Skip to main content

Deposit and withdraw

This page tells an integrator how to deposit to and withdraw from LT. It gives the signatures, the calculation of debt, the preview functions, the error strings and the events.

LT.vy is ERC-4626-like, not ERC-4626-compliant:

  • It emits the ERC-4626 Deposit and Withdraw events.
  • Its deposit takes an explicit debt argument.
  • It does not implement convertToAssets, convertToShares, mint, redeem, previewMint or previewRedeem.
  • It implements IERC20.

LT allows ERC-20 transfers to and from the staker (LT.staker()). These transfers move value between the unstaked and staked buckets. Only three functions reject the staker:

  • deposit reverts if receiver is the staker.
  • withdraw reverts if the caller or receiver is the staker.
  • emergency_withdraw reverts if owner or receiver is the staker.

For ERC-4626 accounting, use the LiquidityGauge (the staker). On the gauges of markets 3 and later, previewMint is not callable. On the gauges of markets 0 to 2, it is callable.

For the onchain sequence of each call, see Action Flow: Deposit and Action Flow: Withdraw.

Deposit​

Signature​

def deposit(
assets: uint256,
debt: uint256,
min_shares: uint256,
receiver: address = msg.sender
) -> uint256
  • assets: amount of the volatile asset (for example WBTC) that you deposit.
  • debt: amount of crvUSD that the leveraged AMM (LEVAMM) lends to the deposit. LT adds [debt, assets] to the Curve pool. On a pool without an oracle policy, use the asset value: assets * pool.price_oracle() // 10**asset_decimals. On a pool that runs the oracle-report policy, debt must match the LEVAMM book ratio within ratio_tolerance. See Compute debt on a pool with an oracle policy.
  • min_shares: the minimum shares to receive. This protects against slippage and sandwich trades.
  • receiver: the address that gets the yb-LP shares. It must not be LT.staker(). For an atomic deposit and stake, use StakeZap.deposit_and_stake.

Compute debt on a pool without an oracle policy​

pool.price_oracle() is the Curve pool's internal exponential moving average (EMA) price, in crvUSD per whole asset with 18 decimals. LTMigrator uses this price only when it moves a position between two different Curve pools. On the same pool, it keeps the debt structure of the position. HybridVault.assets_for_crvusd() uses price_oracle only as a sizing ratio. CryptopoolLPOracle.price() returns the LP-token price in USD, not the asset price. Do not use it for debt.

debt = assets * pool.functions.price_oracle().call() // 10**asset_decimals

Example: an 18-decimal asset, assets = 1e18, and price_oracle() = 100_000e18 give debt = 100_000e18 crvUSD. The deposit adds both the asset and the borrowed crvUSD to the Curve pool. The borrowed leg is about equal to the asset value. The LEVAMM then holds LP worth about twice the debt (leverage 2).

This rule applies to these pools:

  • Pools with no policy hook (older Twocrypto code).
  • Pools that run a policy other than YBOraclizedPolicy.
  • A pool whose YBOraclizedPolicy is killed.

To see which policy a pool runs, see Contract addresses or call POLICY() on the pool.

Compute debt on a pool with an oracle policy​

A pool that runs YBOraclizedPolicy calls the policy during add_liquidity, unless the policy is killed. The policy checks each LT deposit before the LEVAMM books it (YBOraclizedPolicy._update_lt_gate):

added_debt   = growth of the pool's crvUSD balance       (= debt; the pool admin_fee is 0)
added_lp = growth of pool.user_supply() (= LP minted by add_liquidity)
proportional = added_lp * AMM.get_debt() // AMM.collateral_amount()

revert "LT deviation" if |added_debt - proportional| * 1e18 > proportional * ratio_tolerance

ratio_tolerance is at most 10 bp (1e15). To read it, call policy.config(). The check does not apply when the LEVAMM book is empty (collateral_amount() == 0). The check does not apply to withdrawals, swaps and donations.

A debt from the asset price misses the proportional debt by the LEVAMM's distance from 50% debt-to-value. For example, suppose that the LEVAMM debt-to-value is 50.175%, which is 0.35% above 50% in relative terms. A debt from the asset price is then about 35 bp below the proportional debt, and the deposit reverts.

LT.preview_deposit does not run this check. A debt that fails the check still returns a share estimate. The check also applies to HybridVault.deposit and LTMigrator, which call LT.deposit with a debt that they compute.

To compute debt:

  1. Read pool.POLICY(). If the call reverts (older Twocrypto code), use the price rule in the section before.
  2. If POLICY() is the zero address, use the price rule.
  3. If POLICY() is a YBTwocryptoPolicy, use the price rule. To identify it, compare the address with the table in Contract addresses. A YBTwocryptoPolicy has no is_killed() or FEED_ID() function, so these calls revert.
  4. Read policy.is_killed(). If it is True, use the price rule.
  5. Read amm.get_debt() and amm.collateral_amount().
  6. Solve debt = calc_token_amount([debt, assets], True) * get_debt() // collateral_amount() with the function that follows.
  7. Simulate lt.deposit(...) with eth_call in the most recent block.
  8. Send the transaction.
# Test on a mainnet fork before use.
def policy_debt(pool, amm, assets, asset_decimals):
book_debt = amm.functions.get_debt().call()
book_coll = amm.functions.collateral_amount().call()

def gap(d): # deposit debt minus proportional debt
lp = pool.functions.calc_token_amount([d, assets], True).call()
return d - lp * book_debt // book_coll

d0 = assets * pool.functions.price_scale().call() // 10**asset_decimals
d1 = d0 * 101 // 100
for _ in range(6): # secant steps; gap(d) is close to linear in d
g0, g1 = gap(d0), gap(d1)
if g1 == 0 or g1 == g0:
break
d0, d1 = d1, d1 - g1 * (d1 - d0) // (g1 - g0)
return d1

Interest accrues on get_debt() between the quote and the block of inclusion. The tolerance absorbs a short delay. If the deposit reverts with LT deviation, compute debt again in the most recent block.

Call pattern​

# 1. Compute debt (see the two sections before)
debt = deposit_debt(pool, amm, assets, asset_decimals) # defined in the Developer quickstart

# 2. Preview the shares (reverts "Debt too high" over the deposit cap)
shares_estimate = lt.functions.preview_deposit(assets, debt).call()
# With raise_overflow=False, preview_deposit skips the cap check and returns the share estimate.
# LTMigrator and HybridVault use this form.

# 3. Approve, simulate, deposit
min_shares = shares_estimate * 9_990 // 10_000
asset.functions.approve(lt.address, assets).transact({"from": user})
lt.functions.deposit(assets, debt, min_shares, receiver).call({"from": user})
shares = lt.functions.deposit(assets, debt, min_shares, receiver).transact({"from": user})

Error modes​

ErrorCause
"Deposit to staker"receiver == LT.staker(). Use another receiver, or use StakeZap.
"Debt too high"The LEVAMM value after the deposit exceeds AMM.max_debt() / 2. Deposit less, or wait for more crvUSD allocation.
"Unsafe min"After the deposit, LEVAMM debt is below 1/16 of the collateral value.
"Unsafe max"After the deposit, LEVAMM debt is above 8.5/16 of the collateral value.
"LT deviation"Pool runs YBOraclizedPolicy and the policy is not killed: debt differs from the proportional debt by more than ratio_tolerance. The pool policy raises it inside add_liquidity.
"Slippage"shares < min_shares. Quote again with preview_deposit and send again.
"Remainder too small"Total supply after the mint would be below MIN_SHARE_REMAINDER (1e6). Deposit more.
"!wl"The Curve pool LP allowlist is on and does not list the LT.
"slippage", "donation above cap!" (Curve pool)The borrower-fee donation at the end of the call failed. Try again later. See LT: distribute_borrower_fees.

Gas​

Gas use depends on the pool and the policy. Estimate it with eth_estimateGas or a simulation.

Withdraw​

Signature​

def withdraw(
shares: uint256,
min_assets: uint256,
receiver: address = msg.sender
) -> uint256

Returns the amount of the asset that receiver gets.

If the pool has an active oracle policy, the policy returns fallback_fee for every liquidity operation. LT.withdraw uses remove_liquidity_fixed_out, which charges fallback_fee / 2 × Sdiff / S of the LP amount, plus NOISE_FEE (0.1 bp). Sdiff / S measures the imbalance of the withdrawal: 0 when balanced, 1 for one coin. The native fee curve does not apply. preview_withdraw includes this fee.

Call pattern​

# If staked, unstake first (redeem gauge shares, not a computed asset amount)
staked = gauge.functions.balanceOf(user).call()
if staked > 0:
gauge.functions.redeem(staked, user, user).transact({"from": user})

# Quote the redemption (includes TRD)
expected_assets = lt.functions.preview_withdraw(shares).call()
min_assets = expected_assets * 9_990 // 10_000

# Withdraw with a slippage bound
assets = lt.functions.withdraw(shares, min_assets, receiver).transact({"from": user})

Error modes​

ErrorCause
"Withdrawing nothing"shares == 0.
"Withdraw to/from staker"msg.sender or receiver equals LT.staker(). Unstake from the gauge first.
"We're dead. Use emergency_withdraw"AMM.is_killed() == True. Use emergency_withdraw; repay the debt part from your wallet.
"Slippage"crypto_received < min_assets. Quote again and send again.
"Remainder too small"Before the burn, supply minus shares would be below MIN_SHARE_REMAINDER. Withdraw less, or withdraw all remaining shares.
"slippage", "donation above cap!" (Curve pool)The borrower-fee donation at the end of the call failed. Try again later. See LT: distribute_borrower_fees.

preview_withdraw and pricePerShare​

The two functions use different reference prices:

  • pricePerShare() returns v.total × 1e18 / v.supply_tokens from _calculate_values at the oracle price. On markets 3 to 10, the oracle price is price_scale times the crvUSD price. On markets 0 to 2, it is the pool's price_oracle times the crvUSD price. pricePerShare() is the fundamental value per share and does not include the temporary redemption discount (TRD). For a position, multiply by shares / 1e18.
  • preview_withdraw(shares) runs calc_withdraw_fixed_out against the live Curve pool state. It includes TRD.

TRD = preview_withdraw(1e18) / pricePerShare() − 1 for one share. For a position: preview_withdraw(shares) × 1e18 / (pricePerShare() × shares) − 1. TRD is negative during a discount and positive during a premium. See Temporary Redemption Discount.

Gas​

Gas use depends on the pool and the policy. Estimate it with eth_estimateGas or a simulation.

Staked positions​

To withdraw yb-LP that is staked in LiquidityGauge, use one of the two procedures.

With StakeZap:

  1. Approve StakeZap for the gauge shares.
  2. Call StakeZap.withdraw_and_unstake(gauge, shares, min_assets, receiver). shares is the number of gauge shares.

With two calls:

  1. Call gauge.redeem(shares, receiver, owner). The gauge sends yb-LP to receiver. Claim accrued rewards separately.
  2. Call LT.withdraw as described before.

Use redeem with the share balance. Do not call withdraw(previewRedeem(balance), …): withdraw rounds the burnt shares up and can revert.

Preview reliability​

The preview functions are view calls on the current state. In the same block and with the same state, preview_withdraw matches execution. Across blocks the state can change: price_scale moves, other users trade, deposit or withdraw.

preview_deposit does not check the oracle policy deposit ratio. Simulate deposit with eth_call before you send it.

For flows that are sensitive to maximal extractable value (MEV):

  1. Quote in the simulation.
  2. Set tight min_shares or min_assets bounds.
  3. Quote again if large trades on the pool are pending.

Events​

EventWhen
Deposit(sender indexed, owner indexed, assets, shares)Every deposit
Withdraw(sender indexed, receiver indexed, owner indexed, assets, shares)Every withdraw
Transfer(sender indexed, receiver indexed, value)ERC-20 transfers, mints and burns of yb-LP, and staked-bucket changes
DistributeBorrowerFees(sender indexed, amount, min_amount, discount)When LT donates borrower interest to the Curve pool