From 8d4044ecaa6622f81f85f2931d3794e6e0de10f1 Mon Sep 17 00:00:00 2001 From: "arunchockalingam504@bitgo.com" Date: Tue, 23 Jun 2026 06:20:09 +0000 Subject: [PATCH] fix(sdk-coin-flr): support FLR C->P atomic TSS signing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FLR C-chain to P-chain export (and the matching P-chain import) fails during the MPC signing ceremony because: 1. sdk-coin-flr does not declare isSignablePreHashed, so the MPCv2 signing flow re-hashes the Avalanche atomic signableHex with keccak256. The signableHex is already SHA-256(txBody), so the user's signature share is computed over a different digest from BitGo's, surfacing as "Failed to combine signature shares" on the send-transaction endpoint. 2. The TSS recipient guard rejects intent types 'import' and 'importtoc' (Avalanche / Flare cross-chain atomic imports), even though those intents legitimately have no client-supplied recipients — the wallet imports its own UTXOs and the destination is the wallet itself. That surfaces as "Recipient details are required to verify this transaction before signing". Add isSignablePreHashed to Flr mirroring the Flrp implementation, and add 'import' / 'importtoc' to NO_RECIPIENT_TX_TYPES so the pre-signing verifier no longer blocks the flow. Ticket: WAL-1586 Session-Id: ed1da302-5a6d-427c-9cc8-9226e355d0ed Task-Id: d6c37b72-82ce-4927-b917-f88212aedf23 --- modules/sdk-coin-flr/src/flr.ts | 12 +++++++++++ modules/sdk-coin-flr/test/unit/flr.ts | 20 +++++++++++++++++++ .../src/bitgo/utils/tss/recipientUtils.ts | 7 +++++++ .../unit/bitgo/utils/tss/recipientUtils.ts | 16 +++++++++++++++ 4 files changed, 55 insertions(+) diff --git a/modules/sdk-coin-flr/src/flr.ts b/modules/sdk-coin-flr/src/flr.ts index 8ac281eace..b5b3e7eef3 100644 --- a/modules/sdk-coin-flr/src/flr.ts +++ b/modules/sdk-coin-flr/src/flr.ts @@ -19,10 +19,12 @@ import { common, FeeEstimateOptions, IWallet, + isAvalancheAtomicTx, MPCAlgorithm, MultisigType, multisigTypes, Recipient, + SignableTransaction, TransactionExplanation, Entry, } from '@bitgo/sdk-core'; @@ -85,6 +87,16 @@ export class Flr extends AbstractEthLikeNewCoins { return 'ecdsa'; } + /** + * Returns true when the signableHex for this transaction is already the + * final signing hash. Cross-chain export atomic transactions from FLR + * C-chain to FLRP P-chain are pre-hashed with SHA-256(txBody); the MPC + * signing flow must use that digest directly without applying keccak256. + */ + isSignablePreHashed(unsignedTx: SignableTransaction): boolean { + return isAvalancheAtomicTx(unsignedTx); + } + protected async buildUnsignedSweepTxnTSS(params: RecoverOptions): Promise { return this.buildUnsignedSweepTxnMPCv2(params); } diff --git a/modules/sdk-coin-flr/test/unit/flr.ts b/modules/sdk-coin-flr/test/unit/flr.ts index 8e964e3fe3..5aebc74a43 100644 --- a/modules/sdk-coin-flr/test/unit/flr.ts +++ b/modules/sdk-coin-flr/test/unit/flr.ts @@ -97,6 +97,26 @@ describe('flr', function () { }); }); + describe('isSignablePreHashed', function () { + it('returns true for Avalanche atomic txs (codec prefix 0000)', function () { + // C->P cross-chain export atomic tx — signableHex is already SHA-256(txBody). + // The MPC layer must use this digest directly, not re-hash with keccak256, + // otherwise the user and BitGo signature shares will not combine. + const signableHex = 'a'.repeat(64); + flrCoin.isSignablePreHashed({ serializedTxHex: '0000' + 'b'.repeat(20), signableHex }).should.equal(true); + }); + + it('returns false for standard EVM RLP transactions', function () { + // EIP-1559 / RLP serialized transactions start with 0x02 or 0xf8 — never 0x0000. + flrCoin + .isSignablePreHashed({ + serializedTxHex: '02f17281d902850ba43b740283061a80', + signableHex: '02f17281d902850ba43b740283061a80', + }) + .should.equal(false); + }); + }); + describe('Address Validation', function () { it('should validate valid eth address', function () { const address = '0x1374a2046661f914d1687d85dbbceb9ac7910a29'; diff --git a/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts b/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts index e83728151b..226797f319 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts @@ -57,6 +57,13 @@ export const NO_RECIPIENT_TX_TYPES = new Set([ 'transferOfferWithdrawn', 'cantonCommand', 'pledge', + + // Avalanche / Flare cross-chain atomic imports — recipients are not supplied + // by the client because the import consumes UTXOs already owned by the + // wallet; the destination address is the wallet itself. WP issues these + // with intentType 'import' (P-chain) or 'importtoc' (C-chain). + 'import', + 'importtoc', ]); /** diff --git a/modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts b/modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts index 1df5ccfd95..68ed006d4e 100644 --- a/modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts @@ -49,6 +49,9 @@ describe('recipientUtils', function () { 'transferOfferWithdrawn', 'cantonCommand', 'pledge', + // Avalanche / Flare cross-chain atomic imports + 'import', + 'importtoc', ]; expected.forEach((t) => assert.ok(NO_RECIPIENT_TX_TYPES.has(t), `${t} should be in NO_RECIPIENT_TX_TYPES`)); assert.strictEqual(NO_RECIPIENT_TX_TYPES.size, expected.length); @@ -104,12 +107,25 @@ describe('recipientUtils', function () { 'stake', 'createAccount', 'pledge', + 'import', + 'importtoc', ]) { const txRequest = makeTxRequest(); assert.doesNotThrow(() => resolveEffectiveTxParams(txRequest, { type: txType })); } }); + it('does not throw for Avalanche cross-chain imports resolved from intent.intentType', function () { + // P-chain and C-chain import intents legitimately carry no recipients — + // the wallet imports its own UTXOs and the destination address is the + // wallet itself. The intentType lives only on the intent (txParams.type + // is unset on the MPC signing call) so the guard must read it from there. + for (const intentType of ['import', 'importtoc']) { + const txRequest = makeTxRequest({ intent: { intentType } as any }); + assert.doesNotThrow(() => resolveEffectiveTxParams(txRequest, {})); + } + }); + it('throws InvalidTransactionError for unknown types with no recipients', function () { const txRequest = makeTxRequest(); assert.throws(