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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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.12.0",
"version": "0.13.0",
"type": "module",
"license": "MIT",
"repository": {
Expand Down
135 changes: 135 additions & 0 deletions src/__tests__/unit/adjust-primitives.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { decodeFunctionData, erc20Abi, getAddress } from "viem";
import { describe, expect, it } from "vitest";
import { PERP_ABI } from "../../abis/perp";
import type { PerpCityContext } from "../../context";
import { buildAdjustMakerCalls, buildAdjustTakerCalls } from "../../functions/calldata";
import { estimateTakerAdjust } from "../../functions/perp-actions";
import type { PerpAddress } from "../../types";
import { Q96 } from "../../utils/constants";

const HOLDER = getAddress("0x1111111111111111111111111111111111111111");
const USDC = getAddress("0x2222222222222222222222222222222222222222");
const PERP = getAddress("0x3333333333333333333333333333333333333333") as PerpAddress;

// Pool at mark $100 (sqrt price 10) with deep liquidity so fills land near mark.
const perpData = {
sqrtPriceX96: 10n * Q96,
liquidity: 10n ** 18n,
mark: 100,
};

function makeContext(opts: { allowance?: bigint } = {}): PerpCityContext {
return {
walletClient: { account: { address: HOLDER } },
deployments: () => ({ usdc: USDC }),
getPerpData: async () => perpData,
publicClient: {
readContract: async () => opts.allowance ?? 0n,
},
} as unknown as PerpCityContext;
}

describe("estimateTakerAdjust", () => {
it("quotes a positive (buy) delta with a negative usd leg at ~mark", async () => {
const quote = await estimateTakerAdjust(makeContext(), PERP, {
perpDelta: 1_000_000n, // +1 perp token
});
expect(quote.usdDelta).toBeLessThan(0n);
expect(quote.fillPrice).toBeGreaterThan(99.9);
expect(quote.fillPrice).toBeLessThan(100.1);
expect(quote.exceedsLiquidity).toBe(false);
});

it("quotes a negative (sell) delta with a positive usd leg at ~mark", async () => {
const quote = await estimateTakerAdjust(makeContext(), PERP, {
perpDelta: -1_000_000n, // -1 perp token (reduce a long / open a short)
});
expect(quote.usdDelta).toBeGreaterThan(0n);
expect(quote.fillPrice).toBeGreaterThan(99.9);
expect(quote.fillPrice).toBeLessThan(100.1);
expect(quote.exceedsLiquidity).toBe(false);
});

it("rejects a zero delta", async () => {
await expect(estimateTakerAdjust(makeContext(), PERP, { perpDelta: 0n })).rejects.toThrow(
/non-zero/
);
});
});

describe("buildAdjustTakerCalls", () => {
const params = { posId: 7n, marginDelta: 5_000_000n, perpDelta: 1_000_000n, amt1Limit: 123n };

it("prepends a USDC approve when margin is added and allowance is short", async () => {
const calls = await buildAdjustTakerCalls(makeContext({ allowance: 0n }), PERP, params);
expect(calls).toHaveLength(2);
expect(getAddress(calls[0].to)).toBe(USDC);
const approve = decodeFunctionData({ abi: erc20Abi, data: calls[0].data });
expect(approve.functionName).toBe("approve");
expect(approve.args).toEqual([PERP, params.marginDelta]);
const adjust = decodeFunctionData({ abi: PERP_ABI, data: calls[1].data });
expect(adjust.functionName).toBe("adjustTaker");
});

it("skips the approve when the allowance already covers the margin", async () => {
const calls = await buildAdjustTakerCalls(
makeContext({ allowance: params.marginDelta }),
PERP,
params
);
expect(calls).toHaveLength(1);
expect(getAddress(calls[0].to)).toBe(PERP);
});

it("never approves for a reduce (no margin in)", async () => {
// No publicClient.readContract stubbing needed: marginDelta <= 0 must not
// even check the allowance.
const context = {
walletClient: { account: { address: HOLDER } },
deployments: () => ({ usdc: USDC }),
} as unknown as PerpCityContext;
const calls = await buildAdjustTakerCalls(context, PERP, {
posId: 7n,
marginDelta: 0n,
perpDelta: -1_000_000n,
amt1Limit: 90n,
});
expect(calls).toHaveLength(1);
const adjust = decodeFunctionData({ abi: PERP_ABI, data: calls[0].data });
expect(adjust.functionName).toBe("adjustTaker");
});
});

describe("buildAdjustMakerCalls", () => {
it("approval covers margin plus the USD leg when adding liquidity", async () => {
const calls = await buildAdjustMakerCalls(makeContext({ allowance: 0n }), PERP, {
posId: 9n,
marginDelta: 5_000_000n,
liquidityDelta: 1_000n,
amt0Limit: 0n,
amt1Limit: 2_000_000n,
});
expect(calls).toHaveLength(2);
const approve = decodeFunctionData({ abi: erc20Abi, data: calls[0].data });
expect(approve.args).toEqual([PERP, 7_000_000n]); // margin + amt1Limit
const adjust = decodeFunctionData({ abi: PERP_ABI, data: calls[1].data });
expect(adjust.functionName).toBe("adjustMaker");
});

it("no approve when removing liquidity and withdrawing margin", async () => {
const context = {
walletClient: { account: { address: HOLDER } },
deployments: () => ({ usdc: USDC }),
} as unknown as PerpCityContext;
const calls = await buildAdjustMakerCalls(context, PERP, {
posId: 9n,
marginDelta: -5_000_000n,
liquidityDelta: -1_000n,
amt0Limit: 0n,
amt1Limit: 0n,
});
expect(calls).toHaveLength(1);
const adjust = decodeFunctionData({ abi: PERP_ABI, data: calls[0].data });
expect(adjust.functionName).toBe("adjustMaker");
});
});
50 changes: 50 additions & 0 deletions src/functions/calldata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,56 @@ export async function buildOpenTakerPositionCalls(
return calls;
}

/**
* Full ordered call batch for a taker adjustment: a USDC `approve` (only when
* margin is being added and the current allowance is short) followed by
* `adjustTaker`. Margin is the only USDC the contract pulls on a taker adjust
* (the swap's USD leg settles inside position inventory), so the approval
* covers exactly `marginDelta`.
*/
export async function buildAdjustTakerCalls(
context: PerpCityContext,
perpAddress: PerpAddress,
params: { posId: bigint; marginDelta: bigint; perpDelta: bigint; amt1Limit: bigint }
): Promise<CallData[]> {
const calls: CallData[] = [];
if (params.marginDelta > 0n) {
const approval = await maybeApprovalCall(context, perpAddress, params.marginDelta);
if (approval) calls.push(approval);
}
calls.push(buildAdjustTakerCall(perpAddress, params));
return calls;
}

/**
* Full ordered call batch for a maker adjustment; see
* {@link buildAdjustTakerCalls}. Like `openMaker`, an `adjustMaker` that adds
* liquidity can pull the USDC leg of the LP deposit (up to `amt1Limit`) on top
* of the margin, so the approval covers both.
*/
export async function buildAdjustMakerCalls(
context: PerpCityContext,
perpAddress: PerpAddress,
params: {
posId: bigint;
marginDelta: bigint;
liquidityDelta: bigint;
amt0Limit: bigint;
amt1Limit: bigint;
}
): Promise<CallData[]> {
const calls: CallData[] = [];
const marginIn = params.marginDelta > 0n ? params.marginDelta : 0n;
const usdLegIn = params.liquidityDelta > 0n ? params.amt1Limit : 0n;
const requiredApproval = marginIn + usdLegIn;
if (requiredApproval > 0n) {
const approval = await maybeApprovalCall(context, perpAddress, requiredApproval);
if (approval) calls.push(approval);
}
calls.push(buildAdjustMakerCall(perpAddress, params));
return calls;
}

/** Full ordered call batch for opening a maker position; see {@link buildOpenTakerPositionCalls}. */
export async function buildOpenMakerPositionCalls(
context: PerpCityContext,
Expand Down
38 changes: 38 additions & 0 deletions src/functions/perp-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { PerpCityContext } from "../context";
import type { PerpAddress } from "../types";
import type {
CreatePerpParams,
EstimateTakerAdjustResult,
EstimateTakerPositionResult,
OpenMakerPositionParams,
OpenTakerPositionParams,
Expand Down Expand Up @@ -344,6 +345,39 @@ export async function estimateTakerPosition(
}, "estimateTakerPosition");
}

/**
* Quote an arbitrary signed taker adjustment delta against current pool state.
*
* The `adjustTaker` companion to {@link estimateTakerPosition}: pass the exact
* signed `perpDelta` the adjust call will submit (positive buys perp, negative
* sells — an add for a long is positive, a reduce negative, a flip the full
* signed total) and get the simulated USD leg, average fill price, and depth
* flag. Pure client-side math against the constant-liquidity model
* (`simulateTakerSwap`) — no eth_call, so it is safe on reactive quote paths.
* Fees are not modeled; slippage tolerance is expected to absorb them.
*/
export async function estimateTakerAdjust(
context: PerpCityContext,
perpAddress: PerpAddress,
params: { perpDelta: bigint }
): Promise<EstimateTakerAdjustResult> {
return withErrorHandling(async () => {
if (params.perpDelta === 0n) throw new Error("perpDelta must be non-zero");
const perpData = await context.getPerpData(perpAddress);
const swap = simulateTakerSwap({
sqrtPriceX96: perpData.sqrtPriceX96,
liquidity: perpData.liquidity,
perpDelta: params.perpDelta,
markPrice: perpData.mark,
});
return {
usdDelta: swap.usdDelta,
fillPrice: swap.fillPrice,
exceedsLiquidity: swap.exceedsLiquidity,
};
}, "estimateTakerAdjust");
}

export async function adjustTaker(
context: PerpCityContext,
perpAddress: PerpAddress,
Expand All @@ -363,6 +397,8 @@ export async function adjustTaker(
const txHash = await context.walletClient.writeContract(
await withFeeHeadroom(context.publicClient, request)
);
const receipt = await context.publicClient.waitForTransactionReceipt({ hash: txHash });
if (receipt.status === "reverted") throw new Error(`Transaction reverted. Hash: ${txHash}`);
return { txHash };
}, `adjustTaker for position ${params.posId}`);
}
Expand Down Expand Up @@ -392,6 +428,8 @@ export async function adjustMaker(
const txHash = await context.walletClient.writeContract(
await withFeeHeadroom(context.publicClient, request)
);
const receipt = await context.publicClient.waitForTransactionReceipt({ hash: txHash });
if (receipt.status === "reverted") throw new Error(`Transaction reverted. Hash: ${txHash}`);
return { txHash };
}, `adjustMaker for position ${params.posId}`);
}
Expand Down
7 changes: 7 additions & 0 deletions src/types/entity-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,13 @@ export type EstimateTakerPositionResult = {
exceedsLiquidity: boolean;
};

export type EstimateTakerAdjustResult = {
usdDelta: bigint;
fillPrice: number;
/** See {@link EstimateTakerPositionResult.exceedsLiquidity}. */
exceedsLiquidity: boolean;
};

export type CacheConfig = {
ttl: number; // Time to live in milliseconds
maxSize: number; // Maximum cache size
Expand Down
Loading