diff --git a/sdk/packages/sdk/src/protocols/intents/index.ts b/sdk/packages/sdk/src/protocols/intents/index.ts index 8f5364d2b..9e15ff6b2 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 33671e40d..26eb738a1 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-desktop/scripts/e2e/desktop.e2e.mjs b/sdk/packages/simplex-desktop/scripts/e2e/desktop.e2e.mjs index a2144ce12..d63b41dd1 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: [], @@ -316,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 { @@ -866,6 +879,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", { diff --git a/sdk/packages/simplex-desktop/scripts/e2e/packaged-smoke.mjs b/sdk/packages/simplex-desktop/scripts/e2e/packaged-smoke.mjs index 56be25179..0c142650a 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) { 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 000000000..b20e7bf4e --- /dev/null +++ b/sdk/packages/simplex/docs/ai/changelog/2026-09-15-limit-orders-posted-to-the-hyperfx-orderbook.md @@ -0,0 +1,83 @@ +# 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. +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. + +## 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 { fillChain, tokenIn, amountIn, tokenOut, amountOut, acceptedSources, ttlSecs? } +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`. + +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. + +`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 +`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] +url = "https://orderbook.hyperbridge.network/graphql" +defaultTtlSecs = 900 +reconcileIntervalSecs = 300 +requestTimeoutMs = 10000 +``` + +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. + +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/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 000000000..877625172 --- /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/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 000000000..b018a0a08 --- /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. diff --git a/sdk/packages/simplex/filler-config-example.toml b/sdk/packages/simplex/filler-config-example.toml index e065341b0..7a04b3b68 100644 --- a/sdk/packages/simplex/filler-config-example.toml +++ b/sdk/packages/simplex/filler-config-example.toml @@ -292,6 +292,17 @@ points = [ # ] +# 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 + + # 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 845257132..f49ba6cb8 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: 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 diff --git a/sdk/packages/simplex/src/cli/init/emit-toml.ts b/sdk/packages/simplex/src/cli/init/emit-toml.ts index 046ddd189..724124a08 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 63e47d20f..873c81b22 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 f6caea44c..8ee2338ef 100644 --- a/sdk/packages/simplex/src/config/defaults.ts +++ b/sdk/packages/simplex/src/config/defaults.ts @@ -1,2 +1,8 @@ /** 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 + +/** 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 d20697d6e..a029ea5c9 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. */ + 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 often to reconcile local limit orders against the orderbook, in seconds. */ + reconcileIntervalSecs?: number + requestTimeoutMs?: number } /** @@ -232,6 +256,29 @@ export function validateVaultToml( } } +/** + * 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") + } + const positiveSeconds: [keyof OrderbookConfig, number | undefined, number][] = [ + ["defaultTtlSecs", orderbook.defaultTtlSecs, MIN_ORDER_TTL_SECONDS], + ["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}`) + } + } +} + 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 +358,13 @@ export function validateConfig(config: FillerTomlConfig, cliWatchOnly = false): validateVaultToml(config.vault.vaults) } + // 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) { validateAssetDefinitions(config.assets) diff --git a/sdk/packages/simplex/src/core/boot.ts b/sdk/packages/simplex/src/core/boot.ts index c6bae4555..e8fc9fc58 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. */ + 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,29 @@ 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. + // `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 { await intentFiller.initialize() @@ -673,6 +702,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 381d463b2..922cd78d1 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 c7cacddbf..e2f63f814 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 000000000..6e1881e75 --- /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 9f86d6df0..f59d559e2 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 000000000..6d7fd876f --- /dev/null +++ b/sdk/packages/simplex/src/orderbook/amounts.ts @@ -0,0 +1,113 @@ +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) +} + +/** + * 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 +} + +/** + * 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. + * + * `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 000000000..1a8b30faa --- /dev/null +++ b/sdk/packages/simplex/src/orderbook/client.ts @@ -0,0 +1,192 @@ +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 } + chains + 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 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) { + __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}`) + } + } + + /** + * 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 + 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 000000000..08dcc3eb8 --- /dev/null +++ b/sdk/packages/simplex/src/orderbook/limit-orders.ts @@ -0,0 +1,507 @@ +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 { fromHuman, rateFrom, signedAmounts, toHuman } from "./amounts" +import { OrderbookClient, OrderbookRequestError } from "./client" +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 + +/** 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 {} + +/** + * 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 { + fillChain: string + /** 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. */ + tokenOut: string + /** Whole tokens paid out, as a decimal 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 +} + +/** 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 { + // 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.tokenIn, request.tokenOut) + const ttlSecs = request.ttlSecs ?? this.defaultTtlSecs + const { amountIn, amountOut } = this.validate(request, book, limits, ttlSecs) + + const { side, price } = rateFrom({ + base: book.base, + quote: book.quote, + tokenIn: request.tokenIn, + amountIn, + 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`, + ) + } + + const insert: LimitOrderInsert = { + id: randomUUID(), + book: book.id, + base: book.base, + quote: book.quote, + side, + fillChain: request.fillChain, + price: price.toString(), + size: amountOut.toString(), + acceptedSources: request.acceptedSources, + ttlSecs, + expiresAt: new Date(Date.now() + ttlSecs * 1000).toISOString(), + } + 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.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 { + order: (await this.store.setPosting(id, { + commitment: null, + bookExpiresAt: null, + bookPrice: null, + orderNonce: order.orderNonce, + status: "cancelled", + lastError: null, + }))!, + result, + } + } + + // 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 } + } + + /** + * 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 !== "rejected") 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) { + // 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 + } + } + + /** + * 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 + } + } + + /** 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( + `No book trades ${tokenIn} against ${tokenOut}. The orderbook offers: ${known || "none"}`, + ) + } + return book + } + + 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`) + } + + // 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 ?? [] + 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`) + } + } + + // 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}`, + ) + } + + // 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 && scaled.amountOut < BigInt(floor.size)) { + throw new LimitOrderValidationError( + `amountOut is below the orderbook's dust floor for ${request.tokenOut} (${toHuman(BigInt(floor.size))})`, + ) + } + + // 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}`) + } + } + + 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) + + 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}` + + // 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. + * + * 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 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)).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 + } + } + + /** 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 { result: await this.client.submitOrder(userOp), commitment } + } catch (err) { + if (err instanceof OrderbookRequestError) { + return { result: { kind: "failed", code: "REQUEST_FAILED", message: err.message, retryable: true }, commitment } + } + throw err + } + } + + 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([ + 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}`) + } + + return this.contractService.prepareLimitOrderUserOp({ + fillChain: order.fillChain, + entryPointAddress, + inputToken, + outputToken, + inputAmount, + outputAmount, + orderNonce, + ttlSecs: order.ttlSecs, + acceptedSourceChains: order.acceptedSources, + }) + } +} + +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 000000000..3361e17b6 --- /dev/null +++ b/sdk/packages/simplex/src/orderbook/types.ts @@ -0,0 +1,109 @@ +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[] + /** + * 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 +} + +/** `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 } + /** + * 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/services/ContractInteractionService.ts b/sdk/packages/simplex/src/services/ContractInteractionService.ts index 6a5a2c5ab..4837bfece 100644 --- a/sdk/packages/simplex/src/services/ContractInteractionService.ts +++ b/sdk/packages/simplex/src/services/ContractInteractionService.ts @@ -18,6 +18,8 @@ import { type TokenInfo, encodeFillOrder, readLegPartialFill, + encodePhantomBidDeclaration, + bytes20ToBytes32, } from "@hyperbridge/sdk" import { ERC20_ABI } from "@/config/abis/ERC20" import type { ChainClientManager } from "./ChainClientManager" @@ -33,6 +35,16 @@ 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 + /** * Handles contract interactions for tokens and other contracts */ @@ -503,6 +515,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) { @@ -794,6 +821,118 @@ 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. + * + * 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 + 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 }], + // 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[] = [ + { + 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), + }, + ] + + 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. 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, 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, + 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 4f0881de7..288175710 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. Always present: simplex prices from them. */ + 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,33 @@ 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 { + 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 3a2bf7d6d..20aee91d3 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/emit-toml.test.ts b/sdk/packages/simplex/src/tests/cli/emit-toml.test.ts index c7a6a360c..45fda6c48 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 cfd2d4b70..13815cee5 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 }, @@ -186,3 +187,38 @@ describe("validateConfig", () => { expect(() => validateConfig(config)).toThrow(/askPriceCurve — .*invalid amount/) }) }) + +describe("validateConfig [orderbook]", () => { + const withOrderbook = (orderbook: FillerTomlConfig["orderbook"]): FillerTomlConfig => ({ + ...minimalConfig(), + orderbook, + }) + + 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 block", () => { + expect(() => + validateConfig( + withOrderbook({ + url: "https://orderbook.example/graphql", + defaultTtlSecs: 1800, + reconcileIntervalSecs: 300, + requestTimeoutMs: 10000, + }), + ), + ).not.toThrow() + }) + + 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({ 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 ba7740ad0..e0a501f00 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 196913697..39facbe64 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/data/limit-order-store.test.ts b/sdk/packages/simplex/src/tests/data/limit-order-store.test.ts new file mode 100644 index 000000000..5f68ae309 --- /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 000000000..a138ba26e --- /dev/null +++ b/sdk/packages/simplex/src/tests/orderbook/amounts.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest" +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 +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/, + ) + }) +}) + +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 new file mode 100644 index 000000000..376294219 --- /dev/null +++ b/sdk/packages/simplex/src/tests/orderbook/limit-orders.test.ts @@ -0,0 +1,306 @@ +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() }], + chains: [CHAIN, "EVM-1"], + 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, + /** 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 } + }, + 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 } +} + +/** Take in 1,000 USDC, pay out 1,500,000 cNGN: a USDC to cNGN order at 1,500. */ +const REQUEST: CreateLimitOrderRequest = { + fillChain: CHAIN, + tokenIn: "USDC", + amountIn: "1000", + tokenOut: "CNGN", + amountOut: "1500000", + 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 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) + 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("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, 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([])) + 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 an amountOut under the paid token's dust floor", async () => { + await rejects({ amountOut: "999" }, /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 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 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 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/) + }) +}) + +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) + }) +}) 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 39488c1b4..b7324d63e 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 6c27a0b07..6fa69965c 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" }, } } 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 4ef8df466..e9a9fc99a 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 4a58b4840..2e91c0951 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 7030fe06b..8c35b65a5 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(),