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 (). 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 () and owes crvUSD as debt (). 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 (), , and . For the derivation of , 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 from CryptopoolLPOracle, which computes 2 · virtual_price · √price_scale · agg. Three inputs change :
- The Curve pool changes
price_scale. virtual_priceof the pool changes.- The crvUSD aggregator price changes.
A move in the market price of the asset does not change 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 , and the changed 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_amountof the input coin: crvUSD in the buy direction, or the asset in the sell direction. The VirtualPool pullsin_amountfrom the caller in step 1. - Set
min_outfrom aVirtualPool.get_dyquote minus your tolerance.get_dydoes 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:
The buy direction runs these steps in order:
- The arbitrageur calls
VirtualPool.exchange(0, 1, in_amount, min_out). The VirtualPool pullsin_amountcrvUSD from the caller. - The VirtualPool takes a flash loan of
maxFlashLoancrvUSD fromFactory.flash(). The lender calls backVirtualPool.onFlashLoan. - In the callback, the VirtualPool computes
flash_amountand callsAMM.exchange(0, 1, ...)within_amount * (10**18 - ROUNDING_DISCOUNT) // 10**18 + flash_amountandmin_out = 0. The LEVAMM sends LP tokens for that crvUSD and checks its invariant. It then runs_collect_fees()andLT.distribute_borrower_fees(). - 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-zeroPOLICY(), the pool callsupdate_pool_stateon the policy. - The VirtualPool repays the lender with its whole crvUSD balance.
- Back in
exchange, the VirtualPool checks its asset balance againstmin_out. It sends the asset to_forand emitsTokenExchange._fordefaults 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:
The sell direction runs these steps in order:
- The arbitrageur calls
VirtualPool.exchange(1, 0, in_amount, min_out). The VirtualPool pullsin_amountof the asset from the caller. - As in the buy direction, the VirtualPool takes the flash loan, and the lender calls back
onFlashLoan. - In the callback, the VirtualPool sizes
flash_amountfrom the pool balances,in_amount × balances(0) / balances(1). It adds[flash_amount, in_amount]to the Curve pool withadd_liquidity(..., 0). On a v3.0.0 pool with a non-zeroPOLICY(), the pool callsupdate_pool_state, and the call must succeed. - 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()andLT.distribute_borrower_fees(). - The VirtualPool repays the whole loan (
maxFlashLoan) to the lender. - Back in
exchange, the VirtualPool checks its crvUSD balance againstmin_out. It sends the crvUSD to_forand emitsTokenExchange.
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.debtdecreases in the buy direction, where crvUSD comes in, and increases in the sell direction._debt_w()accrues interest ondebtbefore the change.- The LEVAMM takes a fee from the output (
out = raw_out × (1 − fee) / 1e18). The fee stays in the LEVAMM, sox0after the trade is not smaller thanx0before.
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
At equilibrium, collateral value equals twice the debt ( 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_debttoward 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 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 .
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) | Condition | Behavior |
|---|---|---|
| 1, 1 | (i, j) is not (0, 1) or (1, 0) | VirtualPool.exchange reverts without a message |
| 2, 2 | VirtualPool flash loan not set on Factory | flashLoan call reverts |
| 3, 4 | The LEVAMM is killed | AMM.exchange reverts without a message |
| 3, 4 | The LEVAMM is empty (collateral_amount = 0) | Reverts "Empty AMM" |
| 3, 4 | Resulting x0 < prior x0 | Reverts "Bad final state" |
| 3, 4 | coll_vs_debt outside safe bounds: on markets 7 to 10, only when moving away from L=2; on markets 0 to 6, on every trade | Reverts "Unsafe min" / "Unsafe max" |
| 3, 4 | Markets 7 to 10: the borrower-interest donation after the LEVAMM swap fails in the Curve pool | Reverts "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 call | The Curve pool reverts without a message |
| –, 3 | Markets 7 to 10: pool policy callback reverts on add_liquidity | The Curve pool reverts with the error of the policy |
| –, 3 | Markets 7 to 10: the pool LP allowlist is on and the VirtualPool is not listed | Reverts "!wl" (from the Curve pool) |
| 6, 6 | min_out unmet | Reverts "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.
Related
- LEVAMM:
exchange,get_x0, and the safe limits by market. - VirtualPool:
get_dy,exchange, and the flash-loan callback. - Pool policy: the
price_scaletarget 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.