Somnia Markets / TypeScript SDK
The exchange API
The SomniaMarkets class is the SDK's primary surface: markets keyed by symbols,
fetch*/watch*/createOrder verbs, human-unit structs — the idioms every
exchange bot already speaks (if you've driven a venue through ccxt, nothing
here will surprise you, down to the field names). Under the hood it runs on
the native engine (scoped watches, local books, one-round-trip writes), so the
watch* channels are zero-round-trip and createOrder confirms in a single
round-trip.
import { SomniaMarkets } from "@somnia-chain/markets-sdk";
const exchange = new SomniaMarkets({ indexerUrl, chain, wsRpcUrl, privateKey }); // Use `chains` to configure multiple chains.
await exchange.loadMarkets();
const symbol = "BTC-95000-31DEC26/USDC#YES";
const book = await exchange.watchOrderBook(symbol, 10); // live, zero RTT
const order = await exchange.createOrder(symbol, "limit", "buy", 10, 0.62);
// … later:
await exchange.cancelOrder(order.id, symbol);
The raw engine stays available at exchange.client on a single-chain
exchange, and at exchange.engineFor(chainId) on any exchange — bigint-exact,
address-keyed — for anything the exchange verbs don't cover; see
the engine guide.
Symbols
A symbol names a market; a tradable symbol names something you place orders on. For spot they coincide; for outcome markets each outcome is its own tradable:
| Kind | Market symbol | Tradables |
|---|---|---|
| Spot | SOMI/USDC |
SOMI/USDC |
| Binary | BTC-95000-31DEC26/USDC |
…#YES, …#NO |
| Perp | BTC/USDSO:USDSO |
BTC/USDSO:USDSO |
| Categorical (soon) | US-ELECTION-28/USDC |
…#TRUMP, …#NEWSOM, … |
Binary symbols are synthesized as ASSET-STRIKE-DDMONYY/QUOTE (quote = the
collateral's ERC-20 symbol), with an -HHMM expiry component for intraday
series markets (ETH-171780-03JUL26-0930/TUSDC); any residual collision gets
a deterministic 4-hex market-id tiebreaker on the base side of the slash. Everything before # follows standard exchange symbol conventions; #OUTCOME
is our extension (binary markets need one). Any raw ref works wherever a symbol does — a pool address,
market id, or BinaryMarket address resolves to the same tradable (outcome
markets default to #YES).
Numbers are human units in the tradable's own terms: a #NO price of 0.38
is the NO probability — the YES-terms complement, raw integers, and escrow
math are all internal. Raw payloads ride on every struct's info.
Verbs
| Method | Backed by | Notes |
|---|---|---|
loadMarkets() / fetchMarkets() |
indexer (+ one-time symbol() reads) |
builds the symbol registry; a failed registry read is contained and reported by registryReadError, not thrown |
fetchOrderBook(symbol, limit?) |
chain | head-fresh one-shot |
fetchTrades(symbol, since?, limit?) |
indexer | public tape |
fetchOHLCV(symbol, "5m", since?, limit?) |
indexer | 1m 5m 15m 1h 4h 1d |
fetchOpenOrders(symbol?, limit?) |
indexer | authenticated; limit is per venue (default 200) |
fetchMyTrades(symbol?, since?, limit?) |
indexer | authenticated; scoped + windowed at the query, newest-first across venues |
fetchBalance() |
chain | wallet balances by currency code (incl. binary YES/NO ERC-6909 holdings, keyed by tradable symbol) |
fetchStatus() |
local | watch/socket health |
watchOrderBook(symbol, limit?) |
local store | zero RTT, current to last block |
watchTrades(symbol) · watchOrders(symbol) · watchMyTrades(symbol) |
local store | streaming |
createOrder(symbol, type, side, amount, price?, params?) |
chain write | 1-RTT confirm, fills decoded |
cancelOrder(id, symbol) |
chain write | |
mintSet / burnSet / redeem (symbol, amount) |
chain write | outcome markets only |
fetchFundingRate(symbol) |
chain | perps: mark/index + funding rate |
fetchPositions(symbols?) |
chain | perps: open MarginBank positions |
depositMargin / withdrawMargin (symbol, amount) |
chain write | perps: cross-margin collateral |
watchPrice(asset) · fetchPrice(asset) |
price feed | index price (EMA oracle) — see Price feeds |
fetchPriceOHLCV(asset, "1m") |
price feed | index candles: 1m/1h/1d |
fetchTicker(symbol) |
indexer; best-effort chain read on perps | last, 24h change and volume; the two halves fail independently and each reports its own state. A failed candle read, or a market with indexed: false, gives statsState: "unavailable" with every 24h field absent. A failed getPerpState gives perpState: "degraded" with the 24h half intact |
fetchOrders(symbol?, since?, limit?, { offset? }) |
indexer | authenticated; order history as one offset-paged set |
getOrderHistoryPage({ ref?, status?, limit?, offset? }) |
indexer | authenticated; preferred indexed-history name; partial raw-row page with immutable identity and continuation |
getOrdersPage({ ref?, status?, limit?, offset? }) |
indexer | compatibility name for getOrderHistoryPage |
fetchPortfolioAnalytics(timeframe, params?) |
indexer | authenticated; PnL, equity and holdings series |
createStopOrder(symbol, type, side, amount, triggerPrice, price?, params?) |
chain write | spot only; see Spot markets |
fetchOpenStopOrders(symbol?) · cancelStopOrder(id, symbol) |
indexer · chain write | spot stop orders |
fetchFundingRateHistory(symbol, since?, limit?) |
indexer | perps: funding-rate history |
setSigner({ privateKey | account | walletClient }) |
local | bind or replace the signer after construction |
priceToPrecision(ref, price) · amountToPrecision(ref, amount) |
local | snap a value down onto the market's tick or lot grid |
market(ref) |
local | resolve any handle to its tradable |
unwatchMarket(symbol) |
local | release one market's watch and channels; pending watch* calls on it reject with AbortError |
close() |
local | release all watches, channels, and sockets; a Node script exits on its own afterwards |
Still reserved: watchPositions, setLeverage (the raw
exchange.traderFor(chainId).setPerpLeverage exists for the latter).
For account fill history, exchange.engineFor(chainId).getUserFillsPage uses bounded
opaque-cursor pages. Its datasetGeneration comes from the serving Fill database;
continuation across a reindex or blue/green cutover throws a typed restart-required
error when the replacement's hashed producer inputs differ. Handler changes that
affect stored fills require a reindex; a code-only resume keeps the existing
generation. See Fill history pages and dataset restart
before retaining pages across requests. To keep a whole history current between
reads, getUserFillHistory reads again only a recent window; see
Keep a held history current.
Paginated order identity
getOrderHistoryPage reads one bounded page for the configured account. Use it
when the caller must retain the chain, account, market, pool, and order id
together. getOrdersPage remains available as a compatibility name. The
default limit is 100 and the default offset is 0. A status filter runs in the
indexer. A ref also scopes the query to its pool, then verifies each raw market
id so a recycled binary pool cannot cross market lifetimes.
rowsRead counts raw indexer rows. nextOffset advances after a full raw page,
even when outcome filtering removes every display order or every identity is
unresolved. The unresolved list keeps rows whose immutable market id is absent
or inconsistent in the loaded registry. A null binary side remains on the YES
book. A NO scope excludes that row.
Every result has coverage: "partial" and source: "indexer-offset". These are
indexed mutable records. They are not an immutable lifecycle log or a current
Open Orders snapshot. The indexer orders rows by placement time without a frozen
revision or a unique tie-break. Concurrent inserts or status changes can make
pages skip or repeat rows. A short page does not prove complete account state.
The status option filters indexed history. status: "Open" cannot establish
completeness, removals, continuity, or action freshness. Use a separately
supported current-state flow when the caller needs the SDK's current open-order
view. Each flow retains its documented freshness limits.
let offset = 0;
while (true) {
const page = await exchange.getOrderHistoryPage({ limit: 100, offset });
for (const order of page.orders) console.log(order.identity, order.status);
if (page.nextOffset === null) break;
offset = page.nextOffset;
}
Portfolio analytics fill pages
Portfolio analytics needs the full indexed fill history to calculate PnL.
fetchPortfolioAnalytics reads the account's indexed SPOT fill history through
cursor pages of up to 999 rows. The indexer applies the SPOT filter, so binary
and perp fills are not transferred. The method reads all configured chains at
the same time, with one page in flight per chain. A short nonempty page triggers a one-row
continuation probe, so a positive Hasura row cap smaller than 1,000 cannot
silently end the traversal. It computes PnL only after the final page.
Its default is an exhaustive traversal of the rows visible to the configured
indexer role, including older fills that establish the cost basis for recent
sells. The deployment must establish a row cap of at least one; a zero cap
is indistinguishable from an empty history through the same role.
Set params.maxFillPages to a positive safe integer to bound logical pages for one call.
The budget counts pages across all chains.
Each page may use two indexer requests when a continuation probe is needed.
If more history remains, the method throws IncompleteHistoryError with
reason: "pageBudgetExceeded" and pagesRead instead of returning plausible
but incomplete PnL. A repeated cursor throws the same class with
reason: "cursorCycle". IndexerError still signals a failed indexer request.
A failure on one chain rejects the call and stops the other chains at their next page.
On a multi-chain exchange, the IndexerError carries the chainId that failed.
Each call starts with a fresh first-page read;
an earlier empty result is not cached and cannot hide a newly indexed fill.
Cursor continuation assumes an unchanged indexer dataset during the call;
it is not a reindex generation or chain-head completeness proof.
The watch* idiom
Each await resolves when the channel next changes; the first call resolves
immediately after the (ref-counted) market watch hydrates:
while (running) {
const book = await exchange.watchOrderBook(symbol, 5); // resolves per change
requote(book.bids[0], book.asks[0]);
}
"Did my resting order fill?" — watch your orders and look for the status flip:
const orders = await exchange.watchOrders(symbol); // resolves per change
const mine = orders.find((o) => o.id === orderId);
if (mine?.status === "closed") … // filled
createOrder params
type: "limit" | "market"— market orders compute a crossing limit from the best opposite level ±params.slippage(default 1% on this surface; the engine'squoteBinaryStake/quoteBinarySellhelpers default to 3% with a 10-tick floor) and send IOC; they throwInvalidInputErrorif the opposite side is empty. When the chain rejects an order you get aContractRevertErrorwhoseerrorNameis the contract's own error — see ENGINE.md § Errors.params.timeInForce: "GTC" | "IOC" | "FOK" | "PO"(orpostOnly: true) — maps to the on-chain order types.params.builder/params.builderFeeBpsTimes1k— attribute the order to a routing/builder frontend and pay it a per-order fee (pool bps×1000 unit). Requires a prior one-timeexchange.traderFor(chainId).approveBuilder({ pool, builder, maxFeeBpsTimes1k })opt-in on the pool's chain (for exampleexchange.traderFor(50383)for a Hideki pool), else a non-zero fee reverts (BuilderFeeExceedsCapon binary,BuilderNotApprovedon spot,BuilderFeeExceedsApprovalon perp). Works on binary, spot and perp — but the approval is stored per pool, so opt in on each pool you attribute orders on.params.funding: "explicit" | "auto" | "skip"— what to do when the order's collateral is not on the market's chain (a multi-chain exchange only; see below)."explicit"throwsCollateralOnWrongChainErrorwith a ready bridge plan and sends nothing;"auto"bridges the shortfall the wayexchange.ensureFundsdoes, for the escrow token found by address, waits for delivery, then places;"skip"runs no balance check. The default is"explicit"once the exchange spans a second chain and"skip"on a single-chain exchange.- The returned order:
status: "open"(rested),"closed"(fully filled in the same round-trip —filled/remainingsay how much), or"canceled"(IOC remainder).infocarries the receipt + raw fills, andchainIdnames the chain it was sent on.
One exchange, several chains
A market is unique to one chain, so a symbol names exactly one
(chainId, market). Configure every chain the account trades on and the
exchange builds one engine per binding: reads merge, writes route to the
market's chain, and every struct carries its chainId. Do not mix mainnet and
testnet chains in one exchange. The example pairs the two testnets.
import { SomniaMarkets, SOMNIA_HIDEKI_TESTNET_ADDRESSES, SOMNIA_TESTNET_ADDRESSES } from "@somnia-chain/markets-sdk";
import { hidekiTestnet, somniaShannon } from "@somnia-chain/markets-sdk/chains";
const exchange = new SomniaMarkets({
chains: [
{
chain: somniaShannon,
indexerUrl: "https://dev.smk.somnia.host/v1/graphql",
addresses: SOMNIA_TESTNET_ADDRESSES,
},
// Hideki has no hosted indexer. Indexed reads need an indexer you operate.
{ chain: hidekiTestnet, indexerUrl: hidekiIndexerUrl, addresses: SOMNIA_HIDEKI_TESTNET_ADDRESSES },
],
account: safeAccount, // one address on every chain, one executor per chain
});
await exchange.loadMarkets();
const bal = await exchange.fetchBalance();
bal.USDso?.total; // summed across chains
bal.USDso?.byChain[hidekiTestnet.id]?.total;
const order = await exchange.createOrder("BTC/USDso:USDso", "limit", "buy", 1, 60_000);
order.chainId; // the chain the market lives on
- The single-chain config is the one-element case and behaves exactly as before.
- Each chain needs its own indexer. Two bindings that name one indexer URL,
written with a different case or a trailing slash, also throw
InvalidInputError. - Each engine checks its endpoints before it uses their data. Its first indexed
read or hydration also reads the indexer's
chain_metadata. The two requests go out together, and no row reaches the caller or the live store until the check passes: an indexer that does not index the binding's chain throwsNotConfiguredError. On Hideki (50383), the first chain read first reads block 1: an RPC on the retired v1 genesis throwsNotConfiguredError. - A bare pool address or market id that exists on two chains (the same CREATE2
pool on L1 and Hideki) is ambiguous: pass
{ chainId, id }— aChainMarketRef— wherever a symbol is accepted, and{ chainId, pool, id }tocancelOrder. - Live and history rows name their chain too.
LiveFill,LiveOrder,LiveFundingUpdate,LiquidationEventandFundingPaymentcarrychainId, the chain of the engine that produced the row. A fill id or an order id can repeat on two chains, so key merged rows on(chainId, id). Price-feed rows are not in that set: a feed is configured separately from the chain, so one endpoint can serve two engines and a tick is not a per-chain row. exchange.clientandexchange.tradername no chain, so they throwInvalidInputErroron an exchange with two or more chains. A pool address or market id can name a different market on each chain. Useexchange.engineFor(chainId)for raw reads andexchange.traderFor(chainId)for raw writes.exchange.watchUser()hydrates the account's history on every chain behind one handle. The chains open at the same time. When one fails, the call stops every handle that opened and throws the error of the first failing chain in binding order.- Collateral must sit on the market's chain.
exchange.bridgeplans and executes transfers between the exchange's chains through the account's executors, andexchange.ensureFunds({ code, chainId, amount })is the helper behindcreateOrder(…, { funding: "auto" }). On a native route (STT), all three throwBridgeLiquidityErrorand send nothing when the destination router holds less than the amount. - A bridge waits for its own Hyperlane message on the destination, matched by
message id. On the Hideki 2 lane the measured median is 7.5 s to 8.7 s per
leg, and the default delivery timeout is 180 s. A router drained after the
liquidity check stalls delivery until it is seeded again. On a timeout,
ensureFundsandcreateOrder(…, { funding: "auto" })throwBridgeDeliveryTimeoutErrorwith themessageIdand place no order. Checkdelivered(messageId)on the destination Mailbox before you bridge again. See the bridge guide. - On one exchange,
ensureFundsandcreateOrder(…, { funding: "auto" })run one at a time per account, chain and currency. An auto-funded order keeps its place in that queue until it is sent. So two concurrent auto-funded orders bridge their combined shortfall once, and neither counts funds the other one needs. Each queued call can wait a full delivery timeout. A leg that timed out stays pending on the exchange. When the next call for the same key finds a shortfall, it waits for that message and sends no new leg for it.close()ends the delivery waits of every call that started before it, as a timeout, and the leg stays pending. This state is in memory only. - The account:
{ address, executors: { [chainId]: Executor } }. Build an executor withengine.createExecutor(spec)— a private key, a wallet, a Somnia session seed, or a smart account with the executor that sends its outer transaction. Any object implementingExecutorworks too, for example for a safe that the accounts factory did not deploy: the SDK decodes the order from the receipt it returns and never fetches it again. When the executor returns an inner failure asfailureon a success-status receipt, Trader writes throw it. - A factory smart account from
@somnia-chain/accounts-sdkis a first-class actor:engine.createExecutor({ smartAccount, inner })batches through the accounts SDK's own MultiSend batcher with no configuration, signs nothing when the inner actor is a direct owner at threshold 1, and reaches an account owned by other accounts throughthrough. The whole lifecycle — derive, create on every chain with one signature, manage owners, move funds — isclient.accountsandexchange.accounts;exchange.accounts.accountFor(accountId, index, { sessionSeed })builds the multi-chain account above in one call. It signs locally from the seed's key, so the seed never goes to a node. A{ sessionSeed }executor, which sends the seed to the node, is refused as an account'sinner. See the Smart accounts guide and Trade through a smart account.
Reads across chains
This section tells you what a read does when one chain of a multi-chain exchange fails, and how to read only the chains that answer. A single-chain exchange behaves as before.
Account reads fail closed. fetchBalance, fetchOpenOrders,
fetchOrders, getOrdersPage, getOrderHistoryPage, fetchMyTrades,
fetchPositions, fetchOpenStopOrders and watchUser read every chain when
you give no market and no chain. When one chain fails, the read throws. It does
not return the rows of the other chains, because a merged result with one chain
missing looks like "the account holds less". On an exchange with two or more
chains, the thrown IndexerError or RpcError has chainId set to the chain
that failed. The SDK sets this field on the original error. The class, the
message and the cause do not change, so instanceof checks still work. A read
scoped to one chain, by a market or by a chain filter, throws its error without
chainId.
Read one chain with a chain filter. Each of these reads accepts a
SomniaMarketsChainFilter ({ chainId }): as the last argument, in params
for fetchOrders, and in the options of getOrdersPage and
getOrderHistoryPage. A filtered read calls only the engine of that chain. Use
it to read each chain on its own and to show a failed chain as failed:
const results = await Promise.allSettled(exchange.chainIds.map((chainId) => exchange.fetchBalance({ chainId })));
- A
chainIdthat the exchange does not span throwsNotConfiguredError. - A market and a
chainIdon different chains throwInvalidInputError, with both chains inchainIds. - Before an account read, the exchange loads markets. That load reads only the
chains that the read touches, so a filtered read never calls another chain.
Of those chains, it reads a chain that no load has read yet. It also reads a
chain whose last load failed, but at most once in 30 seconds after that
chain's last load finished. Between those retries, the read uses the cached
markets, and the failure accessors keep the recorded error and failing chain.
A read that starts while a retry of its chain runs waits for that retry. A
read can also wait while another market load runs, for example a read
filtered to another chain on a new exchange. To avoid this, call
loadMarkets()once before you read chains in parallel.
Chains with no indexer. A chain bound with no indexerUrl (Hideki has no
hosted indexer) keeps the parts of each read that come from the chain.
exchange.chainIdsWithoutIndexer lists these chains, in binding order.
| Read | On a chain with no indexerUrl |
|---|---|
fetchBalance |
ERC-20 and native balances. No binary outcome holdings, because holdings are indexer data |
fetchOpenOrders |
Resting orders read from the chain, over the perp pools that loadMarkets found on that chain. These rows have no txHash and no timestamp |
fetchPositions |
Complete. It needs no indexer |
fetchOrders, getOrdersPage, getOrderHistoryPage, fetchMyTrades, fetchOpenStopOrders, watchUser |
The chain is skipped |
- A skipped chain adds no rows. A missing chain in the result does not mean that the account has no orders or trades there.
- A chain is skipped only when the read also spans a chain with an indexer. A
read scoped to a chain with no indexer, or a read where no chain has an
indexer, throws
NotConfiguredError. It never returns a result that looks empty.
One pool's oracle does not fail fetchPositions. fetchPositions reads
the pool state (mark, index, funding) only for a pool where the account holds a
position. When that read fails, for example because the pool's oracle reverts,
the position still lists with perpState: "degraded", and markPrice and
unrealizedPnl are absent. The other positions are not affected. Branch on
perpState, not on the absence of markPrice. fetchPositions returns its
rows grouped by chain, in binding order.
Health and market loading report each chain. These two surfaces keep answering when one chain fails:
fetchDataStatus()measures each chain on its own.indexerByChainholds every chain that was measured, andnullthere still means that the chain's metadata row is absent.indexerErrorByChainholds the original error of every chain that failed, and is present only when a chain failed.indexer(the primary chain) is absent when the primary chain failed. The call throws only when every chain fails. It then throws the primary chain's error, withchainIdset on an exchange with two or more chains.loadMarkets()does not throw on a failed registry read or a failed perp discovery.registryReadErrorsByChain,perpDiscoveryErrorsByChainandperpDiscoveryPartialFailuresByChainreport the failures of each chain.registryReadError,perpDiscoveryErrorandperpDiscoveryPartialFailurestill report one failure: the first failing chain, in the order of thechainsconfig.- A bare
loadMarkets()reads again each chain that failed on the last load. It also loads each chain that no load has read yet, for example after only reads filtered to another chain. The other chains keep their cached markets. ANotConfiguredErrorfailure, for example the registry read of a chain with no indexer, is not retried.loadMarkets(true)reloads every chain. - A verb that loads markets for you, such as
fetchBalance,fetchMyTradesorfetchPositions, retries a failed chain at most once in 30 seconds after that chain's last load finished.loadMarkets(),fetchMarkets(),ensureFundsandloadMarkets(true)retry at once. Between retries, the failure accessors stay populated with the error and the failing chain. - Symbols are unique across the loaded chains. When a chain loads later than
the others, a symbol that it shares with a market on another chain changes on
both chains. Call
loadMarkets()before you keep symbols.
Structs
One set of shapes throughout: markets { id, symbol, type, base, quote, active, precision, limits, outcomes?, info }; books as [price, amount] pairs, best
first; orders { id, symbol, type?, side, price, amount, filled, remaining, status, timestamp, info } (type is absent on orders read back from the
indexer — the pools do not emit it); trades { id, symbol, price, amount, cost, side?, timestamp, info }; balances { [code]: { free, used, total } } (escrowed
funds live in the pools, so wallet free === total); OHLCV rows
[ms, open, high, low, close, baseVolume].
How each market kind maps
- Spot — symbol is the tradable;
sideis base-buy/base-sell; done. - Binary — two tradables per market. The engine keeps one YES-terms book;
the resolver presents each outcome's own view (prices, sides, candles all
outcome-relative).
mintSet/burnSet/redeemoperate at market level. - Perps —
type: "swap", settle = quote (linear, collateral-settled);createOrderunchanged (margin locks from your MarginBank balance —depositMarginfirst);fetchPositions/fetchFundingRateread the MarginBank/pool live. - Categorical / multi-outcome (teed up) — N tradables on one pool
(outcome index = the order's
userData, exactly as binary uses 0/1 today); same verbs, and cross-book complete-set fills will surface inwatchMyTradeswith a shared match id ininfo.
The design intent: the verbs never change again — every future market kind
is new data (a type, an outcomes[] list), not new API.