Exercise 3: Testing Cross-Chain contracts using Chainlink Local

You might noticed so far that building with CCIP directly on test networks is not ideal due to extra points of friction - like setting up a wallet, getting testnet tokens, waiting for cross-chain transactions to complete, etc. This is because test networks are for testing and local environments (like Foundry, Hardhat or Remix IDE) are for building and unit testing.

To address this issue, we created Chainlink Local - the Chainlink CCIP Local Simulator, and in this chapter you will learn how to simulate your cross-chain transactions locally and build with Chainlink CCIP 2000x quicker compared to working on test networks!

Chainlink Local is an installable dependency, like OpenZeppelin for example. It provides a tool (the Chainlink Local Simulator) that developers import into their Foundry or Hardhat or Remix IDE projects. This tool runs Chainlink CCIP locally which means developers can rapidly explore, prototype and iterate CCIP dApps off-chain in a local environment, and move to testnet only when they're ready to test in a live environment.

Most importantly, smart contracts tested with Chainlink Local can be deployed to test networks without any modifications (assuming network specific contract addresses such as Router contracts and LINK token addresses are passed in via a constructor).

To view more detailed documentation and more examples, visit the Chainlink Local Documentation and Chainlink Local YouTube Playlist.

Local Mode vs Forked Mode

The simulator supports two modes:

  1. Local Mode - working with mock contracts on a locally running development blockchain node running on localhost, and

  2. Forked Mode - working with deployed Chainlink CCIP contracts using multiple forked networks.

In this example we will use Forked Mode.

For homework you must use Local Mode.

Local Mode

When working in local simulation mode, the simulator pre-deploys a set of smart contracts to a blank Hardhat/Anvil network EVM state and exposes their details via a call to the configuration() function. Even though there are two Router contracts exposed, sourceRouter and destinationRouter, to support the developer's mental model of routing cross-chain messages through two different Routers, both are actually the same contract running on the locally development blockchain node.

Forked Mode

When working in fork mode, you will need to create multiple locally running blockchain networks (you need an archive node that has historical network state in the pinned block from which you have forked for local development - see here) and interact with the contract addresses provided in the Official Chainlink Documentation.

Chainlink Local is so fast because it does not spin any type of offchain component needed for cross-chain transfers. That's why CCIP Local Simulator Fork (smart contract for Foundry, and typescript script for Hardhat) exposes functionality to switch between forks and route messages to the destination blockchain directly by developers - so don't forget this step 😉.

Coding time: Let's write a unit test for our XNFT.sol smart contract

The full example is available at: https://github.com/smartcontractkit/ccip-cross-chain-nft

Creating a new Foundry project

To get started let's first create a new Foundry project by running:

forge init

If this command fails, make sure you have Foundry installed.

Installing necessary dependencies

After that we will need to install the following dependencies. If you don't want to commit changes after each installment, append the --no-commit flag to each of the following commands

@chainlink/contracts

forge install smartcontractkit/chainlink-brownie-contracts

@chainlink/contracts-ccip

forge install smartcontractkit/ccip@b06a3c2eecb9892ec6f76a015624413fffa1a122

@openzeppelin/contracts

forge install OpenZeppelin/openzeppelin-contracts

@chainlink/local

forge install smartcontractkit/chainlink-local

And then set the following remappings in your foundry.toml or remappings.txt file:

# foundry.toml
[profile.default]
src = "src"
out = "out"
test = "test"
libs = ["lib"]
solc = '0.8.24'

remappings = [
    '@chainlink/contracts-ccip=lib/ccip/contracts',
    '@chainlink/contracts/=lib/chainlink-brownie-contracts/contracts/',
    '@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/',
    '@chainlink/local/=lib/chainlink-local/',
]

Delete src/Counter.sol, test/Counter.t.sol and script/Counter.s.sol files. Create new XNFT.sol file in src folder and paste the content of the XNFT smart contract from the previous page/exercise. Run forge build to compile that contract. This should work if you installed dependencies properly.

Getting Ethereum Sepolia and Arbitrum Sepolia RPC URLs

Create a new file by copying the .env.example file, and name it .env. Fill in with Ethereum Sepolia and Arbitrum Sepolia RPC URLs using either local archive nodes or a service that provides archival data, like Infura or Alchemy.

ETHEREUM_SEPOLIA_RPC_URL=""
ARBITRUM_SEPOLIA_RPC_URL=""

