Hyperlane V3를 사용한 Crosschain NFT

전체 코드


1. Foundry 프로젝트 초기화

forge init hyperlane-v3

2. 라이브러리 설치

$ forge install hyperlane-xyz/hyperlane-monorepo@main
$ forge install OpenZeppelin/openzeppelin-contracts@release-v4.9
$ forge install OpenZeppelin/openzeppelin-contracts-upgradeable@release-v4.9

3. 라이브러리 리맵핑

$ forge remappings > remappings.txt
  • remappings.txt 파일 수정
@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/
@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/
ds-test/=lib/forge-std/lib/ds-test/src/
erc4626-tests/=lib/openzeppelin-contracts/lib/erc4626-tests/
forge-std/=lib/forge-std/src/
@hyperlane-v3/=lib/hyperlane-monorepo/solidity/
openzeppelin-contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/
openzeppelin-contracts/=lib/openzeppelin-contracts/

4. CrosschainNft.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.23;

import {IMessageRecipient} from "@hyperlane-v3/contracts/interfaces/IMessageRecipient.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {ERC721, ERC721URIStorage} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";

contract CrosschainNft is ERC721URIStorage, IMessageRecipient {
    address public mailbox;
    uint256 public tokenId;

    error NotMailbox();

    event Minted(address indexed to, uint256 indexed tokenId, string tokenURI);

    modifier onlyMailbox() {
        if (msg.sender != mailbox) {
            revert NotMailbox();
        }
        _;
    }

    constructor(address _mailbox) ERC721("CrosschainNft", "CCNFT") {
        mailbox = _mailbox;
    }

    function handle(
        uint32 /*_origin*/,
        bytes32 /*_sender*/,
        bytes memory _body
    ) external payable onlyMailbox {
        (address to, string memory tokenURI) = abi.decode(
            _body,
            (address, string)
        );

        uint256 _tokenId = ++tokenId;

        _mint(to, _tokenId);
        _setTokenURI(_tokenId, tokenURI);

        emit Minted(to, _tokenId, tokenURI);
    }
}

5. CrosschainNftRouter.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.23;

import {IMailbox} from "@hyperlane-v3/contracts/interfaces/IMailbox.sol";
import {IRouter} from "@hyperlane-v3/contracts/interfaces/IRouter.sol";
import {TypeCasts} from "@hyperlane-v3/contracts/libs/TypeCasts.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

contract CrosschainNftRouter is Ownable, IRouter {
    error RouteAlreadyEnrolled();
    error RouteNotFound();
    error InvalidInputLength();

    address public mailbox;
    uint32[] private _innerDomains;
    mapping(uint32 => bytes32) private _innerRouters;

    constructor(address _mailbox) Ownable(msg.sender) {
        mailbox = _mailbox;
    }

    function domains() external view override returns (uint32[] memory) {
        return _innerDomains;
    }

    function routers(uint32 _domain) external view override returns (bytes32) {
        return _innerRouters[_domain];
    }

    function enrollRemoteRouter(
        uint32 _domain,
        bytes32 _router
    ) external onlyOwner {
        if (_innerRouters[_domain] != bytes32(0)) {
            revert RouteAlreadyEnrolled();
        }
        _innerRouters[_domain] = _router;
        _innerDomains.push(_domain);
    }

    function enrollRemoteRouters(
        uint32[] calldata _domains,
        bytes32[] calldata _routers
    ) external onlyOwner {
        if (_domains.length != _routers.length) {
            revert InvalidInputLength();
        }

        for (uint256 i = 0; i < _domains.length; i++) {
            if (_innerRouters[_domains[i]] != bytes32(0)) {
                revert RouteAlreadyEnrolled();
            }
            _innerRouters[_domains[i]] = _routers[i];
            _innerDomains.push(_domains[i]);
        }
    }

    function sendNft(
        uint32 _domainId,
        address _to,
        string memory _tokenURI
    ) external payable {
        bytes32 recipientAddress = _tryGetrecipientAddress(_domainId);
        bytes memory messageBody = abi.encode(_to, _tokenURI);

        IMailbox(mailbox).dispatch{value: msg.value}(
            _domainId,
            recipientAddress,
            messageBody
        );
    }

    function estimateFee(
        uint32 _domainId,
        address _to,
        string memory _tokenURI
    ) external view returns (uint256) {
        bytes32 recipientAddress = _tryGetrecipientAddress(_domainId);
        bytes memory messageBody = abi.encode(_to, _tokenURI);

        return _estimateFee(_domainId, recipientAddress, messageBody);
    }

    function _tryGetrecipientAddress(
        uint32 _domainId
    ) internal view returns (bytes32 recipientAddress) {
        recipientAddress = _innerRouters[_domainId];
        if (recipientAddress == bytes32(0)) {
            revert RouteNotFound();
        }
    }

    function _estimateFee(
        uint32 destinationDomain,
        bytes32 recipientAddress,
        bytes memory messageBody
    ) internal view returns (uint256) {
        return
            IMailbox(mailbox).quoteDispatch(
                destinationDomain,
                recipientAddress,
                messageBody
            );
    }
}

6. 배포 준비

  • .env 파일 생성
MUMBAI_RPC_URL=https://polygon-mumbai.g.alchemy.com/v2/<API_KEY>
SEPOLIA_RPC_URL=https://eth-sepolia.g.alchemy.com/v2/<API_KEY>
ARBITRUM_RPC_URL=https://arb-sepolia.g.alchemy.com/v2/<API_KEY>

