@@ -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
1417describe ( '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 ( / \/ 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 / )
876+ . reply ( function ( uri ) {
877+ const match = uri . match ( / \/ d a s h b o a r d s \/ a d d r e s s \/ ( [ ^ ? ] + ) \? / ) ;
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