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
1 change: 1 addition & 0 deletions sdk/packages/sdk/src/protocols/intents/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export {
readRateFillCapability,
UNISWAP_QUOTE_HAIRCUT_BPS,
PERMIT2_SPONSORSHIP_BYTES,
MAX_DECLARED_ENTRIES,
type PhantomBidDeclaration,
type PhantomBidPaymasterAndData,
type PhantomBidSponsorship,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions sdk/packages/simplex-desktop/scripts/e2e/desktop.e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@ function operatorFixture(socketPath, options = {}) {
},
pairs: [],
chains: [],
orderbook: { url: "https://orderbook.example/graphql" },
}
const operator = {
strategies: [],
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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", {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 11 additions & 0 deletions sdk/packages/simplex/filler-config-example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions sdk/packages/simplex/src/bin/simplex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion sdk/packages/simplex/src/cli/init/emit-toml.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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]")
Expand Down
6 changes: 6 additions & 0 deletions sdk/packages/simplex/src/cli/init/migrate-legacy.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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
}
Expand Down
6 changes: 6 additions & 0 deletions sdk/packages/simplex/src/config/defaults.ts
Original file line number Diff line number Diff line change
@@ -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"
54 changes: 54 additions & 0 deletions sdk/packages/simplex/src/config/filler-toml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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
Comment thread
Wizdave97 marked this conversation as resolved.
requestTimeoutMs?: number
}

/**
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading