Getting fee estimation wrong in a payment gateway leads to one of two bad outcomes: the transaction sits in the mempool for minutes (or hours) until the user cancels, or it confirms instantly but the merchant's hot wallet pays 3x the going rate. Both are operational fires. The first triggers webhook retries and support tickets; the second eats margin invisibly until treasury reconciliation surfaces the overpay.
This is how BchainPay builds its fee oracle for EVM chains — the module that
produces maxFeePerGas, maxPriorityFeePerGas, and gasLimit on every
outbound transaction.
Scope note: This post covers EIP-1559 execution-layer fee estimation for Ethereum mainnet and equivalent EVM chains (Polygon, BNB Chain, Gnosis). If you're sweeping on Base, Arbitrum, or OP Mainnet, there is an additional L1 data-submission cost beyond what this oracle computes. That component is covered in EIP-4844 blobs and L2 settlement economics.
The API contract first#
Treating the fee oracle as an API with a fixed response shape helps keep the implementation honest. BchainPay exposes it at:
GET /v1/fee-estimate?chain=ethereum&token=USDC&recipient=0x…Response:
{
"baseFeePerGas": "12500000000",
"maxPriorityFeePerGas": "1200000000",
"maxFeePerGas": "26200000000",
"gasLimit": 72000,
"confidenceTier": "standard",
"basedOnBlock": 19950100,
"expiresAt": 1714165424,
"surgeActive": false
}maxFeePerGas = 2 × baseFee + tip is the EIP-1559 ceiling that tolerates a
full baseFee doubling before the transaction becomes under-priced. The
sections below map to each field and explain where the number comes from.
EIP-1559 mechanics in one paragraph#
On every Ethereum block, the protocol adjusts baseFeePerGas based on how
full the previous block was relative to the 15M-gas target. The maximum change
per block is 12.5% in either direction. An EIP-1559 transaction specifies
maxFeePerGas (the ceiling the sender will pay) and maxPriorityFeePerGas
(the validator tip). The actual fee is min(maxFeePerGas, baseFee + tip) per
gas; the baseFee is burned, the tip goes to the validator. Setting
maxFeePerGas = 2 × currentBaseFee + tip gives you a worst-case runway of
roughly six consecutive 100%-full blocks before your transaction becomes
under-priced. That's the standard defensive ceiling for non-latency-sensitive
payment transactions.
Reading fee history: eth_feeHistory#
eth_feeHistory is the canonical input for any EIP-1559 fee oracle. It
returns per-block baseFee and reward percentiles over a sliding window:
const history = await provider.send('eth_feeHistory', [
20, // blocks to look back
'latest',
[10, 25, 50], // reward percentiles (economy / standard / fast)
]);The response carries:
baseFeePerGas[21]— baseFee for the 20 historical blocks and the next block at index[20]. This next-block value is deterministic: the protocol computes it from the latest block's gas usage, so it is never stale.reward[20][3]— the P10/P25/P50 miner tip for each historical block.gasUsedRatio[20]— block utilisation (0–1), critical for surge detection.
Pull the next-block baseFee directly:
const nextBaseFee = BigInt(
history.baseFeePerGas[history.baseFeePerGas.length - 1],
);Priority fee: percentile selection with guardrails#
Three decisions: which percentile, what to do with sparse or saturated blocks, and where to enforce floors and caps.
const PER_CHAIN_MIN_TIP: Record<string, bigint> = {
ethereum: 1_000_000_000n, // 1 gwei — below this validators can ignore you
polygon: 30_000_000_000n, // 30 gwei — Polygon gas policy floor
bnb: 1_000_000_000n,
};
function selectTip(
chain: string,
history: FeeHistory,
tier: 'economy' | 'standard' | 'fast',
): bigint {
// P10 = economy, P25 = standard, P50 = fast
const col = { economy: 0, standard: 1, fast: 2 }[tier];
// Discard blocks with <5% or >99% utilisation — their reward distributions
// are not representative of a competitive fee market.
const valid = history.reward
.filter((_, i) => {
const r = history.gasUsedRatio[i];
return r > 0.05 && r < 0.99;
})
.map(r => BigInt(r[col]));
if (valid.length === 0) return PER_CHAIN_MIN_TIP[chain];
// Rolling median over the valid window
const sorted = [...valid].sort((a, b) => (a < b ? -1 : 1));
const median = sorted[Math.floor(sorted.length / 2)];
const floor = PER_CHAIN_MIN_TIP[chain];
// Cap at 3 × nextBaseFee to prevent a single NFT-mint spike inflating estimates
const cap = nextBaseFee * 3n;
return median < floor ? floor : median > cap ? cap : median;
}The cap at 3 × nextBaseFee is important. Without it, one block with
hundreds of 200-gwei NFT-mint tips will inflate your estimate for the
remainder of the 20-block window.
Gas limit: eth_estimateGas with per-method floors#
eth_estimateGas simulates the call and returns the gas the EVM will consume
at current chain state. Two caveats before you trust it:
- It throws if the call would revert. Distinguish simulation failures (state inconsistency, chain tip race) from genuine reverts — only surface the latter to callers.
- State can shift between estimate and execution. A concurrent sweep touching the same storage slot can change the real cost by 10–20%.
For ERC-20 transfers, gas breaks down predictably:
| Component | Gas |
|---|---|
| Transfer logic | ~20 000 |
| Recipient holds prior balance (warm SSTORE) | ~5 000 |
| Recipient holds no prior balance (zero-to-nonzero SSTORE) | ~20 000 |
Transfer event |
~1 500 |
The difference between a funded and a zero-balance recipient is roughly 15 000 gas — material when you're sweeping micro-deposits.
const GAS_FLOORS: Record<string, number> = {
'erc20.transfer': 52_000, // funded recipient
'erc20.transfer.new': 72_000, // zero-balance recipient
'eth.transfer': 21_000,
};
async function estimateGasLimit(
call: { to: string; data: string; from: string },
method: string,
): Promise<number> {
let simulated: number;
try {
const hex = await provider.send('eth_estimateGas', [call]);
simulated = Number(hex);
} catch (err: any) {
if (err.message?.includes('revert')) throw err; // real revert — propagate
simulated = GAS_FLOORS[method] ?? 21_000; // simulation glitch — use floor
}
const floor = GAS_FLOORS[method] ?? 21_000;
// Cap at 3 × floor to reject wildly broken estimates
return Math.min(Math.max(simulated, floor), floor * 3);
}Gas limit estimates are cached by (method, token, recipientType) for up to
10 minutes — see the caching section below for why this is safe.
Surge detection and circuit breakers#
Because baseFee can rise at most 12.5% per block, a doubling requires at least six consecutive 100%-full blocks. You want to stop sending non-urgent transactions before the damage compounds.
function isSurging(history: FeeHistory, nextBaseFee: bigint): boolean {
const recent = history.gasUsedRatio.slice(-6);
// Pattern 1: 4 of the last 6 blocks above 90% utilisation
if (recent.filter(r => r > 0.9).length >= 4) return true;
// Pattern 2: nextBaseFee more than 3× the 20-block median
const baseFees = history.baseFeePerGas.slice(0, -1).map(BigInt);
const sorted = [...baseFees].sort((a, b) => (a < b ? -1 : 1));
const median = sorted[Math.floor(sorted.length / 2)];
return nextBaseFee > median * 3n;
}When surgeActive is true, BchainPay queues non-time-sensitive disbursements
(treasury sweeps, batch payouts) rather than sending them at the elevated
rate. The fast tier is still used for live checkout confirmations where
the merchant needs finality quickly. The surgeActive flag is returned in
the fee estimate response so downstream systems can apply their own queuing
policy without reproducing the detection logic.
Caching: two strategies for two types of data#
Fee data and gas-limit data have very different staleness characteristics:
| Data | Cache TTL | Invalidate on |
|---|---|---|
baseFeePerGas, maxFeePerGas, maxPriorityFeePerGas |
1–2 blocks (~12–24 s) | Next block arrival |
gasLimit |
Up to 10 minutes | Token contract upgrade; new recipient type |
Fee data must expire quickly because nextBaseFee is a function of the
latest block. An estimate from five blocks ago on a rising market may price
the transaction below the current baseFee.
Gas-limit data can be cached far longer. The EVM gas consumed by
USDC.transfer(recipient, amount) when the recipient already holds a balance
has been 52 000–53 000 gas for years. Re-simulating on every block burns RPC
quota without improving accuracy. Key the cache entry on
(method, tokenAddress, recipientType) and set a reasonable upper bound.
Oracle observability: the feedback loop#
A fee oracle that doesn't measure its own accuracy will drift. After each transaction confirms, compare the oracle's estimate to the on-chain outcome:
const receipt = await provider.getTransactionReceipt(txHash);
const gasUsed = receipt.gasUsed;
const feePaid = receipt.effectiveGasPrice * gasUsed;
const feeEstimate = BigInt(txRecord.maxFeePerGas) * BigInt(txRecord.gasLimit);
const inclusionLag = receipt.blockNumber - txRecord.submittedAtBlock;
metrics.record({
chain,
method,
estimationError: Number(gasUsed - txRecord.gasLimitEstimate)
/ txRecord.gasLimitEstimate,
inclusionLag,
overpayRatio: Number(feeEstimate - feePaid) / Number(feePaid),
});Alerts that have caught real regressions in production:
inclusionLag > 3blocks for astandard-tier transaction: the oracle is under-pricing tips on this chain.overpayRatio > 2.5consistently: the oracle is over-pricing, usually because a surge subsided but the tip cap hasn't relaxed.|estimationError| > 15%on a known method: a token contract may have been upgraded.
These metrics feed back into tip floor and cap adjustments. Without them, a chain that changes its gas policy — Polygon's periodic tip spikes are a recurring example — will silently degrade your inclusion rate.
Key takeaways#
[ ] Use eth_feeHistory(20, 'latest', [10, 25, 50]) for all fee inputs
[ ] nextBaseFee = baseFeePerGas[last] — deterministic, no staleness risk
[ ] maxFeePerGas = 2 × nextBaseFee + tip — tolerates ~6-block surge
[ ] Filter out blocks with <5% or >99% utilisation before percentile
[ ] Enforce per-chain minimum tip floor (e.g. 1 gwei Ethereum, 30 gwei Polygon)
[ ] Cap tip at 3 × nextBaseFee to absorb spike outliers
[ ] Funded vs zero-balance recipient has 15 000 gas difference for ERC-20
[ ] Cache fee data 1–2 blocks; cache gas-limit data up to 10 minutes
[ ] Detect surge via consecutive high-utilisation blocks, not baseFee alone
[ ] Queue non-urgent disbursements when surgeActive = true
[ ] Record inclusionLag, estimationError, overpayRatio per confirmed tx
[ ] Add L1 data-submission cost separately for rollup chainsBchainPay exposes the fee oracle as a read-only endpoint so you can consume accurate, per-block estimates without building your own RPC fan-out. All token/chain pairs we support are pre-warmed; estimates refresh every block with the feedback loop described above. Sandbox keys are free in the dashboard.