fix(token-swap): mint LP pro-rata, key first deposit on LP supply, enforce mint ordering - #10
Open
SwineCoder101 wants to merge 2 commits into
Open
fix(token-swap): mint LP pro-rata, key first deposit on LP supply, enforce mint ordering#10SwineCoder101 wants to merge 2 commits into
SwineCoder101 wants to merge 2 commits into
Conversation
… bricking via donation, and unordered pool mints - Second depositor after swaps receives sqrt(a*b) LP instead of a pro-rata share and can withdraw more than deposited. - Transferring 1 unit of B to pool_account_b before the first deposit makes every deposit panic on a division by zero; donating A instead mints 0 LP. - create_pool accepts mint_a > mint_b despite the documented ordering constraint, allowing two pools per pair. - Add [programs.localnet] so anchor test loads the program at declare_id.
…force mint ordering - Non-initial deposits now mint min(a * total / pool_a, b * total / pool_b) with total = supply + MINIMUM_LIQUIDITY (the figure withdraw_liquidity divides by), so fees accrued to the reserves stay with existing LPs. - The first-deposit path is selected by mint_liquidity.supply == 0 instead of empty reserves, so tokens transferred directly to the pool accounts can no longer brick the pool or mint 0 LP; a zero reserve on the ratio path returns EmptyPoolReserves instead of panicking. - create_pool requires mint_a < mint_b as documented in the README.
SwineCoder101
force-pushed
the
fix/token-swap-lp-accounting
branch
from
August 27, 2026 12:23
f122d25 to
61e7e73
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Bug: LP tokens are minted from
sqrt(a * b)on every deposit, the first-deposit path is keyed on pool balances, and pool mints are not ordereddeposit_liquiditymintsisqrt(amount_a * amount_b)LP tokens on every deposit regardless of the existing LP supply, so once swap fees have grown the reserves a later depositor is over-minted and can withdraw more than they put in, taking the fees earned by earlier LPs. The same instruction decides "first deposit" frompool_a.amount == 0 && pool_b.amount == 0, so anyone can transfer one base unit of a token straight to a pool token account before the first deposit and either brick the pool forever (division by zero panic on every deposit) or make the first depositor lose their tokens for 0 LP. Finally,create_pooldoes not enforce themint_a < mint_bordering the README documents, so two pools can exist for one pair. These are correctness/loss-of-funds bugs reachable by any unprivileged user.Affected
anchor(the only variant)tokens/token-swap/anchor/programs/token-swap/src/instructions/deposit_liquidity.rstokens/token-swap/anchor/programs/token-swap/src/instructions/create_pool.rstokens/token-swap/anchor/programs/token-swap/src/errors.rstokens/token-swap/anchor/Anchor.toml(test harness only, see Reproduce)tokens/token-swap/anchor/tests/{utils,deposit-liquidity,create-pool}.tsFunctionality
create_poolcreates the canonical constant-product pool for an(amm, mint_a, mint_b)triple.deposit_liquiditytakes tokens in the pool's current ratio and mints LP tokens representing the depositor's share;withdraw_liquidityburns LP tokens and pays outamount * reserve / (supply + MINIMUM_LIQUIDITY)of each reserve. For that accounting to be fair, LP minted on a non-initial deposit must be proportional to the existing supply, and the first-deposit path must only run when no LP has been minted yet.The bug
Over-mint after fee accrual (
deposit_liquidity.rs:68-80onmain):liquidity = isqrt(amount_a * amount_b)is used for every deposit; only the first deposit subtractsMINIMUM_LIQUIDITY. Swaps growk = pool_a * pool_bthrough fees while LP supply is fixed, sosqrt(k)drifts abovesupply + MINIMUM_LIQUIDITY. A later depositor of(a, b)at the pool ratio getssqrt(a * b)LP, which is more than thea * total / pool_ashare they are entitled to.withdraw_liquidity.rs:25-29then pays themlp * reserve / total, i.e. more than they deposited. In the test below (10M/10M seed, four 1M swaps at 5% fee, then a second deposit) the second depositor is minted 9,848,643 LP where the pro-rata amount is 9,755,701 and withdraws more of both tokens than they deposited.First-deposit detection keyed on reserves (
deposit_liquidity.rs:34):pool_creation = pool_a.amount == 0 && pool_b.amount == 0. Anyone canspl-token transfer1 unit directly intopool_account_b(a plain ATA) before the first deposit. Every deposit then takes the ratio path withpool_a = 0and hits.checked_div(0).unwrap()atdeposit_liquidity.rs:49-50, panicking. Tokens only leave the pool throughwithdraw_liquidity, which needs LP that can never be minted, and the pool PDA is deterministic per(amm, mint_a, mint_b), so the canonical pool is bricked. Donating 1 unit of A instead givesamount_b_required = amount_a * 0 / 1 = 0, so the depositor's A is transferred in andliquidity = isqrt(a * 0) = 0LP is minted: the depositor loses their tokens.No mint ordering (
create_pool.rs:31-42):README.md:191says thepoolaccount constraint "ensuresmint_a's key is less thanmint_b's key", supporting "precisely one pool" per pair (README.md:42). No such constraint exists, so(amm, A, B)and(amm, B, A)are both valid pools. The README states the intended behaviour, so the code was changed to match it rather than the other way round.Reproduce
Note:
Anchor.tomlhad no[programs.localnet]entry, soanchor testloaded the.soat the ephemeral keypair address instead ofdeclare_id!, and every test failed with "This program may not be used for executing instructions". The entry is added in this branch so the suite runs at all.Tests added:
Create pool/Rejects mints out of orderDeposit liquidity/Second depositor cannot capture fees accrued by earlier depositorsDeposit liquidity/First deposit succeeds after token B is donated to the poolDeposit liquidity/First deposit mints liquidity after token A is donated to the poolAgainst the unmodified program:
Fix
deposit_liquidity.rs:pool_creationis nowmint_liquidity.supply == 0. Tokens donated to the pool accounts no longer change which path runs; they simply become part of the reserves shared by LPs.EmptyPoolReserves(new error) if either reserve is zero instead of panicking, and usesok_or(MathOverflow)?instead ofunwrap().min(amount_a * total / pool_a, amount_b * total / pool_b)withtotal = supply + MINIMUM_LIQUIDITY, computed in u128 with checked ops and converted withu64::try_from. This is the sametotalthatwithdraw_liquiditydivides by, so a deposit followed by a withdrawal returns at most what was deposited (rounding favours the pool) and fee growth stays with the LPs who earned it. A deposit that rounds to 0 LP is rejected withDepositTooSmallinstead of taking the tokens for nothing.isqrt(a * b) - MINIMUM_LIQUIDITY.create_pool.rs:constraint = mint_a.key() < mint_b.key() @ TutorialError::InvalidMinton thepoolaccount, matching the README.tests/utils.tsalready sorted the generated mints;createValuesnow also honours explicitly supplied mints so the test can build the reversed pair.Verification
cargo fmtapplied to the changed files;cargo clippy -p swap_example -- -D warnings -A deprecatedis clean (the crate is in.github/.workspace-ignoreand has pre-existingAccountInfodeprecation lints in files this change does not touch).pnpm exec tsc --noEmitpasses.