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.
Introducing Chainlink Local
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).
Local Mode - working with mock contracts on a locally running development blockchain node running on localhost, and
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.
Coding time: Let's write a unit test for our XNFT.sol smart contract
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
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.
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
Write your first test with Chainlink Local
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: UNLICENSEDpragmasolidity ^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";contractXNFTTestisTest { 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;functionsetUp() public { alice =makeAddr("alice"); bob =makeAddr("bob");stringmemory ETHEREUM_SEPOLIA_RPC_URL = vm.envString("ETHEREUM_SEPOLIA_RPC_URL");stringmemory 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 =newCCIPLocalSimulatorFork(); 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: MITpragmasolidity ^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.functionencode(uint256 gasLimit) externalpurereturns (bytesmemory 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:
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:
functionsetUp() public { alice =makeAddr("alice"); bob =makeAddr("bob");stringmemory ETHEREUM_SEPOLIA_RPC_URL = vm.envString("ETHEREUM_SEPOLIA_RPC_URL");stringmemory 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 =newCCIPLocalSimulatorFork(); vm.makePersistent(address(ccipLocalSimulatorFork));// Step 1) Deploy XNFT.sol to Ethereum SepoliaassertEq(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 =newXNFT( 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 =newXNFT( arbSepoliaNetworkDetails.routerAddress, arbSepoliaNetworkDetails.linkAddress, arbSepoliaNetworkDetails.chainSelector ); }
And the rest of the steps from the previous exercise directly in a new test function, like this:
functiontestShouldMintNftOnArbitrumSepoliaAndTransferItToEthereumSepolia() public {// Step 3) On Ethereum Sepolia, call enableChain function vm.selectFork(ethSepoliaFork);assertEq(vm.activeFork(), ethSepoliaFork); encodeExtraArgs =newEncodeExtraArgs();uint256 gasLimit =200_000;bytesmemory 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 LINKassertEq(vm.activeFork(), arbSepoliaFork); ccipLocalSimulatorFork.requestLinkFromFaucet(address(arbSepoliaXNFT),3ether);// Step 6) On Arbitrum Sepolia, mint new xNFTassertEq(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: UNLICENSEDpragmasolidity ^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";contractXNFTTestisTest { 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;functionsetUp() public { alice =makeAddr("alice"); bob =makeAddr("bob");stringmemory ETHEREUM_SEPOLIA_RPC_URL = vm.envString("ETHEREUM_SEPOLIA_RPC_URL");stringmemory 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 =newCCIPLocalSimulatorFork(); vm.makePersistent(address(ccipLocalSimulatorFork));// Step 1) Deploy XNFT.sol to Ethereum SepoliaassertEq(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 =newXNFT( 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 =newXNFT( arbSepoliaNetworkDetails.routerAddress, arbSepoliaNetworkDetails.linkAddress, arbSepoliaNetworkDetails.chainSelector ); }functiontestShouldMintNftOnArbitrumSepoliaAndTransferItToEthereumSepolia() public {// Step 3) On Ethereum Sepolia, call enableChain function vm.selectFork(ethSepoliaFork);assertEq(vm.activeFork(), ethSepoliaFork); encodeExtraArgs =newEncodeExtraArgs();uint256 gasLimit =200_000;bytesmemory 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 LINKassertEq(vm.activeFork(), arbSepoliaFork); ccipLocalSimulatorFork.requestLinkFromFaucet(address(arbSepoliaXNFT),3ether);// Step 6) On Arbitrum Sepolia, mint new xNFTassertEq(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); }}
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 .