Tezos (Part 3): Applying Interest in a Liquidity Pool Contract

Protofire.io
Protofire Blog
Published in
9 min readOct 31, 2019

Learn how to modify a contract for interest rates to be accrued depending on how many blocks have elapsed since the initial deposit.

Getting started with Tezos | Part 1 | Part 2 | Part 3

In this blog post, we are going to change the way a liquidity pool contract works. The objective is to make a smart contract with the capacity to generate interest on its deposits under the following conditions:

  • An account will be able to make more than one deposit to the liquidity pool contract.
  • Deposits will generate interest with a fixed value calculated by blocks.
  • Account withdrawals will include the interest generated proportional to deposits.

The final version of this contract can be found in this GitHub repo.

Note: We encourage developers to read our previous blog post, where we modify the contract to only allow withdrawals from addresses that have previously made deposits.

Contract modifications

Let’s start by navigating to the tezos-defi folder and creating a new file for this contract. (Note: Open v3-full-pool.ligo in your editor to start writing the contract.)

$ cd tezos-defi$ touch v3-full-pool.ligo

The variant type for the contract entry point remains unchanged from Part 2 of this series.

type entry_action is
| Deposit
| Withdraw

The storage needs to be changed, so that each deposit will generate interest. We will use Tezos block time stamps to achieve this. For now, it’s enough to modify the deposits’ map attribute to be of the map -> deposit_info type, where deposit_info is a record composed by the deposit amount (tezAmount) and the time stamp in which the deposit transaction was processed (blocktime stamp).

The main function representing the entry point remains unchanged.

function main(const action: entry_action; var finance_storage: finance_storage): (list(operation) * finance_storage) is
block {
skip
} with case action of
| Deposit(param) -> depositImp(finance_storage)
| Withdraw(param) -> withdrawImp(finance_storage)
end;

The const action remains unchanged, but it needs to be added in the contract’s global scope.

const noOperations: list(operation) = nil;

Helper functions

We will use two helper functions:

  • getSender will help us to test our entry points. The behavior is the same as before, but wrapped into a function.
  • calculateInterest is the function that will calculate the interest generated by a deposit. The logic of this function is simple. It will generate 1 tez of interest if a deposit has been in the liquidity pool for 100 blocks before a withdrawal.

The Deposit operation

The Deposit operation will have two modifications. The first one is to adapt the code to the new storage structure. The second one is to handle the case, where the pool already contains a deposit from the sender. For example, if Bob tries to deposit 5 tez, and the pool already contains 20 tez from Bob, while the difference in blocks between the previous deposit and the current one is greater than 100 blocks, the new deposit amount for Bob will be 26 tez. This will include 20 tez of initial deposit, 1 tez of accrued interest, and 5 tez of a new deposit.

The Withdraw operation

The Withdraw operation will be modified slightly. The changes will relate to adapting the new contract storage structure and calculating the interest accrued before generating a withdraw transaction to the sender.

function withdrawImp(var finance_storage: finance_storage): (list(operation) * finance_storage) is
block {
const senderAddress: address = getSender(False);
var operations: list(operation) := nil;

var di: deposit_info := get_force(senderAddress, finance_storage.deposits);
const elapsedBlocks:int = now — di.blocktime stamp;
var withdrawAmount: tez := di.tezAmount + calculateInterest(elapsedBlocks);
if withdrawAmount > finance_storage.liquidity
then failwith(“No tez to withdraw!”);
else block {
// update storage
var depositsMap: map(address, deposit_info) := finance_storage.deposits;
remove senderAddress from map depositsMap;
finance_storage.deposits := depositsMap;
finance_storage.liquidity := finance_storage.liquidity — withdrawAmount; // Create the operation to transfer tez to sender
const receiver: contract(unit) = get_contract(senderAddress);
const payoutOperation: operation = transaction(unit, withdrawAmount, receiver);
operations:= list
payoutOperation
end;
}
} with(operations, finance_storage)

Testing the smart contract

To test this smart contract, we will proceed with the same steps as in Part 2 of this series. Please note that the getSender function receives a Boolean parameter, indicating if the function will return a mocked address or not. To test some parts of our contract, we will need to change the argument sent to getSender inside the Deposit and Withdraw operations.

(Note: The mocked address is related to the address used to initialize the storage. The getSender function will return the original sender address if the mock parameter is false and the hardcoded address if the mock parameter is true).

Let’s start testing the calculateInterest function. For this purpose, you can use a LIGO instruction called run-function. To use it, you need to pass file.ligo, the function name, and the function arguments.

