DEV Community

Noyan
Noyan

Posted on

Build a CLMM on Solana

Preface

In this post, I’ll walk you through building a Concentrated Liquidity Market Maker (CLMM) program on Solana — essentially a smart contract with Uniswap V3-style concentrated liquidity.

I’ll assume you’re already familiar with how CLMMs work and how they differ from traditional constant-product AMMs (like Uniswap V2). If not, I highly recommend this breakdown to get up to speed.

The full source code is available in the GitHub repository.

We’ll be using the Steel framework to write the Solana program in Rust.


Core Math Formulas

Before jumping into the code, let’s review the essential math powering the CLMM. These formulas are implemented in formulas.rs.

1. Tick Index ↔ √Price Conversion

We convert between tick indices and square root prices using a logarithmic spacing:

Tick index formula

pub fn sqrt_price_from_tick_index(tick: i64) -> Result<u64, ClmmError> {
    let price = f64::powf(TICK_BASE, tick as f64 / 2.0);
    let result = price * Q64;
    f64_to_q64(result, false)
}

pub fn tick_index_from_sqrt_price(sqrt_price: u64) -> i64 {
    let sqrt_price_f64 = q64_to_f64(sqrt_price) / Q64;
    let price = sqrt_price_f64 * sqrt_price_f64;
    (f64::ln(price) / f64::ln(TICK_BASE)).round() as i64
}
Enter fullscreen mode Exit fullscreen mode

Note: All sqrt_price values are in Q64.64 fixed-point format.

For example, in a SOL/USDC pool, a sqrt_price of 7_292_386_294_611_484_655 represents:

real_sqrt_price = 7_292_386_294_611_484_655 / 2⁶⁴ ≈ 0.39532105316
price = (0.39532105316)² ≈ 0.15627873507

Since SOL has 9 decimals and USDC has 6, we adjust by 10³:

Price ≈ 156.28 USDC per SOL


2. Delta A (Token A Amount)

Given liquidity and two price bounds, compute the amount of Token A:

Delta A formula

We can also solve for the next price given a desired Token A input:

pub fn get_delta_a(
    liquidity: u64,
    sqrt_price_current: u64,
    sqrt_price_target: u64,
    round_up: bool,
) -> Result<u64, ClmmError> {
    let (sqrt_price_lower, sqrt_price_upper) =
        increasing_sqrt_price_order(sqrt_price_current, sqrt_price_target);

    let l = q64_to_f64(liquidity);
    let p1 = q64_to_f64(sqrt_price_lower);
    let p2 = q64_to_f64(sqrt_price_upper);

    let result = (l * (p2 - p1) * Q64) / (p2 * p1);
    f64_to_q64(result, round_up)
}

pub fn get_next_sqrt_price_a_round_up(
    liquidity: u64,
    sqrt_price_current: u64,
    amount_a: u64,
) -> Result<u64, ClmmError> {
    if amount_a == 0 { return Ok(sqrt_price_current); }

    let l = q64_to_f64(liquidity);
    let p = q64_to_f64(sqrt_price_current);
    let a = q64_to_f64(amount_a);

    let numerator = l * p * Q64;
    let denominator = l * Q64 + a * p;
    f64_to_q64(numerator / denominator, true)
}
Enter fullscreen mode Exit fullscreen mode

3. Delta B (Token B Amount)

Similarly for Token B:

Delta B formula

pub fn get_delta_b(
    liquidity: u64,
    sqrt_price_current: u64,
    sqrt_price_target: u64,
    round_up: bool,
) -> Result<u64, ClmmError> {
    let (sqrt_price_lower, sqrt_price_upper) =
        increasing_sqrt_price_order(sqrt_price_current, sqrt_price_target);

    let l = q64_to_f64(liquidity);
    let p1 = q64_to_f64(sqrt_price_lower);
    let p2 = q64_to_f64(sqrt_price_upper);

    let result = l * (p2 - p1);
    f64_to_q64(result / Q64, round_up)
}

pub fn get_next_sqrt_price_b_round_down(
    liquidity: u64,
    sqrt_price_current: u64,
    amount_b: u64,
) -> Result<u64, ClmmError> {
    if amount_b == 0 { return Ok(sqrt_price_current); }

    let l = q64_to_f64(liquidity);
    let p = q64_to_f64(sqrt_price_current);
    let b = q64_to_f64(amount_b) * Q64;

    let result = p + (b / l);
    f64_to_q64(result, false)
}
Enter fullscreen mode Exit fullscreen mode

