Send Arbitrary Data

In this tutorial, we will use CCIP to send arbitrary string data from one chain to another.

We will send a string payload from Ethereum Sepolia to Arbitrum Sepolia twice:

  1. Once, using native gas tokens ETH and numeric faster than finality.
  2. And then a second time, using LINK tokens and finalized finality (default).

Before you begin

  1. You should understand how to write, compile, deploy, and fund a smart contract. Go through this tutorial to get started.
  2. Your account must have some ETH and LINK tokens on Ethereum Sepolia and ETH tokens on Arbitrum Sepolia. Learn how to Acquire testnet LINK.
  3. Check the CCIP Directory if you want to configure a different source and destination chain pair.

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:

    • Calls the router's getFee function to estimate the CCIP fees.
    • Calls the router's ccipSend function to send CCIP messages.
  • Receiver part:

    • The contract inherits from CCIPReceiver, which serves as a base contract for receiver contracts. This contract requires that child contracts implement the _ccipReceive function.
    • _ccipReceive is called by the ccipReceive function, which ensures that only the router can deliver CCIP messages to the receiver contract.

Some key things to note:

  • OwnerIsCreator sets the deployer as the owner of the contract.
  • The constructor passes the router address into CCIPReceiver at deployment time.
  • sendMessage is payable and open to any caller. It handles both LINK and native fee payments:
    • pass the LINK token address as _feeTokenAddress to pay in LINK, or
    • address(0) to pay in native gas.
  • The function accepts pre-encoded _extraArgs bytes 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)
    • inbound messages are restricted by source chain selector + source sender contract pair (onlyAllowlisted)
  • The contract overrides getCCVsAndFinalityConfig from CCIPReceiver to advertise per-source-chain receiver finality policy to the OffRamp. See Configure receiver finality policy below.
Messenger.sol
// Imports

contract Messenger is CCIPReceiver, OwnerIsCreator {
    constructor(address _router) CCIPReceiver(_router) {}

    // ... state variables, modifiers, allowlist admin functions ...

    function sendMessage(
        uint64 _destinationChainSelector,
        address _receiver,
        string calldata _text,
        address _feeTokenAddress,
        bytes calldata _extraArgs
    )
        external
        payable
        onlyAllowlistedDestinationChain(_destinationChainSelector)
        validateReceiver(_receiver)
        returns (bytes32 messageId)
    {
        messageId = _sendCCIPMessage(_destinationChainSelector, _receiver, _text, _feeTokenAddress, _extraArgs);
    }

    // ... internal helpers (fee handling and message building) ...

    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));

        emit MessageReceived( /* ... */ );
    }

    // ... getCCVsAndFinalityConfig override and withdrawal utilities ...
}
2 Build transaction payload

_sendCCIPMessage calls the _buildCCIPMessage helper to build a CCIP message payload using the 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: An empty EVMTokenAmount array because this tutorial sends data only.
  • extraArgs: Pre-encoded message execution parameters built off-chain by a helper script. For finalized finality, the scripts encode V3 extraArgs with the finalized finality config. For non-default finality requests, the scripts probe whether the lane accepts V3 extraArgs for message-only sends and use V3 requestedFinalityConfig when available, 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, or address(0) to pay in native gas.
_buildCCIPMessage
function _buildCCIPMessage(
    address _receiver,
    string calldata _text,
    address _feeTokenAddress,
    bytes calldata _extraArgs
) private pure returns (Client.EVM2AnyMessage memory) {
    return Client.EVM2AnyMessage({
        receiver: abi.encode(_receiver),
        data: abi.encode(_text),
        tokenAmounts: new Client.EVMTokenAmount[](0),
        extraArgs: _extraArgs,
        feeToken: _feeTokenAddress
    });
}
3 Sending messages

The public sendMessage function delegates to _sendCCIPMessage, which performs four operations:

  1. Builds the message payload by calling _buildCCIPMessage. See Build transaction payload for details.
  2. Computes the fees by invoking the router's getFee function.
  3. Pulls ERC-20 fee tokens from the caller and grants the router the required approval, or validates that msg.value covers the fee when paying in native gas.
  4. Dispatches the CCIP message by executing the router's ccipSend function. 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.

