Token Metadata and How to Issue NFT on Tezos

Last time, we talked about FA1.2 and published a token smart contract on a Tezos testnet. We, however, omitted metadata, so the token didn’t show in the wallet automatically. This time, we’ll be adding metadata to the smart contract so that wallets and blockchain explorers could see it. Then we’ll study the NFT standard in Tezos and create a non-fungible token.

How apps see tokens

Tokens are not coins or separate files but records in the storage of smart contracts. If you have 100 kUSD in your wallet, it means that there is a record in the contract storage saying “Address TZ1 holds 100 tokens.”

Aside from user balances, the storage also holds metadata, i.e. information about the smart contract itself: its name, brief description of the token, its ticker, number of digits after the decimal point, ID, a link to the logo, the owner’s address, pseudo-entry points and other parameters.

Metadata is required for apps to see tokens and correctly display information on them. If there is no metadata, the app won’t see the token, and if they are wrong, the display will also be faulty.

Create a JSON file with metadata

As of 2020, most Tezos developers use the standard TZIP-16 which dictates that metadata is stored in a JSON file. The developer has to upload the file on any public server, such as their own website, IPFS, or GitHub, and then put a link thereto in the smart contract’s storage.

We’ll be adding metadata to the token.ligo code we worked with last time. To do that, use Gist by Github: create a JSON file and get a uniform resource identifier (URI). Sign in or up on Github and go to Gist. Click + in the upper right corner of the window to create a new file.


Name the file fa12-metadata.json and paste the template in it:

  "symbol": "Shorthand, ticker",
  "name": "Full name",
  "decimals": "0",
  "icon": "a link to the picture with the extension of .jpg, .png or .svg",
  "description": "Token description",
  "authors": "author",
  "interfaces": ["TZIP-007-2021-04-17", "TZIP-016-2021-04-17"]

Fill in the fields by adding the ticker, the token’s name, and add a link to the logo. Leave the fields decimals and interfaces untouched.

  • decimals are the number of digits after the decimal point. If you set it over 0 (for instance, 3), wallets will show the token as 0.001. We keep the default value to avoid overcomplicating the example.
  • interfaces are the rules of displaying metadata and the list of standard entry points the contract uses. Making alterations to this field may bring about problems with displaying the token in ceratin wallets.

Open the drop-down menu from the green button and select Create public gist, then click the button. Thus you save the file and make it available to all users.


After refreshing the page, click Raw in the upper right corner of the window to open the file from a direct link.


Don’t close the tab with the file, we’ll need it in a few moments.


Adding metadata to the smart contract

A URI link to the metadata has to have a special format to be stored in the storage: metadata : big_map (string, bytes). Let’s add it to storage.

Start VS Code and open token.ligo. Find the declaration of storage type in the beginning and add the metadata format there.


We have updated the contract in LIGO, now we have to compile it into JSON format to publish it on the testnet. Copy the token.ligo’s content and go to Select Compile Contract from the drop-down list and flag the field Output Michelson in JSON format. Press Run. The compiler will produce the final code under the editor field.


Go to VS Code, open token.json and replace the old code with the new one, then save the file.

Now, storage has a metadata field: wallets and blockchain explorers will look for a link to a JSON file with metadata there. Right now, the field is empty. We’ll specify the file’s URI in the contract’s storage prior to deploying it. For the Michelson VM to read the link, it has to be translated into bytes. Go back to the URI tab at meta.json and copy the address.


Open an online converter like Onlinestringtools. Put the link’s text in String and unflag Add Whitespaces to remove empty spaces between bytes. The converter will produce a long number in the field Bytes. It is the link in byte format. We’ll use it later, so don’t close the tab.


Go to VS Code and create a file called Paste the storage template there:

'(Pair (Pair { Elt "public address from acc.json" (Pair { Elt "public address from acc.json" number of tokens } number of tokens) } { Elt "" 0xlink to meta.json in byte format }) number of tokens)'

Fill in the template’s fields. make sure you leave an empty string “” under the link to meta.json. It is the key that helps the VM understand that what follows is a link to metadata.

