Skip to main content

Rebalance flow

The leveraged AMM (LEVAMM) does not rebalance itself. It quotes prices from its invariant. When an arbitrageur finds a quote profitable against the Curve pool, the trade moves the LEVAMM state back toward leverage 2 (L=2L = 2). Use this trace to see which price inputs open a trade, follow both trade directions, and diagnose a revert.

The two prices​

The LEVAMM holds Curve liquidity provider (LP) tokens as collateral (yy) and owes crvUSD as debt (dd). It quotes the price get_p() = (x0 − d) / y, scaled to collateral precision. x0 is the anchor term of the LEVAMM pricing formula, a virtual balance that keeps the leverage. AMM.get_x0 computes x0 from the oracle price (pop_o), yy, and dd. For the derivation of x0x_0, read Eq. (3.8) in the Math primer.

When get_p() differs from the LP price that the Curve pool implies, an arbitrageur can trade the gap. The arbitrageur calls VirtualPool.exchange. VirtualPool is the contract that wraps the LEVAMM, the Curve pool, and a crvUSD flash loan into one swap. The VirtualPool flash-borrows crvUSD and trades through the LEVAMM in one direction. It then adds to or removes from the Curve pool in the other direction. The profit is the gap minus fees and gas.

What moves the oracle price​

The LEVAMM reads pop_o from CryptopoolLPOracle, which computes 2 · virtual_price · √price_scale · agg. Three inputs change pop_o:

  • The Curve pool changes price_scale.
  • virtual_price of the pool changes.
  • The crvUSD aggregator price changes.

A move in the market price of the asset does not change pop_o until the pool moves price_scale. For figures of what moves price_scale and what reads it, read Price computation in CryptopoolLPOracle.

On markets 7 to 10, only the first operation in a block that calls tweak_price can move price_scale. That operation moves it only if it passes the profit test (vp_boosted > lp_xcp_profit). The target of the move depends on the policy:

  • Without a policy, the target is price_oracle, the internal exponential moving average (EMA) of the pool.
  • With a policy (pool.POLICY()), the target comes from the policy, and the pool still limits the target and the step.

The pools of markets 0 to 6 run older pool code without a policy hook. To see which policy a pool runs, call POLICY() on the pool or read the table in Contract addresses. For the target and the step of each policy, read Pool policy: get_price_scale.

On a pool that runs the oracle-report policy (YBOraclizedPolicy), a delivered price report moves the target. The target moves price_scale, price_scale moves pop_o, and the changed pop_o can open a VirtualPool trade. To trade that gap with a price report, follow Oracle report arbitrage.

Prerequisites​

Before you call VirtualPool.exchange, complete these items:

  • Approve the VirtualPool for in_amount of the input coin: crvUSD in the buy direction, or the asset in the sell direction. The VirtualPool pulls in_amount from the caller in step 1.
  • Set min_out from a VirtualPool.get_dy quote minus your tolerance. get_dy does not include the Curve pool fee or a policy fee. For how to size and price a trade against the LEVAMM, read Arbitrage.

Steps​

Each direction is one VirtualPool.exchange call with one crvUSD flash loan. The trade logic is in VirtualPool.exchange and VirtualPool.onFlashLoan.

crvUSD → asset (buy direction)​

The following figure shows the calls of the buy direction, where crvUSD goes in and the asset comes out:

Sequence of calls in the buy direction: the arbitrageur sends crvUSD to the VirtualPool, which takes a crvUSD flash loan, buys LP tokens from the LEVAMM with about in_amount plus the flash amount (the LEVAMM then collects fees and calls LT.distribute_borrower_fees), removes the LP from the Curve pool for crvUSD and the asset (a pool with a policy calls update_pool_state on a best-effort basis), repays the lender with its whole crvUSD balance, and sends the asset to the arbitrageur.Sequence of calls in the buy direction: the arbitrageur sends crvUSD to the VirtualPool, which takes a crvUSD flash loan, buys LP tokens from the LEVAMM with about in_amount plus the flash amount (the LEVAMM then collects fees and calls LT.distribute_borrower_fees), removes the LP from the Curve pool for crvUSD and the asset (a pool with a policy calls update_pool_state on a best-effort basis), repays the lender with its whole crvUSD balance, and sends the asset to the arbitrageur.

The buy direction runs these steps in order:

  1. The arbitrageur calls VirtualPool.exchange(0, 1, in_amount, min_out). The VirtualPool pulls in_amount crvUSD from the caller.
  2. The VirtualPool takes a flash loan of maxFlashLoan crvUSD from Factory.flash(). The lender calls back VirtualPool.onFlashLoan.
  3. In the callback, the VirtualPool computes flash_amount and calls AMM.exchange(0, 1, ...) with in_amount * (10**18 - ROUNDING_DISCOUNT) // 10**18 + flash_amount and min_out = 0. The LEVAMM sends LP tokens for that crvUSD and checks its invariant. It then runs _collect_fees() and LT.distribute_borrower_fees().
  4. The VirtualPool removes the LP from the Curve pool with remove_liquidity(lp_amount, [0, 0]) and receives crvUSD and the asset. On a v3.0.0 pool with a non-zero POLICY(), the pool calls update_pool_state on the policy.
  5. The VirtualPool repays the lender with its whole crvUSD balance.
  6. Back in exchange, the VirtualPool checks its asset balance against min_out. It sends the asset to _for and emits TokenExchange. _for defaults to the caller.

asset → crvUSD (sell direction)​

The following figure shows the calls of the sell direction, where the asset goes in and crvUSD comes out:

Sequence of calls in the sell direction: the arbitrageur sends the asset to the VirtualPool, which takes a crvUSD flash loan, adds the flash crvUSD and the asset to the Curve pool for LP tokens (a pool with a policy calls update_pool_state, which must succeed), sells the LP to the LEVAMM for crvUSD (the LEVAMM then collects fees and calls LT.distribute_borrower_fees), repays the whole loan (maxFlashLoan) to the lender, and sends the remaining crvUSD to the arbitrageur.Sequence of calls in the sell direction: the arbitrageur sends the asset to the VirtualPool, which takes a crvUSD flash loan, adds the flash crvUSD and the asset to the Curve pool for LP tokens (a pool with a policy calls update_pool_state, which must succeed), sells the LP to the LEVAMM for crvUSD (the LEVAMM then collects fees and calls LT.distribute_borrower_fees), repays the whole loan (maxFlashLoan) to the lender, and sends the remaining crvUSD to the arbitrageur.

The sell direction runs these steps in order:

  1. The arbitrageur calls VirtualPool.exchange(1, 0, in_amount, min_out). The VirtualPool pulls in_amount of the asset from the caller.
  2. As in the buy direction, the VirtualPool takes the flash loan, and the lender calls back onFlashLoan.
  3. In the callback, the VirtualPool sizes flash_amount from the pool balances, in_amount × balances(0) / balances(1). It adds [flash_amount, in_amount] to the Curve pool with add_liquidity(..., 0). On a v3.0.0 pool with a non-zero POLICY(), the pool calls update_pool_state, and the call must succeed.
  4. The VirtualPool sells the LP to the LEVAMM with AMM.exchange(1, 0, lp_amount, 0). The LEVAMM checks its invariant, then runs _collect_fees() and LT.distribute_borrower_fees().
  5. The VirtualPool repays the whole loan (maxFlashLoan) to the lender.
  6. Back in exchange, the VirtualPool checks its crvUSD balance against min_out. It sends the crvUSD to _for and emits TokenExchange.

What the trade changes on the LEVAMM​

The AMM.exchange call updates this stored state of the LEVAMM:

  • collateral_amount (y) increases in the sell direction, where LP tokens come in, and decreases in the buy direction.
  • debt decreases in the buy direction, where crvUSD comes in, and increases in the sell direction. _debt_w() accrues interest on debt before the change.
  • The LEVAMM takes a fee from the output (out = raw_out × (1 − fee) / 1e18). The fee stays in the LEVAMM, so x0 after the trade is not smaller than x0 before.

The invariant check at the end of AMM.exchange enforces this rule. The contract source of AMM.exchange has this check:

assert self.get_x0(p_o, collateral, debt, check_state) >= x0, "Bad final state"

The check rejects a trade that reduces x0, so the fee stays in the LEVAMM.

Why this drifts toward L=2L = 2​

At equilibrium, collateral value equals twice the debt (po⋅y⋅COLLATERAL_PRECISION=2⋅dp_o \cdot y \cdot \text{COLLATERAL\_PRECISION} = 2 \cdot d in 1e18 units). The AMM tracks deviation as coll_vs_debt = p_o × y / d. On markets 7 to 10, AMM.exchange applies the safe-bounds check asymmetrically:

  • If the trade moves coll_vs_debt toward 2, the safe-bounds check is off (check_state = False).
  • If the trade moves it away, the safe-bounds check runs.

On markets 0 to 6, the LEVAMM checks both limits on every trade; their exchange() has no check_state logic.

Arbitrageurs trade only when the gap between the quote and the Curve pool is larger than their costs. The gap opens when pop_o has moved and the LEVAMM is off equilibrium. The trades that pay are the trades that reduce the offset, so arbitrage, not the LEVAMM, moves the state back toward L=2L = 2.

Post-trade hooks​

If LT_CONTRACT is a non-zero contract address, the exchange ends with two calls: _collect_fees() and LT.distribute_borrower_fees(). On every LEVAMM exchange, accrued interest flows from the crvUSD balance of the LEVAMM into the accounting of LT. The caller cannot skip this step.

Each _collect_fees() call accrues borrow interest on the LEVAMM debt and sends it to LT. For the rate multiplier and how the admin sets the rate, read get_rate_mul() and set_rate() in the LEVAMM reference.

LT.distribute_borrower_fees() then donates the crvUSD that LT holds to the Curve pool. This donation is refueling: it funds rebalancing, and LPs get no separate return from it. For the economic framing, read Refueling with borrower interest in Fees and veYB revenue. For the call, its discount, and its reverts, read distribute_borrower_fees() in the LT reference.

Reverts by step​

A rebalance trade can revert at these steps. The Step column gives the step of the buy direction, then the step of the sell direction. For the full list of strings, read Error codes.

Step (buy, sell)ConditionBehavior
1, 1(i, j) is not (0, 1) or (1, 0)VirtualPool.exchange reverts without a message
2, 2VirtualPool flash loan not set on FactoryflashLoan call reverts
3, 4The LEVAMM is killedAMM.exchange reverts without a message
3, 4The LEVAMM is empty (collateral_amount = 0)Reverts "Empty AMM"
3, 4Resulting x0 < prior x0Reverts "Bad final state"
3, 4coll_vs_debt outside safe bounds: on markets 7 to 10, only when moving away from L=2; on markets 0 to 6, on every tradeReverts "Unsafe min" / "Unsafe max"
3, 4Markets 7 to 10: the borrower-interest donation after the LEVAMM swap fails in the Curve poolReverts "slippage" or "donation above cap!" (from the Curve pool). The donation runs only when LT holds crvUSD
4, –Markets 7 to 10: pool policy callback fails on a balanced removal, and less than 500,000 gas was left before the callThe Curve pool reverts without a message
–, 3Markets 7 to 10: pool policy callback reverts on add_liquidityThe Curve pool reverts with the error of the policy
–, 3Markets 7 to 10: the pool LP allowlist is on and the VirtualPool is not listedReverts "!wl" (from the Curve pool)
6, 6min_out unmetReverts "Slippage" from VirtualPool.exchange. The VirtualPool passes min_out = 0 to the LEVAMM

Gas​

A rebalance uses one flash loan (crvUSD), one AMM.exchange, and one CurveCryptoPool.remove_liquidity or add_liquidity. The AMM.exchange call includes _debt_w, price_w, the invariant solve, the fee collection, and the LT hook. Gas depends on the pool and its policy. To estimate it, call eth_estimateGas or simulate the transaction in the target block.

  • LEVAMM: exchange, get_x0, and the safe limits by market.
  • VirtualPool: get_dy, exchange, and the flash-loan callback.
  • Pool policy: the price_scale target and step of each policy.
  • Arbitrage: how to size and price a trade against the LEVAMM.
  • Oracle report arbitrage: how to trade with a price report on a pool that runs the oracle-report policy.