Skip to content

feat(fee): take the fee cap from the chain, not from three constants - #74

Open
bdchatham wants to merge 6 commits into
mainfrom
brandon2/fee-cap-from-chain
Open

feat(fee): take the fee cap from the chain, not from three constants#74
bdchatham wants to merge 6 commits into
mainfrom
brandon2/fee-cap-from-chain

Conversation

@bdchatham

Copy link
Copy Markdown
Contributor

Fourth on the stack, after #71, #72 and #73. Same defect class as #73, other half.

The defect

Three hard-coded fee caps, and the live base fee has passed one of them:

path declared
CreateTransactionOpts — every contract scenario 20 gwei rejected on pacific-1 and atlantic-2
CreateDeploymentOpts, funder 100 gwei clears, by luck
EVMTransfer 200 gwei clears, by luck

Read live while writing this:

chain          baseFee  gasPrice   old cap   new cap   verdict
arctic-1        10.0g     11.0g     20.0g     55.0g   old=ok,       new=ok
pacific-1       50.0g     55.0g     20.0g    275.0g   old=REJECTED, new=ok
atlantic-2      50.0g     55.0g     20.0g    275.0g   old=REJECTED, new=ok

A cap under the base fee fails at the fee ante, before the EVM runs and after
the nonce is consumed. So it lands as a receipt with a failed status rather than
no receipt, and a run with trackReceipts off reports it as sent — the same
invisible failure the gas limits had, for the same reason: a number written down
once cannot be right on a chain that reprices.

What replaces them

Startup asks the chain what gas costs and scales it, before anything is
signed
— a deployment declares a cap too. All four paths read that one value,
so no two can drift apart again.

⛽ gas price 10000000000 wei, fee cap 50000000000 wei (x5.0)

Every path fails closed with no resolved cap. There is no fallback constant,
because a fallback constant is what this removes.

Why the multiple is 5

The cap is a ceiling, not a price. A transaction pays the base fee; the cap
only says how high it will follow one. So a generous multiple costs nothing per
transaction. It costs balance — the chain locks cap × gas limit while a
transaction is in flight — and that is the only argument against a larger one.

It has to be generous because the base fee moves. Sei raises it by up to ~1.9%
per block while blocks are full, which is exactly the state a load run exists to
produce. At 5× the reported price the cap outlives about 90 such blocks; at
the reported price itself, 5.

Scaling stays in the integer domain. A wei price exceeds what a float64 holds
exactly, and a cap that shifted for that reason would look like the chain
disagreeing with itself.

Verification

Five mutations, five caught:

  • the multiplier dropped, so the cap equals the bare price
  • the resolved cap never stored
  • scaling routed through float64
  • the fee cap falling back to the old 20 gwei constant
  • a scenario generating with no cap resolved

The first two are worth naming. My first drift guard survived the mutation it
was named for
— it asserted scaleWei directly and never drove the resolver
that calls it, so removing the multiplier from the resolver couldn't fail it.
Replaced with a test that runs startup against a chain and reads the cap back off
the config. Both mutations are caught now.

gofmt, go vet and golangci-lint run are clean. All 15 packages pass.

What this does not do

The cap is resolved once at startup and held. A run long enough for the base fee
to climb past 5× its starting point would begin failing, and nothing detects
that. The cheapest fix is one eth_gasPrice per stats interval — deferred, and
it un-defers the first time a run reports a healthy send rate with a falling
inclusion rate.

🤖 Generated with Claude Code

bdchatham and others added 5 commits August 27, 2026 20:25
The scenario declared 22460 gas for a mint. Measured against the deployed
binding, a mint to a receiver holding none of the token needs 69319, and one to
a receiver that already holds some needs 51757. Every mint the scenario sent
landed in a block with a failed status, having burned the whole limit, and
trackReceipts defaults to false so the run reported each one as sent.

22460 is ERC20Noop's constant, copied. PLT-1091 covers the two scenarios that
still carry it.

