Page cover image

Exercise #1: Transfer Tokens

Transfer tokens between chains using Chainlink CCIP

Welcome to the first CCIP Masterclass Exercise 🎉

Now that we learned the basics of Chainlink CCIP let's use that knowledge to bridge tokens from one blockchain to another. In exercise number one of this Masterclass, you will use Chainlink CCIP to transfer tokens from a smart contract to an EOA on a different blockchain.

A brief intro to token handling mechanisms

To transfer tokens using CCIP, token pools on both blockchains must exist. That's why, if you remember the first chapter of this Masterclass, we said that you can transfer only supported tokens, not all of them.

We are going to cover Token Pools later in the CCIP Architecture in Depth chapter, but briefly, the CCIP token bridge can support multiple token handling mechanisms at source & destination blockchains. Token handling mechanisms are a key aspect of how token transfers work. They each have different characteristics with trade-offs for issuers, holders, and DeFi applications.

  1. Burn & Mint Tokens are burned on the source chain and minted natively on the destination chain

  2. Lock & Mint (Reverse: Burn & Unlock) Tokens are locked on the source chain (in Token Pools), and wrapped/synthetic/derivative tokens that represent the locked tokens are minted on the destination chain.

  3. Lock & Unlock [ON THE ROADMAP] Transferred tokens are locked on the source chain (in Token Pools) and unlocked from Token Pools on the destination chain. This feature is not live yet.

Faucet

Public faucets sometimes limit how many tokens a user can create and token pools might not have enough liquidity. To resolve these issues, CCIP supports two test tokens that you can mint permissionlessly so you don't run out of tokens while testing different scenarios.

As already said, there are two ERC20 test tokens currently available on each testnet - CCIP-BnM and CCIP-LnM (and its wrapped/synthetic representation, clCCIP-LnM).

Name
Decimals
Type

CCIP-BnM

18

Burn & Mint

CCIP-LnM

18

Lock & Mint (Reverse: Burn & Unlock)

CCIP-BnM

These tokens are minted natively on each testnet because the token contract is deployed on each testnet. When transferring these tokens between testnet blockchains, CCIP burns the tokens on the source chain and mints them on the destination chain.

CCIP-LnM

These tokens are only minted on Ethereum Sepolia because the token contract is only deployed to Sepolia. On other testnet blockchains, the token is represented as a wrapped/synthetic asset called clCCIP-LnM because it is not natively deployed to other testnets. When transferring these tokens from Ethereum Sepolia to another testnet, CCIP locks the CCIP-LnM tokens on the source chain and mints the wrapped representation clCCIP-LnM on the destination chain. Between non-Ethereum Sepolia chains, CCIP burns and mints the wrapped representation clCCIP-LnM.

You can mint both of these tokens using the drip function call on the token contract. This function acts like a faucet. Each call mints 10**18 units of a token to the specified address.

  • For CCIP-BnM, you can call drip on all testnet blockchains.

  • For CCIP-LnM, you can call drip only on Ethereum Sepolia.

The drip function is implemented on these tokens as follows:

function drip(address to) external {
  _mint(to, 1e18);
}

To call this function, you can use Block Explorer like Etherscan, Foundry's cast send command, and more... But the easiest way to get those tokens is probably through the Official Chainlink Documentation. Navigate to the linked documentation page and connect your wallet by clicking the "Connect Wallet" button.

Once connected, switch to Avalanche Fuji Testnet and mint 1 CCIP-BnM token. You should also add the CCIP-BnM token to your MetaMask using the button.

Develop CCIPTokenSender.sol

In this exercise, you will transfer CCIP-BnM tokens from a smart contract on Avalanche Fuji to an Externally Owned Account on Ethereum Sepolia. Obviously, you can reuse this contract for different lanes and for transferring other supported tokens as well.

You should already have @chianlink/contracts and @chainlink/contracts-ccip NPM packages installed. If not, please install them by referring to The @chainlink/contracts-ccip NPM package and Develop CCIP Sender contract subchapters.

Create a new file inside the contracts folder and name it CCIPTokenSender.sol

Start with the development by setting the Solidity compiler version and importing necessary contracts from the @chainlink/contracts-ccip NPM package.

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

import {OwnerIsCreator} from "@chainlink/contracts-ccip/src/v0.8/shared/access/OwnerIsCreator.sol";
import {IRouterClient} from "@chainlink/contracts-ccip/src/v0.8/ccip/interfaces/IRouterClient.sol";
import {Client} from "@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol";
import {IERC20} from "@chainlink/contracts-ccip/src/v0.8/vendor/openzeppelin-solidity/v4.8.0/token/ERC20/IERC20.sol";
import {LinkTokenInterface} from "@chainlink/contracts/src/v0.8/interfaces/LinkTokenInterface.sol";

contract CCIPTokenSender {}

Now let's add storage variables for the Router.sol smart contract address and theLINK token, which we will use for fees. Also, we imported the OwnerIsCreator smart contract because later, we will want to allow onlyOwner to withdraw tokens from this contract (both CCIP-BnM & LINK).

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

import {OwnerIsCreator} from "@chainlink/contracts-ccip/src/v0.8/shared/access/OwnerIsCreator.sol";
import {IRouterClient} from "@chainlink/contracts-ccip/src/v0.8/ccip/interfaces/IRouterClient.sol";
import {Client} from "@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol";
import {IERC20} from "@chainlink/contracts-ccip/src/v0.8/vendor/openzeppelin-solidity/v4.8.0/token/ERC20/IERC20.sol";
import {LinkTokenInterface} from "@chainlink/contracts/src/v0.8/interfaces/LinkTokenInterface.sol";

contract CCIPTokenSender is OwnerIsCreator {
    IRouterClient router;
    LinkTokenInterface linkToken;

    constructor(address _router, address _link) {
        router = IRouterClient(_router);
        linkToken = LinkTokenInterface(_link);
    }
}

Let's develop a function for sending tokens

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

import {OwnerIsCreator} from "@chainlink/contracts-ccip/src/v0.8/shared/access/OwnerIsCreator.sol";
import {IRouterClient} from "@chainlink/contracts-ccip/src/v0.8/ccip/interfaces/IRouterClient.sol";
import {Client} from "@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol";
import {IERC20} from "@chainlink/contracts-ccip/src/v0.8/vendor/openzeppelin-solidity/v4.8.0/token/ERC20/IERC20.sol";
import {LinkTokenInterface} from "@chainlink/contracts/src/v0.8/interfaces/LinkTokenInterface.sol";

contract CCIPTokenSender is OwnerIsCreator {
    IRouterClient router;
    LinkTokenInterface linkToken;
    
    error NotEnoughBalance(uint256 currentBalance, uint256 calculatedFees); 
    
    event TokensTransferred(
        bytes32 indexed messageId, // The unique ID of the message.
        uint64 indexed destinationChainSelector, // The chain selector of the destination chain.
        address receiver, // The address of the receiver on the destination chain.
        address token, // The token address that was transferred.
        uint256 tokenAmount, // The token amount that was transferred.
        address feeToken, // the token address used to pay CCIP fees.
        uint256 fees // The fees paid for sending the message.
    );

    constructor(address _router, address _link) {
        router = IRouterClient(_router);
        linkToken = LinkTokenInterface(_link);
    }
    
    function transferTokens(
        uint64 _destinationChainSelector,
        address _receiver,
        address _token,
        uint256 _amount
    ) 
        external
        returns (bytes32 messageId) 
    {
        Client.EVMTokenAmount[]
            memory tokenAmounts = new Client.EVMTokenAmount[](1);
        Client.EVMTokenAmount memory tokenAmount = Client.EVMTokenAmount({
            token: _token,
            amount: _amount
        });
        tokenAmounts[0] = tokenAmount;
        
        // Build the CCIP Message
        Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({
            receiver: abi.encode(_receiver),
            data: "",
            tokenAmounts: tokenAmounts,
            extraArgs: Client._argsToBytes(
                Client.EVMExtraArgsV1({gasLimit: 0, strict: false})
            ),
            feeToken: address(linkToken)
        });
        
        // CCIP Fees Management
        uint256 fees = router.getFee(_destinationChainSelector, message);

        if (fees > linkToken.balanceOf(address(this)))
            revert NotEnoughBalance(linkToken.balanceOf(address(this)), fees);

        linkToken.approve(address(router), fees);
        
        // Approve Router to spend CCIP-BnM tokens we send
        IERC20(_token).approve(address(router), _amount);
        
        // Send CCIP Message
        messageId = router.ccipSend(_destinationChainSelector, message); 
        
        emit TokensTransferred(
            messageId,
            _destinationChainSelector,
            _receiver,
            _token,
            _amount,
            address(linkToken),
            fees
        );   
    }
}

Before calling the Router's ccipSend function, ensure that your code allows users to send CCIP messages to trusted destination chains. We will also restrict that only Owner can transfer tokens from this contract.

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

import {OwnerIsCreator} from "@chainlink/contracts-ccip/src/v0.8/shared/access/OwnerIsCreator.sol";
import {IRouterClient} from "@chainlink/contracts-ccip/src/v0.8/ccip/interfaces/IRouterClient.sol";
import {Client} from "@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol";
import {IERC20} from "@chainlink/contracts-ccip/src/v0.8/vendor/openzeppelin-solidity/v4.8.0/token/ERC20/IERC20.sol";
import {LinkTokenInterface} from "@chainlink/contracts/src/v0.8/interfaces/LinkTokenInterface.sol";

contract CCIPTokenSender is OwnerIsCreator {
    IRouterClient router;
    LinkTokenInterface linkToken;
    
    mapping(uint64 => bool) public whitelistedChains;
    
    error NotEnoughBalance(uint256 currentBalance, uint256 calculatedFees); 
    error DestinationChainNotWhitelisted(uint64 destinationChainSelector);
    
    event TokensTransferred(
        bytes32 indexed messageId, // The unique ID of the message.
        uint64 indexed destinationChainSelector, // The chain selector of the destination chain.
        address receiver, // The address of the receiver on the destination chain.
        address token, // The token address that was transferred.
        uint256 tokenAmount, // The token amount that was transferred.
        address feeToken, // the token address used to pay CCIP fees.
        uint256 fees // The fees paid for sending the message.
    );
    
    modifier onlyWhitelistedChain(uint64 _destinationChainSelector) {
        if (!whitelistedChains[_destinationChainSelector])
            revert DestinationChainNotWhitelisted(_destinationChainSelector);
        _;
    }

    constructor(address _router, address _link) {
        router = IRouterClient(_router);
        linkToken = LinkTokenInterface(_link);
    }
   
    function whitelistChain(
        uint64 _destinationChainSelector
    ) external onlyOwner {
        whitelistedChains[_destinationChainSelector] = true;
    }

    function denylistChain(
        uint64 _destinationChainSelector
    ) external onlyOwner {
        whitelistedChains[_destinationChainSelector] = false;
    }
    
    function transferTokens(
        uint64 _destinationChainSelector,
        address _receiver,
        address _token,
        uint256 _amount
    ) 
        external
        onlyOwner
        onlyWhitelistedChain(_destinationChainSelector)
        returns (bytes32 messageId) 
    {
        Client.EVMTokenAmount[]
            memory tokenAmounts = new Client.EVMTokenAmount[](1);
        Client.EVMTokenAmount memory tokenAmount = Client.EVMTokenAmount({
            token: _token,
            amount: _amount
        });
        tokenAmounts[0] = tokenAmount;
        
        // Build the CCIP Message
        Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({
            receiver: abi.encode(_receiver),
            data: "",
            tokenAmounts: tokenAmounts,
            extraArgs: Client._argsToBytes(
                Client.EVMExtraArgsV1({gasLimit: 0, strict: false})
            ),
            feeToken: address(linkToken)
        });
        
        // CCIP Fees Management
        uint256 fees = router.getFee(_destinationChainSelector, message);

        if (fees > linkToken.balanceOf(address(this)))
            revert NotEnoughBalance(linkToken.balanceOf(address(this)), fees);

        linkToken.approve(address(router), fees);
        
        // Approve Router to spend CCIP-BnM tokens we send
        IERC20(_token).approve(address(router), _amount);
        
        // Send CCIP Message
        messageId = router.ccipSend(_destinationChainSelector, message); 
        
        emit TokensTransferred(
            messageId,
            _destinationChainSelector,
            _receiver,
            _token,
            _amount,
            address(linkToken),
            fees
        );   
    }
}

