Transfer Tokens with Data
In this tutorial, we will use CCIP to send some tokens + arbitrary data from one chain to another in a single transaction.
We will send CCIP-BnM tokens and a string payload from Ethereum Sepolia to Arbitrum Sepolia twice:
- Once, paying CCIP fees in native gas tokens
ETHand using faster than finality (BLOCK_DEPTH=32). - And then a second time, paying CCIP fees in
LINKand using finalized finality (default).
Before you begin
- You should understand how to write, compile, deploy, and fund a smart contract. Go through this tutorial to get started.
- Your account must have some
ETHandLINKtokens on Ethereum Sepolia andETHtokens on Arbitrum Sepolia.
Learn how to Acquire testnet LINK. - Check the CCIP Directory if you want to configure a different set of source and destination chains/tokens.
Examine the code
1 Initializing the contract
When deploying the contract, we define the router address of the blockchain we deploy the contract on. Defining the router address is useful for the following:
-
Sender part:
-
Receiver part:
- The contract inherits from CCIPReceiver, which serves as a base contract for receiver contracts. This contract requires that child contracts implement the
_ccipReceivefunction. _ccipReceiveis called by theccipReceivefunction, which ensures that only the router can deliver CCIP messages to the receiver contract.
- The contract inherits from CCIPReceiver, which serves as a base contract for receiver contracts. This contract requires that child contracts implement the
Some key things to note:
-
OwnerIsCreatorsets the deployer as the owner of the contract. -
The constructor passes the router address into
CCIPReceiverat deployment time. -
sendMessageispayableand open to any caller (not restricted to the owner). It handles both LINK and native fee payments:- pass the LINK token address as
_feeTokenAddressto pay in LINK, or, address(0)to pay in native gas.
- pass the LINK token address as
-
The function accepts pre-encoded
_extraArgsbytes built off-chain, making the contract forward-compatible with any extraArgs version. -
Access control is enforced through allowlisting:
- outbound messages are restricted by destination chain selector (
onlyAllowlistedDestinationChain), not by destination receiver address - inbound messages are restricted by source chain selector + source sender contract pair (
onlyAllowlisted)
- outbound messages are restricted by destination chain selector (
-
The contract overrides
getCCVsAndFinalityConfigfromCCIPReceiverto advertise per-source-chain receiver finality policy to the OffRamp. See Configure receiver finality policy below.
// Imports
contract ProgrammableTokenTransfers is CCIPReceiver, OwnerIsCreator {
constructor(address _router) CCIPReceiver(_router) {}
// ... state variables, modifiers, allowlist admin functions ...
function sendMessage(
uint64 _destinationChainSelector,
address _receiver,
string calldata _text,
address _token,
uint256 _amount,
address _feeTokenAddress,
bytes calldata _extraArgs
)
external
payable
onlyAllowlistedDestinationChain(_destinationChainSelector)
validateReceiver(_receiver)
returns (bytes32 messageId)
{
messageId = _sendCCIPMessage(
_destinationChainSelector,
_receiver,
_text,
_token,
_amount,
_feeTokenAddress,
_extraArgs
);
}
// ... internal helpers (fee handling, message building, approvals) ...
function _ccipReceive(Client.Any2EVMMessage memory any2EvmMessage)
internal
override
onlyAllowlisted(any2EvmMessage.sourceChainSelector, abi.decode(any2EvmMessage.sender, (address)))
{
s_lastReceivedMessageId = any2EvmMessage.messageId;
s_lastReceivedSender = abi.decode(any2EvmMessage.sender, (address));
s_lastReceivedText = abi.decode(any2EvmMessage.data, (string));
bool hasToken = any2EvmMessage.destTokenAmounts.length > 0;
s_lastReceivedTokenAddress = hasToken ? any2EvmMessage.destTokenAmounts[0].token : address(0);
s_lastReceivedTokenAmount = hasToken ? any2EvmMessage.destTokenAmounts[0].amount : 0;
emit MessageReceived( /* ... */ );
}
// ... getCCVsAndFinalityConfig override, fee helpers, and withdrawal utilities ...
}
2 Build transaction payload
_sendCCIPMessage calls the _buildCCIPMessage helper to build a CCIP message payload using EVM2AnyMessage struct.
This payload is then passed to the router's getFee and ccipSend functions.
The payload includes:
receiver: ABI-encoded destination address (abi.encode(_receiver)).data: ABI-encoded text payload (abi.encode(_text)).tokenAmounts: A 1-element array containing the token address and amount to transfer.extraArgs: Pre-encoded message execution parameters built off-chain by a helper script. For finalized (default) finality, the scripts encode V3 extraArgs with the finalized finality config. For faster than finality requests, the scripts detect lane support and use V3 extraArgs withrequestedFinalityConfigon FTF-capable lanes, or V2 extraArgs on pre-v2.0 lanes.feeToken: The token used to pay CCIP fees (_feeTokenAddress). Pass the LINK token address to pay in LINK, oraddress(0)to pay in native gas.
function _buildCCIPMessage(
address _receiver,
string calldata _text,
address _token,
uint256 _amount,
address _feeTokenAddress,
bytes calldata _extraArgs
) private pure returns (Client.EVM2AnyMessage memory) {
Client.EVMTokenAmount[] memory tokenAmounts = new Client.EVMTokenAmount[](1);
tokenAmounts[0] = Client.EVMTokenAmount({token: _token, amount: _amount});
return Client.EVM2AnyMessage({
receiver: abi.encode(_receiver),
data: abi.encode(_text),
tokenAmounts: tokenAmounts,
extraArgs: _extraArgs,
feeToken: _feeTokenAddress
});
}
3 Sending messages
The public sendMessage function delegates to _sendCCIPMessage, which performs four operations:
- Builds the message payload by calling
_buildCCIPMessage. See Build transaction payload for details. - Computes the fees by invoking the router's
getFeefunction. - Pulls tokens from the caller and grants the router the required approvals by calling
_handleFeeAndTokenApprovals. See Handling fees and token approvals for details. - Dispatches the CCIP message by executing the router's
ccipSendfunction. If paying in native gas (_feeTokenAddress == address(0)), the fee is forwarded via{value: ccipFee}.
Note: As a security measure, sendMessage is protected by the onlyAllowlistedDestinationChain and validateReceiver modifiers. Any caller can invoke it -- access is governed by the destination chain allowlist, not ownership.
function _sendCCIPMessage(
uint64 _destinationChainSelector,
address _receiver,
string calldata _text,
address _token,
uint256 _amount,
address _feeTokenAddress,
bytes calldata _extraArgs
) private returns (bytes32 messageId) {
Client.EVM2AnyMessage memory evm2AnyMessage = _buildCCIPMessage(
_receiver, _text, _token, _amount, _feeTokenAddress, _extraArgs
);
IRouterClient router = IRouterClient(this.getRouter());
uint256 ccipFee = router.getFee(_destinationChainSelector, evm2AnyMessage);
_handleFeeAndTokenApprovals(router, _token, _amount, _feeTokenAddress, ccipFee);
if (_feeTokenAddress == address(0)) {
messageId = router.ccipSend{value: ccipFee}(_destinationChainSelector, evm2AnyMessage);
} else {
messageId = router.ccipSend(_destinationChainSelector, evm2AnyMessage);
}
emit MessageSent(
messageId, _destinationChainSelector, _receiver, _text, _token, _amount, _feeTokenAddress, ccipFee
);
return messageId;
}
4 Handling fees and token approvals
The contract uses a pull-from-caller model: when a user calls sendMessage, the contract pulls the required tokens from msg.sender via safeTransferFrom, then approves the Router to spend them via forceApprove. The caller (EOA or upstream contract) must approve this contract before calling sendMessage.
_handleFeeAndTokenApprovals handles three scenarios:
- Native fee (
_feeTokenAddress == address(0)): Validates thatmsg.valuecovers the CCIP fee. Pulls the transfer token from the caller viasafeTransferFromand approves the Router. - Same token for fee and transfer (
_token == _feeTokenAddress): Pulls the combined total (ccipFee + amount) from the caller in onesafeTransferFrom. Approves the Router for the combined total. - Different ERC-20 tokens: Pulls each token separately from the caller. Approves the Router for each.
function _handleFeeAndTokenApprovals(
IRouterClient _router,
address _token,
uint256 _amount,
address _feeTokenAddress,
uint256 _ccipFee
) private {
if (_feeTokenAddress == address(0)) {
if (msg.value < _ccipFee) {
revert InsufficientNativeForFees(msg.value, _ccipFee);
}
IERC20(_token).safeTransferFrom(msg.sender, address(this), _amount);
IERC20(_token).forceApprove(address(_router), _amount);
} else if (_token == _feeTokenAddress) {
uint256 totalAmount = _ccipFee + _amount;
IERC20(_token).safeTransferFrom(msg.sender, address(this), totalAmount);
IERC20(_token).forceApprove(address(_router), totalAmount);
} else {
IERC20(_feeTokenAddress).safeTransferFrom(msg.sender, address(this), _ccipFee);
IERC20(_token).safeTransferFrom(msg.sender, address(this), _amount);
IERC20(_feeTokenAddress).forceApprove(address(_router), _ccipFee);
IERC20(_token).forceApprove(address(_router), _amount);
}
}
5 Receiving messages
On the destination blockchain, the router calls the inherited ccipReceive function, which verifies the caller is the router and then invokes the contract's internal _ccipReceive function. The _ccipReceive function expects an Any2EVMMessage struct that contains:
- The CCIP
messageId. - The
sourceChainSelector. - The
senderaddress in bytes format. The address is decoded from bytes to an Ethereum address using the ABI specifications and stored ins_lastReceivedSender. - The
destTokenAmountsarray contains received tokens and their respective amounts. The function checks whether tokens were included before accessing the array, so it gracefully handles messages with or without token transfers. - The
data, which is also in bytes format. Given astringis expected, the data is decoded from bytes to a string using the ABI specifications.
Note: Two important security measures are applied:
_ccipReceiveis called by theccipReceivefunction, which ensures that only the router can deliver CCIP messages to the receiver contract. See theonlyRoutermodifier for more information.- The modifier
onlyAllowlistedensures that only a call from an allowlisted source chain and sender pair is accepted.
function _ccipReceive(Client.Any2EVMMessage memory any2EvmMessage)
internal
override
onlyAllowlisted(any2EvmMessage.sourceChainSelector, abi.decode(any2EvmMessage.sender, (address)))
{
s_lastReceivedMessageId = any2EvmMessage.messageId;
s_lastReceivedSender = abi.decode(any2EvmMessage.sender, (address));
s_lastReceivedText = abi.decode(any2EvmMessage.data, (string));
bool hasToken = any2EvmMessage.destTokenAmounts.length > 0;
s_lastReceivedTokenAddress = hasToken ? any2EvmMessage.destTokenAmounts[0].token : address(0);
s_lastReceivedTokenAmount = hasToken ? any2EvmMessage.destTokenAmounts[0].amount : 0;
emit MessageReceived(
any2EvmMessage.messageId,
any2EvmMessage.sourceChainSelector,
s_lastReceivedSender,
s_lastReceivedText,
s_lastReceivedTokenAddress,
s_lastReceivedTokenAmount
);
}
6 Configure receiver finality policy
The receiver exposes an allowedFinalityConfig for each source chain. This value tells CCIP which finality modes the receiver accepts for messages from that chain. The sender scripts encode the requested mode into V3 extraArgs as requestedFinalityConfig, then validate the request against the receiver policy (and the token pool policy, when applicable) before sending.
CCIP 2.0 supports two finality request styles:
- Default finality (finalized): Omit
BLOCK_DEPTH(or setBLOCK_DEPTH=DEFAULT). - Numeric block depth (faster than finality): Set
BLOCK_DEPTH=32(or higher). This tutorial standardizes on32.
This contract uses two functions to manage receiver-side finality policy:
setAllowedFinalityConfig: An owner-only setter that stores theFinalityCodec-encoded policy for a source chain.getCCVsAndFinalityConfig: The OffRamp and scripts can call this hook to read the receiver's CCV and finality policy. This tutorial does not configure custom CCVs, so it returns empty CCV arrays andoptionalThreshold = 0.
function setAllowedFinalityConfig(
uint64 _sourceChainSelector,
bytes4 _allowedFinalityConfig
) external onlyOwner {
s_allowedFinalityConfig[_sourceChainSelector] = _allowedFinalityConfig;
emit AllowedFinalityConfigSet(_sourceChainSelector, _allowedFinalityConfig);
}
function getCCVsAndFinalityConfig(
uint64 sourceChainSelector,
bytes calldata sender
)
external
view
override
returns (
address[] memory requiredCCVs,
address[] memory optionalCCVs,
uint8 optionalThreshold,
bytes4 allowedFinalityConfig
)
{
address decodedSender = abi.decode(sender, (address));
if (!allowlistedChainSenders[sourceChainSelector][decodedSender]) {
revert SenderNotAllowedForChain(sourceChainSelector, decodedSender);
}
requiredCCVs = new address[](0);
optionalCCVs = new address[](0);
optionalThreshold = 0;
allowedFinalityConfig = s_allowedFinalityConfig[sourceChainSelector];
}
Check out the complete contract code on Github.
Tutorial
Let's get started! Choose your preferred development environment below.
Foundry
Best for Solidity-native workflows that prefer a modular, powerful scripting framework.
1 Bootstrap a new Foundry project
Clone the Foundry Starter Kit for a smoother setup.
- Clone the CCIP 2.0 template repository, and open a terminal inside the project directory:
git clone https://github.com/smartcontractkit/docs-ccip.git && cd docs-ccip
- If you don't already have a Foundry keystore, use the
castcommand to create a new one. Here,your_keystore_nameis the alias you assign to this keystore entry -- Foundry will prompt you to enter the actual private key and a password to encrypt it:
cast wallet import your_keystore_name --interactive
And use the cast wallet list command to verify:

- Install dependencies:
npm install
- Create a
.envfile by copying the example file, and fill in your values:
cp .env.example .env
Set KEYSTORE_NAME to the name of the keystore entry you created above, and provide RPC endpoints for the chains you will use:
# Keystore name
KEYSTORE_NAME=your_keystore_name
# RPC URLs (add the ones you need)
ETHEREUM_SEPOLIA_RPC_URL=
ARBITRUM_SEPOLIA_RPC_URL=
# Etherscan API key (required only if you pass --verify to deployment scripts)
ETHERSCAN_API_KEY=
- Load the environment variables:
source .env
- Run the following command to compile all the contracts:
forge build
2 Deploy your contracts
In this section, you will deploy the contracts on the source and destination chains.
- The
Deploy.s.solscript does the following:
- Deploy
ProgrammableTokenTransferson Ethereum Sepolia (source). - Deploy
ProgrammableTokenTransferson Arbitrum Sepolia (destination). - Returns the contract addresses in the terminal
To run the script, use the following command:
SOURCE_CHAIN=ETHEREUM_SEPOLIA DEST_CHAIN=ARBITRUM_SEPOLIA \
forge script foundry/scripts/tutorials/programmable-token-transfers/deploy/Deploy.s.sol \
--account $KEYSTORE_NAME \
--broadcast -vv
The deploy script prints the deployed contract addresses and follow-up commands. Your terminal should look something like this:
========================================
๐ Deploy CCIP Contracts on Both Chains
========================================
Source Chain: Ethereum Sepolia
Destination Chain: Arbitrum Sepolia
========================================
[Step 1] Deploying ProgrammableTokenTransfers on Ethereum Sepolia
Contract deployed at: 0x2a0C1c7E6fD5b3A9b8C7d6E5f4A3B2c1D0eF9a8B
https://sepolia.etherscan.io/address/0x2a0C1c7E6fD5b3A9b8C7d6E5f4A3B2c1D0eF9a8B
========================================
โ
Deployment Complete on Ethereum Sepolia!
========================================
Source Contract Address: 0x2a0C1c7E6fD5b3A9b8C7d6E5f4A3B2c1D0eF9a8B
[Step 2] Deploying ProgrammableTokenTransfers on Arbitrum Sepolia
Contract deployed at: 0x8A4f2E6c1B9d3D5a7C8e0F2b4D6a8C1e3F5b7A9c
https://sepolia.arbiscan.io/address/0x8A4f2E6c1B9d3D5a7C8e0F2b4D6a8C1e3F5b7A9c
========================================
โ
Deployment Complete on Arbitrum Sepolia!
========================================
Destination Contract Address: 0x8A4f2E6c1B9d3D5a7C8e0F2b4D6a8C1e3F5b7A9c
========================================
โ
All Deployments Complete!
========================================
Source Chain: Ethereum Sepolia
Source Contract: 0x2a0C1c7E6fD5b3A9b8C7d6E5f4A3B2c1D0eF9a8B
https://sepolia.etherscan.io/address/0x2a0C1c7E6fD5b3A9b8C7d6E5f4A3B2c1D0eF9a8B
Destination Chain: Arbitrum Sepolia
Destination Contract: 0x8A4f2E6c1B9d3D5a7C8e0F2b4D6a8C1e3F5b7A9c
https://sepolia.arbiscan.io/address/0x8A4f2E6c1B9d3D5a7C8e0F2b4D6a8C1e3F5b7A9c
Run this command to set both environment variables:
export ETHEREUM_SEPOLIA_CONTRACT=0x2a0C1c7E6fD5b3A9b8C7d6E5f4A3B2c1D0eF9a8B && export ARBITRUM_SEPOLIA_CONTRACT=0x8A4f2E6c1B9d3D5a7C8e0F2b4D6a8C1e3F5b7A9c
========================================
Check out the complete file on Github here:
Deploy.s.solCheck out the complete script code on Github.
3 Configure allowlists and finality
As a best practice, configure allowlists before sending messages: the sender contract restricts outbound sends by destination chain, and the receiver contract restricts inbound delivery by source chain-sender pair.
Configure.s.solCheck out the complete script code on Github.
- Export the addresses of the previously deployed contracts so that they're available in the terminal:
export ETHEREUM_SEPOLIA_CONTRACT=<Sender Contract address> && export ARBITRUM_SEPOLIA_CONTRACT=<Receiver Contract Address>
- The
Configure.s.solscript handles both sides in a single command:- allowlists the destination chain on the sender contract,
- and allowlists the chain-sender pair on the receiver contract.
SOURCE_CHAIN=ETHEREUM_SEPOLIA DEST_CHAIN=ARBITRUM_SEPOLIA \
ALLOWED_FINALITY_CONFIG=BLOCK_DEPTH \
ALLOWED_BLOCK_DEPTH=32 \
forge script foundry/scripts/tutorials/programmable-token-transfers/configure/Configure.s.sol \
--account $KEYSTORE_NAME --broadcast -vv
Finality configuration
- To allow numeric faster than finality requests, set
ALLOWED_FINALITY_CONFIG=BLOCK_DEPTHand choose a minimumALLOWED_BLOCK_DEPTH. \ - In this tutorial,
ALLOWED_BLOCK_DEPTH=32allows send-time requests ofBLOCK_DEPTH=32(or higher).
Your terminal should look something like this:
========================================
โ๏ธ Configure CCIP Contracts on Both Chains
========================================
Source Chain: Ethereum Sepolia
Source Contract: 0x2a0C1c7E6fD5b3A9b8C7d6E5f4A3B2c1D0eF9a8B
Destination Chain: Arbitrum Sepolia
Destination Contract: 0x8A4f2E6c1B9d3D5a7C8e0F2b4D6a8C1e3F5b7A9c
========================================
[Step 1] Configuring sender on Ethereum Sepolia
Allowlisting Arbitrum Sepolia as destination chain...
โ
Destination chain allowlisted: Arbitrum Sepolia
========================================
โ
Configuration Complete on Ethereum Sepolia!
========================================
[Step 2] Configuring receiver on Arbitrum Sepolia
Allowlisting sender 0x2a0C1c7E6fD5b3A9b8C7d6E5f4A3B2c1D0eF9a8B from Ethereum Sepolia...
โ
Chain-sender pair allowlisted: Ethereum Sepolia -> 0x2a0C1c7E6fD5b3A9b8C7d6E5f4A3B2c1D0eF9a8B
Setting allowed finality config to 0x00000020 (BLOCK_DEPTH=32)...
โ
Allowed finality config set to 0x00000020 (BLOCK_DEPTH=32) for Ethereum Sepolia
========================================
โ
Configuration Complete on Arbitrum Sepolia!
========================================
========================================
โ
All Configurations Complete!
========================================
Ethereum Sepolia can send messages to Arbitrum Sepolia
Arbitrum Sepolia can receive messages from Ethereum Sepolia
- Optional -- enable bidirectional messaging: If you want the destination contract to also send messages back to the source, run the reversed command:
SOURCE_CHAIN=ARBITRUM_SEPOLIA DEST_CHAIN=ETHEREUM_SEPOLIA \
ALLOWED_FINALITY_CONFIG=BLOCK_DEPTH \
ALLOWED_BLOCK_DEPTH=32 \
forge script foundry/scripts/tutorials/programmable-token-transfers/configure/Configure.s.sol \
--account $KEYSTORE_NAME --broadcast -vv
4 Fund your wallet with test tokens
Check out the faucet script on Github.
Before sending a CCIP message, you need CCIP-BnM test tokens in your wallet. The send scripts transfer CCIP-BnM from
your EOA to the contract, so your wallet must hold a balance.
Use the faucet script included in the starter kit to drip CCIP-BnM tokens to your address:
CHAIN=ETHEREUM_SEPOLIA RECIPIENT_ADDRESS=<your-wallet-address> \
forge script foundry/scripts/faucet/DripBnMToken.s.sol \
--account $KEYSTORE_NAME --broadcast -vv
5 Send a message
SendMessage.s.sol is a unified send script that handles both native and LINK fee payments. Set FEE_TOKEN=LINK to pay with LINK, or omit it (defaults to NATIVE) to pay with the native gas token. The script:
- Builds off-chain
extraArgsfor the lane. Foundry usesExtraArgsHelper.buildExtraArgsto detect the lane version and encode V2 or V3. - Approves the contract to spend the caller's tokens (the contract then pulls via
safeTransferFrom). - Sends the CCIP message.
Faster Than Finality (block depth)
The BLOCK_DEPTH environment variable controls send-side finality behavior:
- Omit
BLOCK_DEPTH, or setBLOCK_DEPTH=DEFAULT/BLOCK_DEPTH=0(default): Use finalized finality. - Set
BLOCK_DEPTH=32: Request faster than finality using numeric block depth.
Example 1: Pay with native gas + faster than finality (BLOCK_DEPTH=32)
SOURCE_CHAIN=ETHEREUM_SEPOLIA DEST_CHAIN=ARBITRUM_SEPOLIA \
BLOCK_DEPTH=32 \
TOKEN_AMOUNT=1000000000000000 GAS_LIMIT=200000 \
MESSAGE='Hello from Foundry!' \
forge script foundry/scripts/tutorials/programmable-token-transfers/interact/SendMessage.s.sol \
--account $KEYSTORE_NAME --broadcast -vv
Your terminal should look like this:
========================================
๐ก CCIP Message Transfer - Pay with Native
========================================
Source Chain: Ethereum Sepolia
Destination Chain: Arbitrum Sepolia
Sender: 0x2a0C1c7E6fD5b3A9b8C7d6E5f4A3B2c1D0eF9a8B
Receiver: 0x8A4f2E6c1B9d3D5a7C8e0F2b4D6a8C1e3F5b7A9c
Fee Token: Native (ETH)
========================================
[Pre-validation] Detecting lane version and building extraArgs...
Gas limit (override): 200000
Token pool ALLOWED_FINALITY_CONFIG: 0x00000020 (BLOCK_DEPTH: 32 block(s))
Receiver contract ALLOWED_FINALITY_CONFIG: 0x00000020 (BLOCK_DEPTH: 32 block(s))
โ
Using V3 extraArgs with FTF (gasLimit=200000, finalityConfig=0x00000020 (BLOCK_DEPTH: 32 block(s))).
[Step 1] Approving contract to spend CCIP-BnM...
โ
Contract approved to spend CCIP-BnM
[Step 2] Sending CCIP message with native token fee ( ETH )...
Required CCIP fee (in WEI): 230463107598943
========================================
โ
Message sent successfully!
========================================
CCIP messageId: 0xa1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f91
CCIP Explorer:
https://ccip.chain.link/#/side-drawer/msg/0xa1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f91
Example 2: Pay with LINK + finalized finality (default)
SOURCE_CHAIN=ETHEREUM_SEPOLIA DEST_CHAIN=ARBITRUM_SEPOLIA \
FEE_TOKEN=LINK \
TOKEN_AMOUNT=1000000000000000 GAS_LIMIT=200000 \
MESSAGE='Hello from Foundry!' \
forge script foundry/scripts/tutorials/programmable-token-transfers/interact/SendMessage.s.sol \
--account $KEYSTORE_NAME --broadcast -vv
Your terminal should look like this:
========================================
๐ก CCIP Message Transfer - Pay with LINK
========================================
Source Chain: Ethereum Sepolia
Destination Chain: Arbitrum Sepolia
Sender: 0x2a0C1c7E6fD5b3A9b8C7d6E5f4A3B2c1D0eF9a8B
Receiver: 0x8A4f2E6c1B9d3D5a7C8e0F2b4D6a8C1e3F5b7A9c
Fee Token: LINK
========================================
[Pre-validation] Detecting lane version and building extraArgs...
Gas limit (override): 200000
โ
Using default finality (BLOCK_DEPTH=DEFAULT). V3 extraArgs (gasLimit=200000, finalityConfig=0x00000000).
[Step 1] Approving contract to spend fee token for CCIP fees...
Required CCIP fee (in token units): 28700123456789000
โ
Contract approved to spend fee token
[Step 2] Approving contract to spend CCIP-BnM...
โ
Contract approved to spend CCIP-BnM
[Step 3] Sending CCIP message...
========================================
โ
Message sent successfully!
========================================
CCIP messageId: 0xb2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a2
CCIP Explorer:
https://ccip.chain.link/#/side-drawer/msg/0xb2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a2
Environment variables
| Description | ||
|---|---|---|
KEYSTORE_NAME | Foundry encrypted keystore name used by --account $KEYSTORE_NAME | your_keystore_name |
SOURCE_CHAIN | Source chain name identifier (for example, ETHEREUM_SEPOLIA) | |
DEST_CHAIN | Destination chain name identifier (for example, ARBITRUM_SEPOLIA) | |
{CHAIN}_RPC_URL | RPC URL for each chain you run scripts against (for example, ETHEREUM_SEPOLIA_RPC_URL, ARBITRUM_SEPOLIA_RPC_URL) | |
{CHAIN}_CONTRACT | Deployed tutorial contract address per chain (for example, ETHEREUM_SEPOLIA_CONTRACT, ARBITRUM_SEPOLIA_CONTRACT) | |
FEE_TOKEN | LINK or NATIVE | NATIVE |
FEE_TOKEN_ADDRESS | ERC-20 address of a CCIP-supported fee token on the lane. Takes priority over FEE_TOKEN | |
TOKEN_AMOUNT | Amount of CCIP-BnM to transfer (in wei) | 1000000000000000 (0.001) |
GAS_LIMIT | Gas limit for the destination callback | 200000 |
BLOCK_DEPTH | Omit or set DEFAULT for finalized finality (default), or set 32 for faster than finality | DEFAULT |
ALLOWED_FINALITY_CONFIG | Receiver allowed finality mode(s) used by the configure step. Set to BLOCK_DEPTH to allow numeric faster than finality requests. | |
ALLOWED_BLOCK_DEPTH | Receiver minimum block depth (used when ALLOWED_FINALITY_CONFIG=BLOCK_DEPTH). Use 32 in this tutorial. | |
MESSAGE | Text payload to send | Default String Text |
CHAIN | Chain name identifier used by the receipt verification scripts (the chain where the receiver is deployed) |
Check out the complete script code on Github.
6 Verify receipt on the destination chain
The GetLastReceivedMessageDetails.s.sol script queries the receiver contract for the last received message. This is a read-only operation: no transaction is broadcast.
- Run the script, passing the
CHAINwhere you deployed the receiver:
CHAIN=ARBITRUM_SEPOLIA forge script foundry/scripts/tutorials/programmable-token-transfers/interact/GetLastReceivedMessageDetails.s.sol
Your terminal should look something like this:
========================================
๐ Verify Received Message
========================================
Chain: Arbitrum Sepolia
Receiver Address: 0x8A4f2E6c1B9d3D5a7C8e0F2b4D6a8C1e3F5b7A9c
========================================
Checking for received message...
========================================
โ
Message Received Successfully!
========================================
Message ID: 0xa1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f91
Sender: 0x2a0C1c7E6fD5b3A9b8C7d6E5f4A3B2c1D0eF9a8B
Received Text: "Hello from Foundry!"
Received Token: 0x686325E21F55c64Bf724047E0fe7C454D6faD37D
Received Token Amount: 1000000000000000
========================================
Check out the complete file on Github here:
GetLastReceivedMessageDetails.s.solCheck out the complete script code on Github.
Hardhat
Best for developers who want a mature, TypeScript-based smart contract development framework.
1 Bootstrap a new Hardhat project
Clone the starter kit (contains both Hardhat and Foundry code) for a smoother setup.
- Clone the CCIP 2.0 template repository, and open a terminal inside the project directory:
git clone https://github.com/smartcontractkit/docs-ccip.git && cd docs-ccip
- Copy the example environment file and fill in your values:
cp .env.example .env
Set KEYSTORE_NAME to the keystore alias you will create later in this section, and provide RPC endpoints for the chains you will use:
# Keystore name
KEYSTORE_NAME=your_keystore_name
# RPC URLs (add the ones you need)
ETHEREUM_SEPOLIA_RPC_URL=
ARBITRUM_SEPOLIA_RPC_URL=
# Etherscan API key (required only if you pass --verify to deployment scripts)
ETHERSCAN_API_KEY=
- Install dependencies and compile:
npm install && npx hardhat compile
- Load the environment variables:
source .env
- Create a Hardhat keystore entry for your private key. Use the same name as
KEYSTORE_NAMEin your.envfile. Hardhat will prompt you to enter the private key and a password to encrypt it:
npx hardhat keystore set your_keystore_name
2 Deploy your contracts
Check out the complete script code on Github.
In this section, you will deploy the contracts on the source and destination chains. The next step configures the sender contract to allow the destination chain and the receiver contract to allow the source chain-sender pair.
- The
deploy.tsscript does the following:
- Deploy
ProgrammableTokenTransferson Ethereum Sepolia (source). - Deploy
ProgrammableTokenTransferson Arbitrum Sepolia (destination). - Returns the contract addresses in the terminal
- To run the script, use the following command:
SOURCE_CHAIN=ETHEREUM_SEPOLIA DEST_CHAIN=ARBITRUM_SEPOLIA \
npx hardhat run hardhat/scripts/tutorials/programmable-token-transfers/deploy/deploy.ts
The deploy script prints the deployed contract addresses and follow-up commands. Your terminal should look something like this:
========================================
๐ Deploy CCIP Contracts on Both Chains
========================================
Source Chain: Ethereum Sepolia
Destination Chain: Arbitrum Sepolia
========================================
[Step 1] Deploying ProgrammableTokenTransfers on Ethereum Sepolia
Contract deployed at: 0x4b7C2d9E3F1a6B8c0D2e4F6a8B0c2D4e6F8a0B2c
https://sepolia.etherscan.io/address/0x4b7C2d9E3F1a6B8c0D2e4F6a8B0c2D4e6F8a0B2c
========================================
โ
Deployment Complete on Ethereum Sepolia!
========================================
Source Contract Address: 0x4b7C2d9E3F1a6B8c0D2e4F6a8B0c2D4e6F8a0B2c
[Step 2] Deploying ProgrammableTokenTransfers on Arbitrum Sepolia
Contract deployed at: 0x3E7a9C1f5B2d8A4e6F0c3D7b9E1a5C8f2B4d6A0e
https://sepolia.arbiscan.io/address/0x3E7a9C1f5B2d8A4e6F0c3D7b9E1a5C8f2B4d6A0e
========================================
โ
Deployment Complete on Arbitrum Sepolia!
========================================
Destination Contract Address: 0x3E7a9C1f5B2d8A4e6F0c3D7b9E1a5C8f2B4d6A0e
========================================
โ
All Deployments Complete!
========================================
Source Chain: Ethereum Sepolia
Source Contract: 0x4b7C2d9E3F1a6B8c0D2e4F6a8B0c2D4e6F8a0B2c
https://sepolia.etherscan.io/address/0x4b7C2d9E3F1a6B8c0D2e4F6a8B0c2D4e6F8a0B2c
Destination Chain: Arbitrum Sepolia
Destination Contract: 0x3E7a9C1f5B2d8A4e6F0c3D7b9E1a5C8f2B4d6A0e
https://sepolia.arbiscan.io/address/0x3E7a9C1f5B2d8A4e6F0c3D7b9E1a5C8f2B4d6A0e
Run this command to set both environment variables:
export ETHEREUM_SEPOLIA_CONTRACT=0x4b7C2d9E3F1a6B8c0D2e4F6a8B0c2D4e6F8a0B2c && export ARBITRUM_SEPOLIA_CONTRACT=0x3E7a9C1f5B2d8A4e6F0c3D7b9E1a5C8f2B4d6A0e
========================================
- Before moving on, export the addresses of the previously deployed contracts so that they're available in the terminal:
export ETHEREUM_SEPOLIA_CONTRACT=<sender-contract-address> && \
export ARBITRUM_SEPOLIA_CONTRACT=<receiver-contract-address>
3 Configure allowlists and finality
As a best practice, configure allowlists before sending messages: the sender contract restricts outbound sends by destination chain, and the receiver contract restricts inbound delivery by source chain-sender pair.
configure.tsCheck out the complete script code on Github.
- The
configure.tsscript handles both sides in a single command -- it allowlists the destination chain on the sender contract and allowlists the chain-sender pair on the receiver contract:
SOURCE_CHAIN=ETHEREUM_SEPOLIA DEST_CHAIN=ARBITRUM_SEPOLIA \
ALLOWED_FINALITY_CONFIG=BLOCK_DEPTH \
ALLOWED_BLOCK_DEPTH=32 \
npx hardhat run hardhat/scripts/tutorials/programmable-token-transfers/configure/configure.ts
Finality configuration
To allow numeric faster than finality requests, set ALLOWED_FINALITY_CONFIG=BLOCK_DEPTH and choose a minimum ALLOWED_BLOCK_DEPTH. In this tutorial, ALLOWED_BLOCK_DEPTH=32 allows send-time requests of BLOCK_DEPTH=32 (or higher).
Your terminal should look something like this:
========================================
โ๏ธ Configure CCIP Contracts on Both Chains
========================================
Source Chain: Ethereum Sepolia
Source Contract: 0x4b7C2d9E3F1a6B8c0D2e4F6a8B0c2D4e6F8a0B2c
Destination Chain: Arbitrum Sepolia
Destination Contract: 0x3E7a9C1f5B2d8A4e6F0c3D7b9E1a5C8f2B4d6A0e
========================================
[Step 1] Configuring sender on Ethereum Sepolia
Allowlisting Arbitrum Sepolia as destination chain...
โ
Destination chain allowlisted: Arbitrum Sepolia
========================================
โ
Configuration Complete on Ethereum Sepolia!
========================================
[Step 2] Configuring receiver on Arbitrum Sepolia
Allowlisting sender 0x4b7C2d9E3F1a6B8c0D2e4F6a8B0c2D4e6F8a0B2c from Ethereum Sepolia...
โ
Chain-sender pair allowlisted: Ethereum Sepolia -> 0x4b7C2d9E3F1a6B8c0D2e4F6a8B0c2D4e6F8a0B2c
Setting allowed finality config to 0x00000020 (BLOCK_DEPTH=32)...
โ
Allowed finality config set to 0x00000020 (BLOCK_DEPTH=32) for Ethereum Sepolia
========================================
โ
Configuration Complete on Arbitrum Sepolia!
========================================
========================================
โ
All Configurations Complete!
========================================
Ethereum Sepolia can send messages to Arbitrum Sepolia
Arbitrum Sepolia can receive messages from Ethereum Sepolia
- Optional: enable bidirectional messaging: If you want the destination contract to also send messages back to the source, run the reversed command:
SOURCE_CHAIN=ARBITRUM_SEPOLIA DEST_CHAIN=ETHEREUM_SEPOLIA \
ALLOWED_FINALITY_CONFIG=BLOCK_DEPTH \
ALLOWED_BLOCK_DEPTH=32 \
npx hardhat run hardhat/scripts/tutorials/programmable-token-transfers/configure/configure.ts
4 Fund your wallet with test tokens
Check out the faucet script on Github.
Before sending a CCIP message, you need CCIP-BnM test tokens in your wallet. The send scripts transfer CCIP-BnM from your EOA to the contract, so your wallet must hold a balance.
Use the faucet script included in the starter kit to drip CCIP-BnM tokens to your address:
CHAIN=ETHEREUM_SEPOLIA \
RECIPIENT_ADDRESS=<your-wallet-address> \
npx hardhat run hardhat/scripts/faucet/drip-bnm-token.ts
If you plan to pay CCIP fees in LINK (instead of native gas), you also need LINK tokens. Get test LINK from the Chainlink faucet.
5 Send a message
send-message.ts is a unified send script that handles both native and LINK fee payments. Set FEE_TOKEN=LINK to pay with LINK, or omit it (defaults to NATIVE) to pay with the native gas token. The script:
- Builds off-chain
extraArgsfor the lane. Hardhat uses@chainlink/ccip-sdkand the receiver policy check inbuildExtraArgsto encode V2 or V3. - Approves the contract to spend the caller's tokens (the contract then pulls via
safeTransferFrom). - Sends the CCIP message.
Dynamic gas estimation
When GAS_LIMIT is not set, the script uses estimateReceiveExecution from @chainlink/ccip-sdk to dynamically estimate the required gas limit for the destination callback. This avoids wasting gas by over-estimating, or failing by under-estimating. If estimation fails, it falls back to 200000. You can override by setting GAS_LIMIT explicitly.
Faster Than Finality (block depth)
The BLOCK_DEPTH environment variable controls send-side finality behavior:
- Omit
BLOCK_DEPTH, or setBLOCK_DEPTH=DEFAULT/BLOCK_DEPTH=0(default): Use finalized finality. - Set
BLOCK_DEPTH=32: Request faster than finality using numeric block depth.
Example 1: Pay with native gas + faster than finality (BLOCK_DEPTH=32) + explicit gas limit
SOURCE_CHAIN=ETHEREUM_SEPOLIA DEST_CHAIN=ARBITRUM_SEPOLIA \
BLOCK_DEPTH=32 \
TOKEN_AMOUNT=1000000000000000 \
MESSAGE="Hello from Hardhat" \
GAS_LIMIT=200000 \
FEE_TOKEN=NATIVE \
npx hardhat run hardhat/scripts/tutorials/programmable-token-transfers/interact/send-message.ts
Your terminal should look like this:
========================================
๐ก CCIP Message Transfer - Pay with ETH
========================================
Source Chain: Ethereum Sepolia
Destination Chain: Arbitrum Sepolia
Sender: 0x4b7C2d9E3F1a6B8c0D2e4F6a8B0c2D4e6F8a0B2c
Receiver: 0x3E7a9C1f5B2d8A4e6F0c3D7b9E1a5C8f2B4d6A0e
Fee Token: Native (ETH)
========================================
[Pre-validation] Querying lane features and receiver contract...
Gas limit (override): 200000
Token pool ALLOWED_FINALITY_CONFIG: 0x00000020 (BLOCK_DEPTH: 32 block(s))
Receiver contract ALLOWED_FINALITY_CONFIG: 0x00000020 (BLOCK_DEPTH: 32 block(s))
โ
Using V3 extraArgs with FTF (gasLimit=200000, finalityConfig=32 block(s)).
[Pre-validation] CCIP fee: 230463107598943
[Step 1] Approving contract to spend CCIP-BnM...
โ
Contract approved to spend CCIP-BnM
[Step 2] Sending CCIP message with native token fee (ETH)...
Required CCIP fee (in WEI): 230463107598943
========================================
โ
Message sent successfully!
========================================
CCIP messageId: 0xc3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b3
CCIP Explorer:
https://ccip.chain.link/#/side-drawer/msg/0xc3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b3
Example 2: Pay with LINK + finalized finality (default) + dynamic gas estimation
This example deliberately omits GAS_LIMIT to demonstrate dynamic estimation via estimateReceiveExecution:
SOURCE_CHAIN=ETHEREUM_SEPOLIA DEST_CHAIN=ARBITRUM_SEPOLIA \
TOKEN_AMOUNT=1000000000000000 \
MESSAGE="Hello from Hardhat" \
FEE_TOKEN=LINK \
npx hardhat run hardhat/scripts/tutorials/programmable-token-transfers/interact/send-message.ts
Your terminal should look like this:
========================================
๐ก CCIP Message Transfer - Pay with LINK
========================================
Source Chain: Ethereum Sepolia
Destination Chain: Arbitrum Sepolia
Sender: 0x4b7C2d9E3F1a6B8c0D2e4F6a8B0c2D4e6F8a0B2c
Receiver: 0x3E7a9C1f5B2d8A4e6F0c3D7b9E1a5C8f2B4d6A0e
Fee Token: LINK
========================================
[Pre-validation] Querying lane features and receiver contract...
Gas limit (estimated): 198765
โ
Using default finality (BLOCK_DEPTH=DEFAULT). V3 extraArgs (gasLimit=198765, finalityConfig=0x00000000).
[Pre-validation] CCIP fee: 28700123456789000
[Step 1] Approving contract to spend LINK for CCIP fees...
Required CCIP fee (in LINK units): 28700123456789000
โ
Contract approved to spend LINK
[Step 2] Approving contract to spend CCIP-BnM...
โ
Contract approved to spend CCIP-BnM
[Step 3] Sending CCIP message...
========================================
โ
Message sent successfully!
========================================
CCIP messageId: 0xd4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c4
CCIP Explorer:
https://ccip.chain.link/#/side-drawer/msg/0xd4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c4
Environment variables
| Description | ||
|---|---|---|
KEYSTORE_NAME | Hardhat keystore entry name, set in .env | your_keystore_name |
SOURCE_CHAIN | Source chain name identifier (for example, ETHEREUM_SEPOLIA) | |
DEST_CHAIN | Destination chain name identifier (for example, ARBITRUM_SEPOLIA) | |
{CHAIN}_RPC_URL | RPC URL for each chain you run scripts against (for example, ETHEREUM_SEPOLIA_RPC_URL, ARBITRUM_SEPOLIA_RPC_URL) | |
{CHAIN}_CONTRACT | Deployed tutorial contract address per chain (for example, ETHEREUM_SEPOLIA_CONTRACT, ARBITRUM_SEPOLIA_CONTRACT) | |
FEE_TOKEN | LINK or NATIVE | NATIVE |
FEE_TOKEN_ADDRESS | ERC-20 address of a CCIP-supported fee token on the lane. Takes priority over FEE_TOKEN | |
TOKEN_AMOUNT | Amount of CCIP-BnM to transfer (in wei) | 1000000000000000 (0.001) |
GAS_LIMIT | Gas limit for the destination callback (if unset, estimated dynamically via estimateReceiveExecution) | |
BLOCK_DEPTH | Omit or set DEFAULT for finalized finality (default), or set 32 for faster than finality | DEFAULT |
ALLOWED_FINALITY_CONFIG | Receiver allowed finality mode(s) used by the configure step. Set to BLOCK_DEPTH to allow numeric faster than finality requests. | |
ALLOWED_BLOCK_DEPTH | Receiver minimum block depth (used when ALLOWED_FINALITY_CONFIG=BLOCK_DEPTH). Use 32 in this tutorial. | |
MESSAGE | Text payload to send | Default String Text |
CHAIN | Chain name identifier used by the receipt verification scripts (the chain where the receiver is deployed) |
Check out the complete script code on Github.
6 Verify receipt on the destination chain
Check out the complete script code on Github.
The get-last-received-message-details.ts script queries the receiver contract for the last received message. This is a read-only operation: no transaction is actually broadcast to the destination chain.
- Run the script, passing the
CHAINwhere you deployed the receiver:
CHAIN=ARBITRUM_SEPOLIA \
npx hardhat run hardhat/scripts/tutorials/programmable-token-transfers/interact/get-last-received-message-details.ts
Your terminal should look something like this:
========================================
๐ Verify Received Message
========================================
Chain: Arbitrum Sepolia
Receiver Address: 0x3E7a9C1f5B2d8A4e6F0c3D7b9E1a5C8f2B4d6A0e
========================================
Checking for received message...
========================================
โ
Message Received Successfully!
========================================
Message ID: 0xc3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b3
Sender: 0x4b7C2d9E3F1a6B8c0D2e4F6a8B0c2D4e6F8a0B2c
Received Text: "Hello from Hardhat"
Received Token: 0x686325E21F55c64Bf724047E0fe7C454D6faD37D
Received Token Amount: 1000000000000000
========================================