From fbc0e560876b0d812d7b9b12faffa145a0541af3 Mon Sep 17 00:00:00 2001 From: Lokesh Chandra Date: Tue, 4 Nov 2025 15:41:58 +0530 Subject: [PATCH] feat(express): migrated prebuildAndSignTrans as type route Ticket: WP-5428 --- modules/express/src/clientRoutes.ts | 16 +- modules/express/src/typedRoutes/api/index.ts | 10 +- .../api/v2/prebuildAndSignTransaction.ts | 523 ++++++++++++ .../unit/clientRoutes/prebuildAndSignTx.ts | 8 +- .../typedRoutes/prebuildAndSignTransaction.ts | 765 ++++++++++++++++++ 5 files changed, 1307 insertions(+), 15 deletions(-) create mode 100644 modules/express/src/typedRoutes/api/v2/prebuildAndSignTransaction.ts create mode 100644 modules/express/test/unit/typedRoutes/prebuildAndSignTransaction.ts diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index 698538d946..eb6295322c 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -968,11 +968,13 @@ async function handleV2SendMany(req: express.Request) { * - send/broadcast transaction * @param req where req.body is {@link PrebuildAndSignTransactionOptions} */ -export async function handleV2PrebuildAndSignTransaction(req: express.Request): Promise { +export async function handleV2PrebuildAndSignTransaction( + req: ExpressApiRouteRequest<'express.v2.wallet.prebuildandsigntransaction', 'post'> +): Promise { const bitgo = req.bitgo; - const coin = bitgo.coin(req.params.coin); + const coin = bitgo.coin(req.decoded.coin); const reqId = new RequestTracer(); - const wallet = await coin.wallets().get({ id: req.params.id, reqId }); + const wallet = await coin.wallets().get({ id: req.decoded.id, reqId }); req.body.reqId = reqId; let result; try { @@ -1640,12 +1642,10 @@ export function setupAPIRoutes(app: express.Application, config: Config): void { // send transaction app.post('/api/v2/:coin/wallet/:id/sendcoins', parseBody, prepareBitGo(config), promiseWrapper(handleV2SendOne)); app.post('/api/v2/:coin/wallet/:id/sendmany', parseBody, prepareBitGo(config), promiseWrapper(handleV2SendMany)); - app.post( - '/api/v2/:coin/wallet/:id/prebuildAndSignTransaction', - parseBody, + router.post('express.v2.wallet.prebuildandsigntransaction', [ prepareBitGo(config), - promiseWrapper(handleV2PrebuildAndSignTransaction) - ); + typedPromiseWrapper(handleV2PrebuildAndSignTransaction), + ]); // token enablement app.post( diff --git a/modules/express/src/typedRoutes/api/index.ts b/modules/express/src/typedRoutes/api/index.ts index 444b29e610..21aae5ecaf 100644 --- a/modules/express/src/typedRoutes/api/index.ts +++ b/modules/express/src/typedRoutes/api/index.ts @@ -35,6 +35,7 @@ import { PostShareWallet } from './v2/shareWallet'; import { PutExpressWalletUpdate } from './v2/expressWalletUpdate'; import { PostFanoutUnspents } from './v2/fanoutUnspents'; import { PostConsolidateUnspents } from './v2/consolidateunspents'; +import { PostPrebuildAndSignTransaction } from './v2/prebuildAndSignTransaction'; import { PostCoinSign } from './v2/coinSign'; // Too large types can cause the following error @@ -109,10 +110,13 @@ export const ExpressV1PendingApprovalsApiSpec = apiSpec({ }, }); -export const ExpressV1WalletSignTransactionApiSpec = apiSpec({ +export const ExpressWalletSignTransactionApiSpec = apiSpec({ 'express.v1.wallet.signTransaction': { post: PostSignTransaction, }, + 'express.v2.wallet.prebuildandsigntransaction': { + post: PostPrebuildAndSignTransaction, + }, }); export const ExpressV1KeychainDeriveApiSpec = apiSpec({ @@ -240,7 +244,7 @@ export type ExpressApi = typeof ExpressPingApiSpec & typeof ExpressV1WalletAcceptShareApiSpec & typeof ExpressV1WalletSimpleCreateApiSpec & typeof ExpressV1PendingApprovalsApiSpec & - typeof ExpressV1WalletSignTransactionApiSpec & + typeof ExpressWalletSignTransactionApiSpec & typeof ExpressV1KeychainDeriveApiSpec & typeof ExpressV1KeychainLocalApiSpec & typeof ExpressV1PendingApprovalConstructTxApiSpec & @@ -270,7 +274,7 @@ export const ExpressApi: ExpressApi = { ...ExpressV1WalletAcceptShareApiSpec, ...ExpressV1WalletSimpleCreateApiSpec, ...ExpressV1PendingApprovalsApiSpec, - ...ExpressV1WalletSignTransactionApiSpec, + ...ExpressWalletSignTransactionApiSpec, ...ExpressV1KeychainDeriveApiSpec, ...ExpressV1KeychainLocalApiSpec, ...ExpressV1PendingApprovalConstructTxApiSpec, diff --git a/modules/express/src/typedRoutes/api/v2/prebuildAndSignTransaction.ts b/modules/express/src/typedRoutes/api/v2/prebuildAndSignTransaction.ts new file mode 100644 index 0000000000..295dc0cd1d --- /dev/null +++ b/modules/express/src/typedRoutes/api/v2/prebuildAndSignTransaction.ts @@ -0,0 +1,523 @@ +import * as t from 'io-ts'; +import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http'; +import { TransactionRequest as TxRequestResponse } from '@bitgo/public-types'; +import { BitgoExpressError } from '../../schemas/error'; + +/** + * Request parameters for prebuild and sign transaction + */ +export const PrebuildAndSignTransactionParams = { + /** The coin type */ + coin: t.string, + /** The wallet ID */ + id: t.string, +} as const; + +/** + * EIP1559 transaction parameters for Ethereum + */ +export const EIP1559 = t.partial({ + /** Maximum fee per gas */ + maxFeePerGas: t.union([t.string, t.number]), + /** Maximum priority fee per gas */ + maxPriorityFeePerGas: t.union([t.string, t.number]), +}); + +/** + * Token enablement configuration + */ +export const TokenEnablement = t.partial({ + /** Token name */ + name: t.string, + /** Token address */ + address: t.string, +}); + +/** + * Memo information for transactions (e.g., Stellar, EOS) + */ +export const Memo = t.partial({ + /** Memo value */ + value: t.string, + /** Memo type */ + type: t.string, +}); + +/** + * Recipient information for transactions + */ +export const Recipient = t.partial({ + /** Recipient address */ + address: t.string, + /** Amount to send */ + amount: t.union([t.string, t.number]), + /** Token name for token transfers */ + tokenName: t.string, + /** Token-specific data */ + tokenData: t.any, +}); + +/** + * Message to sign + */ +export const MessageToSign = t.partial({ + /** Address */ + address: t.string, + /** Message to sign */ + message: t.string, +}); + +/** + * Reservation information for transactions + */ +export const Reservation = t.partial({ + /** Expiration time */ + expireTime: t.string, + /** Pending approval ID */ + pendingApprovalId: t.string, +}); + +/** + * Solana instruction account + */ +export const SolInstructionKey = t.partial({ + /** Account public key */ + pubkey: t.string, + /** Whether account is a signer */ + isSigner: t.boolean, + /** Whether account is writable */ + isWritable: t.boolean, +}); + +/** + * Solana custom instruction + */ +export const SolInstruction = t.partial({ + /** Program ID */ + programId: t.string, + /** Account keys */ + keys: t.array(SolInstructionKey), + /** Instruction data */ + data: t.string, +}); + +/** + * Solana versioned transaction address lookup table + */ +export const SolAddressLookupTable = t.partial({ + /** Account key */ + accountKey: t.string, + /** Writable indexes */ + writableIndexes: t.array(t.number), + /** Readonly indexes */ + readonlyIndexes: t.array(t.number), +}); + +/** + * Solana versioned transaction message header + */ +export const SolMessageHeader = t.partial({ + /** Number of required signatures */ + numRequiredSignatures: t.number, + /** Number of readonly signed accounts */ + numReadonlySignedAccounts: t.number, + /** Number of readonly unsigned accounts */ + numReadonlyUnsignedAccounts: t.number, +}); + +/** + * Solana versioned instruction + */ +export const SolVersionedInstruction = t.partial({ + /** Program ID index */ + programIdIndex: t.number, + /** Account key indexes */ + accountKeyIndexes: t.array(t.number), + /** Instruction data */ + data: t.string, +}); + +/** + * Solana versioned transaction data + */ +export const SolVersionedTransactionData = t.partial({ + /** Versioned instructions */ + versionedInstructions: t.array(SolVersionedInstruction), + /** Address lookup tables */ + addressLookupTables: t.array(SolAddressLookupTable), + /** Static account keys */ + staticAccountKeys: t.array(t.string), + /** Message header */ + messageHeader: SolMessageHeader, + /** Recent blockhash */ + recentBlockhash: t.string, +}); + +/** + * Aptos custom transaction parameters + */ +export const AptosCustomTransactionParams = t.partial({ + /** Module name */ + moduleName: t.string, + /** Function name */ + functionName: t.string, + /** Type arguments */ + typeArguments: t.array(t.string), + /** Function arguments */ + functionArguments: t.array(t.any), + /** ABI definition */ + abi: t.any, +}); + +/** + * Keychain structure for verification + */ +export const KeychainForVerification = t.partial({ + /** User keychain public key */ + pub: t.string, + /** Keychain ID */ + id: t.string, +}); + +/** + * Keychains for verification + */ +export const KeychainsForVerification = t.partial({ + /** User keychain */ + user: KeychainForVerification, + /** Backup keychain */ + backup: KeychainForVerification, + /** BitGo keychain */ + bitgo: KeychainForVerification, +}); + +/** + * Address verification data + */ +export const AddressVerificationData = t.partial({ + /** Chain index */ + chain: t.number, + /** Address index */ + index: t.number, + /** Coin-specific address data */ + coinSpecific: t.any, +}); + +/** + * Fee information + */ +export const FeeInfo = t.partial({ + /** Fee amount */ + fee: t.number, + /** Fee as string */ + feeString: t.string, +}); + +/** + * Consolidation details + */ +export const ConsolidationDetails = t.partial({ + /** Sender address index */ + senderAddressIndex: t.number, +}); + +/** + * Transaction prebuild result (for when transaction is already prebuilt) + * Extends TransactionPrebuild with additional fields + */ +export const TransactionPrebuildResult = t.partial({ + // From TransactionPrebuild + /** Transaction hex */ + txHex: t.string, + /** Transaction base64 */ + txBase64: t.string, + /** Transaction info */ + txInfo: t.any, + /** Transaction request ID */ + txRequestId: t.string, + /** Wallet ID */ + walletId: t.string, + /** Consolidate ID */ + consolidateId: t.string, + /** Consolidation details */ + consolidationDetails: ConsolidationDetails, + /** Fee information */ + feeInfo: FeeInfo, + /** Pending approval ID */ + pendingApprovalId: t.string, + /** Payload string */ + payload: t.string, +}); + +/** + * Verification options for transaction verification + */ +export const VerificationOptions = t.partial({ + /** Disable networking for verification */ + disableNetworking: t.boolean, + /** Keychains for verification */ + keychains: KeychainsForVerification, + /** Addresses to verify */ + addresses: t.record(t.string, AddressVerificationData), + /** Allow paygo output */ + allowPaygoOutput: t.boolean, + /** Consider migrated-from address as internal */ + considerMigratedFromAddressInternal: t.boolean, + /** Verify token enablement */ + verifyTokenEnablement: t.boolean, + /** Verify consolidation to base address */ + consolidationToBaseAddress: t.boolean, +}); + +/** + * Request body for prebuild and sign transaction + * Combines all fields from PrebuildAndSignTransactionOptions interface + */ +export const PrebuildAndSignTransactionBody = { + // === Core PrebuildTransactionOptions fields === + /** Recipients of the transaction */ + recipients: optional(t.array(Recipient)), + /** Number of blocks to use for fee estimation */ + numBlocks: optional(t.number), + /** Maximum fee rate */ + maxFeeRate: optional(t.number), + /** Minimum confirmations */ + minConfirms: optional(t.number), + /** Enforce minimum confirms for change */ + enforceMinConfirmsForChange: optional(t.boolean), + /** Target wallet unspents */ + targetWalletUnspents: optional(t.number), + /** Minimum value */ + minValue: optional(t.union([t.number, t.string])), + /** Maximum value */ + maxValue: optional(t.union([t.number, t.string])), + /** Sequence ID */ + sequenceId: optional(t.string), + /** Last ledger sequence (XRP) */ + lastLedgerSequence: optional(t.number), + /** Ledger sequence delta (XRP) */ + ledgerSequenceDelta: optional(t.number), + /** Gas price */ + gasPrice: optional(t.number), + /** Do not split change */ + noSplitChange: optional(t.boolean), + /** Unspents to use */ + unspents: optional(t.array(t.any)), + /** Sender address (for specific coins like ADA) */ + senderAddress: optional(t.string), + /** Sender wallet ID (for BTC unstaking) */ + senderWalletId: optional(t.string), + /** Messages to sign */ + messages: optional(t.array(MessageToSign)), + /** Change address */ + changeAddress: optional(t.string), + /** Allow external change address */ + allowExternalChangeAddress: optional(t.boolean), + /** Transaction type */ + type: optional(t.string), + /** Close remainder to address (Algorand) */ + closeRemainderTo: optional(t.string), + /** Non-participation flag (Algorand) */ + nonParticipation: optional(t.boolean), + /** Valid from block */ + validFromBlock: optional(t.number), + /** Valid to block */ + validToBlock: optional(t.number), + /** Instant send */ + instant: optional(t.boolean), + /** Memo */ + memo: optional(Memo), + /** Address type */ + addressType: optional(t.string), + /** Change address type */ + changeAddressType: optional(t.string), + /** Use hop transaction (ETH) */ + hop: optional(t.boolean), + /** Wallet passphrase for signing */ + walletPassphrase: optional(t.string), + /** Reservation information */ + reservation: optional(Reservation), + /** Offline verification */ + offlineVerification: optional(t.boolean), + /** Wallet contract address */ + walletContractAddress: optional(t.string), + /** IDF signed timestamp */ + idfSignedTimestamp: optional(t.string), + /** IDF user ID */ + idfUserId: optional(t.string), + /** IDF version */ + idfVersion: optional(t.number), + /** Comment */ + comment: optional(t.string), + /** Token name for token transfers */ + tokenName: optional(t.string), + /** NFT collection ID */ + nftCollectionId: optional(t.string), + /** NFT ID */ + nftId: optional(t.string), + /** Tokens to enable */ + enableTokens: optional(t.array(TokenEnablement)), + /** Nonce */ + nonce: optional(t.string), + /** Preview transaction */ + preview: optional(t.boolean), + /** EIP1559 parameters (ETH) */ + eip1559: optional(EIP1559), + /** Gas limit */ + gasLimit: optional(t.number), + /** Low fee transaction ID for CPFP */ + lowFeeTxid: optional(t.string), + /** Receive address */ + receiveAddress: optional(t.string), + /** Is TSS transaction */ + isTss: optional(t.boolean), + /** Custodian transaction ID */ + custodianTransactionId: optional(t.string), + /** API version ('lite' or 'full') */ + apiVersion: optional(t.union([t.literal('lite'), t.literal('full')])), + /** Keep alive (for coins with minimum balance) */ + keepAlive: optional(t.boolean), + /** Transaction format ('legacy', 'psbt', or 'psbt-lite') */ + txFormat: optional(t.union([t.literal('legacy'), t.literal('psbt'), t.literal('psbt-lite')])), + /** Solana custom instructions */ + solInstructions: optional(t.array(SolInstruction)), + /** Solana versioned transaction data */ + solVersionedTransactionData: optional(SolVersionedTransactionData), + /** Aptos custom transaction parameters */ + aptosCustomTransactionParams: optional(AptosCustomTransactionParams), + /** Transaction request ID */ + txRequestId: optional(t.string), + /** Whether this is the last signature */ + isLastSignature: optional(t.boolean), + /** Multisig type version */ + multisigTypeVersion: optional(t.literal('MPCv2')), + /** Transaction prebuild data */ + txPrebuild: optional(TransactionPrebuildResult), + /** Public keys for signing */ + pubs: optional(t.array(t.string)), + /** Cosigner public key */ + cosignerPub: optional(t.string), + /** Transaction verification parameters */ + verifyTxParams: optional( + t.partial({ + /** Transaction parameters */ + txParams: t.partial({ + /** Recipients */ + recipients: t.array(Recipient), + /** Wallet passphrase */ + walletPassphrase: t.string, + /** Transaction type */ + type: t.string, + /** Memo */ + memo: Memo, + /** Tokens to enable */ + enableTokens: t.array(TokenEnablement), + }), + /** Verification options */ + verification: VerificationOptions, + }) + ), + /** Pre-built transaction (string or object) - alternative to txPrebuild */ + prebuildTx: optional(t.union([t.string, TransactionPrebuildResult])), + /** Verification options */ + verification: optional(VerificationOptions), +} as const; + +/** + * Response for a fully signed transaction + */ +export const FullySignedTransactionResponse = t.type({ + /** Transaction in hex format */ + txHex: t.string, +}); + +/** + * Response for a half-signed account transaction + */ +export const HalfSignedAccountTransactionResponse = t.partial({ + /** Half signed data */ + halfSigned: t.partial({ + /** Transaction hex */ + txHex: t.string, + /** Payload */ + payload: t.string, + /** Transaction base64 */ + txBase64: t.string, + }), +}); + +/** + * Response for a half-signed UTXO transaction + */ +export const HalfSignedUtxoTransactionResponse = t.type({ + /** Transaction in hex format */ + txHex: t.string, +}); + +/** + * Response for a transaction request + */ +export const SignedTransactionRequestResponse = t.type({ + /** Transaction request ID */ + txRequestId: t.string, +}); + +/** + * Combined response type for prebuild and sign transaction + * Returns one of several possible response types depending on wallet type and signing flow + */ +export const PrebuildAndSignTransactionResponse = t.union([ + FullySignedTransactionResponse, + HalfSignedAccountTransactionResponse, + HalfSignedUtxoTransactionResponse, + SignedTransactionRequestResponse, + TxRequestResponse, // For TSS wallets +]); + +/** + * Response types for prebuild and sign transaction endpoint + */ +export const PrebuildAndSignTransactionApiResponse = { + /** Successfully prebuilt and signed transaction */ + 200: PrebuildAndSignTransactionResponse, + /** Error response */ + 400: BitgoExpressError, +}; + +/** + * Prebuild and sign a transaction + * + * This endpoint combines transaction building and signing in one atomic operation: + * 1. Builds the transaction using the BitGo Platform API + * 2. Signs with the user key (wallet passphrase required) + * 3. Requests signature from BitGo HSM (second key) + * 4. Returns the signed transaction (ready for broadcast) + * + * The request body accepts fields from: + * - **PrebuildTransactionOptions**: Transaction building parameters (recipients, fees, etc.) + * - **WalletSignTransactionOptions**: Signing configuration (passphrase, etc.) + * - **Additional fields**: prebuildTx (if already prebuilt), verification options + * + * Common use cases: + * - **Simple send**: Provide `recipients` and `walletPassphrase` + * - **Custom fees**: Add `numBlocks`, `gasPrice`, `gasLimit` + * - **Memo transactions**: Include `memo` (XLM, EOS, etc.) + * - **TSS wallets**: Returns transaction request ID for approval flow + * + * Response varies by wallet type: + * - **Hot wallets**: Returns fully signed transaction (`txHex`) + * - **Cold wallets**: Returns half-signed transaction (`halfSigned.txHex`) + * - **TSS wallets**: Returns transaction request (`txRequestId`) + * + * @tag express + * @operationId express.v2.wallet.prebuildandsigntransaction + */ +export const PostPrebuildAndSignTransaction = httpRoute({ + path: '/api/v2/{coin}/wallet/{id}/prebuildAndSignTransaction', + method: 'POST', + request: httpRequest({ + params: PrebuildAndSignTransactionParams, + body: PrebuildAndSignTransactionBody, + }), + response: PrebuildAndSignTransactionApiResponse, +}); diff --git a/modules/express/test/unit/clientRoutes/prebuildAndSignTx.ts b/modules/express/test/unit/clientRoutes/prebuildAndSignTx.ts index af28ca5ff5..4f77b993c8 100644 --- a/modules/express/test/unit/clientRoutes/prebuildAndSignTx.ts +++ b/modules/express/test/unit/clientRoutes/prebuildAndSignTx.ts @@ -4,8 +4,8 @@ import 'should-http'; import 'should-sinon'; import '../../lib/asserts'; -import { Request } from 'express'; import { handleV2PrebuildAndSignTransaction } from '../../../src/clientRoutes'; +import { ExpressApiRouteRequest } from '../../../src/typedRoutes/api'; import { BitGo } from 'bitgo'; @@ -44,13 +44,13 @@ describe('Prebuild and Sign (and Send) transaction', function () { const bitGoStub = sinon.createStubInstance(BitGo as any, { coin: coinStub }); const req = { bitgo: bitGoStub, - params: { + decoded: { coin, id: '632874c8be7b040007104869d2fee228', + ...txParams, }, - query: {}, body: txParams, - } as unknown as Request; + } as unknown as ExpressApiRouteRequest<'express.v2.wallet.prebuildandsigntransaction', 'post'>; await handleV2PrebuildAndSignTransaction(req).should.be.resolvedWith(expectedResponse); }); }); diff --git a/modules/express/test/unit/typedRoutes/prebuildAndSignTransaction.ts b/modules/express/test/unit/typedRoutes/prebuildAndSignTransaction.ts new file mode 100644 index 0000000000..c474d1a7a8 --- /dev/null +++ b/modules/express/test/unit/typedRoutes/prebuildAndSignTransaction.ts @@ -0,0 +1,765 @@ +import * as assert from 'assert'; +import * as t from 'io-ts'; +import { TransactionRequest as TxRequestResponse } from '@bitgo/public-types'; +import { + PrebuildAndSignTransactionParams, + FullySignedTransactionResponse, + HalfSignedAccountTransactionResponse, + SignedTransactionRequestResponse, +} from '../../../src/typedRoutes/api/v2/prebuildAndSignTransaction'; +import { assertDecode } from './common'; +import 'should'; +import 'should-http'; +import 'should-sinon'; +import * as sinon from 'sinon'; +import { BitGo } from 'bitgo'; +import { setupAgent } from '../../lib/testutil'; + +describe('PrebuildAndSignTransaction codec tests', function () { + describe('prebuildAndSignTransaction', function () { + const agent = setupAgent(); + const coin = 'tbtc'; + const walletId = '5a1341e7c8421dc90710673b3166bbd5'; + + const mockFullySignedResponse = { + txHex: + '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + }; + + afterEach(function () { + sinon.restore(); + }); + + it('should successfully prebuild and sign a transaction with basic parameters', async function () { + const requestBody = { + recipients: [ + { + address: '2N9NhCaYwCEYdYwqqW4k2tCrF4s4Lf6pD3H', + amount: 1000000, + }, + ], + walletPassphrase: 'test_wallet_passphrase_12345', + }; + + // Create mock wallet + const mockWallet = { + prebuildAndSignTransaction: sinon.stub().resolves(mockFullySignedResponse), + }; + + // Create mock coin + const mockCoin = { + wallets: () => ({ + get: sinon.stub().resolves(mockWallet), + }), + }; + + // Stub BitGo.prototype.coin to return our mock coin + const coinStub = sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + // Make the request to Express + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/prebuildAndSignTransaction`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Verify the response + assert.strictEqual(result.status, 200); + result.body.should.have.property('txHex'); + assert.strictEqual(result.body.txHex, mockFullySignedResponse.txHex); + + // This ensures the response structure matches the typed definition + const decodedResponse = assertDecode(FullySignedTransactionResponse, result.body); + assert.strictEqual(decodedResponse.txHex, mockFullySignedResponse.txHex); + + // Verify that the correct BitGoJS methods were called + assert.strictEqual(coinStub.calledOnceWith(coin), true); + assert.strictEqual(mockWallet.prebuildAndSignTransaction.calledOnce, true); + }); + + it('should successfully return a half-signed account transaction', async function () { + const requestBody = { + recipients: [ + { + address: '0xe514ee5028934565c3f839429ea3c091efe4c701', + amount: 1000000, + }, + ], + walletPassphrase: 'test_wallet_passphrase_12345', + }; + + const mockHalfSignedResponse = { + halfSigned: { + txHex: + '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + payload: 'signed_payload_data', + txBase64: + 'AQAAAAFz2JT3Xvjk8jKcYcMrKR8tPMRm5+/Q6J2sMgtz7QDpAAAAAAD+////AoCWmAAAAAAAGXapFJA29QPQaHHwR3Uriuhw2A6tHkPgiKwAAAAAAAEBH9cQ2QAAAAAAAXapFCf/zr8zPrMftHGIRsOt0Cf+wdOyiKwA', + }, + }; + + const mockWallet = { + prebuildAndSignTransaction: sinon.stub().resolves(mockHalfSignedResponse), + }; + + const mockCoin = { + wallets: () => ({ + get: sinon.stub().resolves(mockWallet), + }), + }; + + const coinStub = sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/prebuildAndSignTransaction`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Verify the response + assert.strictEqual(result.status, 200); + result.body.should.have.property('halfSigned'); + result.body.halfSigned.should.have.property('txHex'); + assert.strictEqual(result.body.halfSigned.txHex, mockHalfSignedResponse.halfSigned.txHex); + + // This ensures the response structure matches the typed definition + const decodedResponse = assertDecode(HalfSignedAccountTransactionResponse, result.body); + assert.ok(decodedResponse.halfSigned); + assert.strictEqual(decodedResponse.halfSigned?.txHex, mockHalfSignedResponse.halfSigned.txHex); + + // Verify that the correct BitGoJS methods were called + assert.strictEqual(coinStub.calledOnceWith(coin), true); + assert.strictEqual(mockWallet.prebuildAndSignTransaction.calledOnce, true); + }); + + it('should successfully return a transaction request ID', async function () { + const requestBody = { + recipients: [ + { + address: '2N9NhCaYwCEYdYwqqW4k2tCrF4s4Lf6pD3H', + amount: 1000000, + }, + ], + walletPassphrase: 'test_wallet_passphrase_12345', + }; + + const mockTxRequestResponse = { + txRequestId: '5a1341e7c8421dc90710673b3166bbd5', + }; + + const mockWallet = { + prebuildAndSignTransaction: sinon.stub().resolves(mockTxRequestResponse), + }; + + const mockCoin = { + wallets: () => ({ + get: sinon.stub().resolves(mockWallet), + }), + }; + + const coinStub = sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/prebuildAndSignTransaction`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Verify the response + assert.strictEqual(result.status, 200); + result.body.should.have.property('txRequestId'); + assert.strictEqual(result.body.txRequestId, mockTxRequestResponse.txRequestId); + + // This ensures the response structure matches the typed definition + const decodedResponse = assertDecode(SignedTransactionRequestResponse, result.body); + assert.strictEqual(decodedResponse.txRequestId, mockTxRequestResponse.txRequestId); + + // Verify that the correct BitGoJS methods were called + assert.strictEqual(coinStub.calledOnceWith(coin), true); + assert.strictEqual(mockWallet.prebuildAndSignTransaction.calledOnce, true); + }); + + it('should successfully return a TSS transaction request (Full TxRequestResponse)', async function () { + const requestBody = { + recipients: [ + { + address: '2N9NhCaYwCEYdYwqqW4k2tCrF4s4Lf6pD3H', + amount: 1000000, + }, + ], + walletPassphrase: 'test_wallet_passphrase_12345', + isTss: true, + }; + + const mockTxRequestFullResponse = { + txRequestId: '5a1341e7c8421dc90710673b3166bbd5', + walletId: '5a1341e7c8421dc90710673b3166bbd5', + walletType: 'hot', + version: 1, + state: 'pendingApproval', + date: '2023-01-01T00:00:00.000Z', + createdDate: '2023-01-01T00:00:00.000Z', + userId: '5a1341e7c8421dc90710673b3166bbd5', + initiatedBy: '5a1341e7c8421dc90710673b3166bbd5', + updatedBy: '5a1341e7c8421dc90710673b3166bbd5', + intents: [], + enterpriseId: '5a1341e7c8421dc90710673b3166bbd5', + intent: {}, + pendingApprovalId: '5a1341e7c8421dc90710673b3166bbd5', + policiesChecked: true, + signatureShares: [ + { + from: 'user', + to: 'bitgo', + share: 'abc123', + }, + ], + commitmentShares: [ + { + from: 'user', + to: 'bitgo', + share: 'abc123', + type: 'commitment', + }, + ], + txHashes: ['hash1', 'hash2'], + unsignedTxs: [ + { + serializedTxHex: + '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + signableHex: + '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + derivationPath: "m/44'/0'/0'/0/0", + }, + ], + apiVersion: 'lite', + latest: true, + }; + + const mockWallet = { + prebuildAndSignTransaction: sinon.stub().resolves(mockTxRequestFullResponse), + }; + + const mockCoin = { + wallets: () => ({ + get: sinon.stub().resolves(mockWallet), + }), + }; + + const coinStub = sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/prebuildAndSignTransaction`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Verify the response + assert.strictEqual(result.status, 200); + result.body.should.have.property('txRequestId'); + result.body.should.have.property('walletId'); + result.body.should.have.property('version'); + result.body.should.have.property('state'); + result.body.should.have.property('intents'); + result.body.should.have.property('latest'); + assert.strictEqual(result.body.txRequestId, mockTxRequestFullResponse.txRequestId); + assert.strictEqual(result.body.walletId, mockTxRequestFullResponse.walletId); + assert.strictEqual(result.body.version, mockTxRequestFullResponse.version); + assert.strictEqual(result.body.state, mockTxRequestFullResponse.state); + assert.strictEqual(result.body.latest, mockTxRequestFullResponse.latest); + + // Verify TSS-specific fields + result.body.should.have.property('signatureShares'); + result.body.should.have.property('commitmentShares'); + result.body.should.have.property('unsignedTxs'); + result.body.signatureShares.should.be.Array(); + result.body.signatureShares.should.have.length(1); + result.body.commitmentShares.should.be.Array(); + result.body.commitmentShares.should.have.length(1); + result.body.unsignedTxs.should.be.Array(); + result.body.unsignedTxs.should.have.length(1); + + // This ensures the TSS transaction request response structure matches the typed definition + const decodedResponse = assertDecode(TxRequestResponse, result.body); + assert.strictEqual(decodedResponse.txRequestId, mockTxRequestFullResponse.txRequestId); + assert.strictEqual(decodedResponse.walletId, mockTxRequestFullResponse.walletId); + assert.strictEqual(decodedResponse.version, mockTxRequestFullResponse.version); + assert.strictEqual(decodedResponse.state, mockTxRequestFullResponse.state); + assert.strictEqual(decodedResponse.latest, mockTxRequestFullResponse.latest); + + // Verify that the correct BitGoJS methods were called + assert.strictEqual(coinStub.calledOnceWith(coin), true); + assert.strictEqual(mockWallet.prebuildAndSignTransaction.calledOnce, true); + }); + + it('should successfully handle Ethereum transaction with EIP1559 parameters', async function () { + const requestBody = { + recipients: [ + { + address: '0xe514ee5028934565c3f839429ea3c091efe4c701', + amount: '1000000000000000000', + }, + ], + walletPassphrase: 'test_wallet_passphrase_12345', + eip1559: { + maxFeePerGas: '20000000000', + maxPriorityFeePerGas: '10000000000', + }, + gasLimit: 21000, + }; + + const mockWallet = { + prebuildAndSignTransaction: sinon.stub().resolves(mockFullySignedResponse), + }; + + const mockCoin = { + wallets: () => ({ + get: sinon.stub().resolves(mockWallet), + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/eth/wallet/${walletId}/prebuildAndSignTransaction`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + assert.strictEqual(mockWallet.prebuildAndSignTransaction.calledOnce, true); + + // Verify the request included EIP1559 parameters + const callArgs = mockWallet.prebuildAndSignTransaction.firstCall.args[0]; + callArgs.should.have.property('eip1559'); + callArgs.eip1559.should.have.property('maxFeePerGas'); + callArgs.eip1559.should.have.property('maxPriorityFeePerGas'); + }); + + it('should successfully handle transaction with memo (Stellar/EOS)', async function () { + const requestBody = { + recipients: [ + { + address: 'GDSRV7ELPLQFDJWZKNPM3VVZSHQJLD2QKRS3QEVRMPOPNFV2FO5S7ZXZ', + amount: '10000000', + }, + ], + walletPassphrase: 'test_wallet_passphrase_12345', + memo: { + value: 'test_memo_123', + type: 'text', + }, + }; + + const mockWallet = { + prebuildAndSignTransaction: sinon.stub().resolves(mockFullySignedResponse), + }; + + const mockCoin = { + wallets: () => ({ + get: sinon.stub().resolves(mockWallet), + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/xlm/wallet/${walletId}/prebuildAndSignTransaction`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + assert.strictEqual(mockWallet.prebuildAndSignTransaction.calledOnce, true); + + // Verify the request included memo + const callArgs = mockWallet.prebuildAndSignTransaction.firstCall.args[0]; + callArgs.should.have.property('memo'); + callArgs.memo.should.have.property('value', 'test_memo_123'); + callArgs.memo.should.have.property('type', 'text'); + }); + + it('should successfully handle token transfer with enableTokens', async function () { + const requestBody = { + recipients: [ + { + address: '0xe514ee5028934565c3f839429ea3c091efe4c701', + amount: '1000000', + tokenName: 'usdc', + }, + ], + walletPassphrase: 'test_wallet_passphrase_12345', + enableTokens: [ + { + name: 'usdc', + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + }, + ], + }; + + const mockWallet = { + prebuildAndSignTransaction: sinon.stub().resolves(mockFullySignedResponse), + }; + + const mockCoin = { + wallets: () => ({ + get: sinon.stub().resolves(mockWallet), + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/eth/wallet/${walletId}/prebuildAndSignTransaction`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + assert.strictEqual(mockWallet.prebuildAndSignTransaction.calledOnce, true); + + // Verify the request included enableTokens + const callArgs = mockWallet.prebuildAndSignTransaction.firstCall.args[0]; + callArgs.should.have.property('enableTokens'); + callArgs.enableTokens.should.be.Array(); + callArgs.enableTokens.should.have.length(1); + callArgs.enableTokens[0].should.have.property('name', 'usdc'); + }); + + it('should successfully handle transaction with verification options', async function () { + const requestBody = { + recipients: [ + { + address: '2N9NhCaYwCEYdYwqqW4k2tCrF4s4Lf6pD3H', + amount: 1000000, + }, + ], + walletPassphrase: 'test_wallet_passphrase_12345', + verification: { + disableNetworking: true, + allowPaygoOutput: false, + }, + }; + + const mockWallet = { + prebuildAndSignTransaction: sinon.stub().resolves(mockFullySignedResponse), + }; + + const mockCoin = { + wallets: () => ({ + get: sinon.stub().resolves(mockWallet), + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/prebuildAndSignTransaction`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + assert.strictEqual(mockWallet.prebuildAndSignTransaction.calledOnce, true); + + // Verify the request included verification options + const callArgs = mockWallet.prebuildAndSignTransaction.firstCall.args[0]; + callArgs.should.have.property('verification'); + callArgs.verification.should.have.property('disableNetworking', true); + callArgs.verification.should.have.property('allowPaygoOutput', false); + }); + + it('should successfully handle transaction with prebuildTx parameter', async function () { + const requestBody = { + walletPassphrase: 'test_wallet_passphrase_12345', + prebuildTx: { + txHex: + '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + walletId: walletId, + }, + }; + + const mockWallet = { + prebuildAndSignTransaction: sinon.stub().resolves(mockFullySignedResponse), + }; + + const mockCoin = { + wallets: () => ({ + get: sinon.stub().resolves(mockWallet), + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/prebuildAndSignTransaction`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + assert.strictEqual(mockWallet.prebuildAndSignTransaction.calledOnce, true); + + // Verify the request included prebuildTx + const callArgs = mockWallet.prebuildAndSignTransaction.firstCall.args[0]; + callArgs.should.have.property('prebuildTx'); + callArgs.prebuildTx.should.have.property('txHex'); + callArgs.prebuildTx.should.have.property('walletId', walletId); + }); + + describe('Error Cases', function () { + it('should handle invalid coin error', async function () { + const invalidCoin = 'invalid_coin_xyz'; + const requestBody = { + recipients: [ + { + address: '2N9NhCaYwCEYdYwqqW4k2tCrF4s4Lf6pD3H', + amount: 1000000, + }, + ], + walletPassphrase: 'test_wallet_passphrase_12345', + }; + + // Stub coin() to throw error for invalid coin + sinon.stub(BitGo.prototype, 'coin').throws(new Error(`Coin ${invalidCoin} is not supported`)); + + // Make the request to Express + const result = await agent + .post(`/api/v2/${invalidCoin}/wallet/${walletId}/prebuildAndSignTransaction`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Verify error response + assert.strictEqual(result.status, 500); + result.body.should.have.property('error'); + }); + + it('should handle wallet not found error', async function () { + const requestBody = { + recipients: [ + { + address: '2N9NhCaYwCEYdYwqqW4k2tCrF4s4Lf6pD3H', + amount: 1000000, + }, + ], + walletPassphrase: 'test_wallet_passphrase_12345', + }; + + const mockCoin = { + wallets: () => ({ + get: sinon.stub().rejects(new Error('Wallet not found')), + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/invalid_wallet_id/prebuildAndSignTransaction`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Verify error response + assert.strictEqual(result.status, 500); + result.body.should.have.property('error'); + }); + + it('should handle prebuildAndSignTransaction failure', async function () { + const requestBody = { + recipients: [ + { + address: '2N9NhCaYwCEYdYwqqW4k2tCrF4s4Lf6pD3H', + amount: 1000000, + }, + ], + walletPassphrase: 'wrong_passphrase', + }; + + const mockWallet = { + prebuildAndSignTransaction: sinon.stub().rejects(new Error('Invalid wallet passphrase')), + }; + + const mockCoin = { + wallets: () => ({ + get: sinon.stub().resolves(mockWallet), + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/prebuildAndSignTransaction`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Verify error response + assert.strictEqual(result.status, 400); + result.body.should.have.property('error'); + }); + }); + + describe('Invalid Request Body', function () { + it('should accept request with empty body (all fields are optional)', async function () { + const mockWallet = { + prebuildAndSignTransaction: sinon.stub().rejects(new Error('Missing required parameters')), + }; + + const mockCoin = { + wallets: () => ({ + get: sinon.stub().resolves(mockWallet), + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + // Make the request with empty body + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/prebuildAndSignTransaction`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send({}); + + // Should reach the handler (body is valid), but SDK rejects it + assert.strictEqual(result.status, 400); + }); + + it('should reject request with invalid field types', async function () { + const requestBody = { + recipients: [ + { + address: '2N9NhCaYwCEYdYwqqW4k2tCrF4s4Lf6pD3H', + amount: 'invalid_amount', // Should be number or string representing number + }, + ], + walletPassphrase: 12345, // Number instead of string! + numBlocks: 'five', // String instead of number! + }; + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/prebuildAndSignTransaction`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Should fail validation + assert.ok(result.status >= 400); + }); + + it('should handle request with malformed JSON', async function () { + // Make the request with malformed JSON + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/prebuildAndSignTransaction`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send('{ invalid json ]'); + + // Should fail parsing + assert.ok(result.status >= 400); + }); + }); + + describe('Edge Cases', function () { + it('should handle multiple recipients', async function () { + const requestBody = { + recipients: [ + { + address: '2N9NhCaYwCEYdYwqqW4k2tCrF4s4Lf6pD3H', + amount: 1000000, + }, + { + address: '2MsFW8ywUv3xRPZnwHe4gNAkKcjxE4vxUsy', + amount: 2000000, + }, + { + address: '2N1234567890abcdef1234567890abcdef', + amount: 3000000, + }, + ], + walletPassphrase: 'test_wallet_passphrase_12345', + }; + + const mockWallet = { + prebuildAndSignTransaction: sinon.stub().resolves(mockFullySignedResponse), + }; + + const mockCoin = { + wallets: () => ({ + get: sinon.stub().resolves(mockWallet), + }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/prebuildAndSignTransaction`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + assert.strictEqual(mockWallet.prebuildAndSignTransaction.calledOnce, true); + + // Verify all recipients are included + const callArgs = mockWallet.prebuildAndSignTransaction.firstCall.args[0]; + callArgs.should.have.property('recipients'); + callArgs.recipients.should.be.Array(); + callArgs.recipients.should.have.length(3); + }); + }); + }); + + describe('PrebuildAndSignTransactionParams codec validation', function () { + it('should validate params with required coin and id', function () { + const validParams = { + coin: 'btc', + id: '5a1341e7c8421dc90710673b3166bbd5', + }; + + const decoded = assertDecode(t.type(PrebuildAndSignTransactionParams), validParams); + assert.strictEqual(decoded.coin, validParams.coin); + assert.strictEqual(decoded.id, validParams.id); + }); + + it('should reject params with invalid types', function () { + const invalidParams = { + coin: 123, // number instead of string + id: 12345, // number instead of string + }; + + assert.throws(() => { + assertDecode(t.type(PrebuildAndSignTransactionParams), invalidParams); + }); + }); + }); + + describe('Response codec validation', function () { + it('should validate FullySignedTransactionResponse', function () { + const validResponse = { + txHex: + '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + }; + + const decoded = assertDecode(FullySignedTransactionResponse, validResponse); + assert.strictEqual(decoded.txHex, validResponse.txHex); + }); + + it('should validate SignedTransactionRequestResponse', function () { + const validResponse = { + txRequestId: '5a1341e7c8421dc90710673b3166bbd5', + }; + + const decoded = assertDecode(SignedTransactionRequestResponse, validResponse); + assert.strictEqual(decoded.txRequestId, validResponse.txRequestId); + }); + + it('should reject response with invalid structure', function () { + const invalidResponse = {}; + + assert.throws(() => { + assertDecode(FullySignedTransactionResponse, invalidResponse); + }); + }); + }); +});