diff --git a/CHANGELOG.md b/CHANGELOG.md index 6461eb96..4ed89410 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/finance/vault-strategy/anchor-v1/CHANGELOG.md b/finance/vault-strategy/anchor-v1/CHANGELOG.md index 2eca9340..49c9e7da 100644 --- a/finance/vault-strategy/anchor-v1/CHANGELOG.md +++ b/finance/vault-strategy/anchor-v1/CHANGELOG.md @@ -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. diff --git a/finance/vault-strategy/anchor-v1/PRODUCT.md b/finance/vault-strategy/anchor-v1/PRODUCT.md index 0c508e90..82ef3729 100644 --- a/finance/vault-strategy/anchor-v1/PRODUCT.md +++ b/finance/vault-strategy/anchor-v1/PRODUCT.md @@ -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. diff --git a/finance/vault-strategy/anchor-v1/README.md b/finance/vault-strategy/anchor-v1/README.md index 7bf93950..c859d326 100644 --- a/finance/vault-strategy/anchor-v1/README.md +++ b/finance/vault-strategy/anchor-v1/README.md @@ -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 @@ -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). --- diff --git a/finance/vault-strategy/anchor-v1/app/src/idl/vault_strategy.json b/finance/vault-strategy/anchor-v1/app/src/idl/vault_strategy.json index ff173d27..ef778fb4 100644 --- a/finance/vault-strategy/anchor-v1/app/src/idl/vault_strategy.json +++ b/finance/vault-strategy/anchor-v1/app/src/idl/vault_strategy.json @@ -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": [ diff --git a/finance/vault-strategy/anchor-v1/programs/vault-strategy/Cargo.toml b/finance/vault-strategy/anchor-v1/programs/vault-strategy/Cargo.toml index d5fe6c4b..6886921c 100644 --- a/finance/vault-strategy/anchor-v1/programs/vault-strategy/Cargo.toml +++ b/finance/vault-strategy/anchor-v1/programs/vault-strategy/Cargo.toml @@ -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] diff --git a/finance/vault-strategy/anchor-v1/programs/vault-strategy/src/error.rs b/finance/vault-strategy/anchor-v1/programs/vault-strategy/src/error.rs index c80b5a70..4f1d48a4 100644 --- a/finance/vault-strategy/anchor-v1/programs/vault-strategy/src/error.rs +++ b/finance/vault-strategy/anchor-v1/programs/vault-strategy/src/error.rs @@ -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, } diff --git a/finance/vault-strategy/anchor-v1/programs/vault-strategy/src/oracle.rs b/finance/vault-strategy/anchor-v1/programs/vault-strategy/src/oracle.rs index cfa41e11..44f08b79 100644 --- a/finance/vault-strategy/anchor-v1/programs/vault-strategy/src/oracle.rs +++ b/finance/vault-strategy/anchor-v1/programs/vault-strategy/src/oracle.rs @@ -1,4 +1,5 @@ use anchor_lang::prelude::*; +use solana_sysvar::last_restart_slot::LastRestartSlot; use crate::error::VaultError; @@ -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. @@ -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( @@ -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 { require_keys_eq!( price_feed.key(), @@ -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!( @@ -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) } diff --git a/finance/vault-strategy/anchor-v1/programs/vault-strategy/tests/vault_strategy.rs b/finance/vault-strategy/anchor-v1/programs/vault-strategy/tests/vault_strategy.rs index 405e54cb..60cd1a7d 100644 --- a/finance/vault-strategy/anchor-v1/programs/vault-strategy/tests/vault_strategy.rs +++ b/finance/vault-strategy/anchor-v1/programs/vault-strategy/tests/vault_strategy.rs @@ -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 { +/// 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 { let discriminator: [u8; 8] = [34, 241, 35, 99, 157, 126, 244, 205]; let mut data = Vec::with_capacity(133); data.extend_from_slice(&discriminator); @@ -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, @@ -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::(); + 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(); diff --git a/finance/vault-strategy/anchor/CHANGELOG.md b/finance/vault-strategy/anchor/CHANGELOG.md index 2eca9340..49c9e7da 100644 --- a/finance/vault-strategy/anchor/CHANGELOG.md +++ b/finance/vault-strategy/anchor/CHANGELOG.md @@ -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. diff --git a/finance/vault-strategy/anchor/PRODUCT.md b/finance/vault-strategy/anchor/PRODUCT.md index 0c508e90..82ef3729 100644 --- a/finance/vault-strategy/anchor/PRODUCT.md +++ b/finance/vault-strategy/anchor/PRODUCT.md @@ -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. diff --git a/finance/vault-strategy/anchor/README.md b/finance/vault-strategy/anchor/README.md index c85e626e..f1a23bee 100644 --- a/finance/vault-strategy/anchor/README.md +++ b/finance/vault-strategy/anchor/README.md @@ -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 @@ -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). --- diff --git a/finance/vault-strategy/anchor/app/src/idl/vault_strategy.json b/finance/vault-strategy/anchor/app/src/idl/vault_strategy.json index ff173d27..ef778fb4 100644 --- a/finance/vault-strategy/anchor/app/src/idl/vault_strategy.json +++ b/finance/vault-strategy/anchor/app/src/idl/vault_strategy.json @@ -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": [ diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/Cargo.toml b/finance/vault-strategy/anchor/programs/vault-strategy/Cargo.toml index f93b1b44..b9b144fd 100644 --- a/finance/vault-strategy/anchor/programs/vault-strategy/Cargo.toml +++ b/finance/vault-strategy/anchor/programs/vault-strategy/Cargo.toml @@ -36,6 +36,9 @@ wincode = { version = "0.5", features = ["derive"] } # belong to the version the account derives are not using and every # `SchemaRead`/`SchemaWrite` bound fails. 2.6.1 is the last 0.5-line release. solana-address = ">=2.6, <2.7" +# pinocchio ships only Clock and Rent, so LastRestartSlot is read through +# pinocchio's own `get_sysvar` wrapper (see src/last_restart.rs). +pinocchio = "0.11" anchor-spl = "2.0.0-rc.1" mock-swap-router = { path = "../mock-swap-router", features = ["cpi"] } @@ -56,6 +59,8 @@ mock-swap-router = { path = "../mock-swap-router", features = ["cpi"] } # an ordinary version. anchor-v2-testing = { git = "https://github.com/otter-sec/anchor.git", rev = "3e38a121b969d1a77230baeb0056baf5cc9b3c1a" } solana-clock = "3.0.1" +# Host-side LastRestartSlot for LiteSVM's set_sysvar, as with Clock above. +solana-sysvar = "3" solana-account = "3.0.0" solana-kite = "0.4.0" borsh = "1.6.1" diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/src/error.rs b/finance/vault-strategy/anchor/programs/vault-strategy/src/error.rs index c80b5a70..4f1d48a4 100644 --- a/finance/vault-strategy/anchor/programs/vault-strategy/src/error.rs +++ b/finance/vault-strategy/anchor/programs/vault-strategy/src/error.rs @@ -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, } diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/src/last_restart.rs b/finance/vault-strategy/anchor/programs/vault-strategy/src/last_restart.rs new file mode 100644 index 00000000..11985013 --- /dev/null +++ b/finance/vault-strategy/anchor/programs/vault-strategy/src/last_restart.rs @@ -0,0 +1,49 @@ +//! The LastRestartSlot sysvar: the slot of the most recent cluster restart, +//! or 0 if the cluster has never restarted (SIMD-0047). anchor-lang v2 is +//! built on pinocchio, which ships only the Clock and Rent sysvars, so this +//! program declares the 8-byte layout itself and reads it through +//! `pinocchio::sysvars::get_sysvar`, the same syscall wrapper pinocchio's own +//! sysvars use. (`solana-sysvar`'s `LastRestartSlot::get` is not usable here: +//! it is bound to that crate's `Sysvar` trait, not pinocchio's.) +//! +//! Why the program reads it: the vault checks Pyth freshness in seconds, and +//! 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. A +//! halt barely advances the slot count, so after a restart the timestamp +//! cannot jump forward by the length of the halt: it trails real time and +//! catches up gradually. A price published just before the halt can therefore +//! still look fresh by seconds. `oracle::load_price` rejects any price posted +//! at or before the restart slot, so deposits and rebalances pause until Pyth +//! posts again. + +use anchor_lang::prelude::*; + +/// `SysvarLastRestartS1ot1111111111111111111111`, decoded at compile time. +const LAST_RESTART_SLOT_ID: Address = + anchor_lang::address!("SysvarLastRestartS1ot1111111111111111111111"); + +/// The sysvar's whole data: one little-endian u64. +#[repr(C)] +#[derive(Copy, Clone)] +pub struct LastRestartSlot { + pub last_restart_slot: [u8; 8], +} + +const _: () = assert!(core::mem::size_of::() == 8); + +impl LastRestartSlot { + /// Slot of the most recent cluster restart, 0 if there has never been one. + pub fn last_restart_slot(&self) -> u64 { + u64::from_le_bytes(self.last_restart_slot) + } + + pub fn get() -> Result { + // `pinocchio::sysvars::get_sysvar` is the safe wrapper over the + // `sol_get_sysvar` syscall. Off-chain (IDL builds, client compilation) + // it is a no-op that leaves the buffer zeroed, which reads as "the + // cluster has never restarted". + let mut last_restart_slot = [0u8; 8]; + pinocchio::sysvars::get_sysvar(&mut last_restart_slot, &LAST_RESTART_SLOT_ID, 0)?; + Ok(Self { last_restart_slot }) + } +} diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/src/lib.rs b/finance/vault-strategy/anchor/programs/vault-strategy/src/lib.rs index 36c2c785..eb9878a4 100644 --- a/finance/vault-strategy/anchor/programs/vault-strategy/src/lib.rs +++ b/finance/vault-strategy/anchor/programs/vault-strategy/src/lib.rs @@ -1,5 +1,6 @@ pub mod error; pub mod instructions; +pub mod last_restart; pub mod oracle; pub mod state; diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/src/oracle.rs b/finance/vault-strategy/anchor/programs/vault-strategy/src/oracle.rs index 8faf037e..8b420648 100644 --- a/finance/vault-strategy/anchor/programs/vault-strategy/src/oracle.rs +++ b/finance/vault-strategy/anchor/programs/vault-strategy/src/oracle.rs @@ -8,6 +8,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. @@ -22,8 +25,9 @@ const TOKEN_OWNER_OFFSET: usize = 32; /// `mint` Address 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( @@ -36,11 +40,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: &AccountView, expected_key: &Address, now: i64) -> Result { require_keys_eq!( *price_feed.address(), @@ -49,7 +59,7 @@ pub fn load_price(price_feed: &AccountView, expected_key: &Address, 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!( @@ -59,6 +69,20 @@ pub fn load_price(price_feed: &AccountView, expected_key: &Address, 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 = crate::last_restart::LastRestartSlot::get()?.last_restart_slot(); + require!( + last_restart == 0 || posted_slot > last_restart, + VaultError::PricePredatesRestart + ); + Ok(price as u128) } diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/tests/vault_strategy.rs b/finance/vault-strategy/anchor/programs/vault-strategy/tests/vault_strategy.rs index 00936155..567d31ba 100644 --- a/finance/vault-strategy/anchor/programs/vault-strategy/tests/vault_strategy.rs +++ b/finance/vault-strategy/anchor/programs/vault-strategy/tests/vault_strategy.rs @@ -42,8 +42,13 @@ fn derive_ata(wallet: &Address, mint: &Address) -> Address { } /// 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 { +/// 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 { let discriminator: [u8; 8] = [34, 241, 35, 99, 157, 126, 244, 205]; let mut data = Vec::with_capacity(133); data.extend_from_slice(&discriminator); @@ -57,12 +62,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: Address, 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: Address, 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, @@ -993,6 +1003,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::(); + 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(); diff --git a/finance/vault-strategy/quasar/CHANGELOG.md b/finance/vault-strategy/quasar/CHANGELOG.md index 3748ff04..5fc26a73 100644 --- a/finance/vault-strategy/quasar/CHANGELOG.md +++ b/finance/vault-strategy/quasar/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [2026-09-22] + +### Changed + +- Reject Pyth prices from before a cluster restart. Under Alpenglow the + Clock's `unix_timestamp` may advance by at most twice the slot time elapsed + since the parent block, so after a halt it trails real time and the + 60-second `publish_time` check would accept a price published just before + the halt. `load_price` now reads the update's `posted_slot` (offset 125) and + requires it 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 `deposit_rejects_price_from_before_a_restart`. + ## [2026-09-10] ### Changed diff --git a/finance/vault-strategy/quasar/README.md b/finance/vault-strategy/quasar/README.md index 5fec7b50..064d4950 100644 --- a/finance/vault-strategy/quasar/README.md +++ b/finance/vault-strategy/quasar/README.md @@ -89,7 +89,11 @@ withdraw), in index order. - The management fee is capped (10% per year) and the slippage tolerance is capped (10%), so neither can be configured to drain the vault. - Price feeds are validated against the address recorded on the asset config and - rejected if stale or non-positive. + rejected if stale or non-positive, or if posted at or before the last cluster + restart. Under Alpenglow the Clock's timestamp trails real time after a halt, + so a pre-halt price can still pass the 60-second check; `load_price` also + requires the update's `posted_slot` to be after the `LastRestartSlot` + sysvar's slot (`PricePredatesRestart`). ## What the Quasar port does differently @@ -107,6 +111,10 @@ differences follow from Quasar's model: token accounts, matching the other Quasar finance examples. - **The share mint carries no freeze authority** (it is never used); the Anchor build sets it to the strategy PDA. +- **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. `load_price` uses + it to reject prices posted before a cluster restart. ## Building and testing @@ -126,7 +134,8 @@ The router suite (`mock-swap-router/src/tests.rs`) exercises initialize, set-rat and a USDC-for-asset swap. The vault suite (`vault-strategy/src/tests.rs`) drives the manager setup (registry, approve asset, strategy, add asset) and a two-program deposit that deploys USDC into the basket through the router CPI, asserting share -minting, vault balances, and treasury flow. +minting, vault balances, and treasury flow. A second deposit test shows a price +posted before a cluster restart is rejected until Pyth posts again. ## Extending diff --git a/finance/vault-strategy/quasar/vault-strategy/Cargo.toml b/finance/vault-strategy/quasar/vault-strategy/Cargo.toml index 597ceed1..c0a19f58 100644 --- a/finance/vault-strategy/quasar/vault-strategy/Cargo.toml +++ b/finance/vault-strategy/quasar/vault-strategy/Cargo.toml @@ -36,6 +36,12 @@ quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" zeropod = "=0.3.3" 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" [dev-dependencies] quasar-test = { git = "https://github.com/blueshift-gg/quasar", rev = "be60fca" } diff --git a/finance/vault-strategy/quasar/vault-strategy/src/errors.rs b/finance/vault-strategy/quasar/vault-strategy/src/errors.rs index 0c5b5e20..0015f108 100644 --- a/finance/vault-strategy/quasar/vault-strategy/src/errors.rs +++ b/finance/vault-strategy/quasar/vault-strategy/src/errors.rs @@ -29,4 +29,5 @@ pub enum VaultError { InvalidUsdcMint, InvalidSwapRouter, FeeTooHigh, + PricePredatesRestart, } diff --git a/finance/vault-strategy/quasar/vault-strategy/src/last_restart.rs b/finance/vault-strategy/quasar/vault-strategy/src/last_restart.rs new file mode 100644 index 00000000..969533a7 --- /dev/null +++ b/finance/vault-strategy/quasar/vault-strategy/src/last_restart.rs @@ -0,0 +1,81 @@ +//! 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: the vault checks Pyth freshness in seconds, and +//! 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. A +//! halt barely advances the slot count, so after a restart the timestamp +//! cannot jump forward by the length of the halt: it trails real time and +//! catches up gradually. A price published just before the halt can therefore +//! still look fresh by seconds. `oracle::load_price` rejects any price posted +//! at or before the restart slot, so deposits and rebalances pause until Pyth +//! 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/vault-strategy/quasar/vault-strategy/src/lib.rs b/finance/vault-strategy/quasar/vault-strategy/src/lib.rs index da121861..51bac6ee 100644 --- a/finance/vault-strategy/quasar/vault-strategy/src/lib.rs +++ b/finance/vault-strategy/quasar/vault-strategy/src/lib.rs @@ -4,6 +4,7 @@ use quasar_lang::prelude::*; pub mod errors; pub mod instructions; +pub mod last_restart; pub mod oracle; pub mod state; diff --git a/finance/vault-strategy/quasar/vault-strategy/src/oracle.rs b/finance/vault-strategy/quasar/vault-strategy/src/oracle.rs index 22fbc2da..e9d0b6bd 100644 --- a/finance/vault-strategy/quasar/vault-strategy/src/oracle.rs +++ b/finance/vault-strategy/quasar/vault-strategy/src/oracle.rs @@ -1,12 +1,15 @@ -use quasar_lang::prelude::*; +use quasar_lang::{prelude::*, sysvars::Sysvar}; -use crate::errors::VaultError; +use crate::{errors::VaultError, last_restart::LastRestartSlot}; // Byte offset of `price` (i64) inside a Pyth PriceUpdateV2 account: // 8 discriminator + 32 write_authority + 1 verification_level + 32 feed_id = 73 const PYTH_PRICE_OFFSET: usize = 73; // Byte offset of `publish_time` (i64): price(8) + conf(8) + exponent(4) 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) after publish_time. +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. @@ -35,8 +38,17 @@ fn read_i64(data: &[u8], offset: usize) -> Result { Ok(i64::from_le_bytes(bytes)) } +fn read_u64(data: &[u8], offset: usize) -> Result { + let bytes: [u8; 8] = data + .get(offset..offset + 8) + .and_then(|slice| slice.try_into().ok()) + .ok_or(VaultError::InvalidPriceFeed)?; + Ok(u64::from_le_bytes(bytes)) +} + /// 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: &AccountView, expected_key: &Address, @@ -47,11 +59,12 @@ pub fn load_price( } let data = account_data(price_feed); - if data.len() < PYTH_PUBLISH_TIME_OFFSET + 8 { + if data.len() < PYTH_POSTED_SLOT_OFFSET + 8 { return Err(VaultError::InvalidPriceFeed.into()); } let price = read_i64(data, PYTH_PRICE_OFFSET)?; let publish_time = read_i64(data, PYTH_PUBLISH_TIME_OFFSET)?; + let posted_slot = read_u64(data, PYTH_POSTED_SLOT_OFFSET)?; require!(price > 0, VaultError::NegativePrice); require!( @@ -61,6 +74,20 @@ pub fn load_price( 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 = u64::from(LastRestartSlot::get()?.last_restart_slot); + require!( + last_restart == 0 || posted_slot > last_restart, + VaultError::PricePredatesRestart + ); + Ok(price as u128) } diff --git a/finance/vault-strategy/quasar/vault-strategy/src/tests.rs b/finance/vault-strategy/quasar/vault-strategy/src/tests.rs index 7a2c4496..65d0bb62 100644 --- a/finance/vault-strategy/quasar/vault-strategy/src/tests.rs +++ b/finance/vault-strategy/quasar/vault-strategy/src/tests.rs @@ -3,7 +3,8 @@ //! `deposit` is a two-program test: it loads the mock swap router too, wires //! up rates and a Pyth-shaped price feed, and deposits, checking that the //! deposit is priced 1:1 on the first deposit and deployed into the basket -//! through the router CPI. +//! through the router CPI. `deposit_rejects_price_from_before_a_restart` +//! reuses that setup to show a pre-restart price is refused. use { crate::{ @@ -11,6 +12,7 @@ use { AddAssetInstruction, ApproveAssetInstruction, DepositInstruction, InitializeRegistryInstruction, InitializeStrategyInstruction, }, + errors::VaultError, state::{AssetConfig, AssetVaultPda, Registry, ShareMintPda, Strategy, UsdcVaultPda}, }, quasar_test::prelude::*, @@ -53,14 +55,39 @@ fn router_rate_pda(mint: &Pubkey) -> Pubkey { } // A Pyth PriceUpdateV2-shaped account: `price` (i64) at offset 73, -// `publish_time` (i64) at offset 93. The program reads only those two fields. +// `publish_time` (i64) at offset 93, `posted_slot` (u64) at offset 125. The +// program reads only those three fields. Posted at slot 1. fn add_pyth_feed(test: &mut Test, price: i64, publish_time: i64) { + add_pyth_feed_posted_at(test, price, publish_time, 1); +} + +// The same feed, as if Pyth posted it in `posted_slot`. +fn add_pyth_feed_posted_at(test: &mut Test, price: i64, publish_time: i64, posted_slot: u64) { let mut data = vec![0u8; 200]; data[73..81].copy_from_slice(&price.to_le_bytes()); data[93..101].copy_from_slice(&publish_time.to_le_bytes()); + data[125..133].copy_from_slice(&posted_slot.to_le_bytes()); test.set_account(Account::new(PRICE_FEED, FEED_OWNER, 1_000_000, data)); } +/// Pin the LastRestartSlot sysvar account, simulating a cluster restart at +/// `slot`: prices posted at or before it must be rejected until Pyth 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(), + )); +} + /// The strategy-side PDAs the assertions read. struct Pdas { strategy: Pubkey, @@ -147,11 +174,15 @@ fn strategy_setup_records_the_basket(test: &mut Test) { ); } -/// Two-program deposit: set up the router + a single-asset strategy, then -/// deposit USDC. The first deposit mints shares 1:1 and deploys the whole -/// amount into the asset through the router CPI. -#[quasar_test] -fn deposit_mints_shares_and_deploys_into_the_basket(test: &mut Test) { +// Asset priced 250 USDC/token: Pyth price = 250 * 10^8 so +// asset_value = amount * price / 10^8 gives 250 USDC per token base unit. +const PYTH_PRICE: i64 = 250 * 100_000_000; +const DEPOSIT: u64 = 1_000; + +/// Load the router, set up a single-asset strategy, fund the depositor, and +/// initialize the router with the asset's rate. Leaves the Pyth feed to the +/// caller. +fn setup_deposit(test: &mut Test) -> Pdas { // Runtime read (NOT include_bytes!): quasar-test auto-loads only this // program's .so; the sibling router program is added explicitly. let router_elf = @@ -166,14 +197,6 @@ fn deposit_mints_shares_and_deploys_into_the_basket(test: &mut Test) { test.add(Wallet::new().at(DEPOSITOR)); - // Asset priced 250 USDC/token: Pyth price = 250 * 10^8 so - // asset_value = amount * price / 10^8 gives 250 USDC per token base unit. - let pyth_price: i64 = 250 * 100_000_000; - add_pyth_feed(test, pyth_price, NOW); - - const DEPOSIT: u64 = 1_000; - const ASSET_OUT: u64 = DEPOSIT / RATE; // 4 - // Depositor token accounts (share account created up front). test.add( TokenAccount::new(USDC_MINT, DEPOSITOR) @@ -219,8 +242,12 @@ fn deposit_mints_shares_and_deploys_into_the_basket(test: &mut Test) { }) .succeeds(); - // Deposit: declared accounts, then remaining accounts per basket asset - // (asset_config, vault_asset, asset_mint, asset_rate, price_feed). + w +} + +/// Deposit `DEPOSIT` USDC: declared accounts, then remaining accounts per +/// basket asset (asset_config, vault_asset, asset_mint, asset_rate, price_feed). +fn send_deposit(test: &mut Test, w: &Pdas) -> Outcome { test.send(DepositInstruction { depositor: DEPOSITOR, strategy_index_seed: STRATEGY_INDEX, @@ -240,13 +267,49 @@ fn deposit_mints_shares_and_deploys_into_the_basket(test: &mut Test) { AccountMeta::new_readonly(PRICE_FEED, false), ], }) - .succeeds() - // First deposit mints shares 1:1 with USDC. - .has_tokens(DEPOSITOR_SHARE, DEPOSIT) - // The deposit was deployed into the asset via the router. - .has_tokens(w.vault_asset, ASSET_OUT) - .has_tokens(DEPOSITOR_USDC, 0) - .has_tokens(router_treasury_pda(), DEPOSIT) - // All USDC was swapped out of the vault into the asset. - .has_tokens(w.vault_usdc, 0); +} + +/// Two-program deposit: set up the router + a single-asset strategy, then +/// deposit USDC. The first deposit mints shares 1:1 and deploys the whole +/// amount into the asset through the router CPI. +#[quasar_test] +fn deposit_mints_shares_and_deploys_into_the_basket(test: &mut Test) { + let w = setup_deposit(test); + add_pyth_feed(test, PYTH_PRICE, NOW); + + const ASSET_OUT: u64 = DEPOSIT / RATE; // 4 + + send_deposit(test, &w) + .succeeds() + // First deposit mints shares 1:1 with USDC. + .has_tokens(DEPOSITOR_SHARE, DEPOSIT) + // The deposit was deployed into the asset via the router. + .has_tokens(w.vault_asset, ASSET_OUT) + .has_tokens(DEPOSITOR_USDC, 0) + .has_tokens(router_treasury_pda(), DEPOSIT) + // All USDC was swapped out of the vault into the asset. + .has_tokens(w.vault_usdc, 0); +} + +/// 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. +#[quasar_test] +fn deposit_rejects_price_from_before_a_restart(test: &mut Test) { + let w = setup_deposit(test); + + // The feed is posted at slot 1 and stamped `NOW`, so it is fresh by the + // 60-second bound, but the cluster restarted at slot 3: only the restart + // check can catch it. + add_pyth_feed(test, PYTH_PRICE, NOW); + set_last_restart_slot(test, 3); + send_deposit(test, &w).fails_with(VaultError::PricePredatesRestart); + + // Pyth posting again after the restart (slot 4) reopens the vault. + add_pyth_feed_posted_at(test, PYTH_PRICE, NOW, 4); + send_deposit(test, &w) + .succeeds() + .has_tokens(DEPOSITOR_SHARE, DEPOSIT); }