메인 콘텐츠로 건너뛰기

Documentation Index

Fetch the complete documentation index at: https://docs.raydium.io/llms.txt

Use this file to discover all available pages before exploring further.

이 페이지는 AI 자동 번역입니다. 모든 내용은 영문판을 기준으로 합니다.영문판 보기 →
이 페이지는 실제 동작하는 페이지입니다. CLMM 프로그램에서 사용하는 공식, 고정소수점 규칙, 단계별 로직을 다룹니다. 집중 유동성 곡선 자체의 원리 — L = sqrt(x · y)가 중요한 이유 — 에 대해서는 algorithms/clmm-math를 참고하세요. 이 페이지는 해당 내용을 이미 읽었다고 가정합니다.

스콰트-가격 표현

CLMM은 가격을 sqrt_price_x64로 저장합니다. 이는 token1-per-token0 가격의 제곱근을 Q64.64 고정소수점 수로 나타낸 것입니다: sqrt_price_x64=p264\text{sqrt\_price\_x64} = \lfloor \sqrt{p} \cdot 2^{64} \rfloor 여기서 p = token1_amount / token0_amount입니다. p 대신 sqrt를 사용하면 스왑 수학이 선형화되어 (토큰 수량 변화가 Δsqrt_price에 대해 선형이 됨) 많은 틱을 거치는 스왑에서도 x64 고정소수점이 정밀도를 유지합니다. 틱 ↔ 스콰트-가격 변환은 비트-단위 로그 근사를 통해 사전에 계산됩니다: sqrt_price_x64(t)264(1.0001)t/2\text{sqrt\_price\_x64}(t) \approx 2^{64} \cdot (1.0001)^{t/2} 이는 tick_math::get_sqrt_price_at_tick의 조회 기반 지수 연산으로 구현됩니다.

유동성을 정준 단위로

범위 [sqrt_a, sqrt_b] 내 (sqrt_a < sqrt_b인) 유동성 L 포지션은 다음과 같이 토큰 수량으로 매핑됩니다. sqrt_c = sqrt_price_x64를 풀의 현재 가격이라 하면:
경우amount0amount1
sqrt_c <= sqrt_a (풀 가격이 범위 아래)L · (sqrt_b - sqrt_a) / (sqrt_a · sqrt_b)0
sqrt_a < sqrt_c < sqrt_b (범위 내)L · (sqrt_b - sqrt_c) / (sqrt_c · sqrt_b)L · (sqrt_c - sqrt_a)
sqrt_c >= sqrt_b (풀 가격이 범위 위)0L · (sqrt_b - sqrt_a)
세 가지 항등식은 집중 유동성이 범위 내에서 만족하는 불변식 x = L / sqrt_p, y = L · sqrt_p에서 나옵니다. 통합자는 보통 역을 원합니다: amount0 / amount1 입금이 주어졌을 때 범위에 맞는 최대 L을 계산합니다. SDK의 LiquidityMath.getLiquidityFromTokenAmounts가 이를 수행합니다. 범위 내 경우의 공식: L0=amount0sqrt_csqrt_bsqrt_bsqrt_c,L1=amount1sqrt_csqrt_a,L=min(L0,L1)L_0 = \text{amount0} \cdot \frac{\text{sqrt\_c} \cdot \text{sqrt\_b}}{\text{sqrt\_b} - \text{sqrt\_c}}, \qquad L_1 = \frac{\text{amount1}}{\text{sqrt\_c} - \text{sqrt\_a}}, \qquad L = \min(L_0, L_1) 어느 쪽이든 제약이 결정되면 실제로 소비되는 비율이 정해지고, 다른 쪽에는 남은 부분이 있을 수 있습니다.

단일 틱 스왑 스텝

스왑은 스텝으로 진행됩니다. 각 스텝은 (a) 현재 틱 범위 내의 모든 사용 가능한 입력을 소비하되 틱을 넘지 않거나, (b) 가격을 다음 초기화된 틱으로 정확히 이동시킵니다. 현재 상태 (sqrt_c, L)상향 스왑 (token0 입력, token1 출력, sqrt_price 증가)이 주어졌을 때, 다음 초기화된 틱까지의 거리를 sqrt_t라 하면, 이 미세 구간 내에서 입력과 가격의 관계는: Δamount0=L(1sqrt_c1sqrt_t)=L(sqrt_tsqrt_c)sqrt_csqrt_t\Delta\text{amount0} = L \cdot \left( \frac{1}{\text{sqrt\_c}} - \frac{1}{\text{sqrt\_t}} \right) = \frac{L \cdot (\text{sqrt\_t} - \text{sqrt\_c})}{\text{sqrt\_c} \cdot \text{sqrt\_t}} 그리고 Δamount1=L(sqrt_tsqrt_c)\Delta\text{amount1} = L \cdot (\text{sqrt\_t} - \text{sqrt\_c}) 프로그램은 다음 두 가지 중 하나를 수행합니다:
  • 전체 입력이 들어맞나요? 남은 입력 (수수료 차감 후)이 sqrt_t에 도달하는 Δamount0보다 적으면 새로운 sqrt_c'를 정확히 풀어냅니다: sqrt_c=Lsqrt_cL+Δinputsqrt_c\text{sqrt\_c}' = \frac{L \cdot \text{sqrt\_c}}{L + \Delta\text{input} \cdot \text{sqrt\_c}} (정확한 입력 token0 → token1 스왑의 경우). 스왑은 틱을 넘지 않고 이 스텝에서 완료됩니다.
  • 입력이 Δamount0을 초과하나요? sqrt_c' = sqrt_t로 설정하고, 틱을 넘으면서 (liquidity_net 적용), 남은 입력을 Δamount0만큼 감소시키고, 출력을 Δamount1만큼 증가시킨 후 반복합니다.
반대 방향 (token1 → token0, 가격 하향)의 경우 공식은 sqrt_csqrt_t가 바뀌고 반전이 다른 슬롯에 있습니다. 전체 Rust 구현은 raydium-clmm/programs/amm/src/libraries/swap_math.rs에 있습니다. 그 로직은 Uniswap v3의 SwapMath.computeSwapStep과 일대일로 일치합니다.

각 스텝의 수수료

거래 수수료는 각 스텝의 입력 수량에서 차감됩니다. CPMM과 동일한 규칙입니다:
step_fee_amount  = ceil(step_input * trade_fee_rate / 1_000_000)
step_net_input   = step_input - step_fee_amount
protocol_portion = floor(step_fee_amount * protocol_fee_rate / 1_000_000)
fund_portion     = floor(step_fee_amount * fund_fee_rate     / 1_000_000)
lp_portion       = step_fee_amount - protocol_portion - fund_portion
LP 부분은 글로벌 수수료 성장 누적기를 업데이트하여 현재 범위 내 유동성에 분산됩니다: fee_growth_globalin+=lp_portion264L\text{fee\_growth\_global}_{\text{in}} \mathrel{+}= \text{lp\_portion} \cdot \frac{2^{64}}{L} 즉, 유동성 단위당 수수료로 표현되며, Q64.64입니다. 따라서 이 스왑 동안 범위 내에 있던 크기 L_i의 포지션은 나중에 L_i · Δfee_growth_global / 2^{64} 토큰을 받을 것으로 읽습니다. 프로토콜 및 펀드 부분은 각각 PoolState.protocol_fees_token_{0,1}PoolState.fund_fees_token_{0,1}에 누적되며, CPMM과 동일합니다. 이들은 CollectProtocolFee / CollectFundFee로 정산됩니다.

범위 내외 수수료 성장

CLMM 수수료 회계의 까다로운 부분: 포지션은 풀의 가격이 범위 내에 있는 동안에만 수수료를 얻습니다. 풀은 글로벌 누적 수수료를 추적하지만, 포지션은 특정 범위 내의 누적 수수료를 알아야 합니다. 해결책은 틱 기반 누적기입니다. 각 틱은 다음을 저장합니다:
fee_growth_outside_0_x64
fee_growth_outside_1_x64
틱 초기화 시점에:
  • 풀의 가격이 이 틱 위에 있으면 (tick_current >= this_tick), fee_growth_outside = fee_growth_global입니다. (지금까지 벌어진 모든 것은 현재 가격 상대로 이 틱 “아래” 즉 “바깥”입니다.)
  • 그렇지 않으면 fee_growth_outside = 0입니다.
가격이 틱을 넘을 때 프로그램은 그 틱의 fee_growth_outside뒤집습니다: fee_growth_outsidefee_growth_globalfee_growth_outside\text{fee\_growth\_outside} \gets \text{fee\_growth\_global} - \text{fee\_growth\_outside} 이것이 유지하는 불변식: 모든 틱 t에 대해 fee_growth_outside(t)tick_currentt의 반대쪽에 있던 동안 발생한 수수료와 같습니다. 범위 [tick_lower, tick_upper] 내의 수수료 성장은 다음과 같이 유도됩니다:
if tick_current >= tick_upper:
    fee_growth_below = fee_growth_outside(tick_lower)
    fee_growth_above = fee_growth_global - fee_growth_outside(tick_upper)
elif tick_current >= tick_lower:
    fee_growth_below = fee_growth_outside(tick_lower)
    fee_growth_above = fee_growth_outside(tick_upper)
else:
    fee_growth_below = fee_growth_global - fee_growth_outside(tick_lower)
    fee_growth_above = fee_growth_outside(tick_upper)

fee_growth_inside = fee_growth_global - fee_growth_below - fee_growth_above
이것은 Uniswap-v3 수수료 성장 공식으로, 변하지 않았습니다.

포지션이 저장하는 것과 읽는 것

PersonalPositionStatefee_growth_inside_0_last_x64fee_growth_inside_1_last_x64를 저장합니다: 포지션을 마지막으로 건드렸을 때의 fee_growth_inside 값입니다. 그 후 건드릴 때마다 (증가, 감소, 수집), 프로그램은:
  1. 위 공식을 사용하여 현재 fee_growth_inside_{0,1}_x64를 계산합니다.
  2. Δ = fee_growth_inside_now − fee_growth_inside_last를 계산합니다 (u128에 대한 모듈로 뺄셈).
  3. Δ × position.liquidity / 2^{64}tokens_fees_owed_{0,1}에 더합니다.
  4. fee_growth_inside_last를 새 값으로 업데이트합니다.
토큰이 실제로 금고를 떠나는 것은 CollectFees / DecreaseLiquidity에서만, tokens_fees_owed를 상대로 합니다.

보상

풀의 최대 3개 보상 스트림은 각각의 reward_growth_global_x64 누적기에서 동일한 범위-내 기계를 사용합니다. 발행 시간에: reward_growth_global+=emission_per_secondΔt264L\text{reward\_growth\_global} \mathrel{+}= \text{emission\_per\_second} \cdot \Delta t \cdot \frac{2^{64}}{L} — 발행이 활동 유동성에 반비례하므로, 더 높은 밀도의 풀은 각 포지션당 초당 비례적으로 적게 지급하지만, 더 많은 포지션 총수에 걸쳐 지급합니다. 포지션당 보상 미지급은 reward_owed=(reward_growth_insidenowreward_growth_insidelast)L/264\text{reward\_owed} = (\text{reward\_growth\_inside}_{\text{now}} - \text{reward\_growth\_inside}_{\text{last}}) \cdot L / 2^{64} 이며 CollectReward로 청구합니다. products/clmm/fees를 참고하세요.

작업 예: 정확한 입력 스왑

다음을 가정합니다:
  • tick_spacing = 60
  • sqrt_price_x64 = 1 × 2^{64} — 가격 = 1.0, 따라서 tick_current = 0.
  • 활동 유동성 L = 1_000_000 × 2^{64}.
  • 위의 다음 초기화된 틱: t = 60 (sqrt_price_b ≈ 1.003004 × 2^{64}).
  • 거래 수수료율: 500 (0.05%).
사용자: 정확한 입력 1,000 token0로 SwapBaseInput. 스텝 1 — 수수료:
trade_fee       = ceil(1000 * 500 / 1_000_000)  = 1
step_net_input  = 999
스텝 2 — 999가 현재 틱 범위 내에 들어가나요?
다음 틱까지의 거리 (amount0):
  L · (sqrt_t - sqrt_c) / (sqrt_c * sqrt_t)
  ≈ 1_000_000 · (1.003004 − 1) / (1 · 1.003004)
  ≈ 2995.5 token0
999 < 2995.5이므로 전체 입력이 틱을 넘지 않고 들어갑니다. 스텝 3 — 새로운 가격:
sqrt_c' = L · sqrt_c / (L + Δin · sqrt_c)
        = 1_000_000 · 1 / (1_000_000 + 999 · 1)
        ≈ 0.999001
즉, sqrt_c'sqrt_c 약간 아래입니다. 위의 공식은 token1 → token0 스왑용입니다. 여기 예는 token0 → token1이므로 가격을 위로 올립니다 — token0 입력에 대한 해당 형식을 사용합니다:
sqrt_c' = sqrt_c + Δin / L
        = 1 + 999 / 1_000_000
        = 1.000999
(이는 token0 → token1 스왑의 예상 방향과 일치합니다: 가격과 함께 sqrt_c가 올라갑니다.) 스텝 4 — 출력 수량:
Δout token1 = L · (sqrt_c' − sqrt_c)
            = 1_000_000 · 0.000999
            = 999.00
반올림을 계산한 후, 사용자는 ≈ 999 token1을 받습니다. 수수료 (1 token0)는 trade_fee_rate × protocol_fee_rate / 1e6 (펀드도 유사함)로 LP, 프로토콜, 펀드 간에 분할됩니다; LP 부분은 fee_growth_global_0_x64로 흐릅니다.

스왑 중 리미트 오더 매칭

스왑 스텝이 열린 리미트 오더를 보유한 틱을 넘을 때, 그 오더들은 스왑 입력을 먼저 소비합니다 (LP 곡선보다 전), 틱의 정확한 가격으로. 매칭은 order_phase 코호트별로 틱 내에서 FIFO입니다.

TickState의 코호트별 상태

order_phase                  : u64    단조 코호트 id
orders_amount                : u64    현재 (가장 최신) 코호트의 입력-토큰 합계
part_filled_orders_remaining : u64    스왑이 현재 채우고 있는 코호트의 남은 입력
unfilled_ratio_x64           : u128   부분 채워진 코호트의 Q64.64 채움 비율
2-코호트 배치는 새 오더가 스왑 중에 틱에서 열려 있을 수 있기 때문에 존재합니다 (이전 코호트가 여전히 채워지고 있는 동안). 새로 열린 오더는 orders_amount에 참여하고 다음 order_phase를 상속받습니다; 이전 코호트가 완전히 소비될 때까지 채울 수 없습니다.

매칭 스텝

스왑 중 각 틱 교차 시 발생하는 매칭의 의사-코드:
fn match_limit_orders_at_tick(tick, swap_input_remaining, sqrt_p):
    # 1. 부분 채워진 코호트를 먼저 채우려고 시도합니다.
    if tick.part_filled_orders_remaining > 0:
        consume = min(tick.part_filled_orders_remaining, swap_input_remaining)
        # 그 코호트의 미충전 비율을 업데이트합니다.
        tick.unfilled_ratio_x64 *= (1 - consume / tick.part_filled_orders_remaining)
        tick.part_filled_orders_remaining -= consume
        swap_input_remaining -= consume
        if tick.part_filled_orders_remaining == 0:
            tick.unfilled_ratio_x64 = 0
        if swap_input_remaining == 0: return

    # 2. 활동 코호트를 승격합니다.
    if tick.orders_amount > 0:
        tick.part_filled_orders_remaining = tick.orders_amount
        tick.orders_amount = 0
        tick.order_phase += 1
        tick.unfilled_ratio_x64 = ONE_X64
        # 새로 승격된 코호트로 재귀합니다.
        return match_limit_orders_at_tick(tick, swap_input_remaining, sqrt_p)

    return  # 틱에는 더 이상 리미트 오더가 없습니다
리미트-오더 소유자에게 가는 출력 토큰은 스왑당 전송되지 않습니다. 오더 소유자가 SettleLimitOrder (또는 DecreaseLimitOrder)를 호출할 때까지 풀의 출력 금고에 가상으로 앉아있습니다. 풀은 단순히 unfilled_ratio_x64를 통해 코호트가 얼마나 채워졌는지 추적합니다. 각 LimitOrderState는 열린 시간에 자신의 (order_phase, unfilled_ratio_x64) 스냅샷을 저장하므로, 정산은:
filled_amount  = total_amount × (1 − tick_now.unfilled_ratio_x64 / order.unfilled_ratio_x64)
                if tick_now.order_phase > order.order_phase
                else 0
output_amount  = price_at(tick_index) × filled_amount   # 방향에 맞게 조정
이 O(1) 정산이 바로 코호트 설계의 핵심입니다 — 틱은 오더당 가스 없이 임의로 많은 오더를 채울 수 있습니다.

LP 곡선과의 상호작용

스왑 스텝에서 리미트-오더 매칭은 틱 에서 일어나고 (영 Δsqrt_price); LP 곡선 소비는 틱 사이에서 일어납니다. 따라서 순서는:
  1. t_cross 교차 (LP liquidity_net 변화를 먼저 적용합니다. Uniswap-V3이 이렇게 하기 때문).
  2. t_cross에 앉아있는 리미트 오더를 채웁니다.
  3. 다음 초기화된 틱이나 swap_input 소진까지 LP 곡선을 따라 계속합니다.
리미트 오더는 따라서 거래자에게 정확히 오더의 틱 가격에서 효과적인 유동성을 제공합니다 (가격 개선 효과). 대신 LP는 스왑 거래량의 그 부분에 대해 수수료를 얻지 못합니다 — 리미트-오더 부분의 거래는 스왑퍼에게는 수수료 없습니다. 리미트-오더 배치자는 메이커로 행동하기 때문입니다. 동적 수수료 할증 (활성화된 경우)은 여전히 동일 스왑의 LP 부분에 적용됩니다.

동적 수수료 파생

PoolState.dynamic_fee_info는 변동성 상태를 전달합니다. 각 스왑 스텝은 스텝당 수수료율을 다음과 같이 계산합니다: fee_ratetotal=trade_fee_rateconfig+dynamic_fee_control(vol_acctick_spacing)2DctrlSvol2동적 할증\text{fee\_rate}_{\text{total}} = \text{trade\_fee\_rate}_{\text{config}} + \underbrace{\frac{\text{dynamic\_fee\_control} \cdot (\text{vol\_acc} \cdot \text{tick\_spacing})^2} {D_{\text{ctrl}} \cdot S_{\text{vol}}^2}}_{\text{동적 할증}} 여기서:
  • Dctrl=100,000D_{\text{ctrl}} = 100{,}000DYNAMIC_FEE_CONTROL_DENOMINATOR
  • Svol=10,000S_{\text{vol}} = 10{,}000VOLATILITY_ACCUMULATOR_SCALE
  • vol_acc는 아래 업데이트 규칙 후의 스텝당 누적기입니다
  • tick_spacingPoolState.tick_spacing에서 나옵니다
결과는 100,000/106=10%100{,}000 / 10^6 = 10\%에서 크램핑됩니다.

누적기 업데이트

매 스왑마다 두 규칙을 이 순서로 적용합니다: 감소. 참조 바닥은 마지막 업데이트 이후 시간에 따라 감소합니다: vol_ref={0if Δt>decay_periodvol_accprevreduction_factor10,000if filter_period<Δtdecay_periodvol_refprevif Δtfilter_period\text{vol\_ref} = \begin{cases} 0 & \text{if } \Delta t > \text{decay\_period} \\ \text{vol\_acc}_{\text{prev}} \cdot \dfrac{\text{reduction\_factor}}{10{,}000} & \text{if } \text{filter\_period} < \Delta t \le \text{decay\_period} \\ \text{vol\_ref}_{\text{prev}} & \text{if } \Delta t \le \text{filter\_period} \end{cases} 누적. 새 누적기는 참조에 이전 참조 인덱스 이후 순회한 틱-거리를 더한 것입니다: vol_acc=min(vol_ref+treftnowSvol,max_vol_acc)\text{vol\_acc} = \min\left( \text{vol\_ref} + \left| t_{\text{ref}} - t_{\text{now}} \right| \cdot S_{\text{vol}}, \text{max\_vol\_acc} \right) tick_spacing_index_reference (treft_{\text{ref}})는 원시 틱이 아니라 틱-스펙 단위입니다: tref=tick_current/tick_spacingt_{\text{ref}} = \lfloor \text{tick\_current} / \text{tick\_spacing} \rfloor.

틱 거리에서 포물선인 이유

누적기를 제곱하면 수수료는 가격이 참조점에서 걸어간 거리의 제곱으로 올라갑니다. 경험상 이는 무작위 이동 압력 하에서 가격의 분산 스케일링과 일치합니다: 2× 틱 편차는 4× 암시된 변동성을 의미하므로 4× 할증을 부과합니다. dynamic_fee_control 파라미터는 절대 수준을 보정합니다. filter_period 윈도우는 작은 소수점 이하 진동 (예: MEV 봇의 샌드위칭)이 누적기를 부풀리는 것을 방지합니다. decay_period 윈도우는 과거의 단일 스파이크가 시장이 진정된 후 무한정 수수료를 부과하는 것을 방지합니다.

수치 견고성

  • 모든 중간 곱은 u128 또는 u256 형태 산술을 통과합니다. CLMM은 U128Sqrt 헬퍼와 FullMath::mulDiv 패턴을 Uniswap v3에서 직접 이식합니다.
  • 나눗셈 반올림은 불변식 k' ≥ k를 로컬에서 시행하기 위해 스텝별로 선택됩니다. SwapBaseInput은 출력을 내림합니다; SwapBaseOutput은 입력을 올림합니다.
  • PoolState.liquidity를 0으로 떨어뜨리는 틱 교차는 허용됩니다 (가격은 “유동성 구멍”을 통과할 수 있음) 하지만 스왑은 단순히 다음 초기화된 틱으로 진행하며 입력을 소비하지 않고 수수료를 부과하지 않습니다.
  • 오버플로우 가드: sqrt_price_x64는 포함 범위 [MIN_SQRT_PRICE_X64, MAX_SQRT_PRICE_X64] 내에 유지됩니다. [MIN_TICK, MAX_TICK]에 해당합니다. 경계 중 하나를 넘을 스왑은 SqrtPriceLimitOverflow로 되돌립니다.

다음에 갈 곳

출처: