diff --git a/public/images/ccip/tutorials/multiple-messages-single-transaction/ccip-explorer-msg-status-1.webp b/public/images/ccip/tutorials/multiple-messages-single-transaction/ccip-explorer-msg-status-1.webp new file mode 100644 index 00000000000..973001059e1 Binary files /dev/null and b/public/images/ccip/tutorials/multiple-messages-single-transaction/ccip-explorer-msg-status-1.webp differ diff --git a/public/images/ccip/tutorials/multiple-messages-single-transaction/ccip-explorer-msg-status-2.webp b/public/images/ccip/tutorials/multiple-messages-single-transaction/ccip-explorer-msg-status-2.webp new file mode 100644 index 00000000000..3fa2fbc5c1e Binary files /dev/null and b/public/images/ccip/tutorials/multiple-messages-single-transaction/ccip-explorer-msg-status-2.webp differ diff --git a/public/images/ccip/tutorials/multiple-messages-single-transaction/ccip-explorer-msg-status.webp b/public/images/ccip/tutorials/multiple-messages-single-transaction/ccip-explorer-msg-status.webp new file mode 100644 index 00000000000..51e04293011 Binary files /dev/null and b/public/images/ccip/tutorials/multiple-messages-single-transaction/ccip-explorer-msg-status.webp differ diff --git a/public/images/ccip/tutorials/multiple-messages-single-transaction/remix-txn-hash.webp b/public/images/ccip/tutorials/multiple-messages-single-transaction/remix-txn-hash.webp new file mode 100644 index 00000000000..b344c8bb39b Binary files /dev/null and b/public/images/ccip/tutorials/multiple-messages-single-transaction/remix-txn-hash.webp differ diff --git a/public/samples/CCIP/MessageDispatcher.sol b/public/samples/CCIP/MessageDispatcher.sol new file mode 100644 index 00000000000..a72402e9d5c --- /dev/null +++ b/public/samples/CCIP/MessageDispatcher.sol @@ -0,0 +1,285 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.19; + +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 {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; + +/// @title MessageDispatcher +/// @notice Handles sending CCIP messages to multiple chains within a single transaction. +/// @dev Allows messages to be sent immediately or registered for later dispatch. +contract MessageDispatcher is OwnerIsCreator { + /// @notice Thrown when the contract's balance is insufficient to cover the calculated fees. + /// @param currentBalance The current LINK token balance of the contract. + /// @param calculatedFees The required fees for the operation. + error NotEnoughBalance(uint256 currentBalance, uint256 calculatedFees); + + /// @notice Thrown when the destination chain is not allowlisted. + /// @param destinationChainSelector The selector of the destination chain. + error DestinationChainNotAllowlisted(uint64 destinationChainSelector); + + /// @notice Thrown when the receiver address is invalid (zero address). + error InvalidReceiverAddress(); + + /// @notice Thrown when there are no tokens available to withdraw. + error NothingToWithdraw(); + + /// @notice Thrown when no messages have been registered for dispatch. + error NoMessagesRegistered(); + + /// @notice Represents a message to be dispatched to another chain. + struct Message { + /// @notice The selector identifying the destination blockchain. + uint64 chainSelector; + /// @notice The recipient's address on the destination blockchain. + address receiver; + /// @notice The text content of the message. + string text; + } + + /// @notice Indicates whether a destination chain is allowlisted. + /// @dev Mapping from chain selector to its allowlist status. + mapping(uint64 => bool) public allowlistedDestinationChains; + + /// @notice Stores messages that have been registered for future dispatch. + Message[] public registeredMessages; + + /// @notice Emitted when a message is registered for later dispatch. + /// @param chainSelector The selector of the destination chain. + /// @param receiver The recipient's address on the destination chain. + /// @param text The text content of the message. + event MessageRegistered( + uint64 indexed chainSelector, + address indexed receiver, + string text + ); + + /// @notice Emitted when a message is sent to a destination chain. + /// @param messageId The unique identifier of the CCIP message. + /// @param destinationChainSelector The selector of the destination chain. + /// @param receiver The recipient's address on the destination chain. + /// @param text The text content of the message. + /// @param feeToken The address of the token used to pay CCIP fees. + /// @param fees The amount of fees paid for sending the CCIP message. + event MessageSent( + bytes32 indexed messageId, + uint64 indexed destinationChainSelector, + address receiver, + string text, + address feeToken, + uint256 fees + ); + + IRouterClient private s_router; + IERC20 private s_linkToken; + + /// @notice Initializes the contract with the specified router and LINK token addresses. + /// @param _router The address of the Chainlink CCIP router contract. + /// @param _link The address of the LINK token contract. + constructor(address _router, address _link) { + s_router = IRouterClient(_router); + s_linkToken = IERC20(_link); + } + + /// @notice Ensures that the destination chain is allowlisted. + /// @param _destinationChainSelector The selector of the destination chain. + modifier onlyAllowlistedDestinationChain(uint64 _destinationChainSelector) { + if (!allowlistedDestinationChains[_destinationChainSelector]) + revert DestinationChainNotAllowlisted(_destinationChainSelector); + _; + } + + /// @notice Validates that the receiver address is not the zero address. + /// @param _receiver The address of the receiver. + modifier validateReceiver(address _receiver) { + if (_receiver == address(0)) revert InvalidReceiverAddress(); + _; + } + + /// @notice Updates the allowlist status of a destination chain. + /// @param _destinationChainSelector The selector of the destination chain. + /// @param allowed Indicates whether the chain should be allowlisted (`true`) or removed (`false`). + function allowlistDestinationChain( + uint64 _destinationChainSelector, + bool allowed + ) external onlyOwner { + allowlistedDestinationChains[_destinationChainSelector] = allowed; + } + + /// @notice Registers a message for later dispatch to a specific chain. + /// @param _chainSelector The selector of the destination blockchain. + /// @param _receiver The recipient's address on the destination blockchain. + /// @param _text The text content of the message. + function registerMessage( + uint64 _chainSelector, + address _receiver, + string calldata _text + ) + external + onlyOwner + onlyAllowlistedDestinationChain(_chainSelector) + validateReceiver(_receiver) + { + registeredMessages.push( + Message({ + chainSelector: _chainSelector, + receiver: _receiver, + text: _text + }) + ); + + emit MessageRegistered(_chainSelector, _receiver, _text); + } + + /// @notice Dispatches all registered messages to their respective destination chains. + /// @dev Requires the contract to have sufficient LINK balance to cover fees. + function dispatchMessages() external onlyOwner { + uint256 messageCount = registeredMessages.length; + if (messageCount == 0) { + revert NoMessagesRegistered(); + } + + for (uint256 i = 0; i < messageCount; i++) { + Message memory message = registeredMessages[i]; + + string memory messageText = message.text; + + (bytes32 messageId, uint256 fees) = _sendMessage( + message.chainSelector, + message.receiver, + messageText + ); + + emit MessageSent( + messageId, + message.chainSelector, + message.receiver, + messageText, + address(s_linkToken), + fees + ); + } + + // Clear all registered messages after dispatching + delete registeredMessages; + } + + /// @notice Sends multiple messages directly to their respective destination chains in a single transaction. + /// @dev Requires the contract to have sufficient LINK balance to cover all fees. + /// @param messages An array of `Message` structs containing details for each message to be sent. + function dispatchMessagesDirect( + Message[] calldata messages + ) external onlyOwner { + uint256 messageCount = messages.length; + if (messageCount == 0) { + revert NoMessagesRegistered(); + } + + for (uint256 i = 0; i < messageCount; i++) { + Message calldata message = messages[i]; + + (bytes32 messageId, uint256 fees) = _sendMessage( + message.chainSelector, + message.receiver, + message.text + ); + + emit MessageSent( + messageId, + message.chainSelector, + message.receiver, + message.text, + address(s_linkToken), + fees + ); + } + } + + /// @notice Internal function to handle the sending of a single message to a destination chain. + /// @param _destinationChainSelector The selector of the destination blockchain. + /// @param _receiver The recipient's address on the destination blockchain. + /// @param _text The text content of the message. + /// @return messageId The unique identifier of the sent CCIP message. + /// @return fees The amount of LINK tokens paid for the message. + function _sendMessage( + uint64 _destinationChainSelector, + address _receiver, + string memory _text + ) private returns (bytes32 messageId, uint256 fees) { + // Construct the CCIP message with necessary details + Client.EVM2AnyMessage memory evm2AnyMessage = _buildCCIPMessage( + _receiver, + _text, + address(s_linkToken) + ); + + // Retrieve the fee required to send the CCIP message + fees = s_router.getFee(_destinationChainSelector, evm2AnyMessage); + + if (fees > s_linkToken.balanceOf(address(this))) + revert NotEnoughBalance(s_linkToken.balanceOf(address(this)), fees); + + // Approve the router to spend the necessary LINK tokens if not already approved + uint256 currentAllowance = s_linkToken.allowance( + address(this), + address(s_router) + ); + if (currentAllowance < fees) { + s_linkToken.safeApprove(address(s_router), fees - currentAllowance); + } + + // Send the CCIP message via the router and obtain the message ID + messageId = s_router.ccipSend( + _destinationChainSelector, + evm2AnyMessage + ); + + return (messageId, fees); + } + + /// @notice Constructs a CCIP message with the specified parameters. + /// @dev Prepares the `EVM2AnyMessage` struct with the receiver, data, and fee token. + /// @param _receiver The recipient's address on the destination chain. + /// @param _text The text content to be sent. + /// @param _feeTokenAddress The address of the token used to pay fees. Use `address(0)` for native gas. + /// @return Client.EVM2AnyMessage The constructed CCIP message. + function _buildCCIPMessage( + address _receiver, + string memory _text, + address _feeTokenAddress + ) private pure returns (Client.EVM2AnyMessage memory) { + return + Client.EVM2AnyMessage({ + receiver: abi.encode(_receiver), + data: abi.encode(_text), + tokenAmounts: new Client.EVMTokenAmount[](0), // Empty array as no tokens are transferred + extraArgs: Client._argsToBytes( + Client.EVMExtraArgsV1({gasLimit: 300_000}) + ), + feeToken: _feeTokenAddress + }); + } + + /// @notice Enables the contract to receive Ether. + /// @dev This is a fallback function with no additional logic. + receive() external payable {} + + /// @notice Allows the contract owner to withdraw all tokens of a specified ERC20 token. + /// @dev Reverts with `NothingToWithdraw` if the contract holds no tokens of the specified type. + /// @param _beneficiary The address to receive the withdrawn tokens. + /// @param _token The ERC20 token contract address to withdraw. + function withdrawToken( + address _beneficiary, + address _token + ) public onlyOwner { + uint256 amount = IERC20(_token).balanceOf(address(this)); + + if (amount == 0) revert NothingToWithdraw(); + + IERC20(_token).safeTransfer(_beneficiary, amount); + } +} diff --git a/src/config/sidebar.ts b/src/config/sidebar.ts index f235be5478a..a3693c7f74f 100644 --- a/src/config/sidebar.ts +++ b/src/config/sidebar.ts @@ -1015,6 +1015,10 @@ export const SIDEBAR: Partial> = { title: "Send Arbitrary Data and Receive Transfer Confirmation: A -> B -> A", url: "ccip/tutorials/send-arbitrary-data-receipt-acknowledgment", }, + { + title: "Send Multiple Messages in a Single Transaction", + url: "ccip/tutorials/multiple-messages-single-transaction", + }, { title: "Manual Execution", url: "ccip/tutorials/manual-execution", diff --git a/src/content/ccip/tutorials/multiple-messages-single-transaction.mdx b/src/content/ccip/tutorials/multiple-messages-single-transaction.mdx new file mode 100644 index 00000000000..cf0491cf9af --- /dev/null +++ b/src/content/ccip/tutorials/multiple-messages-single-transaction.mdx @@ -0,0 +1,227 @@ +--- +section: ccip +date: Last Modified +title: "Send Multiple Messages in a Single Transaction" +whatsnext: + { + "See example cross-chain dApps and tools": "/ccip/examples", + "See the list of supported networks": "/ccip/supported-networks", + "Learn about CCIP Architecture and Billing": "/ccip/architecture", + "Learn CCIP best practices": "/ccip/best-practices", + } +--- + +import { CodeSample, ClickToZoom, CopyText, Aside } from "@components" +import CcipCommon from "@features/ccip/CcipCommon.astro" + +This tutorial will teach you how to send multiple messages to different chains within a single transaction using Chainlink CCIP. You will learn how to send messages immediately without storing them in the contract's state, and how to register messages first and dispatch them later, which can be useful for scenarios like scheduled or automated message sending. + +**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 + +- This tutorial assumes you have completed the [Send Arbitrary Data](/ccip/tutorials/send-arbitrary-data) tutorial. +- Your account must have some testnet LINK and AVAX tokens on _Avalanche Fuji_. Both are available on the [Chainlink Faucet](https://faucets.chain.link/fuji). +- Learn how to [Fund a contract with LINK](/resources/fund-your-contract). + +## Tutorial + + + +### Deploy the _message dispatcher_ (sender) contract + +Deploy the `MessageDispatcher` contract on the source blockchain (e.g., Avalanche Fuji) from which you want to send messages. + +1. [Open the MessageDispatcher.sol contract](https://remix.ethereum.org/#url=https://docs.chain.link/samples/CCIP/MessageDispatcher.sol) in Remix. + + + + Note: The contract code is also available in the [Examine the code](/ccip/tutorials/multiple-messages-single-transaction#messagedispatchersol) section. + +1. Compile the contract. + +1. Deploy the contract on _Avalanche Fuji_: + + 1. Open MetaMask and select the _Avalanche Fuji_ network. + 1. 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_. + 1. 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](/ccip/supported-networks) page. The LINK token contract address is also listed on the [LINK Token Contracts](/resources/link-token-contracts) page. For _Avalanche Fuji_: + + - The router address is + - The LINK token address is + + 1. 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_. + + 1. After you confirm the transaction, the contract address will appear on the **Deployed Contracts** list. Copy your contract address. + + 1. Open MetaMask and send LINK to the contract address you copied. Your contract will pay CCIP fees in LINK. + +1. Allow the _Arbitrum Sepolia_ and _Ethereum Sepolia_ chains as destination chains for the _message dispatcher_ contract: + + 1. On the **Deploy & Run Transactions** tab in Remix, expand the _message dispatcher_ contract in the **Deployed Contracts** section. + 1. Call the `allowlistDestinationChain` function with as the destination chain selector for _Arbitrum Sepolia_ and as allowed. + 1. Once the transaction is confirmed, call the `allowlistDestinationChain` function with as the destination chain selector for _Ethereum Sepolia_ and as allowed. + + Note: You can find each network's chain selector on the [supported networks page](/ccip/supported-networks). + +### Deploy the Messenger (receiver) contract + +#### Deploy the Messenger contract on Arbitrum Sepolia + +Deploy the `Messenger` contract on _Arbitrum Sepolia_ and enable it to receive CCIP messages from _Avalanche Fuji_. You must also enable your contract to receive CCIP messages from the _message dispatcher_ contract. + +1. [Open the Messenger.sol](https://remix.ethereum.org/#url=https://docs.chain.link/samples/CCIP/Messenger.sol) contract in Remix. + + + + Note: The contract code is also available in the [Examine the code](/ccip/tutorials/multiple-messages-single-transaction#messengersol) section. + +1. Compile the contract. + +1. Deploy the contract on _Arbitrum Sepolia_: + + 1. Open MetaMask and select the _Arbitrum Sepolia_ network. + + 1. On the **Deploy & Run Transactions** tab in Remix, make sure the **Environment** is still set to _Injected Provider - MetaMask_. + 1. 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](/ccip/supported-networks) page. The LINK token contract address is also listed on the [LINK Token Contracts](/resources/link-token-contracts) page. For _Arbitrum Sepolia_: + + - The Router address is . + - The LINK token address is . + + 1. 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 _Arbitrum Sepolia_. + + 1. After you confirm the transaction, the contract address appears in the **Deployed Contracts** list. Copy this contract address. + +1. Allow the _Avalanche Fuji_ chain selector for the source chain. You must also enable your receiver contract to receive CCIP messages from the message dispatcher you deployed on _Avalanche Fuji_. + + 1. On the **Deploy & Run Transactions** tab in Remix, expand the _messenger_ contract in the **Deployed Contracts** section. Expand the `allowlistSourceChain` and `allowlistSender` functions and fill in the following arguments: + + | Function | Description | Value (_Avalanche Fuji_) | + | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | + | allowlistSourceChain | CCIP Chain identifier of the source blockchain. You can find each network's chain selector on the [ supported networks page ](/ccip/supported-networks) | , | + | allowlistSender | The address of the message dispatcher contract deployed on _Avalanche Fuji_ | Your deployed contract address, | + + 1. Open MetaMask and select the _Arbitrum Sepolia_ network. + 1. 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. + +#### Deploy the Messenger contract on Ethereum Sepolia + +Repeat the [steps above](/ccip/tutorials/multiple-messages-single-transaction#deploy-the-messenger-contract-on-arbitrum-sepolia) to deploy the `Messenger.sol` contract on _Ethereum Sepolia_. When you deploy the _messenger_ contract on _Ethereum Sepolia_, you must use the _Ethereum Sepolia_ router address and LINK token address: + +- Router address: . +- LINK token address: . + +**Note**: Once your _messenger_ contract is deployed on _Ethereum Sepolia_, remember to allow the _Avalanche Fuji_ chain selector for the source chain and the _message dispatcher_ contract as a sender. + +At this point: + +- You have one _message dispatcher_ (sender) contract on _Avalanche Fuji_, one _messenger_ (receiver) contract on _Arbitrum Sepolia_, and one _messenger_ (receiver) contract on _Ethereum Sepolia_. +- You sent `0.5` LINK to the _message dispatcher_ contract to pay the CCIP fees. +- You allowed _Arbitrum Sepolia_ and _Ethereum Sepolia_ as destination chains for the _message dispatcher_ contract. +- You allowed _Avalanche Fuji_ as a source chain for both your _messenger_ contracts and allowed the _message dispatcher_ contract as a sender. + +**Note**: This setup allows you to send messages from the _message dispatcher_ contract to _messenger_ (receiver) contracts deployed on Arbitrum Sepolia and Ethereum Sepolia. You can extend this setup to send messages to as many _messenger_ (receiver) contracts as you want on any supported chains. + +### Send multiple messages directly + +The `dispatchMessagesDirect` function allows you to send directly multiple messages to a single chain or different chains in a single transaction. In this example, you will send two messages to your _messenger_ contract on _Arbitrum Sepolia_ and one to your _messenger_ contract on _Ethereum Sepolia_. + +1. On the **Deploy & Run Transactions** tab in Remix, expand the _message dispatcher_ contract in the **Deployed Contracts** section. Fill in the `dispatchMessagesDirect` function argument with the following tuple array: + + ```solidity + [ + [3478487238524512106, "", "Hello Arbitrum Sepolia ! This is Message 1"], + [3478487238524512106, "", "Hello Arbitrum Sepolia ! This is Message 2"], + [16015286601757825753, "", "Hello Ethereum Sepolia ! This is Message 3"] + ] + ``` + + Where each tuple contains the following elements: + + - The chain selector for the destination chain + - The address of the messenger (receiver) contract + - The message to be sent + +1. Open MetaMask and select the _Avalanche Fuji_ network. +1. Click **transact** to call the `dispatchMessagesDirect` function. MetaMask prompts you to confirm the transaction. +1. Upon transaction success, expand the last transaction in the Remix log and copy the transaction hash. In this example, it is `0x5096b8cbd179d4370444c51b582345002885d92f4ff24395bf3dc68876f977c4`. + + + +1. Open the [CCIP Explorer](https://ccip.chain.link/) and use the transaction hash that you copied to search for your cross-chain transaction. + + + + After the transaction is finalized on the source chain, it will take a few minutes for CCIP to deliver the data to _Arbitrum Sepolia_ and _Ethereum Sepolia_, and call the `ccipReceive` function on your _messenger_ contracts. + +1. Wait for each CCIP message to be marked with a "Success" status on the [CCIP Explorer](https://ccip.chain.link/). + + + +### Register and dispatch messages later + +The `MessageDispatcher` contract also allows you to register messages in the contract's state and dispatch them later. + +In this example, you will register two messages in the _message dispatcher_ contract before dispatching them. + +1. Open MetaMask and select the _Avalanche Fuji_ network. +1. In the **Deploy & Run Transactions** tab of Remix, locate the `MessageDispatcher` contract within the **Deployed Contracts** section. To register messages for dispatch, invoke the `registerMessage` function separately for each message you want to send. For each call, provide the following parameters: + + - The chain selector for the destination chain + - The address of the messenger (receiver) contract + - The message to be sent + + Each set of parameters is provided as a tuple consisting of a chain selector, a receiver address, and the message text. For example: + + - Call `registerMessage` with the following tuple for _Arbitrum Sepolia_: + + ``` + [3478487238524512106,"","Hello Arbitrum Sepolia! This is Message 1"] + ``` + + Replace `` with the address of your _messenger_ contract on \_Arbitrum Sepolia + + - Call `registerMessage` again with the following tuple for _Ethereum Sepolia_: + + ``` + [16015286601757825753,"","Hello Ethereum Sepolia! This is Message 2"] + ``` + + Replace `` with the address of your _messenger_ contract on \_Ethereum Sepolia + +1. When you are ready to send the registered messages, call the `dispatchMessages` function in the **Deployed Contracts** section of Remix. This will send all registered messages in a single transaction. + +1. Retrieve the transaction hash and monitor the status of the messages on the [CCIP Explorer](https://ccip.chain.link/). + +## Explanation + +### Two methods for dispatching multiple messages in a single transaction + +- Immediate dispatch (`dispatchMessagesDirect`): This method is ideal for users who want to send multiple messages within the same transaction quickly, and without storing them in the contract's state. + +- Registered dispatch (`registerMessage` and `dispatchMessages`): This method is suitable for use cases where messages need to be stored and sent later, possibly triggered by an external event or at a specific time (e.g., using [Chainlink Automation](/chainlink-automation)). It is useful for scheduling and automating message sending. + +### Security and integrity + +Contracts use allowlists to process only messages from and to allowed sources. + +- The `sendMessage` function is protected by the `onlyAllowlistedDestinationChain` modifier, which ensures 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. + +## Examine the code + +### MessageDispatcher.sol + + + +### Messenger.sol + +