Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
3 changes: 3 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export {
createPositions,
createPositionsUSD,
createDefaultPositions,
createLiquidPositionsUSD,
shiftPositions,
describePositions,
DEFAULT_TRANCHES_USD,
encodeStaticFeePoolData,
Expand Down
2 changes: 2 additions & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export {
createPositions,
createPositionsUSD,
createDefaultPositions,
createLiquidPositionsUSD,
shiftPositions,
describePositions,
DEFAULT_TRANCHES_USD,
} from "./positions";
Expand Down
84 changes: 84 additions & 0 deletions src/utils/positions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import { getTickFromMarketCapETH, getTickFromMarketCapUSD } from "./tick-math";
import { POOL_POSITIONS, DEFAULTS, type PoolPosition } from "../constants";

// ── Types ────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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.
Expand Down
69 changes: 69 additions & 0 deletions test/unit/positions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
createPositions,
createPositionsUSD,
createDefaultPositions,
createLiquidPositionsUSD,
shiftPositions,
describePositions,
DEFAULT_TRANCHES_USD,
} from "../../src/utils/positions";
Expand Down Expand Up @@ -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");
});
});
Loading