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

Large diffs are not rendered by default.

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('accepts multiple sources when explicitly passed', 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
73 changes: 73 additions & 0 deletions src/__tests__/api/master/recoveryConsolidationsWallet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import {
BitGoAPITestHarness,
DEFAULT_ASYNC_MODE_CONFIG,
makeMasterExpressTestConfig,
makeSplitAwmMasterExpressConfig,
nockAsyncMultisigRecoveryJob,
nockAsyncRecoveryJobBypass,
nockSplitAwmMultisigRecovery,
} from './testUtils';

describe('POST /api/v1/:coin/advancedwallet/recoveryconsolidations', () => {
Expand Down Expand Up @@ -735,4 +738,74 @@ describe('POST /api/v1/:coin/advancedwallet/recoveryconsolidations', () => {
);
});
});

describe('Split AWM (separate user and backup AWMs)', () => {
const halfSignedTxHex = 'half-signed-trx-tx';
const fullSignedTxHex = 'signed-trx-tx';

const trxConsolidationRequest = {
multisigType: 'onchain' as const,
userPub: mockUserPub,
backupPub: mockBackupPub,
bitgoPub: mockBitgoPub,
tokenContractAddress: trxTokenContractAddress,
startingScanIndex: 1,
endingScanIndex: 3,
};

function nockTrxTwoAddressConsolidationScan() {
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 });
}

it('calls user AWM with keyToSign=user then backup AWM with keyToSign=backup per tx', async () => {
nockTrxTwoAddressConsolidationScan();
const { userAwmNock, backupAwmNock } = nockSplitAwmMultisigRecovery({
coin: 'trx',
halfSignedTxHex,
fullSignedTxHex,
times: 2,
});

const response = await request
.agent(expressApp(makeSplitAwmMasterExpressConfig()))
.post('/api/v1/trx/advancedwallet/recoveryconsolidations')
.set('Authorization', `Bearer ${accessToken}`)
.send(trxConsolidationRequest);

response.status.should.equal(200);
response.body.signedTxs.should.have.length(2);
response.body.signedTxs[0].should.have.property('txHex', fullSignedTxHex);
userAwmNock.done();
backupAwmNock.done();
});

it('stays sync when async mode is enabled (bridge is not called)', async () => {
nockTrxTwoAddressConsolidationScan();
const bridgeNock = nockAsyncRecoveryJobBypass('trx');
const { userAwmNock, backupAwmNock } = nockSplitAwmMultisigRecovery({
coin: 'trx',
halfSignedTxHex,
fullSignedTxHex,
times: 2,
});

const response = await request
.agent(expressApp(makeSplitAwmMasterExpressConfig({ asyncEnabled: true })))
.post('/api/v1/trx/advancedwallet/recoveryconsolidations')
.set('Authorization', `Bearer ${accessToken}`)
.send(trxConsolidationRequest);

response.status.should.equal(200);
response.body.signedTxs.should.have.length(2);
userAwmNock.done();
backupAwmNock.done();
bridgeNock.isDone().should.be.false();
});
});
});
201 changes: 201 additions & 0 deletions src/__tests__/api/master/recoveryWallet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import {
BitGoAPITestHarness,
DEFAULT_ASYNC_MODE_CONFIG,
makeMasterExpressTestConfig,
makeSplitAwmMasterExpressConfig,
nockAsyncMultisigRecoveryJob,
nockAsyncRecoveryJobBypass,
nockSplitAwmMultisigRecovery,
} from './testUtils';

describe('Recovery Tests', () => {
Expand Down Expand Up @@ -817,3 +820,201 @@ describe('Recovery Tests', () => {
});
});
});

describe('Split AWM recovery (separate user and backup AWMs)', () => {
const accessToken = 'test-token';
const coin = 'tbtc';
const userPub =
'xpub661MyMwAqRbcEtjU21VjQhGDdg5noG6kCGjcpc4EZwnLUxr9Pi56i14Eek8CQqcuGVnXQf3Zy47Uizr5WHDbZ3GumXEFXpwFLHWGbKrWWcg';
const backupPub =
'xpub661MyMwAqRbcEnTrcp222pRm7G1ZAbDD3KxXT2XEKRe3jnnvydqnyssewd2eUxgeWr1c1ffHcqqRKB8j3Lw9VR4dvrAhTov4kPKZF5rs6Vr';
const bitgoPub =
'xpub661MyMwAqRbcFNUFGFmDcC3Frgtz4FnJqFdCGbzLva2hf5i3ZJuQdsGc3z5FXCVqR9NQ6h2zTyGcQkfFtsLT5St621Fcu1C22kCKhbo4kQy';

const halfSignedTxHex = 'half-signed-utxo-tx-hex';
const fullSignedTxHex =
'01000000000101edd7a583fef5aabf265e6dca24452581a3cca2671a1fa6b4e404bccb6ff4c83b0000000000ffffffff01780f0000000000002200202120dcf53e62a4cc9d3843993aa2258bd14fbf911a4ea4cf4f3ac840f41702790400473044022043a9256810ef47ce36a092305c0b1ef675bce53e46418eea8cacbf1643e541d90220450766e048b841dac658d0a2ba992628bfe131dff078c3a574cadf67b4946647014730440220360045a15e459ed44aa3e52b86dd6a16dddaf319821f4dcc15627686f377edd102205cb3d5feab1a773c518d43422801e01dd1bc586bb09f6a9ed23a1fc0cfeeb5310169522103a1c425fd9b169e6ab5ed3de596acb777ccae0cda3d91256238b5e739a3f14aae210222a76697605c890dc4365132f9ae0d351952a1aad7eecf78d9923766dbe74a1e21033b21c0758ffbd446204914fa1d1c5921e9f82c2671dac89737666aa9375973e953ae00000000';

const utxoRecoveryRequest = {
multiSigRecoveryParams: { userPub, backupPub, bitgoPub, walletContractAddress: '' },
recoveryDestinationAddress: 'tb1qprdy6jwxrrr2qrwgd2tzl8z99hqp29jn6f3sguxulqm448myj6jsy2nwsu',
coin,
apiKey: 'key',
coinSpecificParams: { utxoRecoveryOptions: { scan: 1 } },
};

function nockUtxoRecoveryScan() {
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,
});
}

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 () => {
nockUtxoRecoveryScan();
const { userAwmNock, backupAwmNock } = nockSplitAwmMultisigRecovery({
coin,
halfSignedTxHex,
fullSignedTxHex,
});

const response = await request
.agent(expressApp(makeSplitAwmMasterExpressConfig()))
.post(`/api/v1/${coin}/advancedwallet/recovery`)
.set('Authorization', `Bearer ${accessToken}`)
.send(utxoRecoveryRequest);

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 () => {
nockUtxoRecoveryScan();
const bridgeNock = nockAsyncRecoveryJobBypass(coin);
const { userAwmNock, backupAwmNock } = nockSplitAwmMultisigRecovery({
coin,
halfSignedTxHex,
fullSignedTxHex,
validateBackupBody: (body) => body.keyToSign === 'backup',
});

const response = await request
.agent(expressApp(makeSplitAwmMasterExpressConfig({ asyncEnabled: true })))
.post(`/api/v1/${coin}/advancedwallet/recovery`)
.set('Authorization', `Bearer ${accessToken}`)
.send(utxoRecoveryRequest);

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',
});

const halfSignedObject = {
halfSigned: {
txHex: '0xhalfsigned',
recipients: [{ address: '0xrecipient', amount: '1000' }],
expireTime: 123,
backupKeyNonce: 1,
},
recipients: [{ address: '0xrecipient', amount: '1000' }],
};

const { userAwmNock, backupAwmNock } = nockSplitAwmMultisigRecovery({
coin: ethCoinId,
halfSignedTxHex: '0xhalfsigned',
fullSignedTxHex: '0xfullsigned',
userHalfSignBody: halfSignedObject,
validateBackupBody: (body) =>
body.keyToSign === 'backup' &&
body.halfSignedTransaction?.halfSigned?.txHex === '0xhalfsigned',
});

const response = await request
.agent(expressApp(makeSplitAwmMasterExpressConfig()))
.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