diff --git a/finance/fundraiser/quasar/CHANGELOG.md b/finance/fundraiser/quasar/CHANGELOG.md index 5113b82c..370b5550 100644 --- a/finance/fundraiser/quasar/CHANGELOG.md +++ b/finance/fundraiser/quasar/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [2026-10-04] + +### Fixed + +- `initialize_fundraiser` accepted any nonzero target and `contribute` any + nonzero amount, where the Anchor versions require a target of at least + `MIN_AMOUNT_TO_RAISE` (3) major units of the raised token and a contribution + of at least one major unit (`10^decimals` minor units). The Quasar version + now enforces both: a smaller target fails with `InvalidAmount`, and a + smaller contribution, zero included, fails with the new + `ContributionTooSmall` error. `initialize_rejects_target_below_minimum` and + `contribute_below_one_major_unit_fails` try one minor unit under each + minimum. The tests' mint now has 2 decimals, so one major unit is 100 minor + units. + ## [2026-10-03] ### Changed diff --git a/finance/fundraiser/quasar/README.md b/finance/fundraiser/quasar/README.md index 7596b2fe..a2b1984a 100644 --- a/finance/fundraiser/quasar/README.md +++ b/finance/fundraiser/quasar/README.md @@ -16,8 +16,8 @@ See also: the [repository catalog](../../../README.md) and the [Anchor variant]( ## Lifecycle -- `initialize_fundraiser` (maker signs): rejects a zero target (`InvalidAmount`) or zero duration (`InvalidDuration`), creates the Fundraiser PDA and the vault, and records the current Clock time as `time_started`, with `claimed` false and `open_contributions` zero. -- `contribute` (contributor signs): rejects a zero amount, fails with `FundraiserClaimed` once the maker has claimed, and fails with `FundraiserEnded` after the deadline. Creates the contributor's Contribution PDA on first use (idempotent init, contributor pays the rent) and adds one to `open_contributions`, adds the amount to both `current_amount` and the contributor's record with checked arithmetic, transfers tokens from the contributor's token account into the vault, then verifies the vault gained exactly the contributed amount (`BalanceMismatch` otherwise). +- `initialize_fundraiser` (maker signs): rejects a target below `MIN_AMOUNT_TO_RAISE` (3) major units of the mint, which is `3 * 10^decimals` minor units (`InvalidAmount`), or a zero duration (`InvalidDuration`), creates the Fundraiser PDA and the vault, and records the current Clock time as `time_started`, with `claimed` false and `open_contributions` zero. +- `contribute` (contributor signs): rejects an amount below one major unit of the mint, `10^decimals` minor units (`ContributionTooSmall`), fails with `FundraiserClaimed` once the maker has claimed, and fails with `FundraiserEnded` after the deadline. Creates the contributor's Contribution PDA on first use (idempotent init, contributor pays the rent) and adds one to `open_contributions`, adds the amount to both `current_amount` and the contributor's record with checked arithmetic, transfers tokens from the contributor's token account into the vault, then verifies the vault gained exactly the contributed amount (`BalanceMismatch` otherwise). - `check_contributions` (maker signs): fails with `FundraiserClaimed` if already claimed and with `TargetNotMet` unless `current_amount >= amount_to_raise`. Sets `claimed` and transfers the whole vault balance to the maker's token account with the Fundraiser PDA signing. The Fundraiser and the empty vault stay open until every Contribution account is closed. - `refund` (anyone may send it; the contributor does not sign): fails with `FundraiserNotEnded` before the deadline and with `TargetMet` if the fundraiser succeeded. Pays the contributor's recorded amount back from the vault to the contributor's own token account (checked against the contributor's address) with the PDA signing, subtracts it from `current_amount` and one from `open_contributions`, verifies the vault lost exactly that amount, and closes the Contribution account, returning its rent to the contributor. The tokens and rent can only reach the contributor, so the maker can refund everyone without waiting on them. - `close_contribution` (anyone may send it; the contributor does not sign): fails with `FundraiserNotClaimed` until the maker has claimed, because before then the contribution can still be refunded. Closes the Contribution account, returning its rent to the contributor, and subtracts one from `open_contributions`. The Contribution account's seeds bind it to the fundraiser address, so no other fundraiser can be substituted. diff --git a/finance/fundraiser/quasar/src/error.rs b/finance/fundraiser/quasar/src/error.rs index 41ec58d2..72d8910e 100644 --- a/finance/fundraiser/quasar/src/error.rs +++ b/finance/fundraiser/quasar/src/error.rs @@ -33,4 +33,7 @@ pub enum FundraiserError { /// Contributions to an unclaimed fundraiser have not all been refunded, /// so closing its vault would strand them. RefundsOutstanding, + /// A contribution was below one major unit of the raised token + /// (`10^decimals` minor units). + ContributionTooSmall, } diff --git a/finance/fundraiser/quasar/src/instructions/contribute.rs b/finance/fundraiser/quasar/src/instructions/contribute.rs index 67518546..c5e8b44a 100644 --- a/finance/fundraiser/quasar/src/instructions/contribute.rs +++ b/finance/fundraiser/quasar/src/instructions/contribute.rs @@ -1,7 +1,7 @@ use { crate::{ error::FundraiserError, - state::{fundraiser_deadline, Contribution, Fundraiser}, + state::{fundraiser_deadline, one_major_unit, Contribution, Fundraiser}, }, quasar_lang::{prelude::*, sysvars::Sysvar as _}, quasar_spl::prelude::*, @@ -52,7 +52,12 @@ pub fn handle_contribute( amount: u64, bumps: &ContributeAccountConstraintsBumps, ) -> Result<(), ProgramError> { - require!(amount > 0, FundraiserError::InvalidAmount); + // The minimum contribution is one major unit, which is 10^decimals minor + // units. + require!( + amount >= one_major_unit(accounts.mint_to_raise.decimals())?, + FundraiserError::ContributionTooSmall + ); // A claimed fundraiser has paid its vault out to the maker, so a later // contribution would go to the maker with no refund path. @@ -84,7 +89,7 @@ pub fn handle_contribute( ); // `init(idempotent)` creates the contribution account zeroed and reuses it - // on later contributions. Every contribution is nonzero, so a recorded + // on later contributions. Every contribution is at least one major unit, so a recorded // amount of zero means the account was created by this instruction: save // its bump and count it against the fundraiser. if contributed_so_far == 0 { diff --git a/finance/fundraiser/quasar/src/instructions/initialize_fundraiser.rs b/finance/fundraiser/quasar/src/instructions/initialize_fundraiser.rs index ab490c4a..72a480c5 100644 --- a/finance/fundraiser/quasar/src/instructions/initialize_fundraiser.rs +++ b/finance/fundraiser/quasar/src/instructions/initialize_fundraiser.rs @@ -1,7 +1,7 @@ use { crate::{ error::FundraiserError, - state::{Fundraiser, FundraiserInner}, + state::{one_major_unit, Fundraiser, FundraiserInner, MIN_AMOUNT_TO_RAISE}, }, quasar_lang::{prelude::*, sysvars::Sysvar as _}, quasar_spl::prelude::*, @@ -39,7 +39,15 @@ pub fn handle_initialize_fundraiser( duration: u16, bump: u8, ) -> Result<(), ProgramError> { - require!(amount_to_raise > 0, FundraiserError::InvalidAmount); + // The target must be at least MIN_AMOUNT_TO_RAISE major units, expressed + // in minor units: MIN_AMOUNT_TO_RAISE * 10^decimals. + let minimum_amount_to_raise = MIN_AMOUNT_TO_RAISE + .checked_mul(one_major_unit(accounts.mint_to_raise.decimals())?) + .ok_or(FundraiserError::MathOverflow)?; + require!( + amount_to_raise >= minimum_amount_to_raise, + FundraiserError::InvalidAmount + ); // A zero-day window would close before any contribution could land. require!(duration > 0, FundraiserError::InvalidDuration); diff --git a/finance/fundraiser/quasar/src/state.rs b/finance/fundraiser/quasar/src/state.rs index 49d3d44e..a548efa5 100644 --- a/finance/fundraiser/quasar/src/state.rs +++ b/finance/fundraiser/quasar/src/state.rs @@ -4,6 +4,19 @@ use {crate::error::FundraiserError, quasar_lang::prelude::*}; /// days; deadline math converts it to seconds with this factor. pub const SECONDS_PER_DAY: i64 = 86_400; +/// The smallest target a fundraiser can set, in major units of the raised +/// token. `initialize_fundraiser` converts it to minor units with the mint's +/// decimals. +pub const MIN_AMOUNT_TO_RAISE: u64 = 3; + +/// One major unit of a token with `decimals` decimals, in minor units: +/// `10^decimals`. +pub fn one_major_unit(decimals: u8) -> Result { + Ok(10_u64 + .checked_pow(decimals as u32) + .ok_or(FundraiserError::MathOverflow)?) +} + /// State for the fundraiser: records the maker, target mint, vault, amounts, /// and timing. #[account(discriminator = 1, set_inner)] diff --git a/finance/fundraiser/quasar/src/tests.rs b/finance/fundraiser/quasar/src/tests.rs index 7849aa78..d2162a2b 100644 --- a/finance/fundraiser/quasar/src/tests.rs +++ b/finance/fundraiser/quasar/src/tests.rs @@ -12,12 +12,17 @@ use { RefundInstruction, }, error::FundraiserError, - state::{Contribution, Fundraiser, SECONDS_PER_DAY}, + state::{Contribution, Fundraiser, MIN_AMOUNT_TO_RAISE, SECONDS_PER_DAY}, }, quasar_lang::error::QuasarError, quasar_test::prelude::*, }; +/// Decimals of the raised token. Two keeps one major unit at 100 minor units, +/// below every contribution the tests make. +const MINT_DECIMALS: u8 = 2; +/// One major unit of the raised token, the smallest contribution allowed. +const ONE_TOKEN: u64 = 10_u64.pow(MINT_DECIMALS as u32); /// Fundraising target in minor units of the raised token. const TARGET_AMOUNT: u64 = 10_000; /// Fundraising window length in days. @@ -96,7 +101,12 @@ fn framework_error(error: QuasarError) -> ProgramError { /// fixed start time. fn base_world(test: &mut Test) { test.add(Wallet::new().at(MAKER)); - test.add(Mint::new(MAKER).at(MINT).supply(1_000_000_000).decimals(9)); + test.add( + Mint::new(MAKER) + .at(MINT) + .supply(1_000_000_000) + .decimals(MINT_DECIMALS), + ); test.add(TokenAccount::new(MINT, MAKER).at(MAKER_TA)); test.warp_to_timestamp(START_TIME); } @@ -264,6 +274,17 @@ fn initialize_rejects_zero_amount(test: &mut Test) { initialize_fundraiser(test, 0, DURATION_DAYS).fails_with(FundraiserError::InvalidAmount); } +/// The target must be at least `MIN_AMOUNT_TO_RAISE` major units; one minor +/// unit less is refused and the minimum itself is accepted. +#[quasar_test] +fn initialize_rejects_target_below_minimum(test: &mut Test) { + base_world(test); + let minimum = MIN_AMOUNT_TO_RAISE * ONE_TOKEN; + initialize_fundraiser(test, minimum - 1, DURATION_DAYS) + .fails_with(FundraiserError::InvalidAmount); + initialize_fundraiser(test, minimum, DURATION_DAYS).succeeds(); +} + #[quasar_test] fn initialize_rejects_zero_duration(test: &mut Test) { base_world(test); @@ -296,6 +317,24 @@ fn contribute_creates_contribution_and_moves_tokens(test: &mut Test) { assert_eq!(contribution_state.bump, expected_bump); } +/// A contribution must be at least one major unit of the raised token. One +/// minor unit less, and zero, are refused with nothing moved and no +/// contribution account opened; exactly one major unit is accepted. +#[quasar_test] +fn contribute_below_one_major_unit_fails(test: &mut Test) { + let fundraiser = initialized_world(test); + + for amount in [ONE_TOKEN - 1, 0] { + contribute(test, amount).fails_with(FundraiserError::ContributionTooSmall); + } + assert_eq!(test.tokens(VAULT), 0); + assert_eq!(open_contributions(test, fundraiser), 0); + + contribute(test, ONE_TOKEN) + .succeeds() + .has_tokens(VAULT, ONE_TOKEN); +} + #[quasar_test] fn contributions_accumulate_in_one_contribution(test: &mut Test) { let fundraiser = initialized_world(test); diff --git a/finance/lending/quasar/README.md b/finance/lending/quasar/README.md index 21ec8f8d..334aaecc 100644 --- a/finance/lending/quasar/README.md +++ b/finance/lending/quasar/README.md @@ -24,6 +24,12 @@ fixed-size accounts, so this port follows that idiom: touches at the top of the instruction. Health is then computed inline from the freshly accrued reserves and the oracle prices passed in. +- **Reserve configuration is fixed at creation.** There is no + `update_reserve_config`: a reserve's loan-to-value, liquidation threshold and + bonus, close factor, reserve factor, interest-rate curve and oracle + confidence limit are set by `initialize_reserve` and never change, so the + bounds the Anchor version checks on update are checked there. + - **A hand-declared `LastRestartSlot` sysvar.** quasar-lang ships only the Clock and Rent sysvars, so `src/last_restart.rs` declares the 8-byte layout itself and reads it with the same `sol_get_sysvar` syscall. `price_scaled` diff --git a/finance/token-swap/quasar/CHANGELOG.md b/finance/token-swap/quasar/CHANGELOG.md index 7435faba..397608b3 100644 --- a/finance/token-swap/quasar/CHANGELOG.md +++ b/finance/token-swap/quasar/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [2026-10-04] - Mint order and empty reserves + +### Fixed + +- `initialize_pool` accepted its two mints in either order, so a pair could + have an (X, Y) pool and a (Y, X) pool side by side, splitting its liquidity. + It now requires `mint_a` to sort strictly below `mint_b` and fails with the + new `InvalidMintOrder` error otherwise, as the Anchor versions do. + `initialize_pool_rejects_unordered_mints` tries the reversed pair. +- `swap_tokens` priced a trade against an empty reserve. Tokens sent straight + to `pool_b` before the first deposit leave `pool_a` empty, and a swap of any + size into `pool_a` was then paid all of `pool_b`, with the invariant check + passing because the pre-trade product was zero. It now refuses a swap while + either LP-claimable reserve is zero, with the new `EmptyPoolReserve` error, + as the Anchor versions do. `swap_rejects_empty_reserve` funds `pool_b` alone + and tries the swap. + ## [2026-10-02] - Reserves at PDAs ### Changed diff --git a/finance/token-swap/quasar/README.md b/finance/token-swap/quasar/README.md index 6bf03301..4b7d68a6 100644 --- a/finance/token-swap/quasar/README.md +++ b/finance/token-swap/quasar/README.md @@ -31,6 +31,21 @@ Requesting more than the caller's token balance fails fast with always refers to the amounts actually moved. Error codes live in `src/error.rs` and start at 6000, matching the Anchor variant's offset. +## One pool per pair, and no trading against an empty side + +`initialize_pool` requires `mint_a` to sort strictly below `mint_b`, failing +with `InvalidMintOrder` otherwise, so each pair of mints has exactly one pool +rather than an (X, Y) pool and a (Y, X) pool splitting its liquidity. Passing +the same mint as both fails earlier, with Quasar's `AccountBorrowFailed`, since +the runtime hands the program the second slot as a duplicate of the first. + +`swap_tokens` refuses to trade while either LP-claimable reserve is zero, +failing with `EmptyPoolReserve`. Against an empty input side the +constant-product formula pays out the whole opposite reserve, and the +invariant check cannot catch it because the pre-trade product is zero. +`swap_rejects_empty_reserve` runs that swap against a pool whose `pool_b` was +funded directly before any deposit. + ## Setup From `finance/token-swap/quasar/`: diff --git a/finance/token-swap/quasar/src/error.rs b/finance/token-swap/quasar/src/error.rs index 30e052c0..440e9a14 100644 --- a/finance/token-swap/quasar/src/error.rs +++ b/finance/token-swap/quasar/src/error.rs @@ -45,4 +45,14 @@ pub enum AmmError { /// A `pool_a` or `pool_b` account is not the reserve recorded on the /// pool's `PoolConfig`. InvalidPoolVault, + /// `initialize_pool` was called with `mint_a >= mint_b`. Requiring a + /// strict ascending order gives each pair of mints exactly one canonical + /// pool PDA; without it an (X, Y) pool and a (Y, X) pool would both be + /// valid, fragmenting liquidity. + InvalidMintOrder, + /// `swap_tokens` found an LP-claimable (effective) reserve of zero. + /// Swapping against an empty reserve would let the constant-product curve + /// pay out the whole opposite side while the invariant check passes + /// vacuously (k = 0 >= 0), so the swap is rejected outright. + EmptyPoolReserve, } diff --git a/finance/token-swap/quasar/src/instructions/initialize_pool.rs b/finance/token-swap/quasar/src/instructions/initialize_pool.rs index ee9f0a1c..bfdaa3f0 100644 --- a/finance/token-swap/quasar/src/instructions/initialize_pool.rs +++ b/finance/token-swap/quasar/src/instructions/initialize_pool.rs @@ -1,5 +1,6 @@ use { crate::{ + error::AmmError, state::{Config, PoolConfig, PoolConfigInner}, ConfigPda, LiquidityMintPda, PoolAPda, PoolBPda, PoolPda, }, @@ -39,6 +40,12 @@ pub struct InitializePoolAccountConstraints { )] pub liquidity_provider_mint: Account, pub mint_a: Account, + /// `mint_a` must sort strictly below `mint_b`, so each pair of mints has + /// exactly one pool. Without it an (X, Y) pool and a (Y, X) pool would + /// both be valid and split the pair's liquidity between them. + #[account( + constraints(mint_a.address().as_ref() < mint_b.address().as_ref()) @ AmmError::InvalidMintOrder, + )] pub mint_b: Account, /// Pool's token A reserve, owned by `pool_config`, at a PDA of the pool. #[account( diff --git a/finance/token-swap/quasar/src/instructions/swap_tokens.rs b/finance/token-swap/quasar/src/instructions/swap_tokens.rs index 7c244482..a96958f3 100644 --- a/finance/token-swap/quasar/src/instructions/swap_tokens.rs +++ b/finance/token-swap/quasar/src/instructions/swap_tokens.rs @@ -115,6 +115,22 @@ pub fn handle_swap_tokens( .checked_sub(owed_b) .ok_or(AmmError::MathOverflow)?; + // Refuse to trade against an empty reserve. If the input side's effective + // reserve were 0, the constant-product formula below would output the + // ENTIRE opposite reserve (output = taxed_input * other / (0 + taxed_input) + // = other), draining that side - and the end-of-swap + // `new_invariant >= invariant` check would NOT catch it, because the + // pre-trade product k = 0 * other = 0 makes `0 >= 0` hold vacuously. A + // bootstrapped pool keeps both sides positive (the MINIMUM_LIQUIDITY floor + // on the first deposit, and swaps preserve the product), but tokens sent + // straight to a reserve before the first deposit leave one side funded and + // the other empty, and the curve's solvency must not rest on that never + // happening. + require!( + effective_pool_a > 0 && effective_pool_b > 0, + AmmError::EmptyPoolReserve + ); + let output_u128 = if input_is_token_a { (taxed_input as u128) .checked_mul(effective_pool_b as u128) diff --git a/finance/token-swap/quasar/src/tests.rs b/finance/token-swap/quasar/src/tests.rs index ca66a844..ed77c758 100644 --- a/finance/token-swap/quasar/src/tests.rs +++ b/finance/token-swap/quasar/src/tests.rs @@ -312,6 +312,37 @@ fn initialize_pool_creates_pool_config_and_lp_mint(test: &mut Test) { } } +/// Pools are keyed by an ordered pair: creating the (B, A) pool beside the +/// (A, B) pool would split the pair's liquidity across two pools, so the +/// reversed order is refused. A pool of a mint against itself never reaches +/// the order check: the same address in the `mint_a` and `mint_b` slots is a +/// duplicate account, which Quasar's account parsing refuses with +/// `AccountBorrowFailed` before any handler code runs. +#[quasar_test] +fn initialize_pool_rejects_unordered_mints(test: &mut Test) { + setup_pool(test); + + test.send(InitializePoolInstruction { + mint_a: MINT_B, + mint_b: MINT_A, + payer: PAYER, + }) + .fails_with(AmmError::InvalidMintOrder); + let config = test.derive_pda(ConfigPda::seeds()); + assert!( + test.account(test.derive_pda(PoolPda::seeds(&config, &MINT_B, &MINT_A))) + .is_none(), + "the reversed pool must not exist" + ); + + test.send(InitializePoolInstruction { + mint_a: MINT_A, + mint_b: MINT_A, + payer: PAYER, + }) + .fails(ProgramError::Runtime("AccountBorrowFailed".into())); +} + // ─── deposit_liquidity ─────────────────────────────────────────────────────── #[quasar_test] @@ -857,6 +888,41 @@ fn swap_rejects_substituted_pool_vault(test: &mut Test) { ); } +/// Tokens sent straight to `pool_b` before anyone deposits leave a pool with +/// one funded reserve and one empty one. Pricing a swap from that would pay +/// the trader the whole of `pool_b` for any input (`input * b / (0 + input) +/// = b`), and the invariant check would pass because the pre-trade product is +/// zero, so the swap is refused. +#[quasar_test] +fn swap_rejects_empty_reserve(test: &mut Test) { + let env = setup_pool(test); + let pool_b = pool_b(test); + test.add( + TokenAccount::new(MINT_B, env.pool_config) + .at(pool_b) + .amount(5_000_000), + ); + fund(test, TRADER, TRADER_TOKEN_A, TRADER_TOKEN_B, 1_000_000, 0); + + swap( + test, + TRADER, + TRADER_TOKEN_A, + TRADER_TOKEN_B, + true, + 1_000_000, + 1, + ) + .fails_with(AmmError::EmptyPoolReserve); + + assert_eq!( + test.tokens(pool_b), + 5_000_000, + "pool_b must be untouched after the refused swap" + ); + assert_eq!(test.tokens(TRADER_TOKEN_B), 0); +} + /// A deposit that names the depositor's own token accounts as the reserves /// must be refused. Without the `has_one` checks the LP tokens minted would be /// priced from those accounts' balances, and the deposit would land in them.