Skip to content

[codex] add evm-only giga executor path#3583

Open
codchen wants to merge 23 commits into
mainfrom
codex/sei-v3-evm-only-scaffold
Open

[codex] add evm-only giga executor path#3583
codchen wants to merge 23 commits into
mainfrom
codex/sei-v3-evm-only-scaffold

Conversation

@codchen

@codchen codchen commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • add giga/evmonly as the final-form EVM-only giga execution path boundary
  • define raw Ethereum RLP block input and commit-neutral outputs as an EVM-native state changeset plus receipts
  • port the core sei-v3-style execution shape directly into giga/evmonly:
    • raw RLP tx parsing and sender recovery
    • go-ethereum execution against an SDK-free native vm.StateDB
    • key-addressable EVM-native balance, nonce, code, and storage state reads
    • deterministic post-block StateChangeSet output
    • Ethereum receipt and per-tx metadata construction
  • add a map-backed MemoryState backend for tests and early integration
  • leave custom precompiles as fail-closed placeholders behind an SDK-free registry/context
  • refresh the README to describe the current implementation, input/output contract, and known limitations

Notes

Custom precompile behavior is intentionally still open. Registered custom precompile addresses return ErrCustomPrecompilesOpen, including through geth's precompile map, so calls fail closed instead of silently executing as empty accounts.

The current port is sequential. The state boundary and changeset shape are intended to be replaceable with the sei-v3 OCC scheduler/store once that layer is brought over.

Historical BLOCKHASH lookups beyond the parent block are not wired yet; BlockHash is currently used for receipt/log metadata.

Validation

go test ./giga/evmonly/...
go test ./giga/...

@codecov

codecov Bot commented Jun 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 73.74909% with 362 lines in your changes missing coverage. Please review.
✅ Project coverage is 58.30%. Comparing base (0ff5a52) to head (145f089).
⚠️ Report is 28 commits behind head on main.

Files with missing lines Patch % Lines
giga/evmonly/state_db.go 67.20% 162 Missing and 22 partials ⚠️
giga/evmonly/occ.go 72.91% 55 Missing and 23 partials ⚠️
giga/evmonly/executor.go 83.00% 30 Missing and 13 partials ⚠️
giga/evmonly/occ_pool.go 63.38% 22 Missing and 4 partials ⚠️
giga/evmonly/result_pool.go 76.62% 10 Missing and 8 partials ⚠️
giga/evmonly/state.go 90.52% 5 Missing and 4 partials ⚠️
giga/evmonly/parser.go 80.00% 3 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #3583      +/-   ##
==========================================
- Coverage   59.16%   58.30%   -0.87%     
==========================================
  Files        2262     2179      -83     
  Lines      187009   177181    -9828     
==========================================
- Hits       110643   103298    -7345     
+ Misses      66430    64809    -1621     
+ Partials     9936     9074     -862     
Flag Coverage Δ
sei-chain-pr 73.74% <73.74%> (?)
sei-db 70.41% <ø> (ø)
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/evmonly/config.go 100.00% <100.00%> (ø)
giga/evmonly/types.go 100.00% <100.00%> (ø)
giga/evmonly/parser.go 80.00% <80.00%> (ø)
giga/evmonly/state.go 90.52% <90.52%> (ø)
giga/evmonly/result_pool.go 76.62% <76.62%> (ø)
giga/evmonly/occ_pool.go 63.38% <63.38%> (ø)
giga/evmonly/executor.go 83.00% <83.00%> (ø)
giga/evmonly/occ.go 72.91% <72.91%> (ø)
giga/evmonly/state_db.go 67.20% <67.20%> (ø)

... and 168 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.

@github-actions

github-actions Bot commented Jun 15, 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 2, 2026, 2:01 PM

@codchen codchen changed the title [codex] scaffold evm-only giga executor path [codex] add evm-only giga executor path Jun 15, 2026
@codchen codchen marked this pull request as ready for review June 15, 2026 07:54
@cursor

cursor Bot commented Jun 15, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Large new block execution and state-diff path with OCC merge semantics; mitigated by isolation in a new package, fail-closed precompiles, and broad tests, but correctness of changesets and OCC fallback still matters before production integration.

Overview
Adds a new giga/evmonly package as the final-form EVM-only giga execution boundary, separate from Cosmos-backed wiring in app/app.go. Callers pass BlockRequest (block context + ordered raw RLP txs) and get a commit-neutral BlockResult: deterministic post-block StateChangeSet, per-tx metadata, and Ethereum receipts; persistence stays with optional ResultSink / BlockResultSink.

The Executor runs go-ethereum via core.ApplyMessage on an SDK-free nativeStateDB backed by StateReader (plus test MemoryState). It supports PrepareBlock (RLP decode + sender recovery) pipelined ahead of ExecutePreparedBlock, enforces min gas price / post-London base fee, and aborts the whole block on validation errors while still emitting failed receipts for in-tx EVM failures.

When OCCWorkers > 1 and no custom precompiles are registered, blocks can run optimistic parallel execution with read/write conflict tracking, gas-limit checks, and automatic fallback to sequential execution (with OCCStats). BlockResultPoolSize enables reusable BlockResult buffers with Release for async sinks.

Custom precompiles are stubbed: registered addresses map to contracts that return ErrCustomPrecompilesOpen. A small precompiles package defines the future SDK-free Contract / Context API. Documentation in README.md describes the I/O contract and known gaps (e.g. historical BLOCKHASH, real precompile port).

Reviewed by Cursor Bugbot for commit 145f089. Bugbot is set up for automated code reviews on this repo. Configure here.

Comment thread giga/evmonly/executor.go
Comment thread giga/evmonly/state_db.go
@codchen codchen force-pushed the codex/sei-v3-evm-only-scaffold branch from 44a10bc to ef04c0e Compare June 15, 2026 11:25
Comment thread giga/evmonly/parser.go
@codchen codchen force-pushed the codex/sei-v3-evm-only-scaffold branch from ef04c0e to cb338ff Compare June 16, 2026 07:12
Comment thread giga/evmonly/state_db.go
Comment thread giga/evmonly/state_db.go
Comment thread giga/evmonly/executor.go Outdated
@codchen codchen force-pushed the codex/sei-v3-evm-only-scaffold branch from 06357b4 to 966d056 Compare June 17, 2026 02:37
Comment thread giga/evmonly/README.md

- sequential execution of the ordered block transaction list
- RLP decoding and sender recovery through go-ethereum signers
- go-ethereum `core.ApplyMessage` execution against an SDK-free `vm.StateDB`

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.

does "SDK" mean Cosmos?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

yep

Comment thread giga/evmonly/state_db.go Outdated
for _, acct := range s.accounts {
if acct.SelfDestructed {
acct.Code = nil
acct.Storage = map[common.Hash]common.Hash{}

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.

do we need to set acct.Created = false?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Created is used to decided whether SelfDestruct6780 should early return, and I think the behavior of SelfDestruct6780 is to not early return if selfdestruct is called multiple times, so I think we shouldn't set Created to false upon selfdestruct (consistent with geth)

Comment thread giga/evmonly/executor.go
Comment on lines +68 to +70
if gasLimit == 0 {
gasLimit = math.MaxUint64
}

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.

is this dangerous? could someone maliciously set gasLimit = 0 to bypass any limits we may have?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

gasLimit here is the block gas limit, so it'd be decided by consensus and not set by tx senders

Comment thread giga/evmonly/state_db.go
@@ -0,0 +1,653 @@
package evmonly

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 is a lot of critical state changing code. i think we need a lot of tests here. Could we use AI to generate a ton of unit cases, interleaving ordering of contract creation/deletion, invalid txs (out of gas, invalid state transition, invalid nonce, verify receipt outputs...etc).

@codchen codchen force-pushed the codex/sei-v3-evm-only-scaffold branch from 966d056 to ab82ec3 Compare June 24, 2026 03:37
Comment thread giga/evmonly/state_db.go
@codchen codchen force-pushed the codex/sei-v3-evm-only-scaffold branch from ab82ec3 to 23fc6d3 Compare June 24, 2026 03:53
Comment thread giga/evmonly/state_db.go
Comment thread giga/evmonly/state_db.go

@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.

A new, self-contained giga/evmonly package adds an SDK-free EVM-only block executor with a go-ethereum-backed vm.StateDB, changeset output, receipts, and extensive tests. It is not wired into the production app path, but contains two genuine EVM-semantics correctness bugs (EXTCODEHASH for existing code-less accounts, and storage clearing on legacy SELFDESTRUCT/SetStorage) plus a notable per-snapshot deep-copy performance cost that should be addressed before this becomes the live giga path.

Findings: 2 blocking | 5 non-blocking | 3 posted inline

Blockers

  • None at the file/PR level.
  • 2 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • cursor-review.md was empty — the Cursor second-opinion pass produced no output, so only this review and Codex's findings were merged.
  • GetStorageRoot always returns the zero hash, so the CREATE/CREATE2 collision check (nonce != 0 || codeHash != EmptyCodeHash || storageRoot != EmptyRootHash) can miss collisions against a pre-existing account that has storage but no code/nonce. Combined with the GetCodeHash issue this narrows collision detection to nonce-only for storage-only accounts. Edge case, but worth documenting alongside the known limitations.
  • EIP-158/161 empty-account deletion is not implemented in Finalise (touched-but-empty accounts are not pruned). For the value-based changeset this is mostly benign since no diff is recorded, but it diverges from go-ethereum StateDB semantics and is worth a note in the README limitations.
  • Consider adding tests that exercise the two correctness gaps above: EXTCODEHASH against a funded code-less EOA (expect EmptyCodeHash, not 0x0), and a legacy SELFDESTRUCT of a pre-existing contract whose persisted storage slots were never read in-block (expect deletes in the changeset).
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread giga/evmonly/state_db.go
func (s *nativeStateDB) GetCodeHash(addr common.Address) common.Hash {
code := s.GetCode(addr)
if len(code) == 0 {
return common.Hash{}

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] EXTCODEHASH semantics bug: this returns common.Hash{} (0x0) whenever code is empty, conflating "account does not exist" with "account exists but has no code". go-ethereum's StateDB returns types.EmptyCodeHash (keccak256 of empty) for an existing account with no code and only common.Hash{} for a non-existent account. As written, EXTCODEHASH on a funded/active EOA returns 0x0 instead of the empty-code hash, so contracts observe incorrect account existence/code state. Distinguish existence (e.g. via Exist(addr)) and return types.EmptyCodeHash for existing code-less accounts.

Comment thread giga/evmonly/state_db.go
Delete: len(acct.Code) == 0,
})
}
storageKeys := storageKeyUnion(base.Storage, acct.Storage)

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] The changeset only diffs storage keys present in base.Storage/acct.Storage, and base.Storage is populated lazily only for slots read via ensureStorage. When Finalise clears a self-destructed account (legacy SELFDESTRUCT) or SetStorage resets an account, persisted slots that were never read in-block are in neither map, so no delete is emitted for them. Applying the changeset to durable state then leaves stale storage behind. Because the backend is key-addressable with no range reads, the executor cannot enumerate those slots — this needs an explicit account-clear/delete signal in the changeset (or a documented constraint) rather than per-slot diffs.

