Skip to content

feat: read gas limits, fee caps and contract addresses from the chain - #73

Merged
bdchatham merged 17 commits into
mainfrom
brandon2/gas-estimate-calls
Aug 29, 2026
Merged

feat: read gas limits, fee caps and contract addresses from the chain#73
bdchatham merged 17 commits into
mainfrom
brandon2/gas-estimate-calls

Conversation

@bdchatham

@bdchatham bdchatham commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Replaces the numbers this tool hard-codes with numbers it asks the chain for.

Sei charges 72,000 for a storage write that takes a slot from zero, against the
EVM default of 20,000. Every gas constant in the repo was calibrated against the
default, so every contract scenario was short on Sei — and a short limit does not
fail visibly. The transaction reaches a block, burns the whole limit, and a run
with trackReceipts off reports it as sent.

was arctic-1 needs
AMM.swapAToB 85,000 185,711
ERC20.transfer 72,156 175,242
ERC721.mint 22,460 175,000
fee cap (all paths) 20 gwei 55 gwei

What it does

A scenario declares GasEstimateCalls(); the run prices them once at startup,
after the contracts are bound, and the send path reads a number.
ContractScenarioBase does not implement the method, so a scenario added without
one does not compile.

The fee cap comes from eth_gasPrice at startup. Contract addresses for arctic-1
ship in registry/chains/, so a run binds them instead of redeploying on every
restart.

Pricing failure stops the run. There is no fallback constant, because a fallback
constant is what this removes.

Also here

  • /healthz and /readyz, and readiness now drops on every exit rather than
    only on a signal
  • an amm scenario, and ERC721's gas constant fixed before it was deleted

Verification

Run against arctic-1: contracts bound from the registry with no deployment sent,
gas quoted per operation, transactions executed — the AMM reserve moved off its
floor and ERC721 token 1 has an owner.

Every fix broken on purpose first. Four review rounds found five, three, two and
one defect; all are fixed here. Twice a fix introduced its own mirror image: the
value-carrying estimate now funds its caller through a state override, and the
per-quote timeout runs through WithinBudget so a hung endpoint reads as a
failure rather than a clean shutdown.

gofmt, go vet, golangci-lint clean; 15 packages passing.

Known

  • committing registry/chains/arctic-1.json makes genesisHash required for any
    arctic-1 profile that drives a contract scenario; profiles/arctic-1.json now
    carries it, but a profile kept outside this repo needs it added
  • the gas tip is still a constant while the cap is chain-derived, and StorageRW
    packs its calldata twice per send; both are flagged non-blocking on the PR
  • ERC721's token counter restarts at 1, so reusing a recorded contract wastes gas
    until it passes the previous high-water mark (PLT-1107)
  • gas and fee cap are resolved once and held; a long run outliving either is
    undetected
  • --duration N exits 1 at the end of a bounded run

Collapsed from #74, #75 and #76, which are closed. Deploys via
sei-protocol/platform#1587.

bdchatham and others added 4 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>
@cursor

cursor Bot commented Aug 29, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes core transaction pricing and startup prerequisites for all live runs; mis-estimation or RPC failures now block startup, but the fail-closed design reduces silent bad throughput on production Sei chains.

Overview
Replaces hard-coded gas limits and fee caps with values read from the chain once during generator preparation, so Sei networks with higher base fees and storage costs stop rejecting or silently failing transactions.

Fee caps come from eth_gasPrice scaled by a new gasFeeCapMultiplier (default 5). The resolved cap is stored on LoadConfig and required everywhere transactions are built—deployments, funding, contract sends, and native transfer scenarios—with no literal fallback.

Gas limits move to a startup measureGasLimits step after contracts are bound. Each contract scenario implements GasEstimateCalls(); the run estimates against cold “probe” callers (no fee fields; value-carrying calls use state overrides), applies gasMargin (default 1.2) via a GasModel that splits execution from calldata, and fails startup if pricing fails. StorageRW recomposes limits per drawn calldata and adds fixed read headroom; Disperse prices with the same recipient count and msg.value as the send path.

Registry: arctic-1 is committed under registry/chains/ with genesisHash in the profile so runs bind recorded contracts instead of redeploying every restart; docs and tests lock the shipped addresses.

Settings validation rejects multipliers/margins below 1; extensive tests cover base-fee headroom, estimator wire shape, hung-RPC budget errors, and fail-closed behavior when no cap is resolved.

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

Comment thread generator/scenarios/Disperse.go
Comment thread generator/scenarios/Disperse.go
Comment thread generator/gas.go
seidroid[bot]
seidroid Bot previously requested changes Aug 29, 2026

@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 hard-coded gas constants with a measured GasModel is the right call and the recomposition/round-trip tests are solid, but two PR-introduced defects block it: the Disperse probe omits msg.value so eth_estimateGas reverts and startup fails, and GasLimitFor rebuilds every scenario's probe calldata per transaction — generating a fresh secp256k1 key on the send path for the ERC20/ERC721 scenarios.

Findings: 2 blocking | 6 non-blocking | 5 posted inline

Blockers

  • None at the file/PR level.
  • 2 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • [suggestion] Test coverage for the new mechanism is uneven: requireGasMatchesModel is only wired into AMM_test.go and StorageRW_test.go, and the deleted ERC721_test.go was not replaced with an equivalent. TestEveryDrawableOperationIsPriced proves an operation is priced, but nothing proves the send path applies the measured limit — which is exactly how the Disperse gap (priced, then ignored) survives the suite. A table test over every contract scenario that prices with a known GasModel, generates a tx, and asserts tx.Gas() == model.Limit(tx.Data()) would close both that gap and the ERC20/ERC721 regression risk.
  • [suggestion] ContractScenarioBase.GasLimitForData (generator/scenarios/base.go:238) is added but never called — StorageRW uses MaxGasLimitForData and everyone else uses GasLimitFor. It is the method the per-operation scenarios should be using (see the inline comment on GasLimitFor); as it stands it is dead code.
  • 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/Disperse.go:82CreateContractTransaction calls DisperseEtherFixed without setting auth.Value, but the contract requires msg.value == fixedEtherAmount * recipients.length (100 wei, given the bigOne constructor args in DeployContract). Every disperse transaction therefore reverts on chain, and with trackReceipts off the run reports it as sent — the same invisible failure this PR exists to eliminate.

Comment thread generator/scenarios/Disperse.go Outdated
Comment thread generator/scenarios/base.go Outdated
Comment thread generator/scenarios/Disperse.go
Comment thread generator/scenarios/StorageRW.go
Comment thread generator/scenarios/StorageRW.go
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>
…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>
@bdchatham

Copy link
Copy Markdown
Contributor Author

All three findings confirmed and fixed. The fix landed in #74, one commit up the stack (d51fb14), rather than here — the branches merge in order, so it is in before anything ships, and moving it down would have meant force-pushing #74.

The send path rebuilt the priced call on every transaction. Correct, and worse than the comment says: GasLimitFor derived its answer from the call's calldata, and building that call mints a fresh address, so crypto.GenerateKey() ran once per generated transaction. The limit is now resolved once while the chain is being asked, and the send path reads a number. Allocations per generated transaction on the AMM scenario fell from 50 to 39; the scenarios whose priced call mints an address were paying far more than that.

My PR body claimed "the send path issues no estimate". Literally true and materially wrong — it did per-transaction keygen and ABI packing instead. There is now a test that counts how often the priced call is built and fails if that number moves with the number of transactions. It catches the regression.

Disperse could not be priced. Correct. disperseEtherFixed opens with require(msg.value == fixedEtherAmount * recipients.length) and the priced call carried no value, so any profile naming disperse refused to start. GasEstimateCall now carries a value.

The send path never set auth.Value either, so every disperse reverted on entry and burned its limit while reporting as sent — a pre-existing defect this change surfaced. Fixed in the same commit.

On reading fixedEtherAmount off the contract rather than a constant: agreed in principle. A contract this run deployed holds the constructed value by construction; one bound from a registry entry could hold another. Reading it back needs GasEstimateCalls to be able to report a failure, which is an interface change. The constant is documented with that limitation named rather than left implicit.

One shared budget for every quote. Fair. Each quote now has its own 10s 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.

bdchatham and others added 2 commits August 28, 2026 19:19
arctic-1 was excluded because it can be re-genesised. It can, but we control
that and intend not to, and the exclusion cost more than the risk.

Supplying the file from a deployment instead lets anyone who can edit that source
point a run at a contract of their choosing, and the code-hash check cannot catch
it: the check proves the address holds the code the file recorded, not that the
code is ours. Committing removes that exposure and puts every address change
through review.

Two corrections to what the section claimed about a re-genesis.

It does not break a run. Resolve returns "deploy" when the chain key does not
match, with no error, so a run whose committed entries have gone stale deploys
its own contracts — which is what every run did before this directory held a
file. Recovery is a pull request here at whatever pace suits. What does not
degrade quietly is a recorded address whose code has changed: Verify fails and
the run stops at startup.

It also omitted the requirement that makes committing work at all. The registry
keys on genesisHash, so a profile that omits it matches nothing, and a run that
omits it on a chain the registry describes already fails at startup with that
explanation. Any profile reading a committed entry has to carry it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three contracts deployed to arctic-1 from a local run, so a deployment binds them
instead of deploying its own on every restart.

  defi-amm         0x225af59603bb554686adfbb2869af4cec12488a1
  tokenops-erc20   0xe66344c8ed6dbde610725cd7e3359b1fe4d7ff26
  tokenops-erc721  0x815299db5f8e3c6c42356655429cf2701e502bea

The run recorded 3 deployed and 0 copied unverified, so nothing here is carried
through from a file this run did not check.

Verified independently of the run that wrote it. Every address holds code, the
recorded hash is keccak256 of the code the chain serves, and that code is
byte-identical to what the pinned compiler produces locally. So these are our
contracts, not merely addresses that hold something.

Verified end to end: a binary carrying this file bound all three and sent no
deployment, and the transactions it then sent moved the contracts' state.
arctic-1's AMM reserve is no longer at its floor and its ERC721 token 1 has an
owner, so the transactions executed rather than reverting.

The tripwire test asserting an empty compiled-in registry said a chain file needs
its own test naming it. It has one. Three mutations, three caught: a changed
address, a changed genesis hash, a dropped contract.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review found that StorageRW's doc claimed the margin absorbs the gap between its
priced calls and read's expensive shape. Settings.Validate accepts a GasMargin of
1, and at 1 nothing absorbs it: the first read of a written slot would land in a
block having burned its whole limit, which is the failure this sizing exists to
remove.

read costs most against a slot that already holds a value, and every call the
scenario prices reads an untouched one, so no measurement reaches that shape.
What the write and rmw models miss is a cold slot read, which EIP-2929 prices at
2,100 and which is not one of the costs Sei moves. The scenario adds twice that
as a constant, so correctness no longer depends on how an operator sets a knob.

Two mutations, two caught: the headroom removed, and the headroom shrunk below a
cold read.

Also moved gasProbeSlot out of CreateContractTransaction's doc comment. Inserting
it there left the function undocumented and turned a warning about that
function's PRNG draw order into commentary on a package-level big.Int.

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

Copy link
Copy Markdown
Contributor Author

Both StorageRW findings confirmed and fixed, in #76 at the top of the stack (3e5b0a1), same as the earlier batch — the branches merge in order and moving it down would mean force-pushing three PRs.

The read headroom is the real one, and you're right that the margin cannot carry it. My comment said "which the margin absorbs", but Settings.Validate accepts GasMargin == 1, and at 1 nothing does. The first read of a written slot would have burned its whole limit and reported as sent — precisely the failure this PR exists to close.

Fixed with a constant rather than by requiring GasMargin > 1, because correctness should not depend on how an operator sets a knob. storageRWReadHeadroom is 4,200: twice EIP-2929's cold slot read, which is the gap between the priced shape and read's peak, and which is not one of the costs Sei moves — the fork's chain config carries a single Sei-specific gas field and it is the zero-to-value store cost.

Guarded at the margin that gives it no help: TestStorageRWClearsReadsPeakAtTheLowestMargin prices at 1 and asserts the limit still clears the model by at least a cold read. Two mutations, two caught — removing the headroom, and shrinking it to 100.

The comment placement is also mine. I inserted gasProbeSlot with a blind text replacement and it landed inside CreateContractTransaction's doc, leaving that function undocumented and turning its PRNG draw-order warning into commentary on a package-level big.Int. Moved above the function, which is documented again.

Worth noting the first fix I attempted for this reported BUILD_OK while the build was actually failing — I had chained && echo off head rather than off the compiler. Caught it on the next read.

@bdchatham
bdchatham changed the base branch from brandon2/health-endpoints to main August 29, 2026 03:32
#71 and #72 both merged squashed, so their content reached main under new
commits and this branch's history no longer shares it. Git therefore saw the
scenario files as added on both sides.

Resolved toward this branch for AMM.go, AMM_test.go and ERC721.go, which carry
the same contracts with their hard-coded gas constants replaced by the measured
path — main holds the earlier form. Removed ERC721_test.go, which pinned a
constant this branch deletes and could not compile against it.

Took main's deferred NotReady in main.go. That fix landed in #72 after this
branch was cut, and it covers every exit rather than the signal path alone.
The conflict resolution took main's deferred call but git had already
auto-merged this branch's inline one from a region that did not conflict, so
the signal path called NotReady twice. Harmless, and the opposite of what #72
did: it replaced the inline call precisely because it covered only that path.
@bdchatham

Copy link
Copy Markdown
Contributor Author

All five findings are fixed, none of them in this PR — they landed upstack in #74 and #76, and I have verified each against the top of the stack. Merging this one and #74 back to back so the two blockers do not sit on main.

[blocker] Disperse probe omits msg.value — right, and it would have refused to start any profile naming disperse. GasEstimateCall carries a Value now, plumbed into the CallMsg. Fixed in #74.

[blocker] gasCallData rebuilds the probe per transaction — this was the worse one, and worse than the comment says. GasLimitFor derived its answer from the priced call, and building one mints an address, so crypto.GenerateKey() ran once per generated transaction on the send path. My PR body claimed "the send path issues no estimate" — literally true and materially wrong. The limit is resolved once at pricing time now and the send path reads a map. Allocations per generated transaction on AMM went 50 → 39, and the scenarios whose probe mints an address were paying far more. There is a test that counts how often the priced call is built and fails if that number moves with the transaction count. Fixed in #74.

Disperse never reads GasLimitFor — correct, it kept the 200,000 default. Same commit. The send path also never set auth.Value, so every disperse reverted on entry and burned its limit while reporting as sent; that is a pre-existing defect this change surfaced, and it is fixed too.

read's peak is unbounded at gasMargin == 1 — correct, and the sharpest of the five. My comment said the margin absorbs the gap; Settings.Validate accepts 1, at which nothing does. Covered with a constant rather than by requiring a margin above 1, because correctness should not depend on a knob. Guarded at margin 1, where it gets no help. Fixed in #76.

gasProbeSlot swallowed a doc comment — mine, from a blind text insertion. Moved above the function, which is documented again. Fixed in #76.

Verified at the stack head:

Value: disperseValue()                          Disperse.go:93
GasLimitFor -> c.gasLimits[operation]           base.go   (a map read)
GasLimitFor(config.OpDisperseEther)             Disperse.go:105
auth.GasLimit = limit + storageRWReadHeadroom   StorageRW.go:174

One note on this branch specifically: #71 and #72 both merged squashed, so the merge back to main conflicted on the scenario files, which git saw as added on both sides. Resolved toward this branch, which carries the same contracts with their constants replaced. main.go is byte-identical to main and out of the diff.

Comment thread generator/gas.go Outdated
Comment thread generator/scenarios/base.go
Comment thread generator/scenarios/StorageRW.go Outdated
@bdchatham
bdchatham dismissed seidroid[bot]’s stale review August 29, 2026 03:50

Dismissing because both blockers are fixed, but upstack in #74 rather than in this diff — so a re-review of this PR alone would raise them again, correctly.

Disperse probe omitting msg.value: GasEstimateCall carries a Value now, plumbed into the CallMsg. Disperse.go:93.

gasCallData rebuilding the priced call per transaction: the limit is resolved once at pricing time and the send path reads a map. Allocations per generated transaction on AMM fell 50 to 39, and a test now counts how often the priced call is built and fails if that number tracks the transaction count. base.go GasLimitFor.

#74 is retargeted to main and merges immediately after this one, so neither blocker sits on main. Merging them apart rather than together was a judgement call taken with the repo owner; the alternative was pulling the fixes down into this PR.

@bdchatham

Copy link
Copy Markdown
Contributor Author

@seidroid please re-review.

Both blockers you raised are correct and both are fixed — but in #74, one PR up the stack, not in this diff. I dismissed the changes-requested review with that reasoning rather than leaving it standing, because a re-review of this PR in isolation would raise them again and be right to.

Context for the re-read, so the same two are not the whole result:

Disperse probe omitting msg.value. GasEstimateCall carries a Value now and gasEstimator plumbs it into the CallMsg. The send path also never set auth.Value, so every disperse reverted on entry and burned its limit while reporting as sent — a pre-existing defect this change surfaced, fixed in the same commit.

gasCallData rebuilding the priced call per transaction. Worse than the comment said: building a priced call mints an address, so crypto.GenerateKey() ran once per generated transaction on the send path. The limit is resolved once at pricing time now; GasLimitFor is a map read. Allocations per generated transaction on AMM went 50 → 39, and there is a test that counts how often the priced call is built and fails if that number moves with the transaction count.

Your other three findings are also fixed: Disperse now reads GasLimitFor; read's peak is covered by a constant rather than by gasMargin, since Validate accepts 1 and at 1 nothing absorbs it; and gasProbeSlot no longer sits inside CreateContractTransaction's doc comment.

What would be most useful from a re-read is anything in the gas design itself that the first pass did not reach — the fail-closed posture in measureGasLimits, whether the cold-shape guarantee actually holds for every scenario's priced call, and whether GasModel's split of execution from calldata is right for a scenario whose calldata varies.

@seidroid

seidroid Bot commented Aug 29, 2026

Copy link
Copy Markdown

Claude finished @bdchatham's task in 6m 21s —— View job


Re-review of the gas design

  • Read the diff at fa93aa7
  • measureGasLimits fail-closed posture
  • Cold-shape guarantee, checked per scenario against each contract's source
  • GasModel's execution/calldata split under varying calldata
  • Post findings

I took the five earlier findings as fixed upstack and did not re-derive them; they are all still present in this diff, so anything reviewing #73 alone will raise them again. Everything below is new.

Note on verification: go build / go vet / go test were blocked by this session's command policy, so the findings below are from reading, not from a run. Nothing here depends on a test result.


1. [blocker at gasMargin: 1] The priced call's calldata is not an upper bound on the sent call's calldata

