Skip to content

Commit b3c6f5b

Browse files
authored
Merge pull request #9011 from BitGo/CECHO-1295
feat(sdk-core): add pre-hashed signable support for Avalanche txn
2 parents 8592357 + 629332d commit b3c6f5b

9 files changed

Lines changed: 126 additions & 31 deletions

File tree

CODEOWNERS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@
136136
/modules/sdk-core/src/bitgo/lightning/ @BitGo/btc-team
137137
/modules/sdk-core/test/unit/bitgo/lightning/ @BitGo/btc-team
138138
/modules/sdk-core/test/unit/bitgo/wallet/resourceManagement.ts @BitGo/ethalt-team
139+
/modules/sdk-core/test/unit/bitgo/utils/tss/preHashedSignable.ts @BitGo/ethalt-team
139140
/modules/sdk-lib-mpc/ @BitGo/wallet-core @BitGo/wallet-core-india @BitGo/hsm
140141
/modules/deser-lib/ @BitGo/wallet-core @BitGo/wallet-core-india @BitGo/hsm
141142
/modules/sdk-rpc-wrapper @BitGo/ethalt-team

modules/sdk-coin-flrp/src/flrp.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import {
1919
BaseTransaction,
2020
SigningError,
2121
MethodNotImplementedError,
22+
SignableTransaction,
23+
isAvalancheAtomicTx,
2224
} from '@bitgo/sdk-core';
2325
import * as FlrpLib from './lib';
2426
import {
@@ -442,6 +444,15 @@ export class Flrp extends BaseCoin {
442444
return FlrpLib.Utils.createSignature(this._staticsCoin.network as FlareNetwork, message, prv);
443445
}
444446

447+
/**
448+
* Returns true when the signableHex for this transaction is already the
449+
* final signing hash (SHA-256 for Avalanche atomic txs), and the MPC
450+
* signing flow should use it directly without additional hashing.
451+
*/
452+
isSignablePreHashed(unsignedTx: SignableTransaction): boolean {
453+
return isAvalancheAtomicTx(unsignedTx);
454+
}
455+
445456
/** @inheritDoc */
446457
auditDecryptedKey(params: AuditDecryptedKeyParams): void {
447458
throw new MethodNotImplementedError();

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as FlrpLib from '../../src/lib';
22
import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test';
33
import { Flrp, TflrP } from '../../src/';
4-
import { randomBytes } from 'crypto';
4+
import { createHash, randomBytes } from 'crypto';
55
import { BitGoAPI } from '@bitgo/sdk-api';
66
import { coins } from '@bitgo/statics';
77
import { SEED_ACCOUNT, ACCOUNT_1, ACCOUNT_2, ON_CHAIN_TEST_WALLET, CONTEXT } from '../resources/account';
@@ -1055,6 +1055,17 @@ describe('Flrp test cases', function () {
10551055
isVerified.should.equal(true);
10561056
});
10571057

1058+
it('isSignablePreHashed should be true for Avalanche atomic txs', async () => {
1059+
const txHex = await buildUnsignedExportInP();
1060+
const rawHex = txHex.startsWith('0x') ? txHex.substring(2) : txHex;
1061+
const signableHex = createHash('sha256').update(Buffer.from(rawHex, 'hex')).digest('hex');
1062+
1063+
basecoin.isSignablePreHashed({ serializedTxHex: rawHex, signableHex }).should.equal(true);
1064+
basecoin
1065+
.isSignablePreHashed({ serializedTxHex: 'f86c808504a817c800825208', signableHex: 'f86c808504a817c800825208' })
1066+
.should.equal(false);
1067+
});
1068+
10581069
it('should verify signablePayload is SHA-256 of serialized tx (sandbox-verified)', async () => {
10591070
// unsignedTx.toBytes() → sha256 → MPC.sign()
10601071
// The signablePayload must be exactly 32 bytes (SHA-256 digest).

modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { Hash } from 'crypto';
1515
import { TransactionType } from '../../account-lib';
1616
import { IInscriptionBuilder } from '../inscriptionBuilder';
1717
import { MessageStandardType, MPCTx, PopulatedIntent, TokenTransferRecipientParams, TokenType } from '../utils';
18+
import type { SignableTransaction } from '../utils/tss/baseTypes';
1819
import { IWebhooks } from '../webhook/iWebhooks';
1920

2021
export const multisigTypes = {
@@ -708,6 +709,12 @@ export interface IBaseCoin {
708709
*/
709710
buildNftTransferData(params: BuildNftTransferDataOptions): string | TokenTransferRecipientParams;
710711
getHashFunction(): Hash;
712+
/**
713+
* Returns true when signableHex is already the final signing hash for MPC signing
714+
* (e.g. SHA-256 for Avalanche atomic cross-chain txs). Only implemented by coins
715+
* that produce pre-hashed signable material.
716+
*/
717+
isSignablePreHashed?(unsignedTx: SignableTransaction): boolean;
711718
broadcastTransaction(params: BaseBroadcastTransactionOptions): Promise<BaseBroadcastTransactionResult>;
712719
setCoinSpecificFieldsInIntent(intent: PopulatedIntent, params: PrebuildTransactionWithIntentOptions): void;
713720

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

Lines changed: 27 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,9 @@ import {
4949
TssTxRecipientSource,
5050
TxRequest,
5151
isV2Envelope,
52+
SignableTransaction,
5253
} from '../baseTypes';
54+
import { shouldUsePreHashedSignable } from '../preHashedSignable';
5355
import { BaseEcdsaUtils } from './base';
5456
import { EcdsaMPCv2KeyGenSendFn, KeyGenSenderForEnterprise } from './ecdsaMPCv2KeyGenSender';
5557
import { envRequiresBitgoPubGpgKeyConfig, isBitgoMpcPubKey } from '../../../tss/bitgoPubKeys';
@@ -837,8 +839,8 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils {
837839
// serializedTxHex is the PVM/EVM atomic tx (codec prefix 0x0000).
838840
// For all other coins, signableHex IS the unsigned transaction (e.g. RLP bytes).
839841
const isIcp = this.baseCoin.getConfig().family === 'icp';
840-
const isAvalancheAtomic = unsignedTx.serializedTxHex && unsignedTx.serializedTxHex.startsWith('0000');
841-
if (isIcp || isAvalancheAtomic) {
842+
const isPreHashed = shouldUsePreHashedSignable(this.baseCoin, unsignedTx);
843+
if (isIcp || isPreHashed) {
842844
await this.baseCoin.verifyTransaction({
843845
txPrebuild: { txHex: unsignedTx.serializedTxHex, txInfo: unsignedTx.signableHex },
844846
txParams: params.txParams || { recipients: [] },
@@ -870,19 +872,7 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils {
870872
// pre-hashed digest. Use it directly as the DKLS message hash instead of
871873
// applying the coin's hash function (keccak256 for EVM coins).
872874
// Same logic as getHashStringAndDerivationPath (external signer path).
873-
let hashBuffer: Buffer;
874-
if (serializedTxHex && serializedTxHex.startsWith('0000')) {
875-
hashBuffer = bufferContent;
876-
assert(hashBuffer.length === 32, `Avalanche pre-hashed signableHex must be 32 bytes, got ${hashBuffer.length}`);
877-
} else {
878-
let hash: Hash;
879-
try {
880-
hash = this.baseCoin.getHashFunction();
881-
} catch (err) {
882-
hash = createKeccakHash('keccak256') as Hash;
883-
}
884-
hashBuffer = hash.update(bufferContent).digest();
885-
}
875+
const hashBuffer = this.getMpcv2HashBuffer(serializedTxHex, txOrMessageToSign, bufferContent);
886876
const otherSigner = new DklsDsg.Dsg(
887877
userKeyShare,
888878
params.mpcv2PartyId !== undefined ? params.mpcv2PartyId : 0,
@@ -1059,18 +1049,27 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils {
10591049
throw new Error('Invalid request type, got: ' + requestType);
10601050
}
10611051

1062-
// For Avalanche atomic transactions (cross-chain export/import between
1063-
// C-chain and P-chain), signableHex is already SHA-256(txBody) — a 32-byte
1064-
// pre-hashed digest. Use it directly as the DKLS message hash instead of
1065-
// applying the coin's hash function (keccak256 for EVM coins).
1066-
// This matches the WP/HSM BitGo-party behaviour (MPCv2Signer.isPreHashed)
1067-
// so both DKLS parties agree on the same message hash.
1068-
// Detection: Avalanche codec type ID prefix is 0x0000; standard EVM RLP
1069-
// starts with 0xf8xx, so there is no collision.
1070-
if (serializedTxHex && serializedTxHex.startsWith('0000')) {
1071-
const hashBuffer = Buffer.from(txToSign, 'hex');
1072-
assert(hashBuffer.length === 32, `Avalanche pre-hashed signableHex must be 32 bytes, got ${hashBuffer.length}`);
1073-
return { hashBuffer, derivationPath };
1052+
const hashBuffer = this.getMpcv2HashBuffer(serializedTxHex, txToSign, Buffer.from(txToSign, 'hex'));
1053+
return { hashBuffer, derivationPath };
1054+
}
1055+
1056+
/**
1057+
* Build the DKLS message hash for MPCv2 signing.
1058+
* For pre-hashed signable material (Avalanche atomic txs), signableHex is
1059+
* already SHA-256(txBody) and is used directly. Otherwise the coin hash
1060+
* function is applied (keccak256 for EVM coins by default).
1061+
*/
1062+
private getMpcv2HashBuffer(serializedTxHex: string | undefined, signableHex: string, bufferContent: Buffer): Buffer {
1063+
const unsignedTx: SignableTransaction = {
1064+
serializedTxHex: serializedTxHex ?? '',
1065+
signableHex,
1066+
};
1067+
if (serializedTxHex && shouldUsePreHashedSignable(this.baseCoin, unsignedTx)) {
1068+
assert(
1069+
bufferContent.length === 32,
1070+
`Avalanche pre-hashed signableHex must be 32 bytes, got ${bufferContent.length}`
1071+
);
1072+
return bufferContent;
10741073
}
10751074

10761075
let hash: Hash;
@@ -1079,9 +1078,7 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils {
10791078
} catch (err) {
10801079
hash = createKeccakHash('keccak256') as Hash;
10811080
}
1082-
const hashBuffer = hash.update(Buffer.from(txToSign, 'hex')).digest();
1083-
1084-
return { hashBuffer, derivationPath };
1081+
return hash.update(bufferContent).digest();
10851082
}
10861083

10871084
// #endregion

modules/sdk-core/src/bitgo/utils/tss/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ export { ITssUtils, IEddsaUtils, TxRequest, EddsaUnsignedTransaction } from './e
1515
export * as BaseTssUtils from './baseTSSUtils';
1616
export * from './baseTypes';
1717
export * from './addressVerification';
18+
export * from './preHashedSignable';
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { IBaseCoin } from '../../baseCoin';
2+
import { SignableTransaction } from './baseTypes';
3+
4+
export interface CoinWithPreHashedSignable {
5+
/**
6+
* Returns true when the signableHex for this transaction is already the
7+
* final signing hash (SHA-256 for Avalanche atomic txs), and the MPC
8+
* signing flow should use it directly without additional hashing.
9+
*/
10+
isSignablePreHashed(unsignedTx: SignableTransaction): boolean;
11+
}
12+
13+
export function isCoinWithPreHashedSignable(coin: unknown): coin is CoinWithPreHashedSignable {
14+
return (
15+
coin !== null &&
16+
typeof coin === 'object' &&
17+
typeof (coin as CoinWithPreHashedSignable).isSignablePreHashed === 'function'
18+
);
19+
}
20+
21+
/**
22+
* Detect Avalanche atomic cross-chain transactions by codec prefix.
23+
*
24+
* Avalanche codec type IDs start with 0x00000000 (first 4 hex chars = '0000').
25+
* Standard EVM RLP encoding starts with 0xf8xx — overlap is currently impossible.
26+
*
27+
* Note: The WP strips the '0x' prefix from serializedTxHex before storing,
28+
* so we check for '0000' not '0x0000'.
29+
*/
30+
export function isAvalancheAtomicTx(unsignedTx: SignableTransaction): boolean {
31+
return unsignedTx.serializedTxHex.startsWith('0000');
32+
}
33+
34+
export function shouldUsePreHashedSignable(coin: IBaseCoin, unsignedTx: SignableTransaction): boolean {
35+
return isCoinWithPreHashedSignable(coin) && coin.isSignablePreHashed(unsignedTx);
36+
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ describe('ECDSA MPC v2', async () => {
6565

6666
const mockCoin = {} as IBaseCoin;
6767
mockCoin.getHashFunction = sinon.stub().callsFake(() => createKeccakHash('keccak256') as Hash);
68+
mockCoin.isSignablePreHashed = (unsignedTx) => unsignedTx.serializedTxHex?.startsWith('0000') ?? false;
6869

6970
ecdsaMPCv2Utils = new EcdsaMPCv2Utils(mockBg, mockCoin);
7071
});
@@ -630,6 +631,8 @@ describe('ECDSA MPC v2', async () => {
630631
verifyTransaction: sinon.stub().resolves(true),
631632
getMPCAlgorithm: sinon.stub().returns('ecdsa'),
632633
getConfig: sinon.stub().returns({ family: 'flrp' }),
634+
isSignablePreHashed: (unsignedTx: { serializedTxHex?: string }) =>
635+
unsignedTx.serializedTxHex?.startsWith('0000') ?? false,
633636
} as unknown as IBaseCoin;
634637

635638
const mockWallet = {
@@ -910,6 +913,7 @@ describe('ECDSA MPC v2', async () => {
910913

911914
const mockCoin = {} as IBaseCoin;
912915
mockCoin.getHashFunction = sinon.stub().callsFake(() => createKeccakHash('keccak256') as Hash);
916+
mockCoin.isSignablePreHashed = (unsignedTx) => unsignedTx.serializedTxHex?.startsWith('0000') ?? false;
913917
ecdsaMPCv2UtilsWithSpy = new EcdsaMPCv2Utils(mockBg, mockCoin);
914918
});
915919

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import * as assert from 'assert';
2+
import { isAvalancheAtomicTx, isCoinWithPreHashedSignable } from '../../../../../src/bitgo/utils/tss/preHashedSignable';
3+
4+
describe('preHashedSignable', function () {
5+
it('isAvalancheAtomicTx detects codec prefix 0000', function () {
6+
assert.strictEqual(
7+
isAvalancheAtomicTx({
8+
serializedTxHex: '0000000000010000007278db5c',
9+
signableHex: 'abc',
10+
}),
11+
true
12+
);
13+
assert.strictEqual(
14+
isAvalancheAtomicTx({
15+
serializedTxHex: 'f86c808504a817c800825208',
16+
signableHex: 'f86c808504a817c800825208',
17+
}),
18+
false
19+
);
20+
});
21+
22+
it('isCoinWithPreHashedSignable type guard', function () {
23+
assert.strictEqual(isCoinWithPreHashedSignable({ isSignablePreHashed: () => true }), true);
24+
assert.strictEqual(isCoinWithPreHashedSignable({}), false);
25+
assert.strictEqual(isCoinWithPreHashedSignable(null), false);
26+
});
27+
});

0 commit comments

Comments
 (0)