@@ -5,6 +5,7 @@ import sinon from 'sinon';
55import { app as expressApp } from '../../../masterBitGoExpressApp' ;
66import { AppMode , MasterExpressConfig , TlsMode } from '../../../shared/types' ;
77import {
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 ( / \/ b i t c o i n \/ t e s t n e t \/ d a s h b o a r d s \/ a d d r e s s \/ [ ^ ? ] + \? k e y = k e y / )
878+ . reply ( function ( uri ) {
879+ const match = uri . match ( / \/ d a s h b o a r d s \/ a d d r e s s \/ ( [ ^ ? ] + ) \? / ) ;
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 ( / \/ b i t c o i n \/ t e s t n e t \/ d a s h b o a r d s \/ a d d r e s s \/ [ ^ ? ] + \? k e y = k e y / )
973+ . reply ( function ( uri ) {
974+ const match = uri . match ( / \/ d a s h b o a r d s \/ a d d r e s s \/ ( [ ^ ? ] + ) \? / ) ;
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