diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fda7adf..07a6002c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this repository are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [2026-08-04] - Oracle readers reject prices from before a cluster restart + +### Added + +- The three oracle-priced finance examples (`finance/lending`, `finance/prop-amm`, `finance/perpetual-futures`, Anchor and Quasar variants) now reject an oracle price stamped at or before the `LastRestartSlot` sysvar's slot, with a dedicated error (`PricePredatesRestart` / `PRICE_PREDATES_RESTART`) and a test per variant. A cluster halt stops the slot count but not the wall clock, so after a restart a feed can pass a slot-measured staleness bound while its price is hours old; the market pauses valuation until the publisher posts again. quasar-lang ships no LastRestartSlot sysvar, so each Quasar variant declares the 8-byte layout in `src/last_restart.rs` and reads it via `sol_get_sysvar`. + +### Fixed + +- The three Quasar variants pin `zeropod = "=0.3.3"`: zeropod 0.3.4 moved to wincode 0.5 while quasar-lang's pinned rev stays on wincode 0.4, so any fresh resolve (these projects commit no lockfile) split the graph across two wincode versions and failed every `Pod*` trait bound. + ## [2026-07-23] - Metadata examples on Quasar 0.1.0 (vendored quasar-metadata) ### Added diff --git a/finance/lending/anchor/CHANGELOG.md b/finance/lending/anchor/CHANGELOG.md index 369a7f2b..8b95d1d5 100644 --- a/finance/lending/anchor/CHANGELOG.md +++ b/finance/lending/anchor/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 2026-08-04 + +Reject oracle prices from before a cluster restart. A halt stops the slot +count but not the wall clock, so after a restart a feed can look fresh in +slots while its price is hours old. `price_scaled` now also requires the +feed's slot to be after the `LastRestartSlot` sysvar's slot +(`PricePredatesRestart`), pausing valuation until the publisher posts again. +Tested by `borrow_with_price_from_before_a_restart_is_rejected`. + ## 0.1.0 Initial lending program: a Kamino/Solend-style borrow/lend market. diff --git a/finance/lending/anchor/README.md b/finance/lending/anchor/README.md index 8b46c6c3..2891f81d 100644 --- a/finance/lending/anchor/README.md +++ b/finance/lending/anchor/README.md @@ -108,7 +108,10 @@ round-trips. `PriceFeed` mirrors a Switchboard On-Demand pull feed: a signed mantissa, an exponent (`price = mantissa * 10^exponent`), and the slot the price was written. Freshness is checked in **slots** (`MAX_PRICE_STALENESS_SLOTS`), not wall-clock -time. The feed PDA is seeded by `[b"price_feed", market, mint]` (scoped to a +time, plus one check slots alone cannot make: a cluster restart passes hours of +wall-clock time in zero slots, so `price_scaled` also rejects any price stamped +at or before the `LastRestartSlot` sysvar's slot, pausing valuation until the +publisher posts again. The feed PDA is seeded by `[b"price_feed", market, mint]` (scoped to a market, not to any individual) and only that market's `owner` may write it (`set_price` checks `has_one = owner`). So prices can't be squatted, a reserve trusts exactly its own market's feed for the mint, and isolated markets can diff --git a/finance/lending/anchor/programs/lending/Cargo.toml b/finance/lending/anchor/programs/lending/Cargo.toml index da36509a..2e7be641 100644 --- a/finance/lending/anchor/programs/lending/Cargo.toml +++ b/finance/lending/anchor/programs/lending/Cargo.toml @@ -23,6 +23,10 @@ custom-panic = [] # init-if-needed: the obligation share vault and the test price feed are created lazily. anchor-lang = { version = "1.1.2", features = ["init-if-needed"] } anchor-spl = "1.1.2" +# For the LastRestartSlot sysvar (not re-exported by anchor-lang): the price +# feed rejects prices from before a cluster restart. Same major as the +# solana-sysvar anchor-lang itself uses, so only one copy is compiled in. +solana-sysvar = "3" [dev-dependencies] litesvm = "0.13.1" diff --git a/finance/lending/anchor/programs/lending/src/errors.rs b/finance/lending/anchor/programs/lending/src/errors.rs index 332906a2..c9c14671 100644 --- a/finance/lending/anchor/programs/lending/src/errors.rs +++ b/finance/lending/anchor/programs/lending/src/errors.rs @@ -18,6 +18,8 @@ pub enum LendingError { ObligationStale, #[msg("Price feed has not been updated recently enough")] StalePriceFeed, + #[msg("Price feed is stale: it predates the last cluster restart")] + PricePredatesRestart, #[msg("Price feed reported a non-positive price")] InvalidOraclePrice, #[msg("Borrow would exceed the obligation's allowed borrow value")] diff --git a/finance/lending/anchor/programs/lending/src/state/price_feed.rs b/finance/lending/anchor/programs/lending/src/state/price_feed.rs index aa4f4785..6d7cc8b8 100644 --- a/finance/lending/anchor/programs/lending/src/state/price_feed.rs +++ b/finance/lending/anchor/programs/lending/src/state/price_feed.rs @@ -1,4 +1,5 @@ use anchor_lang::prelude::*; +use solana_sysvar::last_restart_slot::LastRestartSlot; use crate::constants::MAX_PRICE_STALENESS_SLOTS; use crate::errors::LendingError; @@ -44,6 +45,19 @@ impl PriceFeed { .checked_sub(self.last_updated_slot) .ok_or(LendingError::MathOverflow)?; require!(age <= MAX_PRICE_STALENESS_SLOTS, LendingError::StalePriceFeed); + + // Restart handling. A cluster halt stops the slot count but not the + // wall clock, so after a restart a feed can look fresh in slots while + // its price is hours old. Reject any price stamped at or before the + // restart slot; the market then pauses valuation until the publisher + // posts again, rather than lending against a pre-halt price. Zero + // means the cluster has never restarted. + let last_restart_slot = LastRestartSlot::get()?.last_restart_slot; + require!( + last_restart_slot == 0 || self.last_updated_slot > last_restart_slot, + LendingError::PricePredatesRestart + ); + require!(self.price_mantissa > 0, LendingError::InvalidOraclePrice); price_mantissa_to_scaled(self.price_mantissa as u128, self.exponent) diff --git a/finance/lending/anchor/programs/lending/tests/common/mod.rs b/finance/lending/anchor/programs/lending/tests/common/mod.rs index b51bacdb..99210fe8 100644 --- a/finance/lending/anchor/programs/lending/tests/common/mod.rs +++ b/finance/lending/anchor/programs/lending/tests/common/mod.rs @@ -200,6 +200,14 @@ impl Env { self.svm.expire_blockhash(); } + /// Simulate a cluster restart at `slot`: prices stamped at or before it + /// must be rejected until the publisher posts again. + pub fn set_last_restart_slot(&mut self, slot: u64) { + self.svm.set_sysvar(&solana_sysvar::last_restart_slot::LastRestartSlot { + last_restart_slot: slot, + }); + } + /// The feed PDA the market owner writes for `mint`: seeded by the owner's /// key, so it is the feed `add_reserve` registers reserves against. /// The feed PDA for a given market and mint (seeds `["price_feed", market, mint]`). diff --git a/finance/lending/anchor/programs/lending/tests/test_borrow_repay.rs b/finance/lending/anchor/programs/lending/tests/test_borrow_repay.rs index 4b430e9f..181fffac 100644 --- a/finance/lending/anchor/programs/lending/tests/test_borrow_repay.rs +++ b/finance/lending/anchor/programs/lending/tests/test_borrow_repay.rs @@ -69,6 +69,37 @@ fn borrow_with_stale_price_feed_is_rejected() { assert!(result.unwrap_err().contains("StalePriceFeed")); } +/// A cluster restart passes hours of wall-clock time in zero slots, so a price +/// published before the halt can still look fresh by slot count. The feed must +/// reject it until the publisher posts again. +#[test] +fn borrow_with_price_from_before_a_restart_is_rejected() { + let (mut env, collateral, borrow, borrower, obligation) = setup(); + + // The prices were published at the current slot. Simulate a halt: the + // cluster restarts a few slots later, well inside the staleness window, + // so only the restart check can catch the pre-halt price. + let restart_slot = env.current_slot() + 3; + env.warp_slots(5); + env.set_last_restart_slot(restart_slot); + + let result = env.try_borrow(&borrower, obligation, &[&collateral], &[], &borrow, 100_000_000); + assert!( + result.unwrap_err().contains("PricePredatesRestart"), + "a pre-restart price must be rejected even inside the staleness window" + ); + + // Publishing after the restart reopens the market. Warp first: the retry is + // otherwise byte-identical to the rejected borrow, so it would carry the + // same signature and be dropped as already processed. The failed borrow + // recorded nothing, so the obligation still has no borrows to refresh. + env.warp_slots(1); + env.set_price(collateral.mint, dollars(1)); + env.set_price(borrow.mint, dollars(1)); + env.try_borrow(&borrower, obligation, &[&collateral], &[], &borrow, 100_000_000) + .expect("a freshly published price must be accepted after a restart"); +} + #[test] fn repay_reduces_debt_and_over_repay_clamps() { let (mut env, collateral, borrow, borrower, obligation) = setup(); diff --git a/finance/lending/quasar/CHANGELOG.md b/finance/lending/quasar/CHANGELOG.md index d0f2b2e3..8c386e2e 100644 --- a/finance/lending/quasar/CHANGELOG.md +++ b/finance/lending/quasar/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [2026-08-04] + +### Changed + +- Reject oracle prices from before a cluster restart: `price_scaled` requires + the feed's slot to be after the `LastRestartSlot` sysvar's slot + (`PricePredatesRestart`). quasar-lang has no LastRestartSlot sysvar, so + `src/last_restart.rs` declares the layout and reads it via + `sol_get_sysvar`. Tested by + `borrow_with_price_from_before_a_restart_is_rejected`. +- Pinned `zeropod = "=0.3.3"`: zeropod 0.3.4 moved to wincode 0.5 while + quasar-lang's pinned rev stays on wincode 0.4, so a fresh resolve failed + every Pod* trait bound. + ## [2026-07-22] ### Changed diff --git a/finance/lending/quasar/Cargo.toml b/finance/lending/quasar/Cargo.toml index 2fa4885c..9fea3faf 100644 --- a/finance/lending/quasar/Cargo.toml +++ b/finance/lending/quasar/Cargo.toml @@ -32,6 +32,17 @@ idl-build = ["quasar-lang/idl-build"] quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } +# The LastRestartSlot sysvar declaration (src/last_restart.rs) names these +# three crates directly. Versions match quasar-lang's own constraints so each +# is compiled once. +solana-address = { version = ">=2.2, <2.6" } +solana-program-error = "3.0.0" +solana-define-syscall = "5.0.0" +# Pinned: quasar-lang asks for zeropod "0.3.3" and wincode 0.4, but zeropod +# 0.3.4 moved to wincode 0.5, so a fresh resolve (no lockfile is committed) +# splits the graph across two wincode versions and every Pod* trait bound +# fails. Unpin when quasar-lang's pinned rev accepts zeropod 0.3.4+. +zeropod = "=0.3.3" [dev-dependencies] quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/finance/lending/quasar/README.md b/finance/lending/quasar/README.md index 35d960fa..99c2e281 100644 --- a/finance/lending/quasar/README.md +++ b/finance/lending/quasar/README.md @@ -24,6 +24,13 @@ fixed-size accounts, so this port follows that idiom: touches at the top of the instruction. Health is then computed inline from the freshly accrued reserves and the oracle prices passed in. +- **A hand-declared `LastRestartSlot` sysvar.** quasar-lang ships only the + Clock and Rent sysvars, so `src/last_restart.rs` declares the 8-byte layout + itself and reads it with the same `sol_get_sysvar` syscall. `price_scaled` + uses it to reject prices published before a cluster restart, which slot-based + staleness alone cannot catch (a halt passes hours of wall-clock time in zero + slots). + Everything else mirrors the Anchor version. ## Major concepts diff --git a/finance/lending/quasar/src/error.rs b/finance/lending/quasar/src/error.rs index 8d3b5e63..51933e41 100644 --- a/finance/lending/quasar/src/error.rs +++ b/finance/lending/quasar/src/error.rs @@ -17,4 +17,5 @@ pub enum LendingError { WrongReserve, LiquidationTooLarge, NothingToCollect, + PricePredatesRestart, } diff --git a/finance/lending/quasar/src/last_restart.rs b/finance/lending/quasar/src/last_restart.rs new file mode 100644 index 00000000..e8393873 --- /dev/null +++ b/finance/lending/quasar/src/last_restart.rs @@ -0,0 +1,77 @@ +//! The LastRestartSlot sysvar: the slot of the most recent cluster restart, +//! or 0 if the cluster has never restarted (SIMD-0047). quasar-lang ships +//! only the Clock and Rent sysvars, so this program declares the 8-byte +//! layout itself and reads it through the same `sol_get_sysvar` syscall +//! quasar's own sysvars use. +//! +//! Why the program reads it: a halt stops the slot count but not the wall +//! clock, so after a restart an oracle price can look fresh in slots while +//! its value is hours old. `logic::price_scaled` rejects any price stamped +//! at or before the restart slot, pausing valuation until the publisher +//! posts again. + +use quasar_lang::{pod::PodU64, prelude::Address, sysvars::Sysvar}; +use solana_program_error::ProgramError; + +/// `SysvarLastRestartS1ot1111111111111111111111`, decoded at compile time. +const LAST_RESTART_SLOT_ID: Address = + quasar_lang::prelude::address!("SysvarLastRestartS1ot1111111111111111111111"); + +/// The sysvar's whole data: one little-endian u64. +#[repr(C)] +#[derive(Copy, Clone)] +pub struct LastRestartSlot { + pub last_restart_slot: PodU64, +} + +const _: () = assert!(core::mem::size_of::() == 8); +const _: () = assert!(core::mem::align_of::() == 1); + +// Written out by hand rather than with quasar-lang's `impl_sysvar_get!`: the +// macro's expansion names private quasar-lang constants, so it only works +// inside that crate. The sysvar is 8 bytes with no padding, so the syscall +// fills the whole struct. +impl Sysvar for LastRestartSlot { + const ID: Address = LAST_RESTART_SLOT_ID; + + #[inline(always)] + unsafe fn from_bytes_unchecked(bytes: &[u8]) -> &Self { + // SAFETY: the caller guarantees `bytes` holds at least 8 bytes of + // valid sysvar data; the struct is `#[repr(C)]` with alignment 1, + // so the pointer cast is always valid. + unsafe { &*(bytes.as_ptr() as *const Self) } + } + + fn get() -> Result { + let mut var = core::mem::MaybeUninit::::uninit(); + let var_addr = var.as_mut_ptr() as *mut u8; + + #[cfg(any(target_os = "solana", target_arch = "bpf"))] + // SAFETY: `var_addr` points at 8 writable bytes and the syscall + // writes exactly 8. + let result = unsafe { + solana_define_syscall::definitions::sol_get_sysvar( + &LAST_RESTART_SLOT_ID as *const _ as *const u8, + var_addr, + 0, + core::mem::size_of::() as u64, + ) + }; + + // Off-chain (IDL builds, client compilation) the sysvar reads as + // zero: the cluster has never restarted. + #[cfg(not(any(target_os = "solana", target_arch = "bpf")))] + let result: u64 = { + // SAFETY: `var_addr` points at 8 writable bytes. + unsafe { var_addr.write_bytes(0, core::mem::size_of::()) }; + 0 + }; + + match result { + // SAFETY: on success the syscall (or the zeroing above) has + // initialized all 8 bytes. + 0 => Ok(unsafe { var.assume_init() }), + _ => Err(ProgramError::UnsupportedSysvar), + } + } +} diff --git a/finance/lending/quasar/src/lib.rs b/finance/lending/quasar/src/lib.rs index 51952280..0e90b8c7 100644 --- a/finance/lending/quasar/src/lib.rs +++ b/finance/lending/quasar/src/lib.rs @@ -18,6 +18,7 @@ use quasar_lang::prelude::*; mod constants; mod error; mod instructions; +mod last_restart; mod logic; mod math; mod state; diff --git a/finance/lending/quasar/src/logic.rs b/finance/lending/quasar/src/logic.rs index a2a53b7f..9ac2cfec 100644 --- a/finance/lending/quasar/src/logic.rs +++ b/finance/lending/quasar/src/logic.rs @@ -8,6 +8,7 @@ use quasar_lang::{prelude::*, sysvars::Sysvar}; use crate::{ constants::{FIXED_POINT_SCALE, MAX_PRICE_STALENESS_SLOTS}, error::LendingError, + last_restart::LastRestartSlot, math::{accrue_index, current_debt, mul_div_floor, price_mantissa_to_scaled}, state::{Obligation, ObligationInner, PriceFeed, Reserve, ReserveInner}, }; @@ -105,6 +106,19 @@ pub fn price_scaled(feed: &Account, slot: u64) -> Result last_restart, + LendingError::PricePredatesRestart + ); + let mantissa = i128::from(feed.price_mantissa); require!(mantissa > 0, LendingError::InvalidOraclePrice); price_mantissa_to_scaled(mantissa as u128, i32::from(feed.exponent)) diff --git a/finance/lending/quasar/src/tests.rs b/finance/lending/quasar/src/tests.rs index 41de2e43..e8d2d663 100644 --- a/finance/lending/quasar/src/tests.rs +++ b/finance/lending/quasar/src/tests.rs @@ -754,6 +754,31 @@ mod slot_warp { ); } + /// A cluster restart passes hours of wall-clock time in zero slots, so a + /// price published before the halt can still look fresh by slot count. + /// `price_scaled` must reject it until the publisher posts again. + #[test] + fn borrow_with_price_from_before_a_restart_is_rejected() { + let mut world = World::new(); + world.bootstrap_position(); + + // Prices were published at the current slot. Simulate a halt: the + // cluster restarts a few slots later, well inside the staleness + // window, so only the restart check can catch the pre-halt price. + let restart_slot = world.svm.sysvars.clock.slot + 3; + world.svm.sysvars.warp_to_slot(restart_slot + 2); + world.svm.sysvars.last_restart_slot.last_restart_slot = restart_slot; + + world.borrow(100 * UNIT).assert_error(quasar_svm::ProgramError::Custom( + crate::error::LendingError::PricePredatesRestart as u32, + )); + + // Publishing after the restart reopens the market. + world.set_price(COLLATERAL_MINT, world.collateral_price, dollars(1)); + world.set_price(BORROW_MINT, world.borrow_price, dollars(1)); + world.borrow(100 * UNIT).assert_success(); + } + #[test] fn protocol_fees_accrue_and_owner_can_collect() { let mut world = World::new(); diff --git a/finance/perpetual-futures/anchor/CHANGELOG.md b/finance/perpetual-futures/anchor/CHANGELOG.md index 4f116388..ecd49fe1 100644 --- a/finance/perpetual-futures/anchor/CHANGELOG.md +++ b/finance/perpetual-futures/anchor/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 2026-08-04 + +Reject oracle prices from before a cluster restart. A halt stops the slot +count but not the wall clock, so after a restart a feed can look fresh in +slots while its price is hours old; with leverage that error is amplified +market-wide. `read_oracle_price` now also requires the feed's slot to be +after the `LastRestartSlot` sysvar's slot (`PricePredatesRestart`). Tested +by `test_open_rejects_price_from_before_a_restart`. + ## 2026-07-07 Added this changelog. Changes prior to this date were tracked in git history only. diff --git a/finance/perpetual-futures/anchor/README.md b/finance/perpetual-futures/anchor/README.md index e7bf4d64..617df0df 100644 --- a/finance/perpetual-futures/anchor/README.md +++ b/finance/perpetual-futures/anchor/README.md @@ -48,7 +48,7 @@ A position's *equity* is its net collateral plus profit/loss minus funding. Once ### Oracle -The mark price comes from an oracle feed. This example validates the price for staleness (by slot), positivity, scale, and a [confidence band](https://docs.pyth.network/price-feeds/best-practices#confidence-intervals) that must stay within `max_confidence_bps` of the price: rejecting an uncertain price is one of the most common oracle-safety checks. +The mark price comes from an oracle feed. This example validates the price for staleness (by slot), publication after the most recent cluster restart (the `LastRestartSlot` sysvar, because a halt passes hours of wall-clock time in zero slots), positivity, scale, and a [confidence band](https://docs.pyth.network/price-feeds/best-practices#confidence-intervals) that must stay within `max_confidence_bps` of the price: rejecting an uncertain price is one of the most common oracle-safety checks. ### Fees and slippage @@ -205,7 +205,7 @@ This is a teaching example, not an audited exchange. Notably: ## Testing -The tests run in-process with [LiteSVM](https://www.anchor-lang.com/docs/testing/litesvm) and [solana-kite](https://solanakite.org); no local validator is needed. They deploy both programs, drive the mock oracle, and cover liquidity round-trips, opening and closing longs and shorts in profit and loss, leverage and slippage rejection, stale-price and wide-confidence rejection, funding accrual, liquidation (and the refusal to liquidate a healthy position), reserved-liquidity behaviour (profit capped at the reserve, opens rejected when the pool can't back them, withdrawals blocked by reserved liquidity), and fee collection. +The tests run in-process with [LiteSVM](https://www.anchor-lang.com/docs/testing/litesvm) and [solana-kite](https://solanakite.org); no local validator is needed. They deploy both programs, drive the mock oracle, and cover liquidity round-trips, opening and closing longs and shorts in profit and loss, leverage and slippage rejection, stale-price, pre-restart-price, and wide-confidence rejection, funding accrual, liquidation (and the refusal to liquidate a healthy position), reserved-liquidity behaviour (profit capped at the reserve, opens rejected when the pool can't back them, withdrawals blocked by reserved liquidity), and fee collection. ```bash anchor build diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/Cargo.toml b/finance/perpetual-futures/anchor/programs/perpetual-futures/Cargo.toml index 6a477895..5c8300a5 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/Cargo.toml +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/Cargo.toml @@ -31,6 +31,10 @@ anchor-spl = "1.1.2" # spl-token's) and fails. spl-token = { version = "9.0.0", features = ["no-entrypoint"] } spl-associated-token-account = { version = "8.0.0", features = ["no-entrypoint"] } +# For the LastRestartSlot sysvar (not re-exported by anchor-lang): the oracle +# reader rejects prices from before a cluster restart. Same major as the +# solana-sysvar anchor-lang itself uses, so only one copy is compiled in. +solana-sysvar = "3" [dev-dependencies] litesvm = "0.13.1" diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/errors.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/errors.rs index 442e93f8..94509f06 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/errors.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/errors.rs @@ -55,4 +55,7 @@ pub enum PerpError { #[msg("No protocol fees are available to collect")] NothingToClaim, + + #[msg("Oracle price is stale: it predates the last cluster restart")] + PricePredatesRestart, } diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/oracle.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/oracle.rs index 64813677..e05b7ce3 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/oracle.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/oracle.rs @@ -1,4 +1,5 @@ use anchor_lang::prelude::*; +use solana_sysvar::last_restart_slot::LastRestartSlot; use crate::constants::{BASIS_POINTS_DENOMINATOR, MAX_PRICE_STALENESS_SLOTS}; use crate::errors::PerpError; @@ -81,6 +82,18 @@ pub fn read_oracle_price( PerpError::StalePrice ); + // Restart handling. A cluster halt stops the slot count but not the wall + // clock, so after a restart a feed can look fresh in slots while its + // price is hours old. With leverage a stale price is amplified into a + // market-wide equity error, so reject any price stamped at or before the + // restart slot; the pool pauses valuation until the publisher posts + // again. Zero means the cluster has never restarted. + let last_restart_slot = LastRestartSlot::get()?.last_restart_slot; + require!( + last_restart_slot == 0 || last_update_slot > last_restart_slot, + PerpError::PricePredatesRestart + ); + // Reject an untrustworthy price: confidence band as a fraction of price, // in basis points, must not exceed the pool's limit. Widen to u128 so the // product cannot overflow, and `price > 0` is already guaranteed. diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/tests/test_perpetual_futures.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/tests/test_perpetual_futures.rs index 4bf2809d..3055ffea 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/tests/test_perpetual_futures.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/tests/test_perpetual_futures.rs @@ -219,6 +219,18 @@ impl Market { self.svm.expire_blockhash(); } + fn current_slot(&self) -> u64 { + self.svm.get_sysvar::().slot + } + + /// Simulate a cluster restart at `slot`: prices stamped at or before it + /// must be rejected until the publisher posts again. + fn set_last_restart_slot(&mut self, slot: u64) { + self.svm.set_sysvar(&solana_sysvar::last_restart_slot::LastRestartSlot { + last_restart_slot: slot, + }); + } + /// Create a wallet holding `amount` collateral tokens in its associated /// token account. fn funded_trader(&mut self, amount: u64) -> (Keypair, Pubkey) { @@ -770,6 +782,53 @@ fn test_stale_price_rejected() { .is_err()); } +/// A cluster restart passes hours of wall-clock time in zero slots, so a +/// price published before the halt can still look fresh by slot count. With +/// leverage a stale price is amplified into a market-wide equity error, so +/// the pool must refuse it until the publisher posts again. +#[test] +fn test_open_rejects_price_from_before_a_restart() { + let mut market = Market::default_market(); + market.seed_liquidity(100_000 * ONE_USDC); + let collateral = 1_000 * ONE_USDC; + let (trader, trader_collateral) = market.funded_trader(collateral); + + // Simulate a halt: the cluster restarts a few slots after the price was + // published, well inside the staleness window, so only the restart check + // can catch the pre-halt price. + market.set_price(dollars(100)); + let published_at = market.current_slot(); + market.warp(published_at + 5); + market.set_last_restart_slot(published_at + 3); + + assert!(market + .open_position( + &trader, + trader_collateral, + Side::Long, + collateral, + 5_000 * ONE_USDC, + u64::MAX + ) + .is_err()); + + // Publishing after the restart reopens the pool. Warp first: the retry is + // otherwise byte-identical to the rejected open, so it would carry the same + // signature and be dropped as already processed. + market.warp(published_at + 6); + market.set_price(dollars(100)); + market + .open_position( + &trader, + trader_collateral, + Side::Long, + collateral, + 5_000 * ONE_USDC, + u64::MAX + ) + .expect("a freshly published price must be accepted after a restart"); +} + #[test] fn test_wide_oracle_confidence_rejected() { let mut market = Market::default_market(); diff --git a/finance/perpetual-futures/quasar/CHANGELOG.md b/finance/perpetual-futures/quasar/CHANGELOG.md index 939b6e87..5a9b02bc 100644 --- a/finance/perpetual-futures/quasar/CHANGELOG.md +++ b/finance/perpetual-futures/quasar/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 2026-08-04 + +Reject oracle prices from before a cluster restart: `read_oracle_price` +requires the feed's slot to be after the `LastRestartSlot` sysvar's slot +(`PRICE_PREDATES_RESTART`). quasar-lang has no LastRestartSlot sysvar, so +`src/last_restart.rs` declares the layout and reads it via +`sol_get_sysvar`. Also pinned `zeropod = "=0.3.3"` (zeropod 0.3.4 moved to +wincode 0.5 while quasar-lang's pinned rev stays on wincode 0.4, so a fresh +resolve failed every Pod* trait bound). Tested by +`open_rejects_price_from_before_a_restart`. + ## [2026-07-22] ### Changed diff --git a/finance/perpetual-futures/quasar/Cargo.toml b/finance/perpetual-futures/quasar/Cargo.toml index f29400ec..eaf1435b 100644 --- a/finance/perpetual-futures/quasar/Cargo.toml +++ b/finance/perpetual-futures/quasar/Cargo.toml @@ -32,6 +32,17 @@ idl-build = ["quasar-lang/idl-build"] quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } +# The LastRestartSlot sysvar declaration (src/last_restart.rs) names these +# three crates directly. Versions match quasar-lang's own constraints so each +# is compiled once. +solana-address = { version = ">=2.2, <2.6" } +solana-program-error = "3.0.0" +solana-define-syscall = "5.0.0" +# Pinned: quasar-lang asks for zeropod "0.3.3" and wincode 0.4, but zeropod +# 0.3.4 moved to wincode 0.5, so a fresh resolve (no lockfile is committed) +# splits the graph across two wincode versions and every Pod* trait bound +# fails. Unpin when quasar-lang's pinned rev accepts zeropod 0.3.4+. +zeropod = "=0.3.3" [dev-dependencies] quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/finance/perpetual-futures/quasar/README.md b/finance/perpetual-futures/quasar/README.md index 81ec35b0..dfcda2cf 100644 --- a/finance/perpetual-futures/quasar/README.md +++ b/finance/perpetual-futures/quasar/README.md @@ -14,6 +14,12 @@ math. This page only covers what differs in the Quasar version. inputs, not instruction arguments, so the side cannot be a seed; the position PDA is `[b"position", pool, owner]` and the side is stored in the account. A trader therefore holds a single open position per pool here. +- **A hand-declared `LastRestartSlot` sysvar.** quasar-lang ships only the + Clock and Rent sysvars, so `src/last_restart.rs` declares the 8-byte layout + itself and reads it with the same `sol_get_sysvar` syscall. + `read_oracle_price` uses it to reject prices published before a cluster + restart, which slot-based staleness alone cannot catch (a halt passes hours + of wall-clock time in zero slots). - **Oracle feed in tests.** Rather than a separate mock-oracle program, the tests write the feed account's bytes directly (price, scale, last-update slot) and the program reads them the same way it would read a real Switchboard feed. diff --git a/finance/perpetual-futures/quasar/src/instructions/shared.rs b/finance/perpetual-futures/quasar/src/instructions/shared.rs index 9e02f8c7..a1106532 100644 --- a/finance/perpetual-futures/quasar/src/instructions/shared.rs +++ b/finance/perpetual-futures/quasar/src/instructions/shared.rs @@ -2,7 +2,9 @@ //! All integer, all `checked_*`, multiply-before-divide, rounding toward the //! protocol. Errors are `ProgramError::Custom(code)`; the codes are listed here. -use quasar_lang::prelude::*; +use quasar_lang::{prelude::*, sysvars::Sysvar}; + +use crate::last_restart::LastRestartSlot; use crate::constants::{ BASIS_POINTS_DENOMINATOR, FUNDING_PRECISION, MAX_PRICE_STALENESS_SLOTS, SIDE_LONG, @@ -28,6 +30,7 @@ pub mod error { pub const AMOUNT_ROUNDS_TO_ZERO: u32 = 15; pub const ORACLE_CONFIDENCE_TOO_WIDE: u32 = 16; pub const INSUFFICIENT_COLLATERAL: u32 = 17; + pub const PRICE_PREDATES_RESTART: u32 = 18; } #[inline(always)] @@ -101,6 +104,17 @@ pub fn read_oracle_price( return Err(err(error::STALE_PRICE)); } + // Restart handling. A cluster halt stops the slot count but not the wall + // clock, so after a restart a feed can look fresh in slots while its + // price is hours old. With leverage a stale price is amplified into a + // market-wide equity error, so reject any price stamped at or before the + // restart slot; the pool pauses valuation until the publisher posts + // again. Zero means the cluster has never restarted. + let last_restart = u64::from(LastRestartSlot::get()?.last_restart_slot); + if last_restart != 0 && last_update_slot <= last_restart { + return Err(err(error::PRICE_PREDATES_RESTART)); + } + // Confidence band as a fraction of price, in basis points, must stay within // the pool's limit. Widen to u128 so the product cannot overflow. let confidence_bps = (confidence as u128) diff --git a/finance/perpetual-futures/quasar/src/last_restart.rs b/finance/perpetual-futures/quasar/src/last_restart.rs new file mode 100644 index 00000000..bf7abbe7 --- /dev/null +++ b/finance/perpetual-futures/quasar/src/last_restart.rs @@ -0,0 +1,77 @@ +//! The LastRestartSlot sysvar: the slot of the most recent cluster restart, +//! or 0 if the cluster has never restarted (SIMD-0047). quasar-lang ships +//! only the Clock and Rent sysvars, so this program declares the 8-byte +//! layout itself and reads it through the same `sol_get_sysvar` syscall +//! quasar's own sysvars use. +//! +//! Why the program reads it: a halt stops the slot count but not the wall +//! clock, so after a restart an oracle price can look fresh in slots while +//! its value is hours old. `shared::read_oracle_price` rejects any price +//! stamped at or before the restart slot, so the pool pauses valuation +//! until the publisher posts again. + +use quasar_lang::{pod::PodU64, prelude::Address, sysvars::Sysvar}; +use solana_program_error::ProgramError; + +/// `SysvarLastRestartS1ot1111111111111111111111`, decoded at compile time. +const LAST_RESTART_SLOT_ID: Address = + quasar_lang::prelude::address!("SysvarLastRestartS1ot1111111111111111111111"); + +/// The sysvar's whole data: one little-endian u64. +#[repr(C)] +#[derive(Copy, Clone)] +pub struct LastRestartSlot { + pub last_restart_slot: PodU64, +} + +const _: () = assert!(core::mem::size_of::() == 8); +const _: () = assert!(core::mem::align_of::() == 1); + +// Written out by hand rather than with quasar-lang's `impl_sysvar_get!`: the +// macro's expansion names private quasar-lang constants, so it only works +// inside that crate. The sysvar is 8 bytes with no padding, so the syscall +// fills the whole struct. +impl Sysvar for LastRestartSlot { + const ID: Address = LAST_RESTART_SLOT_ID; + + #[inline(always)] + unsafe fn from_bytes_unchecked(bytes: &[u8]) -> &Self { + // SAFETY: the caller guarantees `bytes` holds at least 8 bytes of + // valid sysvar data; the struct is `#[repr(C)]` with alignment 1, + // so the pointer cast is always valid. + unsafe { &*(bytes.as_ptr() as *const Self) } + } + + fn get() -> Result { + let mut var = core::mem::MaybeUninit::::uninit(); + let var_addr = var.as_mut_ptr() as *mut u8; + + #[cfg(any(target_os = "solana", target_arch = "bpf"))] + // SAFETY: `var_addr` points at 8 writable bytes and the syscall + // writes exactly 8. + let result = unsafe { + solana_define_syscall::definitions::sol_get_sysvar( + &LAST_RESTART_SLOT_ID as *const _ as *const u8, + var_addr, + 0, + core::mem::size_of::() as u64, + ) + }; + + // Off-chain (IDL builds, client compilation) the sysvar reads as + // zero: the cluster has never restarted. + #[cfg(not(any(target_os = "solana", target_arch = "bpf")))] + let result: u64 = { + // SAFETY: `var_addr` points at 8 writable bytes. + unsafe { var_addr.write_bytes(0, core::mem::size_of::()) }; + 0 + }; + + match result { + // SAFETY: on success the syscall (or the zeroing above) has + // initialized all 8 bytes. + 0 => Ok(unsafe { var.assume_init() }), + _ => Err(ProgramError::UnsupportedSysvar), + } + } +} diff --git a/finance/perpetual-futures/quasar/src/lib.rs b/finance/perpetual-futures/quasar/src/lib.rs index 995baa0b..245aff21 100644 --- a/finance/perpetual-futures/quasar/src/lib.rs +++ b/finance/perpetual-futures/quasar/src/lib.rs @@ -9,6 +9,7 @@ use quasar_lang::prelude::*; mod constants; mod instructions; +mod last_restart; pub mod state; #[cfg(test)] mod tests; diff --git a/finance/perpetual-futures/quasar/src/tests.rs b/finance/perpetual-futures/quasar/src/tests.rs index 4dfe6539..961d35b0 100644 --- a/finance/perpetual-futures/quasar/src/tests.rs +++ b/finance/perpetual-futures/quasar/src/tests.rs @@ -42,14 +42,55 @@ fn dollars(whole: i128) -> i128 { /// last_update_slot (u64), confidence (u64). The tests own this; production /// reads a real feed. fn set_feed(test: &mut Test, price: i128, confidence: u64) { + set_feed_at_slot(test, price, SLOT, confidence); +} + +fn set_feed_at_slot(test: &mut Test, price: i128, slot: u64, confidence: u64) { let mut data = Vec::with_capacity(36); data.extend_from_slice(&price.to_le_bytes()); data.extend_from_slice(&ORACLE_SCALE.to_le_bytes()); - data.extend_from_slice(&SLOT.to_le_bytes()); + data.extend_from_slice(&slot.to_le_bytes()); data.extend_from_slice(&confidence.to_le_bytes()); test.set_account(Account::new(FEED, system_program::ID, 1_000_000, data)); } +/// Pin the Clock sysvar account at `slot`. Clock's bincode layout is the raw +/// little-endian fields: slot, epoch_start_timestamp, epoch, +/// leader_schedule_epoch, unix_timestamp. +fn set_clock_at(test: &mut Test, slot: u64) { + let mut data = Vec::with_capacity(40); + data.extend_from_slice(&slot.to_le_bytes()); + data.extend_from_slice(&0i64.to_le_bytes()); + data.extend_from_slice(&0u64.to_le_bytes()); + data.extend_from_slice(&0u64.to_le_bytes()); + data.extend_from_slice(&0i64.to_le_bytes()); + let clock_id: Pubkey = "SysvarC1ock11111111111111111111111111111111" + .parse() + .unwrap(); + let sysvar_owner: Pubkey = "Sysvar1111111111111111111111111111111111111" + .parse() + .unwrap(); + test.set_account(Account::new(clock_id, sysvar_owner, 1_169_280, data)); +} + +/// Pin the LastRestartSlot sysvar account, simulating a cluster restart at +/// `slot`: prices stamped at or before it must be rejected until the +/// publisher posts again. The sysvar's whole data is one little-endian u64. +fn set_last_restart_slot(test: &mut Test, slot: u64) { + let sysvar_id: Pubkey = "SysvarLastRestartS1ot1111111111111111111111" + .parse() + .unwrap(); + let sysvar_owner: Pubkey = "Sysvar1111111111111111111111111111111111111" + .parse() + .unwrap(); + test.set_account(Account::new( + sysvar_id, + sysvar_owner, + 1_169_280, + slot.to_le_bytes().to_vec(), + )); +} + fn init_pool(test: &mut Test, maintenance_margin_bps: u16, close_fee_bps: u16) -> Outcome { test.send(InitializePoolInstruction { authority: ADMIN, @@ -199,6 +240,33 @@ fn open_long_position_creates_the_position(test: &mut Test) { assert!(test.account(position).is_some()); } +/// A cluster restart passes hours of wall-clock time in zero slots, so a +/// price published before the halt can still look fresh by slot count. With +/// leverage a stale price is amplified into a market-wide equity error, so +/// the pool must refuse it until the publisher posts again. +#[quasar_test] +fn open_rejects_price_from_before_a_restart(test: &mut Test) { + let env = setup(test); + fund(test, PROVIDER, PROVIDER_COLLATERAL, 100_000 * ONE_USDC); + add_liquidity(test, &env, 100_000 * ONE_USDC).succeeds(); + fund(test, TRADER, TRADER_COLLATERAL, 1_000 * ONE_USDC); + + // The feed sits at slot 5, fresh by the 150-slot staleness bound, but the + // cluster restarted at slot 7: only the restart check can catch the + // pre-halt price. + set_clock_at(test, 10); + set_feed_at_slot(test, dollars(100), 5, 0); + set_last_restart_slot(test, 7); + assert!( + open_position(test, &env, 0, 1_000 * ONE_USDC, 5_000 * ONE_USDC).is_err(), + "a pre-restart price must be rejected even inside the staleness bound" + ); + + // Publishing after the restart (slot 10) reopens the pool. + set_feed_at_slot(test, dollars(100), 10, 0); + open_position(test, &env, 0, 1_000 * ONE_USDC, 5_000 * ONE_USDC).succeeds(); +} + #[quasar_test] fn close_long_in_profit_pays_collateral_plus_pnl_minus_fees(test: &mut Test) { let env = setup(test); diff --git a/finance/prop-amm/anchor/CHANGELOG.md b/finance/prop-amm/anchor/CHANGELOG.md index fe14b7b2..a011befe 100644 --- a/finance/prop-amm/anchor/CHANGELOG.md +++ b/finance/prop-amm/anchor/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 2026-08-04 + +Reject oracle prices from before a cluster restart. A halt stops the slot +count but not the wall clock, so after a restart a feed can look fresh in +slots while its price is hours old; for a market maker that is a free option +for whoever trades first. `read_oracle_price` now also requires the feed's +slot to be after the `LastRestartSlot` sysvar's slot +(`PricePredatesRestart`). Tested by +`test_swap_rejects_price_from_before_a_restart`. + ## 2026-07-11 (later) Retuned the walkthrough trade to 5 NVDAx (825.825 USDC at the ask, diff --git a/finance/prop-amm/anchor/README.md b/finance/prop-amm/anchor/README.md index 783642a2..9b94124f 100644 --- a/finance/prop-amm/anchor/README.md +++ b/finance/prop-amm/anchor/README.md @@ -62,8 +62,10 @@ during fast markets their quotes vanish and return minutes later. ### Oracle staleness and confidence Every swap re-validates the feed: the price must be positive, at the pinned -scale, no older than 150 slots (~1 minute), and its confidence band must be -inside `max_confidence_bps`. For this design the staleness bound is not +scale, no older than 150 slots (~1 minute), stamped after the most recent +cluster restart (the `LastRestartSlot` sysvar; a halt passes hours of +wall-clock time in zero slots), and its confidence band must be inside +`max_confidence_bps`. For this design the staleness bound is not hygiene, it is the business: a quote priced off an old number is a free option for whoever notices first. @@ -171,4 +173,4 @@ The spread is the fee: buyers pay the oracle price plus `spread_bps`, sellers re ### What stops the venue from quoting a stale price? -Every `swap` re-validates the feed: the price must be fresh (no older than 150 slots), at the pinned scale, and inside the configured confidence band. A stale quote is a free option for whoever notices first, so the staleness checks are the business model, not hygiene. +Every `swap` re-validates the feed: the price must be fresh (no older than 150 slots), stamped after the most recent cluster restart, at the pinned scale, and inside the configured confidence band. A stale quote is a free option for whoever notices first, so the staleness checks are the business model, not hygiene. diff --git a/finance/prop-amm/anchor/programs/prop-amm/Cargo.toml b/finance/prop-amm/anchor/programs/prop-amm/Cargo.toml index a13aa36c..fb8a4f9a 100644 --- a/finance/prop-amm/anchor/programs/prop-amm/Cargo.toml +++ b/finance/prop-amm/anchor/programs/prop-amm/Cargo.toml @@ -24,6 +24,10 @@ custom-panic = [] # doesn't exist yet, so a first-time buyer needs no separate setup transaction. anchor-lang = { version = "1.1.2", features = ["init-if-needed"] } anchor-spl = "1.1.2" +# For the LastRestartSlot sysvar (not re-exported by anchor-lang): the oracle +# reader rejects prices from before a cluster restart. Same major as the +# solana-sysvar anchor-lang itself uses, so only one copy is compiled in. +solana-sysvar = "3" # Declared only so Cargo feature-unification turns on `no-entrypoint`; without # these the test binary links two `entrypoint` symbols and fails to build. spl-token = { version = "9.0.0", features = ["no-entrypoint"] } diff --git a/finance/prop-amm/anchor/programs/prop-amm/src/errors.rs b/finance/prop-amm/anchor/programs/prop-amm/src/errors.rs index 10d9cfca..1f622fc1 100644 --- a/finance/prop-amm/anchor/programs/prop-amm/src/errors.rs +++ b/finance/prop-amm/anchor/programs/prop-amm/src/errors.rs @@ -40,4 +40,7 @@ pub enum PropAmmError { #[msg("Oracle price confidence band is too wide to trust")] OracleConfidenceTooWide, + + #[msg("Oracle price is stale: it predates the last cluster restart")] + PricePredatesRestart, } diff --git a/finance/prop-amm/anchor/programs/prop-amm/src/state/oracle.rs b/finance/prop-amm/anchor/programs/prop-amm/src/state/oracle.rs index 09c6ed94..83479816 100644 --- a/finance/prop-amm/anchor/programs/prop-amm/src/state/oracle.rs +++ b/finance/prop-amm/anchor/programs/prop-amm/src/state/oracle.rs @@ -1,4 +1,5 @@ use anchor_lang::prelude::*; +use solana_sysvar::last_restart_slot::LastRestartSlot; use crate::constants::{BASIS_POINTS_DENOMINATOR, MAX_PRICE_STALENESS_SLOTS}; use crate::errors::PropAmmError; @@ -83,6 +84,18 @@ pub fn read_oracle_price( PropAmmError::StalePrice ); + // Restart handling. A cluster halt stops the slot count but not the wall + // clock, so after a restart a feed can look fresh in slots while its + // price is hours old. For a market maker that is a free option for + // whoever trades first, so reject any price stamped at or before the + // restart slot; the market refuses to quote until the publisher posts + // again. Zero means the cluster has never restarted. + let last_restart_slot = LastRestartSlot::get()?.last_restart_slot; + require!( + last_restart_slot == 0 || last_update_slot > last_restart_slot, + PropAmmError::PricePredatesRestart + ); + // Reject an untrustworthy price: confidence band as a fraction of price, // in basis points, must not exceed the market's limit. Widen to u128 so the // product cannot overflow, and `price > 0` is already guaranteed. diff --git a/finance/prop-amm/anchor/programs/prop-amm/tests/test_prop_amm.rs b/finance/prop-amm/anchor/programs/prop-amm/tests/test_prop_amm.rs index 2ea29b01..5f10fb4a 100644 --- a/finance/prop-amm/anchor/programs/prop-amm/tests/test_prop_amm.rs +++ b/finance/prop-amm/anchor/programs/prop-amm/tests/test_prop_amm.rs @@ -264,6 +264,18 @@ impl Market { self.svm.expire_blockhash(); } + fn current_slot(&self) -> u64 { + self.svm.get_sysvar::().slot + } + + /// Simulate a cluster restart at `slot`: prices stamped at or before it + /// must be rejected until the publisher posts again. + fn set_last_restart_slot(&mut self, slot: u64) { + self.svm.set_sysvar(&solana_sysvar::last_restart_slot::LastRestartSlot { + last_restart_slot: slot, + }); + } + /// Create a wallet holding `base` and `quote` minor units in associated /// token accounts. fn funded_trader(&mut self, base: u64, quote: u64) -> (Keypair, Pubkey, Pubkey) { @@ -636,6 +648,36 @@ fn test_swap_rejects_stale_price() { .is_err()); } +/// A cluster restart passes hours of wall-clock time in zero slots, so a price +/// published before the halt can still look fresh by slot count. The market +/// must refuse to quote against it until the publisher posts again. +#[test] +fn test_swap_rejects_price_from_before_a_restart() { + let mut market = Market::default_market(); + market.set_price(dollars(165)); + let (alice, _, _) = market.funded_trader(0, 825_825_000); + + // Simulate a halt: the cluster restarts a few slots after the price was + // published, well inside the 150-slot staleness bound, so only the + // restart check can catch the pre-halt price. + let published_at = market.current_slot(); + market.warp(published_at + 5); + market.set_last_restart_slot(published_at + 3); + + assert!(market + .swap(&alice, Direction::BuyBase, 825_825_000, 0) + .is_err()); + + // Publishing after the restart reopens the market. Warp first: the retry is + // otherwise byte-identical to the rejected swap, so it would carry the same + // signature and be dropped as already processed. + market.warp(published_at + 6); + market.set_price(dollars(165)); + market + .swap(&alice, Direction::BuyBase, 825_825_000, 0) + .expect("a freshly published price must be accepted after a restart"); +} + /// A price the oracle itself is unsure about is rejected: the confidence band /// (about 1.2% here) exceeds the market's 1% limit. #[test] diff --git a/finance/prop-amm/quasar/CHANGELOG.md b/finance/prop-amm/quasar/CHANGELOG.md index c866b606..902d8d24 100644 --- a/finance/prop-amm/quasar/CHANGELOG.md +++ b/finance/prop-amm/quasar/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 2026-08-04 + +Reject oracle prices from before a cluster restart: `read_oracle_price` +requires the feed's slot to be after the `LastRestartSlot` sysvar's slot +(`PRICE_PREDATES_RESTART`). quasar-lang has no LastRestartSlot sysvar, so +`src/last_restart.rs` declares the layout and reads it via +`sol_get_sysvar`. Also pinned `zeropod = "=0.3.3"` (zeropod 0.3.4 moved to +wincode 0.5 while quasar-lang's pinned rev stays on wincode 0.4, so a fresh +resolve failed every Pod* trait bound). Tested by +`swap_rejects_price_from_before_a_restart`. + ## [2026-07-22] ### Changed diff --git a/finance/prop-amm/quasar/Cargo.toml b/finance/prop-amm/quasar/Cargo.toml index 35311d86..cb2e6483 100644 --- a/finance/prop-amm/quasar/Cargo.toml +++ b/finance/prop-amm/quasar/Cargo.toml @@ -32,6 +32,17 @@ idl-build = ["quasar-lang/idl-build"] quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } solana-instruction = { version = "3.2.0" } +# The LastRestartSlot sysvar declaration (src/last_restart.rs) names these +# three crates directly. Versions match quasar-lang's own constraints so each +# is compiled once. +solana-address = { version = ">=2.2, <2.6" } +solana-program-error = "3.0.0" +solana-define-syscall = "5.0.0" +# Pinned: quasar-lang asks for zeropod "0.3.3" and wincode 0.4, but zeropod +# 0.3.4 moved to wincode 0.5, so a fresh resolve (no lockfile is committed) +# splits the graph across two wincode versions and every Pod* trait bound +# fails. Unpin when quasar-lang's pinned rev accepts zeropod 0.3.4+. +zeropod = "=0.3.3" [dev-dependencies] quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/finance/prop-amm/quasar/README.md b/finance/prop-amm/quasar/README.md index dea6c39f..c377fff5 100644 --- a/finance/prop-amm/quasar/README.md +++ b/finance/prop-amm/quasar/README.md @@ -16,6 +16,12 @@ Quasar version. - **Trader token accounts must already exist.** The Anchor version uses `init_if_needed` to create the trader's destination account inside the swap; here the tests create both token accounts up front. +- **A hand-declared `LastRestartSlot` sysvar.** quasar-lang ships only the + Clock and Rent sysvars, so `src/last_restart.rs` declares the 8-byte layout + itself and reads it with the same `sol_get_sysvar` syscall. + `read_oracle_price` uses it to reject prices published before a cluster + restart, which slot-based staleness alone cannot catch (a halt passes hours + of wall-clock time in zero slots). - **Oracle feed in tests.** Rather than a separate mock-oracle program, the tests write the feed account's bytes directly (price, scale, last-update slot, confidence) and the program reads them the same way it would read a @@ -30,7 +36,7 @@ They build the program, set up both mints, an oracle feed at $165, and an operator with funded inventory, then verify the quote math to the minor unit in both directions, the exact 1.65 USDC round-trip spread, oracle repricing and re-quoting, the operator's full exit, and that every gate shuts: slippage, -staleness, confidence, pause, zero amounts, inventory bounds, and operator +staleness, restart handling, confidence, pause, zero amounts, inventory bounds, and operator access control. ```bash diff --git a/finance/prop-amm/quasar/src/instructions/shared.rs b/finance/prop-amm/quasar/src/instructions/shared.rs index 4ebe82a4..cd34a30c 100644 --- a/finance/prop-amm/quasar/src/instructions/shared.rs +++ b/finance/prop-amm/quasar/src/instructions/shared.rs @@ -4,9 +4,10 @@ //! favoring the market. Errors are `ProgramError::Custom(code)`; the codes are //! listed here. -use quasar_lang::prelude::*; +use quasar_lang::{prelude::*, sysvars::Sysvar}; use crate::constants::{BASIS_POINTS_DENOMINATOR, MAX_PRICE_STALENESS_SLOTS}; +use crate::last_restart::LastRestartSlot; pub mod error { pub const ZERO_AMOUNT: u32 = 0; @@ -22,6 +23,7 @@ pub mod error { pub const AMOUNT_ROUNDS_TO_ZERO: u32 = 10; pub const INVARIANT_VIOLATED: u32 = 11; pub const INVALID_DIRECTION: u32 = 12; + pub const PRICE_PREDATES_RESTART: u32 = 13; } #[inline(always)] @@ -90,6 +92,17 @@ pub fn read_oracle_price( return Err(err(error::STALE_PRICE)); } + // Restart handling. A cluster halt stops the slot count but not the wall + // clock, so after a restart a feed can look fresh in slots while its + // price is hours old. For a market maker that is a free option for + // whoever trades first, so reject any price stamped at or before the + // restart slot; the market refuses to quote until the publisher posts + // again. Zero means the cluster has never restarted. + let last_restart = u64::from(LastRestartSlot::get()?.last_restart_slot); + if last_restart != 0 && last_update_slot <= last_restart { + return Err(err(error::PRICE_PREDATES_RESTART)); + } + // Confidence band as a fraction of price, in basis points, must stay // within the market's limit. Widen to u128 so the product cannot overflow. let confidence_bps = (confidence as u128) diff --git a/finance/prop-amm/quasar/src/last_restart.rs b/finance/prop-amm/quasar/src/last_restart.rs new file mode 100644 index 00000000..61e6e2dd --- /dev/null +++ b/finance/prop-amm/quasar/src/last_restart.rs @@ -0,0 +1,77 @@ +//! The LastRestartSlot sysvar: the slot of the most recent cluster restart, +//! or 0 if the cluster has never restarted (SIMD-0047). quasar-lang ships +//! only the Clock and Rent sysvars, so this program declares the 8-byte +//! layout itself and reads it through the same `sol_get_sysvar` syscall +//! quasar's own sysvars use. +//! +//! Why the program reads it: a halt stops the slot count but not the wall +//! clock, so after a restart an oracle price can look fresh in slots while +//! its value is hours old. `shared::read_oracle_price` rejects any price +//! stamped at or before the restart slot, so the market refuses to quote +//! until the publisher posts again. + +use quasar_lang::{pod::PodU64, prelude::Address, sysvars::Sysvar}; +use solana_program_error::ProgramError; + +/// `SysvarLastRestartS1ot1111111111111111111111`, decoded at compile time. +const LAST_RESTART_SLOT_ID: Address = + quasar_lang::prelude::address!("SysvarLastRestartS1ot1111111111111111111111"); + +/// The sysvar's whole data: one little-endian u64. +#[repr(C)] +#[derive(Copy, Clone)] +pub struct LastRestartSlot { + pub last_restart_slot: PodU64, +} + +const _: () = assert!(core::mem::size_of::() == 8); +const _: () = assert!(core::mem::align_of::() == 1); + +// Written out by hand rather than with quasar-lang's `impl_sysvar_get!`: the +// macro's expansion names private quasar-lang constants, so it only works +// inside that crate. The sysvar is 8 bytes with no padding, so the syscall +// fills the whole struct. +impl Sysvar for LastRestartSlot { + const ID: Address = LAST_RESTART_SLOT_ID; + + #[inline(always)] + unsafe fn from_bytes_unchecked(bytes: &[u8]) -> &Self { + // SAFETY: the caller guarantees `bytes` holds at least 8 bytes of + // valid sysvar data; the struct is `#[repr(C)]` with alignment 1, + // so the pointer cast is always valid. + unsafe { &*(bytes.as_ptr() as *const Self) } + } + + fn get() -> Result { + let mut var = core::mem::MaybeUninit::::uninit(); + let var_addr = var.as_mut_ptr() as *mut u8; + + #[cfg(any(target_os = "solana", target_arch = "bpf"))] + // SAFETY: `var_addr` points at 8 writable bytes and the syscall + // writes exactly 8. + let result = unsafe { + solana_define_syscall::definitions::sol_get_sysvar( + &LAST_RESTART_SLOT_ID as *const _ as *const u8, + var_addr, + 0, + core::mem::size_of::() as u64, + ) + }; + + // Off-chain (IDL builds, client compilation) the sysvar reads as + // zero: the cluster has never restarted. + #[cfg(not(any(target_os = "solana", target_arch = "bpf")))] + let result: u64 = { + // SAFETY: `var_addr` points at 8 writable bytes. + unsafe { var_addr.write_bytes(0, core::mem::size_of::()) }; + 0 + }; + + match result { + // SAFETY: on success the syscall (or the zeroing above) has + // initialized all 8 bytes. + 0 => Ok(unsafe { var.assume_init() }), + _ => Err(ProgramError::UnsupportedSysvar), + } + } +} diff --git a/finance/prop-amm/quasar/src/lib.rs b/finance/prop-amm/quasar/src/lib.rs index 795c5eec..548cfde6 100644 --- a/finance/prop-amm/quasar/src/lib.rs +++ b/finance/prop-amm/quasar/src/lib.rs @@ -9,6 +9,7 @@ use quasar_lang::prelude::*; mod constants; mod instructions; +mod last_restart; pub mod state; #[cfg(test)] mod tests; diff --git a/finance/prop-amm/quasar/src/tests.rs b/finance/prop-amm/quasar/src/tests.rs index 802f10e4..3dddac97 100644 --- a/finance/prop-amm/quasar/src/tests.rs +++ b/finance/prop-amm/quasar/src/tests.rs @@ -91,6 +91,24 @@ fn make_price_stale(test: &mut Test) { set_feed_at_slot(test, dollars(165), SLOT - 151, 0); } +/// Pin the LastRestartSlot sysvar account, simulating a cluster restart at +/// `slot`: prices stamped at or before it must be rejected until the +/// publisher posts again. The sysvar's whole data is one little-endian u64. +fn set_last_restart_slot(test: &mut Test, slot: u64) { + let sysvar_id: Pubkey = "SysvarLastRestartS1ot1111111111111111111111" + .parse() + .unwrap(); + let sysvar_owner: Pubkey = "Sysvar1111111111111111111111111111111111111" + .parse() + .unwrap(); + test.set_account(Account::new( + sysvar_id, + sysvar_owner, + 1_169_280, + slot.to_le_bytes().to_vec(), + )); +} + fn init_market(test: &mut Test, spread_bps: u16) -> Outcome { test.send(InitializeMarketInstruction { operator: OPERATOR, @@ -375,6 +393,30 @@ fn swap_rejects_stale_price(test: &mut Test) { ); } +/// A cluster restart passes hours of wall-clock time in zero slots, so a +/// price published before the halt can still look fresh by slot count. The +/// market must refuse to quote against it until the publisher posts again. +#[quasar_test] +fn swap_rejects_price_from_before_a_restart(test: &mut Test) { + let env = setup(test); + fund_trader(test, TRADER, TRADER_BASE, TRADER_QUOTE, 0, 825_825_000 * 2); + + // The feed is stamped at `SLOT - 5`: fresh by the 150-slot staleness + // bound, but published before a restart at `SLOT - 3`, so only the + // restart check can catch it. + set_feed_at_slot(test, dollars(165), SLOT - 5, 0); + set_last_restart_slot(test, SLOT - 3); + assert!( + swap(test, &env, TRADER, TRADER_BASE, TRADER_QUOTE, DIRECTION_BUY_BASE, 825_825_000, 0).is_err(), + "a pre-restart price must be rejected even inside the staleness bound" + ); + + // Publishing after the restart (at `SLOT`) reopens the market. + set_feed(test, dollars(165), 0); + swap(test, &env, TRADER, TRADER_BASE, TRADER_QUOTE, DIRECTION_BUY_BASE, 825_825_000, 0) + .succeeds(); +} + /// A price the oracle itself is unsure about is rejected: the confidence band /// (about 1.2% here) exceeds the market's 1% limit. #[quasar_test]