Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion sdk/packages/simplex/ui/src/lib/route.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -10,6 +10,7 @@ export type OperatorTab = "overview" | "orders" | "wallet" | "logs" | "operation
*/
export const TAB_PATHS: Record<OperatorTab, string> = {
overview: "/",
"limit-orders": "/limit-orders",
orders: "/orders",
wallet: "/wallet",
logs: "/logs",
Expand Down
269 changes: 269 additions & 0 deletions sdk/packages/simplex/ui/src/operator/LimitOrders.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string>
/** 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<LimitOrder>()
const [detail, setDetail] = useState<LimitOrderFills>()
const [busy, setBusy] = useState(false)
const [actionError, setActionError] = useState<string>()

// 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 (
<>
<section className="operator-section">
<div className="operator-section-heading">
<div>
<span className="eyebrow">Pricing</span>
<h2>Limit orders</h2>
</div>
<button type="button" className="operator-text-button" onClick={() => setCreating(true)}>
+ New limit order
</button>
</div>

{error ? <p className="error">{error}</p> : null}

<div className="operator-market-list">
{live.map((order) => (
<LimitOrderRow key={order.id} order={order} chainLabel={chainLabel} onOpen={() => void open(order)} />
))}
{live.length === 0 && !loading ? (
<p className="operator-empty">
Nothing is resting on the orderbook. Post a limit order to say what simplex will pay.
</p>
) : null}
</div>

{closed.length > 0 ? (
<>
<div className="operator-section-heading">
<div>
<span className="eyebrow">Closed</span>
</div>
</div>
<div className="operator-market-list">
{closed.map((order) => (
<LimitOrderRow
key={order.id}
order={order}
chainLabel={chainLabel}
onOpen={() => void open(order)}
/>
))}
</div>
</>
) : null}
</section>

<OperatorSheet
open={creating}
onClose={() => 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."
>
<CreateLimitOrderForm
symbols={symbols}
chains={chainOptions.map((chain) => chain.stateMachineId)}
chainLabel={chainLabel}
create={create}
onCreated={() => setCreating(false)}
onCancel={() => setCreating(false)}
/>
</OperatorSheet>

<OperatorSheet
open={selected !== undefined}
onClose={() => setSelected(undefined)}
title={selected ? `${selected.base}/${selected.quote}` : "Limit order"}
description={selected ? describeRate(selected) : undefined}
>
{selected ? (
<LimitOrderDetail
order={detail?.order ?? selected}
fills={detail?.fills ?? []}
chainLabel={chainLabel}
busy={busy}
error={actionError}
onCancel={() => void cancelSelected()}
onRefresh={() => void reload()}
/>
) : null}
</OperatorSheet>
</>
)
}

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 (
<button type="button" className="operator-market-row" onClick={onOpen}>
<TokenPairIcons tokenA={input} tokenB={output} />
<span className="operator-market-copy">
<strong>
{fromScaled(order.remaining)} {output} left at {describeRate(order)}
</strong>
<small>
takes {input} on {chainLabel(order.fillChain)}
{expiry ? ` · ${expiry}` : ""}
</small>
</span>
<span className={`badge ${status.tone}`}>{status.label}</span>
<ChevronRightIcon aria-hidden="true" />
</button>
)
}

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 (
<section className="operator-panel-form">
<dl className="limit-order-facts">
<div>
<dt>Status</dt>
<dd>
<span className={`badge ${status.tone}`}>{status.label}</span>
{status.detail ? <small> {status.detail}</small> : null}
</dd>
</div>
<div>
<dt>Rate</dt>
<dd>{describeRate(order)}</dd>
</div>
<div>
<dt>Pays out</dt>
<dd>
{fromScaled(order.remaining)} {output} left of {fromScaled(order.size)}
</dd>
</div>
<div>
<dt>Held by live bids</dt>
<dd>
{fromScaled(order.reserved)} {output}, leaving {fromScaled(available(order).toString())} to draw on
</dd>
</div>
<div>
<dt>Takes in</dt>
<dd>
{input} on {chainLabel(order.fillChain)}
</dd>
</div>
<div>
<dt>Accepts swaps from</dt>
<dd>{order.acceptedSources.map(chainLabel).join(", ")}</dd>
</div>
<div>
<dt>Expires</dt>
<dd>{describeExpiry(order.expiresAt) ?? "does not expire"}</dd>
</div>
</dl>

{order.lastError ? <p className="error">{order.lastError}</p> : null}
{error ? <p className="error">{error}</p> : null}

<h3>Fills</h3>
{fills.length === 0 ? (
<p className="hint">Nothing has drawn on this order yet.</p>
) : (
<ul className="limit-order-fills">
{fills.map((fill) => (
<li key={fill.commitment}>
<span>{formatDate(sqliteUtcToMs(fill.createdAt))}</span>
<span>{fill.success ? "filled" : (fill.error ?? "failed")}</span>
</li>
))}
</ul>
)}

<footer className="market-dialog-footer">
<button type="button" onClick={onRefresh} disabled={busy}>
Refresh
</button>
{!closed ? (
<button type="button" className="market-delete-button" onClick={onCancel} disabled={busy}>
{busy ? "Cancelling…" : "Cancel order"}
</button>
) : null}
</footer>
</section>
)
}
25 changes: 24 additions & 1 deletion sdk/packages/simplex/ui/src/operator/Operator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 },
Expand All @@ -37,6 +48,11 @@ const PAGE_COPY: Record<Tab, { eyebrow: string; title: string; description: stri
title: "Overview",
description: "Monitor liquidity, market coverage, and the health of your running filler.",
},
"limit-orders": {
eyebrow: "Your book",
title: "Limit orders",
description: "Post what simplex will pay, and watch what is left of each order as fills draw it down.",
},
orders: {
eyebrow: "Execution feed",
title: "Orders",
Expand Down Expand Up @@ -217,6 +233,13 @@ export function Operator(props: { status: StatusOperator; refresh: () => void })
/>
) : null}

{tab === "limit-orders" ? (
<LimitOrders
chains={status.chains}
chainLabels={status.chainLabels}
symbols={marketSymbols(config)}
/>
) : null}
{tab === "orders" ? <Orders chainLabels={status.chainLabels} /> : null}
{tab === "logs" && !handheld ? <Logs /> : null}
{tab === "wallet" ? (
Expand Down
Loading
Loading