Liquidity Tutorials — Hands-on Series — Part 5.1

Marty Stumpf
6 min readSep 18, 2019

This is the fifth part of a tutorial series on writing smart contracts in Liquidity. I recommend going through the first, second, third, and fourth parts if you haven’t already.

In this tutorial, we’ll step up our game (yet again!) and write a token contract! Deploying the contract creates a fungible token system running on the Tezos ledger. One can transfer tokens between accounts in the token system and approve amounts to be transferred on your behalf. The token standard was inspired by this. A comparable (more detailed) Ethereum standard can be found here. In the next tutorial (part 5.2), we’ll deploy and call the contract.

The users of this contract include the users (account holders) of the tokens, the owner of the token system, and other contracts (more below).

The account holders can call the contract to:

  • make a transfer/multiple transfers. If the recipient doesn’t have an account in the token system, the contract will create one, and initialize it with zero balance.
  • make an approval of a transfer amount to another account holder. (More on this below.)

The owner can, on top of what the account holders can do, deploy the contract to start running the token system.

Let’s start writing the contract!

The contract

I saved the contract as token.liq (see the whole contract here). It’s the largest contract we’ve seen so far! I’m going to split the contract into 2 parts, and go through each part:

  1. Type and function definitions
  2. Entry point definitions related to token transfers

Type and function definitions

We first define 2 data types; the account type and the storage type:

type account = {
balance : nat;
allowances : (address, nat) map;
}

type storage = {
accounts : (address, account) big_map;
version : nat; (* version of token standard *)
totalSupply : nat;
name : string;
symbol : string;
owner : address;
}

The account type is used to associate with an address in the accounts type, which is a field in the storage type.

The account type is a record with 2 fields:

  • the balance field contains the balance of the token associated with the address.
  • the allowances field contains the amount of token approved by the account holder to send to another address. It is of type (address, nat) map, corresponding to the address of the approved recipient and the amount of token approved.

The storage is a record with 6 fields:

  • the accounts field contains the set of accounts (of type (address, account) big map ). The address is the account holder’s tz1 address in Tezos. Each account holder is associated with a balance of tokens owned, and the allowances approved to other account holders.
  • the version field contains the version of the token standard.
  • the totalSupply field contains the total supply of the token.
  • the name and symbol fields contains the name and symbol of the token.
  • the owner field contains the Tezos address of the owner of this token system.

Next, we define 2 functions (get_account and perform_transfer) to help us define the entry points.

let get_account (a, (accounts : (address, account) big_map)) =
match Map.find a accounts with
| None -> { balance = 0p; allowances = Map [] }
| Some account -> account

let perform_transfer
(from, dest, tokens, storage) =
let accounts = storage.accounts in
let account_sender = get_account (from, accounts) in
let new_account_sender = match is_nat (account_sender.balance - tokens) with
| None ->
failwith ("Not enough tokens for transfer", account_sender.balance)
| Some b -> account_sender.balance <- b in
let accounts = Map.add from new_account_sender accounts in
let account_dest = get_account (dest, accounts) in
let new_account_dest =
account_dest.balance <- account_dest.balance + tokens in
let accounts = Map.add dest new_account_dest accounts in
[], storage.accounts <- accounts

First, the get_account function is used in the perform_transfer function. It searches for an account in a big map of accounts. If the input address (a) corresponds to an account in the accounts big map, the output is the input address’s associated account map, i.e., its balance and allowances map. Otherwise, an account is created with 0 balance and an empty allowance map.

Second, the perform_transfer function performs a transfer. If the sender does not have enough balance, the transfer will fail with the error “Not enough tokens for transfer”. Otherwise, it updates (1) the sender’s account balance, decreased by the transfer amount, and (2) the recipient’s account balance, increased by the transfer amount. The output is a pair of empty list and storage, with the storage updated to the new account balances.

Entry points for token transfers

There are 4 entry points related to token transfers: the entry points transfer, multiTransfer, and approve are called by account holders. The entry point transferFrom is called by a contract.

The entry point transfer transfers the specified amount of tokens from the caller of the entry point to the specified destination address. This entry point simply calls the perform_transfer function above:

let%entry transfer (dest, tokens) storage =
perform_transfer (Current.sender (), dest, tokens, storage)
  • its inputs are:
  1. the parameter of the contract, which I named (dest, tokens), of type address and nat pair. dest is the recipient’s Tezos address, tokens is the amount to transfer.
  2. the storage, which I named storage, of type storage. The state before the transfer is passed in so the accounts can be updated to the new state.
  • its output, as always, is a pair of operation list and storage. The output is the output of the perform_transfer function, which is a pair of empty list and storage, with the storage updated to the new account balances.

As per the perform_transfer function, the sender has to have enough balance for a successful tranfer. If the recipent does not already have an account in the token system, an account with 0 balance and an empty allowances map will be added to the accounts big map (as per the get_account function), and the balance will be updated to the specified transfer amount.

The multiTransfer entry point is very similar to the transfer entry point, except that the sender inputs a list of a (dest,token) to make multiple transfers in one call. It makes use of the List.fold function in Liquidity. See here for a detailed explanation on how folds work.

let%entry multiTransfer list_of_transfers storage =
List.fold (fun ((dest, tokens), (_ops, storage)) ->
perform_transfer (Current.sender (), dest, tokens, storage)
) list_of_transfers ([], storage)

The entry point approve is called when an account holder wants to allow contracts to use the transferFrom entry point to transfer an amount up to but not more than the specified amount of tokens. There is no limit on the number of withdrawals. The caller inputs (in addition to the storage)

  1. spender, of type address (the address of the account holder the caller approved to) and
  2. tokens, of type nat (the amount of token approved).

If the specified amount is zero, then the input address and its associated nat (token amount) will be removed from the allowance map. If the specified amount is any other natural numbers, then the allowance map will be updated, with the input address’s associated nat updated to the input amount:

let%entry approve (spender, tokens) storage =
let account_sender = get_account (Current.sender (), storage.accounts) in
let account_sender =
account_sender.allowances <-
if tokens = 0p then
Map.remove spender account_sender.allowances
else
Map.add spender tokens account_sender.allowances in
let storage = storage.accounts <-
Map.add (Current.sender ()) account_sender storage.accounts in
[], storage

The entry point transferFrom allows contracts to transfer tokens on your behalf. We will write a contract that uses this entry point in a later tutorial. The account holder must have already approved the amount (using the approve entry point above). The caller (probably a contract) inputs (in addition to the storage):

  1. from, the address to transfer the tokens from, and
  2. dest, the address the tokens are transferred to, and
  3. tokens, the amount of tokens to transfer.

Two conditions must be met before the transfer is performed. First, the dest address must be in the allowance map of the from address. If not, the call will fail with the error “Not allowed to spend from”. Second, the from address must have enough balance for the transfer, if not, the call will fail with the error “Not enough allowance for transfer”. If there is enough balance and allowance, the allowance map will be updated with the correct allowance (after the transfer), and the perform_transfer function will be called:

let%entry transferFrom (from, dest, tokens) storage =
let account_from = get_account (from, storage.accounts) in
let new_allowances_from =
match Map.find dest account_from.allowances with
| None -> failwith ("Not allowed to spend from", from)
| Some allowed ->
match is_nat (allowed - tokens) with
| None ->
failwith ("Not enough allowance for transfer", allowed)
| Some allowed ->
if allowed = 0p then
Map.remove dest account_from.allowances
else
Map.add dest allowed account_from.allowances in
let account_from = account_from.allowances <- new_allowances_from in
let storage = storage.accounts <-
Map.add from account_from storage.accounts in
perform_transfer (from, dest, tokens, storage)

Woohoo! You’ve written a contract that enables you to launch a token system on Tezos! In the next tutorial we’ll deploy and call the contract and see it in action. Stay tuned!

--

--

Marty Stumpf

(Functional) Software engineer from BC, Canada. thealmarty.com, twitter: @MartyStumpf.