Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions finance/lending/anchor/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
5 changes: 4 additions & 1 deletion finance/lending/anchor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions finance/lending/anchor/programs/lending/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions finance/lending/anchor/programs/lending/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
14 changes: 14 additions & 0 deletions finance/lending/anchor/programs/lending/src/state/price_feed.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions finance/lending/anchor/programs/lending/tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]`).
Expand Down
31 changes: 31 additions & 0 deletions finance/lending/anchor/programs/lending/tests/test_borrow_repay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
14 changes: 14 additions & 0 deletions finance/lending/quasar/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
11 changes: 11 additions & 0 deletions finance/lending/quasar/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
7 changes: 7 additions & 0 deletions finance/lending/quasar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions finance/lending/quasar/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ pub enum LendingError {
WrongReserve,
LiquidationTooLarge,
NothingToCollect,
PricePredatesRestart,
}
77 changes: 77 additions & 0 deletions finance/lending/quasar/src/last_restart.rs
Original file line number Diff line number Diff line change
@@ -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::<LastRestartSlot>() == 8);
const _: () = assert!(core::mem::align_of::<LastRestartSlot>() == 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<Self, ProgramError> {
let mut var = core::mem::MaybeUninit::<Self>::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::<Self>() 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::<Self>()) };
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),
}
}
}
1 change: 1 addition & 0 deletions finance/lending/quasar/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ use quasar_lang::prelude::*;
mod constants;
mod error;
mod instructions;
mod last_restart;
mod logic;
mod math;
mod state;
Expand Down
14 changes: 14 additions & 0 deletions finance/lending/quasar/src/logic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
};
Expand Down Expand Up @@ -105,6 +106,19 @@ pub fn price_scaled(feed: &Account<PriceFeed>, slot: u64) -> Result<u128, Progra
.checked_sub(last_updated)
.ok_or(LendingError::MathOverflow)?;
require!(age <= MAX_PRICE_STALENESS_SLOTS, LendingError::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. 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 = u64::from(LastRestartSlot::get()?.last_restart_slot);
require!(
last_restart == 0 || last_updated > 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))
Expand Down
25 changes: 25 additions & 0 deletions finance/lending/quasar/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
9 changes: 9 additions & 0 deletions finance/perpetual-futures/anchor/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions finance/perpetual-futures/anchor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Loading
Loading