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