Using Chainlink Local simulator in your Hardhat project

You can use Chainlink Local to run CCIP 2.0 in a localhost environment within your Hardhat project. To get started quickly, you will use the CCIP Hardhat Starter Kit. This project is a Hardhat 3 boilerplate that includes the Chainlink Local package (V3, v0.3.1) and CCIP 2.0 examples (Example01-Example10) as Hardhat tasks and Ignition modules.

In local mode, the simulator delivers messages synchronously inside ccipSend and applies the CCIP 2.0 OnRamp and OffRamp message rules: finality, one token per message, no zero amounts, and receiver CCV config validation. Read CCIP 2.0 behavior in local mode for the full list of what is and is not enforced.

Prerequisites

  1. In a terminal, clone the CCIP Hardhat Starter Kit repository and change directories:

    git clone https://github.com/smartcontractkit/ccip-starter-kit-hardhat.git && \
    cd ./ccip-starter-kit-hardhat/
    
  2. Install the dependencies, including the Chainlink Local package:

    npm install
    
  3. Compile the contracts:

    npx hardhat build
    

The kit pins solc 0.8.26 with evmVersion: "cancun" in hardhat.config.ts. CCIP 2.0 contracts need Cancun opcodes, so keep that setting in your own project. Contracts from npm packages (including the Chainlink Local simulator) are only compiled if listed in solidity.npmFilesToBuild.

Test token transfers

You will run a test that transfers tokens between two accounts using ExtraArgsV3 with Fast Transfers (non-zero block confirmations). The test file Example01.test.ts is located in the ./test directory. It calls the real example01 task action (tasks/Example01.ts) against the Chainlink Local simulator, so a regression in the task fails the test. The file contains four test cases:

  1. Transfer with native fees: Transfers CCIP-BnM from the sender to a receiver EOA, paying the fee in native gas, and verifies the receiver was credited and the router collected the fee.
  2. Transfer with LINK fees: Same transfer, paying the fee in LINK.
  3. Revert on zero amount: The task rejects a zero amount before sending anything.
  4. Revert on zero receiver: The task rejects a zero receiver.

For a detailed explanation of the test file, refer to the Examine the code section.

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

npx hardhat test nodejs

Example output:

$ npx hardhat test nodejs

Example01
  ✔ transfers token to an EOA paying the fee in native (1048ms)
  ✔ transfers token to an EOA paying the fee in LINK (166ms)
  ✔ rejects a zero amount before sending anything (105ms)
  ✔ rejects a zero receiver (96ms)

...

51 passing (3392ms)

No RPC URLs or keys are needed: the tests in test/ run against the in-process Chainlink Local simulator.

Examine the code

Setup

The shared setup lives in test/utils/localSimulator.ts. Each call to setupLocalSimulator() returns an isolated in-process chain with the simulator deployed:

  1. Connect to the in-process network and deploy the Chainlink Local simulator contract:

    const connection = await hre.network.connect()
    const { viem } = connection
    const simulator = await viem.deployContract("CCIPLocalSimulator")
    
  2. Read the simulator's configuration and connect to the router, LINK and CCIP-BnM contracts:

    const [chainSelector, routerAddress, , , linkAddress, bnmAddress] = await simulator.read.configuration()
    
    const router = await viem.getContractAt("CCIPLocalRouter", routerAddress)
    const link = await viem.getContractAt("LinkToken", linkAddress)
    const ccipBnM = await viem.getContractAt("BurnMintERC677Helper", bnmAddress)
    

    Note: The configuration call also returns the destination router, WETH9 and CCIP-LnM contracts, which these tests do not use. Hence, there are empty slots in the destructured array.

  3. The example tasks open their own connection with hre.network.connect(), which would be a fresh chain. The setup hands them an hre whose connect() returns the test's connection instead, and exposes the first Hardhat wallet as broadcaster (the account every example task sends from):

    const taskHre = Object.create(hre, { network: { value: { connect: async () => connection } } }) as typeof hre
    
  4. Example01's beforeEach funds the broadcaster with CCIP-BnM and picks the receiver:

    s = await setupLocalSimulator()
    receiver = s.other.account.address
    await s.ccipBnM.write.drip([s.broadcaster.account.address])
    

Test case 1: Transfer with native fees

The transfers token to an EOA paying the fee in native test case:

  1. Set the fee the local router charges. The local router charges only what router.write.setFee(...) sets (default 0) and uses a mock message ID:

    const fee = 10n ** 16n
    await s.router.write.setFee([fee])
    const senderBefore = await s.ccipBnM.read.balanceOf([s.broadcaster.account.address])
    
  2. Run the task with the router, chain selector, receiver, token, amount, gas limit, block confirmations and fee token. The constants used:

    const AMOUNT = 10n ** 17n
    const EOA_GAS_LIMIT = 0
    const BLOCK_CONFIRMATIONS = 5
    
    • EOA_GAS_LIMIT is 0 because the receiver is an EOA: there is no ccipReceive logic to execute. The sender is billed for the specified gas limit, not actual usage, so do not set a gas limit for token-only transfers.
    • BLOCK_CONFIRMATIONS is 5 (any value > 0): this requests a Fast Transfer, delivered before full chain finality. 0 would mean default finality.
    await run()
    

    where run() calls the task action with feeTokenAddress: ZERO_ADDRESS and the test's taskHre.

  3. Inside the task, the ExtraArgsV3 are encoded with the encodeV3Basic helper (scripts/CallEncodeExtraArgsOffchain.ts), and the Client.EVM2AnyMessage is built with the receiver, token amounts, extraArgs and fee token:

    const params: EncodeV3Params = { gasLimit, blockConfirmations }
    const extraArgs = await encodeV3Basic(params)
    
    const ccipMessage = {
      receiver: receiverEncoded,
      data: "0x" as `0x${string}`,
      tokenAmounts,
      extraArgs,
      feeToken: feeTokenAddress,
    }
    

    encodeV3Basic maps blockConfirmations to the requestedFinalityConfig with FinalityCodec._encodeBlockDepth. Read more about extraArgs formats in the CCIP best practices.

  4. The task quotes the fee, approves the router to spend the token, and sends. With feeTokenAddress set to the zero address the fee is paid in native gas:

    const fee = await publicClient.readContract({
      address: sourceRouter,
      abi: routerArtifact.abi,
      functionName: "getFee",
      args: [destinationChainSelector, ccipMessage],
    })
    
  5. The test verifies the transfer: the receiver was credited, the sender was debited, and the router collected the fee. Because local mode delivers synchronously inside ccipSend, the assertions run immediately after the task:

    assert.equal(await s.ccipBnM.read.balanceOf([receiver]), AMOUNT)
    assert.equal(await s.ccipBnM.read.balanceOf([s.broadcaster.account.address]), senderBefore - AMOUNT)
    assert.equal(await s.publicClient.getBalance({ address: s.router.address }), fee)
    

The transfers token to an EOA paying the fee in LINK test case runs the same task with two differences:

  1. Fund the broadcaster with LINK from the simulator faucet instead of relying on native gas:

    await s.simulator.write.requestLinkFromFaucet([s.broadcaster.account.address, 10n ** 18n])
    
  2. Pass the LINK token address as the fee token (run({ feeTokenAddress: s.link.address })), and verify the router collected the fee in LINK:

    assert.equal(await s.link.read.balanceOf([s.router.address]), fee)
    

Revert test cases

The remaining two test cases verify the task's argument validation, which mirrors the CCIP 2.0 send-time rules:

  1. A zero amount is rejected before anything is sent, and no tokens move:

    await assert.rejects(run({ amount: 0n }), /Required args must be non-zero/)
    assert.equal(await s.ccipBnM.read.balanceOf([receiver]), 0n)
    
  2. A zero receiver is rejected the same way:

    await assert.rejects(run({ receiver: ZERO_ADDRESS }), /Required args must be non-zero/)
    

Next steps

Get the latest Chainlink content straight to your inbox.