diff --git a/finance/fundraiser/anchor-v1/CHANGELOG.md b/finance/fundraiser/anchor-v1/CHANGELOG.md index 0718dffe..d1913374 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 f72a3afe..e3fa6983 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 791617c5..42697bfb 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 e2319219..79798b45 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 df28f3bd..72521ad1 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 a298d012..154c6793 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 ce8b0cff..71e71ae7 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 4d31c0a3..fcc21e87 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 825a885e..79b29ef0 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 fb99735a..2aeb17aa 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 01d5a6dc..1af70fa2 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 e40d3167..1dec0a33 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 4c21ac9a..66c0e524 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 4f480167..64e9ad25 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 f9d4f3a0..7917c26d 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 8752f83a..23713bb4 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 a965be57..d84a614e 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 4df3ff74..319bcf7a 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)] @@ -138,7 +141,12 @@ 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 + // 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); } /// The program's cut of one accrual's interest: `ceil(interest * reserve_factor @@ -176,17 +184,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 +211,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 +230,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 +246,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 +385,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 a210b960..bb13909f 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 444b06b2..334ab9cb 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 2f05a240..54f2a2cd 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 d3007d54..518a3b28 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 fd9d84f9..7ef472cc 100644 --- a/finance/managed-fund/anchor-v1/CHANGELOG.md +++ b/finance/managed-fund/anchor-v1/CHANGELOG.md @@ -8,6 +8,12 @@ - **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 + +- **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 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 - **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 4e8012bd..319e2106 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 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. 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_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 891535ab..accc8b0f 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 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 462d7f94..1f7ff6be 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 a quorum of Pyth's signers")] + 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 bdbe2135..90cf62a9 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/instructions/rebalance.rs b/finance/managed-fund/anchor-v1/programs/managed-fund/src/instructions/rebalance.rs index ac30d48a..090c700e 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 1f90ec47..65694813 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 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; /// 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 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!( + 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 a quorum of Pyth's signers 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,55 @@ 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, + ) +} + +/// `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 cfec8e5c..026eec36 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(); @@ -1295,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() { @@ -2277,3 +2370,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 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. +#[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 8e79bd00..83703281 100644 --- a/finance/managed-fund/anchor/CHANGELOG.md +++ b/finance/managed-fund/anchor/CHANGELOG.md @@ -8,6 +8,12 @@ - **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 + +- **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 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 - **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 db52556a..2f8b161b 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 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. 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_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 891535ab..accc8b0f 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 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 462d7f94..1f7ff6be 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 a quorum of Pyth's signers")] + 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 dc4afb77..1d0b21bc 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/instructions/rebalance.rs b/finance/managed-fund/anchor/programs/managed-fund/src/instructions/rebalance.rs index f30aba4e..af5d2ec7 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 427e9248..ba7bd3ff 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 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; /// 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 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!( + 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 a quorum of Pyth's signers 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,55 @@ 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, + ) +} + +/// `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 5347c21d..7771a4e3 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(); @@ -1297,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() { @@ -2279,3 +2372,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 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. +#[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/kani-proofs/README.md b/finance/managed-fund/kani-proofs/README.md index c8f2d038..fba60867 100644 --- a/finance/managed-fund/kani-proofs/README.md +++ b/finance/managed-fund/kani-proofs/README.md @@ -22,6 +22,8 @@ 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. +- `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 @@ -34,6 +36,8 @@ 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 +- `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 1c1a3ff4..6ca54041 100644 --- a/finance/managed-fund/kani-proofs/src/lib.rs +++ b/finance/managed-fund/kani-proofs/src/lib.rs @@ -111,6 +111,54 @@ 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) +} + +/// `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( @@ -385,6 +433,101 @@ 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); + } +} + +// =========================================================================== +// 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. // =========================================================================== @@ -417,6 +560,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), @@ -463,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 246643a1..55f5471a 100644 --- a/finance/managed-fund/quasar/CHANGELOG.md +++ b/finance/managed-fund/quasar/CHANGELOG.md @@ -22,6 +22,34 @@ TSLAx at nine decimals on the same feed, so the decimals vary as well; both get the story's share counts. +### 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 + 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 + 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 258aa734..444e9cce 100644 --- a/finance/managed-fund/quasar/README.md +++ b/finance/managed-fund/quasar/README.md @@ -106,8 +106,14 @@ 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. 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 @@ -119,6 +125,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 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 + later field a byte along. ## What the Quasar port does differently @@ -160,7 +172,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 a24c35a2..20b349ce 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 a quorum of Pyth's signers. + 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 dbd5b145..929fa996 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/instructions/rebalance.rs b/finance/managed-fund/quasar/managed-fund/src/instructions/rebalance.rs index 87a7903c..ecdded0c 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 0d9a2b9b..bc4966a1 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 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; // 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 a quorum of Pyth's signers 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 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!( + 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,55 @@ 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, + ) +} + +/// `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 29db6ea1..39e5b0b8 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 @@ -62,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]); @@ -97,10 +102,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 +139,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 +148,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 +429,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 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. +#[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 +983,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); @@ -958,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/options/anchor-v1/CHANGELOG.md b/finance/options/anchor-v1/CHANGELOG.md index 00f5b52d..1fcc0edb 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 bc1d4bd7..f8349dee 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 2f2b4655..785cefc8 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 d3a942a8..a411f6ec 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 de9dd771..9bc65346 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 28b72e82..6c387a5d 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 f650c33b..97679b68 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 eef41b49..96a52ebd 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 8e5e6023..fc41661e 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 09aaed78..e44a4e53 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,136 @@ 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(); + } +} + +/// 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. +#[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 463f0cc5..ac2b790a 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 4edbe1b7..aff24d74 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 2f2b4655..785cefc8 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 913a8aaa..8f2b00f2 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 24823799..ba54a502 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 592f0053..118412e3 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 9e00c1ac..646ffa3f 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 e5d70631..9e59de2e 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 d534be50..1e344f73 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 d20a34f1..88a035db 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,94 @@ 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 +1161,136 @@ 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(); + } +} + +/// 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. +#[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 82981a21..225a45c3 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 6725561f..fc043d81 100644 --- a/finance/options/quasar/README.md +++ b/finance/options/quasar/README.md @@ -13,14 +13,25 @@ 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; 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 + 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, 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 @@ -61,7 +72,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 abba417f..53a769ab 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 f9a9748c..c16be4c8 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 4b5d12d6..66fba2df 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 4aa10c80..81e62e03 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 ac369abd..d7983a07 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 2d02361a..1a062b5a 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 791dc089..8b2435c1 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 6072a82b..d67b2407 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 bab6e209..7813b482 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,143 @@ 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); + } +} + +/// 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. +#[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 +1004,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 17b99af2..f22bc226 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 7b1b3330..8499991c 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 9a4ce792..da30f1c9 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 b0a65240..a93ebc6f 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/CHANGELOG.md b/finance/perpetual-futures/anchor-v1/CHANGELOG.md index 64a85ffd..c1cd823d 100644 --- a/finance/perpetual-futures/anchor-v1/CHANGELOG.md +++ b/finance/perpetual-futures/anchor-v1/CHANGELOG.md @@ -2,6 +2,38 @@ ## 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 +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 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 fc3aceca..27b7a1fa 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`). 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 3ce6c4d3..73ea2ac5 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 95283b56..03990e2d 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 bc2530f0..7cc78392 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,37 +132,77 @@ 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()) } +/// 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_value = price - .checked_mul(pool.long_size_scaled as i128) - .ok_or(PerpError::MathOverflow)? - .checked_div(size_precision) - .ok_or(PerpError::MathOverflow)?; + let (long_rounding, short_rounding) = match rounding { + Rounding::Up => (Rounding::Up, Rounding::Down), + Rounding::Down => (Rounding::Down, Rounding::Up), + }; + + 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)?; @@ -176,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()) @@ -202,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() @@ -265,6 +317,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 +331,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 1ce06408..b1145687 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}, }, @@ -1812,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. @@ -2084,14 +2161,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) @@ -2130,6 +2208,58 @@ 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. +#[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/CHANGELOG.md b/finance/perpetual-futures/anchor/CHANGELOG.md index 80e871e4..050be932 100644 --- a/finance/perpetual-futures/anchor/CHANGELOG.md +++ b/finance/perpetual-futures/anchor/CHANGELOG.md @@ -2,6 +2,38 @@ ## 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 +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 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 2165c0a9..49a58598 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`). 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 95955d80..bd69cd05 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 68c74a46..f6340548 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 41288aab..18dffb1a 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,37 +132,77 @@ 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()) } +/// 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_value = price - .checked_mul(pool.long_size_scaled as i128) - .ok_or(PerpError::MathOverflow)? - .checked_div(size_precision) - .ok_or(PerpError::MathOverflow)?; + let (long_rounding, short_rounding) = match rounding { + Rounding::Up => (Rounding::Up, Rounding::Down), + Rounding::Down => (Rounding::Down, Rounding::Up), + }; + + 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)?; @@ -176,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()) @@ -202,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() @@ -265,6 +317,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 +331,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 e41ffd71..af8d616c 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}, }, @@ -1809,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. @@ -2081,14 +2158,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) @@ -2127,6 +2205,58 @@ 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. +#[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/CHANGELOG.md b/finance/perpetual-futures/quasar/CHANGELOG.md index 96c5b067..01db41e4 100644 --- a/finance/perpetual-futures/quasar/CHANGELOG.md +++ b/finance/perpetual-futures/quasar/CHANGELOG.md @@ -2,6 +2,38 @@ ## 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 +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 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 afeb9d05..416b27a6 100644 --- a/finance/perpetual-futures/quasar/README.md +++ b/finance/perpetual-futures/quasar/README.md @@ -65,6 +65,14 @@ 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 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 148d44d1..dfebd24d 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 8d2178e2..b9c950ce 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 2b5b0cfa..dfc4c89c 100644 --- a/finance/perpetual-futures/quasar/src/instructions/shared.rs +++ b/finance/perpetual-futures/quasar/src/instructions/shared.rs @@ -209,36 +209,82 @@ 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) } +/// 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_value = price - .checked_mul(long_size_scaled as i128) - .ok_or_else(overflow)? - .checked_div(size_precision) - .ok_or_else(overflow)?; + let (long_rounding, short_rounding) = match rounding { + Rounding::Up => (Rounding::Up, Rounding::Down), + Rounding::Down => (Rounding::Down, Rounding::Up), + }; + + 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)?; @@ -275,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 { @@ -334,6 +383,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 +399,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)?; + let floored = numerator + .checked_div_euclid(FUNDING_PRECISION) .ok_or_else(overflow)?; - Ok(if side == SIDE_LONG { - long_owed + 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 5656abcb..1013bb94 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, }, @@ -1093,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 { @@ -1312,9 +1367,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) @@ -1348,6 +1404,52 @@ 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. +#[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 9ec04547..3717bb65 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 85e5514a..0b5bce2b 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 18a8f252..939de06c 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 00000000..c824d3d7 --- /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 8d4b5caf..5c70da9d 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 6770f0d0..995f5255 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 85d524c7..808eaa8d 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,92 @@ 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) + } + + /// 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) + .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 +733,155 @@ 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"); +} + +/// 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(); + 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 +1029,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 4adfbdf5..081ca59f 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 12e087b5..b4235378 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 18a8f252..939de06c 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 00000000..7f8525ab --- /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 8d4b5caf..5c70da9d 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 4c8760f2..693493eb 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 2be74af7..11be07b4 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,7 +1,8 @@ use { anchor_lang::{ - solana_program::instruction::Instruction, system_program, AccountDeserialize, Address, - Error as AnchorError, ErrorCode as AnchorErrorCode, InstructionData, ToAccountMetas, + solana_program::instruction::{AccountMeta, Instruction}, + system_program, AccountDeserialize, Address, Error as AnchorError, + ErrorCode as AnchorErrorCode, InstructionData, ToAccountMetas, }, anchor_v2_testing::{Keypair, LiteSVM, Signer}, prop_amm::{ @@ -500,6 +501,89 @@ 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) + } + + /// 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) + .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 +730,160 @@ 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"); +} + +/// 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); + // 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); +} + +#[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 +1029,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 eb8dd6e4..29d53a69 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 19af3f92..60617765 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 00000000..3cab145d --- /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 a254dd43..0ca9c5cc 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 ce7b9269..8cda0b89 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 b995a655..5918027f 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 1126890c..a66bdbdd 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,53 @@ 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, + }) +} + +/// 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) { + 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 +537,199 @@ 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); +} + +/// 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, + ); + // 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, + TRADER_BASE, + TRADER_QUOTE, + DIRECTION_BUY_BASE, + FIVE_NVDAX_AT_THE_ASK, + 0, + ) + .fails(ProgramError::Runtime("IllegalOwner".into())); + 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); + 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 +962,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]