Skip to content
Open
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

Large diffs are not rendered by default.

31 changes: 31 additions & 0 deletions src/__tests__/api/master/asyncJobWorker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -676,5 +676,36 @@ describe('asyncJobWorker', () => {
/expected txHex or halfSigned/,
);
});

it('uses awmBackupResponse as the final tx for split-AWM two-phase recovery', async () => {
const halfSignedHex = 'half-signed-tx-hex';
const fullSignedHex = 'full-signed-tx-hex';
const job = makeRecoveryJob({
awmResponse: awmOk({ txHex: halfSignedHex }),
awmBackupResponse: awmOk({ txHex: fullSignedHex }),
});

const updateNock = nock(BRIDGE_URL)
.patch(
`/job/${job.jobId}`,
(body) => body.status === 'complete' && body.result?.txHex === fullSignedHex,
)
.reply(204);

await handleMultisigRecoveryOperation(job, bridge, bitgo);

updateNock.done();
});

it('throws when awmBackupResponse is present but not a valid signed transaction', async () => {
const job = makeRecoveryJob({
awmResponse: awmOk({ txHex: 'half-signed-tx-hex' }),
awmBackupResponse: { status: 200, body: { bad: 'shape' } },
});

await handleMultisigRecoveryOperation(job, bridge, bitgo).should.be.rejectedWith(
/expected txHex or halfSigned/,
);
});
});
});
33 changes: 33 additions & 0 deletions src/__tests__/api/master/multisigRecoveryUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,39 @@ describe('multisigRecoveryUtils', () => {
result.should.eql({ jobId, status: 'pending' });
bridgeNock.done();
});