_sendCCIPMessage
function _sendCCIPMessage(
    uint64 _destinationChainSelector,
    address _receiver,
    string calldata _text,
    address _feeTokenAddress,
    bytes calldata _extraArgs
) private returns (bytes32 messageId) {
    Client.EVM2AnyMessage memory evm2AnyMessage =
        _buildCCIPMessage(_receiver, _text, _feeTokenAddress, _extraArgs);

    IRouterClient router = IRouterClient(this.getRouter());
    uint256 ccipFee = router.getFee(_destinationChainSelector, evm2AnyMessage);

    _handleFeeApprovals(router, _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, _feeTokenAddress, ccipFee);

    return messageId;
}
4 Handling fees and approvals

The contract uses a pull-from-caller model for ERC-20 fees: when a user calls sendMessage with an ERC-20 fee token, the contract pulls the required fee from msg.sender via safeTransferFrom, then approves the Router to spend it via forceApprove. The caller must approve this contract before calling sendMessage.

_handleFeeApprovals handles two scenarios:

  1. Native fee (_feeTokenAddress == address(0)): Validates that msg.value covers the CCIP fee.
  2. ERC-20 fee token: Pulls ccipFee from the caller and approves the Router for the same amount.
_handleFeeApprovals
function _handleFeeApprovals(IRouterClient _router, address _feeTokenAddress, uint256 _ccipFee) private {
    if (_feeTokenAddress == address(0)) {
        if (msg.value < _ccipFee) {
            revert InsufficientNativeForFees(msg.value, _ccipFee);
        }
    } else {
        IERC20(_feeTokenAddress).safeTransferFrom(msg.sender, address(this), _ccipFee);
        IERC20(_feeTokenAddress).forceApprove(address(_router), _ccipFee);
    }
}
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 sender address in bytes format. The address is decoded from bytes to an Ethereum address using the ABI specifications and stored in s_lastReceivedSender.
  • The data, which is also in bytes format. Given a string is expected, the data is decoded from bytes to a string using the ABI specifications.

Note: Two important security measures are applied:

  • _ccipReceive is called by the ccipReceive function, which ensures that only the router can deliver CCIP messages to the receiver contract. See the onlyRouter modifier for more information.
  • The modifier onlyAllowlisted ensures that only a call from an allowlisted source chain and sender pair is accepted.
_ccipReceive
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));

    emit MessageReceived(
        any2EvmMessage.messageId,
        any2EvmMessage.sourceChainSelector,
        s_lastReceivedSender,
        s_lastReceivedText
    );
}
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 set BLOCK_DEPTH=DEFAULT).
  • Numeric block depth (faster than finality): Set BLOCK_DEPTH=32 (or higher). This tutorial standardizes on 32.

This contract uses two functions to manage receiver-side finality policy:

  1. setAllowedFinalityConfig: An owner-only setter that stores the FinalityCodec-encoded policy for a source chain.
  2. 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 and optionalThreshold = 0.
Receiver finality policy
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];
}
Messenger.sol

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
Foundry Starter Kit

Clone the Foundry Starter Kit for a smoother setup.

  1. Clone the CCIP 2.0 template repository, and open a terminal inside the project directory:
Terminal
git clone https://github.com/smartcontractkit/docs-ccip.git && cd docs-ccip
  1. If you don't already have a Foundry keystore, use the cast command to create a new one. Here, your_keystore_name is the alias you assign to this keystore entry. Foundry will prompt you to enter the actual private key and a password to encrypt it:
Terminal
cast wallet import your_keystore_name --interactive
  1. Install dependencies:
Terminal
npm install
  1. Create a .env file by copying the example file, and fill in your values:
Terminal
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:

.env
# 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=
  1. Load the environment variables:
Terminal
source .env
  1. Run the following command to compile all the contracts:
Terminal
forge build
2 Deploy your contracts
Deploy.s.sol

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.s.sol script does the following:

  • Deploys Messenger on Ethereum Sepolia (source).
  • Deploys Messenger on Arbitrum Sepolia (destination).
  • Returns the contract addresses in the terminal.

To run the script, use the following command:

Terminal
SOURCE_CHAIN=ETHEREUM_SEPOLIA DEST_CHAIN=ARBITRUM_SEPOLIA \
forge script foundry/scripts/tutorials/send-arbitrary-data/deploy/Deploy.s.sol \
--account $KEYSTORE_NAME \
--broadcast -vv

Your terminal should look something like this:

