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 want | Call |
|---|---|
| Quote asset → crvUSD through VirtualPool | VirtualPool.get_dy(1, 0, dx) |
| Quote crvUSD → asset through VirtualPool | VirtualPool.get_dy(0, 1, dx) |
| Quote a direct swap on the Curve pool | pool.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 share | LT.pricePerShare() |
| Liquidity bucket state | LT.liquidity() returns (admin, total, ideal_staked, staked) |
| Emergency-path quote | LT.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_withdrawis 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_withdrawreturns asset units, andpricePerShare()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, readpreview_withdrawandpricePerSharein Deposit and withdraw. - Preview functions are
@view. The_wvariants (for example,CryptopoolLPOracle.price_w) are mutating and update state. Do not call mutating functions in quote paths. LT.preview_depositneeds adebtargument in crvUSD wei. Use the price rule unless the pool runs a liveYBOraclizedPolicyand the LEVAMM book holds collateral; then computedebtfrom 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_depositreverts with"Debt too high"over the deposit cap. WithFalse, it skips the cap check and returns the share estimate. preview_depositdoes not run the LT deviation check. It returns a share estimate for adebtthat the check rejects. Simulatedepositwitheth_callbefore 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
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:
- Get a quote with
get_dyin the latest block, immediately before you send the swap. - Set
min_outfrom the quote minus your slippage tolerance. - Approve VirtualPool for
in_amountof the input coin. - 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 source | Does pool.get_dy match execution? |
|---|---|---|
| No policy hook (older Twocrypto code) | Pool's native fee curve | Yes, in the same block with unchanged state |
YBTwocryptoPolicy | Native fee (the policy returns 0) | Yes, as above |
YBOraclizedPolicy (the oracle-report policy) | Policy, from the stored Pyth Pro (Pyth Lazer) price report | Only 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_timeis the price report age at which the discount is gone. When the age reachesrelax_time, every swap paysfallback_fee, the policy fee without a discount. - A swap gets a fee below
fallback_feeonly if it moves the pool price toward the report and does not pass it. Other swaps payfallback_fee. - Unbalanced liquidity operations pay
fallback_fee / 2 × Sdiff / Sof the LP amount, plusNOISE_FEE(0.1 bp).Sdiff / Sis 0 for a balanced operation and 1 for a one-coin operation. A balancedremove_liquiditypays no fee. - A later report from another trader can change the fee before a block includes your transaction.
- During an
Aramp, a quote can show a discount that the swap does not get. AnAramp is a gradual change of the pool parameterA, which the pool logs withRampAgamma. - If the policy is killed, the pool uses its native fee.
YBOraclizedPolicy has three quote views:
| View | Use |
|---|---|
get_dy_with_report(i: uint256, j: uint256, dx: uint256, report_data: Bytes[112], block_timestamp: uint256) -> uint256 | pool.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) -> uint256 | pool.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) -> uint256 | The 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":iorjis not valid."unsafe value for y":dxdrains 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:
- Get the timestamp of the target block.
- 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 callpolicy.get_dy_with_report_age(i, j, dx, report.asset_price, age_at_target).
- If you deliver your own report, call
- Set
min_dyfrom that quote, not from a quote in the current block. - 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 theMarketstruct:(asset_token, cryptopool, amm, lt, price_oracle, virtual_pool, staker)(7 fields, in this order). It has noaggfield. Read the crvUSD aggregator fromFactory.agg()orLT.agg().Factory.market_count()is the upper bound for iteration.Factoryhas noget_market_infofunction, so iterate overmarkets(i).- To find a gauge, read
stakerfrom the market struct, or callLT.staker().GaugeController.gaugesis anaddress[1_000_000_000]array indexed by integer, not a mapping by LT address. Do not callgauges(lt_address). cryptopoolis the Curve pool. To find the pool policy, callPOLICY()on it.
Related
- Deposit and withdraw: the canonical
debtcalculation and the call patterns that use these quotes. - VirtualPool: the route behind
VirtualPool.get_dy. - Pool policy: the fee rules behind the policy quote views.
- Arbitrage: how to size a VirtualPool trade against the LEVAMM.
- Oracle report arbitrage: how to quote and send a swap with your own price report.