GasLimitFor resolves model.Limit(probeCalldata), and that stays true upstack — resolving once at pricing time still resolves against the probe's bytes. For AMM and StorageRW that is fine: AMM's probe calldata is byte-identical to what it sends, and StorageRW packs the real bytes. For the four address-carrying scenarios it is not.

ERC20.GasEstimateCalls (generator/scenarios/ERC20.go:64) packs transfer(gasProbeAddress(), 1); CreateContractTransaction sends transfer(scenario.Receiver, 1). Intrinsic gas is 16/nonzero byte and 4/zero byte, so those two words cost differently whenever the two addresses hold different numbers of zero bytes — up to 20 × 12 = 240 gas, in whichever direction the probe address fell when it was minted.

That is absorbed at 1.20. It is not absorbed at 1, and Settings.Validate (config/settings.go:74) admits 1 — its error frames only < 1 as wrong. Take the PR's own arctic-1 number for ERC20Noop.transfer: 22,468 quoted against an intrinsic of ~21,572, so Exec ≈ 896. At margin 1 the limit is the quote exactly, with zero slack, and a receiver whose address carries fewer zero bytes than the probe's needs more than the model declared. The probe is minted once and held for the whole run, so this is a per-run coin flip (~7.5% that a random 20-byte address holds at least one zero byte), not a per-transaction one: on the runs where it lands, near every transaction burns its limit and reports as sent.

ERC721 has the same shape in both argument words — probe id 1<<255 is one nonzero byte and 31 zeros, while a run id of 257 is two nonzero bytes, 12 gas more.

The PR's own test helper is the tell. requireGasMatchesModel (generator/scenarios/gasestimate_test_helper_test.go:41) asserts the limit equals what the model derives from the transaction's own calldata — the exact invariant at issue — and it is wired into AMM_test.go and StorageRW_test.go only, the two scenarios that satisfy it. ERC721_test.go was deleted rather than converted. Pointing that helper at ERC20 or ERC721 today would fail intermittently, which is the finding.

Cheapest fix that keeps the upstack "resolve once" win: make the probe's calldata maximal by construction rather than random. Have gasProbeAddress() (generator/scenarios/gasestimate.go:81) replace any zero byte in the fresh address with a nonzero one — still fresh, so the cold-slot property is untouched, but intrinsic(probe) ≥ intrinsic(sent) for that word always. Same trick for gasProbeTokenID: 2^256−1 is as certainly unminted as 1<<255 and is all-nonzero. The alternative is routing these through GasLimitForData(op, data) with the bytes about to be sent, which is what StorageRW does — and which is why that method exists.

Fix this →


2. The margin scales the calldata intrinsic, which both doc comments say it does not

GasModel.Margin (generator/scenarios/gasestimate.go:52): "Margin multiplies the execution term." Settings.GasMargin (config/settings.go:43): "It is a margin on execution, not on calldata: the calldata part is a closed form over the exact bytes on the wire and needs none."

Limit is max(uint64(float64(intrinsic+m.Exec)*m.Margin), floor) (generator/scenarios/gasestimate.go:67). intrinsic is the 21,000 base plus the per-byte calldata charge, and the margin scales all of it. The floor is exempt; the intrinsic is not.

It errs high, so it is not a correctness bug — but it is not free either, and it lands on exactly the transactions the size distribution makes largest. StorageRW at a 32 KiB zero pad has an intrinsic of ~152,000, so the default 1.20 declares ~30,400 gas that no byte of calldata can consume, against the max_gas_wanted budget the PR body sizes the argument on. Either scale only the execution term — max(intrinsic + uint64(float64(m.Exec)*m.Margin), floor) — or fix both comments to say what the code does.


3. GasModel's split is sound here, but its precondition is unwritten

Reusing one Exec across every pad is exact for StorageRW for a specific reason: _pad is bytes calldata and no function body reads it (generator/contracts/StorageRWv1.sol:29-61), so there is no CALLDATACOPY and no memory expansion, and execution genuinely is pad-independent. That is a property of this contract, not of the model.

A method taking bytes memory would have the ABI decoder copy the argument into memory, and the run would pay memory-expansion gas that grows quadratically in the length — none of which is in an Exec measured at an empty pad. The limit would be short, and short exactly for the largest draws. Nothing in GasEstimateCall's or GasModel's doc names that constraint, and GasLimitForData/MaxGasLimitForData are offered as general facilities. One sentence on GasModelthe varying part of the calldata must be unread by the contract — is what keeps the next scenario from reusing this wrongly.

Related, worth a comment rather than a change: Exec = required − intrinsic silently absorbs the EIP-7623 floor whenever the chain's quote is floor-dominated, since Limit then adds the floor back. It over-states execution, which is the safe direction, and the guard at generator/gas.go:99 already catches the degenerate end. ERC20Noop is the closest this PR comes — 22,468 quoted against a 22,430 floor, 38 gas of separation. Naming it stops someone later reading the max() as redundant.


4. Cold-shape guarantee: holds, and I checked each one

Not a finding — this is the answer to the question, since the argument only works if it holds everywhere.

scenario probe writes run's worst holds
ERC20 / ERC20Conflict _balances[fresh sender] 0→1 (the < branch takes it), _balances[fresh recipient] 0→1 same two cold sets on an account's first transfer
AMM balIn[fresh] 0→wrapped, balOut[fresh] 0→out; reserves nonzero from the constructor in both same two cold sets on an account's first swap
ERC721 _balanceOf[fresh] 0→1, _ownerOf[1<<255] 0→addr same pair
ERC20Noop nothing nothing ✓ (its exposure is finding 1)
StorageRW write/rmw store[1<<200] 0→v same
StorageRW read the gap you fixed upstack

Two things worth pinning while they are still true. Pricing ERC721 at 1<<255 rather than at a low id is what makes the guarantee survive a recorded contract that a previous run already minted into — the doc at ERC721.go:61 says this and it is the sharpest comment in the diff. And gasProbeSlot = 1<<200 is outside every configurable keyspace only because RecordCount is a uint64 and pickSlot draws SampleIndex(rng, cfg.RecordCount). That is load-bearing and unwritten; a RecordCount widened later collides in silence, and the probe stops being cold.


5. Smaller things

  • MaxGasLimitForData reports "no measured gas limits" when every Exec is 0. found is set only inside if model.Exec > widest.Exec (generator/scenarios/base.go:258), so a map of models that are all zero looks empty. Unreachable today — gasEstimator rejects required <= intrinsic, the mock uses 200,000 — but the flag is keyed on the comparison rather than on presence. if !found || model.Exec > widest.Exec says what is meant.
  • The block-limit error names the wrong number. blockGasLimit returns min(header.GasLimit, gasEstimateCap) (generator/gas.go:125), and the failure reads "past the chain's %d per block" (generator/gas.go:111). On a chain whose header reports more than 10,000,000, the number printed is the estimate cap, and an operator goes looking for a block-size problem that does not exist. Two values deserve two messages.
  • GasLimitFor collapses two failures into one. (0, false) means both "never priced" and "recomposition failed" (generator/scenarios/base.go:223-233), and every caller reports it as "no measured gas limit". The second is a bug in the model, not a missing measurement, and it now reads as the first.
  • GasLimitForData is still dead code (generator/scenarios/base.go:238) — no caller. Finding 1 is the caller it was written for.
  • mockGasLimits drops the caller's context for context.Background() (generator/gas.go:146). Nothing blocks on that path today, so it costs nothing until something does.

Verdict

The core of this is right and the reasoning behind it is unusually well recorded — the fail-closed posture, the ordering that puts pricing after recordDeployments so a pricing failure cannot discard a paid-for deployment, the cold-by-construction probe, and the compile-time gate on GasEstimateCalls are all sound, and I checked the cold-shape claim against each contract rather than taking it. The round-trip test does pin what it says it pins.

Finding 1 is the one I would not ship without: at gasMargin: 1 it is the same silent burn this PR exists to close, reached by a different route, and the PR's own test helper is already shaped to catch it. Findings 2 and 3 are about the code and its stated contract disagreeing, which matters more than usual here because the comments are doing real work.
· branch brandon2/gas-estimate-calls

Re-review found three things the first pass did not reach. All three are real
and the first changes behaviour.

A priced call has to bound the call the run sends, and for the four scenarios
carrying an address it did not. GasLimitFor resolves against the probe's bytes,
and calldata costs 16 gas for a non-zero byte against 4 for a zero one, so a
probe address holding zero bytes prices a cheaper word than the address a
transaction actually carries. Measured: 21,440 intrinsic against 21,560, a
shortfall of 120. About one address in thirteen holds a zero byte, and the probe
is minted once and held for the whole run, so it is a per-run coin flip rather
than a per-transaction one. On the runs where it lands, nearly every transaction
is short at a margin of 1, which Validate accepts.

Both probe values are now non-zero in every byte, so the priced call is the more
expensive one on calldata as well as on storage. The address is still one this
run mints and never uses again, which is what makes its slots cold.

The tell was in this package's own test helper. requireGasMatchesModel asserts
the limit equals what the model derives from the transaction's own calldata,
which is exactly the invariant at issue, and it was wired into the two scenarios
that satisfy it. ERC721's test had been deleted rather than converted. There is
now a test over every contract scenario asserting the priced call's intrinsic
cost is at least the sent call's.

The margin scaled the calldata intrinsic as well as execution, which both doc
comments said it did not. It errs high, so it was not a correctness bug, but it
declares gas no byte can consume and it lands hardest on the largest draws a size
distribution produces: about thirty thousand at a 32 KiB pad. The margin now
scales execution alone.

Reusing one Exec across calldata sizes holds only while the varying bytes are
ones the contract never reads. StorageRWv1 takes its pad as bytes calldata and
touches it nowhere, so nothing copies it into memory. A method taking bytes
memory would pay memory expansion growing with the square of the length, none of
it in an Exec measured at an empty pad. Said so on GasModel, along with why the
floor comparison in Limit is not redundant.

Three mutations, three caught. Two survived a first attempt, because the guards
were weak rather than the fixes: the token-id case needed draws past 255, where
an id first needs a second non-zero byte, and the margin case needed a margin
above 1, where the two forms stop agreeing. A fixture of mine was also wrong — at
a 32 KiB pad the EIP-7623 floor dominates, so the assertion was about the floor
rather than the margin.

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

Copy link
Copy Markdown
Contributor Author

@seidroid all three are real and fixed in 010bd04. The first changes behaviour; I verified each by measurement rather than by reading, since your note said build and test were blocked.

1. The priced call was not an upper bound on the sent call. Confirmed:

probe address holding zero bytes    21,440 intrinsic
sent, dense address                 21,560 intrinsic     shortfall 120
random address carrying a zero byte:  7.54%   (your estimate: ~7.5%)

Took your fix. gasProbeAddress forces every byte non-zero and gasProbeTokenID is now 2^256−1, so intrinsic(probe) >= intrinsic(sent) holds by construction. The address is still minted per run and never reused, so the cold-slot property is untouched.

Your observation about requireGasMatchesModel is the part I would not have found. It asserts the limit equals what the model derives from the transaction's own calldata — the exact invariant — and it was wired into the two scenarios that satisfy it, with ERC721's test deleted rather than converted. There is now a test over every contract scenario asserting the priced call's intrinsic cost bounds the sent one's.

2. The margin scaled the calldata intrinsic. Confirmed, and it matches your figure:

32 KiB pad:  margin on both  273,206      margin on execution  242,672
             declared and unusable: 30,534

Now intrinsic + exec*margin, which is what both doc comments already claimed.

3. The precondition on GasModel. Correct — reusing one Exec across pad sizes is exact only because StorageRWv1 takes bytes calldata and reads it nowhere. Stated on the type, along with why the floor comparison in Limit is not redundant, which was your related note.

Two of my three mutations survived a first attempt, and both because the guards were weak rather than the fixes. The token-id case needed draws past 255, where an id first needs a second non-zero byte — your example of 257 was the thing my 200-transaction loop never reached. The margin case needed a margin above 1, where the two formulas stop agreeing.

One fixture of mine was also simply wrong: at a 32 KiB pad the EIP-7623 floor dominates, so Limit correctly returned the floor and my assertion was about the wrong quantity. The test now checks it is below the crossover before asserting anything about the margin.

Three mutations, three caught. gofmt, go vet, golangci-lint clean; 15 packages passing.

Worth another look given this round changed behaviour rather than comments.

@seidroid

seidroid Bot commented Aug 29, 2026

Copy link
Copy Markdown

Claude finished @bdchatham's task in 5m 2s —— View job


Re-review of 010bd04

  • Read the fix commit
  • Verify fix 1 — probe calldata is now an upper bound
  • Verify fix 2 — margin scales execution only
  • Verify fix 3 — GasModel precondition documented
  • Check the new tests actually pin the invariants
  • Look for anything the fixes introduced

Verification note, same as last round: go build / go vet / go test are blocked by this session's command policy, so this is from reading. Nothing below depends on a run.


All three fixes are correct

1. Probe calldata is now an upper bound — and by construction, not by luck. gasProbeAddress forcing every zero byte to 0xff (gasestimate.go:109) and gasProbeTokenID = 2²⁵⁶−1 (ERC721.go:72) give the probe the maximum non-zero count for every byte position the sent call can occupy, so intrinsic(probe) ≥ intrinsic(sent) holds for all four address-carrying scenarios. I checked each probe against its send path: ERC20/ERC20Noop transfer(probe, 1) vs transfer(Receiver, 1), ERC721 mint(probe, MAX) vs mint(Receiver, atomic id) — bounded at 8 non-zero bytes by int64 — Disperse's 100 probe addresses vs 100 drawn ones, and AMM, whose two probes are byte-identical to what it sends. _mint places no constraint on the id (ERC721.sol:179), and the address is only ever an ABI argument, never the estimate's From, so mutating it away from its key costs nothing.

One thing worth recording, because the new test depends on it and does not say so: asserting on intrinsic alone is sufficient, not a proxy. IntrinsicGas − 21000 = 4·zeros + 16·nonzeros = 4·tokens and FloorDataGas − 21000 = 10·tokens, so the floor is exactly 2.5× the intrinsic's variable part — always, at any length. intrinsic(probe) ≥ intrinsic(sent) therefore implies floor(probe) ≥ floor(sent), and since Limit takes the max of two terms each monotone in that quantity, Limit(probe) ≥ Limit(sent). The test's chosen assertion is the whole property, not part of it.

A property the commit does not claim. intrinsic(probeData) is now a compile-time constant per scenario rather than a per-mint draw, so the derived limit is identical across runs against the same chain. Previously it moved by up to 240 gas depending on which address got minted, which made tx.Gas() non-reproducible at a fixed seed. That also means gasCallData's per-transaction re-minting never changed the answer — so the upstack "resolve once" change is purely a cost fix, as you described it.

2. Margin on execution alone. max(intrinsic + uint64(float64(m.Exec)*m.Margin), floor) (gasestimate.go:83) matches both doc comments now. I checked the one case where narrowing a limit could bite — where the old form cleared the floor and the new one falls under it. It is safe: when intrinsic + Exec·margin < floor the max returns floor, and floor > intrinsic + Exec·margin ≥ intrinsic + actual_exec, so the limit still bounds consumption. At a 32 KiB pad both forms were already floor-dominated (≈349,200 either way), so the ~30,500 you measured is recovered from the band below the crossover, which is where the block-space argument actually applies.

3. The precondition. gasestimate.go:47-57 states it correctly — bytes calldata unread by any body, versus bytes memory paying memory expansion quadratic in length and none of it in an Exec measured at an empty pad. The Exec-absorbs-the-floor note lands where someone reading max as redundant will hit it.


New, non-blocking

a. The new test asserts the ingredient, not the property — and the gap is one that already bit. TestAPricedCallCostsAtLeastWhatItsTransactionsCost (gasestimate_internal_test.go:172) generates the transaction, then throws away tx.Gas() and compares intrinsics. Everything needed for the stronger assertion is already in hand:

want, err := GasModel{Exec: 200_000, Margin: 1}.Limit(call.Data)  // per priced op
require.GreaterOrEqual(t, tx.Gas(), modelLimit(tx.Data()))

That version catches a scenario that prices correctly and then never reads the measurement back — which is exactly what Disperse did, and what took a separate reviewer pass to find. Concretely: Disperse's drawn calldata is ~58,000 intrinsic, so the model wants ~258,000, against the 200,000 default CreateTransactionOpts leaves in place. The intrinsic-only form passes that scenario cleanly. #74 fixed Disperse, but nothing in the suite stops it returning.

b. The same test asserts more than StorageRW is required to satisfy. It runs over every entry in scenarioFactories, and StorageRW resolves through MaxGasLimitForData(realData) — it has no obligation for its probe to bound its sent calldata, and with a size distribution configured it would not. It passes only because config.Scenario{Name: name} leaves SizeDistribution nil, so pickPad returns an empty pad (StorageRW.go:185-195). A default size bucket added later fails a scenario that is correct. Worth either skipping scenarios that resolve via GasLimitForData/MaxGasLimitForData, or a line in the fixture saying the empty-pad config is load-bearing.

c. The block-fit guard covers the probe shape only. gasEstimator rejects a call whose limit exceeds blockGasLimit (generator/gas.go:109), but it evaluates model.Limit(call.Data) — the probe's bytes. StorageRW's largest drawn pad produces a much larger limit (~349,000 at 32 KiB against ~110,000 for the empty-pad probe), and MaxGasLimitForData performs no block check at all. So "a priced call that cannot fit in any block fails at startup rather than on every send" holds for fixed-calldata scenarios and not for the one whose calldata varies — a profile with large enough SizeBuckets starts fine and fails per transaction. Fixing it means the size distribution's maximum bucket has to be visible at pricing time, which is a bigger change than this PR; a comment naming the limit of the guard would do for now.

d. gasProbeAddress mints a secp256k1 key to produce 20 bytes it then partly overwrites. types.NewAccount(false) is a crypto.GenerateKey plus a public-key derivation (types/account.go:19-29), and none of the key survives — the address is never signed with. crypto/rand into a [20]byte with the same non-zero fixup is the identical value at a fraction of the cost. It matters because Disperse calls it 100 times per GasEstimateCalls(), and in this diff gasCallData calls that per transaction; #74 removes the per-transaction part, which leaves this as startup cost only, but the new test does 400 draws × 100 keys for Disperse alone.

e. The five smaller items from last round are untouched, which is expected from a three-file commit — noting them so they are not lost: MaxGasLimitForData's found flag keyed on the comparison rather than on presence (base.go:258); the block-limit error naming min(header, cap) as "the chain's per block" (gas.go:111 / :125); GasLimitFor collapsing "never priced" and "recomposition failed" into one false (base.go:223-233); mockGasLimits dropping the caller's context (gas.go:146).

