Skip to content

feat(gas): ask the chain what a call costs instead of hard-coding it - #73

Open
bdchatham wants to merge 8 commits into
mainfrom
brandon2/gas-estimate-calls
Open

feat(gas): ask the chain what a call costs instead of hard-coding it#73
bdchatham wants to merge 8 commits into
mainfrom
brandon2/gas-estimate-calls

Conversation

@bdchatham

Copy link
Copy Markdown
Contributor

Third on the stack, after #71 and #72.

Every contract scenario declared its 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.

Measured against arctic-1 by injecting each contract's runtime bytecode and
calling eth_estimateGas:

scenario declared arctic-1 needs
AMM.swapAToB 85,000 ~185,200
ERC20.transfer 72,156 175,097
ERC721.mint 75,000 174,782
ERC20Noop.transfer 22,460 22,468

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. It also
compounds — the out-of-gas revert leaves the slots at zero, so the next
transaction is the same shape. It is 100% failure, not a warm-up.

ERC20Noop is short by eight gas, with no Sei parameter involved. That is
the argument against hand-picked constants in one line.

What replaces them

A scenario declares the calls it issues; the preparation step asks the chain what
each costs, once, after the contracts are bound:

func (s *AMMScenario) GasEstimateCalls() []GasEstimateCall {
	return []GasEstimateCall{
		{Operation: config.OpSwapAToB, Data: mustPack(bindings.AMMMetaData, "swapAToB", ammSwapAmount)},
		{Operation: config.OpSwapBToA, Data: mustPack(bindings.AMMMetaData, "swapBToA", ammSwapAmount)},
	}
}

ContractScenarioBase deliberately does not implement it, so a scenario added
without one does not compile — the same gate that already forces
Operation(). I confirmed that by adding the method: all seven scenarios failed
to build until each declared its calls.

The send path issues no estimate. A four-scenario profile adds 5–8 startup calls;
a profile of native transfers alone adds none.

Three decisions worth reviewing

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 cold by construction, so the measurement
bounds what a run sends rather than describing its cheap case. Pricing from a
used account would return the cheap number and every account's first transaction
would fail.

The call carries no fee cap. Verified against arctic-1: the same estimate
from a zero-balance address returns 180,067 with the fee fields unset and fails
insufficient funds for transfer with maxFeePerGas set. Anyone building the
probe from CreateTransactionOpts would hit that and have no idea why.

Calldata is recomposed, not 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 core.IntrinsicGas and
core.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.

Fail closed

Pricing failure stops the run. A fallback to the constant is a cold branch that
runs exactly when the estimate could not be trusted, and its failure mode is the
invisible one. This matches registry.Verify, which already refuses to bind an
address whose code it cannot confirm, for the same reason.

Margin

Defaults to 1.20, settable per profile as gasMargin. Sei fills a block against
two budgets — max_gas at 12,500,000 charged at what a transaction spends, and
max_gas_wanted at 50,000,000 charged at the declared limit. Read live from
pacific-1. The declared limit therefore binds only past four times the spend,
so margin below that costs no block space, only the balance each in-flight
transaction locks. Erring high is nearly free; erring low is total failure.

Verification

Five mutations, five caught:

  • an operation left unpriced
  • two operations priced against the same method
  • the limit no longer coming from the model
  • a scenario declaring no calls at all
  • the decomposition failing to round-trip

The round-trip test is the one that pins exactness: taking the calldata cost out
of a quote and putting it back must return the quote. It also asserts the floor
takes over where it should — which it did, catching a bad fixture of mine before
it caught anything else.

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

Not in this PR

The fee cap is the other half of the same defect and it blocks the public
chains.
utils.go pins gasFeeCapWei = 20 gwei; the live base fee is 50 gwei
on pacific-1 and atlantic-2, so the fee ante rejects every transaction before the
EVM runs. arctic-1 is at 10 gwei, so this PR is enough to unblock the deployment
there. Next PR on the stack.

Also deferred: contract-aware prewarm (worth ~4x block occupancy, but it cannot
help ERC20, whose sender balance oscillates 0↔1 forever, or ERC721, whose token
slot is fresh by construction), and mid-run re-estimation for a governance
parameter change.

🤖 Generated with Claude Code

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 gas limits for all contract scenarios and adds a startup dependency on RPC eth_estimateGas and block headers; mis-estimation or margin misconfiguration can still cause OOG or inflated declared gas, though the design fails closed at startup when estimates fail.

Overview
Replaces per-scenario hard-coded gas limits with one-time chain pricing after contracts are bound. Each contract scenario now declares GasEstimateCalls(); preparation runs measureGasLimits (or mockGasLimits on dry-run) and stores GasModel limits used on the send path via GasLimitFor / MaxGasLimitForData.

Adds configurable gasMargin (default 1.20, validated ≥ 1) applied only to the execution term; calldata is recomposed with IntrinsicGas and FloorDataGas (EIP-7623), which matters for StorageRW pads and removes manual pad math. Estimates use a fresh unfunded From with no fee cap, probe addresses for cold-storage shapes, and fail closed if pricing or block-limit checks fail (after deployment recording).

