From 71d8e52763808884faea0b3cbd4d8c17e64cbebc Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Mon, 5 Oct 2026 03:16:25 +0000 Subject: [PATCH] Fees round against the user, real tokens carry real decimals, refusals assert codes Three rules from the book's second pre-print audit, applied to every copy (Anchor 1, Anchor 2, Quasar, native where present) of nine programs. Every fee rounds up, against the user, and the user's side takes the remainder: the AMM's swap fee and the admin's share of it, the options venue's premium split, the perpetual futures venue's open, close and liquidation fees and its maintenance requirement (only the insurance fund's cut of a collected fee still rounds down, through the new basis_points_of_rounded_down), the betting market's fee on the losing pool, the lending market's program fee on accrued interest, and the managed fund's fee shares. Each program gains a test that shows the rounding, and the Kani harnesses that model a fee prove the new direction. NVDAx and TSLAx are minted at eight decimals in every suite that names them (escrow, prop AMM, options, managed fund, the Quasar order book), with USDC at six; the walkthrough amounts the book quotes stay exact. Every refusal test asserts the error it expects instead of is_err(), including the Anchor constraint codes, and the betting market and order book gain tests for a non-admin settle or cancel and a non-authority fee withdrawal. The AMM refuses a first deposit whose square root is at or below MINIMUM_LIQUIDITY, so the smallest pool leaves its creator one minor unit of LP; the perps close-fee test runs the at and accepted cases. READMEs and CHANGELOGs follow. Verified by building each copy with platform-tools v1.53 (v1.52 for Quasar) and running every suite and Kani crate. Claude-Session: https://claude.ai/code/session_01UX53A6YR1Hjr8z6WzJxf2q --- Cargo.lock | 1 + finance/betting-market/anchor-v1/CHANGELOG.md | 19 + finance/betting-market/anchor-v1/README.md | 22 +- .../src/instructions/settle_event.rs | 7 +- .../tests/test_betting_market.rs | 77 +++- finance/betting-market/anchor/CHANGELOG.md | 19 + finance/betting-market/anchor/README.md | 22 +- .../src/instructions/settle_event.rs | 7 +- .../tests/test_betting_market.rs | 77 +++- finance/betting-market/kani-proofs/src/lib.rs | 33 +- finance/betting-market/quasar/CHANGELOG.md | 20 + finance/betting-market/quasar/README.md | 22 +- .../quasar/src/instructions/settle_event.rs | 8 +- finance/betting-market/quasar/src/tests.rs | 62 ++- finance/escrow/anchor-v1/CHANGELOG.md | 4 + finance/escrow/anchor-v1/README.md | 4 +- .../programs/escrow/tests/test_escrow.rs | 99 ++-- finance/escrow/anchor/CHANGELOG.md | 4 + finance/escrow/anchor/README.md | 4 +- .../programs/escrow/tests/test_escrow.rs | 108 +++-- finance/escrow/native/CHANGELOG.md | 4 + finance/escrow/native/README.md | 4 +- finance/escrow/native/program/tests/test.rs | 146 +++--- finance/escrow/quasar/CHANGELOG.md | 11 + finance/escrow/quasar/README.md | 5 +- finance/escrow/quasar/src/tests.rs | 209 ++++----- finance/lending/anchor-v1/CHANGELOG.md | 19 + finance/lending/anchor-v1/README.md | 17 +- .../programs/lending/src/state/reserve.rs | 8 +- .../programs/lending/tests/test_interest.rs | 60 ++- .../programs/lending/tests/test_security.rs | 4 +- finance/lending/anchor/CHANGELOG.md | 19 + finance/lending/anchor/README.md | 17 +- .../programs/lending/src/state/reserve.rs | 8 +- .../programs/lending/tests/test_interest.rs | 60 ++- .../programs/lending/tests/test_security.rs | 16 +- finance/lending/kani-proofs/README.md | 2 + finance/lending/kani-proofs/src/lib.rs | 48 +- finance/lending/quasar/CHANGELOG.md | 23 + finance/lending/quasar/README.md | 12 +- finance/lending/quasar/src/logic.rs | 9 +- finance/lending/quasar/src/tests.rs | 62 ++- finance/managed-fund/anchor-v1/CHANGELOG.md | 8 + finance/managed-fund/anchor-v1/README.md | 8 +- .../src/instructions/collect_fees.rs | 11 +- .../managed-fund/tests/managed_fund.rs | 332 +++++++++----- finance/managed-fund/anchor/CHANGELOG.md | 8 + finance/managed-fund/anchor/README.md | 8 +- .../src/instructions/collect_fees.rs | 11 +- .../managed-fund/tests/managed_fund.rs | 337 +++++++++----- finance/managed-fund/kani-proofs/README.md | 2 +- finance/managed-fund/kani-proofs/src/lib.rs | 77 +++- finance/managed-fund/quasar/CHANGELOG.md | 18 + finance/managed-fund/quasar/README.md | 20 +- .../src/instructions/collect_fees.rs | 9 +- .../quasar/managed-fund/src/tests.rs | 146 ++++-- finance/options/anchor-v1/CHANGELOG.md | 15 + finance/options/anchor-v1/README.md | 21 +- .../programs/options/src/contract_math.rs | 19 +- .../options/src/instructions/write_option.rs | 2 +- .../options/src/state/option_contract.rs | 2 +- .../programs/options/tests/test_options.rs | 68 ++- finance/options/anchor/CHANGELOG.md | 15 + finance/options/anchor/README.md | 23 +- .../programs/options/src/contract_math.rs | 18 +- .../options/src/instructions/write_option.rs | 2 +- .../options/src/state/option_contract.rs | 2 +- .../programs/options/tests/test_options.rs | 68 ++- finance/options/kani-proofs/README.md | 16 +- finance/options/kani-proofs/src/lib.rs | 120 ++--- finance/options/quasar/CHANGELOG.md | 15 + finance/options/quasar/README.md | 9 +- .../options/quasar/src/instructions/shared.rs | 15 +- finance/options/quasar/src/tests.rs | 67 ++- finance/order-book/anchor-v1/CHANGELOG.md | 25 + finance/order-book/anchor-v1/README.md | 23 +- .../order-book/tests/test_order_book.rs | 197 ++++++-- finance/order-book/anchor/CHANGELOG.md | 25 + finance/order-book/anchor/README.md | 23 +- .../order-book/tests/test_order_book.rs | 197 ++++++-- finance/order-book/quasar/CHANGELOG.md | 9 + finance/order-book/quasar/README.md | 2 +- finance/order-book/quasar/src/tests.rs | 19 +- .../perpetual-futures/anchor-v1/CHANGELOG.md | 31 ++ finance/perpetual-futures/anchor-v1/README.md | 13 +- .../src/instructions/liquidate_position.rs | 3 +- .../src/instructions/shared.rs | 26 +- .../tests/test_perpetual_futures.rs | 224 +++++++-- finance/perpetual-futures/anchor/CHANGELOG.md | 31 ++ finance/perpetual-futures/anchor/README.md | 13 +- .../src/instructions/liquidate_position.rs | 3 +- .../src/instructions/shared.rs | 26 +- .../tests/test_perpetual_futures.rs | 223 +++++++-- finance/perpetual-futures/quasar/CHANGELOG.md | 23 + finance/perpetual-futures/quasar/README.md | 8 +- .../src/instructions/liquidate_position.rs | 3 + .../quasar/src/instructions/shared.rs | 24 +- finance/perpetual-futures/quasar/src/tests.rs | 102 ++++- finance/prop-amm/anchor-v1/CHANGELOG.md | 19 + finance/prop-amm/anchor-v1/README.md | 30 +- .../programs/prop-amm/tests/test_prop_amm.rs | 239 ++++++---- finance/prop-amm/anchor/CHANGELOG.md | 19 + finance/prop-amm/anchor/README.md | 30 +- .../programs/prop-amm/tests/test_prop_amm.rs | 242 ++++++---- finance/prop-amm/kani-proofs/README.md | 2 +- finance/prop-amm/kani-proofs/src/lib.rs | 43 +- finance/prop-amm/quasar/CHANGELOG.md | 17 + finance/prop-amm/quasar/README.md | 16 +- finance/prop-amm/quasar/src/tests.rs | 432 ++++++++++-------- finance/token-swap/README.md | 10 +- finance/token-swap/anchor-v1/CHANGELOG.md | 20 + .../programs/token-swap/src/errors.rs | 8 +- .../src/instructions/swap_tokens.rs | 23 +- .../programs/token-swap/src/liquidity.rs | 6 +- .../programs/token-swap/tests/test_swap.rs | 94 +++- finance/token-swap/anchor/CHANGELOG.md | 20 + .../anchor/programs/token-swap/src/errors.rs | 8 +- .../src/instructions/swap_tokens.rs | 23 +- .../programs/token-swap/src/liquidity.rs | 6 +- .../programs/token-swap/tests/test_swap.rs | 95 +++- finance/token-swap/kani-proofs/README.md | 5 +- finance/token-swap/kani-proofs/src/lib.rs | 48 +- finance/token-swap/quasar/CHANGELOG.md | 25 + finance/token-swap/quasar/src/error.rs | 8 +- .../quasar/src/instructions/swap_tokens.rs | 13 +- finance/token-swap/quasar/src/liquidity.rs | 6 +- finance/token-swap/quasar/src/tests.rs | 131 +++++- 127 files changed, 4228 insertions(+), 1557 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ac364fe3..da4bc3c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2914,6 +2914,7 @@ dependencies = [ "anchor-spl", "anchor-v2-testing", "borsh 1.7.0", + "litesvm", "solana-address 2.6.1", "solana-clock 3.1.1", "solana-kite", diff --git a/finance/betting-market/anchor-v1/CHANGELOG.md b/finance/betting-market/anchor-v1/CHANGELOG.md index ce6e1866..0939b0b1 100644 --- a/finance/betting-market/anchor-v1/CHANGELOG.md +++ b/finance/betting-market/anchor-v1/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [Unreleased] - 2026-10-05 + +### Changed + +- `settle_event` rounds the fee up: `fee = ceil(losing_pool * fee_bps / 10_000)`, + and `distributable_losing_pool` is the losing pool minus that fee, so the + rounding on the fee goes the program's way as the rounding on each payout + does. A losing pool that is an exact multiple of the fee rate pays the same + fee as before; any other pays one minor unit more. The Kani harness + `proof_settlement_fee_and_split` proves the fee is that ceiling. +- `test_double_claim_fails` asserts the error the second claim hits: Anchor's + own `AccountNotInitialized` (3012), since the first claim closed the Bet account. + +### Added + +- `test_only_admin_can_settle_or_cancel_event`: `settle_event` and + `cancel_event` from a non-admin both fail with `Unauthorized`, the event stays + `Open`, and the admin then settles it. + ## [Unreleased] - 2026-10-04 ### Added diff --git a/finance/betting-market/anchor-v1/README.md b/finance/betting-market/anchor-v1/README.md index 146e7531..1c3b1050 100644 --- a/finance/betting-market/anchor-v1/README.md +++ b/finance/betting-market/anchor-v1/README.md @@ -71,8 +71,8 @@ When an event settles to a winning outcome: ``` losing_pool = total_pool - winning_pool -fee = losing_pool * fee_bps / 10000 // charged only on the losing side -distributable_losing = losing_pool - fee +fee = ceil(losing_pool * fee_bps / 10000) // charged only on the losing side, rounded up +distributable_losing = losing_pool - fee // what the winners share ``` Each winning bet then claims: @@ -81,13 +81,16 @@ Each winning bet then claims: 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, which -`close_event` pays to the fee recipient once every bet is closed. +A winner always gets their own stake back; the fee is only ever taken from losing stakes. Every +rounding goes the program's way: the fee is the ceiling of its fraction of the losing pool, and +each winner's share is floored, so the payouts and the fee together never exceed what the vault +holds. The flooring leaves 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 -`40 + 40 * 49 / 100 = 59`. +`40 + 40 * 49 / 100 = 59`. Had the losing pool been 55, the fee would be `ceil(1.1) = 2` and +`distributable_losing = 53`. ### Instruction handlers @@ -164,8 +167,11 @@ anchor build Tests are Rust integration tests running against [LiteSVM](https://www.anchor-lang.com/docs/testing/litesvm) with [solana-kite](https://crates.io/crates/solana-kite) helpers. They cover the full lifecycle (bet → -settle → claim with exact payout and fee assertions), admin authorization, the bet-after-settle and -double-claim guards, the outcome list locking when betting opens, the two-outcome minimum, both +settle → claim with exact payout and fee assertions), admin authorization of every admin handler +(`test_only_admin_can_settle_or_cancel_event` covers settling and cancelling), the bet-after-settle +guard, the double-claim guard (the first claim closes the Bet account, so Anchor refuses the second +while loading the accounts, and the test asserts that error), the outcome list locking when betting +opens, the two-outcome minimum, both edges of the betting close time, settling an outcome with no bets, the cancel/refund path, the `close_losing_bet` guards, and a wallet holding forty open bets at once, which shows there is no per-wallet cap. diff --git a/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/settle_event.rs b/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/settle_event.rs index 154fd09b..67ca7587 100644 --- a/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/settle_event.rs +++ b/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/settle_event.rs @@ -93,8 +93,11 @@ pub fn handle_settle_event( // Winners always get their own stake back; the fee is only ever charged on // the losing side, so a winner can never receive less than they staked. - let fee = - (losing_pool as u128 * context.accounts.event.fee_bps as u128 / BPS_DENOMINATOR) as u64; + // The fee rounds up, in the program's favor: fee = ceil(losing_pool * + // fee_bps / 10_000), and the winners share what the fee leaves. fee_bps + // never exceeds 10_000, so the fee never exceeds the losing pool. + let fee = (losing_pool as u128 * context.accounts.event.fee_bps as u128) + .div_ceil(BPS_DENOMINATOR) as u64; let distributable_losing_pool = losing_pool - fee; if fee > 0 { 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 75ce2f6a..b7273dc9 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 @@ -6,7 +6,7 @@ use { }, AccountDeserialize, InstructionData, ToAccountMetas, }, - betting_market::{error::BettingError, Bet, Event}, + betting_market::{error::BettingError, Bet, Event, EventStatus}, litesvm::LiteSVM, solana_keypair::Keypair, solana_kite::{ @@ -116,7 +116,20 @@ fn warp_to(svm: &mut LiteSVM, unix_timestamp: i64) { // failed transaction reports the number as `Custom(n)`. Matching it proves the // transaction failed for the rule under test, not for some unrelated reason. fn assert_fails_with(result: Result<(), SolanaKiteError>, expected: BettingError) { - let code = 6000 + expected as u32; + assert_fails_with_code(result, 6000 + expected as u32); +} + +// Anchor's own errors (account checks that fail before any handler runs) are +// numbered from the framework's `ErrorCode`, below 6000, and reported the same +// way. +fn assert_fails_with_anchor_error( + result: Result<(), SolanaKiteError>, + expected: anchor_lang::error::ErrorCode, +) { + assert_fails_with_code(result, expected as u32); +} + +fn assert_fails_with_code(result: Result<(), SolanaKiteError>, code: u32) { let error = format!("{:?}", result.expect_err("transaction should have failed")); assert!( error.contains(&format!("Custom({code})")), @@ -773,6 +786,60 @@ fn test_only_admin_can_initialize_event() { assert_fails_with(unauthorized_open, BettingError::Unauthorized); } +// Ending a market is the admin's alone: a stranger can neither settle it to +// an outcome nor cancel it, and the event stays open for the admin to settle. +#[test] +fn test_only_admin_can_settle_or_cancel_event() { + 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, "Will it rain on Sunday?"); + place_bet(&mut market, &alice, &alice_ata, event_id, 0, 100); + place_bet(&mut market, &carol, &carol_ata, event_id, 1, 200); + + let mallory = create_wallet(&mut market.svm, 10_000_000_000).unwrap(); + let unauthorized_cancel = send_transaction_from_instructions( + &mut market.svm, + vec![cancel_event_ix(mallory.pubkey(), event_id)], + &[&mallory], + &mallory.pubkey(), + ); + assert_fails_with(unauthorized_cancel, BettingError::Unauthorized); + + // Betting has closed, so the only thing stopping Mallory is the signer. + warp_to(&mut market.svm, BETTING_CLOSES_AT); + let unauthorized_settle = send_transaction_from_instructions( + &mut market.svm, + vec![settle_event_ix( + mallory.pubkey(), + mint, + fee_recipient, + fee_recipient_ata, + event_id, + 0, + )], + &[&mallory], + &mallory.pubkey(), + ); + assert_fails_with(unauthorized_settle, BettingError::Unauthorized); + assert!(read_event(&market, event_id).status == EventStatus::Open); + assert_eq!( + get_token_account_balance(&market.svm, &fee_recipient_ata).unwrap(), + 0 + ); + + // The admin settles the same event, so the refusals were the signer's. + settle(&mut market, event_id, 0); + assert!(read_event(&market, event_id).status == EventStatus::Settled); +} + #[test] fn test_cannot_bet_after_settle() { let mut market = setup(); @@ -940,7 +1007,11 @@ fn test_double_claim_fails() { &[&alice], &alice.pubkey(), ); - assert!(second.is_err(), "claiming the same bet twice must fail"); + // The first claim closed the Bet account (`close = bettor`), so the second + // transaction passes an address with no lamports and no data. Anchor + // refuses it while loading the accounts, before the handler runs, with + // its own `AccountNotInitialized` (3012) rather than a `BettingError`. + assert_fails_with_anchor_error(second, anchor_lang::error::ErrorCode::AccountNotInitialized); } #[test] diff --git a/finance/betting-market/anchor/CHANGELOG.md b/finance/betting-market/anchor/CHANGELOG.md index b7338eed..6048a531 100644 --- a/finance/betting-market/anchor/CHANGELOG.md +++ b/finance/betting-market/anchor/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [Unreleased] - 2026-10-05 + +### Changed + +- `settle_event` rounds the fee up: `fee = ceil(losing_pool * fee_bps / 10_000)`, + and `distributable_losing_pool` is the losing pool minus that fee, so the + rounding on the fee goes the program's way as the rounding on each payout + does. A losing pool that is an exact multiple of the fee rate pays the same + fee as before; any other pays one minor unit more. The Kani harness + `proof_settlement_fee_and_split` proves the fee is that ceiling. +- `test_double_claim_fails` asserts the error the second claim hits: Anchor's + own account check, reported as the runtime's `UninitializedAccount`, since the first claim closed the Bet account. + +### Added + +- `test_only_admin_can_settle_or_cancel_event`: `settle_event` and + `cancel_event` from a non-admin both fail with `Unauthorized`, the event stays + `Open`, and the admin then settles it. + ## [Unreleased] - 2026-10-04 ### Added diff --git a/finance/betting-market/anchor/README.md b/finance/betting-market/anchor/README.md index ae03d4df..b880f3ce 100644 --- a/finance/betting-market/anchor/README.md +++ b/finance/betting-market/anchor/README.md @@ -71,8 +71,8 @@ When an event settles to a winning outcome: ``` losing_pool = total_pool - winning_pool -fee = losing_pool * fee_bps / 10000 // charged only on the losing side -distributable_losing = losing_pool - fee +fee = ceil(losing_pool * fee_bps / 10000) // charged only on the losing side, rounded up +distributable_losing = losing_pool - fee // what the winners share ``` Each winning bet then claims: @@ -81,13 +81,16 @@ Each winning bet then claims: 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, which -`close_event` pays to the fee recipient once every bet is closed. +A winner always gets their own stake back; the fee is only ever taken from losing stakes. Every +rounding goes the program's way: the fee is the ceiling of its fraction of the losing pool, and +each winner's share is floored, so the payouts and the fee together never exceed what the vault +holds. The flooring leaves 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 -`40 + 40 * 49 / 100 = 59`. +`40 + 40 * 49 / 100 = 59`. Had the losing pool been 55, the fee would be `ceil(1.1) = 2` and +`distributable_losing = 53`. ### Instruction handlers @@ -164,8 +167,11 @@ anchor build Tests are Rust integration tests running against [LiteSVM](https://www.anchor-lang.com/docs/testing/litesvm) with [solana-kite](https://crates.io/crates/solana-kite) helpers. They cover the full lifecycle (bet → -settle → claim with exact payout and fee assertions), admin authorization, the bet-after-settle and -double-claim guards, the outcome list locking when betting opens, the two-outcome minimum, both +settle → claim with exact payout and fee assertions), admin authorization of every admin handler +(`test_only_admin_can_settle_or_cancel_event` covers settling and cancelling), the bet-after-settle +guard, the double-claim guard (the first claim closes the Bet account, so Anchor refuses the second +while loading the accounts, and the test asserts that error), the outcome list locking when betting +opens, the two-outcome minimum, both edges of the betting close time, settling an outcome with no bets, the cancel/refund path, the `close_losing_bet` guards, and a wallet holding forty open bets at once, which shows there is no per-wallet cap. diff --git a/finance/betting-market/anchor/programs/betting-market/src/instructions/settle_event.rs b/finance/betting-market/anchor/programs/betting-market/src/instructions/settle_event.rs index 76589801..9da75ea5 100644 --- a/finance/betting-market/anchor/programs/betting-market/src/instructions/settle_event.rs +++ b/finance/betting-market/anchor/programs/betting-market/src/instructions/settle_event.rs @@ -95,8 +95,11 @@ pub fn handle_settle_event( // Winners always get their own stake back; the fee is only ever charged on // the losing side, so a winner can never receive less than they staked. - let fee = - (losing_pool as u128 * context.accounts.event.fee_bps as u128 / BPS_DENOMINATOR) as u64; + // The fee rounds up, in the program's favor: fee = ceil(losing_pool * + // fee_bps / 10_000), and the winners share what the fee leaves. fee_bps + // never exceeds 10_000, so the fee never exceeds the losing pool. + let fee = (losing_pool as u128 * context.accounts.event.fee_bps as u128) + .div_ceil(BPS_DENOMINATOR) as u64; let distributable_losing_pool = losing_pool - fee; if fee > 0 { 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 5ce76822..9eb5a972 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 @@ -6,7 +6,11 @@ use { anchor_v2_testing::{Keypair, LiteSVM, Signer}, // `Event` through `state`: the prelude's `Event` trait shadows the glob // re-export at the crate root. - betting_market::{error::BettingError, state::Event, Bet}, + betting_market::{ + error::BettingError, + state::{Event, EventStatus}, + Bet, + }, // LiteSVM's get_sysvar / set_sysvar want the host-side Clock. solana_clock::Clock, solana_kite::{ @@ -117,10 +121,16 @@ fn warp_to(svm: &mut LiteSVM, unix_timestamp: i64) { // transaction failed for the rule under test, not for some unrelated reason. fn assert_fails_with(result: Result<(), SolanaKiteError>, expected: BettingError) { let code = 6000 + expected as u32; + assert_fails_with_text(result, &format!("Custom({code})")); +} + +// Anchor 2 reports a failed account check as a plain `ProgramError` rather +// than a numbered code, so it is matched by its Debug text. +fn assert_fails_with_text(result: Result<(), SolanaKiteError>, expected: &str) { let error = format!("{:?}", result.expect_err("transaction should have failed")); assert!( - error.contains(&format!("Custom({code})")), - "expected error {code}, got: {error}" + error.contains(expected), + "expected error {expected}, got: {error}" ); } @@ -773,6 +783,60 @@ fn test_only_admin_can_initialize_event() { assert_fails_with(unauthorized_open, BettingError::Unauthorized); } +// Ending a market is the admin's alone: a stranger can neither settle it to +// an outcome nor cancel it, and the event stays open for the admin to settle. +#[test] +fn test_only_admin_can_settle_or_cancel_event() { + 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, "Will it rain on Sunday?"); + place_bet(&mut market, &alice, &alice_ata, event_id, 0, 100); + place_bet(&mut market, &carol, &carol_ata, event_id, 1, 200); + + let mallory = create_wallet(&mut market.svm, 10_000_000_000).unwrap(); + let unauthorized_cancel = send_transaction_from_instructions( + &mut market.svm, + vec![cancel_event_ix(mallory.pubkey(), event_id)], + &[&mallory], + &mallory.pubkey(), + ); + assert_fails_with(unauthorized_cancel, BettingError::Unauthorized); + + // Betting has closed, so the only thing stopping Mallory is the signer. + warp_to(&mut market.svm, BETTING_CLOSES_AT); + let unauthorized_settle = send_transaction_from_instructions( + &mut market.svm, + vec![settle_event_ix( + mallory.pubkey(), + mint, + fee_recipient, + fee_recipient_ata, + event_id, + 0, + )], + &[&mallory], + &mallory.pubkey(), + ); + assert_fails_with(unauthorized_settle, BettingError::Unauthorized); + assert!(read_event(&market, event_id).status == EventStatus::Open); + assert_eq!( + get_token_account_balance(&market.svm, &fee_recipient_ata).unwrap(), + 0 + ); + + // The admin settles the same event, so the refusals were the signer's. + settle(&mut market, event_id, 0); + assert!(read_event(&market, event_id).status == EventStatus::Settled); +} + #[test] fn test_cannot_bet_after_settle() { let mut market = setup(); @@ -940,7 +1004,12 @@ fn test_double_claim_fails() { &[&alice], &alice.pubkey(), ); - assert!(second.is_err(), "claiming the same bet twice must fail"); + // The first claim closed the Bet account (`close = bettor`), so the second + // transaction passes an address with no lamports and no data. Anchor + // refuses it while loading the accounts, before the handler runs: an + // address with no lamports that the system program owns is reported as + // the runtime's `UninitializedAccount` rather than a `BettingError`. + assert_fails_with_text(second, "UninitializedAccount"); } #[test] diff --git a/finance/betting-market/kani-proofs/src/lib.rs b/finance/betting-market/kani-proofs/src/lib.rs index ac9fc288..6155222e 100644 --- a/finance/betting-market/kani-proofs/src/lib.rs +++ b/finance/betting-market/kani-proofs/src/lib.rs @@ -26,12 +26,14 @@ pub const BPS_DENOMINATOR: u128 = 10_000; /// Settlement math from `handle_settle_event`: split the pool into the losing -/// side, the fee (charged only on losers), and the distributable remainder. -/// Returns `(losing_pool, fee, distributable_losing_pool)`. `None` on the -/// underflow/overflow paths. +/// side, the fee (charged only on losers, and rounded up: +/// `ceil(losing_pool * fee_bps / 10_000)`), and the distributable remainder +/// the winners share. Returns `(losing_pool, fee, distributable_losing_pool)`. +/// `None` on the underflow/overflow paths. pub fn settle(total_pool: u64, winning_pool: u64, fee_bps: u16) -> Option<(u64, u64, u64)> { let losing_pool = total_pool.checked_sub(winning_pool)?; - let fee: u64 = ((losing_pool as u128) * (fee_bps as u128) / BPS_DENOMINATOR) + let fee: u64 = ((losing_pool as u128) * (fee_bps as u128)) + .div_ceil(BPS_DENOMINATOR) .try_into() .ok()?; let distributable = losing_pool.checked_sub(fee)?; @@ -56,8 +58,9 @@ pub fn winnings(stake: u64, distributable: u64, winning_pool: u64) -> Option= exact); + assert!((fee as u128) * BPS_DENOMINATOR < exact + BPS_DENOMINATOR); // Conservation: nothing created or destroyed by settlement. assert_eq!( winning_pool as u128 + distributable as u128 + fee as u128, @@ -340,6 +348,19 @@ mod tests { assert_eq!((losing, fee, dist), (600, 12, 588)); } + #[test] + fn settle_fee_rounds_up() { + // total 550, winning 300, 1% fee on the 250 losing pool: 2.5 rounds up + // to 3, and the winners share 247. + assert_eq!(settle(550, 300, 100).unwrap(), (250, 3, 247)); + // 2% of 201 is 4.02: the fee is 5, never 4. + assert_eq!(settle(201, 0, 200).unwrap(), (201, 5, 196)); + // A fee of 100% takes the whole losing pool and no more. + assert_eq!(settle(201, 1, 10_000).unwrap(), (200, 200, 0)); + // No fee: nothing to round. + assert_eq!(settle(201, 1, 0).unwrap(), (200, 0, 200)); + } + #[test] fn winnings_pro_rata() { // stake 100 of a 400 winning pool, distributable 588 -> floor(100*588/400)=147. diff --git a/finance/betting-market/quasar/CHANGELOG.md b/finance/betting-market/quasar/CHANGELOG.md index 21e8282b..d59ec3d9 100644 --- a/finance/betting-market/quasar/CHANGELOG.md +++ b/finance/betting-market/quasar/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## [Unreleased] - 2026-10-05 + +### Changed + +- `settle_event` rounds the fee up: `fee = ceil(losing_pool * fee_bps / 10_000)`, + and `distributable_losing_pool` is the losing pool minus that fee, so the + rounding on the fee goes the program's way as the rounding on each payout + does. A losing pool that is an exact multiple of the fee rate pays the same + fee as before; any other pays one minor unit more. In + `close_event_pays_dust_to_fee_recipient_and_returns_rent` the 1% fee on the + 250 losing pool is 3, the winners share 247 (82 and 164), and the fee + recipient ends with 4 after the dust. The Kani harness + `proof_settlement_fee_and_split` proves the fee is that ceiling. + +### Added + +- `settle_and_cancel_reject_a_non_admin_signer`: `settle_event` and + `cancel_event` from a non-admin both fail with `Unauthorized`, the event stays + `Open`, and the admin then settles it. + ## [Unreleased] - 2026-10-04 ### Added diff --git a/finance/betting-market/quasar/README.md b/finance/betting-market/quasar/README.md index 6437579e..0ed8706b 100644 --- a/finance/betting-market/quasar/README.md +++ b/finance/betting-market/quasar/README.md @@ -32,8 +32,9 @@ result is known the winners divide the pool. betting_closes_at` and settlement only once `now >= betting_closes_at`, so nobody can stake after the result could be known. The program fee is charged only on the losing pool, so a winner can never - receive less than they staked. The fee moves to the fee recipient immediately; - the figures winners need are recorded on the event. + receive less than they staked, and it rounds up: + `ceil(losing_pool * fee_bps / 10_000)`. The fee moves to the fee recipient + immediately; the figures winners need are recorded on the event. - A winner calls `claim_winnings` to withdraw their stake plus their share of the losing pool (their stake divided by the total winning stake, times the distributable losing pool). A loser calls `close_losing_bet` to reclaim their @@ -114,9 +115,10 @@ deployment. only the deployed program can move pooled funds. There is no admin path to 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. `close_event` pays - that dust to the fee recipient once every bet is closed. + and every rounding goes the pool's way: the fee is the ceiling of its fraction + of the losing pool and each winner's share is floored, leaving at 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. @@ -154,12 +156,14 @@ suite in `src/tests.rs` drives the full lifecycle (create a market, add outcomes open betting, place opposing bets, settle after the close, claim the winnings, close the losing bet) and the cancel-and-refund path, asserting onchain state, token balances, and fee accounting at each step. It also checks the admin -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. +authorization of every admin handler +(`settle_and_cancel_reject_a_non_admin_signer` covers settling and cancelling), +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, +whose fee (1% of 250, rounded up to 3) and 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`, diff --git a/finance/betting-market/quasar/src/instructions/settle_event.rs b/finance/betting-market/quasar/src/instructions/settle_event.rs index 6a8c1993..3bebbe28 100644 --- a/finance/betting-market/quasar/src/instructions/settle_event.rs +++ b/finance/betting-market/quasar/src/instructions/settle_event.rs @@ -78,13 +78,15 @@ pub fn handle_settle_event( .ok_or(BettingError::MathOverflow)?; // The fee is only ever charged on the losing side, so a winner can never - // receive less than they staked. + // receive less than they staked. It rounds up, in the program's favor: + // fee = ceil(losing_pool * fee_bps / 10_000), and the winners share what + // the fee leaves. fee_bps never exceeds 10_000, so the fee never exceeds + // the losing pool. let fee_bps = u16::from(accounts.event.fee_bps); let fee: u64 = (losing_pool as u128) .checked_mul(fee_bps as u128) .ok_or(BettingError::MathOverflow)? - .checked_div(BPS_DENOMINATOR) - .ok_or(BettingError::MathOverflow)? + .div_ceil(BPS_DENOMINATOR) .try_into() .map_err(|_| BettingError::MathOverflow)?; let distributable_losing_pool = losing_pool diff --git a/finance/betting-market/quasar/src/tests.rs b/finance/betting-market/quasar/src/tests.rs index 2c97a948..c476db49 100644 --- a/finance/betting-market/quasar/src/tests.rs +++ b/finance/betting-market/quasar/src/tests.rs @@ -101,7 +101,7 @@ fn full_lifecycle_settles_and_pays_the_winner(test: &mut Test) { const STAKE_A: u64 = 100; const STAKE_B: u64 = 300; - const FEE: u64 = 1; // floor(100 * 100 / 10000) = 1 + const FEE: u64 = 1; // ceil(100 * 100 / 10000) = 1, exact const PAYOUT_B: u64 = STAKE_B + 99; // stake + winnings(99) test.send(InitializeEventInstruction { @@ -520,6 +520,46 @@ fn open_betting_needs_two_outcomes_and_the_admin(test: &mut Test) { ); } +/// Ending a market is the admin's alone: a stranger can neither settle it to +/// an outcome nor cancel it, and the event stays open for the admin to settle. +#[quasar_test] +fn settle_and_cancel_reject_a_non_admin_signer(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)); + + test.send(bet(BETTOR_A, TOKEN_A, 0, 100)).succeeds(); + test.send(bet(BETTOR_C, TOKEN_C, 1, 200)).succeeds(); + + test.send(CancelEventInstruction { + admin: ATTACKER, + event_event_id_seed: EVENT_ID, + }) + .fails_with(BettingError::Unauthorized); + + // Betting has closed, so the only thing stopping the attacker is the + // signer. + test.warp_to_timestamp(BETTING_CLOSES_AT); + test.send(SettleEventInstruction { + admin: ATTACKER, + token_mint: TOKEN_MINT, + event_event_id_seed: EVENT_ID, + fee_recipient_token_account: FEE_RECIPIENT_TOKEN, + winning_outcome_index: 0, + }) + .fails_with(BettingError::Unauthorized); + assert_eq!(test.read::(event).status, EventStatus::Open as u8); + assert_eq!(test.tokens(FEE_RECIPIENT_TOKEN), 0); + + // The admin settles the same event, so the refusals were the signer's. + test.send(settle(0)).succeeds(); + assert_eq!(test.read::(event).status, EventStatus::Settled as u8); +} + /// Bets land strictly before the close time and settlement only at or after /// it, so there is no second at which someone who knows the result can still /// stake, and none at which the admin can end the market early. @@ -588,9 +628,10 @@ fn no_cap_on_open_bets_per_wallet(test: &mut Test) { /// 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. +/// ceil(2.5) = 3 (1%, rounded up), distributable 247; A gets +/// floor(100 * 247 / 300) = 82 and B floor(200 * 247 / 300) = 164, 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); @@ -621,7 +662,12 @@ fn close_event_pays_dust_to_fee_recipient_and_returns_rent(test: &mut Test) { test.warp_to_timestamp(BETTING_CLOSES_AT); test.send(settle(0)).succeeds(); - assert_eq!(test.tokens(FEE_RECIPIENT_TOKEN), 2); + // 1% of 250 is 2.5; the fee rounds up. + assert_eq!(test.tokens(FEE_RECIPIENT_TOKEN), 3); + assert_eq!( + u64::from(test.read::(event).distributable_losing_pool), + 247 + ); // C closes the losing bet; A and B claim. test.send(CloseLosingBetInstruction { @@ -646,7 +692,7 @@ fn close_event_pays_dust_to_fee_recipient_and_returns_rent(test: &mut Test) { .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!(test.tokens(TOKEN_B), STARTING_TOKENS - 200 + 364); assert_eq!(u64::from(test.read::(event).open_bets), 0); // The two floors left one minor unit in the vault. @@ -658,8 +704,8 @@ fn close_event_pays_dust_to_fee_recipient_and_returns_rent(test: &mut Test) { 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); + // The dust joined the fee: 3 + 1. + assert_eq!(test.tokens(FEE_RECIPIENT_TOKEN), 4); } /// A settled event keeps its accounts while any Bet account of it is open: diff --git a/finance/escrow/anchor-v1/CHANGELOG.md b/finance/escrow/anchor-v1/CHANGELOG.md index 611b4fc2..735cd28f 100644 --- a/finance/escrow/anchor-v1/CHANGELOG.md +++ b/finance/escrow/anchor-v1/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased, 2026-10-05 + +- The tests tell the book's story: token A is TSLAx, minted at 8 decimals, token B is USDC at 6, and Alice offers 1 TSLAx (`TSLAX_OFFERED`, 100,000,000 minor units) for 1,000 USDC (`USDC_WANTED`, 1,000,000,000 minor units). `test_cancel_offer_rejects_non_maker` asserts Anchor's `ConstraintHasOne` (2001), so every refusal test asserts its error code. + ## 2026-10-04 - `take_offer` takes two arguments the taker signs, `minimum_token_a_out` and `maximum_token_b_in`, and refuses the take with `OfferTermsChanged` before any token moves if the vault holds less token A or the offer wants more token B than those bounds. An offer's address is its maker and `id`, so a maker could cancel an offer and re-make the same `id` at worse terms while a taker's transaction was in flight, and the transaction would trade at the new terms. `test_take_offer_rejects_switched_offer` and `test_take_offer_rejects_switched_offer_wanting_more_token_b` run that switch. diff --git a/finance/escrow/anchor-v1/README.md b/finance/escrow/anchor-v1/README.md index 2b9435eb..534f6cbe 100644 --- a/finance/escrow/anchor-v1/README.md +++ b/finance/escrow/anchor-v1/README.md @@ -8,7 +8,7 @@ This Solana [program](https://solana.com/docs/terminology#program) is an **escrow** - it lets a **maker** swap a specific amount of one token for a desired amount of another token with a **taker**, atomically and without either party having to trust the other. -For example: Alice offers 10 USDC and wants 100 WIF in return. The program holds Alice's USDC in a vault until someone delivers the WIF, then releases both sides in a single transaction. Neither party can take the other's tokens and run, and there is no spread or middleman fee on the swap. +For example: Alice offers 1 TSLAx and wants 1,000 USDC in return. The program holds Alice's TSLAx in a vault until someone delivers the USDC, then releases both sides in a single transaction. Neither party can take the other's tokens and run, and there is no spread or middleman fee on the swap. See also the [native](../native/) and [Quasar](../quasar/) variants of the same program. @@ -47,7 +47,7 @@ The tests are Rust integration tests running against [LiteSVM](https://www.ancho cargo test ``` -(`anchor test` runs the same command, per `Anchor.toml`.) The tests cover the make/take flow, the make/cancel flow, rejection of a non-maker cancel, rejection of a take that lands on an offer the maker cancelled and re-made at worse terms (`test_take_offer_rejects_switched_offer` for less token A, `test_take_offer_rejects_switched_offer_wanting_more_token_b` for more token B), rejection of offers with zero tokens on either side or the same token on both, token balances on every leg, and the rent refunds (the maker's lamports recover the offer and vault rent after both take and cancel). +(`anchor test` runs the same command, per `Anchor.toml`.) The tests tell the story above: token A is TSLAx, minted at 8 decimals, token B is USDC at 6, and Alice offers 1 TSLAx (100,000,000 minor units) for 1,000 USDC (1,000,000,000 minor units). They cover the make/take flow, the make/cancel flow, rejection of a non-maker cancel (Anchor's `ConstraintHasOne`, 2001), rejection of a take that lands on an offer the maker cancelled and re-made at worse terms (`test_take_offer_rejects_switched_offer` for less token A, `test_take_offer_rejects_switched_offer_wanting_more_token_b` for more token B), rejection of offers with zero tokens on either side (`ZeroAmount`) or the same token on both (`ConstraintDuplicateMutableAccount`, 2040), token balances on every leg, and the rent refunds (the maker's lamports recover the offer and vault rent after both take and cancel). Every refusal test asserts the error code it expects. ## FAQ diff --git a/finance/escrow/anchor-v1/programs/escrow/tests/test_escrow.rs b/finance/escrow/anchor-v1/programs/escrow/tests/test_escrow.rs index f6cc380b..dfcb607f 100644 --- a/finance/escrow/anchor-v1/programs/escrow/tests/test_escrow.rs +++ b/finance/escrow/anchor-v1/programs/escrow/tests/test_escrow.rs @@ -1,5 +1,6 @@ use { anchor_lang::{ + error::ErrorCode, solana_program::{instruction::Instruction, pubkey::Pubkey, system_program}, InstructionData, ToAccountMetas, }, @@ -25,6 +26,19 @@ fn ata_program_id() -> Pubkey { .unwrap() } +// The story the tests tell: Alice offers 1 TSLAx and wants 1,000 USDC for it. +// TSLAx has 8 decimals and USDC has 6, so every amount below is in those +// minor units. +const TSLAX_DECIMALS: u8 = 8; +const USDC_DECIMALS: u8 = 6; +const ONE_TSLAX: u64 = 10u64.pow(TSLAX_DECIMALS as u32); +const ONE_USDC: u64 = 10u64.pow(USDC_DECIMALS as u32); +const TSLAX_OFFERED: u64 = ONE_TSLAX; +const USDC_WANTED: u64 = 1_000 * ONE_USDC; +// What each side holds before the offer: Alice 10 TSLAx, Bob 10,000 USDC. +const ALICE_TSLAX: u64 = 10 * ONE_TSLAX; +const BOB_USDC: u64 = 10_000 * ONE_USDC; + fn lamports(svm: &LiteSVM, address: &Pubkey) -> u64 { svm.get_account(address).map(|a| a.lamports).unwrap_or(0) } @@ -68,13 +82,10 @@ fn full_setup() -> EscrowSetup { let alice = create_wallet(&mut svm, 10_000_000_000).unwrap(); let bob = create_wallet(&mut svm, 10_000_000_000).unwrap(); - let decimals: u8 = 6; - let alice_amount: u64 = 1_000_000_000; - let bob_amount: u64 = 1_000_000_000; - - // Create mints (payer is mint authority) - let mint_a = create_token_mint(&mut svm, &payer, decimals, None).unwrap(); - let mint_b = create_token_mint(&mut svm, &payer, decimals, None).unwrap(); + // Token A is TSLAx and token B is USDC; the payer is the mint authority + // of both. + let mint_a = create_token_mint(&mut svm, &payer, TSLAX_DECIMALS, None).unwrap(); + let mint_b = create_token_mint(&mut svm, &payer, USDC_DECIMALS, None).unwrap(); // Create ATAs let alice_ata_a = @@ -87,9 +98,9 @@ fn full_setup() -> EscrowSetup { // bob_ata_a is derived but not pre-created (program uses init_if_needed) let bob_ata_a = derive_ata(&bob.pubkey(), &mint_a); - // Mint tokens: Alice gets token A, Bob gets token B - mint_tokens_to_token_account(&mut svm, &mint_a, &alice_ata_a, alice_amount, &payer).unwrap(); - mint_tokens_to_token_account(&mut svm, &mint_b, &bob_ata_b, bob_amount, &payer).unwrap(); + // Alice holds the TSLAx, Bob holds the USDC. + mint_tokens_to_token_account(&mut svm, &mint_a, &alice_ata_a, ALICE_TSLAX, &payer).unwrap(); + mint_tokens_to_token_account(&mut svm, &mint_b, &bob_ata_b, BOB_USDC, &payer).unwrap(); EscrowSetup { svm, @@ -111,8 +122,8 @@ fn test_make_offer() { let mut es = full_setup(); let offer_id: u64 = 1; - let token_a_offered_amount: u64 = 1_000_000; - let token_b_wanted_amount: u64 = 1_000_000; + let token_a_offered_amount: u64 = TSLAX_OFFERED; + let token_b_wanted_amount: u64 = USDC_WANTED; // Derive offer PDA let (offer_pda, _bump) = Pubkey::find_program_address( @@ -178,8 +189,8 @@ fn test_take_offer() { let mut es = full_setup(); let offer_id: u64 = 2; - let token_a_offered_amount: u64 = 1_000_000; - let token_b_wanted_amount: u64 = 1_000_000; + let token_a_offered_amount: u64 = TSLAX_OFFERED; + let token_b_wanted_amount: u64 = USDC_WANTED; // Derive offer PDA let (offer_pda, _bump) = Pubkey::find_program_address( @@ -315,8 +326,8 @@ fn test_cancel_offer() { let mut es = full_setup(); let offer_id: u64 = 3; - let token_a_offered_amount: u64 = 500_000; - let token_b_wanted_amount: u64 = 1_000_000; + let token_a_offered_amount: u64 = TSLAX_OFFERED; + let token_b_wanted_amount: u64 = USDC_WANTED; let (offer_pda, _bump) = Pubkey::find_program_address( &[ @@ -419,8 +430,8 @@ fn test_cancel_offer_rejects_non_maker() { let mut es = full_setup(); let offer_id: u64 = 4; - let token_a_offered_amount: u64 = 500_000; - let token_b_wanted_amount: u64 = 1_000_000; + let token_a_offered_amount: u64 = TSLAX_OFFERED; + let token_b_wanted_amount: u64 = USDC_WANTED; let (offer_pda, _bump) = Pubkey::find_program_address( &[ @@ -489,10 +500,9 @@ fn test_cancel_offer_rejects_non_maker() { &[&es.payer, &es.bob], &es.payer.pubkey(), ); - assert!( - result.is_err(), - "Bob must not be able to cancel Alice's offer" - ); + // Bob passes himself as the maker, so the offer's seeds no longer derive + // its address: Anchor's seeds check fails before `has_one = maker` runs. + assert_fails_with_anchor_error(result, ErrorCode::ConstraintSeeds); } // Anchor numbers a program's errors from 6000 in declaration order, and a @@ -502,7 +512,19 @@ fn assert_fails_with( result: Result<(), solana_kite::SolanaKiteError>, expected: escrow::error::EscrowError, ) { - let code = 6000 + expected as u32; + assert_fails_with_code(result, 6000 + expected as u32); +} + +// The same check for one of Anchor's own constraint errors, whose numbers are +// the `ErrorCode` discriminants. +fn assert_fails_with_anchor_error( + result: Result<(), solana_kite::SolanaKiteError>, + expected: ErrorCode, +) { + assert_fails_with_code(result, expected as u32); +} + +fn assert_fails_with_code(result: Result<(), solana_kite::SolanaKiteError>, code: u32) { let error = format!("{:?}", result.expect_err("transaction should have failed")); assert!( error.contains(&format!("Custom({code})")), @@ -565,7 +587,7 @@ fn test_make_offer_rejects_zero_offered_amount() { let (mint_b, alice_ata_b) = (es.mint_b, es.alice_ata_b); let alice_balance_before = get_token_account_balance(&es.svm, &es.alice_ata_a).unwrap(); - let result = try_make_offer(&mut es, 5, 0, 1_000_000, mint_b, alice_ata_b); + let result = try_make_offer(&mut es, 5, 0, USDC_WANTED, mint_b, alice_ata_b); assert_fails_with(result, escrow::error::EscrowError::ZeroAmount); assert_eq!( @@ -580,7 +602,7 @@ fn test_make_offer_rejects_zero_wanted_amount() { let (mint_b, alice_ata_b) = (es.mint_b, es.alice_ata_b); let alice_balance_before = get_token_account_balance(&es.svm, &es.alice_ata_a).unwrap(); - let result = try_make_offer(&mut es, 6, 1_000_000, 0, mint_b, alice_ata_b); + let result = try_make_offer(&mut es, 6, TSLAX_OFFERED, 0, mint_b, alice_ata_b); assert_fails_with(result, escrow::error::EscrowError::ZeroAmount); assert_eq!( @@ -597,14 +619,17 @@ fn test_make_offer_rejects_same_mint() { let (mint_a, alice_ata_a) = (es.mint_a, es.alice_ata_a); let alice_balance_before = get_token_account_balance(&es.svm, &alice_ata_a).unwrap(); - let result = try_make_offer(&mut es, 7, 1_000_000, 2_000_000, mint_a, alice_ata_a); + let result = try_make_offer( + &mut es, + 7, + TSLAX_OFFERED, + 2 * TSLAX_OFFERED, + mint_a, + alice_ata_a, + ); // Anchor refuses the same mutable account twice before the handler runs. - let error = format!("{:?}", result.expect_err("a same-token offer must fail")); - assert!( - error.contains("Custom(2040)"), - "expected ConstraintDuplicateMutableAccount (2040), got: {error}" - ); + assert_fails_with_anchor_error(result, ErrorCode::ConstraintDuplicateMutableAccount); assert_eq!( get_token_account_balance(&es.svm, &alice_ata_a).unwrap(), alice_balance_before @@ -696,8 +721,8 @@ fn assert_switched_offer_refused(switched_a_offered: u64, switched_b_wanted: u64 let mut es = full_setup(); let (mint_b, alice_ata_b) = (es.mint_b, es.alice_ata_b); let offer_id: u64 = 8; - let token_a_offered_amount: u64 = 1_000_000; - let token_b_wanted_amount: u64 = 1_000_000; + let token_a_offered_amount: u64 = TSLAX_OFFERED; + let token_b_wanted_amount: u64 = USDC_WANTED; try_make_offer( &mut es, @@ -753,12 +778,12 @@ fn assert_switched_offer_refused(switched_a_offered: u64, switched_b_wanted: u64 #[test] fn test_take_offer_rejects_switched_offer() { - // Alice re-makes the offer putting a thousandth of the token A in the vault. - assert_switched_offer_refused(1_000, 1_000_000); + // Alice re-makes the offer putting a thousandth of the TSLAx in the vault. + assert_switched_offer_refused(TSLAX_OFFERED / 1_000, USDC_WANTED); } #[test] fn test_take_offer_rejects_switched_offer_wanting_more_token_b() { - // Alice re-makes the offer asking for twice the token B. - assert_switched_offer_refused(1_000_000, 2_000_000); + // Alice re-makes the offer asking for twice the USDC. + assert_switched_offer_refused(TSLAX_OFFERED, 2 * USDC_WANTED); } diff --git a/finance/escrow/anchor/CHANGELOG.md b/finance/escrow/anchor/CHANGELOG.md index 611b4fc2..eedd16ea 100644 --- a/finance/escrow/anchor/CHANGELOG.md +++ b/finance/escrow/anchor/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased, 2026-10-05 + +- The tests tell the book's story: token A is TSLAx, minted at 8 decimals, token B is USDC at 6, and Alice offers 1 TSLAx (`TSLAX_OFFERED`, 100,000,000 minor units) for 1,000 USDC (`USDC_WANTED`, 1,000,000,000 minor units). `test_cancel_offer_rejects_non_maker` asserts Anchor's `ConstraintAddress` (2012), so every refusal test asserts its error code. + ## 2026-10-04 - `take_offer` takes two arguments the taker signs, `minimum_token_a_out` and `maximum_token_b_in`, and refuses the take with `OfferTermsChanged` before any token moves if the vault holds less token A or the offer wants more token B than those bounds. An offer's address is its maker and `id`, so a maker could cancel an offer and re-make the same `id` at worse terms while a taker's transaction was in flight, and the transaction would trade at the new terms. `test_take_offer_rejects_switched_offer` and `test_take_offer_rejects_switched_offer_wanting_more_token_b` run that switch. diff --git a/finance/escrow/anchor/README.md b/finance/escrow/anchor/README.md index 08e23933..2b21595a 100644 --- a/finance/escrow/anchor/README.md +++ b/finance/escrow/anchor/README.md @@ -8,7 +8,7 @@ This Solana [program](https://solana.com/docs/terminology#program) is an **escrow** - it lets a **maker** swap a specific amount of one token for a desired amount of another token with a **taker**, atomically and without either party having to trust the other. -For example: Alice offers 10 USDC and wants 100 WIF in return. The program holds Alice's USDC in a vault until someone delivers the WIF, then releases both sides in a single transaction. Neither party can take the other's tokens and run, and there is no spread or middleman fee on the swap. +For example: Alice offers 1 TSLAx and wants 1,000 USDC in return. The program holds Alice's TSLAx in a vault until someone delivers the USDC, then releases both sides in a single transaction. Neither party can take the other's tokens and run, and there is no spread or middleman fee on the swap. See also the [native](../native/) and [Quasar](../quasar/) variants of the same program. @@ -47,7 +47,7 @@ The tests are Rust integration tests running against [LiteSVM](https://www.ancho cargo test ``` -(`anchor test` runs the same command, per `Anchor.toml`.) The tests cover the make/take flow, the make/cancel flow, rejection of a non-maker cancel, rejection of a take that lands on an offer the maker cancelled and re-made at worse terms (`test_take_offer_rejects_switched_offer` for less token A, `test_take_offer_rejects_switched_offer_wanting_more_token_b` for more token B), rejection of offers with zero tokens on either side or the same token on both, token balances on every leg, and the rent refunds (the maker's lamports recover the offer and vault rent after both take and cancel). +(`anchor test` runs the same command, per `Anchor.toml`.) The tests tell the story above: token A is TSLAx, minted at 8 decimals, token B is USDC at 6, and Alice offers 1 TSLAx (100,000,000 minor units) for 1,000 USDC (1,000,000,000 minor units). They cover the make/take flow, the make/cancel flow, rejection of a non-maker cancel (Anchor's `ConstraintAddress`, 2012), rejection of a take that lands on an offer the maker cancelled and re-made at worse terms (`test_take_offer_rejects_switched_offer` for less token A, `test_take_offer_rejects_switched_offer_wanting_more_token_b` for more token B), rejection of offers with zero tokens on either side (`ZeroAmount`) or the same token on both (`ConstraintDuplicateMutableAccount`, 2040), token balances on every leg, and the rent refunds (the maker's lamports recover the offer and vault rent after both take and cancel). Every refusal test asserts the error code it expects. ## FAQ diff --git a/finance/escrow/anchor/programs/escrow/tests/test_escrow.rs b/finance/escrow/anchor/programs/escrow/tests/test_escrow.rs index f785a03e..9f82a989 100644 --- a/finance/escrow/anchor/programs/escrow/tests/test_escrow.rs +++ b/finance/escrow/anchor/programs/escrow/tests/test_escrow.rs @@ -1,7 +1,7 @@ use { anchor_lang::{ - solana_program::instruction::Instruction, system_program, Address, InstructionData, - ToAccountMetas, + solana_program::instruction::Instruction, system_program, Address, ErrorCode, + InstructionData, ToAccountMetas, }, anchor_v2_testing::{Keypair, LiteSVM, Signer}, solana_kite::{ @@ -23,6 +23,19 @@ fn ata_program_id() -> Address { .unwrap() } +// The story the tests tell: Alice offers 1 TSLAx and wants 1,000 USDC for it. +// TSLAx has 8 decimals and USDC has 6, so every amount below is in those +// minor units. +const TSLAX_DECIMALS: u8 = 8; +const USDC_DECIMALS: u8 = 6; +const ONE_TSLAX: u64 = 10u64.pow(TSLAX_DECIMALS as u32); +const ONE_USDC: u64 = 10u64.pow(USDC_DECIMALS as u32); +const TSLAX_OFFERED: u64 = ONE_TSLAX; +const USDC_WANTED: u64 = 1_000 * ONE_USDC; +// What each side holds before the offer: Alice 10 TSLAx, Bob 10,000 USDC. +const ALICE_TSLAX: u64 = 10 * ONE_TSLAX; +const BOB_USDC: u64 = 10_000 * ONE_USDC; + fn lamports(svm: &LiteSVM, address: &Address) -> u64 { svm.get_account(address).map(|a| a.lamports).unwrap_or(0) } @@ -66,13 +79,10 @@ fn full_setup() -> EscrowSetup { let alice = create_wallet(&mut svm, 10_000_000_000).unwrap(); let bob = create_wallet(&mut svm, 10_000_000_000).unwrap(); - let decimals: u8 = 6; - let alice_amount: u64 = 1_000_000_000; - let bob_amount: u64 = 1_000_000_000; - - // Create mints (payer is mint authority) - let mint_a = create_token_mint(&mut svm, &payer, decimals, None).unwrap(); - let mint_b = create_token_mint(&mut svm, &payer, decimals, None).unwrap(); + // Token A is TSLAx and token B is USDC; the payer is the mint authority + // of both. + let mint_a = create_token_mint(&mut svm, &payer, TSLAX_DECIMALS, None).unwrap(); + let mint_b = create_token_mint(&mut svm, &payer, USDC_DECIMALS, None).unwrap(); // Create ATAs let alice_ata_a = @@ -85,9 +95,9 @@ fn full_setup() -> EscrowSetup { // bob_ata_a is derived but not pre-created (program uses init_if_needed) let bob_ata_a = derive_ata(&bob.pubkey(), &mint_a); - // Mint tokens: Alice gets token A, Bob gets token B - mint_tokens_to_token_account(&mut svm, &mint_a, &alice_ata_a, alice_amount, &payer).unwrap(); - mint_tokens_to_token_account(&mut svm, &mint_b, &bob_ata_b, bob_amount, &payer).unwrap(); + // Alice holds the TSLAx, Bob holds the USDC. + mint_tokens_to_token_account(&mut svm, &mint_a, &alice_ata_a, ALICE_TSLAX, &payer).unwrap(); + mint_tokens_to_token_account(&mut svm, &mint_b, &bob_ata_b, BOB_USDC, &payer).unwrap(); EscrowSetup { svm, @@ -109,8 +119,8 @@ fn test_make_offer() { let mut es = full_setup(); let offer_id: u64 = 1; - let token_a_offered_amount: u64 = 1_000_000; - let token_b_wanted_amount: u64 = 1_000_000; + let token_a_offered_amount: u64 = TSLAX_OFFERED; + let token_b_wanted_amount: u64 = USDC_WANTED; // Derive offer PDA let (offer_pda, _bump) = Address::find_program_address( @@ -176,8 +186,8 @@ fn test_take_offer() { let mut es = full_setup(); let offer_id: u64 = 2; - let token_a_offered_amount: u64 = 1_000_000; - let token_b_wanted_amount: u64 = 1_000_000; + let token_a_offered_amount: u64 = TSLAX_OFFERED; + let token_b_wanted_amount: u64 = USDC_WANTED; // Derive offer PDA let (offer_pda, _bump) = Address::find_program_address( @@ -313,8 +323,8 @@ fn test_cancel_offer() { let mut es = full_setup(); let offer_id: u64 = 3; - let token_a_offered_amount: u64 = 500_000; - let token_b_wanted_amount: u64 = 1_000_000; + let token_a_offered_amount: u64 = TSLAX_OFFERED; + let token_b_wanted_amount: u64 = USDC_WANTED; let (offer_pda, _bump) = Address::find_program_address( &[ @@ -417,8 +427,8 @@ fn test_cancel_offer_rejects_non_maker() { let mut es = full_setup(); let offer_id: u64 = 4; - let token_a_offered_amount: u64 = 500_000; - let token_b_wanted_amount: u64 = 1_000_000; + let token_a_offered_amount: u64 = TSLAX_OFFERED; + let token_b_wanted_amount: u64 = USDC_WANTED; let (offer_pda, _bump) = Address::find_program_address( &[ @@ -487,10 +497,7 @@ fn test_cancel_offer_rejects_non_maker() { &[&es.payer, &es.bob], &es.payer.pubkey(), ); - assert!( - result.is_err(), - "Bob must not be able to cancel Alice's offer" - ); + assert_fails_with_anchor_error(result, ErrorCode::ConstraintAddress); } // Anchor numbers a program's errors from 6000 in declaration order, and a @@ -500,11 +507,27 @@ fn assert_fails_with( result: Result<(), solana_kite::SolanaKiteError>, expected: escrow::error::EscrowError, ) { - let code = 6000 + expected as u32; + assert_fails_with_program_error(result, &anchor_lang::Error::Custom(6000 + expected as u32)); +} + +// The same check for one of Anchor's own constraint errors. Anchor 2 reports +// a constraint failure as the `ProgramError` its `ErrorCode` converts to, +// which for most constraints is `Custom(n)` with Anchor 1's number for it. +fn assert_fails_with_anchor_error( + result: Result<(), solana_kite::SolanaKiteError>, + expected: ErrorCode, +) { + assert_fails_with_program_error(result, &anchor_lang::Error::from(expected)); +} + +fn assert_fails_with_program_error( + result: Result<(), solana_kite::SolanaKiteError>, + expected: &anchor_lang::Error, +) { let error = format!("{:?}", result.expect_err("transaction should have failed")); assert!( - error.contains(&format!("Custom({code})")), - "expected error {code}, got: {error}" + error.contains(&format!("{expected:?}")), + "expected error {expected:?}, got: {error}" ); } @@ -563,7 +586,7 @@ fn test_make_offer_rejects_zero_offered_amount() { let (mint_b, alice_ata_b) = (es.mint_b, es.alice_ata_b); let alice_balance_before = get_token_account_balance(&es.svm, &es.alice_ata_a).unwrap(); - let result = try_make_offer(&mut es, 5, 0, 1_000_000, mint_b, alice_ata_b); + let result = try_make_offer(&mut es, 5, 0, USDC_WANTED, mint_b, alice_ata_b); assert_fails_with(result, escrow::error::EscrowError::ZeroAmount); assert_eq!( @@ -578,7 +601,7 @@ fn test_make_offer_rejects_zero_wanted_amount() { let (mint_b, alice_ata_b) = (es.mint_b, es.alice_ata_b); let alice_balance_before = get_token_account_balance(&es.svm, &es.alice_ata_a).unwrap(); - let result = try_make_offer(&mut es, 6, 1_000_000, 0, mint_b, alice_ata_b); + let result = try_make_offer(&mut es, 6, TSLAX_OFFERED, 0, mint_b, alice_ata_b); assert_fails_with(result, escrow::error::EscrowError::ZeroAmount); assert_eq!( @@ -595,14 +618,17 @@ fn test_make_offer_rejects_same_mint() { let (mint_a, alice_ata_a) = (es.mint_a, es.alice_ata_a); let alice_balance_before = get_token_account_balance(&es.svm, &alice_ata_a).unwrap(); - let result = try_make_offer(&mut es, 7, 1_000_000, 2_000_000, mint_a, alice_ata_a); + let result = try_make_offer( + &mut es, + 7, + TSLAX_OFFERED, + 2 * TSLAX_OFFERED, + mint_a, + alice_ata_a, + ); // Anchor refuses the same mutable account twice before the handler runs. - let error = format!("{:?}", result.expect_err("a same-token offer must fail")); - assert!( - error.contains("Custom(2040)"), - "expected ConstraintDuplicateMutableAccount (2040), got: {error}" - ); + assert_fails_with_anchor_error(result, ErrorCode::ConstraintDuplicateMutableAccount); assert_eq!( get_token_account_balance(&es.svm, &alice_ata_a).unwrap(), alice_balance_before @@ -694,8 +720,8 @@ fn assert_switched_offer_refused(switched_a_offered: u64, switched_b_wanted: u64 let mut es = full_setup(); let (mint_b, alice_ata_b) = (es.mint_b, es.alice_ata_b); let offer_id: u64 = 8; - let token_a_offered_amount: u64 = 1_000_000; - let token_b_wanted_amount: u64 = 1_000_000; + let token_a_offered_amount: u64 = TSLAX_OFFERED; + let token_b_wanted_amount: u64 = USDC_WANTED; try_make_offer( &mut es, @@ -751,12 +777,12 @@ fn assert_switched_offer_refused(switched_a_offered: u64, switched_b_wanted: u64 #[test] fn test_take_offer_rejects_switched_offer() { - // Alice re-makes the offer putting a thousandth of the token A in the vault. - assert_switched_offer_refused(1_000, 1_000_000); + // Alice re-makes the offer putting a thousandth of the TSLAx in the vault. + assert_switched_offer_refused(TSLAX_OFFERED / 1_000, USDC_WANTED); } #[test] fn test_take_offer_rejects_switched_offer_wanting_more_token_b() { - // Alice re-makes the offer asking for twice the token B. - assert_switched_offer_refused(1_000_000, 2_000_000); + // Alice re-makes the offer asking for twice the USDC. + assert_switched_offer_refused(TSLAX_OFFERED, 2 * USDC_WANTED); } diff --git a/finance/escrow/native/CHANGELOG.md b/finance/escrow/native/CHANGELOG.md index 2ce77cd4..13af1277 100644 --- a/finance/escrow/native/CHANGELOG.md +++ b/finance/escrow/native/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased, 2026-10-05 + +- The tests tell the book's story: mint A is TSLAx, minted at 8 decimals, mint B is USDC at 6, and the maker offers 1 TSLAx (`TSLAX_OFFERED`, 100,000,000 minor units) for 1,000 USDC (`USDC_WANTED`, 1,000,000,000 minor units). `test_cancel_offer_rejects_non_maker` asserts `MakerMismatch`, so every refusal test asserts its error code. + ## 2026-10-04 - The `TakeOffer` instruction carries two fields the taker signs, `minimum_token_a_out` and `maximum_token_b_in`, and the handler refuses the take with `OfferTermsChanged` (appended to `EscrowError`, so existing codes keep their numbers) before any token moves if the vault holds less token A or the offer wants more token B than those bounds. An offer's address is its maker and `id`, so a maker could cancel an offer and re-make the same `id` at worse terms while a taker's transaction was in flight, and the transaction would trade at the new terms. `test_take_offer_rejects_switched_offer` and `test_take_offer_rejects_switched_offer_wanting_more_token_b` run that switch. diff --git a/finance/escrow/native/README.md b/finance/escrow/native/README.md index 5b87169c..b42bfdda 100644 --- a/finance/escrow/native/README.md +++ b/finance/escrow/native/README.md @@ -2,7 +2,7 @@ This Solana program is an **escrow** written directly against `solana-program`, with no framework. It lets a **maker** swap a specific amount of one token for a desired amount of another token with a **taker**, atomically and without either party having to trust the other. -For example: Alice offers 10 USDC and wants 100 WIF in return. The program holds Alice's USDC in a vault until someone delivers the WIF, then releases both sides in a single transaction. +For example: Alice offers 1 TSLAx and wants 1,000 USDC in return. The program holds Alice's TSLAx in a vault until someone delivers the USDC, then releases both sides in a single transaction. See also the [Anchor](../anchor/) and [Quasar](../quasar/) variants of the same program. @@ -45,4 +45,4 @@ The Rust + [LiteSVM](https://www.anchor-lang.com/docs/testing/litesvm) tests loa cargo test --manifest-path=./program/Cargo.toml ``` -The tests cover the make/take flow, the make/cancel flow, rejection of a non-maker cancel, rejection of a take that lands on an offer the maker cancelled and re-made at worse terms (`test_take_offer_rejects_switched_offer` for less token A, `test_take_offer_rejects_switched_offer_wanting_more_token_b` for more token B), rejection of offers with zero tokens on either side or the same token on both, token balances on every leg, and the rent refunds (the maker's lamports recover the offer and vault rent after both take and cancel). +The tests tell the story above: mint A is TSLAx, minted at 8 decimals, mint B is USDC at 6, and the maker offers 1 TSLAx (100,000,000 minor units) for 1,000 USDC (1,000,000,000 minor units). They cover the make/take flow, the make/cancel flow, rejection of a non-maker cancel (`MakerMismatch`), rejection of a take that lands on an offer the maker cancelled and re-made at worse terms (`test_take_offer_rejects_switched_offer` for less token A, `test_take_offer_rejects_switched_offer_wanting_more_token_b` for more token B), rejection of offers with zero tokens on either side (`ZeroAmount`) or the same token on both (`SameMint`), token balances on every leg, and the rent refunds (the maker's lamports recover the offer and vault rent after both take and cancel). Every refusal test asserts the error code it expects. diff --git a/finance/escrow/native/program/tests/test.rs b/finance/escrow/native/program/tests/test.rs index a8c40a65..e79472cb 100644 --- a/finance/escrow/native/program/tests/test.rs +++ b/finance/escrow/native/program/tests/test.rs @@ -21,10 +21,19 @@ const MAKE_OFFER: u8 = 0; const TAKE_OFFER: u8 = 1; const CANCEL_OFFER: u8 = 2; -const DECIMALS: u8 = 6; -const MINTED_AMOUNT: u64 = 100 * 1_000_000; // 100 tokens at 6 decimals -const AMOUNT_A: u64 = 4 * 1_000_000; // offered -const AMOUNT_B: u64 = 1_000_000; // wanted +// The story the tests tell: the maker offers 1 TSLAx and wants 1,000 USDC for +// it. TSLAx has 8 decimals and USDC has 6, so every amount below is in those +// minor units. +const TSLAX_DECIMALS: u8 = 8; +const USDC_DECIMALS: u8 = 6; +const ONE_TSLAX: u64 = 10u64.pow(TSLAX_DECIMALS as u32); +const ONE_USDC: u64 = 10u64.pow(USDC_DECIMALS as u32); +const TSLAX_OFFERED: u64 = ONE_TSLAX; +const USDC_WANTED: u64 = 1_000 * ONE_USDC; +// What each side holds before the offer: the maker 10 TSLAx, the taker +// 10,000 USDC. +const MAKER_TSLAX: u64 = 10 * ONE_TSLAX; +const TAKER_USDC: u64 = 10_000 * ONE_USDC; const OFFER_ID: u64 = 0; /// Sign with `payer` (fee payer) plus any extra signers and send the tx, @@ -52,9 +61,16 @@ fn try_send( svm.send_transaction(tx).map(|_| ()).map_err(Box::new) } -/// Create `mint`, an ATA for `holder`, and mint `MINTED_AMOUNT` into it. The -/// payer is the mint + freeze authority. -fn mint_tokens(svm: &mut LiteSVM, payer: &Keypair, mint: &Keypair, holder: &Pubkey) { +/// Create `mint` with `decimals`, an ATA for `holder`, and mint `amount` into +/// it. The payer is the mint + freeze authority. +fn mint_tokens( + svm: &mut LiteSVM, + payer: &Keypair, + mint: &Keypair, + decimals: u8, + holder: &Pubkey, + amount: u64, +) { let token_program = spl_token_interface::id(); let rent = svm.minimum_balance_for_rent_exemption(Mint::LEN); @@ -70,7 +86,7 @@ fn mint_tokens(svm: &mut LiteSVM, payer: &Keypair, mint: &Keypair, holder: &Pubk &mint.pubkey(), &payer.pubkey(), Some(&payer.pubkey()), - DECIMALS, + decimals, ) .unwrap(); send(svm, payer, &[create_mint_account, init_mint], &[mint]); @@ -84,7 +100,7 @@ fn mint_tokens(svm: &mut LiteSVM, payer: &Keypair, mint: &Keypair, holder: &Pubk &ata, &payer.pubkey(), &[], - MINTED_AMOUNT, + amount, ) .unwrap(); send(svm, payer, &[create_ata, mint_to_ix], &[]); @@ -136,9 +152,23 @@ fn setup() -> EscrowSetup { svm.airdrop(&maker.pubkey(), LAMPORTS_PER_SOL).unwrap(); svm.airdrop(&taker.pubkey(), LAMPORTS_PER_SOL).unwrap(); - // Maker holds Mint A, taker holds Mint B. - mint_tokens(&mut svm, &payer, &mint_a, &maker.pubkey()); - mint_tokens(&mut svm, &payer, &mint_b, &taker.pubkey()); + // Mint A is TSLAx, held by the maker; mint B is USDC, held by the taker. + mint_tokens( + &mut svm, + &payer, + &mint_a, + TSLAX_DECIMALS, + &maker.pubkey(), + MAKER_TSLAX, + ); + mint_tokens( + &mut svm, + &payer, + &mint_b, + USDC_DECIMALS, + &taker.pubkey(), + TAKER_USDC, + ); let (offer, _bump) = Pubkey::find_program_address( &[b"offer", maker.pubkey().as_ref(), &OFFER_ID.to_le_bytes()], @@ -170,8 +200,8 @@ fn setup() -> EscrowSetup { fn make_offer_instruction(es: &EscrowSetup) -> Instruction { make_offer_instruction_for( es, - AMOUNT_A, - AMOUNT_B, + TSLAX_OFFERED, + USDC_WANTED, &es.mint_b.pubkey(), &es.maker_account_b, ) @@ -210,7 +240,7 @@ fn make_offer_instruction_for( } fn take_offer_instruction(es: &EscrowSetup) -> Instruction { - take_offer_instruction_for(es, AMOUNT_A, AMOUNT_B) + take_offer_instruction_for(es, TSLAX_OFFERED, USDC_WANTED) } /// A `take_offer` instruction signed for the given terms: the least token A @@ -285,7 +315,7 @@ fn test_escrow_make_and_take() { // Vault holds the offered Mint A amount, and the maker paid the rent for // the offer account and the vault. - assert_eq!(token_amount(&es.svm, &es.vault), AMOUNT_A); + assert_eq!(token_amount(&es.svm, &es.vault), TSLAX_OFFERED); let offer_rent = lamports(&es.svm, &es.offer); let vault_rent = lamports(&es.svm, &es.vault); assert!(offer_rent > 0 && vault_rent > 0); @@ -304,8 +334,8 @@ fn test_escrow_make_and_take() { assert_eq!(lamports(&es.svm, &es.vault), 0); // Taker received Mint A; maker received Mint B. - assert_eq!(token_amount(&es.svm, &es.taker_account_a), AMOUNT_A); - assert_eq!(token_amount(&es.svm, &es.maker_account_b), AMOUNT_B); + assert_eq!(token_amount(&es.svm, &es.taker_account_a), TSLAX_OFFERED); + assert_eq!(token_amount(&es.svm, &es.maker_account_b), USDC_WANTED); // Rent destinations: the maker's lamports fully recover (the offer and // vault rent both come back to the maker). The taker only paid the rent @@ -335,7 +365,7 @@ fn test_escrow_make_and_cancel() { // maker. let make_ix = make_offer_instruction(&es); send(&mut es.svm, &payer, &[make_ix], &[&maker]); - assert_eq!(token_amount(&es.svm, &es.vault), AMOUNT_A); + assert_eq!(token_amount(&es.svm, &es.vault), TSLAX_OFFERED); let maker_ata_b_rent = lamports(&es.svm, &es.maker_account_b); assert!(maker_ata_b_rent > 0); @@ -376,53 +406,70 @@ fn test_cancel_offer_rejects_non_maker() { // match the signer, so the program must reject it. let cancel_ix = cancel_offer_instruction(&es, &es.taker.pubkey()); let result = try_send(&mut es.svm, &payer, &[cancel_ix], &[&taker]); - assert!( - result.is_err(), - "non-maker must not be able to cancel the offer" - ); + assert_fails_with(result, MAKER_MISMATCH); // The vault still holds the offered tokens. - assert_eq!(token_amount(&es.svm, &es.vault), AMOUNT_A); + assert_eq!(token_amount(&es.svm, &es.vault), TSLAX_OFFERED); } // `EscrowError` variants in declaration order, as the program reports them in // `InstructionError::Custom`. +const MAKER_MISMATCH: u32 = 2; const ZERO_AMOUNT: u32 = 7; const SAME_MINT: u32 = 8; const OFFER_TERMS_CHANGED: u32 = 9; -/// Send a `make_offer` the program should refuse, and check it failed with -/// `expected_code`, left the maker's tokens where they were, and created no -/// offer account. -fn assert_make_offer_refused(es: &mut EscrowSetup, instruction: Instruction, expected_code: u32) { - let payer = es.payer.insecure_clone(); - let maker = es.maker.insecure_clone(); - let result = try_send(&mut es.svm, &payer, &[instruction], &[&maker]); +/// Check that a refused transaction failed with `expected_code`, so the test +/// knows it failed for the check under test and not for some other reason. +fn assert_fails_with( + result: Result<(), Box>, + expected_code: u32, +) { let error = format!( "{:?}", - result.expect_err("make_offer should have failed").err + result.expect_err("transaction should have failed").err ); assert!( error.contains(&format!("Custom({expected_code})")), "expected error {expected_code}, got: {error}" ); - assert_eq!(token_amount(&es.svm, &es.maker_account_a), MINTED_AMOUNT); +} + +/// Send a `make_offer` the program should refuse, and check it failed with +/// `expected_code`, left the maker's tokens where they were, and created no +/// offer account. +fn assert_make_offer_refused(es: &mut EscrowSetup, instruction: Instruction, expected_code: u32) { + let payer = es.payer.insecure_clone(); + let maker = es.maker.insecure_clone(); + let result = try_send(&mut es.svm, &payer, &[instruction], &[&maker]); + assert_fails_with(result, expected_code); + assert_eq!(token_amount(&es.svm, &es.maker_account_a), MAKER_TSLAX); assert_eq!(lamports(&es.svm, &es.offer), 0); } #[test] fn test_make_offer_rejects_zero_offered_amount() { let mut es = setup(); - let instruction = - make_offer_instruction_for(&es, 0, AMOUNT_B, &es.mint_b.pubkey(), &es.maker_account_b); + let instruction = make_offer_instruction_for( + &es, + 0, + USDC_WANTED, + &es.mint_b.pubkey(), + &es.maker_account_b, + ); assert_make_offer_refused(&mut es, instruction, ZERO_AMOUNT); } #[test] fn test_make_offer_rejects_zero_wanted_amount() { let mut es = setup(); - let instruction = - make_offer_instruction_for(&es, AMOUNT_A, 0, &es.mint_b.pubkey(), &es.maker_account_b); + let instruction = make_offer_instruction_for( + &es, + TSLAX_OFFERED, + 0, + &es.mint_b.pubkey(), + &es.maker_account_b, + ); assert_make_offer_refused(&mut es, instruction, ZERO_AMOUNT); } @@ -433,8 +480,8 @@ fn test_make_offer_rejects_same_mint() { // account is their token-A account. let instruction = make_offer_instruction_for( &es, - AMOUNT_A, - AMOUNT_B, + TSLAX_OFFERED, + USDC_WANTED, &es.mint_a.pubkey(), &es.maker_account_a, ); @@ -457,7 +504,7 @@ fn assert_switched_offer_refused(switched_a_offered: u64, switched_b_wanted: u64 send(&mut es.svm, &payer, &[make_ix], &[&maker]); // The taker signs for the terms they saw. - let take_ix = take_offer_instruction_for(&es, AMOUNT_A, AMOUNT_B); + let take_ix = take_offer_instruction_for(&es, TSLAX_OFFERED, USDC_WANTED); // The maker switches the offer before the taker's transaction lands. let cancel_ix = cancel_offer_instruction(&es, &es.maker.pubkey()); @@ -472,18 +519,11 @@ fn assert_switched_offer_refused(switched_a_offered: u64, switched_b_wanted: u64 send(&mut es.svm, &payer, &[switched_make_ix], &[&maker]); let result = try_send(&mut es.svm, &payer, &[take_ix], &[&taker]); - let error = format!( - "{:?}", - result.expect_err("take_offer should have failed").err - ); - assert!( - error.contains(&format!("Custom({OFFER_TERMS_CHANGED})")), - "expected error {OFFER_TERMS_CHANGED}, got: {error}" - ); + assert_fails_with(result, OFFER_TERMS_CHANGED); assert_eq!( token_amount(&es.svm, &es.taker_account_b), - MINTED_AMOUNT, + TAKER_USDC, "the taker must not pay token B for a switched offer" ); assert_eq!( @@ -497,12 +537,12 @@ fn assert_switched_offer_refused(switched_a_offered: u64, switched_b_wanted: u64 #[test] fn test_take_offer_rejects_switched_offer() { - // The maker re-makes the offer with a thousandth of the token A. - assert_switched_offer_refused(AMOUNT_A / 1_000, AMOUNT_B); + // The maker re-makes the offer with a thousandth of the TSLAx. + assert_switched_offer_refused(TSLAX_OFFERED / 1_000, USDC_WANTED); } #[test] fn test_take_offer_rejects_switched_offer_wanting_more_token_b() { - // The maker re-makes the offer asking for twice the token B. - assert_switched_offer_refused(AMOUNT_A, 2 * AMOUNT_B); + // The maker re-makes the offer asking for twice the USDC. + assert_switched_offer_refused(TSLAX_OFFERED, 2 * USDC_WANTED); } diff --git a/finance/escrow/quasar/CHANGELOG.md b/finance/escrow/quasar/CHANGELOG.md index ac61d7bc..e9c3fb6d 100644 --- a/finance/escrow/quasar/CHANGELOG.md +++ b/finance/escrow/quasar/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [Unreleased, 2026-10-05] + +### Changed + +- The tests tell the book's story: `TSLAX_MINT` is minted at 8 decimals, + `USDC_MINT` at 6, and the maker offers 1 TSLAx (`TSLAX_OFFERED`, + 100,000,000 minor units) for 1,000 USDC (`USDC_WANTED`, 1,000,000,000 minor + units). The substituted-mint, substituted-vault and non-maker-cancel tests + assert `QuasarError::HasOneMismatch` (3005), so every refusal test asserts + its error code. + ## [2026-10-04] ### Fixed diff --git a/finance/escrow/quasar/README.md b/finance/escrow/quasar/README.md index d428e2bb..214bf615 100644 --- a/finance/escrow/quasar/README.md +++ b/finance/escrow/quasar/README.md @@ -24,13 +24,14 @@ Prerequisites: [Quasar](https://quasar-lang.com/docs) CLI and [Agave](https://do ## Testing -In-process tests via **Quasar SVM** (`quasar-svm` in `Quasar.toml`): +In-process tests with the `quasar-test` harness (`cargo test`, the command `Quasar.toml` names): ```bash +quasar build cargo test ``` -Tests invoke instruction handlers and assert onchain state. No local validator. +Tests invoke instruction handlers and assert onchain state. No local validator. They tell one story: token A is TSLAx, minted at 8 decimals, token B is USDC at 6, and the maker offers 1 TSLAx (`TSLAX_OFFERED`, 100,000,000 minor units) for 1,000 USDC (`USDC_WANTED`, 1,000,000,000 minor units). They cover the make, take and cancel flows with their rent refunds, a take that lands on an offer the maker cancelled and re-made at worse terms (`OfferTermsChanged`), offers with zero on either side (`ZeroAmount`) or one token on both (`AccountBorrowFailed`), and a substituted mint, a substituted vault and a signer who is not the maker (each `HasOneMismatch`). Every refusal test asserts the error code it expects. ## Usage diff --git a/finance/escrow/quasar/src/tests.rs b/finance/escrow/quasar/src/tests.rs index be43cf3c..c202248f 100644 --- a/finance/escrow/quasar/src/tests.rs +++ b/finance/escrow/quasar/src/tests.rs @@ -9,14 +9,15 @@ use { error::EscrowError, state::{Offer, OfferData}, }, + quasar_lang::error::QuasarError, quasar_test::prelude::*, }; // Deterministic addresses keep tests independent of discovery order. const MAKER: Pubkey = Pubkey::new_from_array([1; 32]); const TAKER: Pubkey = Pubkey::new_from_array([2; 32]); -const TOKEN_MINT_A: Pubkey = Pubkey::new_from_array([3; 32]); -const TOKEN_MINT_B: Pubkey = Pubkey::new_from_array([4; 32]); +const TSLAX_MINT: Pubkey = Pubkey::new_from_array([3; 32]); +const USDC_MINT: Pubkey = Pubkey::new_from_array([4; 32]); const MAKER_TOKEN_ACCOUNT_A: Pubkey = Pubkey::new_from_array([5; 32]); const MAKER_TOKEN_ACCOUNT_B: Pubkey = Pubkey::new_from_array([6; 32]); const VAULT: Pubkey = Pubkey::new_from_array([7; 32]); @@ -28,27 +29,39 @@ const WRONG_MINT: Pubkey = Pubkey::new_from_array([12; 32]); const WRONG_VAULT: Pubkey = Pubkey::new_from_array([13; 32]); const OFFER_ID: u64 = 7; -const DEPOSIT_AMOUNT: u64 = 1337; -const RECEIVE_AMOUNT: u64 = 1337; -/// Register the maker and both mints. +// The story the tests tell: the maker offers 1 TSLAx and wants 1,000 USDC for +// it. TSLAx has 8 decimals and USDC has 6, so every amount below is in those +// minor units. +const TSLAX_DECIMALS: u8 = 8; +const USDC_DECIMALS: u8 = 6; +const ONE_TSLAX: u64 = 10u64.pow(TSLAX_DECIMALS as u32); +const ONE_USDC: u64 = 10u64.pow(USDC_DECIMALS as u32); +const TSLAX_OFFERED: u64 = ONE_TSLAX; +const USDC_WANTED: u64 = 1_000 * ONE_USDC; +// What each side holds before the offer: the maker 10 TSLAx, the taker +// 10,000 USDC. Each mint's supply is what its holder was given. +const MAKER_TSLAX: u64 = 10 * ONE_TSLAX; +const TAKER_USDC: u64 = 10_000 * ONE_USDC; + +/// Register the maker and both mints: TSLAx at 8 decimals and USDC at 6. fn base_world(test: &mut Test) { test.add(Wallet::new().at(MAKER)); test.add( Mint::new(MAKER) - .at(TOKEN_MINT_A) - .supply(1_000_000_000) - .decimals(9), + .at(TSLAX_MINT) + .supply(MAKER_TSLAX) + .decimals(TSLAX_DECIMALS), ); test.add( Mint::new(MAKER) - .at(TOKEN_MINT_B) - .supply(1_000_000_000) - .decimals(9), + .at(USDC_MINT) + .supply(TAKER_USDC) + .decimals(USDC_DECIMALS), ); } -/// Register a live offer holding `DEPOSIT_AMOUNT` vault tokens, exactly as +/// Register a live offer holding `TSLAX_OFFERED` in the vault, exactly as /// `make_offer` leaves it. fn live_offer(test: &mut Test) -> Pubkey { let (offer, bump) = test.derive_pda_with_bump(Offer::seeds(&MAKER, OFFER_ID)); @@ -57,18 +70,18 @@ fn live_offer(test: &mut Test) -> Pubkey { OfferData { id: OFFER_ID.into(), maker: MAKER, - token_mint_a: TOKEN_MINT_A, - token_mint_b: TOKEN_MINT_B, + token_mint_a: TSLAX_MINT, + token_mint_b: USDC_MINT, maker_token_account_b: MAKER_TOKEN_ACCOUNT_B, vault: VAULT, - receive: RECEIVE_AMOUNT.into(), + receive: USDC_WANTED.into(), bump, }, ); test.add( - TokenAccount::new(TOKEN_MINT_A, offer) + TokenAccount::new(TSLAX_MINT, offer) .at(VAULT) - .amount(DEPOSIT_AMOUNT), + .amount(TSLAX_OFFERED), ); offer } @@ -77,39 +90,39 @@ fn live_offer(test: &mut Test) -> Pubkey { fn make_offer_records_the_offer_and_funds_the_vault(test: &mut Test) { base_world(test); test.add( - TokenAccount::new(TOKEN_MINT_A, MAKER) + TokenAccount::new(TSLAX_MINT, MAKER) .at(MAKER_TOKEN_ACCOUNT_A) - .amount(1_000_000), + .amount(MAKER_TSLAX), ); let (offer, bump) = test.derive_pda_with_bump(Offer::seeds(&MAKER, OFFER_ID)); test.send(MakeOfferInstruction { maker: MAKER, - token_mint_a: TOKEN_MINT_A, - token_mint_b: TOKEN_MINT_B, + token_mint_a: TSLAX_MINT, + token_mint_b: USDC_MINT, maker_token_account_a: MAKER_TOKEN_ACCOUNT_A, maker_token_account_b: MAKER_TOKEN_ACCOUNT_B, vault: VAULT, id: OFFER_ID, - deposit: DEPOSIT_AMOUNT, - receive: RECEIVE_AMOUNT, + deposit: TSLAX_OFFERED, + receive: USDC_WANTED, }) .succeeds() // The deposit landed in the vault. - .has_tokens(VAULT, DEPOSIT_AMOUNT); + .has_tokens(VAULT, TSLAX_OFFERED); // Verify the recorded offer state. let state = test.read::(offer); assert_eq!(u64::from(state.id), OFFER_ID, "id"); assert_eq!(state.maker, MAKER, "maker"); - assert_eq!(state.token_mint_a, TOKEN_MINT_A, "token_mint_a"); - assert_eq!(state.token_mint_b, TOKEN_MINT_B, "token_mint_b"); + assert_eq!(state.token_mint_a, TSLAX_MINT, "token_mint_a"); + assert_eq!(state.token_mint_b, USDC_MINT, "token_mint_b"); assert_eq!( state.maker_token_account_b, MAKER_TOKEN_ACCOUNT_B, "maker_token_account_b" ); assert_eq!(state.vault, VAULT, "vault"); - assert_eq!(u64::from(state.receive), RECEIVE_AMOUNT, "receive"); + assert_eq!(u64::from(state.receive), USDC_WANTED, "receive"); assert_eq!(state.bump, bump, "bump"); } @@ -119,11 +132,11 @@ fn take_offer_swaps_tokens_and_returns_rent_to_the_maker(test: &mut Test) { test.add(Wallet::new().at(TAKER)); let offer = live_offer(test); test.add( - TokenAccount::new(TOKEN_MINT_B, TAKER) + TokenAccount::new(USDC_MINT, TAKER) .at(TAKER_TOKEN_ACCOUNT_B) - .amount(10_000), + .amount(TAKER_USDC), ); - test.add(TokenAccount::new(TOKEN_MINT_B, MAKER).at(MAKER_TOKEN_ACCOUNT_B)); + test.add(TokenAccount::new(USDC_MINT, MAKER).at(MAKER_TOKEN_ACCOUNT_B)); // Rent destinations are asserted exactly: the maker paid the offer and // vault rent in make_offer and must recover both on close. @@ -136,20 +149,20 @@ fn take_offer_swaps_tokens_and_returns_rent_to_the_maker(test: &mut Test) { taker: TAKER, offer_id_seed: OFFER_ID, maker: MAKER, - token_mint_a: TOKEN_MINT_A, - token_mint_b: TOKEN_MINT_B, + token_mint_a: TSLAX_MINT, + token_mint_b: USDC_MINT, taker_token_account_a: TAKER_TOKEN_ACCOUNT_A, taker_token_account_b: TAKER_TOKEN_ACCOUNT_B, maker_token_account_b: MAKER_TOKEN_ACCOUNT_B, vault: VAULT, - minimum_token_a_out: DEPOSIT_AMOUNT, - maximum_token_b_in: RECEIVE_AMOUNT, + minimum_token_a_out: TSLAX_OFFERED, + maximum_token_b_in: USDC_WANTED, }) .succeeds() // Token balances: the taker received the vault's mint A, the maker // received the wanted mint B. - .has_tokens(TAKER_TOKEN_ACCOUNT_A, DEPOSIT_AMOUNT) - .has_tokens(MAKER_TOKEN_ACCOUNT_B, RECEIVE_AMOUNT) + .has_tokens(TAKER_TOKEN_ACCOUNT_A, TSLAX_OFFERED) + .has_tokens(MAKER_TOKEN_ACCOUNT_B, USDC_WANTED) // The offer and vault are closed. .is_closed(offer) .is_closed(VAULT); @@ -171,38 +184,36 @@ fn take_offer_rejects_a_mint_that_does_not_match_the_offer(test: &mut Test) { test.add(Wallet::new().at(TAKER)); live_offer(test); test.add( - TokenAccount::new(TOKEN_MINT_B, TAKER) + TokenAccount::new(USDC_MINT, TAKER) .at(TAKER_TOKEN_ACCOUNT_B) - .amount(10_000), + .amount(TAKER_USDC), ); - test.add(TokenAccount::new(TOKEN_MINT_B, MAKER).at(MAKER_TOKEN_ACCOUNT_B)); + test.add(TokenAccount::new(USDC_MINT, MAKER).at(MAKER_TOKEN_ACCOUNT_B)); - // The attacker substitutes a different mint for token_mint_a. The - // has_one(token_mint_a) binding to the offer state must reject it. + // The attacker substitutes a different mint, with TSLAx's decimals, for + // token_mint_a. The has_one(token_mint_a) binding to the offer state + // refuses it. test.add( Mint::new(MAKER) .at(WRONG_MINT) - .supply(1_000_000_000) - .decimals(9), + .supply(MAKER_TSLAX) + .decimals(TSLAX_DECIMALS), ); - let result = test.send(TakeOfferInstruction { + test.send(TakeOfferInstruction { taker: TAKER, offer_id_seed: OFFER_ID, maker: MAKER, token_mint_a: WRONG_MINT, - token_mint_b: TOKEN_MINT_B, + token_mint_b: USDC_MINT, taker_token_account_a: TAKER_TOKEN_ACCOUNT_A, taker_token_account_b: TAKER_TOKEN_ACCOUNT_B, maker_token_account_b: MAKER_TOKEN_ACCOUNT_B, vault: VAULT, - minimum_token_a_out: DEPOSIT_AMOUNT, - maximum_token_b_in: RECEIVE_AMOUNT, - }); - assert!( - result.is_err(), - "take_offer must reject a mint that does not match the offer state" - ); + minimum_token_a_out: TSLAX_OFFERED, + maximum_token_b_in: USDC_WANTED, + }) + .fails_with(QuasarError::HasOneMismatch); } #[quasar_test] @@ -211,38 +222,35 @@ fn take_offer_rejects_a_vault_that_does_not_match_the_offer(test: &mut Test) { test.add(Wallet::new().at(TAKER)); let offer = live_offer(test); test.add( - TokenAccount::new(TOKEN_MINT_B, TAKER) + TokenAccount::new(USDC_MINT, TAKER) .at(TAKER_TOKEN_ACCOUNT_B) - .amount(10_000), + .amount(TAKER_USDC), ); - test.add(TokenAccount::new(TOKEN_MINT_B, MAKER).at(MAKER_TOKEN_ACCOUNT_B)); + test.add(TokenAccount::new(USDC_MINT, MAKER).at(MAKER_TOKEN_ACCOUNT_B)); // The attacker substitutes a different token account (same mint, also // owned by the offer PDA) for the vault. The has_one(vault) binding to - // the offer state must reject it. + // the offer state refuses it. test.add( - TokenAccount::new(TOKEN_MINT_A, offer) + TokenAccount::new(TSLAX_MINT, offer) .at(WRONG_VAULT) - .amount(DEPOSIT_AMOUNT), + .amount(TSLAX_OFFERED), ); - let result = test.send(TakeOfferInstruction { + test.send(TakeOfferInstruction { taker: TAKER, offer_id_seed: OFFER_ID, maker: MAKER, - token_mint_a: TOKEN_MINT_A, - token_mint_b: TOKEN_MINT_B, + token_mint_a: TSLAX_MINT, + token_mint_b: USDC_MINT, taker_token_account_a: TAKER_TOKEN_ACCOUNT_A, taker_token_account_b: TAKER_TOKEN_ACCOUNT_B, maker_token_account_b: MAKER_TOKEN_ACCOUNT_B, vault: WRONG_VAULT, - minimum_token_a_out: DEPOSIT_AMOUNT, - maximum_token_b_in: RECEIVE_AMOUNT, - }); - assert!( - result.is_err(), - "take_offer must reject a vault that does not match the offer state" - ); + minimum_token_a_out: TSLAX_OFFERED, + maximum_token_b_in: USDC_WANTED, + }) + .fails_with(QuasarError::HasOneMismatch); } #[quasar_test] @@ -251,7 +259,7 @@ fn cancel_offer_returns_deposit_and_rent_to_the_maker(test: &mut Test) { let offer = live_offer(test); // Pre-created with a zero balance so the maker's tokens can be compared // exactly after the cancel. - test.add(TokenAccount::new(TOKEN_MINT_A, MAKER).at(MAKER_TOKEN_ACCOUNT_A)); + test.add(TokenAccount::new(TSLAX_MINT, MAKER).at(MAKER_TOKEN_ACCOUNT_A)); let offer_rent = test.lamports(offer); let vault_rent = test.lamports(VAULT); @@ -260,13 +268,13 @@ fn cancel_offer_returns_deposit_and_rent_to_the_maker(test: &mut Test) { test.send(CancelOfferInstruction { maker: MAKER, offer_id_seed: OFFER_ID, - token_mint_a: TOKEN_MINT_A, + token_mint_a: TSLAX_MINT, maker_token_account_a: MAKER_TOKEN_ACCOUNT_A, vault: VAULT, }) .succeeds() // The maker got their mint A tokens back. - .has_tokens(MAKER_TOKEN_ACCOUNT_A, DEPOSIT_AMOUNT) + .has_tokens(MAKER_TOKEN_ACCOUNT_A, TSLAX_OFFERED) // The offer and vault are closed. .is_closed(offer) .is_closed(VAULT); @@ -283,16 +291,17 @@ fn cancel_offer_rejects_a_signer_who_is_not_the_maker(test: &mut Test) { base_world(test); test.add(Wallet::new().at(ATTACKER)); let offer = live_offer(test); - test.add(TokenAccount::new(TOKEN_MINT_A, ATTACKER).at(ATTACKER_TOKEN_ACCOUNT_A)); + test.add(TokenAccount::new(TSLAX_MINT, ATTACKER).at(ATTACKER_TOKEN_ACCOUNT_A)); // The attacker signs as the "maker" but passes the real maker's offer. - // has_one(maker) and the offer's PDA seeds both fail to match. The - // builder would derive the attacker's own (nonexistent) offer PDA, so - // the real offer address is substituted at the instruction level. + // The offer's PDA seeds no longer derive its address with the attacker + // as maker, so the PDA check refuses it before has_one(maker) runs. The + // builder would derive the attacker's own (nonexistent) offer PDA, so the + // real offer address is substituted at the instruction level. let mut ix: Instruction = CancelOfferInstruction { maker: ATTACKER, offer_id_seed: OFFER_ID, - token_mint_a: TOKEN_MINT_A, + token_mint_a: TSLAX_MINT, maker_token_account_a: ATTACKER_TOKEN_ACCOUNT_A, vault: VAULT, } @@ -300,11 +309,7 @@ fn cancel_offer_rejects_a_signer_who_is_not_the_maker(test: &mut Test) { // Account order = the accounts-struct field order: maker, offer, ... ix.accounts[1].pubkey = offer; - let result = test.send(ix); - assert!( - result.is_err(), - "cancel_offer must reject a signer who is not the offer's maker" - ); + test.send(ix).fails_with(QuasarError::InvalidPda); } /// Send `make_offer` for the given amounts and wanted token, from a maker who @@ -312,7 +317,7 @@ fn cancel_offer_rejects_a_signer_who_is_not_the_maker(test: &mut Test) { fn make_offer(test: &mut Test, deposit: u64, receive: u64, token_mint_b: Pubkey) -> Outcome { test.send(MakeOfferInstruction { maker: MAKER, - token_mint_a: TOKEN_MINT_A, + token_mint_a: TSLAX_MINT, token_mint_b, maker_token_account_a: MAKER_TOKEN_ACCOUNT_A, maker_token_account_b: MAKER_TOKEN_ACCOUNT_B, @@ -326,22 +331,22 @@ fn make_offer(test: &mut Test, deposit: u64, receive: u64, token_mint_b: Pubkey) fn maker_holding_token_a(test: &mut Test) { base_world(test); test.add( - TokenAccount::new(TOKEN_MINT_A, MAKER) + TokenAccount::new(TSLAX_MINT, MAKER) .at(MAKER_TOKEN_ACCOUNT_A) - .amount(1_000_000), + .amount(MAKER_TSLAX), ); } #[quasar_test] fn make_offer_rejects_a_zero_deposit(test: &mut Test) { maker_holding_token_a(test); - make_offer(test, 0, RECEIVE_AMOUNT, TOKEN_MINT_B).fails_with(EscrowError::ZeroAmount); + make_offer(test, 0, USDC_WANTED, USDC_MINT).fails_with(EscrowError::ZeroAmount); } #[quasar_test] fn make_offer_rejects_a_zero_receive_amount(test: &mut Test) { maker_holding_token_a(test); - make_offer(test, DEPOSIT_AMOUNT, 0, TOKEN_MINT_B).fails_with(EscrowError::ZeroAmount); + make_offer(test, TSLAX_OFFERED, 0, USDC_MINT).fails_with(EscrowError::ZeroAmount); } #[quasar_test] @@ -349,7 +354,7 @@ fn make_offer_rejects_an_offer_of_a_token_for_itself(test: &mut Test) { maker_holding_token_a(test); // Both mint slots hold the same account, and loading it twice fails // before the handler runs. - make_offer(test, DEPOSIT_AMOUNT, RECEIVE_AMOUNT, TOKEN_MINT_A) + make_offer(test, TSLAX_OFFERED, USDC_WANTED, TSLAX_MINT) .fails(ProgramError::Runtime("AccountBorrowFailed".into())); } @@ -359,8 +364,8 @@ fn take_offer_instruction(minimum_token_a_out: u64, maximum_token_b_in: u64) -> taker: TAKER, offer_id_seed: OFFER_ID, maker: MAKER, - token_mint_a: TOKEN_MINT_A, - token_mint_b: TOKEN_MINT_B, + token_mint_a: TSLAX_MINT, + token_mint_b: USDC_MINT, taker_token_account_a: TAKER_TOKEN_ACCOUNT_A, taker_token_account_b: TAKER_TOKEN_ACCOUNT_B, maker_token_account_b: MAKER_TOKEN_ACCOUNT_B, @@ -381,38 +386,38 @@ fn assert_switched_offer_refused(test: &mut Test, switched_deposit: u64, switche maker_holding_token_a(test); test.add(Wallet::new().at(TAKER)); test.add( - TokenAccount::new(TOKEN_MINT_B, TAKER) + TokenAccount::new(USDC_MINT, TAKER) .at(TAKER_TOKEN_ACCOUNT_B) - .amount(10_000), + .amount(TAKER_USDC), ); - make_offer(test, DEPOSIT_AMOUNT, RECEIVE_AMOUNT, TOKEN_MINT_B).succeeds(); + make_offer(test, TSLAX_OFFERED, USDC_WANTED, USDC_MINT).succeeds(); // The taker signs for the terms they saw. - let take = take_offer_instruction(DEPOSIT_AMOUNT, RECEIVE_AMOUNT); + let take = take_offer_instruction(TSLAX_OFFERED, USDC_WANTED); // The maker switches the offer before the taker's transaction lands. test.send(CancelOfferInstruction { maker: MAKER, offer_id_seed: OFFER_ID, - token_mint_a: TOKEN_MINT_A, + token_mint_a: TSLAX_MINT, maker_token_account_a: MAKER_TOKEN_ACCOUNT_A, vault: VAULT, }) .succeeds(); - make_offer(test, switched_deposit, switched_receive, TOKEN_MINT_B).succeeds(); + make_offer(test, switched_deposit, switched_receive, USDC_MINT).succeeds(); test.send(take).fails_with(EscrowError::OfferTermsChanged); assert_eq!( test.tokens(TAKER_TOKEN_ACCOUNT_B), - 10_000, - "the taker must not pay token B for a switched offer" + TAKER_USDC, + "the taker must not pay USDC for a switched offer" ); assert!( test.account(TAKER_TOKEN_ACCOUNT_A) .is_none_or(|account| account.lamports == 0), - "the taker must receive no token A from a switched offer" + "the taker must receive no TSLAx from a switched offer" ); assert_eq!(test.tokens(MAKER_TOKEN_ACCOUNT_B), 0); assert_eq!(test.tokens(VAULT), switched_deposit); @@ -420,12 +425,12 @@ fn assert_switched_offer_refused(test: &mut Test, switched_deposit: u64, switche #[quasar_test] fn test_take_offer_rejects_switched_offer(test: &mut Test) { - // The maker re-makes the offer with a single token A in the vault. - assert_switched_offer_refused(test, 1, RECEIVE_AMOUNT); + // The maker re-makes the offer with a thousandth of the TSLAx in the vault. + assert_switched_offer_refused(test, TSLAX_OFFERED / 1_000, USDC_WANTED); } #[quasar_test] fn test_take_offer_rejects_switched_offer_wanting_more_token_b(test: &mut Test) { - // The maker re-makes the offer asking for twice the token B. - assert_switched_offer_refused(test, DEPOSIT_AMOUNT, 2 * RECEIVE_AMOUNT); + // The maker re-makes the offer asking for twice the USDC. + assert_switched_offer_refused(test, TSLAX_OFFERED, 2 * USDC_WANTED); } diff --git a/finance/lending/anchor-v1/CHANGELOG.md b/finance/lending/anchor-v1/CHANGELOG.md index 705e9136..16fe39b5 100644 --- a/finance/lending/anchor-v1/CHANGELOG.md +++ b/finance/lending/anchor-v1/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## Unreleased (2026-10-05) + +The program fee on accrued interest rounds up. `accrue_interest` computes the +reserve factor's cut of each accrual with `mul_div_ceil`, so when the cut is +not whole the extra unit goes to the market owner, and the suppliers take the +remainder; fee and remainder sum to the interest and never exceed it. A fee is +the program's cut and rounds against the user, as every fee in these programs +does. `program_fees_accrue_and_owner_can_collect` asserts the fee equals the +interest times the reserve factor, rounded up, and the new +`program_fee_rounds_up_and_suppliers_take_the_remainder` accrues one second of +interest on a 500-unit borrow (3 units) and checks the fee is 1 and the +suppliers' pool grows by 2. The Kani crate gains +`proof_program_fee_rounds_up_within_interest`, which checks the fee never +exceeds the interest for any reserve factor up to 100%. + +`non_owner_cannot_write_market_price_feed` asserts the constraint error the +refusal raises (`ConstraintHasOne`), so every refusal test in the suite names +its error code. + ## Unreleased (2026-10-04) Refuse a price the oracle itself is unsure of. `PriceFeed` gains a diff --git a/finance/lending/anchor-v1/README.md b/finance/lending/anchor-v1/README.md index 5c46e465..f89c5c3d 100644 --- a/finance/lending/anchor-v1/README.md +++ b/finance/lending/anchor-v1/README.md @@ -107,11 +107,15 @@ read the reserve's value require the refresh to have run in the current slot. Borrowers owe the full interest, but suppliers don't receive all of it. On each accrual the reserve keeps `config.reserve_factor_bps` of the freshly accrued -interest in `accumulated_program_fees`; only the remainder lifts the supplier -exchange rate. Those fees are carved out of `total_liquidity`, so they never -count as a supplier claim, and the market owner withdraws them with -**`collect_program_fees`** (paid out of the reserve's available liquidity). -This spread between the borrow rate and the supply rate is the program's revenue. +interest in `accumulated_program_fees`, rounded up (`mul_div_ceil`), since a +fee is the program's cut and rounds against the user; only the remainder lifts +the supplier exchange rate, so the two parts sum to the interest and never +exceed it (`program_fee_rounds_up_and_suppliers_take_the_remainder` checks a +second's interest of 3 units, of which the fee is 1). Those fees are carved out +of `total_liquidity`, so they never count as a supplier claim, and the market +owner withdraws them with **`collect_program_fees`** (paid out of the reserve's +available liquidity). This spread between the borrow rate and the supply rate +is the program's revenue. ### Obligation health @@ -139,7 +143,8 @@ less, which would make the liquidator overpay. All arithmetic is integer-only `u128`: no floats, no fixed-point crates. Ratios (rates, the index, the exchange rate, obligation values) are scaled by `FIXED_POINT_SCALE` (10^18). Every conversion rounds in the program's favour -(user output floored, debt ceiled), so dust cannot be extracted by repeated +(user output floored, debt and the program fee ceiled), so dust cannot be +extracted by repeated round-trips; `deposit_redeem_round_trip_creates_no_value` checks this by depositing and redeeming 777,777,777 units fifty times against a reserve whose exchange rate interest has moved off one-to-one, and asserts the supplier never 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 3a00e50f..bb2aa242 100644 --- a/finance/lending/anchor-v1/programs/lending/src/state/reserve.rs +++ b/finance/lending/anchor-v1/programs/lending/src/state/reserve.rs @@ -280,12 +280,14 @@ impl Reserve { // Borrowers owe the full interest (the factor grew for all of it); the // program keeps `reserve_factor_bps` of the newly accrued interest, - // and the remainder lifts the supplier exchange rate. Flooring the fee - // rounds the owner's cut down, in the suppliers' favour. + // and the remainder lifts the supplier exchange rate. The fee rounds + // up, in the owner's favour: a fee is the program's cut and so rounds + // against the user, and the suppliers take what is left, so the two + // parts sum to the interest and never exceed it. let interest = self .current_borrowed_amount()? .saturating_sub(borrowed_before); - let fee = mul_div_floor( + let fee = mul_div_ceil( interest as u128, self.config.reserve_factor_bps as u128, BPS_DENOMINATOR, diff --git a/finance/lending/anchor-v1/programs/lending/tests/test_interest.rs b/finance/lending/anchor-v1/programs/lending/tests/test_interest.rs index 5115bdb5..fb99735a 100644 --- a/finance/lending/anchor-v1/programs/lending/tests/test_interest.rs +++ b/finance/lending/anchor-v1/programs/lending/tests/test_interest.rs @@ -106,16 +106,15 @@ fn program_fees_accrue_and_owner_can_collect() { env.warp_seconds(TENTH_OF_A_YEAR); env.refresh_reserve_only(&borrower, &borrow); - // Fees accrued, and they are ~10% (the reserve factor) of total interest. + // Fees accrued: 10% (the reserve factor) of the interest, rounded up. let reserve = env.reserve(&borrow); let fees = reserve.accumulated_program_fees; assert!(fees > 0, "program fees should accrue once interest does"); let total_interest = reserve.current_borrowed_amount().unwrap() - 500_000_000; - let expected_fee = total_interest / 10; // 1000 bps = 10% - // Allow a 1-unit rounding tolerance from flooring. - assert!( - fees.abs_diff(expected_fee) <= 1, - "fees {fees} should be ~10% of interest {total_interest}" + let expected_fee = total_interest.div_ceil(10); // 1000 bps = 10% + assert_eq!( + fees, expected_fee, + "fees {fees} should be 10% of interest {total_interest}, rounded up" ); // Maria withdraws the fees to her own account. @@ -123,3 +122,52 @@ fn program_fees_accrue_and_owner_can_collect() { assert_eq!(env.token_balance(owner_account), fees); assert_eq!(env.reserve(&borrow).accumulated_program_fees, 0); } + +/// The program fee is the program's cut of the interest, so it rounds against +/// the user: up. One second of interest on a 500-unit borrow at the default +/// curve is 3 units, a tenth of which is not whole; the fee is 1, not 0, and +/// the suppliers' pool grows by the other 2, so fee and remainder sum to the +/// interest and never exceed it. +#[test] +fn program_fee_rounds_up_and_suppliers_take_the_remainder() { + let mut env = Env::new(); + let collateral = env.add_reserve(6, dollars(1), default_config()); + let borrow = env.add_reserve(6, dollars(1), default_config()); + + let supplier = env.create_user(); + env.fund(&supplier, borrow.mint, 1_000_000_000); + env.supply(&supplier, &borrow, 1_000_000_000); + + let borrower = env.create_user(); + env.fund(&borrower, collateral.mint, 1_000_000_000); + env.fund(&borrower, borrow.mint, 0); + env.supply(&borrower, &collateral, 1_000_000_000); + let obligation = env.initialize_obligation(&borrower); + env.post_collateral(&borrower, obligation, &collateral, 1_000_000_000); + env.try_borrow( + &borrower, + obligation, + &[&collateral], + &[], + &borrow, + 500_000_000, + ) + .unwrap(); + let before = env.reserve(&borrow); + let debt_before = before.current_borrowed_amount().unwrap(); + let suppliers_before = before.total_liquidity().unwrap(); + + env.warp_seconds(1); + env.refresh_reserve_only(&borrower, &borrow); + + let after = env.reserve(&borrow); + let interest = after.current_borrowed_amount().unwrap() - debt_before; + assert_eq!(interest, 3, "one second of interest on the 500-unit borrow"); + let fee = after.accumulated_program_fees; + assert_eq!(fee, 1, "a tenth of 3 units rounds up to 1 for the program"); + assert_eq!( + after.total_liquidity().unwrap(), + suppliers_before + (interest - fee) as u128, + "the suppliers' pool grows by the interest less the fee" + ); +} 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 5d41185d..4cbb0239 100644 --- a/finance/lending/anchor-v1/programs/lending/tests/test_security.rs +++ b/finance/lending/anchor-v1/programs/lending/tests/test_security.rs @@ -73,8 +73,10 @@ fn non_owner_cannot_write_market_price_feed() { &[&attacker], &attacker.pubkey(), ); + // `SetPrice` binds the signer with `has_one = owner` on the market, so + // the refusal is Anchor's constraint error, not one of the program's. assert!( - result.is_err(), + format!("{:?}", result.unwrap_err()).contains("ConstraintHasOne"), "only the market owner may write its price feed" ); } diff --git a/finance/lending/anchor/CHANGELOG.md b/finance/lending/anchor/CHANGELOG.md index 705e9136..aa698d8e 100644 --- a/finance/lending/anchor/CHANGELOG.md +++ b/finance/lending/anchor/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## Unreleased (2026-10-05) + +The program fee on accrued interest rounds up. `accrue_interest` computes the +reserve factor's cut of each accrual with `mul_div_ceil`, so when the cut is +not whole the extra unit goes to the market owner, and the suppliers take the +remainder; fee and remainder sum to the interest and never exceed it. A fee is +the program's cut and rounds against the user, as every fee in these programs +does. `program_fees_accrue_and_owner_can_collect` asserts the fee equals the +interest times the reserve factor, rounded up, and the new +`program_fee_rounds_up_and_suppliers_take_the_remainder` accrues one second of +interest on a 500-unit borrow (3 units) and checks the fee is 1 and the +suppliers' pool grows by 2. The Kani crate gains +`proof_program_fee_rounds_up_within_interest`, which checks the fee never +exceeds the interest for any reserve factor up to 100%. + +`non_owner_cannot_write_market_price_feed` asserts the constraint error the +refusal raises (`ConstraintAddress`, by its code), so every refusal test in the suite names +its error code. + ## Unreleased (2026-10-04) Refuse a price the oracle itself is unsure of. `PriceFeed` gains a diff --git a/finance/lending/anchor/README.md b/finance/lending/anchor/README.md index 49cb4947..22f683b2 100644 --- a/finance/lending/anchor/README.md +++ b/finance/lending/anchor/README.md @@ -107,11 +107,15 @@ read the reserve's value require the refresh to have run in the current slot. Borrowers owe the full interest, but suppliers don't receive all of it. On each accrual the reserve keeps `config.reserve_factor_bps` of the freshly accrued -interest in `accumulated_program_fees`; only the remainder lifts the supplier -exchange rate. Those fees are carved out of `total_liquidity`, so they never -count as a supplier claim, and the market owner withdraws them with -**`collect_program_fees`** (paid out of the reserve's available liquidity). -This spread between the borrow rate and the supply rate is the program's revenue. +interest in `accumulated_program_fees`, rounded up (`mul_div_ceil`), since a +fee is the program's cut and rounds against the user; only the remainder lifts +the supplier exchange rate, so the two parts sum to the interest and never +exceed it (`program_fee_rounds_up_and_suppliers_take_the_remainder` checks a +second's interest of 3 units, of which the fee is 1). Those fees are carved out +of `total_liquidity`, so they never count as a supplier claim, and the market +owner withdraws them with **`collect_program_fees`** (paid out of the reserve's +available liquidity). This spread between the borrow rate and the supply rate +is the program's revenue. ### Obligation health @@ -139,7 +143,8 @@ less, which would make the liquidator overpay. All arithmetic is integer-only `u128`: no floats, no fixed-point crates. Ratios (rates, the index, the exchange rate, obligation values) are scaled by `FIXED_POINT_SCALE` (10^18). Every conversion rounds in the program's favour -(user output floored, debt ceiled), so dust cannot be extracted by repeated +(user output floored, debt and the program fee ceiled), so dust cannot be +extracted by repeated round-trips; `deposit_redeem_round_trip_creates_no_value` checks this by depositing and redeeming 777,777,777 units fifty times against a reserve whose exchange rate interest has moved off one-to-one, and asserts the supplier never diff --git a/finance/lending/anchor/programs/lending/src/state/reserve.rs b/finance/lending/anchor/programs/lending/src/state/reserve.rs index be00ad7a..4212af24 100644 --- a/finance/lending/anchor/programs/lending/src/state/reserve.rs +++ b/finance/lending/anchor/programs/lending/src/state/reserve.rs @@ -282,12 +282,14 @@ impl Reserve { // Borrowers owe the full interest (the factor grew for all of it); the // program keeps `reserve_factor_bps` of the newly accrued interest, - // and the remainder lifts the supplier exchange rate. Flooring the fee - // rounds the owner's cut down, in the suppliers' favour. + // and the remainder lifts the supplier exchange rate. The fee rounds + // up, in the owner's favour: a fee is the program's cut and so rounds + // against the user, and the suppliers take what is left, so the two + // parts sum to the interest and never exceed it. let interest = self .current_borrowed_amount()? .saturating_sub(borrowed_before); - let fee = mul_div_floor( + let fee = mul_div_ceil( interest as u128, self.config.reserve_factor_bps as u128, BPS_DENOMINATOR, diff --git a/finance/lending/anchor/programs/lending/tests/test_interest.rs b/finance/lending/anchor/programs/lending/tests/test_interest.rs index c80f8ace..f9d4f3a0 100644 --- a/finance/lending/anchor/programs/lending/tests/test_interest.rs +++ b/finance/lending/anchor/programs/lending/tests/test_interest.rs @@ -106,16 +106,15 @@ fn program_fees_accrue_and_owner_can_collect() { env.warp_seconds(TENTH_OF_A_YEAR); env.refresh_reserve_only(&borrower, &borrow); - // Fees accrued, and they are ~10% (the reserve factor) of total interest. + // Fees accrued: 10% (the reserve factor) of the interest, rounded up. let reserve = env.reserve(&borrow); let fees = reserve.accumulated_program_fees; assert!(fees > 0, "program fees should accrue once interest does"); let total_interest = reserve.current_borrowed_amount().unwrap() - 500_000_000; - let expected_fee = total_interest / 10; // 1000 bps = 10% - // Allow a 1-unit rounding tolerance from flooring. - assert!( - fees.abs_diff(expected_fee) <= 1, - "fees {fees} should be ~10% of interest {total_interest}" + let expected_fee = total_interest.div_ceil(10); // 1000 bps = 10% + assert_eq!( + fees, expected_fee, + "fees {fees} should be 10% of interest {total_interest}, rounded up" ); // Maria withdraws the fees to her own account. @@ -123,3 +122,52 @@ fn program_fees_accrue_and_owner_can_collect() { assert_eq!(env.token_balance(owner_account), fees); assert_eq!(env.reserve(&borrow).accumulated_program_fees, 0); } + +/// The program fee is the program's cut of the interest, so it rounds against +/// the user: up. One second of interest on a 500-unit borrow at the default +/// curve is 3 units, a tenth of which is not whole; the fee is 1, not 0, and +/// the suppliers' pool grows by the other 2, so fee and remainder sum to the +/// interest and never exceed it. +#[test] +fn program_fee_rounds_up_and_suppliers_take_the_remainder() { + let mut env = Env::new(); + let collateral = env.add_reserve(6, dollars(1), default_config()); + let borrow = env.add_reserve(6, dollars(1), default_config()); + + let supplier = env.create_user(); + env.fund(&supplier, borrow.mint, 1_000_000_000); + env.supply(&supplier, &borrow, 1_000_000_000); + + let borrower = env.create_user(); + env.fund(&borrower, collateral.mint, 1_000_000_000); + env.fund(&borrower, borrow.mint, 0); + env.supply(&borrower, &collateral, 1_000_000_000); + let obligation = env.initialize_obligation(&borrower); + env.post_collateral(&borrower, obligation, &collateral, 1_000_000_000); + env.try_borrow( + &borrower, + obligation, + &[&collateral], + &[], + &borrow, + 500_000_000, + ) + .unwrap(); + let before = env.reserve(&borrow); + let debt_before = before.current_borrowed_amount().unwrap(); + let suppliers_before = before.total_liquidity().unwrap(); + + env.warp_seconds(1); + env.refresh_reserve_only(&borrower, &borrow); + + let after = env.reserve(&borrow); + let interest = after.current_borrowed_amount().unwrap() - debt_before; + assert_eq!(interest, 3, "one second of interest on the 500-unit borrow"); + let fee = after.accumulated_program_fees; + assert_eq!(fee, 1, "a tenth of 3 units rounds up to 1 for the program"); + assert_eq!( + after.total_liquidity().unwrap(), + suppliers_before + (interest - fee) as u128, + "the suppliers' pool grows by the interest less the fee" + ); +} diff --git a/finance/lending/anchor/programs/lending/tests/test_security.rs b/finance/lending/anchor/programs/lending/tests/test_security.rs index ae6dfe9c..58bd3ebe 100644 --- a/finance/lending/anchor/programs/lending/tests/test_security.rs +++ b/finance/lending/anchor/programs/lending/tests/test_security.rs @@ -71,8 +71,20 @@ fn non_owner_cannot_write_market_price_feed() { &[&attacker], &attacker.pubkey(), ); + // `SetPrice` binds the signer with `address = lending_market.owner`, so + // the refusal is Anchor's constraint error, not one of the program's; + // v2 logs only its numeric code. + let anchor_lang::Error::Custom(code) = + anchor_lang::Error::from(anchor_lang::ErrorCode::ConstraintAddress) + else { + panic!("a constraint error converts to a custom code"); + }; + let message = format!( + "{:?}", + result.expect_err("only the market owner may write its price feed") + ); assert!( - result.is_err(), - "only the market owner may write its price feed" + message.contains(&format!("Custom({code})")), + "expected ConstraintAddress (Custom({code})), got: {message}" ); } diff --git a/finance/lending/kani-proofs/README.md b/finance/lending/kani-proofs/README.md index 9f5f4186..a965be57 100644 --- a/finance/lending/kani-proofs/README.md +++ b/finance/lending/kani-proofs/README.md @@ -22,6 +22,7 @@ formulas faithfully and checks their invariants: - `proof_mul_div_floor_ceil_correct`: `mul_div_floor`/`mul_div_ceil` are the true floor/ceil of `a·b/d`, differ by ≤ 1, and coincide iff the division is exact. - `proof_rounding_is_program_favourable`: `ceil ≥ floor` always, debt (rounded up) is never undercounted and a supplier claim (rounded down) never overcounted, so dust can't be extracted by round-trips. - `proof_accumulation_factor_monotonic`: The borrow accumulation factor never decreases (`accrue_interest` multiplies by a factor ≥ 1), borrowers always owe ≥ principal. +- `proof_program_fee_rounds_up_within_interest`: The program's cut of an accrual, `ceil(interest · reserve_factor / 10000)`, rounds up yet never exceeds the interest, so the suppliers' remainder never underflows and fee + remainder = interest. - `proof_utilization_in_range`: Utilization is always a valid `[0, 10000]` bps fraction (`borrowed ≤ gross`). - `proof_borrow_rate_within_bounds`: The kinked rate curve stays within `[min_rate, max_rate]` for every utilization, given the config ordering `min ≤ optimal ≤ max`. - `proof_deposit_redeem_cannot_extract`: A deposit→redeem round-trip never returns more liquidity than was put in (both legs floor), no rounding drain of the pool. @@ -49,6 +50,7 @@ so the harness can use a small one: - `proof_mul_div_floor_ceil_correct`: `a, b, d <= 31`, ~37s - `proof_rounding_is_program_favourable`: `a, b, d <= 127`, ~29s - `proof_accumulation_factor_monotonic`: `old/accrued <= 255`, `scale <= 127`, ~5s +- `proof_program_fee_rounds_up_within_interest`: `interest <= 4095`, <1s - `proof_utilization_in_range`: `<= 4095`, ~1s - `proof_borrow_rate_within_bounds`: rates `<= 255`, `full_utilization <= 32`, ~25s - `proof_deposit_redeem_cannot_extract`: `<= 31`, ~6s diff --git a/finance/lending/kani-proofs/src/lib.rs b/finance/lending/kani-proofs/src/lib.rs index 39a4fdcc..4df3ff74 100644 --- a/finance/lending/kani-proofs/src/lib.rs +++ b/finance/lending/kani-proofs/src/lib.rs @@ -9,8 +9,9 @@ //! //! The lending program is the richest of the finance examples: a Solend-style //! pool with `mul_div` floor/ceil rounding (`math.rs`), a kinked interest-rate -//! curve and a compounding accumulation factor (`state::reserve`), a share-token -//! exchange rate (`deposit`/`redeem`), and liquidation sizing with a close +//! curve, a compounding accumulation factor and a fee split that rounds up for +//! the program (`state::reserve`), a share-token exchange rate +//! (`deposit`/`redeem`), and liquidation sizing with a close //! factor and bonus (`liquidate_obligation`). All of that is pure integer //! arithmetic; the token movement is delegated to SPL CPIs that Kani cannot //! symbolically execute. This crate reproduces the formulas faithfully and @@ -140,6 +141,37 @@ fn proof_accumulation_factor_monotonic() { assert!(new_factor >= old_factor); // the factor never decreases } +/// The program's cut of one accrual's interest: `ceil(interest * reserve_factor +/// / 10_000)` (mirrors the fee line of `accrue_interest`). The suppliers take +/// the remainder, `interest - fee`. +pub fn program_fee(interest: u128, reserve_factor_bps: u128) -> Option { + mul_div_ceil(interest, reserve_factor_bps, BPS_DENOMINATOR) +} + +/// The fee split is exact and bounded: the fee rounds up, in the program's +/// favour, yet never exceeds the interest (the reserve factor is at most +/// 100%), so the suppliers' remainder never underflows and fee plus remainder +/// is exactly the interest. Nothing is created by rounding, and whatever +/// rounding there is goes to the program, never to the user. +#[cfg(kani)] +#[kani::proof] +#[kani::solver(cadical)] +fn proof_program_fee_rounds_up_within_interest() { + let interest: u128 = kani::any(); + let reserve_factor_bps: u128 = kani::any(); + kani::assume(interest <= 4095); + kani::assume(reserve_factor_bps <= BPS_DENOMINATOR); // validate(): factor <= 10_000 bps + + let fee = program_fee(interest, reserve_factor_bps).unwrap(); + assert!(fee <= interest); // the program never takes more than accrued + let remainder = interest - fee; // so the suppliers' share cannot underflow + assert_eq!(fee + remainder, interest); // nothing created, nothing lost + + // The fee is the least integer covering the exact cut: rounding went up. + assert!(fee * BPS_DENOMINATOR >= interest * reserve_factor_bps); + assert!(fee == 0 || (fee - 1) * BPS_DENOMINATOR < interest * reserve_factor_bps); +} + // =========================================================================== // 3. Utilization and the kinked borrow-rate curve (reserve.rs) // =========================================================================== @@ -342,6 +374,18 @@ mod tests { assert_eq!(grow_factor(150, 0, 100).unwrap(), 150); } + #[test] + fn fee_rounds_up_and_leaves_the_rest() { + // 3 units of interest at a 10% factor: the exact cut is 0.3, the fee is 1 + // and the suppliers take 2. + assert_eq!(program_fee(3, 1_000).unwrap(), 1); + // exact division rounds nowhere. + assert_eq!(program_fee(30, 1_000).unwrap(), 3); + // a 100% factor takes everything and nothing more. + assert_eq!(program_fee(7, BPS_DENOMINATOR).unwrap(), 7); + assert_eq!(program_fee(0, 1_000).unwrap(), 0); + } + #[test] fn rate_curve_endpoints() { // min 100, optimal 300, max 2000, kink at 8000 bps. diff --git a/finance/lending/quasar/CHANGELOG.md b/finance/lending/quasar/CHANGELOG.md index 766d0145..7c771028 100644 --- a/finance/lending/quasar/CHANGELOG.md +++ b/finance/lending/quasar/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## [Unreleased] (2026-10-05) + +### Changed + +- The program fee on accrued interest rounds up. `accrue` computes the reserve + factor's cut of each accrual with `mul_div_ceil`, so when the cut is not + whole the extra unit goes to the market owner, and the suppliers take the + remainder; fee and remainder sum to the interest and never exceed it. A fee + is the program's cut and rounds against the user, as every fee in these + programs does. The Kani crate gains + `proof_program_fee_rounds_up_within_interest`, which checks the fee never + exceeds the interest for any reserve factor up to 100%. +- `first_deposit_must_exceed_the_minimum`, `borrow_up_to_ltv_succeeds_and_beyond_fails` + and `unhealthy_position_is_liquidated_and_healthy_is_rejected` assert the + error each refusal raises (`DepositTooSmall`, `BorrowTooLarge`, + `ObligationHealthy`), so every refusal test in the suite names its error code. + +### Added + +- `program_fee_rounds_up_and_suppliers_take_the_remainder` accrues one second + of interest on a 500-unit borrow (3 units) through `collect_program_fees` + and checks the owner receives 1 and the suppliers' pool grows by 2. + ## [Unreleased] (2026-10-04) ### Changed diff --git a/finance/lending/quasar/README.md b/finance/lending/quasar/README.md index 334aaecc..3b6bc718 100644 --- a/finance/lending/quasar/README.md +++ b/finance/lending/quasar/README.md @@ -100,10 +100,14 @@ Everything else mirrors the Anchor version. conversion between shares and liquidity divides by. The withheld shares belong to nobody, so the attacker's one share is 1 of 1,001. - **Program fees**: the reserve keeps `reserve_factor_bps` of each interest - accrual in `accumulated_program_fees` (carved out of total liquidity, so it - never lifts the supplier exchange rate); the market owner withdraws it with - `collect_program_fees`. That spread between the borrow and supply rates is how - the owner earns. + accrual in `accumulated_program_fees`, rounded up (`mul_div_ceil`), since a + fee is the program's cut and rounds against the user; the suppliers take the + remainder, so the two parts sum to the interest and never exceed it + (`program_fee_rounds_up_and_suppliers_take_the_remainder` checks a second's + interest of 3 units, of which the fee is 1). The fees are carved out of total + liquidity, so they never lift the supplier exchange rate, and the market + owner withdraws them with `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, so dust cannot be extracted by repeated round-trips; `deposit_redeem_round_trip_creates_no_value` checks diff --git a/finance/lending/quasar/src/logic.rs b/finance/lending/quasar/src/logic.rs index 2ed1aa7b..6ae57b79 100644 --- a/finance/lending/quasar/src/logic.rs +++ b/finance/lending/quasar/src/logic.rs @@ -9,7 +9,7 @@ use crate::{ constants::{FIXED_POINT_SCALE, MAX_PRICE_STALENESS_SLOTS}, error::LendingError, last_restart::LastRestartSlot, - math::{accrue_factor, current_debt, mul_div_floor, price_mantissa_to_scaled}, + math::{accrue_factor, current_debt, mul_div_ceil, price_mantissa_to_scaled}, state::{Obligation, ObligationInner, PriceFeed, Reserve, ReserveInner}, }; @@ -103,13 +103,16 @@ pub fn accrue( reserve.max_borrow_rate_bps, )?; // The program keeps `reserve_factor_bps` of the newly accrued interest; the - // rest lifts the supplier exchange rate. Flooring rounds the owner's cut down. + // rest lifts the supplier exchange rate. The fee rounds up, in the owner's + // favour: a fee is the program's cut and so rounds against the user, and + // the suppliers take what is left, so the two parts sum to the interest and + // never exceed it. let borrowed_after = current_debt( reserve.borrowed_principal, reserve.borrow_accumulation_factor, )?; let interest = borrowed_after.saturating_sub(borrowed_before); - let fee = mul_div_floor( + let fee = mul_div_ceil( interest as u128, reserve.reserve_factor_bps as u128, BPS_DENOMINATOR, diff --git a/finance/lending/quasar/src/tests.rs b/finance/lending/quasar/src/tests.rs index d538769c..e0f2edf0 100644 --- a/finance/lending/quasar/src/tests.rs +++ b/finance/lending/quasar/src/tests.rs @@ -484,10 +484,9 @@ fn first_deposit_withholds_the_minimum(test: &mut Test) { fn first_deposit_must_exceed_the_minimum(test: &mut Test) { let w = base_world(test); setup_empty_markets(test, &w); - assert!( - deposit_borrow_side(test, &w, crate::constants::MINIMUM_SHARES).is_err(), - "a first deposit of only the minimum mints nothing and must be rejected" - ); + // A first deposit of only the minimum mints nothing and is refused. + deposit_borrow_side(test, &w, crate::constants::MINIMUM_SHARES) + .fails_with(LendingError::DepositTooSmall); } #[quasar_test] @@ -501,10 +500,7 @@ fn borrow_up_to_ltv_succeeds_and_beyond_fails(test: &mut Test) { .has_tokens(BORROWER_BORROW, 750 * UNIT); // One unit more exceeds the allowed borrow value. - assert!( - borrow(test, &w, UNIT).is_err(), - "borrowing past LTV must fail" - ); + borrow(test, &w, UNIT).fails_with(LendingError::BorrowTooLarge); } /// A price the oracle is unsure of is no price to lend against. The borrow @@ -685,10 +681,7 @@ fn unhealthy_position_is_liquidated_and_healthy_is_rejected(test: &mut Test) { borrow(test, &w, 700 * UNIT).succeeds(); // Healthy at $1 collateral ($1000 * 80% = $800 threshold > $700 debt). - assert!( - liquidate(test, &w, 350 * UNIT).is_err(), - "healthy obligation must not be liquidatable" - ); + liquidate(test, &w, 350 * UNIT).fails_with(LendingError::ObligationHealthy); // Collateral price halves to $0.50: $500 collateral, $400 threshold < $700 debt. set_price(test, &w, COLLATERAL_MINT, cents(50)); @@ -1487,6 +1480,51 @@ mod clock_warp { ); } + /// The program fee is the program's cut of the interest, so it rounds + /// against the user: up. One second of interest on a 500-unit borrow at + /// the harness's curve is 3 units, a tenth of which is not whole; the fee + /// is 1, not 0, and the suppliers' pool grows by the other 2, so fee and + /// remainder sum to the interest and never exceed it. `collect_program_fees` + /// accrues the reserve itself and pays the fee out, so it is the accrual + /// here and the owner's balance is the fee. + #[test] + fn program_fee_rounds_up_and_suppliers_take_the_remainder() { + let mut world = World::new(); + world.bootstrap_position(); + world + .borrow(BORROWER, BORROWER_BORROW, 500 * UNIT) + .assert_success(); + let before = world.reserve(world.borrow_reserve); + let debt_before = current_debt( + u128::from(before.borrowed_principal), + u128::from(before.borrow_accumulation_factor), + ) + .unwrap(); + let (suppliers_before, _) = world.borrow_reserve_totals(); + + world.warp_seconds(1); + let result = world.collect_borrow_fees(); + result.assert_success(); + + let after = world.reserve(world.borrow_reserve); + let interest = current_debt( + u128::from(after.borrowed_principal), + u128::from(after.borrow_accumulation_factor), + ) + .unwrap() + - debt_before; + assert_eq!(interest, 3, "one second of interest on the 500-unit borrow"); + let fee = balance(&result, OWNER_BORROW); + assert_eq!(fee, 1, "a tenth of 3 units rounds up to 1 for the program"); + assert_eq!(u64::from(after.accumulated_program_fees), 0); + let (suppliers_after, _) = world.borrow_reserve_totals(); + assert_eq!( + suppliers_after, + suppliers_before + u128::from(interest - fee), + "the suppliers' pool grows by the interest less the fee" + ); + } + /// First-depositor share inflation without a donation. Shares are priced /// against tracked total liquidity, not the vault balance, so tokens sent /// straight to the vault move nothing. But total liquidity also counts diff --git a/finance/managed-fund/anchor-v1/CHANGELOG.md b/finance/managed-fund/anchor-v1/CHANGELOG.md index ea576926..5121e342 100644 --- a/finance/managed-fund/anchor-v1/CHANGELOG.md +++ b/finance/managed-fund/anchor-v1/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [Unreleased] - 2026-10-05 + +### Changed + +- **The management fee rounds up.** `collect_fees` mints `ceil(total_shares × fee_bps × elapsed / (10_000 × SECONDS_PER_YEAR))` shares to the manager: a fraction of a share owed is minted as a whole share, so the rounding dilutes the holders rather than shorting the manager. A year's fee in the test suites is an exact multiple and does not change; `test_collect_fees_rounds_up` checks that a day of a 1% fee on 1,000,000,000 shares, 27,397.26 shares, mints 27,398 (a floor minted 27,397). The Kani crate's `proof_fee_shares_bounded_by_supply` models the ceiling with the new `mul_div_ceil` and `fee_shares`, and checks the fee is never below the exact quotient and never more than one share above it. +- **Tests mint TSLAx and NVDAx with eight decimals**, as the real tokens have, and USDC with six: `ASSET_DECIMALS` and `USDC_DECIMALS` replace `TOKEN_DECIMALS`. The program reads decimals from each mint, so every asserted basket amount is now in eight-decimal minor units and the same in major units as before (1.44 TSLAx is `144_000_000`). `test_valuation_scales_by_decimals_and_exponent` now varies TSLAx to nine decimals on its exponent −5 feed; `value_in_usdc` scales by each asset's decimals and the feed exponent. +- **Every refusal test asserts its error code.** The tests that checked only `is_err()` now assert the program error (`WeightOverflow`, `TooManyAssets`, `FeeTooHigh`, `SlippageConfigTooHigh`, `FundNotFullyAllocated`, `InvalidSwapRouter`, `UsdcSlippage`, `IncompleteAssetAccounts`), the router's `SlippageExceeded` raised inside a deposit's swap CPI, or Anchor's own errors, `ConstraintHasOne` for a non-manager `set_weight` and `AccountNotInitialized` for an unapproved asset, through the new `assert_router_error` and `assert_anchor_error` helpers. + ## 2026-10-03 - **Prices with a wide confidence interval are rejected.** Pyth reports each price with a confidence interval (`conf`, offset 81), and `load_price` ignored it, so a price the publishers disagreed on by several percent was used as if it were exact. Deposits price shares from that price and rebalance sets its swap floor from it, so a wide band moves value between depositors or loosens the floor by the same amount. `load_price` now rejects a price whose interval exceeds `MAX_CONFIDENCE_BPS` (100 bps, 1% of the price) with the new `OracleConfidenceTooWide` error. `withdraw` reads no price and is unaffected, so investors can still leave in kind. The limit is a program constant, like the 60-second staleness window; prop-amm and perpetual-futures store theirs per market. Tested by `test_wide_confidence_price_rejected`. The web app's IDL gains the error. diff --git a/finance/managed-fund/anchor-v1/README.md b/finance/managed-fund/anchor-v1/README.md index 43b40893..fbde4ccb 100644 --- a/finance/managed-fund/anchor-v1/README.md +++ b/finance/managed-fund/anchor-v1/README.md @@ -49,9 +49,11 @@ A [share](https://www.investopedia.com/terms/s/shares.asp) represents a fraction A [management fee](https://www.investopedia.com/terms/m/managementfee.asp), in [basis points](https://www.investopedia.com/terms/b/basispoint.asp) (100 bps = 1% per year), is charged by *minting new shares to the manager*, diluting holders proportionally. This is the common onchain pattern (Yearn, Lido charge fees this way) and differs from a traditional fund, which deducts the fee in cash from assets. ``` -fee_shares = total_shares × fee_bps × elapsed_seconds / (10_000 × 31_536_000) +fee_shares = ceil(total_shares × fee_bps × elapsed_seconds / (10_000 × 31_536_000)) ``` +The fee rounds up: a fraction of a share owed to the manager is minted as a whole share, so the rounding dilutes the holders rather than shorting the manager. `test_collect_fees_rounds_up` checks that a day of a 1% fee on 1,000,000,000 shares, 27,397.26 shares, mints 27,398. + `collect_fees` is permissionless. The fee is fixed at creation and capped at `MAX_FEE_BPS` (1,000 bps = 10%); there is no setter to raise it later. ### Weights and Rebalancing @@ -143,7 +145,7 @@ What remains to trust: the honesty of the registered router and registry. The ma ## Financial Math Implementation - Integer arithmetic only; intermediate products use `u128`; multiply before divide. -- All arithmetic uses `checked_*`. Users receive floor division; the program keeps the remainder. +- All arithmetic uses `checked_*`. Deposits and withdrawals floor in the fund's favour: a depositor's shares and a withdrawer's payout round down, and the fund keeps the remainder. The management fee rounds up, so the manager is never minted less than the fee owed. - `transfer_checked` carries decimals through every token CPI. --- @@ -161,7 +163,7 @@ cargo build-sbf --manifest-path programs/managed-fund/Cargo.toml cargo test --manifest-path programs/managed-fund/Cargo.toml ``` -Tests live in `programs/managed-fund/tests/managed_fund.rs` and use [LiteSVM](https://github.com/LiteSVM/litesvm). Both `.so` files are loaded from `target/deploy/`, so build before testing. The suite covers the full lifecycle end to end (deposit with auto-deployment, a price move, rebalance back to target, a second depositor priced at the new NAV, a year's fee, in-kind withdrawal), retiring an asset with `set_weight` and reallocating to reopen deposits, and the rejection paths: unapproved asset, weight overflow, over-cap fee and slippage, oracle-bounded deposit slippage, an under-allocated fund, non-manager `set_weight`, unregistered router, and incomplete asset accounts on deposit and rebalance. The rebalance tests sign as a stranger, since anyone may call it: `test_rebalance_refuses_fund_at_target`, `test_rebalance_refuses_drift_below_threshold` and `test_rebalance_cannot_churn` check that a fund at its targets, or within its threshold, or just rebalanced, cannot be traded; `test_rebalance_refuses_buying_overweight_asset`, `test_rebalance_sells_retired_asset` and `test_initialize_rejects_threshold_out_of_range` cover the rest of its rules. `test_valuation_scales_by_decimals_and_exponent` runs the story with an eight-decimal TSLAx priced by an exponent −5 feed and gets the same share counts. `test_wide_confidence_price_rejected` widens NVDAx's confidence interval to 2% of its price and checks that deposit and rebalance fail with `OracleConfidenceTooWide`, that withdraw still pays out in kind, and that a band of exactly 1% is accepted. `test_full_lifecycle` checks after every step that the recorded holdings equal the vaults' balances. `test_donation_does_not_inflate_share_price` runs the first-depositor attack (a one-minor-unit deposit, a 1,000 USDC transfer straight into the USDC vault, then a 1,000 USDC deposit with no `minimum_shares` floor) and checks the victim gets exactly the shares they would have got without the donation. `test_deposit_rejects_leg_that_buys_nothing` and `test_rebalance_ignores_donations` pin the other two guards: the second checks that donated tokens can neither force a rebalance nor be spent by one. +Tests live in `programs/managed-fund/tests/managed_fund.rs` and use [LiteSVM](https://github.com/LiteSVM/litesvm). Both `.so` files are loaded from `target/deploy/`, so build before testing. TSLAx and NVDAx are minted with eight decimals, as the real tokens are, and USDC with six, so the basket amounts the tests assert are in eight-decimal minor units while shares and USDC stay in six. The suite covers the full lifecycle end to end (deposit with auto-deployment, a price move, rebalance back to target, a second depositor priced at the new NAV, a year's fee, in-kind withdrawal), retiring an asset with `set_weight` and reallocating to reopen deposits, and the rejection paths, each asserting the error code it fails with: unapproved asset, weight overflow, over-cap fee and slippage, oracle-bounded deposit slippage (the router's `SlippageExceeded`, raised inside the swap CPI), an under-allocated fund, non-manager `set_weight` (Anchor's own constraint error), unregistered router, and incomplete asset accounts on deposit and rebalance. The rebalance tests sign as a stranger, since anyone may call it: `test_rebalance_refuses_fund_at_target`, `test_rebalance_refuses_drift_below_threshold` and `test_rebalance_cannot_churn` check that a fund at its targets, or within its threshold, or just rebalanced, cannot be traded; `test_rebalance_refuses_buying_overweight_asset`, `test_rebalance_sells_retired_asset` and `test_initialize_rejects_threshold_out_of_range` cover the rest of its rules. `test_valuation_scales_by_decimals_and_exponent` runs the story with a nine-decimal TSLAx priced by an exponent −5 feed and gets the same share counts. `test_collect_fees` checks a year's fee comes out exact and `test_collect_fees_rounds_up` that a day's fee rounds up to the manager. `test_wide_confidence_price_rejected` widens NVDAx's confidence interval to 2% of its price and checks that deposit and rebalance fail with `OracleConfidenceTooWide`, that withdraw still pays out in kind, and that a band of exactly 1% is accepted. `test_full_lifecycle` checks after every step that the recorded holdings equal the vaults' balances. `test_donation_does_not_inflate_share_price` runs the first-depositor attack (a one-minor-unit deposit, a 1,000 USDC transfer straight into the USDC vault, then a 1,000 USDC deposit with no `minimum_shares` floor) and checks the victim gets exactly the shares they would have got without the donation. `test_deposit_rejects_leg_that_buys_nothing` and `test_rebalance_ignores_donations` pin the other two guards: the second checks that donated tokens can neither force a rebalance nor be spent by one. ## FAQ diff --git a/finance/managed-fund/anchor-v1/programs/managed-fund/src/instructions/collect_fees.rs b/finance/managed-fund/anchor-v1/programs/managed-fund/src/instructions/collect_fees.rs index 9db78424..4be035af 100644 --- a/finance/managed-fund/anchor-v1/programs/managed-fund/src/instructions/collect_fees.rs +++ b/finance/managed-fund/anchor-v1/programs/managed-fund/src/instructions/collect_fees.rs @@ -60,7 +60,9 @@ pub fn handle_collect_fees(context: Context) -> R let fund_index = context.accounts.fund.index; let fund_bump = context.accounts.fund.bump; - // fee_shares = total_shares * fee_bps * elapsed / (10_000 * SECONDS_PER_YEAR) + // fee_shares = ceil(total_shares * fee_bps * elapsed / (10_000 * SECONDS_PER_YEAR)). + // The fee is owed to the manager, so it rounds up: a fraction of a share + // becomes a whole share, and the holders bear the rounding. let denominator = (10_000u128) .checked_mul(SECONDS_PER_YEAR as u128) .ok_or(FundError::MathOverflow)?; @@ -70,10 +72,11 @@ pub fn handle_collect_fees(context: Context) -> R .ok_or(FundError::MathOverflow)? .checked_mul(elapsed_seconds as u128) .ok_or(FundError::MathOverflow)? - .checked_div(denominator) - .ok_or(FundError::MathOverflow)? as u64; + .div_ceil(denominator) + .try_into() + .map_err(|_| FundError::MathOverflow)?; - // Update timestamp even if fee_shares rounds to zero + // Update the timestamp even when fee_shares is zero (no supply, or no fee). context.accounts.fund.last_fee_accrual_timestamp = current_ts; if fee_shares == 0 { diff --git a/finance/managed-fund/anchor-v1/programs/managed-fund/tests/managed_fund.rs b/finance/managed-fund/anchor-v1/programs/managed-fund/tests/managed_fund.rs index 913c9e2b..6a42b2a1 100644 --- a/finance/managed-fund/anchor-v1/programs/managed-fund/tests/managed_fund.rs +++ b/finance/managed-fund/anchor-v1/programs/managed-fund/tests/managed_fund.rs @@ -19,6 +19,7 @@ use { }; use managed_fund::error::FundError; +use mock_swap_router::error::RouterError; fn token_program_id() -> Pubkey { "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" @@ -78,7 +79,7 @@ fn set_price_feed(svm: &mut LiteSVM, key: Pubkey, price: i64) { /// Write a Pyth feed as if Pyth posted it in `posted_slot`. fn set_price_feed_posted_at(svm: &mut LiteSVM, key: Pubkey, price: i64, posted_slot: u64) { - write_price_feed(svm, key, price, -8, posted_slot); + write_price_feed(svm, key, price, PYTH_EXPONENT, posted_slot); } /// Write a Pyth feed with its own exponent: Pyth's US equity feeds use -5. @@ -114,11 +115,20 @@ fn write_price_feed_with_confidence( const PUBLISH_TIME: i64 = 1_700_000_000; /// A tight confidence interval, $0.001 at exponent -8, far inside the 1% limit. const DEFAULT_CONFIDENCE: u64 = 100_000; -const TOKEN_DECIMALS: u8 = 6; +const USDC_DECIMALS: u8 = 6; +/// TSLAx and NVDAx carry the eight decimals the real tokens have. The fund +/// reads each mint's decimals rather than assuming USDC's six, so every basket +/// amount below is in eight-decimal minor units while shares and USDC stay in +/// six. +const ASSET_DECIMALS: u8 = 8; +/// The exponent of Pyth's crypto USD feeds, which the test feeds use unless a +/// test writes its own. +const PYTH_EXPONENT: i32 = -8; const SECONDS_PER_YEAR: i64 = 31_536_000; +const SECONDS_PER_DAY: i64 = 86_400; -const TSLA_PRICE: i64 = 25_000_000_000; // $250 -const NVDA_PRICE: i64 = 18_000_000_000; // $180 +const TSLA_PRICE: i64 = 25_000_000_000; // $250 at PYTH_EXPONENT +const NVDA_PRICE: i64 = 18_000_000_000; // $180 at PYTH_EXPONENT const TSLA_RATE: u64 = 250_000_000; // router USDC minor units per whole token const NVDA_RATE: u64 = 180_000_000; @@ -165,10 +175,10 @@ impl TestContext { /// Mints, router (config + rates + treasury), Pyth feeds, a registry with TSLAx /// and NVDAx approved, and all derived PDAs. Does not create the fund. fn setup_full() -> TestContext { - setup_with_tsla_decimals(TOKEN_DECIMALS) + setup_with_tsla_decimals(ASSET_DECIMALS) } -/// `setup_full`, with TSLAx minted at `tsla_decimals` instead of six. +/// `setup_full`, with TSLAx minted at `tsla_decimals` instead of eight. fn setup_with_tsla_decimals(tsla_decimals: u8) -> TestContext { let fund_program_id = managed_fund::id(); let router_program_id = mock_swap_router::id(); @@ -201,9 +211,9 @@ fn setup_with_tsla_decimals(tsla_decimals: u8) -> TestContext { let payer = create_wallet(&mut svm, 100_000_000_000).unwrap(); let manager = create_wallet(&mut svm, 10_000_000_000).unwrap(); - let usdc_mint = create_token_mint(&mut svm, &payer, TOKEN_DECIMALS, None).unwrap(); + let usdc_mint = create_token_mint(&mut svm, &payer, USDC_DECIMALS, None).unwrap(); let tsla_mint = create_token_mint(&mut svm, &payer, tsla_decimals, None).unwrap(); - let nvda_mint = create_token_mint(&mut svm, &payer, TOKEN_DECIMALS, None).unwrap(); + let nvda_mint = create_token_mint(&mut svm, &payer, ASSET_DECIMALS, None).unwrap(); let (router_config_pda, _) = Pubkey::find_program_address(&[b"router_config"], &router_program_id); @@ -644,6 +654,18 @@ fn program_error(error: FundError) -> String { format!("Custom({})", 6000 + error as u32) } +/// How a failed transaction reports one of the mock router's errors, raised +/// inside the swap CPI: numbered from 6000 like the fund's own. +fn router_error(error: RouterError) -> String { + format!("Custom({})", 6000 + error as u32) +} + +/// How a failed transaction reports one of Anchor's own errors: constraint +/// errors are numbered from 2000 and account errors from 3000. +fn anchor_error(error: anchor_lang::error::ErrorCode) -> String { + format!("Custom({})", error as u32) +} + /// Recorded holdings must equal the vaults' token balances whenever nothing has /// been donated: a mismatch means a handler moved tokens without recording it. fn assert_holdings_match_vaults(ctx: &TestContext) { @@ -681,13 +703,19 @@ fn donate_usdc(ctx: &mut TestContext, donor: &Keypair, amount: u64) { } /// A holder's position valued in USDC minor units at the test's starting prices -/// (TSLAx $250, NVDAx $180; both assets and USDC have six decimals). +/// (TSLAx $250, NVDAx $180), floored. An asset balance is in eight-decimal +/// minor units and its price is at `PYTH_EXPONENT`, so the product is scaled by +/// `10^(USDC_DECIMALS + PYTH_EXPONENT - ASSET_DECIMALS)` to reach USDC minor +/// units, as the program's `asset_value_in_usdc` does. fn value_in_usdc(ctx: &TestContext, owner: &Pubkey) -> u64 { - let balance = - |mint: &Pubkey| get_token_account_balance(&ctx.svm, &derive_ata(owner, mint)).unwrap_or(0); - balance(&ctx.usdc_mint) - + balance(&ctx.tsla_mint) * (TSLA_PRICE as u64 / 100_000_000) - + balance(&ctx.nvda_mint) * (NVDA_PRICE as u64 / 100_000_000) + let balance = |mint: &Pubkey| { + get_token_account_balance(&ctx.svm, &derive_ata(owner, mint)).unwrap_or(0) as u128 + }; + let scale = 10u128.pow((ASSET_DECIMALS as i32 - PYTH_EXPONENT - USDC_DECIMALS as i32) as u32); + let in_usdc = |amount: u128, price: i64| amount * price as u128 / scale; + (balance(&ctx.usdc_mint) + + in_usdc(balance(&ctx.tsla_mint), TSLA_PRICE) + + in_usdc(balance(&ctx.nvda_mint), NVDA_PRICE)) as u64 } fn read_asset_config(ctx: &TestContext, index: u8) -> managed_fund::state::AssetConfig { @@ -755,27 +783,59 @@ fn do_rebalance(ctx: &mut TestContext, sell_index: u8, buy_index: u8) { try_rebalance(ctx, sell_index, buy_index).unwrap(); } +/// Assert that a transaction failed, and that it reported `expected`, the +/// `Custom(code)` the error is sent as. +fn assert_failed_with(result: Result<(), solana_kite::SolanaKiteError>, expected: &str, why: &str) { + let err = format!("{:?}", result.expect_err(why)); + assert!( + err.contains(expected), + "{why}: expected {expected}, got {err}" + ); +} + /// Assert that a transaction failed with one of the program's errors. fn assert_program_error( result: Result<(), solana_kite::SolanaKiteError>, error: FundError, why: &str, ) { - let err = format!("{:?}", result.expect_err(why)); - assert!(err.contains(&program_error(error)), "{why}: {err}"); + assert_failed_with(result, &program_error(error), why); } -fn advance_one_year(ctx: &mut TestContext) { +/// Assert that a transaction failed with one of the mock router's errors. +fn assert_router_error( + result: Result<(), solana_kite::SolanaKiteError>, + error: RouterError, + why: &str, +) { + assert_failed_with(result, &router_error(error), why); +} + +/// Assert that a transaction failed on one of Anchor's own account constraints. +fn assert_anchor_error( + result: Result<(), solana_kite::SolanaKiteError>, + error: anchor_lang::error::ErrorCode, + why: &str, +) { + assert_failed_with(result, &anchor_error(error), why); +} + +/// Move the clock `seconds` past the test's starting time, `PUBLISH_TIME`. +fn advance_seconds(ctx: &mut TestContext, seconds: i64) { let clock = ctx.svm.get_sysvar::(); ctx.svm.set_sysvar(&Clock { slot: clock.slot + 1_000_000, epoch_start_timestamp: clock.epoch_start_timestamp, epoch: clock.epoch, leader_schedule_epoch: clock.leader_schedule_epoch, - unix_timestamp: PUBLISH_TIME + SECONDS_PER_YEAR, + unix_timestamp: PUBLISH_TIME + seconds, }); } +fn advance_one_year(ctx: &mut TestContext) { + advance_seconds(ctx, SECONDS_PER_YEAR); +} + fn do_collect_fees(ctx: &mut TestContext) -> Pubkey { let manager_share = derive_ata(&ctx.manager.pubkey(), &ctx.share_mint_pda); let ix = Instruction::new_with_bytes( @@ -844,7 +904,7 @@ fn test_add_asset_rejects_unapproved() { init_fund(&mut ctx, FEE_BPS, SLIPPAGE_BPS, router); // A mint that was never approved: its approved_asset PDA does not exist. - let rogue_mint = create_token_mint(&mut ctx.svm, &ctx.payer, TOKEN_DECIMALS, None).unwrap(); + let rogue_mint = create_token_mint(&mut ctx.svm, &ctx.payer, ASSET_DECIMALS, None).unwrap(); let (rogue_entry, _) = Pubkey::find_program_address( &[ b"approved_asset", @@ -856,7 +916,11 @@ fn test_add_asset_rejects_unapproved() { let rogue_vault = derive_ata(&ctx.fund_pda, &rogue_mint); let result = add_asset(&mut ctx, 0, rogue_mint, rogue_entry, rogue_vault, 5000); - assert!(result.is_err(), "adding an unapproved mint must fail"); + assert_anchor_error( + result, + anchor_lang::error::ErrorCode::AccountNotInitialized, + "adding an unapproved mint must fail", + ); } #[test] @@ -868,14 +932,18 @@ fn test_add_asset_rejects_weight_overflow() { add_asset(&mut ctx, 0, tm, wt, vt, 6000).unwrap(); let (nm, wn, vn) = (ctx.nvda_mint, ctx.approved_nvda, ctx.vault_nvda); let result = add_asset(&mut ctx, 1, nm, wn, vn, 6000); - assert!(result.is_err(), "weights over 10000 bps must fail"); + assert_program_error( + result, + FundError::WeightOverflow, + "weights over 10000 bps must fail", + ); } /// Create a fresh mint and approve it in the registry. The bound price feed is an /// arbitrary pubkey: callers that never value this asset (e.g. the cap boundary test) /// do not need a real feed account. fn create_and_approve_mint(ctx: &mut TestContext) -> (Pubkey, Pubkey) { - let mint = create_token_mint(&mut ctx.svm, &ctx.payer, TOKEN_DECIMALS, None).unwrap(); + let mint = create_token_mint(&mut ctx.svm, &ctx.payer, ASSET_DECIMALS, None).unwrap(); let (entry, _) = Pubkey::find_program_address( &[b"approved_asset", ctx.registry_pda.as_ref(), mint.as_ref()], &ctx.fund_program_id, @@ -920,7 +988,11 @@ fn test_add_asset_enforces_max_assets() { let (mint, entry) = create_and_approve_mint(&mut ctx); let vault = derive_ata(&ctx.fund_pda, &mint); let result = add_asset(&mut ctx, 16, mint, entry, vault, 0); - assert!(result.is_err(), "adding beyond MAX_ASSETS must revert"); + assert_program_error( + result, + FundError::TooManyAssets, + "adding beyond MAX_ASSETS must revert", + ); } #[test] @@ -956,7 +1028,11 @@ fn test_initialize_rejects_excessive_fee() { &[&ctx.manager], &ctx.manager.pubkey(), ); - assert!(r.is_err(), "fee above MAX_FEE_BPS must be rejected"); + assert_program_error( + r, + FundError::FeeTooHigh, + "fee above MAX_FEE_BPS must be rejected", + ); } #[test] @@ -992,9 +1068,10 @@ fn test_initialize_rejects_excessive_slippage() { &[&ctx.manager], &ctx.manager.pubkey(), ); - assert!( - r.is_err(), - "slippage above MAX_SLIPPAGE_BPS must be rejected" + assert_program_error( + r, + FundError::SlippageConfigTooHigh, + "slippage above MAX_SLIPPAGE_BPS must be rejected", ); } @@ -1008,7 +1085,7 @@ fn test_deposit_first() { let user_share = do_deposit(&mut ctx, &user, amount, amount); // First deposit is 1:1, then deployed at 40/60: 0.4 USDC -> TSLAx, 0.6 -> NVDAx, - // leaving no idle USDC. + // leaving no idle USDC. The basket vaults count eight-decimal minor units. assert_eq!( get_token_account_balance(&ctx.svm, &user_share).unwrap(), amount @@ -1017,14 +1094,14 @@ fn test_deposit_first() { get_token_account_balance(&ctx.svm, &ctx.vault_usdc).unwrap(), 0 ); - // 400000 USDC / 250 = 1600 TSLAx; 600000 USDC / 180 = 3333 NVDAx (floor). + // 0.4 USDC / 250 = 0.0016 TSLAx; 0.6 USDC / 180 = 0.00333333 NVDAx (floor). assert_eq!( get_token_account_balance(&ctx.svm, &ctx.vault_tsla).unwrap(), - 1_600 + 160_000 ); assert_eq!( get_token_account_balance(&ctx.svm, &ctx.vault_nvda).unwrap(), - 3_333 + 333_333 ); } @@ -1037,9 +1114,10 @@ fn test_deposit_rejects_underallocated() { let user = fund_user(&mut ctx, 10_000_000); let ix = deposit_instruction(&ctx, &user, 10_000_000, 1, deposit_remaining_tsla(&ctx)); let r = send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&user], &user.pubkey()); - assert!( - r.is_err(), - "deposit into an under-allocated fund must revert" + assert_program_error( + r, + FundError::FundNotFullyAllocated, + "deposit into an under-allocated fund must revert", ); // Bring TSLAx to 100%; the deposit now succeeds and deploys fully into TSLAx. @@ -1048,10 +1126,10 @@ fn test_deposit_rejects_underallocated() { ctx.svm.expire_blockhash(); do_deposit_tsla_only(&mut ctx, &user, 10_000_000, 1); - // 10 USDC / 250 = 40000 TSLAx, with no idle USDC left. + // 10 USDC / 250 = 0.04 TSLAx, with no idle USDC left. assert_eq!( get_token_account_balance(&ctx.svm, &ctx.vault_tsla).unwrap(), - 40_000 + 4_000_000 ); assert_eq!( get_token_account_balance(&ctx.svm, &ctx.vault_usdc).unwrap(), @@ -1064,17 +1142,19 @@ fn test_deposit_rejects_slippage() { let mut ctx = setup_full(); standard_fund(&mut ctx); - // Router rate for TSLAx far worse than the oracle: a deposit's TSLAx deploy leg - // must revert, taking the whole deposit with it. + // Router rate for TSLAx far worse than the oracle: the deposit's TSLAx deploy + // leg asks the router for at least the oracle amount less 1%, the router + // refuses, and its error reverts the whole deposit. let (tsla_mint, tsla_rate_pda) = (ctx.tsla_mint, ctx.tsla_rate_pda); set_router_rate(&mut ctx, tsla_mint, 300_000_000, tsla_rate_pda); let user = fund_user(&mut ctx, 10_000_000); let ix = deposit_instruction(&ctx, &user, 10_000_000, 1, deposit_remaining(&ctx)); let r = send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&user], &user.pubkey()); - assert!( - r.is_err(), - "deposit deploy leg worse than oracle must revert the deposit" + assert_router_error( + r, + RouterError::SlippageExceeded, + "deposit deploy leg worse than oracle must revert the deposit", ); } @@ -1092,9 +1172,10 @@ fn test_deposit_rejects_unregistered_router() { let user = fund_user(&mut ctx, 10_000_000); let ix = deposit_instruction(&ctx, &user, 10_000_000, 1, deposit_remaining(&ctx)); let r = send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&user], &user.pubkey()); - assert!( - r.is_err(), - "deposit deploying through an unregistered router must fail" + assert_program_error( + r, + FundError::InvalidSwapRouter, + "deposit deploying through an unregistered router must fail", ); } @@ -1131,13 +1212,10 @@ fn test_deposit_rejects_price_from_before_restart() { set_last_restart_slot(&mut ctx, 3); let ix = deposit_instruction(&ctx, &user, 10_000_000, 1, deposit_remaining(&ctx)); - let message = - send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&user], &user.pubkey()) - .expect_err("a deposit priced before the restart must fail"); - let code = managed_fund::error::FundError::PricePredatesRestart as u32 + 6000; - assert!( - message.to_string().contains(&format!("Custom({code})")), - "expected PricePredatesRestart (Custom({code})), got: {message}" + assert_program_error( + send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&user], &user.pubkey()), + FundError::PricePredatesRestart, + "a deposit priced before the restart must fail", ); // Pyth posting again after the restart reopens the fund. Fresh blockhash @@ -1206,11 +1284,11 @@ fn test_rebalance() { // The USDC vault nets to zero across the two legs. assert_eq!( get_token_account_balance(&ctx.svm, &ctx.vault_tsla).unwrap(), - 1_536_000 + 153_600_000 ); assert_eq!( get_token_account_balance(&ctx.svm, &ctx.vault_nvda).unwrap(), - 2_880_000 + 288_000_000 ); assert_eq!( get_token_account_balance(&ctx.svm, &ctx.vault_usdc).unwrap(), @@ -1229,11 +1307,33 @@ fn test_collect_fees() { advance_one_year(&mut ctx); let manager_share = do_collect_fees(&mut ctx); - // 1% of 1,000,000,000 = 10,000,000 fee shares. + // 1% of 1,000,000,000 = 10,000,000 fee shares, exactly. assert_eq!( get_token_account_balance(&ctx.svm, &manager_share).unwrap(), 10_000_000 ); + assert_eq!(read_fund(&ctx).total_shares, 1_010_000_000); +} + +/// A fee that is not a whole number of shares rounds up, against the holders: +/// one day of a 1% fee on 1,000,000,000 shares is 27,397.26 shares, and the +/// manager is minted 27,398. +#[test] +fn test_collect_fees_rounds_up() { + let mut ctx = setup_full(); + standard_fund(&mut ctx); + + let user = fund_user(&mut ctx, 1_000_000_000); // 1000 USDC + do_deposit(&mut ctx, &user, 1_000_000_000, 1); + + advance_seconds(&mut ctx, SECONDS_PER_DAY); + let manager_share = do_collect_fees(&mut ctx); + + assert_eq!( + get_token_account_balance(&ctx.svm, &manager_share).unwrap(), + 27_398 + ); + assert_eq!(read_fund(&ctx).total_shares, 1_000_027_398); } fn withdraw_remaining(ctx: &TestContext, user: &Pubkey) -> Vec { @@ -1255,7 +1355,8 @@ fn test_withdraw() { standard_fund(&mut ctx); let user = fund_user(&mut ctx, 10_000_000); - // Deposit auto-deploys 4 USDC -> 16000 TSLAx and 6 USDC -> 33333 NVDAx, no idle USDC. + // Deposit auto-deploys 4 USDC -> 0.016 TSLAx and 6 USDC -> 0.03333333 NVDAx, + // no idle USDC. let user_share = do_deposit(&mut ctx, &user, 10_000_000, 1); let shares = get_token_account_balance(&ctx.svm, &user_share).unwrap(); @@ -1292,15 +1393,16 @@ fn test_withdraw() { ); send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&user], &user.pubkey()).unwrap(); - // Sole holder withdraws everything in kind: all 16000 TSLAx + 33333 NVDAx, no USDC. + // Sole holder withdraws everything in kind: all 0.016 TSLAx + 0.03333333 + // NVDAx, no USDC. assert_eq!(get_token_account_balance(&ctx.svm, &user_usdc).unwrap(), 0); assert_eq!( get_token_account_balance(&ctx.svm, &derive_ata(&user.pubkey(), &ctx.tsla_mint)).unwrap(), - 16_000 + 1_600_000 ); assert_eq!( get_token_account_balance(&ctx.svm, &derive_ata(&user.pubkey(), &ctx.nvda_mint)).unwrap(), - 33_333 + 3_333_333 ); } @@ -1346,7 +1448,11 @@ fn test_withdraw_rejects_slippage() { metas, ); let r = send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&user], &user.pubkey()); - assert!(r.is_err(), "min_usdc_out above payout must revert"); + assert_program_error( + r, + FundError::UsdcSlippage, + "min_usdc_out above payout must revert", + ); } #[test] @@ -1360,7 +1466,11 @@ fn test_deposit_rejects_incomplete_assets() { // Only one asset's accounts supplied (5) for a two-asset fund (needs 10). let ix = deposit_instruction(&ctx, &user, amount, 1, deposit_remaining_tsla(&ctx)); let r = send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&user], &user.pubkey()); - assert!(r.is_err(), "incomplete asset accounts must revert"); + assert_program_error( + r, + FundError::IncompleteAssetAccounts, + "incomplete asset accounts must revert", + ); } fn do_withdraw(ctx: &mut TestContext, user: &Keypair, shares: u64, min_usdc_out: u64) { @@ -1411,9 +1521,10 @@ fn test_set_weight_retire() { let user = fund_user(&mut ctx, 100_000_000); let ix = deposit_instruction(&ctx, &user, 100_000_000, 1, deposit_remaining(&ctx)); let r = send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&user], &user.pubkey()); - assert!( - r.is_err(), - "an under-allocated (retired) fund must reject deposits" + assert_program_error( + r, + FundError::FundNotFullyAllocated, + "an under-allocated (retired) fund must reject deposits", ); // Reassign the freed weight to TSLAx (back to 100%); deposits reopen and now deploy @@ -1422,10 +1533,10 @@ fn test_set_weight_retire() { set_weight(&mut ctx, 0, 10_000).unwrap(); ctx.svm.expire_blockhash(); do_deposit(&mut ctx, &user, 100_000_000, 1); - // 100 USDC / 250 = 400000 TSLAx, nothing to NVDAx, no idle USDC. + // 100 USDC / 250 = 0.4 TSLAx, nothing to NVDAx, no idle USDC. assert_eq!( get_token_account_balance(&ctx.svm, &ctx.vault_tsla).unwrap(), - 400_000 + 40_000_000 ); assert_eq!( get_token_account_balance(&ctx.svm, &ctx.vault_nvda).unwrap(), @@ -1443,9 +1554,10 @@ fn test_set_weight_rejects_overflow() { standard_fund(&mut ctx); // TSLAx 4000 + NVDAx 6000 = 10000. Raising TSLAx to 6000 would total 12000. let r = set_weight(&mut ctx, 0, 6000); - assert!( - r.is_err(), - "weight change pushing total over 10000 must revert" + assert_program_error( + r, + FundError::WeightOverflow, + "weight change pushing total over 10000 must revert", ); } @@ -1471,7 +1583,13 @@ fn test_set_weight_rejects_non_manager() { &[&intruder], &intruder.pubkey(), ); - assert!(r.is_err(), "only the manager may set weights"); + // The manager is bound to the fund's stored `manager` by an Anchor + // constraint, so the refusal is Anchor's, not one of the program's errors. + assert_anchor_error( + r, + anchor_lang::error::ErrorCode::ConstraintHasOne, + "only the manager may set weights", + ); } /// The whole lifecycle with the exact figures the book's Managed Fund chapter narrates: deposit and @@ -1491,11 +1609,11 @@ fn test_full_lifecycle() { ); assert_eq!( get_token_account_balance(&ctx.svm, &ctx.vault_tsla).unwrap(), - 1_440_000 + 144_000_000 ); assert_eq!( get_token_account_balance(&ctx.svm, &ctx.vault_nvda).unwrap(), - 3_000_000 + 300_000_000 ); assert_holdings_match_vaults(&ctx); @@ -1506,11 +1624,11 @@ fn test_full_lifecycle() { assert_holdings_match_vaults(&ctx); assert_eq!( get_token_account_balance(&ctx.svm, &ctx.vault_tsla).unwrap(), - 1_536_000 + 153_600_000 ); assert_eq!( get_token_account_balance(&ctx.svm, &ctx.vault_nvda).unwrap(), - 2_880_000 + 288_000_000 ); // Bob deposits 480 USDC at NAV 960 -> 450,000,000 shares, deployed 40/60. @@ -1522,16 +1640,17 @@ fn test_full_lifecycle() { ); assert_eq!( get_token_account_balance(&ctx.svm, &ctx.vault_tsla).unwrap(), - 2_304_000 + 230_400_000 ); assert_eq!( get_token_account_balance(&ctx.svm, &ctx.vault_nvda).unwrap(), - 4_320_000 + 432_000_000 ); assert_holdings_match_vaults(&ctx); - // A year passes; the manager collects 1% of the 1,350,000,000 supply = 13,500,000. + // A year passes; the manager collects 1% of the 1,350,000,000 supply = + // 13,500,000, exactly: nothing to round. advance_one_year(&mut ctx); let manager_share = do_collect_fees(&mut ctx); assert_eq!( @@ -1540,15 +1659,16 @@ fn test_full_lifecycle() { ); assert_eq!(read_fund(&ctx).total_shares, 1_363_500_000); - // Alice withdraws all 900,000,000 shares in kind: her 900/1363.5 slice of each vault. + // Alice withdraws all 900,000,000 shares in kind: her 900/1363.5 slice of + // each vault, floored: 1.52079207 TSLAx and 2.85148514 NVDAx. do_withdraw(&mut ctx, &alice, 900_000_000, 0); assert_eq!( get_token_account_balance(&ctx.svm, &derive_ata(&alice.pubkey(), &ctx.tsla_mint)).unwrap(), - 1_520_792 + 152_079_207 ); assert_eq!( get_token_account_balance(&ctx.svm, &derive_ata(&alice.pubkey(), &ctx.nvda_mint)).unwrap(), - 2_851_485 + 285_148_514 ); assert_eq!( get_token_account_balance(&ctx.svm, &derive_ata(&alice.pubkey(), &ctx.usdc_mint)).unwrap(), @@ -1656,10 +1776,10 @@ fn test_deposit_rejects_leg_that_buys_nothing() { ); assert_eq!(read_fund(&ctx).total_shares, 0); - // A deposit large enough to buy some TSLAx goes through. + // A deposit large enough to buy some TSLAx goes through: 1 USDC buys 0.004. let user = fund_user(&mut ctx, 1_000_000); do_deposit_tsla_only(&mut ctx, &user, 1_000_000, 1); - assert_eq!(read_fund(&ctx).asset_holdings[0], 4_000); + assert_eq!(read_fund(&ctx).asset_holdings[0], 400_000); } /// Transfer `amount` of `mint` from the donor's token account straight into a @@ -1721,7 +1841,7 @@ fn test_rebalance_refuses_drift_below_threshold() { FundError::DriftBelowThreshold, "drift under the threshold must not trade", ); - assert_eq!(read_fund(&ctx).asset_holdings[1], 3_000_000); + assert_eq!(read_fund(&ctx).asset_holdings[1], 300_000_000); } /// Churn: once a rebalance has restored the weights, calling it again, in @@ -1765,7 +1885,7 @@ struct ExtraAsset { } fn create_routable_asset(ctx: &mut TestContext, price: i64, rate: u64) -> ExtraAsset { - let mint = create_token_mint(&mut ctx.svm, &ctx.payer, TOKEN_DECIMALS, None).unwrap(); + let mint = create_token_mint(&mut ctx.svm, &ctx.payer, ASSET_DECIMALS, None).unwrap(); let ix = spl_token::instruction::set_authority( &spl_token::ID, &mint, @@ -1883,7 +2003,7 @@ fn test_rebalance_refuses_buying_overweight_asset() { &stranger.pubkey(), ) .unwrap(); - assert!(read_fund(&ctx).asset_holdings[0] > 1_600_000); + assert!(read_fund(&ctx).asset_holdings[0] > 160_000_000); } /// A rebalance values every asset, so it needs every asset's accounts, as a @@ -1934,7 +2054,7 @@ fn test_rebalance_sells_retired_asset() { // All 3 NVDAx sold for 3 USDC, which bought 0.012 TSLAx. let fund = read_fund(&ctx); assert_eq!(fund.asset_holdings[1], 0); - assert_eq!(fund.asset_holdings[0], 1_452_000); + assert_eq!(fund.asset_holdings[0], 145_200_000); assert_eq!(fund.usdc_holdings, 0); assert_holdings_match_vaults(&ctx); @@ -2032,35 +2152,35 @@ fn test_initialize_rejects_threshold_out_of_range() { } /// Valuation scales by each asset's decimals and each feed's exponent. TSLAx -/// here has eight decimals and a Pyth equity feed with exponent -5, while USDC -/// and NVDAx keep six decimals and NVDAx's feed keeps -8. Assuming six decimals -/// and -8 would value Alice's 1.44 TSLAx at $360 * 100 * 1,000, and Bob's -/// deposit would buy almost no shares. Scaled correctly, every figure matches -/// the six-decimal story. +/// here has nine decimals and a Pyth equity feed with exponent -5, while NVDAx +/// keeps its eight decimals and its feed keeps -8, and USDC its six. Assuming +/// eight decimals and -8 would value Alice's 1.44 TSLAx at $360 * 10 * 1,000, +/// and Bob's deposit would buy almost no shares. Scaled correctly, every figure +/// matches the story in major units. #[test] fn test_valuation_scales_by_decimals_and_exponent() { - let mut ctx = setup_with_tsla_decimals(8); + let mut ctx = setup_with_tsla_decimals(9); write_price_feed(&mut ctx.svm, ctx.price_feed_tsla, 25_000_000, -5, 1); // $250 standard_fund(&mut ctx); - assert_eq!(read_asset_config(&ctx, 0).decimals, 8); - assert_eq!(read_asset_config(&ctx, 1).decimals, 6); + assert_eq!(read_asset_config(&ctx, 0).decimals, 9); + assert_eq!(read_asset_config(&ctx, 1).decimals, ASSET_DECIMALS); - // Alice's 900 USDC deploys to 1.44 TSLAx (eight decimals) and 3 NVDAx. + // Alice's 900 USDC deploys to 1.44 TSLAx (nine decimals) and 3 NVDAx. let alice = fund_user(&mut ctx, 900_000_000); let alice_share = do_deposit(&mut ctx, &alice, 900_000_000, 1); assert_eq!( get_token_account_balance(&ctx.svm, &alice_share).unwrap(), 900_000_000 ); - assert_eq!(read_fund(&ctx).asset_holdings[0], 144_000_000); - assert_eq!(read_fund(&ctx).asset_holdings[1], 3_000_000); + assert_eq!(read_fund(&ctx).asset_holdings[0], 1_440_000_000); + assert_eq!(read_fund(&ctx).asset_holdings[1], 300_000_000); // NVDAx to $200. The rebalance computes its trade in the same units: 0.12 // NVDAx sold for 24 USDC, which buys 0.096 TSLAx, back to 40/60. set_nvda_price(&mut ctx, 20_000_000_000, 200_000_000); do_rebalance(&mut ctx, 1, 0); - assert_eq!(read_fund(&ctx).asset_holdings[0], 153_600_000); - assert_eq!(read_fund(&ctx).asset_holdings[1], 2_880_000); + assert_eq!(read_fund(&ctx).asset_holdings[0], 1_536_000_000); + assert_eq!(read_fund(&ctx).asset_holdings[1], 288_000_000); // The fund is worth $960, so Bob's 480 USDC buys 450 shares. let bob = fund_user(&mut ctx, 480_000_000); @@ -2069,8 +2189,8 @@ fn test_valuation_scales_by_decimals_and_exponent() { get_token_account_balance(&ctx.svm, &bob_share).unwrap(), 450_000_000 ); - assert_eq!(read_fund(&ctx).asset_holdings[0], 230_400_000); - assert_eq!(read_fund(&ctx).asset_holdings[1], 4_320_000); + assert_eq!(read_fund(&ctx).asset_holdings[0], 2_304_000_000); + assert_eq!(read_fund(&ctx).asset_holdings[1], 432_000_000); assert_holdings_match_vaults(&ctx); } @@ -2095,7 +2215,7 @@ fn test_wide_confidence_price_rejected() { ctx.price_feed_nvda, 20_000_000_000, 400_000_000, - -8, + PYTH_EXPONENT, 1, ); @@ -2116,11 +2236,11 @@ fn test_wide_confidence_price_rejected() { do_withdraw(&mut ctx, &alice, 450_000_000, 0); assert_eq!( get_token_account_balance(&ctx.svm, &derive_ata(&alice.pubkey(), &ctx.tsla_mint)).unwrap(), - 720_000 + 72_000_000 ); assert_eq!( get_token_account_balance(&ctx.svm, &derive_ata(&alice.pubkey(), &ctx.nvda_mint)).unwrap(), - 1_500_000 + 150_000_000 ); // A $2 interval is exactly 1% of the price, which is accepted: the @@ -2130,11 +2250,11 @@ fn test_wide_confidence_price_rejected() { ctx.price_feed_nvda, 20_000_000_000, 200_000_000, - -8, + PYTH_EXPONENT, 1, ); do_rebalance(&mut ctx, 1, 0); - assert_eq!(read_fund(&ctx).asset_holdings[0], 768_000); - assert_eq!(read_fund(&ctx).asset_holdings[1], 1_440_000); + assert_eq!(read_fund(&ctx).asset_holdings[0], 76_800_000); + assert_eq!(read_fund(&ctx).asset_holdings[1], 144_000_000); assert_holdings_match_vaults(&ctx); } diff --git a/finance/managed-fund/anchor/CHANGELOG.md b/finance/managed-fund/anchor/CHANGELOG.md index ea576926..683a0caa 100644 --- a/finance/managed-fund/anchor/CHANGELOG.md +++ b/finance/managed-fund/anchor/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [Unreleased] - 2026-10-05 + +### Changed + +- **The management fee rounds up.** `collect_fees` mints `ceil(total_shares × fee_bps × elapsed / (10_000 × SECONDS_PER_YEAR))` shares to the manager: a fraction of a share owed is minted as a whole share, so the rounding dilutes the holders rather than shorting the manager. A year's fee in the test suites is an exact multiple and does not change; `test_collect_fees_rounds_up` checks that a day of a 1% fee on 1,000,000,000 shares, 27,397.26 shares, mints 27,398 (a floor minted 27,397). The Kani crate's `proof_fee_shares_bounded_by_supply` models the ceiling with the new `mul_div_ceil` and `fee_shares`, and checks the fee is never below the exact quotient and never more than one share above it. +- **Tests mint TSLAx and NVDAx with eight decimals**, as the real tokens have, and USDC with six: `ASSET_DECIMALS` and `USDC_DECIMALS` replace `TOKEN_DECIMALS`. The program reads decimals from each mint, so every asserted basket amount is now in eight-decimal minor units and the same in major units as before (1.44 TSLAx is `144_000_000`). `test_valuation_scales_by_decimals_and_exponent` now varies TSLAx to nine decimals on its exponent −5 feed; `value_in_usdc` scales by each asset's decimals and the feed exponent. +- **Every refusal test asserts its error code.** The tests that checked only `is_err()` now assert the program error (`WeightOverflow`, `TooManyAssets`, `FeeTooHigh`, `SlippageConfigTooHigh`, `FundNotFullyAllocated`, `InvalidSwapRouter`, `UsdcSlippage`, `IncompleteAssetAccounts`), the router's `SlippageExceeded` raised inside a deposit's swap CPI, or Anchor's own errors, `ConstraintAddress` for a non-manager `set_weight` and `UninitializedAccount` for an unapproved asset, through the new `assert_router_error` and `assert_anchor_error` helpers. + ## 2026-10-03 - **Prices with a wide confidence interval are rejected.** Pyth reports each price with a confidence interval (`conf`, offset 81), and `load_price` ignored it, so a price the publishers disagreed on by several percent was used as if it were exact. Deposits price shares from that price and rebalance sets its swap floor from it, so a wide band moves value between depositors or loosens the floor by the same amount. `load_price` now rejects a price whose interval exceeds `MAX_CONFIDENCE_BPS` (100 bps, 1% of the price) with the new `OracleConfidenceTooWide` error. `withdraw` reads no price and is unaffected, so investors can still leave in kind. The limit is a program constant, like the 60-second staleness window; prop-amm and perpetual-futures store theirs per market. Tested by `test_wide_confidence_price_rejected`. The web app's IDL gains the error. diff --git a/finance/managed-fund/anchor/README.md b/finance/managed-fund/anchor/README.md index 080b5fc5..8a5f4d54 100644 --- a/finance/managed-fund/anchor/README.md +++ b/finance/managed-fund/anchor/README.md @@ -49,9 +49,11 @@ A [share](https://www.investopedia.com/terms/s/shares.asp) represents a fraction A [management fee](https://www.investopedia.com/terms/m/managementfee.asp), in [basis points](https://www.investopedia.com/terms/b/basispoint.asp) (100 bps = 1% per year), is charged by *minting new shares to the manager*, diluting holders proportionally. This is the common onchain pattern (Yearn, Lido charge fees this way) and differs from a traditional fund, which deducts the fee in cash from assets. ``` -fee_shares = total_shares × fee_bps × elapsed_seconds / (10_000 × 31_536_000) +fee_shares = ceil(total_shares × fee_bps × elapsed_seconds / (10_000 × 31_536_000)) ``` +The fee rounds up: a fraction of a share owed to the manager is minted as a whole share, so the rounding dilutes the holders rather than shorting the manager. `test_collect_fees_rounds_up` checks that a day of a 1% fee on 1,000,000,000 shares, 27,397.26 shares, mints 27,398. + `collect_fees` is permissionless. The fee is fixed at creation and capped at `MAX_FEE_BPS` (1,000 bps = 10%); there is no setter to raise it later. ### Weights and Rebalancing @@ -143,7 +145,7 @@ What remains to trust: the honesty of the registered router and registry. The ma ## Financial Math Implementation - Integer arithmetic only; intermediate products use `u128`; multiply before divide. -- All arithmetic uses `checked_*`. Users receive floor division; the program keeps the remainder. +- All arithmetic uses `checked_*`. Deposits and withdrawals floor in the fund's favour: a depositor's shares and a withdrawer's payout round down, and the fund keeps the remainder. The management fee rounds up, so the manager is never minted less than the fee owed. - `transfer_checked` carries decimals through every token CPI. --- @@ -161,7 +163,7 @@ cargo build-sbf --manifest-path programs/managed-fund/Cargo.toml cargo test --manifest-path programs/managed-fund/Cargo.toml ``` -Tests live in `programs/managed-fund/tests/managed_fund.rs` and use [LiteSVM](https://github.com/LiteSVM/litesvm). Both `.so` files are loaded from `target/deploy/`, so build before testing. The suite covers the full lifecycle end to end (deposit with auto-deployment, a price move, rebalance back to target, a second depositor priced at the new NAV, a year's fee, in-kind withdrawal), retiring an asset with `set_weight` and reallocating to reopen deposits, and the rejection paths: unapproved asset, weight overflow, over-cap fee and slippage, oracle-bounded deposit slippage, an under-allocated fund, non-manager `set_weight`, unregistered router, and incomplete asset accounts on deposit and rebalance. The rebalance tests sign as a stranger, since anyone may call it: `test_rebalance_refuses_fund_at_target`, `test_rebalance_refuses_drift_below_threshold` and `test_rebalance_cannot_churn` check that a fund at its targets, or within its threshold, or just rebalanced, cannot be traded; `test_rebalance_refuses_buying_overweight_asset`, `test_rebalance_sells_retired_asset` and `test_initialize_rejects_threshold_out_of_range` cover the rest of its rules. `test_valuation_scales_by_decimals_and_exponent` runs the story with an eight-decimal TSLAx priced by an exponent −5 feed and gets the same share counts. `test_wide_confidence_price_rejected` widens NVDAx's confidence interval to 2% of its price and checks that deposit and rebalance fail with `OracleConfidenceTooWide`, that withdraw still pays out in kind, and that a band of exactly 1% is accepted. `test_full_lifecycle` checks after every step that the recorded holdings equal the vaults' balances. `test_donation_does_not_inflate_share_price` runs the first-depositor attack (a one-minor-unit deposit, a 1,000 USDC transfer straight into the USDC vault, then a 1,000 USDC deposit with no `minimum_shares` floor) and checks the victim gets exactly the shares they would have got without the donation. `test_deposit_rejects_leg_that_buys_nothing` and `test_rebalance_ignores_donations` pin the other two guards: the second checks that donated tokens can neither force a rebalance nor be spent by one. +Tests live in `programs/managed-fund/tests/managed_fund.rs` and use [LiteSVM](https://github.com/LiteSVM/litesvm). Both `.so` files are loaded from `target/deploy/`, so build before testing. TSLAx and NVDAx are minted with eight decimals, as the real tokens are, and USDC with six, so the basket amounts the tests assert are in eight-decimal minor units while shares and USDC stay in six. The suite covers the full lifecycle end to end (deposit with auto-deployment, a price move, rebalance back to target, a second depositor priced at the new NAV, a year's fee, in-kind withdrawal), retiring an asset with `set_weight` and reallocating to reopen deposits, and the rejection paths, each asserting the error code it fails with: unapproved asset, weight overflow, over-cap fee and slippage, oracle-bounded deposit slippage (the router's `SlippageExceeded`, raised inside the swap CPI), an under-allocated fund, non-manager `set_weight` (Anchor's own constraint error), unregistered router, and incomplete asset accounts on deposit and rebalance. The rebalance tests sign as a stranger, since anyone may call it: `test_rebalance_refuses_fund_at_target`, `test_rebalance_refuses_drift_below_threshold` and `test_rebalance_cannot_churn` check that a fund at its targets, or within its threshold, or just rebalanced, cannot be traded; `test_rebalance_refuses_buying_overweight_asset`, `test_rebalance_sells_retired_asset` and `test_initialize_rejects_threshold_out_of_range` cover the rest of its rules. `test_valuation_scales_by_decimals_and_exponent` runs the story with a nine-decimal TSLAx priced by an exponent −5 feed and gets the same share counts. `test_collect_fees` checks a year's fee comes out exact and `test_collect_fees_rounds_up` that a day's fee rounds up to the manager. `test_wide_confidence_price_rejected` widens NVDAx's confidence interval to 2% of its price and checks that deposit and rebalance fail with `OracleConfidenceTooWide`, that withdraw still pays out in kind, and that a band of exactly 1% is accepted. `test_full_lifecycle` checks after every step that the recorded holdings equal the vaults' balances. `test_donation_does_not_inflate_share_price` runs the first-depositor attack (a one-minor-unit deposit, a 1,000 USDC transfer straight into the USDC vault, then a 1,000 USDC deposit with no `minimum_shares` floor) and checks the victim gets exactly the shares they would have got without the donation. `test_deposit_rejects_leg_that_buys_nothing` and `test_rebalance_ignores_donations` pin the other two guards: the second checks that donated tokens can neither force a rebalance nor be spent by one. ## FAQ diff --git a/finance/managed-fund/anchor/programs/managed-fund/src/instructions/collect_fees.rs b/finance/managed-fund/anchor/programs/managed-fund/src/instructions/collect_fees.rs index 12d2e854..2fa3a6ba 100644 --- a/finance/managed-fund/anchor/programs/managed-fund/src/instructions/collect_fees.rs +++ b/finance/managed-fund/anchor/programs/managed-fund/src/instructions/collect_fees.rs @@ -60,7 +60,9 @@ pub fn handle_collect_fees(context: &mut Context) let fund_index = context.accounts.fund.index; let fund_bump = context.accounts.fund.bump; - // fee_shares = total_shares * fee_bps * elapsed / (10_000 * SECONDS_PER_YEAR) + // fee_shares = ceil(total_shares * fee_bps * elapsed / (10_000 * SECONDS_PER_YEAR)). + // The fee is owed to the manager, so it rounds up: a fraction of a share + // becomes a whole share, and the holders bear the rounding. let denominator = (10_000u128) .checked_mul(SECONDS_PER_YEAR as u128) .ok_or(FundError::MathOverflow)?; @@ -70,10 +72,11 @@ pub fn handle_collect_fees(context: &mut Context) .ok_or(FundError::MathOverflow)? .checked_mul(elapsed_seconds as u128) .ok_or(FundError::MathOverflow)? - .checked_div(denominator) - .ok_or(FundError::MathOverflow)? as u64; + .div_ceil(denominator) + .try_into() + .map_err(|_| FundError::MathOverflow)?; - // Update timestamp even if fee_shares rounds to zero + // Update the timestamp even when fee_shares is zero (no supply, or no fee). context.accounts.fund.last_fee_accrual_timestamp = current_ts; if fee_shares == 0 { diff --git a/finance/managed-fund/anchor/programs/managed-fund/tests/managed_fund.rs b/finance/managed-fund/anchor/programs/managed-fund/tests/managed_fund.rs index faf702f1..382605d6 100644 --- a/finance/managed-fund/anchor/programs/managed-fund/tests/managed_fund.rs +++ b/finance/managed-fund/anchor/programs/managed-fund/tests/managed_fund.rs @@ -16,6 +16,7 @@ use { }; use managed_fund::error::FundError; +use mock_swap_router::error::RouterError; fn token_program_id() -> Address { "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" @@ -75,7 +76,7 @@ fn set_price_feed(svm: &mut LiteSVM, key: Address, price: i64) { /// Write a Pyth feed as if Pyth posted it in `posted_slot`. fn set_price_feed_posted_at(svm: &mut LiteSVM, key: Address, price: i64, posted_slot: u64) { - write_price_feed(svm, key, price, -8, posted_slot); + write_price_feed(svm, key, price, PYTH_EXPONENT, posted_slot); } /// Write a Pyth feed with its own exponent: Pyth's US equity feeds use -5. @@ -111,11 +112,20 @@ fn write_price_feed_with_confidence( const PUBLISH_TIME: i64 = 1_700_000_000; /// A tight confidence interval, $0.001 at exponent -8, far inside the 1% limit. const DEFAULT_CONFIDENCE: u64 = 100_000; -const TOKEN_DECIMALS: u8 = 6; +const USDC_DECIMALS: u8 = 6; +/// TSLAx and NVDAx carry the eight decimals the real tokens have. The fund +/// reads each mint's decimals rather than assuming USDC's six, so every basket +/// amount below is in eight-decimal minor units while shares and USDC stay in +/// six. +const ASSET_DECIMALS: u8 = 8; +/// The exponent of Pyth's crypto USD feeds, which the test feeds use unless a +/// test writes its own. +const PYTH_EXPONENT: i32 = -8; const SECONDS_PER_YEAR: i64 = 31_536_000; +const SECONDS_PER_DAY: i64 = 86_400; -const TSLA_PRICE: i64 = 25_000_000_000; // $250 -const NVDA_PRICE: i64 = 18_000_000_000; // $180 +const TSLA_PRICE: i64 = 25_000_000_000; // $250 at PYTH_EXPONENT +const NVDA_PRICE: i64 = 18_000_000_000; // $180 at PYTH_EXPONENT const TSLA_RATE: u64 = 250_000_000; // router USDC minor units per whole token const NVDA_RATE: u64 = 180_000_000; @@ -162,10 +172,10 @@ impl TestContext { /// Mints, router (config + rates + treasury), Pyth feeds, a registry with TSLAx /// and NVDAx approved, and all derived PDAs. Does not create the fund. fn setup_full() -> TestContext { - setup_with_tsla_decimals(TOKEN_DECIMALS) + setup_with_tsla_decimals(ASSET_DECIMALS) } -/// `setup_full`, with TSLAx minted at `tsla_decimals` instead of six. +/// `setup_full`, with TSLAx minted at `tsla_decimals` instead of eight. fn setup_with_tsla_decimals(tsla_decimals: u8) -> TestContext { let fund_program_id = managed_fund::id(); let router_program_id = mock_swap_router::id(); @@ -198,9 +208,9 @@ fn setup_with_tsla_decimals(tsla_decimals: u8) -> TestContext { let payer = create_wallet(&mut svm, 100_000_000_000).unwrap(); let manager = create_wallet(&mut svm, 10_000_000_000).unwrap(); - let usdc_mint = create_token_mint(&mut svm, &payer, TOKEN_DECIMALS, None).unwrap(); + let usdc_mint = create_token_mint(&mut svm, &payer, USDC_DECIMALS, None).unwrap(); let tsla_mint = create_token_mint(&mut svm, &payer, tsla_decimals, None).unwrap(); - let nvda_mint = create_token_mint(&mut svm, &payer, TOKEN_DECIMALS, None).unwrap(); + let nvda_mint = create_token_mint(&mut svm, &payer, ASSET_DECIMALS, None).unwrap(); let (router_config_pda, _) = Address::find_program_address(&[b"router_config"], &router_program_id); @@ -641,6 +651,18 @@ fn program_error(error: FundError) -> String { format!("Custom({})", 6000 + error as u32) } +/// How a failed transaction reports one of the mock router's errors, raised +/// inside the swap CPI: numbered from 6000 like the fund's own. +fn router_error(error: RouterError) -> String { + format!("Custom({})", 6000 + error as u32) +} + +/// How a failed transaction reports one of Anchor's own errors: a constraint +/// error is its code, numbered from 2000, as a custom program error. +fn anchor_error(error: anchor_lang::ErrorCode) -> String { + format!("{:?}", anchor_lang::prelude::ProgramError::from(error)) +} + /// Recorded holdings must equal the vaults' token balances whenever nothing has /// been donated: a mismatch means a handler moved tokens without recording it. fn assert_holdings_match_vaults(ctx: &TestContext) { @@ -678,13 +700,19 @@ fn donate_usdc(ctx: &mut TestContext, donor: &Keypair, amount: u64) { } /// A holder's position valued in USDC minor units at the test's starting prices -/// (TSLAx $250, NVDAx $180; both assets and USDC have six decimals). +/// (TSLAx $250, NVDAx $180), floored. An asset balance is in eight-decimal +/// minor units and its price is at `PYTH_EXPONENT`, so the product is scaled by +/// `10^(USDC_DECIMALS + PYTH_EXPONENT - ASSET_DECIMALS)` to reach USDC minor +/// units, as the program's `asset_value_in_usdc` does. fn value_in_usdc(ctx: &TestContext, owner: &Address) -> u64 { - let balance = - |mint: &Address| get_token_account_balance(&ctx.svm, &derive_ata(owner, mint)).unwrap_or(0); - balance(&ctx.usdc_mint) - + balance(&ctx.tsla_mint) * (TSLA_PRICE as u64 / 100_000_000) - + balance(&ctx.nvda_mint) * (NVDA_PRICE as u64 / 100_000_000) + let balance = |mint: &Address| { + get_token_account_balance(&ctx.svm, &derive_ata(owner, mint)).unwrap_or(0) as u128 + }; + let scale = 10u128.pow((ASSET_DECIMALS as i32 - PYTH_EXPONENT - USDC_DECIMALS as i32) as u32); + let in_usdc = |amount: u128, price: i64| amount * price as u128 / scale; + (balance(&ctx.usdc_mint) + + in_usdc(balance(&ctx.tsla_mint), TSLA_PRICE) + + in_usdc(balance(&ctx.nvda_mint), NVDA_PRICE)) as u64 } fn read_asset_config(ctx: &TestContext, index: u8) -> managed_fund::state::AssetConfig { @@ -752,27 +780,59 @@ fn do_rebalance(ctx: &mut TestContext, sell_index: u8, buy_index: u8) { try_rebalance(ctx, sell_index, buy_index).unwrap(); } +/// Assert that a transaction failed, and that it reported `expected`, the +/// `Custom(code)` the error is sent as. +fn assert_failed_with(result: Result<(), solana_kite::SolanaKiteError>, expected: &str, why: &str) { + let err = format!("{:?}", result.expect_err(why)); + assert!( + err.contains(expected), + "{why}: expected {expected}, got {err}" + ); +} + /// Assert that a transaction failed with one of the program's errors. fn assert_program_error( result: Result<(), solana_kite::SolanaKiteError>, error: FundError, why: &str, ) { - let err = format!("{:?}", result.expect_err(why)); - assert!(err.contains(&program_error(error)), "{why}: {err}"); + assert_failed_with(result, &program_error(error), why); } -fn advance_one_year(ctx: &mut TestContext) { +/// Assert that a transaction failed with one of the mock router's errors. +fn assert_router_error( + result: Result<(), solana_kite::SolanaKiteError>, + error: RouterError, + why: &str, +) { + assert_failed_with(result, &router_error(error), why); +} + +/// Assert that a transaction failed on one of Anchor's own account constraints. +fn assert_anchor_error( + result: Result<(), solana_kite::SolanaKiteError>, + error: anchor_lang::ErrorCode, + why: &str, +) { + assert_failed_with(result, &anchor_error(error), why); +} + +/// Move the clock `seconds` past the test's starting time, `PUBLISH_TIME`. +fn advance_seconds(ctx: &mut TestContext, seconds: i64) { let clock = ctx.svm.get_sysvar::(); ctx.svm.set_sysvar(&Clock { slot: clock.slot + 1_000_000, epoch_start_timestamp: clock.epoch_start_timestamp, epoch: clock.epoch, leader_schedule_epoch: clock.leader_schedule_epoch, - unix_timestamp: PUBLISH_TIME + SECONDS_PER_YEAR, + unix_timestamp: PUBLISH_TIME + seconds, }); } +fn advance_one_year(ctx: &mut TestContext) { + advance_seconds(ctx, SECONDS_PER_YEAR); +} + fn do_collect_fees(ctx: &mut TestContext) -> Address { let manager_share = derive_ata(&ctx.manager.pubkey(), &ctx.share_mint_pda); let ix = Instruction::new_with_bytes( @@ -841,7 +901,7 @@ fn test_add_asset_rejects_unapproved() { init_fund(&mut ctx, FEE_BPS, SLIPPAGE_BPS, router); // A mint that was never approved: its approved_asset PDA does not exist. - let rogue_mint = create_token_mint(&mut ctx.svm, &ctx.payer, TOKEN_DECIMALS, None).unwrap(); + let rogue_mint = create_token_mint(&mut ctx.svm, &ctx.payer, ASSET_DECIMALS, None).unwrap(); let (rogue_entry, _) = Address::find_program_address( &[ b"approved_asset", @@ -853,7 +913,16 @@ fn test_add_asset_rejects_unapproved() { let rogue_vault = derive_ata(&ctx.fund_pda, &rogue_mint); let result = add_asset(&mut ctx, 0, rogue_mint, rogue_entry, rogue_vault, 5000); - assert!(result.is_err(), "adding an unapproved mint must fail"); + // The approved_asset PDA was never created, so Anchor finds an empty + // system-owned account where it expects program data. + assert_failed_with( + result, + &format!( + "{:?}", + anchor_lang::prelude::ProgramError::UninitializedAccount + ), + "adding an unapproved mint must fail", + ); } #[test] @@ -865,14 +934,18 @@ fn test_add_asset_rejects_weight_overflow() { add_asset(&mut ctx, 0, tm, wt, vt, 6000).unwrap(); let (nm, wn, vn) = (ctx.nvda_mint, ctx.approved_nvda, ctx.vault_nvda); let result = add_asset(&mut ctx, 1, nm, wn, vn, 6000); - assert!(result.is_err(), "weights over 10000 bps must fail"); + assert_program_error( + result, + FundError::WeightOverflow, + "weights over 10000 bps must fail", + ); } /// Create a fresh mint and approve it in the registry. The bound price feed is an /// arbitrary pubkey: callers that never value this asset (e.g. the cap boundary test) /// do not need a real feed account. fn create_and_approve_mint(ctx: &mut TestContext) -> (Address, Address) { - let mint = create_token_mint(&mut ctx.svm, &ctx.payer, TOKEN_DECIMALS, None).unwrap(); + let mint = create_token_mint(&mut ctx.svm, &ctx.payer, ASSET_DECIMALS, None).unwrap(); let (entry, _) = Address::find_program_address( &[b"approved_asset", ctx.registry_pda.as_ref(), mint.as_ref()], &ctx.fund_program_id, @@ -917,7 +990,11 @@ fn test_add_asset_enforces_max_assets() { let (mint, entry) = create_and_approve_mint(&mut ctx); let vault = derive_ata(&ctx.fund_pda, &mint); let result = add_asset(&mut ctx, 16, mint, entry, vault, 0); - assert!(result.is_err(), "adding beyond MAX_ASSETS must revert"); + assert_program_error( + result, + FundError::TooManyAssets, + "adding beyond MAX_ASSETS must revert", + ); } #[test] @@ -953,7 +1030,11 @@ fn test_initialize_rejects_excessive_fee() { &[&ctx.manager], &ctx.manager.pubkey(), ); - assert!(r.is_err(), "fee above MAX_FEE_BPS must be rejected"); + assert_program_error( + r, + FundError::FeeTooHigh, + "fee above MAX_FEE_BPS must be rejected", + ); } #[test] @@ -989,9 +1070,10 @@ fn test_initialize_rejects_excessive_slippage() { &[&ctx.manager], &ctx.manager.pubkey(), ); - assert!( - r.is_err(), - "slippage above MAX_SLIPPAGE_BPS must be rejected" + assert_program_error( + r, + FundError::SlippageConfigTooHigh, + "slippage above MAX_SLIPPAGE_BPS must be rejected", ); } @@ -1005,7 +1087,7 @@ fn test_deposit_first() { let user_share = do_deposit(&mut ctx, &user, amount, amount); // First deposit is 1:1, then deployed at 40/60: 0.4 USDC -> TSLAx, 0.6 -> NVDAx, - // leaving no idle USDC. + // leaving no idle USDC. The basket vaults count eight-decimal minor units. assert_eq!( get_token_account_balance(&ctx.svm, &user_share).unwrap(), amount @@ -1014,14 +1096,14 @@ fn test_deposit_first() { get_token_account_balance(&ctx.svm, &ctx.vault_usdc).unwrap(), 0 ); - // 400000 USDC / 250 = 1600 TSLAx; 600000 USDC / 180 = 3333 NVDAx (floor). + // 0.4 USDC / 250 = 0.0016 TSLAx; 0.6 USDC / 180 = 0.00333333 NVDAx (floor). assert_eq!( get_token_account_balance(&ctx.svm, &ctx.vault_tsla).unwrap(), - 1_600 + 160_000 ); assert_eq!( get_token_account_balance(&ctx.svm, &ctx.vault_nvda).unwrap(), - 3_333 + 333_333 ); } @@ -1034,9 +1116,10 @@ fn test_deposit_rejects_underallocated() { let user = fund_user(&mut ctx, 10_000_000); let ix = deposit_instruction(&ctx, &user, 10_000_000, 1, deposit_remaining_tsla(&ctx)); let r = send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&user], &user.pubkey()); - assert!( - r.is_err(), - "deposit into an under-allocated fund must revert" + assert_program_error( + r, + FundError::FundNotFullyAllocated, + "deposit into an under-allocated fund must revert", ); // Bring TSLAx to 100%; the deposit now succeeds and deploys fully into TSLAx. @@ -1045,10 +1128,10 @@ fn test_deposit_rejects_underallocated() { ctx.svm.expire_blockhash(); do_deposit_tsla_only(&mut ctx, &user, 10_000_000, 1); - // 10 USDC / 250 = 40000 TSLAx, with no idle USDC left. + // 10 USDC / 250 = 0.04 TSLAx, with no idle USDC left. assert_eq!( get_token_account_balance(&ctx.svm, &ctx.vault_tsla).unwrap(), - 40_000 + 4_000_000 ); assert_eq!( get_token_account_balance(&ctx.svm, &ctx.vault_usdc).unwrap(), @@ -1061,17 +1144,19 @@ fn test_deposit_rejects_slippage() { let mut ctx = setup_full(); standard_fund(&mut ctx); - // Router rate for TSLAx far worse than the oracle: a deposit's TSLAx deploy leg - // must revert, taking the whole deposit with it. + // Router rate for TSLAx far worse than the oracle: the deposit's TSLAx deploy + // leg asks the router for at least the oracle amount less 1%, the router + // refuses, and its error reverts the whole deposit. let (tsla_mint, tsla_rate_pda) = (ctx.tsla_mint, ctx.tsla_rate_pda); set_router_rate(&mut ctx, tsla_mint, 300_000_000, tsla_rate_pda); let user = fund_user(&mut ctx, 10_000_000); let ix = deposit_instruction(&ctx, &user, 10_000_000, 1, deposit_remaining(&ctx)); let r = send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&user], &user.pubkey()); - assert!( - r.is_err(), - "deposit deploy leg worse than oracle must revert the deposit" + assert_router_error( + r, + RouterError::SlippageExceeded, + "deposit deploy leg worse than oracle must revert the deposit", ); } @@ -1089,9 +1174,10 @@ fn test_deposit_rejects_unregistered_router() { let user = fund_user(&mut ctx, 10_000_000); let ix = deposit_instruction(&ctx, &user, 10_000_000, 1, deposit_remaining(&ctx)); let r = send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&user], &user.pubkey()); - assert!( - r.is_err(), - "deposit deploying through an unregistered router must fail" + assert_program_error( + r, + FundError::InvalidSwapRouter, + "deposit deploying through an unregistered router must fail", ); } @@ -1128,13 +1214,10 @@ fn test_deposit_rejects_price_from_before_restart() { set_last_restart_slot(&mut ctx, 3); let ix = deposit_instruction(&ctx, &user, 10_000_000, 1, deposit_remaining(&ctx)); - let message = - send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&user], &user.pubkey()) - .expect_err("a deposit priced before the restart must fail"); - let code = managed_fund::error::FundError::PricePredatesRestart as u32 + 6000; - assert!( - message.to_string().contains(&format!("Custom({code})")), - "expected PricePredatesRestart (Custom({code})), got: {message}" + assert_program_error( + send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&user], &user.pubkey()), + FundError::PricePredatesRestart, + "a deposit priced before the restart must fail", ); // Pyth posting again after the restart reopens the fund. Fresh blockhash @@ -1203,11 +1286,11 @@ fn test_rebalance() { // The USDC vault nets to zero across the two legs. assert_eq!( get_token_account_balance(&ctx.svm, &ctx.vault_tsla).unwrap(), - 1_536_000 + 153_600_000 ); assert_eq!( get_token_account_balance(&ctx.svm, &ctx.vault_nvda).unwrap(), - 2_880_000 + 288_000_000 ); assert_eq!( get_token_account_balance(&ctx.svm, &ctx.vault_usdc).unwrap(), @@ -1226,11 +1309,33 @@ fn test_collect_fees() { advance_one_year(&mut ctx); let manager_share = do_collect_fees(&mut ctx); - // 1% of 1,000,000,000 = 10,000,000 fee shares. + // 1% of 1,000,000,000 = 10,000,000 fee shares, exactly. assert_eq!( get_token_account_balance(&ctx.svm, &manager_share).unwrap(), 10_000_000 ); + assert_eq!(read_fund(&ctx).total_shares, 1_010_000_000); +} + +/// A fee that is not a whole number of shares rounds up, against the holders: +/// one day of a 1% fee on 1,000,000,000 shares is 27,397.26 shares, and the +/// manager is minted 27,398. +#[test] +fn test_collect_fees_rounds_up() { + let mut ctx = setup_full(); + standard_fund(&mut ctx); + + let user = fund_user(&mut ctx, 1_000_000_000); // 1000 USDC + do_deposit(&mut ctx, &user, 1_000_000_000, 1); + + advance_seconds(&mut ctx, SECONDS_PER_DAY); + let manager_share = do_collect_fees(&mut ctx); + + assert_eq!( + get_token_account_balance(&ctx.svm, &manager_share).unwrap(), + 27_398 + ); + assert_eq!(read_fund(&ctx).total_shares, 1_000_027_398); } fn withdraw_remaining(ctx: &TestContext, user: &Address) -> Vec { @@ -1252,7 +1357,8 @@ fn test_withdraw() { standard_fund(&mut ctx); let user = fund_user(&mut ctx, 10_000_000); - // Deposit auto-deploys 4 USDC -> 16000 TSLAx and 6 USDC -> 33333 NVDAx, no idle USDC. + // Deposit auto-deploys 4 USDC -> 0.016 TSLAx and 6 USDC -> 0.03333333 NVDAx, + // no idle USDC. let user_share = do_deposit(&mut ctx, &user, 10_000_000, 1); let shares = get_token_account_balance(&ctx.svm, &user_share).unwrap(); @@ -1289,15 +1395,16 @@ fn test_withdraw() { ); send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&user], &user.pubkey()).unwrap(); - // Sole holder withdraws everything in kind: all 16000 TSLAx + 33333 NVDAx, no USDC. + // Sole holder withdraws everything in kind: all 0.016 TSLAx + 0.03333333 + // NVDAx, no USDC. assert_eq!(get_token_account_balance(&ctx.svm, &user_usdc).unwrap(), 0); assert_eq!( get_token_account_balance(&ctx.svm, &derive_ata(&user.pubkey(), &ctx.tsla_mint)).unwrap(), - 16_000 + 1_600_000 ); assert_eq!( get_token_account_balance(&ctx.svm, &derive_ata(&user.pubkey(), &ctx.nvda_mint)).unwrap(), - 33_333 + 3_333_333 ); } @@ -1343,7 +1450,11 @@ fn test_withdraw_rejects_slippage() { metas, ); let r = send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&user], &user.pubkey()); - assert!(r.is_err(), "min_usdc_out above payout must revert"); + assert_program_error( + r, + FundError::UsdcSlippage, + "min_usdc_out above payout must revert", + ); } #[test] @@ -1357,7 +1468,11 @@ fn test_deposit_rejects_incomplete_assets() { // Only one asset's accounts supplied (5) for a two-asset fund (needs 10). let ix = deposit_instruction(&ctx, &user, amount, 1, deposit_remaining_tsla(&ctx)); let r = send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&user], &user.pubkey()); - assert!(r.is_err(), "incomplete asset accounts must revert"); + assert_program_error( + r, + FundError::IncompleteAssetAccounts, + "incomplete asset accounts must revert", + ); } fn do_withdraw(ctx: &mut TestContext, user: &Keypair, shares: u64, min_usdc_out: u64) { @@ -1408,9 +1523,10 @@ fn test_set_weight_retire() { let user = fund_user(&mut ctx, 100_000_000); let ix = deposit_instruction(&ctx, &user, 100_000_000, 1, deposit_remaining(&ctx)); let r = send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&user], &user.pubkey()); - assert!( - r.is_err(), - "an under-allocated (retired) fund must reject deposits" + assert_program_error( + r, + FundError::FundNotFullyAllocated, + "an under-allocated (retired) fund must reject deposits", ); // Reassign the freed weight to TSLAx (back to 100%); deposits reopen and now deploy @@ -1419,10 +1535,10 @@ fn test_set_weight_retire() { set_weight(&mut ctx, 0, 10_000).unwrap(); ctx.svm.expire_blockhash(); do_deposit(&mut ctx, &user, 100_000_000, 1); - // 100 USDC / 250 = 400000 TSLAx, nothing to NVDAx, no idle USDC. + // 100 USDC / 250 = 0.4 TSLAx, nothing to NVDAx, no idle USDC. assert_eq!( get_token_account_balance(&ctx.svm, &ctx.vault_tsla).unwrap(), - 400_000 + 40_000_000 ); assert_eq!( get_token_account_balance(&ctx.svm, &ctx.vault_nvda).unwrap(), @@ -1440,9 +1556,10 @@ fn test_set_weight_rejects_overflow() { standard_fund(&mut ctx); // TSLAx 4000 + NVDAx 6000 = 10000. Raising TSLAx to 6000 would total 12000. let r = set_weight(&mut ctx, 0, 6000); - assert!( - r.is_err(), - "weight change pushing total over 10000 must revert" + assert_program_error( + r, + FundError::WeightOverflow, + "weight change pushing total over 10000 must revert", ); } @@ -1468,7 +1585,13 @@ fn test_set_weight_rejects_non_manager() { &[&intruder], &intruder.pubkey(), ); - assert!(r.is_err(), "only the manager may set weights"); + // The manager is bound to the fund's stored `manager` by an Anchor + // constraint, so the refusal is Anchor's, not one of the program's errors. + assert_anchor_error( + r, + anchor_lang::ErrorCode::ConstraintAddress, + "only the manager may set weights", + ); } /// The whole lifecycle with the exact figures the book's Managed Fund chapter narrates: deposit and @@ -1488,11 +1611,11 @@ fn test_full_lifecycle() { ); assert_eq!( get_token_account_balance(&ctx.svm, &ctx.vault_tsla).unwrap(), - 1_440_000 + 144_000_000 ); assert_eq!( get_token_account_balance(&ctx.svm, &ctx.vault_nvda).unwrap(), - 3_000_000 + 300_000_000 ); assert_holdings_match_vaults(&ctx); @@ -1503,11 +1626,11 @@ fn test_full_lifecycle() { assert_holdings_match_vaults(&ctx); assert_eq!( get_token_account_balance(&ctx.svm, &ctx.vault_tsla).unwrap(), - 1_536_000 + 153_600_000 ); assert_eq!( get_token_account_balance(&ctx.svm, &ctx.vault_nvda).unwrap(), - 2_880_000 + 288_000_000 ); // Bob deposits 480 USDC at NAV 960 -> 450,000,000 shares, deployed 40/60. @@ -1519,16 +1642,17 @@ fn test_full_lifecycle() { ); assert_eq!( get_token_account_balance(&ctx.svm, &ctx.vault_tsla).unwrap(), - 2_304_000 + 230_400_000 ); assert_eq!( get_token_account_balance(&ctx.svm, &ctx.vault_nvda).unwrap(), - 4_320_000 + 432_000_000 ); assert_holdings_match_vaults(&ctx); - // A year passes; the manager collects 1% of the 1,350,000,000 supply = 13,500,000. + // A year passes; the manager collects 1% of the 1,350,000,000 supply = + // 13,500,000, exactly: nothing to round. advance_one_year(&mut ctx); let manager_share = do_collect_fees(&mut ctx); assert_eq!( @@ -1537,15 +1661,16 @@ fn test_full_lifecycle() { ); assert_eq!(read_fund(&ctx).total_shares, 1_363_500_000); - // Alice withdraws all 900,000,000 shares in kind: her 900/1363.5 slice of each vault. + // Alice withdraws all 900,000,000 shares in kind: her 900/1363.5 slice of + // each vault, floored: 1.52079207 TSLAx and 2.85148514 NVDAx. do_withdraw(&mut ctx, &alice, 900_000_000, 0); assert_eq!( get_token_account_balance(&ctx.svm, &derive_ata(&alice.pubkey(), &ctx.tsla_mint)).unwrap(), - 1_520_792 + 152_079_207 ); assert_eq!( get_token_account_balance(&ctx.svm, &derive_ata(&alice.pubkey(), &ctx.nvda_mint)).unwrap(), - 2_851_485 + 285_148_514 ); assert_eq!( get_token_account_balance(&ctx.svm, &derive_ata(&alice.pubkey(), &ctx.usdc_mint)).unwrap(), @@ -1653,10 +1778,10 @@ fn test_deposit_rejects_leg_that_buys_nothing() { ); assert_eq!(read_fund(&ctx).total_shares, 0); - // A deposit large enough to buy some TSLAx goes through. + // A deposit large enough to buy some TSLAx goes through: 1 USDC buys 0.004. let user = fund_user(&mut ctx, 1_000_000); do_deposit_tsla_only(&mut ctx, &user, 1_000_000, 1); - assert_eq!(read_fund(&ctx).asset_holdings[0], 4_000); + assert_eq!(read_fund(&ctx).asset_holdings[0], 400_000); } /// Transfer `amount` of `mint` from the donor's token account straight into a @@ -1718,7 +1843,7 @@ fn test_rebalance_refuses_drift_below_threshold() { FundError::DriftBelowThreshold, "drift under the threshold must not trade", ); - assert_eq!(read_fund(&ctx).asset_holdings[1], 3_000_000); + assert_eq!(read_fund(&ctx).asset_holdings[1], 300_000_000); } /// Churn: once a rebalance has restored the weights, calling it again, in @@ -1762,7 +1887,7 @@ struct ExtraAsset { } fn create_routable_asset(ctx: &mut TestContext, price: i64, rate: u64) -> ExtraAsset { - let mint = create_token_mint(&mut ctx.svm, &ctx.payer, TOKEN_DECIMALS, None).unwrap(); + let mint = create_token_mint(&mut ctx.svm, &ctx.payer, ASSET_DECIMALS, None).unwrap(); let ix = spl_token::instruction::set_authority( &spl_token::ID, &mint, @@ -1880,7 +2005,7 @@ fn test_rebalance_refuses_buying_overweight_asset() { &stranger.pubkey(), ) .unwrap(); - assert!(read_fund(&ctx).asset_holdings[0] > 1_600_000); + assert!(read_fund(&ctx).asset_holdings[0] > 160_000_000); } /// A rebalance values every asset, so it needs every asset's accounts, as a @@ -1931,7 +2056,7 @@ fn test_rebalance_sells_retired_asset() { // All 3 NVDAx sold for 3 USDC, which bought 0.012 TSLAx. let fund = read_fund(&ctx); assert_eq!(fund.asset_holdings[1], 0); - assert_eq!(fund.asset_holdings[0], 1_452_000); + assert_eq!(fund.asset_holdings[0], 145_200_000); assert_eq!(fund.usdc_holdings, 0); assert_holdings_match_vaults(&ctx); @@ -2029,35 +2154,35 @@ fn test_initialize_rejects_threshold_out_of_range() { } /// Valuation scales by each asset's decimals and each feed's exponent. TSLAx -/// here has eight decimals and a Pyth equity feed with exponent -5, while USDC -/// and NVDAx keep six decimals and NVDAx's feed keeps -8. Assuming six decimals -/// and -8 would value Alice's 1.44 TSLAx at $360 * 100 * 1,000, and Bob's -/// deposit would buy almost no shares. Scaled correctly, every figure matches -/// the six-decimal story. +/// here has nine decimals and a Pyth equity feed with exponent -5, while NVDAx +/// keeps its eight decimals and its feed keeps -8, and USDC its six. Assuming +/// eight decimals and -8 would value Alice's 1.44 TSLAx at $360 * 10 * 1,000, +/// and Bob's deposit would buy almost no shares. Scaled correctly, every figure +/// matches the story in major units. #[test] fn test_valuation_scales_by_decimals_and_exponent() { - let mut ctx = setup_with_tsla_decimals(8); + let mut ctx = setup_with_tsla_decimals(9); write_price_feed(&mut ctx.svm, ctx.price_feed_tsla, 25_000_000, -5, 1); // $250 standard_fund(&mut ctx); - assert_eq!(read_asset_config(&ctx, 0).decimals, 8); - assert_eq!(read_asset_config(&ctx, 1).decimals, 6); + assert_eq!(read_asset_config(&ctx, 0).decimals, 9); + assert_eq!(read_asset_config(&ctx, 1).decimals, ASSET_DECIMALS); - // Alice's 900 USDC deploys to 1.44 TSLAx (eight decimals) and 3 NVDAx. + // Alice's 900 USDC deploys to 1.44 TSLAx (nine decimals) and 3 NVDAx. let alice = fund_user(&mut ctx, 900_000_000); let alice_share = do_deposit(&mut ctx, &alice, 900_000_000, 1); assert_eq!( get_token_account_balance(&ctx.svm, &alice_share).unwrap(), 900_000_000 ); - assert_eq!(read_fund(&ctx).asset_holdings[0], 144_000_000); - assert_eq!(read_fund(&ctx).asset_holdings[1], 3_000_000); + assert_eq!(read_fund(&ctx).asset_holdings[0], 1_440_000_000); + assert_eq!(read_fund(&ctx).asset_holdings[1], 300_000_000); // NVDAx to $200. The rebalance computes its trade in the same units: 0.12 // NVDAx sold for 24 USDC, which buys 0.096 TSLAx, back to 40/60. set_nvda_price(&mut ctx, 20_000_000_000, 200_000_000); do_rebalance(&mut ctx, 1, 0); - assert_eq!(read_fund(&ctx).asset_holdings[0], 153_600_000); - assert_eq!(read_fund(&ctx).asset_holdings[1], 2_880_000); + assert_eq!(read_fund(&ctx).asset_holdings[0], 1_536_000_000); + assert_eq!(read_fund(&ctx).asset_holdings[1], 288_000_000); // The fund is worth $960, so Bob's 480 USDC buys 450 shares. let bob = fund_user(&mut ctx, 480_000_000); @@ -2066,8 +2191,8 @@ fn test_valuation_scales_by_decimals_and_exponent() { get_token_account_balance(&ctx.svm, &bob_share).unwrap(), 450_000_000 ); - assert_eq!(read_fund(&ctx).asset_holdings[0], 230_400_000); - assert_eq!(read_fund(&ctx).asset_holdings[1], 4_320_000); + assert_eq!(read_fund(&ctx).asset_holdings[0], 2_304_000_000); + assert_eq!(read_fund(&ctx).asset_holdings[1], 432_000_000); assert_holdings_match_vaults(&ctx); } @@ -2092,7 +2217,7 @@ fn test_wide_confidence_price_rejected() { ctx.price_feed_nvda, 20_000_000_000, 400_000_000, - -8, + PYTH_EXPONENT, 1, ); @@ -2113,11 +2238,11 @@ fn test_wide_confidence_price_rejected() { do_withdraw(&mut ctx, &alice, 450_000_000, 0); assert_eq!( get_token_account_balance(&ctx.svm, &derive_ata(&alice.pubkey(), &ctx.tsla_mint)).unwrap(), - 720_000 + 72_000_000 ); assert_eq!( get_token_account_balance(&ctx.svm, &derive_ata(&alice.pubkey(), &ctx.nvda_mint)).unwrap(), - 1_500_000 + 150_000_000 ); // A $2 interval is exactly 1% of the price, which is accepted: the @@ -2127,11 +2252,11 @@ fn test_wide_confidence_price_rejected() { ctx.price_feed_nvda, 20_000_000_000, 200_000_000, - -8, + PYTH_EXPONENT, 1, ); do_rebalance(&mut ctx, 1, 0); - assert_eq!(read_fund(&ctx).asset_holdings[0], 768_000); - assert_eq!(read_fund(&ctx).asset_holdings[1], 1_440_000); + assert_eq!(read_fund(&ctx).asset_holdings[0], 76_800_000); + assert_eq!(read_fund(&ctx).asset_holdings[1], 144_000_000); assert_holdings_match_vaults(&ctx); } diff --git a/finance/managed-fund/kani-proofs/README.md b/finance/managed-fund/kani-proofs/README.md index 69f402a6..c8f2d038 100644 --- a/finance/managed-fund/kani-proofs/README.md +++ b/finance/managed-fund/kani-proofs/README.md @@ -21,7 +21,7 @@ input, the harnesses check: - `proof_deposit_withdraw_cannot_extract`: A deposit→withdraw round-trip never returns more than was deposited, no rounding attack mints shares worth more than they cost. - `proof_recorded_holdings_never_exceed_balance`: The program prices shares and pays withdrawals from its recorded holdings, not vault balances. Across a deposit, a donation, and a withdrawal, the recorded holding never exceeds the vault's real balance, so every payout is covered however much is donated. - `proof_donation_cannot_dilute_next_deposit`: The inflation attack modelled directly: after an attacker's first deposit and a donation of any size, the victim's deposit mints exactly one share per minor unit and withdraws in full. -- `proof_fee_shares_bounded_by_supply`: The time-based manager fee can never mint more than 100%/year of dilution (`fee_shares <= total_shares` for `elapsed <= 1yr`, `fee_bps <= 10000`). +- `proof_fee_shares_bounded_by_supply`: The time-based manager fee, `ceil(total_shares·fee_bps·elapsed/(10000·seconds_per_year))`, can never mint more than 100%/year of dilution (`fee_shares <= total_shares` for `elapsed <= 1yr`, `fee_bps <= 10000`), and rounds up: it is never below the exact quotient and never more than one share above it. ## Bounded model checking diff --git a/finance/managed-fund/kani-proofs/src/lib.rs b/finance/managed-fund/kani-proofs/src/lib.rs index 1feb1216..1c1a3ff4 100644 --- a/finance/managed-fund/kani-proofs/src/lib.rs +++ b/finance/managed-fund/kani-proofs/src/lib.rs @@ -9,7 +9,7 @@ //! The program is an ERC4626-style share vault: depositors mint share tokens //! against the fund's net asset value, and withdrawals burn shares for a //! proportional slice of every vault balance. A manager fee mints a small slice -//! of shares over time. Token movement is via SPL CPIs Kani cannot symbolically +//! of shares over time, rounded up. Token movement is via SPL CPIs Kani cannot symbolically //! execute, but the share math (`deposit`, `withdraw`, `collect_fees`) is pure //! integer arithmetic. This crate reproduces it faithfully and checks the //! invariants the fund's solvency rests on. @@ -40,6 +40,31 @@ pub fn mul_div_floor(a: u128, b: u128, d: u128) -> Option { a.checked_mul(b)?.checked_div(d) } +/// `ceil((a*b)/d)`, `None` on overflow / zero divisor. +pub fn mul_div_ceil(a: u128, b: u128, d: u128) -> Option { + if d == 0 { + return None; + } + Some(a.checked_mul(b)?.div_ceil(d)) +} + +/// Shares minted to the manager for `elapsed` seconds of the annual fee: +/// `ceil(total_shares * fee_bps * elapsed / (10_000 * SECONDS_PER_YEAR))`, +/// the formula `handle_collect_fees` applies. The fee is owed to the manager, +/// so a fraction of a share rounds up to a whole one. +pub fn fee_shares( + total_shares: u64, + fee_bps: u16, + elapsed: u64, + seconds_per_year: u64, +) -> Option { + let denominator = 10_000u128.checked_mul(seconds_per_year as u128)?; + let numerator_factor = (fee_bps as u128).checked_mul(elapsed as u128)?; + mul_div_ceil(total_shares as u128, numerator_factor, denominator)? + .try_into() + .ok() +} + /// Proportional withdrawal from one vault's recorded holding: /// `floor(holding * shares / total)` — the formula `handle_withdraw` applies to /// the USDC leg and to every basket asset. @@ -260,12 +285,15 @@ fn proof_donation_cannot_dilute_next_deposit() { // =========================================================================== /// The time-based manager fee mints -/// `fee_shares = floor(total_shares * fee_bps * elapsed / (10_000 * SECONDS_PER_YEAR))`. +/// `fee_shares = ceil(total_shares * fee_bps * elapsed / (10_000 * SECONDS_PER_YEAR))`. /// Over at most one year (`elapsed <= SECONDS_PER_YEAR`) with a valid fee rate /// (`fee_bps <= 10_000`), the combined numerator factor `fee_bps * elapsed` is -/// `<= 10_000 * SECONDS_PER_YEAR`, so `fee_shares <= total_shares`: the manager -/// can never mint more than a 100%-per-year dilution. Modelled with the combined -/// `numerator_factor <= denominator` (the constraint the two bounds imply). +/// `<= 10_000 * SECONDS_PER_YEAR`, so the exact quotient is `<= total_shares`, +/// and rounding it up to a whole share cannot pass an integer bound: the +/// manager can never mint more than a 100%-per-year dilution. Modelled with the +/// combined `numerator_factor <= denominator` (the constraint the two bounds +/// imply). The rounding itself is checked too: the fee is never below the exact +/// quotient, and never more than one share above it. #[cfg(kani)] #[kani::proof] #[kani::solver(cadical)] @@ -280,8 +308,15 @@ fn proof_fee_shares_bounded_by_supply() { kani::assume(numerator_factor <= denominator); let fee_shares = - mul_div_floor(total_shares as u128, numerator_factor, denominator).expect("computes"); + mul_div_ceil(total_shares as u128, numerator_factor, denominator).expect("computes"); assert!(fee_shares <= total_shares as u128); // <= 100%/year dilution + + // Rounds up: at least the exact quotient, and less than one share over it. + let exact_floor = + mul_div_floor(total_shares as u128, numerator_factor, denominator).expect("computes"); + assert!(fee_shares >= exact_floor); + assert!(fee_shares <= exact_floor + 1); + assert!(fee_shares * denominator >= total_shares as u128 * numerator_factor); } // =========================================================================== @@ -390,9 +425,9 @@ mod tests { asset_value_in_usdc(144_000_000, 25_000_000, -5, 8, 6).unwrap(), 360_000_000 ); - // 3 NVDAx at six decimals, $180 at exponent -8, is 540 USDC. + // 3 NVDAx at eight decimals, $180 at exponent -8, is 540 USDC. assert_eq!( - asset_value_in_usdc(3_000_000, 18_000_000_000, -8, 6, 6).unwrap(), + asset_value_in_usdc(300_000_000, 18_000_000_000, -8, 8, 6).unwrap(), 540_000_000 ); // 24 USDC buys 0.096 TSLAx at eight decimals. @@ -402,6 +437,32 @@ mod tests { ); } + #[test] + fn fee_rounds_up_against_the_holders() { + const SECONDS_PER_YEAR: u64 = 31_536_000; + // The book's year: 1% of 1,350,000,000 shares is 13,500,000, exactly. + assert_eq!( + fee_shares(1_350_000_000, 100, SECONDS_PER_YEAR, SECONDS_PER_YEAR).unwrap(), + 13_500_000 + ); + // One day of 1% on 1,000,000,000 shares is 27,397.26: the manager is + // minted 27,398. + assert_eq!( + fee_shares(1_000_000_000, 100, 86_400, SECONDS_PER_YEAR).unwrap(), + 27_398 + ); + // A fee owed at all is at least one share, however short the period. + assert_eq!( + fee_shares(1_000_000_000, 100, 1, SECONDS_PER_YEAR).unwrap(), + 1 + ); + // No supply, no fee. + assert_eq!( + fee_shares(0, 100, SECONDS_PER_YEAR, SECONDS_PER_YEAR).unwrap(), + 0 + ); + } + #[test] fn rebalance_trades_the_smaller_gap() { // The book's rebalance: NVDAx $24 over its $576 target, TSLAx $24 under. diff --git a/finance/managed-fund/quasar/CHANGELOG.md b/finance/managed-fund/quasar/CHANGELOG.md index c94e2009..a5fadaaa 100644 --- a/finance/managed-fund/quasar/CHANGELOG.md +++ b/finance/managed-fund/quasar/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [Unreleased] - 2026-10-05 + +### Changed + +- The management fee rounds up. `collect_fees` mints + `ceil(total_shares × fee_bps × elapsed / (10_000 × SECONDS_PER_YEAR))` + shares to the manager, so a fraction of a share owed is minted as a whole + share and the rounding dilutes the holders. New `test_collect_fees` checks a + year's fee on 1,000,000,000 shares is 10,000,000, exactly, and + `test_collect_fees_rounds_up` that a day's fee, 27,397.26 shares, mints + 27,398. +- The two-asset tests mint TSLAx and NVDAx with eight decimals, as the real + tokens have, and USDC with six (`ASSET_DECIMALS`, `USDC_DECIMALS`, and + `SINGLE_ASSET_DECIMALS` for the single-asset fund's asset, replacing + `DECIMALS`). Every asserted basket amount is in eight-decimal minor units + and unchanged in major units. `test_valuation_scales_by_decimals_and_exponent` + varies TSLAx to nine decimals on its exponent −5 feed. + ## [2026-10-03] ### Fixed diff --git a/finance/managed-fund/quasar/README.md b/finance/managed-fund/quasar/README.md index 78867879..210dbefc 100644 --- a/finance/managed-fund/quasar/README.md +++ b/finance/managed-fund/quasar/README.md @@ -67,7 +67,9 @@ themselves or pair a real mint with a feed they control. the oracle price so a bad swap route reverts, and the buy leg spends only what the sale brought in. - `collect_fees` accrues the time-based management fee by minting fresh shares to - the manager, diluting holders at the configured annual rate. + the manager, diluting holders at the configured annual rate. The fee rounds + up: a fraction of a share owed is minted as a whole share, so the rounding + dilutes the holders rather than shorting the manager. Every swap and rebalance leg is bounded by the registered price feed: the program computes the oracle-implied output and rejects any swap that falls short @@ -103,7 +105,9 @@ withdraw), in index order. along deposit, withdraw, and rebalance. There is no manager path to withdraw holdings, nor to choose a trade: rebalancing is sized by the program. - Value computations use u128 intermediates with checked arithmetic, flooring - in the fund's favour. + in the fund's favour: a depositor's shares and a withdrawer's payout round + down. The management fee rounds up, so the manager is never minted less than + the fee owed. - The management fee is capped (10% per year) and the slippage tolerance is capped (10%), so neither can be configured to drain the fund. - Price feeds are validated against the address recorded on the asset config and @@ -160,11 +164,17 @@ posted before a cluster restart is rejected until Pyth posts again. The rebalance tests sign as a stranger and check that a fund at its targets, within its threshold, or just rebalanced cannot be traded (`test_rebalance_cannot_churn` and its neighbors), and -`test_valuation_scales_by_decimals_and_exponent` runs the story with an -eight-decimal asset on an exponent −5 feed. `test_wide_confidence_price_rejected` +`test_valuation_scales_by_decimals_and_exponent` runs the story with a +nine-decimal TSLAx on an exponent −5 feed. `test_wide_confidence_price_rejected` widens NVDAx's confidence interval to 2% of its price and checks that deposit and rebalance are refused while withdraw still pays out, and that a band of -exactly 1% is accepted. +exactly 1% is accepted. `test_collect_fees` checks a year's 1% fee on +1,000,000,000 shares is 10,000,000 shares, exactly, and +`test_collect_fees_rounds_up` that a day's fee, 27,397.26 shares, is minted as +27,398. The two-asset tests mint TSLAx and NVDAx with eight decimals, as the +real tokens are, and USDC with six, so their basket amounts are in +eight-decimal minor units. Every refusal test asserts the error code it fails +with. ## Extending diff --git a/finance/managed-fund/quasar/managed-fund/src/instructions/collect_fees.rs b/finance/managed-fund/quasar/managed-fund/src/instructions/collect_fees.rs index 233ffdee..d4f0e555 100644 --- a/finance/managed-fund/quasar/managed-fund/src/instructions/collect_fees.rs +++ b/finance/managed-fund/quasar/managed-fund/src/instructions/collect_fees.rs @@ -50,7 +50,9 @@ pub fn handle_collect_fees( let fund_index = u64::from(accounts.fund.index); let fund_bump = accounts.fund.bump; - // fee_shares = total_shares * fee_bps * elapsed / (10_000 * SECONDS_PER_YEAR) + // fee_shares = ceil(total_shares * fee_bps * elapsed / (10_000 * SECONDS_PER_YEAR)). + // The fee is owed to the manager, so it rounds up: a fraction of a share + // becomes a whole share, and the holders bear the rounding. let denominator = (10_000u128) .checked_mul(SECONDS_PER_YEAR as u128) .ok_or(FundError::MathOverflow)?; @@ -59,12 +61,11 @@ pub fn handle_collect_fees( .ok_or(FundError::MathOverflow)? .checked_mul(elapsed_seconds as u128) .ok_or(FundError::MathOverflow)? - .checked_div(denominator) - .ok_or(FundError::MathOverflow)? + .div_ceil(denominator) .try_into() .map_err(|_| FundError::MathOverflow)?; - // Advance the accrual clock even when the fee rounds to zero. + // Advance the accrual clock even when the fee is zero (no supply, or no fee). let mut fund = snapshot_fund(&accounts.fund); fund.last_fee_accrual_timestamp = now; if fee_shares == 0 { diff --git a/finance/managed-fund/quasar/managed-fund/src/tests.rs b/finance/managed-fund/quasar/managed-fund/src/tests.rs index 3fbf412e..8ff19a26 100644 --- a/finance/managed-fund/quasar/managed-fund/src/tests.rs +++ b/finance/managed-fund/quasar/managed-fund/src/tests.rs @@ -14,17 +14,19 @@ //! a repeat call, an over-weight buy side, missing asset accounts and donated //! tokens are all refused, while a retired asset is sold out. //! `test_initialize_rejects_threshold_out_of_range` bounds the threshold, and -//! `test_valuation_scales_by_decimals_and_exponent` values an eight-decimal -//! asset priced by an exponent -5 feed, and +//! `test_valuation_scales_by_decimals_and_exponent` values a nine-decimal +//! asset priced by an exponent -5 feed, //! `test_wide_confidence_price_rejected` shows a feed whose confidence interval -//! is past 1% of its price stops deposit and rebalance but not withdraw. +//! is past 1% of its price stops deposit and rebalance but not withdraw, and +//! `test_collect_fees` and `test_collect_fees_rounds_up` mint the manager a +//! year's fee exactly and a day's fee rounded up. use { crate::{ cpi::{ - AddAssetInstruction, ApproveAssetInstruction, DepositInstruction, - InitializeFundInstruction, InitializeRegistryInstruction, RebalanceInstruction, - SetWeightInstruction, WithdrawInstruction, + AddAssetInstruction, ApproveAssetInstruction, CollectFeesInstruction, + DepositInstruction, InitializeFundInstruction, InitializeRegistryInstruction, + RebalanceInstruction, SetWeightInstruction, WithdrawInstruction, }, errors::FundError, instructions::initialize_fund::{MAX_REBALANCE_THRESHOLD_BPS, MIN_REBALANCE_THRESHOLD_BPS}, @@ -36,9 +38,20 @@ use { quasar_test::prelude::*, }; -const DECIMALS: u8 = 6; +const USDC_DECIMALS: u8 = 6; +/// The single-asset fund's asset shares USDC's six decimals, so one of its +/// minor units is priced in whole USDC minor units (250 at `RATE`), which the +/// donation and dust tests lean on. +const SINGLE_ASSET_DECIMALS: u8 = 6; /// One whole six-decimal token, in minor units. const ONE_TOKEN: u64 = 1_000_000; +/// TSLAx and NVDAx carry the eight decimals the real tokens have. The fund +/// reads each mint's decimals rather than assuming USDC's six, so every +/// two-asset amount below is in eight-decimal minor units while shares and USDC +/// stay in six. +const ASSET_DECIMALS: u8 = 8; +const SECONDS_PER_YEAR: i64 = 31_536_000; +const SECONDS_PER_DAY: i64 = 86_400; const FEE_BPS: u16 = 100; const MAX_SLIPPAGE_BPS: u16 = 100; const REBALANCE_THRESHOLD_BPS: u16 = 200; // two percentage points @@ -170,11 +183,11 @@ fn pdas(test: &Test) -> Pdas { fn setup_fund(test: &mut Test, asset_mint_authority: Pubkey) { test.add(Wallet::new().at(AUTHORITY)); test.add(Wallet::new().at(MANAGER)); - test.add(Mint::new(AUTHORITY).at(USDC_MINT).decimals(DECIMALS)); + test.add(Mint::new(AUTHORITY).at(USDC_MINT).decimals(USDC_DECIMALS)); test.add( Mint::new(asset_mint_authority) .at(ASSET_MINT) - .decimals(DECIMALS), + .decimals(SINGLE_ASSET_DECIMALS), ); let registry = test.derive_pda(Registry::seeds(&AUTHORITY)); @@ -638,6 +651,10 @@ fn usdc_vault_pda(test: &Test) -> Pubkey { test.derive_pda(UsdcVaultPda::seeds(&fund_pda(test))) } +fn share_mint_pda(test: &Test) -> Pubkey { + test.derive_pda(ShareMintPda::seeds(&fund_pda(test))) +} + fn approve_asset(test: &mut Test, mint: Pubkey, feed: Pubkey) { test.send(ApproveAssetInstruction { authority: AUTHORITY, @@ -650,15 +667,15 @@ fn approve_asset(test: &mut Test, mint: Pubkey, feed: Pubkey) { /// Mints, router (config + rates + treasury), Pyth feeds, and a registry with /// TSLAx and NVDAx approved. Does not create the fund. fn setup_full(test: &mut Test) { - setup_with_tsla_decimals(test, DECIMALS); + setup_with_tsla_decimals(test, ASSET_DECIMALS); } -/// `setup_full`, with TSLAx minted at `tsla_decimals` instead of six. +/// `setup_full`, with TSLAx minted at `tsla_decimals` instead of eight. fn setup_with_tsla_decimals(test: &mut Test, tsla_decimals: u8) { load_router(test); test.add(Wallet::new().at(AUTHORITY)); test.add(Wallet::new().at(MANAGER)); - test.add(Mint::new(AUTHORITY).at(USDC_MINT).decimals(DECIMALS)); + test.add(Mint::new(AUTHORITY).at(USDC_MINT).decimals(USDC_DECIMALS)); // The router config account is every asset's mint authority, so the router // can mint it on swap. test.add( @@ -669,7 +686,7 @@ fn setup_with_tsla_decimals(test: &mut Test, tsla_decimals: u8) { test.add( Mint::new(router_config_pda()) .at(NVDA_MINT) - .decimals(DECIMALS), + .decimals(ASSET_DECIMALS), ); write_price_feed(test, TSLA_FEED, TSLA_PRICE, -8, NOW, 1); write_price_feed(test, NVDA_FEED, NVDA_PRICE, -8, NOW, 1); @@ -758,11 +775,10 @@ struct User { fn fund_user(test: &mut Test, usdc_amount: u64) -> User { let owner = test.add(Wallet::new()); - let share_mint = test.derive_pda(ShareMintPda::seeds(&fund_pda(test))); User { owner, usdc: test.add(TokenAccount::new(USDC_MINT, owner).amount(usdc_amount)), - share: test.add(TokenAccount::new(share_mint, owner)), + share: test.add(TokenAccount::new(share_mint_pda(test), owner)), tsla: test.add(TokenAccount::new(TSLA_MINT, owner)), nvda: test.add(TokenAccount::new(NVDA_MINT, owner)), } @@ -935,8 +951,8 @@ fn test_rebalance(test: &mut Test) { // 1.44 + 0.096 = 1.536 TSLAx; 3.0 - 0.12 = 2.88 NVDAx. Now 384 / 576 = 40 / 60. // The USDC vault nets to zero across the two legs. - assert_eq!(test.tokens(asset_vault_pda(test, 0)), 1_536_000); - assert_eq!(test.tokens(asset_vault_pda(test, 1)), 2_880_000); + assert_eq!(test.tokens(asset_vault_pda(test, 0)), 153_600_000); + assert_eq!(test.tokens(asset_vault_pda(test, 1)), 288_000_000); assert_eq!(test.tokens(usdc_vault_pda(test)), 0); assert_holdings_match_vaults(test); } @@ -968,7 +984,7 @@ fn test_rebalance_refuses_drift_below_threshold(test: &mut Test) { // target and under the two-point threshold. set_nvda_price(test, 18_500_000_000, 185_000_000); try_rebalance(test, 1, 0).fails_with(FundError::DriftBelowThreshold); - assert_eq!(read_holdings(test).1[1], 3_000_000); + assert_eq!(read_holdings(test).1[1], 300_000_000); } /// Churn: once a rebalance has restored the weights, calling it again, in @@ -998,7 +1014,7 @@ fn create_routable_asset(test: &mut Test, price: i64, rate: u64) { test.add( Mint::new(router_config_pda()) .at(THIRD_MINT) - .decimals(DECIMALS), + .decimals(ASSET_DECIMALS), ); set_router_rate(test, THIRD_MINT, rate); write_price_feed(test, THIRD_FEED, price, -8, NOW, 1); @@ -1054,7 +1070,7 @@ fn test_rebalance_refuses_buying_overweight_asset(test: &mut Test) { // Selling NVDAx into TSLAx, the asset that is under, goes through. test.send(rebalance_instruction_with(stranger, 1, 0, remaining)) .succeeds(); - assert!(read_holdings(test).1[0] > 1_600_000); + assert!(read_holdings(test).1[0] > 160_000_000); } /// A rebalance values every asset, so it needs every asset's accounts, as a @@ -1097,7 +1113,7 @@ fn test_rebalance_sells_retired_asset(test: &mut Test) { // All 3 NVDAx sold for 3 USDC, which bought 0.012 TSLAx. let (usdc_holdings, asset_holdings) = read_holdings(test); assert_eq!(asset_holdings[1], 0); - assert_eq!(asset_holdings[0], 1_452_000); + assert_eq!(asset_holdings[0], 145_200_000); assert_eq!(usdc_holdings, 0); assert_holdings_match_vaults(test); @@ -1166,45 +1182,45 @@ fn test_initialize_rejects_threshold_out_of_range(test: &mut Test) { } /// Valuation scales by each asset's decimals and each feed's exponent. TSLAx -/// here has eight decimals and a Pyth equity feed with exponent -5, while USDC -/// and NVDAx keep six decimals and NVDAx's feed keeps -8. Assuming six decimals -/// and -8 would value Alice's 1.44 TSLAx at $360 * 100 * 1,000, and Bob's -/// deposit would buy almost no shares. Scaled correctly, every figure matches -/// the six-decimal story. +/// here has nine decimals and a Pyth equity feed with exponent -5, while NVDAx +/// keeps its eight decimals and its feed keeps -8, and USDC its six. Assuming +/// eight decimals and -8 would value Alice's 1.44 TSLAx at $360 * 10 * 1,000, +/// and Bob's deposit would buy almost no shares. Scaled correctly, every figure +/// matches the story in major units. #[quasar_test] fn test_valuation_scales_by_decimals_and_exponent(test: &mut Test) { - setup_with_tsla_decimals(test, 8); + setup_with_tsla_decimals(test, 9); write_price_feed(test, TSLA_FEED, 25_000_000, -5, NOW, 1); // $250 standard_fund(test); assert_eq!( test.read::(asset_config_pda(test, 0)).decimals, - 8 + 9 ); assert_eq!( test.read::(asset_config_pda(test, 1)).decimals, - 6 + ASSET_DECIMALS ); - // Alice's 900 USDC deploys to 1.44 TSLAx (eight decimals) and 3 NVDAx. + // Alice's 900 USDC deploys to 1.44 TSLAx (nine decimals) and 3 NVDAx. let alice = fund_user(test, 900_000_000); do_deposit(test, &alice, 900_000_000); assert_eq!(test.tokens(alice.share), 900_000_000); - assert_eq!(read_holdings(test).1[0], 144_000_000); - assert_eq!(read_holdings(test).1[1], 3_000_000); + assert_eq!(read_holdings(test).1[0], 1_440_000_000); + assert_eq!(read_holdings(test).1[1], 300_000_000); // NVDAx to $200. The rebalance computes its trade in the same units: 0.12 // NVDAx sold for 24 USDC, which buys 0.096 TSLAx, back to 40/60. set_nvda_price(test, 20_000_000_000, 200_000_000); do_rebalance(test, 1, 0); - assert_eq!(read_holdings(test).1[0], 153_600_000); - assert_eq!(read_holdings(test).1[1], 2_880_000); + assert_eq!(read_holdings(test).1[0], 1_536_000_000); + assert_eq!(read_holdings(test).1[1], 288_000_000); // The fund is worth $960, so Bob's 480 USDC buys 450 shares. let bob = fund_user(test, 480_000_000); do_deposit(test, &bob, 480_000_000); assert_eq!(test.tokens(bob.share), 450_000_000); - assert_eq!(read_holdings(test).1[0], 230_400_000); - assert_eq!(read_holdings(test).1[1], 4_320_000); + assert_eq!(read_holdings(test).1[0], 2_304_000_000); + assert_eq!(read_holdings(test).1[1], 432_000_000); assert_holdings_match_vaults(test); } @@ -1233,15 +1249,65 @@ fn test_wide_confidence_price_rejected(test: &mut Test) { // Withdraw reads no price: Alice takes half her shares out in kind. do_withdraw(test, &alice, 450_000_000); - assert_eq!(test.tokens(alice.tsla), 720_000); - assert_eq!(test.tokens(alice.nvda), 1_500_000); + assert_eq!(test.tokens(alice.tsla), 72_000_000); + assert_eq!(test.tokens(alice.nvda), 150_000_000); // A $2 interval is exactly 1% of the price, which is accepted: the // rebalance sells 0.06 NVDAx for 12 USDC and buys 0.048 TSLAx. write_price_feed_with_confidence(test, NVDA_FEED, 20_000_000_000, 200_000_000, -8, NOW, 1); do_rebalance(test, 1, 0); let (_, holdings) = read_holdings(test); - assert_eq!(holdings[0], 768_000); - assert_eq!(holdings[1], 1_440_000); + assert_eq!(holdings[0], 76_800_000); + assert_eq!(holdings[1], 144_000_000); assert_holdings_match_vaults(test); } + +/// The manager's share token account, and the fee collection that mints to +/// it. Anyone may send it; the authority pays here. +fn collect_fees(test: &mut Test) -> (Pubkey, CollectFeesInstruction) { + let manager_share = test.add(TokenAccount::new(share_mint_pda(test), MANAGER)); + let instruction = CollectFeesInstruction { + manager: MANAGER, + fund_index_seed: FUND_INDEX, + manager_share_account: manager_share, + payer: AUTHORITY, + }; + (manager_share, instruction) +} + +/// A year's fee on a 1,000 USDC fund: 1% of 1,000,000,000 shares is +/// 10,000,000 shares, exactly, minted to the manager on top of the supply. +#[quasar_test] +fn test_collect_fees(test: &mut Test) { + setup_full(test); + standard_fund(test); + let user = fund_user(test, 1_000_000_000); + do_deposit(test, &user, 1_000_000_000); + + test.warp_to_timestamp(NOW + SECONDS_PER_YEAR); + let (manager_share, instruction) = collect_fees(test); + test.send(instruction) + .succeeds() + .has_tokens(manager_share, 10_000_000); + let fund = test.read::(fund_pda(test)); + assert_eq!(u64::from(fund.total_shares), 1_010_000_000); +} + +/// A fee that is not a whole number of shares rounds up, against the holders: +/// one day of a 1% fee on 1,000,000,000 shares is 27,397.26 shares, and the +/// manager is minted 27,398. +#[quasar_test] +fn test_collect_fees_rounds_up(test: &mut Test) { + setup_full(test); + standard_fund(test); + let user = fund_user(test, 1_000_000_000); + do_deposit(test, &user, 1_000_000_000); + + test.warp_to_timestamp(NOW + SECONDS_PER_DAY); + let (manager_share, instruction) = collect_fees(test); + test.send(instruction) + .succeeds() + .has_tokens(manager_share, 27_398); + let fund = test.read::(fund_pda(test)); + assert_eq!(u64::from(fund.total_shares), 1_000_027_398); +} diff --git a/finance/options/anchor-v1/CHANGELOG.md b/finance/options/anchor-v1/CHANGELOG.md index b1507c10..00f5b52d 100644 --- a/finance/options/anchor-v1/CHANGELOG.md +++ b/finance/options/anchor-v1/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## Unreleased, 2026-10-05 + +The venue's fee rounds up: `split_premium` takes the ceiling of +`premium * fee_bps / 10_000` and pays the writer the premium minus the fee, +so the venue, not the writer, takes the rounding minor unit on a premium that +is not a multiple of the rate. The walkthrough's premiums (25 and 20 USDC at +1%) are multiples of the rate, so their fees (0.25 and 0.20 USDC) are the +same either way. New test `test_fee_rounds_up_and_the_writer_takes_the_remainder` +buys a 10.000001 USDC option and checks a fee of 0.100001 USDC and 9.90 USDC +to the writer. The Kani harness `proof_premium_split_conserves_the_premium` +checks the exact ceiling, and its unit tests the rounded cases, including a +one-minor-unit premium that rounds entirely into the fee at the highest rate. +NVDAx is minted with 8 decimals in the suite, as the real token is, and USDC +with 6: 5 NVDAx is 500,000,000 minor units; every USDC amount is unchanged. + ## Unreleased, 2026-10-04 The LiteSVM suite asserts the error code of every refusal diff --git a/finance/options/anchor-v1/README.md b/finance/options/anchor-v1/README.md index a77821fa..bc1d4bd7 100644 --- a/finance/options/anchor-v1/README.md +++ b/finance/options/anchor-v1/README.md @@ -73,8 +73,10 @@ minor-unit amounts that change hands on exercise, both chosen by the writer. `strike_amount` is the strike for the whole option, as an amount rather than a price. The collateral, the exercise payment and the proceeds are each one of those two amounts, so settlement does no arithmetic at all: nothing is -multiplied, divided or rounded. The only rounding in the program is the floor -in the venue's fee. +multiplied, divided or rounded. The only rounding in the program is in the +venue's fee, which rounds up: the writer receives the premium minus the fee, +so the venue, not the writer, takes the rounding minor unit on a premium that +is not a multiple of the rate. ### Expiry is one comparison and its complement @@ -90,7 +92,7 @@ deadline is one. ### Participants - **Maria** operates the venue and earns 1% of every premium. -- **Alice** holds 5 NVDAx (tokenized NVIDIA stock, 6 decimals) she would be +- **Alice** holds 5 NVDAx (tokenized NVIDIA stock, 8 decimals) she would be happy to sell at $180, and wants to be paid while she waits. - **Bob** thinks NVIDIA will rally past $180 within the week and wants that upside for less than the price of 5 shares. @@ -122,7 +124,9 @@ any time until someone does. `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, as two transfers (a -venue with a zero fee makes only the first). The 5 NVDAx do not move. The +venue with a zero fee makes only the first). 25 USDC is a multiple of the +rate, so nothing rounds; a premium of 10.000001 USDC would owe a fee of +0.100001 USDC, rounded up from 0.10000001, and pay the writer 9.90 USDC. 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 @@ -222,9 +226,12 @@ The LiteSVM suite (`programs/options/tests/test_options.rs`) walks the call 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 +pins every balance to the minor unit, checks that the fee on a premium that is +not a multiple of the rate rounds up and the writer receives the rest +(`test_fee_rounds_up_and_the_writer_takes_the_remainder`), counts the token +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, diff --git a/finance/options/anchor-v1/programs/options/src/contract_math.rs b/finance/options/anchor-v1/programs/options/src/contract_math.rs index 4a6708a1..0eeef9f9 100644 --- a/finance/options/anchor-v1/programs/options/src/contract_math.rs +++ b/finance/options/anchor-v1/programs/options/src/contract_math.rs @@ -4,9 +4,9 @@ //! //! Settlement does no arithmetic at all: the option stores the two amounts //! that change hands on exercise, so nothing is multiplied, divided or -//! rounded. The only rounding in the program is the floor in the fee split, -//! which returns `None` on the paths the program maps to -//! `OptionsError::MathOverflow`. +//! rounded. The only rounding in the program is in the fee split, where the +//! fee rounds up and the writer takes the remainder; the split returns `None` +//! on the paths the program maps to `OptionsError::MathOverflow`. use crate::state::OptionKind; @@ -35,13 +35,18 @@ pub fn exercise_payment(kind: OptionKind, underlying_amount: u64, strike_amount: } /// Split a premium into the venue's fee and the writer's share. The fee -/// floors, so the writer receives the rounding minor unit; the venue gives up -/// at most one minor unit per sale, and a sale needs a real premium, so the -/// leak cannot be industrialized. +/// rounds up, in the venue's favor, and the writer receives the premium minus +/// the fee: a fee that floored would hand the writer the rounding minor unit +/// on every sale whose premium is not a multiple of the rate. The two shares +/// always sum to the premium, so the buyer never pays more than the writer +/// asked. With the rate under 100% the fee never exceeds the premium, but a +/// premium of a single minor unit rounds entirely into the fee. pub fn split_premium(premium: u64, fee_bps: u16) -> Option<(u64, u64)> { + // The product of a u64 and a u16 is far below u128::MAX, so the ceiling + // division cannot overflow. let fee = (premium as u128) .checked_mul(fee_bps as u128)? - .checked_div(BASIS_POINTS)?; + .div_ceil(BASIS_POINTS); let fee = u64::try_from(fee).ok()?; let to_writer = premium.checked_sub(fee)?; Some((fee, to_writer)) diff --git a/finance/options/anchor-v1/programs/options/src/instructions/write_option.rs b/finance/options/anchor-v1/programs/options/src/instructions/write_option.rs index 2416534b..f650c33b 100644 --- a/finance/options/anchor-v1/programs/options/src/instructions/write_option.rs +++ b/finance/options/anchor-v1/programs/options/src/instructions/write_option.rs @@ -17,7 +17,7 @@ pub struct OptionTerms { pub kind: OptionKind, /// Underlying minor units the option covers: what a call writer posts and - /// a call holder receives, or a put holder delivers (1 NVDAx = 1_000_000). + /// a call holder receives, or a put holder delivers (1 NVDAx = 100_000_000). pub underlying_amount: u64, /// Quote minor units paid for the underlying on exercise: what a put diff --git a/finance/options/anchor-v1/programs/options/src/state/option_contract.rs b/finance/options/anchor-v1/programs/options/src/state/option_contract.rs index 0f01bb45..366a5adb 100644 --- a/finance/options/anchor-v1/programs/options/src/state/option_contract.rs +++ b/finance/options/anchor-v1/programs/options/src/state/option_contract.rs @@ -48,7 +48,7 @@ pub struct OptionContract { pub status: OptionStatus, /// Underlying minor units the option covers: what a call writer posts and - /// a call holder receives, or a put holder delivers (1 NVDAx = 1_000_000). + /// a call holder receives, or a put holder delivers (1 NVDAx = 100_000_000). pub underlying_amount: u64, /// Quote minor units paid for the underlying on exercise: what a put 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 656913d2..09aaed78 100644 --- a/finance/options/anchor-v1/programs/options/tests/test_options.rs +++ b/finance/options/anchor-v1/programs/options/tests/test_options.rs @@ -21,33 +21,35 @@ use { solana_transaction::Transaction, }; -// Both tokens have 6 decimals: the underlying is NVDAx (tokenized NVIDIA -// stock) and the quote is USDC, so one whole unit of either is 1_000_000 -// minor units. -const ONE_TOKEN: u64 = 1_000_000; -const DECIMALS: u8 = 6; +// The underlying is NVDAx (tokenized NVIDIA stock), which has 8 decimals, +// and the quote is USDC, which has 6: one NVDAx is 100_000_000 minor units +// and one USDC is 1_000_000. +const NVDAX_DECIMALS: u8 = 8; +const USDC_DECIMALS: u8 = 6; +const ONE_NVDAX: u64 = 100_000_000; +const ONE_USDC: u64 = 1_000_000; // The venue charges 1% of every premium. const FEE_BPS: u16 = 100; // The walkthrough's call: 5 NVDAx at a strike of 180 USDC each, 900 USDC in // all, asking 25 USDC for the option, expiring a week out. -const CALL_UNDERLYING: u64 = 5 * ONE_TOKEN; -const CALL_STRIKE_AMOUNT: u64 = 900 * ONE_TOKEN; -const CALL_PREMIUM: u64 = 25 * ONE_TOKEN; +const CALL_UNDERLYING: u64 = 5 * ONE_NVDAX; +const CALL_STRIKE_AMOUNT: u64 = 900 * ONE_USDC; +const CALL_PREMIUM: u64 = 25 * ONE_USDC; // And the put: 5 NVDAx at a strike of 150 USDC each, 750 USDC in all, asking // 20 USDC. -const PUT_UNDERLYING: u64 = 5 * ONE_TOKEN; -const PUT_STRIKE_AMOUNT: u64 = 750 * ONE_TOKEN; -const PUT_PREMIUM: u64 = 20 * ONE_TOKEN; +const PUT_UNDERLYING: u64 = 5 * ONE_NVDAX; +const PUT_STRIKE_AMOUNT: u64 = 750 * ONE_USDC; +const PUT_PREMIUM: u64 = 20 * ONE_USDC; const SECONDS_PER_DAY: i64 = 24 * 60 * 60; const ONE_WEEK: i64 = 7 * SECONDS_PER_DAY; // Every character starts with the standard wallet of 1,000 USDC; the story // hands the writers 5 NVDAx. -const STANDARD_USDC: u64 = 1_000 * ONE_TOKEN; -const FIVE_NVDAX: u64 = 5 * ONE_TOKEN; +const STANDARD_USDC: u64 = 1_000 * ONE_USDC; +const FIVE_NVDAX: u64 = 5 * ONE_NVDAX; fn token_program_id() -> Pubkey { "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" @@ -156,11 +158,11 @@ impl Venue { let payer = create_wallet(&mut svm, 100_000_000_000).unwrap(); let admin = create_wallet(&mut svm, 100_000_000_000).unwrap(); - let underlying_mint = create_token_mint(&mut svm, &admin, DECIMALS, None).unwrap(); + let underlying_mint = create_token_mint(&mut svm, &admin, NVDAX_DECIMALS, None).unwrap(); let quote_mint = if same_mint { underlying_mint } else { - create_token_mint(&mut svm, &admin, DECIMALS, None).unwrap() + create_token_mint(&mut svm, &admin, USDC_DECIMALS, None).unwrap() }; let market = Pubkey::find_program_address( @@ -617,6 +619,34 @@ fn test_buy_option_pays_the_premium_minus_the_fee() { venue.assert_vaults_match_ledger(); } +/// A premium that is not a multiple of the rate: 10.000001 USDC at 1% is a +/// fee of 0.100001 USDC, 0.10000001 rounded up to the minor unit, and the +/// writer receives the premium minus the fee. The venue, not the writer, +/// takes the rounding unit. +#[test] +fn test_fee_rounds_up_and_the_writer_takes_the_remainder() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let bob = venue.person(0, STANDARD_USDC); + let premium = 10 * ONE_USDC + 1; + let terms = OptionTerms { + premium, + ..call_terms(venue.now() + ONE_WEEK) + }; + let option = venue + .write_option(&alice, 3, terms) + .expect("writing the call should succeed"); + + venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); + + let fee = 100_001; // 0.100001 USDC + assert_eq!(venue.balance(&bob.quote), STANDARD_USDC - premium); + assert_eq!(venue.balance(&alice.quote), STANDARD_USDC + premium - fee); + assert_eq!(venue.balance(&venue.quote_vault), fee); + assert_eq!(venue.market_state().fees_owed, fee); + venue.assert_vaults_match_ledger(); +} + /// NVIDIA rallies past the strike offchain, so Bob exercises: he pays the /// strike, 5 x 180 = 900 USDC, into the vault and takes the 5 NVDAx. The 900 /// USDC is now owed to Alice, and the underlying is no longer owed to anyone. @@ -668,7 +698,7 @@ fn test_collect_proceeds_pays_the_writer_and_closes_the_option() { assert_eq!( venue.balance(&alice.quote), - STANDARD_USDC + 900 * ONE_TOKEN + CALL_PREMIUM - 250_000 + STANDARD_USDC + 900 * ONE_USDC + CALL_PREMIUM - 250_000 ); assert_eq!(venue.balance(&alice.underlying), 0); assert!(!venue.option_exists(&option)); @@ -699,7 +729,7 @@ fn test_collect_fees_pays_only_the_fees_owed() { let admin_quote = derive_ata(&venue.admin.pubkey(), &venue.quote_mint); assert_eq!(venue.balance(&admin_quote), 250_000); - assert_eq!(venue.balance(&venue.quote_vault), 900 * ONE_TOKEN); + assert_eq!(venue.balance(&venue.quote_vault), 900 * ONE_USDC); assert_eq!(venue.market_state().fees_owed, 0); venue.assert_vaults_match_ledger(); @@ -724,7 +754,7 @@ fn test_put_lifecycle_delivers_the_underlying_for_the_strike() { let dave = venue.person(FIVE_NVDAX, STANDARD_USDC); let option = venue.write_put(&carol); - let collateral = 750 * ONE_TOKEN; + let collateral = 750 * ONE_USDC; assert_eq!(venue.balance(&carol.quote), STANDARD_USDC - collateral); assert_eq!(venue.balance(&venue.quote_vault), collateral); assert_eq!(venue.market_state().quote_owed, collateral); @@ -1041,7 +1071,7 @@ fn test_collect_proceeds_needs_an_exercised_option_and_the_writer() { venue.collect_proceeds(&bob, &option), AnchorErrorCode::ConstraintAddress, ); - assert_eq!(venue.balance(&venue.quote_vault), 900 * ONE_TOKEN + 250_000); + assert_eq!(venue.balance(&venue.quote_vault), 900 * ONE_USDC + 250_000); } /// An exercised option has no collateral left to reclaim, whatever the clock diff --git a/finance/options/anchor/CHANGELOG.md b/finance/options/anchor/CHANGELOG.md index f4ac9611..463f0cc5 100644 --- a/finance/options/anchor/CHANGELOG.md +++ b/finance/options/anchor/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## Unreleased, 2026-10-05 + +The venue's fee rounds up: `split_premium` takes the ceiling of +`premium * fee_bps / 10_000` and pays the writer the premium minus the fee, +so the venue, not the writer, takes the rounding minor unit on a premium that +is not a multiple of the rate. The walkthrough's premiums (25 and 20 USDC at +1%) are multiples of the rate, so their fees (0.25 and 0.20 USDC) are the +same either way. New test `test_fee_rounds_up_and_the_writer_takes_the_remainder` +buys a 10.000001 USDC option and checks a fee of 0.100001 USDC and 9.90 USDC +to the writer. The Kani harness `proof_premium_split_conserves_the_premium` +checks the exact ceiling, and its unit tests the rounded cases, including a +one-minor-unit premium that rounds entirely into the fee at the highest rate. +NVDAx is minted with 8 decimals in the suite, as the real token is, and USDC +with 6: 5 NVDAx is 500,000,000 minor units; every USDC amount is unchanged. + ## Unreleased, 2026-10-04 The LiteSVM suite asserts the error code of every refusal diff --git a/finance/options/anchor/README.md b/finance/options/anchor/README.md index b16a47d6..4edbe1b7 100644 --- a/finance/options/anchor/README.md +++ b/finance/options/anchor/README.md @@ -74,7 +74,9 @@ underlying on exercise (the strike for the whole option, as an amount rather than a price). The collateral, the exercise payment and the proceeds are each one of those two amounts, moved as stored. Settlement neither multiplies, divides, nor rounds, so there is no rounding to decide a direction for; the -only rounding in the program is the floor in the venue's fee. +only rounding in the program is in the venue's fee, which rounds up: the +writer receives the premium minus the fee, so the venue, not the writer, takes +the rounding minor unit on a premium that is not a multiple of the rate. ### Expiry is one comparison and its complement @@ -90,7 +92,7 @@ deadline is one. ### Participants - **Maria** operates the venue and earns 1% of every premium. -- **Alice** holds 5 NVDAx (tokenized NVIDIA stock, 6 decimals) she would be +- **Alice** holds 5 NVDAx (tokenized NVIDIA stock, 8 decimals) she would be happy to sell at $180, and wants to be paid while she waits. - **Bob** thinks NVIDIA will rally past $180 within the week and wants that upside for less than the price of 5 shares. @@ -122,7 +124,9 @@ any time until someone does. `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, as two transfers (a -venue with a zero fee makes only the first). The 5 NVDAx do not move. The +venue with a zero fee makes only the first). 25 USDC is a multiple of the +rate, so nothing rounds; a premium of 10.000001 USDC would owe a fee of +0.100001 USDC, rounded up from 0.10000001, and pay the writer 9.90 USDC. 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 @@ -222,9 +226,12 @@ The LiteSVM suite (`programs/options/tests/test_options.rs`) walks the call 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 +pins every balance to the minor unit, checks that the fee on a premium that is +not a multiple of the rate rounds up and the writer receives the rest +(`test_fee_rounds_up_and_the_writer_takes_the_remainder`), counts the token +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, @@ -259,7 +266,7 @@ exercise. ### How does the venue make money? -`buy_option` takes `fee_bps` of every premium into the quote vault, and the -admin sweeps it with `collect_fees`. The fee is the admin's only reach into +`buy_option` takes `fee_bps` of every premium into the quote vault, rounded +up to the minor unit, and the admin sweeps it with `collect_fees`. The fee is the admin's only reach into the vault; collateral and strike payments are owed to their writers and holders. diff --git a/finance/options/anchor/programs/options/src/contract_math.rs b/finance/options/anchor/programs/options/src/contract_math.rs index 1603de95..47d5ec18 100644 --- a/finance/options/anchor/programs/options/src/contract_math.rs +++ b/finance/options/anchor/programs/options/src/contract_math.rs @@ -4,8 +4,9 @@ //! //! There is no arithmetic in settlement at all: the option stores the two //! amounts that change hands, and settlement moves them as they are. The only -//! rounding in the program is the floor in the fee split, and `split_premium` -//! returns `None` on the paths the program maps to `OptionsError::MathOverflow`. +//! rounding in the program is in the fee split, where the fee rounds up and +//! the writer takes the remainder; `split_premium` returns `None` on the paths +//! the program maps to `OptionsError::MathOverflow`. use crate::state::OptionKind; @@ -34,13 +35,18 @@ pub fn exercise_payment(kind: OptionKind, underlying_amount: u64, strike_amount: } /// Split a premium into the venue's fee and the writer's share. The fee -/// floors, so the writer receives the rounding minor unit; the venue gives up -/// at most one minor unit per sale, and a sale needs a real premium, so the -/// leak cannot be industrialized. +/// rounds up, in the venue's favor, and the writer receives the premium minus +/// the fee: a fee that floored would hand the writer the rounding minor unit +/// on every sale whose premium is not a multiple of the rate. The two shares +/// always sum to the premium, so the buyer never pays more than the writer +/// asked. With the rate under 100% the fee never exceeds the premium, but a +/// premium of a single minor unit rounds entirely into the fee. pub fn split_premium(premium: u64, fee_bps: u16) -> Option<(u64, u64)> { + // The product of a u64 and a u16 is far below u128::MAX, so the ceiling + // division cannot overflow. let fee = (premium as u128) .checked_mul(fee_bps as u128)? - .checked_div(BASIS_POINTS)?; + .div_ceil(BASIS_POINTS); let fee = u64::try_from(fee).ok()?; let to_writer = premium.checked_sub(fee)?; Some((fee, to_writer)) diff --git a/finance/options/anchor/programs/options/src/instructions/write_option.rs b/finance/options/anchor/programs/options/src/instructions/write_option.rs index df455a8c..9e00c1ac 100644 --- a/finance/options/anchor/programs/options/src/instructions/write_option.rs +++ b/finance/options/anchor/programs/options/src/instructions/write_option.rs @@ -17,7 +17,7 @@ pub struct OptionTerms { pub kind: OptionKind, /// Underlying minor units the option covers: what a call writer posts and - /// a call holder receives, or a put holder delivers (1 NVDAx = 1_000_000). + /// a call holder receives, or a put holder delivers (1 NVDAx = 100_000_000). pub underlying_amount: u64, /// Quote minor units paid for the underlying on exercise: what a put diff --git a/finance/options/anchor/programs/options/src/state/option_contract.rs b/finance/options/anchor/programs/options/src/state/option_contract.rs index 1b892e24..29658d39 100644 --- a/finance/options/anchor/programs/options/src/state/option_contract.rs +++ b/finance/options/anchor/programs/options/src/state/option_contract.rs @@ -52,7 +52,7 @@ pub struct OptionContract { pub status: OptionStatus, /// Underlying minor units the option covers: what a call writer posts and - /// a call holder receives, or a put holder delivers (1 NVDAx = 1_000_000). + /// a call holder receives, or a put holder delivers (1 NVDAx = 100_000_000). pub underlying_amount: u64, /// Quote minor units paid for the underlying on exercise: what a put diff --git a/finance/options/anchor/programs/options/tests/test_options.rs b/finance/options/anchor/programs/options/tests/test_options.rs index b28660a8..d20a34f1 100644 --- a/finance/options/anchor/programs/options/tests/test_options.rs +++ b/finance/options/anchor/programs/options/tests/test_options.rs @@ -22,33 +22,35 @@ use { }, }; -// Both tokens have 6 decimals: the underlying is NVDAx (tokenized NVIDIA -// stock) and the quote is USDC, so one whole unit of either is 1_000_000 -// minor units. -const ONE_TOKEN: u64 = 1_000_000; -const DECIMALS: u8 = 6; +// The underlying is NVDAx (tokenized NVIDIA stock), which has 8 decimals, +// and the quote is USDC, which has 6: one NVDAx is 100_000_000 minor units +// and one USDC is 1_000_000. +const NVDAX_DECIMALS: u8 = 8; +const USDC_DECIMALS: u8 = 6; +const ONE_NVDAX: u64 = 100_000_000; +const ONE_USDC: u64 = 1_000_000; // The venue charges 1% of every premium. const FEE_BPS: u16 = 100; // The walkthrough's call: 5 NVDAx at a strike of 180 USDC each, 900 USDC in // all, asking 25 USDC for the option, expiring a week out. -const CALL_UNDERLYING: u64 = 5 * ONE_TOKEN; -const CALL_STRIKE_AMOUNT: u64 = 900 * ONE_TOKEN; -const CALL_PREMIUM: u64 = 25 * ONE_TOKEN; +const CALL_UNDERLYING: u64 = 5 * ONE_NVDAX; +const CALL_STRIKE_AMOUNT: u64 = 900 * ONE_USDC; +const CALL_PREMIUM: u64 = 25 * ONE_USDC; // And the put: 5 NVDAx at a strike of 150 USDC each, 750 USDC in all, asking // 20 USDC. -const PUT_UNDERLYING: u64 = 5 * ONE_TOKEN; -const PUT_STRIKE_AMOUNT: u64 = 750 * ONE_TOKEN; -const PUT_PREMIUM: u64 = 20 * ONE_TOKEN; +const PUT_UNDERLYING: u64 = 5 * ONE_NVDAX; +const PUT_STRIKE_AMOUNT: u64 = 750 * ONE_USDC; +const PUT_PREMIUM: u64 = 20 * ONE_USDC; const SECONDS_PER_DAY: i64 = 24 * 60 * 60; const ONE_WEEK: i64 = 7 * SECONDS_PER_DAY; // Every character starts with the standard wallet of 1,000 USDC; the story // hands the writers 5 NVDAx. -const STANDARD_USDC: u64 = 1_000 * ONE_TOKEN; -const FIVE_NVDAX: u64 = 5 * ONE_TOKEN; +const STANDARD_USDC: u64 = 1_000 * ONE_USDC; +const FIVE_NVDAX: u64 = 5 * ONE_NVDAX; fn token_program_id() -> Address { "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" @@ -163,11 +165,11 @@ impl Venue { let payer = create_wallet(&mut svm, 100_000_000_000).unwrap(); let admin = create_wallet(&mut svm, 100_000_000_000).unwrap(); - let underlying_mint = create_token_mint(&mut svm, &admin, DECIMALS, None).unwrap(); + let underlying_mint = create_token_mint(&mut svm, &admin, NVDAX_DECIMALS, None).unwrap(); let quote_mint = if same_mint { underlying_mint } else { - create_token_mint(&mut svm, &admin, DECIMALS, None).unwrap() + create_token_mint(&mut svm, &admin, USDC_DECIMALS, None).unwrap() }; let market = Address::find_program_address( @@ -630,6 +632,34 @@ fn test_buy_option_pays_the_premium_minus_the_fee() { venue.assert_vaults_match_ledger(); } +/// A premium that is not a multiple of the rate: 10.000001 USDC at 1% is a +/// fee of 0.100001 USDC, 0.10000001 rounded up to the minor unit, and the +/// writer receives the premium minus the fee. The venue, not the writer, +/// takes the rounding unit. +#[test] +fn test_fee_rounds_up_and_the_writer_takes_the_remainder() { + let mut venue = Venue::new(); + let alice = venue.person(FIVE_NVDAX, STANDARD_USDC); + let bob = venue.person(0, STANDARD_USDC); + let premium = 10 * ONE_USDC + 1; + let terms = OptionTerms { + premium, + ..call_terms(venue.now() + ONE_WEEK) + }; + let option = venue + .write_option(&alice, 3, terms) + .expect("writing the call should succeed"); + + venue.buy_option(&bob, &alice.pubkey(), &option).unwrap(); + + let fee = 100_001; // 0.100001 USDC + assert_eq!(venue.balance(&bob.quote), STANDARD_USDC - premium); + assert_eq!(venue.balance(&alice.quote), STANDARD_USDC + premium - fee); + assert_eq!(venue.balance(&venue.quote_vault), fee); + assert_eq!(venue.market_state().fees_owed, fee); + venue.assert_vaults_match_ledger(); +} + /// NVIDIA rallies past the strike offchain, so Bob exercises: he pays the /// strike, 5 x 180 = 900 USDC, into the vault and takes the 5 NVDAx. The 900 /// USDC is now owed to Alice, and the underlying is no longer owed to anyone. @@ -681,7 +711,7 @@ fn test_collect_proceeds_pays_the_writer_and_closes_the_option() { assert_eq!( venue.balance(&alice.quote), - STANDARD_USDC + 900 * ONE_TOKEN + CALL_PREMIUM - 250_000 + STANDARD_USDC + 900 * ONE_USDC + CALL_PREMIUM - 250_000 ); assert_eq!(venue.balance(&alice.underlying), 0); assert!(!venue.option_exists(&option)); @@ -712,7 +742,7 @@ fn test_collect_fees_pays_only_the_fees_owed() { let admin_quote = derive_ata(&venue.admin.pubkey(), &venue.quote_mint); assert_eq!(venue.balance(&admin_quote), 250_000); - assert_eq!(venue.balance(&venue.quote_vault), 900 * ONE_TOKEN); + assert_eq!(venue.balance(&venue.quote_vault), 900 * ONE_USDC); assert_eq!(venue.market_state().fees_owed, 0); venue.assert_vaults_match_ledger(); @@ -737,7 +767,7 @@ fn test_put_lifecycle_delivers_the_underlying_for_the_strike() { let dave = venue.person(FIVE_NVDAX, STANDARD_USDC); let option = venue.write_put(&carol); - let collateral = 750 * ONE_TOKEN; + let collateral = 750 * ONE_USDC; assert_eq!(venue.balance(&carol.quote), STANDARD_USDC - collateral); assert_eq!(venue.balance(&venue.quote_vault), collateral); assert_eq!(venue.market_state().quote_owed, collateral); @@ -1054,7 +1084,7 @@ fn test_collect_proceeds_needs_an_exercised_option_and_the_writer() { venue.collect_proceeds(&bob, &option), AnchorErrorCode::ConstraintAddress, ); - assert_eq!(venue.balance(&venue.quote_vault), 900 * ONE_TOKEN + 250_000); + assert_eq!(venue.balance(&venue.quote_vault), 900 * ONE_USDC + 250_000); } /// An exercised option has no collateral left to reclaim, whatever the clock diff --git a/finance/options/kani-proofs/README.md b/finance/options/kani-proofs/README.md index 187df589..f63b6c9a 100644 --- a/finance/options/kani-proofs/README.md +++ b/finance/options/kani-proofs/README.md @@ -14,8 +14,9 @@ one. The onchain instructions hand token movement to the SPL token program through CPIs that Kani cannot symbolically execute, but the arithmetic they rely on is pure integer math, and small: settlement moves the two amounts the writer -chose and the option stores, the only rounding in the program is the floor in -the fee split, and the expiry window is one comparison and its complement. +chose and the option stores, the only rounding in the program is in the fee +split, where the fee rounds up and the writer takes the remainder, and the +expiry window is one comparison and its complement. This crate reproduces those formulas (mirroring `options::contract_math`) and the handlers' custody accounting (mirroring the `underlying_owed`, `quote_owed` and `fees_owed` counters on the `Market` account) and checks, for every input @@ -28,9 +29,10 @@ in the declared ranges: terms. Settlement does no arithmetic, so nothing can open a gap between posted and delivered. - `proof_premium_split_conserves_the_premium`: fee plus the writer's share is - exactly the premium, the fee never exceeds it, the writer always receives - something while the fee is under 100%, and the fee is the exact floor of - `premium * fee_bps / 10_000`. + exactly the premium, the fee never exceeds it while the rate is under 100%, + the writer's share is the premium minus the fee, and the fee is the exact + ceiling of `premium * fee_bps / 10_000`: never short of the venue's rate, + and never more than one minor unit above it. - `proof_exercise_and_reclaim_windows_partition_time`: at every instant exactly one of the holder (exercise) and the writer (reclaim) can claim a held option's collateral. Never both, never neither. @@ -60,8 +62,8 @@ the bound: and checking a divider exact against a multiplier is the hardest shape of problem a SAT solver sees: a 16-bit premium against the full fee range runs for hours. Eight bits on each side finish in about a second, exercise the - floor on both sides of every carry, and the fee-equals-premium edge at the - 99.99% ceiling is pinned by a unit test. + ceiling on both sides of every carry, and the fee-equals-premium edge at the + 99.99% rate is pinned by a unit test. - `proof_exercise_and_reclaim_windows_partition_time`: fully symbolic; it is one comparison. - `proof_vault_ledger_stays_consistent_across_every_lifecycle`: each option's diff --git a/finance/options/kani-proofs/src/lib.rs b/finance/options/kani-proofs/src/lib.rs index d5dfcbfd..0908cda1 100644 --- a/finance/options/kani-proofs/src/lib.rs +++ b/finance/options/kani-proofs/src/lib.rs @@ -11,10 +11,11 @@ //! program via CPIs that Kani cannot symbolically execute. The arithmetic //! underneath is small and is reproduced here faithfully, mirroring //! `options::contract_math`: settlement moves the two amounts the option -//! stores, the only rounding is the floor in the fee split, and the expiry -//! window is one comparison and its complement. The harnesses check the -//! invariants the program's custody accounting depends on, plus a bounded -//! model of the vault ledger across an option's whole life. +//! stores, the only rounding is in the fee split, where the fee rounds up and +//! the writer takes the remainder, and the expiry window is one comparison and +//! its complement. The harnesses check the invariants the program's custody +//! accounting depends on, plus a bounded model of the vault ledger across an +//! option's whole life. #![cfg_attr(kani, allow(dead_code))] @@ -92,22 +93,22 @@ fn proof_exercise_moves_exactly_the_posted_terms() { // 2. The premium split (contract_math.rs) // =========================================================================== -/// Fee floors; the writer takes the remainder. Mirrors +/// The fee rounds up; the writer takes the remainder. Mirrors /// `contract_math::split_premium`. pub fn split_premium(premium: u64, fee_bps: u16) -> Option<(u64, u64)> { let fee = (premium as u128) .checked_mul(fee_bps as u128)? - .checked_div(BASIS_POINTS)?; + .div_ceil(BASIS_POINTS); let fee = u64::try_from(fee).ok()?; let to_writer = premium.checked_sub(fee)?; Some((fee, to_writer)) } /// The premium is conserved: fee plus the writer's share is exactly the -/// premium, the fee never exceeds the premium, and the writer always gets -/// something. Also checks the fee is the exact floor of `premium * bps / -/// 10_000`, so a refactor that rounds up against the writer, or drops below -/// the floor against the venue, fails the check. +/// premium, and the fee never exceeds the premium. Also checks the fee is the +/// exact ceiling of `premium * bps / 10_000`, so a refactor that floors +/// against the venue, or rounds up by more than the one minor unit a ceiling +/// allows, fails the check. #[cfg(kani)] #[kani::proof] #[kani::solver(cadical)] @@ -118,24 +119,29 @@ fn proof_premium_split_conserves_the_premium() { // a positive premium. Bounded model checking: `premium * fee_bps` is // symbolic times symbolic and the quotient is a 128-bit division, the // worst case for the bit-precise solver (a 16-bit premium against the - // full fee range runs for hours). The floor's behavior depends only on - // the product's residue mod 10_000; a 12-bit premium against a 10-bit + // full fee range runs for hours). The ceiling's behavior depends only + // on the product's residue mod 10_000; a 12-bit premium against a 10-bit // fee already reaches every residue and the fee-equals-premium edge, and // larger operands add magnitude rather than new behavior. The full fee - // range, including the 99.99% ceiling, is pinned by the unit tests. + // range, including the 99.99% rate, is pinned by the unit tests. kani::assume(premium >= 1 && premium <= 0xFF); kani::assume(fee_bps <= 0xFF); let (fee, to_writer) = split_premium(premium, fee_bps).expect("split computes"); assert_eq!(fee as u128 + to_writer as u128, premium as u128); + // With the rate under 100%, the fee never exceeds the premium, even + // rounded up. assert!(fee <= premium); - // With the fee under 100%, the writer is always paid something. - assert!(to_writer > 0); - // Exact floor: fee * 10_000 <= premium * bps < (fee + 1) * 10_000. + assert_eq!(to_writer, premium - fee); + // Exact ceiling: (fee - 1) * 10_000 < premium * bps <= fee * 10_000, so + // the venue is never short and never takes more than one minor unit of + // rounding. let target = (premium as u128) * (fee_bps as u128); - assert!((fee as u128) * BASIS_POINTS <= target); - assert!((fee as u128 + 1) * BASIS_POINTS > target); + assert!((fee as u128) * BASIS_POINTS >= target); + assert!((fee as u128) * BASIS_POINTS < target + BASIS_POINTS); + // The rounding unit is taken only when there is something to round. + assert_eq!(fee == 0, target == 0); } // =========================================================================== @@ -419,57 +425,69 @@ fn proof_vault_ledger_stays_consistent_across_every_lifecycle() { mod tests { use super::*; - // Both tokens have 6 decimals; the venue charges 1% of each premium. - const ONE_TOKEN: u64 = 1_000_000; + // NVDAx has 8 decimals and USDC has 6; the venue charges 1% of each + // premium. + const ONE_NVDAX: u64 = 100_000_000; + const ONE_USDC: u64 = 1_000_000; const FEE_BPS: u16 = 100; #[test] fn the_call_posts_five_nvdax_and_settles_for_nine_hundred_usdc() { // 5 NVDAx at a strike of 180 USDC each, 900 USDC in all. - let collateral = collateral_amount(OptionKind::Call, 5 * ONE_TOKEN, 900 * ONE_TOKEN); - let payment = exercise_payment(OptionKind::Call, 5 * ONE_TOKEN, 900 * ONE_TOKEN); - assert_eq!(collateral, 5 * ONE_TOKEN); - assert_eq!(payment, 900 * ONE_TOKEN); + let collateral = collateral_amount(OptionKind::Call, 5 * ONE_NVDAX, 900 * ONE_USDC); + let payment = exercise_payment(OptionKind::Call, 5 * ONE_NVDAX, 900 * ONE_USDC); + assert_eq!(collateral, 5 * ONE_NVDAX); + assert_eq!(payment, 900 * ONE_USDC); } #[test] fn the_put_posts_seven_fifty_usdc_and_settles_for_five_nvdax() { // 5 NVDAx at a strike of 150 USDC each, 750 USDC in all. - let collateral = collateral_amount(OptionKind::Put, 5 * ONE_TOKEN, 750 * ONE_TOKEN); - let payment = exercise_payment(OptionKind::Put, 5 * ONE_TOKEN, 750 * ONE_TOKEN); - assert_eq!(collateral, 750 * ONE_TOKEN); - assert_eq!(payment, 5 * ONE_TOKEN); + let collateral = collateral_amount(OptionKind::Put, 5 * ONE_NVDAX, 750 * ONE_USDC); + let payment = exercise_payment(OptionKind::Put, 5 * ONE_NVDAX, 750 * ONE_USDC); + assert_eq!(collateral, 750 * ONE_USDC); + assert_eq!(payment, 5 * ONE_NVDAX); } #[test] fn a_twenty_five_usdc_premium_splits_into_a_quarter_dollar_fee() { + // Both of the chapter's premiums are exact multiples of the rate, so + // nothing rounds. assert_eq!( - split_premium(25 * ONE_TOKEN, FEE_BPS).unwrap(), + split_premium(25 * ONE_USDC, FEE_BPS).unwrap(), (250_000, 24_750_000) ); assert_eq!( - split_premium(20 * ONE_TOKEN, FEE_BPS).unwrap(), + split_premium(20 * ONE_USDC, FEE_BPS).unwrap(), (200_000, 19_800_000) ); } #[test] - fn the_fee_floors_and_the_writer_keeps_the_rounding_unit() { - // 999 minor units at 1%: 9.99 floors to 9, the writer gets 990. - assert_eq!(split_premium(999, FEE_BPS).unwrap(), (9, 990)); - // A zero fee passes the whole premium through. + fn the_fee_rounds_up_and_the_writer_takes_the_remainder() { + // 999 minor units at 1%: 9.99 rounds up to 10, the writer gets 989. + assert_eq!(split_premium(999, FEE_BPS).unwrap(), (10, 989)); + // 10.000001 USDC at 1%: the fee is 0.100001 USDC, not 0.10. + assert_eq!( + split_premium(10 * ONE_USDC + 1, FEE_BPS).unwrap(), + (100_001, 9_900_000) + ); + // A zero fee passes the whole premium through: there is nothing to + // round. assert_eq!(split_premium(999, 0).unwrap(), (0, 999)); } #[test] - fn the_writer_is_paid_even_at_the_highest_fee_the_venue_allows() { - // 99.99% is the highest rate initialize_market accepts. The floor - // leaves the writer at least one minor unit at every premium size. - let ceiling: u16 = 9_999; - assert_eq!(split_premium(1, ceiling).unwrap(), (0, 1)); - assert_eq!(split_premium(10_000, ceiling).unwrap(), (9_999, 1)); - let (fee, to_writer) = split_premium(u64::MAX, ceiling).unwrap(); - assert!(to_writer > 0); + fn the_fee_never_exceeds_the_premium_at_the_highest_rate_the_venue_allows() { + // 99.99% is the highest rate initialize_market accepts. A premium of + // one minor unit rounds entirely into the fee; a larger one leaves + // the writer the remainder. + let highest: u16 = 9_999; + assert_eq!(split_premium(1, highest).unwrap(), (1, 0)); + assert_eq!(split_premium(10_000, highest).unwrap(), (9_999, 1)); + assert_eq!(split_premium(10_001, highest).unwrap(), (10_000, 1)); + let (fee, to_writer) = split_premium(u64::MAX, highest).unwrap(); + assert!(fee < u64::MAX); assert_eq!(fee as u128 + to_writer as u128, u64::MAX as u128); } @@ -486,22 +504,22 @@ mod tests { fn the_ledger_returns_to_zero_after_the_chapter() { let mut ledger = Ledger::default(); // Alice's call: written, bought by Bob, exercised, collected. - ledger.write(OptionKind::Call, 5 * ONE_TOKEN).unwrap(); + ledger.write(OptionKind::Call, 5 * ONE_NVDAX).unwrap(); ledger.buy(250_000).unwrap(); ledger - .exercise(OptionKind::Call, 5 * ONE_TOKEN, 900 * ONE_TOKEN) + .exercise(OptionKind::Call, 5 * ONE_NVDAX, 900 * ONE_USDC) .unwrap(); assert!(ledger.is_consistent()); - assert_eq!(ledger.quote_vault, 900 * ONE_TOKEN + 250_000); + assert_eq!(ledger.quote_vault, 900 * ONE_USDC + 250_000); ledger - .collect_proceeds(OptionKind::Call, 900 * ONE_TOKEN) + .collect_proceeds(OptionKind::Call, 900 * ONE_USDC) .unwrap(); // Carol's put: written, bought by Dave, expires, reclaimed. - ledger.write(OptionKind::Put, 750 * ONE_TOKEN).unwrap(); + ledger.write(OptionKind::Put, 750 * ONE_USDC).unwrap(); ledger.buy(200_000).unwrap(); assert!(ledger.is_consistent()); ledger - .return_collateral(OptionKind::Put, 750 * ONE_TOKEN) + .return_collateral(OptionKind::Put, 750 * ONE_USDC) .unwrap(); // Maria sweeps 0.45 USDC and the vaults are empty. assert_eq!(ledger.fees_owed, 450_000); @@ -514,13 +532,13 @@ mod tests { 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 surplus = 3 * ONE_USDC; let ledger = Ledger { underlying_owed: 0, - quote_owed: 900 * ONE_TOKEN, + quote_owed: 900 * ONE_USDC, fees_owed: 250_000, underlying_vault: 0, - quote_vault: 900 * ONE_TOKEN + 250_000 + surplus, + quote_vault: 900 * ONE_USDC + 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 5759043b..82981a21 100644 --- a/finance/options/quasar/CHANGELOG.md +++ b/finance/options/quasar/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## Unreleased, 2026-10-05 + +The venue's fee rounds up: `split_premium` takes the ceiling of +`premium * fee_bps / 10_000` and pays the writer the premium minus the fee, +so the venue, not the writer, takes the rounding minor unit on a premium that +is not a multiple of the rate. The walkthrough's premiums (25 and 20 USDC at +1%) are multiples of the rate, so their fees (0.25 and 0.20 USDC) are the +same either way. New test `fee_rounds_up_and_the_writer_takes_the_remainder` +buys a 10.000001 USDC option and checks a fee of 0.100001 USDC and 9.90 USDC +to the writer. The Kani harness `proof_premium_split_conserves_the_premium` +checks the exact ceiling, and its unit tests the rounded cases, including a +one-minor-unit premium that rounds entirely into the fee at the highest rate. +NVDAx is minted with 8 decimals in the suite, as the real token is, and USDC +with 6: 5 NVDAx is 500,000,000 minor units; every USDC amount is unchanged. + ## Unreleased, 2026-10-04 The quasar-test suite asserts the error code of every refusal (`fails_with` diff --git a/finance/options/quasar/README.md b/finance/options/quasar/README.md index 3ba02a17..6725561f 100644 --- a/finance/options/quasar/README.md +++ b/finance/options/quasar/README.md @@ -53,9 +53,12 @@ expiry is deterministic, then walk the call 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 (`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 +pin every balance to the minor unit, check that the fee on a premium that is +not a multiple of the rate rounds up and the writer receives the rest +(`fee_rounds_up_and_the_writer_takes_the_remainder`), 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, diff --git a/finance/options/quasar/src/instructions/shared.rs b/finance/options/quasar/src/instructions/shared.rs index 9fcc7c6c..43667a19 100644 --- a/finance/options/quasar/src/instructions/shared.rs +++ b/finance/options/quasar/src/instructions/shared.rs @@ -1,7 +1,8 @@ //! The pure contract math and the custody check, ported from the Anchor //! sibling (`options::contract_math` and `instructions::shared`). Settlement //! does no arithmetic at all: the option stores the two amounts that change -//! hands, and the only rounding is the floor in the fee split. +//! hands, and the only rounding is in the fee split, where the fee rounds up +//! and the writer takes the remainder. use { crate::{ @@ -56,13 +57,19 @@ pub fn require_valid_kind(kind: u8) -> Result<(), ProgramError> { } /// Split a premium into the venue's fee and the writer's share. The fee -/// floors, so the writer receives the rounding minor unit. +/// rounds up, in the venue's favor, and the writer receives the premium minus +/// the fee: a fee that floored would hand the writer the rounding minor unit +/// on every sale whose premium is not a multiple of the rate. The two shares +/// always sum to the premium. With the rate under 100% the fee never exceeds +/// the premium, but a premium of a single minor unit rounds entirely into the +/// fee. pub fn split_premium(premium: u64, fee_bps: u16) -> Result<(u64, u64), ProgramError> { + // The product of a u64 and a u16 is far below u128::MAX, so the ceiling + // division cannot overflow. let fee = (premium as u128) .checked_mul(fee_bps as u128) .ok_or(OptionsError::MathOverflow)? - .checked_div(BASIS_POINTS_DENOMINATOR as u128) - .ok_or(OptionsError::MathOverflow)?; + .div_ceil(BASIS_POINTS_DENOMINATOR as u128); let fee = u64::try_from(fee).map_err(|_| OptionsError::MathOverflow)?; let to_writer = premium.checked_sub(fee).ok_or(OptionsError::MathOverflow)?; Ok((fee, to_writer)) diff --git a/finance/options/quasar/src/tests.rs b/finance/options/quasar/src/tests.rs index 857b729e..bab6e209 100644 --- a/finance/options/quasar/src/tests.rs +++ b/finance/options/quasar/src/tests.rs @@ -18,28 +18,32 @@ use { quasar_test::prelude::*, }; -// Both tokens have 6 decimals: the underlying is NVDAx (tokenized NVIDIA -// stock) and the quote is USDC. -const ONE_TOKEN: u64 = 1_000_000; +// The underlying is NVDAx (tokenized NVIDIA stock), which has 8 decimals, +// and the quote is USDC, which has 6: one NVDAx is 100_000_000 minor units +// and one USDC is 1_000_000. +const NVDAX_DECIMALS: u8 = 8; +const USDC_DECIMALS: u8 = 6; +const ONE_NVDAX: u64 = 100_000_000; +const ONE_USDC: u64 = 1_000_000; // The venue charges 1% of every premium. const FEE_BPS: u16 = 100; // The walkthrough's call: 5 NVDAx at a 180 USDC strike per share, so the // holder pays 5 x 180 = 900 USDC on exercise, asking 25 USDC for the option. // And the put: 5 NVDAx at 150 USDC, so 750 USDC, asking 20 USDC. -const CALL_UNDERLYING: u64 = 5 * ONE_TOKEN; -const CALL_STRIKE_AMOUNT: u64 = 900 * ONE_TOKEN; -const CALL_PREMIUM: u64 = 25 * ONE_TOKEN; -const PUT_UNDERLYING: u64 = 5 * ONE_TOKEN; -const PUT_STRIKE_AMOUNT: u64 = 750 * ONE_TOKEN; -const PUT_PREMIUM: u64 = 20 * ONE_TOKEN; +const CALL_UNDERLYING: u64 = 5 * ONE_NVDAX; +const CALL_STRIKE_AMOUNT: u64 = 900 * ONE_USDC; +const CALL_PREMIUM: u64 = 25 * ONE_USDC; +const PUT_UNDERLYING: u64 = 5 * ONE_NVDAX; +const PUT_STRIKE_AMOUNT: u64 = 750 * ONE_USDC; +const PUT_PREMIUM: u64 = 20 * ONE_USDC; const CALL_ID: u64 = 1; const PUT_ID: u64 = 2; // Every character starts with the standard wallet of 1,000 USDC; the story // hands the writers 5 NVDAx. -const STANDARD_USDC: u64 = 1_000 * ONE_TOKEN; -const FIVE_NVDAX: u64 = 5 * ONE_TOKEN; +const STANDARD_USDC: u64 = 1_000 * ONE_USDC; +const FIVE_NVDAX: u64 = 5 * ONE_NVDAX; // A fixed unix timestamp the clock is warped to before anything is written, // so the expiry a week later is deterministic. @@ -125,8 +129,8 @@ fn initialize_market(test: &mut Test, fee_bps: u16, quote_mint: Pubkey) -> Outco fn setup_with_fee(test: &mut Test, fee_bps: u16) -> Env { test.add(Wallet::new().at(MARIA)); test.add(TokenAccount::new(USDC_MINT, MARIA).at(MARIA_USDC).amount(0)); - test.add(Mint::new(MARIA).at(NVDAX_MINT).decimals(6)); - test.add(Mint::new(MARIA).at(USDC_MINT).decimals(6)); + test.add(Mint::new(MARIA).at(NVDAX_MINT).decimals(NVDAX_DECIMALS)); + test.add(Mint::new(MARIA).at(USDC_MINT).decimals(USDC_DECIMALS)); test.warp_to_timestamp(START_TIME); add_person(test, &ALICE_P, FIVE_NVDAX, STANDARD_USDC); add_person(test, &BOB_P, 0, STANDARD_USDC); @@ -394,6 +398,37 @@ fn buy_option_pays_the_premium_minus_the_fee(test: &mut Test) { assert_vaults_match_ledger(test, &env); } +/// A premium that is not a multiple of the rate: 10.000001 USDC at 1% is a +/// fee of 0.100001 USDC, 0.10000001 rounded up to the minor unit, and the +/// writer receives the premium minus the fee. The venue, not the writer, +/// takes the rounding unit. +#[quasar_test] +fn fee_rounds_up_and_the_writer_takes_the_remainder(test: &mut Test) { + let env = setup(test); + let premium = 10 * ONE_USDC + 1; + write_option( + test, + &env, + &ALICE_P, + CALL_ID, + KIND_CALL, + CALL_UNDERLYING, + CALL_STRIKE_AMOUNT, + premium, + EXPIRY, + ) + .succeeds(); + + let fee = 100_001; // 0.100001 USDC + buy_option(test, &env, &BOB_P, &ALICE_P, CALL_ID) + .succeeds() + .has_tokens(BOB_USDC, STANDARD_USDC - premium) + .has_tokens(ALICE_USDC, STANDARD_USDC + premium - fee) + .has_tokens(env.quote_vault, fee); + assert_eq!(u64::from(test.read::(env.market).fees_owed), fee); + assert_vaults_match_ledger(test, &env); +} + /// NVIDIA rallies past the strike offchain, so Bob exercises: he pays the /// strike, 5 x 180 = 900 USDC, into the vault and takes the 5 NVDAx. #[quasar_test] @@ -839,8 +874,8 @@ fn write_option_rejects_an_expiry_that_has_passed(test: &mut Test) { #[quasar_test] fn initialize_market_rejects_a_full_fee(test: &mut Test) { test.add(Wallet::new().at(MARIA)); - test.add(Mint::new(MARIA).at(NVDAX_MINT).decimals(6)); - test.add(Mint::new(MARIA).at(USDC_MINT).decimals(6)); + test.add(Mint::new(MARIA).at(NVDAX_MINT).decimals(NVDAX_DECIMALS)); + test.add(Mint::new(MARIA).at(USDC_MINT).decimals(USDC_DECIMALS)); initialize_market(test, 10_000, USDC_MINT).fails_with(OptionsError::InvalidParameter); } @@ -850,7 +885,7 @@ fn initialize_market_rejects_a_full_fee(test: &mut Test) { #[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)); + test.add(Mint::new(MARIA).at(NVDAX_MINT).decimals(NVDAX_DECIMALS)); initialize_market(test, FEE_BPS, NVDAX_MINT) .fails(ProgramError::Runtime("AccountBorrowFailed".into())); } diff --git a/finance/order-book/anchor-v1/CHANGELOG.md b/finance/order-book/anchor-v1/CHANGELOG.md index 27b9bebb..33981eb2 100644 --- a/finance/order-book/anchor-v1/CHANGELOG.md +++ b/finance/order-book/anchor-v1/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## Unreleased - 2026-10-05 + +### Added + +- `withdraw_fees_rejects_a_non_authority_signer`: after a fill, a trader + signing `withdraw_fees` gets `NotMarketAuthority`, the fee stays in the + vault, and the trader's quote balance is unchanged. + +### Changed + +- Every refusal test asserts the error code the call fails with, through + the suite's `failure_text` and `assert_fails_with` helpers: + `place_order_rejects_zero_price` (`InvalidPrice`), + `place_order_rejects_unaligned_tick` (`InvalidTickSize`), + `place_order_rejects_below_min_order_size` (`BelowMinOrderSize`), + `cancel_order_rejects_non_owner` (`Unauthorized`), + `settle_funds_rejects_fee_vault_substituted_for_quote_vault` + (`InvalidQuoteVault`), `initialize_market_rejects_zero_tick_size` + (`InvalidTickSize`), `initialize_market_rejects_zero_base_lot_size` + (`InvalidBaseLotSize`), `initialize_market_rejects_zero_quote_lot_size` + (`InvalidQuoteLotSize`) and `initialize_market_rejects_oversized_fee` + (`InvalidFeeBasisPoints`). +- `build_withdraw_fees_ix` takes the signing authority, as the pause and + resume builders do. + ## Unreleased - 2026-10-04 ### Added diff --git a/finance/order-book/anchor-v1/README.md b/finance/order-book/anchor-v1/README.md index 87f5edfd..275ac84c 100644 --- a/finance/order-book/anchor-v1/README.md +++ b/finance/order-book/anchor-v1/README.md @@ -1519,7 +1519,7 @@ anchor test --skip-local-validator Expected: ``` -running 40 tests +running 41 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 @@ -1560,6 +1560,7 @@ test taker_crosses_multiple_resting_orders_best_price_first ... ok test taker_partially_filled_remainder_rests_on_book ... ok test taker_partially_fills_resting_order_rest_stays_on_book ... ok test trader_can_evict_their_own_worst_order ... ok +test withdraw_fees_rejects_a_non_authority_signer ... ok ``` ### What each test exercises @@ -1572,14 +1573,18 @@ test trader_can_evict_their_own_worst_order ... ok - `place_ask_moves_base_into_vault`: Fund lock on ask - `settle_funds_moves_unsettled_base_to_user`: Vault → user ATA transfer via market PDA signer -**Validation:** - -- `place_order_rejects_zero_price`: `price > 0` -- `place_order_rejects_unaligned_tick`: `price % tick_size == 0` -- `place_order_rejects_below_min_order_size`: `quantity >= min_order_size` -- `cancel_order_rejects_non_owner`: Ownership check on cancel -- `initialize_market_rejects_zero_tick_size`: Init constraint -- `initialize_market_rejects_oversized_fee`: `fee_bps <= 10_000` +**Validation (each asserts the error code the refusal carries):** + +- `place_order_rejects_zero_price`: `price > 0`, else `InvalidPrice` +- `place_order_rejects_unaligned_tick`: `price % tick_size == 0`, else `InvalidTickSize` +- `place_order_rejects_below_min_order_size`: `quantity >= min_order_size`, else `BelowMinOrderSize` +- `cancel_order_rejects_non_owner`: A signer who does not own the order gets `Unauthorized` +- `settle_funds_rejects_fee_vault_substituted_for_quote_vault`: The fee vault passed as `quote_vault` gets `InvalidQuoteVault` +- `initialize_market_rejects_zero_tick_size`: `InvalidTickSize` +- `initialize_market_rejects_zero_base_lot_size`: `InvalidBaseLotSize` +- `initialize_market_rejects_zero_quote_lot_size`: `InvalidQuoteLotSize` +- `initialize_market_rejects_oversized_fee`: `fee_bps <= 10_000`, else `InvalidFeeBasisPoints` +- `withdraw_fees_rejects_a_non_authority_signer`: A trader signing `withdraw_fees` after a fill gets `NotMarketAuthority`, and the fee stays in the vault **Cancel + settle flow:** 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 8e117184..c2016d4c 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 @@ -395,7 +395,11 @@ fn build_place_order_with_makers_ix( ix } -fn build_withdraw_fees_ix(sc: &Scenario, authority_quote_account: Pubkey) -> Instruction { +fn build_withdraw_fees_ix( + sc: &Scenario, + authority: &Pubkey, + authority_quote_account: Pubkey, +) -> Instruction { Instruction::new_with_bytes( sc.program_id, &order_book::instruction::WithdrawFees {}.data(), @@ -404,7 +408,7 @@ fn build_withdraw_fees_ix(sc: &Scenario, authority_quote_account: Pubkey) -> Ins fee_vault: sc.fee_vault, authority_quote_account, quote_mint: sc.quote_mint, - authority: sc.authority.pubkey(), + authority: *authority, token_program: token_program_id(), } .to_account_metas(None), @@ -714,9 +718,14 @@ fn place_order_rejects_zero_price() { 0, BID_QUANTITY, ); - let result = - send_transaction_from_instructions(&mut sc.svm, vec![ix], &[&sc.buyer], &sc.buyer.pubkey()); - assert!(result.is_err(), "order at price 0 must be rejected"); + let error = failure_text( + &mut sc.svm, + vec![ix], + &[&sc.buyer], + &sc.buyer.pubkey(), + "order at price 0 must be rejected", + ); + assert_fails_with(&error, order_book::errors::ErrorCode::InvalidPrice); } #[test] @@ -765,12 +774,14 @@ fn place_order_rejects_unaligned_tick() { unaligned_price, BID_QUANTITY, ); - let result = - send_transaction_from_instructions(&mut sc.svm, vec![ix], &[&sc.buyer], &sc.buyer.pubkey()); - assert!( - result.is_err(), - "unaligned price must be rejected by tick check" + let error = failure_text( + &mut sc.svm, + vec![ix], + &[&sc.buyer], + &sc.buyer.pubkey(), + "unaligned price must be rejected by tick check", ); + assert_fails_with(&error, order_book::errors::ErrorCode::InvalidTickSize); } #[test] @@ -817,16 +828,14 @@ fn place_order_rejects_below_min_order_size() { ASK_PRICE, too_small_quantity, ); - let result = send_transaction_from_instructions( + let error = failure_text( &mut sc.svm, vec![ix], &[&sc.seller], &sc.seller.pubkey(), + "quantity below min_order_size must be rejected", ); - assert!( - result.is_err(), - "quantity below min_order_size must be rejected" - ); + assert_fails_with(&error, order_book::errors::ErrorCode::BelowMinOrderSize); } #[test] @@ -916,16 +925,14 @@ fn cancel_order_rejects_non_owner() { sc.seller_market_user, bid_order_id, ); - let result = send_transaction_from_instructions( + let error = failure_text( &mut sc.svm, vec![attack_ix], &[&sc.seller], &sc.seller.pubkey(), + "non-owner must not be able to cancel an order", ); - assert!( - result.is_err(), - "non-owner must not be able to cancel an order" - ); + assert_fails_with(&error, order_book::errors::ErrorCode::Unauthorized); } #[test] @@ -1031,13 +1038,13 @@ fn cancel_and_settle_bid_refunds_full_quote() { ); } -// Regression test for the fee-drain attack on settle_funds. Pre-fix, -// `SettleFundsAccountConstraints` did not bind `quote_vault` to `market.quote_vault` via -// `has_one`, so a caller could pass `market.fee_vault` (same mint and -// same authority) where `quote_vault` was expected and drain accumulated -// taker fees while spending their own unsettled_quote credit. The -// has_one constraint now bound on the `market` field must surface this -// as `ConstraintHasOne` (anchor error 2001) before any transfer runs. +// The fee-drain attack on settle_funds: the fee vault has the same mint and +// the same authority as the quote vault, so a caller who passes +// `market.fee_vault` where `quote_vault` belongs would be paid accumulated +// taker fees against their own unsettled_quote credit. The constraint +// `has_one = quote_vault @ ErrorCode::InvalidQuoteVault` on the `market` +// field refuses the call with `InvalidQuoteVault` +// before any transfer runs. #[test] fn settle_funds_rejects_fee_vault_substituted_for_quote_vault() { let mut sc = full_setup(); @@ -1092,16 +1099,14 @@ fn settle_funds_rejects_fee_vault_substituted_for_quote_vault() { .to_account_metas(None), ); - let result = send_transaction_from_instructions( + let error = failure_text( &mut sc.svm, vec![attack_ix], &[&sc.buyer], &sc.buyer.pubkey(), + "settle_funds must reject fee_vault substituted for quote_vault", ); - assert!( - result.is_err(), - "settle_funds must reject fee_vault substituted for quote_vault" - ); + assert_fails_with(&error, order_book::errors::ErrorCode::InvalidQuoteVault); } #[test] @@ -1118,13 +1123,14 @@ fn initialize_market_rejects_zero_tick_size() { QUOTE_LOT_SIZE, MIN_ORDER_SIZE, ); - let result = send_transaction_from_instructions( + let error = failure_text( &mut sc.svm, vec![create_ix, ix], &[&sc.authority, &sc.order_book], &sc.authority.pubkey(), + "tick_size == 0 must be rejected", ); - assert!(result.is_err(), "tick_size == 0 must be rejected"); + assert_fails_with(&error, order_book::errors::ErrorCode::InvalidTickSize); } #[test] @@ -1140,13 +1146,14 @@ fn initialize_market_rejects_zero_base_lot_size() { QUOTE_LOT_SIZE, MIN_ORDER_SIZE, ); - let result = send_transaction_from_instructions( + let error = failure_text( &mut sc.svm, vec![create_ix, ix], &[&sc.authority, &sc.order_book], &sc.authority.pubkey(), + "base_lot_size == 0 must be rejected", ); - assert!(result.is_err(), "base_lot_size == 0 must be rejected"); + assert_fails_with(&error, order_book::errors::ErrorCode::InvalidBaseLotSize); } #[test] @@ -1162,13 +1169,14 @@ fn initialize_market_rejects_zero_quote_lot_size() { 0, MIN_ORDER_SIZE, ); - let result = send_transaction_from_instructions( + let error = failure_text( &mut sc.svm, vec![create_ix, ix], &[&sc.authority, &sc.order_book], &sc.authority.pubkey(), + "quote_lot_size == 0 must be rejected", ); - assert!(result.is_err(), "quote_lot_size == 0 must be rejected"); + assert_fails_with(&error, order_book::errors::ErrorCode::InvalidQuoteLotSize); } #[test] @@ -1186,16 +1194,14 @@ fn initialize_market_rejects_oversized_fee() { QUOTE_LOT_SIZE, MIN_ORDER_SIZE, ); - let result = send_transaction_from_instructions( + let error = failure_text( &mut sc.svm, vec![create_ix, ix], &[&sc.authority, &sc.order_book], &sc.authority.pubkey(), + "fee_basis_points above 10_000 must be rejected", ); - assert!( - result.is_err(), - "fee_basis_points above 10_000 must be rejected" - ); + assert_fails_with(&error, order_book::errors::ErrorCode::InvalidFeeBasisPoints); } // --------------------------------------------------------------------------- @@ -2044,7 +2050,7 @@ fn authority_can_withdraw_fees_after_match() { EXPECTED_FEE ); - let withdraw_ix = build_withdraw_fees_ix(&sc, authority_quote_ata); + let withdraw_ix = build_withdraw_fees_ix(&sc, &sc.authority.pubkey(), authority_quote_ata); send_transaction_from_instructions( &mut sc.svm, vec![withdraw_ix], @@ -2064,6 +2070,81 @@ fn authority_can_withdraw_fees_after_match() { ); } +#[test] +fn withdraw_fees_rejects_a_non_authority_signer() { + // The same fill as `authority_can_withdraw_fees_after_match`, so the fee + // vault holds something worth taking; then the buyer, not the authority, + // signs `withdraw_fees` with their own quote ATA as the destination. + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + + const MAKER_ASK_ID: u64 = 1; + const PRICE: u64 = 2000; + const QUANTITY: u64 = 50; + const GROSS: u64 = PRICE * QUANTITY * QUOTE_LOT_SIZE; + const EXPECTED_FEE: u64 = fee_ceil(GROSS); + + let ask_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, + MAKER_ASK_ID, + PRICE, + QUANTITY, + ); + send_transaction_from_instructions( + &mut sc.svm, + vec![ask_ix], + &[&sc.seller], + &sc.seller.pubkey(), + ) + .unwrap(); + + const TAKER_BID_ID: u64 = 2; + 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, + TAKER_BID_ID, + PRICE, + QUANTITY, + &[(MAKER_ASK_ID, sc.seller_market_user)], + ); + send_transaction_from_instructions(&mut sc.svm, vec![bid_ix], &[&sc.buyer], &sc.buyer.pubkey()) + .unwrap(); + assert_eq!( + get_token_account_balance(&sc.svm, &sc.fee_vault).unwrap(), + EXPECTED_FEE + ); + + let attack_ix = build_withdraw_fees_ix(&sc, &sc.buyer.pubkey(), sc.buyer_quote_ata); + let error = failure_text( + &mut sc.svm, + vec![attack_ix], + &[&sc.buyer], + &sc.buyer.pubkey(), + "a trader must not be able to withdraw the fee vault", + ); + assert_fails_with(&error, order_book::errors::ErrorCode::NotMarketAuthority); + + // The fee stays in the vault and the buyer's quote balance is what the + // fill left: the starting balance less the gross they paid. + assert_eq!( + get_token_account_balance(&sc.svm, &sc.fee_vault).unwrap(), + EXPECTED_FEE + ); + assert_eq!( + get_token_account_balance(&sc.svm, &sc.buyer_quote_ata).unwrap(), + TRADER_STARTING_BALANCE - GROSS + ); +} + #[test] fn settle_funds_after_match_pays_out_both_unsettled_balances() { // End-to-end: match, then call settle_funds for both sides. Both @@ -2459,6 +2540,32 @@ fn custom_error(code: order_book::errors::ErrorCode) -> String { format!("Custom({})", code as u32 + 6000) } +/// Sends instructions that must fail and returns the failure text, so the +/// caller can assert on the error code with `assert_fails_with`. Panics with +/// `must_fail` if the transaction goes through. +fn failure_text( + svm: &mut LiteSVM, + instructions: Vec, + signers: &[&Keypair], + payer: &Pubkey, + must_fail: &str, +) -> String { + match send_transaction_from_instructions(svm, instructions, signers, payer) { + Ok(_) => panic!("{must_fail}"), + Err(error) => format!("{error:?}"), + } +} + +/// Asserts that a failure text carries `code`: the one error the call under +/// test must refuse with, not merely some error. +fn assert_fails_with(error: &str, code: order_book::errors::ErrorCode) { + let expected = custom_error(code); + assert!( + error.contains(&expected), + "expected the failure to carry {expected}, got: {error}" + ); +} + struct Trader { keypair: Keypair, market_user: Pubkey, @@ -3005,7 +3112,7 @@ fn paused_market_still_pays_out_fills_and_withdraws_fees() { ); // So does the authority's fee withdrawal. - let withdraw_ix = build_withdraw_fees_ix(&sc, authority_quote_ata); + let withdraw_ix = build_withdraw_fees_ix(&sc, &sc.authority.pubkey(), authority_quote_ata); send_transaction_from_instructions( &mut sc.svm, vec![withdraw_ix], diff --git a/finance/order-book/anchor/CHANGELOG.md b/finance/order-book/anchor/CHANGELOG.md index ca62637c..169338d2 100644 --- a/finance/order-book/anchor/CHANGELOG.md +++ b/finance/order-book/anchor/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## Unreleased - 2026-10-05 + +### Added + +- `withdraw_fees_rejects_a_non_authority_signer`: after a fill, a trader + signing `withdraw_fees` gets `NotMarketAuthority`, the fee stays in the + vault, and the trader's quote balance is unchanged. + +### Changed + +- Every refusal test asserts the error code the call fails with, through + the suite's `failure_text` and `assert_fails_with` helpers: + `place_order_rejects_zero_price` (`InvalidPrice`), + `place_order_rejects_unaligned_tick` (`InvalidTickSize`), + `place_order_rejects_below_min_order_size` (`BelowMinOrderSize`), + `cancel_order_rejects_non_owner` (`Unauthorized`), + `settle_funds_rejects_fee_vault_substituted_for_quote_vault` + (`InvalidQuoteVault`), `initialize_market_rejects_zero_tick_size` + (`InvalidTickSize`), `initialize_market_rejects_zero_base_lot_size` + (`InvalidBaseLotSize`), `initialize_market_rejects_zero_quote_lot_size` + (`InvalidQuoteLotSize`) and `initialize_market_rejects_oversized_fee` + (`InvalidFeeBasisPoints`). +- `build_withdraw_fees_ix` takes the signing authority, as the pause and + resume builders do. + ## Unreleased - 2026-10-04 ### Added diff --git a/finance/order-book/anchor/README.md b/finance/order-book/anchor/README.md index bc9d27e2..301191a4 100644 --- a/finance/order-book/anchor/README.md +++ b/finance/order-book/anchor/README.md @@ -1527,7 +1527,7 @@ anchor test --skip-local-validator Expected: ``` -running 40 tests +running 41 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 @@ -1568,6 +1568,7 @@ test taker_crosses_multiple_resting_orders_best_price_first ... ok test taker_partially_filled_remainder_rests_on_book ... ok test taker_partially_fills_resting_order_rest_stays_on_book ... ok test trader_can_evict_their_own_worst_order ... ok +test withdraw_fees_rejects_a_non_authority_signer ... ok ``` ### What each test exercises @@ -1580,14 +1581,18 @@ test trader_can_evict_their_own_worst_order ... ok - `place_ask_moves_base_into_vault`: Fund lock on ask - `settle_funds_moves_unsettled_base_to_user`: Vault → user ATA transfer via market PDA signer -**Validation:** - -- `place_order_rejects_zero_price`: `price > 0` -- `place_order_rejects_unaligned_tick`: `price % tick_size == 0` -- `place_order_rejects_below_min_order_size`: `quantity >= min_order_size` -- `cancel_order_rejects_non_owner`: Ownership check on cancel -- `initialize_market_rejects_zero_tick_size`: Init constraint -- `initialize_market_rejects_oversized_fee`: `fee_bps <= 10_000` +**Validation (each asserts the error code the refusal carries):** + +- `place_order_rejects_zero_price`: `price > 0`, else `InvalidPrice` +- `place_order_rejects_unaligned_tick`: `price % tick_size == 0`, else `InvalidTickSize` +- `place_order_rejects_below_min_order_size`: `quantity >= min_order_size`, else `BelowMinOrderSize` +- `cancel_order_rejects_non_owner`: A signer who does not own the order gets `Unauthorized` +- `settle_funds_rejects_fee_vault_substituted_for_quote_vault`: The fee vault passed as `quote_vault` gets `InvalidQuoteVault` +- `initialize_market_rejects_zero_tick_size`: `InvalidTickSize` +- `initialize_market_rejects_zero_base_lot_size`: `InvalidBaseLotSize` +- `initialize_market_rejects_zero_quote_lot_size`: `InvalidQuoteLotSize` +- `initialize_market_rejects_oversized_fee`: `fee_bps <= 10_000`, else `InvalidFeeBasisPoints` +- `withdraw_fees_rejects_a_non_authority_signer`: A trader signing `withdraw_fees` after a fill gets `NotMarketAuthority`, and the fee stays in the vault **Cancel + settle flow:** 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 56f47474..67b8b5dc 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 @@ -398,7 +398,11 @@ fn build_place_order_with_makers_ix( ix } -fn build_withdraw_fees_ix(sc: &Scenario, authority_quote_account: Address) -> Instruction { +fn build_withdraw_fees_ix( + sc: &Scenario, + authority: &Address, + authority_quote_account: Address, +) -> Instruction { Instruction::new_with_bytes( sc.program_id, &order_book::instruction::WithdrawFees {}.data(), @@ -407,7 +411,7 @@ fn build_withdraw_fees_ix(sc: &Scenario, authority_quote_account: Address) -> In fee_vault: sc.fee_vault, authority_quote_account, quote_mint: sc.quote_mint, - authority: sc.authority.pubkey(), + authority: *authority, token_program: token_program_id(), } .to_account_metas(None), @@ -717,9 +721,14 @@ fn place_order_rejects_zero_price() { 0, BID_QUANTITY, ); - let result = - send_transaction_from_instructions(&mut sc.svm, vec![ix], &[&sc.buyer], &sc.buyer.pubkey()); - assert!(result.is_err(), "order at price 0 must be rejected"); + let error = failure_text( + &mut sc.svm, + vec![ix], + &[&sc.buyer], + &sc.buyer.pubkey(), + "order at price 0 must be rejected", + ); + assert_fails_with(&error, order_book::errors::ErrorCode::InvalidPrice); } #[test] @@ -768,12 +777,14 @@ fn place_order_rejects_unaligned_tick() { unaligned_price, BID_QUANTITY, ); - let result = - send_transaction_from_instructions(&mut sc.svm, vec![ix], &[&sc.buyer], &sc.buyer.pubkey()); - assert!( - result.is_err(), - "unaligned price must be rejected by tick check" + let error = failure_text( + &mut sc.svm, + vec![ix], + &[&sc.buyer], + &sc.buyer.pubkey(), + "unaligned price must be rejected by tick check", ); + assert_fails_with(&error, order_book::errors::ErrorCode::InvalidTickSize); } #[test] @@ -820,16 +831,14 @@ fn place_order_rejects_below_min_order_size() { ASK_PRICE, too_small_quantity, ); - let result = send_transaction_from_instructions( + let error = failure_text( &mut sc.svm, vec![ix], &[&sc.seller], &sc.seller.pubkey(), + "quantity below min_order_size must be rejected", ); - assert!( - result.is_err(), - "quantity below min_order_size must be rejected" - ); + assert_fails_with(&error, order_book::errors::ErrorCode::BelowMinOrderSize); } #[test] @@ -919,16 +928,14 @@ fn cancel_order_rejects_non_owner() { sc.seller_market_user, bid_order_id, ); - let result = send_transaction_from_instructions( + let error = failure_text( &mut sc.svm, vec![attack_ix], &[&sc.seller], &sc.seller.pubkey(), + "non-owner must not be able to cancel an order", ); - assert!( - result.is_err(), - "non-owner must not be able to cancel an order" - ); + assert_fails_with(&error, order_book::errors::ErrorCode::Unauthorized); } #[test] @@ -1034,13 +1041,13 @@ fn cancel_and_settle_bid_refunds_full_quote() { ); } -// Regression test for the fee-drain attack on settle_funds. Pre-fix, -// `SettleFundsAccountConstraints` did not bind `quote_vault` to `market.quote_vault` via -// an address constraint, so a caller could pass `market.fee_vault` (same mint and -// same authority) where `quote_vault` was expected and drain accumulated -// taker fees while spending their own unsettled_quote credit. The -// address constraint now bound on the vault field must surface this -// as `ConstraintHasOne` (anchor error 2001) before any transfer runs. +// The fee-drain attack on settle_funds: the fee vault has the same mint and +// the same authority as the quote vault, so a caller who passes +// `market.fee_vault` where `quote_vault` belongs would be paid accumulated +// taker fees against their own unsettled_quote credit. The constraint +// `address = market.quote_vault @ ErrorCode::InvalidQuoteVault` on the +// `quote_vault` field refuses the call with `InvalidQuoteVault` +// before any transfer runs. #[test] fn settle_funds_rejects_fee_vault_substituted_for_quote_vault() { let mut sc = full_setup(); @@ -1095,16 +1102,14 @@ fn settle_funds_rejects_fee_vault_substituted_for_quote_vault() { .to_account_metas(None), ); - let result = send_transaction_from_instructions( + let error = failure_text( &mut sc.svm, vec![attack_ix], &[&sc.buyer], &sc.buyer.pubkey(), + "settle_funds must reject fee_vault substituted for quote_vault", ); - assert!( - result.is_err(), - "settle_funds must reject fee_vault substituted for quote_vault" - ); + assert_fails_with(&error, order_book::errors::ErrorCode::InvalidQuoteVault); } #[test] @@ -1121,13 +1126,14 @@ fn initialize_market_rejects_zero_tick_size() { QUOTE_LOT_SIZE, MIN_ORDER_SIZE, ); - let result = send_transaction_from_instructions( + let error = failure_text( &mut sc.svm, vec![create_ix, ix], &[&sc.authority, &sc.order_book], &sc.authority.pubkey(), + "tick_size == 0 must be rejected", ); - assert!(result.is_err(), "tick_size == 0 must be rejected"); + assert_fails_with(&error, order_book::errors::ErrorCode::InvalidTickSize); } #[test] @@ -1143,13 +1149,14 @@ fn initialize_market_rejects_zero_base_lot_size() { QUOTE_LOT_SIZE, MIN_ORDER_SIZE, ); - let result = send_transaction_from_instructions( + let error = failure_text( &mut sc.svm, vec![create_ix, ix], &[&sc.authority, &sc.order_book], &sc.authority.pubkey(), + "base_lot_size == 0 must be rejected", ); - assert!(result.is_err(), "base_lot_size == 0 must be rejected"); + assert_fails_with(&error, order_book::errors::ErrorCode::InvalidBaseLotSize); } #[test] @@ -1165,13 +1172,14 @@ fn initialize_market_rejects_zero_quote_lot_size() { 0, MIN_ORDER_SIZE, ); - let result = send_transaction_from_instructions( + let error = failure_text( &mut sc.svm, vec![create_ix, ix], &[&sc.authority, &sc.order_book], &sc.authority.pubkey(), + "quote_lot_size == 0 must be rejected", ); - assert!(result.is_err(), "quote_lot_size == 0 must be rejected"); + assert_fails_with(&error, order_book::errors::ErrorCode::InvalidQuoteLotSize); } #[test] @@ -1189,16 +1197,14 @@ fn initialize_market_rejects_oversized_fee() { QUOTE_LOT_SIZE, MIN_ORDER_SIZE, ); - let result = send_transaction_from_instructions( + let error = failure_text( &mut sc.svm, vec![create_ix, ix], &[&sc.authority, &sc.order_book], &sc.authority.pubkey(), + "fee_basis_points above 10_000 must be rejected", ); - assert!( - result.is_err(), - "fee_basis_points above 10_000 must be rejected" - ); + assert_fails_with(&error, order_book::errors::ErrorCode::InvalidFeeBasisPoints); } // --------------------------------------------------------------------------- @@ -2047,7 +2053,7 @@ fn authority_can_withdraw_fees_after_match() { EXPECTED_FEE ); - let withdraw_ix = build_withdraw_fees_ix(&sc, authority_quote_ata); + let withdraw_ix = build_withdraw_fees_ix(&sc, &sc.authority.pubkey(), authority_quote_ata); send_transaction_from_instructions( &mut sc.svm, vec![withdraw_ix], @@ -2067,6 +2073,81 @@ fn authority_can_withdraw_fees_after_match() { ); } +#[test] +fn withdraw_fees_rejects_a_non_authority_signer() { + // The same fill as `authority_can_withdraw_fees_after_match`, so the fee + // vault holds something worth taking; then the buyer, not the authority, + // signs `withdraw_fees` with their own quote ATA as the destination. + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + + const MAKER_ASK_ID: u64 = 1; + const PRICE: u64 = 2000; + const QUANTITY: u64 = 50; + const GROSS: u64 = PRICE * QUANTITY * QUOTE_LOT_SIZE; + const EXPECTED_FEE: u64 = fee_ceil(GROSS); + + let ask_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, + MAKER_ASK_ID, + PRICE, + QUANTITY, + ); + send_transaction_from_instructions( + &mut sc.svm, + vec![ask_ix], + &[&sc.seller], + &sc.seller.pubkey(), + ) + .unwrap(); + + const TAKER_BID_ID: u64 = 2; + 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, + TAKER_BID_ID, + PRICE, + QUANTITY, + &[(MAKER_ASK_ID, sc.seller_market_user)], + ); + send_transaction_from_instructions(&mut sc.svm, vec![bid_ix], &[&sc.buyer], &sc.buyer.pubkey()) + .unwrap(); + assert_eq!( + get_token_account_balance(&sc.svm, &sc.fee_vault).unwrap(), + EXPECTED_FEE + ); + + let attack_ix = build_withdraw_fees_ix(&sc, &sc.buyer.pubkey(), sc.buyer_quote_ata); + let error = failure_text( + &mut sc.svm, + vec![attack_ix], + &[&sc.buyer], + &sc.buyer.pubkey(), + "a trader must not be able to withdraw the fee vault", + ); + assert_fails_with(&error, order_book::errors::ErrorCode::NotMarketAuthority); + + // The fee stays in the vault and the buyer's quote balance is what the + // fill left: the starting balance less the gross they paid. + assert_eq!( + get_token_account_balance(&sc.svm, &sc.fee_vault).unwrap(), + EXPECTED_FEE + ); + assert_eq!( + get_token_account_balance(&sc.svm, &sc.buyer_quote_ata).unwrap(), + TRADER_STARTING_BALANCE - GROSS + ); +} + #[test] fn settle_funds_after_match_pays_out_both_unsettled_balances() { // End-to-end: match, then call settle_funds for both sides. Both @@ -2467,6 +2548,32 @@ fn custom_error(code: order_book::errors::ErrorCode) -> String { format!("Custom({})", code as u32 + 6000) } +/// Sends instructions that must fail and returns the failure text, so the +/// caller can assert on the error code with `assert_fails_with`. Panics with +/// `must_fail` if the transaction goes through. +fn failure_text( + svm: &mut LiteSVM, + instructions: Vec, + signers: &[&Keypair], + payer: &Address, + must_fail: &str, +) -> String { + match send_transaction_from_instructions(svm, instructions, signers, payer) { + Ok(_) => panic!("{must_fail}"), + Err(error) => format!("{error:?}"), + } +} + +/// Asserts that a failure text carries `code`: the one error the call under +/// test must refuse with, not merely some error. +fn assert_fails_with(error: &str, code: order_book::errors::ErrorCode) { + let expected = custom_error(code); + assert!( + error.contains(&expected), + "expected the failure to carry {expected}, got: {error}" + ); +} + struct Trader { keypair: Keypair, market_user: Address, @@ -3013,7 +3120,7 @@ fn paused_market_still_pays_out_fills_and_withdraws_fees() { ); // So does the authority's fee withdrawal. - let withdraw_ix = build_withdraw_fees_ix(&sc, authority_quote_ata); + let withdraw_ix = build_withdraw_fees_ix(&sc, &sc.authority.pubkey(), authority_quote_ata); send_transaction_from_instructions( &mut sc.svm, vec![withdraw_ix], diff --git a/finance/order-book/quasar/CHANGELOG.md b/finance/order-book/quasar/CHANGELOG.md index 17f8e13b..df444177 100644 --- a/finance/order-book/quasar/CHANGELOG.md +++ b/finance/order-book/quasar/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## Unreleased - 2026-10-05 + +### Changed + +- The tests mint NVDAx with its onchain 8 decimals over USDC's 6, so + `base_lot_size` is 100: a 5-lot ask locks 500 raw base and the pause + tests' 7-lot ask locks 700. The README's two-lot example uses the same + figures. + ## Unreleased - 2026-10-04 ### Added diff --git a/finance/order-book/quasar/README.md b/finance/order-book/quasar/README.md index 8ffe7f4c..f6a0f955 100644 --- a/finance/order-book/quasar/README.md +++ b/finance/order-book/quasar/README.md @@ -56,7 +56,7 @@ raw_quote = quantity × price × quote_lot_size ``` Choose `base_lot_size = 10^max(d_base − d_quote, 0)` and `quote_lot_size = 10^max(d_quote − d_base, 0)`. For -NVDAx (9 decimals) / USDC (6 decimals): `base_lot_size = 1000`, `quote_lot_size = 1`, so `price = 100` means +NVDAx (8 decimals) / USDC (6 decimals): `base_lot_size = 100`, `quote_lot_size = 1`, so `price = 100` means 100 USDC-units per base lot and `tick_size = 1` is one atomic increment. ## Instruction lifecycle diff --git a/finance/order-book/quasar/src/tests.rs b/finance/order-book/quasar/src/tests.rs index 68ea4392..3ce05303 100644 --- a/finance/order-book/quasar/src/tests.rs +++ b/finance/order-book/quasar/src/tests.rs @@ -20,15 +20,16 @@ use { quasar_test::prelude::*, }; -// --- Market parameters used across the tests (NVDAx-style base / USDC-style -// quote): base 9 decimals, quote 6 decimals. base_lot_size = 10^(9-6) = 1000, -// quote_lot_size = 1, so `price` reads as quote units per base lot. --- +// --- Market parameters used across the tests: NVDAx (8 decimals onchain, +// https://explorer.solana.com/address/Xsc9qvGR1efVDFGLrVsmkzv3qi45LTBjeUKSPmx9qEh) +// over USDC (6 decimals). base_lot_size = 10^(8-6) = 100, quote_lot_size = 1, +// so `price` reads as quote units per base lot. --- const FEE_BASIS_POINTS: u16 = 100; // 1% const TICK_SIZE: u64 = 1; -const BASE_LOT_SIZE: u64 = 1000; +const BASE_LOT_SIZE: u64 = 100; const QUOTE_LOT_SIZE: u64 = 1; const MIN_ORDER_SIZE: u64 = 1; -const BASE_DECIMALS: u8 = 9; +const BASE_DECIMALS: u8 = 8; const QUOTE_DECIMALS: u8 = 6; // Deterministic addresses keep tests independent of discovery order. @@ -220,18 +221,18 @@ fn initialize_market_user_starts_with_empty_balances(test: &mut Test) { /// Full lifecycle: a maker rests an ask, a taker bid crosses it fully, both /// settle, and the authority withdraws the fee. Prices in the NVDAx/USDC lot /// model: ask 5 lots @ 100 -> gross 500 quote, 1% fee = 5, maker nets 495 -/// quote, taker receives 5000 raw base. +/// quote, taker receives 500 raw base. #[quasar_test] fn place_match_settle_withdraw_moves_tokens_and_fees(test: &mut Test) { let market = init_market(test); let maker_market_user = initialize_market_user(test, market, MAKER); let taker_market_user = initialize_market_user(test, market, TAKER); - // Maker sells 5 base lots (locks 5 * 1000 = 5000 raw base); taker buys 5 + // Maker sells 5 base lots (locks 5 * 100 = 500 raw base); taker buys 5 // lots at 100 (locks 100 * 5 * 1 = 500 raw quote). const PRICE: u64 = 100; const QUANTITY: u64 = 5; - const MAKER_BASE_LOCK: u64 = QUANTITY * BASE_LOT_SIZE; // 5000 + const MAKER_BASE_LOCK: u64 = QUANTITY * BASE_LOT_SIZE; // 500 const TAKER_QUOTE_LOCK: u64 = PRICE * QUANTITY * QUOTE_LOT_SIZE; // 500 const GROSS_QUOTE: u64 = PRICE * QUANTITY * QUOTE_LOT_SIZE; // 500 const FEE_QUOTE: u64 = 5; // ceil(500 * 100 / 10000) @@ -710,7 +711,7 @@ 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 PAUSE_LOCKED_BASE: u64 = PAUSE_QUANTITY * BASE_LOT_SIZE; // 700 const ASK: u8 = 1; /// A market with the maker (the seller) and the taker (the buyer) registered diff --git a/finance/perpetual-futures/anchor-v1/CHANGELOG.md b/finance/perpetual-futures/anchor-v1/CHANGELOG.md index 42695ee5..64a85ffd 100644 --- a/finance/perpetual-futures/anchor-v1/CHANGELOG.md +++ b/finance/perpetual-futures/anchor-v1/CHANGELOG.md @@ -1,5 +1,36 @@ # Changelog +## Unreleased, 2026-10-05 + +Every fee rounds up. `basis_points_of` in `instructions/shared.rs` rounds its +result up to the next base unit, so the open, close and liquidation fees and +the maintenance requirement a position is liquidated at each round in the +pool's favour: a fee is never a minor unit short, and a position is never a +minor unit too healthy to liquidate. The insurance fund's cut of a fee the +pool already holds is split by the new `basis_points_of_rounded_down`, and the +program takes the remainder, so the two still add up to the whole fee. The +walkthrough's figures are exact multiples and are unchanged. Tested by +`test_fees_and_maintenance_requirement_round_up`, which opens, closes and +liquidates a position one base unit over $5,000 and checks the 5,000,001-unit +fees, the 2,500,000 / 2,500,001 insurance and program split, the liquidation +at an equity of exactly 250,000,001 and the 50,000,001 liquidation fee, and by +`test_basis_points_of_rounds_up_and_the_insurance_split_rounds_down` at the +boundaries. + +Every refusal test asserts its error code. `test_open_rejects_zero_amounts` +asserts `ZeroAmount`, `test_open_long_slippage_guard` `SlippageExceeded`, +`test_stale_price_rejected` `StalePrice`, +`test_open_rejects_price_from_before_a_restart` `PricePredatesRestart`, +`test_wide_oracle_confidence_rejected` `OracleConfidenceTooWide`, +`test_healthy_position_cannot_be_liquidated` `PositionHealthy`, the second +sweep in `test_collect_fees` `NothingToClaim`, and +`test_collect_fees_requires_authority` Anchor's `ConstraintHasOne` (2001), the code of the +constraint that binds `authority` to the pool, through the new +`assert_fails_with_anchor_error`. +`test_initialize_pool_rejects_close_fee_at_or_above_maintenance_margin` runs +a close fee of 600 and of 500 basis points against a 500 maintenance margin, +both refused with `InvalidParameter`, and 499, accepted. + ## 2026-10-04 Check which program owns the price feed. `initialize_pool` records the feed diff --git a/finance/perpetual-futures/anchor-v1/README.md b/finance/perpetual-futures/anchor-v1/README.md index c6e20fa4..fc3aceca 100644 --- a/finance/perpetual-futures/anchor-v1/README.md +++ b/finance/perpetual-futures/anchor-v1/README.md @@ -21,7 +21,7 @@ A [perpetual future](https://www.investopedia.com/terms/f/futurescontract.asp) ( - `perpetual-futures`: The exchange: pool creation, liquidity provision, opening/closing leveraged positions, funding, liquidation, and fee collection. - `mock-price-feed`: Test-only price feed. Stores a price, scale, last-update slot, and confidence band that tests write directly. Replaced in production by a Pyth `PriceUpdateV2` account, as read in [`basics/pyth`](../../../basics/pyth/). -All arithmetic is integer `u128` with `checked_*` operations, multiplying before dividing and rounding in the pool's favour: no floats, no fixed-point library. +All arithmetic is integer `u128` with `checked_*` operations, multiplying before dividing and rounding in the pool's favour: every fee and the maintenance requirement round up, and what is paid out rounds down. No floats, no fixed-point library. --- @@ -81,7 +81,7 @@ Funding runs on the wall clock rather than the slot count, so what a position co ### Maintenance margin and liquidation -A position's *equity* is its net collateral plus profit/loss minus funding. Once equity falls to or below the [maintenance margin](https://www.investopedia.com/terms/m/maintenancemargin.asp) (`maintenance_margin_bps` of notional), the position can be [liquidated](https://www.investopedia.com/terms/l/liquidation.asp). Liquidation is permissionless: anyone can crank it and earn the liquidation fee, `liquidation_fee_bps` of the position's size, paid out of its remaining equity. Whatever part of the fee the equity cannot cover is forgiven, as in Percolator: neither the insurance fund nor the liquidity providers pay it, so a liquidator of a position whose equity is already below zero receives nothing, and the position still closes. The insurance fund pays its deficit first (see [the insurance fund](#the-insurance-fund)); `test_liquidation_of_bankrupt_position_charges_insurance_before_liquidity` liquidates such a position and checks the exact split. +A position's *equity* is its net collateral plus profit/loss minus funding. Once equity falls to or below the [maintenance margin](https://www.investopedia.com/terms/m/maintenancemargin.asp) (`maintenance_margin_bps` of notional, rounded up), the position can be [liquidated](https://www.investopedia.com/terms/l/liquidation.asp). Liquidation is permissionless: anyone can crank it and earn the liquidation fee, `liquidation_fee_bps` of the position's size, rounded up and paid out of its remaining equity. Whatever part of the fee the equity cannot cover is forgiven, as in Percolator: neither the insurance fund nor the liquidity providers pay it, so a liquidator of a position whose equity is already below zero receives nothing, and the position still closes. The insurance fund pays its deficit first (see [the insurance fund](#the-insurance-fund)); `test_liquidation_of_bankrupt_position_charges_insurance_before_liquidity` liquidates such a position and checks the exact split. `initialize_pool` requires `maintenance_margin_bps < initial_margin_bps <= 10_000` and refuses anything else with `InitialMarginNotAboveMaintenance` (or `InvalidParameter` above 10,000). Every position therefore opens with more margin than it is liquidated at, so none can be liquidated in the slot it opened. @@ -104,7 +104,7 @@ A single oracle print can be wrong while still being fresh, positive and confide ### Fees and slippage -Open and close fees are charged in [basis points](https://www.investopedia.com/terms/b/basispoint.asp) (1 bp = 0.01%) of notional; `insurance_fee_bps` of each goes to the insurance fund and the rest accrues to the program. Every state-changing handler takes a `minimum_*` / acceptable-price bound (protection against [slippage](https://www.investopedia.com/terms/s/slippage.asp), the gap between the expected and actual fill) and reverts if the bound is breached. Pass `0` to opt out. +Open and close fees are charged in [basis points](https://www.investopedia.com/terms/b/basispoint.asp) (1 bp = 0.01%) of notional. `basis_points_of` rounds each fee up to the next base unit, as it does the liquidation fee and the maintenance requirement, so a fee is never a minor unit short in the trader's favour; `insurance_fee_bps` of each fee, rounded down by `basis_points_of_rounded_down`, goes to the insurance fund and the program takes the rest. `test_fees_and_maintenance_requirement_round_up` opens, closes and liquidates a position one base unit over $5,000 and checks every rounded figure. Every state-changing handler takes a `minimum_*` / acceptable-price bound (protection against [slippage](https://www.investopedia.com/terms/s/slippage.asp), the gap between the expected and actual fill) and reverts if the bound is breached. Pass `0` to opt out. --- @@ -267,13 +267,14 @@ The tests run in-process with [LiteSVM](https://www.anchor-lang.com/docs/testing - 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 +- liquidation, and the refusal to liquidate a healthy position (`test_healthy_position_cannot_be_liquidated`, which asserts `PositionHealthy`) - 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`) - the profit warm-up on both sides of its boundary (`test_profit_blocked_before_maturation`, `test_profit_realized_after_maturation`), and a loss closing in the slot it opened (`test_loss_not_gated_by_maturation`) - the insurance fund: its exact share of each fee (`test_insurance_fund_funded_by_fees`), a bankrupt position's deficit paid by the fund (`test_insurance_absorbs_bankruptcy_deficit`), and a bankrupt position liquidated for no fee with the fund paying before the providers (`test_liquidation_of_bankrupt_position_charges_insurance_before_liquidity`) - withdrawals capped at `liquidity` while traders are down (`test_remove_liquidity_capped_at_liquidity`) -- `initialize_pool`'s parameter checks, including an initial margin at or below the maintenance margin, a price band outside its range, and an insurance fee of 10,000 basis points or more -- fee collection +- `initialize_pool`'s parameter checks, including an initial margin at or below the maintenance margin, a close fee at or above it (`test_initialize_pool_rejects_close_fee_at_or_above_maintenance_margin`), a price band outside its range, and an insurance fee of 10,000 basis points or more +- every fee and the maintenance requirement rounding up (`test_fees_and_maintenance_requirement_round_up`), and `basis_points_of` at its boundaries +- fee collection, and its refusal to anyone but the pool's authority (`test_collect_fees_requires_authority`, which asserts the constraint's own error code) ```bash anchor build diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/liquidate_position.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/liquidate_position.rs index c8cd38dc..a1d908af 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/liquidate_position.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/liquidate_position.rs @@ -21,7 +21,8 @@ pub fn handle_liquidate_position( let settlement = settle_position(pool, position, price)?; // Liquidatable only once equity has fallen to or below the maintenance - // margin. A healthy position can only be closed by its owner. + // margin, rounded up against the trader. A healthy position can only be + // closed by its owner. let maintenance = basis_points_of(position_size, pool.maintenance_margin_bps)?; require!( settlement.equity <= maintenance as i128, 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 fd7ef1c0..07ab1c94 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 @@ -241,7 +241,7 @@ pub fn apply_haircut(profit: i128, haircut: u128) -> Result { /// insurance cut is `insurance_fee_bps` of the fee, rounded down, and the /// program keeps the rest, so the two always add up to the whole fee. pub fn split_fee(fee: u64, insurance_fee_bps: u16) -> Result<(u64, u64)> { - let insurance_cut = basis_points_of(fee, insurance_fee_bps)?; + let insurance_cut = basis_points_of_rounded_down(fee, insurance_fee_bps)?; let program_cut = fee .checked_sub(insurance_cut) .ok_or(PerpError::MathOverflow)?; @@ -286,10 +286,28 @@ pub fn position_funding( }) } -/// `basis_points` of `amount`, rounded down — used for fees and for the -/// maintenance-margin threshold alike. Widened to `u128` so a large amount -/// cannot overflow the intermediate product. +/// `basis_points` of `amount`, rounded up: the open, close and liquidation +/// fees and the maintenance requirement a position is liquidated at, each of +/// which rounds in the pool's favour, so a fee is never a minor unit short and +/// a position is never a minor unit too healthy to liquidate. Widened to +/// `u128` so a large amount cannot overflow the intermediate product. pub fn basis_points_of(amount: u64, basis_points: u16) -> Result { + let denominator = BASIS_POINTS_DENOMINATOR as u128; + (amount as u128) + .checked_mul(basis_points as u128) + .ok_or(PerpError::MathOverflow)? + .checked_add(denominator - 1) + .ok_or(PerpError::MathOverflow)? + .checked_div(denominator) + .ok_or(PerpError::MathOverflow)? + .try_into() + .map_err(|_| PerpError::MathOverflow.into()) +} + +/// `basis_points` of `amount`, rounded down: the insurance fund's cut of a fee +/// the pool has already collected, where the rounding moves nothing between +/// the pool and a trader. `split_fee` gives the program the remainder. +pub fn basis_points_of_rounded_down(amount: u64, basis_points: u16) -> Result { (amount as u128) .checked_mul(basis_points as u128) .ok_or(PerpError::MathOverflow)? 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 7ef79c16..1ce06408 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 @@ -1,12 +1,16 @@ use { anchor_lang::{ + error::ErrorCode as AnchorErrorCode, solana_program::{instruction::Instruction, pubkey::Pubkey, system_program}, AccountDeserialize, InstructionData, ToAccountMetas, }, litesvm::LiteSVM, perpetual_futures::{ errors::PerpError, - instructions::initialize_pool::PoolParameters, + instructions::{ + initialize_pool::PoolParameters, + shared::{basis_points_of, basis_points_of_rounded_down}, + }, state::{Pool, Position, Side}, }, solana_keypair::Keypair, @@ -91,7 +95,16 @@ fn default_parameters(funding_rate_per_second: u64) -> PoolParameters { /// 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: PerpError) { - let code = expected as u32 + 6000; + assert_fails_with_code(result, expected as u32 + 6000); +} + +/// Assert that `result` failed on one of Anchor's own account constraints, +/// which report their code without the program-error offset. +fn assert_fails_with_anchor_error(result: Result, expected: AnchorErrorCode) { + assert_fails_with_code(result, expected as u32); +} + +fn assert_fails_with_code(result: Result, code: u32) { let Err(error) = result else { panic!("the transaction should have failed with error code {code}"); }; @@ -697,7 +710,10 @@ fn test_first_deposit_below_minimum_fails() { .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); + assert_eq!( + get_token_account_balance(&market.svm, &provider_lp).unwrap(), + 1 + ); } #[test] @@ -967,26 +983,28 @@ fn test_open_rejects_zero_amounts() { market.seed_liquidity(100_000 * ONE_USDC); let (trader, trader_collateral) = market.funded_trader(1_000 * ONE_USDC); - assert!(market - .open_position( + assert_fails_with( + market.open_position( &trader, trader_collateral, Side::Long, 0, 5_000 * ONE_USDC, - 0 - ) - .is_err()); - assert!(market - .open_position( + 0, + ), + PerpError::ZeroAmount, + ); + assert_fails_with( + market.open_position( &trader, trader_collateral, Side::Long, 1_000 * ONE_USDC, 0, - 0 - ) - .is_err()); + 0, + ), + PerpError::ZeroAmount, + ); } #[test] @@ -1049,16 +1067,17 @@ fn test_open_long_slippage_guard() { // Current price is $100 (10^10 in scale 8). A long willing to pay at most // $99 must be rejected. let acceptable_price = (dollars(99)) as u64; - assert!(market - .open_position( + assert_fails_with( + market.open_position( &trader, trader_collateral, Side::Long, collateral, 5_000 * ONE_USDC, - acceptable_price - ) - .is_err()); + acceptable_price, + ), + PerpError::SlippageExceeded, + ); } #[test] @@ -1073,16 +1092,17 @@ fn test_stale_price_rejected() { // slot, not at zero, so an absolute target could move time backwards. let opened_at = market.current_slot(); market.warp(opened_at + 10_000); - assert!(market - .open_position( + assert_fails_with( + market.open_position( &trader, trader_collateral, Side::Long, collateral, 5_000 * ONE_USDC, - 0 - ) - .is_err()); + 0, + ), + PerpError::StalePrice, + ); } /// A cluster restart passes hours of wall-clock time in zero slots, so a @@ -1104,16 +1124,17 @@ fn test_open_rejects_price_from_before_a_restart() { market.warp(published_at + 5); market.set_last_restart_slot(published_at + 3); - assert!(market - .open_position( + assert_fails_with( + market.open_position( &trader, trader_collateral, Side::Long, collateral, 5_000 * ONE_USDC, - u64::MAX - ) - .is_err()); + u64::MAX, + ), + PerpError::PricePredatesRestart, + ); // Publishing after the restart reopens the pool. Warp first: the retry is // otherwise byte-identical to the rejected open, so it would carry the same @@ -1183,16 +1204,17 @@ fn test_wide_oracle_confidence_rejected() { // The pool tolerates a 1% confidence band (max_confidence_bps = 100). Widen // the feed's band to 2% of the price and the open must be rejected. market.set_price_with_confidence(dollars(100), dollars(2) as u64); - assert!(market - .open_position( + assert_fails_with( + market.open_position( &trader, trader_collateral, Side::Long, collateral, 5_000 * ONE_USDC, - 0 - ) - .is_err()); + 0, + ), + PerpError::OracleConfidenceTooWide, + ); } #[test] @@ -1422,9 +1444,10 @@ fn test_healthy_position_cannot_be_liquidated() { // Price barely moves; the position stays healthy. market.set_price(dollars(99)); - assert!(market - .liquidate(&liquidator, &trader.pubkey(), trader_collateral, Side::Long) - .is_err()); + assert_fails_with( + market.liquidate(&liquidator, &trader.pubkey(), trader_collateral, Side::Long), + PerpError::PositionHealthy, + ); } #[test] @@ -1458,8 +1481,10 @@ fn test_collect_fees() { ); assert_eq!(market.pool_state().program_fees, 0); - // Nothing left to claim on a second sweep. - assert!(market.collect_fees(&admin).is_err()); + // Nothing left to claim on a second sweep (a fresh blockhash, or the + // identical transaction is rejected as already processed). + market.svm.expire_blockhash(); + assert_fails_with(market.collect_fees(&admin), PerpError::NothingToClaim); } #[test] @@ -1487,7 +1512,12 @@ fn test_collect_fees_requires_authority() { &market.payer, ) .unwrap(); - assert!(market.collect_fees(&imposter).is_err()); + // The pool's `has_one = authority` constraint refuses the imposter, so + // the refusal is Anchor's, not the program's. + assert_fails_with_anchor_error( + market.collect_fees(&imposter), + AnchorErrorCode::ConstraintHasOne, + ); } /// Nothing is set aside to back a position's profit, so a position can open @@ -1887,13 +1917,13 @@ fn test_insurance_fund_funded_by_fees() { .unwrap(); market.seed_liquidity(100_000 * ONE_USDC); - // A size whose 0.1% fee is 1,234,567 minor units: 3,333 basis points of - // that is 411,481.18, so the insurance fund gets 411,481 and the program - // the other 823,086. - let size = 1_234_567_890; - let fee = 1_234_567; + // A size whose 0.1% fee is 1,234,567.89 minor units, rounded up to + // 1,234,568: 3,333 basis points of that is 411,481.5, so the insurance + // fund gets 411,481 (its cut rounds down) and the program the other 823,087. + let size: u64 = 1_234_567_890; + let fee = 1_234_568; let insurance_cut = 411_481; - assert_eq!(size / 1_000, fee); + assert_eq!(size.div_ceil(1_000), fee); let collateral = 200 * ONE_USDC; let (trader, trader_collateral) = market.funded_trader(collateral); market @@ -2008,6 +2038,98 @@ fn test_liquidation_of_bankrupt_position_charges_insurance_before_liquidity() { market.assert_vault_matches_ledger(); } +/// Every fee rounds up, and so does the maintenance requirement, so none is +/// a minor unit short in the trader's favour. A position one base unit over +/// $5,000 pays $5.000001 to open and the same to close: 0.1% of it is +/// 5,000,000.001 base units, rounded up to 5,000,001. The insurance fund's +/// half of a fee rounds down, and the program takes the odd unit. The same +/// position is liquidatable at an equity of 250,000,001 base units, the +/// maintenance requirement 250,000,000.05 rounded up, where a requirement +/// rounded down would have left it one base unit too healthy, and the +/// liquidation fee is 50,000,001. +#[test] +fn test_fees_and_maintenance_requirement_round_up() { + let mut market = Market::default_market(); + market.seed_liquidity(100_000 * ONE_USDC); + let collateral = 1_000 * ONE_USDC; + let size = 5_000 * ONE_USDC + 1; + let fee = 5_000_001; + let (trader, trader_collateral) = market.funded_trader(2 * collateral); + + market + .open_position(&trader, trader_collateral, Side::Long, collateral, size, 0) + .unwrap(); + let pool = market.pool_state(); + assert_eq!(pool.insurance_fund, 2_500_000); + assert_eq!(pool.program_fees, 2_500_001); + assert_eq!(pool.total_collateral, collateral - fee); + assert_eq!( + market + .position_state(&trader.pubkey(), Side::Long) + .collateral, + collateral - fee + ); + + // Closing at the entry price settles no profit or loss, so the payout is + // the net collateral less the close fee. + market + .close_position(&trader, trader_collateral, Side::Long, 0) + .unwrap(); + assert_eq!( + get_token_account_balance(&market.svm, &trader_collateral).unwrap(), + 2 * collateral - fee - fee + ); + let pool = market.pool_state(); + assert_eq!(pool.insurance_fund, 2 * 2_500_000); + assert_eq!(pool.program_fees, 2 * 2_500_001); + + // The same position again, taken to an equity of exactly the rounded-up + // maintenance requirement: $85.10000004 loses it 744,999,998 base units. + // The open is byte-identical to the first, so it would carry the same + // signature and be dropped as already processed without a new blockhash. + market.svm.expire_blockhash(); + market + .open_position(&trader, trader_collateral, Side::Long, collateral, size, 0) + .unwrap(); + market.set_price(8_510_000_004); + let (liquidator, liquidator_collateral) = market.liquidator(); + market + .liquidate(&liquidator, &trader.pubkey(), trader_collateral, Side::Long) + .unwrap(); + assert_eq!( + get_token_account_balance(&market.svm, &liquidator_collateral).unwrap(), + 50_000_001 + ); + // The trader is refunded the equity less the liquidation fee. + assert_eq!( + get_token_account_balance(&market.svm, &trader_collateral).unwrap(), + collateral - fee - fee + (250_000_001 - 50_000_001) + ); + market.assert_vault_matches_ledger(); +} + +/// `basis_points_of` rounds up, so an amount that is not an exact multiple +/// rounds to the next base unit and the smallest non-zero amount pays a whole +/// unit; an exact multiple is unchanged. `basis_points_of_rounded_down` +/// splits a fee the pool already holds, so it rounds the other way. +#[test] +fn test_basis_points_of_rounds_up_and_the_insurance_split_rounds_down() { + assert_eq!(basis_points_of(5_000 * ONE_USDC, 10).unwrap(), 5 * ONE_USDC); + assert_eq!( + basis_points_of(5_000 * ONE_USDC + 1, 10).unwrap(), + 5_000_001 + ); + assert_eq!(basis_points_of(1, 10).unwrap(), 1); + assert_eq!(basis_points_of(0, 10).unwrap(), 0); + // Widened to `u128`, so the largest amount does not overflow. + assert_eq!(basis_points_of(u64::MAX, 10_000).unwrap(), u64::MAX); + assert_eq!( + basis_points_of_rounded_down(5_000_001, 5_000).unwrap(), + 2_500_000 + ); + assert_eq!(basis_points_of_rounded_down(1, 5_000).unwrap(), 0); +} + #[test] fn test_initialize_pool_rejects_insurance_fee_at_or_above_full_fee() { let with_insurance_fee = |insurance_fee_bps| PoolParameters { @@ -2026,14 +2148,20 @@ fn test_initialize_pool_rejects_close_fee_at_or_above_maintenance_margin() { // A pool whose close fee reached the maintenance margin could strand a // position that is too healthy to liquidate but too poor to pay the fee to // close, so initialize_pool refuses the configuration. - let parameters = PoolParameters { - close_fee_bps: 600, + let with_close_fee = |close_fee_bps| PoolParameters { + close_fee_bps, ..default_parameters(0) }; assert_fails_with( - Market::try_new(dollars(100), parameters), + Market::try_new(dollars(100), with_close_fee(600)), + PerpError::InvalidParameter, + ); + assert_fails_with( + Market::try_new(dollars(100), with_close_fee(500)), PerpError::InvalidParameter, ); + // One basis point below the maintenance margin is accepted. + assert!(Market::try_new(dollars(100), with_close_fee(499)).is_ok()); } #[test] diff --git a/finance/perpetual-futures/anchor/CHANGELOG.md b/finance/perpetual-futures/anchor/CHANGELOG.md index 05c64fc7..80e871e4 100644 --- a/finance/perpetual-futures/anchor/CHANGELOG.md +++ b/finance/perpetual-futures/anchor/CHANGELOG.md @@ -1,5 +1,36 @@ # Changelog +## Unreleased, 2026-10-05 + +Every fee rounds up. `basis_points_of` in `instructions/shared.rs` rounds its +result up to the next base unit, so the open, close and liquidation fees and +the maintenance requirement a position is liquidated at each round in the +pool's favour: a fee is never a minor unit short, and a position is never a +minor unit too healthy to liquidate. The insurance fund's cut of a fee the +pool already holds is split by the new `basis_points_of_rounded_down`, and the +program takes the remainder, so the two still add up to the whole fee. The +walkthrough's figures are exact multiples and are unchanged. Tested by +`test_fees_and_maintenance_requirement_round_up`, which opens, closes and +liquidates a position one base unit over $5,000 and checks the 5,000,001-unit +fees, the 2,500,000 / 2,500,001 insurance and program split, the liquidation +at an equity of exactly 250,000,001 and the 50,000,001 liquidation fee, and by +`test_basis_points_of_rounds_up_and_the_insurance_split_rounds_down` at the +boundaries. + +Every refusal test asserts its error code. `test_open_rejects_zero_amounts` +asserts `ZeroAmount`, `test_open_long_slippage_guard` `SlippageExceeded`, +`test_stale_price_rejected` `StalePrice`, +`test_open_rejects_price_from_before_a_restart` `PricePredatesRestart`, +`test_wide_oracle_confidence_rejected` `OracleConfidenceTooWide`, +`test_healthy_position_cannot_be_liquidated` `PositionHealthy`, the second +sweep in `test_collect_fees` `NothingToClaim`, and +`test_collect_fees_requires_authority` Anchor's `ConstraintAddress` (2012), the code of the +constraint that binds `authority` to the pool, through the new +`assert_fails_with_anchor_error`. +`test_initialize_pool_rejects_close_fee_at_or_above_maintenance_margin` runs +a close fee of 600 and of 500 basis points against a 500 maintenance margin, +both refused with `InvalidParameter`, and 499, accepted. + ## 2026-10-04 Check which program owns the price feed. `initialize_pool` records the feed diff --git a/finance/perpetual-futures/anchor/README.md b/finance/perpetual-futures/anchor/README.md index f0e32324..2165c0a9 100644 --- a/finance/perpetual-futures/anchor/README.md +++ b/finance/perpetual-futures/anchor/README.md @@ -21,7 +21,7 @@ A [perpetual future](https://www.investopedia.com/terms/f/futurescontract.asp) ( - `perpetual-futures`: The exchange: pool creation, liquidity provision, opening/closing leveraged positions, funding, liquidation, and fee collection. - `mock-price-feed`: Test-only price feed. Stores a price, scale, last-update slot, and confidence band that tests write directly. Replaced in production by a Pyth `PriceUpdateV2` account, as read in [`basics/pyth`](../../../basics/pyth/). -All arithmetic is integer `u128` with `checked_*` operations, multiplying before dividing and rounding in the pool's favour: no floats, no fixed-point library. +All arithmetic is integer `u128` with `checked_*` operations, multiplying before dividing and rounding in the pool's favour: every fee and the maintenance requirement round up, and what is paid out rounds down. No floats, no fixed-point library. --- @@ -81,7 +81,7 @@ Funding runs on the wall clock rather than the slot count, so what a position co ### Maintenance margin and liquidation -A position's *equity* is its net collateral plus profit/loss minus funding. Once equity falls to or below the [maintenance margin](https://www.investopedia.com/terms/m/maintenancemargin.asp) (`maintenance_margin_bps` of notional), the position can be [liquidated](https://www.investopedia.com/terms/l/liquidation.asp). Liquidation is permissionless: anyone can crank it and earn the liquidation fee, `liquidation_fee_bps` of the position's size, paid out of its remaining equity. Whatever part of the fee the equity cannot cover is forgiven, as in Percolator: neither the insurance fund nor the liquidity providers pay it, so a liquidator of a position whose equity is already below zero receives nothing, and the position still closes. The insurance fund pays its deficit first (see [the insurance fund](#the-insurance-fund)); `test_liquidation_of_bankrupt_position_charges_insurance_before_liquidity` liquidates such a position and checks the exact split. +A position's *equity* is its net collateral plus profit/loss minus funding. Once equity falls to or below the [maintenance margin](https://www.investopedia.com/terms/m/maintenancemargin.asp) (`maintenance_margin_bps` of notional, rounded up), the position can be [liquidated](https://www.investopedia.com/terms/l/liquidation.asp). Liquidation is permissionless: anyone can crank it and earn the liquidation fee, `liquidation_fee_bps` of the position's size, rounded up and paid out of its remaining equity. Whatever part of the fee the equity cannot cover is forgiven, as in Percolator: neither the insurance fund nor the liquidity providers pay it, so a liquidator of a position whose equity is already below zero receives nothing, and the position still closes. The insurance fund pays its deficit first (see [the insurance fund](#the-insurance-fund)); `test_liquidation_of_bankrupt_position_charges_insurance_before_liquidity` liquidates such a position and checks the exact split. `initialize_pool` requires `maintenance_margin_bps < initial_margin_bps <= 10_000` and refuses anything else with `InitialMarginNotAboveMaintenance` (or `InvalidParameter` above 10,000). Every position therefore opens with more margin than it is liquidated at, so none can be liquidated in the slot it opened. @@ -104,7 +104,7 @@ A single oracle print can be wrong while still being fresh, positive and confide ### Fees and slippage -Open and close fees are charged in [basis points](https://www.investopedia.com/terms/b/basispoint.asp) (1 bp = 0.01%) of notional; `insurance_fee_bps` of each goes to the insurance fund and the rest accrues to the program. Every state-changing handler takes a `minimum_*` / acceptable-price bound (protection against [slippage](https://www.investopedia.com/terms/s/slippage.asp), the gap between the expected and actual fill) and reverts if the bound is breached. Pass `0` to opt out. +Open and close fees are charged in [basis points](https://www.investopedia.com/terms/b/basispoint.asp) (1 bp = 0.01%) of notional. `basis_points_of` rounds each fee up to the next base unit, as it does the liquidation fee and the maintenance requirement, so a fee is never a minor unit short in the trader's favour; `insurance_fee_bps` of each fee, rounded down by `basis_points_of_rounded_down`, goes to the insurance fund and the program takes the rest. `test_fees_and_maintenance_requirement_round_up` opens, closes and liquidates a position one base unit over $5,000 and checks every rounded figure. Every state-changing handler takes a `minimum_*` / acceptable-price bound (protection against [slippage](https://www.investopedia.com/terms/s/slippage.asp), the gap between the expected and actual fill) and reverts if the bound is breached. Pass `0` to opt out. --- @@ -267,13 +267,14 @@ The tests run in-process with [LiteSVM](https://www.anchor-lang.com/docs/testing - 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 +- liquidation, and the refusal to liquidate a healthy position (`test_healthy_position_cannot_be_liquidated`, which asserts `PositionHealthy`) - 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`) - the profit warm-up on both sides of its boundary (`test_profit_blocked_before_maturation`, `test_profit_realized_after_maturation`), and a loss closing in the slot it opened (`test_loss_not_gated_by_maturation`) - the insurance fund: its exact share of each fee (`test_insurance_fund_funded_by_fees`), a bankrupt position's deficit paid by the fund (`test_insurance_absorbs_bankruptcy_deficit`), and a bankrupt position liquidated for no fee with the fund paying before the providers (`test_liquidation_of_bankrupt_position_charges_insurance_before_liquidity`) - withdrawals capped at `liquidity` while traders are down (`test_remove_liquidity_capped_at_liquidity`) -- `initialize_pool`'s parameter checks, including an initial margin at or below the maintenance margin, a price band outside its range, and an insurance fee of 10,000 basis points or more -- fee collection +- `initialize_pool`'s parameter checks, including an initial margin at or below the maintenance margin, a close fee at or above it (`test_initialize_pool_rejects_close_fee_at_or_above_maintenance_margin`), a price band outside its range, and an insurance fee of 10,000 basis points or more +- every fee and the maintenance requirement rounding up (`test_fees_and_maintenance_requirement_round_up`), and `basis_points_of` at its boundaries +- fee collection, and its refusal to anyone but the pool's authority (`test_collect_fees_requires_authority`, which asserts the constraint's own error code) ```bash anchor build diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/liquidate_position.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/liquidate_position.rs index c7a07670..6f53ab88 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/liquidate_position.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/liquidate_position.rs @@ -21,7 +21,8 @@ pub fn handle_liquidate_position( let settlement = settle_position(pool, position, price)?; // Liquidatable only once equity has fallen to or below the maintenance - // margin. A healthy position can only be closed by its owner. + // margin, rounded up against the trader. A healthy position can only be + // closed by its owner. let maintenance = basis_points_of(position_size, pool.maintenance_margin_bps)?; require!( settlement.equity <= maintenance as i128, 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 76e90942..e37699dd 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 @@ -241,7 +241,7 @@ pub fn apply_haircut(profit: i128, haircut: u128) -> Result { /// insurance cut is `insurance_fee_bps` of the fee, rounded down, and the /// program keeps the rest, so the two always add up to the whole fee. pub fn split_fee(fee: u64, insurance_fee_bps: u16) -> Result<(u64, u64)> { - let insurance_cut = basis_points_of(fee, insurance_fee_bps)?; + let insurance_cut = basis_points_of_rounded_down(fee, insurance_fee_bps)?; let program_cut = fee .checked_sub(insurance_cut) .ok_or(PerpError::MathOverflow)?; @@ -286,10 +286,28 @@ pub fn position_funding( }) } -/// `basis_points` of `amount`, rounded down — used for fees and for the -/// maintenance-margin threshold alike. Widened to `u128` so a large amount -/// cannot overflow the intermediate product. +/// `basis_points` of `amount`, rounded up: the open, close and liquidation +/// fees and the maintenance requirement a position is liquidated at, each of +/// which rounds in the pool's favour, so a fee is never a minor unit short and +/// a position is never a minor unit too healthy to liquidate. Widened to +/// `u128` so a large amount cannot overflow the intermediate product. pub fn basis_points_of(amount: u64, basis_points: u16) -> Result { + let denominator = BASIS_POINTS_DENOMINATOR as u128; + (amount as u128) + .checked_mul(basis_points as u128) + .ok_or(PerpError::MathOverflow)? + .checked_add(denominator - 1) + .ok_or(PerpError::MathOverflow)? + .checked_div(denominator) + .ok_or(PerpError::MathOverflow)? + .try_into() + .map_err(|_| PerpError::MathOverflow.into()) +} + +/// `basis_points` of `amount`, rounded down: the insurance fund's cut of a fee +/// the pool has already collected, where the rounding moves nothing between +/// the pool and a trader. `split_fee` gives the program the remainder. +pub fn basis_points_of_rounded_down(amount: u64, basis_points: u16) -> Result { (amount as u128) .checked_mul(basis_points as u128) .ok_or(PerpError::MathOverflow)? 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 59c50d94..e41ffd71 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 @@ -1,12 +1,15 @@ 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}, perpetual_futures::{ errors::PerpError, - instructions::initialize_pool::PoolParameters, + instructions::{ + initialize_pool::PoolParameters, + shared::{basis_points_of, basis_points_of_rounded_down}, + }, state::{Pool, Position, Side}, }, solana_kite::{ @@ -89,7 +92,19 @@ fn default_parameters(funding_rate_per_second: u64) -> PoolParameters { /// 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: PerpError) { - let code = expected as u32 + 6000; + assert_fails_with_code(result, expected as u32 + 6000); +} + +/// Assert that `result` failed on one of Anchor's own account constraints, +/// which report their code without the program-error offset. +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); +} + +fn assert_fails_with_code(result: Result, code: u32) { let Err(error) = result else { panic!("the transaction should have failed with error code {code}"); }; @@ -968,26 +983,28 @@ fn test_open_rejects_zero_amounts() { market.seed_liquidity(100_000 * ONE_USDC); let (trader, trader_collateral) = market.funded_trader(1_000 * ONE_USDC); - assert!(market - .open_position( + assert_fails_with( + market.open_position( &trader, trader_collateral, Side::Long, 0, 5_000 * ONE_USDC, - 0 - ) - .is_err()); - assert!(market - .open_position( + 0, + ), + PerpError::ZeroAmount, + ); + assert_fails_with( + market.open_position( &trader, trader_collateral, Side::Long, 1_000 * ONE_USDC, 0, - 0 - ) - .is_err()); + 0, + ), + PerpError::ZeroAmount, + ); } #[test] @@ -1050,16 +1067,17 @@ fn test_open_long_slippage_guard() { // Current price is $100 (10^10 in scale 8). A long willing to pay at most // $99 must be rejected. let acceptable_price = (dollars(99)) as u64; - assert!(market - .open_position( + assert_fails_with( + market.open_position( &trader, trader_collateral, Side::Long, collateral, 5_000 * ONE_USDC, - acceptable_price - ) - .is_err()); + acceptable_price, + ), + PerpError::SlippageExceeded, + ); } #[test] @@ -1071,16 +1089,17 @@ fn test_stale_price_rejected() { // Move far past the staleness window without refreshing the feed. market.warp(10_000); - assert!(market - .open_position( + assert_fails_with( + market.open_position( &trader, trader_collateral, Side::Long, collateral, 5_000 * ONE_USDC, - 0 - ) - .is_err()); + 0, + ), + PerpError::StalePrice, + ); } /// A cluster restart passes hours of wall-clock time in zero slots, so a @@ -1102,16 +1121,17 @@ fn test_open_rejects_price_from_before_a_restart() { market.warp(published_at + 5); market.set_last_restart_slot(published_at + 3); - assert!(market - .open_position( + assert_fails_with( + market.open_position( &trader, trader_collateral, Side::Long, collateral, 5_000 * ONE_USDC, - u64::MAX - ) - .is_err()); + u64::MAX, + ), + PerpError::PricePredatesRestart, + ); // Publishing after the restart reopens the pool. Warp first: the retry is // otherwise byte-identical to the rejected open, so it would carry the same @@ -1181,16 +1201,17 @@ fn test_wide_oracle_confidence_rejected() { // The pool tolerates a 1% confidence band (max_confidence_bps = 100). Widen // the feed's band to 2% of the price and the open must be rejected. market.set_price_with_confidence(dollars(100), dollars(2) as u64); - assert!(market - .open_position( + assert_fails_with( + market.open_position( &trader, trader_collateral, Side::Long, collateral, 5_000 * ONE_USDC, - 0 - ) - .is_err()); + 0, + ), + PerpError::OracleConfidenceTooWide, + ); } #[test] @@ -1420,9 +1441,10 @@ fn test_healthy_position_cannot_be_liquidated() { // Price barely moves; the position stays healthy. market.set_price(dollars(99)); - assert!(market - .liquidate(&liquidator, &trader.pubkey(), trader_collateral, Side::Long) - .is_err()); + assert_fails_with( + market.liquidate(&liquidator, &trader.pubkey(), trader_collateral, Side::Long), + PerpError::PositionHealthy, + ); } #[test] @@ -1456,8 +1478,10 @@ fn test_collect_fees() { ); assert_eq!(market.pool_state().program_fees, 0); - // Nothing left to claim on a second sweep. - assert!(market.collect_fees(&admin).is_err()); + // Nothing left to claim on a second sweep (a fresh blockhash, or the + // identical transaction is rejected as already processed). + market.svm.expire_blockhash(); + assert_fails_with(market.collect_fees(&admin), PerpError::NothingToClaim); } #[test] @@ -1485,7 +1509,12 @@ fn test_collect_fees_requires_authority() { &market.payer, ) .unwrap(); - assert!(market.collect_fees(&imposter).is_err()); + // The `authority` account is bound to `pool.authority` by an `address` + // constraint, so the refusal is Anchor's, not the program's. + assert_fails_with_anchor_error( + market.collect_fees(&imposter), + AnchorErrorCode::ConstraintAddress, + ); } /// Nothing is set aside to back a position's profit, so a position can open @@ -1885,13 +1914,13 @@ fn test_insurance_fund_funded_by_fees() { .unwrap(); market.seed_liquidity(100_000 * ONE_USDC); - // A size whose 0.1% fee is 1,234,567 minor units: 3,333 basis points of - // that is 411,481.18, so the insurance fund gets 411,481 and the program - // the other 823,086. - let size = 1_234_567_890; - let fee = 1_234_567; + // A size whose 0.1% fee is 1,234,567.89 minor units, rounded up to + // 1,234,568: 3,333 basis points of that is 411,481.5, so the insurance + // fund gets 411,481 (its cut rounds down) and the program the other 823,087. + let size: u64 = 1_234_567_890; + let fee = 1_234_568; let insurance_cut = 411_481; - assert_eq!(size / 1_000, fee); + assert_eq!(size.div_ceil(1_000), fee); let collateral = 200 * ONE_USDC; let (trader, trader_collateral) = market.funded_trader(collateral); market @@ -2006,6 +2035,98 @@ fn test_liquidation_of_bankrupt_position_charges_insurance_before_liquidity() { market.assert_vault_matches_ledger(); } +/// Every fee rounds up, and so does the maintenance requirement, so none is +/// a minor unit short in the trader's favour. A position one base unit over +/// $5,000 pays $5.000001 to open and the same to close: 0.1% of it is +/// 5,000,000.001 base units, rounded up to 5,000,001. The insurance fund's +/// half of a fee rounds down, and the program takes the odd unit. The same +/// position is liquidatable at an equity of 250,000,001 base units, the +/// maintenance requirement 250,000,000.05 rounded up, where a requirement +/// rounded down would have left it one base unit too healthy, and the +/// liquidation fee is 50,000,001. +#[test] +fn test_fees_and_maintenance_requirement_round_up() { + let mut market = Market::default_market(); + market.seed_liquidity(100_000 * ONE_USDC); + let collateral = 1_000 * ONE_USDC; + let size = 5_000 * ONE_USDC + 1; + let fee = 5_000_001; + let (trader, trader_collateral) = market.funded_trader(2 * collateral); + + market + .open_position(&trader, trader_collateral, Side::Long, collateral, size, 0) + .unwrap(); + let pool = market.pool_state(); + assert_eq!(pool.insurance_fund, 2_500_000); + assert_eq!(pool.program_fees, 2_500_001); + assert_eq!(pool.total_collateral, collateral - fee); + assert_eq!( + market + .position_state(&trader.pubkey(), Side::Long) + .collateral, + collateral - fee + ); + + // Closing at the entry price settles no profit or loss, so the payout is + // the net collateral less the close fee. + market + .close_position(&trader, trader_collateral, Side::Long, 0) + .unwrap(); + assert_eq!( + get_token_account_balance(&market.svm, &trader_collateral).unwrap(), + 2 * collateral - fee - fee + ); + let pool = market.pool_state(); + assert_eq!(pool.insurance_fund, 2 * 2_500_000); + assert_eq!(pool.program_fees, 2 * 2_500_001); + + // The same position again, taken to an equity of exactly the rounded-up + // maintenance requirement: $85.10000004 loses it 744,999,998 base units. + // The open is byte-identical to the first, so it would carry the same + // signature and be dropped as already processed without a new blockhash. + market.svm.expire_blockhash(); + market + .open_position(&trader, trader_collateral, Side::Long, collateral, size, 0) + .unwrap(); + market.set_price(8_510_000_004); + let (liquidator, liquidator_collateral) = market.liquidator(); + market + .liquidate(&liquidator, &trader.pubkey(), trader_collateral, Side::Long) + .unwrap(); + assert_eq!( + get_token_account_balance(&market.svm, &liquidator_collateral).unwrap(), + 50_000_001 + ); + // The trader is refunded the equity less the liquidation fee. + assert_eq!( + get_token_account_balance(&market.svm, &trader_collateral).unwrap(), + collateral - fee - fee + (250_000_001 - 50_000_001) + ); + market.assert_vault_matches_ledger(); +} + +/// `basis_points_of` rounds up, so an amount that is not an exact multiple +/// rounds to the next base unit and the smallest non-zero amount pays a whole +/// unit; an exact multiple is unchanged. `basis_points_of_rounded_down` +/// splits a fee the pool already holds, so it rounds the other way. +#[test] +fn test_basis_points_of_rounds_up_and_the_insurance_split_rounds_down() { + assert_eq!(basis_points_of(5_000 * ONE_USDC, 10).unwrap(), 5 * ONE_USDC); + assert_eq!( + basis_points_of(5_000 * ONE_USDC + 1, 10).unwrap(), + 5_000_001 + ); + assert_eq!(basis_points_of(1, 10).unwrap(), 1); + assert_eq!(basis_points_of(0, 10).unwrap(), 0); + // Widened to `u128`, so the largest amount does not overflow. + assert_eq!(basis_points_of(u64::MAX, 10_000).unwrap(), u64::MAX); + assert_eq!( + basis_points_of_rounded_down(5_000_001, 5_000).unwrap(), + 2_500_000 + ); + assert_eq!(basis_points_of_rounded_down(1, 5_000).unwrap(), 0); +} + #[test] fn test_initialize_pool_rejects_insurance_fee_at_or_above_full_fee() { let with_insurance_fee = |insurance_fee_bps| PoolParameters { @@ -2024,14 +2145,20 @@ fn test_initialize_pool_rejects_close_fee_at_or_above_maintenance_margin() { // A pool whose close fee reached the maintenance margin could strand a // position that is too healthy to liquidate but too poor to pay the fee to // close, so initialize_pool refuses the configuration. - let parameters = PoolParameters { - close_fee_bps: 600, + let with_close_fee = |close_fee_bps| PoolParameters { + close_fee_bps, ..default_parameters(0) }; assert_fails_with( - Market::try_new(dollars(100), parameters), + Market::try_new(dollars(100), with_close_fee(600)), + PerpError::InvalidParameter, + ); + assert_fails_with( + Market::try_new(dollars(100), with_close_fee(500)), PerpError::InvalidParameter, ); + // One basis point below the maintenance margin is accepted. + assert!(Market::try_new(dollars(100), with_close_fee(499)).is_ok()); } #[test] diff --git a/finance/perpetual-futures/quasar/CHANGELOG.md b/finance/perpetual-futures/quasar/CHANGELOG.md index c4564f93..96c5b067 100644 --- a/finance/perpetual-futures/quasar/CHANGELOG.md +++ b/finance/perpetual-futures/quasar/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## Unreleased, 2026-10-05 + +Every fee rounds up. `basis_points_of` in `instructions/shared.rs` rounds its +result up to the next base unit, so the open, close and liquidation fees and +the maintenance requirement a position is liquidated at each round in the +pool's favour: a fee is never a minor unit short, and a position is never a +minor unit too healthy to liquidate. The insurance fund's cut of a fee the +pool already holds is split by the new `basis_points_of_rounded_down`, and the +program takes the remainder, so the two still add up to the whole fee. Tested +by `fees_and_maintenance_requirement_round_up`, which opens, closes and +liquidates a position one base unit over $5,000 and checks the 5,000,001-unit +fees, the 2,500,000 / 2,500,001 insurance and program split, the liquidation +at an equity of exactly 250,000,001 and the 50,000,001 liquidation fee, and by +`basis_points_of_rounds_up_and_the_insurance_split_rounds_down` at the +boundaries. + +Every refusal test asserts its error code: +`open_rejects_price_from_before_a_restart` asserts `PRICE_PREDATES_RESTART` +(18) and `wide_oracle_confidence_is_rejected` `ORACLE_CONFIDENCE_TOO_WIDE` +(16). `initialize_pool_rejects_close_fee_at_or_above_maintenance_margin` runs +a close fee of 600 and of 500 basis points against a 500 maintenance margin, +both refused with `INVALID_PARAMETER`, and 499, accepted. + ## 2026-10-04 Check which program owns the price feed. `initialize_pool` records the feed diff --git a/finance/perpetual-futures/quasar/README.md b/finance/perpetual-futures/quasar/README.md index 4b929949..afeb9d05 100644 --- a/finance/perpetual-futures/quasar/README.md +++ b/finance/perpetual-futures/quasar/README.md @@ -39,8 +39,9 @@ They build the program, set up a collateral mint, oracle feed, and funded 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) + maintenance margin, at most 10,000 basis points), the close fee (below the + maintenance margin) and the price band (above zero, below 10,000 basis + points) - 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 @@ -61,6 +62,9 @@ wallets, then exercise: after an idle window leaving the average where it was (`one_manipulated_read_after_idle_does_not_move_average`) - liquidation, and fee collection +- every fee and the maintenance requirement rounding up + (`fees_and_maintenance_requirement_round_up`), and `basis_points_of` at + its boundaries - the haircut: a position opening without full backing, profit paid in full while the pool backs it, two winners each paid exactly half when the pool is stressed (`haircut_scales_profit_when_pool_stressed`), the insurance fund diff --git a/finance/perpetual-futures/quasar/src/instructions/liquidate_position.rs b/finance/perpetual-futures/quasar/src/instructions/liquidate_position.rs index eca444c3..45bde085 100644 --- a/finance/perpetual-futures/quasar/src/instructions/liquidate_position.rs +++ b/finance/perpetual-futures/quasar/src/instructions/liquidate_position.rs @@ -87,6 +87,9 @@ pub fn handle_liquidate_position( .checked_sub(funding) .ok_or(ProgramError::ArithmeticOverflow)?; + // Liquidatable only once equity has fallen to or below the maintenance + // margin, rounded up against the trader. A healthy position can only be + // closed by its owner. let maintenance = basis_points_of(size, accounts.pool.maintenance_margin_bps.get())?; if equity > maintenance as i128 { return Err(err(error::POSITION_HEALTHY)); diff --git a/finance/perpetual-futures/quasar/src/instructions/shared.rs b/finance/perpetual-futures/quasar/src/instructions/shared.rs index a1127958..49d6265b 100644 --- a/finance/perpetual-futures/quasar/src/instructions/shared.rs +++ b/finance/perpetual-futures/quasar/src/instructions/shared.rs @@ -310,7 +310,7 @@ pub fn apply_haircut(profit: i128, haircut: u128) -> Result /// insurance cut is `insurance_fee_bps` of the fee, rounded down, and the /// program keeps the rest, so the two always add up to the whole fee. pub fn split_fee(fee: u64, insurance_fee_bps: u16) -> Result<(u64, u64), ProgramError> { - let insurance_cut = basis_points_of(fee, insurance_fee_bps)?; + let insurance_cut = basis_points_of_rounded_down(fee, insurance_fee_bps)?; let program_cut = fee.checked_sub(insurance_cut).ok_or_else(overflow)?; Ok((insurance_cut, program_cut)) } @@ -355,9 +355,27 @@ pub fn position_funding( }) } -/// `basis_points` of `amount`, rounded down — used for fees and for the -/// maintenance-margin threshold alike. +/// `basis_points` of `amount`, rounded up: the open, close and liquidation +/// fees and the maintenance requirement a position is liquidated at, each of +/// which rounds in the pool's favour, so a fee is never a minor unit short and +/// a position is never a minor unit too healthy to liquidate. Widened to +/// `u128` so a large amount cannot overflow the intermediate product. pub fn basis_points_of(amount: u64, basis_points: u16) -> Result { + let denominator = BASIS_POINTS_DENOMINATOR as u128; + let fraction = (amount as u128) + .checked_mul(basis_points as u128) + .ok_or_else(overflow)? + .checked_add(denominator - 1) + .ok_or_else(overflow)? + .checked_div(denominator) + .ok_or_else(overflow)?; + u64::try_from(fraction).map_err(|_| overflow()) +} + +/// `basis_points` of `amount`, rounded down: the insurance fund's cut of a fee +/// the pool has already collected, where the rounding moves nothing between +/// the pool and a trader. `split_fee` gives the program the remainder. +pub fn basis_points_of_rounded_down(amount: u64, basis_points: u16) -> Result { let fraction = (amount as u128) .checked_mul(basis_points as u128) .ok_or_else(overflow)? diff --git a/finance/perpetual-futures/quasar/src/tests.rs b/finance/perpetual-futures/quasar/src/tests.rs index 38da4930..5656abcb 100644 --- a/finance/perpetual-futures/quasar/src/tests.rs +++ b/finance/perpetual-futures/quasar/src/tests.rs @@ -11,7 +11,7 @@ use { InitializePoolInstruction, LiquidatePositionInstruction, OpenPositionInstruction, RemoveLiquidityInstruction, UpdatePriceAverageInstruction, }, - instructions::shared::error, + instructions::shared::{basis_points_of, basis_points_of_rounded_down, error}, state::{Pool, Position}, LpMintPda, VaultPda, }, @@ -556,10 +556,8 @@ fn open_rejects_price_from_before_a_restart(test: &mut Test) { set_clock_at(test, 10, 0); set_feed_at_slot(test, dollars(100), 5, 0); set_last_restart_slot(test, 7); - assert!( - open_position(test, &env, 0, 1_000 * ONE_USDC, 5_000 * ONE_USDC).is_err(), - "a pre-restart price must be rejected even inside the staleness bound" - ); + open_position(test, &env, 0, 1_000 * ONE_USDC, 5_000 * ONE_USDC) + .fails_with(error::PRICE_PREDATES_RESTART); // Publishing after the restart (slot 10) reopens the pool. set_feed_at_slot(test, dollars(100), 10, 0); @@ -819,10 +817,8 @@ fn wide_oracle_confidence_is_rejected(test: &mut Test) { // The pool tolerates a 1% confidence band (max_confidence_bps = 100). Widen // the feed's band to 2% of the price and the open must be rejected. set_feed(test, dollars(100), dollars(2) as u64); - assert!( - open_position(test, &env, 0, 1_000 * ONE_USDC, 5_000 * ONE_USDC).is_err(), - "a confidence band wider than max_confidence_bps must be rejected" - ); + open_position(test, &env, 0, 1_000 * ONE_USDC, 5_000 * ONE_USDC) + .fails_with(error::ORACLE_CONFIDENCE_TOO_WIDE); } /// Nothing is set aside to back a position's profit, so a position can open @@ -1178,13 +1174,13 @@ fn insurance_fund_funded_by_fees(test: &mut Test) { fund(test, PROVIDER, PROVIDER_COLLATERAL, 100_000 * ONE_USDC); add_liquidity(test, &env, 100_000 * ONE_USDC).succeeds(); - // A size whose 0.1% fee is 1,234,567 minor units: 3,333 basis points of - // that is 411,481.18, so the insurance fund gets 411,481 and the program - // the other 823,086. - let size = 1_234_567_890; - let fee = 1_234_567; + // A size whose 0.1% fee is 1,234,567.89 minor units, rounded up to + // 1,234,568: 3,333 basis points of that is 411,481.5, so the insurance + // fund gets 411,481 (its cut rounds down) and the program the other 823,087. + let size: u64 = 1_234_567_890; + let fee = 1_234_568; let insurance_cut = 411_481; - assert_eq!(size / 1_000, fee); + assert_eq!(size.div_ceil(1_000), fee); fund(test, TRADER, TRADER_COLLATERAL, 200 * ONE_USDC); open_position(test, &env, SIDE_LONG, 200 * ONE_USDC, size).succeeds(); let pool = test.read::(env.pool); @@ -1279,6 +1275,79 @@ fn liquidation_of_bankrupt_position_charges_insurance_before_liquidity(test: &mu assert_vault_matches_ledger(test, &env); } +/// Every fee rounds up, and so does the maintenance requirement, so none is +/// a minor unit short in the trader's favour. A position one base unit over +/// $5,000 pays $5.000001 to open and the same to close: 0.1% of it is +/// 5,000,000.001 base units, rounded up to 5,000,001. The insurance fund's +/// half of a fee rounds down, and the program takes the odd unit. The same +/// position is liquidatable at an equity of 250,000,001 base units, the +/// maintenance requirement 250,000,000.05 rounded up, where a requirement +/// rounded down would have left it one base unit too healthy, and the +/// liquidation fee is 50,000,001. +#[quasar_test] +fn fees_and_maintenance_requirement_round_up(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(); + let collateral = 1_000 * ONE_USDC; + let size = 5_000 * ONE_USDC + 1; + let fee = 5_000_001; + fund(test, TRADER, TRADER_COLLATERAL, 2 * collateral); + + open_position(test, &env, SIDE_LONG, collateral, size).succeeds(); + let pool = test.read::(env.pool); + assert_eq!(u64::from(pool.insurance_fund), 2_500_000); + assert_eq!(u64::from(pool.program_fees), 2_500_001); + assert_eq!(u64::from(pool.total_collateral), collateral - fee); + let position = test.read::(test.derive_pda(Position::seeds(&env.pool, &TRADER))); + assert_eq!(u64::from(position.collateral), collateral - fee); + + // Closing at the entry price settles no profit or loss, so the payout is + // the net collateral less the close fee. + close_position(test, &env) + .succeeds() + .has_tokens(TRADER_COLLATERAL, 2 * collateral - fee - fee); + let pool = test.read::(env.pool); + assert_eq!(u64::from(pool.insurance_fund), 2 * 2_500_000); + assert_eq!(u64::from(pool.program_fees), 2 * 2_500_001); + + // The same position again, taken to an equity of exactly the rounded-up + // maintenance requirement: $85.10000004 loses it 744,999,998 base units. + open_position(test, &env, SIDE_LONG, collateral, size).succeeds(); + set_feed(test, 8_510_000_004, 0); + liquidate(test, &env) + .succeeds() + .has_tokens(LIQUIDATOR_COLLATERAL, 50_000_001) + // The trader is refunded the equity less the liquidation fee. + .has_tokens( + TRADER_COLLATERAL, + collateral - fee - fee + (250_000_001 - 50_000_001), + ); + assert_vault_matches_ledger(test, &env); +} + +/// `basis_points_of` rounds up, so an amount that is not an exact multiple +/// rounds to the next base unit and the smallest non-zero amount pays a whole +/// unit; an exact multiple is unchanged. `basis_points_of_rounded_down` +/// splits a fee the pool already holds, so it rounds the other way. +#[test] +fn basis_points_of_rounds_up_and_the_insurance_split_rounds_down() { + assert_eq!(basis_points_of(5_000 * ONE_USDC, 10).unwrap(), 5 * ONE_USDC); + assert_eq!( + basis_points_of(5_000 * ONE_USDC + 1, 10).unwrap(), + 5_000_001 + ); + assert_eq!(basis_points_of(1, 10).unwrap(), 1); + assert_eq!(basis_points_of(0, 10).unwrap(), 0); + // Widened to `u128`, so the largest amount does not overflow. + assert_eq!(basis_points_of(u64::MAX, 10_000).unwrap(), u64::MAX); + assert_eq!( + basis_points_of_rounded_down(5_000_001, 5_000).unwrap(), + 2_500_000 + ); + assert_eq!(basis_points_of_rounded_down(1, 5_000).unwrap(), 0); +} + #[quasar_test] fn initialize_pool_rejects_insurance_fee_at_or_above_full_fee(test: &mut Test) { add_pool_prerequisites(test); @@ -1301,6 +1370,9 @@ fn initialize_pool_rejects_close_fee_at_or_above_maintenance_margin(test: &mut T // close, so initialize_pool refuses the configuration. add_pool_prerequisites(test); init_pool(test, 500, 600).fails_with(error::INVALID_PARAMETER); + init_pool(test, 500, 500).fails_with(error::INVALID_PARAMETER); + // One basis point below the maintenance margin is accepted. + init_pool(test, 500, 499).succeeds(); } #[quasar_test] diff --git a/finance/prop-amm/anchor-v1/CHANGELOG.md b/finance/prop-amm/anchor-v1/CHANGELOG.md index 254fdfcd..9ec04547 100644 --- a/finance/prop-amm/anchor-v1/CHANGELOG.md +++ b/finance/prop-amm/anchor-v1/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## Unreleased, 2026-10-05 + +The LiteSVM suite mints NVDAx with 8 decimals, its real count, and USDC with +6, so the two mints no longer share a decimal count (`NVDAX_DECIMALS`, +`ONE_NVDAX`, `USDC_DECIMALS`, `ONE_USDC`). The walkthrough amounts are the +same: 5 NVDAx (`FIVE_NVDAX`, now 500,000,000 minor units) costs 825.825 USDC +at the ask (`FIVE_NVDAX_AT_THE_ASK`) and sells for 824.175 at the bid +(`FIVE_NVDAX_AT_THE_BID`), 850.85 at $170 and 829.125 at a 50 bps spread, all +exact, because the ask and bid have three decimal places of a dollar and 5 +is a whole number of NVDAx at any decimal count. Every refusal test asserts +its error code: `assert_fails_with` for the program's errors (slippage, +staleness, a pre-restart price, confidence, pause, zero amounts, inventory +bounds and parameter bounds) and the new `assert_fails_with_anchor_error` +for the `has_one = operator` constraint (`ConstraintHasOne`) that refuses an imposter +operator; `move_inventory_as` and `set_quote_as` return the transaction error +text for it. No program source changed: the quote math reads both mints' +decimals from the market and already rounds the ask up, the bid down and +both outputs down. + ## 2026-10-04 Check which program owns the price feed. `initialize_market` records the diff --git a/finance/prop-amm/anchor-v1/README.md b/finance/prop-amm/anchor-v1/README.md index 92daa04f..85e5514a 100644 --- a/finance/prop-amm/anchor-v1/README.md +++ b/finance/prop-amm/anchor-v1/README.md @@ -82,8 +82,8 @@ option for whoever notices first. ### Participants - **Maria** operates the market-making firm. -- **Alice** and **Bob** trade NVDAx (tokenized NVIDIA stock, 6 decimals) - against USDC. +- **Alice** and **Bob** trade NVDAx (tokenized NVIDIA stock, 8 decimals) + against USDC (6 decimals). - The oracle quotes NVDAx at **$165** with 8 decimals of scale. ### Step 1: Maria opens the market @@ -103,14 +103,18 @@ nobody else to account for. ### Step 3: Alice buys 5 NVDAx at the ask At $165 with a 10 bps spread the ask is $165.165. Alice's `swap` -(`Direction::BuyBase`) spends exactly 1,651.65 USDC for 10 NVDAx; -whether she bought 1 or 500, the unit price would be the same. +(`Direction::BuyBase`) spends exactly 825.825 USDC for 5 NVDAx; +whether she bought 1 or 500, the unit price would be the same. The fill is +exact in USDC's six decimals because the ask has three decimal places of a +dollar, and 5 is a whole number of NVDAx whatever the token's decimals: the +quote math scales the base and quote amounts by their own mints' decimals, +which need not match. ### Step 4: Bob sells 5 NVDAx at the bid The bid is $164.835, so Bob's `swap` (`Direction::SellBase`) receives -exactly 1,648.35 USDC. A round trip through both sides costs exactly the -3.30 USDC spread: the spread is the fee, and it lands in the inventory, +exactly 824.175 USDC. A round trip through both sides costs exactly the +1.65 USDC spread: the spread is the fee, and it lands in the inventory, not in a fee ledger. ### Step 5: The oracle reprices; the quote follows @@ -157,12 +161,16 @@ anchor build 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, a feed account owned by another program +The LiteSVM suite (`programs/prop-amm/tests/test_prop_amm.rs`) mints NVDAx +with 8 decimals and USDC with 6, 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, a price from before a cluster +restart, confidence, a feed account owned by another program (`test_swap_rejects_price_feed_from_another_program`), pause, zero amounts, -inventory bounds, and operator access control. +inventory bounds, parameter bounds, and operator access control. Every +refusal test asserts its error code: `assert_fails_with` for the program's +own errors and `assert_fails_with_anchor_error` for the constraint that keeps +the operator's instructions to the operator. ## FAQ 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 d3af29a7..85d524c7 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 @@ -1,5 +1,6 @@ use { anchor_lang::{ + error::{ErrorCode as AnchorErrorCode, ERROR_CODE_OFFSET}, solana_program::{instruction::Instruction, pubkey::Pubkey, system_program}, AccountDeserialize, InstructionData, ToAccountMetas, }, @@ -18,10 +19,22 @@ use { solana_signer::Signer, }; -// Both tokens have 6 decimals: the base is NVDAx (tokenized NVIDIA stock) and -// the quote is USDC, so one whole unit of either is 1_000_000 minor units. -const ONE_TOKEN: u64 = 1_000_000; -const DECIMALS: u8 = 6; +// The base is NVDAx (tokenized NVIDIA stock), which has 8 decimals; the quote +// is USDC, which has 6. The program reads both from the mints, so nothing in +// the quote math assumes they match. +const NVDAX_DECIMALS: u8 = 8; +const ONE_NVDAX: u64 = 100_000_000; +const USDC_DECIMALS: u8 = 6; +const ONE_USDC: u64 = 1_000_000; + +// The walkthrough trade: at $165 with a 10 bps spread the ask is $165.165 and +// the bid $164.835, so 5 NVDAx costs 825.825 USDC and sells for 824.175. Both +// are exact in USDC's six decimals because the ask and bid have only three +// decimal places of a dollar, and 5 is a whole number of NVDAx whatever the +// token's decimals. +const FIVE_NVDAX: u64 = 5 * ONE_NVDAX; +const FIVE_NVDAX_AT_THE_ASK: u64 = 825_825_000; // 825.825 USDC +const FIVE_NVDAX_AT_THE_BID: u64 = 824_175_000; // 824.175 USDC // The oracle quotes prices with 8 decimals, so $165 is 165 * 10^8. const ORACLE_SCALE: u32 = 8; @@ -55,10 +68,10 @@ 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; +/// 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}"); }; @@ -68,6 +81,19 @@ fn assert_fails_with(result: Result, expected: PropAmmError) { ); } +/// Assert that `result` failed with the program's `expected` error. Anchor +/// numbers a program's own errors from `ERROR_CODE_OFFSET` in declaration +/// order. +fn assert_fails_with(result: Result, expected: PropAmmError) { + assert_fails_with_code(result, ERROR_CODE_OFFSET + expected as u32); +} + +/// Anchor's own constraint errors (a `has_one` mismatch, for instance) 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); +} + /// One deployed market plus the keys needed to drive it. struct Market { svm: LiteSVM, @@ -119,8 +145,8 @@ impl Market { let payer = create_wallet(&mut svm, 100_000_000_000).unwrap(); let operator = create_wallet(&mut svm, 100_000_000_000).unwrap(); - let base_mint = create_token_mint(&mut svm, &operator, DECIMALS, None).unwrap(); - let quote_mint = create_token_mint(&mut svm, &operator, DECIMALS, None).unwrap(); + let base_mint = create_token_mint(&mut svm, &operator, NVDAX_DECIMALS, None).unwrap(); + let quote_mint = create_token_mint(&mut svm, &operator, USDC_DECIMALS, None).unwrap(); // Create the mock oracle feed as a fresh account owned by the mock // program; the operator is its update authority. @@ -195,7 +221,7 @@ impl Market { &mut svm, &base_mint, &operator_base, - 10_000 * ONE_TOKEN, + 10_000 * ONE_NVDAX, &operator, ) .unwrap(); @@ -203,7 +229,7 @@ impl Market { &mut svm, "e_mint, &operator_quote, - 10_000_000 * ONE_TOKEN, + 10_000_000 * ONE_USDC, &operator, ) .unwrap(); @@ -227,7 +253,7 @@ impl Market { fn default_market() -> Market { let mut market = Market::new(dollars(165)); market - .deposit_inventory(1_000 * ONE_TOKEN, 200_000 * ONE_TOKEN) + .deposit_inventory(1_000 * ONE_NVDAX, 200_000 * ONE_USDC) .unwrap(); market } @@ -327,7 +353,7 @@ impl Market { deposit: bool, base_amount: u64, quote_amount: u64, - ) -> Result<(), ()> { + ) -> Result<(), String> { let signer_base = derive_ata(&signer.pubkey(), &self.base_mint); let signer_quote = derive_ata(&signer.pubkey(), &self.quote_mint); let instruction = if deposit { @@ -380,20 +406,25 @@ impl Market { &signer.pubkey(), ) .map(|_| ()) - .map_err(|_| ()) + .map_err(|error| format!("{error:?}")) } - fn deposit_inventory(&mut self, base_amount: u64, quote_amount: u64) -> Result<(), ()> { + fn deposit_inventory(&mut self, base_amount: u64, quote_amount: u64) -> Result<(), String> { let operator = self.operator.insecure_clone(); self.move_inventory_as(&operator, true, base_amount, quote_amount) } - fn withdraw_inventory(&mut self, base_amount: u64, quote_amount: u64) -> Result<(), ()> { + fn withdraw_inventory(&mut self, base_amount: u64, quote_amount: u64) -> Result<(), String> { let operator = self.operator.insecure_clone(); self.move_inventory_as(&operator, false, base_amount, quote_amount) } - fn set_quote_as(&mut self, signer: &Keypair, spread_bps: u16, paused: bool) -> Result<(), ()> { + fn set_quote_as( + &mut self, + signer: &Keypair, + spread_bps: u16, + paused: bool, + ) -> Result<(), String> { let instruction = Instruction::new_with_bytes( prop_amm::id(), &prop_amm::instruction::SetQuote { spread_bps, paused }.data(), @@ -410,10 +441,10 @@ impl Market { &signer.pubkey(), ) .map(|_| ()) - .map_err(|_| ()) + .map_err(|error| format!("{error:?}")) } - fn set_quote(&mut self, spread_bps: u16, paused: bool) -> Result<(), ()> { + fn set_quote(&mut self, spread_bps: u16, paused: bool) -> Result<(), String> { let operator = self.operator.insecure_clone(); self.set_quote_as(&operator, spread_bps, paused) } @@ -491,20 +522,20 @@ impl Market { #[test] fn test_swap_buys_base_at_the_ask() { let mut market = Market::default_market(); - let quote_in = 825_825_000; // 825.825 USDC + let quote_in = FIVE_NVDAX_AT_THE_ASK; let (alice, alice_base, alice_quote) = market.funded_trader(0, quote_in); market - .swap(&alice, Direction::BuyBase, quote_in, 5 * ONE_TOKEN) + .swap(&alice, Direction::BuyBase, quote_in, FIVE_NVDAX) .unwrap(); - assert_eq!(market.balance(&alice_base), 5 * ONE_TOKEN); + assert_eq!(market.balance(&alice_base), FIVE_NVDAX); assert_eq!(market.balance(&alice_quote), 0); // Conservation: the vaults moved by exactly the two legs of the fill. - assert_eq!(market.balance(&market.base_vault), 995 * ONE_TOKEN); + assert_eq!(market.balance(&market.base_vault), 995 * ONE_NVDAX); assert_eq!( market.balance(&market.quote_vault), - 200_000 * ONE_TOKEN + quote_in + 200_000 * ONE_USDC + quote_in ); } @@ -513,18 +544,18 @@ fn test_swap_buys_base_at_the_ask() { #[test] fn test_swap_sells_base_at_the_bid() { let mut market = Market::default_market(); - let (bob, bob_base, bob_quote) = market.funded_trader(5 * ONE_TOKEN, 0); + let (bob, bob_base, bob_quote) = market.funded_trader(FIVE_NVDAX, 0); market - .swap(&bob, Direction::SellBase, 5 * ONE_TOKEN, 824_175_000) + .swap(&bob, Direction::SellBase, FIVE_NVDAX, FIVE_NVDAX_AT_THE_BID) .unwrap(); assert_eq!(market.balance(&bob_base), 0); - assert_eq!(market.balance(&bob_quote), 824_175_000); // 824.175 USDC - assert_eq!(market.balance(&market.base_vault), 1_005 * ONE_TOKEN); + assert_eq!(market.balance(&bob_quote), FIVE_NVDAX_AT_THE_BID); + assert_eq!(market.balance(&market.base_vault), 1_005 * ONE_NVDAX); assert_eq!( market.balance(&market.quote_vault), - 200_000 * ONE_TOKEN - 824_175_000 + 200_000 * ONE_USDC - FIVE_NVDAX_AT_THE_BID ); } @@ -534,23 +565,23 @@ fn test_swap_sells_base_at_the_bid() { #[test] fn test_round_trip_costs_exactly_the_spread() { let mut market = Market::default_market(); - let quote_in = 825_825_000; + let quote_in = FIVE_NVDAX_AT_THE_ASK; let (carol, carol_base, carol_quote) = market.funded_trader(0, quote_in); market .swap(&carol, Direction::BuyBase, quote_in, 0) .unwrap(); market - .swap(&carol, Direction::SellBase, 5 * ONE_TOKEN, 0) + .swap(&carol, Direction::SellBase, FIVE_NVDAX, 0) .unwrap(); assert_eq!(market.balance(&carol_base), 0); // 825.825 in, 824.175 back: the market kept 1.65 USDC. assert_eq!(market.balance(&carol_quote), quote_in - 1_650_000); - assert_eq!(market.balance(&market.base_vault), 1_000 * ONE_TOKEN); + assert_eq!(market.balance(&market.base_vault), 1_000 * ONE_NVDAX); assert_eq!( market.balance(&market.quote_vault), - 200_000 * ONE_TOKEN + 1_650_000 + 200_000 * ONE_USDC + 1_650_000 ); } @@ -565,10 +596,10 @@ fn test_quote_follows_the_oracle() { let quote_in = 850_850_000; // 850.85 USDC let (alice, alice_base, _) = market.funded_trader(0, quote_in); market - .swap(&alice, Direction::BuyBase, quote_in, 5 * ONE_TOKEN) + .swap(&alice, Direction::BuyBase, quote_in, FIVE_NVDAX) .unwrap(); - assert_eq!(market.balance(&alice_base), 5 * ONE_TOKEN); + assert_eq!(market.balance(&alice_base), FIVE_NVDAX); } /// The operator re-quotes to a 50 bps spread; the next fill prices at @@ -582,10 +613,10 @@ fn test_set_quote_changes_the_spread() { let quote_in = 829_125_000; // 829.125 USDC let (alice, alice_base, _) = market.funded_trader(0, quote_in); market - .swap(&alice, Direction::BuyBase, quote_in, 5 * ONE_TOKEN) + .swap(&alice, Direction::BuyBase, quote_in, FIVE_NVDAX) .unwrap(); - assert_eq!(market.balance(&alice_base), 5 * ONE_TOKEN); + assert_eq!(market.balance(&alice_base), FIVE_NVDAX); } // =========================================================================== @@ -599,51 +630,60 @@ fn test_set_quote_changes_the_spread() { fn test_operator_can_withdraw_everything_and_swaps_then_fail() { let mut market = Market::default_market(); market - .withdraw_inventory(1_000 * ONE_TOKEN, 200_000 * ONE_TOKEN) + .withdraw_inventory(1_000 * ONE_NVDAX, 200_000 * ONE_USDC) .unwrap(); assert_eq!(market.balance(&market.base_vault), 0); assert_eq!(market.balance(&market.quote_vault), 0); let operator_base = market.operator_base; let operator_quote = market.operator_quote; - assert_eq!(market.balance(&operator_base), 10_000 * ONE_TOKEN); - assert_eq!(market.balance(&operator_quote), 10_000_000 * ONE_TOKEN); + assert_eq!(market.balance(&operator_base), 10_000 * ONE_NVDAX); + assert_eq!(market.balance(&operator_quote), 10_000_000 * ONE_USDC); - let (alice, _, _) = market.funded_trader(0, 825_825_000); - assert!(market - .swap(&alice, Direction::BuyBase, 825_825_000, 0) - .is_err()); + let (alice, _, _) = market.funded_trader(0, FIVE_NVDAX_AT_THE_ASK); + assert_fails_with( + market.swap(&alice, Direction::BuyBase, FIVE_NVDAX_AT_THE_ASK, 0), + PropAmmError::InsufficientInventory, + ); } #[test] fn test_withdraw_more_than_inventory_fails() { let mut market = Market::default_market(); - assert!(market.withdraw_inventory(1_001 * ONE_TOKEN, 0).is_err()); + assert_fails_with( + market.withdraw_inventory(1_001 * ONE_NVDAX, 0), + PropAmmError::InsufficientInventory, + ); } #[test] fn test_deposit_inventory_rejects_non_operator() { let mut market = Market::default_market(); - let (mallory, _, _) = market.funded_trader(ONE_TOKEN, ONE_TOKEN); - assert!(market - .move_inventory_as(&mallory, true, ONE_TOKEN, 0) - .is_err()); + let (mallory, _, _) = market.funded_trader(ONE_NVDAX, ONE_USDC); + assert_fails_with_anchor_error( + market.move_inventory_as(&mallory, true, ONE_NVDAX, 0), + AnchorErrorCode::ConstraintHasOne, + ); } #[test] fn test_withdraw_inventory_rejects_non_operator() { let mut market = Market::default_market(); let (mallory, _, _) = market.funded_trader(0, 0); - assert!(market - .move_inventory_as(&mallory, false, ONE_TOKEN, 0) - .is_err()); + assert_fails_with_anchor_error( + market.move_inventory_as(&mallory, false, ONE_NVDAX, 0), + AnchorErrorCode::ConstraintHasOne, + ); } #[test] fn test_set_quote_rejects_non_operator() { let mut market = Market::default_market(); let (mallory, _, _) = market.funded_trader(0, 0); - assert!(market.set_quote_as(&mallory, 500, true).is_err()); + assert_fails_with_anchor_error( + market.set_quote_as(&mallory, 500, true), + AnchorErrorCode::ConstraintHasOne, + ); } // =========================================================================== @@ -654,12 +694,13 @@ fn test_set_quote_rejects_non_operator() { #[test] fn test_swap_rejects_slippage() { let mut market = Market::default_market(); - let quote_in = 825_825_000; + let quote_in = FIVE_NVDAX_AT_THE_ASK; let (alice, _, _) = market.funded_trader(0, quote_in); // The fill would be exactly 5 NVDAx; demand one minor unit more. - assert!(market - .swap(&alice, Direction::BuyBase, quote_in, 5 * ONE_TOKEN + 1) - .is_err()); + assert_fails_with( + market.swap(&alice, Direction::BuyBase, quote_in, FIVE_NVDAX + 1), + PropAmmError::SlippageExceeded, + ); } /// An oracle price older than the staleness bound cannot be traded against. @@ -668,15 +709,16 @@ fn test_swap_rejects_slippage() { #[test] fn test_swap_rejects_stale_price() { let mut market = Market::default_market(); - let (alice, _, _) = market.funded_trader(0, 825_825_000); + let (alice, _, _) = market.funded_trader(0, FIVE_NVDAX_AT_THE_ASK); // The feed was last updated at the current slot; 200 slots later it is // stale (the bound is 150 slots). Warp relative to the current slot: // LiteSVM starts the clock at a mainnet-like slot, not at zero. let published_at = market.current_slot(); market.warp(published_at + 200); - assert!(market - .swap(&alice, Direction::BuyBase, 825_825_000, 0) - .is_err()); + assert_fails_with( + market.swap(&alice, Direction::BuyBase, FIVE_NVDAX_AT_THE_ASK, 0), + PropAmmError::StalePrice, + ); } /// A cluster restart passes hours of wall-clock time in zero slots, so a price @@ -686,7 +728,7 @@ fn test_swap_rejects_stale_price() { fn test_swap_rejects_price_from_before_a_restart() { let mut market = Market::default_market(); market.set_price(dollars(165)); - let (alice, _, _) = market.funded_trader(0, 825_825_000); + let (alice, _, _) = market.funded_trader(0, FIVE_NVDAX_AT_THE_ASK); // Simulate a halt: the cluster restarts a few slots after the price was // published, well inside the 150-slot staleness bound, so only the @@ -695,9 +737,10 @@ fn test_swap_rejects_price_from_before_a_restart() { market.warp(published_at + 5); market.set_last_restart_slot(published_at + 3); - assert!(market - .swap(&alice, Direction::BuyBase, 825_825_000, 0) - .is_err()); + assert_fails_with( + market.swap(&alice, Direction::BuyBase, FIVE_NVDAX_AT_THE_ASK, 0), + PropAmmError::PricePredatesRestart, + ); // Publishing after the restart reopens the market. Warp first: the retry is // otherwise byte-identical to the rejected swap, so it would carry the same @@ -705,7 +748,7 @@ fn test_swap_rejects_price_from_before_a_restart() { market.warp(published_at + 6); market.set_price(dollars(165)); market - .swap(&alice, Direction::BuyBase, 825_825_000, 0) + .swap(&alice, Direction::BuyBase, FIVE_NVDAX_AT_THE_ASK, 0) .expect("a freshly published price must be accepted after a restart"); } @@ -721,11 +764,11 @@ fn test_swap_rejects_price_feed_from_another_program() { market.market_state().price_feed_program, mock_price_feed::id() ); - let (alice, _, _) = market.funded_trader(0, 825_825_000); + let (alice, _, _) = market.funded_trader(0, FIVE_NVDAX_AT_THE_ASK); market.set_feed_owner(Pubkey::new_unique()); assert_fails_with( - market.swap(&alice, Direction::BuyBase, 825_825_000, 0), + market.swap(&alice, Direction::BuyBase, FIVE_NVDAX_AT_THE_ASK, 0), PropAmmError::PriceFeedNotFromOracle, ); @@ -734,7 +777,7 @@ fn test_swap_rejects_price_feed_from_another_program() { market.svm.expire_blockhash(); market.set_feed_owner(mock_price_feed::id()); market - .swap(&alice, Direction::BuyBase, 825_825_000, 0) + .swap(&alice, Direction::BuyBase, FIVE_NVDAX_AT_THE_ASK, 0) .expect("the same feed owned by the recorded oracle program must be accepted"); } @@ -744,10 +787,11 @@ fn test_swap_rejects_price_feed_from_another_program() { fn test_swap_rejects_wide_confidence() { let mut market = Market::default_market(); market.set_price_with_confidence(dollars(165), 200_000_000); - let (alice, _, _) = market.funded_trader(0, 825_825_000); - assert!(market - .swap(&alice, Direction::BuyBase, 825_825_000, 0) - .is_err()); + let (alice, _, _) = market.funded_trader(0, FIVE_NVDAX_AT_THE_ASK); + assert_fails_with( + market.swap(&alice, Direction::BuyBase, FIVE_NVDAX_AT_THE_ASK, 0), + PropAmmError::OracleConfidenceTooWide, + ); } /// While the operator has pulled its quotes, nobody can swap. @@ -755,23 +799,32 @@ fn test_swap_rejects_wide_confidence() { fn test_swap_rejects_when_paused() { let mut market = Market::default_market(); market.set_quote(SPREAD_BPS, true).unwrap(); - let (alice, _, _) = market.funded_trader(0, 825_825_000); - assert!(market - .swap(&alice, Direction::BuyBase, 825_825_000, 0) - .is_err()); + let (alice, _, _) = market.funded_trader(0, FIVE_NVDAX_AT_THE_ASK); + assert_fails_with( + market.swap(&alice, Direction::BuyBase, FIVE_NVDAX_AT_THE_ASK, 0), + PropAmmError::MarketPaused, + ); // Unpausing restores the exact same quote. market.set_quote(SPREAD_BPS, false).unwrap(); market - .swap(&alice, Direction::BuyBase, 825_825_000, 5 * ONE_TOKEN) + .swap( + &alice, + Direction::BuyBase, + FIVE_NVDAX_AT_THE_ASK, + FIVE_NVDAX, + ) .unwrap(); } #[test] fn test_swap_rejects_zero_amount() { let mut market = Market::default_market(); - let (alice, _, _) = market.funded_trader(0, ONE_TOKEN); - assert!(market.swap(&alice, Direction::BuyBase, 0, 0).is_err()); + let (alice, _, _) = market.funded_trader(0, ONE_USDC); + assert_fails_with( + market.swap(&alice, Direction::BuyBase, 0, 0), + PropAmmError::ZeroAmount, + ); } /// A buy bigger than the base inventory is rejected whole — a prop AMM never @@ -783,9 +836,10 @@ fn test_swap_rejects_insufficient_inventory() { // but the vault only holds 1,000 NVDAx. let quote_in = 181_681_500_000; let (whale, _, _) = market.funded_trader(0, quote_in); - assert!(market - .swap(&whale, Direction::BuyBase, quote_in, 0) - .is_err()); + assert_fails_with( + market.swap(&whale, Direction::BuyBase, quote_in, 0), + PropAmmError::InsufficientInventory, + ); } // =========================================================================== @@ -814,7 +868,10 @@ fn test_initialize_market_rejects_zero_spread() { spread_bps: 0, max_confidence_bps: MAX_CONFIDENCE_BPS, }; - assert!(Market::try_new(dollars(165), parameters).is_err()); + assert_fails_with( + Market::try_new(dollars(165), parameters), + PropAmmError::InvalidParameter, + ); } #[test] @@ -824,12 +881,18 @@ fn test_initialize_market_rejects_full_spread() { spread_bps: 10_000, max_confidence_bps: MAX_CONFIDENCE_BPS, }; - assert!(Market::try_new(dollars(165), parameters).is_err()); + assert_fails_with( + Market::try_new(dollars(165), parameters), + PropAmmError::InvalidParameter, + ); } #[test] fn test_set_quote_rejects_invalid_spread() { let mut market = Market::default_market(); - assert!(market.set_quote(0, false).is_err()); - assert!(market.set_quote(10_000, false).is_err()); + assert_fails_with(market.set_quote(0, false), PropAmmError::InvalidParameter); + assert_fails_with( + market.set_quote(10_000, false), + PropAmmError::InvalidParameter, + ); } diff --git a/finance/prop-amm/anchor/CHANGELOG.md b/finance/prop-amm/anchor/CHANGELOG.md index 591b5d2d..4adfbdf5 100644 --- a/finance/prop-amm/anchor/CHANGELOG.md +++ b/finance/prop-amm/anchor/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## Unreleased, 2026-10-05 + +The LiteSVM suite mints NVDAx with 8 decimals, its real count, and USDC with +6, so the two mints no longer share a decimal count (`NVDAX_DECIMALS`, +`ONE_NVDAX`, `USDC_DECIMALS`, `ONE_USDC`). The walkthrough amounts are the +same: 5 NVDAx (`FIVE_NVDAX`, now 500,000,000 minor units) costs 825.825 USDC +at the ask (`FIVE_NVDAX_AT_THE_ASK`) and sells for 824.175 at the bid +(`FIVE_NVDAX_AT_THE_BID`), 850.85 at $170 and 829.125 at a 50 bps spread, all +exact, because the ask and bid have three decimal places of a dollar and 5 +is a whole number of NVDAx at any decimal count. Every refusal test asserts +its error code: `assert_fails_with` for the program's errors (slippage, +staleness, a pre-restart price, confidence, pause, zero amounts, inventory +bounds and parameter bounds) and the new `assert_fails_with_anchor_error` +for the `address = market.operator` constraint (`ConstraintAddress`) that refuses an imposter +operator; `move_inventory_as` and `set_quote_as` return the transaction error +text for it. No program source changed: the quote math reads both mints' +decimals from the market and already rounds the ask up, the bid down and +both outputs down. + ## 2026-10-04 Check which program owns the price feed. `initialize_market` records the diff --git a/finance/prop-amm/anchor/README.md b/finance/prop-amm/anchor/README.md index 866d383c..12e087b5 100644 --- a/finance/prop-amm/anchor/README.md +++ b/finance/prop-amm/anchor/README.md @@ -82,8 +82,8 @@ option for whoever notices first. ### Participants - **Maria** operates the market-making firm. -- **Alice** and **Bob** trade NVDAx (tokenized NVIDIA stock, 6 decimals) - against USDC. +- **Alice** and **Bob** trade NVDAx (tokenized NVIDIA stock, 8 decimals) + against USDC (6 decimals). - The oracle quotes NVDAx at **$165** with 8 decimals of scale. ### Step 1: Maria opens the market @@ -103,14 +103,18 @@ nobody else to account for. ### Step 3: Alice buys 5 NVDAx at the ask At $165 with a 10 bps spread the ask is $165.165. Alice's `swap` -(`Direction::BuyBase`) spends exactly 1,651.65 USDC for 10 NVDAx; -whether she bought 1 or 500, the unit price would be the same. +(`Direction::BuyBase`) spends exactly 825.825 USDC for 5 NVDAx; +whether she bought 1 or 500, the unit price would be the same. The fill is +exact in USDC's six decimals because the ask has three decimal places of a +dollar, and 5 is a whole number of NVDAx whatever the token's decimals: the +quote math scales the base and quote amounts by their own mints' decimals, +which need not match. ### Step 4: Bob sells 5 NVDAx at the bid The bid is $164.835, so Bob's `swap` (`Direction::SellBase`) receives -exactly 1,648.35 USDC. A round trip through both sides costs exactly the -3.30 USDC spread: the spread is the fee, and it lands in the inventory, +exactly 824.175 USDC. A round trip through both sides costs exactly the +1.65 USDC spread: the spread is the fee, and it lands in the inventory, not in a fee ledger. ### Step 5: The oracle reprices; the quote follows @@ -157,12 +161,16 @@ anchor build 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, a feed account owned by another program +The LiteSVM suite (`programs/prop-amm/tests/test_prop_amm.rs`) mints NVDAx +with 8 decimals and USDC with 6, 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, a price from before a cluster +restart, confidence, a feed account owned by another program (`test_swap_rejects_price_feed_from_another_program`), pause, zero amounts, -inventory bounds, and operator access control. +inventory bounds, parameter bounds, and operator access control. Every +refusal test asserts its error code: `assert_fails_with` for the program's +own errors and `assert_fails_with_anchor_error` for the constraint that keeps +the operator's instructions to the operator. ## FAQ 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 29922bb3..2be74af7 100644 --- a/finance/prop-amm/anchor/programs/prop-amm/tests/test_prop_amm.rs +++ b/finance/prop-amm/anchor/programs/prop-amm/tests/test_prop_amm.rs @@ -1,7 +1,7 @@ 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}, prop_amm::{ @@ -16,10 +16,22 @@ use { }, }; -// Both tokens have 6 decimals: the base is NVDAx (tokenized NVIDIA stock) and -// the quote is USDC, so one whole unit of either is 1_000_000 minor units. -const ONE_TOKEN: u64 = 1_000_000; -const DECIMALS: u8 = 6; +// The base is NVDAx (tokenized NVIDIA stock), which has 8 decimals; the quote +// is USDC, which has 6. The program reads both from the mints, so nothing in +// the quote math assumes they match. +const NVDAX_DECIMALS: u8 = 8; +const ONE_NVDAX: u64 = 100_000_000; +const USDC_DECIMALS: u8 = 6; +const ONE_USDC: u64 = 1_000_000; + +// The walkthrough trade: at $165 with a 10 bps spread the ask is $165.165 and +// the bid $164.835, so 5 NVDAx costs 825.825 USDC and sells for 824.175. Both +// are exact in USDC's six decimals because the ask and bid have only three +// decimal places of a dollar, and 5 is a whole number of NVDAx whatever the +// token's decimals. +const FIVE_NVDAX: u64 = 5 * ONE_NVDAX; +const FIVE_NVDAX_AT_THE_ASK: u64 = 825_825_000; // 825.825 USDC +const FIVE_NVDAX_AT_THE_BID: u64 = 824_175_000; // 824.175 USDC // The oracle quotes prices with 8 decimals, so $165 is 165 * 10^8. const ORACLE_SCALE: u32 = 8; @@ -53,10 +65,10 @@ 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; +/// 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}"); }; @@ -66,6 +78,21 @@ fn assert_fails_with(result: Result, expected: PropAmmError) { ); } +/// 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) { + assert_fails_with_code(result, expected as u32 + 6000); +} + +/// Anchor's own constraint errors (an `address =` mismatch, for instance) 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); +} + /// One deployed market plus the keys needed to drive it. struct Market { svm: LiteSVM, @@ -117,8 +144,8 @@ impl Market { let payer = create_wallet(&mut svm, 100_000_000_000).unwrap(); let operator = create_wallet(&mut svm, 100_000_000_000).unwrap(); - let base_mint = create_token_mint(&mut svm, &operator, DECIMALS, None).unwrap(); - let quote_mint = create_token_mint(&mut svm, &operator, DECIMALS, None).unwrap(); + let base_mint = create_token_mint(&mut svm, &operator, NVDAX_DECIMALS, None).unwrap(); + let quote_mint = create_token_mint(&mut svm, &operator, USDC_DECIMALS, None).unwrap(); // Create the mock oracle feed as a fresh account owned by the mock // program; the operator is its update authority. @@ -193,7 +220,7 @@ impl Market { &mut svm, &base_mint, &operator_base, - 10_000 * ONE_TOKEN, + 10_000 * ONE_NVDAX, &operator, ) .unwrap(); @@ -201,7 +228,7 @@ impl Market { &mut svm, "e_mint, &operator_quote, - 10_000_000 * ONE_TOKEN, + 10_000_000 * ONE_USDC, &operator, ) .unwrap(); @@ -225,7 +252,7 @@ impl Market { fn default_market() -> Market { let mut market = Market::new(dollars(165)); market - .deposit_inventory(1_000 * ONE_TOKEN, 200_000 * ONE_TOKEN) + .deposit_inventory(1_000 * ONE_NVDAX, 200_000 * ONE_USDC) .unwrap(); market } @@ -325,7 +352,7 @@ impl Market { deposit: bool, base_amount: u64, quote_amount: u64, - ) -> Result<(), ()> { + ) -> Result<(), String> { let signer_base = derive_ata(&signer.pubkey(), &self.base_mint); let signer_quote = derive_ata(&signer.pubkey(), &self.quote_mint); let instruction = if deposit { @@ -378,20 +405,25 @@ impl Market { &signer.pubkey(), ) .map(|_| ()) - .map_err(|_| ()) + .map_err(|error| format!("{error:?}")) } - fn deposit_inventory(&mut self, base_amount: u64, quote_amount: u64) -> Result<(), ()> { + fn deposit_inventory(&mut self, base_amount: u64, quote_amount: u64) -> Result<(), String> { let operator = self.operator.insecure_clone(); self.move_inventory_as(&operator, true, base_amount, quote_amount) } - fn withdraw_inventory(&mut self, base_amount: u64, quote_amount: u64) -> Result<(), ()> { + fn withdraw_inventory(&mut self, base_amount: u64, quote_amount: u64) -> Result<(), String> { let operator = self.operator.insecure_clone(); self.move_inventory_as(&operator, false, base_amount, quote_amount) } - fn set_quote_as(&mut self, signer: &Keypair, spread_bps: u16, paused: bool) -> Result<(), ()> { + fn set_quote_as( + &mut self, + signer: &Keypair, + spread_bps: u16, + paused: bool, + ) -> Result<(), String> { let instruction = Instruction::new_with_bytes( prop_amm::id(), &prop_amm::instruction::SetQuote { spread_bps, paused }.data(), @@ -408,10 +440,10 @@ impl Market { &signer.pubkey(), ) .map(|_| ()) - .map_err(|_| ()) + .map_err(|error| format!("{error:?}")) } - fn set_quote(&mut self, spread_bps: u16, paused: bool) -> Result<(), ()> { + fn set_quote(&mut self, spread_bps: u16, paused: bool) -> Result<(), String> { let operator = self.operator.insecure_clone(); self.set_quote_as(&operator, spread_bps, paused) } @@ -489,20 +521,20 @@ impl Market { #[test] fn test_swap_buys_base_at_the_ask() { let mut market = Market::default_market(); - let quote_in = 825_825_000; // 825.825 USDC + let quote_in = FIVE_NVDAX_AT_THE_ASK; let (alice, alice_base, alice_quote) = market.funded_trader(0, quote_in); market - .swap(&alice, Direction::BuyBase, quote_in, 5 * ONE_TOKEN) + .swap(&alice, Direction::BuyBase, quote_in, FIVE_NVDAX) .unwrap(); - assert_eq!(market.balance(&alice_base), 5 * ONE_TOKEN); + assert_eq!(market.balance(&alice_base), FIVE_NVDAX); assert_eq!(market.balance(&alice_quote), 0); // Conservation: the vaults moved by exactly the two legs of the fill. - assert_eq!(market.balance(&market.base_vault), 995 * ONE_TOKEN); + assert_eq!(market.balance(&market.base_vault), 995 * ONE_NVDAX); assert_eq!( market.balance(&market.quote_vault), - 200_000 * ONE_TOKEN + quote_in + 200_000 * ONE_USDC + quote_in ); } @@ -511,18 +543,18 @@ fn test_swap_buys_base_at_the_ask() { #[test] fn test_swap_sells_base_at_the_bid() { let mut market = Market::default_market(); - let (bob, bob_base, bob_quote) = market.funded_trader(5 * ONE_TOKEN, 0); + let (bob, bob_base, bob_quote) = market.funded_trader(FIVE_NVDAX, 0); market - .swap(&bob, Direction::SellBase, 5 * ONE_TOKEN, 824_175_000) + .swap(&bob, Direction::SellBase, FIVE_NVDAX, FIVE_NVDAX_AT_THE_BID) .unwrap(); assert_eq!(market.balance(&bob_base), 0); - assert_eq!(market.balance(&bob_quote), 824_175_000); // 824.175 USDC - assert_eq!(market.balance(&market.base_vault), 1_005 * ONE_TOKEN); + assert_eq!(market.balance(&bob_quote), FIVE_NVDAX_AT_THE_BID); + assert_eq!(market.balance(&market.base_vault), 1_005 * ONE_NVDAX); assert_eq!( market.balance(&market.quote_vault), - 200_000 * ONE_TOKEN - 824_175_000 + 200_000 * ONE_USDC - FIVE_NVDAX_AT_THE_BID ); } @@ -532,23 +564,23 @@ fn test_swap_sells_base_at_the_bid() { #[test] fn test_round_trip_costs_exactly_the_spread() { let mut market = Market::default_market(); - let quote_in = 825_825_000; + let quote_in = FIVE_NVDAX_AT_THE_ASK; let (carol, carol_base, carol_quote) = market.funded_trader(0, quote_in); market .swap(&carol, Direction::BuyBase, quote_in, 0) .unwrap(); market - .swap(&carol, Direction::SellBase, 5 * ONE_TOKEN, 0) + .swap(&carol, Direction::SellBase, FIVE_NVDAX, 0) .unwrap(); assert_eq!(market.balance(&carol_base), 0); // 825.825 in, 824.175 back: the market kept 1.65 USDC. assert_eq!(market.balance(&carol_quote), quote_in - 1_650_000); - assert_eq!(market.balance(&market.base_vault), 1_000 * ONE_TOKEN); + assert_eq!(market.balance(&market.base_vault), 1_000 * ONE_NVDAX); assert_eq!( market.balance(&market.quote_vault), - 200_000 * ONE_TOKEN + 1_650_000 + 200_000 * ONE_USDC + 1_650_000 ); } @@ -563,10 +595,10 @@ fn test_quote_follows_the_oracle() { let quote_in = 850_850_000; // 850.85 USDC let (alice, alice_base, _) = market.funded_trader(0, quote_in); market - .swap(&alice, Direction::BuyBase, quote_in, 5 * ONE_TOKEN) + .swap(&alice, Direction::BuyBase, quote_in, FIVE_NVDAX) .unwrap(); - assert_eq!(market.balance(&alice_base), 5 * ONE_TOKEN); + assert_eq!(market.balance(&alice_base), FIVE_NVDAX); } /// The operator re-quotes to a 50 bps spread; the next fill prices at @@ -580,10 +612,10 @@ fn test_set_quote_changes_the_spread() { let quote_in = 829_125_000; // 829.125 USDC let (alice, alice_base, _) = market.funded_trader(0, quote_in); market - .swap(&alice, Direction::BuyBase, quote_in, 5 * ONE_TOKEN) + .swap(&alice, Direction::BuyBase, quote_in, FIVE_NVDAX) .unwrap(); - assert_eq!(market.balance(&alice_base), 5 * ONE_TOKEN); + assert_eq!(market.balance(&alice_base), FIVE_NVDAX); } // =========================================================================== @@ -597,51 +629,60 @@ fn test_set_quote_changes_the_spread() { fn test_operator_can_withdraw_everything_and_swaps_then_fail() { let mut market = Market::default_market(); market - .withdraw_inventory(1_000 * ONE_TOKEN, 200_000 * ONE_TOKEN) + .withdraw_inventory(1_000 * ONE_NVDAX, 200_000 * ONE_USDC) .unwrap(); assert_eq!(market.balance(&market.base_vault), 0); assert_eq!(market.balance(&market.quote_vault), 0); let operator_base = market.operator_base; let operator_quote = market.operator_quote; - assert_eq!(market.balance(&operator_base), 10_000 * ONE_TOKEN); - assert_eq!(market.balance(&operator_quote), 10_000_000 * ONE_TOKEN); + assert_eq!(market.balance(&operator_base), 10_000 * ONE_NVDAX); + assert_eq!(market.balance(&operator_quote), 10_000_000 * ONE_USDC); - let (alice, _, _) = market.funded_trader(0, 825_825_000); - assert!(market - .swap(&alice, Direction::BuyBase, 825_825_000, 0) - .is_err()); + let (alice, _, _) = market.funded_trader(0, FIVE_NVDAX_AT_THE_ASK); + assert_fails_with( + market.swap(&alice, Direction::BuyBase, FIVE_NVDAX_AT_THE_ASK, 0), + PropAmmError::InsufficientInventory, + ); } #[test] fn test_withdraw_more_than_inventory_fails() { let mut market = Market::default_market(); - assert!(market.withdraw_inventory(1_001 * ONE_TOKEN, 0).is_err()); + assert_fails_with( + market.withdraw_inventory(1_001 * ONE_NVDAX, 0), + PropAmmError::InsufficientInventory, + ); } #[test] fn test_deposit_inventory_rejects_non_operator() { let mut market = Market::default_market(); - let (mallory, _, _) = market.funded_trader(ONE_TOKEN, ONE_TOKEN); - assert!(market - .move_inventory_as(&mallory, true, ONE_TOKEN, 0) - .is_err()); + let (mallory, _, _) = market.funded_trader(ONE_NVDAX, ONE_USDC); + assert_fails_with_anchor_error( + market.move_inventory_as(&mallory, true, ONE_NVDAX, 0), + AnchorErrorCode::ConstraintAddress, + ); } #[test] fn test_withdraw_inventory_rejects_non_operator() { let mut market = Market::default_market(); let (mallory, _, _) = market.funded_trader(0, 0); - assert!(market - .move_inventory_as(&mallory, false, ONE_TOKEN, 0) - .is_err()); + assert_fails_with_anchor_error( + market.move_inventory_as(&mallory, false, ONE_NVDAX, 0), + AnchorErrorCode::ConstraintAddress, + ); } #[test] fn test_set_quote_rejects_non_operator() { let mut market = Market::default_market(); let (mallory, _, _) = market.funded_trader(0, 0); - assert!(market.set_quote_as(&mallory, 500, true).is_err()); + assert_fails_with_anchor_error( + market.set_quote_as(&mallory, 500, true), + AnchorErrorCode::ConstraintAddress, + ); } // =========================================================================== @@ -652,12 +693,13 @@ fn test_set_quote_rejects_non_operator() { #[test] fn test_swap_rejects_slippage() { let mut market = Market::default_market(); - let quote_in = 825_825_000; + let quote_in = FIVE_NVDAX_AT_THE_ASK; let (alice, _, _) = market.funded_trader(0, quote_in); // The fill would be exactly 5 NVDAx; demand one minor unit more. - assert!(market - .swap(&alice, Direction::BuyBase, quote_in, 5 * ONE_TOKEN + 1) - .is_err()); + assert_fails_with( + market.swap(&alice, Direction::BuyBase, quote_in, FIVE_NVDAX + 1), + PropAmmError::SlippageExceeded, + ); } /// An oracle price older than the staleness bound cannot be traded against. @@ -666,13 +708,14 @@ fn test_swap_rejects_slippage() { #[test] fn test_swap_rejects_stale_price() { let mut market = Market::default_market(); - let (alice, _, _) = market.funded_trader(0, 825_825_000); + let (alice, _, _) = market.funded_trader(0, FIVE_NVDAX_AT_THE_ASK); // The feed was last updated near slot 0; 200 slots later it is stale // (the bound is 150 slots). market.warp(200); - assert!(market - .swap(&alice, Direction::BuyBase, 825_825_000, 0) - .is_err()); + assert_fails_with( + market.swap(&alice, Direction::BuyBase, FIVE_NVDAX_AT_THE_ASK, 0), + PropAmmError::StalePrice, + ); } /// A cluster restart passes hours of wall-clock time in zero slots, so a price @@ -682,7 +725,7 @@ fn test_swap_rejects_stale_price() { fn test_swap_rejects_price_from_before_a_restart() { let mut market = Market::default_market(); market.set_price(dollars(165)); - let (alice, _, _) = market.funded_trader(0, 825_825_000); + let (alice, _, _) = market.funded_trader(0, FIVE_NVDAX_AT_THE_ASK); // Simulate a halt: the cluster restarts a few slots after the price was // published, well inside the 150-slot staleness bound, so only the @@ -691,9 +734,10 @@ fn test_swap_rejects_price_from_before_a_restart() { market.warp(published_at + 5); market.set_last_restart_slot(published_at + 3); - assert!(market - .swap(&alice, Direction::BuyBase, 825_825_000, 0) - .is_err()); + assert_fails_with( + market.swap(&alice, Direction::BuyBase, FIVE_NVDAX_AT_THE_ASK, 0), + PropAmmError::PricePredatesRestart, + ); // Publishing after the restart reopens the market. Warp first: the retry is // otherwise byte-identical to the rejected swap, so it would carry the same @@ -701,7 +745,7 @@ fn test_swap_rejects_price_from_before_a_restart() { market.warp(published_at + 6); market.set_price(dollars(165)); market - .swap(&alice, Direction::BuyBase, 825_825_000, 0) + .swap(&alice, Direction::BuyBase, FIVE_NVDAX_AT_THE_ASK, 0) .expect("a freshly published price must be accepted after a restart"); } @@ -717,11 +761,11 @@ fn test_swap_rejects_price_feed_from_another_program() { market.market_state().price_feed_program, mock_price_feed::id() ); - let (alice, _, _) = market.funded_trader(0, 825_825_000); + let (alice, _, _) = market.funded_trader(0, FIVE_NVDAX_AT_THE_ASK); market.set_feed_owner(Address::new_unique()); assert_fails_with( - market.swap(&alice, Direction::BuyBase, 825_825_000, 0), + market.swap(&alice, Direction::BuyBase, FIVE_NVDAX_AT_THE_ASK, 0), PropAmmError::PriceFeedNotFromOracle, ); @@ -730,7 +774,7 @@ fn test_swap_rejects_price_feed_from_another_program() { market.svm.expire_blockhash(); market.set_feed_owner(mock_price_feed::id()); market - .swap(&alice, Direction::BuyBase, 825_825_000, 0) + .swap(&alice, Direction::BuyBase, FIVE_NVDAX_AT_THE_ASK, 0) .expect("the same feed owned by the recorded oracle program must be accepted"); } @@ -740,10 +784,11 @@ fn test_swap_rejects_price_feed_from_another_program() { fn test_swap_rejects_wide_confidence() { let mut market = Market::default_market(); market.set_price_with_confidence(dollars(165), 200_000_000); - let (alice, _, _) = market.funded_trader(0, 825_825_000); - assert!(market - .swap(&alice, Direction::BuyBase, 825_825_000, 0) - .is_err()); + let (alice, _, _) = market.funded_trader(0, FIVE_NVDAX_AT_THE_ASK); + assert_fails_with( + market.swap(&alice, Direction::BuyBase, FIVE_NVDAX_AT_THE_ASK, 0), + PropAmmError::OracleConfidenceTooWide, + ); } /// While the operator has pulled its quotes, nobody can swap. @@ -751,23 +796,32 @@ fn test_swap_rejects_wide_confidence() { fn test_swap_rejects_when_paused() { let mut market = Market::default_market(); market.set_quote(SPREAD_BPS, true).unwrap(); - let (alice, _, _) = market.funded_trader(0, 825_825_000); - assert!(market - .swap(&alice, Direction::BuyBase, 825_825_000, 0) - .is_err()); + let (alice, _, _) = market.funded_trader(0, FIVE_NVDAX_AT_THE_ASK); + assert_fails_with( + market.swap(&alice, Direction::BuyBase, FIVE_NVDAX_AT_THE_ASK, 0), + PropAmmError::MarketPaused, + ); // Unpausing restores the exact same quote. market.set_quote(SPREAD_BPS, false).unwrap(); market - .swap(&alice, Direction::BuyBase, 825_825_000, 5 * ONE_TOKEN) + .swap( + &alice, + Direction::BuyBase, + FIVE_NVDAX_AT_THE_ASK, + FIVE_NVDAX, + ) .unwrap(); } #[test] fn test_swap_rejects_zero_amount() { let mut market = Market::default_market(); - let (alice, _, _) = market.funded_trader(0, ONE_TOKEN); - assert!(market.swap(&alice, Direction::BuyBase, 0, 0).is_err()); + let (alice, _, _) = market.funded_trader(0, ONE_USDC); + assert_fails_with( + market.swap(&alice, Direction::BuyBase, 0, 0), + PropAmmError::ZeroAmount, + ); } /// A buy bigger than the base inventory is rejected whole — a prop AMM never @@ -779,9 +833,10 @@ fn test_swap_rejects_insufficient_inventory() { // but the vault only holds 1,000 NVDAx. let quote_in = 181_681_500_000; let (whale, _, _) = market.funded_trader(0, quote_in); - assert!(market - .swap(&whale, Direction::BuyBase, quote_in, 0) - .is_err()); + assert_fails_with( + market.swap(&whale, Direction::BuyBase, quote_in, 0), + PropAmmError::InsufficientInventory, + ); } // =========================================================================== @@ -810,7 +865,10 @@ fn test_initialize_market_rejects_zero_spread() { spread_bps: 0, max_confidence_bps: MAX_CONFIDENCE_BPS, }; - assert!(Market::try_new(dollars(165), parameters).is_err()); + assert_fails_with( + Market::try_new(dollars(165), parameters), + PropAmmError::InvalidParameter, + ); } #[test] @@ -820,12 +878,18 @@ fn test_initialize_market_rejects_full_spread() { spread_bps: 10_000, max_confidence_bps: MAX_CONFIDENCE_BPS, }; - assert!(Market::try_new(dollars(165), parameters).is_err()); + assert_fails_with( + Market::try_new(dollars(165), parameters), + PropAmmError::InvalidParameter, + ); } #[test] fn test_set_quote_rejects_invalid_spread() { let mut market = Market::default_market(); - assert!(market.set_quote(0, false).is_err()); - assert!(market.set_quote(10_000, false).is_err()); + assert_fails_with(market.set_quote(0, false), PropAmmError::InvalidParameter); + assert_fails_with( + market.set_quote(10_000, false), + PropAmmError::InvalidParameter, + ); } diff --git a/finance/prop-amm/kani-proofs/README.md b/finance/prop-amm/kani-proofs/README.md index 758287d0..55d07f74 100644 --- a/finance/prop-amm/kani-proofs/README.md +++ b/finance/prop-amm/kani-proofs/README.md @@ -44,7 +44,7 @@ Following percolator's practice, amounts and prices are bounded and the decimal exponents are kept small; the identities are independent of the exponents' actual values (they enter both sides of each comparison symmetrically), so the bounded domain exercises the same rounding edges as -scale 8 with 6-decimal tokens. Spreads stay fully symbolic over their entire +scale 8 with an 8-decimal base (NVDAx) against a 6-decimal quote (USDC). Spreads stay fully symbolic over their entire valid range (`1..10_000`). - `proof_quote_brackets_oracle`: price fully symbolic (linear arithmetic) diff --git a/finance/prop-amm/kani-proofs/src/lib.rs b/finance/prop-amm/kani-proofs/src/lib.rs index 316cf445..7804c28c 100644 --- a/finance/prop-amm/kani-proofs/src/lib.rs +++ b/finance/prop-amm/kani-proofs/src/lib.rs @@ -150,8 +150,8 @@ fn proof_buy_never_exceeds_oracle_value() { // finance Kani crates stay tractable with — and the decimal exponents // are kept small (the identity is independent of the exponents' actual // values — they enter both sides of the comparison symmetrically — so - // tiny exponents exercise the same rounding edges as scale 8 and 6/6 - // decimals). + // tiny exponents exercise the same rounding edges as scale 8 with an + // 8-decimal base and a 6-decimal quote). kani::assume(quote_in <= 255); kani::assume(price >= 1 && price <= 255); kani::assume(spread_bps >= 1 && (spread_bps as u128) < BASIS_POINTS); @@ -255,10 +255,12 @@ fn proof_round_trip_never_profits_the_trader() { mod tests { use super::*; - // $165 at scale 8; both tokens 6 decimals; 10 bps spread. + // $165 at scale 8; NVDAx has 8 decimals, USDC 6; 10 bps spread. const PRICE: u64 = 16_500_000_000; const SCALE: u32 = 8; - const DEC: u8 = 6; + const NVDAX_DECIMALS: u8 = 8; + const USDC_DECIMALS: u8 = 6; + const FIVE_NVDAX: u64 = 500_000_000; #[test] fn ask_and_bid_at_165() { @@ -271,8 +273,8 @@ mod tests { // 825.825 USDC buys exactly 5 NVDAx at the ask. let ask = ask_price(PRICE, 10).unwrap(); assert_eq!( - base_out_for_quote_in(825_825_000, ask, SCALE, DEC, DEC).unwrap(), - 5_000_000 + base_out_for_quote_in(825_825_000, ask, SCALE, NVDAX_DECIMALS, USDC_DECIMALS).unwrap(), + FIVE_NVDAX ); } @@ -281,7 +283,7 @@ mod tests { // 5 NVDAx sells for exactly 824.175 USDC at the bid. let bid = bid_price(PRICE, 10).unwrap(); assert_eq!( - quote_out_for_base_in(5_000_000, bid, SCALE, DEC, DEC).unwrap(), + quote_out_for_base_in(FIVE_NVDAX, bid, SCALE, NVDAX_DECIMALS, USDC_DECIMALS).unwrap(), 824_175_000 ); } @@ -291,11 +293,34 @@ mod tests { // In 825.825, back 824.175: the market keeps exactly 1.65 USDC. let ask = ask_price(PRICE, 10).unwrap(); let bid = bid_price(PRICE, 10).unwrap(); - let base = base_out_for_quote_in(825_825_000, ask, SCALE, DEC, DEC).unwrap(); - let back = quote_out_for_base_in(base, bid, SCALE, DEC, DEC).unwrap(); + let base = + base_out_for_quote_in(825_825_000, ask, SCALE, NVDAX_DECIMALS, USDC_DECIMALS).unwrap(); + let back = quote_out_for_base_in(base, bid, SCALE, NVDAX_DECIMALS, USDC_DECIMALS).unwrap(); assert_eq!(825_825_000 - back, 1_650_000); } + #[test] + fn walkthrough_fills_are_exact_at_any_base_decimals() { + // The fills stay exact whatever NVDAx's decimals: 825.825 / 165.165 + // is the whole number 5, and the ask and bid have three decimal + // places of a dollar, fewer than USDC's six. The base decimals only + // move the answer by a power of ten. + let ask = ask_price(PRICE, 10).unwrap(); + let bid = bid_price(PRICE, 10).unwrap(); + for base_decimals in 0..=9u8 { + let five = 5 * 10u64.pow(base_decimals as u32); + assert_eq!( + base_out_for_quote_in(825_825_000, ask, SCALE, base_decimals, USDC_DECIMALS) + .unwrap(), + five + ); + assert_eq!( + quote_out_for_base_in(five, bid, SCALE, base_decimals, USDC_DECIMALS).unwrap(), + 824_175_000 + ); + } + } + #[test] fn ask_ceils_and_bid_floors() { // price 999, spread 1 bps: 999 * 10_001 = 9_990_999 -> ask ceil 1_000, diff --git a/finance/prop-amm/quasar/CHANGELOG.md b/finance/prop-amm/quasar/CHANGELOG.md index b644ea5b..eb8dd6e4 100644 --- a/finance/prop-amm/quasar/CHANGELOG.md +++ b/finance/prop-amm/quasar/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## Unreleased, 2026-10-05 + +The quasar-test suite mints NVDAx with 8 decimals, its real count, and USDC +with 6, so the two mints no longer share a decimal count (`NVDAX_DECIMALS`, +`ONE_NVDAX`, `USDC_DECIMALS`, `ONE_USDC`). The walkthrough amounts are the +same: 5 NVDAx (`FIVE_NVDAX`, now 500,000,000 minor units) costs 825.825 USDC +at the ask (`FIVE_NVDAX_AT_THE_ASK`) and sells for 824.175 at the bid +(`FIVE_NVDAX_AT_THE_BID`), 850.85 at $170 and 829.125 at a 50 bps spread, all +exact, because the ask and bid have three decimal places of a dollar and 5 +is a whole number of NVDAx at any decimal count. Every refusal test asserts +its error code with `fails_with`: the program's codes for slippage, +staleness, a pre-restart price, confidence, pause, zero amounts, inventory +bounds and parameter bounds, and `QuasarError::HasOneMismatch` for the +`has_one(operator)` constraint that refuses an imposter operator. No program +source changed: the quote math reads both mints' decimals from the market and +already rounds the ask up, the bid down and both outputs down. + ## 2026-10-04 Check which program owns the price feed. `initialize_market` records the diff --git a/finance/prop-amm/quasar/README.md b/finance/prop-amm/quasar/README.md index 8ab1747a..19af3f92 100644 --- a/finance/prop-amm/quasar/README.md +++ b/finance/prop-amm/quasar/README.md @@ -37,12 +37,16 @@ Quasar version. ## Testing Tests run in-process with [`quasar-svm`](https://github.com/blueshift-gg/quasar-svm). -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, a feed account owned by another -program, pause, zero amounts, inventory bounds, and operator access control. +They build the program, set up both mints (NVDAx with 8 decimals, USDC with +6), 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, +a feed account owned by another program, pause, zero amounts, inventory +bounds, parameter bounds, and operator access control. Every refusal test +asserts its error code with `fails_with`: the program's own codes from +`instructions::shared::error`, and `QuasarError::HasOneMismatch` for the +`has_one(operator)` constraint that refuses an imposter operator. ```bash quasar build diff --git a/finance/prop-amm/quasar/src/tests.rs b/finance/prop-amm/quasar/src/tests.rs index b7d550bc..1126890c 100644 --- a/finance/prop-amm/quasar/src/tests.rs +++ b/finance/prop-amm/quasar/src/tests.rs @@ -12,13 +12,27 @@ use { state::Market, BaseVaultPda, QuoteVaultPda, }, + quasar_lang::error::QuasarError, quasar_test::prelude::*, }; -// Both tokens have 6 decimals: base is NVDAx (tokenized NVIDIA stock), quote -// is USDC. -const ONE_TOKEN: u64 = 1_000_000; -// The oracle quotes prices with 8 decimals, so $165 is 165 * 10^8. +// The base is NVDAx (tokenized NVIDIA stock), which has 8 decimals; the quote +// is USDC, which has 6. The program reads both from the mints, so nothing in +// the quote math assumes they match. +const NVDAX_DECIMALS: u8 = 8; +const ONE_NVDAX: u64 = 100_000_000; +const USDC_DECIMALS: u8 = 6; +const ONE_USDC: u64 = 1_000_000; + +// The walkthrough trade: at $165 with a 10 bps spread the ask is $165.165 and +// the bid $164.835, so 5 NVDAx costs 825.825 USDC and sells for 824.175. Both +// are exact in USDC's six decimals because the ask and bid have only three +// decimal places of a dollar, and 5 is a whole number of NVDAx whatever the +// token's decimals. +const FIVE_NVDAX: u64 = 5 * ONE_NVDAX; +const FIVE_NVDAX_AT_THE_ASK: u64 = 825_825_000; // 825.825 USDC +const FIVE_NVDAX_AT_THE_BID: u64 = 824_175_000; // 824.175 USDC + // The oracle quotes prices with 8 decimals, so $165 is 165 * 10^8. const ORACLE_SCALE: u32 = 8; const SPREAD_BPS: u16 = 10; const MAX_CONFIDENCE_BPS: u16 = 100; @@ -142,19 +156,19 @@ struct Env { /// and an initialized (but unstocked) market at `spread_bps`. fn base_world(test: &mut Test, spread_bps: u16) -> (Env, Outcome) { test.add(Wallet::new().at(OPERATOR)); - test.add(Mint::new(OPERATOR).at(BASE_MINT).decimals(6)); - test.add(Mint::new(OPERATOR).at(QUOTE_MINT).decimals(6)); + test.add(Mint::new(OPERATOR).at(BASE_MINT).decimals(NVDAX_DECIMALS)); + test.add(Mint::new(OPERATOR).at(QUOTE_MINT).decimals(USDC_DECIMALS)); set_feed(test, dollars(165), 0); set_clock(test); test.add( TokenAccount::new(BASE_MINT, OPERATOR) .at(OPERATOR_BASE) - .amount(10_000 * ONE_TOKEN), + .amount(10_000 * ONE_NVDAX), ); test.add( TokenAccount::new(QUOTE_MINT, OPERATOR) .at(OPERATOR_QUOTE) - .amount(10_000_000 * ONE_TOKEN), + .amount(10_000_000 * ONE_USDC), ); let outcome = init_market(test, spread_bps); let market = test.derive_pda(Market::seeds(&BASE_MINT, "E_MINT)); @@ -201,8 +215,8 @@ fn setup(test: &mut Test) -> Env { OPERATOR, OPERATOR_BASE, OPERATOR_QUOTE, - 1_000 * ONE_TOKEN, - 200_000 * ONE_TOKEN, + 1_000 * ONE_NVDAX, + 200_000 * ONE_USDC, ) .succeeds(); env @@ -292,8 +306,8 @@ fn initialize_market_creates_market_and_stocked_vaults(test: &mut Test) { let env = setup(test); // The market and both vaults were created, and the inventory landed. assert!(test.account(env.market).is_some()); - assert_eq!(test.tokens(env.base_vault), 1_000 * ONE_TOKEN); - assert_eq!(test.tokens(env.quote_vault), 200_000 * ONE_TOKEN); + assert_eq!(test.tokens(env.base_vault), 1_000 * ONE_NVDAX); + assert_eq!(test.tokens(env.quote_vault), 200_000 * ONE_USDC); // The market account itself is the token authority of both vaults (the // owner field is bytes 32..64 of the SPL Token account layout). let vault_owner = @@ -307,7 +321,7 @@ fn initialize_market_creates_market_and_stocked_vaults(test: &mut Test) { #[quasar_test] fn swap_buys_base_at_the_ask(test: &mut Test) { let env = setup(test); - let quote_in = 825_825_000; + let quote_in = FIVE_NVDAX_AT_THE_ASK; fund_trader(test, TRADER, TRADER_BASE, TRADER_QUOTE, 0, quote_in); swap( @@ -318,14 +332,14 @@ fn swap_buys_base_at_the_ask(test: &mut Test) { TRADER_QUOTE, DIRECTION_BUY_BASE, quote_in, - 5 * ONE_TOKEN, + FIVE_NVDAX, ) .succeeds() - .has_tokens(TRADER_BASE, 5 * ONE_TOKEN) + .has_tokens(TRADER_BASE, FIVE_NVDAX) .has_tokens(TRADER_QUOTE, 0) // Conservation: the vaults moved by exactly the two legs of the fill. - .has_tokens(env.base_vault, 995 * ONE_TOKEN) - .has_tokens(env.quote_vault, 200_000 * ONE_TOKEN + quote_in); + .has_tokens(env.base_vault, 995 * ONE_NVDAX) + .has_tokens(env.quote_vault, 200_000 * ONE_USDC + quote_in); } /// Bob sells 5 NVDAx. At $165 with a 10 bps spread the bid is $164.835, so @@ -333,7 +347,7 @@ fn swap_buys_base_at_the_ask(test: &mut Test) { #[quasar_test] fn swap_sells_base_at_the_bid(test: &mut Test) { let env = setup(test); - fund_trader(test, TRADER, TRADER_BASE, TRADER_QUOTE, 5 * ONE_TOKEN, 0); + fund_trader(test, TRADER, TRADER_BASE, TRADER_QUOTE, FIVE_NVDAX, 0); swap( test, @@ -342,12 +356,14 @@ fn swap_sells_base_at_the_bid(test: &mut Test) { TRADER_BASE, TRADER_QUOTE, DIRECTION_SELL_BASE, - 5 * ONE_TOKEN, - 824_175_000, + FIVE_NVDAX, + FIVE_NVDAX_AT_THE_BID, ) .succeeds() .has_tokens(TRADER_BASE, 0) - .has_tokens(TRADER_QUOTE, 824_175_000); + .has_tokens(TRADER_QUOTE, FIVE_NVDAX_AT_THE_BID) + .has_tokens(env.base_vault, 1_005 * ONE_NVDAX) + .has_tokens(env.quote_vault, 200_000 * ONE_USDC - FIVE_NVDAX_AT_THE_BID); } /// A buy immediately followed by a sell of the same 5 NVDAx costs exactly @@ -355,7 +371,7 @@ fn swap_sells_base_at_the_bid(test: &mut Test) { #[quasar_test] fn round_trip_costs_exactly_the_spread(test: &mut Test) { let env = setup(test); - let quote_in = 825_825_000; + let quote_in = FIVE_NVDAX_AT_THE_ASK; fund_trader(test, TRADER, TRADER_BASE, TRADER_QUOTE, 0, quote_in); swap( @@ -376,13 +392,15 @@ fn round_trip_costs_exactly_the_spread(test: &mut Test) { TRADER_BASE, TRADER_QUOTE, DIRECTION_SELL_BASE, - 5 * ONE_TOKEN, + FIVE_NVDAX, 0, ) .succeeds() .has_tokens(TRADER_BASE, 0) + // 825.825 in, 824.175 back: the market kept 1.65 USDC. .has_tokens(TRADER_QUOTE, quote_in - 1_650_000) - .has_tokens(env.quote_vault, 200_000 * ONE_TOKEN + 1_650_000); + .has_tokens(env.base_vault, 1_000 * ONE_NVDAX) + .has_tokens(env.quote_vault, 200_000 * ONE_USDC + 1_650_000); } /// When the oracle reprices, the quote follows instantly. At $170 the ask is @@ -402,10 +420,10 @@ fn quote_follows_the_oracle(test: &mut Test) { TRADER_QUOTE, DIRECTION_BUY_BASE, quote_in, - 5 * ONE_TOKEN, + FIVE_NVDAX, ) .succeeds() - .has_tokens(TRADER_BASE, 5 * ONE_TOKEN); + .has_tokens(TRADER_BASE, FIVE_NVDAX); } /// The operator re-quotes to a 50 bps spread; the next fill prices at @@ -425,10 +443,10 @@ fn set_quote_changes_the_spread(test: &mut Test) { TRADER_QUOTE, DIRECTION_BUY_BASE, quote_in, - 5 * ONE_TOKEN, + FIVE_NVDAX, ) .succeeds() - .has_tokens(TRADER_BASE, 5 * ONE_TOKEN); + .has_tokens(TRADER_BASE, FIVE_NVDAX); } /// The operator can withdraw every token in both vaults at any time — its @@ -442,48 +460,53 @@ fn operator_can_withdraw_everything_and_swaps_then_fail(test: &mut Test) { OPERATOR, OPERATOR_BASE, OPERATOR_QUOTE, - 1_000 * ONE_TOKEN, - 200_000 * ONE_TOKEN, + 1_000 * ONE_NVDAX, + 200_000 * ONE_USDC, ) .succeeds() .has_tokens(env.base_vault, 0) - .has_tokens(env.quote_vault, 0); - - fund_trader(test, TRADER, TRADER_BASE, TRADER_QUOTE, 0, 825_825_000); - assert!( - swap( - test, - &env, - TRADER, - TRADER_BASE, - TRADER_QUOTE, - DIRECTION_BUY_BASE, - 825_825_000, - 0 - ) - .is_err(), - "a swap against an empty inventory must fail" + .has_tokens(env.quote_vault, 0) + .has_tokens(OPERATOR_BASE, 10_000 * ONE_NVDAX) + .has_tokens(OPERATOR_QUOTE, 10_000_000 * ONE_USDC); + + fund_trader( + test, + TRADER, + TRADER_BASE, + TRADER_QUOTE, + 0, + FIVE_NVDAX_AT_THE_ASK, ); + swap( + test, + &env, + TRADER, + TRADER_BASE, + TRADER_QUOTE, + DIRECTION_BUY_BASE, + FIVE_NVDAX_AT_THE_ASK, + 0, + ) + .fails_with(error::INSUFFICIENT_INVENTORY); } #[quasar_test] fn withdraw_more_than_inventory_fails(test: &mut Test) { let env = setup(test); - assert!( - withdraw_inventory( - test, - &env, - OPERATOR, - OPERATOR_BASE, - OPERATOR_QUOTE, - 1_001 * ONE_TOKEN, - 0 - ) - .is_err(), - "withdrawing more than the vault holds must fail" - ); + withdraw_inventory( + test, + &env, + OPERATOR, + OPERATOR_BASE, + OPERATOR_QUOTE, + 1_001 * ONE_NVDAX, + 0, + ) + .fails_with(error::INSUFFICIENT_INVENTORY); } +/// `has_one(operator)` on the market is the whole access control, so an +/// imposter is refused by Quasar's constraint check before the handler runs. #[quasar_test] fn deposit_rejects_non_operator(test: &mut Test) { let env = setup(test); @@ -492,74 +515,62 @@ fn deposit_rejects_non_operator(test: &mut Test) { MALLORY, MALLORY_BASE, MALLORY_QUOTE, - ONE_TOKEN, - ONE_TOKEN, - ); - assert!( - deposit_inventory( - test, - &env, - MALLORY, - MALLORY_BASE, - MALLORY_QUOTE, - ONE_TOKEN, - 0 - ) - .is_err(), - "deposit_inventory must reject a non-operator signer" + ONE_NVDAX, + ONE_USDC, ); + deposit_inventory( + test, + &env, + MALLORY, + MALLORY_BASE, + MALLORY_QUOTE, + ONE_NVDAX, + 0, + ) + .fails_with(QuasarError::HasOneMismatch); } #[quasar_test] fn withdraw_rejects_non_operator(test: &mut Test) { let env = setup(test); fund_trader(test, MALLORY, MALLORY_BASE, MALLORY_QUOTE, 0, 0); - assert!( - withdraw_inventory( - test, - &env, - MALLORY, - MALLORY_BASE, - MALLORY_QUOTE, - ONE_TOKEN, - 0 - ) - .is_err(), - "withdraw_inventory must reject a non-operator signer" - ); + withdraw_inventory( + test, + &env, + MALLORY, + MALLORY_BASE, + MALLORY_QUOTE, + ONE_NVDAX, + 0, + ) + .fails_with(QuasarError::HasOneMismatch); } #[quasar_test] fn set_quote_rejects_non_operator(test: &mut Test) { setup(test); test.add(Wallet::new().at(MALLORY)); - assert!( - set_quote(test, MALLORY, 500, 1).is_err(), - "set_quote must reject a non-operator signer" - ); + set_quote(test, MALLORY, 500, 1).fails_with(QuasarError::HasOneMismatch); } /// A fill below the caller's minimum is rejected, not filled worse. #[quasar_test] fn swap_rejects_slippage(test: &mut Test) { let env = setup(test); - let quote_in = 825_825_000; + let quote_in = FIVE_NVDAX_AT_THE_ASK; fund_trader(test, TRADER, TRADER_BASE, TRADER_QUOTE, 0, quote_in); // The fill would be exactly 5 NVDAx; demand one minor unit more. - assert!( - swap( - test, - &env, - TRADER, - TRADER_BASE, - TRADER_QUOTE, - DIRECTION_BUY_BASE, - quote_in, - 5 * ONE_TOKEN + 1 - ) - .is_err(), - "a fill below minimum_amount_out must be rejected" - ); + swap( + test, + &env, + TRADER, + TRADER_BASE, + TRADER_QUOTE, + DIRECTION_BUY_BASE, + quote_in, + FIVE_NVDAX + 1, + ) + .fails_with(error::SLIPPAGE_EXCEEDED); } /// An oracle price older than the staleness bound cannot be traded against: @@ -568,21 +579,25 @@ fn swap_rejects_slippage(test: &mut Test) { fn swap_rejects_stale_price(test: &mut Test) { let env = setup(test); make_price_stale(test); - fund_trader(test, TRADER, TRADER_BASE, TRADER_QUOTE, 0, 825_825_000); - assert!( - swap( - test, - &env, - TRADER, - TRADER_BASE, - TRADER_QUOTE, - DIRECTION_BUY_BASE, - 825_825_000, - 0 - ) - .is_err(), - "a stale oracle price must be rejected" + fund_trader( + test, + TRADER, + TRADER_BASE, + TRADER_QUOTE, + 0, + FIVE_NVDAX_AT_THE_ASK, ); + swap( + test, + &env, + TRADER, + TRADER_BASE, + TRADER_QUOTE, + DIRECTION_BUY_BASE, + FIVE_NVDAX_AT_THE_ASK, + 0, + ) + .fails_with(error::STALE_PRICE); } /// A cluster restart passes hours of wall-clock time in zero slots, so a @@ -591,27 +606,31 @@ fn swap_rejects_stale_price(test: &mut Test) { #[quasar_test] fn swap_rejects_price_from_before_a_restart(test: &mut Test) { let env = setup(test); - fund_trader(test, TRADER, TRADER_BASE, TRADER_QUOTE, 0, 825_825_000 * 2); + fund_trader( + test, + TRADER, + TRADER_BASE, + TRADER_QUOTE, + 0, + FIVE_NVDAX_AT_THE_ASK * 2, + ); // The feed is stamped at `SLOT - 5`: fresh by the 150-slot staleness // bound, but published before a restart at `SLOT - 3`, so only the // restart check can catch it. set_feed_at_slot(test, dollars(165), SLOT - 5, 0); set_last_restart_slot(test, SLOT - 3); - assert!( - swap( - test, - &env, - TRADER, - TRADER_BASE, - TRADER_QUOTE, - DIRECTION_BUY_BASE, - 825_825_000, - 0 - ) - .is_err(), - "a pre-restart price must be rejected even inside the staleness bound" - ); + swap( + test, + &env, + TRADER, + TRADER_BASE, + TRADER_QUOTE, + DIRECTION_BUY_BASE, + FIVE_NVDAX_AT_THE_ASK, + 0, + ) + .fails_with(error::PRICE_PREDATES_RESTART); // Publishing after the restart (at `SLOT`) reopens the market. set_feed(test, dollars(165), 0); @@ -622,7 +641,7 @@ fn swap_rejects_price_from_before_a_restart(test: &mut Test) { TRADER_BASE, TRADER_QUOTE, DIRECTION_BUY_BASE, - 825_825_000, + FIVE_NVDAX_AT_THE_ASK, 0, ) .succeeds(); @@ -640,7 +659,14 @@ fn swap_rejects_price_feed_from_another_program(test: &mut Test) { test.read::(env.market).price_feed_program, system_program::ID ); - fund_trader(test, TRADER, TRADER_BASE, TRADER_QUOTE, 0, 825_825_000); + fund_trader( + test, + TRADER, + TRADER_BASE, + TRADER_QUOTE, + 0, + FIVE_NVDAX_AT_THE_ASK, + ); set_feed_owned_by(test, OTHER_PROGRAM, dollars(165), SLOT, 0); swap( @@ -650,7 +676,7 @@ fn swap_rejects_price_feed_from_another_program(test: &mut Test) { TRADER_BASE, TRADER_QUOTE, DIRECTION_BUY_BASE, - 825_825_000, + FIVE_NVDAX_AT_THE_ASK, 0, ) .fails_with(error::PRICE_FEED_NOT_FROM_ORACLE); @@ -663,7 +689,7 @@ fn swap_rejects_price_feed_from_another_program(test: &mut Test) { TRADER_BASE, TRADER_QUOTE, DIRECTION_BUY_BASE, - 825_825_000, + FIVE_NVDAX_AT_THE_ASK, 0, ) .succeeds(); @@ -675,21 +701,25 @@ fn swap_rejects_price_feed_from_another_program(test: &mut Test) { fn swap_rejects_wide_confidence(test: &mut Test) { let env = setup(test); set_feed(test, dollars(165), 200_000_000); - fund_trader(test, TRADER, TRADER_BASE, TRADER_QUOTE, 0, 825_825_000); - assert!( - swap( - test, - &env, - TRADER, - TRADER_BASE, - TRADER_QUOTE, - DIRECTION_BUY_BASE, - 825_825_000, - 0 - ) - .is_err(), - "a confidence band wider than max_confidence_bps must be rejected" + fund_trader( + test, + TRADER, + TRADER_BASE, + TRADER_QUOTE, + 0, + FIVE_NVDAX_AT_THE_ASK, ); + swap( + test, + &env, + TRADER, + TRADER_BASE, + TRADER_QUOTE, + DIRECTION_BUY_BASE, + FIVE_NVDAX_AT_THE_ASK, + 0, + ) + .fails_with(error::ORACLE_CONFIDENCE_TOO_WIDE); } /// While the operator has pulled its quotes, nobody can swap; unpausing @@ -699,21 +729,25 @@ fn swap_rejects_when_paused(test: &mut Test) { let env = setup(test); set_quote(test, OPERATOR, SPREAD_BPS, 1).succeeds(); - fund_trader(test, TRADER, TRADER_BASE, TRADER_QUOTE, 0, 825_825_000); - assert!( - swap( - test, - &env, - TRADER, - TRADER_BASE, - TRADER_QUOTE, - DIRECTION_BUY_BASE, - 825_825_000, - 0 - ) - .is_err(), - "a paused market must reject swaps" + fund_trader( + test, + TRADER, + TRADER_BASE, + TRADER_QUOTE, + 0, + FIVE_NVDAX_AT_THE_ASK, ); + swap( + test, + &env, + TRADER, + TRADER_BASE, + TRADER_QUOTE, + DIRECTION_BUY_BASE, + FIVE_NVDAX_AT_THE_ASK, + 0, + ) + .fails_with(error::MARKET_PAUSED); set_quote(test, OPERATOR, SPREAD_BPS, 0).succeeds(); swap( @@ -723,8 +757,8 @@ fn swap_rejects_when_paused(test: &mut Test) { TRADER_BASE, TRADER_QUOTE, DIRECTION_BUY_BASE, - 825_825_000, - 5 * ONE_TOKEN, + FIVE_NVDAX_AT_THE_ASK, + FIVE_NVDAX, ) .succeeds(); } @@ -732,21 +766,18 @@ fn swap_rejects_when_paused(test: &mut Test) { #[quasar_test] fn swap_rejects_zero_amount(test: &mut Test) { let env = setup(test); - fund_trader(test, TRADER, TRADER_BASE, TRADER_QUOTE, 0, ONE_TOKEN); - assert!( - swap( - test, - &env, - TRADER, - TRADER_BASE, - TRADER_QUOTE, - DIRECTION_BUY_BASE, - 0, - 0 - ) - .is_err(), - "a zero-amount swap must be rejected" - ); + fund_trader(test, TRADER, TRADER_BASE, TRADER_QUOTE, 0, ONE_USDC); + swap( + test, + &env, + TRADER, + TRADER_BASE, + TRADER_QUOTE, + DIRECTION_BUY_BASE, + 0, + 0, + ) + .fails_with(error::ZERO_AMOUNT); } /// A buy bigger than the base inventory is rejected whole — a prop AMM never @@ -758,37 +789,34 @@ fn swap_rejects_insufficient_inventory(test: &mut Test) { // but the vault only holds 1,000 NVDAx. let quote_in = 181_681_500_000; fund_trader(test, TRADER, TRADER_BASE, TRADER_QUOTE, 0, quote_in); - assert!( - swap( - test, - &env, - TRADER, - TRADER_BASE, - TRADER_QUOTE, - DIRECTION_BUY_BASE, - quote_in, - 0 - ) - .is_err(), - "a swap larger than the inventory must be rejected" - ); + swap( + test, + &env, + TRADER, + TRADER_BASE, + TRADER_QUOTE, + DIRECTION_BUY_BASE, + quote_in, + 0, + ) + .fails_with(error::INSUFFICIENT_INVENTORY); } #[quasar_test] fn initialize_market_rejects_zero_spread(test: &mut Test) { let (_env, outcome) = base_world(test, 0); - assert!(outcome.is_err(), "a zero spread must be rejected"); + outcome.fails_with(error::INVALID_PARAMETER); } #[quasar_test] fn initialize_market_rejects_full_spread(test: &mut Test) { let (_env, outcome) = base_world(test, 10_000); - assert!(outcome.is_err(), "a 100% spread must be rejected"); + outcome.fails_with(error::INVALID_PARAMETER); } #[quasar_test] fn set_quote_rejects_invalid_spread(test: &mut Test) { setup(test); - assert!(set_quote(test, OPERATOR, 0, 0).is_err()); - assert!(set_quote(test, OPERATOR, 10_000, 0).is_err()); + set_quote(test, OPERATOR, 0, 0).fails_with(error::INVALID_PARAMETER); + set_quote(test, OPERATOR, 10_000, 0).fails_with(error::INVALID_PARAMETER); } diff --git a/finance/token-swap/README.md b/finance/token-swap/README.md index bcb2efc2..303124f2 100644 --- a/finance/token-swap/README.md +++ b/finance/token-swap/README.md @@ -106,7 +106,7 @@ Initializes the singleton `Config` account with the supplied `admin`, `fee`, and Initializes a `PoolConfig` account, an LP mint (`liquidity_provider_mint`) whose mint authority is `pool_config`, and the two pool reserve token accounts (`pool_a`, `pool_b`), the associated token accounts of `pool_config`, then takes the creator's first deposit: `amount_a` of token A and `amount_b` of token B move from the creator's token accounts into the reserves, and the creator's LP tokens are minted to their LP token account, which the handler also creates. Enforces `mint_a < mint_b` for canonical pool addressing. - The first deposit sets the pool's price (the ratio of its reserves), so both amounts must be nonzero; a zero on either side fails with `EmptyInitialDeposit` and nothing is created. Taking the deposit in the same instruction means no pool ever exists empty, so nobody can set a pool's price for its creator by depositing first. `test_pool_creation_cannot_be_front_run` runs the attack: a deposit at a hostile ratio right after the pool opens is clamped to the creator's price, and a deposit against an empty reserve fails with `EmptyPoolReserve`. -- The creator is minted `sqrt(amount_a * amount_b) - MINIMUM_LIQUIDITY` LP tokens (`liquidity::initial_lp_amount`), computed with a `u128` integer-sqrt (Newton's method), no floats. If `sqrt(amount_a * amount_b)` is below `MINIMUM_LIQUIDITY`, the handler fails with `DepositTooSmall`. The floor is never minted to anyone; from then on `deposit_liquidity` and `withdraw_liquidity` divide by `supply + MINIMUM_LIQUIDITY`, so its share of the reserves stays in the pool and no withdrawal can empty a reserve. +- The creator is minted `sqrt(amount_a * amount_b) - MINIMUM_LIQUIDITY` LP tokens (`liquidity::initial_lp_amount`), computed with a `u128` integer-sqrt (Newton's method), no floats. If `sqrt(amount_a * amount_b)` is at or below `MINIMUM_LIQUIDITY`, the handler fails with `DepositTooSmall`, so the smallest pool that opens leaves its creator at least 1 LP token (`test_initialize_pool_rejects_sqrt_equal_to_floor` checks both sides of the boundary). The floor is never minted to anyone; from then on `deposit_liquidity` and `withdraw_liquidity` divide by `supply + MINIMUM_LIQUIDITY`, so its share of the reserves stays in the pool and no withdrawal can empty a reserve. - The transfers and the LP mint are the same code `deposit_liquidity` ends with (`liquidity::deposit_and_mint_lp_tokens`); only the arithmetic that decides the amounts differs. ### `deposit_liquidity` @@ -128,11 +128,11 @@ Transfers token A and token B from the depositor to the pool, then mints LP toke Swaps a fixed `input_amount` of one token for as much of the other as possible (subject to `min_output_amount`). The `input_is_token_a` flag selects the input side (`true` = trader sends token A and receives token B; `false` = the reverse). -- The total trading fee is taken off the input first: `fee_amount = input * fee / 10_000`. +- The total trading fee is taken off the input first: `fee_amount = input * fee / 10_000`, rounded up, so a fee that is not a whole number of minor units costs the trader one more unit rather than the pool one less. On 100 USDC at 30 bps the fee is exactly 300,000 minor units; on 1,000,001 minor units at 500 bps it is 50,000.05, charged as 50,001 (`test_swap_fee_rounds_up`). - The fee is split between LPs and the admin: - - `admin_portion = fee_amount * admin_share_bps / 10_000` - accumulates as a virtual claim on the input-side reserve (`admin_fees_owed_a` or `admin_fees_owed_b`). Not transferred immediately, swept later by `claim_admin_fees`. Saves a CPI per swap. + - `admin_portion = fee_amount * admin_share_bps / 10_000`, rounded up against the LPs (300,000 at 1667 bps is exactly 50,010; 25,000 is 4,167.5, owed as 4,168) - accumulates as a virtual claim on the input-side reserve (`admin_fees_owed_a` or `admin_fees_owed_b`). Not transferred immediately, swept later by `claim_admin_fees`. Saves a CPI per swap. - `lp_portion = fee_amount - admin_portion` - stays physically in the reserves and boosts LP yield ("less output for the same input"). -- `taxed_input = input - fee_amount` is what enters the curve. +- `taxed_input = input - fee_amount`, the trader's side after the rounded-up fee, is what enters the curve. - The output is computed against the **effective reserves** (`pool_X.amount - admin_fees_owed_X`), so the admin's outstanding fees do not contribute to the price. The curve math runs in `u128` with checked arithmetic, multiplying before dividing to keep precision; floor rounding favours the pool (Uniswap V2 convention). - The [price impact](https://www.investopedia.com/terms/p/price-impact.asp) of a swap - the difference between the quoted mid-price and the effective execution price - is determined by the size of the trade relative to the pool's effective reserves. Larger trades move the curve further, resulting in higher price impact. - If `output < min_output_amount`, the handler reverts with `SlippageExceeded`. This is the trader's slippage guard for cases where the pool shifted between quote time and tx landing. @@ -267,7 +267,7 @@ Math (constant product, 0.3% fee from `Config.fee`, fee split per `Config.admin_ - Total fee on the input: `11 × 0.003 = 0.033 USDC`. - Fee split: - - Admin slice (`admin_share_bps = 1667`): `0.033 × 0.1667 ≈ 0.0055 USDC` - added to `admin_fees_owed_b`. + - Admin slice (`admin_share_bps = 1667`): `0.033 × 0.1667 ≈ 0.0055 USDC` - added to `admin_fees_owed_b`. In minor units that is `33,000 × 1667 / 10,000 = 5,501.1`, owed as 5,502: the admin's slice rounds up against the LPs. - LP slice: `0.033 − 0.0055 ≈ 0.0275 USDC` - stays in the reserves, boosts LP yield. - Input into the curve: `11 − 0.033 = 10.967 USDC`. - Effective reserves before the trade: `effective_pool_a = 120`, `effective_pool_b = 600` (admin owes nothing yet). diff --git a/finance/token-swap/anchor-v1/CHANGELOG.md b/finance/token-swap/anchor-v1/CHANGELOG.md index 4222fc83..070e782a 100644 --- a/finance/token-swap/anchor-v1/CHANGELOG.md +++ b/finance/token-swap/anchor-v1/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## Unreleased (2026-10-05) + +`swap_tokens` rounds the trading fee up: `fee_amount` is `input * fee / +10_000` rounded to the next whole minor unit, and the admin's slice of it, +`fee_amount * admin_share_bps / 10_000`, rounds up the same way, so a fee +that is not a whole number of minor units costs the trader one unit more +rather than the pool one unit less, and the admin's share rounds against the +LPs. The trader's side, `taxed_input`, is the input minus the rounded-up fee. +A 500 bps fee on 1_000_001 minor units is 50_000.05, charged as 50_001, and +the admin's 1667 bps of a 25_000 fee is 4_167.5, owed as 4_168 +(`test_swap_fee_rounds_up`, and `test_claim_admin_fees` asserts the exact +amounts). The Kani harness `proof_fee_split_bounds` checks the rounding +direction. + +`initialize_pool` refuses a deposit whose square root equals +`MINIMUM_LIQUIDITY` as well as one below it, so the smallest pool that opens +leaves its creator at least 1 LP token: a pool opened with `sqrt(100 * 100)` +would mint its creator nothing. `test_initialize_pool_rejects_sqrt_equal_to_floor` +checks both sides of the boundary. + ## Unreleased (2026-10-04) `initialize_pool` now takes the creator's first deposit: it gains `amount_a` diff --git a/finance/token-swap/anchor-v1/programs/token-swap/src/errors.rs b/finance/token-swap/anchor-v1/programs/token-swap/src/errors.rs index 842a72e3..66575af1 100644 --- a/finance/token-swap/anchor-v1/programs/token-swap/src/errors.rs +++ b/finance/token-swap/anchor-v1/programs/token-swap/src/errors.rs @@ -12,10 +12,10 @@ pub enum AmmError { #[msg("Admin share must be less than 10000 basis points")] AdminShareTooHigh, - // Returned by `initialize_pool` when `sqrt(amount_a * amount_b)` is below - // `MINIMUM_LIQUIDITY`, so withholding the floor would leave the creator - // nothing, and by `deposit_liquidity` when a later deposit is too small a - // share of the pool to mint a single LP token. + // Returned by `initialize_pool` when `sqrt(amount_a * amount_b)` is at or + // below `MINIMUM_LIQUIDITY`, so withholding the floor would leave the + // creator nothing, and by `deposit_liquidity` when a later deposit is too + // small a share of the pool to mint a single LP token. #[msg("Depositing too little liquidity")] DepositTooSmall, diff --git a/finance/token-swap/anchor-v1/programs/token-swap/src/instructions/swap_tokens.rs b/finance/token-swap/anchor-v1/programs/token-swap/src/instructions/swap_tokens.rs index 852e214e..d9027fbf 100644 --- a/finance/token-swap/anchor-v1/programs/token-swap/src/instructions/swap_tokens.rs +++ b/finance/token-swap/anchor-v1/programs/token-swap/src/instructions/swap_tokens.rs @@ -35,24 +35,25 @@ pub fn handle_swap_tokens( // // u128 + checked arithmetic: `input * fee` can overflow u64 (both are // u64-sized in practice; fee is u16 but the multiplication grows fast). - // Multiply before divide to preserve precision; floor on the divide is - // program-favouring (the trader pays slightly more fee on rounding, - // not less). + // Multiply before divide to preserve precision. Both divisions round up: + // a fee that is not a whole number of minor units costs the trader one + // more unit rather than the pool one less, and the admin's slice of it + // rounds up against the LPs for the same reason. The trader's side, + // `taxed_input`, is what remains after the ceiled fee. let config = &context.accounts.config; let fee_amount = (input_amount as u128) .checked_mul(config.fee as u128) .ok_or(AmmError::MathOverflow)? - .checked_div(BASIS_POINTS_DIVISOR as u128) - .ok_or(AmmError::MathOverflow)?; + .div_ceil(BASIS_POINTS_DIVISOR as u128); let admin_portion = fee_amount .checked_mul(config.admin_share_bps as u128) .ok_or(AmmError::MathOverflow)? - .checked_div(BASIS_POINTS_DIVISOR as u128) - .ok_or(AmmError::MathOverflow)?; - // Narrow back to u64 for storage / transfer. The fee can never exceed - // `input` (`fee_amount <= input * 9999 / 10_000 < input`, and `input` - // is u64), so the cast is safe - but use try_into anyway to make the - // invariant explicit in the type system. + .div_ceil(BASIS_POINTS_DIVISOR as u128); + // Narrow back to u64 for storage / transfer. `fee < 10_000`, so + // `input * fee < input * 10_000` and the ceiling of the quotient is at + // most `input`, a u64; the admin portion is at most the fee by the same + // argument. The casts cannot fail, but `try_from` keeps the bound + // explicit in the type system. let fee_amount: u64 = u64::try_from(fee_amount).map_err(|_| AmmError::MathOverflow)?; let admin_portion: u64 = u64::try_from(admin_portion).map_err(|_| AmmError::MathOverflow)?; // The LP portion stays in the pool reserves (as today - it's "less output diff --git a/finance/token-swap/anchor-v1/programs/token-swap/src/liquidity.rs b/finance/token-swap/anchor-v1/programs/token-swap/src/liquidity.rs index dfd1bc18..4dc6fb89 100644 --- a/finance/token-swap/anchor-v1/programs/token-swap/src/liquidity.rs +++ b/finance/token-swap/anchor-v1/programs/token-swap/src/liquidity.rs @@ -30,7 +30,9 @@ pub fn integer_sqrt(n: u128) -> u128 { } /// LP tokens minted to the creator for the deposit that opens a pool: -/// `sqrt(amount_a * amount_b) - MINIMUM_LIQUIDITY`. +/// `sqrt(amount_a * amount_b) - MINIMUM_LIQUIDITY`. A deposit whose square +/// root is at or below `MINIMUM_LIQUIDITY` fails with `DepositTooSmall`, so +/// the smallest pool that opens leaves its creator at least 1 LP token. /// /// The `MINIMUM_LIQUIDITY` floor is never minted to anyone. From then on /// `deposit_liquidity` and `withdraw_liquidity` divide by @@ -50,7 +52,7 @@ pub fn initial_lp_amount(amount_a: u64, amount_b: u64) -> Result { .checked_mul(amount_b as u128) .ok_or(AmmError::MathOverflow)?; let sqrt_product = u64::try_from(integer_sqrt(product)).map_err(|_| AmmError::MathOverflow)?; - if sqrt_product < MINIMUM_LIQUIDITY { + if sqrt_product <= MINIMUM_LIQUIDITY { return err!(AmmError::DepositTooSmall); } sqrt_product diff --git a/finance/token-swap/anchor-v1/programs/token-swap/tests/test_swap.rs b/finance/token-swap/anchor-v1/programs/token-swap/tests/test_swap.rs index d60b1e62..d43718f4 100644 --- a/finance/token-swap/anchor-v1/programs/token-swap/tests/test_swap.rs +++ b/finance/token-swap/anchor-v1/programs/token-swap/tests/test_swap.rs @@ -18,6 +18,22 @@ use { /// supply. const MINIMUM_LIQUIDITY: u64 = 100; +/// Mirrors `constants::BASIS_POINTS_DIVISOR`. +const BASIS_POINTS_DIVISOR: u64 = 10_000; + +/// The swap fee as `swap_tokens` computes it: `input * fee_bps / 10_000`, +/// rounded up, so a fee that is not a whole number of minor units costs the +/// trader one more unit. +fn ceiled_fee(input: u64, fee_bps: u64) -> u64 { + (input * fee_bps).div_ceil(BASIS_POINTS_DIVISOR) +} + +/// The admin's slice of a fee as `swap_tokens` computes it: +/// `fee * admin_share_bps / 10_000`, rounded up against the LPs. +fn ceiled_admin_portion(fee: u64, admin_share_bps: u64) -> u64 { + (fee * admin_share_bps).div_ceil(BASIS_POINTS_DIVISOR) +} + fn token_program_id() -> Pubkey { "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" .parse() @@ -700,15 +716,17 @@ fn swap_a_to_b(ts: &mut TestSetup, input_amount: u64) { fn test_claim_admin_fees() { let mut ts = setup_pool(4_000_000, 1_000_000); // fee = 500 bps (5%), admin_share_bps = 1667 (~1/6). - // Per the swap: fee_amount = input * 500 / 10_000 = input * 5 / 100 - // admin_portion = fee_amount * 1667 / 10_000 + // Per the swap: fee_amount = ceil(input * 500 / 10_000) + // admin_portion = ceil(fee_amount * 1667 / 10_000) // Swap input is on the A side, so admin's claim accumulates in mint A. + // 1_000_000 in: a 50_000 fee, of which 50_000 * 1667 / 10_000 = 8_335 + // exactly is the admin's. let swap_in = 1_000_000u64; swap_a_to_b(&mut ts, swap_in); - let fee_amount = swap_in * 500 / 10_000; - let expected_admin_a = fee_amount * 1667 / 10_000; - assert!(expected_admin_a > 0, "expected admin portion > 0"); + let fee_amount = ceiled_fee(swap_in, 500); + let expected_admin_a = ceiled_admin_portion(fee_amount, 1667); + assert_eq!(expected_admin_a, 8_335); // ---- Phase 1: first claim transfers the accumulated A-side fees ---- let admin_balance_a_before = get_token_account_balance(&ts.svm, &ts.holder_account_a).unwrap(); @@ -741,11 +759,13 @@ fn test_claim_admin_fees() { // This proves the accumulators were truly reset (not just zeroed in // memory): a fresh swap accrues new fees from a clean baseline, and the // next claim transfers exactly that new amount. + // 500_000 in: a 25_000 fee, of which 25_000 * 1667 / 10_000 = 4_167.5 + // is the admin's, rounded up to 4_168 against the LPs. let swap_in_2 = 500_000u64; swap_a_to_b(&mut ts, swap_in_2); - let fee_amount_2 = swap_in_2 * 500 / 10_000; - let expected_admin_a_2 = fee_amount_2 * 1667 / 10_000; - assert!(expected_admin_a_2 > 0, "expected second admin portion > 0"); + let fee_amount_2 = ceiled_fee(swap_in_2, 500); + let expected_admin_a_2 = ceiled_admin_portion(fee_amount_2, 1667); + assert_eq!(expected_admin_a_2, 4_168); let balance_a_pre_claim_2 = get_token_account_balance(&ts.svm, &ts.holder_account_a).unwrap(); @@ -1016,7 +1036,8 @@ fn test_deposit_after_swap_uses_shifted_effective_ratio() { // Opened at 1:1 so the post-swap ratio is dramatic and easy to // sanity-check. - // Swap 1M of A in. With fee = 500 bps and admin_share = 1667 bps: + // Swap 1M of A in. With fee = 500 bps and admin_share = 1667 bps, both + // rounded up (exact here): // fee_amount = 50_000 // admin_portion = 50_000 * 1667 / 10_000 = 8_335 (accrues on side A) // taxed_input = 950_000 @@ -1723,3 +1744,58 @@ fn test_invariant_holds_after_normal_swap() { before={k_before}, after={k_after}" ); } + +/// The floor boundary. A deposit whose square root is exactly +/// `MINIMUM_LIQUIDITY` would open a pool and mint its creator nothing, so it +/// is refused with `DepositTooSmall` and nothing is created; one unit above +/// it opens the pool and leaves the creator 1 LP token. +#[test] +fn test_initialize_pool_rejects_sqrt_equal_to_floor() { + let mut ts = setup_config_and_mints(); + // sqrt(100 * 100) = 100 = MINIMUM_LIQUIDITY. + assert_error_code( + send_initialize_pool(&mut ts, MINIMUM_LIQUIDITY, MINIMUM_LIQUIDITY), + "DepositTooSmall", + ); + assert!(ts.svm.get_account(&ts.pool_config_key).is_none()); + + // sqrt(101 * 101) = 101: the smallest pool that opens. + send_initialize_pool(&mut ts, MINIMUM_LIQUIDITY + 1, MINIMUM_LIQUIDITY + 1) + .expect("a square root one above the floor opens the pool"); + assert_eq!( + get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(), + 1 + ); +} + +/// The fee rounds against the trader. 1_000_001 of A at 500 bps is a fee of +/// 50_000.05, charged as 50_001; the admin's 1667 bps of that is 8_335.17, +/// owed as 8_336. The curve prices the 950_000 that remain: +/// `950_000 * 1_000_000 / (4_000_000 + 950_000) = 191_919`. +#[test] +fn test_swap_fee_rounds_up() { + let mut ts = setup_pool(4_000_000, 1_000_000); + let holder_b_before = get_token_account_balance(&ts.svm, &ts.holder_account_b).unwrap(); + + let input = 1_000_001u64; + assert_eq!(ceiled_fee(input, 500), 50_001); + assert_eq!(ceiled_admin_portion(50_001, 1667), 8_336); + swap_a_to_b(&mut ts, input); + + let admin_owed_a: u64 = { + let account = ts.svm.get_account(&ts.pool_config_key).unwrap(); + let start = 8 + 32 * 3; + u64::from_le_bytes(account.data[start..start + 8].try_into().unwrap()) + }; + assert_eq!(admin_owed_a, 8_336, "the admin's slice rounds up"); + assert_eq!( + get_token_account_balance(&ts.svm, &ts.holder_account_b).unwrap() - holder_b_before, + 191_919, + "the output is priced from the input minus the rounded-up fee" + ); + assert_eq!( + get_token_account_balance(&ts.svm, &ts.pool_a).unwrap(), + 4_000_000 + input, + "the trader pays the whole input; the fee is taken from it" + ); +} diff --git a/finance/token-swap/anchor/CHANGELOG.md b/finance/token-swap/anchor/CHANGELOG.md index 4222fc83..070e782a 100644 --- a/finance/token-swap/anchor/CHANGELOG.md +++ b/finance/token-swap/anchor/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## Unreleased (2026-10-05) + +`swap_tokens` rounds the trading fee up: `fee_amount` is `input * fee / +10_000` rounded to the next whole minor unit, and the admin's slice of it, +`fee_amount * admin_share_bps / 10_000`, rounds up the same way, so a fee +that is not a whole number of minor units costs the trader one unit more +rather than the pool one unit less, and the admin's share rounds against the +LPs. The trader's side, `taxed_input`, is the input minus the rounded-up fee. +A 500 bps fee on 1_000_001 minor units is 50_000.05, charged as 50_001, and +the admin's 1667 bps of a 25_000 fee is 4_167.5, owed as 4_168 +(`test_swap_fee_rounds_up`, and `test_claim_admin_fees` asserts the exact +amounts). The Kani harness `proof_fee_split_bounds` checks the rounding +direction. + +`initialize_pool` refuses a deposit whose square root equals +`MINIMUM_LIQUIDITY` as well as one below it, so the smallest pool that opens +leaves its creator at least 1 LP token: a pool opened with `sqrt(100 * 100)` +would mint its creator nothing. `test_initialize_pool_rejects_sqrt_equal_to_floor` +checks both sides of the boundary. + ## Unreleased (2026-10-04) `initialize_pool` now takes the creator's first deposit: it gains `amount_a` diff --git a/finance/token-swap/anchor/programs/token-swap/src/errors.rs b/finance/token-swap/anchor/programs/token-swap/src/errors.rs index 842a72e3..66575af1 100644 --- a/finance/token-swap/anchor/programs/token-swap/src/errors.rs +++ b/finance/token-swap/anchor/programs/token-swap/src/errors.rs @@ -12,10 +12,10 @@ pub enum AmmError { #[msg("Admin share must be less than 10000 basis points")] AdminShareTooHigh, - // Returned by `initialize_pool` when `sqrt(amount_a * amount_b)` is below - // `MINIMUM_LIQUIDITY`, so withholding the floor would leave the creator - // nothing, and by `deposit_liquidity` when a later deposit is too small a - // share of the pool to mint a single LP token. + // Returned by `initialize_pool` when `sqrt(amount_a * amount_b)` is at or + // below `MINIMUM_LIQUIDITY`, so withholding the floor would leave the + // creator nothing, and by `deposit_liquidity` when a later deposit is too + // small a share of the pool to mint a single LP token. #[msg("Depositing too little liquidity")] DepositTooSmall, diff --git a/finance/token-swap/anchor/programs/token-swap/src/instructions/swap_tokens.rs b/finance/token-swap/anchor/programs/token-swap/src/instructions/swap_tokens.rs index 22d2d61e..eafff18d 100644 --- a/finance/token-swap/anchor/programs/token-swap/src/instructions/swap_tokens.rs +++ b/finance/token-swap/anchor/programs/token-swap/src/instructions/swap_tokens.rs @@ -35,24 +35,25 @@ pub fn handle_swap_tokens( // // u128 + checked arithmetic: `input * fee` can overflow u64 (both are // u64-sized in practice; fee is u16 but the multiplication grows fast). - // Multiply before divide to preserve precision; floor on the divide is - // program-favouring (the trader pays slightly more fee on rounding, - // not less). + // Multiply before divide to preserve precision. Both divisions round up: + // a fee that is not a whole number of minor units costs the trader one + // more unit rather than the pool one less, and the admin's slice of it + // rounds up against the LPs for the same reason. The trader's side, + // `taxed_input`, is what remains after the ceiled fee. let config = &context.accounts.config; let fee_amount = (input_amount as u128) .checked_mul(config.fee as u128) .ok_or(AmmError::MathOverflow)? - .checked_div(BASIS_POINTS_DIVISOR as u128) - .ok_or(AmmError::MathOverflow)?; + .div_ceil(BASIS_POINTS_DIVISOR as u128); let admin_portion = fee_amount .checked_mul(config.admin_share_bps as u128) .ok_or(AmmError::MathOverflow)? - .checked_div(BASIS_POINTS_DIVISOR as u128) - .ok_or(AmmError::MathOverflow)?; - // Narrow back to u64 for storage / transfer. The fee can never exceed - // `input` (`fee_amount <= input * 9999 / 10_000 < input`, and `input` - // is u64), so the cast is safe - but use try_into anyway to make the - // invariant explicit in the type system. + .div_ceil(BASIS_POINTS_DIVISOR as u128); + // Narrow back to u64 for storage / transfer. `fee < 10_000`, so + // `input * fee < input * 10_000` and the ceiling of the quotient is at + // most `input`, a u64; the admin portion is at most the fee by the same + // argument. The casts cannot fail, but `try_from` keeps the bound + // explicit in the type system. let fee_amount: u64 = u64::try_from(fee_amount).map_err(|_| AmmError::MathOverflow)?; let admin_portion: u64 = u64::try_from(admin_portion).map_err(|_| AmmError::MathOverflow)?; // The LP portion stays in the pool reserves (as today - it's "less output diff --git a/finance/token-swap/anchor/programs/token-swap/src/liquidity.rs b/finance/token-swap/anchor/programs/token-swap/src/liquidity.rs index 026da3ca..f9de5b0b 100644 --- a/finance/token-swap/anchor/programs/token-swap/src/liquidity.rs +++ b/finance/token-swap/anchor/programs/token-swap/src/liquidity.rs @@ -30,7 +30,9 @@ pub fn integer_sqrt(n: u128) -> u128 { } /// LP tokens minted to the creator for the deposit that opens a pool: -/// `sqrt(amount_a * amount_b) - MINIMUM_LIQUIDITY`. +/// `sqrt(amount_a * amount_b) - MINIMUM_LIQUIDITY`. A deposit whose square +/// root is at or below `MINIMUM_LIQUIDITY` fails with `DepositTooSmall`, so +/// the smallest pool that opens leaves its creator at least 1 LP token. /// /// The `MINIMUM_LIQUIDITY` floor is never minted to anyone. From then on /// `deposit_liquidity` and `withdraw_liquidity` divide by @@ -50,7 +52,7 @@ pub fn initial_lp_amount(amount_a: u64, amount_b: u64) -> Result { .checked_mul(amount_b as u128) .ok_or(AmmError::MathOverflow)?; let sqrt_product = u64::try_from(integer_sqrt(product)).map_err(|_| AmmError::MathOverflow)?; - if sqrt_product < MINIMUM_LIQUIDITY { + if sqrt_product <= MINIMUM_LIQUIDITY { return err!(AmmError::DepositTooSmall); } let lp_amount = sqrt_product diff --git a/finance/token-swap/anchor/programs/token-swap/tests/test_swap.rs b/finance/token-swap/anchor/programs/token-swap/tests/test_swap.rs index 92fede8e..3fae17a9 100644 --- a/finance/token-swap/anchor/programs/token-swap/tests/test_swap.rs +++ b/finance/token-swap/anchor/programs/token-swap/tests/test_swap.rs @@ -18,6 +18,22 @@ use { /// supply. const MINIMUM_LIQUIDITY: u64 = 100; +/// Mirrors `constants::BASIS_POINTS_DIVISOR`. +const BASIS_POINTS_DIVISOR: u64 = 10_000; + +/// The swap fee as `swap_tokens` computes it: `input * fee_bps / 10_000`, +/// rounded up, so a fee that is not a whole number of minor units costs the +/// trader one more unit. +fn ceiled_fee(input: u64, fee_bps: u64) -> u64 { + (input * fee_bps).div_ceil(BASIS_POINTS_DIVISOR) +} + +/// The admin's slice of a fee as `swap_tokens` computes it: +/// `fee * admin_share_bps / 10_000`, rounded up against the LPs. +fn ceiled_admin_portion(fee: u64, admin_share_bps: u64) -> u64 { + (fee * admin_share_bps).div_ceil(BASIS_POINTS_DIVISOR) +} + fn token_program_id() -> Address { "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" .parse() @@ -714,15 +730,17 @@ fn swap_a_to_b(ts: &mut TestSetup, input_amount: u64) { fn test_claim_admin_fees() { let mut ts = setup_pool(4_000_000, 1_000_000); // fee = 500 bps (5%), admin_share_bps = 1667 (~1/6). - // Per the swap: fee_amount = input * 500 / 10_000 = input * 5 / 100 - // admin_portion = fee_amount * 1667 / 10_000 + // Per the swap: fee_amount = ceil(input * 500 / 10_000) + // admin_portion = ceil(fee_amount * 1667 / 10_000) // Swap input is on the A side, so admin's claim accumulates in mint A. + // 1_000_000 in: a 50_000 fee, of which 50_000 * 1667 / 10_000 = 8_335 + // exactly is the admin's. let swap_in = 1_000_000u64; swap_a_to_b(&mut ts, swap_in); - let fee_amount = swap_in * 500 / 10_000; - let expected_admin_a = fee_amount * 1667 / 10_000; - assert!(expected_admin_a > 0, "expected admin portion > 0"); + let fee_amount = ceiled_fee(swap_in, 500); + let expected_admin_a = ceiled_admin_portion(fee_amount, 1667); + assert_eq!(expected_admin_a, 8_335); // ---- Phase 1: first claim transfers the accumulated A-side fees ---- let admin_balance_a_before = get_token_account_balance(&ts.svm, &ts.holder_account_a).unwrap(); @@ -755,11 +773,13 @@ fn test_claim_admin_fees() { // This proves the accumulators were truly reset (not just zeroed in // memory): a fresh swap accrues new fees from a clean baseline, and the // next claim transfers exactly that new amount. + // 500_000 in: a 25_000 fee, of which 25_000 * 1667 / 10_000 = 4_167.5 + // is the admin's, rounded up to 4_168 against the LPs. let swap_in_2 = 500_000u64; swap_a_to_b(&mut ts, swap_in_2); - let fee_amount_2 = swap_in_2 * 500 / 10_000; - let expected_admin_a_2 = fee_amount_2 * 1667 / 10_000; - assert!(expected_admin_a_2 > 0, "expected second admin portion > 0"); + let fee_amount_2 = ceiled_fee(swap_in_2, 500); + let expected_admin_a_2 = ceiled_admin_portion(fee_amount_2, 1667); + assert_eq!(expected_admin_a_2, 4_168); let balance_a_pre_claim_2 = get_token_account_balance(&ts.svm, &ts.holder_account_a).unwrap(); @@ -1030,7 +1050,8 @@ fn test_deposit_after_swap_uses_shifted_effective_ratio() { // Opened at 1:1 so the post-swap ratio is dramatic and easy to // sanity-check. - // Swap 1M of A in. With fee = 500 bps and admin_share = 1667 bps: + // Swap 1M of A in. With fee = 500 bps and admin_share = 1667 bps, both + // rounded up (exact here): // fee_amount = 50_000 // admin_portion = 50_000 * 1667 / 10_000 = 8_335 (accrues on side A) // taxed_input = 950_000 @@ -1749,3 +1770,59 @@ fn test_invariant_holds_after_normal_swap() { before={k_before}, after={k_after}" ); } + +/// The floor boundary. A deposit whose square root is exactly +/// `MINIMUM_LIQUIDITY` would open a pool and mint its creator nothing, so it +/// is refused with `DepositTooSmall` and nothing is created; one unit above +/// it opens the pool and leaves the creator 1 LP token. +#[test] +fn test_initialize_pool_rejects_sqrt_equal_to_floor() { + let mut ts = setup_config_and_mints(); + // sqrt(100 * 100) = 100 = MINIMUM_LIQUIDITY. + assert_error_code( + send_initialize_pool(&mut ts, MINIMUM_LIQUIDITY, MINIMUM_LIQUIDITY), + AmmError::DepositTooSmall, + "DepositTooSmall", + ); + assert!(ts.svm.get_account(&ts.pool_config_key).is_none()); + + // sqrt(101 * 101) = 101: the smallest pool that opens. + send_initialize_pool(&mut ts, MINIMUM_LIQUIDITY + 1, MINIMUM_LIQUIDITY + 1) + .expect("a square root one above the floor opens the pool"); + assert_eq!( + get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(), + 1 + ); +} + +/// The fee rounds against the trader. 1_000_001 of A at 500 bps is a fee of +/// 50_000.05, charged as 50_001; the admin's 1667 bps of that is 8_335.17, +/// owed as 8_336. The curve prices the 950_000 that remain: +/// `950_000 * 1_000_000 / (4_000_000 + 950_000) = 191_919`. +#[test] +fn test_swap_fee_rounds_up() { + let mut ts = setup_pool(4_000_000, 1_000_000); + let holder_b_before = get_token_account_balance(&ts.svm, &ts.holder_account_b).unwrap(); + + let input = 1_000_001u64; + assert_eq!(ceiled_fee(input, 500), 50_001); + assert_eq!(ceiled_admin_portion(50_001, 1667), 8_336); + swap_a_to_b(&mut ts, input); + + let admin_owed_a: u64 = { + let account = ts.svm.get_account(&ts.pool_config_key).unwrap(); + let start = 8 + 32 * 3; + u64::from_le_bytes(account.data[start..start + 8].try_into().unwrap()) + }; + assert_eq!(admin_owed_a, 8_336, "the admin's slice rounds up"); + assert_eq!( + get_token_account_balance(&ts.svm, &ts.holder_account_b).unwrap() - holder_b_before, + 191_919, + "the output is priced from the input minus the rounded-up fee" + ); + assert_eq!( + get_token_account_balance(&ts.svm, &ts.pool_a).unwrap(), + 4_000_000 + input, + "the trader pays the whole input; the fee is taken from it" + ); +} diff --git a/finance/token-swap/kani-proofs/README.md b/finance/token-swap/kani-proofs/README.md index 12fe6417..4ea5c77d 100644 --- a/finance/token-swap/kani-proofs/README.md +++ b/finance/token-swap/kani-proofs/README.md @@ -17,9 +17,10 @@ CPIs that Kani cannot symbolically execute, but the *interesting* part, the constant-product curve, the fee split, the integer square root `initialize_pool` uses for the creator's LP mint, and the proportional deposit and withdraw math, is pure integer arithmetic. This crate reproduces those formulas faithfully (same `u128` -widening, multiply-before-divide, floor rounding) and checks their invariants: +widening, multiply-before-divide, the fee rounded up and everything paid out rounded +down) and checks their invariants: -- `proof_fee_split_bounds`: `fee <= input`, `admin_portion <= fee`, and `taxed_input + fee == input`. +- `proof_fee_split_bounds`: `fee <= input`, `admin_portion <= fee`, `taxed_input + fee == input`, and both the fee and the admin's slice of it are the exact fraction rounded up, never down. - `proof_swap_preserves_constant_product`: **The core safety property**: a swap never decreases `k = reserve_in * reserve_out`. - `proof_swap_cannot_fully_drain_when_reserve_positive`: With a non-empty input reserve, output is always `< other_reserve` (pool stays solvent). - `proof_swap_at_zero_reserve_drains_whole_pool`: **Finding**, checked as a positive characterization (see below). diff --git a/finance/token-swap/kani-proofs/src/lib.rs b/finance/token-swap/kani-proofs/src/lib.rs index cb9d0bb4..d3c665d2 100644 --- a/finance/token-swap/kani-proofs/src/lib.rs +++ b/finance/token-swap/kani-proofs/src/lib.rs @@ -14,8 +14,9 @@ //! But the *interesting* part — the constant-product curve, the fee split, the //! integer square root `initialize_pool` uses for the creator's LP mint, and //! the proportional deposit and withdraw math — is pure integer arithmetic. This crate reproduces those formulas -//! faithfully (same `u128` widening, same multiply-before-divide, same floor -//! rounding) and checks the invariants the program depends on. +//! faithfully (same `u128` widening, same multiply-before-divide, the fee +//! rounded up and everything paid out rounded down) and checks the +//! invariants the program depends on. //! //! Constants mirror `constants.rs`. @@ -31,17 +32,19 @@ pub const MINIMUM_LIQUIDITY: u128 = 100; // =========================================================================== /// `(fee_amount, admin_portion, taxed_input)` as computed at the top of -/// `handle_swap_tokens`. Returns `None` on the same overflow paths the program -/// maps to `AmmError::MathOverflow`. +/// `handle_swap_tokens`: the fee is `input * fee_bps / 10_000` rounded up, +/// the admin's slice is `fee * admin_share_bps / 10_000` rounded up, and the +/// trader's side is what remains. Returns `None` on the same overflow paths +/// the program maps to `AmmError::MathOverflow`. /// /// `fee_bps` and `admin_share_bps` are validated `< 10_000` in `initialize_config`. pub fn fee_split(input_amount: u64, fee_bps: u16, admin_share_bps: u16) -> Option<(u64, u64, u64)> { let fee_amount = (input_amount as u128) .checked_mul(fee_bps as u128)? - .checked_div(BASIS_POINTS_DIVISOR)?; + .div_ceil(BASIS_POINTS_DIVISOR); let admin_portion = fee_amount .checked_mul(admin_share_bps as u128)? - .checked_div(BASIS_POINTS_DIVISOR)?; + .div_ceil(BASIS_POINTS_DIVISOR); let fee_amount: u64 = u64::try_from(fee_amount).ok()?; let admin_portion: u64 = u64::try_from(admin_portion).ok()?; let taxed_input = input_amount.checked_sub(fee_amount)?; @@ -51,7 +54,10 @@ pub fn fee_split(input_amount: u64, fee_bps: u16, admin_share_bps: u16) -> Optio /// The fee never exceeds the input, the admin slice never exceeds the fee, and /// the taxed input plus fee reconstitutes the input exactly. These are the /// preconditions the rest of `swap_tokens` (the `checked_sub` for `taxed_input`, -/// the `u64::try_from` casts) silently relies on. +/// the `u64::try_from` casts) silently relies on. The harness also checks the +/// rounding direction: each of the two quotients is the smallest integer at +/// or above the exact fraction, so a fee that is not a whole number of minor +/// units is charged one unit over, and never one unit under. #[cfg(kani)] #[kani::proof] #[kani::solver(cadical)] @@ -77,6 +83,15 @@ fn proof_fee_split_bounds() { assert!(admin <= fee); // admin slice is a fraction of the fee assert_eq!(taxed as u128 + fee as u128, input as u128); // nothing lost assert_eq!(taxed, input - fee); + + // Rounded up: `fee` is at or above the exact fraction, and one unit less + // would be below it. The same for the admin's slice of the fee. + let exact_fee = input as u128 * fee_bps as u128; + assert!(fee as u128 * BASIS_POINTS_DIVISOR >= exact_fee); + assert!((fee as u128) * BASIS_POINTS_DIVISOR < exact_fee + BASIS_POINTS_DIVISOR); + let exact_admin = fee as u128 * admin_share_bps as u128; + assert!(admin as u128 * BASIS_POINTS_DIVISOR >= exact_admin); + assert!((admin as u128) * BASIS_POINTS_DIVISOR < exact_admin + BASIS_POINTS_DIVISOR); } // =========================================================================== @@ -454,13 +469,30 @@ mod tests { #[test] fn fee_split_basic() { - // 1% fee, 50% admin share, on 10_000 input. + // 1% fee, 50% admin share, on 10_000 input: both exact. let (fee, admin, taxed) = fee_split(10_000, 100, 5_000).unwrap(); assert_eq!(fee, 100); assert_eq!(admin, 50); assert_eq!(taxed, 9_900); } + #[test] + fn fee_split_rounds_up() { + // 5% fee on 1_000_001 is 50_000.05, charged as 50_001; the admin's + // 1667 bps of that is 8_335.17, owed as 8_336. The trader's side is + // the remainder. + let (fee, admin, taxed) = fee_split(1_000_001, 500, 1_667).unwrap(); + assert_eq!(fee, 50_001); + assert_eq!(admin, 8_336); + assert_eq!(taxed, 950_000); + // The book's walkthrough: 100 USDC at 30 bps, admin share 1667 bps, + // both exact. + let (fee, admin, taxed) = fee_split(100_000_000, 30, 1_667).unwrap(); + assert_eq!(fee, 300_000); + assert_eq!(admin, 50_010); + assert_eq!(taxed, 99_700_000); + } + #[test] fn swap_output_basic() { // Symmetric pool 1_000_000 / 1_000_000, taxed input 1_000. diff --git a/finance/token-swap/quasar/CHANGELOG.md b/finance/token-swap/quasar/CHANGELOG.md index 61cf9ead..7661547d 100644 --- a/finance/token-swap/quasar/CHANGELOG.md +++ b/finance/token-swap/quasar/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## Unreleased (2026-10-05) + +### Changed + +- `swap_tokens` rounds the trading fee up: `fee_amount` is + `input * fee / 10_000` rounded to the next whole minor unit, and the + admin's slice of it, `fee_amount * admin_share_bps / 10_000`, rounds up the + same way, so a fee that is not a whole number of minor units costs the + trader one unit more rather than the pool one unit less, and the admin's + share rounds against the LPs. The trader's side, `taxed_input`, is the + input minus the rounded-up fee. A 30 bps fee on 100_001 minor units is + 300.003, charged as 301, and the admin's 1_667 bps of a 1_500 fee is 250.05, + owed as 251 (`swap_fee_rounds_up`; `claim_admin_fees_pays_the_admin` asserts + the exact amount). +- `initialize_pool` refuses a deposit whose square root equals + `MINIMUM_LIQUIDITY` as well as one below it, so the smallest pool that opens + leaves its creator at least 1 LP token: a pool opened with + `sqrt(100 * 100)` would mint its creator nothing. + `initialize_pool_rejects_sqrt_equal_to_floor` checks both sides of the + boundary. +- `initialize_config_rejects_invalid_fee`, + `initialize_config_rejects_invalid_admin_share` and + `claim_admin_fees_rejects_non_admin` assert `InvalidFee`, + `AdminShareTooHigh` and `Unauthorized` rather than any failure. + ## Unreleased (2026-10-04) ### Changed diff --git a/finance/token-swap/quasar/src/error.rs b/finance/token-swap/quasar/src/error.rs index 9286a1d9..988c23e7 100644 --- a/finance/token-swap/quasar/src/error.rs +++ b/finance/token-swap/quasar/src/error.rs @@ -13,10 +13,10 @@ pub enum AmmError { /// cannot take more than the whole fee. AdminShareTooHigh, /// Depositing too little liquidity. Returned by `initialize_pool` when - /// `sqrt(amount_a * amount_b)` is below `MINIMUM_LIQUIDITY`, so withholding - /// the floor would leave the creator nothing, and by `deposit_liquidity` - /// when a later deposit is too small a share of the pool to mint a single - /// LP token. + /// `sqrt(amount_a * amount_b)` is at or below `MINIMUM_LIQUIDITY`, so + /// withholding the floor would leave the creator nothing, and by + /// `deposit_liquidity` when a later deposit is too small a share of the + /// pool to mint a single LP token. DepositTooSmall, /// Clamping the caller's amounts to the current pool ratio rounded one /// side down to zero; the pool cannot issue meaningful LP shares. diff --git a/finance/token-swap/quasar/src/instructions/swap_tokens.rs b/finance/token-swap/quasar/src/instructions/swap_tokens.rs index a96958f3..c1560bbe 100644 --- a/finance/token-swap/quasar/src/instructions/swap_tokens.rs +++ b/finance/token-swap/quasar/src/instructions/swap_tokens.rs @@ -81,19 +81,24 @@ pub fn handle_swap_tokens( // `pool_config.admin_fees_owed_` and is swept later by // `claim_admin_fees`. This saves a CPI per swap. u128 + checked: the // intermediate `input * fee` can overflow u64; multiply before divide. + // Both divisions round up: a fee that is not a whole number of minor + // units costs the trader one more unit rather than the pool one less, + // and the admin's slice of it rounds up against the LPs for the same + // reason. `taxed_input`, the trader's side, is what remains after the + // ceiled fee. `fee < 10_000`, so the ceiling of `input * fee / 10_000` + // is at most `input` and fits a u64; the admin portion is at most the + // fee by the same argument. let fee = accounts.config.fee() as u128; let admin_share_bps = accounts.config.admin_share_bps() as u128; let fee_amount_u128 = (input as u128) .checked_mul(fee) .ok_or(AmmError::MathOverflow)? - .checked_div(BASIS_POINTS_DIVISOR as u128) - .ok_or(AmmError::MathOverflow)?; + .div_ceil(BASIS_POINTS_DIVISOR as u128); let fee_amount = u64::try_from(fee_amount_u128).map_err(|_| AmmError::MathOverflow)?; let admin_portion_u128 = (fee_amount as u128) .checked_mul(admin_share_bps) .ok_or(AmmError::MathOverflow)? - .checked_div(BASIS_POINTS_DIVISOR as u128) - .ok_or(AmmError::MathOverflow)?; + .div_ceil(BASIS_POINTS_DIVISOR as u128); let admin_portion = u64::try_from(admin_portion_u128).map_err(|_| AmmError::MathOverflow)?; let taxed_input = input .checked_sub(fee_amount) diff --git a/finance/token-swap/quasar/src/liquidity.rs b/finance/token-swap/quasar/src/liquidity.rs index 4eb73dd4..b055bb1f 100644 --- a/finance/token-swap/quasar/src/liquidity.rs +++ b/finance/token-swap/quasar/src/liquidity.rs @@ -30,7 +30,9 @@ pub fn integer_sqrt(n: u128) -> u128 { } /// LP tokens minted to the creator for the deposit that opens a pool: -/// `sqrt(amount_a * amount_b) - MINIMUM_LIQUIDITY`. +/// `sqrt(amount_a * amount_b) - MINIMUM_LIQUIDITY`. A deposit whose square +/// root is at or below `MINIMUM_LIQUIDITY` fails with `DepositTooSmall`, so +/// the smallest pool that opens leaves its creator at least 1 LP token. /// /// The `MINIMUM_LIQUIDITY` floor is never minted to anyone. From then on /// `deposit_liquidity` and `withdraw_liquidity` divide by @@ -50,7 +52,7 @@ pub fn initial_lp_amount(amount_a: u64, amount_b: u64) -> Result u64 { .expect("mul_div: result exceeds u64") } +/// `amount * numerator / denominator` rounded up, in u128 with checked ops. +/// Mirrors the program's fee math: the swap fee and the admin's slice of it +/// both round against the trader and the LPs. +fn mul_div_ceil(amount: u64, numerator: u64, denominator: u64) -> u64 { + u64::try_from( + (amount as u128) + .checked_mul(numerator as u128) + .expect("mul_div_ceil: product overflow") + .div_ceil(denominator as u128), + ) + .expect("mul_div_ceil: result exceeds u64") +} + /// Constant-product quote mirroring the program's swap math, on effective /// reserves: output = taxed_input * pool_out / (pool_in + taxed_input), where -/// taxed_input = input - input * fee_bps / 10_000. All products in u128. +/// taxed_input = input - ceil(input * fee_bps / 10_000). All products in u128. fn expected_swap_output(input: u64, fee_bps: u64, pool_in: u64, pool_out: u64) -> u64 { - let fee_amount = mul_div(input, fee_bps, crate::BASIS_POINTS_DIVISOR); + let fee_amount = mul_div_ceil(input, fee_bps, crate::BASIS_POINTS_DIVISOR); let taxed_input = input.checked_sub(fee_amount).expect("fee exceeds input"); let divisor = pool_in.checked_add(taxed_input).expect("reserve overflow"); mul_div(taxed_input, pool_out, divisor) @@ -267,21 +280,13 @@ fn initialize_config_records_admin_and_fees(test: &mut Test) { #[quasar_test] fn initialize_config_rejects_invalid_fee(test: &mut Test) { // fee >= 10_000 → invalid. - let outcome = initialize_config(test, 10_000, 1_667); - assert!( - outcome.is_err(), - "initialize_config should have failed with invalid fee" - ); + initialize_config(test, 10_000, 1_667).fails_with(AmmError::InvalidFee); } #[quasar_test] fn initialize_config_rejects_invalid_admin_share(test: &mut Test) { // admin_share_bps >= 10_000 → invalid. - let outcome = initialize_config(test, 30, 10_000); - assert!( - outcome.is_err(), - "initialize_config should have failed with admin_share_bps >= 10000" - ); + initialize_config(test, 30, 10_000).fails_with(AmmError::AdminShareTooHigh); } // ─── initialize_pool ───────────────────────────────────────────────────────────── @@ -444,6 +449,36 @@ fn initialize_pool_rejects_zero_amount_b(test: &mut Test) { ); } +/// The floor boundary. A deposit whose square root is exactly +/// `MINIMUM_LIQUIDITY` would open a pool and mint its creator nothing, so it +/// is refused with `DepositTooSmall` and nothing is created; one unit above +/// it opens the pool and leaves the creator 1 LP token. +#[quasar_test] +fn initialize_pool_rejects_sqrt_equal_to_floor(test: &mut Test) { + setup_config_and_mints(test); + let one_above_floor = crate::MINIMUM_LIQUIDITY + 1; + fund( + test, + CREATOR, + CREATOR_TOKEN_A, + CREATOR_TOKEN_B, + one_above_floor, + one_above_floor, + ); + // sqrt(100 * 100) = 100 = MINIMUM_LIQUIDITY. + send_initialize_pool(test, crate::MINIMUM_LIQUIDITY, crate::MINIMUM_LIQUIDITY) + .fails_with(AmmError::DepositTooSmall); + assert!( + test.account(pool_config_address(test)).is_none(), + "no pool must exist after the refused initialize_pool" + ); + + // sqrt(101 * 101) = 101: the smallest pool that opens. + send_initialize_pool(test, one_above_floor, one_above_floor) + .succeeds() + .has_tokens(CREATOR_LP, 1); +} + // ─── deposit_liquidity ─────────────────────────────────────────────────────── /// The front-run. Maria (the creator) opens an ACME/USDC pool with 400 ACME @@ -1159,13 +1194,65 @@ fn claim_admin_fees_pays_the_admin(test: &mut Test) { test.add(TokenAccount::new(MINT_A, ADMIN).at(ADMIN_TOKEN_A)); test.add(TokenAccount::new(MINT_B, ADMIN).at(ADMIN_TOKEN_B)); - claim_fees(test, ADMIN, ADMIN_TOKEN_A, ADMIN_TOKEN_B).succeeds(); + // A was the input side: 500_000 at 30 bps is a 1_500 fee, of which + // 1_500 * 1_667 / 10_000 = 250.05 is the admin's, owed as 251 because + // the admin's slice rounds up against the LPs. The claim pays exactly + // that and nothing on the B side. + let expected_admin_a = mul_div_ceil( + mul_div_ceil(500_000, POOL_FEE_BPS, crate::BASIS_POINTS_DIVISOR), + ADMIN_SHARE_BPS as u64, + crate::BASIS_POINTS_DIVISOR, + ); + assert_eq!(expected_admin_a, 251); + claim_fees(test, ADMIN, ADMIN_TOKEN_A, ADMIN_TOKEN_B) + .succeeds() + .has_tokens(ADMIN_TOKEN_A, expected_admin_a) + .has_tokens(ADMIN_TOKEN_B, 0); + let pool = test.read::(pool_config_address(test)); + assert_eq!(u64::from(pool.admin_fees_owed_a), 0); +} - // After the claim, admin_token_a should have received some fees (A was - // the input side). - assert!( - test.tokens(ADMIN_TOKEN_A) > 0, - "admin should have received token-A fees" +/// The fee rounds against the trader. 100_001 of A at 30 bps is a fee of +/// 300.003, charged as 301; the admin's 1_667 bps of that is 50.18, owed as +/// 51. The curve prices the 99_700 that remain: +/// `99_700 * 10_000_000 / (10_000_000 + 99_700) = 98_715`. +#[quasar_test] +fn swap_fee_rounds_up(test: &mut Test) { + setup_pool(test, 10_000_000, 10_000_000); + test.add(Wallet::new().at(TRADER)); + test.add( + TokenAccount::new(MINT_A, TRADER) + .at(TRADER_TOKEN_A) + .amount(1_000_000), + ); + + let input = 100_001u64; + assert_eq!( + mul_div_ceil(input, POOL_FEE_BPS, crate::BASIS_POINTS_DIVISOR), + 301 + ); + assert_eq!( + expected_swap_output(input, POOL_FEE_BPS, 10_000_000, 10_000_000), + 98_715 + ); + swap( + test, + TRADER, + TRADER_TOKEN_A, + TRADER_TOKEN_B, + true, + input, + 98_715, + ) + .succeeds() + // The trader pays the whole input; the fee is taken from it. + .has_tokens(pool_a(test), 10_000_000 + input) + .has_tokens(TRADER_TOKEN_B, 98_715); + let pool = test.read::(pool_config_address(test)); + assert_eq!( + u64::from(pool.admin_fees_owed_a), + 51, + "the admin's slice rounds up" ); } @@ -1196,9 +1283,5 @@ fn claim_admin_fees_rejects_non_admin(test: &mut Test) { test.add(TokenAccount::new(MINT_A, BAD_ACTOR).at(BAD_TOKEN_A)); test.add(TokenAccount::new(MINT_B, BAD_ACTOR).at(BAD_TOKEN_B)); - let outcome = claim_fees(test, BAD_ACTOR, BAD_TOKEN_A, BAD_TOKEN_B); - assert!( - outcome.is_err(), - "unauthorized claim_admin_fees should fail" - ); + claim_fees(test, BAD_ACTOR, BAD_TOKEN_A, BAD_TOKEN_B).fails_with(AmmError::Unauthorized); }