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..e78041afec 100644 --- a/modules/express/src/typedRoutes/api/v2/sendCoins.ts +++ b/modules/express/src/typedRoutes/api/v2/sendCoins.ts @@ -17,15 +17,18 @@ 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 = { - /** 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..f1f8a9fb64 100644 --- a/modules/express/test/unit/typedRoutes/sendCoins.ts +++ b/modules/express/test/unit/typedRoutes/sendCoins.ts @@ -103,6 +103,37 @@ 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); + 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 () { const requestBody = { address: 'mzKTJw3XJNb7VfkFP77mzPJJz4Dkp4M1T6', @@ -918,14 +949,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/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'); }); }); diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index 435599da12..d66ec0dd2a 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; diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index 02f0ab5e31..14eb25b994 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; @@ -2999,12 +3000,12 @@ export class Wallet implements IWallet { } }); - const recipients: SendManyOptions['recipients'] = [ + const recipients = [ { - address: params.address, + ...(params.address !== undefined ? { address: params.address } : { walletId: params.walletId }), amount: params.amount, }, - ]; + ] as NonNullable; if (params.tokenName) { recipients[0].tokenName = params.tokenName; } diff --git a/modules/sdk-core/test/unit/bitgo/wallet/ofcWalletSignTransaction.ts b/modules/sdk-core/test/unit/bitgo/wallet/ofcWalletSignTransaction.ts index 6ba3a9b3b7..fe527b1dac 100644 --- a/modules/sdk-core/test/unit/bitgo/wallet/ofcWalletSignTransaction.ts +++ b/modules/sdk-core/test/unit/bitgo/wallet/ofcWalletSignTransaction.ts @@ -142,6 +142,18 @@ 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(); + const callArgs = sendManyStub.firstCall.args[0]!; + callArgs.recipients!.should.deepEqual([{ walletId: 'destination-wallet-id', amount: '100' }]); + }); + }); }); describe('with userKeySigningRequired: false (remote signing via BitGo key)', function () {