Understanding how fungible tokens work on the Tezos blockchain
It could be hard sometimes as a beginner to understand what’s going on in the Tezos ecosystem if you don’t master some critical terminology. You have probably heard or read about “fungible tokens” or “ERC20-like tokens” but you are left wondering what they are, what they do, how they work and most importantly, how you can create your own. As it is often the case in the blockchain universe, behind seemingly complex words are hidden very simple concepts!
This article will guide through fungible tokens on the Tezos blockchain by building a smart contract to create them, store them and transfer them using Ligo programming language (and its ReasonLigo flavor) so you can have a better understanding of what they are and how they are set to revolutionize the Tezos blockchain. We will build a simple smart contract to demonstrate the necessary functions of any fungible token. I will assume you have at least a general knowledge of some programming language, although basic knowledge of Ligo would be better to follow the code examples.
You can find the complete smart contract at this address in the Ligo web IDE. You may also check my own implementation of the TZIP-7 proposal available on Github, it contains all the code introduced in this tutorial + improvements I am working on + tests made with Truffle.
If you look for the definition of fungible online, you will find this: “able to replace or be replaced by another identical item; mutually interchangeable.” Fungible tokens are tokens that are mutually interchangeable with other tokens of the same type. If you have one of these tokens, you can find someone with the same kind of token, exchange each other tokens without losing any value. That is basically how bills and coins work: if I give you 1 euro coin and you give me another 1 euro coin, I still have 1 euro. They are the opposite of non-fungible tokens, i.e tokens that are unique, of different value and non-interchangeable.
One of the first public appearances of fungible tokens was on the Ethereum blockchain under the ERC20 standard (this is why this type of token is often called “ERC20-like”). The Ethereum blockchain allowed anyone to create and manage fungible tokens on-chain in a very simple way: you basically only need a few information to create your own token! The fungible tokens were also one of the basic necessary elements for the explosion of ICOs: every company would create its own token, distribute/sell it to interested parties and hope people would use it so it would gain in value.
When you want to create a fungible token on a blockchain, there are a few considerations that you have to keep in mind:
All these considerations led developers to establish a common standard for fungible tokens on the Tezos blockchain called the “TZIP-7 proposal”. As explained by the official documentation, the TZIP-7 proposal “[…] describes a smart contract which implements a ledger that maps identities to balances. This ledger implements token transfer operations, as well as approvals for spending tokens from other accounts”.
In the future, the TZIP-7 proposal may be replaced by other proposals, for example, the TZIP-12. But the TZIP-7 remains a very good example of how fungible tokens can be implemented on the Tezos blockchain and presents the core concepts in a way that’s easier to understand for beginners.
The Tezos Interoperability Proposals (or TZIPs) “[…] are documents that explain how the Tezos blockchain can be improved with new and updated standards and concepts, such as smart contract specifications.” They are basically different standards for smart contracts that meet different needs. The TZIP-7 is built for fungible tokens. These proposals include an interface (mostly a set of entry points) that the smart contract must include to follow the standard. The official documentation gives the following interface:
(address :from, (address :to, nat :value)) %transfer
=> the transfer entry point allows the token owners and third parties to move tokens from one account to the other. It must accept 3 parameters: the address from which the tokens will be sent, the address to which the tokens will be sent and the amount of tokens sent as a natural number.(address :spender, nat :value) %approve
=> the approve entry point allows the token owners to authorize third parties to spend their tokens on their behalf. It must accept 2 parameters: the address that will be allowed to spend tokens on the owner’s behalf and the maximum value that can be spent.(view (address :owner, address :spender) nat) %getAllowance
=> the view keyword indicates that the entry point will return a value forwarded to the callback contract whose address will also be passed as an argument. By convention, view entry points emit a transaction and must not change the storage.(view (address :owner) nat) %getBalance
=> another view entry point, getBalance returns the balance of the owner of tokens as a natural number. For example, a dapp accepting a specific token for payment could query the smart contract to verify its users have enough tokens before processing a transaction.(view unit nat) %getTotalSupply
=> the last entry point of the standard, getTotalSupply returns the total supply of tokens in circulation as a natural number. This could be useful, for example, for an online exchange.One thing that is also important to remember when you read TZIP standards is that they only lay out the bare minimum to follow in order to write a TZIP-X compliant smart contract. You are free (and even encouraged!) to add more entry points, more parameters and more functionalities (more about that below).
Now it’s time to get to your keyboard 😅 We are going to write a simple TZIP-7 compliant smart contract to demonstrate how it works. This will be a great opportunity to learn more about security considerations and structural conventions.
The storage and entry points structure
This will probably be the easiest part of the smart contract 😬 We only need to keep track of 3 different information in our storage: 1- the owner of the smart contract (for administration or token minting/burning purposes) 2- the ledger that will record the balance and the allowances of every account 3- the total supply of tokens. In this case, a record will be well-suited:
type storage = {
owner: address,
totalSupply: nat,
ledger: big_map (address, account)
};
The ledger is a big map that is going to create bindings whose keys are the addresses of the account owners and whose values are information about their account. For this purpose, we will create a new record of type account:
type account = {
balance: nat,
allowances: map (address, nat)
};
type storage = {
owner: address,
totalSupply: nat,
ledger: big_map (address, account)
};
As you can see, the balance is going to be represented by a natural number (to avoid negative balances) while the allowances are going to be maps matching a third party address with the amount of tokens they are allowed to spend on behalf of the account owner.
The entry points variant will be pretty straightforward:
type action =
| Transfer ((address, address, nat))
| Approve ((address, nat))
| GetAllowance ((address, address, contract(nat)))
| GetBalance ((address, contract(nat)))
| GetTotalSupply ((unit, contract(nat)));
You can see that the parameters for each entry point follow the TZIP-7 standard. In addition to the recommended parameters, the view entry points receive the contract where the requested information will be returned to.
The transfer entry point
This is probably one of the most important and sensitive features of your smart contract. What would be the purpose of owning your tokens if it was impossible to use them or exchange them after all?
The standard is only a blueprint that gives you the parameters the entry point should accept, the value it must return (if any) and other considerations about how it may affect the storage. So it will be up to you to turn it into code. Let’s start with the skeleton of the entry point:
We create a function called
transfer
with 4 parameters. Remember that the standard includes 3 parameters, but you are free to add more (which is necessary here). However, you cannot remove parameters. Here are our 4 parameters: accountFrom
will contain the address of the account sending the tokens (which we will refer to as the spender), accountTo
will contain the address of the account receiving the tokens (which we will refer to as the recipient), value
will contain the amount of tokens to be sent (as a natural number to avoid negative amounts) and s is our storage. The function returns the new storage after the amount of tokens has been switched from one account to the other.The first thing we want to do is verify if the
accountFrom
and accountTo
addresses are not the same, it wouldn’t make any sense to have the same account as a spender and a recipient:If both addresses are the same, we just use failwith with an error code (“SameOriginAndDestination” is a personal choice, you can use the code you like). Don’t forget to annotate the type that is expected after failwith, storage here.
Now that we know that the two addresses are different, let’s proceed with one of the major security features of the transfer entry point: we are going to check if the spender (i.e the address in the
accountFrom
variable) is allowed to spend the tokens. There are only two categories of allowed spenders: the owners of the account themselves and the third parties authorized by the owners to spend tokens on their behalf. In order to make the code more readable and easier to maintain, we will separate the piece of code that will check it into its own function:The isAllowed function takes 2 parameters:
accountFrom
with the address of the spender and value
with the amount of tokens to be transferred. We check first if the initiator of the transfer is not the owner of the account, because if that’s the case, he/she is obviously allowed to transfer the tokens and the function returns true
. If that’s not the case, we have to check if the spender has been allowed by the owner of the account to spend tokens on his/her behalf.switch
checks if the spender exists as a key in the ledger. If not, we return false
because the spender address doesn’t exist in the ledger and doesn’t have any token to be transferred in the first place. If it exists, we must check if the amount requested to transfer has been approved by the account owner.switch
checks the allowances
property of the account
record that contains a map whose bindings represent which amount has been approved for which third party address. If the spender address is not found, it means that it hasn’t been allowed to spend tokens on behalf of the account owner and it returns false
. If it is found, we evaluate whether the allowed amount is superior or equal to the requested amount. If this is the case, the function returns true
, otherwise, it returns false
.Now let’s add the isAllowed function to our transfer function:
If the spender is not allowed to transfer the tokens, the function will fail with the
NotEnoughAllowance
error message (as recommended in the proposal). Otherwise, the transaction will continue.We are now going to find the account in the ledger from which the tokens are going to be deducted:
Once found we check if the account has enough tokens to continue with the transfer and throw an error if it doesn’t (the error code
NotEnoughBalance
is recommended by the standard):If the owner of the account has enough balance, we can go ahead and do two things: the first one, we will deduct the requested amount from the spender’s balance, the second one, we will add the requested amount to the recipient’s balance. The order doesn’t matter too much (as it does in Solidity) because no change will affect the storage before we return the new storage at the end of the entry point. It just feels more logical to me to subtract the tokens before adding them:
In the
src_
variable, we will store the record of type account with the updated balance (remember that variables in ReasonLigo are immutable and it is not possible to update a variable previously declared with a value). Note how the result of the subtraction is enclosed in the abs()
function, because the subtraction of 2 natural numbers in Ligo returns an integer, so we have to cast it back to a nat in order to save it in the storage.Next, the
dest
variable will be a record of type account and will contain the updated account of the recipient. Note that if the recipient doesn’t have an account, the smart contract will automatically create one, credit its balance with the right amount of tokens and add an empty map of allowances. If an account is found, it will just update the token balance and return the account.Before returning the updated storage, there is only one thing left to do: we must decrease the allowance of the spender if necessary:
If the sender cannot be found in the allowances map, it’s probably because he/she is the owner of the account, so we can return the unchanged allowances map (remember we checked earlier if the spender is allowed to transfer the tokens, but the transaction can also have been initiated by the owner of the account).
If the sender is found, we call
Map.update
and subtract the amount of transferred tokens from the total amount of available tokens approved for transfer. We wrap the subtraction inside the abs()
function to get a nat.Now it’s time to return our updated storage. We will do it in two stages because of the immutable nature of the variables in Ligo as we need to perform two updates on the ledger:
In the first stage, we update the ledger with the new information about the spender’s account: we spread the
src_
record where the balance has been updated earlier and we insert the new_allowances
map at the allowances property where the spender’s allowances have been reduced if necessary. We could also have put directly the previous switch
after the allowance property but it would have made our code less readable.In the second stage, we update the newly-created
new_storage
record and insert the updated account of the recipient in the ledger before returning the storage. Now the two accounts have been updated, the spender’s account has been deducted with the amount of tokens passed into the entry point and the recipient’s account has been credited with the same amount of tokens 🥳The approve entry point
This entry point is also one of the most important mechanisms of the smart contract. As it will allow third parties to spend tokens on behalf of the account owners, you want to make sure that 1- third parties are correctly registered so they can spend the tokens they are allowed to spend when the times comes 2- the right amount of tokens is saved into the account to avoid unauthorized spendings.
Let’s start by declaring our function:
First, we want to check if the spender and the initiator of the transaction are not the same address, it wouldn’t make any sense for our users to set themselves an allowance, they are free to spend their tokens as they please.
Once we are sure that the spender is different from the sender, we must check if the sender has already an account in the ledger, otherwise, we won’t be able to update allowances in an account that doesn’t exist. Notice how the transaction fails if no account is found:
Now we can add or update the allowance for the matching spender. At this point, there is a very critical safety measure to keep in mind in order to update a given allowance which the official documentation warns us about: the prevention of attack vectors. In a nutshell, it means that we must forbid any transaction that would change a non-zero allowance to another non-zero allowance to prevent the allowed spender from spending their allowance while the account owner updates the aforementioned allowance, which would allow the spender to spend the previous allowance + the new allowance. This gives us only three scenarios that we can allow:
Let’s translate it into code:
First, we find the allowance linked to the spender’s address in the allowances map. If there is no allowance set up, we update the map and add the allowance value passed by the account owner. If there is already an allowance set up, the if condition makes sure that both the passed value and the allowance are not non-zero values, in which case the transaction fails. If the allowance is equal to 0 and the value is a number above zero, the allowance will be set to this number. If the allowance is a number above zero and the value is 0, the allowance will be set to 0. This will prevent the attack vector.
Once the allowance has been safely updated, we can return the new storage:
Now the users of our smart contract can safely approve third parties and set a maximum amount of tokens they are allowed to transfer on their behalf. The authorized allowance can be securely revoked at any moment.
The getAllowance entry point
It is useful to remember that all transactions are not initiated by human users, some are also sent by smart contracts or dapps. In the case of a dapp that you allowed to spend some tokens on your behalf, the dapp may check how much of its allowance is left before sending a transaction to the network. This is one of the purposes of view entry points. As per the standard, the function takes 2 parameters: the address of the account owner and the address of the allowed spender. It returns the available allowance as a new transaction.
For our example, we will add a third parameter: the contract to which the result must be returned. Note that the entry point does NOT modify the storage, which is one of the key characteristics of a view entry point:
First, we are going to find the owner’s account:
Note that in this kind of function, it is important to make the transaction fail when things don’t go as planned to avoid unnecessary transactions returned to the callback contract and ensure a clean chain of transactions.
Once we have the account, we can find the existing allowance and create a new transaction to return it:
Note: this is how you send a transaction from within a smart contract in Ligo: you call the Tezos.transaction function that takes 3 parameters: the value you want to send (or unit if you just want to transfer tezzies), the amount attached to the transaction and the address of the recipient of the transaction.
The getBalance and getTotalSupply are very similar to the previous function, for the first one, you just need to get the owner’s account, for the second one, to return the total supply from the storage.
The TZIP-7 proposal only provides guidelines to follow in order to implement the standard. It isn’t by no means a complete out-of-the-box solution. For example, the standard doesn’t indicate how the tokens are going to be created or “minted”. You will have to provide your own solution for that. This will be the focus of this paragraph.
The mint entry point
Let’s add a way of creating new tokens for our smart contract. As we are creating a simple one, we will assume that only the owner of the contract is allowed to mint tokens. In a real-world example, there can be multiple actors who may be allowed to mint tokens according to preset rules.
The mint entry point will accept one parameter: the amount to be minted and will return the modified storage:
Next, we must make sure that the owner of the contract is calling the function in order to prevent anyone from minting new tokens:
After this check, we are going to update the contract owner’s account and return the new storage:
The code is pretty basic here. First, we get the owner’s account from the ledger. If there is no account, we create it and we add to it the requested tokens. If there is already an account, we update the balance with the requested value. Finally, we return the new storage with the updated owner’s account and the updated total supply of tokens.
Other possible improvements
As an exercise, you can try to add other useful entry points for a fungible token contract: for example, you can add a burn entry point to burn tokens. You could add a removeApproval entry point to revoke the token allowance of an address and removing it from the map instead of just setting it to zero. You can create a way for your users to buy the tokens you minted. You can also imagine different roles with various rights, for example, the right to mint tokens. I’d love to hear about your ideas in the comments 😊
I believe fungible tokens are, at least in part, responsible for the rise of the Ethereum blockchain as the number one blockchain with smart contracts. They open the door to a lot of different possibilities, they are very flexible, easy to set up and full of benefits. They can be money, they can be goods, they can be rewards, they can be whatever you want!
The Tezos blockchain will only be able to rise to the same status as the Ethereum blockchain and beyond when a strong standard for fungible tokens will be established. TZIP-7 is a good proposal for simple tokens and was chosen in this tutorial because it introduces beginner blockchain developers to key concepts: minting tokens, transferring tokens, approving third parties to transfer tokens on behalf of their owner and exposing view entry points. New proposals like TZIP-12 (a unified standard for fungible and non-fungible tokens) and TZIP-13 (a fungible asset standard with enhanced safety features) will help build the future of fungible tokens on the Tezos blockchain!