diff --git a/src/handlers/execute-action/orders.ts b/src/handlers/execute-action/orders.ts index c757755..f24ccea 100644 --- a/src/handlers/execute-action/orders.ts +++ b/src/handlers/execute-action/orders.ts @@ -10,6 +10,7 @@ import { emitOrderExecutionTelemetryInBackground, extractOrderTelemetryIds, } from "../../helpers/order-telemetry"; +import { classifyPassiveOrderError } from "../../helpers/passive-order"; import { safeLogError, safeLogRedactedError, @@ -47,11 +48,23 @@ async function handleCreateOrder(ctx: ExecuteActionContext): Promise { const orderValue = parsePayloadForAction(ctx, CreateOrderPayloadSchema); if (orderValue === null) return; + const isPassiveOrder = orderValue.orderIntent === "passive_only"; + if (isPassiveOrder && orderValue.orderType !== "limit") { + return ctx.wrappedCallback( + { + code: grpc.status.INVALID_ARGUMENT, + message: + "ValidationError: passive_only order intent requires a limit order", + }, + null, + ); + } const createOrderParams = { ...orderValue.params, ...(orderValue.clientOrderId !== undefined && { clientOrderId: orderValue.clientOrderId, }), + ...(isPassiveOrder && { postOnly: true }), }; let resolvedOrderTelemetry: { symbol?: string; @@ -59,6 +72,12 @@ async function handleCreateOrder(ctx: ExecuteActionContext): Promise { requestedQuantity?: number; } = {}; let marketMetadataHash: string | undefined; + // A passive error code is a statement about what the VENUE did with our + // submission. Failures before the call (policy resolution, metadata capture) + // never reached the venue, and failures after it leave a real order resting — + // reporting either as a passive rejection would tell the client its rung was + // never placed and invite a duplicate repost. + let submission: "not_attempted" | "in_flight" | "placed" = "not_attempted"; try { if (!broker) { return ctx.wrappedCallback( @@ -117,6 +136,7 @@ async function handleCreateOrder(ctx: ExecuteActionContext): Promise { ...telemetryIds, }, ); + submission = "in_flight"; const order = await broker.createOrder( resolution.symbol, orderValue.orderType, @@ -125,6 +145,7 @@ async function handleCreateOrder(ctx: ExecuteActionContext): Promise { orderValue.price, createOrderParams, ); + submission = "placed"; const createOrderContext = { action: "CreateOrder" as const, cex, @@ -149,7 +170,14 @@ async function handleCreateOrder(ctx: ExecuteActionContext): Promise { undefined, { marketMetadataHash }, ); - ctx.wrappedCallback(null, { result: JSON.stringify({ ...order }) }); + ctx.wrappedCallback(null, { + result: JSON.stringify({ + ...order, + ...(isPassiveOrder && { + passivePlacementOutcome: "accepted_passive", + }), + }), + }); } catch (error) { rethrowArchiveDurabilityError(error); safeLogRedactedError("Order Creation failed", error); @@ -178,6 +206,13 @@ async function handleCreateOrder(ctx: ExecuteActionContext): Promise { error, { marketMetadataHash }, ); + if (isPassiveOrder && submission === "in_flight") { + const passiveErrorCode = classifyPassiveOrderError(error); + return rejectWithGrpcError(ctx, error, { + message: `${passiveErrorCode}: ${sanitizeErrorDetail(error)}`, + preferStableMessageOnly: true, + }); + } ctx.wrappedCallback( { code: grpc.status.INTERNAL, diff --git a/src/helpers/grpc/status.ts b/src/helpers/grpc/status.ts index 4e508a5..c447b22 100644 --- a/src/helpers/grpc/status.ts +++ b/src/helpers/grpc/status.ts @@ -18,6 +18,15 @@ export function stableGrpcErrorCode(message: string): grpc.status | undefined { if (message.startsWith("deposit_amount_mismatch:")) { return grpc.status.FAILED_PRECONDITION; } + if (message.startsWith("passive_order_unsupported:")) { + return grpc.status.UNIMPLEMENTED; + } + if ( + message.startsWith("passive_order_rejected:") || + message.startsWith("passive_order_would_cross:") + ) { + return grpc.status.FAILED_PRECONDITION; + } if ( message.startsWith("policy_withdrawal_denied:") || message.startsWith("policy_deposit_denied:") diff --git a/src/helpers/passive-order.ts b/src/helpers/passive-order.ts new file mode 100644 index 0000000..7d7677d --- /dev/null +++ b/src/helpers/passive-order.ts @@ -0,0 +1,55 @@ +import ccxt from "@usherlabs/ccxt"; +import { getErrorMessage } from "./shared/errors"; + +export const PASSIVE_ORDER_ERROR_CODES = { + unsupported: "passive_order_unsupported", + rejected: "passive_order_rejected", + wouldCross: "passive_order_would_cross", +} as const; + +export type PassiveOrderErrorCode = + (typeof PASSIVE_ORDER_ERROR_CODES)[keyof typeof PASSIVE_ORDER_ERROR_CODES]; + +function identifiesWouldCross(message: string): boolean { + const normalized = message.toLowerCase(); + // Post-only venues reject a crossing limit instead of resting it. Binance + // says it "would immediately match and take"; Hyperliquid and other venues + // use equivalent explicit immediate-execution wording. + return ( + normalized.includes("would immediately match and take") || + /post[\s-]?only\b.*\bwould\b.*\bimmediately\b.*\b(?:execute|fill|match)/.test( + normalized, + ) || + /post[\s-]?only\b.*\bwould\b.*\b(?:execute|fill|match)\w*\b.*\bimmediately/.test( + normalized, + ) + ); +} + +function identifiesUnsupported(message: string): boolean { + const normalized = message.toLowerCase(); + return ( + /post[\s-]?only\b.*\b(?:not supported|unsupported|does not support)\b/.test( + normalized, + ) || + /\b(?:not supported|unsupported|does not support)\b.*\bpost[\s-]?only\b/.test( + normalized, + ) + ); +} + +export function classifyPassiveOrderError( + error: unknown, +): PassiveOrderErrorCode { + const message = getErrorMessage(error); + if ( + error instanceof ccxt.OrderImmediatelyFillable || + identifiesWouldCross(message) + ) { + return PASSIVE_ORDER_ERROR_CODES.wouldCross; + } + if (error instanceof ccxt.NotSupported || identifiesUnsupported(message)) { + return PASSIVE_ORDER_ERROR_CODES.unsupported; + } + return PASSIVE_ORDER_ERROR_CODES.rejected; +} diff --git a/src/schemas/action-payloads.ts b/src/schemas/action-payloads.ts index 9bf675c..b20f6cb 100644 --- a/src/schemas/action-payloads.ts +++ b/src/schemas/action-payloads.ts @@ -77,6 +77,7 @@ const unknownParamsSchema = z.preprocess( export const CreateOrderPayloadSchema = z.object({ orderType: z.enum(["market", "limit"]).default("limit"), + orderIntent: z.enum(["passive_only"]).optional(), amount: z.coerce.number().positive(), fromToken: z.string().min(1), toToken: z.string().min(1), diff --git a/test/grpc-status.test.ts b/test/grpc-status.test.ts index f568f81..621dd7b 100644 --- a/test/grpc-status.test.ts +++ b/test/grpc-status.test.ts @@ -21,6 +21,15 @@ describe("grpc status", () => { expect(stableGrpcErrorCode("policy_deposit_denied: x")).toBe( grpc.status.PERMISSION_DENIED, ); + expect(stableGrpcErrorCode("passive_order_unsupported: x")).toBe( + grpc.status.UNIMPLEMENTED, + ); + expect(stableGrpcErrorCode("passive_order_rejected: x")).toBe( + grpc.status.FAILED_PRECONDITION, + ); + expect(stableGrpcErrorCode("passive_order_would_cross: x")).toBe( + grpc.status.FAILED_PRECONDITION, + ); }); test("mapCcxtErrorToGrpcStatus maps authentication errors", () => { diff --git a/test/passive-order.test.ts b/test/passive-order.test.ts new file mode 100644 index 0000000..202b64c --- /dev/null +++ b/test/passive-order.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, test } from "bun:test"; +import * as grpc from "@grpc/grpc-js"; +import ccxt, { type Exchange } from "@usherlabs/ccxt"; +import type { ExecuteActionContext } from "../src/handlers/execute-action/context"; +import { handleOrders } from "../src/handlers/execute-action/orders"; +import { Action } from "../src/helpers/constants"; +import type { PolicyConfig } from "../src/types"; + +type CallbackError = { code?: number; message?: string }; +type CallbackResponse = { result?: string }; + +function createFixture(createOrderResult: unknown = { id: "order-1" }) { + const createOrderCalls: unknown[][] = []; + const broker = { + loadMarkets: async () => {}, + markets: { + "USDC/USDT": { + symbol: "USDC/USDT", + base: "USDC", + quote: "USDT", + spot: true, + type: "spot", + }, + }, + createOrder: async (...args: unknown[]) => { + createOrderCalls.push(args); + if (createOrderResult instanceof Error) { + throw createOrderResult; + } + return createOrderResult; + }, + } as unknown as Exchange; + + let callbackError: CallbackError | null = null; + let callbackResponse: CallbackResponse | null = null; + const ctx = { + action: Action.CreateOrder, + call: { request: { payload: {} } }, + wrappedCallback: ( + error: CallbackError | null, + response: CallbackResponse | null, + ) => { + callbackError = error; + callbackResponse = response; + }, + cex: "binance", + normalizedCex: "binance", + symbol: "USDC/USDT", + broker, + verity: { proof: "" }, + policy: { + order: { rule: { markets: ["*"], limits: [] } }, + } as unknown as PolicyConfig, + brokers: {}, + } as unknown as ExecuteActionContext; + + return { + ctx, + createOrderCalls, + getError: () => callbackError, + getResponse: () => callbackResponse, + }; +} + +function createOrderPayload( + overrides: Record = {}, +): Record { + return { + orderType: "limit", + amount: "10", + fromToken: "USDC", + toToken: "USDT", + price: "1", + marketType: "spot", + ...overrides, + }; +} + +describe("passive CreateOrder", () => { + test("keeps the existing ccxt request and response byte-for-byte when intent is absent", async () => { + const order = { id: "ordinary-1", status: "open" }; + const fixture = createFixture(order); + fixture.ctx.call.request.payload = createOrderPayload({ + clientOrderId: "client-1", + params: JSON.stringify({ timeInForce: "IOC", strategyId: 7 }), + }); + + await handleOrders(fixture.ctx); + + expect(fixture.createOrderCalls).toEqual([ + [ + "USDC/USDT", + "limit", + "sell", + 10, + 1, + { + timeInForce: "IOC", + strategyId: 7, + clientOrderId: "client-1", + }, + ], + ]); + expect(fixture.getError()).toBeNull(); + expect(fixture.getResponse()?.result).toBe(JSON.stringify(order)); + }); + + test("adds postOnly without clobbering params and reports accepted passive placement", async () => { + const order = { id: "passive-1", status: "open" }; + const fixture = createFixture(order); + fixture.ctx.call.request.payload = createOrderPayload({ + orderIntent: "passive_only", + params: JSON.stringify({ timeInForce: "GTC", strategyId: 9 }), + }); + + await handleOrders(fixture.ctx); + + expect(fixture.createOrderCalls[0]?.[5]).toEqual({ + timeInForce: "GTC", + strategyId: 9, + postOnly: true, + }); + expect(JSON.parse(fixture.getResponse()?.result ?? "{}")).toEqual({ + ...order, + passivePlacementOutcome: "accepted_passive", + }); + }); + + test("overrides a conflicting caller postOnly value to preserve passive intent", async () => { + const fixture = createFixture(); + fixture.ctx.call.request.payload = createOrderPayload({ + orderIntent: "passive_only", + params: JSON.stringify({ postOnly: 0, strategyId: 9 }), + }); + + await handleOrders(fixture.ctx); + + expect(fixture.createOrderCalls[0]?.[5]).toEqual({ + postOnly: true, + strategyId: 9, + }); + }); + + test("rejects passive market orders as invalid before calling ccxt", async () => { + const fixture = createFixture(); + fixture.ctx.call.request.payload = createOrderPayload({ + orderType: "market", + orderIntent: "passive_only", + }); + + await handleOrders(fixture.ctx); + + expect(fixture.createOrderCalls).toHaveLength(0); + expect(fixture.getError()).toEqual({ + code: grpc.status.INVALID_ARGUMENT, + message: + "ValidationError: passive_only order intent requires a limit order", + }); + }); + + test("rejects unknown order intents in the payload schema", async () => { + const fixture = createFixture(); + fixture.ctx.call.request.payload = createOrderPayload({ + orderIntent: "maker_if_possible", + }); + + await handleOrders(fixture.ctx); + + expect(fixture.createOrderCalls).toHaveLength(0); + expect(fixture.getError()?.code).toBe(grpc.status.INVALID_ARGUMENT); + expect(fixture.getError()?.message).toContain("orderIntent"); + }); + + test("maps an immediately fillable venue rejection to would-cross", async () => { + const fixture = createFixture( + new ccxt.InvalidOrder("binance Order would immediately match and take."), + ); + fixture.ctx.call.request.payload = createOrderPayload({ + orderIntent: "passive_only", + }); + + await handleOrders(fixture.ctx); + + expect(fixture.getError()?.code).toBe(grpc.status.FAILED_PRECONDITION); + expect(fixture.getError()?.message).toStartWith( + "passive_order_would_cross:", + ); + }); + + test("maps missing ccxt post-only support to unsupported", async () => { + const fixture = createFixture( + new ccxt.NotSupported("binance post-only orders are not supported"), + ); + fixture.ctx.call.request.payload = createOrderPayload({ + orderIntent: "passive_only", + }); + + await handleOrders(fixture.ctx); + + expect(fixture.getError()?.code).toBe(grpc.status.UNIMPLEMENTED); + expect(fixture.getError()?.message).toStartWith( + "passive_order_unsupported:", + ); + }); + + test("maps any other passive venue rejection to rejected", async () => { + const fixture = createFixture( + new ccxt.InvalidOrder("binance passive order rejected: invalid price"), + ); + fixture.ctx.call.request.payload = createOrderPayload({ + orderIntent: "passive_only", + }); + + await handleOrders(fixture.ctx); + + expect(fixture.getError()?.code).toBe(grpc.status.FAILED_PRECONDITION); + expect(fixture.getError()?.message).toStartWith("passive_order_rejected:"); + }); + + test("does not classify a pre-submission failure as a passive venue rejection", async () => { + const fixture = createFixture(); + (fixture.ctx.broker as unknown as { loadMarkets: () => Promise }) + .loadMarkets = async () => { + throw new Error("market resolution unavailable"); + }; + fixture.ctx.call.request.payload = createOrderPayload({ + orderIntent: "passive_only", + }); + + await handleOrders(fixture.ctx); + + expect(fixture.createOrderCalls).toHaveLength(0); + expect(fixture.getError()?.message).not.toStartWith("passive_order_"); + }); + + test("does not classify a post-submission failure as a passive venue rejection", async () => { + const circularOrder: Record = { id: "order-1" }; + circularOrder.self = circularOrder; + const fixture = createFixture(circularOrder); + fixture.ctx.call.request.payload = createOrderPayload({ + orderIntent: "passive_only", + }); + + await handleOrders(fixture.ctx); + + // The order is resting on the venue; a passive code would tell the client + // it was never placed and invite a duplicate repost. + expect(fixture.createOrderCalls).toHaveLength(1); + expect(fixture.getError()?.code).toBe(grpc.status.INTERNAL); + expect(fixture.getError()?.message).not.toStartWith("passive_order_"); + }); +});