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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---
Expand Down
2 changes: 1 addition & 1 deletion crates/anvil/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"] }

Expand Down
27 changes: 27 additions & 0 deletions crates/anvil/src/cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -420,6 +421,12 @@ pub struct AnvilEvmArgs {
)]
pub fork_url: Option<ForkUrl>,

/// 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<String>,

/// Headers to use for the rpc client, e.g. "User-Agent: test-agent"
///
/// See --fork-url.
Expand Down Expand Up @@ -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();
Expand Down
29 changes: 29 additions & 0 deletions crates/anvil/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use crate::{
EthereumHardfork, FeeManager, PrecompileFactory,
eth::{
backend::{
beacon::ForkBeacon,
db::{Db, SerializableState},
env::Env,
fork::{ClientFork, ClientForkConfig},
Expand Down Expand Up @@ -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<String>,
/// Historical Beacon endpoint used to serve blobs through the execution fork boundary.
pub fork_beacon_url: Option<String>,
/// pins the block number or transaction hash for the state fork
pub fork_choice: Option<ForkChoice>,
/// headers to use with `eth_rpc_url`
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<String>) -> 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<U: Into<u64>>(self, fork_block_number: Option<U>) -> Self {
Expand Down Expand Up @@ -1056,6 +1067,17 @@ impl NodeConfig {
///
/// *Note*: only memory based backend for now
pub(crate) async fn setup(&mut self) -> Result<mem::Backend> {
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();
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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");
Expand Down
5 changes: 2 additions & 3 deletions crates/anvil/src/eth/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2747,7 +2747,7 @@ impl EthApi {
pub fn evm_set_time(&self, timestamp: u64) -> Result<u64> {
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);
Expand All @@ -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
Expand Down
140 changes: 140 additions & 0 deletions crates/anvil/src/eth/backend/beacon.rs
Original file line number Diff line number Diff line change
@@ -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<Self> {
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::<GenesisResponse>(
&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::<serde_json::Value>(
&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<u64> {
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<Option<Vec<Blob>>> {
let Some(response) = Self::get::<GetBlobsResponse>(
&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<Vec<Blob>> {
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<T: DeserializeOwned>(
client: &Client,
url: Url,
hashes: &[B256],
max_bytes: usize,
) -> Result<Option<T>> {
let mut request = client.get(url).header("Accept", "application/json");
if !hashes.is_empty() {
let hashes = hashes.iter().map(ToString::to_string).collect::<Vec<_>>().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)?))
}
}
17 changes: 16 additions & 1 deletion crates/anvil/src/eth/backend/fork.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -657,6 +670,8 @@ pub struct ClientForkConfig {
pub total_difficulty: U256,
/// Transactions to force include in the forked chain
pub force_transactions: Option<Vec<PoolTransaction>>,
/// Historical Beacon connectivity and slot identity, if enabled.
pub beacon: Option<ForkBeacon>,
}

impl ClientForkConfig {
Expand Down
Loading
Loading