Arbitrage
An arbitrage trade against the leveraged AMM (LEVAMM), sent through the VirtualPool, returns a market to 2× leverage and pays the trader the price gap. The VirtualPool is the market's two-coin swap interface between crvUSD and the asset. This trade works on every market.
You find a LEVAMM that is off target, quote the trade, and send it. The steps are in Execute a LEVAMM arbitrage. The sections before the steps explain why the spread appears, how the VirtualPool routes the trade, and how the LEVAMM prices it.
Arbitrage on a Curve pool that runs the oracle-report policy is a different trade. For that trade, read Oracle report arbitrage.
Two arbitrage venues
A YieldBasis market has two places where an arbitrageur can trade. They use different contracts, prices, and fees, as this table shows:
| LEVAMM arbitrage (this page) | Oracle-report arbitrage | |
|---|---|---|
| Contract you call | VirtualPool.exchange | Curve pool exchange, with policy.deliver_oracle_report in the same transaction |
| Price gap | LEVAMM LP price against the value of the LP in the Curve pool | Curve pool price against the market price in a signed Pyth Pro (Pyth Lazer) price report |
| Fee you pay | LEVAMM fee, AMM.fee() | Curve pool fee. On a pool that runs the oracle-report policy, between base_fee and fallback_fee, from the price report |
| Markets | All markets | Pools that run YBOraclizedPolicy |
| Result | LEVAMM debt-to-value ratio moves toward 50% | Curve pool price moves toward the market. The price report steers the price_scale target |
fallback_fee is the policy fee without a discount, and base_fee is the lowest discounted fee.
The two venues interact through price_scale, the Curve pool's internal price. A Curve pool swap can trigger a price_scale step. A price_scale step changes the LEVAMM oracle price, which can open a LEVAMM arbitrage.
When the LEVAMM goes off target
The LEVAMM holds Curve liquidity provider (LP) tokens as collateral and owes crvUSD debt. Its target is a debt-to-value ratio (DTV) of 50%, which is 2× leverage. The LEVAMM values its collateral at the LP oracle price p_o. These formulas give p_o and the DTV:
p_o = 2 × virtual_price × √price_scale × crvUSD_price
DTV = debt / (collateral × p_o)
crvUSD_price comes from the crvUSD price aggregator. price_scale and virtual_price come from the Curve pool.
The DTV drifts from 50% when one of these changes:
price_scale. The LP value scales with√price_scale. This is the main driver.- Interest. Debt grows at
AMM.rate(). virtual_priceand the crvUSD price. Both enterp_odirectly.
The LEVAMM quote does not move when the market price moves. It moves when the Curve pool moves price_scale. The pool moves price_scale only within these limits:
- Only in the first operation of a block that calls
tweak_price: a swap, an unbalanced liquidity operation, or a donation. If that operation fails the profit test (vp_boosted > lp_xcp_profit),price_scaledoes not move in that block. For the step rules of each pool version, read Step limits on price_scale in Oracle design. - By a limited step. The step limits follow the target table.
The price_scale target depends on the policy that the pool runs. EMA means exponential moving average. A dual-EMA target combines a fast EMA and a slow EMA. This table gives the target for each case:
POLICY() | price_scale target |
|---|---|
YBOraclizedPolicy (the oracle-report policy) | Dual-EMA target. The Pyth price report is the observation for report_lifetime. After that, the pool spot is the observation |
YBTwocryptoPolicy | Dual-EMA target from the pool's own prices. No external price |
No policy: older Twocrypto code (markets 0 to 6), or a v3.0.0 pool whose POLICY() returns the zero address | Native: the pool's price_oracle EMA |
To see which policy a pool runs, call POLICY() on the pool or read the pool policy table in Contract addresses.
The pool limits the size of each step in these ways:
- A pool caps each step at
adjustment_step_maxofprice_scale. - A pool does not move for a step at or below
adjustment_step_min. - On a pool that runs the oracle-report policy, the policy also limits each request to half the gap to its target, capped at
step_cap.step_capis a policy setting, a fraction ofprice_scale. - On a pool that runs
YBTwocryptoPolicy, the policy limits each request to a cap that grows fromMIN_CAPtoMAX_CAPover the hour after the previous pool operation. It requests no move while the gap is withinDEADBAND.MIN_CAP,MAX_CAP, andDEADBANDare policy immutables, fractions ofprice_scale. - A v3.0.0 pool clamps a policy target to
price_oracle ± 20%.
To read adjustment_step_min and adjustment_step_max, call adjustment_step() on the pool.
How the VirtualPool routes trades
The VirtualPool trades between crvUSD (index 0) and the asset (index 1). It uses the Curve pool, the market's LEVAMM, and a crvUSD flash loan from Factory.flash(). The route has three properties:
- The flash loan covers only the opposite leg. You supply the input coin. The VirtualPool borrows crvUSD internally.
- No LP custody. The VirtualPool mints or burns LP inside the call. You never receive LP.
- The LEVAMM checks the result. The VirtualPool only routes. The LEVAMM applies its own trade check.
Asset to crvUSD (i = 1, j = 0)
The route runs these steps:
- You send the asset to the VirtualPool.
- The VirtualPool takes a crvUSD flash loan.
- The VirtualPool mints LP:
POOL.add_liquidity([flash_amount, asset_in], 0). The amounts are in the pool's balance ratio. - The VirtualPool sells the LP to the LEVAMM:
AMM.exchange(1, 0, lp_amount, 0). - The VirtualPool repays the flash loan.
- The VirtualPool sends the remaining crvUSD to you.
crvUSD to asset (i = 0, j = 1)
The route runs these steps:
- You send crvUSD to the VirtualPool.
- The VirtualPool takes a crvUSD flash loan.
- The VirtualPool buys LP from the LEVAMM:
AMM.exchange(0, 1, stable_in × (1 − ROUNDING_DISCOUNT) + flash_amount, 0). - The VirtualPool burns the LP:
POOL.remove_liquidity(lp_amount, [0, 0]). This is a balanced removal. - The VirtualPool repays the flash loan with its crvUSD balance.
- The VirtualPool sends the asset to you.
The flash callback onFlashLoan requires initiator == self, token == STABLECOIN, and msg.sender == Factory.flash() ("Wrong caller").
ROUNDING_DISCOUNT is 10**18 // 10**8, a fraction of 1e-8. It applies to the crvUSD input on the crvUSD-to-asset path, so the quote does not overstate the output. For example, on a 10,000 crvUSD input, it is 0.0001 crvUSD.
Flash sizing is internal. exchange asks for the lender's full capacity (flash.maxFlashLoan). The callback computes the amount it needs with the quadratic in Flash-loan sizing in the Math primer. The VirtualPool repays the borrowed amount in the same transaction. The route pays no flash fee.
Pool policy effects on the route
The pool and its policy affect the route in three ways:
- Pool fee.
VirtualPool.get_dydoes not include the pool fee, so setmin_outwith a margin. The route pays these pool fees:- The add in step 3 is in the pool's balance ratio, so its imbalance fee is close to 0. On a pool that runs
YBOraclizedPolicy, the policy sets the imbalance fee rate tofallback_fee / 2times the imbalance shareSdiff / S. The pool addsNOISE_FEE(0.1 bp). The imbalance part stays close to 0. - The balanced removal pays no imbalance fee.
- During the donation protection period, the pool also adds a spam penalty that does not depend on imbalance. The penalty grows with the donated share of supply, up to the imbalance fee rate.
- The add in step 3 is in the pool's balance ratio, so its imbalance fee is close to 0. On a pool that runs
- LT deviation check. The add mints LP to the VirtualPool, not to the LEVAMM. The LT deviation check of
YBOraclizedPolicy(LT deviation) does not apply to it. - Gas on balanced removal. On a pool with any policy, a balanced
remove_liquiditycalls the policy with 300,000 gas. If that call fails and less than 500,000 gas remained before it, the pool reverts. Set a gas limit with margin on the crvUSD-to-asset path. This rule applies to every pool with a policy. To check, callPOLICY()on the pool.
How the LEVAMM prices the trade
State and price
The LEVAMM gives its state and its price through these views:
AMM.get_state()returns(collateral, debt, x0).collateralis the LP amount.debtincludes accrued interest.AMM.get_p()returns the LEVAMM's LP price in crvUSD:(x0 − debt) / collateral, withx0 = get_x0(p_o, collateral, debt, False).AMM.get_dy(i, j, in_amount)quotes on the constant-leverage curve and takesfeefrom the output. Fori = 0, ifin_amountis more thandebt, it reverts with "Amount too large".
Why a spread appears
The DTV sets which trade direction is profitable:
- If the DTV is below 50%, the LEVAMM pays more crvUSD for LP than the LP is worth in the Curve pool. Selling LP to the LEVAMM (asset to crvUSD) is profitable. The trade adds debt and collateral, and the DTV rises.
- If the DTV is above 50%, the LEVAMM sells LP for less than the LP is worth in the Curve pool. Buying LP from the LEVAMM (crvUSD to asset) is profitable. The trade removes debt and collateral, and the DTV falls.
The fee and ROUNDING_DISCOUNT do not create the spread. Together with gas, they set the smallest spread that pays.
Directional inventory changes
Each direction changes the LEVAMM inventory as follows:
| Call | debt | collateral | DTV |
|---|---|---|---|
AMM.exchange(0, 1, …) (crvUSD in, LP out) | Decreases by in_amount | Decreases by out_amount | Decreases |
AMM.exchange(1, 0, …) (LP in, crvUSD out) | Increases by out_amount | Increases by in_amount | Increases |
Trade check in the LEVAMM
On markets 7 to 10, AMM.exchange compares coll_vs_debt = p_o × collateral / debt before and after the trade. The equilibrium value is 2. The direction of the trade sets the check:
- If the trade moves
coll_vs_debttoward 2, the safe-bounds check is off (check_state = False). - If the trade moves
coll_vs_debtaway from 2, the safe-bounds check is on. Debt must stay between 6.25% and 53.125% of the collateral value atp_o("Unsafe min", "Unsafe max").
In both cases, the trade ends with this check from the contract source (AMM.exchange):
assert self.get_x0(p_o, collateral, debt, check_state) >= x0, "Bad final state"
x0 after the trade must be equal to or more than x0 before it. The fee stays in the LEVAMM, so a valid trade does not reduce x0. On markets 7 to 10, the safe bounds do not block a trade that restores the DTV. A trade that moves the DTV away from 50% must stay inside them.
On markets 0 to 6, the LEVAMM always applies the safe bounds, also to a trade that restores the DTV.
VirtualPool quote source
VirtualPool.get_dy(i, j, in_amount) returns the output of the full route: LEVAMM swap, Curve mint or burn, and flash repayment. You do not need LP math.
Contract source: VirtualPool._calculate()
From the VirtualPool.vy source.
@internal
@view
def _calculate(i: uint256, in_amount: uint256, only_flash: bool) -> (uint256, uint256):
stables_in_pool: uint256 = staticcall POOL.balances(0)
out_amount: uint256 = 0
if i == 0:
state: AMMState = staticcall AMM.get_state()
pool_supply: uint256 = staticcall POOL.totalSupply()
fee: uint256 = staticcall AMM.fee()
r0fee: uint256 = stables_in_pool * (10**18 - fee) // pool_supply
# Solving quadratic eqn instead of calling the AMM b/c we have a special case
b: uint256 = state.x0 - state.debt + in_amount - r0fee * state.collateral // 10**18
D: uint256 = b**2 + 4 * state.collateral * r0fee // 10**18 * in_amount
flash_amount: uint256 = (isqrt(D) - b) // 2 # We received this withdrawing from the pool
if not only_flash:
crypto_in_pool: uint256 = staticcall POOL.balances(1)
out_amount = flash_amount * crypto_in_pool // stables_in_pool
# Withdrawal was ideally balanced
return out_amount, flash_amount
else:
crypto_in_pool: uint256 = staticcall POOL.balances(1)
flash_amount: uint256 = in_amount * stables_in_pool // crypto_in_pool
if not only_flash:
pool_supply: uint256 = staticcall POOL.totalSupply()
lp_amount: uint256 = pool_supply * in_amount // crypto_in_pool
out_amount = staticcall AMM.get_dy(1, 0, lp_amount) - flash_amount
return out_amount, flash_amount
Arbitrage scenarios
The following figure shows both VirtualPool routes step by step:
The two VirtualPool routes. Each restores the LEVAMM debt-to-value ratio toward 50% and pays the arbitrageur the price gap.
The scenarios start at DTV 50%. They hold virtual_price, the crvUSD price, and the debt constant. Only price_scale changes. price_scale moves in limited steps per block, so a 10% move takes many blocks. The figures show the state after the full move.
Scenario 1: price_scale rises 10%
The move has these effects:
p_ois multiplied by√1.10= 1.0488.- DTV falls to 50% / 1.0488 = 47.67%. The LEVAMM is below target.
- The LEVAMM pays more for LP than the LP is worth in the Curve pool.
Trade: VirtualPool.exchange(1, 0, asset_in, min_out). The VirtualPool mints LP with your asset and flash-loaned crvUSD. It sells the LP to the LEVAMM, repays the flash loan, and sends you crvUSD. The LEVAMM debt rises, and the DTV moves toward 50%.
Scenario 2: price_scale falls 10%
The move has these effects:
p_ois multiplied by√0.90= 0.9487.- DTV rises to 50% / 0.9487 = 52.70%. The LEVAMM is above target.
- The LEVAMM sells LP for less than the LP is worth in the Curve pool.
Trade: VirtualPool.exchange(0, 1, stable_in, min_out). The VirtualPool buys LP from the LEVAMM with your crvUSD and flash-loaned crvUSD. It burns the LP, repays the flash loan, and sends you the asset. The LEVAMM debt falls, and the DTV moves toward 50%.
Execute a LEVAMM arbitrage
Do these tasks in order: find the opportunity, compute the DTV, calculate the profit, and send the trade. The code samples are fragments in short notation. In web3.py, vpool.get_dy(...) stands for vpool.functions.get_dy(...).call(), and vpool.exchange(...) for vpool.functions.exchange(...).transact(...).
Find the opportunity
To find a profitable direction, follow these steps:
- Read
AMM.get_state()to getcollateral,debt, andx0. - Read
p_ofromAMM.PRICE_ORACLE_CONTRACT().price(). - Compute the DTV.
- Choose the direction from the DTV:
- If the DTV is below 50%, quote asset to crvUSD.
- If the DTV is above 50%, quote crvUSD to asset.
- To confirm the spread, compare
AMM.get_p()with the value of one LP in the Curve pool at market prices.
The LP value at market prices is (POOL.balances(0) + POOL.balances(1) × market_price) / POOL.totalSupply(), with decimals scaled to 18. market_price is your own price for the asset, for example the price at which you hedge. It is in crvUSD per one whole asset unit, with 18 decimals. Do not use POOL.lp_price() or POOL.virtual_price() for this comparison. They use price_scale, not the market price.
Compute the DTV
This fragment computes the DTV from the LEVAMM state and the LP oracle price:
collateral, debt, x0 = amm.get_state()
p_o = price_oracle.price() # USD per LP, 18 decimals
dtv = debt * 10**36 // (p_o * collateral) # 1e18 = 100%; target 5e17
LP tokens have 18 decimals, so no extra scaling is necessary.
Calculate the profit
This fragment computes the profit in each direction. Use integers, as the fragment does:
ASSET_DECIMALS = asset.decimals() # 8 for WBTC, 18 for WETH
# Asset to crvUSD (DTV below 50%)
out_crvusd = vpool.get_dy(1, 0, asset_in) # 18 decimals
cost_crvusd = asset_in * market_price // 10**ASSET_DECIMALS
profit_crvusd = out_crvusd - cost_crvusd
# crvUSD to asset (DTV above 50%)
out_asset = vpool.get_dy(0, 1, stable_in) # asset decimals
cost_asset = stable_in * 10**ASSET_DECIMALS // market_price
profit_asset = out_asset - cost_asset
This profit is before gas and the cost of your hedge.
This fragment quotes two example amounts:
# Example amounts
expected_crvusd = vpool.get_dy(1, 0, 10**7) # 0.1 WBTC (8 decimals) to crvUSD
expected_wbtc = vpool.get_dy(0, 1, 10_000 * 10**18) # 10,000 crvUSD to WBTC
Send the trade
To send the trade, follow these steps:
- Approve the VirtualPool for the input coin.
- Quote
VirtualPool.get_dy(i, j, in_amount)in the block that you target. - Set
min_outfrom that quote. - Call
VirtualPool.exchange(i, j, in_amount, min_out).
This fragment sends the trade in each direction. It sends real transactions, so run it on a mainnet fork first:
vpool.exchange(1, 0, asset_in, min_out) # asset to crvUSD
vpool.exchange(0, 1, stable_in, min_out) # crvUSD to asset
If the output is less than min_out, the VirtualPool reverts with "Slippage". The route also reverts if the borrower-fee donation after the LEVAMM swap fails in the Curve pool (slippage or donation above cap!). For the causes of every VirtualPool and LEVAMM revert string, read Error codes.
Key functions
VirtualPool
The VirtualPool has these functions for a trade:
| Function | Returns |
|---|---|
get_dy(i, j, in_amount) | Output of the full route. i = 0: crvUSD in. i = 1: asset in |
exchange(i, j, in_amount, min_out, _for) | Executes the route. _for defaults to msg.sender |
coins(0), coins(1) | crvUSD address, asset address |
AMM(), POOL(), FACTORY() | LEVAMM, Curve pool, Factory |
LEVAMM
The LEVAMM (contract AMM) has these functions for a trade:
| Function | Returns |
|---|---|
get_state() | (collateral, debt, x0) |
get_p() | LEVAMM LP price in crvUSD |
get_dy(i, j, in_amount) | LEVAMM quote, after fee |
fee() | LEVAMM fee, 1e18 = 100%. Example: 3 * 10**15 is 0.3% |
PRICE_ORACLE_CONTRACT() | LP oracle that gives p_o |
get_debt(), collateral_amount() | Debt with interest, LP collateral |
MIN_SAFE_DEBT and MAX_SAFE_DEBT are immutables without a getter. Use 6.25% and 53.125% of the collateral value for a 2× market.
Related
- Oracle report arbitrage: arbitrage on a Curve pool that runs the oracle-report policy, with a Pyth price report.
- LEVAMM contract reference: the invariant,
exchange,get_dy,get_state, andget_x0. - VirtualPool: the flash-loan wrapper that routes the trade.
- Rebalance flow: the full call sequence of a LEVAMM trade.
- Arbitrage and MEV: the user-level view of arbitrage.