浪迹陨灭nd
- 关注

ccip主要组件
三个领域
源链 (Source Chain) ├── Sender (发送方合约) ├── Router (路由合约) └── Token Pool (代币池) 目标链 (Destination Chain) ├── Receiver (接收方合约) ├── Router (路由合约) └── Token Pool (代币池) 链下部分 (Offchain) ├── Committing DON (提交DON网络) ├── Executing DON (执行DON网络) └── RMN (风险管理网络)
工作流程
概括CCIP的三个部分的完整流程:
源链(Source Chain)
用户 → Sender合约 → Router → OnRamp → Token Pool
具体流程:
- 用户调用Sender合约发起跨链请求
- Router验证请求并计算费用
- OnRamp准备跨链数据
- Token Pool锁定用户代币
- 生成消息证明并等待DON处理
- 链下(Off-chain)
链下(Off-chain)
Committing DON → RMN → Executing DON
具体流程:
- Committing DON监听并验证源链消息
- 构建merkle树和收集DON签名
- RMN进行风险评估和安全检查
- 通过后,Executing DON准备目标链执行数据
- 将执行包发送到目标链
- 目标链(Destination Chain)
目标链(Destination Chain)
OffRamp → Token Pool → Router → 接收方合约
具体流程:
- OffRamp接收并验证DON发来的执行包
- Token Pool释放对应代币给接收方
- Router将消息路由到接收方合约
- 接收方合约执行最终的业务逻辑
源链
发起跨链请求:
用户使用sender合约,指定目标链,指定接收地址,准备要发送的代币,附带自定义消息数据
// 1. Sender发起跨链请求 contract Sender { function sendMessage( uint64 destinationChainSelector, address receiver, bytes memory data, TokenTransfer[] memory tokens ) external { // 构造CCIP消息 Message memory message = Message({ sender: msg.sender, receiver: receiver, data: data, tokens: tokens }); // 计算费用 uint256 fee = router.getFee(destinationChainSelector, message); // 调用Router router.ccipSend{value: fee}(destinationChainSelector, message); } }
Router合约源链路由器,验证消息格式,计算费用,处理代币锁定
// 2. Router验证并转发到OnRamp contract Router { function ccipSend(uint64 chainSelector, Message memory message) external { // 验证目标链 validateDestination(chainSelector); // 获取对应的OnRamp OnRamp onRamp = getOnRamp(chainSelector); // 转发到OnRamp onRamp.forwardMessage(message); } }
OnRamp合约处理(源链):接收Router的请求,验证消息格式和费用,与Token Pool交互锁定代币,生成跨链消息的证明
// 3. OnRamp处理并与Token Pool交互 contract OnRamp { function forwardMessage(Message memory message) external { // 验证消息格式 validateMessage(message); // 处理代币锁定 for (TokenTransfer token : message.tokens) { tokenPool.lockTokens( token.token, token.amount, message.sender ); } // 生成merkle叶子 bytes32 leaf = generateLeaf(message); // 提交到Commit Store commitStore.addMessage(leaf); // 触发事件供DON监听 emit MessageSent(message); } }
Token Pool: 代币池锁定源链代币,管理流动性
详细交互流程:
- 第一阶段:源链发起
- 用户通过Sender合约发起跨链请求
- Router接收请求并验证基本参数
- Fee Manager计算所需费用
- Price Registry检查代币价格
- ARM进行初步风险评估
- OnRamp准备跨链数据
- Token Pool锁定相应代币
- 生成merkle叶子并提交到Commit Store
链下
Committing DON 监听和处理
class CommittingDON { // 监听源链事件 async listenToSourceChain() { sourceChain.on('MessageSent', async (event) => { const message = event.args.message; await this.processMessage(message); }); } // 处理跨链消息 async processMessage(message) { // 验证消息 await this.validateMessage(message); // 构建 merkle 树 const leaf = this.generateLeaf(message); const merkleTree = this.buildMerkleTree([leaf]); // 收集 DON 签名 const signatures = await this.collectSignatures(merkleTree.root); // 提交到 RMN await this.submitToRMN(message, merkleTree, signatures); } // 生成 merkle 叶子 generateLeaf(message) { return ethers.utils.keccak256( ethers.utils.defaultAbiCoder.encode( ['address', 'address', 'bytes', 'uint256'], [message.sender, message.receiver, message.data, message.nonce] ) ); } }
监听和收集:
- 监听源链上的MessageSent事件
- 收集交易详情和证明
- 验证交易状态
验证流程:
- 验证消息格式
- 检查签名有效性
- 验证代币锁定状态
- 确认费用支付
数据整理:
- 构建merkle树
- 生成merkle证明
- 准跨链数据包
Lane Manager(通道管理)
class LaneManager { constructor() { this.lanes = new Map(); this.limits = new Map(); } // 检查通道状态 async checkLane(sourceChain, destChain) { const lane = this.getLane(sourceChain, destChain); // 检查通道容量 if (lane.messageCount >= lane.maxCapacity) { throw new Error('Lane capacity exceeded'); } // 检查限额 if (lane.totalValue >= this.limits.get(lane.id)) { throw new Error('Lane value limit exceeded'); } // 更新统计 await this.updateLaneStats(lane); } // 更新通道统计 async updateLaneStats(lane) { lane.messageCount++; lane.lastUpdated = Date.now(); await this.persistLaneData(lane); } }
通道状态管理:
- 检查源链-目标链通道状态
- 验证通道容量和限额
- 监控通道拥堵情况
- 更新通道统计数据
流量控制:
- 管理消息队列
- 控制处理速率
- 优化资源分配
- 负载均衡
Price Feed(价格预言机):
class PriceFeed { // 获取实时价格 async getPrice(token) { // 从多个源获取价格 const prices = await Promise.all([ this.getPriceFromSource1(token), this.getPriceFromSource2(token), this.getPriceFromSource3(token) ]); // 过滤和计算加权价格 return this.calculateWeightedPrice(prices); } // 价格偏差检查 async checkPriceDeviation(token, price) { const historicalPrice = await this.getHistoricalPrice(token); const deviation = Math.abs(price - historicalPrice) / historicalPrice; if (deviation > this.maxDeviation) { await this.triggerPriceProtection(token, price); } } }
价格数据服务:
- 收集多源价格数据
- 过滤异常价格
- 计算加权平均价
- 提供实时价格更新
价格保护机制:
- 监控价格波动
- 设置价格偏差阈值
- 触发价格保护
- 更新价格预警
Risk Management Network (RMN):
class RiskManagementNetwork { // 风险评估 async assessRisk(message, context) { const riskScore = await this.calculateRiskScore({ // 交易相关风险 transactionRisk: await this.assessTransactionRisk(message), // 地址风险 addressRisk: await this.assessAddressRisk(message.sender), // 网络风险 networkRisk: await this.assessNetworkRisk(context), // 代币风险 tokenRisk: await this.assessTokenRisk(message.tokens) }); return this.evaluateRiskScore(riskScore); } // 流动性检查 async checkLiquidity(message) { const liquidityData = await this.getLiquidityData(message.destChain); return { isLiquidityOk: liquidityData.available >= message.value, liquidityRatio: liquidityData.available / liquidityData.total }; } }
风险评估:
- 交易规模分析
- 地址行为评估
- 网络状态监控
- 代币风险评级
安全检查:
- 重放攻击检测
- 异常行为识别
- 流动性风险评估
- 网络安全监控
流动性管理:
- 检查目标链流动性
- 评估Token Pool状态
- 监控资金流向
- 预警异常提现
Executing DON:
class ExecutingDON { // 准备执行 async prepareExecution(message, proof) { // 验证所有条件 await this.validateExecutionConditions(message); // 准备执行数据 const executionData = await this.prepareExecutionData(message); // 收集执行签名 const signatures = await this.collectExecutionSignatures(executionData); return { executionData, signatures }; } // 执行共识 async reachConsensus(executionData) { const nodes = await this.getActiveNodes(); const votes = await this.collectNodeVotes(nodes, executionData); if (this.hasConsensus(votes)) { return this.prepareConsensusProof(votes); } throw new Error('Consensus not reached'); } }
执行准备:
- 接收RMN确认信息
- 验证所有必要条件
- 准备执行数据包
- 构建执行证明
共识过程:
- DON节点投票
- 收集执行签名
- 达成执行共识
- 确认执行决策
执行触发:
- 准备目标链交易
- 构建执行参数
- 发送执行指令
- 监控执行状态
跨链消息传递流程:
消息封装:
- 源链交易信息
- 代币转移数据
- 执行指令
- 证明数据
验证层级:
基础验证
- 格式检查
- 参数验证
- 签名确认
共识验证
- DON节点验证
- 多重签名
- 阈值确认
风险验证
- RMN评估
- 安全检查
- 异常识别
监控和优化系统:
class MonitoringSystem { // 性能监控 async monitorPerformance() { const metrics = { nodeLatency: await this.measureNodeLatency(), messageQueueSize: await this.getQueueSize(), processingTime: await this.getAverageProcessingTime(), resourceUsage: await this.getResourceMetrics() }; await this.analyzeMetrics(metrics); } // 异常检测 async detectAnomalies() { const patterns = await this.analyzePatterns(); if (patterns.hasAnomaly) { await this.triggerAlert(patterns.anomalyType); } } }
性能监控:
- 节点响应时间
- 网络延迟
- 处理队列状态
- 资源使用率
数据分析:
- 交易模式分析
- 风险模型更新
- 性能瓶颈识别
- 优化建议生成
系统调优:
- 动态参数调整
- 资源分配优化
- 处理策略更新
- 性能优化
应急响应机制:
class EmergencyHandler { // 处理异常 async handleEmergency(error) { // 记录错误 await this.logError(error); // 执行应急预案 const plan = await this.selectEmergencyPlan(error); await this.executeEmergencyPlan(plan); // 通知相关方 await this.notifyStakeholders(error, plan); } // 恢复服务 async recoverService() { // 检查系统状态 const status = await this.checkSystemStatus(); // 执行恢复步骤 if (status.needsRecovery) { await this.executeRecoverySteps(status); } // 验证恢复结果 await this.verifyRecovery(); } }
异常处理:
- 检测异常情况
- 触发应急预案
- 执行恢复流程
- 记录事件日志
故障恢复:
- 节点故障切换
- 数据同步修复
- 状态一致性检查
- 服务恢复确认
目标链
OffRamp接收和验证:
这是目标链上的入口合约,负责接收和处理来自链下DON网络的跨链消息。它会验证消息的有效性,包括检查消息证明和DON签名。一旦验证通过,它会协调TokenPool进行代币释放,并通过Router将消息转发给最终的接收方。可以把它理解为跨链消息在目标链上的"报关处",负责验证和清关。
class OffRamp { async processIncomingMessage(message, proof) { // 1. 验证消息和证明 await this.validateMessage(message, proof); // 2. 检查执行条件 const executionContext = { message, proof, timestamp: await this.getBlockTimestamp(), gasPrice: await this.getGasPrice() }; // 3. 准备执行 await this.prepareExecution(executionContext); } async validateMessage(message, proof) { // 验证merkle证明 const isValidProof = await this.verifyMerkleProof( message.leaf, proof.root, proof.path ); // 验证DON签名 const isValidSignature = await this.verifyDONSignatures( message, proof.signatures ); if (!isValidProof || !isValidSignature) { throw new Error('Invalid message or proof'); } } }
TokenPool合约:
这是代币管理合约,管理着目标链上用于跨链的代币流动性池。当OffRamp确认跨链消息有效后,TokenPool负责将对应数量的代币释放给接收方。它还管理流动性提供者的存款和取款,确保池中始终有足够的代币来满足跨链需求。这就像是一个银行金库,负责资金的安全存管和分发。
contract TokenPool { // 代币余额映射 mapping(address => uint256) public poolBalance; mapping(address => uint256) public lockedAmount; // 释放代币给接收方 function releaseTokens( address token, address receiver, uint256 amount ) external onlyOffRamp { require( poolBalance[token] >= amount, "Insufficient liquidity" ); poolBalance[token] -= amount; IERC20(token).transfer(receiver, amount); emit TokensReleased(token, receiver, amount); } // 添加流动性 function addLiquidity( address token, uint256 amount ) external { IERC20(token).transferFrom( msg.sender, address(this), amount ); poolBalance[token] += amount; emit LiquidityAdded(token, amount); } }
Router合约
Router是消息路由合约,负责将验证过的跨链消息传递给正确的接收方合约。它会检查接收方是否是有效的合约地址,并调用接收方的ccipReceive函数。如果消息执行失败,Router还负责处理失败情况。它就像是一个邮递员,确保消息准确送达指定接收方。
contract Router { // 路由表 mapping(address => bool) public whitelistedOffRamps; // 执行消息 function routeMessage( OffRamp.CCIPMessage memory message ) external onlyOffRamp { // 验证接收方合约 require( _isContract(message.receiver), "Receiver must be a contract" ); // 调用接收方的ccipReceive函数 try ICCIPReceiver(message.receiver).ccipReceive( message.sourceChainSelector, message.sender, message.data ) { emit MessageRouted(message.messageId); } catch Error(string memory reason) { emit MessageFailed(message.messageId, reason); _handleFailure(message); } } }
CommitStore合约:
这是消息存储和验证合约,存储了所有经过DON网络确认的消息根。当OffRamp收到跨链消息时,会向CommitStore验证该消息是否已经得到了DON网络的确认。它维护着一个可信消息的数据库,确保只有经过验证的消息才能被执行。这像是一个公证处,负责验证消息的真实性
contract CommitStore { // 存储已确认的消息根 mapping(bytes32 => bool) public committedRoots; // DON签名者 mapping(address => bool) public allowedSigners; // 验证并存储消息证明 function verifyMessage( OffRamp.CCIPMessage memory message, bytes memory proof ) external returns (bool) { bytes32 root = _computeRoot(message); require( committedRoots[root], "Unknown message root" ); require( _verifyProof(message, proof), "Invalid proof" ); return true; } }
接收方合约接口:
这是最终接收和处理跨链消息的合约,需要实现ccipReceive接口。当Router转发消息时,接收方合约会被调用,然后根据收到的消息执行相应的业务逻辑。这可以是任何需要接收跨链消息的智能合约,比如跨链桥、跨链交易所等。
interface ICCIPReceiver { function ccipReceive( uint64 sourceChainSelector, address sender, bytes calldata data ) external; } // 示例实现 contract ExampleReceiver is ICCIPReceiver { // 只允许Router调用 modifier onlyRouter() { require( msg.sender == address(router), "Only router can call" ); _; } function ccipReceive( uint64 sourceChainSelector, address sender, bytes calldata data ) external override onlyRouter { // 解码数据 (uint256 amount, bytes memory payload) = abi.decode( data, (uint256, bytes) ); // 处理业务逻辑 _handleBusinessLogic(sender, amount, payload); } }
权限控制合约:
contract AccessControl { // 角色定义 bytes32 public constant EXECUTOR_ROLE = keccak256("EXECUTOR_ROLE"); bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE"); // 角色管理 mapping(bytes32 => mapping(address => bool)) public roles; modifier onlyRole(bytes32 role) { require( roles[role][msg.sender], "Caller is not authorized" ); _; } }
目标链流程
消息。验证通过后,如果消息包含代币转移,OffRamp会通知TokenPool释放相应的代币。然后OffRamp将消息交给Router,Router负责将消息路由到正确的接收方合约。在这个过程中,CommitStore提供消息验证服务,确保只有经过DON网络确认的消息才会被处理。
跨链NFT
源链发送消息
原始模型
// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import {IRouterClient} from "@chainlink/contracts/src/v0.8/ccip/interfaces/IRouterClient.sol"; import {OwnerIsCreator} from "node_modules/@chainlink/contracts/src/v0.8/shared/access/OwnerIsCreator.sol"; import {Client} from "node_modules/@chainlink/contracts/src/v0.8/ccip/applications/CCIPReceiver.sol"; import {CCIPReceiver} from "@chainlink/contracts/src/v0.8/ccip/applications/CCIPReceiver.sol"; import {IERC20} from "node_modules/@chainlink/contracts/src/v0.8/vendor/openzeppelin-solidity/v4.8.3/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "node_modules/@chainlink/contracts/src/v0.8/vendor/openzeppelin-solidity/v4.8.3/contracts/token/ERC20/utils/SafeERC20.sol"; /** * THIS IS AN EXAMPLE CONTRACT THAT USES HARDCODED VALUES FOR CLARITY. * THIS IS AN EXAMPLE CONTRACT THAT USES UN-AUDITED CODE. * DO NOT USE THIS CODE IN PRODUCTION. */ /// @title - A simple messenger contract for sending/receving string data across chains. contract Messenger is CCIPReceiver, OwnerIsCreator { using SafeERC20 for IERC20; // Custom errors to provide more descriptive revert messages. error NotEnoughBalance(uint256 currentBalance, uint256 calculatedFees); // Used to make sure contract has enough balance. error NothingToWithdraw(); // Used when trying to withdraw Ether but there's nothing to withdraw. error FailedToWithdrawEth(address owner, address target, uint256 value); // Used when the withdrawal of Ether fails. // Event emitted when a message is sent to another chain. event MessageSent( bytes32 indexed messageId, // The unique ID of the CCIP message. uint64 indexed destinationChainSelector, // The chain selector of the destination chain. address receiver, // The address of the receiver on the destination chain. bytes text, // The text being sent. address feeToken, // the token address used to pay CCIP fees. uint256 fees // The fees paid for sending the CCIP message. ); bytes32 private s_lastReceivedMessageId; // Store the last received messageId. string private s_lastReceivedText; // Store the last received text. IERC20 private s_linkToken; // remember to add visibility for the variable MyToken public nft; struct RequestData{ uint256 tokenId; address newOwner; } /// @notice Constructor initializes the contract with the router address. /// @param _router The address of the router contract. /// @param _link The address of the link contract. constructor(address _router, address _link, address nftAddr) CCIPReceiver(_router) { s_linkToken = IERC20(_link); nft = MyToken(nftAddr); } /// @notice Sends data to receiver on the destination chain. /// @notice Pay for fees in LINK. /// @dev Assumes your contract has sufficient LINK. /// @param _destinationChainSelector The identifier (aka selector) for the destination blockchain. /// @param _receiver The address of the recipient on the destination blockchain. /// @param _payload The data to be sent. /// @return messageId The ID of the CCIP message that was sent. function sendMessagePayLINK( uint64 _destinationChainSelector, address _receiver, bytes memory _payload ) internal returns (bytes32 messageId) { // Create an EVM2AnyMessage struct in memory with necessary information for sending a cross-chain message Client.EVM2AnyMessage memory evm2AnyMessage = _buildCCIPMessage( _receiver, _payload, address(s_linkToken) ); // Initialize a router client instance to interact with cross-chain router IRouterClient router = IRouterClient(this.getRouter()); // Get the fee required to send the CCIP message uint256 fees = router.getFee(_destinationChainSelector, evm2AnyMessage); if (fees > s_linkToken.balanceOf(address(this))) revert NotEnoughBalance(s_linkToken.balanceOf(address(this)), fees); // approve the Router to transfer LINK tokens on contract's behalf. It will spend the fees in LINK s_linkToken.approve(address(router), fees); // Send the CCIP message through the router and store the returned CCIP message ID messageId = router.ccipSend(_destinationChainSelector, evm2AnyMessage); // Emit an event with message details emit MessageSent( messageId, _destinationChainSelector, _receiver, _payload, address(s_linkToken), fees ); // Return the CCIP message ID return messageId; } /// handle a received message function _ccipReceive( Client.Any2EVMMessage memory any2EvmMessage ) internal override { s_lastReceivedMessageId = any2EvmMessage.messageId; // fetch the messageId RequestData memory requestData = abi.decode(any2EvmMessage.data, (RequestData)); uint256 tokenId = requestData.tokenId; address newOwner = requestData.newOwner; require(tokenLocked[tokenId], "the NFT is not locked"); nft.transferFrom(address(this), newOwner, tokenId); emit TokenUnlocked(tokenId, newOwner); } /// @notice Construct a CCIP message. /// @dev This function will create an EVM2AnyMessage struct with all the necessary information for sending a text. /// @param _receiver The address of the receiver. /// @param _payload The string data to be sent. /// @param _feeTokenAddress The address of the token used for fees. Set address(0) for native gas. /// @return Client.EVM2AnyMessage Returns an EVM2AnyMessage struct which contains information for sending a CCIP message. function _buildCCIPMessage( address _receiver, bytes memory _payload, address _feeTokenAddress ) private pure returns (Client.EVM2AnyMessage memory) { // Create an EVM2AnyMessage struct in memory with necessary information for sending a cross-chain message return Client.EVM2AnyMessage({ receiver: abi.encode(_receiver), // ABI-encoded receiver address data: _payload, // ABI-encoded string tokenAmounts: new Client.EVMTokenAmount[](0), // Empty array aas no tokens are transferred extraArgs: Client._argsToBytes( // Additional arguments, setting gas limit Client.EVMExtraArgsV1({gasLimit: 200_000}) ), // Set the feeToken to a feeTokenAddress, indicating specific asset will be used for fees feeToken: _feeTokenAddress }); } /// @notice Fetches the details of the last received message. /// @return messageId The ID of the last received message. /// @return text The last received text. function getLastReceivedMessageDetails() external view returns (bytes32 messageId, string memory text) { return (s_lastReceivedMessageId, s_lastReceivedText); } /// @notice Fallback function to allow the contract to receive Ether. /// @dev This function has no function body, making it a default function for receiving Ether. /// It is automatically called when Ether is sent to the contract without any data. receive() external payable {} /// @notice Allows the contract owner to withdraw the entire balance of Ether from the contract. /// @dev This function reverts if there are no funds to withdraw or if the transfer fails. /// It should only be callable by the owner of the contract. /// @param _beneficiary The address to which the Ether should be sent. function withdraw(address _beneficiary) public onlyOwner { // Retrieve the balance of this contract uint256 amount = address(this).balance; // Revert if there is nothing to withdraw if (amount == 0) revert NothingToWithdraw(); // Attempt to send the funds, capturing the success status and discarding any return data (bool sent, ) = _beneficiary.call{value: amount}(""); // Revert if the send failed, with information about the attempted transfer if (!sent) revert FailedToWithdrawEth(msg.sender, _beneficiary, amount); } /// @notice Allows the owner of the contract to withdraw all tokens of a specific ERC20 token. /// @dev This function reverts with a 'NothingToWithdraw' error if there are no tokens to withdraw. /// @param _beneficiary The address to which the tokens will be sent. /// @param _token The contract address of the ERC20 token to be withdrawn. function withdrawToken( address _beneficiary, address _token ) public onlyOwner { // Retrieve the balance of this contract uint256 amount = IERC20(_token).balanceOf(address(this)); // Revert if there is nothing to withdraw if (amount == 0) revert NothingToWithdraw(); IERC20(_token).safeTransfer(_beneficiary, amount); } }
NFT合约
// SPDX-License-Identifier: MIT // Compatible with OpenZeppelin Contracts ^5.0.0 pragma solidity ^0.8.20; import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol"; import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol"; import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Burnable.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; contract MyNFT is ERC721, ERC721Enumerable, ERC721URIStorage, ERC721Burnable, Ownable { string constant public METADATA_URI = "ipfs://QmXw7TEAJWKjKifvLE25Z9yjvowWk2NWY3WgnZPUto9XoA"; uint256 private _nextTokenId; constructor(string memory tokenName, string memory tokenSymbol) ERC721(tokenName, tokenSymbol) Ownable(msg.sender) {} function safeMint(address to) public { uint256 tokenId = _nextTokenId++; _safeMint(to, tokenId); _setTokenURI(tokenId, METADATA_URI); } // The following functions are overrides required by Solidity. function _update(address to, uint256 tokenId, address auth) internal override(ERC721, ERC721Enumerable) returns (address) { return super._update(to, tokenId, auth); } function _increaseBalance(address account, uint128 value) internal override(ERC721, ERC721Enumerable) { super._increaseBalance(account, value); } function tokenURI(uint256 tokenId) public view override(ERC721, ERC721URIStorage) returns (string memory ) { return super.tokenURI(tokenId); } function supportsInterface(bytes4 interfaceId) public view override(ERC721, ERC721Enumerable, ERC721URIStorage) returns (bool) { return super.supportsInterface(interfaceId); } }
NFT-Locked-Release
// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import {IRouterClient} from "@chainlink/contracts/src/v0.8/ccip/interfaces/IRouterClient.sol"; import {OwnerIsCreator} from "node_modules/@chainlink/contracts/src/v0.8/shared/access/OwnerIsCreator.sol"; import {Client} from "node_modules/@chainlink/contracts/src/v0.8/ccip/applications/CCIPReceiver.sol"; import {CCIPReceiver} from "@chainlink/contracts/src/v0.8/ccip/applications/CCIPReceiver.sol"; import {IERC20} from "node_modules/@chainlink/contracts/src/v0.8/vendor/openzeppelin-solidity/v4.8.3/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "node_modules/@chainlink/contracts/src/v0.8/vendor/openzeppelin-solidity/v4.8.3/contracts/token/ERC20/utils/SafeERC20.sol"; import {MyNFT} from "./MyNFT.sol"; /** * THIS IS AN EXAMPLE CONTRACT THAT USES HARDCODED VALUES FOR CLARITY. * THIS IS AN EXAMPLE CONTRACT THAT USES UN-AUDITED CODE. * DO NOT USE THIS CODE IN PRODUCTION. */ /// @title - A simple messenger contract for sending/receving string data across chains. contract NFTPoolLockAndRelease is CCIPReceiver, OwnerIsCreator { using SafeERC20 for IERC20; // Custom errors to provide more descriptive revert messages. error NotEnoughBalance(uint256 currentBalance, uint256 calculatedFees); // Used to make sure contract has enough balance. error NothingToWithdraw(); // Used when trying to withdraw Ether but there's nothing to withdraw. error FailedToWithdrawEth(address owner, address target, uint256 value); // Used when the withdrawal of Ether fails. // Event emitted when a message is sent to another chain. event MessageSent( bytes32 indexed messageId, // The unique ID of the CCIP message. uint64 indexed destinationChainSelector, // The chain selector of the destination chain. address receiver, // The address of the receiver on the destination chain. bytes text, // The text being sent. address feeToken, // the token address used to pay CCIP fees. uint256 fees // The fees paid for sending the CCIP message. ); bytes32 private s_lastReceivedMessageId; // Store the last received messageId. string private s_lastReceivedText; // Store the last received text. IERC20 private s_linkToken; MyNFT public nft;//第一步先实例化一个nft对象,同时需要在构造函数中初始化 // remember to add visibility for the variable MyToken public nft; struct RequestData{ uint256 tokenId; address newOwner; } /// @notice Constructor initializes the contract with the router address. /// @param _router The address of the router contract. /// @param _link The address of the link contract. constructor(address _router, address _link, address nftAddr) CCIPReceiver(_router) { s_linkToken = IERC20(_link); nft = MyToken(nftAddr); } function lockAndSendNFT( uint256 tokenId, address newOwner, uint64 chainSelector, address receiver) public returns(bytes32 messageId){ //transfer nft.transferFrom(msg.sender,address(this),tokenId);//从持有人地址转入当前地址 //发送跨链消息:需要传入receiver地址和tokenid给到链下的ccip组件 //通过加密,打包两个参数 bytes memory payload = abi.encode(tokenId,newOwner); //发送消息,使用link支付 bytes32 messageId = sendMessagePayLINK(chainSelector,receiver,payload); } /// @notice Sends data to receiver on the destination chain. /// @notice Pay for fees in LINK. /// @dev Assumes your contract has sufficient LINK. /// @param _destinationChainSelector The identifier (aka selector) for the destination blockchain. /// @param _receiver The address of the recipient on the destination blockchain. /// @param _payload The data to be sent. /// @return messageId The ID of the CCIP message that was sent. function sendMessagePayLINK( uint64 _destinationChainSelector, address _receiver, bytes memory _payload ) internal returns (bytes32 messageId) { // Create an EVM2AnyMessage struct in memory with necessary information for sending a cross-chain message Client.EVM2AnyMessage memory evm2AnyMessage = _buildCCIPMessage( _receiver, _payload, address(s_linkToken) ); // Initialize a router client instance to interact with cross-chain router IRouterClient router = IRouterClient(this.getRouter()); // Get the fee required to send the CCIP message uint256 fees = router.getFee(_destinationChainSelector, evm2AnyMessage); if (fees > s_linkToken.balanceOf(address(this))) revert NotEnoughBalance(s_linkToken.balanceOf(address(this)), fees); // approve the Router to transfer LINK tokens on contract's behalf. It will spend the fees in LINK s_linkToken.approve(address(router), fees); // Send the CCIP message through the router and store the returned CCIP message ID messageId = router.ccipSend(_destinationChainSelector, evm2AnyMessage); // Emit an event with message details emit MessageSent( messageId, _destinationChainSelector, _receiver, _payload, address(s_linkToken), fees ); // Return the CCIP message ID return messageId; } /// handle a received message function _ccipReceive( Client.Any2EVMMessage memory any2EvmMessage ) internal override { s_lastReceivedMessageId = any2EvmMessage.messageId; // fetch the messageId RequestData memory requestData = abi.decode(any2EvmMessage.data, (RequestData)); uint256 tokenId = requestData.tokenId; address newOwner = requestData.newOwner; require(tokenLocked[tokenId], "the NFT is not locked"); nft.transferFrom(address(this), newOwner, tokenId); emit TokenUnlocked(tokenId, newOwner); } /// @notice Construct a CCIP message. /// @dev This function will create an EVM2AnyMessage struct with all the necessary information for sending a text. /// @param _receiver The address of the receiver. /// @param _payload The string data to be sent. /// @param _feeTokenAddress The address of the token used for fees. Set address(0) for native gas. /// @return Client.EVM2AnyMessage Returns an EVM2AnyMessage struct which contains information for sending a CCIP message. function _buildCCIPMessage( address _receiver, bytes memory _payload, address _feeTokenAddress ) private pure returns (Client.EVM2AnyMessage memory) { // Create an EVM2AnyMessage struct in memory with necessary information for sending a cross-chain message return Client.EVM2AnyMessage({ receiver: abi.encode(_receiver), // ABI-encoded receiver address data: _payload, // ABI-encoded string tokenAmounts: new Client.EVMTokenAmount[](0), // Empty array aas no tokens are transferred extraArgs: Client._argsToBytes( // Additional arguments, setting gas limit Client.EVMExtraArgsV1({gasLimit: 200_000}) ), // Set the feeToken to a feeTokenAddress, indicating specific asset will be used for fees feeToken: _feeTokenAddress }); } /// @notice Fetches the details of the last received message. /// @return messageId The ID of the last received message. /// @return text The last received text. function getLastReceivedMessageDetails() external view returns (bytes32 messageId, string memory text) { return (s_lastReceivedMessageId, s_lastReceivedText); } /// @notice Fallback function to allow the contract to receive Ether. /// @dev This function has no function body, making it a default function for receiving Ether. /// It is automatically called when Ether is sent to the contract without any data. receive() external payable {} /// @notice Allows the contract owner to withdraw the entire balance of Ether from the contract. /// @dev This function reverts if there are no funds to withdraw or if the transfer fails. /// It should only be callable by the owner of the contract. /// @param _beneficiary The address to which the Ether should be sent. function withdraw(address _beneficiary) public onlyOwner { // Retrieve the balance of this contract uint256 amount = address(this).balance; // Revert if there is nothing to withdraw if (amount == 0) revert NothingToWithdraw(); // Attempt to send the funds, capturing the success status and discarding any return data (bool sent, ) = _beneficiary.call{value: amount}(""); // Revert if the send failed, with information about the attempted transfer if (!sent) revert FailedToWithdrawEth(msg.sender, _beneficiary, amount); } /// @notice Allows the owner of the contract to withdraw all tokens of a specific ERC20 token. /// @dev This function reverts with a 'NothingToWithdraw' error if there are no tokens to withdraw. /// @param _beneficiary The address to which the tokens will be sent. /// @param _token The contract address of the ERC20 token to be withdrawn. function withdrawToken( address _beneficiary, address _token ) public onlyOwner { // Retrieve the balance of this contract uint256 amount = IERC20(_token).balanceOf(address(this)); // Revert if there is nothing to withdraw if (amount == 0) revert NothingToWithdraw(); IERC20(_token).safeTransfer(_beneficiary, amount); } }
lockAndSendNFT
传入参数
function lockAndSendNFT( uint256 tokenId, address newOwner, uint64 chainSelector, address receiver) public{ //transfer }
先将NFT锁入当前合约并检查
先创建一个nft的实例,前面我们已经创建了好了一个MyNFT的合约
import {MyNFT} from "./MyNFT.sol";
MyNFT public nft;//第一步先实例化一个nft对象,同时需要在构造函数中初始化
之后将自己拥有的NFT转移到当前的合约中
nft.transferFrom(msg.sender,address(this),tokenId);//从持有人地址转入当前地址
发送跨链消息,首先两个主要函数起到主要作用
sendMessagePayLINK
/// @notice Sends data to receiver on the destination chain. /// @notice Pay for fees in LINK. /// @dev Assumes your contract has sufficient LINK. /// @param _destinationChainSelector The identifier (aka selector) for the destination blockchain. /// @param _receiver The address of the recipient on the destination blockchain. /// @param _payload The data to be sent. /// @return messageId The ID of the CCIP message that was sent. function sendMessagePayLINK( uint64 _destinationChainSelector, address _receiver, bytes memory _payload ) internal returns (bytes32 messageId) { // Create an EVM2AnyMessage struct in memory with necessary information for sending a cross-chain message // evm to AnyMessage, 这个消息时从evm链上发送到链下的 Client.EVM2AnyMessage memory evm2AnyMessage = _buildCCIPMessage( _receiver, _payload, address(s_linkToken) ); // Initialize a router client instance to interact with cross-chain router //Router验证请求并计算gas费用 IRouterClient router = IRouterClient(this.getRouter()); // Get the fee required to send the CCIP message //计算发送消息的gas费 uint256 fees = router.getFee(_destinationChainSelector, evm2AnyMessage); if (fees > s_linkToken.balanceOf(address(this))) revert NotEnoughBalance(s_linkToken.balanceOf(address(this)), fees); // approve the Router to transfer LINK tokens on contract's behalf. It will spend the fees in LINK //授权link给router合约 to 发送消息 s_linkToken.approve(address(router), fees); // Send the CCIP message through the router and store the returned CCIP message ID //通过router合约发送ccip消息,并将CCIP message ID返回 messageId = router.ccipSend(_destinationChainSelector, evm2AnyMessage); // Emit an event with message details // 释放事件 emit MessageSent( messageId, _destinationChainSelector, _receiver, _payload, address(s_linkToken), fees ); // Return the CCIP message ID return messageId; }
_buildCCIPMessage
该函数的主要目的是构建一个用于跨链消息传递的 EVM2AnyMessage 结构体。这个结构体包含了发送跨链消息所需的所有信息
/// @notice Construct a CCIP message. /// @dev This function will create an EVM2AnyMessage struct with all the necessary information for sending a text. /// @param _receiver The address of the receiver. /// @param _payload The string data to be sent. /// @param _feeTokenAddress The address of the token used for fees. Set address(0) for native gas. /// @return Client.EVM2AnyMessage Returns an EVM2AnyMessage struct which contains information for sending a CCIP message. function _buildCCIPMessage( address _receiver, bytes memory _payload, address _feeTokenAddress ) private pure returns (Client.EVM2AnyMessage memory) { // Create an EVM2AnyMessage struct in memory with necessary information for sending a cross-chain message return Client.EVM2AnyMessage({ receiver: abi.encode(_receiver), // ABI-encoded receiver address data: _payload, // ABI-encoded string tokenAmounts: new Client.EVMTokenAmount[](0), // Empty array aas no tokens are transferred extraArgs: Client._argsToBytes( // Additional arguments, setting gas limit Client.EVMExtraArgsV1({gasLimit: 200_000}) ), // Set the feeToken to a feeTokenAddress, indicating specific asset will be used for fees feeToken: _feeTokenAddress }); }
参数:
- address _receiver:接收者的地址,表示消息将发送到哪个地址。
- bytes memory _payload:要发送的数据,通常是经过编码的参数。
- address _feeTokenAddress:用于支付费用的代币地址。如果使用原生代币支付,则可以设置为 address(0)。
构建 EVM2AnyMessage 结构体:
return Client.EVM2AnyMessage({ receiver: abi.encode(_receiver), // ABI-encoded receiver address data: _payload, // ABI-encoded string tokenAmounts: new Client.EVMTokenAmount[](0), // Empty array as no tokens are transferred extraArgs: Client._argsToBytes( // Additional arguments, setting gas limit Client.EVMExtraArgsV1({gasLimit: 200_000}) ), // Set the feeToken to a feeTokenAddress, indicating specific asset will be used for fees feeToken: _feeTokenAddress });
- receiver:使用 abi.encode 对接收者地址进行编码,以确保其格式正确。
- data:直接使用传入的 _payload,这是要发送的消息内容。
- tokenAmounts:初始化为空数组,因为在此消息中不涉及代币转移。
- extraArgs:使用 Client._argsToBytes 函数设置额外参数,这里主要是设置了 gasLimit,确保跨链消息有足够的 gas 进行处理。
- feeToken:设置为传入的费用代币地址,指明将使用哪个代币支付跨链消息的费用。
并将构建好的消息返回给 sendMessagePayLINK 函数
// Create an EVM2AnyMessage struct in memory with necessary information for sending a cross-chain message Client.EVM2AnyMessage memory evm2AnyMessage = _buildCCIPMessage( _receiver, _payload, address(s_linkToken) );
这里重新写了函数 lockAndSendNFT ,将 tokenId ,newOwner 编码打包
function lockAndSendNFT( uint256 tokenId, address newOwner, uint64 chainSelector, address receiver) public returns(bytes32 messageId){ //transfer nft.transferFrom(msg.sender,address(this),tokenId);//从持有人地址转入当前地址 //发送跨链消息:需要传入receiver地址和tokenid给到链下的ccip组件 //通过加密,打包两个参数 bytes memory payload = abi.encode(tokenId,newOwner); //发送消息,使用link支付 bytes32 messageId = sendMessagePayLINK(chainSelector,receiver,payload); }
其实对于结构体的参数结构,Client 库里面定义了
// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; // End consumer library. library Client { /// @dev RMN depends on this struct, if changing, please notify the RMN maintainers. struct EVMTokenAmount { address token; // token address on the local chain. uint256 amount; // Amount of tokens. } struct Any2EVMMessage { bytes32 messageId; // MessageId corresponding to ccipSend on source. uint64 sourceChainSelector; // Source chain selector. bytes sender; // abi.decode(sender) if coming from an EVM chain. bytes data; // payload sent in original message. EVMTokenAmount[] destTokenAmounts; // Tokens and their amounts in their destination chain representation. } // If extraArgs is empty bytes, the default is 200k gas limit. struct EVM2AnyMessage { bytes receiver; // abi.encode(receiver address) for dest EVM chains bytes data; // Data payload EVMTokenAmount[] tokenAmounts; // Token transfers address feeToken; // Address of feeToken. address(0) means you will send msg.value. bytes extraArgs; // Populate this with _argsToBytes(EVMExtraArgsV2) } // bytes4(keccak256("CCIP EVMExtraArgsV1")); bytes4 public constant EVM_EXTRA_ARGS_V1_TAG = 0x97a657c9; struct EVMExtraArgsV1 { uint256 gasLimit; } function _argsToBytes( EVMExtraArgsV1 memory extraArgs ) internal pure returns (bytes memory bts) { return abi.encodeWithSelector(EVM_EXTRA_ARGS_V1_TAG, extraArgs); } // bytes4(keccak256("CCIP EVMExtraArgsV2")); bytes4 public constant EVM_EXTRA_ARGS_V2_TAG = 0x181dcf10; /// @param gasLimit: gas limit for the callback on the destination chain. /// @param allowOutOfOrderExecution: if true, it indicates that the message can be executed in any order relative to other messages from the same sender. /// This value's default varies by chain. On some chains, a particular value is enforced, meaning if the expected value /// is not set, the message request will revert. struct EVMExtraArgsV2 { uint256 gasLimit; bool allowOutOfOrderExecution; } function _argsToBytes( EVMExtraArgsV2 memory extraArgs ) internal pure returns (bytes memory bts) { return abi.encodeWithSelector(EVM_EXTRA_ARGS_V2_TAG, extraArgs); } }
NFT-Burn-Mint
MINT--_ccipReceive
来自目标链上的合约接收链下的ccip的组件的消息
首先接收的到消息需要先进行decode解码 any2EvmMessage,获取需要的信息,信息结构需要进行实例化
RequestData memory message = abi.decode(any2EvmMessage.data,(RequestData)); uint256 tokenId = message.tokenId; address newOwner = message.newOwner;
将 wnft 转给新的owner地址
//mint the NFT ,注意这里是mint一个nft,而不是直接进行transferFrom wnft.ResetTokenId(newOwner,tokenId);
补充
接收的这个信息结构由Client库提供
/// handle a received message function _ccipReceive( Client.Any2EVMMessage memory any2EvmMessage ) internal override { RequestData memory message = abi.decode(any2EvmMessage.data,(RequestData)); uint256 tokenId = message.tokenId; address newOwner = message.newOwner; //mint the NFT wnft.ResetTokenId(newOwner,tokenId); emit MessageReceived( any2EvmMessage.messageId, any2EvmMessage.sourceChainSelector, abi.decode(any2EvmMessage.sender, (address)), tokenId, newOwner ); }
BURN--BurnAndReturn
首先需要的参数与 lockAndSendNFT的函数是一样的,因为需要使用 sendMessagePayLINK 函数去发送消息
function BurnAndReturn( uint256 _tokenId, address newOwner, uint64 destChainSelector, address receiver) public {}
将 wnft 从owner地址转移到pool地址,用burn函数烧毁
// transfer NFT to the pool wnft.transferFrom(msg.sender, address(this), _tokenId); // burn the NFT wnft.burn(_tokenId);
使用encode打包消息,提供 payload 给 sendMessagePayLINK函数
// send transaction to the destination chain bytes memory payload = abi.encode(_tokenId, newOwner); sendMessagePayLINK(destChainSelector, receiver, payload);
BurnAndReturn函数
function BurnAndReturn( uint256 _tokenId, address newOwner, uint64 destChainSelector, address receiver) public { // verify if the sender is the owner of NFT // comment this because the check is already performed by ERC721 // require(wnft.ownerOf(_tokenId) == msg.sender, "you are not the owner of the NFT"); // transfer NFT to the pool wnft.transferFrom(msg.sender, address(this), _tokenId); // burn the NFT wnft.burn(_tokenId); // send transaction to the destination chain bytes memory payload = abi.encode(_tokenId, newOwner); sendMessagePayLINK(destChainSelector, receiver, payload); }
部署合约
对于hardhat框架来说,部署的时候,主要用到两个工具,getNamedAccounts和 deployments
getNamedAccounts
const {getNamedAccounts,deployments} = require("hardhat"); module.exports = async({getNamedAccounts,deployments}) => { const {firstAccount} = await getNamedAccounts(); const {deploy,log} = deployments; log("Deploying CCIP Simulator..."); await deploy("CCIPLocalSimulator",{ contract: "CCIPLocalSimulator", from: firstAccount, log:true, args:[] }); log("CCIPSimulator contract deployed successfully"); } module.exports.tags = ["testlocal","all"];//输出标签
使用异步函数,原因是需要先等待关键参数的获取,区别于按顺序执行命令,没有等待时间
使用 getNamedAccounts() 方法时,需要在 hardhat-config 文件中进行配置,需要先声明 firstAccount 对象
require("@nomicfoundation/hardhat-toolbox"); require("@nomicfoundation/hardhat-ethers"); require("hardhat-deploy"); require("hardhat-deploy-ethers"); /** @type import('hardhat/config').HardhatUserConfig */ module.exports = { solidity: { compilers: [ { version: "0.8.28", settings: { optimizer: { enabled: true, runs: 200 } } } ] }, namedAccounts: { firstAccount: { default: 0, } } };
deploy 和 log 是 deployment 的方法
await deploy("CCIPLocalSimulator",{ contract: "CCIPLocalSimulator", from: firstAccount, log:true, args:[] //构造函数需要传入的参数 }); log("CCIPSimulator contract deployed successfully");
deployments 方法的使用
const {getNamedAccounts,deployments, ethers} = require("hardhat"); module.exports = async({getNamedAccounts,deployments}) => { const {firstAccount} = await getNamedAccounts(); const {deploy,log} = deployments; log("Deploying NFTPoolLockAndRelease contract..."); // 1. 先获取部署信息 const ccipSimulatorDeployment = await deployments.get("CCIPLocalSimulator"); // 2. 获取合约实例 const ccipSimulator = await ethers.getContractAt("CCIPLocalSimulator",ccipSimulatorDeployment.address); // 3. 调用configuration函数 const ccipConfig = await ccipSimulator.configuration(); // 4. 获取router,link的地址,nft的地址 const sourceChainRouter = ccipConfig.sourceRouter_; const sourceChainlink = ccipConfig.linkToken_; const nftAddrDeployment = await deployments.get("MyNFT"); const nftAddr = await nftAddrDeployment.address; // 5. 部署NFTPoolLockAndRelease合约 await deploy("NFTPoolLockAndRelease",{ contract: "NFTPoolLockAndRelease", from: firstAccount, log:true, //需要传入的参数: address _router, address _link, address nftAddr args:[sourceChainRouter,sourceChainlink,nftAddr] }); log("NFTPoolLockAndRelease contract deployed successfully"); } module.exports.tags = ["SourceChain","all"];
deployment.get 方法 -- 获取部署的信息 -- 查找合约在哪
ethers.getContractAt -- 创建合约实例
const ccipSimulator = await ethers.getContractAt("CCIPLocalSimulator",ccipSimulatorDeployment.address);
传入合约名字 和 地址信息,创建合约实例 -- ccipSimulator
调用configuration函数 --- 这个是 ccip-local 的函数
返回参数
/** * @dev Returns the configuration of the CCIP simulator */ function configuration() public view returns ( uint64 chainSelector, IRouterClient sourceRouter, IRouterClient destinationRouter, WETH9 wrappedNative, LinkToken linkToken, BurnMintERC677Helper ccipBnM, BurnMintERC677Helper ccipLnM ) { // 返回所有配置参数 return ( chainSelector, sourceRouter, destinationRouter, wrappedNative, linkToken, ccipBnM, ccipLnM ); }
测试
test --流程测试
//源链 --> 目标链 //mint 一个 nft 到源链 //将nft 锁定在源链, 发送跨链消息 //在目标链得到 mint的 wnft //目标链 --> 源链 //将目标链的 wnft烧掉,发送跨链消息 //将源链的nft解锁,得到nft //验证nft是否正确
ethers.getContractAt 与 ethers.getContract 方法
ethers.getContractAt 用于任何地址的合约,包括其他项目的合约。需要传入合约地址进行调用
// 需要指定具体的合约地址 const myNFT = await ethers.getContractAt( "MyNFT", "0x1234..." // 具体的合约地址 ); // 适用场景: - 与已经部署的合约交互 - 与其他项目的合约交互 - 需要指定特定版本的合约
ethers.getContract 适用于同一个项目
// 直接用合约名获取最新部署的合约 const myNFT = await ethers.getContract("MyNFT"); // 多传入一个signer参数,带 signer 的用法: // 需要发送交易的场景 const contract = await ethers.getContract("ContractName", signer); await contract.mint(tokenId); // 铸造 NFT await contract.transfer(to, amount); // 转账 await contract.approve(spender, id); // 授权 await contract.setBaseURI(uri); // 设置 URI // 适用场景: - 在同一个项目中 - 合约刚刚部署完 - 想要获取最新部署的合约实例
Chai工具
Chai 是一个用于测试的断言库,它让我们可以写出更易读的测试代码
const { expect } = require("chai"); describe("NFT Contract", function() { it("Should mint NFT correctly", async function() { const nft = await ethers.getContract("MyNFT"); const [owner] = await ethers.getSigners(); // Chai 的断言方法 expect(await nft.balanceOf(owner.address)).to.equal(0); // 检查初始余额 await nft.mint(owner.address, 1); expect(await nft.balanceOf(owner.address)).to.equal(1); // 检查铸造后余额 expect(await nft.ownerOf(1)).to.equal(owner.address); // 检查所有权 }); }); 常用方法 // 相等判断 expect(value).to.equal(expectedValue); expect(value).to.be.equal(expectedValue); // 大小比较 expect(value).to.be.gt(5); // 大于 expect(value).to.be.gte(5); // 大于等于 expect(value).to.be.lt(10); // 小于 expect(value).to.be.lte(10); // 小于等于 // 包含判断 expect(array).to.include(item); expect(string).to.contain("text"); // 事件测试 await expect(contract.function()) .to.emit(contract, "EventName") .withArgs(arg1, arg2); // 错误测试 await expect(contract.function()) .to.be.revertedWith("error message");
Mocha
是一个功能强大的 JavaScript 测试框架,主要用于 Node.js 应用程序的单元测试和集成测试。它提供了一个灵活的测试环境,支持异步测试,并且可以与其他断言库(如 Chai)结合使用。
describe 块:
describe("Mint NFT,source chain --> destination chain",async function(){//一个注释,一个函数参数,JavaScript 的语法需要一个函数来包含代码块,函数参数就是来形成闭包的 it("Mint NFT",async function(){ await nft.mint(firstAccount.user1.address,1); }) })
变量准备
//变量准备 const {getNamedAccounts,deployments, ethers} = require("hardhat"); const {expect} = require("chai"); let firstAccount; let ccipSimulator; let nft; let wnft; let NFTPoolLockAndRelease; let NFTPoolBurnAndMint; let chainSelector; before(async function () { firstAccount = (await getNamedAccounts()).firstAccount; // 部署所有带 "all" 标签的合约并创建快照 await deployments.fixture(["all"]); ccipSimulator = await ethers.getContract("CCIPLocalSimulator",firstAccount); nft = await ethers.getContract("MyNFT",firstAccount); wnft = await ethers.getContract("WrappedNFT",firstAccount); NFTPoolLockAndRelease = await ethers.getContract("NFTPoolLockAndRelease",firstAccount); NFTPoolBurnAndMint = await ethers.getContract("NFTPoolBurnAndMint",firstAccount); chainSelector = (await ccipSimulator.configuration()).chainSelector_; })
进行测试
describe("source chain --> dest chain", async function () { it("mint nft and test the owner is minter", async function () { // get nft await nft.safeMint(firstAccount); const ownerOfNft = await nft.ownerOf(0); expect(ownerOfNft).to.equal(firstAccount); console.log("owner address is",firstAccount); } ) it("transfer NFT from source chain to dest chain, check if the nft is locked", async function() { await ccipSimulator.requestLinkFromFaucet(NFTPoolLockAndRelease.target, ethers.parseEther("10")) // lock and send with CCIP await nft.approve(NFTPoolLockAndRelease.target, 0) await NFTPoolLockAndRelease.lockAndSendNFT(0, firstAccount, chainSelector, NFTPoolBurnAndMint.target) // check if owner of nft is pool's address const newOwner = await nft.ownerOf(0) console.log("test") expect(newOwner).to.equal(NFTPoolLockAndRelease.target) // check if the nft is locked const isLocked = await NFTPoolLockAndRelease.tokenLocked(0) expect(isLocked).to.equal(true) } ) it("check if the nft is minted on dest chain", async function() { const ownerOfNft = await wnft.ownerOf(0) expect(ownerOfNft).to.equal(firstAccount) } ) }) describe("dest chain --> source chain", async function () { it("burn nft and check the nft owner is firstAccount", async function() { await wnft.approve(NFTPoolBurnAndMint.target,0) await NFTPoolBurnAndMint.BurnAndReturn(0, firstAccount, chainSelector, NFTPoolLockAndRelease.target) const ownerOfNft = await nft.ownerOf(0) expect(ownerOfNft).to.equal(firstAccount) } ) } )
task测试
网络配置文件
developmentChains = ["hardhat", "localhost"] const networkConfig = { 11155111: { name: "sepolia", router: "0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59", linkToken: "0x779877A7B0D9E8603169DdbD7836e478b4624789", companionChainSelector: "16281711391670634445" }, 80002: { name: "amoy", router: "0x9C32fCB86BF0f4a1A8921a9Fe46de3198bb884B2", linkToken: "0x0Fd9e8d3aF1aaee056EB9e802c3A762a667b1904", companionChainSelector: "16015286601757825753" } } module.exports ={ developmentChains, networkConfig }
部署脚本更改
deploy--ccipsimulator
如果network.name是 hardhat 或者 localhost,就运行该脚本
const {getNamedAccounts,deployments, network} = require("hardhat"); const {ethers} = require("hardhat"); const {developmentChains} = require("helper-hardhat-config.js") module.exports = async({getNamedAccounts,deployments}) => { if(developmentChains.includes(network.name)){ const {firstAccount} = await getNamedAccounts(); const {deploy,log} = deployments; log("Deploying CCIP Simulator..."); const ccipSimulator = await deploy("CCIPLocalSimulator",{ contract: "CCIPLocalSimulator", from: firstAccount, log:true, args:[] }); } } module.exports.tags = ["testlocal","all"];
developmentChains.includes(network.name)
在 Hardhat 部署脚本中,network.name 会返回当前运行网络的名称。
比如:
当你运行 hardhat deploy 时,network.name 会是 "hardhat"
当你运行 hardhat deploy --network localhost 时,会是 "localhost"
deploy--NFTPoolLockAndRelease
const {getNamedAccounts,deployments, ethers, network} = require("hardhat"); const {developmentChains,networkConfig} = require("helper-hardhat-config.js") module.exports = async({getNamedAccounts,deployments}) => { const {firstAccount} = await getNamedAccounts(); const {deploy,log} = deployments; let sourceChainRouter let linkTokenAddr if(developmentChains.includes(network.name)){ // 1. 先获取部署信息 const ccipSimulatorDeployment = await deployments.get("CCIPLocalSimulator"); // 2. 获取合约实例 const ccipSimulator = await ethers.getContractAt("CCIPLocalSimulator",ccipSimulatorDeployment.address); // 3. 调用configuration函数 const ccipConfig = await ccipSimulator.configuration(); // 4. 获取router,link的地址,nft的地址 sourceChainRouter = ccipConfig.sourceRouter_; linkTokenAddr = ccipConfig.linkToken_; else{ //network.config 是 Hardhat 提供的,用来获取当前运行网络的配置 //network.config 从 hardhat.config.js 获取网络配置(比如 chainId) //用这个 chainId 去 helper-hardhat-config.js 中查找对应的合约配置 sourceChainRouter = networkConfig[network.config.chainId].router linkTokenAddr = networkConfig[network.config.chainId].linkToken } log("Deploying NFTPoolLockAndRelease contract..."); const nftAddrDeployment = await deployments.get("MyNFT"); const nftAddr = await nftAddrDeployment.address; // 5. 部署NFTPoolLockAndRelease合约 await deploy("NFTPoolLockAndRelease",{ contract: "NFTPoolLockAndRelease", from: firstAccount, log:true, //需要传入的参数: address _router, address _link, address nftAddr args:[sourceChainRouter,sourceChainlink,nftAddr] }); log("NFTPoolLockAndRelease contract deployed successfully"); } module.exports.tags = ["SourceChain","all"];
sourceChainRouter = networkConfig[network.config.chainId].router
这种方法的使用例子
// 2. 使用数字作为键的对象 const networkConfig = { 11155111: { name: "sepolia", router: "0x0BF3..." }, 80002: { name: "amoy", router: "0x9C32..." } } // 假设现在 network.config.chainId 是 11155111 // 这三种写法是等价的: console.log(networkConfig[11155111].router) // "0x0BF3..." console.log(networkConfig["11155111"].router) // "0x0BF3..." console.log(networkConfig[network.config.chainId].router) // "0x0BF3..."
而 network.config.chainId 是从 hardhat.config.js 的配置中去获取的
deploy--NFTPoolBurnAndMint
const {getNamedAccounts,deployments, ethers, network} = require("hardhat"); const {developmentChains,networkConfig} = require("../helper-hardhat-config.js") module.exports = async({getNamedAccounts,deployments}) => { const {firstAccount} = await getNamedAccounts(); const {deploy,log} = deployments; let destChainRouter; let linkTokenAddr; if(developmentChains.includes(network.name)){ // 1. 获取部署信息 const ccipSimulatorDeployment = await deployments.get("CCIPLocalSimulator"); // 2. 获取合约实例 const ccipSimulator = await ethers.getContractAt("CCIPLocalSimulator",ccipSimulatorDeployment.address); // 3. 获取router,link,wnft的地址 const ccipConfig = await ccipSimulator.configuration(); destChainRouter = ccipConfig.destinationRouter_; linkTokenAddr = ccipConfig.linkToken_; } else{ destChainRouter = networkConfig[network.config.chainId].router linkTokenAddr = networkConfig[network.config.chainId].linkToken } log("Deploying NFTPoolBurnAndMint contract..."); const wnftAddrDeployment = await deployments.get("WrappedNFT"); const wnftAddr = wnftAddrDeployment.address; // 4. 部署NFTPoolBurnAndMint合约 await deploy("NFTPoolBurnAndMint",{ contract:"NFTPoolBurnAndMint", from:firstAccount, log:true, args:[destChainRouter,linkTokenAddr,wnftAddr] }) } module.exports.tags = ["destChain","all"];
task的工具使用
// 定义一个名为 "check-nft" 的任务 task("check-nft") // 添加参数(可选) .addParam("address", "NFT contract address") // 添加可选参数(可选) .addOptionalParam("tokenId", "Token ID to check") // 设置任务描述(可选) .setDescription("Check NFT information") // 设置任务执行的操作 .setAction(async (taskArgs, hre) => { // taskArgs: taskArgs 就是用来接收通过 addParam 和 addOptionalParam 定义的参数 // hre: Hardhat Runtime Environment,包含 ethers, network 等工具 // 任务逻辑 const nftContract = await hre.ethers.getContractAt("YourNFT", taskArgs.address); console.log("Checking NFT..."); });
使用实例
task("check-nft") .addParam("address", "NFT contract address") .addOptionalParam("tokenId", "Token ID to check", "0") .setAction(async (taskArgs, hre) => { // 获取合约实例 const nftContract = await hre.ethers.getContractAt("YourNFT", taskArgs.address); // 获取 NFT 信息 const owner = await nftContract.ownerOf(taskArgs.tokenId); const uri = await nftContract.tokenURI(taskArgs.tokenId); console.log(`Token ${taskArgs.tokenId}:`); console.log(`Owner: ${owner}`); console.log(`URI: ${uri}`); });
任务运行
# 基本使用 npx hardhat check-nft --address 0x123... --network sepolia # 带可选参数 npx hardhat check-nft --address 0x123... --token-id 1 --network sepolia
其他用法
task("task-name") // 添加必需参数 .addParam("param1", "描述") // 添加可选参数 .addOptionalParam("param2", "描述", "默认值") // 添加标志参数 .addFlag("flag", "描述") // 添加位置参数 .addPositionalParam("pos", "描述") // 设置描述 .setDescription("任务描述") // 设置执行操作 .setAction(async (taskArgs, hre) => { // 任务逻辑 });
文件组织结构
在 Hardhat 中,任务(Task)系统的文件组织采用了模块化的结构:每个具体任务都在独立的文件中定义(如 check-nft.js),然后通过一个中心化的 index.js 文件统一导出所有任务,最后在 hardhat.config.js 中只需要一行代码就能导入所有任务。这种结构使得代码更容易维护和扩展,同时保持了项目结构的清晰性。当需要添加新任务时,只需创建新的任务文件并在 index.js 中添加导出即可,而不需要修改配置文件。
如果你后来添加了新任务:
task("mint-nft").setAction(async(taskArgs,hre)=>{ // 铸造 NFT 的逻辑 }) module.exports = {}
只需要在 index.js 中添加:
exports.checkNft = require("./check-nft") exports.mintNft = require("./mint-nft") // 添加新任务
hardhat.config.js 不需要改变:
require("./task") // 自动包含所有任务
任务脚本
mint-nft
const {task} = require("hardhat/config") task("mint-nft").setAction(async(taskArgs,hre)=>{ try { // 1. 检查网络 const network = await hre.ethers.provider.getNetwork(); console.log("Current network:", network.name, network.chainId); // 2. 检查账户 const {firstAccount} = await hre.getNamedAccounts(); console.log("Account:", firstAccount); // 3. 检查部署 const deployments = await hre.deployments.all(); console.log("Available deployments:", Object.keys(deployments)); // 4. 获取合约 console.log("Getting contract..."); const MyNFT = await hre.deployments.get("MyNFT"); console.log("Contract address:", MyNFT.address); // 5. 创建合约实例 const nft = await hre.ethers.getContractAt( "MyNFT", MyNFT.address, await hre.ethers.getSigner(firstAccount) ); // 6. 铸造 NFT console.log("Minting NFT..."); const mintTx = await nft.safeMint(firstAccount); console.log("Waiting for confirmation..."); await mintTx.wait(6); const tokenAmount = await nft.totalSupply(); const tokenId = tokenAmount - 1n; console.log(`Mint successful! TokenId:${tokenId}, Amount:${tokenAmount}, Owner:${firstAccount}`); } catch (error) { console.error("Detailed error:"); console.error(error); // 检查特定错误 if (error.code === 'INVALID_ARGUMENT') { console.error("Contract deployment not found. Please ensure the contract is deployed to Sepolia."); } } }) module.exports = {}
为什么safeMint函数没有返回值,却可以赋值给 mintTx ?
在以太坊智能合约中,当你调用一个写入函数(比如 safeMint)时,它会返回一个 Transaction 对象,即使函数本身没有返回值。这是因为所有改变状态的操作都需要发送交易。
// 1. 调用 safeMint 函数会返回一个待处理的交易对象 const mintTx = await nft.safeMint(firstAccount) // mintTx 包含了交易的信息,例如: // { // hash: "0x...", // 交易哈希 // from: "0x...", // 发送者地址 // to: "0x...", // 合约地址 // nonce: 1, // 交易序号 // gasLimit: BigNumber, // gas 限制 // data: "0x...", // 调用数据 // value: BigNumber, // 发送的以太币数量 // ... // } // 2. wait(6) 等待 6 个区块确认 await mintTx.wait(6) // 返回交易收据 // 交易收据包含: // { // transactionHash: "0x...", // blockNumber: 123, // blockHash: "0x...", // status: 1, // 1 表示成功 // events: [...], // 包含事件日志 // ... // }
ethers.js 仍然会返回一个交易对象,这让我们可以:
- 获取交易哈希
- 等待交易确认
- 检查交易状态
- 获取事件日志
这是以太坊交易机制的一部分,所有状态改变都通过交易完成
为什么需要这样子写 const tokenId = tokenAmount - BigInt(1) ?
1n 是 JavaScript 中的 BigInt 字面量表示法。在以太坊开发中,我们经常需要处理大数字,特别是当与智能合约交互时。
合约返回的数字通常是 BigNumber 或 BigInt,BigInt 的范围是无限的,只受限于系统的内存
JavaScript 的普通数字(Number)只能安全表示到 2^53 - 1
与 BigInt 类型的数字运算时,必须使用 BigInt 类型
例子
// ❌ 错误:不能混合 BigInt 和 Number const tokenId = tokenAmount - 1 // TypeError // ✅ 正确:使用 BigInt const tokenId = tokenAmount - 1n // 正确 const tokenId = tokenAmount - BigInt(1) // 也正确 // 其他 BigInt 字面量例子 const a = 1n const b = 100n const c = 1000000000000000000n // 1 ETH 的 wei 值
check-nft
//引入task工具 const {task} = require("hardhat/config") task("check-nft").setAction(async(taskArgs,hre)=>{ const {firstAccount } = (await getNamedAccounts()).firstAccount; const nft = await hre.ethers.getContract("MyNFT",firstAccount) const totalSupply = await nft.totalSupply() console.log("check-nft status:") for(let tokenId=0; tokenId< totalSupply; tokenId++){ const owner = await nft.ownerOf(tokenId) console.log(`tokenId:${tokenId},owner:${owner}`) } }) module.exports = {}
lock-and-cross
const {task} = require("hardhat/config"); const { networkConfig } = require("../helper-hardhat-config"); const { networks } = require("../hardhat.config"); task("lock-and-cross") .addParam("tokenid", "tokenid to lock and cross") .addOptionalParam("chainselector", "chainSelector of destination chain") .addOptionalParam("receiver", "receiver in destination chain") .setAction(async(taskArgs, hre) => { //get tokenid const tokenId = taskArgs.tokenid //get deployer const {firstAccount} = await hre.getNamedAccounts(); console.log("deployer is:", firstAccount) //get chainSelector let destChainSelector if(taskArgs.chainselector){ destChainSelector = taskArgs.chainselector }else{ destChainSelector = networkConfig[hre.network.config.chainId].companionChainSelector } console.log("destination chainSelector is:", destChainSelector) //get receiver let destReceiver if(taskArgs.receiver){ destReceiver = taskArgs.receiver }else{ const nftBurnAndMint = await hre.companionNetworks["destChain"].deployments.get("NFTPoolBurnAndMint") destReceiver = nftBurnAndMint.address } console.log("destination receiver is:", destReceiver) //get link token const linkTokenAddr = networkConfig[hre.network.config.chainId].linkToken const linkToken = await hre.ethers.getContractAt("LinkToken", linkTokenAddr) console.log("link token is:", linkTokenAddr) //get nft pool const nftPoolLockAndRelease = await hre.ethers.getContract("NFTPoolLockAndRelease", firstAccount) console.log("nft pool is:", nftPoolLockAndRelease.target) //Transfer link token to nft pool const balanceBefore = await linkToken.balanceOf(nftPoolLockAndRelease.target) console.log("balance before is:", balanceBefore) const transferLinkTx = await linkToken.transfer(nftPoolLockAndRelease.target, hre.ethers.parseEther("0")) await transferLinkTx.wait(6) const balanceAfter = await linkToken.balanceOf(nftPoolLockAndRelease.target) console.log("balance after is:", balanceAfter) //get nft and approve const nft = await hre.ethers.getContract("MyNFT", firstAccount) await nft.approve(nftPoolLockAndRelease.target, tokenId) console.log("nft approved successfully") //lock nft console.log("locking nft...") console.log(`tokenId: ${tokenId}`, `owner: ${firstAccount}`, `destChainSelector: ${destChainSelector}`, `destReceiver: ${destReceiver}`) const lockAndCrossTx = await nftPoolLockAndRelease.lockAndSendNFT(tokenId, firstAccount , destChainSelector, destReceiver) await lockAndCrossTx.wait(6) console.log("nft locked and sent successfully") // provide the transaction hash console.log(`NFT locked and crossed, transaction hash is ${lockAndCrossTx.hash}`) //messageId console.log(`messageId is ${lockAndCrossTx.value}`) })
注意这里要使用 addOptionalParam,保证参数是可选项,而不是 addParam
companionNetworks["destChain"].deployments.get("NFTPoolBurnAndMint"),我们执行命令的时候会使用 network --sepolia 这样的参数,这个参数可以让 hardhat 识别我们config文件里面的网络配置
不管你的函数是否有返回值,根据以太坊的规则,都会有交易对象返回,比如这里
const lockAndCrossTx = await nftPoolLockAndRelease.lockAndSendNFT(tokenId, firstAccount , destChainSelector, destReceiver) await lockAndCrossTx.wait(6) console.log("nft locked and sent successfully") // provide the transaction hash console.log(`NFT locked and crossed, transaction hash is ${lockAndCrossTx.hash}`) //messageId console.log(`messageId is ${lockAndCrossTx.value}`)
lockAndSendNFT 是会返回一个 bytes32 类型的数据的,但是 lockAndCrossTx 并不是 bytes32 类型的数据,而是一个交易对象,交易对象类似json数据
const transferTx = await linkToken.transfer(...) // transferTx = { // hash: "0x...", // 交易哈希 // from: "0x...", // 发送者地址 // to: "0x...", // 接收者地址 // data: "0x...", // 交易数据 // ... // }
还有一点,这里是先返回对象再进行 wait ,wait() 需要交易对象才能监听确认
必须先有交易才能等待它的确认
check-wnft
const { task } = require("hardhat/config") task("check-wrapped-nft") .addParam("tokenid", "tokenid to check") .setAction(async(taskArgs, hre) => { const tokenId = taskArgs.tokenid const {firstAccount} = await getNamedAccounts() const wnft = await ethers.getContract("WrappedNFT", firstAccount) console.log("checking status of ERC-721") const totalSupply = await wnft.totalSupply() console.log(`there are ${totalSupply} tokens under the collection`) const owner = await wnft.ownerOf(tokenId) console.log(`TokenId: ${tokenId}, Owner is ${owner}`) }) module.exports = {}
burn-and-cross
const { task } = require("hardhat/config") const { networkConfig } = require("../helper-hardhat-config") task("burn-and-cross") .addParam("tokenid", "token id to be burned and crossed") .addOptionalParam("chainselector", "chain selector of destination chain") .addOptionalParam("receiver", "receiver in the destination chain") .setAction(async(taskArgs, hre) => { const { firstAccount } = await getNamedAccounts() // get token id from parameter const tokenId = taskArgs.tokenid const wnft = await ethers.getContract("WrappedNFT", firstAccount) const nftPoolBurnAndMint = await ethers.getContract("NFTPoolBurnAndMint", firstAccount) // approve the pool have the permision to transfer deployer's token const approveTx = await wnft.approve(nftPoolBurnAndMint.target, tokenId) await approveTx.wait(6) // transfer 10 LINK token from deployer to pool console.log("transfering 10 LINK token to NFTPoolBurnAndMint contract") const linkAddr = networkConfig[network.config.chainId].linkToken const linkToken = await ethers.getContractAt("LinkToken", linkAddr) const transferTx = await linkToken.transfer(nftPoolBurnAndMint.target, ethers.parseEther("0")) await transferTx.wait(6) // get chain selector let chainSelector if(taskArgs.chainselector) { chainSelector = taskArgs.chainselector } else { chainSelector = networkConfig[network.config.chainId].companionChainSelector } // get receiver let receiver if(taskArgs.receiver) { receiver = taskArgs.receiver } else { receiver = (await hre.companionNetworks["destChain"].deployments.get("NFTPoolLockAndRelease")).address } // burn and cross const burnAndCrossTx = await nftPoolBurnAndMint.BurnAndReturn(tokenId, firstAccount, chainSelector, receiver) console.log(`NFT burned and crossed with txhash ${burnAndCrossTx.hash}`) }) module.exports = {}
如需授权、对文章有疑问或需删除稿件,请联系 FreeBuf 客服小蜜蜂(微信:freebee1024)