Creating an FA 1.2 Token

In our past lessons, we studied the syntax of LIGO and published a simple contract on the Tezos testnet. This time, we’ll delve into the mechanisms behind digital assets, take a closer look at the standard FA 1.2, and release a token on the testnet.

Token standards

Let’s settle the matter of terms and definitions first. The word token refers to two kinds of units: native tokens and smart-contract issued tokens. The logic behind the former is described in the protocol code, and the latter, in the code of smart contracts (SC).

Native tokens are also known as cryptocurrencies. They include Bitcoin, Ether, Tezos, Litecoin, and so forth.

Smart-contract tokens are also known as digital assets created by blockchain users. These are USDT or DOGE based on Ethereum, kUSD based on Tezos. For the purposes of our lessons, referring to tokens we’ll mean smart-contract tokens.

So far, we know the token logic is not described in their protocol. Devs create it in the smart contract code: they add basic functions like balance storage or transfer. Thus, 100 kUSD on the user’s wallet is a record in the SC’s storage saying something like “address TZ1 can send 100 tokens of this contract.” In other words, tokens are ledgers within the blockchain.

For the developers to release operational and attack-resilient SCs, the community approves token standards. They include lists of functions required for the smart contract to interact with wallets or blockchain apps. Devs can also write additional functions into the contracts, such as token burning. Anyway, the main standard functions are mandatory.

Main functions of the FA 1.2 standard

Tezos standards are called Financial Applications, hence FA. The most popular of them are the following two:

  • FA 1.2 for fungible tokens;
  • FA 2 for both fungible and non-fungible tokens (NFTs).

This time we’ll take a look at the simpler standard FA1.2, and cover FA2 next time.

The FA 1.2 standard includes the following functions:

  • transfer: transacting between users;
  • approve: allowing Alice to send tokens from Bob’s address. This allows the contract to send transaction requests to users;
  • allowance (getAllowance): browsing the amount of tokens available for Alice to sending from the contract’s address;
  • user balance (getBallance): browsing user balance;
  • current supply (getTotalSupply): browsing the number of tokens on all user addresses.

Aside from that, a token contract has to keep data on the total supply and user balances in storage, and messages on standard errors like insufficient funds in the code. Also, a developer can incorporate metadata with the contract’s and the token’s names as well as a link to the logo in the storage.

What is FA 1.2 implementation?

Implementation is a working product based on an idea. In the case of FA1.2, it’s a template smart contract for operations with tokens.

A developer can copy the implementation, change it for his or her current purposes, publish it on the blockchain, and thus get a working token. An FA1.2 implementation includes nearly a hundred strings. The code can be conventionally divided into eight parts:

  • aliases, data types, and pseudo-entry points;
  • function getAccount, which gets user data;
  • function getAllowance, which gets the number of tokens available for transacting;
  • function transfer for transferring tokens between addresses;
  • function approve, which confirms the user’s right to transfer tokens;
  • function getBalance, which returns the user’s balance;
  • function getTotalSupply, which returns the number of free tokens;
  • function main, which accepts entry parameters and sends them to one of the previous functions.

Below we’ll cover each of those parts in greater detail.

Declaring aliases and types

First of all, you have to declare all types and aliases the smart contract will use in functions:

//declaring the alias trusted of address type. We will use it for addresses entitled to send tokens
type trusted is address;

//declaring the alias amt (amount) of nat type to store balances
type amt is nat;

(* announcing the alias account of record type. It will store data on users entitled to receive tokens
*)
type account is
  record [
    balance         : amt;
    allowances      : map (trusted, amt);
  ]

(* declaring the type of SC storage. It keeps the overall quantity of tokens and big_map data structure that ensures the connection between user balances and public addresses *)
type storage is
  record [
    totalSupply     : amt;
    ledger          : big_map (address, account);
  ]

(* declaring the alias for the return method that will return operations. In short contracts one can do without it, yet it is easier to describe the return type just once in contracts with several pseudo-entry points and then use it in every function *)
type return is list (operation) * storage

(* declare the empty list noOperations. It will be returned by transfer and approve *)
const noOperations : list (operation) = nil;

The Michelson compiler automatically sorts the contents of complex data in alphabetical order but sorting can accidentally break the contract. Thus, if the first param (sender’s address) starts with TZ19, and the second (recipient’s address) starts with TZ11, the compiler will switch their places and the contract will attempt to send tokens from the wrong contract.

