Skip to content

Commit 80cc8bf

Browse files
refactor: move EIP-7928 BAL tracking from DB to Journal
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>
1 parent b85262d commit 80cc8bf

File tree

4 files changed

+226
-86
lines changed

4 files changed

+226
-86
lines changed

src/context/journal.zig

Lines changed: 221 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,35 @@ pub const JournalEntryFactory = struct {
5252
}
5353
};
5454

55+
/// Pre-block account state snapshot for EIP-7928 BAL tracking.
56+
pub const AccountPreState = struct {
57+
nonce: u64 = 0,
58+
balance: primitives.U256 = @as(primitives.U256, 0),
59+
code_hash: primitives.Hash = primitives.KECCAK_EMPTY,
60+
};
61+
62+
/// Block Access List log produced after all txs complete.
63+
/// Ownership of the maps is transferred by `JournalInner.takeAccessLog()`.
64+
pub const AccessLog = struct {
65+
/// Pre-block account states (nonce, balance, code_hash) for all accessed addresses.
66+
accounts: std.AutoHashMap(primitives.Address, AccountPreState),
67+
/// Pre-block storage values for all accessed slots.
68+
storage: std.AutoHashMap(primitives.Address, std.AutoHashMap(primitives.StorageKey, primitives.StorageValue)),
69+
/// Slots that were committed to a value different from the pre-block value at any tx boundary.
70+
/// Used to distinguish storageChanges from storageReads for cross-tx net-zero writes.
71+
committed_changed: std.AutoHashMap(primitives.Address, std.AutoHashMap(primitives.StorageKey, void)),
72+
73+
pub fn deinit(self: *@This()) void {
74+
self.accounts.deinit();
75+
var sit = self.storage.valueIterator();
76+
while (sit.next()) |m| m.deinit();
77+
self.storage.deinit();
78+
var cit = self.committed_changed.valueIterator();
79+
while (cit.next()) |m| m.deinit();
80+
self.committed_changed.deinit();
81+
}
82+
};
83+
5584
/// Selfdestruction revert status
5685
pub const SelfdestructionRevertStatus = enum {
5786
GloballySelfdestroyed,
@@ -403,6 +432,19 @@ pub const JournalInner = struct {
403432
/// Emitted as Burn logs at postExecution, sorted by address. Checkpointed via burn_i.
404433
pending_burns: std.ArrayList(PendingBurn),
405434

435+
// ── EIP-7928 BAL tracking ──────────────────────────────────────────────────
436+
// Permanently committed account pre-states (survive across txs in a block).
437+
bal_pre_accounts: std.AutoHashMap(primitives.Address, AccountPreState),
438+
// Permanently committed storage pre-states.
439+
bal_pre_storage: std.AutoHashMap(primitives.Address, std.AutoHashMap(primitives.StorageKey, primitives.StorageValue)),
440+
// Per-tx staging: flushed on commitTx, cleared on discardTx.
441+
bal_pending_accounts: std.AutoHashMap(primitives.Address, AccountPreState),
442+
bal_pending_storage: std.AutoHashMap(primitives.Address, std.AutoHashMap(primitives.StorageKey, primitives.StorageValue)),
443+
// Slots committed to a non-pre-block value at any tx boundary.
444+
bal_committed_changed: std.AutoHashMap(primitives.Address, std.AutoHashMap(primitives.StorageKey, void)),
445+
// Addresses loaded only for gas calculation that went OOG (excluded from BAL).
446+
bal_untracked: std.AutoHashMap(primitives.Address, void),
447+
406448
pub fn new() JournalInner {
407449
return .{
408450
.evm_state = state.EvmState.init(alloc_mod.get()),
@@ -413,6 +455,12 @@ pub const JournalInner = struct {
413455
.spec = primitives.SpecId.prague,
414456
.warm_addresses = WarmAddresses.new(),
415457
.pending_burns = std.ArrayList(PendingBurn){},
458+
.bal_pre_accounts = std.AutoHashMap(primitives.Address, AccountPreState).init(alloc_mod.get()),
459+
.bal_pre_storage = std.AutoHashMap(primitives.Address, std.AutoHashMap(primitives.StorageKey, primitives.StorageValue)).init(alloc_mod.get()),
460+
.bal_pending_accounts = std.AutoHashMap(primitives.Address, AccountPreState).init(alloc_mod.get()),
461+
.bal_pending_storage = std.AutoHashMap(primitives.Address, std.AutoHashMap(primitives.StorageKey, primitives.StorageValue)).init(alloc_mod.get()),
462+
.bal_committed_changed = std.AutoHashMap(primitives.Address, std.AutoHashMap(primitives.StorageKey, void)).init(alloc_mod.get()),
463+
.bal_untracked = std.AutoHashMap(primitives.Address, void).init(alloc_mod.get()),
416464
};
417465
}
418466

@@ -424,6 +472,107 @@ pub const JournalInner = struct {
424472
self.journal.deinit(alloc_mod.get());
425473
self.warm_addresses.deinit();
426474
self.pending_burns.deinit(alloc_mod.get());
475+
self.bal_pre_accounts.deinit();
476+
var pre_sit = self.bal_pre_storage.valueIterator();
477+
while (pre_sit.next()) |m| m.deinit();
478+
self.bal_pre_storage.deinit();
479+
self.bal_pending_accounts.deinit();
480+
var pend_sit = self.bal_pending_storage.valueIterator();
481+
while (pend_sit.next()) |m| m.deinit();
482+
self.bal_pending_storage.deinit();
483+
var cc_it = self.bal_committed_changed.valueIterator();
484+
while (cc_it.next()) |m| m.deinit();
485+
self.bal_committed_changed.deinit();
486+
self.bal_untracked.deinit();
487+
}
488+
489+
// ── EIP-7928 BAL tracking helpers ─────────────────────────────────────────
490+
491+
/// Record an account at its pre-block state (null = non-existent account).
492+
/// Uses first-access-wins: if the address is already in pre or pending, skip.
493+
fn recordAccountAccess(self: *JournalInner, address: primitives.Address, info: ?state.AccountInfo) void {
494+
if (self.bal_pre_accounts.contains(address) or self.bal_pending_accounts.contains(address)) return;
495+
const pre: AccountPreState = if (info) |i| .{
496+
.nonce = i.nonce,
497+
.balance = i.balance,
498+
.code_hash = i.code_hash,
499+
} else .{};
500+
self.bal_pending_accounts.put(address, pre) catch {};
501+
}
502+
503+
/// Record a storage slot at its pre-block value.
504+
/// Uses first-access-wins per slot.
505+
fn recordStorageAccess(self: *JournalInner, address: primitives.Address, key: primitives.StorageKey, value: primitives.StorageValue) void {
506+
// Check permanent pre_storage first
507+
if (self.bal_pre_storage.get(address)) |slots| {
508+
if (slots.contains(key)) return;
509+
}
510+
// Check pending
511+
if (self.bal_pending_storage.get(address)) |slots| {
512+
if (slots.contains(key)) return;
513+
}
514+
const gop = self.bal_pending_storage.getOrPut(address) catch return;
515+
if (!gop.found_existing) gop.value_ptr.* = std.AutoHashMap(primitives.StorageKey, primitives.StorageValue).init(alloc_mod.get());
516+
gop.value_ptr.put(key, value) catch {};
517+
}
518+
519+
/// Mark an address as an OOG phantom — it was loaded for gas calc but the
520+
/// operation failed before the address was truly accessed. Removed from pending.
521+
pub fn untrackAddress(self: *JournalInner, address: primitives.Address) void {
522+
_ = self.bal_pending_accounts.remove(address);
523+
self.bal_untracked.put(address, {}) catch {};
524+
}
525+
526+
/// Force-add an address to the current-tx pending set (for EIP-7702 delegation targets
527+
/// that execute but are never loaded via basic()).
528+
pub fn forceTrackAddress(self: *JournalInner, address: primitives.Address) void {
529+
if (self.bal_pre_accounts.contains(address) or self.bal_pending_accounts.contains(address)) return;
530+
self.bal_pending_accounts.put(address, .{}) catch {};
531+
}
532+
533+
/// Returns true if the address is legitimately tracked (not an OOG phantom).
534+
pub fn isTrackedAddress(self: *const JournalInner, address: primitives.Address) bool {
535+
if (self.bal_pre_accounts.contains(address)) return true;
536+
if (self.bal_untracked.contains(address)) return false;
537+
return self.bal_pending_accounts.contains(address);
538+
}
539+
540+
/// Drain the accumulated Block Access Log. Flushes any remaining pending state
541+
/// into the permanent maps and transfers ownership to the caller.
542+
pub fn takeAccessLog(self: *JournalInner) AccessLog {
543+
// Flush remaining pending (e.g. if called after last tx without commitTx)
544+
var pa_it = self.bal_pending_accounts.iterator();
545+
while (pa_it.next()) |e| {
546+
if (!self.bal_pre_accounts.contains(e.key_ptr.*)) {
547+
self.bal_pre_accounts.put(e.key_ptr.*, e.value_ptr.*) catch {};
548+
}
549+
}
550+
self.bal_pending_accounts.clearRetainingCapacity();
551+
552+
var ps_it = self.bal_pending_storage.iterator();
553+
while (ps_it.next()) |e| {
554+
const addr = e.key_ptr.*;
555+
var slot_it = e.value_ptr.iterator();
556+
while (slot_it.next()) |s| {
557+
const pre_gop = self.bal_pre_storage.getOrPut(addr) catch continue;
558+
if (!pre_gop.found_existing) pre_gop.value_ptr.* = std.AutoHashMap(primitives.StorageKey, primitives.StorageValue).init(alloc_mod.get());
559+
if (!pre_gop.value_ptr.contains(s.key_ptr.*)) {
560+
pre_gop.value_ptr.put(s.key_ptr.*, s.value_ptr.*) catch {};
561+
}
562+
}
563+
e.value_ptr.deinit();
564+
}
565+
self.bal_pending_storage.clearRetainingCapacity();
566+
567+
const log = AccessLog{
568+
.accounts = self.bal_pre_accounts,
569+
.storage = self.bal_pre_storage,
570+
.committed_changed = self.bal_committed_changed,
571+
};
572+
self.bal_pre_accounts = std.AutoHashMap(primitives.Address, AccountPreState).init(alloc_mod.get());
573+
self.bal_pre_storage = std.AutoHashMap(primitives.Address, std.AutoHashMap(primitives.StorageKey, primitives.StorageValue)).init(alloc_mod.get());
574+
self.bal_committed_changed = std.AutoHashMap(primitives.Address, std.AutoHashMap(primitives.StorageKey, void)).init(alloc_mod.get());
575+
return log;
427576
}
428577

429578
/// Returns the logs
@@ -441,6 +590,53 @@ pub const JournalInner = struct {
441590
///
442591
/// `commit_tx` is used even for discarding transactions so transaction_id will be incremented.
443592
pub fn commitTx(self: *JournalInner) void {
593+
// EIP-7928: record committed-changed storage BEFORE resetting original_value.
594+
// Slots where present != original were dirty at this tx boundary; flag them so
595+
// cross-tx net-zero writes are classified as storageChanges, not storageReads.
596+
// Skip accounts created AND selfdestructed in the same tx (net zero effect).
597+
{
598+
var state_it = self.evm_state.iterator();
599+
while (state_it.next()) |entry| {
600+
if (entry.value_ptr.status.created and entry.value_ptr.status.self_destructed) continue;
601+
var slot_it = entry.value_ptr.storage.iterator();
602+
while (slot_it.next()) |slot| {
603+
if (slot.value_ptr.present_value != slot.value_ptr.original_value) {
604+
const addr = entry.key_ptr.*;
605+
const gop = self.bal_committed_changed.getOrPut(addr) catch continue;
606+
if (!gop.found_existing) gop.value_ptr.* = std.AutoHashMap(primitives.StorageKey, void).init(alloc_mod.get());
607+
gop.value_ptr.put(slot.key_ptr.*, {}) catch {};
608+
}
609+
}
610+
}
611+
}
612+
613+
// EIP-7928: flush per-tx pending into permanent pre-state maps (first-access-wins).
614+
{
615+
var pa_it = self.bal_pending_accounts.iterator();
616+
while (pa_it.next()) |e| {
617+
if (!self.bal_pre_accounts.contains(e.key_ptr.*)) {
618+
self.bal_pre_accounts.put(e.key_ptr.*, e.value_ptr.*) catch {};
619+
}
620+
}
621+
self.bal_pending_accounts.clearRetainingCapacity();
622+
623+
var ps_it = self.bal_pending_storage.iterator();
624+
while (ps_it.next()) |e| {
625+
const addr = e.key_ptr.*;
626+
var slot_it = e.value_ptr.iterator();
627+
while (slot_it.next()) |s| {
628+
const pre_gop = self.bal_pre_storage.getOrPut(addr) catch continue;
629+
if (!pre_gop.found_existing) pre_gop.value_ptr.* = std.AutoHashMap(primitives.StorageKey, primitives.StorageValue).init(alloc_mod.get());
630+
if (!pre_gop.value_ptr.contains(s.key_ptr.*)) {
631+
pre_gop.value_ptr.put(s.key_ptr.*, s.value_ptr.*) catch {};
632+
}
633+
}
634+
e.value_ptr.deinit();
635+
}
636+
self.bal_pending_storage.clearRetainingCapacity();
637+
self.bal_untracked.clearRetainingCapacity();
638+
}
639+
444640
// Per EIP-2200/EIP-3529: after committing a transaction, the "original value"
445641
// of each storage slot becomes the committed (present) value. Without this,
446642
// subsequent transactions in the same block would incorrectly treat slots as
@@ -484,6 +680,12 @@ pub const JournalInner = struct {
484680
self.pending_burns.clearRetainingCapacity();
485681
self.transaction_id += 1;
486682
self.warm_addresses.clearCoinbaseAndAccessList();
683+
// EIP-7928: discard per-tx pending tracking; pre_* survives (from prior txs).
684+
self.bal_pending_accounts.clearRetainingCapacity();
685+
var ps_it = self.bal_pending_storage.valueIterator();
686+
while (ps_it.next()) |m| m.deinit();
687+
self.bal_pending_storage.clearRetainingCapacity();
688+
self.bal_untracked.clearRetainingCapacity();
487689
}
488690

489691
/// Take the [`EvmState`] and clears the journal by resetting it to initial state.
@@ -1057,6 +1259,11 @@ pub const JournalInner = struct {
10571259
gop.value_ptr.* = new_account;
10581260
is_cold = acct_is_cold;
10591261
account_ptr = gop.value_ptr;
1262+
// EIP-7928: record pre-block account state at first load (using already-fetched info).
1263+
self.recordAccountAccess(
1264+
address,
1265+
if (gop.value_ptr.isLoadedAsNotExisting()) null else gop.value_ptr.info,
1266+
);
10601267
}
10611268

10621269
// Journal cold account load
@@ -1125,13 +1332,10 @@ pub const JournalInner = struct {
11251332
if (skip_cold_load) {
11261333
return JournalLoadError.ColdLoadSkipped;
11271334
}
1128-
// For newly-created accounts all storage is implicitly zero. Notify
1129-
// the fallback (e.g. WitnessDatabase) so it can record the slot for
1130-
// EIP-7928 BAL tracking without performing an MPT proof lookup.
1131-
const value = if (is_newly_created) blk: {
1132-
if (@hasDecl(@TypeOf(db.*), "notifyStorageRead")) db.notifyStorageRead(address, key);
1133-
break :blk @as(primitives.StorageValue, 0);
1134-
} else try db.storage(address, key);
1335+
// For newly-created accounts all storage is implicitly zero (no DB lookup needed).
1336+
const value = if (is_newly_created) @as(primitives.StorageValue, 0) else try db.storage(address, key);
1337+
// EIP-7928: record pre-block storage value at first access.
1338+
self.recordStorageAccess(address, key, value);
11351339
try account.storage.put(key, state.EvmStorageSlot.new(value, self.transaction_id));
11361340
const is_cold = !self.warm_addresses.isStorageWarm(address, key);
11371341
if (is_cold) {
@@ -1419,46 +1623,24 @@ pub fn Journal(comptime DB: type) type {
14191623
return self.inner.isStorageCold(address, key);
14201624
}
14211625

1422-
/// Un-record a pending address access in the database fallback.
1423-
/// Called when a CALL loaded an address for gas calculation but went OOG.
1626+
/// Mark an address as an OOG phantom — loaded for gas calc but operation went OOG.
14241627
pub fn untrackAddress(self: *@This(), address: primitives.Address) void {
1425-
if (comptime @hasDecl(DB, "untrackAddress")) self.getDbMut().untrackAddress(address);
1628+
self.inner.untrackAddress(address);
14261629
}
14271630

1428-
/// Force-add an address to the current-tx access log in the database fallback.
1429-
/// Used for EIP-7702 delegation targets that execute but are not in the witness.
1631+
/// Force-add an address to the current-tx access log (EIP-7702 delegation targets).
14301632
pub fn forceTrackAddress(self: *@This(), address: primitives.Address) void {
1431-
if (comptime @hasDecl(DB, "forceTrackAddress")) self.getDbMut().forceTrackAddress(address);
1432-
}
1433-
1434-
/// Notify the DB that a new call frame is starting (EIP-7928 BAL tracking).
1435-
pub fn snapshotFrame(self: *@This()) void {
1436-
if (comptime @hasDecl(DB, "snapshotFrame")) self.getDbMut().snapshotFrame();
1437-
}
1438-
1439-
/// Commit the current call frame's accesses to the parent frame (EIP-7928).
1440-
pub fn commitFrame(self: *@This()) void {
1441-
if (comptime @hasDecl(DB, "commitFrame")) self.getDbMut().commitFrame();
1442-
}
1443-
1444-
/// Revert the current call frame's accesses (EIP-7928, on revert/OOG).
1445-
pub fn revertFrame(self: *@This()) void {
1446-
if (comptime @hasDecl(DB, "revertFrame")) self.getDbMut().revertFrame();
1447-
}
1448-
1449-
/// Commit all tracked accesses for this transaction to the block-level BAL.
1450-
pub fn commitTracking(self: *@This()) void {
1451-
if (comptime @hasDecl(DB, "commitTracking")) self.getDbMut().commitTracking();
1633+
self.inner.forceTrackAddress(address);
14521634
}
14531635

1454-
/// Discard all tracked accesses for this transaction (on tx revert/failure).
1455-
pub fn discardTracking(self: *@This()) void {
1456-
if (comptime @hasDecl(DB, "discardTracking")) self.getDbMut().discardTracking();
1636+
/// Returns true if the address is legitimately tracked (not an OOG phantom).
1637+
pub fn isTrackedAddress(self: *const @This(), address: primitives.Address) bool {
1638+
return self.inner.isTrackedAddress(address);
14571639
}
14581640

1459-
/// Notify the DB that a storage slot was committed (EIP-7928 write tracking).
1460-
pub fn notifyStorageSlotCommit(self: *@This(), addr: primitives.Address, key: primitives.StorageKey, val: primitives.StorageValue) void {
1461-
if (comptime @hasDecl(DB, "notifyStorageSlotCommit")) self.getDbMut().notifyStorageSlotCommit(addr, key, val);
1641+
/// Drain the accumulated Block Access Log for EIP-7928 validation.
1642+
pub fn takeAccessLog(self: *@This()) AccessLog {
1643+
return self.inner.takeAccessLog();
14621644
}
14631645

14641646
/// Returns true if the address has any non-zero storage in the DB.

src/context/main.zig

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ pub const AccountInfoLoad = @import("journal.zig").AccountInfoLoad;
2121
pub const SStoreResult = @import("journal.zig").SStoreResult;
2222
pub const SelfDestructResult = @import("journal.zig").SelfDestructResult;
2323
pub const TransferError = @import("journal.zig").TransferError;
24+
pub const AccountPreState = @import("journal.zig").AccountPreState;
25+
pub const AccessLog = @import("journal.zig").AccessLog;
2426
pub const ContextError = @import("context.zig").ContextError;
2527
pub const LocalContext = @import("local.zig").LocalContext;
2628
pub const Context = @import("context.zig").Context;

0 commit comments

Comments
 (0)