The public address must be in quotation marks, while the number of tokens and the link must not. Make sure to write 0x before the byte-format link, otherwise, the compiler won’t be able to read it.


Copy the code from storage.js and switch to token-deploy.js. Replace the old storage state record with the new one.


Open the terminal in VS Code. Make sure you are in taq-test and execute the following command:

npx ts-node token-deploy-ts

Taquito will produce a link to the operation’s hash. Go to, find the contract address from the hash, and copy it. Check if the wallets see metadata: prepare and execute a transaction. Open transfer.ts and replace the old token address with the newly published one.


Open the terminal and execute:

npx ts-node transfer.ts

Wait for the transaction to be confirmed and check the wallet. If you’ve done everything right, it will display your tokens.


Here comes the FA 2

The uniqueness of NFTs (non-fungible tokens) rests on two factors: the issuer contract address and ID. Fungible tokens have no IDs.

NFTs can be issued through a simple contract with two functions: transfer to transfer the tokens, and update to update user balances. Technically, such a token will be unique. Still, if you don’t stick to FA1.2 and FA2 standards, wallets will never see this NFT, and you will have to use the Tezos client and the terminal to send the token.

To make working with NFTs simpler, Tezos devs created the standard FA2. It describes the interface for working with fungible, non-fungible, non-transferable, and other sorts of tokens. FA2 is way more complicated than FA1.2 but it gives more leeway to developers. Thus, one can issue several tokens within the same contract or put transactions in batches to save gas.

The code of an FA2 token consists of four parts:

  • messages about basic errors;
  • interfaces: declaring all custom data types;
  • functions for work with operators, i.e. users that can transfer tokens;
  • core: metadata, storage, token transfer and balance check functions.

Implementing the NFT standard FA2 in LIGO looks like this:


const fa2_token_undefined = "FA2_TOKEN_UNDEFINED"
const fa2_insufficient_balance = "FA2_INSUFFICIENT_BALANCE"
const fa2_tx_denied = "FA2_TX_DENIED"
const fa2_not_owner = "FA2_NOT_OWNER"
const fa2_not_operator = "FA2_NOT_OPERATOR"
const fa2_operators_not_supported = "FA2_OPERATORS_UNSUPPORTED"
const fa2_receiver_hook_failed = "FA2_RECEIVER_HOOK_FAILED"
const fa2_sender_hook_failed = "FA2_SENDER_HOOK_FAILED"
const fa2_receiver_hook_undefined = "FA2_RECEIVER_HOOK_UNDEFINED"
const fa2_sender_hook_undefined = "FA2_SENDER_HOOK_UNDEFINED"


//declaring the token id type, a natural number
type token_id is nat