Disperse now sets msg.value on send to match priced calls. Tests assert every drawable operation is priced, model round-trips quotes, and generated txs match derived limits.

Reviewed by Cursor Bugbot for commit 511fc8f. 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
targets = append(targets, gasProbeAddress())
}
return []GasEstimateCall{
{Operation: config.OpDisperseEther, Data: mustPack(bindings.DisperseMetaData, "disperseEtherFixed", targets)},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[blocker] This probe omits msg.value, so it cannot be estimated. disperseEtherFixed starts with require(msg.value == fixedEtherAmount * recipients.length) (generator/contracts/Disperse.sol:35), and DeployContract passes bigOne for fixedEtherAmount, so the call needs exactly 100 wei. gasEstimator builds ethereum.CallMsg with only From/To/Data, leaving Value nil, so eth_estimateGas reverts, measureGasLimits returns an error, and prepareAll aborts — any profile containing disperse now fails at startup where it previously started.

Fixing this needs a Value *big.Int field on GasEstimateCall, plumbed into the CallMsg in generator/gas.go. Note that the value must come from the deployed contract's fixedEtherAmount, not a duplicated constant, or the probe drifts from the contract the same way the old gas constants drifted from the chain. (Separately, the send path never sets auth.Value either — see the pre-existing note.)

if !ok {
return 0, false
}
limit, err := model.Limit(c.gasCallData(operation))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[blocker] gasCallData calls c.deployer.GasEstimateCalls() on every Generate, which rebuilds the probe calldata per transaction. For ERC20, ERC20Conflict, ERC20Noop and ERC721 that means gasProbeAddress()types.NewAccount(false)crypto.GenerateKey() — a fresh secp256k1 keypair generated per generated transaction (tens of microseconds plus allocation churn), on the hot path of a load generator. AMM re-packs both legs' ABI calldata per transaction for the same reason. The PR description states "the send path issues no estimate", but it does now do per-tx keygen and ABI packing.

There is a correctness edge too: the limit is recomposed against the probe's calldata, not the transaction's. Intrinsic gas is 16/byte non-zero vs 4/byte zero, so a random probe address and the real receiver do not carry the same intrinsic cost, and Limit()'s "exact rather than fitted" property does not hold for these scenarios.

Both go away by having the scenarios call the already-written GasLimitForData(op, data) with the calldata they are about to send (as StorageRW does), or at minimum by caching GasEstimateCalls() output once at pricing time instead of recomputing it per send.

// create new accounts so that it auto-creates the accounts.
targets := make([]common.Address, 0, 100)
for range 100 {
targets := make([]common.Address, 0, disperseRecipients)

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] CreateContractTransaction never reads the measurement back — no GasLimitFor(config.OpDisperseEther) call, so disperse still sends under the 200,000 default from CreateTransactionOpts. This scenario therefore pays the full cost of the new pricing step (100 probe keypairs plus a startup-fatal estimate) and uses none of it. Given the estimate comment claims 100 fresh recipient accounts, the measured limit is very likely to exceed 200,000 on Sei, so this is also the same under-provisioning the PR is fixing everywhere else.

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] The read operation's worst shape is not bounded by the largest priced model when gasMargin is at its minimum. read at peak pays a cold SLOAD of store[slot] plus the readAccumulator write from zero, while the write/rmw probes pay the slot-from-zero write and one cold access — so read exceeds them by roughly one cold slot access (~2,100 gas). The doc comment at line 102 says "which the margin absorbs", but Settings.Validate accepts GasMargin == 1, and at 1.0 nothing absorbs it: the first read of a written slot on a fresh deployment burns its whole limit, which is precisely the invisible failure mode this PR is closing. Either require GasMargin > 1, or add explicit fixed headroom in MaxGasLimitForData rather than relying on a configurable multiplier.

// The draws run in a fixed order: slot, then pad, then operation. That order
// must stay stable — all three share the run's single PRNG, so reordering them
// shifts every subsequent draw and diverges a replay at the same seed.
// gasProbeSlot is the slot this scenario prices against. It sits outside any

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] gasProbeSlot was inserted directly into CreateContractTransaction's doc comment, with no blank line between them. The result is one comment block spanning lines 83–102 that now documents the variable, and CreateContractTransaction at line 116 is left undocumented — including the PRNG draw-order warning at lines 89–91, which was specifically about that function and now reads as commentary on a package-level big.Int. Move gasProbeSlot and its comment above line 83.

@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

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

@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 511fc8f. Configure here.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unfunded estimate From rejects payable calls

High Severity

The estimate now forwards call.Value while From is still a freshly generated address with a zero balance. Nodes check balance >= value even when fee fields are unset, so a payable probe can fail with insufficient funds. Disperse is the scenario that sets Value, so a profile that names it can refuse to start — the same fail-closed outcome this field was added to avoid.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 511fc8f. Configure here.

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