Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,19 @@ timestamp before enabling mining; do not move Beacon genesis to hide the gap. Ex
are bounded to year 9999. Transaction-body pruning is incompatible with retaining local blobs.
Safe/finalized tags remain Anvil's simulated block-depth policy, not Ethereum finality.

Use `--state <file>` for graceful shutdown and resume, with the same explicit fork block, chain ID,
Beacon genesis/slot duration, and `--slots-in-an-epoch`. Incompatible identity or incomplete local
header history is rejected before loading. Beacon dumps require Beacon mode on resume.
Legacy dumps remain readable outside Beacon-backed mode;
they cannot safely resume a Beacon fork because they lack its identity. A live `anvil_loadState`
in this mode replaces local accounts, blocks and snapshots instead of merging histories. Pause
mining and drain pending transactions before loading.

The existing block serialization preserves blob sidecars. Dumps contain neither endpoint URLs nor
clock offsets or pending timestamp overrides. Resume restores time from the canonical tip; the
orchestrator must reapply connectivity, mining policy and a current aligned timestamp after downtime.
State files are development checkpoints, not atomic crash recovery across multiple devnet processes.

Everything below is inherited from upstream Foundry and works unchanged; only the Base additions above are specific to this fork.

---
Expand Down
46 changes: 45 additions & 1 deletion crates/anvil/src/cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -657,7 +657,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
}
Expand Down Expand Up @@ -807,6 +809,48 @@ 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 dump_waits_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());
drop(db_guard);
let (mined, saved) =
tokio::time::timeout(Duration::from_secs(30), async { tokio::join!(mining, dump) })
.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);
assert!(saved.blocks.iter().any(|block| block.header.number == mined.block_number));
}

#[test]
fn beacon_url_requires_execution_url_and_reaches_config() {
assert!(
Expand Down
11 changes: 1 addition & 10 deletions crates/anvil/src/eth/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1702,16 +1702,7 @@ impl EthApi {
node_info!("eth_feeHistory");
// max number of blocks in the requested range

let current = self.backend.best_number();
let slots_in_an_epoch = 32u64;

let number = match newest_block {
BlockNumber::Latest | BlockNumber::Pending => current,
BlockNumber::Earliest => 0,
BlockNumber::Number(n) => n,
BlockNumber::Safe => current.saturating_sub(slots_in_an_epoch),
BlockNumber::Finalized => current.saturating_sub(slots_in_an_epoch * 2),
};
let number = self.backend.convert_block_number(Some(newest_block));

// check if the number predates the fork, if in fork mode
if let Some(fork) = self.get_fork() {
Expand Down
16 changes: 16 additions & 0 deletions crates/anvil/src/eth/backend/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use alloy_primitives::{
map::{AddressMap, HashMap},
};
use alloy_rpc_types::BlockId;
use alloy_rpc_types_beacon::genesis::GenesisData;
use anvil_core::eth::{
block::Block,
transaction::{MaybeImpersonatedTransaction, TransactionInfo},
Expand Down Expand Up @@ -486,6 +487,18 @@ where
Ok(number)
}

/// Identity required to resume a Beacon-backed fork. Connectivity and mining policy are not state.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SerializableForkBeacon {
pub chain_id: u64,
pub block_number: u64,
pub block_hash: B256,
pub timestamp: u64,
pub genesis: GenesisData,
pub seconds_per_slot: u64,
pub slots_in_an_epoch: u64,
}

#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct SerializableState {
/// The block number of the state
Expand All @@ -506,6 +519,9 @@ pub struct SerializableState {
/// Note: This is an Option for backwards compatibility.
#[serde(default)]
pub historical_states: Option<SerializableHistoricalStates>,
/// Absent in legacy dumps and when the historical Beacon upstream is not configured.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fork_beacon: Option<SerializableForkBeacon>,
}

impl SerializableState {
Expand Down
2 changes: 2 additions & 0 deletions crates/anvil/src/eth/backend/mem/fork_db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ impl Db for ForkedDatabase {
blocks,
transactions,
historical_states,
fork_beacon: None,
}))
}

Expand Down Expand Up @@ -110,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) {
Expand Down
1 change: 1 addition & 0 deletions crates/anvil/src/eth/backend/mem/in_memory_db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ impl Db for MemDb {
blocks,
transactions,
historical_states,
fork_beacon: None,
}))
}

