A tutorial contributed to the LZ community
What are we building?
This article it is going to walk you through building a prototype of omnichain NFT with LayerZero. Think of it as building a project for a hackathon or a prototype demo for your startup in a limited time.As a result, we create A cross-chain NFT contract using LayerZero, and use the default UA configuration.
In other words, we are not trying to build something perfect — but rather something that works.
If you get stuck in any part of this tutorial or if I have forgotten to mention something, you can check out the Github repository end of the article.
If you have questions about how to bootstrap a LayerZero project with Hardhat or not familiar with Solidity and LayerZero, it is highly recommended you read the first tutorial.
https://medium.com/@Tim4l1f3/layerzero-tutorial-for-beginners-d3fe9326e8b7
Prerequisites
This tutorial assumes that you have some familiarity with SolidityHardhat
, fundamentals of LayerZero,
and ERC721
would be helpful.
Overview
This demo uses LayerZero’s cross-chain messaging protocol for sending messages across chain. We leverage this to build a cross-chain bridging protocol.
In this tutorial, every NFT has an origin chain, and every chain has 100 NFT that can be minted. When bridging away from its origin chain, this protocol that we build uses a burn-and-mint mechanism where the source NFT gets burned and a new one gets minted on the destination chain.
Setup for the Tutorial
- Create a hardhat project
npx hardhat
Create an advanced sample project.
- Import library and interface
Install OpenZeppelin contracts library, which a library for secure smart contract development.Build on a solid foundation of community-vetted code.
npm install @openzeppelin/contracts
import the interface from LayerZero repository.
https://github.com/LayerZero-Labs/LayerZero/tree/main/contracts/interfaces.
Develop Smart Contracts
create contract file:
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "../interfaces/ILayerZeroEndpoint.sol";
import "../interfaces/ILayerZeroReceiver.sol";contract OmniChainNFT is Ownable, ERC721, ILayerZeroReceiver {
uint256 counter = 0;
uint256 nextId = 0;
uint256 MAX = 100;
uint256 gas = 350000;
ILayerZeroEndpoint public endpoint; event ReceiveNFT(
uint16 _srcChainId,
address _from,
uint256 _tokenId,
uint256 counter
); constructor(
address _endpoint,
uint256 startId,
uint256 _max
) ERC721("OmniChainNFT", "OOCCNFT") {
endpoint = ILayerZeroEndpoint(_endpoint);
nextId = startId;
MAX = _max;
} function mint() external payable {
require(nextId + 1 <= MAX, "Exceeds supply");
nextId += 1;
_safeMint(msg.sender, nextId);
counter += 1;
} function crossChain(
uint16 _dstChainId,
bytes calldata _destination,
uint256 tokenId
) public payable {
require(msg.sender == ownerOf(tokenId), "Not the owner");
// burn NFT
_burn(tokenId);
counter -= 1;
bytes memory payload = abi.encode(msg.sender, tokenId); // encode adapterParams to specify more gas for the destination
uint16 version = 1;
bytes memory adapterParams = abi.encodePacked(version, gas); (uint256 messageFee, ) = endpoint.estimateFees(
_dstChainId,
address(this),
payload,
false,
adapterParams
); require(
msg.value >= messageFee,
"Must send enough value to cover messageFee"
); endpoint.send{value: msg.value}(
_dstChainId,
_destination,
payload,
payable(msg.sender),
address(0x0),
adapterParams
);
} function lzReceive(
uint16 _srcChainId,
bytes memory _from,
uint64,
bytes memory _payload
) external override {
require(msg.sender == address(endpoint));
address from;
assembly {
from := mload(add(_from, 20))
}
(address toAddress, uint256 tokenId) = abi.decode(
_payload,
(address, uint256)
); // mint the tokens
_safeMint(toAddress, tokenId);
counter += 1;
emit ReceiveNFT(_srcChainId, toAddress, tokenId, counter);
} // Endpoint.sol estimateFees() returns the fees for the message
function estimateFees(
uint16 _dstChainId,
address _userApplication,
bytes calldata _payload,
bool _payInZRO,
bytes calldata _adapterParams
) external view returns (uint256 nativeFee, uint256 zroFee) {
return
endpoint.estimateFees(
_dstChainId,
_userApplication,
_payload,
_payInZRO,
_adapterParams
);
}
}
The contract create a ERC721 token named “OOCCNFT”, and it limit 100 NFT on every deployed chain, the first token id and max limited initialized in constructor
function with contract deployed. For simplicity we only give the NFT a name, and haven't config any metadata, you can try it yourself.
Custom function crossChain
means any "OOCCNFT" wants transfer between 2 origin chains, the NFT gets burned and the contract will modify the counter which count the number of the NFT on current chain. Above all it wraps endpoint.send(...)
which will cause lzReceive()
to be called on the destination chain.
Override function lzReceive
will automatically invoked on the receiving chain after the source chain calls endpoint.send(...)
.A new one gets minted has same token id on the destination chain.
Deploy contracts
- Create a deploy script for Fantom testnet
LayerZero endpoint: 0x7dcAD72640F835B0FA36EFD3D6d3ec902C7E5acf
const hre = require("hardhat");async function main() {
const OmniChainNFT = await hre.ethers.getContractFactory("OmniChainNFT");
const omniChainNFT = await OmniChainNFT.deploy(
"0x7dcAD72640F835B0FA36EFD3D6d3ec902C7E5acf",
0,
100
); await omniChainNFT.deployed(); console.log(
"Fantom testnet ----- omniChainNFT deployed to:",
omniChainNFT.address
);
}main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
npx hardhat run scripts/deploy_fantomtest.js --network fantomtest
Deployed Contract on Fantom testnet: 0xb03a572ee91aEcbdfa8ceF8196BF140A1E7410dF
- Create a deploy script for Fuji (Avalanche testnet)
LayerZero endpoint: 0x93f54D755A063cE7bB9e6Ac47Eccc8e33411d706
const hre = require("hardhat");async function main() {
const OmniChainNFT = await hre.ethers.getContractFactory("OmniChainNFT");
const omniChainNFT = await OmniChainNFT.deploy(
"0x93f54D755A063cE7bB9e6Ac47Eccc8e33411d706",
100,
200
); await omniChainNFT.deployed(); console.log("Fuji ----- omniChainNFT deployed to:", omniChainNFT.address);
}main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
npx hardhat run scripts/deploy_fuji.js --network fuji
Deployed Contract on Fuji: 0xB7CAa37D36C077C575BF227A23968186e54468Ee
- Create a deploy script for Mumbai (Polygon testnet)
LayerZero endpoint: 0xf69186dfBa60DdB133E91E9A4B5673624293d8F8
const hre = require("hardhat");async function main() {
const OmniChainNFT = await hre.ethers.getContractFactory("OmniChainNFT");
const omniChainNFT = await OmniChainNFT.deploy(
"0xf69186dfBa60DdB133E91E9A4B5673624293d8F8",
200,
300
); await omniChainNFT.deployed(); console.log("Mumbai ----- omniChainNFT deployed to:", omniChainNFT.address);
}main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
npx hardhat run scripts/deploy_mumbai.js --network mumbai
Deployed Contract on Mumbai: 0x3cdB05ca336DbF64Fa091Bd660dBAd8106C686a6
Test
- Mint on Fantom testnet
const hre = require("hardhat");async function main() {
const account = "0x244a807084a3eb9fD5fE88Aa0b13AEC8401577Bd";
const OmniChainNFT = await hre.ethers.getContractFactory("OmniChainNFT");
const omniChainNFT = await OmniChainNFT.attach(
"0xb03a572ee91aEcbdfa8ceF8196BF140A1E7410dF"
);
await omniChainNFT.mint();
const balance = await omniChainNFT.balanceOf(account);
console.log("Fnatom NFT balance: ", balance.toString());
const owner = await omniChainNFT.ownerOf(1);
console.log("Token 1 owner: ", owner);
}main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Create a script, attach the contract we have deployed on Fantom testnet.In this script, we mint a NFT we deployed, and check the balance and the owner of token id 1 of test EOA accout which have already mint at previous step.
npx hardhat run scripts/mint_fantomtest.js --network fantomtest
For convenience, we repeat mint on the on chains.
npx hardhat run scripts/mint_fuji.js --network fuji
npx hardhat run scripts/mint_mumbai.js --network mumbai
Now the test EOA account have 3 NFT on three different chains, token id 1 on the Fantom testnet, token id 101 on Fuji, token id 201 on the Mumbai.
2.Cross chains
Create a simple script to call the crossChain
function, transfer "OmniChainNFT" from Fantom testnet to Fuji
const hre = require("hardhat");
const { ethers } = require("ethers");async function main() {
const OmniChainNFT = await hre.ethers.getContractFactory("OmniChainNFT");
const omniChainNFT = await OmniChainNFT.attach(
"0xb03a572ee91aEcbdfa8ceF8196BF140A1E7410dF"
);
await omniChainNFT.crossChain(
10006,
"0xB7CAa37D36C077C575BF227A23968186e54468Ee",
ethers.BigNumber.from("1"),
{ value: ethers.utils.parseEther("5") }
);
}main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
The Fantom testnet test script attached address 0xb03a572ee91aEcbdfa8ceF8196BF140A1E7410dF
. It will send the token id which we need briding cross chains, and the destination contract 0xB7CAa37D36C077C575BF227A23968186e54468Ee
is the contract which we have deployed on Fuji. In this prototype, for simplicity it set with the function call, in the actual use you can write a setter function to set it for your contract secure, and we send with value 5FTM for transaction fee.If the source transaction is cheaper than the amount of value passed, it will refund the additional amount to the address we have passed the _refundAddress
.
Run the script with hardhat:
npx hardhat run scripts/cross_fantom2fuji.js --network fantomtest
After the script finished, we could search the transaction in the FTMScan testnet, the TokenID[1]
gets burned, and the contract has called LayerZero endpoint 0x7dcAD72640F835B0FA36EFD3D6d3ec902C7E5acf
Now we can check the balance of the test account:
npx hardhat run scripts/check_fantom.js --network fantomtest
npx hardhat run scripts/check_fuji.js --network fuji
It also can be confirmed on Snowtrace (AVAX Testnet explorer), the TokenID[1]
minted to the test account.
It can be found the LayerZero endpoint call the NFT contract which we have deployed.
Now let’s make the TokenID[1]
"OmniChainNFT" travel around three chains, and back Fantom testnet to finish the test.
Fuji to Mumbai:
npx hardhat run scripts/cross_fuji2mumbai.js --network fuji
Mumbai to Fantom testnet:
npx hardhat run scripts/cross_mumbai2fantom.js --network mumbai
After the tutorial and the test, the NFT back to Fantom testnet account.
Conclusion
In this tutorial, we have used LayerZero, Solidity, Hardhat created omnichain NFT called “OmniChainNFT”, and made one of the NFT travelled around three testnet chains.Obviously, LayerZero make it easy to make omnichain NFT collection, even through a beginners of NFT or Solidity. LayerZero make it possible to bridge NFT cross chains in one transaction and it finished very fast, developer work with it easy too.
If you are interested in NFT, omnichain or LayerZero, feel free to use the source code in this tutorial, and add some metadata or use Moralis, NFT port API, Opensea to make your own NFT collection.Let’s make omnichain NFT more and more interesting.
Source code: https://github.com/The-dLab/LayerZero_NFT_Demo
LayerZero Testnet: https://layerzero.gitbook.io/docs/technical-reference/testnet/testnet-addresses
OpenZeppelin docs: https://docs.openzeppelin.com/contracts/4.x/
GitHub: github.com/The-dLab