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
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,37 @@ 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-09-22] - The first-deposit minimum is counted in every share divisor

The token swap and perpetual futures examples both withhold a minimum from the
first deposit's LP shares, but only one side of their share math counted it.

- Token swap: `withdraw_liquidity` divided by `lp_supply + MINIMUM_LIQUIDITY`,
but `deposit_liquidity` minted later deposits against the bare `lp_supply`.
Every later depositor was minted slightly fewer LP tokens than they could
redeem; a donation to the vaults as large as a victim's deposit, rather than
101 times it, rounded that deposit down; and once every LP token was burned
the pool could never take a deposit again, since the floor's reserves stayed
behind with a supply of zero to divide by. Later deposits now divide by
`lp_supply + MINIMUM_LIQUIDITY` as well, in all three ports.
- Perpetual futures: `add_liquidity` withheld 1,000 shares from the first
deposit but both `add_liquidity` and `remove_liquidity` divided by the bare
share supply, so the withheld value belonged to the share holders pro rata
and locked nothing. Shares are priced against `Pool.liquidity` rather than
the vault balance, so a direct donation moves nothing, but a provider who is
also the pool's only trader can grow `liquidity` with their own funding
payments. A new test does exactly that: the attacker opens the pool with one
share, pays about 1,000 USDC of funding into it, and against the old program
withdraws about 1,500 USDC after a victim deposits. Both handlers now divide
by the share supply plus `MINIMUM_LIQUIDITY`, and a pool whose providers have
all left prices the next deposit against the locked slice instead of
bootstrapping it, in all three ports.
- The token swap's Kani crate models the deposit formula and proves a deposit
followed by a withdrawal returns at most the deposit and loses less than one
LP token's worth, a bound the bare-supply formula fails.
- The Anchor v1 ports take the same fixes, since they correct share
accounting rather than add features.

## [2026-09-22] - Vault strategy ignores donations

The vault strategy valued itself and paid withdrawals from its vaults' token
Expand Down
12 changes: 12 additions & 0 deletions finance/perpetual-futures/anchor-v1/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## 2026-09-22

`add_liquidity` and `remove_liquidity` now divide by the share supply plus
`MINIMUM_LIQUIDITY`, so the 1,000 shares withheld from the first deposit
belong to nobody and their slice of the pool stays locked. Before, both divided
by the bare supply, the withheld value was shared among the holders, and a
provider who was also the only trader could pay funding into `liquidity` to
inflate their single share and take part of the next deposit. A pool whose
providers have all left now prices the next deposit against the locked slice
rather than bootstrapping it. A sole provider's round trip returns the deposit
less the 1,000 minimum, and a new test runs the funding-based inflation attack.

## 2026-09-10

Remove the separate dataless signing PDA (seeds `["authority", pool]`) that
Expand Down
2 changes: 1 addition & 1 deletion finance/perpetual-futures/anchor-v1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ short profit/loss = size * (entry_price - price) / entry_price

### The liquidity pool and provider shares

