Transfer Tokens Between Chains from Smart Contracts
In this tutorial, we will use CCIP to transfer CCIP-BnM tokens from a smart contract on Ethereum Sepolia to an externally owned account (EOA) on Arbitrum Sepolia. The example uses off-chain extraArgs encoding so the scripts can select the correct finality configuration for the lane.
We will send the transfer twice:
- Once, paying CCIP fees in native gas tokens
ETHand using faster than finality (BLOCK_DEPTH=32). - And again, 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. Learn how to Acquire testnet LINK. - Check the CCIP Directory if you want to configure a different set of source and destination chains/tokens.
- Acquire CCIP test tokens. You should have
CCIP-BnMtokens, andCCIP-BnMshould appear in the list of your tokens in MetaMask.
Examine the code
1 Initializing the contract
When deploying the contract, you provide the CCIP router address for the source chain. The router address is useful for the following:
- Calls the router's
getFeefunction to estimate CCIP fees. - Calls the router's
ccipSendfunction to send CCIP messages.
Some key things to note:
OwnerIsCreatorsets the deployer as the owner of the contract.- This is a sender-only contract: it does not inherit from
CCIPReceiverbecause no receiver callback is executed on the destination chain. Tokens can be sent to any address (EOA or contract) on the destination chain. 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, oraddress(0)to pay in native gas. The function accepts pre-encoded_extraArgsbytes built off-chain, making the contract forward-compatible with any extraArgs version.- Access control is enforced through allowlisting: destination chains are gated by the
onlyAllowlistedDestinationChainmodifier, andvalidateReceiverprevents sending to the zero address.
contract TokenTransferor is OwnerIsCreator {
using SafeERC20 for IERC20;
IRouterClient private s_router;
constructor(address _router) {
s_router = IRouterClient(_router);
}
// ... modifiers, allowlist admin functions ...
function sendMessage(
uint64 _destinationChainSelector,
address _receiver,
address _token,
uint256 _amount,
address _feeTokenAddress,
bytes calldata _extraArgs
)
external
payable
onlyAllowlistedDestinationChain(_destinationChainSelector)
validateReceiver(_receiver)
returns (bytes32 messageId)
{
messageId = _sendCCIPMessage(
_destinationChainSelector, _receiver, _token, _amount, _feeTokenAddress, _extraArgs
);
}
function getFee(
uint64 _destinationChainSelector,
address _receiver,
address _token,
uint256 _amount,
address _feeTokenAddress,
bytes calldata _extraArgs
) external view returns (uint256 fees) {
Client.EVM2AnyMessage memory evm2AnyMessage = _buildCCIPMessage(
_receiver, _token, _amount, _feeTokenAddress, _extraArgs
);
fees = s_router.getFee(_destinationChainSelector, evm2AnyMessage);
}
// ... internal helpers (message building, fee handling, approvals) ...
}
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: Empty ("") because this tutorial transfers tokens only (no data payload).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 finality (default), the scripts encode V3 extraArgs with finalized finality. For faster than finality requests (BLOCK_DEPTH=32), 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,
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: "",
tokenAmounts: tokenAmounts,
extraArgs: _extraArgs,
feeToken: _feeTokenAddress
});
}
3 Sending tokens
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,
address _token,
uint256 _amount,
address _feeTokenAddress,
bytes calldata _extraArgs
) private returns (bytes32 messageId) {
Client.EVM2AnyMessage memory evm2AnyMessage = _buildCCIPMessage(
_receiver, _token, _amount, _feeTokenAddress, _extraArgs
);
uint256 ccipFee = s_router.getFee(_destinationChainSelector, evm2AnyMessage);
_handleFeeAndTokenApprovals(s_router, _token, _amount, _feeTokenAddress, ccipFee);
if (_feeTokenAddress == address(0)) {
messageId = s_router.ccipSend{value: ccipFee}(_destinationChainSelector, evm2AnyMessage);
} else {
messageId = s_router.ccipSend(_destinationChainSelector, evm2AnyMessage);
}
emit TokensTransferred(
messageId, _destinationChainSelector, _receiver, _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);
}
}
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 contract
In this section, you will deploy TokenTransferor on the source chain (Ethereum Sepolia).
Unlike the programmable token transfers tutorial, no destination contract is needed because tokens are transferred to an address (EOA or contract) on the destination chain.
Run the script:
SOURCE_CHAIN=ETHEREUM_SEPOLIA forge script foundry/scripts/tutorials/token-transfers/deploy/Deploy.s.sol \
--account $KEYSTORE_NAME \
--broadcast -vv
Your terminal output will look like this:
========================================
๐ Deploy TokenTransferor Contract
========================================
Source Chain: Ethereum Sepolia
========================================
[Step 1] Deploying TokenTransferor on Ethereum Sepolia
Contract deployed at: 0x1D4CC0abA2C8401382497FBa4e8d4CEAabE73901
https://sepolia.etherscan.io/address/0x1D4CC0abA2C8401382497FBa4e8d4CEAabE73901
========================================
โ
Deployment Complete!
========================================
Chain: Ethereum Sepolia
Contract: 0x1D4CC0abA2C8401382497FBa4e8d4CEAabE73901
https://sepolia.etherscan.io/address/0x1D4CC0abA2C8401382497FBa4e8d4CEAabE73901
Run this command to set the environment variable:
export ETHEREUM_SEPOLIA_CONTRACT=0x1D4CC0abA2C8401382497FBa4e8d4CEAabE73901
========================================
** Next Step: Configuration **
Allowlist the destination chain:
SOURCE_CHAIN=ETHEREUM_SEPOLIA DEST_CHAIN=ARBITRUM_SEPOLIA forge script foundry/scripts/tutorials/token-transfers/configure/Configure.s.sol:Configure --account $KEYSTORE_NAME --broadcast -vv
========================================
Save the exported contract address from the output:
export ETHEREUM_SEPOLIA_CONTRACT=<your-deployed-contract-address>
Check out the complete script code on Github.
3 Configure allowlists
As a best practice, allowlist the destination chain before sending tokens.
Configure.s.solCheck out the complete script code on Github.
- If you haven't already, export the address of the deployed contract so that it's available in the terminal:
export ETHEREUM_SEPOLIA_CONTRACT=<your-deployed-contract-address>
- Run the configure script to allowlist the destination chain:
SOURCE_CHAIN=ETHEREUM_SEPOLIA DEST_CHAIN=ARBITRUM_SEPOLIA \
forge script foundry/scripts/tutorials/token-transfers/configure/Configure.s.sol \
--account $KEYSTORE_NAME --broadcast -vv
Your terminal output will look like this:
========================================
โ๏ธ Configure TokenTransferor
========================================
Source Chain: Ethereum Sepolia
Source Contract: 0xD18B78Ce9f4dc76b65ECD22F784352041B92Adc1
Destination Chain: Arbitrum Sepolia
========================================
Configuring TokenTransferor on Ethereum Sepolia
Allowlisting Arbitrum Sepolia as destination chain...
โ
Destination chain allowlisted: Arbitrum Sepolia
========================================
โ
Configuration Complete!
========================================
Ethereum Sepolia can send tokens to Arbitrum Sepolia
** Next Step: Transfer Tokens **
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 Transfer Tokens
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:
- Detects the lane version and encodes the correct extraArgs (V2 for pre-v2.0 lanes, V3 for v2.0+ lanes) via
ExtraArgsHelper.buildExtraArgs. - Approves the contract to spend the caller's tokens (the contract then pulls via
safeTransferFrom). - Sends the CCIP token transfer.
Faster Than Finality (block depth)
Set the receiver address. This is the EOA or contract on the destination chain that will receive the transferred CCIP-BnM tokens:
export RECEIVER_ADDRESS=<receiver-address>
Example 1: Pay with native gas + faster than finality (BLOCK_DEPTH=32)
SOURCE_CHAIN=ETHEREUM_SEPOLIA DEST_CHAIN=ARBITRUM_SEPOLIA \
RECEIVER_ADDRESS=$RECEIVER_ADDRESS \
BLOCK_DEPTH=32 \
forge script foundry/scripts/tutorials/token-transfers/interact/SendMessage.s.sol \
--account $KEYSTORE_NAME --broadcast -vv
Your terminal should look like this:
========================================
๐ก CCIP Token Transfer - Pay with Native
========================================
Source Chain: Ethereum Sepolia
Destination Chain: Arbitrum Sepolia
Sender: 0x1D4CC0abA2C8401382497FBa4e8d4CEAabE73901
Receiver: 0x3A34637a41aB08519d30Fdb65344aBa8E9b2e994
Fee Token: Native (ETH)
========================================
[Pre-validation] Detecting lane version and building extraArgs...
Token pool ALLOWED_FINALITY_CONFIG: 0x00000020 (BLOCK_DEPTH: 32 block(s))
Receiver is an EOA โ no receiver constraint.
โ
Using V3 extraArgs with FTF (gasLimit=0, 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 token transfer with native token fee ( ETH )...
Required CCIP fee (in WEI): 130463107598943
========================================
โ
Tokens transferred successfully!
========================================
CCIP messageId: 0xc1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f6
CCIP Explorer:
https://ccip.chain.link/#/side-drawer/msg/0xc1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f6
Example 2: Pay with LINK + finalized finality (default)
SOURCE_CHAIN=ETHEREUM_SEPOLIA DEST_CHAIN=ARBITRUM_SEPOLIA \
RECEIVER_ADDRESS=$RECEIVER_ADDRESS \
FEE_TOKEN=LINK \
forge script foundry/scripts/tutorials/token-transfers/interact/SendMessage.s.sol \
--account $KEYSTORE_NAME --broadcast -vv
Your terminal should look like this:
========================================
๐ก CCIP Token Transfer - Pay with LINK
========================================
Source Chain: Ethereum Sepolia
Destination Chain: Arbitrum Sepolia
Sender: 0x1D4CC0abA2C8401382497FBa4e8d4CEAabE73901
Receiver: 0x3A34637a41aB08519d30Fdb65344aBa8E9b2e994
Fee Token: LINK
========================================
[Pre-validation] Detecting lane version and building extraArgs...
โ
Using default finality (BLOCK_DEPTH=DEFAULT). V3 extraArgs (gasLimit=0, finalityConfig=0x00000000).
[Step 1] Approving contract to spend fee token for CCIP fees...
Required CCIP fee (in token units): 26110523180283030
โ
Contract approved to spend fee token
[Step 2] Approving contract to spend CCIP-BnM...
โ
Contract approved to spend CCIP-BnM
[Step 3] Sending CCIP token transfer...
========================================
โ
Tokens transferred successfully!
========================================
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 on the source chain (for example, ETHEREUM_SEPOLIA_CONTRACT) | |
RECEIVER_ADDRESS | Destination receiver address for the token transfer and balance verification | |
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) |
BLOCK_DEPTH | Omit or set DEFAULT for finalized finality (default), or set 32 for faster than finality | DEFAULT |
Check out the complete script code on Github.
6 Verify transfer on the destination chain
After sending the transfer, you can verify it was successful:
-
Use the CCIP Explorer link printed in the send output to track the message status in real time.
-
Once the message is marked as delivered, check the receiver's token balance on the destination chain. You can use a block explorer or the following command:
cast call <CCIP_BNM_TOKEN_ADDRESS_ON_DESTINATION> "balanceOf(address)(uint256)" $RECEIVER_ADDRESS --rpc-url $ARBITRUM_SEPOLIA_RPC_URL --chain arbitrum-sepolia
Find the CCIP-BnM token address for each chain in the CCIP Directory.
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
- 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 contract
Check out the complete script code on Github.
In this section, you will deploy TokenTransferor on the source chain (Ethereum Sepolia). No destination deployment is required.
Run the script:
SOURCE_CHAIN=ETHEREUM_SEPOLIA npx hardhat run hardhat/scripts/tutorials/token-transfers/deploy/deploy.ts
Your terminal output will look like this:
========================================
๐ Deploy TokenTransferor Contract
========================================
Source Chain: Ethereum Sepolia
========================================
[Step 1] Deploying TokenTransferor on Ethereum Sepolia
Contract deployed at: 0x67b6e59e7b3d36ee90890935a1d13e87734e94d1
https://sepolia.etherscan.io/address/0x67b6e59e7b3d36ee90890935a1d13e87734e94d1
========================================
โ
Deployment Complete!
========================================
Chain: Ethereum Sepolia
Contract: 0x67b6e59e7b3d36ee90890935a1d13e87734e94d1
https://sepolia.etherscan.io/address/0x67b6e59e7b3d36ee90890935a1d13e87734e94d1
Run this command to set the environment variable:
export ETHEREUM_SEPOLIA_CONTRACT=0x67b6e59e7b3d36ee90890935a1d13e87734e94d1
========================================
** Next Step: Configuration **
Allowlist the destination chain:
SOURCE_CHAIN=ETHEREUM_SEPOLIA DEST_CHAIN=ARBITRUM_SEPOLIA npx hardhat run hardhat/scripts/tutorials/token-transfers/configure/configure.ts
========================================
Before moving on, export the address of the deployed contract so that it's available in the terminal:
export ETHEREUM_SEPOLIA_CONTRACT=<your-deployed-contract-address>
3 Configure allowlists
As a best practice, allowlist the destination chain before sending tokens.
configure.tsCheck out the complete script code on Github.
- The
configure.tsscript allowlists the destination chain on the sender contract:
SOURCE_CHAIN=ETHEREUM_SEPOLIA DEST_CHAIN=ARBITRUM_SEPOLIA \
npx hardhat run hardhat/scripts/tutorials/token-transfers/configure/configure.ts
Your terminal output will look like this:
========================================
โ๏ธ Configure TokenTransferor
========================================
Source Chain: Ethereum Sepolia
Source Contract: 0x67b6e59e7b3d36ee90890935a1d13e87734e94d1
Destination Chain: Arbitrum Sepolia
========================================
Configuring TokenTransferor on Ethereum Sepolia
Allowlisting Arbitrum Sepolia as destination chain...
โ
Destination chain allowlisted: Arbitrum Sepolia
========================================
โ
Configuration Complete!
========================================
Ethereum Sepolia can send tokens to Arbitrum Sepolia
** Next Step: Transfer Tokens **
Transfer tokens (pay with native gas, default):
SOURCE_CHAIN=ETHEREUM_SEPOLIA DEST_CHAIN=ARBITRUM_SEPOLIA RECEIVER_ADDRESS=$RECEIVER_ADDRESS BLOCK_DEPTH=DEFAULT npx hardhat run hardhat/scripts/tutorials/token-transfers/interact/send-message.ts
Or pay with LINK:
SOURCE_CHAIN=ETHEREUM_SEPOLIA DEST_CHAIN=ARBITRUM_SEPOLIA RECEIVER_ADDRESS=$RECEIVER_ADDRESS FEE_TOKEN=LINK BLOCK_DEPTH=DEFAULT npx hardhat run hardhat/scripts/tutorials/token-transfers/interact/send-message.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 Transfer Tokens
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:
- Queries lane features via
@chainlink/ccip-sdkto encode the correct extraArgs (V2 for pre-v2.0 lanes, V3 for v2.0+ lanes). - Approves the contract to spend the caller's tokens (the contract then pulls via
safeTransferFrom). - Sends the CCIP token transfer.
Faster Than Finality (block depth)
Set the receiver address. This is the EOA or contract on the destination chain that will receive the transferred CCIP-BnM tokens:
export RECEIVER_ADDRESS=<receiver-address>
Example 1: Pay with native gas + faster than finality (BLOCK_DEPTH=32)
SOURCE_CHAIN=ETHEREUM_SEPOLIA DEST_CHAIN=ARBITRUM_SEPOLIA \
RECEIVER_ADDRESS=$RECEIVER_ADDRESS \
BLOCK_DEPTH=32 \
npx hardhat run hardhat/scripts/tutorials/token-transfers/interact/send-message.ts
Your terminal should look like this:
========================================
๐ก CCIP Token Transfer - Pay with ETH
========================================
Source Chain: Ethereum Sepolia
Destination Chain: Arbitrum Sepolia
Sender: 0x67b6e59e7b3d36ee90890935a1d13e87734e94d1
Receiver: 0x3A34637a41aB08519d30Fdb65344aBa8E9b2e994
Fee Token: Native (ETH)
========================================
[Pre-validation] Querying lane features and building extraArgs...
Token pool ALLOWED_FINALITY_CONFIG: 0x00000020 (BLOCK_DEPTH: 32 block(s))
Receiver is an EOA โ no receiver constraint.
โ
Using V3 extraArgs with FTF (gasLimit=0, finalityConfig=32 block(s)).
[Pre-validation] CCIP fee: 130463107598943
[Step 1] Approving contract to spend CCIP-BnM...
โ
Contract approved to spend CCIP-BnM
[Step 2] Sending CCIP token transfer with native token fee (ETH)...
Required CCIP fee (in WEI): 130463107598943
========================================
โ
Tokens transferred successfully!
========================================
CCIP messageId: 0xd2e3f4a5b6c7d8e9f0a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f607
CCIP Explorer:
https://ccip.chain.link/#/side-drawer/msg/0xd2e3f4a5b6c7d8e9f0a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f607
Example 2: Pay with LINK + finalized finality (default)
SOURCE_CHAIN=ETHEREUM_SEPOLIA DEST_CHAIN=ARBITRUM_SEPOLIA \
RECEIVER_ADDRESS=$RECEIVER_ADDRESS \
FEE_TOKEN=LINK \
npx hardhat run hardhat/scripts/tutorials/token-transfers/interact/send-message.ts
Your terminal should look like this:
========================================
๐ก CCIP Token Transfer - Pay with LINK
========================================
Source Chain: Ethereum Sepolia
Destination Chain: Arbitrum Sepolia
Sender: 0x67b6e59e7b3d36ee90890935a1d13e87734e94d1
Receiver: 0x3A34637a41aB08519d30Fdb65344aBa8E9b2e994
Fee Token: LINK
========================================
[Pre-validation] Querying lane features and building extraArgs...
โ
Using default finality (BLOCK_DEPTH=DEFAULT). V3 extraArgs (gasLimit=0, finalityConfig=0x00000000).
[Pre-validation] CCIP fee: 26110523180283030
[Step 1] Approving contract to spend LINK for CCIP fees...
Required CCIP fee (in LINK units): 26110523180283030
โ
Contract approved to spend LINK
[Step 2] Approving contract to spend CCIP-BnM...
โ
Contract approved to spend CCIP-BnM
[Step 3] Sending CCIP token transfer...
========================================
โ
Tokens transferred successfully!
========================================
CCIP messageId: 0xe3f4a5b6c7d8e9f0a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718
CCIP Explorer:
https://ccip.chain.link/#/side-drawer/msg/0xe3f4a5b6c7d8e9f0a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718
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 on the source chain (for example, ETHEREUM_SEPOLIA_CONTRACT) | |
RECEIVER_ADDRESS | Destination receiver address for the token transfer and balance verification | |
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) |
BLOCK_DEPTH | Omit or set DEFAULT for finalized finality (default), or set 32 for faster than finality | DEFAULT |
Check out the complete script code on Github.
6 Verify transfer on the destination chain
After sending the transfer, you can verify it was successful:
-
Use the CCIP Explorer link printed in the send output to track the message status in real time.
-
Once the message is marked as delivered, check the receiver's token balance on the destination chain using a block explorer or any on-chain query tool.
Find the CCIP-BnM token address for each chain in the CCIP Directory.