FA2 Token Development (Tezos)
FA2 (TZIP-12) is multi-asset token standard in Tezos ecosystem. Unlike ERC-20/ERC-721, one FA2 contract can manage fungible and non-fungible tokens simultaneously with arbitrary token_id. This is architectural solution: instead of deploying separate contract for each token — one contract for entire asset portfolio.
Tezos smart contracts are written primarily in SmartPy or LIGO. Runtime is Michelson. Understanding Michelson is needed for debugging and gas optimization, but writing directly in it isn't necessary.
FA2 Specification: mandatory interface
Transfer entrypoint
Key entrypoint — transfer. Takes list transfer_batch: each batch describes sender, list of recipients and token_id/amount.
# SmartPy: FA2 transfer type
# transfer_params: list of {from_: address, txs: list of {to_: address, token_id: nat, amount: nat}}
@sp.entrypoint
def transfer(self, batch):
for transfer in batch:
from_ = transfer.from_
# Check authorization: sender himself or approved operator
sp.verify(
(from_ == sp.sender) |
self.data.operators.contains(sp.record(
owner=from_,
operator=sp.sender,
token_id=transfer.txs[0].token_id # per-token operator
)),
"FA2_NOT_OPERATOR"
)
for tx in transfer.txs:
self._transfer_tokens(from_, tx.to_, tx.token_id, tx.amount)
Operators in FA2 are third parties whom owner allows to manage his tokens. Analogue of setApprovalForAll in ERC-721, but more granular: can give permission for specific token_id.
Balance_of view
@sp.onchain_view()
def balance_of(self, requests):
# requests: list of {owner: address, token_id: nat}
# returns: list of {request: ..., balance: nat}
results = []
for req in requests:
balance = self.data.ledger.get(
sp.record(owner=req.owner, token_id=req.token_id),
default=0
)
results.append(sp.record(request=req, balance=balance))
return results
This is on-chain view — doesn't change state, called from other contracts or off-chain. Tezos differs from EVM in that views are explicitly declared as onchain_view.
Update_operators
@sp.entrypoint
def update_operators(self, updates):
for update in updates:
update.match_cases({
"add_operator": lambda op: self._add_operator(op),
"remove_operator": lambda op: self._remove_operator(op),
})
def _add_operator(self, op):
# Only owner can add operators for his tokens
sp.verify(op.owner == sp.sender, "FA2_NOT_OWNER")
self.data.operators.add(sp.record(
owner=op.owner,
operator=op.operator,
token_id=op.token_id
))
Complete FA2 implementation on SmartPy
import smartpy as sp
FA2_ERRORS = sp.utils.import_script_from_url(
"https://smartpy.io/templates/fa2_lib.py"
)
@sp.module
def main():
class FA2Token(
FA2_ERRORS.Fungible, # fungible token mixin
FA2_ERRORS.Admin, # admin control
FA2_ERRORS.MintFungible,
FA2_ERRORS.BurnFungible,
sp.Contract
):
def __init__(self, admin, metadata, token_metadata):
FA2_ERRORS.Admin.__init__(self, admin)
FA2_ERRORS.Fungible.__init__(self, {
"ledger": sp.big_map(), # (owner, token_id) → balance
"operators": sp.big_map(), # (owner, operator, token_id) → unit
})
self.init_metadata("metadata", metadata)
# Initialize token 0
self.data.token_metadata = sp.big_map({
0: sp.record(
token_id=0,
token_info=token_metadata
)
})
self.data.supply = sp.big_map({0: 0})
@sp.entrypoint
def mint(self, to_, token_id, amount):
sp.verify(sp.sender == self.data.admin, "NOT_ADMIN")
# Update recipient balance
key = sp.record(owner=to_, token_id=token_id)
current = self.data.ledger.get(key, default=0)
self.data.ledger[key] = current + amount
# Update total supply
self.data.supply[token_id] = self.data.supply.get(token_id, default=0) + amount
Storage: big_map vs map
In Tezos important to distinguish map and big_map:
-
map— stored fully in memory when executing. Expensive in gas for large collections. -
big_map— lazy loaded. When accessing key you pay only for reading that key. For ledger with thousands of addresses —big_mapis mandatory.
# Correct: big_map for ledger
self.data.ledger = sp.big_map(
tkey=sp.TRecord(owner=sp.TAddress, token_id=sp.TNat),
tvalue=sp.TNat
)
# Incorrect for production: map recalculated fully on each access
# self.data.ledger = sp.map(...) — only for small collections
Tzip-16: token metadata
TZIP-16 is metadata standard for Tezos contracts. Metadata stored in contract storage or linked to IPFS/HTTPS.
# Contract metadata (TZIP-16)
contract_metadata = {
"name": "My FA2 Token",
"description": "Utility token for platform",
"interfaces": ["TZIP-012", "TZIP-016"],
"homepage": "https://example.com"
}
# Token metadata (TZIP-021 for NFT)
token_metadata = {
"name": sp.utils.bytes_of_string("MyToken"),
"symbol": sp.utils.bytes_of_string("MTK"),
"decimals": sp.utils.bytes_of_string("6"),
"description": sp.utils.bytes_of_string("Utility token"),
"thumbnailUri": sp.utils.bytes_of_string("ipfs://Qm...")
}
Standard requires storing values as bytes (UTF-8 encoded). Tools (wallets, explorers) decode back to strings. Creates inconvenience in code, but ensures compatibility.
Multi-asset: multiple tokens in one contract
FA2's strength is token_id. One contract, three tokens:
# Token 0: governance token (fungible)
# Token 1: utility token (fungible)
# Token 2-10000: NFT collection (non-fungible, amount=1 always)
# On NFT mint
@sp.entrypoint
def mint_nft(self, to_, token_id, metadata):
sp.verify(sp.sender == self.data.admin, "NOT_ADMIN")
sp.verify(~self.data.ledger.contains(
sp.record(owner=to_, token_id=token_id)), "NFT_EXISTS")
self.data.ledger[sp.record(owner=to_, token_id=token_id)] = 1
self.data.token_metadata[token_id] = sp.record(
token_id=token_id,
token_info=metadata
)
For NFT: never mint amount > 1 for one token_id. This is convention, not protocol-enforced.
Testing with SmartPy
# SmartPy unit test
@sp.add_test(name="FA2 Transfer Test")
def test():
sc = sp.test_scenario(main)
admin = sp.test_account("Admin")
alice = sp.test_account("Alice")
bob = sp.test_account("Bob")
token = main.FA2Token(
admin=admin.address,
metadata={"": sp.utils.bytes_of_string("ipfs://...")},
token_metadata={"symbol": sp.utils.bytes_of_string("MTK")}
)
sc += token
# Mint 1000 tokens to Alice
token.mint(to_=alice.address, token_id=0, amount=1000).run(sender=admin)
# Alice transfers 100 tokens to Bob
token.transfer([sp.record(
from_=alice.address,
txs=[sp.record(to_=bob.address, token_id=0, amount=100)]
)]).run(sender=alice)
# Verify balances
sc.verify(token.balance_of([
sp.record(owner=alice.address, token_id=0)
])[0].balance == 900)
sc.verify(token.balance_of([
sp.record(owner=bob.address, token_id=0)
])[0].balance == 100)
Deployment and tools
SmartPy IDE (smartpy.io) — browser IDE with simulator. Taquito — JavaScript library for Tezos interaction (ethers.js analogue). Ghostnet — main Tezos testnet. Temple Wallet / Kukai — wallets for testing.
// Taquito: interaction with FA2 contract
import { TezosToolkit } from "@taquito/taquito"
const Tezos = new TezosToolkit("https://ghostnet.ecadinfra.com")
const contract = await Tezos.contract.at("KT1...")
// Transfer tokens
const op = await contract.methods.transfer([{
from_: "tz1Alice...",
txs: [{ to_: "tz1Bob...", token_id: 0, amount: 100 }]
}]).send()
await op.confirmation(3)
Process
Requirements analysis (2-3 days). How many token_id, fungible or NFT or mixed, custom entrypoints (whitelist, vesting), TZIP-16/21 requirements.
Development (1-3 weeks). Basic FA2 contract → custom logic → tests → deploy on Ghostnet → verification through explorer (tzkt.io).
Integration (1-2 weeks). Taquito frontend or backend integration, wallet connection, transaction tracking.
Typical FA2 token with standard functionality — 1-2 weeks. With custom tokenomics (vesting, bonding curve, governance) — 3-6 weeks.