Terminal
========================================
๐Ÿš€ Deploy CCIP Messenger Contracts on Both Chains
========================================
Source Chain: Ethereum Sepolia
Destination Chain: Arbitrum Sepolia
========================================


[Step 1] Deploying Messenger on Ethereum Sepolia
Contract deployed at: 0x385240F511f64c25739fD05fC28a523649086491
https://sepolia.etherscan.io/address/0x385240F511f64c25739fD05fC28a523649086491

========================================
โœ… Deployment Complete on Ethereum Sepolia!
========================================
Source Contract Address: 0x385240F511f64c25739fD05fC28a523649086491


[Step 2] Deploying Messenger on Arbitrum Sepolia
Contract deployed at: 0x7F3a1C8e2B5d9A4f6E0c3D7b1F9a2E5c8B4d6A0F
https://sepolia.arbiscan.io/address/0x7F3a1C8e2B5d9A4f6E0c3D7b1F9a2E5c8B4d6A0F

========================================
โœ… Deployment Complete on Arbitrum Sepolia!
========================================
Destination Contract Address: 0x7F3a1C8e2B5d9A4f6E0c3D7b1F9a2E5c8B4d6A0F

========================================
โœ… All Deployments Complete!
========================================
Source Chain: Ethereum Sepolia
Source Contract: 0x385240F511f64c25739fD05fC28a523649086491
https://sepolia.etherscan.io/address/0x385240F511f64c25739fD05fC28a523649086491

Destination Chain: Arbitrum Sepolia
Destination Contract: 0x7F3a1C8e2B5d9A4f6E0c3D7b1F9a2E5c8B4d6A0F
https://sepolia.arbiscan.io/address/0x7F3a1C8e2B5d9A4f6E0c3D7b1F9a2E5c8B4d6A0F

Run this command to set both environment variables:
export ETHEREUM_SEPOLIA_CONTRACT=0x385240F511f64c25739fD05fC28a523649086491 && export ARBITRUM_SEPOLIA_CONTRACT=0x7F3a1C8e2B5d9A4f6E0c3D7b1F9a2E5c8B4d6A0F
========================================

Before moving on, export the addresses of the previously deployed contracts:

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.s.sol

Check out the complete script code on Github.

The Configure.s.sol script handles both sides in a single command:

  • allowlists the destination chain on the sender contract
  • allowlists the chain-sender pair on the receiver contract
  • sets the receiver contract's allowed finality config for the source chain
Terminal
SOURCE_CHAIN=ETHEREUM_SEPOLIA DEST_CHAIN=ARBITRUM_SEPOLIA ALLOWED_FINALITY_CONFIG=BLOCK_DEPTH ALLOWED_BLOCK_DEPTH=32 \
forge script foundry/scripts/tutorials/send-arbitrary-data/configure/Configure.s.sol \
--account $KEYSTORE_NAME \
--broadcast -vv
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:

Terminal
========================================
โš™๏ธ Configure CCIP Messenger Contracts on Both Chains
========================================
Source Chain: Ethereum Sepolia
Source Contract: 0x385240F511f64c25739fD05fC28a523649086491
Destination Chain: Arbitrum Sepolia
Destination Contract: 0x7F3a1C8e2B5d9A4f6E0c3D7b1F9a2E5c8B4d6A0F
========================================


