Build
Tutorials
NFT

In this tutorial you will learn how to create a universal ERC-721 NFT that can be easily transferred between chains connected to ZetaChain.

The project consists of two ERC-721 NFT contracts.

Universal contract is deployed on ZetaChain. The contract is used to:

  • Mint NFTs on ZetaChain
  • Transfer NFTs from ZetaChain to a connected chain
  • Handle incoming NFT transfers from connected chain to ZetaChain
  • Handle NFT transfers between connected chains

Connected contract is deployed one or more connected EVM chains. The contract is used to:

  • Mint an NFT on a connected chain
  • Transfer NFT to another connected chain or ZetaChain
  • Handling incoming NFT transfers from ZetaChain or another connected chain

A universal NFT can be minted on any chain: ZetaChain or any connected EVM chain. When an NFT is minted, it gets assigned a persistent token ID that is unique across all chains. When an NFT is transferred between chains, the token ID remains the same.

An NFT can be transferred from ZetaChain to a connected chain, from a connected chain to ZetaChain and between connected chains. ZetaChain acts as a hub for cross-chain transactions, so all transfers go through ZetaChain. For example, when you transfer an NFT from Ethereum to BNB, two cross-chain transactions are initiated: Ethereum → ZetaChain → BNB. This doesn't impact the transfer time or costs, but makes it easier to connect any number of chains.

Cross-chain NFT transfers are capable of handling reverts. If the transfer fails on the destination chain, an NFT will be returned to the original sender on the source chain.

NFT contracts only accept cross-chain calls from trusted "counterparty" contracts. Each contract on a connected chain stores a "counterparty" address — an address of the Universal contract on ZetaChain. The Universal contract stores a list of "counterparty" contracts on connected chains. This ensures that only the contracts from the same NFT collection can participate in the cross-chain transfer.

https://excalidraw.com/#json=dQJisu_uJ0N8T6IPi2m0E,PJU63ktFfbi1WsfAXsompA

This tutorial relies on the Gateway, which is currently available only on localnet and testnet.

Start by cloning the example contracts repository and installing the necessary dependencies:

git clone https://github.com/zeta-chain/example-contracts
cd example-contracts/examples/nft
yarn
contracts/Universal.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
 
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Burnable.sol";
import "@openzeppelin/contracts/access/Ownable2Step.sol";
import {RevertContext, RevertOptions} from "@zetachain/protocol-contracts/contracts/Revert.sol";
import "@zetachain/protocol-contracts/contracts/zevm/interfaces/UniversalContract.sol";
import "@zetachain/protocol-contracts/contracts/zevm/interfaces/IGatewayZEVM.sol";
import "@zetachain/protocol-contracts/contracts/zevm/GatewayZEVM.sol";
import {SwapHelperLib} from "@zetachain/toolkit/contracts/SwapHelperLib.sol";
import {SystemContract} from "@zetachain/toolkit/contracts/SystemContract.sol";
import "./shared/Events.sol";
 