//declaring the types of input parameters the token transfer function assumes: recipient address, id, and the number of tokens. Adding the sender’s address to the type transfer
type transfer_destination is
record [
 to_ : address;
 token_id : token_id;
 amount : nat;

type transfer is
record [
 from_ : address;
 txs : list(transfer_destination);

//declaring types to read the balance: the owner’s address, token id
type balance_of_request is
record [
 owner : address;
 token_id : token_id;

type balance_of_response is
record [
 request : balance_of_request;
 balance : nat;

type balance_of_param is
record [
 requests : list(balance_of_request);
 callback : contract (list(balance_of_response));

// declaring the operator type (the address that can send tokens)
type operator_param is
record [
 owner : address;
 operator : address;
 token_id: token_id;

//declaring the type of params required to update the list of operators
type update_operator is
 | Add_operator of operator_param
 | Remove_operator of operator_param

//declaring the type that contains NFT metadata: token ID and link to the JSON file
type token_info is (token_id * map(string, bytes))

type token_metadata is
big_map (token_id, token_info)

//declaring the type with the link to the smart contract’s metadata. The data will be shown in the wallet
type metadata is
big_map(string, bytes)

//declaring the type that can store records on several tokens and their metadata in the same contract
type token_metadata_param is
record [
 token_ids : list(token_id);
 handler : (list(token_metadata)) -> unit;

//declaring the pseudo-entry points: token transfer, balance check, operator update, and metadata check
type fa2_entry_points is
 | Transfer of list(transfer)
 | Balance_of of balance_of_param
 | Update_operators of list(update_operator)
 | Token_metadata_registry of contract(address)

type fa2_token_metadata is
 | Token_metadata of token_metadata_param

//declaring the data types to change permissions to transfer tokens. E.g., they can create a token that can’t be sent elsewhere
type operator_transfer_policy is
 | No_transfer
 | Owner_transfer
 | Owner_or_operator_transfer

type owner_hook_policy is
 | Owner_no_hook
 | Optional_owner_hook
 | Required_owner_hook

type custom_permission_policy is
record [
 tag : string;
 config_api: option(address);

type permissions_descriptor is
record [
 operator : operator_transfer_policy;
 receiver : owner_hook_policy;
 sender : owner_hook_policy;
 custom : option(custom_permission_policy);

type transfer_destination_descriptor is
record [
 to_ : option(address);
 token_id : token_id;
 amount : nat;

type transfer_descriptor is
record [
 from_ : option(address);
 txs : list(transfer_destination_descriptor)

type transfer_descriptor_param is
record [
 batch : list(transfer_descriptor);
 operator : address;


//declaring the type that stores records on operators in the same big_map
type operator_storage is big_map ((address * (address * token_id)), unit)

//declaring the function for updating the list of operators
function update_operators (const update : update_operator; const storage : operator_storage)
   : operator_storage is
 case update of
 | Add_operator (op) ->
   Big_map.update ((op.owner, (op.operator, op.token_id)), (Some (unit)), storage)
 | Remove_operator (op) ->
   Big_map.remove ((op.owner, (op.operator, op.token_id)), storage)

//declaring the function that checks if the user can update the list of operators
function validate_update_operators_by_owner (const update : update_operator; const updater : address)
   : unit is block {
     const op = case update of
       | Add_operator (op) -> op
       | Remove_operator (op) -> op
     if (op.owner = updater) then skip else failwith (fa2_not_owner)
   } with unit

//declaring the function that checks if the user can update the list of owner addresses, and only in that case calls the update function
function fa2_update_operators (const updates : list(update_operator); const storage : operator_storage) : operator_storage is block {
 const updater = Tezos.sender;
 function process_update (const ops : operator_storage; const update : update_operator) is block {
   const u = validate_update_operators_by_owner (update, updater);
 } with update_operators(update, ops)
} with List.fold(process_update, updates, storage)

type operator_validator is (address * address * token_id * operator_storage) -> unit

//declaring the function that checks the permissions to transfer tokens. If the user can’t send a token, the function terminates the contract’s execution
function make_operator_validator (const tx_policy : operator_transfer_policy) : operator_validator is block {
 const x = case tx_policy of
 | No_transfer -> (failwith (fa2_tx_denied) : bool * bool)
 | Owner_transfer -> (True, False)
 | Owner_or_operator_transfer -> (True, True)
 const can_owner_tx = x.0;
 const can_operator_tx = x.1;
 const inner = function (const owner : address; const operator : address; const token_id : token_id; const ops_storage : operator_storage):unit is
   if (can_owner_tx and owner = operator)
   then unit
   else if not (can_operator_tx)
   then failwith (fa2_not_owner)
   else if (Big_map.mem  ((owner, (operator, token_id)), ops_storage))
   then unit
   else failwith (fa2_not_operator)
} with inner

//declaring the function for the owner to transfer the token
function default_operator_validator (const owner : address; const operator : address; const token_id : token_id; const ops_storage : operator_storage) : unit is
 if (owner = operator)
 then unit
 else if Big_map.mem ((owner, (operator, token_id)), ops_storage)
 then unit
 else failwith (fa2_not_operator)

//declaring the function that collects all transactions of the same token in one batch
function validate_operator (const tx_policy : operator_transfer_policy; const txs : list(transfer); const ops_storage : operator_storage) : unit is block {
 const validator = make_operator_validator (tx_policy);
 List.iter (function (const tx : transfer) is
   List.iter (function (const dst : transfer_destination) is
     validator (tx.from_, Tezos.sender, dst.token_id ,ops_storage),
} with unit


//declaring the data type to store records on which address keeps the token with the given id
type ledger is big_map (token_id, address)

//declaring the contract storage: TZIP-16 metadata, ledger of addresses and tokens, list of operators, and on-chain metadata
type collection_storage is record [
 metadata : big_map (string, bytes);
 ledger : ledger;
 operators : operator_storage;
 token_metadata : token_metadata;

//declaring the token transfer function. It will get the token’s id, the addresses of the sender and the recipient, and checks whether the sender has the right to transfer the token
function transfer (
 const txs : list(transfer);
 const validate : operator_validator;
 const ops_storage : operator_storage;
 const ledger : ledger) : ledger is block {
   //checking the sender’s right to transfer the token
   function make_transfer (const l : ledger; const tx : transfer) is
     List.fold (
       function (const ll : ledger; const dst : transfer_destination) is block {
         const u = validate (tx.from_, Tezos.sender, dst.token_id, ops_storage);
       } with
         //checking the number of transferred NFT. We imply that the contract has issued just 1 token with this ID
         //if the user wants to send 0, 0.5, 2 or any other number of tokens, the function will terminate the contract’s execution
         if (dst.amount = 0n) then
         else if (dst.amount =/= 1n)
         then (failwith(fa2_insufficient_balance): ledger)
         else block {
           const owner = Big_map.find_opt(dst.token_id, ll);
         } with
           case owner of
             Some (o) ->
             //checking whether the sender has the token
             if (o =/= tx.from_)
             then (failwith(fa2_insufficient_balance) : ledger)
             else Big_map.update(dst.token_id, Some(dst.to_), ll)
           | None -> (failwith(fa2_token_undefined) : ledger)
} with List.fold(make_transfer, txs, ledger)

//declaring the function that returns the sender’s balance
function get_balance (const p : balance_of_param; const ledger : ledger) : operation is block {
 function to_balance (const r : balance_of_request) is block {
   const owner = Big_map.find_opt(r.token_id, ledger);
   case owner of
     None -> (failwith (fa2_token_undefined): record[balance: nat; request: record[owner: address ; token_id : nat]])
   | Some (o) -> block {
     const bal = if o = r.owner then 1n else 0n;
   } with record [request = r; balance = bal]
 const responses = (to_balance, p.requests);
} with Tezos.transaction(responses, 0mutez, p.callback)

//declaring the function with pseudo-entry points which underpin the very FA2 standard
function main (const param : fa2_entry_points; const storage : collection_storage) : (list (operation) * collection_storage) is
 case param of
   | Transfer (txs) -> block {
     const new_ledger = transfer (txs, default_operator_validator, storage.operators, storage.ledger);
     const new_storage = storage with record [ ledger = new_ledger ]
   } with ((list [] : list(operation)), new_storage)
   | Balance_of (p) -> block {
     const op = get_balance (p, storage.ledger);
   } with (list [op], storage)
   | Update_operators (updates) -> block {
     const new_operators = fa2_update_operators(updates, storage.operators);
     const new_storage = storage with record [ operators = new_operators ];
   } with ((list [] : list(operation)), new_storage)
   | Token_metadata_registry (callback) -> block {
     const callback_op = Tezos.transaction(Tezos.self_address, 0mutez, callback);
   } with (list [callback_op], storage)

Issuing NFT on FA 2

Open VS Code and create nft.ligo in taq-test. Paste therein the contents of the window above or copy the code from Gist.

Compile the code in LIGO into JSON. Copy the contents of the window file and go to There, select Compile Contract in the drop-down list and flag Output Michelson in JSON Format. Press Run. The compiler will produce the final code under the editor window. Create nft.json in VS Code and paste the code there.


Prepare two JSON files with metadata: for NFT and the smart contract. Go to Gist and click + in the upper right corner to create a new file. Name the file nft_meta.json and paste the following there:

  "symbol": "ticker",
  "name": "name of the NFT",
  "description": "Description",
  "decimals": "0",
  "isBooleanAmount": true,
  "artifactUri": "link to the tokenised object",
  "thumbnailUri": "logo of theNFT",
  "minter": "token issuer name",
  "interfaces": ["TZIP-007-2021-04-17", "TZIP-016-2021-04-17", "TZIP-21"]

Fill in all the fields except interfaces. Specify the links to the tokenised object and the token’s logo as https://… rather than in byte format. Open the drop-down menu on the green button and select Create public gist and click the button. Thus you save the file and make it available to any user. After refreshing the page, click Raw to open the file via a direct link. Don’t close the tab. In Gist, create contract_meta.json and paste therein the following template:

  "name": "contract name",
  "description": "contract description",
  "interfaces": ["TZIP-012-2020-11-17"]

Fill in the contract’s metadata, click on Create Public Gist, and then press Raw. Don’t close the tab, we’ll need it in a few moments. Meanwhile, prepare the script for publishing NFTs. Create nft-deploy.ts and paste therein the code from token-deploy.js. Find the method code: JSON.parse() and replace the readable file’s name:

- code: JSON.parse(fs.readFileSync("./token.json").toString()),
+ code: JSON.parse(fs.readFileSync("./nft.json").toString()),


To publish the smart contract, we have to specify the state of the storage. To do that, create the file nft-storage with no extension and paste the following therein:

'(Pair (Pair { Elt id "your wallet address" } { Elt "" 0x link to contract metadata in byte format}) { Elt (Pair "your wallet address" "your wallet address" id) Unit } { Elt 0 (Pair id { Elt "" 0x link to contract metadata in byte format }) })'

Fill in the fields in the template. The code shall be one string without interruptions. The token’s id has to be written without quotation marks. Don’t use any script other than Latin in the token’s name as Michelson works exclusively with Unicode. We issue the token with id=5:


Put the storage state code in nft-deploy.ts in the field init. Don’t forget to separate it with apostrophes and put a comma. Our script looked like that:


Open the terminal and execute:

npx ts-node nft-deploy.js

Wait for the deployment to end and check your wallet. The NFT will arrive in about a minute.


If the deployment was fault-free yet the wallet fails to show the token, check the network. The wallet may be connected to the mainnet rather than the testnet. As a last resort, add the token manually by pressing Manage. Sometimes, when new tokens are issued directly into the user’s address, the wallet may not display them until the first transaction. You will see the token’s id and the availability of the NFT on your balance but not the tokenised content. Wallets are unable to show it as they lack the interface to read the field artifactUri, unlike NFT marketplaces.


Go to the blockchain explorer BetterCallDev and find your contract from the operation hash or the address. Then go to the tab Tokens. The link to the tokenised content is in the field artifact_uri.


In order to issue or burn an NFT, you have to add respective functions to the contract code. We omitted that to avoid overcomplicating the first acquaintance with FA2.

Final conclusions

Metadata is a standardised description of a smart contract/token that includes the name, ticker, description, decimals, logo link, supported interfaces, and other data. The simplest way to add metadata is to use the standard TZIP-16. According to it, info on the token and the smart contract is stored in JSON files that have to be stored on a public server like GitHub. After that, add the links to the storage. This approach’s main advantage is that you can revise files in case there has been a mistake.

NFT exist thanks to metadata. The developer records the tokens identifier and the link to the tokenised content therein. The connection between the ID and the contract address is unique, so NFT with similar content and metadata will still be different from each other.

  • Written by Pavel Skoroplyas
  • Produced by Svetlana Lukina
  • Stylistic framework by Dmitri Boyko
  • Visuals by Krzystof Szpak
  • Layouts by Zara Arakelian
  • Development by Oleksandr Pupko
  • Directed by Vlad Likhuta