Skip to content

fix(evm): bound store-cache depth amplification from stacked EVM snapshots#3713

Draft
codchen wants to merge 1 commit into
mainfrom
claude/optimistic-khorana-1ca578
Draft

fix(evm): bound store-cache depth amplification from stacked EVM snapshots#3713
codchen wants to merge 1 commit into
mainfrom
claude/optimistic-khorana-1ca578

Conversation

@codchen

@codchen codchen commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Problem

Each successful EVM call frame stacks a new nested CacheMultiStore layer in DBImpl.Snapshot(). The sei go-ethereum fork only calls RevertToSnapshot on failure — the success-path DiscardSnapshot is a commented-out TODO, and vm.StateDB exposes no discard hook — so geth cannot tell sei-chain a frame succeeded and the layers are never released.

After N sequential successful calls the current context sits atop ~N nested cachekv layers. A Cosmos read at that depth then walks every layer:

  • The distribution rewards() precompile (reachable via CALL/STATICCALL) → DelegationTotalRewardsstakingKeeper.IterateDelegations (an iterator through the merge-iterator chain) plus ~8–10 per-delegation point reads (Validator, IncrementValidatorPeriod, GetDelegatorStartingInfo, GetValidatorHistoricalRewards).
  • Each read recurses through all N layers. Done at growing depth across the attacker's call loop, a linear number of reads becomes O(N²) wall-clock while gas stays linear.

Staking/distribution route through the regular nested cachekv (not the giga store), and those layers are empty for EVM txs — yet the merge iterators and getFromCache still traverse them.

Fix

Entirely opt-in, so all non-EVM code paths are unchanged (they never call Freeze()):

  • cachekv / giga store — add frozen+dirty state, Freeze()/Unfreeze(), and a memoized readThroughParent() that skips runs of frozen empty layers on the Get path. iterator() returns the parent iterator directly when the layer is empty in-range (closing the unused cache iterator), collapsing the cacheMergeIterator chain. Reads become O(1) in depth.
  • cachemulti — propagate Freeze()/Unfreeze() to sub-stores via a shared flag so lazily-materialized stores inherit the state.
  • DBImpl.Snapshot (x/evm/state and the giga fork) freezes the just-superseded layer — never the base layer (snapshottedCtxs[0], the flush target read directly by GetCommittedState). RevertToSnapshot unfreezes the re-exposed layer, since a writable top must never be treated as a skippable frozen layer.

Why it's correct

A layer only receives writes while it is the newest layer; once superseded it is frozen and, if empty, stays empty. Skips are gated on frozen && !dirty, so a re-exposed-and-written layer (revert → write → snapshot) is always consulted again. Within one DBImpl, RevertToSnapshot also discards every layer above the target (so no live skip memo can go stale) and now unfreezes the target. The concurrent statedb.Copy() trace path shares layers, but is safe because the EVM never reverts below a tx's starting layer, and the unfreeze gate covers the immediate-parent case.

Verification

  • New tests (frozen_skip_test.go in cachekv and giga store): read/iterate equivalence through deep empty stacks, shadowing/deletes through frozen layers (must not skip), the exact revert → write → snapshot sequence, unfreeze bypassing a stale skip memo, and Write() reset.
  • Benchmark: deep-stack Get goes from O(depth) (251 ns → 3.9 µs → 16.2 µs at depth 16/256/1024) to a flat ~134 ns — ~120× faster at depth 1024.
  • Passing with -race: cachekv, cachemulti, both giga stores, x/evm/state, giga/deps/xevm/state, x/evm/keeper, the distribution + staking precompiles, and the full giga integration suite. gofmt, goimports, go vet clean.

🤖 Generated with Claude Code

…shots

Each successful EVM call frame stacks a new nested CacheMultiStore layer
(DBImpl.Snapshot), and geth never signals success so the layers are never
discarded. After N sequential successful calls the current context sits atop
~N nested cachekv layers, and Cosmos reads at that depth (e.g. the distribution
`rewards()` precompile -> IterateDelegations + per-delegation reward math) walk
every layer. That makes a linear number of reads O(N^2) in wall-clock while gas
stays linear.

Fix, entirely opt-in so non-EVM code is unchanged:

- cachekv / giga store: add frozen+dirty state, Freeze()/Unfreeze(), and a
  memoized readThroughParent() that skips runs of frozen empty layers on the
  Get path. iterator() also returns the parent iterator directly when the layer
  is empty in-range (closing the unused cache iterator), collapsing the
  cacheMergeIterator chain. Reads become O(1) in depth instead of O(depth).
- cachemulti: propagate Freeze()/Unfreeze() to sub-stores via a shared flag so
  lazily-materialized stores inherit the state.
- DBImpl.Snapshot (x/evm/state and the giga fork) freezes the just-superseded
  layer, never the base layer (snapshottedCtxs[0], the flush target read by
  GetCommittedState). RevertToSnapshot unfreezes the re-exposed layer so a
  writable top is never treated as a skippable frozen layer.

Safety: a layer only takes writes while it is the newest layer; skips are gated
on `frozen && !dirty`, so a re-exposed-and-written layer is always consulted
again. Benchmark: deep-stack Get goes from ~16us (depth 1024) to a flat ~134ns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown

The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedJul 7, 2026, 4:34 AM

@codecov

codecov Bot commented Jul 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.07921% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 58.38%. Comparing base (2937bbb) to head (b2d9282).

Files with missing lines Patch % Lines
sei-cosmos/store/cachekv/store.go 87.09% 3 Missing and 1 partial ⚠️
sei-cosmos/store/cachemulti/store.go 88.23% 2 Missing and 2 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #3713      +/-   ##
==========================================
- Coverage   59.30%   58.38%   -0.93%     
==========================================
  Files        2274     2187      -87     
  Lines      188706   178980    -9726     
==========================================
- Hits       111917   104500    -7417     
+ Misses      66716    65206    -1510     
+ Partials    10073     9274     -799     
Flag Coverage Δ
sei-chain-pr 76.21% <92.07%> (?)
sei-db 70.62% <ø> (+0.21%) ⬆️
sei-db-state-db ?

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
giga/deps/store/cachekv.go 67.56% <100.00%> (+7.56%) ⬆️
giga/deps/xevm/state/state.go 96.96% <100.00%> (+0.14%) ⬆️
x/evm/state/state.go 96.96% <100.00%> (+0.14%) ⬆️
sei-cosmos/store/cachekv/store.go 87.44% <87.09%> (-0.26%) ⬇️
sei-cosmos/store/cachemulti/store.go 76.30% <88.23%> (+2.72%) ⬆️

... and 88 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant