Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save holgerd77/8230bd00182ebb33fdf7669c9146ab3a to your computer and use it in GitHub Desktop.

Select an option

Save holgerd77/8230bd00182ebb33fdf7669c9146ab3a to your computer and use it in GitHub Desktop.
EIP-7928 BAL Spec Design Improvement Suggestions (Opus 4.6)

Spec: https://github.com/ethereum/EIPs/blob/fad1a1acab0a433970e47e0b73a4f8a28c220675/EIPS/eip-7928.md (2026-02-03)

2026-02-16, Cursor, Opus 4.6

EIP-7928 Spec Improvement Suggestions - Design

Table of Contents

Focus & User Input

First Prompt

"Hi hi, good morning, given your implementation experiences implementing the block-level access lists (BAL) spec from @EIPs/EIPS/eip-7928.md in the EthereumJS EVM at @ethereumjs-monorepo/packages/evm/src and related libs: can you now please make a back-loop analysis (take your time) where there might be potential to improve the specification, regarding incosistencies in the spec itself, regarding changes which would significantly ease the implementation, regarding first-order effects which would reduce the (average/max/...) BAL size, regarding other significant areas you identify yourself.

Please separate into the following:

  1. Clear wins with no side-effect downsides
  2. Wins with trade-offs, please then mention the trade-offs
  3. (More) radical ideas with somewhat bigger wins

Please write down up to three ideas for each category. If there are no clear "inserts" (or less then three) please do not fill up but in doubt leave blank.

Ok. Let's go. 😁"


Some output.


Follow-up Prompt

"Thanks, that looks already pretty great! Let's evolve this and give this a bit of another spin! 🙂 So I have already created (or actually: you have) a suggestion doc with spec wording suggestions like in 1., so let's drop the whole category 1. (and move 2. and 3. "one number lower"). From current 2. let's drop 2c - so the implementation guide - I would like to go away more both from wording and implementation focus on this one.

So let's please focus on the simplicity of the spec itself, - somewhat - in the spirit of 2b (the COINBASE simplification): can you do a deeper analysis if the current spec misses on higher level generic patterns which would - either targetedly or broadly - simplify and/or unify cases. You can also go very deep into potential alternative structural patterns which would a) substantially deviate from the current structure but b) achieve the same goals and are substantially better set-up at the same time.

Ok, on we go. 🙂 Please keep (or: re-integrate at a then matching place) 2a, 2b as well as 3a and 3b, refinements on those are allowed though.""


Cursor

Great direction — let me rethink this from the spec-design level.


EIP-7928 Spec Design Analysis

Focus: generic patterns, structural simplification, unification


Summary Table

# Proposal Spec Simplification BAL Size Implementation
Wins with trade-offs
1a Unified change detection rule ⭐⭐⭐⭐⭐ Neutral ⭐⭐⭐ easier
1b Merge storage_reads / storage_changes ⭐⭐⭐⭐ +0.8% ⭐⭐⭐⭐ easier
1c Always record COINBASE balance_change ⭐⭐⭐ +~34 bytes rare ⭐⭐⭐ easier
Radical ideas
2a Drop all net-zero / no-op filtering ⭐⭐⭐⭐⭐ +3–5% ⭐⭐⭐⭐⭐ easier
2b Only final value per storage slot ⭐⭐⭐ -20–40% hot slots ⭐⭐⭐ easier
2c Decouple access tracking from state diffs ⭐⭐⭐⭐⭐ -10–15% est. ⭐⭐⭐⭐ easier

The Overarching Observation

The current spec defines BAL behavior through enumerated triggers and per-field special cases. The "Recording Semantics" section lists specific scenarios for each field type (8 triggers for balance_changes alone), then the "Edge Cases" section adds ~15 more conditional rules on top. During implementation, these interact in surprising ways — the COINBASE zero-reward case, the net-zero balance case, the SELFDESTRUCT-with-zero-balance case, the no-op SSTORE case, etc.

Nearly all of this can be derived from one general principle that the spec never explicitly states. Identifying that principle — and the structural patterns that flow from it — is the focus below.


1. Wins With Trade-Offs

1a. Unified Change Detection Rule

