From c91377115eb412ee51e64edd7b4a2ac241ec2e44 Mon Sep 17 00:00:00 2001 From: Francis Li Date: Mon, 5 Oct 2026 19:01:25 +0000 Subject: [PATCH 1/5] fix(anvil): use configured slots per epoch for fee history tags Amp-Thread-ID: https://ampcode.com/threads/T-01a10d69-00b6-7378-855f-af2b1a148692 Co-authored-by: Amp --- crates/anvil/src/eth/api.rs | 11 +---------- crates/anvil/tests/it/gas.rs | 21 ++++++++++++++++++++- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/crates/anvil/src/eth/api.rs b/crates/anvil/src/eth/api.rs index 4d9fbcb2f..04e4ee8fa 100644 --- a/crates/anvil/src/eth/api.rs +++ b/crates/anvil/src/eth/api.rs @@ -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() { diff --git a/crates/anvil/tests/it/gas.rs b/crates/anvil/tests/it/gas.rs index 2becfb386..5f4a87aaf 100644 --- a/crates/anvil/tests/it/gas.rs +++ b/crates/anvil/tests/it/gas.rs @@ -4,7 +4,7 @@ use crate::utils::http_provider_with_signer; use alloy_network::{EthereumWallet, TransactionBuilder}; use alloy_primitives::{Address, U64, U256, uint}; use alloy_provider::Provider; -use alloy_rpc_types::{BlockId, TransactionRequest}; +use alloy_rpc_types::{BlockId, BlockNumberOrTag, TransactionRequest}; use alloy_serde::WithOtherFields; use anvil::{NodeConfig, eth::fees::INITIAL_BASE_FEE, spawn}; @@ -217,6 +217,25 @@ async fn test_can_use_fee_history() { } } +#[tokio::test(flavor = "multi_thread")] +async fn test_fee_history_safe_finalized_use_slots_in_an_epoch() { + let (api, handle) = spawn(NodeConfig::test().with_slots_in_an_epoch(3)).await; + let provider = handle.http_provider(); + + // (blocks mined, expected safe, expected finalized); finalized saturates at genesis first + for (mined, safe, finalized) in [(4, 1, 0), (10, 7, 4)] { + while api.block_number().unwrap() < U256::from(mined) { + api.mine_one().await; + } + for (tag, expected) in + [(BlockNumberOrTag::Safe, safe), (BlockNumberOrTag::Finalized, finalized)] + { + let history = provider.get_fee_history(1, tag, &[]).await.unwrap(); + assert_eq!(history.oldest_block, expected, "{tag} at block {mined}"); + } + } +} + #[tokio::test(flavor = "multi_thread")] async fn test_estimate_gas_empty_data() { let (api, handle) = spawn(NodeConfig::test()).await; From c63414d7c2d4048790b5bc1fdbf7c4dea535e60b Mon Sep 17 00:00:00 2001 From: Francis Li Date: Mon, 5 Oct 2026 19:02:15 +0000 Subject: [PATCH 2/5] fix(anvil): reject malformed Beacon blob versioned_hashes filters Invalid hashes were dropped, so a bad filter returned more blobs than requested or every blob in the block. Amp-Thread-ID: https://ampcode.com/threads/T-01a10d69-00b6-7378-855f-af2b1a148692 Co-authored-by: Amp --- crates/anvil/src/server/beacon/handlers.rs | 17 ++++++++++++----- crates/anvil/tests/it/beacon_api.rs | 11 +++++++++++ 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/crates/anvil/src/server/beacon/handlers.rs b/crates/anvil/src/server/beacon/handlers.rs index 15876a8bf..0f8e466c5 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}; @@ -45,13 +48,17 @@ pub async fn handle_get_blobs( // 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 + let Ok(versioned_hashes) = versioned_hashes .get("versioned_hashes") - .map(|s| s.split(',').filter_map(|hash| B256::from_str(hash.trim()).ok()).collect()) - .unwrap_or_default(); + .map(|s| s.split(',').map(|hash| B256::from_str(hash.trim())).collect()) + .transpose() + else { + return BeaconError::new(BeaconErrorCode::BadRequest, "Invalid versioned_hashes") + .into_response(); + }; // Get the blob sidecars using existing EthApi logic - match api.anvil_get_blobs_by_block_id(block_id, versioned_hashes) { + match api.anvil_get_blobs_by_block_id(block_id, versioned_hashes.unwrap_or_default()) { Ok(Some(blobs)) => { if must_be_ssz(&headers) { blobs.as_ssz_bytes().into_response() diff --git a/crates/anvil/tests/it/beacon_api.rs b/crates/anvil/tests/it/beacon_api.rs index 9039acdc2..adaecf26e 100644 --- a/crates/anvil/tests/it/beacon_api.rs +++ b/crates/anvil/tests/it/beacon_api.rs @@ -218,6 +218,17 @@ async fn test_beacon_api_get_blobs() { "Expected 0 blobs when filtering by non-existent versioned_hash" ); + // Ignoring invalid filters would broaden the request + let partly_invalid = format!("{},0xzz", actual_versioned_hashes[0]); + for filter in ["0x1234", "not-a-hash", "", partly_invalid.as_str()] { + let url = format!( + "{}/eth/v1/beacon/blobs/{block_number}?versioned_hashes={filter}", + handle.http_endpoint() + ); + let response = client.get(&url).send().await.unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST, "filter {filter:?}"); + } + // Test with special block identifiers let test_ids = vec!["latest", "finalized", "safe", "earliest"]; for block_id in test_ids { From 803ade7cf4c4127e9af91ac012eeca058cba4210 Mon Sep 17 00:00:00 2001 From: Francis Li Date: Mon, 5 Oct 2026 19:32:49 +0000 Subject: [PATCH 3/5] Serve fork-aware Beacon blobs, genesis, and spec Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-01a10d69-00b6-7378-855f-af2b1a148692 --- README.md | 27 ++ crates/anvil/src/cmd.rs | 27 ++ crates/anvil/src/config.rs | 29 ++ crates/anvil/src/eth/api.rs | 8 +- crates/anvil/src/eth/backend/fork.rs | 17 +- crates/anvil/src/eth/backend/mem/mod.rs | 75 +++- crates/anvil/src/server/beacon/handlers.rs | 53 ++- crates/anvil/tests/it/beacon_api.rs | 383 ++++++++++++++++++++- 8 files changed, 586 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index b27f25ce6..73fa66499 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,33 @@ 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. +`anvil_reset` is rejected 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/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 32ee16150..7333a7857 100644 --- a/crates/anvil/src/eth/api.rs +++ b/crates/anvil/src/eth/api.rs @@ -2188,8 +2188,14 @@ impl EthApi { /// /// Handler for RPC call: `anvil_reset` pub async fn anvil_reset(&self, forking: Option) -> Result<()> { - self.reset_instance_id(); node_info!("anvil_reset"); + if self.backend.get_fork().is_some_and(|fork| fork.config.read().beacon.is_some()) { + return Err(RpcError::invalid_params( + "anvil_reset is unsupported with a fork Beacon URL", + ) + .into()); + } + self.reset_instance_id(); if let Some(forking) = forking { // if we're resetting the fork we need to reset the instance id self.backend.reset_fork(forking).await diff --git a/crates/anvil/src/eth/backend/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..fbb360192 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); } @@ -3307,23 +3315,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/server/beacon/handlers.rs b/crates/anvil/src/server/beacon/handlers.rs index 0f8e466c5..b02a1dfd7 100644 --- a/crates/anvil/src/server/beacon/handlers.rs +++ b/crates/anvil/src/server/beacon/handlers.rs @@ -41,11 +41,6 @@ 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(); - }; - // Parse indices from query parameters // Supports both comma-separated (?indices=1,2,3) and repeated parameters (?indices=1&indices=2) let Ok(versioned_hashes) = versioned_hashes @@ -56,9 +51,33 @@ pub async fn handle_get_blobs( return BeaconError::new(BeaconErrorCode::BadRequest, "Invalid versioned_hashes") .into_response(); }; + let versioned_hashes: Vec = versioned_hashes.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 { + // 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(); + }; + 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.unwrap_or_default()) { + match result { Ok(Some(blobs)) => { if must_be_ssz(&headers) { blobs.as_ssz_bytes().into_response() @@ -72,16 +91,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 { @@ -99,7 +126,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 adaecf26e..5be6e0fc9 100644 --- a/crates/anvil/tests/it/beacon_api.rs +++ b/crates/anvil/tests/it/beacon_api.rs @@ -3,13 +3,30 @@ 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::StatusCode, + response::{IntoResponse, Response}, + routing::get, +}; use foundry_evm::hardfork::EthereumHardfork; use ssz::Decode; -use std::time::Duration; +use std::{ + sync::{Arc, Mutex}, + time::Duration, +}; +use tokio::net::TcpListener; #[tokio::test(flavor = "multi_thread")] async fn test_beacon_api_get_blob_sidecars() { @@ -287,3 +304,361 @@ async fn test_beacon_api_get_spec() { assert_eq!(response, serde_json::json!({ "data": { "SECONDS_PER_SLOT": "4" } })); } + +// Mock Beacon chain: genesis 1,000 with 12-second slots; the target forks at block 100, slot 20. +pub(super) const BEACON_ORIGIN_BLOCK: u64 = 100; +pub(super) const BEACON_ORIGIN_TIMESTAMP_SECS: u64 = 1_240; +pub(super) const BEACON_GENESIS_TIME_SECS: u64 = 1_000; +pub(super) const BEACON_SECONDS_PER_SLOT: u64 = 12; +// Nonzero, so a locally synthesized zero identity is detectable. +pub(super) const BEACON_GENESIS_VALIDATORS_ROOT: B256 = + b256!("0x4b363db94e286120d76eb905340fdd4e54bfe9f06bf33ff6cf5ad27f511bfe95"); +pub(super) const BEACON_GENESIS_FORK_VERSION: [u8; 4] = [0x01, 0x02, 0x03, 0x04]; +/// Upstream-owned blob at slot 20. +pub(super) const BEACON_HISTORICAL_BLOB_DATA: &[u8] = b"mock beacon historical slot 20 blob"; +/// Upstream 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"; +pub(super) const BEACON_LOCAL_BLOB_DATA: [&[u8]; 2] = + [b"local slot 21 blob zero", b"local slot 21 blob one"]; + +pub(super) const fn beacon_slot_timestamp(slot: u64) -> u64 { + BEACON_GENESIS_TIME_SECS + slot * BEACON_SECONDS_PER_SLOT +} + +pub(super) fn beacon_sidecar(data: &[u8]) -> BlobTransactionSidecar { + SidecarBuilder::::from_slice(data).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) +} + +struct MockBeaconState { + historical_blob: Blob, + conflicting_blob: Blob, + blob_requests: Mutex>, +} + +/// Mock Beacon node. Blob routes: slot 18 fails with 500, slots 20 and 21 return +/// [`BEACON_HISTORICAL_BLOB_DATA`] and [`BEACON_CONFLICTING_BLOB_DATA`] ignoring filters, every +/// other block ID is 404. Records every blob request's block ID. +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(), + }); + let router = Router::new() + .route("/eth/v1/beacon/genesis", get(|| async { Json(Self::genesis()) })) + .route( + "/eth/v1/config/spec", + get(|| async { + let slot = BEACON_SECONDS_PER_SLOT.to_string(); + Json(serde_json::json!({ "data": { "SECONDS_PER_SLOT": slot } })) + }), + ) + .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 } + } + + 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() + } + + fn genesis() -> GenesisResponse { + 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 blobs( + State(state): State>, + Path(block_id): Path, + ) -> Response { + state.blob_requests.lock().unwrap().push(block_id.clone()); + let blobs = match block_id.as_str() { + "18" => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + "20" => vec![state.historical_blob], + "21" => vec![state.conflicting_blob], + _ => return StatusCode::NOT_FOUND.into_response(), + }; + 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 } + } + + /// Mines one single-blob transaction per payload into one block at `timestamp`. Returns + /// hashes and sidecars in 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 + } +} + +/// Requests `/eth/v1/beacon/blobs/{block_id}`, which may include a query. +async fn beacon_blobs(endpoint: &str, block_id: &str, ssz: bool) -> reqwest::Response { + let accept = if ssz { "application/octet-stream" } else { "application/json" }; + let url = format!("{endpoint}/eth/v1/beacon/blobs/{block_id}"); + reqwest::Client::new().get(url).header("accept", accept).send().await.unwrap() +} + +async fn beacon_blobs_status(endpoint: &str, block_id: &str) -> StatusCode { + beacon_blobs(endpoint, block_id, false).await.status() +} + +async fn beacon_blobs_json(endpoint: &str, block_id: &str) -> Vec { + let response = beacon_blobs(endpoint, block_id, false).await; + assert_eq!(response.status(), StatusCode::OK, "JSON blobs for {block_id}"); + response.json::().await.unwrap().data +} + +async fn beacon_blobs_ssz(endpoint: &str, block_id: &str) -> Vec { + let response = beacon_blobs(endpoint, block_id, true).await; + assert_eq!(response.status(), StatusCode::OK, "SSZ blobs for {block_id}"); + assert_eq!(response.headers()["content-type"], "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 get = |path: &str| { + let url = format!("{endpoint}/eth/v1/{path}"); + async move { reqwest::get(url).await.unwrap().json::().await.unwrap() } + }; + + // 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 = serde_json::from_value::(get("beacon/genesis").await); + assert_eq!(genesis.unwrap(), MockBeacon::genesis(), "genesis with {interval_secs:?}"); + assert_eq!( + get("config/spec").await["data"]["SECONDS_PER_SLOT"], + BEACON_SECONDS_PER_SLOT.to_string(), + "spec with {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, StatusCode::NOT_FOUND); + + let sent = fixture.mine_blob_block(beacon_slot_timestamp(21), &BEACON_LOCAL_BLOB_DATA).await; + let expected = [sent[0].1.blobs[0], sent[1].1.blobs[0]]; + assert!(expected[0] != expected[1], "fixture blobs must be distinct"); + assert_blobs_eq(&beacon_blobs_json(&endpoint, "21").await, &expected, "slot 21 JSON"); + assert_blobs_eq(&beacon_blobs_ssz(&endpoint, "21").await, &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!("21?versioned_hashes={second_hash},0xzz"); + assert_eq!(beacon_blobs_status(&endpoint, &partly_malformed).await, StatusCode::BAD_REQUEST); + + // Skip slot 22 and mine slot 23: the skipped slot is missing, not slot 21's blobs. + let at_23 = MineOptions::Timestamp(Some(beacon_slot_timestamp(23))); + fixture.api.evm_mine(Some(at_23)).await.unwrap(); + assert_eq!(beacon_blobs_status(&endpoint, "22").await, StatusCode::NOT_FOUND); + assert!(beacon_blobs_json(&endpoint, "23").await.is_empty()); + assert_eq!(fixture.beacon.blob_requests(), Vec::::new(), "post-boundary upstream"); + + let reset = Forking { json_rpc_url: None, block_number: Some(BEACON_ORIGIN_BLOCK) }; + assert!(fixture.api.anvil_reset(Some(reset)).await.is_err()); + assert!(fixture.api.anvil_reset(None).await.is_err()); + assert_eq!(fixture.api.block_number().unwrap(), U256::from(BEACON_ORIGIN_BLOCK + 2)); + assert_blobs_eq(&beacon_blobs_json(&endpoint, "21").await, &expected, "slot 21 after reset"); +} + +#[tokio::test(flavor = "multi_thread")] +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. + assert_blobs_eq(&beacon_blobs_json(&endpoint, "20").await, &expected, "slot 20 JSON"); + assert_blobs_eq(&beacon_blobs_ssz(&endpoint, "20").await, &expected, "slot 20 SSZ"); + assert_eq!(fixture.beacon.blob_requests(), ["20", "20"]); + + 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, StatusCode::NOT_FOUND); + let malformed = "20?versioned_hashes=0x1234"; + assert_eq!(beacon_blobs_status(&endpoint, malformed).await, StatusCode::BAD_REQUEST); + for invalid_slot in ["18446744073709551615", "latest", "0x15"] { + assert_eq!(beacon_blobs_status(&endpoint, invalid_slot).await, StatusCode::BAD_REQUEST); + } + assert_eq!(fixture.beacon.blob_requests(), ["20", "20", "18", "19"]); + + // The mock ignores filters; the target must filter actual content itself. + let [historical, conflicting] = [BEACON_HISTORICAL_BLOB_DATA, BEACON_CONFLICTING_BLOB_DATA] + .map(|data| beacon_sidecar(data).versioned_hashes().next().unwrap()); + let filtered = beacon_blobs_json(&endpoint, &format!("20?versioned_hashes={historical}")).await; + assert_blobs_eq(&filtered, &expected, "historical filter"); + let filtered = + beacon_blobs_json(&endpoint, &format!("20?versioned_hashes={conflicting}")).await; + assert!(filtered.is_empty(), "conflicting filter"); +} + +#[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"); + let block = provider.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)); +} + +#[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. + let at_25 = MineOptions::Timestamp(Some(beacon_slot_timestamp(25))); + fixture.origin_api.evm_mine(Some(at_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(); + let boundary_hash = boundary.header.hash; + assert_eq!(boundary_hash, origin_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); + + // F+1 is the local blob block, by number and by hash. + 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); + 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_hash, Some(local_hash)); + } + + // 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"); +} From 17d1c67752fe739982e96843009d8668b871c474 Mon Sep 17 00:00:00 2001 From: Francis Li Date: Mon, 5 Oct 2026 20:21:50 +0000 Subject: [PATCH 4/5] Hide upstream transactions and logs outside the pinned Beacon chain Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-01a10d69-00b6-7378-855f-af2b1a148692 --- crates/anvil/src/eth/backend/fork.rs | 25 +++--- crates/anvil/tests/it/beacon_api.rs | 109 +++++++++++++++++++++------ 2 files changed, 100 insertions(+), 34 deletions(-) diff --git a/crates/anvil/src/eth/backend/fork.rs b/crates/anvil/src/eth/backend/fork.rs index 868e5ddc8..b76009668 100644 --- a/crates/anvil/src/eth/backend/fork.rs +++ b/crates/anvil/src/eth/backend/fork.rs @@ -256,7 +256,8 @@ impl ClientFork { return Ok(logs); } - let logs = self.provider().get_logs(filter).await?; + let mut logs = self.provider().get_logs(filter).await?; + logs.retain(|log| !self.beacon_excludes(log.block_number, log.block_hash)); let mut storage = self.storage_write(); storage.logs.insert(filter.clone(), logs.clone()); @@ -364,6 +365,7 @@ impl ClientFork { } let tx = self.provider().get_transaction_by_hash(hash).await?; + let tx = tx.filter(|tx| !self.beacon_excludes(tx.block_number(), tx.block_hash())); if let Some(tx) = tx.clone() { let mut storage = self.storage_write(); storage.transactions.insert(hash, tx); @@ -544,14 +546,8 @@ 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); - } + if self.beacon_excludes(Some(block_number), Some(hash)) { + return Ok(None); } let mut storage = self.storage_write(); // also insert all transactions @@ -614,6 +610,17 @@ impl ClientFork { Ok(uncles.get(index).cloned()) } + /// Whether Beacon mode hides upstream data from a block outside the pinned chain: unmined, + /// past the fork block, or a competing fork block. + fn beacon_excludes(&self, number: Option, hash: Option) -> bool { + let config = self.config.read(); + config.beacon.is_some() + && number.is_none_or(|number| { + number > config.block_number + || (number == config.block_number && hash != Some(config.block_hash)) + }) + } + /// Converts a block of hashes into a full block fn convert_to_full_block(&self, mut block: AnyRpcBlock) -> AnyRpcBlock { let storage = self.storage.read(); diff --git a/crates/anvil/tests/it/beacon_api.rs b/crates/anvil/tests/it/beacon_api.rs index 5be6e0fc9..570f248f8 100644 --- a/crates/anvil/tests/it/beacon_api.rs +++ b/crates/anvil/tests/it/beacon_api.rs @@ -1,10 +1,10 @@ use crate::utils::http_provider; use alloy_consensus::{Blob, BlobTransactionSidecar, SidecarBuilder, SimpleCoder, Transaction}; -use alloy_network::{TransactionBuilder, TransactionBuilder4844}; -use alloy_primitives::{B256, FixedBytes, U256, b256}; +use alloy_network::{TransactionBuilder, TransactionBuilder4844, TransactionResponse}; +use alloy_primitives::{Address, B256, Bytes, FixedBytes, U256, b256}; use alloy_provider::Provider; use alloy_rpc_types::{ - BlockId, BlockNumberOrTag, TransactionRequest, + BlockId, BlockNumberOrTag, Filter, Log, TransactionRequest, anvil::{Forking, MineOptions}, }; use alloy_rpc_types_beacon::{ @@ -620,45 +620,104 @@ async fn beacon_api_rejects_offgrid_and_reused_slot_timestamps() { #[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. - let at_25 = MineOptions::Timestamp(Some(beacon_slot_timestamp(25))); - fixture.origin_api.evm_mine(Some(at_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(); + // Upstream history: block 101 emits a log; the target pins empty block 102. + const PINNED: u64 = BEACON_ORIGIN_BLOCK + 2; + let mine_at = async |api: &EthApi, slot| { + api.evm_mine(Some(MineOptions::Timestamp(Some(beacon_slot_timestamp(slot))))).await.unwrap() + }; + let (origin_api, origin) = spawn(beacon_origin_config()).await; + let origin_provider = http_provider(&origin.http_endpoint()); + let origin_sender = origin.dev_accounts().nth(1).unwrap(); + let historical = send_log_tx(&origin.http_endpoint(), origin_sender).await; + mine_at(&origin_api, 21).await; + mine_at(&origin_api, 22).await; + let origin_block = async |number: u64| { + origin_provider.get_block(BlockId::number(number)).await.unwrap().unwrap() + }; + let origin_boundary = origin_block(PINNED).await; - let sent = fixture.mine_blob_block(beacon_slot_timestamp(21), &BEACON_LOCAL_BLOB_DATA).await; - let sent_hashes = sent.iter().map(|(hash, _)| *hash).collect::>(); + let beacon = MockBeacon::spawn().await; + let config = beacon_target_config(origin.http_endpoint(), beacon.url.clone()) + .with_fork_block_number(Some(PINNED)); + let (api, handle) = spawn(config).await; + let fixture = BeaconTargetFixture { origin_api, origin, beacon, api, handle }; + let provider = http_provider(&fixture.handle.http_endpoint()); // 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(); + let boundary = provider.get_block(BlockId::number(PINNED)).await.unwrap().unwrap(); let boundary_hash = boundary.header.hash; assert_eq!(boundary_hash, origin_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); + assert_eq!(by_hash.header.number, PINNED); let by_hash = provider.get_block(BlockId::hash(boundary_hash)).full().await.unwrap().unwrap(); - assert_eq!(by_hash.header.number, BEACON_ORIGIN_BLOCK); + assert_eq!(by_hash.header.number, PINNED); + + // Upstream replaces F, continues past it, and holds a pending transaction. + let origin_endpoint = fixture.origin.http_endpoint(); + fixture.origin_api.anvil_rollback(Some(1)).await.unwrap(); + let competing = send_log_tx(&origin_endpoint, origin_sender).await; + mine_at(&fixture.origin_api, 22).await; + let remote = send_log_tx(&origin_endpoint, origin_sender).await; + mine_at(&fixture.origin_api, 25).await; + let pending = send_log_tx(&origin_endpoint, origin_sender).await; + let competing_boundary = origin_block(PINNED).await; + assert_ne!(competing_boundary.header.hash, boundary_hash); + let remote_successor = origin_block(PINNED + 1).await; + + let sent = fixture.mine_blob_block(beacon_slot_timestamp(23), &BEACON_LOCAL_BLOB_DATA).await; + let sent_hashes = sent.iter().map(|(hash, _)| *hash).collect::>(); + let local_log = + send_log_tx(&fixture.handle.http_endpoint(), fixture.handle.dev_accounts().next().unwrap()) + .await; + mine_at(&fixture.api, 24).await; // F+1 is the local blob block, by number and by hash. - let local = - provider.get_block(BlockId::number(BEACON_ORIGIN_BLOCK + 1)).await.unwrap().unwrap(); + let local = provider.get_block(BlockId::number(PINNED + 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); 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.header.number, PINNED + 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_hash, Some(local_hash)); } - // 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"); + // Upstream blocks off the pinned chain are not part of the local chain. + for (block, context) in [(&competing_boundary, "competing F"), (&remote_successor, "F+1")] { + let hash = BlockId::hash(block.header.hash); + assert!(provider.get_block(hash).await.unwrap().is_none(), "{context} hashes lookup"); + assert!(provider.get_block(hash).full().await.unwrap().is_none(), "{context} full lookup"); + } + + // Nor are their transactions and logs, unlike historical and local ones. + let tx_by_hash = async |hash| provider.get_transaction_by_hash(hash).await.unwrap(); + let historical_tx = tx_by_hash(historical).await.unwrap(); + assert_eq!(historical_tx.block_number(), Some(BEACON_ORIGIN_BLOCK + 1)); + let local_tx = tx_by_hash(local_log).await.unwrap(); + assert_eq!(local_tx.block_number(), Some(PINNED + 2)); + for (hash, context) in [(competing, "competing F"), (remote, "F+1"), (pending, "pending")] { + assert!(tx_by_hash(hash).await.is_none(), "{context} transaction"); + } + let log_txs = |logs: Vec| logs.into_iter().map(|log| log.transaction_hash.unwrap()); + let logs_at = async |hash| { + let logs = provider.get_logs(&Filter::new().at_block_hash(hash)).await.unwrap(); + log_txs(logs).collect::>() + }; + assert_eq!(logs_at(historical_tx.block_hash().unwrap()).await, [historical]); + assert_eq!(logs_at(local_tx.block_hash().unwrap()).await, [local_log]); + assert!(logs_at(competing_boundary.header.hash).await.is_empty(), "competing F logs"); + assert!(logs_at(remote_successor.header.hash).await.is_empty(), "F+1 logs"); + let range = provider.get_logs(&Filter::new().from_block(BEACON_ORIGIN_BLOCK)).await.unwrap(); + assert_eq!(log_txs(range).collect::>(), [historical, local_log], "range logs"); +} + +/// Sends a contract creation whose init code emits one empty `LOG0`. +async fn send_log_tx(endpoint: &str, from: Address) -> B256 { + // PUSH1 0, PUSH1 0, LOG0 + let init_code = Bytes::from_static(&[0x60, 0x00, 0x60, 0x00, 0xa0]); + let tx = TransactionRequest::default().with_from(from).with_deploy_code(init_code); + let pending = http_provider(endpoint).send_transaction(WithOtherFields::new(tx)).await; + *pending.unwrap().tx_hash() } From 42b28cbecc96c077fd47fdb21fda9f6f600a8f95 Mon Sep 17 00:00:00 2001 From: Francis Li Date: Mon, 5 Oct 2026 20:34:20 +0000 Subject: [PATCH 5/5] Hide receipts and traces outside the pinned Beacon chain Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-01a10d69-00b6-7378-855f-af2b1a148692 --- crates/anvil/src/eth/backend/fork.rs | 11 ++++++++++- crates/anvil/src/eth/backend/mem/mod.rs | 8 ++++++-- crates/anvil/tests/it/beacon_api.rs | 22 +++++++++++++++++++++- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/crates/anvil/src/eth/backend/fork.rs b/crates/anvil/src/eth/backend/fork.rs index b76009668..039db0c14 100644 --- a/crates/anvil/src/eth/backend/fork.rs +++ b/crates/anvil/src/eth/backend/fork.rs @@ -433,7 +433,9 @@ impl ClientFork { return Ok(Some(receipt)); } - if let Some(receipt) = self.provider().get_transaction_receipt(hash).await? { + if let Some(receipt) = self.provider().get_transaction_receipt(hash).await? + && !self.beacon_excludes(receipt.block_number, receipt.block_hash) + { let receipt = FoundryTxReceipt::try_from(receipt) .map_err(|_| BlockchainError::FailedToDecodeReceipt)?; let mut storage = self.storage_write(); @@ -460,6 +462,7 @@ impl ClientFork { let receipts = receipts .map(|r| { r.into_iter() + .filter(|r| !self.beacon_excludes(r.block_number, r.block_hash)) .map(|r| { FoundryTxReceipt::try_from(r) .map_err(|_| BlockchainError::FailedToDecodeReceipt) @@ -621,6 +624,12 @@ impl ClientFork { }) } + /// Whether Beacon mode hides an upstream transaction that is not on the pinned chain. + pub async fn beacon_hides_transaction(&self, hash: B256) -> Result { + let beacon = self.config.read().beacon.is_some(); + Ok(beacon && self.transaction_by_hash(hash).await?.is_none()) + } + /// Converts a block of hashes into a full block fn convert_to_full_block(&self, mut block: AnyRpcBlock) -> AnyRpcBlock { let storage = self.storage.read(); diff --git a/crates/anvil/src/eth/backend/mem/mod.rs b/crates/anvil/src/eth/backend/mem/mod.rs index fbb360192..8a3bf5146 100644 --- a/crates/anvil/src/eth/backend/mem/mod.rs +++ b/crates/anvil/src/eth/backend/mem/mod.rs @@ -2662,7 +2662,9 @@ impl Backend { return Ok(traces); } - if let Some(fork) = self.get_fork() { + if let Some(fork) = self.get_fork() + && !fork.beacon_hides_transaction(hash).await? + { return Ok(fork.trace_transaction(hash).await?); } @@ -2715,7 +2717,9 @@ impl Backend { return trace; } - if let Some(fork) = self.get_fork() { + if let Some(fork) = self.get_fork() + && !fork.beacon_hides_transaction(hash).await? + { return Ok(fork.debug_trace_transaction(hash, opts).await?); } diff --git a/crates/anvil/tests/it/beacon_api.rs b/crates/anvil/tests/it/beacon_api.rs index 570f248f8..c3616e46b 100644 --- a/crates/anvil/tests/it/beacon_api.rs +++ b/crates/anvil/tests/it/beacon_api.rs @@ -2,10 +2,14 @@ use crate::utils::http_provider; use alloy_consensus::{Blob, BlobTransactionSidecar, SidecarBuilder, SimpleCoder, Transaction}; use alloy_network::{TransactionBuilder, TransactionBuilder4844, TransactionResponse}; use alloy_primitives::{Address, B256, Bytes, FixedBytes, U256, b256}; -use alloy_provider::Provider; +use alloy_provider::{ + Provider, + ext::{DebugApi, TraceApi}, +}; use alloy_rpc_types::{ BlockId, BlockNumberOrTag, Filter, Log, TransactionRequest, anvil::{Forking, MineOptions}, + trace::geth::{GethDebugTracingOptions, GethTrace}, }; use alloy_rpc_types_beacon::{ genesis::{GenesisData, GenesisResponse}, @@ -697,9 +701,25 @@ async fn beacon_api_execution_reads_around_boundary() { assert_eq!(historical_tx.block_number(), Some(BEACON_ORIGIN_BLOCK + 1)); let local_tx = tx_by_hash(local_log).await.unwrap(); assert_eq!(local_tx.block_number(), Some(PINNED + 2)); + let receipt = async |hash| provider.get_transaction_receipt(hash).await.unwrap(); + let traces = async |hash| provider.trace_transaction(hash).await.unwrap(); + let debug_trace = async |hash| { + provider.debug_trace_transaction(hash, GethDebugTracingOptions::default()).await.unwrap() + }; + let no_debug_trace = GethTrace::Default(Default::default()); + for (hash, context) in [(historical, "historical"), (local_log, "local")] { + assert!(receipt(hash).await.is_some(), "{context} receipt"); + assert!(!traces(hash).await.is_empty(), "{context} traces"); + assert_ne!(debug_trace(hash).await, no_debug_trace, "{context} debug trace"); + } for (hash, context) in [(competing, "competing F"), (remote, "F+1"), (pending, "pending")] { assert!(tx_by_hash(hash).await.is_none(), "{context} transaction"); + assert!(receipt(hash).await.is_none(), "{context} receipt"); + assert!(traces(hash).await.is_empty(), "{context} traces"); + assert_eq!(debug_trace(hash).await, no_debug_trace, "{context} debug trace"); } + let receipts = provider.get_block_receipts(BlockId::number(PINNED)).await.unwrap(); + assert_eq!(receipts.map(|receipts| receipts.len()), Some(0), "F block receipts"); let log_txs = |logs: Vec| logs.into_iter().map(|log| log.transaction_hash.unwrap()); let logs_at = async |hash| { let logs = provider.get_logs(&Filter::new().at_block_hash(hash)).await.unwrap();