GasLimitForData (base.go:238) deserves a decision rather than a re-mention. It was written as the route for finding 1, and taking the probe-maximal route instead means nothing will call it — the fixed-calldata scenarios do not need it and StorageRW uses MaxGasLimitForData. Either delete it, or say in its doc that it is the facility a future scenario with varying calldata and a single priced operation should use.


Verdict

The behaviour changes are right, and the reasoning holds where I could check it independently — including the part the commit message leaves implicit, that the intrinsic-only assertion is exactly equivalent to the limit assertion because the EIP-7623 floor is a fixed 2.5× of the intrinsic's variable part. Nothing in this round blocks. (a) is the one I would take before merge: it is a two-line change to a test that already exists, and it guards the failure mode that has now been found twice by review rather than by the suite.
· branch brandon2/gas-estimate-calls

…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>
@bdchatham

Copy link
Copy Markdown
Contributor Author

@seidroid review

Everything from both earlier passes is addressed as of 511fc8f. Summary of what changed since your last read, so this pass can go at the current state rather than re-deriving:

From the first pass — all five fixed. Disperse's probe carries the msg.value the contract requires and the send path sets it; GasLimitFor resolves once at pricing time so the send path no longer mints a keypair per transaction; Disperse reads the measurement back; read's peak is covered by a constant rather than by gasMargin, which Validate accepts at 1; and gasProbeSlot no longer sits inside another function's doc comment.

From the second pass — all three behaviour fixes plus four of the five smaller items. Probe calldata is maximal by construction, so intrinsic(probe) >= intrinsic(sent) holds for every address-carrying scenario. The margin scales execution alone. GasModel states the precondition its split rests on. GasLimitForData is deleted, MaxGasLimitForData keys presence on the map rather than a comparison, the block-fit guard names what it does not cover, mockGasLimits takes the caller's context, and gasProbeAddress no longer mints a secp256k1 key for twenty bytes that are only ever an ABI argument.

The one you flagged as pre-merge is done, and it found something. Asserting the limit rather than the calldata cost behind it caught Disperse immediately — 258,072 needed against 200,000 declared, the figure you predicted. Rather than skip that scenario I pulled the Disperse fix down from #74, since the defect is in this diff. This PR is now individually correct rather than relying on the next one.

Your equivalence argument is load-bearing and I checked it independently: the EIP-7623 floor is a fixed 2.5x of the intrinsic's variable part at every byte composition I tried, so the intrinsic bound does imply the limit bound. I still moved the assertion to the limit, because the two failures that land there are not both about calldata.

Verification on my side: gofmt, go vet and golangci-lint run clean, 15 packages passing, and every fix broken on purpose first — the probe address left random, the token id returned to a mostly-zero word, the margin put back on the intrinsic, the read headroom removed and then shrunk below a cold read, and a scenario that prices but never reads it back. All caught. Two of those survived a first attempt because the guards were weak rather than the fixes, and I strengthened the guards rather than the claims.

@seidroid

seidroid Bot commented Aug 29, 2026

Copy link
Copy Markdown

Claude finished @bdchatham's task in 5m 53s —— View job


Re-review of 511fc8f

  • Read the diff at the current head
  • Verify the first-pass fixes are present in this diff
  • Verify the second-pass fixes
  • Check the new limit-level assertion pins what it claims
  • Look for anything the fixes introduced
  • Post findings

Verification note, third round running: go build / go vet / go test are blocked by this session's command policy. Findings are from reading. Nothing below depends on a run — but finding 1 depends on a chain, and I say so where it does.


What I confirmed as landed here

The second-pass fixes are all in this diff and all correct. gasProbeAddress forces every byte to 0xff (gasestimate.go:118-129) and gasProbeTokenID is 2²⁵⁶−1 (ERC721.go:72), so intrinsic(probe) ≥ intrinsic(sent) holds by construction for ERC20/ERC20Noop/ERC20Conflict/ERC721/Disperse; Limit is max(intrinsic + Exec·margin, floor) (gasestimate.go:89); the precondition on GasModel (gasestimate.go:53-63) states the bytes calldata-unread constraint and why the max is not redundant; GasLimitForData is gone; MaxGasLimitForData keys presence on the map (base.go:244-249); the block-fit guard names what it does not cover (gas.go:110-114); gasProbeAddress draws from crypto/rand with no key derivation.

Two things I'd credit specifically. Moving TestEveryTransactionCarriesEnoughGasForItsOwnCalldata to assert on tx.Gas() rather than on intrinsic also dissolves the objection I raised about StorageRW — the limit form is one StorageRW satisfies at any pad, because MaxGasLimitForData recomposes against the bytes it is about to send. The empty-pad fixture is no longer load-bearing. And the doc comment on that test (gasestimate_internal_test.go:163-182) records why, which is the part that survives.


1. [blocker, needs one curl to confirm] The Disperse probe now carries value from an address with no balance

GasEstimateCall.Value is plumbed into the CallMsg (gas.go:90) and From is types.NewAccount(false).Address (gas.go:87) — a freshly minted, zero-balance address. Disperse sets Value: disperseValue() = 100 wei (Disperse.go:93).

The comment immediately above justifies the empty From this way:

The three fee fields stay unset. A call carrying one makes the node check the caller's balance, and this caller has none by design (gas.go:83-85)

That reasoning is right about the fee check and does not extend to the value check. In geth's estimator the balance recap is gated on feeCap.BitLen() != 0, so unsetting the fee fields skips it — that is the behaviour the PR body verified against arctic-1. But the value-transfer check lives inside the state transition itself and is not gated on anything:

// core/state_transition.go, executed for eth_estimateGas as for any call
if !value.IsZero() && !st.evm.Context.CanTransfer(st.state, msg.From, value) {
    return nil, fmt.Errorf("%w: address %v", ErrInsufficientFundsForTransfer, msg.From.Hex())
}

SkipNonceChecks / SkipFromEOACheck, which ToMessage does set for an estimate, do not cover it. So a value-carrying probe from a zero-balance sender returns insufficient funds for transfer — the same error string the PR body reports from the fee-cap experiment, reached by a different check — measureGasLimits fails, and any profile naming disperse refuses to start. Fail-closed makes it loud rather than silent, which is the design working; it is still a startup blocker on every chain.

Nothing in the suite can see this: every test stubs the estimator (gasestimate_test_helper_test.go:24, gasestimate_internal_test.go:200), so gasEstimator's CallMsg is never built by a test. That is the seam — see finding 3.

One curl against arctic-1 settles it, and it is the same kind of check the PR body already reports for the fee-cap case:

curl -s $RPC -H 'content-type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"eth_estimateGas",
  "params":[{"from":"0x<fresh, unfunded>","to":"0x<disperse>","value":"0x64","data":"0x<disperseEtherFixed calldata>"}]}'

If it does fail, the fix that keeps the cold-shape guarantee is a state override on the probe's balance — eth_estimateGas takes a third parameter, reachable through client.Client().CallContext(ctx, &out, "eth_estimateGas", arg, "latest", override) since ethclient.EstimateGas does not expose one. Using the funded deployer as From also works for Disperse specifically — the contract's cost is a function of its recipients, not of msg.sender — but that is per-scenario knowledge the estimator does not have, and it would quietly break the cold-shape guarantee for the ERC20 scenarios if reused.

Fix this →


2. Three items listed as fixed are not in this diff

The comment says "Everything from both earlier passes is addressed as of 511fc8f" and "This PR is now individually correct rather than relying on the next one." Only the Disperse fix was pulled down. Three of the first pass's five are still upstack-only, and I checked each by reading the branch head rather than taking either side:

a. read's peak at gasMargin == 1 — still open, and still a correctness bug. storageRWReadHeadroom does not exist anywhere in the tree (grep over **/*.go: no match). StorageRW.go:153 is auth.GasLimit = limit, unadjusted, and the doc at StorageRW.go:101-102 and package doc.go still end on "covers read to within one cold read of its own peak, which the margin absorbs."

Re-derived against the contract to be sure the gap is real: the priced read hits an untouched slot, so readAccumulator += 0 is an SSTORE of the value already there — cold access 2,100 plus 100. The priced write pays SSTORE_SET on Sei, ~72,000 plus 2,100 cold, and is the widest. read at peak pays a cold SLOAD of a written store[slot] (2,100) and the accumulator going zero→non-zero (2,100 cold + ~72,000), so it exceeds the widest model by one cold slot access. Settings.Validate (config/settings.go:74) admits GasMargin == 1, and at 1 nothing covers it. That is the invisible burn this PR exists to close.

b. GasLimitFor still resolves per transaction. base.go:228 is model.Limit(c.gasCallData(operation)), and gasCallData (base.go:260) calls c.deployer.GasEstimateCalls() on every generate. The secp256k1 keygen is gone, which is the expensive part and a real improvement — but per generated transaction the send path still does a crypto/rand read, an ABI pack, an IntrinsicGas and a FloorDataGas, and throws the calldata away. Disperse pays 100 crypto/rand reads and packs a ~3.2 KB address array per transaction, on the single goroutine in Generator.Run (generator.go:147-167) that is the whole run's generation ceiling. The answer is now invariant across calls — probe calldata is maximal by construction, so this is cost, not correctness.

c. gasProbeSlot still sits inside CreateContractTransaction's doc comment. StorageRW.go:83-91 is the function's doc; line 92 continues into gasProbeSlot's with no blank line; var gasProbeSlot is at 103 and CreateContractTransaction at 116, undocumented. The PRNG draw-order warning at 89-91 still reads as commentary on a package-level big.Int.

Also still here, and named in the same first-pass batch: measureGasLimits wraps the header read and every quote in one 60s WithinBudget (gas.go:63), with no per-quote bound.

None of this argues the fixes are wrong — they're right and I verified the reasoning last round. It argues the claim of individual correctness. (a) is the one that decides whether this branch can stand alone: it is a live short limit at a margin Validate accepts.


3. Nothing exercises gasEstimator, which is where finding 1 lives

Every path into the estimator is stubbed. generator/mockchain_test.go is the one place a real ethclient reaches a fake node, and its EstimateGas (mockchain_test.go:154) discards the json.RawMessage it is handed. Having it decode that argument and assert value is present when the scenario declared one — and that the fee fields are absent, which is the other documented invariant of this call — would have caught finding 1 at the same cost as the header fixture already added beside it. It is the only assertion available about the wire shape of the request, and the wire shape is where both of this PR's estimator decisions live.


4. Smaller

  • mockGasLimits still does not receive the caller's context. It takes one now (gas.go:144), but mockPrepareAll has no ctx parameter and hands it context.Background() (prepare.go:390), while prepareAll calls mockPrepareAll() at prepare.go:87 with a ctx in hand. The boundary moved one frame; it did not close. Two-line change.
  • TestPricedCallsCarryDistinctCalldata panics instead of failing on short calldata. string(call.Data[:4]) (gasestimate_internal_test.go:73) slices without a length check; TestEveryDrawableOperationIsPriced asserts NotEmpty, not len >= 4. A scenario declaring stub data fails with a slice-bounds panic rather than the message the test was written to print.
  • GetGasMargin silently substitutes the default for any margin below 1 (config/config.go:166), not only for a missing one. Settings.Validate rejects 0.5 for a parsed profile, so this only reaches a config built in code — but the doc says "A margin of zero would declare no gas at all", which describes one of the two cases it swallows.
  • Operational note, not a defect. Disperse's true cost is roughly 100 × (cold account access + new-account + call-value), so the measured limit will be near 3.7M and the declared one near 4.5M at the default margin — against the ~4M it should have been declaring all along and the 200,000 it actually was. That is the fix working, and it is also ~12 disperses per block under max_gas_wanted. Worth knowing before a profile that names disperse is pointed at pacific-1; the PR body's "erring high is nearly free" argument is about the margin ratio and does not speak to this.
  • gasProbeSlot = 1<<200 is outside every configurable keyspace only because RecordCount is a uint64 and pickSlot draws SampleIndex(rng, cfg.RecordCount) (StorageRW.go:175). Still unwritten, still load-bearing: a widened RecordCount collides in silence and the probe stops being cold. Third mention, so treat it as a nit I keep re-finding rather than a request.

Verdict

The second pass's three behaviour fixes are correct and I could check each independently — the probe is maximal by construction rather than by luck, the margin lands where both doc comments always said it did, and the assertion moved to the limit in a way that happens to resolve my StorageRW objection as a side effect rather than by exempting it.

Two things stand between this and merge. Finding 1 is new and introduced by the Value fix: the justification for the unfunded probe covers the fee check and not the value check, and if that holds on-chain then disperse cannot start — the mirror image of the defect the Value field was added to fix. Finding 2a is not new but is not fixed here either: read at gasMargin: 1 still burns its limit, and the doc still says the margin absorbs it. If this branch merges before #76, that hole is on main.
· branch brandon2/gas-estimate-calls

Comment thread generator/gas.go Outdated
bdchatham and others added 2 commits August 29, 2026 08:02
The Value field added last round broke the scenario it was added for. The
justification for an unfunded caller covers the fee check and not the value
check: the state transition tests that the caller can cover msg.value whatever
the fee fields say, so a value-carrying call from a fresh address fails with
insufficient funds before it runs. Pricing then fails closed and any profile
naming disperse refuses to start — the mirror image of the defect the field was
added to fix.

Measured against arctic-1, injecting Disperse's runtime with fixedEtherAmount at
1 and sending the hundred recipients the scenario sends:

  no balance override    insufficient funds
  balance override       3,573,887 gas

A call carrying a value now goes through the raw endpoint with a state override
funding its caller by exactly what the call sends. The override touches that
balance and nothing else, so the contract's slots stay cold and the quote stays
the expensive shape. A call sending no value goes through ethclient as before, so
an endpoint serving no state overrides still prices every scenario that needs
none.

Nothing exercised this path. Every other test stubs the estimator, and the mock
chain discarded the request it was handed, so the one place the shape of this
call could have been asserted asserted nothing. It records the request now, and a
test reads it back: no fee field on any priced call, calldata present, and a
value-carrying call funded by an override for at least what it sends.

Two mutations, two caught: the value sent with no override, and the value dropped
from the call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four branches reviewed apart cost more than the separate boundaries were worth.
The Disperse fix and the Value field landed in one, the read headroom in
another, and the newest estimator fixes in a third, so no branch was
individually correct and every review pass had to reason about where a fix
actually lived. A reviewer checked the branch rather than the claim and found
three fixes I had said were present and were not.

All of it is one change: stop declaring numbers the chain owns, and ask it
instead. Gas limits, the fee cap, and the contract addresses that make a run
reproducible across restarts.

Four files conflicted, all where both sides had grown a version of the same fix.
Resolved toward the union in each: the estimator keeps the new helper that funds
a value-carrying call and the per-quote timeout that bounds it; the mock chain
keeps both the recorded estimate requests and the gas price; the test file keeps
every test from both sides.

The merge caught one thing by itself. The newest test built a config with no fee
cap, and the fail-closed path this merge brought in rejected it — the two halves
meeting the way they should.

@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 1 potential issue.

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 ee754ed. Configure here.

Comment thread generator/gas.go Outdated
@bdchatham bdchatham changed the title feat(gas): ask the chain what a call costs instead of hard-coding it feat: read gas limits, fee caps and contract addresses from the chain Aug 29, 2026
@bdchatham

Copy link
Copy Markdown
Contributor Author

@seidroid review

This PR is now the whole change. #74, #75 and #76 are collapsed into it and closed — reviewing them apart was what let a fix sit in one branch while the defect it fixed sat in another, which you caught last round when three things I said were present were not.

What is here that you have not read:

  • The fee cap, previously feat(fee): take the fee cap from the chain, not from three constants #74. Three hard-coded caps replaced by one derived from eth_gasPrice at startup. The live base fee is 50 gwei on pacific-1 and atlantic-2 against a declared 20, so nothing this tool sent was admitted there.
  • The arctic-1 registry entry and the policy change behind it, previously docs(registry): commit arctic-1 alongside pacific-1 and atlantic-2 #75 and feat(registry): record arctic-1's deployed contracts #76. Contract addresses ship compiled in, so a run binds rather than redeploys.
  • Finding 1 from your last round. Confirmed on-chain and fixed. A value-carrying call from an unfunded caller fails the value check whatever the fee fields say, so disperse could not start. It now goes through the raw endpoint with a state override funding the caller by exactly what the call sends; a zero-value call still goes through ethclient, so an endpoint without overrides prices everything that needs none. Measured against arctic-1 with Disperse's runtime injected: insufficient funds without the override, 3,573,887 gas with it.
  • Your point 3. Nothing exercised the estimator — the mock chain discarded the request it was handed. It records it now, and a test asserts the wire shape: no fee field on any priced call, calldata present, and a value-carrying call funded by an override for at least what it sends. Two mutations, two caught.
  • Findings 2a, 2b and 2c resolve by the collapse: the read headroom, the resolve-once change and the gasProbeSlot comment move were all real and all upstack. I verified each is in this tree rather than asserting it.

Smaller items from your last round also taken: mockGasLimits receives the caller's context through mockPrepareAll rather than one frame short of it; GasLimitForData is deleted; MaxGasLimitForData keys presence on the map; the block-fit guard names what it does not cover.

Not taken, with reasons: the TestPricedCallsCarryDistinctCalldata slice-bounds panic and GetGasMargin swallowing a sub-1 margin are both real and both cosmetic against a config the profile loader already rejects — say if you disagree. The gasProbeSlot = 1<<200 keyspace assumption you have raised three times is still unwritten; it deserves the comment and I would rather you tell me where it belongs than guess again.

Worth reading as a new surface rather than a diff against your last read — it is four PRs wide now, and each of your three rounds has found something real.

@seidroid

seidroid Bot commented Aug 29, 2026

Copy link
Copy Markdown

Claude finished @bdchatham's task in 6m 51s —— View job


Full-surface review of ee754ed

  • Read the collapsed diff as one change
  • Fee cap path (generator/fee.go, funder, deployment, transfer)
  • Registry entry and the policy behind it
  • The state-override estimator
  • Wire-shape test and the mock chain
  • Verify 2a / 2b / 2c are present in this tree
  • Answer the three open questions
  • Post findings

Verification note, fourth round: go build / go vet / go test are still blocked by this session's command policy. Findings are from reading. Nothing below depends on a run.

I read this as a new surface. Everything below is new except where I say otherwise, and I checked each claim in your comment against the tree rather than taking it.


What I confirmed landed

