diff --git a/keeper/package.json b/keeper/package.json index ea42073..cd88e6a 100644 --- a/keeper/package.json +++ b/keeper/package.json @@ -24,8 +24,8 @@ "@hashpower/portfolio-margin": "github:Lumerin-protocol/collateral-margin#c34b4a360d6616d017b157a4a9e27e1a8e60079c&path:/portfolio-margin", "amaro": "^1.1.9", "collateral-margin-abi": "github:Lumerin-protocol/collateral-margin#c34b4a360d6616d017b157a4a9e27e1a8e60079c&path:/contracts/abi", - "derivatives-marketplace-abi": "github:Lumerin-protocol/derivatives-marketplace#f7e219f704646ab654a2a8d0286c0f477148e299&path:/contracts/abi", - "futures-marketplace-abi": "github:Lumerin-protocol/futures-marketplace#547ade13eb944b0e86bbef325749ed627cc886b4&path:/contracts/abi", + "derivatives-marketplace-abi": "github:Lumerin-protocol/derivatives-marketplace#8b7ed0f3572d0ea8039a11757b7c1b963be75535&path:/contracts/abi", + "futures-marketplace-abi": "github:Lumerin-protocol/futures-marketplace#05e97deb86a59e6be45fa0aa2ac10b8cb070c303&path:/contracts/abi", "pino": "^10.3.1", "viem": "^2.48.8" }, diff --git a/keeper/pnpm-lock.yaml b/keeper/pnpm-lock.yaml index d96ef49..def7113 100644 --- a/keeper/pnpm-lock.yaml +++ b/keeper/pnpm-lock.yaml @@ -18,11 +18,11 @@ importers: specifier: github:Lumerin-protocol/collateral-margin#c34b4a360d6616d017b157a4a9e27e1a8e60079c&path:/contracts/abi version: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/c34b4a360d6616d017b157a4a9e27e1a8e60079c#path:/contracts/abi derivatives-marketplace-abi: - specifier: github:Lumerin-protocol/derivatives-marketplace#f7e219f704646ab654a2a8d0286c0f477148e299&path:/contracts/abi - version: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/f7e219f704646ab654a2a8d0286c0f477148e299#path:/contracts/abi + specifier: github:Lumerin-protocol/derivatives-marketplace#8b7ed0f3572d0ea8039a11757b7c1b963be75535&path:/contracts/abi + version: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/8b7ed0f3572d0ea8039a11757b7c1b963be75535#path:/contracts/abi futures-marketplace-abi: - specifier: github:Lumerin-protocol/futures-marketplace#547ade13eb944b0e86bbef325749ed627cc886b4&path:/contracts/abi - version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/547ade13eb944b0e86bbef325749ed627cc886b4#path:/contracts/abi + specifier: github:Lumerin-protocol/futures-marketplace#05e97deb86a59e6be45fa0aa2ac10b8cb070c303&path:/contracts/abi + version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/05e97deb86a59e6be45fa0aa2ac10b8cb070c303#path:/contracts/abi pino: specifier: ^10.3.1 version: 10.3.1 @@ -213,8 +213,8 @@ packages: dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} - derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/f7e219f704646ab654a2a8d0286c0f477148e299#path:/contracts/abi: - resolution: {gitHosted: true, integrity: sha512-WoZc9oXcWv0waw6+iZLtWfVyG7mZ6ngTWE5YOi0Nu8+dZ9rsnetk+044w3BHRNMqp0TvX3YuMHMSf6TAIaxtdw==, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/f7e219f704646ab654a2a8d0286c0f477148e299} + derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/8b7ed0f3572d0ea8039a11757b7c1b963be75535#path:/contracts/abi: + resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/8b7ed0f3572d0ea8039a11757b7c1b963be75535} version: 0.0.0 end-of-stream@1.4.5: @@ -229,8 +229,8 @@ packages: fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/547ade13eb944b0e86bbef325749ed627cc886b4#path:/contracts/abi: - resolution: {gitHosted: true, integrity: sha512-LQiUPSYCFvY86dYZEk61cpMWwy3VvhAaYgnn/NUdY+RoifWHKV3PIrpx0Am9x27U840+KyMhkdOMDoGjPJq9MQ==, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/547ade13eb944b0e86bbef325749ed627cc886b4} + futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/05e97deb86a59e6be45fa0aa2ac10b8cb070c303#path:/contracts/abi: + resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/05e97deb86a59e6be45fa0aa2ac10b8cb070c303} version: 0.0.0 help-me@5.0.0: @@ -452,7 +452,7 @@ snapshots: dateformat@4.6.3: {} - derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/f7e219f704646ab654a2a8d0286c0f477148e299#path:/contracts/abi: {} + derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/8b7ed0f3572d0ea8039a11757b7c1b963be75535#path:/contracts/abi: {} end-of-stream@1.4.5: dependencies: @@ -464,7 +464,7 @@ snapshots: fast-safe-stringify@2.1.1: {} - futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/547ade13eb944b0e86bbef325749ed627cc886b4#path:/contracts/abi: {} + futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/05e97deb86a59e6be45fa0aa2ac10b8cb070c303#path:/contracts/abi: {} help-me@5.0.0: {} diff --git a/keeper/src/config.ts b/keeper/src/config.ts index 71eb100..520afcc 100644 --- a/keeper/src/config.ts +++ b/keeper/src/config.ts @@ -177,8 +177,8 @@ export interface Config { */ sweepIntervalMs: number; /** - * Maximum number of `removeOutdatedOrder` calls bundled into a - * single `Futures.multicall(bytes[])` tx. Each call is roughly + * Maximum number of ids passed to one + * `Futures.removeOutdatedOrders(bytes32[])` tx. Each cleanup is roughly * 50-80k gas (one `_closeOrder` traversal); 50 keeps us well under * Base's 30M block-gas limit (~4M worst case). Larger user-side * fan-outs split across multiple sequential txs. @@ -221,8 +221,8 @@ export interface Config { */ bootstrapUsers: readonly Address[]; /** - * Maximum number of `settlePosition` calls bundled into a single - * `Futures.multicall(bytes[])` transaction. Trades a single nonce per + * Maximum number of position pairs passed to a single + * `Futures.settlePositions(address[],uint256[])` transaction. Trades a single nonce per * sweep tick (no replacement-underpriced races) for one bigger tx. * Capped to keep gas usage well under the block limit — Base has 30M * block gas, each `settlePosition` is roughly 200-300k gas, so 50 is diff --git a/keeper/src/delivery/coordinator.ts b/keeper/src/delivery/coordinator.ts index ceaa3ae..bba247b 100644 --- a/keeper/src/delivery/coordinator.ts +++ b/keeper/src/delivery/coordinator.ts @@ -1,7 +1,6 @@ import { BaseError, ContractFunctionRevertedError, - encodeFunctionData, getAddress, type Address, type Hex, @@ -431,31 +430,14 @@ export class DeliveryCoordinator { if (this.config.keeper.dryRun) { this.logger.info( { batchSize: settleable.length }, - "[dryRun] would call Futures.multicall(settlePosition × N)", + "[dryRun] would call Futures.settlePositions", ); for (const pos of settleable) this.dropTracked(pos.user, pos.expirationAt); return; } - const calldatas: Hex[] = []; - const encodable: TrackedPosition[] = []; - for (const pos of settleable) { - try { - const data = encodeFunctionData({ - abi: FuturesAbi, - functionName: "settlePosition", - args: [pos.user, pos.expirationAt], - }); - calldatas.push(data); - encodable.push(pos); - } catch (err) { - this.logger.error( - { err, user: pos.user, expirationAt: pos.expirationAt.toString() }, - "delivery: encodeFunctionData threw — dropping malformed entry from batch", - ); - } - } - if (calldatas.length === 0) return; + const users = settleable.map((pos) => pos.user); + const expirationAts = settleable.map((pos) => pos.expirationAt); type WriteParams = Parameters< typeof this.chain.walletClient.writeContract @@ -466,8 +448,8 @@ export class DeliveryCoordinator { this.chain.walletClient.writeContract({ address: this.config.futures.address, abi: FuturesAbi, - functionName: "multicall", - args: [calldatas], + functionName: "settlePositions", + args: [users, expirationAts], account: this.chain.account, chain: this.chain.walletClient.chain ?? null, } as unknown as WriteParams), @@ -481,10 +463,10 @@ export class DeliveryCoordinator { return; } this.logger.warn( - { err, batchSize: encodable.length }, + { err, batchSize: settleable.length }, "delivery batch: write reverted — falling back to per-position retries", ); - for (const pos of encodable) { + for (const pos of settleable) { try { await this.attemptSettle(pos); } catch (innerErr) { @@ -505,13 +487,13 @@ export class DeliveryCoordinator { { hash, blockNumber: receipt.blockNumber.toString(), - batchSize: encodable.length, + batchSize: settleable.length, ...formatGasCost(receipt, this.ethUsdFeed), }, - "delivery batch: multicall confirmed", + "delivery batch: settlePositions confirmed", ); - for (const pos of encodable) { + for (const pos of settleable) { this.dropTracked(pos.user, pos.expirationAt); } } diff --git a/keeper/src/predict/snapshot.ts b/keeper/src/predict/snapshot.ts index 718e9ad..db630ca 100644 --- a/keeper/src/predict/snapshot.ts +++ b/keeper/src/predict/snapshot.ts @@ -63,7 +63,7 @@ export async function readMMParams( * Read everything needed to evaluate `mmSurplus(P)` for a single user as a * function of price. Two RPC round-trips: * - * 1. Bulk multicall: balance, both venues' `getRiskView` / `getOrderValues`, + * 1. Bulk multicall: balance, both venues' `getRiskView` / `getOrderAggregate`, * the perp position, futures activeExpirationAts. * 2. Per-expiry multicall: hydrate each aggregate via `getUserPosition`, plus * its `settlementPrice` — an expiry that has settled but not yet been swept @@ -75,7 +75,7 @@ export async function readMMParams( * * `getRiskView` carries the per-side order delta but reports fill loss only at the * current mark, and the clamp makes that non-invertible once it reads zero — so the - * per-side limit-price totals come from `getOrderValues` and the predictor derives + * per-side limit-price totals come from `getOrderAggregate` and the predictor derives * fill loss at whatever price it is evaluating. Pending funding also rides in * `getRiskView`, replacing the separate `getPendingFunding` read. */ @@ -88,9 +88,9 @@ export async function readAccountSnapshot( balance, perpPosition, perpRisk, - perpOrderValues, + perpOrderAggregate, futuresRisk, - futuresOrderValues, + futuresOrderAggregate, activeExpirationAts, ] = await chain.publicClient.multicall({ contracts: [ @@ -115,7 +115,7 @@ export async function readAccountSnapshot( { address: config.perps.address, abi: HashPowerPerpsDEXAbi, - functionName: "getOrderValues" as const, + functionName: "getOrderAggregate" as const, args: [user] as const, }, { @@ -127,7 +127,7 @@ export async function readAccountSnapshot( { address: config.futures.address, abi: FuturesAbi, - functionName: "getOrderValues" as const, + functionName: "getOrderAggregate" as const, args: [user] as const, }, { @@ -182,26 +182,26 @@ export async function readAccountSnapshot( perp: { netQty: perpPosition.netQuantity, entryPrice: perpPosition.aggregatedEntryPrice, - orders: restingOrders(perpRisk, perpOrderValues), + orders: restingOrders(perpRisk, perpOrderAggregate), // PME uses `max(0, pendingFunding)` — only what the user owes. fundingOwed: funding > 0n ? funding : 0n, }, futures: { positions: futuresPositions, - orders: restingOrders(futuresRisk, futuresOrderValues), + orders: restingOrders(futuresRisk, futuresOrderAggregate), }, }; } -/** Pair a venue's `getRiskView` deltas with its `getOrderValues` limit-price totals. */ +/** Pair a venue's risk deltas with its cached order aggregate. */ function restingOrders( risk: { buyOrderDelta: bigint; sellOrderDelta: bigint }, - values: readonly [bigint, bigint], + aggregate: { buyValue: bigint; sellValue: bigint }, ): AccountSnapshot["perp"]["orders"] { return { buyDelta: risk.buyOrderDelta, sellDelta: risk.sellOrderDelta, - buyValue: values[0], - sellValue: values[1], + buyValue: aggregate.buyValue, + sellValue: aggregate.sellValue, }; } diff --git a/keeper/src/runtime/outdatedOrderSweeper.ts b/keeper/src/runtime/outdatedOrderSweeper.ts index 8043fe2..126060c 100644 --- a/keeper/src/runtime/outdatedOrderSweeper.ts +++ b/keeper/src/runtime/outdatedOrderSweeper.ts @@ -1,10 +1,4 @@ -import { - BaseError, - ContractFunctionRevertedError, - encodeFunctionData, - type Address, - type Hex, -} from "viem"; +import { type Address, type Hex } from "viem"; import type pino from "pino"; import { FuturesAbi } from "futures-marketplace-abi/Futures.ts"; import { withUnstickRetry } from "../tx/unstick.ts"; @@ -39,20 +33,26 @@ import type { ParticipantTracker } from "../discovery/tracker.ts"; * tick → for each tracked user: * 1. readContract `getUserOrders(user)` — empty? skip * 2. multicall `getOrder(id)` for each id → filter expired - * 3. one `Futures.multicall([removeOutdatedOrder(id1), ...])` write + * 3. one `Futures.removeOutdatedOrders([id1, ...])` write * (capped at `outdatedOrders.maxBatchSize`; larger user-side * fan-outs are split into N batches, each its own tx). * - * Recoverable reverts during simulation (`OrderNotExists`, `OrderNotExpired`) - * just drop the id from the batch — they happen when an id closed between - * our read and our write (user cancel, match, prior keeper instance won the - * race). We log them at debug because they're entirely benign. + * The typed batch skips stale and not-yet-expired ids on-chain, so a user + * cancellation or competing keeper cannot revert unrelated cleanup work. * * Non-futures venues (perps) don't have order expiry so this module is * Futures-only by design. */ -const RECOVERABLE_REVERTS = new Set(["OrderNotExists", "OrderNotExpired"]); +const FUTURES_REMOVE_OUTDATED_ORDERS_ABI = [ + { + type: "function", + name: "removeOutdatedOrders", + stateMutability: "nonpayable", + inputs: [{ name: "_orderIds", type: "bytes32[]" }], + outputs: [{ name: "removed", type: "uint256" }], + }, +] as const; interface ExpiredOrder { user: Address; @@ -251,72 +251,21 @@ export class OutdatedOrderSweeper { } /** - * Simulates each `removeOutdatedOrder(id)` to filter stale entries - * (`OrderNotExists` / `OrderNotExpired` — usually a race against a user - * cancel or a prior keeper run), then encodes the survivors into one - * `Futures.multicall(bytes[])` write. - * - * Returns the number of orders actually broadcast for closure (zero on - * dry-run or empty-batch-after-filter — both are normal). Throws only on - * unexpected reverts during the write phase; transient RPC failures are - * caught and logged so the next sweep retries. + * Sends one race-tolerant `removeOutdatedOrders(ids)` write. The contract + * skips stale/live ids and preserves every valid cleanup in the batch. */ private async closeBatch(batch: readonly ExpiredOrder[]): Promise { - type SimParams = Parameters< - typeof this.chain.publicClient.simulateContract - >[0]; - - const simResults = await Promise.allSettled( - batch.map((entry) => - this.chain.publicClient.simulateContract({ - address: this.config.futures.address, - abi: FuturesAbi, - functionName: "removeOutdatedOrder", - args: [entry.orderId], - account: this.chain.account, - } as unknown as SimParams), - ), - ); - - const survivors: ExpiredOrder[] = []; - for (let i = 0; i < batch.length; i++) { - const entry = batch[i] as ExpiredOrder; - const r = simResults[i] as PromiseSettledResult; - if (r.status === "fulfilled") { - survivors.push(entry); - continue; - } - const decoded = decodeRecoverableRevert(r.reason); - if (decoded !== undefined) { - this.logger.debug( - { orderId: entry.orderId, user: entry.user, revert: decoded }, - "skipping stale candidate (state moved between read and simulate)", - ); - continue; - } - this.logger.warn( - { err: r.reason, orderId: entry.orderId, user: entry.user }, - "simulate failed with non-recoverable error — dropping from batch", - ); - } - - if (survivors.length === 0) return 0; + if (batch.length === 0) return 0; if (this.config.keeper.dryRun) { this.logger.info( - { batchSize: survivors.length }, - "[dryRun] would call Futures.multicall(removeOutdatedOrder × N)", + { batchSize: batch.length }, + "[dryRun] would call Futures.removeOutdatedOrders", ); return 0; } - const calldatas: Hex[] = survivors.map((entry) => - encodeFunctionData({ - abi: FuturesAbi, - functionName: "removeOutdatedOrder", - args: [entry.orderId], - }), - ); + const orderIds = batch.map((entry) => entry.orderId); type WriteParams = Parameters< typeof this.chain.walletClient.writeContract @@ -330,9 +279,9 @@ export class OutdatedOrderSweeper { hash = await withUnstickRetry(this.chain, this.logger, () => this.chain.walletClient.writeContract({ address: this.config.futures.address, - abi: FuturesAbi, - functionName: "multicall", - args: [calldatas], + abi: FUTURES_REMOVE_OUTDATED_ORDERS_ABI, + functionName: "removeOutdatedOrders", + args: [orderIds], account: this.chain.account, chain: this.chain.walletClient.chain ?? null, } as unknown as WriteParams), @@ -342,7 +291,7 @@ export class OutdatedOrderSweeper { // want unhandled rejection on the setInterval-fired path to crash // the keeper, so always swallow and log. this.logger.warn( - { err, batchSize: survivors.length }, + { err, batchSize: batch.length }, "tx submission failed — sweep will retry", ); return 0; @@ -356,22 +305,11 @@ export class OutdatedOrderSweeper { { hash, blockNumber: receipt.blockNumber.toString(), - batchSize: survivors.length, + batchSize: batch.length, ...formatGasCost(receipt, this.ethUsdFeed), }, - "multicall(removeOutdatedOrder × N) confirmed", + "removeOutdatedOrders confirmed", ); - return survivors.length; + return batch.length; } } - -function decodeRecoverableRevert(err: unknown): string | undefined { - if (!(err instanceof BaseError)) return undefined; - const revert = err.walk((e) => e instanceof ContractFunctionRevertedError); - if (!(revert instanceof ContractFunctionRevertedError)) return undefined; - const name = revert.data?.errorName; - if (typeof name !== "string") return undefined; - return RECOVERABLE_REVERTS.has(name) ? name : undefined; -} - -export const __testing = { decodeRecoverableRevert }; diff --git a/keeper/tests/delivery/coordinator.test.ts b/keeper/tests/delivery/coordinator.test.ts index 97f5890..3e447f8 100644 --- a/keeper/tests/delivery/coordinator.test.ts +++ b/keeper/tests/delivery/coordinator.test.ts @@ -95,6 +95,7 @@ interface ChainStubOptions { activeDatesByUser?: Record; positionsByUserDate?: Record; readContractError?: (functionName: string) => Error | undefined; + writes?: Array<{ functionName: string; args: readonly unknown[] }>; } function posKey(user: Address, expirationAt: bigint): string { @@ -176,7 +177,16 @@ function makeChain(opts: ChainStubOptions = {}): Chain { }, walletClient: { chain: null, - writeContract: async () => writeHash, + writeContract: async ({ + functionName, + args, + }: { + functionName: string; + args: readonly unknown[]; + }) => { + opts.writes?.push({ functionName, args }); + return writeHash; + }, }, } as unknown as Chain; } @@ -285,6 +295,7 @@ describe("delivery/coordinator: bootstrap + settle", () => { it("settleBatch simulates settlePosition(user, expirationAt) and drops on success", async () => { const simulated: unknown[][] = []; + const writes: Array<{ functionName: string; args: readonly unknown[] }> = []; const chain = makeChain({ // Far-future timestamp so bootstrap's trailing sweep is a no-op. blockTimestamp: 1n, @@ -296,6 +307,7 @@ describe("delivery/coordinator: bootstrap + settle", () => { positionsByUserDate: { [posKey(USER_A, DELIVERY_A)]: { netQuantity: 1n, netEntryValue: 50n }, }, + writes, }); const coord = new DeliveryCoordinator(chain, makeConfig({ settleDelayMs: 0 }), silentLogger); await coord.bootstrapFromUsers([USER_A]); @@ -307,6 +319,13 @@ describe("delivery/coordinator: bootstrap + settle", () => { USER_A.toLowerCase(), ); assert.equal(simulated[0]?.[1], DELIVERY_A); + assert.equal(writes.length, 1); + assert.equal(writes[0]?.functionName, "settlePositions"); + assert.equal( + ((writes[0]?.args[0] as Address[])[0] as string).toLowerCase(), + USER_A.toLowerCase(), + ); + assert.deepEqual(writes[0]?.args[1], [DELIVERY_A]); assert.equal(coord.has(USER_A, DELIVERY_A), false); }); diff --git a/keeper/tests/integration/buildKeeper.ts b/keeper/tests/integration/buildKeeper.ts index 2da307e..ae84766 100644 --- a/keeper/tests/integration/buildKeeper.ts +++ b/keeper/tests/integration/buildKeeper.ts @@ -86,7 +86,7 @@ export interface BuildKeeperOverrides { */ deliveryBootstrapUsers?: readonly Address[]; /** - * Maximum settlePosition calls bundled into one Futures.multicall tx by + * Maximum position pairs passed to one Futures.settlePositions tx by * the delivery coordinator. Defaults to 50 for parity with production. * Override to a small value to assert batching behaviour explicitly * (e.g. set to 1 to force per-id calls, or 2 to assert chunked sweeps). diff --git a/keeper/tests/predict/coordinator.test.ts b/keeper/tests/predict/coordinator.test.ts index be7f4f3..53c0a63 100644 --- a/keeper/tests/predict/coordinator.test.ts +++ b/keeper/tests/predict/coordinator.test.ts @@ -129,8 +129,8 @@ function buildHarness({ buyOrderFillLoss: 0n, sellOrderFillLoss: 0n, }; - case "getOrderValues": - return [0n, 0n]; + case "getOrderAggregate": + return { buyQty: 0n, sellQty: 0n, buyValue: 0n, sellValue: 0n }; case "getActiveExpirationDates": return []; case "computePortfolioIM": diff --git a/keeper/tests/predict/coordinatorAlerts.test.ts b/keeper/tests/predict/coordinatorAlerts.test.ts index bfaad08..10529c8 100644 --- a/keeper/tests/predict/coordinatorAlerts.test.ts +++ b/keeper/tests/predict/coordinatorAlerts.test.ts @@ -104,8 +104,8 @@ function buildHarness({ balance, perpEntry }: { balance: bigint; perpEntry: bigi buyOrderFillLoss: 0n, sellOrderFillLoss: 0n, }; - case "getOrderValues": - return [0n, 0n]; + case "getOrderAggregate": + return { buyQty: 0n, sellQty: 0n, buyValue: 0n, sellValue: 0n }; case "getActiveExpirationDates": return []; case "computePortfolioIM": diff --git a/keeper/tests/predict/snapshot.test.ts b/keeper/tests/predict/snapshot.test.ts index 243fc6a..efe87df 100644 --- a/keeper/tests/predict/snapshot.test.ts +++ b/keeper/tests/predict/snapshot.test.ts @@ -90,10 +90,15 @@ function makeChain(scripted: { sellOrderFillLoss: 0n, }; } - case "getOrderValues": { + case "getOrderAggregate": { const orders = (c.address === PERPS ? scripted.perpOrders : scripted.futuresOrders) ?? NO_ORDERS; - return [orders.buyValue, orders.sellValue]; + return { + buyQty: 0n, + sellQty: 0n, + buyValue: orders.buyValue, + sellValue: orders.sellValue, + }; } case "getActiveExpirationDates": return scripted.activeExpirationAts ?? []; @@ -137,7 +142,7 @@ describe("predict/snapshot: readAccountSnapshot", () => { assert.deepEqual(snap.futures.orders, NO_ORDERS); }); - it("pairs each venue's getRiskView deltas with its getOrderValues totals", async () => { + it("pairs each venue's risk deltas with its order aggregate totals", async () => { const perpOrders: RestingOrders = { buyDelta: 2_000_000n, sellDelta: 500_000n, diff --git a/keeper/tests/runtime/outdatedOrderSweeper.test.ts b/keeper/tests/runtime/outdatedOrderSweeper.test.ts index ebdb98d..a051584 100644 --- a/keeper/tests/runtime/outdatedOrderSweeper.test.ts +++ b/keeper/tests/runtime/outdatedOrderSweeper.test.ts @@ -1,14 +1,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { - BaseError, - ContractFunctionRevertedError, - decodeFunctionData, - type Address, - type Hex, -} from "viem"; +import { type Address, type Hex } from "viem"; import pino from "pino"; -import { FuturesAbi } from "futures-marketplace-abi/Futures.ts"; import { OutdatedOrderSweeper } from "../../src/runtime/outdatedOrderSweeper.ts"; import type { Chain } from "../../src/chain.ts"; import type { Config } from "../../src/config.ts"; @@ -60,22 +53,18 @@ interface FakeChainOpts { blockTimestamp: bigint; orderIdsByUser: Map; orders: Map; - /** Simulate failure: returns an `errorName` for the given orderId, else undefined. */ - simulateRevert?: (orderId: Hex) => string | undefined; } interface Recorded { readContractCalls: number; multicallReadCalls: number; - simulateCalls: Hex[]; - writeCalls: Array<{ functionName: string; calldatas: Hex[] }>; + writeCalls: Array<{ functionName: string; orderIds: Hex[] }>; } function makeChain(opts: FakeChainOpts): { chain: Chain; recorded: Recorded } { const recorded: Recorded = { readContractCalls: 0, multicallReadCalls: 0, - simulateCalls: [], writeCalls: [], }; @@ -121,24 +110,6 @@ function makeChain(opts: FakeChainOpts): { chain: Chain; recorded: Recorded } { }; }); }, - simulateContract: async ({ - functionName, - args, - }: { - functionName: string; - args: unknown[]; - }) => { - if (functionName !== "removeOutdatedOrder") { - throw new Error(`unexpected simulate fn: ${functionName}`); - } - const orderId = args[0] as Hex; - recorded.simulateCalls.push(orderId); - const errorName = opts.simulateRevert?.(orderId); - if (errorName !== undefined) { - throw new MockRevertError(errorName); - } - return { request: { functionName, args } }; - }, waitForTransactionReceipt: async () => ({ blockNumber: 1n, gasUsed: 200_000n, @@ -155,10 +126,10 @@ function makeChain(opts: FakeChainOpts): { chain: Chain; recorded: Recorded } { functionName: string; args: unknown[]; }) => { - if (functionName !== "multicall") { + if (functionName !== "removeOutdatedOrders") { throw new Error(`unexpected write fn: ${functionName}`); } - recorded.writeCalls.push({ functionName, calldatas: args[0] as Hex[] }); + recorded.writeCalls.push({ functionName, orderIds: args[0] as Hex[] }); return "0xabc" as Hex; }, }; @@ -171,20 +142,6 @@ function makeChain(opts: FakeChainOpts): { chain: Chain; recorded: Recorded } { return { chain, recorded }; } -/** Mimics a viem ContractFunctionRevertedError so `BaseError.walk` finds it. */ -class MockRevertError extends BaseError { - override name = "ContractFunctionExecutionError"; - constructor(errorName: string) { - const inner = new ContractFunctionRevertedError({ - abi: FuturesAbi, - data: undefined, - functionName: "removeOutdatedOrder", - }); - (inner as unknown as { data: { errorName: string } }).data = { errorName }; - super("simulated revert", { cause: inner }); - } -} - function makeConfig(overrides: Partial = {}): Config { return { futures: { address: FUTURES }, @@ -260,11 +217,10 @@ describe("OutdatedOrderSweeper", () => { ); const closed = await sweeper.runSweep(); assert.equal(closed, 0); - assert.equal(recorded.simulateCalls.length, 0); assert.equal(recorded.writeCalls.length, 0); }); - it("batches all expired orders for a user into a single multicall write", async () => { + it("batches all expired orders for a user into one typed write", async () => { const { logger, calls } = makeRecordingLogger(); const id1 = ("0x" + "11".repeat(32)) as Hex; const id2 = ("0x" + "22".repeat(32)) as Hex; @@ -289,21 +245,12 @@ describe("OutdatedOrderSweeper", () => { assert.equal(closed, 2); assert.equal(recorded.writeCalls.length, 1); - assert.deepEqual(recorded.simulateCalls, [id1, id2]); - - // Decode each calldata to confirm both are `removeOutdatedOrder()`. - const decoded = recorded.writeCalls[0]!.calldatas.map((cd) => - decodeFunctionData({ abi: FuturesAbi, data: cd }), - ); - assert.equal(decoded.length, 2); - assert.equal(decoded[0]?.functionName, "removeOutdatedOrder"); - assert.equal(decoded[0]?.args?.[0], id1); - assert.equal(decoded[1]?.functionName, "removeOutdatedOrder"); - assert.equal(decoded[1]?.args?.[0], id2); + assert.equal(recorded.writeCalls[0]!.functionName, "removeOutdatedOrders"); + assert.deepEqual(recorded.writeCalls[0]!.orderIds, [id1, id2]); assert.ok( calls.some((c) => c.level === "info" && c.msg.includes("confirmed")), - "expected an INFO log when the multicall write confirms", + "expected an INFO log when the batch write confirms", ); }); @@ -333,9 +280,9 @@ describe("OutdatedOrderSweeper", () => { assert.equal( recorded.writeCalls.length, 1, - "one multicall write for cross-user batch", + "one typed write for cross-user batch", ); - assert.equal(recorded.writeCalls[0]!.calldatas.length, 2); + assert.deepEqual(recorded.writeCalls[0]!.orderIds, [idA, idB]); }); it("splits across multiple writes when batch size cap is exceeded", async () => { @@ -363,54 +310,12 @@ describe("OutdatedOrderSweeper", () => { // 5 expired / batch of 2 → ceil(5/2) = 3 writes assert.equal(recorded.writeCalls.length, 3); assert.deepEqual( - recorded.writeCalls.map((c) => c.calldatas.length), + recorded.writeCalls.map((c) => c.orderIds.length), [2, 2, 1], ); }); - it("drops stale-state candidates flagged by simulate (OrderNotExists / OrderNotExpired)", async () => { - // Race scenario: between our `getOrder` read and our simulate, the - // user (or a concurrent keeper) closed orderId1, and orderId2 had its - // expirationAt bumped. The sweeper must skip them silently and still - // broadcast a write for the survivor (orderId3). - const { logger, calls } = makeRecordingLogger(); - const id1 = ("0x" + "11".repeat(32)) as Hex; - const id2 = ("0x" + "22".repeat(32)) as Hex; - const id3 = ("0x" + "33".repeat(32)) as Hex; - const { chain, recorded } = makeChain({ - blockTimestamp: 10_000n, - orderIdsByUser: new Map([[USER_A, [id1, id2, id3]]]), - orders: new Map([ - [id1, { participant: USER_A, expirationAt: 5_000n }], - [id2, { participant: USER_A, expirationAt: 6_000n }], - [id3, { participant: USER_A, expirationAt: 7_000n }], - ]), - simulateRevert: (id) => { - if (id === id1) return "OrderNotExists"; - if (id === id2) return "OrderNotExpired"; - return undefined; - }, - }); - const sweeper = new OutdatedOrderSweeper( - chain, - makeConfig(), - makeTracker([USER_A]), - logger, - ); - const closed = await sweeper.runSweep(); - assert.equal(closed, 1); - assert.equal(recorded.writeCalls.length, 1); - assert.equal(recorded.writeCalls[0]!.calldatas.length, 1); - // Stale-state skips are debug — they're benign and shouldn't pollute INFO. - assert.equal( - calls.filter( - (c) => c.level === "warn" && c.msg.includes("non-recoverable"), - ).length, - 0, - ); - }); - - it("skips the write entirely on dry-run but still simulates", async () => { + it("skips the write entirely on dry-run", async () => { const { logger, calls } = makeRecordingLogger(); const id1 = ("0x" + "11".repeat(32)) as Hex; const { chain, recorded } = makeChain({ @@ -429,11 +334,6 @@ describe("OutdatedOrderSweeper", () => { const closed = await sweeper.runSweep(); assert.equal(closed, 0); assert.equal(recorded.writeCalls.length, 0); - assert.equal( - recorded.simulateCalls.length, - 1, - "simulate runs so dry-run still surfaces reverts", - ); assert.ok(calls.some((c) => c.msg.startsWith("[dryRun]"))); }); @@ -451,7 +351,6 @@ describe("OutdatedOrderSweeper", () => { const recorded: Recorded = { readContractCalls: 0, multicallReadCalls: 0, - simulateCalls: [], writeCalls: [], }; const publicClient = { @@ -479,10 +378,6 @@ describe("OutdatedOrderSweeper", () => { }; }); }, - simulateContract: async ({ args }: { args: unknown[] }) => { - recorded.simulateCalls.push(args[0] as Hex); - return { request: {} }; - }, waitForTransactionReceipt: async () => ({ blockNumber: 1n, gasUsed: 0n, @@ -493,8 +388,8 @@ describe("OutdatedOrderSweeper", () => { chain: null, writeContract: async ({ args }: { args: unknown[] }) => { recorded.writeCalls.push({ - functionName: "multicall", - calldatas: args[0] as Hex[], + functionName: "removeOutdatedOrders", + orderIds: args[0] as Hex[], }); return "0xabc" as Hex; }, diff --git a/keeper/tests/venues/reduceToTarget.test.ts b/keeper/tests/venues/reduceToTarget.test.ts index bc70084..911cac4 100644 --- a/keeper/tests/venues/reduceToTarget.test.ts +++ b/keeper/tests/venues/reduceToTarget.test.ts @@ -31,7 +31,7 @@ const EMPTY_RISK_VIEW = { /** * The bulk read `readAccountSnapshot` issues, in order: balance, the perp position, - * then each venue's `getRiskView` / `getOrderValues` pair, then the active futures + * then each venue's `getRiskView` / `getOrderAggregate` pair, then the active futures * expiries. Only the expiry list varies between these cases. */ function snapshotMulticall(balance: bigint, expiries: readonly bigint[]) { diff --git a/market-maker/src/adapters/futures/venue.ts b/market-maker/src/adapters/futures/venue.ts index f232b50..ec4c86a 100644 --- a/market-maker/src/adapters/futures/venue.ts +++ b/market-maker/src/adapters/futures/venue.ts @@ -260,36 +260,6 @@ export class FuturesVenueAdapter implements VenueAdapter { } } - /** @deprecated Prefer {@link sendCall} with a single `updateOrders` encoding. */ - async multicall( - calls: `0x${string}`[], - opts: { maxFeePerGas?: bigint; nonce?: number } = {}, - ): Promise<`0x${string}`> { - try { - return await this.wallet.walletClient.writeContract({ - address: this.address, - abi: FuturesAbi, - functionName: "multicall", - args: [calls], - account: this.wallet.account, - chain: this.chain, - maxFeePerGas: opts.maxFeePerGas, - nonce: opts.nonce, - }); - } catch (err) { - throw attachTenderlyUrl(err, { - chainId: this.chain.id, - from: this.wallet.account.address, - to: this.address, - data: encodeFunctionData({ - abi: FuturesAbi, - functionName: "multicall", - args: [calls], - }), - }); - } - } - // ── Internal helpers ──────────────────────────────────────────────────── async resolveAddresses(): Promise<{ diff --git a/market-maker/src/adapters/perps/venue.ts b/market-maker/src/adapters/perps/venue.ts index c1586f3..60873ab 100644 --- a/market-maker/src/adapters/perps/venue.ts +++ b/market-maker/src/adapters/perps/venue.ts @@ -168,36 +168,6 @@ export class PerpsVenueAdapter implements VenueAdapter { } } - /** @deprecated Prefer {@link sendCall} with a single `updateOrders` encoding. */ - async multicall( - calls: `0x${string}`[], - opts: { maxFeePerGas?: bigint; nonce?: number } = {}, - ): Promise<`0x${string}`> { - try { - return await this.wallet.walletClient.writeContract({ - address: this.address, - abi: HashPowerPerpsDEXAbi, - functionName: "multicall", - args: [calls], - account: this.wallet.account, - chain: this.chain, - maxFeePerGas: opts.maxFeePerGas, - nonce: opts.nonce, - }); - } catch (err) { - throw attachTenderlyUrl(err, { - chainId: this.chain.id, - from: this.wallet.account.address, - to: this.address, - data: encodeFunctionData({ - abi: HashPowerPerpsDEXAbi, - functionName: "multicall", - args: [calls], - }), - }); - } - } - // ── Internal helpers used by the collateral account & instrument ───────── async resolveAddresses(): Promise<{ diff --git a/market-maker/src/core/adapter.ts b/market-maker/src/core/adapter.ts index 70f7c27..71bfb68 100644 --- a/market-maker/src/core/adapter.ts +++ b/market-maker/src/core/adapter.ts @@ -385,7 +385,7 @@ export interface InstrumentAdapter { export type VenueKind = "perps" | "futures"; /** - * Per-venue interface. One per process; owns the wallet, the multicall route, + * Per-venue interface. One per process; owns the wallet, read-batching route, * the venue-events stream, and the collateral account. Single-instrument * venues (perps, futures) expose `getInstrument()` directly; a future * multi-instrument venue (options) would expose `listInstruments()` instead. @@ -427,13 +427,4 @@ export interface VenueAdapter { data: `0x${string}`, opts: { maxFeePerGas?: bigint; nonce?: number }, ): Promise<`0x${string}`>; - - /** - * @deprecated Prefer {@link sendCall} with a single `updateOrders` encoding. - * Multicall wrapping is no longer used by the portfolio coordinator. - */ - multicall( - calls: `0x${string}`[], - opts: { maxFeePerGas?: bigint; nonce?: number }, - ): Promise<`0x${string}`>; } diff --git a/market-maker/tests/core/errSerializer.test.ts b/market-maker/tests/core/errSerializer.test.ts index 58c6ebb..d2458d6 100644 --- a/market-maker/tests/core/errSerializer.test.ts +++ b/market-maker/tests/core/errSerializer.test.ts @@ -71,12 +71,12 @@ describe("serializeError", () => { const err = Object.assign(new Error("contract reverted"), { shortMessage: "reverted", contractAddress: "0xabc", - functionName: "multicall", + functionName: "updateOrders", sender: "0xdef", }); const out = serializeError(err); assert.equal(out.contractAddress, "0xabc"); - assert.equal(out.functionName, "multicall"); + assert.equal(out.functionName, "updateOrders"); assert.equal(out.sender, "0xdef"); }); diff --git a/portfolio-margin/src/types.ts b/portfolio-margin/src/types.ts index 472e406..15016a2 100644 --- a/portfolio-margin/src/types.ts +++ b/portfolio-margin/src/types.ts @@ -8,7 +8,7 @@ export type Address = `0x${string}`; /** * A venue's resting book reduced to what the margin math needs, as reported by - * `ILinearMarket.getRiskView` plus the venue's `getOrderValues`. + * `ILinearMarket.getRiskView` plus the venue's `getOrderAggregate`. * * Nothing here is constant in P. The engine stresses order delta as part of net * delta, and the fill-loss terms are `max(0, value − P × delta / 10^tokenDecimals)` @@ -37,8 +37,8 @@ export interface RestingOrders { * predictor can re-evaluate at any new price without further RPC reads. * * Shapes deliberately mirror the on-chain getters: - * - perps: `getRiskView` + `getOrderValues` + `getUserPosition` - * - futures: `getRiskView` + `getOrderValues` + `getActiveExpirationDates`/`getUserPosition` + * - perps: `getRiskView` + `getOrderAggregate` + `getUserPosition` + * - futures: `getRiskView` + `getOrderAggregate` + `getActiveExpirationDates`/`getUserPosition` * * Bigints throughout because PME math is performed in token-decimal units * (typically USDC = 6 decimals) with intermediate WAD scaling. JS numbers