Oracles on Tezos with Ligo: a simple use case

0
155

Connect to the Harbinger oracle with Ligo to get exchange rates on-chain

Image by Christian Hardi from Pixabay

It is incredible to see how far the Tezos blockchain has gone in just 6 months: at the beginning of 2020, TezBridge was the only wallet around and the possibilities of dapps on Tezos were very limited. Now, in the fall of 2020, we have Thanos, a better and more elegant wallet to interact with dapps and we have oracles! Oracles are a game-changer as they allow smart contracts to have access to live data. They add a layer of security to the contracts as they allow developers to depend less on data provided by the users and more on a reliable source of information.

However, getting information from an oracle on the Tezos blockchain may not seem as straightforward as it may be on other blockchains, for example, Ethereum. The added complexity is the result of an additional layer of security in the Tezos blockchain: Michelson forbids any external intervention during the execution of the contract. In a blockchain like Ethereum, when a transaction hits an entrypoint that needs data from an oracle, the execution of the contract is briefly paused while waiting for the data to come back from the oracle. This may allow reentrancy attacks, for example. Reentrancy attacks are virtually impossible on Tezos but this means you need a workaround to use oracles. This is the subject of this article.

This tutorial will show you how to implement an oracle call in a contract written in Ligo and how to receive the data back from the oracle. We are going to use the Coinbase Pro normalizer contract as a source of data and the CameLigo syntax of LigoLang. We will cover 2 entrypoints of this contract that I used to create the Hodlers Lifesaver dapp. The dapp allows users to lock their tez in and will only let them withdraw their funds if the current exchange rate between XTZ and USD is higher than at the time they locked their tez.

Setting up the contract

Getting data from an oracle on Tezos always implies 2 entrypoints: one to send the request for data and one to receive the data. The entrypoint of the oracle to target will always accept at least a value of type contract type_of_returned_value because the oracle needs to be told where to send the data back. Let’s start by setting up the contract.

For our present use case, we need an entrypoint that will accept the tez sent by our users and record in the contract the amount sent and the exchange rate at the time the tez were sent. We will implement a simple ledger with a big map where the keys will be the users’ addresses and the values will be a record of the exchange rate and the amount they locked in the contract:

As mentioned above, the storage will contain a ledger to record the transactions, but also the oracle address and the admin address (in case we need to change the oracle address).

We will also create extra types for the parameter expected by the oracle:

The Harbinger contracts expect a string representing the currency pair and a (string (timestamp * nat)) contract to return the currency pair, the timestamp of the last update and the current exchange rate for the pair. The returned type looks like that in Michelson: (pair string (pair timestamp nat)). It is called a right-combed pair, which means that it is a pair with nested pairs that all are on the right side of the parent pair. The opposite would be a left-combed pair with its nested pair on the left side like that: (pair (pair timestamp nat) string)). It is of the utmost importance to respect the structure of the pairs in Michelson: an entrypoint that receives a left-combed pair while expecting a right-combed pair will reject it, even if the inner types are correct.

This brings us to the setup of our types for the value that will be returned from the oracle: first, we create a record as it will be more readable and easier to manipulate for us. We then use a converter called michelson_pair_right_comb to turn our Ligo record into a Michelson right-combed pair. Last, we create the tuple that will be sent to the oracle with the string and the right-combed pair.

Now, we want to write our entrypoints. For this tutorial, we will only write 2 of the 5 entrypoints the example contract contains. The first one will send the request to the oracle, the second one will receive the value returned by the oracle:

The Hodl entrypoint doesn’t take any parameter (which translates into a unit in Ligo), it will get all the values it needs from Tezos.amount and Tezos.sender. The Hodl_callback will receive the data from the oracle, which will be of type returned_val_michelson. We can use the entrypoint type to write the main entrypoint:

The Hodl entrypoint will point to a function that has to return a list of transactions with one transaction in it (the call to the oracle) and the storage. The Hodl_callback will point to a function that doesn’t return any transaction, so we can let the function return the new storage alone.

The setup is now done, we can have a look at the different entrypoints.

Setting up the request to the oracle

The hodl function doesn’t need any value as a parameter, so we will use a parameter of type unit. First, let’s write the structure of the function, its name, its parameters, its types and the values it will return:

As the declaration suggests, the function will return a list of operations and the storage. Our list of operations will contain the transaction to send to the oracle and the storage will be updated as we will keep track of the user’s request.

As the whole point of the contract is for our users to lock their tez, we will first make sure there are tez attached to the transaction:

if Tezos.amount = 0tez verifies the amount sent to the contract, if it is equal to zero, the contract will fail. If not, the execution continues.

Note: when using failwith in Ligo, you must indicate the type that would be expected if the entrypoint was to return at this moment.

Now, we want to check if the user sending the transaction doesn’t have already some tez locked. As it is a simple contract, we won’t allow users to add more tez, they can either deposit or withdraw tez:

In this step, we look for the Tezos.source address in the ledger, if we find it, we make the contract fail and if we don’t, we will add a new entry to the ledger. The deposit matches the Tezos.amount sent to the contract and we initialize the price to 0n as this value will be updated later with the value from the oracle. At this point, the s variable (containing the storage) has been modified with the value that will be our new storage.

Next, we prepare the request for the oracle. Let’s see how it looks like:

This may look a little daunting the first times you use it but it becomes quickly easier when you understand what each piece does:

  • let call_to_oracle: oracle_param contract: this variable will receive the reference to the oracle entrypoint we will use to build the transaction later. The variable has to be of type contract with a parameter that mirrors the parameter expected by the oracle (string * (string * (timestamp * nat)) contract).
  • Tezos.get_entrypoint_opt: this function returns an optional value whose parameter is of typecontract with a parameter of the same type as the oracle parameter. It takes 2 parameters: the name of the entrypoint you target under the format "%name_of_parameter" and the address of the targetted contract (here, the oracle address).
  • match ... with ...: this is pattern matching over the result of Tezos.get_entrypoint_opt, if the contract with the defined entrypoint is not found, it will return None, otherwise Some along with the reference of the contract we are after.
  • (failwith "NO_ORACLE_FOUND": oracle_param contract): if the get_entrypoint_opt function fails to find the contract with the defined entrypoint, the contract fails with the above error code. The failwith must be typed and have the same type as the one expected by the call_to_oracle variable.
  • Some contract -> contract: if everything works as expected, you receive a reference to the oracle contract and its entrypoint that will be stored in the call_to_oracle variable.

Now let’s have a look at our hodl entrypoint:

Last, we build the transaction that will be sent to the oracle at the end of the execution of the contract:

We use the Tezos.transaction function that takes 3 arguments:

  • the parameter to send to the contract, in this case, the string representing the currency pair we want (XTZ-USD) and the callback entrypoint that we can easily get in Ligo with Tezos.self("%name_of_entrypoint") + the type of this entrypoint wrapped in a contract type
  • the amount to send to the oracle (0 tez, it is free to use!)
  • the reference to the contract the transaction will be sent to

And that’s it! Make sure to include this transaction in the list of operations that is returned at the end of the function:

This new transaction will be sent to the oracle to collect the data we want. But then, what do we do to get this data back?

Setting up the callback entrypoint

The callback entrypoint is the place of all the dangers! There are some inherent risks with this kind of architecture that you must keep in mind when you build the entrypoint that will receive the data you need from the oracle. Ask yourself the following questions:

  • How do I know the transaction actually comes from the oracle? After all, the entrypoints of your contract are open and anyone can send a transaction to them, even if they are designed to receive data from an oracle.
  • How do I sanitize the incoming data? Even if you know for a fact that the current transaction comes from the oracle, this doesn’t mean the data is what you expect (as we will see below).
  • How do I safely pursue the changes I started in the first transaction? You requested the data because you need them to update the storage, however, a lot of things are different now, for example, Tezos.sender is not the same anymore.

The easiest question to answer will be the first one: you can keep the address of the oracle in the storage of the contract (and implement an entrypoint to update it in case you want to change your source of data) so you can easily find it when needed: if Tezos.sender <> storage.oracle_address. This is what we do in our contract:

First, we have to convert the value we get from the oracle into the record we declared earlier to make our lives easier. You just have to use the helper function Layout.convert_from_right_comb followed by the parameter and use the returned_val type on the variable that will hold the record. Next, we compare the address of Tezos.sender with the address of the oracle.

Use Tezos.sender here and not Tezos.source because you want to check who sent the current transaction and not who sent the very first one.

Now, the second question. The Harbinger oracle makes our life easier by sending the currency pair along with the exchange rate. We want to make sure we receive the right currency pair, so we can do:

If we were using different currency pairs, this will be an excellent way of splitting the logic of our entrypoint into different branches according to the currency pair it receives.

We don’t use the timestamp returned by the oracle here but you are free to use it, for example, if you want to verify if the exchange rate you receive is fresh enough.

Now, it’s time to update the storage. In the previous entrypoint, we set up the user’s account with a 0n as a price to indicate that the initialization is not complete. We are going to get the account back and finish its setup!

We try to find the account created in the ledger whose key matches the address of the very first transaction (Tezos.source), if we don’t find it, the contract fails (it is definitely an unexpected behaviour!) If we find it, we want to make sure that the price field is set to 0n (this means the account is waiting to be updated with the exchange rate). The contract fails if it is not the case.

Now we can update the account and return the new storage:

We simply put the exchange rate into the user’s account and update the storage. When the users will come back later to withdraw their funds, we can call the oracle again, compare the current exchange rate with the one saved in the storage, if the current rate is higher than the previous one, we can release their funds!

The risks of using oracles on Tezos

Every time you will need data from an oracle, you will leave the warm comfort of your contract for the dangerous and scary outside world! It is a place full of ill-intentioned people who lurk behind the contracts waiting for an opportunity to sneak into your code and steal your funds!

But fear not, by following very simple methods, you can defeat most of them! Verify that the data come from the right oracle, that you are getting the data you expect, be mindful of the difference between Tezos.sender and Tezos.source and update the storage as less as possible after receiving the data from the oracle. Never assume that only the oracle will use the callback entrypoint.

Here is a concrete example of what an attacker can do: just before the writing of this article, the Harbinger contract used in this tutorial only returned a nat value (the current exchange rate for the requested currency pair). After the publication of the contract used in this article, it was discovered that there was a flaw in this design: a malicious actor could forge a transaction and put the present contract as a callback before sending it to the oracle with the wrong currency pair. As a result, the present contract would receive a wrong value with no way of knowing that the value is not for the right pair. In this simple use case, it is not a big issue but in a contract that would use the returned exchange rate to decide how many tez to transfer to a given address, that could be catastrophic!

I hope this article gave you more information about oracles and contracts on Tezos. The use of oracles on Tezos differs from the one you can find in other blockchains and it is vital to understand how the exchange of transactions works and how malicious contracts can affect the transactions and your own contracts.

With a couple of simple extra steps, you can start using safely the Harbinger oracles and create amazing dapps!

Get Best Software Deals Directly In Your Inbox

Oracles on Tezos with Ligo: a simple use case was originally published in Coinmonks on Medium, where people are continuing the conversation by highlighting and responding to this story.