From e9fe399e05ca5518fc52dfe9ed08fd78b7fafc7d Mon Sep 17 00:00:00 2001 From: dharjeezy Date: Tue, 15 Sep 2026 13:09:06 +0100 Subject: [PATCH 01/12] Post operator limit orders to the HyperFX orderbook --- .../sdk/src/protocols/intents/index.ts | 1 + .../protocols/intents/phantom-aggregation.ts | 2 +- ...-orders-posted-to-the-hyperfx-orderbook.md | 64 +++ .../simplex/filler-config-example.toml | 13 + sdk/packages/simplex/src/bin/simplex.ts | 1 + sdk/packages/simplex/src/config/defaults.ts | 3 + .../simplex/src/config/filler-toml.ts | 60 +++ sdk/packages/simplex/src/core/boot.ts | 30 +- sdk/packages/simplex/src/data/memory.ts | 65 +++ sdk/packages/simplex/src/data/sqlite/index.ts | 10 +- .../simplex/src/data/sqlite/limit-orders.ts | 173 ++++++++ sdk/packages/simplex/src/data/types.ts | 117 +++++- sdk/packages/simplex/src/orderbook/amounts.ts | 50 +++ sdk/packages/simplex/src/orderbook/client.ts | 173 ++++++++ .../simplex/src/orderbook/limit-orders.ts | 395 ++++++++++++++++++ sdk/packages/simplex/src/orderbook/types.ts | 98 +++++ .../services/ContractInteractionService.ts | 123 ++++++ .../simplex/src/services/server/UiServer.ts | 69 ++- sdk/packages/simplex/src/simplex.ts | 69 +++ .../tests/cli/filler-toml-validate.test.ts | 44 ++ .../src/tests/data/limit-order-store.test.ts | 122 ++++++ .../src/tests/orderbook/amounts.test.ts | 75 ++++ .../src/tests/orderbook/limit-orders.test.ts | 246 +++++++++++ 23 files changed, 1994 insertions(+), 9 deletions(-) create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-15-limit-orders-posted-to-the-hyperfx-orderbook.md create mode 100644 sdk/packages/simplex/src/data/sqlite/limit-orders.ts create mode 100644 sdk/packages/simplex/src/orderbook/amounts.ts create mode 100644 sdk/packages/simplex/src/orderbook/client.ts create mode 100644 sdk/packages/simplex/src/orderbook/limit-orders.ts create mode 100644 sdk/packages/simplex/src/orderbook/types.ts create mode 100644 sdk/packages/simplex/src/tests/data/limit-order-store.test.ts create mode 100644 sdk/packages/simplex/src/tests/orderbook/amounts.test.ts create mode 100644 sdk/packages/simplex/src/tests/orderbook/limit-orders.test.ts diff --git a/sdk/packages/sdk/src/protocols/intents/index.ts b/sdk/packages/sdk/src/protocols/intents/index.ts index 8f5364d2b7..9e15ff6b23 100644 --- a/sdk/packages/sdk/src/protocols/intents/index.ts +++ b/sdk/packages/sdk/src/protocols/intents/index.ts @@ -63,6 +63,7 @@ export { readRateFillCapability, UNISWAP_QUOTE_HAIRCUT_BPS, PERMIT2_SPONSORSHIP_BYTES, + MAX_DECLARED_ENTRIES, type PhantomBidDeclaration, type PhantomBidPaymasterAndData, type PhantomBidSponsorship, diff --git a/sdk/packages/sdk/src/protocols/intents/phantom-aggregation.ts b/sdk/packages/sdk/src/protocols/intents/phantom-aggregation.ts index 33671e40d2..26eb738a17 100644 --- a/sdk/packages/sdk/src/protocols/intents/phantom-aggregation.ts +++ b/sdk/packages/sdk/src/protocols/intents/phantom-aggregation.ts @@ -164,7 +164,7 @@ const PERMIT2_DATA_BYTES = 1 + 20 + 32 + 32 + 32 + 1 + 32 + 32 export const PERMIT2_SPONSORSHIP_BYTES = PAYMASTER_DATA_OFFSET + PERMIT2_DATA_BYTES /** Upper bound on declared chains and positions alike; one byte of count each. */ -const MAX_DECLARED_ENTRIES = 255 +export const MAX_DECLARED_ENTRIES = 255 /** Widest tokenId the codec will carry — a uint256, as minted by the V4 PositionManager. */ const MAX_TOKEN_ID_BYTES = 32 diff --git a/sdk/packages/simplex/docs/ai/changelog/2026-09-15-limit-orders-posted-to-the-hyperfx-orderbook.md b/sdk/packages/simplex/docs/ai/changelog/2026-09-15-limit-orders-posted-to-the-hyperfx-orderbook.md new file mode 100644 index 0000000000..10cd4b4ba6 --- /dev/null +++ b/sdk/packages/simplex/docs/ai/changelog/2026-09-15-limit-orders-posted-to-the-hyperfx-orderbook.md @@ -0,0 +1,64 @@ +# 2026-09-15 — Limit orders posted to the HyperFX orderbook + +The operator can now create limit orders, and simplex advertises them on the HyperFX orderbook. +A limit order is what simplex offers to pay: a book, a side, a fill chain, a price and a size. It +lives in a new `limit_orders` table in `bids.db` behind `SimplexDataStore.limitOrders`, and the +orderbook entry is a derived copy that expires and is reposted. Amounts and prices are decimal +strings at 1e18 everywhere they cross the orderbook boundary, whatever decimals the tokens use on +their own chains. + +## The API + +`Simplex.limitOrders` and four routes on the operator server: + +``` +GET /api/limit-orders?status=&chain=&book= +GET /api/limit-orders/:id +POST /api/limit-orders { book, side, fillChain, price, size, acceptedSources, ttlSecs?, expiresAt? } +DELETE /api/limit-orders/:id +``` + +`acceptedSources` is required and non-empty: it names the source chains the order accepts swaps +from, and the orderbook refuses an order that declares none. A create emits `limit-order:posted` or +`limit-order:rejected`, a cancel emits `limit-order:cancelled`. + +A request is validated against `serverInfo` and `books` before anything is stored, so the rejection +codes the orderbook reserves for bad requests (`TTL_TOO_SHORT`, `MIN_ORDER_SIZE`, `UNSUPPORTED_PAIR`) +should never come back. The order is stored before it is posted, so a posting that fails leaves a +row carrying the reason rather than a request that vanished. + +## What gets signed + +`ContractInteractionService.prepareLimitOrderUserOp` builds a `fillOrder` UserOperation for a +synthetic same-chain order at the operator's rate. It is a price commitment, not a transaction: +nothing ever submits it, the orderbook only verifies the solver signature over the userOpHash and +reads the amounts out of the calldata. So the gas fields are fixed rather than estimated, and +`paymasterAndData` carries the accepted-source declaration instead of a paymaster. + +It is always encoded as FillOptions v2, without consulting the deployed gateway: the op never runs, +and v1 has nowhere to put `validUntil`, which the orderbook requires. `validUntil` here is a TTL in +seconds from the orderbook's receipt, not a block number. + +The input is rounded up against the operator's price, so the rate the op carries is never better for +the taker than the price asked for. `REPLAYED` and `ORDER_EXISTS` are answered once by bumping +`orderNonce`, which changes the commitment and the userOpHash; the orderbook remembers every hash it +has accepted, so a fresh nonce is the only way past. + +## Config + +```toml +[orderbook] +enabled = true +url = "https://orderbook.hyperbridge.network/graphql" +defaultTtlSecs = 900 +renewMarginSecs = 120 +reconcileIntervalSecs = 300 +requestTimeoutMs = 10000 +``` + +Off unless enabled, and the limit orders themselves are not configured here. They are inventory the +operator opens and closes while the filler runs, so they live in `bids.db` and are created over the +API. + +Pricing still comes from the pair curves; matching incoming orders against limit orders, and drawing +`remaining` down on a fill, land separately. diff --git a/sdk/packages/simplex/filler-config-example.toml b/sdk/packages/simplex/filler-config-example.toml index e065341b08..ead77ef4e5 100644 --- a/sdk/packages/simplex/filler-config-example.toml +++ b/sdk/packages/simplex/filler-config-example.toml @@ -292,6 +292,19 @@ points = [ # ] +# The HyperFX orderbook simplex advertises its limit orders on. Off by default; +# with no orderbook the filler posts nothing. The limit orders themselves are not +# configured here — they are created over the API and live in bids.db, because +# they are inventory you open and close while the filler runs. +# [orderbook] +# enabled = true +# url = "https://orderbook.hyperbridge.network/graphql" +# defaultTtlSecs = 900 # TTL per posting; the orderbook's own floor is 900 +# renewMarginSecs = 120 # repost this long before a posting expires +# reconcileIntervalSecs = 300 # how often to check the orderbook still matches +# requestTimeoutMs = 10000 + + # Remote access from a phone. Off by default. When enabled, simplex keeps an outbound # SSH tunnel to a relay so a phone's SSH client can open this UI with a local port # forward; the phone's session terminates inside simplex, the relay only sees ciphertext. diff --git a/sdk/packages/simplex/src/bin/simplex.ts b/sdk/packages/simplex/src/bin/simplex.ts index 8452571324..eefe03eb62 100644 --- a/sdk/packages/simplex/src/bin/simplex.ts +++ b/sdk/packages/simplex/src/bin/simplex.ts @@ -219,6 +219,7 @@ async function operatorContextFrom( stop: () => stopAll(), activity: runtime.activity, bids: runtime.data.bids, + limitOrders: runtime.limitOrders ? simplex.limitOrders : undefined, setPaused: (paused) => patchRuntimeState(runtime.data.state, { paused }), // Both contexts, not just the filler's: the dashboard shows one merged feed // and reports one level for it, so leaving the process-wide context (the UI diff --git a/sdk/packages/simplex/src/config/defaults.ts b/sdk/packages/simplex/src/config/defaults.ts index f6caea44c1..6332389eeb 100644 --- a/sdk/packages/simplex/src/config/defaults.ts +++ b/sdk/packages/simplex/src/config/defaults.ts @@ -1,2 +1,5 @@ /** Orders the engine evaluates concurrently when the config does not say. */ export const DEFAULT_MAX_CONCURRENT_ORDERS = 5 + +/** How long an orderbook request waits before it is treated as unreachable. */ +export const DEFAULT_ORDERBOOK_TIMEOUT_MS = 10_000 diff --git a/sdk/packages/simplex/src/config/filler-toml.ts b/sdk/packages/simplex/src/config/filler-toml.ts index d20697d6e9..79452f2297 100644 --- a/sdk/packages/simplex/src/config/filler-toml.ts +++ b/sdk/packages/simplex/src/config/filler-toml.ts @@ -6,6 +6,7 @@ import { validateAssetDefinitions, type AssetDefinition } from "@/config/asset-r import { validatePairConfigs, type PairConfig } from "@/config/pairs" import type { SignerConfig } from "@/services/wallet" import { MIN_BLOCK_SCAN_INTERVAL_SECONDS } from "@/services/FillerConfigService" +import { MIN_ORDER_TTL_SECONDS } from "@/orderbook/types" import type { UserProvidedChainConfig, AllowlistConfig } from "@/services/FillerConfigService" import type { PaymasterKeeperConfig } from "@/services/PaymasterKeeperService" @@ -159,6 +160,29 @@ export interface FillerTomlConfig { allowlist?: AllowlistConfig /** SimplexPaymaster fee-recycling keeper (`paymaster-keeper` subcommand). */ keeper?: PaymasterKeeperConfig + /** The HyperFX orderbook simplex posts its limit orders to. */ + orderbook?: OrderbookConfig +} + +/** + * Where the operator's limit orders are advertised. + * + * The limit orders themselves are not configured here: they live in `bids.db` + * and are created over the API, because they are inventory the operator opens + * and closes while the filler runs rather than startup settings. + */ +export interface OrderbookConfig { + /** GraphQL endpoint. Required when `enabled`. */ + url?: string + /** Off unless set. A filler with no orderbook simply posts nothing. */ + enabled?: boolean + /** TTL written into each posting, in seconds. At least 900, which is the orderbook's floor. */ + defaultTtlSecs?: number + /** How long before a posting expires to repost it, in seconds. */ + renewMarginSecs?: number + /** How often to reconcile local limit orders against the orderbook, in seconds. */ + reconcileIntervalSecs?: number + requestTimeoutMs?: number } /** @@ -232,6 +256,38 @@ export function validateVaultToml( } } +/** + * Checked at the gate rather than at first use: an orderbook that is enabled but + * misconfigured means every limit order the operator creates is refused, and a + * TTL under the orderbook's own floor is refused one order at a time with a + * `TTL_TOO_SHORT` nobody sees until they try. + */ +function validateOrderbookConfig(orderbook: OrderbookConfig): void { + if (!orderbook.url) { + throw new Error("orderbook.url is required when orderbook.enabled is true") + } + const positiveSeconds: [keyof OrderbookConfig, number | undefined, number][] = [ + ["defaultTtlSecs", orderbook.defaultTtlSecs, MIN_ORDER_TTL_SECONDS], + ["renewMarginSecs", orderbook.renewMarginSecs, 1], + ["reconcileIntervalSecs", orderbook.reconcileIntervalSecs, 1], + ["requestTimeoutMs", orderbook.requestTimeoutMs, 1], + ] + for (const [name, value, minimum] of positiveSeconds) { + if (value === undefined) continue + if (!Number.isInteger(value) || value < minimum) { + throw new Error(`orderbook.${name} must be an integer >= ${minimum}; got ${value}`) + } + } + if (orderbook.renewMarginSecs !== undefined) { + const ttl = orderbook.defaultTtlSecs ?? MIN_ORDER_TTL_SECONDS + if (orderbook.renewMarginSecs >= ttl) { + throw new Error( + `orderbook.renewMarginSecs (${orderbook.renewMarginSecs}) must be shorter than orderbook.defaultTtlSecs (${ttl}), or every posting is due for renewal the moment it lands`, + ) + } + } +} + export function validateConfig(config: FillerTomlConfig, cliWatchOnly = false): void { // The [[strategies]] array was removed when the pair engine subsumed the // stable strategy — fail loudly so stale configs are migrated, not ignored. @@ -311,6 +367,10 @@ export function validateConfig(config: FillerTomlConfig, cliWatchOnly = false): validateVaultToml(config.vault.vaults) } + if (config.orderbook?.enabled) { + validateOrderbookConfig(config.orderbook) + } + // Asset registry and trading pairs — the entire trading configuration. if (config.assets) { validateAssetDefinitions(config.assets) diff --git a/sdk/packages/simplex/src/core/boot.ts b/sdk/packages/simplex/src/core/boot.ts index c6bae45559..c568902fee 100644 --- a/sdk/packages/simplex/src/core/boot.ts +++ b/sdk/packages/simplex/src/core/boot.ts @@ -18,9 +18,13 @@ import { } from "@/services/FillerConfigService" import { assertConfirmationCoverage, validateConfig, type FillerTomlConfig, type VaultToml } from "@/config/filler-toml" import type { ConfirmationPolicy } from "@/config/interpolated-curve" -import { DEFAULT_MAX_CONCURRENT_ORDERS } from "@/config/defaults" +import { DEFAULT_MAX_CONCURRENT_ORDERS, DEFAULT_ORDERBOOK_TIMEOUT_MS } from "@/config/defaults" import { ChainClientManager } from "@/services/ChainClientManager" import { ContractInteractionService } from "@/services/ContractInteractionService" +import { DelegationService } from "@/services/DelegationService" +import { OrderbookClient } from "@/orderbook/client" +import { LimitOrderService } from "@/orderbook/limit-orders" +import { MIN_ORDER_TTL_SECONDS } from "@/orderbook/types" import { UserOpSender } from "@/services/UserOpSender" import { RebalancingService } from "@/services/RebalancingService" import { getLogger, moduleLogger, type Logger, type LogLevel, type LoggerContext } from "@/services/Logger" @@ -103,6 +107,8 @@ export interface FillerRuntime { loggers: LoggerContext /** Symbol-to-address resolution for the configured chains (send options, balance labels). */ assetRegistry: AssetRegistry + /** Creates and posts the operator's limit orders, absent unless `[orderbook]` is enabled. */ + limitOrders?: LimitOrderService /** The live trading engine, absent when the config declared no pairs. */ engine?: FXFiller /** The engine's live pair array (same instance), indexed 1:1 with config.pairs. */ @@ -504,6 +510,27 @@ export async function bootFiller(config: FillerTomlConfig, options: BootOptions) started.push(() => intentFiller.stop()) + // Limit orders are inventory the operator opens while the filler runs, so the + // service exists as soon as an orderbook is configured, whether or not any + // order has been created yet. + const limitOrderService = config.orderbook?.enabled + ? new LimitOrderService( + options.data.limitOrders, + new OrderbookClient( + config.orderbook.url!, + config.orderbook.requestTimeoutMs ?? DEFAULT_ORDERBOOK_TIMEOUT_MS, + options.loggers, + ), + contractService, + configService, + assetRegistry, + runtimeSigner, + config.orderbook.defaultTtlSecs ?? MIN_ORDER_TTL_SECONDS, + new DelegationService(chainClientManager, configService, runtimeSigner), + options.loggers, + ) + : undefined + // Initialize (sets up EIP-7702 delegation if solver selection is configured) try { await intentFiller.initialize() @@ -673,6 +700,7 @@ export async function bootFiller(config: FillerTomlConfig, options: BootOptions) return { intentFiller, + limitOrders: limitOrderService, balanceProvider, vaultVenue, adminStrategies, diff --git a/sdk/packages/simplex/src/data/memory.ts b/sdk/packages/simplex/src/data/memory.ts index 381d463b2e..922cd78d1d 100644 --- a/sdk/packages/simplex/src/data/memory.ts +++ b/sdk/packages/simplex/src/data/memory.ts @@ -7,6 +7,12 @@ import type { BidInsert, BidStats, BidStore, + LimitOrder, + LimitOrderFilter, + LimitOrderInsert, + LimitOrderPosting, + LimitOrderStatus, + LimitOrderStore, RuntimeState, SimplexDataStore, StateStore, @@ -308,6 +314,64 @@ class MemoryActivityStore implements ActivityStore { } } +class MemoryLimitOrderStore implements LimitOrderStore { + private orders = new Map() + + async create(order: LimitOrderInsert): Promise { + const now = sqliteDatetime(new Date()) + const stored: LimitOrder = { + ...order, + acceptedSources: [...order.acceptedSources], + remaining: order.size, + reserved: "0", + expiresAt: order.expiresAt ?? null, + status: "open", + commitment: null, + orderNonce: "0", + bookExpiresAt: null, + bookPrice: null, + lastError: null, + createdAt: now, + updatedAt: now, + } + this.orders.set(stored.id, stored) + return { ...stored } + } + + async get(id: string): Promise { + const order = this.orders.get(id) + return order ? { ...order } : null + } + + async list(filter: LimitOrderFilter = {}): Promise { + return [...this.orders.values()] + .filter( + (order) => + (filter.status === undefined || order.status === filter.status) && + (filter.fillChain === undefined || order.fillChain === filter.fillChain) && + (filter.book === undefined || order.book === filter.book), + ) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + .map((order) => ({ ...order })) + } + + async setPosting(id: string, posting: LimitOrderPosting): Promise { + return this.patch(id, posting) + } + + async setStatus(id: string, status: LimitOrderStatus, lastError: string | null = null): Promise { + return this.patch(id, { status, lastError }) + } + + private patch(id: string, fields: Partial): LimitOrder | null { + const order = this.orders.get(id) + if (!order) return null + const next = { ...order, ...fields, updatedAt: sqliteDatetime(new Date()) } + this.orders.set(id, next) + return { ...next } + } +} + class MemoryStateStore implements StateStore { private state: RuntimeState = {} @@ -333,4 +397,5 @@ export class MemoryDataStore implements SimplexDataStore { readonly bids: BidStore = new MemoryBidStore() readonly activity: ActivityStore = new MemoryActivityStore() readonly state: StateStore = new MemoryStateStore() + readonly limitOrders: LimitOrderStore = new MemoryLimitOrderStore() } diff --git a/sdk/packages/simplex/src/data/sqlite/index.ts b/sdk/packages/simplex/src/data/sqlite/index.ts index c7cacddbfb..e2f63f8140 100644 --- a/sdk/packages/simplex/src/data/sqlite/index.ts +++ b/sdk/packages/simplex/src/data/sqlite/index.ts @@ -2,13 +2,15 @@ import { existsSync, mkdirSync } from "node:fs" import { join } from "node:path" import { DatabaseSync } from "node:sqlite" import { defaultLoggerContext, type Logger, type LoggerContext } from "@/services/Logger" -import type { ActivityStore, BidStore, SimplexDataStore, StateStore } from "@/data/types" +import type { ActivityStore, BidStore, LimitOrderStore, SimplexDataStore, StateStore } from "@/data/types" import { SqliteActivityStore } from "./activity" import { SqliteBidStore } from "./bids" +import { SqliteLimitOrderStore } from "./limit-orders" import { SqliteStateStore } from "./state" export { SqliteActivityStore } from "./activity" export { SqliteBidStore } from "./bids" +export { SqliteLimitOrderStore } from "./limit-orders" export { SqliteStateStore } from "./state" /** @@ -31,8 +33,8 @@ const BUSY_TIMEOUT_MS = 5_000 * * Keeps bids and activity in separate database files (`bids.db`, * `activity.db`) so an existing data directory written by an earlier version is - * picked up unchanged. Operator state rides in `bids.db` beside the bids; a - * `runtime-state.json` from before that is imported once and deleted. + * picked up unchanged. Operator state and limit orders ride in `bids.db` beside + * the bids; a `runtime-state.json` from before that is imported once and deleted. * * Built on `node:sqlite`, so there is nothing to install and nothing to * compile — the engine ships inside the Node runtime. That is why the package @@ -43,6 +45,7 @@ export class SqliteDataStore implements SimplexDataStore { readonly bids: BidStore readonly activity: ActivityStore readonly state: StateStore + readonly limitOrders: LimitOrderStore private databases: DatabaseSync[] private logger: Logger @@ -61,6 +64,7 @@ export class SqliteDataStore implements SimplexDataStore { this.bids = new SqliteBidStore(bidsDb, loggers) this.activity = new SqliteActivityStore(activityDb, loggers) this.state = new SqliteStateStore(bidsDb, dataDir, loggers) + this.limitOrders = new SqliteLimitOrderStore(bidsDb, loggers) this.logger.info({ dataDir }, "SQLite data store opened") } diff --git a/sdk/packages/simplex/src/data/sqlite/limit-orders.ts b/sdk/packages/simplex/src/data/sqlite/limit-orders.ts new file mode 100644 index 0000000000..6e1881e75f --- /dev/null +++ b/sdk/packages/simplex/src/data/sqlite/limit-orders.ts @@ -0,0 +1,173 @@ +import type { DatabaseSync } from "node:sqlite" +import { defaultLoggerContext, type Logger, type LoggerContext } from "@/services/Logger" +import type { + LimitOrder, + LimitOrderFilter, + LimitOrderInsert, + LimitOrderPosting, + LimitOrderStatus, + LimitOrderStore, +} from "@/data/types" + +/** Column list shared by every SELECT that returns a LimitOrder. */ +const LIMIT_ORDER_COLUMNS = ` + id, + book, + base, + quote, + side, + fill_chain as fillChain, + price, + size, + remaining, + reserved, + accepted_sources as acceptedSources, + ttl_secs as ttlSecs, + expires_at as expiresAt, + status, + commitment, + order_nonce as orderNonce, + book_expires_at as bookExpiresAt, + book_price as bookPrice, + last_error as lastError, + created_at as createdAt, + updated_at as updatedAt +` + +/** + * SQLite-backed {@link LimitOrderStore}, sharing `bids.db` with the bid store. + * + * Amounts are stored as the decimal strings they arrive as. SQLite's own + * integers top out at 64 bits, which a 1e18 amount overruns as soon as the size + * passes about 18 tokens, so they are never arithmetic in SQL. + */ +export class SqliteLimitOrderStore implements LimitOrderStore { + private logger: Logger + + constructor( + private db: DatabaseSync, + loggers: LoggerContext = defaultLoggerContext(), + ) { + this.logger = loggers.get("limit-order-store") + this.initializeSchema() + } + + private initializeSchema(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS limit_orders ( + id TEXT PRIMARY KEY, + book TEXT NOT NULL, + base TEXT NOT NULL, + quote TEXT NOT NULL, + side TEXT NOT NULL, + fill_chain TEXT NOT NULL, + price TEXT NOT NULL, + size TEXT NOT NULL, + remaining TEXT NOT NULL, + reserved TEXT NOT NULL DEFAULT '0', + accepted_sources TEXT NOT NULL, + ttl_secs INTEGER NOT NULL, + expires_at TEXT, + status TEXT NOT NULL, + commitment TEXT, + order_nonce TEXT NOT NULL DEFAULT '0', + book_expires_at TEXT, + book_price TEXT, + last_error TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE INDEX IF NOT EXISTS idx_limit_orders_status ON limit_orders(status); + CREATE INDEX IF NOT EXISTS idx_limit_orders_fill_chain ON limit_orders(fill_chain); + CREATE INDEX IF NOT EXISTS idx_limit_orders_commitment ON limit_orders(commitment); + `) + } + + // biome-ignore lint/suspicious/noExplicitAny: raw sqlite row + private toLimitOrder(row: any): LimitOrder { + return { ...row, acceptedSources: JSON.parse(row.acceptedSources) } + } + + private read(id: string): LimitOrder | null { + const row = this.db.prepare(`SELECT ${LIMIT_ORDER_COLUMNS} FROM limit_orders WHERE id = ?`).get(id) + return row ? this.toLimitOrder(row) : null + } + + async create(order: LimitOrderInsert): Promise { + this.db + .prepare(` + INSERT INTO limit_orders ( + id, book, base, quote, side, fill_chain, price, size, remaining, + accepted_sources, ttl_secs, expires_at, status + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open') + `) + .run( + order.id, + order.book, + order.base, + order.quote, + order.side, + order.fillChain, + order.price, + order.size, + order.size, + JSON.stringify(order.acceptedSources), + order.ttlSecs, + order.expiresAt ?? null, + ) + this.logger.info({ id: order.id, book: order.book, side: order.side }, "Limit order created") + return this.read(order.id)! + } + + async get(id: string): Promise { + return this.read(id) + } + + async list(filter: LimitOrderFilter = {}): Promise { + const clauses: string[] = [] + const args: string[] = [] + for (const [column, value] of [ + ["status", filter.status], + ["fill_chain", filter.fillChain], + ["book", filter.book], + ] as const) { + if (value === undefined) continue + clauses.push(`${column} = ?`) + args.push(value) + } + const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "" + const rows = this.db + .prepare(`SELECT ${LIMIT_ORDER_COLUMNS} FROM limit_orders ${where} ORDER BY created_at DESC`) + .all(...args) + return rows.map((row) => this.toLimitOrder(row)) + } + + async setPosting(id: string, posting: LimitOrderPosting): Promise { + this.db + .prepare(` + UPDATE limit_orders + SET commitment = ?, book_expires_at = ?, book_price = ?, order_nonce = ?, + status = ?, last_error = ?, updated_at = datetime('now') + WHERE id = ? + `) + .run( + posting.commitment, + posting.bookExpiresAt, + posting.bookPrice, + posting.orderNonce, + posting.status, + posting.lastError, + id, + ) + return this.read(id) + } + + async setStatus(id: string, status: LimitOrderStatus, lastError: string | null = null): Promise { + this.db + .prepare("UPDATE limit_orders SET status = ?, last_error = ?, updated_at = datetime('now') WHERE id = ?") + .run(status, lastError, id) + return this.read(id) + } +} diff --git a/sdk/packages/simplex/src/data/types.ts b/sdk/packages/simplex/src/data/types.ts index 9f86d6df08..f59d559e25 100644 --- a/sdk/packages/simplex/src/data/types.ts +++ b/sdk/packages/simplex/src/data/types.ts @@ -1,10 +1,10 @@ /** * The persistence contract for a running filler. * - * Everything simplex must remember across a restart lives behind these three + * Everything simplex must remember across a restart lives behind these four * stores: submitted bids (so deposits can be reclaimed), the order-activity - * feed, and a scrap of operator state. Nothing else in the filler touches a - * database, a file, or a data directory. + * feed, the operator's limit orders, and a scrap of operator state. Nothing + * else in the filler touches a database, a file, or a data directory. * * Every method is async. The bundled SQLite adapter is synchronous underneath * and simply returns resolved promises — the async signature exists so a @@ -17,6 +17,7 @@ export interface SimplexDataStore { bids: BidStore activity: ActivityStore state: StateStore + limitOrders: LimitOrderStore /** Releases any underlying handles. Called by `Simplex.stop()`. */ close?(): Promise } @@ -259,6 +260,116 @@ export interface OrderHistoryPage { orders: OrderHistoryEntry[] } +// =========================================================================== +// Limit orders +// =========================================================================== + +/** Which way round a limit order trades its book's pair. */ +export type LimitOrderSide = "BID" | "ASK" + +/** + * `open`: live, and bids may draw on it. `resizing`: a repost is in flight after + * a fill, so the orderbook entry may be missing until it lands. `filled`: worked + * down past the dust floor. `cancelled`: withdrawn by the operator. `rejected`: + * the orderbook refused it and `lastError` says why. + */ +export type LimitOrderStatus = "open" | "resizing" | "filled" | "cancelled" | "rejected" + +/** + * One of the operator's limit orders. + * + * This record is what simplex prices against and draws down; the orderbook entry + * is a derived copy that expires and is reposted. Amounts and prices are decimal + * strings at 1e18, the unit the orderbook takes and returns, whatever decimals + * the tokens use on their own chains. + */ +export interface LimitOrder { + /** Stable across every repost, unlike `commitment`. */ + id: string + /** Orderbook book id, with the symbols it resolved to. */ + book: string + base: string + quote: string + side: LimitOrderSide + /** Where simplex fills, as a state machine id. */ + fillChain: string + /** Quote per 1 base: the rate simplex signs, before the orderbook's fee haircut. */ + price: string + /** The output simplex offered to pay when the order was created. */ + size: string + /** Output not yet delivered. */ + remaining: string + /** Output promised to bids that have neither filled nor been retracted. */ + reserved: string + /** Source chains this order accepts swaps from. Never empty. */ + acceptedSources: string[] + /** TTL written into each posting. */ + ttlSecs: number + /** Operator expiry for the limit order itself, independent of the posting's. */ + expiresAt: string | null + status: LimitOrderStatus + /** The current posting's commitment, absent while nothing is live. */ + commitment: string | null + /** Bumped on every repost, so each posting hashes differently. */ + orderNonce: string + /** When the current posting expires, as the orderbook reported it. */ + bookExpiresAt: string | null + /** `Order.price` from the orderbook, which shades `price` by the protocol fee. */ + bookPrice: string | null + /** The last rejection, as "CODE: message". */ + lastError: string | null + /** SQLite-style "YYYY-MM-DD HH:MM:SS" in UTC. Sorts lexicographically. */ + createdAt: string + updatedAt: string +} + +/** What the operator supplies; everything else is derived or defaulted. */ +export interface LimitOrderInsert { + id: string + book: string + base: string + quote: string + side: LimitOrderSide + fillChain: string + price: string + size: string + acceptedSources: string[] + ttlSecs: number + expiresAt?: string | null +} + +export interface LimitOrderFilter { + status?: LimitOrderStatus + fillChain?: string + book?: string +} + +/** The fields a posting writes back, applied together so a half-posted row is never visible. */ +export interface LimitOrderPosting { + commitment: string | null + bookExpiresAt: string | null + bookPrice: string | null + orderNonce: string + status: LimitOrderStatus + lastError: string | null +} + +/** + * Persistent record of the operator's limit orders. + * + * This is inventory, not a cache: `remaining` and `reserved` are what stop two + * chains from paying out the same liability twice, so a store that loses writes + * overcommits real money. + */ +export interface LimitOrderStore { + create(order: LimitOrderInsert): Promise + get(id: string): Promise + list(filter?: LimitOrderFilter): Promise + /** Records what the orderbook did with the current posting. */ + setPosting(id: string, posting: LimitOrderPosting): Promise + setStatus(id: string, status: LimitOrderStatus, lastError?: string | null): Promise +} + // =========================================================================== // Operator state // =========================================================================== diff --git a/sdk/packages/simplex/src/orderbook/amounts.ts b/sdk/packages/simplex/src/orderbook/amounts.ts new file mode 100644 index 0000000000..ca16fe765c --- /dev/null +++ b/sdk/packages/simplex/src/orderbook/amounts.ts @@ -0,0 +1,50 @@ +import type { LimitOrderSide } from "@/data/types" + +/** + * The unit every amount and price crosses the orderbook boundary in. + * + * The orderbook normalises every token to 18 decimals so one book can hold + * assets that disagree about decimals on chain. Raw amounts only appear inside + * the signed UserOp, which the destination chain's tokens have to accept. + */ +export const ORDERBOOK_SCALE = 10n ** 18n + +/** A normalised amount in the token's own units on `fillChain`. Truncates. */ +export function toRaw(amount: bigint, decimals: number): bigint { + return amount / 10n ** BigInt(18 - decimals) +} + +function divCeil(numerator: bigint, denominator: bigint): bigint { + return (numerator + denominator - 1n) / denominator +} + +/** + * The raw input and output a limit order signs for. + * + * `size` is the output simplex offers to pay and `price` is quote per 1 base, + * both at 1e18. The input is rounded up so the rate the op actually carries is + * never better for the taker than the operator's price: a bid ends up asking + * for slightly more base, an ask for slightly more quote. + */ +export function signedAmounts(params: { + side: LimitOrderSide + size: bigint + price: bigint + baseDecimals: number + quoteDecimals: number +}): { inputAmount: bigint; outputAmount: bigint } { + const { side, size, price, baseDecimals, quoteDecimals } = params + if (price <= 0n) throw new Error("A limit order's price must be greater than zero") + + const base = 10n ** BigInt(baseDecimals) + const quote = 10n ** BigInt(quoteDecimals) + + if (side === "BID") { + const outputAmount = toRaw(size, quoteDecimals) + return { outputAmount, inputAmount: divCeil(outputAmount * base * ORDERBOOK_SCALE, price * quote) } + } + + const outputAmount = toRaw(size, baseDecimals) + return { outputAmount, inputAmount: divCeil(outputAmount * price * quote, base * ORDERBOOK_SCALE) } +} + diff --git a/sdk/packages/simplex/src/orderbook/client.ts b/sdk/packages/simplex/src/orderbook/client.ts new file mode 100644 index 0000000000..a9c46dd1a4 --- /dev/null +++ b/sdk/packages/simplex/src/orderbook/client.ts @@ -0,0 +1,173 @@ +import type { HexString } from "@hyperbridge/sdk" +import { defaultLoggerContext, type Logger, type LoggerContext } from "@/services/Logger" +import type { + CancelOrderResult, + MessageRejectionCode, + OrderbookLimits, + PostedOrder, + RejectionCode, + SubmitOrderResult, +} from "./types" + +const LIMITS_QUERY = ` + query Limits { + serverInfo { + minOrderTtlSecs + heartbeatIntervalSecs + signatureSkewSecs + maxBatchSize + minOrderSizes { symbol size } + eip712DomainName + eip712DomainVersion + } + books { id base quote } + } +` + +const POSTED_ORDER_FIELDS = "commitment side status price quotedAmount advertisedSize expiresAt acceptedSources" + +const SUBMIT_ORDER_MUTATION = ` + mutation SubmitOrder($userOp: Bytes!) { + submitOrder(userOp: $userOp) { + __typename + ... on OrderAccepted { surfaced order { ${POSTED_ORDER_FIELDS} } } + ... on OrderUnchanged { order { ${POSTED_ORDER_FIELDS} } } + ... on OrderRejected { code message } + ... on OrderSubmissionFailed { code message retryable } + } + } +` + +const CANCEL_ORDER_MUTATION = ` + mutation CancelOrder($solver: Address!, $commitment: Bytes!, $timestamp: Int!, $signature: Bytes!) { + cancelOrder(solver: $solver, commitment: $commitment, timestamp: $timestamp, signature: $signature) { + __typename + ... on OrderCancelled { commitment } + ... on MessageRejected { code message } + } + } +` + +/** + * A GraphQL error the orderbook returned, or a transport failure reaching it. + * + * Distinct from a rejection: a rejection is the orderbook's considered answer + * about an order, while this is the request never getting one. Callers back off + * and retry these; they act on rejections. + */ +export class OrderbookRequestError extends Error {} + +/** + * Talks to one HyperFX orderbook over GraphQL. + * + * Stateless apart from the endpoint and timeout. Every method either returns the + * orderbook's own answer, including its refusals, or throws + * {@link OrderbookRequestError} because it could not get one. + */ +export class OrderbookClient { + private logger: Logger + + constructor( + private readonly url: string, + private readonly requestTimeoutMs: number, + loggers: LoggerContext = defaultLoggerContext(), + ) { + this.logger = loggers.get("orderbook") + } + + /** The limits to validate against before posting, plus the books on offer. */ + async limits(): Promise { + return this.request(LIMITS_QUERY, {}) + } + + async submitOrder(userOp: HexString): Promise { + const { submitOrder } = await this.request<{ submitOrder: RawSubmitOrder }>(SUBMIT_ORDER_MUTATION, { userOp }) + switch (submitOrder.__typename) { + case "OrderAccepted": + return { kind: "accepted", order: submitOrder.order!, surfaced: submitOrder.surfaced ?? false } + case "OrderUnchanged": + return { kind: "unchanged", order: submitOrder.order! } + case "OrderRejected": + return { kind: "rejected", code: submitOrder.code as RejectionCode, message: submitOrder.message! } + case "OrderSubmissionFailed": + return { + kind: "failed", + code: submitOrder.code!, + message: submitOrder.message!, + retryable: submitOrder.retryable ?? false, + } + default: + throw new OrderbookRequestError(`Unknown submitOrder result ${submitOrder.__typename}`) + } + } + + async cancelOrder(params: { + solver: HexString + commitment: HexString + timestamp: number + signature: HexString + }): Promise { + const { cancelOrder } = await this.request<{ cancelOrder: RawCancelOrder }>(CANCEL_ORDER_MUTATION, params) + if (cancelOrder.__typename === "OrderCancelled") { + return { kind: "cancelled", commitment: cancelOrder.commitment! } + } + if (cancelOrder.__typename === "MessageRejected") { + return { kind: "rejected", code: cancelOrder.code as MessageRejectionCode, message: cancelOrder.message! } + } + throw new OrderbookRequestError(`Unknown cancelOrder result ${cancelOrder.__typename}`) + } + + private async request(query: string, variables: Record): Promise { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), this.requestTimeoutMs) + let response: Response + try { + response = await fetch(this.url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ query, variables }), + signal: controller.signal, + }) + } catch (err) { + throw new OrderbookRequestError(`Could not reach the orderbook at ${this.url}: ${describe(err)}`) + } finally { + clearTimeout(timer) + } + + if (!response.ok) { + throw new OrderbookRequestError(`Orderbook returned HTTP ${response.status} ${response.statusText}`) + } + + const body = (await response.json().catch((err) => { + throw new OrderbookRequestError(`Orderbook returned a body that is not JSON: ${describe(err)}`) + })) as { data?: T; errors?: { message: string }[] } + + if (body.errors?.length) { + const message = body.errors.map((error) => error.message).join("; ") + this.logger.warn({ url: this.url, message }, "Orderbook request failed") + throw new OrderbookRequestError(message) + } + if (!body.data) throw new OrderbookRequestError("Orderbook returned no data") + return body.data + } +} + +function describe(err: unknown): string { + return err instanceof Error ? err.message : String(err) +} + +interface RawSubmitOrder { + __typename: string + order?: PostedOrder + surfaced?: boolean + code?: string + message?: string + retryable?: boolean +} + +interface RawCancelOrder { + __typename: string + commitment?: HexString + code?: string + message?: string +} diff --git a/sdk/packages/simplex/src/orderbook/limit-orders.ts b/sdk/packages/simplex/src/orderbook/limit-orders.ts new file mode 100644 index 0000000000..15cedd2ba4 --- /dev/null +++ b/sdk/packages/simplex/src/orderbook/limit-orders.ts @@ -0,0 +1,395 @@ +import { randomUUID } from "node:crypto" +import { getChainId, MAX_DECLARED_ENTRIES, type HexString } from "@hyperbridge/sdk" +import type { AssetRegistry } from "@/config/asset-registry" +import type { LimitOrder, LimitOrderFilter, LimitOrderInsert, LimitOrderStore } from "@/data/types" +import type { ContractInteractionService } from "@/services/ContractInteractionService" +import type { DelegationService } from "@/services/DelegationService" +import type { FillerConfigService } from "@/services/FillerConfigService" +import { defaultLoggerContext, type Logger, type LoggerContext } from "@/services/Logger" +import type { Signer } from "@/services/wallet" +import { signedAmounts } from "./amounts" +import { OrderbookClient, OrderbookRequestError } from "./client" +import type { Book, CancelOrderResult, OrderbookLimits, SubmitOrderResult } from "./types" + +/** How long a read of `serverInfo` and `books` is reused before being refreshed. */ +const LIMITS_TTL_MS = 5 * 60 * 1000 + +/** EIP-712 types for the signed messages the orderbook accepts. */ +const CANCEL_ORDER_TYPES = { + EIP712Domain: [ + { name: "name", type: "string" }, + { name: "version", type: "string" }, + ], + CancelOrder: [ + { name: "commitment", type: "bytes32" }, + { name: "timestamp", type: "uint64" }, + ], +} as const + +/** + * An operator error: the request itself is wrong, and retrying it unchanged will + * fail the same way. The HTTP layer turns these into a 400. + */ +export class LimitOrderValidationError extends Error {} + +export interface CreateLimitOrderRequest { + book: string + side: "BID" | "ASK" + fillChain: string + /** Quote per 1 base at 1e18, as a decimal string. */ + price: string + /** The output to pay at 1e18, as a decimal string. */ + size: string + acceptedSources: string[] + ttlSecs?: number + expiresAt?: string | null +} + +/** The stored limit order, and what the orderbook said about its posting. */ +export interface PostedLimitOrder { + order: LimitOrder + result: SubmitOrderResult +} + +export interface CancelledLimitOrder { + order: LimitOrder + result: CancelOrderResult +} + +/** + * Creates the operator's limit orders and keeps the orderbook's copy of them. + * + * A limit order is stored before it is posted, so a posting that fails leaves a + * row the operator can see and act on rather than a request that vanished. The + * orderbook's answer is then written back onto that row: an accepted order + * carries its commitment, a rejected one carries the reason. + */ +export class LimitOrderService { + private logger: Logger + private cachedLimits?: { limits: OrderbookLimits; readAt: number } + + constructor( + private readonly store: LimitOrderStore, + private readonly client: OrderbookClient, + private readonly contractService: ContractInteractionService, + private readonly configService: FillerConfigService, + private readonly assetRegistry: AssetRegistry, + private readonly signer: Signer, + private readonly defaultTtlSecs: number, + private readonly delegationService?: DelegationService, + loggers: LoggerContext = defaultLoggerContext(), + ) { + this.logger = loggers.get("limit-orders") + } + + list(filter?: LimitOrderFilter): Promise { + return this.store.list(filter) + } + + get(id: string): Promise { + return this.store.get(id) + } + + /** + * Validates, stores and posts one limit order. + * + * Everything the orderbook would refuse for is checked first, so a rejection + * that does come back is either a race with the server's own limits or a bug + * in what we encode. The one check that can cost a transaction, 7702 + * delegation, runs before the row is written: the orderbook deletes an + * undelegated solver's orders outright, so posting without it achieves nothing. + */ + async create(request: CreateLimitOrderRequest): Promise { + const limits = await this.limits() + const book = this.resolveBook(limits, request.book) + const ttlSecs = request.ttlSecs ?? this.defaultTtlSecs + this.validate(request, book, limits, ttlSecs) + + if (this.delegationService && !(await this.delegationService.setupDelegation(request.fillChain))) { + throw new LimitOrderValidationError( + `The solver is not 7702-delegated on ${request.fillChain}, and the orderbook deletes an undelegated solver's orders`, + ) + } + + const insert: LimitOrderInsert = { + id: randomUUID(), + book: book.id, + base: book.base, + quote: book.quote, + side: request.side, + fillChain: request.fillChain, + price: request.price, + size: request.size, + acceptedSources: request.acceptedSources, + ttlSecs, + expiresAt: request.expiresAt ?? null, + } + return this.post(await this.store.create(insert)) + } + + /** + * Withdraws a limit order. + * + * Local first: once the status is no longer `open` nothing new can draw on the + * order, which matters more than the orderbook entry going away promptly. A + * cancel the orderbook refuses still leaves the order cancelled here, with the + * refusal on the row for the operator to see. + */ + async cancel(id: string): Promise { + const existing = await this.store.get(id) + if (!existing) throw new LimitOrderValidationError(`No limit order with id '${id}'`) + + const order = (await this.store.setStatus(id, "cancelled"))! + if (!existing.commitment) { + return { order, result: { kind: "cancelled", commitment: "0x" as HexString } } + } + + const result = await this.withdraw(existing.commitment as HexString) + if (result.kind === "cancelled" || result.code === "UNKNOWN_ORDER") { + // UNKNOWN_ORDER means the entry is already gone, whether it expired, was + // swept, or the orderbook deleted it over balance or delegation. + return { + order: (await this.store.setPosting(id, { + commitment: null, + bookExpiresAt: null, + bookPrice: null, + orderNonce: order.orderNonce, + status: "cancelled", + lastError: null, + }))!, + result, + } + } + + const message = `${result.code}: ${result.message}` + this.logger.error({ id, err: message }, "Orderbook refused to cancel the limit order") + return { order: (await this.store.setStatus(id, "cancelled", message))!, result } + } + + /** + * Signs and sends one `cancelOrder`, retrying once on a timestamp the server + * would not take. Both retryable codes are about the timestamp alone, so a + * later one is the whole fix: `SIGNATURE_EXPIRED` means our clock has drifted + * past the skew the server allows, `SIGNATURE_REUSED` that a previous cancel + * already used this second. + */ + private async withdraw(commitment: HexString): Promise { + const first = await this.signAndCancel(commitment, nowSecs()) + if (first.kind === "cancelled") return first + if (first.code !== "SIGNATURE_EXPIRED" && first.code !== "SIGNATURE_REUSED") return first + + if (first.code === "SIGNATURE_EXPIRED") { + this.logger.warn({ commitment }, "Orderbook rejected the cancel timestamp as expired; check this host's clock") + } + return this.signAndCancel(commitment, nowSecs() + 1) + } + + private async signAndCancel(commitment: HexString, timestamp: number): Promise { + const { eip712DomainName, eip712DomainVersion } = (await this.limits()).serverInfo + const signature = await this.signer.signTypedData({ + domain: { name: eip712DomainName, version: eip712DomainVersion }, + types: CANCEL_ORDER_TYPES, + primaryType: "CancelOrder", + message: { commitment, timestamp }, + }) + try { + return await this.client.cancelOrder({ solver: this.signer.address, commitment, timestamp, signature }) + } catch (err) { + if (err instanceof OrderbookRequestError) { + return { kind: "rejected", code: "UNKNOWN_ORDER", message: err.message } + } + throw err + } + } + + /** + * Reads `serverInfo` and `books`, reusing the last read for {@link LIMITS_TTL_MS}. + * + * The limits change rarely and every create needs them, so re-reading per + * request would put a round trip in front of an operator action for nothing. + */ + private async limits(): Promise { + const cached = this.cachedLimits + if (cached && Date.now() - cached.readAt < LIMITS_TTL_MS) return cached.limits + try { + const limits = await this.client.limits() + this.cachedLimits = { limits, readAt: Date.now() } + return limits + } catch (err) { + // A stale read still describes the server's limits better than nothing, + // and the posting itself is about to find out whether it is still right. + if (cached) { + this.logger.warn({ err }, "Could not refresh the orderbook's limits; using the last read") + return cached.limits + } + throw err + } + } + + private resolveBook(limits: OrderbookLimits, id: string): Book { + const book = limits.books.find((candidate) => candidate.id === id) + if (!book) { + const known = limits.books.map((candidate) => candidate.id).join(", ") + throw new LimitOrderValidationError(`Unknown book '${id}'. The orderbook offers: ${known || "none"}`) + } + return book + } + + private validate(request: CreateLimitOrderRequest, book: Book, limits: OrderbookLimits, ttlSecs: number): void { + if (request.side !== "BID" && request.side !== "ASK") { + throw new LimitOrderValidationError("side must be 'BID' or 'ASK'") + } + if (!this.configService.getConfiguredChainIds().includes(getChainId(request.fillChain) ?? -1)) { + throw new LimitOrderValidationError(`'${request.fillChain}' is not a chain this filler is configured for`) + } + + for (const [name, value] of [ + ["price", request.price], + ["size", request.size], + ] as const) { + if (!/^[0-9]+$/.test(value ?? "") || BigInt(value) <= 0n) { + throw new LimitOrderValidationError(`${name} must be a positive integer at 1e18, as a decimal string`) + } + } + + const sources = request.acceptedSources ?? [] + if (sources.length === 0) { + throw new LimitOrderValidationError( + "acceptedSources must name at least one source chain; the orderbook rejects an order that declares none", + ) + } + if (new Set(sources).size !== sources.length) { + throw new LimitOrderValidationError("acceptedSources must not repeat a chain") + } + if (sources.length > MAX_DECLARED_ENTRIES) { + throw new LimitOrderValidationError(`acceptedSources cannot name more than ${MAX_DECLARED_ENTRIES} chains`) + } + for (const source of sources) { + const bytes = new TextEncoder().encode(source).length + if (bytes === 0 || bytes > 255) { + throw new LimitOrderValidationError(`'${source}' is not a state machine id of 1 to 255 UTF-8 bytes`) + } + } + + if (ttlSecs < limits.serverInfo.minOrderTtlSecs) { + throw new LimitOrderValidationError( + `ttlSecs must be at least the orderbook's minimum of ${limits.serverInfo.minOrderTtlSecs}; got ${ttlSecs}`, + ) + } + + // The output is what the operator pays out, so that is the side the dust + // floor applies to: a bid pays the quote, an ask pays the base. + const outputSymbol = request.side === "BID" ? book.quote : book.base + const floor = limits.serverInfo.minOrderSizes.find((entry) => entry.symbol === outputSymbol) + if (floor && BigInt(request.size) < BigInt(floor.size)) { + throw new LimitOrderValidationError( + `size is below the orderbook's dust floor for ${outputSymbol} (${floor.size} at 1e18)`, + ) + } + + // Resolved here rather than at post time so an unknown symbol reads as an + // error on the request instead of a rejection against a row already stored. + for (const symbol of [book.base, book.quote]) { + if (!this.assetRegistry.getAddress(symbol, request.fillChain)) { + throw new LimitOrderValidationError(`'${symbol}' does not resolve to a token address on ${request.fillChain}`) + } + } + } + + /** Builds and submits the posting, then writes the orderbook's answer onto the row. */ + private async post(order: LimitOrder): Promise { + const { result, orderNonce } = await this.submit(order) + + if (result.kind === "accepted" || result.kind === "unchanged") { + const posted = result.order + this.logger.info( + { id: order.id, commitment: posted.commitment, price: posted.price }, + "Limit order posted to the orderbook", + ) + const stored = await this.store.setPosting(order.id, { + commitment: posted.commitment, + bookExpiresAt: posted.expiresAt, + bookPrice: posted.price, + orderNonce: orderNonce.toString(), + status: "open", + lastError: null, + }) + return { order: stored!, result } + } + + const message = `${result.code}: ${result.message}` + this.logger.error({ id: order.id, err: message }, "Orderbook refused the limit order") + return { order: (await this.store.setStatus(order.id, "rejected", message))!, result } + } + + /** + * Submits the posting, answering a nonce the orderbook has already seen with a + * fresh one. `REPLAYED` and `ORDER_EXISTS` both mean the op hashed to something + * it remembers, and it remembers every hash forever, so bumping the nonce is + * the only way past. Anything else is returned as it came. + */ + private async submit(order: LimitOrder): Promise<{ result: SubmitOrderResult; orderNonce: bigint }> { + const orderNonce = BigInt(order.orderNonce) + const first = await this.buildAndSubmit(order, orderNonce) + if (first.kind !== "rejected" || (first.code !== "REPLAYED" && first.code !== "ORDER_EXISTS")) { + return { result: first, orderNonce } + } + + this.logger.warn({ id: order.id, code: first.code }, "Orderbook has seen this op before; reposting on a new nonce") + const retried = orderNonce + 1n + return { result: await this.buildAndSubmit(order, retried), orderNonce: retried } + } + + private async buildAndSubmit(order: LimitOrder, orderNonce: bigint): Promise { + const userOp = await this.buildUserOp(order, orderNonce) + try { + return await this.client.submitOrder(userOp) + } catch (err) { + if (err instanceof OrderbookRequestError) { + return { kind: "failed", code: "REQUEST_FAILED", message: err.message, retryable: true } + } + throw err + } + } + + private async buildUserOp(order: LimitOrder, orderNonce: bigint): Promise { + const baseToken = this.assetRegistry.getAddress(order.base, order.fillChain)! + const quoteToken = this.assetRegistry.getAddress(order.quote, order.fillChain)! + const [baseDecimals, quoteDecimals] = await Promise.all([ + this.contractService.getTokenDecimals(baseToken, order.fillChain), + this.contractService.getTokenDecimals(quoteToken, order.fillChain), + ]) + + // A bid receives the base and pays the quote; an ask is the other way round. + const [inputToken, outputToken] = order.side === "BID" ? [baseToken, quoteToken] : [quoteToken, baseToken] + const { inputAmount, outputAmount } = signedAmounts({ + side: order.side, + size: BigInt(order.remaining), + price: BigInt(order.price), + baseDecimals, + quoteDecimals, + }) + + const entryPointAddress = this.configService.getEntryPointAddress(order.fillChain) + if (!entryPointAddress) { + throw new LimitOrderValidationError(`No EntryPoint is configured for ${order.fillChain}`) + } + + const { userOp } = await this.contractService.prepareLimitOrderUserOp({ + fillChain: order.fillChain, + entryPointAddress, + inputToken, + outputToken, + inputAmount, + outputAmount, + orderNonce, + ttlSecs: order.ttlSecs, + acceptedSourceChains: order.acceptedSources, + }) + return userOp + } +} + +function nowSecs(): number { + return Math.floor(Date.now() / 1000) +} diff --git a/sdk/packages/simplex/src/orderbook/types.ts b/sdk/packages/simplex/src/orderbook/types.ts new file mode 100644 index 0000000000..6be225d896 --- /dev/null +++ b/sdk/packages/simplex/src/orderbook/types.ts @@ -0,0 +1,98 @@ +import type { HexString } from "@hyperbridge/sdk" + +/** + * The HyperFX orderbook's wire shapes, narrowed to the fields simplex reads. + * + * Every `BigInt` the schema declares arrives as a decimal string and stays one + * here: these values are U256 and only ever compared or stored, so parsing them + * eagerly would buy nothing and lose precision on the way back out. + */ + +/** + * The shortest TTL the orderbook documents itself as accepting. + * + * A fallback for validating config before anything has been read from the + * server: the live `serverInfo.minOrderTtlSecs` is authoritative and a posting + * is checked against that, not this. + */ +export const MIN_ORDER_TTL_SECONDS = 900 + +export interface Book { + id: string + base: string + quote: string +} + +export interface TokenMinSize { + symbol: string + size: string +} + +export interface ServerInfo { + minOrderTtlSecs: number + heartbeatIntervalSecs: number + signatureSkewSecs: number + maxBatchSize: number + minOrderSizes: TokenMinSize[] + eip712DomainName: string + eip712DomainVersion: string +} + +/** `serverInfo` and `books` together, which is how simplex reads them. */ +export interface OrderbookLimits { + serverInfo: ServerInfo + books: Book[] +} + +export interface PostedOrder { + commitment: HexString + side: "BID" | "ASK" + status: "ACTIVE" | "SUSPENDED" + price: string + quotedAmount: string + advertisedSize: string + expiresAt: string + acceptedSources: string[] +} + +/** + * Why the orderbook refused an order. `MIN_ORDER_SIZE` and `TTL_TOO_SHORT` are + * prevented by validation before posting; `REPLAYED` and `ORDER_EXISTS` are + * answered by bumping the nonce; the rest are encoding bugs on our side. + */ +export type RejectionCode = + | "MALFORMED_USER_OP" + | "NO_FILL_ORDER" + | "UNSUPPORTED_CHAIN" + | "UNSUPPORTED_SHAPE" + | "NOT_PHANTOM" + | "MISSING_VALID_UNTIL" + | "TTL_TOO_SHORT" + | "UNSUPPORTED_PAIR" + | "BAD_SIGNATURE" + | "BAD_NONCE_BINDING" + | "REPLAYED" + | "ORDER_EXISTS" + | "TOO_MANY_ORDERS" + | "MIN_ORDER_SIZE" + | "MISSING_DECLARATION" + | "EMPTY_DECLARATION" + +export type SubmitOrderResult = + | { kind: "accepted"; order: PostedOrder; surfaced: boolean } + | { kind: "unchanged"; order: PostedOrder } + | { kind: "rejected"; code: RejectionCode; message: string } + | { kind: "failed"; code: string; message: string; retryable: boolean } + +/** Why a signed message was refused, shared by `cancelOrder` and `heartbeat`. */ +export type MessageRejectionCode = + | "BAD_SIGNATURE" + | "SOLVER_MISMATCH" + | "SIGNATURE_EXPIRED" + | "SIGNATURE_REUSED" + | "UNKNOWN_ORDER" + | "UNKNOWN_SOLVER" + +export type CancelOrderResult = + | { kind: "cancelled"; commitment: HexString } + | { kind: "rejected"; code: MessageRejectionCode; message: string } diff --git a/sdk/packages/simplex/src/services/ContractInteractionService.ts b/sdk/packages/simplex/src/services/ContractInteractionService.ts index 6a5a2c5abc..ce3e37cb87 100644 --- a/sdk/packages/simplex/src/services/ContractInteractionService.ts +++ b/sdk/packages/simplex/src/services/ContractInteractionService.ts @@ -18,6 +18,9 @@ import { type TokenInfo, encodeFillOrder, readLegPartialFill, + encodePhantomBidDeclaration, + bytes20ToBytes32, + type FillOptionsVersion, } from "@hyperbridge/sdk" import { ERC20_ABI } from "@/config/abis/ERC20" import type { ChainClientManager } from "./ChainClientManager" @@ -33,6 +36,22 @@ import { buildPaymasterAndData } from "@/services/paymaster" // Configure for financial precision Decimal.config({ precision: 28, rounding: 4 }) + +/** + * Gas fields for a limit order's UserOp. The orderbook does not read them and no + * bundler ever prices the op, so they are fixed rather than estimated; they exist + * because the packed struct the solver signs over has the fields. + */ +const LIMIT_ORDER_CALL_GAS_LIMIT = 500_000n +const LIMIT_ORDER_VERIFICATION_GAS_LIMIT = 150_000n +const LIMIT_ORDER_PRE_VERIFICATION_GAS = 50_000n + +/** + * Limit orders are always encoded as v2, whatever the destination gateway is. + * The op never executes, so the deployed shape does not matter, and v1 has + * nowhere to put `validUntil` which the orderbook requires. + */ +const LIMIT_ORDER_FILL_OPTIONS_VERSION: FillOptionsVersion = 2 /** * Handles contract interactions for tokens and other contracts */ @@ -794,6 +813,110 @@ export class ContractInteractionService { return currentBlock + BigInt(Math.ceil(windowSec / blockTimeSec)) } + /** + * Builds the signed, never-executed UserOperation that carries one limit order + * to the HyperFX orderbook. + * + * The op is a `fillOrder` for a synthetic same-chain order at the operator's + * rate. It is a price commitment rather than a transaction: the orderbook + * verifies the solver signature over the userOpHash, reads the amounts out of + * the calldata, and nothing ever submits it to a bundler. That is why the gas + * fields are fixed rather than estimated, and why `paymasterAndData` carries + * the accepted-source declaration instead of a paymaster. + * + * Always encoded as FillOptions v2. The op never runs, so the deployed + * gateway's own version is beside the point, and the orderbook rejects v1 for + * having nowhere to put `validUntil`. + */ + async prepareLimitOrderUserOp(params: { + fillChain: string + entryPointAddress: HexString + inputToken: HexString + outputToken: HexString + inputAmount: bigint + outputAmount: bigint + orderNonce: bigint + /** Seconds from the orderbook's receipt, not a block number. */ + ttlSecs: number + acceptedSourceChains: string[] + }): Promise<{ commitment: HexString; userOp: HexString }> { + const { fillChain, inputToken, outputToken, inputAmount, outputAmount, acceptedSourceChains } = params + if (acceptedSourceChains.length === 0) { + throw new Error("A limit order must declare at least one accepted source chain") + } + + const sdkHelper = await this.getIntentGateway(fillChain, fillChain) + const gateway = this.configService.getIntentGatewayAddress(fillChain) + + // The shape the orderbook recognises: one input, a single output whose + // amount lives in the fill options rather than the order, no session key, + // and source == destination so nothing is dispatched. + const order: Order = { + user: bytes20ToBytes32(ADDRESS_ZERO), + source: fillChain, + destination: fillChain, + deadline: 0n, + nonce: params.orderNonce, + fees: 0n, + session: ADDRESS_ZERO, + predispatch: { assets: [], call: "0x" }, + inputs: [{ token: bytes20ToBytes32(inputToken), amount: inputAmount }], + output: { + beneficiary: bytes20ToBytes32(ADDRESS_ZERO), + assets: [{ token: bytes20ToBytes32(outputToken), amount: 0n }], + call: "0x", + }, + } + const commitment = orderCommitment(order) + + const fillOptions: FillOptions = { + relayerFee: 0n, + nativeDispatchFee: 0n, + validUntil: BigInt(params.ttlSecs), + outputs: [{ token: bytes20ToBytes32(outputToken), amount: outputAmount }], + } + + const calls: ERC7821Call[] = [ + { + target: outputToken, + value: 0n, + data: encodeFunctionData({ + abi: ERC20_ABI, + functionName: "approve", + args: [gateway, outputAmount], + }) as HexString, + }, + { + target: gateway, + value: 0n, + // biome-ignore lint/suspicious/noExplicitAny: the SDK's contract-order shape is not exported + data: encodeFillOrder(transformOrderForContract(order) as any, fillOptions, LIMIT_ORDER_FILL_OPTIONS_VERSION), + }, + ] + + // `prepareSubmitBid` binds the nonce key and prefixes the signature with + // `order.id`, both of which have to be this commitment. Setting it here is + // what makes the shared builder produce a limit order's op rather than a + // second copy of the signing logic. + const userOp = await sdkHelper.prepareSubmitBid({ + order: { ...order, id: commitment }, + fillOptions, + solverAccount: this.solverAccountAddress, + solverSigner: sdkSigningAccount(this.signer), + nonce: CryptoUtils.bidNonceKey(commitment, ADDRESS_ZERO) << 64n, + entryPointAddress: params.entryPointAddress, + callGasLimit: LIMIT_ORDER_CALL_GAS_LIMIT, + verificationGasLimit: LIMIT_ORDER_VERIFICATION_GAS_LIMIT, + preVerificationGas: LIMIT_ORDER_PRE_VERIFICATION_GAS, + maxFeePerGas: 0n, + maxPriorityFeePerGas: 0n, + callData: encodeERC7821ExecuteBatch(calls), + paymasterAndData: encodePhantomBidDeclaration({ acceptedSourceChains }), + }) + + return { commitment, userOp: encodeUserOpScale(userOp) } + } + /** * Builds ERC-7821 batch calldata that prepends any required ERC20 approvals * before the fillOrder call, all within a single UserOp payload. diff --git a/sdk/packages/simplex/src/services/server/UiServer.ts b/sdk/packages/simplex/src/services/server/UiServer.ts index 4f0881de7e..a2ca74c377 100644 --- a/sdk/packages/simplex/src/services/server/UiServer.ts +++ b/sdk/packages/simplex/src/services/server/UiServer.ts @@ -18,7 +18,10 @@ import { formatUnits, isAddress } from "viem" import { validateRpcUrls, type AllowlistConfig } from "@/services/FillerConfigService" import { withTimeout, PROBE_TIMEOUT_MS } from "@/cli/init/prompt-utils" import type { ActivityRecorder } from "@/data/recorder" -import type { ActivityEvent, BidStore, OrderLeg } from "@/data/types" +import type { ActivityEvent, BidStore, LimitOrderFilter, OrderLeg } from "@/data/types" +import { OrderbookRequestError } from "@/orderbook/client" +import { LimitOrderValidationError, type CreateLimitOrderRequest } from "@/orderbook/limit-orders" +import type { LimitOrderController } from "@/simplex" import type { BalanceProvider } from "../BalanceProvider" import { getLogger, type LogLevel } from "../Logger" import { DEFAULT_TUNNEL_RELAY, parseRelayAddress, relayKey, type TunnelControls } from "../tunnel/TunnelService" @@ -161,6 +164,8 @@ export interface OperatorContext { stop(): Promise activity: Pick bids?: Pick + /** The operator's limit orders. Absent unless `[orderbook]` is enabled. */ + limitOrders?: Pick /** Persists an operator pause so it survives a restart. */ setPaused(paused: boolean): Promise /** @@ -824,6 +829,38 @@ export class UiServer { return handleSetupRequest(this, this.setup, req, res, path, method) } + if (path === "/api/limit-orders") { + if (this.mode !== "operator") return sendJson(res, 409, { error: "Filler is not running" }) + if (method === "GET") { + const params = new URL(req.url ?? "/", "http://localhost").searchParams + return this.handleLimitOrders(res, () => + this.operator!.limitOrders!.list({ + status: (params.get("status") as LimitOrderFilter["status"]) ?? undefined, + fillChain: params.get("chain") ?? undefined, + book: params.get("book") ?? undefined, + }).then((orders) => ({ orders })), + ) + } + if (method === "POST") return this.handleLimitOrderCreate(req, res) + return sendJson(res, 405, { error: "Method not allowed" }) + } + + const limitOrderMatch = path.match(/^\/api\/limit-orders\/([\w-]+)$/) + if (limitOrderMatch) { + if (this.mode !== "operator") return sendJson(res, 409, { error: "Filler is not running" }) + const id = limitOrderMatch[1] + if (method === "GET") { + return this.handleLimitOrders(res, async () => { + const order = await this.operator!.limitOrders!.get(id) + return order && { order } + }) + } + if (method === "DELETE") { + return this.handleLimitOrders(res, () => this.operator!.limitOrders!.cancel(id)) + } + return sendJson(res, 405, { error: "Method not allowed" }) + } + if (path === "/api/strategies") { if (this.mode !== "operator") return sendJson(res, 409, { error: "Filler is not running" }) if (method === "GET") { @@ -1357,6 +1394,36 @@ export class UiServer { * graph, symbol resolution on the running chains) before anything mutates, * hydrated into the running engine when possible, and persisted either way. */ + /** + * Runs one limit-order operation and maps its failures onto status codes: an + * operator mistake is a 400, an orderbook that could not be reached is a 502, + * and an operation resolving null is a 404. + */ + private async handleLimitOrders(res: ServerResponse, run: () => Promise): Promise { + if (!this.operator?.limitOrders) { + return sendJson(res, 501, { error: "No orderbook is configured for this filler" }) + } + try { + const payload = await run() + if (payload === null || payload === undefined) return sendJson(res, 404, { error: "Not found" }) + return sendJson(res, 200, payload) + } catch (err) { + if (err instanceof LimitOrderValidationError) return sendJson(res, 400, { error: err.message }) + if (err instanceof OrderbookRequestError) return sendJson(res, 502, { error: err.message }) + throw err + } + } + + private async handleLimitOrderCreate(req: IncomingMessage, res: ServerResponse): Promise { + let body: CreateLimitOrderRequest + try { + body = JSON.parse(await readBody(req)) + } catch { + return sendJson(res, 400, { error: "Invalid JSON body" }) + } + return this.handleLimitOrders(res, () => this.operator!.limitOrders!.create(body)) + } + private async handleMarketAdd(req: IncomingMessage, res: ServerResponse): Promise { const op = this.operator! let body: { diff --git a/sdk/packages/simplex/src/simplex.ts b/sdk/packages/simplex/src/simplex.ts index 3a2bf7d6d6..20aee91d31 100644 --- a/sdk/packages/simplex/src/simplex.ts +++ b/sdk/packages/simplex/src/simplex.ts @@ -15,6 +15,13 @@ import { patchRuntimeState } from "@/data/state" import { MemoryDataStore } from "@/data/memory" import { OrderScanner as OrderScannerImpl } from "@/scanner/order-scanner" import type { OrderScanner } from "@/scanner/types" +import type { LimitOrder, LimitOrderFilter } from "@/data/types" +import type { + CancelledLimitOrder, + CreateLimitOrderRequest, + LimitOrderService, + PostedLimitOrder, +} from "@/orderbook/limit-orders" import type { BalanceSnapshot } from "@/services/BalanceProvider" import type { Signer } from "@/services/wallet" @@ -113,6 +120,9 @@ export interface SimplexEvents { "order:fill-observed": { commitment: HexString; filler: string; chainId: number; txHash?: string; ours: boolean } rebalance: { success: boolean; transferCount?: number; executedCount?: number; error?: string } activity: ActivityEvent + "limit-order:posted": { order: LimitOrder } + "limit-order:rejected": { order: LimitOrder; code: string; message: string } + "limit-order:cancelled": { order: LimitOrder } } /** Internal monitor event name to public event name. */ @@ -182,6 +192,63 @@ export interface SimplexStatus { // =========================================================================== /** Trading pairs of the running engine. Every mutation binds on the next order. */ +/** + * The operator's limit orders: what simplex offers to pay, and what the + * orderbook advertises on its behalf. + * + * Every method rejects when the filler was started without `[orderbook]` + * enabled, rather than quietly doing nothing. + */ +export class LimitOrderController { + constructor( + private runtime: FillerRuntime, + private emit: LimitOrderEmitter, + ) {} + + private get service(): LimitOrderService { + const service = this.runtime.limitOrders + if (!service) { + throw new Error("No orderbook is configured — set [orderbook] enabled and url to use limit orders") + } + return service + } + + list(filter?: LimitOrderFilter): Promise { + return this.service.list(filter) + } + + get(id: string): Promise { + return this.service.get(id) + } + + /** Creates the order, posts it, and reports what the orderbook made of it. */ + async create(request: CreateLimitOrderRequest): Promise { + const posted = await this.service.create(request) + if (posted.result.kind === "rejected" || posted.result.kind === "failed") { + this.emit("limit-order:rejected", { + order: posted.order, + code: posted.result.code, + message: posted.result.message, + }) + } else { + this.emit("limit-order:posted", { order: posted.order }) + } + return posted + } + + async cancel(id: string): Promise { + const cancelled = await this.service.cancel(id) + this.emit("limit-order:cancelled", { order: cancelled.order }) + return cancelled + } +} + +/** How the controller publishes an outcome on the `Simplex` it belongs to. */ +type LimitOrderEmitter = ( + event: E, + payload: SimplexEvents[E], +) => void + export class PairController { constructor( private runtime: FillerRuntime, @@ -776,6 +843,7 @@ export class Simplex extends EventEmitter { readonly assets: AssetController readonly wallet: WalletController readonly rebalancing: RebalanceController + readonly limitOrders: LimitOrderController private logger: Logger private stopped = false @@ -795,6 +863,7 @@ export class Simplex extends EventEmitter { this.assets = new AssetController(runtime, persist) this.wallet = new WalletController(runtime) this.rebalancing = new RebalanceController(runtime, persist) + this.limitOrders = new LimitOrderController(runtime, (event, payload) => this.emit(event, payload)) this.forwardEvents() } diff --git a/sdk/packages/simplex/src/tests/cli/filler-toml-validate.test.ts b/sdk/packages/simplex/src/tests/cli/filler-toml-validate.test.ts index cfd2d4b708..14b53dfff7 100644 --- a/sdk/packages/simplex/src/tests/cli/filler-toml-validate.test.ts +++ b/sdk/packages/simplex/src/tests/cli/filler-toml-validate.test.ts @@ -186,3 +186,47 @@ describe("validateConfig", () => { expect(() => validateConfig(config)).toThrow(/askPriceCurve — .*invalid amount/) }) }) + +describe("validateConfig [orderbook]", () => { + const withOrderbook = (orderbook: FillerTomlConfig["orderbook"]): FillerTomlConfig => ({ + ...minimalConfig(), + orderbook, + }) + + it("ignores the block entirely while it is disabled", () => { + expect(() => validateConfig(withOrderbook({ enabled: false, defaultTtlSecs: 1 }))).not.toThrow() + }) + + it("accepts a well-formed enabled block", () => { + expect(() => + validateConfig( + withOrderbook({ + enabled: true, + url: "https://orderbook.example/graphql", + defaultTtlSecs: 1800, + renewMarginSecs: 120, + reconcileIntervalSecs: 300, + requestTimeoutMs: 10000, + }), + ), + ).not.toThrow() + }) + + it("requires a url once enabled, rather than failing on the first posting", () => { + expect(() => validateConfig(withOrderbook({ enabled: true }))).toThrow(/orderbook.url is required/) + }) + + it("refuses a ttl below the orderbook's own floor", () => { + expect(() => + validateConfig(withOrderbook({ enabled: true, url: "https://example", defaultTtlSecs: 60 })), + ).toThrow(/defaultTtlSecs must be an integer >= 900/) + }) + + it("refuses a renewal margin that is not shorter than the ttl", () => { + expect(() => + validateConfig( + withOrderbook({ enabled: true, url: "https://example", defaultTtlSecs: 900, renewMarginSecs: 900 }), + ), + ).toThrow(/must be shorter than orderbook.defaultTtlSecs/) + }) +}) diff --git a/sdk/packages/simplex/src/tests/data/limit-order-store.test.ts b/sdk/packages/simplex/src/tests/data/limit-order-store.test.ts new file mode 100644 index 0000000000..5f68ae3097 --- /dev/null +++ b/sdk/packages/simplex/src/tests/data/limit-order-store.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest" +import { mkdtempSync } from "fs" +import { tmpdir } from "os" +import { join } from "path" +import { LoggerContext } from "@/services/Logger" +import { MemoryDataStore } from "@/data/memory" +import { SqliteDataStore } from "@/data/sqlite" +import type { LimitOrderInsert, LimitOrderStore } from "@/data/types" + +const dataDir = () => mkdtempSync(join(tmpdir(), "simplex-limit-orders-")) + +const ORDER: LimitOrderInsert = { + id: "order-1", + book: "USDC/CNGN", + base: "USDC", + quote: "CNGN", + side: "BID", + fillChain: "EVM-8453", + price: "1500000000000000000000", + size: "1500000000000000000000000", + acceptedSources: ["EVM-1", "EVM-42161"], + ttlSecs: 900, +} + +/** Both backends implement the same contract, so both run the same suite. */ +const backends: [string, () => { store: LimitOrderStore; close: () => Promise }][] = [ + [ + "SqliteLimitOrderStore", + () => { + const store = new SqliteDataStore(dataDir(), new LoggerContext({ level: "warn" })) + return { store: store.limitOrders, close: () => store.close() } + }, + ], + ["MemoryLimitOrderStore", () => ({ store: new MemoryDataStore().limitOrders, close: async () => {} })], +] + +describe.each(backends)("%s", (_name, open) => { + it("opens an order at its full size with nothing reserved", async () => { + const { store, close } = open() + const created = await store.create(ORDER) + + expect(created.status).toBe("open") + expect(created.remaining).toBe(ORDER.size) + expect(created.reserved).toBe("0") + expect(created.commitment).toBeNull() + expect(created.orderNonce).toBe("0") + expect(created.acceptedSources).toEqual(["EVM-1", "EVM-42161"]) + await close() + }) + + it("keeps 1e18 amounts exact, past what a 64-bit integer column would hold", async () => { + const { store, close } = open() + // Well past 2^63, which is where a numeric column would start rounding. + const size = "123456789012345678901234567890" + const created = await store.create({ ...ORDER, size, price: size }) + + expect(created.size).toBe(size) + expect((await store.get(ORDER.id))?.price).toBe(size) + await close() + }) + + it("records a posting and reads it back", async () => { + const { store, close } = open() + await store.create(ORDER) + const posted = await store.setPosting(ORDER.id, { + commitment: "0xabc", + bookExpiresAt: "2026-09-15T12:00:00.000Z", + bookPrice: "1490000000000000000000", + orderNonce: "3", + status: "open", + lastError: null, + }) + + expect(posted?.commitment).toBe("0xabc") + expect(posted?.orderNonce).toBe("3") + expect(posted?.bookPrice).toBe("1490000000000000000000") + await close() + }) + + it("filters a listing by status, chain and book", async () => { + const { store, close } = open() + await store.create(ORDER) + await store.create({ ...ORDER, id: "order-2", fillChain: "EVM-1" }) + await store.setStatus("order-2", "cancelled") + + expect((await store.list({ status: "open" })).map((order) => order.id)).toEqual(["order-1"]) + expect((await store.list({ fillChain: "EVM-1" })).map((order) => order.id)).toEqual(["order-2"]) + expect(await store.list({ book: "USDC/EURC" })).toEqual([]) + expect(await store.list()).toHaveLength(2) + await close() + }) + + it("carries a rejection on the row instead of losing it", async () => { + const { store, close } = open() + await store.create(ORDER) + const rejected = await store.setStatus(ORDER.id, "rejected", "UNSUPPORTED_PAIR: no such market") + + expect(rejected?.status).toBe("rejected") + expect(rejected?.lastError).toBe("UNSUPPORTED_PAIR: no such market") + await close() + }) + + it("resolves null for an id it does not know", async () => { + const { store, close } = open() + expect(await store.get("missing")).toBeNull() + expect(await store.setStatus("missing", "cancelled")).toBeNull() + await close() + }) +}) + +describe("SqliteLimitOrderStore", () => { + it("survives a reopen of the same data directory", async () => { + const dir = dataDir() + const first = new SqliteDataStore(dir, new LoggerContext({ level: "warn" })) + await first.limitOrders.create(ORDER) + await first.close() + + const second = new SqliteDataStore(dir, new LoggerContext({ level: "warn" })) + expect((await second.limitOrders.get(ORDER.id))?.size).toBe(ORDER.size) + await second.close() + }) +}) diff --git a/sdk/packages/simplex/src/tests/orderbook/amounts.test.ts b/sdk/packages/simplex/src/tests/orderbook/amounts.test.ts new file mode 100644 index 0000000000..7c89c35293 --- /dev/null +++ b/sdk/packages/simplex/src/tests/orderbook/amounts.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest" +import { ORDERBOOK_SCALE, signedAmounts, toRaw } from "@/orderbook/amounts" + +/** 1,500 quote per 1 base, the shape a USDC/cNGN book reads at. */ +const PRICE = 1500n * ORDERBOOK_SCALE +const ONE = ORDERBOOK_SCALE + +describe("toRaw", () => { + it("scales an 18-decimal amount down to the token's own units", () => { + expect(toRaw(1000n * ONE, 6)).toBe(1_000_000_000n) + expect(toRaw(1000n * ONE, 18)).toBe(1000n * ONE) + }) + + it("truncates rather than rounding, so a raw amount is never more than was offered", () => { + expect(toRaw(ONE - 1n, 6)).toBe(999_999n) + }) +}) + +describe("signedAmounts", () => { + it("prices a bid at the operator's rate: pay the quote, receive the base", () => { + // Pay 1,500,000 cNGN (18dp) at 1500, so the base leg is 1,000 USDC (6dp). + const { inputAmount, outputAmount } = signedAmounts({ + side: "BID", + size: 1_500_000n * ONE, + price: PRICE, + baseDecimals: 6, + quoteDecimals: 18, + }) + expect(outputAmount).toBe(1_500_000n * ONE) + expect(inputAmount).toBe(1_000_000_000n) + }) + + it("prices an ask the other way round: pay the base, receive the quote", () => { + const { inputAmount, outputAmount } = signedAmounts({ + side: "ASK", + size: 1000n * ONE, + price: PRICE, + baseDecimals: 6, + quoteDecimals: 18, + }) + expect(outputAmount).toBe(1_000_000_000n) + expect(inputAmount).toBe(1_500_000n * ONE) + }) + + it("rounds the input up, so the signed rate is never better for the taker than the price", () => { + // 1 unit of an 18dp quote at 1500 needs a base input of 1/1500 of a unit, + // which is not a whole 6dp unit — the ceiling costs the taker one more. + const bid = signedAmounts({ side: "BID", size: 1n, price: PRICE, baseDecimals: 6, quoteDecimals: 18 }) + expect(bid.outputAmount).toBe(1n) + expect(bid.inputAmount).toBe(1n) + + const ask = signedAmounts({ side: "ASK", size: 1n, price: PRICE, baseDecimals: 18, quoteDecimals: 6 }) + expect(ask.outputAmount).toBe(1n) + expect(ask.inputAmount).toBe(1n) + }) + + it("holds the rate across a pair whose tokens disagree about decimals", () => { + // 6dp base against 6dp quote: both legs land on whole raw units. + const { inputAmount, outputAmount } = signedAmounts({ + side: "BID", + size: 3000n * ONE, + price: 2n * ONE, + baseDecimals: 6, + quoteDecimals: 6, + }) + expect(outputAmount).toBe(3_000_000_000n) + expect(inputAmount).toBe(1_500_000_000n) + }) + + it("refuses a price of zero rather than dividing by it", () => { + expect(() => signedAmounts({ side: "BID", size: ONE, price: 0n, baseDecimals: 6, quoteDecimals: 18 })).toThrow( + /greater than zero/, + ) + }) +}) diff --git a/sdk/packages/simplex/src/tests/orderbook/limit-orders.test.ts b/sdk/packages/simplex/src/tests/orderbook/limit-orders.test.ts new file mode 100644 index 0000000000..5933644628 --- /dev/null +++ b/sdk/packages/simplex/src/tests/orderbook/limit-orders.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it } from "vitest" +import type { HexString } from "@hyperbridge/sdk" +import { MemoryDataStore } from "@/data/memory" +import { ORDERBOOK_SCALE } from "@/orderbook/amounts" +import { OrderbookRequestError } from "@/orderbook/client" +import { LimitOrderService, LimitOrderValidationError, type CreateLimitOrderRequest } from "@/orderbook/limit-orders" +import type { CancelOrderResult, OrderbookLimits, PostedOrder, SubmitOrderResult } from "@/orderbook/types" + +const CHAIN = "EVM-8453" +const USDC = "0x1111111111111111111111111111111111111111" as HexString +const CNGN = "0x2222222222222222222222222222222222222222" as HexString +const SOLVER = "0x3333333333333333333333333333333333333333" as HexString +const ONE = ORDERBOOK_SCALE + +const LIMITS: OrderbookLimits = { + serverInfo: { + minOrderTtlSecs: 900, + heartbeatIntervalSecs: 60, + signatureSkewSecs: 30, + maxBatchSize: 20, + minOrderSizes: [{ symbol: "CNGN", size: (1000n * ONE).toString() }], + eip712DomainName: "HyperFX Orderbook", + eip712DomainVersion: "1", + }, + books: [{ id: "USDC/CNGN", base: "USDC", quote: "CNGN" }], +} + +function postedOrder(overrides: Partial = {}): PostedOrder { + return { + commitment: "0xabc" as HexString, + side: "BID", + status: "ACTIVE", + price: (1490n * ONE).toString(), + quotedAmount: (1_500_000n * ONE).toString(), + advertisedSize: (1_500_000n * ONE).toString(), + expiresAt: "2026-09-15T12:00:00.000Z", + acceptedSources: ["EVM-1"], + ...overrides, + } +} + +/** An orderbook that answers from a queue, and records what it was sent. */ +function fakeClient(results: SubmitOrderResult[], cancels: CancelOrderResult[] = []) { + const submitted: HexString[] = [] + return { + submitted, + limits: async () => LIMITS, + submitOrder: async (userOp: HexString) => { + submitted.push(userOp) + return results.shift() ?? { kind: "accepted" as const, order: postedOrder(), surfaced: true } + }, + cancelOrder: async () => + cancels.shift() ?? ({ kind: "cancelled", commitment: "0xabc" as HexString } as CancelOrderResult), + } +} + +function makeService(client: ReturnType, store = new MemoryDataStore().limitOrders) { + const contractService = { + getTokenDecimals: async (token: string) => (token === USDC ? 6 : 18), + // The op is opaque to the service; the nonce is echoed so a repost is visible. + prepareLimitOrderUserOp: async ({ orderNonce }: { orderNonce: bigint }) => ({ + commitment: "0xabc" as HexString, + userOp: `0x0${orderNonce}` as HexString, + }), + } + const configService = { + getConfiguredChainIds: () => [8453], + getEntryPointAddress: () => "0x4444444444444444444444444444444444444444" as HexString, + } + const assetRegistry = { + getAddress: (symbol: string, chain: string) => + chain === CHAIN ? ({ USDC, CNGN } as Record)[symbol] ?? null : null, + } + const signer = { address: SOLVER, signTypedData: async () => "0xsig" as HexString } + + // biome-ignore lint/suspicious/noExplicitAny: narrow stubs for the collaborators this path touches + const service = new LimitOrderService( + store, + client as any, + contractService as any, + configService as any, + assetRegistry as any, + signer as any, + 900, + undefined, + ) + return { service, store } +} + +const REQUEST: CreateLimitOrderRequest = { + book: "USDC/CNGN", + side: "BID", + fillChain: CHAIN, + price: (1500n * ONE).toString(), + size: (1_500_000n * ONE).toString(), + acceptedSources: ["EVM-1"], +} + +describe("LimitOrderService.create", () => { + it("stores the order and records the orderbook's posting", async () => { + const { service, store } = makeService(fakeClient([])) + const { order, result } = await service.create(REQUEST) + + expect(result.kind).toBe("accepted") + expect(order.status).toBe("open") + expect(order.commitment).toBe("0xabc") + // The orderbook shades a posting by the protocol fee, so both prices are kept. + expect(order.price).toBe((1500n * ONE).toString()) + expect(order.bookPrice).toBe((1490n * ONE).toString()) + expect(order.remaining).toBe(order.size) + expect(await store.get(order.id)).toEqual(order) + }) + + it("keeps a rejected order with the reason on it rather than dropping the request", async () => { + const client = fakeClient([{ kind: "rejected", code: "UNSUPPORTED_PAIR", message: "no such market" }]) + const { service, store } = makeService(client) + const { order, result } = await service.create(REQUEST) + + expect(result.kind).toBe("rejected") + expect(order.status).toBe("rejected") + expect(order.lastError).toBe("UNSUPPORTED_PAIR: no such market") + expect(await store.get(order.id)).not.toBeNull() + }) + + it("bumps the nonce and reposts once when the orderbook has seen the op before", async () => { + const client = fakeClient([{ kind: "rejected", code: "REPLAYED", message: "seen" }]) + const { service } = makeService(client) + const { order, result } = await service.create(REQUEST) + + expect(result.kind).toBe("accepted") + expect(client.submitted).toEqual(["0x00", "0x01"]) + expect(order.orderNonce).toBe("1") + expect(order.status).toBe("open") + }) + + it("does not retry a rejection a new nonce cannot fix", async () => { + const client = fakeClient([{ kind: "rejected", code: "BAD_SIGNATURE", message: "bad" }]) + const { service } = makeService(client) + await service.create(REQUEST) + expect(client.submitted).toHaveLength(1) + }) + + it("reports an unreachable orderbook as retryable and leaves the order rejected", async () => { + const client = fakeClient([]) + client.submitOrder = async () => { + throw new OrderbookRequestError("connect ECONNREFUSED") + } + const { service } = makeService(client) + const { order, result } = await service.create(REQUEST) + + expect(result).toMatchObject({ kind: "failed", retryable: true }) + expect(order.status).toBe("rejected") + }) +}) + +describe("LimitOrderService.create validation", () => { + const rejects = async (patch: Partial, match: RegExp) => { + const { service } = makeService(fakeClient([])) + await expect(service.create({ ...REQUEST, ...patch })).rejects.toThrow(match) + } + + it("refuses a missing or empty accepted-sources list, which the orderbook would reject", async () => { + await rejects({ acceptedSources: [] }, /at least one source chain/) + await rejects({ acceptedSources: undefined as unknown as string[] }, /at least one source chain/) + }) + + it("refuses a repeated source chain", async () => { + await rejects({ acceptedSources: ["EVM-1", "EVM-1"] }, /must not repeat/) + }) + + it("refuses a size under the output token's dust floor", async () => { + await rejects({ size: (999n * ONE).toString() }, /dust floor for CNGN/) + }) + + it("refuses a ttl under the orderbook's minimum", async () => { + await rejects({ ttlSecs: 60 }, /at least the orderbook's minimum of 900/) + }) + + it("refuses an unknown book, naming the ones on offer", async () => { + await rejects({ book: "USDC/EURC" }, /Unknown book 'USDC\/EURC'.*USDC\/CNGN/) + }) + + it("refuses a chain this filler does not run", async () => { + await rejects({ fillChain: "EVM-1" }, /not a chain this filler is configured for/) + }) + + it("refuses a price or size that is not a positive 1e18 integer", async () => { + await rejects({ price: "0" }, /price must be a positive integer/) + await rejects({ size: "1.5" }, /size must be a positive integer/) + }) +}) + +describe("LimitOrderService.cancel", () => { + it("cancels locally first, then clears the orderbook entry", async () => { + const { service, store } = makeService(fakeClient([])) + const created = await service.create(REQUEST) + + const { order, result } = await service.cancel(created.order.id) + expect(result.kind).toBe("cancelled") + expect(order.status).toBe("cancelled") + expect(order.commitment).toBeNull() + expect((await store.get(order.id))?.status).toBe("cancelled") + }) + + it("treats an entry the orderbook no longer knows as already cancelled", async () => { + const client = fakeClient([], [{ kind: "rejected", code: "UNKNOWN_ORDER", message: "gone" }]) + const { service } = makeService(client) + const created = await service.create(REQUEST) + + const { order } = await service.cancel(created.order.id) + expect(order.status).toBe("cancelled") + expect(order.commitment).toBeNull() + expect(order.lastError).toBeNull() + }) + + it("re-signs once on a timestamp the server would not take", async () => { + const client = fakeClient([], [{ kind: "rejected", code: "SIGNATURE_REUSED", message: "same second" }]) + let cancels = 0 + const inner = client.cancelOrder + client.cancelOrder = async () => { + cancels += 1 + return inner() + } + const { service } = makeService(client) + const created = await service.create(REQUEST) + + const { result } = await service.cancel(created.order.id) + expect(cancels).toBe(2) + expect(result.kind).toBe("cancelled") + }) + + it("stays cancelled locally even when the orderbook refuses, with the refusal on the row", async () => { + const client = fakeClient([], [{ kind: "rejected", code: "SOLVER_MISMATCH", message: "wrong key" }]) + const { service } = makeService(client) + const created = await service.create(REQUEST) + + const { order } = await service.cancel(created.order.id) + expect(order.status).toBe("cancelled") + expect(order.lastError).toBe("SOLVER_MISMATCH: wrong key") + }) + + it("refuses an id it does not know", async () => { + const { service } = makeService(fakeClient([])) + await expect(service.cancel("nope")).rejects.toThrow(LimitOrderValidationError) + }) +}) From 77422fe7db2d2b36e82799c9301a2bf1d828ce0f Mon Sep 17 00:00:00 2001 From: dharjeezy Date: Tue, 15 Sep 2026 15:17:36 +0100 Subject: [PATCH 02/12] Derive a limit order's rate and side from the amounts the operator gives --- ...-orders-posted-to-the-hyperfx-orderbook.md | 15 ++-- sdk/packages/simplex/src/orderbook/amounts.ts | 37 +++++++++ .../simplex/src/orderbook/limit-orders.ts | 75 ++++++++++++------- .../src/tests/orderbook/amounts.test.ts | 53 ++++++++++++- .../src/tests/orderbook/limit-orders.test.ts | 31 +++++--- 5 files changed, 168 insertions(+), 43 deletions(-) diff --git a/sdk/packages/simplex/docs/ai/changelog/2026-09-15-limit-orders-posted-to-the-hyperfx-orderbook.md b/sdk/packages/simplex/docs/ai/changelog/2026-09-15-limit-orders-posted-to-the-hyperfx-orderbook.md index 10cd4b4ba6..5fb65b3a47 100644 --- a/sdk/packages/simplex/docs/ai/changelog/2026-09-15-limit-orders-posted-to-the-hyperfx-orderbook.md +++ b/sdk/packages/simplex/docs/ai/changelog/2026-09-15-limit-orders-posted-to-the-hyperfx-orderbook.md @@ -1,8 +1,13 @@ # 2026-09-15 — Limit orders posted to the HyperFX orderbook The operator can now create limit orders, and simplex advertises them on the HyperFX orderbook. -A limit order is what simplex offers to pay: a book, a side, a fill chain, a price and a size. It -lives in a new `limit_orders` table in `bids.db` behind `SimplexDataStore.limitOrders`, and the +An operator states a limit order as what simplex takes in and what it pays out for that, for example +10,000 USDC in for 139,000,000 cNGN out. The rate and the side of the book follow from those two +amounts, so an order is directional by construction: that one prices USDC to cNGN swaps and can +never price cNGN to USDC, which needs its own order. An operator can hold as many at once as they +like, and an incoming order is matched against all of them. + +Orders live in a new `limit_orders` table in `bids.db` behind `SimplexDataStore.limitOrders`, and the orderbook entry is a derived copy that expires and is reposted. Amounts and prices are decimal strings at 1e18 everywhere they cross the orderbook boundary, whatever decimals the tokens use on their own chains. @@ -14,7 +19,7 @@ their own chains. ``` GET /api/limit-orders?status=&chain=&book= GET /api/limit-orders/:id -POST /api/limit-orders { book, side, fillChain, price, size, acceptedSources, ttlSecs?, expiresAt? } +POST /api/limit-orders { fillChain, tokenIn, amountIn, tokenOut, amountOut, acceptedSources, ttlSecs?, expiresAt? } DELETE /api/limit-orders/:id ``` @@ -39,8 +44,8 @@ It is always encoded as FillOptions v2, without consulting the deployed gateway: and v1 has nowhere to put `validUntil`, which the orderbook requires. `validUntil` here is a TTL in seconds from the orderbook's receipt, not a block number. -The input is rounded up against the operator's price, so the rate the op carries is never better for -the taker than the price asked for. `REPLAYED` and `ORDER_EXISTS` are answered once by bumping +The derived rate is rounded in simplex's favour, and so is the input the op is built from, so +neither the stored rate nor a repost quotes better than the two amounts the operator gave. `REPLAYED` and `ORDER_EXISTS` are answered once by bumping `orderNonce`, which changes the commitment and the userOpHash; the orderbook remembers every hash it has accepted, so a fresh nonce is the only way past. diff --git a/sdk/packages/simplex/src/orderbook/amounts.ts b/sdk/packages/simplex/src/orderbook/amounts.ts index ca16fe765c..846df2b881 100644 --- a/sdk/packages/simplex/src/orderbook/amounts.ts +++ b/sdk/packages/simplex/src/orderbook/amounts.ts @@ -18,6 +18,43 @@ function divCeil(numerator: bigint, denominator: bigint): bigint { return (numerator + denominator - 1n) / denominator } +/** + * The rate an operator's two amounts imply, as quote per 1 base at 1e18, and the + * side of the book they trade. + * + * The operator states what they will take in and what they will pay out, so the + * direction is the order rather than something chosen separately: taking the + * base in and paying the quote out is a bid, the other way round an ask. A limit + * order written this way can only ever price swaps going the same way. + * + * The rate is rounded in simplex's favour, the same direction + * {@link signedAmounts} rounds, so rebuilding the op from it on a repost never + * quotes better than the operator asked for. + */ +export function rateFrom(params: { + base: string + quote: string + /** The symbol simplex takes in. */ + tokenIn: string + /** What simplex takes in, at 1e18. */ + amountIn: bigint + /** What simplex pays out, at 1e18. */ + amountOut: bigint +}): { side: LimitOrderSide; price: bigint } { + const { base, quote, tokenIn, amountIn, amountOut } = params + if (amountIn <= 0n || amountOut <= 0n) throw new Error("A limit order's amounts must both be greater than zero") + + if (tokenIn === base) { + // Base in, quote out. A lower rate pays away less quote per base. + return { side: "BID", price: (amountOut * ORDERBOOK_SCALE) / amountIn } + } + if (tokenIn === quote) { + // Quote in, base out. A higher rate takes in more quote per base. + return { side: "ASK", price: divCeil(amountIn * ORDERBOOK_SCALE, amountOut) } + } + throw new Error(`'${tokenIn}' is neither side of the ${base}/${quote} book`) +} + /** * The raw input and output a limit order signs for. * diff --git a/sdk/packages/simplex/src/orderbook/limit-orders.ts b/sdk/packages/simplex/src/orderbook/limit-orders.ts index 15cedd2ba4..4a7e31edc5 100644 --- a/sdk/packages/simplex/src/orderbook/limit-orders.ts +++ b/sdk/packages/simplex/src/orderbook/limit-orders.ts @@ -7,7 +7,7 @@ import type { DelegationService } from "@/services/DelegationService" import type { FillerConfigService } from "@/services/FillerConfigService" import { defaultLoggerContext, type Logger, type LoggerContext } from "@/services/Logger" import type { Signer } from "@/services/wallet" -import { signedAmounts } from "./amounts" +import { rateFrom, signedAmounts } from "./amounts" import { OrderbookClient, OrderbookRequestError } from "./client" import type { Book, CancelOrderResult, OrderbookLimits, SubmitOrderResult } from "./types" @@ -32,14 +32,23 @@ const CANCEL_ORDER_TYPES = { */ export class LimitOrderValidationError extends Error {} +/** + * One limit order as the operator states it: what simplex takes in, and what it + * pays out for that. + * + * "10000 USDC for 139000000 CNGN" is `tokenIn: "USDC", amountIn: 10000e18, + * tokenOut: "CNGN", amountOut: 139000000e18`. The rate and the side of the book + * follow from those, so the order is directional by construction: it prices + * USDC to CNGN swaps and never the reverse. + */ export interface CreateLimitOrderRequest { - book: string - side: "BID" | "ASK" fillChain: string - /** Quote per 1 base at 1e18, as a decimal string. */ - price: string - /** The output to pay at 1e18, as a decimal string. */ - size: string + /** The symbol simplex takes in, at 1e18. */ + tokenIn: string + amountIn: string + /** The symbol simplex pays out, at 1e18. */ + tokenOut: string + amountOut: string acceptedSources: string[] ttlSecs?: number expiresAt?: string | null @@ -100,11 +109,24 @@ export class LimitOrderService { * undelegated solver's orders outright, so posting without it achieves nothing. */ async create(request: CreateLimitOrderRequest): Promise { + // Ahead of the book lookup, which would otherwise report a same-symbol + // request as an unknown pair. + if (request.tokenIn === request.tokenOut) { + throw new LimitOrderValidationError("tokenIn and tokenOut must be different symbols") + } const limits = await this.limits() - const book = this.resolveBook(limits, request.book) + const book = this.resolveBook(limits, request.tokenIn, request.tokenOut) const ttlSecs = request.ttlSecs ?? this.defaultTtlSecs this.validate(request, book, limits, ttlSecs) + const { side, price } = rateFrom({ + base: book.base, + quote: book.quote, + tokenIn: request.tokenIn, + amountIn: BigInt(request.amountIn), + amountOut: BigInt(request.amountOut), + }) + if (this.delegationService && !(await this.delegationService.setupDelegation(request.fillChain))) { throw new LimitOrderValidationError( `The solver is not 7702-delegated on ${request.fillChain}, and the orderbook deletes an undelegated solver's orders`, @@ -116,10 +138,10 @@ export class LimitOrderService { book: book.id, base: book.base, quote: book.quote, - side: request.side, + side, fillChain: request.fillChain, - price: request.price, - size: request.size, + price: price.toString(), + size: request.amountOut, acceptedSources: request.acceptedSources, ttlSecs, expiresAt: request.expiresAt ?? null, @@ -226,26 +248,30 @@ export class LimitOrderService { } } - private resolveBook(limits: OrderbookLimits, id: string): Book { - const book = limits.books.find((candidate) => candidate.id === id) + /** The book that trades this pair of symbols, whichever way round they were given. */ + private resolveBook(limits: OrderbookLimits, tokenIn: string, tokenOut: string): Book { + const book = limits.books.find( + (candidate) => + (candidate.base === tokenIn && candidate.quote === tokenOut) || + (candidate.quote === tokenIn && candidate.base === tokenOut), + ) if (!book) { const known = limits.books.map((candidate) => candidate.id).join(", ") - throw new LimitOrderValidationError(`Unknown book '${id}'. The orderbook offers: ${known || "none"}`) + throw new LimitOrderValidationError( + `No book trades ${tokenIn} against ${tokenOut}. The orderbook offers: ${known || "none"}`, + ) } return book } private validate(request: CreateLimitOrderRequest, book: Book, limits: OrderbookLimits, ttlSecs: number): void { - if (request.side !== "BID" && request.side !== "ASK") { - throw new LimitOrderValidationError("side must be 'BID' or 'ASK'") - } if (!this.configService.getConfiguredChainIds().includes(getChainId(request.fillChain) ?? -1)) { throw new LimitOrderValidationError(`'${request.fillChain}' is not a chain this filler is configured for`) } for (const [name, value] of [ - ["price", request.price], - ["size", request.size], + ["amountIn", request.amountIn], + ["amountOut", request.amountOut], ] as const) { if (!/^[0-9]+$/.test(value ?? "") || BigInt(value) <= 0n) { throw new LimitOrderValidationError(`${name} must be a positive integer at 1e18, as a decimal string`) @@ -277,13 +303,12 @@ export class LimitOrderService { ) } - // The output is what the operator pays out, so that is the side the dust - // floor applies to: a bid pays the quote, an ask pays the base. - const outputSymbol = request.side === "BID" ? book.quote : book.base - const floor = limits.serverInfo.minOrderSizes.find((entry) => entry.symbol === outputSymbol) - if (floor && BigInt(request.size) < BigInt(floor.size)) { + // The dust floor applies to what the operator pays out, which is the side + // the orderbook advertises depth on. + const floor = limits.serverInfo.minOrderSizes.find((entry) => entry.symbol === request.tokenOut) + if (floor && BigInt(request.amountOut) < BigInt(floor.size)) { throw new LimitOrderValidationError( - `size is below the orderbook's dust floor for ${outputSymbol} (${floor.size} at 1e18)`, + `amountOut is below the orderbook's dust floor for ${request.tokenOut} (${floor.size} at 1e18)`, ) } diff --git a/sdk/packages/simplex/src/tests/orderbook/amounts.test.ts b/sdk/packages/simplex/src/tests/orderbook/amounts.test.ts index 7c89c35293..a138ba26ef 100644 --- a/sdk/packages/simplex/src/tests/orderbook/amounts.test.ts +++ b/sdk/packages/simplex/src/tests/orderbook/amounts.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest" -import { ORDERBOOK_SCALE, signedAmounts, toRaw } from "@/orderbook/amounts" +import { ORDERBOOK_SCALE, rateFrom, signedAmounts, toRaw } from "@/orderbook/amounts" /** 1,500 quote per 1 base, the shape a USDC/cNGN book reads at. */ const PRICE = 1500n * ORDERBOOK_SCALE @@ -73,3 +73,54 @@ describe("signedAmounts", () => { ) }) }) + +describe("rateFrom", () => { + const book = { base: "USDC", quote: "CNGN" } + + it("derives the rate and the side from the two amounts the operator gave", () => { + // 10,000 USDC in for 139,000,000 cNGN out: base in, quote out, so a bid. + const { side, price } = rateFrom({ + ...book, + tokenIn: "USDC", + amountIn: 10_000n * ONE, + amountOut: 139_000_000n * ONE, + }) + expect(side).toBe("BID") + expect(price).toBe(13_900n * ONE) + }) + + it("reads the other direction on the same book as an ask", () => { + // 139,000,000 cNGN in for 10,000 USDC out: quote in, base out. + const { side, price } = rateFrom({ + ...book, + tokenIn: "CNGN", + amountIn: 139_000_000n * ONE, + amountOut: 10_000n * ONE, + }) + expect(side).toBe("ASK") + expect(price).toBe(13_900n * ONE) + }) + + it("rounds a bid's rate down, so it never pays away more quote than was offered", () => { + // 3 quote for 7 base does not divide; the operator offered 3, not more. + const { price } = rateFrom({ ...book, tokenIn: "USDC", amountIn: 7n, amountOut: 3n }) + expect(price).toBe((3n * ONE) / 7n) + expect(price * 7n <= 3n * ONE).toBe(true) + }) + + it("rounds an ask's rate up, so it never takes in less quote than was asked for", () => { + const { price } = rateFrom({ ...book, tokenIn: "CNGN", amountIn: 3n, amountOut: 7n }) + expect(price * 7n >= 3n * ONE).toBe(true) + }) + + it("refuses a symbol that is neither side of the book", () => { + expect(() => rateFrom({ ...book, tokenIn: "EURC", amountIn: ONE, amountOut: ONE })).toThrow( + /neither side of the USDC\/CNGN book/, + ) + }) + + it("refuses a zero amount rather than dividing by it", () => { + expect(() => rateFrom({ ...book, tokenIn: "USDC", amountIn: 0n, amountOut: ONE })).toThrow(/greater than zero/) + expect(() => rateFrom({ ...book, tokenIn: "USDC", amountIn: ONE, amountOut: 0n })).toThrow(/greater than zero/) + }) +}) diff --git a/sdk/packages/simplex/src/tests/orderbook/limit-orders.test.ts b/sdk/packages/simplex/src/tests/orderbook/limit-orders.test.ts index 5933644628..485f27e199 100644 --- a/sdk/packages/simplex/src/tests/orderbook/limit-orders.test.ts +++ b/sdk/packages/simplex/src/tests/orderbook/limit-orders.test.ts @@ -87,12 +87,13 @@ function makeService(client: ReturnType, store = new MemoryDa return { service, store } } +/** Take in 1,000 USDC, pay out 1,500,000 cNGN: a USDC to cNGN order at 1,500. */ const REQUEST: CreateLimitOrderRequest = { - book: "USDC/CNGN", - side: "BID", fillChain: CHAIN, - price: (1500n * ONE).toString(), - size: (1_500_000n * ONE).toString(), + tokenIn: "USDC", + amountIn: (1000n * ONE).toString(), + tokenOut: "CNGN", + amountOut: (1_500_000n * ONE).toString(), acceptedSources: ["EVM-1"], } @@ -104,7 +105,9 @@ describe("LimitOrderService.create", () => { expect(result.kind).toBe("accepted") expect(order.status).toBe("open") expect(order.commitment).toBe("0xabc") - // The orderbook shades a posting by the protocol fee, so both prices are kept. + // The rate follows from the two amounts: 1,500,000 cNGN for 1,000 USDC. + // The orderbook shades a posting by the protocol fee, so both are kept. + expect(order.side).toBe("BID") expect(order.price).toBe((1500n * ONE).toString()) expect(order.bookPrice).toBe((1490n * ONE).toString()) expect(order.remaining).toBe(order.size) @@ -168,25 +171,29 @@ describe("LimitOrderService.create validation", () => { await rejects({ acceptedSources: ["EVM-1", "EVM-1"] }, /must not repeat/) }) - it("refuses a size under the output token's dust floor", async () => { - await rejects({ size: (999n * ONE).toString() }, /dust floor for CNGN/) + it("refuses an amountOut under the paid token's dust floor", async () => { + await rejects({ amountOut: (999n * ONE).toString() }, /dust floor for CNGN/) }) it("refuses a ttl under the orderbook's minimum", async () => { await rejects({ ttlSecs: 60 }, /at least the orderbook's minimum of 900/) }) - it("refuses an unknown book, naming the ones on offer", async () => { - await rejects({ book: "USDC/EURC" }, /Unknown book 'USDC\/EURC'.*USDC\/CNGN/) + it("refuses a pair no book trades, naming the ones on offer", async () => { + await rejects({ tokenOut: "EURC" }, /No book trades USDC against EURC.*USDC\/CNGN/) + }) + + it("refuses an order that takes in and pays out the same symbol", async () => { + await rejects({ tokenOut: "USDC" }, /must be different symbols/) }) it("refuses a chain this filler does not run", async () => { await rejects({ fillChain: "EVM-1" }, /not a chain this filler is configured for/) }) - it("refuses a price or size that is not a positive 1e18 integer", async () => { - await rejects({ price: "0" }, /price must be a positive integer/) - await rejects({ size: "1.5" }, /size must be a positive integer/) + it("refuses an amount that is not a positive 1e18 integer", async () => { + await rejects({ amountIn: "0" }, /amountIn must be a positive integer/) + await rejects({ amountOut: "1.5" }, /amountOut must be a positive integer/) }) }) From 5806db8c8a77b9440eded5d478835f8ed28f4cef Mon Sep 17 00:00:00 2001 From: dharjeezy Date: Wed, 16 Sep 2026 14:13:19 +0100 Subject: [PATCH 03/12] Keep a limit order alive when the orderbook could not decide it --- ...review-fixes-for-posting-and-cancelling.md | 31 +++++ sdk/packages/simplex/src/orderbook/client.ts | 19 +++ .../simplex/src/orderbook/limit-orders.ts | 108 ++++++++++++++---- sdk/packages/simplex/src/orderbook/types.ts | 11 ++ .../src/tests/orderbook/limit-orders.test.ts | 56 ++++++++- 5 files changed, 203 insertions(+), 22 deletions(-) create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-09-16-review-fixes-for-posting-and-cancelling.md diff --git a/sdk/packages/simplex/docs/ai/changelog/2026-09-16-review-fixes-for-posting-and-cancelling.md b/sdk/packages/simplex/docs/ai/changelog/2026-09-16-review-fixes-for-posting-and-cancelling.md new file mode 100644 index 0000000000..8776251720 --- /dev/null +++ b/sdk/packages/simplex/docs/ai/changelog/2026-09-16-review-fixes-for-posting-and-cancelling.md @@ -0,0 +1,31 @@ +# 2026-09-16 — Review fixes for posting and cancelling + +## A failure is not a refusal + +`post` marked an order `rejected` whichever way the orderbook answered, and `rejected` is terminal: +nothing looks at that row again. One `TIMEOUT`, one `DATABASE_UNAVAILABLE` or one dropped connection +during a create and the operator's order was dead with nothing retrying it. A retryable failure now +leaves the row `open` with no commitment and the reason in `lastError`, which is the shape +reconciliation already knows how to post again. A refusal still retires the order. + +## A cancel that got no answer says so + +`signAndCancel` turned an unreachable orderbook into `UNKNOWN_ORDER`, which is the orderbook's +considered answer that the entry is already gone. `cancel` reads that as "already gone" and clears +the commitment, so a `cancelOrder` that merely timed out left a live entry nothing here owned. +`CancelOrderResult` has a `failed` variant now, and a cancel that fails keeps the commitment on the +row for reconciliation to find. + +## `ORDER_EXISTS` is not `REPLAYED` + +`REPLAYED` is an op hash the orderbook remembers for an order that is gone, and a new nonce is the +only way past it. `ORDER_EXISTS` is a live entry sitting at the commitment we just tried, so bumping +the nonce puts a second entry behind one liability and records only the second. The client can read +`order(solver, commitment)` now, and a live entry that is ours is taken as the posting it already is. + +## The declared chains are checked before posting + +`serverInfo.chains` lists every chain the orderbook serves. A fill chain or accepted source it does +not serve is refused on the way in, naming the ones it does, rather than coming back as +`UNSUPPORTED_SOURCE_CHAIN` against a row already stored. It answers half of that code: whether the +input symbol is registered on a chain is the server's own config and still cannot be checked here. diff --git a/sdk/packages/simplex/src/orderbook/client.ts b/sdk/packages/simplex/src/orderbook/client.ts index a9c46dd1a4..1a8b30faa7 100644 --- a/sdk/packages/simplex/src/orderbook/client.ts +++ b/sdk/packages/simplex/src/orderbook/client.ts @@ -17,6 +17,7 @@ const LIMITS_QUERY = ` signatureSkewSecs maxBatchSize minOrderSizes { symbol size } + chains eip712DomainName eip712DomainVersion } @@ -38,6 +39,12 @@ const SUBMIT_ORDER_MUTATION = ` } ` +const ORDER_QUERY = ` + query OrderAt($solver: Address!, $commitment: Bytes!) { + order(solver: $solver, commitment: $commitment) { ${POSTED_ORDER_FIELDS} } + } +` + const CANCEL_ORDER_MUTATION = ` mutation CancelOrder($solver: Address!, $commitment: Bytes!, $timestamp: Int!, $signature: Bytes!) { cancelOrder(solver: $solver, commitment: $commitment, timestamp: $timestamp, signature: $signature) { @@ -101,6 +108,18 @@ export class OrderbookClient { } } + /** + * The entry this solver holds at `commitment`, or null when it holds none. + * + * What tells `ORDER_EXISTS` from `REPLAYED`: the first means a live entry is + * already sitting there, and posting again on a new nonce would put a second + * one behind the same liability. + */ + async orderAt(solver: HexString, commitment: HexString): Promise { + const { order } = await this.request<{ order: PostedOrder | null }>(ORDER_QUERY, { solver, commitment }) + return order + } + async cancelOrder(params: { solver: HexString commitment: HexString diff --git a/sdk/packages/simplex/src/orderbook/limit-orders.ts b/sdk/packages/simplex/src/orderbook/limit-orders.ts index 4a7e31edc5..be3980c211 100644 --- a/sdk/packages/simplex/src/orderbook/limit-orders.ts +++ b/sdk/packages/simplex/src/orderbook/limit-orders.ts @@ -9,7 +9,7 @@ import { defaultLoggerContext, type Logger, type LoggerContext } from "@/service import type { Signer } from "@/services/wallet" import { rateFrom, signedAmounts } from "./amounts" import { OrderbookClient, OrderbookRequestError } from "./client" -import type { Book, CancelOrderResult, OrderbookLimits, SubmitOrderResult } from "./types" +import type { Book, CancelOrderResult, OrderbookLimits, PostedOrder, SubmitOrderResult } from "./types" /** How long a read of `serverInfo` and `books` is reused before being refreshed. */ const LIMITS_TTL_MS = 5 * 60 * 1000 @@ -167,7 +167,7 @@ export class LimitOrderService { } const result = await this.withdraw(existing.commitment as HexString) - if (result.kind === "cancelled" || result.code === "UNKNOWN_ORDER") { + if (result.kind === "cancelled" || (result.kind === "rejected" && result.code === "UNKNOWN_ORDER")) { // UNKNOWN_ORDER means the entry is already gone, whether it expired, was // swept, or the orderbook deleted it over balance or delegation. return { @@ -183,8 +183,11 @@ export class LimitOrderService { } } - const message = `${result.code}: ${result.message}` - this.logger.error({ id, err: message }, "Orderbook refused to cancel the limit order") + // The commitment stays on the row. A refusal leaves the entry live, and a + // request that never got an answer may have left it live: either way + // something still owns it, and clearing it here is how an entry is orphaned. + const message = result.kind === "rejected" ? `${result.code}: ${result.message}` : result.message + this.logger.error({ id, err: message }, "Could not clear the limit order's entry on the orderbook") return { order: (await this.store.setStatus(id, "cancelled", message))!, result } } @@ -197,7 +200,7 @@ export class LimitOrderService { */ private async withdraw(commitment: HexString): Promise { const first = await this.signAndCancel(commitment, nowSecs()) - if (first.kind === "cancelled") return first + if (first.kind !== "rejected") return first if (first.code !== "SIGNATURE_EXPIRED" && first.code !== "SIGNATURE_REUSED") return first if (first.code === "SIGNATURE_EXPIRED") { @@ -217,9 +220,9 @@ export class LimitOrderService { try { return await this.client.cancelOrder({ solver: this.signer.address, commitment, timestamp, signature }) } catch (err) { - if (err instanceof OrderbookRequestError) { - return { kind: "rejected", code: "UNKNOWN_ORDER", message: err.message } - } + // Never `UNKNOWN_ORDER`: that is the orderbook's considered answer that the + // entry is gone, and this is the request not getting one at all. + if (err instanceof OrderbookRequestError) return { kind: "failed", message: err.message } throw err } } @@ -297,6 +300,20 @@ export class LimitOrderService { } } + // The orderbook lists the chains it serves, so a typo in a source chain is + // worth catching here rather than as an `UNSUPPORTED_SOURCE_CHAIN` against a + // row already stored. It only answers half the question: whether the input + // symbol is registered on that chain is the server's own config. + const served = limits.serverInfo.chains ?? [] + if (served.length > 0) { + const unknown = [request.fillChain, ...sources].filter((chain) => !served.includes(chain)) + if (unknown.length > 0) { + throw new LimitOrderValidationError( + `The orderbook does not serve ${unknown.join(", ")}. It serves: ${served.join(", ")}`, + ) + } + } + if (ttlSecs < limits.serverInfo.minOrderTtlSecs) { throw new LimitOrderValidationError( `ttlSecs must be at least the orderbook's minimum of ${limits.serverInfo.minOrderTtlSecs}; got ${ttlSecs}`, @@ -343,41 +360,93 @@ export class LimitOrderService { } const message = `${result.code}: ${result.message}` + + // A failure is not a refusal. The orderbook could not decide the op, whether + // its database was away or the request never landed, so the order stays open + // with the reason on it and something posts it again later. Marking it + // `rejected` would retire an order the operator still wants over one timeout. + if (result.kind === "failed" && result.retryable) { + this.logger.warn({ id: order.id, err: message }, "Could not post the limit order; leaving it to be posted again") + return { + order: (await this.store.setPosting(order.id, { + commitment: null, + bookExpiresAt: null, + bookPrice: null, + orderNonce: orderNonce.toString(), + status: "open", + lastError: message, + }))!, + result, + } + } + this.logger.error({ id: order.id, err: message }, "Orderbook refused the limit order") return { order: (await this.store.setStatus(order.id, "rejected", message))!, result } } /** * Submits the posting, answering a nonce the orderbook has already seen with a - * fresh one. `REPLAYED` and `ORDER_EXISTS` both mean the op hashed to something - * it remembers, and it remembers every hash forever, so bumping the nonce is - * the only way past. Anything else is returned as it came. + * fresh one. + * + * The two refusals that say so are not the same thing. `REPLAYED` is an op hash + * it remembers for an order that is gone, and the nonce is the only way past. + * `ORDER_EXISTS` is a live entry sitting at that commitment: if it is ours, the + * posting has already happened and is taken as accepted, because posting again + * on a new nonce would put a second entry behind the same liability and record + * only the second one. */ private async submit(order: LimitOrder): Promise<{ result: SubmitOrderResult; orderNonce: bigint }> { const orderNonce = BigInt(order.orderNonce) - const first = await this.buildAndSubmit(order, orderNonce) + const attempt = await this.buildAndSubmit(order, orderNonce) + const first = attempt.result if (first.kind !== "rejected" || (first.code !== "REPLAYED" && first.code !== "ORDER_EXISTS")) { return { result: first, orderNonce } } + if (first.code === "ORDER_EXISTS" && attempt.commitment) { + const live = await this.entryAt(order, attempt.commitment) + if (live) { + this.logger.info({ id: order.id, commitment: live.commitment }, "This posting is already on the orderbook") + return { result: { kind: "unchanged", order: live }, orderNonce } + } + } + this.logger.warn({ id: order.id, code: first.code }, "Orderbook has seen this op before; reposting on a new nonce") const retried = orderNonce + 1n - return { result: await this.buildAndSubmit(order, retried), orderNonce: retried } + return { result: (await this.buildAndSubmit(order, retried)).result, orderNonce: retried } + } + + /** + * The entry the orderbook holds at `commitment`, if it holds one. A lookup that + * fails answers null, which sends the caller down the nonce-bump path it would + * have taken anyway. + */ + private async entryAt(order: LimitOrder, commitment: HexString): Promise { + try { + return await this.client.orderAt(this.signer.address, commitment) + } catch (err) { + this.logger.warn({ id: order.id, err }, "Could not read the entry the orderbook says exists") + return null + } } - private async buildAndSubmit(order: LimitOrder, orderNonce: bigint): Promise { - const userOp = await this.buildUserOp(order, orderNonce) + /** The orderbook's answer, and the commitment the op we sent hashes to. */ + private async buildAndSubmit( + order: LimitOrder, + orderNonce: bigint, + ): Promise<{ result: SubmitOrderResult; commitment?: HexString }> { + const { commitment, userOp } = await this.buildUserOp(order, orderNonce) try { - return await this.client.submitOrder(userOp) + return { result: await this.client.submitOrder(userOp), commitment } } catch (err) { if (err instanceof OrderbookRequestError) { - return { kind: "failed", code: "REQUEST_FAILED", message: err.message, retryable: true } + return { result: { kind: "failed", code: "REQUEST_FAILED", message: err.message, retryable: true }, commitment } } throw err } } - private async buildUserOp(order: LimitOrder, orderNonce: bigint): Promise { + private async buildUserOp(order: LimitOrder, orderNonce: bigint): Promise<{ commitment: HexString; userOp: HexString }> { const baseToken = this.assetRegistry.getAddress(order.base, order.fillChain)! const quoteToken = this.assetRegistry.getAddress(order.quote, order.fillChain)! const [baseDecimals, quoteDecimals] = await Promise.all([ @@ -400,7 +469,7 @@ export class LimitOrderService { throw new LimitOrderValidationError(`No EntryPoint is configured for ${order.fillChain}`) } - const { userOp } = await this.contractService.prepareLimitOrderUserOp({ + return this.contractService.prepareLimitOrderUserOp({ fillChain: order.fillChain, entryPointAddress, inputToken, @@ -411,7 +480,6 @@ export class LimitOrderService { ttlSecs: order.ttlSecs, acceptedSourceChains: order.acceptedSources, }) - return userOp } } diff --git a/sdk/packages/simplex/src/orderbook/types.ts b/sdk/packages/simplex/src/orderbook/types.ts index 6be225d896..3361e17b60 100644 --- a/sdk/packages/simplex/src/orderbook/types.ts +++ b/sdk/packages/simplex/src/orderbook/types.ts @@ -34,6 +34,11 @@ export interface ServerInfo { signatureSkewSecs: number maxBatchSize: number minOrderSizes: TokenMinSize[] + /** + * Every chain the orderbook serves, by state machine id: the chains orders + * fill on, and the source chains they may declare. + */ + chains: string[] eip712DomainName: string eip712DomainVersion: string } @@ -96,3 +101,9 @@ export type MessageRejectionCode = export type CancelOrderResult = | { kind: "cancelled"; commitment: HexString } | { kind: "rejected"; code: MessageRejectionCode; message: string } + /** + * The request never got an answer. Distinct from `UNKNOWN_ORDER`, which is the + * orderbook saying the entry is already gone: here the entry may well still be + * live, and a caller that treats the two alike orphans it. + */ + | { kind: "failed"; message: string } diff --git a/sdk/packages/simplex/src/tests/orderbook/limit-orders.test.ts b/sdk/packages/simplex/src/tests/orderbook/limit-orders.test.ts index 485f27e199..615280a84d 100644 --- a/sdk/packages/simplex/src/tests/orderbook/limit-orders.test.ts +++ b/sdk/packages/simplex/src/tests/orderbook/limit-orders.test.ts @@ -19,6 +19,7 @@ const LIMITS: OrderbookLimits = { signatureSkewSecs: 30, maxBatchSize: 20, minOrderSizes: [{ symbol: "CNGN", size: (1000n * ONE).toString() }], + chains: [CHAIN, "EVM-1"], eip712DomainName: "HyperFX Orderbook", eip712DomainVersion: "1", }, @@ -44,7 +45,12 @@ function fakeClient(results: SubmitOrderResult[], cancels: CancelOrderResult[] = const submitted: HexString[] = [] return { submitted, + /** Entries the orderbook already holds, by commitment. */ + entries: [] as PostedOrder[], limits: async () => LIMITS, + orderAt: async function (_solver: HexString, commitment: HexString) { + return this.entries.find((entry) => entry.commitment === commitment) ?? null + }, submitOrder: async (userOp: HexString) => { submitted.push(userOp) return results.shift() ?? { kind: "accepted" as const, order: postedOrder(), surfaced: true } @@ -143,19 +149,59 @@ describe("LimitOrderService.create", () => { expect(client.submitted).toHaveLength(1) }) - it("reports an unreachable orderbook as retryable and leaves the order rejected", async () => { + it("leaves an order the orderbook could not decide open, to be posted again", async () => { + // A refusal retires the order; a failure must not. One timeout on the way in + // would otherwise kill an order the operator still wants, with nothing left + // looking at the row. const client = fakeClient([]) client.submitOrder = async () => { throw new OrderbookRequestError("connect ECONNREFUSED") } - const { service } = makeService(client) + const { service, store } = makeService(client) const { order, result } = await service.create(REQUEST) expect(result).toMatchObject({ kind: "failed", retryable: true }) + expect(order.status).toBe("open") + expect(order.commitment).toBeNull() + expect(order.lastError).toMatch(/REQUEST_FAILED/) + expect((await store.get(order.id))?.status).toBe("open") + }) + + it("retires an order the orderbook actually refused", async () => { + const client = fakeClient([{ kind: "rejected", code: "BAD_SIGNATURE", message: "bad" }]) + const { service } = makeService(client) + const { order } = await service.create(REQUEST) expect(order.status).toBe("rejected") }) }) +describe("an op the orderbook has already taken", () => { + it("treats a live entry at the same commitment as the posting it was", async () => { + // `ORDER_EXISTS` is a live entry sitting at that commitment, unlike + // `REPLAYED`. Posting again on a new nonce would leave two entries behind one + // liability and record only the second. + const client = fakeClient([{ kind: "rejected", code: "ORDER_EXISTS", message: "already here" }]) + client.entries = [postedOrder({ commitment: "0xabc" })] + const { service } = makeService(client) + + const { order, result } = await service.create(REQUEST) + expect(result.kind).toBe("unchanged") + expect(order.status).toBe("open") + expect(order.commitment).toBe("0xabc") + // One submission, not two. + expect(client.submitted).toEqual(["0x00"]) + }) + + it("bumps the nonce when the orderbook holds no such entry", async () => { + const client = fakeClient([{ kind: "rejected", code: "REPLAYED", message: "seen" }]) + const { service } = makeService(client) + + const { order } = await service.create(REQUEST) + expect(client.submitted).toEqual(["0x00", "0x01"]) + expect(order.orderNonce).toBe("1") + }) +}) + describe("LimitOrderService.create validation", () => { const rejects = async (patch: Partial, match: RegExp) => { const { service } = makeService(fakeClient([])) @@ -191,6 +237,12 @@ describe("LimitOrderService.create validation", () => { await rejects({ fillChain: "EVM-1" }, /not a chain this filler is configured for/) }) + it("refuses a source chain the orderbook does not serve", async () => { + // Half of what UNSUPPORTED_SOURCE_CHAIN tests, and the half an operator gets + // wrong by typo, so it is worth catching before a row is stored. + await rejects({ acceptedSources: ["EVM-1", "EVM-42161"] }, /does not serve EVM-42161/) + }) + it("refuses an amount that is not a positive 1e18 integer", async () => { await rejects({ amountIn: "0" }, /amountIn must be a positive integer/) await rejects({ amountOut: "1.5" }, /amountOut must be a positive integer/) From 1f0e92f387ca6a034d84443f9840aff7478aed50 Mon Sep 17 00:00:00 2001 From: dharjeezy Date: Wed, 16 Sep 2026 14:44:05 +0100 Subject: [PATCH 04/12] Record why the protocol fee needs no adjustment in the limit order path --- ...out-of-an-order-by-the-time-we-price-it.md | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 sdk/packages/simplex/docs/ai/decisions/2026-09-16-the-protocol-fee-is-already-out-of-an-order-by-the-time-we-price-it.md diff --git a/sdk/packages/simplex/docs/ai/decisions/2026-09-16-the-protocol-fee-is-already-out-of-an-order-by-the-time-we-price-it.md b/sdk/packages/simplex/docs/ai/decisions/2026-09-16-the-protocol-fee-is-already-out-of-an-order-by-the-time-we-price-it.md new file mode 100644 index 0000000000..b018a0a08c --- /dev/null +++ b/sdk/packages/simplex/docs/ai/decisions/2026-09-16-the-protocol-fee-is-already-out-of-an-order-by-the-time-we-price-it.md @@ -0,0 +1,32 @@ +# 2026-09-16 — The protocol fee is already out of an order by the time we price it + +Decided: the matcher prices against `order.inputs[0].amount` as it arrives, with no fee adjustment +anywhere in the limit order path, and `bookPrice` is stored next to `price` rather than reconciled +with it. + +`IntentGatewayV2.placeOrder` takes `protocolFeeBps` off the **input**: it escrows `X(1-f)` under a +commitment computed over the reduced inputs, and emits `inputs: reducedInputs`. So the order our +scanner reconstructs is already net of the fee, which is what `inputNet` in `fx.ts` is named for. + +The orderbook shades the other side. `crates/core/src/haircut.rs` applies the fee to what the solver +delivers and quotes every price on that, so `bookPrice = amountOut(1-f) / amountIn`, where `price`, +the rate the op is signed at, is `amountOut / amountIn`. + +Both are right, for different readers: + +- the solver receives `X(1-f)` and pays `X(1-f) · price`, so it gets exactly the rate it signed and + the fee never touches it; +- the swapper pays `X` and receives `X(1-f) · price`, so their rate is `price(1-f)`, which is + `bookPrice`. + +They are two sides of one trade rather than two estimates of one number, which is why both are kept +on the row and neither is derived from the other at read time. + +What makes this worth recording is the failure it hides. If the gateway ever emitted gross inputs, +`inputNet` would quietly become gross, every payout would be `1/(1-f)` too large, and nothing here +would notice: the matcher takes the event at its word, and the golden vectors fix the op rather than +the event. The invariant to hold onto is that `OrderPlaced` carries reduced inputs. + +Rejected: subtracting the fee ourselves before pricing. It would double-count today, and it would put +a second copy of the gateway's fee schedule in the filler, which is the thing the reduced event +exists to avoid. From 0c10643397137fe28da0d85a11e269ad8fa3d3ed Mon Sep 17 00:00:00 2001 From: dharjeezy Date: Fri, 18 Sep 2026 14:23:37 +0100 Subject: [PATCH 05/12] Require the orderbook and give a limit order one clock --- ...-orders-posted-to-the-hyperfx-orderbook.md | 13 ++++--- .../simplex/filler-config-example.toml | 16 ++++----- .../simplex/src/cli/init/emit-toml.ts | 14 +++++++- .../simplex/src/cli/init/migrate-legacy.ts | 6 ++++ sdk/packages/simplex/src/config/defaults.ts | 3 ++ .../simplex/src/config/filler-toml.ts | 36 ++++++++----------- sdk/packages/simplex/src/core/boot.ts | 6 ++-- .../simplex/src/orderbook/limit-orders.ts | 8 +++-- .../simplex/src/tests/cli/emit-toml.test.ts | 3 ++ .../tests/cli/filler-toml-validate.test.ts | 28 ++++++--------- .../tests/cli/update-run-preservation.test.ts | 1 + .../src/tests/core/boot-signer.test.ts | 7 +++- .../services/block-scan-interval.test.ts | 1 + .../simplex/src/tests/setup-api.test.ts | 1 + 14 files changed, 84 insertions(+), 59 deletions(-) diff --git a/sdk/packages/simplex/docs/ai/changelog/2026-09-15-limit-orders-posted-to-the-hyperfx-orderbook.md b/sdk/packages/simplex/docs/ai/changelog/2026-09-15-limit-orders-posted-to-the-hyperfx-orderbook.md index 5fb65b3a47..fb6b260858 100644 --- a/sdk/packages/simplex/docs/ai/changelog/2026-09-15-limit-orders-posted-to-the-hyperfx-orderbook.md +++ b/sdk/packages/simplex/docs/ai/changelog/2026-09-15-limit-orders-posted-to-the-hyperfx-orderbook.md @@ -19,7 +19,7 @@ their own chains. ``` GET /api/limit-orders?status=&chain=&book= GET /api/limit-orders/:id -POST /api/limit-orders { fillChain, tokenIn, amountIn, tokenOut, amountOut, acceptedSources, ttlSecs?, expiresAt? } +POST /api/limit-orders { fillChain, tokenIn, amountIn, tokenOut, amountOut, acceptedSources, ttlSecs? } DELETE /api/limit-orders/:id ``` @@ -53,15 +53,20 @@ has accepted, so a fresh nonce is the only way past. ```toml [orderbook] -enabled = true url = "https://orderbook.hyperbridge.network/graphql" defaultTtlSecs = 900 -renewMarginSecs = 120 reconcileIntervalSecs = 300 requestTimeoutMs = 10000 ``` -Off unless enabled, and the limit orders themselves are not configured here. They are inventory the +The section is required. Simplex prices every fill from the operator's limit orders and those +live on the orderbook, so there is no configuration without one. + +`ttlSecs` is the only clock. It is the TTL written into the posting and the life of the order +itself: `expiresAt` is derived from it when the order is created, and nothing renews it. When it +runs out the posting lapses and the order is done. + +The limit orders themselves are not configured here. They are inventory the operator opens and closes while the filler runs, so they live in `bids.db` and are created over the API. diff --git a/sdk/packages/simplex/filler-config-example.toml b/sdk/packages/simplex/filler-config-example.toml index ead77ef4e5..7a04b3b686 100644 --- a/sdk/packages/simplex/filler-config-example.toml +++ b/sdk/packages/simplex/filler-config-example.toml @@ -292,15 +292,13 @@ points = [ # ] -# The HyperFX orderbook simplex advertises its limit orders on. Off by default; -# with no orderbook the filler posts nothing. The limit orders themselves are not -# configured here — they are created over the API and live in bids.db, because -# they are inventory you open and close while the filler runs. -# [orderbook] -# enabled = true -# url = "https://orderbook.hyperbridge.network/graphql" -# defaultTtlSecs = 900 # TTL per posting; the orderbook's own floor is 900 -# renewMarginSecs = 120 # repost this long before a posting expires +# The HyperFX orderbook simplex advertises its limit orders on, and prices every +# fill from. Required: simplex has no prices of its own. The limit orders +# themselves are not configured here — they are created over the API and live in +# bids.db, because they are inventory you open and close while the filler runs. +[orderbook] +url = "https://orderbook.hyperbridge.network/graphql" +# defaultTtlSecs = 900 # how long an order lives, and its posting's TTL; the floor is 900 # reconcileIntervalSecs = 300 # how often to check the orderbook still matches # requestTimeoutMs = 10000 diff --git a/sdk/packages/simplex/src/cli/init/emit-toml.ts b/sdk/packages/simplex/src/cli/init/emit-toml.ts index 046ddd1894..724124a08e 100644 --- a/sdk/packages/simplex/src/cli/init/emit-toml.ts +++ b/sdk/packages/simplex/src/cli/init/emit-toml.ts @@ -1,4 +1,4 @@ -import { DEFAULT_MAX_CONCURRENT_ORDERS } from "@/config/defaults" +import { DEFAULT_MAX_CONCURRENT_ORDERS, DEFAULT_ORDERBOOK_URL } from "@/config/defaults" import { chmodSync, renameSync, unlinkSync, writeFileSync } from "node:fs" import { dirname, join, basename } from "node:path" import { randomBytes } from "node:crypto" @@ -121,6 +121,18 @@ export function emitFillerToml(config: FillerConfigFile, options: EmitOptions = push() } + // Simplex prices every fill from the operator's limit orders, and those live on + // the orderbook, so the section is written whether or not an order exists yet. + push("# The orderbook simplex posts the operator's limit orders to.") + push("[orderbook]") + push(kv("url", config.orderbook?.url ?? DEFAULT_ORDERBOOK_URL)) + if (config.orderbook?.defaultTtlSecs !== undefined) push(kv("defaultTtlSecs", config.orderbook.defaultTtlSecs)) + if (config.orderbook?.reconcileIntervalSecs !== undefined) { + push(kv("reconcileIntervalSecs", config.orderbook.reconcileIntervalSecs)) + } + if (config.orderbook?.requestTimeoutMs !== undefined) push(kv("requestTimeoutMs", config.orderbook.requestTimeoutMs)) + push() + if (config.rebalancing) { push("# Rebalancing: triggers when a balance falls to (1 - triggerPercentage) * baseBalance.") push("[rebalancing]") diff --git a/sdk/packages/simplex/src/cli/init/migrate-legacy.ts b/sdk/packages/simplex/src/cli/init/migrate-legacy.ts index 63e47d20fc..873c81b222 100644 --- a/sdk/packages/simplex/src/cli/init/migrate-legacy.ts +++ b/sdk/packages/simplex/src/cli/init/migrate-legacy.ts @@ -1,3 +1,4 @@ +import { DEFAULT_ORDERBOOK_URL } from "@/config/defaults" import { ChainConfigService, type HexString } from "@hyperbridge/sdk" import { AssetRegistry, normalizeSymbol, registrySymbols, USD_STABLE_SYMBOLS } from "@/config/asset-registry" import type { PairConfig } from "@/config/pairs" @@ -113,6 +114,11 @@ export function migrateLegacyConfig(config: FillerTomlConfig): string[] { delete legacy.strategies config.pairs = pairs + // A legacy config predates the orderbook, and simplex has no prices without one. + if (!config.orderbook) { + config.orderbook = { url: DEFAULT_ORDERBOOK_URL } + notes.push(`Added [orderbook] pointing at ${DEFAULT_ORDERBOOK_URL}; simplex prices fills from limit orders there.`) + } if (Object.keys(confirmationPolicies).length > 0) { config.confirmationPolicies = confirmationPolicies } diff --git a/sdk/packages/simplex/src/config/defaults.ts b/sdk/packages/simplex/src/config/defaults.ts index 6332389eeb..8ee2338ef2 100644 --- a/sdk/packages/simplex/src/config/defaults.ts +++ b/sdk/packages/simplex/src/config/defaults.ts @@ -3,3 +3,6 @@ export const DEFAULT_MAX_CONCURRENT_ORDERS = 5 /** How long an orderbook request waits before it is treated as unreachable. */ export const DEFAULT_ORDERBOOK_TIMEOUT_MS = 10_000 + +/** The orderbook simplex posts to when the config does not name another. */ +export const DEFAULT_ORDERBOOK_URL = "https://orderbook.hyperbridge.network/graphql" diff --git a/sdk/packages/simplex/src/config/filler-toml.ts b/sdk/packages/simplex/src/config/filler-toml.ts index 79452f2297..a029ea5c95 100644 --- a/sdk/packages/simplex/src/config/filler-toml.ts +++ b/sdk/packages/simplex/src/config/filler-toml.ts @@ -172,14 +172,14 @@ export interface FillerTomlConfig { * and closes while the filler runs rather than startup settings. */ export interface OrderbookConfig { - /** GraphQL endpoint. Required when `enabled`. */ - url?: string - /** Off unless set. A filler with no orderbook simply posts nothing. */ - enabled?: boolean - /** TTL written into each posting, in seconds. At least 900, which is the orderbook's floor. */ + /** GraphQL endpoint. */ + url: string + /** + * How long a limit order lives, in seconds, and the TTL written into its posting. + * At least 900, the orderbook's floor. The order and its posting expire together: + * there is one clock, and nothing renews it. + */ defaultTtlSecs?: number - /** How long before a posting expires to repost it, in seconds. */ - renewMarginSecs?: number /** How often to reconcile local limit orders against the orderbook, in seconds. */ reconcileIntervalSecs?: number requestTimeoutMs?: number @@ -257,18 +257,17 @@ export function validateVaultToml( } /** - * Checked at the gate rather than at first use: an orderbook that is enabled but - * misconfigured means every limit order the operator creates is refused, and a + * Checked at the gate rather than at first use: a misconfigured orderbook means + * every limit order the operator creates is refused, and a * TTL under the orderbook's own floor is refused one order at a time with a * `TTL_TOO_SHORT` nobody sees until they try. */ function validateOrderbookConfig(orderbook: OrderbookConfig): void { if (!orderbook.url) { - throw new Error("orderbook.url is required when orderbook.enabled is true") + throw new Error("orderbook.url is required") } const positiveSeconds: [keyof OrderbookConfig, number | undefined, number][] = [ ["defaultTtlSecs", orderbook.defaultTtlSecs, MIN_ORDER_TTL_SECONDS], - ["renewMarginSecs", orderbook.renewMarginSecs, 1], ["reconcileIntervalSecs", orderbook.reconcileIntervalSecs, 1], ["requestTimeoutMs", orderbook.requestTimeoutMs, 1], ] @@ -278,14 +277,6 @@ function validateOrderbookConfig(orderbook: OrderbookConfig): void { throw new Error(`orderbook.${name} must be an integer >= ${minimum}; got ${value}`) } } - if (orderbook.renewMarginSecs !== undefined) { - const ttl = orderbook.defaultTtlSecs ?? MIN_ORDER_TTL_SECONDS - if (orderbook.renewMarginSecs >= ttl) { - throw new Error( - `orderbook.renewMarginSecs (${orderbook.renewMarginSecs}) must be shorter than orderbook.defaultTtlSecs (${ttl}), or every posting is due for renewal the moment it lands`, - ) - } - } } export function validateConfig(config: FillerTomlConfig, cliWatchOnly = false): void { @@ -367,9 +358,12 @@ export function validateConfig(config: FillerTomlConfig, cliWatchOnly = false): validateVaultToml(config.vault.vaults) } - if (config.orderbook?.enabled) { - validateOrderbookConfig(config.orderbook) + // Simplex prices from the operator's limit orders and those live on the + // orderbook, so there is no configuration in which it is absent. + if (!config.orderbook) { + throw new Error("an [orderbook] section is required") } + validateOrderbookConfig(config.orderbook) // Asset registry and trading pairs — the entire trading configuration. if (config.assets) { diff --git a/sdk/packages/simplex/src/core/boot.ts b/sdk/packages/simplex/src/core/boot.ts index c568902fee..0e57b6fcd8 100644 --- a/sdk/packages/simplex/src/core/boot.ts +++ b/sdk/packages/simplex/src/core/boot.ts @@ -107,7 +107,7 @@ export interface FillerRuntime { loggers: LoggerContext /** Symbol-to-address resolution for the configured chains (send options, balance labels). */ assetRegistry: AssetRegistry - /** Creates and posts the operator's limit orders, absent unless `[orderbook]` is enabled. */ + /** Creates and posts the operator's limit orders. */ limitOrders?: LimitOrderService /** The live trading engine, absent when the config declared no pairs. */ engine?: FXFiller @@ -513,11 +513,11 @@ export async function bootFiller(config: FillerTomlConfig, options: BootOptions) // Limit orders are inventory the operator opens while the filler runs, so the // service exists as soon as an orderbook is configured, whether or not any // order has been created yet. - const limitOrderService = config.orderbook?.enabled + const limitOrderService = config.orderbook ? new LimitOrderService( options.data.limitOrders, new OrderbookClient( - config.orderbook.url!, + config.orderbook.url, config.orderbook.requestTimeoutMs ?? DEFAULT_ORDERBOOK_TIMEOUT_MS, options.loggers, ), diff --git a/sdk/packages/simplex/src/orderbook/limit-orders.ts b/sdk/packages/simplex/src/orderbook/limit-orders.ts index be3980c211..fc06c9b1ed 100644 --- a/sdk/packages/simplex/src/orderbook/limit-orders.ts +++ b/sdk/packages/simplex/src/orderbook/limit-orders.ts @@ -50,8 +50,12 @@ export interface CreateLimitOrderRequest { tokenOut: string amountOut: string acceptedSources: string[] + /** + * How long the order lives, in seconds. It is the TTL of the posting and the + * life of the order itself: one clock, derived into `expiresAt` on the row, and + * nothing renews it. When it runs out the posting lapses and the order is done. + */ ttlSecs?: number - expiresAt?: string | null } /** The stored limit order, and what the orderbook said about its posting. */ @@ -144,7 +148,7 @@ export class LimitOrderService { size: request.amountOut, acceptedSources: request.acceptedSources, ttlSecs, - expiresAt: request.expiresAt ?? null, + expiresAt: new Date(Date.now() + ttlSecs * 1000).toISOString(), } return this.post(await this.store.create(insert)) } diff --git a/sdk/packages/simplex/src/tests/cli/emit-toml.test.ts b/sdk/packages/simplex/src/tests/cli/emit-toml.test.ts index c7a6a360c8..45fda6c483 100644 --- a/sdk/packages/simplex/src/tests/cli/emit-toml.test.ts +++ b/sdk/packages/simplex/src/tests/cli/emit-toml.test.ts @@ -8,6 +8,7 @@ import { validateConfig, type FillerConfigFile } from "@/config/filler-toml" import { SignerType } from "@/services/wallet" const minimalSameAsset: FillerConfigFile = { + orderbook: { url: "https://orderbook.hyperbridge.network/graphql" }, simplex: { signer: { type: SignerType.PrivateKey, @@ -42,6 +43,7 @@ const minimalSameAsset: FillerConfigFile = { } const crossAssetWithCurves: FillerConfigFile = { + orderbook: { url: "https://orderbook.hyperbridge.network/graphql" }, simplex: { signer: { type: SignerType.Turnkey, @@ -94,6 +96,7 @@ const crossAssetWithCurves: FillerConfigFile = { // `side` requires pool pricing with no static curves, so this pair is curve-less // with its own price curves. const kitchenSink: FillerConfigFile = { + orderbook: { url: "https://orderbook.hyperbridge.network/graphql" }, simplex: { signer: { type: SignerType.MpcVault, diff --git a/sdk/packages/simplex/src/tests/cli/filler-toml-validate.test.ts b/sdk/packages/simplex/src/tests/cli/filler-toml-validate.test.ts index 14b53dfff7..13815cee55 100644 --- a/sdk/packages/simplex/src/tests/cli/filler-toml-validate.test.ts +++ b/sdk/packages/simplex/src/tests/cli/filler-toml-validate.test.ts @@ -11,6 +11,7 @@ import { import { SignerType } from "@/services/wallet" const minimalConfig = (): FillerTomlConfig => ({ + orderbook: { url: "https://orderbook.example/graphql" }, simplex: { maxConcurrentOrders: 5, queue: { maxRechecks: 10, recheckDelayMs: 30000 }, @@ -193,18 +194,17 @@ describe("validateConfig [orderbook]", () => { orderbook, }) - it("ignores the block entirely while it is disabled", () => { - expect(() => validateConfig(withOrderbook({ enabled: false, defaultTtlSecs: 1 }))).not.toThrow() + it("refuses a config with no orderbook at all", () => { + const { orderbook: _dropped, ...noOrderbook } = minimalConfig() + expect(() => validateConfig(noOrderbook as FillerTomlConfig)).toThrow(/an \[orderbook\] section is required/) }) - it("accepts a well-formed enabled block", () => { + it("accepts a well-formed block", () => { expect(() => validateConfig( withOrderbook({ - enabled: true, url: "https://orderbook.example/graphql", defaultTtlSecs: 1800, - renewMarginSecs: 120, reconcileIntervalSecs: 300, requestTimeoutMs: 10000, }), @@ -212,21 +212,13 @@ describe("validateConfig [orderbook]", () => { ).not.toThrow() }) - it("requires a url once enabled, rather than failing on the first posting", () => { - expect(() => validateConfig(withOrderbook({ enabled: true }))).toThrow(/orderbook.url is required/) + it("requires a url, rather than failing on the first posting", () => { + expect(() => validateConfig(withOrderbook({ url: "" }))).toThrow(/orderbook.url is required/) }) it("refuses a ttl below the orderbook's own floor", () => { - expect(() => - validateConfig(withOrderbook({ enabled: true, url: "https://example", defaultTtlSecs: 60 })), - ).toThrow(/defaultTtlSecs must be an integer >= 900/) - }) - - it("refuses a renewal margin that is not shorter than the ttl", () => { - expect(() => - validateConfig( - withOrderbook({ enabled: true, url: "https://example", defaultTtlSecs: 900, renewMarginSecs: 900 }), - ), - ).toThrow(/must be shorter than orderbook.defaultTtlSecs/) + expect(() => validateConfig(withOrderbook({ url: "https://example", defaultTtlSecs: 60 }))).toThrow( + /defaultTtlSecs must be an integer >= 900/, + ) }) }) diff --git a/sdk/packages/simplex/src/tests/cli/update-run-preservation.test.ts b/sdk/packages/simplex/src/tests/cli/update-run-preservation.test.ts index ba7740ad09..e0a501f00e 100644 --- a/sdk/packages/simplex/src/tests/cli/update-run-preservation.test.ts +++ b/sdk/packages/simplex/src/tests/cli/update-run-preservation.test.ts @@ -14,6 +14,7 @@ import { INIT_CHAINS } from "@/cli/init/chains" */ describe("CLI wizard update run", () => { const existing: FillerConfigFile = { + orderbook: { url: "https://orderbook.hyperbridge.network/graphql" }, simplex: { signer: { type: SignerType.PrivateKey, key: "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" }, maxConcurrentOrders: 7, diff --git a/sdk/packages/simplex/src/tests/core/boot-signer.test.ts b/sdk/packages/simplex/src/tests/core/boot-signer.test.ts index 1969136977..39facbe647 100644 --- a/sdk/packages/simplex/src/tests/core/boot-signer.test.ts +++ b/sdk/packages/simplex/src/tests/core/boot-signer.test.ts @@ -40,6 +40,7 @@ function config(overrides: Partial = {}): FillerTom { token0: "USDC", token1: "USDC", maxOrderSize: "1000", askPriceCurve: [{ amount: "0", price: "0.999" }] }, ], chains: [{ rpcUrls: [rpc.url], bundlerUrl: "https://bundler.example" }], + orderbook: { url: "https://orderbook.example/graphql" }, } } @@ -99,7 +100,11 @@ describe("signerless runtime is watch-only for good", () => { return { signerless: true, globalWatchOnly: false, - config: { simplex: { watchOnly: { [`EVM-${CHAIN_ID}`]: true } }, chains: [] }, + config: { + simplex: { watchOnly: { [`EVM-${CHAIN_ID}`]: true } }, + chains: [], + orderbook: { url: "https://orderbook.example/graphql" }, + }, configService: { getConfiguredChainIds: () => [1] }, intentFiller: { setWatchOnly: vi.fn(), getWatchOnly: () => ({ [CHAIN_ID]: true }) }, resolvedChains: [], diff --git a/sdk/packages/simplex/src/tests/services/block-scan-interval.test.ts b/sdk/packages/simplex/src/tests/services/block-scan-interval.test.ts index 39488c1b4d..b7324d63e3 100644 --- a/sdk/packages/simplex/src/tests/services/block-scan-interval.test.ts +++ b/sdk/packages/simplex/src/tests/services/block-scan-interval.test.ts @@ -20,6 +20,7 @@ function baseToml(blockScanIntervalSeconds?: number): FillerTomlConfig { blockScanIntervalSeconds, }, chains: [{ rpcUrls: ["https://mainnet.base.org"], bundlerUrl: "https://b" }], + orderbook: { url: "https://orderbook.example/graphql" }, } as FillerTomlConfig } diff --git a/sdk/packages/simplex/src/tests/setup-api.test.ts b/sdk/packages/simplex/src/tests/setup-api.test.ts index 6c27a0b07c..6fa69965cd 100644 --- a/sdk/packages/simplex/src/tests/setup-api.test.ts +++ b/sdk/packages/simplex/src/tests/setup-api.test.ts @@ -64,6 +64,7 @@ describe("setup API", () => { }, ], chains: [{ rpcUrls: [rpcUrl], bundlerUrl: "https://api.pimlico.io/v2/1/rpc?apikey=secretpimlicokey" }], + orderbook: { url: "https://orderbook.example/graphql" }, } } From 513431612d13f1b56e6ff6df96630aea5718ce6e Mon Sep 17 00:00:00 2001 From: dharjeezy Date: Fri, 18 Sep 2026 15:16:21 +0100 Subject: [PATCH 06/12] Take limit order amounts in whole tokens --- ...-orders-posted-to-the-hyperfx-orderbook.md | 5 ++ sdk/packages/simplex/src/orderbook/amounts.ts | 26 ++++++++++ .../simplex/src/orderbook/limit-orders.ts | 47 ++++++++++++------- .../services/ContractInteractionService.ts | 15 ++++++ .../src/tests/orderbook/limit-orders.test.ts | 13 ++--- 5 files changed, 84 insertions(+), 22 deletions(-) diff --git a/sdk/packages/simplex/docs/ai/changelog/2026-09-15-limit-orders-posted-to-the-hyperfx-orderbook.md b/sdk/packages/simplex/docs/ai/changelog/2026-09-15-limit-orders-posted-to-the-hyperfx-orderbook.md index fb6b260858..d9450da1db 100644 --- a/sdk/packages/simplex/docs/ai/changelog/2026-09-15-limit-orders-posted-to-the-hyperfx-orderbook.md +++ b/sdk/packages/simplex/docs/ai/changelog/2026-09-15-limit-orders-posted-to-the-hyperfx-orderbook.md @@ -23,6 +23,11 @@ POST /api/limit-orders { fillChain, tokenIn, amountIn, tokenOut, amountOut DELETE /api/limit-orders/:id ``` +`amountIn` and `amountOut` are whole tokens, as decimal strings: `"1000"`, `"1500.25"`. Nobody +creating an order should have to know an asset's decimals, let alone that the orderbook normalises +everything to 1e18, so the handler scales them while it validates. Anything with more than 18 decimal +places, or that is not a plain decimal, is refused with the amount named. + `acceptedSources` is required and non-empty: it names the source chains the order accepts swaps from, and the orderbook refuses an order that declares none. A create emits `limit-order:posted` or `limit-order:rejected`, a cancel emits `limit-order:cancelled`. diff --git a/sdk/packages/simplex/src/orderbook/amounts.ts b/sdk/packages/simplex/src/orderbook/amounts.ts index 846df2b881..6d7fd876ff 100644 --- a/sdk/packages/simplex/src/orderbook/amounts.ts +++ b/sdk/packages/simplex/src/orderbook/amounts.ts @@ -14,6 +14,32 @@ export function toRaw(amount: bigint, decimals: number): bigint { return amount / 10n ** BigInt(18 - decimals) } +/** + * A human amount ("1000", "1500.25") at 1e18, the unit limit orders are kept in. + * + * What an operator states is whole tokens: nobody creating an order should have + * to know what an asset's decimals are, let alone the orderbook's own scale. The + * string is parsed exactly, so no float ever touches the figure. + */ +export function fromHuman(amount: string): bigint { + const text = amount.trim() + if (!/^[0-9]+(\.[0-9]+)?$/.test(text)) { + throw new RangeError(`'${amount}' is not a positive decimal amount`) + } + const [whole, fraction = ""] = text.split(".") + if (fraction.length > 18) { + throw new RangeError(`'${amount}' has more than 18 decimal places`) + } + return BigInt(whole) * ORDERBOOK_SCALE + BigInt(fraction.padEnd(18, "0") || "0") +} + +/** A 1e18 amount as whole tokens, for reading back to an operator. Truncates trailing zeros. */ +export function toHuman(amount: bigint): string { + const whole = amount / ORDERBOOK_SCALE + const fraction = (amount % ORDERBOOK_SCALE).toString().padStart(18, "0").replace(/0+$/, "") + return fraction ? `${whole}.${fraction}` : whole.toString() +} + function divCeil(numerator: bigint, denominator: bigint): bigint { return (numerator + denominator - 1n) / denominator } diff --git a/sdk/packages/simplex/src/orderbook/limit-orders.ts b/sdk/packages/simplex/src/orderbook/limit-orders.ts index fc06c9b1ed..08dcc3eb89 100644 --- a/sdk/packages/simplex/src/orderbook/limit-orders.ts +++ b/sdk/packages/simplex/src/orderbook/limit-orders.ts @@ -7,7 +7,7 @@ import type { DelegationService } from "@/services/DelegationService" import type { FillerConfigService } from "@/services/FillerConfigService" import { defaultLoggerContext, type Logger, type LoggerContext } from "@/services/Logger" import type { Signer } from "@/services/wallet" -import { rateFrom, signedAmounts } from "./amounts" +import { fromHuman, rateFrom, signedAmounts, toHuman } from "./amounts" import { OrderbookClient, OrderbookRequestError } from "./client" import type { Book, CancelOrderResult, OrderbookLimits, PostedOrder, SubmitOrderResult } from "./types" @@ -43,11 +43,13 @@ export class LimitOrderValidationError extends Error {} */ export interface CreateLimitOrderRequest { fillChain: string - /** The symbol simplex takes in, at 1e18. */ + /** The symbol simplex takes in. */ tokenIn: string + /** Whole tokens taken in, as a decimal string: "1000", "1500.25". */ amountIn: string - /** The symbol simplex pays out, at 1e18. */ + /** The symbol simplex pays out. */ tokenOut: string + /** Whole tokens paid out, as a decimal string. */ amountOut: string acceptedSources: string[] /** @@ -121,14 +123,14 @@ export class LimitOrderService { const limits = await this.limits() const book = this.resolveBook(limits, request.tokenIn, request.tokenOut) const ttlSecs = request.ttlSecs ?? this.defaultTtlSecs - this.validate(request, book, limits, ttlSecs) + const { amountIn, amountOut } = this.validate(request, book, limits, ttlSecs) const { side, price } = rateFrom({ base: book.base, quote: book.quote, tokenIn: request.tokenIn, - amountIn: BigInt(request.amountIn), - amountOut: BigInt(request.amountOut), + amountIn, + amountOut, }) if (this.delegationService && !(await this.delegationService.setupDelegation(request.fillChain))) { @@ -145,7 +147,7 @@ export class LimitOrderService { side, fillChain: request.fillChain, price: price.toString(), - size: request.amountOut, + size: amountOut.toString(), acceptedSources: request.acceptedSources, ttlSecs, expiresAt: new Date(Date.now() + ttlSecs * 1000).toISOString(), @@ -271,18 +273,28 @@ export class LimitOrderService { return book } - private validate(request: CreateLimitOrderRequest, book: Book, limits: OrderbookLimits, ttlSecs: number): void { + private validate( + request: CreateLimitOrderRequest, + book: Book, + limits: OrderbookLimits, + ttlSecs: number, + ): { amountIn: bigint; amountOut: bigint } { if (!this.configService.getConfiguredChainIds().includes(getChainId(request.fillChain) ?? -1)) { throw new LimitOrderValidationError(`'${request.fillChain}' is not a chain this filler is configured for`) } - for (const [name, value] of [ - ["amountIn", request.amountIn], - ["amountOut", request.amountOut], - ] as const) { - if (!/^[0-9]+$/.test(value ?? "") || BigInt(value) <= 0n) { - throw new LimitOrderValidationError(`${name} must be a positive integer at 1e18, as a decimal string`) + // Whole tokens in, 1e18 out. An operator states what they are trading, not + // what the orderbook's scale or the asset's decimals happen to be. + const scaled: Record<"amountIn" | "amountOut", bigint> = { amountIn: 0n, amountOut: 0n } + for (const name of ["amountIn", "amountOut"] as const) { + try { + scaled[name] = fromHuman(request[name] ?? "") + } catch (err) { + throw new LimitOrderValidationError( + `${name} must be an amount in whole tokens, like "1000" or "1500.25"; ${(err as Error).message}`, + ) } + if (scaled[name] <= 0n) throw new LimitOrderValidationError(`${name} must be greater than zero`) } const sources = request.acceptedSources ?? [] @@ -327,9 +339,9 @@ export class LimitOrderService { // The dust floor applies to what the operator pays out, which is the side // the orderbook advertises depth on. const floor = limits.serverInfo.minOrderSizes.find((entry) => entry.symbol === request.tokenOut) - if (floor && BigInt(request.amountOut) < BigInt(floor.size)) { + if (floor && scaled.amountOut < BigInt(floor.size)) { throw new LimitOrderValidationError( - `amountOut is below the orderbook's dust floor for ${request.tokenOut} (${floor.size} at 1e18)`, + `amountOut is below the orderbook's dust floor for ${request.tokenOut} (${toHuman(BigInt(floor.size))})`, ) } @@ -340,8 +352,11 @@ export class LimitOrderService { throw new LimitOrderValidationError(`'${symbol}' does not resolve to a token address on ${request.fillChain}`) } } + + return { amountIn: scaled.amountIn, amountOut: scaled.amountOut } } + /** Builds and submits the posting, then writes the orderbook's answer onto the row. */ private async post(order: LimitOrder): Promise { const { result, orderNonce } = await this.submit(order) diff --git a/sdk/packages/simplex/src/services/ContractInteractionService.ts b/sdk/packages/simplex/src/services/ContractInteractionService.ts index ce3e37cb87..a624a0add6 100644 --- a/sdk/packages/simplex/src/services/ContractInteractionService.ts +++ b/sdk/packages/simplex/src/services/ContractInteractionService.ts @@ -522,6 +522,21 @@ export class ContractInteractionService { /** * Reads the solver account's deposit balance on the ERC-4337 EntryPoint. */ + /** What `holder` holds of an ERC-20 on `chain`, in the token's own units. */ + async getTokenBalance(chain: string, token: HexString, holder: HexString): Promise { + const client = this.clientManager.getPublicClient(chain) + return retryPromise( + () => + client.readContract({ + address: token, + abi: ERC20_ABI, + functionName: "balanceOf", + args: [holder], + }) as Promise, + { maxRetries: 3, backoffMs: 250, logMessage: "Failed to read token balance" }, + ) + } + async getSolverEntryPointBalance(chain: string): Promise { const entryPointAddress = this.configService.getEntryPointAddress(chain) if (!entryPointAddress) { diff --git a/sdk/packages/simplex/src/tests/orderbook/limit-orders.test.ts b/sdk/packages/simplex/src/tests/orderbook/limit-orders.test.ts index 615280a84d..3762942192 100644 --- a/sdk/packages/simplex/src/tests/orderbook/limit-orders.test.ts +++ b/sdk/packages/simplex/src/tests/orderbook/limit-orders.test.ts @@ -97,9 +97,9 @@ function makeService(client: ReturnType, store = new MemoryDa const REQUEST: CreateLimitOrderRequest = { fillChain: CHAIN, tokenIn: "USDC", - amountIn: (1000n * ONE).toString(), + amountIn: "1000", tokenOut: "CNGN", - amountOut: (1_500_000n * ONE).toString(), + amountOut: "1500000", acceptedSources: ["EVM-1"], } @@ -218,7 +218,7 @@ describe("LimitOrderService.create validation", () => { }) it("refuses an amountOut under the paid token's dust floor", async () => { - await rejects({ amountOut: (999n * ONE).toString() }, /dust floor for CNGN/) + await rejects({ amountOut: "999" }, /dust floor for CNGN/) }) it("refuses a ttl under the orderbook's minimum", async () => { @@ -243,9 +243,10 @@ describe("LimitOrderService.create validation", () => { await rejects({ acceptedSources: ["EVM-1", "EVM-42161"] }, /does not serve EVM-42161/) }) - it("refuses an amount that is not a positive 1e18 integer", async () => { - await rejects({ amountIn: "0" }, /amountIn must be a positive integer/) - await rejects({ amountOut: "1.5" }, /amountOut must be a positive integer/) + it("refuses an amount that is not a positive decimal in whole tokens", async () => { + await rejects({ amountIn: "0" }, /amountIn must be greater than zero/) + await rejects({ amountOut: "1.5e3" }, /amountOut must be an amount in whole tokens/) + await rejects({ amountOut: "0.0000000000000000001" }, /more than 18 decimal places/) }) }) From 068176feec7a96338da885fd9dfed715332e9cfd Mon Sep 17 00:00:00 2001 From: dharjeezy Date: Fri, 18 Sep 2026 16:21:54 +0100 Subject: [PATCH 07/12] Make limit orders part of every running filler --- sdk/packages/simplex/src/core/boot.ts | 36 ++++++++++--------- .../simplex/src/services/server/UiServer.ts | 15 ++++---- .../src/tests/ui-server-socket.test.ts | 11 ++++++ .../src/tests/ui-server-tunnel.test.ts | 11 ++++++ .../simplex/src/tests/ui-server.test.ts | 11 ++++++ 5 files changed, 58 insertions(+), 26 deletions(-) diff --git a/sdk/packages/simplex/src/core/boot.ts b/sdk/packages/simplex/src/core/boot.ts index 0e57b6fcd8..e8fc9fc581 100644 --- a/sdk/packages/simplex/src/core/boot.ts +++ b/sdk/packages/simplex/src/core/boot.ts @@ -513,23 +513,25 @@ export async function bootFiller(config: FillerTomlConfig, options: BootOptions) // Limit orders are inventory the operator opens while the filler runs, so the // service exists as soon as an orderbook is configured, whether or not any // order has been created yet. - const limitOrderService = config.orderbook - ? new LimitOrderService( - options.data.limitOrders, - new OrderbookClient( - config.orderbook.url, - config.orderbook.requestTimeoutMs ?? DEFAULT_ORDERBOOK_TIMEOUT_MS, - options.loggers, - ), - contractService, - configService, - assetRegistry, - runtimeSigner, - config.orderbook.defaultTtlSecs ?? MIN_ORDER_TTL_SECONDS, - new DelegationService(chainClientManager, configService, runtimeSigner), - options.loggers, - ) - : undefined + // `validateConfig` refuses a config without an [orderbook] section, so a filler + // that reached here has one. Checked rather than asserted: boot is also entered + // from the setup API, and a filler with no orderbook has nothing to price from. + if (!config.orderbook) throw new Error("an [orderbook] section is required") + const limitOrderService = new LimitOrderService( + options.data.limitOrders, + new OrderbookClient( + config.orderbook.url, + config.orderbook.requestTimeoutMs ?? DEFAULT_ORDERBOOK_TIMEOUT_MS, + options.loggers, + ), + contractService, + configService, + assetRegistry, + runtimeSigner, + config.orderbook.defaultTtlSecs ?? MIN_ORDER_TTL_SECONDS, + new DelegationService(chainClientManager, configService, runtimeSigner), + options.loggers, + ) // Initialize (sets up EIP-7702 delegation if solver selection is configured) try { diff --git a/sdk/packages/simplex/src/services/server/UiServer.ts b/sdk/packages/simplex/src/services/server/UiServer.ts index a2ca74c377..288175710b 100644 --- a/sdk/packages/simplex/src/services/server/UiServer.ts +++ b/sdk/packages/simplex/src/services/server/UiServer.ts @@ -164,8 +164,8 @@ export interface OperatorContext { stop(): Promise activity: Pick bids?: Pick - /** The operator's limit orders. Absent unless `[orderbook]` is enabled. */ - limitOrders?: Pick + /** The operator's limit orders. Always present: simplex prices from them. */ + limitOrders: Pick /** Persists an operator pause so it survives a restart. */ setPaused(paused: boolean): Promise /** @@ -834,7 +834,7 @@ export class UiServer { if (method === "GET") { const params = new URL(req.url ?? "/", "http://localhost").searchParams return this.handleLimitOrders(res, () => - this.operator!.limitOrders!.list({ + this.operator!.limitOrders.list({ status: (params.get("status") as LimitOrderFilter["status"]) ?? undefined, fillChain: params.get("chain") ?? undefined, book: params.get("book") ?? undefined, @@ -851,12 +851,12 @@ export class UiServer { const id = limitOrderMatch[1] if (method === "GET") { return this.handleLimitOrders(res, async () => { - const order = await this.operator!.limitOrders!.get(id) + const order = await this.operator!.limitOrders.get(id) return order && { order } }) } if (method === "DELETE") { - return this.handleLimitOrders(res, () => this.operator!.limitOrders!.cancel(id)) + return this.handleLimitOrders(res, () => this.operator!.limitOrders.cancel(id)) } return sendJson(res, 405, { error: "Method not allowed" }) } @@ -1400,9 +1400,6 @@ export class UiServer { * and an operation resolving null is a 404. */ private async handleLimitOrders(res: ServerResponse, run: () => Promise): Promise { - if (!this.operator?.limitOrders) { - return sendJson(res, 501, { error: "No orderbook is configured for this filler" }) - } try { const payload = await run() if (payload === null || payload === undefined) return sendJson(res, 404, { error: "Not found" }) @@ -1421,7 +1418,7 @@ export class UiServer { } catch { return sendJson(res, 400, { error: "Invalid JSON body" }) } - return this.handleLimitOrders(res, () => this.operator!.limitOrders!.create(body)) + return this.handleLimitOrders(res, () => this.operator!.limitOrders.create(body)) } private async handleMarketAdd(req: IncomingMessage, res: ServerResponse): Promise { diff --git a/sdk/packages/simplex/src/tests/ui-server-socket.test.ts b/sdk/packages/simplex/src/tests/ui-server-socket.test.ts index 4ef8df466c..e9a9fc99a7 100644 --- a/sdk/packages/simplex/src/tests/ui-server-socket.test.ts +++ b/sdk/packages/simplex/src/tests/ui-server-socket.test.ts @@ -142,6 +142,17 @@ function operatorContext(): OperatorContext & { configPath: string } { let paused = false return { strategies: [], + // Every running filler has limit orders: they are what it prices from. + limitOrders: { + list: async () => ({ orders: [] }), + get: async () => null, + create: async () => { + throw new Error("not wired for this test") + }, + cancel: async () => { + throw new Error("not wired for this test") + }, + } as never, filler: { pause() { paused = true diff --git a/sdk/packages/simplex/src/tests/ui-server-tunnel.test.ts b/sdk/packages/simplex/src/tests/ui-server-tunnel.test.ts index 4a58b48400..2e91c09516 100644 --- a/sdk/packages/simplex/src/tests/ui-server-tunnel.test.ts +++ b/sdk/packages/simplex/src/tests/ui-server-tunnel.test.ts @@ -82,6 +82,17 @@ function operatorContext(tunnel?: TunnelControls): OperatorContext & { configPat } return { strategies: [], + // Every running filler has limit orders: they are what it prices from. + limitOrders: { + list: async () => ({ orders: [] }), + get: async () => null, + create: async () => { + throw new Error("not wired for this test") + }, + cancel: async () => { + throw new Error("not wired for this test") + }, + } as never, filler: { pause() {}, resume() {}, diff --git a/sdk/packages/simplex/src/tests/ui-server.test.ts b/sdk/packages/simplex/src/tests/ui-server.test.ts index 7030fe06bf..8c35b65a5b 100644 --- a/sdk/packages/simplex/src/tests/ui-server.test.ts +++ b/sdk/packages/simplex/src/tests/ui-server.test.ts @@ -140,6 +140,17 @@ function baseOperator(overrides: Partial = {}): TestOperator { loggers, strategies: [], filler: fakePauseControl(), + // Every running filler has limit orders: they are what it prices from. + limitOrders: { + list: async () => ({ orders: [] }), + get: async () => null, + create: async () => { + throw new Error("not wired for this test") + }, + cancel: async () => { + throw new Error("not wired for this test") + }, + } as unknown as OperatorContext["limitOrders"], balances: { getSnapshot: () => ({ updatedAt: null, status: "loading", chains: [], issues: [] }) }, haltControls: [], config: fakeConfig(), From a24e50f0e5d3166f846c5fbf18c7d783913323f8 Mon Sep 17 00:00:00 2001 From: dharjeezy Date: Fri, 18 Sep 2026 16:48:48 +0100 Subject: [PATCH 08/12] Hand the binary the limit order controller unconditionally --- sdk/packages/simplex/src/bin/simplex.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/packages/simplex/src/bin/simplex.ts b/sdk/packages/simplex/src/bin/simplex.ts index eefe03eb62..f49ba6cb8d 100644 --- a/sdk/packages/simplex/src/bin/simplex.ts +++ b/sdk/packages/simplex/src/bin/simplex.ts @@ -219,7 +219,7 @@ async function operatorContextFrom( stop: () => stopAll(), activity: runtime.activity, bids: runtime.data.bids, - limitOrders: runtime.limitOrders ? simplex.limitOrders : undefined, + limitOrders: simplex.limitOrders, setPaused: (paused) => patchRuntimeState(runtime.data.state, { paused }), // Both contexts, not just the filler's: the dashboard shows one merged feed // and reports one level for it, so leaving the process-wide context (the UI From 2bfbecaa155fe235f196bfde61c4aa0544436a7c Mon Sep 17 00:00:00 2001 From: dharjeezy Date: Fri, 18 Sep 2026 17:14:43 +0100 Subject: [PATCH 09/12] Give the desktop end to end configs an orderbook section --- sdk/packages/simplex-desktop/scripts/e2e/desktop.e2e.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sdk/packages/simplex-desktop/scripts/e2e/desktop.e2e.mjs b/sdk/packages/simplex-desktop/scripts/e2e/desktop.e2e.mjs index a2144ce12e..f933b2f128 100644 --- a/sdk/packages/simplex-desktop/scripts/e2e/desktop.e2e.mjs +++ b/sdk/packages/simplex-desktop/scripts/e2e/desktop.e2e.mjs @@ -278,6 +278,7 @@ function operatorFixture(socketPath, options = {}) { }, pairs: [], chains: [], + orderbook: { url: "https://orderbook.example/graphql" }, } const operator = { strategies: [], @@ -866,6 +867,7 @@ test("first run writes a valid private config under Electron userData", async (t }, ], chains: [{ rpcUrls: ["http://127.0.0.1:9"], bundlerUrl: "http://127.0.0.1:9" }], + orderbook: { url: "https://orderbook.example/graphql" }, } const result = await page.evaluate(async (body) => { const response = await fetch("/api/setup/save-and-start", { From a804b770137c57a4f94d98739426de6610c58053 Mon Sep 17 00:00:00 2001 From: dharjeezy Date: Fri, 18 Sep 2026 17:44:59 +0100 Subject: [PATCH 10/12] Give the packaged smoke test an orderbook section --- .../simplex-desktop/scripts/e2e/desktop.e2e.mjs | 12 ++++++++++++ .../simplex-desktop/scripts/e2e/packaged-smoke.mjs | 1 + 2 files changed, 13 insertions(+) diff --git a/sdk/packages/simplex-desktop/scripts/e2e/desktop.e2e.mjs b/sdk/packages/simplex-desktop/scripts/e2e/desktop.e2e.mjs index f933b2f128..d63b41dd16 100644 --- a/sdk/packages/simplex-desktop/scripts/e2e/desktop.e2e.mjs +++ b/sdk/packages/simplex-desktop/scripts/e2e/desktop.e2e.mjs @@ -317,6 +317,18 @@ function operatorFixture(socketPath, options = {}) { configPath: join(dirname(socketPath), "filler-config.toml"), chains: [], strategyTypes: [], + // Every running filler has limit orders: they are what it prices from. + limitOrders: { + list: async () => [], + get: async () => null, + withFills: async () => null, + create: async () => { + throw new Error("not wired for this test") + }, + cancel: async () => { + throw new Error("not wired for this test") + }, + }, } server = new UiServer({ mode: "operator", uiDistDir: join(simplexRoot, "dist/ui"), operator }) return { diff --git a/sdk/packages/simplex-desktop/scripts/e2e/packaged-smoke.mjs b/sdk/packages/simplex-desktop/scripts/e2e/packaged-smoke.mjs index 56be251797..0c142650a1 100644 --- a/sdk/packages/simplex-desktop/scripts/e2e/packaged-smoke.mjs +++ b/sdk/packages/simplex-desktop/scripts/e2e/packaged-smoke.mjs @@ -163,6 +163,7 @@ async function assertPackagedOnboarding(socketPath, userData) { }, ], chains: [{ rpcUrls: ["http://127.0.0.1:9"], bundlerUrl: "http://127.0.0.1:9" }], + orderbook: { url: "https://orderbook.example/graphql" }, } const response = await socketRequest(socketPath, "/api/setup/save-and-start", "POST", { config }) if (response.status !== 202) { From e7b30287e53620e4a6941ac0bc8227baae91a853 Mon Sep 17 00:00:00 2001 From: David Salami Date: Sat, 19 Sep 2026 12:12:24 +0000 Subject: [PATCH 11/12] Post a limit order in the one fill shape there is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `fillOrder` takes one quote per leg: the input beside each output is the most escrow the solver takes for it. A limit order's op carries the one leg it is, so its take is the whole input the operator wants for what they are paying — the rate itself, since the order's own output amount is zero, as the orderbook requires. The op is still never executed, and there is no longer a version to choose for it: `encodeFillOrder` has one shape and `FillOptionsVersion` is gone with the versioned encoder. --- ...-orders-posted-to-the-hyperfx-orderbook.md | 10 +++++++--- .../services/ContractInteractionService.ts | 20 +++++++++---------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/sdk/packages/simplex/docs/ai/changelog/2026-09-15-limit-orders-posted-to-the-hyperfx-orderbook.md b/sdk/packages/simplex/docs/ai/changelog/2026-09-15-limit-orders-posted-to-the-hyperfx-orderbook.md index d9450da1db..b20e7bf4e7 100644 --- a/sdk/packages/simplex/docs/ai/changelog/2026-09-15-limit-orders-posted-to-the-hyperfx-orderbook.md +++ b/sdk/packages/simplex/docs/ai/changelog/2026-09-15-limit-orders-posted-to-the-hyperfx-orderbook.md @@ -45,9 +45,13 @@ nothing ever submits it, the orderbook only verifies the solver signature over t reads the amounts out of the calldata. So the gas fields are fixed rather than estimated, and `paymasterAndData` carries the accepted-source declaration instead of a paymaster. -It is always encoded as FillOptions v2, without consulting the deployed gateway: the op never runs, -and v1 has nowhere to put `validUntil`, which the orderbook requires. `validUntil` here is a TTL in -seconds from the orderbook's receipt, not a block number. +`FillOptions` takes one quote per leg, so the order's single leg carries `outputs[0]` — what the +operator pays — and `inputs[0]`, the whole input they want for it. That pair is the rate, since the +order's own output amount is zero as the orderbook requires. `validUntil` is a TTL in seconds from +the orderbook's receipt, not a block number. + +There is one `fillOrder` shape, the one the gateway speaks, so the op is built without choosing a +version: the encoder has no other to offer. The derived rate is rounded in simplex's favour, and so is the input the op is built from, so neither the stored rate nor a repost quotes better than the two amounts the operator gave. `REPLAYED` and `ORDER_EXISTS` are answered once by bumping diff --git a/sdk/packages/simplex/src/services/ContractInteractionService.ts b/sdk/packages/simplex/src/services/ContractInteractionService.ts index a624a0add6..f36f7760af 100644 --- a/sdk/packages/simplex/src/services/ContractInteractionService.ts +++ b/sdk/packages/simplex/src/services/ContractInteractionService.ts @@ -20,7 +20,6 @@ import { readLegPartialFill, encodePhantomBidDeclaration, bytes20ToBytes32, - type FillOptionsVersion, } from "@hyperbridge/sdk" import { ERC20_ABI } from "@/config/abis/ERC20" import type { ChainClientManager } from "./ChainClientManager" @@ -46,12 +45,6 @@ const LIMIT_ORDER_CALL_GAS_LIMIT = 500_000n const LIMIT_ORDER_VERIFICATION_GAS_LIMIT = 150_000n const LIMIT_ORDER_PRE_VERIFICATION_GAS = 50_000n -/** - * Limit orders are always encoded as v2, whatever the destination gateway is. - * The op never executes, so the deployed shape does not matter, and v1 has - * nowhere to put `validUntil` which the orderbook requires. - */ -const LIMIT_ORDER_FILL_OPTIONS_VERSION: FillOptionsVersion = 2 /** * Handles contract interactions for tokens and other contracts */ @@ -839,9 +832,10 @@ export class ContractInteractionService { * fields are fixed rather than estimated, and why `paymasterAndData` carries * the accepted-source declaration instead of a paymaster. * - * Always encoded as FillOptions v2. The op never runs, so the deployed - * gateway's own version is beside the point, and the orderbook rejects v1 for - * having nowhere to put `validUntil`. + * The fill options carry `validUntil`, which the orderbook reads as the + * posting's TTL, and the take beside the output: one quote for the order's one + * leg, which is the rate the operator is signing. There is one `fillOrder` + * shape, so there is no version to pick here. */ async prepareLimitOrderUserOp(params: { fillChain: string @@ -889,6 +883,10 @@ export class ContractInteractionService { nativeDispatchFee: 0n, validUntil: BigInt(params.ttlSecs), outputs: [{ token: bytes20ToBytes32(outputToken), amount: outputAmount }], + // The rate itself: the whole input taken for the output paid. The order's + // own output amount is zero, as the orderbook requires, so this pair is + // where the price lives. + inputs: [{ token: bytes20ToBytes32(inputToken), amount: inputAmount }], } const calls: ERC7821Call[] = [ @@ -905,7 +903,7 @@ export class ContractInteractionService { target: gateway, value: 0n, // biome-ignore lint/suspicious/noExplicitAny: the SDK's contract-order shape is not exported - data: encodeFillOrder(transformOrderForContract(order) as any, fillOptions, LIMIT_ORDER_FILL_OPTIONS_VERSION), + data: encodeFillOrder(transformOrderForContract(order) as any, fillOptions), }, ] From 8f2eb405a76f51535c27ba5bdc6cf4cb0fde31d5 Mon Sep 17 00:00:00 2001 From: David Salami Date: Mon, 21 Sep 2026 10:59:58 +0000 Subject: [PATCH 12/12] Key a limit order's posting by its calldata, as every bid is A posting is a bid's op in form, and SolverAccount now derives a bid's nonce key from its calldata as well as its order and session key. The posting follows the same rule, so the orderbook's nonce binding and the account's are one derivation. --- .../simplex/src/services/ContractInteractionService.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/sdk/packages/simplex/src/services/ContractInteractionService.ts b/sdk/packages/simplex/src/services/ContractInteractionService.ts index f36f7760af..4837bfece6 100644 --- a/sdk/packages/simplex/src/services/ContractInteractionService.ts +++ b/sdk/packages/simplex/src/services/ContractInteractionService.ts @@ -907,23 +907,26 @@ export class ContractInteractionService { }, ] + const callData = encodeERC7821ExecuteBatch(calls) + // `prepareSubmitBid` binds the nonce key and prefixes the signature with // `order.id`, both of which have to be this commitment. Setting it here is // what makes the shared builder produce a limit order's op rather than a - // second copy of the signing logic. + // second copy of the signing logic. The key binds the calldata too, as it + // does for every bid, so each posting is the first sequence of its own key. const userOp = await sdkHelper.prepareSubmitBid({ order: { ...order, id: commitment }, fillOptions, solverAccount: this.solverAccountAddress, solverSigner: sdkSigningAccount(this.signer), - nonce: CryptoUtils.bidNonceKey(commitment, ADDRESS_ZERO) << 64n, + nonce: CryptoUtils.bidNonceKey(commitment, ADDRESS_ZERO, callData) << 64n, entryPointAddress: params.entryPointAddress, callGasLimit: LIMIT_ORDER_CALL_GAS_LIMIT, verificationGasLimit: LIMIT_ORDER_VERIFICATION_GAS_LIMIT, preVerificationGas: LIMIT_ORDER_PRE_VERIFICATION_GAS, maxFeePerGas: 0n, maxPriorityFeePerGas: 0n, - callData: encodeERC7821ExecuteBatch(calls), + callData, paymasterAndData: encodePhantomBidDeclaration({ acceptedSourceChains }), })