diff --git a/finance/betting-market/anchor-v1/CHANGELOG.md b/finance/betting-market/anchor-v1/CHANGELOG.md index b3559f34c..ce6e18668 100644 --- a/finance/betting-market/anchor-v1/CHANGELOG.md +++ b/finance/betting-market/anchor-v1/CHANGELOG.md @@ -1,5 +1,38 @@ # Changelog +## [Unreleased] - 2026-10-04 + +### Added + +- Two admin handlers close what an event created, once it is `Settled` or + `Cancelled` and every Bet account is closed. `close_outcome` closes one + Outcome account and returns its rent to the admin. `close_event`, once every + Outcome account is closed, pays whatever the vault still holds (the rounding + dust a settlement's floored payouts leave; nothing after a cancellation) to + the fee recipient's token account, closes the vault, and closes the Event + account, returning both rents to the admin. Errors `EventNotFinished`, + `BetsStillOpen` and `OutcomesStillOpen`, appended after `BettingStillOpen`. +- `Event.open_bets` counts the Bet accounts still open across every outcome: + `place_bet` adds one when it creates a Bet account (a top-up reuses the + account), and `claim_winnings`, `claim_refund` and `close_losing_bet` each + subtract one. `Event.open_outcomes` counts the Outcome accounts still open: + `add_outcome` adds one, `close_outcome` subtracts one. Both fields sit after + `outcome_count`, so the Event account layout changes. `claim_winnings`, + `claim_refund` and `close_losing_bet` now take the event as writable. +- Tests: `test_close_event_pays_dust_to_fee_recipient_and_returns_rent`, + `test_close_event_refused_while_a_bet_is_open`, + `test_close_event_refused_while_event_is_open` and + `test_only_admin_can_close_outcomes_and_event`; `test_cancel_and_refund` + closes the cancelled event after its refunds. + +### Changed + +- `test_only_admin_can_initialize_event` asserts the `Unauthorized` code and + also sends `add_outcome` and `open_betting` from a non-admin; + `test_close_losing_bet_only_after_settle_and_only_for_losers` asserts + `EventNotSettled` and `BetWon` by code, and `test_full_lifecycle` asserts + `NothingToClaim` for the loser's claim. + ## [2026-10-03] ### Removed diff --git a/finance/betting-market/anchor-v1/README.md b/finance/betting-market/anchor-v1/README.md index ae65c97bd..146e7531d 100644 --- a/finance/betting-market/anchor-v1/README.md +++ b/finance/betting-market/anchor-v1/README.md @@ -22,24 +22,33 @@ them in one place no single bettor controls, and paying winners by a fixed, publ still requires trusting the admin, who chooses the winning outcome, as described below. The pool is a token account owned by the event's PDA, so payouts are signed by the program with the event's seeds - there is no admin key that can move bettors' stakes out of the pool. The admin's only -powers are creating events/outcomes, opening them to bets, and choosing the winning outcome (or -cancelling). +powers are creating events/outcomes, opening them to bets, choosing the winning outcome (or +cancelling), and closing a finished event's accounts once every bet has been paid out or closed. ## Major Concepts ### Accounts - **Config** (`seeds = [b"config"]`) - one per deployment. Holds the `admin` (the only key that can - create events/outcomes, open betting, settle, and cancel), the `token_mint` every market accepts, - the `fee_recipient`, and the `default_fee_bps` each new event copies at creation. + create events/outcomes, open betting, settle, cancel, and close), the `token_mint` every market + accepts, the `fee_recipient`, and the `default_fee_bps` each new event copies at creation. The + config is the admin's long-lived state and stays open for the life of the deployment; no handler + closes it. - **Event** (`seeds = [b"event", event_id]`) - one betting market. Tracks `total_pool`, `status` (`Draft` / `Open` / `Settled` / `Cancelled`), `betting_closes_at`, and - once settled - the `winning_outcome_index`, `winning_pool`, and `distributable_losing_pool` that the payout formula reads. The event's `fee_bps` is copied from the config's `default_fee_bps` at creation and is what settlement charges. It and `betting_closes_at` are fixed at creation, so later Config changes - can't alter a market bettors have already joined. + can't alter a market bettors have already joined. Two counters say when the event can close: + `open_bets`, the number of Bet accounts across every outcome that are still open (`place_bet` + adds one when it creates a Bet account; a top-up reuses the account and adds nothing; + `claim_winnings`, `claim_refund` and `close_losing_bet` each subtract one), and `open_outcomes`, + the number of Outcome accounts still open (`add_outcome` adds one, `close_outcome` subtracts + one). `close_event` closes the Event account and the vault, returning their rent to the admin. - **Outcome** (`seeds = [b"outcome", event, index]`) - one possible result. Its `total_amount` is - the outcome's share of the pool and the denominator for pro-rata payouts when it wins. + the outcome's share of the pool and the denominator for pro-rata payouts when it wins; its + `bet_count` is the number of Bet accounts ever created on it. `close_outcome` closes it once the + event is finished and every bet is closed, returning its rent to the admin. - **Bet** (`seeds = [b"bet", outcome, bettor]`) - a bettor's total stake on one outcome. Re-betting the same outcome adds to the existing Bet, so there is exactly one per (outcome, bettor). The account exists only while the position is open: it closes (rent back to the bettor) via @@ -52,8 +61,9 @@ cancelling). Each event owns a single vault token account - the associated token account of the Event PDA for `config.token_mint`. `place_bet` moves the stake from the bettor's token account into this vault. -`settle_event`, `claim_winnings`, and `claim_refund` move tokens back out, with the program signing -as the Event PDA (`seeds = [b"event", event_id, bump]`). +`settle_event`, `claim_winnings`, `claim_refund` and `close_event` move tokens back out, with the +program signing as the Event PDA (`seeds = [b"event", event_id, bump]`), and `close_event` then +closes the vault with the same signature, returning its rent to the admin. ### Payout formula @@ -72,7 +82,8 @@ payout = stake + stake * distributable_losing / winning_pool ``` A winner always gets their own stake back; the fee is only ever taken from losing stakes. Integer -division floors each share, leaving at most a few minor units of dust in the vault. +division floors each share, leaving at most a few minor units of dust in the vault, which +`close_event` pays to the fee recipient once every bet is closed. **Example:** Outcome A pool 100, Outcome B pool 50, `fee_bps = 200` (2%). A wins. `losing_pool = 50`, `fee = 1`, `distributable_losing = 49`. A bettor who staked 40 claims @@ -96,6 +107,12 @@ division floors each share, leaving at most a few minor units of dust in the vau - `close_losing_bet` - losing bettor. After settlement, closes a worthless Bet to reclaim its rent. - `cancel_event` - admin. Voids a draft or unresolved market. - `claim_refund` - bettor. After a cancellation, reclaims the exact stake; the Bet account closes. +- `close_outcome` - admin. Once the event is `Settled` or `Cancelled` (else `EventNotFinished`) and + `open_bets` is zero (else `BetsStillOpen`), closes one Outcome account and returns its rent to + the admin. +- `close_event` - admin. Under the same two conditions, and once every Outcome account is closed + (else `OutcomesStillOpen`), pays whatever the vault still holds to the fee recipient's token + account, closes the vault, and closes the Event account, returning both rents to the admin. ### Lifecycle @@ -118,6 +135,20 @@ known, and the admin cannot end a market before the window bettors were promised `settle_event` rejects a winning outcome with no bets - use `cancel_event` to unwind an event that can't be resolved fairly. +`Settled` and `Cancelled` are the two ways an event ends, and in both every Bet account has a +handler that pays it out or closes it: `claim_winnings` and `close_losing_bet` after a settlement, +`claim_refund` after a cancellation. Once `open_bets` reaches zero the admin closes the accounts the +event created, children first: `close_outcome` for each Outcome account, then `close_event` for the +vault and the Event account. The order matters because each account's address is derived from its +parent's. An Outcome account left open after its event closed would be found again, with its old +`total_amount`, by a later event created with the same `event_id`, and a Bet account left open +after its outcome closed would have nothing to claim against. So `close_outcome` refuses while any +bet is open, and `close_event` refuses while any outcome is. After a settlement the vault holds +only the dust the floored payouts left behind, and `close_event` pays it to the fee recipient with +the fee; after a cancellation and its refunds the vault is empty. Every closed account's rent goes +back to whoever paid it: the bettor for a Bet account, the admin for the outcomes, the vault and +the event. + ## Setup Install the [Solana CLI](https://docs.anza.xyz/cli/install) (provides `cargo-build-sbf`) and @@ -139,6 +170,13 @@ edges of the betting close time, settling an outcome with no bets, the cancel/re `close_losing_bet` guards, and a wallet holding forty open bets at once, which shows there is no per-wallet cap. +`test_close_event_pays_dust_to_fee_recipient_and_returns_rent` runs a settlement whose payouts do +not divide evenly, closes the losing bet, both winners' bets, both outcomes and the event, and +checks that the vault's one minor unit of dust reaches the fee recipient, that every closed account +is gone, and that each rent returns to the admin. `test_close_event_refused_while_a_bet_is_open`, +`test_close_event_refused_while_event_is_open` and `test_only_admin_can_close_outcomes_and_event` +check the three refusals, and `test_cancel_and_refund` closes a cancelled event after its refunds. + ```sh anchor test ``` @@ -158,3 +196,7 @@ By the crowd, not a bookmaker: each winner's payout scales with their share of t ### What happens if an event is cancelled? The admin calls `cancel_event` and every bettor reclaims their full stake with `claim_refund`. After a settled event, losers reclaim their bet account's rent with `close_losing_bet`. + +### What happens to the accounts once an event is over? + +Once every bet is paid out or closed, the admin calls `close_outcome` for each outcome and then `close_event`, which pays any rounding dust in the vault to the fee recipient and closes the vault and the Event account. Each rent returns to the admin, who paid it. The Config account stays open; it is the admin's state for the whole deployment. diff --git a/finance/betting-market/anchor-v1/programs/betting-market/src/error.rs b/finance/betting-market/anchor-v1/programs/betting-market/src/error.rs index b81f57b08..92629c963 100644 --- a/finance/betting-market/anchor-v1/programs/betting-market/src/error.rs +++ b/finance/betting-market/anchor-v1/programs/betting-market/src/error.rs @@ -38,4 +38,10 @@ pub enum BettingError { BettingClosed, #[msg("Betting on this event is still open, so it cannot be settled yet")] BettingStillOpen, + #[msg("The event has not been settled or cancelled, so its accounts cannot be closed yet")] + EventNotFinished, + #[msg("Bet accounts are still open, so closing now would strand their claims")] + BetsStillOpen, + #[msg("Outcome accounts are still open, so the event cannot be closed yet")] + OutcomesStillOpen, } diff --git a/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/add_outcome.rs b/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/add_outcome.rs index 29d3b1111..f07d68175 100644 --- a/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/add_outcome.rs +++ b/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/add_outcome.rs @@ -57,6 +57,11 @@ pub fn handle_add_outcome( bump: context.bumps.outcome, }); - context.accounts.event.outcome_count += 1; + let event = &mut context.accounts.event; + event.outcome_count += 1; + event.open_outcomes = event + .open_outcomes + .checked_add(1) + .ok_or(BettingError::MathOverflow)?; Ok(()) } diff --git a/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/claim_refund.rs b/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/claim_refund.rs index 0fa32f4d5..2a9174ca0 100644 --- a/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/claim_refund.rs +++ b/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/claim_refund.rs @@ -14,6 +14,7 @@ pub struct ClaimRefundAccountConstraints<'info> { pub token_mint: InterfaceAccount<'info, Mint>, #[account( + mut, seeds = [b"event", event.event_id.to_le_bytes().as_ref()], bump = event.bump, )] @@ -56,6 +57,15 @@ pub fn handle_claim_refund(context: Context) -> R BettingError::EventNotCancelled ); + // This Bet account closes when the handler returns, so the event's count + // of open bets drops by one before any tokens move. + context.accounts.event.open_bets = context + .accounts + .event + .open_bets + .checked_sub(1) + .ok_or(BettingError::MathOverflow)?; + let stake = context.accounts.bet.amount; let event_id = context.accounts.event.event_id; diff --git a/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/claim_winnings.rs b/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/claim_winnings.rs index 8be0ddedd..e4bebdee1 100644 --- a/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/claim_winnings.rs +++ b/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/claim_winnings.rs @@ -14,6 +14,7 @@ pub struct ClaimWinningsAccountConstraints<'info> { pub token_mint: InterfaceAccount<'info, Mint>, #[account( + mut, seeds = [b"event", event.event_id.to_le_bytes().as_ref()], bump = event.bump, )] @@ -60,6 +61,15 @@ pub fn handle_claim_winnings(context: Context) BettingError::NothingToClaim ); + // This Bet account closes when the handler returns, so the event's count + // of open bets drops by one before any tokens move. + context.accounts.event.open_bets = context + .accounts + .event + .open_bets + .checked_sub(1) + .ok_or(BettingError::MathOverflow)?; + let stake = context.accounts.bet.amount; // Total staked on the winning outcome, and the losers' stakes after the fee. let winning_pool = context.accounts.event.winning_pool; diff --git a/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/close_event.rs b/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/close_event.rs new file mode 100644 index 000000000..01574d09f --- /dev/null +++ b/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/close_event.rs @@ -0,0 +1,115 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{Mint, TokenAccount, TokenInterface}, +}; + +use crate::{error::BettingError, Config, Event, EventStatus}; + +use super::{close_vault, transfer_tokens_from_vault}; + +// Close a finished event: pay whatever the vault still holds to the fee +// recipient, close the vault, and close the Event account, returning both +// rents to the admin, who paid them. After settlement the vault holds only +// the dust that flooring each winner's share left behind; after a +// cancellation and its refunds it holds nothing. +#[derive(Accounts)] +pub struct CloseEventAccountConstraints<'info> { + #[account(mut)] + pub admin: Signer<'info>, + + #[account( + seeds = [b"config"], + bump = config.bump, + has_one = admin @ BettingError::Unauthorized, + has_one = token_mint, + has_one = fee_recipient, + )] + pub config: Account<'info, Config>, + + #[account(mint::token_program = token_program)] + pub token_mint: InterfaceAccount<'info, Mint>, + + #[account( + mut, + close = admin, + seeds = [b"event", event.event_id.to_le_bytes().as_ref()], + bump = event.bump, + )] + pub event: Account<'info, Event>, + + #[account( + mut, + associated_token::mint = token_mint, + associated_token::authority = event, + associated_token::token_program = token_program, + )] + pub vault: InterfaceAccount<'info, TokenAccount>, + + /// CHECK: validated against config.fee_recipient by the `has_one` above. + pub fee_recipient: UncheckedAccount<'info>, + + #[account( + init_if_needed, + payer = admin, + associated_token::mint = token_mint, + associated_token::authority = fee_recipient, + associated_token::token_program = token_program, + )] + pub fee_recipient_token_account: InterfaceAccount<'info, TokenAccount>, + + pub associated_token_program: Program<'info, AssociatedToken>, + pub token_program: Interface<'info, TokenInterface>, + pub system_program: Program<'info, System>, +} + +pub fn handle_close_event(context: Context) -> Result<()> { + require!( + matches!( + context.accounts.event.status, + EventStatus::Settled | EventStatus::Cancelled + ), + BettingError::EventNotFinished + ); + // Every claim and refund reads the event, so the event outlives every + // Bet account of it. + require!( + context.accounts.event.open_bets == 0, + BettingError::BetsStillOpen + ); + // An Outcome account left behind would be found again, with its old + // `total_amount`, by a later event created with the same `event_id`. + require!( + context.accounts.event.open_outcomes == 0, + BettingError::OutcomesStillOpen + ); + + let event_id = context.accounts.event.event_id; + let event_bump = context.accounts.event.bump; + + // Winners' shares are floored, so a settled vault can hold a few minor + // units nobody has a claim on; they go to the fee recipient with the fee. + let remainder = context.accounts.vault.amount; + if remainder > 0 { + transfer_tokens_from_vault( + &context.accounts.vault, + &context.accounts.fee_recipient_token_account, + remainder, + &context.accounts.token_mint, + &context.accounts.event.to_account_info(), + &context.accounts.token_program, + event_id, + event_bump, + )?; + } + + // The Event account itself is closed by its `close = admin` constraint. + close_vault( + &context.accounts.vault, + &context.accounts.admin.to_account_info(), + &context.accounts.event.to_account_info(), + &context.accounts.token_program, + event_id, + event_bump, + ) +} diff --git a/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/close_losing_bet.rs b/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/close_losing_bet.rs index 31b15712e..8468d7a5d 100644 --- a/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/close_losing_bet.rs +++ b/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/close_losing_bet.rs @@ -11,6 +11,7 @@ pub struct CloseLosingBetAccountConstraints<'info> { pub bettor: Signer<'info>, #[account( + mut, seeds = [b"event", event.event_id.to_le_bytes().as_ref()], bump = event.bump, )] @@ -37,5 +38,14 @@ pub fn handle_close_losing_bet(context: Context { + #[account(mut)] + pub admin: Signer<'info>, + + #[account( + seeds = [b"config"], + bump = config.bump, + has_one = admin @ BettingError::Unauthorized, + )] + pub config: Account<'info, Config>, + + #[account( + mut, + seeds = [b"event", event.event_id.to_le_bytes().as_ref()], + bump = event.bump, + )] + pub event: Account<'info, Event>, + + #[account( + mut, + close = admin, + has_one = event, + seeds = [b"outcome", event.key().as_ref(), &[outcome.index]], + bump = outcome.bump, + )] + pub outcome: Account<'info, Outcome>, +} + +pub fn handle_close_outcome(context: Context) -> Result<()> { + require!( + matches!( + context.accounts.event.status, + EventStatus::Settled | EventStatus::Cancelled + ), + BettingError::EventNotFinished + ); + // A Bet account derives its address from its outcome's, so an outcome + // closed under an open bet would leave that bet's claim or refund with + // no outcome to check against. + require!( + context.accounts.event.open_bets == 0, + BettingError::BetsStillOpen + ); + + let event = &mut context.accounts.event; + event.open_outcomes = event + .open_outcomes + .checked_sub(1) + .ok_or(BettingError::MathOverflow)?; + Ok(()) +} diff --git a/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/initialize_event.rs b/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/initialize_event.rs index 75ded125f..8a23615f9 100644 --- a/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/initialize_event.rs +++ b/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/initialize_event.rs @@ -68,6 +68,8 @@ pub fn handle_initialize_event( event_id, description, outcome_count: 0, + open_outcomes: 0, + open_bets: 0, total_pool: 0, // Starts as a draft: outcomes are added before anyone can bet. status: EventStatus::Draft, diff --git a/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/mod.rs b/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/mod.rs index b86c58955..ae04c7025 100644 --- a/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/mod.rs +++ b/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/mod.rs @@ -2,7 +2,9 @@ pub mod add_outcome; pub mod cancel_event; pub mod claim_refund; pub mod claim_winnings; +pub mod close_event; pub mod close_losing_bet; +pub mod close_outcome; pub mod initialize_config; pub mod initialize_event; pub mod open_betting; @@ -14,7 +16,9 @@ pub use add_outcome::*; pub use cancel_event::*; pub use claim_refund::*; pub use claim_winnings::*; +pub use close_event::*; pub use close_losing_bet::*; +pub use close_outcome::*; pub use initialize_config::*; pub use initialize_event::*; pub use open_betting::*; diff --git a/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/place_bet.rs b/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/place_bet.rs index e809fbfe4..9f8d1cedb 100644 --- a/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/place_bet.rs +++ b/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/place_bet.rs @@ -128,6 +128,12 @@ pub fn handle_place_bet(context: Context, amount: u6 .total_pool .checked_add(amount) .ok_or(BettingError::MathOverflow)?; + if is_new_bet { + event.open_bets = event + .open_bets + .checked_add(1) + .ok_or(BettingError::MathOverflow)?; + } Ok(()) } diff --git a/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/shared.rs b/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/shared.rs index be66d24ad..5a254dcd8 100644 --- a/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/shared.rs +++ b/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/shared.rs @@ -1,7 +1,8 @@ use anchor_lang::prelude::*; use anchor_spl::token_interface::{ - transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked, + close_account, transfer_checked, CloseAccount, Mint, TokenAccount, TokenInterface, + TransferChecked, }; // Move tokens from a wallet-owned account into the vault. The authority is a @@ -50,3 +51,27 @@ pub fn transfer_tokens_from_vault<'info>( CpiContext::new_with_signer(token_program.key(), transfer_accounts, &signer_seeds); transfer_checked(cpi_context, amount, mint.decimals) } + +// Close the (already empty) vault, signed by the Event PDA, sending its rent +// to `destination`. +pub fn close_vault<'info>( + vault: &InterfaceAccount<'info, TokenAccount>, + destination: &AccountInfo<'info>, + event: &AccountInfo<'info>, + token_program: &Interface<'info, TokenInterface>, + event_id: u64, + event_bump: u8, +) -> Result<()> { + let event_id_bytes = event_id.to_le_bytes(); + let seeds = &[b"event".as_ref(), event_id_bytes.as_ref(), &[event_bump]]; + let signer_seeds = [&seeds[..]]; + + let close_accounts = CloseAccount { + account: vault.to_account_info(), + destination: destination.clone(), + authority: event.clone(), + }; + let cpi_context = + CpiContext::new_with_signer(token_program.key(), close_accounts, &signer_seeds); + close_account(cpi_context) +} diff --git a/finance/betting-market/anchor-v1/programs/betting-market/src/lib.rs b/finance/betting-market/anchor-v1/programs/betting-market/src/lib.rs index e01ea31b2..43517d10d 100644 --- a/finance/betting-market/anchor-v1/programs/betting-market/src/lib.rs +++ b/finance/betting-market/anchor-v1/programs/betting-market/src/lib.rs @@ -95,4 +95,17 @@ pub mod betting_market { pub fn claim_refund(context: Context) -> Result<()> { instructions::claim_refund::handle_claim_refund(context) } + + // Admin closes one Outcome account of a settled or cancelled event once + // every Bet account of the event is closed. The rent returns to the admin. + pub fn close_outcome(context: Context) -> Result<()> { + instructions::close_outcome::handle_close_outcome(context) + } + + // Admin closes a settled or cancelled event once its bets and outcomes are + // closed: the vault's remaining dust goes to the fee recipient, and the + // vault and Event account close, returning their rent to the admin. + pub fn close_event(context: Context) -> Result<()> { + instructions::close_event::handle_close_event(context) + } } diff --git a/finance/betting-market/anchor-v1/programs/betting-market/src/state/event.rs b/finance/betting-market/anchor-v1/programs/betting-market/src/state/event.rs index 29bd6010d..5e9cfdc52 100644 --- a/finance/betting-market/anchor-v1/programs/betting-market/src/state/event.rs +++ b/finance/betting-market/anchor-v1/programs/betting-market/src/state/event.rs @@ -23,6 +23,16 @@ pub struct Event { #[max_len(200)] pub description: String, pub outcome_count: u8, + // How many Outcome accounts of this event are still open. `add_outcome` + // adds one; `close_outcome` subtracts one. `close_event` requires zero, + // because an Outcome left behind would carry its stakes into a later + // event created with the same `event_id`. + pub open_outcomes: u8, + // How many Bet accounts across every outcome are still open. `place_bet` + // adds one when it creates a Bet account (a top-up reuses the account); + // `claim_winnings`, `claim_refund` and `close_losing_bet` each subtract + // one when they close one. `close_outcome` and `close_event` require zero. + pub open_bets: u64, // Sum of every stake placed across all outcomes. pub total_pool: u64, pub status: EventStatus, diff --git a/finance/betting-market/anchor-v1/programs/betting-market/src/state/outcome.rs b/finance/betting-market/anchor-v1/programs/betting-market/src/state/outcome.rs index de817e540..ecfbcd9f5 100644 --- a/finance/betting-market/anchor-v1/programs/betting-market/src/state/outcome.rs +++ b/finance/betting-market/anchor-v1/programs/betting-market/src/state/outcome.rs @@ -2,7 +2,9 @@ use anchor_lang::prelude::*; // One possible result of an event (e.g. "Yes", "Team A wins"). `total_amount` // is this outcome's share of the pool and is the denominator for pro-rata -// payouts when this outcome wins. +// payouts when this outcome wins. `bet_count` is the number of Bet accounts +// ever created on this outcome; closing a bet does not lower it (the event's +// `open_bets` is the count that does fall). #[account] #[derive(InitSpace)] pub struct Outcome { diff --git a/finance/betting-market/anchor-v1/programs/betting-market/tests/test_betting_market.rs b/finance/betting-market/anchor-v1/programs/betting-market/tests/test_betting_market.rs index 822bb83fc..75ce2f6aa 100644 --- a/finance/betting-market/anchor-v1/programs/betting-market/tests/test_betting_market.rs +++ b/finance/betting-market/anchor-v1/programs/betting-market/tests/test_betting_market.rs @@ -1,14 +1,16 @@ use { anchor_lang::{ prelude::Clock, - solana_program::{instruction::Instruction, pubkey::Pubkey, system_program}, + solana_program::{ + instruction::Instruction, pubkey::Pubkey, system_instruction, system_program, + }, AccountDeserialize, InstructionData, ToAccountMetas, }, - betting_market::{error::BettingError, Bet}, + betting_market::{error::BettingError, Bet, Event}, litesvm::LiteSVM, solana_keypair::Keypair, solana_kite::{ - create_associated_token_account, create_token_mint, create_wallet, + create_associated_token_account, create_token_mint, create_wallet, get_sol_balance, get_token_account_balance, mint_tokens_to_token_account, send_transaction_from_instructions, SolanaKiteError, }, @@ -367,15 +369,150 @@ fn close_losing_bet_ix(bettor: &Pubkey, event_id: u64, outcome_index: u8) -> Ins ) } -// A Bet account lives only while its position is open: claiming, refunding or -// closing it as a loser closes the account. -fn bet_is_open(market: &Market, bet: &Pubkey) -> bool { +fn close_outcome_ix(admin: Pubkey, event_id: u64, outcome_index: u8) -> Instruction { + let event = event_pda(event_id); + Instruction::new_with_bytes( + betting_market::id(), + &betting_market::instruction::CloseOutcome {}.data(), + betting_market::accounts::CloseOutcomeAccountConstraints { + admin, + config: config_pda(), + event, + outcome: outcome_pda(&event, outcome_index), + } + .to_account_metas(None), + ) +} + +fn close_event_ix( + admin: Pubkey, + mint: Pubkey, + fee_recipient: Pubkey, + fee_recipient_ata: Pubkey, + event_id: u64, +) -> Instruction { + let event = event_pda(event_id); + Instruction::new_with_bytes( + betting_market::id(), + &betting_market::instruction::CloseEvent {}.data(), + betting_market::accounts::CloseEventAccountConstraints { + admin, + config: config_pda(), + token_mint: mint, + event, + vault: derive_ata(&event, &mint), + fee_recipient, + fee_recipient_token_account: fee_recipient_ata, + associated_token_program: ata_program_id(), + token_program: token_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ) +} + +// A closed account has no lamports and no data. A Bet account lives only +// while its position is open: claiming, refunding or closing it as a loser +// closes the account. Outcome accounts, the vault and the Event account close +// through `close_outcome` and `close_event` once the event is finished. +fn account_is_open(market: &Market, address: &Pubkey) -> bool { market .svm - .get_account(bet) + .get_account(address) .is_some_and(|account| account.lamports > 0 && !account.data.is_empty()) } +// The lamports an open account holds, which closing it returns to whoever +// paid its rent. +fn rent_of(market: &Market, address: &Pubkey) -> u64 { + market.svm.get_account(address).unwrap().lamports +} + +// What one transaction with a single signer costs its fee payer, measured by +// sending the admin a zero-lamport transfer to themselves: nothing else in +// that transaction moves lamports. A fresh blockhash first, so repeated +// measurements are distinct transactions. +fn transaction_fee(market: &mut Market) -> u64 { + let admin = market.admin.pubkey(); + market.svm.expire_blockhash(); + let before = get_sol_balance(&market.svm, &admin); + send_transaction_from_instructions( + &mut market.svm, + vec![system_instruction::transfer(&admin, &admin, 0)], + &[&market.admin], + &admin, + ) + .unwrap(); + before - get_sol_balance(&market.svm, &admin) +} + +fn read_event(market: &Market, event_id: u64) -> Event { + let account = market.svm.get_account(&event_pda(event_id)).unwrap(); + Event::try_deserialize(&mut account.data.as_slice()).unwrap() +} + +// Closes a finished event's Outcome accounts and then the event itself, as +// the admin, asserting that each rent comes back to the admin and that every +// closed account is gone. Returns what the vault held before it closed. +fn close_outcomes_and_event(market: &mut Market, event_id: u64, outcome_count: u8) -> u64 { + let admin = market.admin.pubkey(); + let mint = market.mint; + let fee_recipient = market.fee_recipient.pubkey(); + let fee_recipient_ata = market.fee_recipient_ata; + let event = event_pda(event_id); + let vault = derive_ata(&event, &mint); + let fee = transaction_fee(market); + + for index in 0..outcome_count { + let outcome = outcome_pda(&event, index); + let outcome_rent = rent_of(market, &outcome); + let admin_before = get_sol_balance(&market.svm, &admin); + send_transaction_from_instructions( + &mut market.svm, + vec![close_outcome_ix(admin, event_id, index)], + &[&market.admin], + &admin, + ) + .unwrap(); + assert!( + !account_is_open(market, &outcome), + "outcome {index} must be closed" + ); + assert_eq!( + get_sol_balance(&market.svm, &admin), + admin_before + outcome_rent - fee, + "outcome {index}'s rent must return to the admin" + ); + } + assert_eq!(read_event(market, event_id).open_outcomes, 0); + + let vault_balance = get_token_account_balance(&market.svm, &vault).unwrap(); + let event_rent = rent_of(market, &event); + let vault_rent = rent_of(market, &vault); + let admin_before = get_sol_balance(&market.svm, &admin); + send_transaction_from_instructions( + &mut market.svm, + vec![close_event_ix( + admin, + mint, + fee_recipient, + fee_recipient_ata, + event_id, + )], + &[&market.admin], + &admin, + ) + .unwrap(); + assert!(!account_is_open(market, &event)); + assert!(!account_is_open(market, &vault)); + assert_eq!( + get_sol_balance(&market.svm, &admin), + admin_before + event_rent + vault_rent - fee, + "the event's and the vault's rent must return to the admin" + ); + vault_balance +} + // A bettor's open positions are the Bet accounts whose `bettor` field is their // address. An RPC client finds them with getProgramAccounts and a memcmp filter // at this offset, so the program keeps no per-wallet index (and no cap on @@ -549,11 +686,11 @@ fn test_full_lifecycle() { // Claiming closed the winners' Bet accounts. let winning_outcome = outcome_pda(&event_pda(event_id), 0); - assert!(!bet_is_open( + assert!(!account_is_open( &market, &bet_pda(&winning_outcome, &alice.pubkey()) )); - assert!(!bet_is_open( + assert!(!account_is_open( &market, &bet_pda(&winning_outcome, &bob.pubkey()) )); @@ -571,14 +708,11 @@ fn test_full_lifecycle() { &[&carol], &carol.pubkey(), ); - assert!( - carol_claim.is_err(), - "loser must not be able to claim winnings" - ); + assert_fails_with(carol_claim, BettingError::NothingToClaim); // Her losing position stays open until she closes it. let carol_bet = bet_pda(&outcome_pda(&event_pda(event_id), 1), &carol.pubkey()); - assert!(bet_is_open(&market, &carol_bet)); + assert!(account_is_open(&market, &carol_bet)); send_transaction_from_instructions( &mut market.svm, vec![close_losing_bet_ix(&carol.pubkey(), event_id, 1)], @@ -586,7 +720,7 @@ fn test_full_lifecycle() { &carol.pubkey(), ) .unwrap(); - assert!(!bet_is_open(&market, &carol_bet)); + assert!(!account_is_open(&market, &carol_bet)); } #[test] @@ -594,20 +728,49 @@ fn test_only_admin_can_initialize_event() { let mut market = setup(); init_config(&mut market); + let admin = market.admin.pubkey(); let mint = market.mint; + let event_id: u64 = 7; let mallory = create_wallet(&mut market.svm, 10_000_000_000).unwrap(); let result = send_transaction_from_instructions( &mut market.svm, vec![initialize_event_ix( mallory.pubkey(), mint, - 7, + event_id, "Unauthorized event", )], &[&mallory], &mallory.pubkey(), ); - assert!(result.is_err(), "non-admin must not create an event"); + assert_fails_with(result, BettingError::Unauthorized); + + // Nor can anyone but the admin add an outcome to a draft, or open it. + send_transaction_from_instructions( + &mut market.svm, + vec![ + initialize_event_ix(admin, mint, event_id, "Admin's event"), + add_outcome_ix(admin, event_id, 0, "Glowbugs 3"), + add_outcome_ix(admin, event_id, 1, "The Quiet Floor"), + ], + &[&market.admin], + &admin, + ) + .unwrap(); + let unauthorized_outcome = send_transaction_from_instructions( + &mut market.svm, + vec![add_outcome_ix(mallory.pubkey(), event_id, 2, "Late entry")], + &[&mallory], + &mallory.pubkey(), + ); + assert_fails_with(unauthorized_outcome, BettingError::Unauthorized); + let unauthorized_open = send_transaction_from_instructions( + &mut market.svm, + vec![open_betting_ix(mallory.pubkey(), event_id)], + &[&mallory], + &mallory.pubkey(), + ); + assert_fails_with(unauthorized_open, BettingError::Unauthorized); } #[test] @@ -897,7 +1060,7 @@ fn test_cancel_and_refund() { .unwrap(); let alice_bet = bet_pda(&outcome_pda(&event_pda(event_id), 0), &alice.pubkey()); - assert!(bet_is_open(&market, &alice_bet)); + assert!(account_is_open(&market, &alice_bet)); send_transaction_from_instructions( &mut market.svm, @@ -914,7 +1077,17 @@ fn test_cancel_and_refund() { .unwrap(); // The refund closed Alice's Bet account. - assert!(!bet_is_open(&market, &alice_bet)); + assert!(!account_is_open(&market, &alice_bet)); + + // Carol's refund is still outstanding, so the event's accounts stay. + let outcome_still_in_use = send_transaction_from_instructions( + &mut market.svm, + vec![close_outcome_ix(admin, event_id, 0)], + &[&market.admin], + &admin, + ); + assert_fails_with(outcome_still_in_use, BettingError::BetsStillOpen); + assert_eq!(read_event(&market, event_id).open_bets, 1); send_transaction_from_instructions( &mut market.svm, @@ -941,6 +1114,16 @@ fn test_cancel_and_refund() { ); let vault = derive_ata(&event_pda(event_id), &mint); assert_eq!(get_token_account_balance(&market.svm, &vault).unwrap(), 0); + + // With every stake refunded the admin closes the outcomes and the event. + // The vault was empty, so the fee recipient receives nothing. + assert_eq!(read_event(&market, event_id).open_bets, 0); + let vault_balance_at_close = close_outcomes_and_event(&mut market, event_id, 2); + assert_eq!(vault_balance_at_close, 0); + assert_eq!( + get_token_account_balance(&market.svm, &market.fee_recipient_ata).unwrap(), + 0 + ); } #[test] @@ -1003,10 +1186,7 @@ fn test_close_losing_bet_only_after_settle_and_only_for_losers() { &[&carol], &carol.pubkey(), ); - assert!( - premature_close.is_err(), - "closing before settlement must fail" - ); + assert_fails_with(premature_close, BettingError::EventNotSettled); // Betting has closed, so the event can be settled. warp_to(&mut market.svm, BETTING_CLOSES_AT); @@ -1032,12 +1212,9 @@ fn test_close_losing_bet_only_after_settle_and_only_for_losers() { &[&alice], &alice.pubkey(), ); - assert!( - winner_close.is_err(), - "a winning bet must not be closed as losing" - ); + assert_fails_with(winner_close, BettingError::BetWon); let alice_bet = bet_pda(&outcome_pda(&event_pda(event_id), 0), &alice.pubkey()); - assert!(bet_is_open(&market, &alice_bet)); + assert!(account_is_open(&market, &alice_bet)); // Carol lost; closing returns her Bet account's rent. A fresh blockhash so this is // a distinct transaction from her premature attempt above. @@ -1050,7 +1227,7 @@ fn test_close_losing_bet_only_after_settle_and_only_for_losers() { ) .unwrap(); let carol_bet = bet_pda(&outcome_pda(&event_pda(event_id), 1), &carol.pubkey()); - assert!(!bet_is_open(&market, &carol_bet)); + assert!(!account_is_open(&market, &carol_bet)); } // A wallet can hold as many open positions as it likes: forty bets across @@ -1151,13 +1328,13 @@ fn test_no_cap_on_open_bets_per_wallet() { &outcome_pda(&event_pda(wide_event_id), index), &alice.pubkey(), ); - assert!(bet_is_open(&market, &bet), "bet {index} must be open"); + assert!(account_is_open(&market, &bet), "bet {index} must be open"); } let other_market_bet = bet_pda( &outcome_pda(&event_pda(second_event_id), 0), &alice.pubkey(), ); - assert!(bet_is_open(&market, &other_market_bet)); + assert!(account_is_open(&market, &other_market_bet)); assert_eq!( get_token_account_balance(&market.svm, &alice_ata).unwrap(), 0 @@ -1394,3 +1571,385 @@ fn test_close_time_must_be_in_the_future() { ); assert_fails_with(result, BettingError::CloseTimeInPast); } + +// Sets up a two-outcome market ("Yes" at index 0, "No" at index 1) on +// `event_id` and opens it to bets. +fn open_yes_no_market(market: &mut Market, event_id: u64, description: &str) { + let admin = market.admin.pubkey(); + let mint = market.mint; + send_transaction_from_instructions( + &mut market.svm, + vec![ + initialize_event_ix(admin, mint, event_id, description), + add_outcome_ix(admin, event_id, 0, "Yes"), + add_outcome_ix(admin, event_id, 1, "No"), + open_betting_ix(admin, event_id), + ], + &[&market.admin], + &admin, + ) + .unwrap(); +} + +fn place_bet( + market: &mut Market, + bettor: &Keypair, + bettor_ata: &Pubkey, + event_id: u64, + outcome_index: u8, + amount: u64, +) { + let mint = market.mint; + send_transaction_from_instructions( + &mut market.svm, + vec![place_bet_ix( + mint, + &bettor.pubkey(), + bettor_ata, + event_id, + outcome_index, + amount, + )], + &[bettor], + &bettor.pubkey(), + ) + .unwrap(); +} + +// Settles `event_id` to `winning_outcome_index` once betting has closed. +fn settle(market: &mut Market, event_id: u64, winning_outcome_index: u8) { + let admin = market.admin.pubkey(); + let mint = market.mint; + let fee_recipient = market.fee_recipient.pubkey(); + let fee_recipient_ata = market.fee_recipient_ata; + warp_to(&mut market.svm, BETTING_CLOSES_AT); + send_transaction_from_instructions( + &mut market.svm, + vec![settle_event_ix( + admin, + mint, + fee_recipient, + fee_recipient_ata, + event_id, + winning_outcome_index, + )], + &[&market.admin], + &admin, + ) + .unwrap(); +} + +// The whole lifecycle through to every account closing. Stakes are chosen so +// the pro-rata split does not divide evenly: Yes pool 300 (Alice 100 in two +// bets, Bob 200), No pool 250 (Carol). Yes wins: losing pool 250, fee 5, +// distributable 245; Alice gets floor(100 * 245 / 300) = 81 and Bob +// floor(200 * 245 / 300) = 163, so one minor unit of dust stays in the vault +// until `close_event` pays it to the fee recipient. +#[test] +fn test_close_event_pays_dust_to_fee_recipient_and_returns_rent() { + let mut market = setup(); + let event_id: u64 = 13; + let (alice, alice_ata) = create_bettor(&mut market, 1_000); + let (bob, bob_ata) = create_bettor(&mut market, 1_000); + let (carol, carol_ata) = create_bettor(&mut market, 1_000); + + init_config(&mut market); + let admin = market.admin.pubkey(); + let mint = market.mint; + let fee_recipient = market.fee_recipient.pubkey(); + let fee_recipient_ata = market.fee_recipient_ata; + open_yes_no_market(&mut market, event_id, "Top-grossing film"); + + // Alice's second bet tops up her existing Bet account rather than + // creating another, so it counts once among the open bets. + place_bet(&mut market, &alice, &alice_ata, event_id, 0, 60); + place_bet(&mut market, &alice, &alice_ata, event_id, 0, 40); + place_bet(&mut market, &bob, &bob_ata, event_id, 0, 200); + place_bet(&mut market, &carol, &carol_ata, event_id, 1, 250); + let event = read_event(&market, event_id); + assert_eq!(event.open_bets, 3); + assert_eq!(event.open_outcomes, 2); + assert_eq!(event.total_pool, 550); + + settle(&mut market, event_id, 0); + assert_eq!( + get_token_account_balance(&market.svm, &fee_recipient_ata).unwrap(), + 5 + ); + + // Carol closes her losing bet; Alice and Bob claim. + send_transaction_from_instructions( + &mut market.svm, + vec![close_losing_bet_ix(&carol.pubkey(), event_id, 1)], + &[&carol], + &carol.pubkey(), + ) + .unwrap(); + assert_eq!(read_event(&market, event_id).open_bets, 2); + for (winner, winner_ata) in [(&alice, &alice_ata), (&bob, &bob_ata)] { + send_transaction_from_instructions( + &mut market.svm, + vec![claim_winnings_ix( + mint, + &winner.pubkey(), + winner_ata, + event_id, + 0, + )], + &[winner], + &winner.pubkey(), + ) + .unwrap(); + } + assert_eq!( + get_token_account_balance(&market.svm, &alice_ata).unwrap(), + 1_000 - 100 + 181 + ); + assert_eq!( + get_token_account_balance(&market.svm, &bob_ata).unwrap(), + 1_000 - 200 + 363 + ); + assert_eq!(read_event(&market, event_id).open_bets, 0); + + // The two floors left one minor unit in the vault. + let vault = derive_ata(&event_pda(event_id), &mint); + assert_eq!(get_token_account_balance(&market.svm, &vault).unwrap(), 1); + + // Outcome accounts close before the event does. + let outcomes_still_open = send_transaction_from_instructions( + &mut market.svm, + vec![close_event_ix( + admin, + mint, + fee_recipient, + fee_recipient_ata, + event_id, + )], + &[&market.admin], + &admin, + ); + assert_fails_with(outcomes_still_open, BettingError::OutcomesStillOpen); + + let vault_balance_at_close = close_outcomes_and_event(&mut market, event_id, 2); + assert_eq!(vault_balance_at_close, 1); + // The dust joined the fee: 5 + 1. + assert_eq!( + get_token_account_balance(&market.svm, &fee_recipient_ata).unwrap(), + 6 + ); +} + +// A settled event keeps its accounts while any Bet account of it is open: +// the claim and the losing-bet close both read the event and the outcome. +#[test] +fn test_close_event_refused_while_a_bet_is_open() { + let mut market = setup(); + let event_id: u64 = 14; + let (alice, alice_ata) = create_bettor(&mut market, 1_000); + let (carol, carol_ata) = create_bettor(&mut market, 1_000); + + init_config(&mut market); + let admin = market.admin.pubkey(); + let mint = market.mint; + let fee_recipient = market.fee_recipient.pubkey(); + let fee_recipient_ata = market.fee_recipient_ata; + open_yes_no_market(&mut market, event_id, "Match winner"); + place_bet(&mut market, &alice, &alice_ata, event_id, 0, 100); + place_bet(&mut market, &carol, &carol_ata, event_id, 1, 300); + settle(&mut market, event_id, 0); + + // Alice has claimed; Carol's losing bet is still open. + send_transaction_from_instructions( + &mut market.svm, + vec![claim_winnings_ix( + mint, + &alice.pubkey(), + &alice_ata, + event_id, + 0, + )], + &[&alice], + &alice.pubkey(), + ) + .unwrap(); + assert_eq!(read_event(&market, event_id).open_bets, 1); + + let event_close = send_transaction_from_instructions( + &mut market.svm, + vec![close_event_ix( + admin, + mint, + fee_recipient, + fee_recipient_ata, + event_id, + )], + &[&market.admin], + &admin, + ); + assert_fails_with(event_close, BettingError::BetsStillOpen); + let outcome_close = send_transaction_from_instructions( + &mut market.svm, + vec![close_outcome_ix(admin, event_id, 1)], + &[&market.admin], + &admin, + ); + assert_fails_with(outcome_close, BettingError::BetsStillOpen); + + // Once Carol closes her bet, everything else can close. + send_transaction_from_instructions( + &mut market.svm, + vec![close_losing_bet_ix(&carol.pubkey(), event_id, 1)], + &[&carol], + &carol.pubkey(), + ) + .unwrap(); + close_outcomes_and_event(&mut market, event_id, 2); +} + +// A draft or open event has not finished, so its accounts cannot close, +// even when nobody has bet. Cancelling it is what lets it close. +#[test] +fn test_close_event_refused_while_event_is_open() { + let mut market = setup(); + let event_id: u64 = 15; + + init_config(&mut market); + let admin = market.admin.pubkey(); + let mint = market.mint; + let fee_recipient = market.fee_recipient.pubkey(); + let fee_recipient_ata = market.fee_recipient_ata; + send_transaction_from_instructions( + &mut market.svm, + vec![ + initialize_event_ix(admin, mint, event_id, "Never bet on"), + add_outcome_ix(admin, event_id, 0, "Yes"), + add_outcome_ix(admin, event_id, 1, "No"), + ], + &[&market.admin], + &admin, + ) + .unwrap(); + + // As a draft... + let draft_close = send_transaction_from_instructions( + &mut market.svm, + vec![close_event_ix( + admin, + mint, + fee_recipient, + fee_recipient_ata, + event_id, + )], + &[&market.admin], + &admin, + ); + assert_fails_with(draft_close, BettingError::EventNotFinished); + let draft_outcome_close = send_transaction_from_instructions( + &mut market.svm, + vec![close_outcome_ix(admin, event_id, 0)], + &[&market.admin], + &admin, + ); + assert_fails_with(draft_outcome_close, BettingError::EventNotFinished); + + // ...and once open. A fresh blockhash so this is a distinct transaction + // from the draft attempt above. + send_transaction_from_instructions( + &mut market.svm, + vec![open_betting_ix(admin, event_id)], + &[&market.admin], + &admin, + ) + .unwrap(); + market.svm.expire_blockhash(); + let open_close = send_transaction_from_instructions( + &mut market.svm, + vec![close_event_ix( + admin, + mint, + fee_recipient, + fee_recipient_ata, + event_id, + )], + &[&market.admin], + &admin, + ); + assert_fails_with(open_close, BettingError::EventNotFinished); + + // Cancelled with no bets, it closes straight away. + send_transaction_from_instructions( + &mut market.svm, + vec![cancel_event_ix(admin, event_id)], + &[&market.admin], + &admin, + ) + .unwrap(); + let vault_balance_at_close = close_outcomes_and_event(&mut market, event_id, 2); + assert_eq!(vault_balance_at_close, 0); +} + +#[test] +fn test_only_admin_can_close_outcomes_and_event() { + let mut market = setup(); + let event_id: u64 = 16; + let (alice, alice_ata) = create_bettor(&mut market, 1_000); + let (carol, carol_ata) = create_bettor(&mut market, 1_000); + + init_config(&mut market); + let mint = market.mint; + let fee_recipient = market.fee_recipient.pubkey(); + let fee_recipient_ata = market.fee_recipient_ata; + open_yes_no_market(&mut market, event_id, "Derby winner"); + place_bet(&mut market, &alice, &alice_ata, event_id, 0, 100); + place_bet(&mut market, &carol, &carol_ata, event_id, 1, 300); + settle(&mut market, event_id, 0); + send_transaction_from_instructions( + &mut market.svm, + vec![claim_winnings_ix( + mint, + &alice.pubkey(), + &alice_ata, + event_id, + 0, + )], + &[&alice], + &alice.pubkey(), + ) + .unwrap(); + send_transaction_from_instructions( + &mut market.svm, + vec![close_losing_bet_ix(&carol.pubkey(), event_id, 1)], + &[&carol], + &carol.pubkey(), + ) + .unwrap(); + + // Every bet is closed, so only the signer stands between Mallory and the + // rent. + let mallory = create_wallet(&mut market.svm, 10_000_000_000).unwrap(); + let outcome_close = send_transaction_from_instructions( + &mut market.svm, + vec![close_outcome_ix(mallory.pubkey(), event_id, 0)], + &[&mallory], + &mallory.pubkey(), + ); + assert_fails_with(outcome_close, BettingError::Unauthorized); + let event_close = send_transaction_from_instructions( + &mut market.svm, + vec![close_event_ix( + mallory.pubkey(), + mint, + fee_recipient, + fee_recipient_ata, + event_id, + )], + &[&mallory], + &mallory.pubkey(), + ); + assert_fails_with(event_close, BettingError::Unauthorized); + let event = event_pda(event_id); + assert!(account_is_open(&market, &event)); + assert!(account_is_open(&market, &outcome_pda(&event, 0))); + + close_outcomes_and_event(&mut market, event_id, 2); +} diff --git a/finance/betting-market/anchor/CHANGELOG.md b/finance/betting-market/anchor/CHANGELOG.md index 01d4c3d52..b7338eedc 100644 --- a/finance/betting-market/anchor/CHANGELOG.md +++ b/finance/betting-market/anchor/CHANGELOG.md @@ -1,5 +1,38 @@ # Changelog +## [Unreleased] - 2026-10-04 + +### Added + +- Two admin handlers close what an event created, once it is `Settled` or + `Cancelled` and every Bet account is closed. `close_outcome` closes one + Outcome account and returns its rent to the admin. `close_event`, once every + Outcome account is closed, pays whatever the vault still holds (the rounding + dust a settlement's floored payouts leave; nothing after a cancellation) to + the fee recipient's token account, closes the vault, and closes the Event + account, returning both rents to the admin. Errors `EventNotFinished`, + `BetsStillOpen` and `OutcomesStillOpen`, appended after `BettingStillOpen`. +- `Event.open_bets` counts the Bet accounts still open across every outcome: + `place_bet` adds one when it creates a Bet account (a top-up reuses the + account), and `claim_winnings`, `claim_refund` and `close_losing_bet` each + subtract one. `Event.open_outcomes` counts the Outcome accounts still open: + `add_outcome` adds one, `close_outcome` subtracts one. Both fields sit after + `outcome_count`, so the Event account layout changes. `claim_winnings`, + `claim_refund` and `close_losing_bet` now take the event as writable. +- Tests: `test_close_event_pays_dust_to_fee_recipient_and_returns_rent`, + `test_close_event_refused_while_a_bet_is_open`, + `test_close_event_refused_while_event_is_open` and + `test_only_admin_can_close_outcomes_and_event`; `test_cancel_and_refund` + closes the cancelled event after its refunds. + +### Changed + +- `test_only_admin_can_initialize_event` asserts the `Unauthorized` code and + also sends `add_outcome` and `open_betting` from a non-admin; + `test_close_losing_bet_only_after_settle_and_only_for_losers` asserts + `EventNotSettled` and `BetWon` by code, and `test_full_lifecycle` asserts + `NothingToClaim` for the loser's claim. + ## [2026-10-03] ### Removed diff --git a/finance/betting-market/anchor/README.md b/finance/betting-market/anchor/README.md index 770ee4469..ae03d4dfe 100644 --- a/finance/betting-market/anchor/README.md +++ b/finance/betting-market/anchor/README.md @@ -22,24 +22,33 @@ them in one place no single bettor controls, and paying winners by a fixed, publ still requires trusting the admin, who chooses the winning outcome, as described below. The pool is a token account owned by the event's PDA, so payouts are signed by the program with the event's seeds - there is no admin key that can move bettors' stakes out of the pool. The admin's only -powers are creating events/outcomes, opening them to bets, and choosing the winning outcome (or -cancelling). +powers are creating events/outcomes, opening them to bets, choosing the winning outcome (or +cancelling), and closing a finished event's accounts once every bet has been paid out or closed. ## Major Concepts ### Accounts - **Config** (`seeds = [b"config"]`) - one per deployment. Holds the `admin` (the only key that can - create events/outcomes, open betting, settle, and cancel), the `token_mint` every market accepts, - the `fee_recipient`, and the `default_fee_bps` each new event copies at creation. + create events/outcomes, open betting, settle, cancel, and close), the `token_mint` every market + accepts, the `fee_recipient`, and the `default_fee_bps` each new event copies at creation. The + config is the admin's long-lived state and stays open for the life of the deployment; no handler + closes it. - **Event** (`seeds = [b"event", event_id]`) - one betting market. Tracks `total_pool`, `status` (`Draft` / `Open` / `Settled` / `Cancelled`), `betting_closes_at`, and - once settled - the `winning_outcome_index`, `winning_pool`, and `distributable_losing_pool` that the payout formula reads. The event's `fee_bps` is copied from the config's `default_fee_bps` at creation and is what settlement charges. It and `betting_closes_at` are fixed at creation, so later Config changes - can't alter a market bettors have already joined. + can't alter a market bettors have already joined. Two counters say when the event can close: + `open_bets`, the number of Bet accounts across every outcome that are still open (`place_bet` + adds one when it creates a Bet account; a top-up reuses the account and adds nothing; + `claim_winnings`, `claim_refund` and `close_losing_bet` each subtract one), and `open_outcomes`, + the number of Outcome accounts still open (`add_outcome` adds one, `close_outcome` subtracts + one). `close_event` closes the Event account and the vault, returning their rent to the admin. - **Outcome** (`seeds = [b"outcome", event, index]`) - one possible result. Its `total_amount` is - the outcome's share of the pool and the denominator for pro-rata payouts when it wins. + the outcome's share of the pool and the denominator for pro-rata payouts when it wins; its + `bet_count` is the number of Bet accounts ever created on it. `close_outcome` closes it once the + event is finished and every bet is closed, returning its rent to the admin. - **Bet** (`seeds = [b"bet", outcome, bettor]`) - a bettor's total stake on one outcome. Re-betting the same outcome adds to the existing Bet, so there is exactly one per (outcome, bettor). The account exists only while the position is open: it closes (rent back to the bettor) via @@ -52,8 +61,9 @@ cancelling). Each event owns a single vault token account - the associated token account of the Event PDA for `config.token_mint`. `place_bet` moves the stake from the bettor's token account into this vault. -`settle_event`, `claim_winnings`, and `claim_refund` move tokens back out, with the program signing -as the Event PDA (`seeds = [b"event", event_id, bump]`). +`settle_event`, `claim_winnings`, `claim_refund` and `close_event` move tokens back out, with the +program signing as the Event PDA (`seeds = [b"event", event_id, bump]`), and `close_event` then +closes the vault with the same signature, returning its rent to the admin. ### Payout formula @@ -72,7 +82,8 @@ payout = stake + stake * distributable_losing / winning_pool ``` A winner always gets their own stake back; the fee is only ever taken from losing stakes. Integer -division floors each share, leaving at most a few minor units of dust in the vault. +division floors each share, leaving at most a few minor units of dust in the vault, which +`close_event` pays to the fee recipient once every bet is closed. **Example:** Outcome A pool 100, Outcome B pool 50, `fee_bps = 200` (2%). A wins. `losing_pool = 50`, `fee = 1`, `distributable_losing = 49`. A bettor who staked 40 claims @@ -96,6 +107,12 @@ division floors each share, leaving at most a few minor units of dust in the vau - `close_losing_bet` - losing bettor. After settlement, closes a worthless Bet to reclaim its rent. - `cancel_event` - admin. Voids a draft or unresolved market. - `claim_refund` - bettor. After a cancellation, reclaims the exact stake; the Bet account closes. +- `close_outcome` - admin. Once the event is `Settled` or `Cancelled` (else `EventNotFinished`) and + `open_bets` is zero (else `BetsStillOpen`), closes one Outcome account and returns its rent to + the admin. +- `close_event` - admin. Under the same two conditions, and once every Outcome account is closed + (else `OutcomesStillOpen`), pays whatever the vault still holds to the fee recipient's token + account, closes the vault, and closes the Event account, returning both rents to the admin. ### Lifecycle @@ -118,6 +135,20 @@ known, and the admin cannot end a market before the window bettors were promised `settle_event` rejects a winning outcome with no bets - use `cancel_event` to unwind an event that can't be resolved fairly. +`Settled` and `Cancelled` are the two ways an event ends, and in both every Bet account has a +handler that pays it out or closes it: `claim_winnings` and `close_losing_bet` after a settlement, +`claim_refund` after a cancellation. Once `open_bets` reaches zero the admin closes the accounts the +event created, children first: `close_outcome` for each Outcome account, then `close_event` for the +vault and the Event account. The order matters because each account's address is derived from its +parent's. An Outcome account left open after its event closed would be found again, with its old +`total_amount`, by a later event created with the same `event_id`, and a Bet account left open +after its outcome closed would have nothing to claim against. So `close_outcome` refuses while any +bet is open, and `close_event` refuses while any outcome is. After a settlement the vault holds +only the dust the floored payouts left behind, and `close_event` pays it to the fee recipient with +the fee; after a cancellation and its refunds the vault is empty. Every closed account's rent goes +back to whoever paid it: the bettor for a Bet account, the admin for the outcomes, the vault and +the event. + ## Setup Install the [Solana CLI](https://docs.anza.xyz/cli/install) (provides `cargo-build-sbf`) and @@ -139,6 +170,13 @@ edges of the betting close time, settling an outcome with no bets, the cancel/re `close_losing_bet` guards, and a wallet holding forty open bets at once, which shows there is no per-wallet cap. +`test_close_event_pays_dust_to_fee_recipient_and_returns_rent` runs a settlement whose payouts do +not divide evenly, closes the losing bet, both winners' bets, both outcomes and the event, and +checks that the vault's one minor unit of dust reaches the fee recipient, that every closed account +is gone, and that each rent returns to the admin. `test_close_event_refused_while_a_bet_is_open`, +`test_close_event_refused_while_event_is_open` and `test_only_admin_can_close_outcomes_and_event` +check the three refusals, and `test_cancel_and_refund` closes a cancelled event after its refunds. + ```sh anchor test ``` @@ -158,3 +196,7 @@ By the crowd, not a bookmaker: each winner's payout scales with their share of t ### What happens if an event is cancelled? The admin calls `cancel_event` and every bettor reclaims their full stake with `claim_refund`. After a settled event, losers reclaim their bet account's rent with `close_losing_bet`. + +### What happens to the accounts once an event is over? + +Once every bet is paid out or closed, the admin calls `close_outcome` for each outcome and then `close_event`, which pays any rounding dust in the vault to the fee recipient and closes the vault and the Event account. Each rent returns to the admin, who paid it. The Config account stays open; it is the admin's state for the whole deployment. diff --git a/finance/betting-market/anchor/programs/betting-market/src/error.rs b/finance/betting-market/anchor/programs/betting-market/src/error.rs index b81f57b08..92629c963 100644 --- a/finance/betting-market/anchor/programs/betting-market/src/error.rs +++ b/finance/betting-market/anchor/programs/betting-market/src/error.rs @@ -38,4 +38,10 @@ pub enum BettingError { BettingClosed, #[msg("Betting on this event is still open, so it cannot be settled yet")] BettingStillOpen, + #[msg("The event has not been settled or cancelled, so its accounts cannot be closed yet")] + EventNotFinished, + #[msg("Bet accounts are still open, so closing now would strand their claims")] + BetsStillOpen, + #[msg("Outcome accounts are still open, so the event cannot be closed yet")] + OutcomesStillOpen, } diff --git a/finance/betting-market/anchor/programs/betting-market/src/instructions/add_outcome.rs b/finance/betting-market/anchor/programs/betting-market/src/instructions/add_outcome.rs index 6d8eb000d..ed277f38f 100644 --- a/finance/betting-market/anchor/programs/betting-market/src/instructions/add_outcome.rs +++ b/finance/betting-market/anchor/programs/betting-market/src/instructions/add_outcome.rs @@ -56,6 +56,11 @@ pub fn handle_add_outcome( bump: context.bumps.outcome, }; - context.accounts.event.outcome_count += 1; + let event = &mut context.accounts.event; + event.outcome_count += 1; + event.open_outcomes = event + .open_outcomes + .checked_add(1) + .ok_or(BettingError::MathOverflow)?; Ok(()) } diff --git a/finance/betting-market/anchor/programs/betting-market/src/instructions/claim_refund.rs b/finance/betting-market/anchor/programs/betting-market/src/instructions/claim_refund.rs index edb0f6ba3..1f97097eb 100644 --- a/finance/betting-market/anchor/programs/betting-market/src/instructions/claim_refund.rs +++ b/finance/betting-market/anchor/programs/betting-market/src/instructions/claim_refund.rs @@ -16,9 +16,10 @@ pub struct ClaimRefundAccountConstraints { #[account(mint::token_program = token_program)] pub token_mint: InterfaceAccount, - // `mut` so the borrow released for the vault CPI below can be reacquired: - // v2 has no read-only reacquire, and the derive dereferences `event` again - // when it checks the constraints that name it. + // `mut` because the handler lowers `open_bets`, and because the borrow + // released for the vault CPI below has to be reacquired: v2 has no + // read-only reacquire, and the derive dereferences `event` again when it + // checks the constraints that name it. #[account( mut, seeds = [b"event", event.event_id.to_le_bytes()], @@ -62,6 +63,15 @@ pub fn handle_claim_refund(context: &mut Context) BettingError::EventNotCancelled ); + // This Bet account closes when the handler returns, so the event's count + // of open bets drops by one before any tokens move. + context.accounts.event.open_bets = context + .accounts + .event + .open_bets + .checked_sub(1) + .ok_or(BettingError::MathOverflow)?; + let stake = context.accounts.bet.amount; // Gather the signing material before the borrow goes away. diff --git a/finance/betting-market/anchor/programs/betting-market/src/instructions/claim_winnings.rs b/finance/betting-market/anchor/programs/betting-market/src/instructions/claim_winnings.rs index 4441cdf80..6de4fd652 100644 --- a/finance/betting-market/anchor/programs/betting-market/src/instructions/claim_winnings.rs +++ b/finance/betting-market/anchor/programs/betting-market/src/instructions/claim_winnings.rs @@ -16,9 +16,10 @@ pub struct ClaimWinningsAccountConstraints { #[account(mint::token_program = token_program)] pub token_mint: InterfaceAccount, - // `mut` so the borrow released for the vault CPI below can be reacquired: - // v2 has no read-only reacquire, and the derive dereferences `event` again - // when it checks the constraints that name it. + // `mut` because the handler lowers `open_bets`, and because the borrow + // released for the vault CPI below has to be reacquired: v2 has no + // read-only reacquire, and the derive dereferences `event` again when it + // checks the constraints that name it. #[account( mut, seeds = [b"event", event.event_id.to_le_bytes()], @@ -66,6 +67,15 @@ pub fn handle_claim_winnings(context: &mut Context, + + #[account(mint::token_program = token_program, address = config.token_mint)] + pub token_mint: InterfaceAccount, + + #[account( + mut, + close = admin, + seeds = [b"event", event.event_id.to_le_bytes()], + bump = event.bump, + )] + pub event: BorshAccount, + + #[account( + mut, + associated_token::mint = token_mint, + associated_token::authority = event, + associated_token::token_program = token_program, + )] + pub vault: InterfaceAccount, + + /// CHECK: validated against config.fee_recipient by the `address` constraint. + #[account(address = config.fee_recipient)] + pub fee_recipient: UncheckedAccount, + + #[account( + init_if_needed, + payer = admin, + associated_token::mint = token_mint, + associated_token::authority = fee_recipient, + associated_token::token_program = token_program, + )] + pub fee_recipient_token_account: InterfaceAccount, + + pub associated_token_program: Program, + pub token_program: Interface<'static, TokenInterface>, + pub system_program: Program, +} + +pub fn handle_close_event(context: &mut Context) -> Result<()> { + require!( + matches!( + context.accounts.event.status, + EventStatus::Settled | EventStatus::Cancelled + ), + BettingError::EventNotFinished + ); + // Every claim and refund reads the event, so the event outlives every + // Bet account of it. + require!( + context.accounts.event.open_bets == 0, + BettingError::BetsStillOpen + ); + // An Outcome account left behind would be found again, with its old + // `total_amount`, by a later event created with the same `event_id`. + require!( + context.accounts.event.open_outcomes == 0, + BettingError::OutcomesStillOpen + ); + + // Gather the signing material before the borrow goes away. + let event_signer = EventSigner::new(&context.accounts.event); + // `event` signs both CPIs below. Release its borrow across them: the + // runtime rejects a CPI that borrows an account we hold. + context.accounts.event.release_borrow()?; + + // Winners' shares are floored, so a settled vault can hold a few minor + // units nobody has a claim on; they go to the fee recipient with the fee. + let remainder = context.accounts.vault.amount(); + if remainder > 0 { + transfer_tokens_from_vault( + &mut context.accounts.vault, + &mut context.accounts.fee_recipient_token_account, + remainder, + &context.accounts.token_mint, + &event_signer, + &context.accounts.token_program, + )?; + } + + close_vault( + &mut context.accounts.vault, + &mut context.accounts.admin, + &event_signer, + &context.accounts.token_program, + )?; + + // Take the borrow back before the derive's exit path closes `event` + // through its `close = admin` constraint. + context.accounts.event.reacquire_borrow_mut()?; + + Ok(()) +} diff --git a/finance/betting-market/anchor/programs/betting-market/src/instructions/close_losing_bet.rs b/finance/betting-market/anchor/programs/betting-market/src/instructions/close_losing_bet.rs index df8d97ccd..7b55be045 100644 --- a/finance/betting-market/anchor/programs/betting-market/src/instructions/close_losing_bet.rs +++ b/finance/betting-market/anchor/programs/betting-market/src/instructions/close_losing_bet.rs @@ -13,6 +13,7 @@ pub struct CloseLosingBetAccountConstraints { pub bettor: Signer, #[account( + mut, seeds = [b"event", event.event_id.to_le_bytes()], bump = event.bump, address = bet.event, @@ -40,5 +41,14 @@ pub fn handle_close_losing_bet( BettingError::BetWon ); + // This Bet account closes when the handler returns, so the event's count + // of open bets drops by one. + context.accounts.event.open_bets = context + .accounts + .event + .open_bets + .checked_sub(1) + .ok_or(BettingError::MathOverflow)?; + Ok(()) } diff --git a/finance/betting-market/anchor/programs/betting-market/src/instructions/close_outcome.rs b/finance/betting-market/anchor/programs/betting-market/src/instructions/close_outcome.rs new file mode 100644 index 000000000..ffa2eb79d --- /dev/null +++ b/finance/betting-market/anchor/programs/betting-market/src/instructions/close_outcome.rs @@ -0,0 +1,59 @@ +use anchor_lang::prelude::*; + +use crate::state::Event; + +use crate::{error::BettingError, Config, EventStatus, Outcome}; + +// Close one Outcome account of a finished event and return its rent to the +// admin, who paid it. Bet accounts derive their address from the outcome's, +// so every bet of the event must already be closed, and the Outcome accounts +// must all close before `close_event` can close the event. +#[derive(Accounts)] +pub struct CloseOutcomeAccountConstraints { + #[account(mut, address = config.admin @ BettingError::Unauthorized)] + pub admin: Signer, + + #[account(seeds = [b"config"], + bump = config.bump)] + pub config: BorshAccount, + + #[account( + mut, + seeds = [b"event", event.event_id.to_le_bytes()], + bump = event.bump, + address = outcome.event, + )] + pub event: BorshAccount, + + #[account( + mut, + close = admin, + seeds = [b"outcome", event.address().as_ref(), &[outcome.index]], + bump = outcome.bump, + )] + pub outcome: BorshAccount, +} + +pub fn handle_close_outcome(context: &mut Context) -> Result<()> { + require!( + matches!( + context.accounts.event.status, + EventStatus::Settled | EventStatus::Cancelled + ), + BettingError::EventNotFinished + ); + // A Bet account derives its address from its outcome's, so an outcome + // closed under an open bet would leave that bet's claim or refund with + // no outcome to check against. + require!( + context.accounts.event.open_bets == 0, + BettingError::BetsStillOpen + ); + + let event = &mut context.accounts.event; + event.open_outcomes = event + .open_outcomes + .checked_sub(1) + .ok_or(BettingError::MathOverflow)?; + Ok(()) +} diff --git a/finance/betting-market/anchor/programs/betting-market/src/instructions/initialize_event.rs b/finance/betting-market/anchor/programs/betting-market/src/instructions/initialize_event.rs index d251cc891..b74aa3cff 100644 --- a/finance/betting-market/anchor/programs/betting-market/src/instructions/initialize_event.rs +++ b/finance/betting-market/anchor/programs/betting-market/src/instructions/initialize_event.rs @@ -70,6 +70,8 @@ pub fn handle_initialize_event( event_id, description, outcome_count: 0, + open_outcomes: 0, + open_bets: 0, total_pool: 0, // Starts as a draft: outcomes are added before anyone can bet. status: EventStatus::Draft, diff --git a/finance/betting-market/anchor/programs/betting-market/src/instructions/mod.rs b/finance/betting-market/anchor/programs/betting-market/src/instructions/mod.rs index b86c58955..ae04c7025 100644 --- a/finance/betting-market/anchor/programs/betting-market/src/instructions/mod.rs +++ b/finance/betting-market/anchor/programs/betting-market/src/instructions/mod.rs @@ -2,7 +2,9 @@ pub mod add_outcome; pub mod cancel_event; pub mod claim_refund; pub mod claim_winnings; +pub mod close_event; pub mod close_losing_bet; +pub mod close_outcome; pub mod initialize_config; pub mod initialize_event; pub mod open_betting; @@ -14,7 +16,9 @@ pub use add_outcome::*; pub use cancel_event::*; pub use claim_refund::*; pub use claim_winnings::*; +pub use close_event::*; pub use close_losing_bet::*; +pub use close_outcome::*; pub use initialize_config::*; pub use initialize_event::*; pub use open_betting::*; diff --git a/finance/betting-market/anchor/programs/betting-market/src/instructions/place_bet.rs b/finance/betting-market/anchor/programs/betting-market/src/instructions/place_bet.rs index 85465eba4..ee5919e57 100644 --- a/finance/betting-market/anchor/programs/betting-market/src/instructions/place_bet.rs +++ b/finance/betting-market/anchor/programs/betting-market/src/instructions/place_bet.rs @@ -131,6 +131,12 @@ pub fn handle_place_bet( .total_pool .checked_add(amount) .ok_or(BettingError::MathOverflow)?; + if is_new_bet { + event.open_bets = event + .open_bets + .checked_add(1) + .ok_or(BettingError::MathOverflow)?; + } Ok(()) } diff --git a/finance/betting-market/anchor/programs/betting-market/src/instructions/shared.rs b/finance/betting-market/anchor/programs/betting-market/src/instructions/shared.rs index f58c4b8d2..443a8d0d1 100644 --- a/finance/betting-market/anchor/programs/betting-market/src/instructions/shared.rs +++ b/finance/betting-market/anchor/programs/betting-market/src/instructions/shared.rs @@ -3,7 +3,8 @@ use anchor_lang::prelude::*; use crate::state::Event; use anchor_spl::token_interface::{ - transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked, + close_account, transfer_checked, CloseAccount, Mint, TokenAccount, TokenInterface, + TransferChecked, }; // Move tokens from a wallet-owned account into the vault. The authority is a @@ -79,3 +80,26 @@ pub fn transfer_tokens_from_vault( CpiContext::new_with_signer(token_program.address(), transfer_accounts, &signer_seeds); transfer_checked(cpi_context, amount, decimals) } + +// Close the (already empty) vault, signed by the Event PDA, sending its rent +// to `destination`. As with `transfer_tokens_from_vault`, the caller releases +// the event's borrow before this CPI and reacquires it afterwards. +pub fn close_vault( + vault: &mut InterfaceAccount, + destination: &mut Signer, + event: &EventSigner, + token_program: &Interface<'static, TokenInterface>, +) -> Result<()> { + let event_id_bytes = event.id.to_le_bytes(); + let seeds = &[b"event".as_ref(), event_id_bytes.as_ref(), &[event.bump]]; + let signer_seeds = [&seeds[..]]; + + let close_accounts = CloseAccount { + account: vault.cpi_handle_mut(), + destination: destination.cpi_handle_mut(), + authority: CpiHandle::readonly(&event.view), + }; + let cpi_context = + CpiContext::new_with_signer(token_program.address(), close_accounts, &signer_seeds); + close_account(cpi_context) +} diff --git a/finance/betting-market/anchor/programs/betting-market/src/lib.rs b/finance/betting-market/anchor/programs/betting-market/src/lib.rs index 326178467..b4d41b859 100644 --- a/finance/betting-market/anchor/programs/betting-market/src/lib.rs +++ b/finance/betting-market/anchor/programs/betting-market/src/lib.rs @@ -95,4 +95,17 @@ pub mod betting_market { pub fn claim_refund(context: &mut Context) -> Result<()> { instructions::claim_refund::handle_claim_refund(context) } + + // Admin closes one Outcome account of a settled or cancelled event once + // every Bet account of the event is closed. The rent returns to the admin. + pub fn close_outcome(context: &mut Context) -> Result<()> { + instructions::close_outcome::handle_close_outcome(context) + } + + // Admin closes a settled or cancelled event once its bets and outcomes are + // closed: the vault's remaining dust goes to the fee recipient, and the + // vault and Event account close, returning their rent to the admin. + pub fn close_event(context: &mut Context) -> Result<()> { + instructions::close_event::handle_close_event(context) + } } diff --git a/finance/betting-market/anchor/programs/betting-market/src/state/event.rs b/finance/betting-market/anchor/programs/betting-market/src/state/event.rs index e4f5b978f..89ca7e51d 100644 --- a/finance/betting-market/anchor/programs/betting-market/src/state/event.rs +++ b/finance/betting-market/anchor/programs/betting-market/src/state/event.rs @@ -23,6 +23,16 @@ pub struct Event { #[max_len(200)] pub description: String, pub outcome_count: u8, + // How many Outcome accounts of this event are still open. `add_outcome` + // adds one; `close_outcome` subtracts one. `close_event` requires zero, + // because an Outcome left behind would carry its stakes into a later + // event created with the same `event_id`. + pub open_outcomes: u8, + // How many Bet accounts across every outcome are still open. `place_bet` + // adds one when it creates a Bet account (a top-up reuses the account); + // `claim_winnings`, `claim_refund` and `close_losing_bet` each subtract + // one when they close one. `close_outcome` and `close_event` require zero. + pub open_bets: u64, // Sum of every stake placed across all outcomes. pub total_pool: u64, pub status: EventStatus, diff --git a/finance/betting-market/anchor/programs/betting-market/src/state/outcome.rs b/finance/betting-market/anchor/programs/betting-market/src/state/outcome.rs index 51f18d204..92930c109 100644 --- a/finance/betting-market/anchor/programs/betting-market/src/state/outcome.rs +++ b/finance/betting-market/anchor/programs/betting-market/src/state/outcome.rs @@ -2,7 +2,9 @@ use anchor_lang::prelude::*; // One possible result of an event (e.g. "Yes", "Team A wins"). `total_amount` // is this outcome's share of the pool and is the denominator for pro-rata -// payouts when this outcome wins. +// payouts when this outcome wins. `bet_count` is the number of Bet accounts +// ever created on this outcome; closing a bet does not lower it (the event's +// `open_bets` is the count that does fall). #[account(borsh)] #[derive(InitSpace)] pub struct Outcome { diff --git a/finance/betting-market/anchor/programs/betting-market/tests/test_betting_market.rs b/finance/betting-market/anchor/programs/betting-market/tests/test_betting_market.rs index 2b75b19c1..5ce768224 100644 --- a/finance/betting-market/anchor/programs/betting-market/tests/test_betting_market.rs +++ b/finance/betting-market/anchor/programs/betting-market/tests/test_betting_market.rs @@ -1,14 +1,16 @@ use { anchor_lang::{ - solana_program::instruction::Instruction, system_program, AccountDeserialize, Address, - InstructionData, ToAccountMetas, + solana_program::{instruction::Instruction, system_instruction}, + system_program, AccountDeserialize, Address, InstructionData, ToAccountMetas, }, anchor_v2_testing::{Keypair, LiteSVM, Signer}, - betting_market::{error::BettingError, Bet}, + // `Event` through `state`: the prelude's `Event` trait shadows the glob + // re-export at the crate root. + betting_market::{error::BettingError, state::Event, Bet}, // LiteSVM's get_sysvar / set_sysvar want the host-side Clock. solana_clock::Clock, solana_kite::{ - create_associated_token_account, create_token_mint, create_wallet, + create_associated_token_account, create_token_mint, create_wallet, get_sol_balance, get_token_account_balance, mint_tokens_to_token_account, send_transaction_from_instructions, SolanaKiteError, }, @@ -367,15 +369,150 @@ fn close_losing_bet_ix(bettor: &Address, event_id: u64, outcome_index: u8) -> In ) } -// A Bet account lives only while its position is open: claiming, refunding or -// closing it as a loser closes the account. -fn bet_is_open(market: &Market, bet: &Address) -> bool { +fn close_outcome_ix(admin: Address, event_id: u64, outcome_index: u8) -> Instruction { + let event = event_pda(event_id); + Instruction::new_with_bytes( + betting_market::id(), + &betting_market::instruction::CloseOutcome {}.data(), + betting_market::accounts::CloseOutcomeAccountConstraints { + admin, + config: config_pda(), + event, + outcome: outcome_pda(&event, outcome_index), + } + .to_account_metas(None), + ) +} + +fn close_event_ix( + admin: Address, + mint: Address, + fee_recipient: Address, + fee_recipient_ata: Address, + event_id: u64, +) -> Instruction { + let event = event_pda(event_id); + Instruction::new_with_bytes( + betting_market::id(), + &betting_market::instruction::CloseEvent {}.data(), + betting_market::accounts::CloseEventAccountConstraints { + admin, + config: config_pda(), + token_mint: mint, + event, + vault: derive_ata(&event, &mint), + fee_recipient, + fee_recipient_token_account: fee_recipient_ata, + associated_token_program: ata_program_id(), + token_program: token_program_id(), + system_program: system_program::ID, + } + .to_account_metas(None), + ) +} + +// A closed account has no lamports and no data. A Bet account lives only +// while its position is open: claiming, refunding or closing it as a loser +// closes the account. Outcome accounts, the vault and the Event account close +// through `close_outcome` and `close_event` once the event is finished. +fn account_is_open(market: &Market, address: &Address) -> bool { market .svm - .get_account(bet) + .get_account(address) .is_some_and(|account| account.lamports > 0 && !account.data.is_empty()) } +// The lamports an open account holds, which closing it returns to whoever +// paid its rent. +fn rent_of(market: &Market, address: &Address) -> u64 { + market.svm.get_account(address).unwrap().lamports +} + +// What one transaction with a single signer costs its fee payer, measured by +// sending the admin a zero-lamport transfer to themselves: nothing else in +// that transaction moves lamports. A fresh blockhash first, so repeated +// measurements are distinct transactions. +fn transaction_fee(market: &mut Market) -> u64 { + let admin = market.admin.pubkey(); + market.svm.expire_blockhash(); + let before = get_sol_balance(&market.svm, &admin); + send_transaction_from_instructions( + &mut market.svm, + vec![system_instruction::transfer(&admin, &admin, 0)], + &[&market.admin], + &admin, + ) + .unwrap(); + before - get_sol_balance(&market.svm, &admin) +} + +fn read_event(market: &Market, event_id: u64) -> Event { + let account = market.svm.get_account(&event_pda(event_id)).unwrap(); + Event::try_deserialize(&mut account.data.as_slice()).unwrap() +} + +// Closes a finished event's Outcome accounts and then the event itself, as +// the admin, asserting that each rent comes back to the admin and that every +// closed account is gone. Returns what the vault held before it closed. +fn close_outcomes_and_event(market: &mut Market, event_id: u64, outcome_count: u8) -> u64 { + let admin = market.admin.pubkey(); + let mint = market.mint; + let fee_recipient = market.fee_recipient.pubkey(); + let fee_recipient_ata = market.fee_recipient_ata; + let event = event_pda(event_id); + let vault = derive_ata(&event, &mint); + let fee = transaction_fee(market); + + for index in 0..outcome_count { + let outcome = outcome_pda(&event, index); + let outcome_rent = rent_of(market, &outcome); + let admin_before = get_sol_balance(&market.svm, &admin); + send_transaction_from_instructions( + &mut market.svm, + vec![close_outcome_ix(admin, event_id, index)], + &[&market.admin], + &admin, + ) + .unwrap(); + assert!( + !account_is_open(market, &outcome), + "outcome {index} must be closed" + ); + assert_eq!( + get_sol_balance(&market.svm, &admin), + admin_before + outcome_rent - fee, + "outcome {index}'s rent must return to the admin" + ); + } + assert_eq!(read_event(market, event_id).open_outcomes, 0); + + let vault_balance = get_token_account_balance(&market.svm, &vault).unwrap(); + let event_rent = rent_of(market, &event); + let vault_rent = rent_of(market, &vault); + let admin_before = get_sol_balance(&market.svm, &admin); + send_transaction_from_instructions( + &mut market.svm, + vec![close_event_ix( + admin, + mint, + fee_recipient, + fee_recipient_ata, + event_id, + )], + &[&market.admin], + &admin, + ) + .unwrap(); + assert!(!account_is_open(market, &event)); + assert!(!account_is_open(market, &vault)); + assert_eq!( + get_sol_balance(&market.svm, &admin), + admin_before + event_rent + vault_rent - fee, + "the event's and the vault's rent must return to the admin" + ); + vault_balance +} + // A bettor's open positions are the Bet accounts whose `bettor` field is their // address. An RPC client finds them with getProgramAccounts and a memcmp filter // at this offset, so the program keeps no per-wallet index (and no cap on @@ -549,11 +686,11 @@ fn test_full_lifecycle() { // Claiming closed the winners' Bet accounts. let winning_outcome = outcome_pda(&event_pda(event_id), 0); - assert!(!bet_is_open( + assert!(!account_is_open( &market, &bet_pda(&winning_outcome, &alice.pubkey()) )); - assert!(!bet_is_open( + assert!(!account_is_open( &market, &bet_pda(&winning_outcome, &bob.pubkey()) )); @@ -571,14 +708,11 @@ fn test_full_lifecycle() { &[&carol], &carol.pubkey(), ); - assert!( - carol_claim.is_err(), - "loser must not be able to claim winnings" - ); + assert_fails_with(carol_claim, BettingError::NothingToClaim); // Her losing position stays open until she closes it. let carol_bet = bet_pda(&outcome_pda(&event_pda(event_id), 1), &carol.pubkey()); - assert!(bet_is_open(&market, &carol_bet)); + assert!(account_is_open(&market, &carol_bet)); send_transaction_from_instructions( &mut market.svm, vec![close_losing_bet_ix(&carol.pubkey(), event_id, 1)], @@ -586,7 +720,7 @@ fn test_full_lifecycle() { &carol.pubkey(), ) .unwrap(); - assert!(!bet_is_open(&market, &carol_bet)); + assert!(!account_is_open(&market, &carol_bet)); } #[test] @@ -594,20 +728,49 @@ fn test_only_admin_can_initialize_event() { let mut market = setup(); init_config(&mut market); + let admin = market.admin.pubkey(); let mint = market.mint; + let event_id: u64 = 7; let mallory = create_wallet(&mut market.svm, 10_000_000_000).unwrap(); let result = send_transaction_from_instructions( &mut market.svm, vec![initialize_event_ix( mallory.pubkey(), mint, - 7, + event_id, "Unauthorized event", )], &[&mallory], &mallory.pubkey(), ); - assert!(result.is_err(), "non-admin must not create an event"); + assert_fails_with(result, BettingError::Unauthorized); + + // Nor can anyone but the admin add an outcome to a draft, or open it. + send_transaction_from_instructions( + &mut market.svm, + vec![ + initialize_event_ix(admin, mint, event_id, "Admin's event"), + add_outcome_ix(admin, event_id, 0, "Glowbugs 3"), + add_outcome_ix(admin, event_id, 1, "The Quiet Floor"), + ], + &[&market.admin], + &admin, + ) + .unwrap(); + let unauthorized_outcome = send_transaction_from_instructions( + &mut market.svm, + vec![add_outcome_ix(mallory.pubkey(), event_id, 2, "Late entry")], + &[&mallory], + &mallory.pubkey(), + ); + assert_fails_with(unauthorized_outcome, BettingError::Unauthorized); + let unauthorized_open = send_transaction_from_instructions( + &mut market.svm, + vec![open_betting_ix(mallory.pubkey(), event_id)], + &[&mallory], + &mallory.pubkey(), + ); + assert_fails_with(unauthorized_open, BettingError::Unauthorized); } #[test] @@ -897,7 +1060,7 @@ fn test_cancel_and_refund() { .unwrap(); let alice_bet = bet_pda(&outcome_pda(&event_pda(event_id), 0), &alice.pubkey()); - assert!(bet_is_open(&market, &alice_bet)); + assert!(account_is_open(&market, &alice_bet)); send_transaction_from_instructions( &mut market.svm, @@ -914,7 +1077,17 @@ fn test_cancel_and_refund() { .unwrap(); // The refund closed Alice's Bet account. - assert!(!bet_is_open(&market, &alice_bet)); + assert!(!account_is_open(&market, &alice_bet)); + + // Carol's refund is still outstanding, so the event's accounts stay. + let outcome_still_in_use = send_transaction_from_instructions( + &mut market.svm, + vec![close_outcome_ix(admin, event_id, 0)], + &[&market.admin], + &admin, + ); + assert_fails_with(outcome_still_in_use, BettingError::BetsStillOpen); + assert_eq!(read_event(&market, event_id).open_bets, 1); send_transaction_from_instructions( &mut market.svm, @@ -941,6 +1114,16 @@ fn test_cancel_and_refund() { ); let vault = derive_ata(&event_pda(event_id), &mint); assert_eq!(get_token_account_balance(&market.svm, &vault).unwrap(), 0); + + // With every stake refunded the admin closes the outcomes and the event. + // The vault was empty, so the fee recipient receives nothing. + assert_eq!(read_event(&market, event_id).open_bets, 0); + let vault_balance_at_close = close_outcomes_and_event(&mut market, event_id, 2); + assert_eq!(vault_balance_at_close, 0); + assert_eq!( + get_token_account_balance(&market.svm, &market.fee_recipient_ata).unwrap(), + 0 + ); } #[test] @@ -1003,10 +1186,7 @@ fn test_close_losing_bet_only_after_settle_and_only_for_losers() { &[&carol], &carol.pubkey(), ); - assert!( - premature_close.is_err(), - "closing before settlement must fail" - ); + assert_fails_with(premature_close, BettingError::EventNotSettled); // Betting has closed, so the event can be settled. warp_to(&mut market.svm, BETTING_CLOSES_AT); @@ -1032,12 +1212,9 @@ fn test_close_losing_bet_only_after_settle_and_only_for_losers() { &[&alice], &alice.pubkey(), ); - assert!( - winner_close.is_err(), - "a winning bet must not be closed as losing" - ); + assert_fails_with(winner_close, BettingError::BetWon); let alice_bet = bet_pda(&outcome_pda(&event_pda(event_id), 0), &alice.pubkey()); - assert!(bet_is_open(&market, &alice_bet)); + assert!(account_is_open(&market, &alice_bet)); // Carol lost; closing returns her Bet account's rent. A fresh blockhash so this is // a distinct transaction from her premature attempt above. @@ -1050,7 +1227,7 @@ fn test_close_losing_bet_only_after_settle_and_only_for_losers() { ) .unwrap(); let carol_bet = bet_pda(&outcome_pda(&event_pda(event_id), 1), &carol.pubkey()); - assert!(!bet_is_open(&market, &carol_bet)); + assert!(!account_is_open(&market, &carol_bet)); } // A wallet can hold as many open positions as it likes: forty bets across @@ -1151,13 +1328,13 @@ fn test_no_cap_on_open_bets_per_wallet() { &outcome_pda(&event_pda(wide_event_id), index), &alice.pubkey(), ); - assert!(bet_is_open(&market, &bet), "bet {index} must be open"); + assert!(account_is_open(&market, &bet), "bet {index} must be open"); } let other_market_bet = bet_pda( &outcome_pda(&event_pda(second_event_id), 0), &alice.pubkey(), ); - assert!(bet_is_open(&market, &other_market_bet)); + assert!(account_is_open(&market, &other_market_bet)); assert_eq!( get_token_account_balance(&market.svm, &alice_ata).unwrap(), 0 @@ -1394,3 +1571,385 @@ fn test_close_time_must_be_in_the_future() { ); assert_fails_with(result, BettingError::CloseTimeInPast); } + +// Sets up a two-outcome market ("Yes" at index 0, "No" at index 1) on +// `event_id` and opens it to bets. +fn open_yes_no_market(market: &mut Market, event_id: u64, description: &str) { + let admin = market.admin.pubkey(); + let mint = market.mint; + send_transaction_from_instructions( + &mut market.svm, + vec![ + initialize_event_ix(admin, mint, event_id, description), + add_outcome_ix(admin, event_id, 0, "Yes"), + add_outcome_ix(admin, event_id, 1, "No"), + open_betting_ix(admin, event_id), + ], + &[&market.admin], + &admin, + ) + .unwrap(); +} + +fn place_bet( + market: &mut Market, + bettor: &Keypair, + bettor_ata: &Address, + event_id: u64, + outcome_index: u8, + amount: u64, +) { + let mint = market.mint; + send_transaction_from_instructions( + &mut market.svm, + vec![place_bet_ix( + mint, + &bettor.pubkey(), + bettor_ata, + event_id, + outcome_index, + amount, + )], + &[bettor], + &bettor.pubkey(), + ) + .unwrap(); +} + +// Settles `event_id` to `winning_outcome_index` once betting has closed. +fn settle(market: &mut Market, event_id: u64, winning_outcome_index: u8) { + let admin = market.admin.pubkey(); + let mint = market.mint; + let fee_recipient = market.fee_recipient.pubkey(); + let fee_recipient_ata = market.fee_recipient_ata; + warp_to(&mut market.svm, BETTING_CLOSES_AT); + send_transaction_from_instructions( + &mut market.svm, + vec![settle_event_ix( + admin, + mint, + fee_recipient, + fee_recipient_ata, + event_id, + winning_outcome_index, + )], + &[&market.admin], + &admin, + ) + .unwrap(); +} + +// The whole lifecycle through to every account closing. Stakes are chosen so +// the pro-rata split does not divide evenly: Yes pool 300 (Alice 100 in two +// bets, Bob 200), No pool 250 (Carol). Yes wins: losing pool 250, fee 5, +// distributable 245; Alice gets floor(100 * 245 / 300) = 81 and Bob +// floor(200 * 245 / 300) = 163, so one minor unit of dust stays in the vault +// until `close_event` pays it to the fee recipient. +#[test] +fn test_close_event_pays_dust_to_fee_recipient_and_returns_rent() { + let mut market = setup(); + let event_id: u64 = 13; + let (alice, alice_ata) = create_bettor(&mut market, 1_000); + let (bob, bob_ata) = create_bettor(&mut market, 1_000); + let (carol, carol_ata) = create_bettor(&mut market, 1_000); + + init_config(&mut market); + let admin = market.admin.pubkey(); + let mint = market.mint; + let fee_recipient = market.fee_recipient.pubkey(); + let fee_recipient_ata = market.fee_recipient_ata; + open_yes_no_market(&mut market, event_id, "Top-grossing film"); + + // Alice's second bet tops up her existing Bet account rather than + // creating another, so it counts once among the open bets. + place_bet(&mut market, &alice, &alice_ata, event_id, 0, 60); + place_bet(&mut market, &alice, &alice_ata, event_id, 0, 40); + place_bet(&mut market, &bob, &bob_ata, event_id, 0, 200); + place_bet(&mut market, &carol, &carol_ata, event_id, 1, 250); + let event = read_event(&market, event_id); + assert_eq!(event.open_bets, 3); + assert_eq!(event.open_outcomes, 2); + assert_eq!(event.total_pool, 550); + + settle(&mut market, event_id, 0); + assert_eq!( + get_token_account_balance(&market.svm, &fee_recipient_ata).unwrap(), + 5 + ); + + // Carol closes her losing bet; Alice and Bob claim. + send_transaction_from_instructions( + &mut market.svm, + vec![close_losing_bet_ix(&carol.pubkey(), event_id, 1)], + &[&carol], + &carol.pubkey(), + ) + .unwrap(); + assert_eq!(read_event(&market, event_id).open_bets, 2); + for (winner, winner_ata) in [(&alice, &alice_ata), (&bob, &bob_ata)] { + send_transaction_from_instructions( + &mut market.svm, + vec![claim_winnings_ix( + mint, + &winner.pubkey(), + winner_ata, + event_id, + 0, + )], + &[winner], + &winner.pubkey(), + ) + .unwrap(); + } + assert_eq!( + get_token_account_balance(&market.svm, &alice_ata).unwrap(), + 1_000 - 100 + 181 + ); + assert_eq!( + get_token_account_balance(&market.svm, &bob_ata).unwrap(), + 1_000 - 200 + 363 + ); + assert_eq!(read_event(&market, event_id).open_bets, 0); + + // The two floors left one minor unit in the vault. + let vault = derive_ata(&event_pda(event_id), &mint); + assert_eq!(get_token_account_balance(&market.svm, &vault).unwrap(), 1); + + // Outcome accounts close before the event does. + let outcomes_still_open = send_transaction_from_instructions( + &mut market.svm, + vec![close_event_ix( + admin, + mint, + fee_recipient, + fee_recipient_ata, + event_id, + )], + &[&market.admin], + &admin, + ); + assert_fails_with(outcomes_still_open, BettingError::OutcomesStillOpen); + + let vault_balance_at_close = close_outcomes_and_event(&mut market, event_id, 2); + assert_eq!(vault_balance_at_close, 1); + // The dust joined the fee: 5 + 1. + assert_eq!( + get_token_account_balance(&market.svm, &fee_recipient_ata).unwrap(), + 6 + ); +} + +// A settled event keeps its accounts while any Bet account of it is open: +// the claim and the losing-bet close both read the event and the outcome. +#[test] +fn test_close_event_refused_while_a_bet_is_open() { + let mut market = setup(); + let event_id: u64 = 14; + let (alice, alice_ata) = create_bettor(&mut market, 1_000); + let (carol, carol_ata) = create_bettor(&mut market, 1_000); + + init_config(&mut market); + let admin = market.admin.pubkey(); + let mint = market.mint; + let fee_recipient = market.fee_recipient.pubkey(); + let fee_recipient_ata = market.fee_recipient_ata; + open_yes_no_market(&mut market, event_id, "Match winner"); + place_bet(&mut market, &alice, &alice_ata, event_id, 0, 100); + place_bet(&mut market, &carol, &carol_ata, event_id, 1, 300); + settle(&mut market, event_id, 0); + + // Alice has claimed; Carol's losing bet is still open. + send_transaction_from_instructions( + &mut market.svm, + vec![claim_winnings_ix( + mint, + &alice.pubkey(), + &alice_ata, + event_id, + 0, + )], + &[&alice], + &alice.pubkey(), + ) + .unwrap(); + assert_eq!(read_event(&market, event_id).open_bets, 1); + + let event_close = send_transaction_from_instructions( + &mut market.svm, + vec![close_event_ix( + admin, + mint, + fee_recipient, + fee_recipient_ata, + event_id, + )], + &[&market.admin], + &admin, + ); + assert_fails_with(event_close, BettingError::BetsStillOpen); + let outcome_close = send_transaction_from_instructions( + &mut market.svm, + vec![close_outcome_ix(admin, event_id, 1)], + &[&market.admin], + &admin, + ); + assert_fails_with(outcome_close, BettingError::BetsStillOpen); + + // Once Carol closes her bet, everything else can close. + send_transaction_from_instructions( + &mut market.svm, + vec![close_losing_bet_ix(&carol.pubkey(), event_id, 1)], + &[&carol], + &carol.pubkey(), + ) + .unwrap(); + close_outcomes_and_event(&mut market, event_id, 2); +} + +// A draft or open event has not finished, so its accounts cannot close, +// even when nobody has bet. Cancelling it is what lets it close. +#[test] +fn test_close_event_refused_while_event_is_open() { + let mut market = setup(); + let event_id: u64 = 15; + + init_config(&mut market); + let admin = market.admin.pubkey(); + let mint = market.mint; + let fee_recipient = market.fee_recipient.pubkey(); + let fee_recipient_ata = market.fee_recipient_ata; + send_transaction_from_instructions( + &mut market.svm, + vec![ + initialize_event_ix(admin, mint, event_id, "Never bet on"), + add_outcome_ix(admin, event_id, 0, "Yes"), + add_outcome_ix(admin, event_id, 1, "No"), + ], + &[&market.admin], + &admin, + ) + .unwrap(); + + // As a draft... + let draft_close = send_transaction_from_instructions( + &mut market.svm, + vec![close_event_ix( + admin, + mint, + fee_recipient, + fee_recipient_ata, + event_id, + )], + &[&market.admin], + &admin, + ); + assert_fails_with(draft_close, BettingError::EventNotFinished); + let draft_outcome_close = send_transaction_from_instructions( + &mut market.svm, + vec![close_outcome_ix(admin, event_id, 0)], + &[&market.admin], + &admin, + ); + assert_fails_with(draft_outcome_close, BettingError::EventNotFinished); + + // ...and once open. A fresh blockhash so this is a distinct transaction + // from the draft attempt above. + send_transaction_from_instructions( + &mut market.svm, + vec![open_betting_ix(admin, event_id)], + &[&market.admin], + &admin, + ) + .unwrap(); + market.svm.expire_blockhash(); + let open_close = send_transaction_from_instructions( + &mut market.svm, + vec![close_event_ix( + admin, + mint, + fee_recipient, + fee_recipient_ata, + event_id, + )], + &[&market.admin], + &admin, + ); + assert_fails_with(open_close, BettingError::EventNotFinished); + + // Cancelled with no bets, it closes straight away. + send_transaction_from_instructions( + &mut market.svm, + vec![cancel_event_ix(admin, event_id)], + &[&market.admin], + &admin, + ) + .unwrap(); + let vault_balance_at_close = close_outcomes_and_event(&mut market, event_id, 2); + assert_eq!(vault_balance_at_close, 0); +} + +#[test] +fn test_only_admin_can_close_outcomes_and_event() { + let mut market = setup(); + let event_id: u64 = 16; + let (alice, alice_ata) = create_bettor(&mut market, 1_000); + let (carol, carol_ata) = create_bettor(&mut market, 1_000); + + init_config(&mut market); + let mint = market.mint; + let fee_recipient = market.fee_recipient.pubkey(); + let fee_recipient_ata = market.fee_recipient_ata; + open_yes_no_market(&mut market, event_id, "Derby winner"); + place_bet(&mut market, &alice, &alice_ata, event_id, 0, 100); + place_bet(&mut market, &carol, &carol_ata, event_id, 1, 300); + settle(&mut market, event_id, 0); + send_transaction_from_instructions( + &mut market.svm, + vec![claim_winnings_ix( + mint, + &alice.pubkey(), + &alice_ata, + event_id, + 0, + )], + &[&alice], + &alice.pubkey(), + ) + .unwrap(); + send_transaction_from_instructions( + &mut market.svm, + vec![close_losing_bet_ix(&carol.pubkey(), event_id, 1)], + &[&carol], + &carol.pubkey(), + ) + .unwrap(); + + // Every bet is closed, so only the signer stands between Mallory and the + // rent. + let mallory = create_wallet(&mut market.svm, 10_000_000_000).unwrap(); + let outcome_close = send_transaction_from_instructions( + &mut market.svm, + vec![close_outcome_ix(mallory.pubkey(), event_id, 0)], + &[&mallory], + &mallory.pubkey(), + ); + assert_fails_with(outcome_close, BettingError::Unauthorized); + let event_close = send_transaction_from_instructions( + &mut market.svm, + vec![close_event_ix( + mallory.pubkey(), + mint, + fee_recipient, + fee_recipient_ata, + event_id, + )], + &[&mallory], + &mallory.pubkey(), + ); + assert_fails_with(event_close, BettingError::Unauthorized); + let event = event_pda(event_id); + assert!(account_is_open(&market, &event)); + assert!(account_is_open(&market, &outcome_pda(&event, 0))); + + close_outcomes_and_event(&mut market, event_id, 2); +} diff --git a/finance/betting-market/quasar/CHANGELOG.md b/finance/betting-market/quasar/CHANGELOG.md index 8e643c785..21e8282b3 100644 --- a/finance/betting-market/quasar/CHANGELOG.md +++ b/finance/betting-market/quasar/CHANGELOG.md @@ -1,5 +1,40 @@ # Changelog +## [Unreleased] - 2026-10-04 + +### Added + +- Two admin handlers close what an event created, once it is `Settled` or + `Cancelled` and every Bet account is closed. `close_outcome` closes one + Outcome account and returns its rent to the admin. `close_event`, once every + Outcome account is closed, pays whatever the vault still holds (the rounding + dust a settlement's floored payouts leave; nothing after a cancellation) to + the fee recipient's token account, closes the vault, and closes the Event + account, returning both rents to the admin. Errors `EventNotFinished`, + `BetsStillOpen` and `OutcomesStillOpen`, appended after `BettingStillOpen`. + `close_outcome` takes discriminator 10 and `close_event` 11, leaving the + existing ones in place. +- `Event.open_bets` counts the Bet accounts still open across every outcome: + `place_bet` adds one when it creates a Bet account (a top-up reuses the + account), and `claim_winnings`, `claim_refund` and `close_losing_bet` each + subtract one. `Event.open_outcomes` counts the Outcome accounts still open: + `add_outcome` adds one, `close_outcome` subtracts one. Both fields sit after + `outcome_count`, so the Event account layout changes. `claim_winnings`, + `claim_refund` and `close_losing_bet` now take the event as writable. +- Tests: `close_event_pays_dust_to_fee_recipient_and_returns_rent`, + `close_event_refused_while_a_bet_is_open`, + `close_event_refused_while_event_is_open` and + `only_admin_can_close_outcomes_and_event`; + `cancelled_event_refunds_the_exact_stake` refunds a second bettor and closes + the cancelled event after its refunds. + +### Changed + +- `initialize_event_rejects_a_non_admin_signer` also sends `add_outcome` from + a non-admin, and `full_lifecycle_settles_and_pays_the_winner` asserts + `EventNotSettled` for a close before settlement, `BetWon` for closing the + winning bet as a loser, and `NothingToClaim` for the loser's claim. + ## [2026-10-03] ### Removed diff --git a/finance/betting-market/quasar/README.md b/finance/betting-market/quasar/README.md index e69ea97c7..6437579e5 100644 --- a/finance/betting-market/quasar/README.md +++ b/finance/betting-market/quasar/README.md @@ -41,25 +41,61 @@ result is known the winners divide the pool. - If a market cannot be resolved, the admin calls `cancel_event` (on a draft or an open event), and every bettor reclaims their exact stake with `claim_refund`. No fee is taken. +- Once every bet is paid out or closed, the admin closes the accounts the event + created: `close_outcome` for each Outcome account (once the event is `Settled` + or `Cancelled`, else `EventNotFinished`, and `open_bets` is zero, else + `BetsStillOpen`), then `close_event` (under the same two conditions, and once + every Outcome account is closed, else `OutcomesStillOpen`), which pays + whatever the vault still holds to the fee recipient's token account, closes + the vault, and closes the Event account, returning both rents to the admin. Closing the Bet account is what ends a position and prevents a double claim: a second `claim_winnings` or `claim_refund` fails because the account no longer exists. +`Settled` and `Cancelled` are the two ways an event ends, and in both every Bet +account has a handler that pays it out or closes it: `claim_winnings` and +`close_losing_bet` after a settlement, `claim_refund` after a cancellation. Once +`open_bets` reaches zero the admin closes the accounts the event created, +children first: `close_outcome` for each Outcome account, then `close_event` for +the vault and the Event account. The order matters because each account's +address is derived from its parent's. An Outcome account left open after its +event closed would be found again, with its old `total_amount`, by a later event +created with the same `event_id`, and a Bet account left open after its outcome +closed would have nothing to claim against. So `close_outcome` refuses while any +bet is open, and `close_event` refuses while any outcome is. After a settlement +the vault holds only the dust the floored payouts left behind, and `close_event` +pays it to the fee recipient with the fee; after a cancellation and its refunds +the vault is empty. Every closed account's rent goes back to whoever paid it: +the bettor for a Bet account, the admin for the outcomes, the vault and the +event. The Config account stays open; it is the admin's state for the whole +deployment. + ## Accounts and PDAs - **Config**, PDA `["config"]`. The single global account. Its `admin` is the - only key allowed to create, open, settle, and cancel events; `token_mint` fixes - the one stake asset; `fee_recipient` receives the settlement fee, and - `default_fee_bps` is the fee each new event copies at creation. + only key allowed to create, open, settle, cancel, and close events; + `token_mint` fixes the one stake asset; `fee_recipient` receives the settlement + fee, and `default_fee_bps` is the fee each new event copies at creation. The + config is the admin's long-lived state and stays open for the life of the + deployment; no handler closes it. - **Event**, PDA `["event", event_id]`. One market. Holds the running `total_pool`, the status (Draft, Open, Settled, Cancelled), the `betting_closes_at` timestamp and a fee snapshot both fixed at creation, and the winning figures written at settlement. Its PDA is the token - authority of the pool vault. + authority of the pool vault. Two counters say when the event can close: + `open_bets`, the number of Bet accounts across every outcome that are still + open (`place_bet` adds one when it creates a Bet account; a top-up reuses the + account and adds nothing; `claim_winnings`, `claim_refund` and + `close_losing_bet` each subtract one), and `open_outcomes`, the number of + Outcome accounts still open (`add_outcome` adds one, `close_outcome` subtracts + one). `close_event` closes the Event account and the vault, returning their + rent to the admin. - **Outcome**, PDA `["outcome", event, index]`. One possible result. `total_amount` is this outcome's share of the pool and the denominator for - pro-rata payouts when it wins. + pro-rata payouts when it wins; its `bet_count` is the number of Bet accounts + ever created on it. `close_outcome` closes it once the event is finished and + every bet is closed, returning its rent to the admin. - **Bet**, PDA `["bet", outcome, bettor]`. One bettor's total stake on one outcome. Exactly one per (outcome, bettor); it closes on claim, refund, or loser-close. `bettor` is the first field after the 1-byte discriminator, so a @@ -67,6 +103,9 @@ exists. `memcmp` filter at offset 1; there is no per-wallet index and no cap. - **Pool vault**, PDA `["vault", event]`. One token account per event, holding every stake across all outcomes, with the Event PDA as its authority. + `settle_event`, `claim_winnings`, `claim_refund` and `close_event` move tokens + out of it, signed by the Event PDA, and `close_event` then closes it with the + same signature, returning its rent to the admin. ## Safety and custody @@ -76,7 +115,8 @@ exists. withdraw stakes, only to settle or cancel. - Payouts credit and close before transferring (effects before interactions), and the fee uses integer division that floors in the pool's favor, leaving at - most a few minor units of dust rather than ever overpaying. + most a few minor units of dust rather than ever overpaying. `close_event` pays + that dust to the fee recipient once every bet is closed. - Admin-gated instructions bind the signer to `config.admin` with `has_one`, and the winning outcome is tied to its index through the account's PDA derivation, so a mismatched outcome can't be settled to. @@ -118,6 +158,16 @@ authorization, that the outcome list locks when betting opens, the two-outcome minimum, both edges of the betting close time, and that a close time in the past is refused. +`close_event_pays_dust_to_fee_recipient_and_returns_rent` runs a settlement +whose payouts do not divide evenly, closes the losing bet, both winners' bets, +both outcomes and the event, and checks that the vault's one minor unit of dust +reaches the fee recipient, that every closed account is gone, and that each rent +returns to the admin. `close_event_refused_while_a_bet_is_open`, +`close_event_refused_while_event_is_open` and +`only_admin_can_close_outcomes_and_event` check the three refusals, and +`cancelled_event_refunds_the_exact_stake` closes a cancelled event after its +refunds. + ## Extending - Per-market stake tokens instead of one deployment-wide mint. diff --git a/finance/betting-market/quasar/src/errors.rs b/finance/betting-market/quasar/src/errors.rs index bfa30e2ed..ce1e5396c 100644 --- a/finance/betting-market/quasar/src/errors.rs +++ b/finance/betting-market/quasar/src/errors.rs @@ -23,4 +23,11 @@ pub enum BettingError { CloseTimeInPast, BettingClosed, BettingStillOpen, + /// The event has not been settled or cancelled, so its accounts cannot be + /// closed yet. + EventNotFinished, + /// Bet accounts are still open, so closing now would strand their claims. + BetsStillOpen, + /// Outcome accounts are still open, so the event cannot be closed yet. + OutcomesStillOpen, } diff --git a/finance/betting-market/quasar/src/instructions/add_outcome.rs b/finance/betting-market/quasar/src/instructions/add_outcome.rs index 69451cedd..dc894e842 100644 --- a/finance/betting-market/quasar/src/instructions/add_outcome.rs +++ b/finance/betting-market/quasar/src/instructions/add_outcome.rs @@ -64,6 +64,10 @@ pub fn handle_add_outcome( .outcome_count .checked_add(1) .ok_or(BettingError::MathOverflow)?; + event.open_outcomes = event + .open_outcomes + .checked_add(1) + .ok_or(BettingError::MathOverflow)?; accounts.event.set_inner(event); Ok(()) } diff --git a/finance/betting-market/quasar/src/instructions/claim_refund.rs b/finance/betting-market/quasar/src/instructions/claim_refund.rs index 2183050d3..8b672dfd3 100644 --- a/finance/betting-market/quasar/src/instructions/claim_refund.rs +++ b/finance/betting-market/quasar/src/instructions/claim_refund.rs @@ -2,7 +2,7 @@ use quasar_lang::prelude::*; use quasar_spl::prelude::*; use crate::errors::BettingError; -use crate::state::{Bet, Event, EventStatus, EventVaultPda}; +use crate::state::{snapshot_event, Bet, Event, EventStatus, EventVaultPda}; use super::transfer_from_vault; @@ -13,7 +13,7 @@ pub struct ClaimRefundAccountConstraints { pub token_mint: Account, - #[account(address = Event::seeds(event.event_id.into()))] + #[account(mut, address = Event::seeds(event.event_id.into()))] pub event: Account, // Closing the Bet ends the position: the rent goes back to the bettor and a @@ -59,6 +59,15 @@ pub fn handle_claim_refund( BettingError::EventNotCancelled ); + // This Bet account closes when the handler returns, so the event's count + // of open bets drops by one before any tokens move. + let mut event = snapshot_event(&accounts.event); + event.open_bets = event + .open_bets + .checked_sub(1) + .ok_or(BettingError::MathOverflow)?; + accounts.event.set_inner(event); + let stake = u64::from(accounts.bet.amount); let event_id = u64::from(accounts.event.event_id); diff --git a/finance/betting-market/quasar/src/instructions/claim_winnings.rs b/finance/betting-market/quasar/src/instructions/claim_winnings.rs index a62d9cea5..3ce7d7819 100644 --- a/finance/betting-market/quasar/src/instructions/claim_winnings.rs +++ b/finance/betting-market/quasar/src/instructions/claim_winnings.rs @@ -2,7 +2,7 @@ use quasar_lang::prelude::*; use quasar_spl::prelude::*; use crate::errors::BettingError; -use crate::state::{Bet, Event, EventStatus, EventVaultPda}; +use crate::state::{snapshot_event, Bet, Event, EventStatus, EventVaultPda}; use super::transfer_from_vault; @@ -13,7 +13,7 @@ pub struct ClaimWinningsAccountConstraints { pub token_mint: Account, - #[account(address = Event::seeds(event.event_id.into()))] + #[account(mut, address = Event::seeds(event.event_id.into()))] pub event: Account, // Closing the Bet ends the position: the rent goes back to the bettor and a @@ -63,6 +63,15 @@ pub fn handle_claim_winnings( BettingError::NothingToClaim ); + // This Bet account closes when the handler returns, so the event's count + // of open bets drops by one before any tokens move. + let mut event = snapshot_event(&accounts.event); + event.open_bets = event + .open_bets + .checked_sub(1) + .ok_or(BettingError::MathOverflow)?; + accounts.event.set_inner(event); + let stake = u64::from(accounts.bet.amount); let winning_pool = u64::from(accounts.event.winning_pool); let distributable_losing_pool = u64::from(accounts.event.distributable_losing_pool); diff --git a/finance/betting-market/quasar/src/instructions/close_event.rs b/finance/betting-market/quasar/src/instructions/close_event.rs new file mode 100644 index 000000000..2c011102d --- /dev/null +++ b/finance/betting-market/quasar/src/instructions/close_event.rs @@ -0,0 +1,105 @@ +use quasar_lang::prelude::*; +use quasar_spl::prelude::*; + +use crate::errors::BettingError; +use crate::state::{Config, Event, EventStatus, EventVaultPda}; + +use super::{close_vault, transfer_from_vault}; + +// Close a finished event: pay whatever the vault still holds to the fee +// recipient, close the vault, and close the Event account, returning both +// rents to the admin, who paid them. After settlement the vault holds only +// the dust that flooring each winner's share left behind; after a +// cancellation and its refunds it holds nothing. +#[derive(Accounts)] +pub struct CloseEventAccountConstraints { + #[account(mut)] + pub admin: Signer, + + #[account( + address = Config::seeds(), + has_one(admin) @ BettingError::Unauthorized, + has_one(token_mint), + )] + pub config: Account, + + pub token_mint: Account, + + // The `close(dest = admin)` constraint closes the Event account once the + // handler has closed the vault it signs for. + #[account( + mut, + close(dest = admin), + address = Event::seeds(event.event_id.into()), + )] + pub event: Account, + + #[account(mut, address = EventVaultPda::seeds(event.address()))] + pub vault: InterfaceAccount, + + // Where the vault's remainder goes. Must be a token account owned by the + // config's fee_recipient, as in `settle_event`; the transfer verifies the + // mint. + #[account(mut)] + pub fee_recipient_token_account: Account, + + pub token_program: Program, +} + +#[inline(always)] +pub fn handle_close_event(accounts: &mut CloseEventAccountConstraints) -> Result<(), ProgramError> { + require!( + accounts.event.status == EventStatus::Settled as u8 + || accounts.event.status == EventStatus::Cancelled as u8, + BettingError::EventNotFinished + ); + // Every claim and refund reads the event, so the event outlives every + // Bet account of it. + require!( + u64::from(accounts.event.open_bets) == 0, + BettingError::BetsStillOpen + ); + // An Outcome account left behind would be found again, with its old + // `total_amount`, by a later event created with the same `event_id`. + require!( + accounts.event.open_outcomes == 0, + BettingError::OutcomesStillOpen + ); + + require_keys_eq!( + accounts.fee_recipient_token_account.owner, + accounts.config.fee_recipient, + BettingError::Unauthorized + ); + + let event_id = u64::from(accounts.event.event_id); + let event_bump = accounts.event.bump; + + // Winners' shares are floored, so a settled vault can hold a few minor + // units nobody has a claim on; they go to the fee recipient with the fee. + let remainder = accounts.vault.amount(); + if remainder > 0 { + transfer_from_vault( + &accounts.token_program, + &accounts.vault, + &accounts.token_mint, + &accounts.fee_recipient_token_account, + &accounts.event, + remainder, + accounts.token_mint.decimals, + event_id, + event_bump, + )?; + } + + // The Event account itself is closed by its `close(dest = admin)` + // constraint. + close_vault( + &accounts.token_program, + &accounts.vault, + &accounts.admin, + &accounts.event, + event_id, + event_bump, + ) +} diff --git a/finance/betting-market/quasar/src/instructions/close_losing_bet.rs b/finance/betting-market/quasar/src/instructions/close_losing_bet.rs index c5be64ba5..7c59346a1 100644 --- a/finance/betting-market/quasar/src/instructions/close_losing_bet.rs +++ b/finance/betting-market/quasar/src/instructions/close_losing_bet.rs @@ -1,7 +1,7 @@ use quasar_lang::prelude::*; use crate::errors::BettingError; -use crate::state::{Bet, Event, EventStatus}; +use crate::state::{snapshot_event, Bet, Event, EventStatus}; // A losing bet pays nothing, but its account still holds rent. Closing it // returns the rent to the bettor. Winning bets must go through claim_winnings @@ -11,7 +11,7 @@ pub struct CloseLosingBetAccountConstraints { #[account(mut)] pub bettor: Signer, - #[account(address = Event::seeds(event.event_id.into()))] + #[account(mut, address = Event::seeds(event.event_id.into()))] pub event: Account, #[account( @@ -51,5 +51,14 @@ pub fn handle_close_losing_bet( BettingError::BetWon ); + // This Bet account closes when the handler returns, so the event's count + // of open bets drops by one. + let mut event = snapshot_event(&accounts.event); + event.open_bets = event + .open_bets + .checked_sub(1) + .ok_or(BettingError::MathOverflow)?; + accounts.event.set_inner(event); + Ok(()) } diff --git a/finance/betting-market/quasar/src/instructions/close_outcome.rs b/finance/betting-market/quasar/src/instructions/close_outcome.rs new file mode 100644 index 000000000..d36091d4c --- /dev/null +++ b/finance/betting-market/quasar/src/instructions/close_outcome.rs @@ -0,0 +1,54 @@ +use quasar_lang::prelude::*; + +use crate::errors::BettingError; +use crate::state::{snapshot_event, Config, Event, EventStatus, Outcome}; + +// Close one Outcome account of a finished event and return its rent to the +// admin, who paid it. Bet accounts derive their address from the outcome's, +// so every bet of the event must already be closed, and the Outcome accounts +// must all close before `close_event` can close the event. +#[derive(Accounts)] +pub struct CloseOutcomeAccountConstraints { + #[account(mut)] + pub admin: Signer, + + #[account(address = Config::seeds(), has_one(admin) @ BettingError::Unauthorized)] + pub config: Account, + + #[account(mut, address = Event::seeds(event.event_id.into()))] + pub event: Account, + + #[account( + mut, + close(dest = admin), + has_one(event), + address = Outcome::seeds(event.address(), outcome.index), + )] + pub outcome: Account, +} + +#[inline(always)] +pub fn handle_close_outcome( + accounts: &mut CloseOutcomeAccountConstraints, +) -> Result<(), ProgramError> { + require!( + accounts.event.status == EventStatus::Settled as u8 + || accounts.event.status == EventStatus::Cancelled as u8, + BettingError::EventNotFinished + ); + // A Bet account derives its address from its outcome's, so an outcome + // closed under an open bet would leave that bet's claim or refund with + // no outcome to check against. + require!( + u64::from(accounts.event.open_bets) == 0, + BettingError::BetsStillOpen + ); + + let mut event = snapshot_event(&accounts.event); + event.open_outcomes = event + .open_outcomes + .checked_sub(1) + .ok_or(BettingError::MathOverflow)?; + accounts.event.set_inner(event); + Ok(()) +} diff --git a/finance/betting-market/quasar/src/instructions/initialize_event.rs b/finance/betting-market/quasar/src/instructions/initialize_event.rs index f6bab4071..66113534c 100644 --- a/finance/betting-market/quasar/src/instructions/initialize_event.rs +++ b/finance/betting-market/quasar/src/instructions/initialize_event.rs @@ -65,6 +65,8 @@ pub fn handle_initialize_event( accounts.event.set_inner(EventInner { event_id, outcome_count: 0, + open_outcomes: 0, + open_bets: 0, total_pool: 0, // Starts as a draft: outcomes are added before anyone can bet. status: EventStatus::Draft as u8, diff --git a/finance/betting-market/quasar/src/instructions/mod.rs b/finance/betting-market/quasar/src/instructions/mod.rs index b86c58955..ae04c7025 100644 --- a/finance/betting-market/quasar/src/instructions/mod.rs +++ b/finance/betting-market/quasar/src/instructions/mod.rs @@ -2,7 +2,9 @@ pub mod add_outcome; pub mod cancel_event; pub mod claim_refund; pub mod claim_winnings; +pub mod close_event; pub mod close_losing_bet; +pub mod close_outcome; pub mod initialize_config; pub mod initialize_event; pub mod open_betting; @@ -14,7 +16,9 @@ pub use add_outcome::*; pub use cancel_event::*; pub use claim_refund::*; pub use claim_winnings::*; +pub use close_event::*; pub use close_losing_bet::*; +pub use close_outcome::*; pub use initialize_config::*; pub use initialize_event::*; pub use open_betting::*; diff --git a/finance/betting-market/quasar/src/instructions/place_bet.rs b/finance/betting-market/quasar/src/instructions/place_bet.rs index a77838250..765a67501 100644 --- a/finance/betting-market/quasar/src/instructions/place_bet.rs +++ b/finance/betting-market/quasar/src/instructions/place_bet.rs @@ -117,6 +117,12 @@ pub fn handle_place_bet( .total_pool .checked_add(amount) .ok_or(BettingError::MathOverflow)?; + if is_new_bet { + event.open_bets = event + .open_bets + .checked_add(1) + .ok_or(BettingError::MathOverflow)?; + } accounts.event.set_inner(event); Ok(()) diff --git a/finance/betting-market/quasar/src/instructions/shared.rs b/finance/betting-market/quasar/src/instructions/shared.rs index 24d386408..4ecc18e08 100644 --- a/finance/betting-market/quasar/src/instructions/shared.rs +++ b/finance/betting-market/quasar/src/instructions/shared.rs @@ -49,3 +49,26 @@ pub fn transfer_from_vault( .transfer_checked(vault, mint, to, event, amount, decimals) .invoke_signed(&seeds) } + +/// Close the (already empty) vault, signed by the Event PDA, sending its rent +/// to `destination`. +#[inline(always)] +pub fn close_vault( + token_program: &Program, + vault: &impl AsAccountView, + destination: &impl AsAccountView, + event: &impl AsAccountView, + event_id: u64, + event_bump: u8, +) -> Result<(), ProgramError> { + let event_id_bytes = event_id.to_le_bytes(); + let bump = [event_bump]; + let seeds = [ + Seed::from(EVENT_SEED), + Seed::from(event_id_bytes.as_ref()), + Seed::from(bump.as_ref()), + ]; + token_program + .close_account(vault, destination, event) + .invoke_signed(&seeds) +} diff --git a/finance/betting-market/quasar/src/lib.rs b/finance/betting-market/quasar/src/lib.rs index 350e74026..0facba90a 100644 --- a/finance/betting-market/quasar/src/lib.rs +++ b/finance/betting-market/quasar/src/lib.rs @@ -121,4 +121,19 @@ mod quasar_betting_market { pub fn open_betting(ctx: Ctx) -> Result<(), ProgramError> { instructions::open_betting::handle_open_betting(&mut ctx.accounts) } + + /// Admin closes one Outcome account of a settled or cancelled event once + /// every Bet account of the event is closed. The rent returns to the admin. + #[instruction(discriminator = 10)] + pub fn close_outcome(ctx: Ctx) -> Result<(), ProgramError> { + instructions::close_outcome::handle_close_outcome(&mut ctx.accounts) + } + + /// Admin closes a settled or cancelled event once its bets and outcomes are + /// closed: the vault's remaining dust goes to the fee recipient, and the + /// vault and Event account close, returning their rent to the admin. + #[instruction(discriminator = 11)] + pub fn close_event(ctx: Ctx) -> Result<(), ProgramError> { + instructions::close_event::handle_close_event(&mut ctx.accounts) + } } diff --git a/finance/betting-market/quasar/src/state/event.rs b/finance/betting-market/quasar/src/state/event.rs index be86ed816..72b433e25 100644 --- a/finance/betting-market/quasar/src/state/event.rs +++ b/finance/betting-market/quasar/src/state/event.rs @@ -31,6 +31,16 @@ pub enum EventStatus { pub struct Event { pub event_id: u64, pub outcome_count: u8, + /// How many Outcome accounts of this event are still open. `add_outcome` + /// adds one; `close_outcome` subtracts one. `close_event` requires zero, + /// because an Outcome left behind would carry its stakes into a later + /// event created with the same `event_id`. + pub open_outcomes: u8, + /// How many Bet accounts across every outcome are still open. `place_bet` + /// adds one when it creates a Bet account (a top-up reuses the account); + /// `claim_winnings`, `claim_refund` and `close_losing_bet` each subtract + /// one when they close one. `close_outcome` and `close_event` require zero. + pub open_bets: u64, /// Sum of every stake placed across all outcomes. pub total_pool: u64, pub status: u8, @@ -63,6 +73,8 @@ pub fn snapshot_event(event: &Account) -> EventInner { EventInner { event_id: u64::from(event.event_id), outcome_count: event.outcome_count, + open_outcomes: event.open_outcomes, + open_bets: u64::from(event.open_bets), total_pool: u64::from(event.total_pool), status: event.status, betting_closes_at: i64::from(event.betting_closes_at), diff --git a/finance/betting-market/quasar/src/state/outcome.rs b/finance/betting-market/quasar/src/state/outcome.rs index 461272721..47b8877e7 100644 --- a/finance/betting-market/quasar/src/state/outcome.rs +++ b/finance/betting-market/quasar/src/state/outcome.rs @@ -8,7 +8,9 @@ pub const MAX_LABEL_LEN: usize = 64; /// One possible result of an event (e.g. "Yes", "Team A wins"). `total_amount` /// is this outcome's share of the pool and the denominator for pro-rata payouts -/// when this outcome wins. +/// when this outcome wins. `bet_count` is the number of Bet accounts ever +/// created on this outcome; closing a bet does not lower it (the event's +/// `open_bets` is the count that does fall). /// /// PDA: `["outcome", event, index]`. #[account(discriminator = 3, set_inner)] diff --git a/finance/betting-market/quasar/src/tests.rs b/finance/betting-market/quasar/src/tests.rs index a1438866b..2c97a948b 100644 --- a/finance/betting-market/quasar/src/tests.rs +++ b/finance/betting-market/quasar/src/tests.rs @@ -7,9 +7,9 @@ use { crate::{ cpi::{ AddOutcomeInstruction, CancelEventInstruction, ClaimRefundInstruction, - ClaimWinningsInstruction, CloseLosingBetInstruction, InitializeConfigInstruction, - InitializeEventInstruction, OpenBettingInstruction, PlaceBetInstruction, - SettleEventInstruction, + ClaimWinningsInstruction, CloseEventInstruction, CloseLosingBetInstruction, + CloseOutcomeInstruction, InitializeConfigInstruction, InitializeEventInstruction, + OpenBettingInstruction, PlaceBetInstruction, SettleEventInstruction, }, errors::BettingError, state::{Bet, Config, Event, EventStatus, EventVaultPda, Outcome}, @@ -27,6 +27,8 @@ const BETTOR_B: Pubkey = Pubkey::new_from_array([6; 32]); const TOKEN_A: Pubkey = Pubkey::new_from_array([7; 32]); const TOKEN_B: Pubkey = Pubkey::new_from_array([8; 32]); const ATTACKER: Pubkey = Pubkey::new_from_array([9; 32]); +const BETTOR_C: Pubkey = Pubkey::new_from_array([10; 32]); +const TOKEN_C: Pubkey = Pubkey::new_from_array([11; 32]); const FEE_BPS: u16 = 100; // 1% const DECIMALS: u8 = 6; @@ -147,6 +149,13 @@ fn full_lifecycle_settles_and_pays_the_winner(test: &mut Test) { amount: STAKE_B, }) .succeeds(); + // Nothing closes before settlement. + test.send(CloseLosingBetInstruction { + bettor: BETTOR_A, + event_event_id_seed: EVENT_ID, + bet: bet_a, + }) + .fails_with(BettingError::EventNotSettled); // Betting has closed, so the event can be settled. test.warp_to_timestamp(BETTING_CLOSES_AT); test.send(SettleEventInstruction { @@ -157,6 +166,22 @@ fn full_lifecycle_settles_and_pays_the_winner(test: &mut Test) { winning_outcome_index: 1, }) .succeeds(); + // A winning bet is not closed as a loser, and a losing bet has nothing to + // claim. + test.send(CloseLosingBetInstruction { + bettor: BETTOR_B, + event_event_id_seed: EVENT_ID, + bet: bet_b, + }) + .fails_with(BettingError::BetWon); + test.send(ClaimWinningsInstruction { + bettor: BETTOR_A, + token_mint: TOKEN_MINT, + event_event_id_seed: EVENT_ID, + bet: bet_a, + bettor_token_account: TOKEN_A, + }) + .fails_with(BettingError::NothingToClaim); test.send(ClaimWinningsInstruction { bettor: BETTOR_B, token_mint: TOKEN_MINT, @@ -196,21 +221,20 @@ fn full_lifecycle_settles_and_pays_the_winner(test: &mut Test) { assert_eq!(test.tokens(vault), 0, "vault drained"); } -/// A cancelled event refunds each bettor their exact stake. +/// A cancelled event refunds each bettor their exact stake, and once every +/// refund is taken the admin closes the outcomes and the event. #[quasar_test] fn cancelled_event_refunds_the_exact_stake(test: &mut Test) { base_world(test); - test.add(Wallet::new().at(BETTOR_A)); - test.add( - TokenAccount::new(TOKEN_MINT, BETTOR_A) - .at(TOKEN_A) - .amount(STARTING_TOKENS), - ); + add_bettor(test, BETTOR_A, TOKEN_A); + add_bettor(test, BETTOR_B, TOKEN_B); let event = test.derive_pda(Event::seeds(EVENT_ID)); let vault = test.derive_pda(EventVaultPda::seeds(&event)); let outcome0 = test.derive_pda(Outcome::seeds(&event, 0)); + let outcome1 = test.derive_pda(Outcome::seeds(&event, 1)); let bet = test.derive_pda(Bet::seeds(&outcome0, &BETTOR_A)); + let bet_b = test.derive_pda(Bet::seeds(&outcome1, &BETTOR_B)); const STAKE: u64 = 250; @@ -250,6 +274,7 @@ fn cancelled_event_refunds_the_exact_stake(test: &mut Test) { amount: STAKE, }) .succeeds(); + test.send(bet_on(BETTOR_B, TOKEN_B, 1, STAKE)).succeeds(); test.send(CancelEventInstruction { admin: ADMIN, event_event_id_seed: EVENT_ID, @@ -271,10 +296,33 @@ fn cancelled_event_refunds_the_exact_stake(test: &mut Test) { ); // The bettor got their exact stake back and the bet closed. assert_eq!(test.tokens(TOKEN_A), STARTING_TOKENS); + + // B's refund is still outstanding, so the event's accounts stay. + test.send(close_outcome(ADMIN, 0)) + .fails_with(BettingError::BetsStillOpen); + assert_eq!(u64::from(test.read::(event).open_bets), 1); + + test.send(ClaimRefundInstruction { + bettor: BETTOR_B, + token_mint: TOKEN_MINT, + event_event_id_seed: EVENT_ID, + bet: bet_b, + bettor_token_account: TOKEN_B, + }) + .succeeds() + .is_closed(bet_b); + assert_eq!(test.tokens(TOKEN_B), STARTING_TOKENS); assert_eq!(test.tokens(vault), 0); + + // With every stake refunded the admin closes the outcomes and the event. + // The vault was empty, so the fee recipient receives nothing. + assert_eq!(u64::from(test.read::(event).open_bets), 0); + let vault_balance_at_close = close_outcomes_and_event(test, 2); + assert_eq!(vault_balance_at_close, 0); + assert_eq!(test.tokens(FEE_RECIPIENT_TOKEN), 0); } -/// Only the config admin may open an event. +/// Only the config admin may create an event, or add an outcome to one. #[quasar_test] fn initialize_event_rejects_a_non_admin_signer(test: &mut Test) { base_world(test); @@ -288,6 +336,16 @@ fn initialize_event_rejects_a_non_admin_signer(test: &mut Test) { description: "Team A vs Team B".to_string().into(), }) .fails_with(BettingError::Unauthorized); + + // Nor can anyone but the admin add an outcome to the admin's draft. + draft_event(test, &["Glowbugs 3", "The Quiet Floor"]); + test.send(AddOutcomeInstruction { + admin: ATTACKER, + event_event_id_seed: EVENT_ID, + event_outcome_count_seed: 2, + label: "Late entry".to_string().into(), + }) + .fails_with(BettingError::Unauthorized); } /// Create the event and add `labels` as its outcomes, leaving it a draft. @@ -322,6 +380,11 @@ fn add_bettor(test: &mut Test, bettor: Pubkey, token_account: Pubkey) { } fn bet(bettor: Pubkey, token_account: Pubkey, outcome: u8, amount: u64) -> PlaceBetInstruction { + bet_on(bettor, token_account, outcome, amount) +} + +/// The same as `bet`, for tests whose local `bet` is an address. +fn bet_on(bettor: Pubkey, token_account: Pubkey, outcome: u8, amount: u64) -> PlaceBetInstruction { PlaceBetInstruction { bettor, token_mint: TOKEN_MINT, @@ -332,6 +395,55 @@ fn bet(bettor: Pubkey, token_account: Pubkey, outcome: u8, amount: u64) -> Place } } +fn close_outcome(admin: Pubkey, outcome_index: u8) -> CloseOutcomeInstruction { + CloseOutcomeInstruction { + admin, + event_event_id_seed: EVENT_ID, + outcome_index_seed: outcome_index, + } +} + +fn close_event(admin: Pubkey) -> CloseEventInstruction { + CloseEventInstruction { + admin, + token_mint: TOKEN_MINT, + event_event_id_seed: EVENT_ID, + fee_recipient_token_account: FEE_RECIPIENT_TOKEN, + } +} + +/// Closes a finished event's Outcome accounts and then the event itself, as +/// the admin, asserting that each rent comes back to the admin and that every +/// closed account is gone. quasar-test charges no transaction fee, so the +/// admin's balance rises by exactly each account's rent. Returns what the +/// vault held before it closed. +fn close_outcomes_and_event(test: &mut Test, outcome_count: u8) -> u64 { + let event = test.derive_pda(Event::seeds(EVENT_ID)); + let vault = test.derive_pda(EventVaultPda::seeds(&event)); + + for index in 0..outcome_count { + let outcome = test.derive_pda(Outcome::seeds(&event, index)); + let outcome_rent = test.lamports(outcome); + let admin_before = test.lamports(ADMIN); + test.send(close_outcome(ADMIN, index)) + .succeeds() + .is_closed(outcome) + .has_lamports(ADMIN, admin_before + outcome_rent); + } + assert_eq!(test.read::(event).open_outcomes, 0); + + let vault_balance = test.tokens(vault); + let event_rent = test.lamports(event); + let vault_rent = test.lamports(vault); + let admin_before = test.lamports(ADMIN); + test.send(close_event(ADMIN)) + .succeeds() + .is_closed(event) + .is_closed(vault) + .has_lamports(ADMIN, admin_before + event_rent + vault_rent); + vault_balance +} + fn settle(winning_outcome_index: u8) -> SettleEventInstruction { SettleEventInstruction { admin: ADMIN, @@ -472,3 +584,203 @@ fn no_cap_on_open_bets_per_wallet(test: &mut Test) { STARTING_TOKENS - OUTCOME_COUNT as u64 * STAKE ); } + +/// The whole lifecycle through to every account closing. Stakes are chosen so +/// the pro-rata split does not divide evenly: outcome 0 pool 300 (A 100 in two +/// bets, B 200), outcome 1 pool 250 (C). Outcome 0 wins: losing pool 250, fee +/// 2 (1%), distributable 248; A gets floor(100 * 248 / 300) = 82 and B +/// floor(200 * 248 / 300) = 165, so one minor unit of dust stays in the vault +/// until `close_event` pays it to the fee recipient. +#[quasar_test] +fn close_event_pays_dust_to_fee_recipient_and_returns_rent(test: &mut Test) { + base_world(test); + add_bettor(test, BETTOR_A, TOKEN_A); + add_bettor(test, BETTOR_B, TOKEN_B); + add_bettor(test, BETTOR_C, TOKEN_C); + draft_event(test, &["Yes", "No"]); + test.send(open_betting(ADMIN)).succeeds(); + + let event = test.derive_pda(Event::seeds(EVENT_ID)); + let vault = test.derive_pda(EventVaultPda::seeds(&event)); + let outcome0 = test.derive_pda(Outcome::seeds(&event, 0)); + let outcome1 = test.derive_pda(Outcome::seeds(&event, 1)); + let bet_a = test.derive_pda(Bet::seeds(&outcome0, &BETTOR_A)); + let bet_b = test.derive_pda(Bet::seeds(&outcome0, &BETTOR_B)); + let bet_c = test.derive_pda(Bet::seeds(&outcome1, &BETTOR_C)); + + // A's second bet tops up the existing Bet account rather than creating + // another, so it counts once among the open bets. + test.send(bet(BETTOR_A, TOKEN_A, 0, 60)).succeeds(); + test.send(bet(BETTOR_A, TOKEN_A, 0, 40)).succeeds(); + test.send(bet(BETTOR_B, TOKEN_B, 0, 200)).succeeds(); + test.send(bet(BETTOR_C, TOKEN_C, 1, 250)).succeeds(); + let event_state = test.read::(event); + assert_eq!(u64::from(event_state.open_bets), 3); + assert_eq!(event_state.open_outcomes, 2); + assert_eq!(u64::from(event_state.total_pool), 550); + + test.warp_to_timestamp(BETTING_CLOSES_AT); + test.send(settle(0)).succeeds(); + assert_eq!(test.tokens(FEE_RECIPIENT_TOKEN), 2); + + // C closes the losing bet; A and B claim. + test.send(CloseLosingBetInstruction { + bettor: BETTOR_C, + event_event_id_seed: EVENT_ID, + bet: bet_c, + }) + .succeeds() + .is_closed(bet_c); + assert_eq!(u64::from(test.read::(event).open_bets), 2); + for (winner, winner_token, winner_bet) in + [(BETTOR_A, TOKEN_A, bet_a), (BETTOR_B, TOKEN_B, bet_b)] + { + test.send(ClaimWinningsInstruction { + bettor: winner, + token_mint: TOKEN_MINT, + event_event_id_seed: EVENT_ID, + bet: winner_bet, + bettor_token_account: winner_token, + }) + .succeeds() + .is_closed(winner_bet); + } + assert_eq!(test.tokens(TOKEN_A), STARTING_TOKENS - 100 + 182); + assert_eq!(test.tokens(TOKEN_B), STARTING_TOKENS - 200 + 365); + assert_eq!(u64::from(test.read::(event).open_bets), 0); + + // The two floors left one minor unit in the vault. + assert_eq!(test.tokens(vault), 1); + + // Outcome accounts close before the event does. + test.send(close_event(ADMIN)) + .fails_with(BettingError::OutcomesStillOpen); + + let vault_balance_at_close = close_outcomes_and_event(test, 2); + assert_eq!(vault_balance_at_close, 1); + // The dust joined the fee: 2 + 1. + assert_eq!(test.tokens(FEE_RECIPIENT_TOKEN), 3); +} + +/// A settled event keeps its accounts while any Bet account of it is open: +/// the claim and the losing-bet close both read the event and the outcome. +#[quasar_test] +fn close_event_refused_while_a_bet_is_open(test: &mut Test) { + base_world(test); + add_bettor(test, BETTOR_A, TOKEN_A); + add_bettor(test, BETTOR_C, TOKEN_C); + draft_event(test, &["Yes", "No"]); + test.send(open_betting(ADMIN)).succeeds(); + + let event = test.derive_pda(Event::seeds(EVENT_ID)); + let outcome0 = test.derive_pda(Outcome::seeds(&event, 0)); + let outcome1 = test.derive_pda(Outcome::seeds(&event, 1)); + let bet_a = test.derive_pda(Bet::seeds(&outcome0, &BETTOR_A)); + let bet_c = test.derive_pda(Bet::seeds(&outcome1, &BETTOR_C)); + + test.send(bet(BETTOR_A, TOKEN_A, 0, 100)).succeeds(); + test.send(bet(BETTOR_C, TOKEN_C, 1, 300)).succeeds(); + test.warp_to_timestamp(BETTING_CLOSES_AT); + test.send(settle(0)).succeeds(); + + // A has claimed; C's losing bet is still open. + test.send(ClaimWinningsInstruction { + bettor: BETTOR_A, + token_mint: TOKEN_MINT, + event_event_id_seed: EVENT_ID, + bet: bet_a, + bettor_token_account: TOKEN_A, + }) + .succeeds() + .is_closed(bet_a); + assert_eq!(u64::from(test.read::(event).open_bets), 1); + + test.send(close_event(ADMIN)) + .fails_with(BettingError::BetsStillOpen); + test.send(close_outcome(ADMIN, 1)) + .fails_with(BettingError::BetsStillOpen); + + // Once C closes the bet, everything else can close. + test.send(CloseLosingBetInstruction { + bettor: BETTOR_C, + event_event_id_seed: EVENT_ID, + bet: bet_c, + }) + .succeeds() + .is_closed(bet_c); + close_outcomes_and_event(test, 2); +} + +/// A draft or open event has not finished, so its accounts cannot close, +/// even when nobody has bet. Cancelling it is what lets it close. +#[quasar_test] +fn close_event_refused_while_event_is_open(test: &mut Test) { + base_world(test); + draft_event(test, &["Yes", "No"]); + + // As a draft... + test.send(close_event(ADMIN)) + .fails_with(BettingError::EventNotFinished); + test.send(close_outcome(ADMIN, 0)) + .fails_with(BettingError::EventNotFinished); + + // ...and once open. + test.send(open_betting(ADMIN)).succeeds(); + test.send(close_event(ADMIN)) + .fails_with(BettingError::EventNotFinished); + + // Cancelled with no bets, it closes straight away. + test.send(CancelEventInstruction { + admin: ADMIN, + event_event_id_seed: EVENT_ID, + }) + .succeeds(); + let vault_balance_at_close = close_outcomes_and_event(test, 2); + assert_eq!(vault_balance_at_close, 0); +} + +#[quasar_test] +fn only_admin_can_close_outcomes_and_event(test: &mut Test) { + base_world(test); + test.add(Wallet::new().at(ATTACKER)); + add_bettor(test, BETTOR_A, TOKEN_A); + add_bettor(test, BETTOR_C, TOKEN_C); + draft_event(test, &["Yes", "No"]); + test.send(open_betting(ADMIN)).succeeds(); + + let event = test.derive_pda(Event::seeds(EVENT_ID)); + let outcome0 = test.derive_pda(Outcome::seeds(&event, 0)); + let outcome1 = test.derive_pda(Outcome::seeds(&event, 1)); + let bet_a = test.derive_pda(Bet::seeds(&outcome0, &BETTOR_A)); + let bet_c = test.derive_pda(Bet::seeds(&outcome1, &BETTOR_C)); + + test.send(bet(BETTOR_A, TOKEN_A, 0, 100)).succeeds(); + test.send(bet(BETTOR_C, TOKEN_C, 1, 300)).succeeds(); + test.warp_to_timestamp(BETTING_CLOSES_AT); + test.send(settle(0)).succeeds(); + test.send(ClaimWinningsInstruction { + bettor: BETTOR_A, + token_mint: TOKEN_MINT, + event_event_id_seed: EVENT_ID, + bet: bet_a, + bettor_token_account: TOKEN_A, + }) + .succeeds(); + test.send(CloseLosingBetInstruction { + bettor: BETTOR_C, + event_event_id_seed: EVENT_ID, + bet: bet_c, + }) + .succeeds(); + + // Every bet is closed, so only the signer stands between the attacker and + // the rent. + test.send(close_outcome(ATTACKER, 0)) + .fails_with(BettingError::Unauthorized); + test.send(close_event(ATTACKER)) + .fails_with(BettingError::Unauthorized); + assert!(test.lamports(event) > 0, "the event stays open"); + assert!(test.lamports(outcome0) > 0, "the outcome stays open"); + + close_outcomes_and_event(test, 2); +} diff --git a/finance/lending/anchor-v1/CHANGELOG.md b/finance/lending/anchor-v1/CHANGELOG.md index b4eec5747..705e91362 100644 --- a/finance/lending/anchor-v1/CHANGELOG.md +++ b/finance/lending/anchor-v1/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## Unreleased (2026-10-04) + +Refuse a price the oracle itself is unsure of. `PriceFeed` gains a +`confidence` field, the publisher's confidence band in the mantissa's units +(Pyth's `price_message.conf`), which `set_price` now takes as a third +argument. `ReserveConfig` gains `max_confidence_bps`, the widest band, as a +fraction of the price, the reserve will value against; `validate()` rejects a +limit above 10,000 or of zero, since a zero limit would refuse every live +price and freeze every obligation holding the asset. `price_scaled` now takes +the reserve's limit and fails with the new `OracleConfidenceTooWide` when +`confidence × 10,000 > price × max_confidence_bps`, computed in `u128` with +checked arithmetic, so `refresh_obligation`, `borrow_obligation_liquidity`, +`withdraw_obligation_collateral` and `liquidate_obligation` all refuse the +price. Tested by +`borrow_against_collateral_priced_with_a_wide_band_is_rejected`, +`borrow_of_a_token_priced_with_a_wide_band_is_rejected`, +`confidence_band_at_the_limit_passes_and_one_unit_over_fails`, +`rejects_confidence_limit_wider_than_the_price` and +`rejects_zero_confidence_limit`. The test harness's `set_price` publishes a +0.1% band; `set_price_with_confidence` takes one. + +`deposit_redeem_round_trip_creates_no_value` now runs fifty deposit-and-redeem +round trips of 777,777,777 units against a reserve whose exchange rate interest +has moved off one-to-one, and asserts after each that the supplier holds no +more than they started with. It previously made one trip at a one-to-one rate, +where no rounding occurs. + ## 2026-09-23 Lock a minimum number of reserve shares. The first deposit now mints diff --git a/finance/lending/anchor-v1/README.md b/finance/lending/anchor-v1/README.md index 1aa953e33..5c46e4654 100644 --- a/finance/lending/anchor-v1/README.md +++ b/finance/lending/anchor-v1/README.md @@ -50,7 +50,8 @@ crosses the liquidation threshold and a liquidator can close part of the positio - **`Obligation`**: one per borrower per market: the share-token collateral posted and the liquidity borrowed, with cached quote-currency valuations. PDA seeds `["obligation", market, owner]`. -- **`PriceFeed`**: a price for one token (see Oracle below). +- **`PriceFeed`**: a price for one token, with the publisher's confidence + band (see Oracle below). ### Share tokens (the deposit claim) @@ -139,29 +140,50 @@ 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 ceiled), so dust cannot be extracted by repeated -round-trips. +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 +exchange rate interest has moved off one-to-one, and asserts the supplier never +holds more than they started with. ### Oracle `PriceFeed` mirrors an oracle price feed such as Pyth's: a signed mantissa, an -exponent (`price = mantissa * 10^exponent`), and the slot the price was written. -Freshness is checked in **slots** (`MAX_PRICE_STALENESS_SLOTS`), not wall-clock -time, plus one check slots alone cannot make: a cluster restart passes hours of -wall-clock time in zero slots, so `price_scaled` also rejects any price stamped -at or before the `LastRestartSlot` sysvar's slot, pausing valuation until the -publisher posts again. The feed PDA is seeded by `[b"price_feed", market, mint]` (scoped to a +exponent (`price = mantissa * 10^exponent`), a **confidence** band in the +mantissa's units (how far the publisher's sources disagree, half the width of +the interval around the price), and the slot the price was written. +`price_scaled` makes three checks before any handler values anything at the +price: + +- Freshness is checked in **slots** (`MAX_PRICE_STALENESS_SLOTS`), not + wall-clock time, plus one check slots alone cannot make: a cluster restart + passes hours of wall-clock time in zero slots, so a price stamped at or + before the `LastRestartSlot` sysvar's slot is rejected + (`PricePredatesRestart`), pausing valuation until the publisher posts again. +- The price must be positive (`InvalidOraclePrice`). +- The confidence band must be no wider than the reserve's + `max_confidence_bps` of the price, with the comparison multiplied out as + `confidence × 10,000 ≤ price × max_confidence_bps` in `u128` so no division + truncates. A wider band is refused with `OracleConfidenceTooWide`: the oracle + is reporting that it does not know the price, and a borrow, withdrawal or + liquidation valued at a number the oracle itself doubts would be lending + against a guess. The limit lives in the reserve config beside the loan-to-value + and liquidation thresholds, so a market owner tunes it per asset, and + `validate()` rejects a limit above 100% or of zero, which would refuse every + live price and freeze every obligation holding the asset. + +The feed PDA is seeded by `[b"price_feed", market, mint]` (scoped to a market, not to any individual) and only that market's `owner` may write it (`set_price` checks `has_one = owner`). So prices can't be squatted, a reserve trusts exactly its own market's feed for the mint, and isolated markets can price the same asset independently. -The `set_price` handler writes the feed directly so the LiteSVM tests are -deterministic; in production a reserve points at a Pyth price feed and the -program reads its `PriceUpdateV2` account instead, as -[`basics/pyth`](../../../basics/pyth/) does, after checking the update's -`feed_id`: `price_mantissa` is `price_message.price`, `exponent` is -`price_message.exponent`, and `last_updated_slot` is `posted_slot`. It should -also reject results whose confidence interval is too wide. +The `set_price` handler takes the mantissa, exponent and confidence band and +writes the feed directly so the LiteSVM tests are deterministic; in production +a reserve points at a Pyth price feed and the program reads its `PriceUpdateV2` +account instead, as [`basics/pyth`](../../../basics/pyth/) does, after checking +the update's `feed_id`: `price_mantissa` is `price_message.price`, `exponent` +is `price_message.exponent`, `confidence` is `price_message.conf`, and +`last_updated_slot` is `posted_slot`. ### Custody @@ -213,8 +235,9 @@ anchor test # or: cargo test - runs the LiteSVM integration tests `anchor build` (or `cargo build-sbf`) must run first: the tests load the compiled `target/deploy/lending.so` via `include_bytes!`. The suite covers the non-happy-path branches: interest accrual, borrowing at the LTV limit, stale -reserve/price rejection, liquidation of an unhealthy obligation after a price -move, the share-inflation guard, and rounding edges. +reserve/price rejection, a price whose confidence band is too wide, liquidation +of an unhealthy obligation after a price move, the share-inflation guard, and +rounding edges. ## FAQ @@ -228,7 +251,7 @@ Through a cumulative accumulation factor: `refresh_reserve` advances a per-reser ### How are prices fed into the program? -The admin `set_price` instruction handler stands in for an oracle feed in this example. `refresh_obligation` re-values collateral and debt at those prices before any borrow, withdraw, or liquidation is allowed, and stale reserves or prices are rejected. +The admin `set_price` instruction handler stands in for an oracle feed in this example, writing a price and its confidence band. `refresh_obligation` re-values collateral and debt at those prices before any borrow, withdraw, or liquidation is allowed; stale reserves, stale prices, and prices whose confidence band is wider than the reserve's `max_confidence_bps` are rejected. ### How is this lending program tested? diff --git a/finance/lending/anchor-v1/programs/lending/src/errors.rs b/finance/lending/anchor-v1/programs/lending/src/errors.rs index 0110d3d35..b25ecdb69 100644 --- a/finance/lending/anchor-v1/programs/lending/src/errors.rs +++ b/finance/lending/anchor-v1/programs/lending/src/errors.rs @@ -22,6 +22,8 @@ pub enum LendingError { PricePredatesRestart, #[msg("Price feed reported a non-positive price")] InvalidOraclePrice, + #[msg("Price feed is too uncertain to value against: its confidence band exceeds the reserve's limit")] + OracleConfidenceTooWide, #[msg("Borrow would exceed the obligation's allowed borrow value")] BorrowTooLarge, #[msg("Withdraw would leave the obligation undercollateralized")] diff --git a/finance/lending/anchor-v1/programs/lending/src/instructions/admin/set_price.rs b/finance/lending/anchor-v1/programs/lending/src/instructions/admin/set_price.rs index 8140aaaf9..a981bdc6c 100644 --- a/finance/lending/anchor-v1/programs/lending/src/instructions/admin/set_price.rs +++ b/finance/lending/anchor-v1/programs/lending/src/instructions/admin/set_price.rs @@ -15,6 +15,7 @@ pub fn handle_set_price( context: Context, price_mantissa: i128, exponent: i32, + confidence: u64, ) -> Result<()> { let feed = &mut context.accounts.price_feed; feed.market = context.accounts.lending_market.key(); @@ -22,6 +23,7 @@ pub fn handle_set_price( feed.bump = context.bumps.price_feed; feed.price_mantissa = price_mantissa; feed.exponent = exponent; + feed.confidence = confidence; feed.last_updated_slot = Clock::get()?.slot; Ok(()) } diff --git a/finance/lending/anchor-v1/programs/lending/src/instructions/borrow_obligation_liquidity.rs b/finance/lending/anchor-v1/programs/lending/src/instructions/borrow_obligation_liquidity.rs index 723a29d1b..e08a999e1 100644 --- a/finance/lending/anchor-v1/programs/lending/src/instructions/borrow_obligation_liquidity.rs +++ b/finance/lending/anchor-v1/programs/lending/src/instructions/borrow_obligation_liquidity.rs @@ -23,8 +23,12 @@ pub fn handle_borrow_obligation_liquidity( context.accounts.obligation.require_refreshed()?; context.accounts.reserve.require_refreshed()?; - let price_scaled = context.accounts.price_feed.price_scaled(slot)?; let decimals = context.accounts.reserve.liquidity_decimals; + let max_confidence_bps = context.accounts.reserve.config.max_confidence_bps; + let price_scaled = context + .accounts + .price_feed + .price_scaled(slot, max_confidence_bps)?; let borrow_value = market_value(liquidity_amount, decimals, price_scaled, Rounding::Up)?; let projected_borrowed_value = context diff --git a/finance/lending/anchor-v1/programs/lending/src/instructions/liquidate_obligation.rs b/finance/lending/anchor-v1/programs/lending/src/instructions/liquidate_obligation.rs index e79d541a8..e9e8b5130 100644 --- a/finance/lending/anchor-v1/programs/lending/src/instructions/liquidate_obligation.rs +++ b/finance/lending/anchor-v1/programs/lending/src/instructions/liquidate_obligation.rs @@ -44,8 +44,14 @@ pub fn handle_liquidate_obligation( LendingError::ObligationHealthy ); - let repay_price = context.accounts.repay_price_feed.price_scaled(slot)?; - let collateral_price = context.accounts.collateral_price_feed.price_scaled(slot)?; + let repay_price = context + .accounts + .repay_price_feed + .price_scaled(slot, repay_reserve.config.max_confidence_bps)?; + let collateral_price = context + .accounts + .collateral_price_feed + .price_scaled(slot, collateral_reserve.config.max_confidence_bps)?; let borrow_index = obligation.find_borrow(repay_reserve.key())?; let collateral_index = obligation.find_collateral(collateral_reserve.key())?; diff --git a/finance/lending/anchor-v1/programs/lending/src/instructions/refresh_obligation.rs b/finance/lending/anchor-v1/programs/lending/src/instructions/refresh_obligation.rs index b3b9db6fc..479978387 100644 --- a/finance/lending/anchor-v1/programs/lending/src/instructions/refresh_obligation.rs +++ b/finance/lending/anchor-v1/programs/lending/src/instructions/refresh_obligation.rs @@ -104,7 +104,8 @@ pub fn handle_refresh_obligation(context: Context) -> Result< /// Read the next `[reserve, price_feed]` pair from `remaining_accounts`, /// checking it matches the obligation's stored reserve, belongs to the /// obligation's lending market, and that both the reserve (refreshed this -/// slot) and the price (fresh) are usable. +/// slot) and the price (fresh, and no wider a confidence band than the +/// reserve allows) are usable. fn read_pair<'a, 'info>( accounts: &'a [AccountInfo<'info>], cursor: &mut usize, @@ -142,7 +143,7 @@ where LendingError::InvalidObligationAccount ); let price_feed = Account::::try_from(price_info)?; - let price_scaled = price_feed.price_scaled(slot)?; + let price_scaled = price_feed.price_scaled(slot, reserve.config.max_confidence_bps)?; Ok((reserve.into_inner(), price_scaled)) } diff --git a/finance/lending/anchor-v1/programs/lending/src/instructions/withdraw_obligation_collateral.rs b/finance/lending/anchor-v1/programs/lending/src/instructions/withdraw_obligation_collateral.rs index b31becfcb..1da78055b 100644 --- a/finance/lending/anchor-v1/programs/lending/src/instructions/withdraw_obligation_collateral.rs +++ b/finance/lending/anchor-v1/programs/lending/src/instructions/withdraw_obligation_collateral.rs @@ -22,7 +22,10 @@ pub fn handle_withdraw_obligation_collateral( context.accounts.obligation.require_refreshed()?; context.accounts.reserve.require_refreshed()?; let reserve = &context.accounts.reserve; - let price_scaled = context.accounts.price_feed.price_scaled(slot)?; + let price_scaled = context + .accounts + .price_feed + .price_scaled(slot, reserve.config.max_confidence_bps)?; let obligation = &mut context.accounts.obligation; let index = obligation.find_collateral(reserve.key())?; diff --git a/finance/lending/anchor-v1/programs/lending/src/lib.rs b/finance/lending/anchor-v1/programs/lending/src/lib.rs index b7c9ce394..d017bf867 100644 --- a/finance/lending/anchor-v1/programs/lending/src/lib.rs +++ b/finance/lending/anchor-v1/programs/lending/src/lib.rs @@ -44,8 +44,9 @@ pub mod lending { context: Context, price_mantissa: i128, exponent: i32, + confidence: u64, ) -> Result<()> { - instructions::handle_set_price(context, price_mantissa, exponent) + instructions::handle_set_price(context, price_mantissa, exponent, confidence) } pub fn refresh_reserve(context: Context) -> Result<()> { diff --git a/finance/lending/anchor-v1/programs/lending/src/state/price_feed.rs b/finance/lending/anchor-v1/programs/lending/src/state/price_feed.rs index eff98e870..1a67ef3dc 100644 --- a/finance/lending/anchor-v1/programs/lending/src/state/price_feed.rs +++ b/finance/lending/anchor-v1/programs/lending/src/state/price_feed.rs @@ -1,7 +1,7 @@ use anchor_lang::prelude::*; use solana_sysvar::last_restart_slot::LastRestartSlot; -use crate::constants::MAX_PRICE_STALENESS_SLOTS; +use crate::constants::{BPS_DENOMINATOR, MAX_PRICE_STALENESS_SLOTS}; use crate::errors::LendingError; use crate::math::price_mantissa_to_scaled; @@ -11,15 +11,15 @@ use crate::math::price_mantissa_to_scaled; /// write another's feed. Only the market's `owner` may write it (`set_price`). /// /// The layout mirrors an oracle price feed such as Pyth's: a signed mantissa -/// plus an exponent (`price = price_mantissa * 10^exponent`) and the slot the +/// plus an exponent (`price = price_mantissa * 10^exponent`), the publisher's +/// confidence interval in the same units as the mantissa, and the slot the /// value was written. In production this account would be a Pyth /// `PriceUpdateV2` owned by the Pyth Receiver program (`basics/pyth` reads /// one), mapped as `price_mantissa = price_message.price`, -/// `exponent = price_message.exponent` and `last_updated_slot = posted_slot`, -/// after checking the update's `feed_id`. Here the -/// `set_price` handler writes it directly so LiteSVM tests are deterministic. -/// A production read should also reject results whose confidence interval is -/// too wide; this stand-in has no confidence field to check. +/// `exponent = price_message.exponent`, `confidence = price_message.conf` and +/// `last_updated_slot = posted_slot`, after checking the update's `feed_id`. +/// Here the `set_price` handler writes it directly so LiteSVM tests are +/// deterministic. #[account] #[derive(InitSpace)] pub struct PriceFeed { @@ -32,6 +32,13 @@ pub struct PriceFeed { pub exponent: i32, + /// How far the publisher's price sources disagree, as half the width of + /// the interval around `price_mantissa`, in the mantissa's units (Pyth's + /// `conf`). A reserve refuses a price whose band is wider than its + /// `max_confidence_bps` of the price, so a market that has stopped + /// trading, or whose sources disagree, cannot be valued against. + pub confidence: u64, + pub last_updated_slot: u64, pub bump: u8, @@ -39,9 +46,10 @@ pub struct PriceFeed { impl PriceFeed { /// The price multiplied by FIXED_POINT_SCALE, after asserting the feed is - /// fresh and positive. Combining the price exponent with the fixed-point - /// scale (see `price_mantissa_to_scaled`) keeps the conversion overflow-safe. - pub fn price_scaled(&self, current_slot: u64) -> Result { + /// fresh, positive, and no less certain than `max_confidence_bps` of the + /// price allows. Combining the price exponent with the fixed-point scale + /// (see `price_mantissa_to_scaled`) keeps the conversion overflow-safe. + pub fn price_scaled(&self, current_slot: u64, max_confidence_bps: u16) -> Result { let age = current_slot .checked_sub(self.last_updated_slot) .ok_or(LendingError::MathOverflow)?; @@ -64,6 +72,22 @@ impl PriceFeed { require!(self.price_mantissa > 0, LendingError::InvalidOraclePrice); + // Reject a price the oracle itself is unsure of: the confidence band, + // as a fraction of the price, must not exceed the reserve's limit. + // `confidence` shares the mantissa's exponent, so the comparison needs + // no scaling; it is `confidence / price <= max_confidence_bps / 10_000` + // with both sides multiplied out so no division truncates. + let band_scaled = (self.confidence as u128) + .checked_mul(BPS_DENOMINATOR) + .ok_or(LendingError::MathOverflow)?; + let limit_scaled = (self.price_mantissa as u128) + .checked_mul(max_confidence_bps as u128) + .ok_or(LendingError::MathOverflow)?; + require!( + band_scaled <= limit_scaled, + LendingError::OracleConfidenceTooWide + ); + price_mantissa_to_scaled(self.price_mantissa as u128, self.exponent) } } 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 5e9c7550e..3a00e50f5 100644 --- a/finance/lending/anchor-v1/programs/lending/src/state/reserve.rs +++ b/finance/lending/anchor-v1/programs/lending/src/state/reserve.rs @@ -110,6 +110,11 @@ pub struct ReserveConfig { pub optimal_borrow_rate_bps: u16, /// Borrow APR at 100% utilization. pub max_borrow_rate_bps: u16, + /// Widest confidence band, as a fraction of the price, this reserve's + /// price feed may report and still be valued against. A wider band is + /// refused (`OracleConfidenceTooWide`), so no borrow, withdrawal or + /// liquidation is priced off a quote the oracle itself is unsure of. + pub max_confidence_bps: u16, } impl ReserveConfig { @@ -121,11 +126,16 @@ impl ReserveConfig { && within_bps(self.liquidation_bonus_bps) && within_bps(self.close_factor_bps) && within_bps(self.reserve_factor_bps) - && within_bps(self.optimal_utilization_bps), + && within_bps(self.optimal_utilization_bps) + && within_bps(self.max_confidence_bps), LendingError::InvalidConfig ); // A zero close factor would make every liquidation a no-op. require!(self.close_factor_bps > 0, LendingError::InvalidConfig); + // A zero confidence limit admits only a price whose band is zero, which + // an oracle reports for no traded asset, so the reserve could never be + // valued and every obligation holding it would be frozen. + require!(self.max_confidence_bps > 0, LendingError::InvalidConfig); // The kink must be strictly inside (0, 100%) so neither rate slope divides by zero. require!( self.optimal_utilization_bps > 0 diff --git a/finance/lending/anchor-v1/programs/lending/tests/common/mod.rs b/finance/lending/anchor-v1/programs/lending/tests/common/mod.rs index 70d9e3d92..004d7f388 100644 --- a/finance/lending/anchor-v1/programs/lending/tests/common/mod.rs +++ b/finance/lending/anchor-v1/programs/lending/tests/common/mod.rs @@ -23,7 +23,7 @@ use solana_kite::{ use solana_signer::Signer; use lending::constants::{ - LENDING_MARKET_SEED, LIQUIDITY_VAULT_SEED, MINIMUM_SHARES, OBLIGATION_SEED, + BPS_DENOMINATOR, LENDING_MARKET_SEED, LIQUIDITY_VAULT_SEED, MINIMUM_SHARES, OBLIGATION_SEED, OBLIGATION_SHARE_VAULT_SEED, PRICE_FEED_SEED, RESERVE_SEED, SHARE_MINT_SEED, }; use lending::state::{Obligation, Reserve, ReserveConfig}; @@ -43,6 +43,24 @@ pub fn cents(amount: u64) -> i128 { (amount as i128) * 10_000_000_000_000_000 } +/// Basis points of the price a reserve accepts as a confidence band under +/// `default_config`: 1%. +pub const DEFAULT_MAX_CONFIDENCE_BPS: u16 = 100; + +/// A confidence band of 0.1% of the price, in the mantissa's units: a tenth +/// of what `default_config` allows, so a price published with it is accepted. +pub fn narrow_band(price_mantissa: i128) -> u64 { + u64::try_from(price_mantissa / 1_000).expect("band fits the feed's u64 confidence") +} + +/// The widest confidence band `max_confidence_bps` lets a reserve value +/// `price_mantissa` against: `price * max_confidence_bps / 10_000`, exact for +/// the prices the tests use. +pub fn widest_accepted_band(price_mantissa: i128, max_confidence_bps: u16) -> u64 { + let band = price_mantissa * max_confidence_bps as i128 / BPS_DENOMINATOR as i128; + u64::try_from(band).expect("band fits the feed's u64 confidence") +} + pub fn ata(owner: &Pubkey, mint: &Pubkey) -> Pubkey { let ata_program: Pubkey = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" .parse() @@ -166,7 +184,13 @@ impl Env { ) -> ReserveHandle { let env_owner = self.owner.insecure_clone(); let mint = create_token_mint(&mut self.svm, &env_owner, decimals, None).unwrap(); - self.set_price_for(market_owner, market, mint, price_mantissa); + self.set_price_for( + market_owner, + market, + mint, + price_mantissa, + narrow_band(price_mantissa), + ); let reserve = pda(&[RESERVE_SEED, market.as_ref(), mint.as_ref()]); let share_mint = pda(&[SHARE_MINT_SEED, reserve.as_ref()]); @@ -259,10 +283,23 @@ impl Env { pda(&[PRICE_FEED_SEED, market.as_ref(), mint.as_ref()]) } + /// Publish a price for `mint` in the default market with a narrow + /// confidence band, one the default config accepts. pub fn set_price(&mut self, mint: Pubkey, price_mantissa: i128) { + self.set_price_with_confidence(mint, price_mantissa, narrow_band(price_mantissa)); + } + + /// Publish a price for `mint` in the default market with the given + /// confidence band, in the mantissa's units. + pub fn set_price_with_confidence( + &mut self, + mint: Pubkey, + price_mantissa: i128, + confidence: u64, + ) { let owner = self.owner.insecure_clone(); let market = self.market; - self.set_price_for(&owner, market, mint, price_mantissa); + self.set_price_for(&owner, market, mint, price_mantissa, confidence); } /// Publish a price for `mint` in `market`, signed by that market's `owner`. @@ -272,6 +309,7 @@ impl Env { market: Pubkey, mint: Pubkey, price_mantissa: i128, + confidence: u64, ) { let price_feed = self.price_feed_address(market, mint); let instruction = Instruction { @@ -287,6 +325,7 @@ impl Env { data: lending::instruction::SetPrice { price_mantissa, exponent: PRICE_EXPONENT, + confidence, } .data(), }; @@ -834,12 +873,13 @@ impl Env { } } -/// A reasonable default reserve config: 75% LTV, 80% liquidation threshold, -/// 5% bonus, 50% close factor, 10% reserve factor (program's cut of interest), -/// kink at 80% utilization, 2%/20%/150% APR curve. /// A tenth of a 365-day year, in seconds: long enough for interest to show. pub const TENTH_OF_A_YEAR: i64 = lending::constants::SECONDS_PER_YEAR as i64 / 10; +/// A reasonable default reserve config: 75% LTV, 80% liquidation threshold, +/// 5% bonus, 50% close factor, 10% reserve factor (program's cut of interest), +/// kink at 80% utilization, 2%/20%/150% APR curve, and a 1% confidence limit +/// on the price feed. pub fn default_config() -> ReserveConfig { ReserveConfig { loan_to_value_bps: 7_500, @@ -851,5 +891,6 @@ pub fn default_config() -> ReserveConfig { min_borrow_rate_bps: 200, optimal_borrow_rate_bps: 2_000, max_borrow_rate_bps: 15_000, + max_confidence_bps: DEFAULT_MAX_CONFIDENCE_BPS, } } diff --git a/finance/lending/anchor-v1/programs/lending/tests/test_borrow_repay.rs b/finance/lending/anchor-v1/programs/lending/tests/test_borrow_repay.rs index db2410837..1f4fc6200 100644 --- a/finance/lending/anchor-v1/programs/lending/tests/test_borrow_repay.rs +++ b/finance/lending/anchor-v1/programs/lending/tests/test_borrow_repay.rs @@ -1,6 +1,9 @@ mod common; -use common::{ata, default_config, dollars, Env, ReserveHandle}; +use common::{ + ata, cents, default_config, dollars, narrow_band, widest_accepted_band, Env, ReserveHandle, + DEFAULT_MAX_CONFIDENCE_BPS, +}; use solana_keypair::Keypair; use solana_signer::Signer; @@ -96,6 +99,111 @@ fn borrow_with_stale_price_feed_is_rejected() { assert!(result.unwrap_err().contains("StalePriceFeed")); } +/// A price the oracle is unsure of is no price to lend against. The +/// collateral feed is read by `refresh_obligation`, so the band is refused +/// there, before the borrow handler runs; once the publisher posts a narrow +/// band again the same borrow goes through. +#[test] +fn borrow_against_collateral_priced_with_a_wide_band_is_rejected() { + let (mut env, collateral, borrow, borrower, obligation) = setup(); + + // Twice the band the reserve allows. + let wide_band = 2 * widest_accepted_band(dollars(1), DEFAULT_MAX_CONFIDENCE_BPS); + env.set_price_with_confidence(collateral.mint, dollars(1), wide_band); + let result = env.try_borrow( + &borrower, + obligation, + &[&collateral], + &[], + &borrow, + 100_000_000, + ); + assert!( + result.unwrap_err().contains("OracleConfidenceTooWide"), + "collateral cannot be valued against a price the oracle is unsure of" + ); + + // Warp so the retry is not byte-identical to the rejected borrow. + env.warp_slots(1); + env.set_price(collateral.mint, dollars(1)); + env.set_price(borrow.mint, dollars(1)); + env.try_borrow( + &borrower, + obligation, + &[&collateral], + &[], + &borrow, + 100_000_000, + ) + .unwrap(); + assert_eq!( + env.token_balance(ata(&borrower.pubkey(), &borrow.mint)), + 100_000_000 + ); +} + +/// The borrowed token's feed is read by the borrow handler itself, which +/// applies the same limit, so a wide band on that side is refused too. +#[test] +fn borrow_of_a_token_priced_with_a_wide_band_is_rejected() { + let (mut env, collateral, borrow, borrower, obligation) = setup(); + + let wide_band = 2 * widest_accepted_band(dollars(1), DEFAULT_MAX_CONFIDENCE_BPS); + env.set_price_with_confidence(borrow.mint, dollars(1), wide_band); + let result = env.try_borrow( + &borrower, + obligation, + &[&collateral], + &[], + &borrow, + 100_000_000, + ); + assert!(result.unwrap_err().contains("OracleConfidenceTooWide")); +} + +/// The limit is inclusive: a band of exactly `max_confidence_bps` of the +/// price is accepted, and one unit wider is refused. At $1.23 the 1% limit is +/// 12,300,000,000,000,000 in the mantissa's units, with no rounding to hide +/// behind. +#[test] +fn confidence_band_at_the_limit_passes_and_one_unit_over_fails() { + let (mut env, collateral, borrow, borrower, obligation) = setup(); + let price = cents(123); + let limit = widest_accepted_band(price, DEFAULT_MAX_CONFIDENCE_BPS); + assert_eq!(limit, 12_300_000_000_000_000); + + env.set_price_with_confidence(collateral.mint, price, limit + 1); + let result = env.try_borrow( + &borrower, + obligation, + &[&collateral], + &[], + &borrow, + 100_000_000, + ); + assert!( + result.unwrap_err().contains("OracleConfidenceTooWide"), + "one unit past the limit must be refused" + ); + + env.warp_slots(1); + env.set_price_with_confidence(collateral.mint, price, limit); + env.set_price_with_confidence(borrow.mint, dollars(1), narrow_band(dollars(1))); + env.try_borrow( + &borrower, + obligation, + &[&collateral], + &[], + &borrow, + 100_000_000, + ) + .unwrap(); + assert_eq!( + env.token_balance(ata(&borrower.pubkey(), &borrow.mint)), + 100_000_000 + ); +} + /// A cluster restart passes hours of wall-clock time in zero slots, so a price /// published before the halt can still look fresh by slot count. The feed must /// reject it until the publisher posts again. 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 b8786c176..cd6e9cb4f 100644 --- a/finance/lending/anchor-v1/programs/lending/tests/test_reserve.rs +++ b/finance/lending/anchor-v1/programs/lending/tests/test_reserve.rs @@ -48,6 +48,48 @@ fn rejects_misordered_interest_rate_curve() { assert!(result.unwrap_err().contains("InvalidConfig")); } +/// The confidence limit is a fraction of the price, so it cannot exceed 100%. +#[test] +fn rejects_confidence_limit_wider_than_the_price() { + let mut env = Env::new(); + let usdc = env.add_reserve(6, common::dollars(1), default_config()); + + let mut bad = default_config(); + bad.max_confidence_bps = 10_001; + let result = env.try_update_config(&usdc, bad); + assert!( + result.unwrap_err().contains("InvalidConfig"), + "a confidence limit above 100% of the price must be rejected" + ); + + let mut widest = default_config(); + widest.max_confidence_bps = 10_000; + env.try_update_config(&usdc, widest).unwrap(); + assert_eq!(env.reserve(&usdc).config.max_confidence_bps, 10_000); +} + +/// A zero limit admits only a band of zero, which no live feed reports, so +/// the reserve could never be valued: the config is rejected rather than +/// freezing every obligation that holds the asset. +#[test] +fn rejects_zero_confidence_limit() { + let mut env = Env::new(); + let usdc = env.add_reserve(6, common::dollars(1), default_config()); + + let mut bad = default_config(); + bad.max_confidence_bps = 0; + let result = env.try_update_config(&usdc, bad); + assert!( + result.unwrap_err().contains("InvalidConfig"), + "a zero confidence limit must be rejected" + ); + + let mut tightest = default_config(); + tightest.max_confidence_bps = 1; + env.try_update_config(&usdc, tightest).unwrap(); + assert_eq!(env.reserve(&usdc).config.max_confidence_bps, 1); +} + #[test] fn accepts_valid_config_update() { let mut env = Env::new(); diff --git a/finance/lending/anchor-v1/programs/lending/tests/test_rounding.rs b/finance/lending/anchor-v1/programs/lending/tests/test_rounding.rs index 34aaf15c5..100a5b1e3 100644 --- a/finance/lending/anchor-v1/programs/lending/tests/test_rounding.rs +++ b/finance/lending/anchor-v1/programs/lending/tests/test_rounding.rs @@ -48,21 +48,60 @@ fn deposit_that_would_mint_zero_shares_is_rejected() { ); } +/// Deposits floor the shares minted and redemptions floor the liquidity paid +/// out, so a supplier who deposits and redeems over and over, at a size that +/// does not divide the exchange rate evenly, can never end up with more than +/// they started with. Interest accrues first so the rate is not one-to-one, +/// and the pool stays borrowed throughout so every trip rounds somewhere. #[test] fn deposit_redeem_round_trip_creates_no_value() { let mut env = Env::new(); + let collateral = env.add_reserve(6, dollars(1), default_config()); let usdc = env.add_reserve(6, dollars(1), default_config()); - let user = env.create_user(); - let amount = 777_777_777; - let liquidity_account = env.fund(&user, usdc.mint, amount); - let share_account = env.supply(&user, &usdc, amount); + let supplier = env.create_user(); + env.fund(&supplier, usdc.mint, 1_000_000_000); + env.supply(&supplier, &usdc, 1_000_000_000); - let shares = env.token_balance(share_account); - env.try_redeem(&user, &usdc, shares).unwrap(); + let borrower = env.create_user(); + env.fund(&borrower, collateral.mint, 1_000_000_000); + env.fund(&borrower, usdc.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], + &[], + &usdc, + 500_000_000, + ) + .unwrap(); + env.warp_seconds(common::TENTH_OF_A_YEAR); + env.refresh_reserve_only(&borrower, &usdc); + assert!(env.reserve(&usdc).borrow_accumulation_factor > lending::constants::FIXED_POINT_SCALE); - // The round trip must never return more than was put in. - assert!(env.token_balance(liquidity_account) <= amount); + let user = env.create_user(); + let amount = 777_777_777; + // Funded with more than one deposit's worth, so a trip that loses dust + // leaves enough for the next deposit of the full amount. + let funded = 2 * amount; + let liquidity_account = env.fund(&user, usdc.mint, funded); + let round_trips = 50; + + for trip in 1..=round_trips { + let share_account = env.supply(&user, &usdc, amount); + let shares = env.token_balance(share_account); + env.try_redeem(&user, &usdc, shares).unwrap(); + // The next trip's instructions are byte-identical to this one's. + env.svm.expire_blockhash(); + + assert!( + env.token_balance(liquidity_account) <= funded, + "round trip {trip} returned more than was put in" + ); + } } #[test] diff --git a/finance/lending/anchor-v1/programs/lending/tests/test_security.rs b/finance/lending/anchor-v1/programs/lending/tests/test_security.rs index d928d7e09..5d41185de 100644 --- a/finance/lending/anchor-v1/programs/lending/tests/test_security.rs +++ b/finance/lending/anchor-v1/programs/lending/tests/test_security.rs @@ -62,6 +62,8 @@ fn non_owner_cannot_write_market_price_feed() { data: lending::instruction::SetPrice { price_mantissa: common::dollars(1_000_000), // an absurd price exponent: common::PRICE_EXPONENT, + // Never read: the owner check rejects the instruction first. + confidence: 0, } .data(), }; diff --git a/finance/lending/anchor/CHANGELOG.md b/finance/lending/anchor/CHANGELOG.md index b4eec5747..705e91362 100644 --- a/finance/lending/anchor/CHANGELOG.md +++ b/finance/lending/anchor/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## Unreleased (2026-10-04) + +Refuse a price the oracle itself is unsure of. `PriceFeed` gains a +`confidence` field, the publisher's confidence band in the mantissa's units +(Pyth's `price_message.conf`), which `set_price` now takes as a third +argument. `ReserveConfig` gains `max_confidence_bps`, the widest band, as a +fraction of the price, the reserve will value against; `validate()` rejects a +limit above 10,000 or of zero, since a zero limit would refuse every live +price and freeze every obligation holding the asset. `price_scaled` now takes +the reserve's limit and fails with the new `OracleConfidenceTooWide` when +`confidence × 10,000 > price × max_confidence_bps`, computed in `u128` with +checked arithmetic, so `refresh_obligation`, `borrow_obligation_liquidity`, +`withdraw_obligation_collateral` and `liquidate_obligation` all refuse the +price. Tested by +`borrow_against_collateral_priced_with_a_wide_band_is_rejected`, +`borrow_of_a_token_priced_with_a_wide_band_is_rejected`, +`confidence_band_at_the_limit_passes_and_one_unit_over_fails`, +`rejects_confidence_limit_wider_than_the_price` and +`rejects_zero_confidence_limit`. The test harness's `set_price` publishes a +0.1% band; `set_price_with_confidence` takes one. + +`deposit_redeem_round_trip_creates_no_value` now runs fifty deposit-and-redeem +round trips of 777,777,777 units against a reserve whose exchange rate interest +has moved off one-to-one, and asserts after each that the supplier holds no +more than they started with. It previously made one trip at a one-to-one rate, +where no rounding occurs. + ## 2026-09-23 Lock a minimum number of reserve shares. The first deposit now mints diff --git a/finance/lending/anchor/README.md b/finance/lending/anchor/README.md index 9bb65e5b8..49cb4947c 100644 --- a/finance/lending/anchor/README.md +++ b/finance/lending/anchor/README.md @@ -50,7 +50,8 @@ crosses the liquidation threshold and a liquidator can close part of the positio - **`Obligation`**: one per borrower per market: the share-token collateral posted and the liquidity borrowed, with cached quote-currency valuations. PDA seeds `["obligation", market, owner]`. -- **`PriceFeed`**: a price for one token (see Oracle below). +- **`PriceFeed`**: a price for one token, with the publisher's confidence + band (see Oracle below). ### Share tokens (the deposit claim) @@ -139,29 +140,50 @@ 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 ceiled), so dust cannot be extracted by repeated -round-trips. +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 +exchange rate interest has moved off one-to-one, and asserts the supplier never +holds more than they started with. ### Oracle `PriceFeed` mirrors an oracle price feed such as Pyth's: a signed mantissa, an -exponent (`price = mantissa * 10^exponent`), and the slot the price was written. -Freshness is checked in **slots** (`MAX_PRICE_STALENESS_SLOTS`), not wall-clock -time, plus one check slots alone cannot make: a cluster restart passes hours of -wall-clock time in zero slots, so `price_scaled` also rejects any price stamped -at or before the `LastRestartSlot` sysvar's slot, pausing valuation until the -publisher posts again. The feed PDA is seeded by `[b"price_feed", market, mint]` (scoped to a +exponent (`price = mantissa * 10^exponent`), a **confidence** band in the +mantissa's units (how far the publisher's sources disagree, half the width of +the interval around the price), and the slot the price was written. +`price_scaled` makes three checks before any handler values anything at the +price: + +- Freshness is checked in **slots** (`MAX_PRICE_STALENESS_SLOTS`), not + wall-clock time, plus one check slots alone cannot make: a cluster restart + passes hours of wall-clock time in zero slots, so a price stamped at or + before the `LastRestartSlot` sysvar's slot is rejected + (`PricePredatesRestart`), pausing valuation until the publisher posts again. +- The price must be positive (`InvalidOraclePrice`). +- The confidence band must be no wider than the reserve's + `max_confidence_bps` of the price, with the comparison multiplied out as + `confidence × 10,000 ≤ price × max_confidence_bps` in `u128` so no division + truncates. A wider band is refused with `OracleConfidenceTooWide`: the oracle + is reporting that it does not know the price, and a borrow, withdrawal or + liquidation valued at a number the oracle itself doubts would be lending + against a guess. The limit lives in the reserve config beside the loan-to-value + and liquidation thresholds, so a market owner tunes it per asset, and + `validate()` rejects a limit above 100% or of zero, which would refuse every + live price and freeze every obligation holding the asset. + +The feed PDA is seeded by `[b"price_feed", market, mint]` (scoped to a market, not to any individual) and only that market's `owner` may write it (`set_price` checks `address = lending_market.owner`). So prices can't be squatted, a reserve trusts exactly its own market's feed for the mint, and isolated markets can price the same asset independently. -The `set_price` handler writes the feed directly so the LiteSVM tests are -deterministic; in production a reserve points at a Pyth price feed and the -program reads its `PriceUpdateV2` account instead, as -[`basics/pyth`](../../../basics/pyth/) does, after checking the update's -`feed_id`: `price_mantissa` is `price_message.price`, `exponent` is -`price_message.exponent`, and `last_updated_slot` is `posted_slot`. It should -also reject results whose confidence interval is too wide. +The `set_price` handler takes the mantissa, exponent and confidence band and +writes the feed directly so the LiteSVM tests are deterministic; in production +a reserve points at a Pyth price feed and the program reads its `PriceUpdateV2` +account instead, as [`basics/pyth`](../../../basics/pyth/) does, after checking +the update's `feed_id`: `price_mantissa` is `price_message.price`, `exponent` +is `price_message.exponent`, `confidence` is `price_message.conf`, and +`last_updated_slot` is `posted_slot`. ### Custody @@ -213,8 +235,9 @@ anchor test # or: cargo test - runs the LiteSVM integration tests `anchor build` (or `cargo build-sbf`) must run first: the tests load the compiled `target/deploy/lending.so` via `include_bytes!`. The suite covers the non-happy-path branches: interest accrual, borrowing at the LTV limit, stale -reserve/price rejection, liquidation of an unhealthy obligation after a price -move, the share-inflation guard, and rounding edges. +reserve/price rejection, a price whose confidence band is too wide, liquidation +of an unhealthy obligation after a price move, the share-inflation guard, and +rounding edges. ## FAQ @@ -228,7 +251,7 @@ Through a cumulative accumulation factor: `refresh_reserve` advances a per-reser ### How are prices fed into the program? -The admin `set_price` instruction handler stands in for an oracle feed in this example. `refresh_obligation` re-values collateral and debt at those prices before any borrow, withdraw, or liquidation is allowed, and stale reserves or prices are rejected. +The admin `set_price` instruction handler stands in for an oracle feed in this example, writing a price and its confidence band. `refresh_obligation` re-values collateral and debt at those prices before any borrow, withdraw, or liquidation is allowed; stale reserves, stale prices, and prices whose confidence band is wider than the reserve's `max_confidence_bps` are rejected. ### How is this lending program tested? diff --git a/finance/lending/anchor/programs/lending/src/errors.rs b/finance/lending/anchor/programs/lending/src/errors.rs index 0110d3d35..b25ecdb69 100644 --- a/finance/lending/anchor/programs/lending/src/errors.rs +++ b/finance/lending/anchor/programs/lending/src/errors.rs @@ -22,6 +22,8 @@ pub enum LendingError { PricePredatesRestart, #[msg("Price feed reported a non-positive price")] InvalidOraclePrice, + #[msg("Price feed is too uncertain to value against: its confidence band exceeds the reserve's limit")] + OracleConfidenceTooWide, #[msg("Borrow would exceed the obligation's allowed borrow value")] BorrowTooLarge, #[msg("Withdraw would leave the obligation undercollateralized")] diff --git a/finance/lending/anchor/programs/lending/src/instructions/admin/set_price.rs b/finance/lending/anchor/programs/lending/src/instructions/admin/set_price.rs index 02e3fac70..5590dd3f1 100644 --- a/finance/lending/anchor/programs/lending/src/instructions/admin/set_price.rs +++ b/finance/lending/anchor/programs/lending/src/instructions/admin/set_price.rs @@ -15,6 +15,7 @@ pub fn handle_set_price( context: &mut Context, price_mantissa: i128, exponent: i32, + confidence: u64, ) -> Result<()> { let feed = &mut context.accounts.price_feed; feed.market = *context.accounts.lending_market.address(); @@ -22,6 +23,7 @@ pub fn handle_set_price( feed.bump = context.bumps.price_feed; feed.price_mantissa = price_mantissa; feed.exponent = exponent; + feed.confidence = confidence; feed.last_updated_slot = Clock::get()?.slot; Ok(()) } diff --git a/finance/lending/anchor/programs/lending/src/instructions/borrow_obligation_liquidity.rs b/finance/lending/anchor/programs/lending/src/instructions/borrow_obligation_liquidity.rs index e3557b958..09b2e12e5 100644 --- a/finance/lending/anchor/programs/lending/src/instructions/borrow_obligation_liquidity.rs +++ b/finance/lending/anchor/programs/lending/src/instructions/borrow_obligation_liquidity.rs @@ -23,8 +23,12 @@ pub fn handle_borrow_obligation_liquidity( context.accounts.obligation.require_refreshed()?; context.accounts.reserve.require_refreshed()?; - let price_scaled = context.accounts.price_feed.price_scaled(slot)?; let decimals = context.accounts.reserve.liquidity_decimals; + let max_confidence_bps = context.accounts.reserve.config.max_confidence_bps; + let price_scaled = context + .accounts + .price_feed + .price_scaled(slot, max_confidence_bps)?; let borrow_value = market_value(liquidity_amount, decimals, price_scaled, Rounding::Up)?; let projected_borrowed_value = context diff --git a/finance/lending/anchor/programs/lending/src/instructions/liquidate_obligation.rs b/finance/lending/anchor/programs/lending/src/instructions/liquidate_obligation.rs index 8806e2aaa..4d893d1c0 100644 --- a/finance/lending/anchor/programs/lending/src/instructions/liquidate_obligation.rs +++ b/finance/lending/anchor/programs/lending/src/instructions/liquidate_obligation.rs @@ -45,8 +45,14 @@ pub fn handle_liquidate_obligation( LendingError::ObligationHealthy ); - let repay_price = context.accounts.repay_price_feed.price_scaled(slot)?; - let collateral_price = context.accounts.collateral_price_feed.price_scaled(slot)?; + let repay_price = context + .accounts + .repay_price_feed + .price_scaled(slot, repay_reserve.config.max_confidence_bps)?; + let collateral_price = context + .accounts + .collateral_price_feed + .price_scaled(slot, collateral_reserve.config.max_confidence_bps)?; let borrow_index = obligation.find_borrow(*repay_reserve.address())?; let collateral_index = obligation.find_collateral(*collateral_reserve.address())?; diff --git a/finance/lending/anchor/programs/lending/src/instructions/refresh_obligation.rs b/finance/lending/anchor/programs/lending/src/instructions/refresh_obligation.rs index d146adf2e..1ffc9712b 100644 --- a/finance/lending/anchor/programs/lending/src/instructions/refresh_obligation.rs +++ b/finance/lending/anchor/programs/lending/src/instructions/refresh_obligation.rs @@ -106,7 +106,8 @@ pub fn handle_refresh_obligation(context: &mut Context) -> Re /// Read the next `[reserve, price_feed]` pair from `remaining_accounts`, /// checking it matches the obligation's stored reserve, belongs to the /// obligation's lending market, and that both the reserve (refreshed this -/// slot) and the price (fresh) are usable. +/// slot) and the price (fresh, and no wider a confidence band than the +/// reserve allows) are usable. fn read_pair( accounts: &[AccountView], cursor: &mut usize, @@ -163,7 +164,7 @@ fn read_pair( >::get(&mut payload) .map_err(|_| LendingError::InvalidObligationAccount)? }; - let price_scaled = price_feed.price_scaled(slot)?; + let price_scaled = price_feed.price_scaled(slot, reserve.config.max_confidence_bps)?; Ok((reserve, price_scaled)) } diff --git a/finance/lending/anchor/programs/lending/src/instructions/withdraw_obligation_collateral.rs b/finance/lending/anchor/programs/lending/src/instructions/withdraw_obligation_collateral.rs index ac85e77c5..072d0a6ab 100644 --- a/finance/lending/anchor/programs/lending/src/instructions/withdraw_obligation_collateral.rs +++ b/finance/lending/anchor/programs/lending/src/instructions/withdraw_obligation_collateral.rs @@ -23,7 +23,10 @@ pub fn handle_withdraw_obligation_collateral( context.accounts.obligation.require_refreshed()?; context.accounts.reserve.require_refreshed()?; let reserve = &context.accounts.reserve; - let price_scaled = context.accounts.price_feed.price_scaled(slot)?; + let price_scaled = context + .accounts + .price_feed + .price_scaled(slot, reserve.config.max_confidence_bps)?; let obligation = &mut context.accounts.obligation; let index = obligation.find_collateral(*reserve.address())?; diff --git a/finance/lending/anchor/programs/lending/src/lib.rs b/finance/lending/anchor/programs/lending/src/lib.rs index 70d21f14e..b5d01d488 100644 --- a/finance/lending/anchor/programs/lending/src/lib.rs +++ b/finance/lending/anchor/programs/lending/src/lib.rs @@ -45,8 +45,9 @@ pub mod lending { context: &mut Context, price_mantissa: i128, exponent: i32, + confidence: u64, ) -> Result<()> { - instructions::handle_set_price(context, price_mantissa, exponent) + instructions::handle_set_price(context, price_mantissa, exponent, confidence) } pub fn refresh_reserve(context: &mut Context) -> Result<()> { diff --git a/finance/lending/anchor/programs/lending/src/state/price_feed.rs b/finance/lending/anchor/programs/lending/src/state/price_feed.rs index 719aaea91..c1719caac 100644 --- a/finance/lending/anchor/programs/lending/src/state/price_feed.rs +++ b/finance/lending/anchor/programs/lending/src/state/price_feed.rs @@ -1,6 +1,6 @@ use anchor_lang::prelude::*; -use crate::constants::MAX_PRICE_STALENESS_SLOTS; +use crate::constants::{BPS_DENOMINATOR, MAX_PRICE_STALENESS_SLOTS}; use crate::errors::LendingError; use crate::math::price_mantissa_to_scaled; @@ -10,15 +10,15 @@ use crate::math::price_mantissa_to_scaled; /// write another's feed. Only the market's `owner` may write it (`set_price`). /// /// The layout mirrors an oracle price feed such as Pyth's: a signed mantissa -/// plus an exponent (`price = price_mantissa * 10^exponent`) and the slot the +/// plus an exponent (`price = price_mantissa * 10^exponent`), the publisher's +/// confidence interval in the same units as the mantissa, and the slot the /// value was written. In production this account would be a Pyth /// `PriceUpdateV2` owned by the Pyth Receiver program (`basics/pyth` reads /// one), mapped as `price_mantissa = price_message.price`, -/// `exponent = price_message.exponent` and `last_updated_slot = posted_slot`, -/// after checking the update's `feed_id`. Here the -/// `set_price` handler writes it directly so LiteSVM tests are deterministic. -/// A production read should also reject results whose confidence interval is -/// too wide; this stand-in has no confidence field to check. +/// `exponent = price_message.exponent`, `confidence = price_message.conf` and +/// `last_updated_slot = posted_slot`, after checking the update's `feed_id`. +/// Here the `set_price` handler writes it directly so LiteSVM tests are +/// deterministic. #[account(borsh)] #[derive(InitSpace)] pub struct PriceFeed { @@ -31,6 +31,13 @@ pub struct PriceFeed { pub exponent: i32, + /// How far the publisher's price sources disagree, as half the width of + /// the interval around `price_mantissa`, in the mantissa's units (Pyth's + /// `conf`). A reserve refuses a price whose band is wider than its + /// `max_confidence_bps` of the price, so a market that has stopped + /// trading, or whose sources disagree, cannot be valued against. + pub confidence: u64, + pub last_updated_slot: u64, pub bump: u8, @@ -38,9 +45,10 @@ pub struct PriceFeed { impl PriceFeed { /// The price multiplied by FIXED_POINT_SCALE, after asserting the feed is - /// fresh and positive. Combining the price exponent with the fixed-point - /// scale (see `price_mantissa_to_scaled`) keeps the conversion overflow-safe. - pub fn price_scaled(&self, current_slot: u64) -> Result { + /// fresh, positive, and no less certain than `max_confidence_bps` of the + /// price allows. Combining the price exponent with the fixed-point scale + /// (see `price_mantissa_to_scaled`) keeps the conversion overflow-safe. + pub fn price_scaled(&self, current_slot: u64, max_confidence_bps: u16) -> Result { let age = current_slot .checked_sub(self.last_updated_slot) .ok_or(LendingError::MathOverflow)?; @@ -63,6 +71,22 @@ impl PriceFeed { require!(self.price_mantissa > 0, LendingError::InvalidOraclePrice); + // Reject a price the oracle itself is unsure of: the confidence band, + // as a fraction of the price, must not exceed the reserve's limit. + // `confidence` shares the mantissa's exponent, so the comparison needs + // no scaling; it is `confidence / price <= max_confidence_bps / 10_000` + // with both sides multiplied out so no division truncates. + let band_scaled = (self.confidence as u128) + .checked_mul(BPS_DENOMINATOR) + .ok_or(LendingError::MathOverflow)?; + let limit_scaled = (self.price_mantissa as u128) + .checked_mul(max_confidence_bps as u128) + .ok_or(LendingError::MathOverflow)?; + require!( + band_scaled <= limit_scaled, + LendingError::OracleConfidenceTooWide + ); + price_mantissa_to_scaled(self.price_mantissa as u128, self.exponent) } } diff --git a/finance/lending/anchor/programs/lending/src/state/reserve.rs b/finance/lending/anchor/programs/lending/src/state/reserve.rs index 750aac839..be00ad7a6 100644 --- a/finance/lending/anchor/programs/lending/src/state/reserve.rs +++ b/finance/lending/anchor/programs/lending/src/state/reserve.rs @@ -112,6 +112,11 @@ pub struct ReserveConfig { pub optimal_borrow_rate_bps: u16, /// Borrow APR at 100% utilization. pub max_borrow_rate_bps: u16, + /// Widest confidence band, as a fraction of the price, this reserve's + /// price feed may report and still be valued against. A wider band is + /// refused (`OracleConfidenceTooWide`), so no borrow, withdrawal or + /// liquidation is priced off a quote the oracle itself is unsure of. + pub max_confidence_bps: u16, } impl ReserveConfig { @@ -123,11 +128,16 @@ impl ReserveConfig { && within_bps(self.liquidation_bonus_bps) && within_bps(self.close_factor_bps) && within_bps(self.reserve_factor_bps) - && within_bps(self.optimal_utilization_bps), + && within_bps(self.optimal_utilization_bps) + && within_bps(self.max_confidence_bps), LendingError::InvalidConfig ); // A zero close factor would make every liquidation a no-op. require!(self.close_factor_bps > 0, LendingError::InvalidConfig); + // A zero confidence limit admits only a price whose band is zero, which + // an oracle reports for no traded asset, so the reserve could never be + // valued and every obligation holding it would be frozen. + require!(self.max_confidence_bps > 0, LendingError::InvalidConfig); // The kink must be strictly inside (0, 100%) so neither rate slope divides by zero. require!( self.optimal_utilization_bps > 0 diff --git a/finance/lending/anchor/programs/lending/tests/common/mod.rs b/finance/lending/anchor/programs/lending/tests/common/mod.rs index 144a86e83..22f010063 100644 --- a/finance/lending/anchor/programs/lending/tests/common/mod.rs +++ b/finance/lending/anchor/programs/lending/tests/common/mod.rs @@ -18,7 +18,7 @@ use solana_kite::{ }; use lending::constants::{ - LENDING_MARKET_SEED, LIQUIDITY_VAULT_SEED, MINIMUM_SHARES, OBLIGATION_SEED, + BPS_DENOMINATOR, LENDING_MARKET_SEED, LIQUIDITY_VAULT_SEED, MINIMUM_SHARES, OBLIGATION_SEED, OBLIGATION_SHARE_VAULT_SEED, PRICE_FEED_SEED, RESERVE_SEED, SHARE_MINT_SEED, }; use lending::state::{Obligation, Reserve, ReserveConfig}; @@ -38,6 +38,24 @@ pub fn cents(amount: u64) -> i128 { (amount as i128) * 10_000_000_000_000_000 } +/// Basis points of the price a reserve accepts as a confidence band under +/// `default_config`: 1%. +pub const DEFAULT_MAX_CONFIDENCE_BPS: u16 = 100; + +/// A confidence band of 0.1% of the price, in the mantissa's units: a tenth +/// of what `default_config` allows, so a price published with it is accepted. +pub fn narrow_band(price_mantissa: i128) -> u64 { + u64::try_from(price_mantissa / 1_000).expect("band fits the feed's u64 confidence") +} + +/// The widest confidence band `max_confidence_bps` lets a reserve value +/// `price_mantissa` against: `price * max_confidence_bps / 10_000`, exact for +/// the prices the tests use. +pub fn widest_accepted_band(price_mantissa: i128, max_confidence_bps: u16) -> u64 { + let band = price_mantissa * max_confidence_bps as i128 / BPS_DENOMINATOR as i128; + u64::try_from(band).expect("band fits the feed's u64 confidence") +} + pub fn ata(owner: &Address, mint: &Address) -> Address { let ata_program: Address = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" .parse() @@ -178,7 +196,13 @@ impl Env { ) -> ReserveHandle { let env_owner = self.owner.insecure_clone(); let mint = create_token_mint(&mut self.svm, &env_owner, decimals, None).unwrap(); - self.set_price_for(market_owner, market, mint, price_mantissa); + self.set_price_for( + market_owner, + market, + mint, + price_mantissa, + narrow_band(price_mantissa), + ); let reserve = pda(&[RESERVE_SEED, market.as_ref(), mint.as_ref()]); let share_mint = pda(&[SHARE_MINT_SEED, reserve.as_ref()]); @@ -267,10 +291,23 @@ impl Env { pda(&[PRICE_FEED_SEED, market.as_ref(), mint.as_ref()]) } + /// Publish a price for `mint` in the default market with a narrow + /// confidence band, one the default config accepts. pub fn set_price(&mut self, mint: Address, price_mantissa: i128) { + self.set_price_with_confidence(mint, price_mantissa, narrow_band(price_mantissa)); + } + + /// Publish a price for `mint` in the default market with the given + /// confidence band, in the mantissa's units. + pub fn set_price_with_confidence( + &mut self, + mint: Address, + price_mantissa: i128, + confidence: u64, + ) { let owner = self.owner.insecure_clone(); let market = self.market; - self.set_price_for(&owner, market, mint, price_mantissa); + self.set_price_for(&owner, market, mint, price_mantissa, confidence); } /// Publish a price for `mint` in `market`, signed by that market's `owner`. @@ -280,6 +317,7 @@ impl Env { market: Address, mint: Address, price_mantissa: i128, + confidence: u64, ) { let price_feed = self.price_feed_address(market, mint); let instruction = Instruction { @@ -295,6 +333,7 @@ impl Env { data: lending::instruction::SetPrice { price_mantissa, exponent: PRICE_EXPONENT, + confidence, } .data(), }; @@ -842,12 +881,13 @@ impl Env { } } -/// A reasonable default reserve config: 75% LTV, 80% liquidation threshold, -/// 5% bonus, 50% close factor, 10% reserve factor (program's cut of interest), -/// kink at 80% utilization, 2%/20%/150% APR curve. /// A tenth of a 365-day year, in seconds: long enough for interest to show. pub const TENTH_OF_A_YEAR: i64 = lending::constants::SECONDS_PER_YEAR as i64 / 10; +/// A reasonable default reserve config: 75% LTV, 80% liquidation threshold, +/// 5% bonus, 50% close factor, 10% reserve factor (program's cut of interest), +/// kink at 80% utilization, 2%/20%/150% APR curve, and a 1% confidence limit +/// on the price feed. pub fn default_config() -> ReserveConfig { ReserveConfig { loan_to_value_bps: 7_500, @@ -859,5 +899,6 @@ pub fn default_config() -> ReserveConfig { min_borrow_rate_bps: 200, optimal_borrow_rate_bps: 2_000, max_borrow_rate_bps: 15_000, + max_confidence_bps: DEFAULT_MAX_CONFIDENCE_BPS, } } diff --git a/finance/lending/anchor/programs/lending/tests/test_borrow_repay.rs b/finance/lending/anchor/programs/lending/tests/test_borrow_repay.rs index 8f173c4f4..28da94961 100644 --- a/finance/lending/anchor/programs/lending/tests/test_borrow_repay.rs +++ b/finance/lending/anchor/programs/lending/tests/test_borrow_repay.rs @@ -3,7 +3,10 @@ mod common; use anchor_v2_testing::{Keypair, Signer}; use lending::errors::LendingError; -use common::{ata, default_config, dollars, Env, ReserveHandle}; +use common::{ + ata, cents, default_config, dollars, narrow_band, widest_accepted_band, Env, ReserveHandle, + DEFAULT_MAX_CONFIDENCE_BPS, +}; /// One market with a collateral reserve and a separately-supplied borrow /// reserve, plus a borrower who has posted 1000 units of collateral (value @@ -94,6 +97,107 @@ fn borrow_with_stale_price_feed_is_rejected() { common::assert_program_error!(result, LendingError::StalePriceFeed); } +/// A price the oracle is unsure of is no price to lend against. The +/// collateral feed is read by `refresh_obligation`, so the band is refused +/// there, before the borrow handler runs; once the publisher posts a narrow +/// band again the same borrow goes through. +#[test] +fn borrow_against_collateral_priced_with_a_wide_band_is_rejected() { + let (mut env, collateral, borrow, borrower, obligation) = setup(); + + // Twice the band the reserve allows. + let wide_band = 2 * widest_accepted_band(dollars(1), DEFAULT_MAX_CONFIDENCE_BPS); + env.set_price_with_confidence(collateral.mint, dollars(1), wide_band); + let result = env.try_borrow( + &borrower, + obligation, + &[&collateral], + &[], + &borrow, + 100_000_000, + ); + // The collateral cannot be valued against a price the oracle is unsure of. + common::assert_program_error!(result, LendingError::OracleConfidenceTooWide); + + // Warp so the retry is not byte-identical to the rejected borrow. + env.warp_slots(1); + env.set_price(collateral.mint, dollars(1)); + env.set_price(borrow.mint, dollars(1)); + env.try_borrow( + &borrower, + obligation, + &[&collateral], + &[], + &borrow, + 100_000_000, + ) + .unwrap(); + assert_eq!( + env.token_balance(ata(&borrower.pubkey(), &borrow.mint)), + 100_000_000 + ); +} + +/// The borrowed token's feed is read by the borrow handler itself, which +/// applies the same limit, so a wide band on that side is refused too. +#[test] +fn borrow_of_a_token_priced_with_a_wide_band_is_rejected() { + let (mut env, collateral, borrow, borrower, obligation) = setup(); + + let wide_band = 2 * widest_accepted_band(dollars(1), DEFAULT_MAX_CONFIDENCE_BPS); + env.set_price_with_confidence(borrow.mint, dollars(1), wide_band); + let result = env.try_borrow( + &borrower, + obligation, + &[&collateral], + &[], + &borrow, + 100_000_000, + ); + common::assert_program_error!(result, LendingError::OracleConfidenceTooWide); +} + +/// The limit is inclusive: a band of exactly `max_confidence_bps` of the +/// price is accepted, and one unit wider is refused. At $1.23 the 1% limit is +/// 12,300,000,000,000,000 in the mantissa's units, with no rounding to hide +/// behind. +#[test] +fn confidence_band_at_the_limit_passes_and_one_unit_over_fails() { + let (mut env, collateral, borrow, borrower, obligation) = setup(); + let price = cents(123); + let limit = widest_accepted_band(price, DEFAULT_MAX_CONFIDENCE_BPS); + assert_eq!(limit, 12_300_000_000_000_000); + + env.set_price_with_confidence(collateral.mint, price, limit + 1); + let result = env.try_borrow( + &borrower, + obligation, + &[&collateral], + &[], + &borrow, + 100_000_000, + ); + // One unit past the limit must be refused. + common::assert_program_error!(result, LendingError::OracleConfidenceTooWide); + + env.warp_slots(1); + env.set_price_with_confidence(collateral.mint, price, limit); + env.set_price_with_confidence(borrow.mint, dollars(1), narrow_band(dollars(1))); + env.try_borrow( + &borrower, + obligation, + &[&collateral], + &[], + &borrow, + 100_000_000, + ) + .unwrap(); + assert_eq!( + env.token_balance(ata(&borrower.pubkey(), &borrow.mint)), + 100_000_000 + ); +} + /// A cluster restart passes hours of wall-clock time in zero slots, so a price /// published before the halt can still look fresh by slot count. The feed must /// reject it until the publisher posts again. diff --git a/finance/lending/anchor/programs/lending/tests/test_reserve.rs b/finance/lending/anchor/programs/lending/tests/test_reserve.rs index 1c0f5a9cf..0e38bdfe0 100644 --- a/finance/lending/anchor/programs/lending/tests/test_reserve.rs +++ b/finance/lending/anchor/programs/lending/tests/test_reserve.rs @@ -47,6 +47,44 @@ fn rejects_misordered_interest_rate_curve() { common::assert_program_error!(result, LendingError::InvalidConfig); } +/// The confidence limit is a fraction of the price, so it cannot exceed 100%. +#[test] +fn rejects_confidence_limit_wider_than_the_price() { + let mut env = Env::new(); + let usdc = env.add_reserve(6, common::dollars(1), default_config()); + + let mut bad = default_config(); + bad.max_confidence_bps = 10_001; + let result = env.try_update_config(&usdc, bad); + // A confidence limit above 100% of the price must be rejected. + common::assert_program_error!(result, LendingError::InvalidConfig); + + let mut widest = default_config(); + widest.max_confidence_bps = 10_000; + env.try_update_config(&usdc, widest).unwrap(); + assert_eq!(env.reserve(&usdc).config.max_confidence_bps, 10_000); +} + +/// A zero limit admits only a band of zero, which no live feed reports, so +/// the reserve could never be valued: the config is rejected rather than +/// freezing every obligation that holds the asset. +#[test] +fn rejects_zero_confidence_limit() { + let mut env = Env::new(); + let usdc = env.add_reserve(6, common::dollars(1), default_config()); + + let mut bad = default_config(); + bad.max_confidence_bps = 0; + let result = env.try_update_config(&usdc, bad); + // A zero confidence limit must be rejected. + common::assert_program_error!(result, LendingError::InvalidConfig); + + let mut tightest = default_config(); + tightest.max_confidence_bps = 1; + env.try_update_config(&usdc, tightest).unwrap(); + assert_eq!(env.reserve(&usdc).config.max_confidence_bps, 1); +} + #[test] fn accepts_valid_config_update() { let mut env = Env::new(); diff --git a/finance/lending/anchor/programs/lending/tests/test_rounding.rs b/finance/lending/anchor/programs/lending/tests/test_rounding.rs index cc0e2d0fe..63ae5674d 100644 --- a/finance/lending/anchor/programs/lending/tests/test_rounding.rs +++ b/finance/lending/anchor/programs/lending/tests/test_rounding.rs @@ -47,21 +47,60 @@ fn deposit_that_would_mint_zero_shares_is_rejected() { common::assert_program_error!(result, LendingError::DepositTooSmall); } +/// Deposits floor the shares minted and redemptions floor the liquidity paid +/// out, so a supplier who deposits and redeems over and over, at a size that +/// does not divide the exchange rate evenly, can never end up with more than +/// they started with. Interest accrues first so the rate is not one-to-one, +/// and the pool stays borrowed throughout so every trip rounds somewhere. #[test] fn deposit_redeem_round_trip_creates_no_value() { let mut env = Env::new(); + let collateral = env.add_reserve(6, dollars(1), default_config()); let usdc = env.add_reserve(6, dollars(1), default_config()); - let user = env.create_user(); - let amount = 777_777_777; - let liquidity_account = env.fund(&user, usdc.mint, amount); - let share_account = env.supply(&user, &usdc, amount); + let supplier = env.create_user(); + env.fund(&supplier, usdc.mint, 1_000_000_000); + env.supply(&supplier, &usdc, 1_000_000_000); - let shares = env.token_balance(share_account); - env.try_redeem(&user, &usdc, shares).unwrap(); + let borrower = env.create_user(); + env.fund(&borrower, collateral.mint, 1_000_000_000); + env.fund(&borrower, usdc.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], + &[], + &usdc, + 500_000_000, + ) + .unwrap(); + env.warp_seconds(common::TENTH_OF_A_YEAR); + env.refresh_reserve_only(&borrower, &usdc); + assert!(env.reserve(&usdc).borrow_accumulation_factor > lending::constants::FIXED_POINT_SCALE); - // The round trip must never return more than was put in. - assert!(env.token_balance(liquidity_account) <= amount); + let user = env.create_user(); + let amount = 777_777_777; + // Funded with more than one deposit's worth, so a trip that loses dust + // leaves enough for the next deposit of the full amount. + let funded = 2 * amount; + let liquidity_account = env.fund(&user, usdc.mint, funded); + let round_trips = 50; + + for trip in 1..=round_trips { + let share_account = env.supply(&user, &usdc, amount); + let shares = env.token_balance(share_account); + env.try_redeem(&user, &usdc, shares).unwrap(); + // The next trip's instructions are byte-identical to this one's. + env.svm.expire_blockhash(); + + assert!( + env.token_balance(liquidity_account) <= funded, + "round trip {trip} returned more than was put in" + ); + } } #[test] diff --git a/finance/lending/anchor/programs/lending/tests/test_security.rs b/finance/lending/anchor/programs/lending/tests/test_security.rs index f889196f0..ae6dfe9c9 100644 --- a/finance/lending/anchor/programs/lending/tests/test_security.rs +++ b/finance/lending/anchor/programs/lending/tests/test_security.rs @@ -60,6 +60,8 @@ fn non_owner_cannot_write_market_price_feed() { data: lending::instruction::SetPrice { price_mantissa: common::dollars(1_000_000), // an absurd price exponent: common::PRICE_EXPONENT, + // Never read: the owner check rejects the instruction first. + confidence: 0, } .data(), }; diff --git a/finance/lending/quasar/CHANGELOG.md b/finance/lending/quasar/CHANGELOG.md index a57ed3d67..766d01459 100644 --- a/finance/lending/quasar/CHANGELOG.md +++ b/finance/lending/quasar/CHANGELOG.md @@ -1,5 +1,37 @@ # Changelog +## [Unreleased] (2026-10-04) + +### Changed + +- Refuse a price the oracle itself is unsure of. `PriceFeed` gains a + `confidence` field, the publisher's confidence band in the mantissa's units + (Pyth's `price_message.conf`), which `set_price` now takes as a third + argument. `Reserve` gains `max_confidence_bps`, the widest band, as a + fraction of the price, the reserve will value against, which + `initialize_reserve` takes as its last config argument; `validate_config` + rejects a limit above 10,000 or of zero, since a zero limit would refuse + every live price and freeze every obligation holding the asset. + `price_scaled` now takes the reserve's limit and fails with the new + `OracleConfidenceTooWide` when + `confidence × 10,000 > price × max_confidence_bps`, computed in `u128` with + checked arithmetic, so `borrow_obligation_liquidity`, + `withdraw_obligation_collateral` and `liquidate_obligation` all refuse the + price. Tested by + `borrow_against_collateral_priced_with_a_wide_band_is_rejected`, + `borrow_of_a_token_priced_with_a_wide_band_is_rejected`, + `confidence_band_at_the_limit_passes_and_one_unit_over_fails`, + `rejects_confidence_limit_wider_than_the_price` and + `rejects_zero_confidence_limit`. The test harness's `set_price` publishes a + 0.1% band; `set_price_with_confidence` takes one. + +### Added + +- `deposit_redeem_round_trip_creates_no_value` runs fifty deposit-and-redeem + round trips of 777,777,777 units against a reserve whose exchange rate + interest has moved off one-to-one, and asserts after each that the supplier + holds no more than they started with. + ## [2026-09-23] ### Changed diff --git a/finance/lending/quasar/README.md b/finance/lending/quasar/README.md index 9ae8d0a5f..21ec8f8d2 100644 --- a/finance/lending/quasar/README.md +++ b/finance/lending/quasar/README.md @@ -48,11 +48,35 @@ Everything else mirrors the Anchor version. deposited share amount, plus the borrow reserve and scaled debt. PDA: `["obligation", market, owner]`. - **`PriceFeed`**: an oracle-shaped price (`mantissa * 10^exponent` - + slot). PDA: `["price_feed", market, mint]`: scoped to a market, not to any - individual; only the market's `owner` may write it, so prices can't be squatted - and each market prices its own assets. `set_price` writes it directly for - deterministic tests; in production a reserve points at a real Pyth - price feed. Freshness is checked in slots. + + slot), with the publisher's **confidence** band in the mantissa's units + (how far the publisher's sources disagree, half the width of the interval + around the price). PDA: `["price_feed", market, mint]`: scoped to a market, + not to any individual; only the market's `owner` may write it, so prices + can't be squatted and each market prices its own assets. `set_price` takes + the mantissa, exponent and confidence band and writes the feed directly for + deterministic tests; in production a reserve points at a Pyth price feed and + the program reads its `PriceUpdateV2` account instead, after checking the + update's `feed_id`: `price_mantissa` is `price_message.price`, `exponent` is + `price_message.exponent`, `confidence` is `price_message.conf`, and + `last_updated_slot` is `posted_slot`. `price_scaled` makes three checks + before any handler values anything at the price: + - Freshness is checked in **slots** (`MAX_PRICE_STALENESS_SLOTS`), not + wall-clock time, plus one check slots alone cannot make: a cluster restart + passes hours of wall-clock time in zero slots, so a price stamped at or + before the `LastRestartSlot` sysvar's slot is rejected + (`PricePredatesRestart`), pausing valuation until the publisher posts again. + - The price must be positive (`InvalidOraclePrice`). + - The confidence band must be no wider than the reserve's + `max_confidence_bps` of the price, with the comparison multiplied out as + `confidence × 10,000 ≤ price × max_confidence_bps` in `u128` so no division + truncates. A wider band is refused with `OracleConfidenceTooWide`: the + oracle is reporting that it does not know the price, and a borrow, + withdrawal or liquidation valued at a number the oracle itself doubts would + be lending against a guess. The limit is a reserve config field beside the + loan-to-value and liquidation thresholds, so a market owner tunes it per + asset through `initialize_reserve`, and `validate_config` rejects a limit + above 100% or of zero, which would refuse every live price and freeze every + obligation holding the asset. - **Liquidation**: the close factor (max fraction of the debt one call repays) comes from the borrow reserve; the bonus from the collateral reserve. A repayment whose seizure would exceed the posted collateral fails with @@ -75,7 +99,11 @@ Everything else mirrors the Anchor version. `collect_program_fees`. That spread between the borrow and supply rates is how the owner earns. - **Integer-only math**: `u128`, scaled by `FIXED_POINT_SCALE` (10^18), every - conversion rounding in the program's favour. + conversion rounding in the program's favour, 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 exchange rate interest has moved off one-to-one, and asserts + the supplier never holds more than they started with. - **Interest on the wall clock**: a reserve's rate curve is 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 @@ -112,7 +140,8 @@ cargo test tests:: # runs the quasar-svm integration tests `cargo build-sbf` must run first: the tests load the compiled `target/deploy/quasar_lending.so` into `quasar-svm`. The suite drives the full lifecycle: supply/redeem (1:1 first deposit, less the withheld minimum), borrow up to the LTV limit (and -rejection beyond it), repay, interest accrual lifting the share value after time -passes, interest that follows seconds rather than slots (and charges nothing for a -timestamp behind the last accrual), and liquidation of an unhealthy position (with -a healthy position rejected). +rejection beyond it), a price whose confidence band is too wide, repay, interest +accrual lifting the share value after time passes, interest that follows seconds +rather than slots (and charges nothing for a timestamp behind the last accrual), +liquidation of an unhealthy position (with a healthy position rejected), and +rounding edges. diff --git a/finance/lending/quasar/src/error.rs b/finance/lending/quasar/src/error.rs index 51933e41f..09631899f 100644 --- a/finance/lending/quasar/src/error.rs +++ b/finance/lending/quasar/src/error.rs @@ -18,4 +18,7 @@ pub enum LendingError { LiquidationTooLarge, NothingToCollect, PricePredatesRestart, + /// The price feed is too uncertain to value against: its confidence band + /// exceeds the reserve's limit. + OracleConfidenceTooWide, } diff --git a/finance/lending/quasar/src/instructions/admin.rs b/finance/lending/quasar/src/instructions/admin.rs index bcc62953b..fe63ea225 100644 --- a/finance/lending/quasar/src/instructions/admin.rs +++ b/finance/lending/quasar/src/instructions/admin.rs @@ -87,6 +87,7 @@ impl InitializeReserve { min_borrow_rate_bps: u16, optimal_borrow_rate_bps: u16, max_borrow_rate_bps: u16, + max_confidence_bps: u16, bumps: &InitializeReserveBumps, ) -> Result<(), ProgramError> { validate_config( @@ -99,6 +100,7 @@ impl InitializeReserve { min_borrow_rate_bps, optimal_borrow_rate_bps, max_borrow_rate_bps, + max_confidence_bps, )?; let reserve_address = *self.reserve.address(); @@ -174,6 +176,7 @@ impl InitializeReserve { min_borrow_rate_bps, optimal_borrow_rate_bps, max_borrow_rate_bps, + max_confidence_bps, bump: bumps.reserve, }); Ok(()) @@ -204,6 +207,7 @@ impl SetPrice { &mut self, price_mantissa: i128, exponent: i32, + confidence: u64, bumps: &SetPriceBumps, ) -> Result<(), ProgramError> { // Prices are stamped with the slot: freshness is counted in slots. @@ -213,6 +217,7 @@ impl SetPrice { mint: *self.mint.address(), price_mantissa, exponent, + confidence, last_updated_slot: slot, bump: bumps.price_feed, }); diff --git a/finance/lending/quasar/src/instructions/position.rs b/finance/lending/quasar/src/instructions/position.rs index 6222a9c6d..4d787a521 100644 --- a/finance/lending/quasar/src/instructions/position.rs +++ b/finance/lending/quasar/src/instructions/position.rs @@ -203,7 +203,7 @@ impl BorrowObligationLiquidity { let collateral_value = market_value( u64::try_from(collateral_liquidity).map_err(|_| LendingError::MathOverflow)?, collateral.liquidity_decimals, - price_scaled(&self.collateral_price, slot)?, + price_scaled(&self.collateral_price, slot, collateral.max_confidence_bps)?, Rounding::Down, )?; let allowed = mul_div_floor( @@ -213,7 +213,7 @@ impl BorrowObligationLiquidity { )?; // Existing debt value + the new borrow, both rounded up. - let borrow_price = price_scaled(&self.borrow_price, slot)?; + let borrow_price = price_scaled(&self.borrow_price, slot, borrow.max_confidence_bps)?; let existing_debt = current_debt( obligation.borrowed_principal, borrow.borrow_accumulation_factor, @@ -419,7 +419,7 @@ impl WithdrawObligationCollateral { let remaining_value = market_value( u64::try_from(remaining_liquidity).map_err(|_| LendingError::MathOverflow)?, collateral.liquidity_decimals, - price_scaled(&self.collateral_price, slot)?, + price_scaled(&self.collateral_price, slot, collateral.max_confidence_bps)?, Rounding::Down, )?; let allowed = mul_div_floor( @@ -449,7 +449,7 @@ impl WithdrawObligationCollateral { market_value( debt, borrow.liquidity_decimals, - price_scaled(&self.borrow_price, slot)?, + price_scaled(&self.borrow_price, slot, borrow.max_confidence_bps)?, Rounding::Up, )? } else { @@ -548,8 +548,9 @@ impl LiquidateObligation { accrue(&mut borrow, slot, timestamp)?; let mut obligation = snapshot_obligation(&self.obligation); - let collateral_price = price_scaled(&self.collateral_price, slot)?; - let borrow_price = price_scaled(&self.borrow_price, slot)?; + let collateral_price = + price_scaled(&self.collateral_price, slot, collateral.max_confidence_bps)?; + let borrow_price = price_scaled(&self.borrow_price, slot, borrow.max_confidence_bps)?; // Health: unhealthy when debt value exceeds collateral value * liquidation threshold. let collateral_total = net_total_liquidity( diff --git a/finance/lending/quasar/src/lib.rs b/finance/lending/quasar/src/lib.rs index cf4537781..764f9ab8a 100644 --- a/finance/lending/quasar/src/lib.rs +++ b/finance/lending/quasar/src/lib.rs @@ -55,6 +55,7 @@ mod quasar_lending { min_borrow_rate_bps: u16, optimal_borrow_rate_bps: u16, max_borrow_rate_bps: u16, + max_confidence_bps: u16, ) -> Result<(), ProgramError> { ctx.accounts.run( loan_to_value_bps, @@ -66,6 +67,7 @@ mod quasar_lending { min_borrow_rate_bps, optimal_borrow_rate_bps, max_borrow_rate_bps, + max_confidence_bps, &ctx.bumps, ) } @@ -75,8 +77,10 @@ mod quasar_lending { ctx: Ctx, price_mantissa: i128, exponent: i32, + confidence: u64, ) -> Result<(), ProgramError> { - ctx.accounts.run(price_mantissa, exponent, &ctx.bumps) + ctx.accounts + .run(price_mantissa, exponent, confidence, &ctx.bumps) } #[instruction(discriminator = 3)] diff --git a/finance/lending/quasar/src/logic.rs b/finance/lending/quasar/src/logic.rs index a20ea677d..2ed1aa7bf 100644 --- a/finance/lending/quasar/src/logic.rs +++ b/finance/lending/quasar/src/logic.rs @@ -47,6 +47,7 @@ pub fn snapshot_reserve(reserve: &Account) -> ReserveInner { min_borrow_rate_bps: u16::from(reserve.min_borrow_rate_bps), optimal_borrow_rate_bps: u16::from(reserve.optimal_borrow_rate_bps), max_borrow_rate_bps: u16::from(reserve.max_borrow_rate_bps), + max_confidence_bps: u16::from(reserve.max_confidence_bps), bump: reserve.bump, } } @@ -124,8 +125,14 @@ pub fn accrue( Ok(()) } -/// The feed's price scaled by `FIXED_POINT_SCALE`, after staleness + positivity checks. -pub fn price_scaled(feed: &Account, slot: u64) -> Result { +/// The feed's price scaled by `FIXED_POINT_SCALE`, after asserting the feed +/// is fresh, positive, and no less certain than `max_confidence_bps` of the +/// price allows. +pub fn price_scaled( + feed: &Account, + slot: u64, + max_confidence_bps: u16, +) -> Result { let last_updated = u64::from(feed.last_updated_slot); let age = slot .checked_sub(last_updated) @@ -146,6 +153,23 @@ pub fn price_scaled(feed: &Account, slot: u64) -> Result 0, LendingError::InvalidOraclePrice); + + // Reject a price the oracle itself is unsure of: the confidence band, as + // a fraction of the price, must not exceed the reserve's limit. + // `confidence` shares the mantissa's exponent, so the comparison needs no + // scaling; it is `confidence / price <= max_confidence_bps / 10_000` with + // both sides multiplied out so no division truncates. + let band_scaled = (u64::from(feed.confidence) as u128) + .checked_mul(BPS_DENOMINATOR) + .ok_or(LendingError::MathOverflow)?; + let limit_scaled = (mantissa as u128) + .checked_mul(max_confidence_bps as u128) + .ok_or(LendingError::MathOverflow)?; + require!( + band_scaled <= limit_scaled, + LendingError::OracleConfidenceTooWide + ); + price_mantissa_to_scaled(mantissa as u128, i32::from(feed.exponent)) } diff --git a/finance/lending/quasar/src/math.rs b/finance/lending/quasar/src/math.rs index f7f4f634f..83ea74708 100644 --- a/finance/lending/quasar/src/math.rs +++ b/finance/lending/quasar/src/math.rs @@ -247,6 +247,7 @@ pub fn validate_config( min_borrow_rate_bps: u16, optimal_borrow_rate_bps: u16, max_borrow_rate_bps: u16, + max_confidence_bps: u16, ) -> Result<(), ProgramError> { let within = |value: u16| (value as u128) <= BPS_DENOMINATOR; require!( @@ -255,10 +256,16 @@ pub fn validate_config( && within(liquidation_bonus_bps) && within(close_factor_bps) && within(reserve_factor_bps) - && within(optimal_utilization_bps), + && within(optimal_utilization_bps) + && within(max_confidence_bps), LendingError::InvalidConfig ); + // A zero close factor would make every liquidation a no-op. require!(close_factor_bps > 0, LendingError::InvalidConfig); + // A zero confidence limit admits only a price whose band is zero, which + // an oracle reports for no traded asset, so the reserve could never be + // valued and every obligation holding it would be frozen. + require!(max_confidence_bps > 0, LendingError::InvalidConfig); require!( optimal_utilization_bps > 0 && (optimal_utilization_bps as u128) < BPS_DENOMINATOR, LendingError::InvalidConfig diff --git a/finance/lending/quasar/src/state.rs b/finance/lending/quasar/src/state.rs index de782cc68..23e63b1b0 100644 --- a/finance/lending/quasar/src/state.rs +++ b/finance/lending/quasar/src/state.rs @@ -55,6 +55,11 @@ pub struct Reserve { pub min_borrow_rate_bps: u16, pub optimal_borrow_rate_bps: u16, pub max_borrow_rate_bps: u16, + /// Widest confidence band, as a fraction of the price, this reserve's + /// price feed may report and still be valued against. A wider band is + /// refused (`OracleConfidenceTooWide`), so no borrow, withdrawal or + /// liquidation is priced off a quote the oracle itself is unsure of. + pub max_confidence_bps: u16, pub bump: u8, } @@ -72,12 +77,15 @@ pub struct Obligation { pub bump: u8, } -/// Oracle-shaped price feed, a mantissa and exponent like Pyth's. -/// PDA: `["price_feed", market, mint]` — scoped to a market (not to any -/// individual); only the market's `owner` may write it, so prices can't be -/// squatted and each market prices its own assets. +/// Oracle-shaped price feed: a mantissa, an exponent and a confidence band +/// like Pyth's. PDA: `["price_feed", market, mint]` — scoped to a market (not +/// to any individual); only the market's `owner` may write it, so prices +/// can't be squatted and each market prices its own assets. /// `price = price_mantissa * 10^exponent`; freshness is checked in slots. In -/// production this account would be a real Pyth price feed. +/// production this account would be a Pyth `PriceUpdateV2`, mapped as +/// `price_mantissa = price_message.price`, `exponent = price_message.exponent`, +/// `confidence = price_message.conf` and `last_updated_slot = posted_slot`, +/// after checking the update's `feed_id`. #[account(discriminator = 4, set_inner)] #[seeds(b"price_feed", market: Address, mint: Address)] pub struct PriceFeed { @@ -85,6 +93,12 @@ pub struct PriceFeed { pub mint: Address, pub price_mantissa: i128, pub exponent: i32, + /// How far the publisher's price sources disagree, as half the width of + /// the interval around `price_mantissa`, in the mantissa's units (Pyth's + /// `conf`). A reserve refuses a price whose band is wider than its + /// `max_confidence_bps` of the price, so a market that has stopped + /// trading, or whose sources disagree, cannot be valued against. + pub confidence: u64, pub last_updated_slot: u64, pub bump: u8, } diff --git a/finance/lending/quasar/src/tests.rs b/finance/lending/quasar/src/tests.rs index 547b947a7..d538769c7 100644 --- a/finance/lending/quasar/src/tests.rs +++ b/finance/lending/quasar/src/tests.rs @@ -6,6 +6,7 @@ use { crate::{ + constants::{BPS_DENOMINATOR, FIXED_POINT_SCALE}, cpi::{ BorrowObligationLiquidityInstruction, DepositObligationCollateralInstruction, DepositReserveLiquidityInstruction, InitializeLendingMarketInstruction, @@ -13,6 +14,7 @@ use { LiquidateObligationInstruction, RedeemReserveCollateralInstruction, RepayObligationLiquidityInstruction, SetPriceInstruction, }, + error::LendingError, state::{LendingMarket, LiquidityVaultPda, Obligation, Reserve, ShareMintPda}, }, quasar_test::prelude::*, @@ -27,6 +29,25 @@ fn cents(amount: u64) -> i128 { (amount as i128) * 10_000_000_000_000_000 } +/// Basis points of the price a reserve accepts as a confidence band under +/// `initialize_reserve`: 1%. +const DEFAULT_MAX_CONFIDENCE_BPS: u16 = 100; + +/// A confidence band of 0.1% of the price, in the mantissa's units: a tenth +/// of what `initialize_reserve` allows, so a price published with it is +/// accepted. +fn narrow_band(price_mantissa: i128) -> u64 { + u64::try_from(price_mantissa / 1_000).expect("band fits the feed's u64 confidence") +} + +/// The widest confidence band `max_confidence_bps` lets a reserve value +/// `price_mantissa` against: `price * max_confidence_bps / 10_000`, exact for +/// the prices the tests use. +fn widest_accepted_band(price_mantissa: i128, max_confidence_bps: u16) -> u64 { + let band = price_mantissa * max_confidence_bps as i128 / BPS_DENOMINATOR as i128; + u64::try_from(band).expect("band fits the feed's u64 confidence") +} + const DECIMALS: u8 = 6; const UNIT: u64 = 1_000_000; // 1 token at 6 decimals @@ -61,6 +82,16 @@ const ATTACKER_COLLATERAL: Pubkey = Pubkey::new_from_array([23; 32]); const ATTACKER_COLLATERAL_SHARE: Pubkey = Pubkey::new_from_array([24; 32]); const VICTIM_BORROW: Pubkey = Pubkey::new_from_array([25; 32]); const VICTIM_BORROW_SHARE: Pubkey = Pubkey::new_from_array([26; 32]); +/// The supplier who runs the deposit-and-redeem round trips. +const TRIPPER: Pubkey = Pubkey::new_from_array([27; 32]); +const TRIPPER_BORROW: Pubkey = Pubkey::new_from_array([28; 32]); +const TRIPPER_BORROW_SHARE: Pubkey = Pubkey::new_from_array([29; 32]); +/// One deposit-and-redeem round trip's size: it does not divide the exchange +/// rate evenly once interest has accrued, so every trip rounds somewhere. +const ROUND_TRIP_AMOUNT: u64 = 777_777_777; +/// The round-tripper is funded with more than one deposit's worth, so a trip +/// that loses dust leaves enough for the next deposit of the full amount. +const ROUND_TRIP_FUNDING: u64 = 2 * ROUND_TRIP_AMOUNT; /// What the market owner deposits to open each reserve: the smallest first /// deposit that clears the withheld minimum, minting the owner one share. const OPENING_DEPOSIT: u64 = crate::constants::MINIMUM_SHARES + 1; @@ -106,7 +137,7 @@ fn pdas(test: &Test) -> Pdas { /// harness's world). fn base_world(test: &mut Test) -> Pdas { let w = pdas(test); - for wallet in [OWNER, SUPPLIER, BORROWER, LIQUIDATOR] { + for wallet in [OWNER, SUPPLIER, BORROWER, LIQUIDATOR, TRIPPER] { test.add(Wallet::new().at(wallet)); } for the_mint in [COLLATERAL_MINT, BORROW_MINT, QUOTE_MINT] { @@ -152,23 +183,57 @@ fn base_world(test: &mut Test) -> Pdas { .amount(OPENING_DEPOSIT), ); test.add(TokenAccount::new(w.collateral_share_mint, OWNER).at(OWNER_COLLATERAL_SHARE)); + test.add( + TokenAccount::new(BORROW_MINT, TRIPPER) + .at(TRIPPER_BORROW) + .amount(ROUND_TRIP_FUNDING), + ); + test.add(TokenAccount::new(w.borrow_share_mint, TRIPPER).at(TRIPPER_BORROW_SHARE)); w } +/// Publish a price for `the_mint` with a narrow confidence band, one the +/// default reserve config accepts. fn set_price(test: &mut Test, w: &Pdas, the_mint: Pubkey, mantissa: i128) { + set_price_with_confidence(test, w, the_mint, mantissa, narrow_band(mantissa)); +} + +/// Publish a price for `the_mint` with the given confidence band, in the +/// mantissa's units. +fn set_price_with_confidence( + test: &mut Test, + w: &Pdas, + the_mint: Pubkey, + mantissa: i128, + confidence: u64, +) { test.send(SetPriceInstruction { owner: OWNER, lending_market: w.market, mint: the_mint, price_mantissa: mantissa, exponent: EXP, + confidence, }) .succeeds(); } +/// Create a reserve with the default config and a 1% confidence limit on its +/// price feed. fn initialize_reserve(test: &mut Test, w: &Pdas, the_mint: Pubkey) { - // 75% LTV, 80% liquidation threshold, 5% bonus, 50% close factor, 10% - // reserve factor, kink 80%, 2% / 20% / 150% APR curve. + initialize_reserve_with_confidence_limit(test, w, the_mint, DEFAULT_MAX_CONFIDENCE_BPS) + .succeeds(); +} + +/// Create a reserve with the default config and the given confidence limit: +/// 75% LTV, 80% liquidation threshold, 5% bonus, 50% close factor, 10% +/// reserve factor, kink 80%, 2% / 20% / 150% APR curve. +fn initialize_reserve_with_confidence_limit( + test: &mut Test, + w: &Pdas, + the_mint: Pubkey, + max_confidence_bps: u16, +) -> Outcome { test.send(InitializeReserveInstruction { owner: OWNER, lending_market: w.market, @@ -182,8 +247,8 @@ fn initialize_reserve(test: &mut Test, w: &Pdas, the_mint: Pubkey) { min_borrow_rate_bps: 200, optimal_borrow_rate_bps: 2_000, max_borrow_rate_bps: 15_000, + max_confidence_bps, }) - .succeeds(); } /// Create the market and both reserves, then open each reserve with the @@ -231,14 +296,34 @@ fn setup_empty_markets(test: &mut Test, w: &Pdas) { } fn deposit_borrow_side(test: &mut Test, w: &Pdas, amount: u64) -> Outcome { + deposit_borrow_side_as( + test, + w, + SUPPLIER, + SUPPLIER_BORROW, + SUPPLIER_BORROW_SHARE, + amount, + ) +} + +/// `supplier` deposits `amount` into the borrow reserve from +/// `supplier_liquidity`, receiving shares in `supplier_share`. +fn deposit_borrow_side_as( + test: &mut Test, + w: &Pdas, + supplier: Pubkey, + supplier_liquidity: Pubkey, + supplier_share: Pubkey, + amount: u64, +) -> Outcome { test.send(DepositReserveLiquidityInstruction { - supplier: SUPPLIER, + supplier, reserve: w.borrow_reserve, liquidity_mint: BORROW_MINT, liquidity_vault: w.borrow_vault, share_mint: w.borrow_share_mint, - supplier_liquidity: SUPPLIER_BORROW, - supplier_share: SUPPLIER_BORROW_SHARE, + supplier_liquidity, + supplier_share, amount, }) } @@ -257,14 +342,34 @@ fn deposit_collateral_side(test: &mut Test, w: &Pdas, amount: u64) -> Outcome { } fn redeem(test: &mut Test, w: &Pdas, shares: u64) -> Outcome { + redeem_as( + test, + w, + SUPPLIER, + SUPPLIER_BORROW, + SUPPLIER_BORROW_SHARE, + shares, + ) +} + +/// `supplier` redeems `shares` of the borrow reserve from `supplier_share`, +/// receiving liquidity in `supplier_liquidity`. +fn redeem_as( + test: &mut Test, + w: &Pdas, + supplier: Pubkey, + supplier_liquidity: Pubkey, + supplier_share: Pubkey, + shares: u64, +) -> Outcome { test.send(RedeemReserveCollateralInstruction { - supplier: SUPPLIER, + supplier, reserve: w.borrow_reserve, liquidity_mint: BORROW_MINT, liquidity_vault: w.borrow_vault, share_mint: w.borrow_share_mint, - supplier_liquidity: SUPPLIER_BORROW, - supplier_share: SUPPLIER_BORROW_SHARE, + supplier_liquidity, + supplier_share, shares, }) } @@ -402,6 +507,165 @@ fn borrow_up_to_ltv_succeeds_and_beyond_fails(test: &mut Test) { ); } +/// A price the oracle is unsure of is no price to lend against. The borrow +/// handler reads the collateral feed first, so the band is refused there, +/// before the borrowed token is priced; once the publisher posts a narrow +/// band again the same borrow goes through. +#[quasar_test] +fn borrow_against_collateral_priced_with_a_wide_band_is_rejected(test: &mut Test) { + let w = base_world(test); + bootstrap_position(test, &w); + + // Twice the band the reserve allows. + let wide_band = 2 * widest_accepted_band(dollars(1), DEFAULT_MAX_CONFIDENCE_BPS); + set_price_with_confidence(test, &w, COLLATERAL_MINT, dollars(1), wide_band); + // The collateral cannot be valued against a price the oracle is unsure of. + borrow(test, &w, 100 * UNIT).fails_with(LendingError::OracleConfidenceTooWide); + + set_price(test, &w, COLLATERAL_MINT, dollars(1)); + set_price(test, &w, BORROW_MINT, dollars(1)); + borrow(test, &w, 100 * UNIT) + .succeeds() + .has_tokens(BORROWER_BORROW, 100 * UNIT); +} + +/// The borrowed token's feed is read by the same handler, which applies the +/// borrow reserve's limit, so a wide band on that side is refused too. +#[quasar_test] +fn borrow_of_a_token_priced_with_a_wide_band_is_rejected(test: &mut Test) { + let w = base_world(test); + bootstrap_position(test, &w); + + let wide_band = 2 * widest_accepted_band(dollars(1), DEFAULT_MAX_CONFIDENCE_BPS); + set_price_with_confidence(test, &w, BORROW_MINT, dollars(1), wide_band); + borrow(test, &w, 100 * UNIT).fails_with(LendingError::OracleConfidenceTooWide); +} + +/// The limit is inclusive: a band of exactly `max_confidence_bps` of the +/// price is accepted, and one unit wider is refused. At $1.23 the 1% limit is +/// 12,300,000,000,000,000 in the mantissa's units, with no rounding to hide +/// behind. +#[quasar_test] +fn confidence_band_at_the_limit_passes_and_one_unit_over_fails(test: &mut Test) { + let w = base_world(test); + bootstrap_position(test, &w); + let price = cents(123); + let limit = widest_accepted_band(price, DEFAULT_MAX_CONFIDENCE_BPS); + assert_eq!(limit, 12_300_000_000_000_000); + + set_price_with_confidence(test, &w, COLLATERAL_MINT, price, limit + 1); + // One unit past the limit must be refused. + borrow(test, &w, 100 * UNIT).fails_with(LendingError::OracleConfidenceTooWide); + + set_price_with_confidence(test, &w, COLLATERAL_MINT, price, limit); + set_price_with_confidence(test, &w, BORROW_MINT, dollars(1), narrow_band(dollars(1))); + borrow(test, &w, 100 * UNIT) + .succeeds() + .has_tokens(BORROWER_BORROW, 100 * UNIT); +} + +/// The confidence limit is a fraction of the price, so it cannot exceed 100%. +/// This port has no `update_reserve_config`, so the limit is checked where the +/// config is set, in `initialize_reserve`. +#[quasar_test] +fn rejects_confidence_limit_wider_than_the_price(test: &mut Test) { + let w = base_world(test); + test.send(InitializeLendingMarketInstruction { + owner: OWNER, + quote_mint: QUOTE_MINT, + market_id: MARKET_ID, + }) + .succeeds(); + set_price(test, &w, COLLATERAL_MINT, dollars(1)); + + // A confidence limit above 100% of the price must be rejected. + initialize_reserve_with_confidence_limit(test, &w, COLLATERAL_MINT, 10_001) + .fails_with(LendingError::InvalidConfig); + + initialize_reserve_with_confidence_limit(test, &w, COLLATERAL_MINT, 10_000).succeeds(); + let reserve = test.read::(w.collateral_reserve); + assert_eq!(u16::from(reserve.max_confidence_bps), 10_000); +} + +/// A zero limit admits only a band of zero, which no live feed reports, so +/// the reserve could never be valued: the config is rejected rather than +/// freezing every obligation that holds the asset. +#[quasar_test] +fn rejects_zero_confidence_limit(test: &mut Test) { + let w = base_world(test); + test.send(InitializeLendingMarketInstruction { + owner: OWNER, + quote_mint: QUOTE_MINT, + market_id: MARKET_ID, + }) + .succeeds(); + set_price(test, &w, COLLATERAL_MINT, dollars(1)); + + // A zero confidence limit must be rejected. + initialize_reserve_with_confidence_limit(test, &w, COLLATERAL_MINT, 0) + .fails_with(LendingError::InvalidConfig); + + initialize_reserve_with_confidence_limit(test, &w, COLLATERAL_MINT, 1).succeeds(); + let reserve = test.read::(w.collateral_reserve); + assert_eq!(u16::from(reserve.max_confidence_bps), 1); +} + +/// Deposits floor the shares minted and redemptions floor the liquidity paid +/// out, so a supplier who deposits and redeems over and over, at a size that +/// does not divide the exchange rate evenly, can never end up with more than +/// they started with. Interest accrues first so the rate is not one-to-one, +/// and the pool stays borrowed throughout so every trip rounds somewhere. +#[quasar_test] +fn deposit_redeem_round_trip_creates_no_value(test: &mut Test) { + let w = base_world(test); + bootstrap_position(test, &w); + borrow(test, &w, 500 * UNIT).succeeds(); + + // A tenth of a year passes. This port has no `refresh_reserve`: every + // handler that reads a reserve accrues it first, and redeeming one share + // is the smallest such call that needs no price. + let last_accrual = i64::from( + test.read::(w.borrow_reserve) + .last_accrual_timestamp, + ); + test.warp_to_timestamp(last_accrual + TENTH_OF_A_YEAR); + redeem(test, &w, 1).succeeds(); + assert!( + u128::from( + test.read::(w.borrow_reserve) + .borrow_accumulation_factor + ) > FIXED_POINT_SCALE + ); + + let round_trips = 50; + for trip in 1..=round_trips { + deposit_borrow_side_as( + test, + &w, + TRIPPER, + TRIPPER_BORROW, + TRIPPER_BORROW_SHARE, + ROUND_TRIP_AMOUNT, + ) + .succeeds(); + let shares = test.tokens(TRIPPER_BORROW_SHARE); + redeem_as( + test, + &w, + TRIPPER, + TRIPPER_BORROW, + TRIPPER_BORROW_SHARE, + shares, + ) + .succeeds(); + + assert!( + test.tokens(TRIPPER_BORROW) <= ROUND_TRIP_FUNDING, + "round trip {trip} returned more than was put in" + ); + } +} + #[quasar_test] fn repay_reduces_debt(test: &mut Test) { let w = base_world(test); @@ -448,7 +712,7 @@ fn unhealthy_position_is_liquidated_and_healthy_is_rejected(test: &mut Test) { /// runtime. mod clock_warp { use { - super::{dollars, EXP, TENTH_OF_A_YEAR}, + super::{dollars, narrow_band, DEFAULT_MAX_CONFIDENCE_BPS, EXP, TENTH_OF_A_YEAR}, super::{ ATTACKER, ATTACKER_BORROW, ATTACKER_BORROW_SHARE, ATTACKER_COLLATERAL, ATTACKER_COLLATERAL_SHARE, BORROWER, BORROWER_BORROW, BORROWER_COLLATERAL, @@ -737,10 +1001,13 @@ mod clock_warp { self.run(data, metas).assert_success(); } + /// Publish a price with a narrow confidence band, one the reserve + /// config accepts. fn set_price(&mut self, the_mint: Pubkey, price_feed: Pubkey, mantissa: i128) { let mut data = vec![2u8]; data.extend_from_slice(&mantissa.to_le_bytes()); data.extend_from_slice(&EXP.to_le_bytes()); + data.extend_from_slice(&narrow_band(mantissa).to_le_bytes()); let metas = vec![ meta(OWNER, true, true), meta(self.market, false, false), @@ -760,8 +1027,20 @@ mod clock_warp { price: Pubkey, ) { // 75% LTV, 80% liquidation threshold, 5% bonus, 50% close factor, - // 10% reserve factor, kink 80%, 2% / 20% / 150% APR curve. - let config: [u16; 9] = [7_500, 8_000, 500, 5_000, 1_000, 8_000, 200, 2_000, 15_000]; + // 10% reserve factor, kink 80%, 2% / 20% / 150% APR curve, and a + // 1% confidence limit on the price feed. + let config: [u16; 10] = [ + 7_500, + 8_000, + 500, + 5_000, + 1_000, + 8_000, + 200, + 2_000, + 15_000, + DEFAULT_MAX_CONFIDENCE_BPS, + ]; let mut data = vec![1u8]; for value in config { data.extend_from_slice(&value.to_le_bytes()); diff --git a/finance/options/anchor-v1/CHANGELOG.md b/finance/options/anchor-v1/CHANGELOG.md index 34043e2c0..b1507c10e 100644 --- a/finance/options/anchor-v1/CHANGELOG.md +++ b/finance/options/anchor-v1/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## Unreleased, 2026-10-04 + +The LiteSVM suite asserts the error code of every refusal +(`assert_fails_with` for the program's errors, `assert_fails_with_anchor_error` +for Anchor's constraint errors) instead of only that the transaction failed; +the `errors` module is public so it can. Two refusals that had been passing as +duplicate transactions, the second fee sweep and a stranger's second exercise +attempt, now reach the handler on a fresh blockhash and fail for the rule under +test. New test +`test_reclaim_collateral_after_expiry_returns_the_strike_to_the_put_writer` +follows the put from purchase to expiry and reclaim. +`test_writer_cannot_buy_their_own_option` asserts +`ConstraintDuplicateMutableAccount`, the Anchor check that refuses the +purchase. The suite sends transactions through LiteSVM directly and reads the +metadata back, so the purchase tests count the token transfers a buy makes: +two with a fee, one without. The Kani crate gains +`proof_collect_fees_pays_only_the_fees_owed`. + ## 2026-10-03 The option now stores `underlying_amount` and `strike_amount`, the two amounts diff --git a/finance/options/anchor-v1/README.md b/finance/options/anchor-v1/README.md index efb9015ad..a77821fab 100644 --- a/finance/options/anchor-v1/README.md +++ b/finance/options/anchor-v1/README.md @@ -121,9 +121,14 @@ 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 -vault, owed to Maria, and 24.75 USDC straight to Alice. The 5 NVDAx do not -move. The option's `holder` is now Bob and its status `Held`. Bob's downside -is fixed at the 25 USDC he just paid. +vault, owed to Maria, and 24.75 USDC straight to Alice, as two transfers (a +venue with a zero fee makes only the first). The 5 NVDAx do not move. The +option's `holder` is now Bob and its status `Held`. Bob's downside is fixed at +the 25 USDC he just paid. Alice could not have bought her own option: the +premium's source and destination are each bound to their party's associated +token account, so with Alice on both sides they would be one account in two +mutable slots, and Anchor refuses the instruction with +`ConstraintDuplicateMutableAccount` before the handler runs. ### Step 4: NVIDIA rallies to $200 and Bob exercises @@ -155,8 +160,9 @@ fall that never came should do. ### Step 8: Maria sweeps the fees -`collect_fees` pays Maria the 0.45 USDC of accumulated fees. The vaults are -empty: every token that entered has left to the party it was owed to. +`collect_fees` pays Maria the 0.45 USDC of accumulated fees, and only that: +`fees_owed` is the one part of the quote vault the admin can reach. The vaults +are empty: every token that entered has left to the party it was owed to. Where everyone ended up: Alice earned a premium and sold her shares at her price; Bob turned 25 USDC of premium into 5 NVDAx at a $20 discount to the @@ -172,7 +178,9 @@ call holders' strike payments awaiting collection) and `fees_owed`. Every handler that moves tokens updates the ledger before any transfer and then asserts that each vault still covers what it owes (`CustodyInvariantViolated` otherwise). The [Kani model checks](../kani-proofs) walk every path through an option's -life and check that the ledger returns to zero. +life and check that the ledger returns to zero, and check that `collect_fees` +pays the admin exactly `fees_owed` and leaves everything owed to writers and +holders in the vault. ## Design notes and further reading @@ -211,10 +219,14 @@ cargo test ``` The LiteSVM suite (`programs/options/tests/test_options.rs`) walks the call -from write to collected strike and the put from write to exercise and to -expiry, pins every balance to the minor unit, checks the custody ledger -against the vault balances after every step, and checks that every refusal holds: -the expiry boundary from both sides, cancel after sale, buy after sale or -expiry, exercise by a non-holder, collection by a non-writer or before -exercise, reclaim after exercise, fee collection by a non-admin, and the -parameter checks at write time. +from write to collected strike and from purchase to expiry and reclaim, and +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, 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, +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/Cargo.toml b/finance/options/anchor-v1/programs/options/Cargo.toml index f4da1bd3a..b7e9bc270 100644 --- a/finance/options/anchor-v1/programs/options/Cargo.toml +++ b/finance/options/anchor-v1/programs/options/Cargo.toml @@ -36,6 +36,13 @@ litesvm = "0.16.0" solana-signer = "3.0.0" solana-keypair = "3.0.1" solana-kite = "0.5.0" +# The suite sends each instruction through LiteSVM itself rather than through +# solana-kite's wrapper, so it can read the transaction's metadata back: the +# inner instructions say how many token transfers a handler made. Same major +# versions as litesvm 0.16 and solana-kite 0.5 use, so one copy of each is +# linked. +solana-transaction = "4" +solana-message = "4" borsh = "1.6.1" [lints.rust] 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 2b0b681d7..d3a942a80 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 @@ -111,8 +111,10 @@ pub struct BuyOptionAccountConstraints<'info> { // Created by `write_option`, at the writer's expense, so the buyer never // pays rent on the writer's behalf. A writer buying their own option would - // put this account and `buyer_quote` in two mutable slots at once, which - // the loader rejects, so a writer cannot pay themselves a premium. + // put this account and `buyer_quote`, both the same associated token + // account, in two mutable slots at once; Anchor refuses that with + // `ConstraintDuplicateMutableAccount` before any constraint or handler + // code runs, so a writer cannot pay themselves a premium. #[account( mut, associated_token::mint = quote_mint, diff --git a/finance/options/anchor-v1/programs/options/src/lib.rs b/finance/options/anchor-v1/programs/options/src/lib.rs index 1ed56f3b8..eef41b492 100644 --- a/finance/options/anchor-v1/programs/options/src/lib.rs +++ b/finance/options/anchor-v1/programs/options/src/lib.rs @@ -1,11 +1,12 @@ use anchor_lang::prelude::*; mod constants; -mod errors; // Public so the LiteSVM integration tests can build instruction arguments -// (`OptionTerms`, `OptionKind`) against the program's own types, and the -// proofs crate's README can point at the formulas it mirrors. +// (`OptionTerms`, `OptionKind`) against the program's own types and assert +// each refusal by its `OptionsError` code, and the proofs crate's README can +// point at the formulas it mirrors. pub mod contract_math; +pub mod errors; pub mod instructions; pub mod state; 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 e4cb5cf91..656913d2e 100644 --- a/finance/options/anchor-v1/programs/options/tests/test_options.rs +++ b/finance/options/anchor-v1/programs/options/tests/test_options.rs @@ -1,10 +1,12 @@ use { anchor_lang::{ + error::{ErrorCode as AnchorErrorCode, ERROR_CODE_OFFSET}, solana_program::{clock::Clock, instruction::Instruction, pubkey::Pubkey, system_program}, AccountDeserialize, InstructionData, ToAccountMetas, }, - litesvm::LiteSVM, + litesvm::{types::TransactionMetadata, LiteSVM}, options::{ + errors::OptionsError, instructions::write_option::OptionTerms, state::{Market as MarketState, OptionContract, OptionKind, OptionStatus}, }, @@ -14,7 +16,9 @@ use { get_token_account_balance, mint_tokens_to_token_account, send_transaction_from_instructions, }, + solana_message::Message, solana_signer::Signer, + solana_transaction::Transaction, }; // Both tokens have 6 decimals: the underlying is NVDAx (tokenized NVIDIA @@ -65,6 +69,39 @@ fn derive_ata(wallet: &Pubkey, mint: &Pubkey) -> Pubkey { .0 } +/// A failed transaction reports its program error as `Custom(n)`. Matching +/// `n` checks that the transaction failed for the rule under test, not for +/// some unrelated reason. +fn assert_fails_with_code(result: Result, code: u32) { + let Err(error) = result else { + panic!("the transaction should have failed with error code {code}"); + }; + assert!( + error.contains(&format!("Custom({code})")), + "expected error code {code}, got: {error}" + ); +} + +/// Anchor numbers a program's own errors from `ERROR_CODE_OFFSET` in +/// declaration order. +fn assert_fails_with(result: Result, expected: OptionsError) { + assert_fails_with_code(result, ERROR_CODE_OFFSET + expected as u32); +} + +/// Anchor's own constraint errors (an `address =` mismatch, a duplicate +/// mutable account) are numbered below the program's and reported the same +/// way. +fn assert_fails_with_anchor_error(result: Result, expected: AnchorErrorCode) { + assert_fails_with_code(result, expected as u32); +} + +/// How many CPIs the transaction's one instruction made. The handlers here +/// call no program but the token program, so this is the number of token +/// transfers. +fn cpi_count(metadata: &TransactionMetadata) -> usize { + metadata.inner_instructions[0].len() +} + /// The walkthrough's call, expiring at `expiry`. fn call_terms(expiry: i64) -> OptionTerms { OptionTerms { @@ -109,7 +146,7 @@ impl Venue { /// Like `new`, but surfaces an `initialize_market` rejection instead of /// panicking, so tests can probe the parameter validation. `same_mint` /// passes the underlying mint as the quote mint too. - fn try_new(fee_bps: u16, same_mint: bool) -> Result { + fn try_new(fee_bps: u16, same_mint: bool) -> Result { let mut svm = LiteSVM::new(); svm.add_program( options::id(), @@ -157,7 +194,7 @@ impl Venue { &[&admin], &admin.pubkey(), ) - .map_err(|_| ())?; + .map_err(|error| format!("{error:?}"))?; Ok(Venue { svm, @@ -271,18 +308,29 @@ impl Venue { Pubkey::try_from(&account.data[32..64]).unwrap() } - fn send(&mut self, instruction: Instruction, signer: &Keypair) -> Result<(), ()> { - send_transaction_from_instructions( - &mut self.svm, - vec![instruction], - &[signer], - &signer.pubkey(), - ) - .map(|_| ()) - .map_err(|_| ()) + /// Send one instruction, signed and paid for by `signer`, and return the + /// transaction's metadata: its logs and the CPIs the handler made. A + /// failure carries the transaction error, which names the program error + /// as `Custom(n)`. + fn send( + &mut self, + instruction: Instruction, + signer: &Keypair, + ) -> Result { + let message = Message::new(&[instruction], Some(&signer.pubkey())); + let mut transaction = Transaction::new_unsigned(message); + transaction.sign(&[signer], self.svm.latest_blockhash()); + self.svm + .send_transaction(transaction) + .map_err(|failed| format!("{:?}", failed.err)) } - fn write_option(&mut self, writer: &Person, id: u64, terms: OptionTerms) -> Result { + fn write_option( + &mut self, + writer: &Person, + id: u64, + terms: OptionTerms, + ) -> Result { let option = self.option_pda(&writer.pubkey(), id); let instruction = Instruction::new_with_bytes( options::id(), @@ -330,7 +378,12 @@ impl Venue { .expect("writing the put should succeed") } - fn buy_option(&mut self, buyer: &Person, writer: &Pubkey, option: &Pubkey) -> Result<(), ()> { + fn buy_option( + &mut self, + buyer: &Person, + writer: &Pubkey, + option: &Pubkey, + ) -> Result { let instruction = Instruction::new_with_bytes( options::id(), &options::instruction::BuyOption {}.data(), @@ -350,7 +403,11 @@ impl Venue { self.send(instruction, &buyer.keypair) } - fn cancel_option(&mut self, writer: &Person, option: &Pubkey) -> Result<(), ()> { + fn cancel_option( + &mut self, + writer: &Person, + option: &Pubkey, + ) -> Result { let instruction = Instruction::new_with_bytes( options::id(), &options::instruction::CancelOption {}.data(), @@ -376,7 +433,7 @@ impl Venue { holder: &Person, writer: &Pubkey, option: &Pubkey, - ) -> Result<(), ()> { + ) -> Result { let instruction = Instruction::new_with_bytes( options::id(), &options::instruction::ExerciseOption {}.data(), @@ -400,7 +457,11 @@ impl Venue { self.send(instruction, &holder.keypair) } - fn collect_proceeds(&mut self, writer: &Person, option: &Pubkey) -> Result<(), ()> { + fn collect_proceeds( + &mut self, + writer: &Person, + option: &Pubkey, + ) -> Result { let instruction = Instruction::new_with_bytes( options::id(), &options::instruction::CollectProceeds {}.data(), @@ -423,7 +484,11 @@ impl Venue { self.send(instruction, &writer.keypair) } - fn reclaim_collateral(&mut self, writer: &Person, option: &Pubkey) -> Result<(), ()> { + fn reclaim_collateral( + &mut self, + writer: &Person, + option: &Pubkey, + ) -> Result { let instruction = Instruction::new_with_bytes( options::id(), &options::instruction::ReclaimCollateral {}.data(), @@ -444,7 +509,7 @@ impl Venue { self.send(instruction, &writer.keypair) } - fn collect_fees_as(&mut self, signer: &Keypair) -> Result<(), ()> { + fn collect_fees_as(&mut self, signer: &Keypair) -> Result { let instruction = Instruction::new_with_bytes( options::id(), &options::instruction::CollectFees {}.data(), @@ -464,7 +529,7 @@ impl Venue { self.send(instruction, signer) } - fn collect_fees(&mut self) -> Result<(), ()> { + fn collect_fees(&mut self) -> Result { let admin = self.admin.insecure_clone(); self.collect_fees_as(&admin) } @@ -525,7 +590,7 @@ fn test_write_call_moves_underlying_into_vault() { } /// Bob buys the option. He pays 25 USDC: 1% (0.25 USDC) to the venue, the rest -/// straight to Alice. The 5 NVDAx do not move. +/// straight to Alice, as two transfers. The 5 NVDAx do not move. #[test] fn test_buy_option_pays_the_premium_minus_the_fee() { let mut venue = Venue::new(); @@ -533,8 +598,10 @@ fn test_buy_option_pays_the_premium_minus_the_fee() { let bob = venue.person(0, STANDARD_USDC); let option = venue.write_call(&alice); - venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); + let purchase = venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); + // One transfer to Alice, one to the vault. + assert_eq!(cpi_count(&purchase), 2); let fee = 250_000; // 0.25 USDC assert_eq!(venue.balance(&bob.quote), STANDARD_USDC - CALL_PREMIUM); assert_eq!( @@ -636,8 +703,10 @@ fn test_collect_fees_pays_only_the_fees_owed() { assert_eq!(venue.market_state().fees_owed, 0); venue.assert_vaults_match_ledger(); - // Nothing left to sweep. - assert!(venue.collect_fees().is_err()); + // Nothing left to sweep. A fresh blockhash, or the retry would be dropped + // as a duplicate of the sweep above instead of reaching the handler. + venue.svm.expire_blockhash(); + assert_fails_with(venue.collect_fees(), OptionsError::NothingToCollect); } // =========================================================================== @@ -718,6 +787,46 @@ fn test_reclaim_collateral_after_expiry_returns_it_to_the_writer() { venue.assert_vaults_match_ledger(); } +/// Dave never exercises: the week passes above the 150 strike. At expiry +/// Carol takes her 750 USDC of collateral back, keeping the 19.80 USDC of +/// premium, and the option closes with its rent back to her. Dave keeps his +/// 5 NVDAx and is out the 20 USDC he paid; the venue's 0.20 USDC fee stays in +/// the vault for Maria. +#[test] +fn test_reclaim_collateral_after_expiry_returns_the_strike_to_the_put_writer() { + let mut venue = Venue::new(); + let carol = venue.person(0, STANDARD_USDC); + let dave = venue.person(FIVE_NVDAX, STANDARD_USDC); + let option = venue.write_put(&carol); + venue.buy_option(&dave, &carol.pubkey(), &option).unwrap(); + let fee = 200_000; // 1% of 20 USDC + let expiry = venue.option_state(&option).expiry; + let carol_lamports_before = venue.svm.get_balance(&carol.pubkey()).unwrap(); + + venue.warp_to(expiry); + 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_eq!(venue.balance(&venue.underlying_vault), 0); + assert!(!venue.option_exists(&option)); + assert!( + venue.svm.get_balance(&carol.pubkey()).unwrap() > carol_lamports_before, + "the option's rent must return to the writer" + ); + 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(); +} + // =========================================================================== // The expiry boundary, from both sides // =========================================================================== @@ -734,9 +843,10 @@ fn test_exercise_is_allowed_up_to_but_not_at_expiry() { let expiry = venue.option_state(&option).expiry; venue.warp_to(expiry); - assert!(venue - .exercise_option(&bob, &alice.pubkey(), &option) - .is_err()); + assert_fails_with( + venue.exercise_option(&bob, &alice.pubkey(), &option), + OptionsError::OptionExpired, + ); venue.warp_to(expiry - 1); venue @@ -755,7 +865,10 @@ fn test_reclaim_is_refused_before_expiry() { let expiry = venue.option_state(&option).expiry; venue.warp_to(expiry - 1); - assert!(venue.reclaim_collateral(&alice, &option).is_err()); + assert_fails_with( + venue.reclaim_collateral(&alice, &option), + OptionsError::OptionNotExpired, + ); venue.warp_to(expiry); venue @@ -774,7 +887,10 @@ fn test_buy_is_refused_after_expiry() { let expiry = venue.option_state(&option).expiry; venue.warp_to(expiry); - assert!(venue.buy_option(&bob, &alice.pubkey(), &option).is_err()); + assert_fails_with( + venue.buy_option(&bob, &alice.pubkey(), &option), + OptionsError::OptionExpired, + ); } // =========================================================================== @@ -825,7 +941,10 @@ fn test_cancel_is_refused_once_sold() { let option = venue.write_call(&alice); venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); - assert!(venue.cancel_option(&alice, &option).is_err()); + assert_fails_with( + venue.cancel_option(&alice, &option), + OptionsError::OptionNotListed, + ); assert_eq!(venue.balance(&venue.underlying_vault), FIVE_NVDAX); } @@ -842,25 +961,36 @@ fn test_buy_is_refused_once_sold() { let option = venue.write_call(&alice); venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); - assert!(venue.buy_option(&carol, &alice.pubkey(), &option).is_err()); + assert_fails_with( + venue.buy_option(&carol, &alice.pubkey(), &option), + OptionsError::OptionNotListed, + ); assert_eq!(venue.option_state(&option).holder, bob.pubkey()); } -/// A writer cannot buy their own option: the premium's source and destination -/// would be the same token account in two mutable slots, which the loader -/// rejects before the handler runs. +/// 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 +/// duplicate-mutable-account check refuses the instruction before any +/// handler code runs. The option stays listed and no USDC moves. #[test] fn test_writer_cannot_buy_their_own_option() { let mut venue = Venue::new(); let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); let option = venue.write_call(&alice); - assert!(venue.buy_option(&alice, &alice.pubkey(), &option).is_err()); + assert_fails_with_anchor_error( + venue.buy_option(&alice, &alice.pubkey(), &option), + AnchorErrorCode::ConstraintDuplicateMutableAccount, + ); assert_eq!(venue.option_state(&option).status, OptionStatus::Listed); + assert_eq!(venue.balance(&alice.quote), STANDARD_USDC); + assert_eq!(venue.market_state().fees_owed, 0); } /// Only the holder can exercise: an unsold option has no holder, and a stranger -/// is not the holder of a sold one. +/// is not the holder of a sold one. Both refusals are the `holder` account's +/// `address = option.holder` constraint. #[test] fn test_exercise_is_refused_for_anyone_but_the_holder() { let mut venue = Venue::new(); @@ -869,13 +999,18 @@ fn test_exercise_is_refused_for_anyone_but_the_holder() { let mallory = venue.person(0, STANDARD_USDC); let option = venue.write_call(&alice); - assert!(venue - .exercise_option(&mallory, &alice.pubkey(), &option) - .is_err()); + assert_fails_with_anchor_error( + venue.exercise_option(&mallory, &alice.pubkey(), &option), + AnchorErrorCode::ConstraintAddress, + ); venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); - assert!(venue - .exercise_option(&mallory, &alice.pubkey(), &option) - .is_err()); + // A fresh blockhash, or Mallory's identical retry would be dropped as a + // duplicate instead of reaching the constraint. + venue.svm.expire_blockhash(); + assert_fails_with_anchor_error( + venue.exercise_option(&mallory, &alice.pubkey(), &option), + AnchorErrorCode::ConstraintAddress, + ); assert_eq!(venue.balance(&venue.underlying_vault), FIVE_NVDAX); } @@ -890,13 +1025,22 @@ fn test_collect_proceeds_needs_an_exercised_option_and_the_writer() { let option = venue.write_call(&alice); venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); - assert!(venue.collect_proceeds(&alice, &option).is_err()); + assert_fails_with( + venue.collect_proceeds(&alice, &option), + OptionsError::OptionNotExercised, + ); venue .exercise_option(&bob, &alice.pubkey(), &option) .unwrap(); - assert!(venue.collect_proceeds(&mallory, &option).is_err()); - assert!(venue.collect_proceeds(&bob, &option).is_err()); + assert_fails_with_anchor_error( + venue.collect_proceeds(&mallory, &option), + AnchorErrorCode::ConstraintAddress, + ); + assert_fails_with_anchor_error( + venue.collect_proceeds(&bob, &option), + AnchorErrorCode::ConstraintAddress, + ); assert_eq!(venue.balance(&venue.quote_vault), 900 * ONE_TOKEN + 250_000); } @@ -915,7 +1059,10 @@ fn test_reclaim_is_refused_after_exercise() { let expiry = venue.option_state(&option).expiry; venue.warp_to(expiry + 1); - assert!(venue.reclaim_collateral(&alice, &option).is_err()); + assert_fails_with( + venue.reclaim_collateral(&alice, &option), + OptionsError::OptionNotHeld, + ); } #[test] @@ -927,7 +1074,10 @@ fn test_collect_fees_is_refused_for_anyone_but_the_admin() { let option = venue.write_call(&alice); venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); - assert!(venue.collect_fees_as(&mallory.keypair).is_err()); + assert_fails_with_anchor_error( + venue.collect_fees_as(&mallory.keypair), + AnchorErrorCode::ConstraintAddress, + ); assert_eq!(venue.market_state().fees_owed, 250_000); } @@ -954,9 +1104,9 @@ fn test_write_option_rejects_zero_amounts_and_a_free_premium() { premium, expiry, }; - assert!( - venue.write_option(&alice, id as u64 + 10, terms).is_err(), - "a zero in any term must be refused" + assert_fails_with( + venue.write_option(&alice, id as u64 + 10, terms), + OptionsError::InvalidParameter, ); } assert_eq!(venue.balance(&alice.underlying), FIVE_NVDAX); @@ -971,22 +1121,32 @@ fn test_write_option_rejects_an_expiry_that_has_passed() { let now = venue.now(); for expiry in [now, now - SECONDS_PER_DAY] { - assert!(venue.write_option(&alice, 20, call_terms(expiry)).is_err()); + assert_fails_with( + venue.write_option(&alice, 20, call_terms(expiry)), + OptionsError::ExpiryInPast, + ); } } #[test] fn test_initialize_market_rejects_a_full_fee() { - assert!(Venue::try_new(10_000, false).is_err()); + assert_fails_with( + Venue::try_new(10_000, false), + OptionsError::InvalidParameter, + ); } #[test] fn test_initialize_market_rejects_the_same_mint_on_both_sides() { - assert!(Venue::try_new(FEE_BPS, true).is_err()); + assert_fails_with( + Venue::try_new(FEE_BPS, true), + OptionsError::InvalidParameter, + ); } /// A venue run at cost is a valid choice: with a zero fee the writer -/// receives the whole premium and no fee transfer is attempted. +/// receives the whole premium in one transfer, and `buy_option` makes no +/// fee transfer at all rather than one for zero. #[test] fn test_zero_fee_venue_pays_the_writer_the_whole_premium() { let mut venue = Venue::try_new(0, false).unwrap(); @@ -994,8 +1154,10 @@ fn test_zero_fee_venue_pays_the_writer_the_whole_premium() { let bob = venue.person(0, STANDARD_USDC); let option = venue.write_call(&alice); - venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); + let purchase = venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); + // The transfer to Alice, and nothing to the vault. + assert_eq!(cpi_count(&purchase), 1); assert_eq!(venue.balance(&alice.quote), STANDARD_USDC + CALL_PREMIUM); assert_eq!(venue.market_state().fees_owed, 0); venue.assert_vaults_match_ledger(); diff --git a/finance/options/anchor/CHANGELOG.md b/finance/options/anchor/CHANGELOG.md index f02a4bed9..f4ac9611d 100644 --- a/finance/options/anchor/CHANGELOG.md +++ b/finance/options/anchor/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## Unreleased, 2026-10-04 + +The LiteSVM suite asserts the error code of every refusal +(`assert_fails_with` for the program's errors, `assert_fails_with_anchor_error` +for Anchor's constraint errors) instead of only that the transaction failed; +the `errors` module is public so it can. Two refusals that had been passing as +duplicate transactions, the second fee sweep and a stranger's second exercise +attempt, now reach the handler on a fresh blockhash and fail for the rule under +test. New test +`test_reclaim_collateral_after_expiry_returns_the_strike_to_the_put_writer` +follows the put from purchase to expiry and reclaim. +`test_writer_cannot_buy_their_own_option` asserts +`ConstraintDuplicateMutableAccount`, the Anchor check that refuses the +purchase. The suite sends transactions through LiteSVM directly and reads the +metadata back, so the purchase tests count the token transfers a buy makes: +two with a fee, one without. The Kani crate gains +`proof_collect_fees_pays_only_the_fees_owed`. + ## 2026-10-03 An option now stores `underlying_amount` and `strike_amount`, the two amounts diff --git a/finance/options/anchor/README.md b/finance/options/anchor/README.md index a827c1587..b16a47d6b 100644 --- a/finance/options/anchor/README.md +++ b/finance/options/anchor/README.md @@ -121,9 +121,14 @@ 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 -vault, owed to Maria, and 24.75 USDC straight to Alice. The 5 NVDAx do not -move. The option's `holder` is now Bob and its status `Held`. Bob's downside -is fixed at the 25 USDC he just paid. +vault, owed to Maria, and 24.75 USDC straight to Alice, as two transfers (a +venue with a zero fee makes only the first). The 5 NVDAx do not move. The +option's `holder` is now Bob and its status `Held`. Bob's downside is fixed at +the 25 USDC he just paid. Alice could not have bought her own option: the +premium's source and destination are each bound to their party's associated +token account, so with Alice on both sides they would be one account in two +mutable slots, and Anchor refuses the instruction with +`ConstraintDuplicateMutableAccount` before the handler runs. ### Step 4: NVIDIA rallies to $200 and Bob exercises @@ -155,8 +160,9 @@ fall that never came should do. ### Step 8: Maria sweeps the fees -`collect_fees` pays Maria the 0.45 USDC of accumulated fees. The vaults are -empty: every token that entered has left to the party it was owed to. +`collect_fees` pays Maria the 0.45 USDC of accumulated fees, and only that: +`fees_owed` is the one part of the quote vault the admin can reach. The vaults +are empty: every token that entered has left to the party it was owed to. Where everyone ended up: Alice earned a premium and sold her shares at her price; Bob turned 25 USDC of premium into 5 NVDAx at a $20 discount to the @@ -172,7 +178,9 @@ call holders' strike payments awaiting collection) and `fees_owed`. Every handler that moves tokens updates the ledger before any transfer and then asserts that each vault still covers what it owes (`CustodyInvariantViolated` otherwise). The [Kani model checks](../kani-proofs) walk every path through an option's -life and check that the ledger returns to zero. +life and check that the ledger returns to zero, and check that `collect_fees` +pays the admin exactly `fees_owed` and leaves everything owed to writers and +holders in the vault. ## Design notes and further reading @@ -211,13 +219,17 @@ cargo test ``` The LiteSVM suite (`programs/options/tests/test_options.rs`) walks the call -from write to collected strike and the put from write to exercise and to -expiry, pins every balance to the minor unit, checks the custody ledger -against the vault balances after every step, and checks that every refusal holds: -the expiry boundary from both sides, cancel after sale, buy after sale or -expiry, exercise by a non-holder, collection by a non-writer or before -exercise, reclaim after exercise, fee collection by a non-admin, and the -parameter checks at write time. +from write to collected strike and from purchase to expiry and reclaim, and +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, 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, +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. ## FAQ diff --git a/finance/options/anchor/programs/options/Cargo.toml b/finance/options/anchor/programs/options/Cargo.toml index 0ce665257..1735fa77e 100644 --- a/finance/options/anchor/programs/options/Cargo.toml +++ b/finance/options/anchor/programs/options/Cargo.toml @@ -61,6 +61,11 @@ anchor-v2-testing = { git = "https://github.com/otter-sec/anchor.git", rev = "3e # pinocchio's on-chain one; the tests move `unix_timestamp` past an expiry. solana-clock = "3.0.1" solana-kite = "0.4.0" +# The suite sends each instruction through LiteSVM itself rather than through +# solana-kite's wrapper, so it can read the transaction's metadata back: the +# inner instructions say how many token transfers a handler made. The same +# version anchor-v2-testing pins, so one copy is linked. +litesvm = "=0.13.1" borsh = "1.6.1" [lints.rust] 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 e7495555b..913a8aaa1 100644 --- a/finance/options/anchor/programs/options/src/instructions/buy_option.rs +++ b/finance/options/anchor/programs/options/src/instructions/buy_option.rs @@ -111,8 +111,10 @@ pub struct BuyOptionAccountConstraints { // Created by `write_option`, at the writer's expense, so the buyer never // pays rent on the writer's behalf. A writer buying their own option would - // put this account and `buyer_quote` in two mutable slots at once, which - // the loader rejects, so a writer cannot pay themselves a premium. + // put this account and `buyer_quote`, both the same associated token + // account, in two mutable slots at once; Anchor refuses that with + // `ConstraintDuplicateMutableAccount` before any constraint or handler + // code runs, so a writer cannot pay themselves a premium. #[account( mut, associated_token::mint = quote_mint, diff --git a/finance/options/anchor/programs/options/src/lib.rs b/finance/options/anchor/programs/options/src/lib.rs index 5557c6abc..e5d70631f 100644 --- a/finance/options/anchor/programs/options/src/lib.rs +++ b/finance/options/anchor/programs/options/src/lib.rs @@ -1,11 +1,12 @@ use anchor_lang::prelude::*; mod constants; -mod errors; // Public so the LiteSVM integration tests can build instruction arguments -// (`OptionTerms`, `OptionKind`) against the program's own types, and the -// proofs crate's README can point at the formulas it mirrors. +// (`OptionTerms`, `OptionKind`) against the program's own types and assert +// each refusal by its `OptionsError` code, and the proofs crate's README can +// point at the formulas it mirrors. pub mod contract_math; +pub mod errors; pub mod instructions; pub mod state; diff --git a/finance/options/anchor/programs/options/tests/test_options.rs b/finance/options/anchor/programs/options/tests/test_options.rs index 472388ebb..b28660a80 100644 --- a/finance/options/anchor/programs/options/tests/test_options.rs +++ b/finance/options/anchor/programs/options/tests/test_options.rs @@ -1,10 +1,14 @@ use { anchor_lang::{ solana_program::instruction::Instruction, system_program, AccountDeserialize, Address, - InstructionData, ToAccountMetas, + Error as AnchorError, ErrorCode as AnchorErrorCode, InstructionData, ToAccountMetas, }, - anchor_v2_testing::{Keypair, LiteSVM, Signer}, + anchor_v2_testing::{ + Keypair, LiteSVM, Message, Signer, VersionedMessage, VersionedTransaction, + }, + litesvm::types::TransactionMetadata, options::{ + errors::OptionsError, instructions::write_option::OptionTerms, state::{Market as MarketState, OptionContract, OptionKind, OptionStatus}, }, @@ -66,6 +70,45 @@ fn derive_ata(wallet: &Address, mint: &Address) -> Address { .0 } +/// A failed transaction reports its program error as `Custom(n)`. Matching +/// `n` checks that the transaction failed for the rule under test, not for +/// some unrelated reason. +fn assert_fails_with_code(result: Result, code: u32) { + let Err(error) = result else { + panic!("the transaction should have failed with error code {code}"); + }; + assert!( + error.contains(&format!("Custom({code})")), + "expected error code {code}, got: {error}" + ); +} + +/// Anchor numbers a program's own errors from 6000 in declaration order; +/// `#[error_code]` generates the conversion. +fn assert_fails_with(result: Result, expected: OptionsError) { + let AnchorError::Custom(code) = AnchorError::from(expected) else { + panic!("a program error converts to a custom code"); + }; + assert_fails_with_code(result, code); +} + +/// Anchor's own constraint errors (an `address =` mismatch, a duplicate +/// mutable account) are numbered below the program's and reported the same +/// way. +fn assert_fails_with_anchor_error(result: Result, expected: AnchorErrorCode) { + let AnchorError::Custom(code) = AnchorError::from(expected) else { + panic!("a constraint error converts to a custom code"); + }; + assert_fails_with_code(result, code); +} + +/// How many CPIs the transaction's one instruction made. The handlers here +/// call no program but the token program, so this is the number of token +/// transfers. +fn cpi_count(metadata: &TransactionMetadata) -> usize { + metadata.inner_instructions[0].len() +} + /// The walkthrough's call, expiring at `expiry`. fn call_terms(expiry: i64) -> OptionTerms { OptionTerms { @@ -110,7 +153,7 @@ impl Venue { /// Like `new`, but surfaces an `initialize_market` rejection instead of /// panicking, so tests can probe the parameter validation. `same_mint` /// passes the underlying mint as the quote mint too. - fn try_new(fee_bps: u16, same_mint: bool) -> Result { + fn try_new(fee_bps: u16, same_mint: bool) -> Result { let mut svm = anchor_v2_testing::svm(); svm.add_program( options::id(), @@ -159,7 +202,7 @@ impl Venue { &[&admin], &admin.pubkey(), ) - .map_err(|_| ())?; + .map_err(|error| format!("{error:?}"))?; Ok(Venue { svm, @@ -273,15 +316,26 @@ impl Venue { Address::try_from(&account.data[32..64]).unwrap() } - fn send(&mut self, instruction: Instruction, signer: &Keypair) -> Result<(), ()> { - send_transaction_from_instructions( - &mut self.svm, - vec![instruction], - &[signer], - &signer.pubkey(), - ) - .map(|_| ()) - .map_err(|_| ()) + /// Send one instruction, signed and paid for by `signer`, and return the + /// transaction's metadata: its logs and the CPIs the handler made. A + /// failure carries the transaction error, which names the program error + /// as `Custom(n)`. + fn send( + &mut self, + instruction: Instruction, + signer: &Keypair, + ) -> Result { + let message = Message::new_with_blockhash( + &[instruction], + Some(&signer.pubkey()), + &self.svm.latest_blockhash(), + ); + let transaction = + VersionedTransaction::try_new(VersionedMessage::Legacy(message), &[signer]) + .expect("the payer signs its own transaction"); + self.svm + .send_transaction(transaction) + .map_err(|failed| format!("{:?}", failed.err)) } fn write_option( @@ -289,7 +343,7 @@ impl Venue { writer: &Person, id: u64, terms: OptionTerms, - ) -> Result { + ) -> Result { let option = self.option_pda(&writer.pubkey(), id); let instruction = Instruction::new_with_bytes( options::id(), @@ -337,7 +391,12 @@ impl Venue { .expect("writing the put should succeed") } - fn buy_option(&mut self, buyer: &Person, writer: &Address, option: &Address) -> Result<(), ()> { + fn buy_option( + &mut self, + buyer: &Person, + writer: &Address, + option: &Address, + ) -> Result { let instruction = Instruction::new_with_bytes( options::id(), &options::instruction::BuyOption {}.data(), @@ -357,7 +416,11 @@ impl Venue { self.send(instruction, &buyer.keypair) } - fn cancel_option(&mut self, writer: &Person, option: &Address) -> Result<(), ()> { + fn cancel_option( + &mut self, + writer: &Person, + option: &Address, + ) -> Result { let instruction = Instruction::new_with_bytes( options::id(), &options::instruction::CancelOption {}.data(), @@ -383,7 +446,7 @@ impl Venue { holder: &Person, writer: &Address, option: &Address, - ) -> Result<(), ()> { + ) -> Result { let instruction = Instruction::new_with_bytes( options::id(), &options::instruction::ExerciseOption {}.data(), @@ -407,7 +470,11 @@ impl Venue { self.send(instruction, &holder.keypair) } - fn collect_proceeds(&mut self, writer: &Person, option: &Address) -> Result<(), ()> { + fn collect_proceeds( + &mut self, + writer: &Person, + option: &Address, + ) -> Result { let instruction = Instruction::new_with_bytes( options::id(), &options::instruction::CollectProceeds {}.data(), @@ -430,7 +497,11 @@ impl Venue { self.send(instruction, &writer.keypair) } - fn reclaim_collateral(&mut self, writer: &Person, option: &Address) -> Result<(), ()> { + fn reclaim_collateral( + &mut self, + writer: &Person, + option: &Address, + ) -> Result { let instruction = Instruction::new_with_bytes( options::id(), &options::instruction::ReclaimCollateral {}.data(), @@ -451,7 +522,7 @@ impl Venue { self.send(instruction, &writer.keypair) } - fn collect_fees_as(&mut self, signer: &Keypair) -> Result<(), ()> { + fn collect_fees_as(&mut self, signer: &Keypair) -> Result { let instruction = Instruction::new_with_bytes( options::id(), &options::instruction::CollectFees {}.data(), @@ -471,7 +542,7 @@ impl Venue { self.send(instruction, signer) } - fn collect_fees(&mut self) -> Result<(), ()> { + fn collect_fees(&mut self) -> Result { let admin = self.admin.insecure_clone(); self.collect_fees_as(&admin) } @@ -508,7 +579,7 @@ fn test_market_owns_both_vaults() { assert_eq!(venue.token_authority(&venue.quote_vault), venue.market); } -/// Alice writes 5 covered calls on her 5 NVDAx. The whole 5 NVDAx moves into +/// Alice writes a covered call on her 5 NVDAx. The whole 5 NVDAx moves into /// the vault at once; the option is listed for a 25 USDC premium. #[test] fn test_write_call_moves_underlying_into_vault() { @@ -532,7 +603,7 @@ fn test_write_call_moves_underlying_into_vault() { } /// Bob buys the option. He pays 25 USDC: 1% (0.25 USDC) to the venue, the rest -/// straight to Alice. The 5 NVDAx do not move. +/// straight to Alice, as two transfers. The 5 NVDAx do not move. #[test] fn test_buy_option_pays_the_premium_minus_the_fee() { let mut venue = Venue::new(); @@ -540,8 +611,10 @@ fn test_buy_option_pays_the_premium_minus_the_fee() { let bob = venue.person(0, STANDARD_USDC); let option = venue.write_call(&alice); - venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); + let purchase = venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); + // One transfer to Alice, one to the vault. + assert_eq!(cpi_count(&purchase), 2); let fee = 250_000; // 0.25 USDC assert_eq!(venue.balance(&bob.quote), STANDARD_USDC - CALL_PREMIUM); assert_eq!( @@ -643,8 +716,10 @@ fn test_collect_fees_pays_only_the_fees_owed() { assert_eq!(venue.market_state().fees_owed, 0); venue.assert_vaults_match_ledger(); - // Nothing left to sweep. - assert!(venue.collect_fees().is_err()); + // Nothing left to sweep. A fresh blockhash, or the retry would be dropped + // as a duplicate of the sweep above instead of reaching the handler. + venue.svm.expire_blockhash(); + assert_fails_with(venue.collect_fees(), OptionsError::NothingToCollect); } // =========================================================================== @@ -725,6 +800,46 @@ fn test_reclaim_collateral_after_expiry_returns_it_to_the_writer() { venue.assert_vaults_match_ledger(); } +/// Dave never exercises: the week passes above the 150 strike. At expiry +/// Carol takes her 750 USDC of collateral back, keeping the 19.80 USDC of +/// premium, and the option closes with its rent back to her. Dave keeps his +/// 5 NVDAx and is out the 20 USDC he paid; the venue's 0.20 USDC fee stays in +/// the vault for Maria. +#[test] +fn test_reclaim_collateral_after_expiry_returns_the_strike_to_the_put_writer() { + let mut venue = Venue::new(); + let carol = venue.person(0, STANDARD_USDC); + let dave = venue.person(FIVE_NVDAX, STANDARD_USDC); + let option = venue.write_put(&carol); + venue.buy_option(&dave, &carol.pubkey(), &option).unwrap(); + let fee = 200_000; // 1% of 20 USDC + let expiry = venue.option_state(&option).expiry; + let carol_lamports_before = venue.svm.get_balance(&carol.pubkey()).unwrap(); + + venue.warp_to(expiry); + 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_eq!(venue.balance(&venue.underlying_vault), 0); + assert!(!venue.option_exists(&option)); + assert!( + venue.svm.get_balance(&carol.pubkey()).unwrap() > carol_lamports_before, + "the option's rent must return to the writer" + ); + 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(); +} + // =========================================================================== // The expiry boundary, from both sides // =========================================================================== @@ -741,9 +856,10 @@ fn test_exercise_is_allowed_up_to_but_not_at_expiry() { let expiry = venue.option_state(&option).expiry; venue.warp_to(expiry); - assert!(venue - .exercise_option(&bob, &alice.pubkey(), &option) - .is_err()); + assert_fails_with( + venue.exercise_option(&bob, &alice.pubkey(), &option), + OptionsError::OptionExpired, + ); venue.warp_to(expiry - 1); venue @@ -762,7 +878,10 @@ fn test_reclaim_is_refused_before_expiry() { let expiry = venue.option_state(&option).expiry; venue.warp_to(expiry - 1); - assert!(venue.reclaim_collateral(&alice, &option).is_err()); + assert_fails_with( + venue.reclaim_collateral(&alice, &option), + OptionsError::OptionNotExpired, + ); venue.warp_to(expiry); venue @@ -781,7 +900,10 @@ fn test_buy_is_refused_after_expiry() { let expiry = venue.option_state(&option).expiry; venue.warp_to(expiry); - assert!(venue.buy_option(&bob, &alice.pubkey(), &option).is_err()); + assert_fails_with( + venue.buy_option(&bob, &alice.pubkey(), &option), + OptionsError::OptionExpired, + ); } // =========================================================================== @@ -832,7 +954,10 @@ fn test_cancel_is_refused_once_sold() { let option = venue.write_call(&alice); venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); - assert!(venue.cancel_option(&alice, &option).is_err()); + assert_fails_with( + venue.cancel_option(&alice, &option), + OptionsError::OptionNotListed, + ); assert_eq!(venue.balance(&venue.underlying_vault), FIVE_NVDAX); } @@ -849,25 +974,36 @@ fn test_buy_is_refused_once_sold() { let option = venue.write_call(&alice); venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); - assert!(venue.buy_option(&carol, &alice.pubkey(), &option).is_err()); + assert_fails_with( + venue.buy_option(&carol, &alice.pubkey(), &option), + OptionsError::OptionNotListed, + ); assert_eq!(venue.option_state(&option).holder, bob.pubkey()); } -/// A writer cannot buy their own option: the premium's source and destination -/// would be the same token account in two mutable slots, which the loader -/// rejects before the handler runs. +/// 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 +/// duplicate-mutable-account check refuses the instruction before any +/// handler code runs. The option stays listed and no USDC moves. #[test] fn test_writer_cannot_buy_their_own_option() { let mut venue = Venue::new(); let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); let option = venue.write_call(&alice); - assert!(venue.buy_option(&alice, &alice.pubkey(), &option).is_err()); + assert_fails_with_anchor_error( + venue.buy_option(&alice, &alice.pubkey(), &option), + AnchorErrorCode::ConstraintDuplicateMutableAccount, + ); assert_eq!(venue.option_state(&option).status, OptionStatus::Listed); + assert_eq!(venue.balance(&alice.quote), STANDARD_USDC); + assert_eq!(venue.market_state().fees_owed, 0); } /// Only the holder can exercise: an unsold option has no holder, and a stranger -/// is not the holder of a sold one. +/// is not the holder of a sold one. Both refusals are the `holder` account's +/// `address = option.holder` constraint. #[test] fn test_exercise_is_refused_for_anyone_but_the_holder() { let mut venue = Venue::new(); @@ -876,13 +1012,18 @@ fn test_exercise_is_refused_for_anyone_but_the_holder() { let mallory = venue.person(0, STANDARD_USDC); let option = venue.write_call(&alice); - assert!(venue - .exercise_option(&mallory, &alice.pubkey(), &option) - .is_err()); + assert_fails_with_anchor_error( + venue.exercise_option(&mallory, &alice.pubkey(), &option), + AnchorErrorCode::ConstraintAddress, + ); venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); - assert!(venue - .exercise_option(&mallory, &alice.pubkey(), &option) - .is_err()); + // A fresh blockhash, or Mallory's identical retry would be dropped as a + // duplicate instead of reaching the constraint. + venue.svm.expire_blockhash(); + assert_fails_with_anchor_error( + venue.exercise_option(&mallory, &alice.pubkey(), &option), + AnchorErrorCode::ConstraintAddress, + ); assert_eq!(venue.balance(&venue.underlying_vault), FIVE_NVDAX); } @@ -897,13 +1038,22 @@ fn test_collect_proceeds_needs_an_exercised_option_and_the_writer() { let option = venue.write_call(&alice); venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); - assert!(venue.collect_proceeds(&alice, &option).is_err()); + assert_fails_with( + venue.collect_proceeds(&alice, &option), + OptionsError::OptionNotExercised, + ); venue .exercise_option(&bob, &alice.pubkey(), &option) .unwrap(); - assert!(venue.collect_proceeds(&mallory, &option).is_err()); - assert!(venue.collect_proceeds(&bob, &option).is_err()); + assert_fails_with_anchor_error( + venue.collect_proceeds(&mallory, &option), + AnchorErrorCode::ConstraintAddress, + ); + assert_fails_with_anchor_error( + venue.collect_proceeds(&bob, &option), + AnchorErrorCode::ConstraintAddress, + ); assert_eq!(venue.balance(&venue.quote_vault), 900 * ONE_TOKEN + 250_000); } @@ -922,7 +1072,10 @@ fn test_reclaim_is_refused_after_exercise() { let expiry = venue.option_state(&option).expiry; venue.warp_to(expiry + 1); - assert!(venue.reclaim_collateral(&alice, &option).is_err()); + assert_fails_with( + venue.reclaim_collateral(&alice, &option), + OptionsError::OptionNotHeld, + ); } #[test] @@ -934,7 +1087,10 @@ fn test_collect_fees_is_refused_for_anyone_but_the_admin() { let option = venue.write_call(&alice); venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); - assert!(venue.collect_fees_as(&mallory.keypair).is_err()); + assert_fails_with_anchor_error( + venue.collect_fees_as(&mallory.keypair), + AnchorErrorCode::ConstraintAddress, + ); assert_eq!(venue.market_state().fees_owed, 250_000); } @@ -961,9 +1117,9 @@ fn test_write_option_rejects_zero_amounts_and_a_free_premium() { premium, expiry, }; - assert!( - venue.write_option(&alice, id as u64 + 10, terms).is_err(), - "a zero in any term must be refused" + assert_fails_with( + venue.write_option(&alice, id as u64 + 10, terms), + OptionsError::InvalidParameter, ); } assert_eq!(venue.balance(&alice.underlying), FIVE_NVDAX); @@ -978,22 +1134,32 @@ fn test_write_option_rejects_an_expiry_that_has_passed() { let now = venue.now(); for expiry in [now, now - SECONDS_PER_DAY] { - assert!(venue.write_option(&alice, 20, call_terms(expiry)).is_err()); + assert_fails_with( + venue.write_option(&alice, 20, call_terms(expiry)), + OptionsError::ExpiryInPast, + ); } } #[test] fn test_initialize_market_rejects_a_full_fee() { - assert!(Venue::try_new(10_000, false).is_err()); + assert_fails_with( + Venue::try_new(10_000, false), + OptionsError::InvalidParameter, + ); } #[test] fn test_initialize_market_rejects_the_same_mint_on_both_sides() { - assert!(Venue::try_new(FEE_BPS, true).is_err()); + assert_fails_with( + Venue::try_new(FEE_BPS, true), + OptionsError::InvalidParameter, + ); } /// A venue run at cost is a valid choice: with a zero fee the writer -/// receives the whole premium and no fee transfer is attempted. +/// receives the whole premium in one transfer, and `buy_option` makes no +/// fee transfer at all rather than one for zero. #[test] fn test_zero_fee_venue_pays_the_writer_the_whole_premium() { let mut venue = Venue::try_new(0, false).unwrap(); @@ -1001,8 +1167,10 @@ fn test_zero_fee_venue_pays_the_writer_the_whole_premium() { let bob = venue.person(0, STANDARD_USDC); let option = venue.write_call(&alice); - venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); + let purchase = venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); + // The transfer to Alice, and nothing to the vault. + assert_eq!(cpi_count(&purchase), 1); assert_eq!(venue.balance(&alice.quote), STANDARD_USDC + CALL_PREMIUM); assert_eq!(venue.market_state().fees_owed, 0); venue.assert_vaults_match_ledger(); diff --git a/finance/options/kani-proofs/README.md b/finance/options/kani-proofs/README.md index f93e0d5fb..187df589b 100644 --- a/finance/options/kani-proofs/README.md +++ b/finance/options/kani-proofs/README.md @@ -35,11 +35,16 @@ in the declared ranges: exactly one of the holder (exercise) and the writer (reclaim) can claim a held option's collateral. Never both, never neither. - `proof_vault_ledger_stays_consistent_across_every_lifecycle`: **the core - custody property.** Two options of either kind are written into the shared + custody invariant.** Two options of either kind are written into the shared vaults and each takes one of its three exits (cancel; buy then reclaim; buy, exercise, collect), and after every step each vault holds exactly what the market owes. With every option closed and the fees swept, both vaults are empty: no token is created or lost on any path. +- `proof_collect_fees_pays_only_the_fees_owed`: the admin reaches only the + fees. From any ledger whose quote vault covers what it owes, plus any + surplus sent straight to the vault, one `collect_fees` pays the admin exactly + `fees_owed`, leaves every amount owed to writers and holders and the surplus + in the vault, zeroes `fees_owed`, and a second sweep pays nothing. ## Bounded model checking @@ -63,12 +68,15 @@ the bound: amounts, premiums and fee rates at most 255. The ledger arithmetic it exercises is additions and subtractions whose behavior does not depend on the magnitudes. +- `proof_collect_fees_pays_only_the_fees_owed`: fully symbolic; the owed + amounts, the fees and the surplus are any u64 whose sum fits in a vault + balance, since the sweep is one subtraction. ## Running ```bash -# Plain unit tests (no Kani needed), which also pin the exact numbers the -# LiteSVM tests and the book chapter use: +# Plain unit tests (no Kani needed), which pin the exact numbers the LiteSVM +# tests and the book chapter use and run the collect_fees check on them: cargo test # Full model check (requires cargo-kani): diff --git a/finance/options/kani-proofs/src/lib.rs b/finance/options/kani-proofs/src/lib.rs index 8385713d2..d5dfcbfda 100644 --- a/finance/options/kani-proofs/src/lib.rs +++ b/finance/options/kani-proofs/src/lib.rs @@ -262,14 +262,74 @@ impl Ledger { Some(()) } - /// `collect_fees`: the fees leave for the admin. - pub fn collect_fees(&mut self) -> Option<()> { - self.quote_vault = self.quote_vault.checked_sub(self.fees_owed)?; + /// `collect_fees`: the fees leave for the admin. Returns what the admin + /// is paid. The handler also refuses when nothing is owed + /// (`NothingToCollect`); the model leaves that to its callers, since the + /// lifecycle harness sweeps a venue whose fee may be zero. + pub fn collect_fees(&mut self) -> Option { + let payout = self.fees_owed; + self.quote_vault = self.quote_vault.checked_sub(payout)?; self.fees_owed = 0; - Some(()) + Some(payout) } } +/// The admin reaches only the fees. Starting from any ledger whose quote +/// vault covers what it owes, with `surplus` on top (tokens sent straight to +/// the vault, which the handlers' `>=` custody check tolerates), one +/// `collect_fees` pays the admin exactly `fees_owed`, never more; leaves every +/// amount owed to writers and holders, and the surplus, in the vault; and +/// leaves the counters consistent with the vault. Shared by the Kani harness, +/// which runs it on symbolic inputs, and a plain unit test, which runs it on +/// the chapter's numbers. +pub fn check_collect_fees_pays_only_the_fees_owed(mut ledger: Ledger, surplus: u64) { + let before = ledger; + let payout = ledger + .collect_fees() + .expect("the vault covers the fees owed"); + + assert_eq!(payout, before.fees_owed); + assert!(payout <= before.fees_owed); + assert_eq!(ledger.fees_owed, 0); + // Nothing owed to a writer or holder changed hands. + assert_eq!(ledger.quote_owed, before.quote_owed); + assert_eq!(ledger.underlying_owed, before.underlying_owed); + assert_eq!(ledger.underlying_vault, before.underlying_vault); + // The quote vault lost exactly the payout and still holds what it owes, + // plus the surplus the admin could not reach. + assert_eq!(ledger.quote_vault, before.quote_vault - payout); + assert_eq!(ledger.quote_vault, ledger.quote_owed + surplus); + assert!(ledger.quote_vault >= ledger.quote_owed + ledger.fees_owed); + // A second sweep pays nothing: there is nothing left that is the admin's. + assert_eq!(ledger.collect_fees(), Some(0)); + assert_eq!(ledger.quote_vault, before.quote_vault - payout); +} + +/// `collect_fees` on every ledger the program could hold: any amounts owed, +/// any fees, any surplus, as long as the sums fit in a vault balance. +#[cfg(kani)] +#[kani::proof] +fn proof_collect_fees_pays_only_the_fees_owed() { + let underlying_owed: u64 = kani::any(); + let quote_owed: u64 = kani::any(); + let fees_owed: u64 = kani::any(); + let surplus: u64 = kani::any(); + // collect_fees refuses a sweep of nothing. + kani::assume(fees_owed >= 1); + let quote_vault = quote_owed + .checked_add(fees_owed) + .and_then(|owed| owed.checked_add(surplus)); + kani::assume(quote_vault.is_some()); + let ledger = Ledger { + underlying_owed, + quote_owed, + fees_owed, + underlying_vault: underlying_owed, + quote_vault: quote_vault.unwrap(), + }; + check_collect_fees_pays_only_the_fees_owed(ledger, surplus); +} + /// Every path through an option's life leaves the ledger consistent and, once the /// option is closed and the fees swept, back at zero: cancel; buy then reclaim; /// buy then exercise then collect. Two options of either kind run through the @@ -445,8 +505,23 @@ mod tests { .unwrap(); // Maria sweeps 0.45 USDC and the vaults are empty. assert_eq!(ledger.fees_owed, 450_000); - ledger.collect_fees().unwrap(); + assert_eq!(ledger.collect_fees(), Some(450_000)); assert!(ledger.is_consistent()); assert_eq!(ledger, Ledger::default()); } + + #[test] + fn collect_fees_leaves_the_strike_payment_and_a_donation_in_the_vault() { + // After Bob's exercise: 900 USDC owed to Alice, 0.25 USDC of fees, + // and 3 USDC somebody sent straight to the vault. + let surplus = 3 * ONE_TOKEN; + let ledger = Ledger { + underlying_owed: 0, + quote_owed: 900 * ONE_TOKEN, + fees_owed: 250_000, + underlying_vault: 0, + quote_vault: 900 * ONE_TOKEN + 250_000 + surplus, + }; + check_collect_fees_pays_only_the_fees_owed(ledger, surplus); + } } diff --git a/finance/options/quasar/CHANGELOG.md b/finance/options/quasar/CHANGELOG.md index 10409853c..5759043bd 100644 --- a/finance/options/quasar/CHANGELOG.md +++ b/finance/options/quasar/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## Unreleased, 2026-10-04 + +The quasar-test suite asserts the error code of every refusal (`fails_with` +for the program's and Quasar's own errors, `fails` for the runtime's) instead +of only that the transaction failed. New test +`reclaim_collateral_after_expiry_returns_the_strike_to_the_put_writer` +follows the put from purchase to expiry and reclaim. +`writer_cannot_buy_their_own_option` asserts `AccountBorrowFailed`, the +refusal Quasar's account parsing gives the duplicate `buyer` and `writer` +slots. The purchase tests count the token program invocations in the logs, so +they show the token transfers a buy makes: two with a fee, one without. The +Kani crate gains `proof_collect_fees_pays_only_the_fees_owed`. + ## 2026-10-03 The option now stores `underlying_amount` and `strike_amount`, the two amounts diff --git a/finance/options/quasar/README.md b/finance/options/quasar/README.md index f3187cafd..3ba02a17f 100644 --- a/finance/options/quasar/README.md +++ b/finance/options/quasar/README.md @@ -26,6 +26,16 @@ differs in the Quasar version. `buy_option` checks that the account passed as `writer_quote` is owned by the writer and holds the quote token, so a buyer cannot route the premium to themselves (`buy_refuses_a_premium_account_the_writer_does_not_own`). +- **A writer buying their own option is refused by the duplicate-account + check, not by a token-account constraint.** The Anchor version refuses it + with `ConstraintDuplicateMutableAccount` because the premium's source and + destination resolve to the same associated token account. Here the writer's + 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 it with `AccountBorrowFailed` before the handler + runs, whichever of the writer's token accounts the premium would come from + (`writer_cannot_buy_their_own_option`). The same check is what refuses one + mint on both sides of `initialize_market`. - **State writes** use Quasar's zero-copy field accessors (`field.get()` / `field.set()`) and `set_inner`, rather than Anchor's account mutation. @@ -40,9 +50,15 @@ Tests run in-process with [`quasar-test`](https://github.com/blueshift-gg/quasar They set up both mints, a venue at a 1% fee, and the five characters with their token accounts, warp the clock to a fixed start time so the week-long expiry is deterministic, then walk the call from write to collected strike -and the put from write to exercise and to expiry, checking the custody -ledger against the vault balances after every step. Every gate has a test -that proves it shuts: the expiry boundary from both sides, cancel after -sale, buy after sale or expiry, exercise by a non-holder, collection by a -non-writer or before exercise, reclaim after exercise, fee collection by a -non-admin, and the parameter checks at write time. +and from purchase to expiry and reclaim, and the put from write to exercise +and from purchase to expiry and reclaim +(`reclaim_collateral_after_expiry_returns_the_strike_to_the_put_writer`), +pin every balance to the minor unit, count the token transfers a purchase +makes (two, or one on a zero-fee venue), and check the 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, +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/instructions/buy_option.rs b/finance/options/quasar/src/instructions/buy_option.rs index c58f02fe6..f9a9748cb 100644 --- a/finance/options/quasar/src/instructions/buy_option.rs +++ b/finance/options/quasar/src/instructions/buy_option.rs @@ -66,8 +66,10 @@ pub fn handle_buy_option(accounts: &mut BuyOptionAccountConstraints) -> Result<( OptionsError::InvalidParameter ); // A writer cannot buy their own option: the same address would sit in the - // `buyer` and `writer` slots at once, which the runtime refuses before - // this handler runs. + // `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 duplicate with `AccountBorrowFailed` before this handler runs, + // whichever of the writer's token accounts the premium would come from. let (fee, to_writer) = split_premium(accounts.option.premium.get(), accounts.market.fee_bps.get())?; diff --git a/finance/options/quasar/src/tests.rs b/finance/options/quasar/src/tests.rs index 9d59e41e6..857b729e1 100644 --- a/finance/options/quasar/src/tests.rs +++ b/finance/options/quasar/src/tests.rs @@ -14,6 +14,7 @@ use { errors::OptionsError, state::{Market, OptionContract, QuoteVaultPda, UnderlyingVaultPda}, }, + quasar_lang::error::QuasarError, quasar_test::prelude::*, }; @@ -317,6 +318,17 @@ fn assert_vaults_match_ledger(test: &Test, env: &Env) { ); } +/// How many CPIs the transaction's one instruction made: each one logs a +/// `Program invoke [2]` line. The handlers here call no program but the +/// token program, so this is the number of token transfers. +fn cpi_count(outcome: &Outcome) -> usize { + outcome + .logs() + .iter() + .filter(|log| log.ends_with("invoke [2]")) + .count() +} + // =========================================================================== // The call: write, buy, exercise, collect // =========================================================================== @@ -357,18 +369,21 @@ fn write_call_moves_underlying_into_vault(test: &mut Test) { } /// Bob buys the option. He pays 25 USDC: 1% (0.25 USDC) to the venue, the rest -/// straight to Alice. The 5 NVDAx do not move. +/// straight to Alice, as two transfers. The 5 NVDAx do not move. #[quasar_test] fn buy_option_pays_the_premium_minus_the_fee(test: &mut Test) { let env = setup(test); let option = write_call(test, &env); let fee = 250_000; // 0.25 USDC - buy_option(test, &env, &BOB_P, &ALICE_P, CALL_ID) + let purchase = buy_option(test, &env, &BOB_P, &ALICE_P, CALL_ID); + purchase .succeeds() .has_tokens(BOB_USDC, STANDARD_USDC - CALL_PREMIUM) .has_tokens(ALICE_USDC, STANDARD_USDC + CALL_PREMIUM - fee) .has_tokens(env.quote_vault, fee); + // One transfer to Alice, one to the vault. + assert_eq!(cpi_count(&purchase), 2); // The underlying vault is not part of a buy, and did not move. assert_eq!(test.tokens(env.underlying_vault), FIVE_NVDAX); @@ -511,6 +526,40 @@ fn reclaim_collateral_after_expiry_returns_it_to_the_writer(test: &mut Test) { assert_vaults_match_ledger(test, &env); } +/// Dave never exercises: the week passes above the 150 strike. At expiry +/// Carol takes her 750 USDC of collateral back, keeping the 19.80 USDC of +/// premium, and the option closes with its rent back to her. Dave keeps his +/// 5 NVDAx and is out the 20 USDC he paid; the venue's 0.20 USDC fee stays in +/// the vault for Maria. +#[quasar_test] +fn reclaim_collateral_after_expiry_returns_the_strike_to_the_put_writer(test: &mut Test) { + let env = setup(test); + let option = write_put(test, &env); + buy_option(test, &env, &DAVE_P, &CAROL_P, PUT_ID).succeeds(); + let fee = 200_000; // 1% of 20 USDC + let carol_lamports_before = test.lamports(CAROL); + + test.warp_to_timestamp(EXPIRY); + 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_tokens(env.underlying_vault, 0) + .is_closed(option); + assert_eq!(test.tokens(DAVE_USDC), STANDARD_USDC - PUT_PREMIUM); + assert_eq!(test.tokens(DAVE_NVDAX), FIVE_NVDAX); + assert!( + test.lamports(CAROL) > carol_lamports_before, + "the option's rent must return to the writer" + ); + 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); +} + // =========================================================================== // The expiry boundary, from both sides // =========================================================================== @@ -607,13 +656,17 @@ fn buy_is_refused_once_sold(test: &mut Test) { } /// A writer cannot buy their own option: their address would sit in the `buyer` -/// and `writer` slots at once, which the runtime refuses before the handler -/// runs, whichever of their token accounts the premium is paid from. +/// 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 +/// duplicate with `AccountBorrowFailed` before any handler code runs, +/// whichever of their token accounts the premium is paid from. The option +/// stays listed and no USDC moves. #[quasar_test] fn writer_cannot_buy_their_own_option(test: &mut Test) { let env = setup(test); let option = write_call(test, &env); - assert!(buy_option(test, &env, &ALICE_P, &ALICE_P, CALL_ID).is_err()); + buy_option(test, &env, &ALICE_P, &ALICE_P, CALL_ID) + .fails(ProgramError::Runtime("AccountBorrowFailed".into())); test.add( TokenAccount::new(USDC_MINT, ALICE) @@ -621,9 +674,12 @@ fn writer_cannot_buy_their_own_option(test: &mut Test) { .amount(STANDARD_USDC), ); let alice_from_other_account = person(ALICE, ALICE_NVDAX, ALICE_OTHER_USDC); - assert!(buy_option(test, &env, &alice_from_other_account, &ALICE_P, CALL_ID).is_err()); + buy_option(test, &env, &alice_from_other_account, &ALICE_P, CALL_ID) + .fails(ProgramError::Runtime("AccountBorrowFailed".into())); assert_eq!(test.read::(option).status, STATUS_LISTED); + assert_eq!(test.tokens(ALICE_USDC), STANDARD_USDC); assert_eq!(test.tokens(ALICE_OTHER_USDC), STANDARD_USDC); + assert_eq!(u64::from(test.read::(env.market).fees_owed), 0); } /// A buyer cannot route the premium to their own account by passing it as @@ -656,15 +712,19 @@ fn buy_refuses_a_premium_account_the_writer_does_not_own(test: &mut Test) { assert_eq!(test.tokens(MALLORY_USDC), STANDARD_USDC); } -/// Only the holder can exercise. +/// Only the holder can exercise: an unsold option has no holder, and a stranger +/// is not the holder of a sold one. Both refusals are the option's +/// `has_one(holder)` constraint. #[quasar_test] fn exercise_is_refused_for_anyone_but_the_holder(test: &mut Test) { let env = setup(test); write_call(test, &env); // Unsold: the holder field is all zeroes, which no signer can match. - assert!(exercise_option(test, &env, &MALLORY_P, &ALICE_P, CALL_ID).is_err()); + exercise_option(test, &env, &MALLORY_P, &ALICE_P, CALL_ID) + .fails_with(QuasarError::HasOneMismatch); buy_option(test, &env, &BOB_P, &ALICE_P, CALL_ID).succeeds(); - assert!(exercise_option(test, &env, &MALLORY_P, &ALICE_P, CALL_ID).is_err()); + exercise_option(test, &env, &MALLORY_P, &ALICE_P, CALL_ID) + .fails_with(QuasarError::HasOneMismatch); assert_eq!(test.tokens(env.underlying_vault), FIVE_NVDAX); } @@ -677,9 +737,12 @@ fn collect_proceeds_needs_an_exercised_option_and_the_writer(test: &mut Test) { collect_proceeds(test, &env, &ALICE_P, CALL_ID).fails_with(OptionsError::OptionNotExercised); exercise_option(test, &env, &BOB_P, &ALICE_P, CALL_ID).succeeds(); - // A non-writer's signature derives a different option PDA, so the - // account check fails before the handler runs. - assert!(collect_proceeds(test, &env, &BOB_P, CALL_ID).is_err()); + // A non-writer's signature derives a different option PDA: an address + // nobody created, so the runtime hands the program an empty account owned + // by the system program, and Quasar refuses to read it as an + // `OptionContract` with `IllegalOwner` before the handler runs. + collect_proceeds(test, &env, &BOB_P, CALL_ID) + .fails(ProgramError::Runtime("IllegalOwner".into())); assert_eq!(test.tokens(env.quote_vault), CALL_STRIKE_AMOUNT + 250_000); } @@ -700,7 +763,7 @@ fn collect_fees_is_refused_for_anyone_but_the_admin(test: &mut Test) { let env = setup(test); write_call(test, &env); buy_option(test, &env, &BOB_P, &ALICE_P, CALL_ID).succeeds(); - assert!(collect_fees(test, &env, MALLORY, MALLORY_USDC).is_err()); + collect_fees(test, &env, MALLORY, MALLORY_USDC).fails_with(QuasarError::HasOneMismatch); assert_eq!( u64::from(test.read::(env.market).fees_owed), 250_000 @@ -781,24 +844,30 @@ fn initialize_market_rejects_a_full_fee(test: &mut Test) { initialize_market(test, 10_000, USDC_MINT).fails_with(OptionsError::InvalidParameter); } -/// One mint on both sides is refused by the runtime before the handler's own -/// check runs: the same account cannot be loaded into two slots. +/// One mint on both sides is refused before the handler's own check runs: the +/// runtime hands the program the second slot as a duplicate of the first, and +/// Quasar's account parsing refuses it with `AccountBorrowFailed`. #[quasar_test] fn initialize_market_rejects_the_same_mint_on_both_sides(test: &mut Test) { test.add(Wallet::new().at(MARIA)); test.add(Mint::new(MARIA).at(NVDAX_MINT).decimals(6)); - assert!(initialize_market(test, FEE_BPS, NVDAX_MINT).is_err()); + initialize_market(test, FEE_BPS, NVDAX_MINT) + .fails(ProgramError::Runtime("AccountBorrowFailed".into())); } -/// A venue run at cost is a valid choice: the writer receives the whole -/// premium and no fee transfer is attempted. +/// A venue run at cost is a valid choice: with a zero fee the writer +/// receives the whole premium in one transfer, and `buy_option` makes no +/// fee transfer at all rather than one for zero. #[quasar_test] fn zero_fee_venue_pays_the_writer_the_whole_premium(test: &mut Test) { let env = setup_with_fee(test, 0); write_call(test, &env); - buy_option(test, &env, &BOB_P, &ALICE_P, CALL_ID) + let purchase = buy_option(test, &env, &BOB_P, &ALICE_P, CALL_ID); + purchase .succeeds() .has_tokens(ALICE_USDC, STANDARD_USDC + CALL_PREMIUM); + // The transfer to Alice, and nothing to the vault. + assert_eq!(cpi_count(&purchase), 1); assert_eq!(u64::from(test.read::(env.market).fees_owed), 0); assert_vaults_match_ledger(test, &env); } diff --git a/finance/order-book/anchor-v1/CHANGELOG.md b/finance/order-book/anchor-v1/CHANGELOG.md index 7ca0a190a..27b9bebb8 100644 --- a/finance/order-book/anchor-v1/CHANGELOG.md +++ b/finance/order-book/anchor-v1/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## Unreleased - 2026-10-04 + +### Added + +- `pause_market` and `resume_market`, signed by the market authority + (anyone else gets `NotMarketAuthority`). `pause_market` clears + `Market.is_active`, which `initialize_market` set and nothing cleared, so + the `MarketPaused` refusal in `place_order` can now happen. + `resume_market` sets the flag again. A pause stops new orders and nothing + else: `cancel_order`, `settle_funds` and `withdraw_fees` do not read the + flag. Tests: `pause_market_refuses_new_orders_with_market_paused`, + `paused_market_still_cancels_and_settles_a_resting_order`, + `paused_market_still_pays_out_fills_and_withdraws_fees`, + `resume_market_accepts_orders_again`, + `only_the_market_authority_can_pause_or_resume`. + +### Changed + +- `NotMarketAuthority`'s message names all three handlers it guards. +- The README's code layout shows `withdraw_fees.rs` under + `instructions/admin/`, where it is. + ## 2026-10-03 ### Added diff --git a/finance/order-book/anchor-v1/README.md b/finance/order-book/anchor-v1/README.md index 9d14322b6..87f5edfda 100644 --- a/finance/order-book/anchor-v1/README.md +++ b/finance/order-book/anchor-v1/README.md @@ -498,7 +498,7 @@ this directly (`settle_funds_after_match_pays_out_both_unsettled_balances`). ## 3. Instruction lifecycle walkthrough -The program has six instruction handlers. The order a user encounters +The program has eight instruction handlers. The order a user encounters them is: 1. `initialize_market` (market operator - once) @@ -507,6 +507,8 @@ them is: 4. `cancel_order` (a user - to remove a resting order) 5. `settle_funds` (a user - to collect winnings) 6. `withdraw_fees` (market authority - to collect program revenue) +7. `pause_market` (market authority - to stop new orders) +8. `resume_market` (market authority - to take orders again) For each, the shape is: who signs, what accounts go in, what PDAs get created, what token flows happen, what state mutates, what checks are @@ -647,7 +649,7 @@ resting order. **Checks (top of handler):** -- `market.is_active` → `MarketPaused` +- `market.is_active` (false after `pause_market`) → `MarketPaused` - `price > 0` → `InvalidPrice` - `price % tick_size == 0` → `InvalidTickSize` - `quantity >= min_order_size` → `BelowMinOrderSize` @@ -886,6 +888,47 @@ zero as a side effect of the transfer). --- +### 3.7 `pause_market` and `resume_market` + +**Who calls them:** the market authority, when the market must stop +taking orders (a bad price feed on the operator's side, a mint whose +issuer has halted transfers, a bug under investigation) and when it +may take them again. + +**Signers:** `authority`. + +**Accounts in (both):** + +- `market` (mut, `has_one = authority`) +- `authority` (signer) + +**Checks:** + +- `authority.key() == market.authority` → `NotMarketAuthority` + +**Token movements:** none. + +**State changes:** `pause_market` sets `market.is_active = false`; +`resume_market` sets it back to `true`. + +A pause stops new orders and nothing else. `place_order` is the only +handler that reads `is_active`, so while the market is paused: + +- `place_order` → `MarketPaused` +- `cancel_order` still cancels a resting order and credits its locked + tokens to the owner's unsettled balance +- `settle_funds` still pays unsettled balances out of the vaults +- `withdraw_fees` still empties the fee vault to the authority + +Let a pause stop deposits and trades, never withdrawals: every token +a trader locked or was owed before the pause can still leave the +vaults during it. The tests +`paused_market_still_cancels_and_settles_a_resting_order` and +`paused_market_still_pays_out_fills_and_withdraws_fees` run each exit +on a paused market. + +--- + ## 4. The matching engine - step by step This is the heart of the program. Everything in `place_order` after @@ -1321,7 +1364,7 @@ From [`errors.rs`](programs/order-book/src/errors.rs): - `InvalidPrice`: `place_order` called with `price == 0` - `OrderNotFound`: `cancel_order` failed to locate the order in the book (sanity path) -- `MarketPaused`: `place_order` on a market with `is_active = false` (no handler flips this today, but the field is there) +- `MarketPaused`: `place_order` on a market `pause_market` has paused and `resume_market` has not reopened - `Unauthorized`: `cancel_order` by someone other than the order owner - `OrderBookFull`: `place_order` remainder would rest on a side holding 512 orders without beating that side's worst price - `MissingEvictedAccounts`: A full side, and the worst resting order (with its owner's MarketUser) was not passed after the maker pairs @@ -1336,7 +1379,7 @@ From [`errors.rs`](programs/order-book/src/errors.rs): - `MakerAccountMismatch`: Wrong number of maker accounts, wrong order, wrong market, or caller walked the book out of order - `MissingMakerAccounts`: Fewer remaining accounts than two per planned fill - `MakerOwnerMismatch`: Maker Order and MarketUser have different owners -- `NotMarketAuthority`: `withdraw_fees` called by wrong signer +- `NotMarketAuthority`: `withdraw_fees`, `pause_market` or `resume_market` signed by anyone but `market.authority` ### 6.2 Guarded design choices worth knowing @@ -1385,6 +1428,11 @@ From [`errors.rs`](programs/order-book/src/errors.rs): - **`withdraw_fees` no-ops on empty.** Likewise. +- **A pause stops new orders only.** `pause_market` clears + `is_active`, and `place_order` is the only handler that reads it. + Cancels, settlements and fee withdrawals never check the flag, so + nobody's tokens are locked in a paused market. + - **Boxed InterfaceAccounts.** Several handlers use `Box< InterfaceAccount<...>>` for mint/token accounts. That's a BPF stack-size workaround - each `InterfaceAccount` is ~1 KB on the @@ -1434,8 +1482,6 @@ A production order book would add: ATA at match time. - **Minimum-tick for quantities.** `min_order_size` is a floor, but there's no "round lot" constraint. -- **Pause / admin / upgrade.** `is_active` exists but no handler - flips it. - **Oracle-aware price bands.** A taker bid 10 000× higher than the best ask will happily sweep the book. @@ -1473,7 +1519,7 @@ anchor test --skip-local-validator Expected: ``` -running 35 tests +running 40 tests test authority_can_withdraw_fees_after_match ... ok test better_order_evicts_the_worst_and_rests ... ok test cancel_and_settle_bid_refunds_full_quote ... ok @@ -1493,12 +1539,17 @@ test initialize_market_rejects_zero_quote_lot_size ... ok test initialize_market_rejects_zero_tick_size ... ok test initialize_market_sets_market_and_order_book ... ok test initialize_market_user_tracks_market_and_owner ... ok +test only_the_market_authority_can_pause_or_resume ... ok +test pause_market_refuses_new_orders_with_market_paused ... ok +test paused_market_still_cancels_and_settles_a_resting_order ... ok +test paused_market_still_pays_out_fills_and_withdraws_fees ... ok test place_ask_moves_base_into_vault ... ok test place_bid_moves_quote_into_vault ... ok test place_order_rejects_below_min_order_size ... ok test place_order_rejects_unaligned_tick ... ok test place_order_rejects_zero_price ... ok test resting_orders_at_same_price_fill_by_time_priority ... ok +test resume_market_accepts_orders_again ... ok test settle_funds_after_match_pays_out_both_unsettled_balances ... ok test settle_funds_moves_unsettled_base_to_user ... ok test settle_funds_rejects_fee_vault_substituted_for_quote_vault ... ok @@ -1562,6 +1613,14 @@ test trader_can_evict_their_own_worst_order ... ok - `trader_can_evict_their_own_worst_order`: Only the `Order` account is passed, and the caller's own `MarketUser` is credited - `full_side_cancel_of_the_last_scanned_order_fits_the_default_budget`: Canceling the bid found last among 512 stays inside the default 200,000-unit budget +**Pause and resume:** + +- `pause_market_refuses_new_orders_with_market_paused`: An ask on a paused market gets `MarketPaused` and locks nothing +- `paused_market_still_cancels_and_settles_a_resting_order`: An ask placed before the pause is cancelled and settled during it +- `paused_market_still_pays_out_fills_and_withdraws_fees`: A maker settles a fill, and the authority withdraws the fee, during a pause +- `resume_market_accepts_orders_again`: The ask refused during the pause rests after `resume_market` +- `only_the_market_authority_can_pause_or_resume`: A trader signing either handler gets `NotMarketAuthority`, and the market's state does not change + ### CI note The repo's `.github/workflows/anchor-v1.yml` runs `anchor build` before @@ -1677,7 +1736,11 @@ finance/order-book/anchor/ │ │ ├── place_order.rs (matching engine lives here) │ │ ├── cancel_order.rs │ │ ├── settle_funds.rs - │ │ └── withdraw_fees.rs + │ │ └── admin/ (market-authority handlers) + │ │ ├── mod.rs + │ │ ├── withdraw_fees.rs + │ │ ├── pause_market.rs + │ │ └── resume_market.rs │ └── state/ │ ├── mod.rs │ ├── market.rs diff --git a/finance/order-book/anchor-v1/programs/order-book/src/errors.rs b/finance/order-book/anchor-v1/programs/order-book/src/errors.rs index 294a644ec..5977c84c5 100644 --- a/finance/order-book/anchor-v1/programs/order-book/src/errors.rs +++ b/finance/order-book/anchor-v1/programs/order-book/src/errors.rs @@ -65,7 +65,7 @@ pub enum ErrorCode { #[msg("Maker order and maker MarketUser owner mismatch")] MakerOwnerMismatch, - #[msg("Only the market authority can withdraw fees")] + #[msg("Only the market authority can withdraw fees, pause the market, or resume it")] NotMarketAuthority, #[msg("Order book account does not match the market's order book")] diff --git a/finance/order-book/anchor-v1/programs/order-book/src/instructions/admin/mod.rs b/finance/order-book/anchor-v1/programs/order-book/src/instructions/admin/mod.rs index ef5bf86ce..264ce2692 100644 --- a/finance/order-book/anchor-v1/programs/order-book/src/instructions/admin/mod.rs +++ b/finance/order-book/anchor-v1/programs/order-book/src/instructions/admin/mod.rs @@ -1,3 +1,7 @@ +pub mod pause_market; +pub mod resume_market; pub mod withdraw_fees; +pub use pause_market::*; +pub use resume_market::*; pub use withdraw_fees::*; diff --git a/finance/order-book/anchor-v1/programs/order-book/src/instructions/admin/pause_market.rs b/finance/order-book/anchor-v1/programs/order-book/src/instructions/admin/pause_market.rs new file mode 100644 index 000000000..7fde992f6 --- /dev/null +++ b/finance/order-book/anchor-v1/programs/order-book/src/instructions/admin/pause_market.rs @@ -0,0 +1,25 @@ +use anchor_lang::prelude::*; + +use crate::errors::ErrorCode; +use crate::state::Market; + +/// Stop the market taking new orders. While `is_active` is false, +/// `place_order` is refused with `MarketPaused`; `cancel_order`, +/// `settle_funds` and `withdraw_fees` do not read the flag, so a pause +/// never stops anyone getting their tokens out. `resume_market` reopens +/// the market. +pub fn handle_pause_market(context: Context) -> Result<()> { + context.accounts.market.is_active = false; + Ok(()) +} + +#[derive(Accounts)] +pub struct PauseMarketAccountConstraints<'info> { + #[account( + mut, + has_one = authority @ ErrorCode::NotMarketAuthority, + )] + pub market: Account<'info, Market>, + + pub authority: Signer<'info>, +} diff --git a/finance/order-book/anchor-v1/programs/order-book/src/instructions/admin/resume_market.rs b/finance/order-book/anchor-v1/programs/order-book/src/instructions/admin/resume_market.rs new file mode 100644 index 000000000..ea3e96271 --- /dev/null +++ b/finance/order-book/anchor-v1/programs/order-book/src/instructions/admin/resume_market.rs @@ -0,0 +1,22 @@ +use anchor_lang::prelude::*; + +use crate::errors::ErrorCode; +use crate::state::Market; + +/// Reopen a market that `pause_market` stopped: sets `is_active` back to +/// true, so `place_order` accepts orders again. +pub fn handle_resume_market(context: Context) -> Result<()> { + context.accounts.market.is_active = true; + Ok(()) +} + +#[derive(Accounts)] +pub struct ResumeMarketAccountConstraints<'info> { + #[account( + mut, + has_one = authority @ ErrorCode::NotMarketAuthority, + )] + pub market: Account<'info, Market>, + + pub authority: Signer<'info>, +} 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 e16ad393c..c512b5a1d 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 @@ -79,4 +79,18 @@ pub mod order_book { pub fn withdraw_fees(context: Context) -> Result<()> { instructions::withdraw_fees::handle_withdraw_fees(context) } + + /// Stop the market taking new orders: `place_order` is refused with + /// `MarketPaused` until `resume_market`. Cancels, settlements and fee + /// withdrawals keep working while the market is paused. Only the + /// market's stored `authority` may call this. + pub fn pause_market(context: Context) -> Result<()> { + instructions::pause_market::handle_pause_market(context) + } + + /// Reopen a paused market so `place_order` accepts orders again. Only + /// the market's stored `authority` may call this. + pub fn resume_market(context: Context) -> Result<()> { + instructions::resume_market::handle_resume_market(context) + } } diff --git a/finance/order-book/anchor-v1/programs/order-book/src/state/market.rs b/finance/order-book/anchor-v1/programs/order-book/src/state/market.rs index bccfde907..ad773b773 100644 --- a/finance/order-book/anchor-v1/programs/order-book/src/state/market.rs +++ b/finance/order-book/anchor-v1/programs/order-book/src/state/market.rs @@ -71,6 +71,11 @@ pub struct Market { pub min_order_size: u64, + // True from `initialize_market` until `pause_market` clears it; + // `resume_market` sets it again. `place_order` refuses a market whose + // flag is false with `MarketPaused`. Cancels, settlements and fee + // withdrawals never read it, so a pause stops new orders and nothing + // else. pub is_active: bool, pub bump: u8, diff --git a/finance/order-book/anchor-v1/programs/order-book/tests/test_order_book.rs b/finance/order-book/anchor-v1/programs/order-book/tests/test_order_book.rs index cfcc992bd..8e1171843 100644 --- a/finance/order-book/anchor-v1/programs/order-book/tests/test_order_book.rs +++ b/finance/order-book/anchor-v1/programs/order-book/tests/test_order_book.rs @@ -7,7 +7,9 @@ //! the vaults, and - in the matching block near the bottom - cross incoming //! orders against resting orders using price-time priority, charge the //! configured taker fee to a fee vault, and drain the fee vault via -//! `withdraw_fees`. +//! `withdraw_fees`. The pause block at the end checks that `pause_market` +//! stops new orders and nothing else, and that `resume_market` reopens the +//! market. use { anchor_lang::{ @@ -409,6 +411,30 @@ fn build_withdraw_fees_ix(sc: &Scenario, authority_quote_account: Pubkey) -> Ins ) } +fn build_pause_market_ix(sc: &Scenario, authority: &Pubkey) -> Instruction { + Instruction::new_with_bytes( + sc.program_id, + &order_book::instruction::PauseMarket {}.data(), + order_book::accounts::PauseMarketAccountConstraints { + market: sc.market, + authority: *authority, + } + .to_account_metas(None), + ) +} + +fn build_resume_market_ix(sc: &Scenario, authority: &Pubkey) -> Instruction { + Instruction::new_with_bytes( + sc.program_id, + &order_book::instruction::ResumeMarket {}.data(), + order_book::accounts::ResumeMarketAccountConstraints { + market: sc.market, + authority: *authority, + } + .to_account_metas(None), + ) +} + fn build_cancel_order_ix( sc: &Scenario, owner: &Pubkey, @@ -2801,3 +2827,281 @@ fn full_side_cancel_of_the_last_scanned_order_fits_the_default_budget() { ); assert_eq!(status, ORDER_STATUS_CANCELLED); } + +// --------------------------------------------------------------------------- +// Pause and resume: a pause stops new orders and nothing else. +// --------------------------------------------------------------------------- + +// The pause tests share one resting ask and one crossing bid. Chosen apart +// from the suite's other sizes so a balance that matches is this test's own. +const PAUSE_ASK_ID: u64 = 1; +const PAUSE_BID_ID: u64 = 2; +const PAUSE_PRICE: u64 = 1_300; +const PAUSE_QUANTITY: u64 = 7; +const PAUSE_GROSS: u64 = PAUSE_PRICE * PAUSE_QUANTITY * QUOTE_LOT_SIZE; +const PAUSE_LOCKED_BASE: u64 = PAUSE_QUANTITY * BASE_LOT_SIZE; + +/// Sign `pause_market` or `resume_market` as `signer`. +fn send_market_switch( + svm: &mut LiteSVM, + instruction: Instruction, + signer: &Keypair, +) -> Result<(), String> { + send_transaction_from_instructions(svm, vec![instruction], &[signer], &signer.pubkey()) + .map_err(|error| format!("{error:?}")) +} + +/// The seller places the pause tests' ask. Returns the program's error text +/// when the market refuses it. +fn place_pause_ask(sc: &mut Scenario) -> Result<(), String> { + let ix = build_place_order_ix( + sc, + &sc.seller, + sc.seller_market_user, + sc.seller_base_ata, + sc.seller_quote_ata, + order_book::state::OrderSide::Ask, + PAUSE_ASK_ID, + PAUSE_PRICE, + PAUSE_QUANTITY, + ); + send_transaction_from_instructions(&mut sc.svm, vec![ix], &[&sc.seller], &sc.seller.pubkey()) + .map_err(|error| format!("{error:?}")) +} + +#[test] +fn pause_market_refuses_new_orders_with_market_paused() { + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + + let pause_ix = build_pause_market_ix(&sc, &sc.authority.pubkey()); + send_market_switch(&mut sc.svm, pause_ix, &sc.authority).unwrap(); + + let error = place_pause_ask(&mut sc).expect_err("a paused market must not take an order"); + assert!( + error.contains(&custom_error(order_book::errors::ErrorCode::MarketPaused)), + "{error}" + ); + // The refused ask locked nothing. + assert_eq!( + get_token_account_balance(&sc.svm, &sc.base_vault).unwrap(), + 0 + ); + assert_eq!( + get_token_account_balance(&sc.svm, &sc.seller_base_ata).unwrap(), + TRADER_STARTING_BALANCE + ); +} + +#[test] +fn paused_market_still_cancels_and_settles_a_resting_order() { + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + + place_pause_ask(&mut sc).unwrap(); + assert_eq!( + get_token_account_balance(&sc.svm, &sc.base_vault).unwrap(), + PAUSE_LOCKED_BASE + ); + + let pause_ix = build_pause_market_ix(&sc, &sc.authority.pubkey()); + send_market_switch(&mut sc.svm, pause_ix, &sc.authority).unwrap(); + + // The ask was placed before the pause; its owner can still cancel it + // and take the locked NVDAx back out while the market is paused. + let cancel_ix = build_cancel_order_ix( + &sc, + &sc.seller.pubkey(), + sc.seller_market_user, + PAUSE_ASK_ID, + ); + let settle_ix = build_settle_funds_ix( + &sc, + &sc.seller.pubkey(), + sc.seller_market_user, + sc.seller_base_ata, + sc.seller_quote_ata, + ); + send_transaction_from_instructions( + &mut sc.svm, + vec![cancel_ix, settle_ix], + &[&sc.seller], + &sc.seller.pubkey(), + ) + .unwrap(); + + let (_, status) = read_order_fill_and_status( + &sc.svm, + &order_pda(&sc.program_id, &sc.market, PAUSE_ASK_ID), + ); + assert_eq!(status, ORDER_STATUS_CANCELLED); + assert_eq!( + get_token_account_balance(&sc.svm, &sc.base_vault).unwrap(), + 0 + ); + assert_eq!( + get_token_account_balance(&sc.svm, &sc.seller_base_ata).unwrap(), + TRADER_STARTING_BALANCE + ); +} + +#[test] +fn paused_market_still_pays_out_fills_and_withdraws_fees() { + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + + let authority_quote_ata = create_associated_token_account( + &mut sc.svm, + &sc.authority.pubkey(), + &sc.quote_mint, + &sc.payer, + ) + .unwrap(); + + // A fill before the pause leaves the seller owed quote in their + // unsettled balance and the fee vault holding the taker fee. + place_pause_ask(&mut sc).unwrap(); + let bid_ix = build_place_order_with_makers_ix( + &sc, + &sc.buyer, + sc.buyer_market_user, + sc.buyer_base_ata, + sc.buyer_quote_ata, + order_book::state::OrderSide::Bid, + PAUSE_BID_ID, + PAUSE_PRICE, + PAUSE_QUANTITY, + &[(PAUSE_ASK_ID, sc.seller_market_user)], + ); + send_transaction_from_instructions(&mut sc.svm, vec![bid_ix], &[&sc.buyer], &sc.buyer.pubkey()) + .unwrap(); + let expected_fee = fee_ceil(PAUSE_GROSS); + assert_eq!( + get_token_account_balance(&sc.svm, &sc.fee_vault).unwrap(), + expected_fee + ); + + let pause_ix = build_pause_market_ix(&sc, &sc.authority.pubkey()); + send_market_switch(&mut sc.svm, pause_ix, &sc.authority).unwrap(); + + // The maker's settlement goes through while paused. + let settle_ix = build_settle_funds_ix( + &sc, + &sc.seller.pubkey(), + sc.seller_market_user, + sc.seller_base_ata, + sc.seller_quote_ata, + ); + send_transaction_from_instructions( + &mut sc.svm, + vec![settle_ix], + &[&sc.seller], + &sc.seller.pubkey(), + ) + .unwrap(); + assert_eq!( + get_token_account_balance(&sc.svm, &sc.seller_quote_ata).unwrap(), + PAUSE_GROSS - expected_fee + ); + + // So does the authority's fee withdrawal. + let withdraw_ix = build_withdraw_fees_ix(&sc, authority_quote_ata); + send_transaction_from_instructions( + &mut sc.svm, + vec![withdraw_ix], + &[&sc.authority], + &sc.authority.pubkey(), + ) + .unwrap(); + assert_eq!( + get_token_account_balance(&sc.svm, &sc.fee_vault).unwrap(), + 0 + ); + assert_eq!( + get_token_account_balance(&sc.svm, &authority_quote_ata).unwrap(), + expected_fee + ); +} + +#[test] +fn resume_market_accepts_orders_again() { + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + + let pause_ix = build_pause_market_ix(&sc, &sc.authority.pubkey()); + send_market_switch(&mut sc.svm, pause_ix, &sc.authority).unwrap(); + let error = place_pause_ask(&mut sc).expect_err("a paused market must not take an order"); + assert!( + error.contains(&custom_error(order_book::errors::ErrorCode::MarketPaused)), + "{error}" + ); + + let resume_ix = build_resume_market_ix(&sc, &sc.authority.pubkey()); + send_market_switch(&mut sc.svm, resume_ix, &sc.authority).unwrap(); + + // The same ask, refused a moment ago, now rests and locks its base. Its + // bytes are identical to the refused transaction's, so it needs a fresh + // blockhash to be taken as a new transaction. + sc.svm.expire_blockhash(); + place_pause_ask(&mut sc).unwrap(); + assert_eq!( + get_token_account_balance(&sc.svm, &sc.base_vault).unwrap(), + PAUSE_LOCKED_BASE + ); +} + +#[test] +fn only_the_market_authority_can_pause_or_resume() { + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + + // A trader signing `pause_market` is refused, and the market stays open: + // the ask goes through afterwards. + let pause_ix = build_pause_market_ix(&sc, &sc.buyer.pubkey()); + let error = send_market_switch(&mut sc.svm, pause_ix, &sc.buyer) + .expect_err("only the market authority may pause"); + assert!( + error.contains(&custom_error( + order_book::errors::ErrorCode::NotMarketAuthority + )), + "{error}" + ); + place_pause_ask(&mut sc).unwrap(); + + // Once the authority has paused, a trader signing `resume_market` is + // refused too, and the market stays paused: the buyer's bid is refused. + let pause_ix = build_pause_market_ix(&sc, &sc.authority.pubkey()); + send_market_switch(&mut sc.svm, pause_ix, &sc.authority).unwrap(); + let resume_ix = build_resume_market_ix(&sc, &sc.buyer.pubkey()); + let error = send_market_switch(&mut sc.svm, resume_ix, &sc.buyer) + .expect_err("only the market authority may resume"); + assert!( + error.contains(&custom_error( + order_book::errors::ErrorCode::NotMarketAuthority + )), + "{error}" + ); + let bid_ix = build_place_order_ix( + &sc, + &sc.buyer, + sc.buyer_market_user, + sc.buyer_base_ata, + sc.buyer_quote_ata, + order_book::state::OrderSide::Bid, + PAUSE_BID_ID, + PAUSE_PRICE, + PAUSE_QUANTITY, + ); + let error = send_transaction_from_instructions( + &mut sc.svm, + vec![bid_ix], + &[&sc.buyer], + &sc.buyer.pubkey(), + ) + .map_err(|error| format!("{error:?}")) + .expect_err("a paused market must not take an order"); + assert!( + error.contains(&custom_error(order_book::errors::ErrorCode::MarketPaused)), + "{error}" + ); +} diff --git a/finance/order-book/anchor/CHANGELOG.md b/finance/order-book/anchor/CHANGELOG.md index ae7c562eb..ca62637c6 100644 --- a/finance/order-book/anchor/CHANGELOG.md +++ b/finance/order-book/anchor/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## Unreleased - 2026-10-04 + +### Added + +- `pause_market` and `resume_market`, signed by the market authority + (anyone else gets `NotMarketAuthority`). `pause_market` clears + `Market.is_active`, which `initialize_market` set and nothing cleared, so + the `MarketPaused` refusal in `place_order` can now happen. + `resume_market` sets the flag again. A pause stops new orders and nothing + else: `cancel_order`, `settle_funds` and `withdraw_fees` do not read the + flag. Tests: `pause_market_refuses_new_orders_with_market_paused`, + `paused_market_still_cancels_and_settles_a_resting_order`, + `paused_market_still_pays_out_fills_and_withdraws_fees`, + `resume_market_accepts_orders_again`, + `only_the_market_authority_can_pause_or_resume`. + +### Changed + +- `NotMarketAuthority`'s message names all three handlers it guards. +- The README's code layout shows `withdraw_fees.rs` under + `instructions/admin/`, where it is. + ## 2026-10-03 ### Added diff --git a/finance/order-book/anchor/README.md b/finance/order-book/anchor/README.md index bf96c3fb7..bc9d27e29 100644 --- a/finance/order-book/anchor/README.md +++ b/finance/order-book/anchor/README.md @@ -503,7 +503,7 @@ this directly (`settle_funds_after_match_pays_out_both_unsettled_balances`). ## 3. Instruction lifecycle walkthrough -The program has six instruction handlers. The order a user encounters +The program has eight instruction handlers. The order a user encounters them is: 1. `initialize_market` (market operator - once) @@ -512,6 +512,8 @@ them is: 4. `cancel_order` (a user - to remove a resting order) 5. `settle_funds` (a user - to collect winnings) 6. `withdraw_fees` (market authority - to collect program revenue) +7. `pause_market` (market authority - to stop new orders) +8. `resume_market` (market authority - to take orders again) For each, the shape is: who signs, what accounts go in, what PDAs get created, what token flows happen, what state mutates, what checks are @@ -655,7 +657,7 @@ resting order. **Checks (top of handler):** -- `market.is_active` → `MarketPaused` +- `market.is_active` (false after `pause_market`) → `MarketPaused` - `price > 0` → `InvalidPrice` - `price % tick_size == 0` → `InvalidTickSize` - `quantity >= min_order_size` → `BelowMinOrderSize` @@ -894,6 +896,47 @@ zero as a side effect of the transfer). --- +### 3.7 `pause_market` and `resume_market` + +**Who calls them:** the market authority, when the market must stop +taking orders (a bad price feed on the operator's side, a mint whose +issuer has halted transfers, a bug under investigation) and when it +may take them again. + +**Signers:** `authority`. + +**Accounts in (both):** + +- `market` (mut) +- `authority` (signer, bound by `address = market.authority`) + +**Checks:** + +- `authority.key() == market.authority` → `NotMarketAuthority` + +**Token movements:** none. + +**State changes:** `pause_market` sets `market.is_active = false`; +`resume_market` sets it back to `true`. + +A pause stops new orders and nothing else. `place_order` is the only +handler that reads `is_active`, so while the market is paused: + +- `place_order` → `MarketPaused` +- `cancel_order` still cancels a resting order and credits its locked + tokens to the owner's unsettled balance +- `settle_funds` still pays unsettled balances out of the vaults +- `withdraw_fees` still empties the fee vault to the authority + +Let a pause stop deposits and trades, never withdrawals: every token +a trader locked or was owed before the pause can still leave the +vaults during it. The tests +`paused_market_still_cancels_and_settles_a_resting_order` and +`paused_market_still_pays_out_fills_and_withdraws_fees` run each exit +on a paused market. + +--- + ## 4. The matching engine - step by step This is the heart of the program. Everything in `place_order` after @@ -1329,7 +1372,7 @@ From [`errors.rs`](programs/order-book/src/errors.rs): - `InvalidPrice`: `place_order` called with `price == 0` - `OrderNotFound`: `cancel_order` failed to locate the order in the book (sanity path) -- `MarketPaused`: `place_order` on a market with `is_active = false` (no handler flips this today, but the field is there) +- `MarketPaused`: `place_order` on a market `pause_market` has paused and `resume_market` has not reopened - `Unauthorized`: `cancel_order` by someone other than the order owner - `OrderBookFull`: `place_order` remainder would rest on a side holding 512 orders without beating that side's worst price - `MissingEvictedAccounts`: A full side, and the worst resting order (with its owner's MarketUser) was not passed after the maker pairs @@ -1344,7 +1387,7 @@ From [`errors.rs`](programs/order-book/src/errors.rs): - `MakerAccountMismatch`: Wrong number of maker accounts, wrong order, wrong market, or caller walked the book out of order - `MissingMakerAccounts`: Fewer remaining accounts than two per planned fill - `MakerOwnerMismatch`: Maker Order and MarketUser have different owners -- `NotMarketAuthority`: `withdraw_fees` called by wrong signer +- `NotMarketAuthority`: `withdraw_fees`, `pause_market` or `resume_market` signed by anyone but `market.authority` ### 6.2 Guarded design choices worth knowing @@ -1393,6 +1436,11 @@ From [`errors.rs`](programs/order-book/src/errors.rs): - **`withdraw_fees` no-ops on empty.** Likewise. +- **A pause stops new orders only.** `pause_market` clears + `is_active`, and `place_order` is the only handler that reads it. + Cancels, settlements and fee withdrawals never check the flag, so + nobody's tokens are locked in a paused market. + - **Boxed InterfaceAccounts.** Several handlers use `Box< InterfaceAccount<...>>` for mint/token accounts. That's a BPF stack-size workaround - each `InterfaceAccount` is ~1 KB on the @@ -1442,8 +1490,6 @@ A production order book would add: ATA at match time. - **Minimum-tick for quantities.** `min_order_size` is a floor, but there's no "round lot" constraint. -- **Pause / admin / upgrade.** `is_active` exists but no handler - flips it. - **Oracle-aware price bands.** A taker bid 10 000× higher than the best ask will happily sweep the book. @@ -1481,7 +1527,7 @@ anchor test --skip-local-validator Expected: ``` -running 35 tests +running 40 tests test authority_can_withdraw_fees_after_match ... ok test better_order_evicts_the_worst_and_rests ... ok test cancel_and_settle_bid_refunds_full_quote ... ok @@ -1501,12 +1547,17 @@ test initialize_market_rejects_zero_quote_lot_size ... ok test initialize_market_rejects_zero_tick_size ... ok test initialize_market_sets_market_and_order_book ... ok test initialize_market_user_tracks_market_and_owner ... ok +test only_the_market_authority_can_pause_or_resume ... ok +test pause_market_refuses_new_orders_with_market_paused ... ok +test paused_market_still_cancels_and_settles_a_resting_order ... ok +test paused_market_still_pays_out_fills_and_withdraws_fees ... ok test place_ask_moves_base_into_vault ... ok test place_bid_moves_quote_into_vault ... ok test place_order_rejects_below_min_order_size ... ok test place_order_rejects_unaligned_tick ... ok test place_order_rejects_zero_price ... ok test resting_orders_at_same_price_fill_by_time_priority ... ok +test resume_market_accepts_orders_again ... ok test settle_funds_after_match_pays_out_both_unsettled_balances ... ok test settle_funds_moves_unsettled_base_to_user ... ok test settle_funds_rejects_fee_vault_substituted_for_quote_vault ... ok @@ -1570,6 +1621,14 @@ test trader_can_evict_their_own_worst_order ... ok - `trader_can_evict_their_own_worst_order`: Only the `Order` account is passed, and the caller's own `MarketUser` is credited - `full_side_cancel_of_the_last_scanned_order_fits_the_default_budget`: Canceling the bid found last among 512 stays inside the default 200,000-unit budget +**Pause and resume:** + +- `pause_market_refuses_new_orders_with_market_paused`: An ask on a paused market gets `MarketPaused` and locks nothing +- `paused_market_still_cancels_and_settles_a_resting_order`: An ask placed before the pause is cancelled and settled during it +- `paused_market_still_pays_out_fills_and_withdraws_fees`: A maker settles a fill, and the authority withdraws the fee, during a pause +- `resume_market_accepts_orders_again`: The ask refused during the pause rests after `resume_market` +- `only_the_market_authority_can_pause_or_resume`: A trader signing either handler gets `NotMarketAuthority`, and the market's state does not change + ### CI note The repo's `.github/workflows/anchor.yml` runs `anchor build` before @@ -1685,7 +1744,11 @@ finance/order-book/anchor/ │ │ ├── place_order.rs (matching engine lives here) │ │ ├── cancel_order.rs │ │ ├── settle_funds.rs - │ │ └── withdraw_fees.rs + │ │ └── admin/ (market-authority handlers) + │ │ ├── mod.rs + │ │ ├── withdraw_fees.rs + │ │ ├── pause_market.rs + │ │ └── resume_market.rs │ └── state/ │ ├── mod.rs │ ├── market.rs diff --git a/finance/order-book/anchor/programs/order-book/src/errors.rs b/finance/order-book/anchor/programs/order-book/src/errors.rs index 294a644ec..5977c84c5 100644 --- a/finance/order-book/anchor/programs/order-book/src/errors.rs +++ b/finance/order-book/anchor/programs/order-book/src/errors.rs @@ -65,7 +65,7 @@ pub enum ErrorCode { #[msg("Maker order and maker MarketUser owner mismatch")] MakerOwnerMismatch, - #[msg("Only the market authority can withdraw fees")] + #[msg("Only the market authority can withdraw fees, pause the market, or resume it")] NotMarketAuthority, #[msg("Order book account does not match the market's order book")] diff --git a/finance/order-book/anchor/programs/order-book/src/instructions/admin/mod.rs b/finance/order-book/anchor/programs/order-book/src/instructions/admin/mod.rs index ef5bf86ce..264ce2692 100644 --- a/finance/order-book/anchor/programs/order-book/src/instructions/admin/mod.rs +++ b/finance/order-book/anchor/programs/order-book/src/instructions/admin/mod.rs @@ -1,3 +1,7 @@ +pub mod pause_market; +pub mod resume_market; pub mod withdraw_fees; +pub use pause_market::*; +pub use resume_market::*; pub use withdraw_fees::*; diff --git a/finance/order-book/anchor/programs/order-book/src/instructions/admin/pause_market.rs b/finance/order-book/anchor/programs/order-book/src/instructions/admin/pause_market.rs new file mode 100644 index 000000000..f9c517cf7 --- /dev/null +++ b/finance/order-book/anchor/programs/order-book/src/instructions/admin/pause_market.rs @@ -0,0 +1,25 @@ +use anchor_lang::prelude::*; + +use crate::errors::ErrorCode; +use crate::state::Market; + +/// Stop the market taking new orders. While `is_active` is false, +/// `place_order` is refused with `MarketPaused`; `cancel_order`, +/// `settle_funds` and `withdraw_fees` do not read the flag, so a pause +/// never stops anyone getting their tokens out. `resume_market` reopens +/// the market. +pub fn handle_pause_market(context: &mut Context) -> Result<()> { + context.accounts.market.is_active = false; + Ok(()) +} + +#[derive(Accounts)] +pub struct PauseMarketAccountConstraints { + #[account(mut)] + pub market: BorshAccount, + + // v1's `has_one = authority` on `market`; in v2 the check lives on the + // sibling it names. + #[account(address = market.authority @ ErrorCode::NotMarketAuthority)] + pub authority: Signer, +} diff --git a/finance/order-book/anchor/programs/order-book/src/instructions/admin/resume_market.rs b/finance/order-book/anchor/programs/order-book/src/instructions/admin/resume_market.rs new file mode 100644 index 000000000..400037afb --- /dev/null +++ b/finance/order-book/anchor/programs/order-book/src/instructions/admin/resume_market.rs @@ -0,0 +1,22 @@ +use anchor_lang::prelude::*; + +use crate::errors::ErrorCode; +use crate::state::Market; + +/// Reopen a market that `pause_market` stopped: sets `is_active` back to +/// true, so `place_order` accepts orders again. +pub fn handle_resume_market(context: &mut Context) -> Result<()> { + context.accounts.market.is_active = true; + Ok(()) +} + +#[derive(Accounts)] +pub struct ResumeMarketAccountConstraints { + #[account(mut)] + pub market: BorshAccount, + + // v1's `has_one = authority` on `market`; in v2 the check lives on the + // sibling it names. + #[account(address = market.authority @ ErrorCode::NotMarketAuthority)] + pub authority: Signer, +} 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 0812df92e..e9e93ab37 100644 --- a/finance/order-book/anchor/programs/order-book/src/lib.rs +++ b/finance/order-book/anchor/programs/order-book/src/lib.rs @@ -79,4 +79,18 @@ pub mod order_book { pub fn withdraw_fees(context: &mut Context) -> Result<()> { instructions::withdraw_fees::handle_withdraw_fees(context) } + + /// Stop the market taking new orders: `place_order` is refused with + /// `MarketPaused` until `resume_market`. Cancels, settlements and fee + /// withdrawals keep working while the market is paused. Only the + /// market's stored `authority` may call this. + pub fn pause_market(context: &mut Context) -> Result<()> { + instructions::pause_market::handle_pause_market(context) + } + + /// Reopen a paused market so `place_order` accepts orders again. Only + /// the market's stored `authority` may call this. + pub fn resume_market(context: &mut Context) -> Result<()> { + instructions::resume_market::handle_resume_market(context) + } } diff --git a/finance/order-book/anchor/programs/order-book/src/state/market.rs b/finance/order-book/anchor/programs/order-book/src/state/market.rs index a98a54552..32a85b30e 100644 --- a/finance/order-book/anchor/programs/order-book/src/state/market.rs +++ b/finance/order-book/anchor/programs/order-book/src/state/market.rs @@ -71,6 +71,11 @@ pub struct Market { pub min_order_size: u64, + // True from `initialize_market` until `pause_market` clears it; + // `resume_market` sets it again. `place_order` refuses a market whose + // flag is false with `MarketPaused`. Cancels, settlements and fee + // withdrawals never read it, so a pause stops new orders and nothing + // else. pub is_active: bool, pub bump: u8, diff --git a/finance/order-book/anchor/programs/order-book/tests/test_order_book.rs b/finance/order-book/anchor/programs/order-book/tests/test_order_book.rs index 87feeb351..56f474742 100644 --- a/finance/order-book/anchor/programs/order-book/tests/test_order_book.rs +++ b/finance/order-book/anchor/programs/order-book/tests/test_order_book.rs @@ -7,7 +7,9 @@ //! the vaults, and - in the matching block near the bottom - cross incoming //! orders against resting orders using price-time priority, charge the //! configured taker fee to a fee vault, and drain the fee vault via -//! `withdraw_fees`. +//! `withdraw_fees`. The pause block at the end checks that `pause_market` +//! stops new orders and nothing else, and that `resume_market` reopens the +//! market. use { anchor_lang::{ @@ -412,6 +414,30 @@ fn build_withdraw_fees_ix(sc: &Scenario, authority_quote_account: Address) -> In ) } +fn build_pause_market_ix(sc: &Scenario, authority: &Address) -> Instruction { + Instruction::new_with_bytes( + sc.program_id, + &order_book::instruction::PauseMarket {}.data(), + order_book::accounts::PauseMarketAccountConstraints { + market: sc.market, + authority: *authority, + } + .to_account_metas(None), + ) +} + +fn build_resume_market_ix(sc: &Scenario, authority: &Address) -> Instruction { + Instruction::new_with_bytes( + sc.program_id, + &order_book::instruction::ResumeMarket {}.data(), + order_book::accounts::ResumeMarketAccountConstraints { + market: sc.market, + authority: *authority, + } + .to_account_metas(None), + ) +} + fn build_cancel_order_ix( sc: &Scenario, owner: &Address, @@ -2809,3 +2835,281 @@ fn full_side_cancel_of_the_last_scanned_order_fits_the_default_budget() { ); assert_eq!(status, ORDER_STATUS_CANCELLED); } + +// --------------------------------------------------------------------------- +// Pause and resume: a pause stops new orders and nothing else. +// --------------------------------------------------------------------------- + +// The pause tests share one resting ask and one crossing bid. Chosen apart +// from the suite's other sizes so a balance that matches is this test's own. +const PAUSE_ASK_ID: u64 = 1; +const PAUSE_BID_ID: u64 = 2; +const PAUSE_PRICE: u64 = 1_300; +const PAUSE_QUANTITY: u64 = 7; +const PAUSE_GROSS: u64 = PAUSE_PRICE * PAUSE_QUANTITY * QUOTE_LOT_SIZE; +const PAUSE_LOCKED_BASE: u64 = PAUSE_QUANTITY * BASE_LOT_SIZE; + +/// Sign `pause_market` or `resume_market` as `signer`. +fn send_market_switch( + svm: &mut LiteSVM, + instruction: Instruction, + signer: &Keypair, +) -> Result<(), String> { + send_transaction_from_instructions(svm, vec![instruction], &[signer], &signer.pubkey()) + .map_err(|error| format!("{error:?}")) +} + +/// The seller places the pause tests' ask. Returns the program's error text +/// when the market refuses it. +fn place_pause_ask(sc: &mut Scenario) -> Result<(), String> { + let ix = build_place_order_ix( + sc, + &sc.seller, + sc.seller_market_user, + sc.seller_base_ata, + sc.seller_quote_ata, + order_book::state::OrderSide::Ask, + PAUSE_ASK_ID, + PAUSE_PRICE, + PAUSE_QUANTITY, + ); + send_transaction_from_instructions(&mut sc.svm, vec![ix], &[&sc.seller], &sc.seller.pubkey()) + .map_err(|error| format!("{error:?}")) +} + +#[test] +fn pause_market_refuses_new_orders_with_market_paused() { + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + + let pause_ix = build_pause_market_ix(&sc, &sc.authority.pubkey()); + send_market_switch(&mut sc.svm, pause_ix, &sc.authority).unwrap(); + + let error = place_pause_ask(&mut sc).expect_err("a paused market must not take an order"); + assert!( + error.contains(&custom_error(order_book::errors::ErrorCode::MarketPaused)), + "{error}" + ); + // The refused ask locked nothing. + assert_eq!( + get_token_account_balance(&sc.svm, &sc.base_vault).unwrap(), + 0 + ); + assert_eq!( + get_token_account_balance(&sc.svm, &sc.seller_base_ata).unwrap(), + TRADER_STARTING_BALANCE + ); +} + +#[test] +fn paused_market_still_cancels_and_settles_a_resting_order() { + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + + place_pause_ask(&mut sc).unwrap(); + assert_eq!( + get_token_account_balance(&sc.svm, &sc.base_vault).unwrap(), + PAUSE_LOCKED_BASE + ); + + let pause_ix = build_pause_market_ix(&sc, &sc.authority.pubkey()); + send_market_switch(&mut sc.svm, pause_ix, &sc.authority).unwrap(); + + // The ask was placed before the pause; its owner can still cancel it + // and take the locked NVDAx back out while the market is paused. + let cancel_ix = build_cancel_order_ix( + &sc, + &sc.seller.pubkey(), + sc.seller_market_user, + PAUSE_ASK_ID, + ); + let settle_ix = build_settle_funds_ix( + &sc, + &sc.seller.pubkey(), + sc.seller_market_user, + sc.seller_base_ata, + sc.seller_quote_ata, + ); + send_transaction_from_instructions( + &mut sc.svm, + vec![cancel_ix, settle_ix], + &[&sc.seller], + &sc.seller.pubkey(), + ) + .unwrap(); + + let (_, status) = read_order_fill_and_status( + &sc.svm, + &order_pda(&sc.program_id, &sc.market, PAUSE_ASK_ID), + ); + assert_eq!(status, ORDER_STATUS_CANCELLED); + assert_eq!( + get_token_account_balance(&sc.svm, &sc.base_vault).unwrap(), + 0 + ); + assert_eq!( + get_token_account_balance(&sc.svm, &sc.seller_base_ata).unwrap(), + TRADER_STARTING_BALANCE + ); +} + +#[test] +fn paused_market_still_pays_out_fills_and_withdraws_fees() { + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + + let authority_quote_ata = create_associated_token_account( + &mut sc.svm, + &sc.authority.pubkey(), + &sc.quote_mint, + &sc.payer, + ) + .unwrap(); + + // A fill before the pause leaves the seller owed quote in their + // unsettled balance and the fee vault holding the taker fee. + place_pause_ask(&mut sc).unwrap(); + let bid_ix = build_place_order_with_makers_ix( + &sc, + &sc.buyer, + sc.buyer_market_user, + sc.buyer_base_ata, + sc.buyer_quote_ata, + order_book::state::OrderSide::Bid, + PAUSE_BID_ID, + PAUSE_PRICE, + PAUSE_QUANTITY, + &[(PAUSE_ASK_ID, sc.seller_market_user)], + ); + send_transaction_from_instructions(&mut sc.svm, vec![bid_ix], &[&sc.buyer], &sc.buyer.pubkey()) + .unwrap(); + let expected_fee = fee_ceil(PAUSE_GROSS); + assert_eq!( + get_token_account_balance(&sc.svm, &sc.fee_vault).unwrap(), + expected_fee + ); + + let pause_ix = build_pause_market_ix(&sc, &sc.authority.pubkey()); + send_market_switch(&mut sc.svm, pause_ix, &sc.authority).unwrap(); + + // The maker's settlement goes through while paused. + let settle_ix = build_settle_funds_ix( + &sc, + &sc.seller.pubkey(), + sc.seller_market_user, + sc.seller_base_ata, + sc.seller_quote_ata, + ); + send_transaction_from_instructions( + &mut sc.svm, + vec![settle_ix], + &[&sc.seller], + &sc.seller.pubkey(), + ) + .unwrap(); + assert_eq!( + get_token_account_balance(&sc.svm, &sc.seller_quote_ata).unwrap(), + PAUSE_GROSS - expected_fee + ); + + // So does the authority's fee withdrawal. + let withdraw_ix = build_withdraw_fees_ix(&sc, authority_quote_ata); + send_transaction_from_instructions( + &mut sc.svm, + vec![withdraw_ix], + &[&sc.authority], + &sc.authority.pubkey(), + ) + .unwrap(); + assert_eq!( + get_token_account_balance(&sc.svm, &sc.fee_vault).unwrap(), + 0 + ); + assert_eq!( + get_token_account_balance(&sc.svm, &authority_quote_ata).unwrap(), + expected_fee + ); +} + +#[test] +fn resume_market_accepts_orders_again() { + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + + let pause_ix = build_pause_market_ix(&sc, &sc.authority.pubkey()); + send_market_switch(&mut sc.svm, pause_ix, &sc.authority).unwrap(); + let error = place_pause_ask(&mut sc).expect_err("a paused market must not take an order"); + assert!( + error.contains(&custom_error(order_book::errors::ErrorCode::MarketPaused)), + "{error}" + ); + + let resume_ix = build_resume_market_ix(&sc, &sc.authority.pubkey()); + send_market_switch(&mut sc.svm, resume_ix, &sc.authority).unwrap(); + + // The same ask, refused a moment ago, now rests and locks its base. Its + // bytes are identical to the refused transaction's, so it needs a fresh + // blockhash to be taken as a new transaction. + sc.svm.expire_blockhash(); + place_pause_ask(&mut sc).unwrap(); + assert_eq!( + get_token_account_balance(&sc.svm, &sc.base_vault).unwrap(), + PAUSE_LOCKED_BASE + ); +} + +#[test] +fn only_the_market_authority_can_pause_or_resume() { + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + + // A trader signing `pause_market` is refused, and the market stays open: + // the ask goes through afterwards. + let pause_ix = build_pause_market_ix(&sc, &sc.buyer.pubkey()); + let error = send_market_switch(&mut sc.svm, pause_ix, &sc.buyer) + .expect_err("only the market authority may pause"); + assert!( + error.contains(&custom_error( + order_book::errors::ErrorCode::NotMarketAuthority + )), + "{error}" + ); + place_pause_ask(&mut sc).unwrap(); + + // Once the authority has paused, a trader signing `resume_market` is + // refused too, and the market stays paused: the buyer's bid is refused. + let pause_ix = build_pause_market_ix(&sc, &sc.authority.pubkey()); + send_market_switch(&mut sc.svm, pause_ix, &sc.authority).unwrap(); + let resume_ix = build_resume_market_ix(&sc, &sc.buyer.pubkey()); + let error = send_market_switch(&mut sc.svm, resume_ix, &sc.buyer) + .expect_err("only the market authority may resume"); + assert!( + error.contains(&custom_error( + order_book::errors::ErrorCode::NotMarketAuthority + )), + "{error}" + ); + let bid_ix = build_place_order_ix( + &sc, + &sc.buyer, + sc.buyer_market_user, + sc.buyer_base_ata, + sc.buyer_quote_ata, + order_book::state::OrderSide::Bid, + PAUSE_BID_ID, + PAUSE_PRICE, + PAUSE_QUANTITY, + ); + let error = send_transaction_from_instructions( + &mut sc.svm, + vec![bid_ix], + &[&sc.buyer], + &sc.buyer.pubkey(), + ) + .map_err(|error| format!("{error:?}")) + .expect_err("a paused market must not take an order"); + assert!( + error.contains(&custom_error(order_book::errors::ErrorCode::MarketPaused)), + "{error}" + ); +} diff --git a/finance/order-book/quasar/CHANGELOG.md b/finance/order-book/quasar/CHANGELOG.md index 372087b3c..17f8e13bd 100644 --- a/finance/order-book/quasar/CHANGELOG.md +++ b/finance/order-book/quasar/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## Unreleased - 2026-10-04 + +### Added + +- `pause_market` and `resume_market`, signed by the market authority + (anyone else gets `NotMarketAuthority`). `pause_market` clears + `Market.is_active`, which `initialize_market` set and nothing cleared, so + the `MarketPaused` refusal in `place_order` can now happen. + `resume_market` sets the flag again. A pause stops new orders and nothing + else: `cancel_order`, `settle_funds` and `withdraw_fees` do not read the + flag. Tests: `pause_market_refuses_new_orders_with_market_paused`, + `paused_market_still_cancels_and_settles_a_resting_order`, + `paused_market_still_pays_out_fills_and_withdraws_fees`, + `resume_market_accepts_orders_again`, + `only_the_market_authority_can_pause_or_resume`. + ## 2026-09-23 ### Added diff --git a/finance/order-book/quasar/README.md b/finance/order-book/quasar/README.md index 21db44730..8ffe7f4cf 100644 --- a/finance/order-book/quasar/README.md +++ b/finance/order-book/quasar/README.md @@ -67,6 +67,7 @@ NVDAx (9 decimals) / USDC (6 decimals): `base_lot_size = 1000`, `quote_lot_size - `cancel_order`: Credit an open order's locked remainder back to the owner's `unsettled_*` and remove it from the book. - `settle_funds`: Move a user's `unsettled_*` balances out of the vaults into their token accounts. - `withdraw_fees`: Authority-only: drain the fee vault to the authority's token account. +- `pause_market` / `resume_market`: Authority-only: clear and set `Market.is_active`. While it is clear, `place_order` is refused with `MarketPaused`; `cancel_order`, `settle_funds` and `withdraw_fees` do not read the flag, so a pause stops new orders and nothing else, and every token a trader locked or was owed before the pause can still leave the vaults during it. `place_order` takes `side` (`0` = bid, `1` = ask), `price`, `quantity`, and `order_id`. The caller passes the resting maker orders to cross as **remaining accounts**, in pairs of `(maker_order, maker_market_user)`, in the @@ -125,6 +126,9 @@ are all consequences of Quasar being zero-copy, `no_std`, and zero-allocation: unit to the maker. - A full side **evicts its worst order** for a better one instead of refusing every new order, so filling the book with far-off orders cannot shut a market (see the lifecycle section). +- **A pause stops new orders only.** `pause_market` clears `is_active`, and `place_order` is the only handler + that reads it. Cancels, settlements and fee withdrawals never check the flag, so nobody's tokens are locked in + a paused market. Only the market authority can pause or resume (`NotMarketAuthority` otherwise). ## Building and testing @@ -143,11 +147,13 @@ in `src/tests.rs` drives the full lifecycle (initialize a market, create users, bid, settle both sides, and withdraw the fee), asserting onchain state, token balances, and fee accounting at each step, plus an authorization rejection. Five eviction tests fill the bid side with 512 bids and check that a worse or equal bid is refused, a better bid evicts the worst and rests, the evicted owner settles their refund, -wrong or missing evicted accounts are rejected, and a trader can evict their own worst order. +wrong or missing evicted accounts are rejected, and a trader can evict their own worst order. Five pause tests +check that a paused market refuses a new order with `MarketPaused` and locks nothing, still cancels and settles +a resting order, still pays out a fill and withdraws the fee, takes orders again after `resume_market`, and +refuses a trader signing either handler with `NotMarketAuthority`. ## Extending - **Self-trade prevention:** reject or cancel-back when a taker would cross their own resting order. - **Post-only / IOC / FOK** order types by gating the rest-vs-cancel behaviour on a flag. - **Multiple fee tiers** keyed on the taker's `MarketUser`. -- **Market pause/resume** by flipping `Market.is_active` from an authority-gated handler. diff --git a/finance/order-book/quasar/src/errors.rs b/finance/order-book/quasar/src/errors.rs index adc419217..2a05df4eb 100644 --- a/finance/order-book/quasar/src/errors.rs +++ b/finance/order-book/quasar/src/errors.rs @@ -27,6 +27,8 @@ pub enum OrderBookError { MakerAccountMismatch, MissingMakerAccounts, MakerOwnerMismatch, + /// `withdraw_fees`, `pause_market` or `resume_market` signed by anyone + /// but `market.authority`. NotMarketAuthority, InvalidOrderBook, InvalidOrderBookOwner, diff --git a/finance/order-book/quasar/src/instructions/admin/mod.rs b/finance/order-book/quasar/src/instructions/admin/mod.rs index ef5bf86ce..264ce2692 100644 --- a/finance/order-book/quasar/src/instructions/admin/mod.rs +++ b/finance/order-book/quasar/src/instructions/admin/mod.rs @@ -1,3 +1,7 @@ +pub mod pause_market; +pub mod resume_market; pub mod withdraw_fees; +pub use pause_market::*; +pub use resume_market::*; pub use withdraw_fees::*; diff --git a/finance/order-book/quasar/src/instructions/admin/pause_market.rs b/finance/order-book/quasar/src/instructions/admin/pause_market.rs new file mode 100644 index 000000000..0473b4c14 --- /dev/null +++ b/finance/order-book/quasar/src/instructions/admin/pause_market.rs @@ -0,0 +1,25 @@ +use quasar_lang::prelude::*; + +use crate::errors::OrderBookError; +use crate::state::Market; + +/// Stop the market taking new orders. While `is_active` is false, +/// `place_order` is refused with `MarketPaused`; `cancel_order`, +/// `settle_funds` and `withdraw_fees` do not read the flag, so a pause +/// never stops anyone getting their tokens out. `resume_market` reopens +/// the market. +#[derive(Accounts)] +pub struct PauseMarketAccountConstraints { + #[account(mut, has_one(authority) @ OrderBookError::NotMarketAuthority)] + pub market: Account, + + pub authority: Signer, +} + +#[inline(always)] +pub fn handle_pause_market( + accounts: &mut PauseMarketAccountConstraints, +) -> Result<(), ProgramError> { + accounts.market.is_active = PodBool::from(false); + Ok(()) +} diff --git a/finance/order-book/quasar/src/instructions/admin/resume_market.rs b/finance/order-book/quasar/src/instructions/admin/resume_market.rs new file mode 100644 index 000000000..37823a03b --- /dev/null +++ b/finance/order-book/quasar/src/instructions/admin/resume_market.rs @@ -0,0 +1,22 @@ +use quasar_lang::prelude::*; + +use crate::errors::OrderBookError; +use crate::state::Market; + +/// Reopen a market that `pause_market` stopped: sets `is_active` back to +/// true, so `place_order` accepts orders again. +#[derive(Accounts)] +pub struct ResumeMarketAccountConstraints { + #[account(mut, has_one(authority) @ OrderBookError::NotMarketAuthority)] + pub market: Account, + + pub authority: Signer, +} + +#[inline(always)] +pub fn handle_resume_market( + accounts: &mut ResumeMarketAccountConstraints, +) -> Result<(), ProgramError> { + accounts.market.is_active = PodBool::from(true); + Ok(()) +} diff --git a/finance/order-book/quasar/src/lib.rs b/finance/order-book/quasar/src/lib.rs index d627c398e..79cf6ae17 100644 --- a/finance/order-book/quasar/src/lib.rs +++ b/finance/order-book/quasar/src/lib.rs @@ -106,4 +106,20 @@ mod quasar_order_book { pub fn withdraw_fees(ctx: Ctx) -> Result<(), ProgramError> { instructions::withdraw_fees::handle_withdraw_fees(&mut ctx.accounts) } + + /// Stop the market taking new orders: `place_order` is refused with + /// `MarketPaused` until `resume_market`. Cancels, settlements and fee + /// withdrawals keep working while the market is paused. Only the + /// market's stored `authority` may call this. + #[instruction(discriminator = 6)] + pub fn pause_market(ctx: Ctx) -> Result<(), ProgramError> { + instructions::pause_market::handle_pause_market(&mut ctx.accounts) + } + + /// Reopen a paused market so `place_order` accepts orders again. Only + /// the market's stored `authority` may call this. + #[instruction(discriminator = 7)] + pub fn resume_market(ctx: Ctx) -> Result<(), ProgramError> { + instructions::resume_market::handle_resume_market(&mut ctx.accounts) + } } diff --git a/finance/order-book/quasar/src/state/market.rs b/finance/order-book/quasar/src/state/market.rs index f4a771c5a..0fedde8d1 100644 --- a/finance/order-book/quasar/src/state/market.rs +++ b/finance/order-book/quasar/src/state/market.rs @@ -66,6 +66,11 @@ pub struct Market { pub base_lot_size: u64, pub quote_lot_size: u64, pub min_order_size: u64, + /// True from `initialize_market` until `pause_market` clears it; + /// `resume_market` sets it again. `place_order` refuses a market whose + /// flag is false with `MarketPaused`. Cancels, settlements and fee + /// withdrawals never read it, so a pause stops new orders and nothing + /// else. pub is_active: PodBool, pub bump: u8, } diff --git a/finance/order-book/quasar/src/tests.rs b/finance/order-book/quasar/src/tests.rs index 018b5af83..68ea4392e 100644 --- a/finance/order-book/quasar/src/tests.rs +++ b/finance/order-book/quasar/src/tests.rs @@ -1,13 +1,15 @@ //! quasar-test integration tests. They drive the real program instructions //! end-to-end: initialize a market, create users, place and cross orders, //! settle, and withdraw fees, asserting on-chain state and token balances at -//! each step. +//! each step. The pause block at the end checks that `pause_market` stops new +//! orders and nothing else, and that `resume_market` reopens the market. use { crate::{ cpi::{ CancelOrderInstruction, InitializeMarketInstruction, InitializeMarketUserInstruction, - PlaceOrderInstruction, SettleFundsInstruction, WithdrawFeesInstruction, + PauseMarketInstruction, PlaceOrderInstruction, ResumeMarketInstruction, + SettleFundsInstruction, WithdrawFeesInstruction, }, errors::OrderBookError, state::{ @@ -697,3 +699,226 @@ fn trader_can_evict_their_own_worst_order(test: &mut Test) { // One order out, one order in. assert_eq!(user.open_orders_len as u64, ORDERS_PER_FILLER); } + +// --- Pause and resume: a pause stops new orders and nothing else --- + +// The pause tests share one resting ask and one crossing bid. Chosen apart +// from the suite's other sizes so a balance that matches is this test's own. +const PAUSE_ASK_ID: u64 = 1; +const PAUSE_BID_ID: u64 = 2; +const PAUSE_PRICE: u64 = 1_300; +const PAUSE_QUANTITY: u64 = 7; +const PAUSE_GROSS: u64 = PAUSE_PRICE * PAUSE_QUANTITY * QUOTE_LOT_SIZE; // 9100 +const PAUSE_FEE: u64 = 91; // ceil(9100 * 100 / 10000) +const PAUSE_LOCKED_BASE: u64 = PAUSE_QUANTITY * BASE_LOT_SIZE; // 7000 +const ASK: u8 = 1; + +/// A market with the maker (the seller) and the taker (the buyer) registered +/// and funded for the pause tests' one ask and one bid. +fn init_pause_market(test: &mut Test) -> Pubkey { + let market = init_market(test); + initialize_market_user(test, market, MAKER); + initialize_market_user(test, market, TAKER); + test.add( + TokenAccount::new(BASE_MINT, MAKER) + .at(MAKER_BASE) + .amount(PAUSE_LOCKED_BASE), + ); + test.add(TokenAccount::new(QUOTE_MINT, MAKER).at(MAKER_QUOTE)); + test.add(TokenAccount::new(BASE_MINT, TAKER).at(TAKER_BASE)); + test.add( + TokenAccount::new(QUOTE_MINT, TAKER) + .at(TAKER_QUOTE) + .amount(PAUSE_GROSS), + ); + market +} + +/// The maker places the pause tests' ask. +fn place_pause_ask(test: &mut Test, market: Pubkey) -> Outcome { + place_order( + test, + market, + MAKER, + MAKER_BASE, + MAKER_QUOTE, + ASK, + PAUSE_PRICE, + PAUSE_QUANTITY, + PAUSE_ASK_ID, + &[], + ) +} + +#[quasar_test] +fn pause_market_refuses_new_orders_with_market_paused(test: &mut Test) { + let market = init_pause_market(test); + + test.send(PauseMarketInstruction { + market, + authority: AUTHORITY, + }) + .succeeds(); + assert!(!test.read::(market).is_active.is_true()); + + place_pause_ask(test, market).fails_with(OrderBookError::MarketPaused); + // The refused ask locked nothing. + let vaults = vaults(test, market); + assert_eq!(test.tokens(vaults.base), 0); + assert_eq!(test.tokens(MAKER_BASE), PAUSE_LOCKED_BASE); +} + +#[quasar_test] +fn paused_market_still_cancels_and_settles_a_resting_order(test: &mut Test) { + let market = init_pause_market(test); + let vaults = vaults(test, market); + + place_pause_ask(test, market).succeeds(); + assert_eq!(test.tokens(vaults.base), PAUSE_LOCKED_BASE); + + test.send(PauseMarketInstruction { + market, + authority: AUTHORITY, + }) + .succeeds(); + + // The ask was placed before the pause; its owner can still cancel it + // and take the locked base back out while the market is paused. + let cancel: Instruction = CancelOrderInstruction { + market, + order_book: ORDER_BOOK, + order_order_id_seed: PAUSE_ASK_ID, + owner: MAKER, + } + .into(); + let settle: Instruction = SettleFundsInstruction { + owner: MAKER, + market, + base_vault: vaults.base, + quote_vault: vaults.quote, + user_base_account: MAKER_BASE, + user_quote_account: MAKER_QUOTE, + base_mint: BASE_MINT, + quote_mint: QUOTE_MINT, + } + .into(); + test.send_all([cancel, settle]).succeeds(); + + let ask = test.derive_pda(Order::seeds(&market, PAUSE_ASK_ID)); + assert_eq!(test.read::(ask).status, OrderStatus::Cancelled as u8); + assert_eq!(test.tokens(vaults.base), 0); + assert_eq!(test.tokens(MAKER_BASE), PAUSE_LOCKED_BASE); +} + +#[quasar_test] +fn paused_market_still_pays_out_fills_and_withdraws_fees(test: &mut Test) { + let market = init_pause_market(test); + let maker_market_user = test.derive_pda(MarketUser::seeds(&market, &MAKER)); + let vaults = vaults(test, market); + test.add(TokenAccount::new(QUOTE_MINT, AUTHORITY).at(AUTHORITY_QUOTE)); + + // A fill before the pause leaves the maker owed quote in their + // unsettled balance and the fee vault holding the taker fee. + place_pause_ask(test, market).succeeds(); + let ask = test.derive_pda(Order::seeds(&market, PAUSE_ASK_ID)); + place_order( + test, + market, + TAKER, + TAKER_BASE, + TAKER_QUOTE, + BID, + PAUSE_PRICE, + PAUSE_QUANTITY, + PAUSE_BID_ID, + &[(ask, maker_market_user)], + ) + .succeeds(); + assert_eq!(test.tokens(vaults.fee), PAUSE_FEE); + + test.send(PauseMarketInstruction { + market, + authority: AUTHORITY, + }) + .succeeds(); + + // The maker's settlement goes through while paused. + settle_funds(test, market, MAKER, MAKER_BASE, MAKER_QUOTE).succeeds(); + assert_eq!(test.tokens(MAKER_QUOTE), PAUSE_GROSS - PAUSE_FEE); + + // So does the authority's fee withdrawal. + test.send(WithdrawFeesInstruction { + market, + fee_vault: vaults.fee, + authority_quote_account: AUTHORITY_QUOTE, + quote_mint: QUOTE_MINT, + authority: AUTHORITY, + }) + .succeeds(); + assert_eq!(test.tokens(vaults.fee), 0); + assert_eq!(test.tokens(AUTHORITY_QUOTE), PAUSE_FEE); +} + +#[quasar_test] +fn resume_market_accepts_orders_again(test: &mut Test) { + let market = init_pause_market(test); + + test.send(PauseMarketInstruction { + market, + authority: AUTHORITY, + }) + .succeeds(); + place_pause_ask(test, market).fails_with(OrderBookError::MarketPaused); + + test.send(ResumeMarketInstruction { + market, + authority: AUTHORITY, + }) + .succeeds(); + assert!(test.read::(market).is_active.is_true()); + + // The same ask, refused a moment ago, now rests and locks its base. + place_pause_ask(test, market).succeeds(); + let vaults = vaults(test, market); + assert_eq!(test.tokens(vaults.base), PAUSE_LOCKED_BASE); +} + +#[quasar_test] +fn only_the_market_authority_can_pause_or_resume(test: &mut Test) { + let market = init_pause_market(test); + + // A trader signing `pause_market` is refused, and the market stays open: + // the ask goes through afterwards. + test.send(PauseMarketInstruction { + market, + authority: TAKER, + }) + .fails_with(OrderBookError::NotMarketAuthority); + place_pause_ask(test, market).succeeds(); + + // Once the authority has paused, a trader signing `resume_market` is + // refused too, and the market stays paused: the taker's bid is refused. + test.send(PauseMarketInstruction { + market, + authority: AUTHORITY, + }) + .succeeds(); + test.send(ResumeMarketInstruction { + market, + authority: TAKER, + }) + .fails_with(OrderBookError::NotMarketAuthority); + place_order( + test, + market, + TAKER, + TAKER_BASE, + TAKER_QUOTE, + BID, + PAUSE_PRICE, + PAUSE_QUANTITY, + PAUSE_BID_ID, + &[], + ) + .fails_with(OrderBookError::MarketPaused); +} diff --git a/finance/perpetual-futures/anchor-v1/CHANGELOG.md b/finance/perpetual-futures/anchor-v1/CHANGELOG.md index d60195c24..42695ee58 100644 --- a/finance/perpetual-futures/anchor-v1/CHANGELOG.md +++ b/finance/perpetual-futures/anchor-v1/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 2026-10-04 + +Check which program owns the price feed. `initialize_pool` records the feed +account's owning program on the new `Pool.price_feed_program`, read from the +account's owner at that moment, beside the feed address and scale it already +pins. `read_oracle_price` takes that program and refuses a feed account owned +by any other with the new `PriceFeedNotFromOracle`, before it decodes a byte, +so no handler accepts any account laid out like a feed as a price. Tested by +`test_open_rejects_price_feed_from_another_program`, which swaps the feed for +a byte-identical copy owned by an unrelated program and then restores the +owner; `test_initialize_pool` checks the recorded program. + +`test_funding_charged_to_long` asserts the exact funding paid, `size * rate * +seconds / FUNDING_PRECISION`, rather than that some was paid, and +`test_first_deposit_below_minimum_fails` asserts `DepositTooSmall` by code one +base unit under the withheld minimum and a single share minted one over it. + ## 2026-10-01 Replace the leverage cap with an initial margin. `max_leverage` on diff --git a/finance/perpetual-futures/anchor-v1/README.md b/finance/perpetual-futures/anchor-v1/README.md index fa2e5408c..c6e20fa47 100644 --- a/finance/perpetual-futures/anchor-v1/README.md +++ b/finance/perpetual-futures/anchor-v1/README.md @@ -87,7 +87,7 @@ A position's *equity* is its net collateral plus profit/loss minus funding. Once ### Oracle -The mark price comes from an oracle feed. This example validates the price for staleness (by slot), publication after the most recent cluster restart (the `LastRestartSlot` sysvar, because a halt passes hours of wall-clock time in zero slots), positivity, scale, and a [confidence band](https://docs.pyth.network/price-feeds/best-practices#confidence-intervals) that must stay within `max_confidence_bps` of the price: rejecting an uncertain price is one of the most common oracle-safety checks. +The mark price comes from an oracle feed. `initialize_pool` records the program that owns the feed account on `Pool.price_feed_program`, and every read refuses a feed account owned by any other program with `PriceFeedNotFromOracle`: the feed's byte layout alone says nothing about who wrote it. The read then validates the price for staleness (by slot), publication after the most recent cluster restart (the `LastRestartSlot` sysvar, because a halt passes hours of wall-clock time in zero slots), positivity, scale, and a [confidence band](https://docs.pyth.network/price-feeds/best-practices#confidence-intervals) that must stay within `max_confidence_bps` of the price: rejecting an uncertain price is one of the most common oracle-safety checks. ### Price band @@ -126,11 +126,11 @@ Amounts below are shown in whole USDC; onchain they are base units (× 10⁶). T **Instruction:** `initialize_pool(parameters)` -The handler validates the parameters, then reads the oracle once to seed the pool's average price at $100. +The handler validates the parameters, records the program that owns the oracle feed account as `price_feed_program`, then reads the oracle once to seed the pool's average price at $100. **Accounts created:** -- `Pool` [PDA](https://solana.com/docs/terminology#program-derived-address-pda), seeds `["pool", collateral_mint, oracle_feed]`: parameters, liquidity, collateral total, program fees, insurance fund, per-side open-interest accumulators, funding index, average oracle price. The pool owns the vault and is the LP mint's authority, and signs vault transfers and mint/burn CPIs with its own seeds; there is no separate signing PDA +- `Pool` [PDA](https://solana.com/docs/terminology#program-derived-address-pda), seeds `["pool", collateral_mint, oracle_feed]`: parameters, the feed's owning program, liquidity, collateral total, program fees, insurance fund, per-side open-interest accumulators, funding index, average oracle price. The pool owns the vault and is the LP mint's authority, and signs vault transfers and mint/burn CPIs with its own seeds; there is no separate signing PDA - `custody_vault` [token account](https://solana.com/docs/terminology#token-account) PDA, seeds `["vault", pool]`: all USDC, both provider liquidity and trader collateral; `pool` is its owner - `lp_mint` PDA, seeds `["lp_mint", pool]`: the share [mint](https://solana.com/docs/terminology#mint-account); `pool` is the mint authority @@ -261,11 +261,11 @@ This is a teaching example, not an audited exchange. Notably: The tests run in-process with [LiteSVM](https://www.anchor-lang.com/docs/testing/litesvm) and [solana-kite](https://solanakite.org); no local validator is needed. They deploy both programs, drive the mock oracle, and cover: -- liquidity round-trips, and share inflation through a provider's own trades +- liquidity round-trips, the first deposit on both sides of the withheld minimum (`test_first_deposit_below_minimum_fails`), and share inflation through a provider's own trades - opening and closing longs and shorts in profit and loss - the initial margin on both sides of its boundary, and slippage rejection -- stale-price, pre-restart-price, and wide-confidence rejection -- funding accrual, the funding-rate maximum, an operator's wallet on the lighter side earning only the fixed rate, and funding that follows seconds rather than slots +- stale-price, pre-restart-price, and wide-confidence rejection, and a feed account owned by another program refused on the owner alone (`test_open_rejects_price_feed_from_another_program`) +- the exact funding a long pays over its time open (`test_funding_charged_to_long`), the funding-rate maximum, an operator's wallet on the lighter side earning only the fixed rate, and funding that follows seconds rather than slots - the price band: opens, closes, deposits and withdrawals refused when the oracle jumps outside it (`test_open_rejected_when_oracle_jumps_outside_band`, `test_close_rejected_when_oracle_jumps_outside_band`, `test_liquidity_changes_rejected_when_oracle_jumps_outside_band`), liquidation running outside it (`test_liquidation_runs_outside_band`), the exact average after each `update_price_average` (`test_single_update_moves_average_by_elapsed_fraction`), repeated updates walking the average to a genuine move until trading resumes (`test_price_average_catches_up_after_genuine_move`), and one manipulated read after an idle window leaving the average where it was (`test_one_manipulated_read_after_idle_does_not_move_average`) - liquidation, and the refusal to liquidate a healthy position - the haircut: a position opening without full backing (`test_open_allowed_without_full_backing`), profit paid in full while the pool backs it (`test_profit_runs_uncapped_when_backed`), two winners each paid exactly half when the pool is stressed (`test_haircut_scales_profit_when_pool_stressed`), the insurance fund paying a profit beyond `liquidity` (`test_insurance_pays_profit_beyond_liquidity`), and a winner offset by an open loser paid the pool's whole backing rather than refused (`test_winner_offset_by_open_loser_is_paid_not_refused`) diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/errors.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/errors.rs index ffb3e4537..a8f8328a5 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/errors.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/errors.rs @@ -72,4 +72,7 @@ pub enum PerpError { "Profit cannot be taken yet: the position has not been open for the pool's profit warm-up" )] ProfitNotMatured, + + #[msg("Price feed is not owned by the oracle program the pool recorded")] + PriceFeedNotFromOracle, } diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/initialize_pool.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/initialize_pool.rs index e451c55c6..944a1ab5c 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/initialize_pool.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/initialize_pool.rs @@ -120,10 +120,13 @@ pub fn handle_initialize_pool( PerpError::InvalidPriceDeviation ); - // Seed the average with a validated oracle price, so the band is in force - // from the first trade. + // Record the feed's owning program, and seed the average with a validated + // oracle price, so the owner check and the band are in force from the + // first trade. + let price_feed_program = *context.accounts.oracle_feed.owner; let initial_price = read_oracle_price( &context.accounts.oracle_feed, + &price_feed_program, parameters.oracle_scale, parameters.max_confidence_bps, )?; @@ -133,6 +136,7 @@ pub fn handle_initialize_pool( pool.authority = context.accounts.authority.key(); pool.collateral_mint = context.accounts.collateral_mint.key(); pool.oracle_feed = context.accounts.oracle_feed.key(); + pool.price_feed_program = price_feed_program; pool.oracle_scale = parameters.oracle_scale; pool.custody_vault = context.accounts.custody_vault.key(); pool.lp_mint = context.accounts.lp_mint.key(); @@ -180,10 +184,11 @@ pub struct InitializePoolAccountConstraints<'info> { pub collateral_mint: Box>, - /// CHECK: The oracle feed account. Its key is stored on the pool and every - /// read, including the one here that seeds the average price, validates - /// the layout, scale, and freshness; it is never trusted by type. Swap for - /// a real Pyth price feed in production. + /// CHECK: The oracle feed account. Its key and its owning program are + /// stored on the pool, and every read, including the one here that seeds + /// the average price, requires that owner and validates the layout, scale, + /// and freshness; it is never trusted by type. Swap for a real Pyth price + /// feed in production. pub oracle_feed: UncheckedAccount<'info>, /// Liquidity-provider share mint. The pool account is its mint authority 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 e10fda46a..fd7ef1c03 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 @@ -390,7 +390,12 @@ pub fn refresh_price_and_funding_within_band( } fn read_pool_oracle_price(pool: &Pool, oracle_feed: &AccountInfo) -> Result { - crate::state::oracle::read_oracle_price(oracle_feed, pool.oracle_scale, pool.max_confidence_bps) + crate::state::oracle::read_oracle_price( + oracle_feed, + &pool.price_feed_program, + pool.oracle_scale, + pool.max_confidence_bps, + ) } fn apply_price_and_funding(pool: &mut Pool, price: u64) -> Result<()> { diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/state/oracle.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/state/oracle.rs index c76b8d489..442343897 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/state/oracle.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/state/oracle.rs @@ -27,11 +27,12 @@ use crate::errors::PerpError; // the spot price for a less manipulable mark; the mock omits the EMA to stay // minimal. // -// The feed account's owning program is NOT checked here: the pool trusts -// whatever feed address its creator configured, which is inside the trust model -// (the creator picks the oracle). A production reader must also verify the -// account owner is the oracle program, which the `PriceUpdateV2` account type -// does. +// The feed account must be owned by the oracle program the pool recorded at +// creation (`Pool::price_feed_program`, read from the feed's `owner` at that +// moment). The layout above says nothing about who wrote the bytes, so +// without the owner check any account laid out like a feed would be accepted +// as a price. The pool's creator picks the feed, so the pool trusts the +// program it recorded and refuses a feed account from any other. const PRICE_OFFSET: usize = 8 + 32; const SCALE_OFFSET: usize = PRICE_OFFSET + 16; const LAST_UPDATE_SLOT_OFFSET: usize = SCALE_OFFSET + 4; @@ -41,14 +42,22 @@ const FEED_MINIMUM_LENGTH: usize = CONFIDENCE_OFFSET + 8; /// Read and validate the oracle price from `feed`. /// /// Returns the price as a `u64` in the pool's `expected_scale` fixed point. -/// Rejects a stale price (older than `MAX_PRICE_STALENESS_SLOTS`), a -/// non-positive price, a feed whose scale differs from the pool's pinned scale, -/// and a price whose confidence band exceeds `max_confidence_bps` of the price. +/// Rejects a feed account owned by any program other than `oracle_program`, a +/// stale price (older than `MAX_PRICE_STALENESS_SLOTS`), a non-positive price, +/// a feed whose scale differs from the pool's pinned scale, and a price whose +/// confidence band exceeds `max_confidence_bps` of the price. pub fn read_oracle_price( feed: &AccountInfo, + oracle_program: &Pubkey, expected_scale: u32, max_confidence_bps: u16, ) -> Result { + require_keys_eq!( + *feed.owner, + *oracle_program, + PerpError::PriceFeedNotFromOracle + ); + let data = feed.try_borrow_data()?; require!( data.len() >= FEED_MINIMUM_LENGTH, diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/state/pool.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/state/pool.rs index 3de952bed..11eb751cf 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/state/pool.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/state/pool.rs @@ -19,6 +19,12 @@ pub struct Pool { /// reject any substituted feed account. pub oracle_feed: Pubkey, + /// The program that owned `oracle_feed` when the pool was created. Every + /// price read requires the feed account to still be owned by it, so an + /// account at that address written by any other program is refused as a + /// price. + pub price_feed_program: Pubkey, + /// Decimal places the oracle price is quoted in. Pinned at creation so a /// feed that silently changes scale is rejected rather than mis-read. pub oracle_scale: u32, 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 1b7202c34..7ef79c16e 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 @@ -31,6 +31,9 @@ const TEN_YEARS: i64 = 315_360_000; const PROFIT_WARMUP_SLOTS: u64 = 10; // Matches `HAIRCUT_PRECISION`: a haircut ratio of one. const HAIRCUT_PRECISION: u64 = 1_000_000_000; +// Matches `FUNDING_PRECISION`: the fixed point the funding rate and index are +// carried in, so a position's funding is `size * rate * seconds / this`. +const FUNDING_PRECISION: u64 = 1_000_000_000; // Collateral token has 6 decimals (like USDC), so one whole unit is 1_000_000 // base units. const ONE_USDC: u64 = 1_000_000; @@ -314,6 +317,15 @@ impl Market { (liquidator, liquidator_collateral) } + /// Replace the feed account at the pool's recorded feed address with a + /// copy whose owning program is `owner`. The bytes are unchanged, so the + /// copy still decodes as a fresh, confident price at the pinned scale. + fn set_feed_owner(&mut self, owner: Pubkey) { + let mut feed_account = self.svm.get_account(&self.feed).unwrap(); + feed_account.owner = owner; + self.svm.set_account(self.feed, feed_account).unwrap(); + } + /// Simulate a cluster restart at `slot`: prices stamped at or before it /// must be rejected until the publisher posts again. fn set_last_restart_slot(&mut self, slot: u64) { @@ -625,6 +637,7 @@ fn test_initialize_pool() { assert_eq!(pool.authority, market.admin.pubkey()); assert_eq!(pool.collateral_mint, market.collateral_mint); assert_eq!(pool.oracle_feed, market.feed); + assert_eq!(pool.price_feed_program, mock_price_feed::id()); assert_eq!(pool.oracle_scale, ORACLE_SCALE); assert_eq!(pool.initial_margin_bps, 1_000); assert_eq!(pool.max_price_deviation_bps, 2_000); @@ -670,14 +683,21 @@ fn test_add_liquidity_first_deposit_withholds_minimum() { assert_eq!(shares, deposit - 1_000); } +/// The first deposit must exceed the 1_000 withheld minimum: one base unit +/// short is refused, and one over mints a single share. #[test] fn test_first_deposit_below_minimum_fails() { let mut market = Market::default_market(); let (provider, provider_collateral) = market.funded_trader(10_000); - // 500 base units is below the 1_000 locked minimum. - assert!(market - .add_liquidity(&provider, provider_collateral, 500, 0) - .is_err()); + assert_fails_with( + market.add_liquidity(&provider, provider_collateral, 999, 0), + PerpError::DepositTooSmall, + ); + market + .add_liquidity(&provider, provider_collateral, 1_001, 0) + .unwrap(); + let provider_lp = derive_ata(&provider.pubkey(), &market.lp_mint); + assert_eq!(get_token_account_balance(&market.svm, &provider_lp).unwrap(), 1); } #[test] @@ -1112,6 +1132,47 @@ fn test_open_rejects_price_from_before_a_restart() { .expect("a freshly published price must be accepted after a restart"); } +/// The pool records the program that owns its feed at creation and refuses a +/// price from a feed account owned by any other program, however well its +/// bytes decode. The feed is swapped for a byte-identical copy owned by an +/// unrelated program, and the refusal is by the owner alone: the same bytes +/// owned by the mock oracle program again are accepted. +#[test] +fn test_open_rejects_price_feed_from_another_program() { + let mut market = Market::default_market(); + market.seed_liquidity(100_000 * ONE_USDC); + let collateral = 1_000 * ONE_USDC; + let (trader, trader_collateral) = market.funded_trader(collateral); + + market.set_feed_owner(Pubkey::new_unique()); + assert_fails_with( + market.open_position( + &trader, + trader_collateral, + Side::Long, + collateral, + 5_000 * ONE_USDC, + 0, + ), + PerpError::PriceFeedNotFromOracle, + ); + + // The retry is otherwise byte-identical to the rejected open, so it would + // carry the same signature and be dropped as already processed. + market.svm.expire_blockhash(); + market.set_feed_owner(mock_price_feed::id()); + market + .open_position( + &trader, + trader_collateral, + Side::Long, + collateral, + 5_000 * ONE_USDC, + 0, + ) + .expect("the same feed owned by the recorded oracle program must be accepted"); +} + #[test] fn test_wide_oracle_confidence_rejected() { let mut market = Market::default_market(); @@ -1151,7 +1212,8 @@ fn test_funding_charged_to_long() { // Let funding accrue, then refresh the feed so the price is fresh again and // close at the same price (no profit/loss). - market.pass_seconds(2_000); + let seconds_open = 2_000; + market.pass_seconds(seconds_open); market.set_price(dollars(100)); market .close_position(&trader, trader_collateral, Side::Long, 0) @@ -1162,11 +1224,12 @@ fn test_funding_charged_to_long() { let net_collateral = collateral - open_fee; let payout = get_token_account_balance(&market.svm, &trader_collateral).unwrap(); - // The trader received less than collateral-minus-close-fee; the shortfall - // is the funding they paid, which went to the liquidity providers. - assert!(payout < net_collateral - close_fee); + // The trader's shortfall against collateral-minus-fees is exactly the + // funding for the seconds the position was open, and it went to the + // liquidity providers. let funding_paid = (net_collateral - close_fee) - payout; - assert!(funding_paid > 0); + let expected = size * MAX_FUNDING_RATE_PER_SECOND * seconds_open as u64 / FUNDING_PRECISION; + assert_eq!(funding_paid, expected); assert_eq!( market.pool_state().liquidity, liquidity_before + funding_paid @@ -1282,7 +1345,7 @@ fn test_operator_on_the_lighter_side_earns_only_the_fixed_rate() { let fees = 2 * (size / 1_000); // open and close, 0.1% of notional each let payout = get_token_account_balance(&market.svm, &operator_collateral).unwrap(); let funding_received = payout - (collateral - fees); - let expected = size * MAX_FUNDING_RATE_PER_SECOND * one_hour as u64 / 1_000_000_000; + let expected = size * MAX_FUNDING_RATE_PER_SECOND * one_hour as u64 / FUNDING_PRECISION; assert_eq!(funding_received, expected); assert!( funding_received * 1_000 < size, diff --git a/finance/perpetual-futures/anchor/CHANGELOG.md b/finance/perpetual-futures/anchor/CHANGELOG.md index 0320e8d64..05c64fc7e 100644 --- a/finance/perpetual-futures/anchor/CHANGELOG.md +++ b/finance/perpetual-futures/anchor/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 2026-10-04 + +Check which program owns the price feed. `initialize_pool` records the feed +account's owning program on the new `Pool.price_feed_program`, read from the +account's owner at that moment, beside the feed address and scale it already +pins. `read_oracle_price` takes that program and refuses a feed account owned +by any other with the new `PriceFeedNotFromOracle`, before it decodes a byte, +so no handler accepts any account laid out like a feed as a price. Tested by +`test_open_rejects_price_feed_from_another_program`, which swaps the feed for +a byte-identical copy owned by an unrelated program and then restores the +owner; `test_initialize_pool` checks the recorded program. + +`test_funding_charged_to_long` asserts the exact funding paid, `size * rate * +seconds / FUNDING_PRECISION`, rather than that some was paid, and +`test_first_deposit_below_minimum_fails` asserts `DepositTooSmall` by code one +base unit under the withheld minimum and a single share minted one over it. + ## 2026-10-01 Replace the leverage cap with an initial margin. `max_leverage` on diff --git a/finance/perpetual-futures/anchor/README.md b/finance/perpetual-futures/anchor/README.md index 866af0354..f0e323244 100644 --- a/finance/perpetual-futures/anchor/README.md +++ b/finance/perpetual-futures/anchor/README.md @@ -87,7 +87,7 @@ A position's *equity* is its net collateral plus profit/loss minus funding. Once ### Oracle -The mark price comes from an oracle feed. This example validates the price for staleness (by slot), publication after the most recent cluster restart (the `LastRestartSlot` sysvar, because a halt passes hours of wall-clock time in zero slots), positivity, scale, and a [confidence band](https://docs.pyth.network/price-feeds/best-practices#confidence-intervals) that must stay within `max_confidence_bps` of the price: rejecting an uncertain price is one of the most common oracle-safety checks. +The mark price comes from an oracle feed. `initialize_pool` records the program that owns the feed account on `Pool.price_feed_program`, and every read refuses a feed account owned by any other program with `PriceFeedNotFromOracle`: the feed's byte layout alone says nothing about who wrote it. The read then validates the price for staleness (by slot), publication after the most recent cluster restart (the `LastRestartSlot` sysvar, because a halt passes hours of wall-clock time in zero slots), positivity, scale, and a [confidence band](https://docs.pyth.network/price-feeds/best-practices#confidence-intervals) that must stay within `max_confidence_bps` of the price: rejecting an uncertain price is one of the most common oracle-safety checks. ### Price band @@ -126,11 +126,11 @@ Amounts below are shown in whole USDC; onchain they are base units (× 10⁶). T **Instruction:** `initialize_pool(parameters)` -The handler validates the parameters, then reads the oracle once to seed the pool's average price at $100. +The handler validates the parameters, records the program that owns the oracle feed account as `price_feed_program`, then reads the oracle once to seed the pool's average price at $100. **Accounts created:** -- `Pool` [PDA](https://solana.com/docs/terminology#program-derived-address-pda), seeds `["pool", collateral_mint, oracle_feed]`: parameters, liquidity, collateral total, program fees, insurance fund, per-side open-interest accumulators, funding index, average oracle price. The pool owns the vault and is the LP mint's authority, and signs vault transfers and mint/burn CPIs with its own seeds; there is no separate signing PDA +- `Pool` [PDA](https://solana.com/docs/terminology#program-derived-address-pda), seeds `["pool", collateral_mint, oracle_feed]`: parameters, the feed's owning program, liquidity, collateral total, program fees, insurance fund, per-side open-interest accumulators, funding index, average oracle price. The pool owns the vault and is the LP mint's authority, and signs vault transfers and mint/burn CPIs with its own seeds; there is no separate signing PDA - `custody_vault` [token account](https://solana.com/docs/terminology#token-account) PDA, seeds `["vault", pool]`: all USDC, both provider liquidity and trader collateral; `pool` is its owner - `lp_mint` PDA, seeds `["lp_mint", pool]`: the share [mint](https://solana.com/docs/terminology#mint-account); `pool` is the mint authority @@ -261,11 +261,11 @@ This is a teaching example, not an audited exchange. Notably: The tests run in-process with [LiteSVM](https://www.anchor-lang.com/docs/testing/litesvm) and [solana-kite](https://solanakite.org); no local validator is needed. They deploy both programs, drive the mock oracle, and cover: -- liquidity round-trips, and share inflation through a provider's own trades +- liquidity round-trips, the first deposit on both sides of the withheld minimum (`test_first_deposit_below_minimum_fails`), and share inflation through a provider's own trades - opening and closing longs and shorts in profit and loss - the initial margin on both sides of its boundary, and slippage rejection -- stale-price, pre-restart-price, and wide-confidence rejection -- funding accrual, the funding-rate maximum, an operator's wallet on the lighter side earning only the fixed rate, and funding that follows seconds rather than slots +- stale-price, pre-restart-price, and wide-confidence rejection, and a feed account owned by another program refused on the owner alone (`test_open_rejects_price_feed_from_another_program`) +- the exact funding a long pays over its time open (`test_funding_charged_to_long`), the funding-rate maximum, an operator's wallet on the lighter side earning only the fixed rate, and funding that follows seconds rather than slots - the price band: opens, closes, deposits and withdrawals refused when the oracle jumps outside it (`test_open_rejected_when_oracle_jumps_outside_band`, `test_close_rejected_when_oracle_jumps_outside_band`, `test_liquidity_changes_rejected_when_oracle_jumps_outside_band`), liquidation running outside it (`test_liquidation_runs_outside_band`), the exact average after each `update_price_average` (`test_single_update_moves_average_by_elapsed_fraction`), repeated updates walking the average to a genuine move until trading resumes (`test_price_average_catches_up_after_genuine_move`), and one manipulated read after an idle window leaving the average where it was (`test_one_manipulated_read_after_idle_does_not_move_average`) - liquidation, and the refusal to liquidate a healthy position - the haircut: a position opening without full backing (`test_open_allowed_without_full_backing`), profit paid in full while the pool backs it (`test_profit_runs_uncapped_when_backed`), two winners each paid exactly half when the pool is stressed (`test_haircut_scales_profit_when_pool_stressed`), the insurance fund paying a profit beyond `liquidity` (`test_insurance_pays_profit_beyond_liquidity`), and a winner offset by an open loser paid the pool's whole backing rather than refused (`test_winner_offset_by_open_loser_is_paid_not_refused`) diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/errors.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/errors.rs index ffb3e4537..a8f8328a5 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/errors.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/errors.rs @@ -72,4 +72,7 @@ pub enum PerpError { "Profit cannot be taken yet: the position has not been open for the pool's profit warm-up" )] ProfitNotMatured, + + #[msg("Price feed is not owned by the oracle program the pool recorded")] + PriceFeedNotFromOracle, } diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/initialize_pool.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/initialize_pool.rs index 02fd07361..d39e79d62 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/initialize_pool.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/initialize_pool.rs @@ -122,10 +122,13 @@ pub fn handle_initialize_pool( PerpError::InvalidPriceDeviation ); - // Seed the average with a validated oracle price, so the band is in force - // from the first trade. + // Record the feed's owning program, and seed the average with a validated + // oracle price, so the owner check and the band are in force from the + // first trade. + let price_feed_program = *context.accounts.oracle_feed.account().owner(); let initial_price = read_oracle_price( &context.accounts.oracle_feed, + &price_feed_program, parameters.oracle_scale, parameters.max_confidence_bps, )?; @@ -135,6 +138,7 @@ pub fn handle_initialize_pool( pool.authority = *context.accounts.authority.address(); pool.collateral_mint = *context.accounts.collateral_mint.address(); pool.oracle_feed = *context.accounts.oracle_feed.address(); + pool.price_feed_program = price_feed_program; pool.oracle_scale = parameters.oracle_scale; pool.custody_vault = *context.accounts.custody_vault.address(); pool.lp_mint = *context.accounts.lp_mint.address(); @@ -182,10 +186,11 @@ pub struct InitializePoolAccountConstraints { pub collateral_mint: Box>, - /// CHECK: The oracle feed account. Its key is stored on the pool and every - /// read, including the one here that seeds the average price, validates - /// the layout, scale, and freshness; it is never trusted by type. Swap for - /// a real Pyth price feed in production. + /// CHECK: The oracle feed account. Its key and its owning program are + /// stored on the pool, and every read, including the one here that seeds + /// the average price, requires that owner and validates the layout, scale, + /// and freshness; it is never trusted by type. Swap for a real Pyth price + /// feed in production. pub oracle_feed: UncheckedAccount, /// Liquidity-provider share mint. The pool account is its mint authority 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 23bb78de2..76e909424 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 @@ -390,7 +390,12 @@ pub fn refresh_price_and_funding_within_band( } fn read_pool_oracle_price(pool: &Pool, oracle_feed: &AccountView) -> Result { - crate::state::oracle::read_oracle_price(oracle_feed, pool.oracle_scale, pool.max_confidence_bps) + crate::state::oracle::read_oracle_price( + oracle_feed, + &pool.price_feed_program, + pool.oracle_scale, + pool.max_confidence_bps, + ) } fn apply_price_and_funding(pool: &mut Pool, price: u64) -> Result<()> { diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/oracle.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/oracle.rs index fa727b350..6e6dbe881 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/oracle.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/oracle.rs @@ -27,11 +27,12 @@ use crate::errors::PerpError; // the spot price for a less manipulable mark; the mock omits the EMA to stay // minimal. // -// The feed account's owning program is NOT checked here: the pool trusts -// whatever feed address its creator configured, which is inside the trust model -// (the creator picks the oracle). A production reader must also verify the -// account owner is the oracle program, which the `PriceUpdateV2` account type -// does. +// The feed account must be owned by the oracle program the pool recorded at +// creation (`Pool::price_feed_program`, read from the feed's `owner` at that +// moment). The layout above says nothing about who wrote the bytes, so +// without the owner check any account laid out like a feed would be accepted +// as a price. The pool's creator picks the feed, so the pool trusts the +// program it recorded and refuses a feed account from any other. const PRICE_OFFSET: usize = 8 + 32; const SCALE_OFFSET: usize = PRICE_OFFSET + 16; const LAST_UPDATE_SLOT_OFFSET: usize = SCALE_OFFSET + 4; @@ -41,14 +42,22 @@ const FEED_MINIMUM_LENGTH: usize = CONFIDENCE_OFFSET + 8; /// Read and validate the oracle price from `feed`. /// /// Returns the price as a `u64` in the pool's `expected_scale` fixed point. -/// Rejects a stale price (older than `MAX_PRICE_STALENESS_SLOTS`), a -/// non-positive price, a feed whose scale differs from the pool's pinned scale, -/// and a price whose confidence band exceeds `max_confidence_bps` of the price. +/// Rejects a feed account owned by any program other than `oracle_program`, a +/// stale price (older than `MAX_PRICE_STALENESS_SLOTS`), a non-positive price, +/// a feed whose scale differs from the pool's pinned scale, and a price whose +/// confidence band exceeds `max_confidence_bps` of the price. pub fn read_oracle_price( feed: &AccountView, + oracle_program: &Address, expected_scale: u32, max_confidence_bps: u16, ) -> Result { + require_keys_eq!( + *feed.owner(), + *oracle_program, + PerpError::PriceFeedNotFromOracle + ); + let data = feed.try_borrow()?; require!( data.len() >= FEED_MINIMUM_LENGTH, diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/pool.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/pool.rs index ed995536b..1c2d05878 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/pool.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/state/pool.rs @@ -19,6 +19,12 @@ pub struct Pool { /// reject any substituted feed account. pub oracle_feed: Address, + /// The program that owned `oracle_feed` when the pool was created. Every + /// price read requires the feed account to still be owned by it, so an + /// account at that address written by any other program is refused as a + /// price. + pub price_feed_program: Address, + /// Decimal places the oracle price is quoted in. Pinned at creation so a /// feed that silently changes scale is rejected rather than mis-read. pub oracle_scale: u32, 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 6d7af33e3..59c50d94a 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 @@ -29,6 +29,9 @@ const TEN_YEARS: i64 = 315_360_000; const PROFIT_WARMUP_SLOTS: u64 = 10; // Matches `HAIRCUT_PRECISION`: a haircut ratio of one. const HAIRCUT_PRECISION: u64 = 1_000_000_000; +// Matches `FUNDING_PRECISION`: the fixed point the funding rate and index are +// carried in, so a position's funding is `size * rate * seconds / this`. +const FUNDING_PRECISION: u64 = 1_000_000_000; // Collateral token has 6 decimals (like USDC), so one whole unit is 1_000_000 // base units. const ONE_USDC: u64 = 1_000_000; @@ -312,6 +315,15 @@ impl Market { (liquidator, liquidator_collateral) } + /// Replace the feed account at the pool's recorded feed address with a + /// copy whose owning program is `owner`. The bytes are unchanged, so the + /// copy still decodes as a fresh, confident price at the pinned scale. + fn set_feed_owner(&mut self, owner: Address) { + let mut feed_account = self.svm.get_account(&self.feed).unwrap(); + feed_account.owner = owner; + self.svm.set_account(self.feed, feed_account).unwrap(); + } + /// Simulate a cluster restart at `slot`: prices stamped at or before it /// must be rejected until the publisher posts again. fn set_last_restart_slot(&mut self, slot: u64) { @@ -623,6 +635,7 @@ fn test_initialize_pool() { assert_eq!(pool.authority, market.admin.pubkey()); assert_eq!(pool.collateral_mint, market.collateral_mint); assert_eq!(pool.oracle_feed, market.feed); + assert_eq!(pool.price_feed_program, mock_price_feed::id()); assert_eq!(pool.oracle_scale, ORACLE_SCALE); assert_eq!(pool.initial_margin_bps, 1_000); assert_eq!(pool.max_price_deviation_bps, 2_000); @@ -668,14 +681,24 @@ fn test_add_liquidity_first_deposit_withholds_minimum() { assert_eq!(shares, deposit - 1_000); } +/// The first deposit must exceed the 1_000 withheld minimum: one base unit +/// short is refused, and one over mints a single share. #[test] fn test_first_deposit_below_minimum_fails() { let mut market = Market::default_market(); let (provider, provider_collateral) = market.funded_trader(10_000); - // 500 base units is below the 1_000 locked minimum. - assert!(market - .add_liquidity(&provider, provider_collateral, 500, 0) - .is_err()); + assert_fails_with( + market.add_liquidity(&provider, provider_collateral, 999, 0), + PerpError::DepositTooSmall, + ); + market + .add_liquidity(&provider, provider_collateral, 1_001, 0) + .unwrap(); + let provider_lp = derive_ata(&provider.pubkey(), &market.lp_mint); + assert_eq!( + get_token_account_balance(&market.svm, &provider_lp).unwrap(), + 1 + ); } #[test] @@ -1107,6 +1130,47 @@ fn test_open_rejects_price_from_before_a_restart() { .expect("a freshly published price must be accepted after a restart"); } +/// The pool records the program that owns its feed at creation and refuses a +/// price from a feed account owned by any other program, however well its +/// bytes decode. The feed is swapped for a byte-identical copy owned by an +/// unrelated program, and the refusal is by the owner alone: the same bytes +/// owned by the mock oracle program again are accepted. +#[test] +fn test_open_rejects_price_feed_from_another_program() { + let mut market = Market::default_market(); + market.seed_liquidity(100_000 * ONE_USDC); + let collateral = 1_000 * ONE_USDC; + let (trader, trader_collateral) = market.funded_trader(collateral); + + market.set_feed_owner(Address::new_unique()); + assert_fails_with( + market.open_position( + &trader, + trader_collateral, + Side::Long, + collateral, + 5_000 * ONE_USDC, + 0, + ), + PerpError::PriceFeedNotFromOracle, + ); + + // The retry is otherwise byte-identical to the rejected open, so it would + // carry the same signature and be dropped as already processed. + market.svm.expire_blockhash(); + market.set_feed_owner(mock_price_feed::id()); + market + .open_position( + &trader, + trader_collateral, + Side::Long, + collateral, + 5_000 * ONE_USDC, + 0, + ) + .expect("the same feed owned by the recorded oracle program must be accepted"); +} + #[test] fn test_wide_oracle_confidence_rejected() { let mut market = Market::default_market(); @@ -1146,7 +1210,8 @@ fn test_funding_charged_to_long() { // Let funding accrue, then refresh the feed so the price is fresh again and // close at the same price (no profit/loss). - market.pass_seconds(2_000); + let seconds_open = 2_000; + market.pass_seconds(seconds_open); market.set_price(dollars(100)); market .close_position(&trader, trader_collateral, Side::Long, 0) @@ -1157,11 +1222,12 @@ fn test_funding_charged_to_long() { let net_collateral = collateral - open_fee; let payout = get_token_account_balance(&market.svm, &trader_collateral).unwrap(); - // The trader received less than collateral-minus-close-fee; the shortfall - // is the funding they paid, which went to the liquidity providers. - assert!(payout < net_collateral - close_fee); + // The trader's shortfall against collateral-minus-fees is exactly the + // funding for the seconds the position was open, and it went to the + // liquidity providers. let funding_paid = (net_collateral - close_fee) - payout; - assert!(funding_paid > 0); + let expected = size * MAX_FUNDING_RATE_PER_SECOND * seconds_open as u64 / FUNDING_PRECISION; + assert_eq!(funding_paid, expected); assert_eq!( market.pool_state().liquidity, liquidity_before + funding_paid @@ -1277,7 +1343,7 @@ fn test_operator_on_the_lighter_side_earns_only_the_fixed_rate() { let fees = 2 * (size / 1_000); // open and close, 0.1% of notional each let payout = get_token_account_balance(&market.svm, &operator_collateral).unwrap(); let funding_received = payout - (collateral - fees); - let expected = size * MAX_FUNDING_RATE_PER_SECOND * one_hour as u64 / 1_000_000_000; + let expected = size * MAX_FUNDING_RATE_PER_SECOND * one_hour as u64 / FUNDING_PRECISION; assert_eq!(funding_received, expected); assert!( funding_received * 1_000 < size, diff --git a/finance/perpetual-futures/quasar/CHANGELOG.md b/finance/perpetual-futures/quasar/CHANGELOG.md index b25a9511f..c4564f937 100644 --- a/finance/perpetual-futures/quasar/CHANGELOG.md +++ b/finance/perpetual-futures/quasar/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 2026-10-04 + +Check which program owns the price feed. `initialize_pool` records the feed +account's owning program on the new `Pool.price_feed_program`, read from the +account's owner at that moment, beside the feed address and scale it already +pins. `read_feed_price` takes that program and refuses a feed account owned by +any other with the new `PRICE_FEED_NOT_FROM_ORACLE` (23), before it decodes a +byte, so no handler accepts any account laid out like a feed as a price. +Tested by `open_rejects_price_feed_from_another_program`, which rewrites the +feed as a byte-identical copy owned by an unrelated program and then restores +the owner; `initialize_pool_creates_pool_vault_and_lp_mint` checks the +recorded program. + +`funding_follows_seconds_not_slots` asserts the exact funding paid, `size * +rate * seconds / FUNDING_PRECISION`, rather than that some was paid, and the +new `first_deposit_below_minimum_fails` asserts `DEPOSIT_TOO_SMALL` by code one +base unit under the withheld minimum and a single share minted one over it. + ## 2026-10-01 Replace the leverage cap with an initial margin. `initialize_pool`'s diff --git a/finance/perpetual-futures/quasar/README.md b/finance/perpetual-futures/quasar/README.md index 7f7b246de..4b9299490 100644 --- a/finance/perpetual-futures/quasar/README.md +++ b/finance/perpetual-futures/quasar/README.md @@ -21,8 +21,14 @@ math. This page only covers what differs in the Quasar version. restart, which slot-based staleness alone cannot catch (a halt passes hours of wall-clock time in zero slots). - **Oracle feed in tests.** Rather than a separate mock-oracle program, the - tests write the feed account's bytes directly (price, scale, last-update slot) - and the program reads them the same way it would read a real oracle feed. + tests write the feed account's bytes directly (price, scale, last-update + slot, confidence) as a system-owned account, and the program reads them the + same way it would read a real oracle feed. The pool records the program that + owns the feed account at creation on `Pool.price_feed_program` and every + read refuses a feed account owned by any other program with + `PRICE_FEED_NOT_FROM_ORACLE` (23); here that recorded program is the system + program, and `open_rejects_price_feed_from_another_program` rewrites the + feed with another owner to show the refusal is on the owner alone. - **State writes** use Quasar's zero-copy field accessors (`field.get()` / `field.set()`) and `set_inner`, rather than Anchor's `Account` mutation. @@ -35,12 +41,17 @@ wallets, then exercise: - pool initialization, including its checks on the initial margin (above the maintenance margin, at most 10,000 basis points) and the price band (above zero, below 10,000 basis points) -- liquidity add/remove, and share inflation through a provider's own trades +- liquidity add/remove, the first deposit on both sides of the withheld + minimum (`first_deposit_below_minimum_fails`), and share inflation through a + provider's own trades - opening and closing a long in profit, and the initial margin on both sides of its boundary -- stale-price, pre-restart-price, and wide-confidence rejection +- stale-price, pre-restart-price, and wide-confidence rejection, and a feed + account owned by another program refused on the owner alone + (`open_rejects_price_feed_from_another_program`) - the funding-rate maximum, an operator's wallet on the lighter side earning - only the fixed rate, and funding that follows seconds rather than slots + only the fixed rate, and the exact funding a long pays over its time open, + which follows seconds rather than slots (`funding_follows_seconds_not_slots`) - the price band: opens, closes, deposits and withdrawals refused when the oracle jumps outside it, liquidation running outside it, the exact average after one `update_price_average` @@ -70,7 +81,8 @@ Program errors are `ProgramError::Custom` codes listed in `instructions/shared.rs`, with the same names as the Anchor version's `PerpError` variants in upper snake case: `INITIAL_MARGIN_NOT_MET` (2), `INITIAL_MARGIN_NOT_ABOVE_MAINTENANCE` (19), `INVALID_PRICE_DEVIATION` (20), -`PRICE_OUTSIDE_BAND` (21) and `PROFIT_NOT_MATURED` (22) among them. +`PRICE_OUTSIDE_BAND` (21), `PROFIT_NOT_MATURED` (22) and +`PRICE_FEED_NOT_FROM_ORACLE` (23) among them. `update_price_average` is discriminator 7. `initialize_pool` takes the Anchor version's `PoolParameters` fields as separate arguments, ending with `insurance_fee_bps` and `profit_warmup_slots`. diff --git a/finance/perpetual-futures/quasar/src/instructions/initialize_pool.rs b/finance/perpetual-futures/quasar/src/instructions/initialize_pool.rs index 7cbc09aa3..201b54832 100644 --- a/finance/perpetual-futures/quasar/src/instructions/initialize_pool.rs +++ b/finance/perpetual-futures/quasar/src/instructions/initialize_pool.rs @@ -21,8 +21,9 @@ pub struct InitializePool { )] pub pool: Account, pub collateral_mint: Account, - /// CHECK: stored on the pool; every read, including the one here that seeds - /// the average price, validates layout, scale, freshness. + /// CHECK: its key and its owning program are stored on the pool; every + /// read, including the one here that seeds the average price, requires + /// that owner and validates layout, scale, freshness. pub oracle_feed: UncheckedAccount, /// Liquidity-provider share mint; the pool account is its mint authority. #[account( @@ -113,10 +114,13 @@ pub fn handle_initialize_pool( return Err(err(error::INVALID_PRICE_DEVIATION)); } - // Seed the average with a validated oracle price, so the band is in force - // from the first trade. + // Record the feed's owning program, and seed the average with a validated + // oracle price, so the owner check and the band are in force from the + // first trade. + let price_feed_program = *accounts.oracle_feed.to_account_view().owner(); let initial_price = read_feed_price( &accounts.oracle_feed, + &price_feed_program, oracle_scale, accounts.clock.slot.get(), max_confidence_bps, @@ -126,6 +130,7 @@ pub fn handle_initialize_pool( authority: *accounts.authority.address(), collateral_mint: *accounts.collateral_mint.address(), oracle_feed: *accounts.oracle_feed.address(), + price_feed_program, custody_vault: *accounts.custody_vault.address(), lp_mint: *accounts.lp_mint.address(), oracle_scale, diff --git a/finance/perpetual-futures/quasar/src/instructions/shared.rs b/finance/perpetual-futures/quasar/src/instructions/shared.rs index 917fc2221..a1127958a 100644 --- a/finance/perpetual-futures/quasar/src/instructions/shared.rs +++ b/finance/perpetual-futures/quasar/src/instructions/shared.rs @@ -35,6 +35,7 @@ pub mod error { pub const INVALID_PRICE_DEVIATION: u32 = 20; pub const PRICE_OUTSIDE_BAND: u32 = 21; pub const PROFIT_NOT_MATURED: u32 = 22; + pub const PRICE_FEED_NOT_FROM_ORACLE: u32 = 23; } #[inline(always)] @@ -55,9 +56,15 @@ fn overflow() -> ProgramError { // Like the Anchor sibling, this validates freshness, positivity, and the // confidence band (`confidence / price`), rejecting a price whose band is too // wide. A production reader may also prefer the feed's EMA over the spot price; -// the mock omits the EMA to stay minimal. The feed account's owning program is -// NOT checked here — the pool trusts whatever feed its creator configured; a -// production reader must verify the owner is the oracle program. +// the mock omits the EMA to stay minimal. +// +// The feed account must be owned by the oracle program the pool recorded at +// creation (`Pool::price_feed_program`, read from the feed's `owner` at that +// moment). `read_feed_price` checks that before a byte is decoded. The layout +// above says nothing about who wrote the bytes, so without the owner check +// any account laid out like a feed would be accepted as a price. The pool's +// creator picks the feed, so the pool trusts the program it recorded and +// refuses a feed account from any other. const PRICE_OFFSET: usize = 0; const SCALE_OFFSET: usize = PRICE_OFFSET + 16; const LAST_UPDATE_SLOT_OFFSET: usize = SCALE_OFFSET + 4; @@ -425,14 +432,19 @@ pub fn require_price_within_band(pool: &Account, price: u64) -> Result<(), } /// Read and validate the oracle price from the feed account, checked for -/// freshness against `slot`. +/// freshness against `slot`. Rejects a feed account owned by any program other +/// than `oracle_program` before decoding a byte. pub fn read_feed_price( oracle_feed: &UncheckedAccount, + oracle_program: &Address, expected_scale: u32, slot: u64, max_confidence_bps: u16, ) -> Result { let view = oracle_feed.to_account_view(); + if view.owner() != oracle_program { + return Err(err(error::PRICE_FEED_NOT_FROM_ORACLE)); + } let data = view .try_borrow() .map_err(|_| err(error::ORACLE_DATA_TOO_SHORT))?; @@ -487,6 +499,7 @@ fn read_pool_oracle_price( ) -> Result { read_feed_price( oracle_feed, + &pool.price_feed_program, pool.oracle_scale.get(), slot, pool.max_confidence_bps.get(), diff --git a/finance/perpetual-futures/quasar/src/state.rs b/finance/perpetual-futures/quasar/src/state.rs index 4aa63577e..fe16be4cf 100644 --- a/finance/perpetual-futures/quasar/src/state.rs +++ b/finance/perpetual-futures/quasar/src/state.rs @@ -12,6 +12,11 @@ pub struct Pool { pub authority: Address, pub collateral_mint: Address, pub oracle_feed: Address, + /// The program that owned `oracle_feed` when the pool was created. Every + /// price read requires the feed account to still be owned by it, so an + /// account at that address written by any other program is refused as a + /// price. + pub price_feed_program: Address, pub custody_vault: Address, pub lp_mint: Address, pub oracle_scale: u32, diff --git a/finance/perpetual-futures/quasar/src/tests.rs b/finance/perpetual-futures/quasar/src/tests.rs index 17407306b..38da49308 100644 --- a/finance/perpetual-futures/quasar/src/tests.rs +++ b/finance/perpetual-futures/quasar/src/tests.rs @@ -47,6 +47,8 @@ const OPERATOR_COLLATERAL: Pubkey = Pubkey::new_from_array([16; 32]); const KEEPER: Pubkey = Pubkey::new_from_array([17; 32]); const SECOND_TRADER: Pubkey = Pubkey::new_from_array([18; 32]); const SECOND_TRADER_COLLATERAL: Pubkey = Pubkey::new_from_array([19; 32]); +// A program that is not the one the pool recorded as its feed's owner. +const OTHER_PROGRAM: Pubkey = Pubkey::new_from_array([20; 32]); // Matches `PRICE_AVERAGE_WINDOW_SECONDS`: one fold after this many seconds // replaces the pool's average price with the oracle price. @@ -61,25 +63,36 @@ const PROFIT_WARMUP_SLOTS: u64 = 10; // Matches `HAIRCUT_PRECISION`: a haircut ratio of one. const HAIRCUT_PRECISION: u64 = 1_000_000_000; +// Matches `FUNDING_PRECISION`: the fixed point the funding rate and index are +// carried in, so a position's funding is `size * rate * seconds / this`. +const FUNDING_PRECISION: u64 = 1_000_000_000; fn dollars(whole: i128) -> i128 { whole * 10i128.pow(ORACLE_SCALE) } /// A feed account in this program's layout: price (i128), scale (u32), -/// last_update_slot (u64), confidence (u64). The tests own this; production -/// reads a real feed. +/// last_update_slot (u64), confidence (u64), owned by the system program, +/// which the pool therefore records as the feed's owning program. The tests +/// own this; production reads a real feed. fn set_feed(test: &mut Test, price: i128, confidence: u64) { set_feed_at_slot(test, price, SLOT, confidence); } fn set_feed_at_slot(test: &mut Test, price: i128, slot: u64, confidence: u64) { + set_feed_owned_by(test, system_program::ID, price, slot, confidence); +} + +/// Write the feed account at `FEED` with `owner` as its owning program. The +/// bytes are the same whoever owns it, so a copy owned by another program +/// still decodes as a fresh, confident price at the pinned scale. +fn set_feed_owned_by(test: &mut Test, owner: Pubkey, price: i128, slot: u64, confidence: u64) { let mut data = Vec::with_capacity(36); data.extend_from_slice(&price.to_le_bytes()); data.extend_from_slice(&ORACLE_SCALE.to_le_bytes()); data.extend_from_slice(&slot.to_le_bytes()); data.extend_from_slice(&confidence.to_le_bytes()); - test.set_account(Account::new(FEED, system_program::ID, 1_000_000, data)); + test.set_account(Account::new(FEED, owner, 1_000_000, data)); } /// Pin the Clock sysvar account at `slot` and `unix_timestamp`. Price freshness @@ -375,8 +388,13 @@ fn close_position_for( #[quasar_test] fn initialize_pool_creates_pool_vault_and_lp_mint(test: &mut Test) { let env = setup(test); - // The pool, vault, and liquidity-provider mint were created. + // The pool, vault, and liquidity-provider mint were created, and the pool + // recorded the program that owned the feed. assert!(test.account(env.pool).is_some()); + assert_eq!( + test.read::(env.pool).price_feed_program, + system_program::ID + ); let custody_vault = test.account(env.custody_vault).unwrap(); let lp_mint = test.account(env.lp_mint).unwrap(); @@ -403,6 +421,18 @@ fn add_liquidity_deposits_and_mints_shares(test: &mut Test) { .has_tokens(PROVIDER_LP, 10_000 * ONE_USDC - 1_000); } +/// The first deposit must exceed the 1_000 withheld minimum: one base unit +/// short is refused, and one over mints a single share. +#[quasar_test] +fn first_deposit_below_minimum_fails(test: &mut Test) { + let env = setup(test); + fund(test, PROVIDER, PROVIDER_COLLATERAL, 10_000); + add_liquidity(test, &env, 999).fails_with(error::DEPOSIT_TOO_SMALL); + add_liquidity(test, &env, 1_001) + .succeeds() + .has_tokens(PROVIDER_LP, 1); +} + #[quasar_test] fn remove_liquidity_round_trip_returns_the_deposit_less_the_minimum(test: &mut Test) { let env = setup(test); @@ -536,6 +566,26 @@ fn open_rejects_price_from_before_a_restart(test: &mut Test) { open_position(test, &env, 0, 1_000 * ONE_USDC, 5_000 * ONE_USDC).succeeds(); } +/// The pool records the program that owns its feed at creation and refuses a +/// price from a feed account owned by any other program, however well its +/// bytes decode. The feed is swapped for a byte-identical copy owned by an +/// unrelated program, and the refusal is by the owner alone: the same bytes +/// owned by the recorded program again are accepted. +#[quasar_test] +fn open_rejects_price_feed_from_another_program(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); + + set_feed_owned_by(test, OTHER_PROGRAM, dollars(100), SLOT, 0); + open_position(test, &env, SIDE_LONG, 1_000 * ONE_USDC, 5_000 * ONE_USDC) + .fails_with(error::PRICE_FEED_NOT_FROM_ORACLE); + + set_feed(test, dollars(100), 0); + open_position(test, &env, SIDE_LONG, 1_000 * ONE_USDC, 5_000 * ONE_USDC).succeeds(); +} + #[quasar_test] fn close_long_in_profit_pays_collateral_plus_pnl_minus_fees(test: &mut Test) { let env = setup(test); @@ -685,9 +735,10 @@ fn funding_follows_seconds_not_slots(test: &mut Test) { close_position(test, &env).succeeds(); let with_extra_slots = (before_extra - test.tokens(TRADER_COLLATERAL)) - fees; - assert!( - flat > 0, - "the flat run must pay some funding to compare against" + // The flat run paid exactly the funding for the seconds it was open. + assert_eq!( + flat, + size * MAX_FUNDING_RATE_PER_SECOND * (2 * window) as u64 / FUNDING_PRECISION ); assert_eq!(with_extra_slots, flat); } @@ -750,7 +801,7 @@ fn operator_on_the_lighter_side_earns_only_the_fixed_rate(test: &mut Test) { let fees = 2 * (size / 1_000); // open and close, 0.1% of notional each let funding_received = test.tokens(OPERATOR_COLLATERAL) - (collateral - fees); - let expected = size * MAX_FUNDING_RATE_PER_SECOND * one_hour as u64 / 1_000_000_000; + let expected = size * MAX_FUNDING_RATE_PER_SECOND * one_hour as u64 / FUNDING_PRECISION; assert_eq!(funding_received, expected); assert!( funding_received * 1_000 < size, diff --git a/finance/prop-amm/anchor-v1/CHANGELOG.md b/finance/prop-amm/anchor-v1/CHANGELOG.md index d1c91e84d..254fdfcdd 100644 --- a/finance/prop-amm/anchor-v1/CHANGELOG.md +++ b/finance/prop-amm/anchor-v1/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 2026-10-04 + +Check which program owns the price feed. `initialize_market` records the +feed account's owning program on the new `Market.price_feed_program`, read +from the account's owner at that moment, beside the feed address and scale it +already pins. `read_oracle_price` takes that program and refuses a feed +account owned by any other with the new `PriceFeedNotFromOracle`, before it +decodes a byte, so `swap` no longer accepts any account laid out like a feed +as a price. Tested by `test_swap_rejects_price_feed_from_another_program`, +which swaps the feed for a byte-identical copy owned by an unrelated program +and then restores the owner. The `errors` module is public so the tests can +match `PropAmmError` codes, and the suite's `swap` and `try_new` return the +transaction error text. + ## 2026-09-23 The mock oracle program is now `mock-price-feed` (library and program diff --git a/finance/prop-amm/anchor-v1/README.md b/finance/prop-amm/anchor-v1/README.md index 424828657..92daa04f7 100644 --- a/finance/prop-amm/anchor-v1/README.md +++ b/finance/prop-amm/anchor-v1/README.md @@ -67,11 +67,13 @@ during fast markets their quotes vanish and return minutes later. ### Oracle staleness and confidence -Every swap re-validates the feed: the price must be positive, at the pinned -scale, no older than 150 slots (~1 minute), stamped after the most recent -cluster restart (the `LastRestartSlot` sysvar; a halt passes hours of -wall-clock time in zero slots), and its confidence band must be inside -`max_confidence_bps`. For this design the staleness bound is not +Every swap re-validates the feed: the feed account must be owned by the +program the market recorded at creation (`PriceFeedNotFromOracle` otherwise; +the layout alone says nothing about who wrote the bytes), the price must be +positive, at the pinned scale, no older than 150 slots (~1 minute), stamped +after the most recent cluster restart (the `LastRestartSlot` sysvar; a halt +passes hours of wall-clock time in zero slots), and its confidence band must +be inside `max_confidence_bps`. For this design the staleness bound is not hygiene, it is the business: a quote priced off an old number is a free option for whoever notices first. @@ -88,8 +90,9 @@ option for whoever notices first. `initialize_market` creates the `Market` account (PDA of the mint pair) and the two vaults, which the market account itself owns and signs for, and pins -the oracle feed, its scale, a 10 bps spread, and a 1% confidence limit. One market per pair: -the deployment is the firm. +the oracle feed, the program that owns it (`price_feed_program`, read from +the feed account's owner), its scale, a 10 bps spread, and a 1% confidence +limit. One market per pair: the deployment is the firm. ### Step 2: Maria stocks the inventory @@ -143,9 +146,6 @@ misprices. ## Limitations -- The oracle feed's owning program is not verified: the operator picks the - feed, and a bad choice loses the operator's money, not the traders'. A - production reader must still check the account owner. - One flat spread both ways; no inventory skew, no size-dependent pricing. - `paused` is the only circuit breaker; production venues also bound per-slot volume and single-fill size. @@ -160,8 +160,9 @@ cargo test The LiteSVM suite (`programs/prop-amm/tests/test_prop_amm.rs`) verifies the quote math to the minor unit in both directions, the exact round-trip spread, oracle repricing and re-quoting, and that every gate shuts: slippage, -staleness, confidence, pause, zero amounts, inventory bounds, and operator -access control. +staleness, confidence, a feed account owned by another program +(`test_swap_rejects_price_feed_from_another_program`), pause, zero amounts, +inventory bounds, and operator access control. ## FAQ @@ -179,4 +180,4 @@ The spread is the fee: buyers pay the oracle price plus `spread_bps`, sellers re ### What stops the venue from quoting a stale price? -Every `swap` re-validates the feed: the price must be fresh (no older than 150 slots), stamped after the most recent cluster restart, at the pinned scale, and inside the configured confidence band. A stale quote is a free option for whoever notices first, so the staleness checks are the business model, not hygiene. +Every `swap` re-validates the feed: the feed account must be owned by the program the market recorded at creation, and the price must be fresh (no older than 150 slots), stamped after the most recent cluster restart, at the pinned scale, and inside the configured confidence band. A stale quote is a free option for whoever notices first, so the staleness checks are the business model, not hygiene. 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 1f622fc1e..18a8f2528 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 @@ -43,4 +43,7 @@ pub enum PropAmmError { #[msg("Oracle price is stale: it predates the last cluster restart")] PricePredatesRestart, + + #[msg("Price feed is not owned by the oracle program the market recorded")] + PriceFeedNotFromOracle, } diff --git a/finance/prop-amm/anchor-v1/programs/prop-amm/src/instructions/initialize_market.rs b/finance/prop-amm/anchor-v1/programs/prop-amm/src/instructions/initialize_market.rs index bf7df72e9..9c9024436 100644 --- a/finance/prop-amm/anchor-v1/programs/prop-amm/src/instructions/initialize_market.rs +++ b/finance/prop-amm/anchor-v1/programs/prop-amm/src/instructions/initialize_market.rs @@ -53,6 +53,7 @@ pub fn handle_initialize_market( market.base_mint = context.accounts.base_mint.key(); market.quote_mint = context.accounts.quote_mint.key(); market.oracle_feed = context.accounts.oracle_feed.key(); + market.price_feed_program = *context.accounts.oracle_feed.owner; market.base_vault = context.accounts.base_vault.key(); market.quote_vault = context.accounts.quote_vault.key(); market.oracle_scale = parameters.oracle_scale; @@ -87,9 +88,10 @@ pub struct InitializeMarketAccountConstraints<'info> { pub quote_mint: Box>, - /// CHECK: The oracle feed account. Its key is stored on the market and - /// every read validates the layout, scale, and freshness; it is never - /// trusted by type. Swap for a real Pyth price feed in production. + /// CHECK: The oracle feed account. Its key and its owning program are + /// stored on the market, and every read requires that owner and validates + /// the layout, scale, and freshness; it is never trusted by type. Swap for + /// a real Pyth price feed in production. pub oracle_feed: UncheckedAccount<'info>, // The market account itself is the token authority of both vaults and diff --git a/finance/prop-amm/anchor-v1/programs/prop-amm/src/instructions/swap.rs b/finance/prop-amm/anchor-v1/programs/prop-amm/src/instructions/swap.rs index 669281c60..a3d03d7c3 100644 --- a/finance/prop-amm/anchor-v1/programs/prop-amm/src/instructions/swap.rs +++ b/finance/prop-amm/anchor-v1/programs/prop-amm/src/instructions/swap.rs @@ -27,11 +27,13 @@ pub fn handle_swap( require!(!market.paused, PropAmmError::MarketPaused); require!(amount_in > 0, PropAmmError::ZeroAmount); - // Freshness, scale, and confidence are all enforced inside the read. For a - // market maker the staleness bound is the business itself: a quote priced - // off an old number is a free option for whoever notices first. + // The feed's owning program, freshness, scale, and confidence are all + // enforced inside the read. For a market maker the staleness bound is the + // business itself: a quote priced off an old number is a free option for + // whoever notices first. let oracle_price = read_oracle_price( &context.accounts.oracle_feed, + &market.price_feed_program, market.oracle_scale, market.max_confidence_bps, )?; 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 0706dfb43..6770f0d09 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 @@ -1,7 +1,8 @@ use anchor_lang::prelude::*; mod constants; -mod errors; +// Public so the LiteSVM integration tests can match `PropAmmError` codes. +pub mod errors; // Public so the LiteSVM integration tests can build instruction arguments // (`MarketParameters`, `Direction`) against the program's own types. pub mod instructions; diff --git a/finance/prop-amm/anchor-v1/programs/prop-amm/src/state/market.rs b/finance/prop-amm/anchor-v1/programs/prop-amm/src/state/market.rs index 7efb5eec5..cf4c38e6f 100644 --- a/finance/prop-amm/anchor-v1/programs/prop-amm/src/state/market.rs +++ b/finance/prop-amm/anchor-v1/programs/prop-amm/src/state/market.rs @@ -35,6 +35,12 @@ pub struct Market { /// substituted feed account. pub oracle_feed: Pubkey, + /// The program that owned `oracle_feed` when the market was created. Every + /// price read requires the feed account to still be owned by it, so an + /// account at that address written by any other program is refused as a + /// price. + pub price_feed_program: Pubkey, + pub base_vault: Pubkey, pub quote_vault: Pubkey, diff --git a/finance/prop-amm/anchor-v1/programs/prop-amm/src/state/oracle.rs b/finance/prop-amm/anchor-v1/programs/prop-amm/src/state/oracle.rs index ef33f7a9b..6db0f5c3d 100644 --- a/finance/prop-amm/anchor-v1/programs/prop-amm/src/state/oracle.rs +++ b/finance/prop-amm/anchor-v1/programs/prop-amm/src/state/oracle.rs @@ -26,12 +26,13 @@ use crate::errors::PropAmmError; // market maker it is existential: quoting a tight spread around a price the // oracle itself is unsure of is how inventory walks out the door. // -// The feed account's owning program is NOT checked here: the market trusts -// whatever feed address its operator configured, which is inside the trust -// model (the operator quotes its own capital against its own oracle choice; a -// bad feed loses the operator's money, not the traders'). A production reader -// must still verify the account owner is the oracle program, which -// the `PriceUpdateV2` account type does. +// The feed account must be owned by the oracle program the market recorded +// at creation (`Market::price_feed_program`, read from the feed's `owner` at +// that moment). The layout above says nothing about who wrote the bytes, so +// without the owner check any account laid out like a feed would be accepted +// as a price. The operator picks the feed, and a bad choice loses the +// operator's capital rather than the traders', so the market trusts the +// program it recorded and refuses a feed account from any other. const PRICE_OFFSET: usize = 8 + 32; const SCALE_OFFSET: usize = PRICE_OFFSET + 16; const LAST_UPDATE_SLOT_OFFSET: usize = SCALE_OFFSET + 4; @@ -41,15 +42,22 @@ const FEED_MINIMUM_LENGTH: usize = CONFIDENCE_OFFSET + 8; /// Read and validate the oracle price from `feed`. /// /// Returns the price as a `u64` in the market's `expected_scale` fixed point. -/// Rejects a stale price (older than `MAX_PRICE_STALENESS_SLOTS`), a -/// non-positive price, a feed whose scale differs from the market's pinned -/// scale, and a price whose confidence band exceeds `max_confidence_bps` of -/// the price. +/// Rejects a feed account owned by any program other than `oracle_program`, +/// a stale price (older than `MAX_PRICE_STALENESS_SLOTS`), a non-positive +/// price, a feed whose scale differs from the market's pinned scale, and a +/// price whose confidence band exceeds `max_confidence_bps` of the price. pub fn read_oracle_price( feed: &AccountInfo, + oracle_program: &Pubkey, expected_scale: u32, max_confidence_bps: u16, ) -> Result { + require_keys_eq!( + *feed.owner, + *oracle_program, + PropAmmError::PriceFeedNotFromOracle + ); + let data = feed.try_borrow_data()?; require!( data.len() >= FEED_MINIMUM_LENGTH, 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 b098949ab..d3af29a75 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 @@ -5,6 +5,7 @@ use { }, litesvm::LiteSVM, prop_amm::{ + errors::PropAmmError, instructions::initialize_market::MarketParameters, state::{Direction, Market as MarketState}, }, @@ -54,6 +55,19 @@ fn dollars(whole: i128) -> i128 { whole * 10i128.pow(ORACLE_SCALE) } +/// Assert that `result` failed with the program's `expected` error. Anchor +/// reports a program error as `Custom(6000 + the variant's index)`. +fn assert_fails_with(result: Result, expected: PropAmmError) { + let code = expected as u32 + 6000; + let Err(error) = result else { + panic!("the transaction should have failed with error code {code}"); + }; + assert!( + error.contains(&format!("Custom({code})")), + "expected error code {code}, got: {error}" + ); +} + /// One deployed market plus the keys needed to drive it. struct Market { svm: LiteSVM, @@ -84,7 +98,7 @@ impl Market { /// Like `new`, but takes the full parameter set and surfaces an /// `initialize_market` rejection instead of panicking, so tests can probe /// the parameter validation. - fn try_new(initial_price: i128, parameters: MarketParameters) -> Result { + fn try_new(initial_price: i128, parameters: MarketParameters) -> Result { let mut svm = LiteSVM::new(); svm.add_program( prop_amm::id(), @@ -168,7 +182,7 @@ impl Market { &[&operator], &operator.pubkey(), ) - .map_err(|_| ())?; + .map_err(|error| format!("{error:?}"))?; // Fund the operator's inventory accounts. let operator_base = @@ -410,7 +424,7 @@ impl Market { direction: Direction, amount_in: u64, minimum_amount_out: u64, - ) -> Result<(), ()> { + ) -> Result<(), String> { let trader_base = derive_ata(&trader.pubkey(), &self.base_mint); let trader_quote = derive_ata(&trader.pubkey(), &self.quote_mint); let instruction = Instruction::new_with_bytes( @@ -444,7 +458,16 @@ impl Market { &trader.pubkey(), ) .map(|_| ()) - .map_err(|_| ()) + .map_err(|error| format!("{error:?}")) + } + + /// Replace the feed account at the market's recorded feed address with a + /// copy whose owning program is `owner`. The bytes are unchanged, so the + /// copy still decodes as a fresh, confident price at the pinned scale. + fn set_feed_owner(&mut self, owner: Pubkey) { + let mut feed_account = self.svm.get_account(&self.feed).unwrap(); + feed_account.owner = owner; + self.svm.set_account(self.feed, feed_account).unwrap(); } fn balance(&self, token_account: &Pubkey) -> u64 { @@ -686,6 +709,35 @@ fn test_swap_rejects_price_from_before_a_restart() { .expect("a freshly published price must be accepted after a restart"); } +/// The market records the program that owns its feed at creation and refuses +/// a price from a feed account owned by any other program, however well its +/// bytes decode. The feed is swapped for a byte-identical copy owned by an +/// unrelated program, and the refusal is by the owner alone: the same bytes +/// owned by the mock oracle program again are accepted. +#[test] +fn test_swap_rejects_price_feed_from_another_program() { + let mut market = Market::default_market(); + assert_eq!( + market.market_state().price_feed_program, + mock_price_feed::id() + ); + let (alice, _, _) = market.funded_trader(0, 825_825_000); + + market.set_feed_owner(Pubkey::new_unique()); + assert_fails_with( + market.swap(&alice, Direction::BuyBase, 825_825_000, 0), + PropAmmError::PriceFeedNotFromOracle, + ); + + // 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_feed_owner(mock_price_feed::id()); + market + .swap(&alice, Direction::BuyBase, 825_825_000, 0) + .expect("the same feed owned by the recorded oracle program must be accepted"); +} + /// A price the oracle itself is unsure about is rejected: the confidence band /// (about 1.2% here) exceeds the market's 1% limit. #[test] diff --git a/finance/prop-amm/anchor/CHANGELOG.md b/finance/prop-amm/anchor/CHANGELOG.md index a829f2c15..591b5d2d7 100644 --- a/finance/prop-amm/anchor/CHANGELOG.md +++ b/finance/prop-amm/anchor/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 2026-10-04 + +Check which program owns the price feed. `initialize_market` records the +feed account's owning program on the new `Market.price_feed_program`, read +from the account's owner at that moment, beside the feed address and scale it +already pins. `read_oracle_price` takes that program and refuses a feed +account owned by any other with the new `PriceFeedNotFromOracle`, before it +decodes a byte, so `swap` no longer accepts any account laid out like a feed +as a price. Tested by `test_swap_rejects_price_feed_from_another_program`, +which swaps the feed for a byte-identical copy owned by an unrelated program +and then restores the owner. The `errors` module is public so the tests can +match `PropAmmError` codes, and the suite's `swap` and `try_new` return the +transaction error text. + ## 2026-09-23 The mock oracle program is now `mock-price-feed` (library and program diff --git a/finance/prop-amm/anchor/README.md b/finance/prop-amm/anchor/README.md index 0a209c117..866d383cf 100644 --- a/finance/prop-amm/anchor/README.md +++ b/finance/prop-amm/anchor/README.md @@ -67,11 +67,13 @@ during fast markets their quotes vanish and return minutes later. ### Oracle staleness and confidence -Every swap re-validates the feed: the price must be positive, at the pinned -scale, no older than 150 slots (~1 minute), stamped after the most recent -cluster restart (the `LastRestartSlot` sysvar; a halt passes hours of -wall-clock time in zero slots), and its confidence band must be inside -`max_confidence_bps`. For this design the staleness bound is not +Every swap re-validates the feed: the feed account must be owned by the +program the market recorded at creation (`PriceFeedNotFromOracle` otherwise; +the layout alone says nothing about who wrote the bytes), the price must be +positive, at the pinned scale, no older than 150 slots (~1 minute), stamped +after the most recent cluster restart (the `LastRestartSlot` sysvar; a halt +passes hours of wall-clock time in zero slots), and its confidence band must +be inside `max_confidence_bps`. For this design the staleness bound is not hygiene, it is the business: a quote priced off an old number is a free option for whoever notices first. @@ -88,8 +90,9 @@ option for whoever notices first. `initialize_market` creates the `Market` account (PDA of the mint pair) and the two vaults, which the market account itself owns and signs for, and pins -the oracle feed, its scale, a 10 bps spread, and a 1% confidence limit. One market per pair: -the deployment is the firm. +the oracle feed, the program that owns it (`price_feed_program`, read from +the feed account's owner), its scale, a 10 bps spread, and a 1% confidence +limit. One market per pair: the deployment is the firm. ### Step 2: Maria stocks the inventory @@ -143,9 +146,6 @@ misprices. ## Limitations -- The oracle feed's owning program is not verified: the operator picks the - feed, and a bad choice loses the operator's money, not the traders'. A - production reader must still check the account owner. - One flat spread both ways; no inventory skew, no size-dependent pricing. - `paused` is the only circuit breaker; production venues also bound per-slot volume and single-fill size. @@ -160,8 +160,9 @@ cargo test The LiteSVM suite (`programs/prop-amm/tests/test_prop_amm.rs`) verifies the quote math to the minor unit in both directions, the exact round-trip spread, oracle repricing and re-quoting, and that every gate shuts: slippage, -staleness, confidence, pause, zero amounts, inventory bounds, and operator -access control. +staleness, confidence, a feed account owned by another program +(`test_swap_rejects_price_feed_from_another_program`), pause, zero amounts, +inventory bounds, and operator access control. ## FAQ @@ -179,4 +180,4 @@ The spread is the fee: buyers pay the oracle price plus `spread_bps`, sellers re ### What stops the venue from quoting a stale price? -Every `swap` re-validates the feed: the price must be fresh (no older than 150 slots), stamped after the most recent cluster restart, at the pinned scale, and inside the configured confidence band. A stale quote is a free option for whoever notices first, so the staleness checks are the business model, not hygiene. +Every `swap` re-validates the feed: the feed account must be owned by the program the market recorded at creation, and the price must be fresh (no older than 150 slots), stamped after the most recent cluster restart, at the pinned scale, and inside the configured confidence band. A stale quote is a free option for whoever notices first, so the staleness checks are the business model, not hygiene. 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 1f622fc1e..18a8f2528 100644 --- a/finance/prop-amm/anchor/programs/prop-amm/src/errors.rs +++ b/finance/prop-amm/anchor/programs/prop-amm/src/errors.rs @@ -43,4 +43,7 @@ pub enum PropAmmError { #[msg("Oracle price is stale: it predates the last cluster restart")] PricePredatesRestart, + + #[msg("Price feed is not owned by the oracle program the market recorded")] + PriceFeedNotFromOracle, } diff --git a/finance/prop-amm/anchor/programs/prop-amm/src/instructions/initialize_market.rs b/finance/prop-amm/anchor/programs/prop-amm/src/instructions/initialize_market.rs index f385e70d1..5a11b5418 100644 --- a/finance/prop-amm/anchor/programs/prop-amm/src/instructions/initialize_market.rs +++ b/finance/prop-amm/anchor/programs/prop-amm/src/instructions/initialize_market.rs @@ -54,6 +54,7 @@ pub fn handle_initialize_market( market.base_mint = *context.accounts.base_mint.address(); market.quote_mint = *context.accounts.quote_mint.address(); market.oracle_feed = *context.accounts.oracle_feed.address(); + market.price_feed_program = *context.accounts.oracle_feed.account().owner(); market.base_vault = *context.accounts.base_vault.address(); market.quote_vault = *context.accounts.quote_vault.address(); market.oracle_scale = parameters.oracle_scale; @@ -88,9 +89,10 @@ pub struct InitializeMarketAccountConstraints { pub quote_mint: Box>, - /// CHECK: The oracle feed account. Its key is stored on the market and - /// every read validates the layout, scale, and freshness; it is never - /// trusted by type. Swap for a real Pyth price feed in production. + /// CHECK: The oracle feed account. Its key and its owning program are + /// stored on the market, and every read requires that owner and validates + /// the layout, scale, and freshness; it is never trusted by type. Swap for + /// a real Pyth price feed in production. pub oracle_feed: UncheckedAccount, // The market account itself is the token authority of both vaults and diff --git a/finance/prop-amm/anchor/programs/prop-amm/src/instructions/swap.rs b/finance/prop-amm/anchor/programs/prop-amm/src/instructions/swap.rs index c013a5451..bcf170715 100644 --- a/finance/prop-amm/anchor/programs/prop-amm/src/instructions/swap.rs +++ b/finance/prop-amm/anchor/programs/prop-amm/src/instructions/swap.rs @@ -27,11 +27,13 @@ pub fn handle_swap( require!(!market.paused, PropAmmError::MarketPaused); require!(amount_in > 0, PropAmmError::ZeroAmount); - // Freshness, scale, and confidence are all enforced inside the read. For a - // market maker the staleness bound is the business itself: a quote priced - // off an old number is a free option for whoever notices first. + // The feed's owning program, freshness, scale, and confidence are all + // enforced inside the read. For a market maker the staleness bound is the + // business itself: a quote priced off an old number is a free option for + // whoever notices first. let oracle_price = read_oracle_price( &context.accounts.oracle_feed, + &market.price_feed_program, market.oracle_scale, market.max_confidence_bps, )?; 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 d89023ea2..4c8760f26 100644 --- a/finance/prop-amm/anchor/programs/prop-amm/src/lib.rs +++ b/finance/prop-amm/anchor/programs/prop-amm/src/lib.rs @@ -1,7 +1,8 @@ use anchor_lang::prelude::*; mod constants; -mod errors; +// Public so the LiteSVM integration tests can match `PropAmmError` codes. +pub mod errors; mod last_restart; // Public so the LiteSVM integration tests can build instruction arguments // (`MarketParameters`, `Direction`) against the program's own types. diff --git a/finance/prop-amm/anchor/programs/prop-amm/src/state/market.rs b/finance/prop-amm/anchor/programs/prop-amm/src/state/market.rs index a668e77c9..ec92ef2a9 100644 --- a/finance/prop-amm/anchor/programs/prop-amm/src/state/market.rs +++ b/finance/prop-amm/anchor/programs/prop-amm/src/state/market.rs @@ -35,6 +35,12 @@ pub struct Market { /// substituted feed account. pub oracle_feed: Address, + /// The program that owned `oracle_feed` when the market was created. Every + /// price read requires the feed account to still be owned by it, so an + /// account at that address written by any other program is refused as a + /// price. + pub price_feed_program: Address, + pub base_vault: Address, pub quote_vault: Address, diff --git a/finance/prop-amm/anchor/programs/prop-amm/src/state/oracle.rs b/finance/prop-amm/anchor/programs/prop-amm/src/state/oracle.rs index e06cdd454..0eef06028 100644 --- a/finance/prop-amm/anchor/programs/prop-amm/src/state/oracle.rs +++ b/finance/prop-amm/anchor/programs/prop-amm/src/state/oracle.rs @@ -26,12 +26,13 @@ use crate::errors::PropAmmError; // market maker it is existential: quoting a tight spread around a price the // oracle itself is unsure of is how inventory walks out the door. // -// The feed account's owning program is NOT checked here: the market trusts -// whatever feed address its operator configured, which is inside the trust -// model (the operator quotes its own capital against its own oracle choice; a -// bad feed loses the operator's money, not the traders'). A production reader -// must still verify the account owner is the oracle program, which -// the `PriceUpdateV2` account type does. +// The feed account must be owned by the oracle program the market recorded +// at creation (`Market::price_feed_program`, read from the feed's `owner` at +// that moment). The layout above says nothing about who wrote the bytes, so +// without the owner check any account laid out like a feed would be accepted +// as a price. The operator picks the feed, and a bad choice loses the +// operator's capital rather than the traders', so the market trusts the +// program it recorded and refuses a feed account from any other. const PRICE_OFFSET: usize = 8 + 32; const SCALE_OFFSET: usize = PRICE_OFFSET + 16; const LAST_UPDATE_SLOT_OFFSET: usize = SCALE_OFFSET + 4; @@ -41,15 +42,22 @@ const FEED_MINIMUM_LENGTH: usize = CONFIDENCE_OFFSET + 8; /// Read and validate the oracle price from `feed`. /// /// Returns the price as a `u64` in the market's `expected_scale` fixed point. -/// Rejects a stale price (older than `MAX_PRICE_STALENESS_SLOTS`), a -/// non-positive price, a feed whose scale differs from the market's pinned -/// scale, and a price whose confidence band exceeds `max_confidence_bps` of -/// the price. +/// Rejects a feed account owned by any program other than `oracle_program`, +/// a stale price (older than `MAX_PRICE_STALENESS_SLOTS`), a non-positive +/// price, a feed whose scale differs from the market's pinned scale, and a +/// price whose confidence band exceeds `max_confidence_bps` of the price. pub fn read_oracle_price( feed: &AccountView, + oracle_program: &Address, expected_scale: u32, max_confidence_bps: u16, ) -> Result { + require_keys_eq!( + *feed.owner(), + *oracle_program, + PropAmmError::PriceFeedNotFromOracle + ); + let data = feed.try_borrow()?; require!( data.len() >= FEED_MINIMUM_LENGTH, 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 bd5d456f5..29922bb3d 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 @@ -5,6 +5,7 @@ use { }, anchor_v2_testing::{Keypair, LiteSVM, Signer}, prop_amm::{ + errors::PropAmmError, instructions::initialize_market::MarketParameters, state::{Direction, Market as MarketState}, }, @@ -52,6 +53,19 @@ fn dollars(whole: i128) -> i128 { whole * 10i128.pow(ORACLE_SCALE) } +/// Assert that `result` failed with the program's `expected` error. Anchor +/// reports a program error as `Custom(6000 + the variant's index)`. +fn assert_fails_with(result: Result, expected: PropAmmError) { + let code = expected as u32 + 6000; + let Err(error) = result else { + panic!("the transaction should have failed with error code {code}"); + }; + assert!( + error.contains(&format!("Custom({code})")), + "expected error code {code}, got: {error}" + ); +} + /// One deployed market plus the keys needed to drive it. struct Market { svm: LiteSVM, @@ -82,7 +96,7 @@ impl Market { /// Like `new`, but takes the full parameter set and surfaces an /// `initialize_market` rejection instead of panicking, so tests can probe /// the parameter validation. - fn try_new(initial_price: i128, parameters: MarketParameters) -> Result { + fn try_new(initial_price: i128, parameters: MarketParameters) -> Result { let mut svm = anchor_v2_testing::svm(); svm.add_program( prop_amm::id(), @@ -166,7 +180,7 @@ impl Market { &[&operator], &operator.pubkey(), ) - .map_err(|_| ())?; + .map_err(|error| format!("{error:?}"))?; // Fund the operator's inventory accounts. let operator_base = @@ -408,7 +422,7 @@ impl Market { direction: Direction, amount_in: u64, minimum_amount_out: u64, - ) -> Result<(), ()> { + ) -> Result<(), String> { let trader_base = derive_ata(&trader.pubkey(), &self.base_mint); let trader_quote = derive_ata(&trader.pubkey(), &self.quote_mint); let instruction = Instruction::new_with_bytes( @@ -442,7 +456,16 @@ impl Market { &trader.pubkey(), ) .map(|_| ()) - .map_err(|_| ()) + .map_err(|error| format!("{error:?}")) + } + + /// Replace the feed account at the market's recorded feed address with a + /// copy whose owning program is `owner`. The bytes are unchanged, so the + /// copy still decodes as a fresh, confident price at the pinned scale. + fn set_feed_owner(&mut self, owner: Address) { + let mut feed_account = self.svm.get_account(&self.feed).unwrap(); + feed_account.owner = owner; + self.svm.set_account(self.feed, feed_account).unwrap(); } fn balance(&self, token_account: &Address) -> u64 { @@ -682,6 +705,35 @@ fn test_swap_rejects_price_from_before_a_restart() { .expect("a freshly published price must be accepted after a restart"); } +/// The market records the program that owns its feed at creation and refuses +/// a price from a feed account owned by any other program, however well its +/// bytes decode. The feed is swapped for a byte-identical copy owned by an +/// unrelated program, and the refusal is by the owner alone: the same bytes +/// owned by the mock oracle program again are accepted. +#[test] +fn test_swap_rejects_price_feed_from_another_program() { + let mut market = Market::default_market(); + assert_eq!( + market.market_state().price_feed_program, + mock_price_feed::id() + ); + let (alice, _, _) = market.funded_trader(0, 825_825_000); + + market.set_feed_owner(Address::new_unique()); + assert_fails_with( + market.swap(&alice, Direction::BuyBase, 825_825_000, 0), + PropAmmError::PriceFeedNotFromOracle, + ); + + // 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_feed_owner(mock_price_feed::id()); + market + .swap(&alice, Direction::BuyBase, 825_825_000, 0) + .expect("the same feed owned by the recorded oracle program must be accepted"); +} + /// A price the oracle itself is unsure about is rejected: the confidence band /// (about 1.2% here) exceeds the market's 1% limit. #[test] diff --git a/finance/prop-amm/quasar/CHANGELOG.md b/finance/prop-amm/quasar/CHANGELOG.md index b8eef425d..b644ea5bd 100644 --- a/finance/prop-amm/quasar/CHANGELOG.md +++ b/finance/prop-amm/quasar/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 2026-10-04 + +Check which program owns the price feed. `initialize_market` records the +feed account's owning program on the new `Market.price_feed_program`, read +from the account's owner at that moment, beside the feed address and scale it +already pins. `read_oracle_price` now takes the feed account and that program +rather than the feed's bytes, and refuses a feed account owned by any other +with the new `PRICE_FEED_NOT_FROM_ORACLE` (14), before it decodes a byte, so +`swap` no longer accepts any account laid out like a feed as a price. Tested +by `swap_rejects_price_feed_from_another_program`, which rewrites the feed as +a byte-identical copy owned by an unrelated program and then restores the +owner. + ## 2026-09-23 Documentation only: a production feed is now described as a Pyth diff --git a/finance/prop-amm/quasar/README.md b/finance/prop-amm/quasar/README.md index 32d1ce54d..8ab1747a1 100644 --- a/finance/prop-amm/quasar/README.md +++ b/finance/prop-amm/quasar/README.md @@ -24,8 +24,13 @@ Quasar version. of wall-clock time in zero slots). - **Oracle feed in tests.** Rather than a separate mock-oracle program, the tests write the feed account's bytes directly (price, scale, last-update - slot, confidence) and the program reads them the same way it would read a - real oracle feed. + slot, confidence) as a system-owned account, and the program reads them the + same way it would read a real oracle feed. The market records the program + that owns the feed account at creation on `Market.price_feed_program` and + every read refuses a feed account owned by any other program with + `PRICE_FEED_NOT_FROM_ORACLE` (14); here that recorded program is the system + program, and `swap_rejects_price_feed_from_another_program` rewrites the + feed with another owner to show the refusal is on the owner alone. - **State writes** use Quasar's zero-copy field accessors (`field.get()` / `field.set()`) and `set_inner`, rather than Anchor's `Account` mutation. @@ -36,8 +41,8 @@ They build the program, set up both mints, an oracle feed at $165, and an operator with funded inventory, then verify 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, pause, zero amounts, inventory bounds, and operator -access control. +staleness, restart handling, confidence, a feed account owned by another +program, pause, zero amounts, inventory bounds, and operator access control. ```bash quasar build diff --git a/finance/prop-amm/quasar/src/instructions/initialize_market.rs b/finance/prop-amm/quasar/src/instructions/initialize_market.rs index e76a9d2c1..695262b9f 100644 --- a/finance/prop-amm/quasar/src/instructions/initialize_market.rs +++ b/finance/prop-amm/quasar/src/instructions/initialize_market.rs @@ -24,8 +24,9 @@ pub struct InitializeMarket { pub market: Account, pub base_mint: Account, pub quote_mint: Account, - /// CHECK: stored on the market; every read validates layout, scale, - /// freshness, and confidence. + /// CHECK: its key and its owning program are stored on the market; every + /// read requires that owner and validates layout, scale, freshness, and + /// confidence. pub oracle_feed: UncheckedAccount, // The market account itself is the token authority of both vaults and // signs their outgoing transfers with its own seeds, so no separate @@ -81,6 +82,7 @@ pub fn handle_initialize_market( base_mint: *accounts.base_mint.address(), quote_mint: *accounts.quote_mint.address(), oracle_feed: *accounts.oracle_feed.address(), + price_feed_program: *accounts.oracle_feed.to_account_view().owner(), base_vault: *accounts.base_vault.address(), quote_vault: *accounts.quote_vault.address(), oracle_scale, diff --git a/finance/prop-amm/quasar/src/instructions/shared.rs b/finance/prop-amm/quasar/src/instructions/shared.rs index 0d7e0ec4d..ce7b9269a 100644 --- a/finance/prop-amm/quasar/src/instructions/shared.rs +++ b/finance/prop-amm/quasar/src/instructions/shared.rs @@ -24,6 +24,7 @@ pub mod error { pub const INVARIANT_VIOLATED: u32 = 11; pub const INVALID_DIRECTION: u32 = 12; pub const PRICE_PREDATES_RESTART: u32 = 13; + pub const PRICE_FEED_NOT_FROM_ORACLE: u32 = 14; } #[inline(always)] @@ -41,23 +42,39 @@ fn overflow() -> ProgramError { // production it would be a Pyth `PriceUpdateV2` account, which the Pyth // Receiver program writes only after verifying the update's signatures. Like // the Anchor sibling, this validates freshness, positivity, scale, and the -// confidence band; the feed account's owning program is NOT checked — the -// operator picks the oracle, and a bad choice loses the operator's money, not -// the traders'. +// confidence band. +// +// The feed account must be owned by the oracle program the market recorded +// at creation (`Market::price_feed_program`, read from the feed's `owner` at +// that moment). The layout above says nothing about who wrote the bytes, so +// without the owner check any account laid out like a feed would be accepted +// as a price. The operator picks the feed, and a bad choice loses the +// operator's capital rather than the traders', so the market trusts the +// program it recorded and refuses a feed account from any other. const PRICE_OFFSET: usize = 0; const SCALE_OFFSET: usize = PRICE_OFFSET + 16; const LAST_UPDATE_SLOT_OFFSET: usize = SCALE_OFFSET + 4; const CONFIDENCE_OFFSET: usize = LAST_UPDATE_SLOT_OFFSET + 8; const FEED_MINIMUM_LENGTH: usize = CONFIDENCE_OFFSET + 8; -/// Read and validate the oracle price from raw feed bytes. Returns the price -/// as a `u64` in `expected_scale` fixed point. +/// Read and validate the oracle price from the `feed` account. Returns the +/// price as a `u64` in `expected_scale` fixed point. Rejects a feed account +/// owned by any program other than `oracle_program` before decoding a byte, +/// then a stale, non-positive, mis-scaled or wide-confidence price. pub fn read_oracle_price( - data: &[u8], + feed: &AccountView, + oracle_program: &Address, expected_scale: u32, current_slot: u64, max_confidence_bps: u16, ) -> Result { + if feed.owner() != oracle_program { + return Err(err(error::PRICE_FEED_NOT_FROM_ORACLE)); + } + + let data = feed + .try_borrow() + .map_err(|_| err(error::ORACLE_DATA_TOO_SHORT))?; if data.len() < FEED_MINIMUM_LENGTH { return Err(err(error::ORACLE_DATA_TOO_SHORT)); } diff --git a/finance/prop-amm/quasar/src/instructions/swap.rs b/finance/prop-amm/quasar/src/instructions/swap.rs index 1ac54abe3..edb8d1050 100644 --- a/finance/prop-amm/quasar/src/instructions/swap.rs +++ b/finance/prop-amm/quasar/src/instructions/swap.rs @@ -64,15 +64,16 @@ pub fn handle_swap( let quote_decimals = market.quote_decimals; let spread_bps = market.spread_bps.get(); - // Freshness, scale, and confidence are all enforced inside the read. + // The feed's owning program, freshness, scale, and confidence are all + // enforced inside the read. let slot = accounts.clock.slot.get(); - let oracle_price = { - let view = accounts.oracle_feed.to_account_view(); - let data = view - .try_borrow() - .map_err(|_| err(error::ORACLE_DATA_TOO_SHORT))?; - shared::read_oracle_price(&data, oracle_scale, slot, market.max_confidence_bps.get())? - }; + let oracle_price = shared::read_oracle_price( + accounts.oracle_feed.to_account_view(), + &market.price_feed_program, + oracle_scale, + slot, + market.max_confidence_bps.get(), + )?; let (amount_out, respects_oracle_value) = match direction { DIRECTION_BUY_BASE => { diff --git a/finance/prop-amm/quasar/src/state.rs b/finance/prop-amm/quasar/src/state.rs index 5e72e6c98..a6a50c752 100644 --- a/finance/prop-amm/quasar/src/state.rs +++ b/finance/prop-amm/quasar/src/state.rs @@ -18,6 +18,11 @@ pub struct Market { pub base_mint: Address, pub quote_mint: Address, pub oracle_feed: Address, + /// The program that owned `oracle_feed` when the market was created. Every + /// price read requires the feed account to still be owned by it, so an + /// account at that address written by any other program is refused as a + /// price. + pub price_feed_program: Address, pub base_vault: Address, pub quote_vault: Address, /// Decimal places the oracle price is quoted in, pinned at creation. diff --git a/finance/prop-amm/quasar/src/tests.rs b/finance/prop-amm/quasar/src/tests.rs index 72c41f131..b7d550bcd 100644 --- a/finance/prop-amm/quasar/src/tests.rs +++ b/finance/prop-amm/quasar/src/tests.rs @@ -8,6 +8,7 @@ use { DepositInventoryInstruction, InitializeMarketInstruction, SetQuoteInstruction, SwapInstruction, WithdrawInventoryInstruction, }, + instructions::shared::error, state::Market, BaseVaultPda, QuoteVaultPda, }, @@ -45,6 +46,8 @@ const TRADER_QUOTE: Pubkey = Pubkey::new_from_array([9; 32]); const MALLORY: Pubkey = Pubkey::new_from_array([10; 32]); const MALLORY_BASE: Pubkey = Pubkey::new_from_array([11; 32]); const MALLORY_QUOTE: Pubkey = Pubkey::new_from_array([12; 32]); +// A program that is not the one the market recorded as its feed's owner. +const OTHER_PROGRAM: Pubkey = Pubkey::new_from_array([13; 32]); fn dollars(whole: i128) -> i128 { whole * 10i128.pow(ORACLE_SCALE) @@ -70,15 +73,23 @@ fn set_clock(test: &mut Test) { } /// A feed account in this program's layout: price (i128), scale (u32), -/// last_update_slot (u64), confidence (u64). The tests own this; production -/// reads a real feed. +/// last_update_slot (u64), confidence (u64), owned by the system program, +/// which the market therefore records as the feed's owning program. The tests +/// own this; production reads a real feed. fn set_feed_at_slot(test: &mut Test, price: i128, slot: u64, confidence: u64) { + set_feed_owned_by(test, system_program::ID, price, slot, confidence); +} + +/// Write the feed account at `FEED` with `owner` as its owning program. The +/// bytes are the same whoever owns it, so a copy owned by another program +/// still decodes as a fresh, confident price at the pinned scale. +fn set_feed_owned_by(test: &mut Test, owner: Pubkey, price: i128, slot: u64, confidence: u64) { let mut data = Vec::with_capacity(36); data.extend_from_slice(&price.to_le_bytes()); data.extend_from_slice(&ORACLE_SCALE.to_le_bytes()); data.extend_from_slice(&slot.to_le_bytes()); data.extend_from_slice(&confidence.to_le_bytes()); - test.set_account(Account::new(FEED, system_program::ID, 1_000_000, data)); + test.set_account(Account::new(FEED, owner, 1_000_000, data)); } fn set_feed(test: &mut Test, price: i128, confidence: u64) { @@ -617,6 +628,47 @@ fn swap_rejects_price_from_before_a_restart(test: &mut Test) { .succeeds(); } +/// The market records the program that owns its feed at creation and refuses +/// a price from a feed account owned by any other program, however well its +/// bytes decode. The feed is swapped for a byte-identical copy owned by an +/// unrelated program, and the refusal is by the owner alone: the same bytes +/// owned by the recorded program again are accepted. +#[quasar_test] +fn swap_rejects_price_feed_from_another_program(test: &mut Test) { + let env = setup(test); + assert_eq!( + test.read::(env.market).price_feed_program, + system_program::ID + ); + fund_trader(test, TRADER, TRADER_BASE, TRADER_QUOTE, 0, 825_825_000); + + set_feed_owned_by(test, OTHER_PROGRAM, dollars(165), SLOT, 0); + swap( + test, + &env, + TRADER, + TRADER_BASE, + TRADER_QUOTE, + DIRECTION_BUY_BASE, + 825_825_000, + 0, + ) + .fails_with(error::PRICE_FEED_NOT_FROM_ORACLE); + + set_feed(test, dollars(165), 0); + swap( + test, + &env, + TRADER, + TRADER_BASE, + TRADER_QUOTE, + DIRECTION_BUY_BASE, + 825_825_000, + 0, + ) + .succeeds(); +} + /// A price the oracle itself is unsure about is rejected: the confidence band /// (about 1.2% here) exceeds the market's 1% limit. #[quasar_test]