Skip to content
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---
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
19 changes: 8 additions & 11 deletions crates/anvil/src/eth/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1702,16 +1702,7 @@ impl EthApi {
node_info!("eth_feeHistory");
// max number of blocks in the requested range

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

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

// check if the number predates the fork, if in fork mode
if let Some(fork) = self.get_fork() {
Expand Down Expand Up @@ -2197,8 +2188,14 @@ impl EthApi {
///
/// Handler for RPC call: `anvil_reset`
pub async fn anvil_reset(&self, forking: Option<Forking>) -> 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
Expand Down
37 changes: 34 additions & 3 deletions 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 @@ -252,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());
Expand Down Expand Up @@ -360,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);
Expand Down Expand Up @@ -427,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();
Expand All @@ -454,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)
Expand Down Expand Up @@ -540,6 +549,9 @@ 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;
if self.beacon_excludes(Some(block_number), Some(hash)) {
return Ok(None);
}
let mut storage = self.storage_write();
// also insert all transactions
let block_txs = match block.transactions() {
Expand Down Expand Up @@ -601,6 +613,23 @@ 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<u64>, hash: Option<B256>) -> 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))
})
}

/// Whether Beacon mode hides an upstream transaction that is not on the pinned chain.
pub async fn beacon_hides_transaction(&self, hash: B256) -> Result<bool, TransportError> {
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();
Expand Down Expand Up @@ -657,6 +686,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
83 changes: 64 additions & 19 deletions crates/anvil/src/eth/backend/mem/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -2654,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?);
}

Expand Down Expand Up @@ -2707,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?);
}

Expand Down Expand Up @@ -3307,23 +3319,56 @@ impl Backend {
id: impl Into<BlockId>,
versioned_hashes: Vec<B256>,
) -> Result<Option<Vec<alloy_consensus::Blob>>> {
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<Option<Vec<Blob>>> {
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(&timestamp) {
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<Blob> {
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<Option<Blob>> {
Expand Down
Loading
Loading