From bf8c70090ba2fec7ca0289a90eae988a4aab75a2 Mon Sep 17 00:00:00 2001 From: "arunchockalingam504@bitgo.com" Date: Mon, 27 Jul 2026 09:29:11 +0000 Subject: [PATCH] fix(sdk-coin-flr): add atomic tx support for getSignablePayload and addSignatureToTransaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The FLR coin was missing getSignablePayload and addSignatureToTransaction overrides for atomic cross-chain transactions (C-chain to P-chain export, codec prefix 0x0000). The inherited EVM implementation from AbstractEthLikeCoin uses the EVM transaction builder which cannot parse Avalanche/Flare PVM/EVM atomic format, causing the TSS server-side signature injection step to fail for FLR→FLRP withdrawals. When signature injection fails, the server may retry building a new ExportInC with the same nonce as the already-pending transaction, resulting in "transaction attempted to double spend" from the blockchain. Fix: override getSignablePayload to route atomic txs (codec prefix 0000) to the FLRP atomic builder (getAtomicBuilder), which correctly parses the transaction and computes SHA-256(txBody) as the signable payload. Add addSignatureToTransaction to inject the TSS-produced signature into the unsigned atomic transaction to produce the final broadcastable hex. Both methods mirror the existing FLRP coin implementations but use getAtomicBuilder() (which wraps the FLRP builder) instead of the FLRP- internal getBuilder(). Ticket: CECHO-1784 Session-Id: e17e771f-ba61-468a-a77e-c2c97ade3e23 Task-Id: 81aaba89-3f88-4d30-a6f6-81d77b7908d9 --- modules/sdk-coin-flr/src/flr.ts | 31 +++++++++++++++++ modules/sdk-coin-flr/test/unit/flr.ts | 49 +++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/modules/sdk-coin-flr/src/flr.ts b/modules/sdk-coin-flr/src/flr.ts index 908700b291..0ed1fac09a 100644 --- a/modules/sdk-coin-flr/src/flr.ts +++ b/modules/sdk-coin-flr/src/flr.ts @@ -97,6 +97,37 @@ export class Flr extends AbstractEthLikeNewCoins { return isAvalancheAtomicTx(unsignedTx); } + /** + * Get the signable payload for a transaction. For atomic cross-chain + * transactions (codec prefix 0x0000), route to the FLRP atomic builder + * which knows how to parse PVM/EVM atomic format and compute SHA-256(txBody). + * For regular EVM transactions, use the inherited EVM path. + */ + async getSignablePayload(serializedTx: string): Promise { + const hexWithout0x = serializedTx.startsWith('0x') ? serializedTx.slice(2) : serializedTx; + if (hexWithout0x.startsWith('0000')) { + const txBuilder = this.getAtomicBuilder().from(serializedTx); + const tx = (await txBuilder.build()) as FlrPLib.Transaction; + return Buffer.from(tx.signablePayload); + } + return super.getSignablePayload(serializedTx); + } + + /** + * Inject an MPC-produced signature into an unsigned atomic transaction. + * Called by the server after TSS signing to assemble the final signed tx. + * Only applies to cross-chain export atomic transactions (codec prefix 0x0000). + * @param txHex - Unsigned atomic transaction hex + * @param signature - 65-byte ECDSA signature (r || s || recovery) + * @returns Signed transaction hex + */ + async addSignatureToTransaction(txHex: string, signature: Buffer): Promise { + const txBuilder = this.getAtomicBuilder().from(txHex); + const tx = await txBuilder.build(); + (tx as FlrPLib.Transaction).addExternalSignature(new Uint8Array(signature)); + return tx.toBroadcastFormat(); + } + 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 602fd28d2b..7fe18adf3c 100644 --- a/modules/sdk-coin-flr/test/unit/flr.ts +++ b/modules/sdk-coin-flr/test/unit/flr.ts @@ -117,6 +117,55 @@ describe('flr', function () { }); }); + describe('getSignablePayload and addSignatureToTransaction (atomic C→P export)', function () { + it('should return 32-byte SHA-256 hash for atomic tx', async function () { + const signablePayload = await tflrCoin.getSignablePayload(EXPORT_C_TEST_DATA.unsignedTxHex); + signablePayload.should.be.instanceof(Buffer); + signablePayload.length.should.equal(32); + }); + + it('should produce the same signablePayload as the FLRP builder', async function () { + // FLR getSignablePayload must agree with the FLRP builder's signablePayload + // for the same atomic tx hex — both parties of TSS must hash identically. + const flrpModule = await import('@bitgo/sdk-coin-flrp'); + const flrpFactory = new flrpModule.FlrPLib.TransactionBuilderFactory(coins.get('tflrp')); + const txBuilder = flrpFactory.from(EXPORT_C_TEST_DATA.unsignedTxHex); + const tx = await txBuilder.build(); + const expectedPayload = Buffer.from((tx as any).signablePayload); + + const actualPayload = await tflrCoin.getSignablePayload(EXPORT_C_TEST_DATA.unsignedTxHex); + actualPayload.should.deepEqual(expectedPayload); + }); + + it('addSignatureToTransaction should produce identical result to FLRP builder sign()', async function () { + // Simulate TSS post-ceremony: sign the payload externally, then inject + // via addSignatureToTransaction. This must equal the result of signing + // via the FLRP builder directly (the reference path). + const flrpModule = await import('@bitgo/sdk-coin-flrp'); + const flrpFactory = new flrpModule.FlrPLib.TransactionBuilderFactory(coins.get('tflrp')); + + // Reference: sign via FLRP builder + const txBuilderForSign = flrpFactory.from(EXPORT_C_TEST_DATA.unsignedTxHex); + txBuilderForSign.sign({ key: EXPORT_C_TEST_DATA.privKey }); + const signedTx = await txBuilderForSign.build(); + const signedHexViaBuilder = signedTx.toBroadcastFormat(); + + // Extract the signature from the builder-signed tx, then re-inject it + // via FLR's addSignatureToTransaction (the TSS server path). + const parsedBuilder = flrpFactory.from(signedHexViaBuilder); + const parsedTx = await parsedBuilder.build(); + const sigHex = parsedTx.signature[0]; + const sigBuffer = Buffer.from(sigHex.startsWith('0x') ? sigHex.slice(2) : sigHex, 'hex'); + + const signedHexViaInject = await tflrCoin.addSignatureToTransaction( + EXPORT_C_TEST_DATA.unsignedTxHex, + sigBuffer + ); + + signedHexViaInject.should.equal(signedHexViaBuilder); + }); + }); + describe('Address Validation', function () { it('should validate valid eth address', function () { const address = '0x1374a2046661f914d1687d85dbbceb9ac7910a29';