diff --git a/modules/sdk-coin-flrp/src/lib/ImportInCTxBuilder.ts b/modules/sdk-coin-flrp/src/lib/ImportInCTxBuilder.ts index 263907895d..f3a32be3b0 100644 --- a/modules/sdk-coin-flrp/src/lib/ImportInCTxBuilder.ts +++ b/modules/sdk-coin-flrp/src/lib/ImportInCTxBuilder.ts @@ -14,9 +14,19 @@ 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); + // Unlike P-chain-oriented atomic txs, a C-chain import has no meaningful default fee. + // Clear the inherited network txFee default so an unset fee is detectable at build time, + // rather than silently building with that unrelated default value. + this.transaction._fee.fee = ''; } /** @@ -127,9 +137,16 @@ export class ImportInCTxBuilder extends AtomicInCTransactionBuilder { if (this.transaction._to.length !== 1) { throw new BuildTransactionError('to is required'); } - if (!this.transaction._fee.fee) { + const hasFee = !!this.transaction._fee.fee && BigInt(this.transaction._fee.fee) !== BigInt(0); + const hasBaseFee = !!this.transaction._fee.baseFee && BigInt(this.transaction._fee.baseFee) !== BigInt(0); + if (!hasFee && !hasBaseFee) { throw new BuildTransactionError('fee is required'); } + if (hasFee && hasBaseFee) { + throw new BuildTransactionError( + 'fee and baseFee are mutually exclusive: use baseFee for gas-aware fee calculation, or fee for a fixed amount, not both' + ); + } if (!this.transaction._context) { throw new BuildTransactionError('context is required'); } @@ -147,37 +164,70 @@ 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 (hasBaseFee) { + // 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 as string); + 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)); + const computedFee = totalUtxoAmount - totalOutputAmount; + // Mirror the legacy fixed-fee path: the fee must not consume the entire UTXO amount, + // otherwise nothing is actually imported (totalOutputAmount would be 0). + if (computedFee <= BigInt(0) || totalOutputAmount <= BigInt(0)) { + throw new BuildTransactionError( + `Insufficient UTXO balance: have ${totalUtxoAmount.toString()} nFLR, computed import fee of ${computedFee.toString()} nFLR leaves nothing to import` + ); + } + this.transaction._fee.fee = computedFee.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..58cc217772 100644 --- a/modules/sdk-coin-flrp/test/unit/lib/importInCTxBuilder.ts +++ b/modules/sdk-coin-flrp/test/unit/lib/importInCTxBuilder.ts @@ -1,11 +1,11 @@ import assert from 'assert'; import 'should'; -import { TransactionBuilderFactory, Transaction } from '../../../src/lib'; +import { TransactionBuilderFactory, Transaction, Utils } from '../../../src/lib'; import { coins } from '@bitgo/statics'; import { IMPORT_IN_C as testData } from '../../resources/transactionData/importInC'; import { ON_CHAIN_TEST_WALLET } from '../../resources/account'; import signFlowTest from './signFlowTestSuit'; -import { secp256k1, UnsignedTx } from '@flarenetwork/flarejs'; +import { secp256k1, UnsignedTx, evm, evmSerial } from '@flarenetwork/flarejs'; describe('Flrp Import In C Tx Builder', () => { const factory = new TransactionBuilderFactory(coins.get('tflrp')); @@ -221,6 +221,82 @@ 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; // base fee in wei/gas 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 on top of the raw flarejs gas*baseFee estimate', async () => { + const baseFeeWei = 500n; + const fromAddress = ON_CHAIN_TEST_WALLET.user.pChainAddress; + const toAddress = '0x96993BAEb6AaE2e06BF95F144e2775D4f8efbD35'; + + const paddedTx = (await factory + .getImportInCBuilder() + .threshold(1) + .locktime(0) + .fromPubKey([fromAddress]) + .to(toAddress) + .baseFee(baseFeeWei) + .decodedUtxos([utxo]) + .context(testData.context) + .build()) as Transaction; + const paddedFee = BigInt(paddedTx.fee.fee); + + // Reconstruct the same import tx directly via flarejs using the *unpadded* base fee, + // to recover the network's raw gas*baseFee requirement with no buffer applied. + const assetId = (coins.get('tflrp').network as unknown as { assetId: string }).assetId; + const nativeUtxos = Utils.decodedToUtxos([utxo], assetId); + const rawImportTx = evm.newImportTxFromBaseFee( + testData.context, + Utils.parseAddress(toAddress), + [Utils.parseAddress(fromAddress)], + nativeUtxos, + 'P', + baseFeeWei + ) as UnsignedTx; + const rawInnerTx = rawImportTx.getTx() as evmSerial.ImportTx; + const rawTotalOutput = rawInnerTx.Outs.reduce((sum, out) => sum + out.amount.value(), BigInt(0)); + const rawFee = BigInt(utxo.amount) - rawTotalOutput; + + assert(paddedFee > rawFee, `Expected padded fee (${paddedFee}) to exceed the unpadded raw fee (${rawFee})`); + // Padding is exactly the configured 10% buffer applied to the base fee before estimation. + assert.strictEqual(paddedFee, (rawFee * 11000n) / 10000n); + }); + }); + describe('MPC signing (threshold=1)', () => { const mpcUtxo = { outputID: 7,