The limit is now 75000, and the test pins it against the measurement rather than
against itself. Broke the constant back to 22460 and to 200000 on purpose; the
test caught both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A DeFi profile had no contract to drive. This adds a constant-product pair with
the storage and gas shape of a UniswapV2 swap: both reserves, the caller's
balance in each token, and an event.

The contract never reverts on bookkeeping, which is the choice StorageRWv1
already makes. The balances wrap rather than check, because nothing reads them
back and a load generator that fails on its own accounting stops measuring the
chain. A short caller is not credited: crediting exactly what is then debited
returns the slot to zero, and a zero to non-zero storage write costs four times
one that changes a slot already holding a value. Under the default mix, which
draws one direction, that write would land on every swap rather than the first.

The reserves sit between a floor and a ceiling. Without the ceiling the input
side grows without bound and the output halves every 100000 swaps, so a long run
prices nothing like its start. The ceiling is also what keeps one oversized call
from ending the pair: a swap of 1e49 leaves the input reserve at 1e49, and the
contract has no owner and no reset. Measured, the next ordinary swap instead
resets that side to the floor and pays out in full.

The gas limit is 85000, read from eth_estimateGas rather than from a receipt.
GasUsed is the post-refund charge and a transaction carries the pre-refund peak;
sizing from a receipt put an earlier draft 20% under what its own swap needed.
An account's first swap needs 79988 and every later one needs 45177, so a run in
steady state declares about 44% more gas than it spends. PLT-1093 carries the
prewarm change that would close that. PLT-1092 carries the chain-parameter
exposure, which is the whole package rather than this constant.

Every guard here was broken on purpose before it was believed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A deployment has no way to tell a run that is starting from one that is stuck.
The process serves /metrics and nothing else, so a probe set has nothing to
gate on and a pod counts as available the moment its container starts.

/healthz answers as soon as the server binds and never reads the startup
sequence. /readyz refuses until the dispatcher is running.

Keeping those separate is the whole point. Funding, deployment and prewarm take
minutes against a cold chain. A liveness probe that reported the run dead for
that window would restart the pod before it sent a transaction, then restart the
next attempt at the same place, and the cause would read as a crash loop rather
than a slow start.

While /readyz refuses it names the phase, so a ten-minute startup shows the step
it is on. Measured against the binary: healthz held 200 through a 21 second
prewarm while readyz reported "prewarming accounts", then both answered once the
dispatcher started.

The flag and the phase are stored as one value rather than as two atomics. Two
would leave a window where a reader sees the run serving while the body still
names the step it left, so the status and the body would disagree about the same
instant.

Five mutations, five caught, including that one: split into two atomics, a
reader observed a serving status carrying "funding accounts".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every contract scenario declared a gas limit as a constant. Those constants
assume the EVM default of 20,000 for a storage write that takes a slot from zero
to a value. Sei sets that as a governance parameter and its live networks charge
72,000, so every one of them is short on Sei by a factor.

Measured against arctic-1: an AMM swap needs about 185,000 where the constant
said 85,000, an ERC20 transfer 175,097 against 72,156, an ERC721 mint 174,782
against 75,000. A short limit does not fail visibly. The transaction reaches a
block, burns the whole limit, and a run without receipt tracking reports it as
sent. ERC20Noop was short by eight gas with no Sei parameter involved at all,
which is the argument against hand-picked constants in one line.

A scenario now declares GasEstimateCalls, one per operation it issues, and the
preparation step asks the chain what each costs after the contracts are bound.
ContractScenarioBase does not implement it, so a scenario added without one does
not compile — the same gate that already forces Operation().

The priced call is the expensive shape. Cost is bimodal per account: the first
transaction from an address writes slots holding zero. Pricing from a freshly
generated address makes those slots cold by construction, so the measurement
bounds what a run sends rather than describing its cheap case. The call carries
no fee cap, because a call carrying one makes the node check the caller's
balance and this caller has none; verified against arctic-1, where the same
estimate succeeds without fee fields and fails with them.