To retain the correct order of params, devs record important data types in michelson_pair. The structure includes two arguments and their names (e.g., two addresses): sender and receiver. The compiler transfers the michelson_pair values to the Michelson code without sorting.

(* declaring aliases of input params for each main function of FA 1.2. *)

// transfer function gets the sender’s address, the recipient’s address, and the transaction amount to the input
type transferParams is michelson_pair(address, "from", michelson_pair(address, "to", amt, "value"), "")
// approve gets user address and number of tokens they can send from the SC’s balance
type approveParams is michelson_pair(trusted, "spender", amt, "value")
// getBalance gets the addresses of the user and the proxy contract where it sends balance data
type balanceParams is michelson_pair(address, "owner", contract(amt), "")
// getAllowance gets the user’s address, their SC account data abd the proxy contract
type allowanceParams is michelson_pair(michelson_pair(address, "owner", trusted, "spender"), "", contract(amt), "")
// totalSupply doesn’t use michelson_pair as the first input param is the empty value of unit will be the first anyway after being sorted by Michelson compiler
type totalSupplyParams is (unit * contract(amt))

(* declaring pseudo-entry points: give them a name and assign the type of params described above*)
type entryAction is
  | Transfer of transferParams
  | Approve of approveParams
  | GetBalance of balanceParams
  | GetAllowance of allowanceParams
  | GetTotalSupply of totalSupplyParams

The function getAccount gets the input parameter of address type and the value of storage from the smart contract:

function getAccount (const addr : address; const s : storage) : account is
  block {
      // allowances assigning the variable acct the value of account type: zero balance and empty entry
    var acct : account :=
      record [
        balance    = 0n;
        allowances = (map [] : map (address, amt));
      ];

    (* checking if the storage has the user account: if no, leave acct empty with the previous block’s value; if yes, assign the value from the storage to acct. The function returns the acct value *)
    case s.ledger[addr] of
      None -> skip
    | Some(instance) -> acct := instance
    end;
  } with acct

The function getAllowance asks the user how many tokens they allow it to transfer to another address. It gets the user’s address, the contract address (spender), and the storage state, and returns the argument amt, which is the number of tokens subject to spending.

function getAllowance (const ownerAccount : account; const spender : address; const s : storage) : amt is
  (* if the user has allowed to send a certain amount of tokens, the function assigns the amount to amt. If not, the number of tokens equals zero *)
  case ownerAccount.allowances[spender] of
    Some (amt) -> amt
  | None -> 0n
  end;

The function transfer gets sender and recipient addresses from the user along with the number of tokens subject to sending and the storage state:

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

    (* call getAccount to assign user account data to senderAccount. Then we use senderAccount to read the user’s balance and permissions*)
    var senderAccount : account := getAccount(from_, s);

    (* checking whether the user has sufficient funds. If not, the VM terminates the contract execution, if yes, it carries on with executing the contract *)
    if senderAccount.balance < value then
      failwith("NotEnoughBalance")
    else skip;

    (* checking if the initiating address can send tokens. If it requests a transfer from someone else’s address, the function asks the real owner for permission. If the initiator and the sender are the same address, the VM carries on with executing the contract  *)
    if from_ =/= Tezos.sender then block {
    (* calling the function getAllowance so that the owner would specify how many tokens they allow to be sent, and assign the value to the constant spenderAllowance *)
      const spenderAllowance : amt = getAllowance(senderAccount, Tezos.sender, s);

    (* if the owner has allowed sending less tokens than specified in the input param, the VM will terminate the contract execution *)
      if spenderAllowance < value then
        failwith("NotEnoughAllowance")
      else skip;

      (* subtracting the transaction amount from the allowed amount *)
      senderAccount.allowances[Tezos.sender] := abs(spenderAllowance - value);
    } else skip;

    (* subtracting the sent tokens from the balance of the sender address *)
    senderAccount.balance := abs(senderAccount.balance - value);

    (* updating the balance record in the sender’s storage *)
    s.ledger[from_] := senderAccount;

    (* once again call getAccount to get or create an account record for the recipient address *)
    var destAccount : account := getAccount(to_, s);

    (* adding the amount of sent tokens to the recipient’s balance *)
    destAccount.balance := destAccount.balance + value;

    (* updating the balance record in the recipient’s storage *)
    s.ledger[to_] := destAccount;

  }
  // returning the empty list of operations and the storage state after the function’s execution
 	with (noOperations, s)

