Getting started with Ligo (Part 3)

0
14

Create smart contracts for the Tezos blockchain using Ligo

(This is part 3 of a series of tutorials about Ligolang, a smart contract language for Tezos. You can find part 1 here and part 2 here. The article is also available on my IPFS-hosted blog here)

Now is time to start coding “serious” smart contracts! Incrementing or decrementing an integer is fine, but what about a real-world smart contract that can handle users’ transactions? This will be the subject of this tutorial.

When I first started learning Solidity for Ethereum development 2 years ago, I was looking for something simple to code but with enough depth to see how well I understood the concepts of the lectures. May it is with Ethereum or Tezos, I am highly interested in the applications that blockchain can have in the daily life of common people and the way it can revolutionize our daily life. I wish I could just go to a café and pay with ethers or tezzies to buy my favorite beverage. This is how I decided to try to create a smart contract that will take care of this kind of transaction.

We will create together a smart contract that allows customers to pay for their coffee while getting loyalty points that they can redeem later for a free beverage. The smart contract will also allow the owner to withdraw the income at any time they see fit. This will be excellent to introduce key concepts of Ligo, but also of smart contract development in general. The complete smart contract is available at this address if you want to check the final result. Like in the previous tutorials, you will only need the online IDE provided by the Ligo team to follow along. I will use screenshots for the code as the syntax highlighting make it easier to understand the different pieces, however, you can always refer to the complete code in the link above.

It is also worth noting that although the smart contract works as intended, it was created to learn key features of the Ligo language and may include or exclude parts that would make it safer to use in a real-world application.

Let’s start!

Creating the structure of the smart contract

The first step of every smart contract is to think about what we want to achieve. In this case, we want customers to be able to pay for their coffee with tezzies while accumulating points, to redeem these points to get a free beverage and we want the owner of the contract to be able to withdraw the balance of the contract. These are 3 different actions, so they will require 3 different entry points:

type action =
| RedeemPoints
| Buy (string)
| Withdraw

The names of the entry points are rather self-explanatory, RedeemPoints will allow customers to redeem their points, Withdraw allows the owner to withdraw the balance and Buy allows customers to buy a coffee. Note that the Buy entry point requires a string which is the name of the selected beverage.

Now let’s create the storage. We want to save 4 different kinds of things in the smart contract: a menu that we can use to compare the choice of the customer with the amount they send and verify they sent enough tezzies, a list of customers and their points to verify if they have enough of them to redeem them, a total amount that will keep track of all the amounts ever sent to the smart contract and the address of the owner of the smart contract. This will look like this:

type action =
| RedeemPoints
| Buy (string)
| Withdraw
type storage = {
menu: map (string, tez),
customers: map(address, nat),
total: tez,
owner: address
}

Like in the previous tutorial, the storage is a record that you declare simply by opening a set of curly brackets and adding the name of the key, a colon and the expected type of the field.

Let’s check the different fields we have:

  1. menu: the menu will hold a list of coffees and their associated price, we will use a type called “map” to keep them in the storage. As per the documentation: “Maps are a data structure which associate keys of the same type to values of the same type.” Imagine a table with only two columns. In the left column, the coffees we sell. In the right column, the price of the coffee. When declaring a map, you must also tell the compiler what types of values you are going to put there. For the coffee names, we will use a string (i.e a sequence of characters like a word or a sentence), the price of the coffee will be in tez (the native cryptocurrency of the Tezos blockchain), so in this case, we will write map (string, tez).
  2. customers: the list of customers with their points will also be a map. You have here another example of how to declare a map with the types for its keys and values. Remember, you cannot use any other type as a key or a value once the map is declared. The compiler will throw an error if you try, for example, to put a string as a key while an address is expected. You are introduced here to two new types: the address type (in Ligo, it denotes Tezos addresses like tz1, tz2, tz3, KT1, etc.) and the nat type (“natural number” or a positive-only integer).
  3. total: this field will be a counter that will keep track of how much was sent to the smart contract since its creation (for tax purposes maybe 😅)
  4. owner: this field holds the address of the originator of the contract, i.e the owner. We will use it to verify that no one else except the owner can withdraw the balance of the contract.

Now we can continue and write the main entry point of the smart contract. This step will look pretty familiar as it looks similar to the function we wrote in the previous tutorial:

type action =
| RedeemPoints
| Buy (string)
| Withdraw
type storage = {
menu: map (string, tez),
customers: map(address, nat),
total: tez,
owner: address
}
type return = (list (operation), storage);
let main = ((parameter, storage): (action, storage)) : return =>
switch(parameter) {
| RedeemPoints => redeemPoints (storage)
| Buy (item) => buy ((item, storage))
| Withdraw => withdraw (storage)
}

However, you may have noticed something that is new here: we do not return explicitly ([]: list(operation), storage) at the end of the switch. Instead, we create a new type to represent the value that is always returned from the main function: a tuple with a list of operations and the (updated) storage. By doing so, it is important to remember that each function associated with each pseudo entry point MUST return the same type of value. We will tell the compiler to expect this type by adding it just before the arrow after the parameters of the function. Declaring a new type will become more useful and will make our code more readable later.

The switch is made up of our three entry points declared in the type action. Each entry point is associated with a function that we will write in a minute. RedeemPoints and Withdraw are pretty basic, while Buy receives the name of the selected coffee under the variable named “item” that is then passed to the buy function.

You have probably noticed already that the main function of the contract is generally rather short and simple, its only goal is to dispatch the incoming transaction to the functions that will actually do the work!

Buying a coffee

General overview of the function

Now let’s continue and take care of our first entry point: the buy function.

This is going to be simple: the customers send the name of the beverage they want along with the right amount of tezzies, we will check that the price matches before incrementing the total amount of transaction and adding 1 point to the customer’s account.

A few interesting things are happening there, let’s break it down:

  1. let buy = ((item, storage): (string, storage)): return -> we declare a function called buy accepting two arguments: an item of type string (the beverage name) and the storage (remember, the storage must be passed along to be updated). The returned value is of type return (as explained earlier).
  2. switch -> one of the most amazing features of Ligo is undoubtedly pattern matching. Pattern matching allows us to check if a value matches one of the several options of a variant and act accordingly. The power of it is that the compiler will warn you if you forgot to take into account one of the possible options! The switch expression will come handy here when we search for a value in a map.
  3. Map.find_opt(item, storage.menu) -> now we want to know if our coffee lover ordered a beverage that we actually offer in our menu! In order to find a value in a map when we have the key, we use Map.find_opt. This function takes 2 parameters: the key we are looking for and the map. It returns a special kind of variant called an “option”, basically, the option variant only has 2 values: Some (together with a value of any type) or None. It makes sense here, when looking for a value in a map, we will either find it or not. Map.find_opt will return the value associated with the key if found (Some (value)) or nothing (None). We will use the returned value in the following part of the switch.
  4. | Some (price) => -> if the value associated with the name of the beverage has been found (i.e its price), this option will be chosen and the price of the beverage will be passed on the right side of the arrow.
  5. if (Tezos.amount < price){} else {} -> now that we found the price of the beverage selected by our customer, we must check whether they paid the right price for it or not! Every time tezzies are sent to a smart contract, the amount sent is available in a global variable called “Tezos.amount” of the type tez. We use it here to compare it with the price we just found in the menu. Please note that you must compare values that are comparable! In our example, we compare Tezos.amount of type tez with price of type tez, so we’re good 😅
  6. failwith("You didn't send enough tez!"): return; -> on this line, you can see another very useful feature. When something happens in your smart contract that is not supposed to happen, you want to stop everything. This is what failwith does. If our customer sent an amount of tez that is not equal or superior to the expected price (maybe he sent a tip too!), it will cause the contract to fail with an error message. Note that we give the type return to the failwith because this is what the switch from the main function is exepected to return.
  7. | None => failwith("No such item found!"): return; -> another case where you want your smart contract to just stop everything! If you cannot find the beverage that the customer ordered on the menu, there is no reason to continue anything. You can throw an error with failwith and end the process.

