Transfer Tokens Between Chains
In this tutorial, you will use Chainlink CCIP to transfer tokens from a smart contract to an account on a different blockchain. First, you will pay for the CCIP fees on the source blockchain using LINK. Then, you will use the same contract to pay CCIP fees in native gas tokens. For example, you would use ETH on Ethereum or MATIC on Polygon.
Before you begin
-
You should understand how to write, compile, deploy, and fund a smart contract. If you need to brush up on the basics, read this tutorial, which will guide you through using the Solidity programming language, interacting with the MetaMask wallet and working within the Remix Development Environment.
-
Your account must have some ETH and LINK tokens on Ethereum Sepolia. Learn how to Acquire testnet LINK.
-
Check the Supported Networks page to confirm that the tokens you will transfer are supported for your lane. In this example, you will transfer tokens from Ethereum Sepolia to Polygon Mumbai so check the list of supported tokens here.
-
Learn how to acquire CCIP test tokens. Following this guide, you should have CCIP-BnM tokens, and CCIP-BnM should appear in the list of your tokens in MetaMask.
-
Learn how to fund your contract. This guide shows how to fund your contract in LINK, but you can use the same guide to fund your contract with any ERC20 tokens as long as they appear in the list of tokens in MetaMask.
Tutorial
In this tutorial, you will transfer CCIP-BnM tokens from a contract on Ethereum Sepolia to an account on Polygon Mumbai. First, you will pay CCIP fees in LINK, then you will pay CCIP fees in native gas. The destination account can be an EOA (Externally Owned Account) or a smart contract. Moreover, the example shows how to transfer CCIP-BnM tokens, but you can re-use the same example to transfer other tokens as long as they are supported for your lane.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;
import {IRouterClient} from "@chainlink/contracts-ccip/src/v0.8/ccip/interfaces/IRouterClient.sol";
import {OwnerIsCreator} from "@chainlink/contracts-ccip/src/v0.8/shared/access/OwnerIsCreator.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";
/**
* THIS IS AN EXAMPLE CONTRACT THAT USES HARDCODED VALUES FOR CLARITY.
* THIS IS AN EXAMPLE CONTRACT THAT USES UN-AUDITED CODE.
* DO NOT USE THIS CODE IN PRODUCTION.
*/
/// @title - A simple contract for transferring tokens across chains.
contract TokenTransferor is OwnerIsCreator {
// Custom errors to provide more descriptive revert messages.
error NotEnoughBalance(uint256 currentBalance, uint256 calculatedFees); // Used to make sure contract has enough balance to cover the fees.
error NothingToWithdraw(); // Used when trying to withdraw Ether but there's nothing to withdraw.
error FailedToWithdrawEth(address owner, address target, uint256 value); // Used when the withdrawal of Ether fails.
error DestinationChainNotAllowlisted(uint64 destinationChainSelector); // Used when the destination chain has not been allowlisted by the contract owner.
// Event emitted when the tokens are transferred to an account on another chain.
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.
);
// Mapping to keep track of allowlisted destination chains.
mapping(uint64 => bool) public allowlistedChains;
IRouterClient private s_router;
IERC20 private s_linkToken;
/// @notice Constructor initializes the contract with the router address.
/// @param _router The address of the router contract.
/// @param _link The address of the link contract.
constructor(address _router, address _link) {
s_router = IRouterClient(_router);
s_linkToken = IERC20(_link);
}
/// @dev Modifier that checks if the chain with the given destinationChainSelector is allowlisted.
/// @param _destinationChainSelector The selector of the destination chain.
modifier onlyAllowlistedChain(uint64 _destinationChainSelector) {
if (!allowlistedChains[_destinationChainSelector])
revert DestinationChainNotAllowlisted(_destinationChainSelector);
_;
}
/// @dev Updates the allowlist status of a destination chain for transactions.
/// @notice This function can only be called by the owner.
/// @param _destinationChainSelector The selector of the destination chain to be updated.
/// @param allowed The allowlist status to be set for the destination chain.
function allowlistDestinationChain(
uint64 _destinationChainSelector,
bool allowed
) external onlyOwner {
allowlistedChains[_destinationChainSelector] = allowed;
}
/// @notice Transfer tokens to receiver on the destination chain.
/// @notice pay in LINK.
/// @notice the token must be in the list of supported tokens.
/// @notice This function can only be called by the owner.
/// @dev Assumes your contract has sufficient LINK tokens to pay for the fees.
/// @param _destinationChainSelector The identifier (aka selector) for the destination blockchain.
/// @param _receiver The address of the recipient on the destination blockchain.
/// @param _token token address.
/// @param _amount token amount.
/// @return messageId The ID of the message that was sent.
function transferTokensPayLINK(
uint64 _destinationChainSelector,
address _receiver,
address _token,
uint256 _amount
)
external
onlyOwner
onlyAllowlistedChain(_destinationChainSelector)
returns (bytes32 messageId)
{
// Create an EVM2AnyMessage struct in memory with necessary information for sending a cross-chain message
// address(linkToken) means fees are paid in LINK
Client.EVM2AnyMessage memory evm2AnyMessage = _buildCCIPMessage(
_receiver,
_token,
_amount,
address(s_linkToken)
);
// Get the fee required to send the message
uint256 fees = s_router.getFee(
_destinationChainSelector,
evm2AnyMessage
);
if (fees > s_linkToken.balanceOf(address(this)))
revert NotEnoughBalance(s_linkToken.balanceOf(address(this)), fees);
// approve the Router to transfer LINK tokens on contract's behalf. It will spend the fees in LINK
s_linkToken.approve(address(s_router), fees);
// approve the Router to spend tokens on contract's behalf. It will spend the amount of the given token
IERC20(_token).approve(address(s_router), _amount);
// Send the message through the router and store the returned message ID
messageId = s_router.ccipSend(
_destinationChainSelector,
evm2AnyMessage
);
// Emit an event with message details
emit TokensTransferred(
messageId,
_destinationChainSelector,
_receiver,
_token,
_amount,
address(s_linkToken),
fees
);
// Return the message ID
return messageId;
}
/// @notice Transfer tokens to receiver on the destination chain.
/// @notice Pay in native gas such as ETH on Ethereum or MATIC on Polgon.
/// @notice the token must be in the list of supported tokens.
/// @notice This function can only be called by the owner.
/// @dev Assumes your contract has sufficient native gas like ETH on Ethereum or MATIC on Polygon.
/// @param _destinationChainSelector The identifier (aka selector) for the destination blockchain.
/// @param _receiver The address of the recipient on the destination blockchain.
/// @param _token token address.
/// @param _amount token amount.
/// @return messageId The ID of the message that was sent.
function transferTokensPayNative(
uint64 _destinationChainSelector,
address _receiver,
address _token,
uint256 _amount
)
external
onlyOwner
onlyAllowlistedChain(_destinationChainSelector)
returns (bytes32 messageId)
{
// Create an EVM2AnyMessage struct in memory with necessary information for sending a cross-chain message
// address(0) means fees are paid in native gas
Client.EVM2AnyMessage memory evm2AnyMessage = _buildCCIPMessage(
_receiver,
_token,
_amount,
address(0)
);
// Get the fee required to send the message
uint256 fees = s_router.getFee(
_destinationChainSelector,
evm2AnyMessage
);
if (fees > address(this).balance)
revert NotEnoughBalance(address(this).balance, fees);
// approve the Router to spend tokens on contract's behalf. It will spend the amount of the given token
IERC20(_token).approve(address(s_router), _amount);
// Send the message through the router and store the returned message ID
messageId = s_router.ccipSend{value: fees}(
_destinationChainSelector,
evm2AnyMessage
);
// Emit an event with message details
emit TokensTransferred(
messageId,
_destinationChainSelector,
_receiver,
_token,
_amount,
address(0),
fees
);
// Return the message ID
return messageId;
}
/// @notice Construct a CCIP message.
/// @dev This function will create an EVM2AnyMessage struct with all the necessary information for tokens transfer.
/// @param _receiver The address of the receiver.
/// @param _token The token to be transferred.
/// @param _amount The amount of the token to be transferred.
/// @param _feeTokenAddress The address of the token used for fees. Set address(0) for native gas.
/// @return Client.EVM2AnyMessage Returns an EVM2AnyMessage struct which contains information for sending a CCIP message.
function _buildCCIPMessage(
address _receiver,
address _token,
uint256 _amount,
address _feeTokenAddress
) internal pure returns (Client.EVM2AnyMessage memory) {
// Set the token amounts
Client.EVMTokenAmount[]
memory tokenAmounts = new Client.EVMTokenAmount[](1);
tokenAmounts[0] = Client.EVMTokenAmount({
token: _token,
amount: _amount
});
// Create an EVM2AnyMessage struct in memory with necessary information for sending a cross-chain message
return
Client.EVM2AnyMessage({
receiver: abi.encode(_receiver), // ABI-encoded receiver address
data: "", // No data
tokenAmounts: tokenAmounts, // The amount and type of token being transferred
extraArgs: Client._argsToBytes(
// Additional arguments, setting gas limit to 0 as we are not sending any data and non-strict sequencing mode
Client.EVMExtraArgsV1({gasLimit: 0, strict: false})
),
// Set the feeToken to a feeTokenAddress, indicating specific asset will be used for fees
feeToken: _feeTokenAddress
});
}
/// @notice Fallback function to allow the contract to receive Ether.
/// @dev This function has no function body, making it a default function for receiving Ether.
/// It is automatically called when Ether is transferred to the contract without any data.
receive() external payable {}
/// @notice Allows the contract owner to withdraw the entire balance of Ether from the contract.
/// @dev This function reverts if there are no funds to withdraw or if the transfer fails.
/// It should only be callable by the owner of the contract.
/// @param _beneficiary The address to which the Ether should be transferred.
function withdraw(address _beneficiary) public onlyOwner {
// Retrieve the balance of this contract
uint256 amount = address(this).balance;
// Revert if there is nothing to withdraw
if (amount == 0) revert NothingToWithdraw();
// Attempt to send the funds, capturing the success status and discarding any return data
(bool sent, ) = _beneficiary.call{value: amount}("");
// Revert if the send failed, with information about the attempted transfer
if (!sent) revert FailedToWithdrawEth(msg.sender, _beneficiary, amount);
}
/// @notice Allows the owner of the contract to withdraw all tokens of a specific ERC20 token.
/// @dev This function reverts with a 'NothingToWithdraw' error if there are no tokens to withdraw.
/// @param _beneficiary The address to which the tokens will be sent.
/// @param _token The contract address of the ERC20 token to be withdrawn.
function withdrawToken(
address _beneficiary,
address _token
) public onlyOwner {
// Retrieve the balance of this contract
uint256 amount = IERC20(_token).balanceOf(address(this));
// Revert if there is nothing to withdraw
if (amount == 0) revert NothingToWithdraw();
IERC20(_token).transfer(_beneficiary, amount);
}
}
Deploy your contracts
To use this contract:
-
Compile your contract.
-
Deploy and fund your sender contract on Ethereum Sepolia:
- Open MetaMask and select the network Ethereum Sepolia.
- In Remix IDE, click Deploy & Run Transactions and select Injected Provider - MetaMask from the environment list. Remix will then interact with your MetaMask wallet to communicate with Ethereum Sepolia.
- Fill in your blockchain's router and LINK contract addresses. The router address can be found on the supported networks page and the LINK contract address on the LINK token contracts page. For Ethereum Sepolia, the router address is
0xD0daae2231E9CB96b94C8512223533293C3693Bf
and the LINK contract address is0x779877A7B0D9E8603169DdbD7836e478b4624789
. - Click the transact button. After you confirm the transaction, the contract address appears on the Deployed Contracts list. Note your contract address.
- Open MetaMask and fund your contract with CCIP-BnM tokens. You can transfer
0.002
CCIP-BnM to your contract.
-
Enable your contract to transfer tokens to Polygon Mumbai:
- In Remix IDE, under Deploy & Run Transactions, open the list of functions for your smart contract deployed on Ethereum Sepolia.
- Call the
allowlistDestinationChain
function with12532609583862916517
as the destination chain selector, andtrue
as allowed. Each chain selector is found on the supported networks page.
Transfer tokens and pay in LINK
You will transfer 0.001 CCIP-BnM. The CCIP fees for using CCIP will be paid in LINK. Read this explanation for a detailed description of the code example.
-
Open MetaMask and connect to Ethereum Sepolia. Fund your contract with LINK tokens. You can transfer
0.01
LINK to your contract. Note: The LINK tokens are used to pay for CCIP fees. -
Transfer CCIP-BnM from Ethereum Sepolia:
-
Open MetaMask and select the network Ethereum Sepolia.
-
In Remix IDE, under Deploy & Run Transactions, open the list of transactions of your smart contract deployed on Ethereum Sepolia.
-
Fill in the arguments of the transferTokensPayLINK function:
Argument Value and Description _destinationChainSelector 12532609583862916517
CCIP Chain identifier of the destination blockchain (Polygon Mumbai in this example). You can find each chain selector on the supported networks page._receiver Your account address at Polygon Mumbai.
The destination account address. It could be a smart contract or an EOA._token 0xFd57b4ddBf88a4e07fF4e34C487b99af2Fe82a05
The CCIP-BnM contract address at the source chain (Ethereum Sepolia in this example). You can find all the addresses for each supported blockchain on the supported networks page._amount 1000000000000000
The token amount (0.001 CCIP-BnM). -
Click the transact button and confirm the transaction on MetaMask.
-
Once the transaction is successful, note the transaction hash. Here is an example of a transaction on Ethereum Sepolia.
Note: During gas price spikes, your transaction might fail, requiring more than 0.01 LINK to proceed. If your transaction fails, fund your contract with more LINK tokens and try again.
-
-
Open the CCIP explorer and search your cross-chain transaction using the transaction hash.
-
The CCIP transaction is completed once the status is marked as "Success". The data field is empty because you are only transferring tokens.
-
Check the receiver account on the destination chain:
-
Note the destination transaction hash from the CCIP explorer.
0x7abd502d0cb62bf99f3075ef1f83237de2fd213d09a268eff021957ac1703312
in this example. -
Open the block explorer for your destination chain. For Polygon Mumbai, open polygonscan.
-
Search the transaction hash.
-
Notice in the Tokens Transferred section that CCIP-BnM tokens have been transferred to your account (0.001 CCIP-BnM).
-
Transfer tokens and pay in native
You will transfer 0.001 CCIP-BnM. The CCIP fees for using CCIP will be paid in Sepolia's native ETH. Read this explanation for a detailed description of the code example.
-
Open MetaMask and connect to Ethereum Sepolia. Fund your contract with native gas tokens. You can transfer
0.01
ETH to your contract. Note: The native gas tokens are used to pay for CCIP fees. -
Transfer CCIP-BnM from Ethereum Sepolia:
-
Open MetaMask and select the network Ethereum Sepolia.
-
In Remix IDE, under Deploy & Run Transactions, open the list of transactions of your smart contract deployed on Ethereum Sepolia.
-
Fill in the arguments of the transferTokensPayNative function:
Argument Value and Description _destinationChainSelector 12532609583862916517
CCIP Chain identifier of the destination blockchain (Polygon Mumbai in this example). You can find each chain selector on the supported networks page._receiver Your account address at Polygon Mumbai.
The destination account address. It could be a smart contract or an EOA._token 0xFd57b4ddBf88a4e07fF4e34C487b99af2Fe82a05
The CCIP-BnM contract address at the source chain (Ethereum Sepolia in this example). You can find all the addresses for each supported blockchain on the supported networks page.._amount 1000000000000000
The token amount (0.001 CCIP-BnM). -
Click the transact button and confirm the transaction on MetaMask.
-
Once the transaction is successful, note the transaction hash. Here is an example of a transaction on Ethereum Sepolia.
Note: During gas price spikes, your transaction might fail, requiring more than 0.01 ETH to proceed. If your transaction fails, fund your contract with more ETH and try again.
-
-
Open the CCIP explorer and search your cross-chain transaction using the transaction hash.
-
The CCIP transaction is completed once the status is marked as "Success". The data field is empty because you only transfer tokens. Note that CCIP fees are denominated in LINK. Even if CCIP fees are paid using native gas tokens, node operators will be paid in LINK.
-
Check the receiver account on the destination chain:
-
Note the destination transaction hash from the CCIP explorer.
0xa963f06e370d3eec0d0914646b6d25b6e1f4b767b5a2f5cf66e33e56ae5c4fc2
in this example. -
Open the block explorer for your destination chain. For Polygon Mumbai, open polygonscan.
-
Search the transaction hash.
-
Notice in the Tokens Transferred section that CCIP-BnM tokens have been transferred to your account (0.001 CCIP-BnM).
-
Explanation
The smart contract featured in this tutorial is designed to interact with CCIP to transfer a supported token to an account on a destination chain. The contract code contains supporting comments clarifying the functions, events, and underlying logic. This section further explains initializing the contract and transferring tokens.
Initializing of the contract
When you deploy the contract, you define the router address and LINK contract address of the blockchain where you deploy the contract. The contract uses the router address to interact with the router to estimate the CCIP fees and the transmission of CCIP messages.
Transferring tokens and pay in LINK
The transferTokensPayLINK
function undertakes six primary operations:
-
Call the
_buildCCIPMessage
internal function to construct a CCIP-compatible message using theEVM2AnyMessage
struct:-
The
_receiver
address is encoded in bytes to accommodate non-EVM destination blockchains with distinct address formats. The encoding is achieved through abi.encode. -
The
data
is empty because you only transfer tokens. -
The
tokenAmounts
is an array, with each element comprising aEVMTokenAmount
struct that contains the token address and amount. The array contains one element where the_token
(token address) and_amount
(token amount) are passed by the user when calling thetransferTokensPayNative
function. -
The
extraArgs
specifies thegasLimit
for relaying the message to the recipient contract on the destination blockchain, along with astrict
parameter. In this example, thegasLimit
is set to0
because the contract only transfers tokens and does not expect function calls on the destination blockchain. Thestrict
parameter is set tofalse
. Note: Ifstrict
is true andccipReceive
reverts on the destination blockchain, subsequent messages from the same sender will be blocked by CCIP until the reverted message can be executed. -
The
_feeTokenAddress
designates the token address used for CCIP fees. Here,address(linkToken)
signifies payment in LINK.
-
-
Computes the message fees by invoking the router's
getFee
function. -
Ensures your contract balance in LINK is enough to cover the fees.
-
Grants the router contract permission to deduct the fees from the contract's LINK balance.
-
Grants the router contract permission to deduct the amount from the contract's CCIP-BnM balance.
-
Dispatches the CCIP message to the destination chain by executing the router's
ccipSend
function.
Note: As a security measure, the transferTokensPayLINK
function is protected by the onlyAllowlistedChain
to ensure the contract owner has allowlisted a destination chain.
Transferring tokens and pay in native
The transferTokensPayNative
function undertakes five primary operations:
-
Call the
_buildCCIPMessage
internal function to construct a CCIP-compatible message using theEVM2AnyMessage
struct:-
The
_receiver
address is encoded in bytes to accommodate non-EVM destination blockchains with distinct address formats. The encoding is achieved through abi.encode. -
The
data
is empty because you only transfer tokens. -
The
tokenAmounts
is an array, with each element comprising anEVMTokenAmount
struct containing the token address and amount. The array contains one element where the_token
(token address) and_amount
(token amount) are passed by the user when calling thetransferTokensPayNative
function. -
The
extraArgs
specifies thegasLimit
for relaying the message to the recipient contract on the destination blockchain, along with astrict
parameter. In this example, thegasLimit
is set to0
because the contract only transfers tokens and does not expect function calls on the destination blockchain. Thestrict
parameter is set tofalse
. Note: Ifstrict
is true andccipReceive
reverts on the destination blockchain, subsequent messages from the same sender will be blocked by CCIP until the reverted message can be executed. -
The
_feeTokenAddress
designates the token address used for CCIP fees. Here,address(0)
signifies payment in native gas tokens (ETH).
-
-
Computes the message fees by invoking the router's
getFee
function. -
Ensures your contract balance in native gas is enough to cover the fees.
-
Grants the router contract permission to deduct the amount from the contract's CCIP-BnM balance.
-
Dispatches the CCIP message to the destination chain by executing the router's
ccipSend
function. Note:msg.value
is set because you pay in native gas.
Note: As a security measure, the transferTokensPayNative
function is protected by the onlyAllowlistedChain
, ensuring the contract owner has allowlisted a destination chain.