Comment thread giga/evmonly/state_db.go
return nil
}

func (s *nativeStateDB) Snapshot() int {

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] Snapshot() deep-copies the entire accounts, base, logs, accessList, transientStates, and preimages on every call, and the EVM snapshots on every CALL/CREATE/STATICCALL. This is O(touched-state) per snapshot and will be a severe performance cliff for any non-trivial contract execution. Acceptable for the sequential placeholder, but flag it as a known cost — a journal/undo-log approach (as in go-ethereum) will be needed before this is the live giga path.

@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.

This new, isolated giga/evmonly EVM-only executor is well-structured and well-tested, but contains correctness bugs that diverge from go-ethereum semantics: stale committed-state reads across transactions, an OCC validation gap on per-tx gas reservation, and incorrect EXTCODEHASH for empty-but-existing accounts. The package is not yet wired into app.go (non-app-hash-breaking), so these are not yet consensus-affecting, but must be fixed before integration.

Findings: 2 blocking | 4 non-blocking | 3 posted inline

Blockers

  • None at the file/PR level.
  • 2 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • cursor-review.md is empty — the Cursor second-opinion pass produced no output, so its findings could not be merged.
  • Performance/observability: in executeBlockOCC, every transaction that pays a non-zero fee writes the coinbase balance, so the read/write conflict index will mark a coinbase conflict on virtually every multi-tx block and force a full fallback to sequential execution. With real (non-zero) base fees / tips this defeats the OCC fast path entirely. Consider special-casing coinbase fee accrual (e.g. commutative balance additions) and/or logging when OCC falls back so the regression is observable.
  • The exported SetTxContext(hash, index) (state_db.go:519) does not set txIndexUint, while the executor relies on the unexported setTxContext(hash, index, indexUint). The exported method is part of the vm.StateDB surface; if any future caller uses it, logs will get a stale/zero TxIndex. Worth unifying or documenting why two variants exist.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread giga/evmonly/state_db.go Outdated
func (s *nativeStateDB) GetCommittedState(addr common.Address, key common.Hash) common.Hash {
s.markRead(stateAccessKey{kind: stateAccessStorage, address: addr, slot: key})
s.ensureStorage(addr, key)
return s.baseAccount(addr).Storage[key]

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] GetCommittedState always reads from base, which is the pre-block snapshot and is never advanced between transactions (Finalise does not update base). go-ethereum's GetCommittedState must return the slot value as of the start of the current transaction — i.e. it must reflect writes committed by earlier transactions in the same block (geth checks pendingStorage before originStorage). As written, a later transaction's SSTORE net-gas/refund accounting (EIP-2200/3529) uses the block-start value instead of the current-tx-start value, producing incorrect gas used, receipts, and potentially divergent results when two transactions in a block touch the same slot. Add a per-transaction "committed" layer that is advanced on Finalise, rather than reading the block-base directly.

Comment thread giga/evmonly/occ.go
stateDB.enableAccessTracking()
evm := vm.NewEVM(blockCtx, stateDB, chainConfig, vm.Config{}, nil)
stateDB.SetEVM(evm)
gasPool := new(core.GasPool).AddGas(gasLimit)

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] Each speculative transaction is given a fresh full-block gas pool (AddGas(gasLimit)), and validateOCCResults only checks cumulative actual gasUsed against the block gas limit (occ.go:137). Sequential execution instead reserves each tx's GasLimit from a shared pool up front (gp.SubGas(msg.GasLimit) in ApplyMessage). These disagree: e.g. two non-conflicting transfers each with GasLimit=90k in a 100k block each use ~21k, so OCC validation passes (42k ≤ 100k) and commits a block that sequential execution would reject with ErrGasLimitReached after the first tx reserves 90k. OCC must reproduce the sequential gas-reservation semantics (track cumulative reserved msg.GasLimit, not just used gas) or it can accept invalid blocks.

Comment thread giga/evmonly/state_db.go
func (s *nativeStateDB) GetCodeHash(addr common.Address) common.Hash {
code := s.GetCode(addr)
if len(code) == 0 {
return common.Hash{}

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] GetCodeHash returns the zero hash for every account with empty code. Ethereum semantics require zero only for non-existent accounts; an account that exists (non-zero nonce or balance) but has no code must return types.EmptyCodeHash (keccak256 of empty). As written, EXTCODEHASH on a funded codeless account returns 0 instead of EmptyCodeHash, diverging from geth and the existing x/evm keeper. Return types.EmptyCodeHash for existing-but-codeless accounts and zero only when the account is empty/non-existent.

Comment thread giga/evmonly/occ.go
})
}
if err := group.Wait(); err != nil {
return nil, err

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

OCC aborts without sequential fallback

High Severity

When OCCWorkers is enabled, speculative transactions each run against the same pre-block state. A later transaction that depends on an earlier one (for example the next nonce from the same sender) can fail during parallel execution, and executeBlockOCC returns that error immediately. Conflict validation falls back to sequential execution, but these execution errors do not, so otherwise valid blocks can fail outright.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f1fa418. Configure here.

Comment thread giga/evmonly/occ.go Outdated

@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.

A new, isolated giga/evmonly EVM-only execution package with strong test coverage, but it contains a clear EVM-correctness bug in the default (sequential) path — GetCodeHash returns zero for existing code-less accounts — plus two OCC divergences from sequential execution (same-sender nonce blocks error out instead of falling back, and per-tx gas-limit accounting is bypassed). Codex's four findings are all valid; Cursor produced no output.

Findings: 2 blocking | 6 non-blocking | 4 posted inline

Blockers

  • None at the file/PR level.
  • 2 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • ./REVIEW_GUIDELINES.md is empty/missing, so no repo-specific standards were applied. ./cursor-review.md is also empty — the Cursor second-opinion pass produced no output; only Codex's review was available to merge.
  • OCC speculative execution shares e.state (a StateReader) across worker goroutines. MemoryState is RWMutex-guarded so it is safe, but the StateReader interface contract does not document a thread-safety requirement; a non-concurrent backend would race under OCC. Consider documenting that StateReader must be safe for concurrent reads.
  • No test exercises a block with two transactions from the same sender under OCC, nor a block where two txs declare large gas limits but use little gas — the exact cases that trigger the two OCC divergences below. Adding these would catch regressions.
  • Codex notes it could not run go test ./giga/evmonly/... due to the sandbox being unable to fetch the Go 1.25.6 toolchain; tests were not executed during review.
  • 2 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread giga/evmonly/state_db.go
func (s *nativeStateDB) GetCodeHash(addr common.Address) common.Hash {
code := s.GetCode(addr)
if len(code) == 0 {
return common.Hash{}

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] GetCodeHash returns the zero hash for every account with empty code, but per EIP-1052 EXTCODEHASH (and go-ethereum's StateDB.GetCodeHash) must return zero only for accounts that do not exist / are empty. An account that exists (non-zero balance or nonce) but holds no code should return the empty-code hash (keccak256("")). As written, contracts observing such accounts via EXTCODEHASH see incorrect existence, and this affects the default sequential path too (not just OCC). Gate the zero return on account emptiness and otherwise return the empty-code hash. (Raised by Codex.)

Comment thread giga/evmonly/occ.go Outdated
}
})
}
if err := group.Wait(); err != nil {

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] OCC speculatively executes every tx against the pre-block state, so a second tx from the same sender (nonce N+1) fails core.ErrNonceTooHigh, and a tx depending on a prior create/write hits missing-state errors. Because executeTxSpeculative returns that error and group.Wait() propagates it here as return nil, err, the whole block fails instead of falling back to sequential — even though it is perfectly valid sequentially. Same-sender multi-tx blocks are extremely common, so OCC is effectively unusable for them. Speculative execution errors should trigger the sequential fallback (like validateOCCResults returning false) rather than aborting ExecuteBlock. (Raised by Codex.)

