Philipp Muens | pmuens.eth
Philipp Muens | pmuens.eth

Philipp Muens | pmuens.eth

How to implement LP-Tokens in Cairo

Photo by Or Hakim on Unsplash

How to implement LP-Tokens in Cairo

Write your own Smart Contract that implements LP-Tokens in Cairo

Philipp Muens | pmuens.eth's photo
Philipp Muens | pmuens.eth
ยทAug 30, 2022ยท

10 min read

Subscribe to my newsletter and never miss my upcoming articles

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:

$$ Amount_L = \frac{Amount_T \times Supply_L}{Balance_T} $$

\( Amount_L \) is the amount of LP-Tokens to mint, \( Amount_T \) is the amount of Tokens the user deposited, \( Supply_L \) is the total supply of LP-Tokens and \( Balance_T \) is the balance of Tokens already deposited in the Smart Contract.

Given that the \( Balance_T \) 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:

$$ Amount_L = Amount_T $$

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:

$$ Amount_T = \frac{Amount_L \times Balance_T}{Supply_L} $$

\( Amount_T \) is the Token amount to return, \( Amount_L \) is the LP-Token amount to burn, \( Balance_T \) is the Smart Contract's Token balance and \( Supply_L \) 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:

$$ Amount_L = \frac{Amount_T \times Supply_L}{Balance_T} $$

And if the Smart Contract doesn't hold any Tokens yet:

$$ Amount_L = Amount_T $$

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:

$$ Amount_T = \frac{Amount_L \times Balance_T}{Supply_L} $$

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.

Additional Resources

ย 
Share this