Chainlink CCIP를 활용하여 Kaia에서 크로스체인 NFT 구현하기

소개

Chainlink CCIP를 활용하여 Kaia에서 크로스체인 NFT 구현하기

소개

NFT(Non-Fungible Token, 대체 불가능 토큰)는 검증 가능한 고유의 디지털 자산을 생성하는 수단으로서, 블록체인 기술의 가장 대표적인 활용 사례 중 하나로 자리매김했습니다. 그러나 전통적인 NFT은 단일 블록체인에서만 작동하도록 구현되어 있습니다. 이러한 제약 때문에 커뮤니티도 다르고, 유동성도 활용도도 천차만별인 블록체인 생태계 간의 자산의 자유로운 이동이 어렵습니다.

이에 NFT 본연의 고유성과 출처(provenance)를 유지하면서도 여러 블록체인을 자유로이 옮겨다닐 수 있는 크로스체인 NFT가 해결책으로 등장합니다. Chainlink의 크로스체인 상호운용성 프로토콜(CCIP)을 통해 개발자는 표준화된 안전한 메시징 프레임워크를 활용해 체인 간 신뢰할 수 있는 브릿지를 구축할 수 있습니다.

본 가이드에서는 소각 및 발행(burn-and-mint) 모델을 사용한 크로스체인 NFT를 구축하고 배포할 것입니다. NFT는 소스 체인에서 소각된 후, 동일한 tokenId 및 메타데이터로 대상 체인에서 재발행되어, 어느 시점에서건 유효한 사본은 하나만 존재하도록 보장합니다.

준비물

시작 전에 다음 설정이 완료되었는지 확인하십시오.

  • Node.jsnpm
  • Hardhat
    – 설치: npm install — save-dev hardhat
    – 프로젝트 초기화: npx hardhat — init
  • MetaMask 월렛
    – 개발용 월렛 생성 또는 설정.
    – MetaMask에 Kaia의 Kairos 테스트넷과 Ethereum의 Sepolia 네트워크 모두 추가.
  • 테스트용 토큰 확보
    KAIA: 컨트랙트 배포 또는 트랜잭션 전송 시 Kaia의 가스 수수료 지불.
    LINK (테스트넷): LINK로 결제 시 CCIP 수수료 충당.
    Sepolia ETH: Sepolia에서 가스 수수료를 지불하며, 선택 시 네이티브 ETH로 CCIP 수수료도 충당 가능.
  • Filebase 계정
    – NFT 메타데이터 업로드 및 검색에 필요(IPFS 스토리지).

크로스체인 NFT는 어떻게 작동하나?

NFT는 단일 블록체인에 기록된 고유한 디지털 토큰입니다. 발행, 전송, 소유권 등 핵심 동작은 해당 체인에 연결된 스마트 컨트랙트에 의해 정의됩니다. 이로 인해 NFT는 추가 메커니즘 없이는 자연스럽게 블록체인 간 이동할 수 없습니다. 상호 운용성을 위해 개발자는 여러 체인에 동반 컨트랙트를 배포하고 크로스체인 메시징을 통해 연결합니다. 그 결과 크로스체인 NFT가 탄생합니다. 즉, 어느 블록체인에 있건 동등한 토큰이지만, 어느 시점에서건 활성 상태인 사본은 하나뿐인 토큰입니다.

크로스체인 NFT는 일반적으로 다음 세 가지 방식 중 하나로 구현됩니다.

  • 소각 및 발행(Burn and mint): 소스 체인에서 NFT를 소각한 후, 대상 체인에서 동등한 토큰을 발행합니다.
  • 잠금 및 발행(Lock and mint): NFT를 소스 체인에서 잠그고, 대상 체인에 복제본을 발행합니다. 복원하려면 복제본을 소각하여 원본을 잠금 해제해야 합니다.
  • 잠금 및 잠금 해제(Lock and unlock): 동일한 컬렉션을 여러 체인에 배포합니다. 소유자가 한 체인에서 NFT를 잠그면 다른 체인의 대응 NFT가 잠금 해제되어 한 번에 하나의 사본만 사용 가능하도록 보장합니다.

본 가이드에서는 소각 및 발행 모델을 적용할 것입니다. NFT는 한 체인에서 삭제된 후 다른 체인에서 재발행되며, 전체 프로세스는 Chainlink CCIP로 구동됩니다.

시작하기

본 가이드에서는 Chainlink CCIP를 활용하여 Kaia Kairos 테스트넷과 Ethereum Sepolia 간 크로스체인 NFT를 발행 및 전송하는 방법을 다룹니다. 수행할 작업들은 다음과 같습니다.

  • Kairos 테스트넷과 Ethereum Sepolia 각각에 대한 Hardhat 프로젝트 초기화
  • Chainlink CCIP 컨트랙트 및 인터페이스를 종속성으로 추가
  • 크로스체인 전송을 위해 소각-발행 메커니즘을 갖춘 크로스체인 NFT 컨트랙트 구현
  • 컨트랙트를 양 네트워크에 배포하고 체인 간 NFT 전송

Hardhat 프로젝트 생성

이 튜토리얼에서는 Hardhat 3를 사용하여 컨트랙트를 배포하고 상호작용할 것입니다. Hardhat 3은 암호화된 키스토어에 대한 네이티브 지원, Solidity로 테스트 컨트랙트 작성, 개선된 프로젝트 툴링 등 새로운 기능을 제공합니다.

아래 단계를 따라 프로젝트를 설정합니다.

  1. Node.js 및 npm 설치 확인 다음 명령어를 실행하여 Node.js와 npm이 설치되었는지 확인합니다.
node -v 
npm -v
  1. 새 프로젝트 디렉터리 초기화

새 폴더를 생성하고 해당 폴더로 이동한 후 Node.js 프로젝트를 초기화합니다.

mkdir ccip-nft-kaia-hardhat-example 
cd ccip-nft-kaia-hardhat-example 
npm init -y
  1. Hardhat 프로젝트 생성

다음 명령 실행합니다.

npx hardhat - init

프롬프트가 표시되면 Node.js 테스트 실행 로직 및 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를 생성하려면 이미지를 업로드하고 메타데이터 JSON 파일을 Filebase를 통해 IPFS에 업로드합니다. 업로드 후 ‘파일’ 탭에서 파일 이름을 클릭하고 IPFS URL을 복사합니다. 다음과 유사하게 표시됩니다.

https://disastrous-turquoise-parakeet.myfilebase.com/ipfs/QmY1LZF8JHo2r3h4X5VzLLXtJujqnBFGTyo2aqR9joXnt8

사용 가능한 메타데이터 파일 샘플은 다음과 같습니다.

{ 
    "name": "Kairos NFT", 
    "description": "gkaia frens! gazuaaaaa!!!", 
    "image": "https://disastrous-turquoise-parakeet.myfilebase.com/ipfs/QmRvQc4wZCp6NF7dFL4ywiWTG7FSH3KKGUAkXGgsdYfcKi" 
}

스마트 컨트랙트 작성

이 섹션에서는 Chainlink CCIP 기반의 소각 및 발행(burn-and-mint) 모델을 사용하여 블록체인 간 NFT 전송을 가능하게 하는 컨트랙트를 구현합니다.

프로젝트의 contracts 디렉터리에 *CrosschainNFT.sol*이라는 새 파일을 생성하고 다음 코드를 붙여넣습니다.

// SPDX-License-Identifier: MIT 
pragma 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 Testnet 
contract 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는 블록체인 간 NFT 전송을 위해 Chainlink CCIP를 통합한 ERC-721 컨트랙트입니다. 소스 체인의 NFT를 소각하고 동일한 tokenId 및 tokenURI로 대상 체인에 재발행합니다. 이 컨트랙트는 enableChain을 통해 승인된 대상 체인의 레지스트리를 유지하며, 크로스체인 메시징을 위해 Chainlink 라우터(IRouterClient)에 의존하고, 네이티브 가스 토큰 또는 LINK로 수수료 결제를 지원합니다.