[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 0x385240F511f64c25739fD05fC28a523649086491 from Ethereum Sepolia...
โœ… Chain-sender pair allowlisted: Ethereum Sepolia -> 0x385240F511f64c25739fD05fC28a523649086491
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:

Terminal
SOURCE_CHAIN=ARBITRUM_SEPOLIA DEST_CHAIN=ETHEREUM_SEPOLIA ALLOWED_FINALITY_CONFIG=BLOCK_DEPTH ALLOWED_BLOCK_DEPTH=32 \
forge script foundry/scripts/tutorials/send-arbitrary-data/configure/Configure.s.sol \
--account $KEYSTORE_NAME \
--broadcast -vv
4 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 extraArgs for the lane. Foundry uses ExtraArgsHelper.buildExtraArgs to detect the lane version and encode V2 or V3.
  • Approves the contract to spend the caller's ERC-20 fee token when paying with LINK or another ERC-20.
  • Sends the CCIP message.
Faster Than Finality (block depth)

The BLOCK_DEPTH environment variable controls send-side finality behavior:

  • Omit BLOCK_DEPTH, or set BLOCK_DEPTH=DEFAULT/BLOCK_DEPTH=0 (default): Use finalized finality.
  • Set BLOCK_DEPTH=32: Request faster than finality using numeric block depth.

The scripts detect lane support and encode V2 or V3 extraArgs as needed.

Example 1: Pay with native gas + faster than finality (BLOCK_DEPTH=32)
Terminal
SOURCE_CHAIN=ETHEREUM_SEPOLIA DEST_CHAIN=ARBITRUM_SEPOLIA \
FEE_TOKEN=NATIVE GAS_LIMIT=200000 BLOCK_DEPTH=32 \
MESSAGE="Hello from Foundry" \
forge script foundry/scripts/tutorials/send-arbitrary-data/interact/SendMessage.s.sol \
--account $KEYSTORE_NAME \
--broadcast -vv

Your terminal should look like this:

Terminal
========================================
๐Ÿ“ก CCIP Data-Only Message - Pay with Native
========================================
Source Chain: Ethereum Sepolia
Destination Chain: Arbitrum Sepolia
Sender: 0x385240F511f64c25739fD05fC28a523649086491
Receiver: 0x7F3a1C8e2B5d9A4f6E0c3D7b1F9a2E5c8B4d6A0F
Fee Token: Native (ETH)
========================================


[Pre-validation] Detecting lane version and building extraArgs...
Gas limit (override): 200000
V3 extraArgs accepted by lane.
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] Sending CCIP message with native fee ( ETH )...
Required CCIP fee (in WEI): 10463107598943

========================================
โœ… Message sent successfully!
========================================
CCIP messageId: 0x1e2f3a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f607
CCIP Explorer:
https://ccip.chain.link/#/side-drawer/msg/0x1e2f3a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f607
Terminal
SOURCE_CHAIN=ETHEREUM_SEPOLIA DEST_CHAIN=ARBITRUM_SEPOLIA \
FEE_TOKEN=LINK GAS_LIMIT=200000 \
MESSAGE="Hello from Foundry" \
forge script foundry/scripts/tutorials/send-arbitrary-data/interact/SendMessage.s.sol \
--account $KEYSTORE_NAME \
--broadcast -vv

Your terminal should look like this:

Terminal
========================================
๐Ÿ“ก CCIP Data-Only Message - Pay with LINK
========================================
Source Chain: Ethereum Sepolia
Destination Chain: Arbitrum Sepolia
Sender: 0x385240F511f64c25739fD05fC28a523649086491
Receiver: 0x7F3a1C8e2B5d9A4f6E0c3D7b1F9a2E5c8B4d6A0F
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): 16700123456789000
โœ… Contract approved to spend fee token


[Step 2] Sending CCIP message...

========================================
โœ… Message sent successfully!
========================================
CCIP messageId: 0x2f3a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60719
CCIP Explorer:
https://ccip.chain.link/#/side-drawer/msg/0x2f3a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60719

If you use LINK or another ERC-20 fee token, the script approves the Messenger contract before sending.

Environment variables
VariableDescriptionDefault
KEYSTORE_NAMEFoundry encrypted keystore name used by --account $KEYSTORE_NAMEyour_keystore_name
SOURCE_CHAINSource chain name identifier (for example, ETHEREUM_SEPOLIA)Not set
DEST_CHAINDestination chain name identifier (for example, ARBITRUM_SEPOLIA)Not set
{CHAIN}_RPC_URLRPC URL for each chain you run scripts against (for example, ETHEREUM_SEPOLIA_RPC_URL, ARBITRUM_SEPOLIA_RPC_URL)Not set
{CHAIN}_CONTRACTDeployed tutorial contract address per chain (for example, ETHEREUM_SEPOLIA_CONTRACT, ARBITRUM_SEPOLIA_CONTRACT)Not set
FEE_TOKENLINK or NATIVENATIVE
FEE_TOKEN_ADDRESSERC-20 address of a CCIP-supported fee token on the lane. Takes priority over FEE_TOKENNot set
GAS_LIMITGas limit for the destination callback200000
BLOCK_DEPTHOmit or set DEFAULT for finalized finality (default), or set 32 for faster than finalityDEFAULT
ALLOWED_FINALITY_CONFIGReceiver allowed finality config (used by the configure step). Set BLOCK_DEPTH to allow numeric BLOCK_DEPTH requests. Omit for default-finality-only.Not set
ALLOWED_BLOCK_DEPTHReceiver minimum block depth (used by the configure step). Required when ALLOWED_FINALITY_CONFIG=BLOCK_DEPTH. Use 32 in this tutorial.Not set
MESSAGEText payload to sendHello World From Foundry Script for CCIP 2.0!
CHAINChain name identifier used by the receipt verification scripts (the chain where the receiver is deployed)Not set
SendMessage.s.sol

