Using Chainlink Local simulator in your Foundry project

You can use Chainlink Local to run CCIP 2.0 in a localhost environment within your Foundry project. To get started quickly, you will use the CCIP Foundry Starter Kit. This project is a Foundry boilerplate that includes the Chainlink Local package (V3, v0.3.1) and CCIP 2.0 examples (Example01-Example10) covering token transfers, Fast Transfers, programmable token transfers, and Cross-Chain Token (CCT) setup.

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 Foundry Starter Kit repository and change directories. Clone without --recursive and initialize the submodules one level deep:

    git clone https://github.com/smartcontractkit/ccip-starter-kit-foundry.git && \
    cd ./ccip-starter-kit-foundry/ && \
    git submodule update --init
    
  2. Run forge build to compile the contracts:

    forge build
    

The kit pins solc 0.8.26 with evm_version = cancun in foundry.toml. CCIP 2.0 contracts need Cancun opcodes, so keep that setting in your own project.

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.t.sol is located in the ./test directory. It runs the real Example01 script (script/examples/Example01.s.sol) against the Chainlink Local simulator, so a regression in the script 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 block confirmations: blockConfirmations == 0 means default finality; the script requires > 0 for Faster Than Finality.
  4. Revert on zero amount: The CCIP 2.0 rules reject zero-amount token transfers.

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:

forge test --match-contract Example01Test

Example output:

$ forge test --match-contract Example01Test
[⠊] Compiling...
No files changed, compilation skipped

Ran 4 tests for test/Example01.t.sol:Example01Test
[PASS] test_revertsWhenAmountIsZero() (gas: 41283)
[PASS] test_revertsWhenBlockConfirmationsIsZero() (gas: 41309)
[PASS] test_transfersTokenToEoaPayingLinkFee() (gas: 1430441)
[PASS] test_transfersTokenToEoaPayingNativeFee() (gas: 1350710)
Suite result: ok. 4 passed; 0 failed; 0 skipped; finished in 2.54ms (1.81ms CPU time)

Ran 1 test suite in 6.09ms (2.54ms CPU time): 4 tests passed, 0 failed, 0 skipped (4 total tests)

No RPC URLs are needed: forge test runs only the no-fork Chainlink Local tests.

Examine the code

Setup

To transfer tokens using CCIP, the test needs:

  • The local chain selector
  • The local CCIP router
  • A test token contract (CCIP-BnM)
  • A sender account (the broadcaster)
  • A receiver account

The shared base contract test/utils/LocalSimulatorTest.sol deploys the simulator and exposes the router, LINK and BnM. The setUp() function is invoked before each test case to reinitialize all the variables, ensuring a clean state for each:

  1. Initialize the Chainlink Local simulator contract and read its configuration:

    simulator = new CCIPLocalSimulator();
    (uint64 selector, IRouterClient sourceRouter,,, LinkToken linkToken, BurnMintERC677Helper bnm,) =
        simulator.configuration();
    

    Note: The configuration function also returns the destination router, WETH9 and CCIP-LnM contracts, which these tests do not use. Hence, there are commas in the return values.

  2. The example scripts wrap their calls in vm.startBroadcast() with no argument, so inside a test the transaction sender is DEFAULT_SENDER. That account is exposed as broadcaster and must hold the tokens and ETH the script spends:

    address internal broadcaster = DEFAULT_SENDER;
    
  3. Example01Test.setUp() funds the broadcaster with CCIP-BnM and creates the receiver:

    function setUp() public override {
        super.setUp();
        example = new Example01();
        receiver = makeAddr("receiver");
        ccipBnM.drip(broadcaster);
    }
    

Test case 1: Transfer with native fees

The test_transfersTokenToEoaPayingNativeFee function tests the transfer of tokens to an EOA while paying the fee in native gas. Here are the steps involved:

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

    uint256 fee = 0.01 ether;
    router.setFee(fee);
    deal(broadcaster, 1 ether);
    
  2. Record the sender's initial token balance, then run the Example01 script with the router, chain selector, receiver, token, amount, gas limit, block confirmations and fee token:

    uint256 senderBefore = ccipBnM.balanceOf(broadcaster);
    
    bytes32 messageId = example.run(
        address(router),
        chainSelector,
        receiver,
        address(ccipBnM),
        AMOUNT,
        EOA_GAS_LIMIT,
        BLOCK_CONFIRMATIONS,
        address(0)
    );
    

    The constants used:

    uint256 internal constant AMOUNT = 1e17;
    uint32 internal constant EOA_GAS_LIMIT = 0;
    uint16 internal constant 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.
  3. Inside the script, the ExtraArgsV3 are encoded with the EncodeExtraArgsOffchain helper, and the Client.EVM2AnyMessage is built with the receiver, token amounts, extraArgs and fee token:

    EncodeExtraArgsOffchain extraArgsEncoder = new EncodeExtraArgsOffchain();
    bytes memory extraArgs = extraArgsEncoder.encodeV3Basic(gasLimit, blockConfirmations);
    
    Client.EVMTokenAmount[] memory tokenAmounts = new Client.EVMTokenAmount[](1);
    tokenAmounts[0] = Client.EVMTokenAmount({token: tokenToSend, amount: amount});
    
    Client.EVM2AnyMessage memory ccipMessage = Client.EVM2AnyMessage({
        receiver: abi.encode(receiver),
        data: "",
        tokenAmounts: tokenAmounts,
        extraArgs: extraArgs,
        feeToken: feeTokenAddress
    });
    

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

  4. The script quotes the fee, approves the router to spend the token, and sends. With feeTokenAddress == address(0) the fee is paid in native gas:

    uint256 fee = IRouterClient(sourceRouter).getFee(destinationChainSelector, ccipMessage);
    vm.startBroadcast();
    IERC20(tokenToSend).approve(sourceRouter, amount);
    messageId = IRouterClient(sourceRouter).ccipSend{value: fee}(destinationChainSelector, ccipMessage);
    vm.stopBroadcast();
    
  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 script:

    assertTrue(messageId != bytes32(0));
    assertEq(ccipBnM.balanceOf(receiver), AMOUNT);
    assertEq(ccipBnM.balanceOf(broadcaster), senderBefore - AMOUNT);
    assertEq(address(router).balance, fee);
    

The test_transfersTokenToEoaPayingLinkFee function tests the same transfer paying the fee in LINK. The differences from the previous test case:

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

    simulator.requestLinkFromFaucet(broadcaster, 1 ether);
    
  2. Pass the LINK token address as the fee token, so the script approves and pays in LINK:

    bytes32 messageId = example.run(
        address(router),
        chainSelector,
        receiver,
        address(ccipBnM),
        AMOUNT,
        EOA_GAS_LIMIT,
        BLOCK_CONFIRMATIONS,
        address(link)
    );
    
  3. Verify the router collected the fee in LINK:

    assertEq(link.balanceOf(address(router)), fee);
    

Revert test cases

The remaining two test cases verify the CCIP 2.0 send-time rules the local router enforces:

  1. test_revertsWhenBlockConfirmationsIsZero expects the script's own require message, because 0 means default finality, not a Fast Transfer:

    vm.expectRevert("blockConfirmations == 0 means default finality; use > 0 for Faster Than Finality");
    example.run(address(router), chainSelector, receiver, address(ccipBnM), AMOUNT, EOA_GAS_LIMIT, 0, address(0));
    
  2. test_revertsWhenAmountIsZero expects the zero-amount rejection:

    vm.expectRevert("amount must be > 0");
    

Next steps

Get the latest Chainlink content straight to your inbox.