주요 기능

  • enableChain
    컨트랙트 소유자가 대상 블록체인을 등록할 수 있게 합니다. 해당 NFT 컨트랙트 주소와 CCIP 인수를 s_chains 매핑에 저장하여 해당 체인을 유효한 전송 대상으로 화이트리스트에 등록합니다. 설정 완료 시 ChainEnabled 이벤트를 발생시킵니다.
  • crossChainTransferFrom
    체인 간 NFT 전송을 실행합니다. 먼저 대상 체인이 활성화되었는지 확인한 후, NFT 메타데이터(tokenURI)를 가져오고 소스 체인에서 토큰을 소각합니다. 다음으로 전송 세부 정보를 포함한 CCIP 메시지를 생성하고, 필요한 수수료를 계산한 후 LINK 또는 네이티브 가스로 지불합니다. 라우터를 통해 메시지가 전송되면 전송을 기록하기 위해 CrossChainSent 이벤트가 발생됩니다.

이제 CrosschainNFT.sol의 핵심 흐름이 명확해졌으니 다음 단계로 넘어가겠습니다.

스마트 컨트랙트 컴파일

다음 명령을 실행하여 스마트 컨트랙트를 컴파일합니다.

npx hardhat build

스마트 컨트랙트 배포

이 섹션에서는 필요한 변수를 설정한 후 CrosschainNFT.sol 컨트랙트을 Ethereum Sepolia(소스 체인)와 Kairos 테스트넷(대상 체인)에 배포합니다.

암호화된 키스토어 사용

Hardhat 3의 장점 중 하나는 개인 키나 RPC URL 같은 민감한 값을 일반 텍스트 파일이 아닌 암호화된 키스토어에 저장할 수 있다는 점입니다. 본 가이드에서는 Sepolia와 Kairos용 PRIVATE_KEYRPC URL을 암호화할 것입니다.

개인 키 추가

npx hardhat keystore set PRIVATE_KEY

이 명령어를 처음 실행하면 Hardhat이 키스토어용 비밀번호 생성을 요청합니다. 값을 추가하거나 업데이트할 때마다 이 비밀번호가 필요합니다.

네트워크별 RPC URL 추가

npx hardhat keystore set KAIROS_RPC_URL 
npx hardhat keystore set SEPOLIA_RPC_URL

마지막으로, hardhat.config.ts 파일을 편집하여 암호화된 값을 불러오고 두 네트워크를 구성합니다.

import type { HardhatUserConfig } from "hardhat/config"; 
import hardhatToolboxMochaEthersPlugin from "@nomicfoundation/hardhat-toolbox-mocha-ethers"; 
import { configVariable } from "hardhat/config"; 
const config: HardhatUserConfig = { 
  plugins: [hardhatToolboxMochaEthersPlugin], 
  solidity: { 
    profiles: { 
      default: { 
        version: "0.8.28", 
      }, 
      production: { 
        version: "0.8.28", 
        settings: { 
          optimizer: { 
            enabled: true, 
            runs: 200, 
          }, 
        }, 
      }, 
    }, 
  }, 
  networks: { 
    hardhatMainnet: { 
      type: "edr-simulated", 
      chainType: "l1", 
    }, 
    hardhatOp: { 
      type: "edr-simulated", 
      chainType: "op", 
    }, 
    kairosTestnet: { 
      type: "http", 
      chainType: "l1", 
      url: configVariable("KAIROS_RPC_URL"), 
      accounts: [configVariable("PRIVATE_KEY")], 
    }, 
    ethereumSepolia: { 
      type: "http", 
      chainType: "l1", 
      url: configVariable("SEPOLIA_RPC_URL"), 
      accounts: [configVariable("PRIVATE_KEY")], 
    }, 
  }, 
}; 
export default config;

다음 단계는 CrosschainNFT 스마트 컨트랙트를 각각 Ethereum Sepolia와 Kairos 테스트넷(Kairos Testnet)에 배포하는 것입니다.