Finally, let's develop a function to withdraw tokens from this contract.

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

import {OwnerIsCreator} from "@chainlink/contracts-ccip/src/v0.8/shared/access/OwnerIsCreator.sol";
import {IRouterClient} from "@chainlink/contracts-ccip/src/v0.8/ccip/interfaces/IRouterClient.sol";
import {Client} from "@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol";
import {IERC20} from "@chainlink/contracts-ccip/src/v0.8/vendor/openzeppelin-solidity/v4.8.0/token/ERC20/IERC20.sol";
import {LinkTokenInterface} from "@chainlink/contracts/src/v0.8/interfaces/LinkTokenInterface.sol";

contract CCIPTokenSender is OwnerIsCreator {
    IRouterClient router;
    LinkTokenInterface linkToken;
    
    mapping(uint64 => bool) public whitelistedChains;
    
    error NotEnoughBalance(uint256 currentBalance, uint256 calculatedFees); 
    error DestinationChainNotWhitelisted(uint64 destinationChainSelector);
    error NothingToWithdraw();
    
    event TokensTransferred(
        bytes32 indexed messageId, // The unique ID of the message.
        uint64 indexed destinationChainSelector, // The chain selector of the destination chain.
        address receiver, // The address of the receiver on the destination chain.
        address token, // The token address that was transferred.
        uint256 tokenAmount, // The token amount that was transferred.
        address feeToken, // the token address used to pay CCIP fees.
        uint256 fees // The fees paid for sending the message.
    );
    
    modifier onlyWhitelistedChain(uint64 _destinationChainSelector) {
        if (!whitelistedChains[_destinationChainSelector])
            revert DestinationChainNotWhitelisted(_destinationChainSelector);
        _;
    }

    constructor(address _router, address _link) {
        router = IRouterClient(_router);
        linkToken = LinkTokenInterface(_link);
    }
   
    function whitelistChain(
        uint64 _destinationChainSelector
    ) external onlyOwner {
        whitelistedChains[_destinationChainSelector] = true;
    }

    function denylistChain(
        uint64 _destinationChainSelector
    ) external onlyOwner {
        whitelistedChains[_destinationChainSelector] = false;
    }
    
    function transferTokens(
        uint64 _destinationChainSelector,
        address _receiver,
        address _token,
        uint256 _amount
    ) 
        external
        onlyOwner
        onlyWhitelistedChain(_destinationChainSelector)
        returns (bytes32 messageId) 
    {
        Client.EVMTokenAmount[]
            memory tokenAmounts = new Client.EVMTokenAmount[](1);
        Client.EVMTokenAmount memory tokenAmount = Client.EVMTokenAmount({
            token: _token,
            amount: _amount
        });
        tokenAmounts[0] = tokenAmount;
        
        // Build the CCIP Message
        Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({
            receiver: abi.encode(_receiver),
            data: "",
            tokenAmounts: tokenAmounts,
            extraArgs: Client._argsToBytes(
                Client.EVMExtraArgsV1({gasLimit: 0, strict: false})
            ),
            feeToken: address(linkToken)
        });
        
        // CCIP Fees Management
        uint256 fees = router.getFee(_destinationChainSelector, message);

        if (fees > linkToken.balanceOf(address(this)))
            revert NotEnoughBalance(linkToken.balanceOf(address(this)), fees);

        linkToken.approve(address(router), fees);
        
        // Approve Router to spend CCIP-BnM tokens we send
        IERC20(_token).approve(address(router), _amount);
        
        // Send CCIP Message
        messageId = router.ccipSend(_destinationChainSelector, message); 
        
        emit TokensTransferred(
            messageId,
            _destinationChainSelector,
            _receiver,
            _token,
            _amount,
            address(linkToken),
            fees
        );   
    }
    
    function withdrawToken(
        address _beneficiary,
        address _token
    ) public onlyOwner {
        uint256 amount = IERC20(_token).balanceOf(address(this));
        
        if (amount == 0) revert NothingToWithdraw();
        
        IERC20(_token).transfer(_beneficiary, amount);
    }
}

Deploy CCIPTokenSender.sol

Before we begin with this section, make sure your environment variables are set. Check the Prepare for Deployment subchapter for reference.

Using your development environment of choice, deploy the CCIPTokenSender smart contract we just developed to the Avalanche Fuji testnet.

Create a new file under the scripts folder and name it deployTokenSender.ts or deployTokenSender.js depends on whether you work with TypeScript or JavaScript Hardhat projects.

// scripts/deployTokenSender.ts

import { ethers, network, run } from "hardhat";

async function main() {
  if(network.name !== `avalancheFuji`) {
    console.error(`❌ Sender must be deployed to Avalanche Fuji`);
    return 1;
  }

  const fujiLinkAddress = `0x0b9d5D9136855f6FEc3c0993feE6E9CE8a297846`;
  const fujiRouterAddress = `0x554472a2720E5E7D5D3C817529aBA05EEd5F82D8`;
  
  await run("compile");

  const ccipTokenSenderFactory = await ethers.getContractFactory("CCIPTokenSender");
  const ccipTokenSender = await ccipTokenSenderFactory.deploy(fujiLinkAddress, fujiRouterAddress);

  await ccipTokenSender.deployed();

  console.log(`CCIPTokenSender deployed to ${ccipTokenSender.address}`);
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Deploy CCIPTokenSender smart contract by running:

npx hardhat run ./scripts/deployTokenSender.ts --network avalancheFuji

or for JavaScript:

npx hardhat run ./scripts/deployTokenSender.js --network avalancheFuji

Fund CCIPTokenSender.sol smart contract

Let's now fund our Token Sender smart contract with both CCIP-BnM and LINK tokens. We are transferring CCIP-BnM tokens to Ethereum Sepolia, and we will use LINK tokens for fees.

Using your wallet of choice, send to previously deployed CCIPTokenSender smart contract:

  • 1 LINK

  • 0.1 CCIP-BnM

Transfer CCIP-BnM tokens

Let's transfer 100 sub-units (0.0000000000000001 tokens) of CCIP-BnM tokens to some EOA using Chainlink CCIP.

Prepare:

  • The address of the EOA to send tokens to, as the _receiver parameter;

  • 0xD21341536c5cF5EB1bcb58f6723cE26e8D8E90e4, which is the CCIP-BnM token address on the Avalanche Fuji network, as the _token parameter;

  • 100 as the _amount parameter;

  • 16015286601757825753, which is the CCIP Chain Selector for the Ethereum Sepolia network, as the _destinationChainSelector parameter.

Create a new JavaScript/TypeScript file under the scripts folder and name it transferTokens.js/transferTokens.ts

// scripts/transferTokens.ts

import { ethers, network } from "hardhat";

async function main() {
  if(network.name !== `avalancheFuji`) {
    console.error(`❌ Must be called from Avalanche Fuji`);
    return 1;
  }

  const receiver = `PUT YOUR EOA ADDRESS HERE`;
  const ccipBnMAddress = `0xD21341536c5cF5EB1bcb58f6723cE26e8D8E90e4`;
  const amount = 100n;
  const destinationChainSelector = 16015286601757825753;

  const ccipTokenSenderFactory = await ethers.getContractFactory("CCIPTokenSender");
  const ccipTokenSender = await ccipTokenSenderFactory.connect(ccipSenderAddress, ethers.provider);
  
  const whitelistTx = await ccipTokenSender.whitelistChain(
      destinationChainSelector
  );
  
  console.log(`Whitelisted Sepolia, transaction hash: ${whitelistTx.hash}`);

  const transferTx = await ccipTokenSender.transferTokens(
      destinationChainSelector, 
      receiver,
      ccipBnMAddress,
      amount
  );

  console.log(`Tokens sent, transaction hash: ${transferTx.hash}`);
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Transfer CCIP-BnM by running the following command:

npx hardhat run ./scripts/transferTokens.ts --network avalancheFuji

Or for JavaScript:

npx hardhat run ./scripts/transferTokens.js --network avalancheFuji

You can now monitor live the status of your CCIP Cross-Chain Message via CCIP Explorer. Just paste the transaction hash into the search bar and open the message details.

Last updated