Get Started with CCIP (EVM)
Build and run a secure cross-chain messaging workflow between two EVM chains using Chainlink CCIP.
In this guide, you will:
- Deploy a sender on a source chain
- Deploy a receiver on a destination chain
- Send and verify a cross-chain message
Before you begin
You will need:
-
Basic Solidity and smart contract deployment experience
-
One wallet funded on two CCIP-supported EVM testnets
-
Choose one of the following development environments:
Examine the example code
This section goes through the code for the sender and receiver contracts
needed to complete the tutorial.
We will use the same contracts for both Hardhat and Foundry.
1 Sender code
The smart contract in this tutorial is designed to interact with CCIP to send data. The contract code includes comments to clarify the various functions, events, and underlying logic. However, this section explains the key elements. You can see the full contract code below.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import {IRouterClient} from "@chainlink/contracts-ccip/contracts/interfaces/IRouterClient.sol";
import {Client} from "@chainlink/contracts-ccip/contracts/libraries/Client.sol";
import {OwnerIsCreator} from "@chainlink/contracts/src/v0.8/shared/access/OwnerIsCreator.sol";
import {LinkTokenInterface} from "@chainlink/contracts/src/v0.8/shared/interfaces/LinkTokenInterface.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 sending string data across chains.
contract Sender is OwnerIsCreator {
// Custom errors to provide more descriptive revert messages.
error NotEnoughBalance(uint256 currentBalance, uint256 calculatedFees); // Used to make sure contract has enough
// balance.
// Event emitted when a message is sent to another chain.
// The chain selector of the destination chain.
// The address of the receiver on the destination chain.
// The text being sent.
// the token address used to pay CCIP fees.
// The fees paid for sending the CCIP message.
event MessageSent( // The unique ID of the CCIP message.
bytes32 indexed messageId,
uint64 indexed destinationChainSelector,
address receiver,
string text,
address feeToken,
uint256 fees
);
IRouterClient private s_router;
LinkTokenInterface 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 = LinkTokenInterface(_link);
}
/// @notice Sends data to receiver on the destination chain.
/// @dev Assumes your contract has sufficient LINK.
/// @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) {
// Create an EVM2AnyMessage struct in memory with necessary information for sending a cross-chain message
Client.EVM2AnyMessage memory evm2AnyMessage = Client.EVM2AnyMessage({
receiver: abi.encode(receiver), // ABI-encoded receiver address
data: abi.encode(text), // ABI-encoded string
tokenAmounts: new Client.EVMTokenAmount[](0), // Empty array indicating no tokens are being sent
extraArgs: Client._argsToBytes(
// Additional arguments, setting gas limit and allowing out-of-order execution.
// Best Practice: For simplicity, the values are hardcoded. It is advisable to use a more dynamic approach
// where you set the extra arguments off-chain. This allows adaptation depending on the lanes, messages,
// and ensures compatibility with future CCIP upgrades. Read more about it here:
// https://docs.chain.link/ccip/concepts/best-practices/evm#using-extraargs
Client.GenericExtraArgsV2({
gasLimit: 200_000, // Gas limit for the callback on the destination chain
allowOutOfOrderExecution: true // Allows the message to be executed out of order relative to other messages
// from
// the same sender
})
),
// Set the feeToken address, indicating LINK will be used for fees
feeToken: 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);
// 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 MessageSent(messageId, destinationChainSelector, receiver, text, address(s_linkToken), fees);
// Return the message ID
return messageId;
}
}
Initializing the contract
When deploying the contract, you define the router address and the LINK contract address of the blockchain where you choose to deploy the contract.
The router address provides functions that are required for this example:
Sending data
The sendMessage function completes several operations:
-
Construct a CCIP-compatible message using the
EVM2AnyMessagestruct:- The
receiveraddress is encoded in bytes format to accommodate non-EVM destination blockchains with distinct address formats. The encoding is achieved through abi.encode. - The
datais encoded from a string text to bytes using abi.encode. - The
tokenAmountsis an array. Each element comprises a struct that contains the token address and amount. In this example, the array is empty because no tokens are sent. - The
extraArgsspecify thegasLimitfor relaying the CCIP message to the recipient contract on the destination blockchain. In this example, thegasLimitis set to200000. - The
feeTokendesignates the token address used for CCIP fees. Here,address(linkToken)signifies payment in LINK.
- The
-
Compute the fees by invoking the router's
getFeefunction. -
Ensure that your contract balance in LINK is enough to cover the fees.
-
Grant the router contract permission to deduct the fees from the contract's LINK balance.
-
Dispatch the CCIP message to the destination chain by executing the router's
ccipSendfunction.
2 Receiver code
The smart contract in this tutorial is designed to interact with CCIP to receive data. The contract code includes comments to clarify the various functions, events, and underlying logic. However, this section explains the key elements. You can see the full contract code below.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import {CCIPReceiver} from "@chainlink/contracts-ccip/contracts/applications/CCIPReceiver.sol";
import {Client} from "@chainlink/contracts-ccip/contracts/libraries/Client.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 receiving string data across chains.
contract Receiver is CCIPReceiver {
// Event emitted when a message is received from another chain.
event MessageReceived( // The unique ID of the message.
// The chain selector of the source chain.
// The address of the sender from the source chain.
// The text that was received.
bytes32 indexed messageId,
uint64 indexed sourceChainSelector,
address sender,
string text
);
bytes32 private s_lastReceivedMessageId; // Store the last received messageId.
string private s_lastReceivedText; // Store the last received text.
/// @notice Constructor initializes the contract with the router address.
/// @param router The address of the router contract.
constructor(
address router
) CCIPReceiver(router) {}
/// handle a received message
function _ccipReceive(
Client.Any2EVMMessage memory any2EvmMessage
) internal override {
s_lastReceivedMessageId = any2EvmMessage.messageId; // fetch the messageId
s_lastReceivedText = abi.decode(any2EvmMessage.data, (string)); // abi-decoding of the sent text
emit MessageReceived(
any2EvmMessage.messageId,
any2EvmMessage.sourceChainSelector, // fetch the source chain identifier (aka selector)
abi.decode(any2EvmMessage.sender, (address)), // abi-decoding of the sender address,
abi.decode(any2EvmMessage.data, (string))
);
}
/// @notice Fetches the details of the last received message.
/// @return messageId The ID of the last received message.
/// @return text The last received text.
function getLastReceivedMessageDetails() external view returns (bytes32 messageId, string memory text) {
return (s_lastReceivedMessageId, s_lastReceivedText);
}
}
Initializing the contract
When you deploy the contract, you define the router address. The receiver contract inherits from the CCIPReceiver.sol contract, which uses the router address.
Receiving data
On the destination blockchain:
- The CCIP Router invokes the
ccipReceivefunction. Note: This function is protected by theonlyRoutermodifier, which ensures that only the router can call the receiver contract. - The
ccipReceivefunction calls an internal function_ccipReceivefunction. The receiver contract implements this function. - This
_ccipReceivefunction expects anAny2EVMMessagestruct that contains the following values:- The CCIP
messageId. - The
sourceChainSelector. - The
senderaddress in bytes format. The sender is a contract deployed on an EVM-compatible blockchain, so the address is decoded from bytes to an Ethereum address using the ABI specification. - The
datais also in bytes format. Astringis expected, so the data is decoded from bytes to a string using the ABI specification.
- The CCIP
Send a cross-chain message using CCIP
Send and verify a cross-chain message using CCIP in under 10 minutes, with your favorite development framework.
Hardhat
Best for a Typescript based scripting workflow where you deploy contracts, send a CCIP message, and verify delivery from the command line.
1 Bootstrap a new Hardhat project
- Open a new terminal in a directory of your choice and run this command:
npx hardhat --init
Create a project with the following options:
- Hardhat Version: hardhat-3
- Initialize project: At root of the project
- Type of project: A minimal Hardhat project
- Install the necessary dependencies: Yes
- Install the additional dependencies required by this tutorial:
npm install @chainlink/contracts-ccip @chainlink/contracts @openzeppelin/contracts viem dotenv
npm install --save-dev @nomicfoundation/hardhat-viem @nomicfoundation/hardhat-keystore
- Configure your environment variables using the
hardhat-keystoreplugin. If you have followed the tutorial you have already installed the required package. Follow this page in Hardhat's documentation to configure the environment variables:
SEPOLIA_RPC_URL
FUJI_RPC_URL
PRIVATE_KEY
The output of npx hardhat keystore list should look like this:
- Update
hardhat.config.tsto use your environment variables and thehardhat-viemplugin:
import { configVariable, defineConfig } from "hardhat/config"
import hardhatKeystore from "@nomicfoundation/hardhat-keystore"
import hardhatViem from "@nomicfoundation/hardhat-viem"
export default defineConfig({
plugins: [hardhatViem, hardhatKeystore],
solidity: {
version: "0.8.24",
},
networks: {
sepolia: {
type: "http",
url: configVariable("SEPOLIA_RPC_URL"),
accounts: [configVariable("PRIVATE_KEY")],
},
avalancheFuji: {
type: "http",
url: configVariable("FUJI_RPC_URL"),
accounts: [configVariable("PRIVATE_KEY")],
},
},
})
2 Set up the contracts
- Create a new directory named
contractsfor your smart contracts if it doesn't already exist. - Create a new file named
Sender.solin this directory and paste the sender contract code inside it. - Create a new file named
Receiver.solin the same directory and paste the receiver contract code inside it. - Create a
contracts/interfacesdirectory and create a new file namedIERC20.solinside it. Our script will need to make a call to theLINK ERC-20contract to transfer LINK to the sender contract. It will need access to an ERC-20 interface to be able to call thetransferfunction. - Paste the following code into the
IERC20.solfile:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
// Re-export OpenZeppelin's IERC20 interface
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
interface IERC20Extended is IERC20 {}
- Run the following command to compile the contracts:
npx hardhat build
3 Send a cross-chain message using CCIP
- Create a new directory named
scriptsat the root of the project if it doesn't already exist. - Create a new file named
send-cross-chain-message.tsin this directory and paste the following code inside it:
import { network } from "hardhat"
import { parseUnits } from "viem"
// Avalanche Fuji configuration
const FUJI_ROUTER = "0xF694E193200268f9a4868e4Aa017A0118C9a8177"
const FUJI_LINK = "0x0b9d5D9136855f6FEc3c0993feE6E9CE8a297846"
// Ethereum Sepolia configuration
// Note that the contract on Sepolia doesn't need to have LINK to pay for CCIP fees.
const SEPOLIA_ROUTER = "0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59"
const SEPOLIA_CHAIN_SELECTOR = 16015286601757825753n
// Connect to Avalanche Fuji
console.log("Connecting to Avalanche Fuji...")
const fujiNetwork = await network.connect("avalancheFuji")
// Connect to Ethereum Sepolia
console.log("Connecting to Ethereum Sepolia...")
const sepoliaNetwork = await network.connect("sepolia")
// Step 1: Deploy Sender on Fuji
console.log("\n[Step 1] Deploying Sender contract on Avalanche Fuji...")
const sender = await fujiNetwork.viem.deployContract("Sender", [FUJI_ROUTER, FUJI_LINK])
const fujiPublicClient = await fujiNetwork.viem.getPublicClient()
console.log(`Sender contract has been deployed to this address on the Fuji testnet: ${sender.address}`)
console.log(`View on Avascan: https://testnet.avascan.info/blockchain/all/address/${sender.address}`)
// Step 2: Fund Sender with LINK
console.log("\n[Step 2] Funding Sender with 1 LINK...")
const fujiLinkToken = await fujiNetwork.viem.getContractAt("IERC20Extended", FUJI_LINK)
const transferLinkToFujiContract = await fujiLinkToken.write.transfer([sender.address, parseUnits("1", 18)])
console.log("LINK token transfer in progress, awaiting confirmation...")
await fujiPublicClient.waitForTransactionReceipt({ hash: transferLinkToFujiContract, confirmations: 1 })
console.log(`Funded Sender with 1 LINK`)
// Step 3: Deploy Receiver on Sepolia
console.log("\n[Step 3] Deploying Receiver on Ethereum Sepolia...")
const receiver = await sepoliaNetwork.viem.deployContract("Receiver", [SEPOLIA_ROUTER])
const sepoliaPublicClient = await sepoliaNetwork.viem.getPublicClient()
console.log(`Receiver contract has been deployed to this address on the Sepolia testnet: ${receiver.address}`)
console.log(`View on Etherscan: https://sepolia.etherscan.io/address/${receiver.address}`)
console.log(`\n📋 Copy the receiver address since it will be needed to run the verification script 📋 \n`)
// Step 4: Send cross-chain message
console.log("\n[Step 4] Sending cross-chain message...")
const sendMessageTx = await sender.write.sendMessage([
SEPOLIA_CHAIN_SELECTOR,
receiver.address,
"Hello World! cdnjkdjmdsd",
])
console.log("Cross-chain message sent, awaiting confirmation...")
console.log(`Message sent from source contract! ✅ \n Tx hash: ${sendMessageTx}`)
console.log(`View transaction status on CCIP Explorer: https://ccip.chain.link`)
console.log(
"Run the receiver script after 10 minutes to check if the message has been received on the destination contract."
)
This script does the following:
- Connects to the Avalanche Fuji and Ethereum Sepolia networks.
- Deploys the sender contract on Avalanche Fuji.
- Funds the sender contract with 1 LINK.
- Deploys the receiver contract on Ethereum Sepolia.
- Sends a cross-chain message from the sender contract to the receiver contract.
- Run the following command to send the cross-chain message:
npx hardhat run scripts/send-cross-chain-message.ts
4 Verify message delivery
-
Wait for a few minutes for the message to be delivered to the receiver contract.
-
Create a new file named
verify-cross-chain-message.tsin thescriptsdirectory and paste the following code inside it:
import { network } from "hardhat"
// Paste the Receiver contract address
const RECEIVER_ADDRESS = ""
console.log("Connecting to Ethereum Sepolia...")
const sepoliaNetwork = await network.connect("sepolia")
console.log("Checking for received message...\n")
const receiver = await sepoliaNetwork.viem.getContractAt("Receiver", RECEIVER_ADDRESS)
const [messageId, text] = await receiver.read.getLastReceivedMessageDetails()
// A null hexadecimal value means no message has been received yet
const ZERO_BYTES32 = "0x0000000000000000000000000000000000000000000000000000000000000000"
if (messageId === ZERO_BYTES32) {
console.log("No message received yet.")
console.log("Please wait a bit longer and try again.")
process.exit(1)
} else {
console.log(`✅ Message ID: ${messageId}`)
console.log(`Text: "${text}"`)
}
This script does the following:
- Connects to the Ethereum Sepolia network.
- Reads the last received message details from the receiver contract.
- Checks if any message has been received.
- Prints the message ID and text of the last received message.
- Run the following command to verify the cross-chain message:
npx hardhat run scripts/verify-cross-chain-message.ts
- You should see the message ID and text of the last received message printed in the terminal.
Foundry
Best for Solidity-native workflows that prefer a modular, powerful scripting framework.
1 Bootstrap a new Foundry project
- Open a new terminal in a directory of your choice and run this command to initialize a new Foundry project at the root:
forge init
- Install the required dependencies:
forge install smartcontractkit/chainlink-ccip smartcontractkit/chainlink-evm OpenZeppelin/openzeppelin-contracts
- Configure the remappings so that your
foundry.tomlfile looks like this:
[profile.default]
solc = "0.8.24"
src = "src"
out = "out"
libs = ["lib"]
remappings = [
"forge-std/=lib/forge-std/src/",
"@chainlink/contracts-ccip/contracts/=lib/chainlink-ccip/chains/evm/contracts/",
"@chainlink/contracts/=lib/chainlink-evm/contracts/",
"@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/",
"@openzeppelin/contracts@5.0.2/=lib/openzeppelin-contracts/contracts/",
]
# RPC URLs will be fed to our script via Foundry's config file
[rpc_endpoints]
sepolia = "${SEPOLIA_RPC_URL}"
fuji = "${FUJI_RPC_URL}"
- Create a
.envat the root of the project with the following variables:
PRIVATE_KEY=0x...
SEPOLIA_RPC_URL=https://...
FUJI_RPC_URL=https://...
2 Set up the contracts
- Create a new directory named
srcat the root of the project if it doesn't already exist. - Create a new file named
Sender.solin this directory and paste the sender contract code inside it. - Create a new file named
Receiver.solin the same directory and paste the receiver contract code inside it. - Create a
src/interfacesdirectory and create a new file namedIERC20.solinside it. Our script will need to make a call to theLINK ERC-20contract to transfer LINK to the sender contract. It will need access to an ERC-20 interface to be able to call thetransferfunction. - Paste the following code into the
IERC20.solfile:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
// Re-export OpenZeppelin's IERC20 interface
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
interface IERC20Extended is IERC20 {}
- Run the following command to compile the contracts:
forge build
3 Send a cross-chain message using CCIP
- Create a new directory named
scriptat the root of the project if it doesn't already exist. - Create a new file named
SendCrossChainMessage.s.solin this directory and paste the following code inside it:
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.24;
import {Script, console} from "forge-std/Script.sol";
import {Sender} from "../src/Sender.sol";
import {Receiver} from "../src/Receiver.sol";
import {IERC20Extended} from "../src/interfaces/IERC20.sol";
contract SendCrossChainMessage is Script {
// Avalanche Fuji configuration
address constant FUJI_ROUTER = 0xF694E193200268f9a4868e4Aa017A0118C9a8177;
address constant FUJI_LINK = 0x0b9d5D9136855f6FEc3c0993feE6E9CE8a297846;
// Ethereum Sepolia configuration
address constant SEPOLIA_ROUTER =0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59;
uint64 constant SEPOLIA_CHAIN_SELECTOR = 16015286601757825753;
// Configuring decimal value for LINK token
uint256 ONE_LINK = 1e18;
function run() public {
// Load private key from env file
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
// Load form configs from foundry.toml
uint256 fujiFork = vm.createFork(vm.rpcUrl("fuji"));
uint256 sepoliaFork = vm.createFork(vm.rpcUrl("sepolia"));
// Step 1: Deploy Sender on Fuji
// Connect to Fuji Network
console.log("Connecting to Avalanche Fuji...");
vm.selectFork(fujiFork);
vm.startBroadcast(deployerPrivateKey);
// Deploy Sender contract
console.log("\n[Step 1] Deploying Sender contract on Avalanche Fuji...");
Sender sender = new Sender(FUJI_ROUTER, FUJI_LINK);
console.log("Sender contract has been deployed to this address on the Fuji testnet:", address(sender));
console.log(
string.concat(
"View on Avascan: https://testnet.avascan.info/blockchain/all/address/",
vm.toString(address(sender))
)
);
// Step 2: Fund Sender with 1 LINK
console.log("\n[Step 2] Funding Sender with 1 LINK on Avalanche Fuji...");
IERC20Extended(FUJI_LINK).transfer(address(sender), ONE_LINK);
vm.stopBroadcast();
console.log("Funded Sender with 1 LINK on Fuji");
// Step 3: Deploy Receiver on Sepolia
// Connect to Sepolia Network
console.log("Connecting to Ethereum Sepolia...");
vm.selectFork(sepoliaFork);
vm.startBroadcast(deployerPrivateKey);
// Deploy Receiver contract
console.log("\n[Step 3] Deploying Receiver contract on Ethereum Sepolia...");
Receiver receiver = new Receiver(SEPOLIA_ROUTER);
vm.stopBroadcast();
console.log("Receiver deployed on Sepolia at this address:", address(receiver));
console.log(
string.concat(
"View on Etherscan: https://sepolia.etherscan.io/address/",
vm.toString(address(receiver))
)
);
console.log("\n .....Copy the receiver address since it will be needed to run the verification script.....\n");
console.log(address(receiver));
// Step 4: Send cross-chain message (Fuji -> Sepolia)
vm.selectFork(fujiFork);
vm.startBroadcast(deployerPrivateKey);
// Send cross-chain message
console.log("Sending cross-chain message from Fuji to Sepolia...");
bytes32 messageId = sender.sendMessage(
SEPOLIA_CHAIN_SELECTOR,
address(receiver),
"Hello World from Foundry script!"
);
vm.stopBroadcast();
console.log("The message has been sent to the CCIP router on Fuji, check for successful delivery after 5 minutes...");
console.log("CCIP messageId:");
console.logBytes32(messageId);
console.log("View transaction status on CCIP Explorer: https://ccip.chain.link");
}
}
- Run the following command to send the cross-chain message:
forge script script/SendCrossChainMessage.s.sol:SendCrossChainMessage --broadcast
4 Verify message delivery
- Create a new file named
VerifyCrossChainMessage.s.solin thescriptdirectory and paste the following code inside it:
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.24;
import {Script, console} from "forge-std/Script.sol";
import {Receiver} from "../src/Receiver.sol";
contract VerifyCrossChainMessage is Script {
bytes32 constant ZERO_BYTES32 = bytes32(0);
function run() public {
address receiverAddress = PASTE_RECEIVER_ADDRESS_HERE;
require(receiverAddress != address(0), "Set RECEIVER_ADDRESS (in .env or in script)");
console.log("Connecting to Ethereum Sepolia...");
uint256 sepoliaFork = vm.createFork(vm.rpcUrl("sepolia"));
vm.selectFork(sepoliaFork);
console.log("Checking for received message...\n");
Receiver receiver = Receiver(receiverAddress);
(bytes32 messageId, string memory text) = receiver
.getLastReceivedMessageDetails();
if (messageId == ZERO_BYTES32) {
console.log("No message received yet.");
console.log("Please wait a bit longer and try again.");
revert("No message received yet");
}
console.log("Received Message ID:");
console.logBytes32(messageId);
console.log(string.concat('Received Text: "', text, '"'));
}
}
- Run the following command to verify the cross-chain message:
forge script script/VerifyCrossChainMessage.s.sol:VerifyCrossChainMessage