The function approve requests the confirmation of the number of tokens the spender address can send from the sender address. For example, a blockchain app (spender) requests the user (sender) for permission to send tokens.

This function can be subject to a double-spending attack. For example, the sender reduces the number of tokens allowed for spending from 20 to 10. The spender somehow finds out about it and creates a transaction that spends 20 tokens. They pay an increased fee to make the transaction get into the block before the transaction with the change of permissions. Then the spender gets 20 and waits until the permission changes. After that, they create yet another transaction, this time to send 10 tokens. The function allows them to send it. As a result, the spender sends 30 tokens instead of 10 from the sender address.

To avoid this, developers implement a delay in permission change in approve. If the allowed quantity of tokens is more than null, it can be only changed to null, and if it equals zero in the first place, it can be changed into a natural number. In this case, the spender address cannot spend tokens twice.

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

    (* getting the user account data *)
    var senderAccount : account := getAccount(Tezos.sender, s);

    (* getting the current amount of tokens the user opted to send *)
    const spenderAllowance : amt = getAllowance(senderAccount, spender, s);

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

    (* introducing the number of tokens newly permitted for spending in the account data *)
    senderAccount.allowances[spender] := value;

    (* updating the SC storage *)
    s.ledger[Tezos.sender] := senderAccount;

  } with (noOperations, s)

The functions getBalance, getAllowance, and getTotalSupply are so-called view functions. They return the requested number not to the sender but to a proxy contract. The latter allows apps to get data from user contracts and show them in the interface.

The function getBallance returns the balance of the specified address:

function getBalance (const owner : address; const contr : contract(amt); var s : storage) : return is
  block {
      //assigning account data to the constant ownerAccount
    const ownerAccount : account = getAccount(owner, s);
  }
  //returning the account balance to the proxy contract
  with (list [transaction(ownerAccount.balance, 0tz, contr)], s)

The function getAllowance returns the amount of tokens allowed to be spent by the account in question:

function getAllowance (const owner : address; const spender : address; const contr : contract(amt); var s : storage) : return is
  block {
      //getting account data and retrieve the number of tokens allowed for spending therefrom
    const ownerAccount : account = getAccount(owner, s);
    const spenderAllowance : amt = getAllowance(ownerAccount, spender, s);
  } with (list [transaction(spenderAllowance, 0tz, contr)], s)

The function getTotalSupply returns the number of tokens on all users’ balances:

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

The main function assumes the name of the pseudo-entry point and its params:

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;

Preparing the token’s smart contract for publication

To save time, we’ll use the taq-test folder from the previous lesson. Launch the VS Code editor. Create the folder Token in taq-test, and token.ligo within the former. Copy the token code below.

//declaring the alias trusted of address type. We will use it for addresses entitled to send tokens
type trusted is address;

//declaring the alias amt (amount) of nat type to store balances
type amt is nat;

(* announcing the alias account of record type. It will store data on users entitled to receive tokens
*)
type account is
  record [
    balance         : amt;
    allowances      : map (trusted, amt);
  ]

(* declaring the type of SC storage. It keeps the overall quantity of tokens and big_map data structure that ensures the connection between user balances and public addresses *)
type storage is
  record [
    totalSupply     : amt;
    ledger          : big_map (address, account);
  ]

(* declaring the alias for the return method that will return operations. In short contracts one can do without it, yet it is easier to describe the return type just once in contracts with several pseudo-entry points and then use it in every function *)
type return is list (operation) * storage

(* declare the empty list noOperations. It will be returned by transfer and approve *)
const noOperations : list (operation) = nil;
(* declaring aliases of input params for each main function of FA 1.2. *)

// transfer function gets the sender’s address, the recipient’s address, and the transaction amount to the input
type transferParams is michelson_pair(address, "from", michelson_pair(address, "to", amt, "value"), "")
// approve gets user address and number of tokens they can send from the SC’s balance
type approveParams is michelson_pair(trusted, "spender", amt, "value")
// getBalance gets the addresses of the user and the proxy contract where it sends balance data
type balanceParams is michelson_pair(address, "owner", contract(amt), "")
// getAllowance gets the user’s address, their SC account data abd the proxy contract
type allowanceParams is michelson_pair(michelson_pair(address, "owner", trusted, "spender"), "", contract(amt), "")
// totalSupply doesn’t use michelson_pair as the first input param is the empty value of unit will be the first anyway after being sorted by Michelson compiler
type totalSupplyParams is (unit * contract(amt))

(* declaring pseudo-entry points: give them a name and assign the type of params described above*)
type entryAction is
  | Transfer of transferParams
  | Approve of approveParams
  | GetBalance of balanceParams
  | GetAllowance of allowanceParams
  | GetTotalSupply of totalSupplyParams

function getAccount (const addr : address; const s : storage) : account is
  block {
      // allowances assigning the variable acct the value of account type: zero balance and empty entry
    var acct : account :=
      record [
        balance    = 0n;
        allowances = (map [] : map (address, amt));
      ];

    (* checking if the storage has the user account: if no, leave acct empty with the previous block’s value; if yes, assign the value from the storage to acct. The function returns the acct value *)
    case s.ledger[addr] of
      None -> skip
    | Some(instance) -> acct := instance
    end;
  } with acct

function getAllowance (const ownerAccount : account; const spender : address; const s : storage) : amt is
  (* if the user has allowed to send a certain amount of tokens, the function assigns the amount to amt. If not, the number of tokens equals zero *)
  case ownerAccount.allowances[spender] of
    Some (amt) -> amt
  | None -> 0n
  end;

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

    (* call getAccount to assign user account data to senderAccount. Then we use senderAccount to read the user’s balance and permissions*)
    var senderAccount : account := getAccount(from_, s);

    (* checking whether the user has sufficient funds. If not, the VM terminates the contract execution, if yes, it carries on with executing the contract *)
    if senderAccount.balance < value then
      failwith("NotEnoughBalance")
    else skip;

    (* checking if the initiating address can send tokens. If it requests a transfer from someone else’s address, the function asks the real owner for permission. If the initiator and the sender are the same address, the VM carries on with executing the contract  *)
    if from_ =/= Tezos.sender then block {
    (* calling the function getAllowance so that the owner would specify how many tokens they allow to be sent, and assign the value to the constant spenderAllowance *)
      const spenderAllowance : amt = getAllowance(senderAccount, Tezos.sender, s);

    (* if the owner has allowed sending less tokens than specified in the input param, the VM will terminate the contract execution *)
      if spenderAllowance < value then
        failwith("NotEnoughAllowance")
      else skip;

      (* subtracting the transaction amount from the allowed amount *)
      senderAccount.allowances[Tezos.sender] := abs(spenderAllowance - value);
    } else skip;

    (* subtracting the sent tokens from the balance of the sender address *)
    senderAccount.balance := abs(senderAccount.balance - value);

    (* updating the balance record in the sender’s storage *)
    s.ledger[from_] := senderAccount;

    (* once again call getAccount to get or create an account record for the recipient address *)
    var destAccount : account := getAccount(to_, s);

    (* adding the amount of sent tokens to the recipient’s balance *)
    destAccount.balance := destAccount.balance + value;

    (* updating the balance record in the recipient’s storage *)
    s.ledger[to_] := destAccount;

  }
  // returning the empty list of operations and the storage state after the function’s execution
 	with (noOperations, s)
function approve (const spender : address; const value : amt; var s : storage) : return is
  block {

    (* getting the user account data *)
    var senderAccount : account := getAccount(Tezos.sender, s);

    (* getting the current amount of tokens the user opted to send *)
    const spenderAllowance : amt = getAllowance(senderAccount, spender, s);

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

    (* introducing the number of tokens newly permitted for spending in the account data *)
    senderAccount.allowances[spender] := value;

    (* updating the SC storage *)
    s.ledger[Tezos.sender] := senderAccount;

  } with (noOperations, s)

function getBalance (const owner : address; const contr : contract(amt); var s : storage) : return is
  block {
      //assigning account data to the constant ownerAccount
    const ownerAccount : account = getAccount(owner, s);
  }
  //returning the account balance to the proxy contract
  with (list [transaction(ownerAccount.balance, 0tz, contr)], s)


