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.
Burn & Mint Tokens are burned on the source chain and minted natively on the destination chain
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.
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).
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);
}
}
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 src
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);
}
}
Create a new Solidity file by clicking on the "Create new file" button 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
Option 1)
Deploy CCIPTokenSender smart contract by running:
forge create --rpc-url avalancheFuji --private-key=$PRIVATE_KEY src/CCIPTokenSender.sol:CCIPTokenSender --constructor-args 0x0b9d5D9136855f6FEc3c0993feE6E9CE8a297846 0x554472a2720E5E7D5D3C817529aBA05EEd5F82D8
Option 2)
Create a new smart contract under the script
folder and name it CCIPTokenSender.s.sol
// script/CCIPTokenSender.s.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;
import "forge-std/Script.sol";
import {CCIPTokenSender} from "../src/CCIPTokenSender.sol";
contract DeployCCIPTokenSender is Script {
function run() public {
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployerPrivateKey);
address fujiLink = 0x0b9d5D9136855f6FEc3c0993feE6E9CE8a297846;
address fujiRouter = 0x554472a2720E5E7D5D3C817529aBA05EEd5F82D8;
CCIPTokenSender sender = new CCIPTokenSender(
fujiLink,
fujiRouter
);
console.log(
"CCIPTokenSender deployed to ",
address(sender)
);
vm.stopBroadcast();
}
}
Deploy CCIPTokenSender smart contract by running:
forge script ./script/CCIPTokenSender.s.sol:DeployCCIPTokenSender -vvv --broadcast --rpc-url avalancheFuji
Open your Metamask wallet and switch to the Avalanche Fuji network.
Open the CCIPTokenSender.sol
file.
Navigate to the "Solidity Compiler" tab and click the "Compile CCIPTokenSender.sol" button.
Navigate to the "Deploy & run transactions" tab and select the "Injected Provider - Metamask" option from the "Environment" dropdown menu. Make sure that chainId is switched to 43113 (if not, you may need to refresh the Remix IDE page in your browser).
Under the "Contract" dropdown menu, make sure that the "CCIPTokenSender - CCIPTokenSender.sol" is selected. Locate the orange "Deploy" button. Provide 0x0b9d5D9136855f6FEc3c0993feE6E9CE8a297846
as the link address and 0x554472a2720E5E7D5D3C817529aBA05EEd5F82D8
as the router address. Click the orange "Deploy"/"Transact" button.
Metamask notification will pop up. Sign the transaction.
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
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.
First of all, we need to whitelist the destination chain:
cast send <CCIP_TOKEN_SENDER_ADDRESS> --rpc-url avalancheFuji --private-key=$PRIVATE_KEY "whitelistChain(uint64)" 16015286601757825753
Then run:
cast send <CCIP_TOKEN_SENDER_ADDRESS> --rpc-url avalancheFuji --private-key=$PRIVATE_KEY "transferTokens(uint64,address,address,uint256)" 16015286601757825753 <RECEIVER_ADDRESS> 0xD21341536c5cF5EB1bcb58f6723cE26e8D8E90e4 100
Under the "Deployed Contracts" section, you should find the CCIPTokenSender.sol
smart contract you previously deployed to Avalanche Fuji.
First, find the whitelistChain
function and provide:
16015286601757825753, which is the CCIP Chain Selector for the Ethereum Sepolia network, as the
_destinationChainSelector
parameter.
Hit the "Transact" orange button.
Then find the transferTokens
function and provide:
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.
Hit the "Transact" orange button.
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