This page describes the layout and role of each account. Seeds are canonical and listed in reference/program-addresses. A CLMM pool is more account-heavy than a CPMM pool because liquidity is stored sparsely across the tick range; understanding that sparsity is the bulk of this page.
Account inventory
A live CLMM pool is described by the following account families. All are owned by the CLMM program except the two mints and their vaults.
| Account | Purpose | Count per pool |
|---|
AmmConfig | Fee tier: trade-fee rate, protocol share, fund share, default tick-spacing. Shared across all pools in this tier. | 1 (shared) |
PoolState | Current sqrt_price_x64, current tick, total liquidity, fee growth globals, reward info, observation pointer. | 1 |
TickArrayState | A block of TICK_ARRAY_SIZE adjacent ticks. Only initialized on demand. | 0 ≤ N ≤ range |
TickArrayBitmapExtension | Overflow bitmap tracking which tick arrays exist past the inline bitmap in PoolState. | 0 or 1 |
PersonalPositionState | One per LP position. Stores the range, liquidity, and last-seen fee/reward growth. Authority = NFT owner. | 1 per position |
| Position NFT mint | Mint with supply 1, associated with PersonalPositionState. Transferring transfers the position. | 1 per position |
ObservationState | Ring buffer of price observations for the TWAP. | 1 |
token_0_vault, token_1_vault | Token accounts holding the pool’s balances. Owned by pool authority. | 2 |
DynamicFeeConfig | Reusable parameter set for the dynamic-fee mechanism. Pools created via create_customizable_pool can opt in. Admin-managed. | shared (per index) |
LimitOrderState | One per open limit order. Records owner, tick, side, total amount, settled-output snapshot. | 1 per order |
LimitOrderNonce | Per-(wallet, nonce_index) counter that derives unique order PDAs. | 1 per (wallet, index) |
PoolState
The pool’s live state, read on every swap and every position change.
// programs/amm/src/states/pool.rs
pub struct PoolState {
pub bump: [u8; 1],
pub amm_config: Pubkey, // fee tier binding
pub owner: Pubkey, // admin (multisig)
pub token_mint_0: Pubkey,
pub token_mint_1: Pubkey,
pub token_vault_0: Pubkey,
pub token_vault_1: Pubkey,
pub observation_key: Pubkey,
pub mint_decimals_0: u8,
pub mint_decimals_1: u8,
pub tick_spacing: u16, // inherited from amm_config at init
pub liquidity: u128, // total active (in-range) liquidity
pub sqrt_price_x64: u128, // Q64.64 of sqrt(price)
pub tick_current: i32, // current tick index
pub padding3: u16,
pub padding4: u16,
// Global fee growth per unit of liquidity, Q64.64.
pub fee_growth_global_0_x64: u128,
pub fee_growth_global_1_x64: u128,
// Accrued-but-not-swept protocol fees (per mint).
pub protocol_fees_token_0: u64,
pub protocol_fees_token_1: u64,
// Reserved padding for future upgrades.
pub padding5: [u128; 4],
// Status bitmask. Bits 0-5: open-position, decrease-liquidity,
// collect-fee, collect-reward, swap, limit-order. A set bit disables
// the corresponding operation.
pub status: u8,
// Fee-collection mode (CollectFeeOn).
// 0 = FromInput (deduct fee from the swap input — Uniswap-V3 default)
// 1 = Token0Only (always deduct fee from token0 vault)
// 2 = Token1Only (always deduct fee from token1 vault)
pub fee_on: u8,
pub padding: [u8; 6],
// Live reward streams (up to REWARD_NUM = 3).
pub reward_infos: [RewardInfo; 3],
// Inline bitmap tracking initialized tick-arrays in the primary range.
pub tick_array_bitmap: [u64; 16],
// Reserved padding for future upgrades.
pub padding6: [u64; 4],
pub fund_fees_token_0: u64,
pub fund_fees_token_1: u64,
pub open_time: u64, // currently disabled by the program
pub recent_epoch: u64,
// Per-pool dynamic-fee state. Zero-valued unless the pool was
// created with `enable_dynamic_fee = true` via create_customizable_pool.
pub dynamic_fee_info: DynamicFeeInfo,
// Reserved for future upgrades.
pub padding1: [u64; 14],
pub padding2: [u64; 32],
}
Fields you will actually touch:
sqrt_price_x64 and tick_current are the pool’s price state. They are updated together on every swap. tick_current is the floor of log_{1.0001}(price).
liquidity is the active liquidity — the sum of L values for all positions whose range contains tick_current. It changes every time a swap crosses a tick and every time a position is opened/closed/resized.
fee_growth_global_{0,1}_x64 are the cumulative fees earned per unit of liquidity across the entire pool history. Positions read this to compute what’s owed to them.
tick_spacing is locked to the AmmConfig at initialization and never changes. It determines which tick indices are even allowed to be position endpoints.
tick_array_bitmap is an inline bitmap covering the commonly used tick range around spot price. For pools whose positions reach far out, overflow tracking lives in the separate TickArrayBitmapExtension.
fee_on is fixed at pool creation. 0 (FromInput) reproduces classic Uniswap-V3 behavior. 1 and 2 route the swap fee to a single side of the book — see products/clmm/fees for trade-offs.
dynamic_fee_info carries volatility state for the dynamic-fee surcharge. When enabled, every swap recomputes a dynamic_fee_component on top of AmmConfig.trade_fee_rate. Layout is documented under DynamicFeeInfo below; pools without dynamic fee leave the entire struct zero.
AmmConfig
pub struct AmmConfig {
pub bump: u8,
pub index: u16, // uses "amm_config"+u16 seed
pub owner: Pubkey, // admin
pub protocol_fee_rate: u32, // fraction of trade fee to protocol, denom 1e6
pub trade_fee_rate: u32, // trade fee in 1e6ths of volume
pub tick_spacing: u16, // default spacing for pools using this config
pub fund_fee_rate: u32, // fraction of trade fee to fund, denom 1e6
pub padding_u32: u32,
pub fund_owner: Pubkey,
pub padding: [u64; 3],
}
A typical published set of CLMM fee tiers (confirm against GET https://api-v3.raydium.io/main/clmm-config):
| Index | trade_fee_rate | Tick spacing | Typical use |
|---|
| 0 | 100 (0.01%) | 1 | Stable pairs, USDC/USDT |
| 1 | 500 (0.05%) | 10 | Correlated blue-chips |
| 2 | 2_500 (0.25%) | 60 | Standard pairs |
| 3 | 10_000 (1.00%) | 120 | Volatile or long-tail |
protocol_fee_rate and fund_fee_rate are fractions of the trade fee; same convention as CPMM. See products/clmm/fees.
TickArrayState
CLMM does not store a single record per tick. That would be billions of accounts. Instead it groups TICK_ARRAY_SIZE adjacent initialized-or-not ticks (typically 60 or 88 depending on program version) into a TickArrayState that is lazily created on first use.
pub const TICK_ARRAY_SIZE: usize = 60;
pub const TICK_ARRAY_SIZE_USIZE: usize = 60;
pub struct TickArrayState {
pub pool_id: Pubkey,
pub start_tick_index: i32, // lowest tick in this array
pub ticks: [TickState; TICK_ARRAY_SIZE], // 60 entries
pub initialized_tick_count: u8,
pub recent_epoch: u64,
pub padding: [u8; 107],
}
pub struct TickState {
pub tick: i32,
pub liquidity_net: i128, // ΔL when crossing this tick upward
pub liquidity_gross: u128, // total L referencing this tick
pub fee_growth_outside_0_x64: u128, // see math.mdx
pub fee_growth_outside_1_x64: u128,
pub reward_growths_outside_x64: [u128; 3],
// Limit-order bookkeeping. All zero for ticks that have never carried
// a limit order. See products/clmm/math for the matching algorithm.
pub order_phase: u64, // monotonic FIFO cohort id
pub orders_amount: u64, // unfilled tokens in current cohort
pub part_filled_orders_remaining: u64, // remaining tokens of partially-filled cohort
pub unfilled_ratio_x64: u128, // Q64.64; starts at 1.0 and shrinks as fills occur
pub padding: [u32; 3],
}
The four limit-order fields are zero on any tick that has never been used for a limit order. When orders are opened on a tick, the program tracks them as a sequence of cohorts:
order_phase is the cohort id. It increments every time a cohort transitions from “all unfilled” to “partially filled.”
orders_amount is the input-token total of the current (newest) cohort.
part_filled_orders_remaining tracks the previous cohort that is currently being filled by ongoing swaps.
unfilled_ratio_x64 is a Q64.64 multiplier carried on the cohort: when a swap fills X% of the cohort, the ratio is multiplied by (1 − X). Each open order stores its own (order_phase, unfilled_ratio_x64) snapshot at open time, so settle math reduces to comparing snapshots.
Rules:
- A position endpoint tick t must satisfy
t % tick_spacing == 0. The program rejects off-spacing positions.
- The tick’s array is located at
floor(t / (TICK_ARRAY_SIZE * tick_spacing)) * (TICK_ARRAY_SIZE * tick_spacing).
- A tick array is initialized lazily: the first position or swap that touches an uninitialized array creates it, paying the rent.
- A tick array is never closed by the program. Once allocated it persists for the life of the pool, even after every tick inside it returns to
liquidity_gross == 0. Subsequent positions and swaps reuse the existing account at no extra rent. There is no ClosePosition-driven cleanup path for tick arrays.
TickArrayBitmapExtension
PoolState.tick_array_bitmap (inline) covers the “close to spot” range — ±1,024 tick arrays. Outside that range (for extreme tick values), the program maintains an extension account:
pub struct TickArrayBitmapExtension {
pub pool_id: Pubkey,
pub positive_tick_array_bitmap: [[u64; 8]; 14],
pub negative_tick_array_bitmap: [[u64; 8]; 14],
}
If your position’s range is “normal”, you never think about the extension account. Full-range positions (e.g., (MIN_TICK, MAX_TICK)) require it; the SDK resolves it for you.
Positions
A CLMM position is a bundle of three accounts plus a mint:
Position NFT mint
An SPL Token mint with supply 1. The mint’s address is a deterministic PDA; the position NFT in the owner’s wallet is just an ATA holding that single token. Transferring the NFT is how a position changes hands — the program keys authorization to the current holder of the NFT’s ATA balance, not to a Pubkey stored in state.
PersonalPositionState
One per open position. Keyed off the NFT mint.
pub struct PersonalPositionState {
pub bump: [u8; 1],
pub nft_mint: Pubkey, // this position's NFT mint
pub pool_id: Pubkey,
pub tick_lower_index: i32,
pub tick_upper_index: i32,
pub liquidity: u128, // this position's L
// Fee-growth snapshots at last time the position was touched.
pub fee_growth_inside_0_last_x64: u128,
pub fee_growth_inside_1_last_x64: u128,
pub token_fees_owed_0: u64, // accrued since last collect
pub token_fees_owed_1: u64,
pub reward_infos: [PositionRewardInfo; 3],
pub recent_epoch: u64,
pub padding: [u64; 7],
}
pub struct PositionRewardInfo {
pub growth_inside_last_x64: u128,
pub reward_amount_owed: u64,
}
ProtocolPositionState (deprecated)
Older CLMM releases stored aggregate per-(pool, tick_lower, tick_upper) bookkeeping in a ProtocolPositionState PDA. Newer releases no longer create or read this account. The slot still appears on the OpenPosition / IncreaseLiquidity / DecreaseLiquidity account lists as an UncheckedAccount for ABI compatibility, but the program does not write to it. Existing accounts on-chain are vestigial; the admin can call CloseProtocolPosition to reclaim rent for them.Aggregate range bookkeeping is now derived directly from the two endpoint ticks (liquidity_gross, liquidity_net, and the per-tick fee_growth_outside_* / reward_growths_outside_x64) in TickArrayState. The fee-growth-inside formula fee_growth_inside = global − outside_lower − outside_upper continues to work without an aggregate position account.
Observation
pub const OBSERVATION_NUM: usize = 100;
pub struct Observation {
pub block_timestamp: u32,
pub tick_cumulative: i64, // Σ tick_current × Δt
pub padding: [u64; 4],
}
pub struct ObservationState {
pub initialized: bool,
pub recent_epoch: u64,
pub observation_index: u16,
pub pool_id: Pubkey,
pub observations: [Observation; OBSERVATION_NUM], // 100 entries
pub padding: [u64; 4],
}
CLMM’s observation buffer stores a cumulative tick, not a cumulative price. External consumers compute the geometric-mean price over an interval from (tick_cumulative[t1] − tick_cumulative[t0]) / (t1 − t0) and then price = 1.0001 ** tick. See algorithms/clmm-math.
DynamicFeeConfig and DynamicFeeInfo
Dynamic fee parameters live in two places. The reusable template — DynamicFeeConfig — is admin-managed and shared across pools that opt in. The per-pool runtime state — DynamicFeeInfo — is embedded in PoolState and updated by every swap.
DynamicFeeConfig
// programs/amm/src/states/pool_fee.rs
pub struct DynamicFeeConfig {
pub index: u16, // identifier; PDA seed component
pub filter_period: u16, // seconds — within this window the volatility reference is held
pub decay_period: u16, // seconds — beyond this window the reference fully decays
pub reduction_factor: u16, // fixed-point in [1, 10_000); applied at decay
pub dynamic_fee_control: u32, // fixed-point in (0, 100_000); fee-rate gain
pub max_volatility_accumulator: u32, // ceiling on the volatility accumulator
pub padding: [u64; 8],
}
PDA seed: ["dynamic_fee_config", index.to_be_bytes()]. Created via create_dynamic_fee_config (admin-gated) and modified via update_dynamic_fee_config. A pool created with enable_dynamic_fee = true snapshots the config’s five calibration parameters (filter_period, decay_period, reduction_factor, dynamic_fee_control, max_volatility_accumulator) into its own DynamicFeeInfo at creation time; later edits to the DynamicFeeConfig do not retroactively affect existing pools.
DynamicFeeInfo (embedded in PoolState)
pub struct DynamicFeeInfo {
pub filter_period: u16,
pub decay_period: u16,
pub reduction_factor: u16,
pub dynamic_fee_control: u32,
pub max_volatility_accumulator: u32,
pub tick_spacing_index_reference: i32, // tick-spacing-units; reference for next swap
pub volatility_reference: u32, // running floor for the accumulator
pub volatility_accumulator: u32, // current cumulative volatility (capped)
pub last_update_timestamp: u64,
pub padding: [u8; 46],
}
The bottom four fields are state; the top five are calibration copied from DynamicFeeConfig. The fee math and the decay rules are documented under products/clmm/math and products/clmm/fees.
Constants used by the formula:
| Constant | Value | Meaning |
|---|
VOLATILITY_ACCUMULATOR_SCALE | 10_000 | Granularity of the volatility accumulator |
REDUCTION_FACTOR_DENOMINATOR | 10_000 | Denominator for reduction_factor |
DYNAMIC_FEE_CONTROL_DENOMINATOR | 100_000 | Denominator for dynamic_fee_control |
MAX_FEE_RATE_NUMERATOR | 100_000 | Hard cap of 10% on the resulting fee rate |
LimitOrderState
One account per open limit order.
// programs/amm/src/states/limit_order.rs
pub struct LimitOrderState {
pub pool_id: Pubkey,
pub owner: Pubkey,
pub tick_index: i32,
pub zero_for_one: bool, // direction: true sells token0 for token1
pub order_phase: u64, // snapshot of TickState.order_phase at open time
pub total_amount: u64, // input-token amount placed
pub filled_amount: u64, // informational; computed precisely on settle
pub settle_base: u64, // unfilled remainder at last settle/decrease
pub settled_output: u64, // cumulative output-token paid to owner
pub open_time: u64,
pub unfilled_ratio_x64: u128, // Q64.64 snapshot of TickState.unfilled_ratio_x64 at open
pub padding: [u64; 4],
}
Lifecycle:
- Open — user calls
open_limit_order, deposits total_amount of the input token, the order is bound to a TickState cohort.
- (optional) Increase / Decrease —
increase_limit_order adds to total_amount; decrease_limit_order returns unfilled tokens (and any settled output up to that point).
- Settle — when the cohort is fully or partially filled, the owner or the operational keeper calls
settle_limit_order to push output tokens to the owner’s ATA.
- Close — once
unfilled_amount == 0, the account is closeable. Rent always returns to owner.
PDA seed: [owner.as_ref(), limit_order_nonce.key().as_ref(), limit_order_nonce.order_nonce.to_be_bytes().as_ref()]. The order PDA is therefore unique per (owner, nonce_index, order_nonce).
LimitOrderNonce
Per-(wallet, nonce_index) counter that lets a single user run multiple parallel pipelines of limit orders without colliding on PDAs.
pub struct LimitOrderNonce {
pub user_wallet: Pubkey,
pub nonce_index: u8, // user-chosen, 0..255
pub order_nonce: u64, // monotonic, incremented every time a new order is opened
pub padding: [u64; 4],
}
PDA seed: [user_wallet.as_ref(), &[nonce_index]]. Most clients use nonce_index = 0 and let order_nonce carry the cardinality.
Deriving the key accounts
import { PublicKey } from "@solana/web3.js";
const CLMM_PROGRAM_ID = new PublicKey(
"CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK"
); // see reference/program-addresses
function i32ToBytes(n: number): Buffer {
const b = Buffer.alloc(4);
b.writeInt32BE(n);
return b;
}
export function deriveClmmAccounts(
ammConfig: PublicKey,
token0Mint: PublicKey, // must already be sorted
token1Mint: PublicKey,
) {
const [poolState] = PublicKey.findProgramAddressSync(
[Buffer.from("pool"), ammConfig.toBuffer(),
token0Mint.toBuffer(), token1Mint.toBuffer()],
CLMM_PROGRAM_ID,
);
const [observation] = PublicKey.findProgramAddressSync(
[Buffer.from("observation"), poolState.toBuffer()],
CLMM_PROGRAM_ID,
);
const [tickArrayBitmapExtension] = PublicKey.findProgramAddressSync(
[Buffer.from("pool_tick_array_bitmap_extension"), poolState.toBuffer()],
CLMM_PROGRAM_ID,
);
return { poolState, observation, tickArrayBitmapExtension };
}
export function deriveTickArray(
pool: PublicKey,
startTickIndex: number,
) {
const [tickArray] = PublicKey.findProgramAddressSync(
[Buffer.from("tick_array"), pool.toBuffer(), i32ToBytes(startTickIndex)],
CLMM_PROGRAM_ID,
);
return tickArray;
}
export function deriveDynamicFeeConfig(index: number) {
const idx = Buffer.alloc(2);
idx.writeUInt16BE(index);
const [pda] = PublicKey.findProgramAddressSync(
[Buffer.from("dynamic_fee_config"), idx],
CLMM_PROGRAM_ID,
);
return pda;
}
export function deriveLimitOrderNonce(
wallet: PublicKey,
nonceIndex: number,
) {
const [pda] = PublicKey.findProgramAddressSync(
[wallet.toBuffer(), Buffer.from([nonceIndex & 0xff])],
CLMM_PROGRAM_ID,
);
return pda;
}
export function deriveLimitOrder(
wallet: PublicKey,
nonceAccount: PublicKey,
orderNonce: bigint,
) {
const nonceBytes = Buffer.alloc(8);
nonceBytes.writeBigUInt64BE(orderNonce);
const [pda] = PublicKey.findProgramAddressSync(
[wallet.toBuffer(), nonceAccount.toBuffer(), nonceBytes],
CLMM_PROGRAM_ID,
);
return pda;
}
export function derivePersonalPosition(nftMint: PublicKey) {
const [personalPosition] = PublicKey.findProgramAddressSync(
[Buffer.from("position"), nftMint.toBuffer()],
CLMM_PROGRAM_ID,
);
return personalPosition;
}
The exact seed strings should always be double-checked against the on-chain IDL and reference/program-addresses.
Lifecycle quick reference
| Event | Accounts created | Accounts destroyed |
|---|
CreatePool | poolState, observation, token_0_vault, token_1_vault | — |
OpenPosition[WithToken22Nft] | NFT mint + ATA, personalPosition, possibly new tickArrayState(s), tickArrayBitmapExtension if not yet existing | — |
IncreaseLiquidity | Possibly new tickArrayState(s) | — |
DecreaseLiquidity | — | Possibly clears tick entries (but the tickArrayState itself is not closed) |
ClosePosition | — | NFT mint, personalPosition |
SwapV2 | Possibly new tickArrayState | — |
OpenLimitOrder | limitOrderState, possibly limitOrderNonce (init-if-needed), possibly new tickArrayState | — |
IncreaseLimitOrder | — | — |
DecreaseLimitOrder | — | Closes limitOrderState if order is fully consumed |
SettleLimitOrder | — | — |
CloseLimitOrder | — | limitOrderState (rent → owner) |
CreateDynamicFeeConfig | dynamicFeeConfig | — |
CreateCustomizablePool | poolState, observation, vaults — same as CreatePool. Snapshots dynamicFeeConfig if enable_dynamic_fee = true. | — |
CollectRewards | — | — |
UpdateRewardInfos | — | — |
CloseProtocolPosition (admin) | — | Vestigial protocolPositionState (rent → admin) |
TickArrayState accounts are never closed by the program — they persist for the life of the pool. Once a tick array has been initialised it remains on-chain even when every tick inside it returns to liquidity_gross == 0. Re-using an existing tick array is free; only the first position to touch a never-initialised array pays its rent.
What to read where
Sources: