diff --git a/src/context/journal.zig b/src/context/journal.zig index 1c63327..793da06 100644 --- a/src/context/journal.zig +++ b/src/context/journal.zig @@ -52,6 +52,35 @@ pub const JournalEntryFactory = struct { } }; +/// Pre-block account state snapshot for EIP-7928 BAL tracking. +pub const AccountPreState = struct { + nonce: u64 = 0, + balance: primitives.U256 = @as(primitives.U256, 0), + code_hash: primitives.Hash = primitives.KECCAK_EMPTY, +}; + +/// Block Access List log produced after all txs complete. +/// Ownership of the maps is transferred by `JournalInner.takeAccessLog()`. +pub const AccessLog = struct { + /// Pre-block account states (nonce, balance, code_hash) for all accessed addresses. + accounts: std.AutoHashMap(primitives.Address, AccountPreState), + /// Pre-block storage values for all accessed slots. + storage: std.AutoHashMap(primitives.Address, std.AutoHashMap(primitives.StorageKey, primitives.StorageValue)), + /// Slots that were committed to a value different from the pre-block value at any tx boundary. + /// Used to distinguish storageChanges from storageReads for cross-tx net-zero writes. + committed_changed: std.AutoHashMap(primitives.Address, std.AutoHashMap(primitives.StorageKey, void)), + + pub fn deinit(self: *@This()) void { + self.accounts.deinit(); + var sit = self.storage.valueIterator(); + while (sit.next()) |m| m.deinit(); + self.storage.deinit(); + var cit = self.committed_changed.valueIterator(); + while (cit.next()) |m| m.deinit(); + self.committed_changed.deinit(); + } +}; + /// Selfdestruction revert status pub const SelfdestructionRevertStatus = enum { GloballySelfdestroyed, @@ -403,6 +432,17 @@ pub const JournalInner = struct { /// Emitted as Burn logs at postExecution, sorted by address. Checkpointed via burn_i. pending_burns: std.ArrayList(PendingBurn), + // ── EIP-7928 BAL tracking ────────────────────────────────────────────────── + // Permanently committed account pre-states (survive across txs in a block). + bal_pre_accounts: std.AutoHashMap(primitives.Address, AccountPreState), + // Permanently committed storage pre-states. + bal_pre_storage: std.AutoHashMap(primitives.Address, std.AutoHashMap(primitives.StorageKey, primitives.StorageValue)), + // Per-tx staging: flushed on commitTx, cleared on discardTx. + bal_pending_accounts: std.AutoHashMap(primitives.Address, AccountPreState), + bal_pending_storage: std.AutoHashMap(primitives.Address, std.AutoHashMap(primitives.StorageKey, primitives.StorageValue)), + // Slots committed to a non-pre-block value at any tx boundary. + bal_committed_changed: std.AutoHashMap(primitives.Address, std.AutoHashMap(primitives.StorageKey, void)), + pub fn new() JournalInner { return .{ .evm_state = state.EvmState.init(alloc_mod.get()), @@ -413,6 +453,11 @@ pub const JournalInner = struct { .spec = primitives.SpecId.prague, .warm_addresses = WarmAddresses.new(), .pending_burns = std.ArrayList(PendingBurn){}, + .bal_pre_accounts = std.AutoHashMap(primitives.Address, AccountPreState).init(alloc_mod.get()), + .bal_pre_storage = std.AutoHashMap(primitives.Address, std.AutoHashMap(primitives.StorageKey, primitives.StorageValue)).init(alloc_mod.get()), + .bal_pending_accounts = std.AutoHashMap(primitives.Address, AccountPreState).init(alloc_mod.get()), + .bal_pending_storage = std.AutoHashMap(primitives.Address, std.AutoHashMap(primitives.StorageKey, primitives.StorageValue)).init(alloc_mod.get()), + .bal_committed_changed = std.AutoHashMap(primitives.Address, std.AutoHashMap(primitives.StorageKey, void)).init(alloc_mod.get()), }; } @@ -424,6 +469,91 @@ pub const JournalInner = struct { self.journal.deinit(alloc_mod.get()); self.warm_addresses.deinit(); self.pending_burns.deinit(alloc_mod.get()); + self.bal_pre_accounts.deinit(); + var pre_sit = self.bal_pre_storage.valueIterator(); + while (pre_sit.next()) |m| m.deinit(); + self.bal_pre_storage.deinit(); + self.bal_pending_accounts.deinit(); + var pend_sit = self.bal_pending_storage.valueIterator(); + while (pend_sit.next()) |m| m.deinit(); + self.bal_pending_storage.deinit(); + var cc_it = self.bal_committed_changed.valueIterator(); + while (cc_it.next()) |m| m.deinit(); + self.bal_committed_changed.deinit(); + } + + // ── EIP-7928 BAL tracking helpers ───────────────────────────────────────── + + /// Record an account at its pre-block state (null = non-existent account). + /// Uses first-access-wins: if the address is already in pre or pending, skip. + fn recordAccountAccess(self: *JournalInner, address: primitives.Address, info: ?state.AccountInfo) void { + if (self.bal_pre_accounts.contains(address) or self.bal_pending_accounts.contains(address)) return; + const pre: AccountPreState = if (info) |i| .{ + .nonce = i.nonce, + .balance = i.balance, + .code_hash = i.code_hash, + } else .{}; + self.bal_pending_accounts.put(address, pre) catch {}; + } + + /// Record a storage slot at its pre-block value. + /// Uses first-access-wins per slot. + fn recordStorageAccess(self: *JournalInner, address: primitives.Address, key: primitives.StorageKey, value: primitives.StorageValue) void { + // Check permanent pre_storage first + if (self.bal_pre_storage.get(address)) |slots| { + if (slots.contains(key)) return; + } + // Check pending + if (self.bal_pending_storage.get(address)) |slots| { + if (slots.contains(key)) return; + } + const gop = self.bal_pending_storage.getOrPut(address) catch return; + if (!gop.found_existing) gop.value_ptr.* = std.AutoHashMap(primitives.StorageKey, primitives.StorageValue).init(alloc_mod.get()); + gop.value_ptr.put(key, value) catch {}; + } + + /// Returns true if the address has been accessed (appears in the BAL). + /// Phantom accesses are prevented at the opcode level, so all tracked addresses are legitimate. + pub fn isTrackedAddress(self: *const JournalInner, address: primitives.Address) bool { + return self.bal_pre_accounts.contains(address) or self.bal_pending_accounts.contains(address); + } + + /// Drain the accumulated Block Access Log. Flushes any remaining pending state + /// into the permanent maps and transfers ownership to the caller. + pub fn takeAccessLog(self: *JournalInner) AccessLog { + // Flush remaining pending (e.g. if called after last tx without commitTx) + var pa_it = self.bal_pending_accounts.iterator(); + while (pa_it.next()) |e| { + if (!self.bal_pre_accounts.contains(e.key_ptr.*)) { + self.bal_pre_accounts.put(e.key_ptr.*, e.value_ptr.*) catch {}; + } + } + self.bal_pending_accounts.clearRetainingCapacity(); + + var ps_it = self.bal_pending_storage.iterator(); + while (ps_it.next()) |e| { + const addr = e.key_ptr.*; + var slot_it = e.value_ptr.iterator(); + while (slot_it.next()) |s| { + const pre_gop = self.bal_pre_storage.getOrPut(addr) catch continue; + if (!pre_gop.found_existing) pre_gop.value_ptr.* = std.AutoHashMap(primitives.StorageKey, primitives.StorageValue).init(alloc_mod.get()); + if (!pre_gop.value_ptr.contains(s.key_ptr.*)) { + pre_gop.value_ptr.put(s.key_ptr.*, s.value_ptr.*) catch {}; + } + } + e.value_ptr.deinit(); + } + self.bal_pending_storage.clearRetainingCapacity(); + + const log = AccessLog{ + .accounts = self.bal_pre_accounts, + .storage = self.bal_pre_storage, + .committed_changed = self.bal_committed_changed, + }; + self.bal_pre_accounts = std.AutoHashMap(primitives.Address, AccountPreState).init(alloc_mod.get()); + self.bal_pre_storage = std.AutoHashMap(primitives.Address, std.AutoHashMap(primitives.StorageKey, primitives.StorageValue)).init(alloc_mod.get()); + self.bal_committed_changed = std.AutoHashMap(primitives.Address, std.AutoHashMap(primitives.StorageKey, void)).init(alloc_mod.get()); + return log; } /// Returns the logs @@ -441,6 +571,52 @@ pub const JournalInner = struct { /// /// `commit_tx` is used even for discarding transactions so transaction_id will be incremented. pub fn commitTx(self: *JournalInner) void { + // EIP-7928: record committed-changed storage BEFORE resetting original_value. + // Slots where present != original were dirty at this tx boundary; flag them so + // cross-tx net-zero writes are classified as storageChanges, not storageReads. + // Skip accounts created AND selfdestructed in the same tx (net zero effect). + { + var state_it = self.evm_state.iterator(); + while (state_it.next()) |entry| { + if (entry.value_ptr.status.created and entry.value_ptr.status.self_destructed) continue; + var slot_it = entry.value_ptr.storage.iterator(); + while (slot_it.next()) |slot| { + if (slot.value_ptr.present_value != slot.value_ptr.original_value) { + const addr = entry.key_ptr.*; + const gop = self.bal_committed_changed.getOrPut(addr) catch continue; + if (!gop.found_existing) gop.value_ptr.* = std.AutoHashMap(primitives.StorageKey, void).init(alloc_mod.get()); + gop.value_ptr.put(slot.key_ptr.*, {}) catch {}; + } + } + } + } + + // EIP-7928: flush per-tx pending into permanent pre-state maps (first-access-wins). + { + var pa_it = self.bal_pending_accounts.iterator(); + while (pa_it.next()) |e| { + if (!self.bal_pre_accounts.contains(e.key_ptr.*)) { + self.bal_pre_accounts.put(e.key_ptr.*, e.value_ptr.*) catch {}; + } + } + self.bal_pending_accounts.clearRetainingCapacity(); + + var ps_it = self.bal_pending_storage.iterator(); + while (ps_it.next()) |e| { + const addr = e.key_ptr.*; + var slot_it = e.value_ptr.iterator(); + while (slot_it.next()) |s| { + const pre_gop = self.bal_pre_storage.getOrPut(addr) catch continue; + if (!pre_gop.found_existing) pre_gop.value_ptr.* = std.AutoHashMap(primitives.StorageKey, primitives.StorageValue).init(alloc_mod.get()); + if (!pre_gop.value_ptr.contains(s.key_ptr.*)) { + pre_gop.value_ptr.put(s.key_ptr.*, s.value_ptr.*) catch {}; + } + } + e.value_ptr.deinit(); + } + self.bal_pending_storage.clearRetainingCapacity(); + } + // Per EIP-2200/EIP-3529: after committing a transaction, the "original value" // of each storage slot becomes the committed (present) value. Without this, // subsequent transactions in the same block would incorrectly treat slots as @@ -484,6 +660,11 @@ pub const JournalInner = struct { self.pending_burns.clearRetainingCapacity(); self.transaction_id += 1; self.warm_addresses.clearCoinbaseAndAccessList(); + // EIP-7928: discard per-tx pending tracking; pre_* survives (from prior txs). + self.bal_pending_accounts.clearRetainingCapacity(); + var ps_it = self.bal_pending_storage.valueIterator(); + while (ps_it.next()) |m| m.deinit(); + self.bal_pending_storage.clearRetainingCapacity(); } /// Take the [`EvmState`] and clears the journal by resetting it to initial state. @@ -984,14 +1165,9 @@ pub const JournalInner = struct { pub fn isAddressCold(self: *const JournalInner, address: primitives.Address) bool { if (self.evm_state.get(address)) |acct| { if (acct.isColdTransactionId(self.transaction_id)) { - // EIP-3651: coinbase is pre-warmed each tx. If the coinbase was loaded in - // a previous tx (old transaction_id), still treat it as warm for gas purposes. - // Only check the coinbase slot — not precompiles or access-list entries, to - // avoid unintended cross-tx warm promotion for those. - if (self.warm_addresses.coinbase) |cb| { - if (std.mem.eql(u8, &address, &cb)) return false; - } - return true; + // Account was loaded in a prior tx — defer to warm_addresses which covers + // EIP-3651 coinbase, precompiles, and EIP-2930 access-list entries. + return self.warm_addresses.isCold(address); } return false; } @@ -1057,6 +1233,11 @@ pub const JournalInner = struct { gop.value_ptr.* = new_account; is_cold = acct_is_cold; account_ptr = gop.value_ptr; + // EIP-7928: record pre-block account state at first load (using already-fetched info). + self.recordAccountAccess( + address, + if (gop.value_ptr.isLoadedAsNotExisting()) null else gop.value_ptr.info, + ); } // Journal cold account load @@ -1125,13 +1306,10 @@ pub const JournalInner = struct { if (skip_cold_load) { return JournalLoadError.ColdLoadSkipped; } - // For newly-created accounts all storage is implicitly zero. Notify - // the fallback (e.g. WitnessDatabase) so it can record the slot for - // EIP-7928 BAL tracking without performing an MPT proof lookup. - const value = if (is_newly_created) blk: { - if (@hasDecl(@TypeOf(db.*), "notifyStorageRead")) db.notifyStorageRead(address, key); - break :blk @as(primitives.StorageValue, 0); - } else try db.storage(address, key); + // For newly-created accounts all storage is implicitly zero (no DB lookup needed). + const value = if (is_newly_created) @as(primitives.StorageValue, 0) else try db.storage(address, key); + // EIP-7928: record pre-block storage value at first access. + self.recordStorageAccess(address, key, value); try account.storage.put(key, state.EvmStorageSlot.new(value, self.transaction_id)); const is_cold = !self.warm_addresses.isStorageWarm(address, key); if (is_cold) { @@ -1419,46 +1597,14 @@ pub fn Journal(comptime DB: type) type { return self.inner.isStorageCold(address, key); } - /// Un-record a pending address access in the database fallback. - /// Called when a CALL loaded an address for gas calculation but went OOG. - pub fn untrackAddress(self: *@This(), address: primitives.Address) void { - if (comptime @hasDecl(DB, "untrackAddress")) self.getDbMut().untrackAddress(address); - } - - /// Force-add an address to the current-tx access log in the database fallback. - /// Used for EIP-7702 delegation targets that execute but are not in the witness. - pub fn forceTrackAddress(self: *@This(), address: primitives.Address) void { - if (comptime @hasDecl(DB, "forceTrackAddress")) self.getDbMut().forceTrackAddress(address); - } - - /// Notify the DB that a new call frame is starting (EIP-7928 BAL tracking). - pub fn snapshotFrame(self: *@This()) void { - if (comptime @hasDecl(DB, "snapshotFrame")) self.getDbMut().snapshotFrame(); - } - - /// Commit the current call frame's accesses to the parent frame (EIP-7928). - pub fn commitFrame(self: *@This()) void { - if (comptime @hasDecl(DB, "commitFrame")) self.getDbMut().commitFrame(); - } - - /// Revert the current call frame's accesses (EIP-7928, on revert/OOG). - pub fn revertFrame(self: *@This()) void { - if (comptime @hasDecl(DB, "revertFrame")) self.getDbMut().revertFrame(); - } - - /// Commit all tracked accesses for this transaction to the block-level BAL. - pub fn commitTracking(self: *@This()) void { - if (comptime @hasDecl(DB, "commitTracking")) self.getDbMut().commitTracking(); - } - - /// Discard all tracked accesses for this transaction (on tx revert/failure). - pub fn discardTracking(self: *@This()) void { - if (comptime @hasDecl(DB, "discardTracking")) self.getDbMut().discardTracking(); + /// Returns true if the address has been accessed (appears in the BAL). + pub fn isTrackedAddress(self: *const @This(), address: primitives.Address) bool { + return self.inner.isTrackedAddress(address); } - /// Notify the DB that a storage slot was committed (EIP-7928 write tracking). - pub fn notifyStorageSlotCommit(self: *@This(), addr: primitives.Address, key: primitives.StorageKey, val: primitives.StorageValue) void { - if (comptime @hasDecl(DB, "notifyStorageSlotCommit")) self.getDbMut().notifyStorageSlotCommit(addr, key, val); + /// Drain the accumulated Block Access Log for EIP-7928 validation. + pub fn takeAccessLog(self: *@This()) AccessLog { + return self.inner.takeAccessLog(); } /// Returns true if the address has any non-zero storage in the DB. diff --git a/src/context/main.zig b/src/context/main.zig index 28dcbe7..843669c 100644 --- a/src/context/main.zig +++ b/src/context/main.zig @@ -21,6 +21,8 @@ pub const AccountInfoLoad = @import("journal.zig").AccountInfoLoad; pub const SStoreResult = @import("journal.zig").SStoreResult; pub const SelfDestructResult = @import("journal.zig").SelfDestructResult; pub const TransferError = @import("journal.zig").TransferError; +pub const AccountPreState = @import("journal.zig").AccountPreState; +pub const AccessLog = @import("journal.zig").AccessLog; pub const ContextError = @import("context.zig").ContextError; pub const LocalContext = @import("local.zig").LocalContext; pub const Context = @import("context.zig").Context; diff --git a/src/handler/mainnet_builder.zig b/src/handler/mainnet_builder.zig index f2d4980..d8304f1 100644 --- a/src/handler/mainnet_builder.zig +++ b/src/handler/mainnet_builder.zig @@ -358,15 +358,12 @@ pub const MainnetHandler = struct { // Take checkpoint for top-level CALL: state is reverted through this on failure. const call_checkpoint = ctx.journaled_state.getCheckpoint(); - // Notify db fallback that a new frame has opened (checkpoint-aware tracking). - ctx.journaled_state.snapshotFrame(); // Value transfer for top-level CALL. if (tx.value > 0) { const xfer_err = try ctx.journaled_state.transfer(tx.caller, target, tx.value); if (xfer_err != null) { ctx.journaled_state.checkpointRevert(call_checkpoint); - ctx.journaled_state.revertFrame(); return main.FrameResult.new( main.ExecutionResult.new(.Fail, exec_gas), 0, @@ -388,7 +385,6 @@ pub const MainnetHandler = struct { .success => |out| { if (out.reverted) { ctx.journaled_state.checkpointRevert(call_checkpoint); - ctx.journaled_state.revertFrame(); return main.FrameResult.new( main.ExecutionResult.new(.Revert, exec_gas), 0, @@ -396,7 +392,6 @@ pub const MainnetHandler = struct { ); } ctx.journaled_state.checkpointCommit(); - ctx.journaled_state.commitFrame(); var fr = main.FrameResult.new( main.ExecutionResult.new(.Success, out.gas_used), tx_regular_exec_gas - out.gas_used, @@ -407,7 +402,6 @@ pub const MainnetHandler = struct { }, .err => { ctx.journaled_state.checkpointRevert(call_checkpoint); - ctx.journaled_state.revertFrame(); return main.FrameResult.new( main.ExecutionResult.new(.Fail, exec_gas), 0, @@ -440,10 +434,8 @@ pub const MainnetHandler = struct { if (ir.raw_result.isSuccess()) { ctx.journaled_state.checkpointCommit(); - ctx.journaled_state.commitFrame(); } else { ctx.journaled_state.checkpointRevert(call_checkpoint); - ctx.journaled_state.revertFrame(); } const status: main.ExecutionStatus = switch (ir.raw_result) { @@ -571,30 +563,9 @@ pub const MainnetHandler = struct { result.result.logs = js.takeLogs(); } - // 7. Commit transaction state - // Before committing, notify the fallback about storage slots being committed - // with a changed value. This lets WitnessDatabase track cross-tx intermediate - // writes for EIP-7928 BAL validation (storageChanges vs storageReads). - // Must be done BEFORE commitTx() resets original_value = present_value. - { - var state_it = js.inner.evm_state.iterator(); - while (state_it.next()) |entry| { - // Skip accounts that were both created AND selfdestructed in the same tx: - // their storage writes have zero net effect and should not be recorded as - // committed changes for EIP-7928 BAL (they become storageReads, not storageChanges). - if (entry.value_ptr.status.created and entry.value_ptr.status.self_destructed) continue; - var slot_it = entry.value_ptr.storage.iterator(); - while (slot_it.next()) |slot| { - if (slot.value_ptr.present_value != slot.value_ptr.original_value) { - js.notifyStorageSlotCommit(entry.key_ptr.*, slot.key_ptr.*, slot.value_ptr.present_value); - } - } - } - } + // 7. Commit transaction state. + // commitTx() records committed-changed storage and flushes per-tx BAL tracking internally. js.commitTx(); - // Notify fallback database that this transaction committed. - // WitnessDatabase uses this to flush per-tx pending tracking to the permanent access log. - js.commitTracking(); // 8. Update ExecutionResult with final accounting. // EIP-7778 (Amsterdam+): block gas does NOT deduct refunds. @@ -630,10 +601,8 @@ pub const MainnetHandler = struct { /// Handle errors — revert journal, discard tx. pub fn catchError(evm: anytype, _: anyerror) void { const ctx = evm.getContext(); - // Revert all state changes from this transaction + // Revert all state changes from this transaction (also clears per-tx BAL pending). ctx.journaled_state.discardTx(); - // Notify fallback database that this transaction was discarded. - ctx.journaled_state.discardTracking(); } }; diff --git a/src/interpreter/host.zig b/src/interpreter/host.zig index 0f64366..1190401 100644 --- a/src/interpreter/host.zig +++ b/src/interpreter/host.zig @@ -114,8 +114,6 @@ pub const JournalVTable = struct { // Simple entries — operate on the type-erased journal pointer directly. isAddressCold: *const fn (*anyopaque, primitives.Address) bool, isStorageCold: *const fn (*anyopaque, primitives.Address, primitives.StorageKey) bool, - untrackAddress: *const fn (*anyopaque, primitives.Address) void, - forceTrackAddress: *const fn (*anyopaque, primitives.Address) void, isAddressLoaded: *const fn (*anyopaque, primitives.Address) bool, accountInfo: *const fn (*anyopaque, primitives.Address) anyerror!context_mod.AccountInfoLoad, loadAccountWithCode: *const fn (*anyopaque, primitives.Address) anyerror!context_mod.StateLoad(*const state_mod.Account), @@ -139,8 +137,6 @@ pub const JournalVTable = struct { const vtable: JournalVTable = .{ .isAddressCold = isAddressColdFn, .isStorageCold = isStorageColdFn, - .untrackAddress = untrackAddressFn, - .forceTrackAddress = forceTrackAddressFn, .isAddressLoaded = isAddressLoadedFn, .accountInfo = accountInfoFn, .loadAccountWithCode = loadAccountWithCodeFn, @@ -167,12 +163,6 @@ pub const JournalVTable = struct { fn isStorageColdFn(ptr: *anyopaque, addr: primitives.Address, key: primitives.StorageKey) bool { return j(ptr).isStorageCold(addr, key); } - fn untrackAddressFn(ptr: *anyopaque, addr: primitives.Address) void { - j(ptr).untrackAddress(addr); - } - fn forceTrackAddressFn(ptr: *anyopaque, addr: primitives.Address) void { - j(ptr).forceTrackAddress(addr); - } fn isAddressLoadedFn(ptr: *anyopaque, addr: primitives.Address) bool { return j(ptr).isAddressLoaded(addr); } @@ -345,16 +335,6 @@ pub const Host = struct { return self.js_vtable.isStorageCold(self.js, addr, key); } - /// Un-record a pending address access in the database fallback. - pub fn untrackAddress(self: *Host, addr: primitives.Address) void { - self.js_vtable.untrackAddress(self.js, addr); - } - - /// Force-add an address to the current-tx access log in the database fallback. - pub fn forceTrackAddress(self: *Host, addr: primitives.Address) void { - self.js_vtable.forceTrackAddress(self.js, addr); - } - /// Check whether an address is already in the EVM state cache. pub fn isAddressLoaded(self: *const Host, addr: primitives.Address) bool { return self.js_vtable.isAddressLoaded(@constCast(self.js), addr); @@ -680,17 +660,14 @@ fn setupCallCore(js: anytype, host: *Host, inputs: CallInputs, frame_depth: usiz // 4. Checkpoint const checkpoint = js.getCheckpoint(); - js.snapshotFrame(); // 5. Value transfer if (inputs.value > 0 and inputs.scheme != .delegatecall) { const transfer_err = js.transfer(inputs.caller, inputs.target, inputs.value) catch { - js.revertFrame(); js.checkpointRevert(checkpoint); return .{ .failed = CallResult.preExecFailure(inputs.gas_limit) }; }; if (transfer_err != null) { - js.revertFrame(); js.checkpointRevert(checkpoint); return .{ .failed = CallResult.preExecFailure(inputs.gas_limit) }; } @@ -709,10 +686,8 @@ fn setupCallCore(js: anytype, host: *Host, inputs: CallInputs, frame_depth: usiz fn finalizeCallCore(js: anytype, checkpoint: JournalCheckpoint, result: InstructionResult, gas_limit: u64, gas_remaining: u64, gas_refunded: i64, return_data: []const u8) CallResult { if (result.isSuccess()) { js.checkpointCommit(); - js.commitFrame(); } else { js.checkpointRevert(checkpoint); - js.revertFrame(); } const gas_rem: u64 = if (result.isSuccess() or result == .revert) gas_remaining else 0; const gas_used = if (gas_limit > gas_rem) gas_limit - gas_rem else 0; @@ -785,28 +760,22 @@ fn setupCreateCore( break :blk create2Address(caller, salt, init_hash); } else createAddress(caller, caller_nonce); - _ = js.loadAccount(new_addr) catch return .{ .failed = CreateResult.preExecFailure(gas_limit) }; - const new_addr_was_nonexistent = if (js.inner.evm_state.get(new_addr)) |na| - na.status.loaded_as_not_existing - else - true; - - // Balance check. - if (value > 0) { - const caller_acct = js.inner.evm_state.getPtr(caller) orelse { - if (is_opcode_create and primitives.isEnabledIn(spec_id, .amsterdam)) - js.checkpointRevert(pre_bump_checkpoint); - if (new_addr_was_nonexistent) js.untrackAddress(new_addr); + // Balance check BEFORE loading new_addr to avoid phantom BAL entries. + // Pre-Amsterdam already checked balance before the nonce bump (above); this handles + // Amsterdam (checked after nonce bump) without needing an untrackAddress on failure. + if (primitives.isEnabledIn(spec_id, .amsterdam) and value > 0) { + const ca = js.inner.evm_state.getPtr(caller) orelse { + if (is_opcode_create) js.checkpointRevert(pre_bump_checkpoint); return .{ .failed = CreateResult.preExecFailure(gas_limit) }; }; - if (caller_acct.info.balance < value) { - if (is_opcode_create and primitives.isEnabledIn(spec_id, .amsterdam)) - js.checkpointRevert(pre_bump_checkpoint); - if (new_addr_was_nonexistent) js.untrackAddress(new_addr); + if (ca.info.balance < value) { + if (is_opcode_create) js.checkpointRevert(pre_bump_checkpoint); return .{ .failed = CreateResult.preExecFailure(gas_limit) }; } } + _ = js.loadAccount(new_addr) catch return .{ .failed = CreateResult.preExecFailure(gas_limit) }; + // Address collision: storage already exists at the target address. if (js.inner.evm_state.get(new_addr)) |acct| { var slot_it = acct.storage.valueIterator(); @@ -828,7 +797,6 @@ fn setupCreateCore( const checkpoint = js.createAccountCheckpoint(caller, new_addr, value, spec_id) catch { return .{ .failed = CreateResult.failure() }; }; - js.snapshotFrame(); // EIP-7708 (Amsterdam+): emit Transfer log for ETH sent to the new contract. if (value > 0 and primitives.isEnabledIn(spec_id, .amsterdam)) { @@ -856,7 +824,6 @@ fn finalizeCreateCore( if (!result.isSuccess()) { js.checkpointRevert(checkpoint); - js.revertFrame(); const gas_rem = if (result == .revert) gas_remaining else @as(u64, 0); const rd = if (result == .revert) return_data else &[_]u8{}; return .{ .success = false, .is_revert = (result == .revert), .address = [_]u8{0} ** 20, .gas_remaining = gas_rem, .return_data = rd, .gas_refunded = 0, .state_gas_used = 0, .state_gas_remaining = gas_reservoir }; @@ -865,13 +832,11 @@ fn finalizeCreateCore( const deployed_raw = return_data; if (deployed_raw.len > MAX_CODE_SIZE) { js.checkpointRevert(checkpoint); - js.revertFrame(); return .{ .success = false, .is_revert = false, .address = [_]u8{0} ** 20, .gas_remaining = 0, .return_data = &[_]u8{}, .gas_refunded = 0, .state_gas_used = 0, .state_gas_remaining = gas_reservoir }; } if (primitives.isEnabledIn(spec_id, .london)) { if (deployed_raw.len > 0 and deployed_raw[0] == 0xEF) { js.checkpointRevert(checkpoint); - js.revertFrame(); return .{ .success = false, .is_revert = false, .address = [_]u8{0} ** 20, .gas_remaining = 0, .return_data = &[_]u8{}, .gas_refunded = 0, .state_gas_used = 0, .state_gas_remaining = gas_reservoir }; } } @@ -884,7 +849,6 @@ fn finalizeCreateCore( const regular_deposit = gas_costs.G_KECCAK256WORD * @as(u64, code_words); if (gas_remaining < regular_deposit) { js.checkpointRevert(checkpoint); - js.revertFrame(); return .{ .success = false, .is_revert = false, .address = [_]u8{0} ** 20, .gas_remaining = 0, .return_data = &[_]u8{}, .gas_refunded = 0, .state_gas_used = 0, .state_gas_remaining = remaining_reservoir }; } var gas_after_regular = gas_remaining - regular_deposit; @@ -899,7 +863,6 @@ fn finalizeCreateCore( gas_after_regular -= spill; } else { js.checkpointRevert(checkpoint); - js.revertFrame(); return .{ .success = false, .is_revert = false, .address = [_]u8{0} ** 20, .gas_remaining = 0, .return_data = &[_]u8{}, .gas_refunded = 0, .state_gas_used = 0, .state_gas_remaining = remaining_reservoir }; } } @@ -910,11 +873,9 @@ fn finalizeCreateCore( if (gas_remaining < deposit_cost) { if (primitives.isEnabledIn(spec_id, .homestead)) { js.checkpointRevert(checkpoint); - js.revertFrame(); return CreateResult.failure(); } else { js.checkpointCommit(); - js.commitFrame(); return .{ .success = true, .is_revert = false, .address = new_addr, .gas_remaining = gas_remaining, .return_data = &[_]u8{}, .gas_refunded = gas_refunded, .state_gas_used = 0, .state_gas_remaining = 0 }; } } @@ -924,7 +885,6 @@ fn finalizeCreateCore( if (deployed_raw.len > 0) { const deployed_copy = alloc_mod.get().dupe(u8, deployed_raw) catch { js.checkpointRevert(checkpoint); - js.revertFrame(); return CreateResult.failure(); }; var code_hash: [32]u8 = undefined; @@ -934,7 +894,6 @@ fn finalizeCreateCore( } js.checkpointCommit(); - js.commitFrame(); return .{ .success = true, .is_revert = false, .address = new_addr, .gas_remaining = gas_after_deposit, .return_data = &[_]u8{}, .gas_refunded = gas_refunded, .state_gas_used = code_deposit_state_gas, .state_gas_remaining = remaining_reservoir }; } diff --git a/src/interpreter/opcodes/call.zig b/src/interpreter/opcodes/call.zig index bb83645..362df2a 100644 --- a/src/interpreter/opcodes/call.zig +++ b/src/interpreter/opcodes/call.zig @@ -132,13 +132,17 @@ fn callImpl( } } - // Pre-check: if Berlin+, ensure there's enough gas for the access cost before loading - // the callee. This avoids a DB read when the CALL itself runs OOG before the callee - // is accessed, which would incorrectly add the callee to the EIP-7928 BAL. const pre_is_cold = h.isAddressCold(target_addr); - if (primitives.isEnabledIn(spec, .berlin)) { - const access_cost: u64 = if (pre_is_cold) gas_costs.COLD_ACCOUNT_ACCESS else gas_costs.WARM_ACCOUNT_ACCESS; - if (ctx.interpreter.gas.remaining < access_cost) { + // transfers_value only depends on the stack value — no account load needed. + const transfers_value = has_value and value > 0; + // Worst-case pre-check (assume account non-existent) before any DB load. + // getCallGasCost(..., false) >= exact cost, so if this passes the account can never + // become a phantom BAL entry (the exact-cost check below can never OOG). + // Gated on Amsterdam: pre-Amsterdam has no BAL and G_NEWACCOUNT makes the worst-case + // overly conservative for existing accounts, causing false OOG. + if (primitives.isEnabledIn(spec, .amsterdam)) { + const worst_case = gas_costs.getCallGasCost(spec, pre_is_cold, transfers_value, false); + if (ctx.interpreter.gas.remaining < worst_case) { ctx.interpreter.halt(.out_of_gas); return; } @@ -147,7 +151,6 @@ fn callImpl( // Load target account info to determine warm/cold access and whether the account exists. const acct_info = h.accountInfo(target_addr); const is_cold = if (acct_info) |info| info.is_cold else pre_is_cold; - const transfers_value = has_value and value > 0; // G_NEWACCOUNT applies to the ETH *recipient*, not the code source. // For CALLCODE/DELEGATECALL, ETH goes to self (always exists). Otherwise ETH goes to target_addr. const account_exists = switch (scheme) { @@ -175,14 +178,8 @@ fn callImpl( const base_cost = call_cost_no_delegation + delegation_gas; // Determine forwarded gas (EIP-150 introduces 63/64 rule; pre-EIP-150 uses all remaining). + // worst_case >= call_cost_no_delegation, so the pre-check above guarantees remaining >= it. const remaining = ctx.interpreter.gas.remaining; - if (remaining < call_cost_no_delegation) { - // OOG before the target was "accessed" in EIP-7928 terms (can't pay transfer/new-account). - // Per EELS: target is not yet in accessed_addresses at this point → untrack it. - h.untrackAddress(target_addr); - ctx.interpreter.halt(.out_of_gas); - return; - } if (remaining < base_cost) { // OOG after target access but before delegation (oog_after_target_access / // oog_success_minus_1). Per EELS second check_gas: target IS in BAL, delegation NOT loaded. diff --git a/src/interpreter/opcodes/host_ops.zig b/src/interpreter/opcodes/host_ops.zig index 65b3d7d..6deb221 100644 --- a/src/interpreter/opcodes/host_ops.zig +++ b/src/interpreter/opcodes/host_ops.zig @@ -153,7 +153,10 @@ pub fn opExtcodecopy(ctx: *InstructionContext) void { const addr = host_module.u256ToAddress(addr_val); - // Post-Berlin: charge dynamic warm/cold cost BEFORE loading the code. + // Charge all gas (warm/cold + copy + memory expansion) BEFORE loading the code. + // This eliminates phantom BAL entries: the account is only loaded if gas succeeds. + + // Post-Berlin: charge dynamic warm/cold cost. if (primitives.isEnabledIn(ctx.interpreter.runtime_flags.spec_id, .berlin)) { const dyn_gas: u64 = if (h.isAddressCold(addr)) gas_costs.COLD_ACCOUNT_ACCESS else gas_costs.WARM_ACCOUNT_ACCESS; if (!ctx.interpreter.gas.spend(dyn_gas)) { @@ -162,61 +165,44 @@ pub fn opExtcodecopy(ctx: *InstructionContext) void { } } - const info = h.codeInfo(addr) orelse { - // Address doesn't exist; still need to expand memory and pay copy cost - if (size == 0) return; + // Charge copy cost and expand memory (gas inputs depend only on stack values, not code content). + const mem_off_u: usize = blk: { + if (size == 0) break :blk 0; if (mem_off > std.math.maxInt(usize) or size > std.math.maxInt(usize)) { ctx.interpreter.halt(.memory_limit_oog); return; } - const mem_off_u: usize = @intCast(mem_off); - const size_u: usize = @intCast(size); - const num_words = std.math.divCeil(usize, size_u, 32) catch unreachable; + const mo: usize = @intCast(mem_off); + const sz: usize = @intCast(size); + const new_size = std.math.add(usize, mo, sz) catch { + ctx.interpreter.halt(.memory_limit_oog); + return; + }; + // Dynamic: copy cost — use divCeil to avoid (size + 31) overflow when size = maxInt(usize) + const num_words = std.math.divCeil(usize, sz, 32) catch unreachable; if (!ctx.interpreter.gas.spend(gas_costs.G_COPY * @as(u64, @intCast(num_words)))) { ctx.interpreter.halt(.out_of_gas); return; } - const end_off = std.math.add(usize, mem_off_u, size_u) catch { - ctx.interpreter.halt(.memory_limit_oog); - return; - }; - if (!expandMemory(ctx, end_off)) { + if (!expandMemory(ctx, new_size)) { ctx.interpreter.halt(.out_of_gas); return; } - @memset(ctx.interpreter.memory.buffer.items[mem_off_u..end_off], 0); - return; + break :blk mo; }; - if (size == 0) return; - - if (mem_off > std.math.maxInt(usize) or size > std.math.maxInt(usize)) { - h.untrackAddress(addr); - ctx.interpreter.halt(.memory_limit_oog); - return; - } - - const mem_off_u: usize = @intCast(mem_off); - const size_u: usize = @intCast(size); - const new_size = std.math.add(usize, mem_off_u, size_u) catch { - h.untrackAddress(addr); - ctx.interpreter.halt(.memory_limit_oog); + const info = h.codeInfo(addr) orelse { + // Address doesn't exist: gas and memory already handled above. + if (size == 0) return; + const size_u: usize = @intCast(size); + @memset(ctx.interpreter.memory.buffer.items[mem_off_u .. mem_off_u + size_u], 0); return; }; - // Dynamic: copy cost — use divCeil to avoid (size + 31) overflow when size = maxInt(usize) - const num_words = std.math.divCeil(usize, size_u, 32) catch unreachable; - if (!ctx.interpreter.gas.spend(gas_costs.G_COPY * @as(u64, @intCast(num_words)))) { - h.untrackAddress(addr); - ctx.interpreter.halt(.out_of_gas); - return; - } + if (size == 0) return; - if (!expandMemory(ctx, new_size)) { - h.untrackAddress(addr); - ctx.interpreter.halt(.out_of_gas); - return; - } + const size_u: usize = @intCast(size); + const new_size = mem_off_u + size_u; // valid: overflow already checked above const code = info.bytecode.bytecode(); const dest = ctx.interpreter.memory.buffer.items[mem_off_u..new_size]; @@ -605,6 +591,20 @@ pub fn opSelfdestruct(ctx: *InstructionContext) void { const self_addr = ctx.interpreter.input.target; const spec = ctx.interpreter.runtime_flags.spec_id; + // Worst-case pre-check before loading the target account. + // Assumes cold access (if warm, actual cost is lower) and new account with value (worst case + // for G_NEWACCOUNT). If this passes, the exact dyn_gas is <= max_dyn_gas so gas.spend() below + // always succeeds and the target is never a phantom BAL entry. + const pre_is_cold = h.isAddressCold(target); + var max_dyn_gas: u64 = if (primitives.isEnabledIn(spec, .berlin) and pre_is_cold) gas_costs.COLD_ACCOUNT_ACCESS else 0; + if (primitives.isEnabledIn(spec, .tangerine) and !primitives.isEnabledIn(spec, .amsterdam)) { + max_dyn_gas += 25000; // worst-case G_NEWACCOUNT (Amsterdam replaces with state gas) + } + if (ctx.interpreter.gas.remaining < max_dyn_gas) { + ctx.interpreter.halt(.out_of_gas); + return; + } + const result = h.selfdestruct(self_addr, target) orelse { ctx.interpreter.halt(.invalid_opcode); return; @@ -631,14 +631,8 @@ pub fn opSelfdestruct(ctx: *InstructionContext) void { dyn_gas += 25000; } - // regular gas before state gas. - if (!ctx.interpreter.gas.spend(dyn_gas)) { - // Untrack the beneficiary: it was loaded for gas calculation but SELFDESTRUCT - // never completed (OOG). EIP-7928 BAL should not include it. - h.untrackAddress(target); - ctx.interpreter.halt(.out_of_gas); - return; - } + // dyn_gas <= max_dyn_gas (pre-check passed) — spend always succeeds. + _ = ctx.interpreter.gas.spend(dyn_gas); // EIP-8037 (Amsterdam+): charge state gas for new account via SELFDESTRUCT. // Draws from reservoir first, spills to gas_left if needed.