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
10 changes: 10 additions & 0 deletions masterBitgoExpress.json
Original file line number Diff line number Diff line change
Expand Up @@ -797,6 +797,16 @@
}
}
},
"202": {
"description": "Accepted",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AsyncJobResponseCodec"
}
}
}
},
"400": {
"description": "Bad Request",
"content": {
Expand Down
119 changes: 118 additions & 1 deletion src/__tests__/api/master/asyncJobWorker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
startAsyncJobWorker,
processPendingJobs,
handleKeyGenerationOperation,
handleMultisigSignOperation,
} from '../../../masterBitgoExpress/workers/asyncJobWorker';
import { AppMode, MasterExpressConfig, TlsMode } from '../../../shared/types';
import { DEFAULT_ASYNC_MODE_CONFIG } from './testUtils';
Expand Down Expand Up @@ -138,6 +139,61 @@ function nockUpdateJobComplete(jobId: string, walletId: string) {
.reply(204);
}

function makeSignJob(overrides: Partial<BridgeJobResponse> = {}): BridgeJobResponse {
return {
jobId: 'job-sign-123',
status: 'awaiting_bitgo',
version: 1,
coin: COIN,
operationType: 'multisig_sign',
awmResponse: awmOk({ txHex: 'signed-tx-hex' }),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: you can update the fixture here to use the halfSigned shape from sendMany.test.ts

this i think:

 halfSigned: {
            txHex: 'signed-tx-hex',
            txInfo: {
              nP2SHInputs: 1,
              nSegwitInputs: 0,
              nOutputs: 2,
            },
          },
          walletId: 'test-wallet-id',
          source: 'user',
          pub: 'xpub_user',
        });

request: {
endpoint: `/api/${COIN}/multisig/sign`,
method: 'POST',
body: {
source: 'user',
pub: 'xpub_user',
txPrebuild: { txHex: '70736274ff' },
walletId: 'test-wallet-id',
wpSubmitKind: 'sendMany',
wpSubmitParams: {
recipients: [{ address: 'tb1qtest1', amount: '100000' }],
source: 'user',
},
},
},
createdAt: 1717977600,
updatedAt: 1717977600,
ttl: 3600,
...overrides,
};
}

function nockWalletGet(walletId: string) {
return nock(BITGO_API_URL)
.get(`/api/v2/${COIN}/wallet/${walletId}`)
.matchHeader('any', () => true)
.reply(200, {
id: walletId,
type: 'advanced',
keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'],
multisigType: 'onchain',
});
}

function nockTxSend(walletId: string, txid: string) {
return nock(BITGO_API_URL)
.post(`/api/v2/${COIN}/wallet/${walletId}/tx/send`)
.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)
.reply(204);
}

