feat(fee): take the fee cap from the chain, not from three constants - #74
feat(fee): take the fee cap from the chain, not from three constants#74bdchatham wants to merge 6 commits into
Conversation
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>
PR SummaryMedium Risk Overview Startup runs Related fixes: Reviewed by Cursor Bugbot for commit d51fb14. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
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:54hard-codes a 20 gwei fee cap andgenerator/scenarios/EVMTransferFast.go:55hard-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, soevmtransfernoopremains broken on those networks.
| // 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 |
There was a problem hiding this comment.
[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.
| if s.MaxInFlight <= 0 { | ||
| return fmt.Errorf("MaxInFlight = %v, want > 0", s.MaxInFlight) | ||
| } | ||
| if s.GasFeeCapMultiplier < 1 { |
There was a problem hiding this comment.
[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.
| // 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 |
There was a problem hiding this comment.
[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>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
❌ 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.
| From: types.NewAccount(false).Address, | ||
| To: &address, | ||
| Data: call.Data, | ||
| Value: call.Value, |
There was a problem hiding this comment.
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)
Triggered by learned rule: Sub-operation context sentinels must not escape to runLoadTest
Reviewed by Cursor Bugbot for commit d51fb14. Configure here.
| From: types.NewAccount(false).Address, | ||
| To: &address, | ||
| Data: call.Data, | ||
| Value: call.Value, |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit d51fb14. Configure here.
…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>


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:
CreateTransactionOpts— every contract scenarioCreateDeploymentOpts, funderEVMTransferRead live while writing this:
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
trackReceiptsoff reports it as sent — the sameinvisible 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.
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 first two are worth naming. My first drift guard survived the mutation it
was named for — it asserted
scaleWeidirectly and never drove the resolverthat 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 vetandgolangci-lint runare 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_gasPriceper stats interval — deferred, andit un-defers the first time a run reports a healthy send rate with a falling
inclusion rate.
🤖 Generated with Claude Code