Building with kUSD on Tezos: a use case for inter-contract invocations

Claude Barde
ECAD Labs Inc
Published in
16 min readFeb 17, 2021

--

Learn more about the message passing architecture of Tezos smart contracts with a practical use case

Tokens are the hot subject of the moment in the Tezos ecosystem: everyone is talking about non-fungible tokens (or NFTs) with the release of different platforms to create them (like OpenMinter) and to sell/exchange them (like Kalamint). However, the subject of fungible tokens is often pushed into the background while being as exciting as their non-fungible cousins. Indeed, they are the keys to unlocking the full capacity of DeFi on Tezos.

I got excited since I first heard about the Kolibri stablecoin. I feel unsatisfied with the options we have currently available on Tezos for stablecoins as I believe a completely decentralized minting process is necessary and preferable to stablecoins that require their users to ask for permission or to fulfill certain requirements to mint them. The idea of anyone being able to mint a stablecoin in minutes with the only requirement being to have a few tez available in your wallet is a dream come true! This is what the users of the Kolibri token can achieve!

I am not going to go into details about how the minting process goes as this is not the goal of this article. I wanted to use the Kolibri token (only available on testnet as of February 17th, 2021) while demonstrating the complex interactions between the smart contracts on the Tezos blockchain. The first draft of the dapp was a simple faucet that allowed its users to get 2 kUSD (Kolibri stablecoin) for free at the press of a button. This first version involved interactions between two contracts. But I decided to push the experiment further and add a third contract to the mix, turning the dapp into a buying application. The dapp is available at the following address: https://kolibri-test-faucet.netlify.app/.

In the following paragraphs, you will learn more about the interactions that happen between contracts on the Tezos blockchain and how to code these interactions using the Ligo programming language. This should give you a better understanding of Tezos strengths as a blockchain but also of the specific security concerns that must be kept in mind when writing smart contracts for the Tezos blockchain.

Note: as usual, I am going to use the CameLigo syntax for the contract written in Ligo. A minimal knowledge of Ligo is required to follow along with the code.

Structuring the dapp underlying contract

(You can find the final contract source code at this address)

The first step of every dapp is thinking about what your contract is going to achieve. In the case of this dapp, we want our users to be able to buy 2 kUSD with their tez. I set up a Kolibri oven myself and funded it to have Kolibri tokens available for the dapp. The contract must be aware of the current exchange rate between XTZ and USD¹ so there will be a call made to the Harbinger oracle involved. After getting the current exchange rate, we must also make sure that the oven admin has enough funds to go ahead with the transfer, which involves a call to the Kolibri main contract. Finally, when everything is okay, we send a last call to the Kolibri main contract to transfer 2 kUSD to the user who requested it. These interactions are illustrated in the diagram below:

The faucet contract will contact the Harbinger oracle and the Kolibri main contract to request information. It will then process this information and act upon them. If everything goes as intended, it will finally send a transfer request to the Kolibri contract.

Creating the entrypoints and storage of the contract

- The entrypoints

The entrypoints of the contract will be of two kinds: the functional entrypoints that implement the features of the contract and the admin entrypoints to set up or modify the contract. The inter-contract invocations on Tezos are based on requests sent between contracts with the emitting contract informing the target contract of its entrypoint accepting the expected response (if a response is expected).

A first entrypoint accepts the transaction sent by a user to buy 2 kUSD. A second entrypoint accepts the response from the Harbinger contract regarding the XTZ-USD exchange rate and a third entrypoint accepts the response from the Kolibri contract regarding the available token balance. For the admin entrypoints, we need one to update the address of the Kolibri contract, one to update the address of the Harbinger contract (in case they change in the future), one to update the address holding the Kolibri tokens and finally, one to withdraw the tez sent to the contract to buy kUSD tokens. This is how it looks like:

The entrypoints

The harbinger_param type represents the type of data we will be expecting from the Harbinger oracle. The Transfer_request entrypoint takes as a parameter the address of the account that will be credited the 2 kUSD, the Get_balance entrypoint takes the response from the Harbinger contract and the Transfer entrypoint will send the transfer request to the Kolibri contract. The other entrypoints update the different addresses we will need for our contract calls to work, as well as withdrawing the contract balance.

- The storage

The structure of the storage will showcase one of the methods used to persist data between each contract call. One of the issues you may face when you emit an operation to another contract and wait for its response is that the context of the initial operation is lost. When you receive the response from a separate contract, you don’t have access to the sender’s address of the first operation, the amount that was sent, etc. One of the possibilities to keep this information around is to lock the contract between each interaction with the initial entrypoint. This way, you can save the data you need later in the storage and access it when the response comes back. Let’s have a look at the storage:

The storage

As you can see, the storage has a paused property that will be switched to false when the contract accepts new requests and to true when processing a request.
We also want to protect our tokens and prevent a malicious user from draining our oven so we will set a limit of one transfer every 15 minutes by updating the last_transfer property with the timestamp of the last transfer.
Upon each request, the contract will record the user’s address in the transfer_to property (set as an address option to allow us to reinitialize it to None) and the amount sent in the locked_amount.
The three last properties, admin, kolibri_address and harbinger_address are self-explanatory.

The Transfer_request entrypoint

This is the initial access point of the transfer request. The goal of this entrypoint is to verify that all the requirements are present and to prepare the call to the Harbinger contract to request the current XTZ-USD exchange rate. There are three requirements to execute the code:

  1. An amount in tez has been passed with the transaction.
  2. The contract is not currently paused
  3. There have been at least 15 minutes since the last transfer.

Let’s translate that into code:

With if Tezos.amount = 0tez, we check that an amount has been passed (no tez, no kUSD!), then we check that the contract is not currently paused by reading the paused property of the storage. To finish, we add 900 seconds (15 x 60 seconds) to the timestamp saved in the storage to determine if Tezos.now is greater than 15 minutes, in which case we are good to go!

Note: I generally prefer returning error codes formatted as above with failwith instead of whole sentences, they are easier to write, cost effective, less prone to typos and they give more flexibility to the dapp developers who can display the error message of their choice according to the error code.

Next, we can start writing the contract call to the Harbinger oracle that will request the current XTZ-USD exchange rate:

The call to the Harbinger contract

This part of the code can quickly become confusing because of the number of contract words you have to write and you may not always remember which contract keyword refers to what 😅

The first variable called contract refers to the contract we will call (here, the Harbinger oracle). This variable must be of type contract with as a parameter the parameter type expecting by the entrypoint we will target, in this case, (string * (string * (timestamp * nat)) contract) where the string on the left is the currency pair you want to get the exchange rate for and on the right, the callback to the contract that will receive the response of type string * (timestamp * nat).

Next, we build the contract call by indicating the entrypoint we want to reach in the target contract, the Harbinger oracle exposes a get entrypoint to return exchange rates, so we will use the pattern matching capabilities of Ligo to return a reference to the get entrypoint of the Harbinger contract if the provided address is valid (the one in the s.harbinger_address variable). The reference we’ve just created will be used to forge the operation that will be sent to the Harbinger contract. It points to this contract and is typed according to the type of parameters the oracle expects.

Once our reference is ready, we can build the operation that will be sent at the end of the execution of the contract. The Tezos.transaction function requires 3 arguments:

  • the parameters to send along in the case of an operation to a smart contract => the get entrypoint of the Harbinger oracle expects a pair made of a string and a callback to a contract entrypoint that takes a parameter of type (string * (timestamp * nat)), which is the case of the get_balance entrypoint we will build next, so we have to pass the currency pair as a string XTZ-USD and a reference to our next entrypoint that we can get easily with Tezos.self("%get_balance")
  • the amount of tez to attach to the operation => luckily the Harbinger oracle is available for free 😅 so we set the amount of tez to send to 0tez
  • a reference to a contract => this is the reference we’ve just created, contract.

Last but not least, we have to return the operation we’ve just forged and the updated storage:

Returning the operation and the storage

At the end of every contract on Tezos, a list of operations and the storage must be returned. We include our new operation into the list to return and we update the storage to lock the contract (with paused = true) and save the address of the recipient of the 2 kUSD (with transfer_to = Some p) and the amount that was sent (with locked_amount = Tezos.amount).

Now, an operation will be sent to the Harbinger oracle requesting to return the current exchange rate between XTZ and USD to the get_balance entrypoint of our contract.

