Skip to content
Open
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,6 @@ _
.vite
.wrangler
*.zip

# Offline fork test artifacts
state.json
1 change: 0 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

46 changes: 46 additions & 0 deletions Dockerfile.offline-test
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
FROM rust:latest as builder

WORKDIR /foundry
COPY . .

# Install build dependencies
RUN apt-get update && apt-get install -y \
pkg-config \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*

# Build anvil
RUN cargo build --release -p anvil

FROM ubuntu:24.04

# Install iptables for network blocking
RUN apt-get update && apt-get install -y iptables iproute2 && rm -rf /var/lib/apt/lists/*

COPY --from=builder /foundry/target/release/anvil /usr/local/bin/anvil

# Script to block outbound internet but allow local connections
RUN echo '#!/bin/bash\n\
set -e\n\
\n\
# Block all outbound traffic except to localhost and private networks\n\
iptables -A OUTPUT -o lo -j ACCEPT\n\
iptables -A OUTPUT -d 127.0.0.0/8 -j ACCEPT\n\
iptables -A OUTPUT -d 172.16.0.0/12 -j ACCEPT\n\
iptables -A OUTPUT -d 192.168.0.0/16 -j ACCEPT\n\
iptables -A OUTPUT -d 10.0.0.0/8 -j ACCEPT\n\
iptables -A OUTPUT -j REJECT --reject-with icmp-net-unreachable\n\
\n\
echo "==========================================="\n\
echo "Internet access BLOCKED - Offline mode test"\n\
echo "==========================================="\n\
echo ""\n\
\n\
# Run anvil\n\
exec anvil "$@"\n\
' > /entrypoint.sh && chmod +x /entrypoint.sh

EXPOSE 8545

ENTRYPOINT ["/entrypoint.sh"]
CMD ["--help"]
78 changes: 78 additions & 0 deletions OFFLINE_TEST_GUIDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Offline Fork Testing Guide

Validate that Anvil's offline fork mode prevents all external RPC calls using Docker with network isolation.

## Prerequisites

- Docker & Docker Compose
- Saved state file (see below)

## Step 1: Create State File

```bash
# Build and run anvil with fork
cargo build --release -p anvil
./target/release/anvil \
--fork-url https://sepolia.base.org \
--optimism \
--fork-block-number 20702367 \
--fork-chain-id 84532 \
--dump-state state.json
# Press Ctrl+C when done

# Or save via RPC while running:
cast rpc anvil_dumpState > state.json
```

## Step 2: Test Offline Mode

```bash
# Build and run with blocked internet (first build takes 10-20 min)
docker-compose -f docker-compose.offline-test.yml up --build anvil-offline
```

This blocks all outbound internet using iptables. Test it works:

```bash
cast block-number --rpc-url http://localhost:8545
cast balance 0xYourAddress --rpc-url http://localhost:8545
```

## Verification Methods

**1. Invalid URL**: Change `fork-url` in docker-compose to `https://invalid.test` - if anvil still works, it's offline

**2. Check connections**:
```bash
CONTAINER_ID=$(docker ps | grep anvil-offline | awk '{print $1}')
docker exec $CONTAINER_ID ss -tunp # Should show only listening socket
```

**3. Query missing data**:
```bash
# Address NOT in state.json returns 0 instantly (no RPC call)
cast balance 0x0000000000000000000000000000000000000042 --rpc-url http://localhost:8545
```

## Customization

Edit `docker-compose.offline-test.yml` command section for your chain.

**Fund accounts** (optional):
```yaml
--fund-accounts 0xAddress1:1000 0xAddress2:5000 # Amounts in ETH
```

## Troubleshooting

- **"No such file: state.json"** - Ensure file exists in foundry directory
- **"failed to create offline provider"** - Expected in offline mode, anvil continues normally
- **First build slow (10-20 min)** - Normal, subsequent builds are cached
- **Container exits** - Check logs: `docker-compose -f docker-compose.offline-test.yml logs`

## Cleanup

```bash
docker-compose -f docker-compose.offline-test.yml down
docker rmi foundry-anvil-offline
```
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,24 @@ You can use those same `cast` subcommands against your `anvil` instance:
cast block-number
```

### Offline Mode

Anvil now supports running forks in fully offline mode. This is useful when you need to run Anvil in environments without internet access:

First, save the fork state while online:

```sh
anvil --fork-url https://eth.merkle.io --dump-state state.json
```

Later, run Anvil in offline mode using the saved state:

```sh
anvil --fork-url https://eth.merkle.io --load-state state.json --offline
```

The `--offline` flag ensures Anvil won't make any RPC calls to the fork URL, operating entirely from the loaded state.

---

Run `anvil --help` to explore the full list of available features and their usage.
Expand Down
8 changes: 8 additions & 0 deletions crates/anvil/core/src/eth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,14 @@ pub enum EthRequest {
#[serde(rename = "debug_dbGet")]
DebugDbGet(String),

/// geth's `debug_traceBlockByHash` endpoint
#[serde(rename = "debug_traceBlockByHash")]
DebugTraceBlockByHash(B256, #[serde(default)] GethDebugTracingOptions),

/// geth's `debug_traceBlockByNumber` endpoint
#[serde(rename = "debug_traceBlockByNumber")]
DebugTraceBlockByNumber(BlockNumber, #[serde(default)] GethDebugTracingOptions),

/// Trace transaction endpoint for parity's `trace_transaction`
#[serde(rename = "trace_transaction", with = "sequence")]
TraceTransaction(B256),
Expand Down
56 changes: 54 additions & 2 deletions crates/anvil/src/cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use crate::{
};
use alloy_genesis::Genesis;
use alloy_op_hardforks::OpHardfork;
use alloy_primitives::{B256, U256, utils::Unit};
use alloy_primitives::{Address, B256, U256, utils::Unit};
use alloy_signer_local::coins_bip39::{English, Mnemonic};
use anvil_server::ServerConfig;
use clap::Parser;
Expand Down Expand Up @@ -168,6 +168,21 @@ pub struct NodeArgs {
)]
pub load_state: Option<SerializableState>,

/// Run in offline mode when forking.
///
/// This prevents any RPC requests and requires a previously saved state to be loaded with
/// `--load-state`.
#[arg(long, requires = "load_state", requires = "fork_url")]
pub offline: bool,

/// Fund specific accounts with custom balances on startup.
///
/// Accepts multiple address:balance pairs where balance is in ETH.
/// Example: --fund-accounts 0x1234...5678:1000 0xabcd...ef01:5000
/// This will fund the first address with 1000 ETH and the second with 5000 ETH.
#[arg(long, value_name = "ADDRESS:AMOUNT", value_delimiter = ' ', num_args = 1..)]
pub fund_accounts: Vec<String>,

#[arg(long, help = IPC_HELP, value_name = "PATH", visible_alias = "ipcpath")]
pub ipc: Option<Option<String>>,

Expand Down Expand Up @@ -216,6 +231,9 @@ impl NodeArgs {
let compute_units_per_second =
if self.evm.no_rate_limit { Some(u64::MAX) } else { self.evm.compute_units_per_second };

// Parse funded accounts early before any moves occur
let funded_accounts = self.parse_funded_accounts()?;

let hardfork = match &self.hardfork {
Some(hf) => {
if self.evm.networks.is_optimism() {
Expand Down Expand Up @@ -283,7 +301,41 @@ impl NodeArgs {
.with_disable_pool_balance_checks(self.evm.disable_pool_balance_checks)
.with_slots_in_an_epoch(self.slots_in_an_epoch)
.with_memory_limit(self.evm.memory_limit)
.with_cache_path(self.cache_path))
.with_cache_path(self.cache_path.clone())
.with_offline(self.offline)
.with_funded_accounts(funded_accounts))
}

/// Parses the --fund-accounts argument into a HashMap of Address to balance in wei
fn parse_funded_accounts(&self) -> eyre::Result<std::collections::HashMap<Address, U256>> {
use std::collections::HashMap;

let mut accounts = HashMap::new();

for entry in &self.fund_accounts {
let parts: Vec<&str> = entry.split(':').collect();
if parts.len() != 2 {
eyre::bail!(
"Invalid fund-accounts entry '{}'. Expected format: ADDRESS:AMOUNT",
entry
);
}

let address = parts[0]
.parse::<Address>()
.map_err(|e| eyre::eyre!("Invalid address '{}': {}", parts[0], e))?;

let amount: u64 = parts[1]
.parse()
.map_err(|e| eyre::eyre!("Invalid amount '{}': {}", parts[1], e))?;

// Convert ETH to wei (1 ETH = 10^18 wei)
let balance = Unit::ETHER.wei().saturating_mul(U256::from(amount));

accounts.insert(address, balance);
}

Ok(accounts)
}

fn account_generator(&self) -> AccountGenerator {
Expand Down
Loading
Loading