Using Chainlink Local Simulator in Your Hardhat Project - Forked Environments

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

The helper routes messages 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 through the permissionless execute entrypoint; CCV attestations are simulated with a synthetic no-op verifier.

Prerequisites

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

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

  1. Node.js 22 and the @nomicfoundation/hardhat-ethers plugin, which the JavaScript fork helper uses (connection.ethers). The kit already registers the plugin in hardhat.config.ts.

  2. Archive-capable RPC URLs for both chains. A free-tier Alchemy or Infura endpoint works; public non-archive endpoints fail with "historical state is not available". Provide them as environment variables or in the Hardhat keystore:

    npx hardhat keystore set ETHEREUM_SEPOLIA_RPC_URL
    npx hardhat keystore set POLYGON_AMOY_RPC_URL
    
  3. The production build profile: the token pools exceed the 24 KB contract size limit without the optimizer. The kit's test:fork script sets it.

The fork networks are defined in hardhat.config.ts with pinned blocks, so a fresh clone runs the same chain state:

sepoliaFork: {
  type: "edr-simulated",
  chainType: "generic",
  chainId: 11155111,
  forking: {
    url: configVariable("ETHEREUM_SEPOLIA_RPC_URL"),
    blockNumber: Number(process.env.ETHEREUM_SEPOLIA_FORK_BLOCK ?? 11_920_292),
  },
},

Override the pins for a run with ETHEREUM_SEPOLIA_FORK_BLOCK / POLYGON_AMOY_FORK_BLOCK, for example to check whether the live lane has drifted.

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.test.ts is located in the ./test-fork directory. It deploys a custom token and pool on both forks through the kit's Ignition module, 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 and registers them on both chains.
  2. Configure lane: Sets the remote token and pool.
  3. Send with default finality: Burns on the source and mints on the destination.
  4. Send with Faster Than Finality: Reverts until the pool opts in, then delivers after both pools opt in.

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

npm run test:fork

Example output:

$ npm run test:fork

Example06 (fork)
  โœ” deploy creates the token and pool and registers them (1962ms)
  โœ” configureLane sets the remote token and pool (1200ms)
  โœ” configureLane rerun replaces the existing config (1167ms)
  โœ” send at default finality burns on the source and mints on the destination (1338ms)
  โœ” send with Faster Than Finality reverts until the pool opts in (1328ms)
  โœ” send with Faster Than Finality delivers after both pools opt in (1441ms)
  โœ” check-finality-gates finds the registered pool (1105ms)

...

27 passing (36481ms)

Examine the code

Setup

The shared setup lives in test-fork/utils/cctFork.ts. To route messages across forks, the test needs:

  • A fork connection for the source chain (Ethereum Sepolia) and the destination chain (Polygon Amoy)
  • The chain details (routers, RMN proxy, registries) for both chains
  • A sender account (the broadcaster, Hardhat wallet 0) and a receiver account

The setupForks() function:

  1. Connects to both fork networks and wraps each connection in a Fork object with the chain details, the public client, the broadcaster, and a taskHre whose connect() returns that fork (for running the example tasks against it):

    const sepolia = await connectFork("sepoliaFork", SEPOLIA)
    const amoy = await connectFork("amoyFork", AMOY)
    

    The chain details (routers, RMN proxy, registries) are copied from @chainlink/local's RegisterData.sol:

    export const SEPOLIA = {
      chainSelector: 16015286601757825753n,
      routerAddress: "0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59",
      rmnProxyAddress: "0xba3f6251de62dED61Ff98590cB2fDf6871FbB991",
      registryModuleOwnerCustomAddress: "0xa3c796d480638d7476792230da1E2ADa86e031b0",
      tokenAdminRegistryAddress: "0x95F29FEE11c5C55d26cCcf1DB6772DE953B37B82",
    } as const
    
  2. Verifies 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:

    const onRamp = await sepolia.publicClient.readContract({
      address: SEPOLIA.routerAddress,
      abi: routerOnRampAbi,
      functionName: "getOnRamp",
      args: [AMOY.chainSelector],
    })
    assert.notEqual(onRamp, ZERO_ADDRESS, "Sepolia router has no OnRamp for Amoy at the pinned block")
    assert.equal(
      await sepolia.publicClient.readContract({
        address: onRamp,
        abi: typeAndVersionAbi,
        functionName: "typeAndVersion",
      }),
      "OnRamp 2.0.0",
      "Sepolia -> Amoy is not a CCIP 2.0 lane"
    )
    
  3. Deploys the kit's Ignition module on each fork with the per-chain parameters (router, RMN proxy, registries):

    return (await fork.connection.ignition.deploy(module, {
      parameters: {
        $global: {
          routerAddress: fork.details.routerAddress,
          armProxy: fork.details.rmnProxyAddress,
          registryModuleOwnerCustom: fork.details.registryModuleOwnerCustomAddress,
          tokenAdminRegistry: fork.details.tokenAdminRegistryAddress,
          thresholdAmountForAdditionalCCVs: THRESHOLD,
        },
      },
    })) as Record<string, { address: Address }>
    

Send and route the message

The sendAndRoute() helper shows the two-step flow used by every fork test:

  1. On the source fork, run the book's send task. It builds an ExtraArgsV3 message and calls ccipSend through the Sepolia router:

    await quietly(() =>
      sendAction(
        {
          sourceRouter: SEPOLIA.routerAddress,
          destinationChainSelector: AMOY.chainSelector,
          receiver: forks.receiver,
          tokenToSend: token,
          amount,
          gasLimit: EOA_RECEIVER_GAS_LIMIT,
          blockConfirmations,
          feeTokenAddress: ZERO_ADDRESS,
        },
        forks.sepolia.taskHre
      )
    )
    
  2. Fetch the ccipSend transaction receipt and extract the CCIP message with getCCIPMessages, then route it on the destination connection with routeMessage:

    const receipt = await sepolia.connection.ethers.provider.getTransactionReceipt(sendTx.hash)
    const [sent] = getCCIPMessages(sepolia.connection, receipt)
    return routeMessage(amoy.connection, AMOY.routerAddress, sent)
    

    getCCIPMessages parses the send event for every CCIP era and returns a CCIPSentMessage (for CCIP 2.0, the raw encodedMessage and the receipts). routeMessage finds the destination OffRamp that serves the lane, points owner-configurable CCV resolvers at a synthetic no-op verifier, and executes the message through the OffRamp's permissionless execute. It returns { offRamp, executed, reason } and throws if no OffRamp serves the lane or the execution fails (receiver revert, finality or CCV rejection).

    When the destination chain has a dedicated CCIP 2.0 router, pass both routers so both routers' OffRamps are searched. For example, when the destination is Ethereum Sepolia, which has a dedicated CCIP 2.0 router:

    routeMessage(sepolia.connection, [SEPOLIA.routerAddress, CCIP_V2_ROUTERS[11155111]], sent)
    

Faster Than Finality across the lane

The send with Faster Than Finality delivers after both pools opt in 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.

  2. Opt both pools in by setting their allowed finality config (through the kit's Example06-SetPoolFinality task), then find the smallest depth the live lane accepts by quoting getFee for candidate depths:

    export async function findLaneFtfDepth(forks: Forks, token: Address): Promise<number> {
      for (const depth of [1, 2, 3, 5, 10, 20, 50, 100]) {
        /* quote getFee with blockConfirmations: depth; return the first depth that does not revert */
      }
    }
    
  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 helper does not model

Not modelled by the helper (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 a manual execution does not charge the executor fee a real one 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.test.ts), AdvancedPoolHooks (Example08-Example10). For pool-required CCVs on AdvancedPoolHooks in Foundry fork tests, read the pool-based CCVs tutorial.
  • For the full CCT enabling procedure with the kit's tasks and Ignition modules, read the CCT burn and mint tutorial (the flow is the same in Hardhat; the kit's book chapters list the Hardhat task commands).
  • For an index of every kit example and where its topic is documented, read the CCIP starter kit examples map.

Get the latest Chainlink content straight to your inbox.