Using the Chainlink Local Simulator to fork mainnets

This guide explains how to use the Chainlink Local Simulator to test cross-chain transactions in forked mainnet environments using Foundry. You'll use the Register.sol contract to read the network details of the chains you fork, and setNetworkDetails to add or override details when a chain is not in Register or its values have changed.

Fork routing supports every CCIP era: pre-1.6, 1.6 and CCIP 2.0 (CCV-based) lanes. If the mainnet lane you fork runs CCIP 2.0 through a dedicated router, read it with getCCIPV2RouterAddress or set it with setCCIPV2RouterAddress, and send through that router. Messages sent through either router are routed by switchChainAndRouteMessage.

Understanding Network Registration

The Register.sol smart contract contains pre-configured details for 134 chains, mainnets included, to accelerate cross-chain transaction simulations in Foundry. getNetworkDetails returns an all-zero struct for a chain ID that is not in Register; use setNetworkDetails to add it.

Here's an example of how to verify network details for a test network such as Sepolia:

pragma solidity ^0.8.0;

import { Test, console } from "forge-std/Test.sol";
import {
    CCIPLocalSimulatorFork,
    Register
} from "@chainlink/local/src/ccip/CCIPLocalSimulatorFork.sol"

contract ExampleTest is Test {
    CCIPLocalSimulatorFork public ccipLocalSimulatorFork;
    uint256 public ethSepoliaFork;
    Register.NetworkDetails public ethSepoliaNetworkDetails;

    function setUp() public {
        // Create a fork of the Sepolia network
        string memory SEPOLIA_RPC_URL = vm.envString("SEPOLIA_RPC_URL");
        ethSepoliaFork = vm.createFork(SEPOLIA_RPC_URL);

        // Initialize the CCIP simulator
        ccipLocalSimulatorFork = new CCIPLocalSimulatorFork();
        vm.makePersistent(address(ccipLocalSimulatorFork));

        // Select and verify the fork
        vm.selectFork(ethSepoliaFork);
        assertEq(block.chainid, 11155111);

        // Verify network details match the CCIP Directory
        ethSepoliaNetworkDetails = ccipLocalSimulatorFork.getNetworkDetails(block.chainid);
        assertEq(ethSepoliaNetworkDetails.chainSelector, 16015286601757825753);
        assertEq(ethSepoliaNetworkDetails.routerAddress, 0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59);
    }
}

Working with Mainnet Forks

The Chainlink Local Simulator supports any forked environment where CCIP contracts exist. Mainnet details are in Register, so forking a mainnet lane needs no extra configuration.

Chainlink Local runs this exact flow as an e2e test: CCIPv2MainnetEthereumArbitrumFork.t.sol sends a data-only message across the Ethereum → Arbitrum One mainnet lane, a CCIP 2.0 lane, and needs only ETHEREUM_MAINNET_RPC_URL and ARBITRUM_MAINNET_RPC_URL. The core of the test:

function setUp() public {
    s_ethereumFork = vm.createSelectFork(vm.envString("ETHEREUM_MAINNET_RPC_URL"));
    s_arbitrumFork = vm.createFork(vm.envString("ARBITRUM_MAINNET_RPC_URL"));

    s_forkSimulator = new CCIPLocalSimulatorFork();
    vm.makePersistent(address(s_forkSimulator));
    s_encoder = new EncodeExtraArgsOffchain();
    vm.makePersistent(address(s_encoder));

    // Read the network details of both chains from Register
    vm.selectFork(s_arbitrumFork);
    s_arbitrum = s_forkSimulator.getNetworkDetails(block.chainid);
    vm.selectFork(s_ethereumFork);
    s_ethereum = s_forkSimulator.getNetworkDetails(block.chainid);

    // Fail fast if the lane is not CCIP 2.0 at the forked block
    address onRamp = IRouterOnRamp(s_ethereum.routerAddress).getOnRamp(s_arbitrum.chainSelector);
    assertEq(ITypeAndVersion(onRamp).typeAndVersion(), "OnRamp 2.0.0", "Ethereum -> Arbitrum is not CCIP 2.0");

    s_alice = makeAddr("alice");
    vm.deal(s_alice, 10 ether);
}

function test_mainnet_message_ethereumToArbitrum_fork() external {
    // Deploy a receiver on the destination chain
    vm.selectFork(s_arbitrumFork);
    BasicMessageReceiver receiver = new BasicMessageReceiver(s_arbitrum.routerAddress);

    // Send an ExtraArgsV3 message with default finality through the Ethereum router, paying in native
    bytes memory payload = bytes("Hello mainnet CCIP 2.0");
    bytes32 messageId = _send(
        address(receiver),
        payload,
        new Client.EVMTokenAmount[](0),
        s_encoder.encodeV3Basic(200_000, FinalityCodec.WAIT_FOR_FINALITY_FLAG)
    );

    // Route the message to the Arbitrum OffRamp
    s_forkSimulator.switchChainAndRouteMessage(s_arbitrumFork);

    // Verify the message arrived
    vm.selectFork(s_arbitrumFork);
    assertEq(receiver.latestMessageId(), messageId);
    assertEq(receiver.latestSourceChainSelector(), s_ethereum.chainSelector);
    assertEq(receiver.latestSender(), s_alice);
    assertEq(receiver.latestMessage(), payload);
}

The _send helper builds the Client.EVM2AnyMessage with the given extraArgs, quotes the fee, and calls ccipSend as alice paying in native. The test's receiver and encoder are chainlink-local test contracts (src/test/ccip/, not shipped in the npm package); for your own project, the CCIP Foundry Starter Kit ships a receiver (src/BasicMessageReceiver.sol) and an extraArgs encoder (script/EncodeExtraArgsOffchain.s.sol).

Code explanation:

  • Network Setup: The setUp() function creates forks of both Ethereum and Arbitrum One mainnets, initializes the CCIP simulator, and reads both chains' details from Register with no manual configuration.
  • Lane check: The OnRamp 2.0.0 assertion fails the test early if the lane is not CCIP 2.0 at the forked block, for example after a chain upgrade.
  • Message Transfer: The test deploys the receiver on the destination fork, sends an ExtraArgsV3 message with default finality (FinalityCodec.WAIT_FOR_FINALITY_FLAG) through the source router, and routes it with switchChainAndRouteMessage(), which executes it on the destination OffRamp. Contracts created in a fork test are per-fork: make them persistent with vm.makePersistent before using them on another fork.

Add or override network details with setNetworkDetails

Use setNetworkDetails() when a chain is not in Register (it returns an all-zero struct) or when a value has changed from the built-in one, for example after a router upgrade. The details are keyed by the chain ID, not the fork ID:

ccipLocalSimulatorFork.setNetworkDetails(
  137, // Polygon mainnet chain ID
  Register.NetworkDetails({
    chainSelector: 4051577828743386545,
    routerAddress: 0x849c5ED5a80F5B408Dd4969b78c2C8fdf0565Bfe,
    linkAddress: 0xb0897686c545045aFc77CF20eC7A532E3120E0F1,
    wrappedNativeAddress: 0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270,
    ccipBnMAddress: address(0), // not needed for this test
    ccipLnMAddress: address(0), // not needed for this test
    rmnProxyAddress: 0xf1ceAa46D8d13Cac9fC38aaEF3d3d14754C5A9c2,
    registryModuleOwnerCustomAddress: 0xc751E86208F0F8aF2d5CD0e29716cA7AD98B5eF5,
    tokenAdminRegistryAddress: 0x00F027eA6D0fb03256A15E9182B2B9227A4931d8
  })
);

The values above are the built-in Register entry for Polygon mainnet, shown as an example of the full struct. Check the CCIP Directory for the current values of the chain you are configuring. Parameters not needed for messaging can be set to address(0).

After this configuration, you can simulate cross-chain messages between any forks, enabling thorough testing of your cross-chain applications in a local environment.

Get the latest Chainlink content straight to your inbox.