# /tezos-defi working directory$ ligo run-function v3-full-pool.ligo calculateInterest 100> 1000000mtz

Now, we can test the Deposit operation. It should update the storage with tezAmount, block time stamp, and an increase in the liquidity pool.

Let’s continue to test the Deposit operation to see if there is already a deposit for the sender’s address. Before continuing, update the getSender function inside the deposit function and change the parameter to True.

# /tezos-defi working directory$ ligo dry-run v3-full-pool.ligo — syntax pascaligo — amount 3 main “Deposit(unit)” ‘record deposits = map (“tz1MZ4GPjAA2gZxKTozJt8Cu5Gvu6WU2ikZ4” : address) -> record tezAmount = 3000000mtz; blocktime stamp = 1570635360; end; end; liquidity = 3000000mtz; end’
> tuple[
list[]
record[
liquidity -> 6000000mtz
deposits -> map[
@”tz1MZ4GPjAA2gZxKTozJt8Cu5Gvu6WU2ikZ4" -> record[
tezAmount -> 7000000mtz
blocktime stamp -> +1570893468
]
]
]
]

Let’s analyze what’s going on. We have initialized map with 3 tez at the 1,570,635,360 block time stamp. When we run the command, we are simulating a second deposit in the 1,570,893,468 block time stamp. The Deposit operation will analyze the amount of blocks that have elapsed. Since it was more than 100 blocks, 1 tez will be added to the current deposit as interest, plus the new deposit of 3 tez. This gives us a total deposit of 7 tez. Note that the liquidity pool was increased by 3 tez (the new deposit amount), but the tez generated as interest is not being added. We will deal with this later on.

Now, we’ll continue by testing the Withdraw operation. It is similar to how we did it in our previous blog post. Before we continue, update the getSender function inside the Withdraw operation and change the parameter to True.

# /tezos-defi working directory$ ligo dry-run v3-full-pool.ligo — syntax pascaligo — amount 0 main “Withdraw(unit)” ‘record deposits = map (“tz1MZ4GPjAA2gZxKTozJt8Cu5Gvu6WU2ikZ4” : address) -> record tezAmount = 4000000mtz; blocktime stamp = 1570635360; end; end; liquidity = 6000000mtz; end’
> tuple[
list[
Operation(…bytes)
]
record[
liquidity -> 1000000mtz
deposits -> map[]
]
]

As we can see, the storage was initialized with 6 tez in the pool and 4 tez for the mocked address. When the Withdraw operation is called, it will calculate the interest (1 tez accrued) plus the deposit of 4 tez, giving us a total of 5 tez to be withdrawn. After the withdrawal is executed, the final storage will contain 1 tez remaining in the pool.

Compiling the contract

It’s time to compile the contract. We will use the same instructions as in the previous blog post.

Note: before proceeding, remember to set the argument for getSender in the Withdraw and Deposit operations to False.

# /tezos-defi working directory# compile
$ ligo compile-contract v3-full-pool.ligo main | tr -d ‘\r’ > v3-full-pool.tz
# compile storage
$ ligo compile-storage v3-full-pool.ligo main “record deposits = (map end: map(address, deposit_info)); liquidity = 0mtz; end”
> (Pair {} 0)
# move the compiled contract to docker container
$ docker cp v3-full-pool.tz babylonnet_node_1_9cf366d04dd4:/home/tezos

Previously, we’ve created some wallets. Let’s inspect their current balance by running the following command.

# ./babylonnet.sh shell$ alias babylon-client=”tezos-client -A rpctest.tzbeta.net -P 443 -S -w none”$ babylon-client get balance for contractOwner
> 21931.781292 ꜩ
$ babylon-client get balance for bob
> 6.572821 ꜩ

Deploying and interacting with the contract

For deployment, we will use similar commands as before. Note that after originating the contract, the response will contain the address and alias for the new contract.

# ./babylonnet.sh shell$ babylon-client originate contract v3-full-pool for contractOwner transferring 0 from contractOwner running v3-full-pool.tz — init “(Pair {} 0)” — burn-cap 4.442> New contract KT1WwjrprKi15ZPTb4VgoL7Fn3UePXuxn8gf originated.Contract memorized as v3-full-pool.

Now, let’s interact with the contract. The first thing we will do is to inspect the contract’s storage.

# ./babylonnet.sh shell$ babylon-client get script storage for v3-full-pool
> Pair {} 0

Let’s recap the Michelson instructions to call the entry point.

# /tezos-defi working directory$ ligo compile-parameter v3-full-pool.ligo -s pascaligo main “Deposit(unit)”
> (Left Unit)

