Send Arbitrary Data and Receive Transfer Confirmation: A -> B -> A

This tutorial will teach you how to use Chainlink CCIP to send arbitrary data between smart contracts on different blockchains and how to track the status of each sent message in the sender contract on the source chain. Tracking the status of sent messages allows your smart contracts to execute actions after the receiver acknowledges it received the message. In this example, the sender contract emits an event after it receives acknowledgment from the receiver.

Note: For simplicity, this tutorial demonstrates this pattern for sending arbitrary data. However, you are not limited to this application. You can apply the same pattern to programmable token transfers.

Before you begin

Tutorial

In this tutorial, you will deploy a message tracker contract on the source blockchain (Avalanche Fuji) and an acknowledger on the destination blockchain (Ethereum Sepolia). Throughout the tutorial, you will pay for CCIP fees using LINK tokens. Here is a step-by-step breakdown:

  1. Sending and building a CCIP message: Initiate and send a message from the message tracker contract on Avalanche Fuji to the acknowledger contract on Ethereum Sepolia. The message tracker contract constructs a CCIP message that encapsulates a text string and establishes a tracking status for this message before sending it off.
  2. Receiving and acknowledging the message: After the acknowledger contract receives the text on Ethereum Sepolia, it sends back a CCIP message to the message tracker contract as an acknowledgment of receipt.
  3. Updating tracking status: After the message tracker receives the acknowledgment, the contract updates the tracking status of the initial CCIP message and emits an event to signal completion.

Deploy the message tracker (sender) contract

Deploy the MessageTracker.sol contract on Avalanche Fuji and enable it to send and receive CCIP messages to and from Ethereum Sepolia. You must also enable your contract to receive CCIP messages from the acknowledger contract.

  1. Open the MessageTracker.sol contract in Remix.

    Note: The contract code is also available in the Examine the code section.

  2. Compile the contract.

  3. Deploy the contract on Avalanche Fuji:

    1. Open MetaMask and select the Avalanche Fuji network.

    2. On the Deploy & Run Transactions tab in Remix, select Injected Provider - MetaMask in the Environment list. Remix will use the MetaMask wallet to communicate with Avalanche Fuji.

    3. Under the Deploy section, fill in the router address and the LINK token contract address for your specific blockchain. You can find both of these addresses on the Supported Networks page. The LINK token contract address is also listed on the LINK Token Contracts page. For Avalanche Fuji:

      • The router address is 0xF694E193200268f9a4868e4Aa017A0118C9a8177
      • The LINK token address is 0x0b9d5D9136855f6FEc3c0993feE6E9CE8a297846
    4. Click transact to deploy the contract. MetaMask prompts you to confirm the transaction. Check the transaction details to make sure you are deploying the contract on Avalanche Fuji.

    5. After you confirm the transaction, the contract address appears in the Deployed Contracts list. Copy your contract address.

    6. Open MetaMask and send 70 LINK to the contract address you copied. Your contract will pay CCIP fees in LINK.

      Note: This transaction fee is significantly higher than normal due to gas spikes on Sepolia. To run this example, you can get additional testnet LINK from faucets.chain.link or use a supported testnet other than Sepolia.

  4. Allow the Ethereum Sepolia chain selector for both destination and source chains.

    1. On the Deploy & Run Transactions tab in Remix, expand the message tracker contract in the Deployed Contracts section.
    2. Call the allowlistDestinationChain function with 16015286601757825753 as the destination chain selector for Ethereum Sepolia and true as allowed.
    3. Call the allowlistSourceChain function with 16015286601757825753 as the source chain selector for Ethereum Sepolia and true as allowed. You can find each network's chain selector on the supported networks page.

Deploy the acknowledger (receiver) contract

Deploy the Acknowledger.sol contract on Ethereum Sepolia and enable it to send and receive CCIP messages to and from Avalanche Fuji. You must also enable your contract to receive CCIP messages from the message tracker contract.

  1. Open the Acknowledger.sol contract in Remix.

    Note: The contract code is also available in the Examine the code section.

  2. Compile the contract.

  3. Deploy the contract on Ethereum Sepolia:

    1. Open MetaMask and select the Ethereum Sepolia network.

    2. On the Deploy & Run Transactions tab in Remix, make sure the Environment is still set to Injected Provider - MetaMask.

    3. Under the Deploy section, fill in the router address and the LINK token contract address for your specific blockchain. You can find both of these addresses on the Supported Networks page. The LINK token contract address is also listed on the LINK Token Contracts page. For Ethereum Sepolia:

      • The Router address is 0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59.
      • The LINK token address is 0x779877A7B0D9E8603169DdbD7836e478b4624789.
    4. Click transact to deploy the contract. MetaMask prompts you to confirm the transaction. Check the transaction details to make sure you are deploying the contract to Ethereum Sepolia.

    5. After you confirm the transaction, the contract address appears in the Deployed Contracts list. Copy this contract address.

    6. Open MetaMask and send 70 LINK to the contract address that you copied. Your contract will pay CCIP fees in LINK.

      Note: This transaction fee is significantly higher than normal due to gas spikes on Sepolia. To run this example, you can get additional testnet LINK from faucets.chain.link or use a supported testnet other than Sepolia.

  4. Allow the Avalanche Fuji chain selector for both destination and source chains. You must also enable your acknowledger contract to receive CCIP messages from the message tracker you deployed on Avalanche Fuji.

    1. On the Deploy & Run Transactions tab in Remix, expand the acknowledger contract in the Deployed Contracts section. Expand the allowlistDestinationChain, allowlistSender, and allowlistSourceChain functions and fill in the following arguments:

      FunctionDescriptionValue (Avalanche Fuji)
      allowlistDestinationChainCCIP Chain identifier of the target blockchain. You can find each network's chain selector on the supported networks page 14767482510784806043, true
      allowlistSenderThe address of the message tracker contract deployed on Avalanche FujiYour deployed contract address, true
      allowlistSourceChainCCIP Chain identifier of the source blockchain. You can find each network's chain selector on the supported networks page 14767482510784806043, true
    2. Open MetaMask and select the Ethereum Sepolia network.

    3. For each function you expanded and filled in the arguments for, click the transact button to call the function. MetaMask prompts you to confirm the transaction. Wait for each transaction to succeed before calling the following function.

  5. Finally, enable your message tracker contract to receive CCIP messages from the acknowledger contract you deployed on Ethereum Sepolia.

    1. On the Deploy & Run Transactions tab in Remix, expand the message tracker contract in the Deployed Contracts section. Expand the allowlistSender function and fill in your acknowledger contract address and true as allowed.

    2. Open MetaMask and select the Avalanche Fuji network.

    3. Click transact to call the function. MetaMask prompts you to confirm the transaction.

At this point, you have one message tracker (sender) contract on Avalanche Fuji and one acknowledger (receiver) contract on Ethereum Sepolia. You sent 70 LINK to the message tracker contract and 70 LINK to the acknowledger contract to pay the CCIP fees.

Note: This transaction fee is significantly higher than normal due to gas spikes on Sepolia.

Send data and track the message status

Initial message

  1. Send a Hello World! string from your message tracker contract on Avalanche Fuji to your acknowledger contract deployed on Ethereum Sepolia. You will track the status of this message during this tutorial.

    1. Open MetaMask and select the Avalanche Fuji network.

    2. On the Deploy & Run Transactions tab in Remix, expand the message tracker contract in the Deployed Contracts section.

    3. Expand the sendMessagePayLINK function and fill in the following arguments:

      ArgumentDescriptionValue (Ethereum Sepolia)
      destinationChainSelectorCCIP Chain identifier of the target blockchain. You can find each network's chain selector on the supported networks page 16015286601757825753
      receiverThe destination smart contract addressYour deployed acknowledger contract address
      textAny stringHello World!
    4. Click transact to call the function. MetaMask prompts you to confirm the transaction.

    5. Upon transaction success, expand the last transaction in the Remix log and copy the transaction hash. In this example, it is 0x1f88abc33a4ab426a5466e01d9e5fe8a2b96d6a6e5cedb643a674489c74126b4.

  2. Open the CCIP Explorer and use the transaction hash that you copied to search for your cross-chain transaction.

    Chainlink CCIP Explorer - Fuji to Sepolia Transaction Details

    After the transaction is finalized on the source chain, it will take a few minutes for CCIP to deliver the data to Ethereum Sepolia and call the ccipReceive function on your acknowledger contract.

  3. Copy the message ID from the CCIP Explorer transaction details. You will use this message ID to track your message status on the message tracker contract. In this example, it is 0xdd8be2f5f5d5cf3b8640c62924025b311ae83c6144f0f2ed5c24637436d6aab8.

  4. On the Deploy & Run Transactions tab in Remix, expand your message tracker contract in the Deployed Contracts section.

  5. Paste the message ID you copied from the CCIP explorer as the argument in the messagesInfo getter function. Click messagesInfo to read the message status.

    Chainlink CCIP - Message Tracker Get Message Status - 1

    Note the returned status 1. This value indicates that the message tracker contract has updated your message status to the Sent status as defined by the MessageStatus enum in the message tracker contract.

    // Enum is used to track the status of messages sent via CCIP.
    // `NotSent` indicates a message has not yet been sent.
    // `Sent` indicates that a message has been sent to the Acknowledger contract but not yet acknowledged.
    // `ProcessedOnDestination` indicates that the Acknowledger contract has processed the message and that
    // the Message Tracker contract has received the acknowledgment from the Acknowledger contract.
    enum MessageStatus {
      NotSent, // 0
      Sent, // 1
      ProcessedOnDestination // 2
    }
    
  6. When the transaction is marked with a "Success" status on the CCIP Explorer, the CCIP transaction and the destination transaction are complete. The acknowledger contract has received the message from the message tracker contract.

    Chainlink CCIP Explorer - Fuji to Sepolia Transaction Success

Acknowledgment message

The acknowledger contract processes the message, sends an acknowledgment message containing the initial message ID back to the message tracker contract, and emits an AcknowledgmentSent event. Read this explanation for further description.

// Emitted when an acknowledgment message is successfully sent back to the sender contract.
// This event signifies that the Acknowledger contract has recognized the receipt of an initial message
// and has informed the original sender contract by sending an acknowledgment message,
// including the original message ID.
event AcknowledgmentSent(
  bytes32 indexed messageId, // The unique ID of the CCIP message.
  uint64 indexed destinationChainSelector, // The chain selector of the destination chain.
  address indexed receiver, // The address of the receiver on the destination chain.
  bytes32 data, // The data being sent back, usually containing the message ID of the original message to acknowledge its receipt.
  address feeToken, // The token address used to pay CCIP fees for sending the acknowledgment.
  uint256 fees // The fees paid for sending the acknowledgment message via CCIP.
);
  1. Copy your acknowledger contract address from Remix. Open the Ethereum Sepolia explorer and search for your deployed acknowledger contract. Click the Events tab to see the events log.

    Chainlink CCIP - Ethereum Sepolia Acknowledger Contract Events

    The first indexed topic (topic1) in the AcknowledgmentSent event is the acknowledgment message ID sent to the message tracker contract on Avalanche Fuji. In this example, the message ID is 0xd4d4a5d0db05dc714f8150c1af654ed34eb8c9f7547401fa9bf072a815f56ac1.

  2. Copy your own message ID from the indexed topic1 and search for it in the CCIP explorer.

    Chainlink CCIP - CCIP Explorer Sepolia to Fuji Transaction Success

    When the transaction is marked with a "Success" status on the CCIP explorer, the CCIP transaction and the destination transaction are complete. The message tracker contract has received the message from the acknowledger contract.

Final status check

When the message tracker receives the acknowledgment message, the ccipReceive function updates the initial message status to 2, which corresponds to the ProcessedOnDestination status as defined by the MessageStatus enum. The function emits a MessageProcessedOnDestination event.

  1. Open MetaMask and select the Avalanche Fuji network.

  2. On the Deploy & Run Transactions tab in Remix, expand your message tracker contract in the Deployed Contracts section.

  3. Copy the initial message ID from the CCIP explorer (transaction from Avalanche Fuji to Ethereum Sepolia) and paste it as the argument in the messagesInfo getter function. Click messagesInfo to read the message status. It returns status 2 and the acknowledgment message ID that confirms this status.

    Chainlink CCIP - Message Tracker Get Message Status - 2
  4. Copy your message tracker contract address from Remix. Open the Avalanche Fuji explorer and search for your deployed message tracker contract. Then, click on the Events tab.

    Chainlink CCIP - Message Tracker Message Confirmed Event

    The MessageProcessedOnDestination event is emitted with the acknowledged message ID 0xdd8be2f5f5d5cf3b8640c62924025b311ae83c6144f0f2ed5c24637436d6aab8 as indexed topic2.

    // Event emitted when the sender contract receives an acknowledgment
    // that the receiver contract has successfully received and processed the message.
    event MessageProcessedOnDestination(
      bytes32 indexed messageId, // The unique ID of the CCIP acknowledgment message.
      bytes32 indexed acknowledgedMsgId, // The unique ID of the message acknowledged by the receiver.
      uint64 indexed sourceChainSelector, // The chain selector of the source chain.
      address sender // The address of the sender from the source chain.
    );
    

Explanation

The smart contracts featured in this tutorial are designed to interact with CCIP to send and receive messages with an acknowledgment of receipt mechanism. The contract code across both contracts contains supporting comments clarifying the functions, events, and underlying logic.

Refer to the Send Arbitrary Data tutorial for more explanation about initializing the contracts, sending data, paying in LINK, and receiving data.

Here, we will further explain the acknowledgment of receipt mechanism.

Message acknowledgment of receipt mechanism

This mechanism ensures that a message sent by the message tracker (sender) contract is received and acknowledged by the acknowledger (receiver) contract. The message status is tracked and stored in the message tracker contract.

// Enum is used to track the status of messages sent via CCIP.
// `NotSent` indicates a message has not yet been sent.
// `Sent` indicates that a message has been sent to the Acknowledger contract but not yet acknowledged.
// `ProcessedOnDestination` indicates that the Acknowledger contract has processed the message and that
// the Message Tracker contract has received the acknowledgment from the Acknowledger contract.
enum MessageStatus {
    NotSent, // 0
    Sent, // 1
    ProcessedOnDestination // 2
}

// Struct to store the status and acknowledger message ID of a message.
struct MessageInfo {
    MessageStatus status;
    bytes32 acknowledgerMessageId;
}

// Mapping to keep track of message IDs to their info (status & acknowledger message ID).
mapping(bytes32 => MessageInfo) public messagesInfo;

Message tracker contract

The message tracker contract acts as the sender, initiating cross-chain communication. It performs the following operations:

  • Message sending: Constructs and sends messages to the acknowledger contract on another blockchain, using sendMessagePayLINK function. On top of its five primary operations, the sendMessagePayLINK function also updates the message status upon sending.

  • Status tracking:

    • Upon sending a message, the message tracker updates its internal state to mark the message as Sent (status 1). This status is pivotal for tracking the message lifecycle and awaiting acknowledgment.

      // Update the message status to `Sent`
      messagesInfo[messageId].status = MessageStatus.Sent;
      
    • Upon receiving an acknowledgment message from the acknowledger contract, the message tracker contract updates the message status from Sent (status 1) to ProcessedOnDestination (status 2). This update indicates that the cross-chain communication cycle is complete, and the receiver successfully received and acknowledged the message.

      // Update the message status to `ProcessedOnDestination`
      messagesInfo[messageId].status = MessageStatus.ProcessedOnDestination;
      

Acknowledger contract

The acknowledger contract receives the message, sends back an acknowledgment message, and emits an event. It performs the following operations:

  • Message receipt: Upon receiving a message via CCIP, the ccipReceive function decodes it and calls the acknowledgePayLINK function nested within the ccipReceive function.

  • Acknowledgment sending: The acknowledgePayLINK function acts as a custom sendMessagePayLINK function nested within the ccipReceive function. It sends an acknowledgment (a CCIP message) to the message tracker contract upon the initial message receipt. The data transferred in this acknowledgment message is the initial message ID. It then emits an AcknowledgmentSent event.

Security and integrity

Both contracts use allowlists to process only messages from and to allowed sources.

  • Allowlisting chains and senders:

    • The sendMessagePayLINK function is protected by the onlyAllowlistedDestinationChain modifier, ensuring the contract owner has allowlisted a destination chain.
    • The ccipReceive function is protected by the onlyAllowlisted modifier, ensuring the contract owner has allowlisted a source chain and a sender.
  • Ensuring the initial message authenticity: The message tracker contract first checks that the message awaiting acknowledgment was sent from the contract itself and is currently marked as Sent. Once confirmed, the message status is updated to ProcessedOnDestination.

Examine the code

MessageTracker.sol

// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

import {IRouterClient} from "@chainlink/contracts-ccip/src/v0.8/ccip/interfaces/IRouterClient.sol";
import {OwnerIsCreator} from "@chainlink/contracts-ccip/src/v0.8/shared/access/OwnerIsCreator.sol";
import {Client} from "@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol";
import {CCIPReceiver} from "@chainlink/contracts-ccip/src/v0.8/ccip/applications/CCIPReceiver.sol";
import {IERC20} from "@chainlink/contracts-ccip/src/v0.8/vendor/openzeppelin-solidity/v4.8.3/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@chainlink/contracts-ccip/src/v0.8/vendor/openzeppelin-solidity/v4.8.3/contracts/token/ERC20/utils/SafeERC20.sol";

using SafeERC20 for IERC20;

/**
 * THIS IS AN EXAMPLE CONTRACT THAT USES HARDCODED VALUES FOR CLARITY.
 * THIS IS AN EXAMPLE CONTRACT THAT USES UN-AUDITED CODE.
 * DO NOT USE THIS CODE IN PRODUCTION.
 */

/// @title - A simple messenger contract for sending/receiving data across chains and tracking the status of sent messages.
contract MessageTracker is CCIPReceiver, OwnerIsCreator {
    // Custom errors to provide more descriptive revert messages.
    error NotEnoughBalance(uint256 currentBalance, uint256 calculatedFees); // Used to make sure contract has enough balance.
    error NothingToWithdraw(); // Used when trying to withdraw Ether but there's nothing to withdraw.
    error DestinationChainNotAllowlisted(uint64 destinationChainSelector); // Used when the destination chain has not been allowlisted by the contract owner.
    error SourceChainNotAllowlisted(uint64 sourceChainSelector); // Used when the source chain has not been allowlisted by the contract owner.
    error SenderNotAllowlisted(address sender); // Used when the sender has not been allowlisted by the contract owner.
    error InvalidReceiverAddress(); // Used when the receiver address is 0.
    error MessageWasNotSentByMessageTracker(bytes32 msgId); // Triggered when attempting to confirm a message not recognized as sent by this tracker.
    error MessageHasAlreadyBeenProcessedOnDestination(bytes32 msgId); // Triggered when trying to mark a message as `ProcessedOnDestination` when it is already marked as such.

    // Enum is used to track the status of messages sent via CCIP.
    // `NotSent` indicates a message has not yet been sent.
    // `Sent` indicates that a message has been sent to the Acknowledger contract but not yet acknowledged.
    // `ProcessedOnDestination` indicates that the Acknowledger contract has processed the message and that
    // the Message Tracker contract has received the acknowledgment from the Acknowledger contract.
    enum MessageStatus {
        NotSent, // 0
        Sent, // 1
        ProcessedOnDestination // 2
    }

    // Struct to store the status and acknowledger message ID of a message.
    struct MessageInfo {
        MessageStatus status;
        bytes32 acknowledgerMessageId;
    }

    // Mapping to keep track of allowlisted destination chains.
    mapping(uint64 => bool) public allowlistedDestinationChains;

    // Mapping to keep track of allowlisted source chains.
    mapping(uint64 => bool) public allowlistedSourceChains;

    // Mapping to keep track of allowlisted senders.
    mapping(address => bool) public allowlistedSenders;

    // Mapping to keep track of message IDs to their info (status & acknowledger message ID).
    mapping(bytes32 => MessageInfo) public messagesInfo;

    // Event emitted when a message is sent to another chain.
    event MessageSent(
        bytes32 indexed messageId, // The unique ID of the CCIP message.
        uint64 indexed destinationChainSelector, // The chain selector of the destination chain.
        address receiver, // The address of the receiver on the destination chain.
        string text, // The text being sent.
        address feeToken, // the token address used to pay CCIP fees.
        uint256 fees // The fees paid for sending the CCIP message.
    );

    // Event emitted when the sender contract receives an acknowledgment
    // that the receiver contract has successfully received and processed the message.
    event MessageProcessedOnDestination(
        bytes32 indexed messageId, // The unique ID of the CCIP acknowledgment message.
        bytes32 indexed acknowledgedMsgId, // The unique ID of the message acknowledged by the receiver.
        uint64 indexed sourceChainSelector, // The chain selector of the source chain.
        address sender // The address of the sender from the source chain.
    );

    IERC20 private s_linkToken;

    /// @notice Constructor initializes the contract with the router address.
    /// @param _router The address of the router contract.
    /// @param _link The address of the link contract.
    constructor(address _router, address _link) CCIPReceiver(_router) {
        s_linkToken = IERC20(_link);
    }

    /// @dev Modifier that checks if the chain with the given destinationChainSelector is allowlisted.
    /// @param _destinationChainSelector The selector of the destination chain.
    modifier onlyAllowlistedDestinationChain(uint64 _destinationChainSelector) {
        if (!allowlistedDestinationChains[_destinationChainSelector])
            revert DestinationChainNotAllowlisted(_destinationChainSelector);
        _;
    }

    /// @dev Modifier that checks if the chain with the given sourceChainSelector is allowlisted and if the sender is allowlisted.
    /// @param _sourceChainSelector The selector of the destination chain.
    /// @param _sender The address of the sender.
    modifier onlyAllowlisted(uint64 _sourceChainSelector, address _sender) {
        if (!allowlistedSourceChains[_sourceChainSelector])
            revert SourceChainNotAllowlisted(_sourceChainSelector);
        if (!allowlistedSenders[_sender]) revert SenderNotAllowlisted(_sender);
        _;
    }

    /// @dev Modifier that checks the receiver address is not 0.
    /// @param _receiver The receiver address.
    modifier validateReceiver(address _receiver) {
        if (_receiver == address(0)) revert InvalidReceiverAddress();
        _;
    }

    /// @dev Updates the allowlist status of a destination chain for transactions.
    function allowlistDestinationChain(
        uint64 _destinationChainSelector,
        bool allowed
    ) external onlyOwner {
        allowlistedDestinationChains[_destinationChainSelector] = allowed;
    }

    /// @dev Updates the allowlist status of a source chain for transactions.
    function allowlistSourceChain(
        uint64 _sourceChainSelector,
        bool allowed
    ) external onlyOwner {
        allowlistedSourceChains[_sourceChainSelector] = allowed;
    }

    /// @dev Updates the allowlist status of a sender for transactions.
    function allowlistSender(address _sender, bool allowed) external onlyOwner {
        allowlistedSenders[_sender] = allowed;
    }

    /// @notice Sends data to receiver on the destination chain.
    /// @notice Pay for fees in LINK.
    /// @dev Assumes your contract has sufficient LINK.
    /// @param _destinationChainSelector The identifier (aka selector) for the destination blockchain.
    /// @param _receiver The address of the recipient on the destination blockchain.
    /// @param _text The text to be sent.
    /// @return messageId The ID of the CCIP message that was sent.
    function sendMessagePayLINK(
        uint64 _destinationChainSelector,
        address _receiver,
        string calldata _text
    )
        external
        onlyOwner
        onlyAllowlistedDestinationChain(_destinationChainSelector)
        validateReceiver(_receiver)
        returns (bytes32 messageId)
    {
        // Create an EVM2AnyMessage struct in memory with necessary information for sending a cross-chain message
        Client.EVM2AnyMessage memory evm2AnyMessage = _buildCCIPMessage(
            _receiver,
            _text,
            address(s_linkToken)
        );

        // Initialize a router client instance to interact with cross-chain router
        IRouterClient router = IRouterClient(this.getRouter());

        // Get the fee required to send the CCIP message
        uint256 fees = router.getFee(_destinationChainSelector, evm2AnyMessage);

        if (fees > s_linkToken.balanceOf(address(this)))
            revert NotEnoughBalance(s_linkToken.balanceOf(address(this)), fees);

        // approve the Router to transfer LINK tokens on contract's behalf. It will spend the fees in LINK
        s_linkToken.approve(address(router), fees);

        // Send the CCIP message through the router and store the returned CCIP message ID
        messageId = router.ccipSend(_destinationChainSelector, evm2AnyMessage);

        // Update the message status to `Sent`
        messagesInfo[messageId].status = MessageStatus.Sent;

        // Emit an event with message details
        emit MessageSent(
            messageId,
            _destinationChainSelector,
            _receiver,
            _text,
            address(s_linkToken),
            fees
        );

        // Return the CCIP message ID
        return messageId;
    }

    /**
     * @dev Receives and processes messages sent via the Chainlink CCIP from allowed chains and senders.
     * Upon receiving a message, this function checks if the message's associated data indicates a previously
     * sent message awaiting acknowledgment. If the message is valid (i.e., its status is `Sent`), it updates
     * the message's status to `ProcessedOnDestination`, thereby acknowledging its receipt. It then emits a `MessageProcessedOnDestination`
     * event. If the message cannot be validated (e.g., it was not sent or has been tampered with), the function
     * reverts with a `MessageWasNotSentByMessageTracker` error. This mechanism ensures that only messages
     * genuinely sent and awaiting acknowledgment are marked as `ProcessedOnDestination`.
     * @param any2EvmMessage The CCIP message received, which includes the message ID, the data being acknowledged,
     * the source chain selector, and the sender's address.
     */
    function _ccipReceive(
        Client.Any2EVMMessage memory any2EvmMessage
    )
        internal
        override
        onlyAllowlisted(
            any2EvmMessage.sourceChainSelector,
            abi.decode(any2EvmMessage.sender, (address))
        ) // Ensure the source chain and sender are allowlisted for added security
    {
        bytes32 initialMsgId = abi.decode(any2EvmMessage.data, (bytes32)); // Decode the data sent by the receiver
        bytes32 acknowledgerMsgId = any2EvmMessage.messageId;
        messagesInfo[initialMsgId].acknowledgerMessageId = acknowledgerMsgId; // Store the messageId of the received message

        if (messagesInfo[initialMsgId].status == MessageStatus.Sent) {
            // Updates the status of the message to 'ProcessedOnDestination' to reflect that an acknowledgment
            // of receipt has been received and emits an event to log this confirmation along with relevant details.
            messagesInfo[initialMsgId].status = MessageStatus
                .ProcessedOnDestination;
            emit MessageProcessedOnDestination(
                acknowledgerMsgId,
                initialMsgId,
                any2EvmMessage.sourceChainSelector,
                abi.decode(any2EvmMessage.sender, (address))
            );
        } else if (
            messagesInfo[initialMsgId].status ==
            MessageStatus.ProcessedOnDestination
        ) {
            // If the message is already marked as 'ProcessedOnDestination', this indicates an attempt to
            // re-confirm a message that has already been processed on the destination chain and marked as such.
            revert MessageHasAlreadyBeenProcessedOnDestination(initialMsgId);
        } else {
            // If the message status is neither 'Sent' nor 'ProcessedOnDestination', it implies that the
            // message ID provided for acknowledgment does not correspond to a valid, previously
            // sent message.
            revert MessageWasNotSentByMessageTracker(initialMsgId);
        }
    }

    /// @notice Construct a CCIP message.
    /// @dev This function will create an EVM2AnyMessage struct with all the necessary information for sending a text.
    /// @param _receiver The address of the receiver.
    /// @param _text The string data to be sent.
    /// @param _feeTokenAddress The address of the token used for fees. Set address(0) for native gas.
    /// @return Client.EVM2AnyMessage Returns an EVM2AnyMessage struct which contains information for sending a CCIP message.
    function _buildCCIPMessage(
        address _receiver,
        string calldata _text,
        address _feeTokenAddress
    ) private pure returns (Client.EVM2AnyMessage memory) {
        // Create an EVM2AnyMessage struct in memory with necessary information for sending a cross-chain message
        return
            Client.EVM2AnyMessage({
                receiver: abi.encode(_receiver), // ABI-encoded receiver address
                data: abi.encode(_text), // ABI-encoded string
                tokenAmounts: new Client.EVMTokenAmount[](0), // Empty array as no tokens are transferred
                extraArgs: Client._argsToBytes(
                    // Additional arguments, setting gas limit
                    Client.EVMExtraArgsV2({
                        gasLimit: 300_000,
                        allowOutOfOrderExecution: true // Allows the message to be executed out of order relative to other messages from the same sender
                    })
                ),
                // Set the feeToken to a feeTokenAddress, indicating specific asset will be used for fees
                feeToken: _feeTokenAddress
            });
    }

    /// @notice Allows the owner of the contract to withdraw all tokens of a specific ERC20 token.
    /// @dev This function reverts with a 'NothingToWithdraw' error if there are no tokens to withdraw.
    /// @param _beneficiary The address to which the tokens will be sent.
    /// @param _token The contract address of the ERC20 token to be withdrawn.
    function withdrawToken(
        address _beneficiary,
        address _token
    ) public onlyOwner {
        // Retrieve the balance of this contract
        uint256 amount = IERC20(_token).balanceOf(address(this));

        // Revert if there is nothing to withdraw
        if (amount == 0) revert NothingToWithdraw();

        IERC20(_token).safeTransfer(_beneficiary, amount);
    }
}

Acknowledger.sol

// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

import {IRouterClient} from "@chainlink/contracts-ccip/src/v0.8/ccip/interfaces/IRouterClient.sol";
import {OwnerIsCreator} from "@chainlink/contracts-ccip/src/v0.8/shared/access/OwnerIsCreator.sol";
import {Client} from "@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol";
import {CCIPReceiver} from "@chainlink/contracts-ccip/src/v0.8/ccip/applications/CCIPReceiver.sol";
import {IERC20} from "@chainlink/contracts-ccip/src/v0.8/vendor/openzeppelin-solidity/v4.8.3/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@chainlink/contracts-ccip/src/v0.8/vendor/openzeppelin-solidity/v4.8.3/contracts/token/ERC20/utils/SafeERC20.sol";

using SafeERC20 for IERC20;

/**
 * THIS IS AN EXAMPLE CONTRACT THAT USES HARDCODED VALUES FOR CLARITY.
 * THIS IS AN EXAMPLE CONTRACT THAT USES UN-AUDITED CODE.
 * DO NOT USE THIS CODE IN PRODUCTION.
 */

/// @title - A simple acknowledger contract for receiving data and sending acknowledgement of receipt messages across chains.
contract Acknowledger is CCIPReceiver, OwnerIsCreator {
    // Custom errors to provide more descriptive revert messages.
    error NotEnoughBalance(uint256 currentBalance, uint256 calculatedFees); // Used to make sure contract has enough balance.
    error NothingToWithdraw(); // Used when trying to withdraw Ether but there's nothing to withdraw.
    error DestinationChainNotAllowlisted(uint64 destinationChainSelector); // Used when the destination chain has not been allowlisted by the contract owner.
    error InvalidReceiverAddress(); // Used when the receiver address is 0.
    error SourceChainNotAllowlisted(uint64 sourceChainSelector); // Used when the source chain has not been allowlisted by the contract owner.
    error SenderNotAllowlisted(address sender); // Used when the sender has not been allowlisted by the contract owner.

    string private s_lastReceivedText; // Store the last received text.

    // Mapping to keep track of allowlisted destination chains.
    mapping(uint64 => bool) public allowlistedDestinationChains;

    // Mapping to keep track of allowlisted source chains.
    mapping(uint64 => bool) public allowlistedSourceChains;

    // Mapping to keep track of allowlisted senders.
    mapping(address => bool) public allowlistedSenders;

    // Emitted when an acknowledgment message is successfully sent back to the sender contract.
    // This event signifies that the Acknowledger contract has recognized the receipt of an initial message
    // and has informed the original sender contract by sending an acknowledgment message,
    // including the original message ID.
    event AcknowledgmentSent(
        bytes32 indexed messageId, // The unique ID of the CCIP message.
        uint64 indexed destinationChainSelector, // The chain selector of the destination chain.
        address indexed receiver, // The address of the receiver on the destination chain.
        bytes32 data, // The data being sent back, containing the message ID of the initial message to acknowledge.
        address feeToken, // The token address used to pay CCIP fees for sending the acknowledgment.
        uint256 fees // The fees paid for sending the acknowledgment message via CCIP.
    );

    IERC20 private s_linkToken;

    /// @notice Constructor initializes the contract with the router address.
    /// @param _router The address of the router contract.
    /// @param _link The address of the link contract.
    constructor(address _router, address _link) CCIPReceiver(_router) {
        s_linkToken = IERC20(_link);
    }

    /// @dev Modifier that checks if the chain with the given destinationChainSelector is allowlisted.
    /// @param _destinationChainSelector The selector of the destination chain.
    modifier onlyAllowlistedDestinationChain(uint64 _destinationChainSelector) {
        if (!allowlistedDestinationChains[_destinationChainSelector])
            revert DestinationChainNotAllowlisted(_destinationChainSelector);
        _;
    }

    /// @dev Modifier that checks if the chain with the given sourceChainSelector is allowlisted and if the sender is allowlisted.
    /// @param _sourceChainSelector The selector of the destination chain.
    /// @param _sender The address of the sender.
    modifier onlyAllowlisted(uint64 _sourceChainSelector, address _sender) {
        if (!allowlistedSourceChains[_sourceChainSelector])
            revert SourceChainNotAllowlisted(_sourceChainSelector);
        if (!allowlistedSenders[_sender]) revert SenderNotAllowlisted(_sender);
        _;
    }

    /// @dev Updates the allowlist status of a destination chain for transactions.
    function allowlistDestinationChain(
        uint64 _destinationChainSelector,
        bool allowed
    ) external onlyOwner {
        allowlistedDestinationChains[_destinationChainSelector] = allowed;
    }

    /// @dev Updates the allowlist status of a source chain for transactions.
    function allowlistSourceChain(
        uint64 _sourceChainSelector,
        bool allowed
    ) external onlyOwner {
        allowlistedSourceChains[_sourceChainSelector] = allowed;
    }

    /// @dev Updates the allowlist status of a sender for transactions.
    function allowlistSender(address _sender, bool allowed) external onlyOwner {
        allowlistedSenders[_sender] = allowed;
    }

    /// @notice Sends an acknowledgment message back to the sender contract on the source chain
    /// and pays the fees using LINK tokens.
    /// @dev This function constructs and sends an acknowledgment message using CCIP,
    /// indicating the receipt and processing of an initial message. It emits the `AcknowledgmentSent` event
    /// upon successful sending. This function should be called after processing the received message
    /// to inform the sender contract about the successful message reception.
    /// @param _messageIdToAcknowledge The message ID of the initial message being acknowledged.
    /// @param _messageTrackerAddress The address of the message tracker contract on the source chain.
    /// @param _messageTrackerChainSelector The chain selector of the source chain.
    function _acknowledgePayLINK(
        bytes32 _messageIdToAcknowledge,
        address _messageTrackerAddress,
        uint64 _messageTrackerChainSelector
    ) private {
        if (_messageTrackerAddress == address(0))
            revert InvalidReceiverAddress();

        // Construct the CCIP message for acknowledgment, including the message ID of the initial message.
        Client.EVM2AnyMessage memory acknowledgment = Client.EVM2AnyMessage({
            receiver: abi.encode(_messageTrackerAddress), // ABI-encoded receiver address
            data: abi.encode(_messageIdToAcknowledge), // ABI-encoded message ID to acknowledge
            tokenAmounts: new Client.EVMTokenAmount[](0), // Empty array aas no tokens are transferred
            extraArgs: Client._argsToBytes(
                // Additional arguments, setting gas limit
                Client.EVMExtraArgsV2({
                    gasLimit: 200_000,
                    allowOutOfOrderExecution: true // Allows the message to be executed out of order relative to other messages from the same sender
                })
            ),
            // Set the feeToken to a feeTokenAddress, indicating specific asset will be used for fees
            feeToken: address(s_linkToken)
        });

        // Initialize a router client instance to interact with the cross-chain router.
        IRouterClient router = IRouterClient(this.getRouter());

        // Calculate the fee required to send the CCIP acknowledgment message.
        uint256 fees = router.getFee(
            _messageTrackerChainSelector, // The chain selector for routing the message.
            acknowledgment // The acknowledgment message data.
        );

        // Ensure the contract has sufficient balance to cover the message sending fees.
        if (fees > s_linkToken.balanceOf(address(this))) {
            revert NotEnoughBalance(s_linkToken.balanceOf(address(this)), fees);
        }

        // Approve the router to transfer LINK tokens on behalf of this contract to cover the sending fees.
        s_linkToken.approve(address(router), fees);

        // Send the acknowledgment message via the CCIP router and capture the resulting message ID.
        bytes32 messageId = router.ccipSend(
            _messageTrackerChainSelector, // The destination chain selector.
            acknowledgment // The CCIP message payload for acknowledgment.
        );

        // Emit an event detailing the acknowledgment message sending, for external tracking and verification.
        emit AcknowledgmentSent(
            messageId, // The ID of the sent acknowledgment message.
            _messageTrackerChainSelector, // The destination chain selector.
            _messageTrackerAddress, // The receiver of the acknowledgment, typically the original sender.
            _messageIdToAcknowledge, // The original message ID that was acknowledged.
            address(s_linkToken), // The fee token used.
            fees // The fees paid for sending the message.
        );
    }

    /// @dev Handles a received CCIP message, processes it, and acknowledges its receipt.
    /// This internal function is called upon the receipt of a new message via CCIP from an allowlisted source chain and sender.
    /// It decodes the message and acknowledges its receipt by calling `_acknowledgePayLINK`.
    /// @param any2EvmMessage The CCIP message received
    function _ccipReceive(
        Client.Any2EVMMessage memory any2EvmMessage
    )
        internal
        override
        onlyAllowlisted(
            any2EvmMessage.sourceChainSelector,
            abi.decode(any2EvmMessage.sender, (address))
        ) // Make sure source chain and sender are allowlisted
    {
        bytes32 messageIdToAcknowledge = any2EvmMessage.messageId; // The message ID of the received message to acknowledge
        address messageTrackerAddress = abi.decode(
            any2EvmMessage.sender,
            (address)
        ); // ABI-decoding of the message tracker address
        uint64 messageTrackerChainSelector = any2EvmMessage.sourceChainSelector; // The chain selector of the received message
        s_lastReceivedText = abi.decode(any2EvmMessage.data, (string)); // abi-decoding of the sent text

        _acknowledgePayLINK(
            messageIdToAcknowledge,
            messageTrackerAddress,
            messageTrackerChainSelector
        );
    }

    /// @notice Fetches the details of the last received message.
    /// @return text The last received text.
    function getLastReceivedMessage()
        external
        view
        returns (string memory text)
    {
        return (s_lastReceivedText);
    }

    /// @notice Allows the owner of the contract to withdraw all tokens of a specific ERC20 token.
    /// @dev This function reverts with a 'NothingToWithdraw' error if there are no tokens to withdraw.
    /// @param _beneficiary The address to which the tokens will be sent.
    /// @param _token The contract address of the ERC20 token to be withdrawn.
    function withdrawToken(
        address _beneficiary,
        address _token
    ) public onlyOwner {
        // Retrieve the balance of this contract
        uint256 amount = IERC20(_token).balanceOf(address(this));

        // Revert if there is nothing to withdraw
        if (amount == 0) revert NothingToWithdraw();

        IERC20(_token).safeTransfer(_beneficiary, amount);
    }
}

Final note

In this example, the message tracker contract emits an event when it receives the acknowledgment message confirming the initial message reception and processing on the counterpart chain. However, you could think of any other logic to execute when the message tracker receives the acknowledgment. This tutorial demonstrates the pattern for sending arbitrary data, but you can apply the same pattern to programmable token transfers.

What's next

Get the latest Chainlink content straight to your inbox.