Метадані токенів та випуск NFT на Tezos
На минулому уроці ми вивчили стандарт FA 1.2 і опублікували смарт-контракт токена в тестовій мережі Tezos. Але ми не використали метадані, тому токен не відображався в гаманці автоматично.
На цьому уроці ми додамо в смарт-контракт метадані, щоб його бачили гаманці і блокчейн-оглядачі. Потім вивчимо стандарт NFT в мережі Tezos і створимо невзаємозамінний токен.
Як додатки бачать токени
Нагадаємо, токени — це не монетки і не окремі файли, а записи в сховищі смарт-контрактів. Якщо у вашому гаманці є 100 kUSD, то в сховищі контракту записано «Адресу TZ1 належить 100 токенів».
Крім балансів користувачів в сховищі знаходяться метадані — інформація про смарт-контракті: назва та короткий опис токена, тікер, число знаків після коми, ідентифікатор (id), посилання на логотип, адреса власника, псевдо-точки входу і інші параметри.
Метадані потрібні, щоб додатки бачили токени і правильно відображали інформацію про них. Якщо метадані відсутні, додаток не побачить токен, а якщо заповнені неправильно — відобразить його з помилками.
Створюємо JSON-файл з метаданими
З 2020 року більшість розробників Tezos використовує стандарт TZIP-16, згідно з яким метадані зберігаються в JSON-файлі.
Розробник повинен завантажити файл на будь-який публічний сервер — власний сайт, IPFS або Github, а потім розмістити посилання на нього в сховищі смарт-контракту.
Ми додамо метадані в код токена token.ligo з минулого уроку. Для цього скористаємося сервісом Gist від Github: створимо JSON-файл і отримаємо пряме посилання на нього (Uniform Resource Identifier, URI).
Зареєструйтеся або авторизуйтесь на Github, а потім перейдіть в Gist. Натисніть на + в правому верхньому куті вікна, щоб створити новий файл.
Назвіть файл fa12-metadata.json і вставте в нього шаблон:
{
"Symbol": "Коротка назва, тікер",
"name": "Повна назва",
"decimals": "0",
"icon": "посилання на зображення-логотип. Вона повинна закінчуватися на .jpg, .png або .svg ",
"description": "Опис токена",
"authors": "автор",
"interfaces": ["TZIP-007-2021-04-17", "TZIP-016-2021-04-17"]
}
Заповніть поля: введіть тікер, назву токена і додайте посилання на логотип. Поля decimals і interfaces залиште як є:
- decimals — кількість знаків після коми. Якщо встановити його більше 0, наприклад 3, гаманці відобразять токен у вигляді 0,001. Ми залишаємо значення за замовчуванням, щоб не ускладнювати приклад;
- interfaces — правила відображення метаданих і список стандартних точок входу, які використовує контракт. Зміна цього поля може привести до проблем з відображенням токена в деяких гаманцях.
Відкрийте список, що випадає на зеленій кнопці, і виберіть пункт Create public gist. Після цього натисніть саму кнопку. Так ви збережете файл і зробите його доступним для всіх користувачів.
Після оновлення сторінки натисніть кнопку Raw в правому верхньому куті вікна, щоб відкрити файл за прямим посиланням.
Не закривайте вкладку з файлом, вона знадобиться через кілька хвилин.
Додаємо метадані в смарт-контракт
URI-посилання на метадані повинна знаходитися в сховищі контракту в спеціальному форматі — metadata: big_map (string, bytes). Додамо його в storage.
Запустіть VS Code і відкрийте файл token.ligo. На початку контракту знайдіть оголошення типу storage і додайте в нього формат метаданих.
Ми оновили контракт на LIGO. Тепер його потрібно скомпілювати в формат JSON для публікації в тестнеті.
Скопіюйте вміст файлу token.ligo і перейдіть на сайт ide.ligolang.org. Виберіть пункт Compile Contract в випадаючому списку і поставте галочку в поле Output Michelson in JSON format. Натисніть кнопку Run. Компілятор видасть готовий код під полем редактора.
Перейдіть в VS Code, відкрийте файл token.json. Замініть старий код новим і збережіть файл.
Тепер в storage є поле metadata: гаманці і блокчейн-оглядачі будуть шукати в ньому посилання на JSON-файл з метаданими. Поки в цьому полі порожньо: ми вкажемо URI файлу в сховищі контракта перед розгортанням.
Щоб віртуальна машина Michelson могла прочитати посилання, його потрібно перекласти в байтовий формат. Поверніться на вкладку з URI на meta.json і скопіюйте вміст адресного рядка.
Відкрийте онлайн-конвертер на кшталт Onlinestringtools. Вставте текст посилання в поле String і зніміть галочку з Add Whitespaces, щоб прибрати пропуски між байтами.
Конвертер видасть довге число в поле Bytes — це і є посилання в байтовому форматі. Ми будемо використовувати її пізніше, а поки не закривайте вкладку.
Перейдіть на VS Code і створіть файл storage.tz. Вставте в нього шаблон сховища:
'(Pair (Pair {Elt "публічний адресу з файлу acc.json" (Pair {Elt "публічний адресу з файлу acc.json" кількість токенів} кількість токенів)} {Elt "" 0xпосилання на meta.json в байтовому форматі}) кількість токенів) '
Заповніть поля шаблону. Важливо: перед посиланням на meta.json потрібно обов'язково додати порожній рядок — "". Це ключ, за яким віртуальна машина Tezos розуміє, що далі буде посилання на метадані.
Публічна адреса обов'язково повинна бути в лапках, а кількість токенів і посилання — без. Перед посиланням в байтовому форматі обов'язково допишіть 0x, інакше компілятор її не прочитає.
Скопіюйте код з storage.js і переключіться на token-deploy.ts. Замініть старий запис стану сховища на новий.
Відкрийте термінал в VS Code. Переконайтеся, що знаходитесь в папці taq-test, і виконайте команду:
npx ts-node token-deploy-ts
Taquito видасть посилання на хеш операції. Перейдіть на сайт tzstats.io, знайдіть по хешу адреса контракту і скопіюйте його.
Перевірте, що гаманці бачать метадані — підготуйте і виконайте транзакцію. Відкрийте файл transfer.ts і замініть стару адресу токена новою, яку ви тільки що опублікували.
Відкрийте термінал і виконайте команду:
npx ts-node transfer.ts
Дочекайтеся підтвердження транзакції і перевірте гаманець. Він повинен відразу відобразити токени.
Розбираємося зі стандартом FA 2
Унікальність NFT (non-fungible token) забезпечують два фактори — адреса контракту-емітента і ідентифікатор (id). У взаємозамінних токенів ідентифікаторів немає.
NFT можна випустити з допомогою простого контракту з двох функцій: transfer для передачі токенів і update для поновлення балансів користувачів. Технічно, такий токен буде унікальним. Але якщо не дотримуватися стандартів FA 1.2 і FA 2, гаманці не побачать цей NFT. Для передачі токена доведеться користуватися клієнтом Tezos і терміналом.
Щоб уніфікувати роботу з NFT, розробники Tezos створили стандарт FA 2. Він описує інтерфейс для роботи з взаємозамінними, невзаємозамінними, непередаваними і іншими видами токенів.
Стандарт FA 2 складніший, ніж FA 1.2, але дає розробникам більше свободи. Наприклад, за допомогою FA 2 можна випустити кілька токенів в одному контракті або групувати транзакції в пакети (batch), щоб заощадити газ.
Код FA 2-токена складається з чотирьох частин:
- повідомлення про основні помилки;
- інтерфейси — оголошення всіх кастомних типів даних;
- функції для роботи з операторами — користувачами, які можуть передавати токени;
- ядро — метадані, сховище, функції передачі токенів і перевірки балансу.
Імплементація NFT-стандарту FA2 на LIGO виглядає так:
// ERRORS
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"
// INTERFACE
// оголошуємо тип ідентифікатора токена — натуральне число
type token_id is nat
// оголошуємо типи вхідних параметрів, які приймає функція переказу токена: адресу одержувача, id і кількість токенів. В тип transfer додаємо адресу відправника
type transfer_destination is
[@layout:comb]
record [
to_: address;
token_id: token_id;
amount: nat;
]
type transfer is
[@layout:comb]
record [
from_: address;
txs: list (transfer_destination);
]
// оголошуємо типи для читання балансу: адреса власника, id токена
type balance_of_request is
[@layout:comb]
record [
owner: address;
token_id: token_id;
]
type balance_of_response is
[@layout:comb]
record [
request: balance_of_request;
balance: nat;
]
type balance_of_param is
[@layout:comb]
record [
requests: list (balance_of_request);
callback: contract (list (balance_of_response));
]
// оголошуємо тип оператора — адреси, яка може відправляти токени
type operator_param is
[@layout:comb]
record [
owner: address;
operator: address;
token_id: token_id;
]
// оголошуємо тип параметрів, які потрібні для оновлення списку операторів
type update_operator is
[@layout:comb]
| Add_operator of operator_param
| Remove_operator of operator_param
// оголошуємо тип, який містить метадані NFT: ID токена і посилання на json-файл
type token_info is (token_id * map (string, bytes))
type token_metadata is
big_map (token_id, token_info)
// оголошуємо тип з посиланням на метадані смарт-контракту. Ці дані будуть відображатися в гаманці
type metadata is
big_map (string, bytes)
// оголошуємо тип, який може зберігати записи про кілька токенів і їх метадані в одному контракті
type token_metadata_param is
[@layout:comb]
record [
token_ids: list (token_id);
handler: (list (token_metadata)) -> unit;
]
// оголошуємо псевдо-точки входу: передачу токенів, перевірку балансу, оновлення операторів і перевірку метаданих
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
// оголошуємо типи даних для зміни дозволів на передачу токенів. Наприклад, з їх допомогою можна зробити токен, який не можна відправити на іншу адресу
type operator_transfer_policy is
[@layout:comb]
| No_transfer
| Owner_transfer
| Owner_or_operator_transfer
type owner_hook_policy is
[@layout:comb]
| Owner_no_hook
| Optional_owner_hook
| Required_owner_hook
type custom_permission_policy is
[@layout:comb]
record [
tag: string;
config_api: option (address);
]
type permissions_descriptor is
[@layout:comb]
record [
operator: operator_transfer_policy;
receiver: owner_hook_policy;
sender: owner_hook_policy;
custom: option (custom_permission_policy);
]
type transfer_destination_descriptor is
[@layout:comb]
record [
to_: option (address);
token_id: token_id;
amount: nat;
]
type transfer_descriptor is
[@layout:comb]
record [
from_: option (address);
txs: list (transfer_destination_descriptor)
]
type transfer_descriptor_param is
[@layout:comb]
record [
batch: list (transfer_descriptor);
operator: address;
]
// OPERATORS
// оголошуємо тип, який зберігає записи про операторів в одному big_map
type operator_storage is big_map ((address * (address * token_id)), unit)
// оголошуємо функцію для оновлення списку операторів
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)
end
// оголошуємо функцію, яка перевіряє, чи може користувач оновити список операторів
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
end;
if (op.owner = updater) then skip else failwith (fa2_not_owner)
} with unit
// оголошуємо функцію, яка перевіряє, чи може користувач оновити список адрес власників токенів, і тільки в цьому випадку викликає функцію оновлення
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
// оголошуємо функцію, яка перевіряє дозволи на передачу токенів. Якщо користувач не може передати токен, функція припиняє виконання контракту
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)
end;
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
// оголошуємо функцію для передачі токена власником
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)
// оголошуємо функцію, яка збирає всі транзакції одного токена в пакет (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),
tx.txs),
txs)
} with unit
// MAIN
// оголошуємо тип даних для зберігання записів про те, на якій адресі знаходяться токени з заданим id
type ledger is big_map (token_id, address)
// оголошуємо сховище контракту: метадані TZIP-16, реєстр адрес і токенів, список операторів і ончейн-метадані
type collection_storage is record [
metadata: big_map (string, bytes);
ledger: ledger;
operators: operator_storage;
token_metadata: token_metadata;
]
// оголошуємо функцію передачі токена. Вона отримує id токена, адреси відправника та одержувача, а потім перевіряє, чи є у відправника право передати токен
function transfer (
const txs: list (transfer);
const validate: operator_validator;
const ops_storage: operator_storage;
const ledger: ledger): ledger is block {
// перевірка права відправника передати токен
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
// перевірка кількості переданих NFT. Маємо на увазі, що контракт випустив тільки один токен з цим id
// Якщо користувач хоче передати 0, 0.5, 2 або іншу кількість токенів, функція перериває виконання контракту
if (dst.amount = 0n) then
ll
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) ->
// перевірка, чи є у відправника токен
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)
end
,
tx.txs,
l
)
} with List.fold (make_transfer, txs, ledger)
// оголошуємо функцію, яка поверне баланс відправника
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);
}
with
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]
end;
const responses = List.map (to_balance, p.requests);
} with Tezos.transaction (responses, 0mutez, p.callback)
// оголошуємо головну функцію з псевдо-точками входу. Ці псевдо-точки — основа стандарту FA2
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)
end
Випускаємо NFT на FA 2
Відкрийте VS Code і створіть файл nft.ligo в папці taq-test. Вставте в нього вміст вікна вище або скопіюйте код з Gist.
Скомпілюйте код на LIGO в JSON. Скопіюйте вміст файлу вікна і перейдіть на сайт ide.ligolang.org. Виберіть пункт Compile Contract в випадаючому списку і поставте галочку в полі Output Michelson in JSON format. Натисніть кнопку Run. Компілятор видасть готовий код під полем редактора. Створіть в VS Code файл з назвою nft.json і скопіюйте туди скомпільований код.
Підготуйте два JSON-файлу з метаданими — для і NFT і смарт-контракту. Перейдіть в Gist і натисніть на + в правому верхньому куті, щоб створити новий файл.
Назвіть файл nft_meta.json і вставте в нього шаблон:
{
"symbol": "тікер",
"name": "назва NFT",
"description": "опис",
"decimals": "0",
"isBooleanAmount": true,
"artifactUri": "посилання на токенізований об'єкт",
"thumbnailUri": "логотип NFT",
"Minter": "ім'я емітента токена",
"Interfaces": ["TZIP-007-2021-04-17", "TZIP-016-2021-04-17", "TZIP-21"]
}
Заповніть всі поля, крім interfaces. Вкажіть посилання на токенізований об'єкт і логотип токена у вигляді «https:// ...», а не у байтовому форматі.
Відкрийте список, що випадає на зеленій кнопці, і виберіть пункт Create public gist. Після цього натисніть саму кнопку. Так ви збережете файл і зробите його доступним для всіх користувачів.
Після оновлення сторінки натисніть кнопку Raw в правому верхньому куті вікна, щоб відкрити файл за прямим посиланням. Чи не закривайте вкладку.
Тепер створіть в Gist файл contract_meta.json. Вставте в нього шаблон:
{
"name": "назва контракту",
"description": "опис контракту",
"interfaces": ["TZIP-012-2020-11-17"]
}
Заповніть метадані контракту, натисніть кнопку Create Public Gist, а потім — кнопку Raw. Не закривайте вкладку, вона стане в нагоді через кілька хвилин.
Підготуйте скрипт для публікації NFT. Створіть файл nft-deploy.ts і скопіюйте в нього код з token-deploy.ts.
Знайдіть метод code: JSON.parse() і замініть в ньому назву файлу для зчитування:
- code: JSON.parse(fs.readFileSync("./token.json").toString()),
↓↓↓
+ code: JSON.parse(fs.readFileSync("./nft.json").toString()),
Для публікації смарт-контракту потрібно задати стан сховища. Для цього створіть файл nft-storage без розширення і вставте в нього шаблон:
'(Pair (Pair { Elt id "адреса вашого гаманця" } { Elt "" 0x посилання на метадані контракта в байтовому форматі}) { Elt (Pair "адреса вашого гаманця" "адрес вашого гаманця" id) Unit } { Elt 0 (Pair id { Elt "" 0x посилання на метадані токена в байтовому форматі}) })'
Заповніть поля в шаблоні. Цей код повинен бути одним рядком без переносів.
Вказуйте ідентифікатор (id) токена без лапок. Не використовуйте кирилицю в назві токена: Michelson працює тільки з Unicode, тобто латиницею.
Ми випустимо токен з id = 5:
Вставте код стану сховища в файл nft-deploy.ts в поле init. Не забудьте виділити його апострофами і поставити кому. Наш скрипт виглядає так:
Відкрийте термінал і виконайте команду:
npx ts-node nft-deploy.js
Дочекайтеся завершення розгортання і перевірте свій гаманець. NFT з'явиться в ньому протягом хвилини.
Якщо розгортання пройшло без помилок, але гаманець не показує токен, перевірте мережу. Можливо, гаманець під’єднаний до мейннету, а не тестової мережі. В крайньому випадку додайте токен вручну за допомогою кнопки Manage. Іноді при створенні нових токенів з випуском на адресу користувача гаманець може їх не відображати до першої транзакції.
Ви побачите ідентифікатор токена і наявність NFT на балансі, але не сам токенізірований контент. Гаманці не можуть його відобразити: у них немає інтерфейсів для читання поля artifactUri, як у NFT-маркетплейсов.
Перейдіть на блокчейн-оглядач BetterCallDev і знайдіть свій контракт за хешем операції або адресою.
Перейдіть на вкладку Tokens. Посилання на токенізірованний контент знаходиться в полі artifact_uri.
Для випуску і спалювання NFT потрібно додати відповідні функції в код контракту. Ми цього не робили, щоб не ускладнювати знайомство з FA 2.
Підведемо підсумки
Метадані — стандартизований опис смарт-контракту або токена, в який входить назва, тікер, опис, кількість знаків після коми, посилання на логотип, підтримувані інтерфейси та інші дані.
Найзручніший спосіб додати метадані — скористатися стандартом TZIP-16. Згідно з ним, інформація про токени та смарт-контракти зберігається в JSON-файлах. Їх потрібно розмістити на публічному сервері, наприклад на Github, і додати посилання в сховище. Плюс такого підходу: файли можна виправити, якщо ви допустили помилку.
NFT існують завдяки метаданим: розробник записує в них ідентифікатор токена і посилання на токенізований контент. Зв'язка ідентифікатора і адреси контракту унікальна, тому NFT з однаковим контентом і метаданими будуть відрізнятися один від одного.