Let’s attempt the first contract call by making a deposit.

# ./babylonnet.sh shell$ babylon-client transfer 3 from bob to v3-full-pool — arg “(Left Unit)” — burn-cap 0.045
> Transaction:
Amount: ꜩ3
From: tz1dn56b2qshAAhbwyQuSWoyiaqyV5PYXidt
To: KT1WwjrprKi15ZPTb4VgoL7Fn3UePXuxn8gf
Parameter: (Left Unit)
This transaction was successfully applied
Updated storage:
(Pair { Elt 0x0000c6f7995d920ef51cc2977e6865695ec8fd47a67d (Pair 1570920796 3000000) }
3000000)

As we can see in the response, the storage was properly updated for Bob’s address. We have an entry in map with the block time stamp and the amount transferred. The size of the liquidity pool was updated, as well.

Let’s inspect Bob’s balance, it should have decreased by 3 tez.

# ./babylonnet.sh shell~ $ babylon-client get balance for bob
> 3.519243 ꜩ

Note: If it was not, wait for a moment and try again. Sometimes it might take a few minutes for the node to update.

Now, we will make another deposit for Bob, but this time, we will wait for 100 blocks to ensure 1 tez interest is accrued.

# ./babylonnet.sh shell$ babylon-client get time stamp -s
> 1570921046

We need to execute the command below until the result is greater than 1,570,920,796 plus 100, where 1,570,920,796 is the time stamp of the command above.

Once 100 blocks have elapsed, let’s deposit again. This time with an amount of 1 tez.

# ./babylonnet.sh shell$ babylon-client transfer 1 from bob to v3-full-pool — arg “(Left Unit)” — burn-cap 0.045
> Transaction:
Amount: ꜩ1
From: tz1dn56b2qshAAhbwyQuSWoyiaqyV5PYXidt
To: KT1WwjrprKi15ZPTb4VgoL7Fn3UePXuxn8gf
Parameter: (Left Unit)
This transaction was successfully applied
Updated storage:
(Pair { Elt 0x0000c6f7995d920ef51cc2977e6865695ec8fd47a67d (Pair 1570921655 5000000) }
4000000)

As we can see, the map key for Bob’s address has 5 tez (a previous deposit plus interest plus a new deposit), and the time stamp was updated. Note that the liquidity pool does not have enough tez to transfer if Bob makes a withdrawal. To solve this problem, we need someone else to make a deposit in order to provide tez to the contract. We will provide this additional amount from the contactOwner account.

# ./babylonnet.sh shell$ babylon-client transfer 100 from bob to v3-full-pool — arg “(Left Unit)” 
> Transaction:
Amount: ꜩ100
From: tz1XS48bsBrw9BE6w44ifc5soFySkTyh4n73
To: KT1WwjrprKi15ZPTb4VgoL7Fn3UePXuxn8gf
Parameter: (Left Unit)
This transaction was successfully applied
Updated storage:
(Pair { Elt 0x0000815db8293c4522ea06aec89f3fe0a40f4c64b9ad (Pair 1570922100 100000000) ;
Elt 0x0000c6f7995d920ef51cc2977e6865695ec8fd47a67d (Pair 1570921656 5000000) }
104000000)

As the contract has enough tez, it will complete the withdrawal for Bob.

# ./babylonnet.sh shell$ babylon-client transfer 0 from bob to v3-full-pool — arg “(Right Unit)”
Transaction:
Amount: ꜩ0
From: tz1dn56b2qshAAhbwyQuSWoyiaqyV5PYXidt
To: KT1WwjrprKi15ZPTb4VgoL7Fn3UePXuxn8gf
Parameter: (Right Unit)
This transaction was successfully applied
Updated storage:
(Pair { Elt 0x0000815db8293c4522ea06aec89f3fe0a40f4c64b9ad (Pair 1570922106 100000000) }to ensure
98000000)

map no longer has the entry for Bob, and the liquidity pool was decreased correctly (104,000,000 tez minus Bob’s deposit and the interest accrued).

Summary

The intention of this blog post was to develop a financial contract and learn the basics of Tezos and LIGO. In this version, the liquidity pool contract is more elaborate, having a complex storage and mathematical computations, as well as the code being split into different functions.

While the logic of this liquidity pool can not be applied in the real world, this same logic will be needed to develop strategies for accruing interest, locking funds, borrowing money, etc.

--

--

Protofire.io
Protofire Blog

We help token-based startups with protocol & smart contract engineering, high-performance trusted data feeds (oracles), and awesome developer tools (SDKs/APIs).