Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions finance/fundraiser/quasar/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions finance/fundraiser/quasar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions finance/fundraiser/quasar/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
11 changes: 8 additions & 3 deletions finance/fundraiser/quasar/src/instructions/contribute.rs
Original file line number Diff line number Diff line change
@@ -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::*,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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::*,
Expand Down Expand Up @@ -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);

Expand Down
13 changes: 13 additions & 0 deletions finance/fundraiser/quasar/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u64, ProgramError> {
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)]
Expand Down
43 changes: 41 additions & 2 deletions finance/fundraiser/quasar/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 6 additions & 0 deletions finance/lending/quasar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
17 changes: 17 additions & 0 deletions finance/token-swap/quasar/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
15 changes: 15 additions & 0 deletions finance/token-swap/quasar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`:
Expand Down
10 changes: 10 additions & 0 deletions finance/token-swap/quasar/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
7 changes: 7 additions & 0 deletions finance/token-swap/quasar/src/instructions/initialize_pool.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use {
crate::{
error::AmmError,
state::{Config, PoolConfig, PoolConfigInner},
ConfigPda, LiquidityMintPda, PoolAPda, PoolBPda, PoolPda,
},
Expand Down Expand Up @@ -39,6 +40,12 @@ pub struct InitializePoolAccountConstraints {
)]
pub liquidity_provider_mint: Account<Mint>,
pub mint_a: Account<Mint>,
/// `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<Mint>,
/// Pool's token A reserve, owned by `pool_config`, at a PDA of the pool.
#[account(
Expand Down
16 changes: 16 additions & 0 deletions finance/token-swap/quasar/src/instructions/swap_tokens.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
66 changes: 66 additions & 0 deletions finance/token-swap/quasar/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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.
Expand Down
Loading