@@ -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
5685pub 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.
0 commit comments