From 66ad601915c201402d5f31211e0d7c8895e69757 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 21:30:31 +0000 Subject: [PATCH 1/3] finance: reject oracle prices from before a cluster restart A cluster halt stops the slot count but not the wall clock, so after a restart an oracle price can pass a slot-measured staleness bound while its value is hours old. The three oracle-priced examples (lending, prop-amm, perpetual-futures, both Anchor and Quasar variants) now also require the feed's slot to be after the LastRestartSlot sysvar's slot, failing with PricePredatesRestart until the publisher posts again. Each variant gets a test that simulates a restart inside the staleness window and proves a fresh publication reopens the market. quasar-lang ships only the Clock and Rent sysvars, so each Quasar variant declares the sysvar's 8-byte layout in src/last_restart.rs and reads it through the same sol_get_sysvar syscall (hand-implemented: quasar-lang's impl_sysvar_get! macro names private constants and cannot expand outside that crate). Also pins zeropod = "=0.3.3" in the three Quasar variants: zeropod 0.3.4 moved to wincode 0.5 while quasar-lang's pinned rev stays on wincode 0.4, so any fresh resolve (no lockfile is committed) split the graph across two wincode versions and failed every Pod* trait bound. The other Quasar examples in this repository have the same latent break and will need the same pin or an upstream fix. All six workspaces pass cargo check --workspace --all-targets. The LiteSVM/quasar-test suites need cargo build-sbf, whose platform-tools download is blocked in this environment, so CI is the test signal for this change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ABFpszmPxRorwWtWGR2jXj --- CHANGELOG.md | 10 +++ finance/lending/anchor/CHANGELOG.md | 9 +++ finance/lending/anchor/README.md | 5 +- .../anchor/programs/lending/Cargo.toml | 4 + .../anchor/programs/lending/src/errors.rs | 2 + .../programs/lending/src/state/price_feed.rs | 14 ++++ .../programs/lending/tests/common/mod.rs | 8 ++ .../lending/tests/test_borrow_repay.rs | 27 +++++++ finance/lending/quasar/CHANGELOG.md | 14 ++++ finance/lending/quasar/Cargo.toml | 11 +++ finance/lending/quasar/README.md | 7 ++ finance/lending/quasar/src/error.rs | 1 + finance/lending/quasar/src/last_restart.rs | 77 +++++++++++++++++++ finance/lending/quasar/src/lib.rs | 1 + finance/lending/quasar/src/logic.rs | 14 ++++ finance/lending/quasar/src/tests.rs | 25 ++++++ finance/perpetual-futures/anchor/CHANGELOG.md | 9 +++ finance/perpetual-futures/anchor/README.md | 4 +- .../programs/perpetual-futures/Cargo.toml | 4 + .../programs/perpetual-futures/src/errors.rs | 3 + .../perpetual-futures/src/state/oracle.rs | 13 ++++ .../tests/test_perpetual_futures.rs | 56 ++++++++++++++ finance/perpetual-futures/quasar/CHANGELOG.md | 11 +++ finance/perpetual-futures/quasar/Cargo.toml | 11 +++ finance/perpetual-futures/quasar/README.md | 6 ++ .../quasar/src/instructions/shared.rs | 16 +++- .../quasar/src/last_restart.rs | 77 +++++++++++++++++++ finance/perpetual-futures/quasar/src/lib.rs | 1 + finance/perpetual-futures/quasar/src/tests.rs | 70 ++++++++++++++++- finance/prop-amm/anchor/CHANGELOG.md | 10 +++ finance/prop-amm/anchor/README.md | 8 +- .../anchor/programs/prop-amm/Cargo.toml | 4 + .../anchor/programs/prop-amm/src/errors.rs | 3 + .../programs/prop-amm/src/state/oracle.rs | 13 ++++ .../programs/prop-amm/tests/test_prop_amm.rs | 39 ++++++++++ finance/prop-amm/quasar/CHANGELOG.md | 11 +++ finance/prop-amm/quasar/Cargo.toml | 11 +++ finance/prop-amm/quasar/README.md | 8 +- .../quasar/src/instructions/shared.rs | 15 +++- finance/prop-amm/quasar/src/last_restart.rs | 77 +++++++++++++++++++ finance/prop-amm/quasar/src/lib.rs | 1 + finance/prop-amm/quasar/src/tests.rs | 42 ++++++++++ 42 files changed, 742 insertions(+), 10 deletions(-) create mode 100644 finance/lending/quasar/src/last_restart.rs create mode 100644 finance/perpetual-futures/quasar/src/last_restart.rs create mode 100644 finance/prop-amm/quasar/src/last_restart.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fda7adfe..07a6002c3 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 369a7f2b6..8b95d1d5c 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 8b46c6c38..2891f81dd 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 da36509a4..2e7be6417 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 332906a25..f0a9e2a5c 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 was last updated before the most recent 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 aa4f47859..6d7cc8b82 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 b51bacdbb..99210fe89 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 4b430e9fa..751460f2b 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,33 @@ 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. + env.set_price(collateral.mint, dollars(1)); + env.set_price(borrow.mint, dollars(1)); + env.try_borrow(&borrower, obligation, &[&collateral], &[&borrow], &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 d0f2b2e37..8c386e2ee 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 2fa4885ca..9fea3faf4 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 35d960fad..99c2e2815 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 8d3b5e635..51933e41f 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 000000000..e83938733 --- /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 519522808..0e90b8c76 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 a2a53b7f4..9ac2cfecf 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 41de2e43f..e8d2d663b 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 4f1163881..ecd49fe12 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 e7bf4d64e..617df0dff 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 6a4778959..5c8300a5d 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 442e93f82..3c71afa19 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 was last updated before the most recent 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 648136774..e05b7ce32 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 4bf2809df..ffb083b6a 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,50 @@ 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. + 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 939b6e87b..5a9b02bc4 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 f29400ec5..eaf1435b5 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 81ec35b08..dfcda2cfd 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 9e02f8c76..a11065328 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 000000000..bf7abbe7f --- /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 995baa0ba..245aff216 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 4dfe6539e..961d35b0d 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 fe14b7b24..a011befe1 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 783642a2a..9b94124f0 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 a13aa36c5..fb8a4f9a5 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 10d9cfcab..350097f1a 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 was last updated before the most recent 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 09c6ed947..83479816a 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 2ea29b011..556cd0dd3 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,33 @@ 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. + 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 c866b606d..902d8d24a 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 35311d86c..cb2e6483b 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 dea6c39fe..c377fff55 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 4ebe82a4f..cd34a30c3 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 000000000..61e6e2dd3 --- /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 795c5eec2..548cfde68 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 802f10e42..3dddac97c 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] From 048d4e0985fbdb206bb70a1d47aa6af9c40fa882 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 21:53:25 +0000 Subject: [PATCH 2/3] finance: fix the restart tests' second leg All three restart tests asserted the rejection correctly and then failed on the retry, for two reasons. The retry transaction was byte-identical to the one that was rejected, so it carried the same signature and LiteSVM dropped it as already processed. Warping a slot before republishing the price expires the blockhash, and matches what actually happens: the publisher posts again after the restart. The lending retry also passed the borrow reserve in the obligation's existing-borrows refresh list. The first borrow was rejected, so it recorded nothing and the obligation still had no borrows, which refresh_obligation reported as InvalidObligationAccount (6015). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ABFpszmPxRorwWtWGR2jXj --- .../anchor/programs/lending/tests/test_borrow_repay.rs | 8 ++++++-- .../perpetual-futures/tests/test_perpetual_futures.rs | 5 ++++- .../anchor/programs/prop-amm/tests/test_prop_amm.rs | 5 ++++- 3 files changed, 14 insertions(+), 4 deletions(-) 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 751460f2b..181fffac4 100644 --- a/finance/lending/anchor/programs/lending/tests/test_borrow_repay.rs +++ b/finance/lending/anchor/programs/lending/tests/test_borrow_repay.rs @@ -89,10 +89,14 @@ fn borrow_with_price_from_before_a_restart_is_rejected() { "a pre-restart price must be rejected even inside the staleness window" ); - // Publishing after the restart reopens the market. + // 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], &borrow, 100_000_000) + env.try_borrow(&borrower, obligation, &[&collateral], &[], &borrow, 100_000_000) .expect("a freshly published price must be accepted after a restart"); } 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 ffb083b6a..3055ffead 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 @@ -812,7 +812,10 @@ fn test_open_rejects_price_from_before_a_restart() { ) .is_err()); - // Publishing after the restart reopens the pool. + // 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( 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 556cd0dd3..5f10fb4a6 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 @@ -668,7 +668,10 @@ fn test_swap_rejects_price_from_before_a_restart() { .swap(&alice, Direction::BuyBase, 825_825_000, 0) .is_err()); - // Publishing after the restart reopens the market. + // 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) From 08f910bc86958c770614910806a3f0de42ec5235 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 21:06:39 +0000 Subject: [PATCH 3/3] finance: say why a pre-restart price is rejected "Price feed was last updated before the most recent cluster restart" states the condition that tripped and leaves the reader to work out why anyone cares. Lead with the consequence instead, in the voice the neighbouring staleness errors already use: the price is stale, and the restart is why. Anchor variants only. The Quasar variants raise bare numeric codes with no message strings attached. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ABFpszmPxRorwWtWGR2jXj --- finance/lending/anchor/programs/lending/src/errors.rs | 2 +- .../anchor/programs/perpetual-futures/src/errors.rs | 2 +- finance/prop-amm/anchor/programs/prop-amm/src/errors.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/finance/lending/anchor/programs/lending/src/errors.rs b/finance/lending/anchor/programs/lending/src/errors.rs index f0a9e2a5c..c9c146719 100644 --- a/finance/lending/anchor/programs/lending/src/errors.rs +++ b/finance/lending/anchor/programs/lending/src/errors.rs @@ -18,7 +18,7 @@ pub enum LendingError { ObligationStale, #[msg("Price feed has not been updated recently enough")] StalePriceFeed, - #[msg("Price feed was last updated before the most recent cluster restart")] + #[msg("Price feed is stale: it predates the last cluster restart")] PricePredatesRestart, #[msg("Price feed reported a non-positive price")] InvalidOraclePrice, 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 3c71afa19..94509f063 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/errors.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/errors.rs @@ -56,6 +56,6 @@ pub enum PerpError { #[msg("No protocol fees are available to collect")] NothingToClaim, - #[msg("Oracle price was last updated before the most recent cluster restart")] + #[msg("Oracle price is stale: it predates the last cluster restart")] PricePredatesRestart, } 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 350097f1a..1f622fc1e 100644 --- a/finance/prop-amm/anchor/programs/prop-amm/src/errors.rs +++ b/finance/prop-amm/anchor/programs/prop-amm/src/errors.rs @@ -41,6 +41,6 @@ pub enum PropAmmError { #[msg("Oracle price confidence band is too wide to trust")] OracleConfidenceTooWide, - #[msg("Oracle price was last updated before the most recent cluster restart")] + #[msg("Oracle price is stale: it predates the last cluster restart")] PricePredatesRestart, }