CrosschainNFT.sol을 Ethereum Sepolia에 배포하기

배포 전에 Chainlink CCIP 디렉터리에서 Ethereum Sepolia에 대한 다음 값을 가져옵니다.

  • 체인 선택기
  • CCIP 라우터 주소
  • LINK 토큰 주소

이 값들은 배포 스크립트에서 필요할 것입니다. 다음으로, 프로젝트의 ignition/modules 폴더로 이동하여 deployEthereumSepolia.ts라는 새 파일을 생성하고 다음 코드를 붙여넣습니다.

// This setup uses Hardhat Ignition to manage smart contract deployments. 
// Learn more about it at https://hardhat.org/ignition 
import { buildModule } from "@nomicfoundation/hardhat-ignition/modules"; 
const ETHEREUM_SEPOLIA_ROUTER_ADDRESS = `0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59`; 
const ETHEREUM_SEPOLIA_LINK_TOKEN_ADDRESS = `0x779877A7B0D9E8603169DdbD7836e478b4624789`; 
const ETHEREUM_SEPOLIA_CHAIN_SELECTOR = `16015286601757825753`; 
const CrosschainNFTSepoliaModule = buildModule("CrosschainNFTSepoliaModule", (m) => { 
  const crosschainNFTSepolia = m.contract("CrosschainNFT", [ETHEREUM_SEPOLIA_ROUTER_ADDRESS, ETHEREUM_SEPOLIA_LINK_TOKEN_ADDRESS, ETHEREUM_SEPOLIA_CHAIN_SELECTOR], { 
  }); 
  return { crosschainNFTSepolia }; 
}); 
export default CrosschainNFTSepoliaModule;

배포 스크립트를 실행합니다.

npx hardhat ignition deploy ignition/modules/deployEthereumSepolia.ts - network ethereumSepolia

CrosschainNFT.sol을 Kairos 테스트넷에 배포하기

배포 전에 Chainlink CCIP 디렉토리에서 Kairos 테스트넷에 대한 다음 값을 확인합니다.

  • 체인 선택기
  • CCIP 라우터 주소
  • LINK 토큰 주소

이 값들은 배포 스크립트에서 필요합니다. 다음으로, 프로젝트의 ignition/modules 폴더로 이동하여 deployKairosTestnet.ts라는 새 파일을 생성하고 다음 코드를 붙여넣습니다.

// This setup uses Hardhat Ignition to manage smart contract deployments. 
// Learn more about it at https://hardhat.org/ignition 
import { buildModule } from "@nomicfoundation/hardhat-ignition/modules"; 
const KAIROS_TESTNET_ROUTER_ADDRESS = `0x41477416677843fCE577748D2e762B6638492755`; 
const KAIROS_TESTNET_LINK_TOKEN_ADDRESS = `0xAF3243f975afe2269Da8Ffa835CA3A8F8B6A5A36`; 
const KAIROS_TESTNET_CHAIN_SELECTOR = `2624132734533621656`; 
const CrosschainNFTKairosModule = buildModule("CrosschainNFTKairosModule", (m) => { 
  const crosschainNFTKairos = m.contract("CrosschainNFT", [KAIROS_TESTNET_ROUTER_ADDRESS, KAIROS_TESTNET_LINK_TOKEN_ADDRESS, KAIROS_TESTNET_CHAIN_SELECTOR], { 
  }); 
  return { crosschainNFTKairos }; 
}); 
export default CrosschainNFTKairosModule;

배포 스크립트를 실행합니다.

npx hardhat ignition deploy ignition/modules/deployKairosTestnet.ts - network kairosTestnet

스마트 컨트랙트와 상호작용

이 섹션에서는 배포된 CrosschainNFT 스마트 컨트랙트와 상호작용하며 enableChain, mint 및 crosschainTransfer 함수를 각각 실행해 보겠습니다.

1단계: Ethereum Sepolia에서 enableChain 호출

