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/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/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/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/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..db79ea77e 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,66 @@ 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()); + 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, 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, + 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(); + 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()); + } } // load the blocks and transactions into the storage self.blockchain.storage.write().load_blocks(state.blocks.clone()); @@ -1230,8 +1287,15 @@ 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())); + 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..09795428e 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,159 @@ 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; + // 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); + } + + // 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")] +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_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(); + 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)"); +}