@@ -25,6 +25,34 @@ jest.mock(
2525 { virtual : true } ,
2626) ;
2727
28+ const applyRateLimitMock = jest . fn <
29+ ( req : NextApiRequest , res : NextApiResponse , options ?: unknown ) => boolean
30+ > ( ) ;
31+ const enforceBodySizeMock = jest . fn <
32+ ( req : NextApiRequest , res : NextApiResponse , maxBytes : number ) => boolean
33+ > ( ) ;
34+
35+ jest . mock (
36+ '@/lib/security/requestGuards' ,
37+ ( ) => ( {
38+ __esModule : true ,
39+ applyRateLimit : applyRateLimitMock ,
40+ enforceBodySize : enforceBodySizeMock ,
41+ } ) ,
42+ { virtual : true } ,
43+ ) ;
44+
45+ const getClientIPMock = jest . fn < ( req : NextApiRequest ) => string > ( ) ;
46+
47+ jest . mock (
48+ '@/lib/security/rateLimit' ,
49+ ( ) => ( {
50+ __esModule : true ,
51+ getClientIP : getClientIPMock ,
52+ } ) ,
53+ { virtual : true } ,
54+ ) ;
55+
2856const createCallerMock = jest . fn ( ) ;
2957
3058jest . mock (
@@ -82,6 +110,40 @@ jest.mock(
82110 { virtual : true } ,
83111) ;
84112
113+ const shouldSubmitMultisigTxMock = jest . fn <
114+ ( wallet : unknown , signedAddressesCount : number ) => boolean
115+ > ( ) ;
116+ const submitTxWithScriptRecoveryMock = jest . fn <
117+ ( args : { txHex : string ; submitter : { submitTx : ( txHex : string ) => Promise < string > } } ) => Promise < { txHash : string ; txHex : string ; repaired : boolean } >
118+ > ( ) ;
119+ const createVkeyWitnessFromHexMock = jest . fn <
120+ ( keyHex : string , signatureHex : string ) => {
121+ publicKey : MockPublicKey ;
122+ signature : MockEd25519Signature ;
123+ witness : MockVkeywitness ;
124+ keyHashHex : string ;
125+ }
126+ > ( ) ;
127+ const addUniqueVkeyWitnessToTxMock = jest . fn <
128+ ( originalTxHex : string , witnessToAdd : MockVkeywitness ) => {
129+ txHex : string ;
130+ witnessAdded : boolean ;
131+ vkeyWitnesses : MockVkeywitnesses ;
132+ }
133+ > ( ) ;
134+
135+ jest . mock (
136+ '@/utils/txSignUtils' ,
137+ ( ) => ( {
138+ __esModule : true ,
139+ createVkeyWitnessFromHex : createVkeyWitnessFromHexMock ,
140+ addUniqueVkeyWitnessToTx : addUniqueVkeyWitnessToTxMock ,
141+ shouldSubmitMultisigTx : shouldSubmitMultisigTxMock ,
142+ submitTxWithScriptRecovery : submitTxWithScriptRecoveryMock ,
143+ } ) ,
144+ { virtual : true } ,
145+ ) ;
146+
85147const resolvePaymentKeyHashMock = jest . fn < ( address : string ) => string > ( ) ;
86148
87149jest . mock (
@@ -348,12 +410,19 @@ beforeEach(() => {
348410 dbTransactionUpdateManyMock . mockReset ( ) ;
349411 getProviderMock . mockReset ( ) ;
350412 addressToNetworkMock . mockReset ( ) ;
413+ shouldSubmitMultisigTxMock . mockReset ( ) ;
414+ submitTxWithScriptRecoveryMock . mockReset ( ) ;
415+ createVkeyWitnessFromHexMock . mockReset ( ) ;
416+ addUniqueVkeyWitnessToTxMock . mockReset ( ) ;
351417 resolvePaymentKeyHashMock . mockReset ( ) ;
352418 calculateTxHashMock . mockReset ( ) ;
353419 corsMock . mockReset ( ) ;
354420 addCorsCacheBustingHeadersMock . mockReset ( ) ;
355421 createCallerMock . mockReset ( ) ;
356422 verifyJwtMock . mockReset ( ) ;
423+ applyRateLimitMock . mockReset ( ) ;
424+ enforceBodySizeMock . mockReset ( ) ;
425+ getClientIPMock . mockReset ( ) ;
357426
358427 corsMock . mockResolvedValue ( undefined ) ;
359428 addCorsCacheBustingHeadersMock . mockImplementation ( ( ) => {
@@ -362,6 +431,57 @@ beforeEach(() => {
362431 calculateTxHashMock . mockReturnValue ( 'deadbeef' ) ;
363432 resolvePaymentKeyHashMock . mockReturnValue ( witnessKeyHashHex ) ;
364433 addressToNetworkMock . mockReturnValue ( 0 ) ;
434+ applyRateLimitMock . mockReturnValue ( true ) ;
435+ enforceBodySizeMock . mockReturnValue ( true ) ;
436+ getClientIPMock . mockReturnValue ( '127.0.0.1' ) ;
437+ shouldSubmitMultisigTxMock . mockReturnValue ( true ) ;
438+ createVkeyWitnessFromHexMock . mockImplementation ( ( keyHex , signatureHex ) => {
439+ const publicKey = MockPublicKey . from_hex ( keyHex ) ;
440+ const signature = MockEd25519Signature . from_hex ( signatureHex ) ;
441+ const witness = MockVkeywitness . new ( MockVkey . new ( publicKey ) , signature ) ;
442+ return {
443+ publicKey,
444+ signature,
445+ witness,
446+ keyHashHex : witnessKeyHashHex ,
447+ } ;
448+ } ) ;
449+ addUniqueVkeyWitnessToTxMock . mockImplementation ( ( originalTxHex , witnessToAdd ) => {
450+ const mergedWitnesses = MockVkeywitnesses . from_bytes ( ) ;
451+ const incomingKeyHash = Buffer . from (
452+ witnessToAdd . vkey ( ) . public_key ( ) . hash ( ) . to_bytes ( ) ,
453+ ) . toString ( 'hex' ) . toLowerCase ( ) ;
454+
455+ const existingWitnessCount = mergedWitnesses . len ( ) ;
456+ for ( let i = 0 ; i < existingWitnessCount ; i ++ ) {
457+ const existingWitness = mergedWitnesses . get ( i ) ;
458+ const existingKeyHash = Buffer . from (
459+ existingWitness . vkey ( ) . public_key ( ) . hash ( ) . to_bytes ( ) ,
460+ ) . toString ( 'hex' ) . toLowerCase ( ) ;
461+
462+ if ( existingKeyHash === incomingKeyHash ) {
463+ return {
464+ txHex : originalTxHex ,
465+ witnessAdded : false ,
466+ vkeyWitnesses : mergedWitnesses ,
467+ } ;
468+ }
469+ }
470+
471+ mergedWitnesses . add ( witnessToAdd ) ;
472+ mergedWitnesses . to_bytes ( ) ;
473+
474+ return {
475+ txHex : 'updated-tx-hex' ,
476+ witnessAdded : true ,
477+ vkeyWitnesses : mergedWitnesses ,
478+ } ;
479+ } ) ;
480+ submitTxWithScriptRecoveryMock . mockImplementation ( async ( { txHex, submitter } ) => ( {
481+ txHash : await submitter . submitTx ( txHex ) ,
482+ txHex,
483+ repaired : false ,
484+ } ) ) ;
365485
366486 createCallerMock . mockReturnValue ( {
367487 wallet : { getWallet : walletGetWalletMock } ,
@@ -445,13 +565,15 @@ describe('signTransaction API route', () => {
445565 expect ( addCorsCacheBustingHeadersMock ) . toHaveBeenCalledWith ( res ) ;
446566 expect ( corsMock ) . toHaveBeenCalledWith ( req , res ) ;
447567 expect ( verifyJwtMock ) . toHaveBeenCalledWith ( 'valid-token' ) ;
448- expect ( createCallerMock ) . toHaveBeenCalledWith ( {
449- db : dbMock ,
450- session : expect . objectContaining ( {
451- user : { id : address } ,
452- expires : expect . any ( String ) ,
568+ expect ( createCallerMock ) . toHaveBeenCalledWith (
569+ expect . objectContaining ( {
570+ db : dbMock ,
571+ session : expect . objectContaining ( {
572+ user : { id : address } ,
573+ expires : expect . any ( String ) ,
574+ } ) ,
453575 } ) ,
454- } ) ;
576+ ) ;
455577 expect ( walletGetWalletMock ) . toHaveBeenCalledWith ( { walletId, address } ) ;
456578 expect ( dbTransactionFindUniqueMock ) . toHaveBeenNthCalledWith ( 1 , {
457579 where : { id : transactionId } ,
@@ -694,5 +816,105 @@ describe('signTransaction API route', () => {
694816 expect ( dbTransactionUpdateManyMock ) . not . toHaveBeenCalled ( ) ;
695817 } ) ;
696818
819+ it ( 'persists repaired tx hex when script recovery succeeds' , async ( ) => {
820+ const address = 'addr_test1qprecoverysuccess' ;
821+ const walletId = 'wallet-id-recovery' ;
822+ const transactionId = 'transaction-id-recovery' ;
823+ const signatureHex = 'aa' . repeat ( 64 ) ;
824+ const keyHex = 'bb' . repeat ( 64 ) ;
825+
826+ verifyJwtMock . mockReturnValue ( { address } ) ;
827+
828+ walletGetWalletMock . mockResolvedValue ( {
829+ id : walletId ,
830+ type : 'atLeast' ,
831+ numRequiredSigners : 1 ,
832+ signersAddresses : [ address ] ,
833+ } ) ;
834+
835+ const transactionRecord = {
836+ id : transactionId ,
837+ walletId,
838+ state : 0 ,
839+ signedAddresses : [ ] as string [ ] ,
840+ rejectedAddresses : [ ] as string [ ] ,
841+ txCbor : 'stored-tx-hex' ,
842+ txHash : null as string | null ,
843+ txJson : '{}' ,
844+ } ;
845+
846+ const updatedTransaction = {
847+ ...transactionRecord ,
848+ signedAddresses : [ address ] ,
849+ txCbor : 'repaired-tx-hex' ,
850+ state : 1 ,
851+ txHash : 'recovered-hash' ,
852+ txJson : '{"multisig":{"state":1}}' ,
853+ } ;
854+
855+ dbTransactionFindUniqueMock
856+ . mockResolvedValueOnce ( transactionRecord )
857+ . mockResolvedValueOnce ( updatedTransaction ) ;
858+
859+ dbTransactionUpdateManyMock . mockResolvedValue ( { count : 1 } ) ;
860+
861+ const submitTxMock = jest . fn < ( txHex : string ) => Promise < string > > ( ) ;
862+ submitTxMock . mockResolvedValue ( 'should-not-be-used-directly' ) ;
863+ getProviderMock . mockReturnValue ( { submitTx : submitTxMock } ) ;
864+
865+ submitTxWithScriptRecoveryMock . mockResolvedValueOnce ( {
866+ txHash : 'recovered-hash' ,
867+ txHex : 'repaired-tx-hex' ,
868+ repaired : true ,
869+ } ) ;
870+
871+ const req = {
872+ method : 'POST' ,
873+ headers : { authorization : 'Bearer valid-token' } ,
874+ body : {
875+ walletId,
876+ transactionId,
877+ address,
878+ signature : signatureHex ,
879+ key : keyHex ,
880+ } ,
881+ } as unknown as NextApiRequest ;
882+
883+ const res = createMockResponse ( ) ;
884+
885+ await handler ( req , res ) ;
886+
887+ expect ( submitTxWithScriptRecoveryMock ) . toHaveBeenCalledWith (
888+ expect . objectContaining ( {
889+ txHex : 'updated-tx-hex' ,
890+ submitter : expect . objectContaining ( {
891+ submitTx : expect . any ( Function ) ,
892+ } ) ,
893+ } ) ,
894+ ) ;
895+ expect ( dbTransactionUpdateManyMock ) . toHaveBeenCalledWith ( {
896+ where : {
897+ id : transactionId ,
898+ signedAddresses : { equals : [ ] } ,
899+ rejectedAddresses : { equals : [ ] } ,
900+ txCbor : 'stored-tx-hex' ,
901+ txJson : '{}' ,
902+ } ,
903+ data : expect . objectContaining ( {
904+ signedAddresses : { set : [ address ] } ,
905+ rejectedAddresses : { set : [ ] } ,
906+ txCbor : 'repaired-tx-hex' ,
907+ state : 1 ,
908+ txHash : 'recovered-hash' ,
909+ } ) ,
910+ } ) ;
911+ expect ( res . status ) . toHaveBeenCalledWith ( 200 ) ;
912+ expect ( res . json ) . toHaveBeenCalledWith ( {
913+ transaction : updatedTransaction ,
914+ submitted : true ,
915+ txHash : 'recovered-hash' ,
916+ } ) ;
917+ } ) ;
918+
697919} ) ;
698920
0 commit comments