Conversation
4c9e894 to
61dfdf8
Compare
programs/anchor/basic-macros/create-associated-token-account/Cargo.toml
Outdated
Show resolved
Hide resolved
5526128 to
bd39ca7
Compare
Peer-to-peer escrow, crowdfunding, AMM swap, and mint helper programs demonstrating rent-free Light Token vaults with full test coverage across SPL, Token-2022, and Light token standards.
bd39ca7 to
6a7061c
Compare
programs/anchor/token-swap/src/instructions/deposit_liquidity.rs
Outdated
Show resolved
Hide resolved
programs/anchor/token-swap/src/instructions/deposit_liquidity.rs
Outdated
Show resolved
Hide resolved
programs/anchor/token-swap/src/instructions/swap_exact_tokens_for_tokens.rs
Outdated
Show resolved
Hide resolved
Co-authored-by: ananas-block <58553958+ananas-block@users.noreply.github.com>
Co-authored-by: ananas-block <58553958+ananas-block@users.noreply.github.com>
Co-authored-by: ananas-block <58553958+ananas-block@users.noreply.github.com>
…for_tokens.rs Co-authored-by: ananas-block <58553958+ananas-block@users.noreply.github.com>
Co-authored-by: ananas-block <58553958+ananas-block@users.noreply.github.com>
| let mut liquidity = I64F64::from_num(amount_a) | ||
| .checked_mul(I64F64::from_num(amount_b)) | ||
| .ok_or(SwapError::Overflow)? | ||
| .sqrt() | ||
| .to_num::<u64>(); |
There was a problem hiding this comment.
🟡 AMM deposit_liquidity uses sqrt for subsequent deposits instead of proportional minting, allowing value extraction from existing LPs
For non-initial deposits, deposit_liquidity computes LP tokens as sqrt(amount_a * amount_b) (line 66-70). The correct formula for subsequent deposits is min(amount_a * total_supply / reserve_a, amount_b * total_supply / reserve_b), which ensures the new depositor's share is proportional to their contribution.
Root Cause and Impact
After swaps with fees, the pool invariant R_A * R_B increases beyond the initial a1 * b1. Since sqrt(R_A * R_B) > sqrt(a1 * b1), a proportional deposit of k% of each reserve yields k * sqrt(R_A * R_B) LP tokens — more than the proportional k * total_supply.
Concrete example:
- Initial deposit: 1000 A + 1000 B →
lp_supply = 900(sqrt(1M) - 100) - After swaps with fees, pool is 1100 A, 920 B (product = 1,012,000 > 1,000,000)
- Second depositor adds 10% of each: 110 A, 92 B
- Correct LP:
min(110*1000/1100, 92*1000/920) = 100 - Actual LP:
sqrt(110*92) ≈ 100.6
The 0.6 extra LP tokens dilute existing LP holders' share. The discrepancy scales with the accumulated swap fees. This is inherited from the original Solana Program Examples SPL AMM that this code ports, but it is a real correctness issue in the newly added program.
Prompt for agents
In programs/anchor/token-swap/src/instructions/deposit_liquidity.rs, replace the sqrt-based LP minting at lines 66-77 with a proportional formula for non-initial deposits. For initial deposits (pool_creation == true), keep sqrt(amount_a * amount_b) - MINIMUM_LIQUIDITY. For subsequent deposits, compute: liquidity = min(amount_a * (lp_supply + MINIMUM_LIQUIDITY) / pool_a_balance, amount_b * (lp_supply + MINIMUM_LIQUIDITY) / pool_b_balance) using the existing I64F64 fixed-point math to avoid overflow. The lp_supply value should be read from ctx.accounts.pool.lp_supply, and MINIMUM_LIQUIDITY is already imported from constants.
Was this helpful? React with 👍 or 👎 to provide feedback.
Now that the SDK returns ProgramError directly, the manual conversion at every call site is unnecessary.
…-compressed-account light-token was a path dep while light-client and light-program-test were registry deps, causing two copies of light-compressed-account@0.11.0 (path vs registry). This made CompressedProof types incompatible across the two dependency trees. Changed all 12 Light Protocol workspace deps to path deps pointing to the local monorepo.
Replace UncheckedAccount + Pubkey::default() sentinel with Option<AccountInfo> + .is_some() for optional SPL interface PDAs in fundraiser (contribute, checker, refund) and deposit_liquidity. Remove redundant .map_err() in transfer-interface and create-and-transfer.
Replace TransferFromSpl with TransferInterface in shared-test-utils for consistency with program-side TransferInterfaceCpi. Update escrow, fundraiser, and token-swap programs and tests.
- Add mint param to TransferInterfaceCpi::new() (all programs) - Make fee_payer mandatory, remove Option wrapper (rust-client, pinocchio) - Add fee_payer to Approve/Revoke structs (rust-client) - Add mint field to TransferInterface structs (rust-client, shared-test-utils) - Make escrow spl_interface_pda accounts optional (Option<AccountInfo>) with conditional with_spl_interface for Light-to-Light transfers - Use checked arithmetic in fundraiser contribute (matches refund) - Replace local path deps with git deps pinned to fix/authority-owner-mutability
645e1a7 to
944aea8
Compare
The git branch deps pointed to a repo with an SSH submodule (photon.git) that CI runners can't authenticate. Switch all light-* crates to crates.io 0.23.0 releases across anchor programs, pinocchio swap, rust-client, and streaming-tokens toolkit. Also removes the now-consolidated transfer_checked action/instruction from rust-client (merged into transfer_interface).
Adds a separate test-pinocchio job since it has a different working directory and doesn't need the light-token-minter build step.
Replace duplicated Solana/Rust/Light/Photon setup steps with the shared .github/actions/setup composite action. Bump Solana CLI from 2.1.21 to 2.3.11 to match the Rust workflow. Also normalizes indentation from 4-space to 2-space to match rust-tests.yml.
- Remove transfer-checked row from README (files deleted) - Update CLAUDE.md: local path deps → crates.io 0.23.0 - Add pinocchio/swap build command to CLAUDE.md
| /// CHECK: Can be SPL, T22, or Light | ||
| #[account(mut)] | ||
| pub mint_liquidity: UncheckedAccount<'info>, |
There was a problem hiding this comment.
🔴 Missing LP mint validation allows cross-pool LP token attack in DepositLiquidity and WithdrawLiquidity
The mint_liquidity account in DepositLiquidity and WithdrawLiquidity is an UncheckedAccount with no seeds, has_one, or address constraint validating it corresponds to the pool being operated on. Since all LP mints share the same global pool_authority PDA (derived from just [b"authority"]) as their mint authority, the mint_to and burn CPIs will succeed with any LP mint created by the program.
Exploit Scenario
The Pool struct at token-swap/src/state.rs:18-24 does not store the LP mint address, and DepositLiquidity at token-swap/src/instructions/deposit_liquidity.rs:194-196 declares mint_liquidity with no validation:
/// CHECK: Can be SPL, T22, or Light
#[account(mut)]
pub mint_liquidity: UncheckedAccount<'info>,All LP mints have pool_authority (a global PDA from [AUTHORITY_SEED]) as their mint authority. An attacker can:
- Create pool X (legitimate, with depositors)
- Create pool Y (attacker-controlled, minimal liquidity)
- Call
deposit_liquidityon pool Y but pass pool X's LP mint asmint_liquidity→ receives pool X LP tokens - Call
withdraw_liquidityon pool X with those LP tokens → extracts pool X's funds
The mint_to CPI in deposit_liquidity succeeds because pool X's LP mint authority matches pool_authority. The burn CPI in withdraw_liquidity succeeds because the attacker owns valid pool X LP tokens. Pool X's lp_supply is decremented, locking out legitimate LPs.
Impact: An attacker can drain accumulated fees (and potentially principal) from any pool by cross-minting LP tokens via a sacrificial pool.
Prompt for agents
Fix the missing LP mint validation in both DepositLiquidity (programs/anchor/token-swap/src/instructions/deposit_liquidity.rs) and WithdrawLiquidity (programs/anchor/token-swap/src/instructions/withdraw_liquidity.rs).
1. Add a `mint_liquidity` field to the `Pool` struct in programs/anchor/token-swap/src/state.rs. Update `Pool::LEN` accordingly.
2. In create_pool.rs and create_pool_light_lp.rs, store the LP mint address in pool.mint_liquidity during initialization.
3. In DepositLiquidity, change `mint_liquidity` from UncheckedAccount to have a constraint: `constraint = mint_liquidity.key() == pool.mint_liquidity @ SwapError::InvalidMint`.
4. In WithdrawLiquidity, add the same constraint on `mint_liquidity`.
Alternatively, if you don't want to change Pool state, you could derive the expected LP mint address from seeds and verify it matches, but storing it is simpler and more robust.
Was this helpful? React with 👍 or 👎 to provide feedback.
Replace terminology violations across pinocchio-swap and anchor token-swap: ATA/ATAs → associated token account(s), T22 → Token 2022, standalone Light → Light Token.
| if pool_a_balance > pool_b_balance { | ||
| ( | ||
| I64F64::from_num(amount_b) | ||
| .checked_mul(ratio) | ||
| .ok_or(SwapError::Overflow)? | ||
| .to_num::<u64>(), | ||
| amount_b, | ||
| ) | ||
| } else { | ||
| ( | ||
| amount_a, | ||
| I64F64::from_num(amount_a) | ||
| .checked_div(ratio) | ||
| .ok_or(SwapError::Underflow)? | ||
| .to_num::<u64>(), | ||
| ) | ||
| } |
There was a problem hiding this comment.
🔴 Deposit liquidity ratio adjustment inflates amounts beyond depositor balance, causing unnecessary reverts
The deposit_liquidity ratio adjustment logic always inflates one side of the deposit, which can exceed the depositor's capped balance and cause the transaction to revert.
Root Cause
When an existing pool has a non-1:1 ratio, the code adjusts deposit amounts to match the pool's ratio. However, both branches inflate one of the amounts rather than deflating the other:
- When
pool_a_balance > pool_b_balance(ratio > 1):amount_a = amount_b * ratio— increasesamount_abeyond its capped value - When
pool_a_balance <= pool_b_balance(ratio <= 1):amount_b = amount_a / ratio— sinceratio <= 1, dividing by it increasesamount_bbeyond its capped value
The amounts were already capped to the depositor's balance at lines 28-37:
let mut amount_a = if amount_a > ctx.accounts.depositor_account_a.amount {
ctx.accounts.depositor_account_a.amount
} else { amount_a };But then lines 47-63 overwrite one amount with a value that can exceed the depositor's token balance. The subsequent TransferInterfaceCpi at line 83/104 will then fail because the user doesn't have enough tokens.
Example: Pool has 100A:50B (ratio=2). User has 200A, 200B. After capping: amount_a=200, amount_b=200. Since pool_a > pool_b, code computes amount_a = 200 * 2 = 400. But user only has 200A → transfer reverts.
The correct approach would be to try both directions and pick the one that fits within both balances, or to always adjust downward (keep the limiting-side amount fixed and reduce the other). The fix would swap the branches: when pool_a > pool_b, keep amount_a fixed and compute amount_b = amount_a / ratio (reducing B); when pool_a <= pool_b, keep amount_b fixed and compute amount_a = amount_b * ratio (reducing A since ratio ≤ 1).
Impact: Deposits into any non-1:1 pool will fail unless the user happens to have excess tokens far beyond the deposit amount. The first deposit (pool creation) and 1:1-ratio pools are unaffected.
| if pool_a_balance > pool_b_balance { | |
| ( | |
| I64F64::from_num(amount_b) | |
| .checked_mul(ratio) | |
| .ok_or(SwapError::Overflow)? | |
| .to_num::<u64>(), | |
| amount_b, | |
| ) | |
| } else { | |
| ( | |
| amount_a, | |
| I64F64::from_num(amount_a) | |
| .checked_div(ratio) | |
| .ok_or(SwapError::Underflow)? | |
| .to_num::<u64>(), | |
| ) | |
| } | |
| if pool_a_balance > pool_b_balance { | |
| ( | |
| amount_a, | |
| I64F64::from_num(amount_a) | |
| .checked_div(ratio) | |
| .ok_or(SwapError::Underflow)? | |
| .to_num::<u64>(), | |
| ) | |
| } else { | |
| ( | |
| I64F64::from_num(amount_b) | |
| .checked_mul(ratio) | |
| .ok_or(SwapError::Overflow)? | |
| .to_num::<u64>(), | |
| amount_b, | |
| ) | |
| } |
Was this helpful? React with 👍 or 👎 to provide feedback.
Summary
associated_token::bump(now derived on-chain)Programs
Test plan
cargo test-sbf -p escrowcargo test-sbf -p fundraisercargo test-sbf -p light-token-mintercargo test-sbf -p swap_example