← Back to list

Liquidity Tree Performance using Stablecoins: Part 2

We show how Liquidity Trees improve market efficiency when compared to single pool AMMs

Ian Moore, PhD · 2025-01-17 22:47 · 50 claps · 9.0 min read
#defi #liquidity-trees #simulation #defi-strategy
Open on Medium ↗
Wiki topics: CRY · Crypto & Web3 ECO · Economy · General

Liquidity Tree Performance using Stablecoins: Part 2

  • We show how Liquidity Trees improve market efficiency when compared to single pool AMMs
  • See Part 1 for Stablecoin Trees simulation with more relaxed constraints
  • Here we assume the more realistic scenario of finite index token supply using finite state machines

1. Introduction

In Part 1 of my last article, we discussed Liquidity Tree Performance using Stablecoins under the assumption of an infinite supply of index tokens. If you are unfamiliar with our work, Liquidity Trees are a new class of DeFi primitives which we have defined as Liquidity Trees. These primitives serve as the core feature behind the soon-to-be released Pachira token supported by SYS Labs. In this article, we dive deeper into how Liquidity Trees improve performance.

Since there are some subtle nuances to consider when thinking about Liquidity Trees, for this discussion we will tightening one of the critical constraints over our previous article. Namely, assuming a finite supply of index tokens using a Simple Tree, which we handle using a finite state machine as we will be covering in this discussion.

To begin, in Fig 1 we outline the three main paths that one can consider when investing into a Liquidity Tree; which are as follows:

  • Path #1: single-sided deposit into parent pool and mint parent LP tokens (ie, the standard way of investing into a single AMM pool)
  • Path #2: single-sided deposit into child pool and mint child LP tokens
  • Path #3: single-sided deposit into parent pool and mint index tokens (ie, what we call a SwapIndexMint); next, perform a single-sided deposit of index tokens into child pool and mint tree LP tokens

Fig. 1 Investment paths into a Simple Liquidity Tree provides the option to mint parent, child or tree tokens; a Simple Tree is the simplest possible Liquidity Tree configuration

Fig. 1 Investment paths into a Simple Liquidity Tree provides the option to mint parent, child or tree tokens; a Simple Tree is the simplest possible Liquidity Tree configuration

The third path is the most interesting, as our investment is now making a return from both the parent and child liquidity pool (LP), thus generating higher yield. We are effectively investing into an LP, taking a fully collateralized loan from that investment and reinvesting it. Therefore earning yield from both our original investment and the fully collateralized loan. For the intuition on how tree tokens generate revenue and improve market efficiency, see Fig 2.

Fig 2. Boxes represent liquidity under constant product trading (CPT) curve; creating new market out of index liquidity is a way to address the stagnant liquidity problem; in short, we’ve leveraged some of the green, got red and made some extra purple

Fig 2. Boxes represent liquidity under constant product trading (CPT) curve; creating new market out of index liquidity is a way to address the stagnant liquidity problem; in short, we’ve leveraged some of the green, got red and made some extra purple

2. Simulate Asset Prices

First, we import our packages and simulate the market price of the USDC/USDT trading pair using a gamma distribution:

import scipy.stats as stats 
import statsmodels.api as sm
from uniswappy import *

# *************************
# *** Simulation
# *************************
n_sim_runs = 2000
shape = 2000
scale = 0.0005

p_arr = np.random.gamma(shape = shape, scale = scale, size = n_sim_runs)

As noted in the code block above, we imported the UniswapPy python package, which we will be utilizing for this discussion. For a description of all the classes used in this deployment, please refer to Part 1 of this discussion.

3. Setup Simple Tree

Next, we setup our Simple Tree using Uniswap V2:

dai1 = ERC20(usd_nm, "0x111")
tkn1 = ERC20(tkn_nm, "0x09")
exchg_data = UniswapExchangeData(tkn0 = tkn1, tkn1 = dai1, symbol="LP", address="0x011")

TKN_amt = TokenDeltaModel(tkn_delta_param)
TKN_amt_arb = TokenDeltaModel(100)

lp1_state = MarkovState(stochastic = True)
iVault1 = IndexVault('iVault1', "0x7")

factory = UniswapFactory(f"{tkn_nm} pool factory", "0x2")
lp = factory.deploy(exchg_data)
Join().apply(lp, user_nm, tkn_amount, dai_amount)

tkn2 = ERC20(tkn_nm, "0x09")
itkn1 = IndexERC20(itkn_nm, "0x09", tkn1, lp)
exchg_data1 = UniswapExchangeData(tkn0 = tkn2, tkn1 = itkn1, symbol="LP1", address="0x012")
lp1 = factory.deploy(exchg_data1)
JoinTree().apply(lp1, user_nm, iVault1, 10000)

# Re-balance LP price after JoinTree
SwapDeposit().apply(lp, dai1, user_nm, lp.reserve0-lp.reserve1)

lp.summary()
lp1.summary()
Exchange USDC-USDT (LP)
Reserves: USDC = 109999.99999999997, USDT = 109999.99999999999
Liquidity: 109987.05086970968 

Exchange USDC-iUSDC (LP1)
Reserves: USDC = 9972.071706380626, iUSDC = 4948.733400058262
Liquidity: 7024.893189304859 

Next, we take an investment position, and initialize the three investment paths outlined in Fig 1 via the following:


tkn_invest = 100
invested_user_nm = 'invested_user'

SwapIndexMint(iVault1, opposing = False).apply(lp, tkn1, invested_user_nm, tkn_invest)
mint_itkn1_deposit = iVault1.index_tokens[itkn_nm]['last_lp_deposit']
lp1_state.next_state(mint_itkn1_deposit)  
SwapDeposit().apply(lp1, itkn1, invested_user_nm, mint_itkn1_deposit)

lp.summary()
lp1.summary()

lp_invest_track  = lp.liquidity_providers[invested_user_nm]
lp1_invest_track  = lp1.liquidity_providers[invested_user_nm]

tkn_redeem_parent = RebaseIndexToken().apply(lp, tkn1, lp_invest_track)
itkn_redeem_child = RebaseIndexToken().apply(lp1, itkn1, lp1_invest_track)
tkn_redeem_tree = RebaseIndexToken().apply(lp, tkn1, itkn_redeem_child) 

print(f'{tkn_redeem_parent:.3f} USDC redeemed from {lp_invest_track:.3f} LP tokens if {tkn_invest:.1f} invested USDC immediately pulled from parent')
print(f'{tkn_redeem_tree:.3f} USDC redeemed from {lp1_invest_track:.3f} LP1 tokens if {tkn_invest:.1f} invested USDC immediately pulled from tree')
Exchange USDC-USDT (LP)
Reserves: USDC = 110099.99999999997, USDT = 109999.99999999999
Liquidity: 110036.95853986456 

Exchange USDC-iUSDC (LP1)
Reserves: USDC = 9972.071706380626, iUSDC = 4998.64107021314
Liquidity: 7060.174053870328 

99.700 USDC redeemed from 49.908 LP tokens if 100.0 invested USDC immediately pulled from parent
99.403 USDC redeemed from 35.281 LP1 tokens if 100.0 invested USDC immediately pulled from tree

We can see from the output above, that if we immediately redeem our tokens (after investing), we experience a small loss, which is attributed to the LP swap fees.

If we look at the output of the child LP above, it’s worth noting the reserve amounts of USDC compared to iUSDC. Without knowing how liquidity trees are implemented, one would logically assume that these reserve amounts should be roughly identical. However, this is not to ever be the case, because the token amount passed into the child are always denominated in terms of parent LP tokens, which is an internal feature that gets carried all throughout the tree. However, the amount presented to the user through the protocol is continually rebased in terms of USDC.

One of the initial questions we had regarding the Pachira system was, since the index tokens are unique to this system, what is the behavior in the event of an undersupply of index tokens under various edge-case scenarios? Also, would we have enough index tokens to facilitate services most of the time? To address these questions, we implemented a finite state machine, as shown in Fig. 3. This allowed us to monitor index token distribution at any given time in the simulation. We tested this distribution using Ethereum price data from January 2018 through October 2023.

Another challenge we encountered was keeping the reserve levels in the parent pool aligned with market prices. This required solving a system of nonlinear equations that gets updated at every price change in the market, ensuring that the reserve levels in the parent LP accurately reflect the market. This problem was addressed in a previous article of mine on how to simulate a Uniswap LP.

4. Finite State Machine

When this system is active, it is important to note that there will only every be a finite supply of index token. These index tokens are minted and burned through the Liquidity Tree system at at the discretion of the tree’s providers. Therefore this constraint must be factored into our model; to do so we applied a finite state machine to keep track of where all the index tokens at any given point in time during the simulation; see Fig 3.

Fig 3. Finite state machine used to determine distribution of index token for tree simulation; there are four states an index token can go through: (HOLD) where index tokens are held in non-custodial wallets; (MINT) issuance of new index token out of LP token; (VAULT) index token is being held within LP; and (BURN) index token is burned in exchange for its respective native token

Fig 3. Finite state machine used to determine distribution of index token for tree simulation; there are four states an index token can go through: (HOLD) where index tokens are held in non-custodial wallets; (MINT) issuance of new index token out of LP token; (VAULT) index token is being held within LP; and (BURN) index token is burned in exchange for its respective native token

For our finite state machine, assume we are given the following stochastic (Markov) matrix:

where each element is non-negative and each row sums to one. Each row of the m x n matrix can be regarded as a probability mass function over n possible outcomes. For instance, element pₘ,ₕ represents the probability of a token transitioning from being minted to being held between states. In Fig 3, we represent this Markov process as a directed graph with edges labelled by the transition probabilities in .

Let’s assume Xₜ is a Markov chain with stochastic matrix and the distribution of Xₜ to be ψₜ. Hence, if we let the initial distribution of ψ₀ = [1,0,0,0], the probability distribution of Xₜ can be determined via:

Hence, the estimated token supply at time t is given by the estimate:

where S₀ is the initial token supply (ie, S₀ = [1, 0, 0, 0]) . To simulate transition probabilities in Pₜ, we use the Beta distribution:

where m is the current state, n is the previous state and βₘ,ₙ = 1 - αₘ,ₙ. The parameters αₘ,ₙ and βₘ,ₙ are selected such that E{Pₜ} = . It is important to keep in mind that this is a stationary model, hence the expected values are invariant to time.

5. Simulate Simple Tree

The simulation comprises of a loop which steps through our market events where a number of actions are taken. These actions include: (a) using CorrectReserves and Arbitrageclasses to re-calibrate pool reserves to reflect market price, thus simulating arbitrage; (b) SwapIndexMint new index token; (c) update our state machine in accordance to our transition probability assumptions (see Fig 3); (d) remove burned tokens in accordance to our state machine output; (e) re-balance the child pool in accordance to our state machine output; (f) induce trade volume via random swapping in both parent and child pools with conservatively less trading in the child pool; and finally (g) investment performance data capture of the three investment paths outlined in Fig 1.

arb = CorrectReserves(lp, x0 = 1)
arb1 = Arbitrage(lp1, lp1_state) 

TKN_amt = TokenDeltaModel(tkn_delta_param)

lp_direct_invest_arr = []; lp1_direct_invest_arr = []; lp1_tree_invest_arr = []; 
pTKN_DAI_arr = []; pTKN_iTKN_arr = []
fee_lp_arr  = []; fee_lp1_arr  = [];

for k in range(n_sim_runs):

    #if(k % 100 == 0 and k != 0): print(f'Processing event {k}')

    # *****************************
    # ***** Parent Arbitrage ******
    # *****************************   
    arb.apply(p_arr[k])

    # *****************************
    # ***** Child Arbitrage ******
    # *****************************       
    amt_arb1 = TKN_amt_arb.delta()   
    arb1.apply(1, user_nm, amt_arb1)
    arb1.update_state(itkn1)    

    mint_tkn1_amt = 0.5*TKN_amt.delta()
    SwapIndexMint(iVault1, opposing = False).apply(lp, tkn1, user_nm, mint_tkn1_amt)
    mint_itkn1_deposit = iVault1.index_tokens[itkn_nm]['last_lp_deposit']
    lp1_state.next_state(mint_itkn1_deposit)   
    vault_lp1_amt = lp1_state.get_current_state('dVault')  
    burned_itkn1_amt = lp1_state.get_current_state('dBurned') 

    ## WithdrawSwap burned token from parent LP
    if(burned_itkn1_amt > 0):
        total_tkn_w_swap = LPQuote(False).get_amount_from_lp(lp, tkn1, burned_itkn1_amt)
        amt_out = RemoveLiquidity().apply(lp, tkn1, user_nm, total_tkn_w_swap/2)    

    ## Balance LP1: TKN/iTKN
    if(vault_lp1_amt > 0):
        # A portion of aquired token is coming from newly minted, while the remainder is coming from held 
        amt_tkn = LPQuote(False).get_amount_from_lp(lp, tkn1, vault_lp1_amt) 
        price_tkn = amt_tkn/vault_lp1_amt 
        AddLiquidity(price_tkn).apply(lp1, itkn1, user_nm, vault_lp1_amt)        
    elif(vault_lp1_amt < 0):
        # A portion of removed token is getting held, while the remainder is getting burned
        RemoveLiquidity().apply(lp1, itkn1, user_nm, abs(vault_lp1_amt))  

    # *****************************
    # ***** Random Swapping ******
    # *****************************       
    Swap().apply(lp, tkn1, user_nm, TKN_amt.delta()) 
    Swap().apply(lp, dai1, user_nm, TKN_amt.delta()) 

    # conservatively assume 10% of tokens held outside vault are traded
    held_tokens = lp1_state.get_current_state('Held')
    if(held_tokens > 0):
        tradable_itkn1_amt = 0.1*held_tokens
        Swap().apply(lp1, tkn2, user_nm, LPQuote(False).get_amount_from_lp(lp, tkn1, tradable_itkn1_amt))  
        Swap().apply(lp1, itkn1, user_nm, tradable_itkn1_amt)

    # *****************************
    # ******* Data Capture ********
    # *****************************

    # price
    pTKN_DAI_arr.append(LPQuote().get_price(lp, tkn1)) 
    pTKN_iTKN_arr.append(LPQuote().get_price(lp1, tkn1)) 

    # investment performance
    tkn_redeem_parent = RebaseIndexToken().apply(lp, tkn1, lp_invest_track)
    itkn_redeem_child = RebaseIndexToken().apply(lp1, itkn1, lp1_invest_track)
    tkn_redeem_tree = RebaseIndexToken().apply(lp, tkn1, itkn_redeem_child) 

    lp_direct_invest_arr.append(tkn_redeem_parent)
    lp1_direct_invest_arr.append(RebaseIndexToken().apply(lp1, tkn2, lp1_invest_track))
    lp1_tree_invest_arr.append(tkn_redeem_tree)

4. Review Performance Output

In Fig 4, we compare the raw performance of the parent token (green-line) vs the tree token (red-line):

Fig 4. Simple tree performances of the parent, child and tree tokens outlined via the three select investment paths using conservative assumptions

Fig 4. Simple tree performances of the parent, child and tree tokens outlined via the three select investment paths using conservative assumptions

Finally, we look at the kernel density estimates of the price response of USDC/USDT in the parent compared to USDC/iUSDC in the child in Fig 5. If we look at the longer tails in the distribution of the USDC/iUSDC price response, its clearly evident that the child is more volatile than the parent.

Fig 5. Distributions of USDC/USDT parent LP price and USDC/iUSDC child LP price; we can clearly see that the USDC/iUSDC prices are more volatile, which helps explain why the child pool consistently generates a higher return

Fig 5. Distributions of USDC/USDT parent LP price and USDC/iUSDC child LP price; we can clearly see that the USDC/iUSDC prices are more volatile, which helps explain why the child pool consistently generates a higher return

As discussed in a previous article, this has to do with how the price of index tokens in the child LPs respond to the price of the native tokens within the parent LP. When the reserve balances in the parent changes, this effect has an immediate impact on the price in the child due to the immediate change in supply of the index token (ie, iUSDC). This is where nuance (mentioned above) and the beauty of liquidity trees comes in. Compared to Part 1 of this discussion, we do see that this volatility has calmed down, but yet still higher than the parent.

5. Summary

In summary, we have taken a deep dive into a new class of DeFi primitives called Liquidity Trees, and we use stablecoins to control for impermanent loss. We also assumed a finite supply of index tokens and applied a finite state machine to account for this. In our next article we will be discussing the outcomes of these tree simulations through an on-chain Testnet setup.

We have found that these improvements, are invariant to the simulation settings. Hence, liquidity tree tokens consistently outperform the parent tokens. To better understand or to try other configurations, you can find the script to this presentation on the SYSLabs repos. If you want to stay up-to-date with this exciting project, you can visit the Pachira project website, or follow me on Twitter/X!

TextBook

If you enjoy my DeFi analytics content, you’ll love the official textbook:

📘 DeFiPy: Python SDK for On-Chain Analytics AMM math • Uniswap V2/V3 • Balancer • Stableswap • liquidity modeling • agents 👉 https://www.amazon.com/dp/B0G3RV5QRB


메타데이터
post_id
41b0c7446403
slug
liquidity-tree-performance-using-stablecoins-part-2-41b0c7446403
url
https://medium.com/@icmoore/liquidity-tree-performance-using-stablecoins-part-2-41b0c7446403
canonical_url
https://medium.com/@icmoore/liquidity-tree-performance-using-stablecoins-part-2-41b0c7446403
author_url
https://medium.com/@icmoore
status
ok
fetched_at
2026-06-14 11:28:49