diff --git a/package-lock.json b/package-lock.json index e7cc0a0..a56717d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@strobelabs/perpcity-sdk", - "version": "0.11.0", + "version": "0.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@strobelabs/perpcity-sdk", - "version": "0.11.0", + "version": "0.13.0", "license": "GPL-3.0", "dependencies": { "@graphql-typed-document-node/core": "^3.2.0", diff --git a/package.json b/package.json index cb0ccf4..12c46fc 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.12.0", + "version": "0.13.0", "type": "module", "license": "MIT", "repository": { diff --git a/src/__tests__/unit/adjust-primitives.test.ts b/src/__tests__/unit/adjust-primitives.test.ts new file mode 100644 index 0000000..fd82566 --- /dev/null +++ b/src/__tests__/unit/adjust-primitives.test.ts @@ -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"); + }); +}); diff --git a/src/functions/calldata.ts b/src/functions/calldata.ts index cf1ab98..4141ccd 100644 --- a/src/functions/calldata.ts +++ b/src/functions/calldata.ts @@ -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 { + 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 { + 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, diff --git a/src/functions/perp-actions.ts b/src/functions/perp-actions.ts index 68eef3c..c46ffbe 100644 --- a/src/functions/perp-actions.ts +++ b/src/functions/perp-actions.ts @@ -6,6 +6,7 @@ import type { PerpCityContext } from "../context"; import type { PerpAddress } from "../types"; import type { CreatePerpParams, + EstimateTakerAdjustResult, EstimateTakerPositionResult, OpenMakerPositionParams, OpenTakerPositionParams, @@ -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 { + 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, @@ -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}`); } @@ -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}`); } diff --git a/src/types/entity-data.ts b/src/types/entity-data.ts index a6db783..b8405a1 100644 --- a/src/types/entity-data.ts +++ b/src/types/entity-data.ts @@ -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