describe('asyncJobWorker', () => {
let bitgo: BitGoAPI;
let bridge: OsoBridgeClient;
Expand Down Expand Up @@ -287,7 +343,7 @@ describe('asyncJobWorker', () => {
});

it('skips jobs with unknown operationType', async () => {
const job = makeJob({ operationType: 'multisig_sign' });
const job = makeJob({ operationType: 'mpc_sign' });

const n = nock(BRIDGE_URL)
.get('/jobs')
Expand Down Expand Up @@ -415,4 +471,65 @@ describe('asyncJobWorker', () => {
nock.pendingMocks().should.have.length(0);
});
});

describe('handleMultisigSignOperation()', () => {
it('submits signed tx to WP and PATCHes job complete', async () => {
const job = makeSignJob();
const walletId = 'test-wallet-id';
const txid = 'test-tx-id';

const walletGetNock = nockWalletGet(walletId);
const sendNock = nockTxSend(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 });

await handleMultisigSignOperation(job, bridge, bitgo).should.be.rejected();
});

it('throws when awmResponse.body is not a valid signed transaction', async () => {
const job = makeSignJob({
awmResponse: { status: 200, body: { bad: 'shape' } },
});

await handleMultisigSignOperation(job, bridge, bitgo).should.be.rejectedWith(
/expected txHex or halfSigned/,
);
});

it('throws when request.body is missing walletId', async () => {
const job = makeSignJob({
request: {
endpoint: `/api/${COIN}/multisig/sign`,
method: 'POST',
body: { wpSubmitKind: 'sendMany', wpSubmitParams: { recipients: [] } },
},
});

await handleMultisigSignOperation(job, bridge, bitgo).should.be.rejectedWith(
/missing walletId/,
);
});

it('throws when WP tx submit fails', async () => {
const job = makeSignJob();
const walletId = 'test-wallet-id';

nockWalletGet(walletId);
nock(BITGO_API_URL)
.post(`/api/v2/${COIN}/wallet/${walletId}/tx/send`)
.matchHeader('any', () => true)
.reply(500, { message: 'submit failed' });

await handleMultisigSignOperation(job, bridge, bitgo).should.be.rejected();
});
});
});
216 changes: 216 additions & 0 deletions src/__tests__/api/master/multisigSignUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import 'should';
import assert from 'assert';
import nock from 'nock';
import sinon from 'sinon';
import { Keychain, PrebuildTransactionResult } from '@bitgo-beta/sdk-core';
import {
buildMultisigSignBody,
parseMultisigSignJobContext,
SignedMultisigTransactionSchema,
submitMultisigSignJob,
} from '../../../masterBitgoExpress/handlers/utils/multisigSignUtils';
import { AppMode, KeySource, MasterExpressConfig } from '../../../shared/types';
import { BitGoRequest } from '../../../types/request';
import { DEFAULT_ASYNC_MODE_CONFIG } from './testUtils';
import { OsoBridgeClient } from '../../../masterBitgoExpress/clients/bridgeClient';

