back

Rick ·

How do I fork Ethereum mainnet locally with Anvil?

Dev tooling

One command:

anvil --fork-url https://eth.drpc.org --fork-block-number 25980000

That gives you a local chain that behaves like Ethereum mainnet at block 25,980,000, with ten funded accounts and the ability to send transactions as anyone. Here is what it printed:

Fork
==================
Endpoint:       https://eth.drpc.org/
Block number:   25980000
Block hash:     0xa5512437f1412ce1ec3af627fcca48b494029916d03bd9165a8a9ac6f5f4cffe
Chain ID:       1

Base Fee
==================

58502623

Gas Limit
==================

60000000

Ready in 647 milliseconds. Real USDC is right there:

$ cast call 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 balanceOf(address)(uint256) 0x28C6c06298d514Db089934071355E5743bf21d60 --rpc-url http://127.0.0.1:8611
51951297920 [5.195e10]

That is 51,951.29792 USDC held by an address nobody here has the key for, read out of mainnet state by a node running on a laptop.

The command is the easy part. Everything that makes a fork worth using — that it gives the same answer tomorrow, that your provider can actually serve it, that you can act as an account you have no key for — sits behind four decisions, and three of them have failure modes that are quiet rather than loud. I measured all of them against one pinned fork. The harness, raw results and commands are in this repository under examples/articles/how-do-i-fork-ethereum-mainnet-locally-with-anvil.

Unless noted, everything below was measured on 2026-09-15 with Foundry 1.8.3 (published the same day) on an Apple M3 Pro, against mainnet block 25,980,000, whose timestamp is 1789440827. Commands are shown exactly as they ran, which is why they say port 8611: the harness never binds Anvil's default 8545, because that is usually already taken.

What a fork actually is

Anvil is the local node that ships with Foundry, and it is one of the reasons Foundry took over Solidity tooling in the first place. It does not download mainnet. It starts an empty EVM that fetches state on demand: when something reads an account or a storage slot it does not have, it asks your RPC provider for that one value at the fork block, keeps it, and carries on.

You can see the whole shape of it by pointing Anvil at a logging proxy instead of at the real endpoint and recording every request it sends upstream.

Where a pinned Anvil fork gets each answer. A three-layer diagram of how Anvil answers a call against a mainnet fork pinned at block 25,980,000. A call arrives at the local node on 127.0.0.1. Layer 1, the local overlay, answers from blocks you mined, writes made while impersonating, and anvil_setBalance or anvil_setStorageAt; it costs no network request. Layer 2, the fork cache on disk, answers account and storage reads that were fetched before; Foundry only writes it for a pinned block and keys the file by endpoint, so changing provider starts from empty. Layer 3, the upstream archive endpoint, answers everything left with eth_getBalance, eth_getCode, eth_getStorageAt and eth_getTransactionCount at the fork block hash, not at a block number, which is what most public endpoints cannot do. Measured on one scripted workload: 101 requests reach the provider with a cold cache and 10 with a warm one. With the cache warm and the provider allowed to answer only block headers, the whole run needed two eth_getBlockByNumber calls and one eth_gasPrice and nothing else; with the provider refusing everything, the node exits at start-up with failed to get fork block.

The workload I traced is small: read the whale's USDC balance, impersonate it, move 1,000 USDC, read both balances back. On a cold cache that cost a median of 46 requests before the node even answered, and 55 more during the workload.

The start-up cost is almost entirely the dev accounts. Anvil hydrates each of its ten default accounts from the fork with four calls — eth_getAccountInfo, eth_getBalance, eth_getCode, eth_getTransactionCount — so 40 of those 46 requests exist to populate accounts most tests never use. Starting with --accounts 1 took 10 requests instead of 46.

Only eleven distinct methods ever left the node: eth_chainId, eth_blockNumber, eth_gasPrice, eth_getBlockByNumber, eth_getBalance, eth_getCode, eth_getStorageAt, eth_getTransactionCount, eth_getAccountInfo, eth_getTransactionReceipt and anvil_nodeInfo. That short list is the real compatibility surface, and two entries on it are the reason forks fail against public providers.

Pin the block, or the test has a date on it

Without --fork-block-number, Anvil forks whatever the head is when it starts. Three consecutive runs of the same script forked blocks 25,983,311, 25,983,315 and 25,983,316 — three different chains, minutes apart.

Foundry's own fork-testing guide puts this plainly under best practices: "Always use --fork-block-number for reproducible tests." It is worth seeing what the alternative looks like, because the failure is not always loud. Here is the same assertion written twice, once against a pinned fork and once without:

contract ForkTest is Test {
    uint256 constant PIN = 25_980_000;
    IERC20 constant USDC = IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48);
    address constant WHALE = 0x28C6c06298d514Db089934071355E5743bf21d60;
    address constant RECIPIENT = address(0xBEEF);

    /// USDC held by WHALE in block PIN, in units of 1e-6 USDC.
    uint256 constant WHALE_USDC_AT_PIN = 51_951_297_920;

    function setUp() public {
        vm.createSelectFork(vm.rpcUrl("mainnet"), PIN);
    }

    function test_forkIsPinnedToOneBlock() public view {
        assertEq(block.number, PIN);
        assertEq(block.chainid, 1);
        assertEq(USDC.decimals(), 6);
    }

    /// The whole point of a fork: move a token nobody here has the key for.
    function test_prankMovesRealUsdc() public {
        assertEq(USDC.balanceOf(WHALE), WHALE_USDC_AT_PIN);

        vm.prank(WHALE);
        USDC.transfer(RECIPIENT, 1_000e6);

        assertEq(USDC.balanceOf(RECIPIENT), 1_000e6);
        assertEq(USDC.balanceOf(WHALE), WHALE_USDC_AT_PIN - 1_000e6);
    }

The unpinned version of that suite calls vm.createSelectFork(vm.rpcUrl("mainnet")) with no block, and its block-number assertion fails the way you would expect:

[FAIL: assertion failed: 25983427 != 25980000] test_theBlockNumberWithoutAPin() (block: 25983427) (gas: 3089)

The balance assertion is the interesting one. Run unpinned, it passed — because that account had not moved USDC in the intervening hours. A test like that does not fail when you break something. It fails on an unrelated Tuesday, when that account moves tokens for reasons of its own, and the person who has to explain it is whoever touched the repository last.

Pinning also decides whether the cache is worth anything. Foundry caches fork data under ~/.foundry/cache/rpc/<chain>/<block>/, keyed by block, so an unpinned run writes a directory for a block number it will never ask about again. My three unpinned runs left three separate cache directories behind and reused none of them. The pinned configuration went from 101 requests to 10 on the second run.

One detail the documentation does not mention: the file inside that directory is named storage-<hash>.json, where the hash is derived from the endpoint URL. Point the same pinned fork at a different provider and you start from an empty cache. (The configuration reference also says rpc_storage_caching.endpoints defaults to remote, meaning "only remote endpoints are cached", and offers ["localhost"] as an example of a pattern you would add. A fork pointed at http://127.0.0.1:8613 was cached anyway.)

Repeatability is not free: the clock leaks in

Pinning the block makes the state deterministic. It does not, on its own, make the chain deterministic.

I ran the same scripted sequence on a fresh pinned fork three times and compared every field: fork block, block hash, base fee, the whale's USDC balance and nonce, the transaction's gas, the mined block's hash, timestamp and base fee. All three runs were identical.

Then I measured where the mined block's timestamp comes from. One node, blocks mined at known wall-clock offsets from start-up:

Mined at Block timestamp
0.9 s after start fork timestamp + 0 s
3.4 s after start fork timestamp + 3 s
3.9 s after start fork timestamp + 3 s
6.9 s after start fork timestamp + 6 s
12.9 s after start fork timestamp + 12 s

The timestamp is the fork block's timestamp plus the whole seconds of wall clock since the node started. So "identical across three runs" was true because all three runs reached the transaction inside the same second. A slower network, a cold compile, a loaded CI worker, and the timestamp moves — which moves the block hash, and anything downstream of block.timestamp.

The fix is one call before the block you care about:

$ cast rpc evm_setNextBlockTimestamp 1789440900 --rpc-url http://127.0.0.1:8611
null

$ cast rpc evm_mine --rpc-url http://127.0.0.1:8611
"0x0"

$ cast block latest --rpc-url http://127.0.0.1:8611 --field timestamp
1789440900

Two nodes, one mining immediately and one after a 2.3-second pause, produced the same block hash 0xb404937ef4… with that call in place. Repeating the full sequence three times with the timestamp pinned gave three identical fingerprints, including the block hash.

If your fork test asserts on gas, a hash, or anything time-dependent, pin the block and the timestamp. Pinning only the block gets you a test that is reproducible on a fast machine and flaky on a slow one, which is the worst of the available outcomes.

One thing you cannot use for this: Anvil reports a state root of 0x000…0 on every block it mines, so there is no cheap whole-chain fingerprint to compare.

Your provider has to be an archive node, and most public ones are not

This is where forking actually breaks, and the failure arrives as a node that exits one second after you start it.

Two things about those upstream calls are the problem. First, Anvil reads state at the fork block's hash, not its number — eth_getBalance(0x28C6…1d60, 0xa5512437…f4cffe). Second, it needs that state at all, which means the node has to keep historical state rather than pruning it.

I took the population as given rather than choosing endpoints I liked: every HTTPS entry with no API-key placeholder in the rpc array for chain 1 of ethereum-lists/chains, plus five widely used endpoints that list does not contain. Nineteen in total. Each one got the exact calls a fork makes, three attempts each, and then the real test — anvil --fork-url <endpoint> --fork-block-number 25980000, counted as a pass only if the fork read back the same USDC balance a known-good archive node reported.

Two measured panels. Left: JSON-RPC requests sent to the fork provider for one scripted workload, median of three runs, split into start-up and workload. A pinned fork with a cold cache sends 46 start-up and 55 workload requests, 101 in total. The same fork with --no-storage-caching sends 52 and 50, 102 in total. An unpinned fork sends 48 and 50, 98 in total. A pinned fork with a warm cache sends 6 and 4, 10 in total. Right: how many of 19 public mainnet endpoints survive each step towards backing a pinned fork. 19 are listed for chain 1, 12 answer eth_chainId with 1, 5 to 6 serve all state at the pinned block depending on the pass, and 4 back a working pinned fork in both passes.

Four of nineteen. The same four in both passes, twenty minutes apart: mainnet.gateway.tenderly.co, rpc.mevblocker.io, eth.drpc.org and xrpc.cl/eth.

The ways the other fifteen failed are more useful than the count:

Endpoint What it returned
ethereum-rpc.publicnode.com Archive requests require a personal token (HTTP 403), and historical state 7865d979… is not available
rpc.flashbots.net HTTP 504 on every state call; eth_getAccountInfo → rpc method is not whitelisted
cloudflare-eth.com Internal error on every state call, Cannot fulfill request on eth_blockNumber
eth.merkle.io Cloudflare HTTP 429 on everything past eth_getBlockByNumber
rpc.ankr.com/eth Unauthorized: You must authenticate your request with an API key
eth.llamarpc.com HTTP 525 (TLS handshake failure)
rpc.blocknative.com/boost, api.securerpc.com/v1 connection failed

Several of those are not general-purpose nodes at all. The canonical chain list mixes MEV-protection transaction-submission relays in with ordinary RPC endpoints, so the first URL a newcomer copies has a decent chance of being a node that was never meant to answer eth_getStorageAt.

Two results are worth more than the tally.

rpc.mevblocker.io/fast passed every individual probe — state by hash, code, storage, nonce, a balance one million blocks back — and then failed the actual fork with Max retries exceeded HTTP error 429. Single calls do not predict a fork, because a cold fork start is a burst of about fifty requests and these hostnames are rate-limited load balancers.

Those load balancers are also heterogeneous. ethereum.publicnode.com rejected eth_getAccountInfo on all three attempts in the first pass and answered it on the third attempt in the second pass. Same hostname, different backend, different capability set per request.

Foundry shipped flags for exactly this class of problem in the release I tested. Version 1.8.3's notes add "--fork-state-by-number for RPC providers without state-by-hash support and --no-fork-node-info to skip fork identity probes", and 1.8.1 had already made the initial anvil_nodeInfo probe optional "allowing non-Anvil fork endpoints with vendor-specific rejection codes to fall back to standard RPC discovery". Neither flag exists in 1.5.1. I retried every failing endpoint with --fork-state-by-number and it rescued none of them, because none of them failed only on hash lookups — but if your provider's error message is specifically about a block hash, that flag is the thing to try.

eth_getAccountInfo deserves a note, because Anvil asks for it ten times at start-up and it is not a standard method. It does not appear anywhere in ethereum/execution-apis; it is a reth extension added in reth v1.4.1 in May 2025, and it returns balance, nonce and code in one call. Zero of the nineteen endpoints supported it in the first pass. The endpoint Foundry's own documentation uses in its examples, https://ethereum.reth.rs/rpc, does — it identifies as reth/v2.5.2 — and it backed a pinned fork fine. It did not make start-up cheaper, though: 52 requests against that node, inside the 46–55 range measured against drpc, because Anvil still issues the three individual calls alongside it.

For serious work the honest answer is a paid archive endpoint or your own node. For an article, a tutorial, or a one-off investigation, one of those four will do — and you should expect to be rate-limited. Even eth.drpc.org, which I used for every other measurement here, returned You reached Public endpoint rate limit on 73 requests during one tracing run while still producing a working fork, because Anvil retries.

Impersonation: becoming an account you have no key for

This is the capability that makes a fork worth the trouble. Everything below is from one session against the pinned fork.

Sending as an address the node has no key for fails:

$ cast send 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 transfer(address,uint256) 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 1000000000 --from 0x28C6c06298d514Db089934071355E5743bf21d60 --unlocked --rpc-url http://127.0.0.1:8611
Error: server returned an error response: error code -32602: No Signer available

One RPC call changes that:

$ cast rpc anvil_impersonateAccount 0x28C6c06298d514Db089934071355E5743bf21d60 --rpc-url http://127.0.0.1:8611
null

$ cast send 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 transfer(address,uint256) 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 1000000000 --from 0x28C6c06298d514Db089934071355E5743bf21d60 --unlocked --rpc-url http://127.0.0.1:8611

blockNumber          25980001
effectiveGasPrice    58670406
from                 0x28C6c06298d514Db089934071355E5743bf21d60
gasUsed              62248
status               1 (success)
transactionHash      0x779afbb24109756d715ba910513195bdcb0bbe6c029d196c68879363b5748187

--auto-impersonate skips the extra call and lets any from address through from start-up. anvil_stopImpersonatingAccount puts the account back; the next send returns No Signer available again.

Three limits are worth knowing before you build a workflow on this.

Impersonation does not pay for gas. With the whale's ETH balance set to zero through anvil_setBalance, the same transaction returned Insufficient funds for gas * price + value. Setting the balance back to 1 ETH made it work. An impersonated account is still an account.

Impersonation does not produce signatures. eth_sign, personal_sign and eth_signTypedData_v4 all returned the same No Signer available for an impersonated address. That matters more than it sounds: any flow built on EIP-2612 permit, EIP-712 orders, or an off-chain signature from a specific address cannot be driven by impersonating that address. You need the key, or you need to change the contract's view of who signed.

Impersonating a contract works, and it is the real unlock. USDC's masterMinter is a 7,667-byte contract at 0xe982615d…3de17 that nobody outside Circle can act as. On a fork:

Step Result
anvil_impersonateAccount 0xe982615d…3de17 null
configureMinter(0xf39F…2266, 1000000e6) sent from the master minter status 1
mint(0xf39F…2266, 1000000e6) sent from the newly configured minter status 1

Total supply went from 50,660,679,265,178,087 to 50,661,679,265,178,087 — one million real USDC, minted locally through the token's own permission system, with the token's own accounting intact.

The alternative is to skip the permission system entirely and write the balance slot:

$ cast index address 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 9
0xcb8911fb82c2d10f6cf1d31d1e521ad3f4e3f42615f6ba67c454a9a2fdb9b6a7

Handing that slot to anvil_setStorageAt (or using Forge's deal) set the balance to 123,456 USDC instantly. It also left totalSupply untouched, so the token's invariant no longer holds. That is fine for "this address needs tokens" and wrong for anything that reads supply, and it is the difference between the two approaches: impersonation exercises the contract, slot-writing bypasses it.

The ten accounts Anvil gives you are real mainnet addresses

This one surprised me, and it is specific to forking mainnet.

Anvil's dev accounts come from the published test test … junk mnemonic, and on a fresh chain they are empty. On a mainnet fork they are the same addresses as on mainnet, which means they have mainnet history. Account 0 starts at nonce 8,086; the ten nonces range from 307 to 8,086. Anvil overwrites the balance with 10,000 ETH and leaves everything else alone.

Including the code:

$ cast code 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 --rpc-url http://127.0.0.1:8611
0xef01008a67b5020ee254ef48e3b6a04927f39baf7e408a

0xef0100 is an EIP-7702 delegation indicator. All ten default accounts are delegated on mainnet, all to the same 717-byte contract at 0x8a67b502…e408a. Anyone can set a delegation on an address whose key is public, and someone has.

Sending from those accounts is unaffected — a 0.1 ETH transfer out of account 0 cost exactly 21,000 gas with no inner calls, because a delegation only executes when something calls into the account. Sending to them is a different story. I sent 0.1 ETH from account 0 to account 1 on the fork and traced it:

{"gasUsed": 30360, "creditedWei": "0",
 "innerCalls": [{"type": "CALL", "to": "0xcc04506d439d338bde8ebbb074f17a54b7673b95",
                 "value": "0x16345785d8a0000"}]}

Nothing arrived. The delegate forwarded the entire 0.1 ETH out in the same transaction, at 30,360 gas instead of 21,000. On a local fork that costs you nothing but a confusing afternoon. A 7702 delegation lives in the account's code on one chain, so this says nothing about how the same addresses behave elsewhere — which is the point: cast code on whichever network you are about to fund is the check worth running.

If you want clean accounts on a mainnet fork, use --mnemonic-random, or generate a key and fund it with anvil_setBalance.

From the command line into the test suite

Everything above works the same inside Forge, which is where most fork work actually lives. Worth saying that fork tests are the one workload my Foundry versus Hardhat benchmark explicitly did not cover, because the numbers would have measured an RPC provider rather than either framework — the caching behaviour above is exactly why. Define the endpoint once:

[rpc_endpoints]
mainnet = "${MAINNET_RPC_URL}"

Then vm.createSelectFork(vm.rpcUrl("mainnet"), PIN) in setUp(), vm.prank instead of anvil_impersonateAccount, deal instead of anvil_setStorageAt, and vm.rollFork to move the fork forward. The suite in the repository runs five fork tests in a median of 707 ms warm, 1,557 ms after forge clean and a cleared fork cache.

It is also the cheapest way to show why forking beats mocking. The same test moves 1,000 tokens twice, once through a hand-written mock ERC-20 and once through real USDC:

  mock transfer gas 51638
  USDC transfer gas 65032

That is 25.9 percent more, because the real token is a proxy that delegatecalls its implementation at 0x43506849…02dd and answers paused() and isBlacklisted(address) on the way through. A mock tells you your arithmetic is consistent. A fork tells you what the call costs.

One caution about quoting gas from a fork test at all. Those two numbers are 25,458 and 43,649 on Foundry 1.5.1. The cause is not the fork: isolate mode defaults to false on 1.5.1 and true on 1.8.x, and running 1.5.1 with --isolate reproduces 51,638 exactly. The flip is worth +26,180 gas on the mock and +21,380 on USDC, identically on both releases. A 3-gas difference between the releases survives in both settings, which I did not chase. Quote gas from a transaction receipt, or quote the runner version and the isolate setting alongside it.

Making a fork fast, and how close it gets to offline

A warm pinned fork is dramatically cheaper than a cold one: 10 requests against 101 for the same workload. --no-storage-caching gives up that saving entirely — 102 requests, indistinguishable from cold, every run.

How far does the cache get you when the provider is having a bad day? I put a stand-in upstream in front of Anvil that served block headers and refused every state call. The fork started and the whole sequence ran, three times, with identical fingerprints, on two eth_getBlockByNumber calls and one eth_gasPrice per run — and it was faster, ready in 192 to 308 ms against 452 to 569 ms with the real endpoint.

Refuse everything, though, and it dies immediately:

Error: failed to get fork block
Context:
- Max retries exceeded HTTP error 503

The fork block's header is never cached. --fork-chain-id is documented as enabling offline-start mode, and it does skip the chain-ID lookup, but the header still has to come from somewhere.

--dump-state is not the way around that either, and this one is a real trap. A dump taken from a pinned fork held ten accounts and 2,157 bytes. Loading it with --load-state and no --fork-url produced a chain at the right block where USDC.balanceOf(...) returned 0x — because the dump captures what the node touched locally, not the fork's view of mainnet, and USDC's implementation contract at 0x43506849…02dd was never in it. A state dump is a snapshot of your changes, not a portable copy of mainnet.

There is one more source of surprise traffic: reading a transaction hash the local chain does not know forwards the request upstream. My first tracing harness polled for a receipt in a loop and generated ten upstream calls per run doing it. If you are counting requests against a rate limit, poll the block number instead.

What I would actually do

For a one-off investigation: anvil --fork-url <archive endpoint> --fork-block-number <block> --accounts 1, then cast. Pin the block even for throwaway work, because it makes the second run nearly free.

For a test suite: pin the block in vm.createSelectFork, pin the timestamp with vm.warp or evm_setNextBlockTimestamp before anything you assert on, put the endpoint behind an rpc_endpoints alias and an environment variable, and commit the fork cache decision consciously — CI that starts cold pays the full ~100 requests every run.

For the provider: assume public endpoints will not work. Four of nineteen did, they are rate-limited, and the list will look different in a month. If forking is part of your workflow rather than an experiment, that is the line item to pay for.

And treat the fork as what it is. The state is real, the accounts are real, the gas is realistic, and none of it is a substitute for the parts of a live network a fork does not have — a mempool, other people's transactions, and a block builder who does not care about your test. Anvil gives you mainnet with the adversaries removed. That is exactly what you want while you are working out whether your contract talks to the real USDC correctly, and exactly what you should stop trusting the moment you are asking whether it survives contact with everyone else.

Limitations

This is one machine, one afternoon, one chain. The endpoint results are a snapshot: two passes twenty minutes apart agreed on the verdict for all nineteen endpoints, but rate limits, backends and free tiers change, and my probe sends three attempts per call, which is a gentler burst than a real workload.

Request counts are medians of three runs and move with network conditions; the cold configuration ranged from 96 to 117 total requests across its three runs. Timings come from a warm Foundry install on a fast connection.

The impersonation and delegation findings are specific to mainnet at block 25,980,000 and to the contracts I exercised. USDC's storage layout, that account's balance, and the EIP-7702 delegations on the default accounts are all facts about that block, not permanent properties.

I did not test forking an L2, multi-fork tests with vm.selectFork, WebSocket endpoints, or Hardhat 3's fork mode, which would each need their own measurement. And I did not compare providers on latency, only on whether they work at all.