freeBuf
主站

分类

漏洞 工具 极客 Web安全 系统安全 网络安全 无线安全 设备/客户端安全 数据安全 安全管理 企业安全 工控安全

特色

头条 人物志 活动 视频 观点 招聘 报告 资讯 区块链安全 标准与合规 容器安全 公开课

点我创作

试试在FreeBuf发布您的第一篇文章 让安全圈留下您的足迹
我知道了

官方公众号企业安全新浪微博

FreeBuf.COM网络安全行业门户,每日发布专业的安全资讯、技术剖析。

FreeBuf+小程序

FreeBuf+小程序

Chainlink--CCIP--NFT 讲解
浪迹陨灭nd 2025-03-06 09:47:57 20944
所属地 江苏省

ccip主要组件

三个领域


源链 (Source Chain) ├── Sender (发送方合约) ├── Router (路由合约) └── Token Pool (代币池) 目标链 (Destination Chain) ├── Receiver (接收方合约) ├── Router (路由合约) └── Token Pool (代币池) 链下部分 (Offchain) ├── Committing DON (提交DON网络) ├── Executing DON (执行DON网络) └── RMN (风险管理网络)

工作流程

image-20250118090921151.png

概括CCIP的三个部分的完整流程:

  1. 源链(Source Chain)

    用户 → Sender合约 → Router → OnRamp → Token Pool

    具体流程:

    1. 用户调用Sender合约发起跨链请求
    2. Router验证请求并计算费用
    3. OnRamp准备跨链数据
    4. Token Pool锁定用户代币
    5. 生成消息证明并等待DON处理
    6. 链下(Off-chain)
  2. 链下(Off-chain)

    Committing DON → RMN → Executing DON

    具体流程:

    1. Committing DON监听并验证源链消息
    2. 构建merkle树和收集DON签名
    3. RMN进行风险评估和安全检查
    4. 通过后,Executing DON准备目标链执行数据
    5. 将执行包发送到目标链
    6. 目标链(Destination Chain)
  3. 目标链(Destination Chain)

    OffRamp → Token Pool → Router → 接收方合约

    具体流程:

    1. OffRamp接收并验证DON发来的执行包
    2. Token Pool释放对应代币给接收方
    3. Router将消息路由到接收方合约
    4. 接收方合约执行最终的业务逻辑

源链

  1. 发起跨链请求:

    用户使用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

链下

  1. 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证明
    • 准跨链数据包
  2. 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); } }

    通道状态管理:

    • 检查源链-目标链通道状态
    • 验证通道容量和限额
    • 监控通道拥堵情况
    • 更新通道统计数据

    流量控制:

    • 管理消息队列
    • 控制处理速率
    • 优化资源分配
    • 负载均衡
  3. 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); } } }

    价格数据服务:

    • 收集多源价格数据
    • 过滤异常价格
    • 计算加权平均价
    • 提供实时价格更新

    价格保护机制:

    • 监控价格波动
    • 设置价格偏差阈值
    • 触发价格保护
    • 更新价格预警
  4. 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状态
    • 监控资金流向
    • 预警异常提现
  5. 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节点投票
    • 收集执行签名
    • 达成执行共识
    • 确认执行决策

    执行触发:

    • 准备目标链交易
    • 构建执行参数
    • 发送执行指令
    • 监控执行状态
  6. 跨链消息传递流程:

    消息封装:

    • 源链交易信息
    • 代币转移数据
    • 执行指令
    • 证明数据

    验证层级:

    1. 基础验证

      • 格式检查
      • 参数验证
      • 签名确认
    2. 共识验证

      • DON节点验证
      • 多重签名
      • 阈值确认
    3. 风险验证

      • RMN评估
      • 安全检查
      • 异常识别
  7. 监控和优化系统:


    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); } } }

    性能监控:

    • 节点响应时间
    • 网络延迟
    • 处理队列状态
    • 资源使用率

    数据分析:

    • 交易模式分析
    • 风险模型更新
    • 性能瓶颈识别
    • 优化建议生成

    系统调优:

    • 动态参数调整
    • 资源分配优化
    • 处理策略更新
    • 性能优化
  8. 应急响应机制:


    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(); } }

    异常处理:

    • 检测异常情况
    • 触发应急预案
    • 执行恢复流程
    • 记录事件日志

    故障恢复:

    • 节点故障切换
    • 数据同步修复
    • 状态一致性检查
    • 服务恢复确认

目标链

  1. 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'); } } }
  2. 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); } }
  3. 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); } } }
  4. 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; } }
  5. 接收方合约接口:

    这是最终接收和处理跨链消息的合约,需要实现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); } }
  6. 权限控制合约:


    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

image-20250119151854398.png

源链发送消息

原始模型


// 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
  1. 传入参数


    function lockAndSendNFT( uint256 tokenId, address newOwner, uint64 chainSelector, address receiver) public{ //transfer }
  2. 先将NFT锁入当前合约并检查

    • 先创建一个nft的实例,前面我们已经创建了好了一个MyNFT的合约


      import {MyNFT} from "./MyNFT.sol";

      MyNFT public nft;//第一步先实例化一个nft对象,同时需要在构造函数中初始化

      之后将自己拥有的NFT转移到当前的合约中


      nft.transferFrom(msg.sender,address(this),tokenId);//从持有人地址转入当前地址

      发送跨链消息,首先两个主要函数起到主要作用

      1. 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; }
      2. _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

image-20250119152806603.png

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 }
部署脚本更改
  1. 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"

  2. 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 的配置中去获取的

  3. 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") // 自动包含所有任务
任务脚本
  1. 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 仍然会返回一个交易对象,这让我们可以:

      1. 获取交易哈希
      2. 等待交易确认
      3. 检查交易状态
      4. 获取事件日志

      这是以太坊交易机制的一部分,所有状态改变都通过交易完成

    • 为什么需要这样子写 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 值
  2. 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 = {}
  3. 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() 需要交易对象才能监听确认

      必须先有交易才能等待它的确认

  4. 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 = {}
  5. 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 = {}
# 区块链 # 区块链安全 # 区块链技术 # 区块链应用 # 区块链开发
本文为 浪迹陨灭nd 独立观点,未经授权禁止转载。
如需授权、对文章有疑问或需删除稿件,请联系 FreeBuf 客服小蜜蜂(微信:freebee1024)
被以下专辑收录,发现更多精彩内容
+ 收入我的专辑
+ 加入我的收藏
浪迹陨灭nd LV.4
专注于solidity智能合约开发
  • 6 文章数
  • 0 关注者
稳定币项目构建 (二)
2025-03-06
稳定币项目构建 (一)
2025-03-05
从小白角度看区块链基础
2025-03-05
文章目录