diff --git a/CHANGELOG.md b/CHANGELOG.md index 991a6103..03b3f077 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ 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-10-03] - Managed Fund rejects wide-confidence prices + +### Fixed + +- `finance/managed-fund` (Anchor v2, Anchor v1, Quasar) ignored the + confidence interval on its Pyth prices. `load_price` now rejects a price + whose interval exceeds 1% of the price (`MAX_CONFIDENCE_BPS`, new + `OracleConfidenceTooWide` error), so deposit and rebalance are refused while + publishers disagree; withdraw reads no price and still pays out in kind. + Tested by `test_wide_confidence_price_rejected` in each copy. The web apps' + IDLs gain the error. + ## [2026-10-03] - Fundraiser: `close_contributor` is `close_contribution` ### Changed diff --git a/finance/managed-fund/anchor-v1/CHANGELOG.md b/finance/managed-fund/anchor-v1/CHANGELOG.md index 7d8b0b84..ea576926 100644 --- a/finance/managed-fund/anchor-v1/CHANGELOG.md +++ b/finance/managed-fund/anchor-v1/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 2026-10-03 + +- **Prices with a wide confidence interval are rejected.** Pyth reports each price with a confidence interval (`conf`, offset 81), and `load_price` ignored it, so a price the publishers disagreed on by several percent was used as if it were exact. Deposits price shares from that price and rebalance sets its swap floor from it, so a wide band moves value between depositors or loosens the floor by the same amount. `load_price` now rejects a price whose interval exceeds `MAX_CONFIDENCE_BPS` (100 bps, 1% of the price) with the new `OracleConfidenceTooWide` error. `withdraw` reads no price and is unaffected, so investors can still leave in kind. The limit is a program constant, like the 60-second staleness window; prop-amm and perpetual-futures store theirs per market. Tested by `test_wide_confidence_price_rejected`. The web app's IDL gains the error. + ## 2026-10-01 - **Valuation scales by each asset's decimals and each feed's exponent.** The fund valued an asset as `amount × price / 10⁸`, which is right only when the asset has USDC's 6 decimals and its Pyth feed has exponent −8, and nothing checked either. An eight-decimal asset would have been valued 100 times too high, so a later depositor would have bought almost no shares; Pyth's US equity feeds use exponent −5, a further factor of 1,000. `load_price` now reads the exponent (offset 89) and returns an `OraclePrice`, `AssetConfig` records the mint's `decimals` and `Fund` the USDC mint's `usdc_decimals`, and `deposit` and `rebalance` value and size swaps with `asset_value_in_usdc` and `usdc_to_asset_amount`, which scale by `10^(usdc_decimals + exponent − asset_decimals)`. `PYTH_PRICE_PRECISION` is removed. The mock router's `usdc_per_token` is now USDC minor units per whole token, and its swaps scale by the asset mint's decimals. Tested by `test_valuation_scales_by_decimals_and_exponent`, which runs the story with an eight-decimal TSLAx on an exponent −5 feed. diff --git a/finance/managed-fund/anchor-v1/README.md b/finance/managed-fund/anchor-v1/README.md index 14152021..43b40893 100644 --- a/finance/managed-fund/anchor-v1/README.md +++ b/finance/managed-fund/anchor-v1/README.md @@ -33,7 +33,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, and so is any price posted at or before the last cluster restart (`PricePredatesRestart`), which the seconds check alone cannot catch after a halt. +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. A price whose confidence interval is wider than 1% of the price (`MAX_CONFIDENCE_BPS`, 100) is rejected too (`OracleConfidenceTooWide`): deposits price shares from the oracle and rebalance sets its swap floor from it, so a price the publishers disagree on by more than a typical slippage tolerance is not one to trade on. `withdraw` reads no price, so investors can always leave in kind while deposits and rebalances wait for the band to narrow. ### Shares @@ -161,7 +161,7 @@ cargo build-sbf --manifest-path programs/managed-fund/Cargo.toml cargo test --manifest-path programs/managed-fund/Cargo.toml ``` -Tests live in `programs/managed-fund/tests/managed_fund.rs` and use [LiteSVM](https://github.com/LiteSVM/litesvm). Both `.so` files are loaded from `target/deploy/`, so build before testing. The suite covers the full lifecycle end to end (deposit with auto-deployment, a price move, rebalance back to target, a second depositor priced at the new NAV, a year's fee, in-kind withdrawal), retiring an asset with `set_weight` and reallocating to reopen deposits, and the rejection paths: unapproved asset, weight overflow, over-cap fee and slippage, oracle-bounded deposit slippage, an under-allocated fund, non-manager `set_weight`, unregistered router, and incomplete asset accounts on deposit and rebalance. The rebalance tests sign as a stranger, since anyone may call it: `test_rebalance_refuses_fund_at_target`, `test_rebalance_refuses_drift_below_threshold` and `test_rebalance_cannot_churn` check that a fund at its targets, or within its threshold, or just rebalanced, cannot be traded; `test_rebalance_refuses_buying_overweight_asset`, `test_rebalance_sells_retired_asset` and `test_initialize_rejects_threshold_out_of_range` cover the rest of its rules. `test_valuation_scales_by_decimals_and_exponent` runs the story with an eight-decimal TSLAx priced by an exponent −5 feed and gets the same share counts. `test_full_lifecycle` checks after every step that the recorded holdings equal the vaults' balances. `test_donation_does_not_inflate_share_price` runs the first-depositor attack (a one-minor-unit deposit, a 1,000 USDC transfer straight into the USDC vault, then a 1,000 USDC deposit with no `minimum_shares` floor) and checks the victim gets exactly the shares they would have got without the donation. `test_deposit_rejects_leg_that_buys_nothing` and `test_rebalance_ignores_donations` pin the other two guards: the second checks that donated tokens can neither force a rebalance nor be spent by one. +Tests live in `programs/managed-fund/tests/managed_fund.rs` and use [LiteSVM](https://github.com/LiteSVM/litesvm). Both `.so` files are loaded from `target/deploy/`, so build before testing. The suite covers the full lifecycle end to end (deposit with auto-deployment, a price move, rebalance back to target, a second depositor priced at the new NAV, a year's fee, in-kind withdrawal), retiring an asset with `set_weight` and reallocating to reopen deposits, and the rejection paths: unapproved asset, weight overflow, over-cap fee and slippage, oracle-bounded deposit slippage, an under-allocated fund, non-manager `set_weight`, unregistered router, and incomplete asset accounts on deposit and rebalance. The rebalance tests sign as a stranger, since anyone may call it: `test_rebalance_refuses_fund_at_target`, `test_rebalance_refuses_drift_below_threshold` and `test_rebalance_cannot_churn` check that a fund at its targets, or within its threshold, or just rebalanced, cannot be traded; `test_rebalance_refuses_buying_overweight_asset`, `test_rebalance_sells_retired_asset` and `test_initialize_rejects_threshold_out_of_range` cover the rest of its rules. `test_valuation_scales_by_decimals_and_exponent` runs the story with an eight-decimal TSLAx priced by an exponent −5 feed and gets the same share counts. `test_wide_confidence_price_rejected` widens NVDAx's confidence interval to 2% of its price and checks that deposit and rebalance fail with `OracleConfidenceTooWide`, that withdraw still pays out in kind, and that a band of exactly 1% is accepted. `test_full_lifecycle` checks after every step that the recorded holdings equal the vaults' balances. `test_donation_does_not_inflate_share_price` runs the first-depositor attack (a one-minor-unit deposit, a 1,000 USDC transfer straight into the USDC vault, then a 1,000 USDC deposit with no `minimum_shares` floor) and checks the victim gets exactly the shares they would have got without the donation. `test_deposit_rejects_leg_that_buys_nothing` and `test_rebalance_ignores_donations` pin the other two guards: the second checks that donated tokens can neither force a rebalance nor be spent by one. ## FAQ diff --git a/finance/managed-fund/anchor-v1/app/src/idl/managed_fund.json b/finance/managed-fund/anchor-v1/app/src/idl/managed_fund.json index cc014124..891535ab 100644 --- a/finance/managed-fund/anchor-v1/app/src/idl/managed_fund.json +++ b/finance/managed-fund/anchor-v1/app/src/idl/managed_fund.json @@ -1047,6 +1047,11 @@ "code": 6031, "name": "NotUnderweight", "msg": "The asset to buy is not below its target weight" + }, + { + "code": 6032, + "name": "OracleConfidenceTooWide", + "msg": "Pyth price confidence interval is too wide to trust" } ], "types": [ diff --git a/finance/managed-fund/anchor-v1/programs/managed-fund/src/error.rs b/finance/managed-fund/anchor-v1/programs/managed-fund/src/error.rs index d0cb49a2..462d7f94 100644 --- a/finance/managed-fund/anchor-v1/programs/managed-fund/src/error.rs +++ b/finance/managed-fund/anchor-v1/programs/managed-fund/src/error.rs @@ -66,4 +66,6 @@ pub enum FundError { DriftBelowThreshold, #[msg("The asset to buy is not below its target weight")] NotUnderweight, + #[msg("Pyth price confidence interval is too wide to trust")] + OracleConfidenceTooWide, } diff --git a/finance/managed-fund/anchor-v1/programs/managed-fund/src/oracle.rs b/finance/managed-fund/anchor-v1/programs/managed-fund/src/oracle.rs index 9f9a438a..1f90ec47 100644 --- a/finance/managed-fund/anchor-v1/programs/managed-fund/src/oracle.rs +++ b/finance/managed-fund/anchor-v1/programs/managed-fund/src/oracle.rs @@ -6,6 +6,8 @@ use crate::error::FundError; /// 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 `conf` (u64), the confidence interval: +8 bytes after price. +const PYTH_CONF_OFFSET: usize = PYTH_PRICE_OFFSET + 8; // 81 /// Byte offset of `exponent` (i32): price(8) + conf(8) = +16 bytes after price. const PYTH_EXPONENT_OFFSET: usize = PYTH_PRICE_OFFSET + 8 + 8; // 89 /// Byte offset of `publish_time` (i64): @@ -16,6 +18,12 @@ const PYTH_PUBLISH_TIME_OFFSET: usize = PYTH_PRICE_OFFSET + 8 + 8 + 4; // 93 const PYTH_POSTED_SLOT_OFFSET: usize = PYTH_PUBLISH_TIME_OFFSET + 8 + 8 + 8 + 8; // 125 /// Prices older than this (seconds) are rejected. const MAX_PRICE_AGE_SECONDS: i64 = 60; +/// Widest confidence interval accepted, in basis points of the price (1%). +/// Deposits price shares and rebalance sets its swap floor from the Pyth +/// price, so a price that may be off by more than a typical 1% slippage +/// tolerance would quietly widen that tolerance. Major feeds usually quote +/// well under 0.1%; a band past 1% means the publishers disagree. +const MAX_CONFIDENCE_BPS: u128 = 100; /// SPL token account layout: amount is a u64 at bytes 64..72. The base layout is /// shared by the Classic Token Program and the Token Extensions Program, so this @@ -35,8 +43,8 @@ pub struct OraclePrice { pub exponent: i32, } -/// Returns `(price, exponent, publish_time, posted_slot)`. -fn read_pyth_raw(account_data: &[u8]) -> Result<(i64, i32, i64, u64)> { +/// Returns `(price, conf, exponent, publish_time, posted_slot)`. +fn read_pyth_raw(account_data: &[u8]) -> Result<(i64, u64, i32, i64, u64)> { if account_data.len() < PYTH_POSTED_SLOT_OFFSET + 8 { return err!(FundError::InvalidPriceFeed); } @@ -45,6 +53,11 @@ fn read_pyth_raw(account_data: &[u8]) -> Result<(i64, i32, i64, u64)> { .try_into() .map_err(|_| FundError::InvalidPriceFeed)?, ); + let conf = u64::from_le_bytes( + account_data[PYTH_CONF_OFFSET..PYTH_CONF_OFFSET + 8] + .try_into() + .map_err(|_| FundError::InvalidPriceFeed)?, + ); let exponent = i32::from_le_bytes( account_data[PYTH_EXPONENT_OFFSET..PYTH_EXPONENT_OFFSET + 4] .try_into() @@ -60,11 +73,12 @@ fn read_pyth_raw(account_data: &[u8]) -> Result<(i64, i32, i64, u64)> { .try_into() .map_err(|_| FundError::InvalidPriceFeed)?, ); - Ok((price, exponent, publish_time, posted_slot)) + Ok((price, conf, exponent, publish_time, posted_slot)) } /// Validate a price feed account against the one the fund registered, then /// return its positive, fresh price. `now` is the current unix timestamp. +/// A price whose confidence interval exceeds `MAX_CONFIDENCE_BPS` is rejected. /// A price posted at or before the last cluster restart is rejected too. pub fn load_price( price_feed: &AccountInfo, @@ -74,9 +88,19 @@ pub fn load_price( require_keys_eq!(price_feed.key(), *expected_key, FundError::InvalidPriceFeed); let data = price_feed.try_borrow_data()?; - let (price, exponent, publish_time, posted_slot) = read_pyth_raw(&data)?; + let (price, conf, exponent, publish_time, posted_slot) = read_pyth_raw(&data)?; require!(price > 0, FundError::NegativePrice); + + // Reject a price the oracle itself is unsure of: the confidence interval, + // as a fraction of the price, must not exceed MAX_CONFIDENCE_BPS. `conf` + // shares the price's exponent, so the ratio needs no scaling, and in u128 + // neither product can overflow. + require!( + (conf as u128) * 10_000 <= (price as u128) * MAX_CONFIDENCE_BPS, + FundError::OracleConfidenceTooWide + ); + require!( now.checked_sub(publish_time) .ok_or(FundError::MathOverflow)? diff --git a/finance/managed-fund/anchor-v1/programs/managed-fund/tests/managed_fund.rs b/finance/managed-fund/anchor-v1/programs/managed-fund/tests/managed_fund.rs index 4af62fec..913c9e2b 100644 --- a/finance/managed-fund/anchor-v1/programs/managed-fund/tests/managed_fund.rs +++ b/finance/managed-fund/anchor-v1/programs/managed-fund/tests/managed_fund.rs @@ -47,9 +47,10 @@ 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, posted_slot u64 at 125. Exponent -8. +/// conf u64 at 81, publish_time i64 at 93, posted_slot u64 at 125. Exponent -8. fn build_mock_price_update_account( price: i64, + confidence: u64, exponent: i32, publish_time: i64, posted_slot: u64, @@ -61,7 +62,7 @@ fn build_mock_price_update_account( data.push(1u8); data.extend_from_slice(&[0xEFu8; 32]); data.extend_from_slice(&price.to_le_bytes()); - data.extend_from_slice(&100_000u64.to_le_bytes()); + data.extend_from_slice(&confidence.to_le_bytes()); data.extend_from_slice(&exponent.to_le_bytes()); data.extend_from_slice(&publish_time.to_le_bytes()); data.extend_from_slice(&(publish_time - 1).to_le_bytes()); @@ -82,7 +83,20 @@ fn set_price_feed_posted_at(svm: &mut LiteSVM, key: Pubkey, price: i64, posted_s /// Write a Pyth feed with its own exponent: Pyth's US equity feeds use -5. fn write_price_feed(svm: &mut LiteSVM, key: Pubkey, price: i64, exponent: i32, posted_slot: u64) { - let data = build_mock_price_update_account(price, exponent, PUBLISH_TIME, posted_slot); + write_price_feed_with_confidence(svm, key, price, DEFAULT_CONFIDENCE, exponent, posted_slot); +} + +/// Write a Pyth feed with its own confidence interval, in the price's units. +fn write_price_feed_with_confidence( + svm: &mut LiteSVM, + key: Pubkey, + price: i64, + confidence: u64, + exponent: i32, + posted_slot: u64, +) { + let data = + build_mock_price_update_account(price, confidence, exponent, PUBLISH_TIME, posted_slot); let rent = svm.minimum_balance_for_rent_exemption(data.len()); svm.set_account( key, @@ -98,6 +112,8 @@ fn write_price_feed(svm: &mut LiteSVM, key: Pubkey, price: i64, exponent: i32, p } const PUBLISH_TIME: i64 = 1_700_000_000; +/// A tight confidence interval, $0.001 at exponent -8, far inside the 1% limit. +const DEFAULT_CONFIDENCE: u64 = 100_000; const TOKEN_DECIMALS: u8 = 6; const SECONDS_PER_YEAR: i64 = 31_536_000; @@ -2057,3 +2073,68 @@ fn test_valuation_scales_by_decimals_and_exponent() { assert_eq!(read_fund(&ctx).asset_holdings[1], 4_320_000); assert_holdings_match_vaults(&ctx); } + +/// A price the oracle is unsure of is not traded on. With NVDAx's confidence +/// interval at 2% of its price, past the 1% limit, deposit and rebalance both +/// refuse, but withdraw still pays out in kind: it reads no price, so investors +/// can always leave. A band of exactly 1% is accepted. +#[test] +fn test_wide_confidence_price_rejected() { + let mut ctx = setup_full(); + standard_fund(&mut ctx); + + // Alice deposits 900 USDC: 1.44 TSLAx + 3.0 NVDAx at 40/60. + let alice = fund_user(&mut ctx, 900_000_000); + do_deposit(&mut ctx, &alice, 900_000_000, 1); + + // NVDAx rises to $200, so the fund has drifted and needs a rebalance. Then + // its feed reports a $4 confidence interval: 2% of the price. + set_nvda_price(&mut ctx, 20_000_000_000, 200_000_000); + write_price_feed_with_confidence( + &mut ctx.svm, + ctx.price_feed_nvda, + 20_000_000_000, + 400_000_000, + -8, + 1, + ); + + let bob = fund_user(&mut ctx, 480_000_000); + let ix = deposit_instruction(&ctx, &bob, 480_000_000, 1, deposit_remaining(&ctx)); + assert_program_error( + send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&bob], &bob.pubkey()), + FundError::OracleConfidenceTooWide, + "a deposit priced from a wide-confidence feed must fail", + ); + assert_program_error( + try_rebalance(&mut ctx, 1, 0), + FundError::OracleConfidenceTooWide, + "a rebalance priced from a wide-confidence feed must fail", + ); + + // Withdraw reads no price: Alice takes half her shares out in kind. + do_withdraw(&mut ctx, &alice, 450_000_000, 0); + assert_eq!( + get_token_account_balance(&ctx.svm, &derive_ata(&alice.pubkey(), &ctx.tsla_mint)).unwrap(), + 720_000 + ); + assert_eq!( + get_token_account_balance(&ctx.svm, &derive_ata(&alice.pubkey(), &ctx.nvda_mint)).unwrap(), + 1_500_000 + ); + + // A $2 interval is exactly 1% of the price, which is accepted: the + // rebalance sells 0.06 NVDAx for 12 USDC and buys 0.048 TSLAx. + write_price_feed_with_confidence( + &mut ctx.svm, + ctx.price_feed_nvda, + 20_000_000_000, + 200_000_000, + -8, + 1, + ); + do_rebalance(&mut ctx, 1, 0); + assert_eq!(read_fund(&ctx).asset_holdings[0], 768_000); + assert_eq!(read_fund(&ctx).asset_holdings[1], 1_440_000); + assert_holdings_match_vaults(&ctx); +} diff --git a/finance/managed-fund/anchor/CHANGELOG.md b/finance/managed-fund/anchor/CHANGELOG.md index 7d8b0b84..ea576926 100644 --- a/finance/managed-fund/anchor/CHANGELOG.md +++ b/finance/managed-fund/anchor/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 2026-10-03 + +- **Prices with a wide confidence interval are rejected.** Pyth reports each price with a confidence interval (`conf`, offset 81), and `load_price` ignored it, so a price the publishers disagreed on by several percent was used as if it were exact. Deposits price shares from that price and rebalance sets its swap floor from it, so a wide band moves value between depositors or loosens the floor by the same amount. `load_price` now rejects a price whose interval exceeds `MAX_CONFIDENCE_BPS` (100 bps, 1% of the price) with the new `OracleConfidenceTooWide` error. `withdraw` reads no price and is unaffected, so investors can still leave in kind. The limit is a program constant, like the 60-second staleness window; prop-amm and perpetual-futures store theirs per market. Tested by `test_wide_confidence_price_rejected`. The web app's IDL gains the error. + ## 2026-10-01 - **Valuation scales by each asset's decimals and each feed's exponent.** The fund valued an asset as `amount × price / 10⁸`, which is right only when the asset has USDC's 6 decimals and its Pyth feed has exponent −8, and nothing checked either. An eight-decimal asset would have been valued 100 times too high, so a later depositor would have bought almost no shares; Pyth's US equity feeds use exponent −5, a further factor of 1,000. `load_price` now reads the exponent (offset 89) and returns an `OraclePrice`, `AssetConfig` records the mint's `decimals` and `Fund` the USDC mint's `usdc_decimals`, and `deposit` and `rebalance` value and size swaps with `asset_value_in_usdc` and `usdc_to_asset_amount`, which scale by `10^(usdc_decimals + exponent − asset_decimals)`. `PYTH_PRICE_PRECISION` is removed. The mock router's `usdc_per_token` is now USDC minor units per whole token, and its swaps scale by the asset mint's decimals. Tested by `test_valuation_scales_by_decimals_and_exponent`, which runs the story with an eight-decimal TSLAx on an exponent −5 feed. diff --git a/finance/managed-fund/anchor/README.md b/finance/managed-fund/anchor/README.md index 062ef522..080b5fc5 100644 --- a/finance/managed-fund/anchor/README.md +++ b/finance/managed-fund/anchor/README.md @@ -33,7 +33,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, and so is any price posted at or before the last cluster restart (`PricePredatesRestart`), which the seconds check alone cannot catch after a halt. +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. A price whose confidence interval is wider than 1% of the price (`MAX_CONFIDENCE_BPS`, 100) is rejected too (`OracleConfidenceTooWide`): deposits price shares from the oracle and rebalance sets its swap floor from it, so a price the publishers disagree on by more than a typical slippage tolerance is not one to trade on. `withdraw` reads no price, so investors can always leave in kind while deposits and rebalances wait for the band to narrow. ### Shares @@ -161,7 +161,7 @@ cargo build-sbf --manifest-path programs/managed-fund/Cargo.toml cargo test --manifest-path programs/managed-fund/Cargo.toml ``` -Tests live in `programs/managed-fund/tests/managed_fund.rs` and use [LiteSVM](https://github.com/LiteSVM/litesvm). Both `.so` files are loaded from `target/deploy/`, so build before testing. The suite covers the full lifecycle end to end (deposit with auto-deployment, a price move, rebalance back to target, a second depositor priced at the new NAV, a year's fee, in-kind withdrawal), retiring an asset with `set_weight` and reallocating to reopen deposits, and the rejection paths: unapproved asset, weight overflow, over-cap fee and slippage, oracle-bounded deposit slippage, an under-allocated fund, non-manager `set_weight`, unregistered router, and incomplete asset accounts on deposit and rebalance. The rebalance tests sign as a stranger, since anyone may call it: `test_rebalance_refuses_fund_at_target`, `test_rebalance_refuses_drift_below_threshold` and `test_rebalance_cannot_churn` check that a fund at its targets, or within its threshold, or just rebalanced, cannot be traded; `test_rebalance_refuses_buying_overweight_asset`, `test_rebalance_sells_retired_asset` and `test_initialize_rejects_threshold_out_of_range` cover the rest of its rules. `test_valuation_scales_by_decimals_and_exponent` runs the story with an eight-decimal TSLAx priced by an exponent −5 feed and gets the same share counts. `test_full_lifecycle` checks after every step that the recorded holdings equal the vaults' balances. `test_donation_does_not_inflate_share_price` runs the first-depositor attack (a one-minor-unit deposit, a 1,000 USDC transfer straight into the USDC vault, then a 1,000 USDC deposit with no `minimum_shares` floor) and checks the victim gets exactly the shares they would have got without the donation. `test_deposit_rejects_leg_that_buys_nothing` and `test_rebalance_ignores_donations` pin the other two guards: the second checks that donated tokens can neither force a rebalance nor be spent by one. +Tests live in `programs/managed-fund/tests/managed_fund.rs` and use [LiteSVM](https://github.com/LiteSVM/litesvm). Both `.so` files are loaded from `target/deploy/`, so build before testing. The suite covers the full lifecycle end to end (deposit with auto-deployment, a price move, rebalance back to target, a second depositor priced at the new NAV, a year's fee, in-kind withdrawal), retiring an asset with `set_weight` and reallocating to reopen deposits, and the rejection paths: unapproved asset, weight overflow, over-cap fee and slippage, oracle-bounded deposit slippage, an under-allocated fund, non-manager `set_weight`, unregistered router, and incomplete asset accounts on deposit and rebalance. The rebalance tests sign as a stranger, since anyone may call it: `test_rebalance_refuses_fund_at_target`, `test_rebalance_refuses_drift_below_threshold` and `test_rebalance_cannot_churn` check that a fund at its targets, or within its threshold, or just rebalanced, cannot be traded; `test_rebalance_refuses_buying_overweight_asset`, `test_rebalance_sells_retired_asset` and `test_initialize_rejects_threshold_out_of_range` cover the rest of its rules. `test_valuation_scales_by_decimals_and_exponent` runs the story with an eight-decimal TSLAx priced by an exponent −5 feed and gets the same share counts. `test_wide_confidence_price_rejected` widens NVDAx's confidence interval to 2% of its price and checks that deposit and rebalance fail with `OracleConfidenceTooWide`, that withdraw still pays out in kind, and that a band of exactly 1% is accepted. `test_full_lifecycle` checks after every step that the recorded holdings equal the vaults' balances. `test_donation_does_not_inflate_share_price` runs the first-depositor attack (a one-minor-unit deposit, a 1,000 USDC transfer straight into the USDC vault, then a 1,000 USDC deposit with no `minimum_shares` floor) and checks the victim gets exactly the shares they would have got without the donation. `test_deposit_rejects_leg_that_buys_nothing` and `test_rebalance_ignores_donations` pin the other two guards: the second checks that donated tokens can neither force a rebalance nor be spent by one. ## FAQ diff --git a/finance/managed-fund/anchor/app/src/idl/managed_fund.json b/finance/managed-fund/anchor/app/src/idl/managed_fund.json index cc014124..891535ab 100644 --- a/finance/managed-fund/anchor/app/src/idl/managed_fund.json +++ b/finance/managed-fund/anchor/app/src/idl/managed_fund.json @@ -1047,6 +1047,11 @@ "code": 6031, "name": "NotUnderweight", "msg": "The asset to buy is not below its target weight" + }, + { + "code": 6032, + "name": "OracleConfidenceTooWide", + "msg": "Pyth price confidence interval is too wide to trust" } ], "types": [ diff --git a/finance/managed-fund/anchor/programs/managed-fund/src/error.rs b/finance/managed-fund/anchor/programs/managed-fund/src/error.rs index d0cb49a2..462d7f94 100644 --- a/finance/managed-fund/anchor/programs/managed-fund/src/error.rs +++ b/finance/managed-fund/anchor/programs/managed-fund/src/error.rs @@ -66,4 +66,6 @@ pub enum FundError { DriftBelowThreshold, #[msg("The asset to buy is not below its target weight")] NotUnderweight, + #[msg("Pyth price confidence interval is too wide to trust")] + OracleConfidenceTooWide, } diff --git a/finance/managed-fund/anchor/programs/managed-fund/src/oracle.rs b/finance/managed-fund/anchor/programs/managed-fund/src/oracle.rs index 7c503c38..427e9248 100644 --- a/finance/managed-fund/anchor/programs/managed-fund/src/oracle.rs +++ b/finance/managed-fund/anchor/programs/managed-fund/src/oracle.rs @@ -5,6 +5,8 @@ use crate::error::FundError; /// 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 `conf` (u64), the confidence interval: +8 bytes after price. +const PYTH_CONF_OFFSET: usize = PYTH_PRICE_OFFSET + 8; // 81 /// Byte offset of `exponent` (i32): price(8) + conf(8) = +16 bytes after price. const PYTH_EXPONENT_OFFSET: usize = PYTH_PRICE_OFFSET + 8 + 8; // 89 /// Byte offset of `publish_time` (i64): @@ -15,6 +17,12 @@ const PYTH_PUBLISH_TIME_OFFSET: usize = PYTH_PRICE_OFFSET + 8 + 8 + 4; // 93 const PYTH_POSTED_SLOT_OFFSET: usize = PYTH_PUBLISH_TIME_OFFSET + 8 + 8 + 8 + 8; // 125 /// Prices older than this (seconds) are rejected. const MAX_PRICE_AGE_SECONDS: i64 = 60; +/// Widest confidence interval accepted, in basis points of the price (1%). +/// Deposits price shares and rebalance sets its swap floor from the Pyth +/// price, so a price that may be off by more than a typical 1% slippage +/// tolerance would quietly widen that tolerance. Major feeds usually quote +/// well under 0.1%; a band past 1% means the publishers disagree. +const MAX_CONFIDENCE_BPS: u128 = 100; /// SPL token account layout: amount is a u64 at bytes 64..72. The base layout is /// shared by the Classic Token Program and the Token Extensions Program, so this @@ -34,8 +42,8 @@ pub struct OraclePrice { pub exponent: i32, } -/// Returns `(price, exponent, publish_time, posted_slot)`. -fn read_pyth_raw(account_data: &[u8]) -> Result<(i64, i32, i64, u64)> { +/// Returns `(price, conf, exponent, publish_time, posted_slot)`. +fn read_pyth_raw(account_data: &[u8]) -> Result<(i64, u64, i32, i64, u64)> { if account_data.len() < PYTH_POSTED_SLOT_OFFSET + 8 { return err!(FundError::InvalidPriceFeed); } @@ -44,6 +52,11 @@ fn read_pyth_raw(account_data: &[u8]) -> Result<(i64, i32, i64, u64)> { .try_into() .map_err(|_| FundError::InvalidPriceFeed)?, ); + let conf = u64::from_le_bytes( + account_data[PYTH_CONF_OFFSET..PYTH_CONF_OFFSET + 8] + .try_into() + .map_err(|_| FundError::InvalidPriceFeed)?, + ); let exponent = i32::from_le_bytes( account_data[PYTH_EXPONENT_OFFSET..PYTH_EXPONENT_OFFSET + 4] .try_into() @@ -59,11 +72,12 @@ fn read_pyth_raw(account_data: &[u8]) -> Result<(i64, i32, i64, u64)> { .try_into() .map_err(|_| FundError::InvalidPriceFeed)?, ); - Ok((price, exponent, publish_time, posted_slot)) + Ok((price, conf, exponent, publish_time, posted_slot)) } /// Validate a price feed account against the one the fund registered, then /// return its positive, fresh price. `now` is the current unix timestamp. +/// A price whose confidence interval exceeds `MAX_CONFIDENCE_BPS` is rejected. /// A price posted at or before the last cluster restart is rejected too. pub fn load_price( price_feed: &AccountView, @@ -77,9 +91,19 @@ pub fn load_price( ); let data = price_feed.try_borrow_data()?; - let (price, exponent, publish_time, posted_slot) = read_pyth_raw(&data)?; + let (price, conf, exponent, publish_time, posted_slot) = read_pyth_raw(&data)?; require!(price > 0, FundError::NegativePrice); + + // Reject a price the oracle itself is unsure of: the confidence interval, + // as a fraction of the price, must not exceed MAX_CONFIDENCE_BPS. `conf` + // shares the price's exponent, so the ratio needs no scaling, and in u128 + // neither product can overflow. + require!( + (conf as u128) * 10_000 <= (price as u128) * MAX_CONFIDENCE_BPS, + FundError::OracleConfidenceTooWide + ); + require!( now.checked_sub(publish_time) .ok_or(FundError::MathOverflow)? diff --git a/finance/managed-fund/anchor/programs/managed-fund/tests/managed_fund.rs b/finance/managed-fund/anchor/programs/managed-fund/tests/managed_fund.rs index bb7fde3d..faf702f1 100644 --- a/finance/managed-fund/anchor/programs/managed-fund/tests/managed_fund.rs +++ b/finance/managed-fund/anchor/programs/managed-fund/tests/managed_fund.rs @@ -44,9 +44,10 @@ 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, posted_slot u64 at 125. Exponent -8. +/// conf u64 at 81, publish_time i64 at 93, posted_slot u64 at 125. Exponent -8. fn build_mock_price_update_account( price: i64, + confidence: u64, exponent: i32, publish_time: i64, posted_slot: u64, @@ -58,7 +59,7 @@ fn build_mock_price_update_account( data.push(1u8); data.extend_from_slice(&[0xEFu8; 32]); data.extend_from_slice(&price.to_le_bytes()); - data.extend_from_slice(&100_000u64.to_le_bytes()); + data.extend_from_slice(&confidence.to_le_bytes()); data.extend_from_slice(&exponent.to_le_bytes()); data.extend_from_slice(&publish_time.to_le_bytes()); data.extend_from_slice(&(publish_time - 1).to_le_bytes()); @@ -79,7 +80,20 @@ fn set_price_feed_posted_at(svm: &mut LiteSVM, key: Address, price: i64, posted_ /// Write a Pyth feed with its own exponent: Pyth's US equity feeds use -5. fn write_price_feed(svm: &mut LiteSVM, key: Address, price: i64, exponent: i32, posted_slot: u64) { - let data = build_mock_price_update_account(price, exponent, PUBLISH_TIME, posted_slot); + write_price_feed_with_confidence(svm, key, price, DEFAULT_CONFIDENCE, exponent, posted_slot); +} + +/// Write a Pyth feed with its own confidence interval, in the price's units. +fn write_price_feed_with_confidence( + svm: &mut LiteSVM, + key: Address, + price: i64, + confidence: u64, + exponent: i32, + posted_slot: u64, +) { + let data = + build_mock_price_update_account(price, confidence, exponent, PUBLISH_TIME, posted_slot); let rent = svm.minimum_balance_for_rent_exemption(data.len()); svm.set_account( key, @@ -95,6 +109,8 @@ fn write_price_feed(svm: &mut LiteSVM, key: Address, price: i64, exponent: i32, } const PUBLISH_TIME: i64 = 1_700_000_000; +/// A tight confidence interval, $0.001 at exponent -8, far inside the 1% limit. +const DEFAULT_CONFIDENCE: u64 = 100_000; const TOKEN_DECIMALS: u8 = 6; const SECONDS_PER_YEAR: i64 = 31_536_000; @@ -2054,3 +2070,68 @@ fn test_valuation_scales_by_decimals_and_exponent() { assert_eq!(read_fund(&ctx).asset_holdings[1], 4_320_000); assert_holdings_match_vaults(&ctx); } + +/// A price the oracle is unsure of is not traded on. With NVDAx's confidence +/// interval at 2% of its price, past the 1% limit, deposit and rebalance both +/// refuse, but withdraw still pays out in kind: it reads no price, so investors +/// can always leave. A band of exactly 1% is accepted. +#[test] +fn test_wide_confidence_price_rejected() { + let mut ctx = setup_full(); + standard_fund(&mut ctx); + + // Alice deposits 900 USDC: 1.44 TSLAx + 3.0 NVDAx at 40/60. + let alice = fund_user(&mut ctx, 900_000_000); + do_deposit(&mut ctx, &alice, 900_000_000, 1); + + // NVDAx rises to $200, so the fund has drifted and needs a rebalance. Then + // its feed reports a $4 confidence interval: 2% of the price. + set_nvda_price(&mut ctx, 20_000_000_000, 200_000_000); + write_price_feed_with_confidence( + &mut ctx.svm, + ctx.price_feed_nvda, + 20_000_000_000, + 400_000_000, + -8, + 1, + ); + + let bob = fund_user(&mut ctx, 480_000_000); + let ix = deposit_instruction(&ctx, &bob, 480_000_000, 1, deposit_remaining(&ctx)); + assert_program_error( + send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&bob], &bob.pubkey()), + FundError::OracleConfidenceTooWide, + "a deposit priced from a wide-confidence feed must fail", + ); + assert_program_error( + try_rebalance(&mut ctx, 1, 0), + FundError::OracleConfidenceTooWide, + "a rebalance priced from a wide-confidence feed must fail", + ); + + // Withdraw reads no price: Alice takes half her shares out in kind. + do_withdraw(&mut ctx, &alice, 450_000_000, 0); + assert_eq!( + get_token_account_balance(&ctx.svm, &derive_ata(&alice.pubkey(), &ctx.tsla_mint)).unwrap(), + 720_000 + ); + assert_eq!( + get_token_account_balance(&ctx.svm, &derive_ata(&alice.pubkey(), &ctx.nvda_mint)).unwrap(), + 1_500_000 + ); + + // A $2 interval is exactly 1% of the price, which is accepted: the + // rebalance sells 0.06 NVDAx for 12 USDC and buys 0.048 TSLAx. + write_price_feed_with_confidence( + &mut ctx.svm, + ctx.price_feed_nvda, + 20_000_000_000, + 200_000_000, + -8, + 1, + ); + do_rebalance(&mut ctx, 1, 0); + assert_eq!(read_fund(&ctx).asset_holdings[0], 768_000); + assert_eq!(read_fund(&ctx).asset_holdings[1], 1_440_000); + assert_holdings_match_vaults(&ctx); +} diff --git a/finance/managed-fund/quasar/CHANGELOG.md b/finance/managed-fund/quasar/CHANGELOG.md index 5d4c1395..c94e2009 100644 --- a/finance/managed-fund/quasar/CHANGELOG.md +++ b/finance/managed-fund/quasar/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [2026-10-03] + +### Fixed + +- Prices with a wide confidence interval are rejected. `load_price` reads the + Pyth `conf` field (offset 81) and fails with the new + `OracleConfidenceTooWide` error when it exceeds `MAX_CONFIDENCE_BPS` (100 + bps, 1% of the price). Deposit and rebalance are refused; withdraw reads no + price and still pays out in kind. Tested by + `test_wide_confidence_price_rejected`. + ## [2026-10-01] ### Fixed diff --git a/finance/managed-fund/quasar/README.md b/finance/managed-fund/quasar/README.md index 97254fcf..264de5c1 100644 --- a/finance/managed-fund/quasar/README.md +++ b/finance/managed-fund/quasar/README.md @@ -112,7 +112,10 @@ withdraw), in index order. 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`). + sysvar's slot (`PricePredatesRestart`). A price whose confidence interval is + wider than 1% of the price (`MAX_CONFIDENCE_BPS`) is rejected too + (`OracleConfidenceTooWide`). `withdraw` reads no price, so investors can + always leave in kind. ## What the Quasar port does differently @@ -159,7 +162,10 @@ rebalance tests sign as a stranger and check that a fund at its targets, within its threshold, or just rebalanced cannot be traded (`test_rebalance_cannot_churn` and its neighbors), and `test_valuation_scales_by_decimals_and_exponent` runs the story with an -eight-decimal asset on an exponent −5 feed. +eight-decimal asset on an exponent −5 feed. `test_wide_confidence_price_rejected` +widens NVDAx's confidence interval to 2% of its price and checks that deposit +and rebalance are refused while withdraw still pays out, and that a band of +exactly 1% is accepted. ## Extending diff --git a/finance/managed-fund/quasar/managed-fund/src/errors.rs b/finance/managed-fund/quasar/managed-fund/src/errors.rs index f991ebec..a24c35a2 100644 --- a/finance/managed-fund/quasar/managed-fund/src/errors.rs +++ b/finance/managed-fund/quasar/managed-fund/src/errors.rs @@ -40,4 +40,6 @@ pub enum FundError { DriftBelowThreshold, /// The asset to buy is not below its target weight. NotUnderweight, + /// The Pyth price's confidence interval is too wide to trust. + OracleConfidenceTooWide, } diff --git a/finance/managed-fund/quasar/managed-fund/src/oracle.rs b/finance/managed-fund/quasar/managed-fund/src/oracle.rs index 386d02a0..0d9a2b9b 100644 --- a/finance/managed-fund/quasar/managed-fund/src/oracle.rs +++ b/finance/managed-fund/quasar/managed-fund/src/oracle.rs @@ -5,6 +5,8 @@ use crate::{errors::FundError, 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 `conf` (u64), the confidence interval: +8 bytes after price. +const PYTH_CONF_OFFSET: usize = PYTH_PRICE_OFFSET + 8; // 81 /// Byte offset of `exponent` (i32): price(8) + conf(8) = +16 bytes after price. const PYTH_EXPONENT_OFFSET: usize = PYTH_PRICE_OFFSET + 8 + 8; // 89 /// Byte offset of `publish_time` (i64): price(8) + conf(8) + exponent(4) after price. @@ -14,6 +16,12 @@ const PYTH_PUBLISH_TIME_OFFSET: usize = PYTH_PRICE_OFFSET + 8 + 8 + 4; // 93 const PYTH_POSTED_SLOT_OFFSET: usize = PYTH_PUBLISH_TIME_OFFSET + 8 + 8 + 8 + 8; // 125 /// Prices older than this (seconds) are rejected. const MAX_PRICE_AGE_SECONDS: i64 = 60; +/// Widest confidence interval accepted, in basis points of the price (1%). +/// Deposits price shares and rebalance sets its swap floor from the Pyth +/// price, so a price that may be off by more than a typical 1% slippage +/// tolerance would quietly widen that tolerance. Major feeds usually quote +/// well under 0.1%; a band past 1% means the publishers disagree. +const MAX_CONFIDENCE_BPS: u128 = 100; // SPL token account layout, shared by the Classic and Extensions token programs. const TOKEN_MINT_OFFSET: usize = 0; // mint: Pubkey [0..32] @@ -65,6 +73,7 @@ pub struct OraclePrice { /// Validate a price feed account against the one the fund registered, then /// return its positive, fresh price. `now` is the current unix timestamp. +/// A price whose confidence interval exceeds `MAX_CONFIDENCE_BPS` is rejected. /// A price posted at or before the last cluster restart is rejected too. pub fn load_price( price_feed: &AccountView, @@ -80,11 +89,22 @@ pub fn load_price( return Err(FundError::InvalidPriceFeed.into()); } let price = read_i64(data, PYTH_PRICE_OFFSET)?; + let conf = read_u64(data, PYTH_CONF_OFFSET)?; let exponent = read_i32(data, PYTH_EXPONENT_OFFSET)?; let publish_time = read_i64(data, PYTH_PUBLISH_TIME_OFFSET)?; let posted_slot = read_u64(data, PYTH_POSTED_SLOT_OFFSET)?; require!(price > 0, FundError::NegativePrice); + + // Reject a price the oracle itself is unsure of: the confidence interval, + // as a fraction of the price, must not exceed MAX_CONFIDENCE_BPS. `conf` + // shares the price's exponent, so the ratio needs no scaling, and in u128 + // neither product can overflow. + require!( + (conf as u128) * 10_000 <= (price as u128) * MAX_CONFIDENCE_BPS, + FundError::OracleConfidenceTooWide + ); + require!( now.checked_sub(publish_time) .ok_or(FundError::MathOverflow)? diff --git a/finance/managed-fund/quasar/managed-fund/src/tests.rs b/finance/managed-fund/quasar/managed-fund/src/tests.rs index 7257e373..3fbf412e 100644 --- a/finance/managed-fund/quasar/managed-fund/src/tests.rs +++ b/finance/managed-fund/quasar/managed-fund/src/tests.rs @@ -15,7 +15,9 @@ //! tokens are all refused, while a retired asset is sold out. //! `test_initialize_rejects_threshold_out_of_range` bounds the threshold, and //! `test_valuation_scales_by_decimals_and_exponent` values an eight-decimal -//! asset priced by an exponent -5 feed. +//! asset priced by an exponent -5 feed, and +//! `test_wide_confidence_price_rejected` shows a feed whose confidence interval +//! is past 1% of its price stops deposit and rebalance but not withdraw. use { crate::{ @@ -81,10 +83,11 @@ fn router_rate_pda(mint: &Pubkey) -> Pubkey { Pubkey::find_program_address(&[b"rate", mint.as_ref()], &router_id()).0 } -// A Pyth PriceUpdateV2-shaped account: `price` (i64) at offset 73, -// `exponent` (i32) at offset 89, `publish_time` (i64) at offset 93, -// `posted_slot` (u64) at offset 125. The program reads only those four fields. -// Posted at slot 1, with the -8 exponent of Pyth's crypto USD feeds. +// A Pyth PriceUpdateV2-shaped account: `price` (i64) at offset 73, `conf` +// (u64) at offset 81, `exponent` (i32) at offset 89, `publish_time` (i64) at +// offset 93, `posted_slot` (u64) at offset 125. The program reads only those +// five fields. Posted at slot 1, with the -8 exponent of Pyth's crypto USD +// feeds and a zero confidence interval. fn add_pyth_feed(test: &mut Test, price: i64, publish_time: i64) { add_pyth_feed_posted_at(test, price, publish_time, 1); } @@ -102,9 +105,23 @@ fn write_price_feed( exponent: i32, publish_time: i64, posted_slot: u64, +) { + write_price_feed_with_confidence(test, feed, price, 0, exponent, publish_time, posted_slot); +} + +/// Write a Pyth feed with its own confidence interval, in the price's units. +fn write_price_feed_with_confidence( + test: &mut Test, + feed: Pubkey, + price: i64, + confidence: u64, + exponent: i32, + publish_time: i64, + posted_slot: u64, ) { let mut data = vec![0u8; 200]; data[73..81].copy_from_slice(&price.to_le_bytes()); + data[81..89].copy_from_slice(&confidence.to_le_bytes()); data[89..93].copy_from_slice(&exponent.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()); @@ -1190,3 +1207,41 @@ fn test_valuation_scales_by_decimals_and_exponent(test: &mut Test) { assert_eq!(read_holdings(test).1[1], 4_320_000); assert_holdings_match_vaults(test); } + +/// A price the oracle is unsure of is not traded on. With NVDAx's confidence +/// interval at 2% of its price, past the 1% limit, deposit and rebalance both +/// refuse, but withdraw still pays out in kind: it reads no price, so investors +/// can always leave. A band of exactly 1% is accepted. +#[quasar_test] +fn test_wide_confidence_price_rejected(test: &mut Test) { + setup_full(test); + standard_fund(test); + + // Alice deposits 900 USDC: 1.44 TSLAx + 3.0 NVDAx at 40/60. + let alice = fund_user(test, 900_000_000); + do_deposit(test, &alice, 900_000_000); + + // NVDAx rises to $200, so the fund has drifted and needs a rebalance. Then + // its feed reports a $4 confidence interval: 2% of the price. + set_nvda_price(test, 20_000_000_000, 200_000_000); + write_price_feed_with_confidence(test, NVDA_FEED, 20_000_000_000, 400_000_000, -8, NOW, 1); + + let bob = fund_user(test, 480_000_000); + let ix = deposit_instruction(&bob, 480_000_000, deposit_remaining(test)); + test.send(ix).fails_with(FundError::OracleConfidenceTooWide); + try_rebalance(test, 1, 0).fails_with(FundError::OracleConfidenceTooWide); + + // Withdraw reads no price: Alice takes half her shares out in kind. + do_withdraw(test, &alice, 450_000_000); + assert_eq!(test.tokens(alice.tsla), 720_000); + assert_eq!(test.tokens(alice.nvda), 1_500_000); + + // A $2 interval is exactly 1% of the price, which is accepted: the + // rebalance sells 0.06 NVDAx for 12 USDC and buys 0.048 TSLAx. + write_price_feed_with_confidence(test, NVDA_FEED, 20_000_000_000, 200_000_000, -8, NOW, 1); + do_rebalance(test, 1, 0); + let (_, holdings) = read_holdings(test); + assert_eq!(holdings[0], 768_000); + assert_eq!(holdings[1], 1_440_000); + assert_holdings_match_vaults(test); +}