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": { 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 }; +}