Then add the rpc_endpoints section to your foundry.toml file. Its final version should look like this:

# foundry.toml
[profile.default]
src = "src"
out = "out"
test = "test"
libs = ["lib"]
solc = '0.8.24'

remappings = [
    '@chainlink/contracts-ccip=lib/ccip/contracts',
    '@chainlink/contracts/=lib/chainlink-brownie-contracts/contracts/',
    '@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/',
    '@chainlink/local/=lib/chainlink-local/',
]

[rpc_endpoints]
ethereumSepolia = "${ETHEREUM_SEPOLIA_RPC_URL}"
arbitrumSepolia = "${ARBITRUM_SEPOLIA_RPC_URL}"

# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options

In the previous exercise, we performed 7 steps on two different test networks in order to deploy, mint and transfer XNFT from one account to another. Using Chainlink Local, you can write a test that will perform the same action, using exactly the same CCIP contracts from test networks, in your local environment, much faster.

Create a new test/XNFT.t.sol file. Paste the following content:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {Test, console} from "forge-std/Test.sol";
import {CCIPLocalSimulatorFork, Register} from "@chainlink/local/src/ccip/CCIPLocalSimulatorFork.sol";

import {XNFT} from "../src/XNFT.sol";
import {EncodeExtraArgs} from "./utils/EncodeExtraArgs.sol";

contract XNFTTest is Test {
    CCIPLocalSimulatorFork public ccipLocalSimulatorFork;
    uint256 ethSepoliaFork;
    uint256 arbSepoliaFork;
    Register.NetworkDetails ethSepoliaNetworkDetails;
    Register.NetworkDetails arbSepoliaNetworkDetails;

    address alice;
    address bob;

    XNFT public ethSepoliaXNFT;
    XNFT public arbSepoliaXNFT;

    EncodeExtraArgs public encodeExtraArgs;

    function setUp() public {
        alice = makeAddr("alice");
        bob = makeAddr("bob");

        string memory ETHEREUM_SEPOLIA_RPC_URL = vm.envString("ETHEREUM_SEPOLIA_RPC_URL");
        string memory ARBITRUM_SEPOLIA_RPC_URL = vm.envString("ARBITRUM_SEPOLIA_RPC_URL");
        ethSepoliaFork = vm.createSelectFork(ETHEREUM_SEPOLIA_RPC_URL);
        arbSepoliaFork = vm.createFork(ARBITRUM_SEPOLIA_RPC_URL);

        ccipLocalSimulatorFork = new CCIPLocalSimulatorFork();
        vm.makePersistent(address(ccipLocalSimulatorFork));
    }

    // YOUR TEST GOES HERE...
}

If you try to compile this contract you should encounter in an error because there is no EncodeExtraArgs.sol helper smart contract from the previous exercise. Fix that by creating a new file test/utils/EncodeExtraArgs.sol and paste the following code.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import {Client} from "@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol";

contract EncodeExtraArgs {
    // Below is a simplistic example (same params for all messages) of using storage to allow for new options without
    // upgrading the dapp. Note that extra args are chain family specific (e.g. gasLimit is EVM specific etc.).
    // and will always be backwards compatible i.e. upgrades are opt-in.
    // Offchain we can compute the V1 extraArgs:
    //    Client.EVMExtraArgsV1 memory extraArgs = Client.EVMExtraArgsV1({gasLimit: 300_000});
    //    bytes memory encodedV1ExtraArgs = Client._argsToBytes(extraArgs);
    // Then later compute V2 extraArgs, for example if a refund feature was added:
    //    Client.EVMExtraArgsV2 memory extraArgs = Client.EVMExtraArgsV2({gasLimit: 300_000, destRefundAddress: 0x1234});
    //    bytes memory encodedV2ExtraArgs = Client._argsToBytes(extraArgs);
    // and update storage with the new args.
    // If different options are required for different messages, for example different gas limits,
    // one can simply key based on (chainSelector, messageType) instead of only chainSelector.

    function encode(uint256 gasLimit) external pure returns (bytes memory extraArgsBytes) {
        Client.EVMExtraArgsV1 memory extraArgs = Client.EVMExtraArgsV1({gasLimit: gasLimit});
        extraArgsBytes = Client._argsToBytes(extraArgs);
    }
}

