Skip to content

feat(token-swap): add pinocchio example - #716

Open
MarkFeder wants to merge 2 commits into
solana-foundation:mainfrom
MarkFeder:tokens-token-swap-pinocchio
Open

feat(token-swap): add pinocchio example#716
MarkFeder wants to merge 2 commits into
solana-foundation:mainfrom
MarkFeder:tokens-token-swap-pinocchio

Conversation

@MarkFeder

Copy link
Copy Markdown
Contributor

Adds a Pinocchio implementation of token-swap, alongside the existing Anchor one.

What it does

A constant-product AMM. Five instructions: CreateAmm, CreatePool, DepositLiquidity, WithdrawLiquidity and SwapExactTokensForTokens.

Everything a pool owns hangs off a single authority PDA — both vaults and the LP mint — so the program can move pool funds without any wallet holding that power.

The parts worth reading

  • Deposits are forced to the pool's ratio. Whichever side is short decides how much of the other is actually taken, so a depositor cannot move the price by adding lopsided amounts. LP shares are the geometric mean of the deposit, so they track the pool's value rather than either balance.
  • MINIMUM_LIQUIDITY is burned on the first deposit and never minted to anyone. It keeps the pool from being emptied completely, which is what would otherwise let the share price be skewed while the pool is near-empty. Withdrawals divide by supply + MINIMUM_LIQUIDITY for the same reason.
  • The invariant is re-checked after the CPIs. a * b is read again from the vaults after the transfers; a higher value is fine (rounding in the pool's favour), a lower one aborts.
  • Withdrawal burns last. An over-large amount fails at the burn and rolls the two transfers back, so the pool cannot be drained by asking for more than you hold.

All the arithmetic goes through a mul_div helper that widens to u128, so the product of two u64 balances cannot overflow.

Account binding

The pool records its AMM and both mints. PoolSeeds::load reads them back, checks the supplied mints against the stored ones, and rederives both the pool and the authority — so a caller cannot pair a real pool with unrelated token accounts, or point a pool at a cheaper AMM's fee. There is a test for the mint substitution.

Differences from the Anchor version

  • No 8-byte account discriminators, so the AMM is 66 bytes and the pool 96.
  • Instruction data is packed by hand rather than Borsh; swap_a is a u8.
  • PDA creation goes through the transfer/allocate/assign helper rather than a bare CreateAccount, so a stray lamport on a derivable address cannot block pool creation (see feat(merkle-tree-token-claimer): add pinocchio example #714).

Tests

10 LiteSVM tests: the full lifecycle from AMM through pool, deposit, swap, ratio-trimmed deposit and withdrawal, plus fee, slippage and mint-substitution rejections. The swap test recomputes the expected output from the curve in the test rather than asserting a hardcoded number. Verified locally: tsc --noEmit, pnpm test, prettier --check, cargo fmt --check, cargo clippy -D warnings.

@MarkFeder
MarkFeder requested a review from dev-jodee as a code owner August 31, 2026 14:04
@greptile-apps

greptile-apps Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds a Pinocchio implementation of the token-swap example, including AMM and pool creation, liquidity management, swaps, tests, and workspace integration. The latest account-binding changes rederive the pool authority, canonical reserve accounts, and liquidity mint before those accounts participate in settlement.

  • Adds five on-chain token-swap instruction handlers and shared PDA/account validation.
  • Adds LiteSVM lifecycle, slippage, fee, ratio, and account-substitution tests.
  • Adds Rust workspace, package, CI, and README integration.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the current canonical reserve and liquidity-mint checks address all three previously reported account-substitution paths before pricing, share accounting, or settlement.

Important Files Changed

Filename Overview
tokens/token-swap/pinocchio/program/src/instructions/mod.rs Centralizes pool validation and now binds both reserve accounts and the LP mint to their canonical pool-derived addresses.
tokens/token-swap/pinocchio/program/src/instructions/swap_exact_tokens_for_tokens.rs Performs fee-aware constant-product swaps after validating both canonical pool reserves, resolving the previously reported reserve-substitution path.
tokens/token-swap/pinocchio/program/src/instructions/deposit_liquidity.rs Validates canonical reserves and the pool LP mint before reading balances, transferring deposits, or minting shares.
tokens/token-swap/pinocchio/program/src/instructions/withdraw_liquidity.rs Validates the canonical LP mint before using its supply and releasing proportional reserves.
tokens/token-swap/pinocchio/tests/test.ts Covers the full pool lifecycle and verifies rejection of substituted reserves, counterfeit liquidity mints, and mismatched pool mints.

Reviews (2): Last reviewed commit: "token-swap: bind the pool vaults and liq..." | Re-trigger Greptile

Comment on lines +76 to +77
};
let input = input_amount.min(held);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 security Unbound reserves enable vault drain

When a trader supplies a zero-balance counterfeit paying-side reserve together with the genuine opposite reserve, pricing returns the genuine reserve's balance and the zero-valued invariant check still passes, allowing that vault to be drained. Bind both reserve accounts to the pool authority's canonical token accounts for the stored mints. How this was verified: The reserve arguments are omitted from PoolSeeds::load but are used directly for pricing, PDA-signed settlement, and both invariant calculations.

Knowledge Base Used:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All three are correct and share one root cause — fixed in 3b767698.

PoolSeeds::load rederived the pool and its authority but never checked the two vaults, and neither deposit_liquidity nor withdraw_liquidity checked the liquidity mint. Anchor gets exactly those from associated_token::mint / associated_token::authority and the mint's seeds constraint; I dropped them when porting to explicit checks, and each omission is independently drainable:

  • swap — a zero-balance stand-in for the paying reserve prices the trade against nothing, so the genuine opposite vault pays out its whole balance and the invariant check passes against a zero product;
  • deposit — reserves the caller controls receive the transfers while the pool still PDA-mints genuine LP shares, redeemable against the real reserves;
  • withdraw — a caller-created LP mint sets the entitlement denominator, so they name their own share.

Both vaults are now rederived as the pool authority's associated token accounts, and the liquidity mint from [amm, mint_a, mint_b, b"liquidity"].

Verified rather than assumed: three new tests, one per vector. The swap and deposit ones build a token account that is owned by the pool authority and does hold the right mint but is not the ATA — so a mint-and-owner check would not have caught them, only the address derivation does. Against the previous build all three transactions succeed; with the fix each is rejected with InvalidSeeds and the reserves are asserted untouched. 13 tests passing.

Comment on lines +50 to +54

let supply = mint_supply(&mint_liquidity.try_borrow()?)?;
let denominator = supply.checked_add(MINIMUM_LIQUIDITY).ok_or(SwapError::MathOverflow)?;

let amount_a = mul_div(amount, token_amount(&pool_account_a.try_borrow()?)?, denominator)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 security Counterfeit LP mint drains reserves

When an attacker supplies a self-created liquidity mint while passing the genuine pool vaults, that mint's supply determines the withdrawal entitlement and its counterfeit tokens are burned after the pool PDA releases genuine assets, allowing nearly all reserves to be withdrawn without genuine LP shares. How this was verified: The handler never derives mint_liquidity using LIQUIDITY_SEED, yet reads its supply for entitlement and burns from it after both PDA-signed vault transfers.

Knowledge Base Used: Token escrow, swaps, and fundraising

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All three are correct and share one root cause — fixed in 3b767698.

PoolSeeds::load rederived the pool and its authority but never checked the two vaults, and neither deposit_liquidity nor withdraw_liquidity checked the liquidity mint. Anchor gets exactly those from associated_token::mint / associated_token::authority and the mint's seeds constraint; I dropped them when porting to explicit checks, and each omission is independently drainable:

  • swap — a zero-balance stand-in for the paying reserve prices the trade against nothing, so the genuine opposite vault pays out its whole balance and the invariant check passes against a zero product;
  • deposit — reserves the caller controls receive the transfers while the pool still PDA-mints genuine LP shares, redeemable against the real reserves;
  • withdraw — a caller-created LP mint sets the entitlement denominator, so they name their own share.

Both vaults are now rederived as the pool authority's associated token accounts, and the liquidity mint from [amm, mint_a, mint_b, b"liquidity"].

Verified rather than assumed: three new tests, one per vector. The swap and deposit ones build a token account that is owned by the pool authority and does hold the right mint but is not the ATA — so a mint-and-owner check would not have caught them, only the address derivation does. Against the previous build all three transactions succeed; with the fix each is rejected with InvalidSeeds and the reserves are asserted untouched. 13 tests passing.

Comment on lines +60 to +67
let seeds = PoolSeeds::load(program_id, pool, pool_authority, mint_a, mint_b)?;

// Never take more than the depositor holds.
let mut amount_a = requested_a.min(token_amount(&depositor_account_a.try_borrow()?)?);
let mut amount_b = requested_b.min(token_amount(&depositor_account_b.try_borrow()?)?);

let pool_a = token_amount(&pool_account_a.try_borrow()?)?;
let pool_b = token_amount(&pool_account_b.try_borrow()?)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 security Unbound deposits mint unbacked shares

When a depositor supplies token accounts they control as the pool reserves while retaining the genuine liquidity mint, the transfers return the assets to attacker-controlled accounts but the pool PDA still mints genuine LP shares, which can then redeem assets from the real reserves. How this was verified: PoolSeeds::load omits both reserve accounts, while their balances drive share issuance and the transfers target them immediately before genuine PDA-authorized LP minting.

Knowledge Base Used:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All three are correct and share one root cause — fixed in 3b767698.

PoolSeeds::load rederived the pool and its authority but never checked the two vaults, and neither deposit_liquidity nor withdraw_liquidity checked the liquidity mint. Anchor gets exactly those from associated_token::mint / associated_token::authority and the mint's seeds constraint; I dropped them when porting to explicit checks, and each omission is independently drainable:

  • swap — a zero-balance stand-in for the paying reserve prices the trade against nothing, so the genuine opposite vault pays out its whole balance and the invariant check passes against a zero product;
  • deposit — reserves the caller controls receive the transfers while the pool still PDA-mints genuine LP shares, redeemable against the real reserves;
  • withdraw — a caller-created LP mint sets the entitlement denominator, so they name their own share.

Both vaults are now rederived as the pool authority's associated token accounts, and the liquidity mint from [amm, mint_a, mint_b, b"liquidity"].

Verified rather than assumed: three new tests, one per vector. The swap and deposit ones build a token account that is owned by the pool authority and does hold the right mint but is not the ATA — so a mint-and-owner check would not have caught them, only the address derivation does. Against the previous build all three transactions succeed; with the fix each is rejected with InvalidSeeds and the reserves are asserted untouched. 13 tests passing.

PoolSeeds::load rederived the pool and its authority but never checked
the two vaults, and neither deposit nor withdraw checked the liquidity
mint. Anchor gets those from associated_token::mint/authority and the
mint's seeds constraint; dropping them in the port left three ways to
drain a pool:

  - swap with a zero-balance stand-in for the paying reserve prices the
    trade against nothing and empties the opposite vault;
  - deposit into caller-controlled reserves still mints genuine LP
    shares, redeemable against the real ones;
  - withdraw against a caller-created LP mint sets the entitlement to
    whatever they like.

Rederive both vaults as the authority's associated token accounts, and
the liquidity mint from its seeds. Each vector has a test verified to
succeed without the checks.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant