# Reth: EIP-7708 (ETH transfer logs) and EIP-8246 (SELFDESTRUCT without burn), checked against the Amsterdam spec

Item `reth` of batch `el-transfer`. I only read public pages. Nothing was run against any network or node.

## 0. Method and limits (read first)

- **No shell was available.** Every Bash call on this machine failed before it ran (`apply-seccomp: write /proc/self/setgroups ... Permission denied`). So I could not make a shallow clone, run `grep -n` or count lines. All code was read with an HTTP fetch tool from `raw.githubusercontent.com` (at the pinned SHAs) and from `api.github.com`. That tool returns page text through a summariser. Code blocks quoted below were returned as verbatim reproductions, but **line numbers could not be machine-verified.** Where a line number appears, it is marked `~` (approximate, from the fetch tool) and may be off by a few lines. Each citation also names the enclosing function, so it can be located exactly. The merge step should re-pin the line numbers with `grep -n` on a clone.
- **Where Reth's EVM code lives.** Reth no longer uses revm. PR "refactor!: replace revm with evm2" (https://github.com/paradigmxyz/reth/pull/25002, merged) moved it to `alloy-rs/evm2`. At the Reth pin, `Cargo.toml` (~L441-443) has `evm2 = { git = "https://github.com/alloy-rs/evm2", rev = "4a53ec080dfbb904e98e3ca8f0abcc11ab8abae0", default-features = false }`, and the same rev for `evm2-inspectors` and `evm2-jit`. All EIP-7708/8246 logic that Reth executes is in evm2 at that rev, so evm2 is cited at that rev.

## 1. Pins

