Tezos (Part 3): Applying Interest in a Liquidity Pool Contract
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 1tez
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.