describe('multisigSignUtils', () => {
const bridgeUrl = 'http://bridge.invalid';
const coin = 'tbtc';
const txPrebuild = {
txHex: '70736274ff',
txInfo: { nP2SHInputs: 0, nSegwitInputs: 1, nOutputs: 1 },
walletId: 'test-wallet-id',
} as PrebuildTransactionResult;
const userPub =
'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8';

describe('buildMultisigSignBody', () => {
it('builds the AWM multisig sign payload', () => {
const body = buildMultisigSignBody({
source: 'user',
signingKeychain: { pub: userPub, source: 'user' } as Keychain,
txPrebuilt: txPrebuild,
walletPubs: [userPub, 'backup-pub', 'bitgo-pub'],
});

body.should.eql({
source: 'user',
pub: userPub,
txPrebuild,
walletPubs: [userPub, 'backup-pub', 'bitgo-pub'],
});
});

it('omits walletPubs when not provided', () => {
const body = buildMultisigSignBody({
source: 'backup',
signingKeychain: { pub: userPub, source: 'backup' } as Keychain,
txPrebuilt: txPrebuild,
});

body.should.eql({
source: 'backup',
pub: userPub,
txPrebuild,
});
assert(!('walletPubs' in body));
});

it('throws when signing keychain pub is missing', () => {
(() =>
buildMultisigSignBody({
source: 'user',
signingKeychain: { source: 'user' } as Keychain,
txPrebuilt: txPrebuild,
})).should.throw(/Signing keychain pub not found for user/);
});
});

describe('submitMultisigSignJob', () => {
afterEach(() => {
sinon.restore();
nock.cleanAll();
});

function makeAsyncReq(): BitGoRequest<MasterExpressConfig> {
return {
config: {
appMode: AppMode.MASTER_EXPRESS,
asyncModeConfig: {
enabled: true,
awmAsyncUrl: bridgeUrl,
pollIntervalInMs: 30000,
jobTtlInSeconds: 3600,
jobTtlMpcInSeconds: 7200,
},
} as MasterExpressConfig,
bridgeClient: new OsoBridgeClient(bridgeUrl, 60000),
} as unknown as BitGoRequest<MasterExpressConfig>;
}

it('returns null when async mode is disabled', async () => {
const req = {
config: { asyncModeConfig: DEFAULT_ASYNC_MODE_CONFIG },
} as BitGoRequest<MasterExpressConfig>;

const result = await submitMultisigSignJob(
req,
coin,
{
source: 'user',
pub: userPub,
txPrebuild,
},
{
walletId: 'test-wallet-id',
wpSubmitKind: 'sendMany',
wpSubmitParams: { recipients: [] },
},
);

assert.strictEqual(result, null);
});

it('submits multisig_sign to the bridge with correct path and headers', async () => {
const jobId = 'job-123';
const signBody = buildMultisigSignBody({
source: 'user',
signingKeychain: { pub: userPub, source: 'user' } as Keychain,
txPrebuilt: txPrebuild,
});
const jobContext = {
walletId: 'test-wallet-id',
wpSubmitKind: 'sendMany' as const,
wpSubmitParams: { recipients: [{ address: 'tb1qtest', amount: '100000' }] },
};

const bridgeNock = nock(bridgeUrl)
.post(`/api/${coin}/multisig/sign`, (body) => {
body.should.eql({ ...signBody, ...jobContext });
return true;
})
.matchHeader('X-OSO-Source', KeySource.USER)
.matchHeader('X-OSO-Operation', 'multisig_sign')
.reply(202, { jobId });

const result = await submitMultisigSignJob(makeAsyncReq(), coin, signBody, jobContext);
assert(result);
result.should.eql({ jobId, status: 'pending' });
bridgeNock.done();
});
});

describe('SignedMultisigTransactionSchema', () => {
it('accepts a top-level txHex', () => {
SignedMultisigTransactionSchema.parse({ txHex: 'signed-tx-hex' }).should.eql({
txHex: 'signed-tx-hex',
});
});

it('accepts halfSigned.txHex from UTXO user signing', () => {
SignedMultisigTransactionSchema.parse({
halfSigned: { txHex: 'signed-tx-hex' },
source: 'user',
pub: userPub,
}).should.eql({
halfSigned: { txHex: 'signed-tx-hex' },
source: 'user',
pub: userPub,
});
});

it('accepts halfSigned.signature from ETH-style signing', () => {
SignedMultisigTransactionSchema.parse({
halfSigned: {
signature: '0xabc',
operationHash: '0xdef',
recipients: [{ address: '0x123', amount: '1000' }],
},
}).should.eql({
halfSigned: {
signature: '0xabc',
operationHash: '0xdef',
recipients: [{ address: '0x123', amount: '1000' }],
},
});
});

it('rejects bodies missing txHex and halfSigned', () => {
(() => SignedMultisigTransactionSchema.parse({ bad: 'shape' })).should.throw(
/expected txHex or halfSigned/,
);
});
});

describe('parseMultisigSignJobContext', () => {
it('parses walletId, wpSubmitKind, and wpSubmitParams from job body', () => {
parseMultisigSignJobContext({
walletId: 'test-wallet-id',
wpSubmitKind: 'sendMany',
wpSubmitParams: { recipients: [] },
source: 'user',
pub: userPub,
}).should.eql({
walletId: 'test-wallet-id',
wpSubmitKind: 'sendMany',
wpSubmitParams: { recipients: [] },
});
});

it('throws when wpSubmitKind is missing or unsupported', () => {
(() =>
parseMultisigSignJobContext({
walletId: 'test-wallet-id',
wpSubmitParams: { recipients: [] },
})).should.throw(/unsupported wpSubmitKind/);

(() =>
parseMultisigSignJobContext({
walletId: 'test-wallet-id',
wpSubmitKind: 'accelerate',
wpSubmitParams: { recipients: [] },
})).should.throw(/unsupported wpSubmitKind: accelerate/);
});
});
});
Loading
Loading