storageRWReadHeadroom = 4,200 exists (StorageRW.go:26-45) and auth.GasLimit = limit + storageRWReadHeadroom at StorageRW.go:174. GasLimitFor is a map read (base.go:239-242) with the limit resolved once in GasEstimateCaller (base.go:221-227). gasProbeSlot sits above CreateContractTransaction, which is documented again (StorageRW.go:104-136). GasLimitForData is gone, MaxGasLimitForData keys presence on the map (base.go:253), the block-fit guard names what it does not cover (gas.go:114-118). Disperse carries and sets Value (Disperse.go:74, 111). All five from round one and all of round two are in this diff. 2a/2b/2c check out.


1. [blocker] The per-quote timeout leaks a context sentinel, so a hung eth_estimateGas reports a successful run and exits 0

This is new in ee754ed — it is the fix for the shared-budget finding, and it reintroduces the failure mode WithinBudget exists to prevent.

gasEstimator bounds each quote with a raw context.WithTimeout (generator/gas.go:92-93). Every other bounded step in this repo goes through loadutils.WithinBudget, whose doc says exactly why:

main treats context.Canceled and context.DeadlineExceeded as a clean exit … so an internal budget that surfaced its own sentinel would be read as a successful run that did nothing at all.

The 10s deadline is not that. Trace it:

client.EstimateGas → context.DeadlineExceeded
  measureGasLimits   fmt.Errorf("price %s: %w", …)        gas.go:78
  WithinBudget(60s)  ctx.Err()==nil, within.Err()==nil    utils/budget.go  → passes through
  prepareAll         return                               prepare.go:133
  NewGenerator       fmt.Errorf("…: %w", …)               generator.go:239
  runLoadTest        endedOnRunContext → true             main.go:433
  → return nil

The 60s budget has not expired when the 10s one fires, so WithinBudget's rewording condition is false and the sentinel walks out intact. An endpoint that accepts a request and never answers now produces a zero exit code, a "👋 Shutdown complete", and a run summary of nothing — which is the precise shape of failure this PR exists to close, arriving through the fix for the previous round's finding rather than through gas.

DeployScenario's doc (base.go:294-299) already writes the rule down: "Callers that bound their own work must not let a context sentinel escape."

The fix is one line — nest the budgets, which composes correctly:

return loadutils.WithinBudget(ctx, gasEstimateCallTimeout, "gas estimate", func(ctx context.Context) error { … })

Inner expiry: parent (60s) Err()==nil, inner Err()!=nil → reworded. Outer expiry while inside a quote: the inner passes the sentinel through because its parent is done, and the outer WithinBudget reworders it against the run context. Both cases land as a plain error.

Nothing in the suite can see this: gasEstimateCallTimeout is never exercised at 10s, and the sentinel-escape property has a test for WithinBudget but none for the estimator. The mockChain.EstimateGas seam you just added is where one would go — a variant that blocks, asserting require.NotErrorIs(t, err, context.DeadlineExceeded).

Fix this →


2. The registry README's recovery story does not match what the code does after a re-genesis

This is the argument the policy reversal rests on, so it is worth being right about. README.md:83-85:

A re-genesis changes the chain's genesis hash, and the committed entries stop matching. A run then deploys its own contracts, which is what every run did before this directory held a file.

That holds only if the profile's genesisHash is updated at the same time. config.GenesisHash comes from the profile and is never checked against the chain — nothing reads block 0 (grep GenesisHash over non-test Go: config, prepare, registry, no chain read). So the realistic sequence after arctic-1 is re-genesised, with the profile untouched:

  1. profile still names the old hash;
  2. reg.Chain(713715, oldHash) matches the committed entry — the key is the profile's claim, not the chain's fact;
  3. Resolve calls Verify (registry/resolve.go:138), which reads code at 0x225a… on the new chain and finds none;
  4. planOne returns the error, prepareAll aborts, startup fails.

So the branch three paragraphs down — "One case does not degrade quietly. A recorded address whose code has changed fails Verify, and the run stops at startup"is the re-genesis case, and the two paragraphs contradict each other. Recovery is not "a pull request here, at whatever pace suits"; it is every cell reading that image failing at startup until either the profile's genesisHash moves or someone sets forceDeploy.

I am not arguing against committing arctic-1 — fail-closed with a message naming the address is a defensible place to land, and the supply-chain argument for committing is the stronger one. The README should just say that is what happens. As written it reads as though the old behaviour is preserved, and it is the sentence someone will rely on during an incident.

Adjacent: README.md:68 reads "Commit here: pacific-1, atlantic-2 and arctic-1" while registry_test.go asserts the compiled-in registry holds exactly one chain. Policy versus inventory, and the test's error message is clear, but a reader checking the directory against the heading finds two of the three missing.


3. mockPrepareAll still hands context.Background() — listed as taken, not in the tree

mockGasLimits receives the caller's context through mockPrepareAll rather than one frame short of it

prepare.go:381 is func (g *generatorBuilder) mockPrepareAll() error — no ctx parameter — and prepare.go:396 is return g.mockGasLimits(context.Background(), bindings), with prepare.go:87 calling g.mockPrepareAll() from a function that holds a ctx. The boundary is where it was last round. mockGasLimits takes a ctx and does no I/O, so this costs nothing today; I raise it only because it is the fourth item across four rounds asserted as present that is not, and that pattern is what the collapse was meant to end.


4. The balance override funds the probe with exactly call.Value and no slack

gas.go:164-166 sets the override balance to call.Value — 100 wei for Disperse. That is correct only while the node applies no fee default to an estimate whose fee fields are unset. If it does, buyGas checks gasLimit × feeCap + value against the balance and 100 wei fails it, with the same insufficient funds string you measured without the override. Your arctic-1 measurement settles arctic-1; it does not settle pacific-1, which may run a different sei-geth, and the PR targets it.

Funding generously — value + 1 ether, or value << 64 — costs nothing that matters. The override touches only the caller's balance, so the contract's slots stay cold and the quote stays the expensive shape; the wire test's funded.Balance >= call.Value still passes; and nothing here branches on msg.sender's balance. It converts a property of the node's version into a property of the request.

Related, and cheap to fix: measureGasLimits's own doc still carries the unqualified claim (gas.go:41-42):

It runs before funding, because pricing needs no funded account: the estimate carries no fee cap, so the node does not check the caller's balance.

estimate's doc (gas.go:134-149) states the correction properly — the value check is not gated on the fee fields — but the function above it still says the thing that was disproved, and the package doc at scenarios/doc.go repeats it. Someone reading top-down gets the old claim first. Three words: "…does not check the caller's balance for fees; see estimate for the value check."


5. The fee cap and the tip cap are now decoupled, and nothing keeps them ordered

Before this PR, gasFeeCapWei (20 gwei) ≥ gasTipCapWei (2 gwei) by construction, on every path. Now the cap is 5 × eth_gasPrice while the tip stays a constant — 2 gwei in utils.go:72 and EVMTransfer.go:66, 1 gwei in funder.go:81. A chain reporting a gas price under 0.4 gwei yields feeCap < tipCap, and core.ValidateTransaction rejects that with ErrTipAboveFeeCap before the transaction reaches a pool — every transaction, for the whole run.

Not a concern on the three networks in fee_internal_test.go (10 and 50 gwei), and it fails loudly per send rather than silently, so it is not a blocker. But the invariant used to be free and now is not, and Settings.Validate cannot catch it because it depends on the chain. resolveGasFeeCap is holding the only number that knows: one comparison there, either raising the cap to the tip or refusing with a message naming both, closes it permanently. The same place would naturally carry the tip — deriving it from the chain alongside the cap would remove the last hard-coded fee constant, which is the PR's own thesis.


6. Answers to the three you asked about

gasProbeSlot and the keyspace — where the comment belongs. On pickSlot's return, StorageRW.go:200:

// SetUint64 is what bounds the keyspace below 2^64 and therefore below
// gasProbeSlot. A draw wider than a uint64 collides with the probe slot,
// which stops being untouched, and write and rmw stop pricing their
// slot-from-zero shape.
return new(big.Int).SetUint64(idx), nil

That is the line an author would edit to break it — RecordCount widening to a big.Int, or SampleIndex returning something wider — so it is the line that has to object. A back-reference from gasProbeSlot (StorageRW.go:104) closes the loop: "outside any keyspace a profile can configure; see pickSlot, whose SetUint64 is what bounds it." Fourth mention and now a concrete location, so it should stop recurring either way.

TestPricedCallsCarryDistinctCalldata's slice bounds. Agreed on priority, one correction on blast radius: a panic in a Go test aborts the whole test binary, so a scenario added with stub calldata loses the results of every other test in scenarios, not just this case — and the message the test was written to print never appears. Still small; require.GreaterOrEqual(t, len(call.Data), 4) before the slice is the whole fix.

GetGasMargin / GetGasFeeCapMultiplier swallowing a sub-1 value. Agreed, cosmetic. Validate rejects a parsed profile and every in-repo caller either passes a valid Settings or none. The doc line is the only thing that is wrong, and it is wrong in the safe direction.


Smaller

  • scaleWei truncates the multiplier through int64(factor * scale) (fee.go:50). 5 is exact and 1.5 is exact; 1.1 lands at 1100 from 1100.0000000000002, and 2.9 at 2899 from 2899.9999999999995 — a 0.03% shortfall on the cap. Harmless at a 5× default and invisible to TestScalingKeepsPrecisionAtChainScale, which tests 2, 5 and 1.5. math.Round rather than a truncating conversion makes the function match its own doc, which says it multiplies without leaving the integer domain.
  • Two instances of one scenario price the same contract twice. measureGasLimits builds work per instance (gas.go:56-62), so a profile naming erc20 twice with one shared contractKey issues two identical eth_estimateGas calls. groupByContractName exists precisely to make that group resolve once. Startup cost only, and it keeps each instance's gasModels independent, which may be the point — worth a sentence saying so, since the grouping right above it establishes the opposite convention.
  • estimate sends "input" rather than "data" (gas.go:161). Correct for any modern geth, and the alias has been in TransactionArgs for years — noting it only because this is the one request in the repo built by hand rather than by ethclient, so it is the one that can drift from what a node accepts without a compile error.

