Using the Chainlink Local Simulator in your Foundry project with forked environments

You can use Chainlink Local to run CCIP 2.0 in forked environments within your Foundry project. To get started quickly, you will use the CCIP Foundry Starter Kit. The kit's test/fork directory contains fork tests for the CCT examples (Example06-Example10), which run the real example scripts against pinned forks of Ethereum Sepolia and Polygon Amoy (a live CCIP 2.0 lane) and route each message to the destination OffRamp with CCIPLocalSimulatorFork.

CCIPLocalSimulatorFork routes messages on forked networks for every CCIP era: pre-1.6 (EVM2EVMOnRamp), 1.6 and CCIP 2.0 (CCV-based lanes, which live testnet lanes run today). On CCIP 2.0 lanes the destination OffRamp selects the CCVs and executes the message (V2VerificationMode.OFFRAMP_DERIVED, the default); CCV attestations are simulated with a synthetic no-op verifier.

Prerequisites

This guide assumes that you are familiar with the guide Using the Chainlink Local Simulator in your Foundry project. If not, please get familiar with it and run all the prerequisites.

Fork tests have two requirements beyond the no-fork setup:

  1. Foundry >= 1.5.1.

  2. evm_version = "cancun" or later for the fork tests. Deployed CCIP 2.0 contracts use Cancun opcodes; compiling with paris fails with EvmError: NotActivated. Use a fork-only profile so your deployed bytecode is not affected. The kit's foundry.toml does exactly this:

    [profile.fork]
    optimizer = true
    optimizer_runs = 200
    no_match_path = 'test/none/**'
    match_path = 'test/fork/**'
    

    evm_version = 'cancun' is set at the top level of the kit's foundry.toml.

Set up an .env file (or export the variables) with the fork RPC URLs. The kit needs archive-capable endpoints for both chains:

  • ETHEREUM_SEPOLIA_RPC_URL: The RPC URL for the Ethereum Sepolia testnet. You can sign up for a personal endpoint from Alchemy, Infura, or another node provider service.
  • POLYGON_AMOY_RPC_URL: The RPC URL for the Polygon Amoy testnet, from the same providers.

Test a CCT transfer across a CCIP 2.0 lane

You will run the fork test for Example 06 (CCT 01: CrossChainToken + BurnMint pool). The test file Example06.fork.t.sol is located in the ./test/fork directory. It deploys a custom token and pool on both forks, registers them in the live TokenAdminRegistry, links the pools, and sends token transfers across the Sepolia → Amoy CCIP 2.0 lane. The file contains seven test cases, including:

  1. Deploy: Creates the token and pool on both chains and registers them.
  2. Configure lane: Sets the remote token and pool on each pool.
  3. Send with default finality: Burns on the source and mints on the destination.
  4. Send with Faster Than Finality: Reverts until both pools opt in, then delivers after the pools opt in.

In your terminal, run the following command to execute the test:

FOUNDRY_PROFILE=fork forge test --match-contract Example06ForkTest -j 1

Example output:

$ FOUNDRY_PROFILE=fork forge test --match-contract Example06ForkTest -j 1
Ran 7 tests for test/fork/Example06.fork.t.sol:Example06ForkTest
[PASS] test_checkFinalityGates_findsRegisteredPool() (gas: 3066525)
[PASS] test_configureLane_rerunReplacesExistingConfig() (gas: 942262)
[PASS] test_configureLane_setsRemoteTokenAndPool() (gas: 747587)
[PASS] test_deploy_createsTokenAndPoolAndRegistersThem() (gas: 240027)
[PASS] test_send_defaultFinalityBurnsOnSourceAndMintsOnDestination() (gas: 2510920)
[PASS] test_send_fasterThanFinalityDeliversAfterPoolsOptIn() (gas: 3481610)
[PASS] test_send_fasterThanFinalityRevertsUntilPoolOptsIn() (gas: 1444822)
Suite result: ok. 7 passed; 0 failed; 0 skipped; finished in 2.42s (15.51ms CPU time)

Ran 1 test suite in 2.42s (2.42s CPU time): 7 tests passed, 0 failed, 0 skipped (7 total tests)

Examine the code

Setup

The shared base contract test/fork/utils/CCTForkTest.sol sets up both forks and the simulator. To route messages across forks, the test needs:

  • A fork of the source chain (Ethereum Sepolia) and the destination chain (Polygon Amoy)
  • The network details (router, registry, RMN proxy addresses) for both chains
  • The fork simulator, made persistent so it can route across forks
  • A sender account (the broadcaster) and a receiver account

The setUp() function is invoked before each test case:

  1. Create the forks. Both are pinned to a committed block (DEFAULT_SEPOLIA_FORK_BLOCK, DEFAULT_AMOY_FORK_BLOCK) so a fresh clone runs the same chain state. Override the pins per run with the ETHEREUM_SEPOLIA_FORK_BLOCK / POLYGON_AMOY_FORK_BLOCK environment variables:

    sepoliaFork =
        vm.createSelectFork("ethereumSepolia", vm.envOr("ETHEREUM_SEPOLIA_FORK_BLOCK", DEFAULT_SEPOLIA_FORK_BLOCK));
    amoyFork = vm.createFork("polygonAmoy", vm.envOr("POLYGON_AMOY_FORK_BLOCK", DEFAULT_AMOY_FORK_BLOCK));
    
  2. Initialize the fork Chainlink Local simulator and make it persistent. The simulator must be created on a fork and be persistent so it can route across forks:

    simulator = new CCIPLocalSimulatorFork();
    vm.makePersistent(address(simulator));
    
  3. Read the network details for both chains with getNetworkDetails. Register.NetworkDetails stores the chain selector, router address, LINK address, wrapped native address, CCIP test tokens, RMN proxy, registry module and token admin registry addresses:

    sepolia = simulator.getNetworkDetails(block.chainid);
    vm.selectFork(amoyFork);
    amoy = simulator.getNetworkDetails(block.chainid);
    
  4. Fund the broadcaster on both forks and verify the lane is the CCIP 2.0 lane the test expects. The test fails with a clear message if the pinned block or the lane is not what it describes:

    vm.selectFork(sepoliaFork);
    vm.deal(broadcaster, 100 ether);
    vm.selectFork(amoyFork);
    vm.deal(broadcaster, 100 ether);
    vm.selectFork(sepoliaFork);
    
    address onRamp = IRouterOnRampView(sepolia.routerAddress).getOnRamp(amoy.chainSelector);
    assertTrue(onRamp != address(0), "Sepolia router has no OnRamp for Amoy at the pinned block");
    assertEq(ITypeAndVersionView(onRamp).typeAndVersion(), "OnRamp 2.0.0", "Sepolia -> Amoy is not a CCIP 2.0 lane");
    
  5. Contracts or scripts created in a fork test are per-fork: make them persistent before using them on another fork:

    sendDefaultFinality = SendCCTTokenWithExtraArgsV3DefaultFinality(
        _persistent(address(new SendCCTTokenWithExtraArgsV3DefaultFinality()))
    );
    

Send and route the message

The _sendDefaultFinalityAndRoute helper shows the two-step flow used by every fork test:

  1. On the source fork, run the send script. It builds an ExtraArgsV3 message with blockConfirmations = 0 (default finality) and calls ccipSend through the Sepolia router:

    vm.selectFork(sepoliaFork);
    sendDefaultFinality.run(
        sepolia.routerAddress, amoy.chainSelector, receiver, token, amount, EOA_RECEIVER_GAS_LIMIT, address(0)
    );
    
  2. Call switchChainAndRouteMessage to switch to the destination fork and route the message. The simulator reads the recorded logs, finds the CCIPMessageSent event, and executes the message on the Amoy OffRamp. The active fork is Amoy when the call returns:

    simulator.switchChainAndRouteMessage(amoyFork);
    

Routing is strict by default: switchChainAndRouteMessage reverts with CCIPLocalSimulatorFork__MessageNotRouted(messageId, reason) when a captured message cannot be routed to any of the given forks, and with CCIPLocalSimulatorFork__MessageExecutionFailed(messageId, reason) (the decoded revert data) when it does not execute successfully. Call setStrictRouting(false) to record failures instead and read them with getMessageStatus.

The Example 07 fork test (test_send_failsToExecuteWhenDestinationLockBoxIsEmpty) shows the strict-routing failure path: it sends a Lock & Release transfer without funding the destination lock box, so the OffRamp cannot release the tokens, and expects the routing call to revert:

vm.expectPartialRevert(CCIPLocalSimulatorFork.CCIPLocalSimulatorFork__MessageExecutionFailed.selector);
simulator.switchChainAndRouteMessage(amoyFork);

Faster Than Finality across the lane

The test_send_fasterThanFinalityDeliversAfterPoolsOptIn test case shows the CCIP 2.0 finality gates on a real lane:

  1. New pools allow full finality only, so any block-depth request is rejected by the pool. The test first asserts the pool's default and expects the send to revert:

    assertEq(TokenPool(sepoliaPool).getAllowedFinalityConfig(), FinalityCodec.WAIT_FOR_FINALITY_FLAG);
    vm.expectPartialRevert(FinalityCodec.InvalidRequestedFinality.selector);
    
  2. Opt both pools in with setAllowedFinalityConfig (through the kit's Example06SetPoolAllowedFinalityConfig script), then find the smallest depth the live lane accepts by quoting getFee for candidate depths:

    vm.selectFork(sepoliaFork);
    _setPoolMinBlockDepth(sepoliaPool, 1);
    uint16 depth = _findLaneFtfDepth(sepoliaToken);
    _setPoolMinBlockDepth(sepoliaPool, depth);
    vm.selectFork(amoyFork);
    _setPoolMinBlockDepth(amoyPool, depth);
    
  3. Send with the custom finality (blockConfirmations = depth) and route. The transfer burns on the source and mints on the destination, same as the default-finality case, but delivered before full finality.

What the fork simulator does not model

Not modelled by the simulator (and not asserted in the kit's fork tests): real CCV attestations, RMN and executor fees. CCVs on the lane are replaced by a synthetic verifier, and executePendingV2Message executes without charging the executor fee a real manual execution costs. Pinned state never sees testnet upgrades, so green tests only prove the examples against that snapshot; re-pin the fork blocks when the lane's behavior changes or on every @chainlink/contracts-ccip or @chainlink/local bump.

Next steps

Get the latest Chainlink content straight to your inbox.