@@ -272,6 +272,23 @@ pub const WarmAddresses = struct {
272272pub 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 }
0 commit comments