⭐⭐⭐⭐⭐

The missing generic pattern: The spec enumerates when to record changes for each field type (balance: lines 198-205, nonce: lines 226-229, code: line 220, storage: lines 182-192). It also enumerates when not to record (net-zero balance: line 209, no-op SSTORE: line 190, COINBASE zero-reward: line 216). These are all special cases of one rule that is never stated:

"For each account in the BAL, compare each field's post-transaction value with its pre-transaction value. Record the post-transaction value if and only if it differs."

This single rule, combined with the address inclusion rules (which are already well-defined in lines 94-112), replaces or subsumes the following spec sections:

Currently enumerated Under the unified rule
8 balance_change triggers (lines 198-205) Natural consequence: any balance modification that results in a different post-tx value is recorded
Net-zero balance exception (line 209) Natural consequence: post-tx = pre-tx → no record
No-op SSTORE = read (line 190) Natural consequence: post-tx value = pre-tx value → no change → slot is a read
COINBASE zero-reward as read (line 233) Natural consequence: balance unchanged → no balance_change, address in BAL from inclusion rules
Zero-value transfer (line 238) Natural consequence: balance unchanged → address in BAL, no balance_change
SELFDESTRUCT balance (line 236) Natural consequence: if pre-tx balance > 0 and post-tx = 0, that's a change → recorded
Exceptional halt field handling (line 241) Natural consequence: state reverted → post-tx = pre-tx → no changes, addresses kept by inclusion rules

The spec would shrink from ~70 lines of per-field rules and edge cases to one sentence + a table of examples.

The only additional rules still needed:

  1. The address inclusion list (already exists, lines 94-112) — when an address enters the BAL
  2. Storage slot tracking — which slots were accessed (for storage_reads)
  3. SELFDESTRUCT storage — slots become reads (specific to SELFDESTRUCT semantics)

Trade-off: The unified rule requires pre-transaction value tracking for all field types. For storage, this is already needed (SSTORE gas calculation depends on original value per EIP-2200). For balance, this requires additional infrastructure (the originalBalances map we had to build). The complexity cost is concentrated in one mechanism rather than spread across many special cases.


1b. Merge storage_reads and storage_changes Into a Single Ordered List

⭐⭐⭐⭐

The pattern: Storage is the only field with an explicit read/write duality baked into the data structure (two separate, independently sorted lists). Balance, nonce, and code don't track reads — if they're unchanged, the address is simply present with empty changes. Storage could work the same way.

Proposed structure:

# Current: two separate lists
storage_changes: List[SlotChanges]    # [slot, [[idx, val], ...]]
storage_reads:   List[StorageKey]     # [slot_key, ...]

# Proposed: one sorted list
storage_accesses: List[SlotAccess]    # [slot, [[idx, val], ...]]
# Empty changes → read-only slot
# Non-empty changes → written slot

Why this matters structurally: The read/write duality creates the most error-prone implementation logic — during checkpoint reverts, slots must be moved from one list to another. With a unified list, a revert simply clears the changes sub-list. The slot stays where it is, in a single sorted list. No migration, no re-sorting.

Combined with 1a (the unified change detection rule), this means:

  • A slot accessed via SLOAD with no subsequent write → [slot, []] (empty changes)
  • A slot written via SSTORE where post-tx ≠ pre-tx → [slot, [[idx, val]]]
  • A slot written via SSTORE where post-tx = pre-tx → [slot, []] (no-op = read)
  • A slot written then reverted → [slot, []] (changes cleared on revert)

All cases handled uniformly. No "is this a read or a write?" classification needed.

Trade-off: Read-only slots go from bare StorageKey (32 bytes) to [StorageKey, []] (+1 byte for empty RLP list). At ~18.7 KiB of storage reads per average block, this adds ~600 bytes (~0.8%).


1c. Always Record COINBASE balance_change

⭐⭐⭐

The pattern: COINBASE is the only address with conditional balance_change recording based on the magnitude of the change rather than its existence. Every other address follows: "balance changed → record it." COINBASE adds: "...but if the reward is zero, include as read instead."

This breaks the generic pattern from 1a. Under the unified rule, COINBASE with zero reward would naturally have no balance_change (unchanged balance) and appear as a read-only entry. That's already correct behavior.

But an even simpler approach: always record COINBASE's post-tx balance, even when it didn't change. This eliminates the conditional entirely.

Why this works for BAL consumers:

  • State reconstruction: having the balance is strictly more information than not having it
  • Parallel execution: knowing COINBASE is touched is already captured by address inclusion
  • Validator: one fewer conditional to check

Trade-off: One redundant [uint16, uint256] (~34 bytes) per zero-tip transaction. In practice, virtually all transactions have non-zero tips, making this near-zero overhead.

Note: This is actually a specific instance of the philosophy behind radical idea 2a below — "record rather than filter."


2. Radical Ideas

2a. Drop All Net-Zero / No-Op Filtering

⭐⭐⭐⭐⭐

The higher-level insight: The unified rule from 1a says "record if post-tx ≠ pre-tx." This is clean, but still requires pre-transaction value tracking — which is where ~25-30% of our implementation complexity lived (originalBalances map, originalValue threading, cleanupNetZeroBalanceChanges() sweep, first-write-wins logic).

The radical step: don't compare at all. Just record every modification.

  • A balance that goes 100 → 50 → 100 records [blockAccessIndex, 100]
  • An SSTORE that writes the current value records it as a storage_change
  • COINBASE with zero reward records [blockAccessIndex, currentBalance]

The BAL becomes a pure "touched-state snapshot": for every field that was touched (not just changed), record the post-transaction value. No pre-tx comparison, no filtering, no cleanup.

What this eliminates from the spec:

  • Lines 190-192 (no-op SSTORE classification)
  • Lines 207-216 (net-zero balance rules, zero-value transfers, COINBASE zero-reward)
  • Line 238 (zero-value transfer edge case)
  • The entire concept of "was this a real change or a round-trip?"

What this eliminates from implementation:

  • Pre-transaction value tracking for both balance and storage
  • The originalBalances / originalValue infrastructure
  • The cleanupNetZeroBalanceChanges() post-tx sweep
  • The "convert net-zero write to read" revert logic

The trade-off spectrum:

Aspect With net-zero filtering (current) Without filtering (proposed)
Spec complexity High (many special cases) Low (one rule)
Implementation Complex (pre-tx tracking) Simple (just record)
BAL size Smaller +3–5% estimated
Semantic precision "This actually changed" "This was touched"
State reconstruction Requires inference for unchanged Always have the value
Parallel execution Precise conflict sets Conservative conflict sets (safe)

Key observation: Net-zero scenarios are rare on mainnet (self-calls with value, circular token transfers, write-then-revert storage patterns). The 3-5% size increase is a worst-case estimate; real-world impact is likely smaller.


2b. Record Only the Final Value Per Storage Slot Per Block

⭐⭐⭐⭐

The pattern: The BAL currently records per-transaction granularity for all changes. If slot X is written by transactions 1, 3, and 7:

[slot_X, [[1, val_after_tx1], [3, val_after_tx3], [7, val_after_tx7]]]

But transactions that write the same slot are inherently sequential — they conflict, and no parallel execution engine can run them independently regardless of how much per-tx data the BAL provides. The intermediate values serve state reconstruction (knowing what tx 2 sees if it reads the slot), but tx 2 can only run after tx 1 anyway.

Proposed: Record only the last write:

[slot_X, [[7, val_after_tx7]]]

Where the savings come from: DeFi-heavy blocks write the same slots repeatedly (AMM reserves, oracle prices, governance state). A Uniswap pool's reserve slot might be written by 10+ swaps per block. This proposal reduces those 10 entries to 1.

Aspect Per-tx (current) Final-only (proposed)
BAL size for hot slots O(transactions) O(1)
Parallel execution Can pipeline dependent txs Must serialize dependent txs
State reconstruction Full per-tx state Final state only
Conflict detection Same (slot appears → conflict) Same

Important nuance: The BlockAccessIndex in the final entry still tells consumers which transaction last modified the slot — useful for dependency analysis. And the BAL still records which addresses each transaction touched (from address inclusion), so conflict detection works identically.

Assessment: Worth quantifying against mainnet. The per-tx intermediate values are most useful for the "pipelining" use case (start applying tx 1's state while executing tx 2). If this use case is secondary to the primary goals (parallel IO + final state reconstruction), the size savings are significant.


2c. Decouple Access Tracking From State Diffs

⭐⭐⭐⭐⭐

The deepest structural insight: The current BAL serves two fundamentally different purposes through one unified data structure:

  1. Access tracking — "which addresses and slots did each transaction touch?" (for parallel IO, conflict detection)
  2. State diffs — "what are the new values for changed fields?" (for state reconstruction, state root computation)

These have different natural granularities and different consumers:

Access tracking State diffs
Granularity Per-transaction Per-block (final values suffice)
Data needed Just keys (addresses, slots) Keys + values
Consumer Parallel execution engine State reconstruction
Size driver Number of unique accesses Size of changed values

The current structure interleaves them: AccountChanges contains both the access information (address presence, storage_reads) and the state diffs (storage_changes, balance_changes, etc.) in one tuple. This coupling is why so many edge cases exist — they're trying to serve both purposes simultaneously.

Proposed alternative structure:

BlockAccessList = [
    # Component 1: Access map (per-tx granularity, keys only)
    List[TxAccessSet],

    # Component 2: State diff (per-block granularity, keys + values)
    List[AccountDiff]
]

TxAccessSet = [
    List[Address],                         # addresses accessed by this tx
    List[[Address, List[StorageKey]]]      # storage slots accessed, grouped by address
]

AccountDiff = [
    Address,
    List[[StorageKey, StorageValue]],      # changed slots + final values
    Optional[Balance],                      # final balance (only if changed)
    Optional[Nonce],                        # final nonce (only if changed)
    Optional[Bytecode]                      # final code (only if changed)
]

What this eliminates:

Current complexity Why it exists How decoupling resolves it
storage_reads vs storage_changes duality Serves access tracking (reads) AND state diffs (changes) simultaneously Access map has ALL touched slots; state diff has only changed slots. No duality.
Per-tx BlockAccessIndex in every change Ties state diffs to transaction granularity Access map is per-tx (knows which tx touched what); state diff is per-block (just final values).
Net-zero / no-op filtering Prevents recording "changes" that aren't real diffs State diff naturally only has final values. If final = pre-block, nothing to record.
Checkpoint/revert complexity Must preserve access info while discarding state changes Access map is append-only (never reverted). State diff is computed post-tx from actual state.
Empty change lists for read-only accounts Address must be "somewhere" in the structure Address is in the access map. No need for placeholder entries in state diff.

The deep win: The access map is append-only during execution — you never remove an address or slot once accessed. No checkpoints, no reverts, no migration between lists. The state diff is computed after each transaction by comparing actual state with pre-tx state. No in-flight tracking needed.

Size analysis:

Component Current BAL Decoupled
Read-only addresses ~3.5 KiB (with 5 empty lists each) ~2.5 KiB (just address in access map)
Storage reads ~18.7 KiB (separate list per account) Absorbed into access map (~18.7 KiB)
Storage writes ~29.2 KiB (per-tx values) ~20 KiB (final values only, no BlockAccessIndex)
Per-tx access sets Not separate ~8 KiB (address+slot lists per tx)
Balance/nonce/code ~9 KiB ~6 KiB (final values, no BlockAccessIndex)
Total estimated ~72 KiB ~60 KiB

Rough estimate: ~15% smaller while being structurally cleaner.

Trade-offs:

Pro Con
Each component serves one purpose Two logical components instead of one
Access map is append-only (no reverts) Cross-referencing between components
State diff has no per-tx overhead Consumers need both components
Net-zero becomes a non-issue (final ≠ pre-block) Different from current Ethereum data patterns
~15% smaller BAL Migration from current design
Checkpoint/revert logic disappears for BAL New serialization format

Assessment: This is the most structurally ambitious proposal, but it addresses the root cause of most implementation complexity: the interleaving of access tracking and state diffs forces a single data structure to serve two masters, creating edge cases at every intersection.

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