Skip to content
Merged
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
42 changes: 26 additions & 16 deletions masterBitgoExpress.json
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,16 @@
}
}
},
"202": {
"description": "Accepted",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AsyncJobResponseCodec"
}
}
}
},
"400": {
"description": "Bad Request",
"content": {
Expand Down Expand Up @@ -1933,22 +1943,6 @@
"failed"
]
},
"ConsolidateUnspentsResponseCodec": {
"title": "ConsolidateUnspentsResponseCodec",
"type": "object",
"properties": {
"tx": {
"type": "string"
},
"txid": {
"type": "string"
}
},
"required": [
"tx",
"txid"
]
},
"AsyncJobResponseCodec": {
"title": "AsyncJobResponseCodec",
"type": "object",
Expand All @@ -1968,6 +1962,22 @@
"status"
]
},
"ConsolidateUnspentsResponseCodec": {
"title": "ConsolidateUnspentsResponseCodec",
"type": "object",
"properties": {
"tx": {
"type": "string"
},
"txid": {
"type": "string"
}
},
"required": [
"tx",
"txid"
]
},
"AccelerateResponseCodec": {
"title": "AccelerateResponseCodec",
"type": "object",
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/api/master/accelerate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,7 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/accelerate', () => {
(capturedJobBody.wpSubmitParams as Record<string, unknown>).should.have
.property('cpfpTxIds')
.which.deepEqual(cpfpTxIds);
(capturedJobBody.wpSubmitParams as Record<string, unknown>).should.not.have.property('reqId');

bridgeNock.done();
awmSignNock.isDone().should.be.false();
Expand Down
47 changes: 47 additions & 0 deletions src/__tests__/api/master/asyncJobWorker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,37 @@ function nockAccelerateTxSend(walletId: string, txid: string, cpfpTxId: string)
.reply(200, { txid, status: 'signed' });
}

function makeConsolidateUnspentsSignJob(
overrides: Partial<BridgeJobResponse> = {},
): BridgeJobResponse {
return makeSignJob({
request: {
endpoint: `/api/${COIN}/multisig/sign`,
method: 'POST',
body: {
source: 'user',
pub: 'xpub_user',
txPrebuild: { txHex: '70736274ff' },
walletId: 'test-wallet-id',
wpSubmitKind: 'consolidateUnspents',
wpSubmitParams: { feeRate: 1000, minValue: 1000, txFormat: 'psbt-lite' },
},
},
...overrides,
});
}

function nockConsolidateUnspentsTxSend(walletId: string, txid: string) {
return nock(BITGO_API_URL)
.post(`/api/v2/${COIN}/wallet/${walletId}/tx/send`, (body) => {
body.should.have.property('type', 'consolidate');
body.should.have.property('txHex', 'signed-tx-hex');
return true;
})
.matchHeader('any', () => true)
.reply(200, { txid, status: 'signed' });
}

