Developers
Positions
A liquidity position is an ERC-721 token minted by CLPositionManager. The token ID identifies the position: its pool key, its tick range and its liquidity are stored against that ID, and every change after the mint names it. The examples on this page build on setup.ts from Integration and send the same calls the Giwater app makes from Portfolio.
Every change goes through modifyLiquidities, with the same plan format as a mint: one position action followed by CLOSE_CURRENCY (0x12) for each currency of the pool. CLOSE_CURRENCY settles whichever way the balance goes, paying the Vault what you owe or sending you what you are owed, so the same pair of closes works for deposits, withdrawals and fees.
Only the token's owner, or an address the owner has approved for it with approve or setApprovalForAll, can change a position. Tokens and fees go to the caller, not to the owner, so an approved operator receives what it withdraws. The examples check that the connected wallet owns the token, as the app does, and every transaction carries a deadline 20 minutes after the latest block.
Reading a Position
positions(tokenId) returns the pool key, the tick range, the liquidity, and the fee growth inside the range at the position's last change. ownerOf(tokenId) returns the owner. The pool manager supplies the rest: getSlot0 for the current price and tick, getFeeGrowthGlobals and getPoolTickInfo at both ends of the range for the fees. Read everything at one block, or the price and the fee counters can describe different states.
Token amounts. A position holds liquidity, not token amounts. What it is worth depends on where the price sits: below the range it is all currency0, above it all currency1, and inside it a mix. amountsForLiquidity clamps the price to the range and rounds down, as a withdrawal does.
Unclaimed fees. Fees are not stored per position. The pool keeps cumulative fee growth per unit of liquidity, globally and outside each initialized tick; the growth inside a range is the global value minus the growth below and above it. A position's unclaimed fees are the growth inside since its last change, times its liquidity, divided by 2^128. The counters are allowed to overflow, so every subtraction is taken modulo 2^256.
The examples read TOKEN_ID from the environment. Your token IDs are in the Transfer events CLPositionManager emits to your address.
import { encodeAbiParameters, keccak256, maxUint256, parseAbi, parseAbiParameters } from "viem";
import { CONTRACTS, publicClient } from "./setup";
const positionManagerAbi = parseAbi([
"struct PoolKey { address currency0; address currency1; address hooks; address poolManager; uint24 fee; bytes32 parameters; }",
"function positions(uint256 tokenId) view returns (PoolKey poolKey, int24 tickLower, int24 tickUpper, uint128 liquidity, uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128, address subscriber)",
"function ownerOf(uint256 tokenId) view returns (address)",
]);
const poolManagerAbi = parseAbi([
"struct TickInfo { uint128 liquidityGross; int128 liquidityNet; uint256 feeGrowthOutside0X128; uint256 feeGrowthOutside1X128; }",
"function getSlot0(bytes32 id) view returns (uint160 sqrtPriceX96, int24 tick, uint24 protocolFee, uint24 lpFee)",
"function getFeeGrowthGlobals(bytes32 id) view returns (uint256 feeGrowthGlobal0X128, uint256 feeGrowthGlobal1X128)",
"function getPoolTickInfo(bytes32 id, int24 tick) view returns (TickInfo info)",
]);
const POOL_KEY_ABI =
"(address currency0, address currency1, address hooks, address poolManager, uint24 fee, bytes32 parameters)";
// sqrt(1.0001^tick) * 2^96, rounded up exactly as the pool's TickMath rounds it.
export function getSqrtPriceAtTick(tick: number): bigint {
const absTick = BigInt(Math.abs(tick));
let ratio = absTick & 1n ? 0xfffcb933bd6fad37aa2d162d1a594001n : 0x100000000000000000000000000000000n;
const multipliers = [
0xfff97272373d413259a46990580e213an, 0xfff2e50f5f656932ef12357cf3c7fdccn,
0xffe5caca7e10e4e61c3624eaa0941cd0n, 0xffcb9843d60f6159c9db58835c926644n,
0xff973b41fa98c081472e6896dfb254c0n, 0xff2ea16466c96a3843ec78b326b52861n,
0xfe5dee046a99a2a811c461f1969c3053n, 0xfcbe86c7900a88aedcffc83b479aa3a4n,
0xf987a7253ac413176f2b074cf7815e54n, 0xf3392b0822b70005940c7a398e4b70f3n,
0xe7159475a2c29b7443b29c7fa6e889d9n, 0xd097f3bdfd2022b8845ad8f792aa5825n,
0xa9f746462d870fdf8a65dc1f90e061e5n, 0x70d869a156d2a1b890bb3df62baf32f7n,
0x31be135f97d08fd981231505542fcfa6n, 0x9aa508b5b7a84e1c677de54f3e99bc9n,
0x5d6af8dedb81196699c329225ee604n, 0x2216e584f5fa1ea926041bedfe98n,
0x48a170391f7dc42444e8fa2n,
];
multipliers.forEach((multiplier, i) => {
if (absTick & (2n << BigInt(i))) ratio = (ratio * multiplier) >> 128n;
});
if (tick > 0) ratio = maxUint256 / ratio;
return (ratio >> 32n) + (ratio % (1n << 32n) === 0n ? 0n : 1n);
}
export const tokenId = BigInt(process.env.TOKEN_ID!); // the position's NFT id
// Pin every read to one block so the numbers describe the same state.
export const block = await publicClient.getBlock();
export const [poolKey, tickLower, tickUpper, liquidity, feeGrowthInside0LastX128, feeGrowthInside1LastX128] =
await publicClient.readContract({
address: CONTRACTS.clPositionManager,
abi: positionManagerAbi,
functionName: "positions",
args: [tokenId],
blockNumber: block.number,
});
export const owner = await publicClient.readContract({
address: CONTRACTS.clPositionManager,
abi: positionManagerAbi,
functionName: "ownerOf",
args: [tokenId],
blockNumber: block.number,
});
const poolId = keccak256(encodeAbiParameters(parseAbiParameters(POOL_KEY_ABI), [poolKey]));
const pool = { address: CONTRACTS.clPoolManager, abi: poolManagerAbi, blockNumber: block.number } as const;
export const [sqrtPriceX96, tick] = await publicClient.readContract({
...pool,
functionName: "getSlot0",
args: [poolId],
});
const [feeGrowthGlobal0X128, feeGrowthGlobal1X128] = await publicClient.readContract({
...pool,
functionName: "getFeeGrowthGlobals",
args: [poolId],
});
const lower = await publicClient.readContract({
...pool,
functionName: "getPoolTickInfo",
args: [poolId, tickLower],
});
const upper = await publicClient.readContract({
...pool,
functionName: "getPoolTickInfo",
args: [poolId, tickUpper],
});
// What a slice of liquidity is worth now, rounded down as a withdrawal pays it.
// Below the range the position is all token0, above it all token1.
export function amountsForLiquidity(liquidityDelta: bigint): [bigint, bigint] {
const sqrtLower = getSqrtPriceAtTick(tickLower);
const sqrtUpper = getSqrtPriceAtTick(tickUpper);
const sqrtPrice = tick < tickLower ? sqrtLower : tick >= tickUpper ? sqrtUpper : sqrtPriceX96;
return [
((liquidityDelta << 96n) * (sqrtUpper - sqrtPrice)) / (sqrtUpper * sqrtPrice),
(liquidityDelta * (sqrtPrice - sqrtLower)) >> 96n,
];
}
export const [amount0, amount1] = amountsForLiquidity(liquidity);
// Unclaimed fees: fee growth inside the range since the position last changed, times its
// liquidity. Growth counters wrap at 2^256, so every subtraction is taken mod 2^256.
function feesOwed(global: bigint, lowerOutside: bigint, upperOutside: bigint, last: bigint) {
const below = tick >= tickLower ? lowerOutside : global - lowerOutside;
const above = tick < tickUpper ? upperOutside : global - upperOutside;
const inside = (global - below - above) & maxUint256;
return (((inside - last) & maxUint256) * liquidity) >> 128n;
}
export const fees0 = feesOwed(
feeGrowthGlobal0X128,
lower.feeGrowthOutside0X128,
upper.feeGrowthOutside0X128,
feeGrowthInside0LastX128,
);
export const fees1 = feesOwed(
feeGrowthGlobal1X128,
lower.feeGrowthOutside1X128,
upper.feeGrowthOutside1X128,
feeGrowthInside1LastX128,
);
console.log({ owner, tickLower, tickUpper, liquidity, amount0, amount1, fees0, fees1 });Increase Liquidity
CL_INCREASE_LIQUIDITY (0x00) adds liquidity to an existing position. Its parameters are the token ID, the liquidity to add, amount0Max and amount1Max, and hook data. The tokens are pulled through Permit2, exactly as for a mint, so the position manager needs a Permit2 allowance covering both caps.
The example adds 100 USDC and the BTC that pairs with it, computed the same way as in Add Liquidity: the USDC side is exact and the BTC cap carries 0.5% for price movement. A position that is out of range takes only one token, so the example stops there rather than asking for both.
Adding liquidity also collects the position's unclaimed fees. They are credited in the same step and offset what the deposit owes, which is why the plan closes both currencies instead of only paying.
import { encodeAbiParameters, parseAbi, parseAbiParameters, parseUnits, type Hex } from "viem";
import { account, CONTRACTS, publicClient, walletClient } from "./setup";
import {
block,
getSqrtPriceAtTick,
owner,
poolKey,
sqrtPriceX96,
tick,
tickLower,
tickUpper,
tokenId,
} from "./position";
const permit2Abi = parseAbi([
"function approve(address token, address spender, uint160 amount, uint48 expiration)",
]);
const positionManagerAbi = parseAbi([
"function modifyLiquidities(bytes payload, uint256 deadline) payable",
]);
if (owner.toLowerCase() !== account.address.toLowerCase()) throw new Error("not the position's owner");
// Out of range a position holds one token only, and adding takes only that token.
if (tick < tickLower || tick >= tickUpper) throw new Error("position is out of range");
// 1. Liquidity for 100 more USDC (token1), and the BTC (token0) it pairs with, rounded up.
const amount1 = parseUnits("100", 6);
const sqrtLower = getSqrtPriceAtTick(tickLower);
const sqrtUpper = getSqrtPriceAtTick(tickUpper);
const liquidity = (amount1 << 96n) / (sqrtPriceX96 - sqrtLower);
const amount0Numerator = (liquidity << 96n) * (sqrtUpper - sqrtPriceX96);
const amount0Denominator = sqrtUpper * sqrtPriceX96;
const amount0 = (amount0Numerator + amount0Denominator - 1n) / amount0Denominator;
// Caps the position manager may pull: the exact USDC, and the BTC plus 0.5% for price drift.
const slippageBps = 50n;
const amount0Max = (amount0 * (10_000n + slippageBps) + 9_999n) / 10_000n;
const amount1Max = amount1;
// 2. Both tokens are approved to Permit2 already (see Add Liquidity). Renew the position
// manager's Permit2 allowance for these caps, for 30 days.
for (const [token, amount] of [
[poolKey.currency0, amount0Max],
[poolKey.currency1, amount1Max],
] as const) {
const permitHash = await walletClient.writeContract({
address: CONTRACTS.permit2,
abi: permit2Abi,
functionName: "approve",
args: [token, CONTRACTS.clPositionManager, amount, Number(block.timestamp + 30n * 86_400n)],
});
await publicClient.waitForTransactionReceipt({ hash: permitHash });
}
// 3. The plan: add the liquidity, then close both currencies. The position's unclaimed
// fees are collected in the same step and offset what you pay.
const CL_INCREASE_LIQUIDITY = "00";
const CLOSE_CURRENCY = "12";
const increase = encodeAbiParameters(
parseAbiParameters("uint256 tokenId, uint256 liquidity, uint128 amount0Max, uint128 amount1Max, bytes hookData"),
[tokenId, liquidity, amount0Max, amount1Max, "0x"],
);
const close0 = encodeAbiParameters(parseAbiParameters("address"), [poolKey.currency0]);
const close1 = encodeAbiParameters(parseAbiParameters("address"), [poolKey.currency1]);
const payload = encodeAbiParameters(parseAbiParameters("bytes, bytes[]"), [
("0x" + CL_INCREASE_LIQUIDITY + CLOSE_CURRENCY + CLOSE_CURRENCY) as Hex,
[increase, close0, close1],
]);
const hash = await walletClient.writeContract({
address: CONTRACTS.clPositionManager,
abi: positionManagerAbi,
functionName: "modifyLiquidities",
args: [payload, block.timestamp + 1_200n],
});
console.log("increase:", (await publicClient.waitForTransactionReceipt({ hash })).status);Decrease Liquidity
CL_DECREASE_LIQUIDITY (0x01) removes liquidity. It takes the same parameters as an increase, but the two limits are minimums: amount0Min and amount1Min are the least the withdrawal may return, and the call reverts if the price has moved far enough to return less. The example withdraws half the position, values that half at the current price, and subtracts 0.5% from each side.
The tokens withdrawn and all of the position's unclaimed fees go to the caller. Removing all liquidity leaves an empty position whose NFT still exists; burn it to delete it.
import { encodeAbiParameters, parseAbi, parseAbiParameters, type Hex } from "viem";
import { account, CONTRACTS, publicClient, walletClient } from "./setup";
import { amountsForLiquidity, block, liquidity, owner, poolKey, tokenId } from "./position";
const positionManagerAbi = parseAbi([
"function modifyLiquidities(bytes payload, uint256 deadline) payable",
]);
if (owner.toLowerCase() !== account.address.toLowerCase()) throw new Error("not the position's owner");
// 1. Withdraw half the liquidity, and the tokens that half is worth at the current price.
const percent = 50n;
const liquidityDelta = (liquidity * percent) / 100n;
const [amount0, amount1] = amountsForLiquidity(liquidityDelta);
// Floors the withdrawal must meet: each amount minus 0.5% for price drift.
const slippageBps = 50n;
const amount0Min = (amount0 * (10_000n - slippageBps)) / 10_000n;
const amount1Min = (amount1 * (10_000n - slippageBps)) / 10_000n;
// 2. The plan: remove the liquidity, then close both currencies, sending you the tokens
// and every fee the position has earned.
const CL_DECREASE_LIQUIDITY = "01";
const CLOSE_CURRENCY = "12";
const decrease = encodeAbiParameters(
parseAbiParameters("uint256 tokenId, uint256 liquidity, uint128 amount0Min, uint128 amount1Min, bytes hookData"),
[tokenId, liquidityDelta, amount0Min, amount1Min, "0x"],
);
const close0 = encodeAbiParameters(parseAbiParameters("address"), [poolKey.currency0]);
const close1 = encodeAbiParameters(parseAbiParameters("address"), [poolKey.currency1]);
const payload = encodeAbiParameters(parseAbiParameters("bytes, bytes[]"), [
("0x" + CL_DECREASE_LIQUIDITY + CLOSE_CURRENCY + CLOSE_CURRENCY) as Hex,
[decrease, close0, close1],
]);
const hash = await walletClient.writeContract({
address: CONTRACTS.clPositionManager,
abi: positionManagerAbi,
functionName: "modifyLiquidities",
args: [payload, block.timestamp + 1_200n],
});
console.log("decrease:", (await publicClient.waitForTransactionReceipt({ hash })).status);Collect Fees
Infinity has no separate collect action. Decreasing a position by zero liquidity updates its fee accounting and credits its unclaimed fees, and the two CLOSE_CURRENCY actions send them to you. With nothing withdrawn there is no price exposure, so both minimums are zero.
The app collects from several positions at once by wrapping one such call per position in CLPositionManager.multicall.
import { encodeAbiParameters, parseAbi, parseAbiParameters, type Hex } from "viem";
import { account, CONTRACTS, publicClient, walletClient } from "./setup";
import { block, fees0, fees1, owner, poolKey, tokenId } from "./position";
const positionManagerAbi = parseAbi([
"function modifyLiquidities(bytes payload, uint256 deadline) payable",
]);
if (owner.toLowerCase() !== account.address.toLowerCase()) throw new Error("not the position's owner");
if (fees0 === 0n && fees1 === 0n) throw new Error("nothing to collect");
// There is no separate collect action: decreasing by zero liquidity settles the fees,
// and closing both currencies sends them to you. No price is involved, so no minimums.
const CL_DECREASE_LIQUIDITY = "01";
const CLOSE_CURRENCY = "12";
const collect = encodeAbiParameters(
parseAbiParameters("uint256 tokenId, uint256 liquidity, uint128 amount0Min, uint128 amount1Min, bytes hookData"),
[tokenId, 0n, 0n, 0n, "0x"],
);
const close0 = encodeAbiParameters(parseAbiParameters("address"), [poolKey.currency0]);
const close1 = encodeAbiParameters(parseAbiParameters("address"), [poolKey.currency1]);
const payload = encodeAbiParameters(parseAbiParameters("bytes, bytes[]"), [
("0x" + CL_DECREASE_LIQUIDITY + CLOSE_CURRENCY + CLOSE_CURRENCY) as Hex,
[collect, close0, close1],
]);
const hash = await walletClient.writeContract({
address: CONTRACTS.clPositionManager,
abi: positionManagerAbi,
functionName: "modifyLiquidities",
args: [payload, block.timestamp + 1_200n],
});
console.log("collect:", (await publicClient.waitForTransactionReceipt({ hash })).status);Burn a Position
CL_BURN_POSITION (0x03) removes whatever liquidity remains, credits the unclaimed fees, and burns the NFT. Its parameters are the token ID, amount0Min, amount1Min and hook data: there is no liquidity argument, because it always takes all of it. The minimums are the same as for a 100% decrease.
The Giwater app does not burn positions; it leaves an emptied NFT in place. The example uses the SDK's burn encoder, which follows the upstream Infinity action IDs.
import { encodeAbiParameters, parseAbi, parseAbiParameters, type Hex } from "viem";
import { account, CONTRACTS, publicClient, walletClient } from "./setup";
import { amount0, amount1, block, owner, poolKey, tokenId } from "./position";
const positionManagerAbi = parseAbi([
"function modifyLiquidities(bytes payload, uint256 deadline) payable",
]);
if (owner.toLowerCase() !== account.address.toLowerCase()) throw new Error("not the position's owner");
// Burning withdraws whatever liquidity is left, so it takes the same floors as a 100% decrease.
const slippageBps = 50n;
const amount0Min = (amount0 * (10_000n - slippageBps)) / 10_000n;
const amount1Min = (amount1 * (10_000n - slippageBps)) / 10_000n;
// The plan: burn the NFT, removing its liquidity and fees, then close both currencies.
const CL_BURN_POSITION = "03";
const CLOSE_CURRENCY = "12";
const burn = encodeAbiParameters(
parseAbiParameters("uint256 tokenId, uint128 amount0Min, uint128 amount1Min, bytes hookData"),
[tokenId, amount0Min, amount1Min, "0x"],
);
const close0 = encodeAbiParameters(parseAbiParameters("address"), [poolKey.currency0]);
const close1 = encodeAbiParameters(parseAbiParameters("address"), [poolKey.currency1]);
const payload = encodeAbiParameters(parseAbiParameters("bytes, bytes[]"), [
("0x" + CL_BURN_POSITION + CLOSE_CURRENCY + CLOSE_CURRENCY) as Hex,
[burn, close0, close1],
]);
const hash = await walletClient.writeContract({
address: CONTRACTS.clPositionManager,
abi: positionManagerAbi,
functionName: "modifyLiquidities",
args: [payload, block.timestamp + 1_200n],
});
console.log("burn:", (await publicClient.waitForTransactionReceipt({ hash })).status);- Previous
- Integration
- Next
- Advanced Swaps