@@ -411,10 +411,21 @@ describe('ECDSA MPC v2', async () => {
411411 // serializedTxHex = full unsigned Avalanche atomic tx (codec type ID 0x0000)
412412 // signableHex = SHA-256(txBody) — 32 bytes, already the final signing hash
413413 //
414- // Reference sandbox output (c2p-tss):
414+ // Sandbox reference (coins-sandbox/flareCP/flrC_MPC_to_flrP_MPC):
415+ //
416+ // C→P direction (c2pMpcToMpcTss.ts — export from C-chain):
415417 // Message hash (SHA-256): 9b3e1c8fc9322b667ec61619487b3993e91dcfc5...
416418 // Signature r: d5bc2e2cad314023... s: 47af9d7109135f7a... Recovery: 1
417419 // Export TX ID: 2Z5ELShnmmMgvTeupzLQzEKtAgbvZkDvq6KRYqbzVgcyBGVGpb
420+ //
421+ // P→C direction (p2cMpcToMpcTss.ts — export from P-chain):
422+ // Threshold: 1 (MPC single-sig on-chain, NO hop transaction)
423+ // Message hash (SHA-256): f1afd7bb3df2019ee61b41334abf95172d469d18...
424+ // Signature r: fae44ca89e7a0d3effd0912c16d69735aabbc73ad2d140ffa2c3b46af48d159c
425+ // Signature s: 1dec05d0d477a5b245a0a2e5f3a67e75489ff9b98b29780fc757b12d9f687db3
426+ // Recovery: 0
427+ // Export TX ID: 2tDQmQUtDMyVWe8Bo36yHXykV2RMvh8rft3to5QsgoNhATMDXz
428+ // Network: Coston2 Testnet (ID: 114)
418429 const serializedTxHex =
419430 '0000000000010000007278db5c30bed04c05ce209179812850bbb3fe6d46d7eef3744d814c0da5552479' +
420431 '00000000000000000000000000000000000000000000000000000000000000000000000128a05933dc76' +
@@ -572,14 +583,155 @@ describe('ECDSA MPC v2', async () => {
572583 false // shouldHash=false: message is already SHA-256(txBody)
573584 ) ;
574585 assert . ok ( convertedSignature , 'Pre-hashed Avalanche atomic signature is not valid' ) ;
575- // Format: recid:R_hex:S_hex:publicKey_hex (same as sandbox 65-byte r+s+recovery)
586+ // Format: recid:R_hex:S_hex:publicKey_hex
587+ // Sandbox produces the same structure — e.g. P→C export:
588+ // r: fae44ca89e7a0d3effd0912c16d69735aabbc73ad2d140ffa2c3b46af48d159c (32 bytes)
589+ // s: 1dec05d0d477a5b245a0a2e5f3a67e75489ff9b98b29780fc757b12d9f687db3 (32 bytes)
590+ // Recovery: 0
576591 const sigParts = convertedSignature . split ( ':' ) ;
577592 assert . strictEqual ( sigParts . length , 4 , 'Signature must be recid:R:S:pubkey format' ) ;
578593 assert . ok ( [ '0' , '1' ] . includes ( sigParts [ 0 ] ) , 'Recovery ID must be 0 or 1' ) ;
579594 assert . strictEqual ( sigParts [ 1 ] . length , 64 , 'Signature R must be 32 bytes hex' ) ;
580595 assert . strictEqual ( sigParts [ 2 ] . length , 64 , 'Signature S must be 32 bytes hex' ) ;
581596 } ) ;
582597
598+ it ( 'signRequestBase (hot wallet path) should skip keccak256 for Avalanche atomic tx' , async ( ) => {
599+ const serializedTxHex =
600+ '0000000000010000007278db5c30bed04c05ce209179812850bbb3fe6d46d7eef3744d814c0da5552479' +
601+ '00000000000000000000000000000000000000000000000000000000000000000000000128a05933dc76' +
602+ 'e4e6c25f35d5c9b2a58769700e760000000002ff3d1658734f94af871c3d131b56131b6fb7a0291eac' +
603+ 'add261e69dfb42a9cdf6f7fddd00000000000000090000000158734f94af871c3d131b56131b6fb7a029' +
604+ '1eacadd261e69dfb42a9cdf6f7fddd000000070000000002faf08000000000000000000000000200000003' +
605+ '12cb32eaf92553064db98d271b56cba079ec78f5a6e0c1abd0132f70efb77e2274637ff336a29a57c386' +
606+ 'd58d09a9ae77cf1cf07bf1c9de44ebb0c9f3' ;
607+ const signableHex = createHash ( 'sha256' ) . update ( Buffer . from ( serializedTxHex , 'hex' ) ) . digest ( 'hex' ) ;
608+ const derivationPath = 'm/0' ;
609+
610+ const mockBgWithPost = { } as BitGoBase ;
611+ mockBgWithPost . getEnv = sinon . stub ( ) . returns ( 'test' ) ;
612+ mockBgWithPost . setRequestTracer = sinon . stub ( ) ;
613+ mockBgWithPost . encrypt = sinon . stub ( ) . returns ( 'encrypted' ) ;
614+ mockBgWithPost . decrypt = sinon . stub ( ) . returns ( 'decrypted' ) ;
615+ mockBgWithPost . post = sinon . stub ( ) . returns ( {
616+ send : sinon . stub ( ) . returnsThis ( ) ,
617+ set : sinon . stub ( ) . returnsThis ( ) ,
618+ result : sinon . stub ( ) . rejects ( new Error ( 'mock: HTTP not available' ) ) ,
619+ } ) ;
620+
621+ const hashFunctionSpy = sinon . stub ( ) . callsFake ( ( ) => createKeccakHash ( 'keccak256' ) as Hash ) ;
622+ const mockCoinForHotWallet = {
623+ getHashFunction : hashFunctionSpy ,
624+ verifyTransaction : sinon . stub ( ) . resolves ( true ) ,
625+ getMPCAlgorithm : sinon . stub ( ) . returns ( 'ecdsa' ) ,
626+ getConfig : sinon . stub ( ) . returns ( { family : 'flrp' } ) ,
627+ } as unknown as IBaseCoin ;
628+
629+ const mockWallet = {
630+ id : sinon . stub ( ) . returns ( walletID ) ,
631+ multisigType : sinon . stub ( ) . returns ( 'tss' ) ,
632+ multisigTypeVersion : sinon . stub ( ) . returns ( 'MPCv2' ) ,
633+ } ;
634+
635+ const hotWalletUtils = new EcdsaMPCv2Utils ( mockBgWithPost , mockCoinForHotWallet , mockWallet as any ) ;
636+ sinon . stub ( hotWalletUtils as any , 'pickBitgoPubGpgKeyForSigning' ) . resolves ( bitgoGpgKey . public ) ;
637+
638+ const txRequest = {
639+ txRequestId : 'flrp-export-test' ,
640+ apiVersion : 'full' ,
641+ walletId : walletID ,
642+ transactions : [
643+ {
644+ unsignedTx : {
645+ derivationPath,
646+ signableHex,
647+ serializedTxHex,
648+ } ,
649+ signatureShares : [ ] ,
650+ } ,
651+ ] ,
652+ } as unknown as TxRequest ;
653+
654+ try {
655+ await hotWalletUtils . signTxRequest ( {
656+ txRequest,
657+ prv : userShare . toString ( 'base64' ) ,
658+ reqId : { inc : sinon . stub ( ) , toString : sinon . stub ( ) . returns ( 'test-req' ) } as any ,
659+ } ) ;
660+ } catch ( e ) { }
661+
662+ assert . strictEqual (
663+ hashFunctionSpy . callCount ,
664+ 0 ,
665+ 'getHashFunction must NOT be called for Avalanche atomic tx (serializedTxHex starts with 0000)'
666+ ) ;
667+ } ) ;
668+
669+ it ( 'signRequestBase (hot wallet path) should apply keccak256 for regular EVM tx' , async ( ) => {
670+ const serializedTxHex = 'f86c808504a817c80082520894' + '00' . repeat ( 20 ) + '80808080' ;
671+ const signableHex = serializedTxHex ;
672+ const derivationPath = 'm/0' ;
673+
674+ assert . ok ( ! serializedTxHex . startsWith ( '0000' ) , 'EVM tx must not start with Avalanche prefix' ) ;
675+
676+ const mockBgWithPost = { } as BitGoBase ;
677+ mockBgWithPost . getEnv = sinon . stub ( ) . returns ( 'test' ) ;
678+ mockBgWithPost . setRequestTracer = sinon . stub ( ) ;
679+ mockBgWithPost . encrypt = sinon . stub ( ) . returns ( 'encrypted' ) ;
680+ mockBgWithPost . decrypt = sinon . stub ( ) . returns ( 'decrypted' ) ;
681+ mockBgWithPost . post = sinon . stub ( ) . returns ( {
682+ send : sinon . stub ( ) . returnsThis ( ) ,
683+ set : sinon . stub ( ) . returnsThis ( ) ,
684+ result : sinon . stub ( ) . rejects ( new Error ( 'mock: HTTP not available' ) ) ,
685+ } ) ;
686+
687+ const hashFunctionSpy = sinon . stub ( ) . callsFake ( ( ) => createKeccakHash ( 'keccak256' ) as Hash ) ;
688+ const mockCoinForEvmWallet = {
689+ getHashFunction : hashFunctionSpy ,
690+ verifyTransaction : sinon . stub ( ) . resolves ( true ) ,
691+ getMPCAlgorithm : sinon . stub ( ) . returns ( 'ecdsa' ) ,
692+ getConfig : sinon . stub ( ) . returns ( { family : 'flr' } ) ,
693+ } as unknown as IBaseCoin ;
694+
695+ const mockWallet = {
696+ id : sinon . stub ( ) . returns ( walletID ) ,
697+ multisigType : sinon . stub ( ) . returns ( 'tss' ) ,
698+ multisigTypeVersion : sinon . stub ( ) . returns ( 'MPCv2' ) ,
699+ } ;
700+
701+ const evmUtils = new EcdsaMPCv2Utils ( mockBgWithPost , mockCoinForEvmWallet , mockWallet as any ) ;
702+ sinon . stub ( evmUtils as any , 'pickBitgoPubGpgKeyForSigning' ) . resolves ( bitgoGpgKey . public ) ;
703+
704+ const txRequest = {
705+ txRequestId : 'flr-evm-test' ,
706+ apiVersion : 'full' ,
707+ walletId : walletID ,
708+ transactions : [
709+ {
710+ unsignedTx : {
711+ derivationPath,
712+ signableHex,
713+ serializedTxHex,
714+ } ,
715+ signatureShares : [ ] ,
716+ } ,
717+ ] ,
718+ } as unknown as TxRequest ;
719+
720+ try {
721+ await evmUtils . signTxRequest ( {
722+ txRequest,
723+ prv : userShare . toString ( 'base64' ) ,
724+ reqId : { inc : sinon . stub ( ) , toString : sinon . stub ( ) . returns ( 'test-req' ) } as any ,
725+ } ) ;
726+ } catch ( e ) { }
727+
728+ assert . strictEqual (
729+ hashFunctionSpy . callCount ,
730+ 1 ,
731+ 'getHashFunction must be called for regular EVM tx (serializedTxHex does not start with 0000)'
732+ ) ;
733+ } ) ;
734+
583735 it ( 'should still apply keccak256 for regular FLR EVM transactions' , async ( ) => {
584736 // Regular EVM transaction on FLR (e.g. token transfer, not cross-chain).
585737 // serializedTxHex starts with 'f8' (RLP prefix), NOT '0000'.
0 commit comments