core/vm: check SSTORE access affordability before reading committed state - #2356
Conversation
…tate The PIP-88 SSTORE gas function read committed state before it knew the slot's access cost. Because the cold access cost (ColdSstoreCostPIP88, 2940) exceeds the reentrancy sentry (SstoreSentryGasEIP2200, 2300), clearing the sentry no longer implies the caller can pay for a cold access, so the read could be performed for a call that then runs out of gas. Resolve the access cost and warm the slot first, verify the caller can afford the access, and only then read. This matches the ordering go-ethereum adopted for its EIP-8037/8038 SSTORE handler (ethereum/go-ethereum#35261). Every returned gas value is unchanged. The guard only rejects callers that already fail the subsequent charge (the minimum cold total, 3040, exceeds the 2940 guard), so no transaction changes success/failure or gas used, and both routes surface ErrOutOfGas. Add TestPIP88SStoreAccessCheck, which uses a read-counting StateDB to assert the read does not happen once gas is above the sentry but below the access cost -- the boundary an error-string check alone would miss. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Claude Code Review
This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.
Tip: disable this comment in your organization's Code Review settings.
|
codegenie review |
|
@claude review |
🧞 Codegenie Review✅ No credible findings. CoverageReviewed 2/2 hunks. 🙋 Needs Human Attention
Stats
✅ No FindingsNo credible findings were found. Everything looks good. |
There was a problem hiding this comment.
Pull request overview
This PR adjusts the PIP-88 SSTORE dynamic gas function to determine whether a storage slot access is cold/warm and ensure the caller can afford the slot access cost before performing the committed-state read, preventing unnecessary state reads in calls that will inevitably run out of gas (notably relevant for witness generation paths).
Changes:
- Reorders PIP-88
SSTOREgas logic to resolve access-list status, compute the slot access cost, and guard affordability before callingGetStateAndCommittedState. - Introduces a targeted unit test that counts committed-state reads to pin the ordering invariant and prevent regressions.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
core/vm/operations_acl.go |
Reorders PIP-88 SSTORE dynamic gas path to check access affordability before committed-state reads. |
core/vm/gas_table_test.go |
Adds TestPIP88SStoreAccessCheck to assert no committed-state read occurs in the sentry-cleared-but-cold-unaffordable window. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
I reviewed this PR and did not find any bugs. The fix reorders the PIP-88 SSTORE gas check so the caller's ability to pay the cold access cost is verified before the committed-state read happens, avoiding an unnecessary trie read on a call that would OOG anyway. Because this touches consensus-critical SSTORE gas logic on a path that is live under Chicago (mainnet, Amoy), a human look is still worthwhile before merging.
Extended reasoning...
Overview
The PR modifies makeGasSStoreFuncPIP88 in core/vm/operations_acl.go to resolve and warm the slot's access cost and verify the caller can afford it before calling GetStateAndCommittedState, instead of reading first and only failing later in the interpreter's post-hoc gas check. A new test (TestPIP88SStoreAccessCheck) uses a read-counting StateDB wrapper to pin the exact gas boundary at which the read is skipped.
Security risk assessment
I traced the interpreter's dynamic-gas handling (core/vm/interpreter.go) and the Call error path (core/vm/evm.go): any error returned from a gasFunc is wrapped with ErrOutOfGas and, since it isn't ErrExecutionReverted, zeroes the caller's remaining gas identically to the old path where the interpreter's own contract.Gas < dynamicCost check would have caught the same shortfall after the (now-removed) read. For the warm-slot case the new guard (100 gas) is strictly weaker than the existing sentry check (2300 gas) and can never fire, matching the added test. For the cold case, the guard threshold (2940) matches ColdSstoreCostPIP88 exactly, so the newly-rejected window (2301–2939 gas) is precisely the window that previously would read state and then fail the interpreter's gas check anyway. I did not find a case where this changes success/failure, gas used, or refund behavior — this matches the PR's own devnet verification (identical gasUsed).
Level of scrutiny
This lives in core/vm/, which the repo's own security rules (evm-security.md, hardfork-rollout.md) flag as consensus-critical, and the affected code path is active under the Chicago/PIP-88 hardfork on mainnet and Amoy. Even though my read of the diff and the interpreter's error handling supports the outcome-neutrality claim, gas-accounting changes on a live hardfork path are exactly the category where a second human reviewer's sign-off is warranted, independent of whether a bug was found.
Other factors
The PR includes a boundary-focused unit test that specifically distinguishes "rejected before read" from "rejected after read," which is good coverage for this kind of ordering fix. No CODEOWNERS or other outstanding review comments are present in the timeline yet.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## develop #2356 +/- ##
===========================================
- Coverage 54.23% 54.20% -0.03%
===========================================
Files 908 908
Lines 162243 162252 +9
===========================================
- Hits 87987 87956 -31
- Misses 68825 68855 +30
- Partials 5431 5441 +10
... and 24 files with indirect coverage changes
🚀 New features to boost your workflow:
|
Summary
The PIP-88 SSTORE gas function (
makeGasSStoreFuncPIP88,core/vm/operations_acl.go)performed the committed-state read before it knew the slot's access cost. Because
the cold access cost (
ColdSstoreCostPIP88= 2940) exceeds the reentrancy sentry(
SstoreSentryGasEIP2200= 2300), clearing the sentry no longer guarantees the callercan pay for a cold access — so the read could be performed for a call that then runs out
of gas, i.e. work the gas schedule does not charge for. On bor this path is live behind
Chicago (mainnet, Amoy).
This PR resolves the access cost and warms the slot first, verifies the caller can afford
the access, and only then reads — matching the ordering go-ethereum adopted for its
EIP-8037/8038 SSTORE handler in ethereum/go-ethereum#35261.
Outcome-neutral. The guard only rejects callers that already fail the subsequent
charge — the minimum cold total (3040 = 2940 + 100) exceeds the 2940 guard — so no
transaction changes success/failure or gas used; both routes surface
ErrOutOfGas. Everyreturned gas value is byte-identical. Not a hardfork.
Verified on a devnet built from this branch (Chicago active), against the same devnet
built from
develop:gasUsedThe identical
gasUsedis the outcome-neutrality check on a live chain — same gas, sameOOG outcome; only the block witness (non-consensus) shrinks, because the reverted read no
longer populates the trie-fetch path that feeds witness collection.
Executed tests
go test ./core/vm/— newTestPIP88SStoreAccessCheckpasses (5 boundary cases). Ituses a read-counting
StateDBto assert the committed-state read does not happenonce gas is above the sentry but below the access cost — the boundary an error-string
check alone would miss.
core/vmsuite unchanged (no gas-value test flips). The one intermittent failure,TestAbortDuringJump/JUMP, is a pre-existing timing-race flake — it fails identicallyon
developwith this change stashed out and passes in isolation.go build ./...,gofmt,go vet ./core/vm/clean.debug_traceCallsweep on acold slot flips only in the intended window — at 2600 gas the SSTORE now rejects with
not enough gas for slot accessbefore the read, while ≤2300 and ≥2940 are unchanged.Baseline-vs-attack witness windows show the witness back at baseline (table above); the
chain produced blocks normally throughout.
Rollout notes
success/failure, receipts, and state roots are identical before and after — a full-sync
node produces the same blocks. The only change is a smaller block witness for the
affected calls.
not a schedule change.
producer emits a slightly smaller witness. Full-sync nodes are unaffected; witness
producers and stateless-sync consumers should move together during rollout. The witness
is not committed in the block header on the current (pre-Amsterdam) chain, so there is no
consensus impact — worth a reviewer confirming against the target release.