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

Sqrt-price 표현

CLMM은 가격을 sqrt_price_x64로 저장합니다. 이는 token1-per-token0 가격의 제곱근을 Q64.64 고정소수점 수로 나타낸 것입니다: sqrt_price_x64=⌊p⋅264⌋\text{sqrt\_price\_x64} = \lfloor \sqrt{p} \cdot 2^{64} \rfloor 여기서 p = token1_amount / token0_amount입니다. p 대신 sqrt를 사용하면 스왑 수학이 선형화되고 (토큰 수량 변화가 Δsqrt_price에 대해 선형), x64 고정소수점은 많은 틱을 거치는 스왑에서도 정밀도를 유지합니다. 틱 ↔ sqrt-price 변환은 bit-by-bit 로그 근사를 통해 미리 계산됩니다: 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를 풀의 현재 가격이라 하겠습니다. 세 항등식 모두 집중된 유동성이 범위 내에서 만족하는 불변식 x = L / sqrt_p, y = L · sqrt_p에서 나옵니다. 통합자는 보통 역을 원합니다: amount0 / amount1 예치가 주어졌을 때, 범위에 맞는 최대 L을 계산합니다. SDK의 LiquidityMath.getLiquidityFromTokenAmounts가 이를 수행합니다. 범위 내 경우의 공식: L0=amount0⋅sqrt_c⋅sqrt_bsqrt_b−sqrt_c,L1=amount1sqrt_c−sqrt_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 감소 — 가격은 token1/token0으로 표시되므로 token0을 추가하면 낮아짐)이 주어졌을 때, 아래의 다음 초기화된 틱은 sqrt_t < sqrt_c에 있습니다. 이 미세 구간 내에서 입력과 가격 사이의 관계는: Δamount0=L⋅(1sqrt_t−1sqrt_c)=L⋅(sqrt_c−sqrt_t)sqrt_c⋅sqrt_t\Delta\text{amount0} = L \cdot \left( \frac{1}{\text{sqrt\_t}} - \frac{1}{\text{sqrt\_c}} \right) = \frac{L \cdot (\text{sqrt\_c} - \text{sqrt\_t})}{\text{sqrt\_c} \cdot \text{sqrt\_t}} 그리고 Δamount1=L⋅(sqrt_c−sqrt_t)\Delta\text{amount1} = L \cdot (\text{sqrt\_c} - \text{sqrt\_t}) token1 입력 스왑은 거울상입니다: sqrt_price는 위의 다음 틱을 향해 상승하고, 두 공식은 어느 끝점을 빼는지 바뀝니다. 프로그램은 방향을 zero_for_one으로 인코딩합니다 (입력 민트가 token_mint_0일 때 true), 그리고 zero_for_one 스왑은 MIN_SQRT_PRICE_X64를 향해 제한됩니다. 프로그램은 다음 두 가지 중 하나를 수행합니다:
  • 전체 입력이 맞습니까? 남은 입력 (수수료 후)이 sqrt_t에 도달하는 Δamount0보다 작으면, 새로운 sqrt_c'을 정확히 풉니다: sqrt_c′=L⋅sqrt_cL+Δinput⋅sqrt_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_c와 sqrt_t가 바뀌고 반전이 다른 슬롯에 있습니다. 전체 Rust 구현은 raydium-clmm/programs/amm/src/libraries/swap_math.rs에 있습니다. 그 로직은 Uniswap v3의 SwapMath.computeSwapStep과 일대일로 일치합니다.

각 스텝의 수수료

거래 수수료는 각 스텝의 입력 수량에서 차감되며, CPMM과 동일한 규칙입니다:
LP 부분은 글로벌 수수료 성장 누적기를 업데이트하여 현재 범위 내 유동성에 분산됩니다: fee_growth_globalin+=lp_portion⋅264L\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 수수료 회계의 까다로운 부분: 포지션은 풀의 가격이 범위 내에 있을 때만 수수료를 얻습니다. 풀은 누적 수수료를 전역적으로 추적하고, 포지션은 특정 범위 내의 누적 수수료를 알아야 합니다. 해결책은 틱 기반 누적기입니다. 각 틱은 다음을 저장합니다:
틱 초기화 시점에:
  • 풀의 가격이 이 틱 위에 있으면 (tick_current >= this_tick), fee_growth_outside = fee_growth_global입니다. (지금까지 얻은 모든 것은 현재 가격을 기준으로 이 틱 “아래”인 “외부”입니다.)
  • 그렇지 않으면 fee_growth_outside = 0입니다.
가격이 틱을 넘을 때, 프로그램은 그 틱의 fee_growth_outside를 뒤집습니다: fee_growth_outside←fee_growth_global−fee_growth_outside\text{fee\_growth\_outside} \gets \text{fee\_growth\_global} - \text{fee\_growth\_outside} 이것이 유지하는 불변식: 모든 틱 t에 대해, fee_growth_outside(t)는 tick_current가 t의 반대편에 있는 동안 누적된 수수료와 같습니다. 범위 [tick_lower, tick_upper] 내의 수수료 성장은 다음과 같이 도출됩니다:
이는 Uniswap-v3 수수료 성장 공식이며, 변경되지 않았습니다.

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

PersonalPositionState는 fee_growth_inside_0_last_x64와 fee_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⋅Δt⋅264L\text{reward\_growth\_global} \mathrel{+}= \text{emission\_per\_second} \cdot \Delta t \cdot \frac{2^{64}}{L} — 방출은 활성 유동성에 반비례하므로, 더 밀집된 풀은 각 포지션에 초당 비례적으로 더 적게 지급하지만, 총 더 많은 포지션에 지급합니다. 포지션당 보상 채무는 reward_owed=(reward_growth_insidenow−reward_growth_insidelast)⋅L/264\text{reward\_owed} = (\text{reward\_growth\_inside}_{\text{now}} - \text{reward\_growth\_inside}_{\text{last}}) \cdot L / 2^{64} 이며 DecreaseLiquidity / DecreaseLiquidityV2로 지급됩니다 — 독립형 수거 보상 명령은 없습니다. 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 ≈ 0.997005 × 2^{64}). token0 입력 스왑은 하향으로 이동하므로 위의 틱은 여기서 무관합니다.
  • 거래 수수료율: 500 (0.05%).
사용자: SwapBaseInput 정확한 입력 1,000 token0. 스텝 1 — 수수료:
스텝 2 — 999가 현재 틱 범위 내에 맞습니까?
999 < 3004.4이므로 전체 입력이 틱을 넘지 않고 맞습니다. 스텝 3 — 새로운 가격:
즉, sqrt_c'는 sqrt_c 아래에 착지하며, 이는 올바른 방향입니다: token0 → token1 스왑은 풀에 token0을 추가하므로 token1/token0 가격을 낮춥니다. 스텝 4 — 출력 수량:
반올림을 고려한 후, 사용자는 약 998 token1을 받습니다. 수수료 (1 token0)는 trade_fee_rate × protocol_fee_rate / 1e6 (펀드도 유사)로 LP, 프로토콜, 펀드 간에 분할되고, LP 부분은 fee_growth_global_0_x64로 흐릅니다.

스왑 중 리미트 오더 매칭

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

TickState의 코호트별 상태

두 코호트 레이아웃이 존재하는 이유는 새로운 오더가 틱에서 열릴 수 있기 때문입니다 동안 더 오래된 코호트가 여전히 채워지고 있습니다. 새로 열린 오더는 orders_amount에 참여하고 다음 order_phase를 상속합니다. 이전 코호트가 완전히 소비될 때까지 채울 수 없습니다.

매칭 스텝

스왑 중 각 틱 교차에서 발생하는 매칭의 의사 코드:
리미트 오더 소유자에게 가는 출력 토큰은 스왑당 전송되지 않습니다. 오더 소유자가 SettleLimitOrder (또는 DecreaseLimitOrder)를 호출할 때까지 풀의 출력 볼트에 가상으로 앉아 있습니다. 풀은 단순히 unfilled_ratio_x64를 통해 코호트가 얼마나 채워졌는지 추적합니다. 각 LimitOrderState는 열린 시간에 자체 (order_phase, unfilled_ratio_x64) 스냅샷을 저장하므로 정산은 다음과 같이 축소됩니다:
이 O(1) 정산이 코호트 설계의 핵심입니다 — 틱은 임의로 많은 오더를 채울 수 있으며 오더당 가스 없이 가능합니다.

LP 곡선과의 상호작용

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

동적 수수료 도출

PoolState.dynamic_fee_info는 변동성 상태를 전달합니다. 각 스왑 스텝은 단계별 수수료율을 다음과 같이 계산합니다: fee_ratetotal=trade_fee_rateconfig+dynamic_fee_control⋅(vol_acc⋅tick_spacing)2Dctrl⋅Svol2⏟동적 할증\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{,}000 — DYNAMIC_FEE_CONTROL_DENOMINATOR
  • Svol=10,000S_{\text{vol}} = 10{,}000 — VOLATILITY_ACCUMULATOR_SCALE
  • vol_acc는 아래 업데이트 규칙 후의 스왑당 누적기
  • tick_spacing은 PoolState.tick_spacing에서 나옵니다
결과는 100,000/106=10%100{,}000 / 10^6 = 10\%에서 제한됩니다.

누적기 업데이트

두 규칙이 각 스왑에서 순서대로 적용됩니다: 감소. 참조 바닥은 마지막 업데이트 이후 시간에 따라 감소합니다: vol_ref={0if Δt>decay_periodvol_accprev⋅reduction_factor10,000if filter_period<Δt≤decay_periodvol_refprevif Δt≤filter_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+∣tref−tnow∣⋅Svol,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_spacing⌋t_{\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로 되돌립니다.

다음으로 갈 곳

출처: