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 fromRegisterwith no manual configuration. - Lane check: The
OnRamp 2.0.0assertion 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
ExtraArgsV3message with default finality (FinalityCodec.WAIT_FOR_FINALITY_FLAG) through the source router, and routes it withswitchChainAndRouteMessage(), which executes it on the destination OffRamp. Contracts created in a fork test are per-fork: make them persistent withvm.makePersistentbefore 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.