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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ All notable changes to this repository are documented here.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [2026-10-02] - Token Swap (Quasar): reserves are bound to the pool

### Fixed

- `finance/token-swap/quasar` accepted any token accounts as `pool_a` and
`pool_b` in `deposit_liquidity`, `withdraw_liquidity`, `swap_tokens` and
`claim_admin_fees`, so a trader could price a swap from a token account of
their own and drain the real reserve on the other side. `PoolConfig` now
records both reserves and every handler checks them (`InvalidPoolVault`).
The Anchor v1 and v2 versions already bound them as associated token
accounts of the pool.
- `finance/token-swap/quasar`'s `swap_tokens` re-checks the invariant against
the vault balances after its transfers, rather than against computed ones.

## [2026-10-01] - Managed Fund's video script is removed

### Removed
Expand Down
18 changes: 18 additions & 0 deletions finance/token-swap/quasar/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
# Changelog

## [2026-10-02]

### Fixed

- `deposit_liquidity`, `withdraw_liquidity`, `swap_tokens` and
`claim_admin_fees` took `pool_a` and `pool_b` with no check that they were
the pool's reserves. A swap could name any mint-A token account the trader
owned as `pool_a`: the handler priced the trade from that account's balance,
sent the input into it, and paid out of the real `pool_b`. `PoolConfig` now
records both reserve addresses at `initialize_pool`, and the four handlers
check them with `has_one(pool_a)` and `has_one(pool_b)`, failing with the new
`InvalidPoolVault` error. `swap_rejects_substituted_pool_vault` and
`deposit_rejects_substituted_pool_vaults` run the attack. `PoolConfig` grows
by 64 bytes, so pools created before this change cannot be read by it.
- `swap_tokens` re-checked the constant-product invariant against reserves it
computed from the amounts it meant to transfer. It now reads both vaults'
balances after the transfers land, as the Anchor versions do.

## [2026-09-22]

### Fixed
Expand Down
3 changes: 3 additions & 0 deletions finance/token-swap/quasar/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,7 @@ pub enum AmmError {
MathOverflow,
/// The signer of `claim_admin_fees` does not match `Config.admin`.
Unauthorized,
/// A `pool_a` or `pool_b` account is not the reserve recorded on the
/// pool's `PoolConfig`.
InvalidPoolVault,
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ pub struct ClaimAdminFeesAccountConstraints {
#[account(
mut,
address = PoolPda::seeds(config.address(), mint_a.address(), mint_b.address()),
has_one(pool_a) @ AmmError::InvalidPoolVault,
has_one(pool_b) @ AmmError::InvalidPoolVault,
)]
pub pool_config: Account<PoolConfig>,
pub mint_a: Account<Mint>,
Expand Down Expand Up @@ -77,10 +79,14 @@ pub fn handle_claim_admin_fees(
let config_addr = *accounts.pool_config.config();
let mint_a_addr = *accounts.pool_config.mint_a();
let mint_b_addr = *accounts.pool_config.mint_b();
let pool_a_addr = *accounts.pool_config.pool_a();
let pool_b_addr = *accounts.pool_config.pool_b();
accounts.pool_config.set_inner(PoolConfigInner {
config: config_addr,
mint_a: mint_a_addr,
mint_b: mint_b_addr,
pool_a: pool_a_addr,
pool_b: pool_b_addr,
admin_fees_owed_a: 0,
admin_fees_owed_b: 0,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ pub struct DepositLiquidityAccountConstraints {
#[account(address = ConfigPda::seeds())]
pub config: Account<Config>,
/// Owns both reserves and is the LP mint's authority; signs the mint_to.
#[account(address = PoolPda::seeds(config.address(), mint_a.address(), mint_b.address()))]
#[account(
address = PoolPda::seeds(config.address(), mint_a.address(), mint_b.address()),
has_one(pool_a) @ AmmError::InvalidPoolVault,
has_one(pool_b) @ AmmError::InvalidPoolVault,
)]
pub pool_config: Account<PoolConfig>,
/// Depositor (must be signer to authorise transfers).
pub depositor: Signer,
Expand Down
4 changes: 4 additions & 0 deletions finance/token-swap/quasar/src/instructions/initialize_pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ pub fn handle_initialize_pool(
config: *accounts.config.address(),
mint_a: *accounts.mint_a.address(),
mint_b: *accounts.mint_b.address(),
// Recorded so every later handler can check the reserves it is
// handed are these two (`has_one(pool_a)`, `has_one(pool_b)`).
pool_a: *accounts.pool_a.address(),
pool_b: *accounts.pool_b.address(),
// No swaps have happened yet, so the admin has no fee claim. These
// accumulators are written by `swap_tokens` and zeroed by
// `claim_admin_fees`.
Expand Down
30 changes: 16 additions & 14 deletions finance/token-swap/quasar/src/instructions/swap_tokens.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ pub struct SwapTokensAccountConstraints {
#[account(
mut,
address = PoolPda::seeds(config.address(), mint_a.address(), mint_b.address()),
has_one(pool_a) @ AmmError::InvalidPoolVault,
has_one(pool_b) @ AmmError::InvalidPoolVault,
)]
pub pool_config: Account<PoolConfig>,
pub trader: Signer,
Expand Down Expand Up @@ -171,10 +173,14 @@ pub fn handle_swap_tokens(
let config_addr = *accounts.pool_config.config();
let mint_a_addr = *accounts.pool_config.mint_a();
let mint_b_addr = *accounts.pool_config.mint_b();
let pool_a_addr = *accounts.pool_config.pool_a();
let pool_b_addr = *accounts.pool_config.pool_b();
accounts.pool_config.set_inner(PoolConfigInner {
config: config_addr,
mint_a: mint_a_addr,
mint_b: mint_b_addr,
pool_a: pool_a_addr,
pool_b: pool_b_addr,
admin_fees_owed_a: new_owed_a,
admin_fees_owed_b: new_owed_b,
});
Expand Down Expand Up @@ -242,22 +248,18 @@ pub fn handle_swap_tokens(
.invoke()?;
}

// Verify invariant holds on the LP-claimable (effective) reserves.
// u128 + checked throughout - a raw `+`/`-` could wrap on extreme values.
let new_pool_a_raw = (pool_a_raw as u128)
.checked_add(if input_is_token_a { input as u128 } else { 0 })
.ok_or(AmmError::MathOverflow)?
.checked_sub(if !input_is_token_a { output as u128 } else { 0 })
.ok_or(AmmError::MathOverflow)?;
let new_pool_b_raw = (pool_b_raw as u128)
.checked_add(if !input_is_token_a { input as u128 } else { 0 })
.ok_or(AmmError::MathOverflow)?
.checked_sub(if input_is_token_a { output as u128 } else { 0 })
.ok_or(AmmError::MathOverflow)?;
let new_effective_a = new_pool_a_raw
// Verify invariant holds on the LP-claimable (effective) reserves, read
// from the vaults after the transfers have landed rather than computed
// from the amounts this handler meant to move. A check on computed figures
// only re-runs the math above; reading the vaults also catches a transfer
// that moved something other than what the math said. Quasar token
// accounts are zero-copy, so `amount()` reads the runtime buffer the CPIs
// just wrote and there is nothing to reload.
// u128 + checked throughout - a raw `-` could wrap on extreme values.
let new_effective_a = (accounts.pool_a.amount() as u128)
.checked_sub(new_owed_a as u128)
.ok_or(AmmError::MathOverflow)?;
let new_effective_b = new_pool_b_raw
let new_effective_b = (accounts.pool_b.amount() as u128)
.checked_sub(new_owed_b as u128)
.ok_or(AmmError::MathOverflow)?;
let new_invariant = new_effective_a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ pub struct WithdrawLiquidityAccountConstraints {
#[account(address = ConfigPda::seeds())]
pub config: Account<Config>,
/// Owns both reserves and signs the transfers out of them.
#[account(address = PoolPda::seeds(config.address(), mint_a.address(), mint_b.address()))]
#[account(
address = PoolPda::seeds(config.address(), mint_a.address(), mint_b.address()),
has_one(pool_a) @ AmmError::InvalidPoolVault,
has_one(pool_b) @ AmmError::InvalidPoolVault,
)]
pub pool_config: Account<PoolConfig>,
pub depositor: Signer,
/// LP mint at the LiquidityMintPda.
Expand Down
13 changes: 11 additions & 2 deletions finance/token-swap/quasar/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ pub struct Config {
///
/// Holds the metadata that identifies a single pool: which `Config` it belongs
/// to and which two mints it trades. The actual pool reserves live in separate
/// token accounts (`pool_a`, `pool_b`) that this account owns - they are not
/// stored here. This struct is the pool's *configuration*, not its state.
/// token accounts (`pool_a`, `pool_b`) that this account owns; their balances
/// are not stored here, only their addresses. This struct is the pool's *configuration*, not its state.
///
/// This account is also the pool's signing authority: it owns both reserves,
/// is the mint authority of the LP mint, and signs the transfers out of the
Expand All @@ -46,6 +46,15 @@ pub struct PoolConfig {
pub mint_a: Address,
/// Mint of token B.
pub mint_b: Address,
/// The pool's token A reserve, recorded by `initialize_pool`. Every
/// handler that touches the reserves checks the account it is handed
/// against this address (`has_one(pool_a)`), because a token account's
/// mint and authority do not identify it: anyone can create another
/// token account of mint A, and a swap that read its reserves from one
/// would price the trade from a balance the caller chose.
pub pool_a: Address,
/// The pool's token B reserve, recorded and checked as `pool_a` is.
pub pool_b: Address,
/// Admin's accumulated fee claim on token A, in base units. Sits
/// physically in `pool_a` but excluded from the LP curve and from
/// LP-withdrawable amounts. Swept by `claim_admin_fees`.
Expand Down
92 changes: 92 additions & 0 deletions finance/token-swap/quasar/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -800,6 +800,98 @@ fn swap_slippage_rejected(test: &mut Test) {
);
}

/// A swap that names a token account of its own as `pool_a` must be refused.
/// Without the `has_one(pool_a)` check the handler would price the trade from
/// that account's one-unit balance, send the trader's input into it (back to
/// the trader), and pay out nearly all of `pool_b` from the real reserve.
#[quasar_test]
fn swap_rejects_substituted_pool_vault(test: &mut Test) {
setup_pool(test);
seed_pool(test, 10_000_000, 10_000_000);

test.add(Wallet::new().at(BAD_ACTOR));
test.add(
TokenAccount::new(MINT_A, BAD_ACTOR)
.at(BAD_TOKEN_A)
.amount(1_000_001),
);
// A second mint-A account the attacker owns, holding one unit, passed as
// the pool's token A reserve.
let fake_pool_a = Pubkey::new_from_array([25; 32]);
test.add(
TokenAccount::new(MINT_A, BAD_ACTOR)
.at(fake_pool_a)
.amount(1),
);

test.send(SwapTokensInstruction {
trader: BAD_ACTOR,
mint_a: MINT_A,
mint_b: MINT_B,
pool_a: fake_pool_a,
pool_b: POOL_B,
token_a: BAD_TOKEN_A,
token_b: BAD_TOKEN_B,
payer: PAYER,
input_is_token_a: true,
input_amount: 1_000_000,
min_output_amount: 1,
})
.fails_with(AmmError::InvalidPoolVault);

assert_eq!(
test.tokens(POOL_B),
10_000_000,
"pool_b must be untouched after the refused swap"
);
}

/// 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.
#[quasar_test]
fn deposit_rejects_substituted_pool_vaults(test: &mut Test) {
setup_pool(test);
seed_pool(test, 10_000_000, 10_000_000);

fund(
test,
BAD_ACTOR,
BAD_TOKEN_A,
BAD_TOKEN_B,
1_000_000,
1_000_000,
);
let fake_pool_a = Pubkey::new_from_array([25; 32]);
let fake_pool_b = Pubkey::new_from_array([26; 32]);
test.add(
TokenAccount::new(MINT_A, BAD_ACTOR)
.at(fake_pool_a)
.amount(1),
);
test.add(
TokenAccount::new(MINT_B, BAD_ACTOR)
.at(fake_pool_b)
.amount(1),
);

test.send(DepositLiquidityInstruction {
depositor: BAD_ACTOR,
mint_a: MINT_A,
mint_b: MINT_B,
pool_a: fake_pool_a,
pool_b: fake_pool_b,
liquidity_provider_token: Pubkey::new_from_array([27; 32]),
token_a: BAD_TOKEN_A,
token_b: BAD_TOKEN_B,
payer: PAYER,
amount_a: 1_000_000,
amount_b: 1_000_000,
minimum_lp_tokens_out: 0,
})
.fails_with(AmmError::InvalidPoolVault);
}

// ─── claim_admin_fees ────────────────────────────────────────────────────────

#[quasar_test]
Expand Down
Loading