Skip to content

Commit 95a2371

Browse files
authored
Merge pull request #251 from BitGo/WCN-1082/split-user-backup-awm
feat: enhance multisig recovery with split AWM support
2 parents 3c28c4b + 6f14fe1 commit 95a2371

13 files changed

Lines changed: 1413 additions & 204 deletions

File tree

src/__tests__/api/advancedWalletManager/recoveryMultisigTransaction.test.ts

Lines changed: 484 additions & 2 deletions
Large diffs are not rendered by default.

src/__tests__/api/master/multisigRecoveryUtils.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,39 @@ describe('multisigRecoveryUtils', () => {
7272
result.should.eql({ jobId, status: 'pending' });
7373
bridgeNock.done();
7474
});
75+
76+
it('defaults to the user source when sources is omitted', async () => {
77+
const jobId = 'job-123';
78+
const bridgeNock = nock(bridgeUrl)
79+
.post(`/api/${coin}/multisig/recovery`)
80+
.matchHeader('X-OSO-Source', KeySource.USER)
81+
.reply(202, { jobId });
82+
83+
const result = await submitMultisigRecoveryJob(makeAsyncReq(), coin, recoveryBody);
84+
assert(result);
85+
result.should.eql({ jobId, status: 'pending' });
86+
bridgeNock.done();
87+
});
88+
89+
it('accepts multiple sources when explicitly passed', async () => {
90+
const jobId = 'job-456';
91+
const bridgeNock = nock(bridgeUrl)
92+
.post(`/api/${coin}/multisig/recovery`, (body) => {
93+
body.should.eql(recoveryBody);
94+
return true;
95+
})
96+
.matchHeader('X-OSO-Source', `${KeySource.USER},${KeySource.BACKUP}`)
97+
.matchHeader('X-OSO-Operation', 'multisig_recovery')
98+
.reply(202, { jobId });
99+
100+
const result = await submitMultisigRecoveryJob(makeAsyncReq(), coin, recoveryBody, [
101+
KeySource.USER,
102+
KeySource.BACKUP,
103+
]);
104+
assert(result);
105+
result.should.eql({ jobId, status: 'pending' });
106+
bridgeNock.done();
107+
});
75108
});
76109

77110
describe('parseSignedRecoveryTransaction', () => {

src/__tests__/api/master/recoveryConsolidationsWallet.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ import {
99
BitGoAPITestHarness,
1010
DEFAULT_ASYNC_MODE_CONFIG,
1111
makeMasterExpressTestConfig,
12+
makeSplitAwmMasterExpressConfig,
1213
nockAsyncMultisigRecoveryJob,
14+
nockAsyncRecoveryJobBypass,
15+
nockSplitAwmMultisigRecovery,
1316
} from './testUtils';
1417

1518
describe('POST /api/v1/:coin/advancedwallet/recoveryconsolidations', () => {
@@ -735,4 +738,74 @@ describe('POST /api/v1/:coin/advancedwallet/recoveryconsolidations', () => {
735738
);
736739
});
737740
});
741+
742+
describe('Split AWM (separate user and backup AWMs)', () => {
743+
const halfSignedTxHex = 'half-signed-trx-tx';
744+
const fullSignedTxHex = 'signed-trx-tx';
745+
746+
const trxConsolidationRequest = {
747+
multisigType: 'onchain' as const,
748+
userPub: mockUserPub,
749+
backupPub: mockBackupPub,
750+
bitgoPub: mockBitgoPub,
751+
tokenContractAddress: trxTokenContractAddress,
752+
startingScanIndex: 1,
753+
endingScanIndex: 3,
754+
};
755+
756+
function nockTrxTwoAddressConsolidationScan() {
757+
const tronBalanceWithToken = {
758+
data: [{ balance: 200_000_000, trc20: [{ [trxTokenContractAddress]: '1000000' }] }],
759+
};
760+
nock(tronBase).get(`/v1/accounts/${TRX_ADDR_1}`).reply(200, tronBalanceWithToken);
761+
nock(tronBase).post('/wallet/triggersmartcontract').reply(200, { transaction: TRON_MOCK_TX });
762+
nock(tronBase).get(`/v1/accounts/${TRX_ADDR_2}`).reply(200, tronBalanceWithToken);
763+
nock(tronBase).post('/wallet/triggersmartcontract').reply(200, { transaction: TRON_MOCK_TX });
764+
}
765+
766+
it('calls user AWM with keyToSign=user then backup AWM with keyToSign=backup per tx', async () => {
767+
nockTrxTwoAddressConsolidationScan();
768+
const { userAwmNock, backupAwmNock } = nockSplitAwmMultisigRecovery({
769+
coin: 'trx',
770+
halfSignedTxHex,
771+
fullSignedTxHex,
772+
times: 2,
773+
});
774+
775+
const response = await request
776+
.agent(expressApp(makeSplitAwmMasterExpressConfig()))
777+
.post('/api/v1/trx/advancedwallet/recoveryconsolidations')
778+
.set('Authorization', `Bearer ${accessToken}`)
779+
.send(trxConsolidationRequest);
780+
781+
response.status.should.equal(200);
782+
response.body.signedTxs.should.have.length(2);
783+
response.body.signedTxs[0].should.have.property('txHex', fullSignedTxHex);
784+
userAwmNock.done();
785+
backupAwmNock.done();
786+
});
787+
788+
it('stays sync when async mode is enabled (bridge is not called)', async () => {
789+
nockTrxTwoAddressConsolidationScan();
790+
const bridgeNock = nockAsyncRecoveryJobBypass('trx');
791+
const { userAwmNock, backupAwmNock } = nockSplitAwmMultisigRecovery({
792+
coin: 'trx',
793+
halfSignedTxHex,
794+
fullSignedTxHex,
795+
times: 2,
796+
});
797+
798+
const response = await request
799+
.agent(expressApp(makeSplitAwmMasterExpressConfig({ asyncEnabled: true })))
800+
.post('/api/v1/trx/advancedwallet/recoveryconsolidations')
801+
.set('Authorization', `Bearer ${accessToken}`)
802+
.send(trxConsolidationRequest);
803+
804+
response.status.should.equal(200);
805+
response.body.signedTxs.should.have.length(2);
806+
userAwmNock.done();
807+
backupAwmNock.done();
808+
bridgeNock.isDone().should.be.false();
809+
});
810+
});
738811
});

