Разработка токена стандарта FA2 (Tezos)
FA2 (TZIP-12) — мультиассет стандарт токенов в экосистеме Tezos. В отличие от ERC-20/ERC-721, один FA2 контракт может управлять одновременно fungible и non-fungible токенами с произвольными token_id. Это архитектурное решение: вместо деплоя отдельного контракта для каждого токена — один контракт для целого портфеля ассетов.
Tezos смарт-контракты пишутся преимущественно на SmartPy или LIGO. Рантайм — Michelson. Понимание Michelson нужно для отладки и газовой оптимизации, но напрямую писать на нём не обязательно.
Спецификация FA2: обязательный интерфейс
Transfer entrypoint
Ключевой entrypoint — transfer. Принимает список transfer_batch: каждый batch описывает отправителя, список получателей и token_id/amount.
# SmartPy: FA2 transfer тип
# 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_
# Проверяем авторизацию: отправитель сам или 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 в FA2 — это третьи стороны, которым owner разрешает управлять его токенами. Аналог setApprovalForAll в ERC-721, но более гранулярный: можно дать разрешение на конкретный 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
Это on-chain view — не изменяет состояние, вызывается из других контрактов или off-chain. Tezos отличается от EVM тем, что views явно объявляются как 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):
# Только owner может добавлять операторов для своих токенов
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
))
Полная FA2 реализация на 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 миксин
FA2_ERRORS.Admin, # admin контроль
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)
# Инициализируем 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")
# Обновляем баланс получателя
key = sp.record(owner=to_, token_id=token_id)
current = self.data.ledger.get(key, default=0)
self.data.ledger[key] = current + amount
# Обновляем total supply
self.data.supply[token_id] = self.data.supply.get(token_id, default=0) + amount
Storage: big_map vs map
В Tezos важно различать map и big_map:
-
map— хранится полностью в памяти при исполнении. Для больших коллекций дорого по газу. -
big_map— lazy loaded. При доступе к ключу платите только за чтение этого ключа. Для ledger с тысячами адресов — обязательно big_map.
# Правильно: big_map для ledger
self.data.ledger = sp.big_map(
tkey=sp.TRecord(owner=sp.TAddress, token_id=sp.TNat),
tvalue=sp.TNat
)
# Неправильно для продакшна: map пересчитывается полностью при каждом доступе
# self.data.ledger = sp.map(...) — только для маленьких коллекций
Tzip-16: метаданные токена
TZIP-16 — стандарт метаданных для Tezos контрактов. Метаданные хранятся в storage контракта или ссылкой на IPFS/HTTPS.
# Метаданные контракта (TZIP-16)
contract_metadata = {
"name": "My FA2 Token",
"description": "Utility token for platform",
"interfaces": ["TZIP-012", "TZIP-016"],
"homepage": "https://example.com"
}
# Метаданные токена (TZIP-021 для 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...")
}
Стандарт требует хранить значения как bytes (UTF-8 encoded). Инструменты (кошельки, explorers) декодируют обратно в строки. Это создаёт неудобство в коде, но обеспечивает совместимость.
Multi-asset: несколько токенов в одном контракте
Сила FA2 — token_id. Один контракт, три токена:
# Token 0: governance token (fungible)
# Token 1: utility token (fungible)
# Token 2-10000: NFT коллекция (non-fungible, amount=1 всегда)
# При минте NFT
@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
)
Для NFT: никогда не минтить amount > 1 для одного token_id. Это convention, не enforced протоколом.
Тестирование с Clarinet/SmartPy
# SmartPy unit тест
@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
# Минт 1000 токенов Alice
token.mint(to_=alice.address, token_id=0, amount=1000).run(sender=admin)
# Alice переводит 100 токенов Bob
token.transfer([sp.record(
from_=alice.address,
txs=[sp.record(to_=bob.address, token_id=0, amount=100)]
)]).run(sender=alice)
# Верификация балансов
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)
Деплой и инструменты
SmartPy IDE (smartpy.io) — браузерный IDE с симулятором. Taquito — JavaScript библиотека для взаимодействия с Tezos (аналог ethers.js). Ghostnet — основной Tezos testnet. Temple Wallet / Kukai — кошельки для тестирования.
// Taquito: взаимодействие с FA2 контрактом
import { TezosToolkit } from "@taquito/taquito"
const Tezos = new TezosToolkit("https://ghostnet.ecadinfra.com")
const contract = await Tezos.contract.at("KT1...")
// Transfer токенов
const op = await contract.methods.transfer([{
from_: "tz1Alice...",
txs: [{ to_: "tz1Bob...", token_id: 0, amount: 100 }]
}]).send()
await op.confirmation(3)
Процесс работы
Анализ требований (2-3 дня). Сколько token_id, fungible или NFT или mixed, кастомные entrypoints (whitelist, vesting), требования к метаданным TZIP-16/21.
Разработка (1-3 недели). FA2 базовый контракт → кастомная логика → тесты → деплой на Ghostnet → верификация через explorer (tzkt.io).
Интеграция (1-2 недели). Taquito frontend или backend интеграция, wallet connection, transaction tracking.
Типичный FA2 токен с стандартным функционалом — 1-2 недели. С кастомной токеномикой (vesting, bonding curve, governance) — 3-6 недель.







