Omnichain NFT (ONFT) Development
An NFT tied to a single chain is an asset with limited liquidity. A collection on Ethereum has access to OpenSea and Blur, but is cut off from the Polygon, Arbitrum, Solana ecosystems. An owner who wants to use the NFT in a game on Immutable X or as collateral in a DeFi protocol on Arbitrum — simply can't.
ONFT (Omnichain Non-Fungible Token) — LayerZero standard for NFTs with native cross-chain transfer. Not a bridge with lock-and-mint risks, but a single contract deployed on multiple chains that atomically moves NFT between them without losing metadata and ownership history.
How ONFT Works at Protocol Level
LayerZero: Endpoints and Ultra Light Node
LayerZero is not a separate blockchain. It's a messaging protocol with Endpoint contracts on each supported chain (~50+: Ethereum, Polygon, Arbitrum, Optimism, BSC, Solana, Aptos and others).
When NFT is sent from Ethereum to Arbitrum:
-
sendFrom()on Ethereum callsEndpoint.send()with encoded payload (tokenId, recipient) - LayerZero Oracle (Chainlink, Sequencer, or Google Cloud) captures block header on Arbitrum
- LayerZero Relayer transmits proof of transaction
-
Endpointon Arbitrum verifies proof through Ultra Light Node (ULN) — not full block verification, only needed storage proof -
lzReceive()on ONFT contract on Arbitrum is called with payload, mints NFT to recipient
On source chain NFT is burned (or locked depending on implementation). On destination — minted. Overall supply doesn't change.
ONFT721 vs. Custom Implementation
LayerZero provides ONFT721 base contract in @layerzerolabs/solidity-examples. It's ERC-721 with added sendFrom and lzReceive functions. Simplest ONFT implementation — inherit from ONFT721 with custom logic.
Key parameters at deployment:
constructor(
string memory name,
string memory symbol,
uint256 _minGasToTransfer, // minimum gas for lzReceive on destination
address _lzEndpoint // LayerZero Endpoint address for this chain
) ONFT721(name, symbol, _minGasToTransfer, _lzEndpoint) {}
_minGasToTransfer is critical: if set too low — lzReceive on destination reverts due to out-of-gas, NFT "gets stuck" between chains. LayerZero recommendation: 200,000 gas for basic ONFT721, more if lzReceive has additional logic.
Problems to Solve During Development
Synchronizing Metadata During Cross-Chain Transfer
NFT metadata is stored on IPFS or Arweave — not a problem, URI is same on all chains. Problem with dynamic metadata: if NFT has on-chain attributes (character level in game, accumulated points), this data is stored in contract storage. When moving to another chain, on-chain state doesn't transfer automatically.
Solution: include state in LayerZero payload. Custom _debitFrom on source packs state, custom _creditTo on destination restores it. This increases gas cost of transfer, but preserves full state.
function _debitFrom(address _from, uint16, bytes memory, uint _tokenId)
internal override returns(bytes memory) {
// Collect token state
TokenState memory state = tokenStates[_tokenId];
_burn(_tokenId); // or lock
return abi.encode(_tokenId, state); // include in payload
}
function _creditTo(uint16, address _toAddress, bytes memory _payload)
internal override returns(uint) {
(uint tokenId, TokenState memory state) = abi.decode(_payload, (uint, TokenState));
_mint(_toAddress, tokenId);
tokenStates[tokenId] = state; // restore state
return tokenId;
}
LayerZero Fee Assessment and Payment
Transfer through LayerZero isn't free: user pays with native currency of source chain for:
- Gas on source chain (
Endpoint.send) - Oracle and relayer fee (goes to LayerZero)
- Gas estimation on destination chain (prepaid)
Client code must call estimateSendFee() before transfer and pass result as msg.value. If msg.value is less than estimate — transaction reverts.
function estimateSendFee(
uint16 _dstChainId,
bytes calldata _toAddress,
uint _tokenId,
bool _useZro,
bytes calldata _adapterParams
) public view returns (uint nativeFee, uint zroFee);
Typical transfer cost ETH → Arbitrum: $0.50–2.00 in ETH depending on congestion.
Trusted Remote Configuration
Each ONFT contract on each chain must know addresses of its "siblings" on other chains. This is trustedRemote — authorized list. Without it, any contract could mint ONFT through LayerZero message.
// Executed after deployment on each chain
function setTrustedRemoteAddress(
uint16 _remoteChainId, // LayerZero chain ID
bytes calldata _remoteAddress
) external onlyOwner;
Common mistake: forget to set trusted remote bidirectionally. Transfer Ethereum→Polygon works, Polygon→Ethereum doesn't — because Polygon contract didn't add Ethereum to trusted remote.
Nonce and Ordering Guarantees
LayerZero v1 guarantees ordered delivery: messages between two chains are delivered in order sent. If transaction with nonce N gets stuck (relayer didn't deliver) — all subsequent with nonce N+1, N+2 wait. This can block all transfers from specific chain.
LayerZero v2 (2024) switches to unordered delivery with application-level ordering — more flexible model, doesn't block queue.
Stack for Full ONFT Project
Contracts: Solidity 0.8.x, @layerzerolabs/lz-evm-oapp-v2 (for LZ v2) or @layerzerolabs/solidity-examples (LZ v1), OpenZeppelin ERC721.
Testing: Foundry with LZEndpointMock — mock LayerZero endpoint for local cross-chain call testing without real oracle. Test: send from chain A, verify mint on chain B.
Frontend: wagmi/viem for multi-chain support, network switching on transfer, displaying estimated fee through estimateSendFee.
Deployment: Foundry scripts for parallel deployment to multiple chains + script for setting trustedRemote for all pairs.
Process and Timeline
Design (1 day): list of target chains, presence of on-chain state for synchronization, custom logic in _debitFrom/_creditTo.
Contract Development (2–3 days): ONFT721 with custom logic, tests via LZEndpointMock.
Frontend Component (1 day): bridge interface with destination chain selection, fee estimation, transfer status.
Deployment and Configuration (0.5 day): deploy to all chains, set trustedRemote.
Total: 3–5 days for basic ONFT without on-chain state. With complex state synchronization — 1–2 weeks. Cost calculated individually.







