From 13c1b6f80a9774cdca010bd33583ddb17cd19dcf Mon Sep 17 00:00:00 2001 From: Aslan Tashtanov Date: Wed, 15 Jul 2026 09:56:58 -0400 Subject: [PATCH 1/4] predict: introduce expiry valuation boundary (DBU-557) --- packages/predict/sources/expiry_market.move | 38 +++++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/packages/predict/sources/expiry_market.move b/packages/predict/sources/expiry_market.move index d3d1a0012..dd28ff250 100644 --- a/packages/predict/sources/expiry_market.move +++ b/packages/predict/sources/expiry_market.move @@ -74,6 +74,15 @@ public struct ExpiryMarket has key { mint_paused: bool, } +/// Market-owned facts used to value one expiry independently. +/// `liability_uncertainty` is the absolute error allowance around +/// `estimated_liability`; it is zero while liability is computed exactly. +public struct ExpiryValuation has copy, drop { + free_cash: u64, + estimated_liability: u64, + liability_uncertainty: u64, +} + /// Read-only all-in cost quote for a prospective live mint, in DUSDC base units. /// `trading_fee` is the post-stake-discount fee before the sponsor subsidy, and /// `all_in_cost` is the exact account withdrawal the same-state mint would make: @@ -245,15 +254,14 @@ public fun load_live_pricer( /// exact spot exists yet, the live-pricing liveness abort remains the correct /// failure mode. public fun current_nav(market: &ExpiryMarket, pricer: &Pricer): u64 { - market.assert_pricer_bound(pricer); - let liability = market.strike_exposure.exact_live_liability(pricer); + let valuation = market.current_valuation(pricer); // Floor at 0 rather than abort: a degenerate underwater market has zero // limited-recourse value, and partial-close `walk_linear` survivors can leave // residual ulp dust that makes liability exceed free cash by ~1-2 ulp/order. // This is a ROUNDING_POLICY R1/R2 liveness/dust clamp, not a conservative // supply mark: a lower pool mark would mint more PLP to new suppliers, so the // exact-mark invariant remains the governing safety property. - market.cash.free_cash().saturating_sub(liability) + valuation.free_cash.saturating_sub(valuation.estimated_liability) } /// Return the live holder value of one order, gross of fees. @@ -697,6 +705,30 @@ public fun set_mint_paused( // === Public-Package Functions === +/// Return this market's current valuation facts. Liability is exact and +/// uncertainty is zero. Introducing an estimate requires migrating +/// `current_nav` and its PLP consumer in the same change. +public(package) fun current_valuation(market: &ExpiryMarket, pricer: &Pricer): ExpiryValuation { + market.assert_pricer_bound(pricer); + ExpiryValuation { + free_cash: market.cash.free_cash(), + estimated_liability: market.strike_exposure.exact_live_liability(pricer), + liability_uncertainty: 0, + } +} + +public(package) fun free_cash(valuation: &ExpiryValuation): u64 { + valuation.free_cash +} + +public(package) fun estimated_liability(valuation: &ExpiryValuation): u64 { + valuation.estimated_liability +} + +public(package) fun liability_uncertainty(valuation: &ExpiryValuation): u64 { + valuation.liability_uncertainty +} + /// Ensure terminal settlement has been recorded if Propbook has an exact Pyth spot /// at this market's expiry timestamp. Returns whether the market is settled after /// the attempt. This is the canonical passive settlement gate used immediately From 0a06c7fb7f7efb0dd2b4ed4bee2f1593576a34ea Mon Sep 17 00:00:00 2001 From: Aslan Tashtanov Date: Wed, 15 Jul 2026 10:00:15 -0400 Subject: [PATCH 2/4] predict: consume expiry valuation in PLP (DBU-557) --- packages/predict/sources/plp/plp.move | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/predict/sources/plp/plp.move b/packages/predict/sources/plp/plp.move index f94c62718..90ccc89bc 100644 --- a/packages/predict/sources/plp/plp.move +++ b/packages/predict/sources/plp/plp.move @@ -272,7 +272,8 @@ public fun value_expiry( bs_svi, clock, ); - market.current_nav(&pricer) + let market_valuation = market.current_valuation(&pricer); + market_valuation.free_cash().saturating_sub(market_valuation.estimated_liability()) }; valuation.valued_expiry_markets.push_back(expiry_market_id); valuation.total_nav = valuation.total_nav + nav; From c4e6719adc3e0fe8f90b63bbd55aba50d6b5ca08 Mon Sep 17 00:00:00 2001 From: Aslan Tashtanov Date: Wed, 15 Jul 2026 10:13:05 -0400 Subject: [PATCH 3/4] predict: store exact expiry NAV marks (DBU-557) --- packages/predict/sources/expiry_market.move | 75 +++++++++++--------- packages/predict/sources/plp/plp.move | 76 ++++----------------- 2 files changed, 57 insertions(+), 94 deletions(-) diff --git a/packages/predict/sources/expiry_market.move b/packages/predict/sources/expiry_market.move index dd28ff250..6103abf1e 100644 --- a/packages/predict/sources/expiry_market.move +++ b/packages/predict/sources/expiry_market.move @@ -51,6 +51,19 @@ const EReferenceTickTimestampMismatch: u64 = 9; const EMintRedeemSameTimestamp: u64 = 10; const ERedeemProbabilityBelowMin: u64 = 11; const ERedeemProceedsBelowMin: u64 = 12; +const ENavMarkMissing: u64 = 13; +const ENavMarkStale: u64 = 14; +const ENavMarkExpired: u64 = 15; + +macro fun nav_mark_max_age_ms(): u64 { + 3_000 +} + +/// Exact live liability recorded by a full exposure walk. +public struct NavMark has copy, drop, store { + liability: u64, + computed_at_ms: u64, +} /// Per-expiry market state. public struct ExpiryMarket has key { @@ -66,6 +79,8 @@ public struct ExpiryMarket has key { fee_incentive_balance: Balance, /// Exposure lifecycle state for this expiry's strike ticks. strike_exposure: StrikeExposure, + /// Most recent exact live-liability walk, if this market has been refreshed. + nav_mark: Option, /// Smoothed gas-price stats backing the congestion trade penalty. ewma: EwmaState, /// When true, new mints on this expiry abort. Other flows stay available. @@ -74,15 +89,6 @@ public struct ExpiryMarket has key { mint_paused: bool, } -/// Market-owned facts used to value one expiry independently. -/// `liability_uncertainty` is the absolute error allowance around -/// `estimated_liability`; it is zero while liability is computed exactly. -public struct ExpiryValuation has copy, drop { - free_cash: u64, - estimated_liability: u64, - liability_uncertainty: u64, -} - /// Read-only all-in cost quote for a prospective live mint, in DUSDC base units. /// `trading_fee` is the post-stake-discount fee before the sponsor subsidy, and /// `all_in_cost` is the exact account withdrawal the same-state mint would make: @@ -254,14 +260,15 @@ public fun load_live_pricer( /// exact spot exists yet, the live-pricing liveness abort remains the correct /// failure mode. public fun current_nav(market: &ExpiryMarket, pricer: &Pricer): u64 { - let valuation = market.current_valuation(pricer); + market.assert_pricer_bound(pricer); + let liability = market.strike_exposure.exact_live_liability(pricer); // Floor at 0 rather than abort: a degenerate underwater market has zero // limited-recourse value, and partial-close `walk_linear` survivors can leave // residual ulp dust that makes liability exceed free cash by ~1-2 ulp/order. // This is a ROUNDING_POLICY R1/R2 liveness/dust clamp, not a conservative // supply mark: a lower pool mark would mint more PLP to new suppliers, so the // exact-mark invariant remains the governing safety property. - valuation.free_cash.saturating_sub(valuation.estimated_liability) + market.cash.free_cash().saturating_sub(liability) } /// Return the live holder value of one order, gross of fees. @@ -376,6 +383,19 @@ public fun all_in_cost(quote: &MintQuote): u64 { quote.all_in_cost } +/// Walk every live exposure and replace this market's exact liability mark. +/// Later market activity does not invalidate the mark; flush consumers bound its +/// age and knowingly accept activity and oracle movement inside that window. +public fun refresh_nav(market: &mut ExpiryMarket, pricer: &Pricer, clock: &Clock) { + market.assert_pricer_bound(pricer); + let liability = market.strike_exposure.exact_live_liability(pricer); + market.nav_mark = + option::some(NavMark { + liability, + computed_at_ms: clock.timestamp_ms(), + }); +} + /// Mint an exact live position quantity against this expiry market. /// /// Requires the running package version to be at or above the protocol version @@ -705,28 +725,16 @@ public fun set_mint_paused( // === Public-Package Functions === -/// Return this market's current valuation facts. Liability is exact and -/// uncertainty is zero. Introducing an estimate requires migrating -/// `current_nav` and its PLP consumer in the same change. -public(package) fun current_valuation(market: &ExpiryMarket, pricer: &Pricer): ExpiryValuation { - market.assert_pricer_bound(pricer); - ExpiryValuation { - free_cash: market.cash.free_cash(), - estimated_liability: market.strike_exposure.exact_live_liability(pricer), - liability_uncertainty: 0, - } -} - -public(package) fun free_cash(valuation: &ExpiryValuation): u64 { - valuation.free_cash -} - -public(package) fun estimated_liability(valuation: &ExpiryValuation): u64 { - valuation.estimated_liability -} - -public(package) fun liability_uncertainty(valuation: &ExpiryValuation): u64 { - valuation.liability_uncertainty +/// Return current free cash minus the most recent exact marked liability, floored +/// at zero. Aborts when no mark exists, the mark is older than three seconds, or +/// the market has reached expiry and must be settled and swept before the flush. +public(package) fun marked_nav(market: &ExpiryMarket, clock: &Clock): u64 { + let now_ms = clock.timestamp_ms(); + assert!(now_ms < market.expiry, ENavMarkExpired); + assert!(market.nav_mark.is_some(), ENavMarkMissing); + let mark = market.nav_mark.borrow(); + assert!(now_ms - mark.computed_at_ms <= nav_mark_max_age_ms!(), ENavMarkStale); + market.cash.free_cash().saturating_sub(mark.liability) } /// Ensure terminal settlement has been recorded if Propbook has an exact Pyth spot @@ -884,6 +892,7 @@ public(package) fun create_and_share( strike_exposure_config, ctx, ), + nav_mark: option::none(), ewma: ewma::new(ctx), mint_paused: false, }; diff --git a/packages/predict/sources/plp/plp.move b/packages/predict/sources/plp/plp.move index 90ccc89bc..6e6d49de2 100644 --- a/packages/predict/sources/plp/plp.move +++ b/packages/predict/sources/plp/plp.move @@ -30,13 +30,7 @@ use deepbook_predict::{ }; use dusdc::dusdc::DUSDC; use fixed_math::math; -use propbook::{ - block_scholes_forward_feed::BlockScholesForwardFeed, - block_scholes_spot_feed::BlockScholesSpotFeed, - block_scholes_svi_feed::BlockScholesSVIFeed, - pyth_feed::PythFeed, - registry::OracleRegistry -}; +use propbook::{pyth_feed::PythFeed, registry::OracleRegistry}; use sui::{ accumulator::AccumulatorRoot, balance::{Self, Balance}, @@ -80,10 +74,10 @@ public struct PoolVault has key { /// Transaction-local full-pool NAV valuation hot potato. /// /// `start_pool_valuation` snapshots the active expiry set; each `value_expiry` -/// runs the per-market cash flush then folds that market's NAV into `total_nav` -/// exactly once (a swept settled market contributes 0); `finish_flush` proves every -/// snapshotted market was valued, returns the LP-attributable pool NAV, and drains -/// the LP queues against it. Has no abilities, so it must be consumed by the finisher. +/// reads one market's recent stored mark and folds its NAV into `total_nav` exactly +/// once; `finish_flush` proves every snapshotted market was valued, returns the +/// LP-attributable pool NAV, and drains the LP queues against it. Has no abilities, +/// so it must be consumed by the finisher. public struct PoolValuation { pool_vault_id: ID, /// Active expiry markets snapshotted at start; every one must be valued. @@ -206,18 +200,10 @@ public fun pending_protocol_profit(vault: &PoolVault): u64 { /// Begin a full-pool flush (NAV valuation + LP queue drain) as a market deployer, /// using a registry-generated `MarketLifecycleProof`. This is the sole flush start: /// it is cron-driven and PRIVILEGED, not permissionless (audit L8). Engages the -/// protocol valuation lock — so no NAV-changing op can interleave between value -/// steps — and snapshots the active expiry set every `value_expiry` must cover. The -/// hot potato can only be created here, so gating the start gates the whole flush. -/// -/// The flush prices the pool NAV off the live oracle and `finish_flush` drains the -/// LP queues at that mark, and Pyth updates (`pyth_feed::update`) are permissionless -/// — so a flush-capable cap-holder who manipulates the live oracle in a preceding -/// tx, then flushes, could fill their own queued supply/withdraw request at a mark -/// they chose. The start is therefore gated on both current registry allowlisting -/// and trust in every flush-capable holder not to manipulate the live oracle. The -/// revocable `MarketLifecycleCap` (not the root `AdminCap`) carries this authority; -/// admin retains a break-glass route by minting itself a lifecycle cap. +/// protocol valuation lock and snapshots the active expiry set every `value_expiry` +/// must cover. The hot potato can only be created here, so gating the start gates +/// the whole flush. Each market must have independently stored an exact liability +/// mark no more than three seconds earlier. public fun start_pool_valuation( config: &mut ProtocolConfig, vault: &PoolVault, @@ -228,53 +214,21 @@ public fun start_pool_valuation( start_pool_valuation_internal(config, vault) } -/// Run the per-market cash flow for one snapshotted market, then fold its NAV into -/// the running total. The market must be in the snapshot and not already valued -/// (the exactly-once proof). The flush IS the valuation: a settled market is swept -/// (deactivated, cash returned, profit materialized) and contributes 0; a live -/// market is rebalanced to target and valued on its current cash. -/// -/// Before branching, this passively records terminal settlement from Propbook's -/// exact Pyth timestamp if available: a past-expiry market is normally settled here -/// and swept (contributing 0), so `current_nav` is only reached for a still-live -/// market. Only in the bounded pending-settlement window (past expiry but the -/// exact-expiry spot not yet inserted) does the live branch still abort through -/// `current_nav`; there is no solvency-safe substitute mark for an unsettled expired -/// market, and the abort clears once anyone lands the exact spot. +/// Fold one snapshotted market's recent stored NAV into the running total. The +/// market is read-only and must be in the snapshot and not already valued. Cash +/// rebalance, settlement, and sweeping are standalone prerequisites; an expired +/// market or a mark older than three seconds aborts this flush. public fun value_expiry( valuation: &mut PoolValuation, - vault: &mut PoolVault, - market: &mut ExpiryMarket, + market: &ExpiryMarket, config: &ProtocolConfig, - propbook_registry: &OracleRegistry, - pyth: &PythFeed, - bs_spot: &BlockScholesSpotFeed, - bs_forward: &BlockScholesForwardFeed, - bs_svi: &BlockScholesSVIFeed, clock: &Clock, ) { config.assert_version(); config.assert_valuation_in_progress(); let expiry_market_id = market.id(); valuation.assert_expiry_ready_to_value(expiry_market_id); - vault.expiry_accounting.assert_registered_expiry(expiry_market_id); - let nav = if ( - vault.settle_or_rebalance_expiry(market, config, propbook_registry, pyth, clock) - ) { - 0 - } else { - let pricer = market.load_live_pricer( - config, - propbook_registry, - pyth, - bs_spot, - bs_forward, - bs_svi, - clock, - ); - let market_valuation = market.current_valuation(&pricer); - market_valuation.free_cash().saturating_sub(market_valuation.estimated_liability()) - }; + let nav = market.marked_nav(clock); valuation.valued_expiry_markets.push_back(expiry_market_id); valuation.total_nav = valuation.total_nav + nav; } From 475158b8922170db0ac58493be3c8f6e7c9d118c Mon Sep 17 00:00:00 2001 From: Aslan Tashtanov Date: Wed, 15 Jul 2026 10:20:37 -0400 Subject: [PATCH 4/4] predict: move NAV mark acceptance policy into plp (DBU-557) The expiry market now only stores and reports its mark (NavMark, free cash, liability, computed_at_ms); the flush consumer in plp owns the staleness window, the missing-mark and expired-market aborts, and the per-market zero floor. Co-Authored-By: Claude Fable 5 --- packages/predict/sources/expiry_market.move | 36 +++++++++++---------- packages/predict/sources/plp/plp.move | 18 ++++++++++- 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/packages/predict/sources/expiry_market.move b/packages/predict/sources/expiry_market.move index 6103abf1e..9cde9bb6d 100644 --- a/packages/predict/sources/expiry_market.move +++ b/packages/predict/sources/expiry_market.move @@ -51,13 +51,6 @@ const EReferenceTickTimestampMismatch: u64 = 9; const EMintRedeemSameTimestamp: u64 = 10; const ERedeemProbabilityBelowMin: u64 = 11; const ERedeemProceedsBelowMin: u64 = 12; -const ENavMarkMissing: u64 = 13; -const ENavMarkStale: u64 = 14; -const ENavMarkExpired: u64 = 15; - -macro fun nav_mark_max_age_ms(): u64 { - 3_000 -} /// Exact live liability recorded by a full exposure walk. public struct NavMark has copy, drop, store { @@ -725,16 +718,25 @@ public fun set_mint_paused( // === Public-Package Functions === -/// Return current free cash minus the most recent exact marked liability, floored -/// at zero. Aborts when no mark exists, the mark is older than three seconds, or -/// the market has reached expiry and must be settled and swept before the flush. -public(package) fun marked_nav(market: &ExpiryMarket, clock: &Clock): u64 { - let now_ms = clock.timestamp_ms(); - assert!(now_ms < market.expiry, ENavMarkExpired); - assert!(market.nav_mark.is_some(), ENavMarkMissing); - let mark = market.nav_mark.borrow(); - assert!(now_ms - mark.computed_at_ms <= nav_mark_max_age_ms!(), ENavMarkStale); - market.cash.free_cash().saturating_sub(mark.liability) +/// Return the most recent stored liability mark, if this market has been refreshed. +/// Acceptance policy (mark age, expiry handling) is owned by the consumer. +public(package) fun nav_mark(market: &ExpiryMarket): Option { + market.nav_mark +} + +/// Return DUSDC free cash currently held by this expiry. +public(package) fun free_cash(market: &ExpiryMarket): u64 { + market.cash.free_cash() +} + +/// Return the marked exact live liability. +public(package) fun liability(mark: &NavMark): u64 { + mark.liability +} + +/// Return when the marked liability walk ran, in `clock.timestamp_ms()` time. +public(package) fun computed_at_ms(mark: &NavMark): u64 { + mark.computed_at_ms } /// Ensure terminal settlement has been recorded if Propbook has an exact Pyth spot diff --git a/packages/predict/sources/plp/plp.move b/packages/predict/sources/plp/plp.move index 6e6d49de2..2e4dc3ea7 100644 --- a/packages/predict/sources/plp/plp.move +++ b/packages/predict/sources/plp/plp.move @@ -50,6 +50,14 @@ const EBelowMinBootstrapLiquidity: u64 = 6; const EBelowMinFeeIncentiveSponsorship: u64 = 7; const EMarketNotSettled: u64 = 8; const EMaxLiveExpiryMarketsExceeded: u64 = 9; +const ENavMarkMissing: u64 = 10; +const ENavMarkStale: u64 = 11; +const ENavMarkExpired: u64 = 12; + +/// Oldest liability mark this pool's flush accepts as a market's NAV input. +macro fun nav_mark_max_age_ms(): u64 { + 3_000 +} /// One-time witness type for Predict LP token registration. public struct PLP has drop {} @@ -228,7 +236,15 @@ public fun value_expiry( config.assert_valuation_in_progress(); let expiry_market_id = market.id(); valuation.assert_expiry_ready_to_value(expiry_market_id); - let nav = market.marked_nav(clock); + let now_ms = clock.timestamp_ms(); + assert!(now_ms < market.expiry(), ENavMarkExpired); + let mark = market.nav_mark(); + assert!(mark.is_some(), ENavMarkMissing); + let mark = mark.destroy_some(); + assert!(now_ms - mark.computed_at_ms() <= nav_mark_max_age_ms!(), ENavMarkStale); + // Floor at 0, not abort: an underwater market has zero limited-recourse value + // (full rationale on `expiry_market::current_nav`). + let nav = market.free_cash().saturating_sub(mark.liability()); valuation.valued_expiry_markets.push_back(expiry_market_id); valuation.total_nav = valuation.total_nav + nav; }