The “addPoint” function

After we checked that the selected beverage exists and that the price sent to the smart contract is correct, we don’t want to forget to thank your customers for their loyalty by adding one point to their account. This is how the function to achieve it will look like:

We will go through the function step by step:

  1. let addPoint = (storage: storage): map (address, nat) -> you must be more comfortable with the way of declaring functions now! Our addPoint function takes one parameter of type storage and returns a map whose keys are addresses and values are natural numbers.
  2. let customer_points: option(nat) = Map.find_opt(Tezos.sender, storage.customers); -> this time, we are not going to bloat our switch and will store our option type in the customer_points variable. You could have also put the Map.find_opt inside the parentheses of the switch as we did earlier, but this way makes it a little clearer. You see now Tezos.sender, another global variable that always holds the address that initiated the transaction.
  3. Map.update / Map.add -> this is a great opportunity to see two useful tools to work with maps in Ligo. Map.update allows us to modify a value that’s already in the map, it takes 3 parameters: the key of the value you want to change, the new value and the map to modify. Note that the value you want to modify must be of type option (Some/None). Indeed, you can pass None as the new value if you want to remove the binding in the map. Map.add allows us to add a new key/value pair (also called a binding) to the map, it takes also 3 parameters: the new key you want to create, the value you associate with it and the map to modify.
    If the customer already has points, we will just add 1 point to the total Some (points + 1n) and update the map. If the customer was not found in the map, we will add their address and give them 1 point 1n. Points are natural numbers because we are not going to leave our clients with a negative balance of points 😬

Redeeming customers’ points

One of the best feelings in the world, when you are a programmer, is getting free coffee, am I right ?! Now let’s give our loyal customers the ability to redeem their points for a free beverage:

As usual, let’s break down the function to see how every part does its magic, a lot of which should look familiar to you by now:

  1. let redeemPoints = (storage: storage): return -> nothing groundbreaking here, we receive the storage and return a value of type return.
  2. Another switch that takes the value returned by Map.find_opt, i.e an option with Some if the customer’s address (Tezos.sender) was found or None if it wasn’t. If the customer wasn’t found in the map, we throw an error with failwith.
  3. if(points >= 10n) -> some of our smartest customers could try to call the redeemPoints function to get a free beverage although they don’t have enough points! So, we have to check if they have enough points first. The point counter is stored as a natural number nat so we compare the value returned by the Map.find_opt with the number of points necessary for a free coffee (here, 10 points). If the amount of points is inferior to 10, we throw an error.
  4. let updated_customers = Map.update(Tezos.sender, Some (abs(points — 10n), storage.customers) -> the “updated_customers” variable will hold our modified map with the new number of points for our loyal customer. Map.update takes 3 parameters as usual: the key of the value we want to update (Tezos.sender, the initiator of the transaction), an option with the new value and the map to update. The new value is a little bit convoluted for a simple reason: in Ligo, when subtracting two nats, you get an int. In order to get a nat, we will wrap our subtraction in the abs function which will return the absolute value of our subtraction (i.e a nat).
  5. ([]: list(operation), {...storage, customers: updated_customers}) -> after updating the map, we can return the list of operations and the new storage. We will use the spread operator to update the customers field of the storage record.

That’s it for the redeemPoints function! You have now a better understanding of storage manipulations and security checks you can implement to verify the parameters you receive with the transaction or the initiator of the transaction are what you expect.

After making so many tezzies with our happy customers, let’s withdraw our revenue from the smart contract.

Withdrawing the balance of the contract

This kind of operation can be a little sensitive in a smart contract. Indeed, you want an easy way for you to withdraw your revenue, but you don’t want anyone else to have access to it and steal your money!

We are going to use a method that was explained a little earlier. At the creation of the contract, you will save your address in the owner field of the storage record. This way, you can use it to check whether the address asking to withdraw the tezzies is the one that created the contract (i.e yours). This is how it looks like:

Except for the very last step, there is nothing new in this function. We pass the storage as a parameter because we need the owner address inside. if (Tezos.sender != storage.owner) verifies that the request to withdraw the balance comes from the owner of the contract. If not, it will throw an error with failwith. If the request comes from the owner, we will proceed in three steps:

  1. We must change the address that we saved in the storage into an option(contract(unit)) type to send tez to it. The documentation is quite unclear about this subject, but according to a couple of examples I found, the next function expects an option holding a contract type as the receiver of the transaction. You can use Tezos.get_contract_opt() with the appropriate address to get a variable of type contract wrapped in an option. This pattern has been chosen to make sure that get_contract_opt returns the value we want. We then use a switch to match the value or throw an error if the value is None.
  2. After getting the usable address, it’s time to send the tezzies! This is done via the Tezos.transaction function that accepts 3 parameters: a unit, the amount you want to transfer and the receiver of the transfer. This function returns an operation that we will include in the returned value.
  3. ([op], storage) -> after returning so many empty lists of operation in our tutorials, we will finally include something in it! You just need to put the newly created operation in the list, it will be returned from the function and executed at the end of the main function. Now sit back and relax, your tezzies are on their way 🚀

Recap

This tutorial was a great opportunity to learn a few important features of Ligo that will be the base of the creation of your contracts. The variable manipulations, the functions and the security checks are the foundations of safe and efficient contracts on the Tezos blockchain.

Here is a recap of a few things that you learned today:

  • Storage update: the spread operator of ReasonLigo allows us to update easily and clearly the storage -> {...s, counter: s.counter + 1}
  • Maps manipulations: maps are a structure that you will use very often to store data in a smart contract. It is essential to understand how they work and how to get the best out of them. Maps are designed to hold a certain number of key/value pairs called bindings of the same types and are declared as map (key type/value type). You can add a new binding to a map using Map.add() or update an existing binding with Map.update. You can also remove a binding using Map.remove. These functions all return the updated map.
  • The option type: the option type is very useful to verify you got the value you were expecting to get! It is a special kind of variant that takes only two options: |Some (value) | None. This pattern helps us avoid unexpected values that could break the smart contract.
  • The global variables: a lot of information regarding the current transaction can be crucial to know and access in a smart contract, for example, who sent the transaction, how many tez were sent and the current balance of the contract. Many can be accessed as properties of the Tezos global variable, you can learn more about them here, but we will take on them in a future tutorial too.
  • Conditions and errors: Ligo allows a very simple conditional logic using if () { "this" } else { "that" } where you can act according to the value received. This is very useful in conjunction with failwith to throw errors and stop the process if the value received doesn’t match a certain condition.

Conclusion

You have now a better knowledge of how to write smart contracts with Ligo for the Tezos blockchain. The information in this tutorial is enough to write basic contracts.

If you want to know more, you can check the various links to the official documentation throughout the text. I would suggest you copy-paste the code of this smart contract in the online IDE (or access it here directly) and play with it. Modify some variables, add new functions (for example adding a new item to the menu) and check if your code compiles correctly.

As indicated in the introduction, this smart contract was created with teaching core features of Ligo in mind. Important steps for a production-ready contract may be missing and others should be changed. For example, if outside users can add themselves new bindings to a map, you should use a Big Map and not a map for the following reason (quoted from a discussion I had on Ligo Telegram chat on the subject):

(…) the worry about `map` is that someone can “buy” from many addresses, making the storage so big that no further operations (in particular withdraw!) can fit into the gas limit

For the next tutorials, I plan on discussing different types (like big map or lists) in detail and writing walkthroughs of published contracts written in Ligo.

If you liked this tutorial, consider sending some tezzies to tz1SjrNeUE4zyPGSZpogDZd5tvryixNDsD2v and I promise 100% of them will be staked and NOT exchanged for Bitcoin 😅

Stay tuned!

Read Other Articles Related to Tezos

Getting started with Ligo (Part 3) was originally published in Coinmonks on Medium, where people are continuing the conversation by highlighting and responding to this story.