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
20 changes: 20 additions & 0 deletions masterBitgoExpress.json
Original file line number Diff line number Diff line change
Expand Up @@ -1475,6 +1475,16 @@
}
}
},
"202": {
"description": "Accepted",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AsyncJobResponseCodec"
}
}
}
},
"400": {
"description": "Bad Request",
"content": {
Expand Down Expand Up @@ -1656,6 +1666,16 @@
}
}
},
"202": {
"description": "Accepted",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AsyncJobResponseCodec"
}
}
}
},
"400": {
"description": "Bad Request",
"content": {
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 @@ -10,6 +10,7 @@ import {
processPendingJobs,
handleKeyGenerationOperation,
handleMultisigSignOperation,
handleMultisigRecoveryOperation,
} from '../../../masterBitgoExpress/workers/asyncJobWorker';
import { AppMode, MasterExpressConfig, TlsMode } from '../../../shared/types';
import { DEFAULT_ASYNC_MODE_CONFIG } from './testUtils';
Expand Down Expand Up @@ -630,4 +631,50 @@ describe('asyncJobWorker', () => {
await handleMultisigSignOperation(job, bridge, bitgo).should.be.rejected();
});
});

describe('handleMultisigRecoveryOperation()', () => {
const recoveredTxHex = 'recovered-signed-tx-hex';

function makeRecoveryJob(overrides: Partial<BridgeJobResponse> = {}): BridgeJobResponse {
return makeSignJob({
jobId: 'job-recovery-123',
operationType: 'multisig_recovery',
awmResponse: awmOk({ txHex: recoveredTxHex }),
request: {
endpoint: `/api/${COIN}/multisig/recovery`,
method: 'POST',
body: { userPub: 'xpub_user', backupPub: 'xpub_backup', walletContractAddress: '' },
},
...overrides,
});
}

it('PATCHes job complete with the signed recovery tx (no WP submit)', async () => {
const job = makeRecoveryJob();
const updateNock = nock(BRIDGE_URL)
.patch(
`/job/${job.jobId}`,
(body) => body.status === 'complete' && body.result?.txHex === recoveredTxHex,
)
.reply(204);

await handleMultisigRecoveryOperation(job, bridge, bitgo);

updateNock.done();
});

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

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

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

await handleMultisigRecoveryOperation(job, bridge, bitgo).should.be.rejectedWith(
/expected txHex or halfSigned/,
);
});
});
});
97 changes: 97 additions & 0 deletions src/__tests__/api/master/multisigRecoveryUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import 'should';
import assert from 'assert';
import nock from 'nock';
import {
parseSignedRecoveryTransaction,
submitMultisigRecoveryJob,
} from '../../../masterBitgoExpress/handlers/utils/multisigRecoveryUtils';
import { SignedMultisigTransactionSchema } 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('multisigRecoveryUtils', () => {
const bridgeUrl = 'http://bridge.invalid';
const coin = 'tbtc';
const userPub =
'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8';
const backupPub =
'xpub661MyMwAqRbcEvJQx6spkkHLRgtjxmVdyDSvbDt2m9NFpbkHdcu5WJsHHHqFxNATbNHnhMWJiwckoMqF75EpcNhU9xeVM4oDS7urM3os4BH';
const recoveryBody = {
userPub,
backupPub,
bitgoPub: 'xpub_bitgo',
unsignedSweepPrebuildTx: { txHex: 'unsigned-tx-hex' },
walletContractAddress: '',
};

describe('submitMultisigRecoveryJob', () => {
afterEach(() => {
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 submitMultisigRecoveryJob(req, coin, recoveryBody);
assert.strictEqual(result, null);
});

it('submits multisig_recovery to the bridge with correct path and headers', async () => {
const jobId = 'job-123';
const bridgeNock = nock(bridgeUrl)
.post(`/api/${coin}/multisig/recovery`, (body) => {
body.should.eql(recoveryBody);
return true;
})
.matchHeader('X-OSO-Source', KeySource.USER)
.matchHeader('X-OSO-Operation', 'multisig_recovery')
.reply(202, { jobId });

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

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

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

it('uses the same schema as multisig sign responses', () => {
const body = { halfSigned: { txHex: 'signed-tx-hex' } };
parseSignedRecoveryTransaction(body).should.eql(
SignedMultisigTransactionSchema.parse(body) as { halfSigned: { txHex: string } },
);
});
});
});
118 changes: 117 additions & 1 deletion src/__tests__/api/master/recoveryConsolidationsWallet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import nock from 'nock';
import { app as expressApp } from '../../../masterBitGoExpressApp';
import { AppMode, MasterExpressConfig, TlsMode } from '../../../shared/types';
import { Trx } from '@bitgo-beta/sdk-coin-trx';
import { BitGoAPITestHarness, DEFAULT_ASYNC_MODE_CONFIG } from './testUtils';
import {
BitGoAPITestHarness,
DEFAULT_ASYNC_MODE_CONFIG,
makeMasterExpressTestConfig,
nockAsyncMultisigRecoveryJob,
} from './testUtils';

describe('POST /api/v1/:coin/advancedwallet/recoveryconsolidations', () => {
let agent: request.SuperAgentTest;
Expand Down Expand Up @@ -619,4 +624,115 @@ describe('POST /api/v1/:coin/advancedwallet/recoveryconsolidations', () => {
response.status.should.equal(400);
response.body.should.have.property('error');
});

describe('Async mode', () => {
const jobId = 'recovery-consolidation-job-id-123';
let asyncAgent: request.SuperAgentTest;

before(() => {
const asyncConfig = makeMasterExpressTestConfig(advancedWalletManagerUrl, {
asyncEnabled: true,
overrides: { recoveryMode: true },
});
asyncAgent = request.agent(expressApp(asyncConfig));
});

it('should return 202 + jobId for a single-tx onchain consolidation recovery', async () => {
nock(solRpcBase)
.post('/', (b) => b.method === 'getBalance' && b.params[0] === solWalletAddress2)
.reply(200, { jsonrpc: '2.0', result: { context: { slot: 1 }, value: 1000000000 }, id: 1 });
nock(solRpcBase)
.post('/', (b) => b.method === 'getLatestBlockhash')
.reply(200, {
jsonrpc: '2.0',
result: {
context: { slot: 2792 },
value: {
blockhash: 'EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N',
lastValidBlockHeight: 3090,
},
},
id: 1,
});
nock(solRpcBase)
.post('/', (b) => b.method === 'getAccountInfo' && b.params[0] === solDurableNoncePubKey)
.reply(200, solDurableNonceAccountInfo);
nock(solRpcBase)
.post('/', (b) => b.method === 'getFeeForMessage')
.reply(200, { jsonrpc: '2.0', result: { context: { slot: 1 }, value: 5000 }, id: 1 });

const { bridgeNock, awmRecoveryNock } = nockAsyncMultisigRecoveryJob({
coin: 'sol',
advancedWalletManagerUrl,
jobId,
});

const response = await asyncAgent
.post(`/api/v1/sol/advancedwallet/recoveryconsolidations`)
.set('Authorization', `Bearer ${accessToken}`)
.send({
multisigType: 'onchain' as const,
userPub: solBitgoKey,
backupPub: solBitgoKey,
bitgoPub: solBitgoKey,
startingScanIndex: 2,
endingScanIndex: 3,
durableNonces: {
publicKeys: [solDurableNoncePubKey, solDurableNoncePubKey2, solDurableNoncePubKey3],
secretKey: solDurableNoncePrivKey,
},
});

response.status.should.equal(202);
response.body.should.have.property('jobId', jobId);
response.body.should.have.property('status', 'pending');
bridgeNock.done();
awmRecoveryNock.isDone().should.be.false();
});

it('should reject async mode when more than one consolidation tx is built', async () => {
const tronBalanceWithToken = {
data: [{ balance: 200_000_000, trc20: [{ [trxTokenContractAddress]: '1000000' }] }],
};
nock(tronBase).get(`/v1/accounts/${TRX_ADDR_1}`).reply(200, tronBalanceWithToken);
nock(tronBase).post('/wallet/triggersmartcontract').reply(200, { transaction: TRON_MOCK_TX });
nock(tronBase).get(`/v1/accounts/${TRX_ADDR_2}`).reply(200, tronBalanceWithToken);
nock(tronBase).post('/wallet/triggersmartcontract').reply(200, { transaction: TRON_MOCK_TX });

const response = await asyncAgent
.post(`/api/v1/trx/advancedwallet/recoveryconsolidations`)
.set('Authorization', `Bearer ${accessToken}`)
.send({
multisigType: 'onchain' as const,
userPub: mockUserPub,
backupPub: mockBackupPub,
bitgoPub: mockBitgoPub,
tokenContractAddress: trxTokenContractAddress,
startingScanIndex: 1,
endingScanIndex: 3,
});

response.status.should.equal(400);
response.body.details.should.containEql(
'Async mode supports a single consolidation recovery only',
);
});

it('should reject async mode for MPC (tss) consolidation recovery', async () => {
const response = await asyncAgent
.post(`/api/v1/tsui/advancedwallet/recoveryconsolidations`)
.set('Authorization', `Bearer ${accessToken}`)
.send({
multisigType: 'tss' as const,
commonKeychain: suiBitgoKey,
startingScanIndex: 1,
endingScanIndex: 2,
});

response.status.should.equal(400);
response.body.details.should.containEql(
'Async mode is not yet supported for TSS/MPC recovery consolidations',
);
});
});
});
Loading
Loading