
How YieldBasis really works

Introduction
YieldBasis enables users to provide BTC as liquidity in an AMM pool without impermanent loss (IL), while still earning trading fees.
YieldBasis is a novel DeFi protocol that implements a new leveraged liquidity primitive designed by Michael Egorov.
In this article, we will focus on the implementation of the protocol. For a detailed explanation of mathematics at the root of the YB protocol, please refer to the documentation and whitepaper.
The idea
Liquidity providers deposit BTC (for simplicity, anything else can be used), while the protocol borrows an equal USD value in crvUSD, pairing both into the Curve BTC/crvUSD pool. This keeps the position at ~2× compounding leverage (50% debt-to-value), so its value tracks BTC 1:1 (no IL) while earning trading fees.
vs
For the classic AMM bonding curve, the price of liquidity is approximately the square root of the token price . Which will always lose to holding if price changes: .

To fight this, YieldBasis introduces the concept of compounding leverage, which is denoted as the number determining the fraction of debt relative to collateral size.
If one leverages a token at a price and keeps the loan value equal to (1), then the position will be leveraged at a constant . This makes the price of the whole position proportional to .
So the proposed solution is to square the square root 🙂. This is done by leveraging x2 the position in the pool, which makes the rate of position value change linear to the asset price.

As per the whitepaper x2 leverage also has a great property: we take debt equal to half of the liquidity, hence if leverage is kept constant, we will always have enough USD to close the position.
From this, we can already express APR: we receive twice the pool returns, because we borrow the same value as we initially provided (e.g. BTC), we pay for the borrowed amount, and we suffer from losses to keep the leverage constant:
Rebalancing the leverage
The special LEVAMM is designed to create an economic incentive for arbitrageurs to keep the leverage constant.
The LEVAMM tracks:
- - reserves of cryptopool (BTC/crvUSD) LP tokens;
- - debt of crvUSD;
- - price of a cryptopool LP in USD.
In principle, LEVAMM is a classic constant product with :
As stated above, the debt must be equal to half of the leveraged position. To get the correct amount, we must use a price oracle. In this case, the LEVAMM will always use the LP cryptopool price — .
Here and are “ideal” debt and collateral reserves, which means they are taken when the market price is equal to the oracle price .
From the property of constant-product AMM, we can get the definition of in terms of ideal values ( ):
It's hard to put a physical meaning behind , but if talking plainly, acts like the effective value in USD. Which makes sense, because we want LEVAMM to trade in (LP, USD) pairs, hence the price ratio will denote price of LP in USD.
We can derive for any values of and :
From which we can get the AMM value:
But how does this actually help with rebalancing?
The system itself offers us two sources of collateral:
- LEVAMM with price;
- Cryptopool with price.
We can determine if we can profit from trading on this pool by looking at the LEVAMM price :
1/2
When collateral grows in value (e.g. BTC price is rising), debt-to-value decreases, hence traders must add debt by selling collateral to the pool in exchange for USD. For this, LEVAMM will offer LP tokens for better price . (+debt, +collateral).
2/2
When collateral falls, LEVAMM becomes over-leveraged, hence arbitrageur must reduce debt by buying collateral from the pool by spending USD. For this, LEVAMM will offer USD for better price . (-debt, -collateral).
When trading towards either side, you change both numerator and denominator with the same sign. Here additional spread is created due to dynamic , which reacts to the oracle price.
💡Debt & rebalancing risk. The system holds debt against collateral and relies on rebalancing to keep DTV near 50%. In stressed conditions (flash moves, tight pool limits, high gas), rebalancing can slip, raising DTV and costs. The AMM’s safety band blocks unsafe states, and emergency exits require the caller to fund any stable shortfall—these limit protocol-level bad debt, but users can still incur losses from price moves and execution costs.
Fee split
The main yield comes from trading fees in the cryptopool, while some additional fees are generated in the LEVAMM.
💡Cryptopool (CurveV2 AMM) is a rebalancing AMM, which allocates half of its own revenue to the repegging mechanism. The other half goes to the LPs.
For users, YieldBasis offers two choices of accruing rewards:
- unstaked shares - earning trading fees;
- staked shares - earning YB token emissions.

The dynamic admin fee is adjusted based on staking participation:
Where:
- - total supply of shares
- - staked supply of shares
- - minimum admin fee
From the formula we can see that more users stake their shares, more fees DAO receives.
The code
The “heart” of the protocol is two contracts:
- LT —
LT.vy; - LEVAMM —
AMM.vy.
The LT.vy is an entry-point for liquidity provision into the YB protocol; it acts as an owner of the AMM.vy contract.
The LEVAMM is the rebalancing AMM, the _deposit() and _withdraw() are callable only by LT, while exchange() method is open to any arbitrager.

AMM
The LEVAMM contract is pretty straightforward; it works just like any other constant-product AMM with a few caveats.
The is essential for leverage AMM as it defines the curve and the value of the LEVAMM.
@internal
@view
def get_x0(p_oracle: uint256, collateral: uint256, debt: uint256, safe_limits: bool) -> uint256:
# Safe limits:
# debt >= 0
# debt <= coll_value * 10**18 // (4 * LEV_RATIO) ( == 9 / 16 * coll_value)
# debt in equilibrium = coll_value * (LEVERAGE - 1.0) / LEVERAGE ( == 1/2 * coll_value)
# When L=2, critical value of debt corresponds to p_amm = 9/16 * p_o
# Just in case, we limit between (1/16 .. 8.5/16) which is a somewaht tighter range
# p_o * y
coll_value: uint256 = p_oracle * collateral * COLLATERAL_PRECISION // 10**18
if safe_limits:
assert debt >= coll_value * MIN_SAFE_DEBT // 10**18, "Unsafe min"
assert debt <= coll_value * MAX_SAFE_DEBT // 10**18, "Unsafe max"
# per whitepaper:
# D = (p_o * y)^2 - 4 * p_o * y * d * (L / (2*L-1))^2
D: uint256 = coll_value**2 - 4 * coll_value * LEV_RATIO // 10**18 * debt
# (p_o * y + sqrt(D)) / (2 * (L / (2*L-1))^2)
return (coll_value + self.sqrt(D)) * 10**18 // (2 * LEV_RATIO)
Where LEV_RATIO is an immutable variable:
denominator: uint256 = 2 * leverage - 10**18
LEV_RATIO = leverage**2 * 10**18 // denominator**2
The safety limits are introduced so AMM cannot enter an unfavourable state, where it can become stuck by exchanges and interest charged on debt.
The minimum safe limit is just that we can't have negative debt. The maximum is derived from (4):
The subtle, but quite important nuance, if you look closely at the units in calculation, you may see that p_oracle * collateral is in USD units, while debt is in stablecoin units (not using crvUSD/USD oracle here). This means YB acts as an additional CDP for crvUSD, helping it keep the peg.
Debt
Stablecoins are allocated (from Curve directly) to the LEVAMM contract and then lent to create new cryptopool positions. The resulting debt accrues interest at a borrowing rate set by admin.
Accrued interest is collected and contributed to the cryptopool to fund rebalancing.
💡The new version of cryptopool has been developed along the YieldBasis protocol and supports donations to aid in rebalancing of the pool.
For details, see “Curve Cryptopool with Donations.”
@internal
@view
def _rate_mul() -> uint256:
"""
@notice Rate multiplier which is 1.0 + integral(rate, dt)
@return Rate multiplier in units where 1.0 == 1e18
"""
return unsafe_div(self.rate_mul * (10**18 + self.rate * (block.timestamp - self.rate_time)), 10**18)
@external
@view
def get_rate_mul() -> uint256:
"""
@notice Rate multiplier which is 1.0 + integral(rate, dt)
@return Rate multiplier in units where 1.0 == 1e18
"""
return self._rate_mul()
@internal
@view
def _debt() -> uint256:
return self.debt * self._rate_mul() // self.rate_mul
@internal
def _debt_w() -> uint256:
rate_mul: uint256 = self._rate_mul()
debt: uint256 = self.debt * rate_mul // self.rate_mul
self.rate_mul = rate_mul
self.rate_time = block.timestamp
return debt
The rate_mul factor represents interest that has already accrued up to the last update. The current debt is calculated by dividing self.rate_mul because the debt has already consisted of previous accruals.
On every function execution, we must query for the actual debt value, which includes the growth.
Deposit
The _deposit() function is plain and simple, calculates new values, and returns the pool value to the LT. Accessible only for LT.
@external
def _deposit(d_collateral: uint256, d_debt: uint256) -> OraclizedValue:
assert msg.sender == LT_CONTRACT, "Access violation"
assert not self.is_killed
p_o: uint256 = extcall PRICE_ORACLE_CONTRACT.price_w()
collateral: uint256 = self.collateral_amount # == y_initial
debt: uint256 = self._debt_w() # get debt with growth
debt += d_debt
collateral += d_collateral
self.minted += d_debt # calculates how much debt entered the system
self.debt = debt
self.collateral_amount = collateral
# Assume that transfer of collateral happened already (as a result of exchange)
value_after: uint256 = self.get_x0(p_o, collateral, debt, True) * 10**18 // (2 * LEVERAGE - 10**18) # Value in fiat
log AddLiquidityRaw(token_amounts=[d_collateral, d_debt], invariant=value_after, price_oracle=p_o)
return OraclizedValue(p_o=p_o, value=value_after)
Here, _deposit() does not enforce restrictions on inputs d_collateral and d_debt, but the resulting must abide by safety limits.
Withdraw
Calculate new debt and collateral values by provided fraction, return the difference. Accessible only for LT.
@external
def _withdraw(frac: uint256) -> Pair:
assert msg.sender == LT_CONTRACT, "Access violation"
collateral: uint256 = self.collateral_amount # == y_initial
debt: uint256 = self._debt_w() # get debt with growth
d_collateral: uint256 = collateral * frac // 10**18
d_debt: uint256 = math._ceil_div(debt * frac, 10**18) # avoid dust leftovers
self.collateral_amount -= d_collateral
self.debt = debt - d_debt
self.redeemed += d_debt # calculates how much debt has exited the system
log RemoveLiquidityRaw(collateral_change=d_collateral, debt_change=d_debt)
return Pair(collateral=d_collateral, debt=d_debt)
The LEVAMM allows only proportional withdrawals to avoid any unfair disadvantages for LPs (can’t take less debt, does not change debt-to-value). Generally speaking, frac here is user’s LT shares fraction.
Notice, how _withdraw() does not account for any of the fees, all related calculations are performed in the LT contact.
Exchange (keeping the leverage)
The center of the repegging algorithm is the exchange(), It implements a constant-product like swap mechanism, adapting to the specifics of the leveraging.
@external
@nonreentrant
def exchange(i: uint256, j: uint256, in_amount: uint256, min_out: uint256, _for: address = msg.sender) -> uint256:
"""
@notice Exchanges two coins, callable by anyone
@param i Index of input coin (0 = stablecoin, 1 = LP token collateral)
@param j Output coin index
@param in_amount Amount of input coin to swap
@param min_out Minimal amount to get as output
@param _for Address to send coins to
@return Amount of coins given in/out
"""
assert (i == 0 and j == 1) or (i == 1 and j == 0)
assert not self.is_killed
collateral: uint256 = self.collateral_amount # == y_initial
assert collateral > 0, "Empty AMM"
debt: uint256 = self._debt_w() # debt + new interest
p_o: uint256 = extcall PRICE_ORACLE_CONTRACT.price_w()
x0: uint256 = self.get_x0(p_o, collateral, debt, False) # x0 is also the pool value
x_initial: uint256 = x0 - debt
out_amount: uint256 = 0
# swap fees
fee: uint256 = self.fee
if i == 0: # Trader buys collateral from us
# -debt, -collateral
x: uint256 = x_initial + in_amount
y: uint256 = math._ceil_div(x_initial * collateral, x)
out_amount = (collateral - y) * (10**18 - fee) // 10**18
assert out_amount >= min_out, "Slippage"
debt -= in_amount
collateral -= out_amount
self.redeemed += in_amount # calculates how much debt exited the system
assert extcall STABLECOIN.transferFrom(msg.sender, self, in_amount, default_return_value=True)
assert extcall COLLATERAL.transfer(_for, out_amount, default_return_value=True)
else: # Trader sells collateral to us
# +debt, +collateral
y: uint256 = collateral + in_amount
x: uint256 = math._ceil_div(x_initial * collateral, y)
out_amount = (x_initial - x) * (10**18 - fee) // 10**18
assert out_amount >= min_out, "Slippage"
debt += out_amount
self.minted += out_amount # calculates how much debt entered the system
collateral = y
assert extcall COLLATERAL.transferFrom(msg.sender, self, in_amount, default_return_value=True)
assert extcall STABLECOIN.transfer(_for, out_amount, default_return_value=True)
# This call also will not allow to get too close to the untradable region
assert self.get_x0(p_o, collateral, debt, True) >= x0, "Bad final state"
# dont let the pool value decrease --^
self.collateral_amount = collateral
self.debt = debt
log TokenExchange(buyer=msg.sender, sold_id=i, tokens_sold=in_amount,
bought_id=j, tokens_bought=out_amount, fee=fee, price_oracle=p_o)
if LT_CONTRACT != empty(address) and LT_CONTRACT.is_contract:
# collect the fees charged as interest
self._collect_fees()
# distribute borrower fees to the cryptopool
extcall LT(LT_CONTRACT).distribute_borrower_fees()
return out_amount
The swap logic is something you might see anywhere else with a good old constant-product curve :
The swap does not allow for value to decrease and, just like in _deposit(), the protocol should enforce safety limits for the resulting .
Fees
In the code snippets above, you might see minted and redeemed variables:
mintedcaptures the debt before any interest is applied, is increased on every new debt entering the system —_deposit()andexchange();redeemedcaptures the debt after the interest is paid, is increased on every debt exiting the system -_withdraw()andexchange().
Those variables can show how much interest is already repaid:
LEVAMM also charges swap fees, which are not accounted for in the minted nor redeemed, which means they are scattered as additional value for LT shares holders.
The _collect_fees() is intended to retrieve exactly the debt interest:
@external
@view
def accumulated_interest() -> uint256:
"""
@notice Calculate the amount of fees obtained from the interest
"""
minted: uint256 = self.minted
return unsafe_sub(max(self._debt() + self.redeemed, minted), minted)
@internal
def _collect_fees() -> uint256:
"""
@notice Collect the fees charged as interest.
"""
assert not self.is_killed
debt: uint256 = self._debt_w() # new debt
self.debt = debt
minted: uint256 = self.minted
to_be_redeemed: uint256 = debt + self.redeemed
# Difference between to_be_redeemed and minted amount is exactly due to interest charged
# Everything we minted is either
# 1. minted_d + debt_increase = debt
# 2. minted_r + redeemed_increase = self.redeemed
# hence debt + self.redeemed - self.minted = increase
if to_be_redeemed > minted:
# we take out the interest, hence equalize variables
self.minted = to_be_redeemed
to_be_redeemed = unsafe_sub(to_be_redeemed, minted) # Now this is the fees to charge
stables_in_amm: uint256 = staticcall STABLECOIN.balanceOf(self)
# take out min(stables_in_amm, to_be_redeemed)
if stables_in_amm < to_be_redeemed:
self.minted -= (to_be_redeemed - stables_in_amm)
to_be_redeemed = stables_in_amm
assert extcall STABLECOIN.transfer(LT_CONTRACT, to_be_redeemed, default_return_value=True)
log CollectFees(amount=to_be_redeemed, new_supply=debt)
return to_be_redeemed
else:
log CollectFees(amount=0, new_supply=debt)
return 0
In rare cases, when there are not enough stablecoins to retrieve interest, LEVAMM allows for partial retrieval.
As stated in the idea section, those “fees” are used to refuel the cryptopool rebalancing mechanism.
LT
The LT contract is the market’s share token. Through the methods of this contract, users deposit and withdraw from the cryptopool. It is responsible for stablecoin and fee management.
Staker
The staker itself is a LiquidityGauge contract, which manages the rewards distribution for LT stakers. The staker interactions are cooked into regular transfers, mint/burn paths, so staking effects happen automatically during routine calls.
⚠️Users must interact with
LiquidityGaugecontract to correctly account for staked shares. Directly transferring shares to thestakeraddress will result in loss of funds.
@internal
def _transfer(_from: address, _to: address, _value: uint256):
assert _to not in [self, empty(address)]
staker: address = self.staker
staker_used: bool = (staker != empty(address) and staker in [_from, _to])
if staker_used:
...
self.balanceOf[_from] -= _value
self.balanceOf[_to] += _value
if staker_used and msg.sender != staker:
self._checkpoint_gauge()
log IERC20.Transfer(sender=_from, receiver=_to, value=_value)
If staker is not used, then this is just a regular token transfer of LT shares. Let's dive into the staker functionality.
if staker_used:
assert _from != _to
liquidity: LiquidityValuesOut = empty(LiquidityValuesOut)
# staker is LiquidityGauge with deposit and withdrawal functions
if msg.sender == staker or staticcall self.amm.is_killed():
# If sender is staker - it's mint/redeem/deposit/withdraw
# where we did checkpoint_staker_rebase() in the same tx
liquidity.ideal_staked = self.liquidity.ideal_staked
liquidity.staked = self.liquidity.staked
liquidity.total = self.liquidity.total
liquidity.supply_tokens = self.totalSupply
liquidity.staked_tokens = self.balanceOf[staker]
else:
liquidity = self._calculate_values(self._price_oracle_w())
self.liquidity.admin = liquidity.admin
self.liquidity.total = liquidity.total
self.totalSupply = liquidity.supply_tokens
self.balanceOf[staker] = liquidity.staked_tokens
self._log_token_reduction(staker, liquidity.token_reduction)
The LiquidityGauge offers an interface for user interactions via mint()/redeem()/deposit()/withdraw() functions, which update the state by calling LT.checkpoint_staker_rebase() (invokes _calculate_values()) so the state is always up-to-date. If amm.is_killed() then LT.checkpoint_staker_rebase() skips an update as intended.
The staked shares must not earn trading fees, but they must suffer losses (else everyone would stake during loss periods). For that reason, an ideal_staked is introduced, It is used to denote the maximum value of staked shares. The lost value will be replenished over time on new earnings. If there is no lost value then ideal_staked == staked.

if _from == staker:
# Reduce the staked part
# change by 0 if no supply_tokens or stake_tokens found
liquidity.staked -= unsafe_div(liquidity.total * _value, liquidity.supply_tokens)
liquidity.ideal_staked = unsafe_div(liquidity.ideal_staked * (liquidity.staked_tokens - _value), liquidity.staked_tokens)
elif _to == staker:
# Increase the staked part
d_staked_value: uint256 = liquidity.total * _value // liquidity.supply_tokens
liquidity.staked += d_staked_value
if liquidity.staked_tokens > 10**10:
liquidity.ideal_staked = liquidity.ideal_staked * (liquidity.staked_tokens + _value) // liquidity.staked_tokens
else:
# To exclude division by zero and numerical noise errors
liquidity.ideal_staked += d_staked_value
self.liquidity.staked = liquidity.staked
self.liquidity.ideal_staked = liquidity.ideal_staked
We increase the target value by adding the converted proportion of the total value, because the total may contain additional yield.
And decrease the target by the proportion of unstaked shares, because the staked maximum value is fixed and cannot increase.
_calculate_values()
The core function of the LT contract is the _calculate_values(p_o).
The function updates local state, based on LEVAMM changes, charges admin fees, and maintains the fee splitting invariant.
Some confusion might arise when looking at the price oracle variable, so we must clarify:
AMMuses as the price of an LP (BTC/crvUSD) to the USD;LTuses as the price of an asset (BTC) to the USD.
Let’s omit fee splitting-related logic for now and see what happens in code:
@internal
@view
def _calculate_values(p_o: uint256) -> LiquidityValuesOut:
# struct LiquidityValues:
# admin: int256
# total: uint256
# ideal_staked: uint256
# staked: uint256
prev: LiquidityValues = self.liquidity
staker: address = self.staker
staked: int256 = 0
if staker != empty(address):
staked = convert(self.balanceOf[self.staker], int256)
supply: int256 = convert(self.totalSupply, int256)
# staked is guaranteed to be <= supply
# calculating admin fee
f_a: int256 = convert(
10**18 - (10**18 - self._min_admin_fee()) * isqrt(convert(10**36 - staked * 10**36 // supply, uint256)) // 10**18,
int256)
cur_value: int256 = convert((staticcall self.amm.value_oracle()).value * 10**18 // p_o, int256)
prev_value: int256 = convert(prev.total, int256)
value_change: int256 = cur_value - (prev_value + prev.admin)
The (and other values in this context) denotes the LEVAMM value in depositable asset (e.g. BTC: ).
Note that value variables are int256, so value_change can be negative when losses occur (which is normal).
...
dv_use_36: int256 = ... # value delta
prev.admin += (value_change - dv_use_36 // 10**18)
new_total_value_36: int256 = max(prev_value * 10**18 + dv_use_36, 0)
...
return LiquidityValuesOut(
admin=prev.admin,
total=convert(new_total_value_36 // 10**18, uint256),
...
)
Pretty simple so far:
- Get current value from LEVAMM (
self.amm.value_oracle()). - Calculate the value change.
- Charge the admin fee on the value change.
- Calculate the new total value.
- Return an updated state.
Now, let’s dive into the fee-splitting logistics.
Per design, staked shares must not receive yield from trading, but rather from YB token (governance token) emissions. As stated before, staked shares must suffer losses.
# current staked value
v_st: int256 = convert(prev.staked, int256)
# maximum staked value
v_st_ideal: int256 = convert(prev.ideal_staked, int256)
dv_use_36: int256 = 0 # value delta
# v_st_ideal == v_st if no loss
v_st_loss: int256 = max(v_st_ideal - v_st, 0) # staked loss
if staked >= MIN_STAKED_FOR_FEES:
if value_change > 0:
# Admin fee is only charged once the loss if fully paid off
v_loss: int256 = min(value_change, v_st_loss * supply // staked)
# if v_loss = value_change, then we cant cover the loss yet
# hence dv_use_36 = v_loss * 10**18
# if there is no loss, then v_loss = 0
# hence dv_use_36 = value_change * (10**18 - f_a) - charging admin fees
dv_use_36 = v_loss * 10**18 + (value_change - v_loss) * (10**18 - f_a)
else:
# Admin doesn't pay for value loss
dv_use_36 = value_change * 10**18
else:
# If stakeda part is small - positive admin fees are charged on profits and negative on losses
dv_use_36 = value_change * (10**18 - f_a)
prev.admin += (value_change - dv_use_36 // 10**18)
If enough tokens are staked:
- If there is a profit, we don't charge admin fees until the loss is covered (if any).
- If there is a loss, then there is no loss for the admin.
On the contrary, if not enough is staked, the admin shares profit/loss.
The constant MIN_STAKED_FOR_FEES is set at 10**16, which represents a relatively small amount. Typically, the admin will not incur direct losses due to this logic. This approach is implemented to prevent divisions by small values in the calculation of v_st_loss * supply // staked.
If loss is persistent, users will socialize admin losses regardless, more on that in the Withdrawals section.
# dv_s is guaranteed to be <= dv_use
# if staked < supply (not exactly 100.0% staked) - dv_s is strictly < dv_use
# how much of delta is applicable to staked
dv_s_36: int256 = self.mul_div_signed(dv_use_36, staked, supply)
# reduce the staked value if negative, otherwise increase must be limited
if dv_use_36 > 0:
# the staked value (new_staked_value_36) can't surpass ideal
# we limit the change by max(v_st_ideal - v_st, 0)
dv_s_36 = min(dv_s_36, v_st_loss * 10**18)
# new_staked_value is guaranteed to be <= new_total_value
new_total_value_36: int256 = max(prev_value * 10**18 + dv_use_36, 0)
new_staked_value_36: int256 = max(v_st * 10**18 + dv_s_36, 0)
The code above implements restrictions on the staked value, which is enforced by the invariant staked_value <= staked_ideal. If the market has never suffered a loss, this would just enforce the said invariant; however, if a loss occurred beforehand, we must replenish it.
# Solution of:
# staked - token_reduction new_staked_value
# ------------------------- = -------------------
# supply - token_reduction new_total_value
#
# the result:
# new_total_value * staked - new_staked_value * supply
# token_reduction = ------------------------------------------------------
# new_total_value - new_staked_value
#
# When eps = (supply - staked) / supply << 1, it comes down to:
# token_reduction = value_change / total_value * (1.0 - min_admin_fee) / sqrt(eps) * supply
# So when eps < 1e-8 - we'll limit token_reduction
# If denominator is 0 -> token_reduction = 0 (not a revert)
token_reduction: int256 = new_total_value_36 - new_staked_value_36 # Denominator
token_reduction = self.mul_div_signed(new_total_value_36, staked, token_reduction) - self.mul_div_signed(new_staked_value_36, supply, token_reduction)
max_token_reduction: int256 = abs(value_change * supply // (prev_value + value_change + 1) * (10**18 - f_a) // SQRT_MIN_UNSTAKED_FRACTION)
# let's leave at least 1 LP token for staked and for total
if staked > 0:
token_reduction = min(token_reduction, staked - 1)
if supply > 0:
token_reduction = min(token_reduction, supply - 1)
# But most likely it's this condition to apply
if token_reduction >= 0:
token_reduction = min(token_reduction, max_token_reduction)
else:
token_reduction = max(token_reduction, -max_token_reduction)
# And don't allow negatives if denominator was too small
if new_total_value_36 - new_staked_value_36 < 10**4 * 10**18:
token_reduction = max(token_reduction, 0)
# Supply changes each time:
# value split reduces the amount of staked tokens (but not others),
# and this also reduces the supply of LP tokens
return LiquidityValuesOut(
admin=prev.admin,
total=convert(new_total_value_36 // 10**18, uint256),
ideal_staked=prev.ideal_staked,
staked=convert(new_staked_value_36 // 10**18, uint256),
staked_tokens=convert(staked - token_reduction, uint256),
supply_tokens=convert(supply - token_reduction, uint256),
token_reduction=token_reduction
)
The LP tokens of the YB market are value-accruing tokens; hence, every positive yield is shared among all the holders. But by the fee splitting design, the staked shares must not receive trading fees. This is solved by introducing token_reduction mechanic, which is used to reduce the amount of LP shares of a staker. Virtually, staker still receives a positive yield, but their shares are cut.
There is also a cap to the reduction in the form of max_token_reduction, which guards the protocol in extreme cases when almost or all user shares are staked.
To get a deeper look into the maximum token reduction, see Appendix.
The _calculate_values() plays a central part in the protocol and must be invoked before any state-utilizing action, which are:
- preview functions such as
preview_deposit(),preview_withdraw()andpreview_emergency_withdraw(); deposit()withdraw()andemergency_withdraw()pricePerShare()withdraw_admin_fees()- transfers if staking is involved
Those are pretty much all the functions the contract has 🙂.
deposit()
The deposit() function is the entry point for liquidity providers.
@external
@nonreentrant
def deposit(assets: uint256, debt: uint256, min_shares: uint256, receiver: address = msg.sender) -> uint256:
"""
...
"""
staker: address = self.staker
assert receiver != staker, "Deposit to staker"
amm: LevAMM = self.amm
# taking a loan of stablecoins
assert extcall STABLECOIN.transferFrom(amm.address, self, debt, default_return_value=True)
# transfer assets from user
assert extcall ASSET_TOKEN.transferFrom(msg.sender, self, assets, default_return_value=True)
# receive cryptopool LP
lp_tokens: uint256 = extcall CRYPTOPOOL.add_liquidity([debt, assets], 0, amm.address, False)
# query Asset price (e.g. BTC)
p_o: uint256 = self._price_oracle_w()
supply: uint256 = self.totalSupply
shares: uint256 = 0
liquidity_values: LiquidityValuesOut = empty(LiquidityValuesOut)
# update the state if not the first deposit
if supply > 0:
liquidity_values = self._calculate_values(p_o)
v: OraclizedValue = extcall amm._deposit(lp_tokens, debt)
value_after: uint256 = v.value * 10**18 // p_o
The interesting part here is that by depositing into the cryptopool, we may change the price of an asset, hence changing the value of the leverage LEMAMM. This is considered a yield for existing users.
# Value is measured in USD
# Do not allow value to become larger than HALF of the available stablecoins after the deposit
# If value becomes too large - we don't allow to deposit more to have a buffer when the price rises
assert staticcall amm.max_debt() // 2 >= v.value, "Debt too high"
where amm.max_debt() is STABLECOIN.balanceOf(self) + self._debt() - the LEVAMM stablecoin capacity. This check exists to safeguard the market from insolvency in a sudden collateral price shift. If the price rises, the protocol must be able to sell its collateral for stablecoin.
if supply > 0 and liquidity_values.total > 0:
supply = liquidity_values.supply_tokens
self.liquidity.admin = liquidity_values.admin
value_before: uint256 = liquidity_values.total
# new total value, must exclude admin fees
value_after = convert(convert(value_after, int256) - liquidity_values.admin, uint256)
self.liquidity.total = value_after
self.liquidity.staked = liquidity_values.staked
self.totalSupply = liquidity_values.supply_tokens # will be increased by mint
if staker != empty(address):
self.balanceOf[staker] = liquidity_values.staked_tokens
self._log_token_reduction(staker, liquidity_values.token_reduction)
# ideal_staked is only changed when we transfer coins to staker
# shares minted for user
# this also ensures that value cannot decrease, cuz shares are uint256
shares = supply * value_after // value_before - supply
This part is responsible for the user shares calculation and the total value adjustment. User receives shares by how much their deposit increased the total position value.
else:
# Initial value/shares ratio is EXACTLY 1.0 in collateral units
# Value is measured in USD
shares = value_after
# self.liquidity.admin is 0 at start but can be rolled over if everything was withdrawn
self.liquidity.ideal_staked = 0 # Likely already 0 since supply was 0
self.liquidity.staked = 0 # Same: nothing staked when supply is 0
self.liquidity.total = shares + supply # 1 share = 1 crypto at first deposit
self.liquidity.admin = 0 # if we had admin fees - give them to the first depositor; simpler to handle
if self.balanceOf[staker] > 0:
log IERC20.Transfer(sender=staker, receiver=empty(address), value=self.balanceOf[staker])
self.balanceOf[staker] = 0
We can enter this part of code if supply == 0 or liquidity_values.total == 0, which can happen for variety of precision losses. An inconspicuous self.liquidity.total = shares + supply tells us that sometimes we can have a non-zero supply with zero value.
assert shares + supply >= MIN_SHARE_REMAINDER, "Remainder too small"
assert shares >= min_shares, "Slippage"
The pool must have at least MIN_SHARE_REMAINDER amount of shares to avoid possible manipulations of pricePerShare(). Check out our audit report to get more context 🙂.
# mint actual tokens
self._mint(receiver, shares)
# Checkpoint a gauge if any to prevent flash loan attacks on reward distribution
self._checkpoint_gauge()
log Deposit(sender=msg.sender, owner=receiver, assets=assets, shares=shares)
# donate fees to cryptopool rebalancing if any
self._distribute_borrower_fees(FEE_CLAIM_DISCOUNT)
return shares
You may notice, that deposit() function does not enforce restrictions on neither assets nor debt input variables. Because the only invariant that plays a role here is value_after >= value_before, which means users can actually arbitrage LEVAMM via this function, and even assets = 0 is allowed :).
Withdrawals
There are two different methods:
- The
withdraw()is what should be used in normal circumstances. UsesCRYPTOPOOL.remove_liquidity_fixed_out()to receive the exact amount of stables to cover the debt. Cannot be used ifAMMis killed state. - The
emergency_withdraw()works when killed. UsesCRYPTOPOOL.remove_liquidity()to receive underlying assets in a balanced way. The user can provide additional stablecoins to cover the debt if the pool cannot return enough.
Let's have a look at the base withdraw() function first.
@external
@nonreentrant
def withdraw(shares: uint256, min_assets: uint256, receiver: address = msg.sender) -> uint256:
"""
...
"""
assert shares > 0, "Withdrawing nothing"
staker: address = self.staker
# this can break staker logic, better to restrict such behaviour
assert staker not in [msg.sender, receiver], "Withdraw to/from staker"
assert not (staticcall self.amm.is_killed()), "We're dead. Use emergency_withdraw"
amm: LevAMM = self.amm
# update local amm state
liquidity_values: LiquidityValuesOut = self._calculate_values(self._price_oracle_w())
supply: uint256 = liquidity_values.supply_tokens
self.liquidity.admin = liquidity_values.admin
# self.liquidity.total = liquidity_values.total no need to update since we will record this value later
self.liquidity.staked = liquidity_values.staked
self.totalSupply = supply
# leave at least MIN_SHARE_REMAINDER or withdraw everything
assert supply >= MIN_SHARE_REMAINDER + shares or supply == shares, "Remainder too small"
if staker != empty(address):
self.balanceOf[staker] = liquidity_values.staked_tokens
self._log_token_reduction(staker, liquidity_values.token_reduction)
Here withdraw() enforces the invariant, the market must have at least MIN_SHARE_REMAINDER shares or none at all.
admin_balance: uint256 = convert(max(liquidity_values.admin, 0), uint256)
withdrawn: Pair = extcall amm._withdraw(10**18 * liquidity_values.total // (liquidity_values.total + admin_balance) * shares // supply)
assert extcall CRYPTOPOOL.transferFrom(amm.address, self, withdrawn.collateral, default_return_value=True)
crypto_received: uint256 = extcall CRYPTOPOOL.remove_liquidity_fixed_out(withdrawn.collateral, 0, withdrawn.debt, 0)
The liquidity_values.total does not include admin fees, but total LEVAMM value does.
If there were no admin fees at all, then is what we would withdraw from the LEVAMM, but thats not the case here, so we must rescale the fraction with the .
The fraction that we withdraw from the AMM :
We don’t account for negative admin value .
If users want to withdraw from the market after some losses have occurred, they will socialize the admin losses too.
self._burn(msg.sender, shares) # Changes self.totalSupply
# proportionally decrease total value
self.liquidity.total = liquidity_values.total * (supply - shares) // supply
if liquidity_values.admin < 0:
# socializing here
# If admin fees are negative - we are skipping them, so reduce proportionally
self.liquidity.admin = liquidity_values.admin * convert(supply - shares, int256) // convert(supply, int256)
As said before, the emergency_withdraw() offers another withdrawal path for users. It can be called in killed state, as well as in normal.
@external
@nonreentrant
def emergency_withdraw(shares: uint256, receiver: address = msg.sender, owner: address = msg.sender) -> (uint256, int256):
"""
...
"""
staker: address = self.staker
assert staker not in [owner, receiver], "Withdraw to/from staker"
supply: uint256 = 0
lv: LiquidityValuesOut = empty(LiquidityValuesOut)
amm: LevAMM = self.amm
killed: bool = staticcall amm.is_killed()
if killed:
# If killed:
admin: address = self.admin
if admin.is_contract:
if msg.sender == staticcall Factory(admin).emergency_admin():
# Only emergency admin is allowed to withdraw for others, but then only transfer to themselves
assert receiver == owner, "receiver"
else:
# If sender is a different smart contract - it must be the owner
assert owner == msg.sender, "owner"
else:
if msg.sender == admin:
# If admin is EOA - it can withdraw for receivers when killed, but only send to themselves
assert receiver == owner, "receiver"
else:
# If EOA caller is not admin - it must be the owner
assert owner == msg.sender, "owner"
# do not update amm state if is paused
supply = self.totalSupply
else:
# If not killed - only owner is allowed to work with their LP
assert owner == msg.sender, "Not killed"
lv = self._calculate_values(self._price_oracle_w())
supply = lv.supply_tokens
self.liquidity.admin = lv.admin
self.liquidity.total = lv.total
self.liquidity.staked = lv.staked
self.totalSupply = supply
if staker != empty(address):
self.balanceOf[staker] = lv.staked_tokens
self._log_token_reduction(staker, lv.token_reduction)
Here, YieldBasis introduces an unusual decision to allow DAO withdrawals of user positions during the killed state. This is an additional safety measure, allowing rescue of funds by the DAO itself, if swift action is required. It is somehow convenient for users as DAO will cover the required debt.
Another difference in emergency withdrawal is the lack of state updates (_calculate_values() calls) if LEVAMM is killed.
# leave at least MIN_SHARE_REMAINDER or withdraw everything
assert supply >= MIN_SHARE_REMAINDER + shares or supply == shares, "Remainder too small"
frac: uint256 = 10**18 * shares // supply
frac_clean: int256 = convert(frac, int256)
# if admin fees are positive, then rescale amm fraction
if lv.admin > 0 and lv.total != 0:
frac = frac * lv.total // (convert(lv.admin, uint256) + lv.total)
withdrawn_levamm: Pair = extcall amm._withdraw(frac)
The logic of LEVAMM withdrawal is the same for both LT withdrawal methods: leave some value for admin fees, socialize losses otherwise.
# transfer LP tokens from AMM
assert extcall CRYPTOPOOL.transferFrom(amm.address, self, withdrawn_levamm.collateral, default_return_value=True)
# withdraw LP tokens in a proportional way from the cryptopool
withdrawn_cswap: uint256[2] = extcall CRYPTOPOOL.remove_liquidity(withdrawn_levamm.collateral, [0, 0])
# remove_liquidity does not enforce required amount of stable return
# the difference of actual stablecoins received from the debt owed to the amm
stables_to_return: int256 = convert(withdrawn_cswap[0], int256) - convert(withdrawn_levamm.debt, int256)
self._burn(owner, shares)
# decreasing total value and socializing losses
self.liquidity.total = self.liquidity.total * (supply - shares) // supply
if self.liquidity.admin < 0 or killed:
self.liquidity.admin = self.liquidity.admin * (10**18 - frac_clean) // 10**18
# cryptopool returned more stables than debt owed
if stables_to_return > 0:
assert extcall STABLECOIN.transfer(receiver, convert(stables_to_return, uint256), default_return_value=True)
# not enough stables to cover the debt, user must provide additional value
elif stables_to_return < 0:
assert extcall STABLECOIN.transferFrom(msg.sender, self, convert(-stables_to_return, uint256), default_return_value=True)
assert extcall STABLECOIN.transfer(amm.address, withdrawn_levamm.debt, default_return_value=True)
assert extcall ASSET_TOKEN.transfer(receiver, withdrawn_cswap[1], default_return_value=True)
if not killed:
self._checkpoint_gauge()
return (withdrawn_cswap[1], stables_to_return)
The main difference in withdrawal methods is the interactions with the underlying cryptopool.
The regular withdraw() enforces the stablecoin amount withdrawn from the cryptopool via remove_liquidity_fixed_out(), which might reduce the assets received by the user, but we will always cover the debt. Caveat is, remove_liquidity_fixed_out will not always work for reasons related to the cryptopool implementation ( D and y newton calculations, for example).
The remove_liquidity() on the other hand is more reliable, as it returns both coins in proportion and does not require additional calculations within the pool. Sometimes, returned stablecoins are not enough to cover the debt; for that reason emergency_withdraw() allows users providing additional amount.
allocate_stablecoins()
This method is required for stablecoin allocations to the LEVAMM contract, which later used to give out loans to users.
@external
@nonreentrant
def allocate_stablecoins(limit: uint256 = max_value(uint256)):
"""
@notice This method has to be used once this contract has received allocation of stablecoins
@param limit Limit to allocate for this pool from this allocator. Max uint256 = do not change
"""
allocator: address = self.admin
allocation: uint256 = limit
# how much is already allocated to the amm
allocated: uint256 = self.stablecoin_allocated
if limit == max_value(uint256):
# do not change the target
# this execution path is not guarded by role
allocation = self.stablecoin_allocation
else:
self._check_admin()
# the allocation target to new limit
self.stablecoin_allocation = limit
extcall self.amm.check_nonreentrant()
if allocation > allocated:
# Assume that allocator has everything
assert extcall STABLECOIN.transferFrom(allocator, self.amm.address, allocation - allocated, default_return_value=True)
self.stablecoin_allocated = allocation
elif allocation < allocated:
# cannot retrieve stables, if target amount does not cover whole amm position
# this check is similar to one in deposit() function
lp_price: uint256 = extcall (staticcall self.amm.PRICE_ORACLE_CONTRACT()).price_w()
assert allocation >= lp_price * (staticcall self.amm.collateral_amount()) // 10**18, "Not enough stables"
# cant transfer what one does not have ¯\_(ツ)_/¯
to_transfer: uint256 = min(allocated - allocation, staticcall STABLECOIN.balanceOf(self.amm.address))
allocated -= to_transfer
assert extcall STABLECOIN.transferFrom(self.amm.address, allocator, to_transfer, default_return_value=True)
self.stablecoin_allocated = allocated
log AllocateStablecoins(allocator=allocator, stablecoin_allocation=allocation, stablecoin_allocated=allocated)
An allocator can either provide new funds to the LEVAMM or retrieve previously added stables.
This function ensures that if the limit is lowered, then the LEVAMM contract will have the required amount of stablecoins to keep operations running.
The second execution path allows any user to trigger the stablecoin retrieval. This can happen if the LEVAMM contract has acquired new stablecoins via exchanges or withdrawals.
withdraw_admin_fees()
It is always interesting how protocol manages fees, especially if the admin is DAO :).
@external
@nonreentrant
def withdraw_admin_fees():
"""
@notice Withdraw admin fees and distribute to the DAO
"""
admin: address = self.admin
assert admin.is_contract, "Need factory"
assert not staticcall self.amm.is_killed(), "Killed"
extcall self.amm.check_nonreentrant()
# no fee_receiver is LT state
fee_receiver: address = staticcall Factory(admin).fee_receiver()
assert fee_receiver != empty(address), "No fee_receiver"
# cannot mint shares to staker without logic contained in _transfer
staker: address = self.staker
assert fee_receiver != staker, "Staker=fee_receiver"
v: LiquidityValuesOut = self._calculate_values(self._price_oracle_w())
assert v.admin >= 0, "Loss made admin fee negative"
self.totalSupply = v.supply_tokens
# Mint YB tokens to fee receiver and burn the untokenized admin buffer at the same time
# fee_receiver is just a normal user
new_total: uint256 = v.total + convert(v.admin, uint256)
to_mint: uint256 = v.supply_tokens * new_total // v.total - v.supply_tokens
self._mint(fee_receiver, to_mint)
self.liquidity.total = new_total
self.liquidity.admin = 0
self.liquidity.staked = v.staked
if staker != empty(address):
self.balanceOf[staker] = v.staked_tokens
self._log_token_reduction(staker, v.token_reduction)
log WithdrawAdminFees(receiver=fee_receiver, amount=to_mint)
The minting logic is the same as in deposit() methods, imagine admin value is a deposit, and tokens are minted proportionally to the value growth.
pricePerShare()
This function can display the cost of our shares in USD.
@external
@view
def pricePerShare() -> uint256:
"""
Non-manipulatable "fair price per share" oracle
"""
v: LiquidityValuesOut = self._calculate_values(self._price_oracle())
if v.supply_tokens == 0:
return 10**18
else:
return v.total * 10**18 // v.supply_tokens
The docstring says this price is non-manipulatable:
- Uses the EMA price oracle of the cryptopool, which in itself is very resilient to sudden price movements.
- One can try to manipulate the value or shares, but on deposit or withdrawal, those variables are proportional; hence, the price won't change. Alternatively, an attacker could try using
amm.exchange()to inflate the value, without touching the supply, which is not economically feasible, while also considering the safety measures we discussed earlier.
_price_oracle()
Since the deployment, the protocol has migrated to the newer version with a few tweaks. One notable change is in the price oracle used throughout the LT contract.
@internal
@view
def _price_oracle() -> uint256:
return staticcall CRYPTOPOOL.price_scale() * staticcall self.agg.price() // 10**18
LEVAMM will always use the cryptopool EMA
The previous version used the price_oracle() to fetch the price of an underlying cryptopool.
Where:
price_oracle()- ema of a cryptopool price.price_scale()- cryptopool liquidity concentration price.
Simulations run by Egorov has showed that price_scale() oracle gives better results, which makes sense, since price_scale() represents the current effective center of liquidity and valuation.
From a security perspective, this sounds good too, as price_scale() follows the price_oracle() in a bounded fashion.
💡For details, see “Curve Cryptopool with Donations.”
The future
The LLamaRisk research has shown that the introduction of YB into the ecosystem has increased stress on the crvUSD PegKeepers.
The new (in development) HybridVaults aim to enhance the scaling capabilities of the YB protocol by enabling users to enter markets through their contribution to the crvUSD peg.
The logic behind HybridVaults is somewhat simple and nicely explained in yeildbasis X post.
Conclusion
YieldBasis offers innovation in liquidity providing, delivering hold-like exposure that also earns fees. The YieldBasis can be considered a battle-tested protocol, and the team is clearly focused on improving stability and yield.
It’s a thoughtfully engineered system, and it’s worth digging into the mechanics to appreciate the design and its consequences. The protocol is well-documented, but we wanted to contribute to the community by highlighting the elegance of its smart contracts and protocol engineering.
Reference
- https://github.com/yield-basis/yb-core
- https://github.com/yield-basis/yb-paper
- https://docs.yieldbasis.com/
- https://github.com/statemindio/public-audits/tree/main/Yield Basis
Appendix
A deeper look into the maximum token reduction
Let's have a closer look at what can happen in edge cases:
If , then we will have
which will nuke all existing shares, breaking the market (considering ). To prevent such misfortune, the cap was introduced in the form of the following formula:
Let's digest how this formula limits the reduction impact. Have a look at the original formula:
where:
To limit the effect, we put an upper bound on the numerator and a lower bound on the denominator.
Because token reduction enforces the proportion , after the last token reduction, we have useful equality , then for the numerator:
From here, we can set an upper bound:
And a lower bound for the denominator:
But, actually, the square root of is used, which makes lower-bound a bit larger in practice, which makes the lower bound shrink slower to get even more restricted maximum reduction.
Another safety measure is using instead of dynamic , by this we set an absolute minimum of the denominator, enabling a hard limit of reduction.