function nockUpdateSignJobComplete(jobId: string, txid: string) {
return nock(BRIDGE_URL)
.patch(`/job/${jobId}`, (body) => body.status === 'complete' && body.result?.txid === txid)
Expand Down Expand Up @@ -540,6 +571,22 @@ describe('asyncJobWorker', () => {
updateNock.done();
});

it('submits signed consolidateUnspents tx to WP with type consolidate and PATCHes job complete', async () => {
const job = makeConsolidateUnspentsSignJob();
const walletId = 'test-wallet-id';
const txid = 'consolidate-unspents-tx-id';

const walletGetNock = nockWalletGet(walletId);
const sendNock = nockConsolidateUnspentsTxSend(walletId, txid);
const updateNock = nockUpdateSignJobComplete(job.jobId, txid);

await handleMultisigSignOperation(job, bridge, bitgo);

walletGetNock.done();
sendNock.done();
updateNock.done();
});

it('throws when awmResponse is missing', async () => {
const job = makeSignJob({ awmResponse: undefined });

Expand Down
201 changes: 176 additions & 25 deletions src/__tests__/api/master/consolidateUnspents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,14 @@ import nock from 'nock';
import * as utxolib from '@bitgo-beta/utxo-lib';
import { Btc } from '@bitgo-beta/sdk-coin-btc';
import { app as expressApp } from '../../../masterBitGoExpressApp';
import { AppMode, MasterExpressConfig, TlsMode } from '../../../shared/types';
import { Environments, Wallet } from '@bitgo-beta/sdk-core';
import { BitGoAPITestHarness, DEFAULT_ASYNC_MODE_CONFIG } from './testUtils';
import {
ASYNC_TEST_BRIDGE_URL,
BitGoAPITestHarness,
makeMasterExpressTestConfig,
nockAsyncMultisigSignJob,
} from './testUtils';
import assert from 'assert';

const BTC_PREBUILD_PSBT_HEX = utxolib.bitgo
.createPsbtForNetwork({ network: utxolib.networks.bitcoin })
Expand Down Expand Up @@ -51,24 +56,8 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidateunspents', () =
nock.disableNetConnect();
nock.enableNetConnect('127.0.0.1');

const config: MasterExpressConfig = {
appMode: AppMode.MASTER_EXPRESS,
port: 0,
bind: 'localhost',
timeout: 30000,
httpLoggerFile: '',
env: 'test',
disableEnvCheck: true,
authVersion: 2,
advancedWalletManagerUrl: advancedWalletManagerUrl,
awmServerCaCert: 'test-cert',
tlsMode: TlsMode.DISABLED,
clientCertAllowSelfSigned: true,
asyncModeConfig: DEFAULT_ASYNC_MODE_CONFIG,
};

const app = expressApp(config);
agent = request.agent(app);
const config = makeMasterExpressTestConfig(advancedWalletManagerUrl);
agent = request.agent(expressApp(config));
});

afterEach(() => {
Expand Down Expand Up @@ -339,11 +328,9 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidateunspents', () =
.set('Authorization', `Bearer ${accessToken}`)
.send(requestPayload);

response.status.should.equal(500);
response.body.should.have.property('error', 'Internal Server Error');
response.body.should.have.property('name', 'Error');
response.body.should.have.property(
'details',
response.status.should.equal(400);
response.body.error.should.equal('BadRequestError');
response.body.details.should.containEql(
'Expected single consolidation result, but received 2 results',
);
});
Expand Down Expand Up @@ -671,4 +658,168 @@ describe('POST /api/v1/:coin/advancedwallet/:walletId/consolidateunspents', () =
awmSignNock.done();
capturedSignBody.should.not.have.property('walletPubs');
});

it('should return 202 with jobId when async mode is enabled for onchain multisig consolidateUnspents', async () => {
const jobId = 'test-consolidate-unspents-job-id';
const asyncConfig = makeMasterExpressTestConfig(advancedWalletManagerUrl, {
asyncEnabled: true,
});
const asyncAgent = request.agent(expressApp(asyncConfig));

nockWalletAndKeychains();

let capturedBuildBody: Record<string, unknown> | undefined;
const buildNock = nock(bitgoApiUrl)
.post(`/api/v2/${coin}/wallet/${walletId}/consolidateUnspents`, (body) => {
capturedBuildBody = body;
return true;
})
.reply(200, { txHex: BTC_PREBUILD_PSBT_HEX, txInfo: {} });

sinon.stub(Btc.prototype, 'verifyTransaction').resolves(true);

let capturedJobBody: Record<string, unknown> | undefined;
const { bridgeNock, awmSignNock } = nockAsyncMultisigSignJob({
coin,
advancedWalletManagerUrl,
jobId,
captureJobBody: (body) => {
capturedJobBody = body;
},
});

const requestPayload = {
pubkey: mockUserKeychain.pub,
source: 'user' as const,
feeRate: 1000,
maxFeeRate: 2000,
minValue: 1000,
};

const response = await asyncAgent
.post(`/api/v1/${coin}/advancedwallet/${walletId}/consolidateunspents`)
.set('Authorization', `Bearer ${accessToken}`)
.send(requestPayload);

response.status.should.equal(202);
response.body.should.have.property('jobId', jobId);
response.body.should.have.property('status', 'pending');

assert(capturedBuildBody, 'capturedBuildBody is undefined');
capturedBuildBody.should.have.property('feeRate', 1000);
capturedBuildBody.should.have.property('maxFeeRate', 2000);
capturedBuildBody.should.have.property('minValue', 1000);
capturedBuildBody.should.have.property('txFormat', 'psbt-lite');

assert(capturedJobBody, 'capturedJobBody is undefined');
capturedJobBody.should.have.property('wpSubmitKind', 'consolidateUnspents');
(capturedJobBody.wpSubmitParams as Record<string, unknown>).should.have.property(
'feeRate',
1000,
);
(capturedJobBody.wpSubmitParams as Record<string, unknown>).should.have.property(
'txFormat',
'psbt-lite',
);
(capturedJobBody.wpSubmitParams as Record<string, unknown>).should.not.have.property('reqId');
(capturedJobBody.walletPubs as string[]).should.deepEqual([
mockUserKeychain.pub,
mockBackupKeychain.pub,
mockBitgoKeychain.pub,
]);

buildNock.done();
bridgeNock.done();
awmSignNock.isDone().should.be.false();
});

it('should fail when async mode is enabled with bulk consolidateUnspents', async () => {
const asyncConfig = makeMasterExpressTestConfig(advancedWalletManagerUrl, {
asyncEnabled: true,
});
const asyncAgent = request.agent(expressApp(asyncConfig));

nockWalletAndKeychains();

const response = await asyncAgent
.post(`/api/v1/${coin}/advancedwallet/${walletId}/consolidateunspents`)
.set('Authorization', `Bearer ${accessToken}`)
.send({
pubkey: mockUserKeychain.pub,
source: 'user',
feeRate: 1000,
bulk: true,
});

response.status.should.equal(400);
response.body.details.should.containEql('Async mode does not support bulk consolidateUnspents');
});

it('should fail when async consolidateUnspents prebuild returns more than one result', async () => {
const asyncConfig = makeMasterExpressTestConfig(advancedWalletManagerUrl, {
asyncEnabled: true,
});
const asyncAgent = request.agent(expressApp(asyncConfig));

nockWalletAndKeychains();

const buildNock = nock(bitgoApiUrl)
.post(`/api/v2/${coin}/wallet/${walletId}/consolidateUnspents`)
.reply(200, [
{ txHex: BTC_PREBUILD_PSBT_HEX, txInfo: {} },
{ txHex: BTC_PREBUILD_PSBT_HEX, txInfo: {} },
]);

const bridgeNock = nock(ASYNC_TEST_BRIDGE_URL)
.post(`/api/${coin}/multisig/sign`)
.reply(202, { jobId: 'should-not-reach-bridge' });

const response = await asyncAgent
.post(`/api/v1/${coin}/advancedwallet/${walletId}/consolidateunspents`)
.set('Authorization', `Bearer ${accessToken}`)
.send({
pubkey: mockUserKeychain.pub,
source: 'user',
feeRate: 1000,
});

response.status.should.equal(400);
response.body.error.should.equal('BadRequestError');
response.body.details.should.containEql(
'Expected single consolidation result, but received 2 results',
);

buildNock.done();
bridgeNock.isDone().should.be.false();
});

it('should fail when async transaction verification returns false', async () => {
const asyncConfig = makeMasterExpressTestConfig(advancedWalletManagerUrl, {
asyncEnabled: true,
});
const asyncAgent = request.agent(expressApp(asyncConfig));

nockWalletAndKeychains();

const buildNock = nock(bitgoApiUrl)
.post(`/api/v2/${coin}/wallet/${walletId}/consolidateUnspents`)
.reply(200, { txHex: BTC_PREBUILD_PSBT_HEX, txInfo: {} });

const verifyStub = sinon.stub(Btc.prototype, 'verifyTransaction').resolves(false);

const response = await asyncAgent
.post(`/api/v1/${coin}/advancedwallet/${walletId}/consolidateunspents`)
.set('Authorization', `Bearer ${accessToken}`)
.send({
pubkey: mockUserKeychain.pub,
source: 'user',
feeRate: 1000,
});

response.status.should.equal(400);
response.body.details.should.containEql('Transaction prebuild failed local validation');

buildNock.done();
sinon.assert.calledOnce(verifyStub);
});
});
14 changes: 13 additions & 1 deletion src/__tests__/api/master/multisigSignUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,18 @@ describe('multisigSignUtils', () => {
});
});

it('parses consolidateUnspents wpSubmitKind', () => {
parseMultisigSignJobContext({
walletId: 'test-wallet-id',
wpSubmitKind: 'consolidateUnspents',
wpSubmitParams: { feeRate: 1000, minValue: 1000, txFormat: 'psbt-lite' },
}).should.eql({
walletId: 'test-wallet-id',
wpSubmitKind: 'consolidateUnspents',
wpSubmitParams: { feeRate: 1000, minValue: 1000, txFormat: 'psbt-lite' },
});
});

it('throws when wpSubmitKind is missing or unsupported', () => {
(() =>
parseMultisigSignJobContext({
Expand All @@ -221,7 +233,7 @@ describe('multisigSignUtils', () => {
parseMultisigSignJobContext({
walletId: 'test-wallet-id',
wpSubmitKind: 'consolidate',
wpSubmitParams: { recipients: [] },
wpSubmitParams: { feeRate: 1000 },
})).should.throw(/unsupported wpSubmitKind: consolidate/);
});
});
Expand Down
Loading
Loading