Let's analyze the code so far. We've imported CCIPLocalSimulatorFork from the @chainlink/local package, meaning that we are using the Forked Mode. We created a new instance of it and made it persistent across all different forks so it can work properly.

For your homework however, you will need to import CCIPLocalSimulator from the @chainlink/local package.

We also created two forks (copies of the blockchain networks at the latest block - it is the best practice to pin this block number instead of always using the latest one) of Ethereum Sepolia and Arbitrum Sepolia test networks. We can now switch between multiple forks in Foundry, but we selected Ethereum Sepolia for start.

Fork ID is Foundry's way to manage different forked networks in a same test and those are usually numbers from 1, 2, 3... etc. Fork ID is not the same thing as block.chainid - that's the actuall Chain ID of a blockchain network you can get in Solidity - 11155111 for Ethereum Sepolia, for example.

CCIPLocalSimulatorFork in Foundry only, comes up with this Register helper smart contract that contains NetworkDetails struct which looks like this:

struct NetworkDetails {
        uint64 chainSelector;
        address routerAddress;
        address linkAddress;
        address wrappedNativeAddress;
        address ccipBnMAddress;
        address ccipLnMAddress;
}

It is pre-populated with these details for some test networks and NO MAIN NETWORKS (on purpose) so it is the best practice to always validate these information - otherwise simulations won't work:

ethSepoliaNetworkDetails = ccipLocalSimulatorFork.getNetworkDetails(block.chainid); // we are currently on Ethereum Sepolia Fork
assertEq(
    ethSepoliaNetworkDetails.chainSelector,
    16015286601757825753,
    "Sanity check: Ethereum Sepolia chain selector should be 16015286601757825753"
);

If there is no Network Details (for main networks for example) you must add those manually using the setNetworkDetails function.

We are going to perform Steps 1) and 2) from previous exercise directly in the setup() function, like this:

    function setUp() public {
        alice = makeAddr("alice");
        bob = makeAddr("bob");

        string memory ETHEREUM_SEPOLIA_RPC_URL = vm.envString("ETHEREUM_SEPOLIA_RPC_URL");
        string memory ARBITRUM_SEPOLIA_RPC_URL = vm.envString("ARBITRUM_SEPOLIA_RPC_URL");
        ethSepoliaFork = vm.createSelectFork(ETHEREUM_SEPOLIA_RPC_URL);
        arbSepoliaFork = vm.createFork(ARBITRUM_SEPOLIA_RPC_URL);

        ccipLocalSimulatorFork = new CCIPLocalSimulatorFork();
        vm.makePersistent(address(ccipLocalSimulatorFork));

        // Step 1) Deploy XNFT.sol to Ethereum Sepolia
        assertEq(vm.activeFork(), ethSepoliaFork);

        ethSepoliaNetworkDetails = ccipLocalSimulatorFork.getNetworkDetails(block.chainid); // we are currently on Ethereum Sepolia Fork
        assertEq(
            ethSepoliaNetworkDetails.chainSelector,
            16015286601757825753,
            "Sanity check: Ethereum Sepolia chain selector should be 16015286601757825753"
        );

        ethSepoliaXNFT = new XNFT(
            ethSepoliaNetworkDetails.routerAddress,
            ethSepoliaNetworkDetails.linkAddress,
            ethSepoliaNetworkDetails.chainSelector
        );

        // Step 2) Deploy XNFT.sol to Arbitrum Sepolia
        vm.selectFork(arbSepoliaFork);
        assertEq(vm.activeFork(), arbSepoliaFork);

        arbSepoliaNetworkDetails = ccipLocalSimulatorFork.getNetworkDetails(block.chainid); // we are currently on Arbitrum Sepolia Fork
        assertEq(
            arbSepoliaNetworkDetails.chainSelector,
            3478487238524512106,
            "Sanity check: Arbitrum Sepolia chain selector should be 421614"
        );

        arbSepoliaXNFT = new XNFT(
            arbSepoliaNetworkDetails.routerAddress,
            arbSepoliaNetworkDetails.linkAddress,
            arbSepoliaNetworkDetails.chainSelector
        );
    }

And the rest of the steps from the previous exercise directly in a new test function, like this:

