Создание токена стандарта FA 1.2

На прошлых уроках мы изучили синтаксис языка программирования LIGO и опубликовали простейший контракт в тестовой сети Tezos.

Теперь мы изучим механизм работы цифровых активов, разберемся со стандартом FA 1.2, и выпустим токен в тестовой сети.

Виды и стандарты токенов

Для начала определимся с терминами. Токенами называют два типа единиц учета: нативные токены и токены, которые выпускают смарт-контракты. Логика работы первых описана в коде протокола, а вторых — в коде смарт-контрактов (СК).

Нативные токены также называют криптовалютами. К ним относятся биткоин, Ether (ETH), Tezos (XTZ, tez), Litecoin и другие.

Токены смарт-контрактов — цифровые активы, созданные пользователями блокчейна, например USDT и DOGE на блокчейне Ethereum, kUSD — на Tezos.

В этом и следующем уроках мы будем говорить о токенах смарт-контрактов, но для простоты называть их токенами.

Мы выяснили, что логика работы токенов не описана в протоколе. Разработчики создают ее в коде смарт-контрактов: добавляют базовые функции вроде хранения балансов и передачи монет.

Так, 100 kUSD на кошельке пользователя — это запись в хранилище СК вида «Адрес TZ1 может отправить 100 токенов этого контракта». Другими словами, токены — это реестры внутри блокчейна.

Чтобы разработчики выпускали рабочие и устойчивые к атакам смарт-контракты, сообщество принимает стандарты токенов. Они содержат списки функций, необходимых для взаимодействия смарт-контракта с кошельками и блокчейн-приложениями.

Разработчики могут прописывать в смарт-контрактах дополнительные функции, например сжигание токенов. Но базовые функции стандарта обязательны.

Базовые функции стандарта FA 1.2

Стандарты Tezos называются Financial Application (FA). Два самых популярных из них:

  • FA 1.2 для взаимозаменяемых токенов;
  • FA 2 для взаимозаменяемых токенов и NFT.

На этом уроке мы рассмотрим более простой стандарт FA 1.2, а на следующем — разберемся с FA 2.

Стандарт FA 1.2 включает функции:

  • передача (transfer) — проведение транзакций между пользователями;
  • одобрение (approve) — разрешение Алисе отправить токены с адреса Боба. Позволяет контракту отправлять пользователем запросы на транзакции;
  • одобренное количество (getAllowance) — просмотр количества токенов, которые Алиса может отправить с адреса контракта;
  • баланс пользователя (getBallance) — просмотр баланса пользователя;
  • свободная эмиссия (getTotalSupply) — просмотр количества токенов на адресах всех пользователей.

Кроме того, контракт токена должен хранить в storage информацию о полной эмиссии и балансах пользователей, а в коде — сообщения о стандартных ошибках вроде нехватки средств.

Разработчик может включить в хранилище метаданные с названием контракта и токена, а также ссылку на его логотип.

Что такое имплементация FA 1.2

Имплементация — это рабочий продукт на основе какой-либо идеи. В случае FA 1.2 — шаблон смарт-контракта для операций с токенами.

Разработчик может скопировать имплементацию, изменить ее под свои задачи, опубликовать в блокчейне и получить работоспособный токен.

Имплементация токена FA 1.2 состоит примерно из сотни строк. Код можно разделить на восемь частей:

  • псевдонимы, типы данных и псевдо-точки входа;
  • функция getAccount, которая получает данные пользователя;
  • функция getAllowance, которая получает количество токенов, доступных для транзакции;
  • функция transfer для перевода токенов из одного адреса на другой;
  • функция approve, которая подтверждает право пользователя на перевод токенов;
  • функция getBalance, которая возвращает баланс пользователя;
  • функция getTotalSupply, которая возвращает количество свободных токенов;
  • функция main, которая принимает входящие параметры и передает их одной из предыдущих функций.

Ниже подробнее расскажем о каждой из этих частей.

Объявление псевдонимов и типов

В начале контракта нужно объявить все типы и псевдонимы, которые он будет использовать в функциях:

//объявляем псевдоним trusted типа address. Мы будем использовать его для обозначения адресов, у которых есть право отправлять токены
type trusted is address;

//объявляем псевдоним amt (amount) типа nat для хранения балансов
type amt is nat;

(* объявляем псевдоним account типа record. В нем будем хранить данные пользователей, которым можно передавать токены.
*)
type account is
  record [
    balance         : amt;
    allowances      : map (trusted, amt);
  ]

(* объявляем тип хранилища смарт-контракта. В нем хранится общее количество токенов, а также структура данных big_map, которая связывает публичные адреса и балансы пользователей *)
type storage is
  record [
    totalSupply     : amt;
    ledger          : big_map (address, account);
  ]

(* объявляем псевдоним для метода return, который будем использовать для возвращения операций. В коротких контрактах можно обойтись без него, но в контрактах с несколькими псевдо-точками входа проще один раз прописать тип возврата и использовать его в каждой функции *)
type return is list (operation) * storage

(* объявляем пустой список noOperations. Его будут возвращать функции transfer и approve *)
const noOperations : list (operation) = nil;

Компилятор Michelson автоматически сортирует содержимое продвинутых структур данных в алфавитном порядке. Сортировка может случайно сломать контракт. Например, если первый параметр — адрес отправителя — начинается с TZ19, а второй — адрес получателя — начинается с TZ11, компилятор поменяет их местами. В таком случае контракт попытается отправить токены не с того аккаунта.

Чтобы сохранить нужный порядок параметров, разработчики записывают важные структуры данных в типе michelson_pair. Такая структура включает два аргумента и их названия, например, два адреса: sender и receiver. Компилятор переносит в код на Michelson значения michelson_pair без сортировки.

(* объявляем псевдонимы входящих параметров для каждой базовой функции FA 1.2. *)

// функция transfer получает на вход адрес отправителя, адрес получателя и сумму транзакции
type transferParams is michelson_pair(address, "from", michelson_pair(address, "to", amt, "value"), "")
// approve получает адрес пользователя и количество токенов, которые он может отправить с баланса смарт-контракта
type approveParams is michelson_pair(trusted, "spender", amt, "value")
// getBallance получает адрес пользователя и прокси-контракта, которому она отправит данные о балансе
type balanceParams is michelson_pair(address, "owner", contract(amt), "")
// getAllowance получает адрес пользователя, данные его аккаунта в смарт-контракте и прокси-контракт
type allowanceParams is michelson_pair(michelson_pair(address, "owner", trusted, "spender"), "", contract(amt), "")
// totalSupply не использует michelson_pair, потому что первый входящий параметр — пустое значение unit — и так окажется первым после сортировки компилятора Michelson
type totalSupplyParams is (unit * contract(amt))

(* объявляем псевдо-точки входа: даем название и присваиваем им тип параметров, которые описали выше*)
type entryAction is
  | Transfer of transferParams
  | Approve of approveParams
  | GetBalance of balanceParams
  | GetAllowance of allowanceParams
  | GetTotalSupply of totalSupplyParams

Функция getAccount получает входящий параметр типа address и значение storage из смарт-контракта:

function getAccount (const addr : address; const s : storage) : account is
  block {
      // присваиваем переменной acct значение типа account: нулевой баланс и пустую запись allowances
    var acct : account :=
      record [
        balance    = 0n;
        allowances = (map [] : map (address, amt));
      ];

    (* проверяем, есть ли в хранилище аккаунт пользователя. Если нет — оставляем в acct пустое значение из предыдущего блока. Если есть — присваиваем переменной acct значение из хранилища. Функция возвращает значение acct *)
    case s.ledger[addr] of
      None -> skip
    | Some(instance) -> acct := instance
    end;
  } with acct

Функция getAllowance спрашивает у пользователя, сколько токенов он разрешает перевести на другой адрес. Она получает адрес пользователя, адрес контракта (spender) и состояние хранилища, а возвращает аргумент amt — количество токенов разрешенных для расходования:

function getAllowance (const ownerAccount : account; const spender : address; const s : storage) : amt is
  (* если пользователь разрешил отправить некоторое количество токенов, функция присваивает это количество переменной amt. Если не разрешил — количество токенов равняется нулю *)
  case ownerAccount.allowances[spender] of
    Some (amt) -> amt
  | None -> 0n
  end;

Функция transfer получает от пользователя адреса отправителя и получателя, количество токенов для перевода и состояние хранилища:

function transfer (const from_ : address; const to_ : address; const value : amt; var s : storage) : return is
  block {

    (* вызываем функцию getAccount, чтобы присвоить переменной senderAccount данные аккаунта пользователя. Затем мы используем senderAccount, чтобы считывать баланс пользователя и разрешения *)
    var senderAccount : account := getAccount(from_, s);

    (* проверяем, достаточно ли у пользователя средств для перевода. Если нет — виртуальная машина прерывает исполнение контракта, если достаточно — продолжает исполнять контракт *)
    if senderAccount.balance < value then
      failwith("NotEnoughBalance")
    else skip;

    (* проверяем, может ли адрес-инициатор транзакции отправить токены. Если он запрашивает перевод из чужого адреса, функция запрашивает разрешение у настоящего владельца. Если инициатор и отправитель — один адрес, виртуальная машина продолжает исполнять контракт *)
    if from_ =/= Tezos.sender then block {
    (* вызываем функцию getAllowance, чтобы владелец адреса-отправителя указал, сколько токенов он разрешает отправить. Присваиваем это значение константе spenderAllowance *)
      const spenderAllowance : amt = getAllowance(senderAccount, Tezos.sender, s);

    (* если владелец разрешил отправить меньше токенов, чем указано во входящем параметре, виртуальная машина прекратит исполнять контракт *)
      if spenderAllowance < value then
        failwith("NotEnoughAllowance")
      else skip;

      (* отнимаем от разрешенного для отправки количества токенов сумму транзакции *)
      senderAccount.allowances[Tezos.sender] := abs(spenderAllowance - value);
    } else skip;

    (* отнимаем от баланса адреса-отправителя количество отправленных токенов *)
    senderAccount.balance := abs(senderAccount.balance - value);

    (* обновляем запись о балансе отправителя в storage *)
    s.ledger[from_] := senderAccount;

    (* еще раз вызываем функцию getAccount, чтобы получить или создать запись аккаунта для адреса-получателя *)
    var destAccount : account := getAccount(to_, s);

    (* добавляем к балансу получателя количество отправленных токенов *)
    destAccount.balance := destAccount.balance + value;

    (* обновляем запись о балансе получателя в storage *)
    s.ledger[to_] := destAccount;

  }
  // возвращаем пустой список операций и состояние storage после исполнения функции
 	with (noOperations, s)

Функция approve запрашивает подтверждение количества токенов, которое spender-адрес может отправить с sender-адреса. Пример использования: блокчейн-приложение (spender) запрашивает у пользователя (sender) разрешение на отправку токенов.

Эта функция подвержена атаке опережающего расходования. Например, sender снижает количество разрешенных для отправки токенов с 20 до 10. Spender узнает об этом и создает транзакцию с расходованием 20 токенов. Он платит повышенную комиссию, чтобы транзакция с тратой попала в блок раньше, чем транзакция со сменой разрешений.

Spender получает 20 токенов и дожидается смены разрешения. После этого он создает еще одну транзакцию — на отправку 10 токенов. Функция разрешает отправку. В результате spender отправляет с sender-адреса 30 токенов вместо разрешенных 10.

Чтобы избежать подобной ситуации, разработчики реализуют в approve задержку смены разрешения. Если разрешенное количество токенов больше нуля, его можно изменить только на нуль, а если равно нулю — его можно изменить на натуральное число. В таком случае spender-адрес не может потратить токены дважды.

function approve (const spender : address; const value : amt; var s : storage) : return is
  block {

    (* получаем данные аккаунта пользователя *)
    var senderAccount : account := getAccount(Tezos.sender, s);

    (* получаем текущее количество токенов, которое пользователь разрешил отправить *)
    const spenderAllowance : amt = getAllowance(senderAccount, spender, s);

    if spenderAllowance > 0n and value > 0n then
      failwith("UnsafeAllowanceChange")
    else skip;

    (* вносим в данные аккаунта новое разрешенное количество токенов для расходования *)
    senderAccount.allowances[spender] := value;

    (* обновляем хранилище смарт-контракта *)
    s.ledger[Tezos.sender] := senderAccount;

  } with (noOperations, s)

Функции getBalance, getAllowance и getTotalSupply относятся к обзорным (view). Они возвращают запрашиваемое значение не пользователю, а специальному промежуточному контракту (proxy-contract). Последний позволяет приложениям получать данные от пользовательских контрактов и отображать их в интерфейсе.

Функция getBalance возвращает значение баланса заданного адреса:

function getBalance (const owner : address; const contr : contract(amt); var s : storage) : return is
  block {
      //присваиваем константе ownerAccount данные аккаунта
    const ownerAccount : account = getAccount(owner, s);
  }
  //возвращаем промежуточному контракту баланс аккаунта
  with (list [transaction(ownerAccount.balance, 0tz, contr)], s)

Функция getAllowance возвращает количество разрешенных для расходования токенов запрашиваемого аккаунта:

function getAllowance (const owner : address; const spender : address; const contr : contract(amt); var s : storage) : return is
  block {
      //получаем данные аккаунта, а из них — количество разрешенных для расходования токенов
    const ownerAccount : account = getAccount(owner, s);
    const spenderAllowance : amt = getAllowance(ownerAccount, spender, s);
  } with (list [transaction(spenderAllowance, 0tz, contr)], s)

Функция getTotalSupply возвращает количество токенов на балансах всех пользователей:

function getTotalSupply (const contr : contract(amt); var s : storage) : return is
  block {
    skip
  } with (list [transaction(s.totalSupply, 0tz, contr)], s)

Главная функция принимает название псевдо-точки входа и ее параметры:

function main (const action : entryAction; var s : storage) : return is
  block {
    skip
  } with case action of
    | Transfer(params) -> transfer(params.0, params.1.0, params.1.1, s)
    | Approve(params) -> approve(params.0, params.1, s)
    | GetBalance(params) -> getBalance(params.0, params.1, s)
    | GetAllowance(params) -> getAllowance(params.0.0, params.0.1, params.1, s)
    | GetTotalSupply(params) -> getTotalSupply(params.1, s)
  end;

Готовим смарт-контракт токена к публикации

Чтобы не тратить время на добавление Taquito и создание файлов проекта, воспользуемся папкой taq-test из прошлого урока.

Запустите редактор VS Code. Создайте в папке taq-test папку token, а в ней — файл token.ligo. Скопируйте в файл код токена.

//объявляем псевдоним trusted типа address. Мы будем использовать его для обозначения адресов, которые могут пересылать токены с контракта
type trusted is address;

//объявляем псевдоним amt (amount) типа nat для хранения балансов
type amt is nat;

(* объявляем псевдоним account типа record. В нем будем хранить данные пользователей, которым можно передавать токены.
*)
type account is
 record [
   balance         : amt;
   allowances      : map (trusted, amt);
 ]

(* объявляем тип хранилища смарт-контракта. Он хранит общее количество токенов, а также big_map,
который связывает публичные адреса и данные account пользователей *)
type storage is
 record [
   totalSupply     : amt;
   ledger          : big_map (address, account);
 ]

(* объявляем псевдоним для метода return, который будем использовать для возвращения операций. В коротких контрактах можно обойтись без него.
Но в контрактах с несколькими псевдо-точками входа проще один раз прописать тип возврата и использовать его для каждой функции *)
type return is list (operation) * storage

(* объявляем пустой список noOperations. Его будет возвращать метод return *)
const noOperations : list (operation) = nil;

(* объявляем псевдонимы входящих параметров для каждой базовой функции FA1.2 *)
type transferParams is michelson_pair(address, "from", michelson_pair(address, "to", amt, "value"), "")
type approveParams is michelson_pair(trusted, "spender", amt, "value")
type balanceParams is michelson_pair(address, "owner", contract(amt), "")
type allowanceParams is michelson_pair(michelson_pair(address, "owner", trusted, "spender"), "", contract(amt), "")
type totalSupplyParams is (unit * contract(amt))

(* псевдо-точки входа *)
type entryAction is
 | Transfer of transferParams
 | Approve of approveParams
 | GetBalance of balanceParams
 | GetAllowance of allowanceParams
 | GetTotalSupply of totalSupplyParams

(* функция getAccount получает входящий параметр типа address и значение storage из смарт-контракта.
Это функция-помощник: ее вызывают другие функции контракта для получения данных о пользователе, а пользователь не может вызвать ее напрямую *)
function getAccount (const addr : address; const s : storage) : account is
 block {
     // присваиваем переменной acct значение типа account: нулевой баланс и пустую запись allowances
   var acct : account :=
     record [
       balance    = 0n;
       allowances = (map [] : map (address, amt));
     ];

   (* проверяем, есть ли в хранилище аккаунт пользователя. Если нет — оставляем в acct пустое значение из предыдущего блоа.
   Если есть — присваиваем его переменной acct. Функция возвращает запись acct *)
   case s.ledger[addr] of
     None -> skip
   | Some(instance) -> acct := instance
   end;
 } with acct

