Skip to content

Commit 53fcfdf

Browse files
feat(bal): fix EIP-7928 BAL tracking for 7702 delegation OOG boundaries
Split the CALL instruction OOG check into two stages to match EELS: 1. If remaining < call_cost_no_delegation: untrack target (oog_before_target_access) 2. If remaining < base_cost (includes delegation): halt OOG without untracking target (oog_after_target_access and oog_success_minus_1 — target IS in BAL, delegation NOT) Also add unconditional untrackAddress for OOG in EXTCODECOPY and CALL-type opcodes when gas is exhausted before the target is accessed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 63cf2c6 commit 53fcfdf

File tree

3 files changed

+43
-14
lines changed

3 files changed

+43
-14
lines changed

src/database/main.zig

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,10 @@ pub const FallbackFns = struct {
253253
/// lightweight hook lets the fallback (e.g. WitnessDatabase) record the access for
254254
/// EIP-7928 BAL tracking without performing MPT verification.
255255
notify_storage_read: ?*const fn (*anyopaque, primitives.Address, primitives.StorageKey) void = null,
256+
/// Returns true if the address was committed to the permanent access log.
257+
/// Used by BaTracker to distinguish legitimately-accessed nonexistent accounts
258+
/// from those only loaded for OOG gas calculation.
259+
is_tracked_address: ?*const fn (*anyopaque, primitives.Address) bool = null,
256260
};
257261

258262
/// In-memory database implementation.
@@ -263,6 +267,10 @@ pub const InMemoryDB = struct {
263267
block_hashes: std.AutoHashMap(u64, primitives.Hash),
264268
/// Optional fallback: called on cache miss for account/storage/code/blockHash.
265269
fallback: ?FallbackFns = null,
270+
/// Addresses that were explicitly OOG-untracked via untrackAddress().
271+
/// Used by BaTracker.computeHash() to exclude CALL gas-calc phantoms from the BAL.
272+
/// Populated by untrackAddress(); never cleared between transactions (block-scoped).
273+
oog_addresses: std.AutoHashMap(primitives.Address, void),
266274

267275
const Self = @This();
268276

@@ -272,6 +280,7 @@ pub const InMemoryDB = struct {
272280
.code = std.AutoHashMap(primitives.Hash, bytecode.Bytecode).init(allocator),
273281
.storage_map = std.HashMap(struct { primitives.Address, primitives.StorageKey }, primitives.StorageValue, StorageKeyContext, std.hash_map.default_max_load_percentage).init(allocator),
274282
.block_hashes = std.AutoHashMap(u64, primitives.Hash).init(allocator),
283+
.oog_addresses = std.AutoHashMap(primitives.Address, void).init(allocator),
275284
};
276285
}
277286

@@ -280,6 +289,7 @@ pub const InMemoryDB = struct {
280289
self.code.deinit();
281290
self.storage_map.deinit();
282291
self.block_hashes.deinit();
292+
self.oog_addresses.deinit();
283293
}
284294

285295
pub fn basic(self: *Self, address: primitives.Address) !?state.AccountInfo {
@@ -339,12 +349,20 @@ pub const InMemoryDB = struct {
339349
if (self.fallback) |fb| if (fb.revert_frame) |f| f(fb.ctx);
340350
}
341351

342-
/// Un-record a pending address access in the fallback.
352+
/// Un-record a pending address access.
343353
/// Called when a CALL loaded an address for gas calculation but then went OOG.
354+
/// Marks the address as an OOG phantom so BaTracker can exclude it from the BAL.
344355
pub fn untrackAddress(self: *Self, address: primitives.Address) void {
356+
self.oog_addresses.put(address, {}) catch {};
345357
if (self.fallback) |fb| if (fb.untrack_address) |f| f(fb.ctx, address);
346358
}
347359

360+
/// Returns true if the address was OOG-untracked (loaded only for CALL gas calculation).
361+
/// Used by BaTracker.computeHash() to exclude phantom accounts from the BAL.
362+
pub fn isOogAddress(self: *const Self, address: primitives.Address) bool {
363+
return self.oog_addresses.contains(address);
364+
}
365+
348366
/// Force-add an address to the current-tx access log regardless of witness state.
349367
/// Called for EIP-7702 delegation targets that execute but are not in the witness.
350368
pub fn forceTrackAddress(self: *Self, address: primitives.Address) void {
@@ -365,6 +383,14 @@ pub const InMemoryDB = struct {
365383
if (self.fallback) |fb| if (fb.notify_storage_read) |f| f(fb.ctx, address, slot);
366384
}
367385

386+
/// Returns true if the address was committed to the permanent access log in the fallback.
387+
/// Used by BaTracker to distinguish legitimately-accessed nonexistent accounts
388+
/// from those only loaded for OOG gas calculation.
389+
pub fn isTrackedAddress(self: *Self, address: primitives.Address) bool {
390+
if (self.fallback) |fb| if (fb.is_tracked_address) |f| return f(fb.ctx, address);
391+
return false;
392+
}
393+
368394
pub fn basicRef(self: Self, address: primitives.Address) !?state.AccountInfo {
369395
return self.accounts.get(address);
370396
}

src/interpreter/opcodes/call.zig

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -144,11 +144,7 @@ fn callImpl(
144144
}
145145
}
146146

147-
// Determine warm/cold access for target address (code source).
148-
// Record whether the address was already in the EVM state cache BEFORE this load.
149-
// If not already loaded and the full base_cost check later fails (OOG), we untrack
150-
// the address — it was loaded only for gas calculation, not for actual execution.
151-
const target_already_loaded = h.isAddressLoaded(target_addr);
147+
// Load target account info to determine warm/cold access and whether the account exists.
152148
const acct_info = h.accountInfo(target_addr);
153149
const is_cold = if (acct_info) |info| info.is_cold else pre_is_cold;
154150
const transfers_value = has_value and value > 0;
@@ -175,18 +171,21 @@ fn callImpl(
175171
}
176172

177173
// Base call cost (warm/cold + value transfer + new account + EIP-7702 delegation target access)
178-
const base_cost = gas_costs.getCallGasCost(spec, is_cold, transfers_value, account_exists) + delegation_gas;
174+
const call_cost_no_delegation = gas_costs.getCallGasCost(spec, is_cold, transfers_value, account_exists);
175+
const base_cost = call_cost_no_delegation + delegation_gas;
179176

180177
// Determine forwarded gas (EIP-150 introduces 63/64 rule; pre-EIP-150 uses all remaining).
181178
const remaining = ctx.interpreter.gas.remaining;
179+
if (remaining < call_cost_no_delegation) {
180+
// OOG before the target was "accessed" in EIP-7928 terms (can't pay transfer/new-account).
181+
// Per EELS: target is not yet in accessed_addresses at this point → untrack it.
182+
h.untrackAddress(target_addr);
183+
ctx.interpreter.halt(.out_of_gas);
184+
return;
185+
}
182186
if (remaining < base_cost) {
183-
// The CALL goes OOG before executing. Un-track the target only when it was
184-
// loaded purely for new_account_cost gas estimation (non-existing account with
185-
// value transfer). Existing accounts (including 7702 sources with delegation_gas
186-
// in base_cost) remain in the BAL — they were genuinely accessed.
187-
if (!target_already_loaded and transfers_value and !account_exists) {
188-
h.untrackAddress(target_addr);
189-
}
187+
// OOG after target access but before delegation (oog_after_target_access /
188+
// oog_success_minus_1). Per EELS second check_gas: target IS in BAL, delegation NOT loaded.
190189
ctx.interpreter.halt(.out_of_gas);
191190
return;
192191
}

src/interpreter/opcodes/host_ops.zig

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,25 +191,29 @@ pub fn opExtcodecopy(ctx: *InstructionContext) void {
191191
if (size == 0) return;
192192

193193
if (mem_off > std.math.maxInt(usize) or size > std.math.maxInt(usize)) {
194+
h.untrackAddress(addr);
194195
ctx.interpreter.halt(.memory_limit_oog);
195196
return;
196197
}
197198

198199
const mem_off_u: usize = @intCast(mem_off);
199200
const size_u: usize = @intCast(size);
200201
const new_size = std.math.add(usize, mem_off_u, size_u) catch {
202+
h.untrackAddress(addr);
201203
ctx.interpreter.halt(.memory_limit_oog);
202204
return;
203205
};
204206

205207
// Dynamic: copy cost — use divCeil to avoid (size + 31) overflow when size = maxInt(usize)
206208
const num_words = std.math.divCeil(usize, size_u, 32) catch unreachable;
207209
if (!ctx.interpreter.gas.spend(gas_costs.G_COPY * @as(u64, @intCast(num_words)))) {
210+
h.untrackAddress(addr);
208211
ctx.interpreter.halt(.out_of_gas);
209212
return;
210213
}
211214

212215
if (!expandMemory(ctx, new_size)) {
216+
h.untrackAddress(addr);
213217
ctx.interpreter.halt(.out_of_gas);
214218
return;
215219
}

0 commit comments

Comments
 (0)