From ad0b894474f5bbde4c4671a7bc2749aec2129ce9 Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Tue, 22 Sep 2026 23:51:54 +0000 Subject: [PATCH 1/2] token-swap: count the minimum liquidity in the deposit divisor withdraw_liquidity divided by lp_supply + MINIMUM_LIQUIDITY, but deposit_liquidity minted later deposits against the bare lp_supply, so the two formulas measured the pool in different units: - 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 to zero LP tokens; - once every LP token was burned, the floor's reserves stayed behind with a supply of zero to divide by, and every later deposit failed with DepositTooSmall. Later deposits now divide by lp_supply + MINIMUM_LIQUIDITY in all three ports, matching Uniswap V2, whose totalSupply includes the floor it mints to the zero address. Tests: the expected LP amounts follow the new divisor; a doubled pool mints the first deposit's whole sqrt(a*b) and redeems the deposit to the unit; new tests run the donation attack and deposit into an emptied pool. All five failed against the old program. Kani: the crate models the deposit formula and proves a deposit followed by a withdrawal returns at most the deposit and less than one LP token's worth short of it (the bare-supply formula fails the lower bound), and that rounding a deposit to zero needs a reserve above the deposit times attacker_lp + MINIMUM_LIQUIDITY. Claude-Session: https://claude.ai/code/session_01HagqhpPNqcywT37qqgnFey --- finance/token-swap/README.md | 3 +- finance/token-swap/anchor-v1/CHANGELOG.md | 10 + .../src/instructions/deposit_liquidity.rs | 54 +++-- .../programs/token-swap/tests/test_swap.rs | 184 ++++++++++++++++-- finance/token-swap/anchor/CHANGELOG.md | 10 + .../src/instructions/deposit_liquidity.rs | 54 +++-- .../programs/token-swap/tests/test_swap.rs | 184 ++++++++++++++++-- finance/token-swap/kani-proofs/README.md | 8 +- finance/token-swap/kani-proofs/src/lib.rs | 129 +++++++++++- finance/token-swap/quasar/CHANGELOG.md | 11 ++ .../src/instructions/deposit_liquidity.rs | 22 ++- finance/token-swap/quasar/src/tests.rs | 64 ++++-- 12 files changed, 638 insertions(+), 95 deletions(-) diff --git a/finance/token-swap/README.md b/finance/token-swap/README.md index 0d5ec1ae9..70c247314 100644 --- a/finance/token-swap/README.md +++ b/finance/token-swap/README.md @@ -113,6 +113,7 @@ Transfers token A and token B from the depositor to the pool, then mints LP toke 1. Compute `amount_b_required = amount_a * effective_pool_b / effective_pool_a`. 2. If `amount_b_required ≤ amount_b`, use `(amount_a, amount_b_required)` - the depositor offered enough B, so we take the full A and clamp B down. 3. Otherwise, compute `amount_a_required = amount_b * effective_pool_a / effective_pool_b` and use `(amount_a_required, amount_b)` - B is the binding side, so we take the full B and clamp A down. +- The LP amount for a later deposit is `min(amount_a * total / effective_pool_a, amount_b * total / effective_pool_b)` with `total = liquidity_provider_mint.supply + MINIMUM_LIQUIDITY`: the unminted floor counts as supply, exactly as it does in `withdraw_liquidity`, so a deposit is minted the share of the pool it can later redeem. Counting the floor on both sides is also what makes the donation (inflation) attack cost the attacker: to round a later deposit down to zero LP tokens, the attacker has to donate at least `MINIMUM_LIQUIDITY + 1` times that deposit, and the floor keeps its share of the donation. - All ratio math runs in `u128` with checked arithmetic. No floats are used for money; rounding is always toward the pool (the depositor never gets a sub-base-unit advantage). - The ratio is computed on the **effective reserves** (`pool_X.amount - admin_fees_owed_X`). The admin's owed slice isn't LP-claimable capital, so it doesn't shift the deposit ratio. - If the clamp rounds one of the amounts down to zero (e.g. a depositor offering a sub-base-unit fraction against a thick pool), the handler reverts with `DepositAmountTooSmall` rather than minting LP shares against a zero contribution. @@ -243,7 +244,7 @@ At the current 1:5 ratio, Bob deposits **100 NVDAx and 500 USDC**. - **Accounts:** same shape as Step 3, `depositor` = Bob - **Args:** `amount_a = 100`, `amount_b = 500`, `minimum_lp_tokens_out = 223_000_000` (Bob quoted ~223.6 LP offchain and is unwilling to accept less than ~223.0 if the pool shifts before his tx lands; units here are LP base units at the LP mint's decimals) -Math: subsequent deposits get `min(amount_a / pool_a, amount_b / pool_b) × current_lp_supply = min(100/20, 500/100) × 44.72 ≈ 223.6` LP tokens. +Math: subsequent deposits get `min(amount_a / pool_a, amount_b / pool_b) × (current_lp_supply + MINIMUM_LIQUIDITY) = min(100/20, 500/100) × 44.72 ≈ 223.6` LP tokens (the 100 base-unit floor is too small to show at this precision). NVDAx/USDC pool state: **120 NVDAx, 600 USDC**. LP supply ~268.32. Bob owns ~83%, Alice ~17%. diff --git a/finance/token-swap/anchor-v1/CHANGELOG.md b/finance/token-swap/anchor-v1/CHANGELOG.md index 944402186..3d6e12e98 100644 --- a/finance/token-swap/anchor-v1/CHANGELOG.md +++ b/finance/token-swap/anchor-v1/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 2026-09-22 + +`deposit_liquidity` now mints later deposits against the LP supply plus +`MINIMUM_LIQUIDITY`, the divisor `withdraw_liquidity` already used. Dividing by +the bare supply minted every depositor slightly less than they could redeem, +let a donation as large as a victim's deposit (rather than 101 times it) round +that deposit down to zero LP tokens, and left a pool whose LP tokens were all +burned unable to take another deposit. New tests cover the donation attack and +the deposit into an emptied pool. + ## 2026-09-10 Removed the separate dataless signer PDA (seeds `[config, mint_a, mint_b, diff --git a/finance/token-swap/anchor-v1/programs/token-swap/src/instructions/deposit_liquidity.rs b/finance/token-swap/anchor-v1/programs/token-swap/src/instructions/deposit_liquidity.rs index e6c5b0f64..3105c9381 100644 --- a/finance/token-swap/anchor-v1/programs/token-swap/src/instructions/deposit_liquidity.rs +++ b/finance/token-swap/anchor-v1/programs/token-swap/src/instructions/deposit_liquidity.rs @@ -123,26 +123,36 @@ pub fn handle_deposit_liquidity( // - Initial deposit (pool creation): `liquidity = sqrt(a * b) - MINIMUM_LIQUIDITY`. // The `MINIMUM_LIQUIDITY` floor is never minted to anyone: the first // depositor receives `sqrt(a * b) - MINIMUM_LIQUIDITY` LP tokens, and - // withdraw_liquidity adds the floor back into its supply denominator, - // so the floor's share of the reserves stays in the pool, claimable by - // nobody while any LP supply exists. This stops the first depositor - // from draining the pool to a sub-minor-unit ratio. (Uniswap V2 - // instead mints the floor to the zero address; here, if every LP - // token is burned, the floor's leftover reserves simply seed the next - // bootstrap deposit.) - // - Subsequent deposit: `liquidity = min(a * supply / pool_a, b * supply / pool_b)`. - // This is the canonical Uniswap V2 formula: mint LP tokens in - // proportion to the depositor's share of each reserve, taking the - // smaller side as the binding constraint. After the ratio clamp - // above, both sides give the same result; `min` is kept as an - // invariant safety net and to match the published formula. + // from then on both the subsequent-deposit branch below and + // withdraw_liquidity divide by `lp_supply + MINIMUM_LIQUIDITY`, so the + // floor counts as supply that nobody holds. Its share of the reserves + // stays in the pool, claimable by nobody. An attacker who inflates the + // value behind each share by donating to the vaults inflates the value + // behind those units too, and loses that part of the donation. + // (Uniswap V2 instead mints the floor to the zero address; here, if + // every LP token is burned, the floor's leftover reserves stay behind + // and the next depositor mints against them through the + // subsequent-deposit branch.) + // - Subsequent deposit: `liquidity = min(a * total / pool_a, b * total / pool_b)` + // with `total = lp_supply + MINIMUM_LIQUIDITY`. This is the canonical + // Uniswap V2 formula: mint LP tokens in proportion to the depositor's + // share of each reserve, taking the smaller side as the binding + // constraint. After the ratio clamp above, both sides give the same + // result; `min` is kept as an invariant safety net and to match the + // published formula (Uniswap V2's `totalSupply` includes the floor it + // minted to the zero address). `total` must be the same divisor + // withdraw_liquidity uses: dividing by the bare mint supply here would + // mint every depositor slightly less than they could redeem, and would + // let a donation merely as large as a victim's deposit round that + // deposit down to zero LP tokens, where counting the floor makes the + // attacker donate at least `MINIMUM_LIQUIDITY + 1` times the deposit. // - // All math is in `u128` with checked arithmetic. `amount * supply` can - // overflow `u64` (both are `u64`), but `u128` absorbs it: max product - // is `(2^64 - 1)^2 < 2^128`. We multiply before dividing to keep - // precision, then round down (floor) so the pool keeps any sub-unit - // rounding dust - protocol-favouring rounding, per the financial-math - // rules. + // All math is in `u128` with checked arithmetic. `amount * total` can + // overflow `u64`, but `u128` absorbs it for any supply a real mint can + // reach, and the checked multiply reports the rest. We multiply before + // dividing to keep precision, then round down (floor) so the pool keeps + // any sub-unit rounding dust - protocol-favouring rounding, per the + // financial-math rules. let liquidity: u64 = if pool_creation { let product = (amount_a as u128) .checked_mul(amount_b as u128) @@ -156,7 +166,11 @@ pub fn handle_deposit_liquidity( .checked_sub(MINIMUM_LIQUIDITY) .ok_or(AmmError::MathOverflow)? } else { - let total_supply = context.accounts.liquidity_provider_mint.supply as u128; + // The floor is part of the supply the reserves are shared among, as + // it is in withdraw_liquidity. + let total_supply = (context.accounts.liquidity_provider_mint.supply as u128) + .checked_add(MINIMUM_LIQUIDITY as u128) + .ok_or(AmmError::MathOverflow)?; let liquidity_from_a = (amount_a as u128) .checked_mul(total_supply) .ok_or(AmmError::MathOverflow)? 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 0d4a94fa2..6a6e51358 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 @@ -13,6 +13,11 @@ use { solana_signer::Signer, }; +/// Mirrors `constants::MINIMUM_LIQUIDITY`: the LP units withheld from the +/// first deposit and never minted. Deposits and withdrawals both count them as +/// supply. +const MINIMUM_LIQUIDITY: u64 = 100; + fn token_program_id() -> Pubkey { "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" .parse() @@ -1078,7 +1083,8 @@ fn test_deposit_too_small_for_ratio_reverts() { } /// Test F: LP-mint correctness for a subsequent deposit at the current ratio. -/// With the Uniswap V2 formula `min(a*supply/pool_a, b*supply/pool_b)`, an +/// With the Uniswap V2 formula `min(a*total/pool_a, b*total/pool_b)`, where +/// `total` is the LP supply plus the unminted `MINIMUM_LIQUIDITY` floor, an /// equal-ratio deposit must mint LP tokens exactly proportional to its share /// of the pool. Previously the program used `sqrt(a*b)` for *all* deposits, /// which over- or under-minted depending on pool size and broke @@ -1100,8 +1106,9 @@ fn test_lp_mint_proportional_to_share_of_pool() { ); // Second deposit at the same 4:1 ratio doubles the pool. The proportional - // formula must mint exactly `lp_supply_initial` more LP (so the depositor - // doubles their stake). + // formula must mint exactly `lp_supply_initial + MINIMUM_LIQUIDITY` more + // LP: the first deposit's whole `sqrt(a*b)`, floor included, because the + // second depositor owns exactly as much of the pool as it does. let lp_before_second = get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(); // Same depositor + same args as the first deposit → identical tx // signature. Bump the blockhash so litesvm doesn't reject the second @@ -1111,12 +1118,32 @@ fn test_lp_mint_proportional_to_share_of_pool() { let lp_after_second = get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(); let minted_on_second = lp_after_second - lp_before_second; - // min(4M * 1_999_900 / 4M, 1M * 1_999_900 / 1M) = 1_999_900. - let expected_second: u64 = 1_999_900; + // min(4M * 2_000_000 / 4M, 1M * 2_000_000 / 1M) = 2_000_000. + let expected_second: u64 = 2_000_000; assert_eq!( minted_on_second, expected_second, - "second deposit (same ratio, same size) should mint the same LP \ - amount as the initial deposit minus the locked floor" + "second deposit (same ratio, same size) should mint the initial \ + deposit's LP amount plus the locked floor" + ); + + // Minted and burned LP tokens are the same fraction of the pool, so + // burning what the second deposit minted returns that deposit exactly. + // Dividing the deposit by the bare supply would have minted 1_999_900 + // here, redeemable for only 3_999_900 of the 4_000_000 A deposited. + let pool_a_before = get_token_account_balance(&ts.svm, &ts.pool_a).unwrap(); + let withdraw = withdraw_ix_with_min(&ts, minted_on_second, 0, 0); + send_transaction_from_instructions( + &mut ts.svm, + vec![withdraw], + &[&ts.payer, &ts.admin], + &ts.payer.pubkey(), + ) + .expect("withdraw the second deposit's LP"); + let pool_a_after = get_token_account_balance(&ts.svm, &ts.pool_a).unwrap(); + assert_eq!( + pool_a_before - pool_a_after, + 4_000_000, + "burning the second deposit's LP returns the A it deposited" ); } @@ -1131,7 +1158,8 @@ fn test_lp_mint_after_swap_uses_effective_reserves() { // Seed 10M : 10M, then swap A→B so the pool shifts off 1:1. send_deposit(&mut ts, 10_000_000, 10_000_000).expect("initial deposit"); let lp_after_initial = get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(); - let total_supply_before_second = lp_after_initial; + // The divisor counts the unminted MINIMUM_LIQUIDITY floor as supply. + let total_supply_before_second = lp_after_initial + MINIMUM_LIQUIDITY; let swap_in = 1_000_000u64; swap_a_to_b(&mut ts, swap_in); @@ -1152,7 +1180,7 @@ fn test_lp_mint_after_swap_uses_effective_reserves() { let deposit_a = ((deposit_b as u128) * (effective_pool_a as u128) / (effective_pool_b as u128)) as u64; - // Expected LP minted = min(a*supply/pool_a, b*supply/pool_b) using the + // Expected LP minted = min(a*total/pool_a, b*total/pool_b) using the // *clamped* (a, b) the program actually transfers. After clamp at the // exact ratio, the binding side is whichever clamp picks: in // deposit_liquidity, `amount_b_required = amount_a * pool_b / pool_a`. @@ -1178,6 +1206,132 @@ fn test_lp_mint_after_swap_uses_effective_reserves() { ); } +/// Test H: the donation (inflation) attack. An attacker opens the pool with +/// the smallest deposit that clears the floor, so they hold 1 LP token and 101 +/// units of supply share the reserves (their 1 plus the unminted 100). They +/// then donate straight to the vaults to make each unit of supply expensive, +/// hoping a later deposit rounds down to zero LP tokens. Because deposits +/// divide by the same `supply + MINIMUM_LIQUIDITY` as withdrawals, a donation +/// as large as the victim's deposit is nowhere near enough: the victim is +/// minted their share, and the attacker cannot get the donation back, because +/// 100 of the 101 units it was spread over belong to no one. +#[test] +fn test_donation_cannot_round_a_later_deposit_to_zero() { + let mut ts = full_setup(); + + // sqrt(101 * 101) = 101, minus the 100 floor: the attacker holds 1 LP. + send_deposit(&mut ts, 101, 101).expect("attacker opens the pool"); + assert_eq!( + get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(), + 1 + ); + + // The donation: tokens sent straight to the vaults, no LP minted. + let donation: u64 = 1_000_000; + mint_tokens_to_token_account(&mut ts.svm, &ts.mint_a, &ts.pool_a, donation, &ts.admin).unwrap(); + mint_tokens_to_token_account(&mut ts.svm, &ts.mint_b, &ts.pool_b, donation, &ts.admin).unwrap(); + + // The victim deposits exactly as much as was donated. Dividing by the + // bare supply of 1 would mint 1_000_000 * 1 / 1_000_101 = 0 LP tokens. + let victim = create_wallet(&mut ts.svm, 10_000_000_000).unwrap(); + let victim_a = + create_associated_token_account(&mut ts.svm, &victim.pubkey(), &ts.mint_a, &ts.payer) + .unwrap(); + let victim_b = + create_associated_token_account(&mut ts.svm, &victim.pubkey(), &ts.mint_b, &ts.payer) + .unwrap(); + mint_tokens_to_token_account(&mut ts.svm, &ts.mint_a, &victim_a, donation, &ts.admin).unwrap(); + mint_tokens_to_token_account(&mut ts.svm, &ts.mint_b, &victim_b, donation, &ts.admin).unwrap(); + let victim_lp = derive_ata(&victim.pubkey(), &ts.liquidity_provider_mint); + let victim_deposit = Instruction::new_with_bytes( + ts.program_id, + &swap_example::instruction::DepositLiquidity { + amount_a: donation, + amount_b: donation, + minimum_lp_tokens_out: 0, + } + .data(), + swap_example::accounts::DepositLiquidityAccountConstraints { + pool_config: ts.pool_config_key, + depositor: victim.pubkey(), + liquidity_provider_mint: ts.liquidity_provider_mint, + mint_a: ts.mint_a, + mint_b: ts.mint_b, + pool_a: ts.pool_a, + pool_b: ts.pool_b, + liquidity_provider_token: victim_lp, + token_a: victim_a, + token_b: victim_b, + payer: ts.payer.pubkey(), + token_program: token_program_id(), + associated_token_program: ata_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ); + send_transaction_from_instructions( + &mut ts.svm, + vec![victim_deposit], + &[&ts.payer, &victim], + &ts.payer.pubkey(), + ) + .expect("victim deposit mints LP tokens"); + + // 1_000_000 * 101 / 1_000_101 = 100 LP tokens. + assert_eq!(get_token_account_balance(&ts.svm, &victim_lp).unwrap(), 100); + + // The attacker exits. Their 1 LP token is 1 of 201 units of supply over a + // 2_000_101 reserve: 9_950 of each token, back from a 1_000_101 outlay. + let attacker_exit = withdraw_ix_with_min(&ts, 1, 0, 0); + let attacker_a_before = get_token_account_balance(&ts.svm, &ts.holder_account_a).unwrap(); + send_transaction_from_instructions( + &mut ts.svm, + vec![attacker_exit], + &[&ts.payer, &ts.admin], + &ts.payer.pubkey(), + ) + .expect("attacker withdraws"); + let attacker_a_back = + get_token_account_balance(&ts.svm, &ts.holder_account_a).unwrap() - attacker_a_before; + assert_eq!(attacker_a_back, 9_950); + assert!( + attacker_a_back < donation / 100, + "the attacker recovers under 1% of the donation" + ); +} + +/// Test I: once every LP token is burned, the floor's share of the reserves +/// is still in the pool, so the next deposit takes the subsequent-deposit +/// branch and mints against the floor alone. Dividing by the bare supply of +/// zero would mint nothing and leave the pool unable to take deposits again. +#[test] +fn test_deposit_after_every_lp_token_is_burned() { + let mut ts = full_setup(); + send_deposit(&mut ts, 4_000_000, 4_000_000).expect("initial deposit"); + let lp = get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(); + + // Burn all 3_999_900 LP tokens: 3_999_900 * 4_000_000 / 4_000_000 of + // each side leaves, and the floor's 100 of each side stays. + let withdraw_all = withdraw_ix_with_min(&ts, lp, 0, 0); + send_transaction_from_instructions( + &mut ts.svm, + vec![withdraw_all], + &[&ts.payer, &ts.admin], + &ts.payer.pubkey(), + ) + .expect("withdraw everything"); + assert_eq!(get_token_account_balance(&ts.svm, &ts.pool_a).unwrap(), 100); + assert_eq!(get_token_account_balance(&ts.svm, &ts.pool_b).unwrap(), 100); + + // 1_000_000 * (0 + 100) / 100 = 1_000_000 LP tokens. + ts.svm.expire_blockhash(); + send_deposit(&mut ts, 1_000_000, 1_000_000).expect("deposit into the emptied pool"); + assert_eq!( + get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(), + 1_000_000 + ); +} + // --------------------------------------------------------------------------- // Slippage protection + invariant tests // --------------------------------------------------------------------------- @@ -1336,13 +1490,15 @@ fn test_deposit_reverts_when_lp_below_min() { // Compute the LP that a `(4M, 1M)` deposit at the current ratio would // mint, using the same formula as the program (no probe tx needed): - // liquidity = min(a*supply/pool_a, b*supply/pool_b) - // Effective reserves == raw reserves here because no swaps have happened. - let lp_supply = get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(); + // liquidity = min(a*total/pool_a, b*total/pool_b) + // where total = LP supply + MINIMUM_LIQUIDITY. Effective reserves == raw + // reserves here because no swaps have happened. + let total_supply = + get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap() + MINIMUM_LIQUIDITY; let pool_a_amount = get_token_account_balance(&ts.svm, &ts.pool_a).unwrap(); let pool_b_amount = get_token_account_balance(&ts.svm, &ts.pool_b).unwrap(); - let lp_from_a = (4_000_000u128 * lp_supply as u128) / pool_a_amount as u128; - let lp_from_b = (1_000_000u128 * lp_supply as u128) / pool_b_amount as u128; + let lp_from_a = (4_000_000u128 * total_supply as u128) / pool_a_amount as u128; + let lp_from_b = (1_000_000u128 * total_supply as u128) / pool_b_amount as u128; let achievable_lp = lp_from_a.min(lp_from_b) as u64; // Require *strictly more* than that - the deposit must revert. diff --git a/finance/token-swap/anchor/CHANGELOG.md b/finance/token-swap/anchor/CHANGELOG.md index 944402186..3d6e12e98 100644 --- a/finance/token-swap/anchor/CHANGELOG.md +++ b/finance/token-swap/anchor/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 2026-09-22 + +`deposit_liquidity` now mints later deposits against the LP supply plus +`MINIMUM_LIQUIDITY`, the divisor `withdraw_liquidity` already used. Dividing by +the bare supply minted every depositor slightly less than they could redeem, +let a donation as large as a victim's deposit (rather than 101 times it) round +that deposit down to zero LP tokens, and left a pool whose LP tokens were all +burned unable to take another deposit. New tests cover the donation attack and +the deposit into an emptied pool. + ## 2026-09-10 Removed the separate dataless signer PDA (seeds `[config, mint_a, mint_b, diff --git a/finance/token-swap/anchor/programs/token-swap/src/instructions/deposit_liquidity.rs b/finance/token-swap/anchor/programs/token-swap/src/instructions/deposit_liquidity.rs index f8afeb86c..cffdfdf66 100644 --- a/finance/token-swap/anchor/programs/token-swap/src/instructions/deposit_liquidity.rs +++ b/finance/token-swap/anchor/programs/token-swap/src/instructions/deposit_liquidity.rs @@ -124,26 +124,36 @@ pub fn handle_deposit_liquidity( // - Initial deposit (pool creation): `liquidity = sqrt(a * b) - MINIMUM_LIQUIDITY`. // The `MINIMUM_LIQUIDITY` floor is never minted to anyone: the first // depositor receives `sqrt(a * b) - MINIMUM_LIQUIDITY` LP tokens, and - // withdraw_liquidity adds the floor back into its supply denominator, - // so the floor's share of the reserves stays in the pool, claimable by - // nobody while any LP supply exists. This stops the first depositor - // from draining the pool to a sub-minor-unit ratio. (Uniswap V2 - // instead mints the floor to the zero address; here, if every LP - // token is burned, the floor's leftover reserves simply seed the next - // bootstrap deposit.) - // - Subsequent deposit: `liquidity = min(a * supply / pool_a, b * supply / pool_b)`. - // This is the canonical Uniswap V2 formula: mint LP tokens in - // proportion to the depositor's share of each reserve, taking the - // smaller side as the binding constraint. After the ratio clamp - // above, both sides give the same result; `min` is kept as an - // invariant safety net and to match the published formula. + // from then on both the subsequent-deposit branch below and + // withdraw_liquidity divide by `lp_supply + MINIMUM_LIQUIDITY`, so the + // floor counts as supply that nobody holds. Its share of the reserves + // stays in the pool, claimable by nobody. An attacker who inflates the + // value behind each share by donating to the vaults inflates the value + // behind those units too, and loses that part of the donation. + // (Uniswap V2 instead mints the floor to the zero address; here, if + // every LP token is burned, the floor's leftover reserves stay behind + // and the next depositor mints against them through the + // subsequent-deposit branch.) + // - Subsequent deposit: `liquidity = min(a * total / pool_a, b * total / pool_b)` + // with `total = lp_supply + MINIMUM_LIQUIDITY`. This is the canonical + // Uniswap V2 formula: mint LP tokens in proportion to the depositor's + // share of each reserve, taking the smaller side as the binding + // constraint. After the ratio clamp above, both sides give the same + // result; `min` is kept as an invariant safety net and to match the + // published formula (Uniswap V2's `totalSupply` includes the floor it + // minted to the zero address). `total` must be the same divisor + // withdraw_liquidity uses: dividing by the bare mint supply here would + // mint every depositor slightly less than they could redeem, and would + // let a donation merely as large as a victim's deposit round that + // deposit down to zero LP tokens, where counting the floor makes the + // attacker donate at least `MINIMUM_LIQUIDITY + 1` times the deposit. // - // All math is in `u128` with checked arithmetic. `amount * supply` can - // overflow `u64` (both are `u64`), but `u128` absorbs it: max product - // is `(2^64 - 1)^2 < 2^128`. We multiply before dividing to keep - // precision, then round down (floor) so the pool keeps any sub-unit - // rounding dust - protocol-favouring rounding, per the financial-math - // rules. + // All math is in `u128` with checked arithmetic. `amount * total` can + // overflow `u64`, but `u128` absorbs it for any supply a real mint can + // reach, and the checked multiply reports the rest. We multiply before + // dividing to keep precision, then round down (floor) so the pool keeps + // any sub-unit rounding dust - protocol-favouring rounding, per the + // financial-math rules. let liquidity: u64 = if pool_creation { let product = (amount_a as u128) .checked_mul(amount_b as u128) @@ -157,7 +167,11 @@ pub fn handle_deposit_liquidity( .checked_sub(MINIMUM_LIQUIDITY) .ok_or(AmmError::MathOverflow)? } else { - let total_supply = context.accounts.liquidity_provider_mint.supply() as u128; + // The floor is part of the supply the reserves are shared among, as + // it is in withdraw_liquidity. + let total_supply = (context.accounts.liquidity_provider_mint.supply() as u128) + .checked_add(MINIMUM_LIQUIDITY as u128) + .ok_or(AmmError::MathOverflow)?; let liquidity_from_a = (amount_a as u128) .checked_mul(total_supply) .ok_or(AmmError::MathOverflow)? 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 811336ce1..07b54e75a 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 @@ -13,6 +13,11 @@ use { }, }; +/// Mirrors `constants::MINIMUM_LIQUIDITY`: the LP units withheld from the +/// first deposit and never minted. Deposits and withdrawals both count them as +/// supply. +const MINIMUM_LIQUIDITY: u64 = 100; + fn token_program_id() -> Address { "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" .parse() @@ -1087,7 +1092,8 @@ fn test_deposit_too_small_for_ratio_reverts() { } /// Test F: LP-mint correctness for a subsequent deposit at the current ratio. -/// With the Uniswap V2 formula `min(a*supply/pool_a, b*supply/pool_b)`, an +/// With the Uniswap V2 formula `min(a*total/pool_a, b*total/pool_b)`, where +/// `total` is the LP supply plus the unminted `MINIMUM_LIQUIDITY` floor, an /// equal-ratio deposit must mint LP tokens exactly proportional to its share /// of the pool. Previously the program used `sqrt(a*b)` for *all* deposits, /// which over- or under-minted depending on pool size and broke @@ -1109,8 +1115,9 @@ fn test_lp_mint_proportional_to_share_of_pool() { ); // Second deposit at the same 4:1 ratio doubles the pool. The proportional - // formula must mint exactly `lp_supply_initial` more LP (so the depositor - // doubles their stake). + // formula must mint exactly `lp_supply_initial + MINIMUM_LIQUIDITY` more + // LP: the first deposit's whole `sqrt(a*b)`, floor included, because the + // second depositor owns exactly as much of the pool as it does. let lp_before_second = get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(); // Same depositor + same args as the first deposit → identical tx // signature. Bump the blockhash so litesvm doesn't reject the second @@ -1120,12 +1127,32 @@ fn test_lp_mint_proportional_to_share_of_pool() { let lp_after_second = get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(); let minted_on_second = lp_after_second - lp_before_second; - // min(4M * 1_999_900 / 4M, 1M * 1_999_900 / 1M) = 1_999_900. - let expected_second: u64 = 1_999_900; + // min(4M * 2_000_000 / 4M, 1M * 2_000_000 / 1M) = 2_000_000. + let expected_second: u64 = 2_000_000; assert_eq!( minted_on_second, expected_second, - "second deposit (same ratio, same size) should mint the same LP \ - amount as the initial deposit minus the locked floor" + "second deposit (same ratio, same size) should mint the initial \ + deposit's LP amount plus the locked floor" + ); + + // Minted and burned LP tokens are the same fraction of the pool, so + // burning what the second deposit minted returns that deposit exactly. + // Dividing the deposit by the bare supply would have minted 1_999_900 + // here, redeemable for only 3_999_900 of the 4_000_000 A deposited. + let pool_a_before = get_token_account_balance(&ts.svm, &ts.pool_a).unwrap(); + let withdraw = withdraw_ix_with_min(&ts, minted_on_second, 0, 0); + send_transaction_from_instructions( + &mut ts.svm, + vec![withdraw], + &[&ts.payer, &ts.admin], + &ts.payer.pubkey(), + ) + .expect("withdraw the second deposit's LP"); + let pool_a_after = get_token_account_balance(&ts.svm, &ts.pool_a).unwrap(); + assert_eq!( + pool_a_before - pool_a_after, + 4_000_000, + "burning the second deposit's LP returns the A it deposited" ); } @@ -1140,7 +1167,8 @@ fn test_lp_mint_after_swap_uses_effective_reserves() { // Seed 10M : 10M, then swap A→B so the pool shifts off 1:1. send_deposit(&mut ts, 10_000_000, 10_000_000).expect("initial deposit"); let lp_after_initial = get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(); - let total_supply_before_second = lp_after_initial; + // The divisor counts the unminted MINIMUM_LIQUIDITY floor as supply. + let total_supply_before_second = lp_after_initial + MINIMUM_LIQUIDITY; let swap_in = 1_000_000u64; swap_a_to_b(&mut ts, swap_in); @@ -1161,7 +1189,7 @@ fn test_lp_mint_after_swap_uses_effective_reserves() { let deposit_a = ((deposit_b as u128) * (effective_pool_a as u128) / (effective_pool_b as u128)) as u64; - // Expected LP minted = min(a*supply/pool_a, b*supply/pool_b) using the + // Expected LP minted = min(a*total/pool_a, b*total/pool_b) using the // *clamped* (a, b) the program actually transfers. After clamp at the // exact ratio, the binding side is whichever clamp picks: in // deposit_liquidity, `amount_b_required = amount_a * pool_b / pool_a`. @@ -1187,6 +1215,132 @@ fn test_lp_mint_after_swap_uses_effective_reserves() { ); } +/// Test H: the donation (inflation) attack. An attacker opens the pool with +/// the smallest deposit that clears the floor, so they hold 1 LP token and 101 +/// units of supply share the reserves (their 1 plus the unminted 100). They +/// then donate straight to the vaults to make each unit of supply expensive, +/// hoping a later deposit rounds down to zero LP tokens. Because deposits +/// divide by the same `supply + MINIMUM_LIQUIDITY` as withdrawals, a donation +/// as large as the victim's deposit is nowhere near enough: the victim is +/// minted their share, and the attacker cannot get the donation back, because +/// 100 of the 101 units it was spread over belong to no one. +#[test] +fn test_donation_cannot_round_a_later_deposit_to_zero() { + let mut ts = full_setup(); + + // sqrt(101 * 101) = 101, minus the 100 floor: the attacker holds 1 LP. + send_deposit(&mut ts, 101, 101).expect("attacker opens the pool"); + assert_eq!( + get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(), + 1 + ); + + // The donation: tokens sent straight to the vaults, no LP minted. + let donation: u64 = 1_000_000; + mint_tokens_to_token_account(&mut ts.svm, &ts.mint_a, &ts.pool_a, donation, &ts.admin).unwrap(); + mint_tokens_to_token_account(&mut ts.svm, &ts.mint_b, &ts.pool_b, donation, &ts.admin).unwrap(); + + // The victim deposits exactly as much as was donated. Dividing by the + // bare supply of 1 would mint 1_000_000 * 1 / 1_000_101 = 0 LP tokens. + let victim = create_wallet(&mut ts.svm, 10_000_000_000).unwrap(); + let victim_a = + create_associated_token_account(&mut ts.svm, &victim.pubkey(), &ts.mint_a, &ts.payer) + .unwrap(); + let victim_b = + create_associated_token_account(&mut ts.svm, &victim.pubkey(), &ts.mint_b, &ts.payer) + .unwrap(); + mint_tokens_to_token_account(&mut ts.svm, &ts.mint_a, &victim_a, donation, &ts.admin).unwrap(); + mint_tokens_to_token_account(&mut ts.svm, &ts.mint_b, &victim_b, donation, &ts.admin).unwrap(); + let victim_lp = derive_ata(&victim.pubkey(), &ts.liquidity_provider_mint); + let victim_deposit = Instruction::new_with_bytes( + ts.program_id, + &swap_example::instruction::DepositLiquidity { + amount_a: donation, + amount_b: donation, + minimum_lp_tokens_out: 0, + } + .data(), + swap_example::accounts::DepositLiquidityAccountConstraints { + pool_config: ts.pool_config_key, + depositor: victim.pubkey(), + liquidity_provider_mint: ts.liquidity_provider_mint, + mint_a: ts.mint_a, + mint_b: ts.mint_b, + pool_a: ts.pool_a, + pool_b: ts.pool_b, + liquidity_provider_token: victim_lp, + token_a: victim_a, + token_b: victim_b, + payer: ts.payer.pubkey(), + token_program: token_program_id(), + associated_token_program: ata_program_id(), + system_program: system_program::ID, + } + .to_account_metas(None), + ); + send_transaction_from_instructions( + &mut ts.svm, + vec![victim_deposit], + &[&ts.payer, &victim], + &ts.payer.pubkey(), + ) + .expect("victim deposit mints LP tokens"); + + // 1_000_000 * 101 / 1_000_101 = 100 LP tokens. + assert_eq!(get_token_account_balance(&ts.svm, &victim_lp).unwrap(), 100); + + // The attacker exits. Their 1 LP token is 1 of 201 units of supply over a + // 2_000_101 reserve: 9_950 of each token, back from a 1_000_101 outlay. + let attacker_exit = withdraw_ix_with_min(&ts, 1, 0, 0); + let attacker_a_before = get_token_account_balance(&ts.svm, &ts.holder_account_a).unwrap(); + send_transaction_from_instructions( + &mut ts.svm, + vec![attacker_exit], + &[&ts.payer, &ts.admin], + &ts.payer.pubkey(), + ) + .expect("attacker withdraws"); + let attacker_a_back = + get_token_account_balance(&ts.svm, &ts.holder_account_a).unwrap() - attacker_a_before; + assert_eq!(attacker_a_back, 9_950); + assert!( + attacker_a_back < donation / 100, + "the attacker recovers under 1% of the donation" + ); +} + +/// Test I: once every LP token is burned, the floor's share of the reserves +/// is still in the pool, so the next deposit takes the subsequent-deposit +/// branch and mints against the floor alone. Dividing by the bare supply of +/// zero would mint nothing and leave the pool unable to take deposits again. +#[test] +fn test_deposit_after_every_lp_token_is_burned() { + let mut ts = full_setup(); + send_deposit(&mut ts, 4_000_000, 4_000_000).expect("initial deposit"); + let lp = get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(); + + // Burn all 3_999_900 LP tokens: 3_999_900 * 4_000_000 / 4_000_000 of + // each side leaves, and the floor's 100 of each side stays. + let withdraw_all = withdraw_ix_with_min(&ts, lp, 0, 0); + send_transaction_from_instructions( + &mut ts.svm, + vec![withdraw_all], + &[&ts.payer, &ts.admin], + &ts.payer.pubkey(), + ) + .expect("withdraw everything"); + assert_eq!(get_token_account_balance(&ts.svm, &ts.pool_a).unwrap(), 100); + assert_eq!(get_token_account_balance(&ts.svm, &ts.pool_b).unwrap(), 100); + + // 1_000_000 * (0 + 100) / 100 = 1_000_000 LP tokens. + ts.svm.expire_blockhash(); + send_deposit(&mut ts, 1_000_000, 1_000_000).expect("deposit into the emptied pool"); + assert_eq!( + get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(), + 1_000_000 + ); +} + // --------------------------------------------------------------------------- // Slippage protection + invariant tests // --------------------------------------------------------------------------- @@ -1342,13 +1496,15 @@ fn test_deposit_reverts_when_lp_below_min() { // Compute the LP that a `(4M, 1M)` deposit at the current ratio would // mint, using the same formula as the program (no probe tx needed): - // liquidity = min(a*supply/pool_a, b*supply/pool_b) - // Effective reserves == raw reserves here because no swaps have happened. - let lp_supply = get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap(); + // liquidity = min(a*total/pool_a, b*total/pool_b) + // where total = LP supply + MINIMUM_LIQUIDITY. Effective reserves == raw + // reserves here because no swaps have happened. + let total_supply = + get_token_account_balance(&ts.svm, &ts.liquidity_account).unwrap() + MINIMUM_LIQUIDITY; let pool_a_amount = get_token_account_balance(&ts.svm, &ts.pool_a).unwrap(); let pool_b_amount = get_token_account_balance(&ts.svm, &ts.pool_b).unwrap(); - let lp_from_a = (4_000_000u128 * lp_supply as u128) / pool_a_amount as u128; - let lp_from_b = (1_000_000u128 * lp_supply as u128) / pool_b_amount as u128; + let lp_from_a = (4_000_000u128 * total_supply as u128) / pool_a_amount as u128; + let lp_from_b = (1_000_000u128 * total_supply as u128) / pool_b_amount as u128; let achievable_lp = lp_from_a.min(lp_from_b) as u64; // Require *strictly more* than that - the deposit must revert. diff --git a/finance/token-swap/kani-proofs/README.md b/finance/token-swap/kani-proofs/README.md index 56a7736df..ca9438c8c 100644 --- a/finance/token-swap/kani-proofs/README.md +++ b/finance/token-swap/kani-proofs/README.md @@ -10,7 +10,7 @@ the mathematical correctness of a DeFi engine. The on-chain instructions hand token movement to the SPL token program through CPIs that Kani cannot symbolically execute, but the *interesting* part, the constant-product curve, the fee split, the integer square root used for the -initial LP mint, and the proportional-withdraw math, is pure integer +initial 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 proves their invariants: @@ -20,6 +20,8 @@ widening, multiply-before-divide, floor rounding) and proves their invariants: - `proof_swap_at_zero_reserve_drains_whole_pool`: **Finding**, proven as a positive characterization (see below). - `proof_integer_sqrt_is_floor`: `integer_sqrt` returns the exact floor: `r² <= n < (r+1)²`. - `proof_withdraw_never_exceeds_reserve`: An LP can never withdraw more than the reserve holds (the `MINIMUM_LIQUIDITY` floor guarantees it). +- `proof_deposit_withdraw_round_trip_is_fair`: Burning the LP tokens a deposit just minted returns at most the deposit, and less than one minor unit plus one LP token's worth short of it. The lower bound holds only because deposit and withdraw both divide by `lp_supply + MINIMUM_LIQUIDITY`; a deposit divided by the bare supply fails it. +- `proof_rounding_a_deposit_to_zero_needs_floor_times_donation`: For a later deposit to mint zero LP tokens, the reserve must exceed the deposit times `attacker_lp + MINIMUM_LIQUIDITY`, so an attacker holding one LP token must donate about `MINIMUM_LIQUIDITY + 1` times the victim's deposit. - `proof_deposit_clamp_never_exceeds_request`: The ratio clamp never spends more of either token than the caller offered. ## Bounded model checking @@ -40,9 +42,11 @@ difficulty: - `proof_swap_at_zero_reserve_drains_whole_pool`: `<= 255`, runs in ~15s - `proof_integer_sqrt_is_floor`: `n <= 255`, `unwind(11)`, runs in ~33s - `proof_withdraw_never_exceeds_reserve`: `<= 4095`, runs in ~5s +- `proof_deposit_withdraw_round_trip_is_fair`: `<= 31` (two symbolic divisors), runs in ~18s +- `proof_rounding_a_deposit_to_zero_needs_floor_times_donation`: deposit and attacker LP `<= 15`, reserve `<= 4095`, runs in ~2s - `proof_deposit_clamp_never_exceeds_request`: `<= 31` (symbolic divisor), runs in ~3s -The whole suite verifies in ~90s of solver time. This is why these proofs run +The whole suite verifies in about two minutes of solver time. This is why these proofs run **weekly in CI** (the `kani.yml` `verify` job), not on every push/PR. A fast unit-test job runs per push/PR. diff --git a/finance/token-swap/kani-proofs/src/lib.rs b/finance/token-swap/kani-proofs/src/lib.rs index 48c7a7fd2..8444842fd 100644 --- a/finance/token-swap/kani-proofs/src/lib.rs +++ b/finance/token-swap/kani-proofs/src/lib.rs @@ -7,10 +7,10 @@ //! `withdraw_liquidity`) hand the actual token movement to the SPL token //! program via CPIs that Kani cannot symbolically execute. But the *interesting* //! part — the constant-product curve, the fee split, the integer square root -//! used for the initial LP mint, and the proportional-withdraw math — is pure -//! integer arithmetic. This crate reproduces those formulas faithfully (same -//! `u128` widening, same multiply-before-divide, same floor rounding) and proves -//! the invariants the program depends on. +//! used for the initial 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 proves the invariants the program depends on. //! //! Constants mirror `constants.rs`. @@ -289,7 +289,94 @@ fn proof_withdraw_never_exceeds_reserve() { } // =========================================================================== -// 5. Deposit ratio clamp (deposit_liquidity.rs) +// 5. Proportional deposit (deposit_liquidity.rs) +// =========================================================================== + +/// `lp_out = amount * (lp_supply + MINIMUM_LIQUIDITY) / effective_reserve`, +/// floored: one side of the subsequent-deposit formula in +/// `handle_deposit_liquidity` (the program takes the `min` over both sides). +/// The divisor is the same `lp_supply + MINIMUM_LIQUIDITY` that +/// `withdraw_amount` uses, so a minted LP token and a burned one are the same +/// fraction of the pool. +pub fn deposit_lp_amount(amount: u64, effective_reserve: u64, lp_supply: u64) -> Option { + let total = (lp_supply as u128).checked_add(MINIMUM_LIQUIDITY)?; + let out = (amount as u128) + .checked_mul(total)? + .checked_div(effective_reserve as u128)?; + u64::try_from(out).ok() +} + +/// A deposit followed at once by a withdrawal of the LP tokens it minted +/// returns at most what was deposited, and less than one base unit plus one +/// LP token's worth short of it. The upper bound means no round trip creates +/// value; the lower bound means the depositor is minted the share they paid +/// for. The lower bound holds only because deposit and withdraw divide by the +/// same supply: dividing the deposit by the bare mint supply (without +/// `MINIMUM_LIQUIDITY`) under-mints every depositor by the floor's fraction of +/// the pool and fails it. +#[cfg(kani)] +#[kani::proof] +#[kani::solver(cadical)] +fn proof_deposit_withdraw_round_trip_is_fair() { + let amount: u64 = kani::any(); + let reserve: u64 = kani::any(); + let lp_supply: u64 = kani::any(); + + // Bounded model checking: two symbolic divisors (the reserve, then the + // new supply), the most expensive shape for the solver, so the inputs + // stay small. The inequalities are scale-free, and `MINIMUM_LIQUIDITY` + // keeps its real value of 100, so the floor still dominates a small + // supply the way it does in a freshly seeded pool. + kani::assume(amount <= 31); + kani::assume(reserve >= 1 && reserve <= 31); + kani::assume(lp_supply <= 31); + + let minted = deposit_lp_amount(amount, reserve, lp_supply).expect("computes"); + // The program rejects a deposit that mints nothing (`DepositTooSmall`). + kani::assume(minted > 0); + + let new_supply = lp_supply + minted; + let new_reserve = reserve + amount; + let out = withdraw_amount(minted, new_reserve, new_supply).expect("computes"); + + // Never more than was put in. + assert!(out <= amount); + // Short by less than one base unit plus one LP token's worth of reserve: + // (out + 1) * (total + minted) + reserve > amount * (total + minted). + let total_after = new_supply as u128 + MINIMUM_LIQUIDITY; + assert!((out as u128 + 1) * total_after + reserve as u128 > amount as u128 * total_after); +} + +/// The price of the donation (inflation) attack. An attacker who holds +/// `attacker_lp` LP tokens and wants a victim's deposit of `amount` to mint +/// zero LP tokens must push the reserve to more than +/// `amount * (attacker_lp + MINIMUM_LIQUIDITY)`: with the floor in the divisor, +/// even an attacker holding a single LP token must make the reserve at least +/// `MINIMUM_LIQUIDITY + 1` times the victim's deposit. +#[cfg(kani)] +#[kani::proof] +#[kani::solver(cadical)] +fn proof_rounding_a_deposit_to_zero_needs_floor_times_donation() { + let amount: u64 = kani::any(); + let reserve: u64 = kani::any(); + let attacker_lp: u64 = kani::any(); + + // Bounded model checking (symbolic divisor). The reserve bound sits above + // `(MINIMUM_LIQUIDITY + 1) * amount` for small amounts so both outcomes + // are reachable. + kani::assume(amount >= 1 && amount <= 15); + kani::assume(reserve >= 1 && reserve <= 4095); + kani::assume(attacker_lp >= 1 && attacker_lp <= 15); + + let minted = deposit_lp_amount(amount, reserve, attacker_lp).expect("computes"); + if minted == 0 { + assert!(reserve as u128 > amount as u128 * (attacker_lp as u128 + MINIMUM_LIQUIDITY)); + assert!(reserve as u128 > amount as u128 * (MINIMUM_LIQUIDITY + 1)); + } +} + +// =========================================================================== +// 6. Deposit ratio clamp (deposit_liquidity.rs) // =========================================================================== /// Models the Uniswap-V2 ratio clamp in `handle_deposit_liquidity`: given the @@ -387,6 +474,38 @@ mod tests { assert_eq!(withdraw_amount(100, 1_000, 100).unwrap(), 500); } + #[test] + fn deposit_basic() { + // Doubling a pool whose first deposit minted sqrt(a*b) = 2_000_000 + // (1_999_900 held, 100 floor) mints the full 2_000_000: the second + // depositor owns as much of the pool as the first deposit's whole + // supply, floor included. + assert_eq!( + deposit_lp_amount(4_000_000, 4_000_000, 1_999_900).unwrap(), + 2_000_000 + ); + } + + #[test] + fn deposit_then_withdraw_returns_the_deposit() { + // Same doubling: burning the 2_000_000 just minted from the doubled + // 8_000_000 reserve returns the 4_000_000 deposited, to the unit. + let minted = deposit_lp_amount(4_000_000, 4_000_000, 1_999_900).unwrap(); + assert_eq!( + withdraw_amount(minted, 8_000_000, 1_999_900 + minted).unwrap(), + 4_000_000 + ); + } + + #[test] + fn donation_attack_price() { + // The attacker holds 1 LP token, so 101 units share the reserve. A + // 1_000 deposit rounds to zero only once the reserve passes + // 1_000 * 101 = 101_000. + assert_eq!(deposit_lp_amount(1_000, 101_000, 1).unwrap(), 1); + assert_eq!(deposit_lp_amount(1_000, 101_001, 1).unwrap(), 0); + } + #[test] fn clamp_basic() { // Pool 1:2, offer (10, 100) -> needs 20 B for 10 A; B is plentiful. diff --git a/finance/token-swap/quasar/CHANGELOG.md b/finance/token-swap/quasar/CHANGELOG.md index b279cbf57..4e4fe8949 100644 --- a/finance/token-swap/quasar/CHANGELOG.md +++ b/finance/token-swap/quasar/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [2026-09-22] + +### Fixed + +- `deposit_liquidity` now mints later deposits against the LP supply plus + `MINIMUM_LIQUIDITY`, the divisor `withdraw_liquidity` already used. Dividing + by the bare supply minted every depositor slightly less than they could + redeem, let a donation as large as a victim's deposit round that deposit down + to zero LP tokens, and left a pool whose LP tokens were all burned unable to + take another deposit. + ## [2026-09-10] ### Changed diff --git a/finance/token-swap/quasar/src/instructions/deposit_liquidity.rs b/finance/token-swap/quasar/src/instructions/deposit_liquidity.rs index 0495c3665..ad3f35612 100644 --- a/finance/token-swap/quasar/src/instructions/deposit_liquidity.rs +++ b/finance/token-swap/quasar/src/instructions/deposit_liquidity.rs @@ -162,12 +162,18 @@ pub fn handle_deposit_liquidity( // LP-mint math, two branches: // - First deposit: liquidity = sqrt(a * b) - MINIMUM_LIQUIDITY. The - // geometric mean bootstraps the pool; the locked floor is burned - // forever to prevent the first depositor draining the pool later. - // - Subsequent deposit: liquidity = min(a * supply / pool_a, - // b * supply / pool_b), proportional to the depositor's share of each - // reserve. The geometric mean must not be used here: it breaks - // proportionality once the pool has existing supply. + // geometric mean bootstraps the pool; the floor is never minted to + // anyone, so its share of the reserves stays in the pool for good. + // - Subsequent deposit: liquidity = min(a * total / pool_a, + // b * total / pool_b) with total = supply + MINIMUM_LIQUIDITY, + // proportional to the depositor's share of each reserve. The geometric + // mean must not be used here: it breaks proportionality once the pool + // has existing supply. `total` is the divisor withdraw_liquidity uses, + // so a deposit is minted exactly the share it can later redeem, and an + // attacker who donates to the vaults to round a later deposit down to + // zero must donate at least MINIMUM_LIQUIDITY + 1 times that deposit, + // most of which then belongs to the floor. Dividing by the bare supply + // would let a donation as large as the deposit do it. let liquidity: u64 = if pool_creation { let product = (amount_a as u128) .checked_mul(amount_b as u128) @@ -179,7 +185,9 @@ pub fn handle_deposit_liquidity( sqrt.checked_sub(crate::MINIMUM_LIQUIDITY) .ok_or(AmmError::MathOverflow)? } else { - let total_supply = accounts.liquidity_provider_mint.supply() as u128; + let total_supply = (accounts.liquidity_provider_mint.supply() as u128) + .checked_add(crate::MINIMUM_LIQUIDITY as u128) + .ok_or(AmmError::MathOverflow)?; let from_a = (amount_a as u128) .checked_mul(total_supply) .ok_or(AmmError::MathOverflow)? diff --git a/finance/token-swap/quasar/src/tests.rs b/finance/token-swap/quasar/src/tests.rs index f50268da7..7afaec956 100644 --- a/finance/token-swap/quasar/src/tests.rs +++ b/finance/token-swap/quasar/src/tests.rs @@ -347,13 +347,50 @@ fn deposit_liquidity_subsequent_proportional(test: &mut Test) { .succeeds(); let lp2_bal = test.tokens(DEPOSITOR_LP); - // Half the first deposit → should get roughly half the LP tokens. - assert!( - lp2_bal > 0 && lp2_bal <= lp1_bal, - "second depositor LP={} should be > 0 and <= first LP={}", - lp2_bal, - lp1_bal + // Half the first deposit mints half of the first deposit's sqrt(a * b): + // the LP it received plus the unminted floor, since deposits divide by + // the same `supply + MINIMUM_LIQUIDITY` that withdrawals do. + assert_eq!(lp1_bal, 2_000_000 - crate::MINIMUM_LIQUIDITY); + assert_eq!(lp2_bal, (lp1_bal + crate::MINIMUM_LIQUIDITY) / 2); +} + +/// Once every LP token is burned the floor's share of the reserves is still +/// in the pool, so the next deposit takes the subsequent-deposit branch and +/// mints against the floor alone. Dividing by the bare supply of zero would +/// mint nothing and leave the pool unable to take deposits again. +#[quasar_test] +fn deposit_after_every_lp_token_is_burned(test: &mut Test) { + setup_pool(test); + let lp_balance = seed_pool(test, 4_000_000, 4_000_000); + + // 3_999_900 * 4_000_000 / 4_000_000 of each side leaves; the floor's 100 + // of each side stays. + withdraw(test, SEEDER, SEEDER_LP, RECV_A, RECV_B, lp_balance, 0, 0) + .succeeds() + .has_tokens(POOL_A, crate::MINIMUM_LIQUIDITY) + .has_tokens(POOL_B, crate::MINIMUM_LIQUIDITY); + + // 1_000_000 * (0 + 100) / 100 = 1_000_000 LP tokens. + fund( + test, + DEPOSITOR, + DEPOSITOR_TOKEN_A, + DEPOSITOR_TOKEN_B, + 1_000_000, + 1_000_000, ); + deposit( + test, + DEPOSITOR, + DEPOSITOR_TOKEN_A, + DEPOSITOR_TOKEN_B, + DEPOSITOR_LP, + 1_000_000, + 1_000_000, + 0, + ) + .succeeds() + .has_tokens(DEPOSITOR_LP, 1_000_000); } #[quasar_test] @@ -393,7 +430,8 @@ fn deposit_clamps_down_never_up(test: &mut Test) { // Seed at a 4:1 ratio so pool_a > pool_b. let (pool_seed_a, pool_seed_b) = (4_000_000u64, 1_000_000u64); - let lp_supply = seed_pool(test, pool_seed_a, pool_seed_b); + // Later deposits divide by the LP supply plus the unminted floor. + let total_supply = seed_pool(test, pool_seed_a, pool_seed_b) + crate::MINIMUM_LIQUIDITY; // Depositor offers 1_000_000 of each and holds exactly that much. The // old logic would try to pull 4_000_000 token A (scaling A UP); the @@ -409,7 +447,7 @@ fn deposit_clamps_down_never_up(test: &mut Test) { ); let expected_b_pulled = mul_div(stated_a, pool_seed_b, pool_seed_a); - let expected_lp = mul_div(stated_a, lp_supply, pool_seed_a); + let expected_lp = mul_div(stated_a, total_supply, pool_seed_a); deposit( test, @@ -440,7 +478,8 @@ fn deposit_clamps_down_other_side(test: &mut Test) { // Seed at a 1:4 ratio so pool_b > pool_a. let (pool_seed_a, pool_seed_b) = (1_000_000u64, 4_000_000u64); - let lp_supply = seed_pool(test, pool_seed_a, pool_seed_b); + // Later deposits divide by the LP supply plus the unminted floor. + let total_supply = seed_pool(test, pool_seed_a, pool_seed_b) + crate::MINIMUM_LIQUIDITY; let (stated_a, stated_b) = (1_000_000u64, 1_000_000u64); fund( @@ -455,7 +494,7 @@ fn deposit_clamps_down_other_side(test: &mut Test) { // amount_b_required for the full stated_a would be 4_000_000 > stated_b, // so amount_b binds: all of B is used and A is clamped down. let expected_a_pulled = mul_div(stated_b, pool_seed_a, pool_seed_b); - let expected_lp = mul_div(stated_b, lp_supply, pool_seed_b); + let expected_lp = mul_div(stated_b, total_supply, pool_seed_b); deposit( test, @@ -481,7 +520,8 @@ fn deposit_slippage_rejected(test: &mut Test) { setup_pool(test); let (pool_seed_a, pool_seed_b) = (1_000_000u64, 1_000_000u64); - let lp_supply = seed_pool(test, pool_seed_a, pool_seed_b); + // Later deposits divide by the LP supply plus the unminted floor. + let total_supply = seed_pool(test, pool_seed_a, pool_seed_b) + crate::MINIMUM_LIQUIDITY; let (stated_a, stated_b) = (500_000u64, 500_000u64); fund( @@ -494,7 +534,7 @@ fn deposit_slippage_rejected(test: &mut Test) { ); // The pool will mint exactly this much; ask for one more. - let exact_lp = mul_div(stated_a, lp_supply, pool_seed_a); + let exact_lp = mul_div(stated_a, total_supply, pool_seed_a); deposit( test, DEPOSITOR, From 93e0ae7285f7d16d66c8c1821bfe45ff50f4fa9e Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Tue, 22 Sep 2026 23:51:54 +0000 Subject: [PATCH 2/2] perpetual-futures: lock the withheld minimum in both share divisors add_liquidity withheld MINIMUM_LIQUIDITY shares from the first deposit, but add_liquidity and remove_liquidity both divided by the bare LP mint supply, against an AUM that included the withheld 1,000. The withheld value belonged to the share holders pro rata and locked nothing. Pricing shares against Pool.liquidity rather than the vault balance stops a direct donation, but a provider who is also the pool's only trader can raise liquidity with their own funding payments and losses. test_inflating_liquidity_through_own_trades_does_not_pay does that: the attacker opens the pool with one share, pays about 1,000 USDC of funding from a small long, and waits for a deposit just under twice liquidity. Against the old program (Anchor and Quasar alike) the attacker spends 1,000,001,003 minor units and withdraws 1,500,001,501. Both handlers now divide by the share supply plus MINIMUM_LIQUIDITY, so the attacker's share is 1 of 1,001 and they get back about 1 USDC. A pool whose providers have all left keeps the minimum's slice in liquidity, so add_liquidity bootstraps only a pool with no shares and no liquidity and prices every other deposit against the locked slice. A sole provider's round trip now returns the deposit less the 1,000 minimum. Applied to all three ports, with READMEs and changelogs. Claude-Session: https://claude.ai/code/session_01HagqhpPNqcywT37qqgnFey --- CHANGELOG.md | 31 +++++ .../perpetual-futures/anchor-v1/CHANGELOG.md | 12 ++ finance/perpetual-futures/anchor-v1/README.md | 2 +- .../perpetual-futures/src/constants.rs | 11 +- .../src/instructions/add_liquidity.rs | 24 +++- .../src/instructions/remove_liquidity.rs | 11 +- .../tests/test_perpetual_futures.rs | 119 ++++++++++++++++-- finance/perpetual-futures/anchor/CHANGELOG.md | 12 ++ finance/perpetual-futures/anchor/README.md | 2 +- .../perpetual-futures/src/constants.rs | 11 +- .../src/instructions/add_liquidity.rs | 24 +++- .../src/instructions/remove_liquidity.rs | 11 +- .../tests/test_perpetual_futures.rs | 119 ++++++++++++++++-- finance/perpetual-futures/quasar/CHANGELOG.md | 11 ++ .../perpetual-futures/quasar/src/constants.rs | 4 +- .../quasar/src/instructions/add_liquidity.rs | 15 ++- .../src/instructions/remove_liquidity.rs | 8 +- finance/perpetual-futures/quasar/src/tests.rs | 88 ++++++++++++- 18 files changed, 466 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7fbdc8ed..561e96d4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/finance/perpetual-futures/anchor-v1/CHANGELOG.md b/finance/perpetual-futures/anchor-v1/CHANGELOG.md index 3af811d66..5b91a019a 100644 --- a/finance/perpetual-futures/anchor-v1/CHANGELOG.md +++ b/finance/perpetual-futures/anchor-v1/CHANGELOG.md @@ -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 diff --git a/finance/perpetual-futures/anchor-v1/README.md b/finance/perpetual-futures/anchor-v1/README.md index 244310404..de290d8ea 100644 --- a/finance/perpetual-futures/anchor-v1/README.md +++ b/finance/perpetual-futures/anchor-v1/README.md @@ -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 diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/constants.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/constants.rs index d7476ae4f..5f1ac5213 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/constants.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/constants.rs @@ -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; diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/add_liquidity.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/add_liquidity.rs index f3ef9ed41..78af513ce 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/add_liquidity.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/add_liquidity.rs @@ -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)? diff --git a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/remove_liquidity.rs b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/remove_liquidity.rs index 0600e07b5..e35237279 100644 --- a/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/remove_liquidity.rs +++ b/finance/perpetual-futures/anchor-v1/programs/perpetual-futures/src/instructions/remove_liquidity.rs @@ -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; @@ -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)?; 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 40022aeea..6942907c2 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 @@ -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] @@ -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] diff --git a/finance/perpetual-futures/anchor/CHANGELOG.md b/finance/perpetual-futures/anchor/CHANGELOG.md index 1e6a02f49..37b1b0111 100644 --- a/finance/perpetual-futures/anchor/CHANGELOG.md +++ b/finance/perpetual-futures/anchor/CHANGELOG.md @@ -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 diff --git a/finance/perpetual-futures/anchor/README.md b/finance/perpetual-futures/anchor/README.md index 38d34e5e5..27291bef5 100644 --- a/finance/perpetual-futures/anchor/README.md +++ b/finance/perpetual-futures/anchor/README.md @@ -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 diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/constants.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/constants.rs index d7476ae4f..5f1ac5213 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/constants.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/constants.rs @@ -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; diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/add_liquidity.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/add_liquidity.rs index 67ddc45b9..7cbd26bee 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/add_liquidity.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/add_liquidity.rs @@ -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)? diff --git a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/remove_liquidity.rs b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/remove_liquidity.rs index c49a2a8dc..9d3227ca1 100644 --- a/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/remove_liquidity.rs +++ b/finance/perpetual-futures/anchor/programs/perpetual-futures/src/instructions/remove_liquidity.rs @@ -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; @@ -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)?; 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 0e2944fac..eb4d18ad9 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 @@ -584,10 +584,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] @@ -605,12 +605,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] diff --git a/finance/perpetual-futures/quasar/CHANGELOG.md b/finance/perpetual-futures/quasar/CHANGELOG.md index 1cd9b932c..cee099ca4 100644 --- a/finance/perpetual-futures/quasar/CHANGELOG.md +++ b/finance/perpetual-futures/quasar/CHANGELOG.md @@ -1,5 +1,16 @@ # 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, 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. `inflating_liquidity_through_own_trades_does_not_pay` runs the +attack. + ## 2026-09-10 Remove the separate dataless signing PDA (seeds `["authority", pool]`) that diff --git a/finance/perpetual-futures/quasar/src/constants.rs b/finance/perpetual-futures/quasar/src/constants.rs index c0a0c6a39..f3f1ec62b 100644 --- a/finance/perpetual-futures/quasar/src/constants.rs +++ b/finance/perpetual-futures/quasar/src/constants.rs @@ -11,7 +11,9 @@ pub const FUNDING_PRECISION: i128 = 1_000_000_000; pub const SIZE_PRECISION: u128 = 1_000_000_000; /// Liquidity-provider shares withheld from the first deposit so the share -/// supply never starts at a dust amount. +/// supply never starts at a dust amount. 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. pub const MINIMUM_LIQUIDITY: u64 = 1_000; /// Reject an oracle price older than this many slots. Counted in slots because diff --git a/finance/perpetual-futures/quasar/src/instructions/add_liquidity.rs b/finance/perpetual-futures/quasar/src/instructions/add_liquidity.rs index 6a964a9f9..eb8315d19 100644 --- a/finance/perpetual-futures/quasar/src/instructions/add_liquidity.rs +++ b/finance/perpetual-futures/quasar/src/instructions/add_liquidity.rs @@ -56,7 +56,10 @@ pub fn handle_add_liquidity( let price = refresh_price_and_funding(&mut accounts.pool, &accounts.oracle_feed, slot)?; let lp_supply = accounts.lp_mint.supply(); - let shares: u64 = if lp_supply == 0 { + // Bootstrap only an empty pool. Once any deposit has landed, the withheld + // minimum's slice stays in `liquidity` even if every provider leaves, and + // later deposits are priced against it. + let shares: u64 = if lp_supply == 0 && accounts.pool.liquidity.get() == 0 { amount .checked_sub(MINIMUM_LIQUIDITY) .ok_or_else(|| err(error::DEPOSIT_TOO_SMALL))? @@ -74,8 +77,16 @@ pub fn handle_add_liquidity( if aum <= 0 { return Err(err(error::POOL_INSOLVENT)); } + // The withheld minimum counts as shares nobody holds, here and in + // remove_liquidity. A provider who is also the only trader can grow + // `liquidity` with their own funding payments and losses; with the + // minimum in the divisor, an attacker's single share is 1 of 1_001 and + // whatever they pay in is spread across shares they cannot redeem. + let total_shares = (lp_supply as u128) + .checked_add(MINIMUM_LIQUIDITY as u128) + .ok_or(ProgramError::ArithmeticOverflow)?; let computed = (amount as u128) - .checked_mul(lp_supply as u128) + .checked_mul(total_shares) .ok_or(ProgramError::ArithmeticOverflow)? .checked_div(aum as u128) .ok_or(ProgramError::ArithmeticOverflow)?; diff --git a/finance/perpetual-futures/quasar/src/instructions/remove_liquidity.rs b/finance/perpetual-futures/quasar/src/instructions/remove_liquidity.rs index e7bce4664..cfd377ff2 100644 --- a/finance/perpetual-futures/quasar/src/instructions/remove_liquidity.rs +++ b/finance/perpetual-futures/quasar/src/instructions/remove_liquidity.rs @@ -1,5 +1,6 @@ use { crate::{ + constants::MINIMUM_LIQUIDITY, instructions::shared::{err, error, refresh_price_and_funding, traders_unrealized_pnl}, state::Pool, LpMintPda, @@ -69,10 +70,15 @@ pub fn handle_remove_liquidity( return Err(err(error::POOL_INSOLVENT)); } + // 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(ProgramError::ArithmeticOverflow)?; let amount_out = (shares as u128) .checked_mul(aum as u128) .ok_or(ProgramError::ArithmeticOverflow)? - .checked_div(lp_supply as u128) + .checked_div(total_shares) .ok_or(ProgramError::ArithmeticOverflow)?; let amount_out = u64::try_from(amount_out).map_err(|_| ProgramError::ArithmeticOverflow)?; diff --git a/finance/perpetual-futures/quasar/src/tests.rs b/finance/perpetual-futures/quasar/src/tests.rs index bbf836dbd..9b81cce9b 100644 --- a/finance/perpetual-futures/quasar/src/tests.rs +++ b/finance/perpetual-futures/quasar/src/tests.rs @@ -33,6 +33,9 @@ const TRADER_COLLATERAL: Pubkey = Pubkey::new_from_array([8; 32]); const LIQUIDATOR: Pubkey = Pubkey::new_from_array([9; 32]); const LIQUIDATOR_COLLATERAL: Pubkey = Pubkey::new_from_array([10; 32]); const ADMIN_COLLATERAL: Pubkey = Pubkey::new_from_array([11; 32]); +const VICTIM: Pubkey = Pubkey::new_from_array([12; 32]); +const VICTIM_COLLATERAL: Pubkey = Pubkey::new_from_array([13; 32]); +const VICTIM_LP: Pubkey = Pubkey::new_from_array([14; 32]); fn dollars(whole: i128) -> i128 { whole * 10i128.pow(ORACLE_SCALE) @@ -239,7 +242,7 @@ fn add_liquidity_deposits_and_mints_shares(test: &mut Test) { } #[quasar_test] -fn remove_liquidity_round_trip_returns_the_deposit(test: &mut Test) { +fn remove_liquidity_round_trip_returns_the_deposit_less_the_minimum(test: &mut Test) { let env = setup(test); fund(test, PROVIDER, PROVIDER_COLLATERAL, 10_000 * ONE_USDC); add_liquidity(test, &env, 10_000 * ONE_USDC).succeeds(); @@ -247,8 +250,87 @@ fn remove_liquidity_round_trip_returns_the_deposit(test: &mut Test) { let shares = test.tokens(PROVIDER_LP); remove_liquidity(test, &env, shares) .succeeds() - // Sole provider reclaims the full deposit. - .has_tokens(PROVIDER_COLLATERAL, 10_000 * ONE_USDC); + // Even the sole provider leaves the withheld minimum behind: those + // 1_000 shares belong to nobody, and their 1_000 stays in the vault. + .has_tokens(PROVIDER_COLLATERAL, 10_000 * ONE_USDC - 1_000) + .has_tokens(env.custody_vault, 1_000); + + // The next deposit is priced against the minimum's slice rather than + // bootstrapped: 5_000 * (0 + 1_000) / 1_000 = 5_000 shares. + add_liquidity(test, &env, 5_000) + .succeeds() + .has_tokens(PROVIDER_LP, 5_000); +} + +/// First-depositor share inflation without a donation. Tokens sent straight to +/// the vault move nothing, because shares are priced against `liquidity`, but +/// `liquidity` grows with every funding payment and trader loss, and a +/// provider can also be the pool's only trader. The attacker opens the pool +/// with 1 share, pays funding on a small long of their own until `liquidity` +/// is large, and waits for a deposit. The withheld minimum counts as shares in +/// both directions, so the attacker's share is 1 of 1_001 and what they paid +/// in is spread across shares nobody can redeem. +#[quasar_test] +fn inflating_liquidity_through_own_trades_does_not_pay(test: &mut Test) { + // A steep funding rate: 1_000 of notional pays 1_000 USDC over 1_000 slots. + let env = setup_with_funding(test, 1_000_000_000_000); + fund(test, PROVIDER, PROVIDER_COLLATERAL, 1_001); + add_liquidity(test, &env, 1_001) + .succeeds() + .has_tokens(PROVIDER_LP, 1); + + // The attacker's trading key: a 1_000 long, heavily collateralized. + fund(test, TRADER, TRADER_COLLATERAL, 2_000 * ONE_USDC); + open_position(test, &env, 0, 2_000 * ONE_USDC, 1_000).succeeds(); + set_clock_at(test, 1_000); + set_feed_at_slot(test, dollars(100), 1_000, 0); + close_position(test, &env).succeeds(); + // Spent: the 1_001 deposit, the funding, and a 1-unit fee each way. All + // but the two fees is now `liquidity`. + let attacker_spent = 1_001 + 2_000 * ONE_USDC - test.tokens(TRADER_COLLATERAL); + let pumped_liquidity = attacker_spent - 2; + assert!(pumped_liquidity > 1_000 * ONE_USDC); + + // Just under twice the pumped liquidity: dividing by the bare supply of 1 + // would mint a single share, and the attacker's share would redeem half. + let victim_deposit = 2 * pumped_liquidity - 1; + fund(test, VICTIM, VICTIM_COLLATERAL, victim_deposit); + test.send(AddLiquidityInstruction { + provider: VICTIM, + oracle_feed: FEED, + collateral_mint: COLLATERAL_MINT, + custody_vault: env.custody_vault, + provider_collateral: VICTIM_COLLATERAL, + provider_lp: VICTIM_LP, + amount: victim_deposit, + minimum_shares_out: 0, + }) + .succeeds(); + let victim_shares = test.tokens(VICTIM_LP); + + remove_liquidity(test, &env, 1).succeeds(); + let attacker_back = test.tokens(PROVIDER_COLLATERAL); + assert!( + attacker_back * 100 < attacker_spent, + "attacker spent {attacker_spent} and got back {attacker_back}" + ); + + test.send(RemoveLiquidityInstruction { + provider: VICTIM, + oracle_feed: FEED, + collateral_mint: COLLATERAL_MINT, + custody_vault: env.custody_vault, + provider_collateral: VICTIM_COLLATERAL, + provider_lp: VICTIM_LP, + shares: victim_shares, + minimum_amount_out: 0, + }) + .succeeds(); + let victim_back = test.tokens(VICTIM_COLLATERAL); + assert!( + victim_back * 1_000 >= victim_deposit * 999, + "victim deposited {victim_deposit} and got back {victim_back}" + ); } #[quasar_test]