enableChain 호출 전에 다음 값을 준비합니다.

  • Sepolia 컨트랙트 주소: Ethereum Sepolia에 배포된 CrosschainNFT.sol 컨트랙트의 주소
  • Kairos 컨트랙트 주소: Kairos 테스트넷에 배포된 CrosschainNFT.sol 컨트랙트의 주소
  • 체인 선택기: 2624132734533621656 (Kairos 테스트넷용 CCIP 체인 선택기)
  • CCIP extraArgs: 0x97a657c9000000000000000000000000000000000000000000000000000000000007A120 (가스 제한이 500,000으로 설정된 extraArgs의 기본 인코딩 값입니다)

다음으로, scripts 폴더에 새 TypeScript 파일을 생성하고 이름을 enableChainSepolia.ts로 지정합니다. 다음 코드를 해당 파일에 붙여넣습니다.

// scripts/enableChainSepolia.ts 
import { network } from "hardhat"; 
async function main() { 
  const connection = await network.connect({ 
    network: "ethereumSepolia" 
  }); 
  const { ethers } = connection; 
  const [signer] = await ethers.getSigners(); 
  console.log(`Using account: ${signer.address}`); 
  // Get the contract factory by name 
  const CrosschainNFT = await ethers.getContractFactory("CrosschainNFT", signer); 
  // Contract addresses and parameters 
  const crosschainNFTAddressEthereumSepolia = `0xb1fe42BBd7842703820C7480c22409b872319B22`; 
  const crosschainNFTAddressKairosTestnet = `0x8c464Bb9Bf364F68b898ed0708b8f5F66EF6Cfb1`; 
  const chainSelectorKairosTestnet = `2624132734533621656`; 
  const ccipExtraArgs = `0x97a657c9000000000000000000000000000000000000000000000000000000000007A120`; 
  // Attach to the deployed contract 
  const crosschainNFTSepolia = CrosschainNFT.attach(crosschainNFTAddressEthereumSepolia); 
  console.log(`Enabling chain for Kairos Testnet...`); 
  const tx = await crosschainNFTSepolia.enableChain( 
    chainSelectorKairosTestnet, 
    crosschainNFTAddressKairosTestnet, 
    ccipExtraArgs 
  ); 
  console.log(`Transaction hash: ${tx.hash}`); 
  console.log(`Waiting for confirmation...`); 
  const receipt = await tx.wait(); 
   
  console.log(`Transaction confirmed in block: ${receipt?.blockNumber}`); 
  console.log(`Chain enabled successfully!`); 
} 
main().catch((error) => { 
  console.error(error); 
  process.exitCode = 1; 
});

다음 명령어를 실행하여 함수를 호출합니다.

npx hardhat run scripts/enableChainSepolia.ts - network ethereumSepolia

2단계: Kairos 테스트넷에서 enableChain 호출

enableChain 호출 전 다음 값을 준비합니다.

  • Kairos 컨트랙트 주소: Kairos 테스트넷에 배포된 CrosschainNFT.sol 컨트랙트 주소
  • Sepolia 컨트랙트 주소: Ethereum Sepolia에 배포된 CrosschainNFT.sol 컨트랙트의 주소
  • 체인 선택기: 16015286601757825753 (Kairos 테스트넷용 CCIP 체인 선택기)
  • CCIP extraArgs: 0x97a657c9000000000000000000000000000000000000000000000000000000000007A120 (가스 제한이 500,000으로 설정된 extraArgs의 기본 인코딩 값입니다)

다음으로, scripts 폴더에 새 TypeScript 파일을 생성하고 이름을 enableChainKairos.ts로 지정합니다. 그리고 다음 코드를 붙여넣습니다.

