Skip to content

Commit 9973cbc

Browse files
committed
feat(sdk-core): add support for preHashed flr atomic txn in EcdsaMPCv2Utils
Ticket: CECHO-1138
1 parent f226c24 commit 9973cbc

2 files changed

Lines changed: 336 additions & 1 deletion

File tree

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1013,17 +1013,33 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils {
10131013
): { hashBuffer: Buffer; derivationPath: string } {
10141014
let txToSign: string;
10151015
let derivationPath: string;
1016+
let serializedTxHex: string | undefined;
10161017
if (requestType === RequestType.tx) {
10171018
assert(txRequest.transactions && txRequest.transactions.length === 1, 'Unable to find transactions in txRequest');
10181019
txToSign = txRequest.transactions[0].unsignedTx.signableHex;
10191020
derivationPath = txRequest.transactions[0].unsignedTx.derivationPath;
1021+
serializedTxHex = txRequest.transactions[0].unsignedTx.serializedTxHex;
10201022
} else if (requestType === RequestType.message) {
10211023
// TODO(WP-2176): Add support for message signing
10221024
throw new Error('MPCv2 message signing not supported yet.');
10231025
} else {
10241026
throw new Error('Invalid request type, got: ' + requestType);
10251027
}
10261028

1029+
// For Avalanche atomic transactions (cross-chain export/import between
1030+
// C-chain and P-chain), signableHex is already SHA-256(txBody) — a 32-byte
1031+
// pre-hashed digest. Use it directly as the DKLS message hash instead of
1032+
// applying the coin's hash function (keccak256 for EVM coins).
1033+
// This matches the WP/HSM BitGo-party behaviour (MPCv2Signer.isPreHashed)
1034+
// so both DKLS parties agree on the same message hash.
1035+
// Detection: Avalanche codec type ID prefix is 0x0000; standard EVM RLP
1036+
// starts with 0xf8xx, so there is no collision.
1037+
if (serializedTxHex && serializedTxHex.startsWith('0000')) {
1038+
const hashBuffer = Buffer.from(txToSign, 'hex');
1039+
assert(hashBuffer.length === 32, `Avalanche pre-hashed signableHex must be 32 bytes, got ${hashBuffer.length}`);
1040+
return { hashBuffer, derivationPath };
1041+
}
1042+
10271043
let hash: Hash;
10281044
try {
10291045
hash = this.baseCoin.getHashFunction();

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

Lines changed: 320 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as assert from 'assert';
22
import * as sinon from 'sinon';
3-
import { Hash, randomBytes } from 'crypto';
3+
import { Hash, createHash, randomBytes } from 'crypto';
44
import createKeccakHash from 'keccak';
55
import {
66
MPCv2PartyFromStringOrNumber,
@@ -401,6 +401,325 @@ describe('ECDSA MPC v2', async () => {
401401
.createOfflineRound2Share(reqMPCv2SigningMsg2Round2WithMsg1Session as any)
402402
.should.be.rejectedWith('Error while creating messages from party 0, round 2: Error: Invalid final_session_id');
403403
});
404+
405+
it('should sign a pre-hashed Avalanche atomic export tx without applying keccak256', async () => {
406+
// Real Avalanche ExportInC transaction from sdk-coin-flr test resources.
407+
// Mirrors the sandbox flow: c2pMpcToMpcTss.ts → signWithMpc() where
408+
// FlareJS builds an ExportInC tx, SHA-256 hashes the unsigned bytes,
409+
// and MPC signs the raw SHA-256 hash (NOT keccak256 of it).
410+
//
411+
// serializedTxHex = full unsigned Avalanche atomic tx (codec type ID 0x0000)
412+
// signableHex = SHA-256(txBody) — 32 bytes, already the final signing hash
413+
//
414+
// Reference sandbox output (c2p-tss):
415+
// Message hash (SHA-256): 9b3e1c8fc9322b667ec61619487b3993e91dcfc5...
416+
// Signature r: d5bc2e2cad314023... s: 47af9d7109135f7a... Recovery: 1
417+
// Export TX ID: 2Z5ELShnmmMgvTeupzLQzEKtAgbvZkDvq6KRYqbzVgcyBGVGpb
418+
const serializedTxHex =
419+
'0000000000010000007278db5c30bed04c05ce209179812850bbb3fe6d46d7eef3744d814c0da5552479' +
420+
'00000000000000000000000000000000000000000000000000000000000000000000000128a05933dc76' +
421+
'e4e6c25f35d5c9b2a58769700e760000000002ff3d1658734f94af871c3d131b56131b6fb7a0291eac' +
422+
'add261e69dfb42a9cdf6f7fddd00000000000000090000000158734f94af871c3d131b56131b6fb7a029' +
423+
'1eacadd261e69dfb42a9cdf6f7fddd000000070000000002faf08000000000000000000000000200000003' +
424+
'12cb32eaf92553064db98d271b56cba079ec78f5a6e0c1abd0132f70efb77e2274637ff336a29a57c386' +
425+
'd58d09a9ae77cf1cf07bf1c9de44ebb0c9f3';
426+
// SHA-256(serializedTxHex bytes) — same as FlareJS unsignedTx.toBytes() → sha256
427+
const signableHex = createHash('sha256').update(Buffer.from(serializedTxHex, 'hex')).digest('hex');
428+
const derivationPath = 'm/0';
429+
430+
// Validate fixture properties match sandbox expectations:
431+
// - serializedTxHex starts with Avalanche codec type ID (0x0000)
432+
// - signableHex is exactly 64 hex chars (32-byte SHA-256 digest)
433+
assert.ok(serializedTxHex.startsWith('0000'), 'Fixture must start with Avalanche codec prefix');
434+
assert.strictEqual(signableHex.length, 64, 'signableHex must be 32-byte SHA-256 (64 hex chars)');
435+
// Verify keccak256(signableHex) differs — proves skipping hash matters
436+
const keccakHash = createKeccakHash('keccak256').update(Buffer.from(signableHex, 'hex')).digest('hex');
437+
assert.notStrictEqual(signableHex, keccakHash, 'SHA-256 and keccak256 hashes must differ');
438+
439+
// round 1
440+
const reqMPCv2SigningRound1 = {
441+
txRequest: {
442+
txRequestId: 'flr-export-c2p',
443+
apiVersion: 'full',
444+
walletId: walletID,
445+
transactions: [
446+
{
447+
unsignedTx: {
448+
derivationPath,
449+
signableHex,
450+
serializedTxHex,
451+
},
452+
signatureShares: [],
453+
},
454+
],
455+
},
456+
prv: userShare.toString('base64'),
457+
walletPassphrase,
458+
};
459+
460+
const resMPCv2SigningRound1 = await ecdsaMPCv2Utils.createOfflineRound1Share(reqMPCv2SigningRound1 as any);
461+
resMPCv2SigningRound1.should.have.property('signatureShareRound1');
462+
resMPCv2SigningRound1.should.have.property('encryptedRound1Session');
463+
resMPCv2SigningRound1.should.have.property('encryptedUserGpgPrvKey');
464+
465+
const encryptedRound1Session = resMPCv2SigningRound1.encryptedRound1Session;
466+
const encryptedUserGpgPrvKey = resMPCv2SigningRound1.encryptedUserGpgPrvKey;
467+
468+
// BitGo/HSM party uses the raw SHA-256 hash directly (no keccak256).
469+
// This matches WP's MPCv2Signer isPreHashed=true path where
470+
// txHash = signableMaterial (raw signableHex bytes).
471+
// If the SDK incorrectly applied keccak256, user party would use
472+
// keccak256(SHA-256(txBody)) while HSM uses SHA-256(txBody) — the
473+
// two DKLS parties would disagree, producing an invalid combined sig.
474+
const hashBuffer = Buffer.from(signableHex, 'hex');
475+
assert.strictEqual(hashBuffer.length, 32, 'DKLS message hash must be 32 bytes');
476+
const bitgoSession = new DklsDsg.Dsg(bitgoShare, 2, derivationPath, hashBuffer);
477+
478+
const txRequestRound1 = await signBitgoMPCv2Round1(
479+
bitgoSession,
480+
reqMPCv2SigningRound1.txRequest as any,
481+
resMPCv2SigningRound1.signatureShareRound1,
482+
resMPCv2SigningRound1.userGpgPubKey
483+
);
484+
assert.ok(
485+
txRequestRound1.transactions &&
486+
txRequestRound1.transactions.length === 1 &&
487+
txRequestRound1.transactions[0].signatureShares.length === 2
488+
);
489+
490+
// round 2
491+
const reqMPCv2SigningRound2 = {
492+
...reqMPCv2SigningRound1,
493+
txRequest: txRequestRound1,
494+
encryptedRound1Session,
495+
encryptedUserGpgPrvKey,
496+
bitgoPublicGpgKey: bitgoGpgKey.public,
497+
};
498+
499+
const resMPCv2SigningRound2 = await ecdsaMPCv2Utils.createOfflineRound2Share(reqMPCv2SigningRound2 as any);
500+
resMPCv2SigningRound2.should.have.property('signatureShareRound2');
501+
resMPCv2SigningRound2.should.have.property('encryptedRound2Session');
502+
503+
const encryptedRound2Session = resMPCv2SigningRound2.encryptedRound2Session;
504+
505+
const { txRequest: txRequestRound2, bitgoMsg4 } = await signBitgoMPCv2Round2(
506+
bitgoSession,
507+
reqMPCv2SigningRound2.txRequest,
508+
resMPCv2SigningRound2.signatureShareRound2,
509+
resMPCv2SigningRound1.userGpgPubKey
510+
);
511+
assert.ok(
512+
txRequestRound2.transactions &&
513+
txRequestRound2.transactions.length === 1 &&
514+
txRequestRound2.transactions[0].signatureShares.length === 4
515+
);
516+
bitgoMsg4.should.have.property('signatureR');
517+
518+
// round 3
519+
const reqMPCv2SigningRound3 = {
520+
...reqMPCv2SigningRound2,
521+
txRequest: txRequestRound2,
522+
encryptedRound1Session: null,
523+
encryptedRound2Session,
524+
};
525+
526+
const resMPCv2SigningRound3 = await ecdsaMPCv2Utils.createOfflineRound3Share(reqMPCv2SigningRound3 as any);
527+
resMPCv2SigningRound3.should.have.property('signatureShareRound3');
528+
529+
const { userMsg4 } = await signBitgoMPCv2Round3(
530+
bitgoSession,
531+
resMPCv2SigningRound3.signatureShareRound3,
532+
resMPCv2SigningRound1.userGpgPubKey
533+
);
534+
535+
// Both parties must produce matching R values for a valid combined signature
536+
assert.ok(userMsg4.data.msg4.signatureR === bitgoMsg4.signatureR, 'User and BitGo signaturesR do not match');
537+
538+
const deserializedBitgoMsg4 = DklsTypes.deserializeMessages({
539+
p2pMessages: [],
540+
broadcastMessages: [bitgoMsg4],
541+
});
542+
543+
const deserializedUserMsg4 = DklsTypes.deserializeMessages({
544+
p2pMessages: [],
545+
broadcastMessages: [
546+
{
547+
from: userMsg4.data.msg4.from,
548+
payload: userMsg4.data.msg4.message,
549+
},
550+
],
551+
});
552+
553+
const combinedSigUsingUtil = DklsUtils.combinePartialSignatures(
554+
[deserializedUserMsg4.broadcastMessages[0].payload, deserializedBitgoMsg4.broadcastMessages[0].payload],
555+
Buffer.from(userMsg4.data.msg4.signatureR, 'base64').toString('hex')
556+
);
557+
558+
// Combined signature must have valid R and S components (32 bytes each)
559+
assert.strictEqual(combinedSigUsingUtil.R.length, 32, 'Signature R must be 32 bytes');
560+
assert.strictEqual(combinedSigUsingUtil.S.length, 32, 'Signature S must be 32 bytes');
561+
562+
// Verify with shouldHash=false — signableHex is already SHA-256(txBody).
563+
// This mirrors WP's combineSigSharesMPCv2 where shouldHash=false for
564+
// pre-hashed Avalanche atomic transactions (isSignablePreHashed=true).
565+
// On-chain, Avalanche verifies: ecdsaRecover(SHA-256(txBody)) == signerPubKey
566+
const convertedSignature = DklsUtils.verifyAndConvertDklsSignature(
567+
Buffer.from(signableHex, 'hex'),
568+
combinedSigUsingUtil,
569+
DklsTypes.getCommonKeychain(userShare),
570+
derivationPath,
571+
createHash('sha256') as Hash,
572+
false // shouldHash=false: message is already SHA-256(txBody)
573+
);
574+
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)
576+
const sigParts = convertedSignature.split(':');
577+
assert.strictEqual(sigParts.length, 4, 'Signature must be recid:R:S:pubkey format');
578+
assert.ok(['0', '1'].includes(sigParts[0]), 'Recovery ID must be 0 or 1');
579+
assert.strictEqual(sigParts[1].length, 64, 'Signature R must be 32 bytes hex');
580+
assert.strictEqual(sigParts[2].length, 64, 'Signature S must be 32 bytes hex');
581+
});
582+
583+
it('should still apply keccak256 for regular FLR EVM transactions', async () => {
584+
// Regular EVM transaction on FLR (e.g. token transfer, not cross-chain).
585+
// serializedTxHex starts with 'f8' (RLP prefix), NOT '0000'.
586+
// The SDK must apply keccak256 as the hash function — standard EVM path.
587+
// Use valid hex for signableHex (unlike the pre-existing 'testMessage' pattern
588+
// in earlier tests) so keccak256 operates on a realistic byte buffer.
589+
const serializedTxHex = 'f86c808504a817c80082520894' + '00'.repeat(20) + '80808080';
590+
const signableHex = serializedTxHex; // In EVM, signableHex is the RLP-encoded unsigned tx
591+
const derivationPath = 'm/0';
592+
593+
// Verify fixture does NOT trigger the Avalanche detection
594+
assert.ok(!serializedTxHex.startsWith('0000'), 'EVM tx must not start with Avalanche prefix');
595+
596+
// round 1
597+
const reqMPCv2SigningRound1 = {
598+
txRequest: {
599+
txRequestId: 'flr-evm-transfer',
600+
apiVersion: 'full',
601+
walletId: walletID,
602+
transactions: [
603+
{
604+
unsignedTx: {
605+
derivationPath,
606+
signableHex,
607+
serializedTxHex,
608+
},
609+
signatureShares: [],
610+
},
611+
],
612+
},
613+
prv: userShare.toString('base64'),
614+
walletPassphrase,
615+
};
616+
617+
const resMPCv2SigningRound1 = await ecdsaMPCv2Utils.createOfflineRound1Share(reqMPCv2SigningRound1 as any);
618+
resMPCv2SigningRound1.should.have.property('signatureShareRound1');
619+
resMPCv2SigningRound1.should.have.property('encryptedRound1Session');
620+
resMPCv2SigningRound1.should.have.property('encryptedUserGpgPrvKey');
621+
622+
const encryptedRound1Session = resMPCv2SigningRound1.encryptedRound1Session;
623+
const encryptedUserGpgPrvKey = resMPCv2SigningRound1.encryptedUserGpgPrvKey;
624+
625+
// BitGo party uses keccak256(signableHex) — standard EVM path.
626+
// Both SDK and WP/HSM apply keccak256 for regular EVM transactions.
627+
const hashBuffer = createKeccakHash('keccak256').update(Buffer.from(signableHex, 'hex')).digest();
628+
const bitgoSession = new DklsDsg.Dsg(bitgoShare, 2, derivationPath, hashBuffer);
629+
630+
const txRequestRound1 = await signBitgoMPCv2Round1(
631+
bitgoSession,
632+
reqMPCv2SigningRound1.txRequest as any,
633+
resMPCv2SigningRound1.signatureShareRound1,
634+
resMPCv2SigningRound1.userGpgPubKey
635+
);
636+
assert.ok(
637+
txRequestRound1.transactions &&
638+
txRequestRound1.transactions.length === 1 &&
639+
txRequestRound1.transactions[0].signatureShares.length === 2
640+
);
641+
642+
// round 2
643+
const reqMPCv2SigningRound2 = {
644+
...reqMPCv2SigningRound1,
645+
txRequest: txRequestRound1,
646+
encryptedRound1Session,
647+
encryptedUserGpgPrvKey,
648+
bitgoPublicGpgKey: bitgoGpgKey.public,
649+
};
650+
651+
const resMPCv2SigningRound2 = await ecdsaMPCv2Utils.createOfflineRound2Share(reqMPCv2SigningRound2 as any);
652+
resMPCv2SigningRound2.should.have.property('signatureShareRound2');
653+
resMPCv2SigningRound2.should.have.property('encryptedRound2Session');
654+
655+
const encryptedRound2Session = resMPCv2SigningRound2.encryptedRound2Session;
656+
657+
const { txRequest: txRequestRound2, bitgoMsg4 } = await signBitgoMPCv2Round2(
658+
bitgoSession,
659+
reqMPCv2SigningRound2.txRequest,
660+
resMPCv2SigningRound2.signatureShareRound2,
661+
resMPCv2SigningRound1.userGpgPubKey
662+
);
663+
assert.ok(
664+
txRequestRound2.transactions &&
665+
txRequestRound2.transactions.length === 1 &&
666+
txRequestRound2.transactions[0].signatureShares.length === 4
667+
);
668+
bitgoMsg4.should.have.property('signatureR');
669+
670+
// round 3
671+
const reqMPCv2SigningRound3 = {
672+
...reqMPCv2SigningRound2,
673+
txRequest: txRequestRound2,
674+
encryptedRound1Session: null,
675+
encryptedRound2Session,
676+
};
677+
678+
const resMPCv2SigningRound3 = await ecdsaMPCv2Utils.createOfflineRound3Share(reqMPCv2SigningRound3 as any);
679+
resMPCv2SigningRound3.should.have.property('signatureShareRound3');
680+
681+
const { userMsg4 } = await signBitgoMPCv2Round3(
682+
bitgoSession,
683+
resMPCv2SigningRound3.signatureShareRound3,
684+
resMPCv2SigningRound1.userGpgPubKey
685+
);
686+
687+
assert.ok(userMsg4.data.msg4.signatureR === bitgoMsg4.signatureR, 'User and BitGo signaturesR do not match');
688+
689+
const deserializedBitgoMsg4 = DklsTypes.deserializeMessages({
690+
p2pMessages: [],
691+
broadcastMessages: [bitgoMsg4],
692+
});
693+
694+
const deserializedUserMsg4 = DklsTypes.deserializeMessages({
695+
p2pMessages: [],
696+
broadcastMessages: [
697+
{
698+
from: userMsg4.data.msg4.from,
699+
payload: userMsg4.data.msg4.message,
700+
},
701+
],
702+
});
703+
704+
const combinedSigUsingUtil = DklsUtils.combinePartialSignatures(
705+
[deserializedUserMsg4.broadcastMessages[0].payload, deserializedBitgoMsg4.broadcastMessages[0].payload],
706+
Buffer.from(userMsg4.data.msg4.signatureR, 'base64').toString('hex')
707+
);
708+
709+
// Verify with shouldHash=true and keccak256 — standard EVM verification
710+
const convertedSignature = DklsUtils.verifyAndConvertDklsSignature(
711+
Buffer.from(signableHex, 'hex'),
712+
combinedSigUsingUtil,
713+
DklsTypes.getCommonKeychain(userShare),
714+
derivationPath,
715+
createKeccakHash('keccak256') as Hash
716+
);
717+
assert.ok(convertedSignature, 'EVM signature with serializedTxHex is not valid');
718+
const sigParts = convertedSignature.split(':');
719+
assert.strictEqual(sigParts.length, 4, 'Signature must be recid:R:S:pubkey format');
720+
assert.strictEqual(sigParts[1].length, 64, 'Signature R must be 32 bytes hex');
721+
assert.strictEqual(sigParts[2].length, 64, 'Signature S must be 32 bytes hex');
722+
});
404723
});
405724

406725
function bytesToWord(bytes?: Uint8Array | number[]): number {

0 commit comments

Comments
 (0)