diff --git a/sdk/packages/simplex/docs/ai/changelog/2026-09-19-the-operator-ui-posts-limit-orders.md b/sdk/packages/simplex/docs/ai/changelog/2026-09-19-the-operator-ui-posts-limit-orders.md new file mode 100644 index 000000000..7faa564d6 --- /dev/null +++ b/sdk/packages/simplex/docs/ai/changelog/2026-09-19-the-operator-ui-posts-limit-orders.md @@ -0,0 +1,39 @@ +# 2026-09-19 — The operator UI posts limit orders + +The dashboard lost its curve editors when prices moved onto limit orders, and nothing replaced them: +the API had `/api/limit-orders` from the start, but the only way to reach it was curl. An operator +could see the markets simplex watches and not what it was offering on any of them. + +A **Limit orders** page sits between Overview and Orders, at `/limit-orders`. + +## What it shows + +One row per order, live orders first and closed ones under their own heading. Each row reads as the +offer it is: `1,500,000 CNGN left at 1,500 CNGN per USDC`, the token taken in, the chain it fills on, +and how long is left on the clock. + +Amounts are whole tokens throughout, never the orderbook's 1e18. The operator states `1000` and reads +`1,000` back; `fromScaled` is the only place the scale is handled. + +The badge answers "is this actually working", which the status column alone does not: + +- `open` with a commitment is **On the book**. +- `open` with no commitment is **Posting**, because the row is live before the orderbook has answered. +- `open` with a `lastError` stays **On the book** but turns amber and carries the refusal, since the + row stays open and the next cycle tries again. +- `resizing` is **Resizing**, a fill being settled. +- `cancelled`, `expired`, `filled` and `rejected` say so plainly. + +## What it does + +**Posting an order** asks for the two amounts, in whole tokens, and shows the rate they imply before +anything is sent — the operator chooses amounts, not a rate. It also asks which chain the order fills +on and which sources it accepts, defaulting to every chain the filler watches, since an order +accepting none is one the orderbook refuses outright. + +**Opening an order** shows what is left of it, what live bids are holding against it, and the fills +that took the difference, which is what makes a shrunken `remaining` explicable rather than a number +that quietly moved. It can be cancelled from there while it is still live. + +The list reloads every ten seconds and after every mutation. A posting lands, expires or is filled +without the operator doing anything, so the page cannot be a snapshot of the moment it opened. diff --git a/sdk/packages/simplex/ui/src/lib/route.ts b/sdk/packages/simplex/ui/src/lib/route.ts index afcd0ef4d..89abeaeed 100644 --- a/sdk/packages/simplex/ui/src/lib/route.ts +++ b/sdk/packages/simplex/ui/src/lib/route.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from "react" -export type OperatorTab = "overview" | "orders" | "wallet" | "logs" | "operations" +export type OperatorTab = "overview" | "limit-orders" | "orders" | "wallet" | "logs" | "operations" /** * One path per sidebar page. Single segments only: index.html loads its @@ -10,6 +10,7 @@ export type OperatorTab = "overview" | "orders" | "wallet" | "logs" | "operation */ export const TAB_PATHS: Record = { overview: "/", + "limit-orders": "/limit-orders", orders: "/orders", wallet: "/wallet", logs: "/logs", diff --git a/sdk/packages/simplex/ui/src/operator/LimitOrders.tsx b/sdk/packages/simplex/ui/src/operator/LimitOrders.tsx new file mode 100644 index 000000000..158dd6aba --- /dev/null +++ b/sdk/packages/simplex/ui/src/operator/LimitOrders.tsx @@ -0,0 +1,269 @@ +import { useState } from "react" +import { ChevronRightIcon } from "../components/InterfaceIcons" +import { OperatorSheet } from "../components/OperatorSheet" +import { TokenPairIcons } from "../components/TokenIcon" +import { formatDate, sqliteUtcToMs } from "../lib/format" +import type { LimitOrder } from "../types" +import { CreateLimitOrderForm } from "./limitOrders/CreateLimitOrderForm" +import { available, describeExpiry, describeRate, fromScaled, legs, statusOf } from "./limitOrders/limitOrderModel" +import { type LimitOrderFills, useLimitOrders } from "./limitOrders/useLimitOrders" + +interface LimitOrdersProps { + /** Chain ids the filler runs. A limit order names them as state machine ids. */ + chains: number[] + chainLabels?: Record + /** Symbols the operator can trade, from what the filler knows how to send. */ + symbols: string[] +} + +/** + * The operator's resting orders: what simplex is offering, on what terms, and + * what is left of each. + * + * This is where prices live now. A limit order names two amounts and simplex + * fills at the rate they imply until the order runs out, so the page is a book + * of standing offers rather than a curve to shape. + */ +export function LimitOrders({ chains, chainLabels, symbols }: LimitOrdersProps) { + const { orders, loading, error, create, cancel, withFills, reload } = useLimitOrders() + const [creating, setCreating] = useState(false) + const [selected, setSelected] = useState() + const [detail, setDetail] = useState() + const [busy, setBusy] = useState(false) + const [actionError, setActionError] = useState() + + // The status feed counts chains by id; an order names them the way the gateway + // does, so the page speaks both. + const chainOptions = chains.map((id) => ({ + stateMachineId: `EVM-${id}`, + label: chainLabels?.[String(id)] ?? `Chain ${id}`, + })) + const chainLabel = (stateMachineId: string) => + chainOptions.find((chain) => chain.stateMachineId === stateMachineId)?.label ?? stateMachineId + + const open = async (order: LimitOrder) => { + setSelected(order) + setDetail(undefined) + setActionError(undefined) + // The fills are what make a shrunken `remaining` explicable, and they are a + // second read: the list itself carries only the order. + try { + setDetail(await withFills(order.id)) + } catch { + setDetail(undefined) + } + } + + const cancelSelected = async () => { + if (!selected || busy) return + setBusy(true) + setActionError(undefined) + try { + await cancel(selected.id) + setSelected(undefined) + } catch (err) { + setActionError(err instanceof Error ? err.message : "Could not cancel the limit order") + } finally { + setBusy(false) + } + } + + const live = orders.filter((order) => order.status === "open" || order.status === "resizing") + const closed = orders.filter((order) => order.status !== "open" && order.status !== "resizing") + + return ( + <> +
+
+
+ Pricing +

Limit orders

+
+ +
+ + {error ?

{error}

: null} + +
+ {live.map((order) => ( + void open(order)} /> + ))} + {live.length === 0 && !loading ? ( +

+ Nothing is resting on the orderbook. Post a limit order to say what simplex will pay. +

+ ) : null} +
+ + {closed.length > 0 ? ( + <> +
+
+ Closed +
+
+
+ {closed.map((order) => ( + void open(order)} + /> + ))} +
+ + ) : null} +
+ + setCreating(false)} + wide + title="New limit order" + description="Say what you take in and what you pay out. Simplex fills at the rate the two imply." + > + chain.stateMachineId)} + chainLabel={chainLabel} + create={create} + onCreated={() => setCreating(false)} + onCancel={() => setCreating(false)} + /> + + + setSelected(undefined)} + title={selected ? `${selected.base}/${selected.quote}` : "Limit order"} + description={selected ? describeRate(selected) : undefined} + > + {selected ? ( + void cancelSelected()} + onRefresh={() => void reload()} + /> + ) : null} + + + ) +} + +function LimitOrderRow(props: { order: LimitOrder; chainLabel: (id: string) => string; onOpen: () => void }) { + const { order, chainLabel, onOpen } = props + const status = statusOf(order) + const { input, output } = legs(order) + const expiry = describeExpiry(order.expiresAt) + + return ( + + ) +} + +function LimitOrderDetail(props: { + order: LimitOrder + fills: LimitOrderFills["fills"] + chainLabel: (id: string) => string + busy: boolean + error?: string + onCancel: () => void + onRefresh: () => void +}) { + const { order, fills, chainLabel, busy, error, onCancel, onRefresh } = props + const status = statusOf(order) + const { input, output } = legs(order) + const closed = order.status !== "open" && order.status !== "resizing" + + return ( +
+
+
+
Status
+
+ {status.label} + {status.detail ? {status.detail} : null} +
+
+
+
Rate
+
{describeRate(order)}
+
+
+
Pays out
+
+ {fromScaled(order.remaining)} {output} left of {fromScaled(order.size)} +
+
+
+
Held by live bids
+
+ {fromScaled(order.reserved)} {output}, leaving {fromScaled(available(order).toString())} to draw on +
+
+
+
Takes in
+
+ {input} on {chainLabel(order.fillChain)} +
+
+
+
Accepts swaps from
+
{order.acceptedSources.map(chainLabel).join(", ")}
+
+
+
Expires
+
{describeExpiry(order.expiresAt) ?? "does not expire"}
+
+
+ + {order.lastError ?

{order.lastError}

: null} + {error ?

{error}

: null} + +

Fills

+ {fills.length === 0 ? ( +

Nothing has drawn on this order yet.

+ ) : ( +
    + {fills.map((fill) => ( +
  • + {formatDate(sqliteUtcToMs(fill.createdAt))} + {fill.success ? "filled" : (fill.error ?? "failed")} +
  • + ))} +
+ )} + +
+ + {!closed ? ( + + ) : null} +
+
+ ) +} diff --git a/sdk/packages/simplex/ui/src/operator/Operator.tsx b/sdk/packages/simplex/ui/src/operator/Operator.tsx index 98cd4a21e..00ec52019 100644 --- a/sdk/packages/simplex/ui/src/operator/Operator.tsx +++ b/sdk/packages/simplex/ui/src/operator/Operator.tsx @@ -3,11 +3,21 @@ import { api } from "../api" import { type OperatorTab, useTabRoute } from "../lib/route" import hyperfxLogo from "../assets/hyperfx-logo.webp" import { CopyHash } from "../components/CopyHash" -import { ActivityIcon, LogsIcon, OperationsIcon, OverviewIcon, SettingsIcon, WalletIcon } from "../components/InterfaceIcons" +import { + ActivityIcon, + ChartLineIcon, + LogsIcon, + OperationsIcon, + OverviewIcon, + SettingsIcon, + WalletIcon, +} from "../components/InterfaceIcons" import { OperatorSheet } from "../components/OperatorSheet" import { InstallAppButton } from "../components/InstallAppButton" import { useAction, useIsHandheld, usePolling } from "../lib/hooks" import type { AdminStrategyDto, BalanceSnapshot, ConfigDto, StatusOperator } from "../types" +import { LimitOrders } from "./LimitOrders" +import { marketSymbols } from "./markets/marketModel" import { Orders } from "./Orders" import { Operations, type OperationsPanel } from "./Operations" import { Logs } from "./Logs" @@ -25,6 +35,7 @@ const PAGE_TABS: Array<{ desktopOnly?: true }> = [ { value: "overview", label: "Overview", description: "Health and liquidity", icon: OverviewIcon }, + { value: "limit-orders", label: "Limit orders", description: "What you are offering", icon: ChartLineIcon }, { value: "orders", label: "Orders", description: "History and bids", icon: ActivityIcon }, { value: "wallet", label: "Wallet", description: "Funds and history", icon: WalletIcon }, { value: "logs", label: "Logs", description: "Live filler output", icon: LogsIcon, desktopOnly: true }, @@ -37,6 +48,11 @@ const PAGE_COPY: Record void }) /> ) : null} + {tab === "limit-orders" ? ( + + ) : null} {tab === "orders" ? : null} {tab === "logs" && !handheld ? : null} {tab === "wallet" ? ( diff --git a/sdk/packages/simplex/ui/src/operator/limitOrders/CreateLimitOrderForm.tsx b/sdk/packages/simplex/ui/src/operator/limitOrders/CreateLimitOrderForm.tsx new file mode 100644 index 000000000..a094a5c81 --- /dev/null +++ b/sdk/packages/simplex/ui/src/operator/limitOrders/CreateLimitOrderForm.tsx @@ -0,0 +1,184 @@ +import { useState } from "react" +import { AppSelect } from "../../components/AppSelect" +import type { CreateLimitOrderRequest } from "../../types" +import { ApiError } from "../../api" + +interface Draft { + fillChain: string + tokenIn: string + amountIn: string + tokenOut: string + amountOut: string + acceptedSources: string[] +} + +/** A decimal amount in whole tokens, which is what the operator states and the handler scales. */ +const AMOUNT = /^[0-9]+(\.[0-9]+)?$/ + +export function CreateLimitOrderForm(props: { + symbols: string[] + chains: string[] + chainLabel: (id: string) => string + onCreated: () => Promise | void + onCancel: () => void + create: (request: CreateLimitOrderRequest) => Promise +}) { + const { symbols, chains, chainLabel, onCreated, onCancel, create } = props + const [draft, setDraft] = useState(() => ({ + fillChain: chains[0] ?? "", + tokenIn: symbols[0] ?? "", + amountIn: "", + tokenOut: symbols.find((symbol) => symbol !== symbols[0]) ?? "", + amountOut: "", + // A swap can reach the fill chain from anywhere the filler watches, and an + // order that accepts nothing is one the orderbook refuses outright. + acceptedSources: chains, + })) + const [busy, setBusy] = useState(false) + const [error, setError] = useState() + const patch = (changes: Partial) => setDraft((current) => ({ ...current, ...changes })) + + const amountsValid = AMOUNT.test(draft.amountIn) && AMOUNT.test(draft.amountOut) + const ready = + amountsValid && + Number(draft.amountIn) > 0 && + Number(draft.amountOut) > 0 && + draft.tokenIn !== draft.tokenOut && + draft.fillChain !== "" && + draft.acceptedSources.length > 0 + + // What the two amounts imply, which is the number the operator is really + // choosing: they state what they take and what they pay, not a rate. + const rate = + amountsValid && Number(draft.amountIn) > 0 + ? (Number(draft.amountOut) / Number(draft.amountIn)).toLocaleString(undefined, { maximumFractionDigits: 6 }) + : null + + const submit = async () => { + if (!ready || busy) return + setBusy(true) + setError(undefined) + try { + await create({ + fillChain: draft.fillChain, + tokenIn: draft.tokenIn, + amountIn: draft.amountIn, + tokenOut: draft.tokenOut, + amountOut: draft.amountOut, + acceptedSources: draft.acceptedSources, + }) + await onCreated() + } catch (err) { + setError(err instanceof ApiError ? err.message : "Could not create the limit order") + } finally { + setBusy(false) + } + } + + const toggleSource = (chain: string) => + patch({ + acceptedSources: draft.acceptedSources.includes(chain) + ? draft.acceptedSources.filter((source) => source !== chain) + : [...draft.acceptedSources, chain], + }) + + return ( +
+
+
+ What you are offering +

State the two amounts

+

+ Amounts are whole tokens, as you would say them out loud. The rate is whatever the two imply, and + the order fills at it until it runs out. +

+
+
+ +
+ + +
+ + {rate && draft.tokenIn !== draft.tokenOut ? ( +

+ That is {rate} {draft.tokenOut} per {draft.tokenIn}. +

+ ) : null} + {draft.tokenIn === draft.tokenOut ?

Choose two different tokens.

: null} + +
+ Fills on + ({ value: chain, label: chainLabel(chain) }))} + onValueChange={(value) => patch({ fillChain: value })} + /> + Where you hold the token you are paying out. +
+ +
+ Accepts swaps from +
+ {chains.map((chain) => ( + + ))} +
+ {draft.acceptedSources.length === 0 ? ( +

Name at least one source chain, or the orderbook refuses the order.

+ ) : null} +
+ + {error ?

{error}

: null} + +
+ + +
+
+ ) +} diff --git a/sdk/packages/simplex/ui/src/operator/limitOrders/limitOrderModel.test.ts b/sdk/packages/simplex/ui/src/operator/limitOrders/limitOrderModel.test.ts new file mode 100644 index 000000000..1fb7cef81 --- /dev/null +++ b/sdk/packages/simplex/ui/src/operator/limitOrders/limitOrderModel.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest" +import type { LimitOrder } from "../../types" +import { available, describeExpiry, describeRate, fromScaled, legs, statusOf } from "./limitOrderModel" + +const ONE = 10n ** 18n + +function order(overrides: Partial = {}): LimitOrder { + return { + id: "limit-0", + book: "USDC/CNGN", + base: "USDC", + quote: "CNGN", + side: "BID", + fillChain: "EVM-8453", + price: (1500n * ONE).toString(), + size: (1_500_000n * ONE).toString(), + remaining: (1_500_000n * ONE).toString(), + reserved: "0", + acceptedSources: ["EVM-1"], + ttlSecs: 900, + expiresAt: null, + status: "open", + commitment: "0xabc", + orderNonce: "0", + bookExpiresAt: null, + bookPrice: null, + lastError: null, + createdAt: "2026-09-19 10:00:00", + updatedAt: "2026-09-19 10:00:00", + ...overrides, + } as LimitOrder +} + +describe("reading a limit order back", () => { + it("shows whole tokens, not the orderbook's 1e18", () => { + // What the operator typed is what they should read back. + expect(fromScaled((1_500_000n * ONE).toString())).toBe("1,500,000") + expect(fromScaled((ONE / 2n).toString())).toBe("0.5") + expect(fromScaled((1000n * ONE + ONE / 4n).toString())).toBe("1,000.25") + }) + + it("says which side is taken in and which is paid out", () => { + expect(legs(order())).toEqual({ input: "USDC", output: "CNGN" }) + expect(legs(order({ side: "ASK" }))).toEqual({ input: "CNGN", output: "USDC" }) + }) + + it("states the rate the two amounts imply", () => { + expect(describeRate(order())).toBe("1,500 CNGN per USDC") + }) + + it("counts what live bids are holding out of what is left", () => { + const held = order({ remaining: (1_000_000n * ONE).toString(), reserved: (400_000n * ONE).toString() }) + expect(available(held)).toBe(600_000n * ONE) + // Never negative: a reservation can briefly outrun a draw-down. + expect(available(order({ remaining: "0", reserved: ONE.toString() }))).toBe(0n) + }) +}) + +describe("what the operator sees at a glance", () => { + it("separates an order on the book from one still posting", () => { + expect(statusOf(order())).toMatchObject({ label: "On the book", tone: "ok" }) + expect(statusOf(order({ commitment: null }))).toMatchObject({ label: "Posting", tone: "warn" }) + }) + + it("keeps a refusal visible on an order that is otherwise open", () => { + // The row stays open and the next cycle tries again, so the status alone + // would not tell the operator the orderbook turned it down. + const refused = statusOf(order({ lastError: "TTL_TOO_SHORT: minimum is 900" })) + expect(refused).toMatchObject({ label: "On the book", tone: "warn" }) + expect(refused.detail).toContain("TTL_TOO_SHORT") + }) + + it("names the terminal states plainly", () => { + expect(statusOf(order({ status: "expired" })).label).toBe("Expired") + expect(statusOf(order({ status: "cancelled" })).label).toBe("Cancelled") + expect(statusOf(order({ status: "filled" }))).toMatchObject({ label: "Filled", tone: "ok" }) + }) +}) + +describe("the expiry clock", () => { + const now = Date.parse("2026-09-19T10:00:00.000Z") + + it("counts down in the units an operator thinks in", () => { + expect(describeExpiry("2026-09-19T10:12:00.000Z", now)).toBe("in 12m") + expect(describeExpiry("2026-09-19T12:05:00.000Z", now)).toBe("in 2h 5m") + expect(describeExpiry("2026-09-19T10:00:30.000Z", now)).toBe("in 30s") + }) + + it("says so once the order is past it", () => { + expect(describeExpiry("2026-09-19T09:59:00.000Z", now)).toBe("expired") + }) + + it("has nothing to say about an order with no expiry", () => { + expect(describeExpiry(null, now)).toBeNull() + }) +}) diff --git a/sdk/packages/simplex/ui/src/operator/limitOrders/limitOrderModel.ts b/sdk/packages/simplex/ui/src/operator/limitOrders/limitOrderModel.ts new file mode 100644 index 000000000..2cf279d4d --- /dev/null +++ b/sdk/packages/simplex/ui/src/operator/limitOrders/limitOrderModel.ts @@ -0,0 +1,86 @@ +import type { LimitOrder } from "../../types" + +/** The orderbook keeps every amount and price at 1e18, whatever the token's own decimals. */ +const SCALE = 10n ** 18n + +/** + * A 1e18 figure as whole tokens, trimmed of trailing zeros. + * + * The operator states amounts in whole tokens and reads them back the same way: + * what the orderbook normalises to is not their problem. + */ +export function fromScaled(value: string, maxFraction = 6): string { + let raw: bigint + try { + raw = BigInt(value) + } catch { + return value + } + const whole = raw / SCALE + const fraction = (raw % SCALE) + .toString() + .padStart(18, "0") + .slice(0, maxFraction) + .replace(/0+$/, "") + const grouped = whole.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",") + return fraction ? `${grouped}.${fraction}` : grouped +} + +/** Which symbol the order takes in and which it pays out, from the side it sits on. */ +export function legs(order: Pick): { input: string; output: string } { + return order.side === "BID" ? { input: order.base, output: order.quote } : { input: order.quote, output: order.base } +} + +/** "1,500 CNGN per USDC" — the rate as the operator stated it. */ +export function describeRate(order: Pick): string { + return `${fromScaled(order.price)} ${order.quote} per ${order.base}` +} + +/** How much of what the order promised is still available to a swapper. */ +export function available(order: Pick): bigint { + try { + const left = BigInt(order.remaining) - BigInt(order.reserved) + return left > 0n ? left : 0n + } catch { + return 0n + } +} + +export type Tone = "" | "ok" | "warn" | "err" + +/** + * What the operator needs to know at a glance, which is not quite the status + * column: an order can be `open` and still not be on the book, either because + * its posting is in flight or because the last one was refused. + */ +export function statusOf(order: LimitOrder): { label: string; tone: Tone; detail?: string } { + if (order.status === "cancelled") return { label: "Cancelled", tone: "" } + if (order.status === "expired") return { label: "Expired", tone: "" } + if (order.status === "filled") return { label: "Filled", tone: "ok" } + if (order.status === "rejected") { + return { label: "Refused", tone: "err", detail: order.lastError ?? undefined } + } + if (order.status === "resizing") return { label: "Resizing", tone: "warn", detail: "a fill is being settled" } + if (order.lastError) return { label: "On the book", tone: "warn", detail: order.lastError } + if (!order.commitment) return { label: "Posting", tone: "warn", detail: "not on the book yet" } + return { label: "On the book", tone: "ok" } +} + +/** Seconds until the order's own expiry, negative once it has passed. */ +export function secondsLeft(expiresAt: string | null, now = Date.now()): number | null { + if (!expiresAt) return null + const at = Date.parse(expiresAt) + return Number.isNaN(at) ? null : Math.round((at - now) / 1000) +} + +/** "in 12m", "in 2h 5m", "expired" — the clock an operator reads before topping up. */ +export function describeExpiry(expiresAt: string | null, now = Date.now()): string | null { + const left = secondsLeft(expiresAt, now) + if (left === null) return null + if (left <= 0) return "expired" + const hours = Math.floor(left / 3600) + const minutes = Math.floor((left % 3600) / 60) + if (hours > 0) return `in ${hours}h ${minutes}m` + if (minutes > 0) return `in ${minutes}m` + return `in ${left}s` +} diff --git a/sdk/packages/simplex/ui/src/operator/limitOrders/useLimitOrders.ts b/sdk/packages/simplex/ui/src/operator/limitOrders/useLimitOrders.ts new file mode 100644 index 000000000..33d73fadd --- /dev/null +++ b/sdk/packages/simplex/ui/src/operator/limitOrders/useLimitOrders.ts @@ -0,0 +1,72 @@ +import { useCallback, useEffect, useRef, useState } from "react" +import { api, ApiError } from "../../api" +import type { CreateLimitOrderRequest, LimitOrder, LimitOrderStatus, StoredBid } from "../../types" + +/** How often the list refreshes itself: a posting lands, expires or is filled without the operator acting. */ +const POLL_MS = 10_000 + +export interface LimitOrderFills { + order: LimitOrder + fills: StoredBid[] +} + +/** + * The operator's limit orders, kept current while the page is open. + * + * Every mutation reloads rather than patching in place: creating an order posts + * it to the orderbook, and what comes back — a commitment, a refusal on the row + * — is the server's answer rather than anything this can predict. + */ +export function useLimitOrders(options: { status?: LimitOrderStatus | "" } = {}) { + const { status = "" } = options + const [orders, setOrders] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState() + // A reload that lands after the component is gone must not set state. + const live = useRef(true) + + const load = useCallback(async () => { + try { + const query = status ? `?status=${encodeURIComponent(status)}` : "" + const body = await api.get<{ orders: LimitOrder[] }>(`/api/limit-orders${query}`) + if (!live.current) return + setOrders(body.orders) + setError(undefined) + } catch (err) { + if (!live.current) return + setError(err instanceof ApiError ? err.message : "Could not read the limit orders") + } finally { + if (live.current) setLoading(false) + } + }, [status]) + + useEffect(() => { + live.current = true + void load() + const timer = setInterval(() => void load(), POLL_MS) + return () => { + live.current = false + clearInterval(timer) + } + }, [load]) + + const create = useCallback( + async (request: CreateLimitOrderRequest) => { + await api.post("/api/limit-orders", request) + await load() + }, + [load], + ) + + const cancel = useCallback( + async (id: string) => { + await api.del(`/api/limit-orders/${id}`) + await load() + }, + [load], + ) + + const withFills = useCallback((id: string) => api.get(`/api/limit-orders/${id}`), []) + + return { orders, loading, error, reload: load, create, cancel, withFills } +} diff --git a/sdk/packages/simplex/ui/src/styles/operator.css b/sdk/packages/simplex/ui/src/styles/operator.css index 47f1d73fb..558404651 100644 --- a/sdk/packages/simplex/ui/src/styles/operator.css +++ b/sdk/packages/simplex/ui/src/styles/operator.css @@ -2429,3 +2429,65 @@ button.operator-runtime-stop:hover:not(:disabled) { justify-content: flex-end; gap: 0.75rem; } + +/* + * Limit orders: the operator's standing offers. The rows reuse the market row, + * so what is new here is the detail sheet's fact list, the source-chain + * checkboxes on the create form, and the fills behind a shrunken `remaining`. + */ +.limit-order-facts { + display: grid; + gap: 0.75rem; + margin: 0 0 1rem; +} + +.limit-order-facts > div { + display: grid; + grid-template-columns: minmax(8rem, 12rem) 1fr; + gap: 0.5rem 1rem; + align-items: baseline; +} + +.limit-order-facts dt { + color: var(--text-dim); + font-size: 0.85rem; +} + +.limit-order-facts dd { + margin: 0; +} + +.limit-order-sources { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1rem; +} + +.limit-order-source { + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +.limit-order-fills { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.4rem; +} + +.limit-order-fills li { + display: flex; + justify-content: space-between; + gap: 1rem; + font-size: 0.9rem; +} + +/* The two columns stack before the sheet does, since the labels are wordy. */ +@media (max-width: 30rem) { + .limit-order-facts > div { + grid-template-columns: 1fr; + gap: 0.15rem; + } +} diff --git a/sdk/packages/simplex/ui/src/types.ts b/sdk/packages/simplex/ui/src/types.ts index 06146c72e..712ca685e 100644 --- a/sdk/packages/simplex/ui/src/types.ts +++ b/sdk/packages/simplex/ui/src/types.ts @@ -7,6 +7,10 @@ export type { InitChainMeta as ChainDefault, InitNetwork as Network } from "@/cl export type { FillerConfigFile as FillerConfig } from "@/config/filler-toml" export type { CurvePoint } from "@/config/interpolated-curve" export type { PairConfig } from "@/config/pairs" +// The limit-order routes answer with the stored rows themselves rather than a +// DTO, so the browser reads the same shape the store writes. +export type { LimitOrder, LimitOrderSide, LimitOrderStatus, StoredBid } from "@/data/types" +export type { CreateLimitOrderRequest } from "@/orderbook/limit-orders" export type { ActivityEventDto, OrderHistoryDto,