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:
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
}
Note: All
sqrt_pricevalues are in Q64.64 fixed-point format.
For example, in a SOL/USDC pool, asqrt_priceof7_292_386_294_611_484_655represents:real_sqrt_price = 7_292_386_294_611_484_655 / 2⁶⁴ ≈ 0.39532105316 price = (0.39532105316)² ≈ 0.15627873507Since 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:
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)
}
3. Delta B (Token B Amount)
Similarly for Token B:
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)
}
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],
}
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
}
Why
liquidity_netis 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,
}
This tracks the user’s liquidity range, accrued fees, and checkpoints for fee calculation.
Core Instructions
The program supports four key instructions:
InitializePoolInitializePositionIncreaseLiquiditySwap
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];
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;
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)?;
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)?,
)
};
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;
}
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,
}
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;
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;
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;
}
Direction:
a_to_b = true→ selling Token A → price decreases → fees accrue in Token Aa_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
}
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,
})
}
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:
DecreaseLiquidityCollectFeesClosePosition
I encourage you to try and finish it.
Important Notes
This is for educational purposes only — not production-ready.
Critical Limitations:
-
Floating-point math: We used
f64for clarity. Never do this in production. Use integer arithmetic with checked ops to avoid precision loss. -
Fixed tick array size: Solana accounts are limited. Our
MAX_TICKSis tiny. → Production CLMMs (like Orca) use tick array accounts to scale. Check out Orca Whirlpools — it's gold.




Top comments (1)
Asiiiiii