MiniTez: a minimalistic token on Tezos
Learn Michelson and token contract best practice by building your own token!
Everyone on Tezos speaks about tokens! The different proposals, like the TZIP7 and more recently the TZIP12, allow the creation of complex tokens on the Tezos blockchain. Reading the proposals for less tech-savvy readers can be a little overwhelming as they are full of technical terms and references to Michelson. They are also based on common features tokens developed for blockchains present, may it be for Ethereum or Tezos.
Sometimes, the best way to understand how something works is just to build it yourself! This is the goal of this article, we are going to build miniTez, a minimalistic token for the Tezos blockchain. The token does only one thing: it transfers value from one user to another (a little bit like what Bitcoin does). Transferring value is at the core of tokens use and it is a fundamental mechanism to understand. MiniTez doesn’t have more elaborate features like spending approval or minting, it does one thing and it does it well!
The token is built using the Michelson language. Basic knowledge of the language would be a plus to understand the code but I will explain what each instruction does and guide you throughout the process. The development experience of using Michelson can be a bit rough around the edges at first but it becomes really addicting!
If you want to have a look at the finished code, you can find it here deployed on Mainnet.
Let’s start 😊
Preparing the development environment
In order to write Michelson code, we are going to use one of my favourite tools out there: the Jupyter notebooks with the Michelson kernel developed by the Baking Bad team. This environment will allow us to visualize the stack at every step of the execution and make debugging it a lot easier!
The notebooks can be used to write full-fledged contracts but also to write instructions and see what happens after a basic setup. This is what we are going to use here. That way, we can see the state of the stack at any time and better understand how the different pieces fit together. Let’s set it up now:
There are 5 lines of code that each does something important before we start writing Michelson code:
DEBUG False
: this disables the various changes that will happen in the stack and can take a lot of space. You can remove it if you want to see the changes one by one.storage (big_map :ledger address nat) ;
: this sets up the storage of the contract, a big map where Tezos addresses are associated with a balance of typenat
(non-negative number).parameter (pair (address %to) (nat %tokens)) ;
: the contract only has one entry point and the accepted parameter will always be a pair with the address of the recipient and the tokens to be transferred.BEGIN (Pair “tz1VSUr8wwNhLAzempoch5d6hLRiTh8Cjcjb” 10) { Elt “tz1UVzNHTxMzkPn1uMXaSCTJYBQQc4x5dyNE” 100 } ;
: theBEGIN
instruction is particular to the notebooks, it passes the parameters (the pair) to the contract as if they were sent along with a transaction and the initial storage (the big map).PATCH SENDER “tz1UVzNHTxMzkPn1uMXaSCTJYBQQc4x5dyNE” ;
: this instruction is also particular to the notebooks and indicates to the compiler which address to use for theSENDER
instruction (the one that would normally come with the transaction).
Now, we have everything we need to write some code!
Verifying the parameters are correct before the transfer
Before exchanging any value between two accounts, we have to make sure that some conditions are fulfilled in order to ensure the safety of the transfer. In the case of miniTez, like it is also the case for a lot of tokens out there, we want to check four conditions:
- No amount must be sent to the contract: indeed, any fund sent to the contract will be locked and lost forever, so you want to abort any transaction that comes with XTZ.
- Self-transfer must be forbidden: although mostly harmless, self-transfers are redundant and may open your contract to hacks you haven’t thought of. It is just better to disable them.
- The sender must be registered in the token ledger: for the sake of minimalism, miniTez is a particular case where only users who have their address registered in the ledger can send tokens and the only way to have a registered address is to receive tokens from someone who is registered.
- The sender must have enough tokens to send the requested number. A request for a number of tokens that exceeds the current balance must lead the contract to fail and prevent any further changes.
These four conditions must be implemented as Michelson code before allowing any transfer. This is how checking for an amount of tokens would look like:
This is fairly easy! AMOUNT
is an instruction that pushes the amount sent with the transaction onto the stack. We then push mutez 0
on top of it and use the IFCMPNEQ
(IFCoMPareNotEQual) macro to check if the amount is not equal to 0
. If it is the case, the code inside the first pair of curly brackets will run. The code pushes an error code to the stack and uses FAILWITH
to stop the execution. If the amount is equal to 0
, the code in the second pair of curly brackets will run. The absence of code simply means that the execution continues with whatever code follows the condition.
Now, we can check if our clever users are not trying to send tokens to themselves!
The UNPPAIIR
instruction is a macro that we use to unwrap nested pairs. At this point, we have a pair on top of the stack that holds another pair on the left (the parameters) and a big map on the right (the storage). The UNPPAIIR
instruction tells Michelson to unwrap the root pair and the pair on the left. We are then left with a stack that looks like that:
.-----.--------------.-------------------------------------------.
| Pos | Value | Type |
:-----+--------------+-------------------------------------------:
| 0 | "tz1...jcjb" | address %to |
:-----+--------------+-------------------------------------------:
| 1 | 10 | nat %tokens |
:-----+--------------+-------------------------------------------:
| 2 | {Elts} | big_map :ledger address nat |
'-----'--------------'-------------------------------------------'
We want to use the address on top of the stack to check if the provided address in the parameter and the sender’s address are not the same. As we will need it later again, we have to duplicate it now with the DUP
instruction because Michelson instructions consume the values they are called on. When this is done, we can use SENDER
to push the address from which the transaction has been sent and use IFCMPEQ
(IFCoMPareEQual) to verify if the two addresses are the same. Like in the first example with AMOUNT
, the FAILWITH
instruction will be called if the two addresses are the same or the execution will continue if they are not.
Next, we have to make sure that the sender has his address registered in the ledger:
First, we are going to get the big map on top of the stack to work with it. As you could see from the table above, the big map is in position 2. We can use the DIG
instruction that “digs” out an element of the stack and puts it on the top. So DIG 2
gets the element in position 2 and puts it on top. As you may have guessed, we have to duplicate the big map with DUP
because we will keep using it throughout our code. SENDER
pushes again the sender’s address on the stack so we can use MEM
to verify if the address is a key of the big map. If it is, we will execute the code inside the first pair of curly braces, if it’s not, we execute the code inside the second pair of curly braces, the one that stops the execution of the contract. This is how our stack looks like now if the sender’s address is in the ledger:
.-----.-------------.-------------------------------------------.
| Pos | Value | Type |
:-----+-------------+-------------------------------------------:
| 0 | {Elts} | big_map :ledger address nat |
:-----+-------------+-------------------------------------------:
| 1 | "tz...jcjb" | address %to |
:-----+-------------+-------------------------------------------:
| 2 | 10 | nat %tokens |
'-----'-------------'-------------------------------------------'
To finish our series of verifications, we are going to verify the sender’s balance and check if the number of tokens requested for the transfer is equal or less than the current balance:
Because we are going to use the big map again, we have to duplicate it with DUP
in order to keep a copy for later. Once again, we push the SENDER
’s address to the stack and use the GET
instruction to retrieve the value associated with the key on top of the stack. The value is going to be pushed onto the stack as an optional
value: if the key exists in the big map, the value will be (Some value)
, otherwise, (None)
will be returned. We know from the last step that the key exists in the big map, so no surprise here! We can use IF_NONE
to check if the value is (None)
, which is highly improbable, but better safe than sorry, we will make the contract fail. We have now the value associated with the sender’s address on top of the stack, i.e his balance:
.-----.-------------.-------------------------------------------.
| Pos | Value | Type |
:-----+-------------+-------------------------------------------:
| 0 | 100 | nat %balance |
:-----+-------------+-------------------------------------------:
| 1 | {Elts} | big_map :ledger address nat |
:-----+-------------+-------------------------------------------:
| 2 | "tz...jcjb" | address %to |
:-----+-------------+-------------------------------------------:
| 3 | 10 | nat %tokens |
'-----'-------------'-------------------------------------------'
The following code between curly braces is a great example of what you will spend a lot of time doing when coding in Michelson: putting all the pieces you need in the right positions 😅 Let’s check what it does:
DUP
duplicates the balance.DIP 4 { DUP }
finds the fourth element in the stack and duplicates it.DIG 4
digs out the element in position 4 (the copy of the token value) and bring it to the top of the stack.IFCMPGT
checks if the first element (the value) is greater than the second element (the balance). If it is, the contract fails.
And after all these operations, we get back the same stack state as before!
Updating the sender’s balance
There are two schools of thought for the order of the next two steps: people coming from Ethereum like myself may choose to update the sender’s balance before the recipient’s balance (as it makes sense in Solidity), others prefer updating the recipient’s balance first. I think it is always good to subtract the tokens first before adding them somewhere, as you would do with physical tokens, this is why we will do that first:
Check the diagram at the end of the previous paragraph if you need to refresh your memory about the state of the stack at this point.
Now, we want to calculate the sender’s new balance after we deducted the tokens he sent to the recipient. We have all the elements we need in the stack, but unfortunately, not in the right order 😬 We can start by duplicating the number of tokens at the bottom of the stack with DIP 3 { DUP }
before bringing the duplicated value on top of the stack with DIG 3
. Because we want to subtract the number of tokens from the balance, we have to put the two values in the right order with SWAP
. Next, we can use SUB
to get the result. The SUB
instruction in Michelson always returns a value of type int
, even if you subtracted nat
values, so we have to use ABS
to get a nat
. Let’s check the state of the stack at this point:
.-----.-------------.-------------------------------------------.
| Pos | Value | Type |
:-----+-------------+-------------------------------------------:
| 0 | 90 | nat |
:-----+-------------+-------------------------------------------:
| 1 | {Elts} | big_map :ledger address nat |
:-----+-------------+-------------------------------------------:
| 2 | "tz...jcjb" | address %to |
:-----+-------------+-------------------------------------------:
| 3 | 10 | nat %tokens |
'-----'-------------'-------------------------------------------'
There are two problems we need to resolve before adding this new balance to the big map: first, only a value of type option
can be used to update a value in a big map, so we have to wrap our nat
into an optional. Second, the right order of elements to update a big map is KEY + VALUE + BIGMAP, so we need the sender’s address again.
SOME
is an instruction that turns a value into an optional. After using it, our nat 90
will become (option nat) (Some 90)
. Then, we can simply use SENDER
again to push the sender’s address onto the stack and voilà! Everything is ready for the UPDATE
instruction. After that, we get the updated big map on top of the stack:
.-----.-------------.-------------------------------------------.
| Pos | Value | Type |
:-----+-------------+-------------------------------------------:
| 0 | {New Elts} | big_map :ledger address nat |
:-----+-------------+-------------------------------------------:
| 1 | "tz...jcjb" | address %to |
:-----+-------------+-------------------------------------------:
| 2 | 10 | nat %tokens |
'-----'-------------'-------------------------------------------'
Updating the recipient’s balance
This step will be a little more complex than the previous one because we may face two different situations we must deal with: In the first scenario, the recipient already has an account and a balance and we have to add the tokens to the existing balance. In the second scenario, the recipient doesn’t exist in the ledger and we have to create a new key/value pair with the balance.
Here is the code to create or update the recipient’s balance:
As you can tell from the number of DIP
, DUP
and SWAP
, there will be a lot of moving pieces 😅
First, we duplicate both the big map and the recipient’s address and put them in the right order, that’s the job of DIP { DUP } ; SWAP ; DIP { DUP } ;
Now, our stack looks like that:
.-----.-------------.-------------------------------------------.
| Pos | Value | Type |
:-----+-------------+-------------------------------------------:
| 0 | "tz...jcjb" | address %to |
:-----+-------------+-------------------------------------------:
| 1 | {New Elts} | big_map :ledger address nat |
:-----+-------------+-------------------------------------------:
| 2 | {New Elts} | big_map :ledger address nat |
:-----+-------------+-------------------------------------------:
| 3 | "tz...jcjb" | address %to |
:-----+-------------+-------------------------------------------:
| 4 | 10 | nat %tokens |
'-----'-------------'-------------------------------------------'
We can call MEM
to check if the recipient’s address is a key of the big map. Let’s go down to the bottom of the code and check what we do if the recipient is not a key (which is the more simple case). DUG 2
will bring the big map in position 2 while DIP { SOME }
will ignore the first element of the stack (the address) and wrap the second one (the tokens) in an optional value. Now, our stack looks like that:
.-----.-------------.-------------------------------------------.
| Pos | Value | Type |
:-----+-------------+-------------------------------------------:
| 0 | "tz...jcjb" | address %to |
:-----+-------------+-------------------------------------------:
| 1 | Some 10 | option (nat %tokens) |
:-----+-------------+-------------------------------------------:
| 2 | {New Elts} | big_map :ledger address nat |
'-----'-------------'-------------------------------------------'
As you can see, the elements are in the right order to insert the new value in the big map by calling UPDATE
. This instruction will create a new binding in the big map whose key is the recipient’s address and whose value is the number of tokens.
Now, let’s check what happens if the recipient already exists in the big map! We start by duplicating and moving some values that we will use later with the following instructions: SWAP ; DIP { DUP } ; DUP ; DIP { SWAP } ;
After that, this is how the stack looks like:
.-----.-------------.-------------------------------------------.
| Pos | Value | Type |
:-----+-------------+-------------------------------------------:
| 0 | "tz...jcjb" | address %to |
:-----+-------------+-------------------------------------------:
| 1 | {New Elts} | big_map :ledger address nat |
:-----+-------------+-------------------------------------------:
| 2 | "tz...jcjb" | address %to |
:-----+-------------+-------------------------------------------:
| 3 | {New Elts} | big_map :ledger address nat |
:-----+-------------+-------------------------------------------:
| 4 | 10 | nat %tokens |
'-----'-------------'-------------------------------------------'
At this point, we can use the GET
instruction to fetch the recipient’s balance because we have the key and the big map in the right order on top of the stack. GET
is an instruction that returns a value of type optional
, so we can expect (Some nat)
to be pushed on top of the stack. In order to get the argument value of the optional value, we have to use IF_NONE
or IF_SOME
.We can use IF_NONE
here and in the very unlikely event of the returned value being None
, we make the contract fail (even if the balance is 0
, the returned value will be (Some 0)
. Otherwise, the existing balance becomes available on top of the stack:
.-----.-------------.-------------------------------------------.
| Pos | Value | Type |
:-----+-------------+-------------------------------------------:
| 0 | 15 | nat %balance |
:-----+-------------+-------------------------------------------:
| 1 | "tz...jcjb" | address %to |
:-----+-------------+-------------------------------------------:
| 2 | {New Elts} | big_map :ledger address nat |
:-----+-------------+-------------------------------------------:
| 3 | 10 | nat %tokens |
'-----'-------------'-------------------------------------------'
The only thing that we have to do now is to add the transferred tokens to the current balance. For that purpose, we have to get the tokens value from the bottom of the stack to the top with DIG 3
. Now, the two nat
values are on top of each other and we can call ADD
to get their sum. Before saving the new value in the big map, we have to wrap it in an optional
value with SOME
and once it’s done, we can use UPDATE
to push it back in the big map.
Now, the updated big map is left alone on top of the stack, which is perfect to end the execution of the current contract. After the IF
that checked if the recipient is already registered as a key in the big map, we write the usual instructions NIL operation ; PAIR ;
to return a pair with an empty list of operations on the left and the new big map on the right!
And that’s it, you’ve just created a Bitcoin-like minimalistic token on the Tezos blockchain 🥳
The number of tokens will be limited to the balance you will give to the accounts created when the contract is originated (no new token can ever be minted) and the code only handles the transfer of tokens and the creation of new accounts when tokens are sent to an address that doesn’t exist in the ledger.
Conclusion
This little exercise was a great opportunity to learn more about Michelson smart contracts, their structure, their instructions but also the logic of moving the elements of the stack to put them in the required order before continuing with the execution. A good part of your time writing Michelson will be spent typing instructions like DUP
, SWAP
, DIG
and DUG
to get the elements where you want them.
Remember also that most instructions consume the elements that they use in the stack: GET
will return the value of a key in a big map but it will remove the key and the big map from the stack at the same time!
Going through some code written in Michelson demonstrates the robustness and the safety of the language the Tezos blockchain uses for its smart contracts: there are no unexpected values, no surprise edge cases, no double meaning. A smart contract in Michelson can only work in a determined specific way and any attempt to tamper with it will make the contract fail and keep your precious XTZ safe 😊
Buy miniTez!
Whether you think miniTez is going to be a collector item in the future or you want to help the creation of more educational resources for Tezos development, you can get some miniTez yourself!
If you want to buy miniTez tokens, you can visit the miniTez store and buy tokens! After that, you can keep them or send them to your loved ones through the store interface.