Skip to main content

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. YBOraclizedPolicy is a fee policy. The Curve pool asks this contract for its swap fee and its price_scale target. 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​

ContractHow 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
LEVAMMpolicy.AMM(). It holds the pool LP as collateral
Pyth Lazer verifierpolicy.oracle().verifier. An upgradeable ERC-1967 proxy at 0xACeA761c27A909d4D3895128EBe6370FDE2dF481. To read its version, call version()
Coinspool.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%.

FieldMeaningUnits onchainExample
base_feeLowest fee of a discounted swap1e10 = 100%50,000,000 (50 bp)
captureShare of the edge above base_fee that the pool keeps1e18 = 100%0.675e18 (67.5%)
fallback_feeFee without a discount, and the cap1e10 = 100%175,000,000 (175 bp)
relax_timePrice report age at which the discount is goneseconds300

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.

  1. If the policy is killed, the result is 0. The pool then charges its native fee.
  2. If both balances in xp are equal to or more than the snapshot balances, the result is fallback_fee.
  3. If both balances are equal to or less than the snapshot balances, the result is fallback_fee.
  4. If the policy holds no price report (asset_price is 0), the result is fallback_fee.
  5. If the price report age is relax_time or more, the result is fallback_fee.
  6. If the snapshot D is 0 or differs from the pool's stored D, the result is fallback_fee.
  7. If the edge of the swap at the price report is 0, the result is fallback_fee.
  8. The policy computes the discounted fee.
  9. If that fee is less than fallback_fee and the swap is corrective, the result is the discounted fee.
  10. 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_after and p_after ≤ report_price.
  • coin 1 (the asset) in: p_before > p_after and p_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.

EdgeFee (age 0)You keep, before gas and hedge
0 bpfallback_fee (check 7)none
50 bp50.00 bp0.00 bp
100 bp83.75 bp16.25 bp
150 bp117.50 bp32.50 bp
200 bp151.25 bp48.75 bp
235 bp174.88 bp60.13 bp
300 bp175.00 bp125.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:

AgeWeight on fallback_feeFeeYou keep
0 s0%97.25 bp22.75 bp
1 s5.8%101.74 bp18.26 bp
3 s10.0%105.03 bp14.97 bp
12 s20.0%112.80 bp7.20 bp
60 s44.7%132.02 bp−12.02 bp
300 s100%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.

Linear-impact model

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 where g₀ − 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_fee gives an average edge of (g₀ + 50) / 2. This reaches 235.2 bp at g₀ ≈ 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 at g₀ ≈ 470.4 bp. Above this start gap, every corrective swap pays fallback_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 fieldValueWhy
Feed idpolicy.FEED_ID()The Pyth Lazer feed of the pool's asset. For example, feed 2 is Crypto.ETH/USD, for a WETH pool
ChannelThe channel in oracle().channelThe policy source maps channel 4 to fixed_rate@1000ms
Propertiesprice, exponent, feedUpdateTimestamp, in this orderThe policy reads property ids 0, 4 and 12 at fixed offsets
FormatevmThe 112-byte signed update
caution

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.

BytesFieldRequired valueChecked by
0–4EVM format magic0x2a22999aVerifier
4–69Signature r, s, vRecovers to a trusted Pyth signerVerifier
69–71Payload length41Policy (the verifier reads it)
71–75Payload magic0x93c7d375Policy
75–83Message time, µs≤ block time + future_window_usPolicy
83Channeloracle().channelPolicy
84Feed count1Policy
85–89Feed idFEED_IDPolicy
89Property count3Policy
90Property id0 (price)Policy
91–99Price mantissa, int64> 0Policy
99Property id4 (exponent)Policy
100–102Price exponent, int16−18 to 0Policy
102Property id12 (feed update time)Policy
103Value present1Policy
104–112Observation time, µsSee the admission rulesPolicy

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:

  1. The policy is not killed.
  2. The pool has a price_scale above 0. The call must not come from inside a pool operation.
  3. report_data is 112 bytes.
  4. The verifier accepts the update and returns a non-zero signer and a 41-byte payload.
  5. The payload passes the format checks in the byte layout table.
  6. 0 < observation ≤ message ≤ block time + future_window_us.
  7. observation + past_window_us ≥ block time.
  8. The observation is strictly newer than report().observation_us.
  9. 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.

warning

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
ViewReturns
get_dy_with_reportpool.get_dy(i, j, dx) as if this price report were stored. The view computes the age at block_timestamp
get_dy_with_report_agepool.get_dy(i, j, dx) as if a price report of this price (1e18 = $1) and age (µs) were stored
get_fee_with_reportThe 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_report rejects.
  • Format checks apply. get_dy_with_report decodes 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_scale and A come from the block of the call. Only the price report age moves with block_timestamp.
  • A ramp. During an A ramp, a quote can show a discount that the swap does not get (check 6).
  • Wrong pool. get_dy_with_report and get_dy_with_report_age revert with "not attached" if pool.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​

  1. Read the policy state (see Before you send).
  2. Get the newest price report from Pyth Pro.
  3. Decode the price and the observation time.
  4. Compare the pool's marginal price with the price report price. pool.last_prices() is an approximation (see the Python example).
  5. If the gap is base_fee or less, stop.
  6. If the pool's marginal price is above the price report, sell the asset (i = 1).
  7. If the pool's marginal price is below the price report, sell crvUSD (i = 0).
  8. Quote sizes with get_dy_with_report at the target block timestamp.
  9. Select the size with the largest profit after gas and the hedge cost.
  10. Set min_dy from the quote at the target block timestamp.
  11. Set the delivery value to exactly verification_fee() wei.
  12. Set the gas limit explicitly.
  13. Send deliver_oracle_report and exchange in the same transaction, for example through ReportSwap.
  14. Hedge the output at your external venue.
warning

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:

  1. Deploy ReportSwap.
  2. Approve ReportSwap for dx of the input coin.
  3. Quote get_dy_with_report(i, 1 - i, dx, report, block_timestamp) for the target block.
  4. Call swapWithReport(report, i, dx, minDy) with verification_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:

ReadExpectedIf not
pool.POLICY()The policy address that you quote againstThe views revert "not attached". The pool uses another fee
policy.is_killed()falseDeliveries revert "killed". The pool charges its native fee
policy.config()The fee values that your thresholds useCompute the thresholds again with the live values
policy.oracle()A channel that matches your request, and windows and bounds that fit your price reportChange the price report request or the timing
policy.report()observation_us older than your price reportYour delivery reverts "observation not newer"
policy.verification_fee()The value to sendSend this exact amount
pool.D() and policy.state().DEqualNo 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​

ReasonSourceFunctionMeaning
killedPolicydeliver_oracle_reportThe policy is killed and takes no price reports
pool uninitializedPolicydeliver_oracle_report, syncThe pool price_scale is 0
(pool reentrancy revert)Pooldeliver_oracle_report, syncCalled from inside a pool operation. pool.price_scale() is nonreentrant
report lengthPolicydeliver_oracle_reportThe update is shorter than 112 bytes. A longer update reverts without a reason, in ABI decoding of Bytes[112]
(no reason)Verifier refunddeliver_oracle_reportVerifier 0.2.0: more than verification_fee() was sent. The refund to the policy fails
Insufficient fee providedVerifierdeliver_oracle_reportLess than the verification fee was sent
input too short, invalid evm magicVerifierdeliver_oracle_reportNot an EVM-format update
invalid signature, invalid signerVerifierdeliver_oracle_reportThe signature does not recover to a trusted Pyth signer
signerPolicydeliver_oracle_reportThe verifier returned the zero address
payload lengthPolicydeliver_oracle_reportThe signed payload is not 41 bytes
payload magic, channel, one feed, feedPolicydeliver_oracle_report, get_dy_with_reportWrong format, channel or feed
price property, exponent property, observation propertyPolicydeliver_oracle_report, get_dy_with_reportProperties missing or in a different order
price/exponentPolicydeliver_oracle_report, get_dy_with_reportPrice is 0 or less, or the exponent is outside −18 to 0
report timePolicydeliver_oracle_reportObservation is 0, the message is older than the observation, or the message is more than future_window_us after the block
report too oldPolicydeliver_oracle_reportObservation is more than past_window_us before the block
observation not newerPolicydeliver_oracle_reportA price report with an equal or later observation is stored
price boundsPolicydeliver_oracle_reportPrice is outside [min_price, max_price]
coin index out of rangePolicyget_dy_with_report*i == j or an index above 1
not attachedPolicyget_dy_with_report*pool.POLICY() is not this policy
unsafe value for yPolicyget_dy_with_report*The swap would empty the output balance
slippagePoolexchangeOutput after the fee is below min_dy
!balancePoolexchangeAfter 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.

#TransactionBlock (UTC time)What happenedPrice report ageEdgeFee paid
10x3166b258…9271b26,242,174 (16:28:47)A user called pool.exchange and bought 0.27174 WETH with 700.51 crvUSD, away from the stored price report198 s−105.3 bp175.0 bp (not corrective)
20x3edbcb7f…6abbe26,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 loan0 s124.4 bp100.2 bp = 50 + 0.675 × (124.4 − 50)
30xcdfd1356…e422626,242,290 (16:51:59)deliver_oracle_report alone, with 1 wei. Stored price $2,512.09, observation 16:51:554 s——
40x8cc1139e…3eb6d26,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 crvUSD16 s50.5 bp79.1 bp = 50.36 + (175 − 50.36) × √(16/300)
50x4245a44e…f5c7326,242,271 (16:48:11)pool.exchange: bought 0.000389 WETH with 1 crvUSD, away from the stored price report16 s—175.0 bp (not corrective)
60x1e911217…869fd26,242,235 (16:40:59)deliver_oracle_report of a price report 28 s after its observation28 s—Reverted "report too old"
70x4056b1ce…c645026,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 inclusion40 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_timestampAgecrvUSD outFee
17926015150 s5.02411950.0 bp
179260152712 s5.01149575.0 bp
179260157560 s4.995892105.9 bp
1792601665150 s4.979488138.4 bp
1792601815300 s4.961002175.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)FeeGross at the price report
$1071.4 bp+$0.001
$5068.6 bp−$0.004
$10065.0 bp−$0.03
$25057.2 bp−$0.29
$500175.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_lifetime is separate from relax_time. For example, with report_lifetime 5,400 s and relax_time 300 s, a price report steers the target for 90 minutes. Its fee discount ends after 5 minutes. To read both, call policy.config().

Target and step​

To read these settings, call policy.config().driver.

SettingExampleMeaning
fast_half_life4,800 s (80 min)Half-life of the fast EMA
slow_half_life9,600 s (160 min)Half-life of the slow EMA
trend_gate0.007e18 (0.7%)Trend at which the target equals the observed price
step_cap0.005e18 (0.5%)Largest requested move per touch, as a share of price_scale
report_lifetime5,400 sAge 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_price can move price_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_scale does 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 at adjustment_step_max and 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_scale target 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_scale move changes the LEVAMM oracle price 2 × virtual_price × √price_scale × crvUSD price. That can open a VirtualPool arbitrage. See Arbitrage.
  • A price_scale move 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() and oracle() within the owner limits. This change needs no Curve DAO vote. set_oracle_config drops the stored price report price. A change to relax_time or report_lifetime can 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_ADMIN can kill the policy at any time. While the policy is killed, the pool uses its native fee and native price_scale target, and deliveries revert. Killing drops the stored price report price. Revival calls sync().
  • 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_fee returns fallback_fee. The pool charges fallback_fee / 2 × Sdiff / S of the LP amount, plus NOISE_FEE (0.1 bp). An add during the donation protection period also pays a spam penalty, at most the same rate. Sdiff / S is 0 for a balanced operation and 1 for a one-coin operation. So with a 175 bp fallback_fee, a one-coin add pays 87.5 bp. A balanced remove_liquidity pays 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_tolerance off 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_liquidity calls 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 pay fallback_fee until someone calls sync().

Owner limits​

set_policy_config and set_oracle_config revert outside these bounds:

SettingBound
base_fee0.1 bp ≤ base_fee ≤ fallback_fee
fallback_fee≤ 10%
capture≤ 100%
relax_time1 s to 3,600 s
fast_half_life, slow_half_life600 s ≤ fast ≤ slow ≤ 604,800 s
trend_gate≤ 10%
step_capabove 0, ≤ 60 bp
report_lifetime≤ 14,400 s
ratio_tolerance≤ 10 bp
verifierMust be a contract
channel1 to 4
past_window_us≤ 12 s
future_window_us≤ 6 s
min_price, max_price$1e-12 ≤ min ≤ max ≤ $1e12