Logarithmic Tick System
Biatec CLAMM uses a canonical logarithmic tick grid: an absolute set of price boundaries, fixed once and for all, that every integrator (and the Biatec DEX frontend) snaps to. A bin's width is roughly a fixed fraction of the price, so a sensible bin exists at any magnitude (both at 1000 and at 0.000001). Rather than asking integrators to reason about a raw numeric precision, the SDK exposes three named tick widths — wide, normal, and narrow — plus helpers to size, snap, enumerate and select between them.
Why Ticks Matter
Concentrated liquidity positions are defined by a [priceMin, priceMax] range (see docs/basic-use-cases.md). If every integrator picked arbitrary bounds, liquidity would fragment across many near-duplicate pools instead of concentrating into shared ones. Snapping prices to a shared tick grid before creating a pool or setting a range keeps liquidity concentrated and makes ranges composable across UIs.
That only works if the grid is absolute: the boundaries must not depend on the current market price, on the price window a UI happens to display, or on any previously computed boundary. Otherwise the same position created on two different days lands in two different pools. The canonical grid below guarantees that the bin around a price (say, [1000, 2000] around 1500 at wide) is always the same one.
The Grid
The grid is decade-periodic: one table of mantissas in [1, 10] per tick width, repeated in every decade [10^k, 10^(k+1)). The table follows the log10 tick rule — the tick at a price is 10^-precision of the price rounded to one significant digit — so a bin is always roughly the same fraction of the price:
| Tick type | Precision | Rule | Boundaries in one decade (× 10^k) |
|---|---|---|---|
wide | 0 | anchors 1, 2, 5 (~100 % steps) | 1, 2, 5, 10 |
normal | 1 | tick ≈ 10 % of the price | 1, 1.1, 1.2, 1.3, 1.4, 1.6, 1.8, 2, 2.2, 2.4, 2.7, 3, 3.3, 3.6, 4, 4.4, 5, 6, 7, 8, 9, 10 (21 bins) |
narrow | 2 | tick ≈ 1 % of the price | 1, 1.01, …, 1.49, 1.5, 1.52, …, 2.48, 2.52, 2.55, …, 10 |
For precision ≥ 1 the table is generated by walking one decade from its start: at a boundary b the tick is round(b / 10^precision) mantissa units (the price's leading digit times 10^-precision); the next candidate b + tick is snapped down to a multiple of the tick that applies at the candidate, and a candidate that would leave a bin narrower than that tick is merged into it (0.9 → 1.0, not 0.9 → 0.99). Because the walk always starts at 1 × 10^k, every decade and every caller gets the same table — 0.9 → 1 is exactly one normal tick, 1500 sits in the normal bin [1400, 1600] and in the wide bin [1000, 2000], whatever the current price. wide is the one special case: a ≈100 % tick cannot be decade-periodic, so it is simply the 1, 2, 5 anchors. tickGridDecadeMantissas(precision) returns the table.
Every boundary is a short decimal, so it round-trips exactly through JavaScript numbers: the implementation works on integer mantissa units and a decimal exponent and never accumulates floating point additions.
Lower precision → wider ticks. normal is the recommended default (DEFAULT_TICK_TYPE).
import { TICK_TYPES, DEFAULT_TICK_TYPE, TickType } from 'biatec-concentrated-liquidity-amm';
TICK_TYPES; // ['wide', 'normal', 'narrow'] — build a selector/toggle from this
DEFAULT_TICK_TYPE; // 'normal'
Core Functions
All of these are exported from the package root (biatec-concentrated-liquidity-amm).
getTickSize(price, tickType)
Width of the canonical bin containing price — the exact distance to the next boundary. This is the main entry point for sizing UI steppers.
import { getTickSize } from 'biatec-concentrated-liquidity-amm';
getTickSize(1500, 'wide'); // 1000 (bin [1000, 2000))
getTickSize(3000, 'wide'); // 3000 (bin [2000, 5000))
getTickSize(0.9, 'normal'); // 0.1 (bin [0.9, 1))
getTickSize(1500, 'normal'); // 200 (bin [1400, 1600))
getTickSize(10000, 'normal'); // 1000
getTickSize(0.001, 'narrow'); // 0.00001
getTickDecimals(price, tickType)
Number of decimals to display for a price at a given tick type — use this to format input fields and labels consistently with the tick grid.
import { getTickDecimals } from 'biatec-concentrated-liquidity-amm';
getTickDecimals(0.001, 'narrow'); // 5
getTickDecimals(10000, 'normal'); // 0
snapPriceToTick(price, tickType, rounding?)
Snaps an arbitrary price onto the canonical grid. Call this whenever a user types a free-form price, and before creating a pool, so the resulting bounds land on ticks shared across the whole ecosystem instead of a one-off value. A price already on the grid is returned unchanged in every rounding mode.
rounding accepts 'nearest' (default, ties round up), 'down', or 'up'.
import { snapPriceToTick } from 'biatec-concentrated-liquidity-amm';
snapPriceToTick(0.94, 'normal'); // 0.9 (nearest)
snapPriceToTick(0.96, 'normal'); // 1
snapPriceToTick(1500, 'wide'); // 2000 (nearest of 1000 / 2000)
snapPriceToTick(1500, 'wide', 'down'); // 1000
snapPriceToTick(10123, 'normal', 'up'); // 11000
snapPriceToTick(10123, 'normal', 'down'); // 10000
Use 'down' for a range's lower bound and 'up' for its upper bound so the resulting range always contains the user's original input:
import { snapPriceToTick, TickType } from 'biatec-concentrated-liquidity-amm';
function buildRange(priceLow: number, priceHigh: number, tickType: TickType) {
return {
priceMin: snapPriceToTick(priceLow, tickType, 'down'),
priceMax: snapPriceToTick(priceHigh, tickType, 'up'),
};
}
buildRange(0.91, 1.02, 'narrow'); // { priceMin: 0.91, priceMax: 1.02 }
buildRange(1200, 1800, 'wide'); // { priceMin: 1000, priceMax: 2000 }
tickGridBoundaries(from, to, precision, maxCount?)
All canonical boundaries covering [from, to]: the first is the boundary at or below from, the last the boundary at or above to. Because the grid is absolute, the same window always yields the same boundaries, a wider window yields a superset, and two overlapping windows agree on every shared boundary. Use it to build a price distribution, a depth chart, or a range slider. Returns [] for an unusable window and never more than maxCount (default 1000) boundaries.
import { tickGridBoundaries, precisionForTickType } from 'biatec-concentrated-liquidity-amm';
tickGridBoundaries(536, 2140, precisionForTickType('wide')); // [500, 1000, 2000, 5000]
tickGridBoundaries(1080, 2160, precisionForTickType('wide')); // [1000, 2000, 5000]
tickGridBoundaries(0.9, 1.0, precisionForTickType('normal')); // [0.9, 1]
tickGridBoundaries(2, 3, precisionForTickType('normal')); // [2, 2.2, 2.4, 2.7, 3]
suggestTickTypeForRange(low, high, options?)
Given an existing [low, high] price range (for example a pool's priceMin/priceMax), picks the widest tick type on whose grid the (snapped) range still spans between minBins (default 1) and maxBins (default 40) bins. Returns null for a degenerate range (high <= low, i.e. a single-price "wall" position) or when no tick width fits within the bin bounds.
This is the right helper for pre-filling an "add liquidity" form from an existing pool: because the default minBins is 1, a pool's exact [min, max] maps back to a single bin at its native (widest fitting) width, so the form keeps the exact range and adds to that same pool instead of quietly creating a new, finer-grained one. From there the user can still slide into neighbouring bins.
import { suggestTickTypeForRange } from 'biatec-concentrated-liquidity-amm';
suggestTickTypeForRange(1000, 2000); // 'wide' (exactly one wide bin)
suggestTickTypeForRange(0.9, 1.0); // 'normal' (exactly one normal tick of 0.1)
suggestTickTypeForRange(1, 1); // null (wall / single-price position)
// Tune how many bins the range should represent:
suggestTickTypeForRange(1000, 2000, { minBins: 2, maxBins: 40 }); // 'normal'
tickTypeForPrecision(precision) / precisionForTickType(tickType)
Convert between a raw numeric precision (e.g. one derived from an asset's decimal count) and the named TickType. tickTypeForPrecision snaps precisions that don't map exactly onto wide/normal/narrow to the nearest one.
import { tickTypeForPrecision, precisionForTickType } from 'biatec-concentrated-liquidity-amm';
tickTypeForPrecision(4); // 'narrow' (snapped to the nearest known precision)
tickTypeForPrecision(1); // 'normal' (exact match)
precisionForTickType('normal'); // 1
tickDecimals(tick)
Number of decimal places needed to represent an already-computed tick value (as opposed to getTickDecimals, which computes decimals for a price + tick type in one step).
import { tickDecimals } from 'biatec-concentrated-liquidity-amm';
tickDecimals(100); // 0
tickDecimals(0.05); // 2
tickDecimals(0.000001); // 6
Grid Primitives
The functions above are built on lower-level, exported primitives that walk the canonical grid directly. All of them accept a raw numeric precision and return 0 for an unusable (non-finite or non-positive) price.
tickGridBoundaryBelow(price, precision)— start of the bin containingprice(the largest boundary<= price).tickGridBoundaryAbove(price, precision)— end of the bin containingprice(the smallest boundary> price).nextTickGridBoundary(boundary, precision)/prevTickGridBoundary(boundary, precision)— step one boundary up or down the grid (crossing decades correctly).tickGridDecadeMantissas(precision)— the decade table ([1, 1.1, …, 9, 10]at precision 1).tickGridWidthAt(price, precision)— the bin's exact width (above - below);cleanLogTick/getTickSizeare aliases keyed by tick type.TICK_GRID_ANCHORS([1, 2, 5]) andMAX_TICK_GRID_BOUNDARIES(1000, the default walk cap).initPriceDecimals(price, precision?)— fixed-pointbigintdescriptor of the bin containing a price:fitPriceis the bin start andtickits width (both scaled byTICK_FIXED_SCALE = 10n ** 18n), so walkingfitPrice + tickreproduces the canonical boundaries from any starting price. Kept for integrators already consuming thebigintform; prefer the number-based helpers in new code.priceTickDecimals(price, precision?)— number of decimal places meaningful for a price at a raw numeric precision (default4), for formatting.toFixedBigInt(value)/fromFixedBigInt(value)— convert a plain JSnumberprice to/from the fixed-pointbigintrepresentation, exactly for short decimals.
import {
tickGridBoundaryBelow,
tickGridBoundaryAbove,
initPriceDecimals,
toFixedBigInt,
fromFixedBigInt,
} from 'biatec-concentrated-liquidity-amm';
tickGridBoundaryBelow(1500, 0); // 1000
tickGridBoundaryAbove(1500, 0); // 2000
const { tick, fitPrice } = initPriceDecimals(toFixedBigInt(1500), 0n);
fromFixedBigInt(fitPrice); // 1000
fromFixedBigInt(tick); // 1000
Putting It Together
A typical UI flow for creating a concentrated liquidity position:
import {
TICK_TYPES,
DEFAULT_TICK_TYPE,
TickType,
getTickSize,
getTickDecimals,
snapPriceToTick,
} from 'biatec-concentrated-liquidity-amm';
// 1) Render TICK_TYPES as a selector (e.g. three buttons), defaulting to DEFAULT_TICK_TYPE.
let tickType: TickType = DEFAULT_TICK_TYPE;
// 2) Use the current price to size the stepper and decide input precision.
const currentPrice = 0.94;
const step = getTickSize(currentPrice, tickType); // 0.1
const decimals = getTickDecimals(currentPrice, tickType); // 1
// 3) As the user types priceMin/priceMax, snap onto the shared grid before submitting.
const priceMin = snapPriceToTick(0.82, tickType, 'down'); // 0.8
const priceMax = snapPriceToTick(1.15, tickType, 'up'); // 1.2
// 4) Feed the snapped, base-scale values into clammCreateSender / an add-liquidity call
// (see docs/basic-use-cases.md for the full pool creation and liquidity flows).
And for pre-filling an "add liquidity" form against an existing pool:
import { suggestTickTypeForRange, snapPriceToTick } from 'biatec-concentrated-liquidity-amm';
function prefillFromPool(poolPriceMin: number, poolPriceMax: number) {
const tickType = suggestTickTypeForRange(poolPriceMin, poolPriceMax);
if (!tickType) {
// Wall / constant-price pool — no tick grid applies, keep the exact bounds.
return { priceMin: poolPriceMin, priceMax: poolPriceMax, tickType: null };
}
return {
priceMin: snapPriceToTick(poolPriceMin, tickType, 'down'),
priceMax: snapPriceToTick(poolPriceMax, tickType, 'up'),
tickType,
};
}
Recommended Reading
docs/basic-use-cases.mdfor pool creation and liquidity flows that consume these snapped prices.docs/liquidity-rounding.mdfor how base-scale (1e9) rounding interacts with on-chain amounts.- The API reference for the full generated signatures of every function in this module.