function testShouldMintNftOnArbitrumSepoliaAndTransferItToEthereumSepolia() public {
        // Step 3) On Ethereum Sepolia, call enableChain function
        vm.selectFork(ethSepoliaFork);
        assertEq(vm.activeFork(), ethSepoliaFork);

        encodeExtraArgs = new EncodeExtraArgs();

        uint256 gasLimit = 200_000;
        bytes memory extraArgs = encodeExtraArgs.encode(gasLimit);
        assertEq(extraArgs, hex"97a657c90000000000000000000000000000000000000000000000000000000000030d40"); // value taken from https://cll-devrel.gitbook.io/ccip-masterclass-3/ccip-masterclass/exercise-xnft#step-3-on-ethereum-sepolia-call-enablechain-function

        ethSepoliaXNFT.enableChain(arbSepoliaNetworkDetails.chainSelector, address(arbSepoliaXNFT), extraArgs);

        // Step 4) On Arbitrum Sepolia, call enableChain function
        vm.selectFork(arbSepoliaFork);
        assertEq(vm.activeFork(), arbSepoliaFork);

        arbSepoliaXNFT.enableChain(ethSepoliaNetworkDetails.chainSelector, address(ethSepoliaXNFT), extraArgs);

        // Step 5) On Arbitrum Sepolia, fund XNFT.sol with 3 LINK
        assertEq(vm.activeFork(), arbSepoliaFork);

        ccipLocalSimulatorFork.requestLinkFromFaucet(address(arbSepoliaXNFT), 3 ether);

        // Step 6) On Arbitrum Sepolia, mint new xNFT
        assertEq(vm.activeFork(), arbSepoliaFork);

        vm.startPrank(alice);

        arbSepoliaXNFT.mint();
        uint256 tokenId = 0;
        assertEq(arbSepoliaXNFT.balanceOf(alice), 1);
        assertEq(arbSepoliaXNFT.ownerOf(tokenId), alice);

        // Step 7) On Arbitrum Sepolia, crossTransferFrom xNFT
        arbSepoliaXNFT.crossChainTransferFrom(
            address(alice), address(bob), tokenId, ethSepoliaNetworkDetails.chainSelector, XNFT.PayFeesIn.LINK
        );

        vm.stopPrank();

        assertEq(arbSepoliaXNFT.balanceOf(alice), 0);

        // On Ethereum Sepolia, check if xNFT was succesfully transferred
        ccipLocalSimulatorFork.switchChainAndRouteMessage(ethSepoliaFork); // THIS LINE REPLACES CHAINLINK CCIP DONs, DO NOT FORGET IT
        assertEq(vm.activeFork(), ethSepoliaFork);

        assertEq(ethSepoliaXNFT.balanceOf(bob), 1);
        assertEq(ethSepoliaXNFT.ownerOf(tokenId), bob);
    }

Full Final code:

test/XNFT.t.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {Test, console} from "forge-std/Test.sol";
import {CCIPLocalSimulatorFork, Register} from "@chainlink/local/src/ccip/CCIPLocalSimulatorFork.sol";

import {XNFT} from "../src/XNFT.sol";
import {EncodeExtraArgs} from "./utils/EncodeExtraArgs.sol";

