Skip to content

Commit 2e0ff16

Browse files
feat: EIP-7708 — synthetic ETH transfer/burn logs (Amsterdam) (#13)
* feat: EIP-7708 — synthetic ETH transfer/burn logs Signed-off-by: garyschulte <garyschulte@gmail.com>
1 parent f3d1ad7 commit 2e0ff16

File tree

6 files changed

+242
-5
lines changed

6 files changed

+242
-5
lines changed

src/context/cfg.zig

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -348,11 +348,11 @@ pub const CfgEnv = struct {
348348
/// - Cancun: 3338477 (`BLOB_BASE_FEE_UPDATE_FRACTION_CANCUN`)
349349
pub fn blobBaseFeeUpdateFraction(self: CfgEnv) u64 {
350350
return self.blob_base_fee_update_fraction orelse
351-
if (self.spec.isEnabledIn(.bpo2))
351+
if (primitives.isEnabledIn(self.spec, .bpo2))
352352
primitives.BLOB_BASE_FEE_UPDATE_FRACTION_BPO2
353-
else if (self.spec.isEnabledIn(.bpo1))
353+
else if (primitives.isEnabledIn(self.spec, .bpo1))
354354
primitives.BLOB_BASE_FEE_UPDATE_FRACTION_BPO1
355-
else if (self.spec.isEnabledIn(.prague))
355+
else if (primitives.isEnabledIn(self.spec, .prague))
356356
primitives.BLOB_BASE_FEE_UPDATE_FRACTION_PRAGUE
357357
else
358358
primitives.BLOB_BASE_FEE_UPDATE_FRACTION_CANCUN;

src/context/journal.zig

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,23 @@ pub const WarmAddresses = struct {
272272
pub const JournalCheckpoint = struct {
273273
log_i: usize,
274274
journal_i: usize,
275+
/// Index into JournalInner.pending_burns at checkpoint time (for EIP-7708 revert).
276+
burn_i: usize,
277+
};
278+
279+
/// EIP-7708 deferred burn entry.
280+
///
281+
/// Tracks an account in `accounts_to_delete` that may receive ETH after its SELFDESTRUCT opcode
282+
/// executes and before the transaction finalizes (e.g. a payer contract calling into the already-
283+
/// destructed address, or the coinbase receiving its priority fee). These accounts are added to
284+
/// `pending_burns` at SELFDESTRUCT time; `emitBurnLogs()` re-reads their balance after the
285+
/// coinbase payment and emits a Burn log for any non-zero remainder.
286+
///
287+
/// `amount` is unused — it is kept for potential future use (e.g. assertions) but the finalization
288+
/// path always reads the live balance from `evm_state`.
289+
pub const PendingBurn = struct {
290+
address: primitives.Address,
291+
amount: primitives.U256,
275292
};
276293

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

385405
pub fn new() JournalInner {
386406
return .{
@@ -391,6 +411,7 @@ pub const JournalInner = struct {
391411
.transaction_id = 0,
392412
.spec = primitives.SpecId.prague,
393413
.warm_addresses = WarmAddresses.new(),
414+
.pending_burns = std.ArrayList(PendingBurn){},
394415
};
395416
}
396417

@@ -401,6 +422,7 @@ pub const JournalInner = struct {
401422
self.logs.deinit(alloc_mod.get());
402423
self.journal.deinit(alloc_mod.get());
403424
self.warm_addresses.deinit();
425+
self.pending_burns.deinit(alloc_mod.get());
404426
}
405427

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

444467
/// Discard the current transaction, by reverting the journal entries and incrementing the transaction id.
@@ -457,6 +480,7 @@ pub const JournalInner = struct {
457480
// Free heap-allocated data/topics before clearing the log list
458481
for (self.logs.items) |log| log.deinit(alloc_mod.get());
459482
self.logs.clearRetainingCapacity();
483+
self.pending_burns.clearRetainingCapacity();
460484
self.transaction_id += 1;
461485
self.warm_addresses.clearCoinbaseAndAccessList();
462486
}
@@ -472,6 +496,7 @@ pub const JournalInner = struct {
472496
self.evm_state = state.EvmState.init(alloc_mod.get());
473497
for (self.logs.items) |log| log.deinit(alloc_mod.get());
474498
self.logs.clearRetainingCapacity();
499+
self.pending_burns.clearRetainingCapacity();
475500
self.transient_storage.clearRetainingCapacity();
476501
self.journal.clearRetainingCapacity();
477502
self.transaction_id = 0;
@@ -693,6 +718,7 @@ pub const JournalInner = struct {
693718
return JournalCheckpoint{
694719
.log_i = self.logs.items.len,
695720
.journal_i = self.journal.items.len,
721+
.burn_i = self.pending_burns.items.len,
696722
};
697723
}
698724

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

711739
// iterate over last N journals sets and revert our global evm_state
712740
if (checkpoint.journal_i < self.journal.items.len) {
@@ -726,6 +754,104 @@ pub const JournalInner = struct {
726754
/// current spec enables Cancun, this happens only when the account associated to address
727755
/// is created in the same tx
728756
///
757+
// ─── EIP-7708 helpers ─────────────────────────────────────────────────────────
758+
759+
/// EIP-7708: Build and append a Transfer(from, to, amount) log to the journal log list.
760+
/// Address is encoded into the upper 12 zero-padded bytes of a 32-byte topic.
761+
/// OOM silently skips the log (same policy as addLog).
762+
fn addEip7708TransferLog(self: *JournalInner, from: primitives.Address, to: primitives.Address, amount: primitives.U256) void {
763+
const alloc = alloc_mod.get();
764+
const topics = alloc.alloc(primitives.Hash, 3) catch return;
765+
topics[0] = primitives.EIP7708_TRANSFER_TOPIC;
766+
topics[1] = std.mem.zeroes([32]u8);
767+
@memcpy(topics[1][12..], &from);
768+
topics[2] = std.mem.zeroes([32]u8);
769+
@memcpy(topics[2][12..], &to);
770+
const data = alloc.alloc(u8, 32) catch {
771+
alloc.free(topics);
772+
return;
773+
};
774+
const amount_bytes: [32]u8 = @bitCast(@byteSwap(amount));
775+
@memcpy(data, &amount_bytes);
776+
self.addLog(.{
777+
.address = primitives.EIP7708_LOG_ADDRESS,
778+
.topics = topics,
779+
.data = data,
780+
});
781+
}
782+
783+
/// EIP-7708: Build and append a Burn(address, amount) log to the journal log list.
784+
fn addEip7708BurnLog(self: *JournalInner, addr: primitives.Address, amount: primitives.U256) void {
785+
const alloc = alloc_mod.get();
786+
const topics = alloc.alloc(primitives.Hash, 2) catch return;
787+
topics[0] = primitives.EIP7708_BURN_TOPIC;
788+
topics[1] = std.mem.zeroes([32]u8);
789+
@memcpy(topics[1][12..], &addr);
790+
const data = alloc.alloc(u8, 32) catch {
791+
alloc.free(topics);
792+
return;
793+
};
794+
const amount_bytes: [32]u8 = @bitCast(@byteSwap(amount));
795+
@memcpy(data, &amount_bytes);
796+
self.addLog(.{
797+
.address = primitives.EIP7708_LOG_ADDRESS,
798+
.topics = topics,
799+
.data = data,
800+
});
801+
}
802+
803+
/// EIP-7708: Register an account for a deferred finalization burn check.
804+
///
805+
/// Called at SELFDESTRUCT time for every account entering `accounts_to_delete` (i.e. same-tx-
806+
/// created accounts under Cancun+, or any account pre-Cancun). `amount` is stored but not
807+
/// used by `emitBurnLogs()`; pass 0. The entry is checkpointed so it can be reverted if the
808+
/// enclosing call frame reverts.
809+
fn addPendingBurn(self: *JournalInner, addr: primitives.Address, amount: primitives.U256) void {
810+
self.pending_burns.append(alloc_mod.get(), .{ .address = addr, .amount = amount }) catch {};
811+
}
812+
813+
/// EIP-7708 finalization: emit deferred Burn logs for accounts that received ETH after their
814+
/// SELFDESTRUCT opcode executed.
815+
///
816+
/// # When this is called
817+
/// `postExecution` in `mainnet_builder.zig` calls this after the coinbase priority-fee payment
818+
/// and before `takeLogs()` collects the tx log list. This matches the EELS ordering in
819+
/// `process_transaction` (amsterdam/fork.py): miner fee is transferred first, then for each
820+
/// address in `accounts_to_delete` the current balance is checked and a Burn log is emitted
821+
/// if non-zero.
822+
///
823+
/// # Why two Burn logs can appear for the same address
824+
/// The SELFDESTRUCT opcode itself emits a Burn log for the balance held *at that moment*
825+
/// (see the `addEip7708BurnLog` call in `selfdestruct()`). The account's `evm_state` balance
826+
/// is then zeroed. Any ETH that arrives afterwards — a payer's CALL sending value, or the
827+
/// coinbase receiving its priority fee — accumulates back in `evm_state`. This second pass
828+
/// emits a *separate* Burn log only for that post-SELFDESTRUCT ETH, producing two distinct
829+
/// log entries exactly as the EELS reference implementation does.
830+
///
831+
/// # Sorting
832+
/// Logs are emitted in ascending address order (lexicographic bytes), matching EELS.
833+
pub fn emitBurnLogs(self: *JournalInner) void {
834+
if (self.pending_burns.items.len == 0) return;
835+
std.mem.sort(PendingBurn, self.pending_burns.items, {}, struct {
836+
fn lessThan(_: void, a: PendingBurn, b: PendingBurn) bool {
837+
return std.mem.order(u8, &a.address, &b.address) == .lt;
838+
}
839+
}.lessThan);
840+
for (self.pending_burns.items) |burn| {
841+
// The account's `evm_state` balance was zeroed when SELFDESTRUCT executed.
842+
// Any non-zero balance here is ETH that arrived *after* the opcode (e.g. a
843+
// subsequent CALL with value from a payer, or the coinbase priority fee).
844+
const finalization_balance = if (self.evm_state.get(burn.address)) |acct|
845+
acct.info.balance
846+
else
847+
0;
848+
if (finalization_balance > 0) self.addEip7708BurnLog(burn.address, finalization_balance);
849+
}
850+
self.pending_burns.clearRetainingCapacity();
851+
}
852+
853+
// ─── Selfdestruct ──────────────────────────────────────────────────────────────
854+
729855
/// # References:
730856
/// * <https://github.com/ethereum/go-ethereum/blob/141cd425310b503c5678e674a8c3872cf46b7086/core/vm/instructions.go#L832-L833>
731857
/// * <https://github.com/ethereum/go-ethereum/blob/141cd425310b503c5678e674a8c3872cf46b7086/core/evm_state/evm_statedb.go#L449>
@@ -780,6 +906,55 @@ pub const JournalInner = struct {
780906
self.journal.append(alloc_mod.get(), entry) catch {};
781907
}
782908

909+
// ── EIP-7708 (Amsterdam+): ETH transfer / burn log emission ─────────────────
910+
//
911+
// EIP-7708 requires a LOG2 event for every wei movement that would otherwise be
912+
// invisible on-chain. There are two distinct moments where logs must be emitted:
913+
//
914+
// 1. **Opcode time** — when SELFDESTRUCT executes:
915+
// • SELFDESTRUCT to a *different* beneficiary: the ETH is moved now, so a
916+
// Transfer log is emitted immediately (LOG3 with from/to/amount).
917+
// • SELFDESTRUCT to *self* (same-tx-created or pre-Cancun): the ETH is
918+
// destroyed now, so a Burn log is emitted immediately (LOG2 with addr/amount).
919+
// The account's `evm_state` balance is then zeroed by `entry_blk` above.
920+
//
921+
// 2. **Finalization time** — after the coinbase priority-fee payment:
922+
// Any account in `accounts_to_delete` (same-tx-created SELFDESTRUCTs) may
923+
// receive ETH *after* the opcode executes but *before* the tx commits — for
924+
// example a payer contract calling into the already-destructed address, or
925+
// the coinbase receiving its miner tip. A second Burn log is emitted for
926+
// this post-opcode ETH via `emitBurnLogs()` in `postExecution`.
927+
//
928+
// Only accounts in `accounts_to_delete` (isCreatedLocally || pre-Cancun) are
929+
// eligible for the finalization burn; pre-existing Cancun+ self-destructed accounts
930+
// are a no-op (no state change, no log).
931+
//
932+
// Reference: ethereum/execution-specs — amsterdam/vm/instructions/system.py
933+
// (selfdestruct) and amsterdam/fork.py (process_transaction finalization).
934+
if (primitives.isEnabledIn(self.spec, .amsterdam)) {
935+
if (!std.mem.eql(u8, &address, &target)) {
936+
// Case 1a: SELFDESTRUCT to a different beneficiary.
937+
// Emit Transfer log immediately for the ETH moved by `entry_blk` above.
938+
if (balance > 0) self.addEip7708TransferLog(address, target, balance);
939+
// If this account is also in `accounts_to_delete`, register it for the
940+
// finalization burn check: a payer may still send ETH to this address
941+
// within the same transaction after the opcode returns.
942+
if (acc.isCreatedLocally() or !is_cancun_enabled) {
943+
self.addPendingBurn(address, 0);
944+
}
945+
} else if (acc.isCreatedLocally() or !is_cancun_enabled) {
946+
// Case 1b: SELFDESTRUCT to self (same-tx-created or pre-Cancun).
947+
// Emit Burn log immediately for the ETH destroyed right now, then register
948+
// for the finalization burn check so that any ETH arriving *after* this
949+
// opcode (payer call, coinbase priority fee) also gets a Burn log.
950+
// The two logs are intentionally separate, matching the EELS reference.
951+
if (balance > 0) self.addEip7708BurnLog(address, balance);
952+
self.addPendingBurn(address, 0);
953+
}
954+
// Case 2: SELFDESTRUCT to self on a pre-existing account (Cancun+).
955+
// EIP-6780 makes this a no-op — state is unchanged, no log is emitted.
956+
}
957+
783958
return StateLoad(SelfDestructResult).new(SelfDestructResult{
784959
.had_value = balance != 0,
785960
.target_exists = !is_empty,
@@ -1158,6 +1333,18 @@ pub fn Journal(comptime DB: type) type {
11581333
return self.inner.takeLogs();
11591334
}
11601335

1336+
/// EIP-7708: Emit a Transfer log (Amsterdam+). Caller must already have verified
1337+
/// that `from != to` and `amount > 0` and that the spec is Amsterdam or later.
1338+
pub fn emitTransferLog(self: *@This(), from: primitives.Address, to: primitives.Address, amount: primitives.U256) void {
1339+
self.inner.addEip7708TransferLog(from, to, amount);
1340+
}
1341+
1342+
/// EIP-7708: Sort and emit pending burn logs into the log list.
1343+
/// Call after coinbase payment and before takeLogs() in postExecution.
1344+
pub fn emitBurnLogs(self: *@This()) void {
1345+
self.inner.emitBurnLogs();
1346+
}
1347+
11611348
pub fn commitTx(self: *@This()) void {
11621349
self.inner.commitTx();
11631350
}

src/handler/mainnet_builder.zig

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,12 @@ pub const MainnetHandler = struct {
322322
0,
323323
);
324324
}
325+
// EIP-7708 (Amsterdam+): emit Transfer log for ETH sent via TX.
326+
if (primitives.isEnabledIn(spec, .amsterdam) and
327+
!std.mem.eql(u8, &tx.caller, &target))
328+
{
329+
ctx.journaled_state.emitTransferLog(tx.caller, target, tx.value);
330+
}
325331
}
326332

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

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

src/interpreter/host.zig

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,12 @@ pub const Host = struct {
311311
self.ctx.journaled_state.checkpointRevert(cp);
312312
return .{ .precompile = CallResult.preExecFailure(inputs.gas_limit) };
313313
}
314+
// EIP-7708 (Amsterdam+): emit Transfer log for ETH sent to precompile.
315+
if (primitives.isEnabledIn(self.ctx.cfg.spec, .amsterdam) and
316+
!std.mem.eql(u8, &inputs.caller, &inputs.target))
317+
{
318+
self.ctx.journaled_state.emitTransferLog(inputs.caller, inputs.target, inputs.value);
319+
}
314320
}
315321
const pc_result = pc.execute(inputs.data, inputs.gas_limit);
316322
switch (pc_result) {
@@ -368,6 +374,12 @@ pub const Host = struct {
368374
self.ctx.journaled_state.checkpointRevert(checkpoint);
369375
return .{ .failed = CallResult.preExecFailure(inputs.gas_limit) };
370376
}
377+
// EIP-7708 (Amsterdam+): emit Transfer log for ETH sent via CALL.
378+
if (primitives.isEnabledIn(self.ctx.cfg.spec, .amsterdam) and
379+
!std.mem.eql(u8, &inputs.caller, &inputs.target))
380+
{
381+
self.ctx.journaled_state.emitTransferLog(inputs.caller, inputs.target, inputs.value);
382+
}
371383
}
372384

373385
return .{ .ready = .{ .checkpoint = checkpoint, .code = code, .delegation_gas = delegation_gas } };
@@ -474,9 +486,14 @@ pub const Host = struct {
474486
}
475487

476488
const checkpoint = js.createAccountCheckpoint(caller, new_addr, value, spec_id) catch {
477-
return .{ .failed = CreateResult.preExecFailure(0) };
489+
return .{ .failed = CreateResult.failure() };
478490
};
479491

492+
// EIP-7708 (Amsterdam+): emit Transfer log for ETH sent to the new contract.
493+
if (value > 0 and primitives.isEnabledIn(spec_id, .amsterdam)) {
494+
js.emitTransferLog(caller, new_addr, value);
495+
}
496+
480497
return .{ .ready = .{ .checkpoint = checkpoint, .new_addr = new_addr } };
481498
}
482499

src/primitives/main.zig

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,29 @@ pub const VERSIONED_HASH_VERSION_KZG: u8 = 0x01;
121121
/// Transaction gas limit cap (EIP-7825)
122122
pub const TX_GAS_LIMIT_CAP: u64 = 30000000;
123123

124+
/// EIP-7708: Address that emits synthetic ETH transfer/burn logs (0xff...fe).
125+
pub const EIP7708_LOG_ADDRESS: Address = [20]u8{
126+
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
127+
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe,
128+
};
129+
130+
/// EIP-7708: keccak256("Transfer(address,address,uint256)")
131+
pub const EIP7708_TRANSFER_TOPIC: Hash = [32]u8{
132+
0xdd, 0xf2, 0x52, 0xad, 0x1b, 0xe2, 0xc8, 0x9b,
133+
0x69, 0xc2, 0xb0, 0x68, 0xfc, 0x37, 0x8d, 0xaa,
134+
0x95, 0x2b, 0xa7, 0xf1, 0x63, 0xc4, 0xa1, 0x16,
135+
0x28, 0xf5, 0x5a, 0x4d, 0xf5, 0x23, 0xb3, 0xef,
136+
};
137+
138+
/// EIP-7708: keccak256("Burn(address,uint256)")
139+
/// = 0xcc16f5dbb4873280815c1ee09dbd06736cffcc184412cf7a71a0fdb75d397ca5
140+
pub const EIP7708_BURN_TOPIC: Hash = [32]u8{
141+
0xcc, 0x16, 0xf5, 0xdb, 0xb4, 0x87, 0x32, 0x80,
142+
0x81, 0x5c, 0x1e, 0xe0, 0x9d, 0xbd, 0x06, 0x73,
143+
0x6c, 0xff, 0xcc, 0x18, 0x44, 0x12, 0xcf, 0x7a,
144+
0x71, 0xa0, 0xfd, 0xb7, 0x5d, 0x39, 0x7c, 0xa5,
145+
};
146+
124147
/// The Keccak-256 hash of the empty string "".
125148
pub const KECCAK_EMPTY: Hash = [32]u8{
126149
0xc5, 0xd2, 0x46, 0x01, 0x86, 0xf7, 0x23, 0x3c,

src/spec_test/runner.zig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ pub fn runTestCase(tc: types.TestCase, allocator: std.mem.Allocator) TestOutcome
247247
// Set block env
248248
const blob_excess_gas_and_price: ?context.BlobExcessGasAndPrice =
249249
if (tc.blob_versioned_hashes_count > 0 or tc.excess_blob_gas > 0)
250-
context.BlobExcessGasAndPrice.new(tc.excess_blob_gas, primitives.BLOB_BASE_FEE_UPDATE_FRACTION_PRAGUE)
250+
context.BlobExcessGasAndPrice.new(tc.excess_blob_gas, ctx.cfg.blobBaseFeeUpdateFraction())
251251
else
252252
null;
253253
ctx.setBlock(context.BlockEnv{

0 commit comments

Comments
 (0)