security: fix swapped transfer amounts + broken invariant check in token-swap - #690
security: fix swapped transfer amounts + broken invariant check in token-swap#690NikkiAung wants to merge 3 commits into
Conversation
…ken-swap swap_exact_tokens_for_tokens's swap_a=false branch (B->A swaps) had two independent bugs in the same ~20-line block. Neither was ever exercised by tests - the only existing swap test always calls swap_a=true. 1. The two token transfers in the swap_a=false branch were swapped: the pool paid out `input` (the raw, caller-chosen B amount, reused as if it were the A payout) instead of the formula-computed `output`, and the trader only paid `output` instead of the full `input` they claimed to be swapping. Hand-verified this drains real value: on a pool with pool_a=1,000,000/pool_b=4,000,000 (5% fee), input=500,000 lets a trader pull out half the pool's entire A reserve while paying only ~106,145 B - the invariant drops from 4e12 to ~2.05e12 in one call. 2. The post-trade invariant check compared pool_account_a.amount against itself (A*A) instead of against pool_account_b.amount (A*B), so it never actually verified the real constant-product invariant. These two bugs currently mask each other by coincidence: severe drains via bug 1 always shrink pool_account_a a lot, and shrinking A a lot also shrinks A^2 a lot, so bug 2's check - wrong as it is - still happens to catch the most extreme drains. A more moderate trade can still be profitably exploitable while slipping under that coincidental threshold, and fixing either bug alone (without the other) would remove the accidental protection and make the remaining bug fully exploitable. Both are fixed together here. Also switched the pre/post-trade invariant computation and the fee calculation to u128 arithmetic, matching the checked-math style already used for the output-amount calculation in the same function - the raw u64 multiplications could otherwise overflow and panic (overflow-checks = true in this workspace's release profile) for large-supply pools. Added 'Swap from B to A' to tests/swap.ts, the first test in this example to exercise that branch at all. Verified it fails against the unpatched code (reverts with InvariantViolated, since the trade genuinely destroys value under the old code) and passes after the fix, with all other existing tests unaffected.
Greptile SummaryThis PR corrects B-to-A token transfer amounts, fixes the constant-product invariant comparison, and widens fee and invariant arithmetic to avoid overflow.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains. Important Files Changed
Reviews (3): Last reviewed commit: "fix(#690): use checked_ math for the fee..." | Re-trigger Greptile |
| expect(Number(traderTokenAccountA.value.amount)).to.be.greaterThan( | ||
| values.defaultSupply.sub(values.depositAmountA).toNumber(), | ||
| ); | ||
| expect(Number(traderTokenAccountA.value.amount)).to.be.lessThan( | ||
| values.defaultSupply.sub(values.depositAmountA).add(input).toNumber(), | ||
| ); |
There was a problem hiding this comment.
Output assertion accepts wrong amounts
The broad balance range accepts any positive A output below input, so regressions in the output formula, fee application, reserve selection, or rounding can pass without verifying the exact corrected result.
| expect(Number(traderTokenAccountA.value.amount)).to.be.greaterThan( | |
| values.defaultSupply.sub(values.depositAmountA).toNumber(), | |
| ); | |
| expect(Number(traderTokenAccountA.value.amount)).to.be.lessThan( | |
| values.defaultSupply.sub(values.depositAmountA).add(input).toNumber(), | |
| ); | |
| expect(traderTokenAccountA.value.amount).to.equal( | |
| values.defaultSupply | |
| .sub(values.depositAmountA) | |
| .add(new anchor.BN(2_961_038)) | |
| .toString(), | |
| ); |
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
…eptile review The new 'Swap from B to A' test asserted a broad range on the output side instead of the precise amount. Compute the expected output by mirroring the on-chain constant-product formula in BN arithmetic (fee, taxed input, then the same mul/div as the program) and assert equality instead of a bound.
|
Good catch, fixed in `2ee337a`: the new test now computes the expected output by mirroring the on-chain constant-product formula (fee, taxed input, then the same mul/div) in BN arithmetic, and asserts exact equality instead of a broad range. Re-ran the full suite — all 10 pass. |
|
Hey @dev-jodee — this one's ready whenever you have a chance to take a look. CI is green and the Greptile feedback from earlier has been addressed. |
| // u128 avoids overflow when input * fee approaches u64::MAX. | ||
| let amm = &ctx.accounts.amm; | ||
| let taxed_input = input - input * amm.fee as u64 / 10000; | ||
| let fee_amount = ((input as u128) * (amm.fee as u128) / 10000) as u64; |
There was a problem hiding this comment.
should use checked_ math for better handling of overflow
… per review dev-jodee: "should use checked_ math for better handling of overflow" — fee_amount used raw *//in u128 while the rest of the function (including the output computation a few lines below) uses checked_mul/checked_div chains. Switched both fee_amount and taxed_input to checked arithmetic for consistency with the surrounding code's established style. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Summary
swap_exact_tokens_for_tokens'sswap_a == falsebranch (B→A swaps) has two independent bugs in the same ~20-line block. Neither was ever exercised by the existing tests — the only swap test always callsswap_a = true.Bug 1 (the severe one): transfer amounts are swapped in the B→A branch
Compare to the correct
swap_a == truebranch just above it, which movesinputtrader→pool andoutputpool→trader. In theelsebranch the two amounts are swapped: the pool pays outinput(the raw, caller-chosen B amount, reused as if it were the A payout) instead of the formula-computedoutput, and the trader only paysoutputinstead of the fullinputthey claimed to be swapping.Hand-verified this drains real value: on a pool with
pool_a=1,000,000/pool_b=4,000,000(5% fee),input=500,000lets a trader pull out half the pool's entire A reserve while only paying ~106,145 B — the invariant drops from4e12to~2.05e12in one call.Bug 2: the post-trade invariant check compares the wrong accounts
pool_account_a.amountis multiplied by itself instead of bypool_account_b.amount— comparing pre-tradeA0*B0against post-tradeA1*A1, notA1*B1. This check is meant to be the AMM's final safety net (per its own comment) but as written never actually verifies the real constant-product invariant.Why both had to be fixed together
These two bugs currently mask each other by coincidence: severe drains via Bug 1 always shrink
pool_account_aa lot, and shrinkingAa lot also shrinksA²a lot — so Bug 2's check, wrong as it is, still happens to reject the most extreme drain attempts. A more moderate trade can still be profitably exploitable while slipping under that coincidental threshold. Fixing either bug alone — without the other — would remove the accidental protection and make the remaining bug fully exploitable. Both are fixed in this PR.Secondary fix: overflow-safe arithmetic
Also switched the pre/post-trade invariant computation and the fee calculation to
u128arithmetic, matching the checked-math style already used for the output-amount calculation in the same function. The rawu64multiplications could otherwise overflow and panic (overflow-checks = truein this workspace's release profile) for large-supply pools.Test changes
Added
'Swap from B to A'totests/swap.ts— the first test in this example to exercise that branch at all. Verified it fails against the unpatched code (reverts withInvariantViolated, since the trade genuinely destroys value under the old code) and passes after the fix, with all other existing tests unaffected.Verification
cargo checkclean.anchor build— no account-struct or instruction-signature changes, IDL unaffected beyond recompilation.anchor testsuite (create-amm.ts,create-pool.ts,deposit-liquidity.ts,withdraw-liquidity.ts,swap.ts) — all 10 tests pass post-fix.pnpm exec tsc --noEmitandprettier --checkclean on the modified test file.Explicitly out of scope
create_amm.rs'sadminfield is never actually used to gate any instruction (dead authority field) — a minor design smell, not exploitable since nothing checks it, not touched here.deposit_liquidity.rs'sMINIMUM_LIQUIDITYhandling (withheld from the first LP mint rather than minted-then-burned) — investigated and confirmed self-consistent withwithdraw_liquidity.rs's redemption formula, which compensates for it in the supply denominator. Not a bug.