使用 Chainlink CCIP 在 Kaia 上建立跨鏈 NFT:實用指南
介紹
NFT 已經成為區塊鏈技術最知名的用例之一,能夠創建獨一無二、可驗證的數位資產。 然而,傳統的 NFT 實作被綁定在單一區塊鏈上。 此限制降低了靈活性,並使資產無法在社群、流動性和效用可能不同的生態系統中自由移動。
跨鏈 NFT 可讓 NFT 在區塊鏈之間無縫移動,同時保留其唯一性和來源,從而解決了這一挑戰。 透過 Chainlink 的跨鏈互通協定 (CCIP),開發人員可以使用標準化、安全的訊息傳輸架構,在各鏈之間建立可靠的橋梁。
在本指南中,您將使用「燒機-造幣」模式建立並部署 Crosschain NFT。 NFT 會在來源鏈上燒錄,並在目的地鏈上以相同的 tokenId 和元資料重新製作,以確保在任何時候都只有一份有效的副本存在。
要求
開始之前,請確認您已完成下列設定:
- Node.js 和 npm
- 硬帽
- 安裝:安裝:
npm install --save-dev hardhat - 初始化專案:
npx hardhat --init
- 安裝:安裝:
- MetaMask 錢包
- 建立或設定開發錢包。
- 將 Kaia Kairos Testnet 和 Ethereum Sepolia 網路同時加入 MetaMask。
- 來自水龍頭的測試代幣
- KAIA: 部署契約或傳送交易時,在 Kaia 上支付瓦斯費。
- LINK (testnet):以 LINK 付款時涵蓋 CCIP 費用。
- Sepolia ETH: 支付 Sepolia 上的瓦斯費,如果選擇了,也可以支付原生 ETH 的 CCIP 費用。
- Filebase 帳戶
- 需要上傳和擷取 NFT 元資料 (IPFS 儲存)。
跨鏈 NFT 如何運作?
NFT 是記錄在單一區塊鏈上的唯一數位代幣。 其核心行為,包括鑄幣、轉讓和所有權,都是由與該鏈相連的智慧型契約所定義的。 正因如此,如果沒有額外的機制,NFT 就無法自然地在區塊鏈間移動。 為了實現互操作性,開發人員會在多個鏈上部署配套合約,並透過跨鏈訊息將其連結起來。 結果就是跨區塊鏈 NFT:存在於各區塊鏈上的等價代幣,但在任何特定時間只有一份是有效的。
跨鏈 NFT 通常以三種方式之一實現:
-
** 燒錄與鑄造**:在來源鏈上燒錄 NFT,然後在目的地鏈上鑄造等值的 NFT。
-
鎖定和鑄造:在來源鏈上鎖定 NFT,並在目的地鑄造複本。 返回時需要燒毀複製品以解鎖正本。
-
** 鎖定與解鎖**:相同的集合部署在多個鏈上。 擁有者鎖定一條鏈上的 NFT,以解鎖另一條鏈上的對應副本,確保一次只能使用一個副本。
在本指南中,我們將使用燃燒和薄荷模型來進行 Crosschain NFT。 NFT 將從一條鏈上移除,並在另一條鏈上重新建立,整個過程由 Chainlink CCIP 驅動。
開始使用
在本指南中,您將使用 Chainlink CCIP 在 Kaia Kairos Testnet 和 Ethereum Sepolia 之間製造和傳輸跨鏈 NFT。
到最後,您將能夠
- 初始化同時為 Kairos Testnet 和 Ethereum Sepolia 設定的 Hardhat 專案
- 新增 Chainlink CCIP 契約和介面為依賴項目
- 實施跨鏈 NFT 契約,並針對跨鏈轉移採用燒幣機制
- 將合約部署到兩個網路,並跨鏈傳送 NFT。
建立硬頭帽專案
在本教程中,我們將使用 Hardhat 3 來部署和互動我們的合約。 Hardhat 3 提供了新功能,例如本機支援加密的 keystores、在 Solidity 中撰寫測試的能力,以及改進的專案工具。
按照以下步驟設定專案:
-
驗證 Node.js 和 npm 安裝
執行下列指令以驗證 Node.js 和 npm 是否已安裝:
node -vnpm -v
-
初始化新的專案目錄
建立新資料夾,導覽到其中,並初始化 Node.js 專案:
mkdir ccip-nft-kaia-hardhat-example cd ccip-nft-kaia-hardhat-example npm init -y
-
建立硬帽專案
運行:
npx hardhat --init
出現提示時,請選擇包含 Node.js 測試 runner 和 ethers 的範例專案。 在目前目錄中初始化,並安裝所有需要的相依性。
安裝所需的合約
安裝 Chainlink CCIP 合約:
npm i @chainlink/contracts-ccip --save-dev
安裝標準 Chainlink 合約:
npm i @chainlink/contracts --save-dev
安裝 OpenZeppelin 契約 (提供 ERC-721 及其他基礎實作):
npm i @openzeppelin/contracts --save-dev
設定 NFT 元資料
在撰寫合約之前,讓我們先定義我們要鑄造的 NFT 的規格。 每個 NFT 都需要描述其名稱、描述和影像的元資料,儲存於 JSON 檔案中,並託管於 IPFS。
在本指南中,我們將使用 Filebase 來儲存影像和元資料。 如果您想要建立自己的 NFT,請透過 Filebase 將影像和 metadata JSON 檔案上傳至 IPFS。 上傳後,按一下檔案索引標籤中的檔案名稱,並複製 IPFS URL。 它看起來會與此相似:
https://disastrous-turquoise-parakeet.myfilebase.com/ipfs/QmY1LZF8JHo2r3h4X5VzLLXtJujqnBFGTyo2aqR9joXnt8
以下是您可以使用的範例 metadata 檔案:
{ "name": "Kairos NFT", "description": "gkaia frens! gazuaaaaa!!!", "image": "https://disastrous-turquoise-parakeet.myfilebase.com/ipfs/QmRvQc4wZCp6NF7dFL4ywiWTG7FSH3KKGUAkXGgsdYfcKi"}
撰寫智慧型契約
在本節中,您將使用由 Chainlink CCIP 驅動的燃燒-造幣模型實現合約,使 NFT 在區塊鏈之間轉移。
在專案的 contracts 目錄中建立新檔案,命名為 CrosschainNFT.sol,並將下列程式碼貼入其中:
// SPDX-License-Identifier: MITpragma solidity ^0.8.20;import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";import {ERC721URIStorage} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";import {ERC721Burnable} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721Burnable.sol";import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol";import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";import {Client} from "@chainlink/contracts-ccip/contracts/libraries/Client.sol";import {IRouterClient} from "@chainlink/contracts-ccip/contracts/interfaces/IRouterClient.sol";import {IAny2EVMMessageReceiver} from "@chainlink/contracts-ccip/contracts/interfaces/IAny2EVMMessageReceiver.sol";import {OwnerIsCreator} from "@chainlink/contracts/src/v0.8/shared/access/OwnerIsCreator.sol";import {LinkTokenInterface} from "@chainlink/contracts/src/v0.8/shared/interfaces/LinkTokenInterface.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. */ // Source chain is Ethereum Sepolia // Destination chain is Kairos Testnetcontract CrosschainNFT is ERC721, ERC721URIStorage, ERC721Burnable, IAny2EVMMessageReceiver, ReentrancyGuard, OwnerIsCreator { using SafeERC20 for IERC20; enum PayFeesIn { Native, LINK } error InvalidRouter(address router); error OnlyOnEthereumSepolia(); error NotEnoughBalanceForFees(uint256 currentBalance, uint256 calculatedFees); error NothingToWithdraw(); error FailedToWithdrawEth(address owner, address target, uint256 value); error ChainNotEnabled(uint64 chainSelector); error SenderNotEnabled(address sender); error OperationNotAllowedOnCurrentChain(uint64 chainSelector); struct crosschainNFTDetails { address crosschainNFTAddress; bytes ccipExtraArgsBytes; } uint256 constant ETHEREUM_SEPOLIA_CHAIN_ID = 11155111; string tokenNFTURI = "https://disastrous-turquoise-parakeet.myfilebase.com/ipfs/QmY1LZF8JHo2r3h4X5VzLLXtJujqnBFGTyo2aqR9joXnt8"; IRouterClient internal immutable i_ccipRouter; LinkTokenInterface internal immutable i_linkToken; uint64 private immutable i_currentChainSelector; uint256 private _nextTokenId; mapping(uint64 destChainSelector => crosschainNFTDetails crosschainNFTPerChain) public s_chains; event ChainEnabled(uint64 chainSelector, address xNftAddress, bytes ccipExtraArgs); event ChainDisabled(uint64 chainSelector); event CrossChainSent( address from, address to, uint256 tokenId, uint64 sourceChainSelector, uint64 destinationChainSelector ); event CrossChainReceived( address from, address to, uint256 tokenId, uint64 sourceChainSelector, uint64 destinationChainSelector ); modifier onlyRouter() { if (msg.sender != address(i_ccipRouter)) { revert InvalidRouter(msg.sender); } _; } modifier onlyOnEthereumSepolia() { if (block.chainid != ETHEREUM_SEPOLIA_CHAIN_ID) { revert OnlyOnEthereumSepolia(); } _; } modifier onlyEnabledChain(uint64 _chainSelector) { if (s_chains[_chainSelector].crosschainNFTAddress == address(0)) { revert ChainNotEnabled(_chainSelector); } _; } modifier onlyEnabledSender(uint64 _chainSelector, address _sender) { if (s_chains[_chainSelector].crosschainNFTAddress != _sender) { revert SenderNotEnabled(_sender); } _; } modifier onlyOtherChains(uint64 _chainSelector) { if (_chainSelector == i_currentChainSelector) { revert OperationNotAllowedOnCurrentChain(_chainSelector); } _; } constructor(address ccipRouterAddress, address linkTokenAddress, uint64 currentChainSelector) ERC721("Cross Chain NFT", "XNFT") { if (ccipRouterAddress == address(0)) revert InvalidRouter(address(0)); i_ccipRouter = IRouterClient(ccipRouterAddress); i_linkToken = LinkTokenInterface(linkTokenAddress); i_currentChainSelector = currentChainSelector; } function mint() external onlyOnEthereumSepolia { uint256 tokenId = _nextTokenId++; _safeMint(msg.sender, tokenId); _setTokenURI(tokenId, tokenNFTURI); } function enableChain(uint64 chainSelector, address crosschainNFTAddress, bytes memory ccipExtraArgs) external onlyOwner onlyOtherChains(chainSelector) { s_chains[chainSelector] = crosschainNFTDetails({crosschainNFTAddress: crosschainNFTAddress, ccipExtraArgsBytes: ccipExtraArgs}); emit ChainEnabled(chainSelector, crosschainNFTAddress, ccipExtraArgs); } function disableChain(uint64 chainSelector) external onlyOwner onlyOtherChains(chainSelector) { delete s_chains[chainSelector]; emit ChainDisabled(chainSelector); } function crossChainTransferFrom( address from, address to, uint256 tokenId, uint64 destinationChainSelector, PayFeesIn payFeesIn ) external nonReentrant onlyEnabledChain(destinationChainSelector) returns (bytes32 messageId) { string memory tokenUri = tokenURI(tokenId); _burn(tokenId); Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({ receiver: abi.encode(s_chains[destinationChainSelector].crosschainNFTAddress), data: abi.encode(from, to, tokenId, tokenUri), tokenAmounts: new Client.EVMTokenAmount[](0), extraArgs: s_chains[destinationChainSelector].ccipExtraArgsBytes, feeToken: payFeesIn == PayFeesIn.LINK ? address(i_linkToken) : address(0) }); // Get the fee required to send the CCIP message uint256 fees = i_ccipRouter.getFee(destinationChainSelector, message); if (payFeesIn == PayFeesIn.LINK) { if (fees > i_linkToken.balanceOf(address(this))) { revert NotEnoughBalanceForFees(i_linkToken.balanceOf(address(this)), fees); } // Approve the Router to transfer LINK tokens on contract's behalf. It will spend the fees in LINK i_linkToken.approve(address(i_ccipRouter), fees); // Send the message through the router and store the returned message ID messageId = i_ccipRouter.ccipSend(destinationChainSelector, message); } else { if (fees > address(this).balance) { revert NotEnoughBalanceForFees(address(this).balance, fees); } // Send the message through the router and store the returned message ID messageId = i_ccipRouter.ccipSend{value: fees}(destinationChainSelector, message); } emit CrossChainSent(from, to, tokenId, i_currentChainSelector, destinationChainSelector); } /// @inheritdoc IAny2EVMMessageReceiver function ccipReceive(Client.Any2EVMMessage calldata message) external virtual override onlyRouter nonReentrant onlyEnabledChain(message.sourceChainSelector) onlyEnabledSender(message.sourceChainSelector, abi.decode(message.sender, (address))) { uint64 sourceChainSelector = message.sourceChainSelector; (address from, address to, uint256 tokenId, string memory tokenUri) = abi.decode(message.data, (address, address, uint256, string)); _safeMint(to, tokenId); _setTokenURI(tokenId, tokenUri); emit CrossChainReceived(from, to, tokenId, sourceChainSelector, i_currentChainSelector); } function withdraw(address _beneficiary) public onlyOwner { uint256 amount = address(this).balance; if (amount == 0) revert NothingToWithdraw(); (bool sent,) = _beneficiary.call{value: amount}(""); if (!sent) revert FailedToWithdrawEth(msg.sender, _beneficiary, amount); } function withdrawToken(address _beneficiary, address _token) public onlyOwner { uint256 amount = IERC20(_token).balanceOf(address(this)); if (amount == 0) revert NothingToWithdraw(); IERC20(_token).safeTransfer(_beneficiary, amount); } function tokenURI(uint256 tokenId) public view override(ERC721, ERC721URIStorage) returns (string memory) { return super.tokenURI(tokenId); } function getCCIPRouter() public view returns (address) { return address(i_ccipRouter); } function supportsInterface(bytes4 interfaceId) public view override(ERC721, ERC721URIStorage) returns (bool) { return interfaceId == type(IAny2EVMMessageReceiver).interfaceId || super.supportsInterface(interfaceId); }}
代碼演練
CrosschainNFT 是一種 ERC-721 契約,整合 Chainlink CCIP 以在區塊鏈之間轉移 NFT。 它會在來源鏈上燒錄 NFT,並使用相同的 tokenId 和 tokenURI 在目的地上重新燒錄。 該合約透過 enableChain 維護已核准目的鏈的註冊表,依賴 Chainlink Router (IRouterClient) 進行跨鏈訊息傳輸,並支援以原生瓦斯代幣或 LINK 支付費用。
主要功能
- 啟用鏈
允許合約擁有者註冊目標區塊鏈。 它會在 s_chains 映射中儲存對應的 NFT 合約位址和 CCIP 參數,將鏈白名單列為有效的傳輸目標。 當設定完成時,會發出 ChainEnabled 事件。
- CrossChainTransferFrom
執行 NFT 跨鏈傳輸。 它首先檢查目的地鏈是否啟用,然後擷取 NFT 元資料 (tokenURI),並在來源鏈上燒錄令牌。 接下來,它會建立一個包含轉帳詳細資訊的 CCIP 訊息,計算所需費用,並以 LINK 或本地瓦斯支付。 一旦訊息透過路由器傳送,就會發佈 CrossChainSent 事件來記錄傳輸。
現在,CrosschainNFT.sol 的核心流程已經清楚,讓我們進入下一步。
編譯智慧型契約
要編譯您的智慧型契約,請執行
npx hardhat build
部署智慧契約
在本節中,我們將設定必要的變數,然後在 Ethereum Sepolia(源鏈)和 Kairos Testnet(目的鏈)上部署「CrosschainNFT.sol」契約。
使用加密金鑰庫
Hardhat 3 的優點之一,是能夠將私鑰和 RPC URL 等敏感值儲存在加密的 keystore 中,而非純文字檔案中。 在本指南中,我們將為 Sepolia 和 Kairos 的 PRIVATE_KEY 和 RPC URL 加密。
** 新增您的私人密碼匙**
npx hardhat keystore set PRIVATE_KEY
第一次執行此指令時,Hardhat 會提示您為 keystore 建立密碼。 每次新增或更新值時,您都需要這個密碼。
** 為每個網路加入 RPC URL**
npx hardhat keystore set KAIROS_RPC_URLnpx hardhat keystore set SEPOLIA_RPC_URL