From f96dfc7e342710003bf25580597766a92176e947 Mon Sep 17 00:00:00 2001 From: "peterpong@bitgo.com" Date: Wed, 16 Sep 2026 17:53:48 +0000 Subject: [PATCH 1/7] fix(express): support Go Account walletId withdrawals Allow sendcoins requests to use a Go Account walletId in place of an address, and pass that destination through the SDK as a recipient. This fixes the documented Go Account withdrawal flow, which was rejected by Express validation before reaching the backend.\n\nTicket: CAAS-2627 Session-Id: f7d1c24e-f50f-4e09-868c-b37a42b4a817 Task-Id: 8c59a039-fe33-48b3-8450-dd2d6ce009bc --- modules/express/src/clientRoutes.ts | 4 ++ .../src/typedRoutes/api/v2/sendCoins.ts | 7 +++- .../test/unit/typedRoutes/sendCoins.ts | 39 ++++++++++++++++--- modules/sdk-core/src/bitgo/wallet/iWallet.ts | 4 +- modules/sdk-core/src/bitgo/wallet/wallet.ts | 21 +++++++--- .../bitgo/wallet/ofcWalletSignTransaction.ts | 13 +++++++ 6 files changed, 75 insertions(+), 13 deletions(-) diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index bdcad288ab..7af98c8b48 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -1104,6 +1104,10 @@ function createTSSSendParams(req: express.Request, wallet: Wallet) { * @param req */ async function handleV2SendOne(req: ExpressApiRouteRequest<'express.wallet.sendcoins', 'post'>) { + if (req.decoded.address === undefined && req.decoded.walletId === undefined) { + throw new ApiResponseError('Missing required field: address or walletId', 400); + } + const bitgo = req.bitgo; const coin = bitgo.coin(req.decoded.coin); const reqId = new RequestTracer(); diff --git a/modules/express/src/typedRoutes/api/v2/sendCoins.ts b/modules/express/src/typedRoutes/api/v2/sendCoins.ts index 9afc27c05b..1cef743aa7 100644 --- a/modules/express/src/typedRoutes/api/v2/sendCoins.ts +++ b/modules/express/src/typedRoutes/api/v2/sendCoins.ts @@ -24,8 +24,11 @@ export const SendCoinsRequestParams = { * and calls wallet.sendMany(), so the response structure is identical. */ export const SendCoinsRequestBody = { - /** Destination address (length ≤ 500) */ - address: t.string, + /** Destination address (length ≤ 500), unless walletId is provided */ + address: optional(t.string), + + /** Go Account wallet ID destination, instead of address */ + walletId: optional(t.string), /** Amount in base units (e.g. satoshi, wei, drops, stroops). For doge, only string is allowed. */ amount: t.union([t.number, t.string]), diff --git a/modules/express/test/unit/typedRoutes/sendCoins.ts b/modules/express/test/unit/typedRoutes/sendCoins.ts index 1ca6ba6f9a..cdd121581b 100644 --- a/modules/express/test/unit/typedRoutes/sendCoins.ts +++ b/modules/express/test/unit/typedRoutes/sendCoins.ts @@ -103,6 +103,34 @@ describe('SendCoins V2 codec tests', function () { assert.strictEqual(mockWallet.send.calledOnce, true); }); + it('should send to a Go Account walletId without an address', async function () { + const requestBody = { + walletId: 'destination-wallet-id', + amount: '1000000', + walletPassphrase: 'test_passphrase_12345', + }; + + const mockWallet = { + send: sinon.stub().resolves(mockSendResponse), + _wallet: { type: 'trading', multisigType: 'onchain' }, + }; + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockCoin = { + wallets: sinon.stub().returns({ get: walletsGetStub }), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/ofctbtc/wallet/${walletId}/sendcoins`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + assert.deepStrictEqual(mockWallet.send.firstCall.args[0], requestBody); + }); + it('should successfully send with amount as string', async function () { const requestBody = { address: 'mzKTJw3XJNb7VfkFP77mzPJJz4Dkp4M1T6', @@ -918,14 +946,15 @@ describe('SendCoins V2 codec tests', function () { assert.strictEqual(decoded.tokenName, 'terc'); }); - it('should reject body with missing address', function () { - const invalidBody = { + it('should validate body with walletId instead of address', function () { + const validBody = { + walletId: 'destination-wallet-id', amount: 1000000, }; - assert.throws(() => { - assertDecode(t.type(SendCoinsRequestBody), invalidBody); - }); + const decoded = assertDecode(t.type(SendCoinsRequestBody), validBody); + assert.strictEqual(decoded.walletId, validBody.walletId); + assert.strictEqual(decoded.address, undefined); }); it('should reject body with missing amount', function () { diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index 435599da12..5844542151 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -946,6 +946,7 @@ export interface SubmitTransactionOptions { export interface SendOptions { address?: string; + walletId?: string; amount?: number | string; data?: string; feeLimit?: string; @@ -962,7 +963,8 @@ export interface SendOptions { export interface SendManyOptions extends PrebuildAndSignTransactionOptions { reqId?: IRequestTracer; recipients?: { - address: string; + address?: string; + walletId?: string; amount: string | number; feeLimit?: string; data?: string | TokenTransferRecipientParams; diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index 02f0ab5e31..97b5f75aba 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -2964,6 +2964,7 @@ export class Wallet implements IWallet { * Send coins to a recipient * @param params * @param params.address - the destination address + * @param params.walletId - the destination Go Account wallet ID * @param params.amount - the amount in satoshis/wei/base value to be sent * @param params.message - optional message to attach to transaction * @param params.data - [Ethereum Specific] optional data to pass to transaction @@ -2975,14 +2976,14 @@ export class Wallet implements IWallet { * @returns {*} */ async send(params: SendOptions = {}): Promise { - common.validateParams(params, ['address'], ['message', 'data']); + common.validateParams(params, [], ['message', 'data']); if (_.isUndefined(params.amount)) { throw new Error('missing required parameter amount'); } - if (_.isUndefined(params.address)) { - throw new Error('missing required parameter address'); + if (_.isUndefined(params.address) && _.isUndefined(params.walletId)) { + throw new Error('missing required parameter address or walletId'); } const coin = this.baseCoin; @@ -3001,7 +3002,7 @@ export class Wallet implements IWallet { const recipients: SendManyOptions['recipients'] = [ { - address: params.address, + ...(params.address !== undefined ? { address: params.address } : { walletId: params.walletId }), amount: params.amount, }, ]; @@ -3191,7 +3192,17 @@ export class Wallet implements IWallet { // Close ATA is a rent reclaim, not a value transfer. Wallet Platform requires amount '0'. const isCloseAssociatedTokenAccount = params.type === 'closeAssociatedTokenAccount'; params.recipients.forEach(function (recipient) { - coin.checkRecipient(recipient, { allowZeroAmount: isCloseAssociatedTokenAccount }); + if (recipient.address === undefined) { + if (recipient.walletId === undefined) { + throw new Error('missing required parameter address or walletId'); + } + coin.checkRecipient( + { address: recipient.walletId, amount: recipient.amount }, + { allowZeroAmount: isCloseAssociatedTokenAccount } + ); + } else { + coin.checkRecipient(recipient, { allowZeroAmount: isCloseAssociatedTokenAccount }); + } if (isCloseAssociatedTokenAccount && recipient.amount !== '0' && recipient.amount !== 0) { throw new Error("invalid argument for amount - closeAssociatedTokenAccount requires amount '0'"); } diff --git a/modules/sdk-core/test/unit/bitgo/wallet/ofcWalletSignTransaction.ts b/modules/sdk-core/test/unit/bitgo/wallet/ofcWalletSignTransaction.ts index 6ba3a9b3b7..093a9ec82f 100644 --- a/modules/sdk-core/test/unit/bitgo/wallet/ofcWalletSignTransaction.ts +++ b/modules/sdk-core/test/unit/bitgo/wallet/ofcWalletSignTransaction.ts @@ -142,6 +142,19 @@ describe('Wallet - OFC', function () { result.should.deepEqual({ txid: 'test-txid', status: 'signed' }); }); }); + + describe('send', function () { + it('should send to a Go Account wallet ID without an address', async function () { + const sendManyStub = sinon.stub(wallet, 'sendMany').resolves({ txid: 'test-txid' }); + + await wallet.send({ walletId: 'destination-wallet-id', amount: '100' }); + + sendManyStub.calledOnce.should.be.true(); + sendManyStub.firstCall.args[0].recipients.should.deepEqual([ + { walletId: 'destination-wallet-id', amount: '100' }, + ]); + }); + }); }); describe('with userKeySigningRequired: false (remote signing via BitGo key)', function () { From d6a20216b8408cc7f52f8cd606e227cc61252ed2 Mon Sep 17 00:00:00 2001 From: "peterpong@bitgo.com" Date: Wed, 16 Sep 2026 18:03:02 +0000 Subject: [PATCH 2/7] fix(sdk-core): align walletId recipient option types Share the walletId-capable recipient shape with prebuild options so the SDK compiles across dependent packages, and narrow the validation path before checking recipients. This preserves the Go Account destination through all send flows without weakening address validation for malformed requests.\n\nTicket: CAAS-2627 Session-Id: f7d1c24e-f50f-4e09-868c-b37a42b4a817 Task-Id: 8c59a039-fe33-48b3-8450-dd2d6ce009bc --- modules/sdk-core/src/bitgo/wallet/iWallet.ts | 5 ++++- modules/sdk-core/src/bitgo/wallet/wallet.ts | 8 ++++++-- .../test/unit/bitgo/wallet/ofcWalletSignTransaction.ts | 2 +- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index 5844542151..6105eea3ba 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -149,8 +149,11 @@ export interface BridgingParams { export interface PrebuildTransactionOptions { reqId?: IRequestTracer; recipients?: { - address: string; + address?: string; + walletId?: string; amount: string | number; + feeLimit?: string; + data?: string | TokenTransferRecipientParams; tokenName?: string; tokenData?: TokenTransferRecipientParams; }[]; diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index 97b5f75aba..b65b0eab2f 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -3192,7 +3192,8 @@ export class Wallet implements IWallet { // Close ATA is a rent reclaim, not a value transfer. Wallet Platform requires amount '0'. const isCloseAssociatedTokenAccount = params.type === 'closeAssociatedTokenAccount'; params.recipients.forEach(function (recipient) { - if (recipient.address === undefined) { + const address = recipient.address; + if (address === undefined) { if (recipient.walletId === undefined) { throw new Error('missing required parameter address or walletId'); } @@ -3201,7 +3202,10 @@ export class Wallet implements IWallet { { allowZeroAmount: isCloseAssociatedTokenAccount } ); } else { - coin.checkRecipient(recipient, { allowZeroAmount: isCloseAssociatedTokenAccount }); + coin.checkRecipient( + { address, amount: recipient.amount }, + { allowZeroAmount: isCloseAssociatedTokenAccount } + ); } if (isCloseAssociatedTokenAccount && recipient.amount !== '0' && recipient.amount !== 0) { throw new Error("invalid argument for amount - closeAssociatedTokenAccount requires amount '0'"); diff --git a/modules/sdk-core/test/unit/bitgo/wallet/ofcWalletSignTransaction.ts b/modules/sdk-core/test/unit/bitgo/wallet/ofcWalletSignTransaction.ts index 093a9ec82f..75aa0580eb 100644 --- a/modules/sdk-core/test/unit/bitgo/wallet/ofcWalletSignTransaction.ts +++ b/modules/sdk-core/test/unit/bitgo/wallet/ofcWalletSignTransaction.ts @@ -150,7 +150,7 @@ describe('Wallet - OFC', function () { await wallet.send({ walletId: 'destination-wallet-id', amount: '100' }); sendManyStub.calledOnce.should.be.true(); - sendManyStub.firstCall.args[0].recipients.should.deepEqual([ + sendManyStub.firstCall.args[0].recipients!.should.deepEqual([ { walletId: 'destination-wallet-id', amount: '100' }, ]); }); From 2bec4fe6fe431c502b23f5dddf81ea614c285371 Mon Sep 17 00:00:00 2001 From: "peterpong@bitgo.com" Date: Wed, 16 Sep 2026 18:04:31 +0000 Subject: [PATCH 3/7] fix(sdk-core): preserve prebuild recipient typing Keep walletId support internal to the send-to-sendMany conversion while retaining the existing prebuild recipient contract. This avoids widening address-only APIs and keeps all dependent packages type-safe.\n\nTicket: CAAS-2627 Session-Id: f7d1c24e-f50f-4e09-868c-b37a42b4a817 Task-Id: 8c59a039-fe33-48b3-8450-dd2d6ce009bc --- modules/sdk-core/src/bitgo/wallet/iWallet.ts | 8 ++------ modules/sdk-core/src/bitgo/wallet/wallet.ts | 18 ++---------------- .../bitgo/wallet/ofcWalletSignTransaction.ts | 5 ++--- 3 files changed, 6 insertions(+), 25 deletions(-) diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index 6105eea3ba..d66ec0dd2a 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -149,11 +149,8 @@ export interface BridgingParams { export interface PrebuildTransactionOptions { reqId?: IRequestTracer; recipients?: { - address?: string; - walletId?: string; + address: string; amount: string | number; - feeLimit?: string; - data?: string | TokenTransferRecipientParams; tokenName?: string; tokenData?: TokenTransferRecipientParams; }[]; @@ -966,8 +963,7 @@ export interface SendOptions { export interface SendManyOptions extends PrebuildAndSignTransactionOptions { reqId?: IRequestTracer; recipients?: { - address?: string; - walletId?: string; + address: string; amount: string | number; feeLimit?: string; data?: string | TokenTransferRecipientParams; diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index b65b0eab2f..f24f46b901 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -3005,7 +3005,7 @@ export class Wallet implements IWallet { ...(params.address !== undefined ? { address: params.address } : { walletId: params.walletId }), amount: params.amount, }, - ]; + ] as SendManyOptions['recipients']; if (params.tokenName) { recipients[0].tokenName = params.tokenName; } @@ -3192,21 +3192,7 @@ export class Wallet implements IWallet { // Close ATA is a rent reclaim, not a value transfer. Wallet Platform requires amount '0'. const isCloseAssociatedTokenAccount = params.type === 'closeAssociatedTokenAccount'; params.recipients.forEach(function (recipient) { - const address = recipient.address; - if (address === undefined) { - if (recipient.walletId === undefined) { - throw new Error('missing required parameter address or walletId'); - } - coin.checkRecipient( - { address: recipient.walletId, amount: recipient.amount }, - { allowZeroAmount: isCloseAssociatedTokenAccount } - ); - } else { - coin.checkRecipient( - { address, amount: recipient.amount }, - { allowZeroAmount: isCloseAssociatedTokenAccount } - ); - } + coin.checkRecipient(recipient, { allowZeroAmount: isCloseAssociatedTokenAccount }); if (isCloseAssociatedTokenAccount && recipient.amount !== '0' && recipient.amount !== 0) { throw new Error("invalid argument for amount - closeAssociatedTokenAccount requires amount '0'"); } diff --git a/modules/sdk-core/test/unit/bitgo/wallet/ofcWalletSignTransaction.ts b/modules/sdk-core/test/unit/bitgo/wallet/ofcWalletSignTransaction.ts index 75aa0580eb..fe527b1dac 100644 --- a/modules/sdk-core/test/unit/bitgo/wallet/ofcWalletSignTransaction.ts +++ b/modules/sdk-core/test/unit/bitgo/wallet/ofcWalletSignTransaction.ts @@ -150,9 +150,8 @@ describe('Wallet - OFC', function () { await wallet.send({ walletId: 'destination-wallet-id', amount: '100' }); sendManyStub.calledOnce.should.be.true(); - sendManyStub.firstCall.args[0].recipients!.should.deepEqual([ - { walletId: 'destination-wallet-id', amount: '100' }, - ]); + const callArgs = sendManyStub.firstCall.args[0]!; + callArgs.recipients!.should.deepEqual([{ walletId: 'destination-wallet-id', amount: '100' }]); }); }); }); From 09e4c6331354734ab23df877ef022f3a12ad5a09 Mon Sep 17 00:00:00 2001 From: "peterpong@bitgo.com" Date: Wed, 16 Sep 2026 18:05:04 +0000 Subject: [PATCH 4/7] fix(sdk-core): narrow converted wallet recipients Keep the converted recipient list non-optional after validating the send destination. This resolves the remaining SDK core type errors while preserving walletId-only Go Account withdrawals.\n\nTicket: CAAS-2627 Session-Id: f7d1c24e-f50f-4e09-868c-b37a42b4a817 Task-Id: 8c59a039-fe33-48b3-8450-dd2d6ce009bc --- modules/sdk-core/src/bitgo/wallet/wallet.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index f24f46b901..14eb25b994 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -3000,12 +3000,12 @@ export class Wallet implements IWallet { } }); - const recipients: SendManyOptions['recipients'] = [ + const recipients = [ { ...(params.address !== undefined ? { address: params.address } : { walletId: params.walletId }), amount: params.amount, }, - ] as SendManyOptions['recipients']; + ] as NonNullable; if (params.tokenName) { recipients[0].tokenName = params.tokenName; } From e6c16e78f29d7e43619be8002341dfa4d2b537a8 Mon Sep 17 00:00:00 2001 From: "peterpong@bitgo.com" Date: Wed, 16 Sep 2026 18:13:24 +0000 Subject: [PATCH 5/7] fix(express): require address or walletId destination Model sendcoins destinations as an address-or-walletId union while retaining the existing amount and option validation. This lets Go Account requests omit address without allowing requests with no destination, preserving readable validation errors for malformed withdrawals.\n\nTicket: CAAS-2627 Session-Id: f7d1c24e-f50f-4e09-868c-b37a42b4a817 Task-Id: 8c59a039-fe33-48b3-8450-dd2d6ce009bc --- .../src/typedRoutes/api/v2/sendCoins.ts | 16 +++++++++--- .../test/unit/typedRoutes/sendCoins.ts | 26 +++++++++---------- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/modules/express/src/typedRoutes/api/v2/sendCoins.ts b/modules/express/src/typedRoutes/api/v2/sendCoins.ts index 1cef743aa7..8d0cc69037 100644 --- a/modules/express/src/typedRoutes/api/v2/sendCoins.ts +++ b/modules/express/src/typedRoutes/api/v2/sendCoins.ts @@ -17,13 +17,13 @@ export const SendCoinsRequestParams = { * Request body for sending to a single recipient (v2) * * This endpoint is a convenience wrapper around sendMany that accepts a single - * address and amount instead of a recipients array. It supports the full set of - * parameters available in sendMany. + * address or Go Account wallet ID and amount instead of a recipients array. It + * supports the full set of parameters available in sendMany. * * Internally, wallet.send() converts the address and amount into a recipients array * and calls wallet.sendMany(), so the response structure is identical. */ -export const SendCoinsRequestBody = { +const SendCoinsRequestBodyFields = { /** Destination address (length ≤ 500), unless walletId is provided */ address: optional(t.string), @@ -410,7 +410,15 @@ export const SendCoinsRequestBody = { }), ]) ), -} as const; +}; + +/** + * A send destination must be either an address or a Go Account wallet ID. + */ +export const SendCoinsRequestBody = t.intersection([ + t.type(SendCoinsRequestBodyFields), + t.union([t.type({ address: t.string }), t.type({ walletId: t.string })]), +]); /** * This call allows you to create and send cryptocurrency to a destination address. diff --git a/modules/express/test/unit/typedRoutes/sendCoins.ts b/modules/express/test/unit/typedRoutes/sendCoins.ts index cdd121581b..411eba3375 100644 --- a/modules/express/test/unit/typedRoutes/sendCoins.ts +++ b/modules/express/test/unit/typedRoutes/sendCoins.ts @@ -839,7 +839,7 @@ describe('SendCoins V2 codec tests', function () { amount: 1000000, }; - const decoded = assertDecode(t.type(SendCoinsRequestBody), validBody); + const decoded = assertDecode(SendCoinsRequestBody, validBody); assert.strictEqual(decoded.address, validBody.address); assert.strictEqual(decoded.amount, validBody.amount); }); @@ -850,7 +850,7 @@ describe('SendCoins V2 codec tests', function () { amount: '1000000', }; - const decoded = assertDecode(t.type(SendCoinsRequestBody), validBody); + const decoded = assertDecode(SendCoinsRequestBody, validBody); assert.strictEqual(decoded.address, validBody.address); assert.strictEqual(decoded.amount, '1000000'); }); @@ -866,7 +866,7 @@ describe('SendCoins V2 codec tests', function () { message: 'test message', }; - const decoded = assertDecode(t.type(SendCoinsRequestBody), validBody); + const decoded = assertDecode(SendCoinsRequestBody, validBody); assert.strictEqual(decoded.walletPassphrase, 'test_passphrase'); assert.strictEqual(decoded.feeRate, 50000); assert.strictEqual(decoded.minConfirms, 2); @@ -884,7 +884,7 @@ describe('SendCoins V2 codec tests', function () { }, }; - const decoded = assertDecode(t.type(SendCoinsRequestBody), validBody); + const decoded = assertDecode(SendCoinsRequestBody, validBody); assert.ok(decoded.eip1559); assert.ok('maxPriorityFeePerGas' in decoded.eip1559); assert.ok('maxFeePerGas' in decoded.eip1559); @@ -899,7 +899,7 @@ describe('SendCoins V2 codec tests', function () { eip1559: {}, }; - const decoded = assertDecode(t.type(SendCoinsRequestBody), validBody); + const decoded = assertDecode(SendCoinsRequestBody, validBody); assert.ok(decoded.eip1559); assert.deepStrictEqual(decoded.eip1559, {}); }); @@ -914,7 +914,7 @@ describe('SendCoins V2 codec tests', function () { }; // Partial objects pass schema validation; controller validates and rejects - const decoded = assertDecode(t.type(SendCoinsRequestBody), partialBody); + const decoded = assertDecode(SendCoinsRequestBody, partialBody); assert.ok(decoded.eip1559); assert.strictEqual(decoded.eip1559.maxFeePerGas, 100000000000); }); @@ -929,7 +929,7 @@ describe('SendCoins V2 codec tests', function () { }, }; - const decoded = assertDecode(t.type(SendCoinsRequestBody), validBody); + const decoded = assertDecode(SendCoinsRequestBody, validBody); assert.ok(decoded.memo); assert.strictEqual(decoded.memo.value, 'payment reference 123'); assert.strictEqual(decoded.memo.type, 'text'); @@ -942,7 +942,7 @@ describe('SendCoins V2 codec tests', function () { tokenName: 'terc', }; - const decoded = assertDecode(t.type(SendCoinsRequestBody), validBody); + const decoded = assertDecode(SendCoinsRequestBody, validBody); assert.strictEqual(decoded.tokenName, 'terc'); }); @@ -952,7 +952,7 @@ describe('SendCoins V2 codec tests', function () { amount: 1000000, }; - const decoded = assertDecode(t.type(SendCoinsRequestBody), validBody); + const decoded = assertDecode(SendCoinsRequestBody, validBody); assert.strictEqual(decoded.walletId, validBody.walletId); assert.strictEqual(decoded.address, undefined); }); @@ -963,7 +963,7 @@ describe('SendCoins V2 codec tests', function () { }; assert.throws(() => { - assertDecode(t.type(SendCoinsRequestBody), invalidBody); + assertDecode(SendCoinsRequestBody, invalidBody); }); }); @@ -974,7 +974,7 @@ describe('SendCoins V2 codec tests', function () { }; assert.throws(() => { - assertDecode(t.type(SendCoinsRequestBody), invalidBody); + assertDecode(SendCoinsRequestBody, invalidBody); }); }); @@ -985,7 +985,7 @@ describe('SendCoins V2 codec tests', function () { }; assert.throws(() => { - assertDecode(t.type(SendCoinsRequestBody), invalidBody); + assertDecode(SendCoinsRequestBody, invalidBody); }); }); @@ -1000,7 +1000,7 @@ describe('SendCoins V2 codec tests', function () { }; assert.throws(() => { - assertDecode(t.type(SendCoinsRequestBody), invalidBody); + assertDecode(SendCoinsRequestBody, invalidBody); }); }); }); From 33ff365b9ebc93b071b944ecd5a4dd16cb2d30d3 Mon Sep 17 00:00:00 2001 From: "peterpong@bitgo.com" Date: Wed, 16 Sep 2026 18:14:36 +0000 Subject: [PATCH 6/7] fix(express): retain sendcoins route typing Keep the sendcoins request body as the existing property map while making address optional and adding walletId. The typed route infrastructure requires property maps, so destination presence remains enforced by the handler without breaking generated route types.\n\nTicket: CAAS-2627 Session-Id: f7d1c24e-f50f-4e09-868c-b37a42b4a817 Task-Id: 8c59a039-fe33-48b3-8450-dd2d6ce009bc --- modules/express/src/typedRoutes/api/v2/sendCoins.ts | 12 ++---------- modules/express/test/unit/typedRoutes/sendCoins.ts | 5 ++++- .../test/unit/typedRoutes/validationErrors.ts | 2 +- 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/modules/express/src/typedRoutes/api/v2/sendCoins.ts b/modules/express/src/typedRoutes/api/v2/sendCoins.ts index 8d0cc69037..e78041afec 100644 --- a/modules/express/src/typedRoutes/api/v2/sendCoins.ts +++ b/modules/express/src/typedRoutes/api/v2/sendCoins.ts @@ -23,7 +23,7 @@ export const SendCoinsRequestParams = { * Internally, wallet.send() converts the address and amount into a recipients array * and calls wallet.sendMany(), so the response structure is identical. */ -const SendCoinsRequestBodyFields = { +export const SendCoinsRequestBody = { /** Destination address (length ≤ 500), unless walletId is provided */ address: optional(t.string), @@ -410,15 +410,7 @@ const SendCoinsRequestBodyFields = { }), ]) ), -}; - -/** - * A send destination must be either an address or a Go Account wallet ID. - */ -export const SendCoinsRequestBody = t.intersection([ - t.type(SendCoinsRequestBodyFields), - t.union([t.type({ address: t.string }), t.type({ walletId: t.string })]), -]); +} as const; /** * This call allows you to create and send cryptocurrency to a destination address. diff --git a/modules/express/test/unit/typedRoutes/sendCoins.ts b/modules/express/test/unit/typedRoutes/sendCoins.ts index 411eba3375..c8705ae1ed 100644 --- a/modules/express/test/unit/typedRoutes/sendCoins.ts +++ b/modules/express/test/unit/typedRoutes/sendCoins.ts @@ -128,7 +128,10 @@ describe('SendCoins V2 codec tests', function () { .send(requestBody); assert.strictEqual(result.status, 200); - assert.deepStrictEqual(mockWallet.send.firstCall.args[0], requestBody); + const callArgs = mockWallet.send.firstCall.args[0]; + assert.strictEqual(callArgs.walletId, requestBody.walletId); + assert.strictEqual(callArgs.amount, requestBody.amount); + assert.strictEqual(callArgs.address, undefined); }); it('should successfully send with amount as string', async function () { diff --git a/modules/express/test/unit/typedRoutes/validationErrors.ts b/modules/express/test/unit/typedRoutes/validationErrors.ts index 2942c0e202..40a3af0e41 100644 --- a/modules/express/test/unit/typedRoutes/validationErrors.ts +++ b/modules/express/test/unit/typedRoutes/validationErrors.ts @@ -72,7 +72,7 @@ describe('Validation Error Messages', function () { .send({}); assert.strictEqual(result.status, 400); - assert.ok(result.body.error.includes('address'), 'Error should mention address'); + // Address is optional because walletId is an alternate destination. assert.ok(result.body.error.includes('amount'), 'Error should mention amount'); }); }); From e8c5929cc0e0018b126bd4c5405ca705b9e8bafd Mon Sep 17 00:00:00 2001 From: "peterpong@bitgo.com" Date: Wed, 16 Sep 2026 18:15:19 +0000 Subject: [PATCH 7/7] fix(express): restore sendcoins codec test calls Keep the existing property-map codec test pattern after retaining the typed route's compatible request shape. The regression test still covers walletId-only destinations without introducing unrelated codec type errors.\n\nTicket: CAAS-2627 Session-Id: f7d1c24e-f50f-4e09-868c-b37a42b4a817 Task-Id: 8c59a039-fe33-48b3-8450-dd2d6ce009bc --- .../test/unit/typedRoutes/sendCoins.ts | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/modules/express/test/unit/typedRoutes/sendCoins.ts b/modules/express/test/unit/typedRoutes/sendCoins.ts index c8705ae1ed..f1f8a9fb64 100644 --- a/modules/express/test/unit/typedRoutes/sendCoins.ts +++ b/modules/express/test/unit/typedRoutes/sendCoins.ts @@ -842,7 +842,7 @@ describe('SendCoins V2 codec tests', function () { amount: 1000000, }; - const decoded = assertDecode(SendCoinsRequestBody, validBody); + const decoded = assertDecode(t.type(SendCoinsRequestBody), validBody); assert.strictEqual(decoded.address, validBody.address); assert.strictEqual(decoded.amount, validBody.amount); }); @@ -853,7 +853,7 @@ describe('SendCoins V2 codec tests', function () { amount: '1000000', }; - const decoded = assertDecode(SendCoinsRequestBody, validBody); + const decoded = assertDecode(t.type(SendCoinsRequestBody), validBody); assert.strictEqual(decoded.address, validBody.address); assert.strictEqual(decoded.amount, '1000000'); }); @@ -869,7 +869,7 @@ describe('SendCoins V2 codec tests', function () { message: 'test message', }; - const decoded = assertDecode(SendCoinsRequestBody, validBody); + const decoded = assertDecode(t.type(SendCoinsRequestBody), validBody); assert.strictEqual(decoded.walletPassphrase, 'test_passphrase'); assert.strictEqual(decoded.feeRate, 50000); assert.strictEqual(decoded.minConfirms, 2); @@ -887,7 +887,7 @@ describe('SendCoins V2 codec tests', function () { }, }; - const decoded = assertDecode(SendCoinsRequestBody, validBody); + const decoded = assertDecode(t.type(SendCoinsRequestBody), validBody); assert.ok(decoded.eip1559); assert.ok('maxPriorityFeePerGas' in decoded.eip1559); assert.ok('maxFeePerGas' in decoded.eip1559); @@ -902,7 +902,7 @@ describe('SendCoins V2 codec tests', function () { eip1559: {}, }; - const decoded = assertDecode(SendCoinsRequestBody, validBody); + const decoded = assertDecode(t.type(SendCoinsRequestBody), validBody); assert.ok(decoded.eip1559); assert.deepStrictEqual(decoded.eip1559, {}); }); @@ -917,7 +917,7 @@ describe('SendCoins V2 codec tests', function () { }; // Partial objects pass schema validation; controller validates and rejects - const decoded = assertDecode(SendCoinsRequestBody, partialBody); + const decoded = assertDecode(t.type(SendCoinsRequestBody), partialBody); assert.ok(decoded.eip1559); assert.strictEqual(decoded.eip1559.maxFeePerGas, 100000000000); }); @@ -932,7 +932,7 @@ describe('SendCoins V2 codec tests', function () { }, }; - const decoded = assertDecode(SendCoinsRequestBody, validBody); + const decoded = assertDecode(t.type(SendCoinsRequestBody), validBody); assert.ok(decoded.memo); assert.strictEqual(decoded.memo.value, 'payment reference 123'); assert.strictEqual(decoded.memo.type, 'text'); @@ -945,7 +945,7 @@ describe('SendCoins V2 codec tests', function () { tokenName: 'terc', }; - const decoded = assertDecode(SendCoinsRequestBody, validBody); + const decoded = assertDecode(t.type(SendCoinsRequestBody), validBody); assert.strictEqual(decoded.tokenName, 'terc'); }); @@ -955,7 +955,7 @@ describe('SendCoins V2 codec tests', function () { amount: 1000000, }; - const decoded = assertDecode(SendCoinsRequestBody, validBody); + const decoded = assertDecode(t.type(SendCoinsRequestBody), validBody); assert.strictEqual(decoded.walletId, validBody.walletId); assert.strictEqual(decoded.address, undefined); }); @@ -966,7 +966,7 @@ describe('SendCoins V2 codec tests', function () { }; assert.throws(() => { - assertDecode(SendCoinsRequestBody, invalidBody); + assertDecode(t.type(SendCoinsRequestBody), invalidBody); }); }); @@ -977,7 +977,7 @@ describe('SendCoins V2 codec tests', function () { }; assert.throws(() => { - assertDecode(SendCoinsRequestBody, invalidBody); + assertDecode(t.type(SendCoinsRequestBody), invalidBody); }); }); @@ -988,7 +988,7 @@ describe('SendCoins V2 codec tests', function () { }; assert.throws(() => { - assertDecode(SendCoinsRequestBody, invalidBody); + assertDecode(t.type(SendCoinsRequestBody), invalidBody); }); }); @@ -1003,7 +1003,7 @@ describe('SendCoins V2 codec tests', function () { }; assert.throws(() => { - assertDecode(SendCoinsRequestBody, invalidBody); + assertDecode(t.type(SendCoinsRequestBody), invalidBody); }); }); });