# 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:
- one NFT for another NFT
- 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
- To register a factory, creators will sign the following message with data
factory_address
andcreator_address
.
"\x19Ethereum Signed Message:\n141",
"\nCreator's account ID in zkSync: {creatorIdInHex}",
"\nCreator: {CreatorAddressInHex}",
"\nFactory: {FactoryAddressInHex}"
- The factory contract calls
registerNFTFactoryCreator
on the zkSync L1 smart contract with the signature. - zkSync smart contract validates the signature and emits an event with
factory_address
andcreator_address
.