The Get_balance entrypoint

After getting the current exchange rate between XTZ and USD, we want to double-check that our faucet account has enough balance to provide 2 kUSD to our users. In order to do that, we are going to set up an entrypoint that will send a new operation to the Kolibri contract to request our current balance.

This entrypoint will be an excellent demonstration of the kind of security concerns you must keep in mind when writing code that expects input from another contract. One of the key ideas to remember is: never assume people will not send transactions to an entrypoint dedicated to transactions from other contracts!! You are going to expose your contract to a wide range of exploits if you don’t take into account malicious actors trying to break your code. The input must always be properly sanitized before being used. This is what we will do in the first part of this entrypoint:

Sanitizing the input to `get_balance`

The very first thing we want to check is that the sender of the operation is the Harbinger oracle. No one else should have access to this entrypoint except the Harbinger oracle. So we verify it with Tezos.sender <> s.harbinger_address. If another sender is detected, the operation fails (and all the previous operations with it if any). After verifying that the operation comes from the right sender, we check if the contract is currently paused. At this point in time, the contract is supposed to be paused, if it’s not, the operation fails. Because the value returned by the oracle includes the currency pair, we double-check it to be sure it is the right one.

This is the opportunity to understand a major security issue exposed by the inter-contract invocation mechanism on Tezos. Consider the following diagrams:

Imagine a contract that returns different values, for example, the balance of a specific user and the total supply of a certain token (Contract B), as a nat value. Contract A needs the balance of a user to authorize some action. It sends a request to the get_balance entrypoint of Contract B to get this value and has an entrypoint called %receive to receive the balance. The security issue lies in the data returned by Contract B. A malicious actor could exploit the fact that Contract B returns a numeric value without any other information and query the get_totalsupply entrypoint of Contract B with a callback to the %receive entrypoint of Contract A. Now, instead of receiving the user’s balance (let’s say 100 tokens), Contract A receives the total supply of tokens (let’s say 1.000.000 tokens) and assumes this is the user’s balance!

This is the exact reason why the Harbinger oracle returns the exchange rate along with its associated currency pair, to give some context to the data. Now we can be sure that thenat value we have is the exchange rate for XTZ-USD and not for another currency pair!

Remember how we saved the amount of tez the user sent during the first operation? It’s time to fetch it and verify it against the exchange rate:

The balances in the Kolibri contract are padded with 18 zeros (so 2 kUSD is actually stored in the contract as 2.000.000.000.000.000.000 tokens) and the exchange rate returned but the Harbinger contract is in dollar with 6 decimals (so the oracle returns 2123456 as the price for 1 XTZ, which actually means $2,123456), so if we want to get the price for 2 kUSD, we must remove 6 zeros to the token representation of 2 kUSD and divide it by the exchange rate. However, this value will be a nat so we have to multiply it by 1 mutez in order to turn it into a mutez value that we can compare with the amount the user sent in the first operation. If the two values don’t match, the transaction is aborted.

After these verifications, we can start creating the reference to the Kolibri contract and the operation we will send (you are a little more familiar with the process now!):

The Kolibri contract being a FA1.2 contract, its %getBalance entrypoint expects a pair with an address on the left (the address of the user you are requesting the balance of) and a callback to a contract entrypoint that accepts a nat value. We can add a new type called get_balance_param to make our code more readable.

After that, we create a new transaction whose parameter is the admin’s address (the account that holds the tokens to transfer) and the reference to our local entrypoint that will receive the response from the Kolibri contract with Tezos.self("%transfer"). We don’t need to attach any tez and we finish building the transaction with the reference to the contract the transaction will be sent to.

Note: Ligo requires you to indicate the expected type of Tezos.self(), it’s always of type contract with the type of value accepted by the entrypoint, %transfer accepting a nat value, the type here is nat contract.

As usual, a list of operations containing our newly forged operation and the storage (here unchanged) are returned. Now, let’s see what happens when we receive our balance back!

The Transfer entrypoint²

After calling the %getBalance entrypoint of the Kolibri contract, the current balance of the admin’s account will be sent to the %transfer entrypoint. As it is always the case for every entrypoint, we want to sanitize the data we receive before using it:

First, we check if the current operation is coming from the Kolibri contract, no other contract should be allowed to send a transaction to this entrypoint. Next, the contract should still be paused at this point so we check that too.

Note: Remember the security concern exposed earlier? If we don’t check that the contract is paused, an attacker could send a transaction to the %getBalance entrypoint of the Kolibri contract and reference this %transfer entrypoint, which would bypass all the security checks we did in the previous entrypoints. In the case of this contract, this would allow the attacker to get 2 kUSD for free 😄

The faucet verifies that there are at least 20 kUSD left in the admin’s account and if that’s the case, a new operation will be forged to transfer 2 kUSD to the recipient’s account:

Creating a reference to an external contract in Ligo shouldn’t have any secret for you now! The %transfer entrypoint of the Kolibri contract expects a value of type (address * (address * nat)) that we more conveniently wrapped into its own Ligo type, kolibri_transfer_param.

The forging of the operation is a little different because the recipient’s address is saved as an optional value in the contract, so we have to use pattern matching to verify that the value is not None and get it to use it. Then, we can forge the operation that will transfer 2 kUSD (2_000_000_000_000_000_000n) from the admin (s.admin) to the recipient (r).

To finish, we return a list of operations including this last operation and we don’t forget to update our storage in order to welcome a new user who wants to buy some kUSD 🤗

The contract gets unpaused (paused = false), the last transfer property is set to the current time so the next user has to wait 15 minutes before getting some tokens (last_transfer = Tezos.now), the recipient’s address is reinitialized (transfer_to = (None: address option)), (note that it has to be explicitly typed in Ligo) and the amount locked in the contract is set back to zero (locked_amount = 0tez).

The Withdraw entrypoint

When our contract is full of sweet tez, it’s time to withdraw them to mint more Kolibri tokens or repay the ones we gave away! This entrypoint is fairly simple but it is a good example of the difference between forging a transaction to send to a contract and forging a transaction to send tez to an address. The code looks like this:

Here are two key differences between the two types of operation forging:

  • Sending a transaction to an account requires the use of the get_contract_opt instead of get_entrypoint_opt (obviously, implicit accounts don’t have entrypoints!), however, these two functions work in a similar way and require pattern matching to retrieve the value they return.
  • The parameter of a transaction sent to an account is unit, so the reference to the account should be of type unit contract and the first parameter of Tezos.transaction should be unit.

Note that the entrypoint starts by checking if the sender is the admin to prevent anyone from withdrawing our (hard) earned tez!

Conclusion

In addition to providing a useful service for the users who would like to play around with Kolibri tokens without going through the different steps of creating an oven, supplying it with test XTZ and minting kUSD, the Kolibri faucet is a great example of how inter-contract invocations and message passing architecture work on Tezos with the faucet contract interacting with 2 different contracts for a total of 6 operations.

This mechanism specific to the Tezos blockchain may seem more complicated and inconvenient at the beginning, but it actually provides more security to the blockchain: during a contract invocation, no outside intervention or input can interfere or tamper with the execution of the code because it is self-contained. For the same provided input of parameter and storage, the entrypoint of a smart contract on Tezos will always return the same output, which adds a layer of robustness and security to the contracts.

Although this method provides more security within the contract itself, it also opens the door to different security problems that developers coming from other blockchains may not be aware of or imagine. The chain of operations in complex contracts like this one can be modified by a malicious actor to inject unexpected data into a naive contract. This makes sanitizing input data and providing context to output data crucial for the proper and smooth functioning of the Tezos blockchain in general.

A special thanks to Keefer Taylor for his help while building this smart contract!

Links

Notes

[1] This method is fine for testnet because we assume 1 kUSD is always equal to 1 USD. On mainnet, while the price of kUSD is tied to the US dollar, the actual price may fluctuate by a few cents depending on timing and market factors and this approach is much less reliable.

[2] In a production contract, it is generally considered bad practice to use the result sent from the %getBalance entrypoint of a FA1.2 contract to do logical checks or calculations because there is no guarantee that the nat value you receive is the balance you expect, it could be the balance of another user or even the total supply of that particular token. We mitigate this risk here by having the contract paused but it is still something to keep in mind.

--

--

Self-taught developer interested in web3 and functional programming