| Repo | Ref | Commit | Commit date (UTC) |
|---|---|---|---|
| paradigmxyz/reth | main | `e0378eb691aa2770bb96fd4ecec2f4445a2a3e7c` | 2026-10-27T12:11:23Z ("ci(docker): give each target its own cargo cache…(#28166)") |
| alloy-rs/evm2 (Reth's EVM, pinned in Reth `Cargo.toml`) | rev | `4a53ec080dfbb904e98e3ca8f0abcc11ab8abae0` | n/a |
| ethereum/execution-specs | forks/bogota | `7f2f4c9bf6d73b09ecb8b24eb14d08bf0cf0247b` | 2026-10-27T17:40:55Z |
| ethereum/go-ethereum | master | `8817f99f92852f8ac53e596de6202dd9e3494065` | 2026-10-27T03:32:47Z |

EIP status when read: EIP-7708 is Last Call (deadline 2026-11-01) and requires 1559, 4788, 6780 and 8246. EIP-8246 "Remove SELFDESTRUCT Burn" is Last Call (deadline 2026-11-04) and requires 161 and 6780. Both are listed in the Glamsterdam fixture release tracker (https://github.com/ethereum/execution-specs/issues/3217). Neither is live on mainnet. Amsterdam is not activated.

## 2. Candidates

**No candidate is raised.** For every rule in the brief, Reth (evm2) produces the same gas, state, logs and block-validity result as the execution-specs reference. Section 3 lists the rules and the evidence. Section 2.1 records the one area I could not close. It is below the bar for a candidate: confidence is low, the reth-side half could not be read, and mainnet cannot reach it.

### 2.1 Open observation, not a candidate: storage of an EIP-8246 "survivor" that was created over pre-existing storage

1. **Spec.** EIP-8246 says accounts marked for selfdestruction are, at finalization, modified so that "all storage is cleared" (https://eips.ethereum.org/EIPS/eip-8246). The reference (`fork.py`, `process_transaction`) runs `clear_account_preserving_balance(tx_env.state, address)` for each `accounts_to_delete`. In `state_tracker.py` that function calls `destroy_storage(tx_state, address)` and sets `nonce = 0` and `code_hash = EMPTY_CODE_HASH`. In addition, `vm/interpreter.py` `process_create` already runs `destroy_storage(tx_state, evm.current_target)` when the contract is created.
2. **Client.** evm2 `crates/evm2/src/evm/state/mod.rs`, `fn finalize_transaction` (~L660):
   ```rust
   if eip8246 && !account.balance().is_zero() {
       account.reset_selfdestructed_for_finalization();
       // Like revm, a created survivor keeps its unloaded old storage, which later
       // transactions read as zero.
       account.is_created()
   } else { account.delete_for_finalization(); false }
   ...
   if keeps_storage { storage.zero_loaded() } else { storage.wipe() }
   ```
   The PR that introduced this is https://github.com/alloy-rs/evm2/pull/734 (merged 2026-10-23). It says a created survivor "keeps its old storage, zeroes only the loaded slots, and emits no storage wipe". It adds that `CacheDB`/`Cache::merge` "apply persisted changes where old slots survive, as in revm's bundle". Open draft https://github.com/alloy-rs/evm2/pull/562 ("derive storage resets from account lifecycle") touches the same area.
3. **Condition.** This only matters if a contract is created at an address that already holds storage but has zero nonce and no code, and then self-destructs in the same transaction while it still holds a balance. In that case the spec's post-state has empty storage for the account. Reth's persisted storage root would also be empty only if Reth wipes storage on the account's "created" status. I could not read that part of Reth's state-stream code. Per EIP-7610, mainnet has 28 such addresses and no known way to deploy to them. The case can only arise from synthetic pre-state (test fixtures, devnets).
4. **Geth.** Not compared. I did not read Geth's state-finalization code for this path.
5. **Confidence: low.** Disproof tried: evm2 reads such slots as zero (PR 734), so execution results agree. Only the persisted root is in doubt, and that needs the Reth side, which I did not read. Also, a created account normally has no old storage, so in every reachable case `zero_loaded()` and `wipe()` give the same result. The account-creation-over-storage path is not EIP-7708/8246 logic, and parts of it are live on mainnet. I did not analyse it further, and no details of it are given here.
6. **Severity if real:** state-root mismatch, which would be a consensus split, on synthetic pre-state only.

## 3. Rules checked and found consistent with the spec

Spec files are under `src/ethereum/forks/amsterdam/` at `7f2f4c9b…`. evm2 files are under `crates/evm2/src/` at `4a53ec08…`. Geth is at `8817f99f…`.

### 3.1 The EIP-7708 log itself

| Rule | Spec | Reth (evm2) | Geth | Verdict |
|---|---|---|---|---|
| Emitter is `SYSTEM_ADDRESS` 0xff…fe; topic0 is keccak256 of `Transfer(address,address,uint256)`; topics 1 and 2 are the addresses left-padded to 32 bytes; data is the amount as 32-byte big-endian | `vm/__init__.py` `TRANSFER_TOPIC`/`SYSTEM_ADDRESS` (~L35-38); `emit_transfer_log` (~L247-283): `Log(address=SYSTEM_ADDRESS, topics=(TRANSFER_TOPIC, Hash32(padded_sender), Hash32(padded_recipient)), data=transfer_amount.to_be_bytes32())` | `evm/mod.rs` free fn `eip7708_transfer_log`: `topics = vec![EIP7708_TRANSFER_TOPIC, B256::left_padding_from(from..), B256::left_padding_from(to..)]`, `address: SYSTEM_ADDRESS`, data `value.to_be_bytes::<32>()`. The topic constant is imported from `constants` (`use crate::{… constants::{CALL_DEPTH_LIMIT, EIP7708_TRANSFER_TOPIC} …}`). I did not read the constant's bytes. | `core/types/log.go` `EthTransferLog`: `params.SystemAddress`, `params.EthTransferLogEvent`, `common.BytesToHash(from/to)`, `amount.Bytes32()` | consistent (topic constant bytes not seen) |
| Zero value emits no log | `emit_transfer_log`: `if transfer_amount == 0: return` | `eip7708_transfer_log`: `if value.is_zero() \|\| from == to { return None; }` | `Transfer` and `opSelfdestruct6780` check `!amount.IsZero()` | consistent |
| Self-transfer (sender equals recipient) emits no log | Each call site checks it: `process_call` uses `if evm.caller != evm.current_target:`; `selfdestruct` uses `if beneficiary != originator:`. `emit_transfer_log` itself does not check. | Checked inside `eip7708_transfer_log` (`from == to`) for every call site | `sender != recipient` / `this != beneficiary` | consistent |
| Feature gated to Amsterdam | n/a | `version/mod.rs` `AMSTERDAM { features: [EIP8037, EIP7708, EIP8246, EIP2780], … }`. Tests assert `!osaka.feature(EIP7708)` and `!osaka.feature(EIP8246)`. | `rules.IsAmsterdam` | consistent |
| No gas charged for the log | No gas charge in `emit_transfer_log` | `log_eip7708_transfer` only pushes a log, with no gas accounting | `AddLog` only | consistent |
| Transfer log does not add `SYSTEM_ADDRESS` to the BAL (EIP-7928) | Log construction does not touch state. Tracker #3217 has the checked item "Assert that transfer logs do not add `SYSTEM_ADDRESS` to the block access list". | `log_eip7708_transfer` → `self.log(log)` → `state.log` (`self.logs.push(log)`). No account load. | `AddLog` | consistent |
| Logs dropped on revert or failure | `process_call`/`process_create`: `restore_tx_state(...)` on error. Child logs merge only on success. `process_top_level`: `if evm.error: logs = ()` | The checkpoint is taken before the transfer in `execute_call_message` (`let checkpoint = self.state.checkpoint();`). `State::checkpoint` records `logs.len()`. `State::rollback` does `self.logs.truncate(checkpoint.logs_len)`. Rollback runs on `!stop.is_success()` in `finish_call_message_run` and `execute_call_precompile`. | journaled logs | consistent |
| Gate `logs_observed()` | n/a | `log_eip7708_transfer` requires `self.feature(EIP7708) && self.logs_observed()`. `logs_observed = feature(LOG_COLLECTION) \|\| inspector.is_some()`. `LOG_COLLECTION` is on in the base `Version` (test `base_versions_set_cfg_env_defaults`). With collection off, the log is skipped but no gas or state changes. Logs not collected cannot reach receipts, so the gate cannot change gas or state. A Reth PR to skip collection where logs are unused was closed without merging (https://github.com/paradigmxyz/reth/pull/27968). | n/a | consistent for consensus |
| Receipts, bloom, log index | EIP log goes into `evm.logs`, then `TransactionOutput.logs`, then the receipt | The log goes into the same `State.logs` vector as LOG0-4 (`Host::log` → `Evm::log` → `state.log`). Reth builds the receipt from the tx outcome (`crates/ethereum/evm/src/executor.rs`, `receipt_builder.build_receipt` ~L325-340). Ordering and indices follow the shared vector. | same vector | consistent |
| Deposit-request parsing is not confused by the new logs | n/a | `crates/ethereum/evm/src/execution.rs` `block_requests_from_receipts` (~L322-342) filters by `deposit_contract_address`. Transfer logs come from `SYSTEM_ADDRESS`. | n/a | consistent |

### 3.2 Which value movements emit a log

| Movement | Spec | Reth (evm2) | Geth | Verdict |
|---|---|---|---|---|
| Transaction value (call tx), emitted before any other log | `vm/interpreter.py` `process_call`: `move_ether(...)` then `emit_transfer_log` before the code runs. The top-level message uses `should_transfer_value=True`. | The top-level message goes through `execute_call_message`: `state.transfer` then `log_eip7708_transfer`, before the precompile or interpreter runs. `ethereum/mod.rs` `prepare_initial_frame` passes `value` into the message. | `core/evm.go` `Transfer` (AddLog) called from `EVM.Call` | consistent |
| CALL with value; transfer to a precompile; transfer to an EIP-7702-delegated EOA | Log recipient is `evm.current_target`, the called address, not the code address. The precompile runs after the transfer and log. | Log recipient is `message.destination`, and the transfer and log happen before `contains_precompile`. The recipient is therefore the authority (destination), not the delegate's code. | `Transfer(caller, addr, …)` before `RunPrecompiledContract` | consistent |
| CALLCODE with value | `callcode` passes `should_transfer_value=True`, but caller and current_target are the same, so no log | `MessageKind::CallCode` transfers to itself; `transfer` with `from == to` only touches; no log | `CallCode` has no `Transfer` | consistent |
| DELEGATECALL, STATICCALL | no value | `DelegateCall` does not transfer. `StaticCall` value is 0, so no log. | n/a | consistent |
| CREATE/CREATE2 value, including a top-level create tx | `process_create` → `process_call` → `move_ether` and log to the new address, before initcode logs. On collision, `generic_create` returns before `process_create`, so no log. | `create_message_account`: `state.create_account` (collision returns `CreateCollision` first, out of funds returns `OutOfFunds`) then `log_eip7708_transfer(caller, destination, value)` | `create`: `Transfer(caller, address, value)` | consistent |
| SELFDESTRUCT to a different beneficiary | `selfdestruct`: `move_ether(originator, beneficiary, originator_balance)`; `if beneficiary != originator: emit_transfer_log(...)` | `Host::selfdestruct`: `if contract != target { transferred = state.transfer(contract, target, &balance)?; if transferred { log_eip7708_transfer(contract, target, &balance) } }` | `opSelfdestruct6780`: `IsAmsterdam && !balance.IsZero() && this != beneficiary` → AddLog | consistent |
| Fees (priority fee to coinbase, refunds, base-fee burn): no log | `fork.py` `disburse_gas_fees` uses `create_ether` and emits no log. The EIP-7708 Rationale excludes these. | `ethereum/mod.rs` `default_settle_gas` (~L683-708) uses `add_balance` and emits no log | n/a | consistent |
| Withdrawals: no log | `process_withdrawals` uses `create_ether` and emits no log | `crates/ethereum/evm/src/execution.rs` `post_block_balance_state_changes` (~L484-560) uses `balance_increments` and emits no log | n/a | consistent |
| System calls (4788, 2935, 7002, 7251) | System-tx logs are never added to `block_logs`. Value is 0. | `execute_system_call` → `evm.system_call(SystemTx::new(address, data))`. No value, and the logs are not part of the receipts. | `Call` with `syscall` skips `Transfer` | consistent |

### 3.3 SELFDESTRUCT (EIP-8246 together with EIP-6780 and EIP-8037)

| Rule | Spec (`vm/instructions/system.py` `selfdestruct`; `fork.py`; `state_tracker.py`) | Reth (evm2) | Geth | Verdict |
|---|---|---|---|---|
| Beneficiary is self and the account was created in this tx: balance unchanged (no burn) | `move_ether(o, o, bal)` has no net effect | `else if should_destroy && !balance.is_zero() && !self.feature(EIP8246) { …burn… }`, so the burn is skipped under 8246 | `else if !evm.chainRules.IsAmsterdam { SubBalance }` | consistent |
| Beneficiary is self and the account is pre-existing: no change, no log | `move_ether` has no net effect; no log | Neither branch applies | `!newContract && this != beneficiary` is false | consistent |
| Account marked for deletion only if created in the same tx | `if originator in tx_state.created_accounts: evm.accounts_to_delete.add(originator)` | `should_destroy = !EIP6780 \|\| account.is_created()`, then `mark_destructed()` | `IsNewContract` | consistent |
| Finalization: nonce 0, code cleared, storage cleared, balance kept; a zero balance means the account is deleted | `clear_account_preserving_balance` → `destroy_storage` and `modify_state`. `modify_state` destroys the account if it is empty. | `finalize_transaction`: if balance is nonzero, `reset_selfdestructed_for_finalization` (`present = Some(AccountInfo::default().with_balance(balance))`), otherwise `delete_for_finalization` plus `storage.wipe()`. A created survivor uses `zero_loaded()` (see 2.1). | not read | consistent (except the 2.1 edge) |
| ETH received after SELFDESTRUCT in the same tx is kept (no burn at the end of the tx) | `clear_account_preserving_balance` keeps the final balance | The finalization branch reads the balance at finalization time | not read | consistent |
| Only accounts from successful frames are finalized | `process_top_level`: `if evm.error: accounts_to_delete = set()`. Child sets merge only on success. | `State::rollback` reconciles the `selfdestructs` set against `previous_is_destroyed` | n/a | consistent |
| Gas: base 5000 plus cold access, checked before the state access | `gas_cost = OPCODE_SELFDESTRUCT_BASE (+COLD_ACCOUNT_ACCESS)`; `check_gas(evm, gas_cost)` before access | Static `SELFDESTRUCT: 5000` (`version/mod.rs`, Tangerine/Berlin tables). `instructions/system.rs` `selfdestruct` (~L419-447): `skip_cold_load = cx.gas.remaining() < selfdestruct_cold_cost()`. A cold beneficiary that cannot be paid returns `OutOfGas` without loading it (`account_with_skip`, `ColdLoadSkipped`), so no BAL access is recorded. | n/a | consistent (EIP-8038 values not in scope) |
| Dead-beneficiary charge: ACCOUNT_WRITE execution gas plus NEW_ACCOUNT state gas, only if the beneficiary is not alive and the originator's balance is nonzero; execution gas charged before state gas | `if not is_account_alive(beneficiary) and balance != 0: state_gas = NEW_ACCOUNT; account_write_gas = ACCOUNT_WRITE`; `charge_gas(...)`, then `charge_state_gas(...)` | `should_charge_new_account_gas(EIP161, res.had_value, res.target_is_empty)`. `selfdestruct_cost` adds `NewAccountCostForSelfdestruct` (Amsterdam: `EIP8038_ACCOUNT_WRITE`). `cx.gas.spend(...)` runs first, then `spend_state(new_account_state_gas())` (Amsterdam `NewAccountState: 120 * AMSTERDAM_CPSB`). `is_empty_for_new_account_gas` under EIP-161 is "absent or empty", the inverse of `is_account_alive`. | n/a | consistent |
| State change before the gas charge (evm2) compared with the spec order | Gas is charged, then `move_ether` | `host().selfdestruct` (transfer, log, mark) runs before `cx.gas.spend`. If the charge fails, the frame halts and `rollback` undoes the transfer, log and destructed mark. The BAL is unaffected because the beneficiary was read in both orders. | n/a | equivalent |

## 4. Known-check

| Query | Result |
|---|---|
| paradigmxyz/reth issues and PRs, open and closed, `7708` | 8 hits (search API). None concern consensus or execution of EIP-7708. Most are RPC `eth_simulateV1`: #27110 "suppress synthetic transfer logs when EIP-7708 is active" (merged), #27963 "keep simulate transfers out of receipts" (open), #26854 (closed), #27968 "skip log collection where logs are unused" (closed, not merged), #26865 "post-Cancun selfdestruct e2e suite across hardforks" (merged). |
| reth `8246` | #27849 "stream native state updates to execution hooks" (merged) and #26865. Neither reports a bug. |
| reth `"transfer log"` | 23 hits, all RPC or tracing (e.g. #26627, #24729, #24816, #14052). None are execution bugs. |
| reth `selfdestruct burn` | #25002 "replace revm with evm2" (merged) only |
| alloy-rs/evm2 `7708 OR 8246 OR selfdestruct` | 50 hits; 23 seen. Relevant: #205 "feat(eip8246): remove SELFDESTRUCT balance burn" (merged), #668 "add LOG_COLLECTION feature" (merged), #734 "match revm storage semantics for created accounts" (merged 2026-10-23), #562 (draft, open), #396 "preserve BAL code and selfdestruct reads" (merged), #342 "clear selfdestructs on transaction commit" (merged). None reports an open spec divergence. |
| execution-specs "Test Release Tracker" | #3217 `tests@v21.0.0` (Glamsterdam Testnet, open) lists 7708 and 8246 with completed items (no SYSTEM_ADDRESS in the BAL; same-tx SELFDESTRUCT clears code, nonce and storage while keeping balance). #3889 `bogota-devnet@v2.0.0` (open) mentions Reth but not 7708 or 8246. |
| hive.ethpandaops.io/glamsterdam/listing.jsonl | Latest Reth runs: `eels/consume-rlp` 44155 pass, 6 fail (Reth 2.8.1-dev+def3e3c5, 2026-10-26T03:47Z); `eels/consume-engine` 42599/1626; `eels/consume-enginex` 80164/3094 (2.8.1-dev+3c8b4e84, 2026-10-26T10:02Z); `eels/execute` 173/0. The per-test result files (62-117 MB) were too large to fetch, so I could not tell whether any failure is in 7708 or 8246 tests. |
| Observation 2.1 | not found (queries above; evm2 #734 documents the behaviour as intended, "like revm") |

## 5. URLs read

- https://api.github.com/repos/paradigmxyz/reth/commits/main
- https://api.github.com/repos/ethereum/execution-specs/commits/forks/bogota
- https://api.github.com/repos/ethereum/go-ethereum/commits/master
- https://eips.ethereum.org/EIPS/eip-7708
- https://eips.ethereum.org/EIPS/eip-8246
- https://raw.githubusercontent.com/ethereum/execution-specs/7f2f4c9bf6d73b09ecb8b24eb14d08bf0cf0247b/src/ethereum/forks/amsterdam/vm/__init__.py
- …/amsterdam/vm/instructions/system.py, …/amsterdam/vm/interpreter.py, …/amsterdam/fork.py, …/amsterdam/state_tracker.py (same SHA). `state.py` returned 404 because the Amsterdam state module is `state_tracker.py`. I did not read `transactions.py`; no 7708/8246 rule depends on it beyond the tx value, which `interpreter.py` covers.
- https://api.github.com/repos/ethereum/execution-specs/contents/src/ethereum/forks/amsterdam?ref=7f2f4c9bf6d73b09ecb8b24eb14d08bf0cf0247b
- https://raw.githubusercontent.com/paradigmxyz/reth/e0378eb691aa2770bb96fd4ecec2f4445a2a3e7c/Cargo.toml
- …/reth/e0378eb…/crates/ethereum/evm/src/executor.rs, …/execution.rs; https://api.github.com/repos/paradigmxyz/reth/contents/crates/ethereum/evm/src?ref=e0378eb…
- https://api.github.com/repos/alloy-rs/evm2/git/trees/4a53ec080dfbb904e98e3ca8f0abcc11ab8abae0?recursive=1
- https://raw.githubusercontent.com/alloy-rs/evm2/4a53ec080dfbb904e98e3ca8f0abcc11ab8abae0/crates/evm2/src/evm/mod.rs, …/evm/handler.rs, …/evm/state/mod.rs, …/evm/state/journal.rs, …/evm/state/account.rs, …/interpreter/instructions/system.rs, …/version/gas_params.rs, …/version/mod.rs, …/ethereum/mod.rs
- https://raw.githubusercontent.com/ethereum/go-ethereum/8817f99f92852f8ac53e596de6202dd9e3494065/core/vm/instructions.go, …/core/vm/evm.go, …/core/evm.go, …/core/types/log.go
- https://api.github.com/search/issues?q=repo:paradigmxyz/reth+7708 ; …+8246 ; …+%22transfer+log%22 ; …+selfdestruct+burn
- https://api.github.com/search/issues?q=repo:alloy-rs/evm2+7708+OR+8246+OR+selfdestruct ; https://api.github.com/repos/alloy-rs/evm2/pulls/734
- https://api.github.com/search/issues?q=repo:ethereum/execution-specs+%22Test+Release+Tracker%22 ; https://api.github.com/repos/ethereum/execution-specs/issues/3217
- https://hive.ethpandaops.io/glamsterdam/listing.jsonl
