Developers
Cross-Chain Messaging
Tutorials
Cross-Chain NFT

Cross-Chain NFT

In this tutorial you will learn how to create a contract capable of minting NFTs and sending them to a contract on a different chain using cross-chain messaging.

Set up your environment

git clone https://github.com/zeta-chain/template
cd template
yarn

Create the Contract

To create a new cross-chain messaging contract you will use the messaging Hardhat task available by default in the template.

npx hardhat messaging CrossChainNFT to:address token:uint256
  • to: address of the recipient on the destination chain
  • token - an ID of the NFT being transferred

Modify the contract to implement NFT logic:

contracts/CrossChainNFT.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.7;
 
import "@openzeppelin/contracts/interfaces/IERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@zetachain/protocol-contracts/contracts/evm/tools/ZetaInteractor.sol";
import "@zetachain/protocol-contracts/contracts/evm/interfaces/ZetaInterfaces.sol";
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
 
contract CrossChainNFT is
    ERC721("CrossChainNFT", "CCNFT"),
    ZetaInteractor,
    ZetaReceiver
{
    event CrossChainNFTEvent(address, uint256);
    event CrossChainNFTRevertedEvent(address, uint256);
 
    ZetaTokenConsumer private immutable _zetaConsumer;
    IERC20 internal immutable _zetaToken;
    uint256 private _tokenIds;
 
    constructor(
        address connectorAddress,
        address zetaTokenAddress,
        address zetaConsumerAddress,
        bool useEven
    ) ZetaInteractor(connectorAddress) {
        _zetaToken = IERC20(zetaTokenAddress);
        _zetaConsumer = ZetaTokenConsumer(zetaConsumerAddress);
 
        _tokenIds++;
        if (useEven) _tokenIds++;
    }
 
    function sendMessage(
        uint256 destinationChainId,
        address to,
        uint256 token
    ) external payable {
        if (!_isValidChainId(destinationChainId))
            revert InvalidDestinationChainId();
 
        uint256 crossChainGas = 2 * (10 ** 18);
        uint256 zetaValueAndGas = _zetaConsumer.getZetaFromEth{
            value: msg.value
        }(address(this), crossChainGas);
        _zetaToken.approve(address(connector), zetaValueAndGas);
 
        _burn(token);
 
        connector.send(
            ZetaInterfaces.SendInput({
                destinationChainId: destinationChainId,
                destinationAddress: interactorsByChainId[destinationChainId],
                destinationGasLimit: 300000,
                message: abi.encode(to, token, msg.sender),
                zetaValueAndGas: zetaValueAndGas,
                zetaParams: abi.encode("")
            })
        );
    }
 
    function onZetaMessage(
        ZetaInterfaces.ZetaMessage calldata zetaMessage
    ) external override isValidMessageCall(zetaMessage) {
        (address to, uint256 token) = abi.decode(
            zetaMessage.message,
            (address, uint256)
        );
 
        _safeMint(to, token);
 
        emit CrossChainNFTEvent(to, token);
    }
 
    function onZetaRevert(
        ZetaInterfaces.ZetaRevert calldata zetaRevert
    ) external override isValidRevertCall(zetaRevert) {
        (address to, uint256 token, address from) = abi.decode(
            zetaRevert.message,
            (address, uint256, address)
        );
 
        _safeMint(from, token);
 
        emit CrossChainNFTRevertedEvent(to, token);
    }
 
    function mint(address to) public returns (uint256) {
        _tokenIds++;
        _tokenIds++;
 
        _safeMint(to, _tokenIds);
        return _tokenIds;
    }
}

To integrate NFT functionality, import the ERC721 contract from OpenZeppelin.

Initialize the ERC-721 token by setting a name and a symbol using the ERC721 constructor.

Initialize a _tokenIds counter for managing NFT IDs.

Add a bool useEven parameter to the constructor. The contract will be deployed on two chains (in this example) and you need to ensure that IDs of tokens minted on different chains do not clash. Using this constructor parameter on one chain IDs will be odd, on the other chain IDs will be even. Inside the constructor increment the initial token IDs.

In the sendMessage function, burn the specified NFT by calling _burn. Add msg.sender to the message by passing it as a third argument to abi.decode. This is necessary in case the transaction is reverted, so the same NFT can be minted and transferred back to the original sender.

In the onZetaMessage function, decode the incoming message to retrieve the recipient address and NFT ID. Mint the specified NFT to the recipient using _safeMint.

In the onZetaRevert function, decode the revert message to obtain the recipient, NFT ID, and the original sender. Reimburse the original sender by minting the NFT back to their account using _safeMint.

Create a Mint Task

The mint task accepts a contract address as an argument, calls the mint function on it, searches the events for a "Transfer" event and prints out the token ID.

tasks/mint.ts
import { task } from "hardhat/config";
import { HardhatRuntimeEnvironment } from "hardhat/types";
 
const contractName = "CrossChainNFT";
 
const main = async (args: any, hre: HardhatRuntimeEnvironment) => {
  const [signer] = await hre.ethers.getSigners();
 
  const factory = await hre.ethers.getContractFactory(contractName);
  const contract = factory.attach(args.contract);
 
  const tx = await contract.connect(signer).mint(signer.address);
 
  const receipt = await tx.wait();
  const event = receipt.events?.find((event) => event.event === "Transfer");
  const nftId = event?.args?.tokenId.toString();
 
  if (args.json) {
    console.log(nftId);
  } else {
    console.log(`🔑 Using account: ${signer.address}\n`);
    console.log(`✅ "mint" transaction has been broadcasted to ${hre.network.name}
📝 Transaction hash: ${receipt.transactionHash}
🌠 Minted NFT ID: ${nftId}
`);
  }
};
 
task("mint", "Mint a new NFT.", main).addParam("contract", "Contract address").addFlag("json", "Output JSON");

Import the mint task in the Hardhat config file:

hardhat.config.ts
import "./tasks/mint";

Update the Deploy Task

Modify the deploy task by adding a new argument parity and passing it to the deployContract function. The parity argument is used to determine the parity of NFT IDs on different chains: even IDs on one chain and odd IDs on another.

tasks/deploy.ts
const main = async (args: any, hre: HardhatRuntimeEnvironment) => {
  const networks = args.networks.split(",");
  const contracts: { [key: string]: string } = {};
  await Promise.all(
    networks.map(async (networkName: string, i: number) => {
      const parity = i % 2 == 0;
      contracts[networkName] = await deployContract(
        hre,
        networkName as ParamChainName,
        parity,
        args.json,
        args.gasLimit
      );
    })
  );
 
  // ...
};
 
const deployContract = async (
  hre: HardhatRuntimeEnvironment,
  networkName: string,
  parity: boolean,
  json: boolean = false,
  gasLimit: number
) => {
  //...
  const contract = await factory.deploy(
    connector,
    zetaToken,
    zetaTokenConsumerUniV2 || zetaTokenConsumerUniV3,
    parity,
    { gasLimit }
  );
  //...
};

Deploy the Contract

Clear the cache and artifacts, then compile the contract:

npx hardhat compile --force

Run the following command to deploy the contract to two networks:

npx hardhat deploy --networks sepolia_testnet,bsc_testnet
🚀 Successfully deployed contract on bsc_testnet
📜 Contract address: 0x345b7a0ecd2faecF980db7fC1E645b960450E8E4

🚀 Successfully deployed contract on sepolia_testnet
📜 Contract address: 0x8A0061fFb4572e4D57D260C3c2a99DD25e8Ab66C

🔗 Setting interactors for a contract on bsc_testnet
✅ Interactor address for 11155111 (sepolia_testnet) is set to 0x8a0061ffb4572e4d57d260c3c2a99dd25e8ab66c

🔗 Setting interactors for a contract on sepolia_testnet
✅ Interactor address for 97 (bsc_testnet) is set to 0x345b7a0ecd2faecf980db7fc1e645b960450e8e4

Mint an NFT

npx hardhat mint --contract 0x8A0061fFb4572e4D57D260C3c2a99DD25e8Ab66C --network sepolia_testnet
🔑 Using account: 0x4955a3F38ff86ae92A914445099caa8eA2B9bA32

✅ "mint" transaction has been broadcasted to sepolia_testnet
📝 Transaction hash: 0xd29edcb4a785fb237965cee63ad415a4a3d091eb022241583ddc30eda49b1d0c
🌠 Minted NFT ID: 4

Send the NFT to the Destination Chain

npx hardhat interact --to 0x4955a3F38ff86ae92A914445099caa8eA2B9bA32 --token 4 --contract 0x8A0061fFb4572e4D57D260C3c2a99DD25e8Ab66C --network sepolia_testnet --amount 0.01 --destination bsc_testnet
🔑 Using account: 0x4955a3F38ff86ae92A914445099caa8eA2B9bA32

✅ The transaction has been broadcasted to sepolia_testnet
📝 Transaction hash: 0x132d6312be65590a66eae59df835528b74b6a46ded59b67712c0646280ceac33

You can check the broadcasted transaction on Sepolia's Etherscan:

https://sepolia.etherscan.io/tx/0x132d6312be65590a66eae59df835528b74b6a46ded59b67712c0646280ceac33 (opens in a new tab)

Next, you can track the progress of the cross-chain transaction:

npx hardhat cctx 0x132d6312be65590a66eae59df835528b74b6a46ded59b67712c0646280ceac33
✓ CCTXs on ZetaChain found.

✓ 0xb1ee24fb046397a8fd8444ad2e1ab4762b8d98e091e88bfa6dc7bf1a3a93c70f: 11155111 → 97: OutboundMined

Source Code

You can find the source code for the example in this tutorial here:

https://github.com/zeta-chain/example-contracts/tree/main/messaging/nft (opens in a new tab)