@lit-protocol/flows-clmm
v0.2.0
Published
Pure concentrated-liquidity math, calldata codecs, validators, and pricing helpers for Flows contract-call actions.
Readme
@lit-protocol/flows-clmm
Pure concentrated-liquidity math, calldata codecs, fail-closed validators, and USD
pricing helpers for Flows contract-call
actions (Uniswap v3 and Aerodrome/Velodrome Slipstream).
This is the package the certified onchain-call executor imports inside the
TEE to classify, validate, and price CL call plans — the same code runs
host-side for dry-runs, so what your proposer computes locally is byte-for-byte
what the seat enforces.
npm install @lit-protocol/flows-clmmESM only, Node ≥ 20, zero runtime dependencies.
Conventions (read first)
- Amounts and liquidity are raw base units (no decimal scaling), accepted as
string | bigint, returned as decimal strings — never JS numbers. - Human prices are decimal strings (e.g.
'2543.21'), parsed/emitted with 18 fractional digits of precision internally.sqrtPriceX96values are Q64.96 fixed-point (bigintor decimal string). - Ticks are plain integers in
[-887272, 887272], snapped totickSpacing. Ranges are upper-exclusive (tickLower <= tick < tickUpper). - Profiles:
'univ3'keys pools byfee(uint24, e.g.500);'slipstream'keys pools bytickSpacing(int24, e.g.100). Calldata layouts differ — the codec handles both. - Addresses are lowercased
0x…40-hexstrings in all decoded output. - Everything fails closed: unrecognized calldata classifies to
null, validators return violations instead of guessing, pricing throws or returnsnullrather than mis-pricing.
Math (sqrtRatioAtTick, ranges, amounts)
| Export | Signature | Notes |
|---|---|---|
| sqrtRatioAtTick | (tick: number) => bigint | Uniswap-v3 TickMath port; sqrtPriceX96 at tick. Throws outside the tick domain. |
| tickToPrice | (tick, dec0, dec1) => string | Human price of token0 denominated in token1 (token1-per-token0), decimals-adjusted. |
| priceToTick | (price: string, tickSpacing, dec0, dec1) => number | Inverse of tickToPrice; binary-search then snap to tickSpacing. |
| nearestUsableTick | (tick, tickSpacing) => number | Round to the nearest spacing multiple, clamped to the usable range. |
| centeredRange | (currentTick, widthTicks, tickSpacing) => { tickLower, tickUpper } | Build a ~widthTicks-wide range centered on currentTick. |
| isInRange | (currentTick, tickLower, tickUpper) => boolean | Upper-exclusive, Uniswap convention. |
| getAmountsForLiquidity | (sqrtPriceX96, tickLower, tickUpper, liquidity) => { amount0, amount1 } | Raw token amounts a liquidity figure represents at the current price. |
| getLiquidityForAmounts | (sqrtPriceX96, tickLower, tickUpper, amount0, amount1) => string | Max liquidity mintable from raw amounts. |
Codec (encodeCall / classifyCall)
type ClmmProfile = 'univ3' | 'slipstream';
type TargetRole = 'positionManager' | 'gauge' | 'erc20' | 'erc721' | 'pool' | 'router';
type CallShape =
| 'mint' | 'increaseLiquidity' | 'decreaseLiquidity' | 'collect' | 'burn'
| 'erc20Approve' | 'erc721Approve' | 'erc721SafeTransferFrom'
| 'gaugeDeposit' | 'gaugeWithdraw' | 'gaugeGetReward' | 'exactInputSingle';encodeCall(profile, shape, args) => { to, data, value }— encode a CL / position-manager / router / gauge / ERC call. Slipstream mints key by int24tickSpacingand appendsqrtPriceX96; univ3 mints key by uint24fee. Guards reject invalid addresses, out-of-range ints, and refuseMaxUint256ERC-20 approvals outright. Args are shape-dependent and named exactly like the ABI fields (amount0Desired,amountOutMinimum, …).classifyCall(profile, to, dataHex, { role }) => ClassifiedCall | null— the fail-closed decoder. The role picks the decoder (ERC-20 and ERC-721approveshare a selector), and only static-layout calls decode; arouterrole accepts only the profile'sexactInputSingle. Anything else →null.ClassifiedCallis a discriminated union onkind(one variant perCallShape) carrying the decoded fields — amounts as decimal strings, ticks as numbers, addresses lowercased, plus the 10-charselector.SELECTORS— the pinned 4-byte selector table (e.g.univ3Mint: 0x88316456,slipstreamMint: 0xb5007d1f,slipstreamExactInputSingle: 0xa026383e).
Validation (validateLpPlan)
validateLpPlan(plan: LpPlan, intent: LpIntent, constraints: LpConstraints, poolState: LpPoolState)
=> { ok: boolean; violations: string[]; classified: ClassifiedCall[] }Fail-closed policy validator for a whole call plan: classifies every tx and
enforces target/selector allowlists, no native value, recipient == pkpAddress,
deadline bounds, slippage floors (amountMin / amountOutMinimum vs desired /
expectedAmountOut), pinned pool tuples, range width and center-vs-TWAP bounds,
bounded ERC-20 approvals (spender == main target, amount ≤ decoded need), ERC-721
approval to the gauge only, and tokenId consistency. See the exported LpPlan,
LpIntent, LpConstraints, LpPoolState interfaces for the exact field set.
Pricing (TWAP guards + USD notional)
| Export | Signature | Notes |
|---|---|---|
| twapTick | (observations: { tickCumulative, secondsAgo }[], windowSec) => number | Time-weighted average tick from ≥2 observe() points. |
| spotVsTwapDeviationBps | (slot0Tick, twap) => number | Deviation in basis points, computed in tick space (scale-invariant), ceiling-rounded so a guard can only refuse earlier. |
| poolSpotUsd | (poolState, quoteSide, quoteDecimals, quoteSideUsd = '1') => string | USD spot price of the non-quote token. |
| positionValueUsd | (position, poolState, quoteSide, quoteSideUsd = '1') => string | USD value of both legs of an LP position. |
| classifiedCallNotionalUsd | (call, poolState, quoteSide, swapTokenInSide?, quoteSideUsd = '1') => string \| null | USD notional of a classified call: mint/increase price the desired amounts, decreaseLiquidity the minima, collect/gaugeGetReward → '0', swaps price amountIn (and require swapTokenInSide), everything else → null. |
quoteSide ('token0' | 'token1', named stableSide in the signatures for
compatibility) is the pool side with a known USD price, and quoteSideUsd is
that price as a decimal string — '1' for an allowlisted stable (the classic
rule), or a derived price to route pools with no stable side: price WETH from
its own WETH/USDC reference pool, then pass quoteSideUsd = <wethUsd> to price a
LITKEY/WETH pool. PricingPoolState is
{ sqrtPriceX96, tick, token0Decimals, token1Decimals }.
Example: Slipstream mint → decrease → collect
import {
centeredRange, encodeCall, getLiquidityForAmounts, nearestUsableTick,
} from '@lit-protocol/flows-clmm';
const NPM = '0x827922686190790b37229fd06084350e74485b72'; // Aerodrome Slipstream NPM (Base)
const WETH = '0x4200000000000000000000000000000000000006';
const LITKEY = '0xf732a566121fa6362e9e0fbdd6d66e5c8c925e49';
const PKP = '0xYourFlowsWallet';
const TICK_SPACING = 200;
const deadline = Math.floor(Date.now() / 1000) + 900;
// 1. Range around the current tick (read slot0/observe on-chain yourself).
const { tickLower, tickUpper } = centeredRange(currentTick, 4000, TICK_SPACING);
// 2. Bounded approvals + mint. Token order must be the pool's sorted order.
const callPlan = [
encodeCall('slipstream', 'erc20Approve', { to: WETH, spender: NPM, amount: wethAmount }),
encodeCall('slipstream', 'erc20Approve', { to: LITKEY, spender: NPM, amount: litkeyAmount }),
encodeCall('slipstream', 'mint', {
to: NPM, token0: WETH, token1: LITKEY, tickSpacing: TICK_SPACING,
tickLower, tickUpper,
amount0Desired: wethAmount, amount1Desired: litkeyAmount,
amount0Min: wethMin, amount1Min: litkeyMin, // desired × (10000 − slippageBps) / 10000
recipient: PKP, deadline, sqrtPriceX96: 0,
}),
];
// 3. Later: atomic exit — decrease binds the intent, collect rides as follower.
const exitPlan = [
encodeCall('slipstream', 'decreaseLiquidity', {
to: NPM, tokenId, liquidity, amount0Min, amount1Min, deadline,
}),
encodeCall('slipstream', 'collect', {
to: NPM, tokenId, recipient: PKP,
amount0Max: '340282366920938463463374607431768211455', // MaxUint128
amount1Max: '340282366920938463463374607431768211455',
}),
];Each { to, data, value } goes into a Flows contract-call proposal's
callPlan; the certified executor re-classifies, re-validates, re-prices, and
re-judges the plan in-TEE before signing. See the Flows docs
(verb-reference.md, manifest-reference.md, and the custom DeFi tutorial) for
the manifest/policy side.
