Метадані токенів та випуск 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. Натисніть на + в правому верхньому куті вікна, щоб створити новий файл.

1

Назвіть файл 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. Після цього натисніть саму кнопку. Так ви збережете файл і зробите його доступним для всіх користувачів.

2

Після оновлення сторінки натисніть кнопку Raw в правому верхньому куті вікна, щоб відкрити файл за прямим посиланням.

3

Не закривайте вкладку з файлом, вона знадобиться через кілька хвилин.

4

Додаємо метадані в смарт-контракт

URI-посилання на метадані повинна знаходитися в сховищі контракту в спеціальному форматі — metadata: big_map (string, bytes). Додамо його в storage.

Запустіть VS Code і відкрийте файл token.ligo. На початку контракту знайдіть оголошення типу storage і додайте в нього формат метаданих.

5

Ми оновили контракт на LIGO. Тепер його потрібно скомпілювати в формат JSON для публікації в тестнеті.

Скопіюйте вміст файлу token.ligo і перейдіть на сайт ide.ligolang.org. Виберіть пункт Compile Contract в випадаючому списку і поставте галочку в поле Output Michelson in JSON format. Натисніть кнопку Run. Компілятор видасть готовий код під полем редактора.

6

Перейдіть в VS Code, відкрийте файл token.json. Замініть старий код новим і збережіть файл.

Тепер в storage є поле metadata: гаманці і блокчейн-оглядачі будуть шукати в ньому посилання на JSON-файл з метаданими. Поки в цьому полі порожньо: ми вкажемо URI файлу в сховищі контракта перед розгортанням.

Щоб віртуальна машина Michelson могла прочитати посилання, його потрібно перекласти в байтовий формат. Поверніться на вкладку з URI на meta.json і скопіюйте вміст адресного рядка.

4

Відкрийте онлайн-конвертер на кшталт Onlinestringtools. Вставте текст посилання в поле String і зніміть галочку з Add Whitespaces, щоб прибрати пропуски між байтами.

Конвертер видасть довге число в поле Bytes — це і є посилання в байтовому форматі. Ми будемо використовувати її пізніше, а поки не закривайте вкладку.

7

Перейдіть на VS Code і створіть файл storage.tz. Вставте в нього шаблон сховища:

'(Pair (Pair {Elt "публічний адресу з файлу acc.json" (Pair {Elt "публічний адресу з файлу acc.json" кількість токенів} кількість токенів)} {Elt "" 0xпосилання на meta.json в байтовому форматі}) кількість токенів) '

Заповніть поля шаблону. Важливо: перед посиланням на meta.json потрібно обов'язково додати порожній рядок — "". Це ключ, за яким віртуальна машина Tezos розуміє, що далі буде посилання на метадані.

Публічна адреса обов'язково повинна бути в лапках, а кількість токенів і посилання — без. Перед посиланням в байтовому форматі обов'язково допишіть 0x, інакше компілятор її не прочитає.

8

Скопіюйте код з storage.js і переключіться на token-deploy.ts. Замініть старий запис стану сховища на новий.

9

Відкрийте термінал в VS Code. Переконайтеся, що знаходитесь в папці taq-test, і виконайте команду:

npx ts-node token-deploy-ts

Taquito видасть посилання на хеш операції. Перейдіть на сайт tzstats.io, знайдіть по хешу адреса контракту і скопіюйте його.

Перевірте, що гаманці бачать метадані — підготуйте і виконайте транзакцію. Відкрийте файл transfer.ts і замініть стару адресу токена новою, яку ви тільки що опублікували.

10

Відкрийте термінал і виконайте команду:

npx ts-node transfer.ts

Дочекайтеся підтвердження транзакції і перевірте гаманець. Він повинен відразу відобразити токени.

11

Розбираємося зі стандартом 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 і скопіюйте туди скомпільований код.

12

Підготуйте два 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()),

13

Для публікації смарт-контракту потрібно задати стан сховища. Для цього створіть файл nft-storage без розширення і вставте в нього шаблон:

'(Pair (Pair { Elt id "адреса вашого гаманця" } { Elt "" 0x посилання на метадані контракта в байтовому форматі}) { Elt (Pair "адреса вашого гаманця" "адрес вашого гаманця" id) Unit } { Elt 0 (Pair id { Elt "" 0x посилання на метадані токена в байтовому форматі}) })'

Заповніть поля в шаблоні. Цей код повинен бути одним рядком без переносів.

Вказуйте ідентифікатор (id) токена без лапок. Не використовуйте кирилицю в назві токена: Michelson працює тільки з Unicode, тобто латиницею.

Ми випустимо токен з id = 5:

14

Вставте код стану сховища в файл nft-deploy.ts в поле init. Не забудьте виділити його апострофами і поставити кому. Наш скрипт виглядає так:

15

Відкрийте термінал і виконайте команду:

npx ts-node nft-deploy.js

Дочекайтеся завершення розгортання і перевірте свій гаманець. NFT з'явиться в ньому протягом хвилини.

16

Якщо розгортання пройшло без помилок, але гаманець не показує токен, перевірте мережу. Можливо, гаманець під’єднаний до мейннету, а не тестової мережі. В крайньому випадку додайте токен вручну за допомогою кнопки Manage. Іноді при створенні нових токенів з випуском на адресу користувача гаманець може їх не відображати до першої транзакції.

Ви побачите ідентифікатор токена і наявність NFT на балансі, але не сам токенізірований контент. Гаманці не можуть його відобразити: у них немає інтерфейсів для читання поля artifactUri, як у NFT-маркетплейсов.

17

Перейдіть на блокчейн-оглядач BetterCallDev і знайдіть свій контракт за хешем операції або адресою.

Перейдіть на вкладку Tokens. Посилання на токенізірованний контент знаходиться в полі artifact_uri.

18

Для випуску і спалювання NFT потрібно додати відповідні функції в код контракту. Ми цього не робили, щоб не ускладнювати знайомство з FA 2.

Підведемо підсумки

Метадані — стандартизований опис смарт-контракту або токена, в який входить назва, тікер, опис, кількість знаків після коми, посилання на логотип, підтримувані інтерфейси та інші дані.

Найзручніший спосіб додати метадані — скористатися стандартом TZIP-16. Згідно з ним, інформація про токени та смарт-контракти зберігається в JSON-файлах. Їх потрібно розмістити на публічному сервері, наприклад на Github, і додати посилання в сховище. Плюс такого підходу: файли можна виправити, якщо ви допустили помилку.

NFT існують завдяки метаданим: розробник записує в них ідентифікатор токена і посилання на токенізований контент. Зв'язка ідентифікатора і адреси контракту унікальна, тому NFT з однаковим контентом і метаданими будуть відрізнятися один від одного.

  • Автор — Павло Скоропляс
  • Продюсер — Світлана Коваль
  • Стилі — Дмитро Бойко
  • Ілюстрації — Кшиштоф Шпак
  • Верстка — Зара Аракелян
  • Розробка — Олександр Пупко
  • Керівник — Влад Ліхута