function getAllowance (const owner : address; const spender : address; const contr : contract(amt); var s : storage) : return is
  block {
      //getting account data and retrieve the number of tokens allowed for spending therefrom
    const ownerAccount : account = getAccount(owner, s);
    const spenderAllowance : amt = getAllowance(ownerAccount, spender, s);
  } with (list [transaction(spenderAllowance, 0tz, contr)], s)

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;

The LIGO code has to be compiled in Michelson for the Tezos VM to execute it. Last time, we pasted a two-string contract code directly in the deployment script. This time, the code contains 200 strings so it would be wiser to store it in a separate file and import it in the script through the import command. Open the online environment LIGO and paste the token code in the editor. In the drop-down list choose Compile Contract and put a flag in Output Michelson in JSON format. Then click Run. The compiler will produce a ready-made code underneath the editor field.

1

In the token folder, create token.json and paste the JSON code therein.

2

Last time, we put the test account data into the deploy.ts script and set up an RPC link of the public node on the Tezos testnet. We can use this code to publish our token.

Create the file token-deploy.tx in taq-test and paste the code from deploy.ts therein. Then we have to update it: add the method file sync to read other files, import the contract code, and specify the original storage state.

After import methods, add the method file sync and the Tezos constant to call Taquito methods:

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

Replace the method try. After that, set up the initial storage state: the overall number of tokens and user balance:

try {
   const op = await tezos.contract.originate({
       // reading code from token.json
       code: JSON.parse(fs.readFileSync("./token.json").toString()),
       // setting the storage state in Michelson. Replace both addresses with your account address on the testnet, and numbers with the number of tokens you want to issue.
       init:
           '(Pair { Elt "tz1imn4fjJFwmNaiEWnAGdRrRHxzxrBdKafZ" (Pair { Elt "tz1imn4fjJFwmNaiEWnAGdRrRHxzxrBdKafZ" 1000 } 1000) } 1000)',
   })

The final code looks like this:

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', //mail
    'ZnnZLS0v6O', //password
    [
      'able', //passphrase
      'public',
      'usual',
      'hello',
      'october',
      'owner',
      'essence',
      'old',
      'author',
      'original',
      'various',
      'gossip',
      'core',
      'high',
      'hire',
    ].join(' '),
    '2bed8dc244ee43a1e737096c4723263c269049d8' //private key
  )

  try {
    const op = await tezos.contract.originate({
      // reading code from token.json
      code: JSON.parse(fs.readFileSync('./token.json').toString()),
      // setting the storage state in Michelson. Replace both addresses with your account address on the testnet, and numbers with the number of tokens you want to issue.
      init: '(Pair { Elt "tz1imn4fjJFwmNaiEWnAGdRrRHxzxrBdKafZ" (Pair { Elt "tz1imn4fjJFwmNaiEWnAGdRrRHxzxrBdKafZ" 1000 } 1000) } 1000)',
    })

    //deployment commences
    console.log('Awaiting confirmation...')
    const contract = await op.contract()
    //deployment report: amount of used gas, storage state
    console.log('Gas Used', op.consumedGas)
    console.log('Storage', await contract.storage())
    //operation hash one can use to find the contract in the blockchain explorer
    console.log('Operation hash:', op.hash)
  } catch (ex) {
    console.error(ex)
  }
}

deploy()

Open the terminal and execute npx ts-node token-deploy.ts. In a few minutes, Taquito will publish the token contract on the testnet and produce the operation’s hash.

Find it in florence.tzstats and check the contract storage state. We have 1,000 tokens written therein, you will have the number you have specified.

3

In the Bigmap field, find the records on token owner addresses. When publishing the contract, we sent all tokens to the test address.

4

Sending tokens with Taquito

Remember how we added a number to the contract storage using Taquito? Now we do something more difficult: call the function transfer and send tokens to another user.

Install a Tezos wallet and create an account (in case you didn’t do it last time). We recommend Temple Wallet as it supports Tezos testnets out of the box.

Open VS Code and create the file token-transfer.ts. Paste the code below therein:

//importing the methods of Taquito and the file with the test account data acc.json

import { TezosToolkit } from '@taquito/taquito'
import { InMemorySigner } from '@taquito/signer'
const acc = require('./acc.json')
export class token_transfer {
  // setting up the link to the testnet’s public node
  private tezos: TezosToolkit
  rpcUrl: string

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

