# NFTs in zkSync Lite

Support for NFTs on zkSync Lite is here! Functions include minting, transferring, and atomically swapping NFTs. Users will also be able to withdraw NFTs to Layer 1.

This page demonstrates how NFTs are implemented in zkSync Lite and provides a tutorial for you to integrate NFTs into your project.

# Overview

NFT addresses will encode NFT content and metadata as follows:

address = truncate_to_20_bytes(rescue_hash(creator_account_id || serial_id || content_hash));

This cryptographically ensures two invariants:

  • NFT addresses serve as a unique commitment to the creator, serial number of the NFT, and its content hash.
  • NFT addresses can not be controlled by anyone or have smart contract code on mainnet.

NOTICE: In zkSync Lite, multiple NFTs can be minted with the same content hash.

# Setup

Please read our Getting Started guide before beginning this tutorial.

# Install the zkSync library

yarn add zksync

# Connect to zkSync network

For this tutorial, let's connect to the Goerli testnet. The steps for mainnet and Goerli would be identical.

const syncProvider = await zksync.getDefaultProvider('goerli');

# Mint

To mint an NFT, we will introduce a new opcode MINT_NFT with arguments:

  • creator_account_id
  • content_hash
  • recipient_account_id

By passing in recipient_account_id, we allow creators to choose whether to mint to themselves or directly to others.

# Enforcing Uniqueness

To enforce uniqueness of NFT token IDs, we use the last account in the zkSync balance tree to track token IDs. This account, which we will refer to as SpecialNFTAccount, will have a balance of SPECIAL_NFT_TOKEN representing the token_id of the latest mint.

// token ID is represented by:
SpecialNFTAccount[SPECIAL_NFT_TOKEN];
// for every mint, we increment the token ID of the NFT account
SpecialNFTAccount[SPECIAL_NFT_TOKEN] += 1;

To enforce uniqueness of NFT token addresses, recall serial_id is an input in the hash that generates the address. Creator accounts will have a balance of SPECIAL_NFT_TOKEN representing the serial_id, the number of NFTs that have been minted by the creator.

// serial ID is represented by:
CreatorAccount[SPECIAL_NFT_TOKEN];
// for every mint, we increment the serial ID of the creator account
CreatorAccount[SPECIAL_NFT_TOKEN] += 1;

zkSync servers will maintain a mapping of NFT token addresses to token IDs.

# Calculate Transaction Fee

To calculate the transaction fee for minting an NFT, you can use the getTransactionFee method from the Provider class.

Signature

async getTransactionFee(
    txType: 'Withdraw' | 'Transfer' | 'FastWithdraw' | 'MintNFT' | ChangePubKeyFee | LegacyChangePubKeyFee,
    address: Address,
    tokenLike: TokenLike
): Promise<Fee>

Example:

const { totalFee: fee } = await syncProvider.getTransactionFee('MintNFT', syncWallet.address(), feeToken);

# Mint the NFT

You can mint an NFT by calling the mintNFT function from the Wallet class.

Signature

async mintNFT(mintNft: {
    recipient: string;
    contentHash: string;
    feeToken: TokenLike;
    fee?: BigNumberish;
    nonce?: Nonce;
}): Promise<Transaction>
Name Description
recipient the recipient address represented as a hex string
contentHash an identifier of the NFT represented as a 32-byte hex string (e.g. IPFS content identifier)
feeToken name of token in which fee is to be paid (typically ETH)
fee transaction fee

Example:

const contentHash = '0xbd7289936758c562235a3a42ba2c4a56cbb23a263bb8f8d27aead80d74d9d996';
const nft = await syncWallet.mintNFT({
  recipient: syncWallet.address(),
  contentHash,
  feeToken: 'ETH',
  fee
});

# Get a Receipt

To get a receipt for the minted NFT:

const receipt = await nft.awaitReceipt();

# View the NFT

After an NFT is minted, it can be in two states: committed and verified. An NFT is committed if it has been included in a rollup block, and verified when a zero knowledge proof has been generated for that block and the root hash of the rollup block has been included in the smart contract on Ethereum mainnet.

To view an account's NFTs:

// Get state of account
const state = await syncWallet.getAccountState();
// View committed NFTs
console.log(state.committed.nfts);
// View verified NFTs
console.log(state.verified.nfts);

You may also find the getNFT function from the Wallet class useful.

Signature

async getNFT(tokenId: number, type: 'committed' | 'verified' = 'committed'): Promise<NFT>

# Transfer

Users can transfer NFTs to existing accounts and transfer to addresses that have not yet registered a zkSync account. TRANSFER and TRANSFER_TO_NEW opcodes will work the same.

An NFT can only be transferred after the block with it's mint transaction is verified. This means the newly minted NFT may have to wait a few hours before it can be transferred. This only applies to the first transfer; all following transfers can be completed with no restrictions.

You can transfer an NFT by calling the syncTransferNFT function:

async syncTransferNFT(transfer: {
    to: Address;
    token: NFT;
    feeToken: TokenLike;
    fee?: BigNumberish;
    nonce?: Nonce;
    validFrom?: number;
    validUntil?: number;
}): Promise<Transaction[]>
Name Description
to the recipient address represented as a hex string
feeToken name of token in which fee is to be paid (typically ETH)
token NFT object
fee transaction fee