Verdict

The collapse was the right call and the surface reads better as one change than as four: the fee cap, the pricing step and the registry entry are one argument, and splitting them is what let a fix and its defect live in different branches. The estimator's design holds up — the override is the right shape, estimate's doc is the clearest thing in the diff, and the wire test closes the seam that let two rounds of findings through. TestAPricedCallReachesTheNodeInTheRightShape is the test this change needed from the start.

Finding 1 is the one I would not merge past. It is new here, it is the fix for last round's finding rather than a leftover, and its failure mode is a run that reports success having sent nothing — the same class of invisible failure as a short gas limit, reached through the startup path instead of the send path. One line, and the rule it breaks is already written down in WithinBudget's own doc.

Finding 2 is not a code defect but I would not leave it as written. The recovery story is the argument for committing arctic-1, and after a re-genesis the run stops at startup rather than deploying. The README already contains the correct sentence three paragraphs later; the two just need to be reconciled.
· branch brandon2/gas-estimate-calls

main treats a context sentinel in the error chain as a clean exit. The
per-quote timeout added last round was a bare context, so it expired while
the collective budget was still healthy, nothing above stripped the
sentinel, and a run that never priced a call exited reporting success.

Bound the quote through WithinBudget, which strips it.

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

Copy link
Copy Markdown
Contributor Author

Confirmed and fixed in
generator/gas.go.

The reasoning holds exactly as written. WithinBudget strips the sentinel only when its own context expired (within.Err() != nil). My per-quote timeout was a nested bare context, so it fired while the 60s collective budget was still healthy, WithinBudget passed the error through untouched, and endedOnRunContext read the wrapped context.DeadlineExceeded as a clean shutdown. A run that priced nothing would have exited 0.

The fix routes the per-quote bound through WithinBudget as well, so the inner deadline surfaces as a gas quote exceeded its 10s budget: ... with no sentinel in the chain.

Guarded by TestAHungQuoteIsNotACleanShutdown, which drives startup against a mock chain whose eth_estimateGas accepts the request and never answers, then asserts both halves: the error is not errors.Is(..., context.DeadlineExceeded), and it names a budget. Mutation-checked by restoring the bare context.WithTimeout — the test fails.

Worth naming plainly: I introduced this while implementing your previous round's suggestion to bound each quote. The suggestion was right; my first implementation of it reintroduced the defect the surrounding code exists to prevent.

@bdchatham

Copy link
Copy Markdown
Contributor Author

@seidroid review

@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.

All five previously-flagged findings are genuinely fixed — the Disperse probe now carries msg.value through a balance state override and reads its limit back, priced calldata is resolved once at pricing time instead of per send, and StorageRW's unpriceable read peak is covered by an explicit constant with a test at margin 1. What remains is non-blocking: two sibling transfer scenarios still carry hard-coded fee caps, the gas tip stayed a constant while the cap became chain-derived, and the StorageRW send path now packs its calldata twice.

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

Blockers

  • None at the file/PR level.

Non-blocking

  • [suggestion] Committing registry/chains/arctic-1.json changes startup behaviour for existing arctic-1 profiles: planOne (generator/prepare.go:250) errors when reg.HasChainID(chainID) is true and the profile names no genesisHash, which was previously unreachable because chains/ shipped empty. profiles/arctic-1.json has chainId: 713715 and no genesisHash, and survives only because it drives EVMTransfer alone and so creates no bindings — adding any contract scenario to it turns it into a startup failure. Adding the genesis hash to that profile would keep it consistent with the entry the binary now ships, and the behaviour change is worth a line in the PR's Known section for operators running their own arctic-1 profiles.
  • 3 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread generator/utils/utils.go
Comment thread generator/fee.go
Comment thread generator/scenarios/StorageRW.go
EVMTransferNoop declared 20 gwei and EVMTransferFast 200 gwei as literals.
Both are wrong on a chain whose base fee has passed them, and 20 gwei is
under what pacific-1 and atlantic-2 charge today, so that scenario was
rejected at the ante rather than failing loudly.

Both now read the cap the run resolved from the chain, which is what the
comment in utils already claimed of every path. The 2 gwei tip becomes one
exported constant instead of three literals. EVMTransferFast keeps its zero
tip, which is deliberate; only its comment said otherwise.

profiles/arctic-1.json gains the genesisHash the registry keys on. The
profile drives EVMTransfer alone, so it started without one; adding any
contract scenario to it would have turned that into a startup failure.

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

Copy link
Copy Markdown
Contributor Author

Addressed three of the four non-blocking suggestions. Detail on each:

Hard-coded fee caps (utils.go:54 thread) — fixed. You were right that the comment overclaimed, but the more serious half is that EVMTransferNoop's 20 gwei literal is under the 50 gwei base fee on pacific-1 and atlantic-2. That scenario was being rejected at the ante, which surfaces as a rejection rate rather than an error. EVMTransferFast's 200 gwei cleared the base fee by luck. Both now read the resolved cap.

Tip constant (fee.go:39 thread) — partly fixed. The 2 gwei literal appeared at three sites while utils.gasTipCapWei existed; it is now one exported utils.GasTipCapWei. I did not make the tip chain-derived. A tip is a priority bid, not an admission threshold, so nothing about the chain's base fee tells you what it should be. EVMTransferFast keeps its zero tip deliberately; only its // 2 gwei comment said otherwise.

profiles/arctic-1.json genesisHash — fixed. Your reading of planOne is exact: the profile survives only because it drives EVMTransfer alone and creates no bindings. It now carries the hash, matching the committed registry entry.

StorageRW double-pack (StorageRW.go:170 thread) — not fixed. Confirmed real: data exists only to size the limit and is discarded, then the bound contract packs the same arguments again on every send. Removing it means sending through the raw transactor instead of the generated binding, which is a change to the send path rather than a local edit. I would rather not make that change on an approved PR. Happy to file it or take it here, whichever you prefer.

Guard added: TestEveryRawScenarioTakesTheResolvedFeeCap drives all three raw scenarios through Ready/Generate with a resolved cap far from any plausible literal, so a scenario that writes its own fails rather than passing by coincidence. Mutation-checked by restoring the 20 gwei literal.

One honest gap: that test names its three scenarios explicitly, so a new raw scenario is not covered automatically. The contract path is covered separately by TestNoResolvedFeeCapRefusesToGenerate.

@bdchatham

Copy link
Copy Markdown
Contributor Author

@seidroid review

@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.

The final commit migrates EVMTransferNoop and EVMTransferFast off their hard-coded caps, so the fee cap is now genuinely single-sourced from the chain across every send path, deployment and the funder. Nothing blocking remains; two earlier non-blocking findings (the tip/cap relationship in resolveGasFeeCap, and StorageRW packing its calldata twice per transaction) are still present unchanged.

Findings: 0 blocking | 2 non-blocking | 2 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • None at the file/PR level.
  • 2 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread generator/fee.go
if suggested.Sign() <= 0 {
return fmt.Errorf("the chain reported a gas price of %s, so no cap can be derived from it", suggested)
}
cap := scaleWei(suggested, g.config.GetGasFeeCapMultiplier())

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] Still open from the previous round: the cap is derived from the chain but the tip stayed a constant. utils.GasTipCapWei is 2 gwei (generator/utils/utils.go:37) and EVMTransfer.go:68 / EVMTransferNoop.go both declare it, while the funder declares 1 gwei. Nothing ties either to the value computed here, so if eth_gasPrice reports below ~0.4 gwei — a local dev node, or a chain configured with a lower min fee — suggested * 5 lands under the tip and every transaction is structurally invalid with max priority fee per gas higher than max fee per gas. That is a whole-run failure derived from a perfectly valid chain reading, and the Sign() <= 0 guard above does not catch it because the price is positive. The old constants (20/100/200 gwei) were all far above the tip and could not hit this. A cap = max(cap, gasTipCapWei) clamp here, or deriving the tip from the same read, closes it.

if err2 != nil {
return nil, fmt.Errorf("storagerw: pack %q: %w", op, err2)
}
limit, err := s.MaxGasLimitForData(data)

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] Still open from the previous round. Caching the parsed ABI on the scenario removed the per-transaction JSON decode, which is a real improvement, but the double pack itself is unchanged: data is packed above only to size the limit and is then discarded, and s.contract.Rmw/Read/Write below packs the identical arguments again. MaxGasLimitForData then runs core.IntrinsicGas and core.FloorDataGas, both O(len(data)), over that throwaway copy. With a size distribution reaching multi-KiB pads that is a full extra allocation and copy of the calldata per generated transaction, in the hot loop of a load generator — the same class of send-path work this PR argues against in the gasLimits rationale in base.go. Everything the limit depends on is known here without materialising a second copy: the selector, the slot word's byte composition, the length word, and the padded pad length (the pad is all zero bytes from pickPad). Deriving the zero/non-zero counts from those keeps the recomposition exact and drops the second pack.

@bdchatham
bdchatham merged commit 0b19c12 into main Aug 29, 2026
22 checks passed
@bdchatham
bdchatham deleted the brandon2/gas-estimate-calls branch August 29, 2026 15:57
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