Oracle report arbitrage
This page is for arbitrageurs, searchers and solvers who trade a YieldBasis Curve pool that runs the oracle-report policy, YBOraclizedPolicy. It gives the fee rules from the contract code, the sizing rules and the price report format. It also gives a procedure that delivers a price report and swaps in one transaction.
For arbitrage against the leveraged AMM (LEVAMM) through the VirtualPool, see Arbitrage.
Purpose and status
- Fee policy.
YBOraclizedPolicyis a fee policy. The Curve pool asks this contract for its swap fee and itsprice_scaletarget. The policy prices each swap against a signed Pyth Pro (Pyth Lazer) USD price of the pool's asset. This page calls that signed price a price report. - Pools. Each pool that runs the policy has its own policy contract. To see which policy a pool runs, see Contract addresses or call
POLICY()on the pool. The team's forum post of 21 October 2026 explains the design.
Contracts
| Contract | How to find it |
|---|---|
| Curve pool (Twocrypto v3.0.0) | The market's cryptopool. See Contract addresses |
Pool policy (YBOraclizedPolicy) | pool.POLICY(). To read its owner and emergency admin, call owner() and EMERGENCY_ADMIN() on the policy |
| LEVAMM | policy.AMM(). It holds the pool LP as collateral |
| Pyth Lazer verifier | policy.oracle().verifier. An upgradeable ERC-1967 proxy at 0xACeA761c27A909d4D3895128EBe6370FDE2dF481. To read its version, call version() |
| Coins | pool.coins(0) is crvUSD 0xf939E0A03FB07F59A73314E73794Be0E57ac1b4E. pool.coins(1) is the asset |
How the fee works
The Curve pool calls policy.get_fee(xp) for every fee. xp holds the pool balances after the operation and before its fee, with the asset scaled by price_scale. The pool clamps a non-zero result to the range 0.1 bp to 100%. A result of 0 tells the pool to use its native fee.
Settings
Read policy.config().fee before you trade. The owner can change these values within the bounds in Owner limits. This page gives fees in basis points (bp). 1 bp is 0.01%.
| Field | Meaning | Units onchain | Example |
|---|---|---|---|
base_fee | Lowest fee of a discounted swap | 1e10 = 100% | 50,000,000 (50 bp) |
capture | Share of the edge above base_fee that the pool keeps | 1e18 = 100% | 0.675e18 (67.5%) |
fallback_fee | Fee without a discount, and the cap | 1e10 = 100% | 175,000,000 (175 bp) |
relax_time | Price report age at which the discount is gone | seconds | 300 |
The examples on this page use these values.
Check order
_get_fee runs these checks in this order. The earliest check that fails sets the result.
- If the policy is killed, the result is 0. The pool then charges its native fee.
- If both balances in
xpare equal to or more than the snapshot balances, the result isfallback_fee. - If both balances are equal to or less than the snapshot balances, the result is
fallback_fee. - If the policy holds no price report (
asset_priceis 0), the result isfallback_fee. - If the price report age is
relax_timeor more, the result isfallback_fee. - If the snapshot
Dis 0 or differs from the pool's storedD, the result isfallback_fee. - If the edge of the swap at the price report is 0, the result is
fallback_fee. - The policy computes the discounted fee.
- If that fee is less than
fallback_feeand the swap is corrective, the result is the discounted fee. - Otherwise, the result is
fallback_fee.
Only a swap moves one balance up and the other down, so checks 2 and 3 give liquidity operations fallback_fee. Check 6 fails after a failed best-effort removal callback, and usually during an A ramp.
The snapshot is the pool state that the policy stored at the last pool callback or sync(). In normal operation, it equals the pool state before your swap.
A corrective swap moves the pool's marginal price toward the price report and does not pass it. The policy computes the marginal price with cached_A. If either balance becomes 0, the swap is not corrective.
- coin 0 (crvUSD) in:
p_before < p_afterandp_after ≤ report_price. - coin 1 (the asset) in:
p_before > p_afterandp_after ≥ report_price.
Edge
The edge is the gain of the whole swap at the price report, before the fee. The policy values both legs in crvUSD and counts 1 crvUSD as $1.
edge = 1 − input_value / output_value (0 if input_value ≥ output_value)
buy asset (crvUSD in): edge = 1 − crvUSD_in / (asset_out × report_price)
sell asset (asset in): edge = 1 − (asset_in × report_price) / crvUSD_out
output_value is the output before the fee. The code rounds the edge up to the next fee unit. The edge equals the fee at which the swap breaks even at the price report.
Fee formula
The code computes the fee in two parts.
excess = max(edge − base_fee, 0)
fresh = min(fallback_fee, base_fee + capture × excess)
age = 0: fee = fresh
0 < age < relax_time: fee = fresh + isqrt((fallback_fee − fresh)² × age / relax_time) + 1 fee unit
(if fresh = fallback_fee, fee = fallback_fee)
age ≥ relax_time: fee = fallback_fee
The second line equals fresh + (fallback_fee − fresh) × √(age / relax_time), plus one fee unit (1e-10). The age is block.timestamp minus the observation time of the price report, in microseconds. If the observation time is after block.timestamp, the age is 0.
The fee is never above fallback_fee. So a price report can only lower the fee of a swap.
What you earn and how far to trade
Profit at a fresh price report
Below the cap, with age 0:
profit = V_out × (edge − fee) = V_out × (1 − capture) × (edge − base_fee)
V_out is the output value at the price report, before the fee. With the example settings, you keep 32.5% of the edge above 50 bp. The pool keeps 67.5% of it, plus the 50 bp base_fee.
| Edge | Fee (age 0) | You keep, before gas and hedge |
|---|---|---|
| 0 bp | fallback_fee (check 7) | none |
| 50 bp | 50.00 bp | 0.00 bp |
| 100 bp | 83.75 bp | 16.25 bp |
| 150 bp | 117.50 bp | 32.50 bp |
| 200 bp | 151.25 bp | 48.75 bp |
| 235 bp | 174.88 bp | 60.13 bp |
| 300 bp | 175.00 bp | 125.00 bp |
The discounted fee reaches fallback_fee at edge = base_fee + (fallback_fee − base_fee) / capture. With the example settings, this is 50 + 125 / 0.675 ≈ 235.2 bp. At a larger edge, the price report does not change the fee. The swap is an ordinary 175 bp trade.
At the price report, a swap with an edge of exactly 50 bp breaks even. A swap with a smaller edge loses money. Trade only when the pool price is more than base_fee from the price report.
Price report age
The discount fades with the square root of the age. The fee on a 120 bp edge (fresh fee 97.25 bp) shows the effect:
| Age | Weight on fallback_fee | Fee | You keep |
|---|---|---|---|
| 0 s | 0% | 97.25 bp | 22.75 bp |
| 1 s | 5.8% | 101.74 bp | 18.26 bp |
| 3 s | 10.0% | 105.03 bp | 14.97 bp |
| 12 s | 20.0% | 112.80 bp | 7.20 bp |
| 60 s | 44.7% | 132.02 bp | −12.02 bp |
| 300 s | 100% | 175.00 bp | −55.00 bp |
One block of delay (12 s) removes 20% of the discount. So deliver the price report and swap in the same transaction.
Size of the trade
The edge is an average over the whole swap. Each extra unit of size trades at a smaller gap, so it lowers the average edge.
The results in this subsection assume that the gap between the pool's marginal price and the price report falls linearly with trade size. Real Twocrypto curves are not linear. Use the results as a guide, and search the size numerically with get_dy_with_report.
Let g₀ be the starting gap and g the gap at which you stop. The average edge is (g₀ + g) / 2. Below the cap, profit is proportional to:
(g₀ − g) × ((g₀ + g) / 2 − base_fee)
This value is largest at g = base_fee. So stop when the pool's marginal price is about base_fee (50 bp) short of the price report. A trade to the price report is still corrective, but it earns less. For g₀ = 200 bp, a stop at 0 bp earns 88.9% of the best result.
Above the cap, the fee is fallback_fee, and profit is proportional to (g₀ − g) × ((g₀ + g) / 2 − fallback_fee). This value is largest at g = fallback_fee. The example settings give these thresholds:
- About 340.8 bp: the discount stops paying. The best discounted trade earns
0.325 × (g₀ − 50)² / 2. A capped trade that stops at a 175 bp gap earns(g₀ − 175)² / 2. The two are equal whereg₀ − 175 = √0.325 × (g₀ − 50), at about 340.8 bp. Above this start gap, the capped trade earns more. - About 420.4 bp: the discounted optimum ends. The stop at
base_feegives an average edge of(g₀ + 50) / 2. This reaches 235.2 bp atg₀≈ 420.4 bp. Above this start gap, the best discounted trade is not available. - About 470.4 bp: no discount at all. A trade to the price report gives the smallest average edge of a corrective swap,
g₀ / 2. This reaches 235.2 bp atg₀≈ 470.4 bp. Above this start gap, every corrective swap paysfallback_fee.
Compare the discounted case and the capped case with get_dy_with_report, and keep the larger profit. A small trade at a large gap has an edge close to the gap. At a gap above 235.2 bp, a small trade pays fallback_fee.
If the swap moves the marginal price past the price report, the swap is not corrective. The fee then jumps to fallback_fee.
Price report requirements
Access
You need a Pyth Pro (Pyth Lazer) access token. Request the price report with these values:
| Request field | Value | Why |
|---|---|---|
| Feed id | policy.FEED_ID() | The Pyth Lazer feed of the pool's asset. For example, feed 2 is Crypto.ETH/USD, for a WETH pool |
| Channel | The channel in oracle().channel | The policy source maps channel 4 to fixed_rate@1000ms |
| Properties | price, exponent, feedUpdateTimestamp, in this order | The policy reads property ids 0, 4 and 12 at fixed offsets |
| Format | evm | The 112-byte signed update |
Decode one live price report before you automate. The policy rejects a payload whose properties are in a different order. The Pyth documentation does not say that the server always keeps the requested order.
Pyth Pro documentation: overview, API key, REST API, WebSocket.
Byte layout
A price report is exactly 112 bytes. The verifier checks the 71-byte header and the signature. The policy decodes the 41-byte payload at bytes 71 to 112. Ranges are big-endian and exclude the end byte.
| Bytes | Field | Required value | Checked by |
|---|---|---|---|
| 0–4 | EVM format magic | 0x2a22999a | Verifier |
| 4–69 | Signature r, s, v | Recovers to a trusted Pyth signer | Verifier |
| 69–71 | Payload length | 41 | Policy (the verifier reads it) |
| 71–75 | Payload magic | 0x93c7d375 | Policy |
| 75–83 | Message time, µs | ≤ block time + future_window_us | Policy |
| 83 | Channel | oracle().channel | Policy |
| 84 | Feed count | 1 | Policy |
| 85–89 | Feed id | FEED_ID | Policy |
| 89 | Property count | 3 | Policy |
| 90 | Property id | 0 (price) | Policy |
| 91–99 | Price mantissa, int64 | > 0 | Policy |
| 99 | Property id | 4 (exponent) | Policy |
| 100–102 | Price exponent, int16 | −18 to 0 | Policy |
| 102 | Property id | 12 (feed update time) | Policy |
| 103 | Value present | 1 | Policy |
| 104–112 | Observation time, µs | See the admission rules | Policy |
The policy converts the price to 18 decimals: report_price = mantissa × 10^(18 + exponent).
Admission rules
deliver_oracle_report(report_data: Bytes[112]) is permissionless and payable. It stores the price report only if all of these conditions hold, in this order:
- The policy is not killed.
- The pool has a
price_scaleabove 0. The call must not come from inside a pool operation. report_datais 112 bytes.- The verifier accepts the update and returns a non-zero signer and a 41-byte payload.
- The payload passes the format checks in the byte layout table.
0 < observation ≤ message ≤ block time + future_window_us.observation + past_window_us ≥ block time.- The observation is strictly newer than
report().observation_us. - The price is within
[min_price, max_price].
To read the windows and the price bounds, call policy.oracle(). The windows are at most 12 s (past_window_us) and 6 s (future_window_us). The observation time decides admission and age. A fresh message can carry an old observation.
Verification fee
The policy forwards the whole msg.value to verifier.verifyUpdate. The verifier reverts with "Insufficient fee provided" if msg.value is less than its fee. To read the fee, call verification_fee() on the policy.
The deployed verifier implementation is PythLazer 0.2.0 (source at commit 4255a45). Version 0.2.0 refunds an overpayment to the policy with transfer. The policy has no payable default function, so the refund fails. The whole delivery then reverts without a reason string.
The verifier is an upgradeable proxy. A later Pyth version (0.3.0 on the main branch) keeps the excess and does not revert. To check the version, call version() on the verifier before you rely on either behavior.
Send exactly verification_fee() wei with each delivery. With verifier 0.2.0, an overpayment reverts the delivery.
Quoting
The policy has three quote views. Their signatures in YBOraclizedPolicy.vy are:
get_dy_with_report(i: uint256, j: uint256, dx: uint256, report_data: Bytes[112], block_timestamp: uint256) -> uint256
get_dy_with_report_age(i: uint256, j: uint256, dx: uint256, report_price: uint256, report_age_us: uint256) -> uint256
get_fee_with_report(xp: uint256[2], report_price: uint256, report_age_us: uint256) -> uint256
| View | Returns |
|---|---|
get_dy_with_report | pool.get_dy(i, j, dx) as if this price report were stored. The view computes the age at block_timestamp |
get_dy_with_report_age | pool.get_dy(i, j, dx) as if a price report of this price (1e18 = $1) and age (µs) were stored |
get_fee_with_report | The fee for post-swap, pre-fee balances xp (the asset scaled by price_scale) |
pool.get_dy(i, j, dx) | The quote against the stored price report, at its age in the block of the call |
For get_dy_with_report, pass the target block timestamp. For the next block, this is usually the timestamp of the most recent block plus 12.
Know these limits before you use a quote:
- No signature check. The views do not call the verifier. They do not apply the time window, the price bounds or the "newer" rule. So a quote can succeed for a price report that
deliver_oracle_reportrejects. - Format checks apply.
get_dy_with_reportdecodes bytes 71 to 112 with the same format checks. A malformed payload reverts with the decode reason, for example "payload magic". - Pool state is this block's. Reserves,
price_scaleandAcome from the block of the call. Only the price report age moves withblock_timestamp. - A ramp. During an
Aramp, a quote can show a discount that the swap does not get (check 6). - Wrong pool.
get_dy_with_reportandget_dy_with_report_agerevert with "not attached" ifpool.POLICY()is not this policy. - Killed. If the policy is killed, the views quote with the pool's native fee (
pool.fee_calc).
_quote repeats the arithmetic of the pool's _exchange. So a quote equals the exchange output if the pool state and the stored price report do not change and no A ramp is active. The team's guide reports the same result.
Deliver and swap in one transaction
After a delivery stores a price report, any trader can swap against it. A delivery in a separate transaction gives the discount to the next trader.
Procedure
- Read the policy state (see Before you send).
- Get the newest price report from Pyth Pro.
- Decode the price and the observation time.
- Compare the pool's marginal price with the price report price.
pool.last_prices()is an approximation (see the Python example). - If the gap is
base_feeor less, stop. - If the pool's marginal price is above the price report, sell the asset (
i = 1). - If the pool's marginal price is below the price report, sell crvUSD (
i = 0). - Quote sizes with
get_dy_with_reportat the target block timestamp. - Select the size with the largest profit after gas and the hedge cost.
- Set
min_dyfrom the quote at the target block timestamp. - Set the delivery value to exactly
verification_fee()wei. - Set the gas limit explicitly.
- Send
deliver_oracle_reportandexchangein the same transaction, for example throughReportSwap. - Hedge the output at your external venue.
Do not let your node estimate the gas limit. The estimate runs at the most recent block, and its timestamp can be older than a fresh price report. The time check can then fail in the estimate only.
If a rival stores an equal or newer price report before you, your delivery reverts with "observation not newer". The ReportSwap contract that follows catches only that reason and swaps against the stored price report. Your min_dy then decides whether the swap executes.
ReportSwap contract
The constants point to the practice pool and its policy. For a YieldBasis pool, use the pool address and its POLICY(). To find them, see Contract addresses.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface IERC20 {
function transferFrom(address from, address to, uint256 amount) external returns (bool);
function approve(address spender, uint256 amount) external returns (bool);
}
interface IPolicy {
function deliver_oracle_report(bytes calldata report) external payable;
function verification_fee() external view returns (uint256);
}
interface IPool {
function coins(uint256 i) external view returns (address);
function exchange(uint256 i, uint256 j, uint256 dx, uint256 minDy, address receiver)
external
returns (uint256);
}
/// Deliver a signed Pyth price report, then swap, in one transaction.
contract ReportSwap {
// Practice pool and its policy (for testing).
// For a YieldBasis pool, use its address and the address that pool.POLICY() returns.
IPool public constant POOL = IPool(0x9C9bC7E9F54155760Fb03d406C8813453F284388);
IPolicy public constant POLICY = IPolicy(0xb83697796A35998aF07B01258bBdA3a04caDe241);
/// @param report The 112-byte signed price report from Pyth Pro (evm format).
/// @param i Index of the coin you sell: 0 crvUSD, 1 the asset.
/// @param dx Amount you sell. Approve this contract for it before the call.
/// @param minDy Smallest output you accept, from get_dy_with_report.
function swapWithReport(bytes calldata report, uint256 i, uint256 dx, uint256 minDy)
external
payable
returns (uint256 dy)
{
// Pays exactly verification_fee() from this contract's ETH balance.
// Send verification_fee() wei as msg.value.
try POLICY.deliver_oracle_report{value: POLICY.verification_fee()}(report) {}
catch Error(string memory reason) {
// A price report at least as recent is already stored: swap on that one.
require(keccak256(bytes(reason)) == keccak256("observation not newer"), reason);
}
IERC20 coinIn = IERC20(POOL.coins(i));
coinIn.transferFrom(msg.sender, address(this), dx);
coinIn.approve(address(POOL), dx);
dy = POOL.exchange(i, 1 - i, dx, minDy, msg.sender);
}
}
To use the contract:
- Deploy
ReportSwap. - Approve
ReportSwapfordxof the input coin. - Quote
get_dy_with_report(i, 1 - i, dx, report, block_timestamp)for the target block. - Call
swapWithReport(report, i, dx, minDy)withverification_fee()wei as value.
ETH that you send to the contract stays in it. The contract has no withdrawal function, so send exactly verification_fee().
For a single-transaction arbitrage, add a flash loan for the input. Sell the output on an external venue before you repay. Practice example 2 used a Morpho flash loan this way.
A contract can also ignore every delivery failure and let min_dy decide. With that rule, the contract also swaps when your price report fails admission. The swap then uses the stored price report.
Python example
This script gets the newest price report, picks the direction, quotes for the next block, and sends one transaction to ReportSwap. It needs web3.py 7 or later and requests.
Set these environment variables: ETH_RPC_URL, PYTH_API_KEY, PRIVATE_KEY and REPORT_SWAP (your deployed ReportSwap address).
The script uses the base URL, the path and the Authorization: Bearer header from the Pyth REST reference. The jsonBinaryEncoding field and the ["evm"]["data"] response path come from the team's guide. The Pyth reference pages do not show them. So the decode step accepts hex with or without a 0x prefix.
"""Fetch a signed Pyth price report and swap through ReportSwap in one transaction.
Defaults to the practice pool. Before running: deploy ReportSwap, approve it for
the coin you sell, and set ETH_RPC_URL, PYTH_API_KEY, PRIVATE_KEY and REPORT_SWAP.
"""
import json
import os
import requests
from eth_account import Account
from web3 import Web3
# Practice pool and its policy (for testing). For a YieldBasis pool, use its
# address and the address that pool.POLICY() returns.
POLICY = "0xb83697796A35998aF07B01258bBdA3a04caDe241"
POOL = "0x9C9bC7E9F54155760Fb03d406C8813453F284388" # coin 0 crvUSD, coin 1 WETH
PYTH_URL = "https://pyth-lazer.dourolabs.app/v1/latest_price" # check against Pyth Pro docs
DX_ASSET = 2 * 10**15 # amount to sell when selling the asset (WETH here): 0.002
DX_CRVUSD = 5 * 10**18 # amount to sell when selling crvUSD: 5
ABI = json.loads("""[
{"type":"function","name":"get_dy_with_report","stateMutability":"view","inputs":[
{"name":"i","type":"uint256"},{"name":"j","type":"uint256"},{"name":"dx","type":"uint256"},
{"name":"report_data","type":"bytes"},{"name":"block_timestamp","type":"uint256"}],
"outputs":[{"name":"","type":"uint256"}]},
{"type":"function","name":"FEED_ID","stateMutability":"view","inputs":[],
"outputs":[{"name":"","type":"uint32"}]},
{"type":"function","name":"verification_fee","stateMutability":"view","inputs":[],
"outputs":[{"name":"","type":"uint256"}]},
{"type":"function","name":"last_prices","stateMutability":"view","inputs":[],
"outputs":[{"name":"","type":"uint256"}]},
{"type":"function","name":"swapWithReport","stateMutability":"payable","inputs":[
{"name":"report","type":"bytes"},{"name":"i","type":"uint256"},{"name":"dx","type":"uint256"},
{"name":"minDy","type":"uint256"}],"outputs":[{"name":"dy","type":"uint256"}]}
]""")
w3 = Web3(Web3.HTTPProvider(os.environ["ETH_RPC_URL"]))
me = Account.from_key(os.environ["PRIVATE_KEY"])
policy = w3.eth.contract(Web3.to_checksum_address(POLICY), abi=ABI)
pool = w3.eth.contract(Web3.to_checksum_address(POOL), abi=ABI)
swapper = w3.eth.contract(Web3.to_checksum_address(os.environ["REPORT_SWAP"]), abi=ABI)
def fetch_report():
"""The newest signed price report of the policy's feed, in the 112-byte format it reads."""
response = requests.post(
PYTH_URL,
json={
"priceFeedIds": [policy.functions.FEED_ID().call()],
"properties": ["price", "exponent", "feedUpdateTimestamp"], # this order
"formats": ["evm"],
"channel": "fixed_rate@1000ms",
"jsonBinaryEncoding": "hex", # from the team's guide
},
headers={"Authorization": f"Bearer {os.environ['PYTH_API_KEY']}"},
timeout=5,
)
response.raise_for_status()
data = response.json()["evm"]["data"] # response path from the team's guide
report = bytes.fromhex(data.removeprefix("0x"))
assert len(report) == 112, "unexpected report length"
return report
def report_price(report):
"""Price in 18 decimals (1e18 = $1), from payload bytes 20-28 and 29-31."""
mantissa = int.from_bytes(report[91:99], "big", signed=True)
exponent = int.from_bytes(report[100:102], "big", signed=True)
return mantissa * 10 ** (18 + exponent)
report = fetch_report()
price = report_price(report)
# last_prices is the pool spot after its last trade: an approximation of the
# marginal price. Pool price above the report: sell the asset. Below it: sell crvUSD.
i = 1 if pool.functions.last_prices().call() > price else 0
dx = DX_ASSET if i == 1 else DX_CRVUSD
# Quote for the next block. A later block ages the report: the transaction can
# then fail report admission or the min_dy check.
next_block_time = w3.eth.get_block("latest").timestamp + 12
min_dy = policy.functions.get_dy_with_report(i, 1 - i, dx, report, next_block_time).call()
print(f"report ${price / 1e18:,.2f}; sell {dx} of coin {i}; expect at least {min_dy} of coin {1 - i}")
tx = swapper.functions.swapWithReport(report, i, dx, min_dy).build_transaction(
{
"from": me.address,
"value": policy.functions.verification_fee().call(), # exactly this amount
"nonce": w3.eth.get_transaction_count(me.address),
# Set explicitly: estimation runs at the most recent block, whose timestamp
# can trail a fresh report enough to fail the policy's time check.
"gas": 500_000,
}
)
tx_hash = w3.eth.send_raw_transaction(me.sign_transaction(tx).raw_transaction)
receipt = w3.eth.wait_for_transaction_receipt(tx_hash)
print(f"status {receipt.status}; tx 0x{bytes(tx_hash).hex()}")
The script uses fixed sizes. A production bot must search the size (see Size of the trade) and score the full route with the hedge.
Before you send
Read these values in the block that you build against:
| Read | Expected | If not |
|---|---|---|
pool.POLICY() | The policy address that you quote against | The views revert "not attached". The pool uses another fee |
policy.is_killed() | false | Deliveries revert "killed". The pool charges its native fee |
policy.config() | The fee values that your thresholds use | Compute the thresholds again with the live values |
policy.oracle() | A channel that matches your request, and windows and bounds that fit your price report | Change the price report request or the timing |
policy.report() | observation_us older than your price report | Your delivery reverts "observation not newer" |
policy.verification_fee() | The value to send | Send this exact amount |
pool.D() and policy.state().D | Equal | No discount (check 6). Call policy.sync() if a removal callback failed |
Value crvUSD at its market price in your profit check. The policy counts 1 crvUSD as $1.
Revert reasons
| Reason | Source | Function | Meaning |
|---|---|---|---|
killed | Policy | deliver_oracle_report | The policy is killed and takes no price reports |
pool uninitialized | Policy | deliver_oracle_report, sync | The pool price_scale is 0 |
| (pool reentrancy revert) | Pool | deliver_oracle_report, sync | Called from inside a pool operation. pool.price_scale() is nonreentrant |
report length | Policy | deliver_oracle_report | The update is shorter than 112 bytes. A longer update reverts without a reason, in ABI decoding of Bytes[112] |
| (no reason) | Verifier refund | deliver_oracle_report | Verifier 0.2.0: more than verification_fee() was sent. The refund to the policy fails |
Insufficient fee provided | Verifier | deliver_oracle_report | Less than the verification fee was sent |
input too short, invalid evm magic | Verifier | deliver_oracle_report | Not an EVM-format update |
invalid signature, invalid signer | Verifier | deliver_oracle_report | The signature does not recover to a trusted Pyth signer |
signer | Policy | deliver_oracle_report | The verifier returned the zero address |
payload length | Policy | deliver_oracle_report | The signed payload is not 41 bytes |
payload magic, channel, one feed, feed | Policy | deliver_oracle_report, get_dy_with_report | Wrong format, channel or feed |
price property, exponent property, observation property | Policy | deliver_oracle_report, get_dy_with_report | Properties missing or in a different order |
price/exponent | Policy | deliver_oracle_report, get_dy_with_report | Price is 0 or less, or the exponent is outside −18 to 0 |
report time | Policy | deliver_oracle_report | Observation is 0, the message is older than the observation, or the message is more than future_window_us after the block |
report too old | Policy | deliver_oracle_report | Observation is more than past_window_us before the block |
observation not newer | Policy | deliver_oracle_report | A price report with an equal or later observation is stored |
price bounds | Policy | deliver_oracle_report | Price is outside [min_price, max_price] |
coin index out of range | Policy | get_dy_with_report* | i == j or an index above 1 |
not attached | Policy | get_dy_with_report* | pool.POLICY() is not this policy |
unsafe value for y | Policy | get_dy_with_report* | The swap would empty the output balance |
slippage | Pool | exchange | Output after the fee is below min_dy |
!balance | Pool | exchange | After the swap, a balance is 0, or the scaled balances differ by 1,000:1 or more |
Worked examples from the practice pool
These examples come from practice-pool transactions of 21 October 2026. They are practice-pool data, not production data. The block, direction, amounts and fees come from each transaction. The price report age is the block time minus the observation time of the stored price report.
| # | Transaction | Block (UTC time) | What happened | Price report age | Edge | Fee paid |
|---|---|---|---|---|---|---|
| 1 | 0x3166b258…9271b | 26,242,174 (16:28:47) | A user called pool.exchange and bought 0.27174 WETH with 700.51 crvUSD, away from the stored price report | 198 s | −105.3 bp | 175.0 bp (not corrective) |
| 2 | 0x3edbcb7f…6abbe | 26,242,179 (16:29:47) | A searcher contract took a Morpho flash loan of 0.18956 WETH and delivered a same-second price report (ReportAccepted). It sold the WETH for 476.35 crvUSD, swapped the crvUSD to USDT and the USDT back to WETH, and repaid the flash loan | 0 s | 124.4 bp | 100.2 bp = 50 + 0.675 × (124.4 − 50) |
| 3 | 0xcdfd1356…e4226 | 26,242,290 (16:51:59) | deliver_oracle_report alone, with 1 wei. Stored price $2,512.09, observation 16:51:55 | 4 s | — | — |
| 4 | 0x8cc1139e…3eb6d | 26,242,291 (16:52:11) | pool.exchange in the next block: sold 0.0011692 WETH for 2.92868 crvUSD, against price report 3. The event fee was 0.023364 crvUSD | 16 s | 50.5 bp | 79.1 bp = 50.36 + (175 − 50.36) × √(16/300) |
| 5 | 0x4245a44e…f5c73 | 26,242,271 (16:48:11) | pool.exchange: bought 0.000389 WETH with 1 crvUSD, away from the stored price report | 16 s | — | 175.0 bp (not corrective) |
| 6 | 0x1e911217…869fd | 26,242,235 (16:40:59) | deliver_oracle_report of a price report 28 s after its observation | 28 s | — | Reverted "report too old" |
| 7 | 0x4056b1ce…c6450 | 26,242,285 (16:50:59) | pool.exchange of 0.0011692 WETH with min_dy 2.93009 crvUSD, from a quote with a fresher price report. The stored price report was older at inclusion | 40 s | — | Reverted "slippage" |
Example 2 kept 0.000332 WETH (about $0.83) before about $0.60 of gas. Example 4 paid 79.1 bp instead of the fresh 50.36 bp. One block of delay cost 28.8 bp.
Fee decay with one price report
The team's guide quoted the price report of example 3 at block 26,242,339 for a sale of 0.002 WETH, at five timestamps:
0x2a22999a3a9be01d03727564ef1fd69b520ae4e9a92a8c71db1e90616f2733dec76158fa42eeaf1a71b78bc135556b10bda2d7dccbf39beddee443a71f8202ee50a1f74700002993c7d37500065e5c908680c004010000000203000000003a7d3e87cd04fff80c0100065e5c908680c0
Decoded: payload magic 0x93c7d375, channel 4, feed 2, mantissa 251,209,353,165, exponent −8 (price $2,512.09). The message time and the observation time are both 1,792,601,515,000,000 µs.
block_timestamp | Age | crvUSD out | Fee |
|---|---|---|---|
| 1792601515 | 0 s | 5.024119 | 50.0 bp |
| 1792601527 | 12 s | 5.011495 | 75.0 bp |
| 1792601575 | 60 s | 4.995892 | 105.9 bp |
| 1792601665 | 150 s | 4.979488 | 138.4 bp |
| 1792601815 | 300 s | 4.961002 | 175.0 bp |
The fee follows 50 + 125 × √(age / 300) bp. get_dy_with_report does not apply the admission window, so it can quote this price report out to 300 s.
A small gap does not pay
The team's guide also quoted a fresh price report of $2,506.29 at block 26,242,339. The pool spot (last_prices()) was 2,524.77, 74 bp higher. These quotes are for a WETH sale, 1 s after the observation:
| Sell (USD) | Fee | Gross at the price report |
|---|---|---|
| $10 | 71.4 bp | +$0.001 |
| $50 | 68.6 bp | −$0.004 |
| $100 | 65.0 bp | −$0.03 |
| $250 | 57.2 bp | −$0.29 |
| $500 | 175.0 bp | −$7.88 |
A 74 bp gap leaves 24 bp above base_fee. The pool keeps 67.5% of that. Price impact in the small practice pool takes the rest. At $500, the swap passes the price report and pays fallback_fee.
To compare the depth of two pools, compare their D(). A pool with 1,000 times the D supports about 1,000 times larger trades at the same balance and gap.
Interaction with price_scale
The price report also sets the price_scale target, which affects when the Curve pool rebalances. Through price_scale, the target also moves the LEVAMM oracle price.
What the EMAs observe
The policy keeps a fast and a slow exponential moving average (EMA) of the price.
- While the stored price report is younger than
report_lifetime, the EMAs and the target observe the price report price. - After that, they observe the pool spot stored at the previous pool callback. A swap cannot steer the EMAs with its own trade.
- The policy clamps the observation to
[price_scale / 2, 2 × price_scale]. report_lifetimeis separate fromrelax_time. For example, withreport_lifetime5,400 s andrelax_time300 s, a price report steers the target for 90 minutes. Its fee discount ends after 5 minutes. To read both, callpolicy.config().
Target and step
To read these settings, call policy.config().driver.
| Setting | Example | Meaning |
|---|---|---|
fast_half_life | 4,800 s (80 min) | Half-life of the fast EMA |
slow_half_life | 9,600 s (160 min) | Half-life of the slow EMA |
trend_gate | 0.007e18 (0.7%) | Trend at which the target equals the observed price |
step_cap | 0.005e18 (0.5%) | Largest requested move per touch, as a share of price_scale |
report_lifetime | 5,400 s | Age below which the EMAs observe the price report |
The policy computes the target as follows:
trend = |fast / slow − 1|
w = min(1, trend / trend_gate)
target = fast + w × (observed − fast)
move = min(|target − price_scale| / 2, step_cap × price_scale)
policy returns price_scale ± 5 × move
The pool applies its own limits to the returned value:
- Only the first operation in a block that calls
tweak_pricecan moveprice_scale. These operations are swaps, unbalanced liquidity operations and donations. - That first operation must pass the profit test (
vp_boosted > lp_xcp_profit). If it fails the test,price_scaledoes not move in that block. - The pool clamps the target to
price_oracle ± 20%. - The pool computes the relative step as
min(norm / 5, adjustment_step_max). The pool caps a larger step atadjustment_step_maxand does not skip it. - If the step is not above
adjustment_step_min, the pool does not move. - The pool keeps the move only if the new virtual price is above 1e18 and at or above
lp_xcp_profit.
To read adjustment_step_min and adjustment_step_max, call adjustment_step() on the pool.
The factor 5 in the policy cancels the pool's division by 5. So, if the ±20% clamp does not bind, the net relative step is move / price_scale. That step is at most step_cap and at most adjustment_step_max.
update_pool_state settles the EMAs on every pool operation. price_scale moves only on a rebalance.
Effect for arbitrageurs
- A delivered price report moves the
price_scaletarget in later blocks. - A swap in the same transaction can trigger a step. This occurs only if the swap is the first operation in the block that calls
tweak_price, and it passes the profit test. - A
price_scalemove changes the LEVAMM oracle price2 × virtual_price × √price_scale × crvUSD price. That can open a VirtualPool arbitrage. See Arbitrage. - A
price_scalemove also moves the pool's liquidity concentration. Read the pool state again after each block.
Who delivers price reports
Anyone can deliver a price report. Arbitrageurs deliver one with their swap. Do not depend on another party for a fresh price report. Deliver your own price report in the same transaction as your swap.
Risks for integrators
- crvUSD valued at $1. The policy values crvUSD at exactly $1. If crvUSD is off peg, the edge that the policy sees differs from your real edge. Value your profit at the market price of crvUSD.
- Competition. The earliest corrective swap in a block takes the gap. A rival's newer price report makes your delivery revert with "observation not newer". The next swap then trades against the rival's price report.
- Report selection. The policy accepts any price report that passes admission. The newest observation wins, not the most accurate price. Pick the price report and size by net profit after the hedge.
- Owner changes. A YB DAO vote can replace
config()andoracle()within the owner limits. This change needs no Curve DAO vote.set_oracle_configdrops the stored price report price. A change torelax_timeorreport_lifetimecan revive a stored price report. - Verifier trust. The owner chooses the verifier, and the verifier decides which prices are authentic. If Pyth signs a wrong price, any trader can deliver it.
- Kill switch. The owner or
EMERGENCY_ADMINcan kill the policy at any time. While the policy is killed, the pool uses its native fee and nativeprice_scaletarget, and deliveries revert. Killing drops the stored price report price. Revival callssync(). - Detachment. A Curve DAO vote can detach or replace the policy (
pool.set_policy_contract). The quote views then revert "not attached". - Liquidity operations pay half the fallback fee, scaled by imbalance. Adds and removals are not swaps, so
get_feereturnsfallback_fee. The pool chargesfallback_fee / 2 × Sdiff / Sof the LP amount, plusNOISE_FEE(0.1 bp). An add during the donation protection period also pays a spam penalty, at most the same rate.Sdiff / Sis 0 for a balanced operation and 1 for a one-coin operation. So with a 175 bpfallback_fee, a one-coin add pays 87.5 bp. A balancedremove_liquiditypays no fee. LT deposits (add_liquidity) and LT withdrawals (remove_liquidity_fixed_out) pay this fee on their imbalance. - LT deposit check. LT is the vault contract that mints yb-LP. An LT deposit whose debt is more than
ratio_toleranceoff the LEVAMM debt ratio reverts with "LT deviation". This check affects routers that deposit into the market, not swaps. - Balanced removal gas. 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. A failed call leaves a stale snapshot. Swaps then payfallback_feeuntil someone callssync().
Owner limits
set_policy_config and set_oracle_config revert outside these bounds:
| Setting | Bound |
|---|---|
base_fee | 0.1 bp ≤ base_fee ≤ fallback_fee |
fallback_fee | ≤ 10% |
capture | ≤ 100% |
relax_time | 1 s to 3,600 s |
fast_half_life, slow_half_life | 600 s ≤ fast ≤ slow ≤ 604,800 s |
trend_gate | ≤ 10% |
step_cap | above 0, ≤ 60 bp |
report_lifetime | ≤ 14,400 s |
ratio_tolerance | ≤ 10 bp |
verifier | Must be a contract |
channel | 1 to 4 |
past_window_us | ≤ 12 s |
future_window_us | ≤ 6 s |
min_price, max_price | $1e-12 ≤ min ≤ max ≤ $1e12 |
Related
- Arbitrage: LEVAMM arbitrage through the VirtualPool.
- Quote and route: quotes for swaps, deposits and withdrawals.
- Error codes: revert strings across the protocol.
- Pool policies: user-level description of both policies.
- Arbitrage and MEV: user-level framing.
- Forum post: Oracle reports for YieldBasis pools (21 October 2026).