Program State: Pool & Position Accounts

Now let’s define the core state. Our program uses two main account types:

Pool Account

pub struct Pool {
    pub tick_spacing: u64,
    pub fee_rate: u64,          // 10000 = 100%
    pub protocol_fee_rate: u64, // 10000 = 100%
    pub liquidity: u64,
    pub sqrt_price: u64, // Q64.64
    pub tick_current_index: i64,
    pub token_mint_a: Pubkey,
    pub token_mint_b: Pubkey,
    pub token_vault_a: Pubkey,
    pub token_vault_b: Pubkey,
    pub fee_growth_global_a: u64, // Q64.64
    pub fee_growth_global_b: u64, // Q64.64
    pub protocol_fee_owed_a: u64,
    pub protocol_fee_owed_b: u64,
    pub ticks: [Tick; MAX_TICKS],
}
Enter fullscreen mode Exit fullscreen mode

Tick Struct

Each pool contains an array of MAX_TICKS initialized tick entries:

pub struct Tick {
    pub initialized: u64,         // 1 = true, 0 = false
    pub liquidity_net: i64,
    pub liquidity_gross: u64,
    pub fee_growth_outside_a: u64, // Q64.64
    pub fee_growth_outside_b: u64, // Q64.64
}
Enter fullscreen mode Exit fullscreen mode

Why liquidity_net is signed:

When crossing a lower tick, liquidity is added (+ΔL).

When crossing an upper tick, liquidity is removed (−ΔL).

This enables efficient tracking of active liquidity as price moves.


Position Struct

Each liquidity provider owns a Position account:

pub struct Position {
    pub liquidity: u64,
    pub tick_lower_index: i64,
    pub tick_upper_index: i64,
    pub fee_growth_checkpoint_a: u64, // Q64.64
    pub fee_growth_checkpoint_b: u64, // Q64.64
    pub fee_owed_a: u64,
    pub fee_owed_b: u64,
}
Enter fullscreen mode Exit fullscreen mode

This tracks the user’s liquidity range, accrued fees, and checkpoints for fee calculation.


Core Instructions

The program supports four key instructions:

  1. InitializePool
  2. InitializePosition
  3. IncreaseLiquidity
  4. Swap

Let’s walk through each.


1. InitializePool

Creates the pool and two SPL token accounts (vaults) (for Token A and Token B):

let pool = &mut pool_info.as_account_mut::<Pool>(&clmm_api::ID)?;
pool.tick_spacing = tick_spacing;
pool.fee_rate = fee_rate;
pool.protocol_fee_rate = protocol_fee_rate;

pool.liquidity = 0;
pool.sqrt_price = initial_sqrt_price;
pool.tick_current_index = tick_index_from_sqrt_price(initial_sqrt_price);

pool.token_mint_a = *token_mint_a_info.key;
pool.token_mint_b = *token_mint_b_info.key;
pool.token_vault_a = token_vault_a.0;
pool.token_vault_b = token_vault_b.0;

pool.fee_growth_global_a = 0;
pool.fee_growth_global_b = 0;
pool.protocol_fee_owed_a = 0;
pool.protocol_fee_owed_b = 0;

pool.ticks = [Tick {
    initialized: 0,
    liquidity_net: 0,
    liquidity_gross: 0,
    fee_growth_outside_a: 0,
    fee_growth_outside_b: 0,
}; MAX_TICKS];
Enter fullscreen mode Exit fullscreen mode

All ticks start uninitialized. The pool is ready — but has no liquidity yet.


2. InitializePosition

Creates a user’s liquidity position between two tick indices:

let position = &mut position_info.as_account_mut::<Position>(&clmm_api::ID)?;
position.liquidity = 0;
position.tick_lower_index = tick_lower_index;
position.tick_upper_index = tick_upper_index;
position.fee_growth_checkpoint_a = 0;
position.fee_growth_checkpoint_b = 0;
position.fee_owed_a = 0;
position.fee_owed_b = 0;
Enter fullscreen mode Exit fullscreen mode

