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
-
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/ -
Install the dependencies, including the Chainlink Local package:
npm install -
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:
- 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.
- Transfer with LINK fees: Same transfer, paying the fee in LINK.
- Revert on zero amount: The task rejects a zero amount before sending anything.
- 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:
-
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") -
Read the simulator's
configurationand 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
configurationcall 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. -
The example tasks open their own connection with
hre.network.connect(), which would be a fresh chain. The setup hands them an hre whoseconnect()returns the test's connection instead, and exposes the first Hardhat wallet asbroadcaster(the account every example task sends from):const taskHre = Object.create(hre, { network: { value: { connect: async () => connection } } }) as typeof hre -
Example01'sbeforeEachfunds 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:
-
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]) -
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 = 5EOA_GAS_LIMITis0because the receiver is an EOA: there is noccipReceivelogic 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_CONFIRMATIONSis5(any value> 0): this requests a Fast Transfer, delivered before full chain finality.0would mean default finality.
await run()where
run()calls the task action withfeeTokenAddress: ZERO_ADDRESSand the test'staskHre. -
Inside the task, the
ExtraArgsV3are encoded with theencodeV3Basichelper (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, }encodeV3BasicmapsblockConfirmationsto therequestedFinalityConfigwithFinalityCodec._encodeBlockDepth. Read more aboutextraArgsformats in the CCIP best practices. -
The task quotes the fee, approves the router to spend the token, and sends. With
feeTokenAddressset 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], }) -
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)
Test case 2: Transfer with LINK fees
The transfers token to an EOA paying the fee in LINK test case runs the same task with two differences:
-
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]) -
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:
-
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) -
A zero receiver is rejected the same way:
await assert.rejects(run({ receiver: ZERO_ADDRESS }), /Required args must be non-zero/)
Next steps
- Send a data-only message to a receiver contract and see the local router enforce its CCV config and minimum block depth in the receiver CCV config guide.
- For the other test files in the
./testdirectory (Example03.test.tsprogrammable token transfers,Example04.test.tssender contract,Example05.test.tsmanual execution path), see the CCIP starter kit examples map. - To learn how to use Chainlink Local in forked environments, refer to the guide on Using Chainlink Local Simulator in your Hardhat project with forked environments.