Skip to content
Merged
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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ 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-09-22] - Vault strategy rejects prices from before a cluster restart

The vault strategy checks Pyth freshness in seconds. Under Alpenglow each
leader sets the Clock's `unix_timestamp`, which may advance by at most twice
the slot time elapsed since the parent block, so after a halt the timestamp
trails real time and catches up gradually, and a price published just before
a multi-hour halt still passes the 60-second check.

- `finance/vault-strategy` (Anchor v2, Anchor v1 and Quasar) reads each Pyth
update's `posted_slot` and rejects it when it is at or before the
`LastRestartSlot` sysvar's slot, with the new `PricePredatesRestart` error,
as the lending, prop-amm and perpetual-futures examples already do. Tests,
READMEs, PRODUCT.md files, the web apps' IDLs and changelogs follow.

## [2026-09-22] - The order book's vaults are market PDAs

The order book created its base, quote, and fee vaults at public keys the client
Expand Down
4 changes: 4 additions & 0 deletions finance/vault-strategy/anchor-v1/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## 2026-09-22

- **Prices from before a cluster restart are rejected.** Under Alpenglow each leader sets the Clock's `unix_timestamp`, which may advance by at most twice the slot time elapsed since the parent block, so after a halt the timestamp trails real time and catches up gradually. The 60-second `publish_time` check would therefore accept a Pyth price published just before a multi-hour halt. `load_price` now also reads the update's `posted_slot` (offset 125) and requires it to be after the `LastRestartSlot` sysvar's slot, failing with the new `PricePredatesRestart` error until Pyth posts again. Tested by `test_deposit_rejects_price_from_before_restart`; the web app's IDL gains the error.

## 2026-09-10

- **The mock swap router signs as its config account.** The router used a second, dataless PDA as the owner of its USDC treasury and the mint authority of the basket mints. That PDA is removed: `router_config` (`["router_config"]`) now owns the treasury, is the mint authority of the basket mints, and signs the router's `mint_to` and `transfer_checked` CPIs with its own seeds and stored bump, mirroring how the strategy PDA signs for the share mint and vaults. The extra account is gone from every router instruction, from the vault's `deposit` and `rebalance` contexts, and from the router CPI account lists; the treasury is now the `router_config` account's USDC associated token account. Tests and the web app's PDA helpers, instruction builders, and IDL follow.
Expand Down
2 changes: 1 addition & 1 deletion finance/vault-strategy/anchor-v1/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ A **multi-asset** strategy built from single-asset vaults, transparently priced

- Solana wallet–connected web app; users sign transactions in-wallet.
- Runs against the deployed program on **devnet with mock tokens** for the demo. Portfolio assets in the example are **TSLAx** (Tesla) and **NVDAx** (NVIDIA) — xStocks issued on Solana by Backed Finance; mock tokens in tests.
- Prices come from **Pyth Network** `PriceUpdateV2` accounts with a 60-second staleness window; the client must supply fresh price accounts for pricing operations.
- Prices come from **Pyth Network** `PriceUpdateV2` accounts with a 60-second staleness window; a price posted before the last cluster restart is rejected too, so after a halt deposits and rebalances pause until Pyth posts again. The client must supply fresh price accounts for pricing operations.
- Swaps in the example route through a **test-only mock swap router** (a fake Jupiter) that would be replaced by real [Jupiter](https://jup.ag) in production.
- Baskets beyond ~3 assets exceed a legacy transaction's 1232-byte limit, so the client must send **v0 transactions with an Address Lookup Table**; `deposit` pulls `14 + 5N` accounts and `withdraw` `10 + 4N` for `N` assets.

Expand Down
4 changes: 2 additions & 2 deletions finance/vault-strategy/anchor-v1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Because the asset set is dynamic, `deposit` must value *every* asset. The assets

Referencing every asset has a transaction-size cost: `deposit` pulls in `14 + 5N` accounts and `withdraw` `10 + 4N`, where `N` is the asset count. That stays within Solana's 128-account transaction lock limit at the `MAX_ASSETS` cap of 16 (94 accounts for `deposit`), but a basket beyond roughly three assets no longer fits a legacy transaction's 1232-byte limit, so the client must send a v0 transaction with an [Address Lookup Table](https://docs.anza.xyz/proposals/versioned-transactions).

Prices come from [Pyth Network](https://pyth.network/) `PriceUpdateV2` accounts. A 60-second staleness window is enforced; zero or negative prices are rejected.
Prices come from [Pyth Network](https://pyth.network/) `PriceUpdateV2` accounts. A 60-second staleness window is enforced; zero or negative prices are rejected, and so is any price posted at or before the last cluster restart (`PricePredatesRestart`), which the seconds check alone cannot catch after a halt.

### Shares

Expand Down Expand Up @@ -114,7 +114,7 @@ A price move pushes the basket off target. `rebalance(sell_amount, usdc_to_inves

## Oracle Integration (Pyth)

`PriceUpdateV2` price (i64) is read at byte offset 73 and `publish_time` at 93, directly from account bytes to avoid borsh version incompatibility with Anchor. Pyth USD pairs use exponent −8; with USDC and the basket tokens all at 6 decimals, value in USDC minor units is `amount × price / 10⁸`. Each asset's feed pubkey is fixed in its `AssetConfig` (copied from the registry), and validated on every read. In tests, mock `PriceUpdateV2` accounts are injected into LiteSVM (TSLAx $250, NVDAx $180).
`PriceUpdateV2` price (i64) is read at byte offset 73, `publish_time` at 93 and `posted_slot` (u64) at 125, directly from account bytes to avoid borsh version incompatibility with Anchor. Under Alpenglow each leader sets the Clock's `unix_timestamp`, which may advance by at most twice the slot time elapsed since the parent block, so after a halt the timestamp trails real time and a price published just before the halt can still look fresh by seconds. `load_price` therefore also requires `posted_slot` to be after the `LastRestartSlot` sysvar's slot (0 means the cluster has never restarted), pausing deposits and rebalances until Pyth posts again. Pyth USD pairs use exponent −8; with USDC and the basket tokens all at 6 decimals, value in USDC minor units is `amount × price / 10⁸`. Each asset's feed pubkey is fixed in its `AssetConfig` (copied from the registry), and validated on every read. In tests, mock `PriceUpdateV2` accounts are injected into LiteSVM (TSLAx $250, NVDAx $180).

---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1084,6 +1084,11 @@
"code": 6025,
"name": "FeeTooHigh",
"msg": "Management fee exceeds the maximum allowed"
},
{
"code": 6026,
"name": "PricePredatesRestart",
"msg": "Price feed is stale: it predates the last cluster restart"
}
],
"types": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ custom-panic = []
[dependencies]
anchor-lang = { version = "1.2.0", features = ["init-if-needed"] }
anchor-spl = "1.2.0"
# For the LastRestartSlot sysvar (not re-exported by anchor-lang): the oracle
# 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"
mock-swap-router = { path = "../mock-swap-router", features = ["cpi"] }

[dev-dependencies]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,6 @@ pub enum VaultError {
InvalidSwapRouter,
#[msg("Management fee exceeds the maximum allowed")]
FeeTooHigh,
#[msg("Price feed is stale: it predates the last cluster restart")]
PricePredatesRestart,
}
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::error::VaultError;

Expand All @@ -8,6 +9,9 @@ const PYTH_PRICE_OFFSET: usize = 73;
/// Byte offset of `publish_time` (i64):
/// price(8) + conf(8) + exponent(4) = +20 bytes after price
const PYTH_PUBLISH_TIME_OFFSET: usize = PYTH_PRICE_OFFSET + 8 + 8 + 4; // 93
/// Byte offset of `posted_slot` (u64), the slot the update was posted in:
/// publish_time(8) + prev_publish_time(8) + ema_price(8) + ema_conf(8) = +32 bytes
const PYTH_POSTED_SLOT_OFFSET: usize = PYTH_PUBLISH_TIME_OFFSET + 8 + 8 + 8 + 8; // 125
/// Pyth USD pairs use exponent -8 (price * 10^-8 = dollars per token).
pub const PYTH_PRICE_PRECISION: u128 = 100_000_000; // 10^8
/// Prices older than this (seconds) are rejected.
Expand All @@ -22,8 +26,9 @@ const TOKEN_OWNER_OFFSET: usize = 32;
/// `mint` Pubkey is at bytes 0..32.
const TOKEN_MINT_OFFSET: usize = 0;

fn read_pyth_raw(account_data: &[u8]) -> Result<(i64, i64)> {
if account_data.len() < PYTH_PUBLISH_TIME_OFFSET + 8 {
/// Returns `(price, publish_time, posted_slot)`.
fn read_pyth_raw(account_data: &[u8]) -> Result<(i64, i64, u64)> {
if account_data.len() < PYTH_POSTED_SLOT_OFFSET + 8 {
return err!(VaultError::InvalidPriceFeed);
}
let price = i64::from_le_bytes(
Expand All @@ -36,11 +41,17 @@ fn read_pyth_raw(account_data: &[u8]) -> Result<(i64, i64)> {
.try_into()
.map_err(|_| VaultError::InvalidPriceFeed)?,
);
Ok((price, publish_time))
let posted_slot = u64::from_le_bytes(
account_data[PYTH_POSTED_SLOT_OFFSET..PYTH_POSTED_SLOT_OFFSET + 8]
.try_into()
.map_err(|_| VaultError::InvalidPriceFeed)?,
);
Ok((price, publish_time, posted_slot))
}

/// Validate a price feed account against the one the strategy registered, then
/// return its positive, fresh price as u128. `now` is the current unix timestamp.
/// A price posted at or before the last cluster restart is rejected too.
pub fn load_price(price_feed: &AccountInfo, expected_key: &Pubkey, now: i64) -> Result<u128> {
require_keys_eq!(
price_feed.key(),
Expand All @@ -49,7 +60,7 @@ pub fn load_price(price_feed: &AccountInfo, expected_key: &Pubkey, now: i64) ->
);

let data = price_feed.try_borrow_data()?;
let (price, publish_time) = read_pyth_raw(&data)?;
let (price, publish_time, posted_slot) = read_pyth_raw(&data)?;

require!(price > 0, VaultError::NegativePrice);
require!(
Expand All @@ -59,6 +70,20 @@ pub fn load_price(price_feed: &AccountInfo, expected_key: &Pubkey, now: i64) ->
VaultError::StalePriceFeed
);

// Restart handling. The staleness check above measures seconds against the
// Clock's unix_timestamp, which under Alpenglow may advance by at most
// twice the slot time elapsed since the parent block. A halt barely moves
// the slot count, so after a restart the timestamp trails real time and a
// price posted just before the halt can still pass the 60-second bound.
// Reject any price posted at or before the restart slot; deposits and
// rebalances then pause until Pyth posts again, rather than valuing the
// vault at a pre-halt price. Zero means the cluster has never restarted.
let last_restart = LastRestartSlot::get()?.last_restart_slot;
require!(
last_restart == 0 || posted_slot > last_restart,
VaultError::PricePredatesRestart
);

Ok(price as u128)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,13 @@ fn derive_ata(wallet: &Pubkey, mint: &Pubkey) -> Pubkey {
}

/// Mock PriceUpdateV2 layout (see pyth-solana-receiver-sdk): price i64 at 73,
/// publish_time i64 at 93. Exponent -8.
fn build_mock_price_update_account(price: i64, exponent: i32, publish_time: i64) -> Vec<u8> {
/// publish_time i64 at 93, posted_slot u64 at 125. Exponent -8.
fn build_mock_price_update_account(
price: i64,
exponent: i32,
publish_time: i64,
posted_slot: u64,
) -> Vec<u8> {
let discriminator: [u8; 8] = [34, 241, 35, 99, 157, 126, 244, 205];
let mut data = Vec::with_capacity(133);
data.extend_from_slice(&discriminator);
Expand All @@ -60,12 +65,17 @@ fn build_mock_price_update_account(price: i64, exponent: i32, publish_time: i64)
data.extend_from_slice(&(publish_time - 1).to_le_bytes());
data.extend_from_slice(&price.to_le_bytes());
data.extend_from_slice(&120_000u64.to_le_bytes());
data.extend_from_slice(&1u64.to_le_bytes());
data.extend_from_slice(&posted_slot.to_le_bytes());
data
}

fn set_price_feed(svm: &mut LiteSVM, key: Pubkey, price: i64) {
let data = build_mock_price_update_account(price, -8, PUBLISH_TIME);
set_price_feed_posted_at(svm, key, price, 1);
}

/// Write a Pyth feed as if Pyth posted it in `posted_slot`.
fn set_price_feed_posted_at(svm: &mut LiteSVM, key: Pubkey, price: i64, posted_slot: u64) {
let data = build_mock_price_update_account(price, -8, PUBLISH_TIME, posted_slot);
let rent = svm.minimum_balance_for_rent_exemption(data.len());
svm.set_account(
key,
Expand Down Expand Up @@ -996,6 +1006,57 @@ fn test_deposit_rejects_unregistered_router() {
);
}

/// Simulate a cluster restart at `slot`: prices posted at or before it must be
/// rejected until Pyth posts again.
fn set_last_restart_slot(ctx: &mut TestContext, slot: u64) {
ctx.svm
.set_sysvar(&solana_sysvar::last_restart_slot::LastRestartSlot {
last_restart_slot: slot,
});
}

/// Under Alpenglow the Clock's unix_timestamp may only advance by up to twice
/// the slot time elapsed since the parent block, so after a halt it trails real
/// time and a price published just before the halt still passes the 60-second
/// staleness check. The vault must reject any price posted at or before the
/// restart slot until Pyth posts again.
#[test]
fn test_deposit_rejects_price_from_before_restart() {
let mut ctx = setup_full();
standard_strategy(&mut ctx);
let user = fund_user(&mut ctx, 10_000_000);

// Both feeds were posted at slot 1. Simulate a halt: the cluster restarts
// at slot 3, and the timestamp has barely moved, so the pre-halt prices are
// still well inside the 60-second window and only the restart check can
// catch them.
let clock = ctx.svm.get_sysvar::<Clock>();
ctx.svm.set_sysvar(&Clock {
slot: 5,
unix_timestamp: PUBLISH_TIME + 2,
..clock
});
set_last_restart_slot(&mut ctx, 3);

let ix = deposit_instruction(&ctx, &user, 10_000_000, 1, deposit_remaining(&ctx));
let message =
send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&user], &user.pubkey())
.expect_err("a deposit priced before the restart must fail");
let code = vault_strategy::error::VaultError::PricePredatesRestart as u32 + 6000;
assert!(
message.to_string().contains(&format!("Custom({code})")),
"expected PricePredatesRestart (Custom({code})), got: {message}"
);

// Pyth posting again after the restart reopens the vault. Fresh blockhash
// so the retry is not byte-identical to the rejected deposit.
set_price_feed_posted_at(&mut ctx.svm, ctx.price_feed_tsla, TSLA_PRICE, 4);
set_price_feed_posted_at(&mut ctx.svm, ctx.price_feed_nvda, NVDA_PRICE, 4);
ctx.svm.expire_blockhash();
do_deposit(&mut ctx, &user, 10_000_000, 1);
assert!(get_token_account_balance(&ctx.svm, &ctx.vault_tsla).unwrap() > 0);
}

#[test]
fn test_deposit_fair_pricing() {
let mut ctx = setup_full();
Expand Down
4 changes: 4 additions & 0 deletions finance/vault-strategy/anchor/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## 2026-09-22

- **Prices from before a cluster restart are rejected.** Under Alpenglow each leader sets the Clock's `unix_timestamp`, which may advance by at most twice the slot time elapsed since the parent block, so after a halt the timestamp trails real time and catches up gradually. The 60-second `publish_time` check would therefore accept a Pyth price published just before a multi-hour halt. `load_price` now also reads the update's `posted_slot` (offset 125) and requires it to be after the `LastRestartSlot` sysvar's slot, failing with the new `PricePredatesRestart` error until Pyth posts again. Tested by `test_deposit_rejects_price_from_before_restart`; the web app's IDL gains the error.

## 2026-09-10

- **The mock swap router signs as its config account.** The router used a second, dataless PDA as the owner of its USDC treasury and the mint authority of the basket mints. That PDA is removed: `router_config` (`["router_config"]`) now owns the treasury, is the mint authority of the basket mints, and signs the router's `mint_to` and `transfer_checked` CPIs with its own seeds and stored bump, mirroring how the strategy PDA signs for the share mint and vaults. The extra account is gone from every router instruction, from the vault's `deposit` and `rebalance` contexts, and from the router CPI account lists; the treasury is now the `router_config` account's USDC associated token account. Tests and the web app's PDA helpers, instruction builders, and IDL follow.
Expand Down
2 changes: 1 addition & 1 deletion finance/vault-strategy/anchor/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ A **multi-asset** strategy built from single-asset vaults, transparently priced

- Solana wallet–connected web app; users sign transactions in-wallet.
- Runs against the deployed program on **devnet with mock tokens** for the demo. Portfolio assets in the example are **TSLAx** (Tesla) and **NVDAx** (NVIDIA) — xStocks issued on Solana by Backed Finance; mock tokens in tests.
- Prices come from **Pyth Network** `PriceUpdateV2` accounts with a 60-second staleness window; the client must supply fresh price accounts for pricing operations.
- Prices come from **Pyth Network** `PriceUpdateV2` accounts with a 60-second staleness window; a price posted before the last cluster restart is rejected too, so after a halt deposits and rebalances pause until Pyth posts again. The client must supply fresh price accounts for pricing operations.
- Swaps in the example route through a **test-only mock swap router** (a fake Jupiter) that would be replaced by real [Jupiter](https://jup.ag) in production.
- Baskets beyond ~3 assets exceed a legacy transaction's 1232-byte limit, so the client must send **v0 transactions with an Address Lookup Table**; `deposit` pulls `14 + 5N` accounts and `withdraw` `10 + 4N` for `N` assets.

Expand Down
Loading
Loading