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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
206 changes: 206 additions & 0 deletions src/__tests__/integration/swapQuoter.test.ts
Original file line number Diff line number Diff line change
@@ -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<number, Address> = {
[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<bigint | null> {
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);
});
177 changes: 177 additions & 0 deletions src/__tests__/unit/swapExact.test.ts
Original file line number Diff line number Diff line change
@@ -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));
});
});
Loading
Loading