3/5
## Understanding Cross-Chain Token Transfers with Chainlink CCIP This lesson demonstrates how to write a robust Solidity test function, `bridgeTokens`, using Foundry to verify cross-chain token transfers. We'll simulate bridging tokens from a source chain to a destination chain by leveraging Chainlink's Cross-Chain Interoperability Protocol (CCIP) concepts within a local test environment powered by `CCIPLocalSimulatorFork`. This approach facilitates testing bidirectional token movements. Chainlink CCIP is the foundational technology enabling these transfers. It relies on a Router contract deployed on each participating chain. These routers are responsible for dispatching messages and managing fee collection. The core of a CCIP interaction involves a `message`, specifically an `EVM2AnyMessage` struct, which encapsulates all necessary data for the cross-chain call. The typical cross-chain transfer process, as implemented in this test, follows these steps: 1. **Build the Message:** Construct an `EVM2AnyMessage` struct containing details like the receiver's address, token transfer specifics, the fee token, and any extra arguments for CCIP. 2. **Calculate Fees:** Query the source chain's Router contract using `getFee()` to determine the cost of the CCIP transaction. 3. **Fund Fees:** In our local test setup, we'll use a helper function to mint LINK tokens (the designated fee token in this example) to the user. 4. **Approve Fee Token:** The user must approve the source chain's Router contract to spend the calculated LINK fee. 5. **Approve Bridged Token:** The user must also approve the source chain's Router to spend the amount of the token being bridged. 6. **Send CCIP Message:** Invoke `ccipSend()` on the source chain's Router, passing the destination chain selector and the prepared message. 7. **Simulate Message Propagation:** Utilize the `CCIPLocalSimulatorFork` to mimic the message's journey and processing on the destination chain, including fast-forwarding time to simulate network latency. 8. **Verify Token Reception:** Confirm that the tokens (and any associated data, like interest rates for a `RebaseToken`) are correctly credited to the receiver on the destination chain. ## Crafting a Reusable `bridgeTokens` Test Function in Foundry To effectively test cross-chain functionality, we'll develop a generic helper function, `bridgeTokens`, within our Foundry test suite (e.g., `CrossChain.t.sol`). The function signature is designed for flexibility: ```solidity function bridgeTokens( uint256 amountToBridge, uint256 localFork, // Source chain fork ID uint256 remoteFork, // Destination chain fork ID Register.NetworkDetails memory localNetworkDetails, // Struct with source chain info Register.NetworkDetails memory remoteNetworkDetails, // Struct with dest. chain info RebaseToken localToken, // Source token contract instance RebaseToken remoteToken // Destination token contract instance ) public { // Implementation to follow } ``` This structure allows us to test bridging between various simulated chains (e.g., emulating Sepolia and Arbitrum Sepolia) using different instances of our `RebaseToken` contract. The `localNetworkDetails` and `remoteNetworkDetails` structs encapsulate chain-specific information like router addresses, LINK token addresses, and chain selectors. ## Step-by-Step: Building and Sending a CCIP Message The first crucial step within `bridgeTokens` is constructing the `EVM2AnyMessage`. This message carries the payload for the cross-chain transfer. **1. Message Initialization (`EVM2AnyMessage`)** We begin by selecting the source chain's fork and setting the context to our test `user`. ```solidity // -- On localFork, pranking as user -- vm.selectFork(localFork); // Note: We use vm.prank(user) before each state-changing call instead of vm.startPrank/vm.stopPrank blocks. // 1. Initialize tokenAmounts array Client.EVMTokenAmount[] memory tokenAmounts = new Client.EVMTokenAmount[](1); tokenAmounts[0] = Client.EVMTokenAmount({ token: address(localToken), // Token address on the local chain amount: amountToBridge // Amount to transfer }); // 2. Construct the EVM2AnyMessage Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({ receiver: abi.encode(user), // Receiver on the destination chain data: "", // No additional data payload in this example tokenAmounts: tokenAmounts, // The tokens and amounts to transfer feeToken: localNetworkDetails.linkAddress, // Using LINK as the fee token extraArgs: Client._argsToBytes( Client.EVMExtraArgsV1({gasLimit: 0}) // Use default gas limit ) }); ``` Key points in this construction: * The `receiver` address (here, the `user`) is ABI encoded. * `tokenAmounts` is an array specifying which tokens and their respective amounts are being transferred. In this test, we bridge a single token type. * `feeToken` is set to the LINK token address obtained from `localNetworkDetails`. * `extraArgs` utilizes `Client.EVMExtraArgsV1`. Setting `gasLimit: 0` instructs CCIP to use a default, reasonable gas limit for the destination transaction. `EVMExtraArgsV2`, not used here for simplicity, could offer options like out-of-order execution. This `message` struct is provided by `Client.sol` from the `@ccip/contracts` library. ## Managing Fees in a CCIP Test Environment Cross-chain operations incur fees. Our test needs to simulate obtaining and paying these fees. **1. Fetching CCIP Fees** We call `getFee()` on the source chain's Router contract, providing the destination chain's selector and the `EVM2AnyMessage` we just constructed. ```solidity // 3. Get the CCIP fee uint256 fee = IRouterClient(localNetworkDetails.routerAddress).getFee( remoteNetworkDetails.chainSelector, // Destination chain ID message ); ``` The `IRouterClient.sol` interface provides the definition for `getFee`. **2. Funding Fees in the Test Environment** For local testing with `CCIPLocalSimulatorFork.sol`, we can directly fund the user with the required LINK tokens. ```solidity // 4. Fund the user with LINK (for testing via CCIPLocalSimulatorFork) // This step is specific to the local simulator ccipLocalSimulatorFork.requestLinkFromFaucet(user, fee); ``` `requestLinkFromFaucet` is a utility function within our simulator contract that mints the specified `fee` amount of LINK to the `user`. **3. Approving LINK for the Router** The user must then approve the source chain's Router contract to spend these newly acquired LINK tokens to cover the CCIP fee. ```solidity // 5. Approve LINK for the Router vm.prank(user); IERC20(localNetworkDetails.linkAddress).approve(localNetworkDetails.routerAddress, fee); ``` We use `vm.prank(user)` to execute the `approve` call as the `user`. `IERC20.sol` provides the standard `approve` function interface. ## Token Approvals and Initiating the `ccipSend` Call With fees handled, the next step is to approve the token being bridged and then initiate the actual cross-chain send operation. **1. Approving the Bridged Token** Similar to the fee token, the user must approve the source chain's Router contract to transfer the `amountToBridge` of the `localToken`. ```solidity // 6. Approve the actual token to be bridged vm.prank(user); IERC20(address(localToken)).approve(localNetworkDetails.routerAddress, amountToBridge); ``` Notice the cast `address(localToken)`: when working with a contract instance (`localToken` of type `RebaseToken`) and needing to call a standard interface function like `approve` from `IERC20`, you often need to cast the contract instance to its address. **2. Sending the CCIP Message** Before sending, we record the user's balance of the `localToken`. Then, we execute `ccipSend` on the source Router, again pranking as the `user`. ```solidity // 7. Get user's balance on the local chain BEFORE sending uint256 localBalanceBefore = localToken.balanceOf(user); // 8. Send the CCIP message vm.prank(user); IRouterClient(localNetworkDetails.routerAddress).ccipSend( remoteNetworkDetails.chainSelector, // Destination chain ID message ); // 9. Get user's balance on the local chain AFTER sending and assert uint256 localBalanceAfter = localToken.balanceOf(user); assertEq(localBalanceAfter, localBalanceBefore - amountToBridge, "Local balance incorrect after send"); ``` After the `ccipSend` call, we re-check the user's balance on the source chain. It should have decreased by `amountToBridge`, confirming the tokens have left the user's wallet on this chain. ## Simulating Cross-Chain Message Propagation and Verification In our local Foundry test, the message doesn't magically appear on the destination chain. We need to simulate this. **1. Simulating Time and Message Routing** We use Foundry's cheatcode `vm.warp()` to fast-forward `block.timestamp`, mimicking network latency and finalization times. We then record the user's balance of the `remoteToken` on the destination chain *before* the simulated message processing. ```solidity // 10. Simulate message propagation to the remote chain vm.warp(block.timestamp + 20 minutes); // Fast-forward time // 11. Get user's balance on the remote chain BEFORE message processing // Ensure vm.selectFork(remoteFork) is called if not handled by switchChainAndRouteMessage uint256 remoteBalanceBefore = remoteToken.balanceOf(user); ``` It's critical to ensure the context is switched to `remoteFork` before querying `remoteToken.balanceOf(user)`. **2. Processing the Message on the Destination Chain** The `CCIPLocalSimulatorFork.sol` contract provides a crucial utility, `switchChainAndRouteMessage()`, to handle this simulation. This function internally selects the `remoteFork` and processes the enqueued CCIP message. ```solidity // 12. Process the message on the remote chain (using CCIPLocalSimulatorFork) ccipLocalSimulatorFork.switchChainAndRouteMessage(remoteFork); // 13. Get user's balance on the remote chain AFTER message processing and assert uint256 remoteBalanceAfter = remoteToken.balanceOf(user); assertEq(remoteBalanceAfter, remoteBalanceBefore + amountToBridge, "Remote balance incorrect after receive"); ``` After `switchChainAndRouteMessage` executes, we fetch the user's balance on the `remoteToken` again. This balance should now have increased by `amountToBridge`, confirming successful token reception. ## Handling Specific Token Logic: A RebaseToken Example Beyond standard token transfers, CCIP messages can carry arbitrary data, enabling complex cross-chain interactions. If your token has specific logic, like the `RebaseToken` in this example which might propagate interest rates, you should test this as well. ```solidity // 14. Check interest rates (specific to RebaseToken logic) // IMPORTANT: localUserInterestRate should be fetched *before* switching to remoteFork // Example: Fetch localUserInterestRate while still on localFork // vm.selectFork(localFork); // uint256 localUserInterestRate = localToken.getUserInterestRate(user); // vm.selectFork(remoteFork); // Switch back if necessary or rely on switchChainAndRouteMessage uint256 remoteUserInterestRate = remoteToken.getUserInterestRate(user); // Called on remoteFork // assertEq(remoteUserInterestRate, localUserInterestRate, "Interest rates do not match"); ``` **Important Correction:** To correctly compare values from both chains, ensure `localUserInterestRate` is fetched while the Foundry VM context (`vm.selectFork`) is set to `localFork`, *before* any operations that switch to `remoteFork` or process the message on the remote side. The assertion would then compare this stored local rate with the rate fetched from the `remoteToken` after message processing. ## Essential Foundry Practices for CCIP Testing Several Foundry features and development practices are key when testing CCIP interactions: * **`vm.prank(user)` vs. `vm.startPrank(user)`/`vm.stopPrank()`:** This lesson utilizes single-line `vm.prank(user)` calls immediately before state-changing operations initiated by the `user` (e.g., `approve`, `ccipSend`). This is preferred over `vm.startPrank`/`vm.stopPrank` blocks in scenarios involving external contract calls, such as those made by `CCIPLocalSimulatorFork`. Using `vm.startPrank` could lead to the pranked sender context being inadvertently reset or altered by these external calls, complicating the test logic. `vm.prank` ensures the desired sender context for only that specific call. * **Resolving "Stack too deep" Errors with `--via-ir`:** If you encounter "Stack too deep" compiler errors in Foundry, especially with complex contracts or many local variables, try building with the `--via-ir` flag: `forge build --via-ir` This flag instructs the Solidity compiler to first translate your code to Yul (an intermediate representation). The Yul optimizer can then perform more advanced optimizations, often resolving stack depth issues by managing stack usage more effectively. For a deeper understanding of Yul, resources like the Cyfrin Updraft course on Assembly & Formal Verification can be beneficial. * **Casting to `address` for Interface Calls:** As seen in `IERC20(address(localToken)).approve(...)`, when you have a contract instance (e.g., `localToken`) and need to invoke a function defined in an interface it implements (like `IERC20`), you typically cast the contract instance to its `address` before wrapping it with the interface. * **Leveraging `CCIPLocalSimulatorFork.sol`:** This helper contract (or a similar one tailored to your project) is indispensable for local CCIP testing. It abstracts away the complexities of simulating CCIP's off-chain components and provides convenient functions like: * `requestLinkFromFaucet(address user, uint256 amount)`: Mints LINK tokens to a user for fee payments. * `switchChainAndRouteMessage(uint256 forkId)`: Simulates message routing and execution on the target `forkId`, including handling `vm.selectFork`. * **Iterative Development and Debugging:** The process often involves writing code, encountering compiler errors (such as typos like `remoteBalanceAfer` instead of `remoteBalanceAfter`, or `IERC` instead of `IERC20`), addressing missing `memory` keywords for struct parameters in function signatures, or fixing incorrect casting (e.g., `IERC20(localToken)` to `IERC20(address(localToken))`), and iteratively refining the test until it passes. **Required Imports:** To implement this test, you'll typically need the following imports: * `Client.sol`: From `@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol` (for `EVM2AnyMessage`, `EVMTokenAmount`, `EVMExtraArgsV1`). * `IRouterClient.sol`: From `@chainlink/contracts-ccip/src/v0.8/ccip/interfaces/IRouterClient.sol` (for `getFee`, `ccipSend`). * `IERC20.sol`: From a standard library like OpenZeppelin (`@openzeppelin/contracts/token/ERC20/IERC20.sol`). * `CCIPLocalSimulatorFork.sol` and `Register.sol`: Project-specific helper contracts for the local testing setup. `Register.sol` likely contains the `NetworkDetails` struct. * `RebaseToken.sol`: The custom token contract being bridged and tested. By following these steps and utilizing the described tools and techniques, you can effectively test cross-chain token transfers involving Chainlink CCIP within your Foundry development environment. Remember to consult the official Chainlink CCIP Documentation for comprehensive guidance on `docs.chain.link/ccip`.
A comprehensive walkthrough to Building a Reusable `bridgeTokens` Test for Chainlink CCIP in Foundry - Discover how to implement a flexible `bridgeTokens` test function in Foundry for validating cross-chain token movements with Chainlink CCIP. This lesson covers `EVM2AnyMessage` creation, fee handling via `CCIPLocalSimulatorFork`, and end-to-end transfer verification.
Previous lesson
Previous
Next lesson
Next
Give us feedback
Course Overview
About the course
Advanced smart contract development
How to develop a stablecoin
How to develop a DeFi protocol
How to develop a DAO
Advanced smart contracts testing
Fuzz testing
Manual verification
Web3 Developer Relations
$85,000 - $125,000 (avg. salary)
Web3 developer
$60,000 - $150,000 (avg. salary)
Smart Contract Engineer
$100,000 - $150,000 (avg. salary)
Smart Contract Auditor
$100,000 - $200,000 (avg. salary)
Security researcher
$49,999 - $120,000 (avg. salary)
Web3 engineer, educator, and Cyfrin co-founder. Patrick's smart contract development and security courses have helped hundreds of thousands of engineers kickstarting their careers into web3.
Guest lecturers:
Last updated on May 20, 2025
Solidity Developer
Advanced FoundryDuration: 36min
Duration: 3h 06min
Duration: 5h 02min
Duration: 6h 02min
Duration: 2h 47min
Duration: 1h 23min
Duration: 4h 28min
Duration: 1h 19min
Duration: 1h 10min
Course Overview
About the course
Advanced smart contract development
How to develop a stablecoin
How to develop a DeFi protocol
How to develop a DAO
Advanced smart contracts testing
Fuzz testing
Manual verification
Web3 Developer Relations
$85,000 - $125,000 (avg. salary)
Web3 developer
$60,000 - $150,000 (avg. salary)
Smart Contract Engineer
$100,000 - $150,000 (avg. salary)
Smart Contract Auditor
$100,000 - $200,000 (avg. salary)
Security researcher
$49,999 - $120,000 (avg. salary)
Web3 engineer, educator, and Cyfrin co-founder. Patrick's smart contract development and security courses have helped hundreds of thousands of engineers kickstarting their careers into web3.
Guest lecturers:
Last updated on May 20, 2025