(* getAllowance запрашивает у пользователя разрешение отрпавить токены из его адреса.
Она получает адрес пользователя, адрес контракта spender и состояние хранилища *)
function getAllowance (const ownerAccount : account; const spender : address; const _s : storage) : amt is
 (* если пользователь разрешил отправить некоторое количество токенов, функция присваивает это количество переменной amt.
 Если не разрешил — количество токенов равняется нулю *)
 case ownerAccount.allowances[spender] of
   Some (amt) -> amt
 | None -> 0n
 end;

(* Функция Transfer получает от пользователя адреса отправителя и получателя, количество токенов для перевода и состояние хранилища *)
function transfer (const from_ : address; const to_ : address; const value : amt; var s : storage) : return is
 block {

   (* вызываем фукнцию getAccount, чтобы присвоить ей данные аккаунта пользователя.
   Затем мы используем senderAccount чтобы считывать баланс пользователя и разрешения *)
   var senderAccount : account := getAccount(from_, s);

   (* проверяем, есть ли у пользователя достаточно средств для перевода.
   Если нет — виртуальная машина прерывает исполнение контракта, если есть — продолжает исполнять контракт *)
   if senderAccount.balance < value then
     failwith("NotEnoughBalance")
   else skip;

   (* проверяем, может ли адрес-инициатор транзакции отправить токены.
   Если адрес-инициатор запрашивает перевод из чужого адреса, функция запрашивает разрешение у настоящего владельца. Если инциатор и отправитель — один адрес, виртуальная машина продолжает исполнять контракт *)
   if from_ =/= Tezos.sender then block {
   (* вызываем функцию getAllowance, чтобы владелец адреса-отправителя указал, сколько токенов он разрешает отправить.
   Присваиваем это значение константе spenderAllowance *)
     const spenderAllowance : amt = getAllowance(senderAccount, Tezos.sender, s);

   (* если владелец разрешил отправить меньше токенов, чем указано во входящем параметре, виртуальная машина прекратит исполнять контракт *)
     if spenderAllowance < value then
       failwith("NotEnoughAllowance")
     else skip;

     (* отнимаем от разрешенного для отправки количества токенов сумму транзакции *)
     senderAccount.allowances[Tezos.sender] := abs(spenderAllowance - value);
   } else skip;

   (* отнимаем от баланса адреса-отправителя количество отправленных токенов *)
   senderAccount.balance := abs(senderAccount.balance - value);

   (* обновляем запись о балансе отправителя в storage *)
   s.ledger[from_] := senderAccount;

   (* еще раз вызываем функцию getAccount, чтобы получить или создать запись аккаунта для адреса-получателя *)
   var destAccount : account := getAccount(to_, s);

   (* добавляем к балансу получателя количество отправленных токенов *)
   destAccount.balance := destAccount.balance + value;

   (* обновляем запись о балансе получателя в storage *)
   s.ledger[to_] := destAccount;

 }
 // возвращаем пустой список операций и состояние storage после исполнения функции
 with (noOperations, s)

(* функция Approve запрашивает подверждение на количество токенов, которое адрес-ицинатор может отправить из адреса пользователей  *)
function approve (const spender : address; const value : amt; var s : storage) : return is
 block {

   (* получаем данные аккаунта-иницатора *)
   var senderAccount : account := getAccount(Tezos.sender, s);

   (* получаем текущее количество токенов, которое пользователь разрешил отправить *)
   const spenderAllowance : amt = getAllowance(senderAccount, spender, s);

   (* защищаем контракт от атаки с опережающим расходованием. Допустим, пользователь изменяет разрешенное количество токенов для отправки с 20 до 10.
   Владелец адреса-инициатора может об этом узнать и создать транзакцию с расходованием 20 токенов.
   Если он оплатит повышеннуюю комиссию, то эта транзакция попадет в блок раньше, чем пользователь изменит разрешенное количество токенов.
   По обновлении разрешения владелец адреса-инициатора создаст еще одну транзакцию на 10 токенов.
   В результате он отправит из адреса пользователя 30 токенов вместо 10. Чтобы такого не случилось, разработчики добавляют эту защиту *)

   (* если старое разрешенное количество токенов для расходования больше нуля, его можно изменить только на ноль.
   Если оно равно нулю, его можно изменить на другое натуральное число *)
   if spenderAllowance > 0n and value > 0n then
     failwith("UnsafeAllowanceChange")
   else skip;

   (* вносим в данные аккаунта новое разрешенное количество токенов для расходывания *)
   senderAccount.allowances[spender] := value;

   (* обновляем хранилище смарт-контракта *)
   s.ledger[Tezos.sender] := senderAccount;

 } with (noOperations, s)

(* Функции getBalance, getAllowance и getTotalSupply относятся к обзорным (view).
Они возвращают запрашиваемое значение не пользователю, а специальному промежуточному контракту (proxy-contract).
Во входящих параметрах для вызова этих функций пользователь должен указать адрес промежуточного контракта *)

(* Функция getBallance возвращает значение баланса заданного адреса *)
function getBalance (const owner : address; const contr : contract(amt); var s : storage) : return is
 block {
     //присваиваем константе ownerAccaunt данные аккаунта
   const ownerAccount : account = getAccount(owner, s);
 }
 //возвращаем промежуточному контракту баланс аккаунта
 with (list [transaction(ownerAccount.balance, 0tz, contr)], s)

(* Функция getAllowance возвращает количество разрешенных для расходования токенов запрашиваемого аккаунта *)
function getAllowance (const owner : address; const spender : address; const contr : contract(amt); var s : storage) : return is
 block {
     //получаем данные аккаунта, а из них — количество разрешенных для расходования токенов
   const ownerAccount : account = getAccount(owner, s);
   const spenderAllowance : amt = getAllowance(ownerAccount, spender, s);
 } with (list [transaction(spenderAllowance, 0tz, contr)], s)

(* Функция getTotalSupply возвращает количество токенов на балансах всех пользователей *)
function getTotalSupply (const contr : contract(amt); var s : storage) : return is
 block {
   skip
 } with (list [transaction(s.totalSupply, 0tz, contr)], s)

(* Главная функция принимает название псевдо-точки входа и ее параметры *)
function main (const action : entryAction; var s : storage) : return is
 block {
   skip
 } with case action of
   | Transfer(params) -> transfer(params.0, params.1.0, params.1.1, s)
   | Approve(params) -> approve(params.0, params.1, s)
   | GetBalance(params) -> getBalance(params.0, params.1, s)
   | GetAllowance(params) -> getAllowance(params.0.0, params.0.1, params.1, s)
   | GetTotalSupply(params) -> getTotalSupply(params.1, s)
 end;

Код на LIGO нужно скомпилировать в Michelson, чтобы виртуальная машина Tezos могла его исполнить. На прошлом уроке мы вставили код контракта из двух строчек напрямую в скрипт развертывания. Код токена включает 200 строк, поэтому лучше сохранить его отдельным файлом и импортировать в скрипт с помощью команды import.

Откройте онлайн-среду LIGO и вставьте код токена в поле редактора. В выпадающем списке выберите пункт Compile Contract и поставьте галочку в поле Output Michelson in JSON format. Нажмите кнопку Run. Компилятор выдаст готовый код под полем редактора.

1

Создайте в папке token файл token.json и вставьте в него JSON-код.

2

На прошлом уроке мы вписали в скрипт deploy.ts данные тестового аккаунта и настроили RPC-ссылку публичного узла тестовой сети Tezos. Мы можем использовать этот код для публикации токена.

Создайте в папке taq-test файл token-deploy.ts и вставьте в него код из deploy.ts. Затем его нужно доработать: добавить метод file sync для чтения других файлов, импортировать код контракта и указать начальное состояние хранилища.

После методов import добавьте метод file sync и константу Tezos для вызова методов Taquito:

const fs = require('fs')
const { Tezos } = require('@taquito/taquito')

Замените метод try. После этого задайте исходное состояние хранилища — общее количество токенов и баланс пользователя:

