An EIP-1559 fee ceiling is a hard bound, not advice
An estimator that reads the caller's maximum fee as a suggestion breaks in two quiet ways. It can clamp maxFeePerGas to a value under the current base fee and hand you back a transaction the chain will never include. Or it can clamp maxFeePerGas and leave maxPriorityFeePerGas sitting where it was, so the two fee fields in the result contradict each other. The ceiling is the only number in the whole calculation that came from the caller, and it is the one number the estimator does not get to round in its own favour.
I have been moving tokens on Ethereum since 2018, and the ceiling is the field that most often turns out to be decoration. It gets read, multiplied by something, min'd against something else, and then quietly treated as a target rather than a limit. The code reads as correct, because min is the right operator for a maximum. What goes wrong is which field it lands on.
Three numbers, two fields
A type-2 transaction has two fee fields and three quantities trying to live in them. The base fee is what the next block will charge, and it is not yours to choose. The tip is what you offer on top to get included sooner. The ceiling is what you are willing to pay per unit of gas in total, and out of the three it is the only one that is a decision rather than an observation.
The shape I keep finding in payout code is this:
fees = {
"maxFeePerGas": min(base_fee * 2 + tip, ceiling),
"maxPriorityFeePerGas": tip,
}Two bugs in four lines. If ceiling sits below base_fee, the first line produces a maxFeePerGas under the base fee, and that is not a cheap transaction: it is a transaction that sits in the mempool until it gets dropped, having told nobody why. And when the min does bite, the second line goes on advertising a tip the first line can no longer pay for.
I pulled the fee logic out of my transfer code and into a package, erc20-transfers, mostly so the pricing would be testable without a chain in front of it. The entry point is estimate_fees, and it takes max_fee_ceiling as a required keyword argument. No default, because a default ceiling is a ceiling somebody else chose for your money.
Under the base fee there is nothing to clamp
The first case is not really a clamp. If the caller's ceiling already sits below what the next block charges, every value the estimator could return is wrong: it can obey the ceiling and produce an unincludable transaction, or honour the chain and break the promise it was handed. Neither one is a result. So it raises:
from erc20_transfers import FeeCeilingTooLow, estimate_fees, gwei
try:
estimate_fees(
base_fee_per_gas=gwei(100),
priority_fee_per_gas=gwei(1),
max_fee_ceiling=gwei(80),
)
except FeeCeilingTooLow as error:
print(error.base_fee_per_gas, error.max_fee_ceiling)The message says what the caller has to do about it:
a ceiling of 80 gwei is below the base fee of 100 gwei; a transaction whose
maxFeePerGas is under the base fee is never included, so raise the ceiling or
wait for the base fee to fallFeeCeilingTooLow subclasses ValueError, so code that only wants to retry later can catch it specifically, and code that treats bad inputs uniformly keeps working. The point is that the refusal happens in the estimator and not three layers down in a stuck-transaction sweeper, where the only evidence left is a hash and a timestamp. A withdrawal rejected before signing is a cheap failure: nothing is on chain, nothing needs replacing, and the error names the constraint that got violated.
The clamp lands on the tip, or it lands nowhere
The second case is the interesting one, because the naive version produces a transaction that works. Here is the arithmetic in estimate_fees:
max_fee = min(uncapped, ceiling)
return Eip1559Fees(
base_fee_per_gas=base,
max_fee_per_gas=max_fee,
max_priority_fee_per_gas=min(tip, max_fee - base),
max_fee_ceiling=ceiling,
uncapped_max_fee_per_gas=uncapped,
)The tip gets trimmed to min(tip, max_fee - base), to whatever actually fits between the base fee and the cap. Two ceilings over the same inputs show why that second min is not cosmetic:
from decimal import Decimal
roomy = estimate_fees(
base_fee_per_gas=gwei(100),
priority_fee_per_gas=gwei(1),
max_fee_ceiling=gwei(120),
headroom_blocks=2,
)
assert roomy.capped
assert roomy.max_fee_per_gas == gwei(120)
assert roomy.max_priority_fee_per_gas == gwei(1)
assert roomy.headroom == gwei(19)
tight = estimate_fees(
base_fee_per_gas=gwei(100),
priority_fee_per_gas=gwei(1),
max_fee_ceiling=gwei(Decimal("100.5")),
headroom_blocks=2,
)
assert tight.capped
assert tight.max_fee_per_gas == gwei(Decimal("100.5"))
assert tight.max_priority_fee_per_gas == gwei(Decimal("0.5"))
assert tight.headroom == 0Both are capped, and only the second has its tip cut. At a 120 gwei ceiling there are 20 gwei between the base fee and the cap, so a 1 gwei tip fits unchanged. At a 100.5 gwei ceiling you have half a gwei of room, and the 1 gwei tip does not fit.
Now the part worth being precise about: skipping the tip trim would not have broken anything on chain. The protocol computes the effective tip as the smaller of maxPriorityFeePerGas and maxFeePerGas - baseFee once the transaction is included, so an over-advertised tip just gets ignored. The trim is not for the chain. It is for you. An untrimmed tip means the transaction you signed, logged, and will later show to somebody asking why a withdrawal is slow says you offered 1 gwei when the most you could ever pay was 0.5. The record of your own urgency is wrong, and the dashboard built on it is wrong in the same direction.
The headroom property is the other half of that honesty. In the tight case it is zero: nothing is left between base fee, tip and cap, so the next block's base fee moving up at all pushes this transaction out of range. That is a stall waiting to happen, visible before broadcast instead of twenty minutes after.
Headroom is a bound, not a forecast
The uncapped value above comes from base_fee_headroom, and I want it kept separate from the ceiling because the two answer different questions. The ceiling is a budget. The headroom is a worst case: what the base fee could reach if every one of the next few blocks came in completely full.
from erc20_transfers import base_fee_headroom, next_base_fee
assert base_fee_headroom(gwei(100), blocks=0) == gwei(100)
assert base_fee_headroom(gwei(100), blocks=1) == 112_500_000_000
assert base_fee_headroom(gwei(100), blocks=2) == 126_562_500_000One full block raises a 100 gwei base fee to 112.5 gwei, and two compound to a little over 126.5. MAX_HEADROOM_BLOCKS is 64, and asking for 65 raises rather than returning a number with no meaning. Compounding the maximum per-block increase 64 times predicts nothing: it is the point past which the arithmetic stops bounding reality and starts bounding nothing at all.
next_base_fee applies the same EIP-1559 rule for exactly one block, for callers holding a block that just closed instead of a pending base fee:
assert next_base_fee(base_fee_per_gas=gwei(100), gas_used=target, gas_limit=limit) == gwei(100)
assert next_base_fee(base_fee_per_gas=gwei(100), gas_used=limit, gas_limit=limit) == 112_500_000_000
assert next_base_fee(base_fee_per_gas=gwei(100), gas_used=0, gas_limit=limit) == 87_500_000_000
assert next_base_fee(base_fee_per_gas=7, gas_used=target + 1, gas_limit=limit) == 8That last line is the integer-arithmetic detail that bites people writing their own: a block over target moves the fee up, even when the computed delta rounds down to zero. At a base fee of 7 wei the increase is a fraction of a wei, and the rule still says the fee rises.
The tip comes from priority_fee_from_history, which takes the median of recent rewards rather than the maximum, so one block that paid ten times the going rate does not set the price for the next one. An empty history raises instead of defaulting to zero, because fee history covering no blocks prices nothing.
Say who set the price
The caller who handed you a ceiling needs to know whether it was the binding constraint. Eip1559Fees carries both the value it would have produced and the limit it was given, and that is all capped is:
@property
def capped(self) -> bool:
"""Whether the ceiling, not the chain, decided `max_fee_per_gas`."""
return self.uncapped_max_fee_per_gas > self.max_fee_ceilingAnd explain() puts it in one line for a log or an operator screen:
print(tight.explain())
# base fee 100 gwei, paying up to 100.5 gwei with a 0.5 gwei tip; the ceiling of
# 100.5 gwei cut that back from 127.5625 gwei, so the transaction waits if the
# base fee keeps climbingThose two outcomes need different responses. A transaction that is slow because the chain is busy will go through on its own. A transaction that is slow because the operator's own cap bound it will not, however long anyone waits. Without capped both look identical from outside: a pending hash and a rising base fee. Keeping the uncapped value in the result costs one integer field and removes an entire class of pointless investigation.
The estimator stays pure, base fee in, tip in, ceiling in, fields out, and the calling code does the node work. In my tests that is a four-line helper over eth_feeHistory:
def fees_from(chain, *, ceiling):
history = chain.eth_fee_history(5, "latest", (50,))
return estimate_fees(
base_fee_per_gas=history["baseFeePerGas"][-1],
priority_fee_per_gas=priority_fee_from_history(
[block[0] for block in history["reward"]]
),
max_fee_ceiling=ceiling,
)as_transaction_fields() then returns the maxFeePerGas and maxPriorityFeePerGas pair to merge in before signing, and nothing else, so there is no path by which the estimator hands back a field the caller did not ask it to price.
What I would do differently
I used to carry the ceiling as a single integer through the signing path and apply it at the last moment, right before the transaction got built. It seemed safer: the latest possible check against the freshest base fee. It was worse. Applying a bound to one field at the edge of the system is where the related field gets forgotten, because the invariant between maxFeePerGas and maxPriorityFeePerGas is not visible from the line of code doing the clamping. Moving the ceiling into the function that computes both fields together made the invariant local, and made the min(tip, max_fee - base) line read as obvious rather than clever.
The other change I would make earlier is returning a result object instead of a dict. A dict of two fee fields cannot answer "who decided this", and that question comes up every single time a transfer is slow.
A ceiling is the caller saying: above this, I would rather not transact. An estimator that exceeds it has overspent someone else's money. An estimator that silently clamps under the base fee has produced a transaction that will never land while reporting success. The two honest answers are a refusal and a priced transaction that fits, plus one bit saying which of you chose the number.