Validation

We ensure both ticks are valid and respect tick_spacing:

let pool = pool_info.as_account::<Pool>(&clmm_api::ID)?;
pool.assert_tick_in_bounds_and_spacing(tick_lower_index)?;
pool.assert_tick_in_bounds_and_spacing(tick_upper_index)?;
Enter fullscreen mode Exit fullscreen mode

This prevents invalid ranges and enforces uniform tick spacing.


3. IncreaseLiquidity

The heart of liquidity provision. The user specifies a desired liquidity amount (typically calculated client-side from token inputs), and we compute how much Token A and Token B they must deposit.

Step 1: Compute Required Token Amounts (delta_a, delta_b)

    let (delta_a, delta_b) = if pool.tick_current_index < position.tick_lower_index {
        (
            get_delta_a(liquidity, lower_sqrt_price, upper_sqrt_price, true)?,
            0,
        )
    } else if pool.tick_current_index < position.tick_upper_index {
        (
            get_delta_a(liquidity, pool.sqrt_price, upper_sqrt_price, true)?,
            get_delta_b(liquidity, lower_sqrt_price, pool.sqrt_price, true)?,
        )
    } else {
        (
            0,
            get_delta_b(liquidity, lower_sqrt_price, upper_sqrt_price, true)?,
        )
    };
Enter fullscreen mode Exit fullscreen mode

Interpretation:

Current Price vs Position Deposit
Above range (P > P_upper) Only Token A
In range (P_lower ≤ P ≤ P_upper) Both A and B
Below range (P < P_lower) Only Token B

Step 2: Update Pool Liquidity (if in range)

if pool.tick_current_index >= position.tick_lower_index
    && pool.tick_current_index < position.tick_upper_index
{
    pool.liquidity += liquidity;
}
Enter fullscreen mode Exit fullscreen mode

Only active positions contribute to current pool liquidity.


Step 3: Update Tick State

For both lower and upper ticks:

    let (fee_growth_outside_a, fee_growth_outside_b) = if tick.liquidity_gross == 0 {
        // By convention, assume all prior growth happened below the tick
        if tick_current_index >= tick_index {
            (fee_growth_global_a, fee_growth_global_b)
        } else {
            (0, 0)
        }
    } else {
        (tick.fee_growth_outside_a, tick.fee_growth_outside_b)
    };

    Tick {
        initialized: 1, // true
        liquidity_gross,
        liquidity_net: if is_upper_tick {
            tick.liquidity_net - liquidity_delta
        } else {
            tick.liquidity_net + liquidity_delta
        },
        fee_growth_outside_a,
        fee_growth_outside_b,
    }
Enter fullscreen mode Exit fullscreen mode

Why the sign flip in liquidity_net?

When price crosses a lower tick → liquidity enters the active range → +ΔL

When price crosses an upper tick → liquidity exits−ΔL


Step 4: Update Position Liquidity

position.liquidity += liquidity;
Enter fullscreen mode Exit fullscreen mode

Step 5: Accrue Fees

We calculate fees earned since last update using the fee growth inside the position’s range:

    let (fee_growth_below_a, fee_growth_below_b) = if lower_tick.initialized == 0 {
        // By convention, when initializing a tick, all fees have been earned below the tick.
        (pool.fee_growth_global_a, pool.fee_growth_global_b)
    } else if pool.tick_current_index < position.tick_lower_index {
        (
            pool.fee_growth_global_a - lower_tick.fee_growth_outside_a,
            pool.fee_growth_global_b - lower_tick.fee_growth_outside_b,
        )
    } else {
        (
            lower_tick.fee_growth_outside_a,
            lower_tick.fee_growth_outside_b,
        )
    };

    let (fee_growth_above_a, fee_growth_above_b) = if upper_tick.initialized == 0 {
        // By convention, when initializing a tick, no fees have been earned above the tick.
        (0, 0)
    } else if pool.tick_current_index < position.tick_upper_index {
        (
            upper_tick.fee_growth_outside_a,
            upper_tick.fee_growth_outside_b,
        )
    } else {
        (
            pool.fee_growth_global_a - upper_tick.fee_growth_outside_a,
            pool.fee_growth_global_b - upper_tick.fee_growth_outside_b,
        )
    };

    let fee_growth_inside_a = pool.fee_growth_global_a - fee_growth_below_a - fee_growth_above_a;
    let fee_growth_inside_b = pool.fee_growth_global_b - fee_growth_below_b - fee_growth_above_b;

    let prev_fee_growth_checkpoint_a = position.fee_growth_checkpoint_a;
    let prev_fee_growth_checkpoint_b = position.fee_growth_checkpoint_b;
    position.fee_growth_checkpoint_a = fee_growth_inside_a;
    position.fee_growth_checkpoint_b = fee_growth_inside_b;

    let growth_delta_a = fee_growth_inside_a - prev_fee_growth_checkpoint_a;
    let growth_delta_b = fee_growth_inside_b - prev_fee_growth_checkpoint_b;

    let fee_delta_a = (growth_delta_a as f64 * prev_liquidity as f64) / Q64;
    let fee_delta_b = (growth_delta_b as f64 * prev_liquidity as f64) / Q64;

    position.fee_owed_a += fee_delta_a as u64;
    position.fee_owed_b += fee_delta_b as u64;
