Using the CCIP Local Simulator in your Foundry project with forked environments

You can use Chainlink Local to run CCIP in forked environments within your Foundry project. To get started quickly, you will use the CCIP Foundry Starter Kit. This project includes the Example01ForkTest file located in the ./test/fork directory, demonstrating how to set up and run token transfer tests between two accounts using CCIP in forked environments.

Forked environments allow you to simulate real-world blockchain networks by forking the state of existing chains. In this example, you will fork Arbitrum Sepolia and Ethereum Sepolia.

Prerequisites

This guide assumes that you are familiar with the guide Using the CCIP Local Simulator in your Foundry project. If not, please get familiar with it and run all the prerequisites.

Set up an .env file with the following data:

  • ARBITRUM_SEPOLIA_RPC_URL: The Remote Procedure Call (RPC) URL for the Arbitrum Sepolia network. You can obtain one by creating an account on Alchemy or Infura and setting up an Arbitrum Sepolia project.
  • ETHEREUM_SEPOLIA_RPC_URL: The RPC URL for the Ethereum Sepolia testnet. You can sign up for a personal endpoint from Alchemy, Infura, or another node provider service.

Test tokens transfers

You will run a test to transfer tokens between two accounts in a forked environment. The test file Example01ForkTest.t.sol is located in the ./test/fork directory. This file contains two test cases:

  1. Transfer with LINK fees: This test case transfers tokens from the sender account to the receiver account, paying fees in LINK. At the end of the test, it verifies that the sender account was debited and the receiver account was credited.

  2. Transfer with native gas fees: This test case transfers tokens from the sender account to the receiver account, paying fees in native gas. At the end of the test, it verifies that the sender account was debited and the receiver account was credited.

In these tests, we simulate transfers from a source blockchain (which is a fork of Arbitrum Sepolia) to a destination blockchain (which is a fork of Ethereum Sepolia). Forked environments allow you to simulate real-world blockchain networks by forking the state of existing chains, providing a realistic testing scenario.

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 Example01ForkTest

Example output:

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

Ran 2 tests for test/fork/Example01Fork.t.sol:Example01ForkTest
[PASS] test_transferTokensFromEoaToEoaPayFeesInLink() (gas: 475199)
[PASS] test_transferTokensFromEoaToEoaPayFeesInNative() (gas: 451096)
Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 11.71s (17.29s CPU time)

Ran 1 test suite in 11.90s (11.71s CPU time): 2 tests passed, 0 failed, 0 skipped (2 total tests)

Examine the code

Setup

To transfer tokens using CCIP in a forked environment, we need the following:

  • Destination chain selector
  • Source CCIP router
  • LINK token for paying CCIP fees
  • A test token contract (such as CCIP-BnM) on both source and destination chains
  • A sender account (Alice)
  • A receiver account (Bob)

The setUp() function is invoked before each test case to reinitialize all the variables, ensuring a clean state for each test:

  1. Initialize the source and destination forks:

    string memory DESTINATION_RPC_URL = vm.envString("ETHEREUM_SEPOLIA_RPC_URL");
    string memory SOURCE_RPC_URL = vm.envString("ARBITRUM_SEPOLIA_RPC_URL");
    destinationFork = vm.createSelectFork(DESTINATION_RPC_URL);
    sourceFork = vm.createFork(SOURCE_RPC_URL);
    
  2. Initialize the sender and receiver accounts:

    bob = makeAddr("bob");
    alice = makeAddr("alice");
    
  3. Initialize the fork CCIP local simulator.

    Note: vm.makePersistent is used to make the ccipLocalSimulatorFork address persistent across forks:

    ccipLocalSimulatorFork = new CCIPLocalSimulatorFork();
    vm.makePersistent(address(ccipLocalSimulatorFork));
    
  4. Retrieve and set up the network details for the destination chain.

    Note: Register.NetworkDetails is a struct that stores network details (such as chain selector, router address, link address, wrapped native address, or CCIP test tokens), and getNetworkDetails pulls network details based on chain IDs:

    Register.NetworkDetails memory destinationNetworkDetails = ccipLocalSimulatorFork.getNetworkDetails(block.chainid);
    destinationCCIPBnMToken = BurnMintERC677Helper(destinationNetworkDetails.ccipBnMAddress);
    destinationChainSelector = destinationNetworkDetails.chainSelector;
    
  5. Switch to the source fork and retrieve the network details for the source chain:

    vm.selectFork(sourceFork);
    Register.NetworkDetails memory sourceNetworkDetails = ccipLocalSimulatorFork.getNetworkDetails(block.chainid);
    sourceCCIPBnMToken = BurnMintERC677Helper(sourceNetworkDetails.ccipBnMAddress);
    sourceLinkToken = IERC20(sourceNetworkDetails.linkAddress);
    sourceRouter = IRouterClient(sourceNetworkDetails.routerAddress);
    
  6. All the variables are stored in the contract state for use in the test cases.

Prepare scenario (helper function)