contract Universal is
    ERC721,
    ERC721Enumerable,
    ERC721URIStorage,
    Ownable2Step,
    UniversalContract,
    Events
{
    GatewayZEVM public immutable gateway;
    address public immutable uniswapRouter;
    uint256 private _nextTokenId;
    bool public isUniversal = true;
    uint256 public gasLimit;
 
    error TransferFailed();
    error Unauthorized();
    error InvalidAddress();
    error InvalidGasLimit();
 
    mapping(address => address) public counterparty;
 
    modifier onlyGateway() {
        if (msg.sender != address(gateway)) revert Unauthorized();
        _;
    }
 
    constructor(
        address payable gatewayAddress,
        address owner,
        string memory name,
        string memory symbol,
        uint256 gas,
        address uniswapRouterAddress
    ) ERC721(name, symbol) Ownable(owner) {
        if (
            gatewayAddress == address(0) ||
            owner == address(0) ||
            uniswapRouterAddress == address(0)
        ) revert InvalidAddress();
        if (gas == 0) revert InvalidGasLimit();
        gateway = GatewayZEVM(gatewayAddress);
        uniswapRouter = uniswapRouterAddress;
        gasLimit = gas;
    }
 
    function setCounterparty(
        address zrc20,
        address contractAddress
    ) external onlyOwner {
        counterparty[zrc20] = contractAddress;
        emit CounterpartySet(zrc20, contractAddress);
    }
 
    function transferCrossChain(
        uint256 tokenId,
        address receiver,
        address destination
    ) public {
        if (receiver == address(0)) revert InvalidAddress();
        string memory uri = tokenURI(tokenId);
        _burn(tokenId);
 
        (, uint256 gasFee) = IZRC20(destination).withdrawGasFeeWithGasLimit(
            gasLimit
        );
        if (
            !IZRC20(destination).transferFrom(msg.sender, address(this), gasFee)
        ) revert TransferFailed();
        IZRC20(destination).approve(address(gateway), gasFee);
        bytes memory message = abi.encode(
            receiver,
            tokenId,
            uri,
            0,
            msg.sender
        );
 
        CallOptions memory callOptions = CallOptions(gasLimit, false);
 
        RevertOptions memory revertOptions = RevertOptions(
            address(this),
            true,
            address(0),
            abi.encode(tokenId, uri, msg.sender),
            gasLimit
        );
 
        gateway.call(
            abi.encodePacked(counterparty[destination]),
            destination,
            message,
            callOptions,
            revertOptions
        );
 
        emit TokenTransfer(receiver, destination, tokenId, uri);
    }
 
    function safeMint(address to, string memory uri) public onlyOwner {
        uint256 hash = uint256(
            keccak256(
                abi.encodePacked(address(this), block.number, _nextTokenId++)
            )
        );
 
        uint256 tokenId = hash & 0x00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF;
 
        _safeMint(to, tokenId);
        _setTokenURI(tokenId, uri);
    }
 
    function onCall(
        MessageContext calldata context,
        address zrc20,
        uint256 amount,
        bytes calldata message
    ) external override onlyGateway {
        if (context.sender != counterparty[zrc20]) revert Unauthorized();
 
        (
            address destination,
            address receiver,
            uint256 tokenId,
            string memory uri,
            address sender
        ) = abi.decode(message, (address, address, uint256, string, address));
 
        if (destination == address(0)) {
            _safeMint(receiver, tokenId);
            _setTokenURI(tokenId, uri);
            emit TokenTransferReceived(receiver, tokenId, uri);
        } else {
            (, uint256 gasFee) = IZRC20(destination).withdrawGasFeeWithGasLimit(
                gasLimit
            );
 
            uint256 out = SwapHelperLib.swapExactTokensForTokens(
                uniswapRouter,
                zrc20,
                amount,
                destination,
                0
            );
 
            IZRC20(destination).approve(address(gateway), out);
            gateway.withdrawAndCall(
                abi.encodePacked(counterparty[destination]),
                out - gasFee,
                destination,
                abi.encode(receiver, tokenId, uri, out - gasFee, sender),
                CallOptions(gasLimit, false),
                RevertOptions(
                    address(this),
                    true,
                    address(0),
                    abi.encode(tokenId, uri, sender),
                    0
                )
            );
        }
        emit TokenTransferToDestination(receiver, destination, tokenId, uri);
    }
 
    function onRevert(RevertContext calldata context) external onlyGateway {
        (uint256 tokenId, string memory uri, address sender) = abi.decode(
            context.revertMessage,
            (uint256, string, address)
        );
 
        _safeMint(sender, tokenId);
        _setTokenURI(tokenId, uri);
        emit TokenTransferReverted(sender, tokenId, uri);
    }
 
    // The following functions are overrides required by Solidity.
 
    function _update(
        address to,
        uint256 tokenId,
        address auth
    ) internal override(ERC721, ERC721Enumerable) returns (address) {
        return super._update(to, tokenId, auth);
    }
 
    function _increaseBalance(
        address account,
        uint128 value
    ) internal override(ERC721, ERC721Enumerable) {
        super._increaseBalance(account, value);
    }
 
    function tokenURI(
        uint256 tokenId
    ) public view override(ERC721, ERC721URIStorage) returns (string memory) {
        return super.tokenURI(tokenId);
    }
 
    function supportsInterface(
        bytes4 interfaceId
    )
        public
        view
        override(ERC721, ERC721Enumerable, ERC721URIStorage)
        returns (bool)
    {
        return super.supportsInterface(interfaceId);
    }
}

Minting an NFT

safeMint mint a new NFT. Token ID is generated from the contract address, block time, and an incrementing integer. This ensures that the token ID is unique across all chains. The only scenario where ID collision is possible is when two contracts are deployed on the same address on two different chains, and an NFT is minted on both exactly at the same time using the same integer. You can supply your own logic for generating token IDs.

Transfer NFT from ZetaChain to a Connected Chain

transferCrossChain transfers an NFT from ZetaChain to a connected chain. Transferring an NFT to a connected chain creates a transaction on a connected chain, which costs gas. To pay for gas, the NFT sender must provide the universal app with tokens for gas in the form of ZRC-20 version of the gas token of the connected chain. For example, when a user transfers an NFT from ZetaChain to Ethereum, they need to allow the contract to spend a certain amount of ZRC-20 ETH.

The function transfers the ZRC-20 tokens to the gateway contract.

Next, the function encodes the token ID, sender's address and the URI into a message.

callOptions are defined with the gas limit on the destination chain and isArbitraryCall (the second parameter) is set to false. isArbitraryCall determines if the call is arbitrary (a call to any contract on the destination chain, but without providing address of the universal contract making the call) or authenticated (the call is made to onCall function, the universal contract address is passed as a parameter). Setting isArbitraryCall to false is important, because the contract on a connected chain must know which universal contract is making a call to prevent unauthorized calls.

revertOptions contain the address of a contract on ZetaChain, which will be called if the contract on the destination chain reverts (in our example we want to call the same universal contract), as well as the message that will be passed to the onRevert function of the revert contract. Pass the same encoded message to ensure that the universal contract can successfully mint the token back to the sender if the transfer fails.

Finally, gateway.call is executed to initiate a cross-chain transfer of an NFT from ZetaChain to a connected chain. The destination chain is determined by the ZRC-20 contract address, which corresponds to the gas token of the connected chain. For example, to transfer the NFT to Ethereum, pass address of ZRC-20 ETH.

Handling NFT Transfers from Connected Chains

onCall is executed when an NFT transfer is received from a connected chain.

First, onCall checks that the transfer originates from a trusted counterparty contract. This prevents unauthorized minting by malicious contracts.

Since all cross-chain transfers are processed by ZetaChain, there are two scenarios when onCall is executed: when the destination is ZetaChain or when the destination is another connected chain.

If the destination is a zero address, then the destination chain is ZetaChain. An NFT is minted and transferred to the recipient.

If the destination is a non-zero address, the destination chain is another connected chain identified by the ZRC-20 gas token address in the destination field of the message. The contract initiates a transfer to the connected chain. First, it queries the withdraw fee on the destination chain. Then, it swaps the incoming ZRC-20 tokens into the ZRC-20 gas token of the destination chain. The swap uses the built-in Uniswap v2 pools, but any other swap mechanism can be used, instead. Finally, gateway.call is executed to initiate the transfer to the destination chain.

Revert Handling

If an NFT transfer from ZetaChain to a connected chain fails, onRevert is called. onRevert mints the NFT and transfers it back to original sender.

contracts/Connected.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
 
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Burnable.sol";
import "@openzeppelin/contracts/access/Ownable2Step.sol";
import "@zetachain/protocol-contracts/contracts/evm/GatewayEVM.sol";
import {RevertContext} from "@zetachain/protocol-contracts/contracts/Revert.sol";
import "./shared/Events.sol";
 
contract Connected is
    ERC721,
    ERC721Enumerable,
    ERC721URIStorage,
    Ownable2Step,
    Events
{
    GatewayEVM public immutable gateway;
    uint256 private _nextTokenId;
    address public counterparty;
    uint256 public gasLimit;
 
    error InvalidAddress();
    error Unauthorized();
    error InvalidGasLimit();
    error GasTokenTransferFailed();
 
    function setCounterparty(address contractAddress) external onlyOwner {
        if (contractAddress == address(0)) revert InvalidAddress();
        counterparty = contractAddress;
        emit SetCounterparty(contractAddress);
    }
 
    modifier onlyGateway() {
        if (msg.sender != address(gateway)) revert Unauthorized();
        _;
    }
 
    constructor(
        address payable gatewayAddress,
        address owner,
        string memory name,
        string memory symbol,
        uint256 gas
    ) ERC721(name, symbol) Ownable(owner) {
        if (gatewayAddress == address(0) || owner == address(0))
            revert InvalidAddress();
        if (gas == 0) revert InvalidGasLimit();
        gasLimit = gas;
        gateway = GatewayEVM(gatewayAddress);
    }
 
    function safeMint(address to, string memory uri) public onlyOwner {
        uint256 hash = uint256(
            keccak256(
                abi.encodePacked(address(this), block.number, _nextTokenId++)
            )
        );
 
        uint256 tokenId = hash & 0x00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF;
 
        _safeMint(to, tokenId);
        _setTokenURI(tokenId, uri);
        emit TokenMinted(to, tokenId, uri);
    }
 
    function transferCrossChain(
        uint256 tokenId,
        address receiver,
        address destination
    ) external payable {
        if (receiver == address(0)) revert InvalidAddress();
 
        string memory uri = tokenURI(tokenId);
        _burn(tokenId);
        bytes memory message = abi.encode(
            destination,
            receiver,
            tokenId,
            uri,
            msg.sender
        );
        if (destination == address(0)) {
            gateway.call(
                counterparty,
                message,
                RevertOptions(address(this), false, address(0), message, 0)
            );
        } else {
            gateway.depositAndCall{value: msg.value}(
                counterparty,
                message,
                RevertOptions(
                    address(this),
                    true,
                    address(0),
                    abi.encode(receiver, tokenId, uri, msg.sender),
                    gasLimit
                )
            );
        }
 
        emit TokenTransfer(destination, receiver, tokenId, uri);
    }
 
    function onCall(
        MessageContext calldata context,
        bytes calldata message
    ) external payable onlyGateway returns (bytes4) {
        if (context.sender != counterparty) revert Unauthorized();
 
        (
            address receiver,
            uint256 tokenId,
            string memory uri,
            uint256 gasAmount,
            address sender
        ) = abi.decode(message, (address, uint256, string, uint256, address));
 
        _safeMint(receiver, tokenId);
        _setTokenURI(tokenId, uri);
        if (gasAmount > 0) {
            (bool success, ) = sender.call{value: gasAmount}("");
            if (!success) revert GasTokenTransferFailed();
        }
        emit TokenTransferReceived(receiver, tokenId, uri);
        return "";
    }
 
    function onRevert(RevertContext calldata context) external onlyGateway {
        (, uint256 tokenId, string memory uri, address sender) = abi.decode(
            context.revertMessage,
            (address, uint256, string, address)
        );
 
        _safeMint(sender, tokenId);
        _setTokenURI(tokenId, uri);
        emit TokenTransferReverted(sender, tokenId, uri);
    }
 
    receive() external payable {}
 
    fallback() external payable {}
 
    // The following functions are overrides required by Solidity.
 
    function _update(
        address to,
        uint256 tokenId,
        address auth
    ) internal override(ERC721, ERC721Enumerable) returns (address) {
        return super._update(to, tokenId, auth);
    }
 
    function _increaseBalance(
        address account,
        uint128 value
    ) internal override(ERC721, ERC721Enumerable) {
        super._increaseBalance(account, value);
    }
 
    function tokenURI(
        uint256 tokenId
    ) public view override(ERC721, ERC721URIStorage) returns (string memory) {
        return super.tokenURI(tokenId);
    }
 
    function supportsInterface(
        bytes4 interfaceId
    )
        public
        view
        override(ERC721, ERC721Enumerable, ERC721URIStorage)
        returns (bool)
    {
        return super.supportsInterface(interfaceId);
    }
}

Transfer NFT from a Connected Chain

transferCrossChain initiates a transfer of an NFT to ZetaChain or to another connected chain.

To transfer an NFT to ZetaChain the destination address must be specified as a zero address.

To transfer an NFT to another connected chain the destination address must be the ZRC-20 address of the gas token of the destination chain. For example, to transfer to Ethereum, the destination is ZRC-20 ETH address.

When transferring to ZetaChain a no asset gateway.call is executed, because cross-chain calls to ZetaChain do not require the sender to cover gas costs.

When transferring to another connected chain, gateway.depositAndCall is executed with some gas tokens to cover the gas costs on the destination chain.

Handling NFT Transfers

onCall is executed when an NFT transfer is received from a connected chain or ZetaChain.

Since all cross-chain transactions go through a universal contract on ZetaChain, onCall checks that the call is made from a trusted counterparty universal contract address to prevent unauthorized minting.

Revert Handling

If an NFT transfer from ZetaChain to a connected chain fails, onRevert is called. onRevert mints the NFT and transfers it back to original sender.

Localnet provides a simulated environment for developing and testing ZetaChain contracts locally.

To start localnet, open a terminal window and run:

npx hardhat localnet

This command initializes a local blockchain environment that simulates the behavior of ZetaChain protocol contracts.

Run the following script, which will deploy NFT contracts on localnet, set counterparty addresses, mint an NFT and transfer it ZetaChain → Ethereum → BNB → ZetaChain:

./scripts/localnet.sh

On localnet Ethereum, BNB, and ZetaChain are all running on the same Anvil blockchain, but each has its own Gateway, which allows us to simulate cross-chain transfers.

🚀 Deployed NFT contract on ZetaChain: 0x2E2Ed0Cfd3AD2f1d34481277b3204d807Ca2F8c2
🚀 Deployed NFT contract on EVM chain: 0xD8a5a9b31c3C0232E196d518E89Fd8bF83AcAd43
🚀 Deployed NFT contract on BNB chain: 0xDC11f7E700A4c898AE5CAddB1082cFfa76512aDD

📮 User Address: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266

🔗 Setting counterparty contracts...

🖼️  NFT Balance
---------------------------------------------
🟢 ZetaChain: 0
🔵 EVM Chain: 0
🟡 BNB Chain: 0
---------------------------------------------

Minted NFT with ID: 360200380862174510501150413599333672688790691765 on ZetaChain.

🖼️  NFT Balance
---------------------------------------------
🟢 ZetaChain: 1
🔵 EVM Chain: 0
🟡 BNB Chain: 0
---------------------------------------------

Transferring NFT: ZetaChain → Ethereum...
{"contractAddress":"0x2E2Ed0Cfd3AD2f1d34481277b3204d807Ca2F8c2","transferTransactionHash":"0x044ac700fe16a498d5474c383606efb996425d62323fcdd8b53eadeaa487cb3a","sender":"0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266","tokenId":"360200380862174510501150413599333672688790691765"}

🖼️  NFT Balance
---------------------------------------------
🟢 ZetaChain: 0
🔵 EVM Chain: 1
🟡 BNB Chain: 0
---------------------------------------------

Transferring NFT: Ethereum → BNB...
{"contractAddress":"0xD8a5a9b31c3C0232E196d518E89Fd8bF83AcAd43","transferTransactionHash":"0xc95a1ca71406dbb2e4a1f01b32b62cfa1f72d3c0923d2ea1e2967f8d837899e2","sender":"0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266","tokenId":"360200380862174510501150413599333672688790691765"}

🖼️  NFT Balance
---------------------------------------------
🟢 ZetaChain: 0
🔵 EVM Chain: 0
🟡 BNB Chain: 1
---------------------------------------------

Transferring NFT: BNB → ZetaChain...
{"contractAddress":"0xDC11f7E700A4c898AE5CAddB1082cFfa76512aDD","transferTransactionHash":"0xb139d188c59c347540dbdea26f30f149f8131ce10ebc6f50ed7253dfd04ff014","sender":"0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266","tokenId":"360200380862174510501150413599333672688790691765"}

🖼️  NFT Balance
---------------------------------------------
🟢 ZetaChain: 1
🔵 EVM Chain: 0
🟡 BNB Chain: 0
---------------------------------------------

Before proceeding, you might want to check out the Testnet Setup guide to learn how to set up an account and request testnet tokens.

To launch a universal NFT, deploy the Universal contract to ZetaChain and the Connected contract to one or more connected chains, and set counterparty addresses. To set everything up automatically run ./scripts/testnet.sh or follow the steps below to do it manually.

npx hardhat compile --force --quiet
npx hardhat deploy --name Universal --network zeta_testnet --gateway 0x6c533f7fe93fae114d0954697069df33c9b74fd7 --system-contract 0xEdf1c3275d13489aCdC6cD6eD246E72458B8795B --gas-limit 500000 --json

UNIVERSAL=0x83E60C63b38974Cfe89c287f2b9ddF153eBD13A1
npx hardhat deploy --name Connected --network base_sepolia --gateway 0x0c487a766110c85d301d96e33579c5b317fa4995 --gas-limit 500000 --json

CONNECTED_BASE=0x0A6DdaA0C1592593632EbE96B02a1305B6aA72c8
npx hardhat deploy --name Connected --network bsc_testnet --gateway 0x0c487a766110c85d301d96e33579c5b317fa4995 --gas-limit 500000 --json

CONNECTED_BNB=0x48D512595699A1c1c40C7B5Fc378512Ab0dCFAd7
npx hardhat connected-set-counterparty --network base_sepolia --contract 0x0A6DdaA0C1592593632EbE96B02a1305B6aA72c8 --counterparty 0x83E60C63b38974Cfe89c287f2b9ddF153eBD13A1 --json
npx hardhat connected-set-counterparty --network bsc_testnet --contract 0x48D512595699A1c1c40C7B5Fc378512Ab0dCFAd7 --counterparty 0x83E60C63b38974Cfe89c287f2b9ddF153eBD13A1 --json
npx hardhat universal-set-counterparty --network zeta_testnet --contract 0x83E60C63b38974Cfe89c287f2b9ddF153eBD13A1 --counterparty 0x0A6DdaA0C1592593632EbE96B02a1305B6aA72c8 --zrc20 0x236b0DE675cC8F46AE186897fCCeFe3370C9eDeD --json
npx hardhat universal-set-counterparty --network zeta_testnet --contract 0x83E60C63b38974Cfe89c287f2b9ddF153eBD13A1 --counterparty 0x48D512595699A1c1c40C7B5Fc378512Ab0dCFAd7 --zrc20 0xd97B1de3619ed2c6BEb3860147E30cA8A7dC9891 --json

Mint a universal NFT on ZetaChain:

npx hardhat mint --network zeta_testnet --json --contract 0x83E60C63b38974Cfe89c287f2b9ddF153eBD13A1 --token-uri https://example.com/nft/metadata/1

{"contractAddress":"0x83E60C63b38974Cfe89c287f2b9ddF153eBD13A1","mintTransactionHash":"0xf2544827e5ac4434a300bca190f6b5e391e3cd7c87e515ac2dee4f7cb3b14e44","recipient":"0x4955a3F38ff86ae92A914445099caa8eA2B9bA32","tokenURI":"https://example.com/nft/metadata/1","tokenId":"1145931601449361378070955209842677114337257109052"}

Transfer the NFT from ZetaChain to BNB testnet (represented by its ZRC-20 gas token address 0xd97B1de3619ed2c6BEb3860147E30cA8A7dC9891):

npx hardhat transfer --network zeta_testnet --json --token-id 1145931601449361378070955209842677114337257109052 --from 0x83E60C63b38974Cfe89c287f2b9ddF153eBD13A1 --to 0xd97B1de3619ed2c6BEb3860147E30cA8A7dC9891
{"contractAddress":"0x83E60C63b38974Cfe89c287f2b9ddF153eBD13A1","transferTransactionHash":"0x418ebe5cd999c19ec4a93a2295f25508728c655fb95bc5773c366a956ceac842","sender":"0x4955a3F38ff86ae92A914445099caa8eA2B9bA32","tokenId":"1145931601449361378070955209842677114337257109052"}

Notice that the transaction has been processed on ZetaChain:

https://zetachain-testnet.blockscout.com/tx/0x418ebe5cd999c19ec4a93a2295f25508728c655fb95bc5773c366a956ceac842 (opens in a new tab)

You can track the cross-chain transaction (CCTX) progress using the API:

https://zetachain-athens.blockpi.network/lcd/v1/public/zeta-chain/crosschain/inboundHashToCctxData/0x418ebe5cd999c19ec4a93a2295f25508728c655fb95bc5773c366a956ceac842 (opens in a new tab)

After the CCTX has been processed, you will see the NFT with the same token ID in the recipient's address on the BNB testnet.

You can send the NFT back from BNB testnet to ZetaChain:

npx hardhat transfer --network bsc_testnet --json --token-id 1145931601449361378070955209842677114337257109052 --from 0x48D512595699A1c1c40C7B5Fc378512Ab0dCFAd7
{"contractAddress":"0x48D512595699A1c1c40C7B5Fc378512Ab0dCFAd7","transferTransactionHash":"0x4459aa5369f7a1c5caf8f7eb26082ff5699cffec7748e95145030df7493ffe17","sender":"0x4955a3F38ff86ae92A914445099caa8eA2B9bA32","tokenId":"1145931601449361378070955209842677114337257109052"}

https://testnet.bscscan.com/tx/0x4459aa5369f7a1c5caf8f7eb26082ff5699cffec7748e95145030df7493ffe17 (opens in a new tab)

https://zetachain-athens.blockpi.network/lcd/v1/public/zeta-chain/crosschain/inboundHashToCctxData/0x4459aa5369f7a1c5caf8f7eb26082ff5699cffec7748e95145030df7493ffe17 (opens in a new tab)