Enter fullscreen mode Exit fullscreen mode

Visual Aid:

Fee Growth Inside Calculation

L - lower tick
U - upper tick
This ensures LPs earn fees proportional to their share of active liquidity.


4. Swap — The Core of Trading

The Swap instruction processes a user’s trade by iterating until the full input amount is consumed. It handles two scenarios per loop:

Case Description
1. Enough liquidity The current tick segment has sufficient liquidity to fulfill the entire remaining input → update price, fees, and exit.
2. Liquidity exhausted Only part of the input is used → consume all liquidity in the segment, cross the next tick, update state, and loop again.

This continues until amount_remaining == 0.


High-Level Swap Loop

    let mut amount_remaining = input_amount;
    let mut amount_calculated = 0;
    let mut curr_price = pool.sqrt_price;
    let mut curr_tick_index = pool.tick_current_index;
    let mut curr_liquidity = pool.liquidity;
    let mut curr_fee_growth_global_input = if a_to_b {
        pool.fee_growth_global_a
    } else {
        pool.fee_growth_global_b
    };
    let mut protocol_fee_sum = 0;

    while amount_remaining != 0 {
        let (next_init_tick_index, next_init_tick) =
            get_next_initialized_tick(&pool, curr_tick_index, a_to_b)?;
        let next_init_tick_price = sqrt_price_from_tick_index(next_init_tick_index)?;

        loop {
            let compute_swap_result = compute_swap(
                amount_remaining,
                pool.fee_rate,
                pool.protocol_fee_rate,
                curr_liquidity,
                curr_price,
                next_init_tick_price,
                a_to_b,
            )?;

            amount_remaining = amount_remaining
                .saturating_sub(compute_swap_result.amount_in)
                .saturating_sub(compute_swap_result.fee_amount)
                .saturating_sub(compute_swap_result.protocol_fee_amount);

            amount_calculated += compute_swap_result.amount_out;
            protocol_fee_sum += compute_swap_result.protocol_fee_amount;

            curr_fee_growth_global_input = calculate_next_fee_growth_global_input(
                compute_swap_result.fee_amount,
                curr_fee_growth_global_input,
                curr_liquidity,
            );

            if compute_swap_result.is_max_swap {
                let (fee_growth_global_a, fee_growth_global_b) = if a_to_b {
                    (curr_fee_growth_global_input, pool.fee_growth_global_b)
                } else {
                    (pool.fee_growth_global_a, curr_fee_growth_global_input)
                };
                pool.set_tick(
                    next_init_tick_index,
                    Tick {
                        initialized: next_init_tick.initialized,
                        liquidity_net: next_init_tick.liquidity_net,
                        liquidity_gross: next_init_tick.liquidity_gross,
                        fee_growth_outside_a: fee_growth_global_a
                            - next_init_tick.fee_growth_outside_a,
                        fee_growth_outside_b: fee_growth_global_b
                            - next_init_tick.fee_growth_outside_b,
                    },
                );
                curr_liquidity =
                    get_next_liquidity(next_init_tick.liquidity_net, curr_liquidity, a_to_b);

                curr_tick_index = next_init_tick_index;
            } else {
                curr_tick_index = tick_index_from_sqrt_price(compute_swap_result.next_price);
            }

            curr_price = compute_swap_result.next_price;

            // do outer loop
            if amount_remaining == 0 || compute_swap_result.is_max_swap {
                break;
            }
        }
    }

    pool.liquidity = curr_liquidity;
    pool.sqrt_price = curr_price;
    pool.tick_current_index = curr_tick_index;
    if a_to_b {
        pool.fee_growth_global_a = curr_fee_growth_global_input;
        pool.protocol_fee_owed_a += protocol_fee_sum;
    } else {
        pool.fee_growth_global_b = curr_fee_growth_global_input;
        pool.protocol_fee_owed_b += protocol_fee_sum;
    }
Enter fullscreen mode Exit fullscreen mode

Direction:

  • a_to_b = true → selling Token A → price decreases → fees accrue in Token A
  • a_to_b = false → selling Token B → price increases → fees accrue in Token B

Fee Growth Update

Fees are per unit of liquidity. We add:

fn calculate_next_fee_growth_global_input(
    fee_amount: u64,
    current_growth: u64,
    liquidity: u64,
) -> u64 {
    if liquidity == 0 {
        return current_growth;
    }
    let delta = (fee_amount as f64) * Q64 / (liquidity as f64);
    (current_growth as f64 + delta) as u64
}
Enter fullscreen mode Exit fullscreen mode

This accumulates fees globally for the input token.


compute_swap: Core Price Impact Logic

fn compute_swap(
    amount_remaining: u64,
    fee_rate: u64,
    protocol_fee_rate: u64,
    liquidity: u64,
    price_current: u64,
    price_target: u64,
    a_to_b: bool,
) -> Result<ComputeSwapResult, ClmmError> {
    let initial_amount_fixed_delta =
        get_amount_fixed_delta(price_current, price_target, liquidity, a_to_b)?;

    let next_price = if initial_amount_fixed_delta <= amount_remaining {
        price_target
    } else {
        get_next_price(liquidity, price_current, amount_remaining, a_to_b)?
    };
    let is_max_swap = next_price == price_target;

    let amount_fixed_delta = if is_max_swap {
        initial_amount_fixed_delta
    } else {
        get_amount_fixed_delta(price_current, next_price, liquidity, a_to_b)?
    };
    let amount_fixed_delta = amount_fixed_delta.min(amount_remaining);

    let amount_unfixed_delta =
        get_amount_unfixed_delta(price_current, next_price, liquidity, a_to_b)?;

    let fee_amount = amount_fixed_delta
        .checked_mul(fee_rate)
        .and_then(|v| v.checked_div(10000))
        .unwrap_or(0);
    let protocol_fee_amount = amount_fixed_delta
        .checked_mul(protocol_fee_rate)
        .and_then(|v| v.checked_div(10000))
        .unwrap_or(0);

    let amount_in = amount_fixed_delta
        .saturating_sub(fee_amount)
        .saturating_sub(protocol_fee_amount);

    Ok(ComputeSwapResult {
        amount_in,
        amount_out: amount_unfixed_delta,
        next_price,
        fee_amount,
        protocol_fee_amount,
        is_max_swap,
    })
}
Enter fullscreen mode Exit fullscreen mode

is_max_swap = true → we crossed the tick → update liquidity & tick state

is_max_swap = false → we stayed in segment → final iteration


Closing Thoughts

We just built a working CLMM on Solana — from math to swaps.

We covered the tricky parts:

  • Tick ↔ price conversion
  • Range-based liquidity
  • Fee tracking
  • Tick crossing in swaps

This is not complete — it’s missing:

  • DecreaseLiquidity
  • CollectFees
  • ClosePosition

I encourage you to try and finish it.


Important Notes

This is for educational purposes only — not production-ready.

Critical Limitations:

  1. Floating-point math: We used f64 for clarity. Never do this in production. Use integer arithmetic with checked ops to avoid precision loss.
  2. Fixed tick array size: Solana accounts are limited. Our MAX_TICKS is tiny. → Production CLMMs (like Orca) use tick array accounts to scale. Check out Orca Whirlpools — it's gold.

Top comments (1)

Collapse
 
asi_vuala_dev_xd profile image
VUALA DEVELOPER ASI

Asiiiiii