练习 1:使用防御性示例模式进行可编程代币转移

转移代币与数据 - 防御性示例

本教程扩展了可编程代币传输的示例。它使用 Chainlink CCIP 在不同区块链上的智能合约之间传输代币和任意数据,专注于接收合约中的防御性编码。如果在 CCIP 消息接收期间发生指定错误,合约将锁定代币。锁定代币允许所有者根据需要恢复和重定向它们。防御性编码至关重要,因为它使得恢复锁定的代币成为可能,并确保了用户资产的保护。

开始之前

  1. 您应该了解如何编写、编译、部署和资助智能合约。如果您需要复习基础知识,请阅读本教程,它将指导您使用 Solidity 编程语言,在 MetaMask 钱包中交互以及在 Remix 开发环境中工作。

  2. 您的账户必须在 Avalanche Fuji 上有一些 AVAX 和 LINK 代币,在以太坊 Sepolia 上有一些 ETH 代币。了解如何 获取测试网 LINK

  3. 查看支持的网络页面,确认您要传输的代币是否支持您的通道。在这个例子中,您将从 Avalanche Fuji 传输代币到以太坊 Sepolia,所以请在这里检查支持的代币列表。

  4. 了解如何 获取 CCIP 测试代币。按照本指南操作后,您应该拥有 CCIP-BnM 代币,并且 CCIP-BnM 应该出现在 MetaMask 中您的代币列表中。

  5. 了解如何 资助您的合约。本指南展示了如何使用 LINK 资助您的合约,但您可以使用相同的指南为合约资助任何 ERC20 代币,只要它们出现在 MetaMask 的代币列表中。

  6. 按照前一个教程:使用 数据传输代币 来学习如何使用 CCIP 进行可编程代币传输。

编码时间!

在这个练习中,我们将从 Avalanche Fuji 上的智能合约启动交易,向 以太坊 Sepolia 上的另一个智能合约发送字符串文本和 CCIP-BnM 代币,使用 CCIP。然而,在到达接收合约时,处理逻辑将故意失败。本教程将展示一种优雅的错误处理方法,允许合约所有者恢复被锁定的代币。

正确估计您的 gas 限制

彻底测试所有场景以准确估计所需的 gas 限制至关重要,包括失败场景。请注意,用于执行失败场景的错误处理逻辑的 gas 可能高于成功场景。

// 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 {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";
import {EnumerableMap} from "@chainlink/contracts-ccip/src/v0.8/vendor/openzeppelin-solidity/v4.8.3/contracts/utils/structs/EnumerableMap.sol";

/**
 * 这是一个使用硬编码值来增加清晰度的示例合约。
 * 这是一个使用未经审计代码的示例合约。
 * 不要在生产环境中使用这段代码。
 */