    //reading the mail, password, and the passphrase that can produce the private key
    this.tezos.setSignerProvider(InMemorySigner.fromFundraiser(acc.email, acc.password, acc.mnemonic.join(' ')))
  }

  // declaring the method transfer that has the following params:
  //
  // 1) contract: contract address;
  // 2) sender: sender address;
  // 3) receiver: recipient address;
  // 4) amount: amount of tokens to be sent.

  public transfer(contract: string, sender: string, receiver: string, amount: number) {
    this.tezos.contract
      .at(contract) //calling the contract at the address
      .then((contract) => {
        console.log(`Sending ${amount} from ${sender} to ${receiver}...`)
        //calling the entry point transfer, send the reciever/sender addresses and the amount of tokens to be sent to it.
        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) //waiting for 1 network confirmation
      })
      .then((hash) => console.log(`Hash: https://florence.tzstats.com/${hash}`)) //getting the operation’s hash
      .catch((error) => console.log(`Error: ${JSON.stringify(error, null, 2)}`))
  }
}

Now create a file transfer.ts and paste the following code therein:

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

const RPC_URL = 'https://florencenet.api.tez.ie'
const CONTRACT = 'KT1DUdLarKFG9tmA4uZCgbiHv5SJA9oUBw8G' //address of the published contract
const SENDER = 'tz1imn4fjJFwmNaiEWnAGdRrRHxzxrBdKafZ' //public address of the sender (find it in acc.json)
const RECEIVER = 'tz1UEQzJbuaGJgwvkekk6HwGwaKvjZ7rr9v4' // recipient's public address (take it from the Tezos wallet you had created)
const AMOUNT = 3 //number of tokens to be sent, you can put another value here
new token_transfer(RPC_URL).transfer(CONTRACT, SENDER, RECEIVER, AMOUNT)

Open the console and execute npx ts-node transfer.ts. Wait for it to return the operation’s hash. Then open Temple Wallet, press Tezos Mainnet on the upper side of the window and select Florence Testnet from the drop-down menu.

5

The wallet looks empty but the tokens are in fact there. Temple doesn’t see them because the contract code has no metadata with its name and other params. We will cover metadata next time. Right now, however, we can add them manually and see tokens in the wallet.

Copy the token contract address from transfer.ts. Open Temple Wallet and press Manage on the right-hand side of Search Assets and then press Add Token.

6

Temple will open the browser with a tab where you can manually add the token. Choose the FA 1.2 standard in the drop-down menu Token type and then paste the smart contract address in the Address field.

7

Fill in the token data: its ticker, description, number of digits after the decimal point, and the link to the logo. Write whatever you wish, however, keep 0 (zero) in the Decimals field. Press Add Token to add the token with the preset parameters to the wallet.

8

Good job! Now Temple Wallet shows your token. It used the entry points of FA 1.2 so you can browse the balance and send tokens from the wallet’s interface. That said, the recipient will still have to manually set up the metadata to see the transfer.

9

Next time we’ll deal with metadata and publish a full-fledged token that wallets will see straight away. And, what’s more, we’ll also issue an FA2 NFT.

Some conclusions

Tokens are records in the storage of smart contracts saying things like: “the address TZ1 owns 1,000 coins.” The tokens are transferred in the following manner:

  1. User/app calls the smart contract.
  2. The smart contract checks whether the user is able to send tokens.
  3. The smart contract updates user balance records: reduces the sender’s balance by the amount of the transfer, and then adds it to the recipient’s balance.

Protocol developers create standards that describe standard functions of tokens: storing user data, checking balances, and performing transactions. In Tezos, there are two popular standards: FA 1.2 for fungible tokens and FA 2 for NFT.

Developers can write smart contracts from the scratch or use implementations, i.e. templates with basic functions. Advanced mechanisms like issuance or destruction of tokens have to be created on an individual basis.

  • Written by Pavel Skoroplyas
  • Produced by Svetlana Lukina
  • Stylistic framework by Dmitri Boyko
  • Visuals by Krzystof Szpak
  • Layouts by Zara Arakelian
  • Development by Oleksandr Pupko
  • Directed by Vlad Likhuta