feat(gas): ask the chain what a call costs instead of hard-coding it - #73
feat(gas): ask the chain what a call costs instead of hard-coding it#73bdchatham wants to merge 8 commits into
Conversation
The scenario declared 22460 gas for a mint. Measured against the deployed binding, a mint to a receiver holding none of the token needs 69319, and one to a receiver that already holds some needs 51757. Every mint the scenario sent landed in a block with a failed status, having burned the whole limit, and trackReceipts defaults to false so the run reported each one as sent. 22460 is ERC20Noop's constant, copied. PLT-1091 covers the two scenarios that still carry it. The limit is now 75000, and the test pins it against the measurement rather than against itself. Broke the constant back to 22460 and to 200000 on purpose; the test caught both. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A DeFi profile had no contract to drive. This adds a constant-product pair with the storage and gas shape of a UniswapV2 swap: both reserves, the caller's balance in each token, and an event. The contract never reverts on bookkeeping, which is the choice StorageRWv1 already makes. The balances wrap rather than check, because nothing reads them back and a load generator that fails on its own accounting stops measuring the chain. A short caller is not credited: crediting exactly what is then debited returns the slot to zero, and a zero to non-zero storage write costs four times one that changes a slot already holding a value. Under the default mix, which draws one direction, that write would land on every swap rather than the first. The reserves sit between a floor and a ceiling. Without the ceiling the input side grows without bound and the output halves every 100000 swaps, so a long run prices nothing like its start. The ceiling is also what keeps one oversized call from ending the pair: a swap of 1e49 leaves the input reserve at 1e49, and the contract has no owner and no reset. Measured, the next ordinary swap instead resets that side to the floor and pays out in full. The gas limit is 85000, read from eth_estimateGas rather than from a receipt. GasUsed is the post-refund charge and a transaction carries the pre-refund peak; sizing from a receipt put an earlier draft 20% under what its own swap needed. An account's first swap needs 79988 and every later one needs 45177, so a run in steady state declares about 44% more gas than it spends. PLT-1093 carries the prewarm change that would close that. PLT-1092 carries the chain-parameter exposure, which is the whole package rather than this constant. Every guard here was broken on purpose before it was believed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A deployment has no way to tell a run that is starting from one that is stuck. The process serves /metrics and nothing else, so a probe set has nothing to gate on and a pod counts as available the moment its container starts. /healthz answers as soon as the server binds and never reads the startup sequence. /readyz refuses until the dispatcher is running. Keeping those separate is the whole point. Funding, deployment and prewarm take minutes against a cold chain. A liveness probe that reported the run dead for that window would restart the pod before it sent a transaction, then restart the next attempt at the same place, and the cause would read as a crash loop rather than a slow start. While /readyz refuses it names the phase, so a ten-minute startup shows the step it is on. Measured against the binary: healthz held 200 through a 21 second prewarm while readyz reported "prewarming accounts", then both answered once the dispatcher started. The flag and the phase are stored as one value rather than as two atomics. Two would leave a window where a reader sees the run serving while the body still names the step it left, so the status and the body would disagree about the same instant. Five mutations, five caught, including that one: split into two atomics, a reader observed a serving status carrying "funding accounts". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every contract scenario declared a gas limit as a constant. Those constants assume the EVM default of 20,000 for a storage write that takes a slot from zero to a value. Sei sets that as a governance parameter and its live networks charge 72,000, so every one of them is short on Sei by a factor. Measured against arctic-1: an AMM swap needs about 185,000 where the constant said 85,000, an ERC20 transfer 175,097 against 72,156, an ERC721 mint 174,782 against 75,000. A short limit does not fail visibly. The transaction reaches a block, burns the whole limit, and a run without receipt tracking reports it as sent. ERC20Noop was short by eight gas with no Sei parameter involved at all, which is the argument against hand-picked constants in one line. A scenario now declares GasEstimateCalls, one per operation it issues, and the preparation step asks the chain what each costs after the contracts are bound. ContractScenarioBase does not implement it, so a scenario added without one does not compile — the same gate that already forces Operation(). The priced call is the expensive shape. Cost is bimodal per account: the first transaction from an address writes slots holding zero. Pricing from a freshly generated address makes those slots cold by construction, so the measurement bounds what a run sends rather than describing its cheap case. The call carries no fee cap, because a call carrying one makes the node check the caller's balance and this caller has none; verified against arctic-1, where the same estimate succeeds without fee fields and fails with them. Calldata is recomposed rather than measured. GasModel keeps the execution term apart from the calldata term, so StorageRW reuses one measurement across every pad it draws. The recomposition calls the chain's own IntrinsicGas and FloorDataGas, so it is exact rather than fitted, and it covers the EIP-7623 floor that Sei's ante does not check. That deletes storageRWBaseGas, abiWord and calldataFloorGasPerByte along with the per-scenario constants. Pricing fails the run rather than falling back. A fallback is a cold branch that runs exactly when the estimate could not be trusted, and its failure is the invisible kind. Margin defaults to 1.20 and is a profile setting. Sei fills a block against two budgets, one charged at the declared limit and one at what the transaction spends, and the declared one binds only past four times the spend. Below that, margin costs no block space. A profile of native transfers alone prices nothing and issues no extra call. Five mutations, five caught: an operation left unpriced, two operations priced against one method, the limit stopping coming from the model, a scenario declaring no calls at all, and the decomposition failing to round-trip. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PR SummaryMedium Risk Overview Adds configurable Disperse now sets Reviewed by Cursor Bugbot for commit 511fc8f. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
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:
requireGasMatchesModelis only wired intoAMM_test.goandStorageRW_test.go, and the deletedERC721_test.gowas not replaced with an equivalent.TestEveryDrawableOperationIsPricedproves 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 knownGasModel, generates a tx, and assertstx.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 —StorageRWusesMaxGasLimitForDataand everyone else usesGasLimitFor. It is the method the per-operation scenarios should be using (see the inline comment onGasLimitFor); 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:82—CreateContractTransactioncallsDisperseEtherFixedwithout settingauth.Value, but the contract requiresmsg.value == fixedEtherAmount * recipients.length(100 wei, given thebigOneconstructor args inDeployContract). Every disperse transaction therefore reverts on chain, and withtrackReceiptsoff the run reports it as sent — the same invisible failure this PR exists to eliminate.
| targets = append(targets, gasProbeAddress()) | ||
| } | ||
| return []GasEstimateCall{ | ||
| {Operation: config.OpDisperseEther, Data: mustPack(bindings.DisperseMetaData, "disperseEtherFixed", targets)}, |
There was a problem hiding this comment.
[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)) |
There was a problem hiding this comment.
[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) |
There was a problem hiding this comment.
[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) |
There was a problem hiding this comment.
[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 |
There was a problem hiding this comment.
[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.
|
All three findings confirmed and fixed. The fix landed in #74, one commit up the stack ( The send path rebuilt the priced call on every transaction. Correct, and worse than the comment says: 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. The send path never set On reading 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. |
|
Both StorageRW findings confirmed and fixed, in #76 at the top of the stack ( The Fixed with a constant rather than by requiring Guarded at the margin that gives it no help: The comment placement is also mine. I inserted Worth noting the first fix I attempted for this reported |
#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.
|
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 [blocker] Disperse probe omits [blocker] Disperse never reads
Verified at the stack head: One note on this branch specifically: #71 and #72 both merged squashed, so the merge back to |
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.
|
@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
Your other three findings are also fixed: Disperse now reads 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 |
|
Claude finished @bdchatham's task in 6m 21s —— View job Re-review of the gas design
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: 1. [blocker at
|
| 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
MaxGasLimitForDatareports "no measured gas limits" when everyExecis 0.foundis set only insideif model.Exec > widest.Exec(generator/scenarios/base.go:258), so a map of models that are all zero looks empty. Unreachable today —gasEstimatorrejectsrequired <= intrinsic, the mock uses 200,000 — but the flag is keyed on the comparison rather than on presence.if !found || model.Exec > widest.Execsays what is meant.- The block-limit error names the wrong number.
blockGasLimitreturnsmin(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. GasLimitForcollapses 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.GasLimitForDatais still dead code (generator/scenarios/base.go:238) — no caller. Finding 1 is the caller it was written for.mockGasLimitsdrops the caller's context forcontext.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>
|
@seidroid all three are real and fixed in 1. The priced call was not an upper bound on the sent call. Confirmed: Took your fix. Your observation about 2. The margin scaled the calldata intrinsic. Confirmed, and it matches your figure: Now 3. The precondition on 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 Three mutations, three caught. Worth another look given this round changed behaviour rather than comments. |
|
Claude finished @bdchatham's task in 5m 2s —— View job Re-review of
|
…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>
|
@seidroid review Everything from both earlier passes is addressed as of From the first pass — all five fixed. Disperse's probe carries the From the second pass — all three behaviour fixes plus four of the five smaller items. Probe calldata is maximal by construction, so 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: |
|
Claude finished @bdchatham's task in 5m 53s —— View job Re-review of
|
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ 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.
| From: types.NewAccount(false).Address, | ||
| To: &address, | ||
| Data: call.Data, | ||
| Value: call.Value, |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit 511fc8f. Configure here.


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:AMM.swapAToBERC20.transferERC721.mintERC20Noop.transferA short limit does not fail visibly: the transaction reaches a block, burns the
whole limit, and a run with
trackReceiptsoff reports it as sent. It alsocompounds — 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.
ERC20Noopis short by eight gas, with no Sei parameter involved. That isthe 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:
ContractScenarioBasedeliberately does not implement it, so a scenario addedwithout one does not compile — the same gate that already forces
Operation(). I confirmed that by adding the method: all seven scenarios failedto 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 transferwithmaxFeePerGasset. Anyone building theprobe from
CreateTransactionOptswould hit that and have no idea why.Calldata is recomposed, not measured.
GasModelkeeps the execution termapart from the calldata term, so
StorageRWreuses one measurement across everypad it draws. The recomposition calls the chain's own
core.IntrinsicGasandcore.FloorDataGas, so it is exact rather than fitted, and it covers theEIP-7623 floor that Sei's ante does not check. That deletes
storageRWBaseGas,abiWordandcalldataFloorGasPerBytealong 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 anaddress whose code it cannot confirm, for the same reason.
Margin
Defaults to 1.20, settable per profile as
gasMargin. Sei fills a block againsttwo budgets —
max_gasat 12,500,000 charged at what a transaction spends, andmax_gas_wantedat 50,000,000 charged at the declared limit. Read live frompacific-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:
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 vetandgolangci-lint runare 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.gopinsgasFeeCapWei = 20 gwei; the live base fee is 50 gweion 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