Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions modules/express/src/clientRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
11 changes: 7 additions & 4 deletions modules/express/src/typedRoutes/api/v2/sendCoins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]),
Expand Down
42 changes: 37 additions & 5 deletions modules/express/test/unit/typedRoutes/sendCoins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 () {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
Expand Down
1 change: 1 addition & 0 deletions modules/sdk-core/src/bitgo/wallet/iWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -946,6 +946,7 @@ export interface SubmitTransactionOptions {

export interface SendOptions {
address?: string;
walletId?: string;
amount?: number | string;
data?: string;
feeLimit?: string;
Expand Down
13 changes: 7 additions & 6 deletions modules/sdk-core/src/bitgo/wallet/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -2975,14 +2976,14 @@ export class Wallet implements IWallet {
* @returns {*}
*/
async send(params: SendOptions = {}): Promise<any> {
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;
Expand All @@ -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<SendManyOptions['recipients']>;
if (params.tokenName) {
recipients[0].tokenName = params.tokenName;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down
Loading