it('defaults to the user source when sources is omitted', async () => {
const jobId = 'job-123';
const bridgeNock = nock(bridgeUrl)
.post(`/api/${coin}/multisig/recovery`)
.matchHeader('X-OSO-Source', KeySource.USER)
.reply(202, { jobId });

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

it('submits with user,backup sources for split-AWM recovery', async () => {
const jobId = 'job-456';
const bridgeNock = nock(bridgeUrl)
.post(`/api/${coin}/multisig/recovery`, (body) => {
body.should.eql(recoveryBody);
return true;
})
.matchHeader('X-OSO-Source', `${KeySource.USER},${KeySource.BACKUP}`)
.matchHeader('X-OSO-Operation', 'multisig_recovery')
.reply(202, { jobId });

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

describe('parseSignedRecoveryTransaction', () => {
Expand Down
303 changes: 303 additions & 0 deletions src/__tests__/api/master/recoveryWallet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import sinon from 'sinon';
import { app as expressApp } from '../../../masterBitGoExpressApp';
import { AppMode, MasterExpressConfig, TlsMode } from '../../../shared/types';
import {
ASYNC_TEST_BRIDGE_URL,
BitGoAPITestHarness,
DEFAULT_ASYNC_MODE_CONFIG,
makeMasterExpressTestConfig,
Expand Down Expand Up @@ -817,3 +818,305 @@ describe('Recovery Tests', () => {
});
});
});

describe('Split AWM recovery (separate user and backup AWMs)', () => {
const userAwmUrl = 'http://user-awm.invalid';
const backupAwmUrl = 'http://backup-awm.invalid';
const accessToken = 'test-token';
const coin = 'tbtc';
const userPub =
'xpub661MyMwAqRbcEtjU21VjQhGDdg5noG6kCGjcpc4EZwnLUxr9Pi56i14Eek8CQqcuGVnXQf3Zy47Uizr5WHDbZ3GumXEFXpwFLHWGbKrWWcg';
const backupPub =
'xpub661MyMwAqRbcEnTrcp222pRm7G1ZAbDD3KxXT2XEKRe3jnnvydqnyssewd2eUxgeWr1c1ffHcqqRKB8j3Lw9VR4dvrAhTov4kPKZF5rs6Vr';
const bitgoPub =
'xpub661MyMwAqRbcFNUFGFmDcC3Frgtz4FnJqFdCGbzLva2hf5i3ZJuQdsGc3z5FXCVqR9NQ6h2zTyGcQkfFtsLT5St621Fcu1C22kCKhbo4kQy';

before(() => {
nock.disableNetConnect();
nock.enableNetConnect('127.0.0.1');
});

afterEach(() => {
nock.cleanAll();
BitGoAPITestHarness.clearConstantsCache();
});

it('calls user AWM with keyToSign=user then backup AWM with keyToSign=backup for UTXO recovery', async () => {
const halfSignedTxHex = 'half-signed-utxo-tx-hex';
const fullSignedTxHex =
'01000000000101edd7a583fef5aabf265e6dca24452581a3cca2671a1fa6b4e404bccb6ff4c83b0000000000ffffffff01780f0000000000002200202120dcf53e62a4cc9d3843993aa2258bd14fbf911a4ea4cf4f3ac840f41702790400473044022043a9256810ef47ce36a092305c0b1ef675bce53e46418eea8cacbf1643e541d90220450766e048b841dac658d0a2ba992628bfe131dff078c3a574cadf67b4946647014730440220360045a15e459ed44aa3e52b86dd6a16dddaf319821f4dcc15627686f377edd102205cb3d5feab1a773c518d43422801e01dd1bc586bb09f6a9ed23a1fc0cfeeb5310169522103a1c425fd9b169e6ab5ed3de596acb777ccae0cda3d91256238b5e739a3f14aae210222a76697605c890dc4365132f9ae0d351952a1aad7eecf78d9923766dbe74a1e21033b21c0758ffbd446204914fa1d1c5921e9f82c2671dac89737666aa9375973e953ae00000000';

const blockchairBase = 'https://api.blockchair.com';
const addrWithFunds = 'tb1qs5efv9zqhrc4sne7zphmsxea3cg9m262v6phsqn5dfdwed8ykx4s4wj67d';

nock(blockchairBase)
.get(`/bitcoin/testnet/dashboards/address/${addrWithFunds}?key=key`)
.reply(200, {
data: { [addrWithFunds]: { address: { transaction_count: 1, balance: 4000 } } },
});
nock(blockchairBase)
.get(`/bitcoin/testnet/dashboards/addresses/${addrWithFunds}?key=key`)
.reply(200, {
data: {
utxo: [
{
transaction_hash: '3bc8f46fcbbc04e4b4a61f1a67a2cca381254524ca6d5e26bfaaf5fe83a5d7ed',
index: 0,
recipient: addrWithFunds,
value: 4000,
block_id: 100,
spending_transaction_hash: null,
spending_index: null,
address: addrWithFunds,
},
],
},
});
nock(blockchairBase)
.persist()
.get(/\/bitcoin\/testnet\/dashboards\/address\/[^?]+\?key=key/)
.reply(function (uri) {
const match = uri.match(/\/dashboards\/address\/([^?]+)\?/);
const addr = match ? decodeURIComponent(match[1]) : 'unknown';
return [200, { data: { [addr]: { address: { transaction_count: 0, balance: 0 } } } }];
});
nock('https://mempool.space').get('/api/v1/fees/recommended').reply(200, {
fastestFee: 20,
halfHourFee: 10,
hourFee: 5,
});

// User AWM: receives keyToSign=user, returns half-signed tx
const userAwmNock = nock(userAwmUrl)
.post(`/api/${coin}/multisig/recovery`, (body) => body.keyToSign === 'user')
.reply(200, { txHex: halfSignedTxHex });

// Backup AWM: receives keyToSign=backup and halfSignedTransaction, returns full-signed tx
const backupAwmNock = nock(backupAwmUrl)
.post(`/api/${coin}/multisig/recovery`, (body) => {
return (
body.keyToSign === 'backup' &&
body.halfSignedTransaction !== undefined &&
body.halfSignedTransaction.txHex === halfSignedTxHex
);
})
.reply(200, { txHex: fullSignedTxHex });

const response = await request
.agent(
expressApp(
makeMasterExpressTestConfig(userAwmUrl, {
overrides: {
advancedWalletManagerBackupUrl: backupAwmUrl,
awmBackupServerCaCert: 'dummy-backup-cert',
recoveryMode: true,
},
}),
),
)
.post(`/api/v1/${coin}/advancedwallet/recovery`)
.set('Authorization', `Bearer ${accessToken}`)
.send({
multiSigRecoveryParams: {
userPub,
backupPub,
bitgoPub,
walletContractAddress: '',
},
recoveryDestinationAddress:
'tb1qprdy6jwxrrr2qrwgd2tzl8z99hqp29jn6f3sguxulqm448myj6jsy2nwsu',
coin,
apiKey: 'key',
coinSpecificParams: { utxoRecoveryOptions: { scan: 1 } },
});

response.status.should.equal(200);
response.body.should.have.property('txHex', fullSignedTxHex);
userAwmNock.done();
backupAwmNock.done();
});

it('uses the sync split-AWM two-phase path even when async mode is enabled', async () => {
const halfSignedTxHex = 'half-signed-utxo-tx-hex';
const fullSignedTxHex =
'01000000000101edd7a583fef5aabf265e6dca24452581a3cca2671a1fa6b4e404bccb6ff4c83b0000000000ffffffff01780f0000000000002200202120dcf53e62a4cc9d3843993aa2258bd14fbf911a4ea4cf4f3ac840f41702790400473044022043a9256810ef47ce36a092305c0b1ef675bce53e46418eea8cacbf1643e541d90220450766e048b841dac658d0a2ba992628bfe131dff078c3a574cadf67b4946647014730440220360045a15e459ed44aa3e52b86dd6a16dddaf319821f4dcc15627686f377edd102205cb3d5feab1a773c518d43422801e01dd1bc586bb09f6a9ed23a1fc0cfeeb5310169522103a1c425fd9b169e6ab5ed3de596acb777ccae0cda3d91256238b5e739a3f14aae210222a76697605c890dc4365132f9ae0d351952a1aad7eecf78d9923766dbe74a1e21033b21c0758ffbd446204914fa1d1c5921e9f82c2671dac89737666aa9375973e953ae00000000';

const blockchairBase = 'https://api.blockchair.com';
const addrWithFunds = 'tb1qs5efv9zqhrc4sne7zphmsxea3cg9m262v6phsqn5dfdwed8ykx4s4wj67d';

nock(blockchairBase)
.get(`/bitcoin/testnet/dashboards/address/${addrWithFunds}?key=key`)
.reply(200, {
data: { [addrWithFunds]: { address: { transaction_count: 1, balance: 4000 } } },
});
nock(blockchairBase)
.get(`/bitcoin/testnet/dashboards/addresses/${addrWithFunds}?key=key`)
.reply(200, {
data: {
utxo: [
{
transaction_hash: '3bc8f46fcbbc04e4b4a61f1a67a2cca381254524ca6d5e26bfaaf5fe83a5d7ed',
index: 0,
recipient: addrWithFunds,
value: 4000,
block_id: 100,
spending_transaction_hash: null,
spending_index: null,
address: addrWithFunds,
},
],
},
});
nock(blockchairBase)
.persist()
.get(/\/bitcoin\/testnet\/dashboards\/address\/[^?]+\?key=key/)
.reply(function (uri) {
const match = uri.match(/\/dashboards\/address\/([^?]+)\?/);
const addr = match ? decodeURIComponent(match[1]) : 'unknown';
return [200, { data: { [addr]: { address: { transaction_count: 0, balance: 0 } } } }];
});
nock('https://mempool.space').get('/api/v1/fees/recommended').reply(200, {
fastestFee: 20,
halfHourFee: 10,
hourFee: 5,
});

// The bridge can't sequence a two-phase recovery, so split-AWM always signs synchronously:
// user AWM half-signs, backup AWM full-signs with the half-signed tx. The bridge is NOT called.
const bridgeNock = nock(ASYNC_TEST_BRIDGE_URL)
.post(`/api/${coin}/multisig/recovery`)
.reply(202, { jobId: 'should-not-reach-bridge' });
const userAwmNock = nock(userAwmUrl)
.post(`/api/${coin}/multisig/recovery`, (body) => body.keyToSign === 'user')
.reply(200, { txHex: halfSignedTxHex });
const backupAwmNock = nock(backupAwmUrl)
.post(`/api/${coin}/multisig/recovery`, (body) => body.keyToSign === 'backup')
.reply(200, { txHex: fullSignedTxHex });

const response = await request
.agent(
expressApp(
makeMasterExpressTestConfig(userAwmUrl, {
asyncEnabled: true,
overrides: {
advancedWalletManagerBackupUrl: backupAwmUrl,
awmBackupServerCaCert: 'dummy-backup-cert',
recoveryMode: true,
},
}),
),
)
.post(`/api/v1/${coin}/advancedwallet/recovery`)
.set('Authorization', `Bearer ${accessToken}`)
.send({
multiSigRecoveryParams: { userPub, backupPub, bitgoPub, walletContractAddress: '' },
recoveryDestinationAddress:
'tb1qprdy6jwxrrr2qrwgd2tzl8z99hqp29jn6f3sguxulqm448myj6jsy2nwsu',
coin,
apiKey: 'key',
coinSpecificParams: { utxoRecoveryOptions: { scan: 1 } },
});

response.status.should.equal(200);
response.body.should.have.property('txHex', fullSignedTxHex);
userAwmNock.done();
backupAwmNock.done();
bridgeNock.isDone().should.be.false();
});

it('forwards the rich EVM half-signed object from user AWM to backup AWM for EVM recovery', async () => {
const ethCoinId = 'hteth';
const ethUserPub =
'xpub661MyMwAqRbcFigezGWEYSbCPVuaUmvnp1u7iEpH9YsKU6uYQtPANvudjgAo82QRHXsUieMqKeB1xEj89VUKU1ugtmyAZ3xzNEbHPexxgKK';
const ethBackupPub =
'xpub661MyMwAqRbcGbCirzmQsUJT2eidt9tFLw2m77w6FiKco6TKu49CP3GkHF88xGCpvqkP93SYMAarfyWAn8UWevQtNT6pDo8xH7xmf6GqK6e';
const walletContractAddress = '0x0987654321098765432109876543210987654321';
const backupKeyAddress = '0x30edc88a77598833f58947638b2ac3d5713d9845';
const etherscanBase = 'https://api.etherscan.io';
const chainid = '560048'; // Holesky testnet (hteth)
const apiKey = 'key';

// Etherscan nocks mirror the single-AWM EVM recovery test.
nock(etherscanBase)
.get(
`/v2/api?chainid=${chainid}&module=account&action=txlist&address=${backupKeyAddress}&apikey=${apiKey}`,
)
.twice()
.reply(200, { result: [] });
nock(etherscanBase)
.get(
`/v2/api?chainid=${chainid}&module=account&action=balance&address=${backupKeyAddress}&apikey=${apiKey}`,
)
.reply(200, { result: '10000000000000000' });
nock(etherscanBase)
.get(
`/v2/api?chainid=${chainid}&module=account&action=balance&address=${walletContractAddress}&apikey=${apiKey}`,
)
.reply(200, { result: '1000000000000000000' });
nock(etherscanBase)
.get(
`/v2/api?chainid=${chainid}&module=proxy&action=eth_call&to=${walletContractAddress}&data=a0b7967b&tag=latest&apikey=${apiKey}`,
)
.reply(200, {
result: '0x0000000000000000000000000000000000000000000000000000000000000001',
});

// Rich EVM half-signed object the user AWM returns and the backup AWM must receive verbatim.
const halfSignedObject = {
halfSigned: {
txHex: '0xhalfsigned',
recipients: [{ address: '0xrecipient', amount: '1000' }],
expireTime: 123,
backupKeyNonce: 1,
},
recipients: [{ address: '0xrecipient', amount: '1000' }],
};

const userAwmNock = nock(userAwmUrl)
.post(`/api/${ethCoinId}/multisig/recovery`, (body) => body.keyToSign === 'user')
.reply(200, halfSignedObject);

const backupAwmNock = nock(backupAwmUrl)
.post(`/api/${ethCoinId}/multisig/recovery`, (body) => {
return (
body.keyToSign === 'backup' &&
body.halfSignedTransaction !== undefined &&
body.halfSignedTransaction.halfSigned !== undefined &&
body.halfSignedTransaction.halfSigned.txHex === '0xhalfsigned'
);
})
.reply(200, { txHex: '0xfullsigned' });

const response = await request
.agent(
expressApp(
makeMasterExpressTestConfig(userAwmUrl, {
overrides: {
advancedWalletManagerBackupUrl: backupAwmUrl,
awmBackupServerCaCert: 'dummy-backup-cert',
recoveryMode: true,
},
}),
),
)
.post(`/api/v1/${ethCoinId}/advancedwallet/recovery`)
.set('Authorization', `Bearer ${accessToken}`)
.send({
multiSigRecoveryParams: {
userPub: ethUserPub,
backupPub: ethBackupPub,
bitgoPub: '',
walletContractAddress,
},
recoveryDestinationAddress: '0x1234567890123456789012345678901234567890',
coin: ethCoinId,
apiKey,
coinSpecificParams: { evmRecoveryOptions: {} },
});

response.status.should.equal(200);
response.body.should.have.property('txHex', '0xfullsigned');
userAwmNock.done();
backupAwmNock.done();
});
});
Loading
Loading