src/__tests__/api/master/recoveryWallet.test.ts

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ import {
88
BitGoAPITestHarness,
99
DEFAULT_ASYNC_MODE_CONFIG,
1010
makeMasterExpressTestConfig,
11+
makeSplitAwmMasterExpressConfig,
1112
nockAsyncMultisigRecoveryJob,
13+
nockAsyncRecoveryJobBypass,
14+
nockSplitAwmMultisigRecovery,
1215
} from './testUtils';
1316

1417
describe('Recovery Tests', () => {
@@ -817,3 +820,201 @@ describe('Recovery Tests', () => {
817820
});
818821
});
819822
});
823+
824+
describe('Split AWM recovery (separate user and backup AWMs)', () => {
825+
const accessToken = 'test-token';
826+
const coin = 'tbtc';
827+
const userPub =
828+
'xpub661MyMwAqRbcEtjU21VjQhGDdg5noG6kCGjcpc4EZwnLUxr9Pi56i14Eek8CQqcuGVnXQf3Zy47Uizr5WHDbZ3GumXEFXpwFLHWGbKrWWcg';
829+
const backupPub =
830+
'xpub661MyMwAqRbcEnTrcp222pRm7G1ZAbDD3KxXT2XEKRe3jnnvydqnyssewd2eUxgeWr1c1ffHcqqRKB8j3Lw9VR4dvrAhTov4kPKZF5rs6Vr';
831+
const bitgoPub =
832+
'xpub661MyMwAqRbcFNUFGFmDcC3Frgtz4FnJqFdCGbzLva2hf5i3ZJuQdsGc3z5FXCVqR9NQ6h2zTyGcQkfFtsLT5St621Fcu1C22kCKhbo4kQy';
833+
834+
const halfSignedTxHex = 'half-signed-utxo-tx-hex';
835+
const fullSignedTxHex =
836+
'01000000000101edd7a583fef5aabf265e6dca24452581a3cca2671a1fa6b4e404bccb6ff4c83b0000000000ffffffff01780f0000000000002200202120dcf53e62a4cc9d3843993aa2258bd14fbf911a4ea4cf4f3ac840f41702790400473044022043a9256810ef47ce36a092305c0b1ef675bce53e46418eea8cacbf1643e541d90220450766e048b841dac658d0a2ba992628bfe131dff078c3a574cadf67b4946647014730440220360045a15e459ed44aa3e52b86dd6a16dddaf319821f4dcc15627686f377edd102205cb3d5feab1a773c518d43422801e01dd1bc586bb09f6a9ed23a1fc0cfeeb5310169522103a1c425fd9b169e6ab5ed3de596acb777ccae0cda3d91256238b5e739a3f14aae210222a76697605c890dc4365132f9ae0d351952a1aad7eecf78d9923766dbe74a1e21033b21c0758ffbd446204914fa1d1c5921e9f82c2671dac89737666aa9375973e953ae00000000';
837+
838+
const utxoRecoveryRequest = {
839+
multiSigRecoveryParams: { userPub, backupPub, bitgoPub, walletContractAddress: '' },
840+
recoveryDestinationAddress: 'tb1qprdy6jwxrrr2qrwgd2tzl8z99hqp29jn6f3sguxulqm448myj6jsy2nwsu',
841+
coin,
842+
apiKey: 'key',
843+
coinSpecificParams: { utxoRecoveryOptions: { scan: 1 } },
844+
};
845+
846+
function nockUtxoRecoveryScan() {
847+
const blockchairBase = 'https://api.blockchair.com';
848+
const addrWithFunds = 'tb1qs5efv9zqhrc4sne7zphmsxea3cg9m262v6phsqn5dfdwed8ykx4s4wj67d';
849+
850+
nock(blockchairBase)
851+
.get(`/bitcoin/testnet/dashboards/address/${addrWithFunds}?key=key`)
852+
.reply(200, {
853+
data: { [addrWithFunds]: { address: { transaction_count: 1, balance: 4000 } } },
854+
});
855+
nock(blockchairBase)
856+
.get(`/bitcoin/testnet/dashboards/addresses/${addrWithFunds}?key=key`)
857+
.reply(200, {
858+
data: {
859+
utxo: [
860+
{
861+
transaction_hash: '3bc8f46fcbbc04e4b4a61f1a67a2cca381254524ca6d5e26bfaaf5fe83a5d7ed',
862+
index: 0,
863+
recipient: addrWithFunds,
864+
value: 4000,
865+
block_id: 100,
866+
spending_transaction_hash: null,
867+
spending_index: null,
868+
address: addrWithFunds,
869+
},
870+
],
871+
},
872+
});
873+
nock(blockchairBase)
874+
.persist()
875+
.get(/\/bitcoin\/testnet\/dashboards\/address\/[^?]+\?key=key/)
876+
.reply(function (uri) {
877+
const match = uri.match(/\/dashboards\/address\/([^?]+)\?/);
878+
const addr = match ? decodeURIComponent(match[1]) : 'unknown';
879+
return [200, { data: { [addr]: { address: { transaction_count: 0, balance: 0 } } } }];
880+
});
881+
nock('https://mempool.space').get('/api/v1/fees/recommended').reply(200, {
882+
fastestFee: 20,
883+
halfHourFee: 10,
884+
hourFee: 5,
885+
});
886+
}
887+
888+
before(() => {
889+
nock.disableNetConnect();
890+
nock.enableNetConnect('127.0.0.1');
891+
});
892+
893+
afterEach(() => {
894+
nock.cleanAll();
895+
BitGoAPITestHarness.clearConstantsCache();
896+
});
897+
898+
it('calls user AWM with keyToSign=user then backup AWM with keyToSign=backup for UTXO recovery', async () => {
899+
nockUtxoRecoveryScan();
900+
const { userAwmNock, backupAwmNock } = nockSplitAwmMultisigRecovery({
901+
coin,
902+
halfSignedTxHex,
903+
fullSignedTxHex,
904+
});
905+
906+
const response = await request
907+
.agent(expressApp(makeSplitAwmMasterExpressConfig()))
908+
.post(`/api/v1/${coin}/advancedwallet/recovery`)
909+
.set('Authorization', `Bearer ${accessToken}`)
910+
.send(utxoRecoveryRequest);
911+
912+
response.status.should.equal(200);
913+
response.body.should.have.property('txHex', fullSignedTxHex);
914+
userAwmNock.done();
915+
backupAwmNock.done();
916+
});
917+
918+
it('uses the sync split-AWM two-phase path even when async mode is enabled', async () => {
919+
nockUtxoRecoveryScan();
920+
const bridgeNock = nockAsyncRecoveryJobBypass(coin);
921+
const { userAwmNock, backupAwmNock } = nockSplitAwmMultisigRecovery({
922+
coin,
923+
halfSignedTxHex,
924+
fullSignedTxHex,
925+
validateBackupBody: (body) => body.keyToSign === 'backup',
926+
});
927+
928+
const response = await request
929+
.agent(expressApp(makeSplitAwmMasterExpressConfig({ asyncEnabled: true })))
930+
.post(`/api/v1/${coin}/advancedwallet/recovery`)
931+
.set('Authorization', `Bearer ${accessToken}`)
932+
.send(utxoRecoveryRequest);
933+
934+
response.status.should.equal(200);
935+
response.body.should.have.property('txHex', fullSignedTxHex);
936+
userAwmNock.done();
937+
backupAwmNock.done();
938+
bridgeNock.isDone().should.be.false();
939+
});
940+
941+
it('forwards the rich EVM half-signed object from user AWM to backup AWM for EVM recovery', async () => {
942+
const ethCoinId = 'hteth';
943+
const ethUserPub =
944+
'xpub661MyMwAqRbcFigezGWEYSbCPVuaUmvnp1u7iEpH9YsKU6uYQtPANvudjgAo82QRHXsUieMqKeB1xEj89VUKU1ugtmyAZ3xzNEbHPexxgKK';
945+
const ethBackupPub =
946+
'xpub661MyMwAqRbcGbCirzmQsUJT2eidt9tFLw2m77w6FiKco6TKu49CP3GkHF88xGCpvqkP93SYMAarfyWAn8UWevQtNT6pDo8xH7xmf6GqK6e';
947+
const walletContractAddress = '0x0987654321098765432109876543210987654321';
948+
const backupKeyAddress = '0x30edc88a77598833f58947638b2ac3d5713d9845';
949+
const etherscanBase = 'https://api.etherscan.io';
950+
const chainid = '560048'; // Holesky testnet (hteth)
951+
const apiKey = 'key';
952+
953+
// Etherscan nocks mirror the single-AWM EVM recovery test.
954+
nock(etherscanBase)
955+
.get(
956+
`/v2/api?chainid=${chainid}&module=account&action=txlist&address=${backupKeyAddress}&apikey=${apiKey}`,
957+
)
958+
.twice()
959+
.reply(200, { result: [] });
960+
nock(etherscanBase)
961+
.get(
962+
`/v2/api?chainid=${chainid}&module=account&action=balance&address=${backupKeyAddress}&apikey=${apiKey}`,
963+
)
964+
.reply(200, { result: '10000000000000000' });
965+
nock(etherscanBase)
966+
.get(
967+
`/v2/api?chainid=${chainid}&module=account&action=balance&address=${walletContractAddress}&apikey=${apiKey}`,
968+
)
969+
.reply(200, { result: '1000000000000000000' });
970+
nock(etherscanBase)
971+
.get(
972+
`/v2/api?chainid=${chainid}&module=proxy&action=eth_call&to=${walletContractAddress}&data=a0b7967b&tag=latest&apikey=${apiKey}`,
973+
)
974+
.reply(200, {
975+
result: '0x0000000000000000000000000000000000000000000000000000000000000001',
976+
});
977+
978+
const halfSignedObject = {
979+
halfSigned: {
980+
txHex: '0xhalfsigned',
981+
recipients: [{ address: '0xrecipient', amount: '1000' }],
982+
expireTime: 123,
983+
backupKeyNonce: 1,
984+
},
985+
recipients: [{ address: '0xrecipient', amount: '1000' }],
986+
};
987+
988+
const { userAwmNock, backupAwmNock } = nockSplitAwmMultisigRecovery({
989+
coin: ethCoinId,
990+
halfSignedTxHex: '0xhalfsigned',
991+
fullSignedTxHex: '0xfullsigned',
992+
userHalfSignBody: halfSignedObject,
993+
validateBackupBody: (body) =>
994+
body.keyToSign === 'backup' &&
995+
body.halfSignedTransaction?.halfSigned?.txHex === '0xhalfsigned',
996+
});
997+
998+
const response = await request
999+
.agent(expressApp(makeSplitAwmMasterExpressConfig()))
1000+
.post(`/api/v1/${ethCoinId}/advancedwallet/recovery`)
1001+
.set('Authorization', `Bearer ${accessToken}`)
1002+
.send({
1003+
multiSigRecoveryParams: {
1004+
userPub: ethUserPub,
1005+
backupPub: ethBackupPub,
1006+
bitgoPub: '',
1007+
walletContractAddress,
1008+
},
1009+
recoveryDestinationAddress: '0x1234567890123456789012345678901234567890',
1010+
coin: ethCoinId,
1011+
apiKey,
1012+
coinSpecificParams: { evmRecoveryOptions: {} },
1013+
});
1014+
1015+
response.status.should.equal(200);
1016+
response.body.should.have.property('txHex', '0xfullsigned');
1017+
userAwmNock.done();
1018+
backupAwmNock.done();
1019+
});
1020+
});

0 commit comments

Comments
 (0)