There is no order book. Every trade is against one shared [liquidity pool](https://www.investopedia.com/terms/l/liquidity.asp) that other users fund; the pool is the counterparty to all of them: it pays trader profits and keeps trader losses. Providers receive shares priced against [mark-to-market](https://www.investopedia.com/terms/m/marktomarket.asp) assets-under-management (the pool's value if every open position were settled now), derived from running per-side accumulators rather than by iterating positions. Pricing against the marked value stops a provider exiting just before an in-flight trader profit is realized. The first deposit mints `deposit - MINIMUM_LIQUIDITY` shares (the Uniswap V2 convention) so the share supply never starts at a dust amount.
There is no order book. Every trade is against one shared [liquidity pool](https://www.investopedia.com/terms/l/liquidity.asp) that other users fund; the pool is the counterparty to all of them: it pays trader profits and keeps trader losses. Providers receive shares priced against [mark-to-market](https://www.investopedia.com/terms/m/marktomarket.asp) assets-under-management (the pool's value if every open position were settled now), derived from running per-side accumulators rather than by iterating positions. Pricing against the marked value stops a provider exiting just before an in-flight trader profit is realized. The first deposit mints `deposit - MINIMUM_LIQUIDITY` shares (the Uniswap V2 convention) so the share supply never starts at a dust amount, and both `add_liquidity` and `remove_liquidity` divide by the share supply plus `MINIMUM_LIQUIDITY`, so the withheld shares belong to nobody and their slice of the pool never leaves. That lock is what defeats share inflation here. Tokens sent straight to the vault move nothing, because shares are priced against `Pool.liquidity`, but `liquidity` grows with funding payments and trader losses, and a provider can also be the pool's only trader. An attacker holding one share who pays funding into the pool to make each share expensive owns 1 of 1,001 shares, so almost all of what they pay in stays with the withheld minimum.

### Reserved liquidity

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,13 @@ pub const SIZE_PRECISION: u128 = 1_000_000_000;

/// Liquidity-provider shares withheld from the first deposit. The first
/// depositor receives `deposit - MINIMUM_LIQUIDITY` shares rather than the full
/// amount, the same convention Uniswap V2 uses, so the share supply can never be
/// driven to a dust amount that rounding could exploit. (Share value here is
/// priced off tracked liquidity, not the vault token balance, so a direct
/// donation to the vault cannot move it.)
/// amount, the same convention Uniswap V2 uses, and both `add_liquidity` and
/// `remove_liquidity` divide by the share supply plus this minimum, so the
/// withheld shares belong to nobody and their slice of the pool never leaves.
/// Share value is priced off tracked liquidity, not the vault token balance,
/// so a direct donation to the vault cannot move it; but a provider who is also
/// the only trader can grow `liquidity` with their own funding payments and
/// losses, and it is the locked minimum that makes that cost them.
#[constant]
pub const MINIMUM_LIQUIDITY: u64 = 1_000;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,35 @@ pub fn handle_add_liquidity(
let price = refresh_price_and_funding(pool, &context.accounts.oracle_feed)?;

let lp_supply = context.accounts.lp_mint.supply;
let shares: u64 = if lp_supply == 0 {
let shares: u64 = if lp_supply == 0 && pool.liquidity == 0 {
// Bootstrap: shares track collateral one-for-one, less the withheld
// minimum, so the share supply can never start at a dust amount.
amount
.checked_sub(MINIMUM_LIQUIDITY)
.ok_or(PerpError::DepositTooSmall)?
} else {
// shares = amount * supply / assets-under-management, floored so the
// depositor never receives more than their pro-rata claim.
// shares = amount * (supply + MINIMUM_LIQUIDITY) / assets-under-management,
// floored so the depositor never receives more than their pro-rata
// claim. The withheld minimum counts as shares that nobody holds, here
// and in remove_liquidity, so its slice of the pool is locked for good.
// That lock is what stops share inflation: `liquidity` rises with
// every funding payment and trader loss, and a provider who is also
// the pool's only trader can pay those to themselves, so an attacker
// holding one share could otherwise make each share expensive enough
// to round a later deposit down and take part of it. With the minimum
// in the divisor their one share is 1 of 1_001, and whatever they pay
// in is spread across shares they cannot redeem.
//
// The same divisor covers a pool whose providers have all left: the
// minimum's slice is still in `liquidity`, so the next deposit is
// priced against it rather than bootstrapped.
let aum = liquidity_provider_aum(pool, price)?;
require!(aum > 0, PerpError::PoolInsolvent);
let total_shares = (lp_supply as u128)
.checked_add(MINIMUM_LIQUIDITY as u128)
.ok_or(PerpError::MathOverflow)?;
(amount as u128)
.checked_mul(lp_supply as u128)
.checked_mul(total_shares)
.ok_or(PerpError::MathOverflow)?
.checked_div(aum as u128)
.ok_or(PerpError::MathOverflow)?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use anchor_spl::{
},
};

use crate::constants::{POOL_SEED, VAULT_SEED};
use crate::constants::{MINIMUM_LIQUIDITY, POOL_SEED, VAULT_SEED};
use crate::errors::PerpError;
use crate::instructions::shared::{liquidity_provider_aum, refresh_price_and_funding};
use crate::state::Pool;
Expand All @@ -25,11 +25,16 @@ pub fn handle_remove_liquidity(
let aum = liquidity_provider_aum(pool, price)?;
require!(aum > 0, PerpError::PoolInsolvent);

// amount_out = shares * assets-under-management / supply, floored.
// amount_out = shares * assets-under-management / (supply + MINIMUM_LIQUIDITY),
// floored. The withheld minimum counts as shares nobody holds, as it does
// in add_liquidity, so its slice of the pool never leaves.
let total_shares = (lp_supply as u128)
.checked_add(MINIMUM_LIQUIDITY as u128)
.ok_or(PerpError::MathOverflow)?;
let amount_out: u64 = (shares as u128)
.checked_mul(aum as u128)
.ok_or(PerpError::MathOverflow)?
.checked_div(lp_supply as u128)
.checked_div(total_shares)
.ok_or(PerpError::MathOverflow)?
.try_into()
.map_err(|_| PerpError::MathOverflow)?;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -586,10 +586,10 @@ fn test_add_liquidity_subsequent_is_proportional() {

let provider_lp = derive_ata(&provider.pubkey(), &market.lp_mint);
let shares = get_token_account_balance(&market.svm, &provider_lp).unwrap();
// supply before second deposit was `first - 1_000`; second shares =
// second * supply / aum = second * (first - 1_000) / first.
let expected = ((second as u128) * ((first - 1_000) as u128) / (first as u128)) as u64;
assert_eq!(shares, expected);
// supply before second deposit was `first - 1_000`, and the withheld
// 1_000 counts as shares too, so second shares =
// second * (supply + 1_000) / aum = second * first / first = second.
assert_eq!(shares, second);
}

#[test]
Expand All @@ -607,12 +607,113 @@ fn test_add_and_remove_liquidity_round_trip() {
.remove_liquidity(&provider, provider_collateral, shares, 0)
.unwrap();

// As the only liquidity provider, they reclaim the full deposit: their
// shares carry the whole pool, since the withheld minimum was never minted
// to anyone else to hold it back.
// Even as the only liquidity provider, they reclaim their deposit less
// the withheld minimum: those 1_000 shares belong to nobody, and their
// 1_000 of liquidity stays in the pool.
let returned = get_token_account_balance(&market.svm, &provider_collateral).unwrap();
assert_eq!(returned, deposit);
assert_eq!(market.pool_state().liquidity, 0);
assert_eq!(returned, deposit - 1_000);
assert_eq!(market.pool_state().liquidity, 1_000);

// The next provider is priced against the minimum's slice rather than
// bootstrapped: 5_000 * (0 + 1_000) / 1_000 = 5_000 shares, the same
// one share per unit the pool has always charged.
let (next, next_collateral) = market.funded_trader(5_000);
market
.add_liquidity(&next, next_collateral, 5_000, 0)
.unwrap();
let next_lp = derive_ata(&next.pubkey(), &market.lp_mint);
assert_eq!(
get_token_account_balance(&market.svm, &next_lp).unwrap(),
5_000
);
}

/// First-depositor share inflation without a donation. The vault balance is
/// not what shares are priced against, so tokens sent straight to the vault
/// move nothing, but `liquidity` itself grows with every funding payment and
/// every trader loss, and a liquidity provider can also be the pool's only
/// trader. An attacker opens the pool with the smallest deposit that clears
/// the minimum (1 share), then pays funding on a small long of their own until
/// `liquidity` is large, which makes each share expensive in the same way a
/// donation would. The funding rate is a pool parameter the pool's creator
/// sets, and nothing stops the attacker from being that creator.
///
/// The withheld `MINIMUM_LIQUIDITY` counts as shares in both directions, so
/// the attacker's single share is 1 of 1_001 and the value pumped into the
/// pool is spread across shares nobody can redeem. A later depositor is minted
/// their fair share, and the attacker gets back a small fraction of the
/// funding they paid in.
#[test]
fn test_inflating_liquidity_through_own_trades_does_not_pay() {
// A steep funding rate: 1_000 of notional pays 1_000 USDC over 1_000 slots.
let mut market = Market::new(dollars(100), 1_000_000_000_000);

let (attacker, attacker_collateral) = market.funded_trader(10_000 * ONE_USDC);
market
.add_liquidity(&attacker, attacker_collateral, 1_001, 0)
.unwrap();
let attacker_lp = derive_ata(&attacker.pubkey(), &market.lp_mint);
assert_eq!(
get_token_account_balance(&market.svm, &attacker_lp).unwrap(),
1
);

// The pool holds 1_001, so it can back a position of up to 1_001 notional.
// Heavy collateral keeps the position far from liquidation while funding
// drains it into `liquidity`.
market
.open_position(
&attacker,
attacker_collateral,
Side::Long,
2_000 * ONE_USDC,
1_000,
0,
)
.unwrap();
let opened_at = market.current_slot();
market.warp(opened_at + 1_000);
market.set_price(dollars(100));
market
.close_position(&attacker, attacker_collateral, Side::Long, 0)
.unwrap();
let pumped_liquidity = market.pool_state().liquidity;
assert!(pumped_liquidity > 1_000 * ONE_USDC);
let attacker_spent =
10_000 * ONE_USDC - get_token_account_balance(&market.svm, &attacker_collateral).unwrap();

// The victim deposits just under twice the pumped liquidity. Dividing by
// the bare share supply of 1 would mint them a single share, and the
// attacker's one share would then redeem half the pool.
let victim_deposit = 2 * pumped_liquidity - 1;
let (victim, victim_collateral) = market.funded_trader(victim_deposit);
market
.add_liquidity(&victim, victim_collateral, victim_deposit, 0)
.unwrap();
let victim_lp = derive_ata(&victim.pubkey(), &market.lp_mint);
let victim_shares = get_token_account_balance(&market.svm, &victim_lp).unwrap();

// The attacker exits with their one share.
let before_exit = get_token_account_balance(&market.svm, &attacker_collateral).unwrap();
market
.remove_liquidity(&attacker, attacker_collateral, 1, 0)
.unwrap();
let attacker_back =
get_token_account_balance(&market.svm, &attacker_collateral).unwrap() - before_exit;
assert!(
attacker_back * 100 < attacker_spent,
"attacker spent {attacker_spent} and got back {attacker_back}"
);

// The victim exits with everything and gets back all but a sliver.
market
.remove_liquidity(&victim, victim_collateral, victim_shares, 0)
.unwrap();
let victim_back = get_token_account_balance(&market.svm, &victim_collateral).unwrap();
assert!(
victim_back * 1_000 >= victim_deposit * 999,
"victim deposited {victim_deposit} and got back {victim_back}"
);
}

#[test]
Expand Down
12 changes: 12 additions & 0 deletions finance/perpetual-futures/anchor/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## 2026-09-22

`add_liquidity` and `remove_liquidity` now divide by the share supply plus
`MINIMUM_LIQUIDITY`, so the 1,000 shares withheld from the first deposit
belong to nobody and their slice of the pool stays locked. Before, both divided
by the bare supply, the withheld value was shared among the holders, and a
provider who was also the only trader could pay funding into `liquidity` to
inflate their single share and take part of the next deposit. A pool whose
providers have all left now prices the next deposit against the locked slice
rather than bootstrapping it. A sole provider's round trip returns the deposit
less the 1,000 minimum, and a new test runs the funding-based inflation attack.

## 2026-09-10

Remove the separate dataless signing PDA (seeds `["authority", pool]`) that
Expand Down
2 changes: 1 addition & 1 deletion finance/perpetual-futures/anchor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ short profit/loss = size * (entry_price - price) / entry_price

### The liquidity pool and provider shares

There is no order book. Every trade is against one shared [liquidity pool](https://www.investopedia.com/terms/l/liquidity.asp) that other users fund; the pool is the counterparty to all of them: it pays trader profits and keeps trader losses. Providers receive shares priced against [mark-to-market](https://www.investopedia.com/terms/m/marktomarket.asp) assets-under-management (the pool's value if every open position were settled now), derived from running per-side accumulators rather than by iterating positions. Pricing against the marked value stops a provider exiting just before an in-flight trader profit is realized. The first deposit mints `deposit - MINIMUM_LIQUIDITY` shares (the Uniswap V2 convention) so the share supply never starts at a dust amount.
There is no order book. Every trade is against one shared [liquidity pool](https://www.investopedia.com/terms/l/liquidity.asp) that other users fund; the pool is the counterparty to all of them: it pays trader profits and keeps trader losses. Providers receive shares priced against [mark-to-market](https://www.investopedia.com/terms/m/marktomarket.asp) assets-under-management (the pool's value if every open position were settled now), derived from running per-side accumulators rather than by iterating positions. Pricing against the marked value stops a provider exiting just before an in-flight trader profit is realized. The first deposit mints `deposit - MINIMUM_LIQUIDITY` shares (the Uniswap V2 convention) so the share supply never starts at a dust amount, and both `add_liquidity` and `remove_liquidity` divide by the share supply plus `MINIMUM_LIQUIDITY`, so the withheld shares belong to nobody and their slice of the pool never leaves. That lock is what defeats share inflation here. Tokens sent straight to the vault move nothing, because shares are priced against `Pool.liquidity`, but `liquidity` grows with funding payments and trader losses, and a provider can also be the pool's only trader. An attacker holding one share who pays funding into the pool to make each share expensive owns 1 of 1,001 shares, so almost all of what they pay in stays with the withheld minimum.

### Reserved liquidity

Expand Down
Loading
Loading