使用 Chainlink CCIP 实现跨链数据传输与代币转移教程

·

本文将指导你如何使用 Chainlink 跨链互操作协议(CCIP)在不同区块链的智能合约之间转移代币和任意数据。你将学习两种支付 CCIP 费用的方式:使用 LINK 代币或原生 gas 代币(如以太坊上的 ETH 或 Avalanche 上的 AVAX)。通过本教程,你将掌握构建跨链应用的核心技能。

准备工作

在开始之前,请确保你已完成以下准备工作:

教程概述

本教程将指导你在 Avalanche Fuji 和 Ethereum Sepolia 测试网之间使用 CCIP 发送文本字符串和 CCIP-BnM 代币。你将先后体验使用 LINK 和原生代币支付 CCIP 费用的完整流程。

智能合约代码

以下是用于可编程代币转移的智能合约代码。该合约继承自 CCIPReceiver 和 OwnerIsCreator,支持跨链消息发送与接收:

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

contract ProgrammableTokenTransfers is CCIPReceiver, OwnerIsCreator {
    using SafeERC20 for IERC20;
    
    // 自定义错误与事件声明
    error NotEnoughBalance(uint256 currentBalance, uint256 calculatedFees);
    error NothingToWithdraw();
    error FailedToWithdrawEth(address owner, address target, uint256 value);
    error DestinationChainNotAllowed(uint64 destinationChainSelector);
    error SourceChainNotAllowed(uint64 sourceChainSelector);
    error SenderNotAllowed(address sender);
    error InvalidReceiverAddress();
    
    event MessageSent(
        bytes32 indexed messageId,
        uint64 indexed destinationChainSelector,
        address receiver,
        string text,
        address token,
        uint256 tokenAmount,
        address feeToken,
        uint256 fees
    );
    
    event MessageReceived(
        bytes32 indexed messageId,
        uint64 indexed sourceChainSelector,
        address sender,
        string text,
        address token,
        uint256 tokenAmount
    );
    
    // 状态变量与映射
    bytes32 private s_lastReceivedMessageId;
    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;

    constructor(address _router, address _link) CCIPReceiver(_router) {
        s_linkToken = IERC20(_link);
    }
    
    // 修饰器与权限管理函数
    modifier onlyAllowlistedDestinationChain(uint64 _destinationChainSelector) {
        if (!allowlistedDestinationChains[_destinationChainSelector])
            revert DestinationChainNotAllowed(_destinationChainSelector);
        _;
    }
    
    modifier validateReceiver(address _receiver) {
        if (_receiver == address(0)) revert InvalidReceiverAddress();
        _;
    }
    
    modifier onlyAllowlisted(uint64 _sourceChainSelector, address _sender) {
        if (!allowlistedSourceChains[_sourceChainSelector])
            revert SourceChainNotAllowed(_sourceChainSelector);
        if (!allowlistedSenders[_sender]) revert SenderNotAllowed(_sender);
        _;
    }
    
    function allowlistDestinationChain(uint64 _destinationChainSelector, bool allowed) external onlyOwner {
        allowlistedDestinationChains[_destinationChainSelector] = allowed;
    }
    
    function allowlistSourceChain(uint64 _sourceChainSelector, bool allowed) external onlyOwner {
        allowlistedSourceChains[_sourceChainSelector] = allowed;
    }
    
    function allowlistSender(address _sender, bool allowed) external onlyOwner {
        allowlistedSenders[_sender] = allowed;
    }
    
    // 使用LINK支付费用的发送函数
    function sendMessagePayLINK(
        uint64 _destinationChainSelector,
        address _receiver,
        string calldata _text,
        address _token,
        uint256 _amount
    ) external onlyOwner onlyAllowlistedDestinationChain(_destinationChainSelector) validateReceiver(_receiver) returns (bytes32 messageId) {
        Client.EVM2AnyMessage memory evm2AnyMessage = _buildCCIPMessage(_receiver, _text, _token, _amount, address(s_linkToken));
        IRouterClient router = IRouterClient(this.getRouter());
        uint256 fees = router.getFee(_destinationChainSelector, evm2AnyMessage);
        
        if (fees > s_linkToken.balanceOf(address(this)))
            revert NotEnoughBalance(s_linkToken.balanceOf(address(this)), fees);
        
        s_linkToken.approve(address(router), fees);
        IERC20(_token).approve(address(router), _amount);
        
        messageId = router.ccipSend(_destinationChainSelector, evm2AnyMessage);
        
        emit MessageSent(messageId, _destinationChainSelector, _receiver, _text, _token, _amount, address(s_linkToken), fees);
        return messageId;
    }
    
    // 使用原生代币支付费用的发送函数
    function sendMessagePayNative(
        uint64 _destinationChainSelector,
        address _receiver,
        string calldata _text,
        address _token,
        uint256 _amount
    ) external onlyOwner onlyAllowlistedDestinationChain(_destinationChainSelector) validateReceiver(_receiver) returns (bytes32 messageId) {
        Client.EVM2AnyMessage memory evm2AnyMessage = _buildCCIPMessage(_receiver, _text, _token, _amount, address(0));
        IRouterClient router = IRouterClient(this.getRouter());
        uint256 fees = router.getFee(_destinationChainSelector, evm2AnyMessage);
        
        if (fees > address(this).balance)
            revert NotEnoughBalance(address(this).balance, fees);
        
        IERC20(_token).approve(address(router), _amount);
        
        messageId = router.ccipSend{value: fees}(_destinationChainSelector, evm2AnyMessage);
        
        emit MessageSent(messageId, _destinationChainSelector, _receiver, _text, _token, _amount, address(0), fees);
        return messageId;
    }
    
    // 消息接收处理
    function _ccipReceive(Client.Any2EVMMessage memory any2EvmMessage) internal override onlyAllowlisted(any2EvmMessage.sourceChainSelector, abi.decode(any2EvmMessage.sender, (address))) {
        s_lastReceivedMessageId = any2EvmMessage.messageId;
        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
        );
    }
    
    // 构建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);
        tokenAmounts[0] = Client.EVMTokenAmount({token: _token, amount: _amount});
        
        return Client.EVM2AnyMessage({
            receiver: abi.encode(_receiver),
            data: abi.encode(_text),
            tokenAmounts: tokenAmounts,
            extraArgs: Client._argsToBytes(Client.GenericExtraArgsV2({gasLimit: 200_000, allowOutOfOrderExecution: true})),
            feeToken: _feeTokenAddress
        });
    }
    
    // 资金提取功能
    receive() external payable {}
    
    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);
    }
    
    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);
    }
    
    // 获取最后接收消息详情
    function getLastReceivedMessageDetails() public view returns (bytes32 messageId, string memory text, address tokenAddress, uint256 tokenAmount) {
        return (s_lastReceivedMessageId, s_lastReceivedText, s_lastReceivedTokenAddress, s_lastReceivedTokenAmount);
    }
}

部署智能合约

在 Avalanche Fuji 部署发送方合约

  1. 打开 Remix IDE:访问 Remix Ethereum 并加载上述合约代码。
  2. 编译合约:在 Solidity 编译器中编译合约。
  3. 配置部署环境

    • 在 MetaMask 中选择 Avalanche Fuji 网络
    • 在 Remix 的"Deploy & Run Transactions"中选择"Injected Provider - MetaMask"
    • 填写路由器和 LINK 合约地址(可从 CCIP 目录 获取):

      • 路由器地址:0xF694E193200268f9a4868e4Aa017A0118C9a8177
      • LINK 合约地址:0x0b9d5D9136855f6FEc3c0993feE6E9CE8a297846
  4. 部署合约:点击"transact"按钮并确认交易,记录合约地址。
  5. 注资合约:向合约转账 0.002 CCIP-BnM 代币。
  6. 启用目标链:调用 allowlistDestinationChain 函数,设置目标链选择器为 16015286601757825753(Ethereum Sepolia)并设为 true

在 Ethereum Sepolia 部署接收方合约

  1. 切换网络:在 MetaMask 中切换到 Ethereum Sepolia 网络。
  2. 部署合约:使用相同的步骤部署合约,但使用 Sepolia 的地址:

    • 路由器地址:0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59
    • LINK 合约地址:0x779877A7B0D9E8603169DdbD7836e478b4624789
  3. 启用源链:调用 allowlistSourceChain 函数,设置源链选择器为 14767482510784806043(Avalanche Fuji)并设为 true
  4. 启用发送方:调用 allowlistSender 函数,添加在 Avalanche Fuji 部署的发送方合约地址。

至此,你已在 Avalanche Fuji 部署发送方合约,在 Ethereum Sepolia 部署接收方合约,并设置了必要的安全权限。

👉 查看实时跨链交易工具

使用 LINK 支付费用转移代币和数据

本节将指导你如何使用 LINK 支付 CCIP 费用,转移 0.001 CCIP-BnM 代币和文本数据。

操作步骤

  1. 注资合约:向 Avalanche Fuji 上的发送方合约转账 70 LINK 用于支付费用。
  2. 发送消息

    • 在 MetaMask 中选择 Avalanche Fuji 网络
    • 在 Remix 中调用发送方合约的 sendMessagePayLINK 函数,填写以下参数:

      • _destinationChainSelector: 16015286601757825753(Ethereum Sepolia)
      • _receiver: 你在 Sepolia 上的接收方合约地址
      • _text: Hello World!
      • _token: 0xD21341536c5cF5EB1bcb58f6723cE26e8D8E90e4(Fuji 上的 CCIP-BnM)
      • _amount: 1000000000000000(0.001 代币)
  3. 确认交易:点击"transact"并在 MetaMask 中确认交易,记录交易哈希。
  4. 查看跨链交易:在 CCIP 浏览器 中输入交易哈希查看状态。
  5. 验证接收:当状态显示"Success"后,在 Sepolia 网络上调用接收方合约的 getLastReceivedMessageDetails 函数查看接收详情。

使用原生代币支付费用转移代币和数据

本节将指导你如何使用原生 AVAX 代币支付 CCIP 费用,完成相同的跨链转移。

操作步骤

  1. 注资合约:向 Avalanche Fuji 上的发送方合约转账 0.2 AVAX 用于支付费用。
  2. 发送消息

    • 在 MetaMask 中选择 Avalanche Fuji 网络
    • 在 Remix 中调用发送方合约的 sendMessagePayNative 函数,填写与之前相同的参数
  3. 确认交易:点击"transact"并在 MetaMask 中确认交易,记录交易哈希。
  4. 查看跨链交易:在 CCIP 浏览器 中查看交易状态。
  5. 验证接收:交易成功后,在 Sepolia 上检查接收方合约的接收详情。

技术原理详解

合约初始化

部署合约时需要指定路由器地址和 LINK 合约地址,这是与 CCIP 网络交互的基础。路由器负责费用估算、消息发送和消息路由,而 LINK 合约用于费用支付(当选择 LINK 支付时)。

使用 LINK 支付费用的技术细节

sendMessagePayLINK 函数执行以下关键操作:

  1. 构建 CCIP 消息:使用 _buildCCIPMessage 私有函数创建结构化消息,包括:

    • 接收方地址(ABI 编码)
    • 文本数据(ABI 编码)
    • 代币地址和数量数组
    • 额外参数(gas 限制和乱序执行允许)
    • 费用代币地址(设置为 LINK 合约地址)
  2. 计算费用:调用路由器的 getFee 方法估算跨链费用。
  3. 余额检查:确认合约有足够的 LINK 余额支付费用。
  4. 授权操作:批准路由器从合约中扣除 LINK 费用和转移代币。
  5. 发送消息:调用路由器的 ccipSend 方法发起跨链转移。

使用原生代币支付费用的技术细节

sendMessagePayNative 函数与上述流程类似,但有以下区别:

消息接收机制

在目标链上,路由器调用接收方合约的 _ccipReceive 函数,该函数:

安全机制确保只有路由器可以交付消息,并且只接受来自白名单源链和发送方的消息。

👉 获取进阶跨链开发方法

常见问题

CCIP 支持哪些区块链?

CCIP 支持多条主流区块链,包括 Ethereum、Avalanche、Polygon、Arbitrum、Optimism 等。具体支持情况可查阅 CCIP 目录 获取最新信息。

如何估算跨链转移费用?

费用取决于目标链、数据大小和当前网络条件。可以使用路由器的 getFee 方法预先估算费用,也可以在 CCIP Explorer 中查看历史交易费用作为参考。

为什么需要设置白名单?

白名单是一种重要的安全措施,防止未经授权的跨链交互。通过限制可通信的链和合约,可以有效减少潜在攻击面,提高系统安全性。

如何处理跨链交易失败?

如果跨链交易失败,CCIP 提供了多种处理机制。取决于失败原因,可能会自动重试、部分退款或需要手动干预。建议监控交易状态并及时处理异常情况。

CCIP 与传统桥接有何不同?

CCIP 提供了更高级的功能组合,包括任意数据传输、可编程逻辑和增强的安全性。与传统桥接相比,CCIP 支持更复杂的跨链交互模式,而不仅仅是简单的资产转移。

如何监控跨链交易状态?

可以使用 CCIP Explorer 通过消息ID或交易哈希跟踪交易状态。Explorer 提供详细的状态信息和错误报告,帮助开发者监控和调试跨链交易。