Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 49 additions & 18 deletions modules/sdk-coin-flrp/src/lib/ImportInCTxBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CoinConfig>) {
super(_coinConfig);
Expand Down Expand Up @@ -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) {
Expand All @@ -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) => {
Expand Down
13 changes: 13 additions & 0 deletions modules/sdk-coin-flrp/src/lib/atomicTransactionBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
*
Expand Down
6 changes: 6 additions & 0 deletions modules/sdk-coin-flrp/src/lib/iface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
64 changes: 64 additions & 0 deletions modules/sdk-coin-flrp/test/unit/lib/importInCTxBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading