/** * MetricAMM SDK - Liquidity * Unified liquidity calculations and position operations. */ import type { Address, PublicClient, Hex } from "viem"; import { encodeFunctionData } from "viem"; import { MAX_UINT104, MAX_INT104, MAX_INT128 } from "../constants.js"; import { MetricOmmPoolAbi } from "../abis/MetricOmmPool.js"; import type { LiquidityDelta, LiquidityBinValueInput } from "../types.js"; import { getPoolImmutables } from "./read.js"; import { getSlot0, getBinStatesScaled, getPositionBinSharesRange, getPositionBinSharesForBins, } from "../stateView/read.js"; import { ONE_E18, ceilDiv, toPriceE18, priceX64ToPriceE18, convertToken0ToToken1, convertToken1ToToken0, scaledPositiveDeltaToExternal, } from "../utils/liquidityMath.js"; export { convertToken0ToToken1, convertToken1ToToken0 }; export const getPositionShares = getPositionBinSharesRange; export const getPositionSharesForBins = getPositionBinSharesForBins; export interface RemoveLiquidityParams { percentageToRemove: number; bins?: number[]; lowerBin?: number; upperBin?: number; } export interface BinRemovalSpec { bin: number; percentageToRemove?: number; sharesToRemove?: bigint; } export const NO_SLIPPAGE_LIMIT = MAX_INT128; type ModifyLiquidityParams = { salt: bigint; deltas: LiquidityDelta[]; specAmount0: bigint; specAmount1: bigint; }; export type ModifyLiquidityArgs = readonly [bigint, LiquidityDelta[], bigint, bigint]; export interface BuildModifyLiquidityArgsBase { publicClient: PublicClient; stateViewAddress: Address; poolAddress: Address; specAmountBufferPercent?: number; } export interface BuildModifyLiquidityArgsForAdditionParams extends BuildModifyLiquidityArgsBase { bins: LiquidityBinValueInput[]; currentPrice?: number; currentPriceX64?: bigint; salt?: bigint; } export interface BuildModifyLiquidityArgsForUniformAdditionParams extends BuildModifyLiquidityArgsBase { amountInTokensPerBin: bigint; amountIsInToken0: boolean; currentPrice: number; lowerBin: number; upperBin: number; salt?: bigint; } export interface BuildModifyLiquidityArgsForUniformAdditionWithTotalTokenAmountParams extends BuildModifyLiquidityArgsBase { bins: number[]; weights?: number[]; totalValueToAddInToken: bigint; totalValueInToken0: boolean; currentPriceX64: bigint; salt?: bigint; } export interface BuildModifyLiquidityArgsForPercentageRemovalParams extends BuildModifyLiquidityArgsBase { owner: Address; salt: bigint; percentageToRemove: number; bins?: number[]; lowerBin?: number; upperBin?: number; } export interface BuildModifyLiquidityArgsForRemovalParams extends BuildModifyLiquidityArgsBase { owner: Address; salt: bigint; specs: BinRemovalSpec[]; } function resolvePriceE18(params: { currentPrice?: number; currentPriceX64?: bigint }): bigint { if (params.currentPriceX64 != null) { return priceX64ToPriceE18(params.currentPriceX64); } if (params.currentPrice != null) { return toPriceE18(params.currentPrice); } throw new Error("currentPrice or currentPriceX64 is required"); } /** * Prepare uniform liquidity distribution across a bin range. */ export async function buildModifyLiquidityArgsForUniformAddition({ publicClient, stateViewAddress, poolAddress, amountInTokensPerBin, amountIsInToken0, currentPrice, lowerBin, upperBin, salt = 0n, specAmountBufferPercent = 0, }: BuildModifyLiquidityArgsForUniformAdditionParams): Promise { if (lowerBin > upperBin) { throw new Error("lowerBin must be <= upperBin"); } const bins: LiquidityBinValueInput[] = []; for (let bin = lowerBin; bin <= upperBin; bin++) { bins.push({ bin, targetValueInToken: amountInTokensPerBin, targetValueInToken0: amountIsInToken0, }); } return buildModifyLiquidityArgsForAddition({ publicClient, stateViewAddress, poolAddress, bins, currentPrice, salt, specAmountBufferPercent, }); } /** * Prepare uniform liquidity across arbitrary bins from a TOTAL value budget. * * Algorithm: * 1. Calibrate by assigning weighted 1e18 targets per bin with zero buffer. * 2. Compute linear scale so selected specAmount token matches totalValueToAddInToken. * 3. Rebuild with scaled per-bin value and caller-provided specAmountBufferPercent. */ export async function buildModifyLiquidityArgsForUniformAdditionWithTotalTokenAmount({ publicClient, stateViewAddress, poolAddress, bins, weights, totalValueToAddInToken, totalValueInToken0, currentPriceX64, salt = 0n, specAmountBufferPercent = 0, }: BuildModifyLiquidityArgsForUniformAdditionWithTotalTokenAmountParams): Promise { validateSpecAmountBufferPercent(specAmountBufferPercent); if (!Array.isArray(bins) || bins.length === 0) { throw new Error("bins must be a non-empty array"); } if (weights != null) { if (!Array.isArray(weights) || weights.length !== bins.length) { throw new Error("weights must be an array with the same length as bins"); } } const uniqueBins = new Set(bins); if (uniqueBins.size !== bins.length) { throw new Error("bins must not contain duplicates"); } if (totalValueToAddInToken <= 0n) { throw new Error("totalValueToAddInToken must be > 0"); } if (currentPriceX64 <= 0n) { throw new Error("currentPriceX64 must be > 0"); } const calibrationValue = totalValueToAddInToken / BigInt(bins.length) + 1n; const calibrationTargets: bigint[] = weights ? weights.map((w) => (calibrationValue * BigInt(Math.ceil(w * 1e6) + 1)) / 1_000_000n) : Array(bins.length).fill(calibrationValue); const calibrationBins: LiquidityBinValueInput[] = bins.map((bin, i) => ({ bin, targetValueInToken: calibrationTargets[i], targetValueInToken0: totalValueInToken0, })); const [, , calibrationSpecAmount0, calibrationSpecAmount1] = await buildModifyLiquidityArgsForAddition({ publicClient, stateViewAddress, poolAddress, bins: calibrationBins, currentPriceX64, salt, specAmountBufferPercent: 0, }); const calibrationSpecAmount = totalValueInToken0 ? calibrationSpecAmount0 : calibrationSpecAmount1; if (calibrationSpecAmount <= 0n) { throw new Error("failed to calibrate uniform total amount scaling: specAmount is zero"); } const scaledBins: LiquidityBinValueInput[] = bins.map((bin, i) => ({ bin, targetValueInToken: (calibrationTargets[i] * totalValueToAddInToken) / calibrationSpecAmount, targetValueInToken0: totalValueInToken0, })); return buildModifyLiquidityArgsForAddition({ publicClient, stateViewAddress, poolAddress, bins: scaledBins, currentPriceX64, salt, specAmountBufferPercent, }); } /** * Prepare per-bin liquidity distribution from arbitrary per-bin amounts. * * Each input entry defines a single bin and a value expressed in token0 or token1. * The function computes `deltaShares` for every bin and totals `specAmount0/specAmount1` * needed to execute `modifyLiquidity` safely. * * ## Algorithm Overview * * This is the core distribution engine used by both: * - direct per-bin plans (computeModifyLiquidityForAddition) * - uniform plans via adapter (computeModifyLiquidityForUniformAddition) * * ## Steps * * 1. Validate inputs, price, bin range, and reject duplicate bins. * 2. Convert each bin target into both token0/token1 amounts using current price. * 3. Fetch slot0 and bin states, then classify each bin as below/current/above. * 4. Compute shares to add using ceiling division to avoid under-provisioning: * - Existing bins: proportional to current scaled balances and total shares. * - Empty bins: derived from initial scaled per-share constants. * 5. Convert scaled amounts back to external token amounts and accumulate * `specAmount0/specAmount1` with overflow checks. */ export async function buildModifyLiquidityArgsForAddition({ publicClient, stateViewAddress, poolAddress, bins, currentPrice, currentPriceX64, salt = 0n, specAmountBufferPercent = 0, }: BuildModifyLiquidityArgsForAdditionParams): Promise { validateSpecAmountBufferPercent(specAmountBufferPercent); if (!Array.isArray(bins)) { throw new Error("bins must be an array"); } if (bins.length === 0) { return [salt, [], 0n, 0n]; } const immutables = await getPoolImmutables(publicClient, poolAddress); const priceE18 = resolvePriceE18({ currentPrice, currentPriceX64 }); const targetsByBin = new Map< number, { targetAmount0: bigint; targetAmount1: bigint; targetValueInToken1: bigint; } >(); for (const entry of bins) { if (!Number.isInteger(entry.bin)) { throw new Error(`bin must be an integer, got ${entry.bin}`); } if (entry.bin < immutables.lowestBin || entry.bin > immutables.highestBin) { throw new Error( `bin ${entry.bin} is out of range [${immutables.lowestBin}, ${immutables.highestBin}]`, ); } if (entry.targetValueInToken <= 0n) { throw new Error(`amountInToken for bin ${entry.bin} must be > 0`); } const amount0 = entry.targetValueInToken0 ? entry.targetValueInToken : convertToken1ToToken0( entry.targetValueInToken, immutables.token0ScaleMultiplier, immutables.token1ScaleMultiplier, priceE18, ); const amount1 = entry.targetValueInToken0 ? convertToken0ToToken1( entry.targetValueInToken, immutables.token0ScaleMultiplier, immutables.token1ScaleMultiplier, priceE18, ) : entry.targetValueInToken; const targetValueInToken1 = entry.targetValueInToken0 ? convertToken0ToToken1( entry.targetValueInToken, immutables.token0ScaleMultiplier, immutables.token1ScaleMultiplier, priceE18, ) : entry.targetValueInToken; if (targetsByBin.has(entry.bin)) { throw new Error(`duplicate bin ${entry.bin} is not supported`); } targetsByBin.set(entry.bin, { targetAmount0: amount0, targetAmount1: amount1, targetValueInToken1, }); } const binIndices = Array.from(targetsByBin.keys()).sort((a, b) => a - b); const [slot0, binStates] = await Promise.all([ getSlot0(publicClient, stateViewAddress, poolAddress), getBinStatesScaled(publicClient, stateViewAddress, poolAddress, binIndices), ]); const deltas: LiquidityDelta[] = []; let specAmount0 = 0n; let specAmount1 = 0n; const curBinIdx = slot0.curBinIdx; const curPosInBin = slot0.curPosInBin; for (let i = 0; i < binIndices.length; i++) { const binIdx = binIndices[i]; const target = targetsByBin.get(binIdx); if (!target) { throw new Error(`internal error: missing target for bin ${binIdx}`); } const targetAmount0Scaled = target.targetAmount0 * immutables.token0ScaleMultiplier; const targetAmount1Scaled = target.targetAmount1 * immutables.token1ScaleMultiplier; const targetValueInToken1Scaled = target.targetValueInToken1 * immutables.token1ScaleMultiplier; const token0BalanceScaled = binStates.token0BalancesScaled[i]; const token1BalanceScaled = binStates.token1BalancesScaled[i]; const totalShares = binStates.totalShares[i]; const isBelowBin = binIdx < curBinIdx || (binIdx === curBinIdx && curPosInBin === MAX_UINT104); const isAboveBin = binIdx > curBinIdx || (binIdx === curBinIdx && curPosInBin === 0n); let sharesToAdd: bigint; if (isBelowBin) { if (targetAmount1Scaled <= 0n) { throw new Error( `amount for bin ${binIdx} is too small after conversion and results in 0 token1 target`, ); } if (totalShares > 0n) { if (token1BalanceScaled <= 0n) { throw new Error(`bin ${binIdx} has zero token1 balance with non-zero shares`); } sharesToAdd = ceilDiv(targetAmount1Scaled * totalShares, token1BalanceScaled); } else { sharesToAdd = ceilDiv( targetAmount1Scaled * ONE_E18, immutables.initialScaledToken1PerShareE18, ); } } else if (isAboveBin) { if (targetAmount0Scaled <= 0n) { throw new Error( `amount for bin ${binIdx} is too small after conversion and results in 0 token0 target`, ); } if (totalShares > 0n) { if (token0BalanceScaled <= 0n) { throw new Error(`bin ${binIdx} has zero token0 balance with non-zero shares`); } sharesToAdd = ceilDiv(targetAmount0Scaled * totalShares, token0BalanceScaled); } else { sharesToAdd = ceilDiv( targetAmount0Scaled * ONE_E18, immutables.initialScaledToken0PerShareE18, ); } } else { if (totalShares > 0n) { const totalValueInToken1Scaled = token1BalanceScaled + convertToken0ToToken1(token0BalanceScaled, 1n, 1n, priceE18); sharesToAdd = (targetValueInToken1Scaled * totalShares) / totalValueInToken1Scaled; } else { const token0Portion = MAX_UINT104 - curPosInBin; const token1Portion = curPosInBin; sharesToAdd = (targetValueInToken1Scaled * ONE_E18) / ((immutables.initialScaledToken1PerShareE18 * token1Portion + (immutables.initialScaledToken0PerShareE18 * token0Portion * priceE18) / ONE_E18) / MAX_UINT104); } } if (sharesToAdd === 0n) { sharesToAdd = 1n; } if (sharesToAdd > MAX_INT104) { throw new Error(`sharesToAdd (${sharesToAdd}) for bin ${binIdx} exceeds int104 max`); } const [amount0ScaledToAdd, amount1ScaledToAdd] = totalShares > 0n ? [ ceilDiv(sharesToAdd * token0BalanceScaled, totalShares), ceilDiv(sharesToAdd * token1BalanceScaled, totalShares), ] : isBelowBin ? [0n, ceilDiv(sharesToAdd * immutables.initialScaledToken1PerShareE18, ONE_E18)] : isAboveBin ? [ceilDiv(sharesToAdd * immutables.initialScaledToken0PerShareE18, ONE_E18), 0n] : (() => { const token0Portion = MAX_UINT104 - curPosInBin; const token1Portion = curPosInBin; return [ ceilDiv( sharesToAdd * immutables.initialScaledToken0PerShareE18 * token0Portion, ONE_E18 * MAX_UINT104, ), ceilDiv( sharesToAdd * immutables.initialScaledToken1PerShareE18 * token1Portion, ONE_E18 * MAX_UINT104, ), ] as const; })(); const amount0External = scaledPositiveDeltaToExternal( amount0ScaledToAdd, immutables.token0ScaleMultiplier, ); const amount1External = scaledPositiveDeltaToExternal( amount1ScaledToAdd, immutables.token1ScaleMultiplier, ); specAmount0 += amount0External; specAmount1 += amount1External; if (specAmount0 > MAX_INT128 || specAmount1 > MAX_INT128) { throw new Error("specAmount exceeds int128 max"); } deltas.push({ bin: binIdx, deltaShares: sharesToAdd, }); } const computed: ModifyLiquidityParams = { salt, deltas, specAmount0: applySpecAmountBuffer(specAmount0, specAmountBufferPercent), specAmount1: applySpecAmountBuffer(specAmount1, specAmountBufferPercent), }; return toModifyLiquidityArgs(computed); } export async function buildModifyLiquidityArgsForRemoval({ publicClient, stateViewAddress, poolAddress, owner, salt, specs, specAmountBufferPercent = 0, }: BuildModifyLiquidityArgsForRemovalParams): Promise { validateSpecAmountBufferPercent(specAmountBufferPercent); if (specs.length === 0) { return [salt, [], NO_SLIPPAGE_LIMIT, NO_SLIPPAGE_LIMIT]; } for (const spec of specs) { if (spec.percentageToRemove != null && spec.sharesToRemove != null) { throw new Error( `Bin ${spec.bin}: specify either percentageToRemove or sharesToRemove, not both`, ); } if (spec.percentageToRemove == null && spec.sharesToRemove == null) { throw new Error(`Bin ${spec.bin}: must specify either percentageToRemove or sharesToRemove`); } if ( spec.percentageToRemove != null && (spec.percentageToRemove <= 0 || spec.percentageToRemove > 100) ) { throw new Error(`Bin ${spec.bin}: percentageToRemove must be between 0 and 100`); } } const binIndices = specs.map((s) => s.bin); const positionShares = await getPositionBinSharesForBins( publicClient, stateViewAddress, poolAddress, owner, salt, binIndices, ); const sharesMap = new Map(positionShares.map((p) => [p.binIdx, p.shares])); const deltas: LiquidityDelta[] = []; for (const spec of specs) { const currentShares = sharesMap.get(spec.bin) ?? 0n; if (currentShares === 0n) continue; let sharesToRemove: bigint; if (spec.sharesToRemove != null) { sharesToRemove = spec.sharesToRemove > currentShares ? currentShares : spec.sharesToRemove; } else { sharesToRemove = spec.percentageToRemove === 100 ? currentShares : (currentShares * BigInt(Math.floor(spec.percentageToRemove! * 100))) / 10000n; } if (sharesToRemove > 0n) { deltas.push({ bin: spec.bin, deltaShares: -sharesToRemove, }); } } const computed: ModifyLiquidityParams = { salt, deltas, specAmount0: applySpecAmountBuffer(NO_SLIPPAGE_LIMIT, specAmountBufferPercent), specAmount1: applySpecAmountBuffer(NO_SLIPPAGE_LIMIT, specAmountBufferPercent), }; return toModifyLiquidityArgs(computed); } export async function buildModifyLiquidityArgsForPercentageRemoval({ publicClient, stateViewAddress, poolAddress, owner, salt, percentageToRemove, bins, lowerBin = -10, upperBin = 10, specAmountBufferPercent = 0, }: BuildModifyLiquidityArgsForPercentageRemovalParams): Promise { validateSpecAmountBufferPercent(specAmountBufferPercent); if (percentageToRemove <= 0 || percentageToRemove > 100) { throw new Error("percentageToRemove must be between 0 and 100"); } const binsWithShares = bins ? bins : ( await getPositionBinSharesRange( publicClient, stateViewAddress, poolAddress, owner, salt, lowerBin, upperBin, ) ).map((p) => p.binIdx); if (binsWithShares.length === 0) { return [salt, [], NO_SLIPPAGE_LIMIT, NO_SLIPPAGE_LIMIT]; } const specs: BinRemovalSpec[] = binsWithShares.map((bin) => ({ bin, percentageToRemove, })); return buildModifyLiquidityArgsForRemoval({ publicClient, stateViewAddress, poolAddress, owner, salt, specs, specAmountBufferPercent, }); } export function encodeModifyLiquidityCalldata( preparedModifyLiquidity: ModifyLiquidityParams | ModifyLiquidityArgs, ): Hex { const args = normalizeModifyLiquidityArgs(preparedModifyLiquidity); return encodeFunctionData({ abi: MetricOmmPoolAbi, functionName: "modifyLiquidity", args, }); } function validateSpecAmountBufferPercent(specAmountBufferPercent: number): void { if (!Number.isFinite(specAmountBufferPercent) || specAmountBufferPercent < 0) { throw new Error("specAmountBufferPercent must be a non-negative percent number"); } } function applySpecAmountBuffer(specAmount: bigint, specAmountBufferPercent: number): bigint { if (specAmountBufferPercent === 0 || specAmount === NO_SLIPPAGE_LIMIT) { return specAmount; } const bufferRatio = specAmountBufferPercent / 100; if (specAmount > 0n) { const multiplier = BigInt(Math.floor((1 + bufferRatio) * 1e18)); return (specAmount * multiplier) / BigInt(1e18); } if (specAmount < 0n) { const multiplier = BigInt(Math.floor((1 - bufferRatio) * 1e18)); if (multiplier <= 0n) { return 0n; } return (specAmount * multiplier) / BigInt(1e18); } return specAmount; } function isModifyLiquidityArgs( preparedOrArgs: ModifyLiquidityParams | ModifyLiquidityArgs, ): preparedOrArgs is ModifyLiquidityArgs { return Array.isArray(preparedOrArgs); } function toModifyLiquidityArgs( preparedModifyLiquidity: ModifyLiquidityParams, ): ModifyLiquidityArgs { return [ preparedModifyLiquidity.salt, preparedModifyLiquidity.deltas, preparedModifyLiquidity.specAmount0, preparedModifyLiquidity.specAmount1, ]; } function normalizeModifyLiquidityArgs( preparedOrArgs: ModifyLiquidityParams | ModifyLiquidityArgs, ): ModifyLiquidityArgs { if (isModifyLiquidityArgs(preparedOrArgs)) { return preparedOrArgs as ModifyLiquidityArgs; } return toModifyLiquidityArgs(preparedOrArgs); }