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:
-
Foundry >= 1.5.1.
-
evm_version = "cancun"or later for the fork tests. Deployed CCIP 2.0 contracts use Cancun opcodes; compiling withparisfails withEvmError: NotActivated. Use a fork-only profile so your deployed bytecode is not affected. The kit'sfoundry.tomldoes 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'sfoundry.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:
- Deploy: Creates the token and pool on both chains and registers them.
- Configure lane: Sets the remote token and pool on each pool.
- Send with default finality: Burns on the source and mints on the destination.
- 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:
-
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 theETHEREUM_SEPOLIA_FORK_BLOCK/POLYGON_AMOY_FORK_BLOCKenvironment 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)); -
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)); -
Read the network details for both chains with
getNetworkDetails.Register.NetworkDetailsstores 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); -
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"); -
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:
-
On the source fork, run the send script. It builds an
ExtraArgsV3message withblockConfirmations = 0(default finality) and callsccipSendthrough the Sepolia router:vm.selectFork(sepoliaFork); sendDefaultFinality.run( sepolia.routerAddress, amoy.chainSelector, receiver, token, amount, EOA_RECEIVER_GAS_LIMIT, address(0) ); -
Call
switchChainAndRouteMessageto switch to the destination fork and route the message. The simulator reads the recorded logs, finds theCCIPMessageSentevent, 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:
-
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); -
Opt both pools in with
setAllowedFinalityConfig(through the kit'sExample06SetPoolAllowedFinalityConfigscript), then find the smallest depth the live lane accepts by quotinggetFeefor candidate depths:vm.selectFork(sepoliaFork); _setPoolMinBlockDepth(sepoliaPool, 1); uint16 depth = _findLaneFtfDepth(sepoliaToken); _setPoolMinBlockDepth(sepoliaPool, depth); vm.selectFork(amoyFork); _setPoolMinBlockDepth(amoyPool, depth); -
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
- The other fork tests cover Lock & Release pools with a lock box (
Example07.fork.t.sol),AdvancedPoolHooks(Example08-Example10). For pool-required CCVs onAdvancedPoolHooks, read the pool-based CCVs tutorial. - To fork mainnets, read Using the Chainlink Local Simulator to fork mainnets.
- For the full CCT enabling procedure on the Sepolia → Amoy lane, read the CCT burn and mint tutorial and the CCT lock and release tutorial.
- For an index of every kit example and where its topic is documented, read the CCIP starter kit examples map.