Comment thread giga/evmonly/occ.go Outdated
}, nil
}

func validateOCCResults(results []occTxExecution, gasLimit uint64) bool {

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] validateOCCResults only sums actual gasUsed against the block gas limit, but Ethereum's GasPool reserves each tx's full declared gas limit at buyGas time (refunding the unused remainder). Since each speculative tx gets a fresh full-block GasPool, two txs with high gas limits but low gas used can pass OCC validation (sum of gasUsed ≤ limit) while sequential execution would reject the second with core.ErrGasLimitReached. This is a divergence from sequential semantics; the validation needs to model the per-tx gas-limit reservation, not just cumulative gasUsed. (Raised by Codex.)

Comment thread giga/evmonly/state_db.go Outdated
if acct.SelfDestructed {
s.recordAccount(addr)
acct.Code = nil
acct.Storage = map[common.Hash]common.Hash{}

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] On legacy (pre-6780) selfdestruct, Finalise clears acct.Storage, but ChangeSet() diffs against base.Storage, which only contains slots lazily read via ensureStorage. Slots that were persisted but never read during the block are absent from base.Storage, so no delete is emitted for them and applying the changeset leaves stale storage behind. A correct legacy-selfdestruct wipe needs range iteration over the account's full storage, which the README states is intentionally out of scope — so at minimum document this as a known limitation alongside the existing ones (it currently only lists BLOCKHASH). (Raised by Codex.)

@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.

New giga/evmonly EVM-only executor is well-structured and heavily tested, but the OCC path has correctness/consensus-divergence bugs: it aborts valid blocks on stale-read validation failures, and its block-gas validation and legacy-selfdestruct changeset diverge from sequential execution. These should be fixed before merge.

Findings: 6 blocking | 4 non-blocking | 3 posted inline

Blockers

  • OCC diverges from sequential execution on stale reads and can reject valid blocks. Each speculative tx (executeTxSpeculative) runs against block-initial state, so a later tx that is only valid after an earlier tx in the same block (e.g. a second tx from the same sender with nonce+1, or a tx spending funds received from an earlier tx) fails core.ApplyMessage with ErrNonceTooHigh/ErrInsufficientFunds. executeBlockOCC propagates that error from group.Wait() and returns it instead of falling back to sequential — so a block that sequential execution accepts is rejected under OCC. Speculative validation failures caused by stale reads must be treated as conflicts (fall back / retry), not as hard block errors. Same-sender multi-tx blocks are extremely common, so this is a serious divergence.
  • OCC block-gas validation is not equivalent to sequential gas accounting (also raised by Codex). Each speculative tx gets a fresh full-block GasPool (occ.go:132) and validateOCCResults only checks cumulative gasUsed ≤ gasLimit. Sequential execution charges each tx's declared gas limit against the shared pool at buyGas time, so a later tx whose declared gas limit exceeds the remaining pool triggers ErrGasLimitReached and aborts the block. OCC can accept such a block, diverging from sequential (which the 'block gas exhausted' test shows aborts). Validation must account for per-tx declared gas limits against the running remaining pool, not just cumulative gas used.
  • Legacy (pre-EIP-6780) selfdestruct can leave orphaned storage in the persisted changeset (also raised by Codex). Finalise clears acct.Storage (state_db.go:498), but ChangeSet() only diffs keys present in base.Storage, which is populated lazily via ensureStorage. A pre-existing contract whose storage was never read during the block has an empty base.Storage, so no Delete storage entries are emitted; applying the changeset to a durable backend leaves the old slots behind. Reachable with a legacy chain config (the tests use legacySelfDestructChainConfig) but not covered by a test that pre-populates untouched storage.
  • 3 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • REVIEW_GUIDELINES.md (from the base branch) is empty/missing, so no repo-specific review standards were applied.
  • The Cursor second-opinion review (cursor-review.md) is empty — that pass produced no output.
  • Test coverage gaps for the confirmed bugs: add OCC tests for (a) multiple txs from the same sender / a tx depending on an earlier tx's output, (b) a block whose cumulative declared gas limits force ErrGasLimitReached under sequential, and (c) legacy selfdestruct of a pre-existing contract that has untouched persisted storage, asserting the changeset emits the storage deletions.
  • README documents that historical BLOCKHASH beyond the parent is not wired and GetHash returns a zero hash for older blocks; fine as a documented limitation, but worth a follow-up before production use.

Comment thread giga/evmonly/occ.go Outdated
}
for idx := txRange.start; idx < txRange.end; idx++ {
result, err := e.executeTxSpeculative(groupCtx, req, idx, signer, chainConfig, blockCtx, baseFee, gasLimit)
if err != nil {

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] Speculative execution runs against block-initial state, so a tx that is only valid after an earlier tx in the same block (same-sender nonce+1, or spending funds received earlier) fails ApplyMessage with ErrNonceTooHigh/ErrInsufficientFunds. Returning that error here propagates through group.Wait() and aborts the whole block (no fallback), rejecting blocks that sequential execution accepts. Stale-read validation failures should be treated as conflicts and fall back to sequential, not returned as block errors.

Comment thread giga/evmonly/occ.go
stateDB.enableAccessTracking()
evm := vm.NewEVM(blockCtx, stateDB, chainConfig, vm.Config{}, nil)
stateDB.SetEVM(evm)
gasPool := new(core.GasPool).AddGas(gasLimit)

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] Each speculative tx gets a fresh full-block gas pool, and validateOCCResults only checks cumulative gasUsed ≤ gasLimit. Sequential execution charges each tx's declared gas limit against the shared pool at buyGas time, so a later tx whose declared limit exceeds the remaining pool triggers ErrGasLimitReached and aborts the block. OCC can accept such a block — a divergence from sequential. Validate per-tx declared gas limits against the running remaining pool.

Comment thread giga/evmonly/state_db.go Outdated
if acct.SelfDestructed {
s.recordAccount(addr)
acct.Code = nil
acct.Storage = map[common.Hash]common.Hash{}

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] Clearing acct.Storage on selfdestruct isn't reflected in ChangeSet() for untouched slots: ChangeSet diffs against base.Storage, which is populated lazily by ensureStorage. A pre-existing contract whose storage was never read this block has empty base.Storage, so no Delete entries are emitted and the old slots survive when the changeset is applied to a durable backend. Reachable under a legacy (pre-6780) chain config.

@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.

New giga/evmonly EVM-only executor is well-structured and well-tested, but has one consensus-level bug in the default sequential path (GetCodeHash for empty-code accounts) plus two correctness divergences in the opt-in OCC path (speculative errors abort the block, and block-gas validation disagrees with sequential geth semantics). No prompt-injection content was found in the diff.

Findings: 3 blocking | 4 non-blocking | 3 posted inline

Blockers

  • None at the file/PR level.
  • 3 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • OCC effectively never engages for real fee-paying blocks: geth credits priority fees to the coinbase on every tx, so every tx's write set includes the coinbase balance and validateOCCResults always detects a conflict, forcing the sequential fallback. The OCC tests only pass because they use zero base fee / zero gas price so no coinbase credit occurs. Worth documenting or addressing (e.g. treat coinbase fee accrual as commutative) so the parallel path provides real benefit.
  • Test coverage gaps in the OCC path: there is no test for multiple txs from the same sender with sequential nonces (would surface the speculative-abort bug) nor for the block-gas-pool divergence (per-tx gas limits summing under the block limit while the second tx cannot buy its full limit). Adding these would have caught the two OCC findings.
  • cursor-review.md was empty — the Cursor second-opinion pass produced no output. REVIEW_GUIDELINES.md was also empty, so no repo-specific guidelines were applied.
  • Documented limitation (README): BLOCKHASH only exposes the parent hash; historical block-hash lookups are unwired. Fine for now but callers must be aware EVM BLOCKHASH beyond parent returns zero.