Check out the complete script code on Github.

ExtraArgsHelper.s.sol

Check out the lane-aware extraArgs helper on Github.

5 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.

  1. Run the script, passing the CHAIN where you deployed the receiver:
Terminal
CHAIN=ARBITRUM_SEPOLIA \
forge script foundry/scripts/tutorials/send-arbitrary-data/interact/GetLastReceivedMessageDetails.s.sol

Your terminal should look something like this:

Terminal
========================================
๐Ÿ” Verify Received Message
========================================
Chain: Arbitrum Sepolia
Receiver Address: 0x7F3a1C8e2B5d9A4f6E0c3D7b1F9a2E5c8B4d6A0F
========================================

Checking for received message...

========================================
โœ… Message Received Successfully!
========================================
Message ID: 0x1e2f3a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f607
Sender: 0x385240F511f64c25739fD05fC28a523649086491
Received Text: "Hello from Foundry"
========================================
GetLastReceivedMessageDetails.s.sol

Check 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
CCIP Starter Kit

Clone the starter kit (contains both Hardhat and Foundry code) for a smoother setup.

  1. Clone the CCIP 2.0 template repository, and open a terminal inside the project directory:
Terminal
git clone https://github.com/smartcontractkit/docs-ccip.git && cd docs-ccip
  1. Copy the example environment file and fill in your values:
Terminal
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:

.env
# 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=
  1. Install dependencies and compile:
Terminal
npm install && npx hardhat compile
  1. Load the environment variables:
Terminal
source .env
  1. Create a Hardhat keystore entry for your private key. Use the same name as KEYSTORE_NAME in your .env file. Hardhat will prompt you to enter the private key and a password to encrypt it:
Terminal
npx hardhat keystore set your_keystore_name
2 Deploy your contracts
deploy.ts

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.ts script does the following:

  • Deploys Messenger on Ethereum Sepolia (source).
  • Deploys Messenger on Arbitrum Sepolia (destination).
  • Returns the contract addresses in the terminal.

To run the script, use the following command:

Terminal
SOURCE_CHAIN=ETHEREUM_SEPOLIA DEST_CHAIN=ARBITRUM_SEPOLIA \
npx hardhat run hardhat/scripts/tutorials/send-arbitrary-data/deploy/deploy.ts

Your terminal should look something like this:

Terminal
========================================
๐Ÿš€ Deploy CCIP Messenger Contracts on Both Chains
========================================
Source Chain: Ethereum Sepolia
Destination Chain: Arbitrum Sepolia
========================================

[Step 1] Deploying Messenger on Ethereum Sepolia
Contract deployed at: 0x385240F511f64c25739fD05fC28a523649086491
https://sepolia.etherscan.io/address/0x385240F511f64c25739fD05fC28a523649086491

========================================
โœ… Deployment Complete on Ethereum Sepolia!
========================================
Source Contract Address: 0x385240F511f64c25739fD05fC28a523649086491

[Step 2] Deploying Messenger on Arbitrum Sepolia
Contract deployed at: 0x7F3a1C8e2B5d9A4f6E0c3D7b1F9a2E5c8B4d6A0F
https://sepolia.arbiscan.io/address/0x7F3a1C8e2B5d9A4f6E0c3D7b1F9a2E5c8B4d6A0F

========================================
โœ… Deployment Complete on Arbitrum Sepolia!
========================================
Destination Contract Address: 0x7F3a1C8e2B5d9A4f6E0c3D7b1F9a2E5c8B4d6A0F

========================================
โœ… All Deployments Complete!
========================================
Source Chain: Ethereum Sepolia
Source Contract: 0x385240F511f64c25739fD05fC28a523649086491
https://sepolia.etherscan.io/address/0x385240F511f64c25739fD05fC28a523649086491

Destination Chain: Arbitrum Sepolia
Destination Contract: 0x7F3a1C8e2B5d9A4f6E0c3D7b1F9a2E5c8B4d6A0F
https://sepolia.arbiscan.io/address/0x7F3a1C8e2B5d9A4f6E0c3D7b1F9a2E5c8B4d6A0F

Run this command to set both environment variables:
export ETHEREUM_SEPOLIA_CONTRACT=0x385240F511f64c25739fD05fC28a523649086491 && export ARBITRUM_SEPOLIA_CONTRACT=0x7F3a1C8e2B5d9A4f6E0c3D7b1F9a2E5c8B4d6A0F
========================================

Before moving on, export the addresses of the previously deployed contracts:

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.ts

Check out the complete script code on Github.

The configure.ts script handles both sides in a single command:

  • allowlists the destination chain on the sender contract
  • allowlists the chain-sender pair on the receiver contract
  • sets the receiver contract's allowed finality config for the source chain
Terminal
SOURCE_CHAIN=ETHEREUM_SEPOLIA DEST_CHAIN=ARBITRUM_SEPOLIA ALLOWED_FINALITY_CONFIG=BLOCK_DEPTH ALLOWED_BLOCK_DEPTH=32 \
npx hardhat run hardhat/scripts/tutorials/send-arbitrary-data/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:

Terminal
========================================
โš™๏ธ Configure CCIP Messenger Contracts on Both Chains
========================================
Source Chain: Ethereum Sepolia
Source Contract: 0x385240F511f64c25739fD05fC28a523649086491
Destination Chain: Arbitrum Sepolia
Destination Contract: 0x7F3a1C8e2B5d9A4f6E0c3D7b1F9a2E5c8B4d6A0F
========================================

