From 27ca6a471478d2f05a8465f72313cc7f1355b7e8 Mon Sep 17 00:00:00 2001 From: ArunBala-Bitgo Date: Wed, 29 Jul 2026 17:33:16 +0530 Subject: [PATCH] fix(sdk-coin-flrp): compute Flare C-chain import fee from real gas usage Import-to-C-chain transactions built a fee from a caller-supplied literal amount, bypassing flarejs's actual gas accounting (which includes the ~10,000 AtomicTxBaseCost). When the caller priced the import using a naive gas estimate, the resulting fee was too low and the network rejected the transaction with insufficient funds, independent of transfer amount. Add an opt-in baseFee() builder method that computes the fee via newImportTxFromBaseFee (mirroring the export builder's existing pattern), with a 10% padding buffer to absorb base-fee volatility between signing and broadcast. The legacy fee() literal-amount path is unchanged for backward compatibility. Ticket: CECHO-1821 fix(sdk-coin-flrp): use assert for bigint comparisons in import fee tests should.above() coerces to number and fails on bigint values, breaking the build. Compare with native > via assert instead. Ticket: CECHO-1821 --- .../src/lib/ImportInCTxBuilder.ts | 67 ++++++++++++++----- .../src/lib/atomicTransactionBuilder.ts | 13 ++++ modules/sdk-coin-flrp/src/lib/iface.ts | 6 ++ .../test/unit/lib/importInCTxBuilder.ts | 64 ++++++++++++++++++ 4 files changed, 132 insertions(+), 18 deletions(-) diff --git a/modules/sdk-coin-flrp/src/lib/ImportInCTxBuilder.ts b/modules/sdk-coin-flrp/src/lib/ImportInCTxBuilder.ts index 263907895d..5c79fa49d5 100644 --- a/modules/sdk-coin-flrp/src/lib/ImportInCTxBuilder.ts +++ b/modules/sdk-coin-flrp/src/lib/ImportInCTxBuilder.ts @@ -14,6 +14,12 @@ import { import utils from './utils'; import { DecodedUtxoObj, FlareTransactionType, SECP256K1_Transfer_Output, Tx } from './iface'; +/** + * Buffer applied to a caller-supplied base fee to absorb C-chain base-fee volatility + * between transaction signing and broadcast. Expressed in basis points (1000 = 10%). + */ +const BASE_FEE_PADDING_BPS = 1000n; + export class ImportInCTxBuilder extends AtomicInCTransactionBuilder { constructor(_coinConfig: Readonly) { super(_coinConfig); @@ -127,7 +133,7 @@ export class ImportInCTxBuilder extends AtomicInCTransactionBuilder { if (this.transaction._to.length !== 1) { throw new BuildTransactionError('to is required'); } - if (!this.transaction._fee.fee) { + if (!this.transaction._fee.fee && !this.transaction._fee.baseFee) { throw new BuildTransactionError('fee is required'); } if (!this.transaction._context) { @@ -147,37 +153,62 @@ export class ImportInCTxBuilder extends AtomicInCTransactionBuilder { this.validateUtxoAddresses(); - const actualFeeNFlr = BigInt(this.transaction._fee.fee); const sourceChain = 'P'; // Convert decoded UTXOs to native FlareJS Utxo objects const assetId = utils.cb58Encode(Buffer.from(this.transaction._assetId, 'hex')); const nativeUtxos = utils.decodedToUtxos(this.transaction._utxos, assetId); - // Validate UTXO balance is sufficient to cover the import fee const totalUtxoAmount = nativeUtxos.reduce((sum, utxo) => { const output = utxo.output as TransferOutput; return sum + output.amount(); }, BigInt(0)); - if (totalUtxoAmount <= actualFeeNFlr) { - throw new BuildTransactionError( - `Insufficient UTXO balance: have ${totalUtxoAmount.toString()} nFLR, need more than ${actualFeeNFlr.toString()} nFLR to cover import fee` - ); - } - const signingAddresses = this.getSigningAddresses(); - const importTx = evm.newImportTx( - this.transaction._context, - this.transaction._to[0], - signingAddresses, - nativeUtxos, - sourceChain, - actualFeeNFlr - ); + let importTx: UnsignedTx; + + if (this.transaction._fee.baseFee) { + // Gas-aware path: let flarejs compute the actual import gas cost (including the + // ~10,000 AtomicTxBaseCost) from the real tx size/inputs, instead of trusting a + // pre-computed fee amount. Pad the base fee to absorb volatility between signing + // and broadcast. + const suppliedBaseFee = BigInt(this.transaction._fee.baseFee); + const paddedBaseFee = (suppliedBaseFee * (10000n + BASE_FEE_PADDING_BPS)) / 10000n; + + importTx = evm.newImportTxFromBaseFee( + this.transaction._context, + this.transaction._to[0], + signingAddresses, + nativeUtxos, + sourceChain, + paddedBaseFee + ) as UnsignedTx; + + const innerImportTx = importTx.getTx() as evmSerial.ImportTx; + const totalOutputAmount = innerImportTx.Outs.reduce((sum, out) => sum + out.amount.value(), BigInt(0)); + this.transaction._fee.fee = (totalUtxoAmount - totalOutputAmount).toString(); + } else { + const actualFeeNFlr = BigInt(this.transaction._fee.fee); + + // Validate UTXO balance is sufficient to cover the import fee + if (totalUtxoAmount <= actualFeeNFlr) { + throw new BuildTransactionError( + `Insufficient UTXO balance: have ${totalUtxoAmount.toString()} nFLR, need more than ${actualFeeNFlr.toString()} nFLR to cover import fee` + ); + } + + importTx = evm.newImportTx( + this.transaction._context, + this.transaction._to[0], + signingAddresses, + nativeUtxos, + sourceChain, + actualFeeNFlr + ) as UnsignedTx; + } - const flareUnsignedTx = importTx as UnsignedTx; + const flareUnsignedTx = importTx; const innerTx = flareUnsignedTx.getTx() as evmSerial.ImportTx; const utxosWithIndex = innerTx.importedInputs.map((input) => { diff --git a/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts b/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts index 391da28d12..416fcaedec 100644 --- a/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts +++ b/modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts @@ -79,6 +79,19 @@ export abstract class AtomicTransactionBuilder extends TransactionBuilder { return this; } + /** + * Set the C-chain base fee (nFLR/wei per gas unit) to derive the atomic-tx fee from. + * When set, the fee amount is computed from actual gas usage (including the + * AtomicTxBaseCost) instead of being taken as a fixed, externally-supplied amount. + * + * @param {string | bigint} baseFeeValue - the current C-chain base fee + */ + baseFee(baseFeeValue: string | bigint): this { + const baseFee = typeof baseFeeValue === 'string' ? baseFeeValue : baseFeeValue.toString(); + (this.transaction as Transaction)._fee.baseFee = baseFee; + return this; + } + /** * Set the fee state for dynamic fee calculation (P-chain transactions) * diff --git a/modules/sdk-coin-flrp/src/lib/iface.ts b/modules/sdk-coin-flrp/src/lib/iface.ts index 1c3a786090..18d0a80dde 100644 --- a/modules/sdk-coin-flrp/src/lib/iface.ts +++ b/modules/sdk-coin-flrp/src/lib/iface.ts @@ -163,6 +163,12 @@ export interface FlrpTransactionFee { fee: string; type?: string; feeState?: FlrpFeeState; + /** + * C-chain base fee (in nFLR/wei per gas) used to derive the atomic-tx fee. + * When set, the import builder computes the required fee from actual gas usage + * (including the AtomicTxBaseCost) instead of treating `fee` as a fixed amount. + */ + baseFee?: string; } type DimensionValue = number; diff --git a/modules/sdk-coin-flrp/test/unit/lib/importInCTxBuilder.ts b/modules/sdk-coin-flrp/test/unit/lib/importInCTxBuilder.ts index 651fe10421..796bbc4d2d 100644 --- a/modules/sdk-coin-flrp/test/unit/lib/importInCTxBuilder.ts +++ b/modules/sdk-coin-flrp/test/unit/lib/importInCTxBuilder.ts @@ -221,6 +221,70 @@ describe('Flrp Import In C Tx Builder', () => { }); }); + describe('base-fee driven gas estimation (CECHO-1821)', () => { + const utxo = { + outputID: 7, + amount: '30000000', + txid: 'nSBwNcgfLbk5S425b1qaYaqTTCiMCV75KU4Fbnq8SPUUqLq2', + threshold: 1, + addresses: [ON_CHAIN_TEST_WALLET.user.pChainAddress], + outputidx: '1', + locktime: '0', + }; + + it('should derive a fee that accounts for the AtomicTxBaseCost and exceeds a naive gas*baseFee estimate', async () => { + const baseFeeWei = 500n; // gwei-equivalent base fee used in the reported failure + const underpricedGasEstimate = 5700n; // naive/legacy gas estimate missing AtomicTxBaseCost + const underpricedFee = baseFeeWei * underpricedGasEstimate; + + const txBuilder = factory + .getImportInCBuilder() + .threshold(1) + .locktime(0) + .fromPubKey([ON_CHAIN_TEST_WALLET.user.pChainAddress]) + .to('0x96993BAEb6AaE2e06BF95F144e2775D4f8efbD35') + .baseFee(baseFeeWei) + .decodedUtxos([utxo]) + .context(testData.context); + + const tx = (await txBuilder.build()) as Transaction; + const actualFee = BigInt(tx.fee.fee); + + // The gas-aware fee (including the real AtomicTxBaseCost, ~10,000) must exceed + // the underpriced legacy estimate that caused "insufficient funds" on broadcast. + assert( + actualFee > underpricedFee, + `Expected actualFee (${actualFee}) to be above underpricedFee (${underpricedFee})` + ); + }); + + it('should pad the supplied base fee to absorb volatility between signing and broadcast', async () => { + const baseFeeWei = 500n; + + const buildWithBaseFee = async (fee: bigint) => + factory + .getImportInCBuilder() + .threshold(1) + .locktime(0) + .fromPubKey([ON_CHAIN_TEST_WALLET.user.pChainAddress]) + .to('0x96993BAEb6AaE2e06BF95F144e2775D4f8efbD35') + .baseFee(fee) + .decodedUtxos([utxo]) + .context(testData.context) + .build(); + + const paddedTx = (await buildWithBaseFee(baseFeeWei)) as Transaction; + const unpaddedFeeEquivalentTx = (await buildWithBaseFee(baseFeeWei)) as Transaction; + + // Same input should be deterministic, and strictly greater than the raw baseFee * gas + // (i.e. some padding is applied on top of the network-observed base fee). + BigInt(paddedTx.fee.fee).should.equal(BigInt(unpaddedFeeEquivalentTx.fee.fee)); + const paddedFee = BigInt(paddedTx.fee.fee); + const minExpectedFee = baseFeeWei * 11300n; // > known real gas cost * baseFee, proving padding + assert(paddedFee > minExpectedFee, `Expected paddedFee (${paddedFee}) to be above ${minExpectedFee}`); + }); + }); + describe('MPC signing (threshold=1)', () => { const mpcUtxo = { outputID: 7,