Table of contents
Open Table of contents
Introduction
A common pattern used in DeFi dApps is the Peer-to-Contract model in which one group of users called “Liquidity Providers” (LPs) deposit their assets into a Smart Contract to help other users trade against such liquidity in an asynchronous manner.
Relying on Liquidity Providers to pool their funds allow for traders to trade against the Smart Contract directly rather than users having to trade in a Peer-to-Peer setup which requires more coordination.
Users who deposit their assets into the Smart Contract and therefore act as Liquidity Providers are usually incentivized to do so via fees or yield that accrues while their funds are being used to facilitate market-making.
Quick Recap on LP-Tokens
A proper Bookkeeping system needs to be used in order to keep track of the share Liquidity Providers contribute to the Protocol and to allow for a fair distribution of fees upon withdrawal of liquidity.
An elegant way to solve this problem is the usage of “Liquidity Provider Tokens” (LP-Tokens). The main idea is to mint and transfer LP-Tokens to the Liquidity Provider whenever new funds are deposited into the Smart Contract and to burn LP-Tokens and return the fair share of assets from the Smart Contract when a Liquidity Provider unwinds her position via a withdrawal.
LP-Tokens act as a receipt or “Proof-of-Deposit” that also keeps track of the share the individual contributed to the overall liquidity of the Protocol.
Given that this Blog Post focused on the implementation of LP-Tokens in Cairo we won’t dive deeper into the underlying mechanics of such. For an elaborate exploration of LP-Tokens I’d encourage you to read through the introductory Blog Post that helps you derive the concepts behind LP-Tokens from first principles.
Depositing Funds and minting LP-Tokens
New LP-Tokens are minted whenever a user decides to become a Liquidity Provider by depositing assets into the Smart Contract. The following formula calculates how many LP-Tokens should be minted and transferred to the user upon deposit:
is the amount of LP-Tokens to mint, is the amount of Tokens the user deposited, is the total supply of LP-Tokens and is the balance of Tokens already deposited in the Smart Contract.
Given that the value can be zero we need to account for this and can say that we’ll simply mint an amount of LP-Tokens equal to the Tokens deposited in that case:
Burning LP-Tokens to unlock Funds
LP-Tokens are burnt to unlock the deposited assets plus the portion of fees that were generated. We can use the following formula to calculate the amount of Tokens the users should get transferred upon LP-Token burning:
is the Token amount to return, is the LP-Token amount to burn, is the Smart Contract’s Token balance and is the total supply of LP-Tokens.
Implementation in Cairo
Now that we covered the math we can dive straight into the implementation of such in Cairo. Again, if you want to learn more about the mechanics of LP-Tokens I’d encourage you to read through the introductory Blog Post before moving on.
Note: Throughout this implementation we call the StarkNet Smart Contract we’re about to implement the “Vault” given that it takes custody of user funds.
Contract Scaffold
Let’s kick-off the implementation with a StarkNet Smart Contract scaffold which is a basic Cairo program that includes the %lang starknet
declaration at the top of the file.
Given that we’ll be dealing with Tokens in the form of deposits- and withdrawals and LP-Tokens that are minted and burnt, we’ll also import the ERC-20 implementation by the OpenZeppelin Contracts library which offers audited Smart Contract building blocks to re-use in existing projects.
ERC-20 Tokens have their roots in the Ethereum ecosystem. Given that StarkNet is an L2 scaling solution for Ethereum they’re also widely used within StarkNet dApps.
%lang starknet
from openzeppelin.token.erc20.library import ERC20
from openzeppelin.token.erc20.interfaces.IERC20 import IERC20
Add Token Support
Next up we need to implement a way in which our Vault Smart Contract can take custody of user assets. To do this we use a @storage_var
that keeps track of the Token our Vault supports and set its value within the constructor
.
Doing so ensures that the Token address is only set once upon contract deployment and can never be changed again afterwards.
%lang starknet
from starkware.cairo.common.cairo_builtins import HashBuiltin
from openzeppelin.token.erc20.library import ERC20
from openzeppelin.token.erc20.interfaces.IERC20 import IERC20
@storage_var
func token() -> (address : felt):
end
@constructor
func constructor{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}(
token_address : felt
):
token.write(token_address)
return ()
end
Add LP-Token Support
The Smart Contract is now aware of the Token users can deposit into it to provide liquidity.
The next step is to add support for LP-Tokens that are minted and burnt upon deposits and withdrawals.
In our implementation we’ll follow a pattern used commonly in DeFi dApps in which the Smart Contract itself is turned into an ERC-20 Token. What this means is that our Vault does not only manager user deposits, it’ll also issue and manage LP-Tokens.
We need to do two things to turn our Smart Contract into an ERC-20 Token.
The first thing we’ll do is to call the ERC20.initializer
function within the constructor
which sets up the initial state for our LP-Tokens.
The next thing is that we need to declare and expose the common ERC-20 functions such as totalSupply
or transfer
. Re-declaring the ERC-20 functions in our contract is necessary due to the Extensibility Pattern that’s used by the OpenZeppelin Contracts library we’re working with.
%lang starknet
from starkware.cairo.common.uint256 import Uint256
from starkware.cairo.common.cairo_builtins import HashBuiltin
from openzeppelin.token.erc20.library import ERC20
from openzeppelin.token.erc20.interfaces.IERC20 import IERC20
@storage_var
func token() -> (address : felt):
end
@constructor
func constructor{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}(
token_address : felt
):
let name = 'Liquidity Provider Token'
let symbol = 'LP-TKN'
let decimals = 18
ERC20.initializer(name, symbol, decimals)
token.write(token_address)
return ()
end
#
# Getters
#
@view
func name{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}() -> (name : felt):
let (name) = ERC20.name()
return (name)
end
@view
func symbol{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}() -> (symbol : felt):
let (symbol) = ERC20.symbol()
return (symbol)
end
@view
func totalSupply{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}() -> (
totalSupply : Uint256
):
let (totalSupply : Uint256) = ERC20.total_supply()
return (totalSupply)
end
@view
func decimals{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}() -> (
decimals : felt
):
let (decimals) = ERC20.decimals()
return (decimals)
end
@view
func balanceOf{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}(
account : felt
) -> (balance : Uint256):
let (balance : Uint256) = ERC20.balance_of(account)
return (balance)
end
@view
func allowance{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}(
owner : felt, spender : felt
) -> (remaining : Uint256):
let (remaining : Uint256) = ERC20.allowance(owner, spender)
return (remaining)
end
#
# Externals
#
@external
func transfer{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}(
recipient : felt, amount : Uint256
) -> (success : felt):
ERC20.transfer(recipient, amount)
return (TRUE)
end
@external
func transferFrom{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}(
sender : felt, recipient : felt, amount : Uint256
) -> (success : felt):
ERC20.transfer_from(sender, recipient, amount)
return (TRUE)
end
@external
func approve{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}(
spender : felt, amount : Uint256
) -> (success : felt):
ERC20.approve(spender, amount)
return (TRUE)
end
@external
func increaseAllowance{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}(
spender : felt, added_value : Uint256
) -> (success : felt):
ERC20.increase_allowance(spender, added_value)
return (TRUE)
end
@external
func decreaseAllowance{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}(
spender : felt, subtracted_value : Uint256
) -> (success : felt):
ERC20.decrease_allowance(spender, subtracted_value)
return (TRUE)
end
Note: From now on we won’t repeat all the ERC-20 functions in our code snippets given that it’s mostly boilerplate code that we copied from the official ERC-20 Presets.
Depositing Tokens
Let’s continue by implementing the deposit
function. As you might remember there are two different cases we need to cover:
And if the Smart Contract doesn’t hold any Tokens yet:
Turning this into Cairo code results in the following implementation:
%lang starknet
from starkware.cairo.common.bool import TRUE, FALSE
from starkware.cairo.common.cairo_builtins import HashBuiltin
from starkware.starknet.common.syscalls import get_caller_address, get_contract_address
from starkware.cairo.common.uint256 import (
Uint256,
uint256_eq,
uint256_mul,
uint256_unsigned_div_rem,
)
from openzeppelin.token.erc20.library import ERC20
from openzeppelin.token.erc20.interfaces.IERC20 import IERC20
@storage_var
func token() -> (address : felt):
end
@constructor
func constructor{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}(
token_address : felt
):
let name = 'Liquidity Provider Token'
let symbol = 'LP-TKN'
let decimals = 18
ERC20.initializer(name, symbol, decimals)
token.write(token_address)
return ()
end
@external
func deposit{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}(amount : Uint256):
alloc_locals
with_attr error_message("Vault: Amount cannot be 0"):
let (is_amount_zero) = uint256_eq(amount, Uint256(0, 0))
assert is_amount_zero = FALSE
end
let (token_address) = token.read()
let (caller_address) = get_caller_address()
let (contract_address) = get_contract_address()
let amount_token : Uint256 = amount
let (supply_lp_token) = IERC20.totalSupply(contract_address)
let (balance_token) = IERC20.balanceOf(token_address, contract_address)
let (is_lp_token_supply_zero) = uint256_eq(supply_lp_token, Uint256(0, 0))
if is_lp_token_supply_zero == TRUE:
let amount_lp_token = amount_token
ERC20._mint(caller_address, amount_lp_token)
else:
let (res_mul, carry) = uint256_mul(amount_token, supply_lp_token)
let (amount_lp_token, remainder) = uint256_unsigned_div_rem(res_mul, balance_token)
ERC20._mint(caller_address, amount_lp_token)
end
IERC20.transferFrom(token_address, caller_address, contract_address, amount)
return ()
end
# ... Snip ...
The very first thing we have to do is to alloc_locals
to be able to deal with revoked references.
Next up we check if the amount that’s deposited is zero. If so, we throw an error.
Once we got ourselves a handle on the Token address, the caller address and the contract address we do the calculation to figure out how many LP-Tokens we should mint.
We’re then minting the amount of LP-Tokens to the user’s account contract and transfer the Tokens form the user’s account contract to the Vault contract.
Note: It’s important to do the calculation first before transferring the Tokens and minting the LP-Tokens. The reason being that the LP-Token calculation depends on the Token balances before a deposit happened.
Withdrawing Tokens
The last missing piece that needs to be implemented is the withdraw
function which allows users to burn their LP-Tokens to get access to the assets they previously deposited plus all the accrued fees they generated.
The formula we need to implement is the following:
Turning this into Cairo code is pretty simple:
%lang starknet
from starkware.cairo.common.bool import TRUE, FALSE
from starkware.cairo.common.cairo_builtins import HashBuiltin
from starkware.starknet.common.syscalls import get_caller_address, get_contract_address
from starkware.cairo.common.uint256 import (
Uint256,
uint256_eq,
uint256_mul,
uint256_unsigned_div_rem,
)
from openzeppelin.token.erc20.library import ERC20
from openzeppelin.token.erc20.interfaces.IERC20 import IERC20
@storage_var
func token() -> (address : felt):
end
@constructor
func constructor{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}(
token_address : felt
):
let name = 'Liquidity Provider Token'
let symbol = 'LP-TKN'
let decimals = 18
ERC20.initializer(name, symbol, decimals)
token.write(token_address)
return ()
end
@external
func deposit{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}(amount : Uint256):
alloc_locals
with_attr error_message("Vault: Amount cannot be 0"):
let (is_amount_zero) = uint256_eq(amount, Uint256(0, 0))
assert is_amount_zero = FALSE
end
let (token_address) = token.read()
let (caller_address) = get_caller_address()
let (contract_address) = get_contract_address()
let amount_token : Uint256 = amount
let (supply_lp_token) = IERC20.totalSupply(contract_address)
let (balance_token) = IERC20.balanceOf(token_address, contract_address)
let (is_lp_token_supply_zero) = uint256_eq(supply_lp_token, Uint256(0, 0))
if is_lp_token_supply_zero == TRUE:
let amount_lp_token = amount_token
ERC20._mint(caller_address, amount_lp_token)
else:
let (res_mul, carry) = uint256_mul(amount_token, supply_lp_token)
let (amount_lp_token, remainder) = uint256_unsigned_div_rem(res_mul, balance_token)
ERC20._mint(caller_address, amount_lp_token)
end
IERC20.transferFrom(token_address, caller_address, contract_address, amount)
return ()
end
@external
func withdraw{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}(amount : Uint256):
alloc_locals
with_attr error_message("Vault: Amount cannot be 0"):
let (is_amount_zero) = uint256_eq(amount, Uint256(0, 0))
assert is_amount_zero = FALSE
end
let (token_address) = token.read()
let (caller_address) = get_caller_address()
let (contract_address) = get_contract_address()
let amount_lp_token = amount
let (supply_lp_token) = IERC20.totalSupply(contract_address)
let (balance_token) = IERC20.balanceOf(token_address, contract_address)
let (res_mul, carry) = uint256_mul(amount_lp_token, balance_token)
let (amount_token, remainder) = uint256_unsigned_div_rem(res_mul, supply_lp_token)
ERC20._burn(caller_address, amount_lp_token)
IERC20.transfer(token_address, caller_address, amount_token)
return ()
end
# ... Snip ...
We’re once again using alloc_local
to deal with revoked references.
We also check if the amount of LP-Tokens to burn is zero and throw an error if that’s the case.
Once we got a handle on the Token address, the caller address and the contract address we do the calculation to figure out how many Tokens to transfer to the user.
Right before we return from the function we burn the LP-Tokens and transfer the Tokens to the user’s account contract.
Note: The amount
parameter passed-into the function is the amount of LP-Tokens to burn.
Conclusion
And that wraps up our LP-Token implementation via Cairo in the form of a StarkNet Smart Contract.
Whenever a user deposits funds into this contract she will get LP-Tokens minted that act as a receipt that tracks the user’s asset ownership.
Once the user decides to unwind her positions she can use those LP-Tokens as a “Proof-of-Deposit” to get access to the deployed funds plus the fees that accrued proportional to her individual liquidity contribution.
References
The following resources have been invaluable for me to learn the concepts discussed in this article.
You should definitely give them a read if you want to dive deeper into the topic.