PRIVATE_KEY=<YOUR_PRIVATE_KEY>

SEPOLIA_ETHERSCAN_API_KEY=<YOUR_ETHERSCAN_API_KEY>
POLYGONSCAN_API_KEY=<YOUR_POLYGONSCAN_API_KEY>
  • foundry.toml 파일 수정
[profile.default]
src = "src"
out = "out"
libs = ["lib"]

# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options
[rpc_endpoints]
sepolia = "${SEPOLIA_RPC_URL}"
mumbai = "${MUMBAI_RPC_URL}"

[etherscan]
sepolia = { key = "${SEPOLIA_ETHERSCAN_API_KEY}" }
mumbai = { key = "${POLYGONSCAN_API_KEY}", url = "https://api-testnet.polygonscan.com/api" }

7. CrosschainNftRouter 배포

  • Sepolia 네트워크에 배포 및 검증
  • Sepolia에 배포된 Hyperlane Mailbox의 주소: 0xfFAEF09B3cd11D9b20d1a19bECca54EEC2884766
contract Deploy is Script {
    function setUp() public {}

    function run() public {
        uint256 privateKey = vm.envUint("PRIVATE_KEY");

        vm.startBroadcast(privateKey);

        CrosschainNftRouter instance = new CrosschainNftRouter(
            0xfFAEF09B3cd11D9b20d1a19bECca54EEC2884766
        );

        console.log("Deployed CrosschainNftRouter at: ", address(instance));

        vm.stopBroadcast();
    }
}
$ forge script script/CrosschainNftRouter.s.sol:Deploy --rpc-url sepolia --broadcast --verify -vvvv
...
Script ran successfully.

== Logs ==
  Deployed CrosschainNftRouter at:  0xe996557C3A4D3586786359ccCaBe9f76842D1783

8. CrosschainNft 배포

  • Polygon Mumbai 네트워크에 배포 및 검증
  • Polygon Mumbai 에 배포된 Hyperlane Mailbox의 주소: 0x2d1889fe5B092CD988972261434F7E5f26041115
contract Deploy is Script {
    function setUp() public {}

    function run() public {
        uint256 privateKey = vm.envUint("PRIVATE_KEY");

        vm.startBroadcast(privateKey);

        CrosschainNft instance = new CrosschainNft(
            0x2d1889fe5B092CD988972261434F7E5f26041115
        );

        console.log("Deployed CrosschainNft at: ", address(instance));

        vm.stopBroadcast();
    }
}
$ forge script script/CrosschainNftMumbai.s.sol:Deploy --rpc-url mumbai --broadcast --verify -vvvv
...
Script ran successfully.

== Logs ==
  Deployed CrosschainNft at:  0x3716B00671B801f34bB4c99Aba5889A13d65c42E

9. CrosschainNftRouter를 통해 NFT 발행

  • router 등록
  • 가스비 계산
  • NFT 전송
contract SendNFT is Script {
    function setUp() public {}

    function run() public {
        uint256 privateKey = vm.envUint("PRIVATE_KEY");

        vm.startBroadcast(privateKey);

        CrosschainNftRouter instance = CrosschainNftRouter(
            0xe996557C3A4D3586786359ccCaBe9f76842D1783
        );

        uint32 domainId = 80001;
        bytes32 recipientAddress = TypeCasts.addressToBytes32(
            0x3716B00671B801f34bB4c99Aba5889A13d65c42E
        );

        instance.enrollRemoteRouter(domainId, recipientAddress);

        string
            memory uri = "https://green-main-hoverfly-930.mypinata.cloud/ipfs/QmXbFt5tDifdSgPmhFrwD56iNsJqbxCZ8dSdv4qzp49PNs";

        uint256 fee = instance.estimateFee(domainId, vm.addr(privateKey), uri);

        console.log("Estimated fee: ", fee);

        instance.sendNft{value: fee}(domainId, vm.addr(privateKey), uri);

        console.log("Sent NFT to domain: ", domainId);

        vm.stopBroadcast();
    }
}
$ forge script script/CrosschainNftRouter.s.sol:SendNFT --rpc-url sepolia --broadcast -vvvv
...
Script ran successfully.

== Logs ==
  Estimated fee:  1
  Sent NFT to domain:  80001

10. Hyperlane Explorer


11. 발행된 NFT 확인

contract CheckNFT is Script {
    function setUp() public {}

    function run() public {
        uint256 privateKey = vm.envUint("PRIVATE_KEY");

        vm.startBroadcast(privateKey);

        CrosschainNft instance = CrosschainNft(
            0x3716B00671B801f34bB4c99Aba5889A13d65c42E
        );

        address owner = instance.ownerOf(1);

        console.log("NFT Owner: ", owner);

        string memory uri = instance.tokenURI(1);

        console.log("NFT URI: ", uri);

        vm.stopBroadcast();
    }
}
$ forge script script/CrosschainNftMumbai.s.sol:CheckNFT --rpc-url mumbai -vvvv
...
Script ran successfully.

== Logs ==
  NFT Owner:  0x965B0E63e00E7805569ee3B428Cf96330DFc57EF
  NFT URI:  https://green-main-hoverfly-930.mypinata.cloud/ipfs/QmXbFt5tDifdSgPmhFrwD56iNsJqbxCZ8dSdv4qzp49PNs