Метаданные токенов и выпуск NFT на Tezos

На прошлом уроке мы изучили стандарт FA1.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. Подразумеваем, что контракт выпустил только 1 токен с этим 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 с одинаковым контентом и метаданными будут отличаться друг от друга.

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