From ce2b19be7550748bb179f86fd7458729251c9900 Mon Sep 17 00:00:00 2001 From: Francis Li Date: Mon, 5 Oct 2026 20:01:16 +0000 Subject: [PATCH 1/5] Restore Beacon state loads as validated replacements Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-01a10d69-00b6-7378-855f-af2b1a148692 --- README.md | 12 +- crates/anvil/src/eth/backend/mem/fork_db.rs | 1 + crates/anvil/src/eth/backend/mem/mod.rs | 85 ++++++++- crates/anvil/tests/it/beacon_state.rs | 184 ++++++++++++++++++-- 4 files changed, 258 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 05b6cb17b..810c52214 100644 --- a/README.md +++ b/README.md @@ -69,10 +69,14 @@ are bounded to year 9999. Transaction-body pruning is incompatible with retainin Safe/finalized tags remain Anvil's simulated block-depth policy, not Ethereum finality. State dumps in this mode record the fork block number, hash and timestamp, chain ID, Beacon -genesis, slot duration and `--slots-in-an-epoch`. A Beacon-backed node rejects every state load, -including `--state` resume: dumps with another or no identity are incompatible, and Anvil's -account-merging load cannot replace a Beacon fork's history. Other modes still load legacy dumps -but reject Beacon dumps. +genesis, slot duration and `--slots-in-an-epoch`. Resume with `--state ` and the same +explicit fork block and settings. Dumps with another or no identity, or whose local headers do not +extend the fork block contiguously on later slots, are rejected before loading. Other modes still +load legacy dumps but reject Beacon dumps. A Beacon load, including a live `anvil_loadState`, +replaces local accounts, blocks and snapshots instead of merging histories; pause mining and drain +pending transactions first. Clock and fees resume from the canonical tip. Dumps contain neither +endpoint URLs nor clock offsets or pending timestamp overrides, so an orchestrator must reapply +connectivity, mining policy and a current aligned timestamp after downtime. Everything below is inherited from upstream Foundry and works unchanged; only the Base additions above are specific to this fork. diff --git a/crates/anvil/src/eth/backend/mem/fork_db.rs b/crates/anvil/src/eth/backend/mem/fork_db.rs index ff531a013..686c4a2d9 100644 --- a/crates/anvil/src/eth/backend/mem/fork_db.rs +++ b/crates/anvil/src/eth/backend/mem/fork_db.rs @@ -111,6 +111,7 @@ impl MaybeFullDatabase for ForkedDatabase { fn clear(&mut self) { self.flush_cache(); self.clear_into_state_snapshot(); + self.database_mut().cache = Default::default(); } fn init_from_state_snapshot(&mut self, state_snapshot: StateSnapshot) { diff --git a/crates/anvil/src/eth/backend/mem/mod.rs b/crates/anvil/src/eth/backend/mem/mod.rs index cdfd37190..6a2ae0447 100644 --- a/crates/anvil/src/eth/backend/mem/mod.rs +++ b/crates/anvil/src/eth/backend/mem/mod.rs @@ -17,7 +17,7 @@ use crate::{ storage::MinedTransactionReceipt, }, notifications::{NewBlockNotification, NewBlockNotifications}, - time::{TimeManager, utc_from_secs}, + time::{MAX_BEACON_TIMESTAMP, TimeManager, utc_from_secs}, validate::TransactionValidator, }, error::{BlockchainError, ErrDetail, InvalidTransactionError}, @@ -1098,6 +1098,9 @@ impl Backend { basefee: env.evm_env.block_env.basefee, ..Default::default() } + } else { + // A state load may invalidate backend snapshots while the database still holds them. + return Ok(false); } Ok(self.db.write().await.revert_state(id, RevertStateSnapshotAction::RevertRemove)) } @@ -1174,12 +1177,68 @@ impl Backend { "State dump Beacon identity does not match the configured fork, chain or finality settings", ).into()); } - if identity.is_some() { - // Loading merges accounts and history; a Beacon fork needs a validated replacement. - return Err(RpcError::invalid_params( - "Loading state is not supported with a Beacon upstream", - ) - .into()); + let mut boundary_header = None; + if let Some(identity) = &identity { + // A Beacon resume is a replacement, unlike the legacy account-merge API. Validate + // the entire local chain before discarding anything, including a live node's suffix. + let mut headers = state.blocks.iter().map(|block| &block.header).collect::>(); + headers.sort_unstable_by_key(|header| header.number); + let mut number = identity.block_number; + let mut hash = identity.block_hash; + let mut timestamp = identity.timestamp; + for header in headers { + if number.checked_add(1) != Some(header.number) + || header.parent_hash != hash + || header.timestamp <= timestamp + || header.timestamp > MAX_BEACON_TIMESTAMP + || (header.timestamp - identity.genesis.genesis_time) + % identity.seconds_per_slot + != 0 + { + return Err(RpcError::invalid_params( + "Invalid local Beacon chain in state dump", + ) + .into()); + } + number = header.number; + hash = header.hash_slow(); + timestamp = header.timestamp; + } + if state.best_block_number != Some(number) + || state.block.as_ref().is_none_or(|block| { + block.number != U256::from(number) || block.timestamp != U256::from(timestamp) + }) + { + return Err(RpcError::invalid_params( + "State dump does not match its canonical Beacon tip", + ) + .into()); + } + let fork = self.get_fork().expect("Beacon identity requires a fork"); + if state.blocks.is_empty() { + // At the fork block the dump has no local body. Fetch its fee inputs before + // mutating state. + boundary_header = Some( + fork.block_by_number(identity.block_number) + .await? + .ok_or(BlockchainError::BlockNotFound)? + .header + .inner + .clone(), + ); + } + *self.blockchain.storage.write() = BlockchainStorage::forked( + identity.block_number, + identity.block_hash, + fork.total_difficulty(), + ); + self.states.write().clear(); + self.active_state_snapshots.lock().clear(); + let mut db = self.db.write().await; + db.clear(); + for block in &state.blocks { + db.insert_block_hash(U256::from(block.header.number), block.header.hash_slow()); + } } // load the blocks and transactions into the storage self.blockchain.storage.write().load_blocks(state.blocks.clone()); @@ -1230,8 +1289,16 @@ impl Backend { } } - if let Some(latest) = state.blocks.iter().max_by_key(|b| b.header.number) { - let header = &latest.header; + let head = state + .blocks + .iter() + .max_by_key(|b| b.header.number) + .map(|latest| AnyHeader::from(latest.header.clone())) + .or(boundary_header); + if let Some(header) = head { + if identity.is_some() { + self.time.reset(header.timestamp); + } let next_block_base_fee = self.fees.get_next_block_base_fee_per_gas( header.gas_used, header.gas_limit, diff --git a/crates/anvil/tests/it/beacon_state.rs b/crates/anvil/tests/it/beacon_state.rs index bcfe2ef9a..33f23ff1b 100644 --- a/crates/anvil/tests/it/beacon_state.rs +++ b/crates/anvil/tests/it/beacon_state.rs @@ -4,11 +4,12 @@ use crate::{ beacon_api::{ BEACON_GENESIS_FORK_VERSION, BEACON_GENESIS_TIME_SECS, BEACON_GENESIS_VALIDATORS_ROOT, BEACON_LOCAL_BLOB_DATA, BEACON_ORIGIN_BLOCK, BEACON_ORIGIN_TIMESTAMP_SECS, - BEACON_SECONDS_PER_SLOT, BeaconTargetFixture, beacon_slot_timestamp, beacon_target_config, - send_blob_txs, + BEACON_SECONDS_PER_SLOT, BeaconTargetFixture, beacon_sidecar, beacon_slot_timestamp, + beacon_target_config, send_blob_txs, }, utils::http_provider, }; +use alloy_consensus::BlobTransactionSidecar; use alloy_network::TransactionBuilder; use alloy_primitives::{Address, B256, Bytes, FixedBytes, U256, keccak256}; use alloy_provider::Provider; @@ -24,6 +25,8 @@ use std::{ time::{Duration, Instant}, }; +/// Payload of the single local blob mined at slot 23, after skipped slot 22. +const SLOT_23_BLOB_DATA: &[u8] = b"local slot 23 blob"; /// Payload of a blob mined only by a node whose history must be discarded or kept intact. const OTHER_BLOB_DATA: &[u8] = b"other node local blob"; @@ -46,16 +49,29 @@ fn json_u64(value: &Value) -> u64 { } } +/// Replaces an integer while keeping its JSON encoding (number, decimal, or hex string). +fn set_u64(value: &mut Value, n: u64) { + *value = match value { + Value::Number(_) => json!(n), + Value::String(s) if s.starts_with("0x") => json!(format!("{n:#x}")), + Value::String(_) => json!(n.to_string()), + _ => panic!("not an integer: {value}"), + }; +} + /// Changes an integer or hex value to a different value of the same shape. fn perturb(value: &mut Value) { - *value = match value { + match value { Value::String(s) if s.starts_with("0x") => { let last = if s.ends_with('0') { '1' } else { '0' }; - json!(format!("{}{last}", &s[..s.len() - 1])) + s.pop(); + s.push(last); } - Value::String(s) => json!((s.parse::().unwrap() + 1).to_string()), - _ => json!(json_u64(value) + 1), - }; + _ => { + let n = json_u64(value); + set_u64(value, n + 1); + } + } } fn quantity(n: u64) -> Value { @@ -125,6 +141,21 @@ async fn beacon_get(endpoint: &str, path: &str, ssz: bool) -> (u16, Vec) { (response.status().as_u16(), response.bytes().await.unwrap().to_vec()) } +/// Beacon paths covering metadata, every local slot through `last_slot`, and hash filtering. +fn beacon_paths(sent: &[(u64, &(B256, BlobTransactionSidecar))], last_slot: u64) -> Vec { + let mut paths = vec!["/eth/v1/beacon/genesis".to_string(), "/eth/v1/config/spec".to_string()]; + paths.extend((20..=last_slot).map(|slot| blobs_path(&slot.to_string()))); + let unknown = B256::repeat_byte(0x11); + for (slot, (_, sidecar)) in sent { + let hash = sidecar.versioned_hashes().next().unwrap(); + paths.push(blobs_path(&format!("{slot}?versioned_hashes={hash}"))); + paths.push(blobs_path(&format!("{slot}?versioned_hashes={unknown},{hash}"))); + // A hash from another slot must not leak into this slot. + paths.push(blobs_path(&format!("{}?versioned_hashes={hash}", slot + 1))); + } + paths +} + /// Execution and Beacon views that must be identical across equivalent states: blocks by number /// and hash, block receipts by number and hash, transactions and receipts by hash, and hashes of /// exact Beacon JSON and SSZ bodies. @@ -239,6 +270,13 @@ async fn load_json(api: &EthApi, state: &Value) -> Result { api.anvil_load_state(state).await.map_err(|error| error.to_string()) } +/// Returns the serialized header of local block `number`. +fn header_mut(state: &mut Value, number: u64) -> &mut Value { + let blocks = state["blocks"].as_array_mut().unwrap(); + let block = blocks.iter_mut().find(|block| json_u64(&block["header"]["number"]) == number); + &mut block.unwrap_or_else(|| panic!("block {number} not in dump"))["header"] +} + fn without_identity(state: &Value) -> Value { let mut state = state.clone(); state.as_object_mut().unwrap().remove("fork_beacon"); @@ -246,6 +284,11 @@ fn without_identity(state: &Value) -> Value { } const IDENTITY_MISMATCH: &str = "State dump Beacon identity does not match"; +const INVALID_CHAIN: &str = "Invalid local Beacon chain in state dump"; +const INVALID_TIP: &str = "State dump does not match its canonical Beacon tip"; + +/// Invalid dump: case name, expected error, and mutation of the dump. +type InvalidCase = (&'static str, &'static str, fn(&mut Value)); #[tokio::test(flavor = "multi_thread")] async fn beacon_state_rejected_loads_preserve_target() { @@ -253,6 +296,8 @@ async fn beacon_state_rejected_loads_preserve_target() { let source = fixture.handle.http_endpoint(); let from = fixture.handle.dev_accounts().next().unwrap(); fixture.mine_blob_block(ts(21), &BEACON_LOCAL_BLOB_DATA).await; + send_blob_txs(&source, from, 2, &[SLOT_23_BLOB_DATA]).await; + mine_at(&source, ts(23)).await; let dump = dump_json(&fixture.api).await; let source_txs = dump["transactions"] .as_array() @@ -260,7 +305,7 @@ async fn beacon_state_rejected_loads_preserve_target() { .iter() .map(|tx| serde_json::from_value::(tx["info"]["transaction_hash"].clone()).unwrap()) .collect::>(); - assert_eq!(source_txs.len(), 2, "source transactions in dump"); + assert_eq!(source_txs.len(), 3, "source transactions in dump"); // The target has its own chain, account changes and blobs at different slots. let config = beacon_target_config(fixture.origin.http_endpoint(), fixture.beacon.url.clone()); @@ -269,7 +314,7 @@ async fn beacon_state_rejected_loads_preserve_target() { let sent = send_blob_txs(&endpoint, from, 0, &[OTHER_BLOB_DATA]).await; let started = Instant::now(); mine_at(&endpoint, ts(22)).await; - let paths = ["21", "22"].map(blobs_path); + let paths = ["21", "22", "23"].map(blobs_path); let blocks = [BEACON_ORIGIN_BLOCK, BEACON_ORIGIN_BLOCK + 1]; let target_txs = [sent[0].0]; let expected = snapshot(&endpoint, &blocks, &target_txs, &paths).await; @@ -326,8 +371,36 @@ async fn beacon_state_rejected_loads_preserve_target() { perturb(state["fork_beacon"].pointer_mut(pointer).unwrap()); assert_rejected(&state, &format!("mismatched {pointer}"), IDENTITY_MISMATCH).await; } - let unsupported = "Loading state is not supported with a Beacon upstream"; - assert_rejected(&dump, "matching identity", unsupported).await; + + // Local history must extend the boundary contiguously on strictly later slots, ending at the + // selected head and block environment. + let invalid_cases: [InvalidCase; 9] = [ + ("off-grid timestamp", INVALID_CHAIN, |s| { + set_u64(&mut header_mut(s, 101)["timestamp"], ts(21) + 1) + }), + ("reused boundary slot", INVALID_CHAIN, |s| { + set_u64(&mut header_mut(s, 101)["timestamp"], BEACON_ORIGIN_TIMESTAMP_SECS) + }), + ("non-increasing timestamp", INVALID_CHAIN, |s| { + set_u64(&mut header_mut(s, 102)["timestamp"], ts(21)) + }), + ("timestamp after year 9999", INVALID_CHAIN, |s| { + set_u64(&mut header_mut(s, 102)["timestamp"], ts(21_116_858_317)) + }), + ("detached parent", INVALID_CHAIN, |s| perturb(&mut header_mut(s, 101)["parentHash"])), + ("missing block 101", INVALID_CHAIN, |s| { + let blocks = s["blocks"].as_array_mut().unwrap(); + blocks.retain(|block| json_u64(&block["header"]["number"]) != 101); + }), + ("stale best number", INVALID_TIP, |s| perturb(&mut s["best_block_number"])), + ("stale environment number", INVALID_TIP, |s| perturb(&mut s["block"]["number"])), + ("stale environment timestamp", INVALID_TIP, |s| perturb(&mut s["block"]["timestamp"])), + ]; + for (case, error, mutate) in invalid_cases { + let mut state = dump.clone(); + mutate(&mut state); + assert_rejected(&state, case, error).await; + } // Rejections leave the clock on the target's own tip. rpc_ok(&endpoint, "evm_mine", json!([])).await; @@ -377,3 +450,92 @@ async fn beacon_state_legacy_load_works_without_beacon() { assert_eq!(balance(&endpoint, recipient).await, value, "{mode} balance after Beacon dump"); } } + +#[tokio::test(flavor = "multi_thread")] +async fn beacon_state_load_replaces_local_history_and_resets_clock() { + let fixture = BeaconTargetFixture::spawn().await; + let source = fixture.handle.http_endpoint(); + let mut accounts = fixture.handle.dev_accounts(); + let (from, other) = (accounts.next().unwrap(), accounts.next().unwrap()); + let slot21 = fixture.mine_blob_block(ts(21), &BEACON_LOCAL_BLOB_DATA).await; + let slot23 = send_blob_txs(&source, from, 2, &[SLOT_23_BLOB_DATA]).await; + mine_at(&source, ts(23)).await; + let source_tip = BEACON_ORIGIN_BLOCK + 2; + let sent = [(21, &slot21[0]), (21, &slot21[1]), (23, &slot23[0])]; + let source_txs = sent.iter().map(|(_, (hash, _))| *hash).collect::>(); + let paths = beacon_paths(&sent, 26); + let blocks = (BEACON_ORIGIN_BLOCK..=source_tip).collect::>(); + let expected = snapshot(&source, &blocks, &source_txs, &paths).await; + let dump = dump_json(&fixture.api).await; + + // A longer, later local history with an account change the source never made. + let config = beacon_target_config(fixture.origin.http_endpoint(), fixture.beacon.url.clone()); + let (api, target) = spawn(config).await; + let endpoint = target.http_endpoint(); + let recipient = Address::repeat_byte(0x42); + let transfer = send_transfer(&endpoint, other, recipient, U256::from(1_000_000_000u64)).await; + mine_at(&endpoint, ts(22)).await; + let stale_blob = send_blob_txs(&endpoint, from, 0, &[OTHER_BLOB_DATA]).await[0].0; + mine_at(&endpoint, ts(24)).await; + mine_at(&endpoint, ts(26)).await; + let mut stale_hashes = Vec::new(); + for n in BEACON_ORIGIN_BLOCK + 1..=BEACON_ORIGIN_BLOCK + 3 { + stale_hashes.push(block_at(&endpoint, quantity(n)).await["hash"].clone()); + } + assert_eq!(balance(&endpoint, recipient).await, U256::from(1_000_000_000u64)); + let stale_snapshot = api.evm_snapshot().await.unwrap(); + + let started = Instant::now(); + load_json(&api, &dump).await.expect("matching Beacon state must load"); + assert!(!api.evm_revert(stale_snapshot).await.unwrap(), "stale snapshot survived load"); + + assert_eq!(block_number(&endpoint).await, source_tip); + let actual = snapshot(&endpoint, &blocks, &source_txs, &paths).await; + assert_same(&expected, &actual, "loaded history"); + assert!(block_at(&endpoint, quantity(source_tip + 1)).await.is_null(), "stale future block"); + for hash in &stale_hashes { + let block = rpc_ok(&endpoint, "eth_getBlockByHash", json!([hash, false])).await; + assert!(block.is_null(), "stale block {hash} still served"); + } + for tx in [transfer, stale_blob] { + let receipt = rpc_ok(&endpoint, "eth_getTransactionReceipt", json!([tx])).await; + assert!(receipt.is_null(), "stale transaction {tx} still served"); + } + assert_eq!(balance(&endpoint, recipient).await, U256::ZERO, "stale account change"); + assert_eq!(nonce(&endpoint, other).await, 0, "stale sender nonce"); + assert_eq!(nonce(&endpoint, from).await, 3, "loaded sender nonce"); + + rpc_ok(&endpoint, "evm_mine", json!([])).await; + let head = block_at(&endpoint, json!("latest")).await; + assert_eq!(json_u64(&head["number"]), source_tip + 1); + assert_resumed_timestamp(&head, 23, started); + assert_eq!(head["parentHash"], expected[&format!("block {source_tip}")]["hash"]); +} + +#[tokio::test(flavor = "multi_thread")] +async fn beacon_state_boundary_load_restores_fees() { + let fixture = BeaconTargetFixture::spawn().await; + let endpoint = fixture.handle.http_endpoint(); + let from = fixture.handle.dev_accounts().next().unwrap(); + let boundary_dump = fixture.api.anvil_dump_state(None).await.unwrap(); + mine_at(&endpoint, ts(21)).await; + let first = block_at(&endpoint, json!("latest")).await; + + // Full blob blocks move both fee inputs away from the boundary successor's. + let full = vec![1u8; 5 * 126_976 + 1]; + assert_eq!(beacon_sidecar(&full).blobs.len(), 6, "full blob payload"); + for (nonce, slot) in [(0, 22), (1, 23)] { + send_blob_txs(&endpoint, from, nonce, &[&full]).await; + mine_at(&endpoint, ts(slot)).await; + } + let moved = block_at(&endpoint, json!("latest")).await; + assert_ne!(moved["baseFeePerGas"], first["baseFeePerGas"]); + assert_ne!(moved["excessBlobGas"], first["excessBlobGas"]); + + fixture.api.anvil_load_state(boundary_dump).await.unwrap(); + mine_at(&endpoint, ts(21)).await; + let restored = block_at(&endpoint, json!("latest")).await; + assert_eq!(restored["baseFeePerGas"], first["baseFeePerGas"]); + assert_eq!(restored["excessBlobGas"], first["excessBlobGas"]); + assert_eq!(restored["hash"], first["hash"]); +} From 441c1110f044e2423ae0a1d5ed3afe4da266ef2a Mon Sep 17 00:00:00 2001 From: Francis Li Date: Mon, 5 Oct 2026 20:18:59 +0000 Subject: [PATCH 2/5] Pin the fork block hash when loading Beacon state Clearing the fork database drops cached block hashes, so BLOCKHASH(F) was refetched from the upstream's current canonical chain instead of the dump's fork identity. Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-01a10d69-00b6-7378-855f-af2b1a148692 Co-authored-by: Amp --- crates/anvil/src/eth/backend/mem/mod.rs | 1 + crates/anvil/tests/it/beacon_state.rs | 26 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/crates/anvil/src/eth/backend/mem/mod.rs b/crates/anvil/src/eth/backend/mem/mod.rs index 6a2ae0447..7e5a29837 100644 --- a/crates/anvil/src/eth/backend/mem/mod.rs +++ b/crates/anvil/src/eth/backend/mem/mod.rs @@ -1236,6 +1236,7 @@ impl Backend { self.active_state_snapshots.lock().clear(); let mut db = self.db.write().await; db.clear(); + db.insert_block_hash(U256::from(identity.block_number), identity.block_hash); for block in &state.blocks { db.insert_block_hash(U256::from(block.header.number), block.header.hash_slow()); } diff --git a/crates/anvil/tests/it/beacon_state.rs b/crates/anvil/tests/it/beacon_state.rs index 33f23ff1b..fb5026e79 100644 --- a/crates/anvil/tests/it/beacon_state.rs +++ b/crates/anvil/tests/it/beacon_state.rs @@ -539,3 +539,29 @@ async fn beacon_state_boundary_load_restores_fees() { assert_eq!(restored["excessBlobGas"], first["excessBlobGas"]); assert_eq!(restored["hash"], first["hash"]); } + +#[tokio::test(flavor = "multi_thread")] +async fn beacon_state_load_keeps_fork_block_hash_without_upstream() { + let fixture = BeaconTargetFixture::spawn().await; + let endpoint = fixture.handle.http_endpoint(); + let boundary = block_at(&endpoint, quantity(BEACON_ORIGIN_BLOCK)).await["hash"].clone(); + mine_at(&endpoint, ts(21)).await; + let dump = fixture.api.anvil_dump_state(None).await.unwrap(); + + // Regenerating the origin's genesis with another base fee gives the fork block a new hash. + fixture.origin_api.anvil_set_next_block_base_fee_per_gas(U256::from(1)).await.unwrap(); + fixture.origin_api.anvil_reset(None).await.unwrap(); + let origin = fixture.origin.http_endpoint(); + assert_ne!(block_at(&origin, quantity(BEACON_ORIGIN_BLOCK)).await["hash"], boundary); + + fixture.api.anvil_load_state(dump).await.unwrap(); + let contract = Address::repeat_byte(0x73); + let code = format!("0x7f{BEACON_ORIGIN_BLOCK:064x}4060005260206000f3"); + let evm_hash = rpc_ok( + &endpoint, + "eth_call", + json!([{"to": contract}, "pending", {contract.to_string(): {"code": code}}]), + ) + .await; + assert_eq!(evm_hash, boundary, "EVM BLOCKHASH(fork block)"); +} From 8e62a166277517756107d6be9a091b56ecfbe91d Mon Sep 17 00:00:00 2001 From: Francis Li Date: Mon, 5 Oct 2026 20:28:31 +0000 Subject: [PATCH 3/5] Restore exact nonces on Beacon state loads A Beacon load replaces state, so taking the maximum with the upstream nonce resurrected nonces deliberately set below it. Legacy loads keep merging. Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-01a10d69-00b6-7378-855f-af2b1a148692 --- crates/anvil/src/eth/backend/db.rs | 20 +++++++++++++------- crates/anvil/tests/it/beacon_state.rs | 20 ++++++++++++++++++++ 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/crates/anvil/src/eth/backend/db.rs b/crates/anvil/src/eth/backend/db.rs index 40e9781c3..f38a54ae5 100644 --- a/crates/anvil/src/eth/backend/db.rs +++ b/crates/anvil/src/eth/backend/db.rs @@ -152,14 +152,20 @@ pub trait Db: /// Deserialize and add all chain data to the backend storage fn load_state(&mut self, state: SerializableState) -> DatabaseResult { + // A Beacon load replaces state, so a nonce set below the upstream's must stay exact. + let replace = state.fork_beacon.is_some(); for (addr, account) in state.accounts.into_iter() { - let old_account_nonce = DatabaseRef::basic_ref(self, addr) - .ok() - .and_then(|acc| acc.map(|acc| acc.nonce)) - .unwrap_or_default(); - // use max nonce in case account is imported multiple times with difference - // nonces to prevent collisions - let nonce = std::cmp::max(old_account_nonce, account.nonce); + let nonce = if replace { + account.nonce + } else { + let old_account_nonce = DatabaseRef::basic_ref(self, addr) + .ok() + .and_then(|acc| acc.map(|acc| acc.nonce)) + .unwrap_or_default(); + // use max nonce in case account is imported multiple times with difference + // nonces to prevent collisions + std::cmp::max(old_account_nonce, account.nonce) + }; self.insert_account( addr, diff --git a/crates/anvil/tests/it/beacon_state.rs b/crates/anvil/tests/it/beacon_state.rs index fb5026e79..41837f156 100644 --- a/crates/anvil/tests/it/beacon_state.rs +++ b/crates/anvil/tests/it/beacon_state.rs @@ -540,6 +540,26 @@ async fn beacon_state_boundary_load_restores_fees() { assert_eq!(restored["hash"], first["hash"]); } +#[tokio::test(flavor = "multi_thread")] +async fn beacon_state_load_restores_nonce_below_upstream() { + let fixture = BeaconTargetFixture::spawn().await; + let account = Address::repeat_byte(0x55); + fixture.origin_api.anvil_set_nonce(account, U256::from(5)).await.unwrap(); + let endpoint = fixture.handle.http_endpoint(); + assert_eq!(nonce(&endpoint, account).await, 5, "upstream nonce"); + fixture.api.anvil_set_nonce(account, U256::ZERO).await.unwrap(); + let dump = fixture.api.anvil_dump_state(None).await.unwrap(); + + let config = beacon_target_config(fixture.origin.http_endpoint(), fixture.beacon.url.clone()); + let (restarted_api, restarted) = spawn(config).await; + for (api, endpoint, case) in + [(&fixture.api, endpoint, "live"), (&restarted_api, restarted.http_endpoint(), "restarted")] + { + api.anvil_load_state(dump.clone()).await.unwrap(); + assert_eq!(nonce(&endpoint, account).await, 0, "{case} load nonce"); + } +} + #[tokio::test(flavor = "multi_thread")] async fn beacon_state_load_keeps_fork_block_hash_without_upstream() { let fixture = BeaconTargetFixture::spawn().await; From 77e6e5c58104a79e7ed739e7b7870c5696de1547 Mon Sep 17 00:00:00 2001 From: Francis Li Date: Mon, 5 Oct 2026 20:38:34 +0000 Subject: [PATCH 4/5] Restore initial successor fees on Beacon boundary loads Loading a dump taken at the fork block derived fees from the upstream fork header, ignoring an explicit base fee the uninterrupted chain used. Fork setup now records the successor fee inputs, which boundary loads restore without an upstream request. Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-01a10d69-00b6-7378-855f-af2b1a148692 --- crates/anvil/src/config.rs | 2 + crates/anvil/src/eth/backend/fork.rs | 4 ++ crates/anvil/src/eth/backend/mem/mod.rs | 22 ++++----- crates/anvil/tests/it/beacon_state.rs | 65 ++++++++++++++++--------- 4 files changed, 58 insertions(+), 35 deletions(-) diff --git a/crates/anvil/src/config.rs b/crates/anvil/src/config.rs index 4b0ae34dc..4edb3fa19 100644 --- a/crates/anvil/src/config.rs +++ b/crates/anvil/src/config.rs @@ -1436,6 +1436,8 @@ latest block number: {latest_block}" total_difficulty: block.header.total_difficulty.unwrap_or_default(), blob_gas_used: block.header.blob_gas_used.map(|g| g as u128), blob_excess_gas_and_price: env.evm_env.block_env.blob_excess_gas_and_price, + next_base_fee: fees.base_fee(), + next_blob_excess_gas_and_price: fees.excess_blob_gas_and_price(), force_transactions, beacon, }; diff --git a/crates/anvil/src/eth/backend/fork.rs b/crates/anvil/src/eth/backend/fork.rs index 039db0c14..5aa5086cc 100644 --- a/crates/anvil/src/eth/backend/fork.rs +++ b/crates/anvil/src/eth/backend/fork.rs @@ -674,6 +674,10 @@ pub struct ClientForkConfig { pub blob_gas_used: Option, /// Blob excess gas and price of the forked block pub blob_excess_gas_and_price: Option, + /// Base fee of the fork block's successor, explicit or derived from the fork block + pub next_base_fee: u64, + /// Blob excess gas and price of the fork block's successor + pub next_blob_excess_gas_and_price: Option, /// request timeout pub timeout: Duration, /// request retries for spurious networks diff --git a/crates/anvil/src/eth/backend/mem/mod.rs b/crates/anvil/src/eth/backend/mem/mod.rs index 7e5a29837..db79ea77e 100644 --- a/crates/anvil/src/eth/backend/mem/mod.rs +++ b/crates/anvil/src/eth/backend/mem/mod.rs @@ -1177,7 +1177,6 @@ impl Backend { "State dump Beacon identity does not match the configured fork, chain or finality settings", ).into()); } - let mut boundary_header = None; if let Some(identity) = &identity { // A Beacon resume is a replacement, unlike the legacy account-merge API. Validate // the entire local chain before discarding anything, including a live node's suffix. @@ -1216,16 +1215,14 @@ impl Backend { } let fork = self.get_fork().expect("Beacon identity requires a fork"); if state.blocks.is_empty() { - // At the fork block the dump has no local body. Fetch its fee inputs before - // mutating state. - boundary_header = Some( - fork.block_by_number(identity.block_number) - .await? - .ok_or(BlockchainError::BlockNotFound)? - .header - .inner - .clone(), - ); + // At the fork block, restore the successor fees from fork setup: they may be + // explicit settings rather than values derived from the fork block. + let config = fork.config.read(); + self.time.reset(identity.timestamp); + self.fees.set_base_fee(config.next_base_fee); + if let Some(blob_fees) = config.next_blob_excess_gas_and_price { + self.fees.set_blob_excess_gas_and_price(blob_fees); + } } *self.blockchain.storage.write() = BlockchainStorage::forked( identity.block_number, @@ -1294,8 +1291,7 @@ impl Backend { .blocks .iter() .max_by_key(|b| b.header.number) - .map(|latest| AnyHeader::from(latest.header.clone())) - .or(boundary_header); + .map(|latest| AnyHeader::from(latest.header.clone())); if let Some(header) = head { if identity.is_some() { self.time.reset(header.timestamp); diff --git a/crates/anvil/tests/it/beacon_state.rs b/crates/anvil/tests/it/beacon_state.rs index 41837f156..d5839dc45 100644 --- a/crates/anvil/tests/it/beacon_state.rs +++ b/crates/anvil/tests/it/beacon_state.rs @@ -515,29 +515,50 @@ async fn beacon_state_load_replaces_local_history_and_resets_clock() { #[tokio::test(flavor = "multi_thread")] async fn beacon_state_boundary_load_restores_fees() { let fixture = BeaconTargetFixture::spawn().await; - let endpoint = fixture.handle.http_endpoint(); - let from = fixture.handle.dev_accounts().next().unwrap(); - let boundary_dump = fixture.api.anvil_dump_state(None).await.unwrap(); - mine_at(&endpoint, ts(21)).await; - let first = block_at(&endpoint, json!("latest")).await; - - // Full blob blocks move both fee inputs away from the boundary successor's. - let full = vec![1u8; 5 * 126_976 + 1]; - assert_eq!(beacon_sidecar(&full).blobs.len(), 6, "full blob payload"); - for (nonce, slot) in [(0, 22), (1, 23)] { - send_blob_txs(&endpoint, from, nonce, &[&full]).await; - mine_at(&endpoint, ts(slot)).await; - } - let moved = block_at(&endpoint, json!("latest")).await; - assert_ne!(moved["baseFeePerGas"], first["baseFeePerGas"]); - assert_ne!(moved["excessBlobGas"], first["excessBlobGas"]); + // An explicit base fee replaces the one derived from the fork block. + for base_fee in [None, Some(7)] { + let config = || { + beacon_target_config(fixture.origin.http_endpoint(), fixture.beacon.url.clone()) + .with_base_fee(base_fee) + }; + let (api, handle) = spawn(config()).await; + let endpoint = handle.http_endpoint(); + let from = handle.dev_accounts().next().unwrap(); + let boundary_dump = api.anvil_dump_state(None).await.unwrap(); + mine_at(&endpoint, ts(21)).await; + let first = block_at(&endpoint, json!("latest")).await; + if let Some(base_fee) = base_fee { + assert_eq!(json_u64(&first["baseFeePerGas"]), base_fee); + } - fixture.api.anvil_load_state(boundary_dump).await.unwrap(); - mine_at(&endpoint, ts(21)).await; - let restored = block_at(&endpoint, json!("latest")).await; - assert_eq!(restored["baseFeePerGas"], first["baseFeePerGas"]); - assert_eq!(restored["excessBlobGas"], first["excessBlobGas"]); - assert_eq!(restored["hash"], first["hash"]); + // Full blob blocks and a new base fee move both fee inputs away from the successor's. + api.anvil_set_next_block_base_fee_per_gas(U256::from(1_000)).await.unwrap(); + let full = vec![1u8; 5 * 126_976 + 1]; + assert_eq!(beacon_sidecar(&full).blobs.len(), 6, "full blob payload"); + for (nonce, slot) in [(0, 22), (1, 23)] { + send_blob_txs(&endpoint, from, nonce, &[&full]).await; + mine_at(&endpoint, ts(slot)).await; + } + let moved = block_at(&endpoint, json!("latest")).await; + assert_ne!(moved["baseFeePerGas"], first["baseFeePerGas"]); + // Explicit fees skip the fork's Cancun blob params, so six blobs don't exceed the target. + if base_fee.is_none() { + assert_ne!(moved["excessBlobGas"], first["excessBlobGas"]); + } + + let (restarted_api, restarted) = spawn(config()).await; + for (api, endpoint, case) in + [(&api, endpoint, "live"), (&restarted_api, restarted.http_endpoint(), "restarted")] + { + api.anvil_load_state(boundary_dump.clone()).await.unwrap(); + mine_at(&endpoint, ts(21)).await; + let restored = block_at(&endpoint, json!("latest")).await; + let context = format!("{case} load with base fee {base_fee:?}"); + assert_eq!(restored["baseFeePerGas"], first["baseFeePerGas"], "{context}"); + assert_eq!(restored["excessBlobGas"], first["excessBlobGas"], "{context}"); + assert_eq!(restored["hash"], first["hash"], "{context}"); + } + } } #[tokio::test(flavor = "multi_thread")] From 24df537cc60c7d39961d225a2b237da7d4ed30bc Mon Sep 17 00:00:00 2001 From: Francis Li Date: Mon, 5 Oct 2026 20:40:39 +0000 Subject: [PATCH 5/5] Include boundary hash regression in offline CI Amp-Thread-ID: https://ampcode.com/threads/T-01a10d69-00b6-7378-855f-af2b1a148692 Co-authored-by: Amp --- crates/anvil/tests/it/beacon_state.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/anvil/tests/it/beacon_state.rs b/crates/anvil/tests/it/beacon_state.rs index d5839dc45..09795428e 100644 --- a/crates/anvil/tests/it/beacon_state.rs +++ b/crates/anvil/tests/it/beacon_state.rs @@ -582,7 +582,7 @@ async fn beacon_state_load_restores_nonce_below_upstream() { } #[tokio::test(flavor = "multi_thread")] -async fn beacon_state_load_keeps_fork_block_hash_without_upstream() { +async fn beacon_state_load_keeps_boundary_hash_without_upstream() { let fixture = BeaconTargetFixture::spawn().await; let endpoint = fixture.handle.http_endpoint(); let boundary = block_at(&endpoint, quantity(BEACON_ORIGIN_BLOCK)).await["hash"].clone();