try {
  const op = await tezos.contract.originate({
    // считываем код из файла token.json
    code: JSON.parse(fs.readFileSync("./token.json").toString()),
    // задаем состояние хранилища на языке Michelson. Замените оба адреса на адрес своего аккаунта в тестовой сети,
    // а числа — на количество токенов, которое вы хотите выпустить
    init:
        '(Pair { Elt "tz1imn4fjJFwmNaiEWnAGdRrRHxzxrBdKafZ" (Pair { Elt "tz1imn4fjJFwmNaiEWnAGdRrRHxzxrBdKafZ" 1000 } 1000) } 1000)',
  })

Готовый код выглядит так:

import { TezosToolkit } from '@taquito/taquito'
import { importKey } from '@taquito/signer'

const { Tezos } = require('@taquito/taquito')
const fs = require('fs')

const provider = 'https://florencenet.api.tez.ie'

async function deploy() {
  const tezos = new TezosToolkit(provider)
  await importKey(
    tezos,
    'hoqfgsoy.qyisbhtk@tezos.example.org', //почта
    'ZnnZLS0v6O', //пароль
    [
      'able', //мнемоника
      'public',
      'usual',
      'hello',
      'october',
      'owner',
      'essence',
      'old',
      'author',
      'original',
      'various',
      'gossip',
      'core',
      'high',
      'hire',
    ].join(' '),
    '2bed8dc244ee43a1e737096c4723263c269049d8' //приватный ключ
  )

  try {
    const op = await tezos.contract.originate({
      // считываем код из файла token.json
      code: JSON.parse(fs.readFileSync('./token.json').toString()),
      // задаем состояние хранилища на языке Michelson. Замените оба адреса на адрес своего аккаунта в тестовой сети,
      // а числа — на количество токенов, которое вы хотите выпустить
      init: '(Pair { Elt "tz1imn4fjJFwmNaiEWnAGdRrRHxzxrBdKafZ" (Pair { Elt "tz1imn4fjJFwmNaiEWnAGdRrRHxzxrBdKafZ" 1000 } 1000) } 1000)',
    })

    //начало развертывания
    console.log('Awaiting confirmation...')
    const contract = await op.contract()
    //отчет о развертывании: количество использованного газа, значение хранилища
    console.log('Gas Used', op.consumedGas)
    console.log('Storage', await contract.storage())
    //хеш операции, по которому можно найти контракт в блокчейн-обозревателе
    console.log('Operation hash:', op.hash)
  } catch (ex) {
    console.error(ex)
  }
}

deploy()

Откройте терминал и выполните команду npx ts-node token-deploy.ts. Через несколько минут Taquito опубликует контракт токена в тестовой сети и выдаст хеш операции.

Найдите ее в florence.tzstats и проверьте состояние хранилища контракта. У нас в нем записана тысяча токенов, у вас — ваше количество.

3

В поле Bigmap находятся записи об адресах владельцев токенов. При публикации контракта мы выпустили все токены на тестовый адрес.

4

Отправляем токены с помощью Taquito

Помните, как мы добавили число в хранилище контракта с помощью Taquito? Теперь усложним задачу: вызовем функцию transfer и передадим токены другому пользователю.

Установите кошелек Tezos и создайте аккаунт, если не сделали это на прошлом уроке. Мы рекомендуем Temple Wallet, потому что он из коробки поддерживает тестовые сети Tezos.

Откройте VS Code и создайте файл token-transfer.ts. Вставьте в него код:

//импортируем методы Taquito и файл с данными тестового аккаунта acc.json

import { TezosToolkit } from '@taquito/taquito'
import { InMemorySigner } from '@taquito/signer'
const acc = require('./acc.json')
export class token_transfer {
  // настраиваем ссылку на публичный узел тестовой сети
  private tezos: TezosToolkit
  rpcUrl: string

  constructor(rpcUrl: string) {
    this.tezos = new TezosToolkit(rpcUrl)
    this.rpcUrl = rpcUrl

    //считываем почту, пароль и мнемоническую фразу, из которой можно получить приватный ключ
    this.tezos.setSignerProvider(InMemorySigner.fromFundraiser(acc.email, acc.password, acc.mnemonic.join(' ')))
  }

  // объявляем метод transfer, который принимает параметры:
  //
  // 1) contract — адрес контракта;
  // 2) sender — адрес отправителя;
  // 3) receiver — адрес получателя;
  // 4) amount — количество токенов для отправки.

  public transfer(contract: string, sender: string, receiver: string, amount: number) {
    this.tezos.contract
      .at(contract) //обращаемся к контракту по адресу
      .then((contract) => {
        console.log(`Sending ${amount} from ${sender} to ${receiver}...`)
        //обращаемся к точке входа transfer, передаем ей адреса отправителя и получателя, а также количество токенов для отправки.
        return contract.methods.transfer(sender, receiver, amount).send()
      })
      .then((op) => {
        console.log(`Awaiting for ${op.hash} to be confirmed...`)
        return op.confirmation(1).then(() => op.hash) //ждем одно подтверждение сети
      })
      .then((hash) => console.log(`Hash: https://florence.tzstats.com/${hash}`)) //получаем хеш операции
      .catch((error) => console.log(`Error: ${JSON.stringify(error, null, 2)}`))
  }
}

Теперь создайте файл transfer.ts и вставьте в него код:

import { token_transfer } from './token-transfer'

const RPC_URL = 'https://florencenet.api.tez.ie'
const CONTRACT = 'KT1DUdLarKFG9tmA4uZCgbiHv5SJA9oUBw8G' //адрес опубликованного контракта
const SENDER = 'tz1imn4fjJFwmNaiEWnAGdRrRHxzxrBdKafZ' //публичный адрес отправителя — возьмите его из acc.json
const RECEIVER = 'tz1UEQzJbuaGJgwvkekk6HwGwaKvjZ7rr9v4' //публичный адрес получателя — возьмите его из кошелька Tezos, который вы создали
const AMOUNT = 3 //количество токенов для отправки. Можете ввести другое число
new token_transfer(RPC_URL).transfer(CONTRACT, SENDER, RECEIVER, AMOUNT)

Откройте консоль и выполните команду npx ts-node transfer.ts. Подождите, пока консоль вернет хеш операции.

Теперь откройте кошелек Temple Wallet. Нажмите на кнопку Tezos Mainnet в верхней части окна и выберите пункт Florence Testnet в выпадающем списке.

5

Кошелек выглядит пустым, но на самом деле токены уже пришли на ваш адрес. Temple не видит токены, потому что в коде контракта нет метаданных с названием и другими параметрами. Метаданные — тема следующего урока. Но сейчас мы можем добавить их вручную и увидеть токены на кошельке.

Скопируйте адрес контракта токена из файла transfer.ts. Откройте Temple Wallet и нажмите кнопку Manage справа от поля Search Assets, а затем — кнопку Add Token.

6

Temple откроет браузер с вкладкой, на которой можно добавить токен вручную. Выберите стандарт FA 1.2 в выпадающем списке Token type, а затем вставьте адрес смарт-контракта в поле Address.

7

Заполните данные о токене: тикер, описание, число знаков после запятой и ссылку на логотип. Пишите, что нравится, но оставьте значение «0» в поле Decimals. Нажмите кнопку Add Token, чтобы добавить в кошелек токен с заданными настройками.

8

Готово! Теперь Temple Wallet отображает ваш токен. Он подхватил точки входа FA 1.2, а значит вы можете просматривать баланс и отправлять токены из интерфейса кошелька. Однако получателю придется вручную настраивать метаданные, чтобы увидеть перевод.

9

На следующем уроке мы разберемся с метаданными и опубликуем полноценный токен, который кошельки будут видеть сразу. А еще выпустим NFT стандарта FA 2.

Подбиваем итоги

Токены — это записи в хранилище смарт-контрактов вроде «Адресу TZ1 принадлежит 1000 монет». Передача токенов происходит так:

  1. Пользователь или приложение вызывает смарт-контракт.
  2. Смарт-контракт проверяет, может ли пользователь отправить токены.
  3. Смарт-контракт обновляет записи о балансах пользователей: уменьшает количество токенов отправителя на сумму перевода, а затем добавляет ее к балансу получателя.

Разработчики протокола создают стандарты, которые описывают основные функции токенов: хранение записей о пользователях, проверку балансов и проведение транзакций. В Tezos два популярных стандарта: FA 1.2 для взаимозаменяемых активов и FA 2 для NFT.

Разработчики могут писать смарт-контракты токенов с нуля или использовать имплементации — шаблоны с базовыми функциями. Продвинутые механизмы вроде выпуска и сжигания токенов нужно создавать самостоятельно.

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