[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 0x385240F511f64c25739fD05fC28a523649086491 from Ethereum Sepolia...
โœ… Chain-sender pair allowlisted: Ethereum Sepolia -> 0x385240F511f64c25739fD05fC28a523649086491
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:

Terminal
SOURCE_CHAIN=ARBITRUM_SEPOLIA DEST_CHAIN=ETHEREUM_SEPOLIA ALLOWED_FINALITY_CONFIG=BLOCK_DEPTH ALLOWED_BLOCK_DEPTH=32 \
npx hardhat run hardhat/scripts/tutorials/send-arbitrary-data/configure/configure.ts
4 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 extraArgs for the lane. Hardhat uses buildMessageOnlyExtraArgs from hardhat/scripts/extra-args.ts to detect whether the message-only lane accepts V3 extraArgs and to encode V2 or V3.
  • Approves the contract to spend the caller's ERC-20 fee token when paying with LINK or another ERC-20.
  • Sends the CCIP message.
Faster Than Finality (block depth)

The BLOCK_DEPTH environment variable controls send-side finality behavior:

  • Omit BLOCK_DEPTH, or set BLOCK_DEPTH=DEFAULT/BLOCK_DEPTH=0 (default): Use finalized finality.
  • Set BLOCK_DEPTH=32: Request faster than finality using numeric block depth.

The scripts detect lane support and encode V2 or V3 extraArgs as needed.

Example 1: Pay with native gas + faster than finality (BLOCK_DEPTH=32)
Terminal
SOURCE_CHAIN=ETHEREUM_SEPOLIA DEST_CHAIN=ARBITRUM_SEPOLIA \
FEE_TOKEN=NATIVE GAS_LIMIT=200000 BLOCK_DEPTH=32 \
MESSAGE="Hello from Hardhat" \
npx hardhat run hardhat/scripts/tutorials/send-arbitrary-data/interact/send-message.ts

Your terminal should look like this:

Terminal
========================================
๐Ÿ“ก CCIP Data-Only Message - Pay with ETH
========================================
Source Chain: Ethereum Sepolia
Destination Chain: Arbitrum Sepolia
Sender: 0x385240F511f64c25739fD05fC28a523649086491
Receiver: 0x7F3a1C8e2B5d9A4f6E0c3D7b1F9a2E5c8B4d6A0F
Fee Token: Native (ETH)
========================================


[Pre-validation] Detecting lane version and building extraArgs...
V3 extraArgs accepted by lane.
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: 10463107598943

[Step 1] Sending CCIP message with native token fee (ETH)...
Required CCIP fee (in WEI): 10463107598943

========================================
โœ… Message sent successfully!
========================================
CCIP messageId: 0x1e2f3a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f607
CCIP Explorer:
https://ccip.chain.link/#/side-drawer/msg/0x1e2f3a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f607
Terminal
SOURCE_CHAIN=ETHEREUM_SEPOLIA DEST_CHAIN=ARBITRUM_SEPOLIA \
FEE_TOKEN=LINK GAS_LIMIT=200000 \
MESSAGE="Hello from Hardhat" \
npx hardhat run hardhat/scripts/tutorials/send-arbitrary-data/interact/send-message.ts

Your terminal should look like this:

Terminal
========================================
๐Ÿ“ก CCIP Data-Only Message - Pay with LINK
========================================
Source Chain: Ethereum Sepolia
Destination Chain: Arbitrum Sepolia
Sender: 0x385240F511f64c25739fD05fC28a523649086491
Receiver: 0x7F3a1C8e2B5d9A4f6E0c3D7b1F9a2E5c8B4d6A0F
Fee Token: LINK
========================================


[Pre-validation] Detecting lane version and building extraArgs...
โœ… Using default finality (BLOCK_DEPTH=DEFAULT). V3 extraArgs (gasLimit=200000, finalityConfig=0x00000000).
[Pre-validation] CCIP fee: 16700123456789000

[Step 1] Approving contract to spend LINK for CCIP fees...
Required CCIP fee (in LINK units): 16700123456789000
โœ… Contract approved to spend LINK


[Step 2] Sending CCIP message...

========================================
โœ… Message sent successfully!
========================================
CCIP messageId: 0x2f3a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60719
CCIP Explorer:
https://ccip.chain.link/#/side-drawer/msg/0x2f3a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60719

If you use LINK or another ERC-20 fee token, the script approves the Messenger contract before sending.

Environment variables
VariableDescriptionDefault
KEYSTORE_NAMEHardhat keystore entry name, set in .envyour_keystore_name
SOURCE_CHAINSource chain name identifier (for example, ETHEREUM_SEPOLIA)Not set
DEST_CHAINDestination chain name identifier (for example, ARBITRUM_SEPOLIA)Not set
{CHAIN}_RPC_URLRPC URL for each chain you run scripts against (for example, ETHEREUM_SEPOLIA_RPC_URL, ARBITRUM_SEPOLIA_RPC_URL)Not set
{CHAIN}_CONTRACTDeployed tutorial contract address per chain (for example, ETHEREUM_SEPOLIA_CONTRACT, ARBITRUM_SEPOLIA_CONTRACT)Not set
FEE_TOKENLINK or NATIVENATIVE
FEE_TOKEN_ADDRESSERC-20 address of a CCIP-supported fee token on the lane. Takes priority over FEE_TOKENNot set
GAS_LIMITGas limit for the destination callback200000
BLOCK_DEPTHOmit or set DEFAULT for finalized finality (default), or set 32 for faster than finalityDEFAULT
ALLOWED_FINALITY_CONFIGReceiver allowed finality config (used by the configure step). Set BLOCK_DEPTH to allow numeric BLOCK_DEPTH requests. Omit for default-finality-only.Not set
ALLOWED_BLOCK_DEPTHReceiver minimum block depth (used by the configure step). Required when ALLOWED_FINALITY_CONFIG=BLOCK_DEPTH. Use 32 in this tutorial.Not set
MESSAGEText payload to sendHello World From Hardhat Script for CCIP 2.0!
CHAINChain name identifier used by the receipt verification scripts (the chain where the receiver is deployed)Not set
send-message.ts

Check out the complete script code on Github.

extra-args.ts

Check out the lane-aware extraArgs helper used by Hardhat scripts.

5 Verify receipt on the destination chain
get-last-received-message-details.ts

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 broadcast.

  1. Run the script, passing the CHAIN where you deployed the receiver:
Terminal
CHAIN=ARBITRUM_SEPOLIA \
npx hardhat run hardhat/scripts/tutorials/send-arbitrary-data/interact/get-last-received-message-details.ts

Your terminal should look something like this:

Terminal
========================================
๐Ÿ” Verify Received Message
========================================
Chain: Arbitrum Sepolia
Receiver Address: 0x7F3a1C8e2B5d9A4f6E0c3D7b1F9a2E5c8B4d6A0F
========================================

Checking for received message...

========================================
โœ… Message Received Successfully!
========================================
Message ID: 0x1e2f3a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f607
Sender: 0x385240F511f64c25739fD05fC28a523649086491
Received Text: "Hello from Hardhat"
========================================

What's next

Get the latest Chainlink content straight to your inbox.