diff --git a/.claude/rules/predict-contracts.md b/.claude/rules/predict-contracts.md index c1eacee4b..16e814ef8 100644 --- a/.claude/rules/predict-contracts.md +++ b/.claude/rules/predict-contracts.md @@ -135,12 +135,12 @@ Predict-specific Move rules: package architecture, config and capability shapes, - Terminology: docs use options/structured-product vocabulary (canonical glossary: `packages/predict/docs/glossary.md`). Mint-economics identifiers are `entry_value`, `net_premium`, `financed_amount`, `min_net_premium` (and the `OrderMinted.net_premium` event field). The `floor_*` family (`floor_shares` = the static floor `F`, and `floor_amount`) is the payoff primitive — never rename it to financing/debt vocabulary; the glossary bridges the two. The EWMA congestion surcharge keeps core's `penalty` vocabulary in code. - For Predict mint slippage, spell out what the bound controls. `mint_exact_quantity`'s `max_cost` caps the all-in account withdrawal (`net_premium + trader-paid fee + builder_fee + EWMA penalty`), while `max_probability` caps the quoted per-contract probability before fees. `mint_exact_amount` caps only the `net_premium` budget and uses `min_quantity` as the slippage guard; fees and penalties are paid on top. `OrderMinted` emits payment components separately, not a total. If the check is just summing local payment components, keep the sum in the assert instead of adding a one-use helper. - Leveraged Predict economics are part of the contract terms. Leverage sets a static deterministic floor `F` frozen at mint (`floor_shares = F`); pricing, NAV, payout, settlement, and liquidation model one contract with a static floor and a knock-out level, deriving value from the packed order terms rather than bolting on separate leverage-specific scans. A winner redeems `quantity - floor_shares`; the order knocks out when its gross value reaches `floor_amount / liquidation_ltv`. -- Contract floors should track only the atomic values needed by each index. NAV is the exact per-expiry recoverable value (payout-tree `walk_linear` minus the leveraged `correction`, floored) — economically `Σ qty·P - Σ min(qty·P, F)`. Payout backing uses the exact terminal payout `quantity - floor_shares` plus the aggregate disjoint-backing λ buffer, so live backing can be read without scanning or passing a clock. +- Contract floors should track only the atomic values needed by each index. NAV carries the approximate per-expiry recoverable value (payout-tree `walk_linear` minus the leveraged `correction`, floored) as one signed center-plus-error certificate — economically `Σ qty·P - Σ min(qty·P, F)`. Payout backing uses the exact terminal payout `quantity - floor_shares` plus the aggregate disjoint-backing λ buffer, so live backing can be read without scanning or passing a clock. - An index may aggregate raw order atoms or derived terms. If it aggregates derived terms, removal must subtract bit-equal what insertion added, so the term derivation must be a single owned function (the canonical evaluator, `strike_payout_tree::payout_terms`) called by mint insert, remove, reinsert, and any settlement recompute — never re-implemented per call site. The evaluator must stay pure (snapshotted config plus caller atoms only; no clock, no oracle, no policy asserts — mint admission stays in the mint-only wrapper), every atom it reads must round-trip losslessly through the packed order ID — the id packs boundary ticks, `quantity`, `floor_shares`, and `sequence`, so a lossy repack of `quantity` or `floor_shares` is an accounting bug, not a precision nit (`opened_at_ms` is no longer packed) — and the snapshotted config fields it reads (e.g. `backing_buffer_lambda`) must never gain live-expiry setters. Re-calling the evaluator or one of its sub-primitives is free and safe; re-expressing its formula anywhere is the violation. Flow policy (e.g. mint admission) validates its bounds *before* evaluating terms, so the evaluator's own aborts always mean a broken atom round-trip, never bad flow input. - Payout backing for a winning order is the exact `quantity - floor_shares` (equal to the settled payout under the static floor); the only conservatism is the aggregate disjoint-backing λ buffer (D030). Do not reintroduce a clock-dependent or rising-floor max-live backing term — the static floor makes the terminal payout exact. - NAV may use aggregate floor accounting only under an explicit precondition that every active leveraged order is individually above its floor before valuation. If that invariant is not maintained by the surrounding health/liquidation flow, aggregate subtraction can overstate recoverable value and the implementation must fall back to exact per-order recoverability or another exact representation. - For leveraged Predict economics, the contract floor is limited-recourse to the order that created it. A floor can offset only that order's live value or settled payout, capped at that value/payout. Do not treat aggregate floor value that exceeds aggregate position liability as positive NAV unless the implementation explicitly models exact per-order recoverability. -- **NAV-mark directional invariant — never undercount the SUPPLY mark.** The NAV mark that prices PLP **supply** must be an *upper bound* on true recoverable value (`supply_NAV >= TRUE_NAV`): a supplier priced `>= TRUE` mints `<=` fair shares, so it can never over-mint and dilute incumbents. The flush prices supply AND withdraw at **one mark** — `pool_nav = idle + Σ active-expiry current_nav` (net of the pending-protocol-profit exclusion), computed once in `finish_flush` and passed to both queues in `drain_lp_requests` — so that single mark must equal TRUE in both directions. It does: each `expiry_market::current_nav` is the **EXACT** per-expiry recoverable value (free cash minus the exact per-order live liability = payout-tree `walk_linear` minus the leveraged-book `correction_value`, floored at zero), so `supply_NAV = TRUE` at the valuation boundary — never an under- or over-count. There is **no conservative band** (the bucket/band decomposition belonged to the deleted approximate-NAV world); NAV manipulation is closed by the **privileged** cron flush (audit L8), and dilution by the fair FIFO drain at the frozen mark. A liveness clamp inside `current_nav` (the degenerate-underwater `saturating_sub` cash floor) must *maximize* NAV when it fires. A settlement-dependent mark — a **past-expiry-but-unsettled** market — has no well-defined TRUE, so it cannot be valued until settlement-v2: do NOT substitute an approximate mark (contribute-0 dilutes incumbents on supply; free-cash over-pays withdrawals — both break the single-mark dual-use). This is the documented flush-liveness precondition on `expiry_market::current_nav` / `plp::value_expiry`. +- **Numerical-certificate policy — fail closed where uncertainty transfers economic value.** Pricing creates an `Approx` as soon as numerical error exists and carries that center-plus-error fact intact until the policy owner deconstructs it. Mint admission aborts before creating an order when the contract-price certificate exceeds 0.1%, because an uncertain entry price transfers real value between the trader and pool. Full-pool valuation aborts before draining queues when the pool-NAV certificate exceeds 1%; within that bound, `finish_flush` freezes one paired mark over the same pre-drain PLP supply: supplies price at `center + error` (`supply_NAV >= TRUE_NAV`, preventing over-mint dilution) and withdrawals at `center - error` (`withdraw_NAV <= TRUE_NAV`, preventing overpayment). A zero center produces two zero, non-executable marks so the flush can complete without moving value. This certified bid/ask is not the deleted heuristic NAV band or a withdrawal fee: it is the terminal interpretation of one propagated error certificate. Live close and liquidation continue to use the canonical scalar center with no certificate-dependent branch; liquidation's protocol-chosen LTV threshold already carries a materially larger economic buffer, so numerical error does not create a second liquidation boundary. The privileged flush still closes oracle-timing manipulation. A settlement-dependent **past-expiry-but-unsettled** market has neither a well-defined center nor a certifiable error bound and therefore still blocks valuation; do not substitute a synthetic mark. - Leveraged Predict orders must satisfy `floor_shares <= quantity` (`F <= Q`) at creation — the static floor cannot exceed the max payout. Trading fees and builder fees are transaction costs, not floor value, and should not be included in this invariant. ## Predict Gas & Capacity @@ -148,4 +148,4 @@ Predict-specific Move rules: package architecture, config and capability shapes, Measured localnet capacity findings live in `packages/predict/predeploy/evidence/` (consolidated model: open-items C-1). Respect them when touching the flush / NAV / liquidation paths. - **The full-pool flush is ONE mandatory PTB** (`PoolValuation` is a hot potato) that values EVERY active market. The landed NAV price memo made the single-market 5,000 leveraged-order cap safe in the measured cheap branch (~2.36-2.68B MIST, ~47-54% of the wall), but the independent caps (24 markets / 1,000 nodes / 5,000 leveraged) still do not compose: the pool-total case OOGed around 8.6k leveraged orders across ~9 markets before a clean gas-only boundary was isolated. Any new growth-sensitive cap must be checked against `Σ_markets` under the ~5M computation wall, not in isolation. - **A batched leveraged mint/redeem amplifies the per-op cost vs standalone (measured ~15-20× at a saturated book, replicated across 2 runs; a PTB of ~110-150 leveraged mints OOGs the 5e9 cap — data-dependent on book, and a 100-mint PTB is ~3.4B = 68% of the cap).** The mechanism is per-TRANSACTION metering / command-position accumulation, NOT liq-book dirtying: a leveraged mint appended after 20 *1x* mints — which never touch the liquidation book (`insert_order` is a no-op for 1x) — is amplified just as much (more, by position), so the cost scales with command POSITION / accumulated tx state, not with prior `insert_order` writes to `book.pages` (harness `mint-batch` experiment, `packages/predict/predeploy/evidence/c3-mint-batch-2026-07-01.md`; this REFUTES the earlier "dirtied dynamic fields" claim). `range_price` (`select_liquidation_candidates`) is pure math. Don't assume an op's standalone cost holds when batched — and the ceiling binds ANY large multi-command PTB, not just scan-heavy ops. -- **`lp_pool_value` floors at 0** (`gross.saturating_sub(exclusion + pending_protocol_profit)`); when the sticky profit basis exceeds a collapsed gross it returns 0, and `supply_shares`/`withdraw_dusdc` then abort `EInvalidDrainMark` — a supply/withdraw brick. Treat NAV==0 as a real reachable state, not just an underflow guard. +- **`lp_pool_value_approx` floors its center at 0** after subtracting the exact exclusion and pending protocol profit; when the sticky profit basis exceeds a collapsed gross it returns a zero-center certificate, and `finish_flush` creates two zero, non-executable marks so queued supplies and withdrawals are refunded rather than bricking the flush. Treat NAV==0 as a real reachable state, not just an underflow guard. diff --git a/packages/fixed_math/sources/approx.move b/packages/fixed_math/sources/approx.move new file mode 100644 index 000000000..337a277b5 --- /dev/null +++ b/packages/fixed_math/sources/approx.move @@ -0,0 +1,337 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +/// A 1e9-scaled fixed-point value carried with a certified numerical-error bound — +/// a center-radius "ball". `value` is the canonical fixed-point result selected by +/// its caller; `error` bounds continuous numerical approximation along that path. +/// The ball does not choose protocol policy or represent counterfactual outcomes +/// from taking another branch: consequence-owning call sites either use the center +/// or enforce a bound on the radius. +/// +/// Leaf error comes from each `math` primitive's documented accuracy; continuous +/// propagation uses derivative bounds or endpoint evaluation. Every error term +/// rounds UP, the quotient-rule term is computed division-first so it cannot +/// underflow, and error arithmetic saturates at `u64::MAX` rather than overflowing. +module fixed_math::approx; + +use fixed_math::{i64::{Self, I64}, math}; + +/// A fixed-point value with a certified absolute error radius (raw 1e9 units). +public struct Approx has copy, drop { + value: I64, + error: u64, +} + +// Leaf approximation error of each `math` primitive, in raw 1e9 units, taken from +// the primitive's documented accuracy in `fixed_math::math`. +macro fun cdf_leaf(): u64 { 20 } + +macro fun pdf_leaf(): u64 { 50 } + +macro fun sqrt_leaf(): u64 { 1 } + +// `mul`/`div` and the scaled `i64` ops carry at most one raw unit of rounding. +macro fun round_leaf(): u64 { 1 } + +// Global bound on `|phi'(x)| = |x| * phi(x)`, maximized at `|x| = 1` +// (`phi(1) = 0.241971`), rounded up. Bounds `normal_pdf`'s sensitivity to its input. +macro fun max_pdf_slope(): u64 { 242_000_000 } + +// === Constructors and accessors === + +/// A ball with zero error: an exact signed input. +public fun exact(value: I64): Approx { + Approx { value, error: 0 } +} + +/// A ball with zero error from a nonnegative u64 input. +public fun exact_u64(value: u64): Approx { + Approx { value: i64::from_u64(value), error: 0 } +} + +/// A ball from a value and an explicit error radius. +public fun from_parts(value: I64, error: u64): Approx { + Approx { value, error } +} + +public fun value(a: &Approx): I64 { + a.value +} + +public fun error(a: &Approx): u64 { + a.error +} + +/// The magnitude of the center value (its error is unaffected by the sign). +public fun magnitude(a: &Approx): u64 { + a.value.magnitude() +} + +public fun is_negative(a: &Approx): bool { + a.value.is_negative() +} + +/// Whether every value in the ball is within `max_deviation` (relative, 1e9-scaled) +/// of its possible true value. For nonnegative protocol values the worst denominator +/// is `center - error`, so certification requires +/// `error <= max_deviation * (center - error) / 1e9`. +/// +/// Callers own the bound, abort code, and any zero-magnitude policy. The products +/// use u128 so every pair of u64 operands is representable without saturation. +public fun true_relative_deviation_within(a: &Approx, max_deviation: u64): bool { + let center = a.value.magnitude(); + if (a.error > center) return false; + (a.error as u128) * (math::float_scaling!() as u128) + <= (max_deviation as u128) * ((center - a.error) as u128) +} + +/// Clamp to zero. This continuous projection is 1-Lipschitz, so it retains the +/// numerical radius even when the canonical center is on the zero branch. +public fun clamp_nonnegative(a: &Approx): Approx { + if (a.value.is_negative()) { + Approx { value: i64::zero(), error: a.error } + } else { + *a + } +} + +/// Clamp a probability to `[0, 1]`; both continuous projections retain radius. +public fun clamp_unit_interval(a: &Approx): Approx { + let nonnegative = a.clamp_nonnegative(); + nonnegative.clamp_upper(math::float_scaling!()) +} + +/// Clamp to an exact upper bound. Negative centers already lie below every +/// nonnegative upper bound; the continuous projection retains radius. +public fun clamp_upper(a: &Approx, upper: u64): Approx { + if (!a.value.is_negative() && a.value.magnitude() > upper) { + Approx { + value: i64::from_u64(upper), + error: a.error, + } + } else { + *a + } +} + +// === Linear operations === + +/// Sum. Absolute errors add (saturating). +public fun add(a: &Approx, b: &Approx): Approx { + Approx { value: a.value.add(&b.value), error: saturating_add(a.error, b.error) } +} + +/// Difference. Absolute errors add (subtraction cannot cancel uncertainty). +public fun sub(a: &Approx, b: &Approx): Approx { + Approx { value: a.value.sub(&b.value), error: saturating_add(a.error, b.error) } +} + +/// Negation. The error radius is unchanged. +public fun neg(a: &Approx): Approx { + Approx { value: a.value.neg(), error: a.error } +} + +/// Exact doubling: value and error both double (integer addition, no truncation). +public fun double(a: &Approx): Approx { + add(a, a) +} + +/// Halving by an exact factor of two. The value truncates toward zero (one raw +/// unit); the error is kept in full — a sound over-estimate of the true half-error, +/// negligible where used (the `d2` numerator, dominated by the `1/sqrt(w)` term). +public fun half(a: &Approx): Approx { + Approx { + value: i64::from_parts(a.value.magnitude() / 2, a.value.is_negative()), + error: saturating_add(a.error, round_leaf!()), + } +} + +// === Scaled multiplicative operations === + +/// Scaled product. Propagates via the product rule +/// `d(ab) = |a| db + |b| da + da db`, each term rounded up, plus one raw unit. +public fun mul_scaled(a: &Approx, b: &Approx): Approx { + let ma = a.value.magnitude(); + let mb = b.value.magnitude(); + let error = saturating_add( + saturating_add( + saturating_add(ceil_mul(ma, b.error), ceil_mul(mb, a.error)), + ceil_mul(a.error, b.error), + ), + round_leaf!(), + ); + Approx { value: a.value.mul_scaled(&b.value), error } +} + +/// Scaled square of a signed ball, returning a nonnegative ball. +/// Propagates via `d(x^2) = 2|x| dx + dx^2`, rounded up, plus one raw unit. +public fun square_scaled(a: &Approx): Approx { + let m = a.value.magnitude(); + let cross = ceil_mul(m, a.error); + let error = saturating_add( + saturating_add(saturating_add(cross, cross), ceil_mul(a.error, a.error)), + round_leaf!(), + ); + Approx { value: i64::from_u64(a.value.square_scaled()), error } +} + +/// Scaled quotient. Propagates via the quotient rule with the denominator taken at +/// the worst corner `|b| - db`. The `|a| db / b^2` term is computed division-first +/// (`ceil(|a| db / b)` then `/ b`) so a small numerator cannot underflow it to zero. +/// A denominator that can reach zero (`|b| <= db`) cannot be certified; its error +/// saturates so any downstream gate rejects it. +public fun div_scaled(a: &Approx, b: &Approx): Approx { + let ma = a.value.magnitude(); + let mb = b.value.magnitude(); + let value = a.value.div_scaled(&b.value); + if (mb <= b.error) { + return Approx { value, error: std::u64::max_value!() } + }; + let denom = mb - b.error; + let first = ceil_div(a.error, denom); // |da / b| + let second = ceil_div(ceil_mul_div(ma, b.error, denom), denom); // |a| |db| / b^2 + let error = saturating_add(saturating_add(first, second), round_leaf!()); + Approx { value, error } +} + +/// Fused `a * b / c`, matching `math::mul_div_down`'s single-floor magnitude so +/// the center stays bit-identical to the scalar path. The radius comes from exact +/// outward corner evaluation over the three input balls, not a linearization: when +/// both numerator factors retain their signs, their quotient magnitude lies in +/// `[(|a|-da)(|b|-db)/(|c|+dc), (|a|+da)(|b|+db)/(|c|-dc)]`; +/// otherwise the numerator may cross zero and either output sign is covered. A +/// denominator that can reach zero, or an endpoint that cannot be represented in +/// the u64 error domain, saturates so every downstream precision gate rejects it. +public fun mul_div_down(a: &Approx, b: &Approx, c: &Approx): Approx { + let ma = a.value.magnitude(); + let mb = b.value.magnitude(); + let mc = c.value.magnitude(); + let value_magnitude = math::mul_div_down(ma, mb, mc); + let value = i64::from_parts( + value_magnitude, + a.value.is_negative() != b.value.is_negative() != c.value.is_negative(), + ); + if (mc <= c.error) { + return Approx { value, error: std::u64::max_value!() } + }; + let max = std::u64::max_value!(); + if (ma > max - a.error || mb > max - b.error) { + return Approx { value, error: max } + }; + + let upper = ceil_mul_div(ma + a.error, mb + b.error, mc - c.error); + if (upper == max) { + return Approx { value, error: max } + }; + let numerator_sign_is_fixed = ma > a.error && mb > b.error; + let error = if (numerator_sign_is_fixed) { + let lower = if (mc > max - c.error) { + 0 + } else { + math::mul_div_down(ma - a.error, mb - b.error, mc + c.error) + }; + let lower_distance = value_magnitude - lower; + let upper_distance = if (upper > value_magnitude) upper - value_magnitude else 0; + if (lower_distance >= upper_distance) lower_distance else upper_distance + } else { + // Crossing either numerator factor through zero can reverse the quotient's + // sign relative to the canonical center, so the farthest endpoint is the + // sum of their magnitudes. + saturating_add(value_magnitude, upper) + }; + Approx { value, error } +} + +// === Transcendental operations === + +/// `ln` of a positive u64 ball. Value error is bounded by `dx / (x - dx)` +/// (worst-corner `1/x`, rounded up) plus `ln`'s approximation error: `1e-7` relative +/// plus a three-raw-unit margin covering the near-`ln(1)` quantization regime. +public fun ln(x: u64, x_error: u64): Approx { + let value = math::ln(x); + let leaf = value.magnitude() / 10_000_000 + 3; + let propagated = if (x > x_error) ceil_div(x_error, x - x_error) else std::u64::max_value!(); + Approx { value, error: saturating_add(propagated, leaf) } +} + +/// `ln(numerator / denominator)` for exact positive u64 inputs. The ordinary +/// 1e9 quotient path preserves its established center and error. Ratios whose +/// floored quotient cannot keep a positive lower corner instead subtract the two +/// certified logarithms, so every finite ratio remains finite. +public fun ln_ratio(numerator: u64, denominator: u64): Approx { + let ratio_opt = math::try_mul_div_down(numerator, math::float_scaling!(), denominator); + if (ratio_opt.is_some()) { + let ratio = ratio_opt.destroy_some(); + if (ratio > 1) return ln(ratio, 1) + }; + + let numerator_log = ln(numerator, 0); + let denominator_log = ln(denominator, 0); + numerator_log.sub(&denominator_log) +} + +/// `sqrt` of a nonnegative ball (operand scale 1e9). Monotone, so the true value is +/// enclosed by `[sqrt(x - dx), sqrt(x + dx)]`; the error is the larger endpoint +/// deviation from `sqrt(x)`, plus one raw unit for `sqrt`'s own rounding. Uses the +/// center magnitude; callers guard nonnegativity of the center. +public fun sqrt(a: &Approx): Approx { + let x = a.value.magnitude(); + let root = math::sqrt(x, math::float_scaling!()); + let low = if (x > a.error) math::sqrt(x - a.error, math::float_scaling!()) else 0; + let upper = if (a.error > std::u64::max_value!() - x) std::u64::max_value!() else x + a.error; + let high = math::sqrt(upper, math::float_scaling!()); + let spread = if (root - low >= high - root) { root - low } else { high - root }; + Approx { value: i64::from_u64(root), error: spread + sqrt_leaf!() } +} + +/// `Phi(x)` for a signed ball. `Phi' = phi`, maximized over the ball at the point +/// nearest zero. The PDF primitive's own error is added before using it as an upper +/// derivative bound; `phi_upper * dx` is rounded up, then the CDF leaf error is added. +public fun normal_cdf(a: &Approx): Approx { + let value = i64::from_u64(math::normal_cdf(&a.value)); + let m = a.value.magnitude(); + let nearest = if (m > a.error) i64::from_u64(m - a.error) else i64::zero(); + let sup_phi = saturating_add(math::normal_pdf(&nearest), pdf_leaf!()); + let error = saturating_add(ceil_mul(sup_phi, a.error), cdf_leaf!()); + Approx { value, error } +} + +/// `phi(x)` for a signed ball. `|phi'|` is bounded globally by `max_pdf_slope`, so +/// `max_pdf_slope * dx` (rounded up) bounds the propagated error, plus `normal_pdf`'s +/// own approximation error. +public fun normal_pdf(a: &Approx): Approx { + let value = i64::from_u64(math::normal_pdf(&a.value)); + let error = saturating_add(ceil_mul(max_pdf_slope!(), a.error), pdf_leaf!()); + Approx { value, error } +} + +// === Private === + +/// `ceil(x * y / 1e9)`, saturating to `u64::MAX`. Scaled error products round up. +fun ceil_mul(x: u64, y: u64): u64 { + ceil_mul_div(x, y, math::float_scaling!()) +} + +/// `ceil(x * 1e9 / y)`, saturating to `u64::MAX`. Scaled error quotients round up. +fun ceil_div(x: u64, y: u64): u64 { + ceil_mul_div(x, math::float_scaling!(), y) +} + +/// `ceil(x * y / d)`, saturating to `u64::MAX` and guarding the u128 product so a +/// saturated (`u64::MAX`) error operand cannot overflow. `d == 0` saturates. +fun ceil_mul_div(x: u64, y: u64, d: u64): u64 { + if (d == 0) return std::u64::max_value!(); + let xu = x as u128; + let yu = y as u128; + if (yu != 0 && xu > std::u128::max_value!() / yu) return std::u64::max_value!(); + let num = xu * yu; + let du = d as u128; + let quotient = num / du; + if (quotient >= (std::u64::max_value!() as u128)) return std::u64::max_value!(); + (if (num % du > 0) { quotient + 1 } else { quotient }) as u64 +} + +fun saturating_add(a: u64, b: u64): u64 { + let max = std::u64::max_value!(); + if (a > max - b) max else a + b +} diff --git a/packages/fixed_math/sources/math.move b/packages/fixed_math/sources/math.move index ab103e918..873f82f44 100644 --- a/packages/fixed_math/sources/math.move +++ b/packages/fixed_math/sources/math.move @@ -75,6 +75,13 @@ public fun mul(x: u64, y: u64): u64 { (((x as u128) * (y as u128)) / F) as u64 } +/// Multiply two 1e9-scaled fixed-point values, rounding up. `x*y + F - 1` cannot +/// overflow u128 for any u64 operands, so no intermediate guard is needed. +/// Directed counterpart of `mul`. +public fun mul_up(x: u64, y: u64): u64 { + (((x as u128) * (y as u128) + (F - 1)) / F) as u64 +} + /// Divides two 1e9-scaled fixed-point values, rounding down. /// Aborts when `y` is zero or the quotient does not fit in `u64`. public fun div(x: u64, y: u64): u64 { @@ -179,6 +186,30 @@ public fun sqrt(x: u64, precision: u64): u64 { (sqrt_u128(scaled) / multiplier) as u64 } +/// Integer square root rounded down for a wide nonnegative intermediate. +/// Formula-specific callers retain ownership of their scale and range invariants. +public fun sqrt_u128(x: u128): u128 { + if (x == 0) return 0; + if (x < 4) return 1; + let mut g = sqrt_initial_guess_u128(x); + g = (g + x / g) / 2; + g = (g + x / g) / 2; + g = (g + x / g) / 2; + g = (g + x / g) / 2; + g = (g + x / g) / 2; + g = (g + x / g) / 2; + g = (g + x / g) / 2; + if (g > x / g) { g = g - 1; }; + g +} + +/// Integer square root rounded up for a wide nonnegative intermediate. +/// Formula-specific callers retain ownership of their scale and range invariants. +public fun sqrt_u128_up(x: u128): u128 { + let root = sqrt_u128(x); + if (root * root == x) root else root + 1 +} + /// 10^n for small non-negative n. Capped at 18 because 10^19 overflows u64. public fun pow10(n: u64): u64 { assert!(n <= 18, EPow10ExponentTooLarge); @@ -329,22 +360,6 @@ fun mul_scaled_u128(x: u128, y: u128): u128 { x * y / F } -/// Integer square root for u128 values. -fun sqrt_u128(x: u128): u128 { - if (x == 0) return 0; - if (x < 4) return 1; - let mut g = sqrt_initial_guess_u128(x); - g = (g + x / g) / 2; - g = (g + x / g) / 2; - g = (g + x / g) / 2; - g = (g + x / g) / 2; - g = (g + x / g) / 2; - g = (g + x / g) / 2; - g = (g + x / g) / 2; - if (g * g > x) { g = g - 1; }; - g -} - /// Initial power-of-two guess for Newton square root. fun sqrt_initial_guess_u128(x: u128): u128 { let mut bits: u8 = 0; diff --git a/packages/fixed_math/tests/math/approx_tests.move b/packages/fixed_math/tests/math/approx_tests.move new file mode 100644 index 000000000..c4f8186aa --- /dev/null +++ b/packages/fixed_math/tests/math/approx_tests.move @@ -0,0 +1,290 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +#[test_only] +module fixed_math::approx_tests; + +use fixed_math::{approx::{Self, Approx}, i64::{Self, I64}, math::{Self, float_scaling as float}}; +use std::unit_test::assert_eq; + +const EUnexpectedSuccess: u64 = 999; +// Arbitrary-precision Decimal references emitted by +// `packages/predict/tests/helper/reference/generate_constants.py`. +const LN_RATIO_TWO_REFERENCE: u64 = 693_147_181; +const LN_RATIO_ONE_RAW_REFERENCE_MAG: u64 = 20_723_265_837; +const LN_RATIO_UNDERFLOW_REFERENCE_MAG: u64 = 20_772_056_001; +const LN_RATIO_U64_MAX_REFERENCE: u64 = 44_361_419_556; + +fun assert_center(ball: &Approx, magnitude: u64, negative: bool) { + assert_eq!(ball.magnitude(), magnitude); + assert_eq!(ball.is_negative(), negative); +} + +fun assert_contains(ball: &Approx, candidate: I64) { + let center = ball.value(); + let distance = if (center.is_negative() == candidate.is_negative()) { + let center_magnitude = center.magnitude(); + let candidate_magnitude = candidate.magnitude(); + if (center_magnitude >= candidate_magnitude) { + (center_magnitude - candidate_magnitude) as u128 + } else { + (candidate_magnitude - center_magnitude) as u128 + } + } else { + (center.magnitude() as u128) + (candidate.magnitude() as u128) + }; + assert!(distance <= (ball.error() as u128)); +} + +#[test] +fun constructors_and_linear_operations_preserve_the_scalar_center() { + let a = approx::from_parts(i64::from_parts(3 * float!() / 2, true), 7); + let b = approx::from_parts(i64::from_u64(float!() / 4), 11); + + let sum = a.add(&b); + assert_center(&sum, 5 * float!() / 4, true); + assert_eq!(sum.error(), 18); + + let difference = a.sub(&b); + assert_center(&difference, 7 * float!() / 4, true); + assert_eq!(difference.error(), 18); + + let negated = a.neg(); + assert_center(&negated, 3 * float!() / 2, false); + assert_eq!(negated.error(), 7); + + let doubled = a.double(); + assert_center(&doubled, 3 * float!(), true); + assert_eq!(doubled.error(), 14); + + let halved = a.half(); + assert_center(&halved, 3 * float!() / 4, true); + assert_eq!(halved.error(), 8); + + let exact = approx::exact_u64(42); + assert_center(&exact, 42, false); + assert_eq!(exact.error(), 0); +} + +#[test] +fun continuous_clamps_retain_the_radius() { + let negative = approx::from_parts(i64::from_parts(10, true), 7); + let zero = negative.clamp_nonnegative(); + assert_center(&zero, 0, false); + assert_eq!(zero.error(), 7); + + let above_one = approx::from_parts(i64::from_u64(2 * float!()), 9); + let one = above_one.clamp_unit_interval(); + assert_center(&one, float!(), false); + assert_eq!(one.error(), 9); + + let below_upper = negative.clamp_upper(float!()); + assert_center(&below_upper, 10, true); + assert_eq!(below_upper.error(), 7); +} + +#[test] +fun mul_scaled_encloses_all_positive_corners_and_keeps_scalar_center() { + let a = approx::from_parts(i64::from_u64(3 * float!() / 2), float!() / 5); + let b = approx::from_parts(i64::from_u64(2 * float!()), 3 * float!() / 10); + let result = a.mul_scaled(&b); + + assert_center(&result, 3 * float!(), false); + assert_eq!(result.error(), 910_000_001); + assert_contains(&result, i64::from_u64(2_210_000_000)); + assert_contains(&result, i64::from_u64(3_910_000_000)); +} + +#[test] +fun mul_scaled_encloses_negative_product_corners() { + let a = approx::from_parts(i64::from_parts(3 * float!() / 2, true), float!() / 5); + let b = approx::from_parts(i64::from_u64(2 * float!()), 3 * float!() / 10); + let result = a.mul_scaled(&b); + + assert_center(&result, 3 * float!(), true); + assert_contains(&result, i64::from_parts(2_210_000_000, true)); + assert_contains(&result, i64::from_parts(3_910_000_000, true)); +} + +#[test] +fun square_scaled_encloses_fixed_sign_and_zero_crossing_balls() { + let fixed_sign = approx::from_parts( + i64::from_parts(3 * float!() / 2, true), + float!() / 5, + ); + let fixed_result = fixed_sign.square_scaled(); + assert_center(&fixed_result, 2_250_000_000, false); + assert_eq!(fixed_result.error(), 640_000_001); + assert_contains(&fixed_result, i64::from_u64(1_690_000_000)); + assert_contains(&fixed_result, i64::from_u64(2_890_000_000)); + + let crossing = approx::from_parts(i64::from_u64(float!() / 10), float!() / 5); + let crossing_result = crossing.square_scaled(); + assert_center(&crossing_result, 10_000_000, false); + assert_eq!(crossing_result.error(), 80_000_001); + assert_contains(&crossing_result, i64::zero()); + assert_contains(&crossing_result, i64::from_u64(90_000_000)); +} + +#[test] +fun div_scaled_encloses_outward_quotient_corners() { + let a = approx::from_parts(i64::from_u64(3 * float!()), float!() / 5); + let b = approx::from_parts(i64::from_u64(2 * float!()), float!() / 10); + let result = a.div_scaled(&b); + + assert_center(&result, 3 * float!() / 2, false); + assert_eq!(result.error(), 188_365_653); + let lower = math::mul_div_down(14 * float!() / 5, float!(), 21 * float!() / 10); + let upper = math::mul_div_up(16 * float!() / 5, float!(), 19 * float!() / 10); + assert_contains(&result, i64::from_u64(lower)); + assert_contains(&result, i64::from_u64(upper)); +} + +#[test] +fun div_scaled_saturates_when_denominator_ball_reaches_zero() { + let a = approx::exact_u64(float!()); + let b = approx::from_parts(i64::from_u64(float!() / 10), float!() / 10); + assert_eq!(a.div_scaled(&b).error(), std::u64::max_value!()); +} + +#[test] +fun mul_div_down_encloses_both_fixed_sign_corners() { + let a = approx::from_parts(i64::from_u64(3 * float!() / 2), float!() / 10); + let b = approx::from_parts(i64::from_u64(2 * float!()), float!() / 5); + let c = approx::from_parts(i64::from_u64(4 * float!()), float!() / 10); + let result = a.mul_div_down(&b, &c); + + assert_center(&result, 3 * float!() / 4, false); + assert_eq!(result.error(), 152_564_103); + let lower = math::mul_div_down( + 3 * float!() / 2 - float!() / 10, + 2 * float!() - float!() / 5, + 4 * float!() + float!() / 10, + ); + let upper = math::mul_div_up( + 3 * float!() / 2 + float!() / 10, + 2 * float!() + float!() / 5, + 4 * float!() - float!() / 10, + ); + assert_contains(&result, i64::from_u64(lower)); + assert_contains(&result, i64::from_u64(upper)); +} + +#[test] +fun mul_div_down_accounts_for_a_negative_denominator() { + let a = approx::exact_u64(float!()); + let b = approx::exact_u64(float!()); + let c = approx::exact(i64::from_parts(3 * float!(), true)); + let result = a.mul_div_down(&b, &c); + + assert_center(&result, 333_333_333, true); + assert_eq!(result.error(), 1); + assert_contains(&result, i64::from_parts(333_333_334, true)); +} + +#[test] +fun mul_div_down_covers_a_numerator_sign_change() { + let a = approx::from_parts(i64::from_u64(float!() / 10), float!() / 5); + let b = approx::exact_u64(2 * float!()); + let c = approx::exact_u64(float!()); + let result = a.mul_div_down(&b, &c); + + assert_center(&result, float!() / 5, false); + assert_eq!(result.error(), 4 * float!() / 5); + assert_contains(&result, i64::from_parts(float!() / 5, true)); + assert_contains(&result, i64::from_u64(3 * float!() / 5)); +} + +#[test] +fun mul_div_down_saturates_uncertifiable_domains() { + let one = approx::exact_u64(1); + let denominator_crosses_zero = approx::from_parts(i64::from_u64(1), 1); + assert_eq!(one.mul_div_down(&one, &denominator_crosses_zero).error(), std::u64::max_value!()); + + let endpoint_overflows = approx::from_parts(i64::from_u64(std::u64::max_value!()), 1); + assert_eq!(endpoint_overflows.mul_div_down(&one, &one).error(), std::u64::max_value!()); +} + +#[test] +fun transcendental_balls_enclose_independent_endpoint_references() { + // Python stdlib references, rounded to 1e9; these are independent of the + // contract approximations used to construct each center. + let logarithm = approx::ln(2 * float!(), float!() / 2); + assert_contains(&logarithm, i64::from_u64(405_465_108)); // ln(1.5) + assert_contains(&logarithm, i64::from_u64(916_290_732)); // ln(2.5) + + let square_root_input = approx::from_parts(i64::from_u64(4 * float!()), float!()); + let square_root = square_root_input.sqrt(); + assert_center(&square_root, 2 * float!(), false); + assert_eq!(square_root.error(), 267_949_194); + assert_contains(&square_root, i64::from_u64(1_732_050_807)); // floor(sqrt(3) * 1e9) + assert_contains(&square_root, i64::from_u64(2_236_067_977)); // floor(sqrt(5) * 1e9) + + let normal_input = approx::from_parts(i64::from_u64(float!()), float!() / 2); + let cdf = normal_input.normal_cdf(); + assert_contains(&cdf, i64::from_u64(691_462_461)); // Phi(0.5) + assert_contains(&cdf, i64::from_u64(933_192_799)); // Phi(1.5) + + let pdf = normal_input.normal_pdf(); + assert_contains(&pdf, i64::from_u64(352_065_327)); // phi(0.5) + assert_contains(&pdf, i64::from_u64(129_517_596)); // phi(1.5) +} + +#[test] +fun ln_ratio_encloses_independent_references_across_quotient_domains() { + let ordinary = approx::ln_ratio(2 * float!(), float!()); + assert_contains(&ordinary, i64::from_u64(LN_RATIO_TWO_REFERENCE)); + assert!(ordinary.error() < std::u64::max_value!()); + + let one_raw = approx::ln_ratio(10_000_000, 10_000_000_000_000_000); + assert_contains( + &one_raw, + i64::from_parts(LN_RATIO_ONE_RAW_REFERENCE_MAG, true), + ); + assert!(one_raw.error() < std::u64::max_value!()); + + let underflow = approx::ln_ratio(100_000_000, 105_000_000_000_000_000); + assert_contains( + &underflow, + i64::from_parts(LN_RATIO_UNDERFLOW_REFERENCE_MAG, true), + ); + assert!(underflow.error() < std::u64::max_value!()); + + let overflow = approx::ln_ratio(std::u64::max_value!(), 1); + assert_contains(&overflow, i64::from_u64(LN_RATIO_U64_MAX_REFERENCE)); + assert!(overflow.error() < std::u64::max_value!()); +} + +#[test, expected_failure(abort_code = math::EInputZero)] +fun ln_ratio_zero_numerator_aborts() { + approx::ln_ratio(0, float!()); + abort EUnexpectedSuccess +} + +#[test, expected_failure(abort_code = math::EInputZero)] +fun ln_ratio_zero_denominator_aborts() { + approx::ln_ratio(float!(), 0); + abort EUnexpectedSuccess +} + +#[test] +fun normal_cdf_uses_a_certified_upper_bound_for_its_derivative() { + // At x=0.024, independent Python true math gives phi(x)=398_827_401.568... + // raw units, so 398_827_402 is an outward integer upper bound. The ball spans + // [0.024, 20.024], making x=0.024 the exact maximum-density corner. + let radius = 10 * float!(); + let input = approx::from_parts(i64::from_u64(10_024_000_000), radius); + let result = input.normal_cdf(); + let required_propagation = math::mul_div_up(398_827_402, radius, float!()); + assert!(result.error() >= required_propagation + 20); +} + +#[test] +fun error_arithmetic_saturates_instead_of_wrapping() { + let saturated = approx::from_parts(i64::from_u64(float!()), std::u64::max_value!()); + let exact = approx::exact_u64(float!()); + assert_eq!(saturated.add(&exact).error(), std::u64::max_value!()); + assert_eq!(saturated.sub(&exact).error(), std::u64::max_value!()); + assert_eq!(saturated.mul_scaled(&exact).error(), std::u64::max_value!()); + assert_eq!(approx::ln(float!(), float!()).error(), std::u64::max_value!()); +} diff --git a/packages/fixed_math/tests/math/math_tests.move b/packages/fixed_math/tests/math/math_tests.move index 6dcb92eb3..cd8f04cbc 100644 --- a/packages/fixed_math/tests/math/math_tests.move +++ b/packages/fixed_math/tests/math/math_tests.move @@ -96,6 +96,29 @@ fun mul_rounds_down_to_integer_unit() { assert_eq!(math::mul(1, 1), 0); } +#[test] +fun mul_up_ceils_one_raw_unit_of_dust() { + // ceil(1 * 1 / 1e9) = 1; mul floors the same product to 0 + assert_eq!(math::mul_up(1, 1), 1); +} + +#[test] +fun mul_up_exact_product_does_not_round() { + // 1.5 * 2.25 = 3.375 exactly at 1e9 scale + assert_eq!(math::mul_up(float!() + float!() / 2, 2 * float!() + float!() / 4), 3_375_000_000); +} + +#[test] +fun mul_up_rounds_half_up_to_next_unit() { + // 7 * 0.5 = 3.5 raw units: ceil = 4, floor = 3 + assert_eq!(math::mul_up(7, float!() / 2), 4); +} + +#[test] +fun mul_up_of_zero_is_zero() { + assert_eq!(math::mul_up(0, float!()), 0); +} + #[test] fun div_floors_scaled_quotient() { // floor(5 / 2 * 1e9) = 2.5e9 diff --git a/packages/fixed_math/tests/math/wide_math_tests.move b/packages/fixed_math/tests/math/wide_math_tests.move new file mode 100644 index 000000000..e0da6ccc1 --- /dev/null +++ b/packages/fixed_math/tests/math/wide_math_tests.move @@ -0,0 +1,88 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +#[test_only] +module fixed_math::wide_math_tests; + +use fixed_math::math; +use std::unit_test::assert_eq; + +/// Verify the defining floor-square-root inequalities without relying on a +/// second square-root implementation or on products that can overflow u128. +fun assert_isqrt_floor(x: u128) { + let root = math::sqrt_u128(x); + if (x == 0) { + assert_eq!(root, 0); + } else { + assert!(root > 0); + assert!(root <= x / root); + let successor = root + 1; + assert!(successor > x / successor); + } +} + +/// Verify the defining ceiling-square-root inequalities using division so the +/// mathematical square above `u128::MAX` never has to be represented. +fun assert_isqrt_ceiling(x: u128) { + let root = math::sqrt_u128_up(x); + if (x == 0) { + assert_eq!(root, 0); + } else { + assert!(root > 0); + assert!(root >= x.div_ceil(root)); + let predecessor = root - 1; + if (predecessor > 0) { + assert!(predecessor < x.div_ceil(predecessor)); + }; + } +} + +fun assert_isqrt_contracts(x: u128) { + assert_isqrt_floor(x); + assert_isqrt_ceiling(x); +} + +#[test] +fun sqrt_u128_floor_and_ceiling_hold_at_every_bit_boundary() { + let mut bit: u8 = 0; + while (bit < 128) { + let boundary = 1u128 << bit; + assert_isqrt_contracts(boundary - 1); + assert_isqrt_contracts(boundary); + if (boundary < std::u128::max_value!()) { + assert_isqrt_contracts(boundary + 1); + }; + bit = bit + 1; + }; +} + +#[test] +fun sqrt_u128_floor_and_ceiling_hold_around_large_perfect_squares() { + let mut bit: u8 = 0; + while (bit < 64) { + let root = 1u128 << bit; + let square = root * root; + if (square > 0) { + assert_isqrt_contracts(square - 1); + }; + assert_isqrt_contracts(square); + if (square < std::u128::max_value!()) { + assert_isqrt_contracts(square + 1); + }; + bit = bit + 1; + }; + + let largest_u64 = std::u64::max_value!() as u128; + let square = largest_u64 * largest_u64; + assert_isqrt_contracts(square - 1); + assert_isqrt_contracts(square); + assert_isqrt_contracts(square + 1); +} + +#[test] +fun sqrt_u128_floor_and_ceiling_hold_at_wide_extremes() { + assert_isqrt_contracts(123_456_789_012_345_678_901_234_567_890); + assert_isqrt_contracts(1u128 << 127); + assert_isqrt_contracts(std::u128::max_value!() - 1); + assert_isqrt_contracts(std::u128::max_value!()); +} diff --git a/packages/predict/docs/README.md b/packages/predict/docs/README.md index 08e3952bd..ffb4085f7 100644 --- a/packages/predict/docs/README.md +++ b/packages/predict/docs/README.md @@ -43,7 +43,7 @@ How the protocol works: position is liquidated, and what the holder receives. - **[Liquidity and NAV](./concepts/liquidity-and-nav.md)** — the pool, PLP shares, the async supply/withdraw queues, the privileged flush, and how the - exact pool NAV is computed. + certified pool NAV bid/ask is computed. ## Design diff --git a/packages/predict/docs/concepts/fees-and-rebates.md b/packages/predict/docs/concepts/fees-and-rebates.md index dde5268cb..640fa15b5 100644 --- a/packages/predict/docs/concepts/fees-and-rebates.md +++ b/packages/predict/docs/concepts/fees-and-rebates.md @@ -4,7 +4,7 @@ Every Predict trade — a mint or a live redeem — carries a trading fee, and m All fees are denominated in DUSDC (6 decimals), the settlement asset, and all ratios use Predict's 1e9 fixed-point scaling (`1_000_000_000` = 1.0 = 100%). For the actual configured rates and bounds, see [../design/configuration.md](../design/configuration.md); this page describes the mechanisms, not the numbers. -This page covers **per-trade** fees. The pool itself charges no LP-side fee: PLP supply and withdraw are priced at one exact pool-wide mark with no band or spread, documented in [./liquidity-and-nav.md](./liquidity-and-nav.md). +This page covers **per-trade** fees. The pool itself charges no LP-side fee. PLP supply and withdraw use a certified numerical-error bid/ask—upper NAV for supply, lower NAV for withdraw—not a fee or configurable spread; see [liquidity and NAV](./liquidity-and-nav.md). ## Where fees come from @@ -24,6 +24,8 @@ congestion_fee = penalty_rate * quantity (only when gas is a The base trading fee, the expiry ramp, and the staking discount together set the **fee rate** a trader pays. The builder fee is an **add-on** computed from the (post-discount) fee. The congestion surcharge is a separate per-unit add-on driven by network state, not by the contract's probability. The trading-loss rebate is funded out of trader-paid trading fees and paid back later, so it lowers a losing trader's *net* cost without changing what is charged at trade time. +The intermediate fixed-point rate calculations round down. The final conversion of the trading-fee rate and congestion-penalty rate into a DUSDC amount rounds upward, changing a non-integral component by at most one raw DUSDC atom; integral charges are unchanged. Builder fees remain derived from the integer post-discount trading fee, so advancing that fee by one atom can also advance the builder component at its own integer threshold. + ## 1. Base trading fee — a variance (Bernoulli) fee A range contract settling inside or outside its range is a Bernoulli outcome with success probability `p`. The variance of that outcome is `p · (1 − p)`, and its standard deviation is `sqrt(p · (1 − p))`. The base fee is proportional to that standard deviation: diff --git a/packages/predict/docs/concepts/leverage-and-floor.md b/packages/predict/docs/concepts/leverage-and-floor.md index b6d713ca9..6b6f4ae22 100644 --- a/packages/predict/docs/concepts/leverage-and-floor.md +++ b/packages/predict/docs/concepts/leverage-and-floor.md @@ -183,8 +183,7 @@ expiry's snapshotted floor-to-value ratio (see [liquidation](./liquidation.md) and [configuration](../design/configuration.md)). After admission, the expiry indexes the same contract two ways: payout terms for -cash backing and settlement, and liquidation terms for the exact NAV floor -correction. +cash backing and settlement, and liquidation terms for the NAV floor correction. ## Live redeem diff --git a/packages/predict/docs/concepts/liquidation.md b/packages/predict/docs/concepts/liquidation.md index 77a88dd15..3bc096219 100644 --- a/packages/predict/docs/concepts/liquidation.md +++ b/packages/predict/docs/concepts/liquidation.md @@ -83,7 +83,7 @@ Selecting candidates advances the passive watermark, so the scan makes forward p The knock-out is a *discretely monitored* barrier: it is enforced by bounded keeper passes, not continuous observation. Because scans are bounded, the protocol never proves in a single transaction that *every* leveraged order is above its floor — and it does not need to. Live pool NAV subtracts each leveraged order's floor from its own range value, capped at it (`min(quantity × range_price, floor_shares)`, summed as the NAV correction — see [leverage and the floor](./leverage-and-floor.md)). Because the cap is taken **per order**, each order's net contribution is `max(0, range_value − floor)` in every state: an order above its floor contributes its excess, and an order *below* its floor — economically exhausted, its true recoverable value zero — nets to exactly zero, never a negative or overstated amount. -`current_nav` is therefore **exact whether or not the book has been swept**: it needs no valuation-time liquidation pass and is not conditional on the book being above floor (see [invariants](../design/invariants.md)). Bounded liquidation exists to clear exhausted orders out of the live indexes — letting holders close worthless positions and releasing each removed order's backing buffer back to the pool — not to keep the NAV mark honest. Liquidation timeliness is thus a holder-facing and index-hygiene concern, not the LP-facing NAV-overstatement risk it would be under an aggregate-floor mark. Admin tuning of the budget and the liquidation LTV trades gas cost per pass against how promptly exhausted positions are cleared. +`current_nav` is therefore **structurally complete whether or not the book has been swept**: it needs no valuation-time liquidation pass and is not conditional on the book being above floor (see [invariants](../design/invariants.md)). Its fixed-point center carries a certified numerical-error radius into the pool flush. Bounded liquidation exists to clear exhausted orders out of the live indexes — letting holders close worthless positions and releasing each removed order's backing buffer back to the pool — not to keep the NAV topology honest. Liquidation timeliness is thus a holder-facing and index-hygiene concern, not the LP-facing NAV-overstatement risk it would be under an aggregate-floor mark. Admin tuning of the budget and the liquidation LTV trades gas cost per pass against how promptly exhausted positions are cleared. ## Events diff --git a/packages/predict/docs/concepts/liquidity-and-nav.md b/packages/predict/docs/concepts/liquidity-and-nav.md index e334c102d..772efa9bc 100644 --- a/packages/predict/docs/concepts/liquidity-and-nav.md +++ b/packages/predict/docs/concepts/liquidity-and-nav.md @@ -1,6 +1,6 @@ # Liquidity and NAV -The Predict pool is the counterparty to every market. Liquidity providers deposit DUSDC, receive PLP shares, and collectively back the payout liability of every active expiry. This page describes how that capital is held, how an expiry's exact net asset value (NAV) is computed, how liquidity enters and leaves through an **asynchronous** supply/withdraw flow, how a privileged daily flush prices and fills those requests at a single frozen mark, how cash flows between the pool and individual expiries, and how settlement profit is split between LPs and the protocol. The recurring invariant is the solvency guarantee: each expiry always holds cash at least equal to its payout liability plus its rebate reserve. +The Predict pool is the counterparty to every market. Liquidity providers deposit DUSDC, receive PLP shares, and collectively back the payout liability of every active expiry. This page describes how that capital is held, how an expiry's net asset value (NAV) and numerical-error certificate are computed, how liquidity enters and leaves through an **asynchronous** supply/withdraw flow, how a privileged daily flush prices those requests at one frozen bid/ask pair, how cash flows between the pool and individual expiries, and how settlement profit is split between LPs and the protocol. The recurring invariant is the solvency guarantee: each expiry always holds cash at least equal to its payout liability plus its rebate reserve. For how markets, orders, and absolute ticks work, see [markets and positions](./markets-and-positions.md). For the leverage floor that drives both pricing and NAV, see [leverage and the floor](./leverage-and-floor.md). For tunable values, see [configuration](../design/configuration.md). For trust assumptions and known caveats, see [risks](../risks.md). @@ -23,7 +23,7 @@ PLP is registered as a 6-decimal currency, matching DUSDC's 6 decimals. Fixed-po ## Async supply and withdraw -LPs do not mint or burn PLP synchronously against a live valuation. Instead they **queue a request** that a later flush prices and fills at one pool-wide mark. This decouples the LP's transaction from the (privileged, oracle-reading) valuation, so an LP can never time their entry or exit against a self-supplied oracle snapshot. +LPs do not mint or burn PLP synchronously against a live valuation. Instead they **queue a request** that a later flush prices against one pool-wide NAV certificate. This decouples the LP's transaction from the (privileged, oracle-reading) valuation, so an LP can never time their entry or exit against a self-supplied oracle snapshot. - **`request_supply`** escrows a DUSDC payment, records the requesting account's receive address as the fill recipient, and stores `min_plp_out`, the minimum PLP the frozen mark must mint. It is routed through the account — not just the tx signer — so a composing vault's own account receives the minted PLP. It returns a queue index. - **`request_withdraw`** escrows PLP, records the account recipient, and stores `min_dusdc_out`, the minimum DUSDC the frozen mark must pay. It returns a queue index. @@ -31,13 +31,13 @@ LPs do not mint or burn PLP synchronously against a live valuation. Instead they Each request must clear a minimum size (`min_supply_request` / `min_withdraw_request`). Escrowed funds sit in the queue until the flush fills or refunds the request, a live limit miss carries it, or the LP cancels it. -## The flush: one frozen mark for both sides +## The flush: one frozen certificate, two directional marks -A daily **flush** values the whole pool once and attempts to drain both queues against that single frozen mark, filling only eligible heads. It is a transaction-local **hot potato** with a strict three-phase shape: +A daily **flush** values the whole pool once and attempts to drain both queues against the bid/ask derived from that certificate, filling only eligible heads. It is a transaction-local **hot potato** with a strict three-phase shape: 1. **`start_pool_valuation`** engages the valuation lock in `ProtocolConfig` and snapshots the set of active expiry markets into the potato (`PoolValuation`). -2. **`value_expiry`** is called once per snapshotted market. Each call rebalances that market's cash against the pool (top up / sweep, described below), then folds the market's NAV into a running total — a settled market contributes `0`; a live market contributes its exact `current_nav`. -3. **`finish_flush`** proves every snapshotted market was valued exactly once, computes the pool NAV, snapshots the share price once, runs the queue drain against it, releases the lock, and consumes the potato. +2. **`value_expiry`** is called once per snapshotted market. Each call rebalances that market's cash against the pool (top up / sweep, described below), then folds the market's NAV center and certified error into a running `Approx` — a settled market contributes exact `0`; a live market contributes `current_nav_approx`. +3. **`finish_flush`** proves every snapshotted market was valued exactly once, computes the pool NAV certificate, rejects a relative error above 1%, freezes its bid/ask over one pre-drain PLP supply, runs the queue drain, releases the lock, and consumes the potato. The potato has no abilities, so the sequence cannot be left half-finished: the only way to release the lock is to finish. @@ -47,16 +47,16 @@ PLP bounds live NAV work separately from the flush itself: each active expiry is Only a market-deployer's `MarketLifecycleCap` may **start** a flush (via `start_pool_valuation`), and the hot potato can be created **only** by starting one, so gating the start gates the whole flush. The root `AdminCap` flush path was removed — the flush is routine maintenance that should run on a revocable cap, not the irrevocable root cap; admin keeps a break-glass route by minting itself a lifecycle cap. -This is a deliberate audit decision (L8): the flush prices supply and withdraw against a live oracle, so leaving it permissionless would let anyone sandwich the mark with their own oracle update. The cap-holder is trusted not to manipulate the live oracle, which is the trust that makes the single frozen mark sound. +This is a deliberate audit decision (L8): the flush prices supply and withdraw against a live oracle, so leaving it permissionless would let anyone sandwich the valuation with their own oracle update. The cap-holder is trusted not to manipulate the live oracle; the numerical certificate bounds evaluation error, not oracle-timing abuse. -### Pool NAV and the single mark +### Pool NAV and the bid/ask `finish_flush` computes the LP-attributable pool NAV from the accumulated active-expiry total: ``` -gross_pool_value = idle_DUSDC + Σ active_expiry current_nav -exclusion = protocol_reserve_profit_share × max(0, (profit_basis_credits + Σ current_nav) − profit_basis_debits) -pool_nav = max(0, gross_pool_value − exclusion − pending_protocol_profit) +gross_pool_value_approx = exact(idle_DUSDC) + Σ active_expiry current_nav_approx +exclusion_approx = protocol_reserve_profit_share × max(0, exact(profit_basis_credits) + Σ current_nav_approx − exact(profit_basis_debits)) +pool_nav_approx = max(0, gross_pool_value_approx − exclusion_approx − exact(pending_protocol_profit)) ``` Both subtracted terms are protocol profit not yet sitting in the reserve, in two phases: @@ -66,19 +66,20 @@ Both subtracted terms are protocol profit not yet sitting in the reserve, in two The two are disjoint: the moment a cut materializes it leaves `exclusion` (its profit enters `profit_basis_debits`) and, if not immediately movable, enters `pending_protocol_profit`. Incentive value is not part of this figure (incentives are out of the pool entirely). -`pool_nav` and the PLP `total_supply` are snapshotted **once** and passed to the drain for both queues. This single mark prices supply and withdraw identically: +`pool_nav_approx` is deconstructed only at this economic boundary. If its relative certificate exceeds 1%, the flush aborts before moving LP value. Otherwise the PLP `total_supply` is snapshotted **once** and both marks use it: -- **Supply fill:** `shares = floor(amount × total_supply / pool_nav)`. -- **Withdraw fill:** `payout = floor(shares × pool_nav / total_supply)`. +- **Supply mark:** `supply_pool_value = center + error`; `shares = floor(amount × total_supply / supply_pool_value)`. +- **Withdraw mark:** `withdraw_pool_value = center − error`; `payout = floor(shares × withdraw_pool_value / total_supply)`. -There is **no band, no separate supply/withdraw pricing, and no optimistic/conservative stance.** Because the same mark must be fair in both directions, it must equal the *true* recoverable value — which it does, because each per-expiry `current_nav` is exact (see [An active expiry's exact NAV](#an-active-expirys-exact-nav)). This is the NAV-mark invariant: the supply mark must never undercount true value (or a supplier could over-mint and dilute incumbents), and a single exact mark satisfies it in both directions. +This is a certified numerical-error spread, not the deleted configurable NAV band and not an LP fee. The supply mark is at or above true NAV, so approximation cannot let a supplier over-mint and dilute incumbents. The withdrawal mark is at or below true NAV, so approximation cannot overpay an exiting LP. A zero center produces two zero, non-executable marks; the drain refunds affected queue heads without moving value. ```mermaid flowchart TD CAP[MarketLifecycleCap] -->|start_pool_valuation| LOCK[lock + snapshot active expiries] - LOCK -->|value_expiry x N| EXP[rebalance cash, fold exact current_nav] - EXP -->|finish_flush| NAV[pool_nav = idle + Sigma current_nav - exclusion - pending protocol cut] - NAV --> DRAIN[drain queues at frozen pool_nav / total_supply] + LOCK -->|value_expiry x N| EXP[rebalance cash, fold NAV center + error] + EXP -->|finish_flush| NAV[pool NAV Approx; require error <= 1 percent] + NAV --> MARKS[withdraw = center - error; supply = center + error] + MARKS --> DRAIN[drain both queues over one pre-drain total_supply] DRAIN --> SUP[supplies first: mint PLP into idle] DRAIN --> WD[then withdrawals FIFO until idle dry] ``` @@ -87,43 +88,43 @@ flowchart TD `lp_book::drain` processes **supplies first, then withdrawals**, each bounded by its own operator-supplied budget — `supply_budget` / `withdraw_budget: Option`, where `None` makes that queue unbounded. The budgets are **independent**, so a supply backlog can never starve withdrawals, and the operator sizes them to the gas left after valuing the snapshotted markets: -- **Supplies pass (FIFO from the head).** Each executable request whose quote satisfies `min_plp_out` mints `supply_shares(amount, total_supply, pool_nav)` PLP and joins the escrowed DUSDC into idle. A head supply whose mark or quote is non-executable — PLP price outside the executable band, zero-share output, or u64 overflow — is protocol-cancelled and refunded instead of aborting the flush; it counts against the supply budget because the queue head was processed. If the quote is executable but below `min_plp_out`, the request records a limit miss, remains queued at the head, spends one processed-budget unit, and stops the supply pass for this flush. On its third miss it expires and is refunded instead of carrying again. -- **Withdrawals pass (FIFO until idle is dry).** Each executable request whose quote satisfies `min_dusdc_out` burns its escrowed PLP and pays `withdraw_dusdc(shares, total_supply, pool_nav)` DUSDC out of idle. A head withdrawal whose mark or quote is non-executable is protocol-cancelled and refunded, counting against the withdraw budget. If the quote is executable but below `min_dusdc_out`, the request follows the same three-miss carry-then-refund rule as supply. If the quote is valid and limit-satisfying but idle cannot cover the head request's payout, the drain **stops** without spending withdraw budget and carries that request and every later one to the next flush — withdrawals are never partially filled or reordered to skip a too-large head. +- **Supplies pass (FIFO from the head).** Each executable request whose quote satisfies `min_plp_out` mints shares from `supply_pool_value` and joins the escrowed DUSDC into idle. A head supply whose mark or quote is non-executable — PLP price outside the executable band, zero-share output, or u64 overflow — is protocol-cancelled and refunded instead of aborting the flush; it counts against the supply budget because the queue head was processed. If the quote is executable but below `min_plp_out`, the request records a limit miss, remains queued at the head, spends one processed-budget unit, and stops the supply pass for this flush. On its third miss it expires and is refunded instead of carrying again. +- **Withdrawals pass (FIFO until idle is dry).** Each executable request whose quote satisfies `min_dusdc_out` burns its escrowed PLP and pays DUSDC from `withdraw_pool_value` out of idle. A head withdrawal whose mark or quote is non-executable is protocol-cancelled and refunded, counting against the withdraw budget. If the quote is executable but below `min_dusdc_out`, the request follows the same three-miss carry-then-refund rule as supply. If the quote is valid and limit-satisfying but idle cannot cover the head request's payout, the drain **stops** without spending withdraw budget and carries that request and every later one to the next flush — withdrawals are never partially filled or reordered to skip a too-large head. Because supplies run before withdrawals, the DUSDC supplied this flush is available to pay this flush's withdrawals. Cash funded into expiries is not directly redeemable until it returns through rebalance or settlement, so a large exit can be bounded by idle and deferred — it cannot force-drain a live market. Fills and refunds are delivered to each recipient account through the **balance accumulator** (`send_funds`): the minted PLP, paid DUSDC, or refunded escrow accumulates against the account's receive address, and the account absorbs it lazily on its next capital operation. The flush never holds an account reference; it only needs the recipient address recorded at request time. -## Full-pool NAV is exact, per expiry +## Full-pool NAV carries one certificate -The pool NAV above is just `idle + Σ current_nav`. The substance is `current_nav`, the **exact** live recoverable value of one expiry. +The pool NAV above is exact idle accounting plus `Σ current_nav_approx`. The substance is how one expiry's complete per-order valuation carries fixed-point error. -### An active expiry's exact NAV +### An active expiry's certified NAV -`current_nav` is a pure read: free cash minus the exact per-order live liability, floored at zero. +`current_nav_approx` is a pure read: exact free cash minus the complete per-order live-liability certificate, floored at zero. The public `current_nav` view returns only its center. ``` -current_nav = max(0, free_cash − exact_live_liability) +current_nav_approx = max(0, exact(free_cash) − live_liability_approx) ``` where: - **`free_cash = cash_balance − rebate_reserve`** — the expiry's DUSDC net of the rebate it still owes. -- **`exact_live_liability = walk_linear − correction_value`**, floored at zero, is the exact mark-to-model liability of every open order: - - **`walk_linear`** is `Σ_orders quantity × P(strike)` — the full payout-tree walk, pricing each distinct boundary tick exactly through the resolved pricer and caching those boundary prices in a transaction-local memo. - - **`correction_value`** is `Σ_(leveraged orders) min(quantity × range_price, floor_shares)` — the floor offset, scanned exactly over the active leveraged book using the memo populated by `walk_linear`. +- **`live_liability_approx = walk_linear − correction_value`**, floored at zero, includes every open order and carries the propagated pricing error: + - **`walk_linear`** is `Σ_orders quantity × P(strike)` — the full payout-tree walk, pricing each distinct boundary tick once through the resolved pricer and caching its `Approx` in a transaction-local memo. + - **`correction_value`** is the per-order leveraged floor or liquidation correction, scanned over the active leveraged book using the same cached approximate range prices. -Subtracting `correction_value` is the leveraged contracts' floor offset, applied per order: each leveraged order's floor offsets only its own range value, capped at it (limited recourse), so the floor of an exhausted order can never spill over to inflate another order's value. The leftover after the floor is the order's recoverable equity, and `free_cash − liability` is exactly the cash the pool keeps once every open contract is marked. +Subtracting `correction_value` is the leveraged contracts' floor offset, applied per order: each leveraged order's floor offsets only its own range value, capped at it (limited recourse), so the floor of an exhausted order can never spill over to inflate another order's value. The representation is complete over the active book; the `Approx` radius certifies the numerical error introduced while evaluating its prices. `current_nav` carries **no backing assert** — it is purely a valuation read. Backing is a separate, always-on invariant owned by the cash leaf (below) and proven on every trade; the `max(0, ·)` cash floor only marks a degenerate (underwater) market at zero, which is its correct limited-recourse value, never negative. -> This replaces the old approximate NAV entirely. There is no longer a verified/unscanned bucket split, no aggregate uncertainty band, and no uncertainty-band withdrawal fee — those belonged to the approximate-NAV world and are gone. NAV is now the exact per-order walk, and supply/withdraw share one exact mark. +> This does not restore the deleted heuristic approximate-NAV design. There is still no verified/unscanned bucket split, configurable uncertainty band, or uncertainty-band withdrawal fee. The current design walks the complete per-order representation, propagates only fixed-point numerical error, rejects a pool certificate above 1%, and consumes the admitted certificate as a directional bid/ask. ### Past-expiry settlement liveness `current_nav` loads a live `Pricer`, so it **aborts** for a market that has crossed its expiry. Settlement is a separate PTB command: the keeper inserts the exact normalized Pyth spot and calls `try_settle` before `value_expiry`. A settled market is swept off the active set and contributes `0`; an expired unsettled market moves no cash and then aborts through `current_nav` until settlement succeeds. -If that exact spot is not present, the market remains unsettled and the live branch still aborts. This is intentional, not a bug: there is no solvency-safe mark for an unsettled past-expiry market. The flush uses one mark for both supply and withdraw, so the mark must equal the settlement-dependent true value — substituting an approximation would either dilute incumbents on supply or overpay withdrawals. +If that exact spot is not present, the market remains unsettled and the live branch still aborts. This is intentional, not a bug: there is no settlement-dependent center from which to derive a solvency-safe certificate or either side of the bid/ask. Substituting a synthetic contribution could either dilute incumbents on supply or overpay withdrawals. ## Pool ↔ expiry cash flow diff --git a/packages/predict/docs/concepts/pricing-and-oracles.md b/packages/predict/docs/concepts/pricing-and-oracles.md index 4820a2b0c..03cddfd84 100644 --- a/packages/predict/docs/concepts/pricing-and-oracles.md +++ b/packages/predict/docs/concepts/pricing-and-oracles.md @@ -71,16 +71,13 @@ The derivation, conceptually: The endpoints carry sentinel handling so open-ended ranges work without special-casing: a strike equal to `neg_inf` (the raw value `0`) has UP price `1.0` (the whole distribution is above it), and a strike equal to `pos_inf` (`u64::MAX`) has UP price `0`. A one-sided contract is the difference against the appropriate sentinel. -### Price-tail saturation +### Finite-tail pricing -Because strikes are absolute integer ticks against a forward that can drift far outside the encodable strike ladder (see [markets and positions](./markets-and-positions.md)), the UP-price math must stay live in both deep tails rather than aborting. The strike/forward ratio is computed in `u128`, then both tails saturate to their limits: +Because strikes are absolute integer ticks against a forward that can drift far outside the encodable strike ladder (see [markets and positions](./markets-and-positions.md)), the UP-price math must stay live in both deep tails rather than aborting. `fixed_math::approx::ln_ratio` evaluates log-moneyness for every positive finite strike without requiring its 1e9-scaled strike/forward ratio to fit in `u64`: the ordinary domain retains the single-log quotient path, while quotient underflow, a one-raw-unit quotient, and quotient overflow use the exact identity `ln(strike / forward) = ln(strike) − ln(forward)` with both logarithm errors certified. -- **Deep-ITM** (`strike ≪ forward`, the ratio rounds to `0`): UP price saturates to `1.0` (the `neg_inf` limit, `P(settle > strike) ≈ 1`). -- **Deep-OTM** (`strike ≫ forward`, the ratio exceeds `u64::MAX`): UP price saturates to `0` (the `pos_inf` limit). +The two explicit strike sentinels remain exact: `neg_inf` has UP price `1.0` and `pos_inf` has UP price `0`. A finite strike always flows through the SVI evaluator and final `[0, 1]` probability clamp, even when its fixed-point quotient would otherwise look like an endpoint. This keeps NAV, redeem, and liquidation reads live without issuing a false zero-error certificate. Range-price differencing still clamps at zero, so a thin or far-OTM range with ~0 true probability and a fixed-point inversion prices `0` rather than aborting a legitimate flow. -Reaching either tail requires the forward to leave the entire encodable strike domain by orders of magnitude; saturating there keeps NAV, redeem, and liquidation reads live instead of bricking the whole market on an extreme price. The range-price differencing is likewise saturating, so a thin or far-OTM range with ~0 true probability and a 1-ulp fixed-point inversion prices `0` rather than aborting a legitimate trade. - -The math runs in 1e9 fixed point throughout, using the `fixed_math` `I64` signed type for the intermediate signed quantities (`a`, `rho`, `m`, `k`, `k − m`, `d2`) and guarding the real preconditions: positive forward, non-negative SVI wing term, and positive total variance. The read-time envelope bounds `|a|` instead of rejecting negative `a`, then checks the analytical minimum variance `a + b·sigma·sqrt(1 − rho²)` is positive. That rejects surfaces whose signed baseline over-offsets the SVI wing before any mint, redeem, liquidation, or NAV path can divide by `sqrt(w)`. The deep `ENonPositiveVariance` check remains as a defensive math backstop, but valid loaded surfaces are expected to fail at the envelope if their minimum total variance is non-positive. +The canonical values run at 1e9 fixed point, using the `fixed_math` `I64` signed type for intermediate signed quantities (`a`, `rho`, `m`, `k`, `k − m`, `d2`) and `Approx` for each value once numerical error exists. One deliberately narrow precision island retains the raw `a + b·inner` numerator at 1e18 through `sqrt(w)`, because flooring that variance increment to 1e9 first materially distorts short-dated prices under the `1/sqrt(w)` terms. Dividing the same wide numerator by 1e9 still supplies the canonical 1e9 `w` center to `w/2`; all subsequent pricing math remains on the ordinary 1e9 path. The implementation guards the real preconditions: positive forward, non-negative SVI wing term, and positive total variance. The read-time envelope bounds `|a|` instead of rejecting negative `a`, then checks the analytical minimum variance `a + b·sigma·sqrt(1 − rho²)` is positive. That rejects surfaces whose signed baseline over-offsets the SVI wing before any mint, redeem, liquidation, or NAV path can divide by `sqrt(w)`. The deep `ENonPositiveVariance` check remains as a defensive math backstop, but valid loaded surfaces are expected to fail at the envelope if their minimum total variance is non-positive. For single order/range quotes, range-price differencing is saturating: if a clamped or non-monotone adjusted digital segment would make `up_price(lower) < up_price(higher)`, that order prices at zero rather than aborting the trade path. NAV valuation has an additional active-book check. The payout-tree walk caches finite boundary UP prices in ascending tick order; if an active market's current surface makes those cached UP prices increase over the active boundary set, the flush aborts with a non-monotone price-memo guard instead of netting a non-monotone surface into an overstated `current_nav`. @@ -134,7 +131,7 @@ A timestamp is fresh only if it is positive, not in the future, and within its m **Read-time pricing envelope (Predict, not Propbook).** Propbook stores source facts. Predict's `pricing` module decides whether the combined BS inputs are safe for Predict's fixed-point pricing math: `spot > 0`, `forward > 0`, bounded basis, bounded SVI magnitudes, `|rho| <= 1`, sigma within Predict's accepted range, and positive analytical minimum total variance. Negative SVI `a` is accepted when that minimum-variance condition still holds. -**No writes during pool valuation.** The full-pool flush computes NAV against a frozen snapshot, so Predict's valuation lock blocks Predict trading and admin changes mid-valuation; see [liquidity and NAV](./liquidity-and-nav.md). The propbook feeds are independent objects and are not part of that lock — but the flush is privileged and the flush operator is trusted not to push the oracle mid-flush, which is the model that makes the single frozen mark sound (see the audit-L8 note in [liquidity and NAV](./liquidity-and-nav.md)). +**No writes during pool valuation.** The full-pool flush computes NAV against a frozen snapshot, so Predict's valuation lock blocks Predict trading and admin changes mid-valuation; see [liquidity and NAV](./liquidity-and-nav.md). The propbook feeds are independent objects and are not part of that lock — but the flush is privileged and the flush operator is trusted not to push the oracle mid-flush. The error certificate bounds numerical evaluation, not an operator-induced change in oracle state (see the audit-L8 note in [liquidity and NAV](./liquidity-and-nav.md)). **Min/max entry probability bounds.** A raw probability near `0` or `1` must not become an admitted mint just because the fee moves the all-in cash outlay away from the edge. These bounds live in `StrikeExposureConfig` (snapshotted per expiry from a global template), not in the pricing config: pricing produces the probability, and the mint-admission flow enforces the raw-probability envelope. At mint, `entry_probability` must lie within `[min_entry_probability, max_entry_probability]`. See [configuration](../design/configuration.md) for the bound values. diff --git a/packages/predict/docs/design/architecture.md b/packages/predict/docs/design/architecture.md index 4593ecdec..f73159a4b 100644 --- a/packages/predict/docs/design/architecture.md +++ b/packages/predict/docs/design/architecture.md @@ -175,23 +175,23 @@ Propbook binds the BS spot feed first, then binds the permanent forward/SVI surf ## The pool, NAV, and the async LP layer -LP supply and withdraw are **asynchronous**. An LP queues a request (`request_supply` with `min_plp_out` / `request_withdraw` with `min_dusdc_out`, routed through an account so a composing vault's own account — not necessarily the tx signer — is the fill recipient); the input is escrowed in one of two `RequestQueue`s on `PoolVault`, and a pending request can be cancelled for an immediate refund. A daily **flush** fills eligible queued heads at one frozen mark. +LP supply and withdraw are **asynchronous**. An LP queues a request (`request_supply` with `min_plp_out` / `request_withdraw` with `min_dusdc_out`, routed through an account so a composing vault's own account — not necessarily the tx signer — is the fill recipient); the input is escrowed in one of two `RequestQueue`s on `PoolVault`, and a pending request can be cancelled for an immediate refund. A daily **flush** fills eligible queued heads at one frozen NAV bid/ask. -The per-expiry NAV primitive is `expiry_market::current_nav`: the **exact** live recoverable value of one expiry — free cash minus the exact per-order live liability, floored at zero. The liability is `walk_linear` (the payout tree's full linear walk, `Σ qty·P`, which also fills a price memo for every boundary) minus `correction_value` (the leveraged-book floor-correction scan, reading each order's range price back from that memo), so an underwater leveraged order nets to zero with no liquidation pass needed. There is no approximation and no uncertainty band; the deleted approximate-NAV matrix and its band/withdraw-fee superstructure are gone. +The per-expiry NAV primitive is `expiry_market::current_nav_approx`: exact free cash minus a complete per-order live-liability `Approx`, floored at zero. The liability is `walk_linear` (the payout tree's full linear walk, `Σ qty·P`, which fills a memo with each boundary price and its certified error) minus `correction_value` (the leveraged-book floor/liquidation-correction scan, reading range-price certificates back from that memo), so an underwater leveraged order nets to zero with no liquidation pass needed. The public `current_nav` returns the center only. The deleted approximate-NAV matrix and its bucket/band/withdraw-fee superstructure remain gone; this certificate tracks only numerical evaluation error over the complete walk. The flush is a transaction-local **hot potato** (`PoolValuation`), assembled in three phases over one PTB: 1. `start_pool_valuation` (started with a market-deployer `MarketLifecycleCap` proof) engages the valuation lock and snapshots the active-expiry set. PLP caps the active pre-expiry market count at market registration; expired or settled markets can also be swept independently before a flush. -2. `value_expiry` runs once per snapshotted market: it sweeps a settled market or rebalances a live market, then folds the market's NAV (`current_nav`, or 0 for a swept settled market) into the running total, proving the market is in the snapshot and valued exactly once. An expired unsettled market moves no cash and aborts when live pricing is attempted. -3. `finish_flush` proves every snapshotted market was valued, computes `pool_nav = idle + Σ current_nav` (net of the pending-protocol-profit exclusion priced from the aggregate profit basis), then `lp_book::drain` mints/burns PLP and delivers fills at that one frozen mark — supplies first, then withdrawals FIFO until idle is dry, up to the operator-supplied per-queue budgets (`supply_budget`/`withdraw_budget`, `None` = unbounded; independent so a supply backlog can't starve withdrawals). A head request whose mark or quote is non-executable is protocol-cancelled and refunded instead of aborting the flush; a live request whose frozen-mark quote misses its request-time limit remains queued and stops that queue for the flush, expiring and refunding on the third miss; a withdrawal whose quote is valid and limit-satisfying but exceeds idle stays queued without spending withdraw budget and stops the withdrawal pass. Fills and refunds are delivered to the account receive address through `balance::send_funds` and passively settled into account custody by later Account balance operations. +2. `value_expiry` runs once per snapshotted market: it sweeps a settled market or rebalances a live market, then folds the market's NAV certificate (`current_nav_approx`, or exact 0 for a swept settled market) into the running total, proving the market is in the snapshot and valued exactly once. An expired unsettled market moves no cash and aborts when live pricing is attempted. +3. `finish_flush` proves every snapshotted market was valued, computes `pool_nav_approx = idle + Σ current_nav_approx` net of the protocol-profit exclusions, and aborts if its relative certificate exceeds 1%. Otherwise it freezes `withdraw_pool_value = center - error` and `supply_pool_value = center + error` over the same pre-drain PLP supply, then `lp_book::drain` mints/burns PLP — supplies first, then withdrawals FIFO until idle is dry, up to the operator-supplied per-queue budgets (`supply_budget`/`withdraw_budget`, `None` = unbounded; independent so a supply backlog can't starve withdrawals). A head request whose mark or quote is non-executable is protocol-cancelled and refunded instead of aborting the flush; a live request whose frozen-mark quote misses its request-time limit remains queued and stops that queue for the flush, expiring and refunding on the third miss; a withdrawal whose quote is valid and limit-satisfying but exceeds idle stays queued without spending withdraw budget and stops the withdrawal pass. Fills and refunds are delivered to the account receive address through `balance::send_funds` and passively settled into account custody by later Account balance operations. -The flush is **privileged**, not permissionless: the hot potato can only be created by a market-deployer `MarketLifecycleCap` (the sole flush authority; the root-`AdminCap` path was removed). The cap-holder is trusted not to manipulate the live oracle before flushing — the single frozen mark prices both supply and withdraw, so it must equal true recoverable value, which `current_nav`'s exactness guarantees. Cash rebalancing, the settled-market sweep, and liquidation are decoupled from the potato: each is a standalone, permissionless, per-market entrypoint, because none needs the exactly-once completeness proof. See [liquidity and NAV](../concepts/liquidity-and-nav.md). +The flush is **privileged**, not permissionless: the hot potato can only be created by a market-deployer `MarketLifecycleCap` (the sole flush authority; the root-`AdminCap` path was removed). The cap-holder is trusted not to manipulate the live oracle before flushing; the bid/ask certificate bounds numerical evaluation error, not oracle-timing abuse. Cash rebalancing, the settled-market sweep, and liquidation are decoupled from the potato: each is a standalone, permissionless, per-market entrypoint, because none needs the exactly-once completeness proof. See [liquidity and NAV](../concepts/liquidity-and-nav.md). ## Settlement Settlement is one permissionless public transition. After expiry, `try_settle` asks `pricing` for the canonical exact-history Pyth read and passes its price to `StrikeExposure::record_settlement`, which records the exposure's phase and exact terminal payout liability together. The market's public settlement getters delegate to the exposure, while idempotent repeat calls remain owned by `try_settle`. -`redeem_settled`, `redeem_settled_permissionless`, rebate claim, `plp::rebalance_expiry_cash`, and `value_expiry` do not read settlement oracles; they consume the recorded phase. Transaction builders call `try_settle` first when settlement may be due. If exact timestamp data is absent after expiry, standalone rebalance is a no-op and live valuation still aborts; no approximate mark is substituted because the flush uses one mark for both PLP supply and withdraw. See [decisions](./decisions.md) and [invariants](./invariants.md). +`redeem_settled`, `redeem_settled_permissionless`, rebate claim, `plp::rebalance_expiry_cash`, and `value_expiry` do not read settlement oracles; they consume the recorded phase. Transaction builders call `try_settle` first when settlement may be due. If exact timestamp data is absent after expiry, standalone rebalance is a no-op and live valuation still aborts; without a settlement-dependent center, no valid certificate or bid/ask can be formed. See [decisions](./decisions.md) and [invariants](./invariants.md). ## Version gating diff --git a/packages/predict/docs/design/configuration.md b/packages/predict/docs/design/configuration.md index fe4279fc1..c99813345 100644 --- a/packages/predict/docs/design/configuration.md +++ b/packages/predict/docs/design/configuration.md @@ -69,7 +69,7 @@ The distinction from class (A) is deliberate: live configs govern protocol-wide - `trading_paused` — when true, blocks *new risk creation*. Exits, settlement cleanup, and valuation are intentionally not blocked by the trading pause; they are gated only by the valuation lock. `assert_trading_allowed` combines the not-paused check with the valuation lock. - `valuation_in_progress` — a transaction-local lock held while a full-pool valuation is assembled. `begin_valuation`/`end_valuation` open and close it; while held, config mutations and new-risk flows abort. Most admin setters first assert the valuation lock is *not* in progress so that policy cannot shift mid-valuation. - `protocol_reserve_profit_share` — the merged protocol-and-insurance reserve share used when aggregate expiry profit is materialized, in 1e9 scaling. -- `trade_liquidation_budget` — the total liquidation-candidate budget checked before mint and redeem flows. It bounds how much liquidation work a single trade flow performs. (There is no separate valuation-time budget: the NAV flush values each market exactly with no liquidation pass, so the former `valuation_liquidation_budget` is gone. The uncertainty-band withdraw fee and its `withdraw_fee_alpha` multiplier are likewise gone — the exact single-mark NAV has no uncertainty band to price.) +- `trade_liquidation_budget` — the total liquidation-candidate budget checked before mint and redeem flows. It bounds how much liquidation work a single trade flow performs. (There is no separate valuation-time budget: the NAV flush walks every active market with no liquidation pass, so the former `valuation_liquidation_budget` is gone. The heuristic uncertainty-band withdraw fee and its `withdraw_fee_alpha` multiplier are likewise gone; the current NAV bid/ask is derived from the propagated numerical certificate and has no tunable multiplier.) **Per-expiry mint pause:** `mint_paused` is a live `bool` field on each `ExpiryMarket`, read directly off the market object on the mint path. When true, new mints on that one expiry abort; the market's other flows (redeem) remain available. The admin sets and unsets it through `expiry_market::set_mint_paused` (version-gated), and a `PauseCap` holder can force it true one-way through `registry::pause_expiry_market_mint_pause_cap` (ungated, so the kill switch survives a version freeze). diff --git a/packages/predict/docs/design/decisions.md b/packages/predict/docs/design/decisions.md index b3ca075ad..c723117a7 100644 --- a/packages/predict/docs/design/decisions.md +++ b/packages/predict/docs/design/decisions.md @@ -68,16 +68,16 @@ the invariants these decisions must preserve, see [invariants.md](./invariants.m (`registry → protocol_config → admin`). - **Two sparse strike indexes, both tick-keyed.** A sparse payout treap (quantity + floor-share prefixes, deriving net payout) and a flat liquidation - book coexist; the exact live NAV is read by decomposing the per-order liability + book coexist; live NAV is read by decomposing the complete per-order liability across the two (`Σ qty·P` over the tree minus the leveraged floor-correction - scan over the book). + scan over the book), with numerical error carried in `Approx`. *Superseded:* a dense paged NAV matrix (`{quantity, floor_shares}` with strike-weighted prefix sums), which existed only to make every LP supply/withdraw a cheap synchronous read. It and its whole mitigation stack (the valuation liquidation pass, the verified/unscanned bucket split, the uncertainty band, the Q-haircut conservative-NAV thread) were deleted when LP flows went async — the - daily flush can afford an exact brute-force valuation, so the approximation and - everything compensating for its error are gone. + daily flush can afford a complete brute-force valuation, so the sampling + approximation and everything compensating for missing book state are gone. - **A flat, paged, sorted-`u256` liquidation book**, binary-searched, with a bounded keeper head-scan plus a rotating passive watermark; only leveraged orders enter. Priority is encoded by storing the quantity field's complement, so @@ -98,10 +98,14 @@ the invariants these decisions must preserve, see [invariants.md](./invariants.m launch checklist; `config_constants` defaults (`backing_buffer_lambda` 0.25, caps, budgets) ship as-is unless an open item changes one. Configured values live in [configuration.md](./configuration.md). -- **Uniform round-down math** at 1e9 scale; solvency rests on bit-identical - reserve↔payout pairing (a reserve and its payout derive from the same quantity - via the *identical* helper). *Rejected:* mixed ceil/floor primitives, which - introduced super-additivity drift and were deleted. +- **Reserve and payout math rounds down uniformly** at 1e9 scale; solvency rests + on bit-identical reserve↔payout pairing (a reserve and its payout derive from + the same quantity via the *identical* helper). Independently routed transaction + charges are not future payout liabilities: the final rate×quantity conversion + for trading fees and EWMA surcharges rounds upward by at most one DUSDC atom, + and quote and execution share those same charge owners. *Rejected:* mixed + ceil/floor primitives inside reserve↔payout calculations, which introduced + super-additivity drift. - **Strike-quantity math stays `u64`.** A `u128` widening was tried and reverted: the `u64` mul ceiling is accepted because the failure mode is a graceful per-tx mint abort at extreme strike×quantity (never a brick), and inline `u128` casts @@ -146,7 +150,7 @@ the invariants these decisions must preserve, see [invariants.md](./invariants.m - **Keep the payout tree.** The tree's max-net-payout term is the enforced settlement floor that anchors the live reserve — an O(1) root read, and the structural proof that any reserve ≥ it always pays in full at settlement. The same tree now - also serves the exact NAV linear walk (`Σ qty·P` over its live boundaries), so it + also serves the complete NAV linear walk (`Σ qty·P` over its live boundaries), so it is the single full-lifecycle live index. *Rejected:* folding settlement into the deleted NAV matrix and dropping the tree. @@ -209,7 +213,7 @@ the invariants these decisions must preserve, see [invariants.md](./invariants.m `active_stake` at claim; an owner who unstakes post-settlement, pre-claim, is scaled to zero. Accepted (self-inflicted; the prompt incentivized sweep bounds the window). - **`stake_deep` / `unstake_deep` carry no valuation-lock gate.** Staked DEEP is - excluded from `lp_pool_value`, so neither can move the flush mark; gating them would + excluded from `lp_pool_value_approx`, so neither can move the flush mark; gating them would add lock contention for no solvency benefit. ## Oracle extraction (recent) @@ -287,39 +291,52 @@ the invariants these decisions must preserve, see [invariants.md](./invariants.m re-adding a creation-time spot read purely to sanity-check the tick size against the asset's price scale — the tick size is sized operationally and a mismatch fails loud at the first mint. -- **Deep-tail pricing saturates, it does not abort.** `compute_nd2` computes - `strike/forward` in `u128` and saturates both tails (deep-ITM up tail → ~1.0, the - `neg_inf` limit; deep-OTM up tail → 0) instead of aborting on underflow or wrapping - the `u64` cast. *Rationale:* the widened tick domain makes a deep tail reachable by - a forward drift alone, and the NAV walk prices every live boundary — one - unpriceable order would otherwise brick NAV, redeem, and liquidation for the whole - market until settlement. Saturation keeps those reads live; the `[min_entry_probability, max_entry_probability]` - admission band, not an abort, is what keeps the protocol from writing a tail it - prices poorly. *Rejected:* a standalone reject-at-mint strike-range guard (redundant - with the ask band on mint, and it would not cover redeem / NAV / liquidation, which - re-price already-minted orders with no band). +- **Finite deep-tail pricing stays calculable; only strike sentinels are exact endpoints.** + `compute_nd2` delegates log-moneyness to `fixed_math::approx::ln_ratio`. The + ordinary domain keeps the existing one-log quotient path; quotient underflow, a + one-raw-unit quotient, and quotient overflow use certified + `ln(strike) - ln(forward)` instead of promoting a finite strike to the `neg_inf` + or `pos_inf` price. *Rationale:* the widened tick domain makes a deep tail + reachable by forward drift alone, and the NAV walk prices every live boundary — + one unpriceable order would otherwise brick NAV, redeem, and liquidation until + settlement. Evaluating every positive finite ratio keeps those reads live while + the propagated certificate lets mint and NAV policy fail closed when numerical + error is too large. *Rejected:* exact endpoint saturation for finite quotient + failures (it can issue a false zero-error certificate on an admitted SVI surface) + and a standalone reject-at-mint strike-range guard (it would not cover redeem, + NAV, or liquidation, which re-price already-minted orders). +- **Short-dated pricing has one narrow wide-precision island.** The canonical + pricing centers and downstream formulas stay at 1e9, but + `a + b·(rho·(k−m) + sqrt((k−m)² + sigma²))` retains its raw 1e18 numerator + through `sqrt(w)`. Dividing that same numerator by 1e9 still supplies the + ordinary 1e9 `w` center to `w/2`. *Rationale:* flooring `b·inner` before the + square root made its fractional variance material under short-dated + `1/sqrt(w)` conditioning; widening more of SVI adds duplicate math without + materially improving measured mint availability. -## Async LP, exact NAV, and the privileged flush (recent) +## Async LP, certified NAV, and the privileged flush (recent) -- **LP supply/withdraw is asynchronous; the daily flush values the pool exactly.** +- **LP supply/withdraw is asynchronous; the daily flush values the complete pool.** LPs queue escrowed `request_supply`/`request_withdraw` (cancellable for an immediate refund, with request-time minimum-output limits), and a daily flush - fills eligible queued heads at one frozen mark. + fills eligible queued heads at one frozen bid/ask pair. *Rationale:* moving valuation off the trading hot path lets the flush afford an - exact brute-force NAV, which deletes the entire approximate-NAV mitigation stack; + all-order walk, which deletes the entire sampled approximate-NAV mitigation stack; the cost is a ~24h LP settlement delay. *Rejected:* an operator-posted NAV (this is a trustless on-chain crank), a multi-tx crank, and a flush that pauses trading. -- **`current_nav` is the exact per-expiry mark — one mark, no band.** Per expiry, - `current_nav = free_cash − exact_per_order_liability`, floored at zero, where the - liability is the payout-tree linear walk minus the leveraged-book floor correction; - an underwater leveraged order nets to zero with no liquidation pass. The flush - prices supply *and* withdraw at the single `pool_nav = idle + Σ current_nav` (net of - the pending-protocol-profit exclusion). *Rationale (audit L10):* one mark used in - both directions must equal true recoverable value, so it must be exact — a - conservative band would over-mint on one side or over-pay on the other. The - supply-mark-≥-true directional invariant is satisfied with equality. *Superseded:* - the optimistic supply mark + uncertainty-band withdraw fee of the approximate-NAV - world. +- **`current_nav_approx` carries one per-expiry numerical certificate; the pool + consumes it directionally.** Per expiry, `current_nav_approx = + exact(free_cash) − live_liability_approx`, floored at zero, where the liability + is the payout-tree linear walk minus the leveraged-book correction; an + underwater leveraged order nets to zero with no liquidation pass. The flush + aggregates those certificates, aborts above 1% relative error, then prices + supply at `center + error` and withdraw at `center - error` over one pre-drain + PLP supply. *Rationale (audit L10):* a supplier must never price below true NAV + and dilute incumbents, while a withdrawer must never price above true NAV. A + center-radius certificate satisfies both without asking downstream accounting + to choose interval endpoints. *Superseded:* both the single scalar mark and the + optimistic supply mark plus configurable uncertainty-band withdrawal fee of + the sampled approximate-NAV world. - **The flush is privileged (cron-driven), not permissionless (audit L8).** Only a market-deployer `MarketLifecycleCap` (`start_pool_valuation`) may start a flush; the root-`AdminCap` flush path was removed (the flush is routine maintenance and should @@ -329,7 +346,8 @@ the invariants these decisions must preserve, see [invariants.md](./invariants.m preceding tx could fill their own queued request at a mark they chose. *Rationale:* the cap-holder is trusted not to manipulate the oracle, and the cap is revocable (bounded blast radius, better key hygiene than the root cap). NAV manipulation is - closed by privileging the start; dilution by the fair FIFO drain at the frozen mark. + closed by privileging the start; numerical transfer is bounded by the certified + bid/ask and dilution by the fair FIFO drain. *Rejected:* a permissionless flush. - **Cash maintenance is decoupled from the flush potato.** Cash rebalance, the settled-market sweep, and liquidation are standalone, permissionless, per-market diff --git a/packages/predict/docs/design/invariants.md b/packages/predict/docs/design/invariants.md index 0c6625921..1a7624fb9 100644 --- a/packages/predict/docs/design/invariants.md +++ b/packages/predict/docs/design/invariants.md @@ -60,29 +60,29 @@ and contributors. For *how* each mechanism works, follow the links into ## NAV and valuation -- **`current_nav` is the exact per-expiry mark.** `expiry_market::current_nav = - free_cash − exact_per_order_liability`, floored at zero, where `free_cash = - cash − rebate_reserve` and the liability is the payout-tree linear walk +- **`current_nav_approx` is the certified per-expiry mark.** + `expiry_market::current_nav_approx = exact(free_cash) − live_liability_approx`, + floored at zero, where `free_cash = cash − rebate_reserve` and the liability is + the payout-tree linear walk (`strike_payout_tree::walk_linear`, `Σ qty·P`, caching boundary prices for the same valuation) minus the leveraged-book floor correction (`liquidation_book::correction_value`, reading order range prices from that - cache). An underwater leveraged order + cache). Every numerical value carries its `Approx` radius. An underwater leveraged order nets to zero by the per-order floor cap, so the read needs no liquidation pass. It is a **pure read with no backing assert** (backing is owned by the payout-tree reserve and proven on every trade); the `saturating_sub` cash floor marks a degenerate (underwater) market at 0, the correct per-market limited-recourse - value, never negative. -- **NAV-mark directional invariant — one mark, equals TRUE.** The flush prices PLP - supply *and* withdraw at the single `pool_nav = idle + Σ current_nav` (net of the - protocol's unmaterialized-profit exclusion and any carried `pending_protocol_profit`), - computed once in `finish_flush`. Because each - `current_nav` is exact, that one mark equals true recoverable value in both - directions: a supplier prices `=` fair shares (never over-mints to dilute - incumbents) and a withdrawer draws `=` fair cash. There is **no conservative - band** — the bucket/band decomposition belonged to the deleted approximate-NAV - world. Any liveness clamp inside `current_nav` (the degenerate-underwater cash - floor) only ever *maximizes* NAV when it fires, preserving the supply-mark - direction. See [../concepts/liquidity-and-nav.md](../concepts/liquidity-and-nav.md). + value, never negative. The public `current_nav` is its center-only read. +- **NAV-mark directional invariant — supply high, withdraw low.** The flush + computes one `pool_nav_approx = exact(idle) + Σ current_nav_approx`, net of the + protocol's unmaterialized-profit exclusion and any carried + `pending_protocol_profit`, and rejects a relative radius above 1%. Inside that + ceiling, supply uses `center + error` so it cannot undercount true value and + over-mint; withdraw uses `center - error` so it cannot overpay true value. Both + use the same pre-drain PLP supply. This certificate-derived spread is not the + deleted heuristic bucket/band design or a withdrawal fee. A zero center produces + two zero, non-executable marks. See + [../concepts/liquidity-and-nav.md](../concepts/liquidity-and-nav.md). - **Exactly-once full-pool valuation.** The flush hot potato (`PoolValuation`) snapshots the active-expiry set at `start_pool_valuation`; each `value_expiry` proves its market is in the snapshot and not already valued, and `finish_flush` @@ -179,8 +179,9 @@ and contributors. For *how* each mechanism works, follow the links into - The builder fee and the gas-congestion surcharge are add-ons; both are excluded from the trading-loss rebate fee basis (only the trade fee counts). - PLP supply and withdraw carry **no fee**. The former uncertainty-band withdraw - fee (`withdraw_fee_alpha`) was deleted with the approximate-NAV band — the exact - single-mark NAV has no valuation uncertainty to price. + fee (`withdraw_fee_alpha`) was deleted with the heuristic approximate-NAV band. + The current bid/ask is derived mechanically from the certified numerical error, + not a configurable fee or risk premium. ## Lifecycle @@ -198,8 +199,9 @@ and contributors. For *how* each mechanism works, follow the links into - **Past-expiry exact-data liveness.** A market that crosses its expiry but lacks an exact Propbook Pyth spot cannot be live-valued: `value_expiry` tries passive settlement first, then `current_nav → pricing::load_live_pricer` aborts if the - market remains unsettled. This preserves the single exact mark for PLP supply and - withdraw; no approximate substitute mark is allowed. Because the flush must value + market remains unsettled. Without the settlement-dependent center the flush + cannot derive a certified PLP bid/ask; no synthetic substitute is allowed. + Because the flush must value every active market exactly once, this abort blocks the *whole* pool flush, not just the one market — so an expiry whose exact settlement spot is permanently unobtainable is a cross-market liveness brick, not a benign wait. Guaranteeing the @@ -230,7 +232,9 @@ and contributors. For *how* each mechanism works, follow the links into returns quantities it is the source of truth for (an exposure book returns its raw live liability; the pool returns its profit basis), never a value pre-shaped for a caller's mark, haircut, or stance. `strike_exposure::exact_live_liability` returns - the liability fact; `expiry_market::current_nav` owns the NAV cash floor. + the complete liability `Approx`; `expiry_market::current_nav_approx` owns the NAV + cash floor; `plp::finish_flush` alone turns the final certificate into economic + bid/ask policy. - **Each economic quantity is clamped exactly once, at the policy owner.** A lossy transform (clamp at zero, `min`/`max`, saturating subtraction, rounding) is applied once, as the last step before use, in the module that owns the policy — never on a @@ -239,14 +243,15 @@ and contributors. For *how* each mechanism works, follow the links into ## Rounding -- All fixed-point math is at 1e9 scale; `math::mul` and `math::div` round **down** - uniformly. +- Canonical fixed-point values are at 1e9 scale; `math::mul` and `math::div` + round **down** uniformly. Pricing's one narrow exception retains the raw + `a + b·inner` numerator at 1e18 through `sqrt(w)`, then rejoins the 1e9 path. - **Solvency rests on bit-identical pairing:** where a reserve and a payout derive from the same quantity/floor atoms, they use the same net-payout calculation (`quantity − floor_shares`), so a reserve can never be short of the payout it backs. - Dust is biased to the protocol/LP pool, never against solvency: payouts round - down (the holder absorbs ≤1 unit). The exact NAV walk floors at zero with - `saturating_sub` so bounded fixed-point ulp dust (which the boundary-aggregated - liability can carry) cannot underflow and abort valuation. See the "Rounding and - dust" section of [../risks.md](../risks.md). + down (the holder absorbs ≤1 unit). The NAV `Approx` floors its center at zero + so bounded fixed-point dust cannot underflow and abort valuation; its radius + remains attached until `finish_flush` applies the 1% gate and bid/ask. See the + "Rounding and dust" section of [../risks.md](../risks.md). diff --git a/packages/predict/docs/glossary.md b/packages/predict/docs/glossary.md index 5bb9d20c5..308b5c467 100644 --- a/packages/predict/docs/glossary.md +++ b/packages/predict/docs/glossary.md @@ -178,21 +178,24 @@ certificate**. See [leverage and the floor](./concepts/leverage-and-floor.md). ## Liquidity, NAV, and the flush The LP layer is **asynchronous**: liquidity providers queue requests and a -privileged periodic **flush** prices them all at one frozen pool mark. See +privileged periodic **flush** prices them at one frozen pool bid/ask pair. See [liquidity and NAV](./concepts/liquidity-and-nav.md). - **PLP** — the pool's liquidity-provider share token (`Coin`), minted on a filled supply and burned on a filled withdraw; its value tracks pool NAV. The fungible claim on `PoolVault`. Code `PLP`. -- **`current_nav`** — an `ExpiryMarket`'s **exact** live NAV: free cash minus the - exact per-order live liability (payout-tree `walk_linear` minus the leveraged - book's `correction_value`), floored at zero. There is no approximation or - uncertainty band — it is the true per-expiry recoverable value at the - valuation instant. Code `current_nav`. +- **`current_nav`** — an `ExpiryMarket`'s live NAV center plus a certified + numerical-error radius: free cash minus the complete per-order live liability + (payout-tree `walk_linear` minus the leveraged book's `correction_value`), + floored at zero. Code `current_nav` (value-only view) and + `current_nav_approx` (package certificate). - **Pool NAV (`pool_nav`)** — the LP-attributable pool-wide DUSDC value the flush prices PLP at: `idle + Σ active-market current_nav`, net of the - pending-protocol-profit exclusion. Computed once per flush and used for both - supply and withdraw. Code `pool_nav` (event `FlushExecuted`, field `pool_value`). + pending-protocol-profit exclusion. Computed once per flush as a center and + certified radius; supplies use `center + error`, withdrawals use + `center - error`, and both use the same pre-drain PLP supply. Event + `FlushExecuted` fields `supply_pool_value`, `withdraw_pool_value`, and + `active_market_nav_error`. - **Supply / withdraw queue** — the two FIFO request queues on `PoolVault` (`supply_queue` of escrowed DUSDC, `withdraw_queue` of escrowed PLP). An LP enqueues with `request_supply` / `request_withdraw` (routed through its diff --git a/packages/predict/docs/overview.md b/packages/predict/docs/overview.md index 7f0cb4507..b7c7ebd17 100644 --- a/packages/predict/docs/overview.md +++ b/packages/predict/docs/overview.md @@ -16,7 +16,7 @@ This is *limited-recourse* financing. A leveraged order's floor can only ever co There is **one canonical strike representation across the whole protocol — absolute integer ticks**. A strike is an integer `tick`, and its raw price is always `raw_strike = tick × tick_size`, where `tick_size` is fixed per expiry. There is no second representation: no centered grid and no boundary indices. The public API, order IDs, the payout tree, the liquidation book, and the exposure index all operate over ticks; raw strikes are reconstructed only at the pricing/settlement boundary. A range is the tick pair `(lower_tick, higher_tick)`, carried directly at public entrypoints and events; the open-ended ends are the two sentinel ticks (`lower_tick = 0` is `−∞`, `higher_tick = pos_inf_tick` is `+∞`). Only the durable order ID packs the two ticks into one integer. -Because the tick domain is absolute and fixed in advance, **market creation reads no live spot** — a new expiry market just records its `tick_size` and snapshots the future-market policy from `ProtocolConfig` (the `MarketCreated` event carries `tick_size`, `max_expiry_allocation`, `initial_expiry_cash`, and the immutable policy snapshot, not a min/max strike). The pricing math saturates instead of aborting in the deep tails: a strike far below the forward prices to ~1.0 and far above to 0, so no live quote ever fails on an extreme strike. +Because the tick domain is absolute and fixed in advance, **market creation reads no live spot** — a new expiry market just records its `tick_size` and snapshots the future-market policy from `ProtocolConfig` (the `MarketCreated` event carries `tick_size`, `max_expiry_allocation`, `initial_expiry_cash`, and the immutable policy snapshot, not a min/max strike). Finite-tail pricing stays live without treating a rounded strike/forward ratio as infinity: log-moneyness is evaluated for every positive finite strike, while only the two explicit strike sentinels have exact endpoint prices. ### Prices come from external feeds @@ -67,7 +67,7 @@ stateDiagram-v2 ## Liquidity is asynchronous -Liquidity providers do not transact against a live pool price. They **queue** requests: `request_supply` escrows DUSDC with a `min_plp_out` fill limit and `request_withdraw` escrows PLP with a `min_dusdc_out` fill limit, each routed through the LP's account and cancellable while pending. A periodic **flush** then values the whole pool once and fills eligible queued heads at that single frozen mark. Executable requests that miss their limit remain queued for up to three flush attempts, then expire and refund; non-executable requests are refunded immediately, and withdrawals can also carry when idle is insufficient. The flush is a transaction-local hot potato — `start_pool_valuation` → one `value_expiry` per active market → `finish_flush` — and it is **privileged**: only a market deployer's `MarketLifecycleCap` may start one, so the mark cannot be timed by an adversary against a manipulated oracle. The mark itself, `pool_nav = idle + Σ current_nav`, is **exact** (each `current_nav` is the true per-expiry recoverable value, with no approximation band), so the one mark that prices both supplies and withdrawals equals true NAV in both directions. Fills are delivered to each account's receive address through the balance accumulator and absorbed lazily on the account's next capital op. See [liquidity and NAV](./concepts/liquidity-and-nav.md). +Liquidity providers do not transact against a live pool price. They **queue** requests: `request_supply` escrows DUSDC with a `min_plp_out` fill limit and `request_withdraw` escrows PLP with a `min_dusdc_out` fill limit, each routed through the LP's account and cancellable while pending. A periodic **flush** then values the whole pool once and fills eligible queued heads against one frozen bid/ask pair. Executable requests that miss their limit remain queued for up to three flush attempts, then expire and refund; non-executable requests are refunded immediately, and withdrawals can also carry when idle is insufficient. The flush is a transaction-local hot potato — `start_pool_valuation` → one `value_expiry` per active market → `finish_flush` — and it is **privileged**: only a market deployer's `MarketLifecycleCap` may start one, so the oracle snapshot cannot be timed by an adversary. Pricing carries a certified numerical-error radius through each `current_nav` and the pool total. A flush aborts if the final radius exceeds 1%; otherwise supplies price at `center + error` and withdrawals at `center - error`, both over the same pre-drain PLP supply. Fills are delivered to each account's receive address through the balance accumulator and absorbed lazily on the account's next capital op. See [liquidity and NAV](./concepts/liquidity-and-nav.md). ## Guarantees in plain language @@ -76,8 +76,8 @@ These properties are designed in and hold by construction; their boundaries are - **Cash always backs payouts and rebates.** Each expiry's `ExpiryCash` enforces, on every cash movement, that its balance is at least its payout liability plus its unresolved trading-loss rebate reserve. Surplus above that line is the only cash the pool may sweep. An expiry can always pay both its winners and its owed rebates. - **Leverage floors are limited-recourse.** A floor offsets only its own order's value or payout, capped at it. There is no shared debt and no recourse to a holder's other assets; a leveraged order that breaches its floor is worth zero, never negative. - **Monetary math rounds in the protocol's favor.** Payouts, live redeems, and the per-expiry backing reserve use reserve-favoring rounding, so sub-unit dust accrues to the protocol rather than against its solvency. Reserve and payout reads derive from the same quantity/floor atoms, so a payout can never exceed the cash reserved to back it. -- **The LP mark is exact and unforgeable.** A flush prices PLP supply and withdraw at one mark equal to the pool's exact recoverable NAV, and only a privileged operator can start a flush. A supplier can never over-mint and dilute incumbents, and the mark cannot be timed against a manipulated oracle. -- **Live valuation is exact even with uncleared exhausted orders.** Each market's `current_nav` subtracts the leveraged book's per-order floor correction from the range value, capped per order, so an underwater leveraged order nets to zero instead of overstating NAV. Liquidation clears exhausted positions for holder/index hygiene; it is not a valuation precondition. See [liquidation](./concepts/liquidation.md) and [risks](./risks.md). +- **The LP mark is certified and unforgeable.** A flush admits at most 1% numerical uncertainty, prices supply at the certified upper pool value and withdrawals at the certified lower value, and can be started only by a privileged operator. A supplier cannot over-mint from approximation error, a withdrawer cannot be overpaid by it, and the oracle snapshot cannot be timed by an adversary. +- **Live valuation remains complete with uncleared exhausted orders.** Each market's `current_nav` subtracts the leveraged book's per-order floor correction from the range value, capped per order, so an underwater leveraged order nets to zero instead of overstating the NAV center. The numerical certificate travels with that center. Liquidation clears exhausted positions for holder/index hygiene; it is not a valuation precondition. See [liquidation](./concepts/liquidation.md) and [risks](./risks.md). ## Where to go next diff --git a/packages/predict/docs/risks.md b/packages/predict/docs/risks.md index e1f8e3a09..826466df7 100644 --- a/packages/predict/docs/risks.md +++ b/packages/predict/docs/risks.md @@ -12,7 +12,7 @@ For the mechanisms this page evaluates, see [pricing and oracles](./concepts/pri | Block Scholes source set (propbook BS spot/forward/SVI feeds) | The SVI curve and the spot/forward basis used to price every range | Cannot move custody, mint/redeem, create markets, or start a flush; stale, missing, or Predict-unsafe BS inputs abort at live pricing | | Market-lifecycle operator (`MarketLifecycleCap`) | Creating future expiry markets and starting the pool flush (the sole flush authority) | Cannot write prices or any oracle data, move custody, or mint/redeem; admin-revocable on the registry allowlist | | `AdminCap` holder | Fees, LTV, freshness thresholds, cadence allocation caps, profit share, pause switches, underlying registration, lifecycle-cap minting, and genesis-bootstrapping the pool | Cannot touch a holder's position, an account balance, or pool custody directly | -| Flush starter (`MarketLifecycleCap`) | When the pool is valued and the LP queues drain, i.e. the oracle state at which PLP is priced | Cannot set the mark to anything but the pool's exact NAV at that instant, or fill at an off-mark price | +| Flush starter (`MarketLifecycleCap`) | When the pool is valued and the LP queues drain, i.e. the oracle state at which PLP is priced | Cannot choose the NAV center, error, or directional marks: supply is fixed at `center + error`, withdraw at `center - error`, and certificates above 1% abort | | `PauseCap` holder | Emergency one-way pauses: pause global trading, pause one market's minting | Cannot unpause anything, change config, or move funds | | Liquidation keepers | Trigger permissionless, bounded liquidation passes | Cannot liquidate an order that is above its liquidation threshold | @@ -34,8 +34,8 @@ Every live price in Predict is built from external **propbook** feeds — object The single most important LP-facing trust assumption is **who decides when the pool is valued**. PLP is not priced against a continuously-published pool price; it is priced only during a **flush**, and a flush can be started only by a market deployer (`MarketLifecycleCap`) — a revocable cap, not the root `AdminCap`. It is intended to be a cron-driven operation. -- **The flush is privileged by design.** Making it permissionless would let anyone time the valuation to a favourable oracle state and capture mispriced fills. Gating it behind the operator caps closes that timing attack. The cost is a trust assumption: the flush starter chooses the oracle snapshot at which every queued supply and withdraw is priced, so the **cap-holders must not manipulate the live oracle around a flush**. They cannot set the mark to anything but the pool's exact NAV at that instant — the mark is `idle + Σ current_nav` and each `current_nav` is exact — but they do choose the instant. -- **A single exact mark prices both directions.** Because the same `pool_nav` prices supply and withdraw, it must equal true NAV in both directions or one side is systematically advantaged. It does: each market's `current_nav` is the exact recoverable value (free cash minus the exact per-order live liability), with no conservative band on that per-order liability, so a supplier priced at the mark mints fair shares and a withdrawer is paid fair value. The exactness is what makes the dual-use mark safe; there is no approximation knob to mis-set. The flush valuation enforces it against the leveraged book directly: every active leveraged order at or below its knock-out threshold at the valuation prices is marked at its liquidated worth — zero live liability — inside the read-only NAV correction, so the mark never carries a liquidatable order at holder value and never understates recoverable value by the LTV buffer (response policy RP-17). The order itself is liquidated by the ambient sweep on its own cadence, not mutated inside the valuation pass. +- **The flush is privileged by design.** Making it permissionless would let anyone time the valuation to a favourable oracle state and capture mispriced fills. Gating it behind the operator caps closes that timing attack. The cost is a trust assumption: the flush starter chooses the oracle snapshot at which every queued supply and withdraw is priced, so the **cap-holders must not manipulate the live oracle around a flush**. They cannot choose the computed NAV center or radius, and they cannot choose how it is consumed — supply is fixed at `center + error`, withdrawal at `center - error`, and a relative error above 1% aborts — but they do choose the instant. +- **One certificate prices the two directions conservatively.** Each market's complete per-order NAV walk carries a certified numerical-error radius into the pool total. Inside the 1% ceiling, the supply mark is the certified upper pool value, preventing approximation from over-minting and diluting incumbents; the withdrawal mark is the certified lower pool value, preventing approximation from overpaying an exiting LP. This is not a configurable risk band or fee. The read-only leveraged correction also marks every active order at or below its knock-out threshold at its liquidated worth — zero live liability — so the pool center never carries a liquidatable order at holder value or understates recoverable value by the LTV buffer (response policy RP-17). The order itself is liquidated by the ambient sweep on its own cadence, not mutated inside valuation. - **Fills are FIFO.** The flush fills supplies first, then withdrawals FIFO until idle is dry, each queue bounded by its own operator-supplied budget (independent, so a supply backlog can't starve withdrawals). A request with a live executable quote but a missed user limit carries for up to three flush attempts before expiring and refunding. A withdrawer can be deferred to a later flush if idle is exhausted, and an LP can cancel a still-pending request at any time before it fills or expires. - **Non-executable LP queue heads are isolated.** A head request whose fill is not executable at the frozen mark — PLP price outside the executable band, zero output, or a quote that does not fit in u64 — is protocol-cancelled and refunded instead of aborting the flush. Filled and protocol-refunded heads both spend that queue's flush budget. This avoids a single degenerate request blocking the pool-wide valuation and drain. - **Liveness depends on the operator running flushes.** Because LP fills happen only at a flush, an operator that stops flushing freezes supply and withdraw fills (escrowed funds are safe and cancellable, but not filled). Flush cadence is therefore an operator responsibility, and an LP's effective exit latency is one flush interval. @@ -47,7 +47,7 @@ Terminal settlement is implemented by the permissionless `try_settle` transition The remaining risk is liveness, not payout solvency: - **Exact timestamp data is required.** If Propbook has no normalized Pyth spot exactly at the expiry timestamp, `try_settle` returns false and the market remains unsettled. Standalone rebalance moves no cash, and a flush that still has that market in the active set aborts until the exact timestamp data is inserted and settlement succeeds. -- **No substitute mark is safe.** There is no solvency-safe NAV for a past-expiry-but-unsettled market: the one PLP mark prices both supply and withdraw, so it must equal a settlement-dependent true value. Valuing at zero would dilute incumbents on supply; valuing at free cash would overpay withdrawals. +- **No substitute mark is safe.** There is no settlement-dependent NAV center for a past-expiry-but-unsettled market, so the flush cannot derive a valid certificate or either directional mark. Valuing at zero would dilute incumbents on supply; valuing at free cash would overpay withdrawals. - **Expiry is grid-aligned on-chain, and the settling print is exact.** Settlement is an exact whole-millisecond lookup: `try_settle` reads the Pyth observation keyed at exactly `market.expiry`, and `pyth_feed::insert_at` accepts only a verified Lazer print whose signed publisher timestamp is exactly that millisecond — a sub-millisecond or off-grid timestamp is rejected (`EInsertTimestampNotExactMillisecond`), and the key is derived from the signed payload, so no keeper can forge or round it onto the grid. `market_manager::record_expiry_creation` requires each expiry to land on its cadence period (`expiry % cadence_period_ms == 0`), and every supported cadence period is a multiple of `resolution_period_ms!()` (a 60s grid). The off-chain resolution relayer sources that exact key from **Pyth Lazer's dedicated resolution endpoints, which publish verified prints at exact, grid-aligned timestamps**. Cadence-period alignment makes the key representable and the resolution endpoints supply a print at exactly that microsecond; an off-grid expiry — which could never receive a print at its exact millisecond and would block the flush permanently — is rejected at creation rather than left as an operator footgun. Representable is not the same as producible: `insert_at` also rejects a print Pyth generated more than `constants::max_settlement_carry_ms` (2s) before that envelope (`ESettlementCarryExceedsWindow`), because being the canonical price for a tick does not make an arbitrarily old observation a defensible settlement mark. - **Bounded, keeper-managed window — except when no in-window print exists.** Normally a market is unsettled only between its expiry and the next resolution insert (seconds at the grid cadence). The flush keeper retries and does not flush while an active market is in that window, so the abort is a transient retry, not a stuck pool. Pool-wide flush liveness therefore depends on the resolution relayer staying live: a prolonged relayer outage blocks LP supply/withdraw for the whole vault until it recovers. - **A boundary with no in-window print is unrecoverable, not transient.** Pyth publishes exactly one envelope per tick and never revises it, so if the print at an expiry was carried from beyond the settlement carry window, no `insert_at` for that expiry can ever succeed. `try_settle` returns false forever, `plp::value_expiry` on an expired-unsettled market aborts `ELivePricingExpired`, and `finish_flush` requires every active market to be valued — so that one market blocks the whole pool's flush permanently, ending LP supply and withdraw. This is the accepted cost of refusing to settle on an arbitrarily stale mark; the frequency depends on how often Pyth carries a price across a grid boundary for longer than the window, which is **not yet measured**. Measuring it is a mainnet launch blocker, and an admin settlement fallback is the intended remedy if the rate is non-negligible. @@ -81,12 +81,12 @@ The `AdminCap` can: The `AdminCap` cannot: - move a holder's position, an account balance, or pool custody — there is no admin path that splits, transfers, or seizes user funds or `PoolVault` balances; admin authority is over *parameters and lifecycle*, not over *custody*; -- price a flush at anything but the pool's exact NAV — it chooses *when* a flush runs, not the mark; +- choose a flush's NAV center, certified radius, or bid/ask rule — it chooses *when* a flush runs, not the valuation result; - write or alter oracle data — the propbook feeds are a separate package and Predict's `AdminCap` is not a Propbook feed/update authority. `PauseCap` is a deliberately narrow emergency key: admin mints it for trusted operators, and it can force global trading to paused or pause one market's minting. Every `PauseCap` action is **one-way** — only the `AdminCap` can unpause trading/minting. (Versioning is a separate, admin-only concern: a monotonic watermark advanced by `bump_version_watermark`, with no disable or re-enable — recovery from a version freeze is by package upgrade.) This means a misconfigured or compromised `PauseCap` can halt new risk creation (a denial-of-service on minting/trading) but cannot unlock anything, change parameters, or move funds. Pausing blocks new risk; exits, valuation, and the flush are governed by the separate valuation lock, not by the trading pause. -The honest framing: admin trust is real but bounded. The funds-custody boundary is enforced by the module structure (balances live in modules that expose no admin transfer path), while economic-parameter trust and flush-timing trust are open-ended — an admin can make the protocol uneconomic through bad parameter choices, and can choose the oracle instant at which LPs are priced, even though it can never directly take a position or a balance, nor set the mark off true NAV. +The honest framing: admin trust is real but bounded. The funds-custody boundary is enforced by the module structure (balances live in modules that expose no admin transfer path), while economic-parameter trust and flush-timing trust are open-ended — an admin can make the protocol uneconomic through bad parameter choices, and can choose the oracle instant at which LPs are priced, even though it can never directly take a position or a balance, nor alter the computed certificate or its bid/ask rule. ## Holder and leverage risk @@ -101,17 +101,17 @@ A 1x order is the special case where the floor is zero: it carries no liquidatio ## Liquidity-provider (PLP) risk -PLP holders supply DUSDC to `PoolVault` and receive shares whose value tracks pool net asset value (NAV). The pool is the counterparty to traders, so LP risk is fundamentally directional and path-dependent. LP actions are **asynchronous**: a supply or withdraw is a queued, escrowed, cancellable request that can fill only during a flush, priced at one exact pool mark (see [The privileged flush](#the-privileged-flush)). +PLP holders supply DUSDC to `PoolVault` and receive shares whose value tracks pool net asset value (NAV). The pool is the counterparty to traders, so LP risk is fundamentally directional and path-dependent. LP actions are **asynchronous**: a supply or withdraw is a queued, escrowed, cancellable request that can fill only during a flush, priced at the appropriate side of one certified pool bid/ask (see [The privileged flush](#the-privileged-flush)). - **The pool is effectively short trader payoffs.** Pool NAV includes the current value of every open position as a *liability*. When traders' positions gain value (the market moves their way), pool NAV falls; when they lose value, pool NAV rises. Fees accrue to LPs over time, but mark-to-market losses on open trader positions can accrue at the same time, so growing fee income does not guarantee a rising NAV. Realized outcomes are path-dependent: an LP who supplies and withdraws across a period of adverse marks can lose principal even while cumulative fees were positive. - **Fills are deferred and priced at the operator's chosen instant.** Because the flush is privileged and periodic, an LP does not control the moment its request is priced — the operator does. Escrowed funds are safe and cancellable while pending, and request-time minimum-output limits can reject an unfavorable mark, but the effective entry/exit price is the pool NAV at whichever flush fills the request. A too-tight or stale limit can miss repeatedly and expire after three flush attempts. An LP supplying or withdrawing is implicitly trusting the operator to flush on a sane cadence and not around a manipulated oracle. -- **Live NAV is exact and unconditional on book health.** Each market's `current_nav` subtracts the leveraged book's floor correction from the range value **per order**, capped at each order's own range value (`min(range_value, floor)`), so an underwater leveraged order nets to exactly zero rather than overstating value. The flush valuation goes one step further: it liquidates every leveraged order at or below its knock-out threshold at the valuation prices in the same pass, so the pool mark also never understates recoverable value on liquidatable-but-unswept orders. Neither property depends on keepers having cleared under-floor orders first. The remaining LP-facing dependence is on the *operator running flushes* and on the *oracle feeds being fresh* at the flush instant, not on liquidation throughput keeping the mark honest. +- **Live NAV is complete over the book and numerically certified.** Each market's `current_nav_approx` subtracts the leveraged book's correction from the range value **per order**, so an underwater leveraged order nets to zero rather than overstating the NAV center. The same read-only correction values any order at or below its knock-out threshold at its liquidated worth, so the center also does not understate recoverable value on liquidatable-but-unswept orders. It does not mutate or liquidate the order. Neither property depends on keepers having swept the book first; the propagated radius bounds fixed-point evaluation error. - **Valuation requires valuing every active market.** A flush must process each active expiry exactly once; it aborts if any active market is skipped or cannot be valued. The active pre-expiry market count is bounded on-chain by PLP at market registration; per-market valuation cost is governed separately by the market's index bounds. LP fills still depend on every active market's feeds being fresh enough and its active-book SVI boundary prices being monotone enough to value at the flush instant — stale BS price/SVI data or a non-monotone active-book surface on any one active market blocks the whole flush. It is also the mechanism behind [settlement exact-data liveness](#settlement-exact-data-liveness): a past-expiry-but-unsettled active market aborts the flush outright. - **The protocol-profit exclusion can floor LP value at zero.** The pool excludes the protocol's not-yet-materialized profit share (the unmaterialized-profit exclusion) — plus any already-materialized cut not yet moved to the reserve (carried in `pending_protocol_profit`) — from PLP value. The realized side of that exclusion is sticky — it does not shrink when LPs withdraw against a high active mark — so if active NAV later collapses, the held-out total can exceed gross pool value. Both subtractions are clamped, so the valuation itself can finish at zero. LP requests that are not executable at that mark are protocol-cancelled and refunded rather than bricking the flush. A one-time genesis lock (`plp::lock_capital`, operator-only) mints a permanent minimum-liquidity slug of PLP that is held by the pool and never withdrawable. This is a structural on-chain guarantee (not an operational one): supply/withdraw cannot be reached before the lock, and a full LP exit can never drive `total_supply` to zero, so the residual-idle re-bootstrap brick is unreachable and dust simply accrues to the locked position. - **Protocol profit-taking is one-directional across expiries, and the reserve is write-only — both accepted.** When an expiry materializes terminal profit, prior terminal losses are netted first — but the netting only looks backward: a profitable expiry swept before a loss-making one is booked pays the protocol its full share, and that share is not clawed back against the later loss. The over-take is a timing effect, not a permanent extraction: cumulative protocol profit is capped at the share of the running *peak* of booked net P&L, so a front-loaded cut is repaid one-for-one by suppressed cuts on later profits, and it becomes permanent only if the pool winds down (or stays loss-making) before the loss carry refills. LPs who withdraw between a front-loaded cut and that refill bear the timing difference; a material front-load requires a sweep backlog holding profits and losses concurrently, while coinciding settlement grids leave a small ever-present window whose booking order is permissionlessly choosable, bounded by the protocol share of losses settling in that window. The protocol's reserve is **write-only in this package by decision**: no withdrawal path exists, the reserve's eventual use (for example a buy-and-burn or a solvency backstop) is deliberately undecided, and the entrypoint ships with the package upgrade that decides it (response policy RP-16). - **The trading-loss rebate reserve is held out of NAV until accounts resolve.** A configured fraction of trader-paid trading fees (`unresolved_trading_fees_paid × trading_loss_rebate_rate`) is reserved as part of each expiry's cash backing and excluded from PLP value while the market is live — because whether a trader nets a loss (and is owed a rebate) is unknown until settlement, the maximum payable must be reserved. After settlement the residual (winners, unstaked, and partial-benefit accounts) returns to the pool as each account is resolved, and that resolution rides the permissionless cleanout (`redeem_settled` + rebate claim), which is measured self-incentivized — a searcher is paid the storage rebate to run it, standalone or bundled — so it does not depend on a protocol cron. The LP-facing effect is a small NAV-timing conservatism: value reserved for rebates that mostly will not be paid is excluded during the market's life and accrues back at settlement. - **Pool cash funded into live markets is not directly withdrawable.** DUSDC sent into an expiry backs that market's reserve and returns to idle only through rebalancing sweeps or settlement. The rebalancer tops each active market toward its reserve target during every flush, and a flush always runs before withdrawals are paid, so an exit cannot front-run the refill of a live market. LPs may withdraw only against whatever idle remains after that rebalance; a withdrawal larger than the available idle is deferred by the FIFO drain, never partially filled or skipped around. -- **PLP supply and withdraw carry no fee, and there is no valuation halt gate.** Both sides price at the one exact mark, so there is no uncertainty band to charge for and nothing to attenuate — the protocol deliberately ships without any stress brake on exits, and exits stay live at every executable positive mark. This is safe precisely because the mark is *exact* rather than approximate: a withdrawer is paid exact recoverable value, never an overstated slice, so a withdrawing LP cannot extract value from the LPs who stay. Trader payouts are separately backed by each expiry's cash reserve, so an LP exit is never a solvency event for holders. (An earlier design priced an uncertainty-band withdraw fee against approximate NAV; both the band and the fee were removed with the exact-NAV rework.) +- **PLP supply and withdraw carry no fee, but numerical uncertainty has a hard ceiling.** A pool certificate above 1% aborts before either queue drains. Inside the ceiling, supply uses the upper certified value and withdraw uses the lower value, so neither side can extract approximation error from incumbent LPs. The spread is mechanical and untunable, not a fee or stress haircut. Trader payouts are separately backed by each expiry's cash reserve, so an LP exit is never a solvency event for holders. The earlier sampled-NAV uncertainty band and `withdraw_fee_alpha` remain removed. - **Reward incentives are outside the pool.** The current `PoolVault` holds no LP-owned SUI/DEEP incentive balance and reads no incentive-price oracle. DEEP held by the vault is custodial trading stake for account benefits, not PLP capital. - **DEEP staking is not LP capital and is freely withdrawable.** Account-staked DEEP is held in custody for trading benefits but is excluded from PLP redemption; it can be unstaked at any time (both active and inactive amounts) with no penalty. @@ -120,7 +120,7 @@ PLP holders supply DUSDC to `PoolVault` and receive shares whose value tracks po Predict's monetary math rounds down by convention, and the rounding direction is chosen so the protocol — not the user, and not the pool's solvency — absorbs sub-unit dust. - **Payouts and live redeems round in the reserve-favoring direction.** A live redeem deducts the closed slice's pro-rata static floor with a saturating floor at zero, and the settled payout is `quantity − floor_shares`. -- **The exact live NAV rounds the floor correction toward a higher liability.** `current_nav` is exact per order; where rounding is unavoidable it is taken so that one unit of fixed-point dust never makes the mark overstate recoverable value. The mark biases toward the protocol/incumbent LPs, never toward an over-payment on withdraw. +- **The live NAV carries rounding uncertainty instead of discarding it.** The complete per-order walk propagates a certified fixed-point radius with its center. `finish_flush` pays withdrawals from `center - error` and prices supplies from `center + error`, so numerical dust cannot become an overpayment or dilution. - **Reserved backing is computed from the same atoms as payouts.** The payout tree stores quantity and static floor shares, and derives net payout as `quantity − floor_shares` for reserve and settlement. A partial close removes the closed slice's quantity and floor atoms, leaving the survivor's reserve terms in the tree. The net effect is that a payout can never exceed the cash reserved to back it; rounding error is biased toward the protocol retaining dust, not toward over-paying. The practical takeaway: dust accrues to the protocol, never against solvency. Users should expect occasional one-unit shortfalls in their favour-rounded amounts, never one-unit shortfalls in backing. @@ -161,9 +161,9 @@ Normal one-operation flows are unaffected. Routers, keepers, and integrators bui Predict is pre-deployment software. Beyond the per-topic caveats above: - **Settlement depends on exact Propbook timestamp data.** As above, a past-expiry market cannot be valued or swept until Propbook has the exact normalized Pyth spot at that expiry timestamp. Operators must ensure exact settlement inserts are available around expiry. -- **The interface is still changing.** Module boundaries, function signatures, events, and config shapes are not frozen. Integrations built against the current code should expect breaking changes. In particular, the off-chain indexer/server is not yet rewired for the current event set (async LP/flush events including request limits and limit-miss/cancel reasons, the `(lower_tick, higher_tick)` fields on `OrderMinted`, `MarketCreated` carrying `propbook_underlying_id`, `tick_size`, cadence terms, and the immutable policy snapshot, slimmer expiry-cash/profit events, removed config-value history events, and the removed oracle events). +- **The interface is still changing.** Module boundaries, function signatures, events, and config shapes are not frozen. Integrations built against the current code should expect breaking changes. In particular, the off-chain indexer/server is not yet rewired for the current event set (async LP/flush events including request limits and limit-miss/cancel reasons, `FlushExecuted` carrying withdrawal/supply pool values plus the active-NAV error, the `(lower_tick, higher_tick)` fields on `OrderMinted`, `MarketCreated` carrying `propbook_underlying_id`, `tick_size`, cadence terms, and the immutable policy snapshot, slimmer expiry-cash/profit events, removed config-value history events, and the removed oracle events). - **The package address is unset pre-deploy.** The indexer is wired to fail fast on an empty package address rather than silently index nothing; this is a development guard, not a runtime risk, but it reflects that the system has not yet been deployed end-to-end. -- **Liquidation flow test coverage is limited.** The bounded-liquidation and floor-decay paths are the most safety-critical and the least exercised. The NAV mark itself is exact regardless of sweep state, but the holder-facing knock-out flow (and the backing-buffer release when an order is removed) depends on this path, so its limited coverage is a real maturity risk until it is hardened. +- **Liquidation flow test coverage is limited.** The bounded-liquidation and floor-decay paths are the most safety-critical and the least exercised. The NAV walk remains complete regardless of sweep state and carries numerical error explicitly, but the holder-facing knock-out flow (and the backing-buffer release when an order is removed) depends on this path, so its limited coverage is a real maturity risk until it is hardened. - **Valuation quantity math has a documented u64 envelope.** The per-strike valuation accumulator multiplies quantity by strike price in u64: a maximum-size single mint overflows (and cleanly aborts) only at strikes above roughly $430k, and the aggregate valuation fold would need on the order of $123–184M of concentrated high-strike open interest in one expiry — far above normal cadence allocation caps — before it could abort valuation. Accepted and bounded by allocation caps rather than widened. None of these are reasons the design is unsound; they are the difference between a designed protocol and an audited, deployed, battle-tested one. Anyone integrating before deployment should treat the economics as correct-by-design but unproven-in-production. diff --git a/packages/predict/harness/analyze.py b/packages/predict/harness/analyze.py index 999a1498d..eb01b2fd8 100644 --- a/packages/predict/harness/analyze.py +++ b/packages/predict/harness/analyze.py @@ -196,9 +196,9 @@ def _analyze_one(inst: Path) -> list[str]: print(f" {k:14} n={len(gs):>3} avg={sum(gs) // len(gs):>11,} gas") # pool NAV trend (flushes) -> drain heuristic. - flushes = sorted((r for r in recs if r.get("type") == "flush" and r.get("poolValue")), key=lambda r: r["ts"]) + flushes = sorted((r for r in recs if r.get("type") == "flush" and r.get("navCenter")), key=lambda r: r["ts"]) if flushes: - navs = [r["poolValue"] for r in flushes] + navs = [r["navCenter"] for r in flushes] print(f"\npool NAV (flush, n={len(navs)}): first=${navs[0]:,.0f} last=${navs[-1]:,.0f} min=${min(navs):,.0f} max=${max(navs):,.0f}") if min(navs) < max(navs) * 0.99: print(f" WARN NAV dipped {((max(navs) - min(navs)) / max(navs) * 100):.2f}% below peak (inspect for PLP drain)") @@ -424,7 +424,7 @@ def _by(kind: str) -> dict[int, tuple[int, int]]: # n -> (mean compGas, mean bo if not any(r.get("_actor") == "keeper" for r in recs): signals.append("no-keeper-trace") # keeper never started / crashed in setup print(" *** WARN: no keeper trace — keeper never started or crashed in setup ***") - nav_note = f"NAV ${flushes[-1]['poolValue']:,.0f}" if flushes else "no flush" + nav_note = f"NAV ${flushes[-1]['navCenter']:,.0f}" if flushes else "no flush" print(f"\nsummary [{label}]: {len(recs)} ops, {len(fails)} fail(s) ({len(flagged)} flagged), {len(adv_accepted)} adv-accepted, {nav_note}") return signals diff --git a/packages/predict/harness/ts/keeperService.ts b/packages/predict/harness/ts/keeperService.ts index 8db60710e..9de2eec3d 100644 --- a/packages/predict/harness/ts/keeperService.ts +++ b/packages/predict/harness/ts/keeperService.ts @@ -158,8 +158,13 @@ async function tick(feeds: Feeds, lifecycleCapId: string) { const fe = fr.events?.find((e: any) => e.type?.includes("FlushExecuted"))?.parsedJson; appendTrace("keeper", { type: "flush", marketCount: fe ? Number(fe.market_count) : flush.length, stragglers: settlements.length, - poolValue: fe ? Number(fe.pool_value) / 1e6 : 0, totalSupply: fe ? Number(fe.total_supply) : 0, - activeNav: fe ? Number(fe.active_market_nav) / 1e6 : 0, gas: gasOf(fr), compGas: computationOf(fr), + navCenter: fe ? (Number(fe.withdraw_pool_value) + Number(fe.supply_pool_value)) / 2e6 : 0, + withdrawPoolValue: fe ? Number(fe.withdraw_pool_value) / 1e6 : 0, + supplyPoolValue: fe ? Number(fe.supply_pool_value) / 1e6 : 0, + totalSupply: fe ? Number(fe.total_supply) : 0, + activeNav: fe ? Number(fe.active_market_nav) / 1e6 : 0, + activeNavError: fe ? Number(fe.active_market_nav_error) / 1e6 : 0, + gas: gasOf(fr), compGas: computationOf(fr), }); console.log(`[keeper] flushed ${flush.length} active market(s)`); } catch (e) { diff --git a/packages/predict/predeploy/open-items.md b/packages/predict/predeploy/open-items.md index 36b801ff1..faf166429 100644 --- a/packages/predict/predeploy/open-items.md +++ b/packages/predict/predeploy/open-items.md @@ -143,43 +143,6 @@ bound and accept the aggregation residual in the rounding policy, add a regression covering both directions, and narrow every exact-NAV claim to the accepted bound. (2026-07-17 clean-room gap audit) -### P-14: Short-dated up_price breaches the 0.1% price-deviation bound — variance increment floored to 1e9 - -**Severity:** Medium. - -The `up_price` on short-dated markets breaches the ratified 0.1% price-deviation -bound (`response-policies.md § Pricing and valuation deviation bounds`). -`pricing::compute_nd2` forms the SVI variance increment as `math::mul(b, inner)` -= `(b · inner) / 1e9`, truncating the sub-unit fraction to an integer 1e9 unit. -At the low total variance of short-dated markets that fraction is a material -share of total variance, so `total_var` is biased systematically downward -(truncation toward zero, not zero-mean noise). `up_price` feeds entry price, the -live NAV mark, and liquidation thresholds, so the breach is a systematic mark -bias, not noise. - -Measured against a 30-digit reference over a moneyness grid on real Block Scholes -SVI surfaces (1c–99c band): one-minute worst-case relative error **1.12%** -(absolute 5.15e-4); five-minute (the dominant published cadence) worst-case -**3.28%** (absolute 2.09e-3); daily below 0.27%. One-minute and five-minute -exceed the 0.1% ceiling; daily is within it. A worst-case one-minute surface at -`k = -0.0005` discards 0.806 of 51.806 raw variance-increment units (1.56%): -`up_price` 0.89080 versus 0.89028 exact. - -The breach went undetected because **the deviation bound is only enforced where -the pricing reference has scenarios** — the current dataset is a single -moderate-variance market (`sqrt(w) ≈ 0.008–0.017`, `w ≈ 1e-4`), roughly five -orders of magnitude above the short-dated regime (`w ≈ 1e-8`). Reachable on any -deployed one-minute or five-minute cadence (`CadenceConfig.window_size > 0`); the -near-expiry no-leverage gate (P-2, O-1) caps admission leverage but does not -disable pricing. - -**Action:** two parts. (1) Carry the variance, `sqrt(w)`, and `d2` computation at -u128 / 1e18 precision instead of flooring the `b · inner` product to 1e9, which -brings short-dated error back under the 0.1% bound. (2) Add short-dated scenarios -to the generated pricing reference so the bound is actually enforced at `w ≈ -1e-8` — without them the fix has no regression guard where the bound is tightest -(`1/sqrt(w)` conditioning). - ## Access and Governance ### G-1: Root admin caps have no on-chain revocation or rotation diff --git a/packages/predict/predeploy/response-policies.md b/packages/predict/predeploy/response-policies.md index 65db2c547..b4b78e6aa 100644 --- a/packages/predict/predeploy/response-policies.md +++ b/packages/predict/predeploy/response-policies.md @@ -1,6 +1,6 @@ # Predict Response-Policy Register -Updated 2026-07-21. This is the tracked register of **settled response-policy +Updated 2026-07-23. This is the tracked register of **settled response-policy decisions**: for each degenerate or adversarial state the protocol can reach, the behavior someone deliberately chose, why, and the tests that pin it. @@ -48,7 +48,7 @@ Each entry records: **Trigger state** / **Controller** / **Blast radius** / --- -## RP-1: The flush executes at any exact NAV mark (price circuit breakers removed) +## RP-1: The flush executes at any certified NAV price (economic circuit breakers removed) - **Trigger state:** frozen flush mark implies a PLP price outside the former `[0.01, 100]` DUSDC band, or a pool NAV below the former dust floor. @@ -56,10 +56,15 @@ Each entry records: **Trigger state** / **Controller** / **Blast radius** / history. `total_supply ≤ k × pool_value` is not a maintainable invariant. - **Blast radius:** the mark is computed inside `finish_flush`, the single mandatory pool-wide PTB (valuation, sweeps, LP fills). -- **Response:** proceed — no mark-level guard. Degeneracies are owned at the - fill site (RP-2). (Commit `cc67ed9f`, resolving P-1.) -- **Reasoning:** the mark is the exact pool NAV, so any price it implies is - fair by construction; the deleted `assert_plp_price_in_bounds` was a +- **Response:** proceed at the certified bid/ask regardless of the PLP price + they imply. Degeneracies are owned at the fill site (RP-2). The distinct 1% + numerical-certificate ceiling is defined under "Pricing and valuation + deviation bounds"; it constrains whether NAV is known well enough to move + value, not whether a known economic outcome is acceptable. (Commit + `cc67ed9f`, resolving P-1.) +- **Reasoning:** the withdrawal price is at or below certified pool NAV and the + supply price is at or above it, so neither side extracts value from numerical + uncertainty; the deleted `assert_plp_price_in_bounds` was a state-triggered abort over a market-controlled variable with no on-chain recovery path — it bricked the flush in legitimate states (100x appreciation, post-drawdown recapitalization) until package upgrade. @@ -123,17 +128,17 @@ Each entry records: **Trigger state** / **Controller** / **Blast radius** / protocol-triggered refunds, or a new LP request type adds another non-executable fill mode. -## RP-3: `lp_pool_value` floors at zero +## RP-3: `lp_pool_value_approx` floors its center at zero - **Trigger state:** the sticky held-out total (`exclusion + pending_protocol_profit`) exceeds a collapsed gross pool value. - **Controller:** market (gross collapses via losses); the exclusion basis is protocol accounting but intentionally does not shrink on withdrawals. - **Blast radius:** the NAV read feeding the mandatory flush. -- **Response:** `skip/carry`-shaped clamp — `saturating_sub` to 0, never - abort. LP-attributable value reads as zero until marks recover; the - downstream consequence (zero-value fills) is RP-2's problem, not this - read's. +- **Response:** `skip/carry`-shaped clamp — `Approx::saturating_sub` to a zero + center, never abort. `finish_flush` turns that result into two zero, + non-executable marks until value recovers; the downstream refund behavior is + RP-2's problem, not this read's. - **Reasoning:** LP value cannot be negative; an abort here would brick the flush on an exogenous state. NAV==0 is a real reachable state, not an underflow guard. @@ -161,8 +166,8 @@ Each entry records: **Trigger state** / **Controller** / **Blast radius** / the permissionless exact-ms insert followed by `try_settle`. Standalone cash rebalance is a no-op in the window, and the keeper does not flush until the transition succeeds. Deliberately **no substitute - mark**: a settlement-dependent market has no well-defined true value, and - the single mark prices both queue directions — contribute-0 dilutes + mark**: a settlement-dependent market has no well-defined true value from + which to derive either side of the paired mark — contribute-0 dilutes incumbents on supply, free-cash overpays withdrawals. - **Recovery is not guaranteed.** `pyth_feed::insert_at` rejects a print generated more than `constants::max_settlement_carry_ms` (2s) before its @@ -227,12 +232,13 @@ Each entry records: **Trigger state** / **Controller** / **Blast radius** / this one *is* enforceable as an invariant). - **Response:** gate the flush behind the revocable `MarketLifecycleCap`; the accepted cost is a trust assumption — the operator chooses the valuation - instant (never the price: the mark is the exact NAV at that instant) and + instant (never the price: both sides are derived from the certified NAV at + that instant) and must run flushes for LP liveness. - **Reasoning + disclosure:** `docs/risks.md` "The privileged flush"; audit lens L8 (NAV-timing manipulation closed by privilege). -- **Risk profile:** `BEST-GUESS` — operator-timing abuse bounded by mark - exactness; liveness depends on flush cadence (disclosed). +- **Risk profile:** `BEST-GUESS` — operator-timing abuse is bounded by the + certified bid/ask; liveness depends on flush cadence (disclosed). - **Pinning tests:** not yet catalogued — fill in when this entry is next touched. - **Reopen when:** a continuous/permissionless valuation design (e.g. @@ -609,7 +615,7 @@ Each entry records: **Trigger state** / **Controller** / **Blast radius** / the carry refill bear a small one-time timing transfer (suppliers inside the window pick it up), and the front-load becomes permanent only if the pool winds down or stays loss-making so the carry never refills. LP pricing is - structurally unaffected: `plp::lp_pool_value` already excludes + structurally unaffected: `plp::lp_pool_value_approx` already excludes `share × max(0, live unrealized net − carry)` — the net-basis anticipation of the future cut — and already-materialized cuts have left the pricing basis. - **Risk profile:** `BEST-GUESS` — the peak-of-booked-net invariant itself is @@ -643,10 +649,10 @@ Each entry records: **Trigger state** / **Controller** / **Blast radius** / (every `current_nav`, including the flush lane `plp::value_expiry`). - **Controller:** market (prices move orders into the band between keeper sweeps) × protocol (whether the mark prices those claims at holder value). -- **Blast radius:** the single frozen mark that fills both LP queues. Marking a +- **Blast radius:** the frozen bid/ask pair that fills the LP queues. Marking a liquidatable order at holder value (`range_value - floor`) understates recoverable value by up to the LTV buffer per order, diluting incumbent LPs on - a same-flush supply and contradicting the exact-mark framing (RP-1; the + a same-flush supply and contradicting the certified-mark framing (RP-1; the NAV-mark directional invariant). - **Response:** value the knock-out at its liquidated worth in the read-only NAV correction, without touching the book. `exact_live_liability` already walks the @@ -761,19 +767,32 @@ rounding, not model-evaluation accuracy): - **NAV:** a produced `current_nav` / pool mark must not deviate from true NAV by more than **1%** (relative). +**Runtime response.** The pricing ball retains a numerical-error certificate on +the canonical scalar control path. A mint quote whose contract-price certificate +exceeds 0.1% aborts before it can create an order; the user can retry after a fresh +oracle surface. A full-pool flush whose final pool-NAV certificate exceeds 1% +aborts before it creates a frozen mark; inside that ceiling, supplies price at +`center + error` and withdrawals at `center - error`, both over the same +pre-drain PLP supply. A keeper retries an over-limit certificate after fresh +pricing inputs or settlement. A zero scalar pool NAV proceeds under RP-1/RP-3 +as two zero, non-executable marks, and a purely relative NAV bound has no +denominator. These checks do not reinterpret liquidation, live close, or other +protocol branches: those continue to use the scalar center exactly as the +protocol defines. + Enforcement is by independent-reference test, not model judgment, and is in fact tighter than the ceilings. The price bound is guarded by the generated pricing reference (`packages/predict/tests/pricing/pricing_reference_data.move`, built by `generate_pricing_reference.py` from real Block Scholes surfaces against a -true-math reference), whose per-scenario analytic fixed-point tolerance sits well -inside 0.1% — but only where the dataset has scenarios. It must therefore cover -the full deployed variance range, short-dated included, or the bound goes -unenforced exactly where it is tightest: `1/sqrt(w)` conditioning makes low -variance the worst case, which is the gap open item P-14 records. The NAV bound -is guarded by the `current_nav_flow_tests` independent oracle. A pricer or -valuation change that would breach either ceiling on any deployable surface is a -defect to fix, not an accepted tradeoff — this is the line between negligible and -worth-fixing. +true-math reference). Its moderate-variance scenarios use analytic fixed-point +tolerances, and its short-dated scenario pins the 0.1% ceiling at +`w ~= 3.26e-8`, where `1/sqrt(w)` conditioning makes variance rounding most +material. Pricing retains only `a + b * inner` at 1e18 through `sqrt(w)`; the +1e9 total-variance center and every downstream operation remain on the canonical +fixed-math path. The NAV bound is guarded by the `current_nav_flow_tests` +independent oracle and the pool-flow bid/ask tests. A pricer or valuation change +that would breach either ceiling on any deployable surface is a defect to fix, +not an accepted tradeoff — this is the line between negligible and worth-fixing. --- diff --git a/packages/predict/simulations/README.md b/packages/predict/simulations/README.md index cbcd6f785..005bae56a 100644 --- a/packages/predict/simulations/README.md +++ b/packages/predict/simulations/README.md @@ -292,7 +292,7 @@ Full localnet runs can produce: Localnet/Python parity is a confidence gate, not a proof of every possible terminal state. The normal replay validates that Python and localnet agree on canonical live economics for the same generated CSV rows and runner-synthesized maintenance transactions: oracle refreshes, mints, redeems, passive liquidations, supply, withdraw, queue drains, expiry-cash rebalances, normalized event fields, and tracked state deltas. -`compare_parity.py` removes chain-clock landing timestamps and oracle source timestamps because localnet rebases feed timestamps onto its live `Clock` while Python replays the economic inputs without reproducing that wall clock. It also removes `FlushExecuted.pool_value` and `active_market_nav`: Python's independently aggregated mark can differ from Move by fixed-point dust, while the priced fill outputs and resulting state must still match exactly. Both artifacts retain their full fields; queue depths, pre/post LP supply, fill amounts, maintenance records, and every tracked state value remain parity-gated. +`compare_parity.py` removes chain-clock landing timestamps and oracle source timestamps because localnet rebases feed timestamps onto its live `Clock` while Python replays the economic inputs without reproducing that wall clock. It also removes the Move `FlushExecuted` bid/ask certificate fields and Python's independently aggregated center: those diagnostic values can differ by fixed-point dust, while the priced fill outputs and resulting state must still match exactly. Both artifacts retain their full fields; queue depths, pre/post LP supply, fill amounts, maintenance records, and every tracked state value remain parity-gated. Live pool-sync sweeps increase aggregate pricing credits and can also realize previously carried protocol profit into the reserve when returned idle cash is diff --git a/packages/predict/simulations/compare_parity.py b/packages/predict/simulations/compare_parity.py index 9b4948abd..d06634e9b 100644 --- a/packages/predict/simulations/compare_parity.py +++ b/packages/predict/simulations/compare_parity.py @@ -20,7 +20,13 @@ "block_scholes_forward_source_timestamp_ms", "block_scholes_svi_source_timestamp_ms", } -FLUSH_DIAGNOSTIC_FIELDS = {"pool_value", "active_market_nav"} +FLUSH_DIAGNOSTIC_FIELDS = { + "pool_value", + "withdraw_pool_value", + "supply_pool_value", + "active_market_nav", + "active_market_nav_error", +} def parity_projection(payload: dict[str, Any]) -> dict[str, Any]: diff --git a/packages/predict/simulations/data/generate_scenario.py b/packages/predict/simulations/data/generate_scenario.py index e975a0833..3c88fdd0d 100644 --- a/packages/predict/simulations/data/generate_scenario.py +++ b/packages/predict/simulations/data/generate_scenario.py @@ -194,7 +194,7 @@ def build_mint_row(self, tx: int, snapshot: dict[str, Any]) -> dict[str, str]: except ValueError: continue - fee_amount = replay.deepbook_mul(fee_rate, quantity) + fee_amount = replay.deepbook_mul_up(fee_rate, quantity) cash_required = terms["contribution"] + fee_amount if self.manager_balance - cash_required < MANAGER_CASH_FLOOR: continue @@ -273,7 +273,7 @@ def quantity_for_spend( leverage: int, ) -> int: lot_terms = replay.compute_mint_terms(entry_probability, replay.POSITION_LOT_SIZE, leverage) - lot_fee = replay.deepbook_mul(fee_rate, replay.POSITION_LOT_SIZE) + lot_fee = replay.deepbook_mul_up(fee_rate, replay.POSITION_LOT_SIZE) lot_cost = lot_terms["contribution"] + lot_fee if lot_cost <= 0: raise GenerationError("mint lot cost must be positive") diff --git a/packages/predict/simulations/python_replay.py b/packages/predict/simulations/python_replay.py index 16d6e2336..a92e24f25 100644 --- a/packages/predict/simulations/python_replay.py +++ b/packages/predict/simulations/python_replay.py @@ -523,6 +523,10 @@ def deepbook_mul(x: int, y: int) -> int: return x * y // FLOAT_SCALING +def deepbook_mul_up(x: int, y: int) -> int: + return (x * y + FLOAT_SCALING - 1) // FLOAT_SCALING + + def mul_div_round_down(a: int, b: int, c: int) -> int: return a * b // c @@ -641,6 +645,13 @@ def ln_fixed(x: int) -> I64: return I64(ln_u128(y, n)) +def ln_ratio_fixed(numerator: int, denominator: int) -> I64: + ratio = numerator * FLOAT_SCALING // denominator + if 1 < ratio <= POS_INF_STRIKE: + return ln_fixed(ratio) + return ln_fixed(numerator).sub(ln_fixed(denominator)) + + def exp_series_u128(r: int) -> int: total = F term = F @@ -809,14 +820,7 @@ def normal_pdf(value: I64) -> int: def compute_nd2(svi: dict[str, Any], forward: int, strike: int) -> int: - # Mirror pricing.move's u128 deep-tail saturation exactly: ratio 0 is the - # neg_inf limit (P = 1), ratio above u64::MAX is the pos_inf limit (P = 0). - strike_ratio_scaled = strike * FLOAT_SCALING // forward - if strike_ratio_scaled == 0: - return FLOAT_SCALING - if strike_ratio_scaled > 2**64 - 1: - return 0 - k = ln_fixed(strike_ratio_scaled) + k = ln_ratio_fixed(strike, forward) m = I64(svi["m"], svi["mNegative"]) k_minus_m = k.sub(m) k_minus_m_squared = k_minus_m.square_scaled() @@ -1214,9 +1218,9 @@ def live_position_liability(model: dict[str, Any], curve: list[dict[str, int]] | return exact_live_liability(model) -# current_nav (per ExpiryMarket): free cash minus the exact per-order live -# liability, floored at zero. free_cash = expiry_cash - rebate_reserve. This is the -# EXACT mark the flush prices supply AND withdraw at. +# Scalar replay of the `current_nav_approx` center for one ExpiryMarket: free cash +# minus the per-order live-liability center, floored at zero. free_cash = +# expiry_cash - rebate_reserve. This helper does not reproduce the Approx radius. def current_nav(model: dict[str, Any], state: dict[str, int]) -> int: rebate_reserve = deepbook_mul(state["expiry_unresolved_trading_fees"], TRADING_LOSS_REBATE_RATE) free_cash = max(0, state["expiry_cash_balance"] - rebate_reserve) @@ -1229,11 +1233,11 @@ def compute_pool_value( curve: list[dict[str, int]] | None = None, position_liability: int | None = None, ) -> int: - # Pool NAV = lp_pool_value(idle, credits, debits, share, active, pending), where - # the single active market's NAV is the EXACT `current_nav` above (settled markets - # contribute 0 — not modelled here, settlement is stubbed). Mirrors - # plp::lp_pool_value's saturating exclusion of unmaterialized and carried - # protocol profit. + # Scalar-center analogue of `plp::lp_pool_value_approx`, where the single + # active market contributes the `current_nav` center above (settled markets + # contribute 0 — not modelled here, settlement is stubbed). This mirrors the + # saturating exclusion of unmaterialized and carried protocol profit, but does + # not compute the on-chain certificate or its directional flush marks. if model["current_svi"] is None or model["current_forward"] == 0: raise ValueError("pool valuation requires prior price and SVI updates") active_expiry_value = current_nav(model, state) @@ -1570,7 +1574,7 @@ def order_minted_update( lower_tick, higher_tick = binary_range_ticks(strike, mint["isUp"]) entry_probability = compute_range_price(svi, forward, lower, higher) assert_entry_probability_bounds(entry_probability) - fee_amount = deepbook_mul(assert_mint_fee_rate(entry_probability, time_to_expiry_ms), mint["quantity"]) + fee_amount = deepbook_mul_up(assert_mint_fee_rate(entry_probability, time_to_expiry_ms), mint["quantity"]) terms = compute_mint_terms(entry_probability, mint["quantity"], mint["leverage"]) assert_net_premium_above_min(terms["contribution"]) return { @@ -1875,7 +1879,7 @@ def redeem_order(model: dict[str, Any], row: dict[str, Any]) -> dict[str, str]: raise ValueError(f"order_ref {ref} is not redeemable") probability = compute_range_price(model["current_svi"], model["current_forward"], order["lower"], order["higher"]) - fee = deepbook_mul(fee_rate(probability, model_fee_time_to_expiry_ms(model)), close_quantity) + fee = deepbook_mul_up(fee_rate(probability, model_fee_time_to_expiry_ms(model)), close_quantity) gross = deepbook_mul(probability, close_quantity) remaining_quantity = order["quantity"] - close_quantity @@ -1928,12 +1932,10 @@ def redeem_order(model: dict[str, Any], row: dict[str, Any]) -> dict[str, str]: # === Async LP supply/withdraw (replaces the deleted synchronous SupplyExecuted / # WithdrawExecuted). A supply/withdraw is now: a request that escrows funds, then a -# later privileged flush that drains the queue at one EXACT frozen mark -# (current_nav). plp::supply_shares mints `amount * total_supply / pool_value` -# (bootstrap 1:1 when total_supply == 0 AND pool_value == 0); plp::withdraw_dusdc -# pays `shares * pool_value / total_supply` — NO withdraw fee (the band fee died with -# the approximate-NAV world). Both round down; a dust request that prices to 0 is -# refunded. +# later privileged flush. These long-replay helpers intentionally use the scalar +# pool-value center for both directions; they do not reproduce the contract's +# `center + error` supply mark or `center - error` withdrawal mark. Both helpers +# round down, and a dust request that prices to 0 is refunded. # # These synchronous helpers are for the long Python-only replay. Normal parity # queues requests and drains them later in `parity_flush_updates`. @@ -1946,7 +1948,7 @@ def supply_update( if row["lpRef"] in model["lp_refs"]: raise ValueError(f"duplicate lp_ref {row['lpRef']}") total_supply = synced_state["vault_total_plp_supply"] - # plp::supply_shares: bootstrap 1:1 requires an empty pool NAV. + # Scalar replay supply calculation: bootstrap 1:1 requires an empty pool NAV. if total_supply == 0: if pool_value != 0: raise ValueError("bootstrap supply requires empty pool NAV") @@ -1979,7 +1981,7 @@ def withdraw_update( if shares is None: raise ValueError(f"unknown lp_ref {row['lpRef']}") total_supply = synced_state["vault_total_plp_supply"] - # plp::withdraw_dusdc: pro-rata, rounded down, NO withdraw fee. + # Scalar replay withdrawal calculation: pro-rata and rounded down. payout = mul_div_round_down(shares, pool_value, total_supply) if total_supply else 0 if payout <= 0: raise ValueError("withdraw priced to zero DUSDC (would be refunded)") diff --git a/packages/predict/simulations/src/sim.ts b/packages/predict/simulations/src/sim.ts index 188471413..da5b0f3ae 100644 --- a/packages/predict/simulations/src/sim.ts +++ b/packages/predict/simulations/src/sim.ts @@ -657,9 +657,9 @@ function normalizeSettledOrderRedeemed(event: any, row: ScenarioRow): Record { const json = event.parsedJson ?? {}; return { type: "flush_executed", - pool_value: decimal(json.pool_value), + withdraw_pool_value: decimal(json.withdraw_pool_value), + supply_pool_value: decimal(json.supply_pool_value), total_supply: decimal(json.total_supply), active_market_nav: decimal(json.active_market_nav), + active_market_nav_error: decimal(json.active_market_nav_error), market_count: decimal(json.market_count), idle_balance_before: decimal(json.idle_balance_before), supplies_filled: decimal(json.supplies_filled), diff --git a/packages/predict/sources/config/strike_exposure_config.move b/packages/predict/sources/config/strike_exposure_config.move index ef1e54a1f..4fb580506 100644 --- a/packages/predict/sources/config/strike_exposure_config.move +++ b/packages/predict/sources/config/strike_exposure_config.move @@ -101,7 +101,9 @@ public(package) fun no_leverage_window_ms(config: &StrikeExposureConfig): u64 { config.no_leverage_window_ms } -/// Returns the raw trade fee for a live probability and quantity, rounded down so the trader keeps sub-unit dust. +/// Returns the raw trade fee for a live probability and quantity. The final +/// rate-to-DUSDC conversion rounds upward, so a nonzero fractional charge is +/// collected rather than discarded. /// /// Precondition: `timestamp_ms < expiry_ms`. Live-pricing callers enforce this /// before passing timestamps because the fee-rate helper derives time-to-expiry @@ -113,7 +115,7 @@ public(package) fun trading_fee( quantity: u64, timestamp_ms: u64, ): u64 { - math::mul(config.fee_rate(expiry_ms, probability, timestamp_ms), quantity) + math::mul_up(config.fee_rate(expiry_ms, probability, timestamp_ms), quantity) } /// Assert entry probability and leverage policy without deriving quantity-dependent diff --git a/packages/predict/sources/events/vault_events.move b/packages/predict/sources/events/vault_events.move index 9122ec122..04a3f1616 100644 --- a/packages/predict/sources/events/vault_events.move +++ b/packages/predict/sources/events/vault_events.move @@ -158,18 +158,21 @@ public struct WithdrawFilled has copy, drop, store { requests_pending_after: u64, } -/// Emitted once after a flush drains both queues. `pool_value / total_supply` is -/// the frozen pre-drain mark used by every fill in the flush. +/// Emitted once after a flush drains both queues at one frozen pre-drain +/// bid/ask mark over `total_supply`. public struct FlushExecuted has copy, drop, store { pool_vault_id: ID, epoch: u64, - /// LP-attributable pool NAV every fill was priced at: idle plus - /// `active_market_nav`, excluding unrealized and pending protocol profit. - pool_value: u64, - /// PLP supply in the frozen pre-drain mark used to price every fill. + /// Lower certified pool value used for withdrawal payouts. + withdraw_pool_value: u64, + /// Upper certified pool value used for supply share issuance. + supply_pool_value: u64, + /// PLP supply in the frozen pre-drain mark pair used to price every fill. total_supply: u64, /// Sum of the marked NAV contributed by each active market; settled markets add zero. active_market_nav: u64, + /// Certified absolute error on `active_market_nav`. + active_market_nav_error: u64, /// Number of active markets valued for this flush. market_count: u64, /// Idle DUSDC held by the pool at valuation time, before the drain. @@ -218,6 +221,16 @@ public struct FeeIncentivesReturned has copy, drop, store { pool_reserve_after: u64, } +#[test_only] +public fun flush_withdraw_pool_value(event: &FlushExecuted): u64 { + event.withdraw_pool_value +} + +#[test_only] +public fun flush_supply_pool_value(event: &FlushExecuted): u64 { + event.supply_pool_value +} + // === Public-Package Functions === public(package) fun emit_expiry_cash_received( @@ -447,9 +460,11 @@ public(package) fun emit_withdraw_filled( public(package) fun emit_flush_executed( pool_vault_id: ID, epoch: u64, - pool_value: u64, + withdraw_pool_value: u64, + supply_pool_value: u64, total_supply: u64, active_market_nav: u64, + active_market_nav_error: u64, market_count: u64, idle_balance_before: u64, supplies_filled: u64, @@ -461,9 +476,11 @@ public(package) fun emit_flush_executed( event::emit(FlushExecuted { pool_vault_id, epoch, - pool_value, + withdraw_pool_value, + supply_pool_value, total_supply, active_market_nav, + active_market_nav_error, market_count, idle_balance_before, supplies_filled, diff --git a/packages/predict/sources/ewma.move b/packages/predict/sources/ewma.move index 055d37212..37adcc501 100644 --- a/packages/predict/sources/ewma.move +++ b/packages/predict/sources/ewma.move @@ -53,8 +53,9 @@ public(package) fun penalty_fee( let z_score = math::div(gas_price - self.mean, std_dev); if (z_score <= config.z_score_threshold()) return 0; - // penalty_rate * quantity / float_scaling, round down - math::mul(config.penalty_rate(), quantity) + // Convert the final rate to DUSDC upward so a nonzero fractional surcharge + // is collected rather than discarded. + math::mul_up(config.penalty_rate(), quantity) } /// Fold the current transaction's gas price into the smoothed mean and variance. diff --git a/packages/predict/sources/expiry_market.move b/packages/predict/sources/expiry_market.move index 919d22909..0ef0d0a4d 100644 --- a/packages/predict/sources/expiry_market.move +++ b/packages/predict/sources/expiry_market.move @@ -29,7 +29,7 @@ use deepbook_predict::{ strike_exposure_config }; use dusdc::dusdc::DUSDC; -use fixed_math::math; +use fixed_math::{approx::{Self, Approx}, math}; use propbook::{ block_scholes_forward_feed::BlockScholesForwardFeed, block_scholes_spot_feed::BlockScholesSpotFeed, @@ -237,11 +237,18 @@ public fun load_live_pricer( /// `Pricer`; an expired but unsettled market cannot be valued through this path. /// Public for PTB composition and devInspect pool valuation. public fun current_nav(market: &ExpiryMarket, pricer: &Pricer): u64 { + market.current_nav_approx(pricer).magnitude() +} + +/// Return live marked NAV with its certified numerical error retained for pool +/// valuation. The public `current_nav` is the value-only read wrapper. +public(package) fun current_nav_approx(market: &ExpiryMarket, pricer: &Pricer): Approx { market.assert_pricer_bound(pricer); let liability = market.strike_exposure.exact_live_liability(pricer); // Marked liability and free cash are computed through different rounded // aggregates; negative marked NAV is represented as zero. - market.cash.free_cash().saturating_sub(liability) + let cash = approx::exact_u64(market.cash.free_cash()); + cash.sub(&liability).clamp_nonnegative() } /// Return one order's close value before fees. Liquidated or currently diff --git a/packages/predict/sources/plp/lp_book.move b/packages/predict/sources/plp/lp_book.move index d3f9e224c..b3fabbc6f 100644 --- a/packages/predict/sources/plp/lp_book.move +++ b/packages/predict/sources/plp/lp_book.move @@ -17,6 +17,7 @@ const ERequestNotFound: u64 = 0; const EBelowMinSupplyRequest: u64 = 1; const EBelowMinWithdrawRequest: u64 = 2; const ENotRequestOwner: u64 = 3; +const EInvalidFlushMark: u64 = 4; const PAGE_CAPACITY: u64 = 64; @@ -67,16 +68,17 @@ public struct RequestQueue has store { escrow: Balance, } -/// Frozen flush share-price mark: `pool_value` over `total_supply`. Carried as one -/// value so the supply/withdraw pricing helpers read each term by name and cannot -/// transpose the numerator and denominator. +/// Frozen bid/ask share-price mark over one pre-drain `total_supply`. Supplies +/// price at the higher pool value and withdrawals at the lower pool value. public struct FlushMark has drop { - pool_value: u64, + withdraw_pool_value: u64, + supply_pool_value: u64, total_supply: u64, - executable: bool, + withdraw_executable: bool, + supply_executable: bool, } -/// Result of draining both LP queues at one frozen mark. +/// Result of draining both LP queues at one frozen mark pair. public struct DrainSummary has copy, drop { supplies_filled: u64, withdrawals_filled: u64, @@ -153,11 +155,18 @@ public(package) fun cancel_withdraw_request( (request.account_id, request.amount, refund) } -public(package) fun new_flush_mark(pool_value: u64, total_supply: u64): FlushMark { +public(package) fun new_flush_mark( + withdraw_pool_value: u64, + supply_pool_value: u64, + total_supply: u64, +): FlushMark { + assert!(withdraw_pool_value <= supply_pool_value, EInvalidFlushMark); FlushMark { - pool_value, + withdraw_pool_value, + supply_pool_value, total_supply, - executable: is_executable_mark(pool_value, total_supply), + withdraw_executable: is_executable_mark(withdraw_pool_value, total_supply), + supply_executable: is_executable_mark(supply_pool_value, total_supply), } } @@ -173,12 +182,12 @@ public(package) fun requests_processed(summary: &DrainSummary): u64 { summary.requests_processed } -/// Drain both LP queues at the frozen flush mark (`pool_value` over `total_supply`), -/// supplies first then withdrawals. `supply_budget` / `withdraw_budget` bound how many -/// head requests each queue may process this flush; processed means filled, -/// protocol-refunded as non-executable at the mark, or a live limit miss that remains -/// queued. `None` makes that queue unbounded. The two budgets are independent, so -/// supply pressure can never starve withdrawals. +/// Drain both LP queues at one frozen bid/ask mark over `total_supply`, supplies +/// first then withdrawals. `supply_budget` / `withdraw_budget` bound how many head +/// requests each queue may process this flush; processed means filled, +/// protocol-refunded as non-executable at its side's mark, or a live limit miss +/// that remains queued. `None` makes that queue unbounded. The two budgets are +/// independent, so supply pressure can never starve withdrawals. /// Supplies run first on purpose: their fresh idle cash funds same-flush withdrawals. /// User-cancelled requests are removed at cancel time and never spend flush capacity. public(package) fun drain( @@ -593,10 +602,14 @@ fun entry_offset(entries: &vector, index: u64): u64 { /// LP shares minted for `amount` DUSDC at the frozen flush mark. `None` means the /// mark/request pair is not executable and the queued request must be refunded. fun quote_supply_shares(mark: &FlushMark, amount: u64): Option { - if (!mark.executable) return option::none(); + if (!mark.supply_executable) return option::none(); // = amount * total_supply / pool_value, round down (supplier mints ≤1 ulp // fewer shares; the pool keeps the dust). - math::try_mul_div_down(amount, mark.total_supply, mark.pool_value).and!(|shares| { + math::try_mul_div_down( + amount, + mark.total_supply, + mark.supply_pool_value, + ).and!(|shares| { if (shares == 0) option::none() else option::some(shares) }) } @@ -604,12 +617,14 @@ fun quote_supply_shares(mark: &FlushMark, amount: u64): Option { /// DUSDC owed for `shares` LP at the frozen flush mark. `None` means the /// mark/request pair is not executable and the queued request must be refunded. fun quote_withdraw_dusdc(mark: &FlushMark, shares: u64): Option { - if (!mark.executable) return option::none(); + if (!mark.withdraw_executable) return option::none(); // = shares * pool_value / total_supply, round down (withdrawer is paid ≤1 ulp // less; the pool keeps the dust). - math::try_mul_div_down(shares, mark.pool_value, mark.total_supply).and!(|payout| { - if (payout == 0) option::none() else option::some(payout) - }) + math::try_mul_div_down( + shares, + mark.withdraw_pool_value, + mark.total_supply, + ).and!(|payout| if (payout == 0) option::none() else option::some(payout)) } fun is_executable_mark(pool_value: u64, total_supply: u64): bool { diff --git a/packages/predict/sources/plp/plp.move b/packages/predict/sources/plp/plp.move index 3809951fd..d5abb31b0 100644 --- a/packages/predict/sources/plp/plp.move +++ b/packages/predict/sources/plp/plp.move @@ -28,7 +28,7 @@ use deepbook_predict::{ vault_events }; use dusdc::dusdc::DUSDC; -use fixed_math::math; +use fixed_math::{approx::{Self, Approx}, math}; use propbook::{ block_scholes_forward_feed::BlockScholesForwardFeed, block_scholes_spot_feed::BlockScholesSpotFeed, @@ -55,6 +55,10 @@ const EBelowMinBootstrapLiquidity: u64 = 6; const EBelowMinFeeIncentiveSponsorship: u64 = 7; const EMarketNotSettled: u64 = 8; const EMaxLiveExpiryMarketsExceeded: u64 = 9; +const ENavTooImprecise: u64 = 10; + +/// Relative numerical-error ceiling for the frozen pool NAV mark: 1% at 1e9 scale. +macro fun max_nav_deviation(): u64 { 10_000_000 } /// One-time witness type for Predict LP token registration. public struct PLP has drop {} @@ -89,8 +93,8 @@ public struct PoolValuation { expected_expiry_markets: vector, /// Markets valued so far this flow; folded against `expected` at finish. valued_expiry_markets: vector, - /// Running Σ of each valued market's NAV (settled markets contribute 0). - total_nav: u64, + /// Running Σ of each valued market's NAV (settled markets contribute exact 0). + total_nav: Approx, } // === Package Initializer === @@ -242,7 +246,7 @@ public fun value_expiry( valuation.assert_expiry_ready_to_value(expiry_market_id); vault.expiry_accounting.assert_registered_expiry(expiry_market_id); let nav = if (vault.sweep_or_rebalance_expiry(market, config, clock)) { - 0 + approx::exact_u64(0) } else { let pricer = market.load_live_pricer( config, @@ -253,10 +257,10 @@ public fun value_expiry( bs_svi, clock, ); - market.current_nav(&pricer) + market.current_nav_approx(&pricer) }; valuation.valued_expiry_markets.push_back(expiry_market_id); - valuation.total_nav = valuation.total_nav + nav; + valuation.total_nav = valuation.total_nav.add(&nav); } /// Finish a full-pool valuation and run the LP flush: prove every snapshotted market @@ -290,20 +294,40 @@ public fun finish_flush( let PoolValuation { total_nav, valued_expiry_markets, .. } = valuation; let idle_balance_before = vault.expiry_accounting.idle_balance(); - let pool_nav = lp_pool_value( + let pool_nav = lp_pool_value_approx( vault, config.protocol_reserve_profit_share(), - total_nav, + &total_nav, + ); + // NAV precision policy; a zero pool NAV is a valid liveness mark (RP-1/RP-3: + // both sides become non-executable at the drain, and a purely relative bound + // has no denominator), so it skips the check rather than stalling the flush. + assert!( + pool_nav.magnitude() == 0 + || pool_nav.true_relative_deviation_within(max_nav_deviation!()), + ENavTooImprecise, ); + let active_market_nav = total_nav.magnitude(); + let active_market_nav_error = total_nav.error(); + let pool_nav_center = pool_nav.magnitude(); + let pool_nav_error = pool_nav.error(); + let (withdraw_pool_value, supply_pool_value) = if (pool_nav_center == 0) { + (0, 0) + } else { + (pool_nav_center - pool_nav_error, pool_nav_center + pool_nav_error) + }; let total_supply = vault.lp.total_supply(); let market_count = valued_expiry_markets.length(); - // Snapshot the share price once (frozen pair), drain both queues against it, then - // release the valuation lock at the very end. The flush IS the full-pool - // valuation, so the single FlushExecuted event carries the priced mark and its - // idle + active-NAV breakdown. + // Deconstruct Approx exactly once at the economic boundary. Suppliers pay the + // high pool value and withdrawals receive the low pool value, both over the + // same frozen pre-drain supply. let vault_id = vault.id(); - let mark = lp_book::new_flush_mark(pool_nav, total_supply); + let mark = lp_book::new_flush_mark( + withdraw_pool_value, + supply_pool_value, + total_supply, + ); let drain_summary = vault .lp .drain( @@ -319,9 +343,11 @@ public fun finish_flush( vault_events::emit_flush_executed( vault_id, ctx.epoch(), - pool_nav, + withdraw_pool_value, + supply_pool_value, total_supply, - total_nav, + active_market_nav, + active_market_nav_error, market_count, idle_balance_before, drain_summary.supplies_filled(), @@ -330,7 +356,7 @@ public fun finish_flush( vault.expiry_accounting.idle_balance(), total_supply_after, ); - pool_nav + pool_nav_center } /// Stake DEEP for trading benefits. The DEEP is held in the pool vault; the new @@ -719,28 +745,29 @@ fun claim_trading_loss_rebate_internal( /// deployed elsewhere) has left that debit-basis exclusion, so the carried /// `pending_protocol_profit` is subtracted separately to keep it out of LP value /// until it is drained into the reserve. -fun lp_pool_value( +fun lp_pool_value_approx( vault: &PoolVault, protocol_reserve_profit_share: u64, - active_expiry_value: u64, -): u64 { - let idle_balance = vault.expiry_accounting.idle_balance(); - let profit_basis_credits = vault.expiry_accounting.profit_basis_credits(); - let profit_basis_debits = vault.expiry_accounting.profit_basis_debits(); - let pending_protocol_profit = vault.expiry_accounting.pending_protocol_profit(); - let gross_pool_value = idle_balance + active_expiry_value; - let aggregate_credits = profit_basis_credits + active_expiry_value; - let exclusion = math::mul( - aggregate_credits.saturating_sub(profit_basis_debits), - protocol_reserve_profit_share, - ); + active_expiry_value: &Approx, +): Approx { + let idle_balance = approx::exact_u64(vault.expiry_accounting.idle_balance()); + let profit_basis_credits = approx::exact_u64(vault.expiry_accounting.profit_basis_credits()); + let profit_basis_debits = approx::exact_u64(vault.expiry_accounting.profit_basis_debits()); + let pending_protocol_profit = approx::exact_u64(vault + .expiry_accounting + .pending_protocol_profit()); + let gross_pool_value = idle_balance.add(active_expiry_value); + let aggregate_credits = profit_basis_credits.add(active_expiry_value); + let excess_credits = aggregate_credits.sub(&profit_basis_debits).clamp_nonnegative(); + let protocol_share = approx::exact_u64(protocol_reserve_profit_share); + let exclusion = excess_credits.mul_scaled(&protocol_share); // The realized `credits - debits` term is sticky: it does not shrink when LPs // withdraw idle cash, so when an active mark they withdrew against later // collapses, the held-out total (`exclusion + pending_protocol_profit`) can // exceed gross. LP value can never be negative, so floor it at 0 to keep the // subtraction from underflow-aborting. A 0/dust pool NAV makes non-executable // LP queue heads refund inside `lp_book::drain`, rather than aborting the flush. - gross_pool_value.saturating_sub(exclusion + pending_protocol_profit) + gross_pool_value.sub(&exclusion).sub(&pending_protocol_profit).clamp_nonnegative() } /// Sweep a settled market, rebalance a live market, or leave an expired unsettled @@ -954,7 +981,7 @@ fun start_pool_valuation_internal(config: &mut ProtocolConfig, vault: &PoolVault pool_vault_id: vault.id(), expected_expiry_markets: vault.expiry_accounting.active_expiry_markets(), valued_expiry_markets: vector[], - total_nav: 0, + total_nav: approx::exact_u64(0), } } diff --git a/packages/predict/sources/pricing/pricing.move b/packages/predict/sources/pricing/pricing.move index 1542d8a0f..6c40aa022 100644 --- a/packages/predict/sources/pricing/pricing.move +++ b/packages/predict/sources/pricing/pricing.move @@ -11,7 +11,7 @@ module deepbook_predict::pricing; use deepbook_predict::{constants, pricing_config::PricingConfig, range_codec::{Self, Strike}}; -use fixed_math::{i64::{Self, I64}, math}; +use fixed_math::{approx::{Self, Approx}, i64, math}; use propbook::{ block_scholes_forward_feed::BlockScholesForwardFeed, block_scholes_spot_feed::BlockScholesSpotFeed, @@ -58,8 +58,8 @@ public struct ExactSpotRead has drop { public struct PriceMemo has drop { /// Finite boundary ticks in ascending order (the in-order walk appends them). ticks: vector, - /// `up_price(ticks[i] * tick_size)`, parallel to `ticks`. - prices: vector, + /// `up_price(ticks[i] * tick_size)`, with its certified error, parallel to `ticks`. + prices: vector, } const EZeroForward: u64 = 0; @@ -101,17 +101,24 @@ macro fun max_svi_input(): u64 { 100 * math::float_scaling!() } /// Return the current UP digital probability for a typed strike. Public PTB and /// devInspect reads can compose it with a transaction-local `Pricer`. public fun up_price(pricer: &Pricer, strike: Strike): u64 { - compute_up_price(&pricer.svi, pricer.forward, strike) + compute_up_price(&pricer.svi, pricer.forward, strike).magnitude() } /// Return the current probability for `(lower, higher]`, floored at zero if the /// two approximated boundary probabilities invert. public fun range_price(pricer: &Pricer, lower: Strike, higher: Strike): u64 { - compute_range_price(&pricer.svi, pricer.forward, lower, higher) + compute_range_price(&pricer.svi, pricer.forward, lower, higher).magnitude() } // === Public-Package Functions === +/// Return the probability for `(lower, higher]` with its certified error retained +/// for package-internal protocol decisions. The public `range_price` is the +/// value-only view for external reads. +public(package) fun range_price_approx(pricer: &Pricer, lower: Strike, higher: Strike): Approx { + compute_range_price(&pricer.svi, pricer.forward, lower, higher) +} + /// Return the expiry market this pricer was loaded for. public(package) fun expiry_market_id(pricer: &Pricer): ID { pricer.expiry_market_id @@ -200,32 +207,46 @@ public(package) fun load_exact_spot_read( /// Create an empty per-flush price cache (see `PriceMemo`). public(package) fun new_price_memo(): PriceMemo { - PriceMemo { ticks: vector[], prices: vector[] } + PriceMemo { + ticks: vector[], + prices: vector[], + } } /// Read the cached range price `up_price(lower) - up_price(higher)` for one order's /// tick range, mirroring `range_price`'s infinity sentinels and saturating floor. /// Both finite boundaries must have been cached by the linear walk; a finite miss /// aborts (the order's tick is not a payout-tree node — a broken index, not dust). -public(package) fun cached_range_price(memo: &PriceMemo, lower_tick: u64, higher_tick: u64): u64 { - memo.cached_up_price(lower_tick).saturating_sub(memo.cached_up_price(higher_tick)) +public(package) fun cached_range_price( + memo: &PriceMemo, + lower_tick: u64, + higher_tick: u64, +): Approx { + let lower = memo.cached_up_price(lower_tick); + let higher = memo.cached_up_price(higher_tick); + lower.sub(&higher).clamp_nonnegative() } -/// Price `tick` through `pricer` and append it to the cache. Called once per node by -/// the in-order linear walk, so `ticks` stays ascending for `cached_up_price`'s -/// binary search. Only finite ticks are stored (the tree never holds inf boundaries). +/// Price `tick` through `pricer` and append its approximate value to the cache. +/// Called once per node by the in-order linear walk, so `ticks` stays ascending for +/// `cached_up_price`'s binary search. Only finite ticks are stored (the tree never +/// holds inf boundaries). public(package) fun price_and_cache( memo: &mut PriceMemo, pricer: &Pricer, tick: u64, tick_size: u64, -): u64 { - let price = pricer.up_price(range_codec::strike_from_tick(tick, tick_size)); +): Approx { + let price = compute_up_price( + &pricer.svi, + pricer.forward, + range_codec::strike_from_tick(tick, tick_size), + ); if (!memo.prices.is_empty()) { let previous = memo.prices[memo.prices.length() - 1]; // Higher strikes should not have higher UP prices. NAV's linear tree walk // relies on that order; an inverted surface can overstate pool value. - assert!(price <= previous, ENonMonotonePriceMemo); + assert!(price.magnitude() <= previous.magnitude(), ENonMonotonePriceMemo); }; memo.ticks.push_back(tick); memo.prices.push_back(price); @@ -237,9 +258,9 @@ public(package) fun price_and_cache( /// Look up a boundary tick's cached UP price. Infinity boundaries are never tree /// nodes, so they short-circuit to `compute_up_price`'s sentinels (`P(-inf) = 1`, /// `P(+inf) = 0`); every finite tick must be present or the exposure index is broken. -fun cached_up_price(memo: &PriceMemo, tick: u64): u64 { - if (tick == 0) return math::float_scaling!(); // tick 0 is the neg-inf sentinel - if (tick == constants::pos_inf_tick!()) return 0; +fun cached_up_price(memo: &PriceMemo, tick: u64): Approx { + if (tick == 0) return approx::exact_u64(math::float_scaling!()); // neg-inf sentinel + if (tick == constants::pos_inf_tick!()) return approx::exact_u64(0); let ticks = &memo.ticks; let mut lo = 0; @@ -411,7 +432,10 @@ fun assert_min_total_variance_positive(svi: &SVIParams) { let min_variance_increment = min_svi_variance_increment(svi); let a = svi.a(); let min_total_var = i64::from_u64(min_variance_increment).add(&a); - assert!(is_positive(&min_total_var), EBlockScholesMinVarianceInvalid); + assert!( + !min_total_var.is_negative() && !min_total_var.is_zero(), + EBlockScholesMinVarianceInvalid, + ); } // SVI total variance is `a + b * (rho*x + sqrt(x^2 + sigma^2))`, where @@ -427,23 +451,23 @@ fun min_svi_variance_increment(svi: &SVIParams): u64 { } /// Compute the approximated probability for `(lower, higher]`. -fun compute_range_price(svi: &SVIParams, forward: u64, lower: Strike, higher: Strike): u64 { +fun compute_range_price(svi: &SVIParams, forward: u64, lower: Strike, higher: Strike): Approx { assert!(lower.value() < higher.value(), EInvalidRange); let lower_up_price = compute_up_price(svi, forward, lower); let higher_up_price = compute_up_price(svi, forward, higher); // Fixed-point approximation or a non-monotone SVI surface can invert the // boundary prices; the range probability is floored at zero. - lower_up_price.saturating_sub(higher_up_price) + lower_up_price.sub(&higher_up_price).clamp_nonnegative() } /// Compute the adjusted UP digital probability for `strike`. -fun compute_up_price(svi: &SVIParams, forward: u64, strike: Strike): u64 { +fun compute_up_price(svi: &SVIParams, forward: u64, strike: Strike): Approx { if (strike.is_neg_inf()) { - return math::float_scaling!() + return approx::exact_u64(math::float_scaling!()) }; if (strike.is_pos_inf()) { - return 0 + return approx::exact_u64(0) }; compute_nd2(svi, forward, strike.value()) @@ -454,67 +478,106 @@ fun compute_up_price(svi: &SVIParams, forward: u64, strike: Strike): u64 { /// - w(k) = a + b * (rho * (k - m) + sqrt((k - m)^2 + sigma^2)) /// - d2 = -((k + w(k) / 2) / sqrt(w(k))) /// - price = N(d2) - phi(d2) * w'(k) / (2 * sqrt(w(k))) -fun compute_nd2(svi_params: &SVIParams, forward: u64, strike: u64): u64 { +fun compute_nd2(svi_params: &SVIParams, forward: u64, strike: u64): Approx { assert!(forward > 0, EZeroForward); - // Saturate ratios outside the fixed-point domain to their digital-probability - // limits instead of aborting live valuation and position flows. - let strike_ratio_opt = math::try_mul_div_down(strike, math::float_scaling!(), forward); - // Deep-OTM up tail (strike >> forward): P ≈ 0, the pos_inf limit. - if (strike_ratio_opt.is_none()) return 0; - let strike_ratio = strike_ratio_opt.destroy_some(); - // Deep-ITM up tail (strike << forward): P(settle > strike) ≈ 1, the neg_inf limit. - if (strike_ratio == 0) return math::float_scaling!(); - let k = math::ln(strike_ratio); - let m = svi_params.m(); + let k = approx::ln_ratio(strike, forward); + let (k_minus_m, root) = moneyness_terms(svi_params, &k); + let (total_var, sqrt_var) = total_variance_terms(svi_params, &k_minus_m, &root); + let d2 = standardized_d2(&k, &total_var, &sqrt_var); + let w_prime = variance_slope(svi_params, &k_minus_m, &root); + digital_price(&d2, &w_prime, &sqrt_var) +} + +fun moneyness_terms(svi_params: &SVIParams, k: &Approx): (Approx, Approx) { + let m = approx::exact(svi_params.m()); let k_minus_m = k.sub(&m); let k_minus_m_squared = k_minus_m.square_scaled(); - let sigma = svi_params.sigma(); - let sigma_squared = math::mul(sigma, sigma); - let sqrt_input = k_minus_m_squared + sigma_squared; - let sq = math::sqrt(sqrt_input, math::float_scaling!()); - let sq_i64 = i64::from_u64(sq); - - let rho = svi_params.rho(); - let rho_km = rho.mul_scaled(&k_minus_m); - let inner = rho_km.add(&sq_i64); + let sigma = approx::exact_u64(svi_params.sigma()); + let sigma_squared = sigma.square_scaled(); + let sqrt_input = k_minus_m_squared.add(&sigma_squared); + let root = approx::sqrt(&sqrt_input); + (k_minus_m, root) +} + +fun total_variance_terms( + svi_params: &SVIParams, + k_minus_m: &Approx, + root: &Approx, +): (Approx, Approx) { + let rho = approx::exact(svi_params.rho()); + let inner = rho.mul_scaled(k_minus_m).add(root); // This term is non-negative for |rho| <= 1; abort if fixed-point evaluation // violates that invariant at the envelope boundary. assert!(!inner.is_negative(), ECannotBeNegative); - let b = svi_params.b(); - let variance_increment = math::mul(b, inner.magnitude()); + // Keep `b * inner + a` at 1e18 until sqrt. Dividing the same wide result by + // 1e9 exactly recovers the legacy 1e9 total-variance center used in `w / 2`, + // while sqrt no longer loses the fractional variance before amplification by + // `1 / sqrt(w)`. + let scale = math::float_scaling!() as u128; + let wide_increment = (svi_params.b() as u128) * (inner.magnitude() as u128); let a = svi_params.a(); - let total_var = i64::from_u64(variance_increment).add(&a); - // Total variance must be positive because pricing takes sqrt(w) below. - assert!(is_positive(&total_var), ENonPositiveVariance); - let total_var = total_var.magnitude(); - - let sqrt_var = math::sqrt(total_var, math::float_scaling!()); - let sqrt_var_i64 = i64::from_u64(sqrt_var); - let half_var_i64 = i64::from_u64(total_var / 2); - let d2_numerator = k.add(&half_var_i64); - let d2 = d2_numerator.div_scaled(&sqrt_var_i64); - let d2 = d2.neg(); - - let slope_ratio = k_minus_m.div_scaled(&sq_i64); + let wide_a = (a.magnitude() as u128) * scale; + let wide_total_var = if (a.is_negative()) { + assert!(wide_increment >= wide_a, ENonPositiveVariance); + wide_increment - wide_a + } else { + wide_increment + wide_a + }; + let total_var_center = (wide_total_var / scale) as u64; + assert!(total_var_center > 0, ENonPositiveVariance); + + let wide_error = (svi_params.b() as u128) * (inner.error() as u128); + let scaled_error = wide_error.div_ceil(scale); + let max_error = std::u64::max_value!() as u128; + let total_var_error = if (scaled_error >= max_error) { + std::u64::max_value!() + } else { + (scaled_error as u64) + 1 + }; + let total_var = approx::from_parts(i64::from_u64(total_var_center), total_var_error); + + let sqrt_center = math::sqrt_u128(wide_total_var); + let sqrt_low = if (wide_total_var > wide_error) { + math::sqrt_u128(wide_total_var - wide_error) + } else { + 0 + }; + let sqrt_high = math::sqrt_u128_up(wide_total_var + wide_error); + let sqrt_error = (sqrt_center - sqrt_low).max(sqrt_high - sqrt_center); + let sqrt_var = approx::from_parts(i64::from_u64(sqrt_center as u64), sqrt_error as u64); + (total_var, sqrt_var) +} + +fun standardized_d2(k: &Approx, total_var: &Approx, sqrt_var: &Approx): Approx { + let half_var = total_var.half(); + let d2_numerator = k.add(&half_var); + d2_numerator.div_scaled(sqrt_var).neg() +} + +fun variance_slope(svi_params: &SVIParams, k_minus_m: &Approx, root: &Approx): Approx { + let rho = approx::exact(svi_params.rho()); + let slope_ratio = k_minus_m.div_scaled(root); let slope = rho.add(&slope_ratio); - let w_prime = i64::from_u64(b).mul_scaled(&slope); - let nd2 = math::normal_cdf(&d2); - if (w_prime.is_zero()) return nd2; - - let correction_magnitude = math::mul_div_down( - math::normal_pdf(&d2), - w_prime.magnitude(), - 2 * sqrt_var, - ); - let correction = i64::from_parts(correction_magnitude, w_prime.is_negative()); - let adjusted = i64::from_u64(nd2).sub(&correction); - if (adjusted.is_negative()) return 0; - if (adjusted.magnitude() > math::float_scaling!()) return math::float_scaling!(); - adjusted.magnitude() + let b = approx::exact_u64(svi_params.b()); + b.mul_scaled(&slope) } -fun is_positive(value: &I64): bool { - !value.is_negative() && !value.is_zero() +fun digital_price(d2: &Approx, w_prime: &Approx, sqrt_var: &Approx): Approx { + let nd2 = approx::normal_cdf(d2); + // A certified-exact zero slope is the flat-variance digital: no smile + // correction, N(d2) exactly. A rounded zero with nonzero radius still carries + // a possible correction and must flow through the approximate quotient. + if (w_prime.magnitude() == 0 && w_prime.error() == 0) { + return nd2.clamp_unit_interval() + }; + + // Smile correction phi(d2) * w'(k) / (2 sqrt(w)), carried signed so `sub` clamps + // in the correct direction; N(d2) - correction is then floored/capped to [0, 1]. + let pdf = approx::normal_pdf(d2); + let two_sqrt_var = sqrt_var.double(); + let correction = pdf.mul_div_down(w_prime, &two_sqrt_var); + let adjusted = nd2.sub(&correction); + adjusted.clamp_unit_interval() } diff --git a/packages/predict/sources/strike_exposure/index/liquidation_book.move b/packages/predict/sources/strike_exposure/index/liquidation_book.move index c4121643d..4d3eec8c5 100644 --- a/packages/predict/sources/strike_exposure/index/liquidation_book.move +++ b/packages/predict/sources/strike_exposure/index/liquidation_book.move @@ -10,7 +10,7 @@ module deepbook_predict::liquidation_book; use deepbook_predict::{constants, order::{Self, Order}, pricing::PriceMemo}; -use fixed_math::math; +use fixed_math::{approx::{Self, Approx}, math}; use sui::table::{Self, Table}; const EActiveOrderAlreadyExists: u64 = 0; @@ -72,25 +72,27 @@ public(package) fun correction_value( book: &LiquidationBook, memo: &PriceMemo, liquidation_ltv: u64, -): u64 { - let mut correction = 0; +): Approx { + let mut correction = approx::exact_u64(0); let mut cursor = book.first_cursor(); while (cursor.is_some()) { let scan = cursor.destroy_some(); let order = order::from_order_id(book.order_id_at(scan)); - let range_value = math::mul( - memo.cached_range_price(order.lower_tick(), order.higher_tick()), - order.quantity(), - ); + let range_price = memo.cached_range_price(order.lower_tick(), order.higher_tick()); + let quantity = approx::exact_u64(order.quantity()); + let range_value = range_price.mul_scaled(&quantity); // Knocked out (gross <= floor / ltv): the sweep will liquidate it and it // owes nothing above its separately reserved floor, so mark its live // liability at zero — credit the full range value, not the floor cap. - let cap = if (range_value <= math::div(order.floor_shares(), liquidation_ltv)) { + let cap = if ( + range_value.magnitude() + <= math::div(order.floor_shares(), liquidation_ltv) + ) { range_value } else { - range_value.min(order.floor_shares()) + range_value.clamp_upper(order.floor_shares()) }; - correction = correction + cap; + correction = correction.add(&cap); cursor = book.next_cursor(scan); }; correction diff --git a/packages/predict/sources/strike_exposure/index/strike_payout_tree.move b/packages/predict/sources/strike_exposure/index/strike_payout_tree.move index b38ebc7f2..addb233f9 100644 --- a/packages/predict/sources/strike_exposure/index/strike_payout_tree.move +++ b/packages/predict/sources/strike_exposure/index/strike_payout_tree.move @@ -16,7 +16,7 @@ module deepbook_predict::strike_payout_tree; use deepbook_predict::{constants, pricing::{Pricer, PriceMemo}, range_codec}; -use fixed_math::math; +use fixed_math::approx::{Self, Approx}; use sui::{bcs, hash::blake2b256, table::{Self, Table}}; const EInsufficientPayoutTerms: u64 = 0; @@ -93,19 +93,17 @@ public(package) fun settled_payout_liability( /// once. The in-order walk records boundary prices in `memo` for the leveraged /// correction scan. /// -/// The start and end sides accumulate as two non-negative totals: a node's net -/// `local_start - local_end` quantity is signed, so a single running `u64` would -/// underflow mid-walk. They combine once at the top: -/// `base.quantity + start_total - end_total`. `tree.base` is the `P(-inf) = 1` -/// anchor for `(-inf, h]` ranges (its quantity enters at face value); `+inf` ends -/// are never stored (`P = 0`). +/// Boundary products are rounded separately, then accumulated in one signed +/// approximate total. `tree.base` is the `P(-inf) = 1` anchor for `(-inf, h]` +/// ranges (its quantity enters at face value); `+inf` ends are never stored +/// (`P = 0`). public(package) fun walk_linear( tree: &StrikePayoutTree, pricer: &Pricer, memo: &mut PriceMemo, tick_size: u64, -): u64 { - let (start_total, end_total) = walk_linear_subtree( +): Approx { + let running = walk_linear_subtree( &tree.nodes, tree.root, pricer, @@ -114,7 +112,8 @@ public(package) fun walk_linear( ); // Boundary products are rounded per node and the signed aggregate is floored // once. This can differ from pricing and flooring each order independently. - (tree.base.quantity + start_total).saturating_sub(end_total) + let base = approx::exact_u64(tree.base.quantity); + base.add(&running).clamp_nonnegative() } /// Create an empty sparse payout tree. @@ -399,32 +398,60 @@ fun settlement_prefix_terms( settlement_prefix_terms(nodes, node.right, limit_tick, running) } -/// Accumulate start and end boundary products separately during an in-order walk. -/// Every node is cached even when its equal local start and end quantities cancel, -/// because leveraged-order correction lookups require every finite boundary. +/// Accumulate signed, separately rounded boundary products during an in-order +/// walk. Every node is cached even when its equal local start and end quantities +/// cancel, because leveraged-order correction lookups require every finite boundary. fun walk_linear_subtree( nodes: &Table, root: Option, pricer: &Pricer, tick_size: u64, memo: &mut PriceMemo, -): (u64, u64) { - if (root.is_none()) return (0, 0); +): Approx { + if (root.is_none()) return approx::exact_u64(0); let tick = *root.borrow(); let node = nodes[tick]; - let (left_start, left_end) = walk_linear_subtree(nodes, node.left, pricer, tick_size, memo); + let left = walk_linear_subtree( + nodes, + node.left, + pricer, + tick_size, + memo, + ); let price = memo.price_and_cache(pricer, tick, tick_size); - let mut start_total = 0; - let mut end_total = 0; - if (node.local_start.quantity != node.local_end.quantity) { - start_total = math::mul(price, node.local_start.quantity); - end_total = math::mul(price, node.local_end.quantity); - }; + let local = boundary_linear_value( + &price, + node.local_start.quantity, + node.local_end.quantity, + ); - let (right_start, right_end) = walk_linear_subtree(nodes, node.right, pricer, tick_size, memo); - (start_total + left_start + right_start, end_total + left_end + right_end) + let right = walk_linear_subtree( + nodes, + node.right, + pricer, + tick_size, + memo, + ); + left.add(&local).add(&right) +} + +/// Price one signed boundary delta while retaining the scalar path's two separate +/// product floors. The same uncertain price multiplies both sides, so its error is +/// correlated and scales with `|start - end|`, not `start + end`; two raw units +/// conservatively cover the independently rounded scalar products. +fun boundary_linear_value(price: &Approx, start_quantity: u64, end_quantity: u64): Approx { + if (start_quantity == end_quantity) return approx::exact_u64(0); + + let start = price.mul_scaled(&approx::exact_u64(start_quantity)); + let end = price.mul_scaled(&approx::exact_u64(end_quantity)); + let net_quantity = start_quantity.diff(end_quantity); + let correlated_error = price + .mul_scaled(&approx::exact_u64(net_quantity)) + .error() + .saturating_add(1); + approx::from_parts(start.value().sub(&end.value()), correlated_error) } fun resummarize(nodes: &mut Table, tick: u64, mut node: PayoutNode) { diff --git a/packages/predict/sources/strike_exposure/strike_exposure.move b/packages/predict/sources/strike_exposure/strike_exposure.move index 01ad7f936..78ad16e39 100644 --- a/packages/predict/sources/strike_exposure/strike_exposure.move +++ b/packages/predict/sources/strike_exposure/strike_exposure.move @@ -23,7 +23,7 @@ use deepbook_predict::{ strike_exposure_config::StrikeExposureConfig, strike_payout_tree::{Self, StrikePayoutTree} }; -use fixed_math::math; +use fixed_math::{approx::Approx, math}; use sui::clock::Clock; const EInvalidCloseQuantity: u64 = 0; @@ -34,6 +34,7 @@ const ETermsExposureMismatch: u64 = 4; const EMintQuantityBelowMin: u64 = 5; const EWrongCloseOutcome: u64 = 6; const EPricerRequired: u64 = 7; +const EPriceTooImprecise: u64 = 8; /// Exposure lifecycle state for one expiry market. public struct StrikeExposure has store { @@ -213,7 +214,7 @@ public(package) fun payout_liability(exposure: &StrikeExposure): u64 { /// ambient sweep liquidates it; every other order contributes its positive /// `range_value - floor_shares`. Boundary aggregation and per-order correction /// round at different points, so the subtraction saturates at zero. -public(package) fun exact_live_liability(exposure: &StrikeExposure, pricer: &Pricer): u64 { +public(package) fun exact_live_liability(exposure: &StrikeExposure, pricer: &Pricer): Approx { let mut memo = pricing::new_price_memo(); let linear = exposure.payout.walk_linear(pricer, &mut memo, exposure.tick_size); let correction = exposure @@ -222,7 +223,7 @@ public(package) fun exact_live_liability(exposure: &StrikeExposure, pricer: &Pri &memo, exposure.config.liquidation_ltv(), ); - linear.saturating_sub(correction) + linear.sub(&correction).clamp_nonnegative() } /// Return the liquidation LTV snapshotted for this exposure book. @@ -550,9 +551,15 @@ public(package) fun new( } } +/// The protocol invariant on produced contract prices: a quoted probability may +/// deviate from its true value by at most 0.1% (1e6 at 1e9 scale). Enforced at the +/// mint quote below by aborting when the pricer's certified error exceeds this +/// fraction of the price — imprecision becomes unavailability, never a distorted quote. +macro fun max_contract_price_deviation(): u64 { 1_000_000 } + /// Price the mint tick range `(lower_tick, higher_tick]` after admission-grid -/// validation. The single pricing-prefix orchestration shared by every mint -/// quote/terms path. +/// validation, and enforce the contract-price precision invariant. The single +/// pricing-prefix orchestration shared by every mint quote/terms path. fun admitted_entry_probability( exposure: &StrikeExposure, pricer: &Pricer, @@ -562,7 +569,14 @@ fun admitted_entry_probability( exposure.assert_admitted_mint_ticks(lower_tick, higher_tick); let lower = range_codec::strike_from_tick(lower_tick, exposure.tick_size); let higher = range_codec::strike_from_tick(higher_tick, exposure.tick_size); - pricer.range_price(lower, higher) + let price = pricer.range_price_approx(lower, higher); + // Contract prices cannot deviate from true by more than 0.1%; an over-wide + // certified error aborts the quote rather than admitting an imprecise price. + assert!( + price.true_relative_deviation_within(max_contract_price_deviation!()), + EPriceTooImprecise, + ); + price.magnitude() } fun assert_admitted_mint_ticks(exposure: &StrikeExposure, lower_tick: u64, higher_tick: u64) { diff --git a/packages/predict/tests/config/strike_exposure_config_tests.move b/packages/predict/tests/config/strike_exposure_config_tests.move index 7daf55521..dec4e3887 100644 --- a/packages/predict/tests/config/strike_exposure_config_tests.move +++ b/packages/predict/tests/config/strike_exposure_config_tests.move @@ -177,6 +177,26 @@ fun trading_fee_at_probability_one_floors_at_min_fee() { destroy(config); } +#[test] +fun trading_fee_rounds_final_non_integral_charge_up() { + let mut config = strike_exposure_config::new(); + // At p = 0.5, base_fee = 1 makes the raw Bernoulli rate round to zero, + // so this non-integral min fee binds. Its exact quantity charge is + // 5_000_009 * 1_000_010_000 / 1e9 = 5_000_059.00009. + config.set_base_fee(1); + config.set_min_fee(5_000_009); + assert_eq!( + config.trading_fee( + test_constants::default_expiry_ms(), + ENTRY_PROBABILITY_HALF, + 1_000_010_000, + test_constants::now_ms(), + ), + 5_000_060, + ); + destroy(config); +} + // === EEntryProbabilityOutOfBounds (mint admission) === #[test, expected_failure(abort_code = strike_exposure_config::EEntryProbabilityOutOfBounds)] diff --git a/packages/predict/tests/ewma_tests.move b/packages/predict/tests/ewma_tests.move index fcae2cfc2..42eaa6e2c 100644 --- a/packages/predict/tests/ewma_tests.move +++ b/packages/predict/tests/ewma_tests.move @@ -17,6 +17,9 @@ use sui::{clock::{Self, Clock}, test_scenario::{begin, end, Scenario}}; const QUANTITY: u64 = 1_000_000_000; // 1.0 contract unit in 1e9 scaling // default penalty_rate is 0.1%; 0.1% of 1.0 = 0.001 = 1_000_000 base units. const EXPECTED_PENALTY: u64 = 1_000_000; +const NON_INTEGRAL_QUANTITY: u64 = 3_010_000; +const NON_INTEGRAL_PENALTY_RATE: u64 = 1_000_001; +const ROUNDED_NON_INTEGRAL_PENALTY: u64 = 3_011; fun advance_with_gas(test: &mut Scenario, gas_price: u64, timestamp_advance: u64) { let ts = test.ctx().epoch_timestamp_ms() + timestamp_advance; @@ -114,6 +117,30 @@ fun penalty_fires_once_z_score_crosses_threshold() { end(test); } +#[test] +fun fired_penalty_rounds_final_non_integral_charge_up() { + let mut test = begin(@0xF); + let mut config = config_with(config_constants::min_ewma_z_score_threshold!(), true); + config.set_params( + config_constants::default_ewma_alpha!(), + config_constants::min_ewma_z_score_threshold!(), + NON_INTEGRAL_PENALTY_RATE, + ); + let mut clock = clock::create_for_testing(test.ctx()); + let state = seeded_state(&mut test, &mut clock, &config); + + // 1_000_001 * 3_010_000 / 1e9 = 3_010.00301. + assert_eq!( + state.penalty_fee(&config, NON_INTEGRAL_QUANTITY, test.ctx()), + ROUNDED_NON_INTEGRAL_PENALTY, + ); + + destroy(state); + destroy(config); + destroy(clock); + end(test); +} + #[test] fun extreme_first_observation_suppresses_penalty_for_later_trades() { let mut test = begin(@0xF); diff --git a/packages/predict/tests/flows/current_nav_flow_tests.move b/packages/predict/tests/flows/current_nav_flow_tests.move index 3642364ab..e28812e86 100644 --- a/packages/predict/tests/flows/current_nav_flow_tests.move +++ b/packages/predict/tests/flows/current_nav_flow_tests.move @@ -31,7 +31,7 @@ use deepbook_predict::{ range_codec, test_constants }; -use fixed_math::math::{Self, float_scaling as float}; +use fixed_math::{approx::Approx, math::{Self, float_scaling as float}}; use std::unit_test::assert_eq; /// 1x ATM up range, quantity 2e9: priced 0.5 -> 1e9 liability. @@ -64,6 +64,10 @@ fun empty_live_market_values_at_free_cash() { let nav = fx.current_nav_bundle(&market); assert_eq!(nav, test_constants::default_seeded_expiry_cash()); check_nav(&fx, &market, vector[], float!()); + let pricer = fx.load_pricer_bundle(&market); + let approximate = helpers::market(&market).current_nav_approx(&pricer); + assert_eq!(approximate.magnitude(), nav); + assert_eq!(approximate.error(), 0); helpers::return_market_bundle(market); fx.finish(); @@ -86,6 +90,7 @@ fun single_one_x_up_order() { ); check_nav(&fx, &market, vector[id], float!()); + check_single_order_nav_enclosure(&fx, &market, id); helpers::return_account_bundle(account); @@ -111,6 +116,7 @@ fun single_one_x_down_order_anchored_at_neg_inf() { // The (-inf, strike] range exercises the `tree.base` (P(-inf) = 1) anchor. check_nav(&fx, &market, vector[id], float!()); + check_single_order_nav_enclosure(&fx, &market, id); helpers::return_account_bundle(account); @@ -171,6 +177,7 @@ fun single_leveraged_order_above_floor() { // value = mul(0.5, 2e9) = 1e9 > floor = mul(floor_shares 5e8, 1.0) = 5e8, so the // correction min() picks the floor and the order's net liability is 5e8. check_nav(&fx, &market, vector[id], float!()); + check_single_order_nav_enclosure(&fx, &market, id); helpers::return_account_bundle(account); @@ -203,6 +210,7 @@ fun single_leveraged_order_underwater_nets_to_zero() { let nav = fx.current_nav_bundle(&market); assert_eq!(nav, expiry_market.cash_balance().saturating_sub(expiry_market.rebate_reserve())); check_nav(&fx, &market, vector[id], float!()); + check_single_order_nav_enclosure(&fx, &market, id); helpers::return_account_bundle(account); @@ -264,6 +272,7 @@ fun knocked_out_leveraged_order_marks_at_liquidated_value() { // so NAV rises to the knock-out-aware reference — above the old floor-capped // mark. `check_nav` asserts `current_nav` equals that independent reference. check_nav(&fx, &market, vector[id], float!()); + check_single_order_nav_enclosure(&fx, &market, id); helpers::return_account_bundle(account); helpers::return_market_bundle(market); @@ -380,6 +389,53 @@ fun check_nav( helpers::assert_market_backed(expiry_market); } +/// Compose the certified range-price ball through one order's quantity, fixed +/// correction branch, liability clamp, and cash subtraction. Pricing correctness +/// is covered independently; this checks that the NAV layer encloses both outward +/// price endpoints without changing its scalar center. +fun check_single_order_nav_enclosure( + fx: &helpers::Fixture, + market: &helpers::MarketBundle, + order_id: u256, +) { + let pricer = fx.load_pricer_bundle(market); + let expiry_market = helpers::market(market); + let decoded = order::from_order_id(order_id); + let lower = range_codec::strike_from_tick(decoded.lower_tick(), expiry_market.tick_size()); + let higher = range_codec::strike_from_tick(decoded.higher_tick(), expiry_market.tick_size()); + let price = pricer.range_price_approx(lower, higher); + let price_low = price.magnitude().saturating_sub(price.error()); + let price_high = price.magnitude().saturating_add(price.error()).min(float!()); + + let canonical_gross = math::mul(price.magnitude(), decoded.quantity()); + let knocked_out = + decoded.floor_shares() > 0 + && canonical_gross <= math::div(decoded.floor_shares(), expiry_market.liquidation_ltv()); + let (liability_low, liability_high) = if (knocked_out) { + (0, 0) + } else { + let gross_low = math::mul_div_down(price_low, decoded.quantity(), float!()); + let gross_high = math::mul_div_up(price_high, decoded.quantity(), float!()); + ( + gross_low.saturating_sub(decoded.floor_shares()), + gross_high.saturating_sub(decoded.floor_shares()), + ) + }; + + let free_cash = expiry_market.cash_balance().saturating_sub(expiry_market.rebate_reserve()); + let nav_low = free_cash.saturating_sub(liability_high); + let nav_high = free_cash.saturating_sub(liability_low); + let approximate = expiry_market.current_nav_approx(&pricer); + assert_eq!(approximate.magnitude(), expiry_market.current_nav(&pricer)); + assert_contains(&approximate, nav_low); + assert_contains(&approximate, nav_high); +} + +fun assert_contains(ball: &Approx, candidate: u64) { + assert!(!ball.is_negative()); + assert!(ball.magnitude().diff(candidate) <= ball.error()); +} + /// Independent NAV oracle (unit-tests rule 1): `free_cash - Σ contribution` per /// open order, using only order atoms and `pricing::range_price`. A knocked-out /// leveraged order (live gross at or below `floor / liquidation_ltv`) contributes diff --git a/packages/predict/tests/flows/pool_valuation_flow_tests.move b/packages/predict/tests/flows/pool_valuation_flow_tests.move index aff202680..9ef65c8d0 100644 --- a/packages/predict/tests/flows/pool_valuation_flow_tests.move +++ b/packages/predict/tests/flows/pool_valuation_flow_tests.move @@ -22,12 +22,13 @@ use deepbook_predict::{ flow_test_helpers as helpers, plp::{Self, PoolVault}, protocol_config::{Self, ProtocolConfig}, - test_constants + test_constants, + vault_events::{Self, FlushExecuted} }; use fixed_math::math::float_scaling as float; use propbook::{pyth_feed::PythFeed, registry::OracleRegistry}; use std::unit_test::{assert_eq, destroy}; -use sui::test_scenario::return_shared; +use sui::{event, test_scenario::return_shared}; /// 1x ATM up range, quantity 2e9 (well under the 50e9 cash floor that backs it). const ONE_X_QUANTITY: u64 = 2_000_000_000; @@ -46,10 +47,10 @@ const TWO_MARKET_POOL_NAV: u64 = 1_200_006_000_000; /// Leave exactly 1e9 idle after funding a 250e9 expiry. With 251e9 PLP supply, /// that mark is a very low but executable fair PLP price. const BELOW_MIN_PRICE_IDLE: u64 = 1_000_000_000; -/// Large 1x order used to drive a fully-funded market underwater after a price jump. +/// Large 1x order used to drive a fully-funded market underwater after a price drop. const UNDERWATER_QUANTITY: u64 = 500_000_000_000; const UNDERWATER_TRADER_DEPOSIT: u64 = 400_000_000_000; -const DEEP_ITM_LIVE_PRICE: u64 = 1_000_000_000_000; +const DEEP_DOWN_LIVE_PRICE: u64 = 1; const REPRICE_MS: u64 = 121_000; const REPRICE_SOURCE_TS: u64 = 119_500; /// Empty-market cash above the 10e9 target. Valuation sweeps the 1e9 surplus to @@ -101,6 +102,13 @@ fun multi_market_pool_nav_is_idle_plus_sum_of_navs() { assert_eq!(vault.profit_basis_debits(), POST_VALUATION_PROFIT_DEBITS); assert_eq!(vault.pending_protocol_profit(), 0); assert_eq!(pool_nav, TWO_MARKET_POOL_NAV); + let flushes = event::events_by_type(); + assert_eq!(flushes.length(), 1); + let flush = &flushes[0]; + let withdraw_pool_value = vault_events::flush_withdraw_pool_value(flush); + let supply_pool_value = vault_events::flush_supply_pool_value(flush); + assert!(withdraw_pool_value < pool_nav); + assert_eq!(pool_nav - withdraw_pool_value, supply_pool_value - pool_nav); return_shared(config); return_shared(pyth); @@ -384,6 +392,45 @@ fun value_expiry_for_inactive_market_aborts() { abort 999 } +#[test, expected_failure(abort_code = plp::ENavTooImprecise)] +fun finish_flush_rejects_an_uncertifiable_active_nav() { + let mut fx = helpers::setup_market_default(); + let trader = fx.create_funded_manager(test_constants::default_manager_deposit()); + bootstrap_pool(&mut fx, IDLE_SEED); + let expiry_id = fx.create_expiry(test_constants::default_expiry_ms()); + fund_market_with_order(&mut fx, &trader, expiry_id); + + fx.scenario_mut().next_tx(test_constants::alice()); + let mut market = fx.take_market_bundle(expiry_id); + // Valid but cancellation-heavy SVI: the ATM center remains admissible while + // its numerical certificate saturates. The order was minted on the default + // surface above, so this isolates the valuation gate from mint admission. + fx.seed_bs_surface_with_svi_bundle( + &mut market, + test_constants::default_live_price(), + test_constants::default_live_price(), + 1, + false, + test_constants::pricing_max_svi_input(), + test_constants::pricing_min_svi_sigma(), + float!(), + true, + test_constants::pricing_max_svi_input(), + true, + test_constants::live_source_timestamp_ms() + 1, + ); + + let mut valuation = fx.start_flush_bundle(&mut market); + fx.value_expiry_bundle(&mut valuation, &mut market); + fx.finish_flush_bundle( + valuation, + &mut market, + option::none(), + option::none(), + ); + abort 999 +} + #[test] fun finish_flush_with_zero_pool_nav_and_empty_queues_succeeds() { let (mut fx, e) = setup_underwater_market(0); @@ -520,9 +567,10 @@ fun fund_market_with_order(fx: &mut helpers::Fixture, trader: &helpers::Trader, } /// Build a production-created market whose full pool allocation is deployed into -/// expiry cash, then mint an ATM UP order and reprice it deep in the money. The +/// expiry cash, then mint an ATM DOWN order and reprice it deep in the money. The /// repriced live liability exceeds free cash, so the market contributes zero NAV; -/// `idle_remainder` is the only pool NAV left for `finish_flush`. +/// the extreme tail price is exact, and `idle_remainder` is the only pool NAV left +/// for `finish_flush`. fun setup_underwater_market(idle_remainder: u64): (helpers::Fixture, ID) { let mut fx = helpers::setup_market_default(); let market_allocation = test_constants::default_max_expiry_allocation(); @@ -540,13 +588,13 @@ fun setup_underwater_market(idle_remainder: u64): (helpers::Fixture, ID) { fx.mint_bundle( &mut market, &mut account, + 0, helpers::strike_tick(), - constants::pos_inf_tick!(), UNDERWATER_QUANTITY, test_constants::leverage_one_x(), ); fx.set_clock_for_testing(REPRICE_MS); - fx.prepare_live_oracle_bundle_at(&mut market, DEEP_ITM_LIVE_PRICE, REPRICE_SOURCE_TS); + fx.prepare_live_oracle_bundle_at(&mut market, DEEP_DOWN_LIVE_PRICE, REPRICE_SOURCE_TS); helpers::return_account_bundle(account); helpers::return_market_bundle(market); diff --git a/packages/predict/tests/flows/precision_policy_flow_tests.move b/packages/predict/tests/flows/precision_policy_flow_tests.move new file mode 100644 index 000000000..3ecbaa9f7 --- /dev/null +++ b/packages/predict/tests/flows/precision_policy_flow_tests.move @@ -0,0 +1,110 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +/// Production-flow coverage for numerical precision admission policy. +#[test_only] +module deepbook_predict::precision_policy_flow_tests; + +use deepbook_predict::{ + config_constants, + constants, + flow_test_helpers as helpers, + pricing::Pricer, + range_codec, + strike_exposure, + test_constants +}; +use fixed_math::{approx::Approx, math}; + +const EUnexpectedSuccess: u64 = 999; +// The ratified contract-price deviation bound at 1e9 scale (0.1%), mirroring +// `strike_exposure::max_contract_price_deviation`. +const CONTRACT_MAX_DEVIATION: u64 = 1_000_000; + +#[test] +fun extreme_surface_is_admissible_but_not_numerically_certifiable() { + let (mut fx, expiry_id, _trader) = helpers::setup_everything(); + fx.scenario_mut().next_tx(test_constants::alice()); + let mut market = fx.take_market_bundle(expiry_id); + seed_uncertifiable_surface(&mut fx, &mut market); + let price = atm_up_price(&fx.load_pricer_bundle(&market)); + assert!(price.magnitude() >= config_constants::default_min_entry_probability!()); + assert!(price.magnitude() <= config_constants::default_max_entry_probability!()); + assert!(!price.true_relative_deviation_within(CONTRACT_MAX_DEVIATION)); + + helpers::return_market_bundle(market); + fx.finish(); +} + +#[test] +fun production_mint_accepts_a_certified_default_price() { + let (mut fx, expiry_id, trader) = helpers::setup_everything(); + fx.scenario_mut().next_tx(test_constants::alice()); + let mut market = fx.take_market_bundle(expiry_id); + let mut account = fx.take_account_bundle(&trader); + let price = atm_up_price(&fx.load_pricer_bundle(&market)); + assert!(price.true_relative_deviation_within(CONTRACT_MAX_DEVIATION)); + + let order_id = fx.mint_bundle( + &mut market, + &mut account, + helpers::strike_tick(), + constants::pos_inf_tick!(), + test_constants::mint_quantity(), + test_constants::leverage_one_x(), + ); + assert!(helpers::has_position_bundle(&account, expiry_id, order_id)); + + helpers::return_account_bundle(account); + helpers::return_market_bundle(market); + fx.finish(); +} + +#[test, expected_failure(abort_code = strike_exposure::EPriceTooImprecise)] +fun production_mint_rejects_an_uncertifiable_price() { + let (mut fx, expiry_id, trader) = helpers::setup_everything(); + fx.scenario_mut().next_tx(test_constants::alice()); + let mut market = fx.take_market_bundle(expiry_id); + let mut account = fx.take_account_bundle(&trader); + seed_uncertifiable_surface(&mut fx, &mut market); + + fx.mint_bundle( + &mut market, + &mut account, + helpers::strike_tick(), + constants::pos_inf_tick!(), + test_constants::mint_quantity(), + test_constants::leverage_one_x(), + ); + abort EUnexpectedSuccess +} + +fun seed_uncertifiable_surface(fx: &mut helpers::Fixture, market: &mut helpers::MarketBundle) { + fx.seed_bs_surface_with_svi_bundle( + market, + test_constants::default_live_price(), + test_constants::default_live_price(), + 1, + false, + test_constants::pricing_max_svi_input(), + test_constants::pricing_min_svi_sigma(), + math::float_scaling!(), + true, + test_constants::pricing_max_svi_input(), + true, + test_constants::live_source_timestamp_ms() + 1, + ); +} + +fun atm_up_price(pricer: &Pricer): Approx { + pricer.range_price_approx( + range_codec::strike_from_tick( + helpers::strike_tick(), + test_constants::default_tick_size(), + ), + range_codec::strike_from_tick( + constants::pos_inf_tick!(), + test_constants::default_tick_size(), + ), + ) +} diff --git a/packages/predict/tests/flows/quote_mint_tests.move b/packages/predict/tests/flows/quote_mint_tests.move index c56f5d25a..5b893881c 100644 --- a/packages/predict/tests/flows/quote_mint_tests.move +++ b/packages/predict/tests/flows/quote_mint_tests.move @@ -48,6 +48,12 @@ const ALL_IN_WITH_SUBSIDY: u64 = 504_000_000; const BUILDER_FEE_ATM: u64 = 500_000; const ALL_IN_WITH_BUILDER: u64 = 505_500_000; const BUILDER_CODE_INDEX: u64 = 0; +const ROUND_UP_MIN_FEE: u64 = 5_000_009; +const ROUND_UP_QUANTITY: u64 = 1_000_010_000; +const ROUND_UP_NET_PREMIUM: u64 = 500_005_000; +const ROUND_UP_TRADING_FEE: u64 = 5_000_060; +const ROUND_UP_BUILDER_FEE: u64 = 500_006; +const ROUND_UP_ALL_IN_WITH_BUILDER: u64 = 505_505_066; /// Full-benefit stake (>= upper_benefit_power) earns the max fee discount: /// benefit_ratio = 1.0, discount_fraction = max_fee_discount (0.5), so the fee @@ -267,6 +273,65 @@ fun builder_code_raises_account_quote_and_mint_debits_exactly() { fx.finish(); } +#[test] +fun rounded_trading_fee_can_advance_derived_builder_fee_by_one_atom() { + let mut fx = helpers::setup_market_default(); + fx.set_template_min_fee(ROUND_UP_MIN_FEE); + let expiry_id = fx.create_expiry(test_constants::default_expiry_ms()); + let trader = fx.create_funded_manager(test_constants::mint_deposit()); + let mut market = fx.take_market_bundle(expiry_id); + fx.prepare_live_oracle_bundle(&mut market, test_constants::default_live_price()); + fx.seed_market_cash( + helpers::market_mut(&mut market), + test_constants::default_seeded_expiry_cash(), + ); + helpers::return_market_bundle(market); + fx.create_and_link_builder_code(BUILDER_CODE_INDEX, &trader); + + fx.scenario_mut().next_tx(test_constants::alice()); + let mut market = fx.take_market_bundle(expiry_id); + let mut account = fx.take_account_bundle(&trader); + let quote = fx.quote_mint_for_account_bundle( + &market, + &account, + helpers::strike_tick(), + constants::pos_inf_tick!(), + ROUND_UP_QUANTITY, + test_constants::leverage_one_x(), + ); + + // The pre-change floors were 5_000_059 for trading and 500_005 for the + // derived builder fee. Rounding the first component upward lands exactly on + // the builder's next 10% integer threshold, so the all-in delta is two atoms. + assert_eq!(quote.entry_probability(), ENTRY_PROBABILITY_ATM); + assert_eq!(quote.net_premium(), ROUND_UP_NET_PREMIUM); + assert_eq!(quote.trading_fee(), ROUND_UP_TRADING_FEE); + assert_eq!(quote.fee_incentive_subsidy(), 0); + assert_eq!(quote.builder_fee(), ROUND_UP_BUILDER_FEE); + assert_eq!(quote.penalty_fee(), 0); + assert_eq!(quote.all_in_cost(), ROUND_UP_ALL_IN_WITH_BUILDER); + + let order = fx.mint_exact_quantity_bundle( + &mut market, + &mut account, + helpers::strike_tick(), + constants::pos_inf_tick!(), + ROUND_UP_QUANTITY, + test_constants::leverage_one_x(), + quote.all_in_cost(), + std::u64::max_value!(), + ); + assert!(helpers::has_position_bundle(&account, expiry_id, order)); + assert_eq!( + fx.account_balance_bundle(&account), + test_constants::mint_deposit() - ROUND_UP_ALL_IN_WITH_BUILDER, + ); + + helpers::return_account_bundle(account); + helpers::return_market_bundle(market); + fx.finish(); +} + #[test] fun stale_stake_quote_overstates_and_rolled_quote_matches_discounted_debit() { let (mut fx, expiry_id, trader) = helpers::setup_live_market( diff --git a/packages/predict/tests/helper/flow_test_helpers.move b/packages/predict/tests/helper/flow_test_helpers.move index 2fc6f9069..568d7375b 100644 --- a/packages/predict/tests/helper/flow_test_helpers.move +++ b/packages/predict/tests/helper/flow_test_helpers.move @@ -392,14 +392,18 @@ public fun set_expiry_mint_paused_bundle(self: &Fixture, market: &mut MarketBund self.set_expiry_mint_paused(&mut market.market, &market.config, paused); } -public fun set_template_zero_min_fee(self: &mut Fixture) { +public fun set_template_min_fee(self: &mut Fixture, value: u64) { self.scenario.next_tx(test_constants::admin()); let mut config = self.scenario.take_shared(); - config.set_template_min_fee(&self.admin_cap, 0); + config.set_template_min_fee(&self.admin_cap, value); return_shared(config); self.scenario.next_tx(test_constants::admin()); } +public fun set_template_zero_min_fee(self: &mut Fixture) { + self.set_template_min_fee(0) +} + public fun set_template_backing_buffer_lambda(self: &mut Fixture, value: u64) { self.scenario.next_tx(test_constants::admin()); let mut config = self.scenario.take_shared(); diff --git a/packages/predict/tests/helper/reference/generate_constants.py b/packages/predict/tests/helper/reference/generate_constants.py index 3abc4d21e..5e31ee8e2 100644 --- a/packages/predict/tests/helper/reference/generate_constants.py +++ b/packages/predict/tests/helper/reference/generate_constants.py @@ -1,10 +1,9 @@ #!/usr/bin/env python3 -"""Independent reference values for Predict's fixed-point math tests. +"""Independent reference values for Predict's fixed-point math and focused pricing tests. -Ground truth comes ONLY from Python's stdlib `math` (double precision, ~15-16 -significant digits — far more than the 1e9 / 9-digit fixed-point needs). NOTHING -here reads or depends on the Move contract, so the values are an independent -oracle, not a snapshot of contract output (unit-tests rule 1). +Ground truth comes ONLY from Python's stdlib `math` and `decimal` implementations. +NOTHING here reads or depends on the Move contract, so the values are an +independent oracle, not a snapshot of contract output (unit-tests rule 1). Each reference is `round(f_true(x) * 1e9)` — the correctly-rounded fixed-point representation of the true mathematical value. The Move tests assert the contract @@ -33,6 +32,12 @@ def exp_scaled(num: int, den: int = 1) -> int: return int(((Decimal(num) / Decimal(den)).exp() * Decimal(F)).to_integral_value(ROUND_HALF_EVEN)) +def ln_ratio_scaled(num: int, den: int) -> int: + """ln(num/den) * 1e9 via arbitrary-precision Decimal.""" + ratio = Decimal(num) / Decimal(den) + return int((ratio.ln() * Decimal(F)).to_integral_value(ROUND_HALF_EVEN)) + + def phi(x: float) -> float: # standard normal CDF via stdlib erf return 0.5 * (1.0 + math.erf(x / math.sqrt(2.0))) @@ -41,6 +46,41 @@ def pdf(x: float) -> float: # standard normal PDF return math.exp(-0.5 * x * x) / math.sqrt(2.0 * math.pi) +def adjusted_up(strike: int, forward: int, a: int, b: int, rho: int, m: int, sigma: int) -> float: + """Independent real-number SVI adjusted digital, clamped to [0, 1].""" + k = math.log(strike / forward) + x = k - m / F + sigma_real = sigma / F + root = math.sqrt(x * x + sigma_real * sigma_real) + total_variance = a / F + b / F * (rho / F * x + root) + variance_slope = b / F * (rho / F + x / root) + sqrt_variance = math.sqrt(total_variance) + d2 = -(k + total_variance / 2) / sqrt_variance + value = phi(d2) - pdf(d2) * variance_slope / (2 * sqrt_variance) + return max(0.0, min(1.0, value)) + + +FINITE_TAIL_FORWARD = 105_000_000_000_000_000 +FINITE_TAIL_LOWER_UP = adjusted_up( + 100_000_000, + FINITE_TAIL_FORWARD, + 1, + F, + -F, + 0, + 1_000_000, +) +FINITE_TAIL_UPPER_UP = adjusted_up( + 100_000_000_000, + FINITE_TAIL_FORWARD, + 1, + F, + -F, + 0, + 1_000_000, +) + + POINTS = [ ("LN_2", scaled(math.log(2))), ("LN_10", scaled(math.log(10))), @@ -88,6 +128,16 @@ def pdf(x: float) -> float: # standard normal PDF ("LN_1EM9_MAG", abs(scaled(math.log(1e-9)))), ("LN_U64MAX", scaled(math.log((2**64 - 1) / F))), ("LN_1_5", scaled(math.log(1.5))), # x in (F, 2F): non-degenerate Horner series + # ln_ratio: ordinary quotient, one-raw-unit quotient, quotient underflow, + # and quotient overflow over exact positive u64 inputs. + ("LN_RATIO_TWO", ln_ratio_scaled(2 * F, F)), + ("LN_RATIO_ONE_RAW_MAG", abs(ln_ratio_scaled(10_000_000, 10_000_000_000_000_000))), + ("LN_RATIO_UNDERFLOW_MAG", abs(ln_ratio_scaled(100_000_000, 105_000_000_000_000_000))), + ("LN_RATIO_U64_MAX", ln_ratio_scaled(2**64 - 1, 1)), + # Accepted SVI surface whose finite lower quotient floors to zero. + ("FINITE_TAIL_LOWER_UP_REFERENCE", scaled(FINITE_TAIL_LOWER_UP)), + ("FINITE_TAIL_UPPER_UP_REFERENCE", scaled(FINITE_TAIL_UPPER_UP)), + ("FINITE_TAIL_RANGE_REFERENCE", scaled(max(0.0, FINITE_TAIL_LOWER_UP - FINITE_TAIL_UPPER_UP))), # sqrt with non-default precision: sqrt(x, P) == isqrt(x * P) in raw units. ("SQRT_4F_PREC_ONE", math.isqrt(4 * F * 1)), ("SQRT_U64MAX_PREC_ONE", math.isqrt((2**64 - 1) * 1)), # high-bit Newton path; = 2^32-1 diff --git a/packages/predict/tests/helper/reference/generate_pricing_reference.py b/packages/predict/tests/helper/reference/generate_pricing_reference.py index 8a0506a40..a205b0899 100644 --- a/packages/predict/tests/helper/reference/generate_pricing_reference.py +++ b/packages/predict/tests/helper/reference/generate_pricing_reference.py @@ -107,6 +107,13 @@ The wings (deep ITM/OTM, |d2| large) hit the contract's normal_cdf clamp (0 or F) and Phi rounds to exactly 0/F, so those points are EXACT (tolerance = a 2-unit representation cushion), exercising the clamp path. + +The fourth scenario is a focused short-dated regression. Its SVI inputs come from +a one-minute Block Scholes surface seven seconds before expiry; spot and forward +are normalized to the test market's production-valid $75,000 grid without changing +the dimensionless pricing problem. Its tolerance is the protocol's 0.1% mint-price +ceiling, not the legacy analytic budget above. This point failed that ceiling when +`b * inner` was floored to 1e9 before `sqrt(w)`. """ import csv import math @@ -116,10 +123,33 @@ getcontext().prec = 60 F = 1_000_000_000 +# Exact production `Pricer.range_price` outputs for the points generated below, +# in scenario/point order. These are regression snapshots, not correctness +# expectations; the independent true-math reference remains the correctness +# oracle. +EXPECTED_CENTERS = [ + [ + 999_254_898, 986_475_284, 888_173_166, 748_850_600, 528_329_883, + 295_404_986, 134_304_491, 16_002_241, 850_622, 1_000_000_000, 0, + 471_670_117, 753_868_675, + ], + [ + 999_226_716, 985_952_968, 885_253_256, 734_089_915, 499_130_085, + 289_535_897, 142_538_929, 19_005_287, 1_057_212, 1_000_000_000, 0, + 500_869_915, 742_714_327, + ], + [ + 999_142_871, 984_281_911, 877_147_899, 741_986_937, 539_491_163, + 308_261_500, 143_599_998, 18_232_308, 998_121, 1_000_000_000, 0, + 460_508_837, 733_547_901, + ], + [53_264_202], +] REFERENCE_GRID_TICKS = 100_000 # Reference ladder width used to choose strikes. MARKET_TICK_SIZE_UNIT = 10_000 # constants::market_tick_size_unit!() TICK_SIZE = 1_000_000_000 # $1 ticks: spot/tick in (50000, 100000] for ~$75k spot CUSHION_UNITS = 2 # reference integer rounding + 2nd-order propagation +REFERENCE_GUARD_UNITS = 2 # outward guard around the float64 true-math result NORMAL_CDF_ABS = 2e-8 NORMAL_PDF_ABS = 50.0 / F ULP = 1.0 / F @@ -137,6 +167,28 @@ "357n4TarJkp62atdMpfGExEr77SZGqnDBZ7QcBatgpUF9", # 2026-05-28 02:03:03 sqrt_w_atm ~0.0084 ] +# Real one-minute SVI surface observed seven seconds before expiry. Spot and +# forward are normalized to the fixture's production-valid $75,000 grid; SVI and +# the strike/forward ratio retain the source surface's dimensionless pricing +# problem. At the selected point w ~= 3.26e-8 and the pre-fix 1e9 variance floor +# moved the quote outside the 0.1% mint-price ceiling. +SHORT_DATED_SURFACE = { + "svi_event_digest": "surface_1782548400000", + "svi_timestamp": "2026-06-27 08:19:53", + "oracle_id": "1", + "spot": "75000000000000", + "forward": "75000000000000", + "a": "28", + "b": "6251", + "rho": "940000000", + "rho_negative": "true", + "m": "48425", + "m_negative": "true", + "sigma": "1000000", +} +SHORT_DATED_STRIKE = 75_022_000_000_000 +MINT_PRICE_DEVIATION = 1_000_000 # 0.1% at 1e9 scale + # Interior d2 ladder (well below the sqrt(32)~5.657 clamp) + two clamp wings. INTERIOR_D2 = [3.0, 2.0, 1.0, 0.5, 0.0, -0.5, -1.0, -2.0, -3.0] WING_D2 = [8.0, -8.0] # deep ITM / OTM -> contract clamps to F / 0 @@ -291,6 +343,26 @@ def strike_for_d2(self, d2_target): NEG_INF = 0 +def reference_fields(value): + """Integer center plus an outward raw-unit enclosure of a true-math value.""" + scaled = value * F + return dict( + reference=round(scaled), + reference_lower=max(0, math.floor(scaled) - REFERENCE_GUARD_UNITS), + reference_upper=min(F, math.ceil(scaled) + REFERENCE_GUARD_UNITS), + ) + + +def point(lower, higher, true_value, tolerance, note): + return dict( + lower=lower, + higher=higher, + tolerance=tolerance, + note=note, + **reference_fields(true_value), + ) + + def build_points(s): """Return (list_of_point_dicts, max_delta_up_units_for_this_scenario).""" points = [] @@ -310,10 +382,15 @@ def build_points(s): du = s.delta_up(strike) tol = math.ceil(du * F) + CUSHION_UNITS worst = max(worst, du) - ref = round(s.up_true(strike) * F) - correction = s.up_true(strike) - phi(s.d2_of_strike(strike)[2]) - points.append(dict(lower=strike, higher=POS_INF, reference=ref, tolerance=tol, - note=f"d2~{d2:+.1f} adjusted UP(K) skew={correction:+.3e}")) + true_price = s.up_true(strike) + correction = true_price - phi(s.d2_of_strike(strike)[2]) + points.append(point( + strike, + POS_INF, + true_price, + tol, + f"d2~{d2:+.1f} adjusted UP(K) skew={correction:+.3e}", + )) # clamp wings (strike, +inf): contract CDF/PDF tails clamp, adjusted UP rounds to 0 / F for d2 in WING_D2: @@ -322,20 +399,30 @@ def build_points(s): continue seen.add(strike) _, _, d2t = s.d2_of_strike(strike) - ref = round(s.up_true(strike) * F) + true_price = s.up_true(strike) + ref = round(true_price * F) assert ref in (0, F), f"wing d2={d2t} did not round to clamp (ref={ref})" assert abs(d2t) >= 6.0, f"wing |d2|={abs(d2t)} not deep enough to clamp" - points.append(dict(lower=strike, higher=POS_INF, reference=ref, - tolerance=CUSHION_UNITS, note=f"clamp wing d2~{d2:+.0f}")) + points.append(point( + strike, + POS_INF, + true_price, + CUSHION_UNITS, + f"clamp wing d2~{d2:+.0f}", + )) # neg_inf one-sided range (-inf, strike@ATM): range = 1 - adjusted UP(d2) atm = d2_to_strike[0.0] du = s.delta_up(atm) tol = math.ceil(du * F) + CUSHION_UNITS worst = max(worst, du) - ref = round((1.0 - s.up_true(atm)) * F) - points.append(dict(lower=NEG_INF, higher=atm, reference=ref, tolerance=tol, - note="(-inf, K_atm] = 1 - adjusted UP(d2)")) + points.append(point( + NEG_INF, + atm, + 1.0 - s.up_true(atm), + tol, + "(-inf, K_atm] = 1 - adjusted UP(d2)", + )) # finite-finite range (K@d2=+1, K@d2=-1): both endpoints approximate -> 2 budgets lo = d2_to_strike[1.0] @@ -343,13 +430,32 @@ def build_points(s): assert lo < hi du2 = s.delta_up(lo) + s.delta_up(hi) tol = math.ceil(du2 * F) + CUSHION_UNITS - ref = round((s.up_true(lo) - s.up_true(hi)) * F) - points.append(dict(lower=lo, higher=hi, reference=ref, tolerance=tol, - note="(K@d2=+1, K@d2=-1] = adjusted UP(+1)-adjusted UP(-1)")) + points.append(point( + lo, + hi, + s.up_true(lo) - s.up_true(hi), + tol, + "(K@d2=+1, K@d2=-1] = adjusted UP(+1)-adjusted UP(-1)", + )) return points, worst +def build_short_dated_points(s): + """One policy-bound point in the variance regime fixed by the sqrt island.""" + true_price = s.up_true(SHORT_DATED_STRIKE) + tolerance = math.ceil(true_price * MINT_PRICE_DEVIATION) + return [ + point( + SHORT_DATED_STRIKE, + POS_INF, + true_price, + tolerance, + "short-dated w~3.26e-8; 0.1% mint-price policy regression", + ) + ], 0.0 + + # ---------------------------------------------------------------------------- # Move emission # ---------------------------------------------------------------------------- @@ -364,23 +470,31 @@ def emit_move(scenarios, scen_points, budget_units): w("// SPDX-License-Identifier: Apache-2.0") w("//") w("// @generated by packages/predict/tests/helper/reference/generate_pricing_reference.py") - w("// Source data: packages/predict/simulations/data/scenario_dataset.csv (real on-chain") - w("// Block Scholes SVI, one market, 2026-05-27). DO NOT EDIT BY HAND — regenerate with") + w("// Source data: packages/predict/simulations/data/scenario_dataset.csv plus the") + w("// short-dated Block Scholes fixture documented in the generator. DO NOT EDIT BY HAND.") + w("// Regenerate with") w("// python3 generate_pricing_reference.py") w("//") w("// Independent true-math reference (Python stdlib math.log/sqrt/erf, NOT the contract") w("// and NOT python_replay's fixed-point pricer) for Pricer.range_price.") - w("// Each point's `tolerance` is the analytic worst-case fixed-point error of the") + w("// The first three scenarios use the analytic worst-case fixed-point error of the") w("// skew-adjusted UP=N(d2)-phi(d2)*w'(k)/(2*sqrt(w)),") w("// propagated from math.move's documented per-primitive budgets at the TRUE values; see") - w("// the generator header for the full derivation. The forward priced is the fresh-Pyth") + w("// the generator header for the full derivation. The short-dated scenario instead") + w("// pins the protocol's 0.1% mint-price ceiling at w~3.26e-8. The forward priced is") + w("// the fresh-Pyth") w("// round-trip mul(spot, div(forward, spot)).") w("//") + w("// `expected_center` is the exact production fixed-point output.") + w("// It detects value drift only; the independent reference + tolerance above decides correctness.") + w("// `reference_lower` / `reference_upper` add a two-unit outward guard around the") + w("// float64 true-math result; the Approx radius must enclose both endpoints.") + w("//") w(f"// Worst-case per-endpoint budget across all scenarios/strikes: {fmt_u64(budget_units)} units (@1e9).") w("// Dominated by the small-variance scenario at |d2|~1: d2=-(k+w/2)/sqrt(w)") w("// and the skew term's 1/sqrt(w) denominator amplify fixed-point variance / slope dust.") w("//") - w("// Provenance (svi_event_digest @ svi_timestamp, sqrt(w_atm)):") + w("// Provenance (source identifier @ SVI timestamp, sqrt(w_atm)):") for i, s in enumerate(scenarios): wk = s.w_of_k(0.0) w(f"// [{i}] {s.digest} {s.svi_ts[:19]} sqrt_w_atm={math.sqrt(wk):.6f}") @@ -391,13 +505,15 @@ def emit_move(scenarios, scen_points, budget_units): w("") w("const ENoSuchScenario: u64 = 0;") w("") - w("/// One independent reference point: Pricer.range_price(lower, higher)") - w("/// must be within `tolerance` units of the true-math `reference`.") + w("/// One pricing point with independent correctness and exact compatibility expectations.") w("public struct RefPoint has copy, drop {") w(" lower: u64,") w(" higher: u64,") w(" reference: u64,") + w(" reference_lower: u64,") + w(" reference_upper: u64,") w(" tolerance: u64,") + w(" expected_center: u64,") w("}") w("") w("public fun lower(p: &RefPoint): u64 { p.lower }") @@ -406,10 +522,32 @@ def emit_move(scenarios, scen_points, budget_units): w("") w("public fun reference(p: &RefPoint): u64 { p.reference }") w("") + w("public fun reference_lower(p: &RefPoint): u64 { p.reference_lower }") + w("") + w("public fun reference_upper(p: &RefPoint): u64 { p.reference_upper }") + w("") w("public fun tolerance(p: &RefPoint): u64 { p.tolerance }") w("") - w("fun pt(lower: u64, higher: u64, reference: u64, tolerance: u64): RefPoint {") - w(" RefPoint { lower, higher, reference, tolerance }") + w("public fun expected_center(p: &RefPoint): u64 { p.expected_center }") + w("") + w("fun pt(") + w(" lower: u64,") + w(" higher: u64,") + w(" reference: u64,") + w(" reference_lower: u64,") + w(" reference_upper: u64,") + w(" tolerance: u64,") + w(" expected_center: u64,") + w("): RefPoint {") + w(" RefPoint {") + w(" lower,") + w(" higher,") + w(" reference,") + w(" reference_lower,") + w(" reference_upper,") + w(" tolerance,") + w(" expected_center,") + w(" }") w("}") w("") w("/// Number of real-data scenarios.") @@ -465,7 +603,7 @@ def emit_bool_selector(name, values, doc): emit_u64_selector("svi_m_magnitude", [s.m_mag for s in scenarios], "Real SVI `m` magnitude (1e9) seeded through the Block Scholes surface update.") emit_bool_selector("svi_m_is_negative", [s.m_neg for s in scenarios], "Sign flag for real SVI `m` (true == negative).") - w("/// Reference points for scenario `s` (lower, higher, true-math reference, tolerance).") + w("/// Points for scenario `s` (range, true reference enclosure, tolerance, parent center).") w("public fun points(s: u64): vector {") for i, s in enumerate(scenarios): kw = "if" if i == 0 else "} else if" @@ -475,7 +613,15 @@ def emit_bool_selector(name, values, doc): lo = "constants::neg_inf!()" if p["lower"] == NEG_INF else fmt_u64(p["lower"]) hi = "constants::pos_inf!()" if p["higher"] == POS_INF else fmt_u64(p["higher"]) w(f" // {p['note']}") - w(f" pt({lo}, {hi}, {fmt_u64(p['reference'])}, {fmt_u64(p['tolerance'])}),") + w(" pt(") + w(f" {lo},") + w(f" {hi},") + w(f" {fmt_u64(p['reference'])},") + w(f" {fmt_u64(p['reference_lower'])},") + w(f" {fmt_u64(p['reference_upper'])},") + w(f" {fmt_u64(p['tolerance'])},") + w(f" {fmt_u64(p['expected_center'])},") + w(" ),") w(" ]") w(" } else {") w(" abort ENoSuchScenario") @@ -483,7 +629,7 @@ def emit_bool_selector(name, values, doc): w("}") w("") - w("/// Provenance: svi_event_digest of the source CSV row for scenario `s`.") + w("/// Provenance source identifier (CSV event digest or short-dated surface ID).") w("public fun svi_event_digest(s: u64): vector {") for i, s in enumerate(scenarios): kw = "if" if i == 0 else "} else if" @@ -508,12 +654,25 @@ def main(): if d not in by_digest: raise SystemExit(f"selected svi_event_digest not found in CSV: {d}") scenarios.append(Scenario(by_digest[d])) + scenarios.append(Scenario(SHORT_DATED_SURFACE)) scen_points = [] budget = 0.0 print("=== diagnostic (true-math reference + analytic budget) ===") for i, s in enumerate(scenarios): - pts, worst = build_points(s) + pts, worst = ( + build_short_dated_points(s) + if i == len(SELECTED_DIGESTS) + else build_points(s) + ) + expected = EXPECTED_CENTERS[i] + if len(expected) != len(pts): + raise SystemExit( + f"expected point count mismatch for scenario {i}: " + f"{len(expected)} expected values != {len(pts)} generated points" + ) + for p, center in zip(pts, expected): + p["expected_center"] = center scen_points.append(pts) budget = max(budget, worst) print(f"\n[{i}] {s.digest} {s.svi_ts[:19]}") diff --git a/packages/predict/tests/plp/lp_book_tests.move b/packages/predict/tests/plp/lp_book_tests.move index 0245b088d..bf2a4671a 100644 --- a/packages/predict/tests/plp/lp_book_tests.move +++ b/packages/predict/tests/plp/lp_book_tests.move @@ -84,7 +84,7 @@ fun supply_drain_mints_at_mark_and_joins_idle() { // Drain at pool_value == total_supply == L (mark 1.0): the supply mints 1:1. let summary = book.drain( &mut ledger, - lp_book::new_flush_mark(min_supply!(), min_supply!()), + exact_mark(min_supply!(), min_supply!()), vault_id(), option::none(), option::none(), @@ -110,7 +110,7 @@ fun priced_supply_mints_proportional_shares() { // shares = 20e6 * 30e6 / 60e6 = 10e6. book.drain( &mut ledger, - lp_book::new_flush_mark(60_000_000, 30_000_000), + exact_mark(60_000_000, 30_000_000), vault_id(), option::none(), option::none(), @@ -135,7 +135,7 @@ fun priced_withdraw_burns_and_pays_from_idle() { // dusdc = 10e6 * 60e6 / 30e6 = 20e6. book.drain( &mut ledger, - lp_book::new_flush_mark(60_000_000, 30_000_000), + exact_mark(60_000_000, 30_000_000), vault_id(), option::none(), option::none(), @@ -149,6 +149,34 @@ fun priced_withdraw_burns_and_pays_from_idle() { finish(scenario, book, ledger); } +#[test] +fun supply_and_withdraw_use_their_own_frozen_marks() { + let (mut scenario, mut book, mut ledger) = setup(); + // One pre-drain 100m supply, with a 0.9 withdrawal bid and 1.1 supply ask. + book.mint_locked_liquidity(100_000_000); + seed_idle(&mut ledger, 100_000_000); + let payment = coin::mint_for_testing(20_000_000, scenario.ctx()); + book.request_supply(payment, alice_id(), ALICE, NO_MIN_OUTPUT); + enqueue_withdraw(&mut scenario, &mut book, 10_000_000); + + let summary = book.drain( + &mut ledger, + lp_book::new_flush_mark(90_000_000, 110_000_000, 100_000_000), + vault_id(), + option::none(), + option::none(), + scenario.ctx(), + ); + + // Supply: floor(20m * 100m / 110m) = 18,181,818 shares. + // Withdraw: floor(10m * 90m / 100m) = 9m DUSDC. + assert_drain_summary(&summary, 1, 1, 2); + assert_eq!(book.total_supply(), 108_181_818); + assert_eq!(ledger.idle_balance(), 111_000_000); + + finish(scenario, book, ledger); +} + #[test] fun two_withdrawals_share_one_frozen_mark() { let (mut scenario, mut book, mut ledger) = setup(); @@ -162,7 +190,7 @@ fun two_withdrawals_share_one_frozen_mark() { // If the second repriced post-first it would round to 16_666_667. book.drain( &mut ledger, - lp_book::new_flush_mark(50_000_000, 30_000_000), + exact_mark(50_000_000, 30_000_000), vault_id(), option::none(), option::none(), @@ -191,7 +219,7 @@ fun withdrawals_stop_when_idle_is_dry_and_carry() { let summary = book.drain( &mut ledger, - lp_book::new_flush_mark(30_000_000, 30_000_000), + exact_mark(30_000_000, 30_000_000), vault_id(), option::none(), option::none(), @@ -218,7 +246,7 @@ fun supply_limit_miss_carries_then_fills_when_mark_improves() { let summary = book.drain( &mut ledger, - lp_book::new_flush_mark(60_000_000, 30_000_000), + exact_mark(60_000_000, 30_000_000), vault_id(), option::none(), option::none(), @@ -233,7 +261,7 @@ fun supply_limit_miss_carries_then_fills_when_mark_improves() { // Improved mark 1.0 -> supply quotes 20e6 shares, satisfying the same queued request. let summary = book.drain( &mut ledger, - lp_book::new_flush_mark(30_000_000, 30_000_000), + exact_mark(30_000_000, 30_000_000), vault_id(), option::none(), option::none(), @@ -260,7 +288,7 @@ fun supply_limit_expires_after_three_misses() { while (i < limit_attempts!() - 1) { let summary = book.drain( &mut ledger, - lp_book::new_flush_mark(60_000_000, 30_000_000), + exact_mark(60_000_000, 30_000_000), vault_id(), option::none(), option::none(), @@ -273,7 +301,7 @@ fun supply_limit_expires_after_three_misses() { let summary = book.drain( &mut ledger, - lp_book::new_flush_mark(60_000_000, 30_000_000), + exact_mark(60_000_000, 30_000_000), vault_id(), option::none(), option::none(), @@ -303,7 +331,7 @@ fun withdraw_limit_miss_carries_then_fills_when_mark_improves() { let summary = book.drain( &mut ledger, - lp_book::new_flush_mark(60_000_000, 30_000_000), + exact_mark(60_000_000, 30_000_000), vault_id(), option::none(), option::none(), @@ -318,7 +346,7 @@ fun withdraw_limit_miss_carries_then_fills_when_mark_improves() { // Improved mark 2.1 -> withdraw quotes exactly 21e6 DUSDC, satisfying the request. let summary = book.drain( &mut ledger, - lp_book::new_flush_mark(63_000_000, 30_000_000), + exact_mark(63_000_000, 30_000_000), vault_id(), option::none(), option::none(), @@ -350,7 +378,7 @@ fun withdraw_limit_expires_after_three_misses() { while (i < limit_attempts!() - 1) { let summary = book.drain( &mut ledger, - lp_book::new_flush_mark(60_000_000, 30_000_000), + exact_mark(60_000_000, 30_000_000), vault_id(), option::none(), option::none(), @@ -363,7 +391,7 @@ fun withdraw_limit_expires_after_three_misses() { let summary = book.drain( &mut ledger, - lp_book::new_flush_mark(60_000_000, 30_000_000), + exact_mark(60_000_000, 30_000_000), vault_id(), option::none(), option::none(), @@ -395,7 +423,7 @@ fun unbounded_flush_drains_every_queued_supply() { let summary = book.drain( &mut ledger, - lp_book::new_flush_mark(min_supply!(), min_supply!()), + exact_mark(min_supply!(), min_supply!()), vault_id(), option::none(), option::none(), @@ -438,7 +466,7 @@ fun cancel_tail_page_request_unlinks_page_and_keeps_queue_drainable() { // head_page_id and tail_page_id still point at a coherent single page. let summary = book.drain( &mut ledger, - lp_book::new_flush_mark(min_supply!(), min_supply!()), + exact_mark(min_supply!(), min_supply!()), vault_id(), option::none(), option::none(), @@ -484,7 +512,7 @@ fun cancel_middle_page_forward_relinks_predecessor_to_successor() { // `next`, so all 65 survivors (page 0's 64 + page 2's 1) fill. let summary = book.drain( &mut ledger, - lp_book::new_flush_mark(min_supply!(), min_supply!()), + exact_mark(min_supply!(), min_supply!()), vault_id(), option::none(), option::none(), @@ -531,7 +559,7 @@ fun cancel_middle_page_backward_relinks_successor_to_predecessor() { // Page 0 survived intact: draining fills all 64. let summary = book.drain( &mut ledger, - lp_book::new_flush_mark(min_supply!(), min_supply!()), + exact_mark(min_supply!(), min_supply!()), vault_id(), option::none(), option::none(), @@ -557,7 +585,7 @@ fun bounded_supply_budget_fills_up_to_budget_and_carries() { let summary = book.drain( &mut ledger, - lp_book::new_flush_mark(min_supply!(), min_supply!()), + exact_mark(min_supply!(), min_supply!()), vault_id(), option::some(2), option::none(), @@ -571,7 +599,7 @@ fun bounded_supply_budget_fills_up_to_budget_and_carries() { // The carried supply fills on the next unbounded drain. book.drain( &mut ledger, - lp_book::new_flush_mark(2 * min_supply!(), 3 * min_supply!()), + exact_mark(2 * min_supply!(), 3 * min_supply!()), vault_id(), option::none(), option::none(), @@ -600,7 +628,7 @@ fun independent_budgets_let_withdrawals_drain_under_supply_pressure() { let summary = book.drain( &mut ledger, - lp_book::new_flush_mark(30_000_000, 30_000_000), + exact_mark(30_000_000, 30_000_000), vault_id(), option::some(1), option::some(1), @@ -678,7 +706,7 @@ fun cancelled_supply_requests_do_not_spend_drain_budget() { // physically removed and never counted against the budget. let summary = book.drain( &mut ledger, - lp_book::new_flush_mark(min_supply!(), min_supply!()), + exact_mark(min_supply!(), min_supply!()), vault_id(), option::some(1), option::none(), @@ -727,7 +755,7 @@ fun priced_supply_with_zero_pool_value_refunds() { let summary = book.drain( &mut ledger, - lp_book::new_flush_mark(0, min_supply!()), + exact_mark(0, min_supply!()), vault_id(), option::none(), option::none(), @@ -752,7 +780,7 @@ fun priced_supply_that_rounds_to_zero_shares_refunds() { // shares = floor(min_supply * min_supply / (min_supply^2 + 1)) = 0. let summary = book.drain( &mut ledger, - lp_book::new_flush_mark(ZERO_SHARE_SUPPLY_POOL_VALUE, min_supply!()), + exact_mark(ZERO_SHARE_SUPPLY_POOL_VALUE, min_supply!()), vault_id(), option::none(), option::none(), @@ -776,7 +804,7 @@ fun priced_withdraw_that_rounds_to_zero_payout_refunds() { // payout = floor(min_withdraw * 1 / (min_withdraw + 1)) = 0. let summary = book.drain( &mut ledger, - lp_book::new_flush_mark(1, ZERO_PAYOUT_WITHDRAW_TOTAL_SUPPLY), + exact_mark(1, ZERO_PAYOUT_WITHDRAW_TOTAL_SUPPLY), vault_id(), option::none(), option::none(), @@ -801,7 +829,7 @@ fun supply_at_min_executable_plp_price_fills() { // At 0.01 DUSDC/PLP, 10 DUSDC mints 1,000 PLP = 1_000_000_000 raw shares. let summary = book.drain( &mut ledger, - lp_book::new_flush_mark(MIN_EXECUTABLE_PLP_PRICE, ONE_PLP), + exact_mark(MIN_EXECUTABLE_PLP_PRICE, ONE_PLP), vault_id(), option::none(), option::none(), @@ -824,7 +852,7 @@ fun supply_below_min_executable_plp_price_refunds() { let summary = book.drain( &mut ledger, - lp_book::new_flush_mark(MIN_EXECUTABLE_PLP_PRICE - 1, ONE_PLP), + exact_mark(MIN_EXECUTABLE_PLP_PRICE - 1, ONE_PLP), vault_id(), option::none(), option::none(), @@ -849,7 +877,7 @@ fun supply_at_max_executable_plp_price_fills() { // At 100 DUSDC/PLP, 10 DUSDC mints 0.1 PLP = 100_000 raw shares. let summary = book.drain( &mut ledger, - lp_book::new_flush_mark(MAX_EXECUTABLE_PLP_PRICE, ONE_PLP), + exact_mark(MAX_EXECUTABLE_PLP_PRICE, ONE_PLP), vault_id(), option::none(), option::none(), @@ -872,7 +900,7 @@ fun supply_above_max_executable_plp_price_refunds() { let summary = book.drain( &mut ledger, - lp_book::new_flush_mark(MAX_EXECUTABLE_PLP_PRICE + 1, ONE_PLP), + exact_mark(MAX_EXECUTABLE_PLP_PRICE + 1, ONE_PLP), vault_id(), option::none(), option::none(), @@ -898,7 +926,7 @@ fun oversized_supply_that_exceeds_u64_shares_refunds() { // raw PLP shares, which does not fit in u64 and is therefore non-executable. let summary = book.drain( &mut ledger, - lp_book::new_flush_mark(MIN_EXECUTABLE_PLP_PRICE, ONE_PLP), + exact_mark(MIN_EXECUTABLE_PLP_PRICE, ONE_PLP), vault_id(), option::none(), option::none(), @@ -925,7 +953,7 @@ fun supply_that_exceeds_remaining_plp_headroom_refunds() { // but only 5_000_000 PLP raw units remain before the treasury supply cap. let summary = book.drain( &mut ledger, - lp_book::new_flush_mark(near_max_total_supply, near_max_total_supply), + exact_mark(near_max_total_supply, near_max_total_supply), vault_id(), option::none(), option::none(), @@ -953,7 +981,7 @@ fun non_executable_supply_refunds_spend_supply_budget() { let summary = book.drain( &mut ledger, - lp_book::new_flush_mark(MIN_EXECUTABLE_PLP_PRICE - 1, ONE_PLP), + exact_mark(MIN_EXECUTABLE_PLP_PRICE - 1, ONE_PLP), vault_id(), option::some(2), option::none(), @@ -977,7 +1005,7 @@ fun non_executable_withdraw_refunds_spend_withdraw_budget() { let summary = book.drain( &mut ledger, - lp_book::new_flush_mark(1, ZERO_PAYOUT_WITHDRAW_TOTAL_SUPPLY), + exact_mark(1, ZERO_PAYOUT_WITHDRAW_TOTAL_SUPPLY), vault_id(), option::none(), option::some(1), @@ -1010,6 +1038,12 @@ fun request_withdraw_below_min_aborts() { abort 999 } +#[test, expected_failure(abort_code = lp_book::EInvalidFlushMark)] +fun flush_mark_rejects_withdraw_value_above_supply_value() { + let _ = lp_book::new_flush_mark(2, 1, 1); + abort 999 +} + // === Helpers === fun setup(): (Scenario, LpBook, Ledger) { @@ -1038,6 +1072,10 @@ fun new_book(ctx: &mut TxContext): (LpBook, Ledger) { (lp_book::new(treasury_cap, ctx), pool_accounting::new(ctx)) } +fun exact_mark(pool_value: u64, total_supply: u64): lp_book::FlushMark { + lp_book::new_flush_mark(pool_value, pool_value, total_supply) +} + /// Seed pool idle DUSDC directly so withdraw drains have liquidity to pay from. fun seed_idle(ledger: &mut Ledger, amount: u64) { ledger.receive_idle(balance::create_for_testing(amount)); diff --git a/packages/predict/tests/pricing/precision_guard_tests.move b/packages/predict/tests/pricing/precision_guard_tests.move new file mode 100644 index 000000000..39ba40e31 --- /dev/null +++ b/packages/predict/tests/pricing/precision_guard_tests.move @@ -0,0 +1,46 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +/// Boundary coverage for the shared numerical-certification predicate +/// (`approx::true_relative_deviation_within`). The runtime aborts the gates raise +/// are covered end-to-end by the flow tests; this pins the classification edge. +#[test_only] +module deepbook_predict::precision_guard_tests; + +use fixed_math::{approx, i64}; + +const CENTER: u64 = 1_000_000_000; +// The ratified deviation bounds at 1e9 scale: 0.1% for a contract price, 1% for +// pool NAV (mirroring `max_contract_price_deviation` / `max_nav_deviation`). +const CONTRACT_MAX_DEVIATION: u64 = 1_000_000; +const NAV_MAX_DEVIATION: u64 = 10_000_000; +// Largest integer errors satisfying `error <= deviation * (center - error) / 1e9`, +// and one raw unit past each boundary. +const MAX_PRICE_ERROR: u64 = 999_000; +const PRICE_ERROR_ABOVE_MAX: u64 = 999_001; +const MAX_NAV_ERROR: u64 = 9_900_990; +const NAV_ERROR_ABOVE_MAX: u64 = 9_900_991; + +#[test] +fun contract_price_at_boundary_is_within() { + let price = approx::from_parts(i64::from_u64(CENTER), MAX_PRICE_ERROR); + assert!(price.true_relative_deviation_within(CONTRACT_MAX_DEVIATION)); +} + +#[test] +fun contract_price_above_boundary_is_not_within() { + let price = approx::from_parts(i64::from_u64(CENTER), PRICE_ERROR_ABOVE_MAX); + assert!(!price.true_relative_deviation_within(CONTRACT_MAX_DEVIATION)); +} + +#[test] +fun pool_nav_at_boundary_is_within() { + let nav = approx::from_parts(i64::from_u64(CENTER), MAX_NAV_ERROR); + assert!(nav.true_relative_deviation_within(NAV_MAX_DEVIATION)); +} + +#[test] +fun pool_nav_above_boundary_is_not_within() { + let nav = approx::from_parts(i64::from_u64(CENTER), NAV_ERROR_ABOVE_MAX); + assert!(!nav.true_relative_deviation_within(NAV_MAX_DEVIATION)); +} diff --git a/packages/predict/tests/pricing/pricing_exact_tests.move b/packages/predict/tests/pricing/pricing_exact_tests.move index b858028db..2fdc82602 100644 --- a/packages/predict/tests/pricing/pricing_exact_tests.move +++ b/packages/predict/tests/pricing/pricing_exact_tests.move @@ -47,11 +47,12 @@ const SKEW_CLAMP_M: u64 = 0; const SKEW_CLAMP_SIGMA: u64 = 1_000_000; const FLAT_SVI_A: u64 = 1; const FLAT_SVI_B: u64 = 0; +const MAX_MINT_PRICE_DEVIATION: u64 = 1_000_000; /// Stand up a production-valid oracle for real scenario `s`, seed its real SVI + -/// spot/forward, and assert `Pricer.range_price` matches the independent -/// true-math reference within the per-point derived budget at every reference point. -fun run_scenario(s: u64) { +/// spot/forward, and assert `Pricer.range_price` matches its fixed-point regression +/// snapshot and the independent true-math reference within the per-point budget. +fun run_scenario(s: u64, enforce_mint_deviation: bool) { let mut fx = oracle_fixture::setup_oracle( ref_data::creation_spot(s), ref_data::tick_size(s), @@ -78,8 +79,17 @@ fun run_scenario(s: u64) { let mut i = 0; while (i < n) { let p = &points[i]; - let actual = pricer.range_price(strike(p.lower()), strike(p.higher())); + let priced = pricer.range_price_approx(strike(p.lower()), strike(p.higher())); + assert!(!priced.is_negative()); + assert!(priced.error() < std::u64::max_value!()); + let actual = priced.magnitude(); + assert_eq!(actual, p.expected_center()); test_helpers::assert_within(actual, p.reference(), p.tolerance()); + test_helpers::assert_within(actual, p.reference_lower(), priced.error()); + test_helpers::assert_within(actual, p.reference_upper(), priced.error()); + if (enforce_mint_deviation) { + assert!(priced.true_relative_deviation_within(MAX_MINT_PRICE_DEVIATION)); + }; i = i + 1; }; @@ -88,13 +98,20 @@ fun run_scenario(s: u64) { } #[test] -fun real_scenario_large_variance() { run_scenario(0); } +fun real_scenario_large_variance() { run_scenario(0, false); } #[test] -fun real_scenario_medium_variance() { run_scenario(1); } +fun real_scenario_medium_variance() { run_scenario(1, false); } #[test] -fun real_scenario_small_variance() { run_scenario(2); } +fun real_scenario_small_variance() { run_scenario(2, false); } + +/// This real one-minute SVI surface has w ~= 3.26e-8 at the selected strike. +/// Flooring `b * inner` to 1e9 before sqrt priced it about 3% below the independent +/// reference and produced a certificate above the 0.1% mint ceiling. Retaining +/// that one variance term through sqrt keeps both center and certificate in policy. +#[test] +fun real_short_dated_scenario_meets_mint_deviation() { run_scenario(3, true); } #[test] fun positive_svi_slope_clamps_adjusted_digital_to_zero() { diff --git a/packages/predict/tests/pricing/pricing_guard_tests.move b/packages/predict/tests/pricing/pricing_guard_tests.move index d24c1a371..37eb674a9 100644 --- a/packages/predict/tests/pricing/pricing_guard_tests.move +++ b/packages/predict/tests/pricing/pricing_guard_tests.move @@ -9,11 +9,11 @@ /// passes; /// - `EBlockScholesPriceStale`: a hard staleness abort when one of the split /// Block Scholes price feeds is past its configured freshness window. -/// The old deep-ITM/deep-OTM aborts (`EInvalidStrikeRatio`) are gone: the price -/// tail now SATURATES instead of aborting, so those are pinned here as exact-value -/// tests (deep-ITM up tail -> 1.0, deep-OTM up tail -> 0). A stale Pyth spot no -/// longer aborts either — it falls back to the stored Block Scholes forward; that -/// fallback is pinned with exact values in +/// The old deep-ITM/deep-OTM aborts (`EInvalidStrikeRatio`) are gone: every positive +/// finite strike now evaluates through certified log-ratio math, while the final +/// digital price remains clamped to `[0, 1]`. A stale Pyth spot no longer aborts +/// either — it falls back to the stored Block Scholes forward; that fallback is +/// pinned with exact values in /// `pricing_tests::live_forward_switches_source_exactly_at_pyth_staleness_boundary`, /// so it is not duplicated here. /// @@ -62,15 +62,22 @@ use sui::test_scenario::return_shared; const EUnexpectedSuccess: u64 = 999; const SECOND_SOURCE_ID: u32 = 2; -/// A strike so far below the forward that `strike * 1e9 / forward` truncates to 0, -/// hitting the deep-ITM saturation branch (the neg_inf limit). With the default -/// forward (100e9) the threshold is `forward / 1e9 == 100`, so strike 1 saturates. +/// A finite strike so far below the forward that its 1e9 quotient truncates to zero. const DEEP_ITM_STRIKE: u64 = 1; -/// A finite (non-`pos_inf`) strike so far above a tiny forward that -/// `strike * 1e9 / forward` exceeds `u64::MAX`, hitting the deep-OTM saturation -/// branch (the pos_inf limit). With forward 1 this needs `strike > ~1.8446e10`. +/// A finite strike so far above a tiny forward that its 1e9 quotient exceeds u64. const DEEP_OTM_STRIKE: u64 = 1_000_000_000_000_000_000; +/// Production-valid spot/forward and strike whose floored strike ratio is exactly +/// one raw 1e9 unit. +const MIN_NONZERO_RATIO_FORWARD: u64 = 10_000_000_000_000_000; +const MIN_NONZERO_RATIO_STRIKE: u64 = 10_000_000; +const FINITE_TAIL_FORWARD: u64 = 105_000_000_000_000_000; +const FINITE_TAIL_LOWER_STRIKE: u64 = 100_000_000; +const FINITE_TAIL_UPPER_STRIKE: u64 = 100_000_000_000; +const FINITE_TAIL_LOWER_UP_REFERENCE: u64 = 561_894_965; +const FINITE_TAIL_UPPER_UP_REFERENCE: u64 = 575_761_066; +const FINITE_TAIL_RANGE_REFERENCE: u64 = 0; +const MAX_MINT_PRICE_DEVIATION: u64 = 1_000_000; // Independent copies of `pricing.move`'s private pricing-safe envelope (the macros // are module-private, so the bounds are reproduced here from the source, not read). // The basis ceiling (100 * 1e9) is exercised by computing `spot * 101` directly. @@ -270,12 +277,12 @@ fun fresh_pyth_spot_above_pricing_ceiling_aborts() { abort EUnexpectedSuccess } -// === Price-tail saturation (replaces the deleted strike-ratio aborts) === +// === Finite price tails (replace the deleted strike-ratio aborts) === -/// Deep-ITM up tail: a strike far below the forward underflows the strike ratio to -/// 0, so `up_price` returns ~1.0 (the neg_inf limit) instead of aborting. +/// The finite deep-ITM path evaluates rather than aborting; this default surface's +/// resulting digital price clamps to one. #[test] -fun deep_itm_up_price_saturates_to_one() { +fun deep_itm_up_price_evaluates_to_one() { let mut fx = oracle_fixture::setup_oracle_default(); let mut oracle = fx.take_oracle_bundle(); // Fresh spot == forward == 100e9. @@ -288,10 +295,10 @@ fun deep_itm_up_price_saturates_to_one() { fx.finish(); } -/// Deep-OTM up tail: a strike far above the forward overflows the strike ratio past -/// `u64::MAX`, so `up_price` returns 0 (the pos_inf limit) instead of aborting. +/// The finite deep-OTM path evaluates rather than aborting; this default surface's +/// resulting digital price clamps to zero. #[test] -fun deep_otm_up_price_saturates_to_zero() { +fun deep_otm_up_price_evaluates_to_zero() { let mut fx = oracle_fixture::setup_oracle_default(); let mut oracle = fx.take_oracle_bundle(); // Fresh spot == forward == 1 (a tiny forward, so a finite u64 strike can clear @@ -305,6 +312,91 @@ fun deep_otm_up_price_saturates_to_zero() { fx.finish(); } +/// A one-raw-unit quotient takes the finite log-ratio path without primitive-aborting. +#[test] +fun smallest_nonzero_strike_ratio_still_prices_the_tail() { + let mut fx = oracle_fixture::setup_oracle_default(); + let mut oracle = fx.take_oracle_bundle(); + fx.prepare_real_oracle_bundle( + &mut oracle, + MIN_NONZERO_RATIO_FORWARD, + MIN_NONZERO_RATIO_FORWARD, + 1, + false, + test_constants::pricing_max_svi_input(), + test_constants::pricing_min_svi_sigma(), + float!(), + true, + 0, + false, + ); + let pricer = fx.load_pricer_bundle(&oracle); + + assert_eq!(pricer.up_price(strike(MIN_NONZERO_RATIO_STRIKE)), 0); + + oracle_fixture::return_oracle_bundle(oracle); + fx.finish(); +} + +/// Python stdlib true math gives UP(lower)=0.5618949647268344 and +/// UP(upper)=0.5757610655414342, hence a clamped range price of exactly zero. +/// Both finite strikes and this SVI surface satisfy the live pricing envelope. +#[test] +fun finite_ratio_underflow_does_not_false_certify_range() { + let mut fx = oracle_fixture::setup_oracle_default(); + let mut oracle = fx.take_oracle_bundle(); + fx.prepare_real_oracle_bundle( + &mut oracle, + FINITE_TAIL_FORWARD, + FINITE_TAIL_FORWARD, + 1, + false, + float!(), + test_constants::pricing_min_svi_sigma(), + float!(), + true, + 0, + false, + ); + let pricer = fx.load_pricer_bundle(&oracle); + + let lower_up = pricer.range_price_approx( + strike(FINITE_TAIL_LOWER_STRIKE), + strike(constants::pos_inf!()), + ); + test_helpers::assert_within( + lower_up.magnitude(), + FINITE_TAIL_LOWER_UP_REFERENCE, + lower_up.error(), + ); + assert!(lower_up.true_relative_deviation_within(MAX_MINT_PRICE_DEVIATION)); + + let upper_up = pricer.range_price_approx( + strike(FINITE_TAIL_UPPER_STRIKE), + strike(constants::pos_inf!()), + ); + test_helpers::assert_within( + upper_up.magnitude(), + FINITE_TAIL_UPPER_UP_REFERENCE, + upper_up.error(), + ); + assert!(upper_up.true_relative_deviation_within(MAX_MINT_PRICE_DEVIATION)); + + let range = pricer.range_price_approx( + strike(FINITE_TAIL_LOWER_STRIKE), + strike(FINITE_TAIL_UPPER_STRIKE), + ); + test_helpers::assert_within( + range.magnitude(), + FINITE_TAIL_RANGE_REFERENCE, + range.error(), + ); + assert!(!range.true_relative_deviation_within(MAX_MINT_PRICE_DEVIATION)); + + oracle_fixture::return_oracle_bundle(oracle); + fx.finish(); +} + // === Surface pricing-safe envelope rejects (EBlockScholesInputsInvalid) === #[test, expected_failure(abort_code = pricing::EBlockScholesInputsInvalid)] diff --git a/packages/predict/tests/pricing/pricing_reference_data.move b/packages/predict/tests/pricing/pricing_reference_data.move index 8c21f14f0..727f7c221 100644 --- a/packages/predict/tests/pricing/pricing_reference_data.move +++ b/packages/predict/tests/pricing/pricing_reference_data.move @@ -2,26 +2,35 @@ // SPDX-License-Identifier: Apache-2.0 // // @generated by packages/predict/tests/helper/reference/generate_pricing_reference.py -// Source data: packages/predict/simulations/data/scenario_dataset.csv (real on-chain -// Block Scholes SVI, one market, 2026-05-27). DO NOT EDIT BY HAND — regenerate with +// Source data: packages/predict/simulations/data/scenario_dataset.csv plus the +// short-dated Block Scholes fixture documented in the generator. DO NOT EDIT BY HAND. +// Regenerate with // python3 generate_pricing_reference.py // // Independent true-math reference (Python stdlib math.log/sqrt/erf, NOT the contract // and NOT python_replay's fixed-point pricer) for Pricer.range_price. -// Each point's `tolerance` is the analytic worst-case fixed-point error of the +// The first three scenarios use the analytic worst-case fixed-point error of the // skew-adjusted UP=N(d2)-phi(d2)*w'(k)/(2*sqrt(w)), // propagated from math.move's documented per-primitive budgets at the TRUE values; see -// the generator header for the full derivation. The forward priced is the fresh-Pyth +// the generator header for the full derivation. The short-dated scenario instead +// pins the protocol's 0.1% mint-price ceiling at w~3.26e-8. The forward priced is +// the fresh-Pyth // round-trip mul(spot, div(forward, spot)). // +// `expected_center` is the exact production fixed-point output. +// It detects value drift only; the independent reference + tolerance above decides correctness. +// `reference_lower` / `reference_upper` add a two-unit outward guard around the +// float64 true-math result; the Approx radius must enclose both endpoints. +// // Worst-case per-endpoint budget across all scenarios/strikes: 3_129 units (@1e9). // Dominated by the small-variance scenario at |d2|~1: d2=-(k+w/2)/sqrt(w) // and the skew term's 1/sqrt(w) denominator amplify fixed-point variance / slope dust. // -// Provenance (svi_event_digest @ svi_timestamp, sqrt(w_atm)): +// Provenance (source identifier @ SVI timestamp, sqrt(w_atm)): // [0] 5KbNiu2S7ULJcS1ryDtJ3DC2omTojjJoMFjmu7nYgTAF9 2026-05-27 08:00:18 sqrt_w_atm=0.017067 // [1] H4DNoM3eRw83KdZjASFabLJSgu7YNZYRNfCWErcKgnE59 2026-05-27 20:04:03 sqrt_w_atm=0.010893 // [2] 357n4TarJkp62atdMpfGExEr77SZGqnDBZ7QcBatgpUF9 2026-05-28 02:03:03 sqrt_w_atm=0.008383 +// [3] surface_1782548400000 2026-06-27 08:19:53 sqrt_w_atm=0.000184 #[test_only] module deepbook_predict::pricing_reference_data; @@ -29,13 +38,15 @@ use deepbook_predict::constants; const ENoSuchScenario: u64 = 0; -/// One independent reference point: Pricer.range_price(lower, higher) -/// must be within `tolerance` units of the true-math `reference`. +/// One pricing point with independent correctness and exact compatibility expectations. public struct RefPoint has copy, drop { lower: u64, higher: u64, reference: u64, + reference_lower: u64, + reference_upper: u64, tolerance: u64, + expected_center: u64, } public fun lower(p: &RefPoint): u64 { p.lower } @@ -44,14 +55,36 @@ public fun higher(p: &RefPoint): u64 { p.higher } public fun reference(p: &RefPoint): u64 { p.reference } +public fun reference_lower(p: &RefPoint): u64 { p.reference_lower } + +public fun reference_upper(p: &RefPoint): u64 { p.reference_upper } + public fun tolerance(p: &RefPoint): u64 { p.tolerance } -fun pt(lower: u64, higher: u64, reference: u64, tolerance: u64): RefPoint { - RefPoint { lower, higher, reference, tolerance } +public fun expected_center(p: &RefPoint): u64 { p.expected_center } + +fun pt( + lower: u64, + higher: u64, + reference: u64, + reference_lower: u64, + reference_upper: u64, + tolerance: u64, + expected_center: u64, +): RefPoint { + RefPoint { + lower, + higher, + reference, + reference_lower, + reference_upper, + tolerance, + expected_center, + } } /// Number of real-data scenarios. -public fun scenario_count(): u64 { 3 } +public fun scenario_count(): u64 { 4 } /// Worst-case per-endpoint precision budget (units @1e9) over all scenarios/strikes. public fun worst_case_budget(): u64 { 3_129 } @@ -70,6 +103,8 @@ public fun spot(s: u64): u64 { 75_041_662_630_739 } else if (s == 2) { 74_212_635_061_019 + } else if (s == 3) { + 75_000_000_000_000 } else { abort ENoSuchScenario } @@ -83,6 +118,8 @@ public fun forward(s: u64): u64 { 75_044_761_049_821 } else if (s == 2) { 74_212_629_180_749 + } else if (s == 3) { + 75_000_000_000_000 } else { abort ENoSuchScenario } @@ -96,6 +133,8 @@ public fun svi_a(s: u64): u64 { 99_272 } else if (s == 2) { 54_831 + } else if (s == 3) { + 28 } else { abort ENoSuchScenario } @@ -109,6 +148,8 @@ public fun svi_b(s: u64): u64 { 3_466_091 } else if (s == 2) { 2_366_211 + } else if (s == 3) { + 6_251 } else { abort ENoSuchScenario } @@ -122,6 +163,8 @@ public fun svi_sigma(s: u64): u64 { 6_351_738 } else if (s == 2) { 5_354_101 + } else if (s == 3) { + 1_000_000 } else { abort ENoSuchScenario } @@ -135,6 +178,8 @@ public fun svi_rho_magnitude(s: u64): u64 { 475_372_427 } else if (s == 2) { 298_114_517 + } else if (s == 3) { + 940_000_000 } else { abort ENoSuchScenario } @@ -148,6 +193,8 @@ public fun svi_rho_is_negative(s: u64): bool { true } else if (s == 2) { true + } else if (s == 3) { + true } else { abort ENoSuchScenario } @@ -161,6 +208,8 @@ public fun svi_m_magnitude(s: u64): u64 { 3_647_706 } else if (s == 2) { 2_324_961 + } else if (s == 3) { + 48_425 } else { abort ENoSuchScenario } @@ -174,106 +223,433 @@ public fun svi_m_is_negative(s: u64): bool { true } else if (s == 2) { false + } else if (s == 3) { + true } else { abort ENoSuchScenario } } -/// Reference points for scenario `s` (lower, higher, true-math reference, tolerance). +/// Points for scenario `s` (range, true reference enclosure, tolerance, parent center). public fun points(s: u64): vector { if (s == 0) { vector[ // d2~+3.0 adjusted UP(K) skew=+6.045e-04 - pt(68_488_000_000_000, constants::pos_inf!(), 999_254_898, 44), + pt( + 68_488_000_000_000, + constants::pos_inf!(), + 999_254_898, + 999_254_896, + 999_254_901, + 44, + 999_254_898, + ), // d2~+2.0 adjusted UP(K) skew=+9.224e-03 - pt(71_901_000_000_000, constants::pos_inf!(), 986_475_283, 202), + pt( + 71_901_000_000_000, + constants::pos_inf!(), + 986_475_283, + 986_475_280, + 986_475_285, + 202, + 986_475_284, + ), // d2~+1.0 adjusted UP(K) skew=+4.679e-02 - pt(74_265_000_000_000, constants::pos_inf!(), 888_173_139, 732), + pt( + 74_265_000_000_000, + constants::pos_inf!(), + 888_173_139, + 888_173_137, + 888_173_142, + 732, + 888_173_166, + ), // d2~+0.5 adjusted UP(K) skew=+5.726e-02 - pt(75_100_000_000_000, constants::pos_inf!(), 748_850_555, 801), + pt( + 75_100_000_000_000, + constants::pos_inf!(), + 748_850_555, + 748_850_553, + 748_850_558, + 801, + 748_850_600, + ), // d2~+0.0 adjusted UP(K) skew=+2.822e-02 - pt(75_788_000_000_000, constants::pos_inf!(), 528_329_906, 229), + pt( + 75_788_000_000_000, + constants::pos_inf!(), + 528_329_906, + 528_329_904, + 528_329_909, + 229, + 528_329_883, + ), // d2~-0.5 adjusted UP(K) skew=-1.336e-02 - pt(76_433_000_000_000, constants::pos_inf!(), 295_405_051, 713), + pt( + 76_433_000_000_000, + constants::pos_inf!(), + 295_405_051, + 295_405_049, + 295_405_054, + 713, + 295_404_986, + ), // d2~-1.0 adjusted UP(K) skew=-2.440e-02 - pt(77_136_000_000_000, constants::pos_inf!(), 134_304_459, 827), + pt( + 77_136_000_000_000, + constants::pos_inf!(), + 134_304_459, + 134_304_457, + 134_304_462, + 827, + 134_304_491, + ), // d2~-2.0 adjusted UP(K) skew=-6.758e-03 - pt(78_942_000_000_000, constants::pos_inf!(), 16_002_241, 276), + pt( + 78_942_000_000_000, + constants::pos_inf!(), + 16_002_241, + 16_002_239, + 16_002_244, + 276, + 16_002_241, + ), // d2~-3.0 adjusted UP(K) skew=-5.006e-04 - pt(81_484_000_000_000, constants::pos_inf!(), 850_622, 51), + pt( + 81_484_000_000_000, + constants::pos_inf!(), + 850_622, + 850_619, + 850_624, + 51, + 850_622, + ), // clamp wing d2~+8 - pt(40_875_000_000_000, constants::pos_inf!(), 1_000_000_000, 2), + pt( + 40_875_000_000_000, + constants::pos_inf!(), + 1_000_000_000, + 999_999_997, + 1_000_000_000, + 2, + 1_000_000_000, + ), // clamp wing d2~-8 - pt(111_541_000_000_000, constants::pos_inf!(), 0, 2), + pt( + 111_541_000_000_000, + constants::pos_inf!(), + 0, + 0, + 3, + 2, + 0, + ), // (-inf, K_atm] = 1 - adjusted UP(d2) - pt(constants::neg_inf!(), 75_788_000_000_000, 471_670_094, 229), + pt( + constants::neg_inf!(), + 75_788_000_000_000, + 471_670_094, + 471_670_091, + 471_670_096, + 229, + 471_670_117, + ), // (K@d2=+1, K@d2=-1] = adjusted UP(+1)-adjusted UP(-1) - pt(74_265_000_000_000, 77_136_000_000_000, 753_868_680, 1_557), + pt( + 74_265_000_000_000, + 77_136_000_000_000, + 753_868_680, + 753_868_678, + 753_868_683, + 1_557, + 753_868_675, + ), ] } else if (s == 1) { vector[ // d2~+3.0 adjusted UP(K) skew=+5.761e-04 - pt(70_751_000_000_000, constants::pos_inf!(), 999_226_715, 62), + pt( + 70_751_000_000_000, + constants::pos_inf!(), + 999_226_715, + 999_226_713, + 999_226_718, + 62, + 999_226_716, + ), // d2~+2.0 adjusted UP(K) skew=+8.697e-03 - pt(72_731_000_000_000, constants::pos_inf!(), 985_952_951, 440), + pt( + 72_731_000_000_000, + constants::pos_inf!(), + 985_952_951, + 985_952_949, + 985_952_954, + 440, + 985_952_968, + ), // d2~+1.0 adjusted UP(K) skew=+4.378e-02 - pt(74_122_000_000_000, constants::pos_inf!(), 885_252_958, 1_886), + pt( + 74_122_000_000_000, + constants::pos_inf!(), + 885_252_958, + 885_252_955, + 885_252_960, + 1_886, + 885_253_256, + ), // d2~+0.5 adjusted UP(K) skew=+4.241e-02 - pt(74_620_000_000_000, constants::pos_inf!(), 734_089_382, 1_988), + pt( + 74_620_000_000_000, + constants::pos_inf!(), + 734_089_382, + 734_089_380, + 734_089_385, + 1_988, + 734_089_915, + ), // d2~+0.0 adjusted UP(K) skew=-1.020e-03 - pt(75_040_000_000_000, constants::pos_inf!(), 499_130_516, 777), + pt( + 75_040_000_000_000, + constants::pos_inf!(), + 499_130_516, + 499_130_514, + 499_130_519, + 777, + 499_130_085, + ), // d2~-0.5 adjusted UP(K) skew=-1.902e-02 - pt(75_457_000_000_000, constants::pos_inf!(), 289_536_017, 1_603), + pt( + 75_457_000_000_000, + constants::pos_inf!(), + 289_536_017, + 289_536_015, + 289_536_020, + 1_603, + 289_535_897, + ), // d2~-1.0 adjusted UP(K) skew=-1.634e-02 - pt(75_903_000_000_000, constants::pos_inf!(), 142_539_030, 1_559), + pt( + 75_903_000_000_000, + constants::pos_inf!(), + 142_539_030, + 142_539_028, + 142_539_033, + 1_559, + 142_538_929, + ), // d2~-2.0 adjusted UP(K) skew=-3.789e-03 - pt(76_919_000_000_000, constants::pos_inf!(), 19_005_305, 548), + pt( + 76_919_000_000_000, + constants::pos_inf!(), + 19_005_305, + 19_005_303, + 19_005_308, + 548, + 19_005_287, + ), // d2~-3.0 adjusted UP(K) skew=-2.942e-04 - pt(78_125_000_000_000, constants::pos_inf!(), 1_057_214, 81), + pt( + 78_125_000_000_000, + constants::pos_inf!(), + 1_057_214, + 1_057_212, + 1_057_217, + 81, + 1_057_212, + ), // clamp wing d2~+8 - pt(53_193_000_000_000, constants::pos_inf!(), 1_000_000_000, 2), + pt( + 53_193_000_000_000, + constants::pos_inf!(), + 1_000_000_000, + 999_999_997, + 1_000_000_000, + 2, + 1_000_000_000, + ), // clamp wing d2~-8 - pt(87_962_000_000_000, constants::pos_inf!(), 0, 2), + pt( + 87_962_000_000_000, + constants::pos_inf!(), + 0, + 0, + 3, + 2, + 0, + ), // (-inf, K_atm] = 1 - adjusted UP(d2) - pt(constants::neg_inf!(), 75_040_000_000_000, 500_869_484, 777), + pt( + constants::neg_inf!(), + 75_040_000_000_000, + 500_869_484, + 500_869_481, + 500_869_486, + 777, + 500_869_915, + ), // (K@d2=+1, K@d2=-1] = adjusted UP(+1)-adjusted UP(-1) - pt(74_122_000_000_000, 75_903_000_000_000, 742_713_927, 3_443), + pt( + 74_122_000_000_000, + 75_903_000_000_000, + 742_713_927, + 742_713_925, + 742_713_930, + 3_443, + 742_714_327, + ), ] } else if (s == 2) { vector[ // d2~+3.0 adjusted UP(K) skew=+4.899e-04 - pt(71_198_000_000_000, constants::pos_inf!(), 999_142_870, 85), + pt( + 71_198_000_000_000, + constants::pos_inf!(), + 999_142_870, + 999_142_868, + 999_142_873, + 85, + 999_142_871, + ), // d2~+2.0 adjusted UP(K) skew=+7.022e-03 - pt(72_504_000_000_000, constants::pos_inf!(), 984_281_878, 673), + pt( + 72_504_000_000_000, + constants::pos_inf!(), + 984_281_878, + 984_281_876, + 984_281_881, + 673, + 984_281_911, + ), // d2~+1.0 adjusted UP(K) skew=+3.565e-02 - pt(73_490_000_000_000, constants::pos_inf!(), 877_147_807, 2_326), + pt( + 73_490_000_000_000, + constants::pos_inf!(), + 877_147_807, + 877_147_805, + 877_147_810, + 2_326, + 877_147_899, + ), // d2~+0.5 adjusted UP(K) skew=+5.049e-02 - pt(73_878_000_000_000, constants::pos_inf!(), 741_986_661, 2_645), + pt( + 73_878_000_000_000, + constants::pos_inf!(), + 741_986_661, + 741_986_659, + 741_986_664, + 2_645, + 741_986_937, + ), // d2~+0.0 adjusted UP(K) skew=+3.948e-02 - pt(74_210_000_000_000, constants::pos_inf!(), 539_490_779, 1_293), + pt( + 74_210_000_000_000, + constants::pos_inf!(), + 539_490_779, + 539_490_777, + 539_490_782, + 1_293, + 539_491_163, + ), // d2~-0.5 adjusted UP(K) skew=-4.576e-04 - pt(74_514_000_000_000, constants::pos_inf!(), 308_261_559, 2_564), + pt( + 74_514_000_000_000, + constants::pos_inf!(), + 308_261_559, + 308_261_556, + 308_261_561, + 2_564, + 308_261_500, + ), // d2~-1.0 adjusted UP(K) skew=-1.534e-02 - pt(74_831_000_000_000, constants::pos_inf!(), 143_600_509, 3_129), + pt( + 74_831_000_000_000, + constants::pos_inf!(), + 143_600_509, + 143_600_506, + 143_600_511, + 3_129, + 143_599_998, + ), // d2~-2.0 adjusted UP(K) skew=-4.552e-03 - pt(75_576_000_000_000, constants::pos_inf!(), 18_232_347, 1_005), + pt( + 75_576_000_000_000, + constants::pos_inf!(), + 18_232_347, + 18_232_344, + 18_232_349, + 1_005, + 18_232_308, + ), // d2~-3.0 adjusted UP(K) skew=-3.550e-04 - pt(76_497_000_000_000, constants::pos_inf!(), 998_121, 124), + pt( + 76_497_000_000_000, + constants::pos_inf!(), + 998_121, + 998_118, + 998_123, + 124, + 998_121, + ), // clamp wing d2~+8 - pt(59_811_000_000_000, constants::pos_inf!(), 1_000_000_000, 2), + pt( + 59_811_000_000_000, + constants::pos_inf!(), + 1_000_000_000, + 999_999_997, + 1_000_000_000, + 2, + 1_000_000_000, + ), // clamp wing d2~-8 - pt(84_603_000_000_000, constants::pos_inf!(), 0, 2), + pt( + 84_603_000_000_000, + constants::pos_inf!(), + 0, + 0, + 3, + 2, + 0, + ), // (-inf, K_atm] = 1 - adjusted UP(d2) - pt(constants::neg_inf!(), 74_210_000_000_000, 460_509_221, 1_293), + pt( + constants::neg_inf!(), + 74_210_000_000_000, + 460_509_221, + 460_509_218, + 460_509_223, + 1_293, + 460_508_837, + ), // (K@d2=+1, K@d2=-1] = adjusted UP(+1)-adjusted UP(-1) - pt(73_490_000_000_000, 74_831_000_000_000, 733_547_298, 5_452), + pt( + 73_490_000_000_000, + 74_831_000_000_000, + 733_547_298, + 733_547_296, + 733_547_301, + 5_452, + 733_547_901, + ), + ] + } else if (s == 3) { + vector[ + // short-dated w~3.26e-8; 0.1% mint-price policy regression + pt( + 75_022_000_000_000, + constants::pos_inf!(), + 53_270_030, + 53_270_028, + 53_270_033, + 53_271, + 53_264_202, + ), ] } else { abort ENoSuchScenario } } -/// Provenance: svi_event_digest of the source CSV row for scenario `s`. +/// Provenance source identifier (CSV event digest or short-dated surface ID). public fun svi_event_digest(s: u64): vector { if (s == 0) { b"5KbNiu2S7ULJcS1ryDtJ3DC2omTojjJoMFjmu7nYgTAF9" @@ -281,6 +657,8 @@ public fun svi_event_digest(s: u64): vector { b"H4DNoM3eRw83KdZjASFabLJSgu7YNZYRNfCWErcKgnE59" } else if (s == 2) { b"357n4TarJkp62atdMpfGExEr77SZGqnDBZ7QcBatgpUF9" + } else if (s == 3) { + b"surface_1782548400000" } else { abort ENoSuchScenario } diff --git a/packages/predict/tests/registry_create_tests.move b/packages/predict/tests/registry_create_tests.move index 447065952..8d31e27b3 100644 --- a/packages/predict/tests/registry_create_tests.move +++ b/packages/predict/tests/registry_create_tests.move @@ -406,7 +406,7 @@ fun cadence_configs_two_underlyings_isolated() { #[test, expected_failure(abort_code = market_manager::EUnderlyingNotRegistered)] fun cadence_configs_unregistered_underlying_aborts() { - let (_scenario, reg, config, admin_cap) = test_helpers::begin_registry_test(); + let (_scenario, reg, _config, _admin_cap) = test_helpers::begin_registry_test(); let _configs = reg.cadence_configs(UNDERLYING_BTC); abort EUnexpectedSuccess diff --git a/packages/predict/tests/strike_exposure/index/payout_tree_walk_tests.move b/packages/predict/tests/strike_exposure/index/payout_tree_walk_tests.move index 5090d5f58..f3e34a455 100644 --- a/packages/predict/tests/strike_exposure/index/payout_tree_walk_tests.move +++ b/packages/predict/tests/strike_exposure/index/payout_tree_walk_tests.move @@ -38,6 +38,8 @@ const Q0: u64 = 10_000_000_000; const Q1: u64 = 2_000_000_000; const Q2: u64 = 2_000_000_000; const ADJACENT_QUANTITY: u64 = 5_000_000_000; +const CORRELATED_LEFT_QUANTITY: u64 = 5_000_000_000; +const CORRELATED_RIGHT_QUANTITY: u64 = 4_000_000_000; /// Forward far above the grid so low strikes sit in the deep-ITM flat price tail /// where adjacent ticks price within a floor bucket — the dust-underflow regime. const FLAT_REGION_FORWARD: u64 = 435_000_000_000; @@ -89,12 +91,12 @@ fun walk_linear_caches_boundaries_in_tick_order_for_range_lookup() { // Insertion order is intentionally not sorted. The in-order walk must still // cache ascending ticks, because `cached_range_price` uses binary search. let mut memo = pricing::new_price_memo(); - let walk = tree.walk_linear(&pricer, &mut memo, tick_size()); + let walk = tree.walk_linear(&pricer, &mut memo, tick_size()).magnitude(); assert_eq!(walk, up_reference(&pricer, vector[t0, t1, t2], vector[Q0, Q1, Q2])); - assert_eq!(memo.cached_range_price(t0, t2), pricer.range_price(raw(t0), raw(t2))); - assert_eq!(memo.cached_range_price(0, t0), pricer.range_price(raw(0), raw(t0))); + assert_eq!(memo.cached_range_price(t0, t2).magnitude(), pricer.range_price(raw(t0), raw(t2))); + assert_eq!(memo.cached_range_price(0, t0).magnitude(), pricer.range_price(raw(0), raw(t0))); assert_eq!( - memo.cached_range_price(t2, constants::pos_inf_tick!()), + memo.cached_range_price(t2, constants::pos_inf_tick!()).magnitude(), pricer.range_price(raw(t2), raw(constants::pos_inf_tick!())), ); @@ -115,7 +117,7 @@ fun skip_zero_delta_keeps_adjacent_live_ranges_exact() { tree.insert_range(t1, t2, ADJACENT_QUANTITY, 0); let mut memo = pricing::new_price_memo(); - let walk = tree.walk_linear(&pricer, &mut memo, tick_size()); + let walk = tree.walk_linear(&pricer, &mut memo, tick_size()).magnitude(); assert_eq!( walk, range_reference( @@ -127,8 +129,47 @@ fun skip_zero_delta_keeps_adjacent_live_ranges_exact() { ); // The shared boundary has equal start/end quantity and contributes no net // linear value, but it must still be cached for leveraged correction lookups. - assert_eq!(memo.cached_range_price(t0, t1), pricer.range_price(raw(t0), raw(t1))); - assert_eq!(memo.cached_range_price(t1, t2), pricer.range_price(raw(t1), raw(t2))); + assert_eq!(memo.cached_range_price(t0, t1).magnitude(), pricer.range_price(raw(t0), raw(t1))); + assert_eq!(memo.cached_range_price(t1, t2).magnitude(), pricer.range_price(raw(t1), raw(t2))); + + destroy(tree); + cleanup(fixture, oracle); +} + +#[test] +fun shared_boundary_error_scales_with_net_not_gross_quantity() { + let (mut fixture, oracle, pricer) = live_pricer(); + let mut tree = strike_payout_tree::new(fixture.scenario_mut().ctx()); + let (t0, t1, t2) = clustered_ticks(); + tree.insert_range(t0, t1, CORRELATED_LEFT_QUANTITY, 0); + tree.insert_range(t1, t2, CORRELATED_RIGHT_QUANTITY, 0); + + let mut memo = pricing::new_price_memo(); + let approximate = tree.walk_linear(&pricer, &mut memo, tick_size()); + assert_eq!( + approximate.magnitude(), + range_reference( + &pricer, + vector[t0, t1], + vector[t1, t2], + vector[CORRELATED_LEFT_QUANTITY, CORRELATED_RIGHT_QUANTITY], + ), + ); + + let shared_net = CORRELATED_LEFT_QUANTITY - CORRELATED_RIGHT_QUANTITY; + let expected_shared = expected_boundary_error(&memo, t1, shared_net); + let uncorrelated_shared = expected_boundary_error( + &memo, + t1, + CORRELATED_LEFT_QUANTITY + CORRELATED_RIGHT_QUANTITY, + ); + assert!(expected_shared < uncorrelated_shared); + assert_eq!( + approximate.error(), + expected_boundary_error(&memo, t0, CORRELATED_LEFT_QUANTITY) + + expected_shared + + expected_boundary_error(&memo, t2, CORRELATED_RIGHT_QUANTITY), + ); destroy(tree); cleanup(fixture, oracle); @@ -222,7 +263,14 @@ fun raw(tick: u64): Strike { range_codec::strike_from_tick(tick, tick_size()) } /// Run the exact linear walk with the production price memo. fun walk_linear(tree: &StrikePayoutTree, pricer: &Pricer): u64 { let mut memo = pricing::new_price_memo(); - tree.walk_linear(pricer, &mut memo, tick_size()) + tree.walk_linear(pricer, &mut memo, tick_size()).magnitude() +} + +/// Independent error budget for one boundary with one shared uncertain UP price: +/// `ceil(price_error * |start-end| / 1e9)` plus two product-floor units. +fun expected_boundary_error(memo: &pricing::PriceMemo, tick: u64, net_quantity: u64): u64 { + let price = memo.cached_range_price(tick, constants::pos_inf_tick!()); + math::mul_div_up(price.error(), net_quantity, math::float_scaling!()) + 2 } /// Three adjacent finite ticks around the canonical finite strike (100, 101, 102).