From 1baaf2cf320e9313bee62e7b40ac7b065fce2cae Mon Sep 17 00:00:00 2001 From: Luke Macauley Date: Fri, 10 Jul 2026 14:49:53 +0200 Subject: [PATCH 1/2] feat(swap): exact multi-tick taker swap simulation simulateTakerSwap assumes the pool's current active liquidity holds across the whole trade. That is wrong in both directions once an order crosses an initialized tick, and not merely optimistic: on the live mainnet market a band of liquidity above spot makes a 10-perp long cost 25.4% impact where the flat model predicts 68.5%, while the band's upper edge means the pool runs dry at 10.302 perp where the flat model claims capacity of 24.6. Overstated capacity is the costly half -- the UI quotes orders that revert on-chain with InsufficientLiquidityToFill. A perp's fill is settled by a real Uniswap v4 PoolManager.swap against a hookless, fee-0 pool, so the exact fill can be reproduced by walking the same ticks the chain walks: - tickMath.ts exact bigint port of TickMath.getSqrtPriceAtTick - swapMath.ts SqrtPriceMath/SwapMath primitives, zero-fee token0 paths - swapExact.ts simulateTakerSwapExact, maxFillablePerpDelta - poolTicks.ts fetchPoolTickMap via batched PoolManager.extsload Ticks are read straight from the PoolManager rather than the StateView periphery, which is deployed at a different address per chain and not at all on some; extsload also batches a whole tick map into one eth_call. getSqrtPriceAtTick is monotonic, so the walker locates the current tick by comparing sqrt prices and needs no getTickAtSqrtPrice port. The map only changes when a maker mints or burns, so callers cache it and revalidate with assertTickMapFresh: the sum of liquidityNet at or below the current tick must equal the pool's streamed active liquidity. That check is free and fails loudly rather than quoting from a stale map. Validated against Uniswap's own V4Quoter, which quotes by running the same PoolManager swap: the walker matches it to the wei on both sides across sizes, and maxFillablePerpDelta is exact -- max fills, max + 1 reverts. swapQuoter.test.ts re-derives this against the live chain (eth_call only, skipped without RPC_URL) so it keeps its teeth as liquidity moves. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/__tests__/integration/swapQuoter.test.ts | 206 +++++++++++++++++ src/__tests__/unit/swapExact.test.ts | 177 +++++++++++++++ src/utils/index.ts | 4 + src/utils/poolTicks.ts | 172 ++++++++++++++ src/utils/swap.ts | 4 +- src/utils/swapExact.ts | 226 +++++++++++++++++++ src/utils/swapMath.ts | 149 ++++++++++++ src/utils/tickMath.ts | 90 ++++++++ 8 files changed, 1026 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/integration/swapQuoter.test.ts create mode 100644 src/__tests__/unit/swapExact.test.ts create mode 100644 src/utils/poolTicks.ts create mode 100644 src/utils/swapExact.ts create mode 100644 src/utils/swapMath.ts create mode 100644 src/utils/tickMath.ts diff --git a/src/__tests__/integration/swapQuoter.test.ts b/src/__tests__/integration/swapQuoter.test.ts new file mode 100644 index 0000000..6ec66d9 --- /dev/null +++ b/src/__tests__/integration/swapQuoter.test.ts @@ -0,0 +1,206 @@ +import { type Address, createPublicClient, type Hex, http, type PublicClient } from "viem"; +import { arbitrum, arbitrumSepolia } from "viem/chains"; +import { beforeAll, describe, expect, it } from "vitest"; +import { PERP_ABI } from "../../abis/perp"; +import { fetchPoolTickMap } from "../../utils/poolTicks"; +import { + assertTickMapFresh, + maxFillablePerpDelta, + type PoolTickMap, + simulateTakerSwapExact, +} from "../../utils/swapExact"; + +/** + * Differential test: `simulateTakerSwapExact` vs Uniswap's own V4Quoter, run + * against a live perp pool. + * + * The perp settles its fill through `PoolManager.swap`, and V4Quoter quotes by + * running that same swap under `unlock` and reverting out the result. So the + * quoter is ground truth for the fill, and the walker has to match it to the + * wei — this is the check that lets the exact simulator be trusted on the money + * path. Expectations are derived from the chain at runtime rather than pinned, + * so the test keeps its teeth as the pool's liquidity moves. + * + * Read-only (`eth_call`). Set `RPC_URL` to enable; otherwise the suite skips. + */ + +const RPC_URL = process.env.RPC_URL; +const PERP_ADDRESS = (process.env.PERP_ADDRESS ?? + "0x8ac0179073a9eb5aaee58e5ebe9882066b9e7b6c") as Address; + +/** Uniswap v4 periphery. Test-only: the SDK reads the PoolManager directly. */ +const V4_QUOTER_BY_CHAIN: Record = { + [arbitrum.id]: "0x3972c00f7ed4885e145823eb7c655375d275a1c5", + [arbitrumSepolia.id]: "0x7dE51022d70A725b508085468052E25e22b5c4c9", +}; + +const QUOTER_ABI = [ + { + type: "function", + name: "quoteExactOutputSingle", + stateMutability: "nonpayable", + inputs: [ + { + name: "params", + type: "tuple", + components: [ + { + name: "poolKey", + type: "tuple", + components: [ + { name: "currency0", type: "address" }, + { name: "currency1", type: "address" }, + { name: "fee", type: "uint24" }, + { name: "tickSpacing", type: "int24" }, + { name: "hooks", type: "address" }, + ], + }, + { name: "zeroForOne", type: "bool" }, + { name: "exactAmount", type: "uint128" }, + { name: "hookData", type: "bytes" }, + ], + }, + ], + outputs: [ + { name: "amountIn", type: "uint256" }, + { name: "gasEstimate", type: "uint256" }, + ], + }, + { + type: "function", + name: "quoteExactInputSingle", + stateMutability: "nonpayable", + inputs: [ + { + name: "params", + type: "tuple", + components: [ + { + name: "poolKey", + type: "tuple", + components: [ + { name: "currency0", type: "address" }, + { name: "currency1", type: "address" }, + { name: "fee", type: "uint24" }, + { name: "tickSpacing", type: "int24" }, + { name: "hooks", type: "address" }, + ], + }, + { name: "zeroForOne", type: "bool" }, + { name: "exactAmount", type: "uint128" }, + { name: "hookData", type: "bytes" }, + ], + }, + ], + outputs: [ + { name: "amountOut", type: "uint256" }, + { name: "gasEstimate", type: "uint256" }, + ], + }, +] as const; + +type PoolKey = { + currency0: Address; + currency1: Address; + fee: number; + tickSpacing: number; + hooks: Address; +}; + +describe.runIf(RPC_URL)("simulateTakerSwapExact vs V4Quoter (live pool)", () => { + let publicClient: PublicClient; + let quoter: Address; + let poolKey: PoolKey; + let tickMap: PoolTickMap; + let sqrtPriceX96: bigint; + let liquidity: bigint; + let markPrice: number; + + /** Returns the pool's USD leg, or null when the swap cannot be settled. */ + async function quote(perpDelta: bigint): Promise { + const isLong = perpDelta > 0n; + const exactAmount = isLong ? perpDelta : -perpDelta; + try { + const { result } = await publicClient.simulateContract({ + address: quoter, + abi: QUOTER_ABI, + functionName: isLong ? "quoteExactOutputSingle" : "quoteExactInputSingle", + args: [{ poolKey, zeroForOne: !isLong, exactAmount, hookData: "0x" }], + }); + return result[0]; + } catch { + return null; + } + } + + function simulate(perpDelta: bigint) { + return simulateTakerSwapExact({ sqrtPriceX96, liquidity, perpDelta, markPrice, tickMap }); + } + + beforeAll(async () => { + const chain = + Number(process.env.CHAIN_ID ?? arbitrum.id) === arbitrumSepolia.id + ? arbitrumSepolia + : arbitrum; + publicClient = createPublicClient({ chain, transport: http(RPC_URL) }) as PublicClient; + quoter = V4_QUOTER_BY_CHAIN[chain.id] as Address; + + const [poolId, key, poolState] = await Promise.all([ + publicClient.readContract({ address: PERP_ADDRESS, abi: PERP_ABI, functionName: "POOL_ID" }), + publicClient.readContract({ address: PERP_ADDRESS, abi: PERP_ABI, functionName: "poolKey" }), + publicClient.readContract({ + address: PERP_ADDRESS, + abi: PERP_ABI, + functionName: "poolState", + }), + ]); + + poolKey = key as PoolKey; + sqrtPriceX96 = poolState[1]; + liquidity = poolState[3]; + markPrice = Number(sqrtPriceX96) ** 2 / 2 ** 192; + tickMap = await fetchPoolTickMap(publicClient, { + poolId: poolId as Hex, + tickSpacing: poolKey.tickSpacing, + }); + }, 60_000); + + it("reads a tick map that reproduces the pool's active liquidity", () => { + expect(tickMap.ticks.length).toBeGreaterThan(0); + expect(() => assertTickMapFresh(tickMap, sqrtPriceX96, liquidity)).not.toThrow(); + }); + + it("quotes the pool's fill exactly, across sizes and both sides", async () => { + for (const isLong of [true, false]) { + const max = maxFillablePerpDelta({ sqrtPriceX96, liquidity, tickMap, isLong }); + expect(max).toBeGreaterThan(0n); + + for (const numerator of [1n, 10n, 50n, 90n, 100n]) { + const size = (max * numerator) / 100n; + if (size === 0n) continue; + const perpDelta = isLong ? size : -size; + + const expected = await quote(perpDelta); + const simulated = simulate(perpDelta); + + expect(expected, `pool could not settle ${perpDelta}`).not.toBeNull(); + expect(simulated.exceedsLiquidity).toBe(false); + // usdDelta is signed by side; the quoter always returns a magnitude. + expect(simulated.usdDelta < 0n ? -simulated.usdDelta : simulated.usdDelta).toBe(expected); + } + } + }, 120_000); + + it("finds the exact capacity boundary: max fills, max + 1 reverts", async () => { + for (const isLong of [true, false]) { + const max = maxFillablePerpDelta({ sqrtPriceX96, liquidity, tickMap, isLong }); + const sign = isLong ? 1n : -1n; + + expect(await quote(sign * max)).not.toBeNull(); + expect(simulate(sign * max).exceedsLiquidity).toBe(false); + + expect(await quote(sign * (max + 1n))).toBeNull(); + expect(simulate(sign * (max + 1n)).exceedsLiquidity).toBe(true); + } + }, 60_000); +}); diff --git a/src/__tests__/unit/swapExact.test.ts b/src/__tests__/unit/swapExact.test.ts new file mode 100644 index 0000000..d418f64 --- /dev/null +++ b/src/__tests__/unit/swapExact.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from "vitest"; +import { simulateTakerSwap } from "../../utils/swap"; +import { + activeLiquidityAt, + maxFillablePerpDelta, + type PoolTickMap, + StaleTickMapError, + simulateTakerSwapExact, +} from "../../utils/swapExact"; +import { getSqrtPriceAtTick } from "../../utils/tickMath"; + +/** + * State of the live mainnet perp 0x8ac0179073a9eb5aaee58e5ebe9882066b9e7b6c, + * read from the Uniswap v4 PoolManager (0x360E68fa...) at poolId 0x23ed4a6f... + * + * The expected USD legs below are not hand-derived: every one was produced by + * `eth_call`ing Uniswap's own V4Quoter (0x3972c00f...) against this pool. The + * walker has to reproduce them to the wei, because the same PoolManager settles + * the perp's real fill. `src/__tests__/integration/swapQuoter.test.ts` re-derives + * them against the live chain. + * + * Liquidity here sits in a band *above* spot (tick 40050 adds 3.6x the active + * liquidity) that ends at tick 44460 — the shape that breaks the single-region + * approximation in both directions at once. + */ +const SQRT_PRICE_X96 = 539_635_589_510_744_202_256_725_218_321n; +const LIQUIDITY = 167_610_582n; +const MARK = 46.3919; + +const TICK_MAP: PoolTickMap = { + tickSpacing: 30, + ticks: [ + { tick: 32400, liquidityNet: 100_000n }, + { tick: 33990, liquidityNet: 102_899_920n }, + { tick: 36000, liquidityNet: 5_000_000n }, + { tick: 38040, liquidityNet: 59_610_662n }, + { tick: 40050, liquidityNet: 605_043_985n }, + { tick: 40080, liquidityNet: -102_899_920n }, + { tick: 40920, liquidityNet: 31_245_379n }, + { tick: 41250, liquidityNet: 30_764_892n }, + { tick: 41430, liquidityNet: 19_689_168n }, + { tick: 41760, liquidityNet: -605_043_985n }, + { tick: 42060, liquidityNet: -50_454_060n }, + { tick: 42510, liquidityNet: -90_856_041n }, + { tick: 44460, liquidityNet: -5_100_000n }, + ], +}; + +const base = { + sqrtPriceX96: SQRT_PRICE_X96, + liquidity: LIQUIDITY, + markPrice: MARK, + tickMap: TICK_MAP, +}; + +/** perp leg (6-dp) -> USD leg, from V4Quoter.quoteExactOutputSingle. */ +const LONG_QUOTES: readonly [bigint, bigint][] = [ + [100_000n, 4_658_118n], + [1_000_000n, 48_356_956n], + [2_000_000n, 100_990_910n], + [5_000_000n, 271_223_391n], + [8_000_000n, 453_389_739n], + [10_000_000n, 581_851_768n], + [10_300_000n, 602_915_471n], +]; + +/** perp leg (6-dp) -> USD leg, from V4Quoter.quoteExactInputSingle. */ +const SHORT_QUOTES: readonly [bigint, bigint][] = [ + [100_000n, 4_620_412n], + [1_000_000n, 44_259_103n], +]; + +/** Largest perp leg the pool can settle; one unit more reverts on-chain. */ +const MAX_LONG = 10_302_003n; +const MAX_SHORT = 3_946_165n; + +describe("activeLiquidityAt", () => { + it("reconstructs the pool's active liquidity from the tick map", () => { + expect(activeLiquidityAt(TICK_MAP, SQRT_PRICE_X96)).toBe(LIQUIDITY); + }); + + it("rejects a tick map that no longer describes the pool", () => { + const stale: PoolTickMap = { + tickSpacing: 30, + ticks: TICK_MAP.ticks.filter((t) => t.tick !== 38040), + }; + expect(() => + simulateTakerSwapExact({ ...base, tickMap: stale, perpDelta: 1_000_000n }) + ).toThrow(StaleTickMapError); + }); +}); + +describe("simulateTakerSwapExact", () => { + it.each(LONG_QUOTES)("matches V4Quoter for a %s long", (perpDelta, expectedUsd) => { + const result = simulateTakerSwapExact({ ...base, perpDelta }); + expect(result.exceedsLiquidity).toBe(false); + expect(result.usdDelta).toBe(-expectedUsd); + }); + + it.each(SHORT_QUOTES)("matches V4Quoter for a %s short", (perpDelta, expectedUsd) => { + const result = simulateTakerSwapExact({ ...base, perpDelta: -perpDelta }); + expect(result.exceedsLiquidity).toBe(false); + expect(result.usdDelta).toBe(expectedUsd); + }); + + it("crosses ticks and reports how many", () => { + const withinTick = simulateTakerSwapExact({ ...base, perpDelta: 100_000n }); + expect(withinTick.ticksCrossed).toBe(0); + + const multiTick = simulateTakerSwapExact({ ...base, perpDelta: 10_000_000n }); + expect(multiTick.ticksCrossed).toBeGreaterThan(0); + expect(multiTick.endSqrtPriceX96).toBeGreaterThan(SQRT_PRICE_X96); + }); + + it("flags an order the pool cannot settle rather than quoting it", () => { + expect(simulateTakerSwapExact({ ...base, perpDelta: MAX_LONG }).exceedsLiquidity).toBe(false); + expect(simulateTakerSwapExact({ ...base, perpDelta: MAX_LONG + 1n }).exceedsLiquidity).toBe( + true + ); + expect(simulateTakerSwapExact({ ...base, perpDelta: -MAX_SHORT }).exceedsLiquidity).toBe(false); + expect(simulateTakerSwapExact({ ...base, perpDelta: -(MAX_SHORT + 1n) }).exceedsLiquidity).toBe( + true + ); + }); + + it("keeps fees out of the raw fill and folds them into the effective fields", () => { + const feeRate = 0.0111; + const perpDelta = 1_000_000n; + const raw = simulateTakerSwapExact({ ...base, perpDelta }); + const withFee = simulateTakerSwapExact({ ...base, perpDelta, feeRate }); + + expect(withFee.usdDelta).toBe(raw.usdDelta); + expect(withFee.fillPrice).toBe(raw.fillPrice); + expect(withFee.effectiveFillPrice).toBeCloseTo(raw.fillPrice * (1 + feeRate), 9); + }); + + it("prices a short below the mark and a long above it", () => { + const long = simulateTakerSwapExact({ ...base, perpDelta: 1_000_000n }); + const short = simulateTakerSwapExact({ ...base, perpDelta: -1_000_000n }); + expect(long.fillPrice).toBeGreaterThan(MARK); + expect(short.fillPrice).toBeLessThan(MARK); + }); + + it("corrects the single-region model, which errs in both directions at once", () => { + const perpDelta = 10_000_000n; + const exact = simulateTakerSwapExact({ ...base, perpDelta }); + const approx = simulateTakerSwap({ ...base, perpDelta }); + + // Liquidity above spot makes the true fill much cheaper than the flat model. + expect(-exact.usdDelta).toBeLessThan(-approx.usdDelta); + expect(approx.liquidityLimited).toBe(true); + + // Yet the flat model thinks the pool is far deeper than it is. + expect(simulateTakerSwap({ ...base, perpDelta: 20_000_000n }).exceedsLiquidity).toBe(false); + expect(simulateTakerSwapExact({ ...base, perpDelta: 20_000_000n }).exceedsLiquidity).toBe(true); + }); +}); + +describe("maxFillablePerpDelta", () => { + it("returns the exact largest settleable leg on each side", () => { + expect(maxFillablePerpDelta({ ...base, isLong: true })).toBe(MAX_LONG); + expect(maxFillablePerpDelta({ ...base, isLong: false })).toBe(MAX_SHORT); + }); +}); + +describe("getSqrtPriceAtTick", () => { + it("brackets the pool's live price with its own tick", () => { + expect(getSqrtPriceAtTick(38373)).toBeLessThanOrEqual(SQRT_PRICE_X96); + expect(getSqrtPriceAtTick(38374)).toBeGreaterThan(SQRT_PRICE_X96); + }); + + it("is 2^96 at tick zero and monotonic", () => { + expect(getSqrtPriceAtTick(0)).toBe(79_228_162_514_264_337_593_543_950_336n); + expect(getSqrtPriceAtTick(-1)).toBeLessThan(getSqrtPriceAtTick(0)); + expect(getSqrtPriceAtTick(1)).toBeGreaterThan(getSqrtPriceAtTick(0)); + }); +}); diff --git a/src/utils/index.ts b/src/utils/index.ts index dfed751..acc6c64 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -5,6 +5,10 @@ export * from "./errors"; export * from "./fees"; export * from "./funding"; export * from "./liquidity"; +export * from "./poolTicks"; export * from "./rpc"; export * from "./slippage"; export * from "./swap"; +export * from "./swapExact"; +export * from "./swapMath"; +export * from "./tickMath"; diff --git a/src/utils/poolTicks.ts b/src/utils/poolTicks.ts new file mode 100644 index 0000000..6ca3070 --- /dev/null +++ b/src/utils/poolTicks.ts @@ -0,0 +1,172 @@ +import { type Address, encodeAbiParameters, type Hex, keccak256, type PublicClient } from "viem"; +import type { PoolTick, PoolTickMap } from "./swapExact"; +import { bitmapPosition, compressTick, PERP_MAX_TICK, PERP_MIN_TICK } from "./tickMath"; + +/** + * Reads a perp pool's initialized ticks straight from the Uniswap v4 + * `PoolManager`. + * + * This reimplements v4's `StateLibrary` slot math rather than calling the + * `StateView` periphery contract, because `StateView` is deployed at a + * different address on every chain (and not at all on some), while `extsload` + * is on the `PoolManager` itself. `extsload(bytes32[])` also batches every read + * into one `eth_call`, so a whole tick map costs two round trips: one for the + * bitmap, one for the ticks it points at. + * + * The map only changes when a maker mints or burns, so callers should cache it + * and revalidate with `assertTickMapFresh` against the streamed pool liquidity. + */ + +/** `StateLibrary.POOLS_SLOT` — the `PoolManager.pools` mapping. */ +const POOLS_SLOT = 6n; +/** Offsets into `Pool.State`. */ +const LIQUIDITY_OFFSET = 3n; +const TICKS_OFFSET = 4n; +const TICK_BITMAP_OFFSET = 5n; + +const UINT128_MAX = (1n << 128n) - 1n; +const INT128_SIGN_BIT = 1n << 127n; +const TWO_POW_128 = 1n << 128n; + +const POOL_MANAGER_ABI = [ + { + type: "function", + name: "extsload", + stateMutability: "view", + inputs: [{ name: "slots", type: "bytes32[]" }], + outputs: [{ name: "values", type: "bytes32[]" }], + }, +] as const; + +/** + * Uniswap v4 `PoolManager` per chain. The perp's own `POOL_MANAGER` is a + * compile-time constant with no getter, so it has to be mirrored here. + */ +const POOL_MANAGER_BY_CHAIN: Readonly> = { + 42161: "0x360E68faCcca8cA495c1B759Fd9EEe466db9FB32", + 421614: "0xFB3e0C6F74eB1a21CC1Da29aeC80D2Dfe6C9a317", +}; + +export function getPoolManagerAddress(chainId: number): Address { + const address = POOL_MANAGER_BY_CHAIN[chainId]; + if (!address) throw new Error(`no Uniswap v4 PoolManager known for chain ${chainId}`); + return address; +} + +function addToSlot(slot: Hex, offset: bigint): Hex { + const next = BigInt(slot) + offset; + return `0x${next.toString(16).padStart(64, "0")}`; +} + +/** `StateLibrary._getPoolStateSlot` */ +function poolStateSlot(poolId: Hex): Hex { + return keccak256( + encodeAbiParameters([{ type: "bytes32" }, { type: "uint256" }], [poolId, POOLS_SLOT]) + ); +} + +/** + * Slot of a mapping keyed by a signed tick-ish value. v4 hashes the key + * sign-extended to 32 bytes, so `int256` encoding reproduces it for both the + * `int16` word positions and the `int24` ticks. + */ +function signedKeySlot(key: number, mappingSlot: Hex): Hex { + return keccak256( + encodeAbiParameters([{ type: "int256" }, { type: "bytes32" }], [BigInt(key), mappingSlot]) + ); +} + +function decodeTickInfo(word: Hex): { liquidityGross: bigint; liquidityNet: bigint } { + const value = BigInt(word); + const liquidityNet = value >> 128n; + return { + liquidityGross: value & UINT128_MAX, + liquidityNet: liquidityNet >= INT128_SIGN_BIT ? liquidityNet - TWO_POW_128 : liquidityNet, + }; +} + +/** Bitmap words spanning every tick a perp maker is allowed to initialize. */ +function wordPositions(tickSpacing: number): number[] { + const min = bitmapPosition(compressTick(PERP_MIN_TICK, tickSpacing)).wordPos; + const max = bitmapPosition(compressTick(PERP_MAX_TICK, tickSpacing)).wordPos; + return Array.from({ length: max - min + 1 }, (_, i) => min + i); +} + +async function extsload( + publicClient: PublicClient, + poolManager: Address, + slots: Hex[] +): Promise { + if (slots.length === 0) return []; + return publicClient.readContract({ + address: poolManager, + abi: POOL_MANAGER_ABI, + functionName: "extsload", + args: [slots], + }); +} + +/** + * Fetch every initialized tick of a perp pool, ascending. + * + * @param poolId - The perp's `POOL_ID`. + * @param tickSpacing - The pool key's tick spacing (30 for perps). + * @param poolManager - Override the per-chain `PoolManager` address. + */ +export async function fetchPoolTickMap( + publicClient: PublicClient, + opts: { poolId: Hex; tickSpacing: number; poolManager?: Address } +): Promise { + const { poolId, tickSpacing } = opts; + const chainId = opts.poolManager ? undefined : publicClient.chain?.id; + if (!opts.poolManager && chainId === undefined) { + throw new Error("fetchPoolTickMap needs a poolManager address or a chain-aware client"); + } + const poolManager = opts.poolManager ?? getPoolManagerAddress(chainId as number); + + const stateSlot = poolStateSlot(poolId); + const bitmapMapping = addToSlot(stateSlot, TICK_BITMAP_OFFSET); + const ticksMapping = addToSlot(stateSlot, TICKS_OFFSET); + + const words = wordPositions(tickSpacing); + const bitmaps = await extsload( + publicClient, + poolManager, + words.map((wordPos) => signedKeySlot(wordPos, bitmapMapping)) + ); + + const initializedTicks: number[] = []; + words.forEach((wordPos, index) => { + const bitmap = BigInt(bitmaps[index] ?? "0x0"); + if (bitmap === 0n) return; + for (let bitPos = 0; bitPos < 256; bitPos++) { + if ((bitmap >> BigInt(bitPos)) & 1n) { + initializedTicks.push((wordPos * 256 + bitPos) * tickSpacing); + } + } + }); + + const tickInfos = await extsload( + publicClient, + poolManager, + initializedTicks.map((tick) => signedKeySlot(tick, ticksMapping)) + ); + + const ticks: PoolTick[] = initializedTicks.map((tick, index) => ({ + tick, + liquidityNet: decodeTickInfo(tickInfos[index] ?? "0x0").liquidityNet, + })); + + return { tickSpacing, ticks }; +} + +/** Read the pool's live active liquidity, to check a cached tick map against. */ +export async function fetchPoolLiquidity( + publicClient: PublicClient, + opts: { poolId: Hex; poolManager?: Address } +): Promise { + const poolManager = opts.poolManager ?? getPoolManagerAddress(publicClient.chain?.id as number); + const slot = addToSlot(poolStateSlot(opts.poolId), LIQUIDITY_OFFSET); + const [value] = await extsload(publicClient, poolManager, [slot]); + return BigInt(value ?? "0x0"); +} diff --git a/src/utils/swap.ts b/src/utils/swap.ts index 1dd98b9..99370c5 100644 --- a/src/utils/swap.ts +++ b/src/utils/swap.ts @@ -63,7 +63,7 @@ export type SimulatedTakerSwap = { }; /** 6-dp fixed-point scale for the fee rate (fees are read as `uint24 / 1e6`). */ -const FEE_SCALE = 1_000_000n; +export const FEE_SCALE = 1_000_000n; /** * Fractional pool-price move above which the single-region (constant-liquidity) @@ -118,7 +118,7 @@ function getAmount1Delta(sqrtLowerX96: bigint, sqrtUpperX96: bigint, liquidity: * trader actually pays it: a long pays `feeScaled` more USD, a short receives * `feeScaled` less. `feeScaled` is the fee rate in 6-dp fixed point. */ -function applyFeeToUsd(absUsd: bigint, isLong: boolean, feeScaled: bigint): bigint { +export function applyFeeToUsd(absUsd: bigint, isLong: boolean, feeScaled: bigint): bigint { return isLong ? (absUsd * (FEE_SCALE + feeScaled)) / FEE_SCALE : (absUsd * (FEE_SCALE - feeScaled)) / FEE_SCALE; diff --git a/src/utils/swapExact.ts b/src/utils/swapExact.ts new file mode 100644 index 0000000..3a12dc2 --- /dev/null +++ b/src/utils/swapExact.ts @@ -0,0 +1,226 @@ +import { applyFeeToUsd, FEE_SCALE, type SimulatedTakerSwap } from "./swap"; +import { computeSwapStepToken0, getAmount0Delta } from "./swapMath"; +import { getSqrtPriceAtTick } from "./tickMath"; + +/** + * Exact multi-tick taker swap simulation. + * + * A perp's fill is settled by a real Uniswap v4 `PoolManager.swap` against a + * pool created with `fee: 0` and no hooks (`PerpFactory`), with no sqrt-price + * limit (`PerpLogic.swap`). Given the pool's initialized ticks this walks the + * same curve the chain walks, so the returned USD leg matches the on-chain fill + * to the wei — unlike `simulateTakerSwap`, which assumes the current active + * liquidity holds across the whole trade. + * + * That assumption is wrong in both directions, not merely optimistic: a band of + * liquidity above spot makes real fills *cheaper* than the flat model predicts, + * while the band's edge means the pool runs dry far *sooner* than the flat model + * predicts. Only a tick walk gets both right. + * + * FEES are unchanged: the raw `fillPrice` / `usdDelta` are the pure curve result + * (the true entry price), and the perp charges its taker fee separately out of + * margin. See `simulateTakerSwap` for the `feeRate` / effective-field contract. + */ + +/** An initialized tick and the liquidity delta applied when crossing it upward. */ +export type PoolTick = { + tick: number; + liquidityNet: bigint; +}; + +/** Every initialized tick in a perp pool, ascending by tick. */ +export type PoolTickMap = { + tickSpacing: number; + ticks: readonly PoolTick[]; +}; + +export type ExactSimulatedTakerSwap = SimulatedTakerSwap & { + /** Pool sqrt price after the swap. Feeds the `PriceImpactTooHigh` bounds check. */ + endSqrtPriceX96: bigint; + /** Initialized ticks the swap crosses. Zero means it stays within one region. */ + ticksCrossed: number; +}; + +/** + * Thrown when a tick map cannot reproduce the pool's live active liquidity, + * which means an LP has minted or burned since the map was read. Callers should + * refetch the map and retry, falling back to `simulateTakerSwap` if that fails. + */ +export class StaleTickMapError extends Error { + constructor( + readonly expectedLiquidity: bigint, + readonly derivedLiquidity: bigint + ) { + super( + `tick map is stale: derived active liquidity ${derivedLiquidity} != pool liquidity ${expectedLiquidity}` + ); + this.name = "StaleTickMapError"; + } +} + +/** + * Active liquidity implied by a tick map at a given price: the sum of + * `liquidityNet` over every initialized tick at or below the current tick. + * + * `getSqrtPriceAtTick` is monotonic, so `sqrtPriceAtTick(t) <= sqrtPriceX96` + * holds exactly when `t <= currentTick`. Comparing sqrt prices therefore + * identifies the ticks below spot without needing a `getTickAtSqrtPrice` port. + */ +export function activeLiquidityAt(tickMap: PoolTickMap, sqrtPriceX96: bigint): bigint { + let liquidity = 0n; + for (const { tick, liquidityNet } of tickMap.ticks) { + if (getSqrtPriceAtTick(tick) > sqrtPriceX96) break; + liquidity += liquidityNet; + } + return liquidity; +} + +/** + * Verify a tick map still describes the live pool. The pool's own `liquidity` + * is streamed on every swap, so this costs nothing and catches a stale map + * whenever an LP has changed liquidity spanning the current price. + */ +export function assertTickMapFresh( + tickMap: PoolTickMap, + sqrtPriceX96: bigint, + liquidity: bigint +): void { + const derived = activeLiquidityAt(tickMap, sqrtPriceX96); + if (derived !== liquidity) throw new StaleTickMapError(liquidity, derived); +} + +/** Ticks a long walks through, ascending. Excludes the current tick. */ +function ticksAbove(tickMap: PoolTickMap, sqrtPriceX96: bigint): PoolTick[] { + return tickMap.ticks.filter((t) => getSqrtPriceAtTick(t.tick) > sqrtPriceX96); +} + +/** Ticks a short walks through, descending. Includes the current tick. */ +function ticksAtOrBelow(tickMap: PoolTickMap, sqrtPriceX96: bigint): PoolTick[] { + return tickMap.ticks.filter((t) => getSqrtPriceAtTick(t.tick) <= sqrtPriceX96).reverse(); +} + +/** + * The largest perp leg the pool can fill in one direction, in 6-dp units. + * + * Beyond this the on-chain swap cannot settle the exact perp amount and + * `PerpLogic.swap` reverts with `InsufficientLiquidityToFill`, so the UI must + * block the order rather than quote it. + */ +export function maxFillablePerpDelta(opts: { + sqrtPriceX96: bigint; + liquidity: bigint; + tickMap: PoolTickMap; + isLong: boolean; +}): bigint { + const { sqrtPriceX96, liquidity, tickMap, isLong } = opts; + let sqrtPrice = sqrtPriceX96; + let active = liquidity; + let total = 0n; + + const path = isLong ? ticksAbove(tickMap, sqrtPriceX96) : ticksAtOrBelow(tickMap, sqrtPriceX96); + for (const { tick, liquidityNet } of path) { + const target = getSqrtPriceAtTick(tick); + total += isLong + ? getAmount0Delta(sqrtPrice, target, active, false) + : getAmount0Delta(target, sqrtPrice, active, true); + active += isLong ? liquidityNet : -liquidityNet; + sqrtPrice = target; + } + return total; +} + +export function simulateTakerSwapExact(opts: { + sqrtPriceX96: bigint; + liquidity: bigint; + perpDelta: bigint; + markPrice: number; + tickMap: PoolTickMap; + feeRate?: number; +}): ExactSimulatedTakerSwap { + const { sqrtPriceX96, liquidity, perpDelta, markPrice, tickMap } = opts; + const feeRate = opts.feeRate ?? 0; + if (!Number.isFinite(feeRate) || feeRate < 0) { + throw new Error("feeRate must be a non-negative, finite number"); + } + + const feeScaled = BigInt(Math.round(feeRate * Number(FEE_SCALE))); + const isLong = perpDelta > 0n; + const absPerpDelta = perpDelta < 0n ? -perpDelta : perpDelta; + const feeMultiplier = isLong ? 1 + feeRate : 1 - feeRate; + + if (sqrtPriceX96 <= 0n || absPerpDelta === 0n) { + const usd = (absPerpDelta * BigInt(Math.round(markPrice * 1e6))) / 1_000_000n; + const effectiveUsd = applyFeeToUsd(usd, isLong, feeScaled); + return { + usdDelta: isLong ? -usd : usd, + fillPrice: markPrice, + feeRate, + effectiveUsdDelta: isLong ? -effectiveUsd : effectiveUsd, + effectiveFillPrice: markPrice * feeMultiplier, + exceedsLiquidity: absPerpDelta > 0n, + liquidityLimited: false, + endSqrtPriceX96: sqrtPriceX96, + ticksCrossed: 0, + }; + } + + assertTickMapFresh(tickMap, sqrtPriceX96, liquidity); + + let sqrtPrice = sqrtPriceX96; + let active = liquidity; + let remaining = absPerpDelta; + let usd = 0n; + let ticksCrossed = 0; + + const path = isLong ? ticksAbove(tickMap, sqrtPriceX96) : ticksAtOrBelow(tickMap, sqrtPriceX96); + for (const { tick, liquidityNet } of path) { + const step = computeSwapStepToken0({ + sqrtPriceCurrentX96: sqrtPrice, + sqrtPriceTargetX96: getSqrtPriceAtTick(tick), + liquidity: active, + remainingToken0: remaining, + exactOutput: isLong, + }); + + usd += step.amount1; + remaining -= step.amount0; + sqrtPrice = step.sqrtPriceNextX96; + if (remaining === 0n) break; + + // The step stopped at the tick without filling, so the pool crosses it. + active += isLong ? liquidityNet : -liquidityNet; + ticksCrossed++; + } + + // The pool ran out of initialized ticks before filling the perp leg, so the + // real swap would revert with `InsufficientLiquidityToFill`. + if (remaining > 0n) { + const infUsd = 1n << 255n; + return { + usdDelta: isLong ? -infUsd : infUsd, + fillPrice: Number.POSITIVE_INFINITY, + feeRate, + effectiveUsdDelta: isLong ? -infUsd : infUsd, + effectiveFillPrice: Number.POSITIVE_INFINITY, + exceedsLiquidity: true, + liquidityLimited: false, + endSqrtPriceX96: sqrtPrice, + ticksCrossed, + }; + } + + const fillPrice = Number(usd) / Number(absPerpDelta); + const effectiveUsd = applyFeeToUsd(usd, isLong, feeScaled); + + return { + usdDelta: isLong ? -usd : usd, + fillPrice, + feeRate, + effectiveUsdDelta: isLong ? -effectiveUsd : effectiveUsd, + effectiveFillPrice: fillPrice * feeMultiplier, + exceedsLiquidity: false, + liquidityLimited: false, + endSqrtPriceX96: sqrtPrice, + ticksCrossed, + }; +} diff --git a/src/utils/swapMath.ts b/src/utils/swapMath.ts new file mode 100644 index 0000000..8e77065 --- /dev/null +++ b/src/utils/swapMath.ts @@ -0,0 +1,149 @@ +import { Q96 } from "./constants"; + +/** + * Exact bigint port of the Uniswap v4 `SqrtPriceMath` / `SwapMath` primitives + * needed to walk a perp pool's curve. + * + * Only the token0-specified paths are ported, because a taker order always + * fixes the perp leg (token0) and lets the pool settle the USD leg (token1): + * a long is an exact-output-token0 swap, a short an exact-input-token0 swap. + * Perp pools are created with `fee: 0` (fees are charged by the perp itself and + * donated back), so every function here is the zero-fee specialisation. + */ + +const UINT256_MAX = (1n << 256n) - 1n; + +export function mulDivRoundingUp(a: bigint, b: bigint, denominator: bigint): bigint { + const product = a * b; + const result = product / denominator; + return product % denominator === 0n ? result : result + 1n; +} + +function divRoundingUp(a: bigint, b: bigint): bigint { + return a % b === 0n ? a / b : a / b + 1n; +} + +/** + * `SqrtPriceMath.getAmount0Delta` — the token0 between two prices at a given + * liquidity. `roundUp` when the amount is owed to the pool (an input). + */ +export function getAmount0Delta( + sqrtPriceAX96: bigint, + sqrtPriceBX96: bigint, + liquidity: bigint, + roundUp: boolean +): bigint { + const [lower, upper] = + sqrtPriceAX96 > sqrtPriceBX96 ? [sqrtPriceBX96, sqrtPriceAX96] : [sqrtPriceAX96, sqrtPriceBX96]; + const numerator1 = liquidity << 96n; + const numerator2 = upper - lower; + + return roundUp + ? divRoundingUp(mulDivRoundingUp(numerator1, numerator2, upper), lower) + : (numerator1 * numerator2) / upper / lower; +} + +/** + * `SqrtPriceMath.getAmount1Delta` — the token1 (USD) between two prices at a + * given liquidity. `roundUp` when the amount is owed to the pool (an input). + */ +export function getAmount1Delta( + sqrtPriceAX96: bigint, + sqrtPriceBX96: bigint, + liquidity: bigint, + roundUp: boolean +): bigint { + const [lower, upper] = + sqrtPriceAX96 > sqrtPriceBX96 ? [sqrtPriceBX96, sqrtPriceAX96] : [sqrtPriceAX96, sqrtPriceBX96]; + + return roundUp + ? mulDivRoundingUp(liquidity, upper - lower, Q96) + : (liquidity * (upper - lower)) / Q96; +} + +/** + * `SqrtPriceMath.getNextSqrtPriceFromAmount0RoundingUp`. + * `add` = token0 flows into the pool (a short; price falls). + * `!add` = token0 leaves the pool (a long; price rises). + */ +export function getNextSqrtPriceFromAmount0( + sqrtPriceX96: bigint, + liquidity: bigint, + amount0: bigint, + add: boolean +): bigint { + if (amount0 === 0n) return sqrtPriceX96; + const numerator1 = liquidity << 96n; + const product = amount0 * sqrtPriceX96; + + if (add) { + // Solidity falls back to the less precise form on uint256 overflow; mirror + // that boundary so a pathological input can never silently diverge. + if (product <= UINT256_MAX && numerator1 + product <= UINT256_MAX) { + return mulDivRoundingUp(numerator1, sqrtPriceX96, numerator1 + product); + } + return divRoundingUp(numerator1, numerator1 / sqrtPriceX96 + amount0); + } + + if (product >= numerator1) { + throw new Error("getNextSqrtPriceFromAmount0: amount0 exhausts the liquidity region"); + } + return mulDivRoundingUp(numerator1, sqrtPriceX96, numerator1 - product); +} + +export type SwapStep = { + /** Pool price after this step. Equals the target when the step reached it. */ + sqrtPriceNextX96: bigint; + /** Token0 (perp) moved by this step. */ + amount0: bigint; + /** Token1 (USD) moved by this step: paid in for a long, received for a short. */ + amount1: bigint; +}; + +/** + * `SwapMath.computeSwapStep` with `feePips = 0`, for a token0-denominated swap. + * + * Steps the price from `sqrtPriceCurrentX96` toward `sqrtPriceTargetX96`, + * consuming at most `remainingToken0`. Reaching the target means the step was + * liquidity-bound and the caller should cross the tick and continue; stopping + * short means the order was filled. + * + * A zero-liquidity region moves the price straight to the target for no amount, + * exactly as the pool does. + */ +export function computeSwapStepToken0(opts: { + sqrtPriceCurrentX96: bigint; + sqrtPriceTargetX96: bigint; + liquidity: bigint; + remainingToken0: bigint; + /** True for a long (exact token0 out); false for a short (exact token0 in). */ + exactOutput: boolean; +}): SwapStep { + const { sqrtPriceCurrentX96, sqrtPriceTargetX96, liquidity, remainingToken0, exactOutput } = opts; + + if (exactOutput) { + const maxAmount0 = getAmount0Delta(sqrtPriceCurrentX96, sqrtPriceTargetX96, liquidity, false); + const reachesTarget = remainingToken0 >= maxAmount0; + const sqrtPriceNextX96 = reachesTarget + ? sqrtPriceTargetX96 + : getNextSqrtPriceFromAmount0(sqrtPriceCurrentX96, liquidity, remainingToken0, false); + + return { + sqrtPriceNextX96, + amount0: reachesTarget ? maxAmount0 : remainingToken0, + amount1: getAmount1Delta(sqrtPriceCurrentX96, sqrtPriceNextX96, liquidity, true), + }; + } + + const maxAmount0 = getAmount0Delta(sqrtPriceTargetX96, sqrtPriceCurrentX96, liquidity, true); + const reachesTarget = remainingToken0 >= maxAmount0; + const sqrtPriceNextX96 = reachesTarget + ? sqrtPriceTargetX96 + : getNextSqrtPriceFromAmount0(sqrtPriceCurrentX96, liquidity, remainingToken0, true); + + return { + sqrtPriceNextX96, + amount0: reachesTarget ? maxAmount0 : remainingToken0, + amount1: getAmount1Delta(sqrtPriceNextX96, sqrtPriceCurrentX96, liquidity, false), + }; +} diff --git a/src/utils/tickMath.ts b/src/utils/tickMath.ts new file mode 100644 index 0000000..e3dd535 --- /dev/null +++ b/src/utils/tickMath.ts @@ -0,0 +1,90 @@ +/** + * Exact bigint port of Uniswap v4 `TickMath.getSqrtPriceAtTick`. + * + * The perp's fill is settled by a real Uniswap v4 `PoolManager.swap`, so any + * simulation that wants to agree with the chain to the wei has to reproduce the + * same fixed-point tick math rather than `Math.sqrt(1.0001 ** tick)`. + */ + +/** Magic multipliers from Uniswap's `TickMath`, keyed by the `absTick` bit they apply to. */ +const RATIO_BY_BIT: ReadonlyArray = [ + [0x2, 0xfff97272373d413259a46990580e213an], + [0x4, 0xfff2e50f5f656932ef12357cf3c7fdccn], + [0x8, 0xffe5caca7e10e4e61c3624eaa0941cd0n], + [0x10, 0xffcb9843d60f6159c9db58835c926644n], + [0x20, 0xff973b41fa98c081472e6896dfb254c0n], + [0x40, 0xff2ea16466c96a3843ec78b326b52861n], + [0x80, 0xfe5dee046a99a2a811c461f1969c3053n], + [0x100, 0xfcbe86c7900a88aedcffc83b479aa3a4n], + [0x200, 0xf987a7253ac413176f2b074cf7815e54n], + [0x400, 0xf3392b0822b70005940c7a398e4b70f3n], + [0x800, 0xe7159475a2c29b7443b29c7fa6e889d9n], + [0x1000, 0xd097f3bdfd2022b8845ad8f792aa5825n], + [0x2000, 0xa9f746462d870fdf8a65dc1f90e061e5n], + [0x4000, 0x70d869a156d2a1b890bb3df62baf32f7n], + [0x8000, 0x31be135f97d08fd981231505542fcfa6n], + [0x10000, 0x9aa508b5b7a84e1c677de54f3e99bc9n], + [0x20000, 0x5d6af8dedb81196699c329225ee604n], + [0x40000, 0x2216e584f5fa1ea926041bedfe98n], + [0x80000, 0x48a170391f7dc42444e8fa2n], +]; + +const ODD_TICK_RATIO = 0xfffcb933bd6fad37aa2d162d1a594001n; +const ONE_X128 = 0x100000000000000000000000000000000n; +const UINT256_MAX = (1n << 256n) - 1n; +const TWO_POW_32 = 1n << 32n; + +/** Uniswap's absolute tick bounds (`TickMath.MIN_TICK` / `MAX_TICK`). */ +export const UNI_MIN_TICK = -887272; +export const UNI_MAX_TICK = 887272; + +/** `TickMath.getSqrtPriceAtTick(UNI_MIN_TICK / UNI_MAX_TICK)`. */ +export const UNI_MIN_SQRT_PRICE_X96 = 4295128739n; +export const UNI_MAX_SQRT_PRICE_X96 = 1461446703485210103287273052203988822378723970342n; + +/** + * Tick bounds the perp enforces on maker positions (`TicksOutOfBounds` in + * `PerpLogic.openMaker`). Every initialized tick in a perp pool lies inside + * this range, which bounds how much of the tick bitmap has to be scanned. + * + * Mirrors `MIN_TICK` / `MAX_TICK` in perpcity-contracts `src/libraries/Constants.sol`. + * Those are compile-time constants with no on-chain getter, so keep them in sync. + */ +export const PERP_MIN_TICK = -138180; +export const PERP_MAX_TICK = 138180; + +/** + * `sqrt(1.0001^tick) * 2^96`, matching Uniswap's fixed-point result exactly. + * + * @param tick - Tick to price, within Uniswap's absolute bounds. + */ +export function getSqrtPriceAtTick(tick: number): bigint { + if (!Number.isInteger(tick)) throw new Error(`tick must be an integer, got ${tick}`); + const absTick = tick < 0 ? -tick : tick; + if (absTick > UNI_MAX_TICK) throw new Error(`tick ${tick} out of bounds`); + + let ratio = (absTick & 0x1) !== 0 ? ODD_TICK_RATIO : ONE_X128; + for (const [bit, multiplier] of RATIO_BY_BIT) { + if ((absTick & bit) !== 0) ratio = (ratio * multiplier) >> 128n; + } + + // Ticks above zero price up, so invert the (always <= 1) accumulated ratio. + if (tick > 0) ratio = UINT256_MAX / ratio; + + // X128 -> X96, rounding up so the result never prices a tick below its true value. + return (ratio >> 32n) + (ratio % TWO_POW_32 === 0n ? 0n : 1n); +} + +/** + * Floor-divide `tick` by `tickSpacing`, matching Solidity's `TickBitmap.compress` + * (which rounds toward negative infinity, unlike bigint `/`). + */ +export function compressTick(tick: number, tickSpacing: number): number { + const compressed = Math.trunc(tick / tickSpacing); + return tick < 0 && tick % tickSpacing !== 0 ? compressed - 1 : compressed; +} + +/** Split a compressed tick into its `(wordPos, bitPos)` bitmap coordinates. */ +export function bitmapPosition(compressed: number): { wordPos: number; bitPos: number } { + return { wordPos: compressed >> 8, bitPos: compressed & 0xff }; +} From 3455e69eff68c6ea7ce4e732e4600082a1e6344c Mon Sep 17 00:00:00 2001 From: Luke Macauley Date: Fri, 10 Jul 2026 14:49:59 +0200 Subject: [PATCH 2/2] chore: bump to 0.16.0 for exact multi-tick swap simulation Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a85c78a..ac17f48 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@strobelabs/perpcity-sdk", "author": "Strobe Labs", "description": "TypeScript SDK for interacting with Perp City contracts", - "version": "0.15.0", + "version": "0.16.0", "type": "module", "license": "MIT", "repository": {