diff --git a/README.md b/README.md index b27f25ce6..dffed1024 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,32 @@ This adds `base-foundryup` and the namespaced `base-forge`/`base-cast`/`base-anv - **Test your Base app with base-anvil:** [`docs/base.md`](./docs/base.md) - **Release model and the `base/base` pin (maintainers):** [`RELEASES.md`](./RELEASES.md) +### Forked Ethereum L1 with Beacon blobs + +Build the `anvil` binary without `--base` when it is the Ethereum L1 for a separate rollup node: + +```bash +cargo run -p anvil --bin anvil -- \ + --fork-url "$L1_RPC_URL" --fork-block-number "$L1_FORK_BLOCK" \ + --fork-beacon-url "$L1_BEACON_URL" --no-mining +``` + +The Beacon endpoint must provide genesis, `SECONDS_PER_SLOT`, and historical +`/eth/v1/beacon/blobs/{slot}` responses for the required range. Choose matching execution and +Beacon providers and preferably a finalized fork block; this is not an offline archive or a +Beacon consensus implementation. Genesis and slot duration remain stable while mining is paused +or its interval changes. Numeric Beacon IDs are slots, not execution block numbers. Historical +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. + +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 +wall-clock pacing separately. After downtime, an orchestrator should set a current aligned next +timestamp before enabling mining; do not move Beacon genesis to hide the gap. Extreme clock jumps +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. + 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/Cargo.toml b/crates/anvil/Cargo.toml index adeb988cd..212fea49f 100644 --- a/crates/anvil/Cargo.toml +++ b/crates/anvil/Cargo.toml @@ -90,6 +90,7 @@ async-trait.workspace = true flate2.workspace = true serde_json.workspace = true serde.workspace = true +reqwest.workspace = true thiserror.workspace = true yansi.workspace = true tempfile.workspace = true @@ -114,7 +115,6 @@ alloy-provider = { workspace = true, features = ["txpool-api"] } alloy-pubsub.workspace = true alloy-eip5792.workspace = true rand.workspace = true -reqwest.workspace = true foundry-test-utils.workspace = true tokio = { workspace = true, features = ["full"] } diff --git a/crates/anvil/src/cmd.rs b/crates/anvil/src/cmd.rs index 995829ae7..48e511d64 100644 --- a/crates/anvil/src/cmd.rs +++ b/crates/anvil/src/cmd.rs @@ -257,6 +257,7 @@ impl NodeArgs { .fork_retry_backoff(self.evm.fork_retry_backoff.map(Duration::from_millis)) .fork_compute_units_per_second(compute_units_per_second) .with_eth_rpc_url(self.evm.fork_url.map(|fork| fork.url)) + .with_fork_beacon_url(self.evm.fork_beacon_url) .with_base_fee(self.evm.block_base_fee_per_gas) .disable_min_priority_fee(self.evm.disable_min_priority_fee) .with_storage_caching(self.evm.no_storage_caching) @@ -420,6 +421,12 @@ pub struct AnvilEvmArgs { )] pub fork_url: Option, + /// Serve historical Beacon blobs through the fork block and mine local L1 blocks on + /// the upstream Beacon slot grid. Requires an HTTP(S) Beacon endpoint with genesis, + /// spec, and historical blobs APIs. + #[arg(long, value_name = "URL", help_heading = "Fork config", requires = "fork_url")] + pub fork_beacon_url: Option, + /// Headers to use for the rpc client, e.g. "User-Agent: test-agent" /// /// See --fork-url. @@ -800,6 +807,26 @@ mod tests { use super::*; use std::{env, net::Ipv4Addr}; + #[test] + fn beacon_url_requires_execution_url_and_reaches_config() { + assert!( + NodeArgs::try_parse_from(["anvil", "--fork-beacon-url", "http://localhost:5052"]) + .is_err() + ); + let config = NodeArgs::try_parse_from([ + "anvil", + "--fork-url", + "http://localhost:8545", + "--fork-beacon-url", + "http://localhost:5052", + ]) + .unwrap() + .into_node_config() + .unwrap(); + assert_eq!(config.fork_beacon_url.as_deref(), Some("http://localhost:5052")); + assert_eq!(config.eth_rpc_url.as_deref(), Some("http://localhost:8545")); + } + #[test] fn test_parse_fork_url() { let fork: ForkUrl = "http://localhost:8545@1000000".parse().unwrap(); diff --git a/crates/anvil/src/config.rs b/crates/anvil/src/config.rs index 575999a37..4b0ae34dc 100644 --- a/crates/anvil/src/config.rs +++ b/crates/anvil/src/config.rs @@ -2,6 +2,7 @@ use crate::{ EthereumHardfork, FeeManager, PrecompileFactory, eth::{ backend::{ + beacon::ForkBeacon, db::{Db, SerializableState}, env::Env, fork::{ClientFork, ClientForkConfig}, @@ -140,6 +141,8 @@ pub struct NodeConfig { pub max_transactions: usize, /// url of the rpc server that should be used for any rpc calls pub eth_rpc_url: Option, + /// Historical Beacon endpoint used to serve blobs through the execution fork boundary. + pub fork_beacon_url: Option, /// pins the block number or transaction hash for the state fork pub fork_choice: Option, /// headers to use with `eth_rpc_url` @@ -464,6 +467,7 @@ impl Default for NodeConfig { port: NODE_PORT, max_transactions: 1_000, eth_rpc_url: None, + fork_beacon_url: None, fork_choice: None, account_generator: None, base_fee: None, @@ -841,6 +845,13 @@ impl NodeConfig { self } + /// Sets the historical Beacon endpoint and enables slot-aligned L1 mining. + #[must_use] + pub fn with_fork_beacon_url(mut self, url: Option) -> Self { + self.fork_beacon_url = url; + self + } + /// Sets the `fork_choice` to use to fork off from based on a block number #[must_use] pub fn with_fork_block_number>(self, fork_block_number: Option) -> Self { @@ -1056,6 +1067,17 @@ impl NodeConfig { /// /// *Note*: only memory based backend for now pub(crate) async fn setup(&mut self) -> Result { + if self.fork_beacon_url.is_some() { + eyre::ensure!(self.eth_rpc_url.is_some(), "fork Beacon requires an execution fork URL"); + eyre::ensure!( + self.transaction_block_keeper.is_none(), + "fork Beacon requires retaining block transactions and blob sidecars" + ); + eyre::ensure!( + self.fork_choice.is_none_or(|choice| choice.transaction_hash().is_none()), + "fork Beacon requires a block boundary, not a transaction boundary" + ); + } // configure the revm environment let mut cfg = CfgEnv::default(); @@ -1283,6 +1305,12 @@ latest block number: {latest_block}" eyre::bail!("failed to get block for block number: {fork_block_number}"); }; + let beacon = if let Some(url) = &self.fork_beacon_url { + Some(ForkBeacon::connect(url, self.fork_request_timeout, block.header.timestamp).await?) + } else { + None + }; + let gas_limit = self.fork_gas_limit(&block); self.gas_limit = Some(gas_limit); @@ -1409,6 +1437,7 @@ latest block number: {latest_block}" 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, force_transactions, + beacon, }; debug!(target: "node", fork_number=config.block_number, fork_hash=%config.block_hash, "set up fork db"); diff --git a/crates/anvil/src/eth/api.rs b/crates/anvil/src/eth/api.rs index 4d9fbcb2f..02c513e11 100644 --- a/crates/anvil/src/eth/api.rs +++ b/crates/anvil/src/eth/api.rs @@ -2747,7 +2747,7 @@ impl EthApi { pub fn evm_set_time(&self, timestamp: u64) -> Result { node_info!("evm_setTime"); let now = self.backend.time().current_call_timestamp(); - self.backend.time().reset(timestamp); + self.backend.time().set_time(timestamp)?; // number of seconds between the given timestamp and the current time. let offset = timestamp.saturating_sub(now); @@ -2768,8 +2768,7 @@ impl EthApi { /// Handler for RPC call: `anvil_setBlockTimestampInterval` pub fn evm_set_block_timestamp_interval(&self, seconds: u64) -> Result<()> { node_info!("anvil_setBlockTimestampInterval"); - self.backend.time().set_block_timestamp_interval(seconds); - Ok(()) + self.backend.time().set_block_timestamp_interval(seconds) } /// Sets an interval for the block timestamp diff --git a/crates/anvil/src/eth/backend/beacon.rs b/crates/anvil/src/eth/backend/beacon.rs new file mode 100644 index 000000000..9e3de8aaf --- /dev/null +++ b/crates/anvil/src/eth/backend/beacon.rs @@ -0,0 +1,140 @@ +//! Historical Beacon reads and the immutable slot schedule of an execution fork. + +use alloy_consensus::{Blob, EnvKzgSettings}; +use alloy_eips::eip4844::kzg_to_versioned_hash; +use alloy_primitives::B256; +use alloy_rpc_types_beacon::{ + genesis::{GenesisData, GenesisResponse}, + sidecar::GetBlobsResponse, +}; +use eyre::{Result, ensure, eyre}; +use reqwest::{Client, StatusCode, Url, redirect::Policy}; +use serde::de::DeserializeOwned; +use std::{fmt, time::Duration}; + +// JSON encodes each 128 KiB blob as hex. This permits over 100 blobs without accepting +// unbounded responses from an upstream provider. +const MAX_BLOB_RESPONSE_BYTES: usize = 32 * 1024 * 1024; +const MAX_METADATA_RESPONSE_BYTES: usize = 1024 * 1024; + +/// Beacon connectivity and metadata belonging to an execution fork. +#[derive(Clone)] +pub struct ForkBeacon { + url: Url, + client: Client, + pub genesis: GenesisData, + pub seconds_per_slot: u64, +} + +impl fmt::Debug for ForkBeacon { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // Endpoint paths and query parameters may contain credentials. + f.debug_struct("ForkBeacon") + .field("genesis", &self.genesis) + .field("seconds_per_slot", &self.seconds_per_slot) + .finish_non_exhaustive() + } +} + +impl ForkBeacon { + /// Captures Beacon metadata before the fork starts serving requests. + pub async fn connect(endpoint: &str, timeout: Duration, fork_timestamp: u64) -> Result { + let url = Url::parse(&format!("{}/", endpoint.trim_end_matches('/'))) + .map_err(|_| eyre!("invalid fork Beacon URL"))?; + ensure!(matches!(url.scheme(), "http" | "https"), "fork Beacon URL must use HTTP(S)"); + let client = Client::builder().timeout(timeout).redirect(Policy::none()).build()?; + let genesis = Self::get::( + &client, + url.join("eth/v1/beacon/genesis")?, + &[], + MAX_METADATA_RESPONSE_BYTES, + ) + .await? + .ok_or_else(|| eyre!("fork Beacon genesis is unavailable"))? + .data; + let spec = Self::get::( + &client, + url.join("eth/v1/config/spec")?, + &[], + MAX_METADATA_RESPONSE_BYTES, + ) + .await? + .ok_or_else(|| eyre!("fork Beacon spec is unavailable"))?; + let seconds_per_slot: u64 = spec["data"]["SECONDS_PER_SLOT"] + .as_str() + .ok_or_else(|| eyre!("missing Beacon SECONDS_PER_SLOT"))? + .parse()?; + ensure!(seconds_per_slot > 0, "Beacon slot duration must be positive"); + let elapsed = fork_timestamp + .checked_sub(genesis.genesis_time) + .ok_or_else(|| eyre!("execution fork precedes Beacon genesis"))?; + ensure!( + elapsed.is_multiple_of(seconds_per_slot), + "execution fork timestamp is not on the Beacon slot grid" + ); + Ok(Self { url, client, genesis, seconds_per_slot }) + } + + /// Converts a slot to its exact execution timestamp without wrapping on user input. + pub fn timestamp(&self, slot: u64) -> Option { + slot.checked_mul(self.seconds_per_slot)?.checked_add(self.genesis.genesis_time) + } + + /// Reads historical blobs. The caller must enforce the inclusive execution fork boundary. + pub async fn blobs(&self, slot: u64, hashes: &[B256]) -> Result>> { + let Some(response) = Self::get::( + &self.client, + self.url.join(&format!("eth/v1/beacon/blobs/{slot}"))?, + hashes, + MAX_BLOB_RESPONSE_BYTES, + ) + .await? + else { + return Ok(None); + }; + let mut blobs = response.data; + if !hashes.is_empty() { + // Providers may ignore the filter. Apply it to the actual blob content, not + // to untrusted upstream labels; Base also validates the requested hashes. + let hashes = hashes.to_vec(); + blobs = tokio::task::spawn_blocking(move || -> Result> { + let settings = EnvKzgSettings::Default; + let mut filtered = Vec::new(); + for blob in blobs { + let commitment = settings.get().blob_to_kzg_commitment(&blob.0.into())?; + if hashes.contains(&kzg_to_versioned_hash(commitment.as_slice())) { + filtered.push(blob); + } + } + Ok(filtered) + }) + .await??; + } + Ok(Some(blobs)) + } + + async fn get( + client: &Client, + url: Url, + hashes: &[B256], + max_bytes: usize, + ) -> Result> { + let mut request = client.get(url).header("Accept", "application/json"); + if !hashes.is_empty() { + let hashes = hashes.iter().map(ToString::to_string).collect::>().join(","); + request = request.query(&[("versioned_hashes", hashes)]); + } + let response = request.send().await.map_err(reqwest::Error::without_url)?; + if response.status() == StatusCode::NOT_FOUND { + return Ok(None); + } + let mut response = response.error_for_status().map_err(reqwest::Error::without_url)?; + ensure!(response.status().is_success(), "unexpected Beacon response status"); + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(reqwest::Error::without_url)? { + ensure!(chunk.len() <= max_bytes - bytes.len(), "Beacon response exceeds size limit"); + bytes.extend_from_slice(&chunk); + } + Ok(Some(serde_json::from_slice(&bytes)?)) + } +} diff --git a/crates/anvil/src/eth/backend/fork.rs b/crates/anvil/src/eth/backend/fork.rs index 995206fbf..868e5ddc8 100644 --- a/crates/anvil/src/eth/backend/fork.rs +++ b/crates/anvil/src/eth/backend/fork.rs @@ -1,6 +1,10 @@ //! Support for forking off another client -use crate::eth::{backend::db::Db, error::BlockchainError, pool::transactions::PoolTransaction}; +use crate::eth::{ + backend::{beacon::ForkBeacon, db::Db}, + error::BlockchainError, + pool::transactions::PoolTransaction, +}; use alloy_consensus::TrieAccount; use alloy_eips::eip2930::AccessListResult; use alloy_network::{AnyRpcBlock, AnyRpcTransaction, BlockResponse, TransactionResponse}; @@ -540,6 +544,15 @@ impl ClientFork { if let Some(block) = self.provider().get_block(block_id.into()).full().await? { let hash = block.header.hash; let block_number = block.header.number; + { + let config = self.config.read(); + if config.beacon.is_some() + && (block_number > config.block_number + || (block_number == config.block_number && hash != config.block_hash)) + { + return Ok(None); + } + } let mut storage = self.storage_write(); // also insert all transactions let block_txs = match block.transactions() { @@ -657,6 +670,8 @@ pub struct ClientForkConfig { pub total_difficulty: U256, /// Transactions to force include in the forked chain pub force_transactions: Option>, + /// Historical Beacon connectivity and slot identity, if enabled. + pub beacon: Option, } impl ClientForkConfig { diff --git a/crates/anvil/src/eth/backend/mem/mod.rs b/crates/anvil/src/eth/backend/mem/mod.rs index c29e4dd40..d2b19a909 100644 --- a/crates/anvil/src/eth/backend/mem/mod.rs +++ b/crates/anvil/src/eth/backend/mem/mod.rs @@ -346,6 +346,14 @@ impl Backend { disable_pool_balance_checks, }; + if let Some(fork) = backend.get_fork() + && let Some(beacon) = &fork.config.read().beacon + { + backend + .time + .set_beacon_slots(Some((beacon.genesis.genesis_time, beacon.seconds_per_slot)))?; + } + if let Some(interval_block_time) = automine_block_time { backend.update_interval_mine_block_time(interval_block_time); } @@ -518,6 +526,9 @@ impl Backend { /// Resets the fork to a fresh state pub async fn reset_fork(&self, forking: Forking) -> Result<(), BlockchainError> { + 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(); @@ -622,10 +633,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. + 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", + ) + .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(); @@ -3307,23 +3375,56 @@ impl Backend { id: impl Into, versioned_hashes: Vec, ) -> Result>> { - Ok(self.get_block(id).map(|block| { - block - .body - .transactions - .iter() - .filter_map(|tx| tx.as_ref().sidecar()) - .flat_map(|sidecar| { - sidecar.sidecar.blobs().iter().zip(sidecar.sidecar.commitments().iter()) - }) - .filter(|(_, commitment)| { - // Filter blobs by versioned_hashes if provided - versioned_hashes.is_empty() - || versioned_hashes.contains(&kzg_to_versioned_hash(commitment.as_slice())) - }) - .map(|(blob, _)| *blob) - .collect() - })) + Ok(self.get_block(id).map(|block| Self::block_blobs(&block, &versioned_hashes))) + } + + /// Finds an exact canonical local timestamp, never the preceding block of a missed slot. + /// Local canonical timestamps must be strictly increasing, as enforced by Beacon slot mining. + pub fn get_blobs_by_timestamp( + &self, + timestamp: u64, + first_local_block: u64, + hashes: &[B256], + ) -> Result>> { + let storage = self.blockchain.storage.read(); + let mut low = first_local_block; + let mut high = storage.best_number; + while low <= high { + let number = low + (high - low) / 2; + let block = storage + .hashes + .get(&number) + .and_then(|hash| storage.blocks.get(hash)) + .ok_or_else(|| eyre::eyre!("canonical local block is unavailable"))?; + match block.header.timestamp.cmp(×tamp) { + std::cmp::Ordering::Equal => return Ok(Some(Self::block_blobs(block, hashes))), + std::cmp::Ordering::Less => { + let Some(next) = number.checked_add(1) else { break }; + low = next; + } + std::cmp::Ordering::Greater => { + let Some(previous) = number.checked_sub(1) else { break }; + high = previous; + } + } + } + Ok(None) + } + + fn block_blobs(block: &Block, hashes: &[B256]) -> Vec { + block + .body + .transactions + .iter() + .filter_map(|tx| tx.as_ref().sidecar()) + .flat_map(|sidecar| { + sidecar.sidecar.blobs().iter().zip(sidecar.sidecar.commitments().iter()) + }) + .filter(|(_, commitment)| { + hashes.is_empty() || hashes.contains(&kzg_to_versioned_hash(commitment.as_slice())) + }) + .map(|(blob, _)| *blob) + .collect() } pub fn get_blob_by_versioned_hash(&self, hash: B256) -> Result> { diff --git a/crates/anvil/src/eth/backend/mod.rs b/crates/anvil/src/eth/backend/mod.rs index d73394e8e..8d3831f5c 100644 --- a/crates/anvil/src/eth/backend/mod.rs +++ b/crates/anvil/src/eth/backend/mod.rs @@ -1,5 +1,7 @@ //! blockchain Backend +pub mod beacon; + /// [`revm`] related types pub mod db; /// In-memory Backend diff --git a/crates/anvil/src/eth/backend/time.rs b/crates/anvil/src/eth/backend/time.rs index 175f27924..ef24991dd 100644 --- a/crates/anvil/src/eth/backend/time.rs +++ b/crates/anvil/src/eth/backend/time.rs @@ -5,6 +5,10 @@ use chrono::{DateTime, Utc}; use parking_lot::RwLock; 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; + /// Returns the `Utc` datetime for the given seconds since unix epoch pub fn utc_from_secs(secs: u64) -> DateTime { DateTime::from_timestamp(secs as i64, 0).unwrap() @@ -23,6 +27,8 @@ pub struct TimeManager { next_exact_timestamp: Arc>>, /// The interval to use when determining the next block's timestamp interval: Arc>>, + /// If set, block timestamps are restricted to this Ethereum Beacon slot grid + beacon_slots: Arc>>, } impl TimeManager { @@ -32,6 +38,7 @@ impl TimeManager { offset: Default::default(), next_exact_timestamp: Default::default(), interval: Default::default(), + beacon_slots: Default::default(), }; time_manager.reset(start_timestamp); time_manager @@ -46,6 +53,64 @@ impl TimeManager { self.next_exact_timestamp.write().take(); } + /// Restricts block timestamps to the Ethereum Beacon slot grid + /// `genesis_time + k * seconds_per_slot`, given as `(genesis_time, seconds_per_slot)`, or + /// lifts the restriction with `None`. + /// + /// Pending settings that are invalid on the grid are dropped: an interval that is not a + /// positive multiple of the slot duration, and a next block timestamp that is not a slot after + /// the previous block's timestamp. + pub fn set_beacon_slots(&self, slots: Option<(u64, u64)>) -> Result<(), BlockchainError> { + let slots = slots + .map(|(genesis_time, seconds_per_slot)| BeaconSlots { genesis_time, seconds_per_slot }); + if let Some(slots) = slots { + if slots.seconds_per_slot == 0 + || slots.genesis_time > MAX_BEACON_TIMESTAMP + || slots.seconds_per_slot > MAX_BEACON_TIMESTAMP + || *self.last_timestamp.read() > MAX_BEACON_TIMESTAMP + { + return Err(BlockchainError::TimestampError( + "Beacon schedule must have a positive duration and fit the supported date range".to_string(), + )); + } + let last_timestamp = *self.last_timestamp.read(); + self.interval.write().take_if(|interval| !slots.is_interval(*interval)); + self.next_exact_timestamp + .write() + .take_if(|next| *next <= last_timestamp || !slots.contains(*next)); + } + trace!(target: "time", "set beacon slots {:?}", slots); + *self.beacon_slots.write() = slots; + Ok(()) + } + + /// Sets the clock to `timestamp`. + /// + /// Without Beacon slots this is [`Self::reset`]. With Beacon slots `timestamp` must be a slot + /// at or after the previous block's timestamp, and only the clock moves: the previous block's + /// timestamp is canonical chain state and is never lowered. Like `reset`, this clears the next + /// block timestamp. + pub fn set_time(&self, timestamp: u64) -> Result<(), BlockchainError> { + let slots = *self.beacon_slots.read(); + let Some(slots) = slots else { + self.reset(timestamp); + return Ok(()); + }; + let last_timestamp = *self.last_timestamp.read(); + if timestamp < last_timestamp + || timestamp > MAX_BEACON_TIMESTAMP + || !slots.contains(timestamp) + { + return Err(BlockchainError::TimestampError(format!( + "{timestamp} is not a Beacon slot at or after previous block's timestamp {last_timestamp}" + ))); + } + let current = duration_since_unix_epoch().as_secs() as i128; + *self.offset.write() = (timestamp as i128) - current; + self.next_exact_timestamp.write().take(); + Ok(()) + } + pub fn offset(&self) -> i128 { *self.offset.read() } @@ -67,10 +132,21 @@ impl TimeManager { } /// Sets the exact timestamp to use in the next block - /// Fails if it's before (or at the same time) the last timestamp + /// Fails if it's before the last timestamp, or with Beacon slots, if it's not a slot after the + /// last timestamp pub fn set_next_block_timestamp(&self, timestamp: u64) -> Result<(), BlockchainError> { trace!(target: "time", "override next timestamp {}", timestamp); - if timestamp < *self.last_timestamp.read() { + let last_timestamp = *self.last_timestamp.read(); + if let Some(slots) = *self.beacon_slots.read() { + if timestamp <= last_timestamp + || timestamp > MAX_BEACON_TIMESTAMP + || !slots.contains(timestamp) + { + return Err(BlockchainError::TimestampError(format!( + "{timestamp} is not a Beacon slot after previous block's timestamp {last_timestamp}" + ))); + } + } else if timestamp < last_timestamp { return Err(BlockchainError::TimestampError(format!( "{timestamp} is lower than previous block's timestamp" ))); @@ -82,10 +158,20 @@ impl TimeManager { /// Sets an interval to use when computing the next timestamp /// /// If an interval already exists, this will update the interval, otherwise a new interval will - /// be set starting with the current timestamp. - pub fn set_block_timestamp_interval(&self, interval: u64) { + /// be set starting with the current timestamp. With Beacon slots the interval must be a + /// positive multiple of the slot duration. + pub fn set_block_timestamp_interval(&self, interval: u64) -> Result<(), BlockchainError> { trace!(target: "time", "set interval {}", interval); + if let Some(slots) = *self.beacon_slots.read() + && !slots.is_interval(interval) + { + return Err(BlockchainError::TimestampError(format!( + "interval {interval} is not a positive multiple of the {}s Beacon slot duration", + slots.seconds_per_slot + ))); + } self.interval.write().replace(interval); + Ok(()) } /// Removes the interval if it exists @@ -102,17 +188,26 @@ impl TimeManager { fn compute_next_timestamp(&self) -> (u64, Option) { let current = duration_since_unix_epoch().as_secs() as i128; let last_timestamp = *self.last_timestamp.read(); + let slots = *self.beacon_slots.read(); - let (mut next_timestamp, update_offset) = - if let Some(next) = *self.next_exact_timestamp.read() { - (next, true) - } else if let Some(interval) = *self.interval.read() { - (last_timestamp.saturating_add(interval), false) - } else { - (current.saturating_add(self.offset()) as u64, false) - }; - // Ensures that the timestamp is always increasing - if next_timestamp < last_timestamp { + let (mut next_timestamp, update_offset) = if let Some(next) = + *self.next_exact_timestamp.read() + { + (next, true) + } else if let Some(interval) = *self.interval.read() { + (last_timestamp.saturating_add(interval), false) + } else { + let clock = current.saturating_add(self.offset()); + // Clamp instead of wrapping so slot rounding sees the intended clock. + let clock = + if slots.is_some() { clock.clamp(0, MAX_BEACON_TIMESTAMP as i128) } else { clock }; + (clock as u64, false) + }; + if let Some(slots) = slots { + next_timestamp = + slots.next_after(last_timestamp, next_timestamp.min(MAX_BEACON_TIMESTAMP)); + } else if next_timestamp < last_timestamp { + // Ensures that the timestamp is always increasing next_timestamp = last_timestamp + 1; } let next_offset = update_offset.then_some((next_timestamp as i128) - current); @@ -138,6 +233,39 @@ impl TimeManager { } } +/// Ethereum Beacon slot grid: slot timestamps are `genesis_time + k * seconds_per_slot` +#[derive(Clone, Copy, Debug)] +struct BeaconSlots { + genesis_time: u64, + /// Always positive + seconds_per_slot: u64, +} + +impl BeaconSlots { + /// Returns true if `timestamp` is a slot timestamp + fn contains(&self, timestamp: u64) -> bool { + timestamp + .checked_sub(self.genesis_time) + .is_some_and(|elapsed| elapsed.is_multiple_of(self.seconds_per_slot)) + } + + /// Returns true if `interval` advances from one slot to a later one + fn is_interval(&self, interval: u64) -> bool { + interval != 0 + && interval <= MAX_BEACON_TIMESTAMP + && interval.is_multiple_of(self.seconds_per_slot) + } + + /// Uses the slot containing the clock, or the next slot if the parent already occupies it. + /// Inputs are bounded before reaching here; normal mining cannot exhaust the integer range. + fn next_after(&self, last: u64, candidate: u64) -> u64 { + let clock_slot = candidate.saturating_sub(self.genesis_time) / self.seconds_per_slot; + let next_slot = + (last + 1).saturating_sub(self.genesis_time).div_ceil(self.seconds_per_slot); + self.genesis_time + clock_slot.max(next_slot) * self.seconds_per_slot + } +} + /// Returns the current duration since unix epoch. pub fn duration_since_unix_epoch() -> Duration { use std::time::SystemTime; @@ -145,3 +273,131 @@ pub fn duration_since_unix_epoch() -> Duration { now.duration_since(SystemTime::UNIX_EPOCH) .unwrap_or_else(|err| panic!("Current time {now:?} is invalid: {err:?}")) } + +#[cfg(test)] +mod tests { + use super::*; + + const GENESIS: u64 = 1000; + const SLOT: u64 = 12; + const TIP: u64 = 1240; + + fn beacon(tip: u64) -> TimeManager { + let time = TimeManager::new(tip); + time.set_beacon_slots(Some((GENESIS, SLOT))).unwrap(); + time + } + + #[test] + fn beacon_implicit_timestamp_is_next_slot() { + let time = beacon(TIP); + assert_eq!(time.current_call_timestamp(), 1252); + assert_eq!(time.next_timestamp(), 1252); + assert_eq!(time.next_timestamp(), 1264); + } + + #[test] + fn beacon_next_block_timestamp_must_be_later_slot() { + let time = beacon(TIP); + assert!(time.set_next_block_timestamp(1253).is_err()); + assert!(time.set_next_block_timestamp(TIP).is_err()); + assert!(time.set_next_block_timestamp(1228).is_err()); + time.set_next_block_timestamp(1276).unwrap(); + assert_eq!(time.current_call_timestamp(), 1276); + assert_eq!(time.next_timestamp(), 1276); + } + + #[test] + fn beacon_interval_must_be_positive_slot_multiple() { + let time = beacon(TIP); + assert!(time.set_block_timestamp_interval(5).is_err()); + assert!(time.set_block_timestamp_interval(0).is_err()); + time.set_block_timestamp_interval(24).unwrap(); + assert_eq!(time.current_call_timestamp(), 1264); + assert_eq!(time.next_timestamp(), 1264); + assert_eq!(time.next_timestamp(), 1288); + } + + #[test] + fn beacon_set_time_cannot_move_behind_tip() { + let time = beacon(TIP); + time.set_next_block_timestamp(1300).unwrap(); + assert_eq!(time.next_timestamp(), 1300); + + assert!(time.set_time(1288).is_err()); + assert!(time.set_time(1301).is_err()); + time.set_time(1300).unwrap(); + // The mined tip stays canonical, so the next block lands in the following slot. + assert!(time.set_next_block_timestamp(1300).is_err()); + assert_eq!(time.current_call_timestamp(), 1312); + assert_eq!(time.next_timestamp(), 1312); + } + + #[test] + fn beacon_reset_clears_one_shot_and_keeps_interval() { + let time = beacon(TIP); + time.set_block_timestamp_interval(24).unwrap(); + time.set_next_block_timestamp(1300).unwrap(); + time.reset(TIP); + assert_eq!(time.next_timestamp(), 1264); + } + + #[test] + fn beacon_activation_drops_incompatible_pending_state() { + let time = TimeManager::new(TIP); + time.set_block_timestamp_interval(30).unwrap(); + time.set_next_block_timestamp(1253).unwrap(); + time.set_beacon_slots(Some((GENESIS, SLOT))).unwrap(); + assert_eq!(time.next_timestamp(), 1252); + assert!(time.set_beacon_slots(Some((GENESIS, 0))).is_err()); + } + + #[test] + fn beacon_extreme_time_jump_leaves_room_to_continue_mining() { + let time = beacon(TIP); + time.increase_time(u64::MAX); + let first = time.next_timestamp(); + assert!(first <= MAX_BEACON_TIMESTAMP); + assert_eq!((first - GENESIS) % SLOT, 0); + assert_eq!(time.next_timestamp(), first + SLOT); + assert!(time.set_next_block_timestamp(u64::MAX).is_err()); + assert!(time.set_time(u64::MAX).is_err()); + assert!(time.set_block_timestamp_interval(u64::MAX / SLOT * SLOT).is_err()); + } + + #[test] + fn beacon_clock_uses_containing_slot_after_elapsed_seconds() { + let time = beacon(TIP); + time.set_time(1300).unwrap(); + time.increase_time(1); + assert_eq!(time.next_timestamp(), 1300); + let time = beacon(TIP); + time.increase_time(5); + time.increase_time(12); + assert_eq!(time.next_timestamp(), 1252); + } + + #[test] + fn legacy_behavior_is_unchanged() { + let time = TimeManager::new(TIP); + time.set_next_block_timestamp(TIP).unwrap(); + time.set_next_block_timestamp(1253).unwrap(); + assert_eq!(time.next_timestamp(), 1253); + time.set_block_timestamp_interval(0).unwrap(); + time.set_block_timestamp_interval(5).unwrap(); + assert_eq!(time.next_timestamp(), 1258); + time.set_time(100).unwrap(); + assert_eq!(time.next_timestamp(), 105); + } + + #[test] + fn clearing_beacon_slots_restores_legacy_behavior() { + let time = beacon(TIP); + time.set_beacon_slots(None).unwrap(); + time.set_next_block_timestamp(1253).unwrap(); + assert_eq!(time.next_timestamp(), 1253); + time.set_block_timestamp_interval(5).unwrap(); + time.set_time(100).unwrap(); + assert_eq!(time.next_timestamp(), 105); + } +} diff --git a/crates/anvil/src/server/beacon/handlers.rs b/crates/anvil/src/server/beacon/handlers.rs index 15876a8bf..df3bbe6f3 100644 --- a/crates/anvil/src/server/beacon/handlers.rs +++ b/crates/anvil/src/server/beacon/handlers.rs @@ -1,4 +1,7 @@ -use super::{error::BeaconError, utils::must_be_ssz}; +use super::{ + error::{BeaconError, BeaconErrorCode}, + utils::must_be_ssz, +}; use crate::eth::EthApi; use alloy_eips::BlockId; use alloy_primitives::{B256, aliases::B32}; @@ -38,20 +41,44 @@ pub async fn handle_get_blobs( Path(block_id): Path, Query(versioned_hashes): Query>, ) -> Response { - // Parse block_id from path parameter - let Ok(block_id) = BlockId::from_str(&block_id) else { - return BeaconError::invalid_block_id(block_id).into_response(); + let versioned_hashes = versioned_hashes + .get("versioned_hashes") + .map(|s| { + s.split(',').map(|hash| B256::from_str(hash.trim())).collect::, _>>() + }) + .transpose(); + let versioned_hashes = match versioned_hashes { + Ok(hashes) => hashes.unwrap_or_default(), + Err(_) => { + return BeaconError::new(BeaconErrorCode::BadRequest, "Invalid versioned_hashes") + .into_response(); + } }; - // Parse indices from query parameters - // Supports both comma-separated (?indices=1,2,3) and repeated parameters (?indices=1&indices=2) - let versioned_hashes: Vec = versioned_hashes - .get("versioned_hashes") - .map(|s| s.split(',').filter_map(|hash| B256::from_str(hash.trim()).ok()).collect()) - .unwrap_or_default(); + let fork_beacon = api.backend.get_fork().and_then(|fork| { + let config = fork.config.read(); + config.beacon.clone().map(|beacon| (beacon, config.timestamp, config.block_number)) + }); + let result = if let Some((beacon, fork_timestamp, fork_number)) = fork_beacon { + let Ok(slot) = block_id.parse::() else { + return BeaconError::invalid_block_id(block_id).into_response(); + }; + let Some(timestamp) = beacon.timestamp(slot) else { + return BeaconError::invalid_block_id(block_id).into_response(); + }; + if timestamp <= fork_timestamp { + beacon.blobs(slot, &versioned_hashes).await + } else { + api.backend.get_blobs_by_timestamp(timestamp, fork_number + 1, &versioned_hashes) + } + } else { + let Ok(block_id) = BlockId::from_str(&block_id) else { + return BeaconError::invalid_block_id(block_id).into_response(); + }; + api.anvil_get_blobs_by_block_id(block_id, versioned_hashes).map_err(Into::into) + }; - // Get the blob sidecars using existing EthApi logic - match api.anvil_get_blobs_by_block_id(block_id, versioned_hashes) { + match result { Ok(Some(blobs)) => { if must_be_ssz(&headers) { blobs.as_ssz_bytes().into_response() @@ -65,16 +92,24 @@ pub async fn handle_get_blobs( } } Ok(None) => BeaconError::block_not_found().into_response(), - Err(_) => BeaconError::internal_error().into_response(), + Err(error) => { + warn!(target: "beacon", %error, %block_id, "Beacon blob lookup failed"); + BeaconError::internal_error().into_response() + } } } /// Handles incoming Beacon API requests for genesis details /// -/// Only returns the `genesis_time`, other fields are set to zero. +/// Fork-Beacon mode returns the captured upstream identity; otherwise only genesis time is set. /// /// GET /eth/v1/beacon/genesis pub async fn handle_get_genesis(State(api): State) -> Response { + if let Some(fork) = api.backend.get_fork() + && let Some(beacon) = &fork.config.read().beacon + { + return Json(GenesisResponse { data: beacon.genesis.clone() }).into_response(); + } match api.anvil_get_genesis_time() { Ok(genesis_time) => Json(GenesisResponse { data: GenesisData { @@ -92,7 +127,13 @@ pub async fn handle_get_genesis(State(api): State) -> Response { /// /// GET /eth/v1/config/spec pub async fn handle_get_spec(State(api): State) -> Response { - match api.anvil_get_interval_mining().ok().flatten().filter(|interval| *interval > 0) { + let captured_duration = api + .backend + .get_fork() + .and_then(|fork| fork.config.read().beacon.as_ref().map(|beacon| beacon.seconds_per_slot)); + match captured_duration + .or_else(|| api.anvil_get_interval_mining().ok().flatten().filter(|interval| *interval > 0)) + { Some(interval) => Json(serde_json::json!({ "data": { "SECONDS_PER_SLOT": interval.to_string() diff --git a/crates/anvil/tests/it/beacon_api.rs b/crates/anvil/tests/it/beacon_api.rs index 9039acdc2..59e7af6d9 100644 --- a/crates/anvil/tests/it/beacon_api.rs +++ b/crates/anvil/tests/it/beacon_api.rs @@ -3,13 +3,33 @@ use alloy_consensus::{Blob, BlobTransactionSidecar, SidecarBuilder, SimpleCoder, use alloy_network::{TransactionBuilder, TransactionBuilder4844}; use alloy_primitives::{B256, FixedBytes, U256, b256}; use alloy_provider::Provider; -use alloy_rpc_types::TransactionRequest; -use alloy_rpc_types_beacon::{genesis::GenesisResponse, sidecar::GetBlobsResponse}; +use alloy_rpc_types::{ + BlockId, BlockNumberOrTag, TransactionRequest, + anvil::{Forking, MineOptions}, +}; +use alloy_rpc_types_beacon::{ + genesis::{GenesisData, GenesisResponse}, + sidecar::GetBlobsResponse, +}; use alloy_serde::WithOtherFields; -use anvil::{NodeConfig, spawn}; +use anvil::{NodeConfig, NodeHandle, eth::EthApi, spawn}; +use axum::{ + Json, Router, + extract::{Path, State}, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::get, +}; use foundry_evm::hardfork::EthereumHardfork; -use ssz::Decode; -use std::time::Duration; +use ssz::{Decode, Encode}; +use std::{ + sync::{ + Arc, Mutex, + atomic::{AtomicU64, Ordering}, + }, + time::Duration, +}; +use tokio::net::TcpListener; #[tokio::test(flavor = "multi_thread")] async fn test_beacon_api_get_blob_sidecars() { @@ -276,3 +296,517 @@ async fn test_beacon_api_get_spec() { assert_eq!(response, serde_json::json!({ "data": { "SECONDS_PER_SLOT": "4" } })); } + +/// Execution block the Beacon-backed target pins as its upstream boundary. +pub(super) const BEACON_ORIGIN_BLOCK: u64 = 100; +/// Timestamp of [`BEACON_ORIGIN_BLOCK`], in seconds; slot 20 on the mock Beacon chain. +pub(super) const BEACON_ORIGIN_TIMESTAMP_SECS: u64 = 1_240; +/// Mock Beacon chain genesis time, in seconds. +pub(super) const BEACON_GENESIS_TIME_SECS: u64 = 1_000; +/// Mock Beacon chain slot duration, in seconds. +pub(super) const BEACON_SECONDS_PER_SLOT: u64 = 12; +/// Nonzero mock genesis validators root, so a locally synthesized zero root is detectable. +pub(super) const BEACON_GENESIS_VALIDATORS_ROOT: B256 = + b256!("0x4b363db94e286120d76eb905340fdd4e54bfe9f06bf33ff6cf5ad27f511bfe95"); +/// Nonzero mock genesis fork version. +pub(super) const BEACON_GENESIS_FORK_VERSION: [u8; 4] = [0x01, 0x02, 0x03, 0x04]; +/// Payload of the single upstream-owned blob at slot 20. +pub(super) const BEACON_HISTORICAL_BLOB_DATA: &[u8] = b"mock beacon historical slot 20 blob"; +/// Payload of the upstream's conflicting blob at slot 21, which the target must never serve. +pub(super) const BEACON_CONFLICTING_BLOB_DATA: &[u8] = b"mock beacon conflicting slot 21 blob"; +/// Payloads of the two local blob transactions mined at slot 21. +pub(super) const BEACON_LOCAL_BLOB_DATA: [&[u8]; 2] = + [b"local slot 21 blob zero", b"local slot 21 blob one"]; + +/// Returns the timestamp, in seconds, of `slot` on the mock Beacon chain. +pub(super) const fn beacon_slot_timestamp(slot: u64) -> u64 { + BEACON_GENESIS_TIME_SECS + slot * BEACON_SECONDS_PER_SLOT +} + +/// Builds a single-blob sidecar encoding `data`. +pub(super) fn beacon_sidecar(data: &[u8]) -> BlobTransactionSidecar { + let sidecar: SidecarBuilder = SidecarBuilder::from_slice(data); + sidecar.build().unwrap() +} + +/// Origin execution node: Cancun genesis at block 100, timestamp 1,240, mining paused. +pub(super) fn beacon_origin_config() -> NodeConfig { + NodeConfig::test() + .with_genesis_block_number(Some(BEACON_ORIGIN_BLOCK)) + .with_genesis_timestamp(Some(BEACON_ORIGIN_TIMESTAMP_SECS)) + .with_hardfork(Some(EthereumHardfork::Cancun.into())) + .with_no_mining(true) +} + +/// Target node pinned to `origin_url` at block 100 with `beacon_url` as its Beacon upstream. +pub(super) fn beacon_target_config(origin_url: String, beacon_url: String) -> NodeConfig { + NodeConfig::test() + .with_eth_rpc_url(Some(origin_url)) + .with_fork_block_number(Some(BEACON_ORIGIN_BLOCK)) + .no_storage_caching() + .with_fork_beacon_url(Some(beacon_url)) + .with_hardfork(Some(EthereumHardfork::Cancun.into())) + .with_no_mining(true) +} + +/// Shared state of [`MockBeacon`]. +struct MockBeaconState { + historical_blob: Blob, + conflicting_blob: Blob, + blob_requests: Mutex>, + seconds_per_slot: AtomicU64, +} + +/// Mock Beacon node on an ephemeral local port. +/// +/// Serves genesis 1,000 with nonzero root/version and 12-second slots. Blob routes: slot 18 fails +/// with 500, slot 20 returns [`BEACON_HISTORICAL_BLOB_DATA`], slot 21 returns the conflicting +/// [`BEACON_CONFLICTING_BLOB_DATA`], every other block ID is 404. Every blob request's block ID is +/// recorded. The server lives until the test runtime shuts down. +pub(super) struct MockBeacon { + pub(super) url: String, + state: Arc, +} + +impl MockBeacon { + pub(super) async fn spawn() -> Self { + let state = Arc::new(MockBeaconState { + 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(Self::genesis)) + .route("/eth/v1/config/spec", get(Self::spec)) + .route("/eth/v1/beacon/blobs/{block_id}", get(Self::blobs)) + .with_state(Arc::clone(&state)); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + Self { url, state } + } + + /// Exact upstream bytes of the slot 20 blob. + pub(super) fn historical_blob(&self) -> Blob { + self.state.historical_blob + } + + /// Block IDs of all blob requests received so far, in arrival order. + pub(super) fn blob_requests(&self) -> Vec { + self.state.blob_requests.lock().unwrap().clone() + } + + async fn genesis() -> Json { + Json(GenesisResponse { + data: GenesisData { + genesis_time: BEACON_GENESIS_TIME_SECS, + genesis_validators_root: BEACON_GENESIS_VALIDATORS_ROOT, + genesis_fork_version: FixedBytes::from(BEACON_GENESIS_FORK_VERSION), + }, + }) + } + + async fn spec(State(state): State>) -> Json { + Json(serde_json::json!({ + "data": { "SECONDS_PER_SLOT": state.seconds_per_slot.load(Ordering::Relaxed).to_string() } + })) + } + + async fn blobs( + State(state): State>, + Path(block_id): Path, + headers: HeaderMap, + ) -> Response { + state.blob_requests.lock().unwrap().push(block_id.clone()); + let blobs = match block_id.as_str() { + "16" => return vec![b' '; 33 * 1024 * 1024].into_response(), + "17" => { + tokio::time::sleep(Duration::from_secs(3)).await; + return StatusCode::NOT_FOUND.into_response(); + } + "18" => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + "20" => vec![state.historical_blob], + "21" => vec![state.conflicting_blob], + _ => return StatusCode::NOT_FOUND.into_response(), + }; + let wants_ssz = headers + .get(axum::http::header::ACCEPT) + .and_then(|accept| accept.to_str().ok()) + .is_some_and(|accept| accept.contains("application/octet-stream")); + if wants_ssz { + blobs.as_ssz_bytes().into_response() + } else { + Json(GetBlobsResponse { execution_optimistic: false, finalized: true, data: blobs }) + .into_response() + } + } +} + +/// Origin execution node, mock Beacon upstream, and a target pinned to both at block 100. +pub(super) struct BeaconTargetFixture { + pub(super) origin_api: EthApi, + pub(super) origin: NodeHandle, + pub(super) beacon: MockBeacon, + pub(super) api: EthApi, + pub(super) handle: NodeHandle, +} + +impl BeaconTargetFixture { + pub(super) async fn spawn() -> Self { + let (origin_api, origin) = spawn(beacon_origin_config()).await; + let beacon = MockBeacon::spawn().await; + let (api, handle) = + spawn(beacon_target_config(origin.http_endpoint(), beacon.url.clone())).await; + Self { origin_api, origin, beacon, api, handle } + } + + /// Submits one single-blob transaction per payload from the first dev account, then mines + /// them into one target block at the explicit `timestamp`. Returns hashes and sidecars in + /// submission (nonce) order. + pub(super) async fn mine_blob_block( + &self, + timestamp: u64, + payloads: &[&[u8]], + ) -> Vec<(B256, BlobTransactionSidecar)> { + let provider = http_provider(&self.handle.http_endpoint()); + let from = self.handle.dev_accounts().next().unwrap(); + let mut sent = Vec::with_capacity(payloads.len()); + for (nonce, payload) in payloads.iter().enumerate() { + let sidecar = beacon_sidecar(payload); + let tx = TransactionRequest::default() + .with_from(from) + .with_to(from) + .with_nonce(nonce as u64) + .with_gas_limit(21_000) + .with_max_fee_per_gas(10_000_000_000) + .with_max_priority_fee_per_gas(1_000_000_000) + .with_max_fee_per_blob_gas(10_000_000_000) + .with_blob_sidecar_4844(sidecar.clone()); + let mut tx = WithOtherFields::new(tx); + tx.populate_blob_hashes(); + let pending = provider.send_transaction(tx).await.unwrap(); + sent.push((*pending.tx_hash(), sidecar)); + } + self.api.evm_mine(Some(MineOptions::Timestamp(Some(timestamp)))).await.unwrap(); + sent + } +} + +/// Fetches `/eth/v1/beacon/blobs/{block_id}` (which may include a query) and returns its status. +async fn beacon_blobs_status(endpoint: &str, block_id: &str) -> reqwest::StatusCode { + reqwest::Client::new() + .get(format!("{endpoint}/eth/v1/beacon/blobs/{block_id}")) + .send() + .await + .unwrap() + .status() +} + +/// Fetches `/eth/v1/beacon/blobs/{block_id}` as JSON, asserting success. +async fn beacon_blobs_json(endpoint: &str, block_id: &str) -> Vec { + let response = reqwest::Client::new() + .get(format!("{endpoint}/eth/v1/beacon/blobs/{block_id}")) + .send() + .await + .unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::OK, "JSON blobs for {block_id}"); + response.json::().await.unwrap().data +} + +/// Fetches `/eth/v1/beacon/blobs/{block_id}` as SSZ, asserting success and content type. +async fn beacon_blobs_ssz(endpoint: &str, block_id: &str) -> Vec { + let response = reqwest::Client::new() + .get(format!("{endpoint}/eth/v1/beacon/blobs/{block_id}")) + .header(axum::http::header::ACCEPT, "application/octet-stream") + .send() + .await + .unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::OK, "SSZ blobs for {block_id}"); + assert_eq!( + response.headers().get("content-type").and_then(|h| h.to_str().ok()), + Some("application/octet-stream") + ); + let body = response.bytes().await.unwrap(); + // SSZ decoding of full blobs recurses deeply; use a larger stack like the test above. + std::thread::Builder::new() + .stack_size(8 * 1024 * 1024) + .spawn(move || Vec::::from_ssz_bytes(&body)) + .unwrap() + .join() + .unwrap() + .unwrap() +} + +/// Asserts blob equality without dumping 128 KiB blobs on failure. +fn assert_blobs_eq(actual: &[Blob], expected: &[Blob], context: &str) { + assert_eq!(actual.len(), expected.len(), "{context}: blob count"); + for (index, (actual, expected)) in actual.iter().zip(expected).enumerate() { + assert!(actual == expected, "{context}: blob {index} bytes differ"); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn beacon_api_upstream_metadata_is_stable_across_mining_modes() { + let fixture = BeaconTargetFixture::spawn().await; + let endpoint = fixture.handle.http_endpoint(); + let client = reqwest::Client::new(); + let expected_genesis = MockBeacon::genesis().await.0; + + // Mining starts paused; then switch through interval modes too long to fire, then pause. + for interval_secs in [None, Some(3_600), Some(7_200), Some(0)] { + if let Some(interval_secs) = interval_secs { + fixture.api.anvil_set_interval_mining(interval_secs).unwrap(); + } + + let genesis: GenesisResponse = client + .get(format!("{endpoint}/eth/v1/beacon/genesis")) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(genesis, expected_genesis, "genesis with interval {interval_secs:?}"); + + let spec = client.get(format!("{endpoint}/eth/v1/config/spec")).send().await.unwrap(); + assert_eq!(spec.status(), reqwest::StatusCode::OK, "spec with interval {interval_secs:?}"); + let spec: serde_json::Value = spec.json().await.unwrap(); + assert_eq!( + spec["data"]["SECONDS_PER_SLOT"], + serde_json::json!(BEACON_SECONDS_PER_SLOT.to_string()), + "spec with interval {interval_secs:?}" + ); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn beacon_api_numeric_ids_resolve_local_slots() { + let fixture = BeaconTargetFixture::spawn().await; + let endpoint = fixture.handle.http_endpoint(); + + // Slot 21 has no local block yet; the upstream's conflicting slot 21 must not be consulted. + assert_eq!(beacon_blobs_status(&endpoint, "21").await, reqwest::StatusCode::NOT_FOUND); + + let sent = fixture.mine_blob_block(beacon_slot_timestamp(21), &BEACON_LOCAL_BLOB_DATA).await; + let block = http_provider(&endpoint) + .get_block_by_number(BlockNumberOrTag::Latest) + .await + .unwrap() + .unwrap(); + assert_eq!(block.header.number, BEACON_ORIGIN_BLOCK + 1); + assert_eq!(block.header.timestamp, beacon_slot_timestamp(21)); + + let expected = [sent[0].1.blobs[0], sent[1].1.blobs[0]]; + assert!(expected[0] != expected[1], "fixture blobs must be distinct"); + let json = beacon_blobs_json(&endpoint, "21").await; + assert_blobs_eq(&json, &expected, "slot 21 JSON"); + let ssz = beacon_blobs_ssz(&endpoint, "21").await; + assert_blobs_eq(&ssz, &expected, "slot 21 SSZ"); + + let second_hash = sent[1].1.versioned_hashes().next().unwrap(); + let filtered = + beacon_blobs_json(&endpoint, &format!("21?versioned_hashes={second_hash}")).await; + assert_blobs_eq(&filtered, &expected[1..], "slot 21 filtered to second hash"); + + let partly_malformed = format!("{second_hash},0xzz"); + for malformed in ["0x1234", "not-a-hash", partly_malformed.as_str()] { + assert_eq!( + beacon_blobs_status(&endpoint, &format!("21?versioned_hashes={malformed}")).await, + reqwest::StatusCode::BAD_REQUEST, + "malformed versioned_hashes {malformed}" + ); + } + + // Skip slot 22 and mine slot 23: the skipped slot is missing, not slot 21's blobs. + fixture + .api + .evm_mine(Some(MineOptions::Timestamp(Some(beacon_slot_timestamp(23))))) + .await + .unwrap(); + assert_eq!(beacon_blobs_status(&endpoint, "22").await, reqwest::StatusCode::NOT_FOUND); + assert!(beacon_blobs_json(&endpoint, "23").await.is_empty()); + + assert_eq!( + fixture.beacon.blob_requests(), + Vec::::new(), + "post-boundary upstream reads" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn beacon_api_historical_slots_use_upstream() { + let fixture = BeaconTargetFixture::spawn().await; + let endpoint = fixture.handle.http_endpoint(); + let expected = [fixture.beacon.historical_blob()]; + + // Slot 20 holds the pinned execution block itself, so it is upstream-owned. + let json = beacon_blobs_json(&endpoint, "20").await; + assert_blobs_eq(&json, &expected, "slot 20 JSON"); + let ssz = beacon_blobs_ssz(&endpoint, "20").await; + assert_blobs_eq(&ssz, &expected, "slot 20 SSZ"); + let requests = fixture.beacon.blob_requests(); + assert!(!requests.is_empty(), "slot 20 must be read upstream"); + assert!(requests.iter().all(|id| id == "20"), "unexpected upstream reads {requests:?}"); + + let status = beacon_blobs_status(&endpoint, "18").await; + assert!(status.is_server_error(), "upstream failure must not look missing, got {status}"); + assert_eq!(beacon_blobs_status(&endpoint, "19").await, reqwest::StatusCode::NOT_FOUND); + let requests = fixture.beacon.blob_requests(); + assert!(requests.iter().any(|id| id == "18"), "slot 18 not read upstream: {requests:?}"); + assert!(requests.iter().any(|id| id == "19"), "slot 19 not read upstream: {requests:?}"); + + assert_eq!( + beacon_blobs_status(&endpoint, "20?versioned_hashes=0x1234").await, + reqwest::StatusCode::BAD_REQUEST + ); + assert_eq!(fixture.beacon.blob_requests(), requests, "malformed query reached upstream"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn beacon_api_rejects_offgrid_and_reused_slot_timestamps() { + let fixture = BeaconTargetFixture::spawn().await; + let provider = http_provider(&fixture.handle.http_endpoint()); + let mine_at = |timestamp| fixture.api.evm_mine(Some(MineOptions::Timestamp(Some(timestamp)))); + + assert!(mine_at(BEACON_ORIGIN_TIMESTAMP_SECS).await.is_err(), "reused boundary slot"); + assert!(mine_at(beacon_slot_timestamp(21) + 1).await.is_err(), "off-grid timestamp"); + assert_eq!(provider.get_block_number().await.unwrap(), BEACON_ORIGIN_BLOCK); + + mine_at(beacon_slot_timestamp(21)).await.unwrap(); + assert!(mine_at(beacon_slot_timestamp(21)).await.is_err(), "reused local slot"); + assert_eq!(provider.get_block_number().await.unwrap(), BEACON_ORIGIN_BLOCK + 1); + let block = provider.get_block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert_eq!(block.header.timestamp, beacon_slot_timestamp(21)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn beacon_api_execution_reads_around_boundary() { + let fixture = BeaconTargetFixture::spawn().await; + let provider = http_provider(&fixture.handle.http_endpoint()); + let origin_provider = http_provider(&fixture.origin.http_endpoint()); + + // Competing upstream continuation after the pinned block. + fixture + .origin_api + .evm_mine(Some(MineOptions::Timestamp(Some(beacon_slot_timestamp(25))))) + .await + .unwrap(); + let origin_block = |number: u64| origin_provider.get_block(BlockId::number(number)); + let origin_boundary = origin_block(BEACON_ORIGIN_BLOCK).await.unwrap().unwrap(); + let remote_successor = origin_block(BEACON_ORIGIN_BLOCK + 1).await.unwrap().unwrap(); + + let sent = fixture.mine_blob_block(beacon_slot_timestamp(21), &BEACON_LOCAL_BLOB_DATA).await; + let sent_hashes = sent.iter().map(|(hash, _)| *hash).collect::>(); + + // Boundary block F is the upstream block, by number and by hash. + let boundary = provider.get_block(BlockId::number(BEACON_ORIGIN_BLOCK)).await.unwrap().unwrap(); + assert_eq!(boundary.header.hash, origin_boundary.header.hash); + assert_eq!(boundary.header.timestamp, BEACON_ORIGIN_TIMESTAMP_SECS); + let boundary_hash = boundary.header.hash; + let by_hash = provider.get_block(BlockId::hash(boundary_hash)).await.unwrap().unwrap(); + assert_eq!(by_hash.header.number, BEACON_ORIGIN_BLOCK); + let by_hash = provider.get_block(BlockId::hash(boundary_hash)).full().await.unwrap().unwrap(); + assert_eq!(by_hash.header.number, BEACON_ORIGIN_BLOCK); + let receipts = provider.get_block_receipts(BlockId::number(BEACON_ORIGIN_BLOCK)).await.unwrap(); + assert_eq!(receipts.map(|receipts| receipts.len()), Some(0), "boundary receipts"); + + // F+1 is the local blob block, by number and by hash, with local receipts. + let local = + provider.get_block(BlockId::number(BEACON_ORIGIN_BLOCK + 1)).await.unwrap().unwrap(); + let local_hash = local.header.hash; + assert_ne!(local_hash, remote_successor.header.hash); + assert_eq!(local.header.parent_hash, boundary_hash); + assert_eq!(local.header.timestamp, beacon_slot_timestamp(21)); + assert_eq!(local.transactions.hashes().collect::>(), sent_hashes); + let by_hash = provider.get_block(BlockId::hash(local_hash)).full().await.unwrap().unwrap(); + assert_eq!(by_hash.header.number, BEACON_ORIGIN_BLOCK + 1); + assert_eq!(by_hash.transactions.hashes().collect::>(), sent_hashes); + for hash in &sent_hashes { + let receipt = provider.get_transaction_receipt(*hash).await.unwrap().unwrap(); + assert_eq!(receipt.block_number, Some(BEACON_ORIGIN_BLOCK + 1)); + assert_eq!(receipt.block_hash, Some(local_hash)); + } + for id in [BlockId::number(BEACON_ORIGIN_BLOCK + 1), BlockId::hash(local_hash)] { + let receipts = provider.get_block_receipts(id).await.unwrap().unwrap(); + let receipt_hashes = receipts.iter().map(|r| r.transaction_hash).collect::>(); + assert_eq!(receipt_hashes, sent_hashes, "block receipts by {id:?}"); + } + + // The upstream's post-boundary block is not part of the local chain. + let remote_hash = BlockId::hash(remote_successor.header.hash); + 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 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 and local blobs. + 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()); + assert_eq!(fixture.api.block_number().unwrap(), U256::from(101)); + assert_eq!(beacon_blobs_json(&endpoint, "23").await.len(), 2); + 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(); + fixture + .api + .anvil_reset(Some(Forking { json_rpc_url: None, block_number: Some(101) })) + .await + .unwrap(); + fixture.api.evm_mine(None).await.unwrap(); + let head = http_provider(&endpoint) + .get_block_by_number(BlockNumberOrTag::Latest) + .await + .unwrap() + .unwrap(); + assert_eq!((head.header.number, head.header.timestamp), (102, 1288)); + + fixture.api.anvil_reset(None).await.unwrap(); + 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_bounds_upstream_reads_and_filters_content() { + let (_, origin) = spawn(beacon_origin_config()).await; + let beacon = MockBeacon::spawn().await; + let config = beacon_target_config(origin.http_endpoint(), beacon.url.clone()) + .fork_request_timeout(Some(Duration::from_secs(1))); + let (_, target) = spawn(config).await; + let endpoint = target.http_endpoint(); + for slot in ["16", "17"] { + let status = beacon_blobs_status(&endpoint, slot).await; + assert!(status.is_server_error(), "oversize/timeout must not become absence: {status}"); + } + let historical_hash = + beacon_sidecar(BEACON_HISTORICAL_BLOB_DATA).versioned_hashes().next().unwrap(); + let conflicting_hash = + beacon_sidecar(BEACON_CONFLICTING_BLOB_DATA).versioned_hashes().next().unwrap(); + let filtered = + beacon_blobs_json(&endpoint, &format!("20?versioned_hashes={historical_hash}")).await; + assert_blobs_eq(&filtered, &[beacon.historical_blob()], "historical filter"); + // Mock ignores query filters; the target must filter actual content itself. + assert!( + beacon_blobs_json(&endpoint, &format!("20?versioned_hashes={conflicting_hash}")) + .await + .is_empty() + ); + for invalid_slot in ["18446744073709551615", "latest", "0x15"] { + assert_eq!(beacon_blobs_status(&endpoint, invalid_slot).await, StatusCode::BAD_REQUEST); + } +}