Skip to content

Commit ab22209

Browse files
authored
Merge pull request #8821 from BitGo/CECHO-1140
feat(sdk-coin-flrp): enhance txn verification for atomic transactions
2 parents 5a82bae + b271b3d commit ab22209

3 files changed

Lines changed: 272 additions & 14 deletions

File tree

modules/sdk-coin-flrp/test/unit/flrp.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -959,5 +959,94 @@ describe('Flrp test cases', function () {
959959
isVerified.should.equal(true);
960960
});
961961
});
962+
963+
describe('verifyTransaction with TSS wallet (Avalanche atomic)', () => {
964+
it('should verify TSS ExportInP when passed serializedTxHex (not signableHex)', async () => {
965+
// The SDK's signRequestBase now passes serializedTxHex (the full
966+
// PVM transaction bytes) to verifyTransaction for Avalanche atomic txs,
967+
// instead of signableHex (a 32-byte SHA-256 hash that can't be parsed).
968+
// This test confirms verifyTransaction succeeds with the actual tx bytes.
969+
const txHex = await buildUnsignedExportInP();
970+
const txPrebuild = { txHex, txInfo: {} };
971+
const txParams = {
972+
recipients: [{ address: ON_CHAIN_TEST_WALLET.user.pChainAddress, amount: '30000000' }],
973+
type: 'Export',
974+
locktime: 0,
975+
};
976+
977+
const isVerified = await basecoin.verifyTransaction({
978+
txParams,
979+
txPrebuild,
980+
walletType: 'tss',
981+
});
982+
isVerified.should.equal(true);
983+
});
984+
985+
it('should verify TSS ImportInP when passed serializedTxHex', async () => {
986+
const txHex = await buildUnsignedImportInP();
987+
const txPrebuild = { txHex, txInfo: {} };
988+
const txParams = {
989+
recipients: [{ address: ON_CHAIN_TEST_WALLET.user.pChainAddress, amount: '1' }],
990+
type: 'Import',
991+
locktime: 0,
992+
};
993+
994+
const isVerified = await basecoin.verifyTransaction({
995+
txParams,
996+
txPrebuild,
997+
walletType: 'tss',
998+
});
999+
isVerified.should.equal(true);
1000+
});
1001+
1002+
it('should verify TSS ImportInC when passed serializedTxHex', async () => {
1003+
const txHex = await buildUnsignedImportInC();
1004+
const txPrebuild = { txHex, txInfo: {} };
1005+
const txParams = {
1006+
recipients: [{ address: '0x96993BAEb6AaE2e06BF95F144e2775D4f8efbD35', amount: '1' }],
1007+
type: 'ImportToC',
1008+
locktime: 0,
1009+
};
1010+
1011+
const isVerified = await basecoin.verifyTransaction({
1012+
txParams,
1013+
txPrebuild,
1014+
walletType: 'tss',
1015+
});
1016+
isVerified.should.equal(true);
1017+
});
1018+
1019+
it('should verify signablePayload is SHA-256 of serialized tx (sandbox-verified)', async () => {
1020+
// unsignedTx.toBytes() → sha256 → MPC.sign()
1021+
// The signablePayload must be exactly 32 bytes (SHA-256 digest).
1022+
// This is what the WP stores as signableHex and what ecdsaMPCv2.ts
1023+
// must use directly (no keccak256 re-hashing).
1024+
const txHex = await buildUnsignedExportInP();
1025+
const payload = await basecoin.getSignablePayload(txHex);
1026+
1027+
// signablePayload is SHA-256(txBody) — always 32 bytes
1028+
assert.strictEqual(payload.length, 32, 'signablePayload must be 32 bytes (SHA-256)');
1029+
1030+
// The serializedTxHex (after stripping 0x) starts with Avalanche codec '0000'
1031+
// This is how ecdsaMPCv2.ts detects atomic transactions
1032+
const rawHex = txHex.startsWith('0x') ? txHex.substring(2) : txHex;
1033+
assert.ok(rawHex.startsWith('0000'), 'Avalanche atomic tx must start with codec prefix 0000');
1034+
});
1035+
1036+
it('should reject a SHA-256 hash as txHex (proves the fix is needed)', async () => {
1037+
const sha256Hash = 'a'.repeat(64);
1038+
const txPrebuild = { txHex: sha256Hash, txInfo: {} };
1039+
const txParams = { recipients: [], type: 'Export', locktime: 0 };
1040+
1041+
let threw = false;
1042+
try {
1043+
await basecoin.verifyTransaction({ txParams, txPrebuild, walletType: 'tss' });
1044+
} catch (e) {
1045+
threw = true;
1046+
assert(e.message.includes('Invalid transaction'), `Expected 'Invalid transaction', got: ${e.message}`);
1047+
}
1048+
assert(threw, 'Expected verifyTransaction to throw for SHA-256 hash as txHex');
1049+
});
1050+
});
9621051
});
9631052
});

modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -790,6 +790,7 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils {
790790
let txOrMessageToSign;
791791
let derivationPath;
792792
let bufferContent;
793+
let serializedTxHex: string | undefined;
793794
const userGpgKey = await generateGPGKeyPair('secp256k1');
794795
const bitgoGpgPubKey = await this.pickBitgoPubGpgKeyForSigning(true, params.reqId, txRequest.enterpriseId);
795796

@@ -812,11 +813,16 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils {
812813
);
813814
}
814815

815-
// For ICP transactions, the HSM signs the serializedTxHex, while the user signs the signableHex separately.
816-
// Verification cannot be performed directly on the signableHex alone. However, we can parse the serializedTxHex
817-
// to regenerate the signableHex and compare it against the provided value for verification.
818-
// In contrast, for other coin families, verification is typically done using just the signableHex.
819-
if (this.baseCoin.getConfig().family === 'icp') {
816+
// For ICP and Avalanche atomic transactions, signableHex is a digest (not
817+
// a parseable transaction). Pass serializedTxHex so verifyTransaction can
818+
// parse the full transaction bytes.
819+
// - ICP: signableHex is a hash; serializedTxHex is the CBOR-encoded tx.
820+
// - Avalanche atomic (FLRP/FLR cross-chain): signableHex is SHA-256(txBody);
821+
// serializedTxHex is the PVM/EVM atomic tx (codec prefix 0x0000).
822+
// For all other coins, signableHex IS the unsigned transaction (e.g. RLP bytes).
823+
const isIcp = this.baseCoin.getConfig().family === 'icp';
824+
const isAvalancheAtomic = unsignedTx.serializedTxHex && unsignedTx.serializedTxHex.startsWith('0000');
825+
if (isIcp || isAvalancheAtomic) {
820826
await this.baseCoin.verifyTransaction({
821827
txPrebuild: { txHex: unsignedTx.serializedTxHex, txInfo: unsignedTx.signableHex },
822828
txParams: params.txParams || { recipients: [] },
@@ -833,6 +839,7 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils {
833839
}
834840
txOrMessageToSign = unsignedTx.signableHex;
835841
derivationPath = unsignedTx.derivationPath;
842+
serializedTxHex = unsignedTx.serializedTxHex;
836843
bufferContent = Buffer.from(txOrMessageToSign, 'hex');
837844
} else if (requestType === RequestType.message) {
838845
txOrMessageToSign = txRequest.messages![0].messageEncoded;
@@ -842,14 +849,24 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils {
842849
throw new Error('Invalid request type');
843850
}
844851

845-
let hash: Hash;
846-
try {
847-
hash = this.baseCoin.getHashFunction();
848-
} catch (err) {
849-
hash = createKeccakHash('keccak256') as Hash;
852+
// For Avalanche atomic transactions (cross-chain export/import between
853+
// C-chain and P-chain), signableHex is already SHA-256(txBody) — a 32-byte
854+
// pre-hashed digest. Use it directly as the DKLS message hash instead of
855+
// applying the coin's hash function (keccak256 for EVM coins).
856+
// Same logic as getHashStringAndDerivationPath (external signer path).
857+
let hashBuffer: Buffer;
858+
if (serializedTxHex && serializedTxHex.startsWith('0000')) {
859+
hashBuffer = bufferContent;
860+
assert(hashBuffer.length === 32, `Avalanche pre-hashed signableHex must be 32 bytes, got ${hashBuffer.length}`);
861+
} else {
862+
let hash: Hash;
863+
try {
864+
hash = this.baseCoin.getHashFunction();
865+
} catch (err) {
866+
hash = createKeccakHash('keccak256') as Hash;
867+
}
868+
hashBuffer = hash.update(bufferContent).digest();
850869
}
851-
// check what the encoding is supposed to be for message
852-
const hashBuffer = hash.update(bufferContent).digest();
853870
const otherSigner = new DklsDsg.Dsg(
854871
userKeyShare,
855872
params.mpcv2PartyId !== undefined ? params.mpcv2PartyId : 0,

modules/sdk-core/test/unit/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts

Lines changed: 154 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)