Skip to main content

Quote and route

You can quote a swap, a deposit, or a withdrawal on a YieldBasis market. A swap goes through the VirtualPool, the market's two-coin swap interface, or directly through the Curve pool. A deposit or a withdrawal goes to LT, the market's vault contract. For each quote, the sections that follow give the view function and the conditions under which the quote matches execution. A quote is exact only for the block and the state in which you read it. To send a VirtualPool swap, read Execute a VirtualPool swap.

View functions for each quote​

LT is ERC-4626-like, not compliant. For ERC-4626 accounting, use the gauge (LiquidityGauge). For the differences, read LT and ERC-4626 in Deposit and withdraw.

Each row names a quote and the view function that returns it:

What you wantCall
Quote asset → crvUSD through VirtualPoolVirtualPool.get_dy(1, 0, dx)
Quote crvUSD → asset through VirtualPoolVirtualPool.get_dy(0, 1, dx)
Quote a direct swap on the Curve poolpool.get_dy(i, j, dx). On a pool that runs the oracle-report policy, use the policy quote views
Quote a deposit (assets → shares)LT.preview_deposit(assets, debt, raise_overflow=True). The notes after the table give its limits
Quote a redemption (shares → assets, live Curve pool)LT.preview_withdraw(tokens). The argument is a share count
Fundamental Value per shareLT.pricePerShare()
Liquidity bucket stateLT.liquidity() returns (admin, total, ideal_staked, staked)
Emergency-path quoteLT.preview_emergency_withdraw(shares)
Gauge ERC-4626 (gauge shares)LiquidityGauge.convertToShares/convertToAssets/previewDeposit/previewRedeem
LP-token price in USD (crvUSD per LP times the crvUSD aggregator price)CryptopoolLPOracle.price()
Asset price (crvUSD per whole asset)pool.price_oracle(), the Curve pool's internal exponential moving average (EMA)

LT.preview_withdraw(tokens) runs calc_withdraw_fixed_out against the live Curve pool state. Its output includes the Temporary Redemption Discount (TRD) and the pool fee. LT.pricePerShare() uses the value_oracle of the leveraged AMM (LEVAMM), priced from price_scale and the crvUSD aggregator. On markets 0 to 2, LT converts that value to the asset with the pool's price_oracle, not price_scale. pricePerShare() does not include TRD.

Integrator notes​

Follow these rules when you route with the quotes:

  • For withdrawal routing, use preview_withdraw, which gives the Redemption Value. pricePerShare() is not an upper bound: during a premium, preview_withdraw is higher.
  • TRD is the signed gap between Redemption Value (preview_withdraw) and Fundamental Value (pricePerShare()). For one share, TRD = preview_withdraw(10**18) * 10**(18 - asset_decimals) / pricePerShare() − 1. preview_withdraw returns asset units, and pricePerShare() has 18 decimals. So the scale factor is necessary on a market whose asset has fewer than 18 decimals. TRD is negative during a discount. For the position formula and a converted example, read preview_withdraw and pricePerShare in Deposit and withdraw.
  • Preview functions are @view. The _w variants (for example, CryptopoolLPOracle.price_w) are mutating and update state. Do not call mutating functions in quote paths.
  • LT.preview_deposit needs a debt argument in crvUSD wei. Use the price rule unless the pool runs a live YBOraclizedPolicy and the LEVAMM book holds collateral; then compute debt from the LEVAMM book. For the LEVAMM-book procedure, read Compute debt on a pool that runs the oracle-report policy.
  • With raise_overflow=True, preview_deposit reverts with "Debt too high" over the deposit cap. With False, it skips the cap check and returns the share estimate.
  • preview_deposit does not run the LT deviation check. It returns a share estimate for a debt that the check rejects. Simulate deposit with eth_call before you send it.

VirtualPool quotes​

VirtualPool.get_dy models no Curve pool fee. The VirtualPool route adds to or removes from the Curve pool in proportion. The add on the asset-to-crvUSD path pays NOISE_FEE and a near-zero imbalance fee. During the donation protection period of the pool, the add also pays a spam penalty. So execution can return slightly less than the quote. For the pool fee and the penalty, read get_fee in Pool policy.

In VirtualPool, coin index 0 is crvUSD and coin index 1 is the asset. Give the input in raw units of the input coin: 18 decimals for crvUSD, and asset_decimals for the asset. get_dy returns the output in raw units of the output coin.

This web3.py fragment quotes 0.01 units of each coin. It assumes w3, m, asset_decimals, and the _fn helper from the complete script in the Developer quickstart. The minimal VIRTUAL_POOL_ABI has only get_dy and exchange. For the full ABI, compile contracts/VirtualPool.vy in yield-basis/yb-core, or export the ABI from the deployed contract on Etherscan.

VIRTUAL_POOL_ABI = [
_fn("get_dy", ["uint256", "uint256", "uint256"], ["uint256"]),
_fn("exchange",
["uint256", "uint256", "uint256", "uint256", "address"],
["uint256"], "nonpayable"),
]
vp = w3.eth.contract(address=m[5], abi=VIRTUAL_POOL_ABI) # VirtualPool

crvusd_in = 10**18 // 100 # 0.01 crvUSD (18 decimals)
asset_in = 10**asset_decimals // 100 # 0.01 units of the asset

dy_asset = vp.functions.get_dy(0, 1, crvusd_in).call() # crvUSD in, asset out
dy_crvusd = vp.functions.get_dy(1, 0, asset_in).call() # asset in, crvUSD out

To judge the profit of a trade, compare the quote with your hedge price, and subtract gas.

Execute a VirtualPool swap​

Fork check: on a mainnet endpoint, this section spends real assets

The fragment in this section sends transactions. Run it against a mainnet fork first. Point RPC_URL at mainnet only when you mean to spend real assets from user.

You need only the input coin, because VirtualPool takes the crvUSD flash loan. Gas use depends on the pool. A pool policy can raise the gas that a swap needs, and a policy or the LP allowlist can make a swap revert. For the effect on each swap direction, including the gas that a crvUSD-to-asset swap needs, read Pools with a policy or an LP allowlist in VirtualPool.

To swap, follow these steps in order:

  1. Get a quote with get_dy in the latest block, immediately before you send the swap.
  2. Set min_out from the quote minus your slippage tolerance.
  3. Approve VirtualPool for in_amount of the input coin.
  4. Call exchange(i, j, in_amount, min_out, _for). The output goes to _for.

If the output falls below min_out, exchange reverts with Slippage. The fragment sets min_out 10 basis points (bp) below the quote: min_out = quote * 9_990 // 10_000. Set your own tolerance before you send a production transaction.

This fragment swaps in both directions and pays the output to user. It continues from the quote fragment in VirtualPool quotes. It also uses user, asset, and ERC20_ABI from the complete script. The crvUSD address comes from Core contracts in Contract addresses. On a fork, the first swap pays the crvUSD that the second swap spends.

# Fork check: each .transact() call in this block sends a transaction.
CRVUSD = "0xf939E0A03FB07F59A73314E73794Be0E57ac1b4E" # crvUSD token
crvusd = w3.eth.contract(address=CRVUSD, abi=ERC20_ABI)

# Asset in, crvUSD out
quote = vp.functions.get_dy(1, 0, asset_in).call()
min_out = quote * 9_990 // 10_000 # 10 bp tolerance; set your own
tx = asset.functions.approve(vp.address, asset_in).transact({"from": user})
w3.eth.wait_for_transaction_receipt(tx)
tx = vp.functions.exchange(
1, 0, asset_in, min_out, user
).transact({"from": user}) # .transact() returns the transaction hash
w3.eth.wait_for_transaction_receipt(tx)

# crvUSD in, asset out
quote = vp.functions.get_dy(0, 1, crvusd_in).call()
min_out = quote * 9_990 // 10_000 # 10 bp tolerance; set your own
tx = crvusd.functions.approve(vp.address, crvusd_in).transact({"from": user})
w3.eth.wait_for_transaction_receipt(tx)
tx = vp.functions.exchange(
0, 1, crvusd_in, min_out, user
).transact({"from": user})
w3.eth.wait_for_transaction_receipt(tx)

Each exchange call that succeeds pays at least min_out of coin j to _for. .transact() returns the transaction hash, not the output amount. To read the output, use the change in the balance of _for. You can also use tokens_bought in the five-field TokenExchange event that VirtualPool emits, in which buyer is _for. To decode that event, first add its entry to VIRTUAL_POOL_ABI. The same receipt also holds the seven-field TokenExchange event of the LEVAMM, in which buyer is the VirtualPool and tokens_bought is not the swap output. For the two signatures, read AMM and VirtualPool in Event catalog.

Quote a swap on a YieldBasis Curve pool​

Each YieldBasis market holds LP of a Curve Twocrypto pool. A router can swap on that pool directly. The pool can have a policy contract that sets the fee or the price_scale target. price_scale is the pool's internal price. To find the policy, read pool.POLICY() or the pool policy table in Contract addresses. Pools on older Twocrypto code have no POLICY() getter. For the hook and both policies, read Pool policy.

The fee source depends on the policy. This table shows, for each case, whether pool.get_dy matches execution:

POLICY()Fee sourceDoes pool.get_dy match execution?
No policy hook (older Twocrypto code)Pool's native fee curveYes, in the same block with unchanged state
YBTwocryptoPolicyNative fee (the policy returns 0)Yes, as above
YBOraclizedPolicy (the oracle-report policy)Policy, from the stored Pyth Pro (Pyth Lazer) price reportOnly for the block timestamp of the call. The next section gives the reasons

Pools that run the oracle-report policy​

On a pool that runs YBOraclizedPolicy, pool.get_dy calls the pool's view contract, which reads the fee through pool.fee_calc(xp). The pool passes xp to POLICY.get_fee(xp). YBOraclizedPolicy.get_fee prices that fee against the stored price report, at its age in the block of the call.

The policy fee follows these rules:

  • The fee rises with the square root of the price report age. relax_time is the price report age at which the discount is gone. When the age reaches relax_time, every swap pays fallback_fee, the policy fee without a discount.
  • A swap gets a fee below fallback_fee only if it moves the pool price toward the report and does not pass it. Other swaps pay fallback_fee.
  • Unbalanced liquidity operations pay fallback_fee / 2 × Sdiff / S of the LP amount, plus NOISE_FEE (0.1 bp). Sdiff / S is 0 for a balanced operation and 1 for a one-coin operation. A balanced remove_liquidity pays no fee.
  • A later report from another trader can change the fee before a block includes your transaction.
  • During an A ramp, a quote can show a discount that the swap does not get. An A ramp is a gradual change of the pool parameter A, which the pool logs with RampAgamma.
  • If the policy is killed, the pool uses its native fee.

YBOraclizedPolicy has three quote views:

ViewUse
get_dy_with_report(i: uint256, j: uint256, dx: uint256, report_data: Bytes[112], block_timestamp: uint256) -> uint256pool.get_dy(i, j, dx) as if this signed report were stored, with its age taken at block_timestamp (for example the next block's). The view decodes the payload. It does not verify the signature and does not admit the report
get_dy_with_report_age(i: uint256, j: uint256, dx: uint256, report_price: uint256, report_age_us: uint256) -> uint256pool.get_dy(i, j, dx) with a stored report of report_price (18 decimals, USD) observed report_age_us microseconds before the block. Pass 0 for an observation after the block timestamp
get_fee_with_report(xp: uint256[2], report_price: uint256, report_age_us: uint256) -> uint256The fee alone, for given balances xp

The two get_dy_with_report* views use the pool state of the current block, A included. They revert in these cases:

  • "not attached": pool.POLICY() is not the policy.
  • "coin index out of range": i or j is not valid.
  • "unsafe value for y": dx drains the output coin.

get_dy_with_report also reverts with the payload checks of price report delivery, for example "payload magic" or "feed". A quote view does not run the admission checks "report too old", "observation not newer", or "price bounds". A quote can therefore succeed for a price report that delivery rejects.

To quote a swap that you send for a later block:

  1. Get the timestamp of the target block.
  2. Quote the swap at that timestamp. Use the option that matches your report:
    • If you deliver your own report, call policy.get_dy_with_report(i, j, dx, report_data, target_timestamp).
    • If you use the stored report, read policy.report(). Then call policy.get_dy_with_report_age(i, j, dx, report.asset_price, age_at_target).
  3. Set min_dy from that quote, not from a quote in the current block.
  4. If you deliver your own report, deliver it and swap in one transaction.

For the full procedure, with a contract that delivers a report and swaps in one transaction, read Oracle report arbitrage.

Routing metadata​

Use these getters to find the contracts of each market:

  • Factory.markets(i) returns the Market struct: (asset_token, cryptopool, amm, lt, price_oracle, virtual_pool, staker) (7 fields, in this order). It has no agg field. Read the crvUSD aggregator from Factory.agg() or LT.agg().
  • Factory.market_count() is the upper bound for iteration. Factory has no get_market_info function, so iterate over markets(i).
  • To find a gauge, read staker from the market struct, or call LT.staker(). GaugeController.gauges is an address[1_000_000_000] array indexed by integer, not a mapping by LT address. Do not call gauges(lt_address).
  • cryptopool is the Curve pool. To find the pool policy, call POLICY() on it.