From 5d06758e7ef94b8ff56d67435ab34038dfd55133 Mon Sep 17 00:00:00 2001 From: Francis Li Date: Mon, 5 Oct 2026 19:17:36 +0000 Subject: [PATCH 1/4] fix(anvil): serialize state operations with mining Amp-Thread-ID: https://ampcode.com/threads/T-01a10d69-00b6-7378-855f-af2b1a148692 Co-authored-by: Amp --- crates/anvil/src/cmd.rs | 50 ++++++++++++++++++++++++- crates/anvil/src/eth/backend/mem/mod.rs | 7 ++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/crates/anvil/src/cmd.rs b/crates/anvil/src/cmd.rs index 995829ae7..5407d8f16 100644 --- a/crates/anvil/src/cmd.rs +++ b/crates/anvil/src/cmd.rs @@ -650,7 +650,9 @@ impl PeriodicStateDumper { Self { in_progress_dump: None, api, dump_state, preserve_historical_states, interval } } - async fn dump(&self) { + async fn dump(mut self) { + // The suspended periodic dump may own the mining lock. Drop it before the final dump. + self.in_progress_dump = None; if let Some(state) = self.dump_state.clone() { Self::dump_state(self.api.clone(), state, self.preserve_historical_states).await } @@ -800,6 +802,52 @@ mod tests { use super::*; use std::{env, net::Ipv4Addr}; + #[tokio::test(flavor = "multi_thread")] + async fn shutdown_cancels_suspended_periodic_dump() { + let (api, _handle) = crate::spawn(NodeConfig::test()).await; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("state.json"); + let db_guard = api.backend.get_db().write().await; + let mut dumper = PeriodicStateDumper::new( + api.clone(), + Some(path.clone()), + Duration::from_secs(60), + false, + ); + dumper.in_progress_dump = + Some(Box::pin(PeriodicStateDumper::dump_state(api.clone(), path.clone(), false))); + // Suspend the periodic dump after taking the mining lock, while awaiting the database. + assert!(futures::poll!(&mut dumper).is_pending()); + drop(db_guard); + tokio::time::timeout(Duration::from_secs(30), dumper.dump()).await.unwrap(); + let saved = SerializableState::load(&path).unwrap(); + assert_eq!(saved.best_block_number, Some(api.backend.best_number())); + } + + #[tokio::test(flavor = "multi_thread")] + async fn state_operations_wait_for_in_progress_mining() { + let (api, _handle) = crate::spawn(NodeConfig::test()).await; + let db_guard = api.backend.get_db().write().await; + let mut mining = Box::pin(api.backend.mine_block(Vec::new())); + assert!(futures::poll!(&mut mining).is_pending()); + let mut dump = Box::pin(api.serialized_state(false)); + assert!(futures::poll!(&mut dump).is_pending()); + let mut snapshot = Box::pin(api.evm_snapshot()); + assert!(futures::poll!(&mut snapshot).is_pending()); + drop(db_guard); + let (mined, saved, snapshot) = tokio::time::timeout(Duration::from_secs(30), async { + tokio::join!(mining, dump, snapshot) + }) + .await + .unwrap(); + let saved = saved.unwrap(); + assert_eq!(saved.best_block_number, Some(mined.block_number)); + assert_eq!(saved.block.unwrap().number, U256::from(mined.block_number)); + assert_eq!(saved.blocks.len(), 2); + let snapshots = api.backend.list_state_snapshots(); + assert_eq!(snapshots[&snapshot.unwrap()].0, mined.block_number); + } + #[test] fn test_parse_fork_url() { let fork: ForkUrl = "http://localhost:8545@1000000".parse().unwrap(); diff --git a/crates/anvil/src/eth/backend/mem/mod.rs b/crates/anvil/src/eth/backend/mem/mod.rs index c29e4dd40..c2bb567f1 100644 --- a/crates/anvil/src/eth/backend/mem/mod.rs +++ b/crates/anvil/src/eth/backend/mem/mod.rs @@ -518,6 +518,7 @@ impl Backend { /// Resets the fork to a fresh state pub async fn reset_fork(&self, forking: Forking) -> Result<(), BlockchainError> { + let _mining_guard = self.mining.lock().await; if !self.is_fork() { if let Some(eth_rpc_url) = forking.clone().json_rpc_url { let mut env = self.env.read().clone(); @@ -624,6 +625,7 @@ impl Backend { /// Resets the backend to a fresh in-memory state, clearing all existing data pub async fn reset_to_in_mem(&self) -> Result<(), BlockchainError> { + let _mining_guard = self.mining.lock().await; // Clear the fork if any exists *self.fork.write() = None; @@ -969,6 +971,7 @@ impl Backend { /// /// Returns the id of the snapshot created. pub async fn create_state_snapshot(&self) -> U256 { + let _mining_guard = self.mining.lock().await; let num = self.best_number(); let hash = self.best_hash(); let id = self.db.write().await.snapshot_state(); @@ -979,6 +982,7 @@ impl Backend { /// Reverts the state to the state snapshot identified by the given `id`. pub async fn revert_state_snapshot(&self, id: U256) -> Result { + let _mining_guard = self.mining.lock().await; let block = { self.active_state_snapshots.lock().remove(&id) }; if let Some((num, hash)) = block { let best_block_hash = { @@ -1033,6 +1037,7 @@ impl Backend { &self, preserve_historical_states: bool, ) -> Result { + let _mining_guard = self.mining.lock().await; let at = self.env.read().evm_env.block_env.clone(); let best_number = self.blockchain.storage.read().best_number; let blocks = self.blockchain.storage.read().serialized_blocks(); @@ -1071,6 +1076,7 @@ impl Backend { /// Apply [SerializableState] data to the backend storage. pub async fn load_state(&self, state: SerializableState) -> Result { + let _mining_guard = self.mining.lock().await; // load the blocks and transactions into the storage self.blockchain.storage.write().load_blocks(state.blocks.clone()); self.blockchain.storage.write().load_transactions(state.transactions.clone()); @@ -3469,6 +3475,7 @@ impl Backend { /// The state of the chain is rewound using `rewind` to the common block, including the db, /// storage, and env. pub async fn rollback(&self, common_block: Block) -> Result<(), BlockchainError> { + let _mining_guard = self.mining.lock().await; // Get the database at the common block let common_state = { let return_state_or_throw_err = From 142c5938e42a2cdc955396618a7149924f964af8 Mon Sep 17 00:00:00 2001 From: Francis Li Date: Mon, 5 Oct 2026 19:38:23 +0000 Subject: [PATCH 2/4] Reset execution and Beacon forks together Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-01a10d69-00b6-7378-855f-af2b1a148692 --- README.md | 3 +- crates/anvil/src/eth/api.rs | 8 +--- crates/anvil/src/eth/backend/mem/mod.rs | 59 +++++++++++++++++++++++ crates/anvil/tests/it/beacon_api.rs | 64 +++++++++++++++++++++---- 4 files changed, 117 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 73fa66499..cabe8de8d 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,8 @@ or its interval changes. Numeric Beacon IDs are slots, not execution block numbe slots through the fork block's slot use the upstream; later slots use only local blocks, and missing local slots never fall back to mainnet. JSON/SSZ and `versioned_hashes` filtering work on both sides of the boundary. Symbolic Beacon IDs and Beacon roots are unsupported in this mode. -`anvil_reset` is rejected in this mode. +`anvil_reset` prepares both upstreams before replacing the fork, so a failed read keeps the current +fork; resetting to in-memory mode leaves Beacon mode. Local timestamps stay on that slot grid and strictly advance. Explicit next timestamps must be aligned; timestamp intervals must be positive slot-duration multiples. Interval mining controls diff --git a/crates/anvil/src/eth/api.rs b/crates/anvil/src/eth/api.rs index 7333a7857..32ee16150 100644 --- a/crates/anvil/src/eth/api.rs +++ b/crates/anvil/src/eth/api.rs @@ -2188,14 +2188,8 @@ impl EthApi { /// /// Handler for RPC call: `anvil_reset` pub async fn anvil_reset(&self, forking: Option) -> Result<()> { - node_info!("anvil_reset"); - if self.backend.get_fork().is_some_and(|fork| fork.config.read().beacon.is_some()) { - return Err(RpcError::invalid_params( - "anvil_reset is unsupported with a fork Beacon URL", - ) - .into()); - } self.reset_instance_id(); + node_info!("anvil_reset"); if let Some(forking) = forking { // if we're resetting the fork we need to reset the instance id self.backend.reset_fork(forking).await diff --git a/crates/anvil/src/eth/backend/mem/mod.rs b/crates/anvil/src/eth/backend/mem/mod.rs index afd86920e..03bc185b6 100644 --- a/crates/anvil/src/eth/backend/mem/mod.rs +++ b/crates/anvil/src/eth/backend/mem/mod.rs @@ -527,6 +527,9 @@ impl Backend { /// Resets the fork to a fresh state pub async fn reset_fork(&self, forking: Forking) -> Result<(), BlockchainError> { let _mining_guard = self.mining.lock().await; + if self.node_config.read().await.fork_beacon_url.is_some() { + return self.reset_beacon_fork(forking).await; + } if !self.is_fork() { if let Some(eth_rpc_url) = forking.clone().json_rpc_url { let mut env = self.env.read().clone(); @@ -631,11 +634,67 @@ impl Backend { } } + /// Prepares both upstreams before replacing a Beacon-enabled fork. A failed metadata + /// request must not leave the execution database on a different fork from the slot schedule. + /// The caller must hold the mining lock. + async fn reset_beacon_fork(&self, forking: Forking) -> Result<(), BlockchainError> { + if !self.is_fork() && forking.json_rpc_url.is_none() { + return Err(RpcError::invalid_params( + "Forking not enabled and RPC URL not provided to start forking", + ) + .into()); + } + let mut config = self.node_config.read().await.clone(); + let url = forking + .json_rpc_url + .or_else(|| config.eth_rpc_url.clone()) + .ok_or_else(|| RpcError::invalid_params("Execution fork URL is required"))?; + config.eth_rpc_url = Some(url.clone()); + config.fork_choice = forking.block_number.map(|number| ForkChoice::Block(number as i128)); + config.base_fee = None; + config.gas_price = None; + let mut env = self.env.read().clone(); + let fees = FeeManager::new( + self.spec_id(), + self.fees.base_fee(), + self.fees.is_min_priority_fee_enforced(), + self.fees.raw_gas_price(), + config.get_blob_excess_gas_and_price(), + self.fees.blob_params(), + self.fees.base_fee_params(), + ); + let (db, fork_config) = config.setup_fork_db_config(url, &mut env, &fees).await?; + let beacon = fork_config.beacon.as_ref().expect("Beacon URL is configured"); + self.time.set_beacon_slots(Some((beacon.genesis.genesis_time, beacon.seconds_per_slot)))?; + self.time.reset(fork_config.timestamp); + *self.db.write().await = Box::new(db); + *self.env.write() = env; + *self.blockchain.storage.write() = BlockchainStorage::forked( + fork_config.block_number, + fork_config.block_hash, + fork_config.total_difficulty, + ); + *self.fork.write() = Some(ClientFork::new(fork_config, Arc::clone(&self.db))); + self.fees.set_base_fee(fees.base_fee()); + self.fees.set_gas_price(fees.raw_gas_price()); + self.fees.set_blob_params(fees.blob_params()); + if let Some(blob_fees) = fees.excess_blob_gas_and_price() { + self.fees.set_blob_excess_gas_and_price(blob_fees); + } + self.states.write().clear(); + self.active_state_snapshots.lock().clear(); + *self.node_config.write().await = config; + self.apply_genesis().await?; + Ok(()) + } + /// Resets the backend to a fresh in-memory state, clearing all existing data pub async fn reset_to_in_mem(&self) -> Result<(), BlockchainError> { let _mining_guard = self.mining.lock().await; // Clear the fork if any exists *self.fork.write() = None; + self.time.set_beacon_slots(None)?; + self.active_state_snapshots.lock().clear(); // Get environment and genesis config let env = self.env.read().clone(); diff --git a/crates/anvil/tests/it/beacon_api.rs b/crates/anvil/tests/it/beacon_api.rs index 5be6e0fc9..ef0e95f10 100644 --- a/crates/anvil/tests/it/beacon_api.rs +++ b/crates/anvil/tests/it/beacon_api.rs @@ -23,7 +23,10 @@ use axum::{ use foundry_evm::hardfork::EthereumHardfork; use ssz::Decode; use std::{ - sync::{Arc, Mutex}, + sync::{ + Arc, Mutex, + atomic::{AtomicU64, Ordering}, + }, time::Duration, }; use tokio::net::TcpListener; @@ -353,6 +356,7 @@ struct MockBeaconState { historical_blob: Blob, conflicting_blob: Blob, blob_requests: Mutex>, + seconds_per_slot: AtomicU64, } /// Mock Beacon node. Blob routes: slot 18 fails with 500, slots 20 and 21 return @@ -369,13 +373,14 @@ impl MockBeacon { historical_blob: beacon_sidecar(BEACON_HISTORICAL_BLOB_DATA).blobs[0], conflicting_blob: beacon_sidecar(BEACON_CONFLICTING_BLOB_DATA).blobs[0], blob_requests: Mutex::default(), + seconds_per_slot: AtomicU64::new(BEACON_SECONDS_PER_SLOT), }); let router = Router::new() .route("/eth/v1/beacon/genesis", get(|| async { Json(Self::genesis()) })) .route( "/eth/v1/config/spec", - get(|| async { - let slot = BEACON_SECONDS_PER_SLOT.to_string(); + get(|State(state): State>| async move { + let slot = state.seconds_per_slot.load(Ordering::Relaxed).to_string(); Json(serde_json::json!({ "data": { "SECONDS_PER_SLOT": slot } })) }), ) @@ -562,12 +567,6 @@ async fn beacon_api_numeric_ids_resolve_local_slots() { assert_eq!(beacon_blobs_status(&endpoint, "22").await, StatusCode::NOT_FOUND); assert!(beacon_blobs_json(&endpoint, "23").await.is_empty()); assert_eq!(fixture.beacon.blob_requests(), Vec::::new(), "post-boundary upstream"); - - let reset = Forking { json_rpc_url: None, block_number: Some(BEACON_ORIGIN_BLOCK) }; - assert!(fixture.api.anvil_reset(Some(reset)).await.is_err()); - assert!(fixture.api.anvil_reset(None).await.is_err()); - assert_eq!(fixture.api.block_number().unwrap(), U256::from(BEACON_ORIGIN_BLOCK + 2)); - assert_blobs_eq(&beacon_blobs_json(&endpoint, "21").await, &expected, "slot 21 after reset"); } #[tokio::test(flavor = "multi_thread")] @@ -662,3 +661,50 @@ async fn beacon_api_execution_reads_around_boundary() { assert!(provider.get_block(remote_hash).await.unwrap().is_none(), "hashes lookup"); assert!(provider.get_block(remote_hash).full().await.unwrap().is_none(), "full lookup"); } + +#[tokio::test(flavor = "multi_thread")] +async fn beacon_api_revert_and_reset_follow_canonical_history() { + let fixture = BeaconTargetFixture::spawn().await; + let endpoint = fixture.handle.http_endpoint(); + let provider = http_provider(&endpoint); + let snapshot = fixture.api.evm_snapshot().await.unwrap(); + fixture.mine_blob_block(1252, &BEACON_LOCAL_BLOB_DATA).await; + assert_eq!(beacon_blobs_json(&endpoint, "21").await.len(), 2); + assert!(fixture.api.evm_revert(snapshot).await.unwrap()); + assert_eq!(beacon_blobs_status(&endpoint, "21").await, StatusCode::NOT_FOUND); + fixture.mine_blob_block(1276, &BEACON_LOCAL_BLOB_DATA).await; + + // A failed Beacon refresh must preserve the execution fork, local blobs, metadata and clock. + let head = provider.get_block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + fixture.api.evm_set_next_block_timestamp(beacon_slot_timestamp(30)).unwrap(); + fixture.beacon.state.seconds_per_slot.store(0, Ordering::Relaxed); + let reset = Forking { json_rpc_url: None, block_number: Some(100) }; + assert!(fixture.api.anvil_reset(Some(reset.clone())).await.is_err()); + let kept = provider.get_block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert_eq!(kept.header.hash, head.header.hash); + assert_eq!(beacon_blobs_json(&endpoint, "23").await.len(), 2); + let spec = reqwest::get(format!("{endpoint}/eth/v1/config/spec")).await.unwrap(); + let spec = spec.json::().await.unwrap(); + assert_eq!(spec["data"]["SECONDS_PER_SLOT"], BEACON_SECONDS_PER_SLOT.to_string()); + fixture.api.evm_mine(None).await.unwrap(); + let next = provider.get_block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert_eq!((next.header.number, next.header.timestamp), (102, beacon_slot_timestamp(30))); + + fixture.beacon.state.seconds_per_slot.store(12, Ordering::Relaxed); + fixture.api.anvil_reset(Some(reset)).await.unwrap(); + assert_eq!(fixture.api.block_number().unwrap(), U256::from(100)); + assert_eq!(beacon_blobs_status(&endpoint, "23").await, StatusCode::NOT_FOUND); + + // Advance the origin and reset to that new boundary; the next local slot follows it. + fixture.origin_api.evm_mine(Some(MineOptions::Timestamp(Some(1276)))).await.unwrap(); + let reset = Forking { json_rpc_url: None, block_number: Some(101) }; + fixture.api.anvil_reset(Some(reset)).await.unwrap(); + fixture.api.evm_mine(None).await.unwrap(); + let head = provider.get_block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert_eq!((head.header.number, head.header.timestamp), (102, 1288)); + + // In-memory mode drops the Beacon slot restrictions. + fixture.api.anvil_reset(None).await.unwrap(); + fixture.api.evm_set_block_timestamp_interval(5).unwrap(); + fixture.api.evm_mine(None).await.unwrap(); +} From fd20fbea6e3e580243143a1328a12ed109bfdb74 Mon Sep 17 00:00:00 2001 From: Francis Li Date: Mon, 5 Oct 2026 20:17:19 +0000 Subject: [PATCH 3/4] Apply fork genesis before replacing a Beacon fork Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-01a10d69-00b6-7378-855f-af2b1a148692 --- crates/anvil/src/eth/backend/mem/mod.rs | 39 ++++++++------ crates/anvil/tests/it/beacon_api.rs | 69 ++++++++++++++++++++++++- 2 files changed, 90 insertions(+), 18 deletions(-) diff --git a/crates/anvil/src/eth/backend/mem/mod.rs b/crates/anvil/src/eth/backend/mem/mod.rs index 03bc185b6..292bf7e8a 100644 --- a/crates/anvil/src/eth/backend/mem/mod.rs +++ b/crates/anvil/src/eth/backend/mem/mod.rs @@ -359,7 +359,10 @@ impl Backend { } // Note: this can only fail in forking mode, in which case we can't recover - backend.apply_genesis().await.wrap_err("failed to create genesis")?; + backend + .apply_genesis(&backend.db, backend.is_fork()) + .await + .wrap_err("failed to create genesis")?; Ok(backend) } @@ -411,14 +414,18 @@ impl Backend { /// Applies the configured genesis settings /// /// This will fund, create the genesis accounts - async fn apply_genesis(&self) -> Result<(), DatabaseError> { + async fn apply_genesis( + &self, + db: &Arc>>, + forked: bool, + ) -> Result<(), DatabaseError> { trace!(target: "backend", "setting genesis balances"); - if self.fork.read().is_some() { + if forked { // fetch all account first let mut genesis_accounts_futures = Vec::with_capacity(self.genesis.accounts.len()); for address in self.genesis.accounts.iter().copied() { - let db = Arc::clone(&self.db); + let db = Arc::clone(db); // The forking Database backend can handle concurrent requests, we can fetch all dev // accounts concurrently by spawning the job to a new task @@ -431,7 +438,7 @@ impl Backend { let genesis_accounts = futures::future::join_all(genesis_accounts_futures).await; - let mut db = self.db.write().await; + let mut db = db.write().await; for res in genesis_accounts { let (address, mut info) = res.unwrap()?; @@ -439,7 +446,7 @@ impl Backend { db.insert_account(address, info.clone()); } } else { - let mut db = self.db.write().await; + let mut db = db.write().await; for (account, info) in self.genesis.account_infos() { db.insert_account(account, info); } @@ -449,9 +456,8 @@ impl Backend { db.insert_block_hash(U256::from(self.best_number()), self.best_hash()); } - let db = self.db.write().await; // apply the genesis.json alloc - self.genesis.apply_genesis_json_alloc(db)?; + self.genesis.apply_genesis_json_alloc(db.write().await)?; // Seed Base's activation-gated features (B20Asset, B20Stablecoin, // PolicyRegistry) as active so a standalone `anvil --base` node matches a @@ -460,10 +466,10 @@ impl Backend { // in fork mode, where the live chain already carries real activation state // that seeding would clobber. No-op unless `--base` is set // (`base_activation_seeds` returns empty otherwise). - if self.fork.read().is_none() { + if !forked { let seeds = self.env.read().networks.base_activation_seeds(); if !seeds.is_empty() { - let mut db = self.db.write().await; + let mut db = db.write().await; for (address, slot, value) in seeds { db.set_storage_at( address, @@ -624,7 +630,7 @@ impl Backend { self.states.write().clear(); self.db.write().await.clear(); - self.apply_genesis().await?; + self.apply_genesis(&self.db, self.is_fork()).await?; trace!(target: "backend", "reset fork"); @@ -634,8 +640,8 @@ impl Backend { } } - /// Prepares both upstreams before replacing a Beacon-enabled fork. A failed metadata - /// request must not leave the execution database on a different fork from the slot schedule. + /// Prepares both upstreams before replacing a Beacon-enabled fork. A failed upstream + /// read must not leave the execution database on a different fork from the slot schedule. /// The caller must hold the mining lock. async fn reset_beacon_fork(&self, forking: Forking) -> Result<(), BlockchainError> { if !self.is_fork() && forking.json_rpc_url.is_none() { @@ -664,10 +670,12 @@ impl Backend { self.fees.base_fee_params(), ); let (db, fork_config) = config.setup_fork_db_config(url, &mut env, &fees).await?; + let db: Arc>> = Arc::new(AsyncRwLock::new(Box::new(db))); + self.apply_genesis(&db, true).await?; let beacon = fork_config.beacon.as_ref().expect("Beacon URL is configured"); self.time.set_beacon_slots(Some((beacon.genesis.genesis_time, beacon.seconds_per_slot)))?; self.time.reset(fork_config.timestamp); - *self.db.write().await = Box::new(db); + std::mem::swap(&mut *self.db.write().await, &mut *db.write().await); *self.env.write() = env; *self.blockchain.storage.write() = BlockchainStorage::forked( fork_config.block_number, @@ -684,7 +692,6 @@ impl Backend { self.states.write().clear(); self.active_state_snapshots.lock().clear(); *self.node_config.write().await = config; - self.apply_genesis().await?; Ok(()) } @@ -732,7 +739,7 @@ impl Backend { self.fees.set_gas_price(crate::eth::fees::INITIAL_GAS_PRICE); // Reapply genesis configuration - self.apply_genesis().await?; + self.apply_genesis(&self.db, self.is_fork()).await?; trace!(target: "backend", "reset to fresh in-memory state"); diff --git a/crates/anvil/tests/it/beacon_api.rs b/crates/anvil/tests/it/beacon_api.rs index ef0e95f10..f61b36031 100644 --- a/crates/anvil/tests/it/beacon_api.rs +++ b/crates/anvil/tests/it/beacon_api.rs @@ -18,14 +18,14 @@ use axum::{ extract::{Path, State}, http::StatusCode, response::{IntoResponse, Response}, - routing::get, + routing::{get, post}, }; use foundry_evm::hardfork::EthereumHardfork; use ssz::Decode; use std::{ sync::{ Arc, Mutex, - atomic::{AtomicU64, Ordering}, + atomic::{AtomicBool, AtomicU64, Ordering}, }, time::Duration, }; @@ -708,3 +708,68 @@ async fn beacon_api_revert_and_reset_follow_canonical_history() { fixture.api.evm_set_block_timestamp_interval(5).unwrap(); fixture.api.evm_mine(None).await.unwrap(); } + +#[tokio::test(flavor = "multi_thread")] +async fn beacon_api_reset_keeps_fork_when_account_reads_fail() { + let (origin_api, origin) = spawn(beacon_origin_config()).await; + let beacon = MockBeacon::spawn().await; + // Execution upstream that serves blocks but can reject account reads, like a pruned node. + let fail_accounts = Arc::new(AtomicBool::new(false)); + let (fail, origin_url) = (Arc::clone(&fail_accounts), origin.http_endpoint()); + let proxy = Router::new().route( + "/", + post(move |Json(request): Json| { + let (fail, origin_url) = (Arc::clone(&fail), origin_url.clone()); + async move { + let method = request["method"].as_str().unwrap_or_default(); + let account_read = + matches!(method, "eth_getBalance" | "eth_getTransactionCount" | "eth_getCode"); + if account_read && fail.load(Ordering::Relaxed) { + let error = serde_json::json!({ "code": -32000, "message": "missing trie node" }); + return Json(serde_json::json!({ "jsonrpc": "2.0", "id": request["id"], "error": error })); + } + let response = reqwest::Client::new().post(origin_url).json(&request).send().await; + Json(response.unwrap().json::().await.unwrap()) + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_url = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, proxy).await.unwrap() }); + let (api, handle) = spawn(beacon_target_config(proxy_url, beacon.url.clone())).await; + let fixture = BeaconTargetFixture { origin_api, origin, beacon, api, handle }; + let endpoint = fixture.handle.http_endpoint(); + let provider = http_provider(&endpoint); + let dev = fixture.handle.dev_accounts().next().unwrap(); + + let snapshot = fixture.api.evm_snapshot().await.unwrap(); + fixture.mine_blob_block(1252, &BEACON_LOCAL_BLOB_DATA).await; + fixture.api.evm_set_next_block_timestamp(beacon_slot_timestamp(30)).unwrap(); + let head = provider.get_block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + let blobs = beacon_blobs_json(&endpoint, "21").await; + let balance = provider.get_balance(dev).await.unwrap(); + assert_eq!(provider.get_transaction_count(dev).await.unwrap(), 2); + + fail_accounts.store(true, Ordering::Relaxed); + fixture.beacon.state.seconds_per_slot.store(6, Ordering::Relaxed); + let reset = Forking { json_rpc_url: None, block_number: Some(BEACON_ORIGIN_BLOCK) }; + assert!(fixture.api.anvil_reset(Some(reset)).await.is_err()); + fail_accounts.store(false, Ordering::Relaxed); + + let kept = provider.get_block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert_eq!(kept.header.hash, head.header.hash); + assert_blobs_eq(&beacon_blobs_json(&endpoint, "21").await, &blobs, "slot 21 after reset"); + assert_eq!(provider.get_balance(dev).await.unwrap(), balance); + assert_eq!(provider.get_transaction_count(dev).await.unwrap(), 2); + let spec = reqwest::get(format!("{endpoint}/eth/v1/config/spec")).await.unwrap(); + let spec = spec.json::().await.unwrap(); + assert_eq!(spec["data"]["SECONDS_PER_SLOT"], BEACON_SECONDS_PER_SLOT.to_string()); + fixture.api.evm_mine(None).await.unwrap(); + let next = provider.get_block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert_eq!( + (next.header.number, next.header.timestamp), + (head.header.number + 1, beacon_slot_timestamp(30)) + ); + assert!(fixture.api.evm_revert(snapshot).await.unwrap()); + assert_eq!(fixture.api.block_number().unwrap(), U256::from(BEACON_ORIGIN_BLOCK)); +} From 7eb5ba82052dc79db3ffc24190a1e9e73190107a Mon Sep 17 00:00:00 2001 From: Francis Li Date: Mon, 5 Oct 2026 20:40:39 +0000 Subject: [PATCH 4/4] Include failed-reset 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_api.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/anvil/tests/it/beacon_api.rs b/crates/anvil/tests/it/beacon_api.rs index 6e3dc8868..3e4eac5a1 100644 --- a/crates/anvil/tests/it/beacon_api.rs +++ b/crates/anvil/tests/it/beacon_api.rs @@ -789,7 +789,7 @@ async fn beacon_api_revert_and_reset_follow_canonical_history() { } #[tokio::test(flavor = "multi_thread")] -async fn beacon_api_reset_keeps_fork_when_account_reads_fail() { +async fn beacon_api_failed_reset_preserves_live_chain() { let (origin_api, origin) = spawn(beacon_origin_config()).await; let beacon = MockBeacon::spawn().await; // Execution upstream that serves blocks but can reject account reads, like a pruned node.