Skip to content

refactor(v10): check storage existence only for zeros#3790

Open
brbrr wants to merge 4 commits into
mainfrom
refactor/v10-storage-existence-check
Open

refactor(v10): check storage existence only for zeros#3790
brbrr wants to merge 4 commits into
mainfrom
refactor/v10-storage-existence-check

Conversation

@brbrr

@brbrr brbrr commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Bench results:
(Generated using sepolia snapshot, 1 block temp db wasn't representative, as the data was hot, and results were marginal)

Note that only for the missing contract on the latest block we would face degradation.

Reader Scenario main sec/op branch sec/op delta time main allocs branch allocs Δ allocs
latest non_zero 38.45µs 24.74µs -35.7% 81 70 -13.6%
latest zero_exists 58.16µs 57.08µs ~ 86 86 ~
latest missing_contract 21.71µs 49.65µs +128.7% 58 88 +51.7%
historical non_zero 112.0µs 59.85µs -46.6% 120 88 -26.7%
historical zero_exists 129.5µs 81.69µs -36.9% 124 93 -25.0%
historical missing_contract 20.78µs 21.96µs ~ 55 55 ~

@brbrr brbrr self-assigned this Jul 3, 2026
Copilot AI review requested due to automatic review settings July 3, 2026 09:01
@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Claude finished @brbrr's task in 2m 21s —— View job


Code Review — refactor(v10): check storage existence only for zeros

  • Read the diff and surrounding StorageAt handler
  • Trace ContractStorage semantics across all reader implementations
  • Verify correctness of the "probe only on zero" optimization
  • Review test coverage
  • Post feedback

This is a clean, correct refactor. The optimization skips the ContractClassHash existence probe when the storage value is non-zero, since a non-zero value already proves the contract exists. I traced the semantics across all reader implementations to confirm the invariant the change relies on holds:

ContractStorage returns db.ErrKeyNotFound only when the contract is absent — never for an unset slot on an existing contract:

  • Head readers (core/state/state_reader.go, core/deprecatedstate): a missing contract or unset slot both read as felt.Zero, nil — no error. This is exactly why the zero-value probe is still required, and the code does it. ✅
  • Historical readers (core/state/history.go, core/deprecatedstate/history.go): checkDeployed runs first and returns ErrKeyNotFound for a non-deployed contract; an unset slot on a deployed contract resolves to felt.Zero, nil via ErrNoHistoryValue / ErrCheckHeadState fallback. So ErrKeyNotFound unambiguously means "contract not found." ✅

So mapping ContractStorage → ErrKeyNotFound → ErrContractNotFound is sound, and non-zero ⟹ contract exists holds for every reader. The change is also a net read reduction (non-zero reads: 1 instead of 2; zero reads: unchanged).

Test coverage is good — the new "non-zero value skips the existence probe" case asserts ContractClassHash is not called, and the updated "non-existent contract" / "contract not found" cases exercise both the head-reader probe path and the historical ErrKeyNotFound path.

Summary

  • Blocker: none
  • Important: none
  • Nit: the ContractStorage internal-error path no longer logs, while the class-hash probe path still does — see the inline comment. Non-blocking.

Note: I was unable to run go test ./rpc/v10/... in this environment (command not permitted), so the pass/fail is inferred from the mock expectations, not executed.
· refactor/v10-storage-existence-check

Comment thread rpc/v10/storage.go

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Refactors the v10 StorageAt RPC handler to avoid always probing contract existence, by only doing the existence check when the storage slot value is zero (where “unset slot” and “missing contract” can both appear as zero on the head reader).

Changes:

  • Fetch storage first and only perform a ContractClassHash existence probe when the returned storage value is zero.
  • Update StorageAt unit tests to reflect the new call sequence/behavior and add a test asserting non-zero values skip the probe.
  • Minor gofmt-driven test formatting adjustments.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
rpc/v10/storage.go Changes StorageAt to probe contract existence only when storage returns zero.
rpc/v10/storage_test.go Updates mocks/expectations and adds a test to verify probe is skipped on non-zero values.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread rpc/v10/storage.go Outdated
@codecov

codecov Bot commented Jul 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 68.62745% with 16 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.10%. Comparing base (92a5693) to head (586ee2d).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
core/trie/trie.go 63.63% 10 Missing and 2 partials ⚠️
rpc/v10/storage.go 77.77% 2 Missing ⚠️
sync/preconfirmed/chain_storage.go 66.66% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3790      +/-   ##
==========================================
- Coverage   75.17%   75.10%   -0.08%     
==========================================
  Files         438      439       +1     
  Lines       39376    39500     +124     
==========================================
+ Hits        29602    29665      +63     
- Misses       7699     7741      +42     
- Partials     2075     2094      +19     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@rodrodros rodrodros left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

benchmark numbers please

@rodrodros

Copy link
Copy Markdown
Contributor

Looks good 👍 , you've good findings from Copilot and Claude

@brbrr

brbrr commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

@rodrodros i've addressed the bot's feedback and added bench results to the description. please take a look.

@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Claude finished @brbrr's task in 1m 35s —— View job


Re-review after addressing feedback

  • Read updated StorageAt handler (rpc/v10/storage.go)
  • Verify the two feedback items were addressed
  • Re-trace correctness of the historical-reader guard
  • Review test coverage

Both pieces of bot feedback are addressed correctly, and the change is a strict improvement over the prior revision. 👍

1. Copilot's point — skip the probe for historical readers (storage.go:119)

The existence probe now only runs on head-type readers:

if value.IsZero() && (id.IsLatest() || id.IsPreConfirmed()) {

I traced stateByBlockID (rpc/v10/helpers.go:167) against every reader's ContractStorage to confirm the guard is exactly right:

BlockID Reader Missing contract →
latest HeadState felt.Zero, nil → probe needed ✅
pre_confirmed pending.State → falls through to head.ContractStorage (core/pending/state.go:69) felt.Zero, nil → probe needed ✅
hash / number / l1_accepted stateHistory checkDeployed returns db.ErrKeyNotFound first (core/state/history.go:52, core/deprecatedstate/history.go:63) → no probe needed ✅

So the IsLatest() || IsPreConfirmed() condition matches precisely the set of readers that can return a zero value for a non-existent contract. Historical readers already surface ErrKeyNotFound, which is mapped to ErrContractNotFound at line 110-112. Correct, and it removes an extra DB read on every zero-slot historical response.

2. Logging nit (storage.go:113)

The internal-error path on the primary ContractStorage read now logs (h.logger.Error("Failed to get contract storage", ...)), consistent with the class-hash probe path. ✅

Test coverage — good. The new "zero value on historical reader skips the existence probe" case (storage_test.go:183) locks in the new guard by asserting no ContractClassHash call happens on a historical reader, and "non-zero value skips the existence probe" covers the non-zero fast path. The head-reader probe path ("non-existent contract") and internal-error path remain covered.

Summary

  • Blocker: none
  • Important: none
  • Nit: none — the earlier logging nit is resolved. The 2 lines Codecov flags as uncovered are the internal-DB-error logger.Error + ErrInternal returns (lines ~113/124), which are hard-to-exercise failure branches; not worth a mock for.

Note: I could not execute go test ./rpc/v10/... in this environment (command not permitted), so pass/fail is inferred from the mock expectations rather than an actual run.
· refactor/v10-storage-existence-check

@RafaelGranza RafaelGranza left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I consider this PR to be in good shape and have all previous suggestions addressed.
Still, I'll take a second look tomorrow.

@RafaelGranza RafaelGranza left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It is a good optimization, but I noticed something strange about the pre_confirmed reader that the CI bot also let pass.

Please see my comment and give me feedback on the direction you will take.

Sorry for digging so deep, but I took my time to better understand these readers, states and block types.

Comment thread rpc/v10/storage.go Outdated
return nil, rpccore.ErrInternal.CloneWithData(err)
// Only head readers return zero (not ErrKeyNotFound) for a missing contract,
// so a zero value needs an existence probe there; historical readers don't.
if value.IsZero() && (id.IsLatest() || id.IsPreConfirmed()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The id.IsPreConfirmed() is a little bit annoying here.

Why I find it annoying

You see, a pre_confirmed state is inconsistent to whether or not it uses a historical or head reader.

The CI Bot told us it always falls back to a head one, but that is not true. Actually, it almost always falls back to a historical one, the only way for it to use a head one is when the block is at genesis.

You can check that a pre_confirmed state is set by baseState on sync/preconfirmed/chain_storage.go, and the internal methods varies depending whether the block is near the 0th state (genesis).

The problem it creates

It triggers unnecessary extra lookups.

We could fix this by making the genesis base in baseState return ErrKeyNotFound for a missing contract, like the historical base already does. then pre_confirmed is consistent everywhere and we don't touch the global reader.

But then we would need extra tests to ensure this behavior stays the same. Since someone could try to optimize pre_confirmed always using head readers.

Benchmarks to prove the problem exists

This is a plausibility check, I used a shallow chain (mainnet block 2, in-memory DB, so absolute latencies aren't production-representative), but the structural takeaway holds regardless of depth.

We can see that preconfirmed_mature matches the historical.

Reader storage_only storage + probe delta
historical 60.3µs / 380 allocs 120.6µs / 778 allocs +100% / +398 allocs
preconfirmed_mature 60.1µs / 380 allocs 120.4µs / 778 allocs +100% / +398 allocs
latest (head, hot) 0.84µs / 13 allocs 0.99µs / 16 allocs +18% / +3 allocs

What I expect before approving

I expect to hear your opinion and see one of these two:

  1. Keep things as-is, and add a comment above the guard, to give more context about the IsPreConfirmed thing. Maybe the pre_cofirmed case is rare and the change does not pay off.

  2. Actual fix:

  • Remove IsPreConfirmed
  • make the genesis base in baseState (sync/preconfirmed/chain_storage.go) return ErrKeyNotFound for a missing contract, like the historical base does.
  • Benchmark the mature pre_confirmed to make sure it makes sense.
  • create tests to lock that pre_confirmed with a missing contract returns ErrContractNotFound, for both the mature and the genesis cases. this is what stops someone from later optimizing pre_confirmed to always use head readers and breaking it silently.

@rodrodros

Copy link
Copy Markdown
Contributor

@brbrr why there is a case that worsens on performance, shouldn't everything be an improvement?

@brbrr

brbrr commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

@rodrodros This PR flips the data fetching approach from defensive (check contract existence, and only then access the storage slot) to optimistic (get the storage slot, and if it fails, figure out why - contract or storage key missing).

in the case of missing_contract case, we ending up doing 2 db operations vs 1 as before.

This is PR builds up on the assumption that happy path requests are dominant.

@brbrr brbrr force-pushed the refactor/v10-storage-existence-check branch from 2fa3f01 to 083df26 Compare July 15, 2026 09:31
@github-actions

github-actions Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Claude finished @brbrr's task in 3m 7s —— View job


Re-review after the pre_confirmed fix

  • Read the full diff (storage.go, chain_storage.go + tests)
  • Verify the pre_confirmed genesis fix (RafaelGranza's option 2)
  • Check correctness of ErrKeyNotFound propagation across readers & the VM bridge
  • Review test coverage

You took option 2 — dropped IsPreConfirmed from the guard and made the genesis base return ErrKeyNotFound for a missing contract. I traced it end-to-end and it's correct.

Guard is now exactly the head-reader set. if value.IsZero() && id.IsLatest() matches precisely the one reader that returns felt.Zero, nil for a missing contract:

  • latestHeadState → zero for missing contract → probe needed ✅
  • hash/number/l1_acceptedstateHistory → already ErrKeyNotFound
  • pre_confirmed mature → base is StateAtBlockNumber(bottom-1) (historical) → ErrKeyNotFound
  • pre_confirmed genesis → base wrapped in emptyStateReaderErrKeyNotFound

This resolves RafaelGranza's finding: pre_confirmed no longer takes the redundant probe path, and the genesis vs. mature inconsistency is gone.

emptyStateReader is sound. It only overrides ContractStorage (the sole method that returns felt.Zero, nil for a missing contract), embedding the empty base for everything else. It's reached only via pending.State.ContractStorage's fall-through — i.e. only for contracts that are neither in the state diff nor DeployedContracts — so at a genuinely empty genesis base, unconditionally returning ErrKeyNotFound is correct. Contracts deployed in the pre_confirmed block still resolve through the diff layer (core/pending/state.go:66), never hitting the override.

No execution-path regression. The VM bridge (vm/state.go:29-33) already maps db.ErrKeyNotFoundfelt.Zero, so call/estimateFee/trace against genesis pre_confirmed state are unaffected. The only behavioral change is on the RPC StorageAt path, where ErrKeyNotFoundErrContractNotFound, which is the intended semantics.

Answer to @rodrodros's earlier question (why one case regresses): brbrr's explanation is right — this flips defensive (probe-then-read) to optimistic (read, probe only on zero for head). missing_contract on latest is the one path that now costs 2 reads instead of 1, and it's the rare case; every happy path improves. Sound trade-off.

Summary

  • Blocker: none
  • Important: none
  • Nit: the added test only covers the genesis missing-contract case. RafaelGranza's option 2 explicitly asked to also lock the mature pre_confirmed case, since that's the one a future "always use head readers" refactor would silently break. See the inline comment. Non-blocking.

Note: go build ./rpc/v10/... ./sync/preconfirmed/... passes here, but go test is not permitted in this environment, so pass/fail is inferred from the mock expectations, not executed.
· refactor/v10-storage-existence-check

require.ErrorIs(t, err, db.ErrKeyNotFound)
require.NoError(t, closer())
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This locks the genesis pre_confirmed missing-contract case (via emptyStateReader), but RafaelGranza's option 2 explicitly asked for both the genesis and the mature cases:

create tests to lock that pre_confirmed with a missing contract returns ErrContractNotFound, for both the mature and the genesis cases. this is what stops someone from later optimizing pre_confirmed to always use head readers and breaking it silently.

The mature path (baseStateStateAtBlockNumber(bottom-1), i.e. a historical reader) is currently unguarded here. Consider adding a sibling test that applies a chain with bottom > 0, mocks StateAtBlockNumber(bottom-1) to return a base whose ContractStorage yields db.ErrKeyNotFound, and asserts the pre_confirmed state surfaces it — that's precisely what prevents a future "always use head readers" refactor from silently regressing the mature case.

Non-blocking, but it's the specific regression guard the reviewer requested.

@brbrr

brbrr commented Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

here's bench results for the latest commit:

Reader Scenario main sec/op branch sec/op Δ time main allocs branch allocs Δ allocs
latest non_zero 31.72µs 2.07µs -93.5% 47 19 -59.6%
latest zero_exists 51.68µs 33.59µs -35.0% 52 35 -32.7%
latest missing_contract 14.68µs 29.85µs +103.4% 24 37 +54.2%
historical non_zero 105.81µs 38.79µs -63.3% 123 74 -39.8%
historical zero_exists 133.41µs 61.11µs -54.2% 127 79 -37.8%
historical missing_contract 16.36µs 16.46µs ~ 58 58 ~

@github-actions

github-actions Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Claude finished @brbrr's task in 3m 51s —— View job


Review — latest commit (add leaf accessor to trie reader)

I focused this pass on what's new since the last review: the core/trie leaf accessor and the rewired deprecatedstate.ContractStorage. The earlier storage.go optimistic-read and pre_confirmed genesis fixes were already traced and remain correct.

GetLeafPedersen / newLeafReaderPedersen — correct. TrieReader.Get only touches t.height (via FeltToKey) and t.readStorage; it never reads rootKey or maxKey. So skipping the readStorage.RootKey() DB read for a point lookup is sound, and returning a reader where rootKey == nil is safe as long as only Get is called on it — which the "Only Get is valid on it" comment states and GetLeafPedersen enforces. ✅

Shared maxKeyByHeight pointer — safe. maxKey is only ever read (key.Cmp(t.maxKey) at trie.go:521,577), never mutated, so sharing one *felt.Felt across all readers from the package-init table introduces no aliasing hazard. Indices past felt.Bits (where 2^h would wrap mod the field prime) are unreachable — both constructors guard with height > felt.Bits. ✅

deprecatedstate.ContractStorage — behavior change is intentional and covered. A missing contract still reads as felt.Zero, nil (empty prefix → ErrKeyNotFound in Get → zero). The only difference is a corrupted/dangling root key no longer fails the read, since the leaf is now fetched directly. The renamed test "corrupted root key does not affect a direct leaf read" locks exactly this. ✅

go build ./core/trie/... ./core/deprecatedstate/... ./rpc/v10/... ./sync/preconfirmed/... passes here (go test isn't permitted in this environment, so test pass/fail is inferred from the mock expectations).

Summary

  • Blocker: none
  • Important: none
  • Nit (still open): RafaelGranza's option 2 asked to lock the missing-contract case for both pre_confirmed variants; the added test covers only genesis (emptyStateReader). The mature path (baseStateStateAtBlockNumber(bottom-1)) is still untested — it relies on the historical reader surfacing ErrKeyNotFound, and that's precisely the invariant a future "always use head readers" refactor could silently break. A sibling test with bottom > 0 mocking StateAtBlockNumber(bottom-1) to return ErrKeyNotFound would close it. Non-blocking.

Clean, well-scoped refactor — the trie accessor is a genuine read reduction on the hot path and the correctness invariants hold.
· refactor/v10-storage-existence-check

@brbrr brbrr deployed to Development July 15, 2026 15:12 — with GitHub Actions Active
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.

4 participants