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

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

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

开始之前

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

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

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

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

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

  6. 按照前一个教程:使用 数据传输代币arrow-up-right 来学习如何使用 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 合约地址。可以在 支持的网络页面arrow-up-right 上找到路由器地址,在 LINK 代币合约arrow-up-right页面 arrow-up-right上找到 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 设置为允许。每个链选择器都可以在 支持的网络页面arrow-up-right 上找到。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

恢复被锁定的代币

转移 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 函数的参数:

      参数
      值和描述

      _destinationChainSelector

      16015286601757825753 这是目标区块链的CCIP链标识符(在这个例子中是 以太坊 Sepolia)。您可以在 支持的网络页面arrow-up-right 上找到每个链选择器。

      _receiver

      您在 以太坊 Sepolia 上的接收者合约地址。 目标合约地址。

      _text

      Hello World!Copy to clipboard 任何 string

      _token

      0xD21341536c5cF5EB1bcb58f6723cE26e8D8E90e4 这是源链(本例中为 Avalanche Fuji)上的 CCIP-BnM 合约地址。您可以在 支持的网络页面arrow-up-right 上找到每个支持的区块链的所有地址。

      _amount

      1000000000000000Copy to clipboard 代币数量(对应0.001 CCIP-BnM)。

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

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

注意

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

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

  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 函数:

参数
描述

messageId

失败消息的唯一标识符。

tokenReceiver

代币将被发送到的地址。

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

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

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

说明

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

发送消息

sendMessagePayLINK 函数与 传输代币及数据arrow-up-right 教程中的 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