// scripts/enableChainKairos.ts 
import { network } from "hardhat"; 
async function main() { 
  const connection = await network.connect({ 
    network: "kairosTestnet" 
  }); 
  const { ethers } = connection; 
  const [signer] = await ethers.getSigners(); 
  console.log(`Using account: ${signer.address}`); 
  // Get the contract factory by name 
  const CrosschainNFT = await ethers.getContractFactory("CrosschainNFT", signer); 
  // Contract addresses and parameters  
  const crosschainNFTAddressKairosTestnet = `0x8c464Bb9Bf364F68b898ed0708b8f5F66EF6Cfb1`; 
  const crosschainNFTAddressEthereumSepolia = `0xb1fe42BBd7842703820C7480c22409b872319B22`; 
  const chainSelectorEthereumSepolia = `16015286601757825753`; 
  const ccipExtraArgs = `0x97a657c9000000000000000000000000000000000000000000000000000000000007A120`; 
  // Attach to the deployed contract on Kairos 
  const crosschainNFTKairos = CrosschainNFT.attach(crosschainNFTAddressKairosTestnet); 
  console.log(`Enabling chain for Ethereum Sepolia...`); 
  const tx = await crosschainNFTKairos.enableChain( 
    chainSelectorEthereumSepolia,          
    crosschainNFTAddressEthereumSepolia,     
    ccipExtraArgs                  
  ); 
  console.log(`Transaction hash: ${tx.hash}`); 
  console.log(`Waiting for confirmation...`); 
  const receipt = await tx.wait(); 
   
  console.log(`Transaction confirmed in block: ${receipt?.blockNumber}`); 
  console.log(`Chain enabled successfully!`); 
} 
main().catch((error) => { 
  console.error(error); 
  process.exitCode = 1; 
});

다음 명령어를 실행하여 함수를 호출합니다.

npx hardhat run scripts/enableChainKairos.ts - network KairosTestnet

3단계: Ethereum Sepolia에서 LINK로 컨트랙트 자금 조달

CCIP 수수료를 충당하기 위해 Ethereum Sepolia에 배포된 CrosschainNFT 컨트랙트(crosschainNFTAddressEthereumSepolia)에 LINK를 입금합니다. 테스트용 LINK는 Faucet 사이트에서 받을 수 있습니다. 본 가이드에서는 3 LINK 전송으로 충분합니다.

4단계: Ethereum Sepolia에서 새로운 CrosschainNFT 발행

다음으로, Ethereum Sepolia에 배포된 CrosschainNFT 컨트랙트에서 새로운 NFT를 발행합니다.

scripts 폴더에 새로운 TypeScript 파일을 생성하고, mint.ts로 이름을 지은 후 다음 코드를 붙여넣습니다.

// scripts/mint.ts 
import { network } from "hardhat"; 
async function main() { 
  // Connect to the network 
  const connection = await network.connect({ 
    network: "ethereumSepolia" 
  }); 
if (connection.networkName !== "ethereumSepolia") { 
    console.error(`Must be called from Ethereum Sepolia`); 
    process.exitCode = 1; 
    return; 
  } 
  const { ethers } = connection; 
  const [signer] = await ethers.getSigners(); 
  console.log(`Using account: ${signer.address}`); 
  // Get the contract factory 
  const CrosschainNFT = await ethers.getContractFactory("CrosschainNFT", signer); 
  const crosschainNFTAddressEthereumSepolia = `0xb1fe42BBd7842703820C7480c22409b872319B22` 
  // Attach to the deployed contract 
  const crosschainNFT = CrosschainNFT.attach(crosschainNFTAddressEthereumSepolia); 
  console.log(`Minting NFT...`); 
  const tx = await crosschainNFT.mint(); 
  console.log(`Transaction hash: ${tx.hash}`); 
  console.log(`Waiting for confirmation...`); 
  const receipt = await tx.wait(); 
   
  console.log(`Transaction confirmed in block: ${receipt?.blockNumber}`); 
  console.log(`NFT minted successfully!`); 
} 
main().catch((error) => { 
  console.error(error); 
  process.exitCode = 1; 
});

이 스크립트는 발행 프로세스를 처리하고 NFT를 크로스체인 전송을 위해 준비합니다.

다음 명령어를 실행하여 함수를 호출합니다.

npx hardhat run scripts/mint.ts - network ethereumSepolia

5단계: 체인 간 NFT 전송

