From 48c104111c019098003eb2ed5bf3d7659440cf25 Mon Sep 17 00:00:00 2001 From: 0xMars42 <195151467+0xMars42@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:02:38 +0200 Subject: [PATCH 1/2] fix(anvil): continue the saved timeline after loading state Backend::load_state restores the block env, base fee and blob params from the saved head but never re-anchors the TimeManager, so the first block mined after loading a state file took its timestamp from the node's startup anchor (genesis or fork block) instead of the saved chain head, jumping back in time. Reset block time to the loaded head's timestamp where the fees are already restored from that header, like the rollback path does, skipping the case where the canonical head is a newer fork block. --- crates/anvil/src/eth/backend/mem/mod.rs | 7 +++++ crates/anvil/tests/it/state.rs | 41 +++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/crates/anvil/src/eth/backend/mem/mod.rs b/crates/anvil/src/eth/backend/mem/mod.rs index 2100fac6f364f..266a0a4c1f575 100644 --- a/crates/anvil/src/eth/backend/mem/mod.rs +++ b/crates/anvil/src/eth/backend/mem/mod.rs @@ -4758,6 +4758,13 @@ impl> Backend { header.timestamp, ), )); + + // Re-anchor block time to the loaded head so subsequent blocks continue the saved + // timeline instead of the node's startup anchor (genesis or fork block). Skip when + // the canonical head is not the loaded one (state file older than the fork block). + if header.number == self.blockchain.storage.read().best_number { + self.time.reset(header.timestamp); + } } if !self.db.write().await.load_state(state.clone())? { diff --git a/crates/anvil/tests/it/state.rs b/crates/anvil/tests/it/state.rs index 7a0eb359d89ff..86cabb11d9fa3 100644 --- a/crates/anvil/tests/it/state.rs +++ b/crates/anvil/tests/it/state.rs @@ -51,6 +51,47 @@ async fn can_load_state() { assert_eq!(num, U256::from(num_from_tag)); } +// +#[tokio::test(flavor = "multi_thread")] +async fn test_load_state_continues_saved_timeline() { + let (api, _handle) = spawn(NodeConfig::test()).await; + + // Move the chain's timeline one year ahead of wall-clock time, then mine on it. + let one_year_ahead = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + + 31_536_000; + api.evm_set_next_block_timestamp(one_year_ahead).unwrap(); + api.mine_one().await; + + let saved_head_timestamp = api + .block_by_number(alloy_eips::BlockNumberOrTag::Latest) + .await + .unwrap() + .unwrap() + .header + .timestamp; + assert_eq!(saved_head_timestamp, one_year_ahead); + + let state = api.serialized_state(false).await.unwrap(); + let (api, _handle) = spawn(NodeConfig::test().with_init_state(Some(state))).await; + + api.mine_one().await; + let new_head_timestamp = api + .block_by_number(alloy_eips::BlockNumberOrTag::Latest) + .await + .unwrap() + .unwrap() + .header + .timestamp; + + // The block mined after loading the state must continue the saved timeline instead of + // falling back to the wall-clock anchor of the fresh node. + assert!( + new_head_timestamp >= saved_head_timestamp, + "block after load_state went back in time: {new_head_timestamp} < {saved_head_timestamp}" + ); +} + // #[tokio::test(flavor = "multi_thread")] async fn finalized_block_hash_consistent_after_load_state() { From adf6cd7f2a0fd06811b8097fbcf5777ac275a3d3 Mon Sep 17 00:00:00 2001 From: 0xMars42 <195151467+0xMars42@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:25:47 +0200 Subject: [PATCH 2/2] review: guard the time re-anchor by canonical head identity Compare the loaded head's hash with the canonical best hash instead of comparing block numbers, so an equal-height fork head keeps its time anchor. Covered by a test that forks a node at the same height as the loaded state's head. --- crates/anvil/src/eth/backend/mem/mod.rs | 5 +-- crates/anvil/tests/it/state.rs | 42 +++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/crates/anvil/src/eth/backend/mem/mod.rs b/crates/anvil/src/eth/backend/mem/mod.rs index 266a0a4c1f575..860ba76d1b072 100644 --- a/crates/anvil/src/eth/backend/mem/mod.rs +++ b/crates/anvil/src/eth/backend/mem/mod.rs @@ -4761,8 +4761,9 @@ impl> Backend { // Re-anchor block time to the loaded head so subsequent blocks continue the saved // timeline instead of the node's startup anchor (genesis or fork block). Skip when - // the canonical head is not the loaded one (state file older than the fork block). - if header.number == self.blockchain.storage.read().best_number { + // the canonical head is not the loaded one (state file at or below the fork block), + // comparing by identity since a fork head can share the loaded head's height. + if header.hash_slow() == self.blockchain.storage.read().best_hash { self.time.reset(header.timestamp); } } diff --git a/crates/anvil/tests/it/state.rs b/crates/anvil/tests/it/state.rs index 86cabb11d9fa3..058a82afbcdd1 100644 --- a/crates/anvil/tests/it/state.rs +++ b/crates/anvil/tests/it/state.rs @@ -92,6 +92,48 @@ async fn test_load_state_continues_saved_timeline() { ); } +// Loading a state whose head has the same number as the fork block must keep the fork time +// anchor: the canonical head stays the fork block, so the saved timeline does not apply. +#[tokio::test(flavor = "multi_thread")] +async fn test_load_state_equal_height_fork_keeps_fork_anchor() { + // The state source: one block mined a year ahead of wall-clock time. + let (api_state, _handle_state) = spawn(NodeConfig::test()).await; + let one_year_ahead = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + + 31_536_000; + api_state.evm_set_next_block_timestamp(one_year_ahead).unwrap(); + api_state.mine_one().await; + let state = api_state.serialized_state(false).await.unwrap(); + + // The fork source: one block mined on the wall-clock timeline, same height as the state. + let (api_remote, handle_remote) = spawn(NodeConfig::test()).await; + api_remote.mine_one().await; + + // Fork the remote head (height 1) and load the state (best height 1 as well): the + // canonical head keeps being the fork block, so its time anchor must be preserved. + let (api, _handle) = spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(handle_remote.http_endpoint())) + .with_init_state(Some(state)), + ) + .await; + + api.mine_one().await; + let new_head_timestamp = api + .block_by_number(alloy_eips::BlockNumberOrTag::Latest) + .await + .unwrap() + .unwrap() + .header + .timestamp; + + assert!( + new_head_timestamp < one_year_ahead, + "block after load_state jumped to the loaded state's timeline instead of keeping the \ + fork anchor: {new_head_timestamp} >= {one_year_ahead}" + ); +} + // #[tokio::test(flavor = "multi_thread")] async fn finalized_block_hash_consistent_after_load_state() {