Skip to main content

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 callVirtualPool.exchangeCurve pool exchange, with policy.deliver_oracle_report in the same transaction
Price gapLEVAMM LP price against the value of the LP in the Curve poolCurve pool price against the market price in a signed Pyth Pro (Pyth Lazer) price report
Fee you payLEVAMM 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
MarketsAll marketsPools that run YBOraclizedPolicy
ResultLEVAMM 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_price and the crvUSD price. Both enter p_o directly.

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_scale does 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
YBTwocryptoPolicyDual-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 addressNative: 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_max of price_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_cap is a policy setting, a fraction of price_scale.
  • On a pool that runs YBTwocryptoPolicy, the policy limits each request to a cap that grows from MIN_CAP to MAX_CAP over the hour after the previous pool operation. It requests no move while the gap is within DEADBAND. MIN_CAP, MAX_CAP, and DEADBAND are policy immutables, fractions of price_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:

  1. You send the asset to the VirtualPool.
  2. The VirtualPool takes a crvUSD flash loan.
  3. The VirtualPool mints LP: POOL.add_liquidity([flash_amount, asset_in], 0). The amounts are in the pool's balance ratio.
  4. The VirtualPool sells the LP to the LEVAMM: AMM.exchange(1, 0, lp_amount, 0).
  5. The VirtualPool repays the flash loan.
  6. The VirtualPool sends the remaining crvUSD to you.

crvUSD to asset (i = 0, j = 1)​

The route runs these steps:

  1. You send crvUSD to the VirtualPool.
  2. The VirtualPool takes a crvUSD flash loan.
  3. The VirtualPool buys LP from the LEVAMM: AMM.exchange(0, 1, stable_in × (1 − ROUNDING_DISCOUNT) + flash_amount, 0).
  4. The VirtualPool burns the LP: POOL.remove_liquidity(lp_amount, [0, 0]). This is a balanced removal.
  5. The VirtualPool repays the flash loan with its crvUSD balance.
  6. 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_dy does not include the pool fee, so set min_out with 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 to fallback_fee / 2 times the imbalance share Sdiff / S. The pool adds NOISE_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.
  • 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_liquidity calls 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, call POLICY() 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). collateral is the LP amount. debt includes accrued interest.
  • AMM.get_p() returns the LEVAMM's LP price in crvUSD: (x0 − debt) / collateral, with x0 = get_x0(p_o, collateral, debt, False).
  • AMM.get_dy(i, j, in_amount) quotes on the constant-leverage curve and takes fee from the output. For i = 0, if in_amount is more than debt, 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:

CalldebtcollateralDTV
AMM.exchange(0, 1, …) (crvUSD in, LP out)Decreases by in_amountDecreases by out_amountDecreases
AMM.exchange(1, 0, …) (LP in, crvUSD out)Increases by out_amountIncreases by in_amountIncreases

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_debt toward 2, the safe-bounds check is off (check_state = False).
  • If the trade moves coll_vs_debt away from 2, the safe-bounds check is on. Debt must stay between 6.25% and 53.125% of the collateral value at p_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:

Both VirtualPool arbitrage routes. Path A, after price_scale rises and debt falls below 50% of collateral value: sell cbBTC, receive crvUSD. Path B, after price_scale falls and debt rises above 50%: sell crvUSD, receive cbBTC. Each route flash-borrows crvUSD, trades LP with LEVAMM, and settles in one transaction.Both VirtualPool arbitrage routes. Path A, after price_scale rises and debt falls below 50% of collateral value: sell cbBTC, receive crvUSD. Path B, after price_scale falls and debt rises above 50%: sell crvUSD, receive cbBTC. Each route flash-borrows crvUSD, trades LP with LEVAMM, and settles in one transaction.

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_o is 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_o is 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:

  1. Read AMM.get_state() to get collateral, debt, and x0.
  2. Read p_o from AMM.PRICE_ORACLE_CONTRACT().price().
  3. Compute the DTV.
  4. 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.
  5. 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:

  1. Approve the VirtualPool for the input coin.
  2. Quote VirtualPool.get_dy(i, j, in_amount) in the block that you target.
  3. Set min_out from that quote.
  4. 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:

FunctionReturns
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:

FunctionReturns
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.