/// @title - 一个简单的跨链传输/接收代币和数据的信使合约。
/// @dev - 这个示例展示了如何在撤销的情况下恢复代币
contract ProgrammableDefensiveTokenTransfers is CCIPReceiver, OwnerIsCreator {
    using EnumerableMap for EnumerableMap.Bytes32ToUintMap;
    using SafeERC20 for IERC20;

    // 自定义错误,提供更具描述性的撤销消息。
    error NotEnoughBalance(uint256 currentBalance, uint256 calculatedFees); // 用于确保合约有足够的余额来支付费用。
    error NothingToWithdraw(); // 当尝试提取以太币但没有可提取的以太币时使用。
    error FailedToWithdrawEth(address owner, address target, uint256 value); // 当提取以太币失败时使用。
    error DestinationChainNotAllowlisted(uint64 destinationChainSelector); // 当目标链没有被合约所有者列入允许名单时使用。
    error SourceChainNotAllowed(uint64 sourceChainSelector); // 当源链没有被列入允许名单时使用。
    error SenderNotAllowed(address sender); // 当发件人没有被合约所有者列入允许名单时使用。
    error InvalidReceiverAddress(); // 当接收地址为0时使用。
    error OnlySelf(); // 当函数在合约外部被调用时使用。
    error ErrorCase(); // 当模拟消息处理过程中的撤销时使用。
    error MessageNotFailed(bytes32 messageId);

    // 示例错误代码,可以有许多不同的错误代码。
    enum ErrorCode {
        // 首先是RESOLVED,以便默认值是已解决的。
        RESOLVED,
        // 此处可以有任意数量的错误代码。
        FAILED
    }

    struct FailedMessage {
        bytes32 messageId;
        ErrorCode errorCode;
    }

    // 当消息发送到另一个链时发出的事件。
    event MessageSent(
        bytes32 indexed messageId, // CCIP消息的唯一ID。
        uint64 indexed destinationChainSelector, // 目的链的链选择器。
        address receiver, // 目的链上的接收地址。
        string text, // 被发送的文本。
        address token, // 被转移的代币地址。
        uint256 tokenAmount, // 被转移的代币数量。
        address feeToken, // 用于支付CCIP费用的代币地址。
        uint256 fees // 为发送消息支付的费用。
    );

    // 当从另一个链收到消息时发出的事件。
    event MessageReceived(
        bytes32 indexed messageId, // CCIP消息的唯一ID。
        uint64 indexed sourceChainSelector, // 来源链的链选择器。
        address sender, // 来自源链的发件人地址。
        string text, // 收到的文本。
        address token, // 被转移的代币地址。
        uint256 tokenAmount // 被转移的代币数量。
    );

    event MessageFailed(bytes32 indexed messageId, bytes reason);
    event MessageRecovered(bytes32 indexed messageId);

    bytes32 private s_lastReceivedMessageId; // 存储最后接收的消息ID。
    address private s_lastReceivedTokenAddress; // 存储最后接收的代币地址。
    uint256 private s_lastReceivedTokenAmount; // 存储最后接收的数量。
    string private s_lastReceivedText; // 存储最后接收的文本。

    // 映射,用于跟踪允许的目标链。
    mapping(uint64 => bool) public allowlistedDestinationChains;

    // 映射,用于跟踪允许的源链。
    mapping(uint64 => bool) public allowlistedSourceChains;

    // 映射,用于跟踪允许的发件人。
    mapping(address => bool) public allowlistedSenders;

    IERC20 private s_linkToken;

    // 存储失败消息的内容。
    mapping(bytes32 messageId => Client.Any2EVMMessage contents)
        public s_messageContents;

    // 包含失败消息及其状态。
    EnumerableMap.Bytes32ToUintMap internal s_failedMessages;

    // 用于模拟消息处理函数中的撤销。
    bool internal s_simRevert = false;

    /// @notice 构造函数,用于使用路由器地址初始化合约。
    /// @param _router 路由器合约的地址。
    /// @param _link LINK合约的地址。
    constructor(address _router, address _link) CCIPReceiver(_router) {
        s_linkToken = IERC20(_link);
    }

    /// @dev 修饰符,用于检查给定目标链选择器的链是否被列入允许名单。
    /// @param _destinationChainSelector 目的链的选择器。
    modifier onlyAllowlistedDestinationChain(uint64 _destinationChainSelector) {
        if (!allowlistedDestinationChains[_destinationChainSelector])
            revert DestinationChainNotAllowlisted(_destinationChainSelector);
        _;
    }

    /// @dev 修饰符,用于检查源链选择器的链和发件人是否被允许。
    /// @param _sourceChainSelector 目的链的选择器。
    /// @param _sender 发件人的地址。
    modifier onlyAllowlisted(uint64 _sourceChainSelector, address _sender) {
        if (!allowlistedSourceChains[_sourceChainSelector])
            revert SourceChainNotAllowed(_sourceChainSelector);
        if (!allowlistedSenders[_sender]) revert SenderNotAllowed(_sender);
        _;
    }

    /// @dev 修饰符,检查接收地址不为0。
    /// @param _receiver 接收地址。
    modifier validateReceiver(address _receiver) {
        if (_receiver == address(0)) revert InvalidReceiverAddress();
        _;
    }

    /// @dev 修饰符,仅允许合同本身执行功能。
    /// 如果由任何帐户调用,则抛出异常,而不是合同本身。
    modifier onlySelf() {
        if (msg.sender != address(this)) revert OnlySelf();
        _;
    }

    /// @dev 更新目标链的交易允许状态。
    /// @notice 只能由所有者调用此功能。
    /// @param _destinationChainSelector 要更新的目的链选择器。
    /// @param allowed 设置目的链的允许状态。
    function allowlistDestinationChain(
        uint64 _destinationChainSelector,
        bool allowed
    ) external onlyOwner {
        allowlistedDestinationChains[_destinationChainSelector] = allowed;
    }

    /// @dev 更新源链的允许状态
    /// @notice 只能由所有者调用此功能。
    /// @param _sourceChainSelector 要更新的源链选择器。
    /// @param allowed 设置源链的允许状态。
    function allowlistSourceChain(
        uint64 _sourceChainSelector,
        bool allowed
    ) external onlyOwner {
        allowlistedSourceChains[_sourceChainSelector] = allowed;
    }

    /// @dev 更新发件人的交易允许状态。
    /// @notice 只能由所有者调用此功能。
    /// @param _sender 要更新的发件人地址。
    /// @param allowed 设置发件人的允许状态。
    function allowlistSender(address _sender, bool allowed) external onlyOwner {
        allowlistedSenders[_sender] = allowed;
    }

    /// @notice 向目标链上的接收者发送数据和转移代币。
    /// @notice 使用 LINK 支付费用。
    /// @dev 假设你的合约有足够的LINK来支付 CCIP 费用。
    /// @param _destinationChainSelector 目标区块链的标识符(即选择器)。
    /// @param _receiver 目标区块链上的接收者地址。
    /// @param _text 要发送的字符串数据。
    /// @param _token 代币地址。
    /// @param _amount 代币数量。
    /// @return messageId 发送的 CCIP 消息的 ID。
    function sendMessagePayLINK(
        uint64 _destinationChainSelector,
        address _receiver,
        string calldata _text,
        address _token,
        uint256 _amount
    )
        external
        onlyOwner
        onlyAllowlistedDestinationChain(_destinationChainSelector)
        validateReceiver(_receiver)
        returns (bytes32 messageId)
    {
        // 在内存中创建一个 EVM2AnyMessage 结构,其中包含发送跨链消息所需的信息
        // address(linkToken) 表示费用以 LINK 支付
        Client.EVM2AnyMessage memory evm2AnyMessage = _buildCCIPMessage(
            _receiver,
            _text,
            _token,
            _amount,
            address(s_linkToken)
        );

        // 初始化一个路由器客户端实例,以与跨链路由器交互
        IRouterClient router = IRouterClient(this.getRouter());

        // 获取发送 CCIP 消息所需的费用
        uint256 fees = router.getFee(_destinationChainSelector, evm2AnyMessage);

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

        // 批准路由器代表合约转移 LINK 代币。它将花费费用中的 LINK
        s_linkToken.approve(address(router), fees);

        // 批准路由器代表合约花费给定数量的代币。它将花费给定数量的代币
        IERC20(_token).approve(address(router), _amount);

        // 通过路由器发送消息,并存储返回的消息 ID
        messageId = router.ccipSend(_destinationChainSelector, evm2AnyMessage);

        // 发出一个事件,其中包含消息详细信息
        emit MessageSent(
            messageId,
            _destinationChainSelector,
            _receiver,
            _text,
            _token,
            _amount,
            address(s_linkToken),
            fees
        );

        // 返回消息 ID
        return messageId;
    }

    /// @notice 向目标链上的接收者发送数据和转移代币。
    /// @notice 使用原生gas支付费用。
    /// @dev 假设你的合约有足够的原生gas,如以太坊上的ETH或Polygon上的MATIC。
    /// @param _destinationChainSelector 目标区块链的标识符(即选择器)。
    /// @param _receiver 目标区块链上的接收者地址。
    /// @param _text 要发送的字符串数据。
    /// @param _token 代币地址。
    /// @param _amount 代币数量。
    /// @return messageId 发送的CCIP消息的ID。
    function sendMessagePayNative(
        uint64 _destinationChainSelector,
        address _receiver,
        string calldata _text,
        address _token,
        uint256 _amount
    )
        external
        onlyOwner
        onlyAllowlistedDestinationChain(_destinationChainSelector)
        validateReceiver(_receiver)
        returns (bytes32 messageId)
    {
        // 在内存中创建一个 EVM2AnyMessage 结构,其中包含发送跨链消息所需的信息
        // address(0) 表示费用以原生气体支付
        Client.EVM2AnyMessage memory evm2AnyMessage = _buildCCIPMessage(
            _receiver,
            _text,
            _token,
            _amount,
            address(0)
        );

        // 初始化一个路由器客户端实例,以与跨链路由器交互
        IRouterClient router = IRouterClient(this.getRouter());

        // 获取发送 CCIP 消息所需的费用
        uint256 fees = router.getFee(_destinationChainSelector, evm2AnyMessage);

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

        // 批准路由器代表合约花费给定数量的代币。它将花费给定数量的代币
        IERC20(_token).approve(address(router), _amount);

        // 通过路由器发送消息,并存储返回的消息 ID
        messageId = router.ccipSend{value: fees}(
            _destinationChainSelector,
            evm2AnyMessage
        );

        // 发出一个事件,其中包含消息详细信息
        emit MessageSent(
            messageId,
            _destinationChainSelector,
            _receiver,
            _text,
            _token,
            _amount,
            address(0),
            fees
        );

        // 返回消息 ID
        return messageId;
    }
    
    /**
     * @notice 返回最后接收到的CCIP消息的详细信息。
     * @dev 此函数检索最后接收到的CCIP消息的ID、文本、代币地址和代币数量。
     * @return messageId 最后接收到的CCIP消息的ID。
     * @return text 最后接收到的CCIP消息的文本。
     * @return tokenAddress 最后接收到的CCIP消息中的代币地址。
     * @return tokenAmount 最后接收到的CCIP消息中的代币数量。
     */
    function getLastReceivedMessageDetails()
        public
        view
        returns (
            bytes32 messageId,
            string memory text,
            address tokenAddress,
            uint256 tokenAmount
        )
    {
        return (
            s_lastReceivedMessageId,
            s_lastReceivedText,
            s_lastReceivedTokenAddress,
            s_lastReceivedTokenAmount
        );
    }

    /**
     * @notice 检索失败消息的分页列表。
     * @dev 此功能返回由 `offset` 和 `limit` 参数定义的失败消息的子集。它确保分页参数在可用数据集的范围内。
     * @param offset 要返回的第一个失败消息的索引,通过从数据集的开头跳过指定数量的消息来实现分页。
     * @param limit 要返回的失败消息的最大数量,限制返回数组的大小。
     * @return failedMessages `FailedMessage` 结构的数组,每个结构包含一个 `messageId` 和一个 `errorCode`(解决或失败),表示请求的失败消息的子集。返回数组的长度由 `limit` 和失败消息的总数决定。
     */
    function getFailedMessages(
        uint256 offset,
        uint256 limit
    ) external view returns (FailedMessage[] memory) {
        uint256 length = s_failedMessages.length();

        // 计算实际返回的项目数量(不能超过总长度或请求的限制)
        uint256 returnLength = (offset + limit > length)
            ? length - offset
            : limit;
        FailedMessage[] memory failedMessages = new FailedMessage[](
            returnLength
        );

        // 调整循环以尊重分页(从 offset 开始,结束于 offset + limit 或总长度)
        for (uint256 i = 0; i < returnLength; i++) {
            (bytes32 messageId, uint256 errorCode) = s_failedMessages.at(
                offset + i
            );
            failedMessages[i] = FailedMessage(messageId, ErrorCode(errorCode));
        }
        return failedMessages;
    }

    /// @notice CCIP 路由器调用的入口点。此功能不应撤销,所有错误应在合约内部处理。
    /// @param any2EvmMessage 要处理的消息。
    /// @dev 非常重要的是确保只有路由器调用此功能。
    function ccipReceive(
        Client.Any2EVMMessage calldata any2EvmMessage
    )
        external
        override
        onlyRouter
        onlyAllowlisted(
            any2EvmMessage.sourceChainSelector,
            abi.decode(any2EvmMessage.sender, (address))
        ) // 确保源链和发件人被列入允许名单
    {
        /* solhint-disable no-empty-blocks */
        try this.processMessage(any2EvmMessage) {
            // 本示例中故意为空;如果 processMessage 成功,则无需采取任何行动
        } catch (bytes memory err) {
            // 可以根据捕获的错误设置不同的错误代码。每个都可以有不同的处理方式。
            s_failedMessages.set(
                any2EvmMessage.messageId,
                uint256(ErrorCode.FAILED)
            );
            s_messageContents[any2EvmMessage.messageId] = any2EvmMessage;
            // 不要撤销,以免 CCIP 撤销。改为发出事件。
            // 可以稍后重试该消息,而无需手动执行 CCIP。
            emit MessageFailed(any2EvmMessage.messageId, err);
            return;
        }
    }

    /// @notice 为此合约处理传入消息的入口点。
    /// @param any2EvmMessage 接收到的 CCIP 消息。
    /// @dev 将指定数量的代币转移到此合约的所有者。此功能必须是外部的,因为利用 Solidity 的 try/catch 错误处理机制。
    /// 它使用 `onlySelf`:只能由合约调用。
    function processMessage(
        Client.Any2EVMMessage calldata any2EvmMessage
    )
        external
        onlySelf
        onlyAllowlisted(
            any2EvmMessage.sourceChainSelector,
            abi.decode(any2EvmMessage.sender, (address))
        ) // 确保源链和发件人被列入允许名单
    {
        // 为测试目的模拟撤销
        if (s_simRevert) revert ErrorCase();

        _ccipReceive(any2EvmMessage); // 处理消息 - 可能会撤销
    }

    /// @notice 允许所有者重试失败的消息,以解锁相关的代币。
    /// @param messageId 失败消息的唯一标识符。
    /// @param tokenReceiver 将代币发送到的地址。
    /// @dev 该功能只能由合约所有者调用。它更改消息的状态从 'failed(失败)' 到 'resolved(已解决)',以防止重复条目和多次重试相同的消息
    function retryFailedMessage(
        bytes32 messageId,
        address tokenReceiver
    ) external onlyOwner {
        // 检查消息是否失败;如果没有,则撤销交易。
        if (s_failedMessages.get(messageId) != uint256(ErrorCode.FAILED))
            revert MessageNotFailed(messageId);

        // 将错误代码设置为 RESOLVED,以禁止重新进入和多次重试同一失败消息。
        s_failedMessages.set(messageId, uint256(ErrorCode.RESOLVED));

        // 检索失败消息的内容。
        Client.Any2EVMMessage memory message = s_messageContents[messageId];

        // 本示例期望一次发送一枚代币,但你可以处理多个代币。
        // 将关联的代币转移到指定的接收者作为紧急逃生方式。
        IERC20(message.destTokenAmounts[0].token).safeTransfer(
            tokenReceiver,
            message.destTokenAmounts[0].amount
        );

        // 发出事件,表明消息已被恢复。
        emit MessageRecovered(messageId);
    }

    /// @notice 允许所有者切换模拟撤销的测试。
    /// @param simRevert 如果为 `true`,模拟撤销条件;如果为 `false`,禁用模拟。
    /// @dev 该功能只能由合约所有者调用。
    function setSimRevert(bool simRevert) external onlyOwner {
        s_simRevert = simRevert;
    }

    function _ccipReceive(
        Client.Any2EVMMessage memory any2EvmMessage
    ) internal override {
        s_lastReceivedMessageId = any2EvmMessage.messageId; // 获取消息ID
        s_lastReceivedText = abi.decode(any2EvmMessage.data, (string)); // 解码发送的文本
        // 预期一次转移一个代币,但可以转移多个代币。
        s_lastReceivedTokenAddress = any2EvmMessage.destTokenAmounts[0].token;
        s_lastReceivedTokenAmount = any2EvmMessage.destTokenAmounts[0].amount;
        emit MessageReceived(
            any2EvmMessage.messageId,
            any2EvmMessage.sourceChainSelector, // 获取源链标识符(即选择器)
            abi.decode(any2EvmMessage.sender, (address)), // 解码发送者地址,
            abi.decode(any2EvmMessage.data, (string)),
            any2EvmMessage.destTokenAmounts[0].token,
            any2EvmMessage.destTokenAmounts[0].amount
        );
    }
    
    /// @notice 构建CCIP消息。
    /// @dev 此函数将创建一个EVM2AnyMessage结构体,包含所有必要的信息以实现可编程的代币转移。
    /// @param _receiver 接收者的地址。
    /// @param _text 要发送的字符串数据。
    /// @param _token 要转移的代币。
    /// @param _amount 要转移的代币数量。
    /// @param _feeTokenAddress 用于费用的代币地址。将address(0)设置为使用原生gas。
    /// @return Client.EVM2AnyMessage 返回一个EVM2AnyMessage结构体,其中包含发送CCIP消息所需的信息。
    function _buildCCIPMessage(
        address _receiver,
        string calldata _text,
        address _token,
        uint256 _amount,
        address _feeTokenAddress
    ) private pure returns (Client.EVM2AnyMessage memory) {
        // 设置代币数量
        Client.EVMTokenAmount[]
            memory tokenAmounts = new Client.EVMTokenAmount[](1);
        Client.EVMTokenAmount memory tokenAmount = Client.EVMTokenAmount({
            token: _token,
            amount: _amount
        });
        tokenAmounts[0] = tokenAmount;
        // 在内存中创建一个 EVM2AnyMessage 结构,包含发送跨链消息所需的信息
        Client.EVM2AnyMessage memory evm2AnyMessage = Client.EVM2AnyMessage({
            receiver: abi.encode(_receiver), // ABI 编码的接收地址
            data: abi.encode(_text), // ABI 编码的字符串
            tokenAmounts: tokenAmounts, // 被转移的代币数量和类型
            extraArgs: Client._argsToBytes(
                // 额外参数,设置 gas 限制
                Client.EVMExtraArgsV1({gasLimit: 400_000})
            ),
            // 设置 feeToken 为 feeTokenAddress,表示将使用特定资产支付费用
            feeToken: _feeTokenAddress
        });
        return evm2AnyMessage;
    }

    /// @notice 该合约接收以太的默认函数。
    /// @dev 该函数没有函数体,使其成为接收以太的默认函数。
    /// 当向合约发送以太时而没有任何数据时,会自动调用此函数。
    receive() external payable {}

    /// @notice 允许合约所有者从合约中提取全部以太余额。
    /// @dev 如果没有资金可提取或转账失败,该函数将撤销。
    /// 只能由合约所有者调用。
    /// @param _beneficiary 将以太发送到的地址。
    function withdraw(address _beneficiary) public onlyOwner {
        // 检索此合约的余额
        uint256 amount = address(this).balance;

        // 如果没有可提取的内容,撤销
        if (amount == 0) revert NothingToWithdraw();

        // 尝试发送资金,捕获成功状态并丢弃任何返回数据
        (bool sent, ) = _beneficiary.call{value: amount}("");

        // 如果发送失败,带有尝试转账信息的撤销
        if (!sent) revert FailedToWithdrawEth(msg.sender, _beneficiary, amount);
    }

    /// @notice 允许合约所有者提取特定ERC20代币的所有代币。
    /// @dev 如果没有代币可提取,则此函数将回滚并显示'NothingToWithdraw'错误。
    /// @param _beneficiary 应将代币发送到的地址。
    /// @param _token 要提取的ERC20代币的合约地址。
    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);
    }
}

部署您的合约

要使用此合约:

  1. 编译您的合约。

  2. 部署并在 Avalanche Fuji 上资助您的发送者合约,并启用向以太坊 Sepolia 发送消息的功能:

    1. 打开 MetaMask 并选择网络 Avalanche Fuji。

    2. 在 Remix IDE 中,点击 Deploy & Run Transactions,从环境列表中选择 Injected Provider - MetaMask。然后 Remix 将与您的 MetaMask 钱包交互,以与 Avalanche Fuji 通信。

    3. 填写您的区块链路由器和 LINK 合约地址。可以在 支持的网络页面 上找到路由器地址,在 LINK 代币合约页面 上找到 LINK 合约地址。对于 Avalanche Fuji,路由器地址是 0xF694E193200268f9a4868e4Aa017A0118C9a8177,LINK 合约地址是 0x0b9d5D9136855f6FEc3c0993feE6E9CE8a297846

    4. 点击 transact 按钮。确认交易后,合约地址将出现在 已部署合约 列表中。记下您的合约地址。

    5. 打开 MetaMask 并用 CCIP-BnM 代币为您的合约注资。您可以向您的合约转账 0.002 CCIP-BnM

    6. 启用您的合约以向 以太坊 Sepolia 发送 CCIP 消息:

      1. 在 Remix IDE 中,转到 Deploy & Run Transactions,在您部署在 Avalanche Fuji 上的智能合约的函数列表中。

      2. 调用 allowlistDestinationChain,传入 16015286601757825753 作为目标链选择器,以及 true 设置为允许。每个链选择器都可以在 支持的网络页面 上找到。

  3. 以太坊 Sepolia 上部署您的接收者合约,并启用从您的发送者合约接收消息:

    1. 打开 MetaMask 并选择网络 以太坊 Sepolia

    2. 在 Remix IDE 中,转到 Deploy & Run Transactions,确保环境仍然是 Injected Provider - MetaMask

    3. 填写您的区块链路由器和 LINK 合约地址。可以在 支持的网络页面 上找到路由器地址,在 LINK 代币合约页面 上找到 LINK 合约地址。对于以太坊 Sepolia,路由器地址是 0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59,LINK 合约地址是 0x779877A7B0D9E8603169DdbD7836e478b4624789

    4. 点击 transact 按钮。确认交易后,合约地址将出现在 已部署合约列表 中。记下您的合约地址。

    5. 启用您的合约以接收来自 Avalanche Fuji 的 CCIP 消息::

      1. 在 Remix IDE 中,转到 Deploy & Run Transactions,打开您部署在 以太坊 Sepolia 上的智能合约的函数列表。

      2. 调用 allowlistSourceChain,传入 14767482510784806043 作为源链选择器,以及 true 设置为允许。每个链选择器都可以在 支持的网络页面 上找到。

    6. 启用您的合约以接收来自您部署在 Avalanche Fuji 上的合约的 CCIP 消息:

      1. 在 Remix IDE 中,转到 Deploy & Run Transactions,打开您部署在 以太坊 Sepolia 上的智能合约的函数列表。

      2. 调用 allowlistSender,传入您部署在 Avalanche Fuji 上的合约地址,并设置为 true

    7. 调用 setSimRevert 函数,传入 true 作为参数,然后等待交易确认。将 s_simRevert 设置为 true 会在处理接收到的消息时模拟失败。更多详情请参阅解释部分

此时,您在 Avalanche Fuji 上有一个发送者合约,在 以太坊 Sepolia 上有一个接收者合约。作为安全措施,您已启用发送者合约向 以太坊 Sepolia 发送 CCIP 消息,以及接收者合约接收来自 Avalanche Fuji 上发送者的 CCIP 消息。接收者合约无法处理消息,因此,它不会抛出异常,而是会锁定接收到的代币,使所有者能够恢复它们。

注意:另一项安全措施强制只有路由器可以调用 _ccipReceive 函数。更多详情请参阅解释部分

恢复被锁定的代币

转移 0.001 CCIP-BnM 和一些文本。使用 CCIP 的 CCIP 费用将以 LINK 支付。

  1. 打开 MetaMask 并连接到 Avalanche Fuji。用 LINK 代币为您的合约注资。您可以向您的合约转账 0.5 LINK。在此示例中,LINK 用于支付 CCIP 费用。

  2. Avalanche Fuji 发送带有代币的字符串数据::

    1. 打开 MetaMask 并选择网络 Avalanche Fuji.

    2. 在 Remix IDE 中,转到 Deploy & Run Transactions,打开您部署在 Avalanche Fuji 上的智能合约的函数列表。

    3. 填写 sendMessagePayLINK 函数的参数:

    4. 点击 transact 并在 MetaMask 上确认交易。

    5. 交易成功后,请记录交易哈希。以下是在Avalanche Fuji上进行交易的一个示例

注意

在gas价格飙升期间,您的交易可能会失败,需要超过 0.5 LINK 的gas费才能继续。如果您的交易失败,请向您的合约中注入更多的 LINK 代币,并尝试重新提交交易。

  1. 打开CCIP浏览器,通过交易哈希搜索你的跨链交易。

  1. CCIP交易在状态被标记为“Success”时完成。在这个例子中,CCIP消息ID是0x120367995ef71f83d64a05bd7793862afda9d04049da4cb32851934490d03ae4

  1. 检查目标链上的接收方合约:

    1. 打开 MetaMask 并选择 Ethereum Sepolia 网络。

    2. 在 Remix IDE 中,在“部署与运行交易”(Deploy & Run Transactions)部分,打开您在以太坊 Sepolia 上部署的智能合约的函数列表。

    3. 调用 getFailedMessages 函数,使用偏移量(offset)为 0 和限制(limit)为 1,以检索第一个失败的消息。

    4. 请注意返回的值有:0x120367995ef71f83d64a05bd7793862afda9d04049da4cb32851934490d03ae4(消息ID)和 1(表示失败的错误代码)。

  2. 为了恢复被锁定的代币,请调用 retryFailedMessage 函数:

  1. 确认交易后,您可以在区块链浏览器中打开它。请注意,被锁定的资金已转移到 tokenReceiver 地址。

  2. 再次调用 getFailedMessages 函数,使用偏移量(offset)为 0 和限制(limit)为 1,以检索第一个失败的消息。请注意,现在错误代码是 0,表示该消息已经解决。

注意:这些示例合约被设计为可以双向工作。作为练习,您可以使用它们将带有数据的代币从Avalanche Fuji转移到以太坊Sepolia,也可以从以太坊Sepolia转回Avalanche Fuji

说明

本教程中展示的智能合约旨在与CCIP交互,以传输和接收代币和数据。该合约代码与 传输代币及数据教程 中的代码相似,因此,您可以参阅 其代码解释。我们只将解释主要的不同之处。

发送消息

sendMessagePayLINK 函数与 传输代币及数据 教程中的 sendMessagePayLINK 函数类似。主要的区别在于增加了gas限制,以适应处理错误逻辑所需的额外gas。

接收和处理消息

当目标区块链接收到消息时,CCIP路由器会调用ccipReceive函数。此函数作为合约处理传入CCIP消息的入口点,通过onlyRouteronlyAllowlisted修饰符实施关键的安全检查。

以下是该过程的逐步分解:

  1. 通过ccipReceive进入:

    • ccipReceive函数被调用,传入一个包含待处理消息的Any2EVMMessage结构体。

    • 安全检查确保调用来自授权的路由器、已允许的源链和已允许的发送者。

  2. 处理消息:

    • ccipReceive调用processMessage函数,该函数是外部的,以利用Solidity的try/catch错误处理机制。注意:onlySelf修饰符确保只有合约本身可以调用此函数。

    • processMessage内部,使用s_simRevert状态变量检查是否模拟回滚条件。这个模拟由合约所有者调用的setSimRevert函数来切换。 如

    • s_simRevert为false,processMessage调用_ccipReceive函数进行进一步的消息处理。

  3. _ccipReceive中处理消息:

    • _ccipReceive从消息中提取并存储各种信息,如messageId、解码后的发送者地址、代币数量和数据。

    • 然后发出一个MessageReceived事件,表示消息处理成功。

  4. 错误处理:

    • 如果处理过程中发生错误(或触发模拟回滚),ccipReceive内的catch块将被执行。

    • 失败消息的messageId被添加到s_failedMessages中,消息内容被存储在s_messageContents中。

    • 发出一个MessageFailed事件,允许以后识别和重处理失败的消息。

重新处理失败的消息

retryFailedMessage 函数提供了一种机制,用于在 CCIP 消息处理失败时恢复资产。它专门设计用于处理那些消息数据问题导致整个处理过程受阻,但仍然允许代币恢复的场景:

  1. 初始化:

    • 只有合约所有者可以调用此函数,提供失败消息的 messageId 和用于代币恢复的 tokenReceiver 地址。

  2. 验证:

    • 它使用 s_failedMessages.get(messageId) 检查消息是否已失败。如果没有,它将回滚交易。

  3. 状态更新:

    • 将消息的错误代码更新为 RESOLVED,以防止重复条目和多次重试。

  4. 代币恢复:

    • 使用 s_messageContents[messageId] 检索失败的消息内容。

    • 将与失败消息关联的锁定代币转移到指定的 tokenReceiver,作为一个逃生舱口,而无需再次处理整个消息。

  5. 事件发出:

    • 发出一个 MessageRecovered 事件,用以标示代币恢复操作已成功完成。

这个函数展示了一个优雅的资产恢复解决方案,即使在消息处理遇到问题时也能保护用户的价值。

Last updated