Comment thread giga/evmonly/state_db.go
func (s *nativeStateDB) GetCodeHash(addr common.Address) common.Hash {
code := s.GetCode(addr)
if len(code) == 0 {
return common.Hash{}

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] GetCodeHash returns the zero hash for every empty-code account. Per EIP-1052, EXTCODEHASH must return keccak256("") (types.EmptyCodeHash) for an account that exists but has no code (e.g. a funded EOA or an existing empty-code account); only truly non-existent accounts return zero. geth's opExtCodeHash already guards with Empty(addr) before calling GetCodeHash, so this method should return crypto.Keccak256Hash(nil) for empty code to mirror geth's StateDB. As written, EXTCODEHASH of a funded EOA yields 0x0 instead of 0xc5d2..., which makes contracts observe incorrect EVM state — a consensus-level divergence on the default sequential path.

Comment thread giga/evmonly/occ.go Outdated
for idx := txRange.start; idx < txRange.end; idx++ {
result, err := e.executeTxSpeculative(groupCtx, req, idx, chainConfig, blockCtx, baseFee, gasLimit)
if err != nil {
return err

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] Speculative execution errors abort the entire block instead of falling back to sequential. Each tx is executed against pre-block state in isolation, so a common block with two txs from the same sender using nonces 0 and 1 will execute the nonce-1 tx against pre-block nonce 0, hit core.ErrNonceTooHigh, and return here — group.Wait() propagates the error and executeBlockOCC returns (nil, err) before validateOCCResults can trigger the sequential path. Speculative EVM errors that stem from stale reads (nonce, balance, gas) should be treated as conflicts that force re-execution / sequential fallback, not as fatal block-validation failures.

Comment thread giga/evmonly/occ.go Outdated
if result.gasUsed > math.MaxUint64-totalGas {
return false
}
totalGas += result.gasUsed

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] OCC validates the block gas limit using cumulative actual gas used, but sequential geth execution enforces the block gas pool against each tx's declared gas limit at buyGas time (the pool must hold the full tx.Gas() before execution, with the unused portion refunded afterward). This can accept a block sequential execution rejects: e.g. two transfers each declaring a 40k gas limit under a 50k block limit — cumulative used is 42k (accepted here), but sequentially the second tx cannot buy 40k after the first consumed 21k, yielding ErrGasLimitReached. Validation should mirror the sequential gas-pool semantics.

Comment thread giga/evmonly/occ.go Outdated
Comment on lines +151 to +168
func validateOCCResults(results []occTxExecution, gasLimit uint64) bool {
writes := newStateAccessIndex()
var totalGas uint64
for _, result := range results {
if result.gasUsed > math.MaxUint64-totalGas {
return false
}
totalGas += result.gasUsed
if totalGas > gasLimit {
return false
}
if writes.conflictsWithAny(result.readSet) || writes.conflictsWithAny(result.writeSet) {
return false
}
writes.addAll(result.writeSet)
}
return true
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 OCC will fall back to sequential on every realistic block because state_transition.go calls AddBalance(Coinbase, fee) after every fee-paying tx, and nativeStateDB.AddBalance (state_db.go:204-214) marks a write on (stateAccessBalance, coinbase). Since tx N and tx N+1 both write that exact key, validateOCCResults (occ.go:151-168) sees an exact-match conflict on the second tx onward and returns false, so executeBlockOCC degenerates to speculative-work-then-sequential — strictly slower than plain sequential. The existing OCC tests miss this because they pin gasPrice=0 and baseFee=0 so fee=0 and AddBalance short-circuits via amount.IsZero() without marking a write. Fix by accumulating coinbase tips out-of-band (sum once, apply at merge) or excluding the coinbase balance write from the conflict tracker with matching add-not-overwrite semantics in mergeChangeSets.

Extended reasoning...

What the bug is

The OCC executor added in this PR uses per-tx read/write sets to detect conflicts. Every tx that pays a non-zero fee credits the coinbase via AddBalance in go-ethereum's state_transition.go:

// go-ethereum core/state_transition.go:572
st.state.AddBalance(st.evm.Context.Coinbase, fee, tracing.BalanceIncreaseRewardTransactionFee)

This call reaches nativeStateDB.AddBalance in giga/evmonly/state_db.go:204-214, which unconditionally marks a write on stateAccessKey{kind: stateAccessBalance, address: coinbase} whenever amount is non-zero (the amount.IsZero() branch at line 206 short-circuits before the markWrite at line 211).

Every fee-paying speculative tx therefore ends up with {stateAccessBalance, coinbase} in its writeSet.

How validation fails

validateOCCResults at giga/evmonly/occ.go:151-168 iterates results in order, using a stateAccessIndex whose conflictsWith checks i.exact[key] first:

func (i *stateAccessIndex) conflictsWith(key stateAccessKey) bool {
    if _, ok := i.exact[key]; ok {
        return true
    }
    ...
}

After tx 0 is added via writes.addAll(tx0.writeSet), writes.exact contains {stateAccessBalance, coinbase}. When tx 1 is checked via writes.conflictsWithAny(tx1.writeSet), its own {stateAccessBalance, coinbase} entry produces an exact-match hit → validateOCCResults returns falseexecuteBlockOCC falls back to executeBlockSequential (occ.go:91-92).

Step-by-step proof

Consider two non-conflicting transfers in the same block, each paying fees to the same coinbase (as any real Sei block would):

  1. Both txs are executed speculatively in executeTxSpeculative against block-initial state. Each succeeds independently.
  2. During each execution, ApplyMessage calls st.state.AddBalance(coinbase, fee, ...).
  3. Inside nativeStateDB.AddBalance: amount is non-zero (positive baseFee OR positive tip), so line 211 records markWrite({stateAccessBalance, coinbase}).
  4. Both occTxExecution.writeSet maps contain {stateAccessBalance, coinbase}.
  5. validateOCCResults processes tx 0: no prior writes → conflictsWithAny returns falsewrites.addAll(tx0.writeSet) inserts {stateAccessBalance, coinbase} into writes.exact.
  6. validateOCCResults processes tx 1: writes.conflictsWithAny(tx1.writeSet) iterates tx1.writeSet, hits the same key {stateAccessBalance, coinbase} in writes.exact → returns true → validation fails.
  7. executeBlockOCC returns e.executeBlockSequential(ctx, req).

Result: the block was executed twice (once speculatively in parallel, once sequentially), so OCC is strictly slower than sequential.

Why the tests miss it

Both TestExecutorOCCNonConflictingTransfersMatchSequential and TestExecutorOCCConflictingTransfersMatchSequential set:

  • Config.MinGasPrice: big.NewInt(0)
  • signLegacyTxWithGasPrice(..., big.NewInt(0)) → tx gasPrice = 0
  • blockContext(chainID)BaseFee: big.NewInt(0)

So fee = (baseFee + effectiveTip) * gasUsed = 0, and AddBalance(coinbase, 0) early-returns at state_db.go:206 without calling markWrite. The tests only exercise a code path that will never occur on mainnet.

Impact

  • Correctness: preserved — sequential fallback runs the block correctly.
  • Feature: broken — the OCC parallelism this PR introduces (commits 10c3108, dd519f7) is unreachable for any realistic Sei block. Setting OCCWorkers > 1 in production would be a strict regression: it pays the full cost of parallel speculative execution, then runs the block sequentially anyway.
  • Blast radius: scaffold-level. giga/evmonly is not wired into app/app.go yet, so no runtime path is affected today. But the merge target for this PR ships OCC as a completed feature.

How to fix

The coinbase fee credit is commutative — the final balance is the base value plus the sum of all per-tx fees regardless of order — so it should not participate in conflict detection at all. Two options match the verifiers' recommendations:

  1. Aggregate fees out-of-band: skip the AddBalance(coinbase, fee) inside speculative execution (or filter (stateAccessBalance, coinbase) out of the tracked writeSet/readSet), sum fee across all txs at validation time, and apply that single credit in mergeChangeSets.
  2. Exclude coinbase-balance access from the tracker with matching add semantics in mergeChangeSets: today mergeChangeSets does last-write-wins on balances (occ.go:250-252 overwrites balances[change.Address]), which would drop earlier tips. If coinbase writes are excluded from conflict tracking, coinbase balance must instead be merged by summing the deltas across per-tx changesets.

Either way, mergeChangeSets currently doing last-write-wins on the coinbase balance is a second bug that only manifests once the first is fixed.

Comment thread giga/evmonly/executor.go
Comment on lines +209 to +223
receipt := &ethtypes.Receipt{
Type: tx.Type(),
Status: status,
Logs: txLogs,
TxHash: tx.Hash(),
GasUsed: execResult.UsedGas,
EffectiveGasPrice: effectiveGasPrice(tx, baseFee),
BlockHash: block.BlockHash,
BlockNumber: new(big.Int).SetUint64(block.Number),
TransactionIndex: txIndexUint,
}
if tx.To() == nil {
receipt.ContractAddress = crypto.CreateAddress(p.Sender, tx.Nonce())
}
receipt.Bloom = ethtypes.CreateBloom(receipt)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 The receipt built in executeTx (giga/evmonly/executor.go:209-223) never populates BlobGasUsed or BlobGasPrice, so any Type-3 blob tx executed by this path produces a receipt where eth_getTransactionReceipt returns blobGasUsed=0 and blobGasPrice=null. go-ethereum's core/state_processor.go sets both when tx.Type() == BlobTxType; the fix is a 3-line branch after the receipt literal using params.BlobTxBlobGasPerBlob and block.BlobBaseFee. Not urgent — the executor is not yet wired into app.go and blob txs are still rejected by x/evm/ante/basic.go — but worth catching before this becomes production RPC surface.

Extended reasoning...

What the bug is. In giga/evmonly/executor.go, executeTx constructs *ethtypes.Receipt with Type, Status, Logs, TxHash, GasUsed, EffectiveGasPrice, BlockHash, BlockNumber, TransactionIndex, and (for contract creations) ContractAddress. It does not set BlobGasUsed or BlobGasPrice. In canonical go-ethereum (core/state_processor.go in sei-protocol/go-ethereum@v1.15.7-sei-16, lines 180-183), the block-processing path explicitly sets these for Type-3 transactions:\n\ngo\nif tx.Type() == types.BlobTxType {\n receipt.BlobGasUsed = uint64(len(tx.BlobHashes()) * params.BlobTxBlobGasPerBlob)\n receipt.BlobGasPrice = evm.Context.BlobBaseFee\n}\n\n\nSo any receipt this executor emits for a blob tx will diverge from geth's semantics — the fields are permanently missing from the persisted/RPC-visible receipt.\n\nWhy blob txs reach this path. The port is otherwise blob-aware:\n- parseTx (giga/evmonly/parser.go:29) uses tx.UnmarshalBinary, which correctly decodes typed transaction envelopes including Type-3.\n- transactionToPreparedMessage (giga/evmonly/executor.go:255-256) forwards tx.BlobHashes() and tx.BlobGasFeeCap() into the core.Message, so blob txs run through ApplyMessage.\n- buildBlockContext plumbs BlobBaseFee into vm.BlockContext, and effectiveGasPrice has a BlobTxType branch.\n- The default chainConfig is params.AllDevChainProtocolChanges, which enables Cancun (blob support).\n\nA blob tx therefore executes normally and the executor gladly produces a receipt for it — just one with BlobGasUsed=0 and BlobGasPrice=nil.\n\nStep-by-step proof.\n1. Caller submits a Type-3 blob tx: BlobHashes=[h1, h2], BlobGasFeeCap=1_000_000, in a block with BlobBaseFee=250_000.\n2. parseTx decodes it via UnmarshalBinary — succeeds. Sender recovered.\n3. executeTx builds the message; ApplyMessage runs the payload and returns ExecutionResult.\n4. The receipt literal at lines 209-223 is populated with all listed fields. tx.Type() == 3 is never checked, so BlobGasUsed stays 0 and BlobGasPrice stays nil.\n5. Receipt is stored via the result sink (giga/evmonly/executor.go:79).\n6. An indexer or RPC client calls eth_getTransactionReceipt. It receives blobGasUsed="0x0" and blobGasPrice=null, whereas geth would return blobGasUsed="0x40000" (2 × BlobTxBlobGasPerBlob) and blobGasPrice="0x3d090".\n\nImpact and why this isn't marked normal. The executor is scaffolding — it is not wired into app/app.go yet, and blob transactions are currently rejected protocol-wide by x/evm/ante/basic.go (ErrUnsupportedTxType). The existing x/evm keeper also doesn't set these fields (grep for BlobGasUsed outside this PR returns no hits). So there is no user-facing regression today. This becomes a real bug the moment blob support is enabled and this executor takes over the receipt-building path — at which point the omission is silent and receipts are immutable once persisted.\n\nFix. After the receipt literal in executeTx, add:\n\ngo\nif tx.Type() == ethtypes.BlobTxType {\n receipt.BlobGasUsed = uint64(len(tx.BlobHashes())) * params.BlobTxBlobGasPerBlob\n receipt.BlobGasPrice = new(big.Int).Set(block.BlobBaseFee)\n}\n\n\nThis matches geth exactly and adds no runtime cost for non-blob txs. Worth a short test in executor_test.go alongside TestExecutorDynamicFeeTx once blob txs are permitted through the ante.

@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.

A new, well-structured giga/evmonly EVM-only execution package with good test coverage, but the optional OCC path has two correctness defects: it hard-errors on speculative pre-check failures instead of falling back to sequential (aborting valid blocks with dependent same-sender nonces), and self-destruct of a contract with pre-existing persisted storage never emits slot deletions. Cursor's second-opinion pass produced no output.

Findings: 2 blocking | 5 non-blocking | 2 posted inline

Blockers

  • None at the file/PR level.
  • 2 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • Performance/design: OCC conflict tracking marks every priority-fee payment to the coinbase as a write (AddBalance), so any realistic block with a nonzero tip has all txs conflict on the coinbase balance key and always falls back to sequential — OCC provides no parallelism except in zero-fee blocks. The OCC-vs-sequential tests only exercise gasPrice=0, which sidesteps the coinbase write (AddBalance early-returns on zero amount) and hides this. Consider excluding the coinbase fee credit from conflict tracking (or aggregating it post-merge).
  • Testing gaps: no test covers (a) OCC execution of a valid block with two dependent same-sender txs (nonce 0 then 1), which would catch the OCC nonce-fallback bug, nor (b) self-destruct (legacy config) of a contract that has pre-existing persisted storage from a prior block, which would catch the stale-storage bug.
  • StateReader does not document that implementations must be safe for concurrent reads, yet OCC workers call it from multiple goroutines simultaneously; production backends must guarantee this — worth stating in the interface doc.
  • NewExecutor relies on runtime.SetFinalizer(e, Close) to tear down the OCC worker-pool goroutines; callers that create executors without calling Close() leak worker goroutines until GC runs the finalizer. Consider making explicit lifecycle ownership clearer in the README/interface.
  • Cursor's second-opinion review file (cursor-review.md) was empty — that pass produced no output; only the Codex pass contributed external findings, both of which are incorporated here.

Comment thread giga/evmonly/occ.go
for idx := txRange.start; idx < txRange.end; idx++ {
result, err := e.executeTxSpeculative(workerCtx, req, idx, chainConfig, blockCtx, baseFee, gasLimit)
if err != nil {
return err

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] OCC aborts valid blocks instead of falling back to sequential. Each tx is speculatively executed against pre-block state, so a valid block containing two txs from the same sender with nonces 0 and 1 makes tx#1 fail with ErrNonceTooHigh in core.ApplyMessage. That error is returned here, propagates out of pool.Run (line 62-64), and executeBlockOCC returns nil, err — the validateOCCResults/executeBlockSequential fallback on lines 65-67 is never reached. Since the design treats sequential as the source of truth, a speculative pre-check failure (nonce/fee/gas) should force the sequential fallback rather than aborting the whole (valid) block. Sequential is the only place block-level validity should be decided.

Comment thread giga/evmonly/state_db.go Outdated
if acct.SelfDestructed {
s.recordAccount(addr)
acct.Code = nil
acct.Storage = map[common.Hash]common.Hash{}

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] Self-destruct does not delete pre-existing untouched storage from the changeset. Finalise clears only the lazily-loaded in-memory acct.Storage, and ChangeSet() (lines 149-162) emits per-slot deletes only for slots present in base.Storage/acct.Storage — i.e. slots that were actually read during execution. A contract created in an earlier block that is self-destructed under a pre-Cancun (legacy) chain config leaves its un-read storage slots behind when the changeset is applied via ApplyChangeSet, because those slots are never enumerated (the executor has no range reads). Result: stale storage persists in durable state after a legacy SELFDESTRUCT. Needs either a full-account storage-clear signal in the changeset or a range-read to enumerate the slots.

@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.

New giga/evmonly EVM-only executor is well-structured and well-tested, but has two confirmed correctness divergences between its parallel OCC path / changeset construction and canonical sequential execution: OCC validates block gas using only consumed gas (not per-tx declared limits), and legacy selfdestruct drops untouched pre-existing storage slots from the changeset. Both are in opt-in paths and the package is not yet wired into the app.

Findings: 2 blocking | 5 non-blocking | 2 posted inline

Blockers

  • None at the file/PR level.
  • 2 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • REVIEW_GUIDELINES.md is empty/missing (taken from base), so no repo-specific review standards could be applied.
  • cursor-review.md is empty — the Cursor second-opinion pass produced no output; only Codex's review was available to merge.
  • Codex's two findings were both reproduced and confirmed against the diff and geth semantics; they are included as inline comments.
  • MemoryState.ApplyChangeSet (state.go) mirrors the selfdestruct storage gap: it deletes code on Delete but never wipes remaining storage slots for a destroyed account, so any durable StateWriter must be prepared to fully clear an account's storage — worth documenting alongside the changeset fix.
  • Executor relies on runtime.SetFinalizer to Close() the OCC worker pool; finalizer-based cleanup is non-deterministic, so callers that construct executors with OCCWorkers>1 should be expected to call Close() explicitly.

Comment thread giga/evmonly/occ.go Outdated
return false
}
totalGas += result.gasUsed
if totalGas > gasLimit {

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] OCC block-gas validation only checks the sum of consumed gas (totalGas += result.gasUsed), but sequential execution enforces each tx's declared gas limit against the remaining block pool: core.ApplyMessagebuyGas calls gp.SubGas(msg.GasLimit) and returns core.ErrGasLimitReached if tx.Gas() doesn't fit. A block whose per-tx Gas() limits sum beyond gasLimit but whose used gas stays under it will be accepted here (via mergeOCCResults) while executeBlockSequential would reject it — an accept/reject divergence an attacker can construct (e.g. tx1 uses little gas, tx2 declares a large limit but uses little). Validation should account for declared gas limits the way the sequential gas pool does, or otherwise reproduce ErrGasLimitReached before accepting the OCC result.

Comment thread giga/evmonly/state_db.go Outdated
if acct.SelfDestructed {
s.recordAccount(addr)
acct.Code = nil
acct.Storage = map[common.Hash]common.Hash{}

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] Finalise clears acct.Storage for a selfdestructed account, but ChangeSet() computes storage deltas from storageKeyUnion(base.Storage, acct.Storage), and base.Storage only contains slots that were loaded via ensureStorage during the block. A legacy (pre-EIP-6780) selfdestructed contract with pre-existing storage slots that were never read/written this block will not have those slots in base.Storage, so no delete is emitted and a durable StateWriter retains stale storage. (Under EIP-6780 this is masked because full deletion only occurs for same-tx-created contracts, whose storage is already tracked.) The changeset needs to represent a full storage wipe for destroyed accounts independent of which slots were touched.

@claude claude 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.

Warning

This review may be incomplete: some analysis steps could not run due to a temporary API capacity limit.

@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.

A well-structured, well-tested new EVM-only giga executor boundary (not yet wired into app.go), but it contains three genuine correctness issues in the core executor/state layer: an OCC-vs-sequential block-gas divergence, an EXTCODEHASH spec violation, and a selfdestruct changeset that can leak stale persisted storage. REVIEW_GUIDELINES.md and cursor-review.md were both empty; Codex's three findings are confirmed.

Findings: 3 blocking | 4 non-blocking | 3 posted inline

Blockers

  • None at the file/PR level.
  • 3 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • OCC effectiveness: with non-zero transaction fees, every tx credits the coinbase balance (a tracked write), so all txs conflict on the coinbase address and validateOCCResults will always fall back to sequential. Under realistic fees OCC provides no parallelism; consider excluding coinbase-fee crediting from conflict tracking or accumulating fees out-of-band.
  • mergeChangeSets (occ.go) is defined but only mergeChangeSetsInto is used — dead code; remove it (also a latent unused-linter risk).
  • Second-opinion passes: cursor-review.md is empty (Cursor produced no output) and REVIEW_GUIDELINES.md is empty/absent, so no repo-specific review standards were applied. Codex's three findings were reviewed and are all confirmed above.
  • Documentation and test coverage are strong: README clearly states current limitations (sequential-only wiring, historical BLOCKHASH not wired, precompiles fail-closed) and the test suite covers validation failures, EIP-6780, snapshot/revert, pooling, and OCC/sequential equivalence.

Comment thread giga/evmonly/occ.go Outdated
if result.gasUsed > math.MaxUint64-totalGas {
return false
}
totalGas += result.gasUsed

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] OCC block validation only checks cumulative gasUsed against gasLimit, but the sequential/geth reference decrements the block GasPool by each tx's gas limit (buyGasgp.SubGas(msg.GasLimit)) and returns ErrGasLimitReached if a tx's gas limit exceeds the remaining pool. A block with txs whose gas limits sum above the block limit (but whose gas used fits) is accepted here yet rejected by executeBlockSequential. Because OCC only falls back when validateOCCResults returns false, this produces a nondeterministic accept/reject between the OCC and sequential paths. Validation must model the per-tx gas-limit reservation against the remaining block gas, not just cumulative usage.

Comment thread giga/evmonly/state_db.go
func (s *nativeStateDB) GetCodeHash(addr common.Address) common.Hash {
code := s.GetCode(addr)
if len(code) == 0 {
return common.Hash{}

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] GetCodeHash returns the zero hash for every empty-code account. EXTCODEHASH (geth's opExtCodeHash) already guards with Empty(), so this method is reached for existing accounts with balance/nonce but no code — those must return the empty-code hash (crypto.Keccak256Hash(nil) = 0xc5d246…), and zero must be reserved for nonexistent accounts. As written, existing empty-code EOAs are misclassified as nonexistent.

Comment thread giga/evmonly/state_db.go Outdated
if acct.SelfDestructed {
s.recordAccount(addr)
acct.Code = nil
acct.Storage = map[common.Hash]common.Hash{}

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] On selfdestruct Finalise clears the in-memory Storage map, but ChangeSetInto derives storage deletes from storageKeyUnion(base.Storage, acct.Storage), which only contains slots that were read (ensureStorage) during this block. A destroyed contract's persisted-but-unread slots are therefore never emitted as deletes, so ApplyChangeSet leaves stale storage in the backing StateReader. The tests only exercise contracts created in the same block (all slots cached), which hides this. Legacy selfdestruct (and any real durable backend) needs an account-level tombstone or a way to enumerate/delete all persisted slots.

Comment thread giga/evmonly/executor.go
}
if err := e.resultSink.StoreReceipts(ctx, height, result.Receipts); err != nil {
return fmt.Errorf("store receipts for block %d: %w", height, err)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Split sink partial persistence

Medium Severity

When the executor uses a plain ResultSink (not BlockResultSink), it calls StoreChangeSet and then StoreReceipts separately. If the changeset store succeeds and the receipt store fails, the method still returns an error, but the sink may already hold persisted state for that height without matching receipts.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a38d56e. Configure here.

@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.

A well-structured, well-tested new giga/evmonly EVM-only execution boundary (not yet wired into production), but it has several genuine EVM-semantics/consensus-equivalence defects: the OCC path can accept a block the sequential path would reject on block-gas reservation, selfdestruct leaves untouched persisted storage undeleted in the changeset, and GetCodeHash returns the wrong value for existing empty-code accounts.

Findings: 2 blocking | 6 non-blocking | 4 posted inline

Blockers

  • None at the file/PR level.
  • 2 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • OCC has a coinbase write-hotspot limitation: core.ApplyMessage credits fees to the coinbase on every tx, so under any non-zero effective gas price every tx write-conflicts on the coinbase balance and validateOCCResults falls back to sequential for the whole block. The OCC tests only pass because they use gas price 0 (zero-amount AddBalance skips the write mark). This is correct but makes OCC largely ineffective in production; consider special-casing fee accumulation. Worth documenting as a known limitation alongside the others in the README.
  • Executor.Close is registered via runtime.SetFinalizer to shut down the worker-pool goroutines. Finalizers are not guaranteed to run promptly, so an executor that is dropped without an explicit Close() can leak the pool goroutines; consider requiring callers to call Close() and documenting it.
  • Cursor produced no second-opinion output (cursor-review.md was empty) and REVIEW_GUIDELINES.md was empty, so this review relied on the Codex pass plus direct inspection.
  • Codex noted its go test ./giga/evmonly/... run was blocked by the offline toolchain, so the test suite was not independently executed in that pass.
  • 2 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread giga/evmonly/occ.go
stateDB.enableAccessTracking()
evm := vm.NewEVM(blockCtx, stateDB, chainConfig, vm.Config{}, nil)
stateDB.SetEVM(evm)
gasPool := new(core.GasPool).AddGas(gasLimit)

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] Each speculative tx gets a fresh gas pool with the full block gas limit, but validateOCCResults (line ~150) only checks the summed actual gasUsed. The sequential path shares one depleting GasPool, and core.ApplyMessage reserves the tx's full GasLimit (gp.SubGas(tx.Gas())), not just gas used. So a block whose txs' summed GasLimit exceeds the block limit — while summed gasUsed stays under it — is accepted by OCC but returns core.ErrGasLimitReached (a fatal, whole-block error) under sequential. The two modes must be equivalent; OCC validation should also account for per-tx gas-limit reservation before accepting the speculative result.

Comment thread giga/evmonly/state_db.go Outdated
if acct.SelfDestructed {
s.recordAccount(addr)
acct.Code = nil
acct.Storage = map[common.Hash]common.Hash{}

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] Finalise clears only the in-memory acct.Storage, and ChangeSetInto (line ~155) can emit storage deletes only for keys present in storageKeyUnion(base.Storage, acct.Storage). base.Storage holds only slots that were lazily loaded via ensureStorage, so a (legacy, pre-6780) selfdestructed contract's persisted slots that were never read/written in the block are never emitted as deletes and survive in the underlying state after the changeset is applied — the changeset no longer represents true post-block state. Under EIP-6780 rules this is masked (only same-tx-created contracts are fully destroyed), but the legacySelfDestructChainConfig path exercised by the tests can hit it; the existing tests pass only because the destroyed contracts have no untouched persisted slots.

Comment thread giga/evmonly/state_db.go Outdated

func (s *nativeStateDB) GetCodeHash(addr common.Address) common.Hash {
code := s.GetCode(addr)
if len(code) == 0 {

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] GetCodeHash returns the zero hash for any account with empty code. EXTCODEHASH semantics require zero only for non-existent accounts; an existing account with empty code must return the empty-code hash (crypto.Keccak256Hash(nil) / types.EmptyCodeHash). Contracts using EXTCODEHASH will observe incorrect results here. Distinguish existence (nonce/balance/code) before returning zero.

Comment thread giga/evmonly/occ.go Outdated
key common.Hash
}

func mergeChangeSets(results []occTxExecution) StateChangeSet {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] mergeChangeSets is unexported and never called (only mergeChangeSetsInto is used). This is dead code that golangci-lint's unused linter will flag and fail CI on; remove it or wire it up.

Comment thread giga/evmonly/occ.go
Comment thread giga/evmonly/occ.go Outdated
Comment on lines +66 to +82
if err := pool.Run(ctx, occRanges(txCount, chunkSize), func(workerCtx context.Context, txRange occTxRange, scratch *occWorkerScratch) error {
for idx := txRange.start; idx < txRange.end; idx++ {
result, err := e.executeTxSpeculative(workerCtx, scratch, req, idx, chainConfig, blockCtx, baseFee, gasLimit)
if err != nil {
return err
}
results[idx] = result
}
return nil
}); err != nil {
return nil, err
}
if !validateOCCResults(results, gasLimit) {
return e.executeBlockSequential(ctx, req)
}
return e.mergeOCCResults(ctx, results)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 OCC speculative execution errors bypass the sequential fallback. executeBlockOCC (occ.go:66-77) returns pool.Run's error directly, and executeTxSpeculative (occ.go:143-145) wraps every core.ApplyMessage error — including preCheck failures like ErrNonceTooHigh/ErrNonceTooLow/ErrInsufficientFunds against pre-block state. The fallback at lines 78-80 is never reached, so blocks that sequential execution would accept (e.g. two txs from the same sender with nonces 0 and 1) are rejected outright when OCC is enabled. Fix: treat speculative preCheck failures as conflicts that trigger the sequential fallback rather than fatal errors.

Extended reasoning...

Bug and code path

executeBlockOCC in giga/evmonly/occ.go runs every transaction speculatively against the block-initial state via pool.Run and short-circuits on the first error:

// occ.go:66-77
if err := pool.Run(ctx, occRanges(txCount, chunkSize), func(...) error {
    for idx := txRange.start; idx < txRange.end; idx++ {
        result, err := e.executeTxSpeculative(...)
        if err != nil { return err }
        results[idx] = result
    }
    return nil
}); err != nil {
    return nil, err   // <-- fatal exit, no fallback
}
if !validateOCCResults(results, gasLimit) {
    return e.executeBlockSequential(ctx, req)   // <-- unreachable when a speculative tx errored
}

executeTxSpeculative (occ.go:143-145) wraps the raw core.ApplyMessage error and returns it. Since each speculative tx resets its scratch StateDB to e.state (block-initial), any tx whose validity depends on a prior tx's writes hits ApplyMessage's preCheck against the wrong nonce/balance/fee state.

Concrete step-by-step proof

Consider a valid block with two txs from the same sender A (pre-block nonce = 0):

  • tx0: sender=A, nonce=0
  • tx1: sender=A, nonce=1

Sequential path (executeBlockSequential, executor.go): tx0 executes, Finalise(true) advances A's nonce to 1 in the StateDB, tx1's preCheck sees state nonce=1, tx1 accepted. Block succeeds.

OCC path (executeBlockOCC):

  1. Two speculative jobs are dispatched. Each calls scratch.resetStateDB(e.state) so both see A's pre-block nonce=0.
  2. tx0 executes fine (nonce=0 matches state nonce=0).
  3. tx1 executes with msg.Nonce=1 against state nonce=0. core.ApplyMessage's preCheck returns core.ErrNonceTooHigh.
  4. executeTx returns that error; executeTxSpeculative wraps it with fmt.Errorf; the pool callback returns non-nil.
  5. pool.Run propagates the error; executeBlockOCC returns (nil, err) at line 76.
  6. validateOCCResults/executeBlockSequential at lines 78-80 is never reached.

The block is rejected under OCC but would have been accepted sequentially.

Why existing code doesn't prevent it

The validateOCCResults / sequential fallback pattern is the correct place to catch stale-read conflicts — but it only runs on successful speculative results. The current error propagation treats a speculative pre-check failure as a fatal block-validation error, even though sequential is the source-of-truth path and would have succeeded. Existing tests (TestExecutorOCCNonConflictingTransfersMatchSequential, TestExecutorOCCConflictingTransfersMatchSequential) generate a fresh key per tx, so every sender has nonce=0 and this case is never exercised.

Impact

Same-sender multi-tx blocks are extremely common (bots, market makers, DEX arbitrage, batch senders). Any node with OCCWorkers > 1 will reject these otherwise-valid blocks. If any subset of validators enables OCC while others use sequential (or if OCC is enabled while a legacy block with multi-nonce sender arrives), this creates a hard accept/reject divergence — i.e. a consensus fork. Even absent app-level wiring today, this ships OCC as a strict regression versus sequential: any block OCC accepts can be run sequentially, but blocks exist that OCC rejects and sequential accepts. Multiple external reviewers (seidroid × 4, cursor bugbot × 2, claude) flagged this as a blocker on 2026-06-30, 2026-07-02 (×3), and 2026-07-02.

Suggested fix

Distinguish speculative-execution errors caused by stale reads from genuine block-invalidating failures. The former should trigger the same sequential fallback that validateOCCResults returning false does. Concretely: on any error from executeTxSpeculative that could result from block-initial-state divergence (nonce mismatch, insufficient funds, fee-cap-below-basefee, intrinsic-gas — essentially any core.ApplyMessage preCheck failure), fall back to executeBlockSequential. Only propagate errors that are guaranteed to be block-invalidating regardless of execution ordering (e.g. context cancellation, panics).

Comment thread giga/evmonly/state_db.go
Comment thread giga/evmonly/state_db.go
Comment thread giga/evmonly/state_db.go

@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.

There are 5 total unresolved issues (including 4 from previous reviews).

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 9ae1cfb. Configure here.

Comment thread giga/evmonly/state.go

@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.

This PR adds a new, well-tested EVM-only giga execution boundary (giga/evmonly) that is isolated from production wiring, but the optional OCC path can diverge from the sequential reference path on block-gas accounting, the changeset model cannot express full storage deletion on legacy SELFDESTRUCT, and two unused symbols will fail the staticcheck-based CI lint.

Findings: 2 blocking | 5 non-blocking | 4 posted inline

Blockers

  • None at the file/PR level.
  • 2 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • OCC will almost always fall back to sequential in production: every transaction with a non-zero effective tip credits the coinbase via AddBalance, so each tx's write-set contains {balance, coinbase} and tx N+1 conflicts with tx N on the coinbase balance. The provided OCC tests only pass because they use base fee 0 and gas price 0. Consider excluding coinbase fee accrual from conflict tracking (or aggregating it deterministically) so OCC is usable when fees are non-zero.
  • REVIEW_GUIDELINES.md is empty/missing on the base branch, so no repo-specific standards were applied. The Cursor second-opinion review (cursor-review.md) was also empty — that pass produced no output. The Codex review flagged the same two correctness issues captured here as inline blockers.
  • Neither the OCC gas-reservation divergence nor the legacy-SELFDESTRUCT stale-storage gap is covered by a test; the existing block gas exhausted test only exercises the sequential path (OCCWorkers unset). Add OCC-vs-sequential equivalence tests for (a) txs whose summed declared gas exceeds the block limit but summed used gas does not, and (b) a pre-6780 self-destruct of a pre-existing contract with unread persisted slots.
  • 2 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread giga/evmonly/occ.go Outdated
validation.fallbackReason = occFallbackReasonGasOverflow
return validation
}
totalGas += result.gasUsed

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] OCC validates only the sum of actual gasUsed against the block gas limit, but the sequential reference path reserves each tx's declared tx.Gas() from the shared block GasPool (go-ethereum buyGas calls gp.SubGas(msg.GasLimit) before execution). This makes the two paths disagree: e.g. two independent txs each declaring Gas=90k under a 100k block limit but each only using 21k are accepted by OCC (42k ≤ 100k) while the sequential path rejects the second tx with ErrGasLimitReached. Since OCC output must be replay-consistent with sequential execution, OCC validation should account for declared gas reservation (or fall back when cumulative tx.Gas() exceeds the block gas pool).

Comment thread giga/evmonly/state_db.go
Delete: len(acct.Code) == 0,
})
}
storageKeys := storageKeyUnion(base.Storage, acct.Storage)

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] ChangeSetInto can only emit storage deletes for keys present in base.Storage or acct.Storage (i.e. slots that were read/written during the block). Legacy SelfDestruct clears acct.Storage in Finalise, but for a pre-existing contract with persisted slots that were never touched this block, those keys appear in neither map, so no Delete is emitted and applying the changeset leaves stale storage after account deletion. The key-addressable changeset model has no way to express "delete all storage for this account." This is latent under the default Cancun/EIP-6780 config (existing contracts don't clear storage), but is a real correctness gap for the legacy self-destruct semantics the executor still supports and tests. Needs full-account deletion semantics in the changeset or a backend capability to enumerate/delete all storage for self-destructed accounts.

Comment thread giga/evmonly/occ.go Outdated
key common.Hash
}

func mergeChangeSets(results []occTxExecution) StateChangeSet {

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] mergeChangeSets has no callers (only mergeChangeSetsInto is used). staticcheck (U1000), enabled in .golangci.yml, will flag this unused function and fail CI. Remove it or wire it in.

Comment thread giga/evmonly/occ.go Outdated
}
}

func (i *stateAccessIndex) conflictsWithAny(set map[stateAccessKey]struct{}) bool {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] conflictsWithAny is defined but never called, so staticcheck (U1000) will report it as unused and fail the lint job. Remove it or use it.

Comment thread giga/evmonly/state_db.go
Comment on lines +420 to +437
s.AddAddressToAccessList(addr)
slots, ok := s.accessList.slots[addr]
if !ok {
slots = map[common.Hash]struct{}{}
s.accessList.slots[addr] = slots
}
slots[slot] = struct{}{}
}

func (s *nativeStateDB) Prepare(_ params.Rules, sender, coinbase common.Address, dest *common.Address, precompiles []common.Address, txAccesses ethtypes.AccessList) {
s.accessList.reset()
clearNestedHashMaps(s.transientStates)
s.AddAddressToAccessList(sender)
s.AddAddressToAccessList(coinbase)
if dest != nil {
s.AddAddressToAccessList(*dest)
}
for _, addr := range precompiles {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 🟡 Prepare at giga/evmonly/state_db.go:420 declares its params.Rules argument as _ and unconditionally (a) resets the access list plus warms sender/dest/precompiles, and (b) adds the coinbase to the access list. go-ethereum's StateDB.Prepare (core/state/statedb.go:1343-1372 in sei-fork v1.15.7-sei-16) gates the access-list setup on rules.IsEIP2929 and the coinbase warm-up on rules.IsShanghai (EIP-3651). For a Berlin-but-pre-Shanghai config — e.g. this file's own legacySelfDestructChainConfig (HomesteadBlock..LondonBlock=0, ShanghaiTime nil) exercised by TestExecutorCreateSelfDestructThenTransferSameAddress/TestExecutorFinalisesAfterEachTx — this warms coinbase when geth leaves it cold, so any BALANCE/EXTCODESIZE/EXTCODEHASH/CALL against coinbase is charged 100 gas here vs 2600 gas in geth (a receipt-visible divergence on the default sequential path). AllDevChainProtocolChanges masks it because Shanghai is on. Fix by threading rules through and gating the two behaviors like geth does — a ~4-line change.

Extended reasoning...

What the bug is

In giga/evmonly/state_db.go, nativeStateDB.Prepare declares its signature as Prepare(_ params.Rules, sender, coinbase common.Address, ...) — the params.Rules argument is explicitly discarded. The body then unconditionally:

  1. Resets the access list (s.accessList.reset())
  2. Clears transient state (correct — a separate, orthogonal fix)
  3. Warms sender, coinbase, dest, precompiles, and every txAccesses entry

go-ethereum's canonical StateDB.Prepare (verified in sei-fork github.com/sei-protocol/go-ethereum@v1.15.7-sei-16, core/state/statedb.go:1343-1372) gates these behaviors on the incoming params.Rules:

  • Access-list reset + sender/dest/precompiles/txAccesses warm-up happens only when rules.IsEIP2929 is true (Berlin+).
  • Coinbase warm-up happens only when rules.IsShanghai is true (EIP-3651).

core/state_transition.go:501 already passes the correct rules derived from the executor's chain config, so the call site is fine — this implementation actively discards them.

Concrete divergence for a Berlin-but-pre-Shanghai config

The test file defines and exercises exactly this shape via legacySelfDestructChainConfig:

HomesteadBlock..LondonBlock = big.NewInt(0)
// ShanghaiTime is nil, CancunTime is nil

Under these rules, IsEIP2929=true but IsShanghai=false. In geth, that means the access list is set up but the coinbase is not added — so the coinbase remains cold for the entire transaction. Any EVM opcode that touches the coinbase address (BALANCE(coinbase), EXTCODESIZE(coinbase), EXTCODEHASH(coinbase), CALL(coinbase, ...)) pays the cold access cost of 2600 gas (EIP-2929).

This implementation instead unconditionally calls AddAddressToAccessList(coinbase), so the same opcode sees the coinbase as warm and pays only 100 gas. That is a per-transaction gasUsed and per-transaction receipt divergence — a consensus-level difference on the default sequential path.

The reverse concern (access-list warming being active pre-Berlin) is largely benign because pre-Berlin opcodes do not consult the access list for gas pricing, but the semantically correct fix gates both paths anyway.

Step-by-step proof

Consider a transaction under legacySelfDestructChainConfig that executes BALANCE(coinbase) once:

  1. Executor calls evm.SetTxContext(...), which calls stateDB.Prepare(rules, sender, coinbase, dest, precompiles, txAccesses). Rules has IsEIP2929=true, IsShanghai=false.
  2. Prepare ignores rules and runs AddAddressToAccessList(coinbase). Coinbase is now in s.accessList.addresses.
  3. EVM executes BALANCE(coinbase). Gas cost is computed via gasEip2929AccountCheckSlotInAccessList(coinbase, ...) returns (true, _) → warm access, 100 gas.
  4. Under geth with the same rules: step 2 leaves coinbase absent from the access list. Step 3's access check returns (false, _) → cold access, 2600 gas. Additionally geth increments the read-only warm-up in the same call.

Same block, same tx, same input state — differing receipt.GasUsed and differing cumulativeGasUsed for every downstream tx in the block.

Why existing code does not prevent it

  • The default AllDevChainProtocolChanges used by most tests has Shanghai active, so IsShanghai=true and geth also warms coinbase — the two impls happen to match, masking the divergence.
  • The two tests that use legacySelfDestructChainConfig (TestExecutorCreateSelfDestructThenTransferSameAddress, TestExecutorFinalisesAfterEachTx) exercise the code path but never inspect coinbase-touch gas costs, so the divergence does not fail any assertion.
  • Sei mainnet configs are post-Shanghai/Cancun, so no live production consumer hits this today.

Impact and severity

Marking as nit because:

  1. giga/evmonly is not wired into app/app.go — no runtime consumer is affected today.
  2. Every Sei production chain config has Shanghai active, so the always-warm behavior matches geth on those configs.
  3. The affected path (pre-Shanghai ChainConfig) is only reachable via test-owned configs, and no test asserts on it.

Still worth fixing before this executor is wired into a real callsite, because (a) this ships incorrect EVM semantics on a shipped code path that is exercised by tests, and (b) the fix is a small, mechanical change that mirrors geth exactly.

How to fix

Thread rules through and gate the two behaviors like core/state/statedb.go:1343-1372:

func (s *nativeStateDB) Prepare(rules params.Rules, sender, coinbase common.Address, dest *common.Address, precompiles []common.Address, txAccesses ethtypes.AccessList) {
    clearNestedHashMaps(s.transientStates)
    if rules.IsEIP2929 {
        s.accessList.reset()
        s.AddAddressToAccessList(sender)
        if dest != nil {
            s.AddAddressToAccessList(*dest)
        }
        for _, addr := range precompiles {
            s.AddAddressToAccessList(addr)
        }
        for _, tuple := range txAccesses {
            s.AddAddressToAccessList(tuple.Address)
            for _, key := range tuple.StorageKeys {
                s.AddSlotToAccessList(tuple.Address, key)
            }
        }
        if rules.IsShanghai {
            s.AddAddressToAccessList(coinbase)
        }
    }
}

A regression test that runs a BALANCE(coinbase) (or any EXTCODESIZE/EXTCODEHASH/CALL against the block coinbase) under legacySelfDestructChainConfig and asserts receipt.GasUsed == 21000 + 2600 + ... would catch this and belongs alongside the existing legacy-selfdestruct tests.

@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.

A well-structured, heavily-tested EVM-only giga execution boundary, but the OCC path has a correctness bug: speculative execution runs every tx against base state with nonce checks on, so a valid block containing two consecutive-nonce txs from the same sender aborts with ErrNonceTooHigh instead of falling back to sequential.

Findings: 2 blocking | 3 non-blocking | 1 posted inline

Blockers

  • OCC fails valid blocks with same-sender consecutive nonces (Codex P1, confirmed): executeTxSpeculative runs each tx against the base state with nonce checks enabled, so a second tx from the same sender (nonce 1 while base nonce is 0) returns core.ErrNonceTooHigh, which propagates out of executeBlockOCC and aborts the block rather than being treated as a sender-nonce read/write conflict that triggers sequential fallback. This is a very common transaction pattern and is currently untested (both OCC tests use distinct senders all at nonce 0). Fix direction: run speculation with nonce checks skipped (or catch nonce errors) and let validateOCCResults detect the nonce conflict and fall back.
  • 1 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • OCC will rarely engage under realistic conditions: every non-zero-tip tx credits the block Coinbase balance via ApplyMessage, producing a guaranteed write/write conflict on the coinbase key for any multi-tx block and forcing sequential fallback. The OCC tests only avoid this because they use gasPrice=0 (zero-value AddBalance short-circuits before markWrite). Consider excluding coinbase fee accrual from conflict tracking (e.g. accumulate tips separately) so OCC provides real parallelism.
  • Cursor second-opinion review file (cursor-review.md) was empty — that pass produced no output.
  • Add an OCC test covering multiple transactions from the same sender (sequential nonces) to lock in correct conflict-detection/fallback behavior; the current gap is what hides the blocker above.

Comment thread giga/evmonly/occ.go
for idx := txRange.start; idx < txRange.end; idx++ {
result, err := e.executeTxSpeculative(workerCtx, req, idx, chainConfig, blockCtx, baseFee, gasLimit)
if err != nil {
return err

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] Blocker: this returns speculative-execution errors as a hard block failure before conflict validation can run. Because executeTxSpeculative runs each tx against the base state with nonce checks enabled (DisableNonceCheck defaults false), two consecutive-nonce txs from the same sender make the second speculative run see the base nonce and return core.ErrNonceTooHigh. That error propagates here and aborts a perfectly valid block instead of being detected as a sender-nonce read/write conflict and falling back to sequential execution. Same-sender sequential nonces are extremely common. Skip nonce checks during speculation (or classify nonce errors as a conflict) so validateOCCResults can trigger the intended fallback.

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.

3 participants