Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/context/cfg.zig
Original file line number Diff line number Diff line change
Expand Up @@ -348,11 +348,11 @@ pub const CfgEnv = struct {
/// - Cancun: 3338477 (`BLOB_BASE_FEE_UPDATE_FRACTION_CANCUN`)
pub fn blobBaseFeeUpdateFraction(self: CfgEnv) u64 {
return self.blob_base_fee_update_fraction orelse
if (self.spec.isEnabledIn(.bpo2))
if (primitives.isEnabledIn(self.spec, .bpo2))
primitives.BLOB_BASE_FEE_UPDATE_FRACTION_BPO2
else if (self.spec.isEnabledIn(.bpo1))
else if (primitives.isEnabledIn(self.spec, .bpo1))
primitives.BLOB_BASE_FEE_UPDATE_FRACTION_BPO1
else if (self.spec.isEnabledIn(.prague))
else if (primitives.isEnabledIn(self.spec, .prague))
primitives.BLOB_BASE_FEE_UPDATE_FRACTION_PRAGUE
else
primitives.BLOB_BASE_FEE_UPDATE_FRACTION_CANCUN;
Expand Down
187 changes: 187 additions & 0 deletions src/context/journal.zig
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,23 @@ pub const WarmAddresses = struct {
pub const JournalCheckpoint = struct {
log_i: usize,
journal_i: usize,
/// Index into JournalInner.pending_burns at checkpoint time (for EIP-7708 revert).
burn_i: usize,
};

/// EIP-7708 deferred burn entry.
///
/// Tracks an account in `accounts_to_delete` that may receive ETH after its SELFDESTRUCT opcode
/// executes and before the transaction finalizes (e.g. a payer contract calling into the already-
/// destructed address, or the coinbase receiving its priority fee). These accounts are added to
/// `pending_burns` at SELFDESTRUCT time; `emitBurnLogs()` re-reads their balance after the
/// coinbase payment and emits a Burn log for any non-zero remainder.
///
/// `amount` is unused — it is kept for potential future use (e.g. assertions) but the finalization
/// path always reads the live balance from `evm_state`.
pub const PendingBurn = struct {
address: primitives.Address,
amount: primitives.U256,
};

/// State load result
Expand Down Expand Up @@ -381,6 +398,9 @@ pub const JournalInner = struct {
spec: primitives.SpecId,
/// Warm addresses containing both coinbase and current precompiles.
warm_addresses: WarmAddresses,
/// EIP-7708: pending burn entries for same-tx-created accounts that selfdestructed to self.
/// Emitted as Burn logs at postExecution, sorted by address. Checkpointed via burn_i.
pending_burns: std.ArrayList(PendingBurn),

pub fn new() JournalInner {
return .{
Expand All @@ -391,6 +411,7 @@ pub const JournalInner = struct {
.transaction_id = 0,
.spec = primitives.SpecId.prague,
.warm_addresses = WarmAddresses.new(),
.pending_burns = std.ArrayList(PendingBurn){},
};
}

Expand All @@ -401,6 +422,7 @@ pub const JournalInner = struct {
self.logs.deinit(alloc_mod.get());
self.journal.deinit(alloc_mod.get());
self.warm_addresses.deinit();
self.pending_burns.deinit(alloc_mod.get());
}

/// Returns the logs
Expand Down Expand Up @@ -439,6 +461,7 @@ pub const JournalInner = struct {
// Free heap-allocated data/topics (in case takeLogs() was not called — e.g. failed tx)
for (self.logs.items) |log| log.deinit(alloc_mod.get());
self.logs.clearRetainingCapacity();
self.pending_burns.clearRetainingCapacity();
}

/// Discard the current transaction, by reverting the journal entries and incrementing the transaction id.
Expand All @@ -457,6 +480,7 @@ pub const JournalInner = struct {
// Free heap-allocated data/topics before clearing the log list
for (self.logs.items) |log| log.deinit(alloc_mod.get());
self.logs.clearRetainingCapacity();
self.pending_burns.clearRetainingCapacity();
self.transaction_id += 1;
self.warm_addresses.clearCoinbaseAndAccessList();
}
Expand All @@ -472,6 +496,7 @@ pub const JournalInner = struct {
self.evm_state = state.EvmState.init(alloc_mod.get());
for (self.logs.items) |log| log.deinit(alloc_mod.get());
self.logs.clearRetainingCapacity();
self.pending_burns.clearRetainingCapacity();
self.transient_storage.clearRetainingCapacity();
self.journal.clearRetainingCapacity();
self.transaction_id = 0;
Expand Down Expand Up @@ -693,6 +718,7 @@ pub const JournalInner = struct {
return JournalCheckpoint{
.log_i = self.logs.items.len,
.journal_i = self.journal.items.len,
.burn_i = self.pending_burns.items.len,
};
}

Expand All @@ -707,6 +733,8 @@ pub const JournalInner = struct {
// Free heap-allocated data/topics of logs being discarded by this revert.
for (self.logs.items[checkpoint.log_i..]) |log| log.deinit(alloc_mod.get());
self.logs.shrinkRetainingCapacity(checkpoint.log_i);
// Revert EIP-7708 pending burns added since this checkpoint.
self.pending_burns.shrinkRetainingCapacity(checkpoint.burn_i);

// iterate over last N journals sets and revert our global evm_state
if (checkpoint.journal_i < self.journal.items.len) {
Expand All @@ -726,6 +754,104 @@ pub const JournalInner = struct {
/// current spec enables Cancun, this happens only when the account associated to address
/// is created in the same tx
///
// ─── EIP-7708 helpers ─────────────────────────────────────────────────────────

/// EIP-7708: Build and append a Transfer(from, to, amount) log to the journal log list.
/// Address is encoded into the upper 12 zero-padded bytes of a 32-byte topic.
/// OOM silently skips the log (same policy as addLog).
fn addEip7708TransferLog(self: *JournalInner, from: primitives.Address, to: primitives.Address, amount: primitives.U256) void {
const alloc = alloc_mod.get();
const topics = alloc.alloc(primitives.Hash, 3) catch return;
topics[0] = primitives.EIP7708_TRANSFER_TOPIC;
topics[1] = std.mem.zeroes([32]u8);
@memcpy(topics[1][12..], &from);
topics[2] = std.mem.zeroes([32]u8);
@memcpy(topics[2][12..], &to);
const data = alloc.alloc(u8, 32) catch {
alloc.free(topics);
return;
};
const amount_bytes: [32]u8 = @bitCast(@byteSwap(amount));
@memcpy(data, &amount_bytes);
self.addLog(.{
.address = primitives.EIP7708_LOG_ADDRESS,
.topics = topics,
.data = data,
});
}

/// EIP-7708: Build and append a Burn(address, amount) log to the journal log list.
fn addEip7708BurnLog(self: *JournalInner, addr: primitives.Address, amount: primitives.U256) void {
const alloc = alloc_mod.get();
const topics = alloc.alloc(primitives.Hash, 2) catch return;
topics[0] = primitives.EIP7708_BURN_TOPIC;
topics[1] = std.mem.zeroes([32]u8);
@memcpy(topics[1][12..], &addr);
const data = alloc.alloc(u8, 32) catch {
alloc.free(topics);
return;
};
const amount_bytes: [32]u8 = @bitCast(@byteSwap(amount));
@memcpy(data, &amount_bytes);
self.addLog(.{
.address = primitives.EIP7708_LOG_ADDRESS,
.topics = topics,
.data = data,
});
}

/// EIP-7708: Register an account for a deferred finalization burn check.
///
/// Called at SELFDESTRUCT time for every account entering `accounts_to_delete` (i.e. same-tx-
/// created accounts under Cancun+, or any account pre-Cancun). `amount` is stored but not
/// used by `emitBurnLogs()`; pass 0. The entry is checkpointed so it can be reverted if the
/// enclosing call frame reverts.
fn addPendingBurn(self: *JournalInner, addr: primitives.Address, amount: primitives.U256) void {
self.pending_burns.append(alloc_mod.get(), .{ .address = addr, .amount = amount }) catch {};
}

/// EIP-7708 finalization: emit deferred Burn logs for accounts that received ETH after their
/// SELFDESTRUCT opcode executed.
///
/// # When this is called
/// `postExecution` in `mainnet_builder.zig` calls this after the coinbase priority-fee payment
/// and before `takeLogs()` collects the tx log list. This matches the EELS ordering in
/// `process_transaction` (amsterdam/fork.py): miner fee is transferred first, then for each
/// address in `accounts_to_delete` the current balance is checked and a Burn log is emitted
/// if non-zero.
///
/// # Why two Burn logs can appear for the same address
/// The SELFDESTRUCT opcode itself emits a Burn log for the balance held *at that moment*
/// (see the `addEip7708BurnLog` call in `selfdestruct()`). The account's `evm_state` balance
/// is then zeroed. Any ETH that arrives afterwards — a payer's CALL sending value, or the
/// coinbase receiving its priority fee — accumulates back in `evm_state`. This second pass
/// emits a *separate* Burn log only for that post-SELFDESTRUCT ETH, producing two distinct
/// log entries exactly as the EELS reference implementation does.
///
/// # Sorting
/// Logs are emitted in ascending address order (lexicographic bytes), matching EELS.
pub fn emitBurnLogs(self: *JournalInner) void {
if (self.pending_burns.items.len == 0) return;
std.mem.sort(PendingBurn, self.pending_burns.items, {}, struct {
fn lessThan(_: void, a: PendingBurn, b: PendingBurn) bool {
return std.mem.order(u8, &a.address, &b.address) == .lt;
}
}.lessThan);
for (self.pending_burns.items) |burn| {
// The account's `evm_state` balance was zeroed when SELFDESTRUCT executed.
// Any non-zero balance here is ETH that arrived *after* the opcode (e.g. a
// subsequent CALL with value from a payer, or the coinbase priority fee).
const finalization_balance = if (self.evm_state.get(burn.address)) |acct|
acct.info.balance
else
0;
if (finalization_balance > 0) self.addEip7708BurnLog(burn.address, finalization_balance);
}
self.pending_burns.clearRetainingCapacity();
}

// ─── Selfdestruct ──────────────────────────────────────────────────────────────

/// # References:
/// * <https://github.com/ethereum/go-ethereum/blob/141cd425310b503c5678e674a8c3872cf46b7086/core/vm/instructions.go#L832-L833>
/// * <https://github.com/ethereum/go-ethereum/blob/141cd425310b503c5678e674a8c3872cf46b7086/core/evm_state/evm_statedb.go#L449>
Expand Down Expand Up @@ -780,6 +906,55 @@ pub const JournalInner = struct {
self.journal.append(alloc_mod.get(), entry) catch {};
}

// ── EIP-7708 (Amsterdam+): ETH transfer / burn log emission ─────────────────
//
// EIP-7708 requires a LOG2 event for every wei movement that would otherwise be
// invisible on-chain. There are two distinct moments where logs must be emitted:
//
// 1. **Opcode time** — when SELFDESTRUCT executes:
// • SELFDESTRUCT to a *different* beneficiary: the ETH is moved now, so a
// Transfer log is emitted immediately (LOG3 with from/to/amount).
// • SELFDESTRUCT to *self* (same-tx-created or pre-Cancun): the ETH is
// destroyed now, so a Burn log is emitted immediately (LOG2 with addr/amount).
// The account's `evm_state` balance is then zeroed by `entry_blk` above.
//
// 2. **Finalization time** — after the coinbase priority-fee payment:
// Any account in `accounts_to_delete` (same-tx-created SELFDESTRUCTs) may
// receive ETH *after* the opcode executes but *before* the tx commits — for
// example a payer contract calling into the already-destructed address, or
// the coinbase receiving its miner tip. A second Burn log is emitted for
// this post-opcode ETH via `emitBurnLogs()` in `postExecution`.
//
// Only accounts in `accounts_to_delete` (isCreatedLocally || pre-Cancun) are
// eligible for the finalization burn; pre-existing Cancun+ self-destructed accounts
// are a no-op (no state change, no log).
//
// Reference: ethereum/execution-specs — amsterdam/vm/instructions/system.py
// (selfdestruct) and amsterdam/fork.py (process_transaction finalization).
if (primitives.isEnabledIn(self.spec, .amsterdam)) {
if (!std.mem.eql(u8, &address, &target)) {
// Case 1a: SELFDESTRUCT to a different beneficiary.
// Emit Transfer log immediately for the ETH moved by `entry_blk` above.
if (balance > 0) self.addEip7708TransferLog(address, target, balance);
// If this account is also in `accounts_to_delete`, register it for the
// finalization burn check: a payer may still send ETH to this address
// within the same transaction after the opcode returns.
if (acc.isCreatedLocally() or !is_cancun_enabled) {
self.addPendingBurn(address, 0);
}
} else if (acc.isCreatedLocally() or !is_cancun_enabled) {
// Case 1b: SELFDESTRUCT to self (same-tx-created or pre-Cancun).
// Emit Burn log immediately for the ETH destroyed right now, then register
// for the finalization burn check so that any ETH arriving *after* this
// opcode (payer call, coinbase priority fee) also gets a Burn log.
// The two logs are intentionally separate, matching the EELS reference.
if (balance > 0) self.addEip7708BurnLog(address, balance);
self.addPendingBurn(address, 0);
}
// Case 2: SELFDESTRUCT to self on a pre-existing account (Cancun+).
// EIP-6780 makes this a no-op — state is unchanged, no log is emitted.
}

return StateLoad(SelfDestructResult).new(SelfDestructResult{
.had_value = balance != 0,
.target_exists = !is_empty,
Expand Down Expand Up @@ -1158,6 +1333,18 @@ pub fn Journal(comptime DB: type) type {
return self.inner.takeLogs();
}

/// EIP-7708: Emit a Transfer log (Amsterdam+). Caller must already have verified
/// that `from != to` and `amount > 0` and that the spec is Amsterdam or later.
pub fn emitTransferLog(self: *@This(), from: primitives.Address, to: primitives.Address, amount: primitives.U256) void {
self.inner.addEip7708TransferLog(from, to, amount);
}

/// EIP-7708: Sort and emit pending burn logs into the log list.
/// Call after coinbase payment and before takeLogs() in postExecution.
pub fn emitBurnLogs(self: *@This()) void {
self.inner.emitBurnLogs();
}

pub fn commitTx(self: *@This()) void {
self.inner.commitTx();
}
Expand Down
10 changes: 10 additions & 0 deletions src/handler/mainnet_builder.zig
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,12 @@ pub const MainnetHandler = struct {
0,
);
}
// EIP-7708 (Amsterdam+): emit Transfer log for ETH sent via TX.
if (primitives.isEnabledIn(spec, .amsterdam) and
!std.mem.eql(u8, &tx.caller, &target))
{
ctx.journaled_state.emitTransferLog(tx.caller, target, tx.value);
}
}

// Precompile dispatch for top-level TX targeting a precompile.
Expand Down Expand Up @@ -475,6 +481,10 @@ pub const MainnetHandler = struct {

// 6. Extract logs before commitTx destroys them (only on success — reverted state has no logs).
if (result.result.status == .Success) {
// EIP-7708 (Amsterdam+): emit deferred burn logs (sorted by address) after coinbase payment.
if (primitives.isEnabledIn(spec, .amsterdam)) {
js.emitBurnLogs();
}
result.result.logs = js.takeLogs();
}

Expand Down
19 changes: 18 additions & 1 deletion src/interpreter/host.zig
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,12 @@ pub const Host = struct {
self.ctx.journaled_state.checkpointRevert(cp);
return .{ .precompile = CallResult.preExecFailure(inputs.gas_limit) };
}
// EIP-7708 (Amsterdam+): emit Transfer log for ETH sent to precompile.
if (primitives.isEnabledIn(self.ctx.cfg.spec, .amsterdam) and
!std.mem.eql(u8, &inputs.caller, &inputs.target))
{
self.ctx.journaled_state.emitTransferLog(inputs.caller, inputs.target, inputs.value);
}
}
const pc_result = pc.execute(inputs.data, inputs.gas_limit);
switch (pc_result) {
Expand Down Expand Up @@ -368,6 +374,12 @@ pub const Host = struct {
self.ctx.journaled_state.checkpointRevert(checkpoint);
return .{ .failed = CallResult.preExecFailure(inputs.gas_limit) };
}
// EIP-7708 (Amsterdam+): emit Transfer log for ETH sent via CALL.
if (primitives.isEnabledIn(self.ctx.cfg.spec, .amsterdam) and
!std.mem.eql(u8, &inputs.caller, &inputs.target))
{
self.ctx.journaled_state.emitTransferLog(inputs.caller, inputs.target, inputs.value);
}
}

return .{ .ready = .{ .checkpoint = checkpoint, .code = code, .delegation_gas = delegation_gas } };
Expand Down Expand Up @@ -474,9 +486,14 @@ pub const Host = struct {
}

const checkpoint = js.createAccountCheckpoint(caller, new_addr, value, spec_id) catch {
return .{ .failed = CreateResult.preExecFailure(0) };
return .{ .failed = CreateResult.failure() };
};

// EIP-7708 (Amsterdam+): emit Transfer log for ETH sent to the new contract.
if (value > 0 and primitives.isEnabledIn(spec_id, .amsterdam)) {
js.emitTransferLog(caller, new_addr, value);
}

return .{ .ready = .{ .checkpoint = checkpoint, .new_addr = new_addr } };
}

Expand Down
23 changes: 23 additions & 0 deletions src/primitives/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,29 @@ pub const VERSIONED_HASH_VERSION_KZG: u8 = 0x01;
/// Transaction gas limit cap (EIP-7825)
pub const TX_GAS_LIMIT_CAP: u64 = 30000000;

/// EIP-7708: Address that emits synthetic ETH transfer/burn logs (0xff...fe).
pub const EIP7708_LOG_ADDRESS: Address = [20]u8{
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe,
};

/// EIP-7708: keccak256("Transfer(address,address,uint256)")
pub const EIP7708_TRANSFER_TOPIC: Hash = [32]u8{
0xdd, 0xf2, 0x52, 0xad, 0x1b, 0xe2, 0xc8, 0x9b,
0x69, 0xc2, 0xb0, 0x68, 0xfc, 0x37, 0x8d, 0xaa,
0x95, 0x2b, 0xa7, 0xf1, 0x63, 0xc4, 0xa1, 0x16,
0x28, 0xf5, 0x5a, 0x4d, 0xf5, 0x23, 0xb3, 0xef,
};

/// EIP-7708: keccak256("Burn(address,uint256)")
/// = 0xcc16f5dbb4873280815c1ee09dbd06736cffcc184412cf7a71a0fdb75d397ca5
pub const EIP7708_BURN_TOPIC: Hash = [32]u8{
0xcc, 0x16, 0xf5, 0xdb, 0xb4, 0x87, 0x32, 0x80,
0x81, 0x5c, 0x1e, 0xe0, 0x9d, 0xbd, 0x06, 0x73,
0x6c, 0xff, 0xcc, 0x18, 0x44, 0x12, 0xcf, 0x7a,
0x71, 0xa0, 0xfd, 0xb7, 0x5d, 0x39, 0x7c, 0xa5,
};

/// The Keccak-256 hash of the empty string "".
pub const KECCAK_EMPTY: Hash = [32]u8{
0xc5, 0xd2, 0x46, 0x01, 0x86, 0xf7, 0x23, 0x3c,
Expand Down
2 changes: 1 addition & 1 deletion src/spec_test/runner.zig
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ pub fn runTestCase(tc: types.TestCase, allocator: std.mem.Allocator) TestOutcome
// Set block env
const blob_excess_gas_and_price: ?context.BlobExcessGasAndPrice =
if (tc.blob_versioned_hashes_count > 0 or tc.excess_blob_gas > 0)
context.BlobExcessGasAndPrice.new(tc.excess_blob_gas, primitives.BLOB_BASE_FEE_UPDATE_FRACTION_PRAGUE)
context.BlobExcessGasAndPrice.new(tc.excess_blob_gas, ctx.cfg.blobBaseFeeUpdateFraction())
else
null;
ctx.setBlock(context.BlockEnv{
Expand Down
Loading