Take your Michelson skills to the next level with a more complex project
In the last tutorial about Michelson programming language (the miniTez token), we created a simple token that was just transferring an amount of tokens from one account to another with minimal verifications.
The need for standardizing token interactions on Tezos has driven the community to create token standards that indicate how the tokens behave and how they can be interacted with. This has been the goal of the TZIP-7 proposal. After reading the miniTez tutorial, it should be very easy for you to understand what the TZIP-7 proposal adds to basic token transfer. We will explain the different points it makes and their motivation.
After understanding the requirements for an implementation of the TZIP-7 standard will come the time of getting our hands on the keyboard to write some Michelson. Although the code will be more complex, it is still based on common instructions and nothing dramatically difficult. You can check the code in the Github repository to give you an idea of what we are going to write. The contract we build will strictly follow the TZIP-7 proposal, so there are no entrypoints for minting tokens or other functionalities. However, the standard can be extended and you are free to add them yourself!
For this tutorial, we will use the notebooks with the Michelson kernel developed by Baking Bad as it will give us the flexibility we need to test our code while writing it. The tutorial will be divided into multiple parts: this first part is an introduction to the TZIP-7 standard and presents the values used for the parameter and storage of our smart contract. The different entrypoints of the contract require their own articles, which will be the subject of the following parts of this tutorial.
The TZIP-7 proposal
I previously wrote an article about the TZIP-7 standard so I won’t get into the details of the proposal here, but rather into the details of its implementation in Michelson.
The TZIP-7 proposal requires that the tokens implementing it have the following interface:
(address :from, (address :to, nat :value)) %transfer
(address :spender, nat :value) %approve
(view (address :owner, address :spender) nat) %getAllowance
(view (address :owner) nat) %getBalance
(view unit nat) %getTotalSupply
This interface only introduces the entrypoints of the contract but implies many things:
- (address :from, (address :to, nat :value)) %transfer: the transfer entrypoint must receive a pair containing an address on the left representing the account where the tokens will be taken from and a pair on the right containing an address on the left representing the recipient of the tokens and a nat on the right representing the amount of tokens to be transferred. This doesn’t imply that the address requesting the transfer must be the same as the :from address, so transfers can also be initiated on behalf of another address.
- (address :spender, nat :value) %approve: the approve entrypoint must receive a pair as well, with the address of the account to be approved on the left and the value granted to this account on the right. This implies that the address setting the approval has to be the owner of the account.
- (view (address :owner, address :spender) nat) %getAllowance: the getAllowance entrypoint is marked as view, which means that the entrypoint will be used by other contracts and will return a transaction containing the requested allowance. The entrypoint accepts a pair containing on the left another pair with the owner’s address on the left and the spender’s address on the right and on the right of the root pair, the set allowance as a nat number.
- (view (address :owner) nat) %getBalance: the getBalance entrypoint is also a view entrypoint that will return a transaction. It expects a pair with an address on the left and a nat value on the right.
- (view unit nat) %getTotalSupply: The getTotalSupply entrypoint is a view entrypoint that takes a unit value and returns a value of type nat.
Although being straightforward, the standard implies a few things:
- There must be a totalSupply value in the storage. The TZIP-7 proposal doesn’t enforce any type of storage but it implies that a contract implementing it must keep track of the total supply of tokens (otherwise the getTotalSupply entrypoint doesn’t have anything to return).
- The view entrypoints take a value of type contract nat instead of simply nat. Because the entrypoint has to return a transaction to the contract that requested the value, the nat value will actually be a contract nat value, a reference to a contract (and possibly its entrypoint) expecting a value of type nat.
- The different values passed as parameters must be annotated. This is particularly important for the transfer entrypoint where we get two addresses, one that will lose tokens and one that will gain tokens.
The Michelson code
For this contract, the parameter is going to be a little more complex than what we are used to. Let’s have a look at it first:
This contract introduces a new type of value we haven’t encountered before: the union type. You can imagine a value of type union as a pair where only one side can contain a value at all time. Although this looks like a waste of space, this is actually very useful for conditional branching as you can change the behaviour of the contract according to the side that holds a value.
Imagine a value of type union that looks like this: (or int string). This tells us that if there is a value on the left side, it will be of type int and if there is one on the right side, it will be of type string. Now, if you want to set the value, you just have to indicate the side you want to fill and the value it should hold, for example, (Left 6) or (Right "hello"). When your contract will encounter this value, you can change its behaviour if an int is present on the left or a string on the right.
This pattern is the one used in Michelson to simulate entrypoints. Technically speaking, a Michelson contract only has a single entrypoint but it is possible to simulate more with values of type union. In this contract, we create the transfer and the approve entrypoints as two sides of the same union value. The annotations, in addition to being required by the standard, are also essential to third-party applications like Taquito that depend on them to display in a friendlier way the available entrypoints.
Values of type union can also be nested, which will be very valuable to create more than 2 entrypoints. As a design choice, I decided to put the entrypoints that modify the storage on the left side of the main union value and the view entrypoints on the right side.
In addition to the union type, you can see that the values are annotated, which means that they are given a name through a special notation. Strings that start with : or @ are annotations. Michelson has different types of annotations according to the function of the value you want to annotate, whose explanation would require its own tutorial. Just observe the annotations in this example and try it out in your own code.
The FA1.2 contract doesn’t require a complex storage, as it only needs to track users’ balances and approvals, as well as the total supply.
Once again, you can see that the values are annotated, which makes it easier to read not only for you but also for external tools like Taquito.
The storage is made of a pair with a big_map on the left and a nat value representing the total supply on the right. The big map will keep track of the users’ accounts, both of their balances and of the allowances they set. This kind of big map is generally called ledger so we will go with this name too.
The notebook setup
We must prepare a basic setup in order to use the notebooks. First, we want to run the contract as we write, so we use the BEGIN instruction after the parameter and storage declarations to achieve that. The BEGIN instruction takes three arguments: the entrypoint we target, a value for the parameter and a value for the storage. Next, we are going to test functionalities that prevent people who are not the account’s owner to do certain actions, so we will use the SENDER instruction in Michelson. As you can imagine, there is no sender if there is no transaction, so we have to simulate it in the notebooks. You can simply add PATCH SENDER "tz1..." ; and the address of your choice to do it.
As the contract grows, you may prefer hiding all the information printed out about the stack manipulations. You can achieve it with DEBUG False ; right at the beginning.
Next step: the entrypoints code in Part 2!
Implementing a FA1.2 token in pure Michelson (Part 1) was originally published in Coinmonks on Medium, where people are continuing the conversation by highlighting and responding to this story.