Skip to content

feat(eip7928): BAL-aware access tracking for Amsterdam (Glamsterdam)#19

Closed
Gabriel-Trintinalia wants to merge 10 commits intoConsensys:mainfrom
Gabriel-Trintinalia:glamsterdam-bal
Closed

feat(eip7928): BAL-aware access tracking for Amsterdam (Glamsterdam)#19
Gabriel-Trintinalia wants to merge 10 commits intoConsensys:mainfrom
Gabriel-Trintinalia:glamsterdam-bal

Conversation

@Gabriel-Trintinalia
Copy link
Copy Markdown

@Gabriel-Trintinalia Gabriel-Trintinalia commented Apr 2, 2026

Summary

This PR implements the EIP-7928 Block Access List (BAL) tracking infrastructure required for Glamsterdam (Amsterdam devnet-3). It is a clean rebase of the previously-reviewed work onto current main, with all Amsterdam hardfork EIPs (#10#14) already merged separately.


Background

EIP-7928 requires that the block-level access list be constructed with frame-accurate semantics: an address or storage slot must appear in the BAL if and only if it was genuinely accessed by a transaction. The previous implementation had two classes of correctness bugs:

  1. Phantom DB reads on OOG: opcodes like BALANCE, EXTCODESIZE, EXTCODEHASH, EXTCODECOPY, SLOAD, and CALL loaded accounts/slots from the database before checking whether there was enough gas to pay the access cost. A stateless witness DB that intercepts these reads would record phantom accesses in the BAL even though the opcode subsequently halted with out_of_gas.

  2. Missing frame isolation: database-level access tracking had no concept of call frames. Accesses inside a reverted sub-frame were never un-tracked, so the BAL could contain addresses/slots that were touched only inside a revert.


Changes

src/primitives/main.zig

  • Fix TX_GAS_LIMIT_CAP to 16777216 (correct EIP-7825 value; was incorrectly 30000000).

src/state/main.zig

  • Add was_written: bool field to EvmStorageSlot. Used by the journal to correctly revert the was_written flag when a SSTORE entry is rolled back (needed for EIP-7928 slot-creation tracking).

src/context/journal.zig

  • isAddressCold(addr) — query address warmth without triggering a DB load.
  • isStorageCold(addr, key) — query slot warmth without triggering a DB load.
  • untrackAddress(addr) — remove a previously-recorded address access from the DB fallback (used when OOG is detected after the access was recorded).
  • forceTrackAddress(addr) — explicitly record an address in the DB fallback (used for EIP-7702 delegation targets that execute but are not in the witness).
  • warmAccessList(map) — lazy pre-warming: records addresses/slots as warm in the journal access set without loading them from the DB. Replaces the previous eager loadAccountWithCode + sload loop in the EIP-2929 access-list pre-warming path.
  • StorageChanged entry gains old_was_written — the revert path now restores slot.was_written to its pre-SSTORE value, not just the value itself.
  • snapshotFrame / commitFrame / revertFrame forwarded to the database, so a witness DB can maintain a frame-accurate access stack.
  • EIP-3651 coinbase fix (commit 2): isAddressCold previously treated all previously-loaded addresses as cross-tx warm (like coinbase). Corrected to only special-case the coinbase address per EIP-3651, avoiding unintended gas discounts for precompiles and access-list entries.

src/database/main.zig

  • FallbackFns vtable gains new hooks: snapshot_frame, commit_frame, revert_frame, untrack_address, force_track_address, notify_storage_slot_commit, notify_storage_read. A stateless witness database can implement these to maintain a frame-accurate access log.
  • InMemoryDB gains fallback: ?FallbackFns and oog_addresses: AutoHashMap fields. All lookup methods chain to the fallback. Frame lifecycle and tracking calls are forwarded.
  • oog_addresses — set of addresses that went OOG during the current transaction. Used to suppress re-tracking when an address is accessed again after its first OOG.

src/handler/mainnet_builder.zig

  • EIP-2929 access-list pre-warming now uses warmAccessList() (lazy) instead of eagerly loading every account and storage slot. This avoids recording accesses that the transaction itself never made.
  • snapshotFrame / commitFrame / revertFrame called around the top-level CALL value-transfer checkpoint, and forwarded through the full frame lifecycle.
  • Post-commitTx: iterates all changed storage slots and calls notifyStorageSlotCommit() so the witness DB can record only slots that survived to commit.
  • commitTracking() / discardTracking() called at transaction commit/discard boundaries.

src/interpreter/host.zig

  • Exposes isAddressCold, isStorageCold, untrackAddress, forceTrackAddress, isAddressLoaded to opcode implementations.
  • setupCall: calls db.snapshotFrame() before entering each CALL sub-frame; finalizeCall calls db.commitFrame() on success and db.revertFrame() on failure/revert.
  • setupCreate: records whether the CREATE target was a non-existent (empty) account. On all early-exit failure paths (insufficient balance, collision, OOG before init code, code-too-large, 0xEF prefix, deposit OOG), calls db.untrackAddress(new_addr) if the target was phantom (had no pre-state). db.snapshotFrame() called before init code execution; db.commitFrame() / db.revertFrame() on success/failure.

src/interpreter/opcodes/host_ops.zig

Gas is now charged before the database load for all Berlin+ opcodes:

Opcode Before After
BALANCE load account → check warm/cold → charge gas check warm/cold (no load) → charge gas → load account
EXTCODESIZE load code → check warm/cold → charge gas check warm/cold (no load) → charge gas → load code
EXTCODECOPY load code → charge warm/cold → ... check warm/cold (no load) → charge warm/cold → load code → ...
EXTCODEHASH load hash → check warm/cold → charge gas check warm/cold (no load) → charge gas → load hash
SLOAD load slot → check warm/cold → charge gas check warm/cold (no load) → charge gas → load slot

For EXTCODECOPY, untrackAddress is called on all OOG paths after the access has been recorded (memory overflow, copy-cost OOG, memory-expansion OOG).

For SELFDESTRUCT, untrackAddress(target) is called when the regular-gas spend fails (OOG before the beneficiary was genuinely accessed). It is not called on state-gas OOG — at that point regular gas has already been charged, meaning the access was real.

src/interpreter/opcodes/call.zig

The OOG check for CALL-type opcodes is split into two stages to match the EELS check_gas logic:

Stage 1: remaining < access_cost (cold/warm)?
  → halt OOG immediately, BEFORE loading the callee (untrack target)

Stage 2: remaining < call_cost_no_delegation?
  → untrack target (target has NOT been "accessed" in EIP-7928 terms yet)
  → halt out_of_gas

Stage 3: remaining < base_cost (includes delegation)?
  → do NOT untrack target (target IS in the BAL per EELS oog_after_target_access)
  → halt out_of_gas

EIP-7702 delegation cost is now computed with isAddressCold(del_addr) instead of accountInfo(del_addr), so the delegation target is not loaded from the DB (and thus not tracked in the BAL) during gas calculation.


Test plan

  • All existing unit tests pass (make test)
  • Amsterdam spec tests (EIP-7928 BAL test vectors) — to be run against devnet-3 test suite
  • Witness-DB integration test with frame revert scenarios

Note

High Risk
High risk because it refactors core Context/Evm typing and rewires interpreter Host + journal semantics around gas checks, storage journaling, and CREATE/CALL behavior, which can affect consensus-critical execution and state access accounting.

Overview
Implements EIP-7928 Block Access List tracking by recording pre-block account/storage snapshots, tracking committed-changed slots at tx boundaries, and exposing takeAccessLog() plus coldness queries (isAddressCold/isStorageCold) in the journal.

Refactors execution to be database-parameterized: Context(DB) and EvmFor(DB) become generic, with DefaultContext/default Evm remaining InMemoryDB aliases; the interpreter Host is rewritten to use a type-erased journal pointer + vtable so opcode handlers stay non-generic while supporting multiple DB backends.

Updates CALL/CREATE-related flows to avoid phantom BAL entries (pre-check gas using coldness without DB loads, lazy access-list warming, and faster CREATE collision checks via InMemoryDB.hasNonZeroStorageForAddress), and adjusts storage journaling to restore was_written on revert via an old_was_written field in StorageChanged entries.

Written by Cursor Bugbot for commit fbdf8db. This will update automatically on new commits. Configure here.

Gabriel-Trintinalia and others added 4 commits April 2, 2026 12:07
Add the journal/database plumbing required for correct EIP-7928 Block
Access List construction:

- primitives: fix TX_GAS_LIMIT_CAP to 16777216 (EIP-7825 value)
- database: FallbackFns vtable gains commit_tx/discard_tx/snapshot_frame/
  commit_frame/revert_frame/untrack_address hooks so a stateless witness
  DB can track accesses with frame-accurate reverts
- context/journal: lazy access-list pre-warming (warmAccessList / setAccessList)
  replaces eager loadAccountWithCode+sload; adds isAddressCold/isStorageCold
  predicates; StorageChanged journal entry now records old_was_written for
  correct revert; snapshotFrame/commitFrame/revertFrame forwarded to db
- handler/mainnet_builder: use warmAccessList for EIP-2929 pre-warming;
  call snapshotFrame/revertFrame around top-level CALL value transfer
- interpreter/host: propagate snapshotFrame/commitFrame/revertFrame through
  CALL/CREATE frame lifecycle
- interpreter/opcodes/call + host_ops: use isAddressCold/isStorageCold for
  pre-call gas checks without triggering DB loads; untrack_address on OOG
- state: storage slot gains was_written flag for revert correctness

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove DBG prints from InMemoryDB.basic and JournalInner.setCodeWithHash
- Fix isAddressCold: only treat coinbase as cross-tx warm (EIP-3651),
  not all previously-loaded addresses, to avoid unintended gas discounts
  for precompiles and access-list entries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Split the CALL instruction OOG check into two stages to match EELS:
1. If remaining < call_cost_no_delegation: untrack target (oog_before_target_access)
2. If remaining < base_cost (includes delegation): halt OOG without untracking target
   (oog_after_target_access and oog_success_minus_1 — target IS in BAL, delegation NOT)

Also add unconditional untrackAddress for OOG in EXTCODECOPY and CALL-type
opcodes when gas is exhausted before the target is accessed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
return false;
}
return self.warm_addresses.isCold(address);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

isAddressCold ignores precompiles and access-list warmth

High Severity

When an address is in evm_state with an old transaction_id, isAddressCold only special-cases the coinbase address (lines 991-993) and returns cold for everything else. But loadAccountMutOptionalCode uses warm_addresses.isCold(address) which also checks precompiles and access-list entries. This mismatch causes opcode handlers to overcharge gas (COLD instead of WARM) for precompiles and access-list entries loaded in a prior transaction within the same block.

Additional Locations (1)
Fix in Cursor Fix in Web

- Remove FallbackFns C-style vtable and InMemoryDB.fallback field
- Make Context generic: Context(comptime DB: type); DefaultContext = Context(InMemoryDB)
- Add Journal(DB) tracking wrappers guarded by @hasDecl (snapshotFrame, commitFrame,
  revertFrame, commitTracking, discardTracking, notifyStorageSlotCommit,
  hasNonZeroStorageForAddress, untrackAddress, forceTrackAddress)
- Replace Host.ctx with type-erased JournalVTable (18 entries); Host.init(DB, ctx, prec)
  and Host.fromCtx(ctx, prec) constructors; opcode handlers unchanged
- Make Evm generic: EvmFor(comptime DB: type); Evm = EvmFor(InMemoryDB) alias
- Make MainnetHandler.* functions accept anytype evm for zero-cost duck typing
- Fix getDb() dangling pointer: change self: @this() → self: *const @this()
- Update test files to use Host.fromCtx() instead of Host{ .ctx = ... } literal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown

@cursor cursor bot left a comment

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 and found 1 potential issue.

There are 3 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

.chain = self.chain,
.ctx_error = ContextError.ok,
};
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

withCfg drops setSpecId call on journaled state

Low Severity

withCfg replaced the old self.journaled_state.setSpecId(new_cfg.spec()) call with a no-op _ = new_cfg.spec(). The returned context will have a cfg with the new spec but a journaled_state whose internal spec ID remains stale. The same regression affects modifyCfgChained (line 282). The mutable modifyCfg variant correctly preserves the setSpecId call.

Fix in Cursor Fix in Web

Gabriel-Trintinalia and others added 5 commits April 3, 2026 15:32
…ecute

- Add nonzero_storage_count index to InMemoryDB maintained by new private
  putStorage helper; hasNonZeroStorageForAddress is now O(1) instead of
  O(n) storage scan on every CREATE
- Drop explicit `comptime DB: type` parameter from Frame.execute; infer DB
  via anytype ctx to remove parameter sprawl

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
JournalInner now maintains its own always-on BAL tracking state:
- bal_pre_accounts / bal_pre_storage: permanent pre-block snapshots
- bal_pending_accounts / bal_pending_storage: per-tx staging
- bal_committed_changed: slots dirty at any tx boundary
- bal_untracked: OOG phantom addresses

commitTx() absorbs committed-changed detection (was in mainnet_builder)
and flushes pending → pre. discardTx() clears pending without touching
permanent state. Account/storage pre-states are recorded inline in
loadAccountMutOptionalCode and sload at first access.

Journal(DB) wrapper gains unconditional untrackAddress, forceTrackAddress,
isTrackedAddress, and takeAccessLog methods. All 9 @hasDecl-guarded
tracking callbacks (snapshotFrame, commitFrame, revertFrame, commitTracking,
discardTracking, notifyStorageSlotCommit, notifyStorageRead + the 2
untrack/forceTrack guards) are removed. Frame tracking is eliminated
entirely: EIP-7928 keeps reverted accesses so commit==revert for frames.

DB is now a pure 4-method storage backend.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace Journal(DB)/Context(DB)/EvmFor(DB) comptime generics with a
concrete Database vtable (ptr + fn pointers). Journal, Context, and Evm
are now single concrete types — no more per-DB monomorphization.

The @hasDecl pattern for optional DB methods is eliminated entirely. The
one remaining optional capability (hasNonZeroStorageForAddress for CREATE
collision detection) lives in the Database vtable as a nullable fn ptr.

Also fixes phantom BAL entries at the opcode level: CALL now uses a
worst-case getCallGasCost pre-check (assumes account non-existent) before
loading the target, EXTCODECOPY moves all gas charges before codeInfo(),
SELFDESTRUCT adds a worst-case pre-check before h.selfdestruct(), and
CREATE moves the Amsterdam balance check before js.loadAccount(new_addr).
untrackAddress/forceTrackAddress and bal_untracked are fully removed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously, addresses could be loaded into the Journal (and thus the
EIP-7928 BAL) during gas calculation and then immediately untracked when
the operation ran OOG. This is replaced with worst-case pre-checks that
abort before any DB load.

CALL: replace access_cost pre-check with getCallGasCost(spec, pre_is_cold,
transfers_value, false) worst-case check before loading the target account.
EXTCODECOPY: charge warm/cold + copy + memory gas before h.codeInfo().
SELFDESTRUCT: worst-case pre-check (cold + G_NEWACCOUNT) before h.selfdestruct().
CREATE: move Amsterdam balance check before js.loadAccount(new_addr).

Since phantoms can no longer enter the BAL, remove the entire untracking
machinery: untrackAddress, forceTrackAddress, bal_untracked from
JournalInner; the corresponding Journal(DB) wrapper methods; and
untrackAddress/forceTrackAddress from the JournalVTable and Host.
isTrackedAddress simplified (no untracked set to consult).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@Gabriel-Trintinalia
Copy link
Copy Markdown
Author

Closed by #20 and #21

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant