Skip to content

Commit 1d03528

Browse files
committed
feat: enhance multisig recovery with split AWM support
Ticket: WCN-1082
1 parent cedbbfd commit 1d03528

12 files changed

Lines changed: 1176 additions & 93 deletions

File tree

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

Lines changed: 481 additions & 0 deletions
Large diffs are not rendered by default.

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -676,5 +676,36 @@ describe('asyncJobWorker', () => {
676676
/expected txHex or halfSigned/,
677677
);
678678
});
679+
680+
it('uses awmBackupResponse as the final tx for split-AWM two-phase recovery', async () => {
681+
const halfSignedHex = 'half-signed-tx-hex';
682+
const fullSignedHex = 'full-signed-tx-hex';
683+
const job = makeRecoveryJob({
684+
awmResponse: awmOk({ txHex: halfSignedHex }),
685+
awmBackupResponse: awmOk({ txHex: fullSignedHex }),
686+
});
687+
688+
const updateNock = nock(BRIDGE_URL)
689+
.patch(
690+
`/job/${job.jobId}`,
691+
(body) => body.status === 'complete' && body.result?.txHex === fullSignedHex,
692+
)
693+
.reply(204);
694+
695+
await handleMultisigRecoveryOperation(job, bridge, bitgo);
696+
697+
updateNock.done();
698+
});
699+
700+
it('throws when awmBackupResponse is present but not a valid signed transaction', async () => {
701+
const job = makeRecoveryJob({
702+
awmResponse: awmOk({ txHex: 'half-signed-tx-hex' }),
703+
awmBackupResponse: { status: 200, body: { bad: 'shape' } },
704+
});
705+
706+
await handleMultisigRecoveryOperation(job, bridge, bitgo).should.be.rejectedWith(
707+
/expected txHex or halfSigned/,
708+
);
709+
});
679710
});
680711
});

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('submits with user,backup sources for split-AWM recovery', 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/recoveryWallet.test.ts

Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import sinon from 'sinon';
55
import { app as expressApp } from '../../../masterBitGoExpressApp';
66
import { AppMode, MasterExpressConfig, TlsMode } from '../../../shared/types';
77
import {
8+
ASYNC_TEST_BRIDGE_URL,
89
BitGoAPITestHarness,
910
DEFAULT_ASYNC_MODE_CONFIG,
1011
makeMasterExpressTestConfig,
@@ -817,3 +818,305 @@ describe('Recovery Tests', () => {
817818
});
818819
});
819820
});
821+
822+
describe('Split AWM recovery (separate user and backup AWMs)', () => {
823+
const userAwmUrl = 'http://user-awm.invalid';
824+
const backupAwmUrl = 'http://backup-awm.invalid';
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+
before(() => {
835+
nock.disableNetConnect();
836+
nock.enableNetConnect('127.0.0.1');
837+
});
838+
839+
afterEach(() => {
840+
nock.cleanAll();
841+
BitGoAPITestHarness.clearConstantsCache();
842+
});
843+
844+
it('calls user AWM with keyToSign=user then backup AWM with keyToSign=backup for UTXO recovery', async () => {
845+
const halfSignedTxHex = 'half-signed-utxo-tx-hex';
846+
const fullSignedTxHex =
847+
'01000000000101edd7a583fef5aabf265e6dca24452581a3cca2671a1fa6b4e404bccb6ff4c83b0000000000ffffffff01780f0000000000002200202120dcf53e62a4cc9d3843993aa2258bd14fbf911a4ea4cf4f3ac840f41702790400473044022043a9256810ef47ce36a092305c0b1ef675bce53e46418eea8cacbf1643e541d90220450766e048b841dac658d0a2ba992628bfe131dff078c3a574cadf67b4946647014730440220360045a15e459ed44aa3e52b86dd6a16dddaf319821f4dcc15627686f377edd102205cb3d5feab1a773c518d43422801e01dd1bc586bb09f6a9ed23a1fc0cfeeb5310169522103a1c425fd9b169e6ab5ed3de596acb777ccae0cda3d91256238b5e739a3f14aae210222a76697605c890dc4365132f9ae0d351952a1aad7eecf78d9923766dbe74a1e21033b21c0758ffbd446204914fa1d1c5921e9f82c2671dac89737666aa9375973e953ae00000000';
848+
849+
const blockchairBase = 'https://api.blockchair.com';
850+
const addrWithFunds = 'tb1qs5efv9zqhrc4sne7zphmsxea3cg9m262v6phsqn5dfdwed8ykx4s4wj67d';
851+
852+
nock(blockchairBase)
853+
.get(`/bitcoin/testnet/dashboards/address/${addrWithFunds}?key=key`)
854+
.reply(200, {
855+
data: { [addrWithFunds]: { address: { transaction_count: 1, balance: 4000 } } },
856+
});
857+
nock(blockchairBase)
858+
.get(`/bitcoin/testnet/dashboards/addresses/${addrWithFunds}?key=key`)
859+
.reply(200, {
860+
data: {
861+
utxo: [
862+
{
863+
transaction_hash: '3bc8f46fcbbc04e4b4a61f1a67a2cca381254524ca6d5e26bfaaf5fe83a5d7ed',
864+
index: 0,
865+
recipient: addrWithFunds,
866+
value: 4000,
867+
block_id: 100,
868+
spending_transaction_hash: null,
869+
spending_index: null,
870+
address: addrWithFunds,
871+
},
872+
],
873+
},
874+
});
875+
nock(blockchairBase)
876+
.persist()
877+
.get(/\/bitcoin\/testnet\/dashboards\/address\/[^?]+\?key=key/)
878+
.reply(function (uri) {
879+
const match = uri.match(/\/dashboards\/address\/([^?]+)\?/);
880+
const addr = match ? decodeURIComponent(match[1]) : 'unknown';
881+
return [200, { data: { [addr]: { address: { transaction_count: 0, balance: 0 } } } }];
882+
});
883+
nock('https://mempool.space').get('/api/v1/fees/recommended').reply(200, {
884+
fastestFee: 20,
885+
halfHourFee: 10,
886+
hourFee: 5,
887+
});
888+
889+
// User AWM: receives keyToSign=user, returns half-signed tx
890+
const userAwmNock = nock(userAwmUrl)
891+
.post(`/api/${coin}/multisig/recovery`, (body) => body.keyToSign === 'user')
892+
.reply(200, { txHex: halfSignedTxHex });
893+
894+
// Backup AWM: receives keyToSign=backup and halfSignedTransaction, returns full-signed tx
895+
const backupAwmNock = nock(backupAwmUrl)
896+
.post(`/api/${coin}/multisig/recovery`, (body) => {
897+
return (
898+
body.keyToSign === 'backup' &&
899+
body.halfSignedTransaction !== undefined &&
900+
body.halfSignedTransaction.txHex === halfSignedTxHex
901+
);
902+
})
903+
.reply(200, { txHex: fullSignedTxHex });
904+
905+
const response = await request
906+
.agent(
907+
expressApp(
908+
makeMasterExpressTestConfig(userAwmUrl, {
909+
overrides: {
910+
advancedWalletManagerBackupUrl: backupAwmUrl,
911+
awmBackupServerCaCert: 'dummy-backup-cert',
912+
recoveryMode: true,
913+
},
914+
}),
915+
),
916+
)
917+
.post(`/api/v1/${coin}/advancedwallet/recovery`)
918+
.set('Authorization', `Bearer ${accessToken}`)
919+
.send({
920+
multiSigRecoveryParams: {
921+
userPub,
922+
backupPub,
923+
bitgoPub,
924+
walletContractAddress: '',
925+
},
926+
recoveryDestinationAddress:
927+
'tb1qprdy6jwxrrr2qrwgd2tzl8z99hqp29jn6f3sguxulqm448myj6jsy2nwsu',
928+
coin,
929+
apiKey: 'key',
930+
coinSpecificParams: { utxoRecoveryOptions: { scan: 1 } },
931+
});
932+
933+
response.status.should.equal(200);
934+
response.body.should.have.property('txHex', fullSignedTxHex);
935+
userAwmNock.done();
936+
backupAwmNock.done();
937+
});
938+
939+
it('uses the sync split-AWM two-phase path even when async mode is enabled', async () => {
940+
const halfSignedTxHex = 'half-signed-utxo-tx-hex';
941+
const fullSignedTxHex =
942+
'01000000000101edd7a583fef5aabf265e6dca24452581a3cca2671a1fa6b4e404bccb6ff4c83b0000000000ffffffff01780f0000000000002200202120dcf53e62a4cc9d3843993aa2258bd14fbf911a4ea4cf4f3ac840f41702790400473044022043a9256810ef47ce36a092305c0b1ef675bce53e46418eea8cacbf1643e541d90220450766e048b841dac658d0a2ba992628bfe131dff078c3a574cadf67b4946647014730440220360045a15e459ed44aa3e52b86dd6a16dddaf319821f4dcc15627686f377edd102205cb3d5feab1a773c518d43422801e01dd1bc586bb09f6a9ed23a1fc0cfeeb5310169522103a1c425fd9b169e6ab5ed3de596acb777ccae0cda3d91256238b5e739a3f14aae210222a76697605c890dc4365132f9ae0d351952a1aad7eecf78d9923766dbe74a1e21033b21c0758ffbd446204914fa1d1c5921e9f82c2671dac89737666aa9375973e953ae00000000';
943+
944+
const blockchairBase = 'https://api.blockchair.com';
945+
const addrWithFunds = 'tb1qs5efv9zqhrc4sne7zphmsxea3cg9m262v6phsqn5dfdwed8ykx4s4wj67d';
946+
947+
nock(blockchairBase)
948+
.get(`/bitcoin/testnet/dashboards/address/${addrWithFunds}?key=key`)
949+
.reply(200, {
950+
data: { [addrWithFunds]: { address: { transaction_count: 1, balance: 4000 } } },
951+
});
952+
nock(blockchairBase)
953+
.get(`/bitcoin/testnet/dashboards/addresses/${addrWithFunds}?key=key`)
954+
.reply(200, {
955+
data: {
956+
utxo: [
957+
{
958+
transaction_hash: '3bc8f46fcbbc04e4b4a61f1a67a2cca381254524ca6d5e26bfaaf5fe83a5d7ed',
959+
index: 0,
960+
recipient: addrWithFunds,
961+
value: 4000,
962+
block_id: 100,
963+
spending_transaction_hash: null,
964+
spending_index: null,
965+
address: addrWithFunds,
966+
},
967+
],
968+
},
969+
});
970+
nock(blockchairBase)
971+
.persist()
972+
.get(/\/bitcoin\/testnet\/dashboards\/address\/[^?]+\?key=key/)
973+
.reply(function (uri) {
974+
const match = uri.match(/\/dashboards\/address\/([^?]+)\?/);
975+
const addr = match ? decodeURIComponent(match[1]) : 'unknown';
976+
return [200, { data: { [addr]: { address: { transaction_count: 0, balance: 0 } } } }];
977+
});
978+
nock('https://mempool.space').get('/api/v1/fees/recommended').reply(200, {
979+
fastestFee: 20,
980+
halfHourFee: 10,
981+
hourFee: 5,
982+
});
983+
984+
// The bridge can't sequence a two-phase recovery, so split-AWM always signs synchronously:
985+
// user AWM half-signs, backup AWM full-signs with the half-signed tx. The bridge is NOT called.
986+
const bridgeNock = nock(ASYNC_TEST_BRIDGE_URL)
987+
.post(`/api/${coin}/multisig/recovery`)
988+
.reply(202, { jobId: 'should-not-reach-bridge' });
989+
const userAwmNock = nock(userAwmUrl)
990+
.post(`/api/${coin}/multisig/recovery`, (body) => body.keyToSign === 'user')
991+
.reply(200, { txHex: halfSignedTxHex });
992+
const backupAwmNock = nock(backupAwmUrl)
993+
.post(`/api/${coin}/multisig/recovery`, (body) => body.keyToSign === 'backup')
994+
.reply(200, { txHex: fullSignedTxHex });
995+
996+
const response = await request
997+
.agent(
998+
expressApp(
999+
makeMasterExpressTestConfig(userAwmUrl, {
1000+
asyncEnabled: true,
1001+
overrides: {
1002+
advancedWalletManagerBackupUrl: backupAwmUrl,
1003+
awmBackupServerCaCert: 'dummy-backup-cert',
1004+
recoveryMode: true,
1005+
},
1006+
}),
1007+
),
1008+
)
1009+
.post(`/api/v1/${coin}/advancedwallet/recovery`)
1010+
.set('Authorization', `Bearer ${accessToken}`)
1011+
.send({
1012+
multiSigRecoveryParams: { userPub, backupPub, bitgoPub, walletContractAddress: '' },
1013+
recoveryDestinationAddress:
1014+
'tb1qprdy6jwxrrr2qrwgd2tzl8z99hqp29jn6f3sguxulqm448myj6jsy2nwsu',
1015+
coin,
1016+
apiKey: 'key',
1017+
coinSpecificParams: { utxoRecoveryOptions: { scan: 1 } },
1018+
});
1019+
1020+
response.status.should.equal(200);
1021+
response.body.should.have.property('txHex', fullSignedTxHex);
1022+
userAwmNock.done();
1023+
backupAwmNock.done();
1024+
bridgeNock.isDone().should.be.false();
1025+
});
1026+
1027+
it('forwards the rich EVM half-signed object from user AWM to backup AWM for EVM recovery', async () => {
1028+
const ethCoinId = 'hteth';
1029+
const ethUserPub =
1030+
'xpub661MyMwAqRbcFigezGWEYSbCPVuaUmvnp1u7iEpH9YsKU6uYQtPANvudjgAo82QRHXsUieMqKeB1xEj89VUKU1ugtmyAZ3xzNEbHPexxgKK';
1031+
const ethBackupPub =
1032+
'xpub661MyMwAqRbcGbCirzmQsUJT2eidt9tFLw2m77w6FiKco6TKu49CP3GkHF88xGCpvqkP93SYMAarfyWAn8UWevQtNT6pDo8xH7xmf6GqK6e';
1033+
const walletContractAddress = '0x0987654321098765432109876543210987654321';
1034+
const backupKeyAddress = '0x30edc88a77598833f58947638b2ac3d5713d9845';
1035+
const etherscanBase = 'https://api.etherscan.io';
1036+
const chainid = '560048'; // Holesky testnet (hteth)
1037+
const apiKey = 'key';
1038+
1039+
// Etherscan nocks mirror the single-AWM EVM recovery test.
1040+
nock(etherscanBase)
1041+
.get(
1042+
`/v2/api?chainid=${chainid}&module=account&action=txlist&address=${backupKeyAddress}&apikey=${apiKey}`,
1043+
)
1044+
.twice()
1045+
.reply(200, { result: [] });
1046+
nock(etherscanBase)
1047+
.get(
1048+
`/v2/api?chainid=${chainid}&module=account&action=balance&address=${backupKeyAddress}&apikey=${apiKey}`,
1049+
)
1050+
.reply(200, { result: '10000000000000000' });
1051+
nock(etherscanBase)
1052+
.get(
1053+
`/v2/api?chainid=${chainid}&module=account&action=balance&address=${walletContractAddress}&apikey=${apiKey}`,
1054+
)
1055+
.reply(200, { result: '1000000000000000000' });
1056+
nock(etherscanBase)
1057+
.get(
1058+
`/v2/api?chainid=${chainid}&module=proxy&action=eth_call&to=${walletContractAddress}&data=a0b7967b&tag=latest&apikey=${apiKey}`,
1059+
)
1060+
.reply(200, {
1061+
result: '0x0000000000000000000000000000000000000000000000000000000000000001',
1062+
});
1063+
1064+
// Rich EVM half-signed object the user AWM returns and the backup AWM must receive verbatim.
1065+
const halfSignedObject = {
1066+
halfSigned: {
1067+
txHex: '0xhalfsigned',
1068+
recipients: [{ address: '0xrecipient', amount: '1000' }],
1069+
expireTime: 123,
1070+
backupKeyNonce: 1,
1071+
},
1072+
recipients: [{ address: '0xrecipient', amount: '1000' }],
1073+
};
1074+
1075+
const userAwmNock = nock(userAwmUrl)
1076+
.post(`/api/${ethCoinId}/multisig/recovery`, (body) => body.keyToSign === 'user')
1077+
.reply(200, halfSignedObject);
1078+
1079+
const backupAwmNock = nock(backupAwmUrl)
1080+
.post(`/api/${ethCoinId}/multisig/recovery`, (body) => {
1081+
return (
1082+
body.keyToSign === 'backup' &&
1083+
body.halfSignedTransaction !== undefined &&
1084+
body.halfSignedTransaction.halfSigned !== undefined &&
1085+
body.halfSignedTransaction.halfSigned.txHex === '0xhalfsigned'
1086+
);
1087+
})
1088+
.reply(200, { txHex: '0xfullsigned' });
1089+
1090+
const response = await request
1091+
.agent(
1092+
expressApp(
1093+
makeMasterExpressTestConfig(userAwmUrl, {
1094+
overrides: {
1095+
advancedWalletManagerBackupUrl: backupAwmUrl,
1096+
awmBackupServerCaCert: 'dummy-backup-cert',
1097+
recoveryMode: true,
1098+
},
1099+
}),
1100+
),
1101+
)
1102+
.post(`/api/v1/${ethCoinId}/advancedwallet/recovery`)
1103+
.set('Authorization', `Bearer ${accessToken}`)
1104+
.send({
1105+
multiSigRecoveryParams: {
1106+
userPub: ethUserPub,
1107+
backupPub: ethBackupPub,
1108+
bitgoPub: '',
1109+
walletContractAddress,
1110+
},
1111+
recoveryDestinationAddress: '0x1234567890123456789012345678901234567890',
1112+
coin: ethCoinId,
1113+
apiKey,
1114+
coinSpecificParams: { evmRecoveryOptions: {} },
1115+
});
1116+
1117+
response.status.should.equal(200);
1118+
response.body.should.have.property('txHex', '0xfullsigned');
1119+
userAwmNock.done();
1120+
backupAwmNock.done();
1121+
});
1122+
});

0 commit comments

Comments
 (0)