diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ed5065..17c7729 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to `liquid-sdk` will be documented in this file. ## [1.7.4] - 2026-04-23 +### Added +- `EXTERNAL.DIEM` — the DIEM token address on Base, for DIEM-paired token launches. +- `shiftPositions(positions, shiftBy)` — re-anchor a fixed-shape position layout to a different starting tick, preserving `positionBps`. +- `createLiquidPositionsUSD(startingMarketCapUSD, pairedTokenPriceUSD, tickSpacing?)` — the canonical 5-position "Liquid" layout re-anchored to a starting market cap, denominated in the *paired token's* USD price. Works for any pair token (WETH or DIEM); unlike `createDefaultPositions` (3-tranche), it preserves the exact `POOL_POSITIONS.Liquid` curve `deployToken()` uses by default. + ### Fixed - `deployToken` gas estimation no longer silently swallows on-chain revert errors. Previously a failing `eth_estimateGas` (e.g., insufficient msg.value, paused factory, pre-flight check revert) would be caught and replaced with a 6M fallback, causing the tx to be broadcast and burn real gas for nothing. Now a revert-shaped error bubbles to the caller; only transport-level failures fall back. Non-revert fallbacks log a warning via `console.warn`. diff --git a/src/constants.ts b/src/constants.ts index b5af27a..dcf8324 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -36,6 +36,9 @@ export const ADDRESSES = { export const EXTERNAL = { POOL_MANAGER: "0x498581fF718922c3f8e6A244956aF099B2652b2b" as Address, WETH: "0x4200000000000000000000000000000000000006" as Address, + /** DIEM — Liquid's intelligence-economy token. Also a pair token for + * agent-token launches (see `createLiquidPositionsUSD`). */ + DIEM: "0xF4d97F2da56e8c3098f3a8D538DB630A2606a024" as Address, UNIVERSAL_ROUTER: "0x6fF5693b99212Da76ad316178A184AB56D299b43" as Address, PERMIT2: "0x000000000022D473030F116dDEE9F6B43aC78BA3" as Address, diff --git a/src/index.ts b/src/index.ts index 5cf93b7..6e17bdd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,8 @@ export { createPositions, createPositionsUSD, createDefaultPositions, + createLiquidPositionsUSD, + shiftPositions, describePositions, DEFAULT_TRANCHES_USD, encodeStaticFeePoolData, diff --git a/src/utils/index.ts b/src/utils/index.ts index 8e66ee3..8bc1d72 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -10,6 +10,8 @@ export { createPositions, createPositionsUSD, createDefaultPositions, + createLiquidPositionsUSD, + shiftPositions, describePositions, DEFAULT_TRANCHES_USD, } from "./positions"; diff --git a/src/utils/positions.ts b/src/utils/positions.ts index 163fcb8..809e1b2 100644 --- a/src/utils/positions.ts +++ b/src/utils/positions.ts @@ -7,6 +7,7 @@ */ import { getTickFromMarketCapETH, getTickFromMarketCapUSD } from "./tick-math"; +import { POOL_POSITIONS, DEFAULTS, type PoolPosition } from "../constants"; // ── Types ──────────────────────────────────────────────────────────── @@ -199,6 +200,89 @@ export function createDefaultPositions( }; } +/** + * Shift every tick in a position layout by a fixed amount, preserving the + * positionBps splits. The primitive behind `createLiquidPositionsUSD` — it + * re-anchors a fixed-shape layout (e.g. `POOL_POSITIONS.Liquid`) to a + * different starting tick. + * + * `shiftBy` should be a multiple of the pool's tick spacing so the shifted + * ticks stay aligned. A difference of two aligned ticks is always aligned, + * so deriving it as `targetTick - sourceBottomTick` is safe. + * + * @param positions - Source positions (e.g. `POOL_POSITIONS.Liquid`) + * @param shiftBy - Ticks to add to every tickLower/tickUpper (may be negative) + * @returns A fresh array — the source is not mutated + */ +export function shiftPositions( + positions: readonly PoolPosition[], + shiftBy: number, +): PoolPosition[] { + return positions.map((p) => ({ + tickLower: p.tickLower + shiftBy, + tickUpper: p.tickUpper + shiftBy, + positionBps: p.positionBps, + })); +} + +/** + * Build the canonical 5-position "Liquid" layout (10/50/15/20/5) re-anchored + * to a starting market cap, denominated in the *paired token's* USD price. + * + * Works for any pair token — pass the price of whatever the pool is paired + * against: + * - WETH-paired: `createLiquidPositionsUSD(20_000, ethPriceUSD)` + * - DIEM-paired: `createLiquidPositionsUSD(20_000, diemPriceUSD)` + * + * Unlike `createDefaultPositions` (a 3-tranche layout), this preserves the + * exact shape of `POOL_POSITIONS.Liquid` — the same curve `deployToken()` + * uses by default — just shifted so its bottom sits at the requested start. + * + * @param startingMarketCapUSD - Initial market cap in USD + * @param pairedTokenPriceUSD - USD price of the token the pool is paired against + * @param tickSpacing - Tick spacing (default 200) + * @returns Position arrays + `tickIfToken0IsLiquid`, ready to spread into `deployToken()` + * + * @example + * ```ts + * import { LiquidSDK, EXTERNAL, createLiquidPositionsUSD } from "liquid-sdk"; + * + * const positions = createLiquidPositionsUSD(20_000, diemPriceUSD); + * await sdk.deployToken({ + * name: "Agent Token", + * symbol: "AGENT", + * pairedToken: EXTERNAL.DIEM, + * ...positions, + * }); + * ``` + */ +export function createLiquidPositionsUSD( + startingMarketCapUSD: number, + pairedTokenPriceUSD: number, + tickSpacing: number = 200, +): PositionArrays & { tickIfToken0IsLiquid: number } { + if (pairedTokenPriceUSD <= 0) { + throw new Error("pairedTokenPriceUSD must be positive"); + } + + const startingTick = getTickFromMarketCapUSD( + startingMarketCapUSD, + pairedTokenPriceUSD, + tickSpacing, + ); + const shifted = shiftPositions( + POOL_POSITIONS.Liquid, + startingTick - DEFAULTS.TICK_IF_TOKEN0_IS_LIQUID, + ); + + return { + tickLower: shifted.map((p) => p.tickLower), + tickUpper: shifted.map((p) => p.tickUpper), + positionBps: shifted.map((p) => p.positionBps), + tickIfToken0IsLiquid: startingTick, + }; +} + /** * Describe positions as human-readable market cap ranges. * Useful for displaying position info in UIs. diff --git a/test/unit/positions.test.ts b/test/unit/positions.test.ts index 0e1053f..d0c1569 100644 --- a/test/unit/positions.test.ts +++ b/test/unit/positions.test.ts @@ -3,6 +3,8 @@ import { createPositions, createPositionsUSD, createDefaultPositions, + createLiquidPositionsUSD, + shiftPositions, describePositions, DEFAULT_TRANCHES_USD, } from "../../src/utils/positions"; @@ -260,3 +262,70 @@ describe("POOL_POSITIONS presets", () => { } }); }); + +describe("shiftPositions", () => { + it("shifts every tick by the given amount", () => { + const shifted = shiftPositions(POOL_POSITIONS.Liquid, 1000); + expect(shifted[0].tickLower).toBe(POOL_POSITIONS.Liquid[0].tickLower + 1000); + expect(shifted[0].tickUpper).toBe(POOL_POSITIONS.Liquid[0].tickUpper + 1000); + }); + + it("preserves positionBps", () => { + const shifted = shiftPositions(POOL_POSITIONS.Liquid, -4000); + expect(shifted.map((p) => p.positionBps)).toEqual( + POOL_POSITIONS.Liquid.map((p) => p.positionBps), + ); + }); + + it("keeps ticks aligned when shifted by an aligned amount", () => { + const shifted = shiftPositions(POOL_POSITIONS.Liquid, 2000); + for (const p of shifted) { + expect(p.tickLower % 200 === 0).toBe(true); + expect(p.tickUpper % 200 === 0).toBe(true); + } + }); + + it("does not mutate the source", () => { + const before = POOL_POSITIONS.Liquid[0].tickLower; + shiftPositions(POOL_POSITIONS.Liquid, 5000); + expect(POOL_POSITIONS.Liquid[0].tickLower).toBe(before); + }); +}); + +describe("createLiquidPositionsUSD", () => { + it("returns the 5-position Liquid layout (10/50/15/20/5)", () => { + const result = createLiquidPositionsUSD(20_000, 2070); + expect(result.tickLower).toHaveLength(5); + expect(result.positionBps).toEqual([1000, 5000, 1500, 2000, 500]); + }); + + it("re-anchors so tickIfToken0IsLiquid equals the first tickLower", () => { + const result = createLiquidPositionsUSD(20_000, 2070); + expect(result.tickIfToken0IsLiquid).toBe(result.tickLower[0]); + }); + + it("preserves the Liquid curve shape (tick gaps unchanged)", () => { + const result = createLiquidPositionsUSD(50_000, 1500); + const gaps = (a: number[]) => a.map((t) => t - a[0]); + expect(gaps(result.tickLower)).toEqual( + gaps(POOL_POSITIONS.Liquid.map((p) => p.tickLower)), + ); + }); + + it("adapts the starting tick to the paired-token price", () => { + const cheap = createLiquidPositionsUSD(20_000, 1000); + const dear = createLiquidPositionsUSD(20_000, 4000); + expect(cheap.tickIfToken0IsLiquid).not.toBe(dear.tickIfToken0IsLiquid); + }); + + it("all ticks aligned to tick spacing", () => { + const result = createLiquidPositionsUSD(20_000, 2070); + for (const t of [...result.tickLower, ...result.tickUpper]) { + expect(t % 200 === 0).toBe(true); + } + }); + + it("throws for non-positive paired-token price", () => { + expect(() => createLiquidPositionsUSD(20_000, 0)).toThrow("must be positive"); + }); +});