Calldata is recomposed rather than measured. GasModel keeps the execution term
apart from the calldata term, so StorageRW reuses one measurement across every
pad it draws. The recomposition calls the chain's own IntrinsicGas and
FloorDataGas, so it is exact rather than fitted, and it covers the EIP-7623 floor
that Sei's ante does not check. That deletes storageRWBaseGas, abiWord and
calldataFloorGasPerByte along with the per-scenario constants.

Pricing fails the run rather than falling back. A fallback is a cold branch that
runs exactly when the estimate could not be trusted, and its failure is the
invisible kind.

Margin defaults to 1.20 and is a profile setting. Sei fills a block against two
budgets, one charged at the declared limit and one at what the transaction
spends, and the declared one binds only past four times the spend. Below that,
margin costs no block space.

A profile of native transfers alone prices nothing and issues no extra call.

Five mutations, five caught: an operation left unpriced, two operations priced
against one method, the limit stopping coming from the model, a scenario
declaring no calls at all, and the decomposition failing to round-trip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The repo carried three hard-coded fee caps: 20 gwei for a contract call, 100 for
a deployment and for funding, 200 for a native transfer. The live base fee is 50
gwei on pacific-1 and atlantic-2, so the first of the three was rejecting every
transaction it priced and the other two were guesses that happened to clear.

A cap under the base fee fails at the fee ante, before the EVM runs and after the
nonce is consumed. So it produces a receipt with a failed status rather than no
receipt at all, and a run without receipt tracking reports it as sent. Same
failure mode as the gas limits, same cause: a number written down once cannot be
right on a chain that reprices.

Startup now asks the chain what gas costs and scales it, before anything is
signed. Every path reads that one value, so no two of them can drift apart
again. Measured: the derived cap clears the base fee on all three networks, where
the constant cleared it on one.

The multiple is 5 by default and is a profile setting. The cap is a ceiling, not
a price: a transaction pays the base fee and the cap only says how high it will
follow one, so a generous multiple costs nothing per transaction. It costs
balance, because the chain locks the cap times the gas limit while a transaction
is in flight, and that is the only reason not to make it larger.

It has to be generous because the base fee moves. Sei raises it by up to about
1.9% per block while blocks are full, which is the state a load run exists to
produce. At five times the reported price the cap outlives about ninety such
blocks; at the reported price itself it outlives five.

Scaling stays in the integer domain. A wei price passes what a float64 holds
exactly, and a cap that moved for that reason would look like a chain
disagreeing with itself.

Five mutations, five caught. The first version of the drift guard survived the
mutation it named, because it asserted the arithmetic directly and never drove
the resolver that uses it. It now runs startup against a chain and reads the cap
back off the config.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cursor

cursor Bot commented Aug 29, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes how every signed transaction is priced on live chains; incorrect resolution or multiplier could cause widespread ante rejections or excess balance lock, though fail-closed behavior and tests mitigate regressions.

Overview
Replaces three hard-coded gas fee caps (contract txs, deployments/funding, native transfers) with a single cap resolved once at startup from eth_gasPrice, scaled by a new gasFeeCapMultiplier setting (default ). The cap is stored on LoadConfig via SetGasFeeCap / GetGasFeeCap; paths that sign transactions fail closed if it was never resolved.

Startup runs resolveGasFeeCap before deployments and signing; dry runs use a placeholder cap. GasFeeCapMultiplier is validated (≥ 1), wired through Viper/defaults, and scaling uses integer-safe scaleWei.

Related fixes: GasEstimateCall.Value and estimator timeouts so priced calls (e.g. Disperse) match real msg.value; gas limits are cached at pricing time so the send path does not rebuild estimate calls (avoiding per-tx keygen). Tests cover startup resolution, base-fee headroom on live network fixtures, and refusal to generate without a cap.

Reviewed by Cursor Bugbot for commit d51fb14. Bugbot is set up for automated code reviews on this repo. Configure here.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replacing the three hard-coded fee caps with one value resolved from the chain at startup is a sound change, well-tested and fail-closed on every path it touches. Two non-blocking gaps remain: the priority tip is still a constant that the derived cap can now fall below, and the multiplier validation admits non-finite values that corrupt the cap arithmetic.

Findings: 0 blocking | 4 non-blocking | 3 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • None at the file/PR level.
  • 3 suggestion(s)/nit(s) flagged inline on specific lines.
  • 1 non-blocking pre-existing issue(s) listed below under pre-existing issues.

Pre-existing issues

  • [suggestion] generator/scenarios/EVMTransferNoop.go:54 hard-codes a 20 gwei fee cap and generator/scenarios/EVMTransferFast.go:55 hard-codes 200 gwei. Both are registered scenarios (generator/scenarios/factory.go), and by this PR's own measurements the 20 gwei cap is rejected at the fee ante on pacific-1 and atlantic-2. The PR leaves both untouched, so evmtransfernoop remains broken on those networks.

Comment thread generator/utils/utils.go
// gasTipCapWei is the priority fee (2 gwei). It is a tip rather than a
// ceiling, so unlike the fee cap it does not have to track what the chain
// charges; a transaction is admitted on its cap.
gasTipCapWei = 2_000_000_000

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The fee cap is now derived from the chain but the tip stays a 2 gwei constant, which breaks an invariant the old constants held for free (2 gwei tip < 20 gwei cap, always). A transaction is invalid when GasTipCap > GasFeeCap (ErrTipAboveFeeCap), so on any chain whose eth_gasPrice is under ~0.4 gwei the resolved cap (5x) lands below this tip and every transaction is rejected — including the deployment path, which is a harder failure than the one this PR fixes. Local/dev endpoints are the realistic exposure (profiles/local.json, profiles/local_docker.json).

Cheapest fix is to clamp at the point of use: auth.GasTipCap = big.NewInt(min(gasTipCapWei, feeCap.Int64())), or better, derive the tip from the same resolved value. The same applies to EVMTransfer.go:67 and to the funder's 1 gwei tip.

Comment thread config/settings.go
if s.MaxInFlight <= 0 {
return fmt.Errorf("MaxInFlight = %v, want > 0", s.MaxInFlight)
}
if s.GasFeeCapMultiplier < 1 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] NaN < 1 and +Inf < 1 are both false, so a config carrying either passes validation. GetGasFeeCapMultiplier (config/config.go) repeats the same < 1 test and hands the value straight through, and scaleWei then evaluates int64(factor * scale) at generator/fee.go:50 — an out-of-range float-to-int conversion is implementation-defined in Go and yields MinInt64 on amd64, producing a large negative fee cap that every subsequent transaction declares. A finite but huge multiplier (>~9.2e15) overflows the same conversion.

Suggest rejecting non-finite values and bounding the upper end explicitly, e.g. if math.IsNaN(s.GasFeeCapMultiplier) || s.GasFeeCapMultiplier < 1 || s.GasFeeCapMultiplier > someMax.

Comment thread generator/utils/utils.go
// funding is configured that stream belongs to the root key. A cap the base
// fee has passed puts the stream's weakest-priced transaction at its head and
// blocks every later root transaction until someone replaces the nonce by
// hand. Every path takes the one cap the run resolved from the chain, so no

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] "Every path takes the one cap the run resolved from the chain, so no two of them can drift apart" is not true as of this change: EVMTransferFast.go:55 still declares a hard-coded 200 gwei and EVMTransferNoop.go:54 still declares 20 gwei — the exact constant the PR description marks as REJECTED on pacific-1 and atlantic-2. Both are registered in factory.go, so a profile naming either scenario keeps the old behaviour.

These two are three-line changes mirroring EVMTransfer.go:56-68; converting them would make the comment accurate and finish the stated scope. Otherwise the claim should be softened to name the paths it actually covers.

…requires

Three review findings, all correct.

The send path rebuilt the priced call on every transaction. GasLimitFor derived
its answer from the call's calldata, and building that call mints a fresh
address, so a secp256k1 keypair was generated per transaction on the path this
change exists to keep free of work. The limit is now resolved once, while the
chain is being asked, and the send path reads a number. Measured on the AMM
scenario, allocations per generated transaction fell from 50 to 39; the
scenarios that mint an address in their priced call were paying far more.

The PR body claimed the send path issues no estimate. It did not, but it did do
per-transaction keygen and ABI packing, which is the same claim broken a
different way. A test now counts how often the priced call is built and fails if
that number moves with the number of transactions.

Disperse could not be priced at all. disperseEtherFixed opens with
require(msg.value == fixedEtherAmount * recipients.length), and a priced call
carried no value, so the estimate reverted and any profile naming disperse
refused to start. GasEstimateCall now carries a value, and the send path sets it
too — which it never did, so every disperse reverted on entry and burned its
limit while reporting as sent. A contract bound from a registry entry could hold
a different fixedEtherAmount; reading it back off the contract needs
GasEstimateCalls to be able to report a failure, and the comment says so.

Each quote now has its own timeout inside the step's collective budget, so one
endpoint that accepts a request and never answers cannot spend the ceiling for
every scenario behind it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit d51fb14. Configure here.

Comment thread generator/gas.go
From: types.NewAccount(false).Address,
To: &address,
Data: call.Data,
Value: call.Value,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per-call timeout leaks deadline sentinel

High Severity

gasEstimator bounds each quote with context.WithTimeout and returns the EstimateGas error unchanged, then callers wrap it with %w. When the 10s budget expires and the outer 60s WithinBudget has not, DeadlineExceeded reaches runLoadTest, which treats it as a clean exit.

Additional Locations (2)
Fix in Cursor Fix in Web

Triggered by learned rule: Sub-operation context sentinels must not escape to runLoadTest

Reviewed by Cursor Bugbot for commit d51fb14. Configure here.

Comment thread generator/gas.go
From: types.NewAccount(false).Address,
To: &address,
Data: call.Data,
Value: call.Value,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Estimate sends value from empty account

Medium Severity

gasEstimator now forwards Value on eth_estimateGas from a freshly minted address that holds no balance. A value-bearing call from that sender fails the node's funds check, so Disperse pricing cannot complete and a run that includes it never starts.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d51fb14. Configure here.

@bdchatham
bdchatham changed the base branch from brandon2/gas-estimate-calls to main August 29, 2026 03:43
bdchatham added a commit that referenced this pull request Aug 29, 2026
…hind it

Re-review confirmed the three behaviour fixes and raised five smaller things.
The one worth taking before merge was the shape of the new test.

It compared calldata costs and threw the transaction's own limit away. Two
different failures land there and only one is about calldata: a probe that prices
a cheaper call than the run makes produces a short limit, and so does a scenario
that prices correctly and then never reads the measurement back. The second is
what Disperse did, and it has now been found twice by review rather than once by
this suite. Asserting the limit catches both.

It also fits a scenario whose calldata varies, which the calldata form did not.
StorageRW recomposes against the bytes it is about to send, so its probe has no
obligation to bound them, and it passed the old assertion only because the
fixture left the size distribution unset. A default pad added later would have
failed a scenario that was correct.

The assertion caught Disperse immediately, wanting 258,072 against the 200,000 it
declared. Rather than skip it, the Disperse fix comes down from #74: the priced
call carries the value the contract requires, and the send path reads the
measurement and sets that value too. That defect is in this diff, so it belongs
in this commit.

Also from the re-review: deleted GasLimitForData, which nothing called once the
probe-maximal route was taken; keyed MaxGasLimitForData's presence check on the
map rather than on a comparison, which reported a zero execution term as missing;
named what the block-fit guard does not cover, since it evaluates the probe's
bytes and a varying-calldata scenario can pass it and still exceed a block; gave
mockGasLimits the caller's context; and stopped minting a secp256k1 key to
produce twenty bytes that are only ever an ABI argument, which Disperse asked for
a hundred of per call.

Two mutations, two caught: a scenario that prices and never reads it back, and a
probe address left holding zero bytes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant