Developers
Math
The formulas behind prices, ranges and amounts in a CL pool, as self-contained TypeScript. Everything that touches on-chain integers uses bigint and rounds the way the contracts do; a number appears only where the result is for display. Each block on this page runs as written and is tested against the Giwater app's own math.
Ticks and Prices
A pool's price is the amount of currency1 one unit of currency0 buys, in raw units (each token's smallest unit). Ticks index that price on a geometric scale: price = 1.0001 ^ tick, so each tick is a 0.01% step. Tick 0 is a raw price of 1; above it one raw unit of currency0 is worth more than one of currency1, below it less.
To show a price in whole tokens, scale by the decimals difference. In the BTC/USDC pools BTC (8 decimals) is currency0 and USDC (6) is currency1, so a raw price is multiplied by 10 ^ (8 - 6) to read as USDC per BTC. For the inverse quote, take 1 / price.
Ticks run from MIN_TICK = -887272 to MAX_TICK = 887272. A position's ticks must also be multiples of the pool's tick spacing, which the pool key carries in bits 16–39 of parameters. The seeded pools use 1 (0.01%), 10 (0.05%) and 60 (0.30%). When turning a price range into ticks, round the lower bound down and the upper bound up, so the range covers at least the prices asked for.
export const MIN_TICK = -887272;
export const MAX_TICK = 887272;
// Token1 per token0 in whole tokens.
export function priceAtTick(tick: number, decimals0: number, decimals1: number): number {
return 1.0001 ** tick * 10 ** (decimals0 - decimals1);
}
// The fractional tick of a price; align it before using it as a position bound.
export function tickAtPrice(price: number, decimals0: number, decimals1: number): number {
return Math.log(price / 10 ** (decimals0 - decimals1)) / Math.log(1.0001);
}
export function tickSpacing(parameters: `0x${string}`): number {
return Number((BigInt(parameters) >> 16n) & 0xffffffn);
}
// Lower bounds round down, upper bounds round up.
export function alignTick(tick: number, spacing: number, direction: "down" | "up"): number {
return (direction === "down" ? Math.floor(tick / spacing) : Math.ceil(tick / spacing)) * spacing;
}
// The widest range a position can take: the outermost multiples of the spacing.
export function usableTicks(spacing: number): { lower: number; upper: number } {
return {
lower: Math.ceil(MIN_TICK / spacing) * spacing,
upper: Math.floor(MAX_TICK / spacing) * spacing,
};
}A full-range position uses usableTicks(spacing), not MIN_TICK and MAX_TICK: with a spacing of 60 those are -887220 and 887220.
sqrtPriceX96
The pool stores its price as sqrtPriceX96: the square root of the raw price as a Q64.96 fixed-point number, a uint160 equal to sqrt(price) * 2^96. CLPoolManager.getSlot0(poolId) returns it together with the current tick, which is the largest tick whose square-root price does not exceed it.
sqrtRatioAtTick is the contract's TickMath.getSqrtRatioAtTick, bit for bit: use it whenever a result must match what the pool computes, such as position amounts. Its values at the tick bounds are MIN_SQRT_RATIO = 4295128739 and MAX_SQRT_RATIO = 1461446703485210103287273052203988822378723970342; a valid sqrtPriceX96 lies in [MIN_SQRT_RATIO, MAX_SQRT_RATIO).
export const Q96 = 1n << 96n;
export const MIN_SQRT_RATIO = 4295128739n;
export const MAX_SQRT_RATIO = 1461446703485210103287273052203988822378723970342n;
// TickMath.getSqrtRatioAtTick: sqrt(1.0001 ^ tick) * 2^96, rounded up.
export function sqrtRatioAtTick(tick: number): bigint {
const absTick = BigInt(Math.abs(tick));
if (absTick > BigInt(MAX_TICK)) throw new RangeError("tick out of range");
let ratio = absTick & 1n ? 0xfffcb933bd6fad37aa2d162d1a594001n : 1n << 128n;
const factors = [
0xfff97272373d413259a46990580e213an, 0xfff2e50f5f656932ef12357cf3c7fdccn,
0xffe5caca7e10e4e61c3624eaa0941cd0n, 0xffcb9843d60f6159c9db58835c926644n,
0xff973b41fa98c081472e6896dfb254c0n, 0xff2ea16466c96a3843ec78b326b52861n,
0xfe5dee046a99a2a811c461f1969c3053n, 0xfcbe86c7900a88aedcffc83b479aa3a4n,
0xf987a7253ac413176f2b074cf7815e54n, 0xf3392b0822b70005940c7a398e4b70f3n,
0xe7159475a2c29b7443b29c7fa6e889d9n, 0xd097f3bdfd2022b8845ad8f792aa5825n,
0xa9f746462d870fdf8a65dc1f90e061e5n, 0x70d869a156d2a1b890bb3df62baf32f7n,
0x31be135f97d08fd981231505542fcfa6n, 0x9aa508b5b7a84e1c677de54f3e99bc9n,
0x5d6af8dedb81196699c329225ee604n, 0x2216e584f5fa1ea926041bedfe98n,
0x48a170391f7dc42444e8fa2n,
];
factors.forEach((factor, i) => {
if (absTick & (2n << BigInt(i))) ratio = (ratio * factor) >> 128n;
});
if (tick > 0) ratio = ((1n << 256n) - 1n) / ratio;
return (ratio >> 32n) + (ratio % (1n << 32n) === 0n ? 0n : 1n);
}
// TickMath.getTickAtSqrtRatio: the largest tick whose sqrt ratio is <= sqrtPriceX96.
export function tickAtSqrtRatio(sqrtPriceX96: bigint): number {
if (sqrtPriceX96 < MIN_SQRT_RATIO || sqrtPriceX96 >= MAX_SQRT_RATIO) {
throw new RangeError("sqrtPriceX96 out of range");
}
// A float estimate, then corrected against the exact ratios.
const estimate = Math.floor((2 * Math.log(Number(sqrtPriceX96) / 2 ** 96)) / Math.log(1.0001));
let tick = Math.min(Math.max(estimate, MIN_TICK), MAX_TICK - 1);
while (sqrtRatioAtTick(tick) > sqrtPriceX96) tick--;
while (sqrtRatioAtTick(tick + 1) <= sqrtPriceX96) tick++;
return tick;
}
// Token1 per token0 in whole tokens. Square in bigint; convert to a float last.
export function priceAtSqrtPriceX96(sqrtPriceX96: bigint, decimals0: number, decimals1: number): number {
return (
Number(sqrtPriceX96 * sqrtPriceX96 * 10n ** BigInt(decimals0)) /
Number((1n << 192n) * 10n ** BigInt(decimals1))
);
}
// Float precision: fine to initialize a pool, not for amounts.
export function sqrtPriceX96AtPrice(price: number, decimals0: number, decimals1: number): bigint {
return BigInt(Math.floor(Math.sqrt(price * 10 ** (decimals1 - decimals0)) * 2 ** 96));
}Precision:
- Never square
Number(sqrtPriceX96). A float keeps 53 bits of a 160-bit value; square thebigintand divide last, aspriceAtSqrtPriceX96does. 1.0001 ** tickandsqrtRatioAtTickagree to about twelve significant digits, which is fine for display but not for amounts. Anything that feeds a transaction usessqrtRatioAtTick.tickAtPriceis a float: a price exactly on a tick boundary can land a hair either side. Always align it with an explicit rounding direction.
Liquidity and Amounts
A position holds liquidity L between two ticks, not token amounts. What L is worth depends on where the current price sits relative to the range, using the square-root prices sqrtLower and sqrtUpper of the two ticks:
- Below the range (
tick < tickLower): allcurrency0,amount0 = L · 2^96 · (sqrtUpper − sqrtLower) / (sqrtUpper · sqrtLower). - In range (
tickLower ≤ tick < tickUpper):amount0from the current price up tosqrtUpper, andamount1 = L · (sqrtPrice − sqrtLower) / 2^96. - Above the range (
tick ≥ tickUpper): allcurrency1,amount1 = L · (sqrtUpper − sqrtLower) / 2^96.
The branch is chosen by the current tick, as the pool chooses it, not by comparing square-root prices. Rounding always favours the pool: amounts you pay in (a mint or an increase) round up, and amounts you take out (a decrease or a burn) round down.
const divide = (numerator: bigint, denominator: bigint, roundUp: boolean) =>
roundUp ? (numerator + denominator - 1n) / denominator : numerator / denominator;
// SqrtPriceMath.getAmount0Delta
export function getAmount0Delta(sqrtA: bigint, sqrtB: bigint, liquidity: bigint, roundUp: boolean): bigint {
const [lower, upper] = sqrtA < sqrtB ? [sqrtA, sqrtB] : [sqrtB, sqrtA];
return divide((liquidity << 96n) * (upper - lower), upper * lower, roundUp);
}
// SqrtPriceMath.getAmount1Delta
export function getAmount1Delta(sqrtA: bigint, sqrtB: bigint, liquidity: bigint, roundUp: boolean): bigint {
const [lower, upper] = sqrtA < sqrtB ? [sqrtA, sqrtB] : [sqrtB, sqrtA];
return divide(liquidity * (upper - lower), Q96, roundUp);
}
// The tokens behind a position. roundUp: true for a deposit, false for a withdrawal.
export function getAmountsForLiquidity(
tickLower: number,
tickUpper: number,
currentTick: number,
liquidity: bigint,
sqrtPriceX96: bigint,
roundUp = false,
): [bigint, bigint] {
const lower = sqrtRatioAtTick(tickLower);
const upper = sqrtRatioAtTick(tickUpper);
if (currentTick < tickLower) return [getAmount0Delta(lower, upper, liquidity, roundUp), 0n];
if (currentTick >= tickUpper) return [0n, getAmount1Delta(lower, upper, liquidity, roundUp)];
return [
getAmount0Delta(sqrtPriceX96, upper, liquidity, roundUp),
getAmount1Delta(lower, sqrtPriceX96, liquidity, roundUp),
];
}The reverse — how much liquidity a token amount buys — is what a deposit form needs. Below the range only amount0 counts, above it only amount1, and in range the smaller of the two liquidities is the one both amounts can fund. Each division rounds down, so the liquidity never needs more than the amount given.
export function getLiquidityForAmount0(sqrtA: bigint, sqrtB: bigint, amount0: bigint): bigint {
const [lower, upper] = sqrtA < sqrtB ? [sqrtA, sqrtB] : [sqrtB, sqrtA];
return (amount0 * lower * upper) / Q96 / (upper - lower);
}
export function getLiquidityForAmount1(sqrtA: bigint, sqrtB: bigint, amount1: bigint): bigint {
const [lower, upper] = sqrtA < sqrtB ? [sqrtA, sqrtB] : [sqrtB, sqrtA];
return (amount1 * Q96) / (upper - lower);
}
export function getLiquidityForAmounts(
tickLower: number,
tickUpper: number,
currentTick: number,
sqrtPriceX96: bigint,
amount0: bigint,
amount1: bigint,
): bigint {
const lower = sqrtRatioAtTick(tickLower);
const upper = sqrtRatioAtTick(tickUpper);
if (currentTick < tickLower) return getLiquidityForAmount0(lower, upper, amount0);
if (currentTick >= tickUpper) return getLiquidityForAmount1(lower, upper, amount1);
const liquidity0 = getLiquidityForAmount0(sqrtPriceX96, upper, amount0);
const liquidity1 = getLiquidityForAmount1(lower, sqrtPriceX96, amount1);
return liquidity0 < liquidity1 ? liquidity0 : liquidity1;
}
// The cap for amount0Max / amount1Max: the rounded-up amount plus slippage.
export function maxAmountIn(amount: bigint, slippageBps: number): bigint {
return (amount * BigInt(10_000 + slippageBps) + 9_999n) / 10_000n;
}To deposit a fixed amount of one token, take the liquidity for that amount alone, then getAmountsForLiquidity(…, roundUp = true) for the other side. Pass the result to CL_MINT_POSITION with each amountMax from maxAmountIn; the Integration example does exactly this.
Fee Units
Fees are in pips: hundredths of a basis point, so 1_000_000 is 100% and 3_000 is 0.30%. The pool key's fee is the LP fee alone. The protocol fee comes from slot0 and packs two 12-bit pip values into one uint24, the low 12 bits for zeroForOne swaps and the high 12 for oneForZero. The two fees do not simply add: the protocol fee is taken first and the LP fee applies to what remains.
export const pipsToPercent = (pips: number) => pips / 10_000;
export function protocolFeeFor(packed: number, zeroForOne: boolean): number {
return zeroForOne ? packed & 0xfff : packed >> 12;
}
// ProtocolFeeLibrary.calculateSwapFee: 2011 LP + 989 protocol = 2999 (0.2999%).
export function totalSwapFee(lpFee: number, protocolFee: number): number {
return protocolFee + lpFee - Math.floor((protocolFee * lpFee) / 1_000_000);
}Fees accrue as fee growth: the fees earned per unit of liquidity, a Q128.128 uint256 per currency. The pool keeps a global counter, and each initialized tick keeps the growth on its far side ("outside"). A position's unclaimed fees are the growth inside its range since it last settled, times its liquidity. The counters are allowed to overflow, so every subtraction is modulo 2^256.
const Q128 = 1n << 128n;
const sub256 = (a: bigint, b: bigint) => (a - b) & ((1n << 256n) - 1n);
export function feeGrowthInside(
currentTick: number,
tickLower: number,
tickUpper: number,
feeGrowthGlobalX128: bigint,
lowerOutsideX128: bigint,
upperOutsideX128: bigint,
): bigint {
if (currentTick < tickLower) return sub256(lowerOutsideX128, upperOutsideX128);
if (currentTick >= tickUpper) return sub256(upperOutsideX128, lowerOutsideX128);
return sub256(sub256(feeGrowthGlobalX128, lowerOutsideX128), upperOutsideX128);
}
// In the currency's smallest unit, rounded down.
export function unclaimedFees(insideX128: bigint, insideLastX128: bigint, liquidity: bigint): bigint {
return (sub256(insideX128, insideLastX128) * liquidity) / Q128;
}Run it once per currency. The inputs, all read at the same block:
CLPositionManager.positions(tokenId)— the pool key,tickLower,tickUpper,liquidity, andfeeGrowthInside0LastX128/feeGrowthInside1LastX128.CLPoolManager.getFeeGrowthGlobals(poolId)—feeGrowthGlobal0x128,feeGrowthGlobal1x128.CLPoolManager.getPoolTickInfo(poolId, tick)fortickLowerandtickUpper—feeGrowthOutside0X128,feeGrowthOutside1X128.CLPoolManager.getSlot0(poolId)— the currenttick.
Fees are paid out on the position's next liquidity change; a CL_DECREASE_LIQUIDITY of zero collects them without touching the position.
- Previous
- Advanced Swaps
- Next
- Errors