Cyfrin First Fight 41 - SSSwap
- Description
- My submissions
- Recommended Mitigation
- Missing submissions
- H-01. Incorrect LP Token Calculation in
provide_liquidityInstruction - H-02. Lack of Deposit Slippage Protection in the
liquidity_operations::provide_liquidityfunction - H-03. No Input Validation for Token Decimals in the
liquidity_operations::initialize_poolfunction - H-05. No Minimum Liquidity Lock in the
liquidity_operations::remove_liquidityfunction
- H-01. Incorrect LP Token Calculation in
The protocol is aimed to be a minimalistic AMM on Solana. A template, if you want to call it that way. Users can fork upon the project and integrate additional own logic, that’s why I kept it simple. This article describes the First Fight 41 from Cyfrin.
The code is available on GitHub.
[TOC]
Description
-
It provides the absolute barebones of AMM functionality:
- Pool Creation
- Liquidity Operations
- Swap Exact In and Exact Out
- It has Slippage Protection
- It has LP Fee Collection
You will realize that commonly used functions like “preview swap” and “get price” are missing, which is intended for the current state. I might decide to add those at a later point.
The audit is supposed to focus on the code validation of what is written and invariants I believe are important are documented within the natspec of the functions in lib.rs.
My submissions
H4-provide_liquidity does not reload vault value
Root + Impact
Description
- In normal behavior, a liquidity provider deposits
amount_aof token A, and the program computes the correspondingamount_bof token B needed to maintain the pool ratio. Then both amounts are transferred to the pool vaults, and the appropriate number of LP tokens is minted to the user. - However, the function
calculate_token_b_provision_with_a_givendepends on reading accurate vault balances. If the vault accounts are not explicitly reloaded (.reload()), the cached state may not reflect recent external token transfers, causing the amount of token B to be miscalculated.
pub fn provide_liquidity(context: Context<ModifyLiquidity>,
amount_a: u64
) -> Result<()> {
let amount_b = calculate_token_b_provision_with_a_given(
&mut context.accounts.vault_a,
&mut context.accounts.vault_b,
amount_a
)?;
transfer_tokens(
&context.accounts.liquidity_provider_token_a,
&mut context.accounts.vault_a,
&amount_a,
&context.accounts.token_a_mint,
&context.accounts.liquidity_provider,
&context.accounts.token_program
)?;
Risk
Likelihood:
- This will occur when tokens have been transferred into either
vault_aorvault_bbefore the instruction executes, either as part of a batched transaction or as an external manipulation. - Programs using cached account state without a fresh
.reload()are susceptible to using outdated balances, particularly in multi-instruction transactions.
Impact:
- The calculated
amount_bcould be incorrect, leading to an imbalance in the pool and unfair liquidity provision. - LP tokens could be minted at an incorrect ratio, diluting other liquidity providers or benefiting manipulators.
Proof of Concept
External actor or same user pre-loads vault_a with extra tokens
send_tokens_to(vault_a, 10_000);
Then provides liquidity based on stale vault_a reserve
provide_liquidity(amount_a = 100_000);
vault_a balance appears lower than reality during calculation, but includes added tokens during transfer // This creates misalignment between expected and actual pool state
Recommended Mitigation
Always reload token accounts before using them in reserve-sensitive calculations to prevent stale state from impacting logi
context.accounts.vault_a.reload()?;
context.accounts.vault_b.reload()?;
Missing submissions
H-01. Incorrect LP Token Calculation in provide_liquidity Instruction
let lp_to_mint: u64 = liquidity_calculation(amount_a, amount_b)?;
fn liquidity_calculation(amount_token_a: u64, amount_token_b: u64) -> Result<u64> {
// Converts both amounts to u128 to avoid overflow during multiplication.
let amount_a_u128 = amount_token_a as u128;
let amount_b_u128 = amount_token_b as u128;
/** Multiplies token A and B amounts.
* Uses .checked_mul() to safely multiply without overflow.
* If multiplication fails (i.e., overflow), returns an AmmError::Overflow error.
* Takes the square root of the product, which is a common formula in AMMs (like Uniswap v2) for initial liquidity:
* LP tokens to mint = sqrt(amountA × amountB)
*/
let lp_amount_to_mint_u128 = amount_a_u128
.checked_mul(amount_b_u128)
.ok_or(AmmError::Overflow)?
.sqrt();
let lp_amount_to_mint = lp_amount_to_mint_u128 as u64; // Cast back to u64
// Check if the result is zero (could happen with very small initial amounts)
if lp_amount_to_mint == 0 {
return err!(AmmError::LpAmountCalculation);
}
Ok(lp_amount_to_mint)
}
This calculation is only correct during the initial pool creation (when no LP tokens exist yet). For subsequent liquidity additions, the LP token amount must be proportional to the existing LP supply and reserves.
Impact
Using the initial pool creation formula to mint LP tokens on additional liquidity deposits will cause:
- Incorrect LP token minting: The amount of LP tokens minted will not reflect the depositor’s true share of the pool.
- Imbalanced LP token distribution: Liquidity providers may receive too many or too few LP tokens, resulting in unfair shares.
- Economic exploits: Malicious actors could exploit this miscalculation to mint more LP tokens than deserved, diluting other liquidity providers’ holdings.
- Pool state inconsistency: Pool accounting and invariant assumptions may break, causing downstream issues in swaps or removals.
Correct LP Token Calculation for Adding Liquidity
When liquidity already exists, LP tokens to mint must be proportional to the existing LP supply and token reserves. The correct formula is:
lpA = (amountA * totalSupply) / reserveA;
lpB = (amountB * totalSupply) / reserveB;
lpTokensToMint = min(lpA, lpB);
Where:
totalSupplyis the current total supply of LP tokens.reserveAandreserveBare the current token balances in the pool vaults.
Recommended Fix
Replace the incorrect call to liquidity_calculation with a function that:
- Fetches the current total LP token supply.
- Fetches the current vault balances (reserves) of tokens A and B.
- Calculates
lp_to_mintas the minimum of the proportional LP tokens derived from each token amount relative to reserves and total LP supply.
fn calculate_lp_tokens_to_mint(
amount_a: u64,
amount_b: u64,
reserve_a: u64,
reserve_b: u64,
total_lp_supply: u64,
) -> Result<u64> {
let lp_a = (amount_a as u128)
.checked_mul(total_lp_supply as u128)
.ok_or(AmmError::Overflow)?
.checked_div(reserve_a as u128)
.ok_or(AmmError::DivideByZero)?;
let lp_b = (amount_b as u128)
.checked_mul(total_lp_supply as u128)
.ok_or(AmmError::Overflow)?
.checked_div(reserve_b as u128)
.ok_or(AmmError::DivideByZero)?;
let lp_to_mint = lp_a.min(lp_b);
if lp_to_mint == 0 {
return err!(AmmError::LpAmountCalculation);
}
Ok(lp_to_mint as u64)
}
H-02. Lack of Deposit Slippage Protection in the liquidity_operations::provide_liquidity function
Description: The AMM doesn’t provide slippage protection for liquidity providers, potentially exposing them to front-running attacks. While the swap functions include slippage protection parameters ( min_out and max_in ), the liquidity provision function doesn’t have similar protection. When a user provides liquidity by specifying only amount_a , the contract calculates amount_b based on the current pool ratio. However, if the pool ratio changes between transaction submission and execution (due to front-running), the user might provide more amount_b than expected.
Impact:
- Liquidity providers are vulnerable to front-running attacks
- Users may provide more tokens than intended if pool ratios change
- Economic loss for liquidity providers
Proof of Concept:
// User wants to provide liquidity with 100 token A
// Current pool: 1000 A, 500 B
// Expected amount_b = (100 * 500) / 1000 = 50 B
// Attacker front-runs with a large swap that changes the ratio
// New pool: 800 A, 625 B
// Actual amount_b = (100 * 625) / 800 = 78.125 B
// User ends up providing ~28 more token B than expected
Recommended Mitigation: Add a maximum token B parameter to the liquidity provision function:
pub fn provide_liquidity(
context: Context<ModifyLiquidity>,
amount_a: u64,
max_amount_b: u64 // New parameter
) -> Result<()> {
let amount_b = calculate_token_b_provision_with_a_given(
&mut context.accounts.vault_a,
&mut context.accounts.vault_b,
amount_a
)?;
// Add slippage protection
require!(amount_b <= max_amount_b, AmmError::Slippage);
// Rest of the function...
Ok(())
}
H-03. No Input Validation for Token Decimals in the liquidity_operations::initialize_pool function
Description: The AMM doesn’t validate token decimals, potentially allowing incompatible tokens or tokens with extreme decimal values. The AMM doesn’t validate the decimal places of tokens when creating a new pool. This could allow the creation of pools with tokens that have extreme decimal values (e.g., 0 or >18) or incompatible decimal combinations, leading to calculation issues.
Impact:
- Pools with tokens having extreme decimal values could experience calculation errors
- Incompatible decimal combinations could lead to unfair pricing
- Potential for precision loss in calculations
Proof of Concept: A user could create a pool with a custom token having 0 decimals and another with 30 decimals:
// In initialize_pool, no validation of token_mint_a.decimals or token_mint_b.decimals
// This allows creation of pools with any decimal combination
Recommended Mitigation: Add decimal validation in the initialize_pool function:
pub fn initialize_pool(context: Context<InitializePool>, amount_token_a: u64, amount_token_b: u64) -> Result<()> {
// Existing validation...
// Validate token decimals
let decimals_a = context.accounts.token_mint_a.decimals;
let decimals_b = context.accounts.token_mint_b.decimals;
// Ensure decimals are within reasonable range
require!(decimals_a > 0 && decimals_a <= 18, AmmError::InvalidTokenDecimals);
require!(decimals_b > 0 && decimals_b <= 18, AmmError::InvalidTokenDecimals);
// Optionally, limit decimal difference to prevent extreme imbalances
let decimal_diff = if decimals_a > decimals_b {
decimals_a - decimals_b
} else {
decimals_b - decimals_a
};
require!(decimal_diff <= 12, AmmError::IncompatibleTokenDecimals);
// Rest of the function...
}
H-05. No Minimum Liquidity Lock in the liquidity_operations::remove_liquidity function
H-05. No Minimum Liquidity Lock in the liquidity_operations::remove_liquidity function
Description: The AMM doesn’t permanently lock a minimum amount of liquidity, allowing complete drainage of pools and potential numerical precision issues. The AMM allows liquidity providers to remove 100% of their liquidity from the pool. This creates scenarios where pools can be completely drained, leading to division by zero errors or extremely small reserves that cause precision issues. Most AMM protocols (like Uniswap) permanently lock a small amount of liquidity to prevent these edge cases.
Impact:
- Pools can be completely drained, causing subsequent operations to fail
- Extremely small reserves can lead to precision issues and unfair pricing
- Potential for temporary DoS of specific pools
Proof of Concept: A user who owns 100% of the LP tokens can remove all liquidity:
// User has all LP tokens (total_supply)
// In remove_liquidity function:
let amount_a_to_return_u128 = (total_supply as u128)
.checked_mul(reserve_a as u128)
.ok_or(AmmError::Overflow)?
.checked_div(total_supply as u128)
.ok_or(AmmError::DivisionByZero)?;
// This returns all of reserve_a
// Same for reserve_b
// Result: Pool is completely drained
Recommended Mitigation: Implement a minimum liquidity lock mechanism in the initialize_pool function:
pub fn initialize_pool(context: Context<InitializePool>, amount_token_a: u64, amount_token_b: u64) -> Result<()> {
// Existing code...
// Calculate LP tokens to mint
let lp_amount_to_mint: u64 = liquidity_calculation(amount_token_a, amount_token_b)?;
// Define minimum liquidity to lock (e.g., 1000)
const MINIMUM_LIQUIDITY: u64 = 1000;
// Ensure we're minting enough to lock some
require!(lp_amount_to_mint > MINIMUM_LIQUIDITY, AmmError::InsufficientInitialLiquidity);
// Mint LP tokens to creator
let creator_lp_amount = lp_amount_to_mint - MINIMUM_LIQUIDITY;
// Mint to creator
mint_to(cpi_context, creator_lp_amount)?;
// Mint minimum liquidity to a dead address or the protocol itself
let cpi_accounts_min_liquidity = MintTo {
mint: context.accounts.lp_mint.to_account_info(),
to: dead_address_account.to_account_info(), // An account that can never spend
authority: context.accounts.liquidity_pool.to_account_info(),
};
let cpi_context_min_liquidity = CpiContext::new_with_signer(
context.accounts.token_program.to_account_info(),
cpi_accounts_min_liquidity,
signer_seeds,
);
mint_to(cpi_context_min_liquidity, MINIMUM_LIQUIDITY)?;
Ok(())
}