The syncTransferNFT function works as a batched transaction under the hood, so it will return an array of transactions where the first handle is the NFT transfer and the second is the fee.

const handles = await sender.syncTransferNFT({
  to: receiver.address(),
  feeToken,
  token: nft,
  fee
});

# Get a Receipt

To get a receipt for the transfer:

const receipt = await handles[0].awaitReceipt();

# Swap

The swap function can be used to atomically swap:

  1. one NFT for another NFT
  2. one NFT for fungible tokens (buying the NFT)

# Swap NFTs

To swap 2 NFTs, each party will sign an order specifying the NFT ids for the NFT they are selling and the NFT they are buying.

Using the getOrder method:

const order = await wallet.getOrder({
  tokenSell: myNFT.id,
  tokenBuy: anotherNFT.id,
  amount: 1,
  ratio: utils.tokenRatio({
    [myNFT.id]: 1,
    [anotherNFT.id]: 1
  })
});

Note: when performing an NFT to NFT swap, the ratios will always be set to one.

After 2 orders are signed, anyone can initiate the swap by calling the syncSwap method:

// whoever initiates the swap pays the fee
const swap = await submitter.syncSwap({
  orders: [orderA, orderB],
  feeToken: 'ETH'
});

To get a receipt:

const receipt = await swap.awaitReceipt();

# Buy / Sell NFTs

To buy or sell an NFT for fungible tokens, each party will sign an order specifying the NFT id and the name of the token they are spending/receiving. In the example, pay special attention to the ratio parameter. You can find a list of available tokens and their symbols in our explorer (opens new window).

const buyingNFT = await walletA.getOrder({
  tokenBuy: nft.id,
  tokenSell: 'USDT',
  amount: tokenSet.parseToken('USDT', '100'),
  ratio: utils.tokenRatio({
    USDT: 100,
    [nft.id]: 1
  })
});

const sellingNFT = await walletB.getOrder({
  tokenBuy: 'USDT',
  tokenSell: nft.id,
  amount: 1,
  ratio: utils.tokenRatio({
    USDT: 100,
    [nft.id]: 1
  })
});

# Withdrawal to Layer 1

Withdrawals to L1 will require 3 actors:

  • Factory: L1 contract that can mint L1 NFT tokens
  • Creator: user which mints NFT on L2
  • NFTOwner: user which owns NFT on L2

This guide will demonstrate 2 types of withdrawals: normal and emergency, and explain under what conditions each type should be used. It also explains the architecture of the NFT token bridge between zkSync and L1, and what is needed if protocols want to implement their own NFT factory contract on L1.

# Withdraw NFT

Under normal conditions use a layer 2 operation, withdrawNFT, to withdraw the NFT.

Signature

withdrawNFT(withdrawNFT: {
    to: string;
    token: number;
    feeToken: TokenLike;
    fee?: BigNumberish;
    nonce?: Nonce;
    fastProcessing?: boolean;
    validFrom?: number;
    validUntil?: number;
}): Promise<Transaction>;
Name Description
to L1 recipient address represented as a hex string
feeToken name of token in which fee is to be paid (typically ETH)
token id of the NFT
fee transaction fee
fastProcessing pay additional fee to complete block immediately, skip waiting for other transactions to fill the block
const withdraw = await wallet.withdrawNFT({
  to,
  token,
  feeToken,
  fee,
  fastProcessing
});

Get the receipt:

const receipt = await withdraw.awaitReceipt();

# Emergency Withdraw

In case of censorship, users may call for an emergency withdrawal. Note: This is a layer 1 operation, and is analogous to our fullExit mechanism.

Signature

async emergencyWithdraw(withdraw: {
        token: TokenLike;
        accountId?: number;
        ethTxOptions?: ethers.providers.TransactionRequest;
    }): Promise<ETHOperation>
Name Description
token id of the NFT
accountId (Optional) account id for fullExit
const emergencyWithdrawal = await wallet.emergencyWithdraw({ token, accountId });
const receipt = await emergencyWithdrawal.awaitReceipt();

# Factory and zkSync Smart Contract Interaction

We have a default factory contract that will handle minting NFTs on L1 for projects that do not want to implement their own minting contract. Projects with their own minting contracts only need to implement one minting function: mintNFTFromZkSync. Example: mintNFTFromZkSync (opens new window).

mintNFTFromZkSync(creator: address, recipient: address, creatorAccountId: uint32, serialId: uint32, contentHash: bytes32, tokenId: uint256)

The zkSync Governance contract will implement a function registerNFTFactoryCreator that will register creators as a trusted minter on L2 for the factory contract. Example: registerNFTFactoryCreator (opens new window).

registerNFTFactoryCreator(creatorAccountId: uint32, creatorAddress: address, signature: bytes)

To withdraw, users call withdrawNFT() with the token_id. The zkSync smart contract will verify ownership, burn the token on L2, and call mintNFTFromZkSync on the factory corresponding to the creator.

# Factory Registration

  1. To register a factory, creators will sign the following message with data factory_address and creator_address.
"\x19Ethereum Signed Message:\n141",
"\nCreator's account ID in zkSync: {creatorIdInHex}",
"\nCreator: {CreatorAddressInHex}",
"\nFactory: {FactoryAddressInHex}"
  1. The factory contract calls registerNFTFactoryCreator on the zkSync L1 smart contract with the signature.
  2. zkSync smart contract validates the signature and emits an event with factory_address and creator_address.