Expand Down
138 changes: 122 additions & 16 deletions crates/anvil/src/eth/backend/mem/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use crate::{
eth::{
backend::{
cheats::{CheatEcrecover, CheatsManager},
db::{Db, MaybeFullDatabase, SerializableState, StateDb},
db::{Db, MaybeFullDatabase, SerializableForkBeacon, SerializableState, StateDb},
env::Env,
executor::{ExecutedTransactions, TransactionExecutor},
fork::ClientFork,
Expand All @@ -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},
Expand Down Expand Up @@ -526,6 +526,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.node_config.read().await.fork_beacon_url.is_some() {
return self.reset_beacon_fork(forking).await;
}
Expand Down Expand Up @@ -636,7 +637,6 @@ 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.
async fn reset_beacon_fork(&self, forking: Forking) -> Result<(), BlockchainError> {
let _mining_guard = self.mining.lock().await;
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",
Expand Down Expand Up @@ -1037,6 +1037,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();
Expand All @@ -1047,6 +1048,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<bool, BlockchainError> {
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 = {
Expand Down Expand Up @@ -1088,6 +1090,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))
}
Expand All @@ -1096,11 +1101,28 @@ impl Backend {
self.active_state_snapshots.lock().clone().into_iter().collect()
}

/// Identity of the original fork boundary, not the current local tip.
fn fork_beacon_identity(&self) -> Option<SerializableForkBeacon> {
let fork = self.get_fork()?;
let config = fork.config.read();
let beacon = config.beacon.as_ref()?;
Some(SerializableForkBeacon {
chain_id: self.env.read().evm_env.cfg_env.chain_id,
block_number: config.block_number,
block_hash: config.block_hash,
timestamp: config.timestamp,
genesis: beacon.genesis.clone(),
seconds_per_slot: beacon.seconds_per_slot,
slots_in_an_epoch: self.slots_in_an_epoch,
})
}

/// Get the current state.
pub async fn serialized_state(
&self,
preserve_historical_states: bool,
) -> Result<SerializableState, BlockchainError> {
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();
Expand All @@ -1111,17 +1133,18 @@ impl Backend {
None
};

let state = self.db.read().await.dump_state(
at,
best_number,
blocks,
transactions,
historical_states,
)?;
state.ok_or_else(|| {
RpcError::invalid_params("Dumping state not supported with the current configuration")
.into()
})
let mut state = self
.db
.read()
.await
.dump_state(at, best_number, blocks, transactions, historical_states)?
.ok_or_else(|| {
RpcError::invalid_params(
"Dumping state not supported with the current configuration",
)
})?;
state.fork_beacon = self.fork_beacon_identity();
Ok(state)
}

/// Write all chain data to serialized bytes buffer
Expand All @@ -1139,6 +1162,76 @@ impl Backend {

/// Apply [SerializableState] data to the backend storage.
pub async fn load_state(&self, state: SerializableState) -> Result<bool, BlockchainError> {
let _mining_guard = self.mining.lock().await;
let identity = self.fork_beacon_identity();
if state.fork_beacon != identity {
return Err(RpcError::invalid_params(
"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.
let mut headers = state.blocks.iter().map(|block| &block.header).collect::<Vec<_>>();
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 F the dump has no local body. Obtain 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();
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());
self.blockchain.storage.write().load_transactions(state.transactions.clone());
Expand Down Expand Up @@ -1188,8 +1281,20 @@ impl Backend {
}
}

if let Some(latest) = state.blocks.iter().max_by_key(|b| b.header.number) {
let header = &latest.header;
// Restore from the selected canonical head, not the largest imported block or the
// original fork timestamp. The fork boundary need not have a locally stored body.
let head = self
.get_block_by_hash(self.best_hash())
.map(|block| AnyHeader::from(block.header))
.or(boundary_header);
if let Some(timestamp) = head.as_ref().map(|header| header.timestamp).or_else(|| {
self.get_fork()
.filter(|fork| fork.block_hash() == self.best_hash())
.map(|fork| fork.timestamp())
}) {
self.time.reset(timestamp);
}
if let Some(header) = head {
let next_block_base_fee = self.fees.get_next_block_base_fee_per_gas(
header.gas_used,
header.gas_limit,
Expand Down Expand Up @@ -3570,6 +3675,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 =
Expand Down
2 changes: 1 addition & 1 deletion crates/anvil/src/eth/backend/time.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use std::{sync::Arc, time::Duration};

// Bound development time jumps to year 9999. Mining may continue past this bound,
// but RPC-controlled offsets must not exhaust u64 or chrono's printable date range.
const MAX_BEACON_TIMESTAMP: u64 = 253_402_300_799;
pub(super) const MAX_BEACON_TIMESTAMP: u64 = 253_402_300_799;

/// Returns the `Utc` datetime for the given seconds since unix epoch
pub fn utc_from_secs(secs: u64) -> DateTime<Utc> {
Expand Down
Loading
Loading