Ethereum Sepolia에서 crossChainTransferFrom 함수를 호출하여 NFT를 Kairos 테스트넷으로 전송합니다.

다음 값을 준비합니다.

  • from: Ethereum Sepolia의 본인 EOA 주소
  • to: Kairos 테스트넷의 수신자 EOA 주소 (본인 주소도 가능)
  • tokenId: 전송할 NFT의 ID
  • destinationChainSelector: 2624132734533621656 (Kairos 테스트넷용 CCIP 체인 선택기)
  • payFeesIn: 1 (CCIP 수수료를 LINK로 지불함을 나타냄)

전송 스크립트 실행

scripts 폴더에 새로운 TypeScript 파일을 생성하고, crossChainTransferNFT.ts로 이름을 지정한 후 다음 코드를 붙여넣습니다.

// scripts/crossChainTransferNFT.ts 
import { network } from "hardhat"; 
async function main() { 
  // Connect to the network 
  const connection = await network.connect({ 
    network: "ethereumSepolia" 
  }); 
    // Check if we're on the correct network 
  if (connection.networkName !== "ethereumSepolia") { 
    console.error(`Must be called from Ethereum Sepolia`); 
    process.exitCode = 1; 
    return; 
  } 
  const { ethers } = connection; 
  const [signer] = await ethers.getSigners(); 
  console.log(`Using account: ${signer.address}`); 
  // Get the contract factory 
  const CrosschainNFT = await ethers.getContractFactory("CrosschainNFT", signer); 
  const crosschainNFTAddressEthereumSepolia = `0xb1fe42BBd7842703820C7480c22409b872319B22`; 
  // Transfer parameters 
  const from = `0x7b467A6962bE0ac80784F131049A25CDE27d62Fb`; 
  const to = `0x7b467A6962bE0ac80784F131049A25CDE27d62Fb`; 
  const tokenId = 0; // Put NFT token id here 
  const destinationChainSelector = "2624132734533621656"; // Kairos Testnet 
  const payFeesIn = 1; // 0 - Native, 1 - LINK 
  // Attach to the deployed contract 
  const crosschainNFT = CrosschainNFT.attach(crosschainNFTAddressEthereumSepolia); 
  const tx = await crosschainNFT.crossChainTransferFrom( 
    from, 
    to, 
    tokenId, 
    destinationChainSelector, 
    payFeesIn 
  ); 
  console.log(`Transaction hash: ${tx.hash}`); 
  console.log(`Waiting for confirmation...`); 
  const receipt = await tx.wait(); 
   
  console.log(`Transaction confirmed in block: ${receipt?.blockNumber}`); 
  console.log(`Cross-chain transfer initiated successfully!`); 
  console.log(`Note: The NFT will arrive on Kairos Testnet after CCIP processes the message.`); 
} 
main().catch((error) => { 
  console.error(error); 
  process.exitCode = 1; 
});

그런 다음 아래 스크립트를 실행합니다.

npx hardhat run scripts/crossChainTransferNFT.ts - network ethereumSepolia

이체 확인

CCIP Explorer에서 크로스체인 전송을 모니터링하고 Kaiascan에서 거래를 확인할 수 있습니다.

NFT가 Kairos 테스트넷에 도착하면 MetaMask 월렛에 추가합니다.

  1. MetaMask에서 NFT 탭을 엽니다.
  2. Import NFT를 클릭합니다.
  3. Kairos 테스트넷의 CrosschainNFT 컨트랙트 주소와 수신한 tokenId(예: 0)를 입력합니다.

이제 MetaMask 월렛 내에서 NFT를 확인할 수 있습니다.

맺음말

본 튜토리얼에서는 Chainlink CCIP를 사용하여 Kaia Kairos 테스트넷과 Ethereum Sepolia 간에 소각 및 발행(burn-and-mint) 모델로 NFT를 전송하는 방법을 배웠습니다.

CCIP에 대해 더 깊이 알아보고 추가 사용 사례를 찾아보려면 공식 Chainlink CCIP 문서를 방문하세요.