From a251fc3aa6a07d95e4bda51250a14437c082f538 Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Wed, 7 Oct 2026 15:31:21 +0000 Subject: [PATCH 1/6] Work in progress: fourth audit fixes Snapshot while re-checks run; final commit follows. Claude-Session: https://claude.ai/code/session_01UX53A6YR1Hjr8z6WzJxf2q --- finance/fundraiser/anchor-v1/CHANGELOG.md | 6 + finance/fundraiser/anchor-v1/README.md | 2 +- .../fundraiser/tests/test_fundraiser.rs | 38 ++- finance/fundraiser/anchor/CHANGELOG.md | 6 + finance/fundraiser/anchor/README.md | 2 +- .../fundraiser/tests/test_fundraiser.rs | 46 ++- finance/lending/anchor-v1/CHANGELOG.md | 23 ++ finance/lending/anchor-v1/README.md | 12 +- .../programs/lending/src/state/reserve.rs | 26 +- .../programs/lending/tests/test_interest.rs | 117 ++++++++ .../programs/lending/tests/test_reserve.rs | 5 +- finance/lending/anchor/CHANGELOG.md | 23 ++ finance/lending/anchor/README.md | 12 +- .../programs/lending/src/state/reserve.rs | 26 +- .../programs/lending/tests/test_interest.rs | 117 ++++++++ .../programs/lending/tests/test_reserve.rs | 5 +- finance/lending/kani-proofs/README.md | 12 +- finance/lending/kani-proofs/src/lib.rs | 50 +++- finance/lending/quasar/CHANGELOG.md | 22 ++ finance/lending/quasar/README.md | 8 + finance/lending/quasar/src/math.rs | 24 +- finance/lending/quasar/src/tests.rs | 100 ++++++- finance/managed-fund/anchor-v1/CHANGELOG.md | 4 + finance/managed-fund/anchor-v1/README.md | 4 +- .../anchor-v1/app/src/idl/managed_fund.json | 5 + .../programs/managed-fund/src/error.rs | 2 + .../managed-fund/src/instructions/deposit.rs | 8 +- .../programs/managed-fund/src/oracle.rs | 83 +++++- .../managed-fund/tests/managed_fund.rs | 103 +++++++ finance/managed-fund/anchor/CHANGELOG.md | 4 + finance/managed-fund/anchor/README.md | 4 +- .../anchor/app/src/idl/managed_fund.json | 5 + .../anchor/programs/managed-fund/src/error.rs | 2 + .../managed-fund/src/instructions/deposit.rs | 8 +- .../programs/managed-fund/src/oracle.rs | 83 +++++- .../managed-fund/tests/managed_fund.rs | 103 +++++++ finance/managed-fund/quasar/CHANGELOG.md | 10 + finance/managed-fund/quasar/README.md | 10 +- .../quasar/managed-fund/src/errors.rs | 2 + .../managed-fund/src/instructions/deposit.rs | 8 +- .../quasar/managed-fund/src/oracle.rs | 91 +++++- .../quasar/managed-fund/src/tests.rs | 91 +++++- finance/options/anchor-v1/CHANGELOG.md | 29 ++ finance/options/anchor-v1/README.md | 29 +- .../anchor-v1/programs/options/src/errors.rs | 3 + .../options/src/instructions/buy_option.rs | 22 +- .../options/src/instructions/cancel_option.rs | 14 +- .../src/instructions/reclaim_collateral.rs | 14 +- .../options/src/instructions/write_option.rs | 10 +- .../anchor-v1/programs/options/src/lib.rs | 11 +- .../programs/options/src/state/market.rs | 2 +- .../programs/options/tests/test_options.rs | 250 +++++++++++++++- finance/options/anchor/CHANGELOG.md | 29 ++ finance/options/anchor/README.md | 29 +- .../anchor/programs/options/src/errors.rs | 3 + .../options/src/instructions/buy_option.rs | 22 +- .../options/src/instructions/cancel_option.rs | 14 +- .../src/instructions/reclaim_collateral.rs | 14 +- .../options/src/instructions/write_option.rs | 10 +- .../anchor/programs/options/src/lib.rs | 11 +- .../programs/options/src/state/market.rs | 2 +- .../programs/options/tests/test_options.rs | 250 +++++++++++++++- finance/options/quasar/CHANGELOG.md | 29 ++ finance/options/quasar/README.md | 28 +- finance/options/quasar/src/errors.rs | 2 + .../quasar/src/instructions/buy_option.rs | 32 +- .../quasar/src/instructions/cancel_option.rs | 12 +- .../src/instructions/collect_proceeds.rs | 12 +- .../src/instructions/reclaim_collateral.rs | 12 +- .../quasar/src/instructions/write_option.rs | 14 +- finance/options/quasar/src/lib.rs | 22 +- finance/options/quasar/src/state.rs | 4 +- finance/options/quasar/src/tests.rs | 279 ++++++++++++++++-- finance/order-book/anchor-v1/CHANGELOG.md | 9 + .../anchor-v1/programs/order-book/src/lib.rs | 5 +- finance/order-book/anchor/CHANGELOG.md | 9 + .../anchor/programs/order-book/src/lib.rs | 5 +- .../src/instructions/shared.rs | 39 ++- .../tests/test_perpetual_futures.rs | 57 +++- .../src/instructions/shared.rs | 39 ++- .../tests/test_perpetual_futures.rs | 57 +++- .../quasar/src/instructions/shared.rs | 39 ++- finance/perpetual-futures/quasar/src/tests.rs | 51 +++- finance/prop-amm/anchor-v1/CHANGELOG.md | 22 ++ finance/prop-amm/anchor-v1/README.md | 22 +- .../anchor-v1/programs/prop-amm/src/errors.rs | 3 + .../prop-amm/src/instructions/close_market.rs | 94 ++++++ .../programs/prop-amm/src/instructions/mod.rs | 2 + .../anchor-v1/programs/prop-amm/src/lib.rs | 7 + .../programs/prop-amm/tests/test_prop_amm.rs | 190 ++++++++++++ finance/prop-amm/anchor/CHANGELOG.md | 22 ++ finance/prop-amm/anchor/README.md | 22 +- .../anchor/programs/prop-amm/src/errors.rs | 3 + .../prop-amm/src/instructions/close_market.rs | 105 +++++++ .../programs/prop-amm/src/instructions/mod.rs | 2 + .../anchor/programs/prop-amm/src/lib.rs | 7 + .../programs/prop-amm/tests/test_prop_amm.rs | 190 ++++++++++++ finance/prop-amm/quasar/CHANGELOG.md | 23 ++ finance/prop-amm/quasar/README.md | 12 +- .../quasar/src/instructions/close_market.rs | 72 +++++ .../prop-amm/quasar/src/instructions/mod.rs | 2 + .../quasar/src/instructions/shared.rs | 1 + finance/prop-amm/quasar/src/lib.rs | 7 + finance/prop-amm/quasar/src/tests.rs | 226 +++++++++++++- 104 files changed, 3620 insertions(+), 250 deletions(-) create mode 100644 finance/prop-amm/anchor-v1/programs/prop-amm/src/instructions/close_market.rs create mode 100644 finance/prop-amm/anchor/programs/prop-amm/src/instructions/close_market.rs create mode 100644 finance/prop-amm/quasar/src/instructions/close_market.rs diff --git a/finance/fundraiser/anchor-v1/CHANGELOG.md b/finance/fundraiser/anchor-v1/CHANGELOG.md index 0718dffe9..d19133742 100644 --- a/finance/fundraiser/anchor-v1/CHANGELOG.md +++ b/finance/fundraiser/anchor-v1/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026-10-07 + +### Changed + +- **Two refusal tests now assert their error code instead of any failure.** `test_stale_contribution_cannot_refund_from_next_raise` asserts that the stale refund fails with Anchor's `AccountNotInitialized` (3012): the first-raise Contribution account was closed, so its address is empty and Anchor refuses it before the handler runs. `test_reinitialize_with_open_contributions_fails` asserts that `initialize_fundraiser` fails with the System Program's `AccountAlreadyInUse` (custom error 0): the claimed fundraiser still occupies the PDA, so `init` cannot allocate it. No program changes. + ## 2026-10-03 ### Changed diff --git a/finance/fundraiser/anchor-v1/README.md b/finance/fundraiser/anchor-v1/README.md index f72a3afe6..e3fa6983b 100644 --- a/finance/fundraiser/anchor-v1/README.md +++ b/finance/fundraiser/anchor-v1/README.md @@ -164,7 +164,7 @@ The suite uses a nonzero duration and warps the LiteSVM `Clock` sysvar to exerci It checks that the claim pays the maker and marks the fundraiser claimed, that a second claim and a contribution after the claim are refused, that direct vault donations do not unlock the claim, and that anyone can refund a contributor or close their Contribution account after a claim, with the tokens and rent going to the contributor. -`test_stale_contribution_cannot_refund_from_next_raise` runs the attack the open-account count exists to stop: a raise succeeds, its Contribution accounts and the fundraiser are closed, the maker starts a second raise at the same address, and a first-raise contributor's refund from the second raise fails while every second-raise contributor gets back exactly what they put in. `test_reinitialize_with_open_contributions_fails` and `test_close_fundraiser_with_open_contributions_fails` check that the second raise cannot start while any first-raise Contribution account is open. +`test_stale_contribution_cannot_refund_from_next_raise` runs the attack the open-account count exists to stop: a raise succeeds, its Contribution accounts and the fundraiser are closed, the maker starts a second raise at the same address, and a first-raise contributor's refund from the second raise fails with Anchor's `AccountNotInitialized`, because their Contribution account no longer exists, while every second-raise contributor gets back exactly what they put in. `test_close_fundraiser_with_open_contributions_fails` checks that the claimed fundraiser cannot close while any Contribution account is open (`ContributionsOpen`), and `test_reinitialize_with_open_contributions_fails` checks that a second raise cannot start while the claimed fundraiser still exists: `init` fails with the System Program's `AccountAlreadyInUse`. `close_fundraiser` is tested on both paths (after a failed raise, only after the deadline, only when the target was missed and refunds are complete; after a claim, only once every Contribution account is closed), including that it pays direct donations to the maker and that the same maker can then initialize a fresh fundraiser. Assertions check token balances and decoded account state rather than just transaction success. diff --git a/finance/fundraiser/anchor-v1/programs/fundraiser/tests/test_fundraiser.rs b/finance/fundraiser/anchor-v1/programs/fundraiser/tests/test_fundraiser.rs index 791617c5b..42697bfb1 100644 --- a/finance/fundraiser/anchor-v1/programs/fundraiser/tests/test_fundraiser.rs +++ b/finance/fundraiser/anchor-v1/programs/fundraiser/tests/test_fundraiser.rs @@ -440,6 +440,27 @@ fn assert_error(result: Result, expected_error: F ); } +/// Anchor's `ErrorCode::AccountNotInitialized`: an account the instruction +/// declares as `Account` has no data and is still owned by the System +/// Program. +const ANCHOR_ACCOUNT_NOT_INITIALIZED: u32 = 3012; + +/// The System Program's `SystemError::AccountAlreadyInUse`: `init` asked it to +/// allocate an address that already holds an account. +const SYSTEM_ACCOUNT_ALREADY_IN_USE: u32 = 0; + +/// Asserts that a transaction failed with the given error code, for errors +/// raised by Anchor itself or by a program it calls rather than by +/// `FundraiserError`. +fn assert_error_code(result: Result, expected_code: u32) { + let error = result.expect_err("transaction should have failed"); + let expected_code = format!("Custom({expected_code})"); + assert!( + error.contains(&expected_code), + "expected {expected_code}, got: {error}" + ); +} + #[test] fn test_initialize_fundraiser() { let mut setup = full_setup(); @@ -1008,11 +1029,11 @@ fn test_reinitialize_with_open_contributions_fails() { vec![initialize_instruction], &[&setup.maker], &setup.maker.pubkey(), - ); - assert!( - result.is_err(), - "A new fundraiser must not start while the claimed one exists" - ); + ) + .map_err(|error| format!("{error:?}")); + // `init` asks the System Program to allocate the fundraiser's address, + // which still holds the claimed fundraiser, so it refuses. + assert_error_code(result, SYSTEM_ACCOUNT_ALREADY_IN_USE); let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); assert!(fundraiser_state.claimed); } @@ -1048,10 +1069,13 @@ fn test_stale_contribution_cannot_refund_from_next_raise() { // A raise-one contributor tries to take a refund from raise two. Their // contribution account was closed with raise one, so there is nothing to - // refund. + // refund: Anchor refuses the empty address before the handler runs. let stale_contributor = &first_raise_contributors[0]; let fee_payer = stale_contributor.keypair.insecure_clone(); - assert!(refund(&mut setup, &fee_payer, stale_contributor).is_err()); + assert_error_code( + refund(&mut setup, &fee_payer, stale_contributor), + ANCHOR_ACCOUNT_NOT_INITIALIZED, + ); assert_eq!( get_token_account_balance(&setup.svm, &setup.vault).unwrap(), 2 * CONTRIBUTION diff --git a/finance/fundraiser/anchor/CHANGELOG.md b/finance/fundraiser/anchor/CHANGELOG.md index e23192193..79798b453 100644 --- a/finance/fundraiser/anchor/CHANGELOG.md +++ b/finance/fundraiser/anchor/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026-10-07 + +### Changed + +- **Two refusal tests now assert their error code instead of any failure.** `test_stale_contribution_cannot_refund_from_next_raise` asserts that the stale refund fails with the runtime's `UninitializedAccount` (`InstructionError::UninitializedAccount`, which Anchor 2 returns for an empty account address): the first-raise Contribution account was closed, so its address is empty and Anchor refuses it before the handler runs. `test_reinitialize_with_open_contributions_fails` asserts that `initialize_fundraiser` fails with the System Program's `AccountAlreadyInUse` (custom error 0): the claimed fundraiser still occupies the PDA, so `init` cannot allocate it. No program changes. + ## 2026-10-03 ### Changed diff --git a/finance/fundraiser/anchor/README.md b/finance/fundraiser/anchor/README.md index df28f3bd7..72521ad1a 100644 --- a/finance/fundraiser/anchor/README.md +++ b/finance/fundraiser/anchor/README.md @@ -165,7 +165,7 @@ The suite uses a nonzero duration and warps the LiteSVM `Clock` sysvar to exerci It checks that the claim pays the maker and marks the fundraiser claimed, that a second claim and a contribution after the claim are refused, that direct vault donations do not unlock the claim, and that anyone can refund a contributor or close their Contribution account after a claim, with the tokens and rent going to the contributor. -`test_stale_contribution_cannot_refund_from_next_raise` runs the attack the open-account count exists to stop: a raise succeeds, its Contribution accounts and the fundraiser are closed, the maker starts a second raise at the same address, and a first-raise contributor's refund from the second raise fails while every second-raise contributor gets back exactly what they put in. `test_reinitialize_with_open_contributions_fails` and `test_close_fundraiser_with_open_contributions_fails` check that the second raise cannot start while any first-raise Contribution account is open. +`test_stale_contribution_cannot_refund_from_next_raise` runs the attack the open-account count exists to stop: a raise succeeds, its Contribution accounts and the fundraiser are closed, the maker starts a second raise at the same address, and a first-raise contributor's refund from the second raise fails with the runtime's `UninitializedAccount`, because their Contribution account no longer exists, while every second-raise contributor gets back exactly what they put in. `test_close_fundraiser_with_open_contributions_fails` checks that the claimed fundraiser cannot close while any Contribution account is open (`ContributionsOpen`), and `test_reinitialize_with_open_contributions_fails` checks that a second raise cannot start while the claimed fundraiser still exists: `init` fails with the System Program's `AccountAlreadyInUse`. `close_fundraiser` is tested on both paths (after a failed raise, only after the deadline, only when the target was missed and refunds are complete; after a claim, only once every Contribution account is closed), including that it pays direct donations to the maker and that the same maker can then initialize a fresh fundraiser. Assertions check token balances and decoded account state rather than just transaction success. diff --git a/finance/fundraiser/anchor/programs/fundraiser/tests/test_fundraiser.rs b/finance/fundraiser/anchor/programs/fundraiser/tests/test_fundraiser.rs index a298d012e..154c67935 100644 --- a/finance/fundraiser/anchor/programs/fundraiser/tests/test_fundraiser.rs +++ b/finance/fundraiser/anchor/programs/fundraiser/tests/test_fundraiser.rs @@ -440,6 +440,34 @@ fn assert_error(result: Result, expected_error: F ); } +/// The System Program's `SystemError::AccountAlreadyInUse`: `init` asked it to +/// allocate an address that already holds an account. +const SYSTEM_ACCOUNT_ALREADY_IN_USE: u32 = 0; + +/// Asserts that a transaction failed with the given custom error code, for +/// errors raised by a program the fundraiser calls rather than by +/// `FundraiserError`. +fn assert_error_code(result: Result, expected_code: u32) { + let error = result.expect_err("transaction should have failed"); + let expected_code = format!("Custom({expected_code})"); + assert!( + error.contains(&expected_code), + "expected {expected_code}, got: {error}" + ); +} + +/// Asserts that a transaction's only instruction failed with the given +/// built-in runtime error, such as `UninitializedAccount`, which Anchor 2 +/// returns as a `ProgramError` rather than as a custom code. +fn assert_instruction_error(result: Result, expected_error: &str) { + let error = result.expect_err("transaction should have failed"); + let expected = format!("InstructionError(0, {expected_error})"); + assert!( + error.contains(&expected), + "expected {expected}, got: {error}" + ); +} + #[test] fn test_initialize_fundraiser() { let mut setup = full_setup(); @@ -1008,11 +1036,11 @@ fn test_reinitialize_with_open_contributions_fails() { vec![initialize_instruction], &[&setup.maker], &setup.maker.pubkey(), - ); - assert!( - result.is_err(), - "A new fundraiser must not start while the claimed one exists" - ); + ) + .map_err(|error| format!("{error:?}")); + // `init` asks the System Program to allocate the fundraiser's address, + // which still holds the claimed fundraiser, so it refuses. + assert_error_code(result, SYSTEM_ACCOUNT_ALREADY_IN_USE); let fundraiser_state = read_fundraiser_state(&setup.svm, &setup.fundraiser_pda); assert!(fundraiser_state.claimed); } @@ -1048,10 +1076,14 @@ fn test_stale_contribution_cannot_refund_from_next_raise() { // A raise-one contributor tries to take a refund from raise two. Their // contribution account was closed with raise one, so there is nothing to - // refund. + // refund: Anchor refuses the empty address before the handler runs, with + // the runtime's `UninitializedAccount`. let stale_contributor = &first_raise_contributors[0]; let fee_payer = stale_contributor.keypair.insecure_clone(); - assert!(refund(&mut setup, &fee_payer, stale_contributor).is_err()); + assert_instruction_error( + refund(&mut setup, &fee_payer, stale_contributor), + "UninitializedAccount", + ); assert_eq!( get_token_account_balance(&setup.svm, &setup.vault).unwrap(), 2 * CONTRIBUTION diff --git a/finance/lending/anchor-v1/CHANGELOG.md b/finance/lending/anchor-v1/CHANGELOG.md index ce8b0cffd..71e71ae7d 100644 --- a/finance/lending/anchor-v1/CHANGELOG.md +++ b/finance/lending/anchor-v1/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## Unreleased (2026-10-07) + +Round interest against the borrower. Every debt is `borrowed_principal` +times `borrow_accumulation_factor`, and the debt itself was already ceiled, +but the arithmetic that grows the factor floored at every step, each time in +the borrower's favor. Five divisions now round up with `mul_div_ceil`: +`Reserve::utilization_bps` (its only use is the borrow rate), both segments of +the kinked-curve interpolation in `current_borrow_rate_per_second`, the +conversion of that APR to a per-second rate, and the factor update in +`accrue_interest`. The rate still stays within `[min, max]` and utilization +within 10,000 bps. Suppliers are not overpaid by it: the reserve counts its +debt as `borrowed_principal` times the same factor, ceiled once, which is never +more than the sum of the borrowers' individually ceiled debts, so the pool's +assets never include interest no borrower owes; redemptions still floor and +the program fee still rounds up. On the book's walkthrough (750 borrowed from +a 2,000 USDC pool for five weeks) Bob's debt rises from 757.501028 to +757.508220 USDC. Tested by +`accumulation_factor_rounds_up_against_the_borrower`, which runs a second +accrual from a factor no longer at 1.0, checks that flooring would have given +a smaller utilization, APR, rate and factor, and asserts the program's factor +is the ceiled one. The test helper `factor_after` in `test_reserve.rs` now +rounds up too. + ## Unreleased (2026-10-05) Cap the borrow rate, ratchet the liquidation threshold and keep the bonus diff --git a/finance/lending/anchor-v1/README.md b/finance/lending/anchor-v1/README.md index 4d31c0a3d..fcc21e87a 100644 --- a/finance/lending/anchor-v1/README.md +++ b/finance/lending/anchor-v1/README.md @@ -87,6 +87,15 @@ utilization. Each borrow stores its principal as **scaled debt** (principal ÷ index at borrow time), so every obligation's debt grows automatically as the index advances: no per-obligation accrual loop. +Every division on the way to the factor rounds up, against the borrower: the +utilization, the climb along the curve, the per-second rate and the factor +update itself. A debt is principal times the factor, so flooring any of them +would understate every debt. Suppliers are not overpaid by it: the reserve +counts its own debt as its total principal times the same factor, ceiled once, +which is never more than the borrowers' individually ceiled debts add up to +(`accumulation_factor_rounds_up_against_the_borrower` checks a second accrual +against both roundings). + Those curve parameters are annual, and the conversion to a per-second rate divides by `SECONDS_PER_YEAR`. Elapsed time is the Clock's `unix_timestamp` minus the reserve's `last_accrual_timestamp`, so a borrower pays the advertised @@ -178,7 +187,8 @@ less, which would make the liquidator overpay. All arithmetic is integer-only `u128`: no floats, no fixed-point crates. Ratios (rates, the index, the exchange rate, obligation values) are scaled by `FIXED_POINT_SCALE` (10^18). Every conversion rounds in the program's favour -(user output floored, debt and the program fee ceiled), so dust cannot be +(user output floored; debt, the interest that grows it, and the program fee +ceiled), so dust cannot be extracted by repeated round-trips; `deposit_redeem_round_trip_creates_no_value` checks this by depositing and redeeming 777,777,777 units fifty times against a reserve whose diff --git a/finance/lending/anchor-v1/programs/lending/src/state/reserve.rs b/finance/lending/anchor-v1/programs/lending/src/state/reserve.rs index 825a885ef..79b29ef0e 100644 --- a/finance/lending/anchor-v1/programs/lending/src/state/reserve.rs +++ b/finance/lending/anchor-v1/programs/lending/src/state/reserve.rs @@ -5,7 +5,7 @@ use crate::constants::{ SECONDS_PER_YEAR, }; use crate::errors::LendingError; -use crate::math::{mul_div_ceil, mul_div_floor}; +use crate::math::mul_div_ceil; /// Signer seeds for a reserve PDA, which is the authority over its liquidity /// vault and the mint authority of its share token. @@ -213,13 +213,16 @@ impl Reserve { .ok_or(LendingError::MathOverflow.into()) } - /// Borrowed fraction of the pool, in basis points (0..=10_000). + /// Borrowed fraction of the pool, in basis points (0..=10_000). Rounded + /// up, because its only use is the borrow rate, and a floored utilization + /// would charge the borrower a lower rate. It still never exceeds 10_000, + /// since the debt is part of the gross liquidity it is divided by. pub fn utilization_bps(&self) -> Result { let gross = self.gross_liquidity()?; if gross == 0 { return Ok(0); } - mul_div_floor( + mul_div_ceil( self.current_borrowed_amount()? as u128, BPS_DENOMINATOR, gross, @@ -229,6 +232,11 @@ impl Reserve { /// Per-second borrow rate (FIXED_POINT_SCALE-scaled) from the kinked curve: /// linear from `min` to `optimal` up to the kink, then steeper from `optimal` /// to `max` between the kink and full utilization. + /// + /// Both divisions round up: the interpolated APR and the per-second rate + /// derived from it. A rate is what the borrower is charged, so like the + /// debt it rounds against the borrower. The interpolation still never + /// leaves `[min, max]`, because the climbed amount is at most the range. pub fn current_borrow_rate_per_second(&self) -> Result { let utilization = self.utilization_bps()?; let optimal_utilization = self.config.optimal_utilization_bps as u128; @@ -237,7 +245,7 @@ impl Reserve { let rate_range = (self.config.optimal_borrow_rate_bps as u128) .checked_sub(self.config.min_borrow_rate_bps as u128) .ok_or(LendingError::MathOverflow)?; - let climbed = mul_div_floor(rate_range, utilization, optimal_utilization)?; + let climbed = mul_div_ceil(rate_range, utilization, optimal_utilization)?; (self.config.min_borrow_rate_bps as u128) .checked_add(climbed) .ok_or(LendingError::MathOverflow)? @@ -251,7 +259,7 @@ impl Reserve { let utilization_range = BPS_DENOMINATOR .checked_sub(optimal_utilization) .ok_or(LendingError::MathOverflow)?; - let climbed = mul_div_floor(rate_range, utilization_above, utilization_range)?; + let climbed = mul_div_ceil(rate_range, utilization_above, utilization_range)?; (self.config.optimal_borrow_rate_bps as u128) .checked_add(climbed) .ok_or(LendingError::MathOverflow)? @@ -261,14 +269,16 @@ impl Reserve { let per_year_denominator = BPS_DENOMINATOR .checked_mul(SECONDS_PER_YEAR) .ok_or(LendingError::MathOverflow)?; - mul_div_floor(apr_bps, FIXED_POINT_SCALE, per_year_denominator) + mul_div_ceil(apr_bps, FIXED_POINT_SCALE, per_year_denominator) } /// Advance the accumulation factor for the seconds elapsed since the last /// accrual, and record `current_slot` as the slot of this refresh. /// `new_factor = old_factor * (1 + rate_per_second * elapsed_seconds)`, a /// single multiply per refresh that compounds across refreshes (Solend's - /// approach, on the wall clock rather than the slot count). + /// approach, on the wall clock rather than the slot count). The product + /// rounds up: every debt is principal times this factor, so a floored + /// factor would understate every borrower's debt. /// /// The timestamp is written by each block's leader. The runtime rejects a /// block whose time goes backwards, but a timestamp at or before the stored @@ -292,7 +302,7 @@ impl Reserve { let growth_factor = FIXED_POINT_SCALE .checked_add(accrued) .ok_or(LendingError::MathOverflow)?; - self.borrow_accumulation_factor = mul_div_floor( + self.borrow_accumulation_factor = mul_div_ceil( self.borrow_accumulation_factor, growth_factor, FIXED_POINT_SCALE, diff --git a/finance/lending/anchor-v1/programs/lending/tests/test_interest.rs b/finance/lending/anchor-v1/programs/lending/tests/test_interest.rs index fb99735a6..2aeb17aa0 100644 --- a/finance/lending/anchor-v1/programs/lending/tests/test_interest.rs +++ b/finance/lending/anchor-v1/programs/lending/tests/test_interest.rs @@ -171,3 +171,120 @@ fn program_fee_rounds_up_and_suppliers_take_the_remainder() { "the suppliers' pool grows by the interest less the fee" ); } + +/// What one accrual does to a reserve, worked out from its stored fields with +/// every division rounded one way: `ceil` when `round_up`, `floor` otherwise. +/// Returns the utilization, the APR, the per-second rate and the new factor, +/// so a test can compare the program's factor with both roundings. +fn accrue_by_hand( + reserve: &lending::state::Reserve, + seconds: u128, + round_up: bool, +) -> (u128, u128, u128, u128) { + use lending::constants::{BPS_DENOMINATOR, SECONDS_PER_YEAR}; + let divide = |numerator: u128, denominator: u128| { + if round_up { + numerator.div_ceil(denominator) + } else { + numerator / denominator + } + }; + let config = reserve.config; + let factor = reserve.borrow_accumulation_factor; + // The debt itself is always ceiled; that rounding is not under test here. + let debt = (reserve.borrowed_principal * factor).div_ceil(FIXED_POINT_SCALE); + let gross = reserve.available_liquidity as u128 + debt; + let utilization = divide(debt * BPS_DENOMINATOR, gross); + let optimal_utilization = config.optimal_utilization_bps as u128; + let apr_bps = if utilization <= optimal_utilization { + config.min_borrow_rate_bps as u128 + + divide( + (config.optimal_borrow_rate_bps - config.min_borrow_rate_bps) as u128 * utilization, + optimal_utilization, + ) + } else { + config.optimal_borrow_rate_bps as u128 + + divide( + (config.max_borrow_rate_bps - config.optimal_borrow_rate_bps) as u128 + * (utilization - optimal_utilization), + BPS_DENOMINATOR - optimal_utilization, + ) + }; + let rate_per_second = divide( + apr_bps * FIXED_POINT_SCALE, + BPS_DENOMINATOR * SECONDS_PER_YEAR, + ); + let new_factor = divide( + factor * (FIXED_POINT_SCALE + rate_per_second * seconds), + FIXED_POINT_SCALE, + ); + (utilization, apr_bps, rate_per_second, new_factor) +} + +/// Every debt is principal times the accumulation factor, so every division +/// that leads to the factor rounds against the borrower: the utilization, the +/// APR read off the curve, the per-second rate, and the factor's own growth all +/// round up. The second accrual here starts from a factor that is no longer +/// 1.0, so each of the four divisions has a remainder and flooring would give +/// a smaller factor; the program's factor is the ceiled one. +#[test] +fn accumulation_factor_rounds_up_against_the_borrower() { + let mut env = Env::new(); + let collateral = env.add_reserve(6, dollars(1), default_config()); + let borrow = env.add_reserve(6, dollars(1), default_config()); + + let supplier = env.create_user(); + env.fund(&supplier, borrow.mint, 1_000_000_000); + env.supply(&supplier, &borrow, 1_000_000_000); + + let borrower = env.create_user(); + env.fund(&borrower, collateral.mint, 1_000_000_000); + env.fund(&borrower, borrow.mint, 0); + env.supply(&borrower, &collateral, 1_000_000_000); + let obligation = env.initialize_obligation(&borrower); + env.post_collateral(&borrower, obligation, &collateral, 1_000_000_000); + env.try_borrow( + &borrower, + obligation, + &[&collateral], + &[], + &borrow, + 500_000_000, + ) + .unwrap(); + + // The first accrual moves the factor off exactly 1.0. + env.warp_seconds(TENTH_OF_A_YEAR); + env.refresh_reserve_only(&borrower, &borrow); + let before = env.reserve(&borrow); + assert!(before.borrow_accumulation_factor > FIXED_POINT_SCALE); + + let seconds = 86_400; + env.warp_seconds(seconds); + env.refresh_reserve_only(&borrower, &borrow); + let factor = env.reserve(&borrow).borrow_accumulation_factor; + + let (utilization_up, apr_up, rate_up, factor_up) = + accrue_by_hand(&before, seconds as u128, true); + let (utilization_down, apr_down, rate_down, factor_down) = + accrue_by_hand(&before, seconds as u128, false); + // Every division in the chain has a remainder, so flooring each one would + // have charged less. + assert_eq!(utilization_up, utilization_down + 1); + assert_eq!(apr_up, apr_down + 1); + assert!(rate_up > rate_down); + assert!(factor_up > factor_down); + // The factor's own growth has a remainder too: flooring only that last + // step, with the ceiled rate, would give one unit less. + let growth_up = FIXED_POINT_SCALE + rate_up * seconds as u128; + assert_ne!( + (before.borrow_accumulation_factor * growth_up) % FIXED_POINT_SCALE, + 0 + ); + assert_eq!( + factor_up, + before.borrow_accumulation_factor * growth_up / FIXED_POINT_SCALE + 1 + ); + + assert_eq!(factor, factor_up, "the program's factor is the ceiled one"); +} diff --git a/finance/lending/anchor-v1/programs/lending/tests/test_reserve.rs b/finance/lending/anchor-v1/programs/lending/tests/test_reserve.rs index 01d5a6dc0..1af70fa23 100644 --- a/finance/lending/anchor-v1/programs/lending/tests/test_reserve.rs +++ b/finance/lending/anchor-v1/programs/lending/tests/test_reserve.rs @@ -131,10 +131,11 @@ fn half_borrowed_reserve(env: &mut Env) -> (common::ReserveHandle, common::Reser } /// The factor after one refresh `seconds` after the last: one multiply by -/// `1 + rate_per_second * seconds`, floored, exactly as the program does it. +/// `1 + rate_per_second * seconds`, rounded up, exactly as the program does it. fn factor_after(reserve: &Reserve, seconds: u128) -> u128 { let rate = reserve.current_borrow_rate_per_second().unwrap(); - reserve.borrow_accumulation_factor * (FIXED_POINT_SCALE + rate * seconds) / FIXED_POINT_SCALE + (reserve.borrow_accumulation_factor * (FIXED_POINT_SCALE + rate * seconds)) + .div_ceil(FIXED_POINT_SCALE) } /// The rate fields are annual, and a year is a length of wall-clock time, so diff --git a/finance/lending/anchor/CHANGELOG.md b/finance/lending/anchor/CHANGELOG.md index e40d3167c..1dec0a336 100644 --- a/finance/lending/anchor/CHANGELOG.md +++ b/finance/lending/anchor/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## Unreleased (2026-10-07) + +Round interest against the borrower. Every debt is `borrowed_principal` +times `borrow_accumulation_factor`, and the debt itself was already ceiled, +but the arithmetic that grows the factor floored at every step, each time in +the borrower's favor. Five divisions now round up with `mul_div_ceil`: +`Reserve::utilization_bps` (its only use is the borrow rate), both segments of +the kinked-curve interpolation in `current_borrow_rate_per_second`, the +conversion of that APR to a per-second rate, and the factor update in +`accrue_interest`. The rate still stays within `[min, max]` and utilization +within 10,000 bps. Suppliers are not overpaid by it: the reserve counts its +debt as `borrowed_principal` times the same factor, ceiled once, which is never +more than the sum of the borrowers' individually ceiled debts, so the pool's +assets never include interest no borrower owes; redemptions still floor and +the program fee still rounds up. On the book's walkthrough (750 borrowed from +a 2,000 USDC pool for five weeks) Bob's debt rises from 757.501028 to +757.508220 USDC. Tested by +`accumulation_factor_rounds_up_against_the_borrower`, which runs a second +accrual from a factor no longer at 1.0, checks that flooring would have given +a smaller utilization, APR, rate and factor, and asserts the program's factor +is the ceiled one. The test helper `factor_after` in `test_reserve.rs` now +rounds up too. + ## Unreleased (2026-10-05) Cap the borrow rate, ratchet the liquidation threshold and keep the bonus diff --git a/finance/lending/anchor/README.md b/finance/lending/anchor/README.md index 4c21ac9a3..66c0e524b 100644 --- a/finance/lending/anchor/README.md +++ b/finance/lending/anchor/README.md @@ -87,6 +87,15 @@ utilization. Each borrow stores its principal as **scaled debt** (principal ÷ index at borrow time), so every obligation's debt grows automatically as the index advances: no per-obligation accrual loop. +Every division on the way to the factor rounds up, against the borrower: the +utilization, the climb along the curve, the per-second rate and the factor +update itself. A debt is principal times the factor, so flooring any of them +would understate every debt. Suppliers are not overpaid by it: the reserve +counts its own debt as its total principal times the same factor, ceiled once, +which is never more than the borrowers' individually ceiled debts add up to +(`accumulation_factor_rounds_up_against_the_borrower` checks a second accrual +against both roundings). + Those curve parameters are annual, and the conversion to a per-second rate divides by `SECONDS_PER_YEAR`. Elapsed time is the Clock's `unix_timestamp` minus the reserve's `last_accrual_timestamp`, so a borrower pays the advertised @@ -178,7 +187,8 @@ less, which would make the liquidator overpay. All arithmetic is integer-only `u128`: no floats, no fixed-point crates. Ratios (rates, the index, the exchange rate, obligation values) are scaled by `FIXED_POINT_SCALE` (10^18). Every conversion rounds in the program's favour -(user output floored, debt and the program fee ceiled), so dust cannot be +(user output floored; debt, the interest that grows it, and the program fee +ceiled), so dust cannot be extracted by repeated round-trips; `deposit_redeem_round_trip_creates_no_value` checks this by depositing and redeeming 777,777,777 units fifty times against a reserve whose diff --git a/finance/lending/anchor/programs/lending/src/state/reserve.rs b/finance/lending/anchor/programs/lending/src/state/reserve.rs index 4f4801675..64e9ad257 100644 --- a/finance/lending/anchor/programs/lending/src/state/reserve.rs +++ b/finance/lending/anchor/programs/lending/src/state/reserve.rs @@ -5,7 +5,7 @@ use crate::constants::{ SECONDS_PER_YEAR, }; use crate::errors::LendingError; -use crate::math::{mul_div_ceil, mul_div_floor}; +use crate::math::mul_div_ceil; /// Signer seeds for a reserve PDA, which is the authority over its liquidity /// vault and the mint authority of its share token. @@ -215,13 +215,16 @@ impl Reserve { .ok_or(LendingError::MathOverflow.into()) } - /// Borrowed fraction of the pool, in basis points (0..=10_000). + /// Borrowed fraction of the pool, in basis points (0..=10_000). Rounded + /// up, because its only use is the borrow rate, and a floored utilization + /// would charge the borrower a lower rate. It still never exceeds 10_000, + /// since the debt is part of the gross liquidity it is divided by. pub fn utilization_bps(&self) -> Result { let gross = self.gross_liquidity()?; if gross == 0 { return Ok(0); } - mul_div_floor( + mul_div_ceil( self.current_borrowed_amount()? as u128, BPS_DENOMINATOR, gross, @@ -231,6 +234,11 @@ impl Reserve { /// Per-second borrow rate (FIXED_POINT_SCALE-scaled) from the kinked curve: /// linear from `min` to `optimal` up to the kink, then steeper from `optimal` /// to `max` between the kink and full utilization. + /// + /// Both divisions round up: the interpolated APR and the per-second rate + /// derived from it. A rate is what the borrower is charged, so like the + /// debt it rounds against the borrower. The interpolation still never + /// leaves `[min, max]`, because the climbed amount is at most the range. pub fn current_borrow_rate_per_second(&self) -> Result { let utilization = self.utilization_bps()?; let optimal_utilization = self.config.optimal_utilization_bps as u128; @@ -239,7 +247,7 @@ impl Reserve { let rate_range = (self.config.optimal_borrow_rate_bps as u128) .checked_sub(self.config.min_borrow_rate_bps as u128) .ok_or(LendingError::MathOverflow)?; - let climbed = mul_div_floor(rate_range, utilization, optimal_utilization)?; + let climbed = mul_div_ceil(rate_range, utilization, optimal_utilization)?; (self.config.min_borrow_rate_bps as u128) .checked_add(climbed) .ok_or(LendingError::MathOverflow)? @@ -253,7 +261,7 @@ impl Reserve { let utilization_range = BPS_DENOMINATOR .checked_sub(optimal_utilization) .ok_or(LendingError::MathOverflow)?; - let climbed = mul_div_floor(rate_range, utilization_above, utilization_range)?; + let climbed = mul_div_ceil(rate_range, utilization_above, utilization_range)?; (self.config.optimal_borrow_rate_bps as u128) .checked_add(climbed) .ok_or(LendingError::MathOverflow)? @@ -263,14 +271,16 @@ impl Reserve { let per_year_denominator = BPS_DENOMINATOR .checked_mul(SECONDS_PER_YEAR) .ok_or(LendingError::MathOverflow)?; - mul_div_floor(apr_bps, FIXED_POINT_SCALE, per_year_denominator) + mul_div_ceil(apr_bps, FIXED_POINT_SCALE, per_year_denominator) } /// Advance the accumulation factor for the seconds elapsed since the last /// accrual, and record `current_slot` as the slot of this refresh. /// `new_factor = old_factor * (1 + rate_per_second * elapsed_seconds)`, a /// single multiply per refresh that compounds across refreshes (Solend's - /// approach, on the wall clock rather than the slot count). + /// approach, on the wall clock rather than the slot count). The product + /// rounds up: every debt is principal times this factor, so a floored + /// factor would understate every borrower's debt. /// /// The timestamp is written by each block's leader. The runtime rejects a /// block whose time goes backwards, but a timestamp at or before the stored @@ -294,7 +304,7 @@ impl Reserve { let growth_factor = FIXED_POINT_SCALE .checked_add(accrued) .ok_or(LendingError::MathOverflow)?; - self.borrow_accumulation_factor = mul_div_floor( + self.borrow_accumulation_factor = mul_div_ceil( self.borrow_accumulation_factor, growth_factor, FIXED_POINT_SCALE, diff --git a/finance/lending/anchor/programs/lending/tests/test_interest.rs b/finance/lending/anchor/programs/lending/tests/test_interest.rs index f9d4f3a0d..7917c26d4 100644 --- a/finance/lending/anchor/programs/lending/tests/test_interest.rs +++ b/finance/lending/anchor/programs/lending/tests/test_interest.rs @@ -171,3 +171,120 @@ fn program_fee_rounds_up_and_suppliers_take_the_remainder() { "the suppliers' pool grows by the interest less the fee" ); } + +/// What one accrual does to a reserve, worked out from its stored fields with +/// every division rounded one way: `ceil` when `round_up`, `floor` otherwise. +/// Returns the utilization, the APR, the per-second rate and the new factor, +/// so a test can compare the program's factor with both roundings. +fn accrue_by_hand( + reserve: &lending::state::Reserve, + seconds: u128, + round_up: bool, +) -> (u128, u128, u128, u128) { + use lending::constants::{BPS_DENOMINATOR, SECONDS_PER_YEAR}; + let divide = |numerator: u128, denominator: u128| { + if round_up { + numerator.div_ceil(denominator) + } else { + numerator / denominator + } + }; + let config = reserve.config; + let factor = reserve.borrow_accumulation_factor; + // The debt itself is always ceiled; that rounding is not under test here. + let debt = (reserve.borrowed_principal * factor).div_ceil(FIXED_POINT_SCALE); + let gross = reserve.available_liquidity as u128 + debt; + let utilization = divide(debt * BPS_DENOMINATOR, gross); + let optimal_utilization = config.optimal_utilization_bps as u128; + let apr_bps = if utilization <= optimal_utilization { + config.min_borrow_rate_bps as u128 + + divide( + (config.optimal_borrow_rate_bps - config.min_borrow_rate_bps) as u128 * utilization, + optimal_utilization, + ) + } else { + config.optimal_borrow_rate_bps as u128 + + divide( + (config.max_borrow_rate_bps - config.optimal_borrow_rate_bps) as u128 + * (utilization - optimal_utilization), + BPS_DENOMINATOR - optimal_utilization, + ) + }; + let rate_per_second = divide( + apr_bps * FIXED_POINT_SCALE, + BPS_DENOMINATOR * SECONDS_PER_YEAR, + ); + let new_factor = divide( + factor * (FIXED_POINT_SCALE + rate_per_second * seconds), + FIXED_POINT_SCALE, + ); + (utilization, apr_bps, rate_per_second, new_factor) +} + +/// Every debt is principal times the accumulation factor, so every division +/// that leads to the factor rounds against the borrower: the utilization, the +/// APR read off the curve, the per-second rate, and the factor's own growth all +/// round up. The second accrual here starts from a factor that is no longer +/// 1.0, so each of the four divisions has a remainder and flooring would give +/// a smaller factor; the program's factor is the ceiled one. +#[test] +fn accumulation_factor_rounds_up_against_the_borrower() { + let mut env = Env::new(); + let collateral = env.add_reserve(6, dollars(1), default_config()); + let borrow = env.add_reserve(6, dollars(1), default_config()); + + let supplier = env.create_user(); + env.fund(&supplier, borrow.mint, 1_000_000_000); + env.supply(&supplier, &borrow, 1_000_000_000); + + let borrower = env.create_user(); + env.fund(&borrower, collateral.mint, 1_000_000_000); + env.fund(&borrower, borrow.mint, 0); + env.supply(&borrower, &collateral, 1_000_000_000); + let obligation = env.initialize_obligation(&borrower); + env.post_collateral(&borrower, obligation, &collateral, 1_000_000_000); + env.try_borrow( + &borrower, + obligation, + &[&collateral], + &[], + &borrow, + 500_000_000, + ) + .unwrap(); + + // The first accrual moves the factor off exactly 1.0. + env.warp_seconds(TENTH_OF_A_YEAR); + env.refresh_reserve_only(&borrower, &borrow); + let before = env.reserve(&borrow); + assert!(before.borrow_accumulation_factor > FIXED_POINT_SCALE); + + let seconds = 86_400; + env.warp_seconds(seconds); + env.refresh_reserve_only(&borrower, &borrow); + let factor = env.reserve(&borrow).borrow_accumulation_factor; + + let (utilization_up, apr_up, rate_up, factor_up) = + accrue_by_hand(&before, seconds as u128, true); + let (utilization_down, apr_down, rate_down, factor_down) = + accrue_by_hand(&before, seconds as u128, false); + // Every division in the chain has a remainder, so flooring each one would + // have charged less. + assert_eq!(utilization_up, utilization_down + 1); + assert_eq!(apr_up, apr_down + 1); + assert!(rate_up > rate_down); + assert!(factor_up > factor_down); + // The factor's own growth has a remainder too: flooring only that last + // step, with the ceiled rate, would give one unit less. + let growth_up = FIXED_POINT_SCALE + rate_up * seconds as u128; + assert_ne!( + (before.borrow_accumulation_factor * growth_up) % FIXED_POINT_SCALE, + 0 + ); + assert_eq!( + factor_up, + before.borrow_accumulation_factor * growth_up / FIXED_POINT_SCALE + 1 + ); + + assert_eq!(factor, factor_up, "the program's factor is the ceiled one"); +} diff --git a/finance/lending/anchor/programs/lending/tests/test_reserve.rs b/finance/lending/anchor/programs/lending/tests/test_reserve.rs index 8752f83a1..23713bb49 100644 --- a/finance/lending/anchor/programs/lending/tests/test_reserve.rs +++ b/finance/lending/anchor/programs/lending/tests/test_reserve.rs @@ -126,10 +126,11 @@ fn half_borrowed_reserve(env: &mut Env) -> (common::ReserveHandle, common::Reser } /// The factor after one refresh `seconds` after the last: one multiply by -/// `1 + rate_per_second * seconds`, floored, exactly as the program does it. +/// `1 + rate_per_second * seconds`, rounded up, exactly as the program does it. fn factor_after(reserve: &Reserve, seconds: u128) -> u128 { let rate = reserve.current_borrow_rate_per_second().unwrap(); - reserve.borrow_accumulation_factor * (FIXED_POINT_SCALE + rate * seconds) / FIXED_POINT_SCALE + (reserve.borrow_accumulation_factor * (FIXED_POINT_SCALE + rate * seconds)) + .div_ceil(FIXED_POINT_SCALE) } /// The rate fields are annual, and a year is a length of wall-clock time, so diff --git a/finance/lending/kani-proofs/README.md b/finance/lending/kani-proofs/README.md index a965be576..d84a614ed 100644 --- a/finance/lending/kani-proofs/README.md +++ b/finance/lending/kani-proofs/README.md @@ -21,10 +21,10 @@ formulas faithfully and checks their invariants: - `proof_mul_div_floor_ceil_correct`: `mul_div_floor`/`mul_div_ceil` are the true floor/ceil of `a·b/d`, differ by ≤ 1, and coincide iff the division is exact. - `proof_rounding_is_program_favourable`: `ceil ≥ floor` always, debt (rounded up) is never undercounted and a supplier claim (rounded down) never overcounted, so dust can't be extracted by round-trips. -- `proof_accumulation_factor_monotonic`: The borrow accumulation factor never decreases (`accrue_interest` multiplies by a factor ≥ 1), borrowers always owe ≥ principal. +- `proof_accumulation_factor_monotonic`: The borrow accumulation factor never decreases (`accrue_interest` multiplies by a factor ≥ 1), borrowers always owe ≥ principal; the update rounds up, so it is the least integer covering the exact product and never understates a debt. - `proof_program_fee_rounds_up_within_interest`: The program's cut of an accrual, `ceil(interest · reserve_factor / 10000)`, rounds up yet never exceeds the interest, so the suppliers' remainder never underflows and fee + remainder = interest. -- `proof_utilization_in_range`: Utilization is always a valid `[0, 10000]` bps fraction (`borrowed ≤ gross`). -- `proof_borrow_rate_within_bounds`: The kinked rate curve stays within `[min_rate, max_rate]` for every utilization, given the config ordering `min ≤ optimal ≤ max`. +- `proof_utilization_in_range`: Utilization, rounded up against the borrower, is always a valid `[0, 10000]` bps fraction (`borrowed ≤ gross`). +- `proof_borrow_rate_within_bounds`: The kinked rate curve stays within `[min_rate, max_rate]` for every utilization, given the config ordering `min ≤ optimal ≤ max`, with each segment's climb rounded up against the borrower. - `proof_deposit_redeem_cannot_extract`: A deposit→redeem round-trip never returns more liquidity than was put in (both legs floor), no rounding drain of the pool. - `proof_liquidation_repay_bounded_by_debt`: A liquidation never repays more than the debt (close factor ≤ 100% ⇒ `max_repay ≤ debt`). - `proof_seize_value_includes_bonus`: Seized value always includes the bonus (`seize ≥ repay_value`), the liquidator is never under-compensated. @@ -49,10 +49,10 @@ so the harness can use a small one: - `proof_mul_div_floor_ceil_correct`: `a, b, d <= 31`, ~37s - `proof_rounding_is_program_favourable`: `a, b, d <= 127`, ~29s -- `proof_accumulation_factor_monotonic`: `old/accrued <= 255`, `scale <= 127`, ~5s +- `proof_accumulation_factor_monotonic`: `old/accrued <= 255`, `scale <= 127`, ~50s - `proof_program_fee_rounds_up_within_interest`: `interest <= 4095`, <1s -- `proof_utilization_in_range`: `<= 4095`, ~1s -- `proof_borrow_rate_within_bounds`: rates `<= 255`, `full_utilization <= 32`, ~25s +- `proof_utilization_in_range`: `<= 4095`, ~10s +- `proof_borrow_rate_within_bounds`: rates `<= 255`, `full_utilization <= 32`, ~70s - `proof_deposit_redeem_cannot_extract`: `<= 31`, ~6s - `proof_liquidation_repay_bounded_by_debt`: `debt <= 4095`, <1s - `proof_seize_value_includes_bonus`: `repay_value <= 4095`, <1s diff --git a/finance/lending/kani-proofs/src/lib.rs b/finance/lending/kani-proofs/src/lib.rs index 4df3ff747..f290d0611 100644 --- a/finance/lending/kani-proofs/src/lib.rs +++ b/finance/lending/kani-proofs/src/lib.rs @@ -111,19 +111,22 @@ fn proof_rounding_is_program_favourable() { // 2. Compounding accumulation factor (reserve::accrue_interest) // =========================================================================== -/// Factor update `new = floor(old * growth / scale)` where the growth per -/// accrual is `scale + accrued` (so always `>= scale`). Generic in `scale` -/// because the property is scale-invariant (the real code uses -/// `FIXED_POINT_SCALE = 10^18`). +/// Factor update `new = ceil(old * growth / scale)` where the growth per +/// accrual is `scale + accrued` (so always `>= scale`). It rounds up because +/// every debt is principal times this factor, and a floored factor would +/// understate it. Generic in `scale` because the property is scale-invariant +/// (the real code uses `FIXED_POINT_SCALE = 10^18`). pub fn grow_factor(old_factor: u128, accrued: u128, scale: u128) -> Option { let growth = scale.checked_add(accrued)?; - mul_div_floor(old_factor, growth, scale) + mul_div_ceil(old_factor, growth, scale) } /// The borrow accumulation factor is monotonically non-decreasing: each /// accrual multiplies by a factor `>= 1`, so `new_factor >= old_factor`. A debt /// scaled by this value can therefore never shrink from interest accrual, the -/// core guarantee that borrowers always owe at least their principal. +/// core guarantee that borrowers always owe at least their principal. The +/// update also never understates the exact product: it is the least integer +/// covering `old * growth / scale`, so rounding goes against the borrower. #[cfg(kani)] #[kani::proof] #[kani::solver(cadical)] @@ -139,6 +142,10 @@ fn proof_accumulation_factor_monotonic() { let new_factor = grow_factor(old_factor, accrued, scale).unwrap(); assert!(new_factor >= old_factor); // the factor never decreases + // Rounded up: never below the exact product, and less than one unit above. + let product = old_factor * (scale + accrued); + assert!(new_factor * scale >= product); + assert!(new_factor == 0 || (new_factor - 1) * scale < product); } /// The program's cut of one accrual's interest: `ceil(interest * reserve_factor @@ -176,17 +183,19 @@ fn proof_program_fee_rounds_up_within_interest() { // 3. Utilization and the kinked borrow-rate curve (reserve.rs) // =========================================================================== -/// `utilization_bps = floor(borrowed * 10_000 / gross)` (0 if the pool is empty). +/// `utilization_bps = ceil(borrowed * 10_000 / gross)` (0 if the pool is +/// empty). Rounded up because it only feeds the borrow rate, and a floored +/// utilization would charge the borrower less. pub fn utilization_bps(borrowed: u128, gross: u128) -> u128 { if gross == 0 { return 0; } - mul_div_floor(borrowed, BPS_DENOMINATOR, gross).unwrap() + mul_div_ceil(borrowed, BPS_DENOMINATOR, gross).unwrap() } /// Utilization is always a valid fraction in `[0, 10_000]` bps, because the /// borrowed amount can never exceed gross liquidity (`gross = available + -/// borrowed`). Keeps the rate curve's domain well-defined. +/// borrowed`), even rounded up. Keeps the rate curve's domain well-defined. #[cfg(kani)] #[kani::proof] #[kani::solver(cadical)] @@ -201,7 +210,9 @@ fn proof_utilization_in_range() { } /// The kinked borrow-rate APR (bps) from `current_borrow_rate_per_second`, given a -/// utilization and the curve parameters. Mirrors the two-segment formula. +/// utilization and the curve parameters. Mirrors the two-segment formula, +/// including its rounding: each segment's climb rounds up, against the +/// borrower. /// /// `full_utilization` is the 100%-utilization denominator — `BPS_DENOMINATOR` /// (10_000) on-chain. It is a parameter here only so the scale-invariant @@ -218,13 +229,13 @@ pub fn borrow_rate_bps( ) -> Option { if utilization <= optimal_utilization { let rate_range = optimal_rate.checked_sub(min_rate)?; - let climbed = mul_div_floor(rate_range, utilization, optimal_utilization)?; + let climbed = mul_div_ceil(rate_range, utilization, optimal_utilization)?; min_rate.checked_add(climbed) } else { let rate_range = max_rate.checked_sub(optimal_rate)?; let utilization_above = utilization.checked_sub(optimal_utilization)?; let utilization_range = full_utilization.checked_sub(optimal_utilization)?; - let climbed = mul_div_floor(rate_range, utilization_above, utilization_range)?; + let climbed = mul_div_ceil(rate_range, utilization_above, utilization_range)?; optimal_rate.checked_add(climbed) } } @@ -234,6 +245,7 @@ pub fn borrow_rate_bps( /// enforces (`min <= optimal <= max`, `0 < optimal_utilization < 10_000`). So /// the interest rate can never escape its configured bounds regardless of pool /// state — no utilization makes a borrower pay below `min` or above `max`. +/// Rounding the climb up keeps this: the climb is at most the segment's range. #[cfg(kani)] #[kani::proof] #[kani::solver(cadical)] @@ -372,6 +384,20 @@ mod tests { assert_eq!(grow_factor(150, 10, 100).unwrap(), 165); // zero accrual leaves the index unchanged. assert_eq!(grow_factor(150, 0, 100).unwrap(), 150); + // a remainder rounds up: 3 * 3 / 2 = 4.5 -> 5, against the borrower. + assert_eq!(grow_factor(3, 1, 2).unwrap(), 5); + } + + #[test] + fn utilization_and_rate_round_up() { + // 1 of 3 borrowed is 3,333.3 bps, which rounds up to 3,334. + assert_eq!(utilization_bps(1, 3), 3_334); + // min 200, optimal 2000, kink at 8000: 1800 * 3334 / 8000 = 750.15, + // which rounds up to 751. + assert_eq!( + borrow_rate_bps(3_334, 200, 2_000, 15_000, 8_000, 10_000).unwrap(), + 951 + ); } #[test] diff --git a/finance/lending/quasar/CHANGELOG.md b/finance/lending/quasar/CHANGELOG.md index a210b960a..bb13909f7 100644 --- a/finance/lending/quasar/CHANGELOG.md +++ b/finance/lending/quasar/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## [Unreleased] (2026-10-07) + +### Changed + +- Round interest against the borrower. Every debt is `borrowed_principal` + times `borrow_accumulation_factor`, and the debt itself was already ceiled, + but the arithmetic that grows the factor floored at every step, each time in + the borrower's favor. Five divisions now round up with `mul_div_ceil`: + `utilization_bps` (its only use is the borrow rate), both segments of the + kinked-curve interpolation in `borrow_rate_per_second`, the conversion of + that APR to a per-second rate, and the factor update in `accrue_factor`. The + rate still stays within `[min, max]` and utilization within 10,000 bps. + Suppliers are not overpaid by it: the reserve counts its debt as + `borrowed_principal` times the same factor, ceiled once, which is never more + than the sum of the borrowers' individually ceiled debts, so the pool's + assets never include interest no borrower owes; redemptions still floor and + the program fee still rounds up. Tested by + `accumulation_factor_rounds_up_against_the_borrower`, which runs a second + accrual from a factor no longer at 1.0, checks that flooring would have + given a smaller utilization, APR, rate and factor, and asserts the program's + factor is the ceiled one. The test helper `factor_after` now rounds up too. + ## [Unreleased] (2026-10-05) ### Changed diff --git a/finance/lending/quasar/README.md b/finance/lending/quasar/README.md index 444b06b24..334ab9cb6 100644 --- a/finance/lending/quasar/README.md +++ b/finance/lending/quasar/README.md @@ -139,6 +139,14 @@ Everything else mirrors the Anchor version. block's leader, but the runtime bounds how far one block can move it, so an elapsed time is out by a second or two at most, which is nothing against an annual rate. A timestamp at or before the stored one accrues nothing. + Every division on the way to the accumulation factor rounds up, against the + borrower: the utilization, the climb along the curve, the per-second rate and + the factor update itself, since a debt is principal times the factor. + Suppliers are not overpaid by it: the reserve counts its own debt as its + total principal times the same factor, ceiled once, which is never more than + the borrowers' individually ceiled debts add up to + (`accumulation_factor_rounds_up_against_the_borrower` checks a second accrual + against both roundings). ### Instruction handlers (numeric discriminators) diff --git a/finance/lending/quasar/src/math.rs b/finance/lending/quasar/src/math.rs index 2f05a240a..54f2a2cdd 100644 --- a/finance/lending/quasar/src/math.rs +++ b/finance/lending/quasar/src/math.rs @@ -144,7 +144,10 @@ pub fn total_shares(share_mint_supply: u64) -> Result { .ok_or(LendingError::MathOverflow.into()) } -/// Borrowed fraction of the pool in basis points (0..=10_000). +/// Borrowed fraction of the pool in basis points (0..=10_000). Rounded up, +/// because its only use is the borrow rate, and a floored utilization would +/// charge the borrower a lower rate. It still never exceeds 10_000, since the +/// debt is part of the total it is divided by. pub fn utilization_bps( available: u64, borrowed_principal: u128, @@ -154,7 +157,7 @@ pub fn utilization_bps( if total == 0 { return Ok(0); } - mul_div_floor( + mul_div_ceil( current_debt(borrowed_principal, factor)? as u128, BPS_DENOMINATOR, total, @@ -162,6 +165,11 @@ pub fn utilization_bps( } /// Per-second borrow rate (FIXED_POINT_SCALE-scaled) from the kinked curve. +/// +/// Both divisions round up: the interpolated APR and the per-second rate +/// derived from it. A rate is what the borrower is charged, so like the debt +/// it rounds against the borrower. The interpolation still never leaves +/// `[min, max]`, because the climbed amount is at most the range. pub fn borrow_rate_per_second( utilization: u128, optimal_utilization_bps: u16, @@ -175,7 +183,7 @@ pub fn borrow_rate_per_second( .checked_sub(min_rate_bps as u128) .ok_or(LendingError::MathOverflow)?; (min_rate_bps as u128) - .checked_add(mul_div_floor( + .checked_add(mul_div_ceil( range, utilization, optimal_utilization.max(1), @@ -192,18 +200,20 @@ pub fn borrow_rate_per_second( .checked_sub(optimal_utilization) .ok_or(LendingError::MathOverflow)?; (optimal_rate_bps as u128) - .checked_add(mul_div_floor(range, above, span.max(1))?) + .checked_add(mul_div_ceil(range, above, span.max(1))?) .ok_or(LendingError::MathOverflow)? }; // apr_bps / (BPS_DENOMINATOR * SECONDS_PER_YEAR), carried at FIXED_POINT_SCALE. let per_year_denominator = BPS_DENOMINATOR .checked_mul(SECONDS_PER_YEAR) .ok_or(LendingError::MathOverflow)?; - mul_div_floor(apr_bps, FIXED_POINT_SCALE, per_year_denominator) + mul_div_ceil(apr_bps, FIXED_POINT_SCALE, per_year_denominator) } /// Advance the accumulation factor for `elapsed_seconds`: -/// `new_factor = factor * (1 + rate_per_second * elapsed_seconds)`. +/// `new_factor = factor * (1 + rate_per_second * elapsed_seconds)`, rounded up: +/// every debt is principal times this factor, so a floored factor would +/// understate every borrower's debt. #[allow(clippy::too_many_arguments)] pub fn accrue_factor( factor: u128, @@ -233,7 +243,7 @@ pub fn accrue_factor( .ok_or(LendingError::MathOverflow)?, ) .ok_or(LendingError::MathOverflow)?; - mul_div_floor(factor, growth, FIXED_POINT_SCALE) + mul_div_ceil(factor, growth, FIXED_POINT_SCALE) } #[allow(clippy::too_many_arguments)] diff --git a/finance/lending/quasar/src/tests.rs b/finance/lending/quasar/src/tests.rs index d3007d54c..518a3b286 100644 --- a/finance/lending/quasar/src/tests.rs +++ b/finance/lending/quasar/src/tests.rs @@ -2147,7 +2147,7 @@ mod clock_warp { } /// The factor after one accrual `seconds` after the last: one multiply by - /// `1 + rate_per_second * seconds`, floored, exactly as the program does it. + /// `1 + rate_per_second * seconds`, rounded up, exactly as the program does it. fn factor_after(reserve: &ReserveState, seconds: u128) -> u128 { let factor = u128::from(reserve.borrow_accumulation_factor); let utilization = utilization_bps( @@ -2164,7 +2164,103 @@ mod clock_warp { u16::from(reserve.max_borrow_rate_bps), ) .unwrap(); - factor * (FIXED_POINT_SCALE + rate * seconds) / FIXED_POINT_SCALE + (factor * (FIXED_POINT_SCALE + rate * seconds)).div_ceil(FIXED_POINT_SCALE) + } + + /// What one accrual does to a reserve, worked out from its stored fields + /// with every division rounded one way: `ceil` when `round_up`, `floor` + /// otherwise. Returns the utilization, the APR, the per-second rate and the + /// new factor, so a test can compare the program's factor with both + /// roundings without calling the program's own rate functions. + fn accrue_by_hand( + reserve: &ReserveState, + seconds: u128, + round_up: bool, + ) -> (u128, u128, u128, u128) { + use crate::constants::{BPS_DENOMINATOR, SECONDS_PER_YEAR}; + let divide = |numerator: u128, denominator: u128| { + if round_up { + numerator.div_ceil(denominator) + } else { + numerator / denominator + } + }; + let factor = u128::from(reserve.borrow_accumulation_factor); + let min_rate = u128::from(u16::from(reserve.min_borrow_rate_bps)); + let optimal_rate = u128::from(u16::from(reserve.optimal_borrow_rate_bps)); + let max_rate = u128::from(u16::from(reserve.max_borrow_rate_bps)); + let optimal_utilization = u128::from(u16::from(reserve.optimal_utilization_bps)); + // The debt itself is always ceiled; that rounding is not under test here. + let debt = (u128::from(reserve.borrowed_principal) * factor).div_ceil(FIXED_POINT_SCALE); + let gross = u128::from(u64::from(reserve.available_liquidity)) + debt; + let utilization = divide(debt * BPS_DENOMINATOR, gross); + let apr_bps = if utilization <= optimal_utilization { + min_rate + divide((optimal_rate - min_rate) * utilization, optimal_utilization) + } else { + optimal_rate + + divide( + (max_rate - optimal_rate) * (utilization - optimal_utilization), + BPS_DENOMINATOR - optimal_utilization, + ) + }; + let rate_per_second = divide( + apr_bps * FIXED_POINT_SCALE, + BPS_DENOMINATOR * SECONDS_PER_YEAR, + ); + let new_factor = divide( + factor * (FIXED_POINT_SCALE + rate_per_second * seconds), + FIXED_POINT_SCALE, + ); + (utilization, apr_bps, rate_per_second, new_factor) + } + + /// Every debt is principal times the accumulation factor, so every + /// division that leads to the factor rounds against the borrower: the + /// utilization, the APR read off the curve, the per-second rate, and the + /// factor's own growth all round up. The second accrual here starts from a + /// factor that is no longer 1.0, so each of the four divisions has a + /// remainder and flooring would give a smaller factor; the program's factor + /// is the ceiled one. + #[test] + fn accumulation_factor_rounds_up_against_the_borrower() { + let mut world = World::new(); + world.bootstrap_position(); + world + .borrow(BORROWER, BORROWER_BORROW, 500 * UNIT) + .assert_success(); + + // The first accrual moves the factor off exactly 1.0. + world.warp_seconds(TENTH_OF_A_YEAR); + world.refresh_borrow_reserve(); + let before = world.reserve(world.borrow_reserve); + let factor_before = u128::from(before.borrow_accumulation_factor); + assert!(factor_before > FIXED_POINT_SCALE); + + let seconds: u128 = 86_400; + world.warp_seconds(seconds as i64); + world.refresh_borrow_reserve(); + let factor = u128::from( + world + .reserve(world.borrow_reserve) + .borrow_accumulation_factor, + ); + + let (utilization_up, apr_up, rate_up, factor_up) = accrue_by_hand(&before, seconds, true); + let (utilization_down, apr_down, rate_down, factor_down) = + accrue_by_hand(&before, seconds, false); + // Every division in the chain has a remainder, so flooring each one + // would have charged less. + assert_eq!(utilization_up, utilization_down + 1); + assert_eq!(apr_up, apr_down + 1); + assert!(rate_up > rate_down); + assert!(factor_up > factor_down); + // The factor's own growth has a remainder too: flooring only that last + // step, with the ceiled rate, would give one unit less. + let growth_up = FIXED_POINT_SCALE + rate_up * seconds; + assert_ne!((factor_before * growth_up) % FIXED_POINT_SCALE, 0); + assert_eq!(factor_up, factor_before * growth_up / FIXED_POINT_SCALE + 1); + + assert_eq!(factor, factor_up, "the program's factor is the ceiled one"); } /// The rate fields are annual, and a year is a length of wall-clock time, so diff --git a/finance/managed-fund/anchor-v1/CHANGELOG.md b/finance/managed-fund/anchor-v1/CHANGELOG.md index fd9d84f93..9f5cb6299 100644 --- a/finance/managed-fund/anchor-v1/CHANGELOG.md +++ b/finance/managed-fund/anchor-v1/CHANGELOG.md @@ -8,6 +8,10 @@ - **Tests mint TSLAx and NVDAx with eight decimals**, as the real tokens have, and USDC with six: `ASSET_DECIMALS` and `USDC_DECIMALS` replace `TOKEN_DECIMALS`. The program reads decimals from each mint, so every asserted basket amount is now in eight-decimal minor units and the same in major units as before (1.44 TSLAx is `144_000_000`). `test_valuation_scales_by_decimals_and_exponent` keeps running the story with an eight-decimal TSLAx on an exponent −5 feed, as the book describes, so it differs from the story in exponent only, and the new `test_valuation_scales_by_nine_decimals_and_exponent` runs it with TSLAx at nine decimals on the same feed, so the decimals vary as well; both get the story's share counts. `value_in_usdc` scales by each asset's decimals and the feed exponent. - **Every refusal test asserts its error code.** The tests that checked only `is_err()` now assert the program error (`WeightOverflow`, `TooManyAssets`, `FeeTooHigh`, `SlippageConfigTooHigh`, `FundNotFullyAllocated`, `InvalidSwapRouter`, `UsdcSlippage`, `IncompleteAssetAccounts`), the router's `SlippageExceeded` raised inside a deposit's swap CPI, or Anchor's own errors, `ConstraintHasOne` for a non-manager `set_weight` and `AccountNotInitialized` for an unapproved asset, through the new `assert_router_error` and `assert_anchor_error` helpers. +### Fixed + +- **A partially verified Pyth update is refused.** `load_price` read the price at fixed offsets (price at 73) that assume the one-byte encoding of `verification_level`, `Full`. A `Partial { num_signatures }` update, verified by fewer than a quorum of Pyth's guardian set, encodes it in two bytes, so every later field sits a byte further along and the price, confidence, exponent, publish time and posted slot would all be read from the wrong bytes. `load_price` now requires the tag at offset 40 to be `Full` (1) and fails with the new `PriceNotFullyVerified` error otherwise; it does not try to parse a `Partial` update. Tested by `test_partially_verified_price_rejected`. The web app's IDL gains the error. + ## 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. diff --git a/finance/managed-fund/anchor-v1/README.md b/finance/managed-fund/anchor-v1/README.md index 4e8012bd5..5c2ad0555 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. 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. +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. The fields are read at fixed byte offsets that assume the update's `verification_level` is `Full`, meaning a quorum of Pyth's guardian set (three of five) verified it, so `load_price` checks that tag (offset 40) first and refuses anything else with `PriceNotFullyVerified`. A `Partial` update was verified by fewer signatures, and its `verification_level` encodes in two bytes rather than one, which would move every later field a byte along. `withdraw` reads no price, so investors can always leave in kind while deposits and rebalances wait for the band to narrow. ### Shares @@ -163,7 +163,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. TSLAx and NVDAx are minted with eight decimals, as the real tokens are, and USDC with six, so the basket amounts the tests assert are in eight-decimal minor units while shares and USDC stay in six. 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, each asserting the error code it fails with: unapproved asset, weight overflow, over-cap fee and slippage, oracle-bounded deposit slippage (the router's `SlippageExceeded`, raised inside the swap CPI), an under-allocated fund, non-manager `set_weight` (Anchor's own constraint error), 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, and `test_valuation_scales_by_nine_decimals_and_exponent` does the same with TSLAx at nine decimals. `test_collect_fees` checks a year's fee comes out exact and `test_collect_fees_rounds_up` that a day's fee rounds up to the manager. `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. +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. TSLAx and NVDAx are minted with eight decimals, as the real tokens are, and USDC with six, so the basket amounts the tests assert are in eight-decimal minor units while shares and USDC stay in six. 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, each asserting the error code it fails with: unapproved asset, weight overflow, over-cap fee and slippage, oracle-bounded deposit slippage (the router's `SlippageExceeded`, raised inside the swap CPI), an under-allocated fund, non-manager `set_weight` (Anchor's own constraint error), 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, and `test_valuation_scales_by_nine_decimals_and_exponent` does the same with TSLAx at nine decimals. `test_collect_fees` checks a year's fee comes out exact and `test_collect_fees_rounds_up` that a day's fee rounds up to the manager. `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_partially_verified_price_rejected` writes NVDAx's feed as a `Partial` update signed by two guardians and checks that a deposit fails with `PriceNotFullyVerified`, then rewrites it as `Full` at the same price and checks the deposit prices exactly as `test_deposit_first` does. `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 891535abf..677aed539 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 @@ -1052,6 +1052,11 @@ "code": 6032, "name": "OracleConfidenceTooWide", "msg": "Pyth price confidence interval is too wide to trust" + }, + { + "code": 6033, + "name": "PriceNotFullyVerified", + "msg": "Pyth price update is not fully verified by the guardian set" } ], "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 462d7f940..1451ab61e 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 @@ -68,4 +68,6 @@ pub enum FundError { NotUnderweight, #[msg("Pyth price confidence interval is too wide to trust")] OracleConfidenceTooWide, + #[msg("Pyth price update is not fully verified by the guardian set")] + PriceNotFullyVerified, } diff --git a/finance/managed-fund/anchor-v1/programs/managed-fund/src/instructions/deposit.rs b/finance/managed-fund/anchor-v1/programs/managed-fund/src/instructions/deposit.rs index bdbe2135d..90cf62a9b 100644 --- a/finance/managed-fund/anchor-v1/programs/managed-fund/src/instructions/deposit.rs +++ b/finance/managed-fund/anchor-v1/programs/managed-fund/src/instructions/deposit.rs @@ -8,7 +8,9 @@ use anchor_spl::{ use mock_swap_router::cpi::accounts::SwapUsdcForAssetAccountConstraints as RouterSwapAccounts; use crate::error::FundError; -use crate::oracle::{asset_value_in_usdc, load_price, read_token_amount, usdc_to_asset_amount}; +use crate::oracle::{ + asset_value_in_usdc_rounded_up, load_price, read_token_amount, usdc_to_asset_amount, +}; use crate::state::{AssetConfig, Fund}; #[derive(Accounts)] @@ -116,6 +118,8 @@ pub fn handle_deposit<'info>( // Net asset value over the complete asset set. The assets are exactly indices // 0..asset_count, so requiring five accounts per index, in order, each with a // matching index, makes it impossible to omit an asset and understate NAV. + // Each asset is valued rounding up, so NAV is never understated and the + // floored share count below rounds against the depositor, not the holders. let remaining = context.remaining_accounts; require!( remaining.len() == asset_count * 5, @@ -144,7 +148,7 @@ pub fn handle_deposit<'info>( let price = load_price(feed_account, &config.price_feed, now)?; let amount = asset_holdings[index]; nav = nav - .checked_add(asset_value_in_usdc( + .checked_add(asset_value_in_usdc_rounded_up( amount as u128, price, config.decimals, 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 1f90ec474..86f62cbc2 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 @@ -3,6 +3,14 @@ use solana_sysvar::last_restart_slot::LastRestartSlot; use crate::error::FundError; +/// Byte offset of the `verification_level` enum tag inside a Pyth +/// PriceUpdateV2 account: 8 discriminator + 32 write_authority = 40. +const PYTH_VERIFICATION_LEVEL_OFFSET: usize = 40; +/// Borsh tag of `VerificationLevel::Full`, a price verified against a quorum +/// of Pyth's guardian set. `Partial { num_signatures }` is tag 0 followed by a +/// one-byte signature count, so it encodes in two bytes rather than one and +/// moves every later field one byte along. The offsets below assume `Full`. +const PYTH_VERIFICATION_LEVEL_FULL: u8 = 1; /// 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; @@ -48,6 +56,14 @@ 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); } + // Refuse anything but a fully verified update. A partially verified one + // was signed by fewer than a quorum of the guardian set, and its longer + // `verification_level` encoding would shift every offset below by a byte, + // so its price would be read from the wrong bytes. + require!( + account_data[PYTH_VERIFICATION_LEVEL_OFFSET] == PYTH_VERIFICATION_LEVEL_FULL, + FundError::PriceNotFullyVerified + ); let price = i64::from_le_bytes( account_data[PYTH_PRICE_OFFSET..PYTH_PRICE_OFFSET + 8] .try_into() @@ -79,7 +95,8 @@ fn read_pyth_raw(account_data: &[u8]) -> Result<(i64, u64, i32, i64, u64)> { /// 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. +/// A price posted at or before the last cluster restart is rejected too, and +/// so is an update Pyth's guardian set did not fully verify. pub fn load_price( price_feed: &AccountInfo, expected_key: &Pubkey, @@ -165,23 +182,47 @@ pub fn read_token_mint_and_owner(account: &AccountInfo) -> Result<(Pubkey, Pubke Ok((mint, owner)) } -/// `numerator * 10^power / denominator`, floored, for a power of either sign: -/// a negative power divides by `10^-power` instead. Multiplies before dividing. -fn mul_pow10_div(numerator: u128, power: i32, denominator: u128) -> Result { +/// The fraction `numerator * 10^power / denominator` as a (numerator, +/// denominator) pair, for a power of either sign: a negative power multiplies +/// the denominator by `10^-power` instead. Multiplies before dividing. +fn mul_pow10_fraction(numerator: u128, power: i32, denominator: u128) -> Result<(u128, u128)> { let scale = 10u128 .checked_pow(power.unsigned_abs()) .ok_or(FundError::MathOverflow)?; - let (numerator, denominator) = if power >= 0 { - (numerator.checked_mul(scale), Some(denominator)) + if power >= 0 { + Ok(( + numerator + .checked_mul(scale) + .ok_or(FundError::MathOverflow)?, + denominator, + )) } else { - (Some(numerator), denominator.checked_mul(scale)) - }; + Ok(( + numerator, + denominator + .checked_mul(scale) + .ok_or(FundError::MathOverflow)?, + )) + } +} + +/// `numerator * 10^power / denominator`, floored. +fn mul_pow10_div(numerator: u128, power: i32, denominator: u128) -> Result { + let (numerator, denominator) = mul_pow10_fraction(numerator, power, denominator)?; numerator - .ok_or(FundError::MathOverflow)? - .checked_div(denominator.ok_or(FundError::MathOverflow)?) + .checked_div(denominator) .ok_or(FundError::MathOverflow.into()) } +/// `numerator * 10^power / denominator`, rounded up. +fn mul_pow10_div_ceil(numerator: u128, power: i32, denominator: u128) -> Result { + let (numerator, denominator) = mul_pow10_fraction(numerator, power, denominator)?; + if denominator == 0 { + return Err(FundError::MathOverflow.into()); + } + Ok(numerator.div_ceil(denominator)) +} + /// Value of `amount` asset minor units in USDC minor units. The asset has /// `asset_decimals`, USDC has `usdc_decimals`, and a whole asset is worth /// `price * 10^exponent` dollars, so @@ -204,6 +245,28 @@ pub fn asset_value_in_usdc( ) } +/// `asset_value_in_usdc` rounded up rather than down. Deposit prices new +/// shares against net asset value, and a NAV floored per asset is understated, +/// which would mint the depositor more shares than their USDC buys at the +/// expense of the holders already in the fund. Rounding each asset's value up +/// overstates NAV by under one minor unit per asset instead, so the share +/// count, floored again, rounds against the depositor. +pub fn asset_value_in_usdc_rounded_up( + amount: u128, + price: OraclePrice, + asset_decimals: u8, + usdc_decimals: u8, +) -> Result { + let power = usdc_decimals as i32 + price.exponent - asset_decimals as i32; + mul_pow10_div_ceil( + amount + .checked_mul(price.price) + .ok_or(FundError::MathOverflow)?, + power, + 1, + ) +} + /// The inverse of `asset_value_in_usdc`: how many asset minor units /// `usdc_amount` USDC minor units buys at the oracle price, /// usdc_amount * 10^(asset_decimals - exponent - usdc_decimals) / price. Floored. 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 cfec8e5c1..cd185a763 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 @@ -112,6 +112,34 @@ fn write_price_feed_with_confidence( .unwrap(); } +/// Write a Pyth feed as a partially verified update, laid out as Pyth's +/// receiver writes one: `verification_level` is `Partial { num_signatures }`, +/// tag 0 at offset 40 then the signature count at 41, so every later field sits +/// one byte further along than in a fully verified update. +fn write_partially_verified_price_feed( + svm: &mut LiteSVM, + key: Pubkey, + price: i64, + num_signatures: u8, +) { + let mut data = + build_mock_price_update_account(price, DEFAULT_CONFIDENCE, PYTH_EXPONENT, PUBLISH_TIME, 1); + data[40] = 0; + data.insert(41, num_signatures); + let rent = svm.minimum_balance_for_rent_exemption(data.len()); + svm.set_account( + key, + SolanaAccount { + lamports: rent, + data, + owner: pyth_receiver_program_id(), + executable: false, + rent_epoch: 0, + }, + ) + .unwrap(); +} + 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; @@ -1105,6 +1133,38 @@ fn test_deposit_first() { ); } +/// Deposit values each asset rounding up, so the NAV it prices shares against +/// is never understated. A 1 USDC first deposit leaves the fund holding 160,000 +/// TSLAx minor units, worth exactly 400,000 USDC minor units, and 333,333 +/// NVDAx minor units, worth 599,999.4. Floored per asset the NAV would be +/// 999,999 and a second 1 USDC deposit would mint +/// floor(1,000,000 * 1,000,000 / 999,999) = 1,000,001 shares, one more than +/// its USDC buys, taken from the first holder. Rounded up the NAV is 1,000,000 +/// and the deposit mints floor(1,000,000 * 1,000,000 / 1,000,000) = 1,000,000. +#[test] +fn test_deposit_values_assets_rounding_up() { + let mut ctx = setup_full(); + standard_fund(&mut ctx); + + let amount = 1_000_000u64; // 1 USDC + let first = fund_user(&mut ctx, amount); + do_deposit(&mut ctx, &first, amount, amount); + let fund = read_fund(&ctx); + assert_eq!(fund.usdc_holdings, 0); + assert_eq!(fund.asset_holdings[0], 160_000); + assert_eq!(fund.asset_holdings[1], 333_333); + assert_eq!(fund.total_shares, 1_000_000); + + let second = fund_user(&mut ctx, amount); + let second_share = do_deposit(&mut ctx, &second, amount, 1); + assert_eq!( + get_token_account_balance(&ctx.svm, &second_share).unwrap(), + 1_000_000, + "a NAV floored per asset would have minted 1,000,001 shares" + ); + assert_eq!(read_fund(&ctx).total_shares, 2_000_000); +} + #[test] fn test_deposit_rejects_underallocated() { let mut ctx = setup_full(); @@ -2277,3 +2337,46 @@ fn test_wide_confidence_price_rejected() { assert_eq!(read_fund(&ctx).asset_holdings[1], 144_000_000); assert_holdings_match_vaults(&ctx); } + +/// The fund reads a Pyth update at fixed offsets that assume a fully verified +/// one. A partially verified update, signed by two of the five guardians, is +/// refused with `PriceNotFullyVerified` rather than read a byte off. Rewritten +/// as fully verified at the same price, the same deposit prices exactly as +/// `test_deposit_first` does. +#[test] +fn test_partially_verified_price_rejected() { + let mut ctx = setup_full(); + standard_fund(&mut ctx); + + let amount = 1_000_000u64; // 1 USDC + let user = fund_user(&mut ctx, amount); + write_partially_verified_price_feed(&mut ctx.svm, ctx.price_feed_nvda, NVDA_PRICE, 2); + let ix = deposit_instruction(&ctx, &user, amount, amount, deposit_remaining(&ctx)); + assert_program_error( + send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&user], &user.pubkey()), + FundError::PriceNotFullyVerified, + "a deposit priced from a partially verified update must fail", + ); + + set_price_feed(&mut ctx.svm, ctx.price_feed_nvda, NVDA_PRICE); + ctx.svm.expire_blockhash(); + let user_share = do_deposit(&mut ctx, &user, amount, amount); + assert_eq!( + get_token_account_balance(&ctx.svm, &user_share).unwrap(), + amount + ); + assert_eq!( + get_token_account_balance(&ctx.svm, &ctx.vault_usdc).unwrap(), + 0 + ); + // 0.4 USDC / 250 = 0.0016 TSLAx; 0.6 USDC / 180 = 0.00333333 NVDAx (floor). + assert_eq!( + get_token_account_balance(&ctx.svm, &ctx.vault_tsla).unwrap(), + 160_000 + ); + assert_eq!( + get_token_account_balance(&ctx.svm, &ctx.vault_nvda).unwrap(), + 333_333 + ); + assert_holdings_match_vaults(&ctx); +} diff --git a/finance/managed-fund/anchor/CHANGELOG.md b/finance/managed-fund/anchor/CHANGELOG.md index 8e79bd00f..0db6ceaf6 100644 --- a/finance/managed-fund/anchor/CHANGELOG.md +++ b/finance/managed-fund/anchor/CHANGELOG.md @@ -8,6 +8,10 @@ - **Tests mint TSLAx and NVDAx with eight decimals**, as the real tokens have, and USDC with six: `ASSET_DECIMALS` and `USDC_DECIMALS` replace `TOKEN_DECIMALS`. The program reads decimals from each mint, so every asserted basket amount is now in eight-decimal minor units and the same in major units as before (1.44 TSLAx is `144_000_000`). `test_valuation_scales_by_decimals_and_exponent` keeps running the story with an eight-decimal TSLAx on an exponent −5 feed, as the book describes, so it differs from the story in exponent only, and the new `test_valuation_scales_by_nine_decimals_and_exponent` runs it with TSLAx at nine decimals on the same feed, so the decimals vary as well; both get the story's share counts. `value_in_usdc` scales by each asset's decimals and the feed exponent. - **Every refusal test asserts its error code.** The tests that checked only `is_err()` now assert the program error (`WeightOverflow`, `TooManyAssets`, `FeeTooHigh`, `SlippageConfigTooHigh`, `FundNotFullyAllocated`, `InvalidSwapRouter`, `UsdcSlippage`, `IncompleteAssetAccounts`), the router's `SlippageExceeded` raised inside a deposit's swap CPI, or Anchor's own errors, `ConstraintAddress` for a non-manager `set_weight` and `UninitializedAccount` for an unapproved asset, through the new `assert_router_error` and `assert_anchor_error` helpers. +### Fixed + +- **A partially verified Pyth update is refused.** `load_price` read the price at fixed offsets (price at 73) that assume the one-byte encoding of `verification_level`, `Full`. A `Partial { num_signatures }` update, verified by fewer than a quorum of Pyth's guardian set, encodes it in two bytes, so every later field sits a byte further along and the price, confidence, exponent, publish time and posted slot would all be read from the wrong bytes. `load_price` now requires the tag at offset 40 to be `Full` (1) and fails with the new `PriceNotFullyVerified` error otherwise; it does not try to parse a `Partial` update. Tested by `test_partially_verified_price_rejected`. The web app's IDL gains the error. + ## 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. diff --git a/finance/managed-fund/anchor/README.md b/finance/managed-fund/anchor/README.md index db52556a3..a2b4b743f 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. 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. +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. The fields are read at fixed byte offsets that assume the update's `verification_level` is `Full`, meaning a quorum of Pyth's guardian set (three of five) verified it, so `load_price` checks that tag (offset 40) first and refuses anything else with `PriceNotFullyVerified`. A `Partial` update was verified by fewer signatures, and its `verification_level` encodes in two bytes rather than one, which would move every later field a byte along. `withdraw` reads no price, so investors can always leave in kind while deposits and rebalances wait for the band to narrow. ### Shares @@ -163,7 +163,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. TSLAx and NVDAx are minted with eight decimals, as the real tokens are, and USDC with six, so the basket amounts the tests assert are in eight-decimal minor units while shares and USDC stay in six. 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, each asserting the error code it fails with: unapproved asset, weight overflow, over-cap fee and slippage, oracle-bounded deposit slippage (the router's `SlippageExceeded`, raised inside the swap CPI), an under-allocated fund, non-manager `set_weight` (Anchor's own constraint error), 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, and `test_valuation_scales_by_nine_decimals_and_exponent` does the same with TSLAx at nine decimals. `test_collect_fees` checks a year's fee comes out exact and `test_collect_fees_rounds_up` that a day's fee rounds up to the manager. `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. +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. TSLAx and NVDAx are minted with eight decimals, as the real tokens are, and USDC with six, so the basket amounts the tests assert are in eight-decimal minor units while shares and USDC stay in six. 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, each asserting the error code it fails with: unapproved asset, weight overflow, over-cap fee and slippage, oracle-bounded deposit slippage (the router's `SlippageExceeded`, raised inside the swap CPI), an under-allocated fund, non-manager `set_weight` (Anchor's own constraint error), 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, and `test_valuation_scales_by_nine_decimals_and_exponent` does the same with TSLAx at nine decimals. `test_collect_fees` checks a year's fee comes out exact and `test_collect_fees_rounds_up` that a day's fee rounds up to the manager. `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_partially_verified_price_rejected` writes NVDAx's feed as a `Partial` update signed by two guardians and checks that a deposit fails with `PriceNotFullyVerified`, then rewrites it as `Full` at the same price and checks the deposit prices exactly as `test_deposit_first` does. `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 891535abf..677aed539 100644 --- a/finance/managed-fund/anchor/app/src/idl/managed_fund.json +++ b/finance/managed-fund/anchor/app/src/idl/managed_fund.json @@ -1052,6 +1052,11 @@ "code": 6032, "name": "OracleConfidenceTooWide", "msg": "Pyth price confidence interval is too wide to trust" + }, + { + "code": 6033, + "name": "PriceNotFullyVerified", + "msg": "Pyth price update is not fully verified by the guardian set" } ], "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 462d7f940..1451ab61e 100644 --- a/finance/managed-fund/anchor/programs/managed-fund/src/error.rs +++ b/finance/managed-fund/anchor/programs/managed-fund/src/error.rs @@ -68,4 +68,6 @@ pub enum FundError { NotUnderweight, #[msg("Pyth price confidence interval is too wide to trust")] OracleConfidenceTooWide, + #[msg("Pyth price update is not fully verified by the guardian set")] + PriceNotFullyVerified, } diff --git a/finance/managed-fund/anchor/programs/managed-fund/src/instructions/deposit.rs b/finance/managed-fund/anchor/programs/managed-fund/src/instructions/deposit.rs index dc4afb773..1d0b21bcd 100644 --- a/finance/managed-fund/anchor/programs/managed-fund/src/instructions/deposit.rs +++ b/finance/managed-fund/anchor/programs/managed-fund/src/instructions/deposit.rs @@ -8,7 +8,9 @@ use anchor_spl::{ use mock_swap_router::cpi::accounts::SwapUsdcForAssetAccountConstraints as RouterSwapAccounts; use crate::error::FundError; -use crate::oracle::{asset_value_in_usdc, load_price, read_token_amount, usdc_to_asset_amount}; +use crate::oracle::{ + asset_value_in_usdc_rounded_up, load_price, read_token_amount, usdc_to_asset_amount, +}; use crate::state::{AssetConfig, Fund}; #[derive(Accounts)] @@ -117,6 +119,8 @@ pub fn handle_deposit( // Net asset value over the complete asset set. The assets are exactly indices // 0..asset_count, so requiring five accounts per index, in order, each with a // matching index, makes it impossible to omit an asset and understate NAV. + // Each asset is valued rounding up, so NAV is never understated and the + // floored share count below rounds against the depositor, not the holders. let remaining = context.remaining_accounts()?; require!( remaining.len() == asset_count * 5, @@ -145,7 +149,7 @@ pub fn handle_deposit( let price = load_price(feed_account, &config.price_feed, now)?; let amount = asset_holdings[index]; nav = nav - .checked_add(asset_value_in_usdc( + .checked_add(asset_value_in_usdc_rounded_up( amount as u128, price, config.decimals, 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 427e9248b..db914edb2 100644 --- a/finance/managed-fund/anchor/programs/managed-fund/src/oracle.rs +++ b/finance/managed-fund/anchor/programs/managed-fund/src/oracle.rs @@ -2,6 +2,14 @@ use anchor_lang::prelude::*; use crate::error::FundError; +/// Byte offset of the `verification_level` enum tag inside a Pyth +/// PriceUpdateV2 account: 8 discriminator + 32 write_authority = 40. +const PYTH_VERIFICATION_LEVEL_OFFSET: usize = 40; +/// Borsh tag of `VerificationLevel::Full`, a price verified against a quorum +/// of Pyth's guardian set. `Partial { num_signatures }` is tag 0 followed by a +/// one-byte signature count, so it encodes in two bytes rather than one and +/// moves every later field one byte along. The offsets below assume `Full`. +const PYTH_VERIFICATION_LEVEL_FULL: u8 = 1; /// 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; @@ -47,6 +55,14 @@ 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); } + // Refuse anything but a fully verified update. A partially verified one + // was signed by fewer than a quorum of the guardian set, and its longer + // `verification_level` encoding would shift every offset below by a byte, + // so its price would be read from the wrong bytes. + require!( + account_data[PYTH_VERIFICATION_LEVEL_OFFSET] == PYTH_VERIFICATION_LEVEL_FULL, + FundError::PriceNotFullyVerified + ); let price = i64::from_le_bytes( account_data[PYTH_PRICE_OFFSET..PYTH_PRICE_OFFSET + 8] .try_into() @@ -78,7 +94,8 @@ fn read_pyth_raw(account_data: &[u8]) -> Result<(i64, u64, i32, i64, u64)> { /// 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. +/// A price posted at or before the last cluster restart is rejected too, and +/// so is an update Pyth's guardian set did not fully verify. pub fn load_price( price_feed: &AccountView, expected_key: &Address, @@ -168,23 +185,47 @@ pub fn read_token_mint_and_owner(account: &AccountView) -> Result<(Address, Addr Ok((mint, owner)) } -/// `numerator * 10^power / denominator`, floored, for a power of either sign: -/// a negative power divides by `10^-power` instead. Multiplies before dividing. -fn mul_pow10_div(numerator: u128, power: i32, denominator: u128) -> Result { +/// The fraction `numerator * 10^power / denominator` as a (numerator, +/// denominator) pair, for a power of either sign: a negative power multiplies +/// the denominator by `10^-power` instead. Multiplies before dividing. +fn mul_pow10_fraction(numerator: u128, power: i32, denominator: u128) -> Result<(u128, u128)> { let scale = 10u128 .checked_pow(power.unsigned_abs()) .ok_or(FundError::MathOverflow)?; - let (numerator, denominator) = if power >= 0 { - (numerator.checked_mul(scale), Some(denominator)) + if power >= 0 { + Ok(( + numerator + .checked_mul(scale) + .ok_or(FundError::MathOverflow)?, + denominator, + )) } else { - (Some(numerator), denominator.checked_mul(scale)) - }; + Ok(( + numerator, + denominator + .checked_mul(scale) + .ok_or(FundError::MathOverflow)?, + )) + } +} + +/// `numerator * 10^power / denominator`, floored. +fn mul_pow10_div(numerator: u128, power: i32, denominator: u128) -> Result { + let (numerator, denominator) = mul_pow10_fraction(numerator, power, denominator)?; numerator - .ok_or(FundError::MathOverflow)? - .checked_div(denominator.ok_or(FundError::MathOverflow)?) + .checked_div(denominator) .ok_or(FundError::MathOverflow.into()) } +/// `numerator * 10^power / denominator`, rounded up. +fn mul_pow10_div_ceil(numerator: u128, power: i32, denominator: u128) -> Result { + let (numerator, denominator) = mul_pow10_fraction(numerator, power, denominator)?; + if denominator == 0 { + return Err(FundError::MathOverflow.into()); + } + Ok(numerator.div_ceil(denominator)) +} + /// Value of `amount` asset minor units in USDC minor units. The asset has /// `asset_decimals`, USDC has `usdc_decimals`, and a whole asset is worth /// `price * 10^exponent` dollars, so @@ -207,6 +248,28 @@ pub fn asset_value_in_usdc( ) } +/// `asset_value_in_usdc` rounded up rather than down. Deposit prices new +/// shares against net asset value, and a NAV floored per asset is understated, +/// which would mint the depositor more shares than their USDC buys at the +/// expense of the holders already in the fund. Rounding each asset's value up +/// overstates NAV by under one minor unit per asset instead, so the share +/// count, floored again, rounds against the depositor. +pub fn asset_value_in_usdc_rounded_up( + amount: u128, + price: OraclePrice, + asset_decimals: u8, + usdc_decimals: u8, +) -> Result { + let power = usdc_decimals as i32 + price.exponent - asset_decimals as i32; + mul_pow10_div_ceil( + amount + .checked_mul(price.price) + .ok_or(FundError::MathOverflow)?, + power, + 1, + ) +} + /// The inverse of `asset_value_in_usdc`: how many asset minor units /// `usdc_amount` USDC minor units buys at the oracle price, /// usdc_amount * 10^(asset_decimals - exponent - usdc_decimals) / price. Floored. 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 5347c21d8..f14e7e63c 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 @@ -109,6 +109,34 @@ fn write_price_feed_with_confidence( .unwrap(); } +/// Write a Pyth feed as a partially verified update, laid out as Pyth's +/// receiver writes one: `verification_level` is `Partial { num_signatures }`, +/// tag 0 at offset 40 then the signature count at 41, so every later field sits +/// one byte further along than in a fully verified update. +fn write_partially_verified_price_feed( + svm: &mut LiteSVM, + key: Address, + price: i64, + num_signatures: u8, +) { + let mut data = + build_mock_price_update_account(price, DEFAULT_CONFIDENCE, PYTH_EXPONENT, PUBLISH_TIME, 1); + data[40] = 0; + data.insert(41, num_signatures); + let rent = svm.minimum_balance_for_rent_exemption(data.len()); + svm.set_account( + key, + SolanaAccount { + lamports: rent, + data, + owner: pyth_receiver_program_id(), + executable: false, + rent_epoch: 0, + }, + ) + .unwrap(); +} + 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; @@ -1107,6 +1135,38 @@ fn test_deposit_first() { ); } +/// Deposit values each asset rounding up, so the NAV it prices shares against +/// is never understated. A 1 USDC first deposit leaves the fund holding 160,000 +/// TSLAx minor units, worth exactly 400,000 USDC minor units, and 333,333 +/// NVDAx minor units, worth 599,999.4. Floored per asset the NAV would be +/// 999,999 and a second 1 USDC deposit would mint +/// floor(1,000,000 * 1,000,000 / 999,999) = 1,000,001 shares, one more than +/// its USDC buys, taken from the first holder. Rounded up the NAV is 1,000,000 +/// and the deposit mints floor(1,000,000 * 1,000,000 / 1,000,000) = 1,000,000. +#[test] +fn test_deposit_values_assets_rounding_up() { + let mut ctx = setup_full(); + standard_fund(&mut ctx); + + let amount = 1_000_000u64; // 1 USDC + let first = fund_user(&mut ctx, amount); + do_deposit(&mut ctx, &first, amount, amount); + let fund = read_fund(&ctx); + assert_eq!(fund.usdc_holdings, 0); + assert_eq!(fund.asset_holdings[0], 160_000); + assert_eq!(fund.asset_holdings[1], 333_333); + assert_eq!(fund.total_shares, 1_000_000); + + let second = fund_user(&mut ctx, amount); + let second_share = do_deposit(&mut ctx, &second, amount, 1); + assert_eq!( + get_token_account_balance(&ctx.svm, &second_share).unwrap(), + 1_000_000, + "a NAV floored per asset would have minted 1,000,001 shares" + ); + assert_eq!(read_fund(&ctx).total_shares, 2_000_000); +} + #[test] fn test_deposit_rejects_underallocated() { let mut ctx = setup_full(); @@ -2279,3 +2339,46 @@ fn test_wide_confidence_price_rejected() { assert_eq!(read_fund(&ctx).asset_holdings[1], 144_000_000); assert_holdings_match_vaults(&ctx); } + +/// The fund reads a Pyth update at fixed offsets that assume a fully verified +/// one. A partially verified update, signed by two of the five guardians, is +/// refused with `PriceNotFullyVerified` rather than read a byte off. Rewritten +/// as fully verified at the same price, the same deposit prices exactly as +/// `test_deposit_first` does. +#[test] +fn test_partially_verified_price_rejected() { + let mut ctx = setup_full(); + standard_fund(&mut ctx); + + let amount = 1_000_000u64; // 1 USDC + let user = fund_user(&mut ctx, amount); + write_partially_verified_price_feed(&mut ctx.svm, ctx.price_feed_nvda, NVDA_PRICE, 2); + let ix = deposit_instruction(&ctx, &user, amount, amount, deposit_remaining(&ctx)); + assert_program_error( + send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&user], &user.pubkey()), + FundError::PriceNotFullyVerified, + "a deposit priced from a partially verified update must fail", + ); + + set_price_feed(&mut ctx.svm, ctx.price_feed_nvda, NVDA_PRICE); + ctx.svm.expire_blockhash(); + let user_share = do_deposit(&mut ctx, &user, amount, amount); + assert_eq!( + get_token_account_balance(&ctx.svm, &user_share).unwrap(), + amount + ); + assert_eq!( + get_token_account_balance(&ctx.svm, &ctx.vault_usdc).unwrap(), + 0 + ); + // 0.4 USDC / 250 = 0.0016 TSLAx; 0.6 USDC / 180 = 0.00333333 NVDAx (floor). + assert_eq!( + get_token_account_balance(&ctx.svm, &ctx.vault_tsla).unwrap(), + 160_000 + ); + assert_eq!( + get_token_account_balance(&ctx.svm, &ctx.vault_nvda).unwrap(), + 333_333 + ); + assert_holdings_match_vaults(&ctx); +} diff --git a/finance/managed-fund/quasar/CHANGELOG.md b/finance/managed-fund/quasar/CHANGELOG.md index 246643a11..a502a8012 100644 --- a/finance/managed-fund/quasar/CHANGELOG.md +++ b/finance/managed-fund/quasar/CHANGELOG.md @@ -22,6 +22,16 @@ TSLAx at nine decimals on the same feed, so the decimals vary as well; both get the story's share counts. +### Fixed + +- A partially verified Pyth update is refused. `load_price` read the price at + fixed offsets (price at 73) that assume the one-byte encoding of + `verification_level`, `Full`. A `Partial { num_signatures }` update encodes + it in two bytes, so every later field would be read a byte off. `load_price` + now requires the tag at offset 40 to be `Full` (1) and fails with the new + `PriceNotFullyVerified` error otherwise. The test feeds now carry the `Full` + tag. Tested by `test_partially_verified_price_rejected`. + ## [2026-10-03] ### Fixed diff --git a/finance/managed-fund/quasar/README.md b/finance/managed-fund/quasar/README.md index 258aa7344..8868df538 100644 --- a/finance/managed-fund/quasar/README.md +++ b/finance/managed-fund/quasar/README.md @@ -119,6 +119,12 @@ withdraw), in index order. wider than 1% of the price (`MAX_CONFIDENCE_BPS`) is rejected too (`OracleConfidenceTooWide`). `withdraw` reads no price, so investors can always leave in kind. +- The feed's fields are read at fixed byte offsets that assume its + `verification_level` is `Full`, verified by a quorum of Pyth's guardian set + (three of five), so `load_price` checks that tag (offset 40) first and + refuses anything else (`PriceNotFullyVerified`). A `Partial` update encodes + `verification_level` in two bytes rather than one, which would move every + later field a byte along. ## What the Quasar port does differently @@ -160,7 +166,9 @@ and a USDC-for-asset swap. The fund suite (`managed-fund/src/tests.rs`) drives the manager setup (registry, approve asset, fund, 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. A second deposit test shows a price -posted before a cluster restart is rejected until Pyth posts again. The +posted before a cluster restart is rejected until Pyth posts again, and +`test_partially_verified_price_rejected` that a `Partial` Pyth update is refused +while a `Full` one at the same price deposits as before. The 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 diff --git a/finance/managed-fund/quasar/managed-fund/src/errors.rs b/finance/managed-fund/quasar/managed-fund/src/errors.rs index a24c35a29..45429de3b 100644 --- a/finance/managed-fund/quasar/managed-fund/src/errors.rs +++ b/finance/managed-fund/quasar/managed-fund/src/errors.rs @@ -42,4 +42,6 @@ pub enum FundError { NotUnderweight, /// The Pyth price's confidence interval is too wide to trust. OracleConfidenceTooWide, + /// The Pyth price update is not fully verified by the guardian set. + PriceNotFullyVerified, } diff --git a/finance/managed-fund/quasar/managed-fund/src/instructions/deposit.rs b/finance/managed-fund/quasar/managed-fund/src/instructions/deposit.rs index dbd5b145b..929fa9967 100644 --- a/finance/managed-fund/quasar/managed-fund/src/instructions/deposit.rs +++ b/finance/managed-fund/quasar/managed-fund/src/instructions/deposit.rs @@ -5,7 +5,9 @@ use quasar_lang::sysvars::Sysvar as _; use quasar_spl::prelude::*; use crate::errors::FundError; -use crate::oracle::{asset_value_in_usdc, load_price, read_token_amount, usdc_to_asset_amount}; +use crate::oracle::{ + asset_value_in_usdc_rounded_up, load_price, read_token_amount, usdc_to_asset_amount, +}; use crate::state::{ load_asset_config, read_asset_holdings, snapshot_fund, write_asset_holdings, Fund, ShareMintPda, UsdcVaultPda, FUND_SEED, @@ -118,6 +120,8 @@ pub fn handle_deposit( let mut asset_holdings = read_asset_holdings(&snapshot.asset_holdings); // Net asset value over the complete asset set. + // Each asset is valued rounding up, so NAV is never understated and the + // floored share count below rounds against the depositor, not the holders. let mut nav: u128 = usdc_holdings as u128; for (index, &amount) in asset_holdings.iter().enumerate().take(asset_count) { let config_view = get_view(&remaining, index * ACCOUNTS_PER_ASSET)?; @@ -138,7 +142,7 @@ pub fn handle_deposit( let price = load_price(&feed_view, &config.price_feed, now)?; nav = nav - .checked_add(asset_value_in_usdc( + .checked_add(asset_value_in_usdc_rounded_up( amount as u128, price, config.decimals, diff --git a/finance/managed-fund/quasar/managed-fund/src/oracle.rs b/finance/managed-fund/quasar/managed-fund/src/oracle.rs index 0d9a2b9bc..ed3d3803c 100644 --- a/finance/managed-fund/quasar/managed-fund/src/oracle.rs +++ b/finance/managed-fund/quasar/managed-fund/src/oracle.rs @@ -2,6 +2,14 @@ use quasar_lang::{prelude::*, sysvars::Sysvar}; use crate::{errors::FundError, last_restart::LastRestartSlot}; +/// Byte offset of the `verification_level` enum tag inside a Pyth +/// PriceUpdateV2 account: 8 discriminator + 32 write_authority = 40. +const PYTH_VERIFICATION_LEVEL_OFFSET: usize = 40; +/// Borsh tag of `VerificationLevel::Full`, a price verified against a quorum +/// of Pyth's guardian set. `Partial { num_signatures }` is tag 0 followed by a +/// one-byte signature count, so it encodes in two bytes rather than one and +/// moves every later field one byte along. The offsets below assume `Full`. +const PYTH_VERIFICATION_LEVEL_FULL: u8 = 1; // 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; @@ -74,7 +82,8 @@ 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. +/// A price posted at or before the last cluster restart is rejected too, and +/// so is an update Pyth's guardian set did not fully verify. pub fn load_price( price_feed: &AccountView, expected_key: &Address, @@ -88,6 +97,14 @@ pub fn load_price( if data.len() < PYTH_POSTED_SLOT_OFFSET + 8 { return Err(FundError::InvalidPriceFeed.into()); } + // Refuse anything but a fully verified update. A partially verified one + // was signed by fewer than a quorum of the guardian set, and its longer + // `verification_level` encoding would shift every offset below by a byte, + // so its price would be read from the wrong bytes. + require!( + data[PYTH_VERIFICATION_LEVEL_OFFSET] == PYTH_VERIFICATION_LEVEL_FULL, + FundError::PriceNotFullyVerified + ); let price = read_i64(data, PYTH_PRICE_OFFSET)?; let conf = read_u64(data, PYTH_CONF_OFFSET)?; let exponent = read_i32(data, PYTH_EXPONENT_OFFSET)?; @@ -165,23 +182,55 @@ pub fn read_token_mint_and_owner( Ok((Address::from(mint), Address::from(owner))) } -/// `numerator * 10^power / denominator`, floored, for a power of either sign: -/// a negative power divides by `10^-power` instead. Multiplies before dividing. -fn mul_pow10_div(numerator: u128, power: i32, denominator: u128) -> Result { +/// The fraction `numerator * 10^power / denominator` as a (numerator, +/// denominator) pair, for a power of either sign: a negative power multiplies +/// the denominator by `10^-power` instead. Multiplies before dividing. +fn mul_pow10_fraction( + numerator: u128, + power: i32, + denominator: u128, +) -> Result<(u128, u128), ProgramError> { let scale = 10u128 .checked_pow(power.unsigned_abs()) .ok_or(FundError::MathOverflow)?; - let (numerator, denominator) = if power >= 0 { - (numerator.checked_mul(scale), Some(denominator)) + if power >= 0 { + Ok(( + numerator + .checked_mul(scale) + .ok_or(FundError::MathOverflow)?, + denominator, + )) } else { - (Some(numerator), denominator.checked_mul(scale)) - }; + Ok(( + numerator, + denominator + .checked_mul(scale) + .ok_or(FundError::MathOverflow)?, + )) + } +} + +/// `numerator * 10^power / denominator`, floored. +fn mul_pow10_div(numerator: u128, power: i32, denominator: u128) -> Result { + let (numerator, denominator) = mul_pow10_fraction(numerator, power, denominator)?; numerator - .ok_or(FundError::MathOverflow)? - .checked_div(denominator.ok_or(FundError::MathOverflow)?) + .checked_div(denominator) .ok_or_else(|| FundError::MathOverflow.into()) } +/// `numerator * 10^power / denominator`, rounded up. +fn mul_pow10_div_ceil( + numerator: u128, + power: i32, + denominator: u128, +) -> Result { + let (numerator, denominator) = mul_pow10_fraction(numerator, power, denominator)?; + if denominator == 0 { + return Err(FundError::MathOverflow.into()); + } + Ok(numerator.div_ceil(denominator)) +} + /// Value of `amount` asset minor units in USDC minor units. The asset has /// `asset_decimals`, USDC has `usdc_decimals`, and a whole asset is worth /// `price * 10^exponent` dollars, so @@ -204,6 +253,28 @@ pub fn asset_value_in_usdc( ) } +/// `asset_value_in_usdc` rounded up rather than down. Deposit prices new +/// shares against net asset value, and a NAV floored per asset is understated, +/// which would mint the depositor more shares than their USDC buys at the +/// expense of the holders already in the fund. Rounding each asset's value up +/// overstates NAV by under one minor unit per asset instead, so the share +/// count, floored again, rounds against the depositor. +pub fn asset_value_in_usdc_rounded_up( + amount: u128, + price: OraclePrice, + asset_decimals: u8, + usdc_decimals: u8, +) -> Result { + let power = usdc_decimals as i32 + price.exponent - asset_decimals as i32; + mul_pow10_div_ceil( + amount + .checked_mul(price.price) + .ok_or(FundError::MathOverflow)?, + power, + 1, + ) +} + /// The inverse of `asset_value_in_usdc`: how many asset minor units /// `usdc_amount` USDC minor units buys at the oracle price, /// usdc_amount * 10^(asset_decimals - exponent - usdc_decimals) / price. Floored. diff --git a/finance/managed-fund/quasar/managed-fund/src/tests.rs b/finance/managed-fund/quasar/managed-fund/src/tests.rs index 29db6ea11..174ed5cac 100644 --- a/finance/managed-fund/quasar/managed-fund/src/tests.rs +++ b/finance/managed-fund/quasar/managed-fund/src/tests.rs @@ -4,7 +4,9 @@ //! 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. `deposit_rejects_price_from_before_a_restart` -//! reuses that setup to show a pre-restart price is refused. +//! reuses that setup to show a pre-restart price is refused, and +//! `test_partially_verified_price_rejected` to show a partially verified Pyth +//! update is refused while a fully verified one prices as before. //! `donation_does_not_inflate_share_price` shows a donation straight into the //! USDC vault leaves the share price alone, and //! `deposit_rejects_leg_that_buys_nothing` shows a deposit too small to buy @@ -97,10 +99,10 @@ 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, `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 +// A Pyth PriceUpdateV2-shaped account: the `verification_level` tag at offset +// 40 (1, `Full`), `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 six 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); @@ -134,6 +136,7 @@ fn write_price_feed_with_confidence( posted_slot: u64, ) { let mut data = vec![0u8; 200]; + data[40] = 1; // VerificationLevel::Full 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()); @@ -142,6 +145,27 @@ fn write_price_feed_with_confidence( test.set_account(Account::new(feed, FEED_OWNER, 1_000_000, data)); } +/// Write the single-asset fund's feed as a partially verified update, laid out +/// as Pyth's receiver writes one: `verification_level` is +/// `Partial { num_signatures }`, tag 0 at offset 40 then the signature count at +/// 41, so every later field sits one byte further along than in a fully +/// verified update. +fn add_partially_verified_pyth_feed( + test: &mut Test, + price: i64, + publish_time: i64, + num_signatures: u8, +) { + let mut data = vec![0u8; 201]; + data[40] = 0; // VerificationLevel::Partial + data[41] = num_signatures; + data[74..82].copy_from_slice(&price.to_le_bytes()); + data[90..94].copy_from_slice(&(-8i32).to_le_bytes()); + data[94..102].copy_from_slice(&publish_time.to_le_bytes()); + data[126..134].copy_from_slice(&1u64.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. @@ -402,6 +426,30 @@ fn deposit_rejects_price_from_before_a_restart(test: &mut Test) { .has_tokens(DEPOSITOR_SHARE, DEPOSIT); } +/// The fund reads a Pyth update at fixed offsets that assume a fully verified +/// one. A partially verified update, signed by two of the five guardians, is +/// refused with `PriceNotFullyVerified` rather than read a byte off. Rewritten +/// as fully verified at the same price, the same deposit prices exactly as +/// `deposit_mints_shares_and_deploys_into_the_basket` does. +#[quasar_test] +fn test_partially_verified_price_rejected(test: &mut Test) { + let w = setup_deposit(test); + + add_partially_verified_pyth_feed(test, PYTH_PRICE, NOW, 2); + send_deposit(test, &w).fails_with(FundError::PriceNotFullyVerified); + + const ASSET_OUT: u64 = DEPOSIT * ONE_TOKEN / RATE; // 4 + + add_pyth_feed(test, PYTH_PRICE, NOW); + send_deposit(test, &w) + .succeeds() + .has_tokens(DEPOSITOR_SHARE, DEPOSIT) + .has_tokens(w.vault_asset, ASSET_OUT) + .has_tokens(DEPOSITOR_USDC, 0) + .has_tokens(router_treasury_pda(), DEPOSIT) + .has_tokens(w.vault_usdc, 0); +} + /// A depositor's wallet plus their USDC, share, and asset token accounts (the /// share account must exist before a deposit; the asset account before an /// in-kind withdrawal). @@ -932,6 +980,39 @@ fn donate_token(test: &mut Test, owner: Pubkey, from: Pubkey, vault: Pubkey, amo .succeeds(); } +/// Deposit values each asset rounding up, so the NAV it prices shares against +/// is never understated. A 1 USDC first deposit leaves the fund holding 160,000 +/// TSLAx minor units, worth exactly 400,000 USDC minor units, and 333,333 +/// NVDAx minor units, worth 599,999.4. Floored per asset the NAV would be +/// 999,999 and a second 1 USDC deposit would mint +/// floor(1,000,000 * 1,000,000 / 999,999) = 1,000,001 shares, one more than +/// its USDC buys, taken from the first holder. Rounded up the NAV is 1,000,000 +/// and the deposit mints floor(1,000,000 * 1,000,000 / 1,000,000) = 1,000,000. +#[quasar_test] +fn test_deposit_values_assets_rounding_up(test: &mut Test) { + setup_full(test); + standard_fund(test); + + let amount = 1_000_000u64; // 1 USDC + let first = fund_user(test, amount); + do_deposit(test, &first, amount); + let (usdc_holdings, asset_holdings) = read_holdings(test); + assert_eq!(usdc_holdings, 0); + assert_eq!(asset_holdings[0], 160_000); + assert_eq!(asset_holdings[1], 333_333); + assert_eq!(test.tokens(first.share), 1_000_000); + + let second = fund_user(test, amount); + do_deposit(test, &second, amount); + assert_eq!( + test.tokens(second.share), + 1_000_000, + "a NAV floored per asset would have minted 1,000,001 shares" + ); + let fund = test.read::(fund_pda(test)); + assert_eq!(u64::from(fund.total_shares), 2_000_000); +} + #[quasar_test] fn test_rebalance(test: &mut Test) { setup_full(test); diff --git a/finance/options/anchor-v1/CHANGELOG.md b/finance/options/anchor-v1/CHANGELOG.md index 00f5b52d9..1fcc0edbf 100644 --- a/finance/options/anchor-v1/CHANGELOG.md +++ b/finance/options/anchor-v1/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## Unreleased, 2026-10-07 + +`buy_option` takes an `OptionTerms` argument, the terms the buyer read from +the option, and refuses the purchase with the new error `OptionTermsChanged` +unless the option still has exactly those terms (kind, `underlying_amount`, +`strike_amount`, `premium` and `expiry`). Without it a writer could cancel an +option and write a new one at the same address (the same `id`) at a higher +premium, on fewer shares or with a sooner expiry while a purchase was on its +way, the switched-offer attack the escrow's `take_offer` already refuses. New +tests `test_buy_option_refuses_a_switched_option` (four switches, each +refused with nothing moved) and `test_buy_option_succeeds_when_the_terms_match`. +The suite's `buy_option` helper passes the option's current terms, and +`buy_option_with_terms` passes the terms a buyer saw earlier. + +`write_option`, `cancel_option` and `reclaim_collateral` create the writer's +underlying associated token account if it does not exist, at the writer's +expense, as `collect_proceeds` already did; `cancel_option` and +`reclaim_collateral` take the associated token and system programs for it. +A put writer who has never held the underlying could not write, cancel or +reclaim before. For an account that exists, the mint and authority +constraints are unchanged. New tests +`test_put_writer_without_an_underlying_account_writes_and_reclaims` and +`test_put_writer_without_an_underlying_account_writes_and_cancels` start +Carol with no NVDAx account (the new `person_without_underlying_account` +helper) and pin her rent and token balances to the minor unit. + +The `Market` doc comment names the `*_owed` fields instead of the old +`*_locked` names. + ## Unreleased, 2026-10-05 The venue's fee rounds up: `split_premium` takes the ceiling of diff --git a/finance/options/anchor-v1/README.md b/finance/options/anchor-v1/README.md index bc1d4bd7b..f8349dee4 100644 --- a/finance/options/anchor-v1/README.md +++ b/finance/options/anchor-v1/README.md @@ -122,7 +122,15 @@ any time until someone does. ### Step 3: Bob buys the option -`buy_option` takes 25 USDC from Bob: 0.25 USDC (the 1% fee) into the quote +Bob calls `buy_option` with the five terms he read from the option (`kind`, +`underlying_amount`, `strike_amount`, `premium` and `expiry`, as an +`OptionTerms`), and the purchase is refused with `OptionTermsChanged` unless +the option still has exactly those terms. Without that check Alice could +cancel and write a new option at the same address (the same `id`) at a +higher premium, on fewer shares, or with a sooner expiry while Bob's +transaction is on its way, and Bob would pay for an option he never saw. It +is the same switched-offer defense the escrow's `take_offer` has. The +purchase itself takes 25 USDC from Bob: 0.25 USDC (the 1% fee) into the quote vault, owed to Maria, and 24.75 USDC straight to Alice, as two transfers (a venue with a zero fee makes only the first). 25 USDC is a multiple of the rate, so nothing rounds; a premium of 10.000001 USDC would owe a fee of @@ -151,8 +159,12 @@ already had, and gave up everything above $180. Carol's `write_option(id = 2, kind = Put, underlying_amount = 5 NVDAx, strike_amount = 750 USDC, premium = 20 USDC)`, a strike of 150 USDC a share, -moves 750 USDC into the quote vault. Dave's `buy_option` pays 19.80 USDC to Carol -and 0.20 USDC to the vault for Maria. +moves 750 USDC into the quote vault. Carol has never held NVDAx, so she has no +NVDAx account; `write_option` creates it at her expense, the account +`collect_proceeds` would pay her shares into had Dave exercised. +`cancel_option` and `reclaim_collateral` create it too if it is missing. +Dave's `buy_option` pays 19.80 USDC to Carol and 0.20 USDC to the vault for +Maria. ### Step 7: The week passes above $150, and Carol reclaims her collateral @@ -228,12 +240,19 @@ the put from write to exercise and from purchase to expiry and reclaim (`test_reclaim_collateral_after_expiry_returns_the_strike_to_the_put_writer`), pins every balance to the minor unit, checks that the fee on a premium that is not a multiple of the rate rounds up and the writer receives the rest -(`test_fee_rounds_up_and_the_writer_takes_the_remainder`), counts the token +(`test_fee_rounds_up_and_the_writer_takes_the_remainder`), follows a put +writer with no NVDAx account from write to reclaim and from write to cancel +(`test_put_writer_without_an_underlying_account_writes_and_reclaims`, +`test_put_writer_without_an_underlying_account_writes_and_cancels`), counts the token transfers a purchase makes (two, or one on a zero-fee venue), checks the custody ledger against the vault balances after every lifecycle step, and checks that every refusal holds and fails with the expected error code: the expiry boundary from both sides, -cancel after sale, buy after sale or expiry, a writer buying their own option, +cancel after sale, buy after sale or expiry, a buy whose terms the writer +switched by cancelling and rewriting the option +(`test_buy_option_refuses_a_switched_option`, with +`test_buy_option_succeeds_when_the_terms_match` for the matching buy), a +writer buying their own option, exercise by a non-holder, collection by a non-writer or before exercise, reclaim after exercise, fee collection by a non-admin, a second sweep with nothing owed, and the parameter checks at market and write time. diff --git a/finance/options/anchor-v1/programs/options/src/errors.rs b/finance/options/anchor-v1/programs/options/src/errors.rs index 2f2b46557..785cefc8b 100644 --- a/finance/options/anchor-v1/programs/options/src/errors.rs +++ b/finance/options/anchor-v1/programs/options/src/errors.rs @@ -31,4 +31,7 @@ pub enum OptionsError { #[msg("Vault balance would fall below what the market owes")] CustodyInvariantViolated, + + #[msg("Option terms differ from the terms the buyer agreed to")] + OptionTermsChanged, } diff --git a/finance/options/anchor-v1/programs/options/src/instructions/buy_option.rs b/finance/options/anchor-v1/programs/options/src/instructions/buy_option.rs index d3a942a80..a411f6ecf 100644 --- a/finance/options/anchor-v1/programs/options/src/instructions/buy_option.rs +++ b/finance/options/anchor-v1/programs/options/src/instructions/buy_option.rs @@ -5,18 +5,38 @@ use crate::constants::{MARKET_SEED, OPTION_SEED, QUOTE_VAULT_SEED}; use crate::contract_math; use crate::errors::OptionsError; use crate::instructions::shared::{check_custody, transfer_from_signer}; +use crate::instructions::write_option::OptionTerms; use crate::state::{Market, OptionContract, OptionStatus}; /// Buy a listed option. The premium is the only money that changes hands: the /// venue's fee comes out of it into the quote vault, and the rest goes /// straight to the writer, whose money it is from this moment whatever the /// holder later does. The collateral does not move. -pub fn handle_buy_option(context: Context) -> Result<()> { +/// +/// `terms` are the terms the buyer read from the option before signing. A +/// writer can cancel an option and write a new one at the same address (the +/// same `id`) with a higher premium, a smaller `underlying_amount`, or a +/// sooner expiry while the buyer's transaction is on its way, so the purchase +/// is refused with `OptionTermsChanged` unless every term still matches. +pub fn handle_buy_option( + context: Context, + terms: OptionTerms, +) -> Result<()> { let option = &mut context.accounts.option; require!( option.status == OptionStatus::Listed, OptionsError::OptionNotListed ); + // The switched-option check, before anything moves: the buyer pays only + // for the option they saw. + require!( + option.kind == terms.kind + && option.underlying_amount == terms.underlying_amount + && option.strike_amount == terms.strike_amount + && option.premium == terms.premium + && option.expiry == terms.expiry, + OptionsError::OptionTermsChanged + ); // An option nobody can exercise any more is not for sale. let now = Clock::get()?.unix_timestamp; require!( diff --git a/finance/options/anchor-v1/programs/options/src/instructions/cancel_option.rs b/finance/options/anchor-v1/programs/options/src/instructions/cancel_option.rs index de9dd771f..9bc65346b 100644 --- a/finance/options/anchor-v1/programs/options/src/instructions/cancel_option.rs +++ b/finance/options/anchor-v1/programs/options/src/instructions/cancel_option.rs @@ -1,5 +1,8 @@ use anchor_lang::prelude::*; -use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{Mint, TokenAccount, TokenInterface}, +}; use crate::constants::{MARKET_SEED, OPTION_SEED, QUOTE_VAULT_SEED, UNDERLYING_VAULT_SEED}; use crate::contract_math; @@ -113,8 +116,13 @@ pub struct CancelOptionAccountConstraints<'info> { )] pub quote_vault: Box>, + // A call writer's collateral comes back here. A put writer may never have + // held the underlying (or may have closed the account since writing), so + // it is created if needed, at the writer's expense, as `collect_proceeds` + // does. #[account( - mut, + init_if_needed, + payer = writer, associated_token::mint = underlying_mint, associated_token::authority = writer, associated_token::token_program = token_program, @@ -130,4 +138,6 @@ pub struct CancelOptionAccountConstraints<'info> { pub writer_quote: Box>, pub token_program: Interface<'info, TokenInterface>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, } diff --git a/finance/options/anchor-v1/programs/options/src/instructions/reclaim_collateral.rs b/finance/options/anchor-v1/programs/options/src/instructions/reclaim_collateral.rs index 28b72e821..6c387a5d0 100644 --- a/finance/options/anchor-v1/programs/options/src/instructions/reclaim_collateral.rs +++ b/finance/options/anchor-v1/programs/options/src/instructions/reclaim_collateral.rs @@ -1,5 +1,8 @@ use anchor_lang::prelude::*; -use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{Mint, TokenAccount, TokenInterface}, +}; use crate::constants::{MARKET_SEED, OPTION_SEED, QUOTE_VAULT_SEED, UNDERLYING_VAULT_SEED}; use crate::contract_math; @@ -119,8 +122,13 @@ pub struct ReclaimCollateralAccountConstraints<'info> { )] pub quote_vault: Box>, + // A call writer's collateral comes back here. A put writer may never have + // held the underlying (or may have closed the account since writing), so + // it is created if needed, at the writer's expense, as `collect_proceeds` + // does. #[account( - mut, + init_if_needed, + payer = writer, associated_token::mint = underlying_mint, associated_token::authority = writer, associated_token::token_program = token_program, @@ -136,4 +144,6 @@ pub struct ReclaimCollateralAccountConstraints<'info> { pub writer_quote: Box>, pub token_program: Interface<'info, TokenInterface>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, } diff --git a/finance/options/anchor-v1/programs/options/src/instructions/write_option.rs b/finance/options/anchor-v1/programs/options/src/instructions/write_option.rs index f650c33bb..97679b688 100644 --- a/finance/options/anchor-v1/programs/options/src/instructions/write_option.rs +++ b/finance/options/anchor-v1/programs/options/src/instructions/write_option.rs @@ -169,10 +169,14 @@ pub struct WriteOptionAccountConstraints<'info> { )] pub quote_vault: Box>, - // A call writer pays collateral from this account; a put writer's copy - // is only validated. + // A call writer pays collateral from this account. A put writer may never + // have held the underlying, so the account is created if needed, at the + // writer's expense; it is where `collect_proceeds` pays a put writer, and + // `cancel_option` and `reclaim_collateral` take it too. For an account + // that exists, the mint and authority constraints apply as before. #[account( - mut, + init_if_needed, + payer = writer, associated_token::mint = underlying_mint, associated_token::authority = writer, associated_token::token_program = token_program, diff --git a/finance/options/anchor-v1/programs/options/src/lib.rs b/finance/options/anchor-v1/programs/options/src/lib.rs index eef41b492..96a52ebd0 100644 --- a/finance/options/anchor-v1/programs/options/src/lib.rs +++ b/finance/options/anchor-v1/programs/options/src/lib.rs @@ -50,9 +50,14 @@ pub mod options { } /// Buy a listed option: pay the premium (the venue's fee comes out of it, - /// the rest goes to the writer) and become the holder. - pub fn buy_option(context: Context) -> Result<()> { - instructions::handle_buy_option(context) + /// the rest goes to the writer) and become the holder. `terms` are the + /// terms the buyer saw when they built the transaction; the purchase is + /// refused unless the option still has exactly those terms. + pub fn buy_option( + context: Context, + terms: OptionTerms, + ) -> Result<()> { + instructions::handle_buy_option(context, terms) } /// Writer withdraws an unsold option: collateral back, account closed. diff --git a/finance/options/anchor-v1/programs/options/src/state/market.rs b/finance/options/anchor-v1/programs/options/src/state/market.rs index 8e5e6023c..fc41661e0 100644 --- a/finance/options/anchor-v1/programs/options/src/state/market.rs +++ b/finance/options/anchor-v1/programs/options/src/state/market.rs @@ -5,7 +5,7 @@ use anchor_lang::prelude::*; /// two vaults. /// /// The vaults hold other people's money (writers' collateral, and the strike -/// payments holders make at exercise), so the two `*_locked` fields say how +/// payments holders make at exercise), so the two `*_owed` fields say how /// much of each vault the market owes and to whom it is owed in aggregate. /// Every handler that moves tokens asserts, after its own arithmetic, that /// each vault still covers what the market owes. diff --git a/finance/options/anchor-v1/programs/options/tests/test_options.rs b/finance/options/anchor-v1/programs/options/tests/test_options.rs index 09aaed787..6a1027065 100644 --- a/finance/options/anchor-v1/programs/options/tests/test_options.rs +++ b/finance/options/anchor-v1/programs/options/tests/test_options.rs @@ -51,6 +51,10 @@ const ONE_WEEK: i64 = 7 * SECONDS_PER_DAY; const STANDARD_USDC: u64 = 1_000 * ONE_USDC; const FIVE_NVDAX: u64 = 5 * ONE_NVDAX; +// LiteSVM charges the default 5,000 lamports for each transaction's one +// signature. +const TRANSACTION_FEE: u64 = 5_000; + fn token_program_id() -> Pubkey { "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" .parse() @@ -240,6 +244,13 @@ impl Venue { .unwrap_or(false) } + fn lamports(&self, address: &Pubkey) -> u64 { + self.svm + .get_account(address) + .map(|account| account.lamports) + .unwrap_or(0) + } + fn now(&self) -> i64 { self.svm.get_sysvar::().unix_timestamp } @@ -257,14 +268,34 @@ impl Venue { /// associated token accounts. Every character gets one SOL's worth of /// lamports and change for rent and fees. fn person(&mut self, underlying: u64, quote: u64) -> Person { + self.person_with_accounts(underlying, quote, true) + } + + /// A character with a quote account only, as a put writer who has never + /// held the underlying would be. `underlying` is the address their + /// underlying associated token account would have; nothing exists there. + fn person_without_underlying_account(&mut self, quote: u64) -> Person { + self.person_with_accounts(0, quote, false) + } + + fn person_with_accounts( + &mut self, + underlying: u64, + quote: u64, + with_underlying_account: bool, + ) -> Person { let keypair = create_wallet(&mut self.svm, 10_000_000_000).unwrap(); - let underlying_account = create_associated_token_account( - &mut self.svm, - &keypair.pubkey(), - &self.underlying_mint, - &self.payer, - ) - .unwrap(); + let underlying_account = if with_underlying_account { + create_associated_token_account( + &mut self.svm, + &keypair.pubkey(), + &self.underlying_mint, + &self.payer, + ) + .unwrap() + } else { + derive_ata(&keypair.pubkey(), &self.underlying_mint) + }; let quote_account = create_associated_token_account( &mut self.svm, &keypair.pubkey(), @@ -380,15 +411,42 @@ impl Venue { .expect("writing the put should succeed") } + /// The terms a buyer reads from an option's account before signing a + /// purchase. + fn listed_terms(&self, option: &Pubkey) -> OptionTerms { + let state = self.option_state(option); + OptionTerms { + kind: state.kind, + underlying_amount: state.underlying_amount, + strike_amount: state.strike_amount, + premium: state.premium, + expiry: state.expiry, + } + } + + /// Buy `option` at the terms it is listed with now. fn buy_option( &mut self, buyer: &Person, writer: &Pubkey, option: &Pubkey, + ) -> Result { + let terms = self.listed_terms(option); + self.buy_option_with_terms(buyer, writer, option, terms) + } + + /// Buy `option`, agreeing to `terms`: the terms the buyer saw, which the + /// option may no longer have by the time the purchase lands. + fn buy_option_with_terms( + &mut self, + buyer: &Person, + writer: &Pubkey, + option: &Pubkey, + terms: OptionTerms, ) -> Result { let instruction = Instruction::new_with_bytes( options::id(), - &options::instruction::BuyOption {}.data(), + &options::instruction::BuyOption { terms }.data(), options::accounts::BuyOptionAccountConstraints { buyer: buyer.pubkey(), writer: *writer, @@ -424,6 +482,8 @@ impl Venue { writer_underlying: writer.underlying, writer_quote: writer.quote, token_program: token_program_id(), + associated_token_program: ata_program_id(), + system_program: system_program::id(), } .to_account_metas(None), ); @@ -505,6 +565,8 @@ impl Venue { writer_underlying: writer.underlying, writer_quote: writer.quote, token_program: token_program_id(), + associated_token_program: ata_program_id(), + system_program: system_program::id(), } .to_account_metas(None), ); @@ -857,6 +919,91 @@ fn test_reclaim_collateral_after_expiry_returns_the_strike_to_the_put_writer() { venue.assert_vaults_match_ledger(); } +/// Carol has never held NVDAx, so she has no NVDAx account. Writing the put +/// creates it at her expense: her lamports pay the option's rent, the new +/// account's rent and the fee, and nothing else. Dave buys, never exercises, +/// and at expiry Carol reclaims her 750 USDC; the option's rent comes back to +/// her to the lamport. +#[test] +fn test_put_writer_without_an_underlying_account_writes_and_reclaims() { + let mut venue = Venue::new(); + let carol = venue.person_without_underlying_account(STANDARD_USDC); + let dave = venue.person(FIVE_NVDAX, STANDARD_USDC); + assert!(venue.svm.get_account(&carol.underlying).is_none()); + let carol_lamports_before_write = venue.lamports(&carol.pubkey()); + + let option = venue.write_put(&carol); + + let underlying_account_rent = venue.lamports(&carol.underlying); + let option_rent = venue.lamports(&option); + assert!(underlying_account_rent > 0); + assert_eq!(venue.balance(&carol.underlying), 0); + assert_eq!( + venue.lamports(&carol.pubkey()), + carol_lamports_before_write - option_rent - underlying_account_rent - TRANSACTION_FEE + ); + let collateral = PUT_STRIKE_AMOUNT; + assert_eq!(venue.balance(&carol.quote), STANDARD_USDC - collateral); + assert_eq!(venue.balance(&venue.quote_vault), collateral); + venue.assert_vaults_match_ledger(); + + venue.buy_option(&dave, &carol.pubkey(), &option).unwrap(); + let fee = 200_000; // 1% of 20 USDC + let expiry = venue.option_state(&option).expiry; + venue.warp_to(expiry); + let carol_lamports_before_reclaim = venue.lamports(&carol.pubkey()); + + venue.reclaim_collateral(&carol, &option).unwrap(); + + assert_eq!( + venue.balance(&carol.quote), + STANDARD_USDC + PUT_PREMIUM - fee + ); + assert_eq!(venue.balance(&carol.underlying), 0); + assert_eq!(venue.balance(&dave.quote), STANDARD_USDC - PUT_PREMIUM); + assert_eq!(venue.balance(&dave.underlying), FIVE_NVDAX); + assert_eq!(venue.balance(&venue.quote_vault), fee); + assert!(!venue.option_exists(&option)); + assert_eq!( + venue.lamports(&carol.pubkey()), + carol_lamports_before_reclaim + option_rent - TRANSACTION_FEE + ); + let market = venue.market_state(); + assert_eq!(market.quote_owed, 0); + assert_eq!(market.underlying_owed, 0); + assert_eq!(market.fees_owed, fee); + venue.assert_vaults_match_ledger(); +} + +/// Carol, with no NVDAx account, writes a put nobody buys and withdraws it. +/// Her 750 USDC comes back, the option closes with its rent back to her to +/// the lamport, and the NVDAx account the write created for her stays hers. +#[test] +fn test_put_writer_without_an_underlying_account_writes_and_cancels() { + let mut venue = Venue::new(); + let carol = venue.person_without_underlying_account(STANDARD_USDC); + assert!(venue.svm.get_account(&carol.underlying).is_none()); + + let option = venue.write_put(&carol); + assert_eq!(venue.balance(&carol.underlying), 0); + assert_eq!(venue.balance(&carol.quote), STANDARD_USDC - PUT_STRIKE_AMOUNT); + let option_rent = venue.lamports(&option); + let carol_lamports_before_cancel = venue.lamports(&carol.pubkey()); + + venue.cancel_option(&carol, &option).unwrap(); + + assert_eq!(venue.balance(&carol.quote), STANDARD_USDC); + assert_eq!(venue.balance(&carol.underlying), 0); + assert_eq!(venue.balance(&venue.quote_vault), 0); + assert!(!venue.option_exists(&option)); + assert_eq!( + venue.lamports(&carol.pubkey()), + carol_lamports_before_cancel + option_rent - TRANSACTION_FEE + ); + assert_eq!(venue.market_state().quote_owed, 0); + venue.assert_vaults_match_ledger(); +} + // =========================================================================== // The expiry boundary, from both sides // =========================================================================== @@ -998,6 +1145,93 @@ fn test_buy_is_refused_once_sold() { assert_eq!(venue.option_state(&option).holder, bob.pubkey()); } +/// The switched-option attack: Bob reads Alice's call and signs a purchase at +/// those terms. Before it lands, Alice cancels and writes a new option at the +/// same address (the same `id`) on worse terms: a higher premium, fewer +/// shares, a higher strike, or a sooner expiry. Each time, Bob's purchase is +/// refused with `OptionTermsChanged`, and no USDC moves. +#[test] +fn test_buy_option_refuses_a_switched_option() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let bob = venue.person(0, STANDARD_USDC); + let option = venue.write_call(&alice); + let seen = venue.listed_terms(&option); + + let switches = [ + OptionTerms { + premium: 2 * CALL_PREMIUM, + ..seen + }, + OptionTerms { + underlying_amount: ONE_NVDAX, + ..seen + }, + OptionTerms { + strike_amount: CALL_STRIKE_AMOUNT + 100 * ONE_USDC, + ..seen + }, + OptionTerms { + expiry: seen.expiry - SECONDS_PER_DAY, + ..seen + }, + ]; + for switched in switches { + // A fresh blockhash, or the identical cancel and purchase would be + // dropped as duplicates of the previous round's. + venue.svm.expire_blockhash(); + venue.cancel_option(&alice, &option).unwrap(); + let rewritten = venue.write_option(&alice, 1, switched).unwrap(); + assert_eq!(rewritten, option); + + assert_fails_with( + venue.buy_option_with_terms(&bob, &alice.pubkey(), &option, seen), + OptionsError::OptionTermsChanged, + ); + assert_eq!(venue.balance(&bob.quote), STANDARD_USDC); + assert_eq!(venue.balance(&alice.quote), STANDARD_USDC); + assert_eq!(venue.balance(&venue.quote_vault), 0); + let state = venue.option_state(&option); + assert_eq!(state.status, OptionStatus::Listed); + assert_eq!(state.holder, Pubkey::default()); + assert_eq!(venue.market_state().fees_owed, 0); + venue.assert_vaults_match_ledger(); + } +} + +/// A purchase whose terms match the option's goes through: Bob, reading the +/// rewritten option at a 50 USDC premium, buys it at that premium, paying +/// 0.50 USDC to the venue and 49.50 USDC to Alice. +#[test] +fn test_buy_option_succeeds_when_the_terms_match() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let bob = venue.person(0, STANDARD_USDC); + let option = venue.write_call(&alice); + let first = venue.listed_terms(&option); + venue.cancel_option(&alice, &option).unwrap(); + let premium = 2 * CALL_PREMIUM; + venue + .write_option(&alice, 1, OptionTerms { premium, ..first }) + .unwrap(); + let seen = venue.listed_terms(&option); + assert_eq!(seen.premium, premium); + + venue + .buy_option_with_terms(&bob, &alice.pubkey(), &option, seen) + .unwrap(); + + let fee = 500_000; // 0.50 USDC + assert_eq!(venue.balance(&bob.quote), STANDARD_USDC - premium); + assert_eq!(venue.balance(&alice.quote), STANDARD_USDC + premium - fee); + assert_eq!(venue.balance(&venue.quote_vault), fee); + let state = venue.option_state(&option); + assert_eq!(state.holder, bob.pubkey()); + assert_eq!(state.status, OptionStatus::Held); + assert_eq!(venue.market_state().fees_owed, fee); + venue.assert_vaults_match_ledger(); +} + /// A writer cannot buy their own option. `buyer_quote` and `writer_quote` /// are each bound to their party's associated token account, so with Alice /// on both sides they are one account in two mutable slots, and Anchor's diff --git a/finance/options/anchor/CHANGELOG.md b/finance/options/anchor/CHANGELOG.md index 463f0cc50..ac2b790a2 100644 --- a/finance/options/anchor/CHANGELOG.md +++ b/finance/options/anchor/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## Unreleased, 2026-10-07 + +`buy_option` takes an `OptionTerms` argument, the terms the buyer read from +the option, and refuses the purchase with the new error `OptionTermsChanged` +unless the option still has exactly those terms (kind, `underlying_amount`, +`strike_amount`, `premium` and `expiry`). Without it a writer could cancel an +option and write a new one at the same address (the same `id`) at a higher +premium, on fewer shares or with a sooner expiry while a purchase was on its +way, the switched-offer attack the escrow's `take_offer` already refuses. New +tests `test_buy_option_refuses_a_switched_option` (four switches, each +refused with nothing moved) and `test_buy_option_succeeds_when_the_terms_match`. +The suite's `buy_option` helper passes the option's current terms, and +`buy_option_with_terms` passes the terms a buyer saw earlier. + +`write_option`, `cancel_option` and `reclaim_collateral` create the writer's +underlying associated token account if it does not exist, at the writer's +expense, as `collect_proceeds` already did; `cancel_option` and +`reclaim_collateral` take the associated token and system programs for it. +A put writer who has never held the underlying could not write, cancel or +reclaim before. For an account that exists, the mint and authority +constraints are unchanged. New tests +`test_put_writer_without_an_underlying_account_writes_and_reclaims` and +`test_put_writer_without_an_underlying_account_writes_and_cancels` start +Carol with no NVDAx account (the new `person_without_underlying_account` +helper) and pin her rent and token balances to the minor unit. + +The `Market` doc comment names the `*_owed` fields instead of the old +`*_locked` names. + ## Unreleased, 2026-10-05 The venue's fee rounds up: `split_premium` takes the ceiling of diff --git a/finance/options/anchor/README.md b/finance/options/anchor/README.md index 4edbe1b7b..aff24d745 100644 --- a/finance/options/anchor/README.md +++ b/finance/options/anchor/README.md @@ -122,7 +122,15 @@ any time until someone does. ### Step 3: Bob buys the option -`buy_option` takes 25 USDC from Bob: 0.25 USDC (the 1% fee) into the quote +Bob calls `buy_option` with the five terms he read from the option (`kind`, +`underlying_amount`, `strike_amount`, `premium` and `expiry`, as an +`OptionTerms`), and the purchase is refused with `OptionTermsChanged` unless +the option still has exactly those terms. Without that check Alice could +cancel and write a new option at the same address (the same `id`) at a +higher premium, on fewer shares, or with a sooner expiry while Bob's +transaction is on its way, and Bob would pay for an option he never saw. It +is the same switched-offer defense the escrow's `take_offer` has. The +purchase itself takes 25 USDC from Bob: 0.25 USDC (the 1% fee) into the quote vault, owed to Maria, and 24.75 USDC straight to Alice, as two transfers (a venue with a zero fee makes only the first). 25 USDC is a multiple of the rate, so nothing rounds; a premium of 10.000001 USDC would owe a fee of @@ -151,8 +159,12 @@ already had, and gave up everything above $180. Carol's `write_option(id = 2, kind = Put, underlying_amount = 5 NVDAx, strike_amount = 750 USDC, premium = 20 USDC)`, a strike of 150 USDC a share, -moves the 750 USDC into the quote vault. Dave's `buy_option` pays 19.80 USDC -to Carol and 0.20 USDC to the vault for Maria. +moves the 750 USDC into the quote vault. Carol has never held NVDAx, so she +has no NVDAx account; `write_option` creates it at her expense, the account +`collect_proceeds` would pay her shares into had Dave exercised. +`cancel_option` and `reclaim_collateral` create it too if it is missing. +Dave's `buy_option` pays 19.80 USDC to Carol and 0.20 USDC to the vault for +Maria. ### Step 7: The week passes above $150, and Carol reclaims her collateral @@ -228,12 +240,19 @@ the put from write to exercise and from purchase to expiry and reclaim (`test_reclaim_collateral_after_expiry_returns_the_strike_to_the_put_writer`), pins every balance to the minor unit, checks that the fee on a premium that is not a multiple of the rate rounds up and the writer receives the rest -(`test_fee_rounds_up_and_the_writer_takes_the_remainder`), counts the token +(`test_fee_rounds_up_and_the_writer_takes_the_remainder`), follows a put +writer with no NVDAx account from write to reclaim and from write to cancel +(`test_put_writer_without_an_underlying_account_writes_and_reclaims`, +`test_put_writer_without_an_underlying_account_writes_and_cancels`), counts the token transfers a purchase makes (two, or one on a zero-fee venue), checks the custody ledger against the vault balances after every lifecycle step, and checks that every refusal holds and fails with the expected error code: the expiry boundary from both sides, -cancel after sale, buy after sale or expiry, a writer buying their own option, +cancel after sale, buy after sale or expiry, a buy whose terms the writer +switched by cancelling and rewriting the option +(`test_buy_option_refuses_a_switched_option`, with +`test_buy_option_succeeds_when_the_terms_match` for the matching buy), a +writer buying their own option, exercise by a non-holder, collection by a non-writer or before exercise, reclaim after exercise, fee collection by a non-admin, a second sweep with nothing owed, and the parameter checks at market and write time. diff --git a/finance/options/anchor/programs/options/src/errors.rs b/finance/options/anchor/programs/options/src/errors.rs index 2f2b46557..785cefc8b 100644 --- a/finance/options/anchor/programs/options/src/errors.rs +++ b/finance/options/anchor/programs/options/src/errors.rs @@ -31,4 +31,7 @@ pub enum OptionsError { #[msg("Vault balance would fall below what the market owes")] CustodyInvariantViolated, + + #[msg("Option terms differ from the terms the buyer agreed to")] + OptionTermsChanged, } diff --git a/finance/options/anchor/programs/options/src/instructions/buy_option.rs b/finance/options/anchor/programs/options/src/instructions/buy_option.rs index 913a8aaa1..8f2b00f27 100644 --- a/finance/options/anchor/programs/options/src/instructions/buy_option.rs +++ b/finance/options/anchor/programs/options/src/instructions/buy_option.rs @@ -5,18 +5,38 @@ use crate::constants::{MARKET_SEED, OPTION_SEED, QUOTE_VAULT_SEED}; use crate::contract_math; use crate::errors::OptionsError; use crate::instructions::shared::{check_custody, transfer_from_signer}; +use crate::instructions::write_option::OptionTerms; use crate::state::{Market, OptionContract, OptionStatus}; /// Buy a listed option. The premium is the only money that changes hands: the /// venue's fee comes out of it into the quote vault, and the rest goes /// straight to the writer, whose money it is from this moment whatever the /// holder later does. The collateral does not move. -pub fn handle_buy_option(context: &mut Context) -> Result<()> { +/// +/// `terms` are the terms the buyer read from the option before signing. A +/// writer can cancel an option and write a new one at the same address (the +/// same `id`) with a higher premium, a smaller `underlying_amount`, or a +/// sooner expiry while the buyer's transaction is on its way, so the purchase +/// is refused with `OptionTermsChanged` unless every term still matches. +pub fn handle_buy_option( + context: &mut Context, + terms: OptionTerms, +) -> Result<()> { let option = &mut context.accounts.option; require!( option.status == OptionStatus::Listed, OptionsError::OptionNotListed ); + // The switched-option check, before anything moves: the buyer pays only + // for the option they saw. + require!( + option.kind == terms.kind + && option.underlying_amount == terms.underlying_amount + && option.strike_amount == terms.strike_amount + && option.premium == terms.premium + && option.expiry == terms.expiry, + OptionsError::OptionTermsChanged + ); // An option nobody can exercise any more is not for sale. let now = Clock::get()?.unix_timestamp; require!( diff --git a/finance/options/anchor/programs/options/src/instructions/cancel_option.rs b/finance/options/anchor/programs/options/src/instructions/cancel_option.rs index 248237994..ba54a5022 100644 --- a/finance/options/anchor/programs/options/src/instructions/cancel_option.rs +++ b/finance/options/anchor/programs/options/src/instructions/cancel_option.rs @@ -1,5 +1,8 @@ use anchor_lang::prelude::*; -use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{Mint, TokenAccount, TokenInterface}, +}; use crate::constants::{MARKET_SEED, OPTION_SEED, QUOTE_VAULT_SEED, UNDERLYING_VAULT_SEED}; use crate::contract_math; @@ -113,8 +116,13 @@ pub struct CancelOptionAccountConstraints { )] pub quote_vault: Box>, + // A call writer's collateral comes back here. A put writer may never have + // held the underlying (or may have closed the account since writing), so + // it is created if needed, at the writer's expense, as `collect_proceeds` + // does. #[account( - mut, + init_if_needed, + payer = writer, associated_token::mint = underlying_mint, associated_token::authority = writer, associated_token::token_program = token_program, @@ -130,4 +138,6 @@ pub struct CancelOptionAccountConstraints { pub writer_quote: Box>, pub token_program: Interface<'static, TokenInterface>, + pub associated_token_program: Program, + pub system_program: Program, } diff --git a/finance/options/anchor/programs/options/src/instructions/reclaim_collateral.rs b/finance/options/anchor/programs/options/src/instructions/reclaim_collateral.rs index 592f0053e..118412e35 100644 --- a/finance/options/anchor/programs/options/src/instructions/reclaim_collateral.rs +++ b/finance/options/anchor/programs/options/src/instructions/reclaim_collateral.rs @@ -1,5 +1,8 @@ use anchor_lang::prelude::*; -use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{Mint, TokenAccount, TokenInterface}, +}; use crate::constants::{MARKET_SEED, OPTION_SEED, QUOTE_VAULT_SEED, UNDERLYING_VAULT_SEED}; use crate::contract_math; @@ -119,8 +122,13 @@ pub struct ReclaimCollateralAccountConstraints { )] pub quote_vault: Box>, + // A call writer's collateral comes back here. A put writer may never have + // held the underlying (or may have closed the account since writing), so + // it is created if needed, at the writer's expense, as `collect_proceeds` + // does. #[account( - mut, + init_if_needed, + payer = writer, associated_token::mint = underlying_mint, associated_token::authority = writer, associated_token::token_program = token_program, @@ -136,4 +144,6 @@ pub struct ReclaimCollateralAccountConstraints { pub writer_quote: Box>, pub token_program: Interface<'static, TokenInterface>, + pub associated_token_program: Program, + pub system_program: Program, } diff --git a/finance/options/anchor/programs/options/src/instructions/write_option.rs b/finance/options/anchor/programs/options/src/instructions/write_option.rs index 9e00c1ac7..646ffa3f6 100644 --- a/finance/options/anchor/programs/options/src/instructions/write_option.rs +++ b/finance/options/anchor/programs/options/src/instructions/write_option.rs @@ -168,10 +168,14 @@ pub struct WriteOptionAccountConstraints { )] pub quote_vault: Box>, - // A call writer pays collateral from this account; a put writer's copy - // is only validated. + // A call writer pays collateral from this account. A put writer may never + // have held the underlying, so the account is created if needed, at the + // writer's expense; it is where `collect_proceeds` pays a put writer, and + // `cancel_option` and `reclaim_collateral` take it too. For an account + // that exists, the mint and authority constraints apply as before. #[account( - mut, + init_if_needed, + payer = writer, associated_token::mint = underlying_mint, associated_token::authority = writer, associated_token::token_program = token_program, diff --git a/finance/options/anchor/programs/options/src/lib.rs b/finance/options/anchor/programs/options/src/lib.rs index e5d70631f..9e59de2e8 100644 --- a/finance/options/anchor/programs/options/src/lib.rs +++ b/finance/options/anchor/programs/options/src/lib.rs @@ -50,9 +50,14 @@ pub mod options { } /// Buy a listed option: pay the premium (the venue's fee comes out of it, - /// the rest goes to the writer) and become the holder. - pub fn buy_option(context: &mut Context) -> Result<()> { - instructions::handle_buy_option(context) + /// the rest goes to the writer) and become the holder. `terms` are the + /// terms the buyer saw when they built the transaction; the purchase is + /// refused unless the option still has exactly those terms. + pub fn buy_option( + context: &mut Context, + terms: OptionTerms, + ) -> Result<()> { + instructions::handle_buy_option(context, terms) } /// Writer withdraws an unsold option: collateral back, account closed. diff --git a/finance/options/anchor/programs/options/src/state/market.rs b/finance/options/anchor/programs/options/src/state/market.rs index d534be503..1e344f735 100644 --- a/finance/options/anchor/programs/options/src/state/market.rs +++ b/finance/options/anchor/programs/options/src/state/market.rs @@ -5,7 +5,7 @@ use anchor_lang::prelude::*; /// two vaults. /// /// The vaults hold other people's money (writers' collateral, and the strike -/// payments holders make at exercise), so the two `*_locked` fields say how +/// payments holders make at exercise), so the two `*_owed` fields say how /// much of each vault the market owes and to whom it is owed in aggregate. /// Every handler that moves tokens asserts, after its own arithmetic, that /// each vault still covers what the market owes. diff --git a/finance/options/anchor/programs/options/tests/test_options.rs b/finance/options/anchor/programs/options/tests/test_options.rs index d20a34f17..d2372cb65 100644 --- a/finance/options/anchor/programs/options/tests/test_options.rs +++ b/finance/options/anchor/programs/options/tests/test_options.rs @@ -52,6 +52,10 @@ const ONE_WEEK: i64 = 7 * SECONDS_PER_DAY; const STANDARD_USDC: u64 = 1_000 * ONE_USDC; const FIVE_NVDAX: u64 = 5 * ONE_NVDAX; +// LiteSVM charges the default 5,000 lamports for each transaction's one +// signature. +const TRANSACTION_FEE: u64 = 5_000; + fn token_program_id() -> Address { "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" .parse() @@ -248,6 +252,13 @@ impl Venue { .unwrap_or(false) } + fn lamports(&self, address: &Address) -> u64 { + self.svm + .get_account(address) + .map(|account| account.lamports) + .unwrap_or(0) + } + fn now(&self) -> i64 { self.svm.get_sysvar::().unix_timestamp } @@ -265,14 +276,34 @@ impl Venue { /// associated token accounts. Every character gets one SOL's worth of /// lamports and change for rent and fees. fn person(&mut self, underlying: u64, quote: u64) -> Person { + self.person_with_accounts(underlying, quote, true) + } + + /// A character with a quote account only, as a put writer who has never + /// held the underlying would be. `underlying` is the address their + /// underlying associated token account would have; nothing exists there. + fn person_without_underlying_account(&mut self, quote: u64) -> Person { + self.person_with_accounts(0, quote, false) + } + + fn person_with_accounts( + &mut self, + underlying: u64, + quote: u64, + with_underlying_account: bool, + ) -> Person { let keypair = create_wallet(&mut self.svm, 10_000_000_000).unwrap(); - let underlying_account = create_associated_token_account( - &mut self.svm, - &keypair.pubkey(), - &self.underlying_mint, - &self.payer, - ) - .unwrap(); + let underlying_account = if with_underlying_account { + create_associated_token_account( + &mut self.svm, + &keypair.pubkey(), + &self.underlying_mint, + &self.payer, + ) + .unwrap() + } else { + derive_ata(&keypair.pubkey(), &self.underlying_mint) + }; let quote_account = create_associated_token_account( &mut self.svm, &keypair.pubkey(), @@ -393,15 +424,42 @@ impl Venue { .expect("writing the put should succeed") } + /// The terms a buyer reads from an option's account before signing a + /// purchase. + fn listed_terms(&self, option: &Address) -> OptionTerms { + let state = self.option_state(option); + OptionTerms { + kind: state.kind, + underlying_amount: state.underlying_amount, + strike_amount: state.strike_amount, + premium: state.premium, + expiry: state.expiry, + } + } + + /// Buy `option` at the terms it is listed with now. fn buy_option( &mut self, buyer: &Person, writer: &Address, option: &Address, + ) -> Result { + let terms = self.listed_terms(option); + self.buy_option_with_terms(buyer, writer, option, terms) + } + + /// Buy `option`, agreeing to `terms`: the terms the buyer saw, which the + /// option may no longer have by the time the purchase lands. + fn buy_option_with_terms( + &mut self, + buyer: &Person, + writer: &Address, + option: &Address, + terms: OptionTerms, ) -> Result { let instruction = Instruction::new_with_bytes( options::id(), - &options::instruction::BuyOption {}.data(), + &options::instruction::BuyOption { terms }.data(), options::accounts::BuyOptionAccountConstraints { buyer: buyer.pubkey(), writer: *writer, @@ -437,6 +495,8 @@ impl Venue { writer_underlying: writer.underlying, writer_quote: writer.quote, token_program: token_program_id(), + associated_token_program: ata_program_id(), + system_program: system_program::ID, } .to_account_metas(None), ); @@ -518,6 +578,8 @@ impl Venue { writer_underlying: writer.underlying, writer_quote: writer.quote, token_program: token_program_id(), + associated_token_program: ata_program_id(), + system_program: system_program::ID, } .to_account_metas(None), ); @@ -870,6 +932,91 @@ fn test_reclaim_collateral_after_expiry_returns_the_strike_to_the_put_writer() { venue.assert_vaults_match_ledger(); } +/// Carol has never held NVDAx, so she has no NVDAx account. Writing the put +/// creates it at her expense: her lamports pay the option's rent, the new +/// account's rent and the fee, and nothing else. Dave buys, never exercises, +/// and at expiry Carol reclaims her 750 USDC; the option's rent comes back to +/// her to the lamport. +#[test] +fn test_put_writer_without_an_underlying_account_writes_and_reclaims() { + let mut venue = Venue::new(); + let carol = venue.person_without_underlying_account(STANDARD_USDC); + let dave = venue.person(FIVE_NVDAX, STANDARD_USDC); + assert!(venue.svm.get_account(&carol.underlying).is_none()); + let carol_lamports_before_write = venue.lamports(&carol.pubkey()); + + let option = venue.write_put(&carol); + + let underlying_account_rent = venue.lamports(&carol.underlying); + let option_rent = venue.lamports(&option); + assert!(underlying_account_rent > 0); + assert_eq!(venue.balance(&carol.underlying), 0); + assert_eq!( + venue.lamports(&carol.pubkey()), + carol_lamports_before_write - option_rent - underlying_account_rent - TRANSACTION_FEE + ); + let collateral = PUT_STRIKE_AMOUNT; + assert_eq!(venue.balance(&carol.quote), STANDARD_USDC - collateral); + assert_eq!(venue.balance(&venue.quote_vault), collateral); + venue.assert_vaults_match_ledger(); + + venue.buy_option(&dave, &carol.pubkey(), &option).unwrap(); + let fee = 200_000; // 1% of 20 USDC + let expiry = venue.option_state(&option).expiry; + venue.warp_to(expiry); + let carol_lamports_before_reclaim = venue.lamports(&carol.pubkey()); + + venue.reclaim_collateral(&carol, &option).unwrap(); + + assert_eq!( + venue.balance(&carol.quote), + STANDARD_USDC + PUT_PREMIUM - fee + ); + assert_eq!(venue.balance(&carol.underlying), 0); + assert_eq!(venue.balance(&dave.quote), STANDARD_USDC - PUT_PREMIUM); + assert_eq!(venue.balance(&dave.underlying), FIVE_NVDAX); + assert_eq!(venue.balance(&venue.quote_vault), fee); + assert!(!venue.option_exists(&option)); + assert_eq!( + venue.lamports(&carol.pubkey()), + carol_lamports_before_reclaim + option_rent - TRANSACTION_FEE + ); + let market = venue.market_state(); + assert_eq!(market.quote_owed, 0); + assert_eq!(market.underlying_owed, 0); + assert_eq!(market.fees_owed, fee); + venue.assert_vaults_match_ledger(); +} + +/// Carol, with no NVDAx account, writes a put nobody buys and withdraws it. +/// Her 750 USDC comes back, the option closes with its rent back to her to +/// the lamport, and the NVDAx account the write created for her stays hers. +#[test] +fn test_put_writer_without_an_underlying_account_writes_and_cancels() { + let mut venue = Venue::new(); + let carol = venue.person_without_underlying_account(STANDARD_USDC); + assert!(venue.svm.get_account(&carol.underlying).is_none()); + + let option = venue.write_put(&carol); + assert_eq!(venue.balance(&carol.underlying), 0); + assert_eq!(venue.balance(&carol.quote), STANDARD_USDC - PUT_STRIKE_AMOUNT); + let option_rent = venue.lamports(&option); + let carol_lamports_before_cancel = venue.lamports(&carol.pubkey()); + + venue.cancel_option(&carol, &option).unwrap(); + + assert_eq!(venue.balance(&carol.quote), STANDARD_USDC); + assert_eq!(venue.balance(&carol.underlying), 0); + assert_eq!(venue.balance(&venue.quote_vault), 0); + assert!(!venue.option_exists(&option)); + assert_eq!( + venue.lamports(&carol.pubkey()), + carol_lamports_before_cancel + option_rent - TRANSACTION_FEE + ); + assert_eq!(venue.market_state().quote_owed, 0); + venue.assert_vaults_match_ledger(); +} + // =========================================================================== // The expiry boundary, from both sides // =========================================================================== @@ -1011,6 +1158,93 @@ fn test_buy_is_refused_once_sold() { assert_eq!(venue.option_state(&option).holder, bob.pubkey()); } +/// The switched-option attack: Bob reads Alice's call and signs a purchase at +/// those terms. Before it lands, Alice cancels and writes a new option at the +/// same address (the same `id`) on worse terms: a higher premium, fewer +/// shares, a higher strike, or a sooner expiry. Each time, Bob's purchase is +/// refused with `OptionTermsChanged`, and no USDC moves. +#[test] +fn test_buy_option_refuses_a_switched_option() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let bob = venue.person(0, STANDARD_USDC); + let option = venue.write_call(&alice); + let seen = venue.listed_terms(&option); + + let switches = [ + OptionTerms { + premium: 2 * CALL_PREMIUM, + ..seen + }, + OptionTerms { + underlying_amount: ONE_NVDAX, + ..seen + }, + OptionTerms { + strike_amount: CALL_STRIKE_AMOUNT + 100 * ONE_USDC, + ..seen + }, + OptionTerms { + expiry: seen.expiry - SECONDS_PER_DAY, + ..seen + }, + ]; + for switched in switches { + // A fresh blockhash, or the identical cancel and purchase would be + // dropped as duplicates of the previous round's. + venue.svm.expire_blockhash(); + venue.cancel_option(&alice, &option).unwrap(); + let rewritten = venue.write_option(&alice, 1, switched).unwrap(); + assert_eq!(rewritten, option); + + assert_fails_with( + venue.buy_option_with_terms(&bob, &alice.pubkey(), &option, seen), + OptionsError::OptionTermsChanged, + ); + assert_eq!(venue.balance(&bob.quote), STANDARD_USDC); + assert_eq!(venue.balance(&alice.quote), STANDARD_USDC); + assert_eq!(venue.balance(&venue.quote_vault), 0); + let state = venue.option_state(&option); + assert_eq!(state.status, OptionStatus::Listed); + assert_eq!(state.holder, Address::default()); + assert_eq!(venue.market_state().fees_owed, 0); + venue.assert_vaults_match_ledger(); + } +} + +/// A purchase whose terms match the option's goes through: Bob, reading the +/// rewritten option at a 50 USDC premium, buys it at that premium, paying +/// 0.50 USDC to the venue and 49.50 USDC to Alice. +#[test] +fn test_buy_option_succeeds_when_the_terms_match() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let bob = venue.person(0, STANDARD_USDC); + let option = venue.write_call(&alice); + let first = venue.listed_terms(&option); + venue.cancel_option(&alice, &option).unwrap(); + let premium = 2 * CALL_PREMIUM; + venue + .write_option(&alice, 1, OptionTerms { premium, ..first }) + .unwrap(); + let seen = venue.listed_terms(&option); + assert_eq!(seen.premium, premium); + + venue + .buy_option_with_terms(&bob, &alice.pubkey(), &option, seen) + .unwrap(); + + let fee = 500_000; // 0.50 USDC + assert_eq!(venue.balance(&bob.quote), STANDARD_USDC - premium); + assert_eq!(venue.balance(&alice.quote), STANDARD_USDC + premium - fee); + assert_eq!(venue.balance(&venue.quote_vault), fee); + let state = venue.option_state(&option); + assert_eq!(state.holder, bob.pubkey()); + assert_eq!(state.status, OptionStatus::Held); + assert_eq!(venue.market_state().fees_owed, fee); + venue.assert_vaults_match_ledger(); +} + /// A writer cannot buy their own option. `buyer_quote` and `writer_quote` /// are each bound to their party's associated token account, so with Alice /// on both sides they are one account in two mutable slots, and Anchor's diff --git a/finance/options/quasar/CHANGELOG.md b/finance/options/quasar/CHANGELOG.md index 82981a212..225a45c3a 100644 --- a/finance/options/quasar/CHANGELOG.md +++ b/finance/options/quasar/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## Unreleased, 2026-10-07 + +`buy_option` takes the five terms the buyer read from the option (`kind`, +`underlying_amount`, `strike_amount`, `premium` and `expiry`) and refuses +the purchase with the new error `OptionTermsChanged` unless the option still +has exactly those terms. Without it a writer could cancel an option and +write a new one at the same address (the same `id`) at a higher premium, on +fewer shares or with a sooner expiry while a purchase was on its way, the +switched-offer attack the escrow's `take_offer` already refuses. New tests +`buy_option_refuses_a_switched_option` (four switches, each refused with +nothing moved) and `buy_option_succeeds_when_the_terms_match`. The suite's +`buy_option` helper passes the option's current terms, and +`buy_option_with_terms` passes the terms a buyer saw earlier. + +`write_option`, `cancel_option`, `reclaim_collateral` and `collect_proceeds` +take the writer's underlying account as their associated token account and +create it with `init(idempotent)` if it does not exist, at the writer's +expense, so a put writer who has never held the underlying can write, +cancel, reclaim and collect. Each takes the associated token program, and +the three that did not already take the system program now do. The suite's +NVDAx accounts are now each character's associated token account. New tests +`put_writer_without_an_underlying_account_writes_and_reclaims` and +`put_writer_without_an_underlying_account_writes_and_cancels` start Carol +with no NVDAx account (`setup_with_carol_holding_no_nvdax_account`) and pin +her rent and token balances to the minor unit. + +The `Market` doc comment names the `*_owed` counters instead of the old +`*_locked` names. + ## Unreleased, 2026-10-05 The venue's fee rounds up: `split_premium` takes the ceiling of diff --git a/finance/options/quasar/README.md b/finance/options/quasar/README.md index 6725561fa..4d811a9f1 100644 --- a/finance/options/quasar/README.md +++ b/finance/options/quasar/README.md @@ -13,14 +13,23 @@ differs in the Quasar version. and `OptionStatus` enums become the constants in `constants.rs`: `KIND_CALL` / `KIND_PUT` and `STATUS_LISTED` / `STATUS_HELD` / `STATUS_EXERCISED`. -- **`write_option` takes its terms as separate arguments** (`kind`, - `underlying_amount`, `strike_amount`, `premium`, `expiry`) rather than the - Anchor sibling's `OptionTerms` struct. -- **Every party's token accounts must already exist.** The Anchor version - uses `init_if_needed` to create a call holder's underlying account and a - put writer's underlying account at the moment they are first paid in that - token; here the tests create both token accounts for every character up - front. +- **`write_option` and `buy_option` take their terms as separate + arguments** (`kind`, `underlying_amount`, `strike_amount`, `premium`, + `expiry`) rather than the Anchor sibling's `OptionTerms` struct. As there, + `buy_option` refuses a purchase with `OptionTermsChanged` unless the option + still has exactly the terms the buyer passed, so a writer cannot cancel and + rewrite the option at the same `id` on worse terms while the purchase is on + its way (`buy_option_refuses_a_switched_option`). +- **The writer's underlying account is created if needed; a holder's token + accounts must already exist.** `write_option`, `cancel_option`, + `reclaim_collateral` and `collect_proceeds` take the writer's underlying + account as their associated token account, created with + `init(idempotent)` at the writer's expense, so a put writer who has never + held the underlying can write, cancel, reclaim and collect + (`put_writer_without_an_underlying_account_writes_and_reclaims`, + `put_writer_without_an_underlying_account_writes_and_cancels`). The Anchor + version also uses `init_if_needed` for a call holder's underlying account + at exercise; here the tests create a holder's token accounts up front. - **The writer's premium account is bound in the handler.** The Anchor version derives it as the writer's associated token account; here `buy_option` checks that the account passed as `writer_quote` is owned by @@ -61,7 +70,8 @@ custody ledger against the vault balances after every lifecycle step. Every gate has a test that proves it shuts and fails with the expected error code: the expiry boundary from both sides, cancel after sale, buy after sale or expiry, a -writer buying their own option, a premium account the writer does not own, +buy whose terms the writer switched by cancelling and rewriting the option, +a writer buying their own option, a premium account the writer does not own, exercise by a non-holder, collection by a non-writer or before exercise, reclaim after exercise, fee collection by a non-admin, a second sweep with nothing owed, and the parameter checks at market and write time. diff --git a/finance/options/quasar/src/errors.rs b/finance/options/quasar/src/errors.rs index abba417fc..53a769ab4 100644 --- a/finance/options/quasar/src/errors.rs +++ b/finance/options/quasar/src/errors.rs @@ -25,4 +25,6 @@ pub enum OptionsError { NothingToCollect, /// Vault balance would fall below what the market owes. CustodyInvariantViolated, + /// Option terms differ from the terms the buyer agreed to. + OptionTermsChanged, } diff --git a/finance/options/quasar/src/instructions/buy_option.rs b/finance/options/quasar/src/instructions/buy_option.rs index f9a9748cb..c16be4c8f 100644 --- a/finance/options/quasar/src/instructions/buy_option.rs +++ b/finance/options/quasar/src/instructions/buy_option.rs @@ -9,6 +9,17 @@ use { quasar_spl::prelude::*, }; +/// The terms the buyer read from the option before signing, bundled so the +/// handler signature stays readable. +#[derive(Clone, Copy)] +pub struct BuyOptionArguments { + pub kind: u8, + pub underlying_amount: u64, + pub strike_amount: u64, + pub premium: u64, + pub expiry: i64, +} + #[derive(Accounts)] pub struct BuyOptionAccountConstraints { #[account(mut)] @@ -47,12 +58,31 @@ pub struct BuyOptionAccountConstraints { /// Buy a listed option. The premium is the only money that changes hands: the /// venue's fee comes out of it into the quote vault, and the rest goes /// straight to the writer. The collateral does not move. +/// +/// `arguments` are the terms the buyer read from the option before signing. A +/// writer can cancel an option and write a new one at the same address (the +/// same `id`) with a higher premium, a smaller `underlying_amount`, or a +/// sooner expiry while the buyer's transaction is on its way, so the purchase +/// is refused with `OptionTermsChanged` unless every term still matches. #[inline(always)] -pub fn handle_buy_option(accounts: &mut BuyOptionAccountConstraints) -> Result<(), ProgramError> { +pub fn handle_buy_option( + accounts: &mut BuyOptionAccountConstraints, + arguments: BuyOptionArguments, +) -> Result<(), ProgramError> { require!( accounts.option.status == STATUS_LISTED, OptionsError::OptionNotListed ); + // The switched-option check, before anything moves: the buyer pays only + // for the option they saw. + require!( + accounts.option.kind == arguments.kind + && accounts.option.underlying_amount.get() == arguments.underlying_amount + && accounts.option.strike_amount.get() == arguments.strike_amount + && accounts.option.premium.get() == arguments.premium + && accounts.option.expiry.get() == arguments.expiry, + OptionsError::OptionTermsChanged + ); // An option nobody can exercise any more is not for sale. let now: i64 = Clock::get()?.unix_timestamp.into(); require!( diff --git a/finance/options/quasar/src/instructions/cancel_option.rs b/finance/options/quasar/src/instructions/cancel_option.rs index 4b5d12d65..66fba2df3 100644 --- a/finance/options/quasar/src/instructions/cancel_option.rs +++ b/finance/options/quasar/src/instructions/cancel_option.rs @@ -34,11 +34,21 @@ pub struct CancelOptionAccountConstraints { pub underlying_vault: Account, #[account(mut)] pub quote_vault: Account, - #[account(mut)] + /// A call writer's collateral comes back here. A put writer may never + /// have held the underlying (or may have closed the account since + /// writing), so it is created if needed, at the writer's expense. + #[account( + mut, + init(idempotent), + payer = writer, + associated_token(mint = underlying_mint, authority = writer, token_program = token_program), + )] pub writer_underlying: Account, #[account(mut)] pub writer_quote: Account, pub token_program: Program, + pub associated_token_program: Program, + pub system_program: Program, } /// Withdraw an unsold option. Any time is fine, including after expiry: an diff --git a/finance/options/quasar/src/instructions/collect_proceeds.rs b/finance/options/quasar/src/instructions/collect_proceeds.rs index 4aa10c80d..81e62e035 100644 --- a/finance/options/quasar/src/instructions/collect_proceeds.rs +++ b/finance/options/quasar/src/instructions/collect_proceeds.rs @@ -34,12 +34,20 @@ pub struct CollectProceedsAccountConstraints { pub underlying_vault: Account, #[account(mut)] pub quote_vault: Account, - /// Unlike the Anchor sibling, both writer accounts must already exist. - #[account(mut)] + /// A put writer is paid in the underlying, which they may never have + /// held, so the account is created if needed, at the writer's expense. + #[account( + mut, + init(idempotent), + payer = writer, + associated_token(mint = underlying_mint, authority = writer, token_program = token_program), + )] pub writer_underlying: Account, #[account(mut)] pub writer_quote: Account, pub token_program: Program, + pub associated_token_program: Program, + pub system_program: Program, } /// The writer collects what the holder paid at exercise: the strike for a diff --git a/finance/options/quasar/src/instructions/reclaim_collateral.rs b/finance/options/quasar/src/instructions/reclaim_collateral.rs index ac369abd8..d7983a07e 100644 --- a/finance/options/quasar/src/instructions/reclaim_collateral.rs +++ b/finance/options/quasar/src/instructions/reclaim_collateral.rs @@ -34,11 +34,21 @@ pub struct ReclaimCollateralAccountConstraints { pub underlying_vault: Account, #[account(mut)] pub quote_vault: Account, - #[account(mut)] + /// A call writer's collateral comes back here. A put writer may never + /// have held the underlying (or may have closed the account since + /// writing), so it is created if needed, at the writer's expense. + #[account( + mut, + init(idempotent), + payer = writer, + associated_token(mint = underlying_mint, authority = writer, token_program = token_program), + )] pub writer_underlying: Account, #[account(mut)] pub writer_quote: Account, pub token_program: Program, + pub associated_token_program: Program, + pub system_program: Program, } /// The holder let the option expire, so the writer takes the collateral diff --git a/finance/options/quasar/src/instructions/write_option.rs b/finance/options/quasar/src/instructions/write_option.rs index 2d02361a1..1a062b5a2 100644 --- a/finance/options/quasar/src/instructions/write_option.rs +++ b/finance/options/quasar/src/instructions/write_option.rs @@ -46,15 +46,23 @@ pub struct WriteOptionAccountConstraints { pub underlying_vault: Account, #[account(mut)] pub quote_vault: Account, - /// A call writer pays collateral from this account; a put writer's copy - /// is only validated. Unlike the Anchor sibling, it must already exist. - #[account(mut)] + /// A call writer pays collateral from this account. A put writer may + /// never have held the underlying, so the account is created if needed, + /// at the writer's expense; it is where `collect_proceeds` pays a put + /// writer, and `cancel_option` and `reclaim_collateral` take it too. + #[account( + mut, + init(idempotent), + payer = writer, + associated_token(mint = underlying_mint, authority = writer, token_program = token_program), + )] pub writer_underlying: Account, /// A put writer pays collateral from this account, and every writer is /// paid their premium into it by `buy_option`. Must already exist. #[account(mut)] pub writer_quote: Account, pub token_program: Program, + pub associated_token_program: Program, pub system_program: Program, pub rent: Sysvar, } diff --git a/finance/options/quasar/src/lib.rs b/finance/options/quasar/src/lib.rs index 791dc089f..8b2435c13 100644 --- a/finance/options/quasar/src/lib.rs +++ b/finance/options/quasar/src/lib.rs @@ -54,9 +54,27 @@ mod quasar_options { ) } + /// The five terms are the ones the buyer saw; the purchase is refused + /// unless the option still has exactly those terms. #[instruction(discriminator = 2)] - pub fn buy_option(ctx: Ctx) -> Result<(), ProgramError> { - instructions::handle_buy_option(&mut ctx.accounts) + pub fn buy_option( + ctx: Ctx, + kind: u8, + underlying_amount: u64, + strike_amount: u64, + premium: u64, + expiry: i64, + ) -> Result<(), ProgramError> { + instructions::handle_buy_option( + &mut ctx.accounts, + BuyOptionArguments { + kind, + underlying_amount, + strike_amount, + premium, + expiry, + }, + ) } #[instruction(discriminator = 3)] diff --git a/finance/options/quasar/src/state.rs b/finance/options/quasar/src/state.rs index 6072a82b0..d67b24070 100644 --- a/finance/options/quasar/src/state.rs +++ b/finance/options/quasar/src/state.rs @@ -1,8 +1,8 @@ use quasar_lang::prelude::*; /// One options venue. Mirrors the Anchor `Market` field-for-field; see the -/// Anchor sibling's README for what each field means. The three `*_locked` / -/// `fees_owed` counters are the ledger of what each vault owes, asserted +/// Anchor sibling's README for what each field means. The `*_owed` counters +/// (`underlying_owed`, `quote_owed` and `fees_owed`) are the ledger of what each vault owes, asserted /// against the vault balances after every transfer. #[account(discriminator = 1, set_inner)] #[seeds(b"market", underlying_mint: Address, quote_mint: Address)] diff --git a/finance/options/quasar/src/tests.rs b/finance/options/quasar/src/tests.rs index bab6e2098..4d7fcd635 100644 --- a/finance/options/quasar/src/tests.rs +++ b/finance/options/quasar/src/tests.rs @@ -51,25 +51,32 @@ const START_TIME: i64 = 1_750_000_000; const SECONDS_PER_DAY: i64 = 24 * 60 * 60; const EXPIRY: i64 = START_TIME + 7 * SECONDS_PER_DAY; -// Deterministic addresses. +/// A wallet's NVDAx associated token account: the account the program's +/// writer handlers require and create. +const fn nvdax_account(wallet: Pubkey) -> Pubkey { + quasar_spl::get_associated_token_address_const(&wallet, &NVDAX_MINT).0 +} + +// Deterministic addresses. Each NVDAx account is the wallet's associated +// token account. const MARIA: Pubkey = Pubkey::new_from_array([1; 32]); const NVDAX_MINT: Pubkey = Pubkey::new_from_array([2; 32]); const USDC_MINT: Pubkey = Pubkey::new_from_array([3; 32]); const MARIA_USDC: Pubkey = Pubkey::new_from_array([4; 32]); const ALICE: Pubkey = Pubkey::new_from_array([5; 32]); -const ALICE_NVDAX: Pubkey = Pubkey::new_from_array([6; 32]); +const ALICE_NVDAX: Pubkey = nvdax_account(ALICE); const ALICE_USDC: Pubkey = Pubkey::new_from_array([7; 32]); const BOB: Pubkey = Pubkey::new_from_array([8; 32]); -const BOB_NVDAX: Pubkey = Pubkey::new_from_array([9; 32]); +const BOB_NVDAX: Pubkey = nvdax_account(BOB); const BOB_USDC: Pubkey = Pubkey::new_from_array([10; 32]); const CAROL: Pubkey = Pubkey::new_from_array([11; 32]); -const CAROL_NVDAX: Pubkey = Pubkey::new_from_array([12; 32]); +const CAROL_NVDAX: Pubkey = nvdax_account(CAROL); const CAROL_USDC: Pubkey = Pubkey::new_from_array([13; 32]); const DAVE: Pubkey = Pubkey::new_from_array([14; 32]); -const DAVE_NVDAX: Pubkey = Pubkey::new_from_array([15; 32]); +const DAVE_NVDAX: Pubkey = nvdax_account(DAVE); const DAVE_USDC: Pubkey = Pubkey::new_from_array([16; 32]); const MALLORY: Pubkey = Pubkey::new_from_array([17; 32]); -const MALLORY_NVDAX: Pubkey = Pubkey::new_from_array([18; 32]); +const MALLORY_NVDAX: Pubkey = nvdax_account(MALLORY); const MALLORY_USDC: Pubkey = Pubkey::new_from_array([19; 32]); // A second USDC account of Alice's, for the self-purchase test. const ALICE_OTHER_USDC: Pubkey = Pubkey::new_from_array([20; 32]); @@ -102,12 +109,18 @@ const DAVE_P: Person = person(DAVE, DAVE_NVDAX, DAVE_USDC); const MALLORY_P: Person = person(MALLORY, MALLORY_NVDAX, MALLORY_USDC); fn add_person(test: &mut Test, who: &Person, nvdax: u64, usdc: u64) { - test.add(Wallet::new().at(who.wallet)); + add_person_without_nvdax_account(test, who, usdc); test.add( TokenAccount::new(NVDAX_MINT, who.wallet) .at(who.nvdax) .amount(nvdax), ); +} + +/// A character with a wallet and a USDC account only, as a put writer who +/// has never held NVDAx would be. Nothing exists at `who.nvdax`. +fn add_person_without_nvdax_account(test: &mut Test, who: &Person, usdc: u64) { + test.add(Wallet::new().at(who.wallet)); test.add( TokenAccount::new(USDC_MINT, who.wallet) .at(who.usdc) @@ -127,6 +140,15 @@ fn initialize_market(test: &mut Test, fee_bps: u16, quote_mint: Pubkey) -> Outco /// Mints, the clock at `START_TIME`, Maria's wallet, and a venue at /// `FEE_BPS`. Alice and Dave hold 5 NVDAx; everyone holds 1,000 USDC. fn setup_with_fee(test: &mut Test, fee_bps: u16) -> Env { + setup_with(test, fee_bps, true) +} + +/// Like `setup`, but Carol has no NVDAx account at all. +fn setup_with_carol_holding_no_nvdax_account(test: &mut Test) -> Env { + setup_with(test, FEE_BPS, false) +} + +fn setup_with(test: &mut Test, fee_bps: u16, carol_has_nvdax_account: bool) -> Env { test.add(Wallet::new().at(MARIA)); test.add(TokenAccount::new(USDC_MINT, MARIA).at(MARIA_USDC).amount(0)); test.add(Mint::new(MARIA).at(NVDAX_MINT).decimals(NVDAX_DECIMALS)); @@ -134,7 +156,11 @@ fn setup_with_fee(test: &mut Test, fee_bps: u16) -> Env { test.warp_to_timestamp(START_TIME); add_person(test, &ALICE_P, FIVE_NVDAX, STANDARD_USDC); add_person(test, &BOB_P, 0, STANDARD_USDC); - add_person(test, &CAROL_P, 0, STANDARD_USDC); + if carol_has_nvdax_account { + add_person(test, &CAROL_P, 0, STANDARD_USDC); + } else { + add_person_without_nvdax_account(test, &CAROL_P, STANDARD_USDC); + } add_person(test, &DAVE_P, FIVE_NVDAX, STANDARD_USDC); add_person(test, &MALLORY_P, 0, STANDARD_USDC); initialize_market(test, fee_bps, USDC_MINT).succeeds(); @@ -168,7 +194,6 @@ fn write_option( quote_mint: USDC_MINT, underlying_vault: env.underlying_vault, quote_vault: env.quote_vault, - writer_underlying: writer.nvdax, writer_quote: writer.usdc, id, kind, @@ -217,8 +242,41 @@ fn write_put(test: &mut Test, env: &Env) -> Pubkey { option_pda(test, env, &CAROL_P, PUT_ID) } +/// An option's five terms, as a buyer reads them before signing. +#[derive(Clone, Copy)] +struct Terms { + kind: u8, + underlying_amount: u64, + strike_amount: u64, + premium: u64, + expiry: i64, +} + +fn listed_terms(test: &Test, env: &Env, writer: &Person, id: u64) -> Terms { + let state = test.read::(option_pda(test, env, writer, id)); + Terms { + kind: state.kind, + underlying_amount: state.underlying_amount.into(), + strike_amount: state.strike_amount.into(), + premium: state.premium.into(), + expiry: state.expiry.into(), + } +} + +/// Buy at the terms the option is listed with now. fn buy_option(test: &mut Test, env: &Env, buyer: &Person, writer: &Person, id: u64) -> Outcome { - test.send(BuyOptionInstruction { + let terms = listed_terms(test, env, writer, id); + buy_option_with_terms(test, env, buyer, writer, id, terms) +} + +fn buy_instruction( + env: &Env, + buyer: &Person, + writer: &Person, + id: u64, + terms: Terms, +) -> BuyOptionInstruction { + BuyOptionInstruction { buyer: buyer.wallet, writer: writer.wallet, option_id_seed: id, @@ -227,7 +285,25 @@ fn buy_option(test: &mut Test, env: &Env, buyer: &Person, writer: &Person, id: u quote_vault: env.quote_vault, buyer_quote: buyer.usdc, writer_quote: writer.usdc, - }) + kind: terms.kind, + underlying_amount: terms.underlying_amount, + strike_amount: terms.strike_amount, + premium: terms.premium, + expiry: terms.expiry, + } +} + +/// Buy agreeing to `terms`: the terms the buyer saw, which the option may no +/// longer have by the time the purchase lands. +fn buy_option_with_terms( + test: &mut Test, + env: &Env, + buyer: &Person, + writer: &Person, + id: u64, + terms: Terms, +) -> Outcome { + test.send(buy_instruction(env, buyer, writer, id, terms)) } fn cancel_option(test: &mut Test, env: &Env, writer: &Person, id: u64) -> Outcome { @@ -238,7 +314,6 @@ fn cancel_option(test: &mut Test, env: &Env, writer: &Person, id: u64) -> Outcom quote_mint: USDC_MINT, underlying_vault: env.underlying_vault, quote_vault: env.quote_vault, - writer_underlying: writer.nvdax, writer_quote: writer.usdc, }) } @@ -271,7 +346,6 @@ fn collect_proceeds(test: &mut Test, env: &Env, writer: &Person, id: u64) -> Out quote_mint: USDC_MINT, underlying_vault: env.underlying_vault, quote_vault: env.quote_vault, - writer_underlying: writer.nvdax, writer_quote: writer.usdc, }) } @@ -284,7 +358,6 @@ fn reclaim_collateral(test: &mut Test, env: &Env, writer: &Person, id: u64) -> O quote_mint: USDC_MINT, underlying_vault: env.underlying_vault, quote_vault: env.quote_vault, - writer_underlying: writer.nvdax, writer_quote: writer.usdc, }) } @@ -595,6 +668,77 @@ fn reclaim_collateral_after_expiry_returns_the_strike_to_the_put_writer(test: &m assert_vaults_match_ledger(test, &env); } +/// Carol has never held NVDAx, so she has no NVDAx account. Writing the put +/// creates it at her expense: her lamports pay the option's rent and the new +/// account's rent, and nothing else. Dave buys, never exercises, and at +/// expiry Carol reclaims her 750 USDC; the option's rent comes back to her to +/// the lamport. +#[quasar_test] +fn put_writer_without_an_underlying_account_writes_and_reclaims(test: &mut Test) { + let env = setup_with_carol_holding_no_nvdax_account(test); + assert!(test.account(CAROL_NVDAX).is_none()); + let carol_lamports_before_write = test.lamports(CAROL); + + let option = write_put(test, &env); + + let nvdax_account_rent = test.lamports(CAROL_NVDAX); + let option_rent = test.lamports(option); + assert!(nvdax_account_rent > 0); + assert_eq!(test.tokens(CAROL_NVDAX), 0); + assert_eq!(token_authority(test, CAROL_NVDAX), CAROL); + assert_eq!( + test.lamports(CAROL), + carol_lamports_before_write - option_rent - nvdax_account_rent + ); + assert_eq!(test.tokens(CAROL_USDC), STANDARD_USDC - PUT_STRIKE_AMOUNT); + assert_eq!(test.tokens(env.quote_vault), PUT_STRIKE_AMOUNT); + assert_vaults_match_ledger(test, &env); + + buy_option(test, &env, &DAVE_P, &CAROL_P, PUT_ID).succeeds(); + let fee = 200_000; // 1% of 20 USDC + test.warp_to_timestamp(EXPIRY); + let carol_lamports_before_reclaim = test.lamports(CAROL); + + reclaim_collateral(test, &env, &CAROL_P, PUT_ID) + .succeeds() + .has_tokens(CAROL_USDC, STANDARD_USDC + PUT_PREMIUM - fee) + .has_tokens(CAROL_NVDAX, 0) + .has_tokens(env.quote_vault, fee) + .has_lamports(CAROL, carol_lamports_before_reclaim + option_rent) + .is_closed(option); + assert_eq!(test.tokens(DAVE_USDC), STANDARD_USDC - PUT_PREMIUM); + assert_eq!(test.tokens(DAVE_NVDAX), FIVE_NVDAX); + let market = test.read::(env.market); + assert_eq!(u64::from(market.quote_owed), 0); + assert_eq!(u64::from(market.underlying_owed), 0); + assert_eq!(u64::from(market.fees_owed), fee); + assert_vaults_match_ledger(test, &env); +} + +/// Carol, with no NVDAx account, writes a put nobody buys and withdraws it. +/// Her 750 USDC comes back, and the option closes with its rent back to her +/// to the lamport. +#[quasar_test] +fn put_writer_without_an_underlying_account_writes_and_cancels(test: &mut Test) { + let env = setup_with_carol_holding_no_nvdax_account(test); + assert!(test.account(CAROL_NVDAX).is_none()); + + let option = write_put(test, &env); + assert_eq!(test.tokens(CAROL_NVDAX), 0); + let option_rent = test.lamports(option); + let carol_lamports_before_cancel = test.lamports(CAROL); + + cancel_option(test, &env, &CAROL_P, PUT_ID) + .succeeds() + .has_tokens(CAROL_USDC, STANDARD_USDC) + .has_tokens(CAROL_NVDAX, 0) + .has_tokens(env.quote_vault, 0) + .has_lamports(CAROL, carol_lamports_before_cancel + option_rent) + .is_closed(option); + assert_eq!(u64::from(test.read::(env.market).quote_owed), 0); + assert_vaults_match_ledger(test, &env); +} + // =========================================================================== // The expiry boundary, from both sides // =========================================================================== @@ -690,6 +834,100 @@ fn buy_is_refused_once_sold(test: &mut Test) { assert_eq!(test.read::(option).holder, BOB); } +/// The switched-option attack: Bob reads Alice's call and signs a purchase at +/// those terms. Before it lands, Alice cancels and writes a new option at the +/// same address (the same `id`) on worse terms: a higher premium, fewer +/// shares, a higher strike, or a sooner expiry. Each time, Bob's purchase is +/// refused with `OptionTermsChanged`, and no USDC moves. +#[quasar_test] +fn buy_option_refuses_a_switched_option(test: &mut Test) { + let env = setup(test); + let option = write_call(test, &env); + let seen = listed_terms(test, &env, &ALICE_P, CALL_ID); + + let switches = [ + Terms { + premium: 2 * CALL_PREMIUM, + ..seen + }, + Terms { + underlying_amount: ONE_NVDAX, + ..seen + }, + Terms { + strike_amount: CALL_STRIKE_AMOUNT + 100 * ONE_USDC, + ..seen + }, + Terms { + expiry: seen.expiry - SECONDS_PER_DAY, + ..seen + }, + ]; + for switched in switches { + cancel_option(test, &env, &ALICE_P, CALL_ID).succeeds(); + write_option( + test, + &env, + &ALICE_P, + CALL_ID, + switched.kind, + switched.underlying_amount, + switched.strike_amount, + switched.premium, + switched.expiry, + ) + .succeeds(); + + buy_option_with_terms(test, &env, &BOB_P, &ALICE_P, CALL_ID, seen) + .fails_with(OptionsError::OptionTermsChanged); + assert_eq!(test.tokens(BOB_USDC), STANDARD_USDC); + assert_eq!(test.tokens(ALICE_USDC), STANDARD_USDC); + assert_eq!(test.tokens(env.quote_vault), 0); + let state = test.read::(option); + assert_eq!(state.status, STATUS_LISTED); + assert_eq!(state.holder, Pubkey::default()); + assert_eq!(u64::from(test.read::(env.market).fees_owed), 0); + assert_vaults_match_ledger(test, &env); + } +} + +/// A purchase whose terms match the option's goes through: Bob, reading the +/// rewritten option at a 50 USDC premium, buys it at that premium, paying +/// 0.50 USDC to the venue and 49.50 USDC to Alice. +#[quasar_test] +fn buy_option_succeeds_when_the_terms_match(test: &mut Test) { + let env = setup(test); + let option = write_call(test, &env); + cancel_option(test, &env, &ALICE_P, CALL_ID).succeeds(); + let premium = 2 * CALL_PREMIUM; + write_option( + test, + &env, + &ALICE_P, + CALL_ID, + KIND_CALL, + CALL_UNDERLYING, + CALL_STRIKE_AMOUNT, + premium, + EXPIRY, + ) + .succeeds(); + let seen = listed_terms(test, &env, &ALICE_P, CALL_ID); + assert_eq!(seen.premium, premium); + + let fee = 500_000; // 0.50 USDC + buy_option_with_terms(test, &env, &BOB_P, &ALICE_P, CALL_ID, seen) + .succeeds() + .has_tokens(BOB_USDC, STANDARD_USDC - premium) + .has_tokens(ALICE_USDC, STANDARD_USDC + premium - fee) + .has_tokens(env.quote_vault, fee); + let state = test.read::(option); + assert_eq!(state.holder, BOB); + assert_eq!(state.status, STATUS_HELD); + assert_eq!(u64::from(test.read::(env.market).fees_owed), fee); + assert_vaults_match_ledger(test, &env); +} + /// A writer cannot buy their own option: their address would sit in the `buyer` /// and `writer` slots at once, so the runtime hands the program the second as /// a duplicate of the first, and Quasar's account parsing refuses the @@ -723,17 +961,8 @@ fn writer_cannot_buy_their_own_option(test: &mut Test) { fn buy_refuses_a_premium_account_the_writer_does_not_own(test: &mut Test) { let env = setup(test); write_call(test, &env); - let mut instruction: Instruction = BuyOptionInstruction { - buyer: BOB, - writer: ALICE, - option_id_seed: CALL_ID, - underlying_mint: NVDAX_MINT, - quote_mint: USDC_MINT, - quote_vault: env.quote_vault, - buyer_quote: BOB_USDC, - writer_quote: ALICE_USDC, - } - .into(); + let terms = listed_terms(test, &env, &ALICE_P, CALL_ID); + let mut instruction: Instruction = buy_instruction(&env, &BOB_P, &ALICE_P, CALL_ID, terms).into(); // Account order matches the `#[derive(Accounts)]` struct: writer_quote is // the last account before the token program. let writer_quote_index = instruction diff --git a/finance/order-book/anchor-v1/CHANGELOG.md b/finance/order-book/anchor-v1/CHANGELOG.md index 17b99af20..f22bc2260 100644 --- a/finance/order-book/anchor-v1/CHANGELOG.md +++ b/finance/order-book/anchor-v1/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## Unreleased - 2026-10-07 + +### Fixed + +- The `place_order` doc comment said ties at one price break on the + earliest timestamp. They break on the lowest order id, the order book's + sequence number, which is what the matching code compares. Comment + only; no behavior changes. + ## Unreleased - 2026-10-05 ### Added diff --git a/finance/order-book/anchor-v1/programs/order-book/src/lib.rs b/finance/order-book/anchor-v1/programs/order-book/src/lib.rs index 7b1b33307..8499991cb 100644 --- a/finance/order-book/anchor-v1/programs/order-book/src/lib.rs +++ b/finance/order-book/anchor-v1/programs/order-book/src/lib.rs @@ -43,8 +43,9 @@ pub mod order_book { /// Place a bid or ask. Locks the required funds (quote for bids, base /// for asks) into the market vault, crosses against the opposing side - /// of the book using price-time priority (best price first, earliest - /// timestamp at a tie), credits fills to maker/taker `unsettled_*` + /// of the book using price-time priority (best price first, lowest + /// order id at a tie: the order book's sequence number, so the order + /// that rested first), credits fills to maker/taker `unsettled_*` /// balances, routes the taker fee to the fee vault, and rests any /// unmatched remainder on the book at the caller's limit price. /// diff --git a/finance/order-book/anchor/CHANGELOG.md b/finance/order-book/anchor/CHANGELOG.md index 9a4ce7926..da30f1c99 100644 --- a/finance/order-book/anchor/CHANGELOG.md +++ b/finance/order-book/anchor/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## Unreleased - 2026-10-07 + +### Fixed + +- The `place_order` doc comment said ties at one price break on the + earliest timestamp. They break on the lowest order id, the order book's + sequence number, which is what the matching code compares. Comment + only; no behavior changes. + ## Unreleased - 2026-10-05 ### Added diff --git a/finance/order-book/anchor/programs/order-book/src/lib.rs b/finance/order-book/anchor/programs/order-book/src/lib.rs index b0a652409..a93ebc6f5 100644 --- a/finance/order-book/anchor/programs/order-book/src/lib.rs +++ b/finance/order-book/anchor/programs/order-book/src/lib.rs @@ -43,8 +43,9 @@ pub mod order_book { /// Place a bid or ask. Locks the required funds (quote for bids, base /// for asks) into the market vault, crosses against the opposing side - /// of the book using price-time priority (best price first, earliest - /// timestamp at a tie), credits fills to maker/taker `unsettled_*` + /// of the book using price-time priority (best price first, lowest + /// order id at a tie: the order book's sequence number, so the order + /// that rested first), credits fills to maker/taker `unsettled_*` /// balances, routes the taker fee to the fee vault, and rests any /// unmatched remainder on the book at the caller's limit price. /// diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/shared.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/shared.rs index bc2530f0d..78286aa16 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/shared.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/shared.rs @@ -132,9 +132,12 @@ pub fn position_pnl(side: Side, size: u64, entry_price: u64, price: u64) -> Resu .ok_or(PerpError::MathOverflow)?; // Multiply before dividing to keep precision; `entry > 0` is guaranteed. + // Floored toward negative infinity rather than truncated toward zero, so a + // loss that is not a whole base unit rounds up to the next one and a + // profit rounds down: the trader's result is always the lower of the two. size.checked_mul(price_change) .ok_or(PerpError::MathOverflow)? - .checked_div(entry) + .checked_div_euclid(entry) .ok_or(PerpError::MathOverflow.into()) } @@ -265,6 +268,11 @@ pub fn credit_fee(pool: &mut Pool, fee: u64) -> Result<()> { /// Funding a position owes since it opened, in collateral base units. Positive /// means the trader pays the pool; negative means the pool pays the trader. +/// +/// Rounded up, toward positive infinity, so a fraction of a base unit always +/// goes to the pool: funding the trader pays rounds up to the next whole unit +/// and funding the trader receives rounds down. The side's sign is applied +/// before dividing, so a short is rounded the same way as a long. pub fn position_funding( side: Side, size: u64, @@ -274,16 +282,27 @@ pub fn position_funding( let funding_change = pool_funding .checked_sub(entry_funding) .ok_or(PerpError::MathOverflow)?; - let long_owed = (size as i128) - .checked_mul(funding_change) - .ok_or(PerpError::MathOverflow)? - .checked_div(FUNDING_PRECISION) + // Longs owe the index's rise and shorts its fall. + let owed_change = match side { + Side::Long => funding_change, + Side::Short => funding_change + .checked_neg() + .ok_or(PerpError::MathOverflow)?, + }; + let numerator = (size as i128) + .checked_mul(owed_change) .ok_or(PerpError::MathOverflow)?; - - Ok(match side { - Side::Long => long_owed, - Side::Short => -long_owed, - }) + let floored = numerator + .checked_div_euclid(FUNDING_PRECISION) + .ok_or(PerpError::MathOverflow)?; + let remainder = numerator + .checked_rem_euclid(FUNDING_PRECISION) + .ok_or(PerpError::MathOverflow)?; + if remainder == 0 { + Ok(floored) + } else { + floored.checked_add(1).ok_or(PerpError::MathOverflow.into()) + } } /// `basis_points` of `amount`, rounded up: the open, close and liquidation diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/tests/test_perpetual_futures.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/tests/test_perpetual_futures.rs index 1ce06408e..d2f16ce56 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/tests/test_perpetual_futures.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/tests/test_perpetual_futures.rs @@ -9,7 +9,9 @@ use { errors::PerpError, instructions::{ initialize_pool::PoolParameters, - shared::{basis_points_of, basis_points_of_rounded_down}, + shared::{ + basis_points_of, basis_points_of_rounded_down, position_funding, position_pnl, + }, }, state::{Pool, Position, Side}, }, @@ -2130,6 +2132,59 @@ fn test_basis_points_of_rounds_up_and_the_insurance_split_rounds_down() { assert_eq!(basis_points_of_rounded_down(1, 5_000).unwrap(), 0); } +/// `position_pnl` floors toward negative infinity rather than truncating +/// toward zero. A position of 1,000 base units entered at 3 and marked at 2 +/// has lost 333.33 base units: truncation would book a loss of 333, floor +/// books 334, so the fraction goes to the pool. A profit of 333.33 still +/// books 333, the same as truncation, and a short is floored the same way. +#[test] +fn test_position_pnl_rounds_against_the_trader() { + assert_eq!(position_pnl(Side::Long, 1_000, 3, 2).unwrap(), -334); + assert_eq!(position_pnl(Side::Long, 1_000, 3, 4).unwrap(), 333); + assert_eq!(position_pnl(Side::Short, 1_000, 3, 4).unwrap(), -334); + assert_eq!(position_pnl(Side::Short, 1_000, 3, 2).unwrap(), 333); + // A whole number of base units is unchanged. + assert_eq!(position_pnl(Side::Long, 1_000, 4, 2).unwrap(), -500); +} + +/// `position_funding` rounds up, toward positive infinity, so a fraction of a +/// base unit goes to the pool. A funding index that moves 1,500,000 (in +/// `FUNDING_PRECISION` units of 10^9) on a position of 1,000 base units is +/// 1.5 base units. A trader who pays is charged 2, where truncation would +/// charge 1; a trader who is paid receives 1, the same as truncation. A short +/// is rounded the same way as a long: truncating before applying its sign +/// would have charged a paying short 1. +#[test] +fn test_position_funding_rounds_against_the_trader() { + // The index rises: longs pay, shorts are paid. + assert_eq!( + position_funding(Side::Long, 1_000, 0, 1_500_000).unwrap(), + 2 + ); + assert_eq!( + position_funding(Side::Short, 1_000, 0, 1_500_000).unwrap(), + -1 + ); + // The index falls: shorts pay, longs are paid. + assert_eq!( + position_funding(Side::Short, 1_000, 0, -1_500_000).unwrap(), + 2 + ); + assert_eq!( + position_funding(Side::Long, 1_000, 0, -1_500_000).unwrap(), + -1 + ); + // A whole number of base units is unchanged. + assert_eq!( + position_funding(Side::Long, 1_000, 0, 2_000_000).unwrap(), + 2 + ); + assert_eq!( + position_funding(Side::Short, 1_000, 0, 2_000_000).unwrap(), + -2 + ); +} + #[test] fn test_initialize_pool_rejects_insurance_fee_at_or_above_full_fee() { let with_insurance_fee = |insurance_fee_bps| PoolParameters { diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/shared.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/shared.rs index 41288aabc..f60835704 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/shared.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/shared.rs @@ -132,9 +132,12 @@ pub fn position_pnl(side: Side, size: u64, entry_price: u64, price: u64) -> Resu .ok_or(PerpError::MathOverflow)?; // Multiply before dividing to keep precision; `entry > 0` is guaranteed. + // Floored toward negative infinity rather than truncated toward zero, so a + // loss that is not a whole base unit rounds up to the next one and a + // profit rounds down: the trader's result is always the lower of the two. size.checked_mul(price_change) .ok_or(PerpError::MathOverflow)? - .checked_div(entry) + .checked_div_euclid(entry) .ok_or(PerpError::MathOverflow.into()) } @@ -265,6 +268,11 @@ pub fn credit_fee(pool: &mut Pool, fee: u64) -> Result<()> { /// Funding a position owes since it opened, in collateral base units. Positive /// means the trader pays the pool; negative means the pool pays the trader. +/// +/// Rounded up, toward positive infinity, so a fraction of a base unit always +/// goes to the pool: funding the trader pays rounds up to the next whole unit +/// and funding the trader receives rounds down. The side's sign is applied +/// before dividing, so a short is rounded the same way as a long. pub fn position_funding( side: Side, size: u64, @@ -274,16 +282,27 @@ pub fn position_funding( let funding_change = pool_funding .checked_sub(entry_funding) .ok_or(PerpError::MathOverflow)?; - let long_owed = (size as i128) - .checked_mul(funding_change) - .ok_or(PerpError::MathOverflow)? - .checked_div(FUNDING_PRECISION) + // Longs owe the index's rise and shorts its fall. + let owed_change = match side { + Side::Long => funding_change, + Side::Short => funding_change + .checked_neg() + .ok_or(PerpError::MathOverflow)?, + }; + let numerator = (size as i128) + .checked_mul(owed_change) .ok_or(PerpError::MathOverflow)?; - - Ok(match side { - Side::Long => long_owed, - Side::Short => -long_owed, - }) + let floored = numerator + .checked_div_euclid(FUNDING_PRECISION) + .ok_or(PerpError::MathOverflow)?; + let remainder = numerator + .checked_rem_euclid(FUNDING_PRECISION) + .ok_or(PerpError::MathOverflow)?; + if remainder == 0 { + Ok(floored) + } else { + floored.checked_add(1).ok_or(PerpError::MathOverflow.into()) + } } /// `basis_points` of `amount`, rounded up: the open, close and liquidation diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/tests/test_perpetual_futures.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/tests/test_perpetual_futures.rs index e41ffd71d..e4600159a 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/tests/test_perpetual_futures.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/tests/test_perpetual_futures.rs @@ -8,7 +8,9 @@ use { errors::PerpError, instructions::{ initialize_pool::PoolParameters, - shared::{basis_points_of, basis_points_of_rounded_down}, + shared::{ + basis_points_of, basis_points_of_rounded_down, position_funding, position_pnl, + }, }, state::{Pool, Position, Side}, }, @@ -2127,6 +2129,59 @@ fn test_basis_points_of_rounds_up_and_the_insurance_split_rounds_down() { assert_eq!(basis_points_of_rounded_down(1, 5_000).unwrap(), 0); } +/// `position_pnl` floors toward negative infinity rather than truncating +/// toward zero. A position of 1,000 base units entered at 3 and marked at 2 +/// has lost 333.33 base units: truncation would book a loss of 333, floor +/// books 334, so the fraction goes to the pool. A profit of 333.33 still +/// books 333, the same as truncation, and a short is floored the same way. +#[test] +fn test_position_pnl_rounds_against_the_trader() { + assert_eq!(position_pnl(Side::Long, 1_000, 3, 2).unwrap(), -334); + assert_eq!(position_pnl(Side::Long, 1_000, 3, 4).unwrap(), 333); + assert_eq!(position_pnl(Side::Short, 1_000, 3, 4).unwrap(), -334); + assert_eq!(position_pnl(Side::Short, 1_000, 3, 2).unwrap(), 333); + // A whole number of base units is unchanged. + assert_eq!(position_pnl(Side::Long, 1_000, 4, 2).unwrap(), -500); +} + +/// `position_funding` rounds up, toward positive infinity, so a fraction of a +/// base unit goes to the pool. A funding index that moves 1,500,000 (in +/// `FUNDING_PRECISION` units of 10^9) on a position of 1,000 base units is +/// 1.5 base units. A trader who pays is charged 2, where truncation would +/// charge 1; a trader who is paid receives 1, the same as truncation. A short +/// is rounded the same way as a long: truncating before applying its sign +/// would have charged a paying short 1. +#[test] +fn test_position_funding_rounds_against_the_trader() { + // The index rises: longs pay, shorts are paid. + assert_eq!( + position_funding(Side::Long, 1_000, 0, 1_500_000).unwrap(), + 2 + ); + assert_eq!( + position_funding(Side::Short, 1_000, 0, 1_500_000).unwrap(), + -1 + ); + // The index falls: shorts pay, longs are paid. + assert_eq!( + position_funding(Side::Short, 1_000, 0, -1_500_000).unwrap(), + 2 + ); + assert_eq!( + position_funding(Side::Long, 1_000, 0, -1_500_000).unwrap(), + -1 + ); + // A whole number of base units is unchanged. + assert_eq!( + position_funding(Side::Long, 1_000, 0, 2_000_000).unwrap(), + 2 + ); + assert_eq!( + position_funding(Side::Short, 1_000, 0, 2_000_000).unwrap(), + -2 + ); +} + #[test] fn test_initialize_pool_rejects_insurance_fee_at_or_above_full_fee() { let with_insurance_fee = |insurance_fee_bps| PoolParameters { diff --git a/finance/perpetual-futures/quasar/src/instructions/shared.rs b/finance/perpetual-futures/quasar/src/instructions/shared.rs index 2b5b0cfae..b77e063ee 100644 --- a/finance/perpetual-futures/quasar/src/instructions/shared.rs +++ b/finance/perpetual-futures/quasar/src/instructions/shared.rs @@ -209,9 +209,13 @@ pub fn position_pnl( entry.checked_sub(price) } .ok_or_else(overflow)?; + // Multiply before dividing to keep precision; `entry > 0` is guaranteed. + // Floored toward negative infinity rather than truncated toward zero, so a + // loss that is not a whole base unit rounds up to the next one and a + // profit rounds down: the trader's result is always the lower of the two. size.checked_mul(price_change) .ok_or_else(overflow)? - .checked_div(entry) + .checked_div_euclid(entry) .ok_or_else(overflow) } @@ -334,6 +338,13 @@ pub fn credit_fee(pool: &mut Account, fee: u64) -> Result<(), ProgramError Ok(()) } +/// Funding a position owes since it opened, in collateral base units. Positive +/// means the trader pays the pool; negative means the pool pays the trader. +/// +/// Rounded up, toward positive infinity, so a fraction of a base unit always +/// goes to the pool: funding the trader pays rounds up to the next whole unit +/// and funding the trader receives rounds down. The side's sign is applied +/// before dividing, so a short is rounded the same way as a long. pub fn position_funding( side: u8, size: u64, @@ -343,16 +354,26 @@ pub fn position_funding( let funding_change = pool_funding .checked_sub(entry_funding) .ok_or_else(overflow)?; - let long_owed = (size as i128) - .checked_mul(funding_change) - .ok_or_else(overflow)? - .checked_div(FUNDING_PRECISION) + // Longs owe the index's rise and shorts its fall. + let owed_change = if side == SIDE_LONG { + funding_change + } else { + funding_change.checked_neg().ok_or_else(overflow)? + }; + let numerator = (size as i128) + .checked_mul(owed_change) .ok_or_else(overflow)?; - Ok(if side == SIDE_LONG { - long_owed + let floored = numerator + .checked_div_euclid(FUNDING_PRECISION) + .ok_or_else(overflow)?; + let remainder = numerator + .checked_rem_euclid(FUNDING_PRECISION) + .ok_or_else(overflow)?; + if remainder == 0 { + Ok(floored) } else { - -long_owed - }) + floored.checked_add(1).ok_or_else(overflow) + } } /// `basis_points` of `amount`, rounded up: the open, close and liquidation diff --git a/finance/perpetual-futures/quasar/src/tests.rs b/finance/perpetual-futures/quasar/src/tests.rs index 5656abcb5..7700ac2aa 100644 --- a/finance/perpetual-futures/quasar/src/tests.rs +++ b/finance/perpetual-futures/quasar/src/tests.rs @@ -11,7 +11,9 @@ use { InitializePoolInstruction, LiquidatePositionInstruction, OpenPositionInstruction, RemoveLiquidityInstruction, UpdatePriceAverageInstruction, }, - instructions::shared::{basis_points_of, basis_points_of_rounded_down, error}, + instructions::shared::{ + basis_points_of, basis_points_of_rounded_down, error, position_funding, position_pnl, + }, state::{Pool, Position}, LpMintPda, VaultPda, }, @@ -1348,6 +1350,53 @@ fn basis_points_of_rounds_up_and_the_insurance_split_rounds_down() { assert_eq!(basis_points_of_rounded_down(1, 5_000).unwrap(), 0); } +/// `position_pnl` floors toward negative infinity rather than truncating +/// toward zero. A position of 1,000 base units entered at 3 and marked at 2 +/// has lost 333.33 base units: truncation would book a loss of 333, floor +/// books 334, so the fraction goes to the pool. A profit of 333.33 still +/// books 333, the same as truncation, and a short is floored the same way. +#[test] +fn position_pnl_rounds_against_the_trader() { + assert_eq!(position_pnl(SIDE_LONG, 1_000, 3, 2).unwrap(), -334); + assert_eq!(position_pnl(SIDE_LONG, 1_000, 3, 4).unwrap(), 333); + assert_eq!(position_pnl(SIDE_SHORT, 1_000, 3, 4).unwrap(), -334); + assert_eq!(position_pnl(SIDE_SHORT, 1_000, 3, 2).unwrap(), 333); + // A whole number of base units is unchanged. + assert_eq!(position_pnl(SIDE_LONG, 1_000, 4, 2).unwrap(), -500); +} + +/// `position_funding` rounds up, toward positive infinity, so a fraction of a +/// base unit goes to the pool. A funding index that moves 1,500,000 (in +/// `FUNDING_PRECISION` units of 10^9) on a position of 1,000 base units is +/// 1.5 base units. A trader who pays is charged 2, where truncation would +/// charge 1; a trader who is paid receives 1, the same as truncation. A short +/// is rounded the same way as a long: truncating before applying its sign +/// would have charged a paying short 1. +#[test] +fn position_funding_rounds_against_the_trader() { + // The index rises: longs pay, shorts are paid. + assert_eq!(position_funding(SIDE_LONG, 1_000, 0, 1_500_000).unwrap(), 2); + assert_eq!( + position_funding(SIDE_SHORT, 1_000, 0, 1_500_000).unwrap(), + -1 + ); + // The index falls: shorts pay, longs are paid. + assert_eq!( + position_funding(SIDE_SHORT, 1_000, 0, -1_500_000).unwrap(), + 2 + ); + assert_eq!( + position_funding(SIDE_LONG, 1_000, 0, -1_500_000).unwrap(), + -1 + ); + // A whole number of base units is unchanged. + assert_eq!(position_funding(SIDE_LONG, 1_000, 0, 2_000_000).unwrap(), 2); + assert_eq!( + position_funding(SIDE_SHORT, 1_000, 0, 2_000_000).unwrap(), + -2 + ); +} + #[quasar_test] fn initialize_pool_rejects_insurance_fee_at_or_above_full_fee(test: &mut Test) { add_pool_prerequisites(test); diff --git a/finance/prop-amm/anchor-v1/CHANGELOG.md b/finance/prop-amm/anchor-v1/CHANGELOG.md index 9ec045474..3717bb653 100644 --- a/finance/prop-amm/anchor-v1/CHANGELOG.md +++ b/finance/prop-amm/anchor-v1/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## Unreleased, 2026-10-07 + +Add `close_market`. The market account and its two vaults had no close +handler, so the rent the operator paid for all three at `initialize_market` +could never be recovered. The new handler is signed by the operator, under the +same `has_one = operator` constraint as `withdraw_inventory`; it refuses with the new +`InventoryNotEmpty` while either vault holds tokens (the operator withdraws +first), closes both vaults with the market's own seeds, and closes the market +account through `close = operator`, so all three rents return to the operator. +Tested by `test_close_market_returns_all_three_rents` (rent back to the +lamport, all three accounts gone), `test_close_market_refuses_while_a_vault_holds_tokens` +(each vault on its own) and `test_close_market_rejects_non_operator` +(`ConstraintHasOne`). + +Four refusals had no test. `test_swap_rejects_non_positive_price` (a zero and +a negative price), `test_swap_rejects_oracle_scale_mismatch` (a market pinned +at scale 6 against a feed at 8), `test_swap_rejects_oracle_data_too_short` (the +feed account cut to 56 bytes, still owned by the oracle program) and +`test_swap_rejects_amount_that_rounds_to_zero` (a 1-minor-unit USDC buy) now +assert `NonPositivePrice`, `OracleScaleMismatch`, `OracleDataTooShort` and +`AmountRoundsToZero`. + ## Unreleased, 2026-10-05 The LiteSVM suite mints NVDAx with 8 decimals, its real count, and USDC with diff --git a/finance/prop-amm/anchor-v1/README.md b/finance/prop-amm/anchor-v1/README.md index 85e5514ac..0b5bce2b1 100644 --- a/finance/prop-amm/anchor-v1/README.md +++ b/finance/prop-amm/anchor-v1/README.md @@ -19,7 +19,7 @@ via Jupiter routing rather than their own user interfaces. ## Programs - **`prop-amm`**: the market. One operator, one base/quote pair, one oracle - feed, two vaults, five instruction handlers. + feed, two vaults, six instruction handlers. - **`mock-price-feed`**: a minimal stand-in for an oracle's price feed, so tests can drive deterministic price scenarios. Not for production. @@ -135,6 +135,14 @@ AMM gets from one price to another. market still exists but rejects fills: an empty prop AMM refuses rather than misprices. +### Step 8: Maria closes the market + +`close_market` closes both vaults and the `Market` account and returns all +three rents to Maria, who paid them at `initialize_market`. Only the operator +can call it (`has_one = operator`, as on `withdraw_inventory`), and it refuses with +`InventoryNotEmpty` while either vault holds a single minor unit, including +tokens someone sent straight to a vault: she withdraws them first. + ## Design notes and further reading - Production prop AMMs on Solana are closed-source and considerably more @@ -167,7 +175,17 @@ both directions, the exact round-trip spread, oracle repricing and re-quoting, and that every gate shuts: slippage, staleness, a price from before a cluster restart, confidence, a feed account owned by another program (`test_swap_rejects_price_feed_from_another_program`), pause, zero amounts, -inventory bounds, parameter bounds, and operator access control. Every +inventory bounds, parameter bounds, and operator access control. The +oracle reader's layout and value checks each have a test that drives a swap +into them: a zero or negative price (`test_swap_rejects_non_positive_price`), +a feed at another scale than the market pinned +(`test_swap_rejects_oracle_scale_mismatch`), and a feed account too short to +decode (`test_swap_rejects_oracle_data_too_short`); so does a buy too small +to deliver one minor unit (`test_swap_rejects_amount_that_rounds_to_zero`). +`test_close_market_returns_all_three_rents` closes an emptied market and +checks the operator gets the three rents back to the lamport; +`test_close_market_refuses_while_a_vault_holds_tokens` and +`test_close_market_rejects_non_operator` cover its refusals. Every refusal test asserts its error code: `assert_fails_with` for the program's own errors and `assert_fails_with_anchor_error` for the constraint that keeps the operator's instructions to the operator. diff --git a/finance/prop-amm/anchor-v1/programs/prop-amm/src/errors.rs b/finance/prop-amm/anchor-v1/programs/prop-amm/src/errors.rs index 18a8f2528..939de06c5 100644 --- a/finance/prop-amm/anchor-v1/programs/prop-amm/src/errors.rs +++ b/finance/prop-amm/anchor-v1/programs/prop-amm/src/errors.rs @@ -46,4 +46,7 @@ pub enum PropAmmError { #[msg("Price feed is not owned by the oracle program the market recorded")] PriceFeedNotFromOracle, + + #[msg("Withdraw all inventory from both vaults before closing the market")] + InventoryNotEmpty, } diff --git a/finance/prop-amm/anchor-v1/programs/prop-amm/src/instructions/close_market.rs b/finance/prop-amm/anchor-v1/programs/prop-amm/src/instructions/close_market.rs new file mode 100644 index 000000000..c824d3d76 --- /dev/null +++ b/finance/prop-amm/anchor-v1/programs/prop-amm/src/instructions/close_market.rs @@ -0,0 +1,94 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{close_account, CloseAccount, TokenAccount, TokenInterface}; + +use crate::constants::{BASE_VAULT_SEED, MARKET_SEED, QUOTE_VAULT_SEED}; +use crate::errors::PropAmmError; +use crate::state::Market; + +/// The operator shuts the market down and takes back the rent it paid for the +/// market account and its two vaults. +/// +/// Only the operator can close, through the same `has_one = operator` +/// constraint that guards `withdraw_inventory`. Both vaults must be empty: the +/// tokens in them are the operator's inventory, and closing a token account +/// that still holds tokens fails, so the handler refuses with +/// `InventoryNotEmpty` and the operator withdraws first. That includes any +/// tokens someone transferred straight into a vault; they are inventory like +/// the rest, and `withdraw_inventory` pays them out. The market account then +/// closes through its `close = operator` constraint, so all three rents go +/// back to the operator. +pub fn handle_close_market(context: Context) -> Result<()> { + require!( + context.accounts.base_vault.amount == 0, + PropAmmError::InventoryNotEmpty + ); + require!( + context.accounts.quote_vault.amount == 0, + PropAmmError::InventoryNotEmpty + ); + + // The market owns both vaults and signs their closure with its own seeds. + let market = &context.accounts.market; + let market_bump = [market.bump]; + let market_seeds: &[&[u8]] = &[ + MARKET_SEED, + market.base_mint.as_ref(), + market.quote_mint.as_ref(), + &market_bump, + ]; + + close_account(CpiContext::new_with_signer( + context.accounts.token_program.key(), + CloseAccount { + account: context.accounts.base_vault.to_account_info(), + destination: context.accounts.operator.to_account_info(), + authority: context.accounts.market.to_account_info(), + }, + &[market_seeds], + ))?; + + close_account(CpiContext::new_with_signer( + context.accounts.token_program.key(), + CloseAccount { + account: context.accounts.quote_vault.to_account_info(), + destination: context.accounts.operator.to_account_info(), + authority: context.accounts.market.to_account_info(), + }, + &[market_seeds], + ))?; + + Ok(()) +} + +#[derive(Accounts)] +pub struct CloseMarketAccountConstraints<'info> { + #[account(mut)] + pub operator: Signer<'info>, + + #[account( + mut, + seeds = [MARKET_SEED, market.base_mint.as_ref(), market.quote_mint.as_ref()], + bump = market.bump, + has_one = operator, + has_one = base_vault, + has_one = quote_vault, + close = operator, + )] + pub market: Box>, + + #[account( + mut, + seeds = [BASE_VAULT_SEED, market.key().as_ref()], + bump, + )] + pub base_vault: Box>, + + #[account( + mut, + seeds = [QUOTE_VAULT_SEED, market.key().as_ref()], + bump, + )] + pub quote_vault: Box>, + + pub token_program: Interface<'info, TokenInterface>, +} diff --git a/finance/prop-amm/anchor-v1/programs/prop-amm/src/instructions/mod.rs b/finance/prop-amm/anchor-v1/programs/prop-amm/src/instructions/mod.rs index 8d4b5cafb..5c70da9d2 100644 --- a/finance/prop-amm/anchor-v1/programs/prop-amm/src/instructions/mod.rs +++ b/finance/prop-amm/anchor-v1/programs/prop-amm/src/instructions/mod.rs @@ -1,9 +1,11 @@ +pub mod close_market; pub mod deposit_inventory; pub mod initialize_market; pub mod set_quote; pub mod swap; pub mod withdraw_inventory; +pub use close_market::*; pub use deposit_inventory::*; pub use initialize_market::*; pub use set_quote::*; diff --git a/finance/prop-amm/anchor-v1/programs/prop-amm/src/lib.rs b/finance/prop-amm/anchor-v1/programs/prop-amm/src/lib.rs index 6770f0d09..995f5255e 100644 --- a/finance/prop-amm/anchor-v1/programs/prop-amm/src/lib.rs +++ b/finance/prop-amm/anchor-v1/programs/prop-amm/src/lib.rs @@ -70,6 +70,13 @@ pub mod prop_amm { instructions::handle_set_quote(context, spread_bps, paused) } + /// Operator closes the market and both vaults, recovering all three + /// rents. Refused while either vault holds tokens: the operator withdraws + /// the inventory first. + pub fn close_market(context: Context) -> Result<()> { + instructions::handle_close_market(context) + } + /// Swap against the operator's quote: buy the base token at oracle plus /// spread, or sell it at oracle minus spread. Permissionless. /// `minimum_amount_out` is slippage protection; pass `0` to opt out. diff --git a/finance/prop-amm/anchor-v1/programs/prop-amm/tests/test_prop_amm.rs b/finance/prop-amm/anchor-v1/programs/prop-amm/tests/test_prop_amm.rs index 85d524c7d..60c770495 100644 --- a/finance/prop-amm/anchor-v1/programs/prop-amm/tests/test_prop_amm.rs +++ b/finance/prop-amm/anchor-v1/programs/prop-amm/tests/test_prop_amm.rs @@ -501,6 +501,52 @@ impl Market { self.svm.set_account(self.feed, feed_account).unwrap(); } + /// Close the market signed by `signer` (the operator in honest tests, an + /// imposter in the access-control test). The payer covers the transaction + /// fee, so the signer's lamports move only by the rent the close returns. + fn close_market_as(&mut self, signer: &Keypair) -> Result<(), String> { + let instruction = Instruction::new_with_bytes( + prop_amm::id(), + &prop_amm::instruction::CloseMarket {}.data(), + prop_amm::accounts::CloseMarketAccountConstraints { + operator: signer.pubkey(), + market: self.market, + base_vault: self.base_vault, + quote_vault: self.quote_vault, + token_program: token_program_id(), + } + .to_account_metas(None), + ); + let payer = self.payer.insecure_clone(); + send_transaction_from_instructions( + &mut self.svm, + vec![instruction], + &[&payer, signer], + &payer.pubkey(), + ) + .map(|_| ()) + .map_err(|error| format!("{error:?}")) + } + + fn close_market(&mut self) -> Result<(), String> { + let operator = self.operator.insecure_clone(); + self.close_market_as(&operator) + } + + fn lamports(&self, address: &Pubkey) -> u64 { + self.svm + .get_account(address) + .map_or(0, |account| account.lamports) + } + + /// Cut the feed account's data to `length` bytes, keeping its owner, so + /// the market's owner check passes and only the layout check can refuse it. + fn truncate_feed(&mut self, length: usize) { + let mut feed_account = self.svm.get_account(&self.feed).unwrap(); + feed_account.data.truncate(length); + self.svm.set_account(self.feed, feed_account).unwrap(); + } + fn balance(&self, token_account: &Pubkey) -> u64 { get_token_account_balance(&self.svm, token_account).unwrap() } @@ -647,6 +693,79 @@ fn test_operator_can_withdraw_everything_and_swaps_then_fail() { ); } +/// Maria withdraws every token, then closes the market. The market account +/// and both vaults are gone, and the three rents she paid at +/// `initialize_market` come back to her to the lamport. +#[test] +fn test_close_market_returns_all_three_rents() { + let mut market = Market::default_market(); + market + .withdraw_inventory(1_000 * ONE_NVDAX, 200_000 * ONE_USDC) + .unwrap(); + + let operator = market.operator.pubkey(); + let operator_before = market.lamports(&operator); + let rents = market.lamports(&market.market) + + market.lamports(&market.base_vault) + + market.lamports(&market.quote_vault); + assert!(rents > 0); + + market.close_market().unwrap(); + + assert_eq!(market.lamports(&operator), operator_before + rents); + assert!(market.svm.get_account(&market.market).is_none()); + assert!(market.svm.get_account(&market.base_vault).is_none()); + assert!(market.svm.get_account(&market.quote_vault).is_none()); + // The inventory went back through `withdraw_inventory`, so the operator + // holds every token it minted. + let operator_base = market.operator_base; + let operator_quote = market.operator_quote; + assert_eq!(market.balance(&operator_base), 10_000 * ONE_NVDAX); + assert_eq!(market.balance(&operator_quote), 10_000_000 * ONE_USDC); +} + +/// The market cannot close while either vault holds a single minor unit: the +/// operator withdraws first. Each vault's check is exercised on its own. +#[test] +fn test_close_market_refuses_while_a_vault_holds_tokens() { + let mut market = Market::default_market(); + assert_fails_with(market.close_market(), PropAmmError::InventoryNotEmpty); + + // Each retry is otherwise byte-identical to the refused close, so it would + // carry the same signature and be dropped as already processed; a fresh + // blockhash gives it a new one. + + // Base vault empty, quote vault still stocked. + market.withdraw_inventory(1_000 * ONE_NVDAX, 0).unwrap(); + market.svm.expire_blockhash(); + assert_fails_with(market.close_market(), PropAmmError::InventoryNotEmpty); + + // Quote vault empty, one minor unit of base back in the base vault. + market.withdraw_inventory(0, 200_000 * ONE_USDC).unwrap(); + market.deposit_inventory(1, 0).unwrap(); + market.svm.expire_blockhash(); + assert_fails_with(market.close_market(), PropAmmError::InventoryNotEmpty); + assert!(market.svm.get_account(&market.market).is_some()); + + market.withdraw_inventory(1, 0).unwrap(); + market.svm.expire_blockhash(); + market.close_market().expect("an empty market must close"); +} + +#[test] +fn test_close_market_rejects_non_operator() { + let mut market = Market::default_market(); + market + .withdraw_inventory(1_000 * ONE_NVDAX, 200_000 * ONE_USDC) + .unwrap(); + let (mallory, _, _) = market.funded_trader(0, 0); + assert_fails_with_anchor_error( + market.close_market_as(&mallory), + AnchorErrorCode::ConstraintHasOne, + ); + assert!(market.svm.get_account(&market.market).is_some()); +} + #[test] fn test_withdraw_more_than_inventory_fails() { let mut market = Market::default_market(); @@ -794,6 +913,77 @@ fn test_swap_rejects_wide_confidence() { ); } +/// A zero or negative oracle price is not a price. The market refuses to +/// quote against either rather than divide by it or flip the spread. +#[test] +fn test_swap_rejects_non_positive_price() { + let mut market = Market::default_market(); + let (alice, _, _) = market.funded_trader(0, FIVE_NVDAX_AT_THE_ASK); + + market.set_price(0); + assert_fails_with( + market.swap(&alice, Direction::BuyBase, FIVE_NVDAX_AT_THE_ASK, 0), + PropAmmError::NonPositivePrice, + ); + + // The retry is otherwise byte-identical to the rejected swap, so it would + // carry the same signature and be dropped as already processed. + market.svm.expire_blockhash(); + market.set_price(-dollars(165)); + assert_fails_with( + market.swap(&alice, Direction::BuyBase, FIVE_NVDAX_AT_THE_ASK, 0), + PropAmmError::NonPositivePrice, + ); +} + +/// A market created for a feed with 6 decimals of scale refuses a feed that +/// reports 8: read at the wrong scale, $165 would be $16,500. +#[test] +fn test_swap_rejects_oracle_scale_mismatch() { + let parameters = MarketParameters { + oracle_scale: ORACLE_SCALE - 2, + spread_bps: SPREAD_BPS, + max_confidence_bps: MAX_CONFIDENCE_BPS, + }; + let mut market = Market::try_new(dollars(165), parameters).unwrap(); + market + .deposit_inventory(1_000 * ONE_NVDAX, 200_000 * ONE_USDC) + .unwrap(); + let (alice, _, _) = market.funded_trader(0, FIVE_NVDAX_AT_THE_ASK); + assert_fails_with( + market.swap(&alice, Direction::BuyBase, FIVE_NVDAX_AT_THE_ASK, 0), + PropAmmError::OracleScaleMismatch, + ); +} + +/// A feed account owned by the recorded oracle program but too short to hold +/// the price layout is refused before a byte of it is decoded. +#[test] +fn test_swap_rejects_oracle_data_too_short() { + let mut market = Market::default_market(); + let (alice, _, _) = market.funded_trader(0, FIVE_NVDAX_AT_THE_ASK); + // The layout needs 76 bytes; keep the discriminator, authority and price. + market.truncate_feed(56); + assert_fails_with( + market.swap(&alice, Direction::BuyBase, FIVE_NVDAX_AT_THE_ASK, 0), + PropAmmError::OracleDataTooShort, + ); +} + +/// One minor unit of USDC (0.000001) at the $165.165 ask buys 0.0000000060546 +/// NVDAx, which floors to zero minor units. The market refuses rather than +/// take the trader's input for nothing. +#[test] +fn test_swap_rejects_amount_that_rounds_to_zero() { + let mut market = Market::default_market(); + let (alice, _, alice_quote) = market.funded_trader(0, 1); + assert_fails_with( + market.swap(&alice, Direction::BuyBase, 1, 0), + PropAmmError::AmountRoundsToZero, + ); + assert_eq!(market.balance(&alice_quote), 1); +} + /// While the operator has pulled its quotes, nobody can swap. #[test] fn test_swap_rejects_when_paused() { diff --git a/finance/prop-amm/anchor/CHANGELOG.md b/finance/prop-amm/anchor/CHANGELOG.md index 4adfbdf59..081ca59fb 100644 --- a/finance/prop-amm/anchor/CHANGELOG.md +++ b/finance/prop-amm/anchor/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## Unreleased, 2026-10-07 + +Add `close_market`. The market account and its two vaults had no close +handler, so the rent the operator paid for all three at `initialize_market` +could never be recovered. The new handler is signed by the operator, under the +same `address = market.operator` constraint as `withdraw_inventory`; it refuses with the new +`InventoryNotEmpty` while either vault holds tokens (the operator withdraws +first), closes both vaults with the market's own seeds, and closes the market +account through `close = operator`, so all three rents return to the operator. +Tested by `test_close_market_returns_all_three_rents` (rent back to the +lamport, all three accounts gone), `test_close_market_refuses_while_a_vault_holds_tokens` +(each vault on its own) and `test_close_market_rejects_non_operator` +(`ConstraintAddress`). + +Four refusals had no test. `test_swap_rejects_non_positive_price` (a zero and +a negative price), `test_swap_rejects_oracle_scale_mismatch` (a market pinned +at scale 6 against a feed at 8), `test_swap_rejects_oracle_data_too_short` (the +feed account cut to 56 bytes, still owned by the oracle program) and +`test_swap_rejects_amount_that_rounds_to_zero` (a 1-minor-unit USDC buy) now +assert `NonPositivePrice`, `OracleScaleMismatch`, `OracleDataTooShort` and +`AmountRoundsToZero`. + ## Unreleased, 2026-10-05 The LiteSVM suite mints NVDAx with 8 decimals, its real count, and USDC with diff --git a/finance/prop-amm/anchor/README.md b/finance/prop-amm/anchor/README.md index 12e087b5e..b4235378e 100644 --- a/finance/prop-amm/anchor/README.md +++ b/finance/prop-amm/anchor/README.md @@ -19,7 +19,7 @@ via Jupiter routing rather than their own user interfaces. ## Programs - **`prop-amm`**: the market. One operator, one base/quote pair, one oracle - feed, two vaults, five instruction handlers. + feed, two vaults, six instruction handlers. - **`mock-price-feed`**: a minimal stand-in for an oracle's price feed, so tests can drive deterministic price scenarios. Not for production. @@ -135,6 +135,14 @@ AMM gets from one price to another. market still exists but rejects fills: an empty prop AMM refuses rather than misprices. +### Step 8: Maria closes the market + +`close_market` closes both vaults and the `Market` account and returns all +three rents to Maria, who paid them at `initialize_market`. Only the operator +can call it (`address = market.operator`, as on `withdraw_inventory`), and it refuses with +`InventoryNotEmpty` while either vault holds a single minor unit, including +tokens someone sent straight to a vault: she withdraws them first. + ## Design notes and further reading - Production prop AMMs on Solana are closed-source and considerably more @@ -167,7 +175,17 @@ both directions, the exact round-trip spread, oracle repricing and re-quoting, and that every gate shuts: slippage, staleness, a price from before a cluster restart, confidence, a feed account owned by another program (`test_swap_rejects_price_feed_from_another_program`), pause, zero amounts, -inventory bounds, parameter bounds, and operator access control. Every +inventory bounds, parameter bounds, and operator access control. The +oracle reader's layout and value checks each have a test that drives a swap +into them: a zero or negative price (`test_swap_rejects_non_positive_price`), +a feed at another scale than the market pinned +(`test_swap_rejects_oracle_scale_mismatch`), and a feed account too short to +decode (`test_swap_rejects_oracle_data_too_short`); so does a buy too small +to deliver one minor unit (`test_swap_rejects_amount_that_rounds_to_zero`). +`test_close_market_returns_all_three_rents` closes an emptied market and +checks the operator gets the three rents back to the lamport; +`test_close_market_refuses_while_a_vault_holds_tokens` and +`test_close_market_rejects_non_operator` cover its refusals. Every refusal test asserts its error code: `assert_fails_with` for the program's own errors and `assert_fails_with_anchor_error` for the constraint that keeps the operator's instructions to the operator. diff --git a/finance/prop-amm/anchor/programs/prop-amm/src/errors.rs b/finance/prop-amm/anchor/programs/prop-amm/src/errors.rs index 18a8f2528..939de06c5 100644 --- a/finance/prop-amm/anchor/programs/prop-amm/src/errors.rs +++ b/finance/prop-amm/anchor/programs/prop-amm/src/errors.rs @@ -46,4 +46,7 @@ pub enum PropAmmError { #[msg("Price feed is not owned by the oracle program the market recorded")] PriceFeedNotFromOracle, + + #[msg("Withdraw all inventory from both vaults before closing the market")] + InventoryNotEmpty, } diff --git a/finance/prop-amm/anchor/programs/prop-amm/src/instructions/close_market.rs b/finance/prop-amm/anchor/programs/prop-amm/src/instructions/close_market.rs new file mode 100644 index 000000000..7f8525ab3 --- /dev/null +++ b/finance/prop-amm/anchor/programs/prop-amm/src/instructions/close_market.rs @@ -0,0 +1,105 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{close_account, CloseAccount, TokenAccount, TokenInterface}; + +use crate::constants::{BASE_VAULT_SEED, MARKET_SEED, QUOTE_VAULT_SEED}; +use crate::errors::PropAmmError; +use crate::state::Market; + +/// The operator shuts the market down and takes back the rent it paid for the +/// market account and its two vaults. +/// +/// Only the operator can close, through the same `address = market.operator` +/// constraint that guards `withdraw_inventory`. Both vaults must be empty: the +/// tokens in them are the operator's inventory, and closing a token account +/// that still holds tokens fails, so the handler refuses with +/// `InventoryNotEmpty` and the operator withdraws first. That includes any +/// tokens someone transferred straight into a vault; they are inventory like +/// the rest, and `withdraw_inventory` pays them out. The market account then +/// closes through its `close = operator` constraint, so all three rents go +/// back to the operator. +pub fn handle_close_market(context: &mut Context) -> Result<()> { + require!( + context.accounts.base_vault.amount() == 0, + PropAmmError::InventoryNotEmpty + ); + require!( + context.accounts.quote_vault.amount() == 0, + PropAmmError::InventoryNotEmpty + ); + + // Read the seeds before releasing the market's borrow below. + let base_mint = context.accounts.market.base_mint; + let quote_mint = context.accounts.market.quote_mint; + let market_bump = [context.accounts.market.bump]; + let market_seeds: &[&[u8]] = &[ + MARKET_SEED, + base_mint.as_ref(), + quote_mint.as_ref(), + &market_bump, + ]; + + // The market signs both CPIs below. It is a writable data account holding + // a live borrow on its buffer, so release it across the CPIs: the runtime + // rejects a CPI that borrows an account we still hold. Take it back after. + context.accounts.market.release_borrow()?; + let market_view = *context.accounts.market.account(); + + close_account(CpiContext::new_with_signer( + context.accounts.token_program.address(), + CloseAccount { + account: context.accounts.base_vault.to_cpi_handle_mut(), + destination: context.accounts.operator.cpi_handle_mut(), + authority: CpiHandle::readonly(&market_view), + }, + &[market_seeds], + ))?; + + close_account(CpiContext::new_with_signer( + context.accounts.token_program.address(), + CloseAccount { + account: context.accounts.quote_vault.to_cpi_handle_mut(), + destination: context.accounts.operator.cpi_handle_mut(), + authority: CpiHandle::readonly(&market_view), + }, + &[market_seeds], + ))?; + + // Take the borrow back before the derive's exit path closes the market. + context.accounts.market.reacquire_borrow_mut()?; + + Ok(()) +} + +#[derive(Accounts)] +pub struct CloseMarketAccountConstraints { + // `address = market.operator` is the access control, as on + // `withdraw_inventory`: only the firm that opened the market closes it. + #[account(mut, address = market.operator)] + pub operator: Signer, + + #[account( + mut, + seeds = [MARKET_SEED, market.base_mint.as_ref(), market.quote_mint.as_ref()], + bump = market.bump, + close = operator, + )] + pub market: Box>, + + #[account( + mut, + seeds = [BASE_VAULT_SEED, market.address().as_ref()], + bump, + address = market.base_vault, + )] + pub base_vault: Box>, + + #[account( + mut, + seeds = [QUOTE_VAULT_SEED, market.address().as_ref()], + bump, + address = market.quote_vault, + )] + pub quote_vault: Box>, + + pub token_program: Interface<'static, TokenInterface>, +} diff --git a/finance/prop-amm/anchor/programs/prop-amm/src/instructions/mod.rs b/finance/prop-amm/anchor/programs/prop-amm/src/instructions/mod.rs index 8d4b5cafb..5c70da9d2 100644 --- a/finance/prop-amm/anchor/programs/prop-amm/src/instructions/mod.rs +++ b/finance/prop-amm/anchor/programs/prop-amm/src/instructions/mod.rs @@ -1,9 +1,11 @@ +pub mod close_market; pub mod deposit_inventory; pub mod initialize_market; pub mod set_quote; pub mod swap; pub mod withdraw_inventory; +pub use close_market::*; pub use deposit_inventory::*; pub use initialize_market::*; pub use set_quote::*; diff --git a/finance/prop-amm/anchor/programs/prop-amm/src/lib.rs b/finance/prop-amm/anchor/programs/prop-amm/src/lib.rs index 4c8760f26..693493eb9 100644 --- a/finance/prop-amm/anchor/programs/prop-amm/src/lib.rs +++ b/finance/prop-amm/anchor/programs/prop-amm/src/lib.rs @@ -71,6 +71,13 @@ pub mod prop_amm { instructions::handle_set_quote(context, spread_bps, paused) } + /// Operator closes the market and both vaults, recovering all three + /// rents. Refused while either vault holds tokens: the operator withdraws + /// the inventory first. + pub fn close_market(context: &mut Context) -> Result<()> { + instructions::handle_close_market(context) + } + /// Swap against the operator's quote: buy the base token at oracle plus /// spread, or sell it at oracle minus spread. Permissionless. /// `minimum_amount_out` is slippage protection; pass `0` to opt out. diff --git a/finance/prop-amm/anchor/programs/prop-amm/tests/test_prop_amm.rs b/finance/prop-amm/anchor/programs/prop-amm/tests/test_prop_amm.rs index 2be74af7f..a10266a55 100644 --- a/finance/prop-amm/anchor/programs/prop-amm/tests/test_prop_amm.rs +++ b/finance/prop-amm/anchor/programs/prop-amm/tests/test_prop_amm.rs @@ -500,6 +500,52 @@ impl Market { self.svm.set_account(self.feed, feed_account).unwrap(); } + /// Close the market signed by `signer` (the operator in honest tests, an + /// imposter in the access-control test). The payer covers the transaction + /// fee, so the signer's lamports move only by the rent the close returns. + fn close_market_as(&mut self, signer: &Keypair) -> Result<(), String> { + let instruction = Instruction::new_with_bytes( + prop_amm::id(), + &prop_amm::instruction::CloseMarket {}.data(), + prop_amm::accounts::CloseMarketAccountConstraints { + operator: signer.pubkey(), + market: self.market, + base_vault: self.base_vault, + quote_vault: self.quote_vault, + token_program: token_program_id(), + } + .to_account_metas(None), + ); + let payer = self.payer.insecure_clone(); + send_transaction_from_instructions( + &mut self.svm, + vec![instruction], + &[&payer, signer], + &payer.pubkey(), + ) + .map(|_| ()) + .map_err(|error| format!("{error:?}")) + } + + fn close_market(&mut self) -> Result<(), String> { + let operator = self.operator.insecure_clone(); + self.close_market_as(&operator) + } + + fn lamports(&self, address: &Address) -> u64 { + self.svm + .get_account(address) + .map_or(0, |account| account.lamports) + } + + /// Cut the feed account's data to `length` bytes, keeping its owner, so + /// the market's owner check passes and only the layout check can refuse it. + fn truncate_feed(&mut self, length: usize) { + let mut feed_account = self.svm.get_account(&self.feed).unwrap(); + feed_account.data.truncate(length); + self.svm.set_account(self.feed, feed_account).unwrap(); + } + fn balance(&self, token_account: &Address) -> u64 { get_token_account_balance(&self.svm, token_account).unwrap() } @@ -646,6 +692,79 @@ fn test_operator_can_withdraw_everything_and_swaps_then_fail() { ); } +/// Maria withdraws every token, then closes the market. The market account +/// and both vaults are gone, and the three rents she paid at +/// `initialize_market` come back to her to the lamport. +#[test] +fn test_close_market_returns_all_three_rents() { + let mut market = Market::default_market(); + market + .withdraw_inventory(1_000 * ONE_NVDAX, 200_000 * ONE_USDC) + .unwrap(); + + let operator = market.operator.pubkey(); + let operator_before = market.lamports(&operator); + let rents = market.lamports(&market.market) + + market.lamports(&market.base_vault) + + market.lamports(&market.quote_vault); + assert!(rents > 0); + + market.close_market().unwrap(); + + assert_eq!(market.lamports(&operator), operator_before + rents); + assert!(market.svm.get_account(&market.market).is_none()); + assert!(market.svm.get_account(&market.base_vault).is_none()); + assert!(market.svm.get_account(&market.quote_vault).is_none()); + // The inventory went back through `withdraw_inventory`, so the operator + // holds every token it minted. + let operator_base = market.operator_base; + let operator_quote = market.operator_quote; + assert_eq!(market.balance(&operator_base), 10_000 * ONE_NVDAX); + assert_eq!(market.balance(&operator_quote), 10_000_000 * ONE_USDC); +} + +/// The market cannot close while either vault holds a single minor unit: the +/// operator withdraws first. Each vault's check is exercised on its own. +#[test] +fn test_close_market_refuses_while_a_vault_holds_tokens() { + let mut market = Market::default_market(); + assert_fails_with(market.close_market(), PropAmmError::InventoryNotEmpty); + + // Each retry is otherwise byte-identical to the refused close, so it would + // carry the same signature and be dropped as already processed; a fresh + // blockhash gives it a new one. + + // Base vault empty, quote vault still stocked. + market.withdraw_inventory(1_000 * ONE_NVDAX, 0).unwrap(); + market.svm.expire_blockhash(); + assert_fails_with(market.close_market(), PropAmmError::InventoryNotEmpty); + + // Quote vault empty, one minor unit of base back in the base vault. + market.withdraw_inventory(0, 200_000 * ONE_USDC).unwrap(); + market.deposit_inventory(1, 0).unwrap(); + market.svm.expire_blockhash(); + assert_fails_with(market.close_market(), PropAmmError::InventoryNotEmpty); + assert!(market.svm.get_account(&market.market).is_some()); + + market.withdraw_inventory(1, 0).unwrap(); + market.svm.expire_blockhash(); + market.close_market().expect("an empty market must close"); +} + +#[test] +fn test_close_market_rejects_non_operator() { + let mut market = Market::default_market(); + market + .withdraw_inventory(1_000 * ONE_NVDAX, 200_000 * ONE_USDC) + .unwrap(); + let (mallory, _, _) = market.funded_trader(0, 0); + assert_fails_with_anchor_error( + market.close_market_as(&mallory), + AnchorErrorCode::ConstraintAddress, + ); + assert!(market.svm.get_account(&market.market).is_some()); +} + #[test] fn test_withdraw_more_than_inventory_fails() { let mut market = Market::default_market(); @@ -791,6 +910,77 @@ fn test_swap_rejects_wide_confidence() { ); } +/// A zero or negative oracle price is not a price. The market refuses to +/// quote against either rather than divide by it or flip the spread. +#[test] +fn test_swap_rejects_non_positive_price() { + let mut market = Market::default_market(); + let (alice, _, _) = market.funded_trader(0, FIVE_NVDAX_AT_THE_ASK); + + market.set_price(0); + assert_fails_with( + market.swap(&alice, Direction::BuyBase, FIVE_NVDAX_AT_THE_ASK, 0), + PropAmmError::NonPositivePrice, + ); + + // The retry is otherwise byte-identical to the rejected swap, so it would + // carry the same signature and be dropped as already processed. + market.svm.expire_blockhash(); + market.set_price(-dollars(165)); + assert_fails_with( + market.swap(&alice, Direction::BuyBase, FIVE_NVDAX_AT_THE_ASK, 0), + PropAmmError::NonPositivePrice, + ); +} + +/// A market created for a feed with 6 decimals of scale refuses a feed that +/// reports 8: read at the wrong scale, $165 would be $16,500. +#[test] +fn test_swap_rejects_oracle_scale_mismatch() { + let parameters = MarketParameters { + oracle_scale: ORACLE_SCALE - 2, + spread_bps: SPREAD_BPS, + max_confidence_bps: MAX_CONFIDENCE_BPS, + }; + let mut market = Market::try_new(dollars(165), parameters).unwrap(); + market + .deposit_inventory(1_000 * ONE_NVDAX, 200_000 * ONE_USDC) + .unwrap(); + let (alice, _, _) = market.funded_trader(0, FIVE_NVDAX_AT_THE_ASK); + assert_fails_with( + market.swap(&alice, Direction::BuyBase, FIVE_NVDAX_AT_THE_ASK, 0), + PropAmmError::OracleScaleMismatch, + ); +} + +/// A feed account owned by the recorded oracle program but too short to hold +/// the price layout is refused before a byte of it is decoded. +#[test] +fn test_swap_rejects_oracle_data_too_short() { + let mut market = Market::default_market(); + let (alice, _, _) = market.funded_trader(0, FIVE_NVDAX_AT_THE_ASK); + // The layout needs 76 bytes; keep the discriminator, authority and price. + market.truncate_feed(56); + assert_fails_with( + market.swap(&alice, Direction::BuyBase, FIVE_NVDAX_AT_THE_ASK, 0), + PropAmmError::OracleDataTooShort, + ); +} + +/// One minor unit of USDC (0.000001) at the $165.165 ask buys 0.0000000060546 +/// NVDAx, which floors to zero minor units. The market refuses rather than +/// take the trader's input for nothing. +#[test] +fn test_swap_rejects_amount_that_rounds_to_zero() { + let mut market = Market::default_market(); + let (alice, _, alice_quote) = market.funded_trader(0, 1); + assert_fails_with( + market.swap(&alice, Direction::BuyBase, 1, 0), + PropAmmError::AmountRoundsToZero, + ); + assert_eq!(market.balance(&alice_quote), 1); +} + /// While the operator has pulled its quotes, nobody can swap. #[test] fn test_swap_rejects_when_paused() { diff --git a/finance/prop-amm/quasar/CHANGELOG.md b/finance/prop-amm/quasar/CHANGELOG.md index eb8dd6e42..29d53a699 100644 --- a/finance/prop-amm/quasar/CHANGELOG.md +++ b/finance/prop-amm/quasar/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## Unreleased, 2026-10-07 + +Add `close_market` (discriminator 5). The market account and its two vaults +had no close handler, so the rent the operator paid for all three at +`initialize_market` could never be recovered. The new handler is signed by the +operator, under the same `has_one(operator)` constraint as +`withdraw_inventory`; it refuses with the new `INVENTORY_NOT_EMPTY` (15) while +either vault holds tokens (the operator withdraws first), closes both vaults +with the market's own seeds, and closes the market account through +`close(dest = operator)`, so all three rents return to the operator. Tested by +`close_market_returns_all_three_rents` (rent back to the lamport, all three +accounts closed), `close_market_refuses_while_a_vault_holds_tokens` (each +vault on its own) and `close_market_rejects_non_operator` +(`QuasarError::HasOneMismatch`). + +Four refusals had no test. `swap_rejects_non_positive_price` (a zero and a +negative price), `swap_rejects_oracle_scale_mismatch` (a feed at scale 6 for a +market pinned at 8), `swap_rejects_oracle_data_too_short` (a 20-byte feed +account owned by the recorded program) and +`swap_rejects_amount_that_rounds_to_zero` (a 1-minor-unit USDC buy) now +assert `NON_POSITIVE_PRICE`, `ORACLE_SCALE_MISMATCH`, `ORACLE_DATA_TOO_SHORT` +and `AMOUNT_ROUNDS_TO_ZERO`. + ## Unreleased, 2026-10-05 The quasar-test suite mints NVDAx with 8 decimals, its real count, and USDC diff --git a/finance/prop-amm/quasar/README.md b/finance/prop-amm/quasar/README.md index 19af3f927..606177659 100644 --- a/finance/prop-amm/quasar/README.md +++ b/finance/prop-amm/quasar/README.md @@ -43,7 +43,17 @@ the quote math to the minor unit in both directions, the exact 1.65 USDC round-trip spread, oracle repricing and re-quoting, the operator's full exit, and that every gate shuts: slippage, staleness, restart handling, confidence, a feed account owned by another program, pause, zero amounts, inventory -bounds, parameter bounds, and operator access control. Every refusal test +bounds, parameter bounds, and operator access control. The oracle reader's +layout and value checks each have a test: a zero or negative price +(`swap_rejects_non_positive_price`), a feed at another scale +(`swap_rejects_oracle_scale_mismatch`), a feed account too short to decode +(`swap_rejects_oracle_data_too_short`), and a buy too small to deliver one +minor unit (`swap_rejects_amount_that_rounds_to_zero`). `close_market` +(discriminator 5) closes both vaults and the market and returns the three +rents to the operator, refusing with `INVENTORY_NOT_EMPTY` (15) while either +vault holds tokens; `close_market_returns_all_three_rents`, +`close_market_refuses_while_a_vault_holds_tokens` and +`close_market_rejects_non_operator` test it. Every refusal test asserts its error code with `fails_with`: the program's own codes from `instructions::shared::error`, and `QuasarError::HasOneMismatch` for the `has_one(operator)` constraint that refuses an imposter operator. diff --git a/finance/prop-amm/quasar/src/instructions/close_market.rs b/finance/prop-amm/quasar/src/instructions/close_market.rs new file mode 100644 index 000000000..3cab145da --- /dev/null +++ b/finance/prop-amm/quasar/src/instructions/close_market.rs @@ -0,0 +1,72 @@ +use { + crate::{ + instructions::shared::{err, error}, + state::Market, + }, + quasar_lang::cpi::Seed, + quasar_lang::prelude::*, + quasar_spl::prelude::*, +}; + +#[derive(Accounts)] +pub struct CloseMarket { + #[account(mut)] + pub operator: Signer, + #[account( + mut, + address = Market::seeds(base_mint.address(), quote_mint.address()), + has_one(operator), + has_one(base_vault), + has_one(quote_vault), + close(dest = operator), + )] + pub market: Account, + pub base_mint: Account, + pub quote_mint: Account, + #[account(mut)] + pub base_vault: Account, + #[account(mut)] + pub quote_vault: Account, + pub token_program: Program, +} + +/// The operator shuts the market down and takes back the rent it paid for the +/// market account and its two vaults. +/// +/// Only the operator can close, through the same `has_one(operator)` +/// constraint that guards `withdraw_inventory`. Both vaults must be empty: the +/// tokens in them are the operator's inventory, and closing a token account +/// that still holds tokens fails, so the handler refuses with +/// `INVENTORY_NOT_EMPTY` and the operator withdraws first. That includes any +/// tokens someone transferred straight into a vault; they are inventory like +/// the rest, and `withdraw_inventory` pays them out. The market account then +/// closes through its `close(dest = operator)` constraint, so all three rents +/// go back to the operator. +#[inline(always)] +pub fn handle_close_market(accounts: &mut CloseMarket) -> Result<(), ProgramError> { + if accounts.base_vault.amount() != 0 || accounts.quote_vault.amount() != 0 { + return Err(err(error::INVENTORY_NOT_EMPTY)); + } + + // The market owns both vaults and signs their closure with its own seeds. + let bump = [accounts.market.bump]; + let base_mint = *accounts.base_mint.address(); + let quote_mint = *accounts.quote_mint.address(); + let seeds: &[Seed] = &[ + Seed::from(b"market".as_ref()), + Seed::from(base_mint.as_ref()), + Seed::from(quote_mint.as_ref()), + Seed::from(&bump as &[u8]), + ]; + + accounts + .token_program + .close_account(&accounts.base_vault, &accounts.operator, &accounts.market) + .invoke_signed(seeds)?; + accounts + .token_program + .close_account(&accounts.quote_vault, &accounts.operator, &accounts.market) + .invoke_signed(seeds)?; + + Ok(()) +} diff --git a/finance/prop-amm/quasar/src/instructions/mod.rs b/finance/prop-amm/quasar/src/instructions/mod.rs index a254dd434..0ca9c5cc5 100644 --- a/finance/prop-amm/quasar/src/instructions/mod.rs +++ b/finance/prop-amm/quasar/src/instructions/mod.rs @@ -1,3 +1,4 @@ +pub mod close_market; pub mod deposit_inventory; pub mod initialize_market; pub mod set_quote; @@ -5,6 +6,7 @@ pub mod shared; pub mod swap; pub mod withdraw_inventory; +pub use close_market::*; pub use deposit_inventory::*; pub use initialize_market::*; pub use set_quote::*; diff --git a/finance/prop-amm/quasar/src/instructions/shared.rs b/finance/prop-amm/quasar/src/instructions/shared.rs index ce7b9269a..8cda0b891 100644 --- a/finance/prop-amm/quasar/src/instructions/shared.rs +++ b/finance/prop-amm/quasar/src/instructions/shared.rs @@ -25,6 +25,7 @@ pub mod error { pub const INVALID_DIRECTION: u32 = 12; pub const PRICE_PREDATES_RESTART: u32 = 13; pub const PRICE_FEED_NOT_FROM_ORACLE: u32 = 14; + pub const INVENTORY_NOT_EMPTY: u32 = 15; } #[inline(always)] diff --git a/finance/prop-amm/quasar/src/lib.rs b/finance/prop-amm/quasar/src/lib.rs index b995a6554..5918027f5 100644 --- a/finance/prop-amm/quasar/src/lib.rs +++ b/finance/prop-amm/quasar/src/lib.rs @@ -80,4 +80,11 @@ mod quasar_prop_amm { ) -> Result<(), ProgramError> { instructions::handle_swap(&mut ctx.accounts, direction, amount_in, minimum_amount_out) } + + /// Operator closes the market and both vaults, recovering all three + /// rents. Refused while either vault holds tokens. + #[instruction(discriminator = 5)] + pub fn close_market(ctx: Ctx) -> Result<(), ProgramError> { + instructions::handle_close_market(&mut ctx.accounts) + } } diff --git a/finance/prop-amm/quasar/src/tests.rs b/finance/prop-amm/quasar/src/tests.rs index 1126890c8..e443eb8f2 100644 --- a/finance/prop-amm/quasar/src/tests.rs +++ b/finance/prop-amm/quasar/src/tests.rs @@ -5,8 +5,8 @@ use { crate::{ cpi::{ - DepositInventoryInstruction, InitializeMarketInstruction, SetQuoteInstruction, - SwapInstruction, WithdrawInventoryInstruction, + CloseMarketInstruction, DepositInventoryInstruction, InitializeMarketInstruction, + SetQuoteInstruction, SwapInstruction, WithdrawInventoryInstruction, }, instructions::shared::error, state::Market, @@ -301,6 +301,22 @@ fn withdraw_inventory( }) } +fn close_market(test: &mut Test, env: &Env, signer: Pubkey) -> Outcome { + test.send(CloseMarketInstruction { + operator: signer, + base_mint: BASE_MINT, + quote_mint: QUOTE_MINT, + base_vault: env.base_vault, + quote_vault: env.quote_vault, + }) +} + +/// Write raw bytes as the feed account, owned by the program the market +/// recorded, so only the layout and value checks can refuse it. +fn set_feed_data(test: &mut Test, data: Vec) { + test.set_account(Account::new(FEED, system_program::ID, 1_000_000, data)); +} + #[quasar_test] fn initialize_market_creates_market_and_stocked_vaults(test: &mut Test) { let env = setup(test); @@ -490,6 +506,99 @@ fn operator_can_withdraw_everything_and_swaps_then_fail(test: &mut Test) { .fails_with(error::INSUFFICIENT_INVENTORY); } +/// Maria withdraws every token, then closes the market. The market account +/// and both vaults are gone, and the three rents she paid at +/// `initialize_market` come back to her to the lamport. +#[quasar_test] +fn close_market_returns_all_three_rents(test: &mut Test) { + let env = setup(test); + withdraw_inventory( + test, + &env, + OPERATOR, + OPERATOR_BASE, + OPERATOR_QUOTE, + 1_000 * ONE_NVDAX, + 200_000 * ONE_USDC, + ) + .succeeds(); + + let operator_before = test.lamports(OPERATOR); + let rents = + test.lamports(env.market) + test.lamports(env.base_vault) + test.lamports(env.quote_vault); + assert!(rents > 0); + + close_market(test, &env, OPERATOR) + .succeeds() + .is_closed(env.market) + .is_closed(env.base_vault) + .is_closed(env.quote_vault) + .has_lamports(OPERATOR, operator_before + rents); + // The inventory went back through `withdraw_inventory`, so the operator + // holds every token it started with. + assert_eq!(test.tokens(OPERATOR_BASE), 10_000 * ONE_NVDAX); + assert_eq!(test.tokens(OPERATOR_QUOTE), 10_000_000 * ONE_USDC); +} + +/// The market cannot close while either vault holds a single minor unit: the +/// operator withdraws first. Each vault's check is exercised on its own. +#[quasar_test] +fn close_market_refuses_while_a_vault_holds_tokens(test: &mut Test) { + let env = setup(test); + close_market(test, &env, OPERATOR).fails_with(error::INVENTORY_NOT_EMPTY); + + // Base vault empty, quote vault still stocked. + withdraw_inventory( + test, + &env, + OPERATOR, + OPERATOR_BASE, + OPERATOR_QUOTE, + 1_000 * ONE_NVDAX, + 0, + ) + .succeeds(); + close_market(test, &env, OPERATOR).fails_with(error::INVENTORY_NOT_EMPTY); + + // Quote vault empty, one minor unit of base back in the base vault. + withdraw_inventory( + test, + &env, + OPERATOR, + OPERATOR_BASE, + OPERATOR_QUOTE, + 0, + 200_000 * ONE_USDC, + ) + .succeeds(); + deposit_inventory(test, &env, OPERATOR, OPERATOR_BASE, OPERATOR_QUOTE, 1, 0).succeeds(); + close_market(test, &env, OPERATOR).fails_with(error::INVENTORY_NOT_EMPTY); + assert!(test.account(env.market).is_some()); + + withdraw_inventory(test, &env, OPERATOR, OPERATOR_BASE, OPERATOR_QUOTE, 1, 0).succeeds(); + close_market(test, &env, OPERATOR) + .succeeds() + .is_closed(env.market); +} + +#[quasar_test] +fn close_market_rejects_non_operator(test: &mut Test) { + let env = setup(test); + withdraw_inventory( + test, + &env, + OPERATOR, + OPERATOR_BASE, + OPERATOR_QUOTE, + 1_000 * ONE_NVDAX, + 200_000 * ONE_USDC, + ) + .succeeds(); + test.add(Wallet::new().at(MALLORY)); + close_market(test, &env, MALLORY).fails_with(QuasarError::HasOneMismatch); + assert!(test.account(env.market).is_some()); +} + #[quasar_test] fn withdraw_more_than_inventory_fails(test: &mut Test) { let env = setup(test); @@ -722,6 +831,119 @@ fn swap_rejects_wide_confidence(test: &mut Test) { .fails_with(error::ORACLE_CONFIDENCE_TOO_WIDE); } +/// A zero or negative oracle price is not a price. The market refuses to +/// quote against either rather than divide by it or flip the spread. +#[quasar_test] +fn swap_rejects_non_positive_price(test: &mut Test) { + let env = setup(test); + fund_trader( + test, + TRADER, + TRADER_BASE, + TRADER_QUOTE, + 0, + FIVE_NVDAX_AT_THE_ASK, + ); + for price in [0, -dollars(165)] { + set_feed(test, price, 0); + swap( + test, + &env, + TRADER, + TRADER_BASE, + TRADER_QUOTE, + DIRECTION_BUY_BASE, + FIVE_NVDAX_AT_THE_ASK, + 0, + ) + .fails_with(error::NON_POSITIVE_PRICE); + } +} + +/// A market pinned to a feed with 8 decimals of scale refuses a feed that +/// reports 6: read at the wrong scale, $165 would be $1.65. +#[quasar_test] +fn swap_rejects_oracle_scale_mismatch(test: &mut Test) { + let env = setup(test); + let mut data = Vec::with_capacity(36); + data.extend_from_slice(&(165i128 * 10i128.pow(6)).to_le_bytes()); + data.extend_from_slice(&(ORACLE_SCALE - 2).to_le_bytes()); + data.extend_from_slice(&SLOT.to_le_bytes()); + data.extend_from_slice(&0u64.to_le_bytes()); + set_feed_data(test, data); + fund_trader( + test, + TRADER, + TRADER_BASE, + TRADER_QUOTE, + 0, + FIVE_NVDAX_AT_THE_ASK, + ); + swap( + test, + &env, + TRADER, + TRADER_BASE, + TRADER_QUOTE, + DIRECTION_BUY_BASE, + FIVE_NVDAX_AT_THE_ASK, + 0, + ) + .fails_with(error::ORACLE_SCALE_MISMATCH); +} + +/// A feed account owned by the recorded oracle program but too short to hold +/// the price layout is refused before a byte of it is decoded. +#[quasar_test] +fn swap_rejects_oracle_data_too_short(test: &mut Test) { + let env = setup(test); + // The layout needs 36 bytes; keep the price and the scale. + let mut data = Vec::with_capacity(20); + data.extend_from_slice(&dollars(165).to_le_bytes()); + data.extend_from_slice(&ORACLE_SCALE.to_le_bytes()); + set_feed_data(test, data); + fund_trader( + test, + TRADER, + TRADER_BASE, + TRADER_QUOTE, + 0, + FIVE_NVDAX_AT_THE_ASK, + ); + swap( + test, + &env, + TRADER, + TRADER_BASE, + TRADER_QUOTE, + DIRECTION_BUY_BASE, + FIVE_NVDAX_AT_THE_ASK, + 0, + ) + .fails_with(error::ORACLE_DATA_TOO_SHORT); +} + +/// One minor unit of USDC (0.000001) at the $165.165 ask buys 0.0000000060546 +/// NVDAx, which floors to zero minor units. The market refuses rather than +/// take the trader's input for nothing. +#[quasar_test] +fn swap_rejects_amount_that_rounds_to_zero(test: &mut Test) { + let env = setup(test); + fund_trader(test, TRADER, TRADER_BASE, TRADER_QUOTE, 0, 1); + swap( + test, + &env, + TRADER, + TRADER_BASE, + TRADER_QUOTE, + DIRECTION_BUY_BASE, + 1, + 0, + ) + .fails_with(error::AMOUNT_ROUNDS_TO_ZERO); + assert_eq!(test.tokens(TRADER_QUOTE), 1); +} + /// While the operator has pulled its quotes, nobody can swap; unpausing /// restores the exact same quote. #[quasar_test] From 8e1a14fb6883fce76500c91b4129320fd8b50209 Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Wed, 7 Oct 2026 15:32:57 +0000 Subject: [PATCH 2/6] Work in progress: fourth audit re-check fixes Claude-Session: https://claude.ai/code/session_01UX53A6YR1Hjr8z6WzJxf2q --- finance/lending/kani-proofs/src/lib.rs | 5 +- finance/managed-fund/anchor-v1/CHANGELOG.md | 1 + finance/managed-fund/anchor-v1/README.md | 2 +- finance/managed-fund/anchor/CHANGELOG.md | 1 + finance/managed-fund/anchor/README.md | 2 +- finance/managed-fund/kani-proofs/src/lib.rs | 102 +++++++++++++++ finance/managed-fund/quasar/CHANGELOG.md | 9 ++ finance/managed-fund/quasar/README.md | 7 +- finance/options/quasar/README.md | 8 +- .../perpetual-futures/anchor-v1/CHANGELOG.md | 12 ++ finance/perpetual-futures/anchor-v1/README.md | 2 +- finance/perpetual-futures/anchor/CHANGELOG.md | 12 ++ finance/perpetual-futures/anchor/README.md | 2 +- finance/perpetual-futures/quasar/CHANGELOG.md | 12 ++ finance/perpetual-futures/quasar/README.md | 4 + .../programs/prop-amm/tests/test_prop_amm.rs | 116 ++++++++++++++++++ 16 files changed, 286 insertions(+), 11 deletions(-) diff --git a/finance/lending/kani-proofs/src/lib.rs b/finance/lending/kani-proofs/src/lib.rs index f290d0611..319bcf7a5 100644 --- a/finance/lending/kani-proofs/src/lib.rs +++ b/finance/lending/kani-proofs/src/lib.rs @@ -141,8 +141,9 @@ fn proof_accumulation_factor_monotonic() { kani::assume(accrued <= 255); let new_factor = grow_factor(old_factor, accrued, scale).unwrap(); - assert!(new_factor >= old_factor); // the factor never decreases - // Rounded up: never below the exact product, and less than one unit above. + // The factor never decreases. + assert!(new_factor >= old_factor); + // Rounded up: never below the exact product, and less than one unit above. let product = old_factor * (scale + accrued); assert!(new_factor * scale >= product); assert!(new_factor == 0 || (new_factor - 1) * scale < product); diff --git a/finance/managed-fund/anchor-v1/CHANGELOG.md b/finance/managed-fund/anchor-v1/CHANGELOG.md index 9f5cb6299..a282a9bf6 100644 --- a/finance/managed-fund/anchor-v1/CHANGELOG.md +++ b/finance/managed-fund/anchor-v1/CHANGELOG.md @@ -10,6 +10,7 @@ ### Fixed +- **Deposit values the basket rounding up.** `deposit` priced shares as `usdc_amount × total_shares / nav` with each asset's value in `nav` floored by `asset_value_in_usdc`, so NAV read up to a minor unit per asset low and a depositor could be minted a share more than their USDC bought, paid for by the holders already in the fund. `deposit` now values each asset with the new `asset_value_in_usdc_rounded_up`, so NAV is never understated by rounding and the floored share count rounds against the depositor. `withdraw` pays out in kind as a floored proportion of each holding and reads no price, so it is unchanged, and `rebalance` keeps the floored valuation. Tested by `test_deposit_values_assets_rounding_up`: after a 1 USDC first deposit the fund holds 333,333 NVDAx minor units worth 599,999.4 USDC minor units, and a second 1 USDC deposit mints 1,000,000 shares where a floored NAV of 999,999 would have minted 1,000,001. The walkthrough's figures are exact and unchanged. - **A partially verified Pyth update is refused.** `load_price` read the price at fixed offsets (price at 73) that assume the one-byte encoding of `verification_level`, `Full`. A `Partial { num_signatures }` update, verified by fewer than a quorum of Pyth's guardian set, encodes it in two bytes, so every later field sits a byte further along and the price, confidence, exponent, publish time and posted slot would all be read from the wrong bytes. `load_price` now requires the tag at offset 40 to be `Full` (1) and fails with the new `PriceNotFullyVerified` error otherwise; it does not try to parse a `Partial` update. Tested by `test_partially_verified_price_rejected`. The web app's IDL gains the error. ## 2026-10-03 diff --git a/finance/managed-fund/anchor-v1/README.md b/finance/managed-fund/anchor-v1/README.md index 5c2ad0555..9deccdc7b 100644 --- a/finance/managed-fund/anchor-v1/README.md +++ b/finance/managed-fund/anchor-v1/README.md @@ -145,7 +145,7 @@ What remains to trust: the honesty of the registered router and registry. The ma ## Financial Math Implementation - Integer arithmetic only; intermediate products use `u128`; multiply before divide. -- All arithmetic uses `checked_*`. Deposits and withdrawals floor in the fund's favour: a depositor's shares and a withdrawer's payout round down, and the fund keeps the remainder. The management fee rounds up, so the manager is never minted less than the fee owed. +- All arithmetic uses `checked_*`. Deposits and withdrawals floor in the fund's favour: a depositor's shares and a withdrawer's payout round down, and the fund keeps the remainder. Deposit values each asset rounding up (`asset_value_in_usdc_rounded_up`), so NAV is never understated by rounding and the floored share count cannot hand a depositor a share the holders paid for; `test_deposit_values_assets_rounding_up` checks a second 1 USDC deposit against a NAV of 999,999.4 mints 1,000,000 shares, not the 1,000,001 a floored NAV would. The management fee rounds up, so the manager is never minted less than the fee owed. - `transfer_checked` carries decimals through every token CPI. --- diff --git a/finance/managed-fund/anchor/CHANGELOG.md b/finance/managed-fund/anchor/CHANGELOG.md index 0db6ceaf6..028cb0f69 100644 --- a/finance/managed-fund/anchor/CHANGELOG.md +++ b/finance/managed-fund/anchor/CHANGELOG.md @@ -10,6 +10,7 @@ ### Fixed +- **Deposit values the basket rounding up.** `deposit` priced shares as `usdc_amount × total_shares / nav` with each asset's value in `nav` floored by `asset_value_in_usdc`, so NAV read up to a minor unit per asset low and a depositor could be minted a share more than their USDC bought, paid for by the holders already in the fund. `deposit` now values each asset with the new `asset_value_in_usdc_rounded_up`, so NAV is never understated by rounding and the floored share count rounds against the depositor. `withdraw` pays out in kind as a floored proportion of each holding and reads no price, so it is unchanged, and `rebalance` keeps the floored valuation. Tested by `test_deposit_values_assets_rounding_up`: after a 1 USDC first deposit the fund holds 333,333 NVDAx minor units worth 599,999.4 USDC minor units, and a second 1 USDC deposit mints 1,000,000 shares where a floored NAV of 999,999 would have minted 1,000,001. The walkthrough's figures are exact and unchanged. - **A partially verified Pyth update is refused.** `load_price` read the price at fixed offsets (price at 73) that assume the one-byte encoding of `verification_level`, `Full`. A `Partial { num_signatures }` update, verified by fewer than a quorum of Pyth's guardian set, encodes it in two bytes, so every later field sits a byte further along and the price, confidence, exponent, publish time and posted slot would all be read from the wrong bytes. `load_price` now requires the tag at offset 40 to be `Full` (1) and fails with the new `PriceNotFullyVerified` error otherwise; it does not try to parse a `Partial` update. Tested by `test_partially_verified_price_rejected`. The web app's IDL gains the error. ## 2026-10-03 diff --git a/finance/managed-fund/anchor/README.md b/finance/managed-fund/anchor/README.md index a2b4b743f..ae38e401a 100644 --- a/finance/managed-fund/anchor/README.md +++ b/finance/managed-fund/anchor/README.md @@ -145,7 +145,7 @@ What remains to trust: the honesty of the registered router and registry. The ma ## Financial Math Implementation - Integer arithmetic only; intermediate products use `u128`; multiply before divide. -- All arithmetic uses `checked_*`. Deposits and withdrawals floor in the fund's favour: a depositor's shares and a withdrawer's payout round down, and the fund keeps the remainder. The management fee rounds up, so the manager is never minted less than the fee owed. +- All arithmetic uses `checked_*`. Deposits and withdrawals floor in the fund's favour: a depositor's shares and a withdrawer's payout round down, and the fund keeps the remainder. Deposit values each asset rounding up (`asset_value_in_usdc_rounded_up`), so NAV is never understated by rounding and the floored share count cannot hand a depositor a share the holders paid for; `test_deposit_values_assets_rounding_up` checks a second 1 USDC deposit against a NAV of 999,999.4 mints 1,000,000 shares, not the 1,000,001 a floored NAV would. The management fee rounds up, so the manager is never minted less than the fee owed. - `transfer_checked` carries decimals through every token CPI. --- diff --git a/finance/managed-fund/kani-proofs/src/lib.rs b/finance/managed-fund/kani-proofs/src/lib.rs index 1c1a3ff49..ea3c7cb1e 100644 --- a/finance/managed-fund/kani-proofs/src/lib.rs +++ b/finance/managed-fund/kani-proofs/src/lib.rs @@ -111,6 +111,34 @@ pub fn asset_value_in_usdc( mul_pow10_div(amount.checked_mul(price)?, power, 1) } +/// `numerator * 10^power / denominator`, rounded up (`mul_pow10_div_ceil` in +/// `oracle.rs`). +pub fn mul_pow10_div_ceil(numerator: u128, power: i32, denominator: u128) -> Option { + let scale = 10u128.checked_pow(power.unsigned_abs())?; + let (numerator, denominator) = if power >= 0 { + (numerator.checked_mul(scale)?, denominator) + } else { + (numerator, denominator.checked_mul(scale)?) + }; + if denominator == 0 { + return None; + } + Some(numerator.div_ceil(denominator)) +} + +/// `asset_value_in_usdc`, rounded up (`asset_value_in_usdc_rounded_up`): how +/// `handle_deposit` values each asset in the NAV it prices shares against. +pub fn asset_value_in_usdc_rounded_up( + amount: u128, + price: u128, + exponent: i32, + asset_decimals: u8, + usdc_decimals: u8, +) -> Option { + let power = usdc_decimals as i32 + exponent - asset_decimals as i32; + mul_pow10_div_ceil(amount.checked_mul(price)?, power, 1) +} + /// Asset minor units that `usdc_amount` USDC minor units buys at the oracle /// price, floored (`usdc_to_asset_amount`). pub fn usdc_to_asset_amount( @@ -385,6 +413,50 @@ fn proof_rebalance_trade_never_overshoots() { } } +// =========================================================================== +// 8. Deposit's NAV is never understated +// =========================================================================== + +/// `handle_deposit` values each asset rounding up. The rounded-up value is the +/// exact value's ceiling: never below the floored value and at most one minor +/// unit above it. A NAV that is not understated mints a depositor no more +/// shares than the floored NAV would, so the rounding goes against the +/// depositor and never against the holders already in the fund. +#[cfg(kani)] +#[kani::proof] +#[kani::solver(cadical)] +fn proof_deposit_nav_rounds_against_the_depositor() { + let amount: u128 = kani::any(); + let price: u128 = kani::any(); + let exponent: i32 = kani::any(); + let asset_decimals: u8 = kani::any(); + let usdc_decimals: u8 = kani::any(); + let usdc_amount: u64 = kani::any(); + let total_shares: u64 = kani::any(); + + kani::assume(amount >= 1 && amount <= 255); + kani::assume(price >= 1 && price <= 255); + kani::assume(exponent >= -3 && exponent <= 0); + kani::assume(asset_decimals <= 3 && usdc_decimals <= 3); + kani::assume(usdc_amount <= 31 && total_shares >= 1 && total_shares <= 31); + + let floored = asset_value_in_usdc(amount, price, exponent, asset_decimals, usdc_decimals) + .expect("computes"); + let rounded_up = + asset_value_in_usdc_rounded_up(amount, price, exponent, asset_decimals, usdc_decimals) + .expect("computes"); + assert!(rounded_up >= floored); + assert!(rounded_up <= floored + 1); + + if floored > 0 { + let shares_at_floor = + deposit_shares(usdc_amount, total_shares, floored as u64).expect("computes"); + let shares_rounded_up = + deposit_shares(usdc_amount, total_shares, rounded_up as u64).expect("computes"); + assert!(shares_rounded_up <= shares_at_floor); + } +} + // =========================================================================== // Plain unit tests. // =========================================================================== @@ -417,6 +489,36 @@ mod tests { assert_eq!(back, 1_000_000_000); } + #[test] + fn deposit_nav_rounds_up() { + // 333,333 NVDAx minor units at $180 (exponent -8, eight decimals) are + // worth 599,999.4 USDC minor units: 599,999 floored, 600,000 rounded up. + assert_eq!( + asset_value_in_usdc(333_333, 18_000_000_000, -8, 8, 6).unwrap(), + 599_999 + ); + assert_eq!( + asset_value_in_usdc_rounded_up(333_333, 18_000_000_000, -8, 8, 6).unwrap(), + 600_000 + ); + // An exact value is unchanged. + assert_eq!( + asset_value_in_usdc_rounded_up(300_000_000, 18_000_000_000, -8, 8, 6).unwrap(), + 540_000_000 + ); + // With 160,000 TSLAx minor units worth exactly 400,000, a second 1 USDC + // deposit against the rounded-up NAV of 1,000,000 mints 1,000,000 + // shares; against the floored 999,999 it would mint 1,000,001. + assert_eq!( + deposit_shares(1_000_000, 1_000_000, 1_000_000), + Some(1_000_000) + ); + assert_eq!( + deposit_shares(1_000_000, 1_000_000, 999_999), + Some(1_000_001) + ); + } + #[test] fn valuation_scales_by_decimals_and_exponent() { // 1.44 TSLAx at eight decimals, $250 on a Pyth equity feed (exponent -5), diff --git a/finance/managed-fund/quasar/CHANGELOG.md b/finance/managed-fund/quasar/CHANGELOG.md index a502a8012..b992db436 100644 --- a/finance/managed-fund/quasar/CHANGELOG.md +++ b/finance/managed-fund/quasar/CHANGELOG.md @@ -24,6 +24,15 @@ ### Fixed +- Deposit values the basket rounding up. `deposit` priced shares as + `usdc_amount × total_shares / nav` with each asset's value in `nav` floored, + so NAV read up to a minor unit per asset low and a depositor could be minted + a share more than their USDC bought, paid for by the existing holders. + `deposit` now values each asset with the new + `asset_value_in_usdc_rounded_up`, so the floored share count rounds against + the depositor. `withdraw` reads no price and is unchanged. Tested by + `test_deposit_values_assets_rounding_up`, where a second 1 USDC deposit + against a NAV of 999,999.4 mints 1,000,000 shares, not 1,000,001. - A partially verified Pyth update is refused. `load_price` read the price at fixed offsets (price at 73) that assume the one-byte encoding of `verification_level`, `Full`. A `Partial { num_signatures }` update encodes diff --git a/finance/managed-fund/quasar/README.md b/finance/managed-fund/quasar/README.md index 8868df538..28779812b 100644 --- a/finance/managed-fund/quasar/README.md +++ b/finance/managed-fund/quasar/README.md @@ -106,8 +106,11 @@ withdraw), in index order. holdings, nor to choose a trade: rebalancing is sized by the program. - Value computations use u128 intermediates with checked arithmetic, flooring in the fund's favour: a depositor's shares and a withdrawer's payout round - down. The management fee rounds up, so the manager is never minted less than - the fee owed. + down. Deposit values each asset rounding up + (`asset_value_in_usdc_rounded_up`), so NAV is never understated by rounding + and the floored share count cannot hand a depositor a share the holders paid + for (`test_deposit_values_assets_rounding_up`). The management fee rounds + up, so the manager is never minted less than the fee owed. - The management fee is capped (10% per year) and the slippage tolerance is capped (10%), so neither can be configured to drain the fund. - Price feeds are validated against the address recorded on the asset config and diff --git a/finance/options/quasar/README.md b/finance/options/quasar/README.md index 4d811a9f1..fc043d818 100644 --- a/finance/options/quasar/README.md +++ b/finance/options/quasar/README.md @@ -20,8 +20,8 @@ differs in the Quasar version. still has exactly the terms the buyer passed, so a writer cannot cancel and rewrite the option at the same `id` on worse terms while the purchase is on its way (`buy_option_refuses_a_switched_option`). -- **The writer's underlying account is created if needed; a holder's token - accounts must already exist.** `write_option`, `cancel_option`, +- **The writer's underlying account is created if needed; every other token + account must already exist.** `write_option`, `cancel_option`, `reclaim_collateral` and `collect_proceeds` take the writer's underlying account as their associated token account, created with `init(idempotent)` at the writer's expense, so a put writer who has never @@ -29,7 +29,9 @@ differs in the Quasar version. (`put_writer_without_an_underlying_account_writes_and_reclaims`, `put_writer_without_an_underlying_account_writes_and_cancels`). The Anchor version also uses `init_if_needed` for a call holder's underlying account - at exercise; here the tests create a holder's token accounts up front. + at exercise, and creates the writer's quote account in `write_option` and + the admin's fee account in `collect_fees`; here the writer's quote account, + the admin's fee account and a holder's token accounts must already exist. - **The writer's premium account is bound in the handler.** The Anchor version derives it as the writer's associated token account; here `buy_option` checks that the account passed as `writer_quote` is owned by diff --git a/finance/perpetual-futures/anchor-v1/CHANGELOG.md b/finance/perpetual-futures/anchor-v1/CHANGELOG.md index 64a85ffd5..3b8477b44 100644 --- a/finance/perpetual-futures/anchor-v1/CHANGELOG.md +++ b/finance/perpetual-futures/anchor-v1/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased, 2026-10-05 +Profit/loss and funding round against the trader. `position_pnl` and +`position_funding` in `instructions/shared.rs` divided with truncation toward +zero, so a fractional loss was booked a base unit small, and funding a trader +owed was charged a base unit short. A short's funding was also truncated +before its sign was applied, so a short that owed funding was rounded in its +own favour. `position_pnl` now floors toward negative infinity, and +`position_funding` applies the side's sign first and then rounds toward +positive infinity, so funding the trader pays rounds up and funding the trader +receives rounds down. The walkthrough's figures are exact and unchanged. +Tested by `test_position_pnl_rounds_against_the_trader` and +`test_position_funding_rounds_against_the_trader`. + Every fee rounds up. `basis_points_of` in `instructions/shared.rs` rounds its result up to the next base unit, so the open, close and liquidation fees and the maintenance requirement a position is liquidated at each round in the diff --git a/finance/perpetual-futures/anchor-v1/README.md b/finance/perpetual-futures/anchor-v1/README.md index fc3aceca5..8615ebb65 100644 --- a/finance/perpetual-futures/anchor-v1/README.md +++ b/finance/perpetual-futures/anchor-v1/README.md @@ -21,7 +21,7 @@ A [perpetual future](https://www.investopedia.com/terms/f/futurescontract.asp) ( - `perpetual-futures`: The exchange: pool creation, liquidity provision, opening/closing leveraged positions, funding, liquidation, and fee collection. - `mock-price-feed`: Test-only price feed. Stores a price, scale, last-update slot, and confidence band that tests write directly. Replaced in production by a Pyth `PriceUpdateV2` account, as read in [`basics/pyth`](../../../basics/pyth/). -All arithmetic is integer `u128` with `checked_*` operations, multiplying before dividing and rounding in the pool's favour: every fee and the maintenance requirement round up, and what is paid out rounds down. No floats, no fixed-point library. +All arithmetic is integer `u128` with `checked_*` operations, multiplying before dividing and rounding in the pool's favour: every fee and the maintenance requirement round up, and what is paid out rounds down. A position's profit/loss is floored toward negative infinity, so a fractional loss rounds up to the next base unit, and its funding rounds toward positive infinity, so funding the trader pays rounds up and funding the trader receives rounds down (`test_position_pnl_rounds_against_the_trader`, `test_position_funding_rounds_against_the_trader`). No floats, no fixed-point library. --- diff --git a/finance/perpetual-futures/anchor/CHANGELOG.md b/finance/perpetual-futures/anchor/CHANGELOG.md index 80e871e43..2bcdeaece 100644 --- a/finance/perpetual-futures/anchor/CHANGELOG.md +++ b/finance/perpetual-futures/anchor/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased, 2026-10-05 +Profit/loss and funding round against the trader. `position_pnl` and +`position_funding` in `instructions/shared.rs` divided with truncation toward +zero, so a fractional loss was booked a base unit small, and funding a trader +owed was charged a base unit short. A short's funding was also truncated +before its sign was applied, so a short that owed funding was rounded in its +own favour. `position_pnl` now floors toward negative infinity, and +`position_funding` applies the side's sign first and then rounds toward +positive infinity, so funding the trader pays rounds up and funding the trader +receives rounds down. The walkthrough's figures are exact and unchanged. +Tested by `test_position_pnl_rounds_against_the_trader` and +`test_position_funding_rounds_against_the_trader`. + Every fee rounds up. `basis_points_of` in `instructions/shared.rs` rounds its result up to the next base unit, so the open, close and liquidation fees and the maintenance requirement a position is liquidated at each round in the diff --git a/finance/perpetual-futures/anchor/README.md b/finance/perpetual-futures/anchor/README.md index 2165c0a9a..40a37e455 100644 --- a/finance/perpetual-futures/anchor/README.md +++ b/finance/perpetual-futures/anchor/README.md @@ -21,7 +21,7 @@ A [perpetual future](https://www.investopedia.com/terms/f/futurescontract.asp) ( - `perpetual-futures`: The exchange: pool creation, liquidity provision, opening/closing leveraged positions, funding, liquidation, and fee collection. - `mock-price-feed`: Test-only price feed. Stores a price, scale, last-update slot, and confidence band that tests write directly. Replaced in production by a Pyth `PriceUpdateV2` account, as read in [`basics/pyth`](../../../basics/pyth/). -All arithmetic is integer `u128` with `checked_*` operations, multiplying before dividing and rounding in the pool's favour: every fee and the maintenance requirement round up, and what is paid out rounds down. No floats, no fixed-point library. +All arithmetic is integer `u128` with `checked_*` operations, multiplying before dividing and rounding in the pool's favour: every fee and the maintenance requirement round up, and what is paid out rounds down. A position's profit/loss is floored toward negative infinity, so a fractional loss rounds up to the next base unit, and its funding rounds toward positive infinity, so funding the trader pays rounds up and funding the trader receives rounds down (`test_position_pnl_rounds_against_the_trader`, `test_position_funding_rounds_against_the_trader`). No floats, no fixed-point library. --- diff --git a/finance/perpetual-futures/quasar/CHANGELOG.md b/finance/perpetual-futures/quasar/CHANGELOG.md index 96c5b0676..f3405d072 100644 --- a/finance/perpetual-futures/quasar/CHANGELOG.md +++ b/finance/perpetual-futures/quasar/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased, 2026-10-05 +Profit/loss and funding round against the trader. `position_pnl` and +`position_funding` in `instructions/shared.rs` divided with truncation toward +zero, so a fractional loss was booked a base unit small, and funding a trader +owed was charged a base unit short. A short's funding was also truncated +before its sign was applied, so a short that owed funding was rounded in its +own favour. `position_pnl` now floors toward negative infinity, and +`position_funding` applies the side's sign first and then rounds toward +positive infinity, so funding the trader pays rounds up and funding the trader +receives rounds down. The walkthrough's figures are exact and unchanged. +Tested by `position_pnl_rounds_against_the_trader` and +`position_funding_rounds_against_the_trader`. + Every fee rounds up. `basis_points_of` in `instructions/shared.rs` rounds its result up to the next base unit, so the open, close and liquidation fees and the maintenance requirement a position is liquidated at each round in the diff --git a/finance/perpetual-futures/quasar/README.md b/finance/perpetual-futures/quasar/README.md index afeb9d055..afdb08d79 100644 --- a/finance/perpetual-futures/quasar/README.md +++ b/finance/perpetual-futures/quasar/README.md @@ -65,6 +65,10 @@ wallets, then exercise: - every fee and the maintenance requirement rounding up (`fees_and_maintenance_requirement_round_up`), and `basis_points_of` at its boundaries +- profit/loss floored toward negative infinity and funding rounded toward + positive infinity, so a fraction of a base unit always goes to the pool + (`position_pnl_rounds_against_the_trader`, + `position_funding_rounds_against_the_trader`) - the haircut: a position opening without full backing, profit paid in full while the pool backs it, two winners each paid exactly half when the pool is stressed (`haircut_scales_profit_when_pool_stressed`), the insurance fund diff --git a/finance/prop-amm/anchor-v1/programs/prop-amm/tests/test_prop_amm.rs b/finance/prop-amm/anchor-v1/programs/prop-amm/tests/test_prop_amm.rs index 60c770495..808eaa8d2 100644 --- a/finance/prop-amm/anchor-v1/programs/prop-amm/tests/test_prop_amm.rs +++ b/finance/prop-amm/anchor-v1/programs/prop-amm/tests/test_prop_amm.rs @@ -533,6 +533,46 @@ impl Market { self.close_market_as(&operator) } + /// A plain SPL Token `TransferChecked` of `amount` minor units from + /// `from_account` (owned by `sender`) straight into `vault`. Nothing in + /// the market program runs: this is a third party donating tokens to a + /// vault, not the operator's `deposit_inventory`. The instruction is + /// built by hand (tag 12, amount, decimals) with the token program's + /// account order: source, mint, destination, owner. + fn donate_to_vault( + &mut self, + sender: &Keypair, + from_account: &Pubkey, + mint: &Pubkey, + decimals: u8, + vault: &Pubkey, + amount: u64, + ) { + let mut data = vec![12u8]; + data.extend_from_slice(&amount.to_le_bytes()); + data.push(decimals); + let transfer = Instruction::new_with_bytes( + token_program_id(), + &data, + vec![ + anchor_lang::solana_program::instruction::AccountMeta::new(*from_account, false), + anchor_lang::solana_program::instruction::AccountMeta::new_readonly(*mint, false), + anchor_lang::solana_program::instruction::AccountMeta::new(*vault, false), + anchor_lang::solana_program::instruction::AccountMeta::new_readonly( + sender.pubkey(), + true, + ), + ], + ); + send_transaction_from_instructions( + &mut self.svm, + vec![transfer], + &[sender], + &sender.pubkey(), + ) + .expect("a plain token transfer into a vault should succeed"); + } + fn lamports(&self, address: &Pubkey) -> u64 { self.svm .get_account(address) @@ -752,6 +792,82 @@ fn test_close_market_refuses_while_a_vault_holds_tokens() { market.close_market().expect("an empty market must close"); } +/// Nobody can wedge the close or slip tokens past it by sending them straight +/// to a vault. After Maria withdraws everything, a stranger sends one minor +/// unit of NVDAx into the base vault with a plain token transfer, not +/// `deposit_inventory`, and the close is refused; then one minor unit of USDC +/// into the quote vault, and the close is refused again. Maria withdraws each +/// donation like any other inventory and the market closes. +#[test] +fn test_close_market_refuses_tokens_sent_straight_to_a_vault() { + let mut market = Market::default_market(); + market + .withdraw_inventory(1_000 * ONE_NVDAX, 200_000 * ONE_USDC) + .unwrap(); + let (stranger, stranger_base, stranger_quote) = market.funded_trader(1, 1); + + let base_mint = market.base_mint; + let base_vault = market.base_vault; + market.donate_to_vault( + &stranger, + &stranger_base, + &base_mint, + NVDAX_DECIMALS, + &base_vault, + 1, + ); + assert_eq!(market.balance(&base_vault), 1); + assert_fails_with(market.close_market(), PropAmmError::InventoryNotEmpty); + assert!(market.svm.get_account(&market.market).is_some()); + market.withdraw_inventory(1, 0).unwrap(); + + let quote_mint = market.quote_mint; + let quote_vault = market.quote_vault; + market.donate_to_vault( + &stranger, + &stranger_quote, + "e_mint, + USDC_DECIMALS, + "e_vault, + 1, + ); + assert_eq!(market.balance("e_vault), 1); + // The retry is otherwise byte-identical to the refused close, so a fresh + // blockhash gives it a new signature. + market.svm.expire_blockhash(); + assert_fails_with(market.close_market(), PropAmmError::InventoryNotEmpty); + assert!(market.svm.get_account(&market.market).is_some()); + market.withdraw_inventory(0, 1).unwrap(); + + market.svm.expire_blockhash(); + market.close_market().expect("an empty market must close"); + assert!(market.svm.get_account(&market.market).is_none()); + // The operator now holds its own inventory plus both donated units. + let operator_base = market.operator_base; + let operator_quote = market.operator_quote; + assert_eq!(market.balance(&operator_base), 10_000 * ONE_NVDAX + 1); + assert_eq!(market.balance(&operator_quote), 10_000_000 * ONE_USDC + 1); +} + +/// A closed market cannot fill. Its account is gone, so a swap naming it +/// fails before any token moves, and the trader keeps every token. +#[test] +fn test_swap_against_a_closed_market_fails() { + let mut market = Market::default_market(); + market + .withdraw_inventory(1_000 * ONE_NVDAX, 200_000 * ONE_USDC) + .unwrap(); + market.close_market().unwrap(); + + let (alice, alice_base, alice_quote) = market.funded_trader(0, FIVE_NVDAX_AT_THE_ASK); + assert_fails_with_anchor_error( + market.swap(&alice, Direction::BuyBase, FIVE_NVDAX_AT_THE_ASK, 0), + AnchorErrorCode::AccountNotInitialized, + ); + assert_eq!(market.balance(&alice_base), 0); + assert_eq!(market.balance(&alice_quote), FIVE_NVDAX_AT_THE_ASK); +} + #[test] fn test_close_market_rejects_non_operator() { let mut market = Market::default_market(); From c1b974f67432d6031444193c796c8e5db147abf5 Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Wed, 7 Oct 2026 15:50:09 +0000 Subject: [PATCH 3/6] Work in progress: fourth audit re-check fixes Claude-Session: https://claude.ai/code/session_01UX53A6YR1Hjr8z6WzJxf2q --- finance/managed-fund/anchor-v1/CHANGELOG.md | 2 +- finance/managed-fund/anchor/CHANGELOG.md | 2 +- finance/managed-fund/kani-proofs/README.md | 2 + .../programs/options/tests/test_options.rs | 43 ++++++ .../programs/options/tests/test_options.rs | 43 ++++++ finance/options/quasar/src/tests.rs | 43 ++++++ .../programs/prop-amm/tests/test_prop_amm.rs | 116 +++++++++++++++- finance/prop-amm/quasar/src/tests.rs | 129 ++++++++++++++++++ 8 files changed, 377 insertions(+), 3 deletions(-) diff --git a/finance/managed-fund/anchor-v1/CHANGELOG.md b/finance/managed-fund/anchor-v1/CHANGELOG.md index a282a9bf6..11a6b6e46 100644 --- a/finance/managed-fund/anchor-v1/CHANGELOG.md +++ b/finance/managed-fund/anchor-v1/CHANGELOG.md @@ -10,7 +10,7 @@ ### Fixed -- **Deposit values the basket rounding up.** `deposit` priced shares as `usdc_amount × total_shares / nav` with each asset's value in `nav` floored by `asset_value_in_usdc`, so NAV read up to a minor unit per asset low and a depositor could be minted a share more than their USDC bought, paid for by the holders already in the fund. `deposit` now values each asset with the new `asset_value_in_usdc_rounded_up`, so NAV is never understated by rounding and the floored share count rounds against the depositor. `withdraw` pays out in kind as a floored proportion of each holding and reads no price, so it is unchanged, and `rebalance` keeps the floored valuation. Tested by `test_deposit_values_assets_rounding_up`: after a 1 USDC first deposit the fund holds 333,333 NVDAx minor units worth 599,999.4 USDC minor units, and a second 1 USDC deposit mints 1,000,000 shares where a floored NAV of 999,999 would have minted 1,000,001. The walkthrough's figures are exact and unchanged. +- **Deposit values the basket rounding up.** `deposit` priced shares as `usdc_amount × total_shares / nav` with each asset's value in `nav` floored by `asset_value_in_usdc`, so NAV read up to a minor unit per asset low and a depositor could be minted a share more than their USDC bought, paid for by the holders already in the fund. `deposit` now values each asset with the new `asset_value_in_usdc_rounded_up`, so NAV is never understated by rounding and the floored share count rounds against the depositor. `withdraw` pays out in kind as a floored proportion of each holding and reads no price, so it is unchanged, and `rebalance` keeps the floored valuation. Tested by `test_deposit_values_assets_rounding_up`: after a 1 USDC first deposit the fund holds 333,333 NVDAx minor units worth 599,999.4 USDC minor units, and a second 1 USDC deposit mints 1,000,000 shares where a floored NAV of 999,999 would have minted 1,000,001. The Kani crate models the ceiling with `asset_value_in_usdc_rounded_up`, and the new `proof_deposit_nav_rounds_against_the_depositor` checks it is at most one minor unit above the floor and never mints more shares than the floored NAV would. The walkthrough's figures are exact and unchanged. - **A partially verified Pyth update is refused.** `load_price` read the price at fixed offsets (price at 73) that assume the one-byte encoding of `verification_level`, `Full`. A `Partial { num_signatures }` update, verified by fewer than a quorum of Pyth's guardian set, encodes it in two bytes, so every later field sits a byte further along and the price, confidence, exponent, publish time and posted slot would all be read from the wrong bytes. `load_price` now requires the tag at offset 40 to be `Full` (1) and fails with the new `PriceNotFullyVerified` error otherwise; it does not try to parse a `Partial` update. Tested by `test_partially_verified_price_rejected`. The web app's IDL gains the error. ## 2026-10-03 diff --git a/finance/managed-fund/anchor/CHANGELOG.md b/finance/managed-fund/anchor/CHANGELOG.md index 028cb0f69..e8d54096a 100644 --- a/finance/managed-fund/anchor/CHANGELOG.md +++ b/finance/managed-fund/anchor/CHANGELOG.md @@ -10,7 +10,7 @@ ### Fixed -- **Deposit values the basket rounding up.** `deposit` priced shares as `usdc_amount × total_shares / nav` with each asset's value in `nav` floored by `asset_value_in_usdc`, so NAV read up to a minor unit per asset low and a depositor could be minted a share more than their USDC bought, paid for by the holders already in the fund. `deposit` now values each asset with the new `asset_value_in_usdc_rounded_up`, so NAV is never understated by rounding and the floored share count rounds against the depositor. `withdraw` pays out in kind as a floored proportion of each holding and reads no price, so it is unchanged, and `rebalance` keeps the floored valuation. Tested by `test_deposit_values_assets_rounding_up`: after a 1 USDC first deposit the fund holds 333,333 NVDAx minor units worth 599,999.4 USDC minor units, and a second 1 USDC deposit mints 1,000,000 shares where a floored NAV of 999,999 would have minted 1,000,001. The walkthrough's figures are exact and unchanged. +- **Deposit values the basket rounding up.** `deposit` priced shares as `usdc_amount × total_shares / nav` with each asset's value in `nav` floored by `asset_value_in_usdc`, so NAV read up to a minor unit per asset low and a depositor could be minted a share more than their USDC bought, paid for by the holders already in the fund. `deposit` now values each asset with the new `asset_value_in_usdc_rounded_up`, so NAV is never understated by rounding and the floored share count rounds against the depositor. `withdraw` pays out in kind as a floored proportion of each holding and reads no price, so it is unchanged, and `rebalance` keeps the floored valuation. Tested by `test_deposit_values_assets_rounding_up`: after a 1 USDC first deposit the fund holds 333,333 NVDAx minor units worth 599,999.4 USDC minor units, and a second 1 USDC deposit mints 1,000,000 shares where a floored NAV of 999,999 would have minted 1,000,001. The Kani crate models the ceiling with `asset_value_in_usdc_rounded_up`, and the new `proof_deposit_nav_rounds_against_the_depositor` checks it is at most one minor unit above the floor and never mints more shares than the floored NAV would. The walkthrough's figures are exact and unchanged. - **A partially verified Pyth update is refused.** `load_price` read the price at fixed offsets (price at 73) that assume the one-byte encoding of `verification_level`, `Full`. A `Partial { num_signatures }` update, verified by fewer than a quorum of Pyth's guardian set, encodes it in two bytes, so every later field sits a byte further along and the price, confidence, exponent, publish time and posted slot would all be read from the wrong bytes. `load_price` now requires the tag at offset 40 to be `Full` (1) and fails with the new `PriceNotFullyVerified` error otherwise; it does not try to parse a `Partial` update. Tested by `test_partially_verified_price_rejected`. The web app's IDL gains the error. ## 2026-10-03 diff --git a/finance/managed-fund/kani-proofs/README.md b/finance/managed-fund/kani-proofs/README.md index c8f2d0389..6d468ccf7 100644 --- a/finance/managed-fund/kani-proofs/README.md +++ b/finance/managed-fund/kani-proofs/README.md @@ -22,6 +22,7 @@ input, the harnesses check: - `proof_recorded_holdings_never_exceed_balance`: The program prices shares and pays withdrawals from its recorded holdings, not vault balances. Across a deposit, a donation, and a withdrawal, the recorded holding never exceeds the vault's real balance, so every payout is covered however much is donated. - `proof_donation_cannot_dilute_next_deposit`: The inflation attack modelled directly: after an attacker's first deposit and a donation of any size, the victim's deposit mints exactly one share per minor unit and withdraws in full. - `proof_fee_shares_bounded_by_supply`: The time-based manager fee, `ceil(total_shares·fee_bps·elapsed/(10000·seconds_per_year))`, can never mint more than 100%/year of dilution (`fee_shares <= total_shares` for `elapsed <= 1yr`, `fee_bps <= 10000`), and rounds up: it is never below the exact quotient and never more than one share above it. +- `proof_deposit_nav_rounds_against_the_depositor`: Deposit values each asset rounding up. The rounded-up value is never below the floored one and at most one minor unit above it, and a deposit priced against it mints no more shares than one priced against the floored NAV, so valuation rounding never hands a depositor a share the holders paid for. ## Bounded model checking @@ -34,6 +35,7 @@ representative range; the share identities are scale-invariant. - `proof_recorded_holdings_never_exceed_balance`: balances and supply `<= 255` - `proof_donation_cannot_dilute_next_deposit`: deposits `<= 31`, donation unbounded - `proof_fee_shares_bounded_by_supply`: `<= 255`, runs in ~4s +- `proof_deposit_nav_rounds_against_the_depositor`: amounts and prices `<= 255`, deposits and supply `<= 31`, runs in ~3 minutes Run weekly in CI (the `kani.yml` `verify` job), not on every push/PR, because the bounded nonlinear model checks are slow. A fast unit-test job runs per push/PR. diff --git a/finance/options/anchor-v1/programs/options/tests/test_options.rs b/finance/options/anchor-v1/programs/options/tests/test_options.rs index 6a1027065..e44a4e53f 100644 --- a/finance/options/anchor-v1/programs/options/tests/test_options.rs +++ b/finance/options/anchor-v1/programs/options/tests/test_options.rs @@ -1199,6 +1199,49 @@ fn test_buy_option_refuses_a_switched_option() { } } +/// The kind switch: Bob reads Alice's call and signs a purchase at those +/// terms. Before it lands, Alice cancels and writes a put at the same address +/// (the same `id`) with every amount and the expiry unchanged, so only the +/// kind differs. A put would hand Bob the right to sell 5 NVDAx for 900 USDC, +/// not to buy them. Bob's purchase is refused with `OptionTermsChanged`, and +/// no USDC moves: Alice's 900 USDC of put collateral stays in the vault. +#[test] +fn test_buy_option_refuses_a_call_switched_to_a_put() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let bob = venue.person(0, STANDARD_USDC); + let option = venue.write_call(&alice); + let seen = venue.listed_terms(&option); + assert_eq!(seen.kind, OptionKind::Call); + + venue.cancel_option(&alice, &option).unwrap(); + let switched = OptionTerms { + kind: OptionKind::Put, + ..seen + }; + let rewritten = venue.write_option(&alice, 1, switched).unwrap(); + assert_eq!(rewritten, option); + assert_eq!(venue.option_state(&option).kind, OptionKind::Put); + + assert_fails_with( + venue.buy_option_with_terms(&bob, &alice.pubkey(), &option, seen), + OptionsError::OptionTermsChanged, + ); + assert_eq!(venue.balance(&bob.quote), STANDARD_USDC); + assert_eq!( + venue.balance(&alice.quote), + STANDARD_USDC - CALL_STRIKE_AMOUNT + ); + assert_eq!(venue.balance(&alice.underlying), FIVE_NVDAX); + assert_eq!(venue.balance(&venue.quote_vault), CALL_STRIKE_AMOUNT); + assert_eq!(venue.balance(&venue.underlying_vault), 0); + let state = venue.option_state(&option); + assert_eq!(state.status, OptionStatus::Listed); + assert_eq!(state.holder, Pubkey::default()); + assert_eq!(venue.market_state().fees_owed, 0); + venue.assert_vaults_match_ledger(); +} + /// A purchase whose terms match the option's goes through: Bob, reading the /// rewritten option at a 50 USDC premium, buys it at that premium, paying /// 0.50 USDC to the venue and 49.50 USDC to Alice. diff --git a/finance/options/anchor/programs/options/tests/test_options.rs b/finance/options/anchor/programs/options/tests/test_options.rs index d2372cb65..ed2639fc8 100644 --- a/finance/options/anchor/programs/options/tests/test_options.rs +++ b/finance/options/anchor/programs/options/tests/test_options.rs @@ -1212,6 +1212,49 @@ fn test_buy_option_refuses_a_switched_option() { } } +/// The kind switch: Bob reads Alice's call and signs a purchase at those +/// terms. Before it lands, Alice cancels and writes a put at the same address +/// (the same `id`) with every amount and the expiry unchanged, so only the +/// kind differs. A put would hand Bob the right to sell 5 NVDAx for 900 USDC, +/// not to buy them. Bob's purchase is refused with `OptionTermsChanged`, and +/// no USDC moves: Alice's 900 USDC of put collateral stays in the vault. +#[test] +fn test_buy_option_refuses_a_call_switched_to_a_put() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let bob = venue.person(0, STANDARD_USDC); + let option = venue.write_call(&alice); + let seen = venue.listed_terms(&option); + assert_eq!(seen.kind, OptionKind::Call); + + venue.cancel_option(&alice, &option).unwrap(); + let switched = OptionTerms { + kind: OptionKind::Put, + ..seen + }; + let rewritten = venue.write_option(&alice, 1, switched).unwrap(); + assert_eq!(rewritten, option); + assert_eq!(venue.option_state(&option).kind, OptionKind::Put); + + assert_fails_with( + venue.buy_option_with_terms(&bob, &alice.pubkey(), &option, seen), + OptionsError::OptionTermsChanged, + ); + assert_eq!(venue.balance(&bob.quote), STANDARD_USDC); + assert_eq!( + venue.balance(&alice.quote), + STANDARD_USDC - CALL_STRIKE_AMOUNT + ); + assert_eq!(venue.balance(&alice.underlying), FIVE_NVDAX); + assert_eq!(venue.balance(&venue.quote_vault), CALL_STRIKE_AMOUNT); + assert_eq!(venue.balance(&venue.underlying_vault), 0); + let state = venue.option_state(&option); + assert_eq!(state.status, OptionStatus::Listed); + assert_eq!(state.holder, Address::default()); + assert_eq!(venue.market_state().fees_owed, 0); + venue.assert_vaults_match_ledger(); +} + /// A purchase whose terms match the option's goes through: Bob, reading the /// rewritten option at a 50 USDC premium, buys it at that premium, paying /// 0.50 USDC to the venue and 49.50 USDC to Alice. diff --git a/finance/options/quasar/src/tests.rs b/finance/options/quasar/src/tests.rs index 4d7fcd635..7813b4829 100644 --- a/finance/options/quasar/src/tests.rs +++ b/finance/options/quasar/src/tests.rs @@ -891,6 +891,49 @@ fn buy_option_refuses_a_switched_option(test: &mut Test) { } } +/// The kind switch: Bob reads Alice's call and signs a purchase at those +/// terms. Before it lands, Alice cancels and writes a put at the same address +/// (the same `id`) with every amount and the expiry unchanged, so only the +/// kind differs. A put would hand Bob the right to sell 5 NVDAx for 900 USDC, +/// not to buy them. Bob's purchase is refused with `OptionTermsChanged`, and +/// no USDC moves: Alice's 900 USDC of put collateral stays in the vault. +#[quasar_test] +fn buy_option_refuses_a_call_switched_to_a_put(test: &mut Test) { + let env = setup(test); + let option = write_call(test, &env); + let seen = listed_terms(test, &env, &ALICE_P, CALL_ID); + assert_eq!(seen.kind, KIND_CALL); + + cancel_option(test, &env, &ALICE_P, CALL_ID).succeeds(); + write_option( + test, + &env, + &ALICE_P, + CALL_ID, + KIND_PUT, + seen.underlying_amount, + seen.strike_amount, + seen.premium, + seen.expiry, + ) + .succeeds(); + assert_eq!(option_pda(test, &env, &ALICE_P, CALL_ID), option); + assert_eq!(test.read::(option).kind, KIND_PUT); + + buy_option_with_terms(test, &env, &BOB_P, &ALICE_P, CALL_ID, seen) + .fails_with(OptionsError::OptionTermsChanged); + assert_eq!(test.tokens(BOB_USDC), STANDARD_USDC); + assert_eq!(test.tokens(ALICE_USDC), STANDARD_USDC - CALL_STRIKE_AMOUNT); + assert_eq!(test.tokens(ALICE_NVDAX), FIVE_NVDAX); + assert_eq!(test.tokens(env.quote_vault), CALL_STRIKE_AMOUNT); + assert_eq!(test.tokens(env.underlying_vault), 0); + let state = test.read::(option); + assert_eq!(state.status, STATUS_LISTED); + assert_eq!(state.holder, Pubkey::default()); + assert_eq!(u64::from(test.read::(env.market).fees_owed), 0); + assert_vaults_match_ledger(test, &env); +} + /// A purchase whose terms match the option's goes through: Bob, reading the /// rewritten option at a 50 USDC premium, buys it at that premium, paying /// 0.50 USDC to the venue and 49.50 USDC to Alice. diff --git a/finance/prop-amm/anchor/programs/prop-amm/tests/test_prop_amm.rs b/finance/prop-amm/anchor/programs/prop-amm/tests/test_prop_amm.rs index a10266a55..974b20b32 100644 --- a/finance/prop-amm/anchor/programs/prop-amm/tests/test_prop_amm.rs +++ b/finance/prop-amm/anchor/programs/prop-amm/tests/test_prop_amm.rs @@ -1,6 +1,7 @@ use { anchor_lang::{ - solana_program::instruction::Instruction, system_program, AccountDeserialize, Address, + solana_program::instruction::{AccountMeta, Instruction}, + system_program, AccountDeserialize, Address, Error as AnchorError, ErrorCode as AnchorErrorCode, InstructionData, ToAccountMetas, }, anchor_v2_testing::{Keypair, LiteSVM, Signer}, @@ -532,6 +533,43 @@ impl Market { self.close_market_as(&operator) } + /// A plain SPL Token `TransferChecked` of `amount` minor units from + /// `from_account` (owned by `sender`) straight into `vault`. Nothing in + /// the market program runs: this is a third party donating tokens to a + /// vault, not the operator's `deposit_inventory`. The instruction is + /// built by hand (tag 12, amount, decimals) with the token program's + /// account order: source, mint, destination, owner. + fn donate_to_vault( + &mut self, + sender: &Keypair, + from_account: &Address, + mint: &Address, + decimals: u8, + vault: &Address, + amount: u64, + ) { + let mut data = vec![12u8]; + data.extend_from_slice(&amount.to_le_bytes()); + data.push(decimals); + let transfer = Instruction::new_with_bytes( + token_program_id(), + &data, + vec![ + AccountMeta::new(*from_account, false), + AccountMeta::new_readonly(*mint, false), + AccountMeta::new(*vault, false), + AccountMeta::new_readonly(sender.pubkey(), true), + ], + ); + send_transaction_from_instructions( + &mut self.svm, + vec![transfer], + &[sender], + &sender.pubkey(), + ) + .expect("a plain token transfer into a vault should succeed"); + } + fn lamports(&self, address: &Address) -> u64 { self.svm .get_account(address) @@ -751,6 +789,82 @@ fn test_close_market_refuses_while_a_vault_holds_tokens() { market.close_market().expect("an empty market must close"); } +/// Nobody can wedge the close or slip tokens past it by sending them straight +/// to a vault. After Maria withdraws everything, a stranger sends one minor +/// unit of NVDAx into the base vault with a plain token transfer, not +/// `deposit_inventory`, and the close is refused; then one minor unit of USDC +/// into the quote vault, and the close is refused again. Maria withdraws each +/// donation like any other inventory and the market closes. +#[test] +fn test_close_market_refuses_tokens_sent_straight_to_a_vault() { + let mut market = Market::default_market(); + market + .withdraw_inventory(1_000 * ONE_NVDAX, 200_000 * ONE_USDC) + .unwrap(); + let (stranger, stranger_base, stranger_quote) = market.funded_trader(1, 1); + + let base_mint = market.base_mint; + let base_vault = market.base_vault; + market.donate_to_vault( + &stranger, + &stranger_base, + &base_mint, + NVDAX_DECIMALS, + &base_vault, + 1, + ); + assert_eq!(market.balance(&base_vault), 1); + assert_fails_with(market.close_market(), PropAmmError::InventoryNotEmpty); + assert!(market.svm.get_account(&market.market).is_some()); + market.withdraw_inventory(1, 0).unwrap(); + + let quote_mint = market.quote_mint; + let quote_vault = market.quote_vault; + market.donate_to_vault( + &stranger, + &stranger_quote, + "e_mint, + USDC_DECIMALS, + "e_vault, + 1, + ); + assert_eq!(market.balance("e_vault), 1); + // The retry is otherwise byte-identical to the refused close, so a fresh + // blockhash gives it a new signature. + market.svm.expire_blockhash(); + assert_fails_with(market.close_market(), PropAmmError::InventoryNotEmpty); + assert!(market.svm.get_account(&market.market).is_some()); + market.withdraw_inventory(0, 1).unwrap(); + + market.svm.expire_blockhash(); + market.close_market().expect("an empty market must close"); + assert!(market.svm.get_account(&market.market).is_none()); + // The operator now holds its own inventory plus both donated units. + let operator_base = market.operator_base; + let operator_quote = market.operator_quote; + assert_eq!(market.balance(&operator_base), 10_000 * ONE_NVDAX + 1); + assert_eq!(market.balance(&operator_quote), 10_000_000 * ONE_USDC + 1); +} + +/// A closed market cannot fill. Its account is gone, so a swap naming it +/// fails before any token moves, and the trader keeps every token. +#[test] +fn test_swap_against_a_closed_market_fails() { + let mut market = Market::default_market(); + market + .withdraw_inventory(1_000 * ONE_NVDAX, 200_000 * ONE_USDC) + .unwrap(); + market.close_market().unwrap(); + + let (alice, alice_base, alice_quote) = market.funded_trader(0, FIVE_NVDAX_AT_THE_ASK); + assert_fails_with_anchor_error( + market.swap(&alice, Direction::BuyBase, FIVE_NVDAX_AT_THE_ASK, 0), + AnchorErrorCode::AccountNotInitialized, + ); + assert_eq!(market.balance(&alice_base), 0); + assert_eq!(market.balance(&alice_quote), FIVE_NVDAX_AT_THE_ASK); +} + #[test] fn test_close_market_rejects_non_operator() { let mut market = Market::default_market(); diff --git a/finance/prop-amm/quasar/src/tests.rs b/finance/prop-amm/quasar/src/tests.rs index e443eb8f2..4c5dce12b 100644 --- a/finance/prop-amm/quasar/src/tests.rs +++ b/finance/prop-amm/quasar/src/tests.rs @@ -311,6 +311,37 @@ fn close_market(test: &mut Test, env: &Env, signer: Pubkey) -> Outcome { }) } +/// A plain SPL Token `transfer_checked` (instruction 12) of `amount` minor +/// units from `from_account` (owned by `sender`) straight into `vault`. +/// Nothing in the market program runs: this is a third party donating tokens +/// to a vault, not the operator's `deposit_inventory`. +fn donate_to_vault( + test: &mut Test, + sender: Pubkey, + from_account: Pubkey, + mint: Pubkey, + decimals: u8, + vault: Pubkey, + amount: u64, +) { + let before = test.tokens(vault); + let mut data = vec![12u8]; + data.extend_from_slice(&amount.to_le_bytes()); + data.push(decimals); + test.send(Instruction { + program_id: SPL_TOKEN_PROGRAM_ID, + accounts: vec![ + AccountMeta::new(from_account, false), + AccountMeta::new_readonly(mint, false), + AccountMeta::new(vault, false), + AccountMeta::new_readonly(sender, true), + ], + data, + }) + .succeeds() + .has_tokens(vault, before + amount); +} + /// Write raw bytes as the feed account, owned by the program the market /// recorded, so only the layout and value checks can refuse it. fn set_feed_data(test: &mut Test, data: Vec) { @@ -581,6 +612,104 @@ fn close_market_refuses_while_a_vault_holds_tokens(test: &mut Test) { .is_closed(env.market); } +/// Nobody can wedge the close or slip tokens past it by sending them straight +/// to a vault. After Maria withdraws everything, a stranger sends one minor +/// unit of NVDAx into the base vault with a plain token transfer, not +/// `deposit_inventory`, and the close is refused; then one minor unit of USDC +/// into the quote vault, and the close is refused again. Maria withdraws each +/// donation like any other inventory and the market closes. +#[quasar_test] +fn close_market_refuses_tokens_sent_straight_to_a_vault(test: &mut Test) { + let env = setup(test); + withdraw_inventory( + test, + &env, + OPERATOR, + OPERATOR_BASE, + OPERATOR_QUOTE, + 1_000 * ONE_NVDAX, + 200_000 * ONE_USDC, + ) + .succeeds(); + fund_trader(test, MALLORY, MALLORY_BASE, MALLORY_QUOTE, 1, 1); + + donate_to_vault( + test, + MALLORY, + MALLORY_BASE, + BASE_MINT, + NVDAX_DECIMALS, + env.base_vault, + 1, + ); + close_market(test, &env, OPERATOR).fails_with(error::INVENTORY_NOT_EMPTY); + assert!(test.account(env.market).is_some()); + withdraw_inventory(test, &env, OPERATOR, OPERATOR_BASE, OPERATOR_QUOTE, 1, 0).succeeds(); + + donate_to_vault( + test, + MALLORY, + MALLORY_QUOTE, + QUOTE_MINT, + USDC_DECIMALS, + env.quote_vault, + 1, + ); + close_market(test, &env, OPERATOR).fails_with(error::INVENTORY_NOT_EMPTY); + assert!(test.account(env.market).is_some()); + withdraw_inventory(test, &env, OPERATOR, OPERATOR_BASE, OPERATOR_QUOTE, 0, 1).succeeds(); + + close_market(test, &env, OPERATOR) + .succeeds() + .is_closed(env.market); + // The operator now holds its own inventory plus both donated units. + assert_eq!(test.tokens(OPERATOR_BASE), 10_000 * ONE_NVDAX + 1); + assert_eq!(test.tokens(OPERATOR_QUOTE), 10_000_000 * ONE_USDC + 1); +} + +/// A closed market cannot fill. Its account is gone, so a swap naming it +/// fails before any token moves, and the trader keeps every token. +#[quasar_test] +fn swap_against_a_closed_market_fails(test: &mut Test) { + let env = setup(test); + withdraw_inventory( + test, + &env, + OPERATOR, + OPERATOR_BASE, + OPERATOR_QUOTE, + 1_000 * ONE_NVDAX, + 200_000 * ONE_USDC, + ) + .succeeds(); + close_market(test, &env, OPERATOR) + .succeeds() + .is_closed(env.market); + + fund_trader( + test, + TRADER, + TRADER_BASE, + TRADER_QUOTE, + 0, + FIVE_NVDAX_AT_THE_ASK, + ); + let outcome = swap( + test, + &env, + TRADER, + TRADER_BASE, + TRADER_QUOTE, + DIRECTION_BUY_BASE, + FIVE_NVDAX_AT_THE_ASK, + 0, + ); + println!("CLOSED SWAP ERROR: {:?}", outcome.error()); + assert!(outcome.is_err()); + assert_eq!(test.tokens(TRADER_BASE), 0); + assert_eq!(test.tokens(TRADER_QUOTE), FIVE_NVDAX_AT_THE_ASK); +} + #[quasar_test] fn close_market_rejects_non_operator(test: &mut Test) { let env = setup(test); From 1730e544afdeaec38582f72a3db43c8e2dc8bb33 Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Wed, 7 Oct 2026 16:27:57 +0000 Subject: [PATCH 4/6] Work in progress: round deposits, PnL and funding against the user Claude-Session: https://claude.ai/code/session_01UX53A6YR1Hjr8z6WzJxf2q --- finance/perpetual-futures/anchor-v1/CHANGELOG.md | 15 +++++++++------ .../tests/test_perpetual_futures.rs | 8 ++++---- finance/perpetual-futures/anchor/CHANGELOG.md | 15 +++++++++------ .../tests/test_perpetual_futures.rs | 8 ++++---- finance/perpetual-futures/quasar/CHANGELOG.md | 15 +++++++++------ finance/perpetual-futures/quasar/src/tests.rs | 8 ++++---- .../programs/prop-amm/tests/test_prop_amm.rs | 15 ++++++++++----- finance/prop-amm/quasar/src/tests.rs | 10 ++++++---- 8 files changed, 55 insertions(+), 39 deletions(-) diff --git a/finance/perpetual-futures/anchor-v1/CHANGELOG.md b/finance/perpetual-futures/anchor-v1/CHANGELOG.md index 3b8477b44..4ea8b767c 100644 --- a/finance/perpetual-futures/anchor-v1/CHANGELOG.md +++ b/finance/perpetual-futures/anchor-v1/CHANGELOG.md @@ -5,14 +5,17 @@ Profit/loss and funding round against the trader. `position_pnl` and `position_funding` in `instructions/shared.rs` divided with truncation toward zero, so a fractional loss was booked a base unit small, and funding a trader -owed was charged a base unit short. A short's funding was also truncated -before its sign was applied, so a short that owed funding was rounded in its -own favour. `position_pnl` now floors toward negative infinity, and -`position_funding` applies the side's sign first and then rounds toward -positive infinity, so funding the trader pays rounds up and funding the trader -receives rounds down. The walkthrough's figures are exact and unchanged. +owed was charged a base unit short. `position_pnl` now floors toward +negative infinity, and `position_funding` applies the side's sign first and +then rounds toward positive infinity, so funding the trader pays rounds up +and funding the trader receives rounds down. The walkthrough's figures are exact and unchanged. Tested by `test_position_pnl_rounds_against_the_trader` and `test_position_funding_rounds_against_the_trader`. +`test_fees_and_maintenance_requirement_round_up` now liquidates at +$85.10000005 instead of $85.10000004: the old price's loss of 744,999,998.15 +base units floors to 744,999,999, and the new one's 744,999,997.65 floors to +744,999,998, so the position is still liquidated at an equity of exactly +250,000,001. Every fee rounds up. `basis_points_of` in `instructions/shared.rs` rounds its result up to the next base unit, so the open, close and liquidation fees and diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/tests/test_perpetual_futures.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/tests/test_perpetual_futures.rs index d2f16ce56..8c6789d99 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/tests/test_perpetual_futures.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/tests/test_perpetual_futures.rs @@ -2086,14 +2086,15 @@ fn test_fees_and_maintenance_requirement_round_up() { assert_eq!(pool.program_fees, 2 * 2_500_001); // The same position again, taken to an equity of exactly the rounded-up - // maintenance requirement: $85.10000004 loses it 744,999,998 base units. + // maintenance requirement: $85.10000005 loses it 744,999,997.65 base + // units, a loss of 744,999,998 once floored against the trader. // The open is byte-identical to the first, so it would carry the same // signature and be dropped as already processed without a new blockhash. market.svm.expire_blockhash(); market .open_position(&trader, trader_collateral, Side::Long, collateral, size, 0) .unwrap(); - market.set_price(8_510_000_004); + market.set_price(8_510_000_005); let (liquidator, liquidator_collateral) = market.liquidator(); market .liquidate(&liquidator, &trader.pubkey(), trader_collateral, Side::Long) @@ -2152,8 +2153,7 @@ fn test_position_pnl_rounds_against_the_trader() { /// `FUNDING_PRECISION` units of 10^9) on a position of 1,000 base units is /// 1.5 base units. A trader who pays is charged 2, where truncation would /// charge 1; a trader who is paid receives 1, the same as truncation. A short -/// is rounded the same way as a long: truncating before applying its sign -/// would have charged a paying short 1. +/// is rounded the same way as a long. #[test] fn test_position_funding_rounds_against_the_trader() { // The index rises: longs pay, shorts are paid. diff --git a/finance/perpetual-futures/anchor/CHANGELOG.md b/finance/perpetual-futures/anchor/CHANGELOG.md index 2bcdeaece..f5aebb809 100644 --- a/finance/perpetual-futures/anchor/CHANGELOG.md +++ b/finance/perpetual-futures/anchor/CHANGELOG.md @@ -5,14 +5,17 @@ Profit/loss and funding round against the trader. `position_pnl` and `position_funding` in `instructions/shared.rs` divided with truncation toward zero, so a fractional loss was booked a base unit small, and funding a trader -owed was charged a base unit short. A short's funding was also truncated -before its sign was applied, so a short that owed funding was rounded in its -own favour. `position_pnl` now floors toward negative infinity, and -`position_funding` applies the side's sign first and then rounds toward -positive infinity, so funding the trader pays rounds up and funding the trader -receives rounds down. The walkthrough's figures are exact and unchanged. +owed was charged a base unit short. `position_pnl` now floors toward +negative infinity, and `position_funding` applies the side's sign first and +then rounds toward positive infinity, so funding the trader pays rounds up +and funding the trader receives rounds down. The walkthrough's figures are exact and unchanged. Tested by `test_position_pnl_rounds_against_the_trader` and `test_position_funding_rounds_against_the_trader`. +`test_fees_and_maintenance_requirement_round_up` now liquidates at +$85.10000005 instead of $85.10000004: the old price's loss of 744,999,998.15 +base units floors to 744,999,999, and the new one's 744,999,997.65 floors to +744,999,998, so the position is still liquidated at an equity of exactly +250,000,001. Every fee rounds up. `basis_points_of` in `instructions/shared.rs` rounds its result up to the next base unit, so the open, close and liquidation fees and diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/tests/test_perpetual_futures.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/tests/test_perpetual_futures.rs index e4600159a..2545aa147 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/tests/test_perpetual_futures.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/tests/test_perpetual_futures.rs @@ -2083,14 +2083,15 @@ fn test_fees_and_maintenance_requirement_round_up() { assert_eq!(pool.program_fees, 2 * 2_500_001); // The same position again, taken to an equity of exactly the rounded-up - // maintenance requirement: $85.10000004 loses it 744,999,998 base units. + // maintenance requirement: $85.10000005 loses it 744,999,997.65 base + // units, a loss of 744,999,998 once floored against the trader. // The open is byte-identical to the first, so it would carry the same // signature and be dropped as already processed without a new blockhash. market.svm.expire_blockhash(); market .open_position(&trader, trader_collateral, Side::Long, collateral, size, 0) .unwrap(); - market.set_price(8_510_000_004); + market.set_price(8_510_000_005); let (liquidator, liquidator_collateral) = market.liquidator(); market .liquidate(&liquidator, &trader.pubkey(), trader_collateral, Side::Long) @@ -2149,8 +2150,7 @@ fn test_position_pnl_rounds_against_the_trader() { /// `FUNDING_PRECISION` units of 10^9) on a position of 1,000 base units is /// 1.5 base units. A trader who pays is charged 2, where truncation would /// charge 1; a trader who is paid receives 1, the same as truncation. A short -/// is rounded the same way as a long: truncating before applying its sign -/// would have charged a paying short 1. +/// is rounded the same way as a long. #[test] fn test_position_funding_rounds_against_the_trader() { // The index rises: longs pay, shorts are paid. diff --git a/finance/perpetual-futures/quasar/CHANGELOG.md b/finance/perpetual-futures/quasar/CHANGELOG.md index f3405d072..660c173fd 100644 --- a/finance/perpetual-futures/quasar/CHANGELOG.md +++ b/finance/perpetual-futures/quasar/CHANGELOG.md @@ -5,14 +5,17 @@ Profit/loss and funding round against the trader. `position_pnl` and `position_funding` in `instructions/shared.rs` divided with truncation toward zero, so a fractional loss was booked a base unit small, and funding a trader -owed was charged a base unit short. A short's funding was also truncated -before its sign was applied, so a short that owed funding was rounded in its -own favour. `position_pnl` now floors toward negative infinity, and -`position_funding` applies the side's sign first and then rounds toward -positive infinity, so funding the trader pays rounds up and funding the trader -receives rounds down. The walkthrough's figures are exact and unchanged. +owed was charged a base unit short. `position_pnl` now floors toward +negative infinity, and `position_funding` applies the side's sign first and +then rounds toward positive infinity, so funding the trader pays rounds up +and funding the trader receives rounds down. The walkthrough's figures are exact and unchanged. Tested by `position_pnl_rounds_against_the_trader` and `position_funding_rounds_against_the_trader`. +`fees_and_maintenance_requirement_round_up` now liquidates at +$85.10000005 instead of $85.10000004: the old price's loss of 744,999,998.15 +base units floors to 744,999,999, and the new one's 744,999,997.65 floors to +744,999,998, so the position is still liquidated at an equity of exactly +250,000,001. Every fee rounds up. `basis_points_of` in `instructions/shared.rs` rounds its result up to the next base unit, so the open, close and liquidation fees and diff --git a/finance/perpetual-futures/quasar/src/tests.rs b/finance/perpetual-futures/quasar/src/tests.rs index 7700ac2aa..99c11b8f1 100644 --- a/finance/perpetual-futures/quasar/src/tests.rs +++ b/finance/perpetual-futures/quasar/src/tests.rs @@ -1314,9 +1314,10 @@ fn fees_and_maintenance_requirement_round_up(test: &mut Test) { assert_eq!(u64::from(pool.program_fees), 2 * 2_500_001); // The same position again, taken to an equity of exactly the rounded-up - // maintenance requirement: $85.10000004 loses it 744,999,998 base units. + // maintenance requirement: $85.10000005 loses it 744,999,997.65 base + // units, a loss of 744,999,998 once floored against the trader. open_position(test, &env, SIDE_LONG, collateral, size).succeeds(); - set_feed(test, 8_510_000_004, 0); + set_feed(test, 8_510_000_005, 0); liquidate(test, &env) .succeeds() .has_tokens(LIQUIDATOR_COLLATERAL, 50_000_001) @@ -1370,8 +1371,7 @@ fn position_pnl_rounds_against_the_trader() { /// `FUNDING_PRECISION` units of 10^9) on a position of 1,000 base units is /// 1.5 base units. A trader who pays is charged 2, where truncation would /// charge 1; a trader who is paid receives 1, the same as truncation. A short -/// is rounded the same way as a long: truncating before applying its sign -/// would have charged a paying short 1. +/// is rounded the same way as a long. #[test] fn position_funding_rounds_against_the_trader() { // The index rises: longs pay, shorts are paid. diff --git a/finance/prop-amm/anchor/programs/prop-amm/tests/test_prop_amm.rs b/finance/prop-amm/anchor/programs/prop-amm/tests/test_prop_amm.rs index 974b20b32..11be07b4e 100644 --- a/finance/prop-amm/anchor/programs/prop-amm/tests/test_prop_amm.rs +++ b/finance/prop-amm/anchor/programs/prop-amm/tests/test_prop_amm.rs @@ -1,8 +1,8 @@ use { anchor_lang::{ solana_program::instruction::{AccountMeta, Instruction}, - system_program, AccountDeserialize, Address, - Error as AnchorError, ErrorCode as AnchorErrorCode, InstructionData, ToAccountMetas, + system_program, AccountDeserialize, Address, Error as AnchorError, + ErrorCode as AnchorErrorCode, InstructionData, ToAccountMetas, }, anchor_v2_testing::{Keypair, LiteSVM, Signer}, prop_amm::{ @@ -857,9 +857,14 @@ fn test_swap_against_a_closed_market_fails() { market.close_market().unwrap(); let (alice, alice_base, alice_quote) = market.funded_trader(0, FIVE_NVDAX_AT_THE_ASK); - assert_fails_with_anchor_error( - market.swap(&alice, Direction::BuyBase, FIVE_NVDAX_AT_THE_ASK, 0), - AnchorErrorCode::AccountNotInitialized, + // Anchor 2 reports the missing market as the runtime's + // `UninitializedAccount`, not a custom code. + let Err(error) = market.swap(&alice, Direction::BuyBase, FIVE_NVDAX_AT_THE_ASK, 0) else { + panic!("a swap against a closed market must fail"); + }; + assert!( + error.contains("InstructionError(0, UninitializedAccount)"), + "expected UninitializedAccount, got: {error}" ); assert_eq!(market.balance(&alice_base), 0); assert_eq!(market.balance(&alice_quote), FIVE_NVDAX_AT_THE_ASK); diff --git a/finance/prop-amm/quasar/src/tests.rs b/finance/prop-amm/quasar/src/tests.rs index 4c5dce12b..a66bdbdd7 100644 --- a/finance/prop-amm/quasar/src/tests.rs +++ b/finance/prop-amm/quasar/src/tests.rs @@ -694,7 +694,10 @@ fn swap_against_a_closed_market_fails(test: &mut Test) { 0, FIVE_NVDAX_AT_THE_ASK, ); - let outcome = swap( + // The closed market's address is an empty system account, so the + // runtime's owner check refuses it as `IllegalOwner` before the handler + // runs. + swap( test, &env, TRADER, @@ -703,9 +706,8 @@ fn swap_against_a_closed_market_fails(test: &mut Test) { DIRECTION_BUY_BASE, FIVE_NVDAX_AT_THE_ASK, 0, - ); - println!("CLOSED SWAP ERROR: {:?}", outcome.error()); - assert!(outcome.is_err()); + ) + .fails(ProgramError::Runtime("IllegalOwner".into())); assert_eq!(test.tokens(TRADER_BASE), 0); assert_eq!(test.tokens(TRADER_QUOTE), FIVE_NVDAX_AT_THE_ASK); } From ab1d9f7b0b2a2ad735af607d083e19ee1bf14556 Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Wed, 7 Oct 2026 18:03:19 +0000 Subject: [PATCH 5/6] Work in progress: LP pricing and sell floor round against the user Claude-Session: https://claude.ai/code/session_01UX53A6YR1Hjr8z6WzJxf2q --- finance/managed-fund/anchor-v1/CHANGELOG.md | 3 +- finance/managed-fund/anchor-v1/README.md | 6 +- .../anchor-v1/app/src/idl/managed_fund.json | 2 +- .../programs/managed-fund/src/error.rs | 2 +- .../src/instructions/rebalance.rs | 14 +-- .../programs/managed-fund/src/oracle.rs | 33 ++++++- .../managed-fund/tests/managed_fund.rs | 35 ++++++- finance/managed-fund/anchor/CHANGELOG.md | 3 +- finance/managed-fund/anchor/README.md | 6 +- .../anchor/app/src/idl/managed_fund.json | 2 +- .../anchor/programs/managed-fund/src/error.rs | 2 +- .../src/instructions/rebalance.rs | 14 +-- .../programs/managed-fund/src/oracle.rs | 33 ++++++- .../managed-fund/tests/managed_fund.rs | 35 ++++++- finance/managed-fund/kani-proofs/README.md | 2 + finance/managed-fund/kani-proofs/src/lib.rs | 92 +++++++++++++++++++ finance/managed-fund/quasar/CHANGELOG.md | 9 ++ finance/managed-fund/quasar/README.md | 7 +- .../quasar/managed-fund/src/errors.rs | 2 +- .../src/instructions/rebalance.rs | 14 +-- .../quasar/managed-fund/src/oracle.rs | 33 ++++++- .../quasar/managed-fund/src/tests.rs | 40 +++++++- .../perpetual-futures/anchor-v1/CHANGELOG.md | 17 ++++ finance/perpetual-futures/anchor-v1/README.md | 3 +- .../src/instructions/add_liquidity.rs | 10 +- .../src/instructions/remove_liquidity.rs | 8 +- .../src/instructions/shared.rs | 77 +++++++++++++--- .../tests/test_perpetual_futures.rs | 75 +++++++++++++++ finance/perpetual-futures/anchor/CHANGELOG.md | 17 ++++ finance/perpetual-futures/anchor/README.md | 3 +- .../src/instructions/add_liquidity.rs | 10 +- .../src/instructions/remove_liquidity.rs | 8 +- .../src/instructions/shared.rs | 77 +++++++++++++--- .../tests/test_perpetual_futures.rs | 75 +++++++++++++++ finance/perpetual-futures/quasar/CHANGELOG.md | 17 ++++ finance/perpetual-futures/quasar/README.md | 4 + .../quasar/src/instructions/add_liquidity.rs | 5 +- .../src/instructions/remove_liquidity.rs | 5 +- .../quasar/src/instructions/shared.rs | 65 +++++++++++-- finance/perpetual-futures/quasar/src/tests.rs | 53 +++++++++++ 40 files changed, 820 insertions(+), 98 deletions(-) diff --git a/finance/managed-fund/anchor-v1/CHANGELOG.md b/finance/managed-fund/anchor-v1/CHANGELOG.md index 11a6b6e46..7ef472ccd 100644 --- a/finance/managed-fund/anchor-v1/CHANGELOG.md +++ b/finance/managed-fund/anchor-v1/CHANGELOG.md @@ -10,8 +10,9 @@ ### Fixed +- **Rebalance's sell floor rounds up.** `rebalance` set the sell leg's minimum output, `minimum_usdc_from_sell`, by flooring the oracle value of what it sells and then flooring the slippage tolerance's share of that, so the floor could sit up to a minor unit below the exact figure and accept a sale that paid the fund a fraction of a minor unit less than its tolerance allows. It now takes the floor from the new `asset_value_share_in_usdc_rounded_up`, which rounds `amount × price × (10_000 − max_slippage_bps) × 10^(usdc_decimals + exponent − asset_decimals) / 10_000` up in one division. Tested by `test_rebalance_sell_floor_rounds_up`: with NVDAx at $200.00000001 the trade sells 11,999,999 NVDAx minor units, whose 99% is 23,759,998.001188 USDC minor units, so a router paying 23,759,998 (exactly 1% under $200) is refused with `SlippageExceeded` where the old floor of 23,759,998 accepted it, and one paying 23,759,999 goes through. The Kani crate models the floor, and the new `proof_sell_floor_rounds_in_the_funds_favour` checks it is never below the exact figure, under one minor unit above it, and never below the old floor. The walkthrough's rebalance sells an exact 24 USDC and is unchanged. - **Deposit values the basket rounding up.** `deposit` priced shares as `usdc_amount × total_shares / nav` with each asset's value in `nav` floored by `asset_value_in_usdc`, so NAV read up to a minor unit per asset low and a depositor could be minted a share more than their USDC bought, paid for by the holders already in the fund. `deposit` now values each asset with the new `asset_value_in_usdc_rounded_up`, so NAV is never understated by rounding and the floored share count rounds against the depositor. `withdraw` pays out in kind as a floored proportion of each holding and reads no price, so it is unchanged, and `rebalance` keeps the floored valuation. Tested by `test_deposit_values_assets_rounding_up`: after a 1 USDC first deposit the fund holds 333,333 NVDAx minor units worth 599,999.4 USDC minor units, and a second 1 USDC deposit mints 1,000,000 shares where a floored NAV of 999,999 would have minted 1,000,001. The Kani crate models the ceiling with `asset_value_in_usdc_rounded_up`, and the new `proof_deposit_nav_rounds_against_the_depositor` checks it is at most one minor unit above the floor and never mints more shares than the floored NAV would. The walkthrough's figures are exact and unchanged. -- **A partially verified Pyth update is refused.** `load_price` read the price at fixed offsets (price at 73) that assume the one-byte encoding of `verification_level`, `Full`. A `Partial { num_signatures }` update, verified by fewer than a quorum of Pyth's guardian set, encodes it in two bytes, so every later field sits a byte further along and the price, confidence, exponent, publish time and posted slot would all be read from the wrong bytes. `load_price` now requires the tag at offset 40 to be `Full` (1) and fails with the new `PriceNotFullyVerified` error otherwise; it does not try to parse a `Partial` update. Tested by `test_partially_verified_price_rejected`. The web app's IDL gains the error. +- **A partially verified Pyth update is refused.** `load_price` read the price at fixed offsets (price at 73) that assume the one-byte encoding of `verification_level`, `Full`. A `Partial { num_signatures }` update, verified by fewer than a quorum of Pyth's signers, encodes it in two bytes, so every later field sits a byte further along and the price, confidence, exponent, publish time and posted slot would all be read from the wrong bytes. `load_price` now requires the tag at offset 40 to be `Full` (1) and fails with the new `PriceNotFullyVerified` error otherwise; it does not try to parse a `Partial` update. Tested by `test_partially_verified_price_rejected`. The web app's IDL gains the error. ## 2026-10-03 diff --git a/finance/managed-fund/anchor-v1/README.md b/finance/managed-fund/anchor-v1/README.md index 9deccdc7b..319e21067 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. 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. The fields are read at fixed byte offsets that assume the update's `verification_level` is `Full`, meaning a quorum of Pyth's guardian set (three of five) verified it, so `load_price` checks that tag (offset 40) first and refuses anything else with `PriceNotFullyVerified`. A `Partial` update was verified by fewer signatures, and its `verification_level` encodes in two bytes rather than one, which would move every later field a byte along. `withdraw` reads no price, so investors can always leave in kind while deposits and rebalances wait for the band to narrow. +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. The fields are read at fixed byte offsets that assume the update's `verification_level` is `Full`, meaning a quorum of Pyth's signers (three of five) verified it, so `load_price` checks that tag (offset 40) first and refuses anything else with `PriceNotFullyVerified`. A `Partial` update was verified by fewer signatures, and its `verification_level` encodes in two bytes rather than one, which would move every later field a byte along. `withdraw` reads no price, so investors can always leave in kind while deposits and rebalances wait for the band to narrow. ### Shares @@ -145,7 +145,7 @@ What remains to trust: the honesty of the registered router and registry. The ma ## Financial Math Implementation - Integer arithmetic only; intermediate products use `u128`; multiply before divide. -- All arithmetic uses `checked_*`. Deposits and withdrawals floor in the fund's favour: a depositor's shares and a withdrawer's payout round down, and the fund keeps the remainder. Deposit values each asset rounding up (`asset_value_in_usdc_rounded_up`), so NAV is never understated by rounding and the floored share count cannot hand a depositor a share the holders paid for; `test_deposit_values_assets_rounding_up` checks a second 1 USDC deposit against a NAV of 999,999.4 mints 1,000,000 shares, not the 1,000,001 a floored NAV would. The management fee rounds up, so the manager is never minted less than the fee owed. +- All arithmetic uses `checked_*`. Deposits and withdrawals floor in the fund's favour: a depositor's shares and a withdrawer's payout round down, and the fund keeps the remainder. Deposit values each asset rounding up (`asset_value_in_usdc_rounded_up`), so NAV is never understated by rounding and the floored share count cannot hand a depositor a share the holders paid for; `test_deposit_values_assets_rounding_up` checks a second 1 USDC deposit against a NAV of 999,999.4 mints 1,000,000 shares, not the 1,000,001 a floored NAV would. The management fee rounds up, so the manager is never minted less than the fee owed. Rebalance's sell-leg slippage floor rounds up too (`asset_value_share_in_usdc_rounded_up`), so it is never looser than the tolerance; `test_rebalance_sell_floor_rounds_up` checks a sale paying 23,759,998 USDC minor units against an exact floor of 23,759,998.001188 is refused. - `transfer_checked` carries decimals through every token CPI. --- @@ -163,7 +163,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. TSLAx and NVDAx are minted with eight decimals, as the real tokens are, and USDC with six, so the basket amounts the tests assert are in eight-decimal minor units while shares and USDC stay in six. 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, each asserting the error code it fails with: unapproved asset, weight overflow, over-cap fee and slippage, oracle-bounded deposit slippage (the router's `SlippageExceeded`, raised inside the swap CPI), an under-allocated fund, non-manager `set_weight` (Anchor's own constraint error), 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, and `test_valuation_scales_by_nine_decimals_and_exponent` does the same with TSLAx at nine decimals. `test_collect_fees` checks a year's fee comes out exact and `test_collect_fees_rounds_up` that a day's fee rounds up to the manager. `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_partially_verified_price_rejected` writes NVDAx's feed as a `Partial` update signed by two guardians and checks that a deposit fails with `PriceNotFullyVerified`, then rewrites it as `Full` at the same price and checks the deposit prices exactly as `test_deposit_first` does. `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. TSLAx and NVDAx are minted with eight decimals, as the real tokens are, and USDC with six, so the basket amounts the tests assert are in eight-decimal minor units while shares and USDC stay in six. 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, each asserting the error code it fails with: unapproved asset, weight overflow, over-cap fee and slippage, oracle-bounded deposit slippage (the router's `SlippageExceeded`, raised inside the swap CPI), an under-allocated fund, non-manager `set_weight` (Anchor's own constraint error), 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, and `test_valuation_scales_by_nine_decimals_and_exponent` does the same with TSLAx at nine decimals. `test_collect_fees` checks a year's fee comes out exact and `test_collect_fees_rounds_up` that a day's fee rounds up to the manager. `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_partially_verified_price_rejected` writes NVDAx's feed as a `Partial` update signed by two signers and checks that a deposit fails with `PriceNotFullyVerified`, then rewrites it as `Full` at the same price and checks the deposit prices exactly as `test_deposit_first` does. `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 677aed539..accc8b0f4 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 @@ -1056,7 +1056,7 @@ { "code": 6033, "name": "PriceNotFullyVerified", - "msg": "Pyth price update is not fully verified by the guardian set" + "msg": "Pyth price update is not fully verified by a quorum of Pyth's signers" } ], "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 1451ab61e..1f7ff6bea 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 @@ -68,6 +68,6 @@ pub enum FundError { NotUnderweight, #[msg("Pyth price confidence interval is too wide to trust")] OracleConfidenceTooWide, - #[msg("Pyth price update is not fully verified by the guardian set")] + #[msg("Pyth price update is not fully verified by a quorum of Pyth's signers")] PriceNotFullyVerified, } diff --git a/finance/managed-fund/anchor-v1/programs/managed-fund/src/instructions/rebalance.rs b/finance/managed-fund/anchor-v1/programs/managed-fund/src/instructions/rebalance.rs index ac30d48a4..090c700e5 100644 --- a/finance/managed-fund/anchor-v1/programs/managed-fund/src/instructions/rebalance.rs +++ b/finance/managed-fund/anchor-v1/programs/managed-fund/src/instructions/rebalance.rs @@ -10,7 +10,8 @@ use mock_swap_router::cpi::accounts::{ use crate::error::FundError; use crate::oracle::{ - asset_value_in_usdc, load_price, read_token_amount, usdc_to_asset_amount, OraclePrice, + asset_value_in_usdc, asset_value_share_in_usdc_rounded_up, load_price, read_token_amount, + usdc_to_asset_amount, OraclePrice, }; use crate::state::{AssetConfig, Fund, MAX_ASSETS}; @@ -184,17 +185,16 @@ pub fn handle_rebalance<'info>( FundError::InsufficientHoldings ); - // Sell leg floor: USDC out within slippage of the oracle value of what is sold. - let minimum_usdc_from_sell: u64 = asset_value_in_usdc( + // Sell leg floor: USDC out within slippage of the oracle value of what is + // sold, rounded up in the fund's favour so the floor is never looser than + // the tolerance. + let minimum_usdc_from_sell: u64 = asset_value_share_in_usdc_rounded_up( sell_amount as u128, sell_price, sell_config.decimals, usdc_decimals, + slip, )? - .checked_mul(slip) - .ok_or(FundError::MathOverflow)? - .checked_div(10_000) - .ok_or(FundError::MathOverflow)? .try_into() .map_err(|_| FundError::MathOverflow)?; 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 86f62cbc2..656948133 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 @@ -7,7 +7,7 @@ use crate::error::FundError; /// PriceUpdateV2 account: 8 discriminator + 32 write_authority = 40. const PYTH_VERIFICATION_LEVEL_OFFSET: usize = 40; /// Borsh tag of `VerificationLevel::Full`, a price verified against a quorum -/// of Pyth's guardian set. `Partial { num_signatures }` is tag 0 followed by a +/// of Pyth's signers. `Partial { num_signatures }` is tag 0 followed by a /// one-byte signature count, so it encodes in two bytes rather than one and /// moves every later field one byte along. The offsets below assume `Full`. const PYTH_VERIFICATION_LEVEL_FULL: u8 = 1; @@ -57,7 +57,7 @@ fn read_pyth_raw(account_data: &[u8]) -> Result<(i64, u64, i32, i64, u64)> { return err!(FundError::InvalidPriceFeed); } // Refuse anything but a fully verified update. A partially verified one - // was signed by fewer than a quorum of the guardian set, and its longer + // was signed by fewer than a quorum of Pyth's signers, and its longer // `verification_level` encoding would shift every offset below by a byte, // so its price would be read from the wrong bytes. require!( @@ -96,7 +96,7 @@ fn read_pyth_raw(account_data: &[u8]) -> Result<(i64, u64, i32, i64, u64)> { /// 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, and -/// so is an update Pyth's guardian set did not fully verify. +/// so is an update a quorum of Pyth's signers did not fully verify. pub fn load_price( price_feed: &AccountInfo, expected_key: &Pubkey, @@ -267,6 +267,33 @@ pub fn asset_value_in_usdc_rounded_up( ) } +/// `share_bps` of the value of `amount` asset minor units in USDC minor units, +/// `amount * price * share_bps * 10^(usdc_decimals + exponent - asset_decimals) +/// / 10_000`, rounded up in one division. Rebalance sets its sell leg's +/// minimum output to this, with `share_bps` the part of the value the +/// slippage tolerance keeps. Flooring the value and then flooring the share +/// again would let the floor sit up to a minor unit below the exact figure, +/// accepting a sale that pays the fund less than its tolerance allows; +/// rounding the exact product up keeps the floor at or above it. +pub fn asset_value_share_in_usdc_rounded_up( + amount: u128, + price: OraclePrice, + asset_decimals: u8, + usdc_decimals: u8, + share_bps: u128, +) -> Result { + let power = usdc_decimals as i32 + price.exponent - asset_decimals as i32; + mul_pow10_div_ceil( + amount + .checked_mul(price.price) + .ok_or(FundError::MathOverflow)? + .checked_mul(share_bps) + .ok_or(FundError::MathOverflow)?, + power, + 10_000, + ) +} + /// The inverse of `asset_value_in_usdc`: how many asset minor units /// `usdc_amount` USDC minor units buys at the oracle price, /// usdc_amount * 10^(asset_decimals - exponent - usdc_decimals) / price. Floored. 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 cd185a763..026eec364 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 @@ -1355,6 +1355,39 @@ fn test_rebalance() { 0 ); } +/// Rebalance's sell floor rounds up, in the fund's favour. With NVDAx at +/// $200.00000001 the trade sells 11,999,999 NVDAx minor units, worth +/// 23,999,998.0012 USDC minor units, and the 1% tolerance keeps +/// 23,759,998.001188 of that, so the floor is 23,759,999. A router quoting +/// exactly 1% under $200 pays 23,759,998: a floor taken from the value floored +/// to 23,999,998 would be 23,759,998 and accept that sale, a fraction of a +/// minor unit short of the tolerance. Here it is refused, and a quote that pays +/// 23,759,999 goes through. +#[test] +fn test_rebalance_sell_floor_rounds_up() { + let mut ctx = setup_full(); + standard_fund(&mut ctx); + let alice = fund_user(&mut ctx, 900_000_000); + do_deposit(&mut ctx, &alice, 900_000_000, 1); + + set_nvda_price(&mut ctx, 20_000_000_001, 198_000_000); + assert_router_error( + try_rebalance(&mut ctx, 1, 0), + RouterError::SlippageExceeded, + "a sale one minor unit under the rounded-up floor", + ); + + // 11,999,999 * 198,000,009 / 10^8 = 23,759,999.08, floored by the router. + set_nvda_price(&mut ctx, 20_000_000_001, 198_000_009); + do_rebalance(&mut ctx, 1, 0); + + // 23,759,999 USDC buys 9,503,999 TSLAx minor units at $250. + let fund = read_fund(&ctx); + assert_eq!(fund.asset_holdings[1], 300_000_000 - 11_999_999); + assert_eq!(fund.asset_holdings[0], 144_000_000 + 9_503_999); + assert_eq!(fund.usdc_holdings, 0); + assert_holdings_match_vaults(&ctx); +} #[test] fn test_collect_fees() { @@ -2339,7 +2372,7 @@ fn test_wide_confidence_price_rejected() { } /// The fund reads a Pyth update at fixed offsets that assume a fully verified -/// one. A partially verified update, signed by two of the five guardians, is +/// one. A partially verified update, signed by two of the five signers, is /// refused with `PriceNotFullyVerified` rather than read a byte off. Rewritten /// as fully verified at the same price, the same deposit prices exactly as /// `test_deposit_first` does. diff --git a/finance/managed-fund/anchor/CHANGELOG.md b/finance/managed-fund/anchor/CHANGELOG.md index e8d54096a..837032816 100644 --- a/finance/managed-fund/anchor/CHANGELOG.md +++ b/finance/managed-fund/anchor/CHANGELOG.md @@ -10,8 +10,9 @@ ### Fixed +- **Rebalance's sell floor rounds up.** `rebalance` set the sell leg's minimum output, `minimum_usdc_from_sell`, by flooring the oracle value of what it sells and then flooring the slippage tolerance's share of that, so the floor could sit up to a minor unit below the exact figure and accept a sale that paid the fund a fraction of a minor unit less than its tolerance allows. It now takes the floor from the new `asset_value_share_in_usdc_rounded_up`, which rounds `amount × price × (10_000 − max_slippage_bps) × 10^(usdc_decimals + exponent − asset_decimals) / 10_000` up in one division. Tested by `test_rebalance_sell_floor_rounds_up`: with NVDAx at $200.00000001 the trade sells 11,999,999 NVDAx minor units, whose 99% is 23,759,998.001188 USDC minor units, so a router paying 23,759,998 (exactly 1% under $200) is refused with `SlippageExceeded` where the old floor of 23,759,998 accepted it, and one paying 23,759,999 goes through. The Kani crate models the floor, and the new `proof_sell_floor_rounds_in_the_funds_favour` checks it is never below the exact figure, under one minor unit above it, and never below the old floor. The walkthrough's rebalance sells an exact 24 USDC and is unchanged. - **Deposit values the basket rounding up.** `deposit` priced shares as `usdc_amount × total_shares / nav` with each asset's value in `nav` floored by `asset_value_in_usdc`, so NAV read up to a minor unit per asset low and a depositor could be minted a share more than their USDC bought, paid for by the holders already in the fund. `deposit` now values each asset with the new `asset_value_in_usdc_rounded_up`, so NAV is never understated by rounding and the floored share count rounds against the depositor. `withdraw` pays out in kind as a floored proportion of each holding and reads no price, so it is unchanged, and `rebalance` keeps the floored valuation. Tested by `test_deposit_values_assets_rounding_up`: after a 1 USDC first deposit the fund holds 333,333 NVDAx minor units worth 599,999.4 USDC minor units, and a second 1 USDC deposit mints 1,000,000 shares where a floored NAV of 999,999 would have minted 1,000,001. The Kani crate models the ceiling with `asset_value_in_usdc_rounded_up`, and the new `proof_deposit_nav_rounds_against_the_depositor` checks it is at most one minor unit above the floor and never mints more shares than the floored NAV would. The walkthrough's figures are exact and unchanged. -- **A partially verified Pyth update is refused.** `load_price` read the price at fixed offsets (price at 73) that assume the one-byte encoding of `verification_level`, `Full`. A `Partial { num_signatures }` update, verified by fewer than a quorum of Pyth's guardian set, encodes it in two bytes, so every later field sits a byte further along and the price, confidence, exponent, publish time and posted slot would all be read from the wrong bytes. `load_price` now requires the tag at offset 40 to be `Full` (1) and fails with the new `PriceNotFullyVerified` error otherwise; it does not try to parse a `Partial` update. Tested by `test_partially_verified_price_rejected`. The web app's IDL gains the error. +- **A partially verified Pyth update is refused.** `load_price` read the price at fixed offsets (price at 73) that assume the one-byte encoding of `verification_level`, `Full`. A `Partial { num_signatures }` update, verified by fewer than a quorum of Pyth's signers, encodes it in two bytes, so every later field sits a byte further along and the price, confidence, exponent, publish time and posted slot would all be read from the wrong bytes. `load_price` now requires the tag at offset 40 to be `Full` (1) and fails with the new `PriceNotFullyVerified` error otherwise; it does not try to parse a `Partial` update. Tested by `test_partially_verified_price_rejected`. The web app's IDL gains the error. ## 2026-10-03 diff --git a/finance/managed-fund/anchor/README.md b/finance/managed-fund/anchor/README.md index ae38e401a..2f8b161ba 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. 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. The fields are read at fixed byte offsets that assume the update's `verification_level` is `Full`, meaning a quorum of Pyth's guardian set (three of five) verified it, so `load_price` checks that tag (offset 40) first and refuses anything else with `PriceNotFullyVerified`. A `Partial` update was verified by fewer signatures, and its `verification_level` encodes in two bytes rather than one, which would move every later field a byte along. `withdraw` reads no price, so investors can always leave in kind while deposits and rebalances wait for the band to narrow. +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. The fields are read at fixed byte offsets that assume the update's `verification_level` is `Full`, meaning a quorum of Pyth's signers (three of five) verified it, so `load_price` checks that tag (offset 40) first and refuses anything else with `PriceNotFullyVerified`. A `Partial` update was verified by fewer signatures, and its `verification_level` encodes in two bytes rather than one, which would move every later field a byte along. `withdraw` reads no price, so investors can always leave in kind while deposits and rebalances wait for the band to narrow. ### Shares @@ -145,7 +145,7 @@ What remains to trust: the honesty of the registered router and registry. The ma ## Financial Math Implementation - Integer arithmetic only; intermediate products use `u128`; multiply before divide. -- All arithmetic uses `checked_*`. Deposits and withdrawals floor in the fund's favour: a depositor's shares and a withdrawer's payout round down, and the fund keeps the remainder. Deposit values each asset rounding up (`asset_value_in_usdc_rounded_up`), so NAV is never understated by rounding and the floored share count cannot hand a depositor a share the holders paid for; `test_deposit_values_assets_rounding_up` checks a second 1 USDC deposit against a NAV of 999,999.4 mints 1,000,000 shares, not the 1,000,001 a floored NAV would. The management fee rounds up, so the manager is never minted less than the fee owed. +- All arithmetic uses `checked_*`. Deposits and withdrawals floor in the fund's favour: a depositor's shares and a withdrawer's payout round down, and the fund keeps the remainder. Deposit values each asset rounding up (`asset_value_in_usdc_rounded_up`), so NAV is never understated by rounding and the floored share count cannot hand a depositor a share the holders paid for; `test_deposit_values_assets_rounding_up` checks a second 1 USDC deposit against a NAV of 999,999.4 mints 1,000,000 shares, not the 1,000,001 a floored NAV would. The management fee rounds up, so the manager is never minted less than the fee owed. Rebalance's sell-leg slippage floor rounds up too (`asset_value_share_in_usdc_rounded_up`), so it is never looser than the tolerance; `test_rebalance_sell_floor_rounds_up` checks a sale paying 23,759,998 USDC minor units against an exact floor of 23,759,998.001188 is refused. - `transfer_checked` carries decimals through every token CPI. --- @@ -163,7 +163,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. TSLAx and NVDAx are minted with eight decimals, as the real tokens are, and USDC with six, so the basket amounts the tests assert are in eight-decimal minor units while shares and USDC stay in six. 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, each asserting the error code it fails with: unapproved asset, weight overflow, over-cap fee and slippage, oracle-bounded deposit slippage (the router's `SlippageExceeded`, raised inside the swap CPI), an under-allocated fund, non-manager `set_weight` (Anchor's own constraint error), 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, and `test_valuation_scales_by_nine_decimals_and_exponent` does the same with TSLAx at nine decimals. `test_collect_fees` checks a year's fee comes out exact and `test_collect_fees_rounds_up` that a day's fee rounds up to the manager. `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_partially_verified_price_rejected` writes NVDAx's feed as a `Partial` update signed by two guardians and checks that a deposit fails with `PriceNotFullyVerified`, then rewrites it as `Full` at the same price and checks the deposit prices exactly as `test_deposit_first` does. `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. TSLAx and NVDAx are minted with eight decimals, as the real tokens are, and USDC with six, so the basket amounts the tests assert are in eight-decimal minor units while shares and USDC stay in six. 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, each asserting the error code it fails with: unapproved asset, weight overflow, over-cap fee and slippage, oracle-bounded deposit slippage (the router's `SlippageExceeded`, raised inside the swap CPI), an under-allocated fund, non-manager `set_weight` (Anchor's own constraint error), 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, and `test_valuation_scales_by_nine_decimals_and_exponent` does the same with TSLAx at nine decimals. `test_collect_fees` checks a year's fee comes out exact and `test_collect_fees_rounds_up` that a day's fee rounds up to the manager. `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_partially_verified_price_rejected` writes NVDAx's feed as a `Partial` update signed by two signers and checks that a deposit fails with `PriceNotFullyVerified`, then rewrites it as `Full` at the same price and checks the deposit prices exactly as `test_deposit_first` does. `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 677aed539..accc8b0f4 100644 --- a/finance/managed-fund/anchor/app/src/idl/managed_fund.json +++ b/finance/managed-fund/anchor/app/src/idl/managed_fund.json @@ -1056,7 +1056,7 @@ { "code": 6033, "name": "PriceNotFullyVerified", - "msg": "Pyth price update is not fully verified by the guardian set" + "msg": "Pyth price update is not fully verified by a quorum of Pyth's signers" } ], "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 1451ab61e..1f7ff6bea 100644 --- a/finance/managed-fund/anchor/programs/managed-fund/src/error.rs +++ b/finance/managed-fund/anchor/programs/managed-fund/src/error.rs @@ -68,6 +68,6 @@ pub enum FundError { NotUnderweight, #[msg("Pyth price confidence interval is too wide to trust")] OracleConfidenceTooWide, - #[msg("Pyth price update is not fully verified by the guardian set")] + #[msg("Pyth price update is not fully verified by a quorum of Pyth's signers")] PriceNotFullyVerified, } diff --git a/finance/managed-fund/anchor/programs/managed-fund/src/instructions/rebalance.rs b/finance/managed-fund/anchor/programs/managed-fund/src/instructions/rebalance.rs index f30aba4e9..af5d2ec7c 100644 --- a/finance/managed-fund/anchor/programs/managed-fund/src/instructions/rebalance.rs +++ b/finance/managed-fund/anchor/programs/managed-fund/src/instructions/rebalance.rs @@ -10,7 +10,8 @@ use mock_swap_router::cpi::accounts::{ use crate::error::FundError; use crate::oracle::{ - asset_value_in_usdc, load_price, read_token_amount, usdc_to_asset_amount, OraclePrice, + asset_value_in_usdc, asset_value_share_in_usdc_rounded_up, load_price, read_token_amount, + usdc_to_asset_amount, OraclePrice, }; use crate::state::{AssetConfig, Fund, MAX_ASSETS}; @@ -185,17 +186,16 @@ pub fn handle_rebalance( FundError::InsufficientHoldings ); - // Sell leg floor: USDC out within slippage of the oracle value of what is sold. - let minimum_usdc_from_sell: u64 = asset_value_in_usdc( + // Sell leg floor: USDC out within slippage of the oracle value of what is + // sold, rounded up in the fund's favour so the floor is never looser than + // the tolerance. + let minimum_usdc_from_sell: u64 = asset_value_share_in_usdc_rounded_up( sell_amount as u128, sell_price, sell_config.decimals, usdc_decimals, + slip, )? - .checked_mul(slip) - .ok_or(FundError::MathOverflow)? - .checked_div(10_000) - .ok_or(FundError::MathOverflow)? .try_into() .map_err(|_| FundError::MathOverflow)?; 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 db914edb2..ba7bd3ffe 100644 --- a/finance/managed-fund/anchor/programs/managed-fund/src/oracle.rs +++ b/finance/managed-fund/anchor/programs/managed-fund/src/oracle.rs @@ -6,7 +6,7 @@ use crate::error::FundError; /// PriceUpdateV2 account: 8 discriminator + 32 write_authority = 40. const PYTH_VERIFICATION_LEVEL_OFFSET: usize = 40; /// Borsh tag of `VerificationLevel::Full`, a price verified against a quorum -/// of Pyth's guardian set. `Partial { num_signatures }` is tag 0 followed by a +/// of Pyth's signers. `Partial { num_signatures }` is tag 0 followed by a /// one-byte signature count, so it encodes in two bytes rather than one and /// moves every later field one byte along. The offsets below assume `Full`. const PYTH_VERIFICATION_LEVEL_FULL: u8 = 1; @@ -56,7 +56,7 @@ fn read_pyth_raw(account_data: &[u8]) -> Result<(i64, u64, i32, i64, u64)> { return err!(FundError::InvalidPriceFeed); } // Refuse anything but a fully verified update. A partially verified one - // was signed by fewer than a quorum of the guardian set, and its longer + // was signed by fewer than a quorum of Pyth's signers, and its longer // `verification_level` encoding would shift every offset below by a byte, // so its price would be read from the wrong bytes. require!( @@ -95,7 +95,7 @@ fn read_pyth_raw(account_data: &[u8]) -> Result<(i64, u64, i32, i64, u64)> { /// 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, and -/// so is an update Pyth's guardian set did not fully verify. +/// so is an update a quorum of Pyth's signers did not fully verify. pub fn load_price( price_feed: &AccountView, expected_key: &Address, @@ -270,6 +270,33 @@ pub fn asset_value_in_usdc_rounded_up( ) } +/// `share_bps` of the value of `amount` asset minor units in USDC minor units, +/// `amount * price * share_bps * 10^(usdc_decimals + exponent - asset_decimals) +/// / 10_000`, rounded up in one division. Rebalance sets its sell leg's +/// minimum output to this, with `share_bps` the part of the value the +/// slippage tolerance keeps. Flooring the value and then flooring the share +/// again would let the floor sit up to a minor unit below the exact figure, +/// accepting a sale that pays the fund less than its tolerance allows; +/// rounding the exact product up keeps the floor at or above it. +pub fn asset_value_share_in_usdc_rounded_up( + amount: u128, + price: OraclePrice, + asset_decimals: u8, + usdc_decimals: u8, + share_bps: u128, +) -> Result { + let power = usdc_decimals as i32 + price.exponent - asset_decimals as i32; + mul_pow10_div_ceil( + amount + .checked_mul(price.price) + .ok_or(FundError::MathOverflow)? + .checked_mul(share_bps) + .ok_or(FundError::MathOverflow)?, + power, + 10_000, + ) +} + /// The inverse of `asset_value_in_usdc`: how many asset minor units /// `usdc_amount` USDC minor units buys at the oracle price, /// usdc_amount * 10^(asset_decimals - exponent - usdc_decimals) / price. Floored. 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 f14e7e63c..7771a4e3c 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 @@ -1357,6 +1357,39 @@ fn test_rebalance() { 0 ); } +/// Rebalance's sell floor rounds up, in the fund's favour. With NVDAx at +/// $200.00000001 the trade sells 11,999,999 NVDAx minor units, worth +/// 23,999,998.0012 USDC minor units, and the 1% tolerance keeps +/// 23,759,998.001188 of that, so the floor is 23,759,999. A router quoting +/// exactly 1% under $200 pays 23,759,998: a floor taken from the value floored +/// to 23,999,998 would be 23,759,998 and accept that sale, a fraction of a +/// minor unit short of the tolerance. Here it is refused, and a quote that pays +/// 23,759,999 goes through. +#[test] +fn test_rebalance_sell_floor_rounds_up() { + let mut ctx = setup_full(); + standard_fund(&mut ctx); + let alice = fund_user(&mut ctx, 900_000_000); + do_deposit(&mut ctx, &alice, 900_000_000, 1); + + set_nvda_price(&mut ctx, 20_000_000_001, 198_000_000); + assert_router_error( + try_rebalance(&mut ctx, 1, 0), + RouterError::SlippageExceeded, + "a sale one minor unit under the rounded-up floor", + ); + + // 11,999,999 * 198,000,009 / 10^8 = 23,759,999.08, floored by the router. + set_nvda_price(&mut ctx, 20_000_000_001, 198_000_009); + do_rebalance(&mut ctx, 1, 0); + + // 23,759,999 USDC buys 9,503,999 TSLAx minor units at $250. + let fund = read_fund(&ctx); + assert_eq!(fund.asset_holdings[1], 300_000_000 - 11_999_999); + assert_eq!(fund.asset_holdings[0], 144_000_000 + 9_503_999); + assert_eq!(fund.usdc_holdings, 0); + assert_holdings_match_vaults(&ctx); +} #[test] fn test_collect_fees() { @@ -2341,7 +2374,7 @@ fn test_wide_confidence_price_rejected() { } /// The fund reads a Pyth update at fixed offsets that assume a fully verified -/// one. A partially verified update, signed by two of the five guardians, is +/// one. A partially verified update, signed by two of the five signers, is /// refused with `PriceNotFullyVerified` rather than read a byte off. Rewritten /// as fully verified at the same price, the same deposit prices exactly as /// `test_deposit_first` does. diff --git a/finance/managed-fund/kani-proofs/README.md b/finance/managed-fund/kani-proofs/README.md index 6d468ccf7..fba60867e 100644 --- a/finance/managed-fund/kani-proofs/README.md +++ b/finance/managed-fund/kani-proofs/README.md @@ -23,6 +23,7 @@ input, the harnesses check: - `proof_donation_cannot_dilute_next_deposit`: The inflation attack modelled directly: after an attacker's first deposit and a donation of any size, the victim's deposit mints exactly one share per minor unit and withdraws in full. - `proof_fee_shares_bounded_by_supply`: The time-based manager fee, `ceil(total_shares·fee_bps·elapsed/(10000·seconds_per_year))`, can never mint more than 100%/year of dilution (`fee_shares <= total_shares` for `elapsed <= 1yr`, `fee_bps <= 10000`), and rounds up: it is never below the exact quotient and never more than one share above it. - `proof_deposit_nav_rounds_against_the_depositor`: Deposit values each asset rounding up. The rounded-up value is never below the floored one and at most one minor unit above it, and a deposit priced against it mints no more shares than one priced against the floored NAV, so valuation rounding never hands a depositor a share the holders paid for. +- `proof_sell_floor_rounds_in_the_funds_favour`: Rebalance refuses a sale that pays less than its slippage tolerance keeps of the oracle value sold. That floor is the exact product rounded up in one division: never below the exact figure, under one minor unit above it, and never below the floor taken by flooring the value and then the tolerance's share, which could accept a sale a fraction of a minor unit short. ## Bounded model checking @@ -36,6 +37,7 @@ representative range; the share identities are scale-invariant. - `proof_donation_cannot_dilute_next_deposit`: deposits `<= 31`, donation unbounded - `proof_fee_shares_bounded_by_supply`: `<= 255`, runs in ~4s - `proof_deposit_nav_rounds_against_the_depositor`: amounts and prices `<= 255`, deposits and supply `<= 31`, runs in ~3 minutes +- `proof_sell_floor_rounds_in_the_funds_favour`: amounts and prices `<= 255`, tolerance 90% to 100%, the scale fixed at `10^-3` (a symbolic power leaves the solver dividing by a symbolic denominator, and it does not finish), runs in ~2.5 minutes Run weekly in CI (the `kani.yml` `verify` job), not on every push/PR, because the bounded nonlinear model checks are slow. A fast unit-test job runs per push/PR. diff --git a/finance/managed-fund/kani-proofs/src/lib.rs b/finance/managed-fund/kani-proofs/src/lib.rs index ea3c7cb1e..6ca540417 100644 --- a/finance/managed-fund/kani-proofs/src/lib.rs +++ b/finance/managed-fund/kani-proofs/src/lib.rs @@ -139,6 +139,26 @@ pub fn asset_value_in_usdc_rounded_up( mul_pow10_div_ceil(amount.checked_mul(price)?, power, 1) } +/// `share_bps` of what `amount` asset minor units are worth in USDC minor +/// units, rounded up in one division (`asset_value_share_in_usdc_rounded_up`): +/// the minimum `handle_rebalance` accepts from its sell leg, with `share_bps` +/// the part of the value its slippage tolerance keeps. +pub fn asset_value_share_in_usdc_rounded_up( + amount: u128, + price: u128, + exponent: i32, + asset_decimals: u8, + usdc_decimals: u8, + share_bps: u128, +) -> Option { + let power = usdc_decimals as i32 + exponent - asset_decimals as i32; + mul_pow10_div_ceil( + amount.checked_mul(price)?.checked_mul(share_bps)?, + power, + 10_000, + ) +} + /// Asset minor units that `usdc_amount` USDC minor units buys at the oracle /// price, floored (`usdc_to_asset_amount`). pub fn usdc_to_asset_amount( @@ -457,6 +477,57 @@ fn proof_deposit_nav_rounds_against_the_depositor() { } } +// =========================================================================== +// 9. Rebalance's sell floor is never looser than its tolerance +// =========================================================================== + +/// `handle_rebalance` refuses a sale that pays less than `share_bps` of the +/// oracle value of what it sells. The floor is that product rounded up in one +/// division, so it is never below the exact figure and less than one minor +/// unit above it, and never below the floor the handler used to take by +/// flooring the value and then the share. +/// +/// The scale is fixed at a negative power of ten, `10^-3` (one-decimal USDC, +/// a two-decimal asset, exponent -2), the sign every real feed gives (six- +/// decimal USDC, an eight-decimal asset and exponent -8 is `10^-10`): with a +/// symbolic power the solver divides by a symbolic denominator and does not +/// finish. +#[cfg(kani)] +#[kani::proof] +#[kani::solver(cadical)] +fn proof_sell_floor_rounds_in_the_funds_favour() { + let amount: u128 = kani::any(); + let price: u128 = kani::any(); + let share_bps: u128 = kani::any(); + + kani::assume(amount >= 1 && amount <= 255); + kani::assume(price >= 1 && price <= 255); + // `MAX_SLIPPAGE_BPS` is 1_000, so the tolerance keeps 90% to 100%. + kani::assume(share_bps >= 9_000 && share_bps <= 10_000); + + let (exponent, asset_decimals, usdc_decimals) = (-2, 2, 1); + let floor = asset_value_share_in_usdc_rounded_up( + amount, + price, + exponent, + asset_decimals, + usdc_decimals, + share_bps, + ) + .expect("computes"); + + // The exact figure as a fraction: amount * price * share_bps / (10_000 * 10^3). + let numerator = amount * price * share_bps; + let denominator = 10_000 * 1_000; + assert!(floor * denominator >= numerator); + assert!(floor * denominator < numerator + denominator); + + let value = asset_value_in_usdc(amount, price, exponent, asset_decimals, usdc_decimals) + .expect("computes"); + let floored_twice = mul_div_floor(value, share_bps, 10_000).expect("computes"); + assert!(floor >= floored_twice); +} + // =========================================================================== // Plain unit tests. // =========================================================================== @@ -565,6 +636,27 @@ mod tests { ); } + #[test] + fn sell_floor_rounds_up() { + // The program's test: 11,999,999 NVDAx minor units at $200.00000001 + // are worth 23,999,998.0012 USDC minor units, 99% of which is + // 23,759,998.001188. Rounded up in one division the floor is + // 23,759,999; flooring the value and then the share gave 23,759,998. + let floor = + asset_value_share_in_usdc_rounded_up(11_999_999, 20_000_000_001, -8, 8, 6, 9_900) + .unwrap(); + assert_eq!(floor, 23_759_999); + let value = asset_value_in_usdc(11_999_999, 20_000_000_001, -8, 8, 6).unwrap(); + assert_eq!(mul_div_floor(value, 9_900, 10_000).unwrap(), 23_759_998); + // A whole figure is unchanged: 0.12 NVDAx at $200 is 24 USDC, 99% of + // which is 23.76 USDC exactly. + assert_eq!( + asset_value_share_in_usdc_rounded_up(12_000_000, 20_000_000_000, -8, 8, 6, 9_900) + .unwrap(), + 23_760_000 + ); + } + #[test] fn rebalance_trades_the_smaller_gap() { // The book's rebalance: NVDAx $24 over its $576 target, TSLAx $24 under. diff --git a/finance/managed-fund/quasar/CHANGELOG.md b/finance/managed-fund/quasar/CHANGELOG.md index b992db436..55f5471a3 100644 --- a/finance/managed-fund/quasar/CHANGELOG.md +++ b/finance/managed-fund/quasar/CHANGELOG.md @@ -24,6 +24,15 @@ ### Fixed +- Rebalance's sell floor rounds up. `minimum_usdc_from_sell` floored the + oracle value of what is sold and then floored the slippage tolerance's share + of it, so it could sit up to a minor unit below the exact figure. It now + comes from the new `asset_value_share_in_usdc_rounded_up`, which rounds the + exact product up in one division. Tested by + `test_rebalance_sell_floor_rounds_up`: with NVDAx at $200.00000001 a router + paying 23,759,998 USDC minor units for 11,999,999 NVDAx minor units (99% of + their value is 23,759,998.001188) is refused with the router's + `SlippageExceeded`, and one paying 23,759,999 goes through. - Deposit values the basket rounding up. `deposit` priced shares as `usdc_amount × total_shares / nav` with each asset's value in `nav` floored, so NAV read up to a minor unit per asset low and a depositor could be minted diff --git a/finance/managed-fund/quasar/README.md b/finance/managed-fund/quasar/README.md index 28779812b..444e9cced 100644 --- a/finance/managed-fund/quasar/README.md +++ b/finance/managed-fund/quasar/README.md @@ -110,7 +110,10 @@ withdraw), in index order. (`asset_value_in_usdc_rounded_up`), so NAV is never understated by rounding and the floored share count cannot hand a depositor a share the holders paid for (`test_deposit_values_assets_rounding_up`). The management fee rounds - up, so the manager is never minted less than the fee owed. + up, so the manager is never minted less than the fee owed. Rebalance's + sell-leg slippage floor rounds up too + (`asset_value_share_in_usdc_rounded_up`), so it is never looser than the + tolerance (`test_rebalance_sell_floor_rounds_up`). - The management fee is capped (10% per year) and the slippage tolerance is capped (10%), so neither can be configured to drain the fund. - Price feeds are validated against the address recorded on the asset config and @@ -123,7 +126,7 @@ withdraw), in index order. (`OracleConfidenceTooWide`). `withdraw` reads no price, so investors can always leave in kind. - The feed's fields are read at fixed byte offsets that assume its - `verification_level` is `Full`, verified by a quorum of Pyth's guardian set + `verification_level` is `Full`, verified by a quorum of Pyth's signers (three of five), so `load_price` checks that tag (offset 40) first and refuses anything else (`PriceNotFullyVerified`). A `Partial` update encodes `verification_level` in two bytes rather than one, which would move every diff --git a/finance/managed-fund/quasar/managed-fund/src/errors.rs b/finance/managed-fund/quasar/managed-fund/src/errors.rs index 45429de3b..20b349ce0 100644 --- a/finance/managed-fund/quasar/managed-fund/src/errors.rs +++ b/finance/managed-fund/quasar/managed-fund/src/errors.rs @@ -42,6 +42,6 @@ pub enum FundError { NotUnderweight, /// The Pyth price's confidence interval is too wide to trust. OracleConfidenceTooWide, - /// The Pyth price update is not fully verified by the guardian set. + /// The Pyth price update is not fully verified by a quorum of Pyth's signers. PriceNotFullyVerified, } diff --git a/finance/managed-fund/quasar/managed-fund/src/instructions/rebalance.rs b/finance/managed-fund/quasar/managed-fund/src/instructions/rebalance.rs index 87a7903c8..ecdded0c1 100644 --- a/finance/managed-fund/quasar/managed-fund/src/instructions/rebalance.rs +++ b/finance/managed-fund/quasar/managed-fund/src/instructions/rebalance.rs @@ -7,7 +7,8 @@ use quasar_spl::prelude::*; use crate::errors::FundError; use crate::instructions::deposit::get_view; use crate::oracle::{ - asset_value_in_usdc, load_price, read_token_amount, usdc_to_asset_amount, OraclePrice, + asset_value_in_usdc, asset_value_share_in_usdc_rounded_up, load_price, read_token_amount, + usdc_to_asset_amount, OraclePrice, }; use crate::state::{ load_asset_config, read_asset_holdings, snapshot_fund, write_asset_holdings, AssetConfigView, @@ -193,17 +194,16 @@ pub fn handle_rebalance( FundError::InsufficientHoldings ); - // Sell leg floor: USDC out within slippage of the oracle value of what is sold. - let minimum_usdc_from_sell: u64 = asset_value_in_usdc( + // Sell leg floor: USDC out within slippage of the oracle value of what is + // sold, rounded up in the fund's favour so the floor is never looser than + // the tolerance. + let minimum_usdc_from_sell: u64 = asset_value_share_in_usdc_rounded_up( sell_amount as u128, sell_price, sell_config.decimals, usdc_decimals, + slip, )? - .checked_mul(slip) - .ok_or(FundError::MathOverflow)? - .checked_div(10_000) - .ok_or(FundError::MathOverflow)? .try_into() .map_err(|_| FundError::MathOverflow)?; diff --git a/finance/managed-fund/quasar/managed-fund/src/oracle.rs b/finance/managed-fund/quasar/managed-fund/src/oracle.rs index ed3d3803c..bc4966a1f 100644 --- a/finance/managed-fund/quasar/managed-fund/src/oracle.rs +++ b/finance/managed-fund/quasar/managed-fund/src/oracle.rs @@ -6,7 +6,7 @@ use crate::{errors::FundError, last_restart::LastRestartSlot}; /// PriceUpdateV2 account: 8 discriminator + 32 write_authority = 40. const PYTH_VERIFICATION_LEVEL_OFFSET: usize = 40; /// Borsh tag of `VerificationLevel::Full`, a price verified against a quorum -/// of Pyth's guardian set. `Partial { num_signatures }` is tag 0 followed by a +/// of Pyth's signers. `Partial { num_signatures }` is tag 0 followed by a /// one-byte signature count, so it encodes in two bytes rather than one and /// moves every later field one byte along. The offsets below assume `Full`. const PYTH_VERIFICATION_LEVEL_FULL: u8 = 1; @@ -83,7 +83,7 @@ pub struct OraclePrice { /// 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, and -/// so is an update Pyth's guardian set did not fully verify. +/// so is an update a quorum of Pyth's signers did not fully verify. pub fn load_price( price_feed: &AccountView, expected_key: &Address, @@ -98,7 +98,7 @@ pub fn load_price( return Err(FundError::InvalidPriceFeed.into()); } // Refuse anything but a fully verified update. A partially verified one - // was signed by fewer than a quorum of the guardian set, and its longer + // was signed by fewer than a quorum of Pyth's signers, and its longer // `verification_level` encoding would shift every offset below by a byte, // so its price would be read from the wrong bytes. require!( @@ -275,6 +275,33 @@ pub fn asset_value_in_usdc_rounded_up( ) } +/// `share_bps` of the value of `amount` asset minor units in USDC minor units, +/// `amount * price * share_bps * 10^(usdc_decimals + exponent - asset_decimals) +/// / 10_000`, rounded up in one division. Rebalance sets its sell leg's +/// minimum output to this, with `share_bps` the part of the value the +/// slippage tolerance keeps. Flooring the value and then flooring the share +/// again would let the floor sit up to a minor unit below the exact figure, +/// accepting a sale that pays the fund less than its tolerance allows; +/// rounding the exact product up keeps the floor at or above it. +pub fn asset_value_share_in_usdc_rounded_up( + amount: u128, + price: OraclePrice, + asset_decimals: u8, + usdc_decimals: u8, + share_bps: u128, +) -> Result { + let power = usdc_decimals as i32 + price.exponent - asset_decimals as i32; + mul_pow10_div_ceil( + amount + .checked_mul(price.price) + .ok_or(FundError::MathOverflow)? + .checked_mul(share_bps) + .ok_or(FundError::MathOverflow)?, + power, + 10_000, + ) +} + /// The inverse of `asset_value_in_usdc`: how many asset minor units /// `usdc_amount` USDC minor units buys at the oracle price, /// usdc_amount * 10^(asset_decimals - exponent - usdc_decimals) / price. Floored. diff --git a/finance/managed-fund/quasar/managed-fund/src/tests.rs b/finance/managed-fund/quasar/managed-fund/src/tests.rs index 174ed5cac..39e5b0b8b 100644 --- a/finance/managed-fund/quasar/managed-fund/src/tests.rs +++ b/finance/managed-fund/quasar/managed-fund/src/tests.rs @@ -64,6 +64,9 @@ const ROUTER_ID_STR: &str = "SWPR8Rk3aq3DrDGLdaANq7xCMnXoUFUJWJJmCWxc8Jm"; const RATE: u64 = 250_000_000; // router USDC minor units per whole token const NOW: i64 = 1_000; // fixed clock for the deposit test const FUND_INDEX: u64 = 0; +/// The mock router's `SlippageExceeded`: its errors start at 6000 and this is +/// the second. +const ROUTER_SLIPPAGE_EXCEEDED: u32 = 6001; // Deterministic addresses. const AUTHORITY: Pubkey = Pubkey::new_from_array([1; 32]); @@ -427,7 +430,7 @@ fn deposit_rejects_price_from_before_a_restart(test: &mut Test) { } /// The fund reads a Pyth update at fixed offsets that assume a fully verified -/// one. A partially verified update, signed by two of the five guardians, is +/// one. A partially verified update, signed by two of the five signers, is /// refused with `PriceNotFullyVerified` rather than read a byte off. Rewritten /// as fully verified at the same price, the same deposit prices exactly as /// `deposit_mints_shares_and_deploys_into_the_basket` does. @@ -1039,6 +1042,41 @@ fn test_rebalance(test: &mut Test) { assert_holdings_match_vaults(test); } +/// Rebalance's sell floor rounds up, in the fund's favour. With NVDAx at +/// $200.00000001 the trade sells 11,999,999 NVDAx minor units, worth +/// 23,999,998.0012 USDC minor units, and the 1% tolerance keeps +/// 23,759,998.001188 of that, so the floor is 23,759,999. A router quoting +/// exactly 1% under $200 pays 23,759,998: a floor taken from the value floored +/// to 23,999,998 would be 23,759,998 and accept that sale, a fraction of a +/// minor unit short of the tolerance. Here it is refused, and a quote that pays +/// 23,759,999 goes through. +#[quasar_test] +fn test_rebalance_sell_floor_rounds_up(test: &mut Test) { + setup_full(test); + standard_fund(test); + let alice = fund_user(test, 900_000_000); + do_deposit(test, &alice, 900_000_000); + + set_nvda_price(test, 20_000_000_001, 198_000_000); + try_rebalance(test, 1, 0).fails(ProgramError::Custom(ROUTER_SLIPPAGE_EXCEEDED)); + + // 11,999,999 * 198,000,009 / 10^8 = 23,759,999.08, floored by the router. + set_nvda_price(test, 20_000_000_001, 198_000_009); + do_rebalance(test, 1, 0); + + // 23,759,999 USDC buys 9,503,999 TSLAx minor units at $250. + assert_eq!( + test.tokens(asset_vault_pda(test, 1)), + 300_000_000 - 11_999_999 + ); + assert_eq!( + test.tokens(asset_vault_pda(test, 0)), + 144_000_000 + 9_503_999 + ); + assert_eq!(test.tokens(usdc_vault_pda(test)), 0); + assert_holdings_match_vaults(test); +} + /// A fund sitting at its target weights has nothing to rebalance, in either /// direction, so nobody can trade it. #[quasar_test] diff --git a/finance/perpetual-futures/anchor-v1/CHANGELOG.md b/finance/perpetual-futures/anchor-v1/CHANGELOG.md index 4ea8b767c..c1cd823d8 100644 --- a/finance/perpetual-futures/anchor-v1/CHANGELOG.md +++ b/finance/perpetual-futures/anchor-v1/CHANGELOG.md @@ -2,6 +2,23 @@ ## Unreleased, 2026-10-05 +Liquidity-provider shares are priced against the provider. +`traders_unrealized_pnl` floored both sides' marked value, so the long side +rounded traders' profit down and the short side rounded it up, and +`add_liquidity` or `remove_liquidity` could round a base unit in the +provider's favour depending on the book. It now takes a `Rounding`, and +`liquidity_provider_aum` takes the direction of the valuation: +`add_liquidity` values the pool rounding up, so a deposit is minted no more +shares than it pays for, and `remove_liquidity` rounds it down, so a +withdrawal is paid no more than its shares are worth. `haircut_ratio` rounds +the traders' liability up, so a fraction of a base unit can only lower `h`. +Tested by `test_add_liquidity_values_the_pool_rounding_up` (a 100,000 USDC +deposit against an open short marked half a base unit in profit mints +100,000,000,000 shares, not 100,000,000,001) and +`test_remove_liquidity_values_the_pool_rounding_down` (a provider +withdrawing against an open long marked half a base unit in profit is paid +99,999,998,999, not 99,999,999,000). No existing figure changes. + Profit/loss and funding round against the trader. `position_pnl` and `position_funding` in `instructions/shared.rs` divided with truncation toward zero, so a fractional loss was booked a base unit small, and funding a trader diff --git a/finance/perpetual-futures/anchor-v1/README.md b/finance/perpetual-futures/anchor-v1/README.md index 8615ebb65..27b7a1fa9 100644 --- a/finance/perpetual-futures/anchor-v1/README.md +++ b/finance/perpetual-futures/anchor-v1/README.md @@ -21,7 +21,7 @@ A [perpetual future](https://www.investopedia.com/terms/f/futurescontract.asp) ( - `perpetual-futures`: The exchange: pool creation, liquidity provision, opening/closing leveraged positions, funding, liquidation, and fee collection. - `mock-price-feed`: Test-only price feed. Stores a price, scale, last-update slot, and confidence band that tests write directly. Replaced in production by a Pyth `PriceUpdateV2` account, as read in [`basics/pyth`](../../../basics/pyth/). -All arithmetic is integer `u128` with `checked_*` operations, multiplying before dividing and rounding in the pool's favour: every fee and the maintenance requirement round up, and what is paid out rounds down. A position's profit/loss is floored toward negative infinity, so a fractional loss rounds up to the next base unit, and its funding rounds toward positive infinity, so funding the trader pays rounds up and funding the trader receives rounds down (`test_position_pnl_rounds_against_the_trader`, `test_position_funding_rounds_against_the_trader`). No floats, no fixed-point library. +All arithmetic is integer `u128` with `checked_*` operations, multiplying before dividing and rounding in the pool's favour: every fee and the maintenance requirement round up, and what is paid out rounds down. A position's profit/loss is floored toward negative infinity, so a fractional loss rounds up to the next base unit, and its funding rounds toward positive infinity, so funding the trader pays rounds up and funding the trader receives rounds down (`test_position_pnl_rounds_against_the_trader`, `test_position_funding_rounds_against_the_trader`). Provider shares are priced against a valuation rounded against the provider: `add_liquidity` values the pool rounding up, so a deposit is minted no more shares than it pays for, and `remove_liquidity` rounds it down, so a withdrawal is paid no more than its shares are worth (`test_add_liquidity_values_the_pool_rounding_up`, `test_remove_liquidity_values_the_pool_rounding_down`). The haircut's liability rounds up, so a fraction of a base unit can only lower `h`. No floats, no fixed-point library. --- @@ -272,6 +272,7 @@ The tests run in-process with [LiteSVM](https://www.anchor-lang.com/docs/testing - the profit warm-up on both sides of its boundary (`test_profit_blocked_before_maturation`, `test_profit_realized_after_maturation`), and a loss closing in the slot it opened (`test_loss_not_gated_by_maturation`) - the insurance fund: its exact share of each fee (`test_insurance_fund_funded_by_fees`), a bankrupt position's deficit paid by the fund (`test_insurance_absorbs_bankruptcy_deficit`), and a bankrupt position liquidated for no fee with the fund paying before the providers (`test_liquidation_of_bankrupt_position_charges_insurance_before_liquidity`) - withdrawals capped at `liquidity` while traders are down (`test_remove_liquidity_capped_at_liquidity`) +- the pool valued rounding up for a deposit and down for a withdrawal, each a base unit in the pool's favour against the old valuation (`test_add_liquidity_values_the_pool_rounding_up`, `test_remove_liquidity_values_the_pool_rounding_down`) - `initialize_pool`'s parameter checks, including an initial margin at or below the maintenance margin, a close fee at or above it (`test_initialize_pool_rejects_close_fee_at_or_above_maintenance_margin`), a price band outside its range, and an insurance fee of 10,000 basis points or more - every fee and the maintenance requirement rounding up (`test_fees_and_maintenance_requirement_round_up`), and `basis_points_of` at its boundaries - fee collection, and its refusal to anyone but the pool's authority (`test_collect_fees_requires_authority`, which asserts the constraint's own error code) diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/add_liquidity.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/add_liquidity.rs index 3ce6c4d3e..73ea2ac56 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/add_liquidity.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/add_liquidity.rs @@ -8,7 +8,9 @@ use anchor_spl::{ use crate::constants::{MINIMUM_LIQUIDITY, POOL_SEED, VAULT_SEED}; use crate::errors::PerpError; -use crate::instructions::shared::{liquidity_provider_aum, refresh_price_and_funding_within_band}; +use crate::instructions::shared::{ + liquidity_provider_aum, refresh_price_and_funding_within_band, Rounding, +}; use crate::state::Pool; pub fn handle_add_liquidity( @@ -44,7 +46,11 @@ pub fn handle_add_liquidity( // The same divisor covers a pool whose providers have all left: the // minimum's slice is still in `liquidity`, so the next deposit is // priced against it rather than bootstrapped. - let aum = liquidity_provider_aum(pool, price)?; + // + // The pool is valued rounding up, so a fraction of a base unit in the + // traders' marked profit/loss raises the price of a share rather than + // lowering it. + let aum = liquidity_provider_aum(pool, price, Rounding::Up)?; require!(aum > 0, PerpError::PoolInsolvent); let total_shares = (lp_supply as u128) .checked_add(MINIMUM_LIQUIDITY as u128) diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/remove_liquidity.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/remove_liquidity.rs index 95283b564..03990e2d3 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/remove_liquidity.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/remove_liquidity.rs @@ -8,7 +8,9 @@ use anchor_spl::{ use crate::constants::{MINIMUM_LIQUIDITY, POOL_SEED, VAULT_SEED}; use crate::errors::PerpError; -use crate::instructions::shared::{liquidity_provider_aum, refresh_price_and_funding_within_band}; +use crate::instructions::shared::{ + liquidity_provider_aum, refresh_price_and_funding_within_band, Rounding, +}; use crate::state::Pool; pub fn handle_remove_liquidity( @@ -22,7 +24,9 @@ pub fn handle_remove_liquidity( let price = refresh_price_and_funding_within_band(pool, &context.accounts.oracle_feed)?; let lp_supply = context.accounts.lp_mint.supply; - let aum = liquidity_provider_aum(pool, price)?; + // The pool is valued rounding down, so a fraction of a base unit in the + // traders' marked profit/loss lowers what a share redeems for. + let aum = liquidity_provider_aum(pool, price, Rounding::Down)?; require!(aum > 0, PerpError::PoolInsolvent); // amount_out = shares * assets-under-management / (supply + MINIMUM_LIQUIDITY), diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/shared.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/shared.rs index 78286aa16..7cc78392c 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/shared.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/shared.rs @@ -141,31 +141,68 @@ pub fn position_pnl(side: Side, size: u64, entry_price: u64, price: u64) -> Resu .ok_or(PerpError::MathOverflow.into()) } +/// Which way a valuation's fractional base unit goes. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Rounding { + Down, + Up, +} + +/// `numerator / denominator` for a non-negative `numerator` and a positive +/// `denominator`, rounded the way `rounding` says. +fn divide_rounding(numerator: i128, denominator: i128, rounding: Rounding) -> Result { + let quotient = numerator + .checked_div(denominator) + .ok_or(PerpError::MathOverflow)?; + let remainder = numerator + .checked_rem(denominator) + .ok_or(PerpError::MathOverflow)?; + if rounding == Rounding::Up && remainder != 0 { + return quotient + .checked_add(1) + .ok_or(PerpError::MathOverflow.into()); + } + Ok(quotient) +} + /// Aggregate unrealized profit/loss of every open trader at `price`, derived /// from the pool's running accumulators rather than iterating positions. /// Positive means traders are collectively up (and the pool is down). /// +/// `rounding` is the direction of the result: `Up` rounds the long side's +/// value up and the short side's value down, so a fraction of a base unit on +/// either side counts as owed to traders; `Down` does the opposite. Callers +/// choose the direction that goes against whoever is being priced. +/// /// Profit is marked in full, before any haircut: while `haircut_ratio` is /// below one, winners will be paid less than this, so assets-under-management /// reads low by the withheld part until they close. -pub fn traders_unrealized_pnl(pool: &Pool, price: u64) -> Result { +pub fn traders_unrealized_pnl(pool: &Pool, price: u64, rounding: Rounding) -> Result { let price = price as i128; let size_precision = SIZE_PRECISION as i128; + let (long_rounding, short_rounding) = match rounding { + Rounding::Up => (Rounding::Up, Rounding::Down), + Rounding::Down => (Rounding::Down, Rounding::Up), + }; - let long_value = price - .checked_mul(pool.long_size_scaled as i128) - .ok_or(PerpError::MathOverflow)? - .checked_div(size_precision) - .ok_or(PerpError::MathOverflow)?; + let long_value = divide_rounding( + price + .checked_mul(pool.long_size_scaled as i128) + .ok_or(PerpError::MathOverflow)?, + size_precision, + long_rounding, + )?; let long_pnl = long_value .checked_sub(pool.long_size as i128) .ok_or(PerpError::MathOverflow)?; - let short_value = price - .checked_mul(pool.short_size_scaled as i128) - .ok_or(PerpError::MathOverflow)? - .checked_div(size_precision) - .ok_or(PerpError::MathOverflow)?; + let short_value = divide_rounding( + price + .checked_mul(pool.short_size_scaled as i128) + .ok_or(PerpError::MathOverflow)?, + size_precision, + short_rounding, + )?; let short_pnl = (pool.short_size as i128) .checked_sub(short_value) .ok_or(PerpError::MathOverflow)?; @@ -179,8 +216,18 @@ pub fn traders_unrealized_pnl(pool: &Pool, price: u64) -> Result { /// what traders are collectively owed. This is what liquidity-provider shares /// are priced against, so it marks open positions to the current price and an /// exiting provider cannot dodge an in-progress trader profit. -pub fn liquidity_provider_aum(pool: &Pool, price: u64) -> Result { - let traders = traders_unrealized_pnl(pool, price)?; +/// +/// `rounding` is the direction of the result, and each caller rounds against +/// the provider: `add_liquidity` values the pool rounding `Up`, so a deposit +/// is minted no more shares than it pays for, and `remove_liquidity` values it +/// rounding `Down`, so a withdrawal is paid no more than its shares are worth. +pub fn liquidity_provider_aum(pool: &Pool, price: u64, rounding: Rounding) -> Result { + // A higher valuation needs the traders' figure rounded the other way. + let traders_rounding = match rounding { + Rounding::Up => Rounding::Down, + Rounding::Down => Rounding::Up, + }; + let traders = traders_unrealized_pnl(pool, price, traders_rounding)?; (pool.liquidity as i128) .checked_sub(traders) .ok_or(PerpError::MathOverflow.into()) @@ -205,7 +252,9 @@ pub fn liquidity_provider_aum(pool: &Pool, price: u64) -> Result { /// paid at most the pool's backing, and is never refused; when the liability is /// the larger, every other winner's fraction is unchanged. pub fn haircut_ratio(pool: &Pool, price: u64, closing_profit: i128) -> Result { - let liability: u128 = traders_unrealized_pnl(pool, price)? + // Rounded up, so a fraction of a base unit counts as owed and can only + // lower `h`: no winner is paid more than the backing allows. + let liability: u128 = traders_unrealized_pnl(pool, price, Rounding::Up)? .max(closing_profit) .max(0) .try_into() diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/tests/test_perpetual_futures.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/tests/test_perpetual_futures.rs index 8c6789d99..b11456879 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/tests/test_perpetual_futures.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/tests/test_perpetual_futures.rs @@ -1814,6 +1814,81 @@ fn test_remove_liquidity_capped_at_liquidity() { assert_eq!(market.pool_state().liquidity, 0); market.assert_vault_matches_ledger(); } +/// `add_liquidity` values the pool rounding up. A $5,000 short entered at +/// $100 and marked at $99.99999999 is worth 4,999,999,999.5 base units, so +/// traders are up half a base unit. Rounded against the depositor that half +/// counts as nothing owed, assets-under-management stays at the pool's +/// 100,000 USDC of liquidity, and a 100,000 USDC deposit is minted exactly +/// 100,000 USDC of shares. Valuing the short at 4,999,999,999 would read the +/// pool one base unit low and mint one share more. +#[test] +fn test_add_liquidity_values_the_pool_rounding_up() { + let mut market = Market::default_market(); + market.seed_liquidity(100_000 * ONE_USDC); + let (trader, trader_collateral) = market.funded_trader(1_000 * ONE_USDC); + market + .open_position( + &trader, + trader_collateral, + Side::Short, + 1_000 * ONE_USDC, + 5_000 * ONE_USDC, + 0, + ) + .unwrap(); + market.set_price(dollars(100) - 1); + + let deposit = 100_000 * ONE_USDC; + let (provider, provider_collateral) = market.funded_trader(deposit); + market + .add_liquidity(&provider, provider_collateral, deposit, 0) + .unwrap(); + let provider_lp = derive_ata(&provider.pubkey(), &market.lp_mint); + assert_eq!( + get_token_account_balance(&market.svm, &provider_lp).unwrap(), + 100_000 * ONE_USDC + ); + assert_eq!(market.pool_state().liquidity, 200_000 * ONE_USDC); + market.assert_vault_matches_ledger(); +} + +/// `remove_liquidity` values the pool rounding down. A $5,000 long entered at +/// $100 and marked at $100.00000001 is worth 5,000,000,000.5 base units, so +/// traders are up half a base unit. Rounded against the withdrawing provider +/// that half counts as a whole unit owed, assets-under-management reads +/// 99,999,999,999, and the provider's 99,999,999,000 shares of 100,000,000,000 +/// redeem 99,999,998,999.00000001, paid as 99,999,998,999. Valuing the long at +/// 5,000,000,000 would pay 99,999,999,000. +#[test] +fn test_remove_liquidity_values_the_pool_rounding_down() { + let mut market = Market::default_market(); + let (provider, provider_collateral) = market.seed_liquidity(100_000 * ONE_USDC); + let (trader, trader_collateral) = market.funded_trader(1_000 * ONE_USDC); + market + .open_position( + &trader, + trader_collateral, + Side::Long, + 1_000 * ONE_USDC, + 5_000 * ONE_USDC, + 0, + ) + .unwrap(); + market.set_price(dollars(100) + 1); + + let provider_lp = derive_ata(&provider.pubkey(), &market.lp_mint); + let shares = get_token_account_balance(&market.svm, &provider_lp).unwrap(); + assert_eq!(shares, 100_000 * ONE_USDC - 1_000); + market + .remove_liquidity(&provider, provider_collateral, shares, 0) + .unwrap(); + assert_eq!( + get_token_account_balance(&market.svm, &provider_collateral).unwrap(), + 99_999_998_999 + ); + assert_eq!(market.pool_state().liquidity, 1_001); + market.assert_vault_matches_ledger(); +} /// One slot short of the warm-up, a profitable close is refused and the /// position stays open. diff --git a/finance/perpetual-futures/anchor/CHANGELOG.md b/finance/perpetual-futures/anchor/CHANGELOG.md index f5aebb809..050be932e 100644 --- a/finance/perpetual-futures/anchor/CHANGELOG.md +++ b/finance/perpetual-futures/anchor/CHANGELOG.md @@ -2,6 +2,23 @@ ## Unreleased, 2026-10-05 +Liquidity-provider shares are priced against the provider. +`traders_unrealized_pnl` floored both sides' marked value, so the long side +rounded traders' profit down and the short side rounded it up, and +`add_liquidity` or `remove_liquidity` could round a base unit in the +provider's favour depending on the book. It now takes a `Rounding`, and +`liquidity_provider_aum` takes the direction of the valuation: +`add_liquidity` values the pool rounding up, so a deposit is minted no more +shares than it pays for, and `remove_liquidity` rounds it down, so a +withdrawal is paid no more than its shares are worth. `haircut_ratio` rounds +the traders' liability up, so a fraction of a base unit can only lower `h`. +Tested by `test_add_liquidity_values_the_pool_rounding_up` (a 100,000 USDC +deposit against an open short marked half a base unit in profit mints +100,000,000,000 shares, not 100,000,000,001) and +`test_remove_liquidity_values_the_pool_rounding_down` (a provider +withdrawing against an open long marked half a base unit in profit is paid +99,999,998,999, not 99,999,999,000). No existing figure changes. + Profit/loss and funding round against the trader. `position_pnl` and `position_funding` in `instructions/shared.rs` divided with truncation toward zero, so a fractional loss was booked a base unit small, and funding a trader diff --git a/finance/perpetual-futures/anchor/README.md b/finance/perpetual-futures/anchor/README.md index 40a37e455..49a58598e 100644 --- a/finance/perpetual-futures/anchor/README.md +++ b/finance/perpetual-futures/anchor/README.md @@ -21,7 +21,7 @@ A [perpetual future](https://www.investopedia.com/terms/f/futurescontract.asp) ( - `perpetual-futures`: The exchange: pool creation, liquidity provision, opening/closing leveraged positions, funding, liquidation, and fee collection. - `mock-price-feed`: Test-only price feed. Stores a price, scale, last-update slot, and confidence band that tests write directly. Replaced in production by a Pyth `PriceUpdateV2` account, as read in [`basics/pyth`](../../../basics/pyth/). -All arithmetic is integer `u128` with `checked_*` operations, multiplying before dividing and rounding in the pool's favour: every fee and the maintenance requirement round up, and what is paid out rounds down. A position's profit/loss is floored toward negative infinity, so a fractional loss rounds up to the next base unit, and its funding rounds toward positive infinity, so funding the trader pays rounds up and funding the trader receives rounds down (`test_position_pnl_rounds_against_the_trader`, `test_position_funding_rounds_against_the_trader`). No floats, no fixed-point library. +All arithmetic is integer `u128` with `checked_*` operations, multiplying before dividing and rounding in the pool's favour: every fee and the maintenance requirement round up, and what is paid out rounds down. A position's profit/loss is floored toward negative infinity, so a fractional loss rounds up to the next base unit, and its funding rounds toward positive infinity, so funding the trader pays rounds up and funding the trader receives rounds down (`test_position_pnl_rounds_against_the_trader`, `test_position_funding_rounds_against_the_trader`). Provider shares are priced against a valuation rounded against the provider: `add_liquidity` values the pool rounding up, so a deposit is minted no more shares than it pays for, and `remove_liquidity` rounds it down, so a withdrawal is paid no more than its shares are worth (`test_add_liquidity_values_the_pool_rounding_up`, `test_remove_liquidity_values_the_pool_rounding_down`). The haircut's liability rounds up, so a fraction of a base unit can only lower `h`. No floats, no fixed-point library. --- @@ -272,6 +272,7 @@ The tests run in-process with [LiteSVM](https://www.anchor-lang.com/docs/testing - the profit warm-up on both sides of its boundary (`test_profit_blocked_before_maturation`, `test_profit_realized_after_maturation`), and a loss closing in the slot it opened (`test_loss_not_gated_by_maturation`) - the insurance fund: its exact share of each fee (`test_insurance_fund_funded_by_fees`), a bankrupt position's deficit paid by the fund (`test_insurance_absorbs_bankruptcy_deficit`), and a bankrupt position liquidated for no fee with the fund paying before the providers (`test_liquidation_of_bankrupt_position_charges_insurance_before_liquidity`) - withdrawals capped at `liquidity` while traders are down (`test_remove_liquidity_capped_at_liquidity`) +- the pool valued rounding up for a deposit and down for a withdrawal, each a base unit in the pool's favour against the old valuation (`test_add_liquidity_values_the_pool_rounding_up`, `test_remove_liquidity_values_the_pool_rounding_down`) - `initialize_pool`'s parameter checks, including an initial margin at or below the maintenance margin, a close fee at or above it (`test_initialize_pool_rejects_close_fee_at_or_above_maintenance_margin`), a price band outside its range, and an insurance fee of 10,000 basis points or more - every fee and the maintenance requirement rounding up (`test_fees_and_maintenance_requirement_round_up`), and `basis_points_of` at its boundaries - fee collection, and its refusal to anyone but the pool's authority (`test_collect_fees_requires_authority`, which asserts the constraint's own error code) diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/add_liquidity.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/add_liquidity.rs index 95955d80f..bd69cd055 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/add_liquidity.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/add_liquidity.rs @@ -8,7 +8,9 @@ use anchor_spl::{ use crate::constants::{MINIMUM_LIQUIDITY, POOL_SEED, VAULT_SEED}; use crate::errors::PerpError; -use crate::instructions::shared::{liquidity_provider_aum, refresh_price_and_funding_within_band}; +use crate::instructions::shared::{ + liquidity_provider_aum, refresh_price_and_funding_within_band, Rounding, +}; use crate::state::Pool; pub fn handle_add_liquidity( @@ -44,7 +46,11 @@ pub fn handle_add_liquidity( // The same divisor covers a pool whose providers have all left: the // minimum's slice is still in `liquidity`, so the next deposit is // priced against it rather than bootstrapped. - let aum = liquidity_provider_aum(pool, price)?; + // + // The pool is valued rounding up, so a fraction of a base unit in the + // traders' marked profit/loss raises the price of a share rather than + // lowering it. + let aum = liquidity_provider_aum(pool, price, Rounding::Up)?; require!(aum > 0, PerpError::PoolInsolvent); let total_shares = (lp_supply as u128) .checked_add(MINIMUM_LIQUIDITY as u128) diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/remove_liquidity.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/remove_liquidity.rs index 68c74a461..f6340548d 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/remove_liquidity.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/remove_liquidity.rs @@ -8,7 +8,9 @@ use anchor_spl::{ use crate::constants::{MINIMUM_LIQUIDITY, POOL_SEED, VAULT_SEED}; use crate::errors::PerpError; -use crate::instructions::shared::{liquidity_provider_aum, refresh_price_and_funding_within_band}; +use crate::instructions::shared::{ + liquidity_provider_aum, refresh_price_and_funding_within_band, Rounding, +}; use crate::state::Pool; pub fn handle_remove_liquidity( @@ -22,7 +24,9 @@ pub fn handle_remove_liquidity( let price = refresh_price_and_funding_within_band(pool, &context.accounts.oracle_feed)?; let lp_supply = context.accounts.lp_mint.supply(); - let aum = liquidity_provider_aum(pool, price)?; + // The pool is valued rounding down, so a fraction of a base unit in the + // traders' marked profit/loss lowers what a share redeems for. + let aum = liquidity_provider_aum(pool, price, Rounding::Down)?; require!(aum > 0, PerpError::PoolInsolvent); // amount_out = shares * assets-under-management / (supply + MINIMUM_LIQUIDITY), diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/shared.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/shared.rs index f60835704..18dffb1ac 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/shared.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/shared.rs @@ -141,31 +141,68 @@ pub fn position_pnl(side: Side, size: u64, entry_price: u64, price: u64) -> Resu .ok_or(PerpError::MathOverflow.into()) } +/// Which way a valuation's fractional base unit goes. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Rounding { + Down, + Up, +} + +/// `numerator / denominator` for a non-negative `numerator` and a positive +/// `denominator`, rounded the way `rounding` says. +fn divide_rounding(numerator: i128, denominator: i128, rounding: Rounding) -> Result { + let quotient = numerator + .checked_div(denominator) + .ok_or(PerpError::MathOverflow)?; + let remainder = numerator + .checked_rem(denominator) + .ok_or(PerpError::MathOverflow)?; + if rounding == Rounding::Up && remainder != 0 { + return quotient + .checked_add(1) + .ok_or(PerpError::MathOverflow.into()); + } + Ok(quotient) +} + /// Aggregate unrealized profit/loss of every open trader at `price`, derived /// from the pool's running accumulators rather than iterating positions. /// Positive means traders are collectively up (and the pool is down). /// +/// `rounding` is the direction of the result: `Up` rounds the long side's +/// value up and the short side's value down, so a fraction of a base unit on +/// either side counts as owed to traders; `Down` does the opposite. Callers +/// choose the direction that goes against whoever is being priced. +/// /// Profit is marked in full, before any haircut: while `haircut_ratio` is /// below one, winners will be paid less than this, so assets-under-management /// reads low by the withheld part until they close. -pub fn traders_unrealized_pnl(pool: &Pool, price: u64) -> Result { +pub fn traders_unrealized_pnl(pool: &Pool, price: u64, rounding: Rounding) -> Result { let price = price as i128; let size_precision = SIZE_PRECISION as i128; + let (long_rounding, short_rounding) = match rounding { + Rounding::Up => (Rounding::Up, Rounding::Down), + Rounding::Down => (Rounding::Down, Rounding::Up), + }; - let long_value = price - .checked_mul(pool.long_size_scaled as i128) - .ok_or(PerpError::MathOverflow)? - .checked_div(size_precision) - .ok_or(PerpError::MathOverflow)?; + let long_value = divide_rounding( + price + .checked_mul(pool.long_size_scaled as i128) + .ok_or(PerpError::MathOverflow)?, + size_precision, + long_rounding, + )?; let long_pnl = long_value .checked_sub(pool.long_size as i128) .ok_or(PerpError::MathOverflow)?; - let short_value = price - .checked_mul(pool.short_size_scaled as i128) - .ok_or(PerpError::MathOverflow)? - .checked_div(size_precision) - .ok_or(PerpError::MathOverflow)?; + let short_value = divide_rounding( + price + .checked_mul(pool.short_size_scaled as i128) + .ok_or(PerpError::MathOverflow)?, + size_precision, + short_rounding, + )?; let short_pnl = (pool.short_size as i128) .checked_sub(short_value) .ok_or(PerpError::MathOverflow)?; @@ -179,8 +216,18 @@ pub fn traders_unrealized_pnl(pool: &Pool, price: u64) -> Result { /// what traders are collectively owed. This is what liquidity-provider shares /// are priced against, so it marks open positions to the current price and an /// exiting provider cannot dodge an in-progress trader profit. -pub fn liquidity_provider_aum(pool: &Pool, price: u64) -> Result { - let traders = traders_unrealized_pnl(pool, price)?; +/// +/// `rounding` is the direction of the result, and each caller rounds against +/// the provider: `add_liquidity` values the pool rounding `Up`, so a deposit +/// is minted no more shares than it pays for, and `remove_liquidity` values it +/// rounding `Down`, so a withdrawal is paid no more than its shares are worth. +pub fn liquidity_provider_aum(pool: &Pool, price: u64, rounding: Rounding) -> Result { + // A higher valuation needs the traders' figure rounded the other way. + let traders_rounding = match rounding { + Rounding::Up => Rounding::Down, + Rounding::Down => Rounding::Up, + }; + let traders = traders_unrealized_pnl(pool, price, traders_rounding)?; (pool.liquidity as i128) .checked_sub(traders) .ok_or(PerpError::MathOverflow.into()) @@ -205,7 +252,9 @@ pub fn liquidity_provider_aum(pool: &Pool, price: u64) -> Result { /// paid at most the pool's backing, and is never refused; when the liability is /// the larger, every other winner's fraction is unchanged. pub fn haircut_ratio(pool: &Pool, price: u64, closing_profit: i128) -> Result { - let liability: u128 = traders_unrealized_pnl(pool, price)? + // Rounded up, so a fraction of a base unit counts as owed and can only + // lower `h`: no winner is paid more than the backing allows. + let liability: u128 = traders_unrealized_pnl(pool, price, Rounding::Up)? .max(closing_profit) .max(0) .try_into() diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/tests/test_perpetual_futures.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/tests/test_perpetual_futures.rs index 2545aa147..af8d616c3 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/tests/test_perpetual_futures.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/tests/test_perpetual_futures.rs @@ -1811,6 +1811,81 @@ fn test_remove_liquidity_capped_at_liquidity() { assert_eq!(market.pool_state().liquidity, 0); market.assert_vault_matches_ledger(); } +/// `add_liquidity` values the pool rounding up. A $5,000 short entered at +/// $100 and marked at $99.99999999 is worth 4,999,999,999.5 base units, so +/// traders are up half a base unit. Rounded against the depositor that half +/// counts as nothing owed, assets-under-management stays at the pool's +/// 100,000 USDC of liquidity, and a 100,000 USDC deposit is minted exactly +/// 100,000 USDC of shares. Valuing the short at 4,999,999,999 would read the +/// pool one base unit low and mint one share more. +#[test] +fn test_add_liquidity_values_the_pool_rounding_up() { + let mut market = Market::default_market(); + market.seed_liquidity(100_000 * ONE_USDC); + let (trader, trader_collateral) = market.funded_trader(1_000 * ONE_USDC); + market + .open_position( + &trader, + trader_collateral, + Side::Short, + 1_000 * ONE_USDC, + 5_000 * ONE_USDC, + 0, + ) + .unwrap(); + market.set_price(dollars(100) - 1); + + let deposit = 100_000 * ONE_USDC; + let (provider, provider_collateral) = market.funded_trader(deposit); + market + .add_liquidity(&provider, provider_collateral, deposit, 0) + .unwrap(); + let provider_lp = derive_ata(&provider.pubkey(), &market.lp_mint); + assert_eq!( + get_token_account_balance(&market.svm, &provider_lp).unwrap(), + 100_000 * ONE_USDC + ); + assert_eq!(market.pool_state().liquidity, 200_000 * ONE_USDC); + market.assert_vault_matches_ledger(); +} + +/// `remove_liquidity` values the pool rounding down. A $5,000 long entered at +/// $100 and marked at $100.00000001 is worth 5,000,000,000.5 base units, so +/// traders are up half a base unit. Rounded against the withdrawing provider +/// that half counts as a whole unit owed, assets-under-management reads +/// 99,999,999,999, and the provider's 99,999,999,000 shares of 100,000,000,000 +/// redeem 99,999,998,999.00000001, paid as 99,999,998,999. Valuing the long at +/// 5,000,000,000 would pay 99,999,999,000. +#[test] +fn test_remove_liquidity_values_the_pool_rounding_down() { + let mut market = Market::default_market(); + let (provider, provider_collateral) = market.seed_liquidity(100_000 * ONE_USDC); + let (trader, trader_collateral) = market.funded_trader(1_000 * ONE_USDC); + market + .open_position( + &trader, + trader_collateral, + Side::Long, + 1_000 * ONE_USDC, + 5_000 * ONE_USDC, + 0, + ) + .unwrap(); + market.set_price(dollars(100) + 1); + + let provider_lp = derive_ata(&provider.pubkey(), &market.lp_mint); + let shares = get_token_account_balance(&market.svm, &provider_lp).unwrap(); + assert_eq!(shares, 100_000 * ONE_USDC - 1_000); + market + .remove_liquidity(&provider, provider_collateral, shares, 0) + .unwrap(); + assert_eq!( + get_token_account_balance(&market.svm, &provider_collateral).unwrap(), + 99_999_998_999 + ); + assert_eq!(market.pool_state().liquidity, 1_001); + market.assert_vault_matches_ledger(); +} /// One slot short of the warm-up, a profitable close is refused and the /// position stays open. diff --git a/finance/perpetual-futures/quasar/CHANGELOG.md b/finance/perpetual-futures/quasar/CHANGELOG.md index 660c173fd..01db41e49 100644 --- a/finance/perpetual-futures/quasar/CHANGELOG.md +++ b/finance/perpetual-futures/quasar/CHANGELOG.md @@ -2,6 +2,23 @@ ## Unreleased, 2026-10-05 +Liquidity-provider shares are priced against the provider. +`traders_unrealized_pnl` floored both sides' marked value, so the long side +rounded traders' profit down and the short side rounded it up, and +`add_liquidity` or `remove_liquidity` could round a base unit in the +provider's favour depending on the book. It now takes a `Rounding`: +`add_liquidity` rounds traders' profit down and so values the pool rounding +up, so a deposit is minted no more shares than it pays for, and +`remove_liquidity` rounds the pool down, so a withdrawal is paid no more +than its shares are worth. `haircut_ratio` rounds the traders' liability up, +so a fraction of a base unit can only lower `h`. Tested by +`add_liquidity_values_the_pool_rounding_up` (a 100,000 USDC deposit against +an open short marked half a base unit in profit mints 100,000,000,000 +shares, not 100,000,000,001) and +`remove_liquidity_values_the_pool_rounding_down` (a provider withdrawing +against an open long marked half a base unit in profit is paid +99,999,998,999, not 99,999,999,000). No existing figure changes. + Profit/loss and funding round against the trader. `position_pnl` and `position_funding` in `instructions/shared.rs` divided with truncation toward zero, so a fractional loss was booked a base unit small, and funding a trader diff --git a/finance/perpetual-futures/quasar/README.md b/finance/perpetual-futures/quasar/README.md index afdb08d79..416b27a6f 100644 --- a/finance/perpetual-futures/quasar/README.md +++ b/finance/perpetual-futures/quasar/README.md @@ -69,6 +69,10 @@ wallets, then exercise: positive infinity, so a fraction of a base unit always goes to the pool (`position_pnl_rounds_against_the_trader`, `position_funding_rounds_against_the_trader`) +- the pool valued rounding up for a deposit and down for a withdrawal, so + liquidity-provider shares are priced against the provider + (`add_liquidity_values_the_pool_rounding_up`, + `remove_liquidity_values_the_pool_rounding_down`) - the haircut: a position opening without full backing, profit paid in full while the pool backs it, two winners each paid exactly half when the pool is stressed (`haircut_scales_profit_when_pool_stressed`), the insurance fund diff --git a/finance/perpetual-futures/quasar/src/instructions/add_liquidity.rs b/finance/perpetual-futures/quasar/src/instructions/add_liquidity.rs index 148d44d18..dfebd24df 100644 --- a/finance/perpetual-futures/quasar/src/instructions/add_liquidity.rs +++ b/finance/perpetual-futures/quasar/src/instructions/add_liquidity.rs @@ -2,7 +2,7 @@ use { crate::{ constants::MINIMUM_LIQUIDITY, instructions::shared::{ - err, error, refresh_price_and_funding_within_band, traders_unrealized_pnl, + err, error, refresh_price_and_funding_within_band, traders_unrealized_pnl, Rounding, }, state::Pool, LpMintPda, @@ -78,6 +78,9 @@ pub fn handle_add_liquidity( accounts.pool.short_size.get(), accounts.pool.short_size_scaled.get(), price, + // Rounded down, so the pool is valued high and a fraction of a + // base unit raises the price of a share rather than lowering it. + Rounding::Down, )?; let aum = (accounts.pool.liquidity.get() as i128) .checked_sub(traders) diff --git a/finance/perpetual-futures/quasar/src/instructions/remove_liquidity.rs b/finance/perpetual-futures/quasar/src/instructions/remove_liquidity.rs index 8d2178e26..b9c950ce0 100644 --- a/finance/perpetual-futures/quasar/src/instructions/remove_liquidity.rs +++ b/finance/perpetual-futures/quasar/src/instructions/remove_liquidity.rs @@ -2,7 +2,7 @@ use { crate::{ constants::MINIMUM_LIQUIDITY, instructions::shared::{ - err, error, refresh_price_and_funding_within_band, traders_unrealized_pnl, + err, error, refresh_price_and_funding_within_band, traders_unrealized_pnl, Rounding, }, state::Pool, LpMintPda, @@ -70,6 +70,9 @@ pub fn handle_remove_liquidity( accounts.pool.short_size.get(), accounts.pool.short_size_scaled.get(), price, + // Rounded up, so the pool is valued low and a fraction of a base + // unit lowers what a share redeems for. + Rounding::Up, )?; let aum = (accounts.pool.liquidity.get() as i128) .checked_sub(traders) diff --git a/finance/perpetual-futures/quasar/src/instructions/shared.rs b/finance/perpetual-futures/quasar/src/instructions/shared.rs index b77e063ee..dfc4c89ce 100644 --- a/finance/perpetual-futures/quasar/src/instructions/shared.rs +++ b/finance/perpetual-futures/quasar/src/instructions/shared.rs @@ -219,30 +219,72 @@ pub fn position_pnl( .ok_or_else(overflow) } +/// Which way a valuation's fractional base unit goes. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Rounding { + Down, + Up, +} + +/// `numerator / denominator` for a non-negative `numerator` and a positive +/// `denominator`, rounded the way `rounding` says. +fn divide_rounding( + numerator: i128, + denominator: i128, + rounding: Rounding, +) -> Result { + let quotient = numerator.checked_div(denominator).ok_or_else(overflow)?; + let remainder = numerator.checked_rem(denominator).ok_or_else(overflow)?; + if rounding == Rounding::Up && remainder != 0 { + return quotient.checked_add(1).ok_or_else(overflow); + } + Ok(quotient) +} + +/// Aggregate unrealized profit/loss of every open trader at `price`, from the +/// pool's per-side accumulators. Positive means traders are collectively up. +/// +/// `rounding` is the direction of the result: `Up` rounds the long side's +/// value up and the short side's value down, so a fraction of a base unit on +/// either side counts as owed to traders; `Down` does the opposite. Callers +/// choose the direction that goes against whoever is being priced: +/// `add_liquidity` rounds this `Down` (the pool is valued high, so a deposit +/// is minted no more shares than it pays for), `remove_liquidity` and +/// `haircut_ratio` round it `Up` (a withdrawal is paid no more than its shares +/// are worth, and no winner more than the backing allows). pub fn traders_unrealized_pnl( long_size: u128, long_size_scaled: u128, short_size: u128, short_size_scaled: u128, price: u64, + rounding: Rounding, ) -> Result { let price = price as i128; let size_precision = SIZE_PRECISION as i128; + let (long_rounding, short_rounding) = match rounding { + Rounding::Up => (Rounding::Up, Rounding::Down), + Rounding::Down => (Rounding::Down, Rounding::Up), + }; - let long_value = price - .checked_mul(long_size_scaled as i128) - .ok_or_else(overflow)? - .checked_div(size_precision) - .ok_or_else(overflow)?; + let long_value = divide_rounding( + price + .checked_mul(long_size_scaled as i128) + .ok_or_else(overflow)?, + size_precision, + long_rounding, + )?; let long_pnl = long_value .checked_sub(long_size as i128) .ok_or_else(overflow)?; - let short_value = price - .checked_mul(short_size_scaled as i128) - .ok_or_else(overflow)? - .checked_div(size_precision) - .ok_or_else(overflow)?; + let short_value = divide_rounding( + price + .checked_mul(short_size_scaled as i128) + .ok_or_else(overflow)?, + size_precision, + short_rounding, + )?; let short_pnl = (short_size as i128) .checked_sub(short_value) .ok_or_else(overflow)?; @@ -279,6 +321,9 @@ pub fn haircut_ratio( pool.short_size.get(), pool.short_size_scaled.get(), price, + // Rounded up, so a fraction of a base unit counts as owed and can + // only lower `h`. + Rounding::Up, )?; let liability = u128::try_from(traders.max(closing_profit).max(0)).map_err(|_| overflow())?; if liability == 0 { diff --git a/finance/perpetual-futures/quasar/src/tests.rs b/finance/perpetual-futures/quasar/src/tests.rs index 99c11b8f1..1013bb94e 100644 --- a/finance/perpetual-futures/quasar/src/tests.rs +++ b/finance/perpetual-futures/quasar/src/tests.rs @@ -1095,6 +1095,59 @@ fn remove_liquidity_capped_at_liquidity(test: &mut Test) { assert_vault_matches_ledger(test, &env); } +/// `add_liquidity` values the pool rounding up. A $5,000 short entered at +/// $100 and marked at $99.99999999 is worth 4,999,999,999.5 base units, so +/// traders are up half a base unit. Rounded against the depositor that half +/// counts as nothing owed, assets-under-management stays at the pool's +/// 100,000 USDC of liquidity, and a 100,000 USDC deposit is minted exactly +/// 100,000 USDC of shares. Valuing the short at 4,999,999,999 would read the +/// pool one base unit low and mint one share more. +#[quasar_test] +fn add_liquidity_values_the_pool_rounding_up(test: &mut Test) { + let env = setup(test); + fund(test, PROVIDER, PROVIDER_COLLATERAL, 200_000 * ONE_USDC); + add_liquidity(test, &env, 100_000 * ONE_USDC).succeeds(); + fund(test, TRADER, TRADER_COLLATERAL, 1_000 * ONE_USDC); + open_position(test, &env, SIDE_SHORT, 1_000 * ONE_USDC, 5_000 * ONE_USDC).succeeds(); + set_feed(test, dollars(100) - 1, 0); + + // The provider already holds the first deposit's 100,000 USDC of shares + // less the withheld 1,000. + add_liquidity(test, &env, 100_000 * ONE_USDC) + .succeeds() + .has_tokens(PROVIDER_LP, 200_000 * ONE_USDC - 1_000); + assert_eq!( + u64::from(test.read::(env.pool).liquidity), + 200_000 * ONE_USDC + ); + assert_vault_matches_ledger(test, &env); +} + +/// `remove_liquidity` values the pool rounding down. A $5,000 long entered at +/// $100 and marked at $100.00000001 is worth 5,000,000,000.5 base units, so +/// traders are up half a base unit. Rounded against the withdrawing provider +/// that half counts as a whole unit owed, assets-under-management reads +/// 99,999,999,999, and the provider's 99,999,999,000 shares of 100,000,000,000 +/// redeem 99,999,998,999.00000001, paid as 99,999,998,999. Valuing the long at +/// 5,000,000,000 would pay 99,999,999,000. +#[quasar_test] +fn remove_liquidity_values_the_pool_rounding_down(test: &mut Test) { + let env = setup(test); + fund(test, PROVIDER, PROVIDER_COLLATERAL, 100_000 * ONE_USDC); + add_liquidity(test, &env, 100_000 * ONE_USDC).succeeds(); + fund(test, TRADER, TRADER_COLLATERAL, 1_000 * ONE_USDC); + open_position(test, &env, SIDE_LONG, 1_000 * ONE_USDC, 5_000 * ONE_USDC).succeeds(); + set_feed(test, dollars(100) + 1, 0); + + let shares = test.tokens(PROVIDER_LP); + assert_eq!(shares, 100_000 * ONE_USDC - 1_000); + remove_liquidity(test, &env, shares) + .succeeds() + .has_tokens(PROVIDER_COLLATERAL, 99_999_998_999); + assert_eq!(u64::from(test.read::(env.pool).liquidity), 1_001); + assert_vault_matches_ledger(test, &env); +} + /// Open a $5,000 long with $1,000 of collateral against a $100,000 pool and /// return the slot it opened in. fn open_long_against_deep_pool(test: &mut Test, env: &Env) -> u64 { From 427d19a833b02f24556d3ed7b6ddf68aa5775dd3 Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Wed, 7 Oct 2026 18:45:56 +0000 Subject: [PATCH 6/6] Format an options test with rustfmt Claude-Session: https://claude.ai/code/session_01UX53A6YR1Hjr8z6WzJxf2q --- .../options/anchor/programs/options/tests/test_options.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/finance/options/anchor/programs/options/tests/test_options.rs b/finance/options/anchor/programs/options/tests/test_options.rs index ed2639fc8..88a035db6 100644 --- a/finance/options/anchor/programs/options/tests/test_options.rs +++ b/finance/options/anchor/programs/options/tests/test_options.rs @@ -999,7 +999,10 @@ fn test_put_writer_without_an_underlying_account_writes_and_cancels() { let option = venue.write_put(&carol); assert_eq!(venue.balance(&carol.underlying), 0); - assert_eq!(venue.balance(&carol.quote), STANDARD_USDC - PUT_STRIKE_AMOUNT); + assert_eq!( + venue.balance(&carol.quote), + STANDARD_USDC - PUT_STRIKE_AMOUNT + ); let option_rent = venue.lamports(&option); let carol_lamports_before_cancel = venue.lamports(&carol.pubkey());