Sending messages and tokens from Base to other chains using Chainlink CCIP
This tutorial will guide you through the process of sending messages and tokens from a Base smart contract to another smart contract on a different chain using Chainlink's Cross-chain Interoperability Protocol (CCIP).
Objectives
By the end of this tutorial you should be able to do the following:
- Set up a smart contract project for Base using Foundry
- Install Chainlink CCIP as a dependency
- Use Chainlink CCIP within your smart contract to send messages and/or tokens to contracts on other different chains
- Deploy and test your smart contracts on Base testnet
Chainlink CCIP is in an “Early Access” development stage, meaning some of the functionality described within this tutorial is under development and may change in later versions.
Prerequisites
Foundry
This tutorial requires you to have Foundry installed.
- From the command-line (terminal), run:
curl -L https://foundry.paradigm.xyz | bash
- Then run
foundryup
, to install the latest (nightly) build of Foundry
For more information, see the Foundry Book installation guide.
Coinbase Wallet
In order to deploy a smart contract, you will first need a wallet. You can create a wallet by downloading the Coinbase Wallet browser extension.
- Download Coinbase Wallet
Wallet funds
For this tutorial you will need to fund your wallet with both ETH and LINK on Base Goerli and Optimism Goerli.
The ETH is required for covering gas fees associated with deploying smart contracts to the blockchain, and the LINK token is required to pay for associated fees when using CCIP.
- To fund your wallet with ETH on Base Goerli, visit a faucet listed on the Base Faucets page.
- To fund your wallet with ETH on Optimism Goerli, visit a faucet listed on the Optimism Faucets page.
- To fund your wallet with LINK, visit the Chainlink Faucet.
If you are interested in building on Mainnet, you will need to apply for Chainlink CCIP mainnet access.
What is Chainlink CCIP?
Chainlink CCIP (Cross-chain Interoperability Protocol) provides a solution for sending message data and transferring tokens across different chains.
The primary way for users to interface with Chainlink CCIP is through smart contracts known as Routers. A Router contract is responsible for initiating cross-chain interactions.
Users can interact with Routers to perform the following cross-chain capabilities:
Capability | Description | Supported receivers |
---|---|---|
Arbitrary messaging | Send arbitrary (encoded) data from one chain to another. | Smart contracts only |
Token transfers | Send tokens from one chain to another. | Smart contracts or EOAs |
Programmable token transfers | Send tokens and arbitrary (encoded) data from one chain to another, in a single transaction. | Smart contracts only |
Externally owned accounts (EOAs) on EVM blockchains are unable to receive message data, because of this, only smart contracts are supported as receivers when sending arbitrary messages or programmable token transfers. Any attempt to send a programmable token transfer (data and tokens) to an EOA, will result in only the tokens being received.
High-level concepts
Although Routers are the primary interface users will interact with when using CCIP, this section will cover what happens after instructions for a cross-chain interaction are sent to a Router.
OnRamps
Once a Router receives an instruction for a cross-chain interaction, it passes it on to another contract known as an OnRamp. OnRamps are responsible for a variety of tasks, including: verifying message size and gas limits, preserving the sequencing of messages, managing any fee payments, and interacting with the token pool to lock
or burn
tokens if a token transfer is being made.
OffRamps
The destination chain will have a contract known as an OffRamp. OffRamps are responsible for a variety of tasks, including: ensuring the authenticity of a message, making sure each transaction is only executed once, and transmitting received messages to the Router contract on the destination chain.
Token pools
A token pool is an abstraction layer over ERC-20 tokens that facilitates OnRamp and OffRamp token-related operations. They are configured to use either a Lock and Unlock
or Burn and Mint
mechanism, depending on the type of token.
For example, because blockchain-native gas tokens (i.e. ETH, MATIC, AVAX) can only be minted on their native chains, a Lock and Mint
mechanism must be used. This mechanism locks the token at the source chain, and mints a synthetic asset on the destination chain.
In contrast, tokens that can be minted on multiple chains (i.e. USDC, USDT, FRAX, etc.), token pools can use a Burn and Mint
mechanism, where the token is burnt on the source chain and minted on the destination chain.
Risk Management Network
Between instructions for a cross-chain interaction making its way from an OnRamp on the source chain to an OffRamp on the destination chain, it will pass through the Risk Management Network.
The Risk Management Network is a secondary validation service built using a variety of offchain and onchain components, with the responsibilities of monitoring all chains against abnormal activities.
A deep-dive on the technical details of each of these components is too much to cover in this tutorial, but if interested you can learn more by visiting the Chainlink documentation.
Creating a project
Before you begin, you need to set up your smart contract development environment. You can setup a development environment using tools like Hardhat or Foundry. For this tutorial you will use Foundry.
To create a new Foundry project, first create a new directory:
mkdir myproject
Then run:
cd myproject
forge init
This will create a Foundry project with the following basic layout:
.
├── foundry.toml
├── script
├── src
└── test
You can delete the src/Counter.sol
, test/Counter.t.sol
, and script/Counter.s.sol
boilerplate files that were generated with the project, as you will not be needing them.
Installing Chainlink smart contracts
To use Chainlink CCIP within your Foundry project, you need to install Chainlink CCIP smart contracts as a project dependency using forge install
.
To install Chainlink CCIP smart contracts, run:
forge install smartcontractkit/ccip --no-commit
Once installed, update your foundry.toml
file by appending the following line:
remappings = ['@chainlink/contracts-ccip/=lib/ccip/contracts']
Writing the smart contracts
The most basic use case for Chainlink CCIP is to send data and/or tokens between smart contracts on different blockchains.
To accomplish this, in this tutorial, you will need to create two separate smart contracts:
Sender
contract: A smart contract that interacts with CCIP to send data and tokens.Receiver
contract: A smart contract that interacts with CCIP to receive data and tokens.
Creating a Sender contract
The code snippet below is for a basic smart contract that uses CCIP to send data:
pragma solidity ^0.8.0;
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.3/contracts/token/ERC20/IERC20.sol";
contract Sender is OwnerIsCreator {
IRouterClient private router;
IERC20 private linkToken;
/// @notice Initializes the contract with the router and LINK token address.
/// @param _router The address of the router contract.
/// @param _link The address of the link contract.
constructor(address _router, address _link) {
router = IRouterClient(_router);
linkToken = IERC20(_link);
}
/// @notice Sends data to receiver on the destination chain.
/// @param destinationChainSelector The identifier (aka selector) for the destination blockchain.
/// @param receiver The address of the recipient on the destination blockchain.
/// @param text The string text to be sent.
/// @return messageId The ID of the message that was sent.
function sendMessage(
uint64 destinationChainSelector,
address receiver,
string calldata text
) external onlyOwner returns (bytes32 messageId) {
Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({
receiver: abi.encode(receiver), // Encode receiver address
data: abi.encode(text), // Encode text to send
tokenAmounts: new Client.EVMTokenAmount[](0), // Empty array indicating no tokens are being sent
extraArgs: Client._argsToBytes(
Client.EVMExtraArgsV1({gasLimit: 200_000}) // Set gas limit
),
feeToken: address(linkToken) // Set the LINK as the feeToken address
});
// Get the fee required to send the message
uint256 fees = router.getFee(
destinationChainSelector,
message
);
// Revert if contract does not have enough LINK tokens for sending a message
require(linkToken.balanceOf(address(this)) > fees, "Not enough LINK balance");
// Approve the Router to transfer LINK tokens on contract's behalf in order to pay for fees in LINK
linkToken.approve(address(router), fees);
// Send the message through the router
messageId = router.ccipSend(destinationChainSelector, message);
// Return the messageId
return messageId;
}
}
Create a new file under your project's src/
directory named Sender.sol
and copy the code above into the file.
Code walkthrough
The sections below provide a detailed explanation for the code for the Sender
contract provided above.
Initializing the contract
In order to send data using CCIP, the Sender
contract will need access to the following dependencies:
- The
Router
contract: This contract serves as the primary interface when using CCIP to send and receive messages and tokens. - The fee token contract: This contract serves as the contract for the token that will be used to pay fees when sending messages and tokens. For this tutorial, the contract address for LINK token is used.
The Router
contract address and LINK token address are passed in as parameters to the contract's constructor and stored as member variables for later for sending messages and paying any associated fees.
contract Sender is OwnerIsCreator {
IRouterClient private router;
IERC20 private linkToken;
/// @notice Initializes the contract with the router and LINK token address.
/// @param _router The address of the router contract.
/// @param _link The address of the link contract.
constructor(address _router, address _link) {
router = IRouterClient(_router);
linkToken = IERC20(_link);
}
The Router
contract provides two important methods that can be used when sending messages using CCIP:
getFee
: Given a chain selector and message, returns the fee amount required to send the message.ccipSend
: Given a chain selector and message, sends the message through the router and returns an associated message ID.
The next section describes how these methods are utilized to send a message to another chain.
Sending a message
The Sender
contract defines a custom method named sendMessage
that utilizes the methods described above in order to:
- Construct a message using the
EVM2AnyMessage
method provided by theClient
CCIP library, using the following data:receiver
: The receiver contract address (encoded).data
: The text data to send with the message (encoded).tokenAmounts
: The amount of tokens to send with the message. For sending just an arbitrary message this field is defined as an empty array (new Client.EVMTokenAmount[](0)
), indicating that no tokens will be sent.extraArgs
: Extra arguments associated with the message, such asgasLimit
.feeToken
: Theaddress
of the token to be used for paying fees.
- Get the fees required to send the message using the
getFee
method provided by theRouter
contract. - Check that the contract holds an adequate amount of tokens to cover the fee. If not, revert the transaction.
- Approve the
Router
contract to transfer tokens on theSender
contracts behalf in order to cover the fees. - Send the message to a specified chain using the
Router
contract'sccipSend
method. - Return a unique ID associated with the sent message.
/// @param receiver The address of the recipient on the destination blockchain.
/// @param text The string text to be sent.
/// @return messageId The ID of the message that was sent.
function sendMessage(
uint64 destinationChainSelector,
address receiver,
string calldata text
) external onlyOwner returns (bytes32 messageId) {
Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({
receiver: abi.encode(receiver), // Encode receiver address
data: abi.encode(text), // Encode text to send
tokenAmounts: new Client.EVMTokenAmount[](0), // Empty array indicating no tokens are being sent
extraArgs: Client._argsToBytes(
Client.EVMExtraArgsV1({gasLimit: 200_000}) // Set gas limit
),
feeToken: address(linkToken) // Set the LINK as the feeToken address
});
// Get the fee required to send the message
uint256 fees = router.getFee(
destinationChainSelector,
message
);
// Revert if contract does not have enough LINK tokens for sending a message
require(linkToken.balanceOf(address(this)) > fees, "Not enough LINK balance");
// Approve the Router to transfer LINK tokens on contract's behalf in order to pay for fees in LINK
linkToken.approve(address(router), fees);
// Send the message through the router
messageId = router.ccipSend(destinationChainSelector, message);
// Return the messageId
return messageId;
}
Creating a Receiver contract
The code snippet below is for a basic smart contract that uses CCIP to receive data:
pragma solidity ^0.8.0;
import {Client} from "@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol";
import {CCIPReceiver} from "@chainlink/contracts-ccip/src/v0.8/ccip/applications/CCIPReceiver.sol";
contract Receiver is CCIPReceiver {
bytes32 private _messageId;
string private _text;
/// @notice Constructor - Initializes the contract with the router address.
/// @param router The address of the router contract.
constructor(address router) CCIPReceiver(router) {}
/// @notice Handle a received message
/// @param message The cross-chain message being received.
function _ccipReceive(
Client.Any2EVMMessage memory message
) internal override {
_messageId = message.messageId; // Store the messageId
_text = abi.decode(message.data, (string)); // Decode and store the message text
}
/// @notice Gets the last received message.
/// @return messageId The ID of the last received message.
/// @return text The last received text.
function getMessage()
external
view
returns (bytes32 messageId, string memory text)
{
return (_messageId, _text);
}
}
Create a new file under your project’s src/
directory named Receiver.sol
and copy the code above into the file.
Code walkthrough
The sections below provide a detailed explanation for the code for the Receiver
contract provided above.
Initializing the contract
In order to receive data using CCIP, the Receiver
contract will need to extend to theCCIPReceiver
interface. Extending this interface allows the Receiver
contract to initialize the contract with the router address from the constructor, as seen below:
import {CCIPReceiver} from "@chainlink/contracts-ccip/src/v0.8/ccip/applications/CCIPReceiver.sol";
contract Receiver is CCIPReceiver {
/// @notice Constructor - Initializes the contract with the router address.
/// @param router The address of the router contract.
constructor(address router) CCIPReceiver(router) {}
}
Receiving a message
Extending the CCIPReceiver
interface also allows the Receiver
contract to override the _ccipReceive
handler method for when a message is received and define custom logic.
/// @notice Handle a received message
/// @param message The cross-chain message being received.
function _ccipReceive(
Client.Any2EVMMessage memory message
) internal override {
// Add custom logic here
}
The Receiver
contract in this tutorial provides custom logic that stores the messageId
and text
(decoded) as member variables.
contract Receiver is CCIPReceiver {
bytes32 private _messageId;
string private _text;
/// @notice Constructor - Initializes the contract with the router address.
/// @param router The address of the router contract.
constructor(address router) CCIPReceiver(router) {}
/// @notice Handle a received message
/// @param message The cross-chain message being received.
function _ccipReceive(
Client.Any2EVMMessage memory message
) internal override {
_messageId = message.messageId; // Store the messageId
_text = abi.decode(message.data, (string)); // Decode and store the message text
}
}
Retrieving a message
The Receiver
contract defines a custom method named getMessage
that returns the details of the last received message _messagId
and _text
. This method can be called to fetch the message data details after the _ccipReceive
receives a new message.
/// @notice Gets the last received message.
/// @return messageId The ID of the last received message.
/// @return text The last received text.
function getMessage()
external
view
returns (bytes32 messageId, string memory text)
{
return (_messageId, _text);
}
Compiling the smart contracts
To compile your smart contracts, run:
forge build
Deploying the smart contract
Setting up your wallet as the deployer
Before you can deploy your smart contract to the Base network, you will need to set up a wallet to be used as the deployer.
To do so, you can use the cast wallet import
command to import the private key of the wallet into Foundry's securely encrypted keystore:
cast wallet import deployer --interactive
After running the command above, you will be prompted to enter your private key, as well as a password for signing transactions.
For instructions on how to get your private key from Coinbase Wallet, visit the Coinbase Wallet documentation. It is critical that you do NOT commit this to a public repo.
To confirm that the wallet was imported as the deployer
account in your Foundry project, run:
cast wallet list
Setting up environment variables
To setup your environment, create an .env
file in the home directory of your project, and add the RPC URLs, CCIP chain selectors, CCIP router addresses, and LINK token addresses for both Base Goerli and Optimism Goerli testnets:
BASE_GOERLI_RPC="https://goerli.base.org"
OPTIMISM_GOERLI_RPC="https://goerli.optimism.io"
BASE_GOERLI_CHAIN_SELECTOR=5790810961207155433
OPTIMISM_GOERLI_CHAIN_SELECTOR=2664363617261496610
BASE_GOERLI_ROUTER_ADDRESS="0x80AF2F44ed0469018922c9F483dc5A909862fdc2"
OPTIMISM_GOERLI_ROUTER_ADDRESS="0xcc5a0B910D9E9504A7561934bed294c51285a78D"
BASE_GOERLI_LINK_ADDRESS="0x6D0F8D488B669aa9BA2D0f0b7B75a88bf5051CD3"
OPTIMISM_GOERLI_LINK_ADDRESS="0xdc2CC710e42857672E7907CF474a69B63B93089f"
Once the .env
file has been created, run the following command to load the environment variables in the current command line session:
source .env
Deploying the smart contracts
With your contracts compiled and environment setup, you are ready to deploy the smart contracts.
To deploy a smart contract using Foundry, you can use the forge create
command. The command requires you to specify the smart contract you want to deploy, an RPC URL of the network you want to deploy to, and the account you want to deploy with.
Your wallet must be funded with ETH on the Base Goerli and Optimism Goerli to cover the gas fees associated with the smart contract deployment. Otherwise, the deployment will fail.
To get testnet ETH for Base Goerli and Optimism Goerli, see the prerequisites.
Deploying the Sender contract to Base Goerli
To deploy the Sender
smart contract to the Base Goerli testnet, run the following command:
forge create ./src/Sender.sol:Sender --rpc-url $BASE_GOERLI_RPC --constructor-args $BASE_GOERLI_ROUTER_ADDRESS $BASE_GOERLI_LINK_ADDRESS --account deployer
When prompted, enter the password that you set earlier, when you imported your wallet's private key.
After running the command above, the contract will be deployed on the Base Goerli test network. You can view the deployment status and contract by using a block explorer.
Deploying the Receiver contract to Optimism Goerli
To deploy the Receiver
smart contract to the Optimism Goerli testnet, run the following command:
forge create ./src/Receiver.sol:Receiver --rpc-url $OPTIMISM_GOERLI_RPC --constructor-args $OPTIMISM_GOERLI_ROUTER_ADDRESS --account deployer
When prompted, enter the password that you set earlier, when you imported your wallet's private key.
After running the command above, the contract will be deployed on the Optimism Goerli test network. You can view the deployment status and contract by using the OP Goerli block explorer.
Funding your smart contracts
In order to pay for the fees associated with sending messages, the Sender
contract will need to hold a balance of LINK tokens.
Fund your contract directly from your wallet, or by running the following cast
command:
cast send $BASE_GOERLI_LINK_ADDRESS --rpc-url $BASE_GOERLI_RPC "transfer(address,uint256)" <SENDER_CONTRACT_ADDRESS> 5 --account deployer
The above command sends 5
LINK tokens on Base Goerli testnet to the Sender
contract.
Replace <SENDER_CONTRACT_ADDRESS>
with the contract address of your deployed Sender
contract before running the provided cast
command.
Interacting with the smart contract
Foundry provides the cast
command-line tool that can be used to interact with deployed smart contracts and call their functions.
Sending data
The cast
command can be used to call the sendMessage(uint64, address, string)
function on the Sender
contract deployed to Base Goerli in order to send message data to the Receiver
contract on Optimism Goerli.
To call the sendMessage(uint64, address, string)
function of the Sender
smart contract, run:
cast send <SENDER_CONTRACT_ADDRESS> --rpc-url $BASE_GOERLI_RPC "sendMessage(uint64, address, string)" $OPTIMISM_GOERLI_CHAIN_SELECTOR <RECEIVER_CONTRACT_ADDRESS> "Based" --account deployer
The command above calls the sendMessage(uint64, address, string)
to send a message. The parameters passed in to the method include: The chain selector to the destination chain (Optimism Goerli), the Receiver
contract address, and the text data to be included in the message (Based
).
Replace <SENDER_CONTRACT_ADDRESS>
and <RECEIVER_CONTRACT_ADDRESS>
with the contract addresses of your deployed Sender
and Receiver
contracts respectively before running the provided cast
command.
After running the command, a unique messageId
should be returned.
Once the transaction has been finalized, it will take a few minutes for CCIP to deliver the data to Optimism Goerli and call the ccipReceive
function on the Receiver
contract.
You can use the CCIP explorer to see the status of the CCIP transaction.
Receiving data
The cast
command can also be used to call the getMessage()
function on the Receiver
contract deployed to Optimism Goerli in order to read the received message data.
To call the getMessage()
function of the Receiver
smart contract, run:
cast send <RECEIVER_CONTRACT_ADDRESS> --rpc-url $OPTIMISM_GOERLI_RPC "getMessage()" --account deployer
Replace <RECEIVER_CONTRACT_ADDRESS>
with the contract addresses of your deployed Receiver
contract before running the provided cast
command.
After running the command, the messageId
and text
of the last received message should be returned.
If the transaction fails, ensure the status of your ccipSend
transaction has been finalized. You can using the CCIP explorer.
Conclusion
Congratulations! You have successfully learned how to perform cross-chain messaging on Base using Chainlink CCIP.
To learn more about cross-chain messaging and Chainlink CCIP, check out the following resources: