Writing a Tezos FA1.2 contract in CameLigo
Learn how to write one of the simplest token contracts on Tezos and improve your skills as a Tezos smart contract developer.
1,950 words, 10 minute read
The FA1.2 contract is probably one of my favorite coding exercises for developers who are new to the Tezos ecosystem, because it presents multiple benefits.
First, the requirements to implement it are pretty simple, so that developers can focus more on the code they write and less on sticking to complex behaviors.
Second, the implementation is quite simple, which makes it perfect to learn the basics of using a smart contract language like CameLigo.
Finally, it also introduces developers to the TZIP standard on Tezos and make them more comfortable reading and implementing TZIP proposals.
Let’s dive in.
The different types #
Let’s start by writing the types we will need for this contract:
type parameter =
| Transfer of address * nat
| Approve of address * nat
type transfer_param =
{
from: address;
to: address;
amount: nat;
}
type approve_param =
{
spender: address;
value: nat
}
type ledger_value =
{
balance: nat;
allowances: (address, nat) map
}
type storage =
{
ledger: (address, ledger_value) big_map;
total_supply: nat;
}
type result = operation list * storage
The parameter
type represents the 2 entrypoints of the contract: a transfer
entrypoint to perform a transfer of tokens between one account and another and an approve
entrypoint to set an allowance for a third party.
The transfer_param
type is a record that will be translated into a pair with annotated fields, which is required by the FA1.2 standard. The same thing goes for the approve_param
type.
You can also set a ledger_value
type to make it easier to manipulate the values in the ledger, then the storage is a record that holds the ledger (to track the different balances and allowances) and the total supply of tokens.
Finally, the result
type is a tuple that holds a list of operations and the new storage, the type of value that is returned at the end of the execution of each entrypoint.
The transfer entrypoint #
The goal of the transfer
entrypoint is to send tokens from one account to another, following the bahaviors specified in the TZIP 7 standard.
Function signature: #
[@entry]
let transfer (p: transfer_param) (s: storage): result =
The transfer function takes two parameters: p
of type transfer_param and s
of type storage.
It returns a value of type result
, which is typically a pattern in Tezos smart contracts to indicate the outcome of the operation.
Note the [@entry]
attribute used here to indicate to the Ligo compiler that this function is an entrypoint.
Extracting parameters: #
let { from = spender ; to = recipient ; amount = amount } = p in
The function begins by destructuring the transfer_param
object p
into spender
, recipient
, and amount
.
Validating spender’s existence: #
match (Big_map.find_opt spender s.ledger) with
| None -> failwith "NotEnoughBalance"
| Some(account) ->
Here, we use pattern matching to check if the spender (the account that will send the tokens) exists in the ledger (a data structure maintaining account balances and allowances).
If the spender is not found in the ledger, it fails with "NotEnoughBalance"
.
If the spender is found, the details of their account is passed in the account variable.
Checking sender’s authority: #
let new_allowances =
if Tezos.get_sender () = spender
then
// sender is spender
account.allowances
else
The contract checks if the sender of the request (obtained by Tezos.get_sender ()
) is authorized to transfer tokens from the spender’s account.
If the sender is the spender, they are obviously allowed to spend their own tokens and no further check is done.
If the sender is not the spender, the code checks whether the sender has enough allowance to transfer the tokens. This is done by looking up the sender’s allowance in the spender’s account:
match (Map.find_opt (Tezos.get_sender ()) account.allowances) with
| None -> failwith
{
msg = "NotEnoughAllowance";
required = amount;
present = 0n
}
| Some(allowance) ->
Pattern matching is used to find a value in a big map or a map by its key. If no allowance is found, the contract execution fails with the "NotEnoughAllowance"
message, the amount of required allowance and the amount of present allowance.
If an allowance is found, you can check it and update it.
Checking and updating allowances:
if allowance < amount
then failwith
{
msg = "NotEnoughAllowance";
required = amount;
present = allowance
}
else
// debits amount from allowance
(match is_nat (allowance - amount) with
| None -> failwith "ERROR_WHILE_UPDATING_ALLOWANCE"
| Some(new_allowance) ->
Map.update
(Tezos.get_sender ())
(Some (new_allowance))
account.allowances)
The code checks that the sender has enough allowance to transfer the tokens on the spender’s behalf. If not, the contract execution fails with the "NotEnoughAllowance"
message as seen previously.
If the allowance is correct, you will update the allowance by subtracting the amount of tokens to be transferred. To do that, you can use the is_nat function
. This function ensures that the new allowance is a natural number (non-negative).
If it is not a natural number, it fails with "ERROR_WHILE_UPDATING_ALLOWANCE"
. If it is one, the map of allowances is updated with the value returned by is_nat
.
Note: at this point, you had already checked that the allowance is enough to transfer the tokens, so
is_nat
should not return None. However, it’s a good habit to use it when subtracting 2 natural numbers.
Validating and updating spender’s balance: #
let new_ledger =
match is_nat (account.balance - amount) with
| None -> failwith "NotEnoughBalance"
| Some(new_balance) ->
Big_map.update
spender
(Some { account with
balance = new_balance ;
allowances = new_allowances
})
s.ledger
in
The first part of the token transfer involves updating the spender’s balance.
As you did in the previous part, you can use is_nat
to debit the amount of tokens from the existing balance while checking if the spender has enough balance to make the transfer.
If the balance is insufficient, the contract fails with "NotEnoughBalance"
.
Otherwise, the spender’s balance is updated by removing the specified amount of tokens.
Updating recipient’s balance: #
let new_ledger =
match (Big_map.find_opt recipient new_ledger) with
| None ->
Big_map.add
recipient
{
balance = amount ;
allowances = (Map.empty: (address, nat) map)
}
new_ledger
| Some(account) ->
Big_map.update
recipient
(Some { account with balance = account.balance + amount })
new_ledger
in
The contract then updates the ledger to reflect the transfer of tokens to the recipient.
If the recipient does not exist in the ledger, a new account is created with the transfer amount as its balance and an empty map of allowances.
If the recipient exists, their balance is updated by adding the amount of tokens.
Finalizing the transaction: #
[], { s with ledger = new_ledger }
The function returns an empty list
(commonly used for operations or events in Tezos smart contracts) and the updated storage
, which includes the new ledger state.
The approve entrypoint #
The goal of the approve entrypoint is to set an allowance for a third party to spend tokens on the user’s behalf.
Multiple implementations are possible but we will stick with the simplest one here, where the sender of the transaction is the only one allowed to set an allowance for their own account.
Function signature: #
[@entry]
let approve (p: approve_param) (s: storage): result =
The [@entry]
attribute marks the function as an entrypoint in the smart contract.
The function is called approve
and takes 2 parameters: a parameter p
of type approve_param
, which typically contains the third party’s address and the amount of tokens they are allowed to spend, and s
of type storage
that represents the current state of the contract’s storage.
The function returns a value of type result
, a typical pattern in Tezos smart contracts to indicate the outcome of the operation, like it did for the transfer entrypoint above.
Getting the sender: #
let sender = Tezos.get_sender () in
The function retrieves the address of the sender (the token holder) using Tezos.get_sender ()
and stores it in the sender variable.
Updating or creating an account in the ledger: #
let new_ledger =
match (Big_map.find_opt sender s.ledger) with
| None ->
// if the sender has no account
Big_map.add
sender
{ balance = 0n ; allowances = Map.literal [(p.spender, p.value)] }
s.ledger
| Some(account) ->
The contract looks up the sender’s account in the ledger (a data structure of type big_map
that stores values associated to keys).
If the sender does not have an account (i.e., not found in the ledger), it creates a new account with a balance of 0
and an empty map of allowances.
If the sender already has an account, the contract proceeds to update the allowances.
Managing allowances: #
let new_account = (
match (Map.find_opt p.spender account.allowances) with
| None -> { account with allowances = Map.literal [(p.spender, p.value)] }
| Some(allowance) ->
The contract checks if an allowance for the specified spender already exists. If no previous allowance exists, it sets a new allowance with the specified value.
If an allowance already exists, the contract checks for unsafe allowance changes:
if allowance <> 0n && p.value <> 0n
then failwith "UnsafeAllowanceChange"
else
let new_allowance = allowance + p.value
in {
account with allowances =
Map.update p.spender (Some new_allowance) account.allowances
}
An unsafe change is defined as changing a non-zero allowance to another non-zero value, which could lead to race conditions in some token standards (like it is the case for ERC-20). This is guarded against by failing with "UnsafeAllowanceChange"
.
If it’s safe to update the allowance, the contract calculates the new allowance and updates it.
Finalizing the update: #
Big_map.update sender (Some new_account) s.ledger
Next, the contract updates the sender’s account in the ledger with the new allowance information.
Returning the result:
[], { s with ledger = new_ledger }
Finally, the function returns an empty list of operations and the updated storage, which includes the new state of the ledger.
Conclusion #
Although the FA1.2 standard has been replaced by the FA2 standard, it remains relevant for developers, not only because important tokens in the Tezos ecosystem like Ctez are implemented following this standard, but also because it is a good exercise for new developers as the contract doesn’t present any major difficulty from a coding point of view.
The transfer
and approve
entrypoints in the FA1.2 contract are integral components of the TZIP-7 standard on Tezos and are designed for managing fungible tokens, each serving distinct yet complementary roles in the token behaviour.
The transfer entrypoint #
The transfer
entrypoint facilitates the movement of tokens from one account to another. It ensures that only authorized transactions occur by verifying that the spender has sufficient balance and, if the spender is not the sender, that they have been granted enough allowance by the token holder.
This entrypoint includes robust checks for account existence, balance sufficiency, and allowance adequacy, ensuring secure and error-free token transfers. Its design reflects the need for strict validation in financial transactions on Tezos.
The approve entrypoint: #
The approve entrypoint allows a token holder to set or modify the allowance of a third party (spender) to use a specified amount of the token owned by the holder. This is crucial for scenarios where tokens need to be spent or managed by smart contracts or other parties on behalf of the token holder.
It includes important safeguards against unsafe allowance changes, a common pitfall in token contract design, highlighting the attention to potential security vulnerabilities.