contract XNFTTest is Test {
    CCIPLocalSimulatorFork public ccipLocalSimulatorFork;
    uint256 ethSepoliaFork;
    uint256 arbSepoliaFork;
    Register.NetworkDetails ethSepoliaNetworkDetails;
    Register.NetworkDetails arbSepoliaNetworkDetails;

    address alice;
    address bob;

    XNFT public ethSepoliaXNFT;
    XNFT public arbSepoliaXNFT;

    EncodeExtraArgs public encodeExtraArgs;

    function setUp() public {
        alice = makeAddr("alice");
        bob = makeAddr("bob");

        string memory ETHEREUM_SEPOLIA_RPC_URL = vm.envString("ETHEREUM_SEPOLIA_RPC_URL");
        string memory ARBITRUM_SEPOLIA_RPC_URL = vm.envString("ARBITRUM_SEPOLIA_RPC_URL");
        ethSepoliaFork = vm.createSelectFork(ETHEREUM_SEPOLIA_RPC_URL);
        arbSepoliaFork = vm.createFork(ARBITRUM_SEPOLIA_RPC_URL);

        ccipLocalSimulatorFork = new CCIPLocalSimulatorFork();
        vm.makePersistent(address(ccipLocalSimulatorFork));

        // Step 1) Deploy XNFT.sol to Ethereum Sepolia
        assertEq(vm.activeFork(), ethSepoliaFork);

        ethSepoliaNetworkDetails = ccipLocalSimulatorFork.getNetworkDetails(block.chainid); // we are currently on Ethereum Sepolia Fork
        assertEq(
            ethSepoliaNetworkDetails.chainSelector,
            16015286601757825753,
            "Sanity check: Ethereum Sepolia chain selector should be 16015286601757825753"
        );

        ethSepoliaXNFT = new XNFT(
            ethSepoliaNetworkDetails.routerAddress,
            ethSepoliaNetworkDetails.linkAddress,
            ethSepoliaNetworkDetails.chainSelector
        );

        // Step 2) Deploy XNFT.sol to Arbitrum Sepolia
        vm.selectFork(arbSepoliaFork);
        assertEq(vm.activeFork(), arbSepoliaFork);

        arbSepoliaNetworkDetails = ccipLocalSimulatorFork.getNetworkDetails(block.chainid); // we are currently on Arbitrum Sepolia Fork
        assertEq(
            arbSepoliaNetworkDetails.chainSelector,
            3478487238524512106,
            "Sanity check: Arbitrum Sepolia chain selector should be 421614"
        );

        arbSepoliaXNFT = new XNFT(
            arbSepoliaNetworkDetails.routerAddress,
            arbSepoliaNetworkDetails.linkAddress,
            arbSepoliaNetworkDetails.chainSelector
        );
    }

    function testShouldMintNftOnArbitrumSepoliaAndTransferItToEthereumSepolia() public {
        // Step 3) On Ethereum Sepolia, call enableChain function
        vm.selectFork(ethSepoliaFork);
        assertEq(vm.activeFork(), ethSepoliaFork);

        encodeExtraArgs = new EncodeExtraArgs();

        uint256 gasLimit = 200_000;
        bytes memory extraArgs = encodeExtraArgs.encode(gasLimit);
        assertEq(extraArgs, hex"97a657c90000000000000000000000000000000000000000000000000000000000030d40"); // value taken from https://cll-devrel.gitbook.io/ccip-masterclass-3/ccip-masterclass/exercise-xnft#step-3-on-ethereum-sepolia-call-enablechain-function

        ethSepoliaXNFT.enableChain(arbSepoliaNetworkDetails.chainSelector, address(arbSepoliaXNFT), extraArgs);

        // Step 4) On Arbitrum Sepolia, call enableChain function
        vm.selectFork(arbSepoliaFork);
        assertEq(vm.activeFork(), arbSepoliaFork);

        arbSepoliaXNFT.enableChain(ethSepoliaNetworkDetails.chainSelector, address(ethSepoliaXNFT), extraArgs);

        // Step 5) On Arbitrum Sepolia, fund XNFT.sol with 3 LINK
        assertEq(vm.activeFork(), arbSepoliaFork);

        ccipLocalSimulatorFork.requestLinkFromFaucet(address(arbSepoliaXNFT), 3 ether);

        // Step 6) On Arbitrum Sepolia, mint new xNFT
        assertEq(vm.activeFork(), arbSepoliaFork);

        vm.startPrank(alice);

        arbSepoliaXNFT.mint();
        uint256 tokenId = 0;
        assertEq(arbSepoliaXNFT.balanceOf(alice), 1);
        assertEq(arbSepoliaXNFT.ownerOf(tokenId), alice);

        // Step 7) On Arbitrum Sepolia, crossTransferFrom xNFT
        arbSepoliaXNFT.crossChainTransferFrom(
            address(alice), address(bob), tokenId, ethSepoliaNetworkDetails.chainSelector, XNFT.PayFeesIn.LINK
        );

        vm.stopPrank();

        assertEq(arbSepoliaXNFT.balanceOf(alice), 0);

        // On Ethereum Sepolia, check if xNFT was succesfully transferred
        ccipLocalSimulatorFork.switchChainAndRouteMessage(ethSepoliaFork); // THIS LINE REPLACES CHAINLINK CCIP DONs, DO NOT FORGET IT
        assertEq(vm.activeFork(), ethSepoliaFork);

        assertEq(ethSepoliaXNFT.balanceOf(bob), 1);
        assertEq(ethSepoliaXNFT.ownerOf(tokenId), bob);
    }
}

Last updated