The prepareScenario() function is invoked at the beginning of each test case. It performs the following actions:

  1. Select the source fork and request CCIP-BnM tokens for Alice:

    vm.selectFork(sourceFork);
    vm.startPrank(alice);
    sourceCCIPBnMToken.drip(alice);
    
  2. Approve the source router to spend tokens on behalf of Alice:

    uint256 amountToSend = 100;
    sourceCCIPBnMToken.approve(address(sourceRouter), amountToSend);
    
  3. Create an array Client.EVMTokenAmount[] to specify the token transfer details. This array and the amount to send are returned by the prepareScenario() function for use in the calling test case:

    tokensToSendDetails = new Client.EVMTokenAmount[](1);
    Client.EVMTokenAmount memory tokenToSendDetails =
        Client.EVMTokenAmount({token: address(ccipBnMToken), amount: amountToSend});
    tokensToSendDetails[0] = tokenToSendDetails;
    
  4. Stop impersonating Alice (sender):

    vm.stopPrank();
    

The test_transferTokensFromEoaToEoaPayFeesInLink function tests the transfer of tokens between two externally owned accounts (EOA) while paying fees in LINK. Here are the steps involved in this test case:

  1. Invoke the prepareScenario() function to set up the necessary variables:

    (Client.EVMTokenAmount[] memory tokensToSendDetails, uint256 amountToSend) = prepareScenario();
    
  2. Select the destination fork and record the initial token balance of Bob receiver:

    vm.selectFork(destinationFork);
    uint256 balanceOfBobBefore = destinationCCIPBnMToken.balanceOf(bob);
    
  3. Select the source fork and record the initial token balance of Alice (sender):

    vm.selectFork(sourceFork);
    uint256 balanceOfAliceBefore = sourceCCIPBnMToken.balanceOf(alice);
    
  4. Request 10 LINK tokens from the CCIP local simulator faucet for Alice (sender):

    ccipLocalSimulatorFork.requestLinkFromFaucet(alice, 10 ether);
    
  5. Construct the Client.EVM2AnyMessage structure with the receiver, token amounts, and other necessary details.

    • Set the data parameter to an empty string because you are not sending any arbitrary data, only tokens.
    • In extraArgs, set the gas limit to 0. This gas limit is for execution of receiver logic, which doesn't apply here because you're sending tokens to an EOA.
    Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({
        receiver: abi.encode(bob),
        data: abi.encode(""),
        tokenAmounts: tokensToSendDetails,
        extraArgs: Client._argsToBytes(Client.EVMExtraArgsV1({gasLimit: 0})),
        feeToken: address(sourceLinkToken)
    });
    
  6. Calculate the required fees for the transfer and approve the router to spend LINK tokens for these fees:

    uint256 fees = sourceRouter.getFee(destinationChainSelector, message);
    sourceLinkToken.approve(address(sourceRouter), fees);
    
  7. Send the CCIP transfer request to the router:

    sourceRouter.ccipSend(destinationChainSelector, message);
    
  8. Stop impersonating Alice (sender):

    vm.stopPrank();
    
  9. Record Alice's final token balance and verify that it has decreased by the amount sent:

    uint256 balanceOfAliceAfter = sourceCCIPBnMToken.balanceOf(alice);
    assertEq(balanceOfAliceAfter, balanceOfAliceBefore - amountToSend);
    
  10. Call the switchChainAndRouteMessage function to switch to the destination fork and route the message to complete the transfer:

    ccipLocalSimulatorFork.switchChainAndRouteMessage(destinationFork);
    
  11. Record Bob's final token balance and verify that it has increased by the amount sent:

    uint256 balanceOfBobAfter = destinationCCIPBnMToken.balanceOf(bob);
    assertEq(balanceOfBobAfter, balanceOfBobBefore + amountToSend);
    

Test case 2: Transfer with native gas fees

The test_transferTokensFromEoaToEoaPayFeesInNative function tests the transfer of tokens between two externally owned accounts (EOA) while paying fees in native gas. Here are the steps involved in this test case:

  1. Invoke the prepareScenario() function to set up the necessary variables. (This function is the same as in the previous test case.)

  2. Select the destination fork and record Bob's initial token balance. (This step is the same as in the previous test case.)

  3. Select the source fork and record Alice's initial token balance. (This step is the same as in the previous test case.)

  4. Begin impersonating Alice (sender) and provide her with native gas:

    vm.startPrank(alice);
    deal(alice, 5 ether);
    
  5. Construct the Client.EVM2AnyMessage structure. This step is the same as in the previous test case. The main difference is that the feeToken is set with address(0) to indicate that the fees are paid in native gas:

    Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({
        receiver: abi.encode(bob),
        data: abi.encode(""),
        tokenAmounts: tokensToSendDetails,
        extraArgs: Client._argsToBytes(Client.EVMExtraArgsV1({gasLimit: 0})),
        feeToken: address(0)
    });
    
  6. Calculate the required fees for the transfer and send the CCIP transfer request along with the necessary native gas:

    uint256 fees = sourceRouter.getFee(destinationChainSelector, message);
    sourceRouter.ccipSend{value: fees}(destinationChainSelector, message, fees);
    
  7. Stop impersonating Alice (sender). (This step is the same as in the previous test case.)

  8. Verify Alice's (sender) balance. (This step is the same as in the previous test case.)

  9. Call the switchChainAndRouteMessage function to switch to the destination fork and route the message to complete the transfer. (This step is the same as in the previous test case.)

  10. Verify Bob's (receiver) balance. (This step is the same as in the previous test case.)

Get the latest Chainlink content straight to your inbox.