本文将指导你如何使用 Chainlink 跨链互操作协议(CCIP)在不同区块链的智能合约之间转移代币和任意数据。你将学习两种支付 CCIP 费用的方式:使用 LINK 代币或原生 gas 代币(如以太坊上的 ETH 或 Avalanche 上的 AVAX)。通过本教程,你将掌握构建跨链应用的核心技能。
准备工作
在开始之前,请确保你已完成以下准备工作:
- 掌握智能合约基础:了解如何编写、编译、部署和注资智能合约。建议先学习 Solidity 编程语言,熟悉 MetaMask 钱包和 Remix 开发环境。
- 获取测试网代币:确保在 Avalanche Fuji 测试网上拥有 AVAX 和 LINK 代币,在 Ethereum Sepolia 测试网上拥有 ETH 代币。可通过官方水龙头获取测试代币。
- 确认代币支持:查阅 CCIP 目录 确认目标链支持所需代币。例如,从 Avalanche Fuji 向 Ethereum Sepolia 转移代币时,需检查支持代币列表。
- 获取 CCIP 测试代币:按照指南 mint CCIP-BnM 测试代币,并确保 MetaMask 中显示该代币。
- 合约注资:学习如何为合约注资。本教程将展示如何使用 LINK 或任何 ERC20 代币注资合约。
- 完成前置教程:建议先完成 代币转移基础教程 以熟悉基本流程。
教程概述
本教程将指导你在 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 部署发送方合约
- 打开 Remix IDE:访问 Remix Ethereum 并加载上述合约代码。
- 编译合约:在 Solidity 编译器中编译合约。
配置部署环境:
- 在 MetaMask 中选择 Avalanche Fuji 网络
- 在 Remix 的"Deploy & Run Transactions"中选择"Injected Provider - MetaMask"
填写路由器和 LINK 合约地址(可从 CCIP 目录 获取):
- 路由器地址:
0xF694E193200268f9a4868e4Aa017A0118C9a8177 - LINK 合约地址:
0x0b9d5D9136855f6FEc3c0993feE6E9CE8a297846
- 路由器地址:
- 部署合约:点击"transact"按钮并确认交易,记录合约地址。
- 注资合约:向合约转账 0.002 CCIP-BnM 代币。
- 启用目标链:调用
allowlistDestinationChain函数,设置目标链选择器为16015286601757825753(Ethereum Sepolia)并设为true。
在 Ethereum Sepolia 部署接收方合约
- 切换网络:在 MetaMask 中切换到 Ethereum Sepolia 网络。
部署合约:使用相同的步骤部署合约,但使用 Sepolia 的地址:
- 路由器地址:
0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59 - LINK 合约地址:
0x779877A7B0D9E8603169DdbD7836e478b4624789
- 路由器地址:
- 启用源链:调用
allowlistSourceChain函数,设置源链选择器为14767482510784806043(Avalanche Fuji)并设为true。 - 启用发送方:调用
allowlistSender函数,添加在 Avalanche Fuji 部署的发送方合约地址。
至此,你已在 Avalanche Fuji 部署发送方合约,在 Ethereum Sepolia 部署接收方合约,并设置了必要的安全权限。
使用 LINK 支付费用转移代币和数据
本节将指导你如何使用 LINK 支付 CCIP 费用,转移 0.001 CCIP-BnM 代币和文本数据。
操作步骤
- 注资合约:向 Avalanche Fuji 上的发送方合约转账 70 LINK 用于支付费用。
发送消息:
- 在 MetaMask 中选择 Avalanche Fuji 网络
在 Remix 中调用发送方合约的
sendMessagePayLINK函数,填写以下参数:_destinationChainSelector:16015286601757825753(Ethereum Sepolia)_receiver: 你在 Sepolia 上的接收方合约地址_text:Hello World!_token:0xD21341536c5cF5EB1bcb58f6723cE26e8D8E90e4(Fuji 上的 CCIP-BnM)_amount:1000000000000000(0.001 代币)
- 确认交易:点击"transact"并在 MetaMask 中确认交易,记录交易哈希。
- 查看跨链交易:在 CCIP 浏览器 中输入交易哈希查看状态。
- 验证接收:当状态显示"Success"后,在 Sepolia 网络上调用接收方合约的
getLastReceivedMessageDetails函数查看接收详情。
使用原生代币支付费用转移代币和数据
本节将指导你如何使用原生 AVAX 代币支付 CCIP 费用,完成相同的跨链转移。
操作步骤
- 注资合约:向 Avalanche Fuji 上的发送方合约转账 0.2 AVAX 用于支付费用。
发送消息:
- 在 MetaMask 中选择 Avalanche Fuji 网络
- 在 Remix 中调用发送方合约的
sendMessagePayNative函数,填写与之前相同的参数
- 确认交易:点击"transact"并在 MetaMask 中确认交易,记录交易哈希。
- 查看跨链交易:在 CCIP 浏览器 中查看交易状态。
- 验证接收:交易成功后,在 Sepolia 上检查接收方合约的接收详情。
技术原理详解
合约初始化
部署合约时需要指定路由器地址和 LINK 合约地址,这是与 CCIP 网络交互的基础。路由器负责费用估算、消息发送和消息路由,而 LINK 合约用于费用支付(当选择 LINK 支付时)。
使用 LINK 支付费用的技术细节
sendMessagePayLINK 函数执行以下关键操作:
构建 CCIP 消息:使用
_buildCCIPMessage私有函数创建结构化消息,包括:- 接收方地址(ABI 编码)
- 文本数据(ABI 编码)
- 代币地址和数量数组
- 额外参数(gas 限制和乱序执行允许)
- 费用代币地址(设置为 LINK 合约地址)
- 计算费用:调用路由器的
getFee方法估算跨链费用。 - 余额检查:确认合约有足够的 LINK 余额支付费用。
- 授权操作:批准路由器从合约中扣除 LINK 费用和转移代币。
- 发送消息:调用路由器的
ccipSend方法发起跨链转移。
使用原生代币支付费用的技术细节
sendMessagePayNative 函数与上述流程类似,但有以下区别:
- 费用代币地址设置为
address(0),表示使用原生代币支付 - 检查原生代币余额而非 LINK 余额
- 在调用
ccipSend时通过value: fees传递原生代币费用
消息接收机制
在目标链上,路由器调用接收方合约的 _ccipReceive 函数,该函数:
- 验证源链和发送方是否在白名单中
- 解码接收到的消息和代币信息
- 更新最后接收消息的状态变量
- 发出接收事件
安全机制确保只有路由器可以交付消息,并且只接受来自白名单源链和发送方的消息。
常见问题
CCIP 支持哪些区块链?
CCIP 支持多条主流区块链,包括 Ethereum、Avalanche、Polygon、Arbitrum、Optimism 等。具体支持情况可查阅 CCIP 目录 获取最新信息。
如何估算跨链转移费用?
费用取决于目标链、数据大小和当前网络条件。可以使用路由器的 getFee 方法预先估算费用,也可以在 CCIP Explorer 中查看历史交易费用作为参考。
为什么需要设置白名单?
白名单是一种重要的安全措施,防止未经授权的跨链交互。通过限制可通信的链和合约,可以有效减少潜在攻击面,提高系统安全性。
如何处理跨链交易失败?
如果跨链交易失败,CCIP 提供了多种处理机制。取决于失败原因,可能会自动重试、部分退款或需要手动干预。建议监控交易状态并及时处理异常情况。
CCIP 与传统桥接有何不同?
CCIP 提供了更高级的功能组合,包括任意数据传输、可编程逻辑和增强的安全性。与传统桥接相比,CCIP 支持更复杂的跨链交互模式,而不仅仅是简单的资产转移。
如何监控跨链交易状态?
可以使用 CCIP Explorer 通过消息ID或交易哈希跟踪交易状态。Explorer 提供详细的状态信息和错误报告,帮助开发者监控和调试跨链交易。