# Somnia Markets > Order books, matching and settlement that run as smart contracts, shared by every venue. Spot, perpetual and event markets share one engine, one liquidity root and one SDK. --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets Somnia Markets is a decentralized information and trading protocol on [Somnia](https://somnia.network): one fully on-chain central limit order-book engine runs every market family — **spot** pairs, **binary** information markets, and **perps** — alongside a realtime on-chain index-price feed. Everything is on-chain and permissionless: order matching, escrow, settlement, market creation (per-venue), and resolution. The APIs below are conveniences over the same public contracts — nothing they do is privileged. **Spot markets** are plain base/quote order books (e.g. SOMI/USDC), each pool pushing a smoothed on-chain mark price straight from its own book. **Binary markets** are the information leg: a YES/NO question — such as an asset above a strike at expiry — whose outcome shares trade on the book at prices that *are* probabilities, so each book doubles as a live on-chain forecast. Shares are ERC-6909 positions backed 1:1 by collateral: a complete set (1 YES + 1 NO) always mints from and merges back to exactly one collateral unit, the winning side redeems 1:1 at resolution, and resolution itself is driven on-chain by the Prophecy oracle through Somnia's reactivity — no operator in the loop. Each market carries an on-chain kind tag (`marketType()`); future kinds (multi-outcome, dual-book) extend the same protocol. **Perps** are linear, collateral-settled perpetuals — cross-margin via the MarginBank, on-chain funding and liquidations — live on testnet. ## Ways to integrate **The TypeScript SDK — `@somnia-chain/markets-sdk`.** The fastest path for apps and bots in a JS runtime: realtime order books, trades, candles, and positions streamed with zero polling, plus a typed trader for placing orders, managing margin, and minting/redeeming outcome shares. Start with [The SDK](./typescript), then [The exchange API](./typescript/exchange); the per-family guides — [Spot markets](./typescript/spot), [Binary markets](./typescript/binary), [Perps](./typescript/perps) — cover what differs per kind, and [Architecture](./typescript/architecture) explains how it works under the hood. The generated [API reference](https://prd.smk.somnia.host/docs/typescript/api) lists every exported function, hook and type. **Raw smart contracts — no SDK.** Any language, any runtime, nothing but an RPC node and the ABIs: discover markets, place orders, and materialize a live local order book straight from contract events. [Raw Smart-Contract Integration](./contracts) is the end-to-end guide; [Market Making](./market-making) covers what is venue-specific for quoting operations — book encoding, escrow semantics, event caveats, market lifecycle. ## Going deeper - The [System page](https://somnia-platform-explorer.vercel.app/markets/system) is the live deployment view: every protocol contract with its address, owner, and implementation, plus indexer health. - The API reference in the sidebar is generated from the SDK source (TypeDoc). - These docs are also served machine-readable at [`/llms.txt`](https://somnia-platform-explorer.vercel.app/markets/llms.txt) and [`/llms-full.txt`](https://somnia-platform-explorer.vercel.app/markets/llms-full.txt). --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/contracts Somnia Markets runs three market families on one on-chain order-book engine: **spot** pairs (`SpotPool`), **binary markets** (`BinaryPool`), and **perps** (`PerpPool`). All three extend the same shared `OrderBook` core, so the placement surface, the order-lifecycle events, and the book-materialization technique are identical across every book; what differs per family is discovery, funding/escrow, and settlement. This guide is how to trade with nothing but an RPC node and the contract ABIs — any language, any stack. It covers the shared core first, then each family in turn, then [materializing a live local order book](#materialize-the-order-book-from-events) from the event stream. Pair it with [Market Making](./market-making) if you are building a quoting system. (Building a JS/TypeScript app instead? The [SDK](./typescript) wraps everything here.) What you need to start: contract addresses and signatures. Protocol contracts (the `BinaryMarketsModule` market registry, the `MarketsCore` control plane, the `OutcomeToken6909` singleton) are listed live per network on this explorer's [System page](https://somnia-platform-explorer.vercel.app/markets/system); spot pairs and live binary markets are listed on the [markets page](https://somnia-platform-explorer.vercel.app/markets); per-market binary addresses are read from the module's registry on-chain ([below](#binary-markets)). The complete Solidity interfaces — everything needed to generate ABIs — are in the [interface reference](#interface-reference) at the end of this guide. The RPC and indexer endpoints of each network, and the Hideki testnet deployment, are in [Networks](#networks). ## Networks Somnia Markets runs on three public networks. Each RPC serves HTTP and WebSocket requests, and each network has its own indexer. | Network | Chain id | HTTP RPC | WebSocket RPC | Indexer GraphQL | |---|---|---|---|---| | Somnia mainnet | `5031` | `https://api.infra.mainnet.somnia.network` | `wss://api.infra.mainnet.somnia.network/ws` | `https://prd.smk.somnia.host/v1/graphql` | | Shannon testnet | `50312` | `https://api.infra.testnet.somnia.network` | `wss://api.infra.testnet.somnia.network/ws` | `https://dev.smk.somnia.host/v1/graphql` | | Hideki testnet | `50383` | `https://api.hideki2.infra.testnet.somnia.network` | `wss://api.hideki2.infra.testnet.somnia.network/ws` | `https://hideki-dev.smk.somnia.host/v1/graphql` | These RPCs reject an `eth_getLogs` request whose `toBlock` is more than 1,000 blocks after its `fromBlock` (`block range exceeds 1000`). Page a log backfill in windows of at most 1,001 blocks. ### Hideki testnet Hideki runs spot, perp and binary markets. The chain was rebuilt on 2026-09-17 with the same chain id. An address from an older Hideki record belongs to the retired chain: on the current chain it can be empty or hold an unrelated contract. Use the addresses below. | Contract | Address | |---|---| | `PerpPoolFactory` | `0xE631fAEE547baeE07353E290f0562ea67d856560` | | `MarginBank` | `0x478769E1177C0773c43A593DCCc4Ad15F15F1394` | | `OperatorPermissionsRegistry` | `0x6B2FbfeD328FF5F20C0BE7D3C3437613aFFE8D3a` | | `SpotPoolRegistry` | `0xaC1d9439a6777Bf80D4302Aa08dF1bFc3334Ce9A` | | `BinaryMarketsModule` | `0x3ecC694Cef705358864a646142ac17A90E29e388` | | `MarketsCore` | `0x2802504314685D89bF6C992CA5a8e7cC78bc0294` | | `OutcomeToken6909` | `0xB52c5934113Af5c0Bb20eb3C72290C8215f755b9` | | USDso (18 decimals), the perp collateral and the spot quote token | `0xed7993A74dAe1e6656B704A424DcD8711aAf4F72` | | tUSDC (6 decimals), the binary collateral | `0x9E9492cC533E8696A3b6afC7df730800DF75148f` | Perp markets. Each one settles in USDso through the `MarginBank` above. `PerpPoolFactory.getPerpPools()` returns the same list. | Base | `PerpPool` | |---|---| | BTC | `0x4181dd08E73BbCdd1Ba3E615A18f6abD941b94C0` | | ETH | `0xD6Cb1DD58580B84D76068aC21594219ce03aeddf` | | SOL | `0xB483ec1fb34996638677c20ab087b16F1896FA53` | | HYPE | `0x63B47eD4A7DE0FdE659b0d82441dD74828b7297A` | | XRP | `0x7058579F3248559963f79AdB937c6De9f953E060` | | DOGE | `0xC410012e39765Cd5f772e84BAcB19554AEC040Df` | | BNB | `0x3D2A99fE1e2BdEaA25024515A2D2202DAF9531aD` | | ADA | `0x34fa62909f77699a7a6278D992F72Ff31c7a619D` | | SUI | `0x1Be21287cfB865a7c462Ff3c4f275E70BcccefE4` | | LINK | `0xf71b619450baFe953F47b9b556416A3A67b443b5` | | AVAX | `0xde9762B6b441133D1314C68864ae4e63A6eE45C7` | | XLM | `0x74b05B9B8c8F180b53b7891018F3B254B3E658C9` | | NEAR | `0x1cD15ae8bFf0FE0a7F49233BE8b90734775bA454` | | WLD | `0x0F464d57c1FD839E4d87A408BBcCDAb8f77E6394` | | TAO | `0xD9b6A347A3Ae9FD6BA21179D3343F2c8A922F697` | Spot pairs. Each one quotes in USDso and is registered in the `SpotPoolRegistry`. | Pair | `SpotPool` | Base token | |---|---|---| | STT / USDso | `0xbE6c45bF96E1B4e5Ec4655BdaE0F23C0410DB35d` | native STT, reported as `0x28f34DeFd2b4CB48d9eE6d89f2Be4Bc601694c00` | | WBTC / USDso | `0xD61bb202984E4c02DD6dbF51489A7ec6144e7AA0` | `0xE97673c634EF069Ecc0d95425Cc8e62F99D27E0E` (8 decimals) | | WETH / USDso | `0xFd87D984Bcdd276C3C411C2590acB6a7fF8aB4fF` | `0x56115cB05652Ac6117Caa1152B630986a51d36b5` (18 decimals) | | HBTT / USDso | `0x33a6ec4A0350dcDc6d91C7E475443779bC3f81A9` | `0x0528dD3beEf917DB06A5249C9Ea84b9D702B3A84` (18 decimals) | Tick, lot and minimum quantity differ by market. Read them from each pool (`getOrderBookParameters()` on a perp pool, `getPoolParams()` on a spot pool), and read the margin settings of a perp market with `getPerpPoolParameters()`. On 2026-10-02 every market above charged zero maker and taker fees. On 2026-10-02, `isPriceable()` on the Hideki perp markets switched between `true` and `false` from one call to the next, a few seconds apart. At a block where it is `false`, an order that increases a position reverts `AllSourcesUnavailable` (`0xc95fe237`). The same order succeeds at a block where it is `true`. Check `isPriceable()` before each quote. **Funding a Hideki account.** USDso on Hideki is the bridged form of Shannon USDso, and it cannot be minted directly (`mint` reverts). Send it from Shannon over the Hyperlane bridge, to any recipient address. Bridge STT for gas the same way. | Token | Shannon router | Hideki router | Sending from Shannon | |---|---|---|---| | USDso | `0x94E2E7f4B2B0a8E3032c7D3ff3c86790BB5979E6` | `0xed7993A74dAe1e6656B704A424DcD8711aAf4F72` (the token) | Approve the Shannon router for Shannon USDso (`0x9c32F3827A1a99f0cf9B213de8b53eC3d57bb171`), then call `transferRemote` with no value. | | STT | `0x67CD0778A719099DAF2D346aD0Db0d9E6FF6674C` | `0xB04373932bc05347757da6b8a226d7657584aB35` | Call `transferRemote` with `msg.value` equal to the amount. | Call `transferRemote(uint32 destination, bytes32 recipient, uint256 amount)` with destination `50383` and the recipient address left-padded to 32 bytes. `quoteGasPayment(50383)` returns `0` on both Shannon routers, so the call pays no bridge fee. Delivery is asynchronous. The [Bridge](./typescript/bridge) guide covers delivery times, the reverse direction, and the WBTC, WETH and HBTT routes. Shannon USDso can be minted by anyone with `mint(address to, uint256 amount)`. Hideki tUSDC has `faucet(uint256 amount)`, capped at 10,000 tUSDC per call. ## The shared order-book core Binary books take orders through `placeBinaryOrder` instead (see [the order kind](#one-book-two-outcomes-the-order-kind)). Spot and perp books expose the same placement entrypoint, covering all order types (GTC / `NormalOrder`, `FillOrKill`, `ImmediateOrCancel`, `PostOnly`): ```solidity function placeOrder( bool isBid, uint64 userData, uint256 price, uint256 quantity, uint64 expireTimestampNs, OrderType orderType, SelfMatchingOption selfMatchingOption, address builder, uint96 builderFeeBpsTimes1k ) external payable returns (bool success, OrderId id); ``` - **`expireTimestampNs` is mandatory and nanoseconds** — a future unix-ns timestamp (`now_seconds * 1e9 + lifetime_ns`); zero or past values are rejected. There is no "no expiry" sentinel. - **A rejected order reverts.** A single-order placement does not return `success == false`; it reverts with a typed error. A `PostOnly` that would cross reverts `PostOnlyWouldCross`. A `FillOrKill` that cannot fill in full reverts `FillOrKillNotFillable`. An IOC that fills nothing reverts `ImmediateOrCancelNoFill`. A self-match under `CancelTaker` reverts `SelfMatchCancelTaker`. An expiry in the past reverts `OrderAlreadyExpired`. Decode the revert data to choose a recovery. The batch calls handle a rejection differently (see [Batch writes](#batch-writes)). - **Quantize in integers.** `getOrderBookParameters()` returns `tickSize`, `lotSize`, and `minQuantity` in raw token units; off-grid values revert `InvalidPrice` / `InvalidQuantity`. - **`builder` / `builderFeeBpsTimes1k`** attribute the order to a routing/builder frontend and pay it a per-order fee (basis-points × 1000). Pass `(address(0), 0)` for none. A non-zero fee requires the order owner to have opted the builder in first — `approveBuilder(builder, maxFeeBpsTimes1k)` on the pool (0 revokes) — and is capped by the pool's `getMaxBuilderFeeBpsTimes1k()`; charges emit `BuilderFeeCharged`. Spot, binary and perp pools all accept builder values; an unapproved non-zero fee reverts `BuilderNotApproved` (spot), `BuilderFeeExceedsCap` (binary) or `BuilderFeeExceedsApproval` (perp). - `cancelOrder(id)` / `reduceOrder(id, newQty)` manage resting orders; both revert (rather than no-op) on already-terminal orders — check your local book or `getOrder(id)` first. `cancelExpiredOrders(ids)` and `sweepExpiredAtLevel(isBid, price, maxCount)` are permissionless cleanup — the SDK's `client.listSweepableOrders({ pool?, marketType? })` is the work-list for both, returning `orderId` / `isBid` / `price` per row. Note it is **not** `status: "Expired"`: that status means the order was already swept, whereas a keeper wants orders still `Open` whose expiry has passed. - `OrderId`s are unique **per pool** — always key by `(pool, orderId)`. Reading any book on demand, via `eth_call`: | View | Returns | |---|---| | `getBookLevels(isBid, numLevels)` | Aggregated `(price, quantity)` levels, best first | | `getAllOpenOrdersOffChain(isBid, maxCount, startCursor)` | Full `Order` structs, paginated (`msg.sender` must be `address(0)` — the `eth_call` default) | | `getOrder(orderId)` / `getOwnOpenOrders()` | Single order / caller's open ids | Polling these is fine for a dashboard; a trading system should take one snapshot and apply event deltas — see [materialization](#materialize-the-order-book-from-events). ### Batch writes A quoting loop often changes several orders at once. Spot and perp pools accept these changes in one transaction. Binary pools reject `placeOrders` and `amendOrders` with `UseBinaryPlacement`. - `placeOrders(requests)` places each request in array order and returns `(successes, ids)`, aligned with `requests`. A request that the book rejects when it lands (a PostOnly that would cross, a FillOrKill or IOC that cannot fill, a self-match under `CancelTaker`, or an expiry in the past) does not revert the call: its flag is `false`, its id is `0`, and the other requests still apply. Any other failure reverts the whole batch, for example an off-grid price (`InvalidPrice`) or a request that the owner cannot fund. - `cancelOrders(orderIds)` returns one flag per id. It returns `false` for an order that is no longer live, where `cancelOrder` reverts. - `reduceOrders(requests)` applies one `(orderId, newQuantityRemaining)` reduction per request. - `amendOrder(request)` re-quotes the resting order `oldOrderId` with the terms in `newOrder`, in one transaction. Use the id that it returns: it can be different from `oldOrderId`. A failed amendment reverts with an error that names the reason, for example `PostOnlyWouldCross` or `AmendOldOrderGone`. - `amendOrders(requests)` amends several orders in one transaction. Deployed pools do not all report a failed request in the same way. Some pools revert the whole batch: `AmendReplacementRejected(requestIndex, reason)` for a rejected replacement, or `AmendOldOrderGone` for an old order that is no longer live. Other pools return id `0` for that request and apply the other requests. Handle both results: catch the revert, and check every returned id. On every pool, a failure that is not a rejection, for example an off-grid price, reverts the whole batch. - An empty array reverts `EmptyBatch`. - The batch calls are not `payable`. A native-token amount that a batch order needs must already be in your vault balance on that pool (`depositNative`). ### Operator keys A market maker often holds funds in one key and signs orders with a second key. Spot and perp pools support this split through an operator registry. Binary pools do not (see [Market Making](./market-making)). 1. Read the registry address from the pool with `getOperatorPermissionsRegistry()`. 2. From the owner key, give the operator key the function selectors that it can call. `setOperatorApprovalForPool(pool, operator, selectors, true)` covers one pool. `setOperatorApprovalGlobal(operator, selectors, true)` covers every official pool. `setOperatorDenialForPool` removes one pool from a global grant. 3. From the operator key, call the `…For` entry points with the owner's address: `placeOrderFor(owner, …)`, `cancelOrderFor(owner, orderId)` and `reduceOrderFor(owner, orderId, newQuantityRemaining)`. The selector to grant is the selector of that function: `0x80054449` for `placeOrderFor`, `0xe37b444b` for `cancelOrderFor`, `0x364c2587` for `reduceOrderFor`. The batch and amend operator entry points use the same grants. `placeOrdersFor` needs the `placeOrderFor` grant, `cancelOrdersFor` needs the `cancelOrderFor` grant, and `reduceOrdersFor` needs the `reduceOrderFor` grant. `amendOrderFor` and `amendOrdersFor` need both the `placeOrderFor` and the `cancelOrderFor` grants. The order belongs to the owner, so its funds and its proceeds are the owner's. A call without a grant reverts `OnlyApprovedContracts`. Read a grant from the pool with `isOperatorAuthorized(owner, operator, selector)`. Two rules need care: - A global grant is broad. On the testnets it also covers every perp pool, so a global `placeOrderFor` grant lets the operator open leveraged positions for the owner. Grant per pool to keep an operator on one market. - On a perp pool, an order that the operator sends does not top up margin from the owner's wallet. Keep the owner's MarginBank balance funded (see [Perps](./typescript/perps)). ## Spot markets Each trading pair is its own `SpotPool` — live pairs are listed on the [markets page](https://somnia-platform-explorer.vercel.app/markets). `getPoolParams()` returns everything static in one call: `(baseToken, quoteToken, makerFeeBpsTimes1k, takerFeeBpsTimes1k, tickSize, minQuantity, lotSize)`. A native-token side is reported as a sentinel token address (not `address(0)`) — resolve it from `getPoolParams` rather than assuming. Spot semantics are the conventional ones: `isBid = true` buys base with quote, a fill is a base ↔ quote swap at the **maker's** resting price, and `userData` is a free 64-bit tag echoed back on the order struct and its `OrderPlaced` event. Use it for strategy/generation ids. **Funding.** By default the pool **auto-pulls** the required input from your wallet at placement (ERC-20 `transferFrom` after a one-time approval, or `msg.value` on native pools) and **auto-delivers** proceeds back on fill, cancel, or expiry. Size the pull with `getAutoPullRequirement(owner, isBid, price, quantity, builderFeeBpsTimes1k)`. High-churn integrations should opt out with `setManualVaultMode(true)`: `deposit` once, quote against the vault balance with no per-order token transfers, `withdraw` when rebalancing — for a loop that places and cancels constantly the gas savings compound. **Mark price.** Spot pools push `MarkPriceUpdated(asset, markPrice, rawMidpoint)` whenever the book midpoint advances — an EMA-smoothed midpoint plus the raw `(bestBid + bestAsk) / 2`, an on-chain fair-price feed with no extra infrastructure. ## Binary markets ### Discover markets `BinaryMarketsModule` is the market registry (the `MarketsCore` control plane holds only operators, venues, and module bindings — no markets). Each market is a `MarketRecord` (read via `module.markets(marketId)`): | Field | Use | |---|---| | `pool` | The `BinaryPool` — the order book you trade on | | `market` | The `BinaryMarket` — lifecycle + resolution state | | `collateral` | The ERC-20 quoted against (one per market) | | `originOperatorId` / `originVenueId` | Origin attribution: the uint32 operator id + opaque bytes32 venue id the market was created under | | `yesId` / `noId` | ERC-6909 ids of the outcome positions on the shared `OutcomeToken6909` singleton | | `tradingStart` / `expiry` | Trading window (unix seconds) | Watch the module's `MarketCreated` event to discover new markets — **every** market fires it, whether created directly via `createMarket` or rolled by a venue's `MarketCreator` (venues run rolling series — hourly up/down and similar — that create markets continuously), and it is the only creation event carrying the `(operatorId, venueId)` origin. `marketId` is a module-scoped counter (`bytes32(++marketSeq)`) that doubles as the CREATE2 salt — unique by construction, not precomputable before the create tx. `module.marketIdByAddress(addr)` is the reverse lookup. Every market carries an immutable kind tag, `IMarket.marketType()`. This section describes **`BINARY_SINGLE_BOOK`** — the only kind live today. Future kinds (dual-book, multi-outcome) will carry different tags and different book semantics: branch on the tag, never on assumptions. Lifecycle: `Listed → Trading → Locked → (Settling) → Resolved | Voided` (`BinaryMarket.StatusChanged`, plus `Resolved` / `Voided`). Orders are only accepted while the market reports Trading — placement reverts `TradingNotActive` otherwise. Cancellation stays open through `Locked`; `redeem` opens at `Resolved` / `Voided`. ### One book, two outcomes: the order kind Each `BinaryPool` runs a **single** order book. Place an order with `placeBinaryOrder` and name its kind: | `OrderKind` | Value | Book side | Locks at placement | |---|---|---|---| | `BUY_YES` | `0` | bid | collateral | | `SELL_YES` | `1` | ask | YES tokens | | `BUY_NO` | `2` | ask | collateral | | `SELL_NO` | `3` | bid | NO tokens | A binary pool rejects `placeOrder`, `placeOrders`, `amendOrder` and `amendOrders` with `UseBinaryPlacement`. `userData` is the last argument of `placeBinaryOrder` and is a free 64-bit tag, as on spot: the pool stores it and does not read it. `placeBinaryOrder` takes no native value; a non-zero `msg.value` reverts `UnexpectedNativeDeposit`. The NO book is price-inverted so the base matcher can cross the two books without knowing about outcomes: a NO order is submitted at the YES-side complement, `price = oneCollateral − noPrice`, where `oneCollateral = 10 ** collateral.decimals()`. All prices must satisfy `0 < price < oneCollateral`, because a share can never be worth more than one collateral unit. A price at or above `oneCollateral` reverts `PriceOutOfBounds`, and a zero price reverts `InvalidPrice`. ### Escrow and funding Placement escrows worst-case value up front: - **BUY orders** (`BUY_YES`, `BUY_NO`) lock collateral (ceil-rounded `price × quantity` in the order's own frame). The pull is **vault-first**: free vault balance is consumed before `transferFrom` on your wallet — so `collateral.approve(pool, …)` once per pool. (There is no auto-pull/manual-vault toggle here; this is the only mode.) - **SELL orders** (`SELL_YES`, `SELL_NO`) lock the outcome tokens themselves, pulled from your ERC-6909 balance on the singleton — call `outcomeToken.setOperator(pool, true)` once; it covers every market, since ids are namespaced by pool. Money flows back through two channels worth knowing: - **Refunds land in your vault balance** — taker surplus (you locked at your limit price, filled at a better maker price) and cancel/reduce/expiry refunds credit the pool vault, not your wallet. Because placement is vault-first, a quoting loop recycles these automatically; `withdraw(collateral, amount)` only when rebalancing. Read it with `getWithdrawableBalance(owner, collateral)`. - **Fill proceeds deliver wallet-first with vault fallback** — if the wallet transfer fails the pool credits the vault and emits `PayoutFallbackToVault`. Inventory comes from complete sets: `module.mintCompleteSet(operatorId, venueId, marketId, amount)` turns `amount` collateral into `amount` YES + `amount` NO (`setOperator(module, true)` once for the merge/redeem directions; `operatorId`/`venueId` are routing attribution only — pass `0` / `bytes32(0)` for none); `mergeCompleteSet(operatorId, venueId, marketId, amount)` is the inverse; `redeem(operatorId, venueId, marketId, outcomeIdx, amount)` burns the winning side 1:1 after resolution (or either side at 1/2 on void). You can also call the pool's `mintSet` / `burnSet` / `redeem` directly. Note you often need **no inventory at all** to quote — see the fill paths below. On a **resolved** market (never a voided one), the pool skims the venue's frozen `settlementFeeBpsTimes1k` ONCE from the whole winning backing when the first winning redeem lands (emitting `SettlementFeeCharged(feeRecipient, winningBacking, fee)`); every winner then redeems for `1 − fee` per token. Per-fill maker/taker fees (`ProtocolFeeCharged`) and the per-order builder fee (`BuilderFeeCharged`) are the other two fee rails — all rates are frozen into the pool at creation from the origin venue's config and readable via `getBinaryPoolParams()`. ### The four fill paths Two opposite-kind orders crossing settle by one of four paths (dispatched on the unordered pair of order kinds): | Crossing pair | Path | Settlement | |---|---|---| | `BUY_YES × SELL_YES` | DIRECT_YES | YES ↔ collateral swap | | `SELL_NO × BUY_NO` | DIRECT_NO | NO ↔ collateral swap | | `BUY_YES × BUY_NO` | MINT_A_PAIR | Both pay collateral; pool mints a fresh pair, one side each (emits `SetMinted`) | | `SELL_YES × SELL_NO` | BURN_A_PAIR | Both escrows burned as a pair; each seller paid their share (emits `SetBurned`) | The fill price on `OrderFilled` is always the **maker's** resting price, in the YES frame; the NO-side display price is `oneCollateral − fillPrice`. There is no on-chain "fill kind" field — consumers derive it from the two orders' kinds — the SDK exports [`sideOfKind`](https://prd.smk.somnia.host/docs/typescript/api/index/functions/sideOfKind) (the YES/NO side of a resting order, from the pool's `BinaryOrderPlaced(orderId, kind)` event) and [`fillKind`](https://prd.smk.somnia.host/docs/typescript/api/index/functions/fillKind) for exactly this derivation. `getOrderInfo(orderId)` exposes the binary-side escrow state per order (`lockedCollateral` / `lockedOutcome`, kind). ## Perps `PerpPool` extends the same `OrderBook` core, so everything in [the shared core](#the-shared-order-book-core) and [materialization](#materialize-the-order-book-from-events) carries over unchanged. On top it adds margin accounting, funding, and liquidations — with `FundingUpdated` / `OpenInterestUpdated` events, and a dedicated `MakerOrderCancelledExceedsPosition` removal event (a maker order erased pre-fill because it would breach the owner's position limit — treat it as a removal in a book reducer). Perp markets are live on the Shannon and Hideki testnets (see [Perps](./typescript/perps)). [Batch writes](#batch-writes) and [operator keys](#operator-keys) work on perp pools as on spot pools, with the differences listed in those sections. **Discovery.** `PerpPoolFactory.getPerpPools()` lists the markets of a network. `getBaseTokenForPool(pool)` returns the base token of one, and `getPoolForBaseToken(baseToken)` is the reverse lookup. The factory is `0x516d2cb6de41bE98444e887793946E65989Ba07b` on Shannon; the Hideki factory and markets are in [Hideki testnet](#hideki-testnet). `getMarketStatus()` returns `0` (`Active`), `1` (`ReduceOnly`), `2` (`Settlement`) or `3` (`Delisted`). Only an `Active` market accepts an order that increases a position. `isPriceable()` is `false` while the market cannot read a usable mark price, so check it before quoting. Read the mark price with `getMarkPrice()` or the non-reverting `tryGetMarkPrice()`, and the oracle index price and its update time with `getIndexPrice()`. `getMaxRestingOrdersPerAccount()` is the most orders that one account can rest on the market (`0` means the pool applies its built-in default). **Prices and sizes.** A price is in collateral units (USDso, 18 decimals) per one whole base token. `getOneBase()` returns one whole base token in raw units, so the notional of an order is `price × quantity / getOneBase()`. **Margin.** A perp pool keeps collateral in its `marginBank()`. Approve the MarginBank to spend the collateral token. `deposit(amount)` and `withdraw(amount)` move margin, and `getAccountEquity(account)` reads an account's equity in the bank. An order that its owner places also tops up the margin it needs from the owner's wallet through the same allowance, so a deposit is optional. Without the allowance, and with no margin in the bank, the placement reverts in the token's `transferFrom`. An order that an operator places does not top up (see [Operator keys](#operator-keys)). ## Materialize the order book from events Every book mutation emits an event, so one pinned snapshot plus the log stream reproduces the full book — for **any** of the three families. This is exactly how the protocol's own indexer and the SDK's live watches work — the reducer below is the same one they run (see [Architecture](./typescript/architecture) for the SDK's implementation of it). ### Snapshot, then stream 1. Pin a block `N` (`eth_blockNumber`). 2. Snapshot both sides with `getAllOpenOrdersOffChain` **at block `N`** (pass the block tag explicitly so pagination pages are mutually consistent). Drop orders whose `expireTimestampNs` has already passed. 3. Stream the pool's logs from `N + 1` (`eth_subscribe("logs")` over WSS, or an `eth_getLogs` loop) and apply them strictly in `(blockNumber, logIndex)` order. ### The event reducer Keep a map `orderId → Order` plus per-side level aggregates: | Event | Action | |---|---| | `OrderPlaced(orderId, placedOrder)` | Cache the struct; do **not** insert. Fires for every accepted order — `placedOrder.quantityRemaining` is already the **post-match residual** (the matcher runs before the event), so zero remaining means it fully filled on entry. | | `OrderRested(orderId)` | Insert the cached order into the book. This is the **only** insert signal. | | `OrderFilled(takerId, makerId, qty, takerRem, makerRem, fillPrice)` | Patch the **maker**: remaining = `makerRem`, remove at zero. Ignore the taker leg — its state arrives on its own `OrderPlaced`/`OrderRested` later in the same tx. | | `OrderReduced(orderId, newQuantity)` | Set remaining = `newQuantity` (not a fill). | | `OrderCancelled(orderId)` / `OrderExpired(orderId)` | Remove (idempotently). | | `OrderCancelledSelfMatch(orderId)` | Remove — a same-owner resting maker erased by a `CancelMaker` self-match. | | `OrderCancelledPreFill(orderId)` | Remove — a resting maker pulled by a pre-fill guard before it could fill. The matching loop emits it for **every** pre-fill removal, so handling it covers every present and future guard. | Family extras, none of which mutate book state: spot pools also emit `MarkPriceUpdated` (fair-price telemetry); binary pools emit `SetMinted` / `SetBurned` / `Redeemed` / `SettlementFeeCharged` (outcome-supply / settlement telemetry — mint-a-pair and burn-a-pair fills emit the first two alongside `OrderFilled`) and `BinaryMarket.StatusChanged` (leaves Trading → no new fills, only cancels from here); perp pools add `MakerOrderCancelledExceedsPosition`, `MakerOrderCancelledNegativeEquity` and `MakerOrderCancelledStaleMark`, which **do** mutate the book — treat all three as removals. They are reason tags emitted alongside the base `OrderCancelledPreFill`, so a reducer that handles the base event alone already removes the order. Ordering inside a placement tx: settlement hooks and one `OrderFilled` per matched maker fire **first**, then the taker's `OrderPlaced` (already residual-adjusted), then `OrderRested` iff the residual rested. A reducer that only inserts on `OrderRested` handles this for free. ### The sharp edges - **Self-match removals have their own event.** With `SelfMatchingOption.CancelMaker`, the matcher erases the same-owner resting maker and emits `OrderCancelledSelfMatch(orderId)` (on every book — spot, binary, perp). Handle it as a removal or your reducer carries a phantom order whenever *any* trader self-crosses with `CancelMaker`. With `CancelTaker` the incoming order is rejected instead: a single-order placement reverts `SelfMatchCancelTaker`, and `placeOrders` returns `false` for that request. - **Expiry is lazy.** Matching walks past expired makers without cleanup; `OrderExpired` only fires later (owner's next placement, or the permissionless sweeps). Prune locally the moment `expireTimestampNs` passes — an expired order can never fill — and treat the eventual event as a no-op. - **Make removals idempotent.** Between local pruning and the several removal paths, "remove an already-absent order" must be a no-op. - **Stream gaps.** On WSS reconnect, backfill the missed range with `eth_getLogs` before resuming, in windows of at most 1,001 blocks (see [Networks](#networks)). Somnia blocks have instant BFT finality, so a delivered log is final and no reorg handling is needed; the SDK's own live tail does none. If you still want a guard, treat a parent-hash discontinuity as a reason to re-snapshot rather than to unwind. ## Track your own orders and fills Both order-id parameters on `OrderFilled` are indexed topics, so you can filter your fills by the ids your `placeOrder` or `placeBinaryOrder` calls returned (or `getOwnOpenOrders()` after a restart). Alert on `PayoutFallbackToVault` — a delivery to your wallet failed and the proceeds are parked in your vault balance. Reconcile funds with `getWithdrawableBalance(owner, token)` and, on binary markets, `outcomeToken.balanceOf(owner, yesId | noId)` for positions. ## Interface reference ABI-faithful excerpts of every function and event used in this guide, taken from the deployed contracts. Admin/venue-governance entrypoints are omitted; external interface types (`IERC20`, adapters) are shown as `address` — the ABI encoding is identical. ### Shared order-book core (every pool) ```solidity type OrderId is uint128; enum OrderType { NormalOrder, FillOrKill, ImmediateOrCancel, PostOnly } enum SelfMatchingOption { CancelTaker, CancelMaker } struct Order { OrderId orderId; bool isBid; address owner; uint64 userData; uint256 price; uint256 fullQuantity; uint256 quantityRemaining; uint64 expireTimestampNs; } struct OrderBookLevel { uint256 price; uint256 quantity; } struct OrderBookParameters { uint256 tickSize; // min price increment, raw quote/collateral units uint256 minQuantity; // min order quantity, raw base units uint256 lotSize; // min quantity increment, raw base units } interface IOrderBook { event OrderPlaced(OrderId indexed orderId, Order placedOrder); event OrderRested(OrderId indexed orderId); event OrderFilled( OrderId indexed takerOrderId, OrderId indexed makerOrderId, uint256 quantityFilled, uint256 takerRemainingQuantity, uint256 makerRemainingQuantity, uint256 fillPrice ); event OrderCancelled(OrderId indexed orderId); event OrderCancelledSelfMatch(OrderId indexed orderId); event OrderExpired(OrderId indexed orderId); event OrderReduced(OrderId indexed orderId, uint256 newQuantity); event OrderBookParametersUpdated(OrderBookParameters newParameters); function placeOrder( bool isBid, uint64 userData, uint256 price, uint256 quantity, uint64 expireTimestampNs, OrderType orderType, SelfMatchingOption selfMatchingOption, address builder, uint96 builderFeeBpsTimes1k ) external payable returns (bool success, OrderId id); function cancelOrder(OrderId orderId) external; function reduceOrder(OrderId orderId, uint256 newQuantityRemaining) external; function cancelExpiredOrders(OrderId[] calldata orderIds) external; function sweepExpiredAtLevel(bool isBid, uint256 price, uint256 maxCount) external returns (uint256 cleaned); function getOrder(OrderId orderId) external view returns (Order memory); function getOwnOpenOrders() external view returns (OrderId[] memory); function getBookLevels(bool isBid, uint64 numLevels) external view returns (OrderBookLevel[] memory); function getOrderBookParameters() external view returns (OrderBookParameters memory); // eth_call only — msg.sender must be address(0) function getAllOpenOrdersOffChain(bool isBid, uint256 maxCount, uint64 startCursor) external view returns (Order[] memory orders, bool hasMoreOrders, uint64 nextCursor); } // The per-user vault, inherited by every pool. interface IVault { function deposit(address token, uint256 amount) external; function depositNative() external payable; function withdraw(address token, uint256 amount) external; function getWithdrawableBalance(address owner, address token) external view returns (uint256); } // Rejections of a single-order placement (every pool). error OrderAlreadyExpired(); error SelfMatchCancelTaker(); error PostOnlyWouldCross(); error FillOrKillNotFillable(); error ImmediateOrCancelNoFill(); ``` ### Batch writes and operator entry points (spot and perp pools) ```solidity struct PlaceOrderRequest { bool isBid; uint64 userData; uint256 price; uint256 quantity; uint64 expireTimestampNs; OrderType orderType; SelfMatchingOption selfMatchingOption; address builder; uint96 builderFeeBpsTimes1k; } struct ReduceOrderRequest { OrderId orderId; uint256 newQuantityRemaining; } struct AmendOrderRequest { OrderId oldOrderId; bool alwaysPlace; PlaceOrderRequest newOrder; } // IOrderBook, continued. interface IOrderBook { // Batch writes and amend. None of them is payable. function placeOrders(PlaceOrderRequest[] calldata requests) external returns (bool[] memory successes, OrderId[] memory ids); function cancelOrders(OrderId[] calldata orderIds) external returns (bool[] memory cancelled); function reduceOrders(ReduceOrderRequest[] calldata requests) external; function amendOrder(AmendOrderRequest calldata request) external returns (OrderId newOrderId); function amendOrders(AmendOrderRequest[] calldata requests) external returns (OrderId[] memory newOrderIds); // Called by an operator key for `owner`. function placeOrderFor( address owner, bool isBid, uint64 userData, uint256 price, uint256 quantity, uint64 expireTimestampNs, OrderType orderType, SelfMatchingOption selfMatchingOption, address builder, uint96 builderFeeBpsTimes1k ) external payable returns (bool success, OrderId id); function cancelOrderFor(address owner, OrderId orderId) external; function reduceOrderFor(address owner, OrderId orderId, uint256 newQuantityRemaining) external; function placeOrdersFor(address owner, PlaceOrderRequest[] calldata requests) external returns (bool[] memory successes, OrderId[] memory ids); function cancelOrdersFor(address owner, OrderId[] calldata orderIds) external returns (bool[] memory cancelled); function reduceOrdersFor(address owner, ReduceOrderRequest[] calldata requests) external; function amendOrderFor(address owner, AmendOrderRequest calldata request) external returns (OrderId newOrderId); function amendOrdersFor(address owner, AmendOrderRequest[] calldata requests) external returns (OrderId[] memory newOrderIds); } // Declared on both ISpotPool and IPerpPool. interface ISpotPool { function getOperatorPermissionsRegistry() external view returns (address); function isOperatorAuthorized(address owner, address operator, bytes4 selector) external view returns (bool); } interface IOperatorPermissionsRegistry { function setOperatorApprovalForPool(address pool, address operator, bytes4[] calldata selectors, bool approved) external; function setOperatorApprovalGlobal(address operator, bytes4[] calldata selectors, bool approved) external; function setOperatorDenialForPool(address pool, address operator, bytes4[] calldata selectors, bool denied) external; function isApproved(address owner, address operator, bytes4 selector, address pool) external view returns (bool); } error EmptyBatch(); error AmendOldOrderGone(OrderId oldOrderId); error AmendReplacementRejected(uint256 requestIndex, uint8 reason); // on pools where amendOrders reverts the whole batch error OnlyApprovedContracts(); error UseBinaryPlacement(); // a binary pool, on placeOrder, placeOrders and amend ``` ### SpotPool additions ```solidity interface ISpotPool /* is IOrderBook, IVault */ { event MarkPriceUpdated(address indexed asset, uint256 markPrice, uint256 rawMidpoint); event ManualVaultModeUpdated(address indexed user, bool enabled); event PayoutFallbackToVault(address indexed owner, address indexed token, uint256 amount); function setManualVaultMode(bool enabled) external; function getPoolParams() external view returns ( address baseToken, address quoteToken, uint256 makerFeeBpsTimes1k, uint256 takerFeeBpsTimes1k, uint256 tickSize, uint256 minQuantity, uint256 lotSize ); function getManualVaultMode(address user) external view returns (bool enabled); function getAutoPullRequirement( address owner, bool isBid, uint256 price, uint256 quantity, uint96 builderFeeBpsTimes1k ) external view returns (address inputToken, uint256 requiredAmount, uint256 delta); function getMidpointEmaState() external view returns (uint256 emaValue, uint64 lastUpdateNs); } ``` ### BinaryPool additions ```solidity enum OrderKind { BUY_YES, SELL_YES, BUY_NO, SELL_NO } struct BinaryPoolOrderInfo { OrderId orderId; uint256 lockedCollateral; // BUY escrow (mutually exclusive with lockedOutcome) uint256 lockedOutcome; // SELL escrow uint8 outcomeIdx; // 0 = YES, 1 = NO bool isBuy; address builder; uint96 builderFeeBpsTimes1k; } struct BinaryPoolInfo { address collateralToken; address market; address outcomeToken; uint256 yesId; uint256 noId; uint256 oneCollateral; // 10 ** collateral.decimals() uint256 setBacking; address feeRecipient; uint256 makerFeeBpsTimes1k; uint256 takerFeeBpsTimes1k; uint256 maxBuilderFeeBpsTimes1k; uint256 settlementFeeBpsTimes1k; } interface IBinaryPool /* is IOrderBook, IVault */ { event BinaryOrderPlaced(OrderId indexed orderId, OrderKind kind); event SetMinted(address indexed payer, address indexed yesTo, address indexed noTo, uint256 amount); event SetBurned(address indexed holder, uint256 amount); event Redeemed( address indexed holder, address indexed to, uint8 outcomeIdx, uint256 amountBurned, uint256 collateralOut ); // Fee rail: per-fill protocol fees, per-order builder fees (opt-in via // approveBuilder), and the one-time settlement fee on the winning backing. event ProtocolFeeCharged( OrderId indexed orderId, address indexed recipient, address indexed token, uint256 amount, bool isTakerSide ); event BuilderApproved(address indexed user, address indexed builder, uint256 maxFeeBpsTimes1k); event BuilderFeeCharged(OrderId indexed orderId, address indexed builder, address indexed token, uint256 amount); event SettlementFeeCharged(address indexed feeRecipient, uint256 winningBacking, uint256 fee); event PayoutFallbackToVault(address indexed owner, address indexed token, uint256 amount); // The placement entrypoint for binary pools. `price` is the YES-side price // for every kind. A non-zero msg.value reverts UnexpectedNativeDeposit. function placeBinaryOrder( OrderKind kind, uint256 price, uint256 quantity, uint64 expireTimestampNs, OrderType orderType, SelfMatchingOption selfMatchingOption, address builder, uint96 builderFeeBpsTimes1k, uint64 userData ) external payable returns (bool success, OrderId id); function approveBuilder(address builder, uint256 maxFeeBpsTimes1k) external; function getMaxBuilderFeeBpsTimes1k() external view returns (uint256 maxFeeBpsTimes1k); function getBuilderApproval(address user, address builder) external view returns (uint256 maxFeeBpsTimes1k); function getEffectiveBuilderApproval(address user, address builder) external view returns (uint256 maxFeeBpsTimes1k); function mintSet(address yesTo, address noTo, uint256 amount) external; function burnSet(uint256 amount) external; function redeem(uint256 amount, uint8 outcomeIdx, address to) external returns (uint256 collateralOut); function collateralToken() external view returns (address); function outcomeToken() external view returns (address); function outcomeId(uint8 outcomeIdx) external view returns (uint256); function market() external view returns (address); function setBacking() external view returns (uint256); function getBinaryPoolParams() external view returns (BinaryPoolInfo memory info); function getOrderInfo(OrderId orderId) external view returns (BinaryPoolOrderInfo memory info); } error UseBinaryPlacement(); // placeOrder, placeOrders or amend on a binary pool error UnexpectedNativeDeposit(); // msg.value on placeBinaryOrder error PriceOutOfBounds(); // price at or above oneCollateral ``` ### BinaryMarketsModule (the market registry) ```solidity enum VoidPolicy { UNIFORM, AMM_SNAPSHOT } // AMM_SNAPSHOT is a dead legacy slot enum MarketType { BINARY_SINGLE_BOOK, BINARY_DUAL_BOOK, NATIVE_MULTI } interface IBinaryMarketsModule { event MarketCreated( bytes32 indexed marketId, address indexed market, address indexed pool, uint256 oracleQuestionId, uint32 operatorId, bytes32 venueId, address creator, address collateral, uint256 yesId, uint256 noId, uint8 outcomeSlotCount, MarketType marketType, uint64 tradingStart, uint64 expiry, VoidPolicy voidPolicy, string asset, uint256 strike, // 0 for reference-mode markets (no fixed strike) string question, bytes context ); function markets(bytes32 marketId) external view returns ( uint256 oracleQuestionId, uint8 outcomeSlotCount, VoidPolicy voidPolicy, address collateral, uint32 originOperatorId, bytes32 originVenueId, address oracleAdapter, address creator, address market, address pool, uint256 yesId, uint256 noId, uint64 tradingStart, uint64 expiry ); function marketIdByAddress(address market) external view returns (bytes32 marketId); // operatorId / venueId are routing attribution only (0 / bytes32(0) = none). function mintCompleteSet(uint32 operatorId, bytes32 venueId, bytes32 marketId, uint256 amount) external; function mergeCompleteSet(uint32 operatorId, bytes32 venueId, bytes32 marketId, uint256 amount) external; function redeem(uint32 operatorId, bytes32 venueId, bytes32 marketId, uint8 outcomeIdx, uint256 amount) external; } ``` ### BinaryMarket (lifecycle) and the outcome-token singleton ```solidity enum MarketStatus { Listed, Trading, Locked, Settling, Resolved, Voided } interface IBinaryMarket { event StatusChanged(MarketStatus indexed oldStatus, MarketStatus indexed newStatus); event Resolved(uint8 indexed winningOutcome); event Voided(); function marketType() external view returns (MarketType); function tradingActive() external view returns (bool); function isResolved() external view returns (bool); function isVoided() external view returns (bool); function winningOutcome() external view returns (uint8); } // ERC-6909 subset. One singleton for all markets; id = (uint160(pool) << 8) | outcomeIdx. interface IOutcomeToken6909 { event Transfer( address caller, address indexed sender, address indexed receiver, uint256 indexed id, uint256 amount ); function setOperator(address spender, bool approved) external returns (bool); function transferFrom(address from, address to, uint256 id, uint256 amount) external returns (bool); function balanceOf(address owner, uint256 id) external view returns (uint256); } ``` ### Perps (factory, pool additions, MarginBank) ```solidity interface IPerpPoolFactory { function getPerpPools() external view returns (address[] memory); function getBaseTokenForPool(address perpPool) external view returns (address); function getPoolForBaseToken(address baseToken) external view returns (address); } enum MarketStatus { Active, ReduceOnly, Settlement, Delisted } struct PerpPoolParameters { uint256 initialMarginBps; uint256 maintenanceMarginBps; uint256 closeOutMarginBps; uint256 maxOpenInterest; uint256 maxPositionSize; uint256 takerFeeBpsTimes1k; int256 makerFeeBpsTimes1k; uint256 insuranceFundShareBps; } interface IPerpPool /* is IOrderBook */ { function marginBank() external view returns (address); function getMarketStatus() external view returns (MarketStatus); function isPriceable() external view returns (bool); function getMarkPrice() external view returns (uint256); function tryGetMarkPrice() external view returns (bool ok, uint256 price); function getIndexPrice() external view returns (uint256 price, uint256 updatedAt); function getOneBase() external view returns (uint256); function getPerpPoolParameters() external view returns (PerpPoolParameters memory); function getMaxRestingOrdersPerAccount() external view returns (uint16); } interface IMarginBank { function deposit(uint256 amount) external; function withdraw(uint256 amount) external; function getAccountEquity(address account) external view returns (int256); } ``` ### Bridge routers and testnet tokens ```solidity // Hyperlane token router (the USDso and STT routers in Hideki testnet). interface ITokenRouter { function transferRemote(uint32 destination, bytes32 recipient, uint256 amount) external payable returns (bytes32 messageId); function quoteGasPayment(uint32 destination) external view returns (uint256); } interface IShannonUSDso { function mint(address to, uint256 amount) external; } interface ITestUSDC { function faucet(uint256 amount) external; // at most 10,000 tUSDC per call } ``` --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/market-making What is different about quoting this venue. Somnia Markets runs **spot** order books, **binary** information markets, and **perps**, all on the same on-chain order-book engine; this guide covers quoting the **binary markets**. It assumes you already run market-making systems and skips the generalities — only the mechanics that will surprise you or cost you money if learned in production. Everything below is specific to binary markets (`marketType() == BINARY_SINGLE_BOOK`), which is every binary market live today; future kinds (multi-outcome, dual-book) carry a different `marketType()` and will not share these semantics — branch on the tag, never assume. Full contract mechanics are in [Raw Integration](./contracts); the SDK (`@somnia-chain/markets-sdk`) wraps all of it if you're in a JS runtime. If you quote spot or perps, read the [batch writes](./contracts#batch-writes) and [operator keys](./contracts#operator-keys) sections there. Binary pools reject batch placement and amend with `UseBinaryPlacement`, and they have no per-user operator keys. ## One book per binary market; the order kind selects the side, and NO is price-inverted Each binary market runs a **single** order book crossing all four order kinds. Place orders with `placeBinaryOrder` and name the kind explicitly; the generic `placeOrder` reverts `UseBinaryPlacement`. `userData` is a free tag that the pool does not read, so you can use it for quote generations. NO orders are submitted at the YES complement: `price = oneCollateral − noPrice`, with `oneCollateral = 10 ** collateral.decimals()`. All prices must sit strictly inside `(0, oneCollateral)`, tick/lot/min from `getOrderBookParameters()` at runtime. The kind matrix. Because of the inversion, a NO buy rests on the ask side: | `OrderKind` | Value | Book side | |---|---|---| | `BUY_YES` | `0` | bid | | `SELL_YES` | `1` | ask | | `BUY_NO` | `2` | ask | | `SELL_NO` | `3` | bid | ## You can quote two-sided with zero inventory A resting BUY_YES at *p* and BUY_NO at *1 − p* is a full two-sided quote: when either crosses an incoming opposite buyer the pool **mints a fresh pair** (`MINT_A_PAIR`) — collateral in, one side to each buyer, no inventory required. `SELL_YES × SELL_NO` burns a pair symmetrically. This changes the standard outcome-market bootstrap: you never need to pre-mint outcome tokens to open a market, and your quoting book can stay entirely collateral-denominated. Deliberate inventory moves go through `mintCompleteSet` / `mergeCompleteSet` (1 collateral ⇄ 1 YES + 1 NO, always), and `redeem` pays the winning side 1:1 at resolution, less the venue's frozen settlement fee if it configured one (0.5 each on void, never fee'd). Price the venue's fees into your spread: maker/taker rates are **per-venue** and frozen into the pool at market creation — read them once per market from `getBinaryPoolParams()` (`makerFeeBpsTimes1k` / `takerFeeBpsTimes1k` / `settlementFeeBpsTimes1k`; basis-points × 1000). The per-order builder fee only applies if you opt a builder in with `approveBuilder` — as an MM placing your own orders you simply pass `(address(0), 0)` and never pay it. ## Escrow: BUYs lock collateral, SELLs lock tokens, refunds recycle via the vault Placement escrows worst-case value up front: BUYs lock collateral (vault-balance-first, then wallet `transferFrom` — one approval per pool), SELLs lock the ERC-6909 outcome tokens themselves (one `setOperator(pool, true)` on the singleton covers every market). Two flows matter for float accounting: - **Taker surplus and cancel/reduce refunds credit your pool vault balance**, not your wallet — and since placement is vault-first, a quoting loop recycles them automatically. `withdraw` only when rebalancing across pools. - **Fill proceeds deliver wallet-first with vault fallback.** Alert on `PayoutFallbackToVault` or your reconciliation will drift. ## Expiry is mandatory — make it your dead man's switch Every order carries `expireTimestampNs` (future unix-ns; there is no "no expiry" sentinel). Set it just past your requote interval rather than far-future: a crashed or partitioned quoter's ladder then ages off the book on its own. Note expired orders are unfillable **immediately**, but the `OrderExpired` event is lazy (fires on later cleanup, not at expiry) — prune your own book model on the timestamp, not the event. ## Self-match: pick your side, and handle `OrderCancelledSelfMatch` With `SelfMatchingOption.CancelMaker`, the matcher erases your same-owner resting maker and emits `OrderCancelledSelfMatch(orderId)` — an event-materialized book (yours, and every other participant's) must treat it as a removal or it carries a phantom order afterward. With `CancelTaker` the incoming order is rejected instead: the placement reverts `SelfMatchCancelTaker`. Details in [the sharp edges](./contracts#the-sharp-edges). The protocol pulls your makers on other paths too. A pre-fill guard emits `OrderCancelledPreFill(orderId)` for every maker it removes before a fill. On a perp pool a reason tag rides alongside it — one of `MakerOrderCancelledExceedsPosition`, `MakerOrderCancelledNegativeEquity` or `MakerOrderCancelledStaleMark`. Treat all four as removals. Handling the base `OrderCancelledPreFill` alone covers every guard, present and future. ## Rejections revert A PostOnly that would cross reverts `PostOnlyWouldCross`. A FillOrKill that cannot fill in full reverts `FillOrKillNotFillable`. An IOC that fills nothing reverts `ImmediateOrCancelNoFill`. Binary pools have no batch placement, so every rejection reaches your loop as a revert. Decode the revert data to choose a recovery. `cancelOrder` / `reduceOrder` also revert on already-terminal orders. Check state before cancelling, or you burn a tx slot in your re-center path. ## Markets die on schedule — and respawn Every market has a hard `expiry`; venue rolling series (e.g. hourly up/down) spawn a successor each interval. Placement reverts `TradingNotActive` the moment the market leaves Trading; cancels keep working through `Locked`. Anything resting at lock is free option value to whoever knows the outcome first — pull quotes ahead of expiry, sweep after resolution (`redeem` winners — note a resolved market's winning payout is net of the venue's frozen settlement fee, if any — `mergeCompleteSet` balanced pairs), and watch `BinaryMarketsModule`'s `MarketCreated` to be resting in the successor the moment it opens. ## Infrastructure and latency The public Somnia RPC nodes — `https://api.infra.mainnet.somnia.network/` (mainnet) and `https://api.infra.testnet.somnia.network/` (testnet) — are hosted in **GCP `europe-west4` (Netherlands)** and have **no rate limit**: host your quoting infrastructure in or near that region and submit transactions / read state as fast as your strategy requires. There is no separate market-data service to rate-limit you on this venue — market data *is* the chain, materialized from the event stream ([Raw Integration](./contracts#materialize-the-order-book-from-events)). A 24/7 operation should keep backup RPCs configured (the authoritative provider list, including WSS endpoints, is at [Somnia network-info](https://docs.somnia.network/developer/network-info)), fail over on repeated errors, and backfill any missed log range before trusting its book model again. The public RPCs accept at most 1,001 blocks per `eth_getLogs` request. The Hideki testnet RPC is a separate deployment from the two above; its endpoints are in [Networks](./contracts#networks). ## No session keys — atomicity comes from owning a contract `BinaryPool` has no per-user operator approvals (the `*For` entrypoints are admin-allowlist only), so the operator/fund key split you may run elsewhere doesn't apply. The equivalent isolation *and* atomic multi-order operations come from making your own contract the order owner: it places a full ladder or cancel-and-replaces both sides in one all-or-nothing transaction, and the book never sees a half-updated ladder. --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript The TypeScript SDK for building on **Somnia Markets** — read live market data and place trades on the on-chain order book from your own app. - **Live data, no wallet required.** Market watches stream chain events after hydration. Account views use event updates and SDK-managed polling. Check [read tiers](./reference/read-tiers) for each view's source and cadence. - **Trading with a signer.** Place and cancel orders, mint and redeem outcome shares, and more, through a typed trader bound to your wallet. - **Works anywhere, with first-class React.** Use plain async functions in any environment, or drop in the hooks for components that update themselves. ## Install ```sh pnpm add @somnia-chain/markets-sdk viem # npm / yarn / bun equivalents work too ``` `viem` is a peer dependency. `react` is an optional peer — only needed for the `@somnia-chain/markets-sdk/react` entry. The current package also ships `/chains`, `/reactivity`, and `/native`. These extra subpaths are existing migration debt against [SDK-API-002](https://github.com/somnia-chain/somnia-markets/blob/main/packages/sdk/RULES.md#sdk-api-002--the-package-has-two-approved-entrypoints). Their presence does not establish additional approved entrypoints. Canary builds of validated `main` commits can be published under the npm `canary` dist-tag. A canary is a test build, not a release. Pin its exact version, for example `pnpm add -E @somnia-chain/markets-sdk@canary`, and check its readiness for your environment. The [canary section of the release process](https://github.com/somnia-chain/somnia-markets/blob/main/packages/sdk/RELEASE_PROCESS.md#3a-canary-publication-from-main) describes versions, source identification and limits. > Versions up to 0.19.0 were published to GitHub Packages under the private > `@somnia-chain` scope; from 0.20.0 the package is public on npm — no > registry configuration or token needed. ## Create an exchange `new SomniaMarkets(config)` is the single entry point — the exchange owns everything: symbols, market data, watches, and writes. No global setup, no hidden singleton; each exchange is isolated. ```ts import { SomniaMarkets, SOMNIA_MAINNET_ADDRESSES } from "@somnia-chain/markets-sdk"; import { somniaMainnet } from "@somnia-chain/markets-sdk/chains"; // every Somnia network, incl. Shannon/Elwood/Hideki/local const exchange = new SomniaMarkets({ indexerUrl: "https://prd.smk.somnia.host/v1/graphql", // the production indexer chain: somniaMainnet, wsRpcUrl: "wss://api.infra.mainnet.somnia.network/ws", addresses: SOMNIA_MAINNET_ADDRESSES, // baked-in per-chain constants (SOMNIA_TESTNET_ADDRESSES for testnet) privateKey, // optional — needed for createOrder & friends }); await exchange.loadMarkets(); const book = await exchange.watchOrderBook("BTC-95000-31DEC26/USDC#YES"); // live, zero RTT const order = await exchange.createOrder("BTC-95000-31DEC26/USDC#YES", "limit", "buy", 10, 0.62); ``` For testnet, swap all four: `https://dev.smk.somnia.host/v1/graphql`, `somniaShannon`, `wss://api.infra.testnet.somnia.network/ws`, and `SOMNIA_TESTNET_ADDRESSES`. The raw engine tier — bigint-exact, address-keyed — is reached _through_ the exchange (`exchange.client`, `exchange.trader`), never constructed separately. The WebSocket opens lazily on first chain I/O, so an indexer-only exchange (e.g. server-side GraphQL reads) never opens one. Nothing is shared between instances: per-request servers, parallel tests, a second account — just construct another. Two exchanges never share watch state or sockets. One exchange spans several chains, so a multi-chain bot needs only one — but `exchange.client` and `exchange.trader` name no chain, so on an exchange with more than one chain binding they throw `InvalidInputError`. Reach the raw tier per chain with `exchange.engineFor(chainId)` and `exchange.traderFor(chainId)`. Node RPC and price-feed sockets use an SDK-managed `ws` transport with owned disposal. Replacing a Node global no longer selects their implementation. Pass a conforming standard constructor as `rpcWebSocketConstructor` on `SomniaMarkets` when you need custom infrastructure. Browser default selection is unchanged. The constructor remains yours; every socket it creates belongs to that exchange and must promptly close connecting/open work without replacement dials. See [constructor selection and migration](./reference/configuration#rpc-constructor-selection). New to the SDK? The **[documentation map](https://prd.smk.somnia.host/docs/typescript)** groups every page by need: two tutorials that end with a cancelled order on testnet, goal-shaped how-to guides, reference tables for configuration, symbols, units, and errors, and explanations of the design. The area guides, in reading order: - **[The exchange API](https://prd.smk.somnia.host/docs/typescript/exchange)** — the `SomniaMarkets` class, the SDK's primary surface: symbols (`SOMI/USDC`, `BTC-95000-31DEC26/USDC#YES`), `fetch*`/`watch*`/`createOrder`, human-unit structs. Exchange-bot muscle memory (ccxt included) transfers directly — start here. - **[Spot markets](https://prd.smk.somnia.host/docs/typescript/spot-markets)** — base/quote books: ticks and lots, native-base escrow, market orders, and stop orders. - **[Binary markets](https://prd.smk.somnia.host/docs/typescript/binary)** — YES/NO information markets: probability prices, the four sides, mint/burn/redeem, and a maker loop. - **[Perps](https://prd.smk.somnia.host/docs/typescript/perps)** — live on testnet: cross-margin via the MarginBank, funding, positions, and how perps slot into the `marketType` union. - **[Price feeds](https://prd.smk.somnia.host/docs/typescript/prices)** — realtime BTC/ETH index prices from the on-chain EMA oracle: `watchPrice`/`getLivePrice`, one-shot history + candles, and the React hooks. - **[SomniaLend](https://prd.smk.somnia.host/docs/typescript/lend)** — the third-party money market wrapped as `client.lend`: supply idle collateral, borrow working capital, reserve rates as APYs. - **[Chains](https://prd.smk.somnia.host/docs/typescript/chains)** — every Somnia network as a viem `Chain` (mainnet, Shannon, Elwood, Hideki, local anvil) from `@somnia-chain/markets-sdk/chains`, plus `getSomniaChain(id)`. - **[Bridge](https://prd.smk.somnia.host/docs/typescript/bridge)** — moving tokens between Somnia networks over the Hyperlane warp routes: the token/network enums, the per-network registry, and `createBridgeTransfer` → the unsigned transactions that do it. - **[Native RPC](https://prd.smk.somnia.host/docs/typescript/native)** — the `somnia_*` namespace wrapped as ordinary calls: the native ledger block, chain statistics, protocol parameters, reactivity subscription reads, and **session transactions** (the node holds the key, tracks the nonce, signs, and returns the receipt). - **[Reactivity](https://prd.smk.somnia.host/docs/typescript/reactivity)** — Somnia's event-driven primitive via `@somnia-chain/markets-sdk/reactivity` (a pointer at the upstream `@somnia-chain/reactivity` package): events pushed _with_ the state that goes with them, into TypeScript (`watch`) or into a Solidity handler (`subscribe`). - **[The engine (advanced)](https://prd.smk.somnia.host/docs/typescript/engine)** — the raw tier behind the exchange (`exchange.client` / `exchange.trader`, or `engineFor(chainId)` / `traderFor(chainId)` when it spans several chains): bigint-exact reads, ref-counted watches, React hook wiring, raw writes. - **[Architecture guide](https://prd.smk.somnia.host/docs/typescript/architecture)** — diagrams of the whole machine: the watch seam, event routing, the local order book, the reconnect lifecycle, and the one-round-trip write path. ### Three ways to read | How | What it is | Returns | | ------------------------------------------ | ---------------------------------------------------------------------- | ------------------- | | `client.list*` / `client.get*` | **One-shot** read (indexer GraphQL or on-chain) | a `Promise` | | `client.getLive*` + `client.subscribeLive` | **Synchronous** read off the live store (within a `watchMarket` scope) | a value, now | | `use*` hooks (`/react`) | React bindings over the live store (auto-watching) | re-render on change | So `client.getFills` fetches once; `client.getLiveFills` reads the live tape; `useLiveFills` re-renders a component as it updates. In React, provide the client once with `` (from `@somnia-chain/markets-sdk/react`) and the hooks read it from context. Markets come from one discriminated union — `Market = SpotMarket | PerpMarket | BinaryMarket`, keyed on `marketType` — via `client.listMarkets` / `client.getMarket`. Binary-only callers can use `client.listBinaryMarkets` / `client.getBinaryMarket`, the same query pre-narrowed to `BinaryMarket`. (Note: _binary_, not _clob_ — a spot market is an order book too, so "CLOB" was never the right label for the binary surface.) Money crosses the API as raw integers (bigint on writes, decimal strings from the indexer) scaled by token decimals. Convert at the edges with `fromHuman` (input) and `toHuman` / `toHumanString` (display); for binary prices, `probabilityToPrice` / `priceToProbability` map a YES price ↔ a 0–1 probability. ## What's included - **Entry point** — `new SomniaMarkets(config)` → the exchange (symbols, `fetch*`/`watch*`/`createOrder`, human-unit structs). Its engine tier — `exchange.client` (`SomniaMarketsClient`, bigint-exact reads + watches) and `exchange.trader` (raw writes), or `engineFor(chainId)` / `traderFor(chainId)` on a multi-chain exchange — is reached through it; `ClientConfig` - **React** — `SomniaMarketsProvider`, `useSomniaMarketsClient`, and the hooks `useWatchMarket`, `useWatchUser`, `useLiveStatus`, `useIsTailing`, `useLiveFills`, `useLiveUserFills`, `useLiveMarketByPool`, `useLiveMarketByAddress`, `useLiveUserOrders`, `useLiveBinaryOrderBook`, `useLiveSpotOrderBook`, … — the pool-keyed data hooks **watch automatically** while mounted; the indexer, price, funding, and lend hooks are listed in the API reference - **Client reads** — `client.listMarkets`/`getMarket` (the `Market` union), `listBinaryMarkets`/`getBinaryMarket`, `getCandles`, `getBinaryOrderBook`, `getOpenOrders`, `getPortfolio`, `getSyncStatus`, `getMarketOnchain`, `getSystemInfo`, … (indexer reads **throw** on failure — an empty result always means "no rows", never "request failed") - **Order state at chain head** — `getOrderOnchain(pool, orderId)`, `getOwnOpenOrdersOnchain(pool, owner)`, `getAllOpenOrdersOnchain(pool, { isBid })` answer from the pool contract, so an order is readable the moment its block lands. Use these to read your own writes; use the indexed `getOpenOrders` / `getOrders` for history — the chain surface only knows what is open **now**. `getOrderOnchain(pool, orderId, { blockNumber })` reads at the end of a past block instead, which is how a log consumer recovers the side and owner of a maker order the fill already removed; the fill's own numbers stay with the event, and an old block needs an archive node - **Live watches (no React)** — `client.watchMarket(pool)` / `watchMarkets({ discover })` / `watchUser(account)` → ref-counted handles; `getWatchStatus`, `subscribeLive`, `getLiveStatus`, `getLiveMarkets`, `getLiveMarketByPool`/`…ByAddress`, `getLiveFills`, `getLiveUserFills`, `getLiveUserOrders`, and the locally materialized resting books `getLiveBinaryOrderBook` (binary, 4-sided) / `getLiveSpotOrderBook` — synchronous, zero round-trips, scoped to what you watch. Every market kind streams; a discovery watch picks up new markets from the creation events (the MarketCreator's rolling series AND direct `BinaryMarketsModule.createMarket` markets); binary status/resolution stays current from chain events. - **Smart accounts** — the factory account model of [`@somnia-chain/accounts-sdk`](https://www.npmjs.com/package/@somnia-chain/accounts-sdk), through this SDK alone: `client.createExecutor({ smartAccount, inner })` sends writes AS an account (a session key that owns the account signs nothing, and an approval and its order land in ONE account transaction), `client.accounts` derives, discovers, creates and administers accounts (every write comes back as an unsigned call for any executor to send), and `exchange.accounts` spans the chains — one signature creates the account on every covered chain, and `accountFor` builds the multi-chain account the exchange trades through. See [docs/ACCOUNTS.md](./accounts) - **Trading** — `client.createTrader(...)` → `placeOrder`, `cancelOrder`, `approveBuilder` (opt a routing/builder frontend in for per-order builder fees), `placeSpotOrder`, `placeSpotStopOrder`, `mintSet`, `burnSet`, `redeem`, `faucet`, `resolve`, `voidMarket`. Each write **awaits its receipt** and resolves to `{ hash, receipt }` (`placeOrder` adds `orderId` + `fills`). With a `privateKey`/local `account` the SDK signs locally with **fixed fees** and a locally-tracked nonce, and sends via `eth_sendRawTransactionSync` — send + confirm in **one round-trip**, zero fee/nonce/gas estimation RPCs. In the browser, pass a `walletClient` (confirm rides the newHeads subscription). - **Contract ABIs** — the minimal ABI data the SDK itself encodes and decodes with, exported so a caller that builds a transaction or reads a receipt by hand uses the SAME signatures: the order-placement writes (`binaryPoolWriteAbi`, `spotPoolWriteAbi`, `perpPoolWriteAbi`, `orderBookBatchWriteAbi`), the funding writes (`erc20WriteAbi`, `erc20VaultWriteAbi`, `marginBankWriteAbi`), operator delegation (`operatorRegistryWriteAbi`), the spot stop-order lifecycle (`spotStopRegistryWriteAbi` + `spotStopRegistryEventsAbi` — created, triggered, cancelled, inert-cancelled), the shared order-book events (`orderBookEventsAbi`), and the binary module / settlement / ERC-6909 / OracleHub ABIs. Hand-copying a signature instead is how one drifts in silence - **Operator delegation** — `client.getOperatorPermissionsRegistry(pool)` names the registry a SpotPool actually gates operator calls through (`null` when the pool is unwired), so a caller can discover the address instead of configuring `addresses.operatorPermissionsRegistry`. Read a grant back with `client.isApprovedForPool` / `isGloballyApproved` (the raw slots) or `client.isOperatorAuthorized` (the pool's resolved answer) - **Types & helpers** — `Market`/`SpotMarket`/`BinaryMarket` (+ `isSpotMarket`/ `isBinaryMarket`), `LiveFill`, `LiveOrder`, `BinarySide`, `TailStatus`, `kindOf`, `fillKind`, `fromHuman`/`toHuman`, `DECIMALS`, … - **Chains** (`@somnia-chain/markets-sdk/chains`) — `somniaMainnet` (5031), `somniaShannon` (Shannon, 50312), `somniaElwood` (50313), `hidekiTestnet` (50383, 10 ms blocks) and `somniaLocal` (anvil, 31337) as viem `Chain`s, plus `somniaChains` / `getSomniaChain(id)` / `isSomniaChainId`. Nothing here imports chains from `viem/chains` any more - **Bridge** (same `/chains` entry) — the Hyperlane warp routes between Somnia networks as live-verified data (`BridgeToken`, `ChainId`, `SOMNIA_BRIDGE`, `getBridgeToken`, `getBridgeNetwork`, …) plus `createBridgeTransfer(...)` → the ordered, tagged **unsigned transactions** (`approve` then `bridge`) that move a balance. The registry and planner are pure (no RPC, no signer); `sendBridgeStep` is the one opt-in sender. ⚠️ dev/test bridge — not for real funds - **Native RPC** (`@somnia-chain/markets-sdk/native`) — `createNative(client)` wraps the node's `somnia_*` namespace, exactly the twelve methods in the [public JSON-RPC reference](https://docs.somnia.network/developer/json-rpc-api): `getBlock` (the native ledger block), `getStatistics`, `listPrivilegedReceipts`, `getNodePublicKeys`, the reactivity subscription reads, and `sendSessionTransaction` — plus `sessionAddress(seed)` / `sessionPrivateKey(seed)`, derived locally with no round-trip. Takes any EIP-1193 client, needs nothing else from the SDK - **Reactivity** (`@somnia-chain/markets-sdk/reactivity`) — a pointer at [`@somnia-chain/reactivity`](https://www.npmjs.com/package/@somnia-chain/reactivity) (optional peer dep, re-exported verbatim — no second copy of it here): `createReactivity(exchange.client)` (or `exchange.engineFor(chainId)` when the exchange spans several chains) → `watch` (a `somnia_watch` socket subscription that delivers each event _together with_ `eth_call` results from the same block), `subscribe` / `subscribeRaw` / `unsubscribe` (Solidity handler subscriptions via the precompile) and `scheduleSubscriptionAt{Timestamp,Block,Epoch}`, plus `unwrap()` to turn upstream's `Error`-returns into throws Every method, hook, and type is listed in the **API reference**. ## Using a query library (TanStack Query, SWR, …) The SDK deliberately ships no cache-library wrapper. Two rules cover the whole surface: - **Live hooks need no cache.** The `useLive*` hooks read a push-fed store that is shared within one owner — ref-counted watches and deduped hydration mean ten components on one pool cost one subscription. Wrapping them in a query cache would cache a value that is already live; don't. - **Async reads go in YOUR query library**, with an `exchange.client` method as the `queryFn` and the SDK's exported key factory as the `queryKey`. Every client read is a plain promise, which is already the ideal `queryFn`, and the key factories (`marketsKey`, `portfolioKey`, `candlesKey`, `syncStatusKey`, `marketOnchainKey`, …) are plain functions from the root entry — no query library is imported, so they work with any of them. Most client reads use `ClientConfig.signal` for cancellation. Account-view `getSnapshot` methods also accept a per-request `signal`; canceling one caller does not cancel other callers sharing its read. Check the method's options before forwarding a query library's signal. ```tsx import { useQuery } from "@tanstack/react-query"; import { candlesKey } from "@somnia-chain/markets-sdk"; const { data: candles } = useQuery({ queryKey: candlesKey(pool, 60, { limit: 500 }), queryFn: () => client.getCandles(pool, 60, { limit: 500 }), refetchInterval: 15_000, }); ``` After a write, invalidate by the same factory — `queryClient.invalidateQueries({ queryKey: portfolioKey(account) })` — or everything SDK-shaped at once with the `["somnia-markets"]` prefix (`QUERY_KEY_SCOPE`). Hand-written key strings drift; the factories are the one canonical spelling per read. On a multi-chain exchange, pass the read's chain as the last argument of every key, for example `candlesKey(pool, 60, { limit: 500 }, { chainId })`. A key without `chainId` is the single-chain key. As a prefix filter it also matches every chain's entry, but a chained key never matches an entry stored without a chain. Outside a query library, `useIndexerQuery(fn, deps)` remains the built-in option: it re-runs on dep changes, keeps `data`/`loading`/`error`, and aborts a superseded request via the `AbortSignal` it passes to `fn`. ## How the live feed works You get instant updates without running your own indexer — scoped to exactly the markets you watch. Opening a watch loads a consistent snapshot of that scope, then keeps it current by streaming its on-chain events over a WebSocket — so trades, orders, prices, and the resting order book update from the delivered events. If the connection drops, market watches reconnect with backoff and backfill missed blocks from chain. Account balances poll every 10 seconds. Account spot orders have a periodic chain-read backstop, and outcome positions poll the indexer's version every 5 seconds after successful reads. Perp-position views also use fallback reads when pushes stop. These timers belong to the owner. See [read tiers](./reference/read-tiers) for retry and cleanup behavior. ## Debugging The SDK is silent by default. To see what a client is doing — every trader call, the sign/broadcast pipeline, live-tail hydration and block application — pass a `debug` sink in the config. Events are structured data (`DebugEvent`: log lines plus span start/end pairs with ids, explicit `parentId` links, durations, and errors), so the sink owns all filtering and formatting. The toggle mechanism belongs to your app, not the SDK: The bundled `consoleDebugSink()` renders the stream as an indented span tree (reconstructed from `parentId`, so it stays correct under concurrency): ```text [sdk] ▶ trader.placeOrder { params: { pool: "0x…", side: "BUY_YES" } } [sdk] ▶ trade.execute { functionName: "placeBinaryOrder", … } [sdk] liveTail applying logs { received: 3, … } [sdk] ▶ trade.signCall [sdk] ◀ trade.signCall 2.1ms [sdk] · trade.execute { hash: "0x…" } [sdk] ◀ trade.execute 38.2ms [sdk] ◀ trader.placeOrder 41.0ms ``` ```ts import { consoleDebugSink } from "@somnia-chain/markets-sdk"; // Browser (explorer dev) — flip on from devtools with // localStorage.setItem("sdk-debug", "1") and reload: const exchange = new SomniaMarkets({ ...config, debug: localStorage.getItem("sdk-debug") ? consoleDebugSink() : undefined, }); // Node bot — JSON lines behind an env var: const exchange = new SomniaMarkets({ ...config, debug: process.env.SDK_DEBUG ? (e) => console.log(JSON.stringify(e, (_, v) => (typeof v === "bigint" ? v.toString() : v))) : undefined, }); ``` In tests, `debugCollector()` captures the stream with typed filters: ```ts import { debugCollector } from "@somnia-chain/markets-sdk"; const c = debugCollector(); const exchange = new SomniaMarkets({ ...config, debug: c.sink }); await exchange.trader.placeOrder(params); expect(c.starts("trade.execute")).toHaveLength(1); ``` Span events map 1:1 onto OpenTelemetry (`name` ↔ span name, `data` ↔ attributes, `error` ↔ status, `parentId` ↔ context link), so a real tracer is just a sink that keeps a `Map` — `phase: "start"` calls `tracer.startSpan(...)` (linking `parentId` via OTel context) and `phase: "end"` calls `span.end()`. The OTel dependency lives entirely in your app. ## Book provenance and live-tail integrity Book reads expose `blockNumber` so consumers can identify the data they hold. A native chain read pins both sides to one block. Supply `blockNumber` in the read options, or let the SDK select one head. Empty chain books retain that pin. Unified `fetchOrderBook` accepts `{ limit, blockNumber }` and preserves the pin in YES and NO views. A caller-built book can omit provenance. Live books expose a per-scope materialization watermark. It is the snapshot seam or highest applied event block for that scope. It is not the observed chain head. It does not prove that all events in that block arrived, or that an indexed seed equals chain state. The displayed live book also filters expiry using the local wall clock. Use a pinned chain read when an exact as-of chain snapshot is required. Historical and streaming unified trades expose their source `blockNumber` and `logIndex`, including NO views. Historical native fill rows retain decimal block strings. These fields are optional on `FillRow` and `UnifiedTrade` for compatibility with caller-built values. SDK reads populate them from the source. If a caller-built row omits either field, its unified trade keeps that field `undefined`; the SDK does not substitute block zero or log zero. A later log delivery checks any intervening range with chunked RPC log reads. Recovered events apply before the triggering later events. Head-only activity makes no default recovery reads. An empty probe is a quiet range; only a nonempty successful recovery increments `getLiveStatus().healedGaps`. Background failures are contained and visible in `getLiveStatus().failure`, with a semantic SDK error. The tail retains its range and retries with bounded reconnect backoff. It does not apply later batches across a failed recovery. This detects bounded block gaps. Adjacent delivered blocks can conceal a partially missing block. Missing terminal activity has no default trigger if no later log arrives. The SDK does not claim a gapless transport protocol. Optional owner `reconciliation` accepts a positive bigint `blockInterval` and integer `depth` from 1 to 100. It is off by default. At each eligible checkpoint, it recovers logs through a fixed target and compares native top levels against both chain sides pinned to that target. It filters local expiry using the target's chain timestamp. Head work coalesces while one comparison runs. Each checkpoint costs chunked log reads, a block header, and two contract reads per watched pool. `lastDivergence` retains the pool, block, depth, and differing raw levels even after a later match. Read failures remain distinct from empty or matching books. Reconciliation never replaces the event-derived order history with a top-N ladder. It does not repair historical seed rows. Owner cleanup stops pending work. The following example compares provenance labels. `config` selects the local or deployed environment, and `pool` is a pool in that environment. Choose a block that exists on that chain for the pinned read. The two labels describe different read contracts; equal labels alone do not prove equal books. ```ts const owner = new SomniaMarkets({ ...config, reconciliation: { blockInterval: 100n, depth: 10 }, }); try { const watch = await owner.client.watchMarket(pool); const live = owner.client.getLiveSpotOrderBook(pool); const pinned = await owner.client.getSpotOrderBook(pool, { blockNumber: 123n }); console.log({ delivered: live.blockNumber, pinned: pinned.blockNumber }); console.log(owner.client.getLiveStatus()); watch.stop(); } finally { await owner.close(); } ``` --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/documentation-map This page is the map of the `@somnia-chain/markets-sdk` documentation. The pages are grouped by what you need right now: to learn, to finish a task, to look something up, or to understand a design. Every TypeScript example has a compile-checked counterpart that uses the public package entrypoints and real dependency types. The tutorials were run on the testnet. ## Start here Two lessons take a newcomer from an empty directory to a cancelled order on the Somnia testnet. Follow them in order. Each one takes about 15 minutes. 1. [Stream a live order book](./tutorials/stream-a-live-order-book). No wallet needed. Install the SDK, load the markets, read a book, and watch it change. 2. [Place and cancel your first order](./tutorials/place-and-cancel-your-first-order). Fund a testnet key, rest a limit order, see it in your open orders, and cancel it. ## Do a task Goal-shaped guides for a developer who already knows what they want. - [Configure the SDK for testnet, mainnet, or a local chain](./how-to/configure-for-a-network) - [Get testnet funds](./how-to/get-testnet-funds) - [Run a quoting loop](./how-to/run-a-quoting-loop): place, amend, and cancel orders from a bot - [Detect when an order fills](./how-to/detect-fills) - [Follow an account's open orders](./how-to/follow-an-account): one complete, live view of an account's orders and stops, its spot orders, its perp positions and its wallet balances - [Sign with a browser wallet](./how-to/sign-with-a-browser-wallet) - [Trade through a smart account](./how-to/trade-through-a-smart-account): the accounts SDK's factory accounts as first-class actors - [Use the React hooks](./how-to/use-the-react-hooks) - [Handle errors and reverts](./how-to/handle-errors) - [Debug what the SDK is doing](./how-to/debug-the-sdk) ## Look something up Neutral descriptions of the machinery. Facts, defaults, and constraints, without instruction. - [Configuration](./reference/configuration): every field of `SomniaMarketsConfig`, with defaults - [Networks and endpoints](./reference/networks-and-endpoints): chain ids, RPC and indexer URLs, address constants - [Symbols](./reference/symbols): the symbol grammar and how raw references resolve - [Units and scales](./reference/units-and-scales): raw versus human values, decimals, basis points, ray, timestamps - [Read tiers](./reference/read-tiers): which methods read the live store, the chain, or the indexer - [Errors](./reference/errors): the SDK error classes and the contract error names they decode - The API reference, generated from the source, lists every method, hook, and type. On the docs site it sits in the sidebar under "TypeScript SDK". ## Understand the design Discussion for reading away from the keyboard. - [About the exchange instance](./explanation/about-the-exchange-instance): why one object owns the sockets, the store, and the signer - [About symbols and human units](./explanation/about-symbols-and-human-units): why the exchange API speaks in symbols and decimal numbers, and what that costs - [Binary pool recycling](./explanation/binary-pool-recycling): why one pool address can host successive markets and how to select safe reads - [Architecture](./architecture): the watch seam, event routing, the local order book, reconnects, and the write path ## Area guides One guide per market family or module. Each one covers reading, trading, and background for its area. - [The exchange API](./exchange), [Spot markets](./spot), [Binary markets](./binary), [Perps](./perps) - [Price feeds](./prices), [SomniaLend](./lend), [Smart accounts](./accounts) - [Chains](./chains), [Bridge](./bridge), [Chains walkthrough](./chains-walkthrough) - [Native RPC](./native), [Reactivity](./reactivity) - [The engine](./engine): the raw client behind the exchange --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/exchange 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. ```ts 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](./engine). ## 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](./prices) | | `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](./spot) | | `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](./reference/fill-history-pages) 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](./reference/fill-history-pages#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. ```ts 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: ```ts 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: ```ts 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's `quoteBinaryStake` / `quoteBinarySell` helpers default to 3% with a 10-tick floor) and send IOC; they throw `InvalidInputError` if the opposite side is empty. When the chain rejects an order you get a `ContractRevertError` whose `errorName` is the contract's own error — see [ENGINE.md § Errors](./engine#errors--branch-on-the-type-never-the-message). - `params.timeInForce: "GTC" | "IOC" | "FOK" | "PO"` (or `postOnly: 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-time `exchange.traderFor(chainId).approveBuilder({ pool, builder, maxFeeBpsTimes1k })` opt-in on the pool's chain (for example `exchange.traderFor(50383)` for a Hideki pool), else a non-zero fee reverts (`BuilderFeeExceedsCap` on binary, `BuilderNotApproved` on spot, `BuilderFeeExceedsApproval` on 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"` throws `CollateralOnWrongChainError` with a ready bridge plan and sends nothing; `"auto"` bridges the shortfall the way `exchange.ensureFunds` does, 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`/`remaining` say how much), or `"canceled"` (IOC remainder). `info` carries the receipt + raw fills, and `chainId` names 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. ```ts 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 throws `NotConfiguredError`. On Hideki (50383), the first chain read first reads block 1: an RPC on the retired v1 genesis throws `NotConfiguredError`. - 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 }` — a `ChainMarketRef` — wherever a symbol is accepted, and `{ chainId, pool, id }` to `cancelOrder`. - Live and history rows name their chain too. `LiveFill`, `LiveOrder`, `LiveFundingUpdate`, `LiquidationEvent` and `FundingPayment` carry `chainId`, 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.client` and `exchange.trader` name no chain, so they throw `InvalidInputError` on an exchange with two or more chains. A pool address or market id can name a different market on each chain. Use `exchange.engineFor(chainId)` for raw reads and `exchange.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.bridge` plans and executes transfers between the exchange's chains through the account's executors, and `exchange.ensureFunds({ code, chainId, amount })` is the helper behind `createOrder(…, { funding: "auto" })`. On a native route (STT), all three throw `BridgeLiquidityError` and 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, `ensureFunds` and `createOrder(…, { funding: "auto" })` throw `BridgeDeliveryTimeoutError` with the `messageId` and place no order. Check `delivered(messageId)` on the destination Mailbox before you bridge again. See [the bridge guide](./bridge#bridging-through-the-exchange). - On one exchange, `ensureFunds` and `createOrder(…, { 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 with `engine.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 implementing `Executor` works 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 as `failure` on a success-status receipt, Trader writes throw it. - A factory smart account from `@somnia-chain/accounts-sdk` is 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 through `through`. The whole lifecycle — derive, create on every chain with one signature, manage owners, move funds — is `client.accounts` and `exchange.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's `inner`. See the [Smart accounts guide](./accounts) and [Trade through a smart account](./how-to/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: ```ts const results = await Promise.allSettled(exchange.chainIds.map((chainId) => exchange.fetchBalance({ chainId }))); ``` - A `chainId` that the exchange does not span throws `NotConfiguredError`. - A market and a `chainId` on different chains throw `InvalidInputError`, with both chains in `chainIds`. - 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. `indexerByChain` holds every chain that was measured, and `null` there still means that the chain's metadata row is absent. `indexerErrorByChain` holds 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, with `chainId` set on an exchange with two or more chains. - `loadMarkets()` does not throw on a failed registry read or a failed perp discovery. `registryReadErrorsByChain`, `perpDiscoveryErrorsByChain` and `perpDiscoveryPartialFailuresByChain` report the failures of each chain. `registryReadError`, `perpDiscoveryError` and `perpDiscoveryPartialFailure` still report one failure: the first failing chain, in the order of the `chains` config. - 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. A `NotConfiguredError` failure, 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`, `fetchMyTrades` or `fetchPositions`, retries a failed chain at most once in 30 seconds after that chain's last load finished. `loadMarkets()`, `fetchMarkets()`, `ensureFunds` and `loadMarkets(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; `side` is 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`/`redeem` operate at market level. - **Perps** — `type: "swap"`, settle = quote (linear, collateral-settled); `createOrder` unchanged (margin locks from your MarginBank balance — `depositMargin` first); `fetchPositions`/`fetchFundingRate` read 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 in `watchMyTrades` with a shared match id in `info`. The design intent: **the verbs never change again** — every future market kind is new data (a `type`, an `outcomes[]` list), not new API. --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/binary A binary market is an order book over a YES/NO question ("Will BTC be above $95k at expiry?"). Each market has its own `BinaryMarket` contract (lifecycle, resolution) and uses a `BinaryPool` (the CLOB + escrow) that can host later markets. Its YES and NO outcome tokens are backed 1:1 by collateral. This guide covers reading and trading them; [pool recycling](./explanation/binary-pool-recycling) explains why the pool address does not identify one binary market. The shared client mechanics (watches, read tiers, signers) are in [the engine guide](./engine). ## The mental model - **Prices are YES probabilities.** Every price in the API is the YES price in raw collateral units per whole outcome token — `620000` with 6-decimal collateral means 0.62, i.e. a 62% implied probability. Convert with `probabilityToPrice` / `priceToProbability`, and `fromHuman` / `toHuman` at the UI edges, passing the selected market's `quoteDecimals`. On the active mainnet venue USDso has 18 decimals; on Shannon and Hideki tUSDC has 6. The NO side is always the complement: a NO at 0.38 _is_ a YES at 0.62. The pool keeps one book in YES terms. - **Four sides, one book.** `BinarySide` is `BUY_YES | SELL_YES | BUY_NO | SELL_NO`. Buys escrow collateral; sells escrow the outcome token you're selling. Opposite-side orders can match by _minting_ a fresh YES+NO pair from collateral (two buyers) or _burning_ one back to collateral (two sellers) — the `BinaryFillKind` on a fill tells you which. - **Lifecycle.** `Listed → Trading → Locked → Settling → Resolved | Voided → Finalized` (`BinaryMarketStatus`; `Finalized` once the settlement snapshot is swept and the pool released). Trading is only possible in `Trading`; after resolution the winning token redeems 1:1 for collateral, net of the venue's one-time settlement fee if it configured one. A voided market pays BOTH sides instead, from the payout vector it stored at void time, never fee'd — so redeeming one names the leg (see Complete sets and settlement). - **Markets roll themselves.** Each cadence (1m / 5m / 15m / 1h / 4h / 24h) is a _series_ that a `MarketCreator` contract advances autonomously — at every wall-clock boundary it creates the next `BinaryMarket`, uses a released `BinaryPool` when available or deploys a new one, and schedules the oracle question. Series markets are **reference-mode**: there is no fixed strike (the event's `strike` is always 0) — a market resolves YES iff its own oracle answer is at/above the previous boundary's _reference_ answer. Markets can also be created directly via `BinaryMarketsModule.createMarket` under an operator's venue (these can be bucket-mode, with a real strike). Discovery is event-driven either way: an all-markets watch with `discover` picks each new market up from the creation events (see below). You don't need to create markets — you watch, trade, and redeem them. ## The series, and how a market is born `MarketCreator` runs the whole protocol off **one** Somnia reactivity `Schedule` subscription (the precompile at `0x0100`). When it fires, a single callback drains that boundary's whole batch of due series in a **gas-bounded loop** — rolling series until the gas envelope runs low — then re-arms the `Schedule` sub for the next-earliest pending boundary. A batch too large for one callback (the 00:00-UTC collision, where every cadence lands at once) saves a resume index and arms a sub for the **next block** to finish the spill. Exactly one subscription is ever live. (Historical note: an earlier one-series-per-hop `RollNext` reentrancy chain was tried and removed — on live Somnia the self-emitted event wasn't re-queued after a heavy roll, so it stalled after one hop. The gas-bounded loop with next-block spill replaced it.) Each roll emits, from the `MarketCreator`: ``` MarketCreated( bytes32 marketId, address market, address pool, uint256 yesId, uint256 noId, address collateral, string asset, uint256 strike, // always 0 — series markets are reference-mode (no fixed strike) uint64 tradingStart, uint64 expiry, uint256 oracleQuestionId, string question, uint64 intervalSec // series cadence: 60 / 300 / 900 / 3600 / 14400 / 86400 ) ``` Every market — series-rolled or not — ALSO fires the `BinaryMarketsModule`'s own 20-field `MarketCreated`, the only creation event carrying the `(operatorId, venueId)` origin attribution. A discovery watch (`watchMarkets({ discover: true })`) listens to both, so module-created markets that never pass through a `MarketCreator` join the watch live too. - **Read cadence from `intervalSec`, and treat it as approximate.** A series' first market is a _bootstrap partial_ (trigger time → the next aligned boundary), so its window is shorter than the cadence. On the SDK's `BinaryMarket` type the cadence surfaces as `intervalSec?: string | null` (`null` when a market predates the field). For a `MarketCreator`-rolled market the indexer patches the exact cadence from `MarketCreator.MarketCreated.intervalSec`, so the field joins by equality. Module-created markets, and rows indexed before that patch existed, still carry the window-derived `expiry − tradingStart`, which a late roll shortens to 898s or 899s on a 15m series. `BinaryMarketFilter.intervalSec` bands the value (± `CADENCE_TOLERANCE_SEC`) and `snapToCadence` groups by the same rule, so both kinds of row match; use those rather than comparing the raw field. **Resolution is a separate loop.** `OracleCore` emits `AnswerPosted(uint256,uint8,string,int256,bool,uint8,uint256[])` when the oracle answers a question. `OracleHub` subscribes to that event on the `OracleCore` address and routes the answer through `BinaryMarketsModule`. The SDK sees a `Resolved` or `Voided` lifecycle event from the individual `BinaryMarket` — gate redemption on that, per the rule below. Permissionless `voidExpired()` can also emit `Voided` without an oracle answer. ### Event emitters For a raw log or Reactivity subscription, filter by the contract that **emits** the event. The module, settlement contract, market and pool are separate log sources. Use `config.addresses` for configured singletons on the selected chain, the selected market's `marketAddress` and `poolAddress` for clones, and `smart-contracts/deployments///addresses.json` for `OracleCore`. An existing receipt's `log.address` identifies its actual emitter. The SDK's `SOMNIA_TESTNET_ADDRESSES` and `SOMNIA_MAINNET_ADDRESSES` are generated from their respective deployment manifests. `marketCreator` names a configured creator; other venues can have their own creator, available as `creator` on the module's `MarketCreated` event. For historical `AnswerPosted` logs, select the OracleCore deployment that emitted those answers if the active core changed. | Event signature | Emitting contract | Filter address | | ------------------------------------------------------------------------ | ------------------------------------------------------------------------- | ------------------------------------------------ | | `MarketCreated` (series, 13 arguments) | `MarketCreator` on a series roll | Venue's `marketCreator` | | `MarketCreated` (module, 20 arguments) | `BinaryMarketsModule` on every creation, including a series roll | `binaryModule` | | `AnswerPosted(uint256,uint8,string,int256,bool,uint8,uint256[])` | `OracleCore`, **not** `OracleHub` | Matching manifest's `OracleCore.address` | | `Resolved(uint32,uint256[])` / `Voided()` | Individual `BinaryMarket` | Market's `marketAddress` | | `MarketFinalized(bytes32,address,uint256)` | `BinaryMarketsModule`; carries `marketId` | `binaryModule` | | `MarketFinalized(uint256,address,uint64,address,uint256,bool,uint256[])` | `BinarySettlement`; carries `netBacking` | `binarySettlement` | | `PoolFinalized(uint64,uint256)` | Individual `BinaryPool`; its backing was swept | Market's `poolAddress` and indexed `marketNonce` | | `PoolReleased(bytes32,address,address)` | `BinaryMarketsModule`; the pool can be reused | `binaryModule` | | `Redeemed(uint256,address,address,uint8,uint256,uint256)` | `BinarySettlement`; tokens burned; collateral paid or owed, possibly zero | `binarySettlement` | A series roll can produce both `MarketCreated` events for one market; deduplicate by `marketId`. Their canonical signatures for topic0 are: ```text MarketCreated(bytes32,address,address,uint256,uint256,address,string,uint256,uint64,uint64,uint256,string,uint64) MarketCreated(bytes32,address,address,uint256,uint32,bytes32,address,address,uint256,uint256,uint64,uint8,uint8,uint64,uint64,uint8,string,uint256,string,bytes) ``` A pool can serve later markets, so its address alone does not identify one market's `PoolFinalized`: match the selected market's `nonce` to the event's indexed `marketNonce` (topic1). The first `MarketFinalized` payload is `(marketId, pool, marketKey)`; the second is `(marketKey, pool, nonce, collateralToken, netBacking, voided, payoutNumerators)`. Their topic0 values differ. On Shannon, a subscriber for settlement `netBacking` must filter the settlement event at `SOMNIA_TESTNET_ADDRESSES.binarySettlement`, not the three-field module event at `SOMNIA_TESTNET_ADDRESSES.binaryModule`. The address is the settlement **proxy** (`0xbF4a49e0Dfd092e5FBE8E5761064C49533e6Ed23` in the Shannon `50312/testnet-development` manifest), not its implementation or the pool. See [Reactivity](./reactivity) for `watch` and `subscribe` filter fields. ### Oracle question links An `oracleQuestionId` is scoped to its oracle deployment. Choose the host from the **market's chain ID**, not from the numeric ID: | Market chain | Question page | | ----------------------- | ------------------------------------------------------------- | | Shannon testnet (50312) | `https://dev.oracle.somnia.host/questions/` | | Somnia mainnet (5031) | `https://prd.oracle.somnia.host/questions/` | For example, a Shannon market's ID belongs at `https://dev.oracle.somnia.host/questions/`; a mainnet ID belongs at the production host. The same number is not a cross-host key. These are oracle **question** pages; the `price-feed.*.oracle.somnia.host` endpoints in the [network reference](./reference/networks-and-endpoints) serve price-feed GraphQL instead. For another chain, verify its oracle host before constructing a link. ## Reading Watch the market, then read the live store — synchronous, zero round-trips, current to the last block: ```ts const watch = await client.watchMarket(market.poolAddress); const book = client.getLiveBinaryOrderBookByMarket(market.id, { depth: 11 }); // { yesBids, yesAsks, noBids, noAsks } — NO sides derived as 1 − yesPrice const tape = client.getLiveFills(market.poolAddress, { limit: 40 }); const live = client.getLiveMarketByPool(market.poolAddress); // status, lastPrice, volumes ``` The book read above uses `market.id` so a page for an older market shows an empty book after its pool is recycled. The live market read describes the pool's current occupant. The pool-keyed tape can include fills from successive markets on that address; it does not identify one market. For a chain snapshot, `getBinaryOrderBook(pool)` also reads the pool's current book without a market-id check. See [pool recycling](./explanation/binary-pool-recycling) before using a pool address to read positions or history. In React, mount `useWatchMarketResult(pool)` or another pool-keyed watch hook alongside `useLiveBinaryOrderBookByMarket(marketId)` when the selected market is not yet in the live store. The market-scoped hook can watch the pool itself only after the market is known; it keeps an older market's book empty after recycle. `useLiveBinaryOrderBook(pool)` and `useLiveMarketByPool(pool)` describe the current occupant. Pool-keyed `useLiveFills(pool)` and `useLiveUserOrders(pool, account)` can include activity from successive markets. Discovery and history come from the indexer tier: `listLiveBinaryMarkets()` / `listPastBinaryMarkets({ limit, offset })` for the market lists, `getCandles(pool, interval)` for charts, `getPortfolio(account)` (trades default to the last seven days — `tradesSince` echoes the bound; pass `since` to widen) for a wallet's positions + orders + trades in one round-trip. To start a selected binary book when the indexer is down, pass `chainSeedPools={[{ chainId, pool }]}` to `SomniaMarketsProvider`. Every pool-watching hook under that provider uses the same chain seed policy, even when a child hook mounts first. The provider reads the pool's current market binding from chain and requires `addresses.binaryModule` on that chain's client. Set this list before mounting the provider; remount to change it. Unlisted spot and funding pools retain their normal indexer seed. Read `usePoolSeedStatus(pool)` for that pool's `seedSource` and `fillsLoaded`: a chain-seeded book can be live while fill history remains unloaded. Discovery, candles, portfolio and other indexed history still need the indexer. `listLiveBinaryMarkets()` with no argument returns every live market; pass a filter to narrow (all fields optional, applied server-side): ```ts await client.listLiveBinaryMarkets(); // all live markets await client.listLiveBinaryMarkets({ operatorId: 1 }); // one operator's live board await client.listLiveBinaryMarkets({ venueId: "0x5bc0…", asset: "BTC", intervalSec: 900 }); await client.listLiveBinaryMarkets({ status: "Trading" }); // active only ``` (`operatorId` is the uint32 operator id; `venueId` is the venue's opaque bytes32 hex id — enumerate the pairs in play with `listBinaryVenueIds()`.) For the underlying-asset universe and board size, use `listBinaryAssets()` (the distinct assets with markets, e.g. `["BTC","ETH"]`) and `countBinaryMarkets({ operatorId?, venueId?, asset?, status?, phase? })` (the total behind a filtered board, for pagination headers; `phase` splits live from past, and omitting it counts both). The same filter carries `resolvedSince` and `voided`, so `countBinaryMarkets({ venueId, resolvedSince: now - 86_400 })` and the same call with `voided: true` are a venue's settlement count and void count over the trailing day — keyed on the terminal timestamp, so a void before expiry counts too. Each row carries `marketAddress`, `poolAddress`, `operatorId`, and `venueId`, so a multi-venue UI can group the live board by origin without any contract-level call — market discovery is the indexer's job, not the MarketCreator's. One correctness rule: **gate writes on the selected market's live or on-chain status, never indexer status**. `getMarketOnchain(marketId).status` is a market-scoped chain read; `marketId` is the selected market's bytes32 id. Use the status from `getLiveMarketByPool(pool)` only after checking that the live row's `id` matches the selected market id. The pool can host another market after recycling, and indexed status lags. ### An account's positions, complete and live `client.accountOutcomePositions` gives one account's complete event-contract positions on the client's chain: every YES and NO balance it holds, including markets it never traded through your app. On an exchange, use `exchange.engineFor(chainId).accountOutcomePositions`. ```ts const stop = client.accountOutcomePositions.subscribe(account, (view) => { if (view.status === "ready") render(view.data.positions); else if (view.status === "unavailable") renderUnknown(view.reason); }); ``` - `getSnapshot(account, { signal })` reads the view once. Concurrent snapshot callers for one account share a read with independent cancellation; a ready live view is reused without a request. `subscribe(account, onView)` keeps it current and returns a function that stops it. Subscriptions to one account share one reader. After the last unsubscribe, it remains active for 30 seconds so a returning subscriber can reuse it. Successful reads poll the account's version every 5 seconds and reread the guarded snapshot when it changes. Recoverable failed reads retry after 5, 10, 20, 40, then 60 seconds, capped at 60 seconds. An `unavailable` capability is checked every 5 minutes over HTTP; this view has no capability subscription, so deployment recovery can take that long to be detected. Recovery restores the 5-second cadence. Held data's clock-derived market status is checked every 5 seconds during backoff. `tooLarge` continues version polling without rereading unchanged oversized rows. `stopLive()` and owner cancellation end every reader immediately, including during reuse. Active subscribers receive `closed`, with no later view. `stopLive()` ends a snapshot read in flight with `IndexerUnavailableError` (`live-reset`); caller cancellation retains the caller's abort reason. - Each position has the market id, the side, the balance, the market's status and `claimable`: `unsettled` until the market is finalized, then `known` with the amount the balance redeems for, net of the settlement fee. A voided market pays its stored share for each side, which is not always a half. - **`unavailable` means unknown, not empty.** Until the indexer enables this view on a chain, every read there is `unavailable`. Do not render it as "no positions". `getPortfolio` is a partial fallback: it returns at most 200 balances and does not say when it cut the list. - `tooLarge` means the account holds more positions than a view can prove complete, or its response exceeds the indexer's byte limit. - A read that fails throws from `getSnapshot`. A live view reports it as `failed`, or as `stale` with the last complete data. A failed view's `retrying` field says whether another read is planned. A missing indexer endpoint is terminal (`retrying: false`); configure an endpoint on a new owner to recover. - Mark price and PnL are not in the view. Use `getOpenPositionsWithPnL` for those. ### History + attribution (indexer) Beyond the live board and portfolio, the indexer serves the fee, resolution, router, and vault-credit history the live tail doesn't materialize — all one-shot reads: ```ts // How a market resolves: lifecycle events + the oracle reference link + the // posted numeric oracle answer (joined by oracleQuestionId), in one round-trip. const { events, reference, oracleAnswer } = await client.getMarketResolution(marketId); // Router-level collateral flow for a wallet (redeem / mint / merge complete set), // attributed to the TRUE end user even through the native/Permit2 periphery. const actions = await client.getRouterActions(account, { market, limit: 50 }); // The per-fill fee streams behind getMarketFees' running total (all filter by // `payer` — the order owner who funded the fee — plus recipient/market/pool). const protoFees = await client.listProtocolFees({ market, payer }); const bldFees = await client.listBuilderFees({ builder }); const settleFees = await client.listSettlementFees({ market }); // Builder-approval directory (the indexed complement to the on-chain point read // getBuilderApproval) — every user→builder cap, filterable by user or builder. const approvals = await client.listBuilderApprovals({ user }); ``` **Vault credits.** A payout that can't be delivered to the wallet is credited to the owner's `ERC20Vault` balance instead. That history is append-only — `client.getVaultPayoutFallbacks(owner, { token })` lists the credit log for one owner, and `client.listVaultPayoutFallbacks({ pool, from, order: "asc" })` lists it across every owner (an operator's alert or reconciliation reads it that way). Each row carries the `pool` whose vault holds the credit — but the vault's own credit/debit is silent (no event), so the **live claimable balance is a chain read**: `client.getVaultBalance({ vault: pool, owner, token })` (`ERC20Vault.getWithdrawableBalance`). Withdraw with `trader.withdrawVault({ vault, token, amount })`. ## Trading ```ts const trader = client.createTrader({ privateKey }); // Node — or { walletClient } in the browser const decimals = market.quoteDecimals; // indexed binary market's collateral scale // Rest a limit order: buy 10 YES at 0.62. const { orderId, fills, receipt } = await trader.placeOrder({ pool: market.poolAddress, side: "BUY_YES", price: probabilityToPrice(0.62, decimals), // raw collateral units per whole token quantity: fromHuman(10, decimals), // raw outcome-token units }); if (fills.length) console.log("crossed immediately:", fills); else console.log("resting as order", orderId); await trader.cancelOrder({ pool: market.poolAddress, orderId }); ``` For a chain-only market read, use `client.getMarketOnchain(marketId)` and its `decimals` and `pool` fields. A binding without `indexerUrl` has no indexed `quoteDecimals`. You can omit `indexerUrl` for chain-only reads and writes; indexed discovery and history need an indexer. [Networks and endpoints](./reference/networks-and-endpoints) lists the hosted indexers. That read uses six decimals only if the token lacks the `decimals()` view; other read failures throw. Verify a non-standard token's scale before writing. The SDK helpers also default to six decimals if you omit their `decimals` argument. Every write **awaits its receipt** — there is no bare hash to babysit, and `placeOrder` resolves with the decoded `orderId` + `fills` from the same round-trip. The escrow token (collateral for buys, YES/NO for sells) is approved automatically on first use; pass `autoApprove: false` to manage approvals yourself. - **Market orders** are `orderType: ORDER_TYPE.MARKET` (an IOC) placed at a crossing price — cross the best opposite level ± slippage so it sweeps and the remainder cancels. `ORDER_TYPE` also has `FILL_OR_KILL` and `POST_ONLY`. - **Sides and prices:** all four `BinarySide`s take the YES-terms price. A `BUY_NO` at YES-price 0.62 escrows `quantity × (1 − 0.62)` collateral. - Token wiring resolves from the pool contract automatically (cached); pass `outcomeToken`/`yesId`/`noId`/`collateral` explicitly to skip even that one-time read. ### Stake-sized market orders ("bet $50 on Up") A stake-first UI usually asks for a **collateral stake**, not a share quantity. `quoteBinaryStake` is the inverse of `quoteBinaryOrder`: it walks the live asks cheapest-first, sizing the largest quantity whose escrow at the worst level touched stays within the stake — so the quoted shares and payout match what actually fills, not a top-of-book estimate. The protective limit is padded with a slippage cushion (default 3%, min 10 ticks) so the IOC still crosses a moving book, aligned to the pool's on-chain tick grid, and the quantity is lot-aligned with escrow re-fit under the stake — off-grid prices and non-lot quantities are the pool's two rejection reasons, handled for you: ```ts const quote = await client.quoteBinaryStake({ pool: market.poolAddress, side: "BUY_YES", // Up; "BUY_NO" for Down stake: fromHuman(50, market.quoteDecimals), // raw collateral — the max loss }); if (quote) { await trader.placeOrder({ pool: market.poolAddress, side: quote.side, price: quote.yesPrice, // protective limit, YES terms quantity: quote.quantity, // shares = payout if this side wins orderType: ORDER_TYPE.MARKET, }); } ``` `null` means nothing is fillable (empty book, or a stake too small for one lot) — disable the control rather than sending a doomed order. Unwind a position with the sell-side sibling, which cushions a floor below the best bid the same way: ```ts const sell = await client.quoteBinarySell({ pool: market.poolAddress, side: "SELL_YES", quantity: position.balance, // lot-aligned down automatically }); if (sell) { await trader.placeOrder({ pool: market.poolAddress, side: sell.side, price: sell.yesPrice, quantity: sell.quantity, orderType: ORDER_TYPE.MARKET, }); } ``` Unlike the buy side, a sell's `quantity` is your position, not a book-sized fit — on a thin book the IOC fills what rests within the cushion and cancels the rest. Compare `sell.fillableQuantity` (with `sell.estProceeds`, the collateral it would raise) against `sell.quantity` and warn before submitting when the unwind would be partial. Both quotes read the live book (needs an active watch) plus the pool's tick/lot grid via one cached `eth_call` (`getBinaryBookParams`). The pure kernels — `quoteBinaryStakeOverBook` / `quoteBinarySellOverBook` / `slippageForCrossing` — are exported for callers that already hold a book. ### Builder / routing fees (optional) A frontend that routes orders can attach itself to each order and charge a per-order fee — but only after the trader has opted it in on that pool: ```ts await trader.approveBuilder({ pool, builder, maxFeeBpsTimes1k: 5_000n }); // allow up to 5 bps; 0 revokes await trader.placeOrder({ pool, side: "BUY_YES", price, quantity, builder, // the routing/builder frontend builderFeeBpsTimes1k: 5_000n, // per-order fee, pool bps×1000 unit }); ``` A non-zero `builderFeeBpsTimes1k` without a prior approval reverts `BuilderFeeExceedsCap` on a BinaryPool (`BuilderNotApproved` on a SpotPool, `BuilderFeeExceedsApproval` on a PerpPool). The enforced ceiling is the trader's approval clamped by the pool's protocol-wide cap — read it with `trader.getEffectiveBuilderApproval({ pool, user, builder })` (the pool-wide cap alone is `trader.getMaxBuilderFeeBpsTimes1k(pool)`). On the exchange surface the same rides `createOrder`'s `params.builder` / `params.builderFeeBpsTimes1k`, on binary, spot and perp alike — each pool holds its own approval, so opt in once per pool. ### Complete sets and settlement ```ts const amount = fromHuman(10, market.quoteDecimals); // raw collateral and outcome units await trader.mintSet({ pool, amount }); // collateral → equal YES + NO await trader.burnSet({ pool, amount }); // YES + NO → collateral back await trader.redeem({ market: marketAddress, amount }); // winning token → collateral (post-resolution) // A VOIDED market has no winner — both legs pay — so name the leg yourself. // One entry per leg claims both in a single transaction: await trader.redeemMany({ entries: [ { marketId, outcomeIdx: 0, amount: yesAmount }, { marketId, outcomeIdx: 1, amount: noAmount }, ], }); ``` `mintSet`/`burnSet` are how you take (or unwind) a both-sides position without touching the book; `redeem` pays out after resolution (it looks up the winning outcome on-chain if you don't pass it). That lookup is **resolution-only**: a voided market has no winning outcome, so `redeem` throws unless you pass `outcomeIdx` — it will not guess a leg for you, because on a void both legs are redeemable and only you know which you hold. An unresolved market also throws; the SDK reads the payout vector only after confirming resolution. If the venue configured a settlement fee, the pool skims it ONCE from the whole winning backing at the first winning redeem (`SettlementFeeCharged`) and every winner redeems for `1 − fee` — voided markets refund both sides with no fee. Demo-stack extras: `faucet()` mints the test collateral; `resolve`/`voidMarket` drive the FakeOracle. #### Unified exchange redemption The exchange wrapper takes display units rather than raw token amounts. A settled market is no longer in `fetchMarkets()` — a series market resolves, finalizes and hands its pool to the next market — but `fetchBalance()` still reports a holding on it under the market's own symbol, and that symbol still resolves for `redeem`. Every pool-routed verb (`createOrder`, `mintSet`, `burnSet`, the book, tape and ticker reads and watches) refuses a settled market, because its pool now serves the successor. `createOrder`, `mintSet` and `burnSet` also read the pool's current market contract before funding or signing. A cached `Trading` row does not authorize a write after its pool has moved to a successor. An observed mismatch throws `InvalidInputError`; refresh with `loadMarkets(true)` and select the intended market again. A failed check throws its `RpcError` or `ContractRevertError` and sends nothing. The check uses one uncached pool read on the existing owner connection and needs no module address. Recycling after this preflight read can still race transaction execution. Historical `redeem` keeps using the original market id. On a resolved market, `redeem` selects the indexed winner: ```ts const bal = await exchange.fetchBalance(); const winningAmount = bal[`${symbol}#YES`]?.total ?? 0; if (winningAmount > 0) await exchange.redeem(symbol, winningAmount); ``` A voided market has no single winner. Pass the leg you hold and call once for each leg you want to redeem: ```ts await exchange.redeem(symbol, yesAmount, { outcomeIdx: 0 }); ``` The SDK rejects a malformed or impossible final payout vector. Only an absent vector on a legacy market uses the compatibility fallback. ### A maker loop, end to end ```ts const watch = await client.watchMarket(pool); const trader = client.createTrader({ privateKey }); client.subscribeLive(async () => { const book = client.getLiveBinaryOrderBook(pool, { depth: 1 }); // zero RTT const mine = client.getLiveUserOrders(pool, me, { limit: 50 }).filter((o) => o.status === "Open"); // zero RTT // decide → place/cancel; each write resolves after its receipt arrives }); ``` The live store is the only state a quoting loop needs: the book, your working orders, and fills all update the moment the chain emits them. Binary claimable reads inspect indexed outcome positions. `getClaimable(account)` returns a complete array only when the account has at most 200 nonzero positions. It throws `IncompletePortfolioError` for a larger wallet. Use `client.getClaimablePage(account, { limit: 200, offset: 0 })` for bounded pages, and follow `nextOffset` even when `claims` is empty. Losing and open positions still count toward that offset. Each page detects continuation with one extra position. Balance changes between pages can move rows; refresh indexed estimates and confirm chain balances before redeeming. `getPortfolio` accepts `positionsLimit` and `positionsOffset` for the same indexed position pages. Check `positionsTruncated` and follow `positionsNextOffset`. Other derived views that consume one portfolio page, including `fetchBalance` and `getOpenPositionsWithPnL`, still cover only that page's binary positions. --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/spot A spot market is a plain base/quote order book on a `SpotPool` (e.g. SOMI/USDC) — same OrderBook core as the binary pools, so the live machinery is identical; only the semantics differ. This guide covers reading and trading spot; the shared client mechanics (watches, read tiers, signers) are in [the engine guide](./engine). ## The mental model - **Prices are quote-per-base.** Raw quote units per whole base token, scaled by the market's own `quoteDecimals`/`baseDecimals` (spot markets are NOT assumed 6dp — read the decimals off the `SpotMarket` row). - **Two sides.** `isBid: true` buys base (escrows quote); `false` sells base (escrows base — or sends native SOMI as `msg.value` when `baseIsNative`). ERC-20 approval checks use `requiredAmount`, which is the pool's worst-case reserve including fee headroom. A native sell sends `delta`, which subtracts the owner's current vault balance from that reserve. Sending only the bare quantity can revert with `InvalidMsgValue` on a fee-bearing pool. - **Book constraints.** Orders must respect the pool's `tickSize`, `lotSize`, and `minQuantity` (all on the `SpotMarket` row, kept live by the watch). - **Mark price.** Pools publish a smoothed `markPrice` (streamed live via `MarkPriceUpdated`) — it's what stop orders trigger on, distinct from `lastPrice` (last fill). ## Reading Discover markets first (indexer tier) — `listSpotMarkets` returns the board and `getSpotMarket` resolves one by id; both yield the `SpotMarket` rows the live reads key off: ```ts const markets = await client.listSpotMarkets({ limit: 50 }); // SpotMarket[]; filterable (base/quote/…) const spot = await client.getSpotMarket(id); // SpotMarket | null ``` ```ts const watch = await client.watchMarket(spot.poolAddress); const book = client.getLiveSpotOrderBook(spot.poolAddress, { depth: 11 }); // { bids, asks }, best first const tape = client.getLiveFills(spot.poolAddress, { limit: 40 }); const live = client.getLiveMarketByPool(spot.poolAddress); // markPrice, tick/lot, stats ``` React: `useLiveSpotOrderBook(pool)`, `useLiveFills(pool)`, `useLiveMarketByPool(pool)` — all auto-watch while mounted. History and wallet views come from the indexer tier: `getCandles(pool, interval)`, `getSpotPortfolio(account)` (open orders + pending stops + the last seven days of trades by default — `tradesSince` says which; pass `since` to widen), and `getSpotStopOrders(account, { pool })`. Holdings are plain balances — read them on-chain with `getErc20Balance` / `getNativeBalance`, not from the indexer. ## Trading ```ts const trader = client.createTrader({ privateKey }); // Rest a limit bid: buy 5 base at 1.25 quote. const { orderId, fills } = await trader.placeSpotOrder({ pool: spot.poolAddress, isBid: true, price: parseUnits("1.25", spot.quoteDecimals), quantity: parseUnits("5", spot.baseDecimals), quoteToken: spot.quoteToken, baseToken: spot.baseToken, baseIsNative: spot.baseIsNative, }); await trader.cancelOrder({ pool: spot.poolAddress, orderId }); // same core as binary ``` ### Per-order fields: expiry, attribution, self-match, user data `placeSpotOrder` takes these per-order fields as optional inputs. Each one has an SDK default that keeps today's behaviour, so an existing call is unaffected: ```ts await trader.placeSpotOrder({ ...order, expireTimestampNs: BigInt(Date.now() + 86_400_000) * 1_000_000n, // default: ~50y (GTC) builder: "0xYourFrontend", // default: the zero address (no attribution) builderFeeBpsTimes1k: 25_000n, // default: 0 selfMatchingOption: SELF_MATCHING_OPTION.CANCEL_MAKER, // default: CANCEL_TAKER userData: 7n, // default: 0 — an opaque tag, never interpreted }); ``` `placeSpotOrders`, `placeOrder` (binary), `placePerpOrder`, `amendOrder` and `amendOrders` take the same `selfMatchingOption`, and the batch verbs take it per request. **Self-match.** A pool matches your incoming order against your own resting order, and `selfMatchingOption` says which side loses. `CANCEL_TAKER` (0) drops the rest of the incoming order and leaves the resting one on the book; `CANCEL_MAKER` (1) cancels the whole resting order and lets the incoming one continue. **The 0 is the SDK's default, not the pool's** — the pool reads the value out of every order request and has no default of its own, so a raw integration must pass it. Reach for `CANCEL_MAKER` when the new quote matters more than the old one, such as re-pricing a ladder into your own resting rungs; keep `CANCEL_TAKER` when a self-cross means you made a mistake and want the order rejected. In a batch a `CANCEL_TAKER` self-match is one of the benign non-placements: the rung reports `outcomes[i].success === false` rather than taking the batch down. **The ceiling.** A non-zero `builderFeeBpsTimes1k` must stay within `trader.getMaxBuilderFeeBpsTimes1k(pool)`. Read that cap rather than assuming it: it is owner-updatable on a SpotPool, and while it is 0 the pool rejects builder codes outright, so the rail is off on that venue. **The approval.** A non-zero fee also needs a prior `trader.approveBuilder({ pool, builder, maxFeeBpsTimes1k })`. Spot pools implement the same builder calls as binary ones, but **the approval is stored per pool** — approving a builder on one pool grants nothing on another, and an unapproved placement reverts `BuilderNotApproved`. > **A past expiry reverts.** A placement whose `expireTimestampNs` is already > behind the chain clock fails with `OrderAlreadyExpired`. Earlier protocol > versions accepted it silently — the pool skipped the placement and returned no > order id, so the transaction still succeeded — but it now rejects outright. > The batch verb differs: `placeSpotOrders` rejects the offending request on its > own rather than taking the whole batch down, so there you check > `outcomes[i].success` — an already-expired expiry is one of the benign > non-placements it reports. > **Expiry does not refund by itself.** When a spot order lapses its escrow > stays locked in the pool until someone sweeps it — > `trader.cancelExpiredOrders({ pool, orderIds })` reclaims it, and is callable > by anyone, not only the owner. Set an expiry deliberately. Escrow is approved automatically (quote on buys, base on non-native sells; native-base sells pay via `msg.value` instead). A **market order** is `orderType: ORDER_TYPE.MARKET` with a crossing price — take the live book's best opposite level ± slippage, tick-aligned, so it sweeps and the remainder cancels: ```ts const best = client.getLiveSpotOrderBook(pool, { depth: 1 }); // zero RTT, last-block fresh const crossing = (best.asks[0].price * 10100n) / 10000n; // +1% slippage bound ``` ### Amending a quote set Re-pricing a ladder one order at a time costs two transactions per rung and leaves the book briefly one-sided. `amendOrders` re-prices the whole ladder in a single transaction: ```ts const { newOrderIds } = await trader.amendOrders({ pool: spot.poolAddress, amendments: [ { oldOrderId: bid1, newOrder: { isBid: true, price: newBid1, quantity: qty } }, { oldOrderId: bid2, newOrder: { isBid: true, price: newBid2, quantity: qty }, alwaysPlace: true }, ], }); ``` `newOrderIds` is index-aligned with `amendments`. What an amend does depends on the pool version: - **Cancel-then-place pool:** every old order is cancelled before any replacement is placed, and each replacement gets a new id at the back of its queue. An all-or-nothing version reverts the whole batch at the first replacement it will not honour, so the book never sees a partial re-quote. A per-rung version applies the other amendments and leaves the rejected one without an order. - **In-place pool:** each order is amended in one pass and keeps its id. It keeps its queue place when its price is unchanged and its quantity is not raised. A rejected amendment is left without an order. A per-rung or in-place pool logs each rejection as `AmendRejected(owner, oldOrderId, reason, requestIndex)`, and that amendment reads `0n` in `newOrderIds`: it holds no order, so place it again. The one exception is an amendment that repeats an `oldOrderId` an earlier amendment in the batch already landed on; that order is live under the earlier amendment's id. Check every id for `0n` before writing them back over your ladder. `alwaysPlace` handles the race where the order you meant to amend already filled or was cancelled. False (the default) reverts the batch with `AmendOldOrderGone` on an all-or-nothing pool and skips that amendment (`0n`) on the others; true places the replacement anyway (an in-place pool places it as post-only with `CANCEL_TAKER`, whatever `newOrder` asks). It never tolerates an ownership failure — a live order owned by someone else still reverts. For ONE order, use `amendOrder` rather than a one-element batch: ```ts const { newOrderId } = await trader.amendOrder({ pool: spot.poolAddress, oldOrderId: bid1, newOrder: { isBid: true, price: newBid1, quantity: qty }, }); ``` The difference is the error you get back. The singular raises the replacement's own landing-time reason — `PostOnlyWouldCross`, `FillOrKillNotFillable` and friends — where an all-or-nothing batch wraps it as `AmendReplacementRejected(requestIndex, reason)` and the other batches return only a `0n` id. Everything else matches: same `alwaysPlace` race rule (the singular always reverts `AmendOldOrderGone`), same queue rule, same non-payable funding constraint. To shrink an order **without** losing queue priority on any pool, use `reduceOrder`. Three things to know before re-laddering with it: - **Replacements are not shielded from each other.** On a cancel-then-place pool, cancelling all the old orders first protects a replacement from the order it replaces — but not from the _other_ replacements in the same batch. If a new bid crosses a new ask, `CANCEL_TAKER` (the SDK's default when you omit `selfMatchingOption`) rejects it. An all-or-nothing pool then reverts the whole re-ladder; a per-rung pool leaves that rung empty. An in-place pool rejects an amendment whose new price would cross the book, including your own orders. Keep the new set uncrossed. - **The failure names the rung.** An all-or-nothing pool reverts `AmendReplacementRejected(requestIndex, reason)`; a per-rung pool logs `AmendRejected(owner, oldOrderId, reason, requestIndex)` and returns `0n` at that index; an in-place pool does the same. `requestIndex` is the position in your `amendments` array and `reason` is the pool's `uint8` rejection code (the SDK does not export a name table for it). `AmendOldOrderGone` is a different, earlier failure from the cancel leg. - **Approve the escrow first.** Unlike `placeSpotOrder`, `amendOrders` does not auto-approve. On an auto-pull pool the cancel leg returns the freed tokens to your wallet and the place leg pulls them back, which needs an allowance — so a trader whose _first_ call is `amendOrders` hits `ERC20InsufficientAllowance`. Place once (or approve manually) before amending. **Not for BinaryPools** — amend places, and binary pools reject generic placement with `UseBinaryPlacement`. Spot and perp only. Note that error is what you hit on a _live_ binary market; a locked one reverts `TradingNotActive` and a malformed replacement reverts on validation first, so don't branch on `UseBinaryPlacement` alone to detect the wrong pool kind. ### Batches — place a ladder, pull a ladder Market making means many orders at once. `placeSpotOrders`, `cancelOrders` and `reduceOrders` each do a whole ladder in ONE transaction instead of a loop of sends: ```ts // Place a three-rung sell ladder. const placed = await trader.placeSpotOrders({ pool: spot.poolAddress, quoteToken: spot.quoteToken, baseToken: spot.baseToken, orders: [1.01, 1.02, 1.03].map((p) => ({ isBid: false, price: parseUnits(String(p), spot.quoteDecimals), quantity: parseUnits("1", spot.baseDecimals), })), }); // outcomes is index-aligned with `orders`; a rung that did not place is // success:false (e.g. a PostOnly that would have crossed), NOT an error. const ids = placed.outcomes.flatMap((o) => (o.success ? [o.orderId!] : [])); // Pull what is left. Best-effort: an id that filled meanwhile is skipped, so // the other rungs still come off the book. const pulled = await trader.cancelOrders({ pool: spot.poolAddress, orderIds: ids }); const skipped = pulled.outcomes.filter((o) => !o.cancelled).map((o) => o.orderId); ``` Three things to know before using them: - **They are non-payable.** Unlike `placeSpotOrder`, a batch sends no `msg.value`, so a **native-base sell** funds from the pool's vault balance — pre-deposit native to the vault first. ERC-20 auto-pull works normally, and the batch approves each escrow token once for the whole batch's total. - **`placeSpotOrders` is spot-only.** Binary pools reject generic placement with `UseBinaryPlacement` (the YES/NO kind must be explicit) — use `placeOrder` there. `cancelOrders` and `reduceOrders` are inherited from the shared order book, so they work on binary pools too. - **Cancel is best-effort, reduce is atomic.** A stale id in `cancelOrders` is skipped; a single invalid reduction in `reduceOrders` reverts the whole batch. A cancel `false` says the id emitted no event — it does not say _why_, so a benign fill race and a wrong id look the same. - **Every per-order field is per rung.** `orderType`, `expireTimestampNs`, `selfMatchingOption`, `userData`, `builder` and `builderFeeBpsTimes1k` are read off each request, so a ladder can attribute one rung to a builder and leave the next unattributed. A rung that omits a field gets the same default the single verb applies. - **Tag rungs with `userData` if exact attribution matters.** Outcomes are matched to requests on every field the `OrderPlaced` event echoes (side, price, quantity, userData, expiry). Two byte-identical adjacent rungs with different outcomes are indistinguishable from logs — the earlier index gets the credit; a distinct `userData` per rung removes the ambiguity. - **Read the tag back from the indexer.** `getOpenOrders` and `getOrders` return each row's `userData`, and `OrdersOptions.userData` filters on it, so a client can ask for the orders it tagged instead of keeping its own id map. The column is not indexed: pair the tag with `pool`/`pools` and a status to keep the scan small. ### Stop orders Spot pools with a `stopRegistry` support stop-loss / take-profit orders that rest OFF the book and fire when the **mark price** crosses the trigger: ```ts await trader.placeSpotStopOrder({ registry: spot.stopRegistry, pool: spot.poolAddress, isBid: false, // sell when the market drops… quantity: parseUnits("5", spot.baseDecimals), triggerPrice: parseUnits("1.10", spot.quoteDecimals), triggerOperator: 1, // 1 = LTE (mark ≤ trigger), 0 = GTE stopOrderType: 1, // 1 = MARKET at trigger, 0 = LIMIT (needs limitPrice) quoteToken: spot.quoteToken, baseToken: spot.baseToken, baseIsNative: spot.baseIsNative, }); await trader.cancelStopOrder({ registry: spot.stopRegistry, orderId }); ``` Under the hood the first stop order per account performs a one-time operator approval (so the registry may place the triggered order for you), funds the trigger gas with a small SOMI payment (`msg.value`, refunded on cancel), and ensures the pool can pull the escrow at trigger time — including pre-loading the pool vault for native-base sells. The SDK handles all of it; list pending stops with `getSpotStopOrders(account, { pool })` and stream their market context via the watch. A listed spot stop has no limit price: a LIMIT stop's limit price can't be read back after placement. Show it as unknown, never as zero, and keep the value you submitted if you need to display it. --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/perps Perps are live on testnet: `BTC/USDSO:USDSO` and `ETH/USDSO:USDSO`, backed by PerpPool CLOBs riding the same shared OrderBook core as spot and binary. ## The shape `Market` is a three-way discriminated union — `SpotMarket | PerpMarket | BinaryMarket`, keyed on `marketType` (guards: `isSpotMarket` / `isPerpMarket` / `isBinaryMarket`). A `PerpMarket` is a plain base/quote book plus perp state: `marginBank`, `initialMarginBps`, `fundingRate`, `cumulativeFundingPerUnit`, `indexPrice`, `openInterest`, `fundingWindowSec`, `fundingIntervalSec`. > **`fundingRate` is per CALCULATION WINDOW** (`fundingWindowSec`, 28800s / 8h on every > live pool) — not per settlement interval and not annualized. Each settlement accrues > `rate / n` where `n = fundingWindowSec / fundingIntervalSec`: **8** on every live pool > (3600s settlement). It has been **96** at a 300s cadence, and the same value means a 12x > different per-interval accrual across that boundary — which still reaches anyone reading > indexed history, so read `n` off each row rather than assuming it. Normalize with the > helpers > (`fundingRate8h`, `fundingRate1h`, `fundingRatePerInterval`, `annualizedFundingRate`), > never with a hardcoded denominator. > > `openInterest` replaced `longOpenInterest` + `shortOpenInterest`: the contract keeps ONE > counter because the short side is provably equal in a matched CLOB. The removed pair was > `null` on every row — the subscription feeding it was dead. - **Watches and live reads are unchanged.** Perp pools emit the same order events, so `watchMarket(pool)`, `getLiveSpotOrderBook`-style depth, `getLiveFills`, `getLiveUserOrders`, and the React hooks just work. Funding (`FundingUpdated`) and open interest (`OpenInterestUpdated`) stream into the perp market row live. - **Margin, not escrow.** Collateral (USDso) lives cross-margin in the MarginBank: `trader.depositMargin` / `withdrawMargin` move it; `trader.placePerpOrder` (or plain `createOrder` on a perp symbol) locks margin from that balance — no per-order token approval. - **Re-quoting a ladder? Amend it in one transaction.** `trader.amendOrders` re-prices N orders in one transaction (see SPOT.md for how each pool version applies it and what a `0n` id means). On a perp pool it needs no token fields at all — margin comes from the MarginBank, so there is no escrow to approve. - **Builder attribution.** `placePerpOrder` takes optional `builder` / `builderFeeBpsTimes1k` (alongside its existing `expireTimestampNs`), both defaulting to no attribution. A non-zero fee needs a prior `trader.approveBuilder({ pool, builder, maxFeeBpsTimes1k })` and must stay within `trader.getMaxBuilderFeeBpsTimes1k(pool)` — read it rather than assuming it, since it is owner-updatable on a PerpPool and the rail is off while it is 0. Perp pools implement the same builder calls as binary ones, but **the approval is per pool**: approving a builder on one pool grants nothing on another, and an unapproved placement reverts `BuilderFeeExceedsApproval` (a PerpPool has no separate "not approved" check — an absent approval is simply a zero cap). - **Positions are on-chain reads**, not indexed rows: `client.getPerpPosition({ marginBank, account, pool })` and `client.getMarginAccount(marginBank, account)` — or the unified `exchange.fetchPositions()`. Live pricing/funding comes from `client.getPerpState(pool)` / `exchange.fetchFundingRate(symbol)`. - **MONITORING a mark feed is a different read.** `client.getPerpFeedStatus(pool)` answers whether the mark is live and how much open interest rides on it. `getPerpState` batches with `allowFailure: false` and one of its legs is a bare `IOracle.getPrice()`, so a dead or rotated oracle rejects the whole read — losing the verdict in the one case it matters most. `getPerpFeedStatus` keeps the mark verdict and open interest as a pair no oracle can revert, and degrades the index timestamp to `undefined` instead. Use `getPerpState` when you want the full pricing and funding picture and a dead oracle should fail the call. - **…except when you want them all at once.** `client.listPerpPositions(account)` returns every pool's position in ONE indexer round-trip instead of a chain read per market — the right read for a positions _table_. It is a snapshot as of each row's `updatedAtBlock` and is **not marked to market**: unrealized PnL, liquidation price and margin health still need the chain reads above. Two things the shape will not let you get wrong, both documented on the type: `size` is **signed** (the entity stores magnitude and direction separately; they are folded back together here so a short can't read as a long), and the entry funding index is **absent by design** — it is not on the schema production serves, so it waits on the next reindex — and anything funding-sensitive belongs on `getPerpPosition`. Fully-closed positions are excluded unless you pass `includeFlat` — upserted rows are never deleted, so they linger at size 0 forever. - **Margin health is a chain read too.** `getMarginAccount` now also returns the requirements + status (`imReq` / `mmReq` / `cmReq` / `marginStatus`, from `MarginBank.getAccountHealth` + `getMarginStatus`); `client.getAccountHealth(marginBank, account)` is the lighter standalone read when only health matters, and `client.getLiquidationPrice({ marginBank, pool, account })` estimates the mark at which this pool's move alone would trip maintenance (null when flat). `MarginStatus` / `MARGIN_STATUS` / `AccountHealth` are exported; `exchange.fetchPositions()` now fills `UnifiedPosition.liquidationPrice`. - **Account equity for scoring is one read.** `client.getAccountEquity(account, { scope: "perps", marginBank })` returns `equity`, `lockedCollateral`, their sum `totalEquity` and the block they were read at. `equity` counts free collateral only, and a resting order moves collateral to `lockedCollateral`, so score on `totalEquity`. The read has no history: store a snapshot at the start and at the end of the period. A market with no fresh mark price makes the read throw. Pass `blockNumber` to read at a fixed block instead of the head. `client.listAccountEquities(accounts, params)` reads a cohort at one block in batched requests; an account that cannot be priced comes back as `{ priceable: false }` and does not fail the rest. The `"spot"` and `"overall"` scopes take no `marginBank` and throw `NotSupportedError`. The change between two snapshots includes money that moved in or out of the account: deposits, withdrawals, credit-voucher grants and collateral that an order pulled from a wallet. Remove those flows before you call the change a trading gain. `getMarginEvents` with `kinds: ["Deposit", "Credited", "Withdraw"]` and `listPerpMarginPulls` list them, and `listPerpMainFundingEvents` kind `Returned` lists principal sent back to a main wallet. Do not add its `Funded` rows: each one repeats a `Main` pull. These reads cannot be bounded to a block yet, so a flow close to either snapshot cannot be placed exactly. ```ts const start = await client.getAccountEquity(account, { scope: "perps", marginBank }); // ... one week later const end = await client.getAccountEquity(account, { scope: "perps", marginBank }); const change = end.totalEquity - start.totalEquity; // still includes deposits, withdrawals and credits ``` - **Liquidation price: both sides of the inequality move with the mark.** Liquidation begins where `equity == mmReq`, and `mmReq` is recomputed on the _current_ mark (`ceil(|size| × mark × mmBps / (oneBase × 10000))`), so it shrinks under a falling long and grows under a rising short. Solving `equity(p) == mmReq(p)` therefore carries a `10000 ∓ mmBps` factor: ```text long p = mark − (equity − mmReq) × oneBase × 10000 / (|size| × (10000 − mmBps)) short p = mark + (equity − mmReq) × oneBase × 10000 / (|size| × (10000 + mmBps)) ``` `perpLiquidationPrice(...)` is that solve, exported as a pure function — no client, no block — so you can re-run it against a live store or a hypothetical. Both `getLiquidationPrice` and `previewPerpLiquidationPrice` go through it, which is why they cannot disagree on identical inputs. It is a **single-market** solve: other markets' contributions are held at the value baked into `equity`/`mmReq`, so a correlated move across several markets liquidates sooner. - **Where would an order put my liquidation price?** `client.previewPerpLiquidationPrice({ pool, marginBank, account, isBid, quantity, price, asMaker? })` answers that for an order not yet placed, returning `currentLiquidationPrice` beside `projectedLiquidationPrice` so a form can show the move. It ports all four of `MarginBank.settleTrade`'s cases — open, increase (floored VWAP entry), reduce/close (realized PnL at the fill price, entry untouched), and flip (old side closed, remainder re-opened) — and charges the fill's fee, defaulting to the taker rate. A reduce moves the price _away_ from the mark and an add moves it _closer_, so the four cases are not interchangeable. Note what it deliberately does **not** do: it applies the whole quantity rather than splitting it against `getReducingCapacity` (that split governs the collateral _lock_, not the position), and it does not judge whether the order would be accepted — that is `previewPerpOrderMargin`'s job. An unpriceable market returns `{ priceable: false }` rather than reverting, so an order form can still render. - **How much can I actually place?** `client.getMaxPerpOrderSize({ pool, marginBank, account, isBid, price })` — what a **Max** button should call. The inverse of `previewPerpOrderMargin`, which the protocol does not offer. It does not re-derive the sizing rule; it **binary-searches the forward one**, so the two cannot disagree. That matters because a max computed by a second, subtly different rule reverts on placement — and the term such a rule most often drops is the adverse mark-to-entry reserve, which on a 10%-above-mark bid cuts the affordable size by roughly two thirds. `maxQuantity` is aligned **down** to the pool's lot grid. **Check `placeable`.** A size below the pool's `minQuantity` is a revert, not a small order. `limitedBy` names the binding gate — `"collateral"`, `"initialMargin"`, `"maxPositionSize"` or `"voucherBlocked"`. Market-wide `maxOpenInterest` is deliberately not modelled: it is enforced at fill against a total every other trader moves, so no client-side number can be right about it for long. Nor is book depth — this is a placement limit, not a liquidity one. Nor are the two non-margin gates that reject the whole order rather than shrinking it: a close-only market (`PerpPool.isRestricted()`) and isolated-margin confinement. **Pass `autoPull: true` when the transaction sender will be the order owner.** The pool tops the in-bank balance up from the owner's wallet before it locks (`_onOrderPlaced` → `MarginBank.quoteOrderTopUp` → `depositFor`), so an account with an empty bank and a funded, approved wallet goes from a max of `0n` to whatever the wallet funds. `msg.sender == order.owner` is the pool's entire gate, and only the caller knows who will send — hence opt-in. Leave it off for `placeOrderFor`, an operator grant or the stop registry, where no pull happens. With it on, `topUpRequired` is the wallet spend to show beside the size, and `limitedBy` gains `"walletBalance"` / `"walletAllowance"` — different shortfalls with different fixes, so don't collapse them. `"restricted"` and `"isolated"` name the two gates that reject the whole order rather than resizing it. **A linked child is funded by two wallets, and both count.** The pool spends the owner's own wallet first and takes the residual from the main it is linked to, so `autoPull` reads `MarginBank.quoteFundingPayer` and adds the main's `quoteWalletCapacity` as a second leg. That matters most for the account the rail exists for: an isolated sub-account is meant to hold nothing, and sized off its own wallet alone it reads as unable to place anything at all. `fundingPayer` names the main **eligible** to fund a shortfall, or is `null` when the account funds itself — unlinked, a main itself, or the rail dormant on this deployment. Eligibility is not a debit: it resolves for every linked account, including an order needing no top-up and one whose own wallet covers the whole pull. `ownWalletPull` and `mainWalletPull` split `topUpRequired` between the two wallets in the order the pool spends them, and **only `mainWalletPull > 0n` proves a second wallet actually moves** — that is the figure to show before the trader signs. Read `topUpRequired` as the total REQUESTED, not one wallet's spend. Showing it as the child's debit over-states it by exactly the main's leg. `limitedBy` gains `"mainWallet"`, reported ahead of the two `wallet*` values whenever a main is in play: at a main-funded ceiling the child's own capacity is fully consumed, so `"walletBalance"` would also be literally true and would send a trader to fund a sub-account that is empty by design. It names the **preferred** remedy, not the only one — the two capacities add, so raising the child's balance or allowance lifts the ceiling just as well. **A partial pull is not a rejection on this path.** The unlinked pool asks for a fixed `topUpRequired` and the token reverts if the wallet is short. Both linked legs are `min(...)`-sized and neither can revert, so the pool pulls what it can and the margin gates judge the result — and `topUpRequired` includes the fee headroom, which is a reserve rather than part of the lock. So capacities that cover the lock but not the headroom fund an order the chain accepts, and `walletCoversTopUp` is informational there rather than a gate: read a `false` as "the position may be born close to its own initial margin", not as "this will be rejected". One case withholds the main's leg entirely. `MarginBank.depositForFromMain` allows one payer at a time, so while the child still owes a **prior** payer the pull reverts `PriorFundingPayerOutstanding` — reachable in one ordinary sequence: funded by main A, unlink, re-link to main B. `mainFundingBlocked` reports it and `limitedBy` says `"mainFundingBlocked"`; the fix is neither wallet but `trader.repayPerpMainFunding` against the old claim. It costs one extra read for an unlinked account and four for a linked one. The main's capacity arrives as a single `min(balance, allowance)`, which is all the bank exposes for another wallet, so a main-side shortfall does not say which of the two bound; read the main's token balance and allowance directly if you need that. Note the max is the top of the **contiguous** placeable region. Auto-pull makes the initial-margin gate non-monotone in quantity — it reduces to `(equity − unlocked) + feeHeadroom ≥ imRequirement`, whose only size-dependent term grows — so an account whose existing positions sit below their own initial margin can be rejected at a middling size and accepted at a much larger one. `previewPerpOrderMargin` reports that faithfully; the max deliberately does not offer sizes out of the disconnected region, because a slider has to be placeable at every value below its maximum. - **What do I get if I close?** `client.previewPerpClosePnl({ pool, marginBank, account, quantity?, price?, asMaker? })` — backs a close modal. Omit `quantity` for the whole position; `price` defaults to the mark, which is the right estimate for a market close. Two things it gets right that a hand-derived figure usually does not, and **both are silent** — the close succeeds, the number shown was just wrong: 1. **The size is aligned down to the lot grid first.** "Close all" on a position that is not a lot multiple leaves a remainder open. A modal reporting the position as flat is wrong, and it reads as a bug in the close button. 2. **Funding settles on the whole position, not the closed share.** `settleTrade` calls `_settleFundingWithValues` _before_ it touches the position, and that uses the full `pos.size`. So a 10% close settles 100% of the accrued funding; pro-rating it — the intuitive move — under-states the cash impact by the other 90%. `netProceeds` is the number to show: `realizedPnl − fundingSettled − fee`, with `fundingSettled` positive when the account pays. `realizedPnl` **floors** toward −∞ (`_realizedPnlForClose`), which is the opposite of the truncation unrealized PnL uses — the same inputs give `-1` here and `0` from `getPerpPositionAnalytics`, and both are correct. `placeable` is the pool minimum and nothing else. A close is only purely reducing up to `_reducingCapacity` — `|size|` minus what is already resting on the reducing side — so closing out while a reduce order is down leaves an increasing remainder that locks collateral and must clear `meetsIMForOrder`. Read `previewPerpOrderMargin` beside this one on an account with resting orders. `fee` is the pool's own maker/taker rate; a builder fee attached at placement is charged on the same notional and lands on top. - **What is this position actually doing?** `client.getPerpPositionAnalytics({ marginBank, pool, account })` for one, `client.listPerpPositionAnalytics({ marginBank, account })` for a positions table. Two reads for one position, `1 + 2n` for the table, all pinned to one block. This is the split `getAccountHealth` cannot give you: it returns **one** equity figure for the whole account, with every market's PnL and funding already summed and netted together, so a two-position trader cannot see which position carries the loss and cannot see funding at all. Back apart: | Field | Means | | ------------------------------------------------------------ | --------------------------------------------------------------------------------------- | | `unrealizedPnl` | `(mark − entry) × size / oneBase` — price only, **excludes** funding | | `accruedFunding` | funding **owed** since entry; **positive means you pay** | | `equityContribution` | `unrealizedPnl − accruedFunding` — what this position adds to equity | | `notional` | `\|size\| × mark / oneBase` | | `initialMarginRequirement` | "position margin" — the only per-position margin the protocol defines | | `maintenanceMarginRequirement` / `closeOutMarginRequirement` | the liquidation and takeover thresholds' shares | | `returnOnMarginBps` | `equityContribution` over the initial requirement, **net of funding**; `null` when flat | A direct port of `MarginBank._computePositionMetrics` and `_marketHealthFromSnapshot`, which is what lets the rows re-sum to the bank's own equity to the wei — a test pins that. **The two roundings differ and are not interchangeable**: unrealized PnL truncates toward zero, while funding ceils toward +∞ so a payer never underpays. For a negative quotient that is the opposite of rounding the magnitude up. `accruedFunding` uses the pool's _projected_ cumulative index, so it includes intervals no one has settled yet — settlement is permissionless and lazy, and measuring against the settled index under-reports what the account already owes. An unpriceable market comes back as `{ priceable: false }` rather than throwing, so one dead feed costs one row rather than the page. `initialMarginRequirement` uses the market's IMF only: an account leverage setting raises the bar for a _new_ order but never appears in health, so use `previewPerpOrderMargin` for the order-gating figure. The pure core is exported as `perpPositionAnalytics(...)` — no client, no block. - **How levered am I?** `client.getPerpLeverage({ marginBank, pool, account })`. **The protocol has no leverage view to call** — `getMaxLeverage` / `getMaxLeverageLimit` / `getVoucherLeverageCap` are all _cap configuration_ and none of them measures a position — so this derives it from mark notional over equity, and returns every denominator rather than picking one: | Field | Means | | ---------------------- | ------------------------------------------------------------------------------------------------------- | | `positionLeverageBps` | this position's notional over account equity | | `accountLeverageBps` | **Σ** notional over account equity — the figure that governs risk under cross margin | | `marketMaxLeverageBps` | `10000² / effectiveImfBps` — the most this market will open at, at _live_ OI-scaled IMF | | `accountMaxLeverageX` | the account's own per-market cap, `0` when unset (what `setPerpLeverage` writes, finally readable) | | `protocolMaxLeverageX` | the ceiling that clamps it | | `creditFloor` | the account's credit-voucher floor; `0n` on an ordinary account, and the switch that arms the two below | | `voucherLeverageCapX` | what a voucher account is confined to when it **increases**; `0` is a block, not "uncapped" | | `voucherMarketAllowed` | whether this market is on the voucher allowlist | The ceilings are **not** collapsed into one number, and they do not compose by taking a minimum. A voucher cap _replaces_ an unset or looser `accountMaxLeverageX` before `protocolMaxLeverageX` clamps the result, and it applies only to a position increase — `MarginBank._meetsIM` gates the whole voucher branch on `additionalSize > 0`, so a voucher holder can always close out or place a stop, even on a market since removed from the allowlist. The load-bearing half is that a voucher turns an _unset_ account cap into an enforced one: `accountMaxLeverageX === 0` means "no cap" on an ordinary account and "confined to `voucherLeverageCapX`" on a voucher one. For whether a _specific_ order passes, use `previewPerpOrderMargin`, which applies all of it and reports `voucherBlocked`. Every ratio is **bps of 1x** (`10_000` = 1.00x, `200_000` = 20x), matching the protocol's own unit for every margin figure. Position and account leverage are `null` on non-positive equity — an account with no equity left is insolvent, not infinitely levered; read `marginStatus` for that. `accountNotional` costs two extra reads per _other_ active market (the MarginBank exposes no aggregate, and `imReq` can't be inverted because each market applies its own IMF), so a single-market account pays nothing extra. - **Liquidation-keeper reads — who holds, and what a bankrupt position is worth.** The MarginBank keeps a per-(pool, side) holder array, so a keeper can find every open position from head state alone — no off-chain indexer: ```ts const { holders, asOfBlock } = await client.getPerpSideHolders({ marginBank, pool, isLong: true }); const prices = await Promise.all( // Priced at the SNAPSHOT's block — at head, a holder that closed since // the enumeration would revert NoOpenPosition and reject the sweep. holders.map((h) => client.getBankruptcyPrice({ marginBank, account: h, pool }, { blockNumber: asOfBlock })), ); ``` `getPerpSideHolders` pages through the bank's bounded slice view (many holders per round-trip) with every page pinned to ONE block; feed `asOfBlock` into `getBankruptcyPrice`'s `blockNumber` option (and into the other side's call) to keep a sweep on one consistent snapshot. The other position/health reads answer at head only. `getBankruptcyPrice` is the **contract's own figure** — the price at which the position's allocated share of the account's equity is exhausted — and it is a different quantity from `getLiquidationPrice`, not a better version of it: `getLiquidationPrice` is the SDK's client-side estimate of where liquidation _triggers_ (use it for UI and monitoring), while `getBankruptcyPrice` is what the contract computes a bankrupt position to be worth (use it for anything that settles or bids — a keeper pays against this number, and a client-side estimate can drift from contract rounding). It reverts rather than returning a sentinel: `ContractRevertError` with `errorName: "NoOpenPosition"` when the account is flat in that pool (branch on `errorName`, never message text). - **The ADL queue — where a position stands if a liquidation reaches ADL.** Auto-deleveraging (ADL) is the last stage of a liquidation that the bidders and the insurance fund cannot cover. The engine closes profitable positions on the other side of the bankrupt's market, in the order that its ranker contract decides. `client.perpAdlQueue` reads that order for one side of one market at one block, and follows it as it changes: ```ts const stop = client.perpAdlQueue.subscribe({ marginBank, pool, isLong: true }, (view) => { if (view.status !== "ready" && view.status !== "stale") return; const { queue } = view.data; if (queue.regime !== "ranked") return; const candidates = queue.holders.filter((h) => h.eligibility === "candidate"); for (const h of candidates) console.log(h.account, `${h.adlRank}/${candidates.length}`, h.positionLeverageBps); }); ``` The read follows the engine. It reads the ranker and the scan cap from the engine at the pinned block. A side with more holders than the scan cap is walked in storage order, so the read returns `regime: "storageOrder"` and no order. A holder is a candidate when its position is in profit at the mark, its account is priceable, and its equity is positive. These are the rules of the engine's claw-back arm, which it takes when the bankrupt account's realized balance is negative. When the ranker reverts or returns a bad order, the engine uses holder-list order. The read gives that order and sets `orderSource` to `"rankerReverted"` or `"rankerInvalid"`. The live view reads the side again when the MarginBank reports a change that can move the queue: any trader's fill, close, liquidation, takeover, forced settlement, ADL or funding settlement on this market, and any account's deposit, withdrawal, credit, linked funding or close-out payment, your own included. It also reads every 30 s, which picks up what moves the order with no followed event: a price move, a holder's equity changing on another market, and order placement and cancellation. Each order locks or releases its margin, which moves the trader's equity and so its place, but the bank emits them several times a second, so they are not followed. `getSnapshot` reads once, unless a live view of the side is ready: then it returns that view. The queue is an estimate for an ADL on this market alone. A running ADL session continues from its own cursor and prices. One liquidation call shares its scan budget across the bankrupt account's markets. On the burn arm, when the bankrupt account's realized balance is not negative, the engine admits any position that is not at a loss at the bankruptcy price and does not check equity, so a holder with no equity that is not at a loss there is closed first. Do not tell a trader that a position cannot be auto-deleveraged. - **Protocol state — is the stack wired and solvent.** The plane below any one account or market, mirroring what the protocol repo's `perps:state` / `perps:health` ops tasks read: ```ts const cfg = await client.getPerpSystemConfig(marginBank); // the address book const fund = await client.getInsuranceFundState(cfg.insuranceFund); const eng = await client.getLiquidationEngineConfig(cfg.liquidationEngine); ``` `getPerpSystemConfig` is the entry point: every other contract in the plane is reachable from it, so nothing is hardcoded per chain, and it carries `fullyWired` — a single flag for "some part of this stack is half-configured", which is the state where liquidation and settlement degrade silently rather than reverting. Point the other two at the addresses it returns. `liquidationEngine` is the **proxy**: an implementation address answers with unset defaults (zero bidders, zero penalty), which reads as a configured-but-idle engine rather than the wrong address. `bidderCount === 0n` is itself operational — with no registered backstop bidders the takeover stage has nobody to take a position over, so the waterfall reaches ADL sooner than the configuration implies. For account-level health when a feed may be down: `tryGetPerpAccountEquity` returns `null` rather than reverting (null is "not computable", never "zero"), and `getPerpCollateralBasis` is a solvency floor that reads one storage pair and cannot revert at all. - **Take-profit / stop-loss listing.** `client.listPerpStopOrders({ account?, pool?, status? })`. The PerpStopOrderRegistry keeps pending orders in private storage behind no enumeration getter, so there is no chain read that answers "what stops do I have" — creation and triggering both work, but without this a trader cannot see, price or cancel what they created. It is indexed, and there is no chain fallback. One call covers every scope: `{ account }` for a trader's working stops (default status `PENDING`), `{ pool }` with no account for a market's whole pending book, and `status` for history. `account` is optional deliberately — a market-wide view of what will fire is a legitimate monitoring read. Read `dropReason` before calling a `TRIGGER_FAILED` order a failure: `ReduceOnly*` means the stop was overtaken by events (position already closed, flipped, or below the minimum), which is ordinary; only `PlacementFailed` is a rejection. SOMI is consumed on every fire either way. A listed row has no `limitPrice`. Fetch it for live stops with `client.getPerpStopOrders(refs)`. - **The registry may be holding SOMI for you.** Placing a perp stop pre-pays the trigger gas in SOMI. Cancelling refunds it by direct transfer — but when that transfer _fails_, the registry credits an unclaimed balance instead and emits `SomiRefundFailed`. That is the ordinary outcome for a contract owner with no payable receiver (most multisigs and smart accounts). A registry wind-down credits the same balance to **every** owner, EOAs included. ```ts const owed = await client.getUnclaimedPerpStopSomi({ registry: perp.stopRegistry, account }); if (owed > 0n) await trader.claimPerpStopSomi({ registry: perp.stopRegistry }); ``` Read before claiming — `claimSomi` reverts `NothingToClaim` on a zero balance, and the payout is a plain native transfer to the caller, so an owner that still cannot receive native reverts `WithdrawalFailed` and the balance stays put. Do not assume an EOA is owed nothing: the wind-down path reaches them too. Note the trigger path is the opposite of a refund — SOMI is consumed on every fire and never returned. - **Batching writes that have to be atomic.** One SDK write is one transaction, which is wrong for a flow that is only safe as one — an order with TP/SL attached (sent separately, the order can fill and sit with no stop on it), approve + deposit, withdraw + forward to the wallet. `trader.buildPlacePerpStopOrder`, `buildCancelPerpStopOrder(s)`, `buildDepositMargin` and `buildWithdrawMargin` take the same parameters as their sending twins and return the unsigned call (`{ to, data, value, description }`) for you to pack into one UserOp / Safe batch / multicall. Two gotchas. Approvals come back rather than going out — `operatorApproval` on a stop, `approval` on a deposit — and must execute **before** the call they enable; the perp stop's grant especially, because without it the trigger reverts and the prepaid SOMI is spent having placed nothing. And no ids come back, because you hold the receipt: read them with `decodePerpStopOrderIds(receipt.logs, registry)`. - **Where the registry address comes from.** Every stop write — `placePerpStopOrder`, `cancelPerpStopOrder`, `cancelPerpStopOrders` — takes the per-pool `PerpStopOrderRegistry` as a required `registry` argument. Read it off the market row: `stopRegistry` on `PerpMarket`, and on the market context attached to perp portfolio orders and fills. `null` there means the pool has no registry deployed, so TP/SL is unavailable on it — not that the address is elsewhere. - **One call for a market header.** `exchange.fetchTicker(symbol)` on a perp now returns `markPrice`, `indexPrice`, `fundingRate`, `fundingTimestamp` and `openInterest` alongside the 24h high/low/volume it already computed from candles. Those five are chain state, not candle-derived, so before this a header needed a second read the consumer had to know to make. They are `undefined` on spot and binary, and no chain round-trip is spent on a non-perp. They also survive an indexer outage. The candle read is contained, so a failed one gives `statsState: "unavailable"` with the 24h fields absent while these five still answer. Read `statsState` before reading a 24h field. `fundingRate` is on the **same per-8h axis** as `fetchFundingRate` and `fetchFundingRateHistory` — deliberately, because a header on one basis beside a chart on another is a wrong number that looks right. It is not the per-settlement amount. For one settlement's charge, divide by `n = fundingWindowSec / fundingIntervalSec` (8 on every live pool) — and for a HISTORICAL row that caught up over several intervals, multiply that by the row's `intervalsAccrued`, since one settlement can charge more than one interval's worth. Both figures ride on `info.perp`. `markPrice` is omitted rather than reported as 0 when the feed is stale. - **Previewing an order before you send it.** `client.previewPerpOrderMargin({ pool, marginBank, account, isBid, quantity, price })` returns what the pool will actually lock and whether it will accept the order — the read behind an order form's "margin required" row and its submit gate. Do **not** reach for `quoteMeetsIMForOrder`: it runs with the order's base margin treated as already reserved (true on the real path, where `lockCollateral` runs first), so called cold it counts the order's margin nowhere and returns `true` for almost any size. `meetsPerpImForFill` does charge base margin, but neither models the lock's **adverse mark-to-entry reserve** — a buy above mark (or sell below) opens underwater by that gap and the pool reserves it on top of initial margin. That term is the usual reason a `notional × IMF`-sized "max" order gets rejected. Two gates are reported separately because they fail for different reasons and imply different fixes: `hasCollateralForLock` (can the lock be taken at all) versus `meetsInitialMargin` (does what remains still cover the requirement) — "deposit more" versus "close something". Only the _increasing_ leg locks. An order that nets against an existing position locks nothing up to `effectiveReducingCapacity`, which resting opposite-side orders have already partly spoken for. **Pass `autoPull: true` when the sender will be the order owner**, and both gates describe the balance the pool will have topped up to from the wallet rather than the one already in the bank. `topUpRequired` is then the wallet spend to show beside the margin figure, and `wallet` carries the balance and MarginBank allowance it was measured against. `feeHeadroom` appears either way: it is not charged and not part of `lockAmount`, only an auto-pull addend that keeps a fresh max-leverage position out of `MarginCall` at birth. A `topUpRequired` of `0n` means three different things — no pull needed, or one of the pool's declines: a purely reducing order, an account already in debt, or a voucher-blocked increase. Read it beside `unlockedCollateral`, not on its own. Every read is pinned to one block, so the result is a statement about that block; the adverse-gap term moves with the mark, so re-quote near send time for anything close to the edge. - **Market discovery is a chain read, and "deployed" is not "tradeable."** `client.listPerpPoolStatuses({ factory })` enumerates the `PerpPoolFactory` and returns every market with the **two independent gates** that decide tradeability — they fail for unrelated reasons and both must pass: 1. `restricted` — the market is close-only. Position-increasing orders revert `MarketRestricted`; closes, reduces and cancels still work. These stay listed on purpose (holders must still exit), and it is reversible. 2. `registered` — the MarginBank has activated the pool. Coming from the factory only proves a pool is _authentic_; `addPerpPool` is what makes it usable. An unregistered pool rejects every quote view and settlement callback while reading as an ordinary market from the factory. (`getPoolTier` is **not** a substitute — it is itself gated on registration.) `tradeable` folds both together, and `listTradeablePerpPools` filters to it. You do not pass a MarginBank: it is a per-network singleton in practice, but each pool names its own and that is the bank its settlement path uses, so it is read per pool and returned on every row — ready for the `getMarginAccount` / `getPerpPosition` reads that follow. Do **not** build a market list from the factory's raw pool list: it is the deployment history, so listing it unfiltered presents wound-down markets as tradeable. Testnet today has 10 pools, four of them restricted `SBTC*` arena markets. This is also more complete than the indexer, whose perp set comes from a curated manifest — a market deployed after that manifest was written is invisible there and present here. - **Per-market risk parameters are a chain read.** `client.getPerpRiskParams(pool)` returns the pool's frozen config — initial / **maintenance** / close-out margin in bps, the OI and position caps, and the maker/taker rates. `maintenanceMarginBps` is exposed nowhere else (the indexed market row carries only `initialMarginBps`), and without it a client can show the real liquidation price of an open position but not the **projected** one for an order it hasn't placed. This read never reverts, so maintenance margin stays available when the mark feed is down — exactly when you most want to explain a liquidation. - **Initial margin is not a constant.** `initialMarginBps` is only the FLOOR of the curve: with dynamic IMF enabled the pool scales it with open interest, and `client.getEffectiveImfBps(pool)` is the rate actually charged. Sizing an order off the static base under-margins it whenever OI has pushed the curve up, and the pool rejects an order the client believed fit. Maintenance margin deliberately does **not** scale — a liquidation threshold must not move under a position because the market's OI grew. - **One call for a health walk.** `client.getPerpHealthSnapshot(pool)` returns `oneBase`, mark, projected cumulative funding, the effective IMF and both thresholds together; the contract added it so a cross-margin walk reads a market once instead of five times. It returns a discriminated union — an unpriceable market arrives as `{ priceable: false }` rather than an all-zero struct, because a `maintenanceMarginBps` of `0` reads as "can never be liquidated". Narrow on `priceable` before touching a field. ## Linked wallets (isolated margin) Isolated margin on perps is one wallet per position. The MarginBank is cross-margin, so a single account's collateral backs every position it holds; the only way to give one position its own collateral bucket is to open it from its own wallet. `setIsolated` does not do this — it is single-market confinement, which caps how many markets one account may touch and changes no margin math. One wallet per position used to mean one treasury operation per position. The linked-wallet rail removes that. A wallet links to a **main** wallet, and from then on the child's position-increasing orders draw their margin shortfall from the main's wallet. One funded main serves every child linked to it. Two layers, and they answer different questions: - `LinkedWalletRegistry` is the **consent** graph. It records who is linked to whom and grants no authority over funds. - `MarginBank` is the **money** layer, and it is armed separately. Until the bank holds a registry address the rail is dormant and no child can draw on any main, whatever the registry says. ### Linking a sub-account A link takes a two-sided handshake and **both wallets sign**. The main offers, the child accepts, and no wallet can act for the other: ```ts // The MAIN offers. Its own deposit leaves the standing allowance the rail spends. await main.trader.depositMargin({ marginBank, amount: fromHuman(1000, 18) }); await main.trader.proposePerpWalletLink({ marginBank, child: childAddress }); // The CHILD accepts, with its own signer and its own transaction. await child.trader.acceptPerpWalletLink({ marginBank, main: mainAddress }); // The child's own placement now pulls the shortfall from the main's wallet. await child.trader.placePerpOrder({ pool, isBid: true, price, quantity }); ``` Pass `registry`, `marginBank` or `pool` to any of the four consent writes. Only `registry` skips a chain read: the bank names the registry it treats as authoritative, so the SDK reads it from the bank rather than a manifest, and a bank holding none throws `NotConfiguredError` rather than sending consent nowhere. That read is never cached, because the bank's owner can rotate the registry. ### How the funding actually happens There is no funding call, and this is the part that surprises people. The main supplies USDso and an ERC-20 allowance to the **MarginBank**, and then sends nothing. `PerpPool` performs the pull inside the child's own order placement: - The child's own wallet pays first, sized by `min(balance, allowance)`. - The main's wallet pays the residual, sized the same way. - The pool's entire gate is `msg.sender == order.owner`. An order routed through `placeOrderFor`, an operator grant, a router or the stop registry pulls **nothing**, from either wallet. So the child must send its own orders, and it needs native SOMI for gas. Revoking the main's allowance stops the funding without touching the link. Funding does not wait for link maturity: the rail reads the raw graph, and `maturesAt` gates ADL netting only. What a main funds is a **claim, not a gift**. The bank records the funded principal against the child, and the child's `withdraw` frees at most `balance - principal` — so a compromised child key can trade the money and lose it, but cannot take it out. The child's own money is the junior tranche: a loss eats it first, and a child that genuinely lost the principal owes nothing. ### Getting the money back Read the outstanding claim with `client.getPerpMainFunding(marginBank, account)`. Two routes send it home, and they differ only in who signs: | Write | Signer | Use it when | | ------------------------------------------------- | --------- | ------------------------------------------- | | `trader.repayPerpMainFunding({ amount })` | the child | the child is finished and returns the money | | `trader.recallPerpMainFunding({ child, amount })` | the payer | the main pulls its capital back | Both pay the payer the bank snapshotted at funding time, not whoever is linked now, so an unlink or a re-link in between cannot misroute the money. Both **clamp** the amount to `min(amount, outstanding, present balance)`, so over-asking is safe and only a clamp that reaches zero reverts. Both recover principal and never winnings. `trader.unlinkPerpWallet({ counterparty })` dissolves the link from either end. It settles no money, so clear the claim first if the intent is to part cleanly. `PerpsUnlinkGuard` vetoes it while the leaver holds open positions and is at `PartialLiquidation` or worse; a healthy or flat wallet can always leave. ### Batching the two steps that need it Two steps have an ordering dependency inside one transaction, and each has a build-only twin for it: - `trader.buildAcceptPerpWalletLink` — accept, then open the isolated position, in one signature. - `trader.buildRepayPerpMainFunding` — repay, then withdraw the remainder. The withdrawal's own gate reads the claim the repayment clears, so it has to follow. The other four writes are standalone administrative calls and have no twin. ### Which read answers which question The six chain reads are live contract STATE — a value as of a block, which no log can reconstruct. The history is the opposite: the contracts expose no getter for any of it, so the events in the chain's logs are the only record, and the indexer is what makes them queryable. | Question | Read | Tier | | --------------------------------------------------- | ------------------------------------------------------ | ------- | | Is the rail on at all on this deployment? | `client.getPerpLinkedWalletRegistry(marginBank)` | chain | | Is a main eligible to fund this account, and which? | `client.quotePerpFundingPayer(marginBank, account)` | chain | | What is outstanding, and to whom? | `client.getPerpMainFunding(marginBank, account)` | chain | | What can a wallet actually contribute right now? | `client.getPerpWalletPullCapacity(marginBank, wallet)` | chain | | Who is in my group, and when did it mature? | `client.getPerpWalletLinkage(registry, wallet)` | chain | | Which children does this main have? | `client.listPerpLinkedChildren(registry, main)` | chain | | How many children may one main hold? | `client.getPerpMaxLinkedChildren(registry)` | chain | | How did it get to this state? | the three `list*` ledgers below | indexer | **Eligibility is not a prediction.** `quotePerpFundingPayer` takes no order and no sender, so it cannot say whether a given order will debit the main. Four things stop an eligible main from paying anything: the order needs no top-up, the child's own wallet covers the whole pull, the order is routed by someone other than its owner, or the main has no spendable capacity. Size the order with `client.previewPerpOrderMargin({ autoPull: true })` and read its `mainWalletPull` for the amount that would actually move. `quotePerpFundingPayer` returns a discriminated union rather than an address, because the contract's single zero collapses three situations a UI must not render alike. Narrow on `funded` first: ```ts const payer = await client.quotePerpFundingPayer(marginBank, account); if (payer.funded) { // A main is ELIGIBLE to cover what this account's own wallet cannot. Whether any // given order debits it is `previewPerpOrderMargin({ autoPull: true }).mainWalletPull`. console.log("eligible payer", payer.payer); } else if (payer.reason === "unlinked") { // The one case the user can fix, by linking. } else if (payer.reason === "dormant") { // The rail is off for everyone here. Linking would not help. } else { // "isMain" — linked, but funding flows main->child only. } ``` `getPerpMainFunding` reports the claim and the payer **snapshotted at funding time**, which is not necessarily whoever is linked now. When the two disagree, the snapshot is who gets repaid. It also carries `withdrawableFromPrincipal`, which is always `0n` — a field rather than prose because "why can I not withdraw my balance" is the commonest question this surface has to answer. ### Finding an incoming proposal The registry keeps pending proposals in a hashed map with **no getter**. So a child cannot discover from any chain read that a main has offered it a link, and `client.listPerpWalletLinkEvents` is the only evidence such an offer exists. That list yields **candidates, not accepts that will succeed**. `acceptLink` applies five live-state guards, and each is a current-state rather than an ever-happened question: 1. An offer stands only while the pair's newest row is the `Proposed` itself. Mind the direction: an unlink clears the pending proposal in **both** directions, so an `Unlinked` naming this wallet as the main can retire an offer it holds as the child. 2. The child must be free right now (`AlreadyLinked`). Accepting one main leaves the losing mains' proposals in storage, dead only while that link stands. 3. The main must not itself be a child right now (`CallerIsChild`). 4. The accepting wallet must not itself be a main (`CallerIsMain`). 5. The main must be under its child cap (`MaxChildrenReached`). All five are **derivable in principle** from a complete `Linked` / `Unlinked` replay — except the cap, whose value is owner-tunable and whose `MaxChildrenUpdated` event is deliberately not subscribed. In practice a paginated page of rows is not a complete replay, which is the real reason not to decide an accept off this list. So confirm on chain. `client.getPerpWalletLinkage(registry, wallet)` settles guards 2, 3 and 4 — read it on both parties, since `main` being non-zero means the wallet is already in a group. It does **not** carry the cap: that is `client.getPerpMaxLinkedChildren(registry)` against `client.listPerpLinkedChildren(registry, main)`. ### The two funding ledgers, which must not be summed Every proposal, link, pull, settle and return is indexed, because the chain keeps no history of any of it: ```ts const links = await client.listPerpWalletLinkEvents({ child: account }); // Proposed | ProposalCancelled | Linked | Unlinked const pulls = await client.listPerpMarginPulls({ account, source: "Main" }); // the POOL side — names the order const claims = await client.listPerpMainFundingEvents({ account }); // the BANK side — carries the running claim ``` **A MAIN-funded leg emits a row on each side for the same wei.** Adding `listPerpMarginPulls` to `listPerpMainFundingEvents` therefore double-counts every main-funded transfer. Read one or the other, never both as one total, and pick by the question: the pool side names the **order**, the bank side carries the **claim**. The overlap is exactly the main legs, and no more. An own-wallet pull has no bank-side twin: the bank's `DepositedFor` is deliberately not subscribed, because `PerpMarginPull(source: "OwnWallet")` already carries the same amount plus the order id. So `PerpMainFundingEvent` is the main legs only, while `PerpMarginPull` is both. One placement can produce two `PerpMarginPull` rows — an `OwnWallet` leg and a `Main` leg, in that order — because the child's own wallet contributes what it can before the main is touched. Filter on `source: "Main"` to see only what a main actually paid for. On `PerpMainFundingEvent`, a `Settled` row moved no cash: it records the child's losses discharging part of the claim, so its `amount` is `null` while `outstandingPrincipal` still drops. Fold by `kind` rather than summing `amount`. ## Credit vouchers (house money) A credit voucher is a one-time grant of collateral into a new account's MarginBank balance. The grant is non-withdrawable. The account can trade with it and can withdraw profit above it. A `VoucherDistributor` holds the budget and the campaign rules, and the bank names the live distributor, so no deployment address is needed. | Question | Read | Tier | | ----------------------------------------- | ------------------------------------------------------- | ----- | | Is a campaign live, and what does it pay? | `client.getPerpVoucherCampaign(marginBank)` | chain | | Can this account claim now? If not, why? | `client.getPerpVoucherEligibility(marginBank, account)` | chain | | How much house money does it hold? | `client.getPerpVoucher(marginBank, account)` | chain | Check eligibility before you claim. A failed claim still mines and costs gas: ```ts const eligibility = await client.getPerpVoucherEligibility(marginBank, account); if (eligibility.eligible) { await trader.claimPerpVoucher({ marginBank }); } else { // "dormant" | "notStarted" | "ended" | "notFlat" | "mainFunded" | "alreadyClaimed" | "budgetExhausted" console.log("cannot claim:", eligibility.reason); } ``` Rules to know before you build on it: - **One claim per address, for the distributor's life.** A new campaign window does not reset it. - **The account must be flat to claim.** No positions and no resting perp orders. - **The grant confines position increases.** Only allowlisted markets, at no more than the voucher leverage cap, and the account cannot change its own leverage setting. `getPerpLeverage` and `previewPerpOrderMargin` already apply these rules. - **Own funds can mix in.** A voucher account can still deposit, and an order placed with an ERC-20 allowance to the bank can pull wallet collateral in. To keep an account on house money only, do not ask it for that allowance. - **The grant expires.** After `expiresAt`, anyone can sweep the remaining floor back to the distributor, but only while the account is flat. A sweep never forces a close. - **Each grant is indexed.** It appears in `client.getMarginEvents` as `kind: "Credited"`. `trader.buildClaimPerpVoucher` returns the unsigned claim, so a claim and the first order can go out as one transaction. The claim must execute first. ## History (indexer) The CURRENT position/margin is the chain read above; the **append-only history** the chain doesn't expose is indexed (perp account plane), all one-shot indexer reads: ```ts const funding = await client.getFundingPayments(account, { pool, limit: 50 }); // signed funding paid/received const margin = await client.getMarginEvents(account, { limit: 50 }); // deposit/withdraw/lock/unlock const liqs = await client.listLiquidations({ party: account, pool }); // liquidation events involving this wallet const changes = await client.listPerpPositionChanges({ account, pool }); // every position update, with its realized PnL const fillChanges = await client.listPerpPositionChangesForFills(account, fills); // the update each of the account's fills made const rates = await client.listFundingRateHistory(pool, { from, to }); // per-pool funding-rate series const candles = await client.listFundingRateCandles(pool, 3600, { from, to }); // 1h/4h/1d rollups for charting const oi = await client.getOpenInterestHistory(pool); // per-pool open-interest series ``` ### Realized PnL per close This section explains how to read the result of one closed trade. The position row from `listPerpPositions` keeps only the latest state, so it cannot show one trade. `client.listPerpPositionChanges` returns one row for each `MarginBank.PositionUpdated`. MarginBank emits one for each order-book fill, for each auto-deleveraging or market wind-down leg, and for both sides of a liquidation takeover. So the history covers every way a position closes, also liquidations that leave no fill. - Each row has the signed size before and after, the size it closed, the entry price before and after, and the PnL of that update alone. All values are exact `bigint` raw units. - `kind` says what the update did: `OPEN`, `INCREASE`, `REDUCE`, `CLOSE` or `FLIP`. Pass `kinds: ["REDUCE", "CLOSE", "FLIP"]` to get only the updates that realized PnL. - One order that fills across several book levels makes several rows. Group the rows by `account`, `pool` and `orderId`, and sum their `realizedPnl` and `closedSize`. Do not group by `txHash` alone: one transaction can hold both parties' rows and several orders. Rows with no `orderId` (liquidation takeover, ADL, wind-down) are one close each. - A full close reports a zero entry price. `entryPriceBefore` keeps the entry that the close realized against, and `exitPrice` the price it closed at, also for a liquidation, ADL or wind-down close with no fill. - The PnL is price PnL only. Fees (`listPerpFees`) and funding (`getFundingPayments`) are separate records. To label a trade history, `client.listPerpPositionChangesForFills(account, fills)` pairs each of the account's fills with the update that fill made, so a row can say Open Long, Close Short or show a flip. Pass one page of the account's fills, at most 1,000. The result has one `PerpPositionChange` or `null` for each fill, in the same order. `null` means the fill is not the account's, is not a perp fill, or its update is not indexed yet. An indexer that predates position changes throws `PerpPositionChangesUnavailableError` instead: show each fill's own side until it is reindexed. `IndexedPerpPosition.lastUpdateRealizedPnl` is the running total of the same deltas for the current position. It restarts from zero when a flat position reopens or the position flips side. `listFundingRateHistory` / `getOpenInterestHistory` are the append-only counterparts to the overwrite-only `fundingRate` / `openInterest` fields on the perp `Market` row (which only carry the latest value). `getFundingRateHistory` is the deprecated alias of the first; it forwards verbatim. `listFundingRateHistory` sorts on `timestamp`, then `blockNumber`, then `id`, all in the direction of `order`. Rows that share a timestamp therefore come back in the same order on every read. Liquidation history is served by the `LiquidationEngine` subscription, which is live — the contract is deployed and indexed from block 436,735,800. There is no `MarginBank`-sourced fallback: `MarginBank.Liquidated` was deleted from the protocol, so the rows that claim used to describe cannot exist. Per-position detail comes from `LiquidationEngine.PositionLiquidated`, and the waterfall's other outcomes (ADL, takeover, close-out, the residual and declined-coverage markers) arrive as sibling rows sharing a `txHash` — read `kind` to tell them apart, and read the TSDoc on `badDebt` / `insuranceCovered` / `deficit` / `coverageDeclined` before aggregating any of them, because only the flows are summable. ### Reading a liquidation's stage `AccountLiquidated` rows carry `stageReached`. It tells you how far down the waterfall a liquidation went. The number needs a mapping, because the protocol enum is 0-indexed with six members while the published waterfall numbers its stages 1 to 6: | `stageReached` | Enum member | Published stage | | -------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | 0 | `OrderCancellation` | before stage 3 — cancelling the account's resting orders alone restored health | | 1 | `CLOBPartial` | stage 3 | | 2 | `BidderTakeover` | stage 4 | | 3 | `InsuranceFund` | stage 5 | | 4 | `ADL` | stage 6 | | 5 | `Deferred` | not a stage — stage 3 hit its per-block rate limit, and stages 4 to 6 were not consulted. Ships dormant, so it does not appear yet | Published stages 1 (Healthy) and 2 (Margin Call) are account states, not actions. The engine cannot reach them, so no value maps to them. Two rules govern the number, and neither is visible in the value itself. **It names the deepest stage that acted, not the deepest stage consulted.** A stage that ran and moved nothing does not promote it. So a `BidderTakeover` (2) row sitting beside a `BadDebtAbsorbed` row is consistent. It means the insurance fund was asked and paid nothing. `BadDebtAbsorbed` fires on the request, not on the payment, so read `insuranceCovered` on that row to see whether the fund actually paid. A market the fund does not cover produces exactly this shape: the loss falls through as `ResidualBadDebt`. **`CLOBPartial` (1) is a fall-through marker.** It means stages 4 to 6 were consulted and none of them acted. It does not prove stage 3 filled anything. Against an empty book you get `CLOBPartial` with a `positionsProcessed` of 0, which means the account's orders were cancelled and no position was closed. Read `positionsProcessed` with it: ```ts const rows = await client.getLiquidations({ account }); const summary = rows.find((r) => r.kind === "AccountLiquidated"); // A real CLOB partial fill, not an empty-book no-op. const clobFilled = summary?.stageReached === 1 && Number(summary.positionsProcessed) > 0; ``` `counterparty` on a `BadDebtAbsorbed` row names the fund the engine asked. The address is recorded even when `insuranceCovered` is 0, so it does not mean the fund paid either. `marginStatusAfter` needs the same care. A flat account that still owes bad debt reports `CloseOut` (3) so the debt stays visible to monitoring. That is a terminal state, not an account that is still liquidatable. A `ResidualBadDebt` row in the same transaction identifies it. If you query the indexer's GraphQL directly rather than through the SDK, note that its own field descriptions still carry the older wording for `kind`, `counterparty` and `stageReached`. Correcting them changes the served schema and so needs a reindex; it is tracked separately. This section is the current reference. ## Watch the perp plane (liquidations + funding settlement) `watchMarket(pool)` gives a perp pool's book, funding RATE and open interest. `watchPerpMarkets()` gives the other half: the liquidation waterfall and per-account funding settlement, with the same snapshot-then-live guarantee. Neither implies the other, so a UI showing both opens both. ```ts const plane = await client.watchPerpMarkets(); const liqs = client.listLiveLiquidations({ party: account, limit: 50 }); const paid = client.listLiveFundingPayments({ account, limit: 50 }); const unsubscribe = client.subscribeLive(() => { // re-read; results are memoized per store version }); // Teardown: the listener and the watch are released separately. unsubscribe(); plane.stop(); ``` The rows are the SAME `LiquidationEvent` and `FundingPayment` types `listLiquidations` and `getFundingPayments` serve, in the same newest-first order, so the two merge without translation. The live window is hydrated from the newest indexed rows, so it OVERLAPS the newest indexed page: merge by `id`, never by concatenation, or legs and their amounts are counted twice. That order is a TOTAL key on all three paths — the live store, the hydration and the indexed reads: `(timestamp, blockNumber, id)` for liquidations, and `(timestamp, id)` for funding payments, which carry no block column. Every leg of one waterfall shares a timestamp, so the tie-break is what makes a `limit` boundary land on the same rows whichever path serves them. The last key is the row id and not the log index inside it, because the indexed reads can only order on a column that exists. React: ```tsx const status = useWatchPerpMarkets(); // "unwatched" | "hydrating" | "live" const liqs = useLiveLiquidations({ party: account }); const paid = useLiveFundingPayments({ account }); ``` The two read hooks acquire the plane themselves, so `useWatchPerpMarkets` is only needed for the status — or to pass `{ marginBank }`, which the read hooks have no way to supply: `useWatchPerpMarkets({ marginBank })`. Each of these hooks also takes `chainId` in its options. It reads that chain's engine on a multi-chain exchange. Without it, the hook reads the primary chain. ```tsx const liqsOnChain = useLiveLiquidations({ party: account, chainId }); ``` For error UI, use `useWatchPerpMarketsResult()`, which returns the typed acquisition result instead of only warning. Worth preferring on this feed: a plane that failed to start and a venue where nothing has been liquidated both render as an empty list, and only the result tells them apart. ### One scope for the whole venue There is no `watchPerpMarket(pool)`, and that is forced rather than chosen. MarginBank and LiquidationEngine are singletons, so the log subscription is one stream whatever you ask for. And the account-level waterfall legs (`AccountLiquidated`, `ResidualBadDebt`, `ResidualBackedByOpenPnl`, `CoverageDeclined`, ...) carry no pool at all, so a per-pool scope would either drop them or attach them to every pool. So filter at read time. A `pool` filter is honest but PARTIAL: it keeps only the per-position legs, exactly as the indexed read's nullable `pool` column does. Filter by `party` for every row involving one wallet. Use `account` when the wallet must be the liquidated side. For a `party` row, compare `row.account` with the wallet. If they differ, the wallet is `row.counterparty`; `size` and `price` describe the liquidated account's leg, not the wallet's leg. Read the `LiquidationEvent` field docs to interpret each kind before showing it in a wallet view. ### One liquidation is several rows Read `kind` first. The rows are stages of one mechanism sharing a `txHash`, not repetitions of one event. Seventeen kinds arrive here, and the SDK produces the same set the indexer does. Two of them key on a wallet you might not expect. `AutoDeleveraged` is keyed on the BANKRUPT account, so an account assigned in as the profitable counterparty has no row of its own — it appears in the `counterparty` column instead. The stage-4 flows (`PositionTransferred`, `CloseOutMarginSettled`) are keyed on `from`. Read either side with `client.listLiquidations({ party: account })`. Use `kind` to select one stage. A wallet view can select its position legs in one bounded read. `kinds: []` adds no filter. ```ts const positionLegs = await client.listLiquidations({ party: account, kinds: ["PositionLiquidated", "PositionTakenOver", "AutoDeleveraged"], }); ``` This selection omits `PositionTransferred`. MarginBank emits it as the settlement record for the same takeover as `PositionTakenOver`, so including both would count one takeover twice. Before aggregating anything, read the field TSDoc on `LiquidationEvent`. Only `insuranceCovered` and `coverageDeclined` are flows and safe to SUM. `badDebt` and `deficit` are LEVELS — overlapping per-stage views of one hole — so summing them double-counts, and `collateralAmount` is collateral that moved between accounts rather than a loss. Four columns the indexer keeps have no home on the published type (`blockVolume`, `volumeCap`, `keeperReward`, `panicCode`), because `getLiquidations` does not select them either. So `Throttled`, `KeeperReward` and `OrderPanicked` arrive as markers from both sources alike. ### What the watch costs One indexer request, at hydration. After that the plane is fed from chain logs only, and a socket drop is healed by resubscribe plus a chain backfill with no second indexer touch — the same contract `watchMarket` has. It also needs the plane's wiring. The MarginBank comes from an indexed perp market row, and `MarginBank.getSystemConfig()` reports the LiquidationEngine from there, so nothing is hardcoded per chain. Pass `watchPerpMarkets({ marginBank })` only for the case that cannot cover: an indexer with no perp rows at all, where there is nothing to ask. It is a FALLBACK, never an override: it is used when the indexer names no bank, including on a scope a read hook acquired a beat earlier with no hint to give, and it is ignored when the indexer named one. The hydrated rows are that bank's plane, so a hint that won would splice one venue's history onto another venue's stream. If that wiring read fails the watch is REFUSED rather than opened over the bank alone, which would stream funding settlements and silently no liquidations — indistinguishable from a venue with none. The rejection type is `SomniaMarketsClientWatchPerpMarketsError`. ## Watch from chain (event ABIs) Indexer history is one-shot and the chain reads are point-in-time. To see a perp event as it lands, decode the log yourself. The SDK publishes the event ABIs so your decoder uses the same signatures the SDK and the indexer use: | ABI | Contract | Scope | Carries | | ---------------------------- | ----------------- | -------------------- | ------------------------------------------------------------------------------------- | | `perpPoolEventsAbi` | PerpPool | one per market | funding rate, open interest, the maker pre-fill reason tags | | `marginBankEventsAbi` | MarginBank | singleton, all pools | positions, collateral flow, funding settlement, fees, the liquidation settlement legs | | `liquidationEngineEventsAbi` | LiquidationEngine | singleton, all pools | the liquidation waterfall stages and their costs | Both singletons serve every pool and every account, so a subscription is one stream. The pool and the account are indexed topics, not the log source. Get the addresses from the market row and the system config: ```ts import { getAbiItem } from "viem"; import { liquidationEngineEventsAbi, marginBankEventsAbi } from "@somnia-chain/markets-sdk"; const market = await client.getPerpMarket(perpMarketId); const { liquidationEngine } = await client.getPerpSystemConfig(market!.marginBank); const viem = client.getViemClient(); // shares the SDK's WebSocket // Per-wallet funding settlement. `payment` is signed: positive is what the // account PAYS. const unwatchFunding = viem.watchEvent({ address: market!.marginBank, event: getAbiItem({ abi: marginBankEventsAbi, name: "FundingSettled" }), args: { account }, onLogs: (logs) => { for (const log of logs) console.log(log.args.perpPool, log.args.payment); }, }); // Every liquidation on the venue. `PositionLiquidated` is pool-scoped, so filter // by pool here if you only want one market. const unwatchLiquidations = viem.watchEvent({ address: liquidationEngine, event: getAbiItem({ abi: liquidationEngineEventsAbi, name: "PositionLiquidated" }), onLogs: (logs) => { for (const log of logs) console.log(log.args.account, log.args.sizeDelta, log.args.markPrice); }, }); ``` Three things to know before you build on this. **The waterfall is split across both singletons.** LiquidationEngine emits the stages and their costs. MarginBank emits the settlement legs of the same liquidation — `AutoDeleveraged`, `PositionTransferred`, `CloseOutMarginSettled` and `BadDebtAbsorbed`. Subscribe both, or you see half of each event. **Account-level events carry no pool.** `AccountLiquidated`, `ResidualBadDebt`, `AdlPriceCapacityExhausted` and their siblings describe the account, not one market. A per-pool liquidation filter therefore drops them. Filter by pool only on the per-position events. **Not every field is summable.** `BadDebtAbsorbed` reports `badDebt` (the full negative balance, before coverage) beside `covered` (the wei the insurance fund moved). `ResidualBadDebt` reports the uncovered remainder of the same hole. Adding them counts one loss twice. The same applies to `collateralTransferred` on `PositionTransferred` and `amount` on `CloseOutMarginSettled`: both are flows between accounts, not losses. The signatures are pinned by topic0 against the compiled artifacts in `test/perpEventsAbi.test.ts`. A wrong signature is a different topic0, which means `watchEvent` filters on something no contract emits and your handler never fires, with no error to see. Use the published ABI rather than a copy. Note the SDK's own live tail does not subscribe either singleton yet, so `client.watchMarket(pool)` gives you funding-RATE updates (`FundingUpdated` on the pool) but not funding settlement, positions or liquidations. Those are the raw watch above until the tail covers them. ## Quick start ```ts const exchange = new SomniaMarkets({ chain, wsRpcUrl, indexerUrl, privateKey }); await exchange.loadMarkets(); await exchange.depositMargin("BTC/USDSO:USDSO", 1_000); // USDso → MarginBank await exchange.createOrder("BTC/USDSO:USDSO", "limit", "buy", 0.001, 62_000); const [pos] = await exchange.fetchPositions(); // long/short + uPnL const { fundingRate, markPrice } = await exchange.fetchFundingRate("BTC/USDSO:USDSO"); ``` ## One-shot reads (no watch) For a plain fetch without a live tail: ```ts const perps = await client.listPerpMarkets({ baseSymbol: "WBTC", limit: 20 }); const one = await client.getPerpMarket(perps[0].id); // null if not a perp const port = await client.getPerpPortfolio(account, { ordersLimit: 50, tradesLimit: 50, since }); const hist = await client.listPerpOrderHistory(account, { limit: 100 }); // FINISHED orders const state = await client.getPerpState(one!.poolAddress); // mark/index/funding/OI ``` `listPerpMarkets` takes a `PerpMarketFilter` (`baseSymbol` / `quoteSymbol`, all server-side); `getPerpPortfolio` takes the shared `PortfolioOptions` (`ordersLimit` / `tradesLimit` / `since`). Trades default to the **last seven days** — the result's `tradesSince` is the bound that was applied; pass `since` to widen it. Positions + collateral stay on-chain (`getPerpPosition` / `getMarginAccount`), not indexed rows. `getPerpPortfolio` returns **open** orders only, so `listPerpOrderHistory` is the other half — finished orders, most-recently-ended first. It excludes working orders by default and sorts by when each order _ended_ rather than when it was placed, so a long-resting order that just filled lands at the top of a history view instead of buried at its placement date. Pass `status` to narrow to particular outcomes. Sort axis is selectable — `orderBy: "ended"` (default) or `"placed"`. Watch `Closed`: it is terminal, not transitional. Every pool places an order as `Closed` and a following `OrderRested` promotes it to `Open`, so an IOC that partially filled without resting stays `Closed` forever — reading it as "still working" shows a finished order as live. ## Writing forward-compatible code ```ts const m = client.getLiveMarketByPool(pool); switch (m?.marketType) { case "BINARY": /* YES/NO book */ break; case "SPOT": /* base/quote book */ break; case "PERP": /* base/quote book + funding/positions */ break; } ``` Key on `marketType` (not on field presence), use the type guards, and read decimals off the market row rather than assuming. --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/lend SomniaLend is a third-party money market on Somnia mainnet and testnet (an Aave v3.0 fork — [docs.somnialend.finance](https://docs.somnialend.finance/)). The SDK wraps its deployed contracts behind the `client.lend` namespace, so trading capital can earn while idle: supply USDso between sessions, post it as collateral, borrow working capital against it. It lives on the master client as the `lend` namespace. Because SomniaLend is third-party, its addresses are not in the deployments manifests — wire them in config, with the published deployments available as constants: ```ts import { SOMNIA_MAINNET_LEND } from "@somnia-chain/markets-sdk"; const exchange = new SomniaMarkets({ ...config, addresses: { ...addresses, lend: SOMNIA_MAINNET_LEND }, }); const lend = exchange.client.lend; ``` `SOMNIA_MAINNET_LEND` (chain 5031) and `SOMNIA_TESTNET_LEND` (chain 50312) carry the two published deployments (Pool, PoolAddressesProvider, UiPoolDataProviderV3, WrappedTokenGatewayV3), verified against the on-chain contracts. Set one as `addresses.lend` and reach the surface through `client.lend`; that is the only entry point, so a lend read always rides the client's own chain transport. Everything else you need without the client comes from the root entry (`@somnia-chain/markets-sdk`): the types, both deployment constants, the ray-math helpers (`lendRayRateToApy`, `rayMul`, …) and the verified minimal ABIs. Targeting a different deployment means a **different client** — the addresses and the chain travel together, so `new SomniaMarkets({ chain, wsRpcUrl, addresses: { lend } })`. (A `createLend(client, addresses)` factory used to be exported; it took a whole client to use two of its members, and let one chain's addresses be grafted onto another chain's socket, which type-checked and silently read nothing.) ## Reads Two calls cover the whole surface — both are chain reads over the client's WebSocket, current to head, no indexer involvement (`useLendReserves` and `useLendAccount` in `@somnia-chain/markets-sdk/react` wrap them for components): ```ts const reserves = await lend.listReserves(); // every listed asset const account = await lend.getAccount(me); // health factor + positions ``` `listReserves` is one aggregated `eth_call` (UiPoolDataProviderV3): per reserve you get the config (LTV / liquidation threshold / caps / flags), live rates and indexes, available liquidity, total debt, and the oracle price. `getAccount` joins the Pool's risk aggregate (health factor, borrowing power) with every non-empty supplied/borrowed position. Units, in the SDK-wide convention (bigint raw units everywhere): - **Rates and indexes are ray (1e27).** Convert a rate for display with `lendRayRateToApy(r.liquidityRateRay)` — the per-second-compounded APY fraction Aave UIs show. - **`healthFactor` is a wad (1e18).** Below `1e18` the position is liquidatable; a debt-free account reports `maxUint256`. - **`*Base` aggregates** (`totalCollateralBase`, `availableBorrowsBase`, reserve prices) are denominated in the oracle base currency — USD with 8 decimals on this deployment (`baseCurrencyDecimals` rides along). - **`borrowCap` / `supplyCap` are whole tokens** (Aave convention), not raw units; `0` means uncapped. - Balances (`aTokenBalance`, `variableDebt`, `totalSupplied`, …) are raw underlying units, accrued to the read's timestamp with Aave's own interest math (linear for supply, compounded for debt) — they match what the Pool would settle, not the last stored checkpoint. ## Writes ```ts const lender = lend.createLender({ privateKey }); // or account / walletClient await lender.supply(usdso, 1_000n * 10n ** 18n); // auto-approves the Pool await lender.borrow(usdce, 500n * 10n ** 6n); // variable rate only await lender.repay(usdce, maxUint256); // maxUint256 = full debt await lender.withdraw(usdso, maxUint256); // maxUint256 = full balance await lender.setUseAsCollateral(usdso, true); ``` Same doctrine as the trader: writes resolve once mined (with the receipt), gas is a fixed generous ceiling, fees are the client's fixed EIP-1559 config, and ERC-20 pulls auto-approve `maxUint256` once per (token, spender) with an in-memory grant cache (`approve: false` opts out per call; `clearApprovalCache()` resets). Borrowing is variable-rate only — stable borrowing isn't surfaced. Native SOMI has gateway-routed siblings so you never touch WSOMI yourself: `supplyNative` / `withdrawNative` / `borrowNative` / `repayNative`. Two of them need a one-time grant the SDK also handles automatically: `withdrawNative` approves the gateway to pull your aWSOMI, and `borrowNative` delegates borrowing power on the WSOMI variable-debt token (`approveDelegation`). `repayNative` doesn't take `maxUint256` — overpay slightly instead; the gateway refunds the excess. ## Risk notes - A borrow that would push the health factor below 1 reverts; watch `getAccount(me).healthFactor` and size against `availableBorrowsBase`. - Liquidation pays the liquidator `liquidationBonusBps` out of your collateral — keep headroom, especially against WSOMI's price. - Reserve flags matter: `isFrozen` blocks new supplies/borrows, `isPaused` blocks everything; check them before sizing. - The SDK talks to SomniaLend's contracts as deployed — protocol risk (upgrades, oracle, listing params) is SomniaLend's admin surface, not this SDK's. See their [risks page](https://docs.somnialend.finance/risks). --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/prices A realtime index price for an asset (BTC, ETH) from the **on-chain EMA price oracle** — streamed the same way the order books are: hydrate a snapshot to get roughly up to speed, then tail live. It's a _read-only_ feed (an index price, not a tradable market), so there are no orders here — just `watch`/`fetch`. The shared client mechanics (read tiers, the reactive store, React wiring) are in [the engine guide](./engine); how the number itself is produced — venue sourcing, weighted median, quantization, validator consensus, the EMA mark — is [Methodology](#methodology) at the bottom of this page. ## The mental model - **A separate service.** Prices come from a standalone EMA price-feed indexer (Hasura GraphQL), _not_ the markets indexer — its own endpoint, its own WebSocket. So price watches are independent of `watchMarket`: their own store, their own `subscribePrices` signal, their own health. - **One endpoint, every asset.** A single endpoint (`config.priceFeed`) serves every tracked asset (`"BTC"`, `"ETH"`, …); callers select an asset by symbol (case-insensitive). Discover what's tracked with `listPriceFeeds()`. With no `priceFeed` configured, the price methods throw with guidance. - **Snapshot, then live tail.** A watch first pulls an HTTP snapshot (current price and, when requested, recent ticks), then Hasura subscriptions stream updates. The two heal differently on a reconnect. The current price is a full-state subscription: every push carries the current row, so re-subscribing is all it needs. The tick tape is a stream cursored on `requestId` — it is seeded from the newest tick in the snapshot and advances on every push, so a reconnect resumes from the last tick delivered rather than replaying the window. Both are handled with backoff internally; neither is anything a caller has to drive. - **One socket and batched snapshots per client.** Every watched asset on one endpoint shares one Hasura socket in the client. Each asset keeps its own current-price subscription and, when requested, tape subscription and cursor. Watches that start in the same tick (for example `watchPrices`, or several hooks that mount together) share snapshot requests of up to 10 assets each, grouped by tape demand. Each tape reader receives up to 200 recent ticks; latest-only readers receive none. A failed snapshot request rejects only the watches it carried. Two clients never share a socket. - **Human numbers + exact raw.** Every price is a 1e18-scaled integer on the wire; the structs carry a display `number` (`price`, `ema`) _and_ the exact 1e18 string in `raw` — never round-trip money through the `number`. Timestamps are unix **seconds** (`blockTimestamp` is chain time, monotonic — use it as a series x-axis; `observedAt` is the oracle's own source time and can drift). ## Configuration ```ts import { SomniaMarkets, SOMNIA_TESTNET_PRICE_FEED } from "@somnia-chain/markets-sdk"; const exchange = new SomniaMarkets({ indexerUrl, chain, wsRpcUrl, priceFeed: SOMNIA_TESTNET_PRICE_FEED, // { url, quote } - the deployed dev feed, all assets // or your own: priceFeed: { url: "https://…/v1/graphql", quote: "USDC" } }); ``` `priceFeed` is `{ url, wsUrl?, quote? }`; `wsUrl` is derived from `url` (`http→ws`, `https→wss`) when omitted. Live subscriptions use the exchange's WebSocket selection: the SDK-managed `ws` transport on Node, the platform constructor in browsers, or `rpcWebSocketConstructor` when you set it. **`quote` is required to watch.** The feed indexes several quotes per base (`BTC/USDC`, `BTC/USDT`), so `base` alone is not a unique feed key. The tick tape streams on a `requestId` cursor, and one oracle request prices every symbol in the tick, so across quotes that cursor is not unique and a batch boundary between two rows sharing one would advance past the second and lose the tick. Rather than drop data quietly, `watchPrice`/`watchPrices` (and the price hooks) reject an unpinned feed with a `NotConfiguredError` naming `config.priceFeed.quote`. The one-shot reads (`fetchPrice`, `fetchPriceHistory`, `fetchPriceOHLCV`, `fetchPrices`, `listPriceFeeds`) still work unpinned, where matching every quote only double-counts a base (duplicate candle buckets, an arbitrary row per asset). Pin it anyway unless you want that. Both shipped constants are pinned to USDC, so anything built on `SOMNIA_TESTNET_PRICE_FEED` or `SOMNIA_MAINNET_PRICE_FEED` needs no change. ## Reading — live Direct `client.watchPrice` and `client.watchPrices` calls include the tick tape by default. Pass `{ ticks: false }` for current price and metadata only. Reads of the tick tape return an empty array until a tick reader acquires it. Latest/info React hooks (`useLivePrice`, `useLivePriceFeedInfo`, `useWatchPrice` and `useWatchPriceResult`) acquire no historical ticks or tape stream. `useLivePriceTicks` acquires the tape for its asset. Adding that reader hydrates only its asset. Removing its last reference releases the tape stream and cached ticks after 30 seconds, while latest/info readers continue. A remount during this linger reuses the tape and its cursor. Owner close releases everything immediately, including pending snapshots. Current-only and tape snapshots batch separately. A tape request failure does not reject a simultaneous current-only acquisition. A tape upgrade also keeps the current-price stream and its newer metadata intact. ```ts const watch = await client.watchPrice("BTC"); // snapshot + subscribe; ref-counted const now = client.getLivePrice("BTC"); // LivePrice | null — { price, ema, blockTimestamp, raw, … } const tape = client.getLivePriceTicks("BTC", { limit: 120 }); // recent ticks, newest first const info = client.getLivePriceFeedInfo("BTC"); // decimals, symbol, description, latest const state = client.getPriceStatus("BTC"); // "unwatched" | "hydrating" | "live" | "error" watch.stop(); // release (the last release ends this asset's subscriptions; the shared socket closes when no asset uses it) ``` **Batch variants** take/return arrays and are the ergonomic choice for a multi-asset ticker — one watch, one read: ```ts const handle = await client.watchPrices(["BTC", "ETH", "SOL"]); // snapshot + subscribe all; ref-counted const rows = client.getLivePrices(["BTC", "ETH", "SOL"]); // (LivePrice | null)[], index-aligned const status = client.getPriceStatus("BTC"); // "live" after snapshot/subscription setup handle.stop(); // release all in one call ``` Reads are synchronous, zero-round-trip, and current to the last pushed tick; subscribe to changes with `client.subscribePrices(listener)` (independent of the order-book `subscribeLive`). In React the hooks auto-watch while mounted: ```tsx import { useLivePrice, useLivePriceTicks, useWatchPrice } from "@somnia-chain/markets-sdk/react"; function Ticker() { const price = useLivePrice("BTC"); // updates the moment a tick lands const status = useWatchPrice("BTC"); // "hydrating" | "live" | "error" — render off this const ticks = useLivePriceTicks("BTC", 120); // for a sparkline if (status === "error") return price feed stopped; return {price ? `$${price.price.toLocaleString()}` : "…"}; } ``` ### The `"error"` state The status is `"error"` when the price-feed server rejected or terminated the asset's subscription. This happens on a validation failure after a schema change, or on a permission denial. The subscription will not heal by itself, so stop the watch and start a new one. Only the rejected asset enters `"error"`. Other assets on the shared socket keep streaming. A dropped connection is different: every asset on that socket becomes `stale` and heals on reconnect. A failed stream keeps its last values. If a tape fails while latest-only readers share the asset, the current-price stream continues. The default `getPriceStatus` and `getPriceHealth` report the tape error. Pass `{ ticks: false }` to either read for current-price state alone. `useWatchPrice` reads that state. With only default/direct readers, a rejection still stops both streams. Start a new tick reader to hydrate and subscribe the failed tape again. Each rejection is also emitted as a `warn` event on the client's debug channel with the server payload. ## Reading — one-shot No watch, one HTTP round-trip each — for history, charts, or a server render: ```ts const info = await client.fetchPriceFeedInfo("BTC"); // metadata + current price const price = await client.fetchPrice("BTC"); // LivePrice | null const history = await client.fetchPriceHistory("BTC", { limit: 500, from, to }); // ticks, newest first const candles = await client.fetchPriceCandles("BTC", "M1", { from, to }); // OHLC, oldest first ``` Candle resolutions are `"M1"` / `"H1"` / `"D1"` (60s / 3600s / 86400s). `count` on a candle is the number of oracle updates in the bucket (update density), **not** trade volume. Like every indexer read these **throw** on request failure — an empty result means "no rows", never "request failed". ## From the exchange The unified [`SomniaMarkets`](./exchange) surface exposes prices by asset (these don't need `loadMarkets` — a price feed isn't a symbol-keyed market): ```ts const px = await exchange.fetchPrice("BTC"); // { symbol, price, ema, timestamp, … } | null for await (const _ of forever) { const tick = await exchange.watchPrice("BTC"); // resolves on each new tick } const ohlcv = await exchange.fetchPriceOHLCV("BTC", "1m"); // [ms, o, h, l, c, count][] — "1m"/"1h"/"1d" ``` The native `LivePrice` (raw strings + block metadata) rides on each struct's `info`. ## Methodology Everything above is the read surface. This section is how the number itself is produced: exactly what is measured, what is discarded, how it is aggregated, how it is encoded, and what a consumer is guaranteed when it reads a cell. Every constant is the value in the shipped code (`impl v9`). **One-line version.** A weighted median across seven venue mids, quantized to nine significant figures, medianed again across three independent validators, packed with its own provenance into one 256-bit storage word — every second, paid for by the contract itself. | | | | -------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | Cadence | 1 s | | Scale | 18 decimals | | Sources | 7 centralised exchanges, top-of-book mid | | On-chain aggregation | 3-validator subcommittee, 2-of-3 threshold | | Networks | Somnia mainnet (5031) `0x1998C88D54a240b8C671493f27f58eC416b81D7F` · testnet (50312) `0x7ccAE31E45693475be1e4BAa2360D6997eB2D32B` | ```mermaid flowchart TB V["7 venues
top-of-book mid"] --> G["4 freshness gates
+ per-symbol quorum floor"] G --> M["weighted median
over the fresh set"] M --> Q["quantize
30-bit mantissa + 5-bit exponent"] Q --> B["48-bit slot per symbol
packed blob"] B --> C["3 validators submit
independently"] C --> M2["second median
per symbol, on-chain"] M2 --> E["EMA mark
alpha = 0.125"] E --> S["one 256-bit cell
spot + mark + provenance"] S --> R["consumer read
pull, never reverts"] ``` --- ### 01 — Source: seven venue mids, streamed A Go process — the **price-oracle agent** — connects to seven centralised exchanges through a pruned fork of `ccxt`. It does not fetch on demand. One goroutine per venue runs continuously, writing the latest quote for every tracked pair into an in-memory store; a price request is a read of that store, so it answers in about a millisecond with zero network I/O on the request path. The quantity collected is the **top-of-book mid**, `(bid + ask) / 2`, spot markets only. Four venues stream over WebSocket; three fall back to bulk REST polling on a 1-second ticker. | Venue | Weight | Transport | Why | | ------- | ------ | --------- | ---------------------------------------------------------- | | binance | 3 | ws stream | | | okx | 2 | ws stream | | | bybit | 2 | rest poll | `wss` endpoint geoblocked from the runners' datacentre IPs | | kucoin | 1 | ws stream | | | kraken | 1 | ws stream | | | gateio | 1 | rest poll | stream unimplemented in the fork | | mexc | 1 | rest poll | stream connects, never delivers | Total weight **11**. Weights are static configuration — **not** measured volume or depth. They are placeholders intended to be tuned against real per-venue liquidity. Measured source freshness: 70–220 ms on the streaming venues, 225 ms–3.3 s on REST. > `internal/exchange/ccxt.go`, `oracle.json` · 61 tracked pairs · > `POLL_INTERVAL` 1 s · `PER_CALL_TIMEOUT` 8 s · reconnect backoff doubles to > 30 s, jittered ### 02 — Admission: four gates, then a quorum floor Staleness is the first and strictest gate. A quote that sat in the cache longer than three seconds is not aggregated — it is not even reported as a number. | Gate | When | Rule | Effect | | ----------- | ----------- | ------------------------------------------------------------------- | ------------------------------------ | | Book sanity | ingest | both sides present, `bid > 0`, `ask > 0`, not crossed (`ask ≥ bid`) | quote discarded, never cached | | Spread | ingest | `(ask − bid) / mid ≤ 0.02` | quote discarded, never cached | | Dwell | aggregation | `now − UpdatedAt > STALE_AFTER (3 s)` | exclude, `excludedReason: "stale"` | | Never seen | aggregation | configured exchange has never returned this pair | exclude, `excludedReason: "no-cell"` | The first two run in `midPrices` as each venue's ticker batch is reduced to a mid, so a bad book never reaches the store at all — there is no exclusion reason for it, and a venue that only ever produces bad books is indistinguishable from one that has never returned the pair. `"stale"` and `"no-cell"` are the only two exclusion reasons that exist. - **The spread bound is loose on purpose.** Liquid USDT pairs run around 1 bp, so 2% only catches a book wide enough to be a manipulation surface. - **Dwell is measured on the agent's own ingest clock** (`now − UpdatedAt`), not the exchange's timestamp, so it is immune to venue clock skew. The comparison is strictly greater: exactly 3.000 s still counts. **Then a per-symbol quorum floor.** With fewer than `minSources` fresh venues the agent refuses to publish. The default floor is **3**, with a per-token override table (`SOMI/USDC: 1`, because it does not list on three of these venues). Below the floor the symbol's slot is written as **all zeroes** — not a small number, not a stale number, nothing. > `internal/store/store.go:136,198` · `internal/server/handlers.go:98–112, 406–437` · `maxMidSpreadFrac = 0.02` · `STALE_AFTER = 3s` ### 03 — Aggregation: a weighted median, not an average Sort the fresh quotes by price. Walk the weights. The first price whose cumulative weight crosses half the total is the index. One venue can be the whole answer. All seven venues fresh, total weight 11, half is 5.5: | Venue | Price | Weight | Cumulative | | ----------- | -------------- | ------ | -------------------------- | | kraken | 107,373.90 | 1 | 1 | | mexc | 107,374.05 | 1 | 2 | | okx | 107,374.18 | 2 | 4 | | **binance** | **107,374.21** | **3** | **7** ← first to cross 5.5 | | bybit | 107,374.44 | 2 | 9 | | kucoin | 107,374.60 | 1 | 10 | | gateio | 107,375.02 | 1 | 11 | The index is **107,374.21**. Drop `binance` (stale) and the total falls to 8, half is 4, and `bybit` at 107,374.44 becomes the index — a 23-cent step with no bad data anywhere. There is **no outlier or deviation rejection anywhere in the agent**. Robustness comes entirely from the choice of median over mean. ### 04 — Quantization: nine significant figures in six bytes The float becomes an 18-decimal integer, then is re-encoded as a 30-bit mantissa and a 5-bit exponent. Everything past the ninth digit was never real. Two lossy steps, in order: 1. **Scale-up.** The price is a `float64` the whole way through the agent, so multiplying by `10^18` in a `big.Float` pinned at a 53-bit mantissa re-rounds rather than shifting exactly. This is why BTC arrives on-chain looking like `63420.750000000000327680` — that tail is `float64` residue, not market data. 2. **Wire encoding.** Pick the smallest exponent whose mantissa still fits 30 bits, rounding half up. One symbol's wire slot is exactly 48 bits and exactly full: | Field | Bits | Range | Example — BTC at 107,374.21 | | ------------- | ---- | -------------- | ----------------------------------------- | | `mantissa` | 30 | `0 … 2^30 − 1` | `107374210` | | `exp` | 5 | `0 … 31` | `15`, so the value is `107374210 × 10^15` | | `sourceAgeMs` | 13 | `0 … 8191` | `120` | ### 05 — Transport: the packed blob The contract calls `getPricesSlots(string[],uint8)` and gets back a single `bytes` value of **exactly** `6 × nActive` bytes — no ABI element padding at all. The symbol at active position `p` lives at bytes `[6p, 6p+6)`, big-endian. Slots straddle 32-byte word boundaries by design. - 30 active symbols → blob length must equal **180 bytes**, exactly. - A **whole-zero slot** means "this validator did not price this symbol". - A **nonzero slot with a zero mantissa** is malformed and is dropped the same way. - **Any other length drops the validator entirely** — a stale cell, never a wrong price. ### 06 — Consensus: three validators, then a second median Nobody trusts one agent. A subcommittee of three runs the same code in three sandboxes against three separate caches, and the contract medians them again. Each elected subcommittee member detects the request, runs the agent in its own Docker sandbox, and submits its own blob on-chain. Because the agent answers from a continuously-updated cache, the three answers are legitimately different — which is why the platform finalises on **Threshold** consensus (2-of-3), not Majority, which would require identical result hashes. | Live parameter | Value | Consequence | | ------------------ | ------------- | --------------------------------------------------------------- | | `subcommitteeSize` | 3 | three independent agent runs per tick | | `threshold` | 2 | two contributors is the ordinary steady state; one is reachable | | `consensusType` | 1 (Threshold) | non-identical results are expected, not a fault | | `timeout` | 15 s | must exceed 10 s — the runner cancels at `deadline − 10s` | | `decimals` | 18 | the scale the agent is asked to pre-apply | **Four gates on the response, before a single cell moves.** | Rejection | Reason code | Why | | ---------------------------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | not in the inflight ring | `not-inflight` | ring lookup and consume, so a duplicate callback is idempotent | | `status ≠ Success` | `AgentRequestFailed` | no cells written | | `block.timestamp − kickedAt > 3 s` | `stale-response` | the platform's timeout upkeep can deliver a quorum-met partial as `Success` hours late; ingesting it would sawtooth the feed and poison the EMA | | `symbolsVersion` moved | `symbols-version-mismatch` | responses carry no symbol identity, only array position — if the symbol list changed since the kick, position `i` may be a different asset | Any symbol-list mutation invalidates every inflight request; the next tick refills everything within one interval. **The second median — per validator, per symbol.** For each symbol the fill loop does one `calldataload` per validator, gates it, and medians the survivors. The gate is **per-validator**, which is the whole point: one garbage validator drops out instead of shifting the median. | Symbol | Validator A | Validator B | Validator C | Cell written | | ------------- | ----------- | ------------ | ----------- | ---------------------------------- | | BTC/USDC | 107,374.18 | 107,374.21 | 107,374.30 | 107,374.21 · vc 3 | | ETH/USDC | 3,142.07 | slot = 0 | 3,142.11 | 3,142.11 · vc 2 | | SOL/USDC | slot = 0 | mantissa = 0 | 184.62 | 184.62 · vc 1 | | FARTCOIN/USDC | slot = 0 | slot = 0 | slot = 0 | untouched — omitted from the batch | Insertion sort, then `values[n / 2]` — the **upper** middle on even counts, which at the ordinary 2-of-3 threshold means the higher of the two prices. On a 2-validator tick the median is not a blend; it is a pick. `vc` is `validatorCount`, the surviving contributor count, saturating at 7, with 0 meaning _unknown_ — never "none". ### 07 — Mark price: the same number, smoothed Mark is **not a different data source**. It is an EMA over the spot series, computed from the previous cell, and it costs no extra storage. Each tick the mark moves `emaAlpha = 0.125e18` — one eighth — of the way from the previous mark toward the new spot. At the 1 s cadence that is a smoothing horizon of τ ≈ 8 s. **Response to a genuine move.** After `n` ticks the fraction of a step captured is `1 − 0.875^n`. The conventional time constant τ is where that crosses 63%, and it lands exactly where `interval / alpha` predicts — 8 ticks: | Ticks | 1 | 2 | 4 | 6 | 8 (= τ) | 16 | 24 | | -------- | ----- | ----- | ----- | ----- | ------- | ----- | ----- | | Captured | 12.5% | 23.4% | 41.4% | 55.1% | 65.6% | 88.2% | 95.9% | **Alpha is per tick, not per second.** τ ≈ 8 s here, but τ ≈ 80 s in the DEX's oracles — same `emaAlpha = 0.125e18`, copied from their production values, but their cadence is 10 s. Rescale alpha if the cadence changes and the smoothing horizon should stay put. `emaAlpha = 1e18` disables smoothing entirely — mark becomes spot. ### 08 — Storage: everything in one slot Spot, mark, timestamp, source age and provenance share a single 256-bit word. One cold `SSTORE` per symbol per tick, one `SLOAD` to read all of it. | Field | Bits | Encoding, and what 0 means | | ------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `price` | 86 | spot median at 18 dp. Ceiling ≈ 77 M per unit. | | `quality` | 14 | packed sub-fields, below | | `mark` | 86 | EMA mark; always between the previous mark and this spot | | _reserved_ | 14 | | | `updatedAtMs` | 42 | `block.timestamp × 1000` — second-granular, shared by every symbol written in the same tick. A `maxAgeMs` bound below the tick cadence is not meaningful. Good to year 2109. | | `sourceAgeMs` | 14 | a delta below `updatedAtMs`, not an absolute stamp — capped at 16.383 s. The top value is the sentinel and reads back as `0 = unknown`, never "current", so a real age clamps one below it. | The 14-bit `quality` field expands to: | Sub-field | Bits | Meaning | | -------------------- | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `validatorCount` | 3 | contributors to the median, saturating at 7. `0 = unknown` (a cell written before the field shipped), never "none" — a written cell always had ≥ 1. | | `medianControlCount` | 3 | reserved. Always reads `0`. No room on the wire. Do not branch on it. | | `flags` | 2 | bit 0 `FLAG_MARK_RESYNCED`; bit 1 `FLAG_DEVIATION` reserved and never set. | | _reserved_ | 6 | | Removing a symbol deletes its cell. **Indices are append-only and never reused**, so an on-chain consumer can resolve index → symbol once off `SymbolAdded` and read by index forever — one `SLOAD` cheaper than the string-keyed path, with no risk that a removal elsewhere silently shifts the mapping. > `contracts/feed/lib/PriceCell.sol` · `lib/FeedHealth.sol` · > `lib/ActiveIndexSet.sol` ### 09 — Consumption: pull, and decide staleness yourself Reads never revert. Staleness is the caller's decision, always — the feed will not decide on your behalf what is too old. | Interface | Keyed by | Gives you | | --------------------- | ---------------- | ---------------------------------------------------- | | `IPriceFeed` | symbol string | spot, cell age, source age, tracked-symbol registry | | `IMarkPriceFeed` | symbol string | EMA mark (separate so `IPriceFeed`'s id never moved) | | `IIndexedPriceFeed` | stable `uint256` | the same data, one `SLOAD` cheaper | | `IQualifiedPriceFeed` | either | quorum, flags, feed liveness, and the safe reads | **Off-chain: one batched event, and history.** Each tick emits a single `PricesUpdated(requestId, updatedAtMs, indices[], prices[], marks[], sourceUpdatedAtsMs[], resyncedBits)` — one event for the whole batch, carrying only stable indices. An Envio + Hasura stack indexes it into `Feed` (latest per symbol, a Chainlink-ish `latestRoundData` analogue), `PricePoint` (the per-tick firehose), `Candle` (OHLC at M1 / H1 / D1) and `Symbol` (the index → pair registry). That GraphQL surface is what the price methods above read. > **GraphQL is for humans, not for settlement.** Testnet only, and observability > only. It lags the chain, can be resynced, and is not part of the support > contract. Never settle, liquidate, or price anything off it. **End-to-end latency.** | Leg | Time | | ---------------------------------------------------- | ----------- | | 1 — exchange → agent | ~0.1 s (ws) | | 2 + 4 + 5 — cache dwell + kick→response + cell write | ~0.8 s | | 6 — wait for your next read | 0–~1 s | Measured on testnet over 150 consecutive BTC/USDC ticks: cell cadence median 1 s (max 2 s), kick→cell median ~0.8 s ≈ 7 blocks at ~116 ms. A consumer acts on a price 1–2 s old; at the instant of the cell write it is ~0.9 s. The dominant and most actionable leg is kick→cell. REST-only venues are the other lever — they inflate leg 1 and can leave source age unmeasurable entirely. --- ### Sources Contracts and scheduler: - `smart-contracts/contracts/feed/PriceFeedScheduler.sol` - `smart-contracts/contracts/feed/lib/PriceCell.sol` - `smart-contracts/contracts/feed/lib/SlotResponse.sol` - `smart-contracts/contracts/feed/lib/FeedHealth.sol` - `smart-contracts/docs/price-feed-scheduler.md`, `price-feed-latency.md`, `price-feed-design-decisions.md` - `smart-contracts/script/config/feed-testnet-development.json` - `docs/price-feed-integration.md` - `price-feed/indexer/schema.graphql` Agent: - `somnia-agents/agents/price-oracle/internal/store/store.go` - `somnia-agents/agents/price-oracle/internal/server/handlers.go` - `somnia-agents/agents/price-oracle/internal/exchange/ccxt.go` - `somnia-agents/agents/price-oracle/oracle.json`, `agent.json` ## Delivery state and acquisition errors The price service is separate from the main indexer. Its results do not carry main-indexer watermarks. `hydrated` means the HTTP snapshot completed. `streaming` requires valid initial results from every acquired stream. Current-only health requires only current-price delivery. Empty arrays confirm delivery without creating a price. A connection acknowledgement alone does not establish streaming. `stale` means known delivery loss. It does not infer an expected oracle tick frequency. The asset retains its last values while reconnecting, and every acquired stream must deliver again before `streaming` returns. `error` means hydration failed or a subscription was rejected. Each asset reports its own state, so a healthy sibling cannot conceal a stale one. Existing `getPriceStatus` and `useWatchPrice` keep `unwatched | hydrating | live | error`. The legacy `live` state means snapshot/subscription setup succeeded; it does not confirm operation delivery. A disconnect does not change that legacy state. Opt into `client.getPriceHealth(asset)` or `client.listPriceHealth()` for the precise states above. They share the existing price watches and `subscribePrices` notifications. No extra socket opens. Automatic hooks continue reporting acquisition failures through the debug/console warning channel. Use `useWatchMarketResult`, `useWatchUserResult`, or `useWatchPriceResult` for typed inline acquisition UI. Effects ignore superseded failures and release handles acquired after unmount. --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/chains Every Somnia network as a viem `Chain`, from one import — so nothing has to hand-roll a `defineChain({...})` or reach for `viem/chains` again. ```ts import { somniaShannon } from "@somnia-chain/markets-sdk/chains"; ``` `chain` is a required field of every exchange/client config (it signs writes and sizes viem's waiting heuristics), and `wsRpcUrl` is the transport the live tail runs on — both come straight off these objects. Moving tokens _between_ these networks is the [bridge](./bridge) — same module, same import. ## The networks | Export | Chain id | Native | Blocks | Multicall3 | Explorer | | --------------- | -------- | ------ | --------- | ---------- | -------------------------------------------------------------------------- | | `somniaMainnet` | `5031` | SOMI | 100 ms | ✅ | [explorer.somnia.network](https://explorer.somnia.network) | | `somniaShannon` | `50312` | STT | 100 ms | ✅ | [shannon-explorer.somnia.network](https://shannon-explorer.somnia.network) | | `somniaElwood` | `50313` | STT | 100 ms | — | — | | `hidekiTestnet` | `50383` | STT | **10 ms** | ✅ | — | | `somniaLocal` | `31337` | STT | — | — | — | - **Shannon** (`somniaShannon`) is the public testnet and the default target for development. `https://dream-rpc.somnia.network` is an alias for the same network and is listed as a secondary endpoint. - **Elwood** is the testnet's regenesis: its own cluster, DNS zone and genesis, otherwise identical to Shannon. - **Hideki** is the low-latency Tokyo network — ~100 blocks/s. If you are measuring latency, measure here. - **`somniaLocal`** is the anvil stack (`demo-clob.sh up`, `DEPLOY_ENV=local`). It deliberately presents as _Somnia_/_STT_ rather than _Foundry_/_Ether_: the agent-facing docs read the chain name and native symbol out of this object, and a local stack should still read as Somnia there. Every definition carries **both** transports — `rpcUrls.default.http` and `rpcUrls.default.webSocket` — so an exchange can be built from the chain alone: ```ts import { SomniaMarkets } from "@somnia-chain/markets-sdk"; import { hidekiTestnet as chain } from "@somnia-chain/markets-sdk/chains"; const exchange = new SomniaMarkets({ chain, wsRpcUrl: chain.rpcUrls.default.webSocket[0], indexerUrl: "/v1/graphql", }); ``` ## Absences are deliberate Elwood and the local chain carry **no `contracts.multicall3`** and no `blockExplorers` — nothing is deployed there to point at. That is not an oversight to be filled in with the canonical `0xcA11bde05977b3631167028862bE2a173976CA11`: viem routes batched reads at whatever address it finds, so a Multicall3 entry with no code behind it turns every batched read into a failure. An absent field just makes viem fall back to individual `eth_call`s. Mainnet, Shannon and Hideki each have their **own** Multicall3 deployment at _different_ addresses (`0x5e44…5a11`, `0x841b…4223` and `0x540B…D131`) — don't copy one to another. Hideki's (deployed 2026-08-10 by the bridge infra) is the canonical runtime bytes at a **non-canonical** address: the presigned "Nick's method" deployment can never land on Somnia chains — contract creation costs ~4,928 gas per deployed byte here, far over that transaction's fixed 500k limit — and its nonce-0 deployer has already been burned on these networks, so `0xcA11bde0…` is unreachable for good. The values here — ids, cadence, Multicall3 addresses — were verified against the live networks (`eth_chainId`, block-timestamp deltas over 10k blocks, and a real `getBlockNumber()` call on each Multicall3 candidate), not copied from a chain list. ## Resolving a chain id you were handed Deployment manifests (and `NEXT_PUBLIC_CHAIN_ID`-style env) carry a plain `number`. `getSomniaChain` bridges that to a `Chain`, returning `null` when the id isn't one of ours: ```ts import { getSomniaChain, defineChain } from "@somnia-chain/markets-sdk/chains"; const chain = getSomniaChain(deployment.chainId) ?? defineChain({ id: deployment.chainId, name: "Somnia", nativeCurrency: { name: "STT", symbol: "STT", decimals: 18 }, rpcUrls: { default: { http: [rpcUrl] } }, }); ``` `somniaChains` is the same table, keyed by id — iterate it to build a network switcher. `isSomniaChainId(id)` is the type guard that narrows a `number` to `ChainId`, enough to index it without a cast. When the id flows the other way — your code names the network — **`ChainId`** is the constant to reach for: `ChainId.somniaShannon` is `50312`, keyed by the same export names as the definitions (`ChainId.somniaShannon === somniaShannon.id` by construction). ## Extending one `defineChain` and the `Chain` type are re-exported here, so overriding an endpoint (a private node, a fork) needs no second import: ```ts import { defineChain, somniaShannon } from "@somnia-chain/markets-sdk/chains"; const forked = defineChain({ ...somniaShannon, id: 31_337, name: "Somnia Testnet (forked)", rpcUrls: { default: { http: ["http://127.0.0.1:8545"], webSocket: ["ws://127.0.0.1:8545"] } }, }); ``` ## Relationship to `viem/chains` viem ships two of these networks as `somnia` and `somniaTestnet`; our `somniaMainnet` and `somniaShannon` carry the same content under the names the runbooks use (a test asserts field-for-field parity), and the module adds the three networks viem cannot ship — Elwood, Hideki and the local stack are ours, on our cadence. One import covers every target, and a new network lands when we bring it up rather than when viem next cuts a release. Each definition lives in its own file under `src/chains/definitions/`, written the way viem writes its own, so the two stay diffable. `src/chains/bridge/` sits alongside them with the warp-route registry — see the [bridge guide](./bridge). --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/bridge Moving tokens between Somnia networks. A specialised chain is only useful if value can reach it — Hideki is a ~10 ms-block network built for traders, and this is how balances get there from the general-purpose testnet. ```ts import { BridgeToken, ChainId, createBridgeTransfer, sendBridgeStep } from "@somnia-chain/markets-sdk/chains"; ``` It lives in the [chains](./chains) module because that is what it is: verified static addresses, alongside the chain definitions and their Multicall3 entries. The registry and `createBridgeTransfer` are **pure** — no RPC, no client, no signer: `createBridgeTransfer` returns transaction data and you send it. `sendBridgeStep` is the one opt-in sender. > ⚠️ **This is a dev/test bridge.** One validator, a threshold-1 ISM, EOA owners — > one key is the entire bridge. `SOMNIA_BRIDGE.status` is `"dev-test"` and it means > it: **do not put real funds behind it.** ## What's live One lane — `somniatestnet-hideki2testnet` — with five routes. [Full deployment record](https://github.com/somnia-chain/hyperlane-bridge-infra/blob/main/docs/deployments/somnia-testnet-hideki2-testnet.md): | Token | Somnia Testnet (50312) | Hideki2 Testnet (50383) | Decimals | | ------- | ---------------------- | ----------------------- | -------- | | `STT` | native | native | 18 | | `USDso` | collateral | synthetic | 18 | | `WBTC` | collateral | synthetic | **8** | | `WETH` | collateral | synthetic | 18 | | `HBTT` | collateral | synthetic | 18 | ### Which Hideki this is This section explains which chain the lane above reaches. Read it before you reconcile any address against the bridge-infra repo. `ChainId.hidekiTestnet` (50383) is **Hideki 2**. Hideki 2 is the 2026-09-17 regenesis of Hideki. The lane above is Hideki 2's lane. Hideki 1 still runs. It has its own lane. This SDK does not ship that lane. Both chains use chain id 50383. Both use Hyperlane domain 50383. They differ only in network id: 50000258 for Hideki 1, and 50000259 for Hideki 2. No signature, wallet or Hyperlane message tells them apart. Each lane has its own core contracts on Somnia Testnet. Each warp router binds to one mailbox when it is deployed. This binding keeps the two lanes apart. Two consequences follow. - **Every Hideki-side address on this lane equals Hideki 1's.** The same deployer replayed its nonce sequence onto Hideki 2. That chain restarted its nonces at genesis. A matching Hideki address therefore does not identify the lane. - **Each Somnia-side address belongs to one lane only.** These addresses identify the lane. In the bridge-infra repo, this lane is `registry-hideki2/`. It is not `registry/`. Mainnet, Elwood and the local chain are **not** bridged — `getBridgeNetwork` returns `null` for them, and `createBridgeTransfer` throws rather than inventing a route. Bridging is also **not transitive**: a chain has to be a member of the token's own route. ## The enumerations ```ts BridgeToken.WBTC; // "WBTC" — the bridge's own enum ChainId.hidekiTestnet; // 50383 — from the chains module: the chain id IS the value ``` Both are `const` objects with a matching type, not TS `enum`s — the pattern the rest of this SDK uses. You get `BridgeToken.WBTC` for the value and `BridgeToken` for the type, plain literals still assign, and nothing non-erasable is emitted. Networks are named by **`ChainId`**, which lives with the [chain definitions](./chains) rather than here — every Somnia network has a chain id; only some are bridged. Its keys are the definition export names, so `ChainId.hidekiTestnet === hidekiTestnet.id` by construction, and the id is the value because that is what wallets, manifests and viem speak — on this bridge the Hyperlane **domain id equals the chain id**, so one number identifies a network everywhere. ## Bridging ```ts const plan = createBridgeTransfer({ token: BridgeToken.WBTC, from: ChainId.somniaShannon, to: ChainId.hidekiTestnet, amount: 100_000_000n, // 1 WBTC — 8 decimals, not 18 recipient: account.address, }); if (plan.approveStep) await sendBridgeStep(client, plan.approveStep, { account }); const receipt = await sendBridgeStep(client, plan.bridgeStep, { account }); ``` Two fields are the whole plan: **`bridgeStep`** — the `transferRemote` call, always present — and **`approveStep`**, the ERC-20 approval a collateral route needs first, simply **absent** on native and synthetic routes. Branching on `plan.approveStep` narrows it, so no `!` is ever needed; its role is the field name, and `description` is the human label. **`sendBridgeStep(client, step, { account })`** is the sender: it signs locally (fixed fees, fixed gas ceiling, never estimated) and broadcasts via Somnia's `eth_sendRawTransactionSync`, which blocks server-side and returns the **receipt in the same call** — send + confirm in one round-trip, so the approve-then-bridge ordering above is safe by construction. It throws on a reverted receipt, and on a node without the method (anvil, stock geth) it falls back to `eth_sendRawTransaction` + a receipt wait. It needs a **local** signer (viem `privateKeyToAccount`, a session account); with a browser wallet, spread the step into `walletClient.sendTransaction({ ...plan.bridgeStep, account })` instead — extensions don't sign raw transactions. One consequence of fixed ceilings worth knowing: Somnia's mempool admits a transaction only when the balance covers `gas × maxFeePerGas` (0.6 STT at the defaults) on top of its `value`, even though unused gas is never charged — fund small accounts for the envelope, not the expected fee. Each transaction is `{ chainId, to, data, value, description }` — everything you must send, nothing you must fill in. No `gas`, no `nonce`, no fees: those belong to the signer, and a builder guessing gas for a contract on another chain would be inventing numbers. `value` **is** ours, because it is protocol-semantic: | Route model | `value` | Approval | | ------------ | ---------------------------------------------- | ------------------------------------ | | `native` | **the amount** — the coin rides in `msg.value` | none | | `collateral` | `0n` | ERC-20 `approve` to the router | | `synthetic` | `0n` | none — the router burns your balance | Amounts are **bigint base units of the origin side's decimals**. Read them from `plan.origin.decimals` rather than assuming 18 — WBTC is 8 on both sides. **Browser wallets and JSON boundaries.** A viem wallet client takes a step as-is — including one on `custom(window.ethereum)`. For the raw `eth_sendTransaction` path, or to move a server-built plan across a JSON boundary (a step's `value` is a `bigint`, and `JSON.stringify` throws on bigints), `toEip1193Transaction(step, { from? })` returns the hex-quantified, JSON-safe request object — typed as viem's own `RpcTransactionRequest`. It deliberately drops `chainId`: an EIP-1193 wallet signs on its **active** chain, so switch with `wallet_switchEthereumChain` first. The [walkthrough](./chains-walkthrough) shows both paths end to end. ## Bridging through the exchange A multi-chain `SomniaMarkets` exposes the same planner as `exchange.bridge`, bound to its own chains and executors. Both ends must be configured chains, the origin legs go through the origin chain's executor (a [smart account](./accounts) packs the approval and the bridge call into one transaction, funded from the account itself), and delivery is observed on the destination engine's socket rather than polled. ```ts const plan = exchange.bridge.plan({ token: "USDso", from: L1, to: HIDEKI, amount: 100n * 10n ** 18n }); const result = await exchange.bridge.transfer( { token: "USDso", from: L1, to: HIDEKI, amount: 100n * 10n ** 18n }, { onStep: (s) => console.log(s.kind, s.status, s.hash) }, ); result.messageId; // the Hyperlane message id, from the origin receipt result.delivered; // true once the destination Mailbox processed that message ``` The SDK tracks one Hyperlane message per transfer. It reads the message id from the origin receipt: the origin Mailbox's `Dispatch` whose sender is the origin router, paired with the `DispatchId` after it. It then waits for the destination Mailbox's `ProcessId` for that id only. A delivery to the same recipient from another transfer does not end the wait. The wait first asks the destination Mailbox `delivered(messageId)`, so a message that landed before the wait started still counts. The wait times out 180 s after the origin transaction is mined by default (`deliveryTimeoutMs`). On a timeout, `exchange.bridge.transfer` resolves with `delivered: false` and the `messageId`. The transfer is not undone and can still land. Before you send again, call `delivered(messageId)` on the destination chain's Mailbox (`getBridgeNetwork(to).mailbox`). It is a view, `function delivered(bytes32 messageId) view returns (bool)`, that you can read with the engine's `getViemClient()`. A second transfer while the first is in flight moves the funds twice. A few `RpcError` operations also mean the origin leg was sent and the funds may be in flight: `bridge message id` (the receipt holds no single dispatch to decode) and `watch bridge delivery` (the destination watch failed). Look up the origin transaction before you retry. `exchange.ensureFunds({ code, chainId, amount })` builds on it: check the account's balance of a currency on one chain and bridge the shortfall from the richest other configured chain. `createOrder(…, { funding: "auto" })` bridges the same way for the order's collateral token. By default an order whose collateral is elsewhere throws `CollateralOnWrongChainError` carrying the plan instead. The route follows the collateral token's address in the bridge registry, not its symbol. A token the registry does not list gets no plan, even when its symbol is `USDso`. When the bridged message is not delivered in time, `ensureFunds` and `createOrder(…, { funding: "auto" })` throw `BridgeDeliveryTimeoutError` with `messageId`, `bridgeHash`, `from`, `to`, `token` and `amount`, and place no order. On one exchange, these calls run one at a time per account, chain and currency. The exchange keeps a leg that timed out as pending. When the next `ensureFunds` or auto-funded order for the same key finds a shortfall, it waits for that message first and sends no new leg for it. The pending leg lives in memory only. A new exchange instance, or another process, must check `delivered(messageId)` before it bridges again. A direct `exchange.bridge.transfer`, and an order with `funding: "explicit"` or `"skip"`, does not join this queue and does not check pending legs. ## Two failure modes worth preflighting **Native-route liquidity.** An `STT` transfer is native on both sides, so delivery is paid out of the _destination router's own balance_ rather than by minting. If that balance is short, `process()` reverts and the transfer sits in escrow until someone refunds the router — not lost, but stuck. The destination side's `requiresDestinationLiquidity` flags it. `exchange.bridge.transfer`, `exchange.ensureFunds`, and `createOrder(…, { funding: "auto" })` read the destination router's balance before they send. When it is below the amount, they throw `BridgeLiquidityError` and send nothing. The check is a read, not a reservation: another transfer can still drain the router before delivery. When you send the planner's transactions yourself, check the balance before you offer the transfer: ```ts if (plan.destination.requiresDestinationLiquidity) { const liquidity = await destinationClient.getBalance({ address: plan.destination.router }); if (liquidity < plan.amount) throw new Error("destination router is short — transfer would stall"); } ``` At the last verification the Somnia-side router held **0 STT** and Hideki's held 100, so a _first_ Hideki → Somnia transfer stalls until seeded. Seeding is a plain native transfer to the router. **The relayer is a liveness dependency.** No InterchainGasPaymaster is deployed, so `quoteGasPayment` is 0 on every router and the relayer pays delivery gas unmetered (`SOMNIA_BRIDGE.relayerPaysDestinationGas`). A sender attaches nothing for delivery — but if the relayer runs dry, transfers keep being accepted on the origin and stop being delivered. Should a gas quote ever be introduced, read it and pass it as `gasPayment`; it lands in `bridgeStep.value`. Delivery is asynchronous either way: `transferRemote` escrows and dispatches, and the far side is credited seconds later. The transaction you send does not wait for it. On the Hideki 2 lane, a 9-leg measurement on 2026-09-17 gave a median of 7.5 s in one direction and 8.7 s in the other. Most legs included a 5.1 s relayer retry. A single leg on 2026-09-23 took 3.0 s. The SDK's 180 s default delivery timeout is about 20 times the median. **A drained router shows up as a timeout.** The SDK refuses a native transfer when the destination router holds less than the amount. The check is a read, so another transfer can still drain the router before delivery. Then `process()` reverts on the destination, the message stays undelivered, and the wait ends with `delivered: false` (or `BridgeDeliveryTimeoutError` from `ensureFunds`). The message is delivered after someone seeds the router and the relayer retries. Check `delivered(messageId)` instead of sending again. ## Looking things up ```ts getBridgeToken(BridgeToken.USDso, ChainId.hidekiTestnet); // one side of one route, or null listBridgeTokens(ChainId.somniaShannon); // everything bridgeable from a network getBridgeNetwork(ChainId.somniaShannon); // mailbox / ISM / hooks, or null listBridgeNetworks(); // the bridged networks getBridgeRoute(BridgeToken.WBTC, 50312, 50383); // the lane, either order, or null getBridgeRouter(BridgeToken.STT, ChainId.somniaShannon); // just the router address SOMNIA_BRIDGE; // the lane: routes, trust model, docs ``` Lookups return `null` on a miss (the `get*` contract: a `get*` read answers with `null` for a real absence); `createBridgeTransfer` **throws**, because an unsupported triple is a programming error and its message names what would work. `warpRouterAbi` is exported too, for reading a router directly — `quoteGasPayment`, `routers`, `destinationGas`, `token`. ## How the values got here Every address, decimal and model in the registry was read off the **live chains**, not transcribed: all ten routers have code, are owned by one account, are wired to their chain's mailbox and ISM, and are mutually enrolled in both directions; the collateral routers' `token()` matches the canonical ERC-20; the synthetics' `decimals()` match; `quoteGasPayment` is exactly 0 everywhere. The `transferRemote` encoding was _simulated_ with `eth_call` — `value = amount` on the native route returns a real message id, while dropping the value, skipping the approval, or naming an unenrolled destination each revert. `test/bridge.test.ts` pins the calldata offline. `test/bridge.e2e.test.ts` re-runs the whole verification against both chains on demand — nothing signed, nothing sent: ```sh SOMNIA_E2E_BRIDGE=1 pnpm test ``` A note on one client-side trap the registry exists to avoid: a **native** router answers `token()` with the _zero address_ rather than reverting, so code that probes `token()` to decide "collateral or native" silently gets `0x0…0`. Use `plan.origin.model` / `BridgeTokenDetails.model` instead. ## Adding a token or a lane Both are data. `hyperlane-bridge-infra` is the source of truth — a new route there becomes a new entry in `src/chains/bridge/registry.ts`, and the types already carry it: `destinations` is a list, `SOMNIA_BRIDGE.routes` is a list, and every lookup is a filter. Adding a route needs no agent restart and no new core contracts; the validator and relayer are token-agnostic. --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/chains-walkthrough A hands-on tour of `@somnia-chain/markets-sdk/chains`, end to end: pick a network, stand up clients, send a transaction, bridge STT both ways, fund a Hideki session account from a Shannon wallet, and run the live test suite that proves all of it against the real networks. The reference guides are [CHAINS.md](./chains) (the network definitions) and [BRIDGE.md](./bridge) (the warp-route registry and its trust model) — this page is the "follow along in a terminal" version. **You need:** Node + pnpm, `viem` (a peer dependency of the SDK), and an account holding STT on the networks you target. Shannon STT comes from the Somnia testnet faucet or a funded team key; Hideki has no public faucet — use a funded key. Everything below runs on the two bridged testnets and costs fractions of a (test) token. ```ts import { somniaShannon, hidekiTestnet } from "@somnia-chain/markets-sdk/chains"; ``` The module is **pure data + pure functions** — importing it performs no RPC. The networks it ships: | Export | Chain id | Blocks | Bridged | | --------------- | -------- | --------- | ------- | | `somniaMainnet` | `5031` | 100 ms | — | | `somniaShannon` | `50312` | 100 ms | ✅ | | `somniaElwood` | `50313` | 100 ms | — | | `hidekiTestnet` | `50383` | **10 ms** | ✅ | | `somniaLocal` | `31337` | — | — | This walkthrough sticks to the two bridged testnets: **Shannon** (`50312`, the general-purpose testnet) and **Hideki** (`50383`, the low-latency Tokyo network). ## 1. Create a client for different chains Every definition is a complete viem `Chain` — endpoints (HTTP _and_ WebSocket), block cadence, and Multicall3 where one actually exists. So a client is one call, and `http()` with no URL already means "the chain's default endpoint": ```ts import { createPublicClient, http, webSocket } from "viem"; import { somniaShannon, hidekiTestnet } from "@somnia-chain/markets-sdk/chains"; const shannon = createPublicClient({ chain: somniaShannon, transport: http() }); const hideki = createPublicClient({ chain: hidekiTestnet, transport: http() }); await shannon.getChainId(); // 50312 await hideki.getChainId(); // 50383 // The live tail and anything latency-sensitive should ride the WebSocket: const hidekiWs = createPublicClient({ chain: hidekiTestnet, transport: webSocket(), // hidekiTestnet.rpcUrls.default.webSocket[0] }); ``` When the chain id arrives as a plain `number` (a deployment manifest, a `NEXT_PUBLIC_CHAIN_ID` env), resolve it instead of switching on magic numbers: ```ts import { getSomniaChain, isSomniaChainId, somniaChains } from "@somnia-chain/markets-sdk/chains"; const chain = getSomniaChain(Number(process.env.CHAIN_ID)); // Chain | null if (!chain) throw new Error("not a Somnia network"); // somniaChains is the same table keyed by id — iterate it for a network switcher. // isSomniaChainId(id) narrows a number so somniaChains[id] indexes without a cast. ``` Two behaviors to know, both deliberate (details in [CHAINS.md](./chains)): - **Every bridged network batches, at its own address.** Shannon, Hideki and mainnet each carry a `contracts.multicall3` — three _different_ addresses (Hideki's arrived 2026-08-10, at a non-canonical address, because the canonical `0xcA11bde0…` deployment can never land on Somnia chains). Elwood and the local chain have none, and viem silently falls back to individual `eth_call`s there. Don't "fix" an absence by pasting the canonical multicall address into an extended definition — there is no code at it, and viem would route every batched read into a failure. - **`blockTime` drives viem's waiting heuristics.** Hideki's `10` means `waitForTransactionReceipt` polls fast enough to return in tens of milliseconds; a hand-rolled `defineChain` without it would wait seconds. ## 2. Post a transaction A wallet client from a private key, a send, a receipt — the only chains-module-specific part is that the `Chain` object is the one import: ```ts import { createWalletClient, http, parseEther } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { somniaShannon } from "@somnia-chain/markets-sdk/chains"; const account = privateKeyToAccount(process.env.SOMNIA_TESTNET_PK as `0x${string}`); const wallet = createWalletClient({ account, chain: somniaShannon, transport: http() }); const hash = await wallet.sendTransaction({ to: "0x0000000000000000000000000000000000000001", value: parseEther("0.001"), }); const receipt = await shannon.waitForTransactionReceipt({ hash }); receipt.status; // "success" ``` The same code targets Hideki by swapping the chain and the key. Gas, nonce and fees are viem's job — the chain definitions carry everything viem needs to estimate them correctly per network. ## 3. Bridge STT both ways The bridge is the same import: a Hyperlane warp lane between Shannon and Hideki carrying `STT`, `USDso`, `WBTC`, `WETH`, `HBTT`. [`createBridgeTransfer`](./bridge#bridging) is pure — it returns the unsigned transactions; you sign and send them. > ⚠️ It is a **dev/test** bridge — one validator, threshold-1 ISM. Testnet > value only. Shannon → Hideki, delivered and verified: ```ts import { BridgeToken, ChainId, createBridgeTransfer, sendBridgeStep } from "@somnia-chain/markets-sdk/chains"; import { parseEther } from "viem"; const amount = parseEther("0.05"); const plan = createBridgeTransfer({ token: BridgeToken.STT, from: ChainId.somniaShannon, // 50312 — the values ARE chain ids to: ChainId.hidekiTestnet, // 50383 amount, recipient: account.address, }); // STT is native on both sides → no approval, the amount rides in msg.value. // A collateral route (USDso/WBTC/WETH/HBTT leaving Shannon) needs an ERC-20 // approval first — plan.approveStep is present exactly when that's the case. // // sendBridgeStep signs locally (fixed fees, no estimation) and broadcasts via // eth_sendRawTransactionSync: the node blocks until the transaction // is executed and answers with the RECEIPT — send + confirm in one round-trip, // so each line below resolves confirmed. (No wallet client needed: the public // client is the wire, the account is the signer.) if (plan.approveStep) await sendBridgeStep(shannon, plan.approveStep, { account }); const receipt = await sendBridgeStep(shannon, plan.bridgeStep, { account }); receipt.status; // "success" — a reverted receipt throws instead ``` That receipt only proves **escrow + dispatch** on the origin. Delivery is asynchronous — the relayer credits the far side seconds later (on the Hideki 2 lane, a median of 7.5 s to 8.7 s per leg, measured 2026-09-17). Watch the destination: ```ts async function waitForDelivery(client, address, floor, timeoutMs = 180_000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const balance = await client.getBalance({ address }); // ERC-20 routes: readContract balanceOf if (balance >= floor) return balance; await new Promise((r) => setTimeout(r, 2_000)); } throw new Error("not delivered — is the relayer alive? is the destination router funded?"); } const before = await hideki.getBalance({ address: account.address }); // ... send the plan (previous snippet) ... await waitForDelivery(hideki, account.address, before + amount); ``` The reverse direction is the same call with `from`/`to` swapped and a Hideki-funded wallet — but preflight it, because **native-route delivery pays out of the destination router's own balance**: ```ts const back = createBridgeTransfer({ token: BridgeToken.STT, from: ChainId.hidekiTestnet, to: ChainId.somniaShannon, amount, recipient: account.address, }); if (back.destination.requiresDestinationLiquidity) { const liquidity = await shannon.getBalance({ address: back.destination.router }); if (liquidity < back.amount) { // Underfunded ≠ lost: the transfer would sit in escrow until the router is // seeded. Seeding is a plain native transfer to the router address — and // note the symmetry: every transfer OUT of a chain escrows on that chain's // router, which is exactly the balance a later reverse transfer draws on. throw new Error(`Shannon STT router holds ${liquidity} wei — seed it first`); } } ``` At the last live run the Shannon-side router held **0 STT**, so a first Hideki → Shannon transfer stalls until the router is seeded (or until an equal amount has been bridged Shannon → Hideki, which self-seeds it). Hideki's router held 100 STT. Mind the units: amounts are **bigint base units of the origin side's decimals** — read `plan.origin.decimals`, don't assume 18 (WBTC is 8 on both sides). Lookups (`getBridgeToken`, `getBridgeRoute`, …) return `null` on a miss; `createBridgeTransfer` **throws** on an unsupported triple, naming what would work. ### Signing in the user's browser Everything so far signed with a private key in Node. In a dapp the key lives in the user's wallet extension, and the order reverses: **connect first** — the sender (and usually the recipient) is the wallet's address, which you only know once it answers — _then_ build the plan, _then_ let the user confirm each step. Two distinct moments, worth keeping apart: 1. **The transaction is created** by `createBridgeTransfer` — a pure function that returns unsigned calldata. No RPC happens, nothing is sent, and there is nothing secret in the result. 2. **The transaction is signed** when a step is handed to the wallet — one prompt per step — and only then does anything reach the chain. The whole flow, self-contained: ```ts import { createPublicClient, createWalletClient, custom, http, parseEther } from "viem"; import { BridgeToken, ChainId, createBridgeTransfer, somniaShannon } from "@somnia-chain/markets-sdk/chains"; // 1. Connect — this is where the user's address comes from. const browser = createWalletClient({ chain: somniaShannon, transport: custom(window.ethereum) }); const [account] = await browser.requestAddresses(); // 2. Create the transaction(s): pure, unsigned, not yet sent anywhere. const plan = createBridgeTransfer({ token: BridgeToken.STT, from: ChainId.somniaShannon, to: ChainId.hidekiTestnet, amount: parseEther("0.05"), recipient: account, // the user's own address on the destination chain }); // 3. Sign + send — one wallet prompt per transaction; the wallet fills // gas/nonce/fees. The approval must land before the bridge call spends it. // (The sync path from §3 doesn't apply here: eth_sendRawTransactionSync // needs a locally-held key, and an extension won't sign raw transactions — // the wallet's own eth_sendTransaction is the browser's send path.) const shannon = createPublicClient({ chain: somniaShannon, transport: http() }); if (plan.approveStep) { const hash = await browser.sendTransaction({ ...plan.approveStep, account }); await shannon.waitForTransactionReceipt({ hash }); } const hash = await browser.sendTransaction({ ...plan.bridgeStep, account }); await shannon.waitForTransactionReceipt({ hash }); ``` Because step 2 is pure it can also run **server-side** (an API route, a server action): the browser sends the connected address up, the server builds the plan and returns the transactions. A `BridgeStep` doesn't cross that boundary as-is — its `value` is a `bigint`, and `JSON.stringify` throws on bigints — `toEip1193Transaction` is the JSON representation: the exact object `eth_sendTransaction` takes, every quantity hex-encoded. ```ts // On the server — build the plan for the address the browser sent: import { ChainId, createBridgeTransfer, toEip1193Transaction } from "@somnia-chain/markets-sdk/chains"; const plan = createBridgeTransfer({ token: BridgeToken.STT, from: ChainId.somniaShannon, to: ChainId.hidekiTestnet, amount: parseEther("0.05"), recipient: userAddress, }); return Response.json({ chainId: plan.bridgeStep.chainId, // the ORIGIN chain the wallet must be on approve: plan.approveStep && toEip1193Transaction(plan.approveStep, { from: userAddress }), bridge: toEip1193Transaction(plan.bridgeStep, { from: userAddress }), }); // bridge: {"from":"0x…","to":"0xB043…aB35","data":"0x81b4e8b4…","value":"0xb1a2bc2ec50000"} ``` ```ts // In the browser — no SDK import needed, the transactions are ready-made requests: import { numberToHex } from "viem"; const { chainId, approve, bridge } = await (await fetch("/api/bridge-plan")).json(); // ⚠️ eth_sendTransaction has NO chain field — an EIP-1193 wallet signs on its // ACTIVE chain. Switch to the origin chain first, or the transfer goes out on // whatever network the wallet happens to be on: await window.ethereum.request({ method: "wallet_switchEthereumChain", params: [{ chainId: numberToHex(chainId) }], }); if (approve) { // wait for this hash (public client / wallet UI) before submitting the bridge call await window.ethereum.request({ method: "eth_sendTransaction", params: [approve] }); } const hash = await window.ethereum.request({ method: "eth_sendTransaction", params: [bridge] }); ``` `toEip1193Transaction` deliberately drops `chainId` (see the warning above) and the `kind` / `description` tags (UI metadata — some wallets reject unknown keys), and pins `from` only when you pass it. Gas, nonce and fees stay with the wallet, same as everywhere else in this module. ## 4. Worked example: a Hideki session funded from Shannon Everything above, composed into the flow a trading UI actually runs: the user's wallet lives on Shannon, a disposable **session account** trades on Hideki, and the bridge is what moves gas and working capital between them. (Session accounts come from the [native module](./native) — a 32-byte seed the node can sign for, whose derivation is public, so the address is computable locally before any RPC. **The seed is a private key in another shape: store it like one.**) ```ts import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; import { sessionAddress, sessionPrivateKey } from "@somnia-chain/markets-sdk/native"; const seed = generatePrivateKey(); // any 32 random bytes, persisted like a secret const session = privateKeyToAccount(sessionPrivateKey(seed)); session.address === sessionAddress(seed); // true — the node derives the same address // The user's wallet signs on Shannon; the session signs on Hideki. const userAccount = privateKeyToAccount(process.env.SOMNIA_TESTNET_PK as `0x${string}`); const userWallet = createWalletClient({ account: userAccount, chain: somniaShannon, transport: http() }); const sessionWallet = createWalletClient({ account: session, chain: hidekiTestnet, transport: http() }); ``` **Fund it.** Two transfers out of the user's Shannon wallet: STT so the session can pay Hideki gas, and HBTT as working capital. HBTT is Hyperlane's open test token — anyone can `mint(address,uint256)` on Shannon, which is what makes this walkthrough self-service: ```ts import { parseAbi, parseEther, erc20Abi } from "viem"; const mintAbi = parseAbi(["function mint(address to, uint256 amount)"]); const hbtt = getBridgeToken(BridgeToken.HBTT, ChainId.somniaShannon)!; // Working capital exists first as the canonical ERC-20 on its home chain. await userWallet.writeContract({ address: hbtt.address!, abi: mintAbi, functionName: "mint", args: [userAccount.address, parseEther("10")], }); // Gas money: STT to the session (native route — §3, recipient swapped). // 1 STT, not a sliver: Somnia's mempool admits a transaction only when the // balance covers the full fee envelope (10M gas ceiling × 60 gwei = 0.6 STT), // even though unused gas is never charged. const gas = createBridgeTransfer({ token: BridgeToken.STT, from: ChainId.somniaShannon, to: ChainId.hidekiTestnet, amount: parseEther("1"), recipient: session.address, }); // Working capital: HBTT to the session (collateral route — approve, then bridge). const capital = createBridgeTransfer({ token: BridgeToken.HBTT, from: ChainId.somniaShannon, to: ChainId.hidekiTestnet, amount: parseEther("10"), recipient: session.address, }); // Each sendBridgeStep resolves once its transaction is CONFIRMED (§3), so the // approve-before-bridge ordering holds by construction. await sendBridgeStep(shannon, gas.bridgeStep, { account: userAccount }); // STT: native, no approval if (capital.approveStep) await sendBridgeStep(shannon, capital.approveStep, { account: userAccount }); await sendBridgeStep(shannon, capital.bridgeStep, { account: userAccount }); ``` Watch both deliveries land on Hideki — the STT with `getBalance` as in §3, the HBTT by polling the synthetic's `balanceOf` (delivery _mints_, so no destination liquidity is involved; remember the synthetic token **is** its router address): ```ts const hbttHideki = getBridgeToken(BridgeToken.HBTT, ChainId.hidekiTestnet)!; await hideki.readContract({ address: hbttHideki.address!, abi: erc20Abi, functionName: "balanceOf", args: [session.address], }); // 10000000000000000000n once the relayer delivers ``` **Spend as the session.** On Hideki it is just an account with a key — the bridged STT pays its gas, the synthetic HBTT is a plain ERC-20: ```ts await sessionWallet.writeContract({ address: hbttHideki.address!, abi: erc20Abi, functionName: "transfer", args: [friend, parseEther("4")], }); ``` **Send what's left home.** Synthetic → collateral needs **no approval** — the router burns the session's balance, and the Shannon router releases the collateral it escrowed on the way out: ```ts const home = createBridgeTransfer({ token: BridgeToken.HBTT, from: ChainId.hidekiTestnet, to: ChainId.somniaShannon, amount: parseEther("6"), recipient: userAccount.address, }); home.approveStep; // undefined — the bridge call alone, signed by the session await sendBridgeStep(hideki, home.bridgeStep, { account: session }); // Poll the canonical HBTT on Shannon for userAccount — it comes back +6. ``` The runnable version of this exact flow (delivery polling, shortfall-only minting, delta assertions) is the session round-trip test in `test/chains.live.e2e.test.ts` — see the next section. ## 5. Run the live test suites Two opt-in suites under `packages/sdk/test/` prove everything above against the real networks. Both skip silently unless their env var is set, so plain `pnpm test` / CI never touch the network. **Read-only** — verifies the registry against the live deployment and simulates the planner's calldata via `eth_call` state overrides. No key, no funds, nothing signed: ```sh cd packages/sdk SOMNIA_E2E_BRIDGE=1 pnpm test test/bridge.e2e.test.ts ``` **Live + signed** — `test/chains.live.e2e.test.ts` checks every chain definition against its endpoints (HTTP + WS chain ids, Multicall3, block cadence, explorer), then **spends**: it bridges ~0.05 STT in each direction with real keys, waits for the relayer to deliver, signs + revokes a real collateral-route approval, and runs the §4 session round-trip for real — derives a session, bridges STT and 10 freshly-minted HBTT to it, pays 4 HBTT to another Hideki wallet as the session, and bridges the remaining 6 home. Needs a funded key per network: ```sh cd packages/sdk SOMNIA_E2E_CHAINS_LIVE=1 \ SOMNIA_TESTNET_PK= \ HIDEKI_TESTNET_PK= \ pnpm test test/chains.live.e2e.test.ts ``` Keys are accepted with or without the `0x` prefix and are read from the environment only — never hardcode them. Budget ≥3 STT on Shannon and ≥1 STT on Hideki. Per run: the two accounts trade ~0.1 STT of bridged value (plus gas), 1 STT of gas money parks with the session account on Hideki (the seed is deterministic, so reruns reuse it), the Hideki account gains 4 HBTT, and the Shannon account nets +6 of the 10 HBTT it mints. If a delivery toward Shannon times out, check the STT router's balance on Shannon — a drained router is the expected failure mode, not a bug in the suite. ## 6. One exchange over both chains Everything above drives the two chains by hand: one client per chain, a plan, a sender. The exchange does the same as one object — configure both chains, and reads merge, writes route to the market's chain, and `exchange.bridge` / `exchange.ensureFunds` move collateral between them through the account's executors. See [EXCHANGE.md § One exchange, several chains](./exchange#one-exchange-several-chains) and [BRIDGE.md § Bridging through the exchange](./bridge#bridging-through-the-exchange). ## 7. Where to go next - [CHAINS.md](./chains) — the definitions in full: why absences are deliberate, parity with `viem/chains`, extending a definition with `defineChain`. - [BRIDGE.md](./bridge) — route models (native/collateral/synthetic), the relayer as a liveness dependency, how every registry value was verified. - Generated API reference: `pnpm docs` in `packages/sdk` writes it to `docs/api/` (gitignored). - The deployment record behind the registry: [hyperlane-bridge-infra](https://github.com/somnia-chain/hyperlane-bridge-infra/blob/main/docs/deployments/somnia-testnet-hideki2-testnet.md). --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/native Somnia's node serves a `somnia_*` namespace alongside the usual `eth_*`. It exposes things the Ethereum-compatible surface has no field for — the native ledger block, chain statistics, reactivity subscription reads — plus **session transactions**, where the node holds the key, tracks the nonce, signs, retries, and hands back the receipt, so a client can transact with no signer at all. This module wraps exactly the twelve methods in the [public JSON-RPC reference](https://docs.somnia.network/developer/json-rpc-api) (as ten TypeScript methods: the by-hash and by-number block and receipt reads, and the two `isReady` variants, fold together), and nothing else — see [Not wrapped](#not-wrapped). ```ts import { createPublicClient, http } from "viem"; import { somniaShannon } from "@somnia-chain/markets-sdk/chains"; import { createNative } from "@somnia-chain/markets-sdk/native"; const client = createPublicClient({ chain: somniaShannon, transport: http() }); const native = createNative(client); const block = await native.getBlock("latest"); console.log(block?.consensusBlock.proposerAddress, block?.executionBlock.executionGasUsed); ``` `createNative` takes anything with an EIP-1193 `.request` — a viem client, the markets client's (`createNative(exchange.client.getViemClient())`), or an injected wallet provider. It needs nothing else from the SDK. ## Why a wrapper Because these endpoints are easy to call wrong, and the node's reply when you do is a bare `-32602 invalid parameters` that tells you nothing. Two shapes surprise everyone, both confirmed against a live node: | You'd write | The node wants | | --------------------------------------- | ----------------------------------------------------------- | | `params: [1]` | `params: ["0x1"]` — block numbers are hex **strings** | | `params: [[1, 2]]` for subscription ids | `params: ["0x1", "0x2"]` — the ids **are** the params array | And what comes back is the node's C++ member names verbatim, with no case conversion — so blocks are `snake_case` (`consensus_block.block_number`) while statistics are `camelCase` (`numSuccessfulTransactions`), purely because that is how the two structs happen to be written. Quantities are hex strings. This module takes `bigint`s and tags, hex-encodes them correctly, and decodes the replies into one consistent camelCase surface with `bigint` quantities. Receipts go through viem's own `formatTransactionReceipt`, so a session receipt is the same `TransactionReceipt` the rest of your code already handles. A session receipt also carries `executionTimeMicros` (a `number`) when the node reports it: the node-side time from request to answer. The untouched wire shapes are exported as `Rpc*` types if you want them. ### Read the node's message, not viem's Somnia reports mempool rejections as JSON-RPC **`-32000`**, with the useful text in `message` and a `MempoolStatusCode` byte in `data`. viem maps `-32000` to its `InvalidInputRpcError`, whose display text is the generic **"Missing or invalid parameters."** So the obvious thing to log claims your parameters are wrong when the real problem is an unfunded account: ``` error.message "Missing or invalid parameters. Double check you have provided…" error.details "account does not exist" ← what the node actually said ``` `getSomniaRpcError` recovers it, and decodes the status byte: ```ts import { getSomniaRpcError, SomniaMempoolStatus } from "@somnia-chain/markets-sdk/native"; try { await native.sendSessionTransaction({ seed, gas: 21_000n, to, value }); } catch (error) { const rpc = getSomniaRpcError(error); console.error(rpc?.message); // "account does not exist" if (rpc?.mempoolStatus === SomniaMempoolStatus.nonceTooSmall) retryWithFreshNonce(); } ``` Branch on `mempoolStatus` rather than matching message text — `nonceTooSmall` is retryable, `insufficientBalance` needs funding, and `accountDoesNotExist` means the _sender_ has never existed, which is a third fix. The codes come from `MempoolStatusCode` in the node source, so the set is complete rather than the three we happened to trip over. **Nothing needs bypassing.** viem preserves the original: `BaseError`'s constructor inherits `details` from a `BaseError` cause, so the node's text propagates up the whole wrapper chain from the `RpcRequestError` that set it, and the raw `{ code, message, data }` object is still the innermost `cause`. `getSomniaRpcError` walks to it structurally — no `instanceof` against viem's classes, so it also works on an error from a plain `fetch`-based requester that never went through viem. This cost real time during live testing: a correctly-encoded session send read as a parameter error until the raw HTTP body showed `account does not exist`. ## Reads ```ts await native.isReady(); // false while syncing await native.getBlock("latest"); // tag, number, or 32-byte hash await native.getStatistics(blockNumber - 100n, "latest"); // aggregate activity await native.listPrivilegedReceipts("latest"); // protocol-issued txs (usually none) await native.listReactivitySubscriptionIds(owner); // → bigint[] await native.getReactivitySubscription(1n); // → one, or null await native.listReactivitySubscriptions([1n, 2n]); // → many, one round-trip await native.getNodePublicKeys(); // address + 2 keys + 2 proofs ``` `getBlock` and `listPrivilegedReceipts` accept a tag (`"latest"`, `"earliest"`, `"pending"`, `"safe"`, `"finalized"`), a block number, **or** a 32-byte hash — a 32-byte hex value dispatches to the by-hash RPC, anything shorter is a number. A **ledger block** is not the Ethereum block: it pairs the consensus half (proposer, timing, the data-chain blocks it commits) with the execution half (gas, receipts hash, state snapshot). Note `consensusBlock.timestamp` is unix **milliseconds** — Somnia blocks are ~100 ms apart, so seconds would be useless. `getNodePublicKeys` returns all five fields the node publishes: the address, the secp256k1 and BLS public keys, and the two proofs that bind them together (a BLS proof of possession and a proof of address). The proofs are what make the rest meaningful, so they are not dropped. ## Session transactions A session is a **32-byte seed**. The node turns it into a key pair, assigns the nonce, signs, submits, retries transient failures, and returns the receipt — one call instead of sign + send + poll. Useful when a client shouldn't hold a key at all: load generators, bots, game backends. ```ts import { createNative, sessionAddress } from "@somnia-chain/markets-sdk/native"; const seed = "0x…"; // 32 bytes, and a SECRET — see below // Where to send funds. Derived locally: no RPC, no node involved. const from = sessionAddress(seed); const receipt = await native.sendSessionTransaction({ seed, gas: 21_000n, // enough ONLY if `to` already exists — see gas, below to: recipient, value: parseEther("0.1"), }); console.log(receipt.status, receipt.transactionHash); ``` Six things to know before using it: - **The seed is a private key in another shape.** The derivation is public and deterministic — `sessionPrivateKey(seed)` computes the very key the node uses. Anyone who learns a seed can drain its account without touching the node. Guard it as a key. A failed call does not hand it back: `sendSessionTransaction` and `getSessionAddress` throw an `RpcError` instead of viem's error, whose message would print the request body — seed included. When the node answered, `cause` is its own `{ code, message, data? }` and `getSomniaRpcError` reads it as before; when the transport failed first, `cause` is `{ name, message, status? }` with the request text kept and the seed blanked out. - **Give a node-side session account gas dust only.** `sendSessionTransaction` puts the seed on the wire with every call, so any RPC endpoint, proxy or request log on the way can read it. Never make a node-side session address an owner of an account that holds funds. For that, sign locally: `createExecutor({ privateKey: sessionPrivateKey(seed) })`, or `exchange.accounts.accountFor(accountId, index, { sessionSeed })` across chains. The SDK refuses a `createExecutor({ sessionSeed })` executor as the `inner` of a smart account. - **Pre-fund the account.** Use `sessionAddress(seed)`; an unfunded session cannot pay gas. `native.getSessionAddress(seed)` asks the node the same question if you want confirmation (the two are asserted equal by the live test suite). Note the node rejects a send from an account that has never existed with `account does not exist` (mempool code `0x02`), not with an out-of-gas. - **Paying a brand-new address costs ~30× the Ethereum figure.** Somnia has to bring the account into existence, and `21_000` is not enough: the transaction **reverts with `status: "reverted"` having consumed the entire limit**, and the recipient is not credited. Measured on Hideki, first payment to a fresh address: | gas limit | outcome | gasUsed | | ---------- | -------------------------------- | ------------------- | | `21_000n` | reverted, recipient not credited | 21,000 (all of it) | | `100_000n` | reverted, recipient not credited | 100,000 (all of it) | | `700_000n` | success | 421,000 | `eth_estimateGas` quotes ~631,500 for such a transfer. A second payment to the same address costs the ordinary 21,000. Since the node does not estimate for you, budget by whether the recipient already exists — and treat a `reverted` receipt whose `gasUsed` equals the limit as "the limit was the problem". - **The nonce space is shared** with `eth_sendRawTransaction` from the same address. Sending both ways at once corrupts the sequence — pick one per account. An address that signs locally (through `accountFor` or `sessionPrivateKey`) must not also send here. - **It blocks until the receipt exists.** The node retries with backoff, so a call can take a while; give the transport a generous timeout. There is no fire-and-forget variant. Omit `to` to deploy a contract, with `data` as the init code. The send goes out with transport retries **disabled** (`retryCount: 0`). viem retries a failed request three times by default, and the node's failures (a node-side ceiling, a full mempool) look retryable — so a default retry could submit the same transfer again under a fresh nonce. One attempt; the node already retries internally where that is safe. A missing receipt **throws** rather than resolving to `null`: a receipt is the whole contract of the call. Sessions live in the serving node's memory: they are not shared between nodes, and are rebuilt from the seed after a restart. Nothing is persisted, and the derived key is never written to disk. ### Deriving locally ```ts sessionAddress(seed); // the address, offline sessionPrivateKey(seed); // the key itself — sign locally instead of trusting the node ``` The algorithm is `keccak256(seed ‖ uint64_le(i))` for `i = 0, 1, 2, …`, taking the first candidate inside `[1, n-1]`. In practice `i = 0` always wins: a keccak output falls outside the curve order with probability under 2^-128. This is pinned against the node for four different seeds in `test/native.e2e.test.ts` — if Somnia ever changed its derivation, every funded session address would move, and that test is what would catch it. ## Not wrapped The wrapped set is the [public JSON-RPC reference](https://docs.somnia.network/developer/json-rpc-api). A node build serves more than that, but an undocumented endpoint is not part of the contract — it can change shape or vanish between node versions, and one of them is outright hazardous. All of them stay reachable via `native.request(...)`, which is the point: stepping off the documented surface should be a decision, not an accident. **`somnia_getStorageDatabaseEntries` — undocumented and dangerous.** Its handler loops over the caller's key list with no cap on the number of keys and no bound on the size of each value returned, so one request can make a node dump unbounded data. **A 256-key request took the public Shannon testnet down on 2026-07-31** (discovered by this SDK's own live testing). It is also not what the name suggests to an Ethereum developer: keys are node-internal `StorageKeyType` discriminants — one byte, or one byte plus a 32-byte body, where byte 0 must be a member of that enum — not contract storage slots. For those, `eth_getStorageAt` is the method you want. A malformed key is an **error**, not a miss. **`somnia_getProtocolParameters` — undocumented.** The key set is node-version dependent and its values are plain JSON numbers rather than hex quantities, so wrapping it would mean publishing a shape that cannot be kept stable. Reach it with `native.request("somnia_getProtocolParameters", ["latest"])` if you need it; that is how the account-creation gas figure below was read. **Operator-only** (`kProtected` on the node) — a public endpoint answers `{ code: -1, message: "unauthorized" }`, which `isUnauthorized(error)` recognises **by message**, not by code. That matters: `-1` is the node's default error code, shared with `invalid range`, `could not load statistics` and `Block does not exist` (all confirmed live), so keying on the code would report a bad block range as an auth failure. The methods: `somnia_connectToPeer`, `somnia_dumpMemory`, `somnia_createTransactionLog`, `somnia_byteStringBenchmark`. If you _are_ the operator, reach them with `native.request("somnia_dumpMemory")`. **Validator plumbing** — `somnia_submitBatchedTransaction` and `somnia_submitMerkleBatchSignature` carry smash-encoded consensus structs a JS caller can't construct. Not exposed. **`eth_sendRawTransactionSync`** (EIP-7966; Somnia's older name for it is `realtime_sendRawTransaction`) is not a `somnia_*` method, and it is already the SDK's write path: a local-signer write sends through it and gets its receipt in one round-trip (see the [engine guide](./engine)). No need to call it yourself. ## Nodes that don't have these Every method here is node-version dependent, and a stock geth or a local anvil has none of them. Degrade instead of breaking: ```ts import { isMethodNotFound } from "@somnia-chain/markets-sdk/native"; const stats = await native.getStatistics("earliest", "latest").catch((e) => { if (isMethodNotFound(e)) return null; // not a Somnia node — hide the panel throw e; }); ``` Everything else throws: a failed read never resolves to `null`. `null` appears in exactly two places, both genuine by-id misses — an unknown block and an unknown subscription id. `isReady()` returns a boolean. `isReady({ withErrorCode: true })` calls the node's error-code variant, which **throws** on a not-ready node instead of returning `false` (that is the point of the variant — a health check keys on the error), so it never returns `false`. ## Where these come from The method list is `somnia/api/handlers/somnia_api_handlers.h` in [somnia-chain/somnia2](https://github.com/somnia-chain/somnia2), and session transactions are specified in its `docs/session_transactions.md`. Both were read — and then every endpoint was called against a live Shannon node, because the wire and the C++ types disagree in ways that matter (the field-name inconsistency above, and `token()`-style traps like a params array that is not what the signature suggests). `test/native.test.ts` pins the encodings offline against captured payloads; `test/native.e2e.test.ts` re-checks them against a real node: ```sh SOMNIA_E2E_NATIVE=1 pnpm test ``` --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/reactivity Somnia pushes events to you **with the state that goes with them**. That is the whole idea: on any other EVM chain, reacting to an event is "see the log, then fetch the state" — two round trips, racing the next block. Here the notification carries the log _and_ the results of a fixed set of `eth_call`s executed at that same block. This module is a **pointer, not a port**. The implementation is [`@somnia-chain/reactivity`](https://github.com/somnia-chain/reactivity) — the upstream repo owns the protocol, the Solidity side (`SomniaEventHandler`, in `@somnia-chain/reactivity-contracts`) and the client. `@somnia-chain/markets-sdk/reactivity` re-exports that package verbatim and adds only the glue a markets consumer needs, so there is no second copy of the ABIs or the validation rules to drift out of step. ```ts import { createReactivity, unwrap } from "@somnia-chain/markets-sdk/reactivity"; ``` ## Install `@somnia-chain/reactivity` is an **optional peer dependency** — exactly like `react` is for the `/react` entry. Only callers of this subpath install it: ```sh pnpm add @somnia-chain/reactivity ``` Both `@somnia-chain/reactivity` and `@somnia-chain/markets-sdk` (from 0.20.0) are on the public npm registry; no `.npmrc` scope configuration is needed. ## What this entry adds | Export | Why it isn't upstream | | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `createReactivity(client, { wallet? })` | builds the reactivity client from the markets client's viem client: reads and receipt waits reuse it; `watch` opens its own WebSocket from `chain.rpcUrls.default.webSocket[0]` | | `unwrap(result)` | upstream _returns_ `Error` objects; this SDK throws instead, so a failure stops the caller | | `SOMNIA_REACTIVITY_PRECOMPILE_ADDRESS` | not exposed by upstream's published build | | `DEFAULT_SUBSCRIPTION_OPTIONS` | the protocol's own `SomniaExtensions.DEFAULT_*` values: upstream ships them at runtime but omits them from its `.d.ts`, so they can't be re-exported with types (a test pins ours against upstream's) | | `isLocalPrecompileUnavailable(chainId)` | already lives in this package; the check to run before any Solidity subscription | Everything else — `Reactivity` / `SDK`, `SomniaReactivityPrecompileABI`, `SomniaEventHandlerABI` and every type — is upstream's, re-exported. A test asserts the re-exported classes are upstream's own identity, so this can never quietly become a fork. ## Two flavours - **WebSocket reactivity** (`watch`) — a TypeScript subscription over the socket (`somnia_watch`). Each matched log arrives with your `eth_call` results from the same block. Nothing on-chain, nothing to pay for, gone when you disconnect. - **Solidity reactivity** (`subscribe`) — a subscription registered _on-chain_ against the reactivity precompile, which makes validators call your handler contract's `onEvent(address,bytes32[],bytes)` when a matching log lands. Callbacks are paid out of the subscription owner's native balance (which must hold at least 32 SOMI/STT). This protocol already runs on the second one: `OracleHub` is the Solidity handler the precompile calls for `OracleCore.AnswerPosted`, and MarketCreator rolls are scheduled subscriptions. The admin `enableReactivity` and `setReactivityGasParams` methods configure these subscriptions. This module points the same primitive at _your_ contracts. ## Markets event emitters For `watch`, set `eventContractSources` to the emitter's address and `topicOverrides[0]` to the exact event's topic0. For an on-chain `subscribe`, set `filter.emitter` to that same address and `filter.eventTopics[0]` to the topic0 of the **exact** event signature. A handler or caller address is not automatically the log emitter. Use the SDK's matching-chain `config.addresses` for configured singletons and `smart-contracts/deployments///addresses.json` for `OracleCore.address`. An existing receipt's `log.address` identifies its actual emitter. The SDK's testnet and mainnet address presets come from those manifests; other venues can have different `MarketCreator` instances; the module's `MarketCreated` event carries the `creator`. Historical oracle answers may come from an older OracleCore deployment, so use the core that emitted those logs. | Event signature | Emitting contract | Filter address | | ------------------------------------------------------------------------ | ---------------------------------------------------------------- | ------------------------------------------------ | | `MarketCreated` (series, 13 arguments) | `MarketCreator` on a series roll | Venue's `marketCreator` | | `MarketCreated` (module, 20 arguments) | `BinaryMarketsModule` on every creation, including a series roll | `binaryModule` | | `AnswerPosted(uint256,uint8,string,int256,bool,uint8,uint256[])` | `OracleCore`, not the receiving `OracleHub` | Matching manifest's `OracleCore.address` | | `Resolved(uint32,uint256[])` / `Voided()` | Individual `BinaryMarket` | Market's `marketAddress` | | `MarketFinalized(bytes32,address,uint256)` | `BinaryMarketsModule`; carries `marketId` | `binaryModule` | | `MarketFinalized(uint256,address,uint64,address,uint256,bool,uint256[])` | `BinarySettlement`; carries `netBacking` | `binarySettlement` | | `PoolFinalized(uint64,uint256)` | Individual `BinaryPool`; carries `marketNonce` | Market's `poolAddress` and indexed `marketNonce` | | `PoolReleased(bytes32,address,address)` | `BinaryMarketsModule` | `binaryModule` | | `Redeemed(uint256,address,address,uint8,uint256,uint256)` | `BinarySettlement` | `binarySettlement` | A series roll can produce both `MarketCreated` events for one `marketId`. Their canonical signatures for topic0 are: ```text MarketCreated(bytes32,address,address,uint256,uint256,address,string,uint256,uint64,uint64,uint256,string,uint64) MarketCreated(bytes32,address,address,uint256,uint32,bytes32,address,address,uint256,uint256,uint64,uint8,uint8,uint64,uint64,uint8,string,uint256,string,bytes) ``` The pool can later serve another market; match `PoolFinalized` topic1 (`marketNonce`) to the selected market's `nonce`, as well as the pool address. For example, on Shannon (50312), a `watch` for settlement `netBacking` uses `eventContractSources: [SOMNIA_TESTNET_ADDRESSES.binarySettlement]`; an on-chain subscription uses `filter.emitter: SOMNIA_TESTNET_ADDRESSES.binarySettlement`. Both use the seven-field settlement `MarketFinalized` topic0 `0xb1884334e955f8d8727678d4fa52dd9fc7140ff5e4ad38d358453bd400ada178` (`topicOverrides[0]` or `filter.eventTopics[0]`). The three-field module event's topic0 is `0x8f396ac6cf2e01887362e2b39d8e56860042c604e5b1b481c87e6d9f90006e08`; filtering it at the settlement address silently matches nothing. The Shannon settlement proxy in `50312/testnet-development` is `0xbF4a49e0Dfd092e5FBE8E5761064C49533e6Ed23`. Use the installed SDK's matching-chain deployment addresses, not an implementation address or an address copied from another chain. Oracle question IDs are scoped to an oracle deployment. A Shannon market (50312) links its ID to `https://dev.oracle.somnia.host/questions/`. A mainnet market (5031) links its own ID to `https://prd.oracle.somnia.host/questions/`. The same number is not a cross-host key. These are question pages, not the `price-feed.*` GraphQL endpoints. Verify the host for any other chain before constructing a question link. ## Watching (TypeScript) `createReactivity` hands upstream the markets client's public client for reads and receipt waits; `watch` itself opens a second WebSocket to the same chain (see the note below): ```ts import { SomniaMarkets } from "@somnia-chain/markets-sdk"; import { createReactivity, unwrap } from "@somnia-chain/markets-sdk/reactivity"; import { somniaShannon } from "@somnia-chain/markets-sdk/chains"; const exchange = new SomniaMarkets({ chain: somniaShannon, wsRpcUrl, indexerUrl }); const reactivity = createReactivity(exchange.client); // Every Transfer on the collateral token, with the sender's new balance read // at the very same block — one notification, no follow-up call. const watch = unwrap( await reactivity.watch({ eventContractSources: [collateral], topicOverrides: [transferTopic], ethCalls: [{ to: collateral, data: balanceOfCalldata }], onData: (n: ReactivityNotification) => console.log(n.result.simulationResults), }), ); await watch.unsubscribe(); ``` > **Use a chain definition from [`/chains`](./chains).** Upstream's `watch` > opens its own socket with viem's `webSocket()` _without a URL_, so the endpoint > comes from `chain.rpcUrls.default.webSocket[0]`. viem's own `somniaTestnet` has > no WebSocket entry, so `watch` fails on it; every definition in > `@somnia-chain/markets-sdk/chains` carries one. Notes that matter in practice: - **Every filter is optional, and omitting one means "everything".** A bare `{ ethCalls: [], onData }` tails every event on the chain. - **`ethCalls` is the point.** Batch several reads into one call against Multicall3 where you can — the notification is only as fast as the calls it carries. `simulationResults` comes back in the order subscribed. - **`context`** splices event-sourced values into the `ethCalls` calldata (`topic1`…`topic4`, `data`, `address`), so one subscription can read state _about the thing that just happened_ rather than a fixed address. - **`onlyPushChanges`** suppresses notifications whose call results match the previous ones — a cheap way to watch for a _state_ change rather than an event. - **The payload is at `notification.result`** — `{ address, topics, data, simulationResults }`. Upstream's README and type docs say `notification.params.result`, one level deeper; that is stale, because viem's WebSocket transport unwraps the JSON-RPC envelope before calling `onData`. Type the callback with `ReactivityNotification` (upstream types it `any`) — the shape is asserted against a live node in `test/reactivity.e2e.test.ts`, which runs with `SOMNIA_E2E_WS` set. This is a different tool from `client.watchMarket`: the markets tail materializes the order book from indexed protocol events, while `watch` is a general-purpose log+state subscription for anything on the chain. ## Subscribing (Solidity) Deploy a handler extending `SomniaEventHandler` (from `@somnia-chain/reactivity-contracts`), then register it. The signer becomes the subscription **owner** and its balance funds every callback: ```ts import { createReactivity, unwrap, DEFAULT_SUBSCRIPTION_OPTIONS } from "@somnia-chain/markets-sdk/reactivity"; const reactivity = createReactivity(exchange.client, { wallet: walletClient }); const hash = unwrap( await reactivity.subscribe({ handlerContractAddress: handler, filter: { emitter: collateral, eventTopics: [transferTopic] }, options: DEFAULT_SUBSCRIPTION_OPTIONS, }), ); ``` Upstream validates the precompile's preconditions before spending gas — a non-zero handler, well-formed `bytes32` topics, at least one narrowing filter (a match-everything subscription is rejected), `0 < gasLimit <= 200_000_000`, `maxFeePerGas >= priorityFeePerGas + 6 gwei` (or `0` to skip that check), and the owner holding ≥ 32 SOMI/STT. Note that writes need a **wallet client**: pass one to `createReactivity`, or upstream's write silently resolves to `null`. Upstream sends each write on the wallet's chain, so the wallet must be on the engine's chain. When `wallet.chain` is set to another chain, `createReactivity` throws `InvalidInputError`. A wallet with no `chain` is not checked. On a multi-chain exchange, pass `exchange.engineFor(chainId)` instead of `exchange.client`. Cancel with `unsubscribe(subscriptionId)` (owner only), read one back with `getSubscriptionInfo(subscriptionId)`, and use `subscribeRaw` when you need the precompile's full struct including the protocol-reserved fields. ## Scheduling — cron, blocks, epochs The precompile emits its own system events (`Schedule`, `BlockTick`, `EpochTick`), so "call me later" is just a subscription to one of them with the tick as a topic filter: ```ts await reactivity.scheduleSubscriptionAtTimestamp({ timestampMs: Date.now() + 60_000, handlerContractAddress, options }); await reactivity.scheduleSubscriptionAtBlock({ blockNumber: head + 100n, handlerContractAddress, options }); await reactivity.scheduleSubscriptionAtEpoch({ epochNumber: 7n, handlerContractAddress, options }); ``` `scheduleSubscriptionAtBlock` with **no** `blockNumber` leaves the block topic a wildcard — a callback on _every_ block. Timestamps are unix **milliseconds** and must be at least a second out; a block must be past the head. ## Errors Upstream methods resolve to `T | Error` instead of throwing. Two ways to live with that, both fine: ```ts // 1. this SDK's contract — throw (recommended) const hash = unwrap(await reactivity.subscribe({ ... })); // 2. upstream's own idiom — check const result = await reactivity.subscribe({ ... }); if (result instanceof Error) throw result; ``` ## Local development The precompile does not exist on anvil or hardhat — check `isLocalPrecompileUnavailable(chainId)` before offering Solidity subscriptions in a UI, and note that it has no bytecode on _any_ chain, so `eth_getCode` can't probe for it. `somnia_watch` is a node feature too: a plain anvil node doesn't serve it. Test reactivity against Shannon (or Elwood/Hideki). --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/accounts The factory account model of [`@somnia-chain/accounts-sdk`](https://www.npmjs.com/package/@somnia-chain/accounts-sdk), reachable through this SDK alone. A smart account is a Gnosis-style safe the cross-chain factory deploys at the same address on every covered chain (Somnia mainnet 5031, Somnia testnet 50312, Hideki testnet 50383); its address derives from `(accountId, index)` before anything is deployed, an account can own other accounts, and a session key added as an owner trades through it without signing. Three surfaces, one dependency: - **`client.createExecutor({ smartAccount, inner })`** — send trades AS an account. The [how-to guide](./how-to/trade-through-a-smart-account) covers it. - **`client.accounts`** — one chain: derive, discover, create, manage owners, move funds, check health. - **`exchange.accounts`** — every configured chain: the cross-chain scan, one-signature creation on all chains, and the multi-chain account the exchange trades through. A client used only for accounts needs no indexer: `indexerUrl` is optional, and every indexer read or live watch on such a client throws `NotConfiguredError` naming the field. ## From nothing to trading, in four calls These four calls act on one chain, so they name it. `exchange.client` is the same engine on a single-chain exchange, but it throws `InvalidInputError` once the exchange spans more than one — use `engineFor(chainId)` here. ```ts const client = exchange.engineFor(chainId); // The session key signs locally. It never leaves this process. const session = client.createExecutor({ privateKey: sessionKey }); // 1. One signature authorizes the account, with the session key as a second owner. const prepared = client.accounts.buildAccountAuthorization({ accountId: ownerAddress, indices: [0n], additionalOwners: [session.actor], }); const authSignature = await ownerWallet.signTypedData(prepared.typedData); // 2. Anyone funded sends the creation call. Here the session key sends it. const creation = client.accounts.buildCreateAccounts(prepared.authorization, authSignature); await session.execute([creation.call], { gas: creation.gas }); // 3. The account is the trader. The session key sends the account's transactions. It signs no Safe transaction. const account = client.createExecutor({ smartAccount: client.accounts.addressOf(ownerAddress, 0n), inner: session, }); const trader = client.createTrader({ executor: account }); // 4. Trade. await trader.placeOrder(params); ``` The authorization's EIP-712 domain deliberately omits the chain id, so the same signature settles on every covered chain — `exchange.accounts.buildCreateAccounts` fans it out into one creation call per covered configured chain. ### Keep the session key in the process The session key is an owner of an account that holds funds. Whoever holds the key can move those funds on every chain where the account exists. So: - **Sign locally.** Build the account's `inner` with `createExecutor({ privateKey })`, as above. Do not use `createExecutor({ sessionSeed })` for an owner: that executor sends the seed to the node on every write, and every RPC endpoint, proxy or request log on the way can read it. The SDK refuses such an executor as the `inner` of a smart account, with `InvalidInputError`. Keep node-side session sending for accounts that hold gas dust only. - **Hold a Somnia session seed?** `exchange.accounts.accountFor(accountId, index, { sessionSeed })` derives the seed's key and signs locally on every chain (see [Multi-chain](#multi-chain-the-facade-surface)). The session address stays the same. - **Use one sending mode per address.** Local sends and node-side session sends from the same address share one nonce space. If you mix them, the nonce sequence breaks. After an address signs locally, do not send from it through `createExecutor({ sessionSeed })` or `sendSessionTransaction`. ## Discovery: which accounts exist, who controls what ```ts const scan = await client.accounts.find(ownerAddress); // deployed / empty / unanswered, per index const owners = await client.accounts.ownersOf(accountAddr); // owners classified key vs account, plus threshold const custody = await client.accounts.custodyChain(accountAddr); // DOWN to the keys that ultimately control it const through = await client.accounts.authorityPath(accountAddr, signerAddress); // UP: the executor's `through`, or null ``` `authorityPath` answers the executor's one configuration question: "I hold this key — what do I pass as `through`?" `null` means the signer has no authority over the account, which is an answer, not an error. A scan can never prove absence — the index space is `uint256` and the factory counts nothing — so `find` reports exactly which indices it checked (`scan.indices`) and three states per slot: deployed, empty, unanswered. Render from those, never from a missing entry; `describeCoverage(scan)` (re-exported) turns the coverage into the honest sentence. ## Indexed reads: history, and whose account an address is Two questions have no chain read: whose account a bare address is, and what an account has done. The accounts indexer answers both. The accounts SDK knows where its indexers are, so these reads take no URL: ```ts const found = await client.accounts.getIndexedAccount(someAddress); // null when the factory made nothing there const children = await client.accounts.listDerivedAccounts(ownerAddress); // every index, every page const controlled = await client.accounts.listControlledAccounts(ownerAddress); // including accounts it was ADDED to const history = await client.accounts.listOwnerHistory(accountAddr, { includeFormer: true }); const page = await client.accounts.getActivityPage(accountAddr, { limit: 25 }); // { items, nextCursor } const more = page.nextCursor && (await client.accounts.getActivityPage(accountAddr, { cursor: page.nextCursor })); const status = await client.accounts.getIndexerStatus(); // behindBy, lagSeconds, ready: hedge anything stale const modules = await client.accounts.listModuleHistory(accountAddr, { includeFormer: true }); const terms = await client.accounts.getCreationAuthorization(accountAddr); // null for a plain createAccount ``` Pages are this SDK's own shape, the same as the fills feeds: `{ items, nextCursor }`, `null` when drained. The cursor is opaque and bound to the read and filter that issued it — passed to another read it is refused with `InvalidInputError`. The network as a whole, for a front page or an explorer: ```ts const newest = await client.accounts.getAccountsPage({ limit: 20 }); // every account the factory made, newest first const latest = await client.accounts.getNetworkActivityPage({ kinds: ["Executed"] }); // any account's history const factory = await client.accounts.getFactoryState(); // admin, implementation, exact accountCount const changes = await client.accounts.getFactoryHistoryPage(); // upgrades, admin changes, domain changes ``` `found.derivation` says whose account it is (`accountId`, `index`), who paid for it, and whether `accountId` is still an owner. Only accounts `SomniaAccountFactory` made come back: a Safe from another factory is `null` here. On `exchange.accounts` the same reads span every covered configured chain: `listIndexedAccounts`, `listDerivedAccounts`, `listControlledAccounts`, `listOwnerHistory`, `getActivityPage`, `listIndexerStatus`, `listModuleHistory`, `listCreationAuthorizations`, `listFactoryStates`, `getAccountsPage`, `getNetworkActivityPage` and `getFactoryHistoryPage`. Unlike the markets indexer, one accounts-indexer deployment serves an environment (Somnia testnet and Hideki share one) and every row names its `chainId`. The paged feeds merge chains by time, and each chain resumes from its own position. The same address can be a different account on each chain. The row types (`IndexedAccount`, `AccountEvent`, `IndexedOwner`, `IndexedModule`, `IndexedAuthorization`, `AccountFactoryState`, …) are the accounts SDK's data shapes, re-exported. The control types — pages, cursors, options and `AccountsIndexerStatus` — are this SDK's own, so a change in the accounts SDK's paging does not reach your code. To point a chain at another accounts indexer (a local stack), or to turn one off, call the accounts SDK's `configureIndexer({ urls })` once at startup. A chain with no accounts indexer is refused with `NotConfiguredError`; a failed request is `IndexerError`. ## Owner management and funds: the account administers itself Every write comes back as an `UnsignedCall` for an executor to send. Owner changes and transfers are calls the ACCOUNT executes, so they go through the account's own smart-account executor: ```ts const owners = await client.accounts.ownersOf(accountAddr); const add = client.accounts.buildAddOwner(accountAddr, newSessionKey, 1n); const move = client.accounts.buildTransfer({ to: mainAccount, tokens: [{ token: usd, amount: 1_000_000n }] }); await account.execute([add, ...move]); // one account transaction, batched by the accounts SDK ``` `buildSwapOwner` and `buildRemoveOwner` take the CURRENT owner list (from `ownersOf`) — Safe's owner list is linked, and the call names the predecessor. ## Sponsorship: sign now, send later When the signer and the payer are different processes — the user signs in the browser, your backend broadcasts and pays — the executor path does not fit (an executor signs AND sends). `buildExecution` splits them: ```ts const prepared = await client.accounts.buildExecution({ account: accountAddr, calls: client.accounts.buildTransfer({ to: recipient, native: amountWei }), signer: ownerAddress, }); const signature = await ownerWallet.signTypedData(prepared.typedData); const finished = prepared.toCall(signature); // hand this to your backend ``` `finished` is an `UnsignedCall` any funded key may broadcast (the signature carries the authority). Send it with at least `prepared.gasFloor` gas and assert the receipt's `status` yourself — a hand-rolled send has no writer to do it. `through` works here the same way it does on the executor. Two details for particular signers: pass `nonce` when you already know it — `nonce: 0n` prepares an undeployed account's FIRST action, signed before the account exists, with no read at all. And a signer that signs the `digest` via `personal_sign`/`eth_sign` instead of typed data must add `4` to the signature's final `v` byte — Safe's marker for an eth_sign signature; without it the recovered address is real and wrong. ## Health ```ts const report = await client.accounts.health(accountAddr); ``` Evaluates the account's setup against the factory's canonical shape — singleton, fallback handler, owners, threshold, modules — each finding with a severity. A finding is not necessarily a problem; it is a deviation worth a look. ## Multi-chain: the facade surface ```ts const everywhere = await exchange.accounts.scan(ownerAddress); // every covered configured chain at once const multi = exchange.accounts.accountFor(ownerAddress, 0n, { sessionSeed }); const spanning = new SomniaMarkets({ chains, account: multi }); // the exchange trades through it on every chain ``` `accountFor` builds the `{ address, executors }` account the exchange config takes: the derived address (identical on every covered chain) plus a smart-account executor per covered configured chain. On each chain a local signer drives the account. `accountFor` derives it from the seed's key (`sessionPrivateKey(seed)`) and binds it to that chain's nonce lane, so no request carries the seed. The same seed derives the same session address everywhere, so one key owns the account on every chain. A configured chain the accounts SDK does not cover gets no executor and is skipped, never silently substituted. Do not also send node-side from the seed you give to `accountFor` (`createExecutor({ sessionSeed })`, `sendSessionTransaction`): both modes share one nonce space. A per-chain factory in place of `{ sessionSeed }` must not return a `createExecutor({ sessionSeed })` executor either. `accountFor` refuses it with `InvalidInputError`. Funds reach a market's chain the same way trades do: `exchange.bridge` plans and executes Hyperlane transfers through the same per-chain executors, so a smart account's approval and bridge call land as one account transaction — see [Bridge](./bridge). ## Errors Everything here throws this SDK's own classes: an input the accounts SDK refuses (including a chain it has no deployment for) is `InvalidInputError`; a transport failure is `RpcError` with the original as `cause`. The indexed reads add `NotConfiguredError` (no accounts indexer for the chain), `IndexerError` (the request failed, the chain on it when there is one), `AccountsIndexerUnavailableError` (the accounts indexer is rate-limited or overloaded — wait `retryAfterMs` and retry; a subclass of `IndexerError`) and `AccountFeedRestartRequiredError` (the chain's accounts index was rebuilt under a feed cursor — restart without it). A revert while sending THROUGH an account surfaces as `ContractRevertError` tied to the account's `execTransaction`, with the actual cause named where a replay can decode it — the account's own `GS` code as a sentence, or the inner call's real revert, including this SDK's contract errors. ## What stays outside - Signature collection for accounts above threshold 1: a workflow, not an executor — the accounts SDK's `signExecutionWith` builds the call, a custom `Executor` sends it. - The accounts SDK's own submission helpers (`submitCall`, relaying) — this SDK's transport is the better send path, which is the accounts SDK's own recommendation. - Per-write payer choice: an executor's payer is fixed in its `inner`. A UI that picks the payer per write builds one executor per candidate and chooses by balance (`client.getNativeBalance`). --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/engine This guide tours the **engine tier** — the raw `SomniaMarketsClient`: bigint-exact, address-keyed. It is not a second entry point: you reach it through the exchange — ```ts const exchange = new SomniaMarkets({ indexerUrl, chain, wsRpcUrl }); const client = exchange.client; // the engine: exact raw-unit reads + watches const trader = exchange.trader; // the raw write tier (needs a signer) ``` Most applications stay on **[the exchange API](./exchange)** itself; the engine is the tier for exact escrow math, custom reads, and anything the exchange verbs don't cover. An engine is bound to ONE chain — one config, one socket, one watch/store, one nonce lane. **Multi-chain is one exchange over several engines**: configure `chains: [...]` and the exchange builds an engine per binding. `exchange.engineFor(chainId)` and `exchange.traderFor(chainId)` reach each chain's engine and raw write tier. On a multi-chain exchange, `exchange.client` and `exchange.trader` throw `InvalidInputError`, because they name no chain. Every `UnsignedCall` a build verb returns names its `chainId`, so a call is never sent on the wrong chain. **Who signs is pluggable.** `client.createTrader({ executor })` takes any `Executor`; `client.createExecutor(spec)` builds the built-ins — a private key or local account, a wallet client, a Somnia session seed, or a factory smart account from `@somnia-chain/accounts-sdk` (`{ smartAccount, inner }`). A smart account sends an approval and its order as one account transaction, with no batching configuration. `through` reaches an account owned by other accounts. The lifecycle around it is `client.accounts`; see [Smart accounts](./accounts). The writer decodes the order from the receipt the executor returns and never fetches it again; `decodePlaceOrder` / `decodeAmendOrder` / `decodeAmendOrders` are the same decoders, exported for a receipt you obtained yourself. **Retry rule for every executor.** When a send times out, its socket drops, or its receipt wait fails after the transaction left, the built-in executors throw `TransactionOutcomeUnknownError` with the transaction's `hash` (a session send has none). The transaction can still mine. Look up `hash`, or read the state the write changes, before you send the write again. A refusal the node answered stays a plain `RpcError` or `ContractRevertError`. A safe or smart-account executor passes the inner executor's error through, so `hash` is the outer transaction's hash. A custom executor gets the same pass-through from `asExecuteError`, but `asExecuteError` never creates this error: only the code that sent the transaction knows that it may have left. A custom executor that sends raw transactions constructs the error itself. Receipt confirmation checks immediately and on each head. If no read is active, it probes after one idle second. The total wait ends after 60 seconds, including a receipt request that never returns. This applies to local fallback sends, wallet sends and machinery writes. It does not change the sync-send fast path. `stopLive()` and exchange `close()` end confirmations from the old owner lifecycle. A configured abort signal ends them too. After broadcast, the error retains the hash and the abort reason in `cause`; before broadcast, cancellation sends nothing. Queued local machinery writes check the captured lifecycle before reading a nonce, signing or sending. They reject with the same abort reason as the canceled wait. A later write uses the fresh lifecycle after `stopLive()`. For market-kind specifics, see the [binary markets](./binary) and [spot](./spot) guides. ## The three read tiers Every read on the client belongs to one of three tiers. The `getLive*` prefix marks the first; for the other two the method's API reference names its source (`getMarket`, `getFills`, `getOpenOrders` are indexer reads; `getMarginAccount`, `getPerpState` are chain reads). The method name tells you which — and the tier tells you the freshness and the cost: | Tier | Methods look like | Backed by | Cost & freshness | | -------------- | --------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------ | | **Live store** | `getLive*` (synchronous) | in-memory materialization of **watched markets** | zero round-trips, current to the last block | | **Chain** | `get*Onchain`, `getBinaryOrderBook`, balances | `eth_call` over the socket | one round-trip, current to head | | **Indexer** | `list*`, `getCandles`, `getPortfolio`, … | GraphQL (HTTP) | one or bounded multiple round-trips; lags the chain slightly | Rule of thumb: **anything that gates an action reads the live store or the chain; the indexer is for history and aggregates.** Indexer reads throw on request failure — an empty result always means "no rows", never "it broke". ## Errors — branch on the type, never the message Every failure the SDK raises is a typed class exported from the barrel, all extending `SomniaMarketsError`. Catch the family, or branch on the member: ```ts import { ContractRevertError, IndexerError, IndexerWaitTimeoutError, NotConfiguredError, } from "@somnia-chain/markets-sdk"; try { await exchange.createOrder("BTC-95000-31DEC26/USDC#YES", "limit", "buy", 25, 0.62); } catch (e) { if (e instanceof ContractRevertError) { // The chain rejected it. `errorName` is the contract's OWN error. if (e.errorName === "ExpiredOrderMustBeCancelled") await cancelFirst(); else if (e.errorName === "InsufficientBalance") await topUp(); else throw e; } else if (e instanceof IndexerError) { retryLater(); // the read didn't complete — never "no rows" } else if (e instanceof NotConfiguredError) { throw new Error(`missing config: ${e.what}`); // a deploy/config bug, not runtime } else throw e; } ``` Handle an indexer wait separately so the timeout branch wraps the method that can actually raise it: ```ts const targetBlock = 1_234_567n; // the confirmed transaction's block try { await exchange.client.waitForIndexedBlock(exchange.client.config.chain.id, targetBlock); } catch (e) { if (e instanceof IndexerWaitTimeoutError) { retryLater(); // the indexer answered but has not processed targetBlock yet } else if (e instanceof IndexerError) { retryLater(); // the metadata read did not complete } else throw e; } ``` | Class | Means | Your move | | ------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------- | | `InvalidInputError` | Bad argument, unknown symbol, wrong market kind (thrown before any I/O) | Fix the call | | `NotConfiguredError` | A needed address/URL was never passed (`what` names it) | Fix the config | | `NotSupportedError` | The call is valid, but this SDK version does not support it yet (`feature` names it) | Use a supported option, or upgrade the SDK | | `SignerRequiredError` | The method writes; this client has no signer | Construct with a signer | | `CollateralOnWrongChainError` | `chainId`, `code`, `needed`, `held`, `shortfall`, `elsewhere`, `plan?` | The order's collateral sits on another configured chain | | `BridgeLiquidityError` | A native route's destination router cannot pay out; nothing was sent | Wait for the router to be seeded / bridge less | | `BridgeDeliveryTimeoutError` | A bridge was sent but its message was not delivered in time; `messageId` names it | Check `delivered(messageId)` before sending again | | `IndexerWaitTimeoutError` | The indexer stayed behind the requested block until the deadline | Wait longer / read chain | | `IndexerError` | An indexer read did not complete | Retry / degrade | | `IncompleteHistoryError` | Portfolio analytics could not read every indexed fill | Raise or omit `maxFillPages` / investigate a cycle | | `IncompletePortfolioError` | `getClaimable` observed more than 200 nonzero positions | Use `getClaimablePage` and follow `nextOffset` | | `IndexerUnavailableError` | The SDK refused the read locally — `reason` says which gate | Branch on `reason` (see its TSDoc) | | `AccountViewError` | The indexer's account-view answer is not one complete account — `reason` says why | Retry `snapshotUnstable`; retry the rest later | | `FillHistoryUnavailableError` | The indexer has no initialized fill-dataset generation | Deploy and reindex a compatible producer | | `PerpPositionChangesUnavailableError` | The indexer predates perp position changes | Show each fill's own side; reindex the indexer | | `FillHistoryRestartRequiredError` | A fill-page cursor belongs to another serving generation or predates generation binding | Discard pages; restart at page one | | `AccountsIndexerUnavailableError` | The accounts indexer is rate-limited or overloaded on a smart-account read — `retryAfterMs` | Wait `retryAfterMs` (or back off) and retry | | `AccountFeedRestartRequiredError` | A smart-account feed cursor predates a rebuild of that chain's accounts index (`chainId`) | Discard pages; restart without a cursor | | `RpcError` | A node request did not complete, or the node refused a transaction | Read: retry / switch RPC. Write: check the chain first | | `TransactionOutcomeUnknownError` | A signed write may have reached the node; its receipt was not observed (`hash`) | Look up `hash` before sending again | | `ContractRevertError` | The chain rejected the call — `errorName` + `args` | Branch on `errorName` | | `InvariantError` | The SDK contradicted an invariant established by its own control flow | Report the SDK defect | Three things worth knowing: - **Revert names come from the contracts.** The SDK bundles every custom error the protocol declares and decodes revert data against it, so a failed write reports `MarketNotSettled` rather than hex. When the data matches nothing known you still get a `ContractRevertError`, with the raw `reason`/`data` preserved. - **A mined-but-reverted transaction throws.** You don't need to check `receipt.status` yourself; the SDK replays the call to recover the reason first. - **`cause` is always chained.** The original viem/fetch error is there for logs. - **Never match on `error.message`** — messages are not a stable API. Branch on the class and its fields (`errorName`, `what`, `operation`). ## Watches — scoped, ref-counted live data Nothing is streamed by default. You open a **watch** on what you care about, and the client becomes a local indexer of exactly that scope: ```ts const watch = await client.watchMarket(pool); // one market: book, fills, orders, status const book = client.getLiveBinaryOrderBook(pool); // synchronous, zero round-trips, last-block fresh // ... trade against it ... watch.stop(); // release (ref-counted, brief linger) ``` - [`watchMarket(pool)`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#watchmarket) — hydrates a consistent snapshot of that market (its row, recent fills, full resting book) and streams its events from the chain WebSocket. Resolves once reads are live. This is the right default: cost scales with what you watch, not with the protocol. - [`watchMarkets({ discover? })`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#watchmarkets) — every SPOT and PERP market and every BINARY market that is not finalized, for list views and multi-market bots. One snapshot hydrates at most 500 of them, newest first; when more exist, `getLiveStatus().allMarketsTruncated` is `true`. Read a finalized BINARY market with `getBinaryMarket(id)`. `discover: true` also watches the creation events (the `MarketCreator` factory AND `BinaryMarketsModule.createMarket`) so newly created markets — rolling-series or module-created — join live. - [`watchUser(user)`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#watchuser) — hydrates one account's order/fill _history_ (live events are attributed to every account automatically within watched markets). - [`getWatchStatus(pool)`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#getwatchstatus) — `"unwatched" | "hydrating" | "live"`; this is how you distinguish "the book is empty" from "I'm not watching this pool". Watches are **ref-counted**: two watchers of the same pool share one snapshot and one subscription, and a released scope lingers ~30s before teardown so quick re-watches don't re-snapshot. If the socket drops, watches heal themselves — resubscribe with backoff, then backfill the missed blocks straight from chain (the indexer is only ever touched to hydrate a _new_ scope). While a market is watched, these answer synchronously with zero round-trips: [`getLiveBinaryOrderBook`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#getlivebinaryorderbook) / [`getLiveSpotOrderBook`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#getlivespotorderbook) (the resting book — **the one to render or quote against**), [`getLiveFills`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#getlivefills) (tape), [`getLiveUserFills`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#getliveuserfills) / [`getLiveUserOrders`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#getliveuserorders) (one account's activity), [`getLiveMarkets`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#getlivemarkets) / [`getLiveMarketByPool`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#getlivemarketbypool) / [`getLiveMarketByAddress`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#getlivemarketbyaddress) (market rows with live status + stats), and [`getLiveStatus`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#getlivestatus) (global health). Reactivity without React: [`subscribeLive`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#subscribelive) fires after every batch of changes — re-read whatever you need inside the callback: ```ts const watch = await client.watchMarket(pool); client.subscribeLive(() => { const { yesBids, yesAsks } = client.getLiveBinaryOrderBook(pool, { depth: 5 }); requote(yesBids, yesAsks); // runs within ms of the on-chain event }); ``` ## React Provide the client once; the hooks read it from context and **watch automatically** — rendering a market's book is what subscribes to it, and unmounting releases it: ```tsx import { SomniaMarketsProvider, useLiveBinaryOrderBook, useLiveFills, useWatchUser, } from "@somnia-chain/markets-sdk/react"; function App({ children }) { return {children}; } function Terminal({ pool, account }: { pool: string; account?: string }) { useWatchUser(account); // hydrate the user's history once const book = useLiveBinaryOrderBook(pool, 11); // auto-watches `pool` while mounted const tape = useLiveFills(pool, 40); // shares the same watch (ref-counted) // ... } ``` The live-store hooks: `useLiveBinaryOrderBook`, `useLiveBinaryOrderBookByMarket`, `useLiveSpotOrderBook`, `useLiveFills`, `useLiveUserFills`, `useLiveUserOrders`, `useLiveFundingUpdates`, `useLiveMarketByPool`, `useLiveMarketByAddress`, `useLiveMarkets`, `useLiveStatus`, `useIsTailing`, plus the explicit `useWatchMarket` / `useWatchUser` and `useSomniaMarketsClient`. The price (`useWatchPrice`, `useLivePrice`, `useLivePriceTicks`, `useLivePriceFeedInfo`), funding (`useFundingRateSeries`), lend (`useLendReserves`, `useLendAccount`) and directory (`useMarketCreators`, `useOracleAdapters`) hooks are listed in the API reference. Every pool-keyed data hook holds a ref-counted watch on its pool while mounted; `useWatchMarket` also returns the pool's `WatchStatus` for loading UI. For the **indexer** tier (history + directories, not the live store) there is a generic engine hook — `useIndexerQuery(fn, deps)` runs `fn(client)` on mount and whenever `deps` change, tracks `{ data, loading, error, refetch }`, and discards stale responses: ```tsx const { data: markets } = useIndexerQuery((c) => c.listBinaryMarkets({ limit: 20 }), []); ``` Concrete wrappers over the common reads: `usePortfolio`, `useMarkets`, `useCandles`, `useMarketFees`, `useOperators`. Separately, `useLiveMarkets` is the zero-round-trip live-store view of every watched market (pair it with a discovery watch), not an indexer fetch. ## Chain reads — head-fresh, no watch required One `eth_call` round-trip over the client's socket (concurrent calls pipeline): - [`getMarketOnchain`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#getmarketonchain) — a BinaryMarket's full wiring + state. **Authoritative for write eligibility** (status, resolution), and works before the indexer has ever seen the market. - [`getBinaryOrderBook`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#getbinaryorderbook) / [`getSpotOrderBook`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#getspotorderbook) — the book from the contract; the one-shot fallback (or checksum) for the live variants. - [`getErc20Balance`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#geterc20balance) / [`getNativeBalance`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#getnativebalance) — raw balances (use these, not indexer balances, to gate a write). - `getBalances(tokens, account)` — batch multi-token balance read (one round-trip for a whole wallet); `getOutcomeBalance({ outcomeToken, account, id })` — a single ERC-6909 outcome-id balance (the on-chain point read behind the indexer's `getOutcomeBalances`). - `getErc20Metadata(token)` / `getErc20Allowance(token, owner, spender)` — token name/symbol/decimals and a spender allowance (gate an `approve` on it). - `getContractMeta(address, { proxy })` — contract/proxy introspection; and `getMaxVenueFeeBps()` — the protocol's hard fee-rate cap (bps). - Perp margin health — `getMarginAccount` (now including `imReq`/`mmReq`/`cmReq`/ `marginStatus`), `getAccountHealth`, and `getLiquidationPrice` (all `MarginBank` reads); see [perps](./perps). - `getVaultBalance({ vault, owner, token })` — the LIVE claimable balance behind the append-only `getVaultPayoutFallbacks` credit log (vault credits emit no event, so the current balance must be read from chain). - [`getHeadBlock`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#getheadblock), [`getStopOrderSomiPayment`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#getstopordersomipayment), [`getSystemInfo`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#getsysteminfo). ## Indexer reads — history and aggregates These are the only methods that touch the indexer besides a watch's initial snapshot. Most point and list reads below make one GraphQL round-trip; `getTradeContext` makes two because its second query depends on the first query's fill. The Ops section identifies the other exception: `waitForIndexedBlock` repeats a metadata query until the indexer reaches the target block or the wait deadline expires. - Markets: [`listMarkets`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#listmarkets), [`getMarket`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#getmarket), and the binary-narrowed [`listBinaryMarkets`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#listbinarymarkets) / [`listLiveBinaryMarkets`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#listlivebinarymarkets) / [`listPastBinaryMarkets`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#listpastbinarymarkets) / [`getBinaryMarket`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#getbinarymarket). - History: [`getCandles`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#getcandles) (OHLCV, chart-ready), [`getFills`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#getfills)`(pool, { market })` (a pool's fills; pass `market` on binary to read one market's fills), and the account-scoped `getOrders(owner, opts)` (an owner's order history → `OrderRow[]`), `getUserFills(account, { market })` (an account's fills → `FillRow[]`; scope by `market` rather than `pool` on binary, where one pool is recycled across successive markets), and `getMarketStatusHistory(marketId)` (a market's lifecycle transitions). - Single-entity detail (the explorer's order/fill pages run on exactly these): [`getOrder`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#getorder)`(pool, orderId)` — one order with owner + full lifecycle attribution (`OrderDetail`; ids never reuse, so the pair is a permanent name), [`getFill`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#getfill)`(id)` — one fill by its `${block}_${logIndex}` id with both parties' order linkage (`FillDetail`), and [`getOrderFills`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#getorderfills)`(pool, orderId, { limit })` — every fill one known order participated in, either side, as a bounded whole-history lookup. It accepts only `limit` and does not take `sinceBlock`; resume a pool or account tape by passing `sinceBlock` to `getFills` or `getUserFills`. All three embed a `MarketRef` (symbols + decimals + routing identity) so a detail view renders from one read. - Every fill of one market kind in a BLOCK RANGE, across every pool of that kind: [`getFillsPage`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#getfillspage)`({ marketType, sinceBlock, toBlock })`. The read behind a published trade feed: it takes no pool, both bounds include their block, and rows arrive oldest first by `(blockNumber, logIndex)`, so successive ranges tile the chain exactly where a `since`/`until` window in seconds cannot. [`getLatestFillBlock`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#getlatestfillblock)`(marketType)` is the head marker to bound it with — read from the fill data, so it never names a block the range read cannot serve. - ONE transaction in full: [`getTransactionActivity`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#gettransactionactivity). The same event union as `getMarketActivity`, selected by transaction hash and ordered forwards, plus the orders placed, the fees paid and the markets touched. Empty for a hash that touched no protocol contract. - ONE trade IN CONTEXT: [`getTradeContext`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#gettradecontext). `getFill` above names one fill and its market in a single query; this adds the surrounding context — both orders resolved, the fees its transaction charged, and that transaction's other fills — in two GraphQL round-trips. Returns `null` for an unknown id. Use it behind a trade detail page that shows the counterparties; use `getFill` when rendering the trade alone. - One market's whole transaction history: [`getMarketActivity`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#getmarketactivity). It reads what `getFills` reads and four more streams — complete-set mints and merges, redemptions, oracle resolution, lifecycle transitions — and returns them interleaved by time as one discriminated union, in one round-trip. Use it for a market page's activity panel; use `getFills` when only trades matter. Every row names its transaction hash. A spot or perp market yields `TRADE` rows only, because the other four streams read binary-only entities. - Wallet views: [`getPortfolio`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#getportfolio) (binary positions + orders + trades in one round-trip), [`getSpotPortfolio`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#getspotportfolio), [`getSpotStopOrders`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#getspotstoporders), [`getOpenOrders`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#getopenorders), [`getOutcomeBalances`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#getoutcomebalances). - Control plane: [`listOperators`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#listoperators) / [`getOperator`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#getoperator) / [`listVenues`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#listvenues) / [`getVenue`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#getvenue) — the indexed MarketsCore operator/venue directory (venue ids are opaque bytes32; decode a BINARY_V1 venue's fee bytes with `decodeBinaryVenueFeeParams`). - Fee / resolution / router history (binary): `getMarketResolution`, `getRouterActions`, `listProtocolFees` / `listBuilderFees` / `listSettlementFees` (per-fill streams behind `getMarketFees`, filterable by `payer`), `listBuilderApprovals`, `getVaultPayoutFallbacks` — see [binary markets](./binary). - Perp account plane: `getFundingPayments`, `getMarginEvents`, `listLiquidations`, `listPerpPositionChanges`, `listPerpPositionChangesForFills`, `listFundingRateHistory`, `listFundingRateCandles`, `getOpenInterestHistory` — the append-only history the chain doesn't expose; see [perps](./perps). - Lookups + totals: `getMarketByPool` (resolve a market by pool address) and `listMarketsByPool(pool)` (every market the pool has HOSTED, newest first — a binary pool is recycled across successive markets, so this is its full history; also `getSeries(creator, seriesId)` for one rolling series' current spec, the single-row sibling of `listSeries`), `countOrders` / `countUserFills` (history-page totals), and the board totals `countMarkets` / `countBinaryMarkets` / `countVenues` / `countOperators` (each takes the same filter as its `list*` sibling — use for pagination headers). `CANDLE_INTERVALS` is the exported bucket list `getCandles` accepts. - One account's complete open orders and pending stops: `client.accountOrders.getSnapshot(account)`. It reads the indexer's account view as a guarded snapshot: two version reads bracket the row read, so the rows are complete at the version they name. When they cannot be, the result says why (`unavailable` or `tooLarge`); it is never an empty account. It sends four GraphQL requests, more when the account changes during the read, and one more for the stop pools whose type the client does not know yet. A perp LIMIT stop's limit price comes from the chain. `client.accountOrders.subscribe(account, onView)` keeps the same view live: it starts from that snapshot and applies the account's changes from the indexer's change stream, over one GraphQL WebSocket per client. Its stops also follow the stop registries' events over the chain socket, ahead of the indexer. See [Read tiers](./reference/read-tiers) and [Follow an account's open orders](./how-to/follow-an-account). - One account's open perp positions, resting perp orders and margin in one MarginBank: `client.accountPositions.getSnapshot(marginBank, account)`. It reads the chain, not the indexer: the MarginBank's own list of the account's pools, each position, its pool's price and health, the account's resting orders in those pools, and the account's collateral and health, all at one block. That is up to three Multicall3 calls, or one once a read has named the pools and orders, plus one order id call per pool, sent as the account. A figure the chain cannot give is `unknown`, never zero. `client.accountPositions.subscribe(marginBank, account, onView)` keeps it live. After the first read, the Somnia node pushes that same read (`somnia_watch`) when the MarginBank emits an event naming the account, and on every price write while the account holds positions, so marks and PnL follow the price. The price trigger needs `addresses.priceFeedScheduler`. A node that does not push leaves the SDK reading on the account's events and every 5 s. See [Follow an account's open orders](./how-to/follow-an-account). - One account's resting spot orders: `client.accountSpotOrders.getSnapshot(account)`. It reads the chain, not the indexer: every spot pool's own list of the account's orders, one call per pool sent as the account, then the orders' details in one Multicall3 call, all at one block. Which pools are spot pools comes from the indexer's market list. A pool that cannot be read is in `data.unreadPools`, with its error, and its orders are unknown, not absent; a read that can read no pool fails. `client.accountSpotOrders.subscribe(account, onView)` keeps it live: it follows every placement in the spot pools and the later events of the account's own orders over the chain socket, reads again the pool an event concerns, and reads every pool 30 s after it last read them all. See [Follow an account's open orders](./how-to/follow-an-account). - Several accounts' wallet balances: `client.accountBalances.getSnapshot(accounts, tokens)`. It reads the chain: each token's `balanceOf`, and the native balance for `"native"`, all at one block in one Multicall3 call. A balance the chain cannot give is `unknown`, never zero. `client.accountBalances.subscribe(accounts, tokens, onView)` repeats that read when one of the tokens emits a `Transfer` from or to one of the accounts, and every 10 s. See [Follow an account's open orders](./how-to/follow-an-account). - Ops: [`getSyncStatus`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#getsyncstatus) — how far behind the indexer itself is, and [`waitForIndexedBlock`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#waitforindexedblock) — poll until it has processed a block, so a write can be read back from the index rather than after a guessed delay. Each poll sends one GraphQL round-trip. Two of these have faster live twins a trading loop should prefer: `getOpenOrders` → `getLiveUserOrders`, `getFills` → `getLiveFills`. ## The network tape — every order on the network, live ```ts const tape = client.createNetworkTape(); const stop = tape.subscribe(() => { render(tape.bids, tape.fills, tape.asks, tape.getStatus()); }); // … stop() closes the socket when the last listener unsubscribes. ``` [`createNetworkTape`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#createnetworktape) returns a [`NetworkTape`](https://prd.smk.somnia.host/docs/typescript/api/index/classes/NetworkTape) — a network-WIDE order-flow firehose: one topics-only chain-log subscription that sees `OrderPlaced` / `OrderFilled` from **every** pool (including pools created after it starts), no indexer on the hot path. It is not the live tail: no books, no snapshots — three newest-first ring buffers (`bids` / `fills` / `asks`) plus a session `(pool, orderId) → owner` map that attributes fills (takers always resolve — their placement follows the fill in the same tx; makers resolve when quoted while the tape ran, else `null`). Rows carry RAW units; join to `listMarkets` for symbols/decimals. Nothing connects until the first `subscribe`, and reconnect/stall handling (Somnia's silently-dying WS subs) is built in. For enriching a tape row's transaction, [`getTransactionSummary`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#gettransactionsummary)`(hash)` is the chain-direct companion: sender, gas used/limit, effective gas price, fee paid, status. It resolves to null for a malformed hash and for a transaction the node reports as not found. A read that does not complete throws `RpcError`, so an outage stays distinct from a transaction that never landed. ## Writes — `createTrader` ```ts const trader = client.createTrader({ privateKey }); // or { walletClient } in the browser const { orderId, fills } = await trader.placeOrder({ pool, side: "BUY_YES", price, quantity }); ``` [`createTrader`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#createtrader) binds a signer to this client's chain, fees, and socket and returns a [`Trader`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/Trader). The trader is **independent of the watches** — a pool's escrow tokens resolve from the pool contract itself (one cached read) when you don't pass them. With a local key the SDK signs locally with fixed fees and a fixed gas ceiling. Before each local send, it reconciles the pending nonce and serializes nonce consumption, signing, and broadcast acceptance. Some orders also need prerequisite funding reads or an approval transaction. `eth_sendRawTransactionSync` returns the mined receipt; every write resolves only once mined, with that receipt (and, for orders, the decoded `orderId` + `fills`). Market-kind specifics: [binary markets](./binary#trading), [spot](./spot#trading). Every write result also carries `executionTimeMicros` when the node reports it. It is the time in microseconds from the node receiving the send to its answer, and it covers mempool, finality and execution inside Somnia. The local-signer and session-key executors read it from the receipt that `eth_sendRawTransactionSync` and `somnia_sendSessionTransaction` return. It is absent on an older node, on the `eth_sendRawTransaction` fallback, and for a wallet-client send. A custom executor can set it on `Executed` to pass it on. For the MarketsCore control plane there is a second, low-frequency write tier: `client.createOperatorAdmin({ privateKey })` returns an `OperatorAdmin` with `registerOperator`, `updateOperator`, `createVenue` (venues are typed by a `bytes4` market-type id — `MARKET_TYPE_BINARY_V1` — and get a contract-generated `bytes32` venue id back), `updateVenue`, `setVenueEnabled`, and the two-step operator-ownership transfer. Build a BINARY_V1 venue's fee bytes with `client.encodeBinaryVenueFeeParams({...})`. ## Escape hatch [`getViemClient()`](https://prd.smk.somnia.host/docs/typescript/api/index/interfaces/SomniaMarketsClient#getviemclient) returns the underlying viem WebSocket client for any read the SDK doesn't cover — your own contracts, or plain calls like `getBalance` / `getCode` / `waitForTransactionReceipt`. Same socket, same pipelining: a client of your own would open a second connection, and one-socket-per-client is deliberate here. **It is undecorated, and that is the point.** Every read reachable from the client interface goes through a _wrapped_ viem client. Its `readContract`, `call`, `multicall`, and `getBlockNumber` methods rethrow as [typed SDK errors](https://prd.smk.somnia.host/docs/typescript/api/index/classes/ContractRevertError). A revert arrives decoded, as its Solidity name. The client this method hands you does **not** do that: reads keep viem's own error contract, so `e instanceof ContractFunctionRevertedError` and the rest of your existing viem error handling still work. Pick accordingly. For a _protocol_ contract, prefer the SDK's own methods — the decoding is the value, and on a contract the SDK doesn't bundle the decoder can only report `errorName: undefined` anyway. For anything else, this is the door. **It belongs to the current connection.** `stopLive()` and `close()` close the socket the client uses. After that, requests on a client you kept reject with viem's `SocketClosedError`; the client does not reconnect. Call `getViemClient()` again after `stopLive()` to get a client on the new connection. --- Next: [binary markets](./binary), [spot](./spot), [perps](./perps), and the [architecture guide](./architecture) for how the machine works inside. --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/architecture The SDK connects a configured `SomniaMarkets` owner to the main indexer, chain RPC, and a separate price-feed service. This page explains where live state comes from and which resources each capability uses. Market watches hydrate indexed snapshots and then apply chain events locally. This keeps repeated store reads synchronous, while startup, recovery, historical reads, and price feeds still have network costs. Watches limit materialization work to the scopes the application uses. - [System overview](#system-overview) - [The client](#the-client) - [Watches: the snapshot seam + chain materialization](#watches) - [Event routing and the reducer](#event-routing-and-the-reducer) - [The local order book](#the-local-order-book) - [Connection lifecycle](#connection-lifecycle) - [The write path](#the-write-path) - [Choosing a read](#choosing-a-read) ## System overview The exchange owns its `SomniaMarketsClient`. The main Envio/Hasura indexer supplies historical reads and market/user snapshots over HTTP GraphQL. The engine's lazy chain WebSocket carries its chain reads, log/head subscriptions, backfills, and sends. Price feeds use a separate Hasura service over HTTP and one shared Hasura socket per client and endpoint. Watches that start together hydrate in batched snapshot requests. Their `PriceStore` is separate from the market `MaterializerStore`. A subscribed network tape owns another chain socket. These capabilities do not share one universal realtime connection. ```mermaid flowchart LR subgraph app["Your app"] hooks["React hooks
useLiveFills, useLiveBinaryOrderBook, …"] node["Node / server code
bots, scripts, RSC"] end subgraph client["the engine (exchange.client)"] direction TB query["query.ts
one-shot indexer reads
listMarkets · getCandles · getPortfolio ·
getFills · getMarketResolution · getRouterActions ·
listProtocolFees · getFundingPayments · …"] reads["reads.ts / system.ts
one-shot chain reads
getBinaryOrderBook · getMarketOnchain ·
getAccountHealth · getLiquidationPrice ·
getVaultBalance · getErc20Balance · …"] subgraph live["live watches"] tail["liveTail.ts
watch registry + ingestion
ref-counted scopes · subscribe ·
backfill · seam"] reducer["reducer.ts
event → state
mirror of the indexer handlers"] store["store.ts
MaterializerStore
markets · orders · fills ·
book levels · status
versioned, memoized selectors"] end prices["priceFeed/
HTTP reads + asset watches
separate PriceStore"] tape["networkTape.ts
caller-owned activity tape
separate chain socket"] trader["trade.ts
writes
sign local · fixed fees ·
sync send"] end subgraph backends["Backends"] indexer["Envio / Hasura indexer
HTTP GraphQL
history · aggregates ·
cold-start snapshot"] feed["Price-feed Hasura
HTTP + one shared WebSocket"] chain["Somnia chain
engine WebSocket + separate tape sockets
eth_subscribe logs+heads ·
eth_call · getLogs ·
eth_sendRawTransactionSync"] end hooks -- "useSyncExternalStore" --> store node -- "getLive* (sync)" --> store node -- "await client.*" --> query node -- "await client.*" --> reads node -- "createTrader(…)" --> trader query -- "POST /v1/graphql" --> indexer prices -- "hydrate/fetch HTTP + subscriptions" --> feed hooks -- "price selectors" --> prices tape -- "own logs + heads subscription" --> chain tail -- "cold scope hydration
(again after teardown)" --> indexer tail -- "watchEvent · watchBlocks ·
getLogs (seam backfill)" --> chain reads -- "eth_call (pipelined)" --> chain trader -- "eth_sendRawTransactionSync
(send + receipt, 1 RTT)" --> chain tail --> reducer --> store ``` Key properties: - **Scoped.** Live materialization is opt-in per market via ref-counted watches. Cost — snapshot size, subscription filter width, event volume, reducer work — scales with what you watch, not with the protocol. - **One engine per chain, one exchange per account.** The engine tier (`SomniaMarketsClient`) is bound to exactly one chain: one socket, one indexer, one nonce lane. The exchange (`SomniaMarkets`) owns one engine per configured `ChainBinding`, merges reads across them, and routes each write to the chain its market lives on. A market is unique to one chain, so a symbol resolves to one `(chainId, market)` and every returned struct names its `chainId`. - **Isolation.** Every exchange owns its own config, stores, watches, and (lazily opened) sockets. Two exchanges never share state — per-request watches and parallel tests all just work. Two engines inside one exchange never share state either: each keeps its own nonce sequence and fee envelope. - **Shared engine chain transport.** Engine subscriptions, reads, backfills, and sends use its memoized chain transport. Parallel requests can overlap, but dependent reads, pagination, and cache misses still affect cost. Price watches and network tapes have separate sockets. - **Indexer startup dependency.** A new market or user scope needs indexed data to hydrate. After a successful market seam, chain events update that scope and chain backfill repairs reconnect gaps. A scope that was torn down needs hydration again. One-shot history and derived reads can continue to request the indexer while watches run. ## The client ```mermaid flowchart TB cc["new SomniaMarkets(config)"] --> ex["SomniaMarkets (exchange)"] -- ".client" --> pc["SomniaMarketsClient (engine)"] pc --> l1["live watches
watchMarket · watchMarkets · watchUser ·
getWatchStatus · stopLive · subscribeLive
getLiveStatus · getLiveMarkets · getLiveMarketByPool/ByAddress
getLiveFills · getLiveUserFills · getLiveUserOrders
getLiveBinaryOrderBook · getLiveSpotOrderBook"] pc --> l2["indexer reads (Promise, throw on failure)
listMarkets · getMarket · listBinaryMarkets · getBinaryMarket
getCandles · getFills · getMarketActivity · getOpenOrders · getOutcomeBalances
getPortfolio · getSpotPortfolio · getSpotStopOrders · getSyncStatus"] pc --> l3["chain reads (Promise)
getBinaryOrderBook · getSpotOrderBook · getMarketOnchain
getErc20Balance · getNativeBalance · getHeadBlock
getStopOrderSomiPayment · getSystemInfo"] pc --> l5["price feeds
fetchPrice · fetchPriceHistory · watchPrice
separate HTTP, Hasura sockets, and PriceStore"] pc --> l4["writes
createTrader({privateKey | account | walletClient | executor})
→ placeOrder · cancelOrder · placeSpotOrder ·
placeSpotStopOrder · mintSet · burnSet · redeem · …"] ``` The socket opens lazily on first chain I/O, so an indexer-only client (e.g. server-side GraphQL reads) never opens the engine chain socket. The mirror case holds too: `indexerUrl` is optional, so a chain-only client (smart accounts, executors, on-chain reads) constructs without one, and every indexer read or live watch on it throws `NotConfiguredError` naming the field. An individual chain read never makes an indexer request either way. ## Watches A market watch materializes its scope locally: it hydrates a consistent snapshot of that scope from Envio up to block X, then materializes X+1.. directly from chain logs. Scopes are ref-counted (two watchers of one pool share everything) and torn down after a short linger when the last handle stops. The seam is gap-free and double-count-free: ```mermaid sequenceDiagram autonumber participant App participant Tail as LiveTail (watch registry) participant Idx as Indexer (GraphQL) participant WS as Chain WebSocket participant Store as MaterializerStore App->>Tail: watchMarket(pool) Tail->>WS: eth_subscribe logs (+ pool) · newHeads Note over Tail,WS: the scope's logs BUFFER from this instant —
nothing past the head can be missed Tail->>Idx: scope snapshot (HTTP GraphQL) Note over Idx: the market row · recent fills ·
its full resting order set ·
chain_metadata.latest_processed_block = X Idx-->>Tail: rows consistent to block X Tail->>Store: mergeSnapshot (other scopes untouched) Tail->>WS: widen subscription (+ the market's
BinaryMarket address, now known) Tail->>WS: eth_getLogs [X+1 … head] for the scope WS-->>Tail: backfill logs Tail->>Store: decode → reduce → commit Tail->>Tail: replay the scope's buffered logs
(deduped by (block, logIndex)) Tail-->>App: watchMarket resolves — reads are live loop live — every block WS-->>Tail: pushed logs + head Tail->>Store: decode → reduce → commit (one notify per batch) Store-->>App: subscribers re-read (hooks re-render) end ``` `watchMarkets({ discover })` is the same seam with scope = every eligible market — SPOT, PERP, and BINARY markets that are not finalized, at most 500 per snapshot, with `allMarketsTruncated` set when more exist. When discovering, it also watches the creation-event sources: the MarketCreator factory AND the BinaryMarketsModule. `watchUser(user)` is snapshot-only (the account's past orders/fills — it opens no subscription of its own, and updates only from markets this client watches). Logs for a scope that is mid-hydration buffer in an inbox while other scopes keep applying live — one scope's seam never stalls another. Why this is safe on Somnia specifically: blocks have **instant BFT finality** — a delivered log is final, so there is no reorg handling. The dedupe set keyed by `(blockNumber, logIndex)` makes the buffer/backfill overlap harmless. Somnia logs carry no timestamp, so the `newHeads` stream doubles as the timestamp source (block number → timestamp map, pruned as the head advances). ## Event routing and the reducer One combined ABI (`liveEventsAbi`) decodes every watched log; the reducer routes by **event name + source address** and mutates the store exactly the way the Envio handlers mutate Postgres (`reducer.ts` mirrors `indexer/src/handlers/orderbook.ts` — keep them in lockstep): ```mermaid flowchart TB log["raw log from WS / backfill"] --> dec{"decodeEventLog
(liveEventsAbi)"} dec -- "source = a pool
(spot OR binary — byte-identical events)" --> ob["OrderBook events"] dec -- "source = a SpotPool" --> spot["spot-only events"] dec -- "source = MarketCreator /
BinaryMarketsModule" --> fac["MarketCreated"] dec -- "source = a BinaryMarket" --> bm["lifecycle events"] ob --> op["OrderPlaced → upsert order
(binary: side from isBid+userData, born Open;
spot: no side, born Closed)"] ob --> orst["OrderRested → rested=true
(spot: Closed → Open)"] ob --> ofill["OrderFilled → write LiveFill ·
patch maker order · bump market stats
(lastPrice, volumes, tradeCount)"] ob --> oc["OrderCancelled / OrderExpired /
OrderCancelledSelfMatch /
OrderReduced → patch order"] ob --> oip["OrderAmendedInPlace → overwrite price,
quantities, expiry, userData
(in-place pools; id kept)"] ob --> bk["SetMinted / SetBurned / Redeemed /
SettlementFeeCharged → market backing"] spot --> mp["MarkPriceUpdated → market.markPrice"] spot --> bp["OrderBookParametersUpdated →
tickSize · lotSize · minQuantity"] fac --> nm["build BinaryMarket row →
indexMarket → GROW the watch set →
catch-up getLogs for the creation range"] bm --> st["StatusChanged → status enum"] bm --> res["Resolved → status + winningOutcome"] bm --> vd["Voided → voided + status"] ``` Two ordering subtleties the reducer encodes: - **`OrderFilled` fires before the taker's `OrderPlaced`** in the same tx, so the taker is unknown at fill time. The fill stores `takerOrder_id` / `makerOrder_id` foreign keys; read-time selectors back-join owner + side from the order map (hydrated by the snapshot's open orders and live events). - **`MarketCreated` grows the discovery watch live.** Two creation events are watched. The `MarketCreator` factory rolls each series autonomously (a Somnia reactivity `Schedule` sub drains the due boundary's series in a gas-bounded loop, spilling to the next block when a batch is too large), emitting its 13-field `MarketCreated(marketId, market, pool, yesId, noId, collateral, asset, strike, tradingStart, expiry, oracleQuestionId, question, intervalSec)` — the trailing `intervalSec` is the series cadence (60 / 300 / 900 / 3600 / 14400 / 86400). The `BinaryMarketsModule` emits its own 20-field `MarketCreated` for EVERY market (rolled or created directly via `createMarket`) — the only creation event carrying the `(operatorId, venueId)` origin attribution — so module-created markets that never pass through a `MarketCreator` are discovered too. When an all-markets watch with `discover` is active, the tail re-subscribes with the new pool + market addresses and sweeps the creation range with a one-shot `getLogs`, so a market created seconds ago is fully tailed — the indexer is never asked again. ## The local order book The store _is_ the book. Every resting order is tracked through its lifecycle, so depth is an in-memory aggregation. Reading a hydrated book costs **zero round-trips** and reflects the events applied to that scope. Disconnection or recovery can leave it behind the head: ```mermaid flowchart LR subgraph store["MaterializerStore.orders"] o1["order: Open + rested
price 620000 · qty 50"] o2["order: Open + rested
price 620000 · qty 25"] o3["order: Open + rested
price 610000 · qty 10"] o4["order: Filled / Cancelled /
not rested → ignored"] end store --> agg["bookLevels(pool, depth)
group by (isBid, price) · sum qty ·
sort best-first · slice depth"] agg --> spotbook["getLiveSpotOrderBook
{ bids, asks }"] agg --> yes["YES-terms levels"] yes --> inv["toBinaryBook( )
NO side = 1 − yesPrice
(quantities carry over)"] inv --> binbook["getLiveBinaryOrderBook
{ yesBids, yesAsks, noBids, noAsks }"] ``` This matches the on-chain `getBookLevels` exactly because binary pools keep a _single_ book in YES terms — a NO order is expressed as `isBid`/`userData` with a YES-terms price, which is also how orders arrive in `OrderPlaced` events. The one-shot chain reads (`getBinaryOrderBook`/`getSpotOrderBook`) remain available as a separate source for callers that need a fresh node read. Stake and sell quotes also read a cached chain tick/lot grid and can resolve a missing pool through the main indexer; see [Read tiers](./reference/read-tiers). Completeness note: live-witnessed orders are always present; orders that rested _before_ the watch opened come from the scope snapshot's open-order set — and because snapshots are per-scope, that set is the market's whole resting book, not a global cap's share of it. ## Connection lifecycle The following state machine describes market live-tail subscriptions. It does not describe price feeds or network tapes. Main-indexer hydration uses HTTP. The live tail relies on pushed chain logs and heads, with chain backfill for recovery. The live tail checks stalled heads every 15 seconds. After a stall it can request the chain head and reconnect if the subscription missed progress or the probe fails. A subscribed network tape checks for stalls every 5 seconds and has its own chain socket and recovery. These timers add background work; the SDK does not guarantee zero polling. ```mermaid stateDiagram-v2 [*] --> idle idle --> hydrating: first watchMarket / watchMarkets hydrating --> live: scope snapshot + backfill + replay OK hydrating --> reconnect_wait: snapshot / backfill failed live --> hydrating: new watch opens (its scope only —
existing scopes keep streaming) live --> reconnect_wait: WS error or stalled-head probe detects failure reconnect_wait --> live: resubscribe + getLogs
[lastBlock+1 … head] — CHAIN ONLY,
the indexer is not consulted note right of reconnect_wait exponential backoff 500ms → 1s → 2s → 4s → 8s (cap) reset to 500ms once live end note live --> idle: last watch released
(after a ~30s linger) live --> live: blocks stream in ``` Reconnects never re-snapshot: the store already holds state to block L, so the seam is simply `getLogs [L+1, head]` deduped against what's processed — chain truth heals chain gaps. This recovery path does not request the main indexer. A new or fully released scope still needs hydration, and one-shot indexer reads remain independent. Price watches first hydrate over the price-feed HTTP endpoint, then subscribe on one shared Hasura socket per client and endpoint. Watches that start together hydrate in batched snapshot requests. They keep a separate store and connection status. A rejected subscription sets `getPriceStatus(asset)` to `error` and retains stale price values; it does not fail market watches. Price scopes also linger for about 30 seconds after the last handle stops. `exchange.close()` releases its watches and channels and stops the client's market and price live machinery. One-shot fetches remain usable. `exchange.unwatchMarket(symbol)` releases one market's watch and channels and keeps the exchange open. Its live-tail scope then lingers like any other. A network tape returned by `createNetworkTape` is caller-owned. Release each subscription with its returned unsubscribe function; the last release closes the tape socket. Its lifecycle is separate from the exchange watches. ## The write path The writer uses fixed gas limits. A sync send returns its receipt in one request. Fallback and wallet confirmations use heads plus a one-second idle probe, with a 60-second total deadline. Owner cancellation stops these waits. A missing receipt remains a hash-bearing unknown outcome; check the hash before retrying. ```mermaid sequenceDiagram autonumber participant Bot as Caller participant T as Trader (trade.ts) participant WS as Chain WebSocket rect rgb(235, 245, 235) Note over Bot,WS: LOCAL SIGNER (privateKey / account) — the fast path Bot->>T: placeOrder({pool, side, price, quantity}) Note over T: resolve funding requirements
uncached ERC-20 approvals may read + send;
native spot sells read the current vault shortfall Note over T: reconcile pending nonce;
serialize nonce consumption, signing,
and broadcast acceptance;
use fixed fees + fixed gas ceiling T->>WS: eth_sendRawTransactionSync(signedTx) Note over WS: node executes, blocks server-side
until the receipt exists WS-->>T: receipt WITH logs Note over T: decode OrderPlaced → orderId
decode OrderFilled → fills[] T-->>Bot: { hash, receipt, orderId, fills } end rect rgb(240, 240, 250) Note over Bot,WS: BROWSER SIGNER (injected walletClient) Bot->>T: placeOrder(…) T->>WS: eth_sendTransaction (wallet signs, fixed fees pinned) WS-->>T: tx hash T->>WS: getTransactionReceipt (immediate check) Note over T,WS: on miss: eth_subscribe newHeads —
re-check on each pushed head, first hit wins.
Idle probe after 1 s; total deadline 60 s.
Failure retains the transaction hash. WS-->>T: receipt T-->>Bot: { hash, receipt, orderId, fills } end ``` An uncached (token, spender) pair does one `allowance` read. A short allowance causes one `approve(maxUint256)` transaction and then enters the cache. An existing effectively unlimited allowance also enters the cache. A finite allowance remains uncached because an order can consume it. The next order then reads the pool requirement and allowance again. Before each local write, viem reconciles the node's pending nonce with its in-memory offset. The trader serializes local nonce consumption, signing, and broadcast acceptance. One failed write therefore cannot invalidate a concurrent write's nonce reservation. A signer or broadcast failure resets the manager only after every earlier local write has reached a terminal send result. Measured on Somnia testnet with the `sdk-e2e` write suite (`E2E_WRITE=1`, which writes a gitignored `latency-report.md`): `placeOrder` confirm p50 ≈ **503ms**, within ~25ms of the raw transport floor. ### Spot funding preconditions The approval cache records only effectively unlimited allowances. A finite allowance is never cached, so the next order reads it again after the current order may have consumed it. Spot orders ask the pool for its exact fee-aware funding requirement when an approval check is needed. A native-base sell does that read every time so it can send only the current vault shortfall. ## Choosing a read Read selection depends on both the source and its startup state. [Read tiers](./reference/read-tiers) contains the canonical Engine inventory, request costs, and method-specific exceptions. | You want | Use | Source and freshness | | --------------------------------------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | A book for frequent rendering | `getLiveBinaryOrderBook` / `getLiveSpotOrderBook` / hooks | Main-indexer hydration, then applied chain events. Synchronous after startup; inspect watch status during recovery. | | Live trade tape or user orders | `getLive*` / hooks | Market store. User hydration adds history; updates cover watched markets only. | | Market state from the node | `getMarketOnchain` | Chain RPC at the block served by the node. | | Candles, portfolios, historical fills, or market activity | `getCandles` / `getPortfolio` / `getFills` / `getMarketActivity` | Main indexer; delay is variable. | | A market list without watches | `listMarkets` / `listBinaryMarkets` | Main indexer; indexed rows can lag discovery. | | Raw balances or wiring | `getErc20Balance` / `getSystemInfo` | Chain RPC; composite reads may make multiple calls. | | Oracle price history or streaming prices | `fetchPriceHistory` / `watchPrice` / price hooks | Separate price-feed Hasura service and PriceStore; values can be stale during errors or disconnection. | Derived reads can combine sources. For example, Exchange `fetchTicker` uses cached market metadata, indexed candles, and best-effort perp chain state. Both the candle read and the perp read are contained, and `statsState` and `perpState` report which of them answered. Its timestamp is response time. Engine `getOpenPositionsWithPnL` and `getClaimable` use indexed inputs, so neither guarantees current chain state. Choose an action's inputs with these limits in mind. React's `useIndexerQuery` invokes a caller-supplied callback on mount, owner/dependency changes, and `refetch`. It does not poll or automatically refresh on live events. The callback chooses the source; the hook does not certify freshness. It passes an abort signal and ignores superseded results, while request cancellation depends on the callback forwarding the signal. --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/migrating This guide explains breaking SDK changes and the required migration steps. Each entry lists affected consumers in this repository. The same steps apply to external consumers that use those features. Only breaking changes appear here. Everything else is in the [changelog](./release-notes). `0.31.0` was published to public npm on September 29, 2026. The `0.32.0` series is available as prereleases. A consumer moving from `0.30.0` to a `0.32.0` prerelease applies both sections below, in order. Check the target version in the [changelog](./release-notes); prereleases can add migrations before the plain `0.32.0` release. ## 0.30.0 → 0.31.0 This section has fifteen entries. Entries 1 to 12 cover the twelve breaking changesets queued for 0.31.0. Entries 13 and 14 cover two breaking changes that the 0.31.0 changelog did not mark as breaking. Entry 15 is a note, not a breaking change: it corrects earlier documentation about nonce handling. Three entries need attention even if your code compiles: - Registry read failures no longer throw from `loadMarkets()` (entry 5). - `getCandles` selects a field that does not exist on an indexer deployed before this release. **Reindex before you upgrade** (entry 8). - A `walletClient` adapter that implements only `writeContract` fails every write (entry 13). ### 1. Executor calls and wallet errors **Was:** `{ to, data, value, description }`. **Now:** `{ chainId, to, data, value, description, functionName? }`. Add the target `chainId` when you construct a call. SDK `build*` methods already set it. Built-in executors throw `InvalidInputError` before signing if the call names another chain. `functionName` is optional and supplies context for error messages. SDK-built calls include it. ```ts // before const call = { to: pool, data, value: 0n, description: "place" }; // after const call = { chainId: 50312, to: pool, data, value: 0n, description: "place" }; ``` **Affected:** custom relayers, bundlers, and fixtures that construct calls. The `call()` fixture in `packages/sdk/test/executor.test.ts` constructs these objects and already includes `chainId`. #### Wallet-client pre-send rejections **Was:** `"writeContract"`. **Now:** `"sendTransaction"`. The wallet executor sends an encoded transaction. Update checks for its operation name to `"sendTransaction"`. The error class and other fields are unchanged. **Affected:** code matching `err.operation === "writeContract"` for a wallet rejection. **No consumer in this repository branches on `RpcError.operation`.** ### 2. `BinaryMarket.strike` and `BinaryMarket.mode` are nullable **Was:** both always present. **Now:** both `| null`. Chain-seeded rows do not supply these fields. They can be `null` when you use `watchMarket(pool, { seed: "chain", marketId })`. Indexer rows still supply both. Check for `null` before use. The type change applies even if you do not use chain seeding. **Affected:** code reading `BinaryMarket.strike` or `.mode`. In this repository, the explorer uses them in `src/lib/seriesQueries.ts` and `src/components/clob/SeriesHistory.tsx`. ### 3. `FillRow` gains two required fields **Was:** no remainder fields. **Now:** `takerRemainingQuantity` and `makerRemainingQuantity`, both required. `getFills` and `getUserFills` return the quantities remaining after a fill. Add both fields to fixtures, mocks, or other code that constructs a `FillRow`. Code that only reads rows needs no change. **Affected:** in this repository, **the explorer** (`src/lib/orderFill.ts`) and **sdk-e2e** (`test/fills.test.ts`, `test/query.test.ts`). All of these read rows rather than build them, so they are affected only if they pin the row shape. ### 4. Failed reads throw instead of reporting absence Four reads now throw for transport failures instead of returning absent or zero values. Transport failures raise `RpcError`. Contract reverts raise `ContractRevertError` except for the documented capability checks below: - `getClosingPrice` — `null` means the pool does not declare `closingPrice()`. Catch errors to handle failed reads. - `tryGetPerpLeverageImSurcharge` — `null` only for a revert from the view itself. In a health sweep, catch per account and report that account as unknown rather than as having no surcharge. - `getSystemInfo` — configured contract read failures now throw instead of returning zero values. For pre-v2 modules without `settlement()`, the result uses the configured `binarySettlement` address, or `null` if none is configured. These fields are no longer nullable: `MarketCreatorInfo.owner`, `SystemInfo.oracle.owner`, `SystemInfo.oracle.binaryModule`, `SystemInfo.usdc.symbol`, and `SystemInfo.usdc.decimals`. Keep null checks on the `marketCreator`, `oracle`, and `usdc` objects: they remain `null` when their contracts are not configured. - `getTransactionSummary` — `null` only for a malformed hash, or a transaction the node reports as not found. To keep the previous best-effort behavior, add `.catch(() => null)` at the call site. `PriceFeedStatus` gains `"error"` for subscriptions the server rejects or terminates. Previously, the status stayed `"live"` with a stale price. Reads still return the last known values, so check the status before displaying them. Handle `"error"` in `getPriceStatus` and `useWatchPrice` consumers. The debug channel also emits a `warn`. Restart the watch to request a fresh snapshot. ### 5. Registry read failures use `registryReadError` **Was:** `loadMarkets()` threw when the registry read failed. **Now:** it reports the failure through `registryReadError`. This lets chain-based perp discovery continue when a factory is configured. Check the accessor after loading. Pass `true` to retry instead of using the cached registry. ```ts await exchange.loadMarkets(); if (exchange.registryReadError) { // Force a registry refresh. await exchange.loadMarkets(true); } if (exchange.registryReadError) throw new Error("market list may be short"); ``` Replace `try`/`catch` checks for registry outages with an accessor check. Without it, a failed registry read can look like a successful load. **Affected:** every `loadMarkets()` caller. In this repository: **market-taker** (`src/shared.ts`, called from `src/index.ts`) and **sdk-e2e** (`test/unified.test.ts`). For the four reads, any caller treating `null` or `0` as "failed" needs to catch instead. ### 6. Registry rows require `updatedAtBlock` **Was:** `IndexedOperator` and `IndexedVenue` had no block marker. **Now:** both require `updatedAtBlock: bigint`. Add this field to fixtures, mocks, or adapters that construct registry rows. Use `0n` in a fixture when its block number is irrelevant. The operator and venue list/get methods already supply it, so code that only reads rows needs no change. The marker tracks control-plane events, not trading rollups. Updates in the same block share a marker. A malformed wire value fails the whole read with `IndexerError`. **Affected:** code that constructs either row type. In this repository, `packages/sdk/test/registryRowUpdateBlock.test.ts` covers the field. Check external adapters and mocks for object literals missing it. ### 7. Fill reads gain a block range, and a cross-pool fill read **Was:** `getFills(pool, …)` read one pool and `getUserFills(account, …)` read one account across pools, both bounded by `since`/`until` in seconds. No read returned every fill of a market kind. **Now:** `client.getFillsPage({ marketType, sinceBlock, toBlock })` is that read — every fill of one kind in an inclusive block range, across every pool, oldest first by `(blockNumber, logIndex)`. It takes no pool, so a consumer never enumerates them. The existing reads gain the same bounds through `FillsOptions.sinceBlock` and `.toBlock`. Both block bounds include their block, and `(blockNumber, logIndex)` totally orders the fill table, so `[a, b]` followed by `[b + 1, c]` reads every fill exactly once. Prefer the block range whenever successive reads must tile the chain: several Somnia blocks share a timestamp, so a seconds window drops or repeats the fills in them at every seam. An inverted range now rejects with `InvalidInputError` rather than answering with no rows. `client.getLatestFillBlock(marketType)` names the newest block carrying an indexed fill of that kind. It reads the fill data rather than the indexer's processed-block watermark, so it never returns a block `getFillsPage` cannot yet serve. Page through `nextCursor`. A full page always returns one, because an indexer row limit can truncate a page silently, so a range ending exactly on a page boundary costs one empty final page. A cursor binds the chain, endpoint, kind and both bounds, and is refused by any other range. **Affected:** implementations of `SomniaMarketsClient` other than the SDK's own must add `getFillsPage` and `getLatestFillBlock`. Consumers that only call the client are unaffected; the seconds window still works. In this repository the interface is implemented only inside the SDK; `packages/sdk/test/fillBlockRange.test.ts` covers the new reads. ### 8. `Candle` gains a required `lastFillId` — reindex first **Was:** no fill-level watermark on a bucket. **Now:** `lastFillId`, the id of the newest fill folded into the bucket, in the same `${blockNumber}_${logIndex}` key space as a fill's own id. **This one has an operational prerequisite.** `getCandles` selects `lastFillId`, so it errors against an indexer whose schema predates the field. Deploy the matching `indexer/schema.graphql` and run a full reindex **before** upgrading the SDK. Constructing `Candle` values yourself? Add the field. Read-only consumers just receive it, and gain the watermark needed to reconcile an open OHLCV bucket against the chain without a second fills read. **Affected:** every `getCandles` caller operationally, through the reindex — that half applies whether or not you read the field. In this repository **the explorer** receives it and deliberately drops it: `src/lib/candleTip.ts` types its chart rows as `Omit`, because a display bucket is not a cursor and its fold does not maintain one. Worth copying if your own derived types would otherwise promise a watermark they do not keep accurate. ### 9. Order reads carry `userData`, and stricter input validation **Was:** order rows carried no correlation tag; invalid runtime inputs reached the indexer. **Now:** `getOpenOrders` and `getOrders` return each row's `userData` as a decimal string, and it is required on constructed `OpenOrder` and `OrderRow` values. `OrdersOptions.userData` filters on that tag, so a client correlates rows with the orders it sent instead of keeping its own map. `OrdersOptions.market`, `.pools` and `.markets` scope a read the way the fill scope already did; an empty array matches nothing, a list caps at 100 entries, and supplying a singular scope alongside a list intersects both. Reads now reject bad input before any network I/O, with `InvalidInputError`: an empty owner on `getOpenOrders`, `getOrders` and `countOrders`, an empty account on `getUserFills` and `countUserFills`, a malformed order id on `getOrderFills`, and, on fill reads, a market, markets or pool identifier that is empty or not a string. Code that relied on those calls answering with no rows now sees an error instead. `sinceBlock` also bounds order reads, where it bounds the **placement** block — it does not report a later amend, fill or cancel of an order placed before the bound. `client.waitForIndexedBlock(chainId, block, opts)` resolves once the indexer has processed a block, and rejects with the new `IndexerWaitTimeoutError` on deadline — a different decision from a failed read. **Affected:** code constructing `OpenOrder` or `OrderRow` values, chiefly fixtures and mocks; implementations of `SomniaMarketsClient` other than the SDK's own must add `waitForIndexedBlock`. In this repository **sdk-e2e** builds and asserts on order rows (`test/query.test.ts`, `test/lifecycle.test.ts`), and the explorer reads `userData` on `src/app/pool/[pool]/order/[orderId]/page.tsx`. None of the new filter columns is indexed — pair a tag or block bound with a pool scope and a status to keep the scan small. ### 10. `pnlEventsFor` rejects malformed rows **Was:** `id` was never read; ordering used the timestamp alone, so a malformed `id` was ignored and its row stayed wherever the input happened to put it. **Now:** `pnlEventsFor` reads each row's `id` to order events by chain position and throws `InvalidInputError` when it is not `${blockNumber}_${logIndex}`. The same check covers every number the fold reads — `FillRow.quantity`, `FillRow.fillPrice` and a router action's `amount`. These went through a bare `BigInt` before, which threw a native `SyntaxError` on a corrupt value: an error outside the documented union of this function and both client methods that wrap it. **Affected:** callers passing indexed data straight through are unaffected — rows from `getUserFills` and the router-action reads already carry well-formed ids. A caller assembling its own `FillRow` or `RouterActionRecord` values, **test fixtures especially**, must set `id` to `` `${blockNumber}_${logIndex}` `` and give `quantity`, `fillPrice` and `amount` as unsigned decimal strings. ### 11. A live price watch requires `priceFeed.quote` **Was:** `watchPrice` accepted a feed configured with a url alone. **Now:** it rejects with `NotConfiguredError` naming the key unless the feed pins a quote: `priceFeed: { url, quote: "USDC" }`. The tape below it now streams on a `requestId` cursor, and one upstream request prices every symbol in a tick — so that id is unique within a feed but not across a base spanning several quotes. Such a stream can carry two rows sharing an id, and a batch boundary between them would skip the second permanently. An unpinned base was already double-counting on this path; refusing it is the alternative to silently losing ticks. The one-shot reads — snapshot, history, candles, batch prices, feed catalog — are unaffected and still work without a quote. `SOMNIA_TESTNET_PRICE_FEED` and `SOMNIA_MAINNET_PRICE_FEED` already pin `USDC`, so configurations built from those constants need no change. Two smaller shifts on the same path: `PricePoint.raw.price` and `raw.ema` now report `"0"` for a tick the feed recorded with no price, where they previously reported `null` despite being typed as strings; and a subscription the server rejects is now surfaced instead of leaving the tape silent while the feed still reads as connected. **Affected:** `watchPrice` and `useWatchPrice` consumers whose feed carries no quote. In this repository **the explorer** already pins one (`src/lib/priceFeeds.ts`, overridable through `PRICE_FEED_QUOTE`), so it needs no change. ### 12. `hidekiTestnet` (50383) now means the v2 regenesis **Was:** the chain rebuilt before 2026-09-17, reached at `api.hideki.infra.testnet.somnia.network`. **Now:** the v2 regenesis — a different genesis, different endpoints, and the canonical Multicall3 at `0xcA11bde0…` that v2 predeploys at block 1. The chain id did not change, which is exactly the hazard. Hideki was rebuilt on the **same id** with fresh state and the deployer's nonces restarted, so an address is no longer a stable identifier across the two chains: most v1 addresses hold a _different_ contract on v2, while the whole binary/CLOB family (deployed via CREATE3) and the bridged USDso synthetic come back at the _same_ address. A stale cache does not fail loudly — it answers with the other chain's history. **To move to v2 (recommended):** take the new definition and discard every piece of Hideki state you hold — indexer databases, address caches, block-height watermarks, saved deployment manifests. Do not reconcile v1 addresses against v2 ones, and do not carry a v1 start block across: v1 is past 586M blocks while v2 is near 2.4M, so a v1 watermark parks you at a head v2 will not reach for a long time. **To stay on v1:** pin the SDK to 0.30.0, or construct the chain yourself with viem's `defineChain` using the v1 host and Multicall3 `0x540B091b…`. Note that v1's `/ws` already answers 502, so a websocket consumer is broken there regardless. **Tell the two apart by height, never by chain id** — nothing in a signature, a wallet or this definition distinguishes them. **Affected:** anything pinned to 50383. In this repository, the explorer's chain and address maps (`src/lib/chains.ts`, `src/lib/addresses.ts`) and the bridge lane, whose five Shannon-side collateral routers moved while the Hideki-side deployment came back at its v1 addresses. ### 13. A `walletClient` signer must implement `sendTransaction` **Was:** the SDK sent each write with `walletClient.writeContract`. **Now:** the wallet executor encodes each call and sends it with `walletClient.sendTransaction`. The SDK does not call `writeContract`. A viem or wagmi wallet client implements both methods, so it needs no change. An adapter object that implements only `account` and `writeContract` still compiles when it is typed as a `WalletClient`. At runtime every write through it fails with `RpcError`, `operation: "sendTransaction"`, and no order is placed. Entry 1 covers the changed `operation` string. Such an adapter usually routes writes to a Safe or a sponsor relay. Check the built-in before writing one: `client.createExecutor({ smartAccount, inner })` covers an account from the accounts factory — see [Trade through a smart account](./how-to/trade-through-a-smart-account). Hand-write an `Executor` only when the submit path is your own: a sponsor relay, a custom co-signer, or a safe that built-in does not cover. An executor receives the encoded calls, sends them, and returns the receipt. An account holds **one executor per chain**, keyed by chain id, so build one for every chain the exchange spans. `exchange.client` is the PRIMARY (first) chain's engine only; reach the others with `exchange.engineFor(chainId)`. Registering a single chain's executor leaves writes on every other chain throwing `NotConfiguredError`. ```ts // before: 0.30.0 sent every write through the adapter's writeContract exchange.setSigner({ walletClient: safeAdapter }); ``` ```ts // after: the adapter becomes an executor import { assertCallsOnChain, InvalidInputError, type Executed, type Executor } from "@somnia-chain/markets-sdk"; // One executor per chain: `exchange.client` is the primary chain's engine only. const makeExecutor = (chainId: number): Executor => { const viem = exchange.engineFor(chainId).getViemClient(); return { chainId, actor: safeAddress, async execute(calls) { // Refuse an empty list or a call for another chain, as the built-in executors do. assertCallsOnChain(chainId, calls); let executed: Executed | undefined; for (const call of calls) { const hash = await submitThroughSafe(chainId, call); // your existing Safe or relay submit executed = { hash, receipt: await viem.waitForTransactionReceipt({ hash }) }; // Stop at the first mined revert. The SDK decodes it from the receipt. // A Safe can also mine a successful receipt around a failed inner call. // Detect that from the Safe's own events and return it as `failure`. if (executed.receipt.status === "reverted") break; } if (executed === undefined) throw new InvalidInputError("execute needs at least one call"); return executed; }, }; }; // Every chain the exchange spans gets one, so no write lands on an unconfigured chain. const executors: Partial> = Object.fromEntries( exchange.chainIds.map((chainId) => [chainId, makeExecutor(chainId)]), ); exchange.setSigner({ account: { address: safeAddress, executors } }); ``` A Safe transaction whose inner call fails can still mine with `status: "success"`. The SDK then treats the write as successful, unless the executor sets `failure` on the result it returns. If your Safe path can swallow an inner revert, detect it and set `failure` to a `ContractRevertError`. See `Executed` in the API reference. The SDK reads allowances and operator grants for `actor`, so set it to the address that holds the funds. An executor without `signTypedData` cannot run verbs that sign typed data, such as `signRedeemAuth`. Those verbs throw `SignerRequiredError`. If the account is a factory account from `@somnia-chain/accounts-sdk`, `exchange.client.createExecutor({ smartAccount, inner })` builds the executor for you. See the [Smart accounts guide](./accounts). **Affected:** `walletClient` adapters that implement `writeContract` but not `sendTransaction`. The DreamDEX web app (dex-web) is affected: its Safe adapter implements only `account` and `writeContract`. In this repository, the explorer passes wagmi wallet clients, which implement `sendTransaction`, so it needs no change. ### 14. `Unified*` structs and `Tradable` carry a required `chainId` **Was:** these types had no chain field. **Now:** `chainId: number` is required on `UnifiedMarket`, `UnifiedOrderBook`, `UnifiedTrade`, `UnifiedOrder`, `UnifiedStopOrder`, `UnifiedTicker`, `UnifiedFundingRate`, `UnifiedPosition` and `Tradable`. Two more required fields arrive with it: `UnifiedBalance.byChain`, the balance per chain, and `UnifiedTicker.perpState`. The SDK sets these fields on every value it returns. Code that only reads the values needs no change. Code that constructs them must add the fields. This is mostly test fixtures, mocks, and adapters. A single-chain exchange sets `chainId` to its chain's id and reports one `byChain` entry. ```ts // before const market: UnifiedMarket = { ...marketFields }; const usdso: UnifiedBalance = { free: 10, used: 0, total: 10 }; ``` ```ts // after const market: UnifiedMarket = { ...marketFields, chainId: 50312 }; const usdso: UnifiedBalance = { free: 10, used: 0, total: 10, byChain: { 50312: { free: 10, used: 0, total: 10 } } }; ``` **Affected:** code that constructs these types. dex-web is affected: its `UnifiedMarket` test fixtures fail `tsc` until they add `chainId`. In this repository, market-taker and sdk-e2e only read `UnifiedMarket` values that the SDK returns, so they need no change. ### 15. Note: nonce lanes belong to the engine This entry is not a breaking change. It corrects earlier documentation and states the safe way to share a signing key. **Was:** each configured client owned one nonce lane. A 0.30.0 exchange had one client, so it had one lane. **Now:** each engine owns one nonce lane. A multi-chain exchange builds one engine per chain, so it has one lane per chain. A single-chain exchange still has one lane. Single-chain code sees no change. Earlier SDK documentation said that viem's nonce manager is process-wide. That was not true in 0.30.0, and it is not true now. Two exchanges never share nonce state, even when they sign with the same key on the same chain. If both write at the same time, they can take the same nonce, and the node rejects one of the transactions. Share one exchange per signing account per chain. ```ts // unsafe: two exchanges with one key on one chain, two nonce lanes const quoting = new SomniaMarkets({ ...config, privateKey }); const hedging = new SomniaMarkets({ ...config, privateKey }); ``` ```ts // safe: one exchange per signing account per chain, one nonce lane const exchange = new SomniaMarkets({ ...config, privateKey }); ``` **Affected:** code that constructs two exchanges that sign with one key on one chain and write at the same time. In this repository, sdk-e2e runs its test files one at a time so that its maker and taker keys never race on nonces (`sdk-e2e/vitest.config.ts`). ## 0.31.0 → 0.32.0 These entries group migrations recorded in the `0.32.0` prerelease history. They include type changes and behavior changes that still compile. Entry 3 has an operational step: deploy and reindex the indexer **before** you upgrade. The following index covers every `BREAKING` item in the `0.32.0` prerelease changelog sections. Several notes concern the same migration. | Breaking change | Migration entry | | ------------------------------------------------------------------------------------ | -------------------------------------------------- | | Hideki 2 bridge addresses and lane identifiers | 1 | | Optional ticker volumes | 2 | | Historical fill dataset generation | 3 | | Settled binary pool routing refusal | 4 | | Bounded live terminal-order history | 5 | | Required chain identifiers on live and historical rows | 6 | | Multi-chain client/trader accessors and `createReactivity` configuration | 7 | | Chain binding validation | 8 | | Bridge message tracking and typed delivery failures | 9 | | Unknown write outcomes | 10 | | Local signing for account session seeds | 11 | | Removed generic Safe executor | 12 | | Paged pool binding history | 13 | | Synchronous local-signer RPC rename | 14 | | Required equity, account-view, `perpAdlQueue`, fill-pairing and fill-history members | 15 | | Node RPC sockets, price-feed sockets and connecting-socket owner cleanup | 16 | | Required margin block provenance | 17 | | Per-chain failures and optional primary indexer health | 18 | | Position-lifetime realized PnL | 19 | | Ticker and perp-state programming errors | 20 | | Lifecycle-based all-market selection | 21 | | Latest-price and feed-info hooks no longer acquire tick history | [Price hooks](#price-hooks-acquire-data-on-demand) | The earlier migrations that need attention even if your code compiles include: - `getLiveUserOrders` no longer returns every terminal order (entry 5). - `exchange.client` and `exchange.trader` throw on a multi-chain exchange (entry 7). - A misconfigured chain binding now fails its first read (entry 8). - `ensureFunds` reports a delivery timeout and a native-route liquidity refusal with new error classes (entry 9). - A write whose outcome is unknown throws a new error class (entry 10). - A smart-account executor refuses a session-seed inner (entry 11). - A local-signer send failure names `eth_sendRawTransactionSync` as its `operation` (entry 14). ### 1. The bridge registry describes the Hideki 2 lane **Was:** `getBridgeNetwork(ChainId.somniaShannon)` returned the Somnia-side core addresses of the Hideki 1 lane. The lane id was `"somniatestnet-hidekitestnet"`. **Now:** it returns the Hideki 2 lane's `mailbox`, `interchainSecurityModule`, `merkleTreeHook`, `validatorAnnounce`, `interchainAccountRouter` and `proxyAdmin`. The lane id is `"somniatestnet-hideki2testnet"`. Every `route.id`, the Hideki network name (`"hideki2testnet"`) and both display names change with it. Routers, tokens and decimals do not change since 0.31.0 (entry 12 of the previous section covers the router move), so `createBridgeTransfer`, `sendBridgeStep` and `getBridgeToken` behave as before. Replace stored lane and route ids, and discard cached Somnia-side core addresses. Read both from the registry, so the next rename does not break your code. ```ts // before: a stored literal const wbtcRouteId = "WBTC/somniatestnet-hidekitestnet"; ``` ```ts // after: read ids and addresses from the registry import { BridgeToken, ChainId, getBridgeNetwork, SOMNIA_BRIDGE } from "@somnia-chain/markets-sdk/chains"; const wbtcRoute = SOMNIA_BRIDGE.routes.find((route) => route.token === BridgeToken.WBTC); wbtcRoute?.id; // "WBTC/somniatestnet-hideki2testnet" const mailbox = getBridgeNetwork(ChainId.somniaShannon)?.mailbox; // the Hideki 2 lane's mailbox ``` **Affected:** configuration, dashboards, analytics keys and fixtures that store lane or route ids, and monitoring that caches Somnia-side core addresses. No consumer in this repository stores these values. ### 2. `UnifiedTicker.baseVolume` and `quoteVolume` are optional **Was:** `fetchTicker` failed as a whole when its 24h candle read failed. Both volumes were always present. **Now:** `fetchTicker` contains an `IndexerError` from the candle read and still returns the perp fields. The new `statsState` field says whether the 24h fields were read: - `"ok"`: the volumes are present. A volume of `0` is a true zero. - `"unavailable"`: the candle read failed with an `IndexerError`, or the market is `indexed: false` and no candle read was sent. `open`, `high`, `low`, `change`, `percentage`, `statsAsOf`, `baseVolume` and `quoteVolume` are absent. Other errors still reject. The type does not narrow on `statsState`, so check the volume for `undefined` too. ```ts // before console.log(ticker.baseVolume.toFixed(2)); ``` ```ts // after if (ticker.statsState === "ok" && ticker.baseVolume !== undefined) { console.log(ticker.baseVolume.toFixed(2)); } else { console.log("24h volume unavailable"); } ``` **Affected:** `fetchTicker` callers that read the volumes, and code that constructs `UnifiedTicker` values, which must add `statsState`. dex-web is affected: its ticker hook shows the 24h volume. No consumer in this repository reads `UnifiedTicker`. ### 3. Historical fill pages need the `FillDataset` generation **Was:** `getUserFillsPage` read from any indexer, and its cursor did not name the dataset it came from. **Now:** each page reads the serving indexer's `FillDataset` generation. `UserFillsPage` gains a required `datasetGeneration`, and every cursor records it. `fetchPortfolioAnalytics` reads fill history through the same pages. - An indexer that does not serve the generation fails the read with `FillHistoryUnavailableError`. Deploy the matching indexer and reindex it before you upgrade the SDK. - A cursor from another generation fails with `FillHistoryRestartRequiredError`. A cursor that 0.30.0 produced has no generation and fails the same way. Discard every page you collected and start again at page one. ```ts import { FillHistoryRestartRequiredError, type FillRow } from "@somnia-chain/markets-sdk"; let fills: FillRow[] = []; let cursor: string | undefined; let restarted = false; for (;;) { try { const page = await exchange.client.getUserFillsPage(account, { limit: 100, cursor }); fills.push(...page.fills); if (page.nextCursor === null) break; cursor = page.nextCursor; } catch (error) { // Restart once. A second change means the indexer is still being replaced. if (!(error instanceof FillHistoryRestartRequiredError) || restarted) throw error; restarted = true; // The dataset changed under the cursor: the pages already read are stale. fills = []; cursor = undefined; } } ``` **Affected:** callers of `getUserFillsPage` and `fetchPortfolioAnalytics`, code that stores fill cursors, and code that constructs `UserFillsPage` values. Every indexer that serves these reads needs the reindex. No consumer in this repository calls either read. ### 4. Pool-routed verbs refuse a settled binary market **Was:** a verb given a settled binary market acted on that market's pool. A binary pool serves successive markets, so after settlement the pool serves the next market in the series. **Now:** `createOrder`, `mintSet`, `burnSet`, `fetchOrderBook`, `fetchTrades`, `fetchOHLCV`, `fetchTicker`, `watchOrderBook`, `watchTrades`, `watchOrders` and `watchMyTrades` throw `InvalidInputError` for a settled binary market. `redeem`, `fetchBalance` and the history reads still accept it. A caller that resolved a settled market by pool address must resolve it by market id, or by the symbol that `fetchBalance` reports for the holding, to call `redeem`. **Affected:** code that keeps a binary market's symbol or pool after the market settles. In this repository, market-taker selects active binary markets before it calls these verbs (`src/shared.ts`). ### 5. `getLiveUserOrders` keeps a bounded terminal history **Was:** `getLiveUserOrders` returned every order the live store had seen for the user, including every filled and cancelled one. **Now:** per pool, the live store keeps every open order, the orders that retained fills need, and up to 400 recent terminal orders that nothing else references. Older terminal orders drop out of the live read. **Affected:** code that reads order history from `getLiveUserOrders` or the `useLiveUserOrders` hook. Read older terminal orders with `getOrders`. In this repository, the explorer's order panels use the hook to show open orders only (`explorer/src/components/clob/UserPanel.tsx`, `explorer/src/components/spot/SpotTradePanel.tsx`), so they need no change. ### 6. Live and history rows carry a required `chainId` **Was:** these rows had no chain field. **Now:** `chainId: number` is required on `LiveFill`, `LiveOrder`, `LiveFundingUpdate`, `LiquidationEvent` and `FundingPayment`. It is the chain of the engine that built or read the row. Ids repeat across chains, so key rows merged from two engines on `(chainId, id)`. `PricePoint` does not change. The SDK sets the field on every row it returns. Code that only reads rows needs no change. Code that constructs them must add it: ```ts // before const fill: LiveFill = { ...fillFields }; ``` ```ts // after const fill: LiveFill = { ...fillFields, chainId: 50312 }; ``` **Affected:** code that constructs these types, mostly test fixtures and mocks. In this repository, the sdk-e2e order fixtures already set `chainId` (`sdk-e2e/test/expiry.unit.test.ts`). ### 7. `exchange.client` and `exchange.trader` throw on a multi-chain exchange **Was:** on an exchange with two or more chains, `exchange.client` and `exchange.trader` acted on the first chain. **Now:** both throw `InvalidInputError`. Its `chainIds` field lists the chains the exchange spans. A pool address or market id can name a different market on each chain, so a call without a chain could reach the wrong market. A single-chain exchange does not change. Name the chain instead: ```ts // before const viem = exchange.client.getViemClient(); const trader = exchange.trader; ``` ```ts // after const viem = exchange.engineFor(hidekiTestnet.id).getViemClient(); const trader = exchange.traderFor(hidekiTestnet.id); ``` Two related changes: - `createReactivity` now reads `config` as well as `getViemClient` from its first argument. An engine from the SDK has both. A hand-built `{ getViemClient }` object must add `config: { chain }`. - `createReactivity` throws `InvalidInputError` when the wallet's `chain` is set and is not the engine's chain. **Affected:** multi-chain exchanges that use `exchange.client` or `exchange.trader`, and code that passes a hand-built object to `createReactivity`. Every consumer in this repository builds a single-chain exchange, so none needs a change. ### 8. Chain bindings are checked against the chain they name **Was:** the SDK trusted each binding's endpoints. **Now:** a binding whose endpoints serve another chain fails on first use: - Construction throws `InvalidInputError` when two bindings name one indexer URL written two ways, for example with a different host case, a default port or a trailing slash. - The first indexed read of each engine reads the indexer's `chain_metadata`. When the indexer has no row for the bound chain, the read throws `NotConfiguredError`. So `getSyncStatus` and `getIndexerFreshness` for the client's own chain throw instead of returning `null` for a missing row. - On Hideki (50383), the first chain read on each connection reads block 1. An RPC on the retired Hideki v1 genesis throws `NotConfiguredError`. - `getIndexerFreshness` throws `NotConfiguredError` when the indexer is more than 10,000 blocks past the RPC head, instead of reporting lag 0. `waitForIndexedBlock` for the client's own chain rejects in the same case. **Affected:** code that treats a `null` from `getSyncStatus` or `getIndexerFreshness` as "not indexed yet", and deployments whose indexer or RPC URL points at another chain. Fix the configuration; the error's `what` names the chain, the URL and the chains the indexer reports. ### 9. The bridge waits for its own message, and its failures are typed **Was:** `exchange.bridge.transfer` ended its wait at any delivery to the recipient, including one from another transfer. On a delivery timeout, `ensureFunds` and `createOrder(…, { funding: "auto" })` threw `RpcError` with `operation: "ensureFunds"`. **Now:** the bridge waits for the Hyperlane message id of its own transfer. A delivery timeout throws `BridgeDeliveryTimeoutError`, which carries `messageId`, `bridgeHash`, `from`, `to`, `token` and `amount`. `ExchangeBridgeResult` gains a required `messageId`. The default `deliveryTimeoutMs` is 180 seconds instead of 120, counted from when the origin transaction is mined. A native route (STT) now reads the destination router's balance first. When the router holds less than the amount, `exchange.bridge.transfer`, `ensureFunds` and `createOrder(…, { funding: "auto" })` throw `BridgeLiquidityError` and send nothing. It carries `token`, `from`, `to`, `router`, `amount` and `available`. Before, the transfer was sent, the funds stayed in escrow on the origin, and the call waited out the delivery timeout. The two errors need different handling: a liquidity refusal is safe to retry once the router is funded, and a delivery timeout means a transfer is in flight. ```ts // before try { await exchange.ensureFunds({ code: "USDso", chainId: hidekiTestnet.id, amount }); } catch (error) { if (!(error instanceof RpcError) || error.operation !== "ensureFunds") throw error; console.log("bridge not delivered"); } ``` ```ts // after try { await exchange.ensureFunds({ code: "USDso", chainId: hidekiTestnet.id, amount }); } catch (error) { if (error instanceof BridgeLiquidityError) { // Nothing was sent. The destination router cannot pay out the amount yet. console.log("bridge refused", error.available); } else if (error instanceof BridgeDeliveryTimeoutError) { // The transfer may still arrive. Check delivered(messageId) on the // destination Mailbox before you bridge again from another exchange. console.log("bridge not delivered", error.messageId); } else { throw error; } } ``` **Affected:** code that catches the old `RpcError` or handles only a delivery timeout, and code that constructs `ExchangeBridgeResult` values, such as test mocks. No consumer in this repository calls the bridge. ### 10. A write with an unknown outcome throws `TransactionOutcomeUnknownError` **Was:** a write whose answer was lost threw a plain `RpcError`. **Now:** it throws `TransactionOutcomeUnknownError`. The built-in executors throw it when the sync send times out or its socket drops, when the receipt wait fails after the transaction was sent, and when a session send loses its answer. It carries `chainId`, `operation` and `hash`. `hash` is absent only for a session send. The transaction can still mine, so do not retry it blindly. The new class extends `RpcError`, so an `instanceof RpcError` check still catches it. Test for it first: ```ts try { await exchange.createOrder(symbol, "limit", "buy", 1, 0.5); } catch (error) { if (error instanceof TransactionOutcomeUnknownError) { // The write may still mine. Look up error.hash before you send it again. console.log("outcome unknown", error.hash); } else if (error instanceof RpcError) { console.log("rpc failure", error.operation); } else { throw error; } } ``` Code that tests `error.name === "RpcError"` no longer matches these failures. A failed receipt wait now reports `operation: "waitForTransactionReceipt"`. A session-key write with a gas limit of zero or less throws `InvalidInputError` before it is sent. **Affected:** write error handlers that retry on `RpcError`. No consumer in this repository retries writes on `RpcError`. ### 11. A session seed that controls an account signs locally **Was:** a session seed sent itself to the node with every write (`somnia_sendSessionTransaction`). Any RPC endpoint, proxy or request log on the way could read it. **Now:** `exchange.accounts.accountFor(accountId, index, { sessionSeed })` derives the key with `sessionPrivateKey(seed)` and signs locally. The session address does not change, so existing owner sets keep working. `createExecutor({ smartAccount, inner })` throws `InvalidInputError` when `inner` was built by `createExecutor({ sessionSeed })`. `accountFor` refuses a per-chain factory that returns such an executor in the same way. Let `exchange.accounts.accountFor` build the account. It derives the seed's key and gives the account a local-signing executor on every covered chain: ```ts // before const inner = client.createExecutor({ sessionSeed: seed }); const executor = client.createExecutor({ smartAccount: ACCOUNT, inner }); ``` ```ts // after const account = exchange.accounts.accountFor(owner, 0n, { sessionSeed: seed }); const executor = account.executors[hidekiTestnet.id]; ``` `owner` and `0n` are the account id and index the accounts factory derived `ACCOUNT` from, so `account.address` is `ACCOUNT`. Pass `account` itself as the `account` of a `SomniaMarkets` config to trade through it on every chain. `createExecutor({ sessionSeed })` stays available for a session account that holds gas dust only. Local and node-side sends from one seed share one nonce space: after a seed signs locally, do not also send from it through `createExecutor({ sessionSeed })` or `sendSessionTransaction`. **Affected:** code that builds a smart-account executor on a session-seed inner. No consumer in this repository does. ### 12. The generic Safe executor is removed **Was:** `createExecutor({ safe, inner, mode?, multiSend?, detectFailure? })` built an executor for a threshold-1 Gnosis safe, in `"owner"` or `"module"` mode. The root exported seven values and types for it: `safeExecAbi`, `preValidatedSignature`, `packMultiSend`, `PackMultiSendError`, `detectGnosisFailure`, `SafeFailureDetector` and `SafeExecutionMode`. **Now:** the `{ safe, inner }` spec and the seven exports are gone. A spec with a `safe` key no longer type-checks. At runtime, `createExecutor` refuses it with `InvalidInputError`. For an account that the accounts factory deployed, use the smart-account spec. It batches several calls into one account transaction without a `multiSend` address. Build `inner` with a local signer, not with `{ sessionSeed }`: ```ts // before const session = client.createExecutor({ privateKey: sessionKey }); const executor = client.createExecutor({ safe: account, inner: session, multiSend }); ``` ```ts // after const session = client.createExecutor({ privateKey: sessionKey }); const executor = client.createExecutor({ smartAccount: account, inner: session }); ``` For any other safe, write a custom `Executor` and pass it to `createTrader({ executor })`. Return an inner failure as `failure` on the result, and Trader writes throw it. For the pre-validated signature and the Safe ABI, use `prevalidatedSignature` and `safeAbi` from `@somnia-chain/accounts-sdk`. **Affected:** code that builds a `{ safe, inner }` executor or imports one of the seven exports. No consumer in this repository does. ### 13. `getPoolBindings` is renamed `listPoolBindings` and returns one page **Was:** `client.getPoolBindings(pool)` returned the whole binding history of a pool in one response. A recycled pool gains one binding for each market, so the response had no upper bound. **Now:** `client.listPoolBindings(pool, { limit, offset })` returns one page, newest nonce first. `limit` is 50 by default, from 1 to 1000. `offset` is 0 by default, from 0 to 2147483647. A bad `limit` or `offset` throws `InvalidInputError` before the request. The row type `PoolBindingRecord` does not change. Rename the call. A caller that only reads the current binding needs no other change: it is the first row. A caller that needs the whole history pages until a page is shorter than `limit`, and deduplicates on `id`. New bindings move older rows down between two requests. The TSDoc of `listPoolBindings` has a compile-checked example. **Affected:** code that calls `getPoolBindings`. No consumer in this repository does. ### 14. Local-signer sends use `eth_sendRawTransactionSync` **Was:** `"realtime_sendRawTransaction"`. **Now:** `"eth_sendRawTransactionSync"`. A local-signer write now sends through `eth_sendRawTransactionSync` (EIP-7966). It is the current name for Somnia's `realtime_sendRawTransaction`, and it also returns the receipt in one round-trip. When this send fails, the `operation` of the `RpcError` or `TransactionOutcomeUnknownError` names the new method. Update checks for the old name. A node that does not serve the method still gets `eth_sendRawTransaction` and a receipt wait. **Affected:** code matching `err.operation === "realtime_sendRawTransaction"`. **No consumer in this repository branches on `RpcError.operation`.** ### 15. Custom client implementations need the account capabilities The client interface now requires `accountOrders`, `accountSpotOrders`, `accountPositions`, `accountBalances`, and `accountOutcomePositions`. It also requires `getAccountEquity`, `listAccountEquities`, `perpAdlQueue`, `listPerpPositionChangesForFills`, and `getUserFillHistory`. On an indexer that predates position changes, `listPerpPositionChangesForFills` throws `PerpPositionChangesUnavailableError`. Existing callers of an SDK-created client need no construction change. If you implement or mock the whole `SomniaMarketsClient` interface, add those members with their declared types. Do not cast an incomplete object to the interface. A test that uses only a few members can type its double as a `Pick` of the interface instead. Application code reaches these capabilities through `exchange.client`, or `exchange.engineFor(chainId)` on a multi-chain exchange. The indexed account views distinguish `unavailable`, `tooLarge`, and failed reads from an empty account. Preserve those states in your double and UI. See [Follow an account](./how-to/follow-an-account) and [event-contract positions](./binary#an-accounts-positions-complete-and-live). **Affected:** custom client implementations and whole-interface test doubles. The repository's implementations expose these members in `createClient` and `clientConformance`; the account-view tests exercise the capability contracts. ### 16. Node sockets use explicit configuration and owner cleanup Node RPC and price-feed sockets now use the SDK-managed transport. Replacing `globalThis.WebSocket` no longer selects their implementation. Pass your standard constructor as `rpcWebSocketConstructor` on `SomniaMarkets` instead. The constructor must promptly dispose connecting and open sockets on `close()`. Browser default selection is unchanged. See the existing example in [constructor selection](./reference/configuration#rpc-constructor-selection). Each owner now has its own chain socket, even at the same URL. `stopLive()` and `close()` also close a socket that is still connecting. A viem client previously returned by `client.getViemClient()` remains tied to that closed connection. Its requests reject with viem's `SocketClosedError`. After `stopLive()`, obtain a new accessor result for further caller-owned viem work. After owner closure, construct a new owner. If you pin an older viem 2.x version, upgrade to at least `2.18.5`, the package's minimum peer version. **Affected:** Node applications replacing the global constructor, applications reusing a viem accessor across `stopLive()`, and consumers pinned below the minimum peer version. See `socketOwnership.test.ts` and `priceFeedConnect.test.ts` for constructor and cleanup evidence. ### 17. `MarginAccount` records its pinned block `MarginAccount` now requires `asOfBlock: bigint`. `client.getMarginAccount` populates it with the block shared by the account's reads. Callers that read the result need no change. If you construct a fixture or a stored domain value, add the block that its figures describe. Do not substitute the current head for figures read at a different block. **Affected:** hand-built `MarginAccount` values. The SDK's margin reader returns the field; `perpStateBlock.test.ts` covers the pinned result. ### 18. Multi-chain reads expose partial health and chain-specific failures `fetchDataStatus()` now resolves when at least one chain's measurement succeeds. It throws the primary chain's error when every measurement fails. `status.indexer` is `undefined` when the primary measurement fails; `null` still means its metadata row is absent after a successful binding check. Read failures from `indexerErrorByChain`, and successful measurements from `indexerByChain`. Do not treat a missing measurement as zero lag. ```ts async function reportIndexerStatus(exchange: SomniaMarkets, primaryChainId: number) { const status = await exchange.fetchDataStatus(); if (status.indexer === undefined) { console.error(status.indexerErrorByChain?.[primaryChainId]); } else if (status.indexer === null) { console.log("No metadata row"); } else { console.log(status.indexer); } } ``` Account reads accept a chain filter; see the signatures in [Multi-chain reads](./exchange#reads-across-chains). Use it when the screen needs only one chain. An unknown chain throws `NotConfiguredError`; a market on another chain than the filter throws `InvalidInputError`. SDK-owned `IndexerError` and `RpcError` values can carry the failed `chainId`. Other account reads still reject when a queried chain fails: the health method's partial-success policy does not apply to all reads. A binding without `indexerUrl` appears in `chainIdsWithoutIndexer`. `fetchBalance` reads its native/ERC-20 balances without binary holdings, and `fetchOpenOrders` reads resting orders from chain. Indexer-only account reads skip such bindings when another queried chain has an indexer; an indexer-only read scoped solely to one of them throws `NotConfiguredError`. `loadMarkets()` exposes `registryReadErrorsByChain`, `perpDiscoveryErrorsByChain`, and `perpDiscoveryPartialFailuresByChain`. The older singular accessors retain the first failure in binding order. A bare `loadMarkets()` retries failed chains except a `NotConfiguredError` failure. Verbs that load markets implicitly throttle failed-chain retries to 30 seconds. `fetchPositions` now groups rows by chain in binding order. Join by chain and market identity instead of array position. A failed pool-state read keeps the position with `perpState: "degraded"` and no `markPrice` or `unrealizedPnl`. A symbol shared by two chains can gain a chain suffix when the second chain loads later; resolve symbols again after registry changes. **Affected:** consumers assuming primary health always exists, position ordering matches cross-chain inputs, or missing marks mean zero. See `multichainDataStatus.test.ts`, `multichainFacade.test.ts`, and `multichainLoadMarkets.test.ts` for the per-chain result and error contracts. ### 19. Indexed realized PnL covers a position lifetime `IndexedPerpPosition.lastUpdateRealizedPnl` now holds price PnL accumulated since the position opened from flat or flipped side. It remains on the flat row after a full close and resets when that row reopens or flips. Fees and funding remain separate. The field name is retained for compatibility. If you need only the latest update's PnL, read `realizedPnl` from the newest row of `client.listPerpPositionChanges({ account, pool, limit: 1 })` instead. Both reads need an indexer reindexed with this change. An older indexer keeps the earlier last-update meaning and does not serve `PerpPositionChange`. **Affected:** analytics treating `lastUpdateRealizedPnl` as one fill's result. The perp position/history readers and indexer position handlers implement the new meaning; [Perps](./perps) explains the history surface. ### 20. Ticker and perp-state programming failures use `InvariantError` Unexpected SDK programming failures in `fetchTicker` and `getPerpState` now reject with `InvariantError`. The error retains the original failure in `cause`. Replace checks for a native programming-error class with checks for `InvariantError`, and inspect `cause` when reporting the defect. Existing SDK errors and caller cancellation retain their identity. Operational outages keep their tier states: a ticker can report unavailable statistics or degraded perp state. Those states do not certify an empty market or a zero mark. The ticker joins started tiers and rejects with the first observed fatal failure. See [Errors](./reference/errors) for the public error vocabulary. **Affected:** callers matching native programming-error identity or treating all rejected ticker reads as outages. Ticker and perp-state tests cover the normalization boundary. ### 21. Market watches select lifecycle and pending waits can reject `client.watchMarkets()` now selects all SPOT and PERP markets and BINARY markets that are not finalized. It no longer selects the newest markets regardless of lifecycle. Read a finalized binary market with `getBinaryMarket(id)`. `watchMarket(pool)` has no lifecycle filter, but the pool can have been recycled onto another market. One all-market snapshot still has a 500-market budget, newest eligible first. Check `getLiveStatus().allMarketsTruncated` while the all-market watch is active before treating its rows as every eligible market. Finalized binary markets also leave that snapshot's fill and open-order budgets. `exchange.unwatchMarket(ref)` now releases one market's watch channels while keeping the owner open. Pending `watchOrderBook`, `watchTrades`, `watchOrders`, and `watchMyTrades` waits on that market reject with a `DOMException` named `AbortError`, including during hydration. `close()` rejects pending market and price waits the same way. Handle this rejection when a watch loop releases its market or closes its owner. The engine's market scope can linger for 30 seconds after release; it then drops that market's retained orders and fills. **Affected:** consumers expecting finalized markets in the discovery watch or pending waits to remain unresolved after release. See `snapshotDocuments.test.ts` and `unwatchMarket.test.ts` for selection and release evidence. ## Price hooks acquire data on demand Latest-price and feed-info hooks now acquire only current data. `useWatchPrice` and `useWatchPriceResult` also skip the historical tape. Direct `client.watchPrice` and `client.watchPrices` calls keep their existing tape default. If you used a latest/info hook to populate a separate `getLivePriceTicks` read, mount `useLivePriceTicks` for that asset or acquire a direct watch. The last tick reader releases its tape after 30 seconds even while latest/info readers remain. See [Reading — live](./prices#reading--live) for demand, release and error semantics. **Affected:** Consumers that read ticks separately after mounting only a latest/info hook. The explorer price card already mounts `useLivePriceTicks`, so it needs no change. --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/release-notes ## 0.32.0 ### Minor Changes - BREAKING: Binary portfolios now expose position continuation fields. Add `positionsTruncated` and `positionsNextOffset` to caller-built portfolio fixtures. `getClaimable` rejects wallets with more than 200 nonzero positions instead of returning incomplete claims; use `client.getClaimablePage(account, { limit, offset })` and follow `nextOffset` to inspect larger wallets with bounded reads. - Smart accounts gain indexed reads, through the accounts indexer the accounts SDK knows (no URL to pass). `client.accounts` (this chain): `getIndexedAccount` (whose account an address is), `listDerivedAccounts`, `listControlledAccounts`, `listOwnerHistory`, `listModuleHistory`, `getCreationAuthorization`, `getActivityPage` (one account), `getNetworkActivityPage` (every account), `getAccountsPage` (every account the factory made, newest first), `getFactoryState`, `getFactoryHistoryPage` and `getIndexerStatus`. `exchange.accounts` (every covered chain): `listIndexedAccounts`, `listDerivedAccounts`, `listControlledAccounts`, `listOwnerHistory`, `listModuleHistory`, `listCreationAuthorizations`, `listFactoryStates`, `getActivityPage`, `getNetworkActivityPage`, `getAccountsPage`, `getFactoryHistoryPage` and `listIndexerStatus`. Feeds merge chains by time. Pages are this SDK's own `{ items, nextCursor }` with an opaque `cursor` option, as for fills; `AccountsIndexerStatus` (with `lagSeconds`, `observedAt`, `ready`) and the option types are this SDK's own too. The `list*` reads follow every page, so they never return a silently shortened list. Row types are the accounts SDK's data shapes, re-exported. New errors: `AccountsIndexerUnavailableError` (the accounts indexer is rate-limited or overloaded; `retryAfterMs`; a subclass of `IndexerError`) and `AccountFeedRestartRequiredError` (the accounts index was rebuilt under a feed cursor). A chain without an accounts indexer throws `NotConfiguredError`; a failed request throws `IndexerError`. Requires `@somnia-chain/accounts-sdk` 0.4.0. - Preserve binary payout vectors in live market watches so snapshot voids use their stored payout rates. Add settledPayoutNumerators and voidVectorSource to BinaryMarket, with consistent values in live and indexed reads. Keep resolution block and timestamp metadata in live updates. - BREAKING: `SomniaMarketsClient` has a new required method, `listPerpPositionChangesForFills`. It pairs each of an account's perp fills with the position update that fill made, so a trade history can say "Open Long", "Close Short" or show a flip instead of only buy or sell. Pass one page of the account's fills, at most 1,000. It returns a `PerpPositionChange` or `null` for each fill, in the same order. It makes one indexed request for every 100 transactions, plus one more for every further 1,000 updates they hold. `null` means the fill is not the account's, is not a perp fill, or its update is not indexed. An indexer that predates position changes makes it throw the new `PerpPositionChangesUnavailableError`: show each fill's own side until the indexer is reindexed. The package root exports `PerpPositionChangesUnavailableError` and `SomniaMarketsClientListPerpPositionChangesForFillsError`. Custom `SomniaMarketsClient` implementations must add the method. - BREAKING: `SomniaMarketsClient` has a new required method, `getUserFillHistory`. It keeps an account's whole fill history current for a caller that holds it between reads, such as a portfolio that polls, without reading all of it again. Pass the history the last call returned as `previous`. The call reads again only the fills from `overlapSeconds` (default 60) before the newest held fill, puts the new fills on top, and keeps the older held rows. When nothing changed, it returns `previous` itself with `changed: false`. That costs one page request and one small probe, and one more request for each further 999 fills in the overlap. A fill never changes once the indexer serves it, except in a chain reorg, so the overlap is a check: when a held fill in it changed, or a fill the history does not hold appears below its newest fill, the call reads the whole history again. When only the newest held fills are not served and nothing newer is, as from an indexer that is behind, the call drops them, and they come back when the indexer catches up. Newer fills while the newest held ones are gone mean a reorg, and the call reads the whole history again. Without `previous`, it reads the newest `maxRows` fills (default 20,000) and says `truncated: true` when older fills exist. A dataset change, or a `previous` for another account, chain, market kind or format, also makes it read the whole history again. Each full read again returns `restarted: true`, and `restartReason` says why: `"previous"`, `"dataset"` or `"overlap"`. The history is plain data that survives a JSON round trip, and the SDK keeps no state between calls. The package root exports `GetUserFillHistoryOptions`, `UserFillHistory`, `UserFillHistoryRead` and `SomniaMarketsClientGetUserFillHistoryError`. Custom `SomniaMarketsClient` implementations must add the method. ### Patch Changes - Receipt confirmation now probes idle head streams and stops after 60 seconds or owner cancellation. Unconfirmed broadcasts retain their transaction hash in `TransactionOutcomeUnknownError`, so callers can check the outcome before retrying. - Queued local lending and admin writes stop before nonce requests when their owner lifecycle ends. Canceled operations release the send lane so the same capability can write after `stopLive()` starts a fresh lifecycle. - Hideki venue 1 now creates its BTC and ETH 5-minute and 10-minute markets with the price-feed creator. The deployment manifest adds the price-feed oracle adapter and creator addresses for the Hideki testnet. - Preserve concurrent local-signer lending and admin transaction intents by sharing send ordering with the configured owner's Traders and executors. - Document receipt cancellation and transaction-hash checks in the client configuration reference. - Reject cached binary market orders, mints and burns when the pool already hosts a successor market. The preflight check runs before funding or signing and preserves historical redemption. - Restrict scoped open-order reads to the requested market and binary outcome before applying the row limit, so unrelated orders cannot hide existing quotes. - The contract-errors table now includes `SomiLock`'s custom errors, so the SDK decodes a reverted SomiLock call (for example `CooldownActive` or `NoWithdrawalRequest`) to its error name instead of raw revert data. - When upgrading from `0.31.0` to stable `0.32.0`, follow the complete `0.31.0 → 0.32.0` migration guide in `docs/MIGRATING.md`, included in the npm package. Read every `0.32.0` prerelease changelog section as well. This stable changelog section covers changes since the last prerelease; the earlier sections contain additional breaking changes and required migration steps. - Account views on Somnia Testnet now report known coverage. The SDK recognises the READY testnet orders manifest, which claims spot, perp and binary orders and stops, and lists its perp and binary exclusions, instead of reporting an unknown manifest. ## 0.32.0-rc.1791380050012 ### Minor Changes - Export the binary event ABIs from the package root: `binaryPoolEventsAbi`, `binaryMarketEventsAbi`, `binaryModuleEventsAbi`, `binarySettlementEventsAbi` and `marketCreatorEventsAbi`. A consumer that decodes binary market logs can now use the same signatures as the SDK and the indexer. - `listLiquidations`, `listLiveLiquidations`, and `useLiveLiquidations` accept `kinds` so a wallet can read several liquidation stages in one bounded request. - `listLiquidations`, `listLiveLiquidations`, and `useLiveLiquidations` accept `party` and `counterparty`. Use `party` to read liquidation rows where a wallet is on either side of a leg. The live read and hook also accept `kind` to select one stage. - Add a `kinds` filter to `client.getMarginEvents`, so one bounded query returns only the margin events of the kinds you name. `getMarginEvents(account, { kinds: ["Deposit", "Credited", "Withdraw"] })` returns the money that moved into or out of the account. A portfolio-return calculation needs only these three kinds, so it no longer has to page through every order lock and unlock. The indexer applies the filter, so `limit` counts only matching rows. Without `kinds`, the read returns every kind, as before. An empty `kinds` array matches nothing, as in `listPerpPositionChanges`. A value outside `MarginEventKind` throws `InvalidInputError` before the request is sent. The package root exports `MarginEventKind`, `GetMarginEventsOptions` and `SomniaMarketsClientGetMarginEventsError`. - BREAKING: once the indexer stores candles per market, `getCandles(pool, …)` on a recycled binary pool returns one row per market in a bucket that spans the recycle, so two rows can share a `bucketStart`. Rows in one bucket are ordered by their last fill, which is market order. To keep one row per bucket, pass the market: `getCandles(pool, interval, { market: marketId })`. `getCandles` accepts `market`, typed as the new `GetCandlesOptions`, and `candlesKey` and `useCandles` accept it too. An empty or non-string `market` throws `InvalidInputError`. `getMarketStats24h({ marketId })` reads only that market's candles. `getMarketStats24h({ pool })` reads every candle in the 24-hour window, however many markets the pool served. `fetchOHLCV` and `fetchTicker` read only the selected market's candles. - BREAKING: `SomniaMarketsClient` has a new required member, `perpAdlQueue`: one side of one perp market's auto-deleveraging (ADL) queue, read from the chain at one block, and followed live. - `getSnapshot({ marginBank, pool, isLong })` reads the side. `data.queue` with `regime: "ranked"` lists every holder on the side. Each holder has its `eligibility`, and each candidate has its `adlRank` in the order that the engine's armed ranker gives. The read follows the liquidation engine. It reads the ranker and the scan cap from the engine at the pinned block. A side with more holders than the scan cap is `regime: "storageOrder"`, and a side that would be ranked but has more than `PERP_ADL_QUEUE_MAX_HOLDERS` (500) holders is `regime: "tooManyHolders"`. A ranker that reverts or returns an unusable order gives holder-list order, and `orderSource` says so. Holder pages that do not add up throw `RpcError`. The read follows the engine's claw-back arm. On the burn arm, the engine admits a position that is not at a loss at the bankruptcy price, and does not check equity. While a live view of the side is `ready`, `getSnapshot` returns that view and reads nothing. - `subscribe(ref, onView)` keeps the view live. It reads again at once when the MarginBank reports a change that can move the queue: any trader's fill, close, liquidation, takeover, forced settlement, ADL or funding settlement on this market, and any account's deposit, withdrawal, credit, linked funding or close-out payment, your own included. It also reads every 30 s, so a subscription that stops without an error costs at most that. A price move, a holder's equity changing on another market, and order placement and cancellation show at the next read. Each order locks or releases its margin, which moves the trader's equity, but the bank emits them several times a second, so they are not followed. Views go `loading`, `ready`, `stale`, `failed` and `closed`, as the account views do, and a view is never older than the one before it. Listeners of the same side share one session, which stays 30 s after the last listener, and `stopLive()`, or an abort of the client's `signal`, ends every view with `closed`. Migration: callers need no change. If you implement `SomniaMarketsClient` yourself, or mock the whole interface, for example in a test double, add the member. - `getFills(pool, opts)` accepts `market` and `markets`, typed as the new `GetFillsOptions`. A binary pool serves successive markets, so the pool alone returns the fills of every market it has served. Pass `market` to read one market's fills. Each row's `kind` tells a direct trade from a complete-set mint or burn. ### Patch Changes - `listFundingRateHistory` now orders rows that share a timestamp by `blockNumber`, then by `id`, in the requested direction. Two reads of an unchanged table return the same sequence, and the indexer database can serve the read from its `(pool, timestamp)` index. - Update the mainnet operator 5 venue to its Safe-owned price-feed creator and adapter. Preserve the previous creator address for historical market replay. - When you upgrade from 0.31.0, read every 0.32.0 prerelease changelog section, including this one, together with the migration guide. The count of four in the note on migration facts predates this section. The migration guide now lists `perpAdlQueue` among the members that a custom client implementation or a whole-interface test double must add, and it no longer gives a fixed number of prerelease sections. - Update the testnet operator 2 venue to its Safe-owned price-feed creator and adapter. Preserve historical creator addresses for market replay. - Preserve owned socket failures for every caller of a shared block-number read. Each read view now receives the original SDK error, including frozen errors, so a joined read cannot turn a connection failure into absent position data. - Live account spot orders now refresh after in-place amendments. The view reads only the matching pool, catches up to the event block, and keeps its 30-second full refresh. - Account spot orders now report unproven empty pool discovery as an indexer error. Live views keep known orders and subscriptions with incomplete coverage until discovery recovers. - Outcome-position subscriptions reuse their reader for 30 seconds after the last unsubscribe. Failed reads use bounded backoff and report whether they retry. Unavailable capabilities recheck every five minutes. Snapshot callers share one read and reuse ready live views. Owner cleanup stops retained readers immediately. - Share balance reads and live subscriptions across equivalent account and token lists. Each caller keeps its requested order, duplicate entries, native-token labels, and independent cancellation. - Correct polling and publication descriptions, and expand migration guidance for client capabilities, socket ownership, multi-chain reads, margin provenance, realized PnL, ticker errors, and watch lifecycle. When upgrading from 0.31.0 to 0.32.0, read the “0.31.0 → 0.32.0” migration-guide section and all four 0.32.0 prerelease changelog sections. The stable section's new patch notes alone do not describe the full upgrade. The guide indexes every prerelease breaking item, including earlier migration repairs. - Update Somnia testnet deployment metadata to use the upgradeable feed market creator while retaining historical creator addresses. - Record the testnet price-feed adapter upgrade that restores concise market questions while preserving existing proxy addresses. - Fix limited pool candle reads to select the newest fills when markets share a bucket. Compare fill positions numerically, including when block numbers or log indexes cross a digit boundary. ## 0.32.0-rc.1791215018402 ### Minor Changes - Add `client.accountBalances`: several accounts' wallet balances, read from the chain at one block. - `getSnapshot(accounts, tokens)` reads every account's balance of every token: `balanceOf` for an ERC-20 address, and the native balance for `"native"` (exported as `NATIVE_BALANCE_TOKEN`) or the protocol's `NATIVE_TOKEN_SENTINEL`. It is one Multicall3 call at one block, or one call per balance at that block on a chain without Multicall3. One call reads at most `ACCOUNT_BALANCES_MAX` (1,000) balances. - Balances are raw base units, in the order given: accounts, each with its tokens. A balance the chain cannot give is `unknown` with its error, never zero, and does not fail the others. - `subscribe(accounts, tokens, onView)` keeps the view live. It reads again at once when one of the tokens emits a `Transfer` from or to one of the accounts, and every 10 s, because a native balance changes with no event. Views go `loading`, `ready`, `stale` (last data, while a read or the transfer subscription recovers), `failed` and `closed`, and a view is never older than the one before it. Listeners of the same accounts and tokens share one session, which stays 30 s after the last listener, and `stopLive()`, or an abort of the client's `signal`, ends every view with `closed`. After an abort, a later `subscribe` gets only `closed` and `getSnapshot` rejects with the signal's reason. While a live view is `ready`, `getSnapshot` returns it, and a read that finishes after the live view moved past it never returns the older block. - BREAKING: `SomniaMarketsClient` has two new required methods, `getAccountEquity` and `listAccountEquities`. `getAccountEquity` reads an account's live equity for one scope at one block. `getAccountEquity(account, { scope: "perps", marginBank })` returns `equity`, `lockedCollateral`, their sum `totalEquity` and `asOfBlock`. Pass `blockNumber` to read at a fixed block instead of the head. `client.listAccountEquities(accounts, params)` reads many accounts at one block in batched requests and reports an unpriceable account as `{ priceable: false }`. Score an account on `totalEquity`, because `equity` excludes the collateral locked behind resting orders. The `"spot"` and `"overall"` scopes throw the new `NotSupportedError`. Migration: callers need no change. If you implement `SomniaMarketsClient` yourself, or mock the whole interface, for example in a test double, add both methods. - `client.accountOrders.subscribe` takes stop changes from the chain, ahead of the indexer. - On a client with a chain socket, the live view follows every stop registry's events. A stop the account creates, links, unlinks or cancels, or that fires, shows within a block or so, on top of the indexer's view, instead of after the indexer's lag and its 5 s push. A stop created on chain is rowed as the indexer rows it: its terms from the creation, its time from its block, its transaction from the log, and its venue and limit price `pending` until the SDK reads them. - Once the indexer's version reaches a change's block, the stops are the indexer's again. Each subscription that opens reads the registries' logs since the indexer's processed height, up to 1,000 blocks back, so a change made before it opened, or while it was down, is not lost. While it is down, the stops follow the indexer. - New field `AccountViewSync.chainStopsBlock`: the newest block of a stop change a live view took from the chain, while its version is behind it, else `null`. A snapshot that `getSnapshot` reads has the indexer's stops, so it is `null` there. When a live view of the account is `ready`, `getSnapshot` returns that view as it is, chain changes included. - Open orders stay the indexer's. Spot orders and perp orders are live from the chain in `client.accountSpotOrders` and `client.accountPositions`. - Add `client.accountOrders.getSnapshot(account)`. It reads one account's complete open orders and pending stops from the indexer's account view, at one version the indexer certifies. - The result is `ready`, `unavailable` or `tooLarge`. `unavailable` means the indexer cannot serve an account view on the chain. `tooLarge` means the account holds more than a view can prove complete: more orders or stops than its capacity, or a response larger than the indexer's proven limit. Neither result is an empty account. Until the indexer enables account views on a chain, every read there returns `unavailable`. - Every number in a row is a `bigint`. A stop also carries its venue and the limit price of the order it places. A value the SDK cannot read is `unknown`, with the error that caused it, and never zero. - `data.coverage` names the source families the view covers, for example `SPOT_ORDER`, and what the indexer's manifest leaves out of them. Orders and stops outside them are unknown to the view, not absent. The view can hold event-contract orders while its manifest does not cover `BINARY_ORDER`: such an order is real, but a missing one proves nothing. - A failed read throws. The new `AccountViewError`, a subclass of `IndexerError`, means the indexer answered, but not with one complete account. `accountViewFailureReason(error)` maps every error the read throws to one failure code. - The package now depends on `zod` 4. The package root exports the zod schemas the view validates indexer rows with: `accountOrdersCapabilityWireSchema`, `accountOrdersGuardWireSchema`, `accountOrderRowWireSchema` and `accountStopRowWireSchema`. - The read never sends `indexerHeaders`. Concurrent calls for one account share one read. `stopLive()` ends a read in flight, which then throws `IndexerUnavailableError` with reason `live-reset`. - Add `client.accountOrders.subscribe(account, onView)`. It keeps one account's complete open orders and pending stops live: it reads a guarded snapshot, then applies the indexer's account change stream in block order. - `onView` receives every state of the view: `loading`, `ready`, `stale`, `failed`, `unavailable`, `tooLarge` and `closed`. Each `ready` view is complete at its version. Changes that arrive together give one view, and a view waits at most 250 ms for more. - When the live connection breaks, or a change fails its checks or cannot be applied in order, the view becomes `stale` with its last complete data, and the SDK reads a new snapshot after a backoff. A read that fails with no data gives `failed`, with the same failure codes as `accountViewFailureReason`. The SDK watches an `unavailable` or `tooLarge` view and reads the account again when the result can change. None of these states is an empty account. - The `venue` and `limitPrice` of a new stop are `pending` until the SDK reads them. A later view carries them. `AccountStopVenue` and `AccountStopLimitPrice` have a `pending` state for this, which only a live view uses. A value whose read fails is `unknown`, and the SDK reads it again up to three times, after a backoff. - All listeners of one account share one session, which stays for 30 s after the last listener stops. `stopLive()`, or an abort of the client's `signal`, ends every live view with `closed`. After an abort, a later `subscribe` gets only `closed` and `getSnapshot` rejects with the signal's reason. While a live view is `ready` and none of its values is `pending`, `getSnapshot` for the same account returns that view and sends no request, and a read that finishes after the live view moved past it never returns the older version. - The new config field `indexerWsUrl` sets the indexer's GraphQL WebSocket endpoint. By default the SDK uses the WebSocket form of an absolute `indexerUrl`. The live view never sends `indexerHeaders`. - New exported types: `AccountOrdersView` and its states `AccountOrdersLoading`, `AccountOrdersStale`, `AccountOrdersFailed` and `AccountOrdersClosed`, and also `AccountOrdersStaleReason`, `AccountOrdersViewError` and `AccountOrdersClientSubscribeError`. - BREAKING: `SomniaMarketsClient` has a new required member, `accountOutcomePositions`. Migration: callers need no change. If you implement `SomniaMarketsClient` yourself, or mock the whole interface, add the member. - Add `client.accountOutcomePositions`: one account's complete event-contract positions on a chain, read from the indexer's account view. - `getSnapshot(account, { signal })` reads the view once. `subscribe(account, onView)` keeps it current and returns a function that stops it. Subscriptions to one account share one reader, which stops with the last of them. `stopLive()` ends every live view with `closed`, and a read in flight with `IndexerUnavailableError` `live-reset`. - The result is `ready`, `unavailable` or `tooLarge`. `unavailable` means the indexer cannot serve the view on the chain. `tooLarge` means the account holds more positions than a view can prove complete, or its response exceeds the indexer's byte limit. Neither is an empty account. Until the indexer enables the view on a chain, every read there returns `unavailable`. - Each position carries the market id, the side, the balance, the market's status and `claimable`: `unsettled`, `known` with the amount net of the settlement fee, or `unknown`. Every amount, token id and block number is a `bigint`. - A read that fails throws `AccountViewError` when the indexer's answer is not one complete account, and `accountViewFailureReason(error)` maps every error it throws to one failure code. A live view reports a failure as `failed`, or as `stale` with the last complete data. - Add `client.accountPositions`: one account's open perp positions, resting perp orders and margin in one MarginBank, read from the chain at one block. - `getSnapshot(marginBank, account)` reads the MarginBank's own list of the account's pools, each position, its pool's price and health, and the account's collateral and health, all at one block. That is up to three Multicall3 calls, or one once a read has named the pools and orders, and one call per figure on a chain without Multicall3. It includes positions in unlisted and restricted pools, and positions the account did not open itself, such as an ADL. - Each position carries its signed size, entry price, leverage cap, its marked figures (mark price, notional, unrealized PnL, accrued funding, margin requirements, return on margin) and its liquidation price, solved from the same block. The margin carries collateral, health, withdrawable collateral, margin status and the leverage surcharge. - `data.orders` holds the account's resting orders in every pool the MarginBank lists, a pool with only orders included: id, pool, order id, side, price, full and remaining quantity, expiry and user data. The pool answers for its caller, so each pool's order ids are one call sent as the account. The orders are `unknown` when one pool's could not be read, and the positions still stand. A pool keeps an expired order until someone cancels it: compare `expireTimestampNs` with the time. - A figure the chain cannot give is `unknown` with a reason, never zero. A pool whose price feed is stale keeps its position, with its marked figures unknown. A failed account state or position read fails the whole read, so the list is never shorter than the account's. - `subscribe(marginBank, account, onView)` keeps the view live. After the first read, the Somnia node pushes the same read (`somnia_watch`) when the MarginBank emits an event naming the account, and on every price write while the account holds positions, so marks, PnL and liquidation prices follow the price. Fills and cancels of resting orders arrive in the push. A new order costs one full read, and whenever the orders change, the push opens again for them, with one read once the node confirms it. Until then the account's events make the client read, and a push the node does not confirm within 10 s opens again after a backoff. The price trigger needs `addresses.priceFeedScheduler`. While pushes arrive the client makes no reads; when they stop, it reads every 5 s while the account holds positions. A node that does not push, such as a local node, leaves the client reading on the account's events and every 5 s. Views go `loading`, `ready`, `stale` (last data, while a read or the event subscription recovers), `failed` and `closed`, and a view is never older than the one before it. Listeners of one account share one session, which stays 30 s after the last listener, and `stopLive()`, or an abort of the client's `signal`, ends every view with `closed`. After an abort, a later `subscribe` gets only `closed` and `getSnapshot` rejects with the signal's reason. While a live view is `ready`, `getSnapshot` returns it, and a read that finishes after the live view moved past it never returns the older block. - Add `client.accountSpotOrders`: one account's resting spot orders, read from the chain. - `getSnapshot(account)` asks every spot pool for the account's open orders, one call per pool sent as the account because the pool answers for its caller, then reads the orders' details in one Multicall3 call, all at one block. It includes orders in unlisted markets. The spot pools come from the indexer's market list, kept for a minute. A pool whose orders cannot be read is listed in `data.unreadPools` with its error, its orders are missing from `data.orders`, and `data.complete` is `false`: those orders are unknown, not absent. A read that can read no pool fails. A pool keeps an expired order until someone cancels it: compare `expireTimestampNs` with the time. - Each order carries its id, pool, order id, side, price, full and remaining quantity, expiry and user data. - `subscribe(account, onView)` keeps the view live. After the first read, the SDK subscribes over the chain socket to every placement in the spot pools, because only a placement names its owner, and to the later events of the account's own orders by id. When one concerns the account, it reads again at once the pool that emitted it: an order the account placed, or a fill, cancel, expiry, reduce or amend of one of its orders. The other pools keep their last read, so `data.blockNumber` is the newest block read. A read that lands before the event's block reads that pool again. It reads every pool once the node confirms the subscription, and 30 s after it last read them all. A pool that cannot be read is read again after a backoff while the other pools stay live. The subscription opens again with each new order's id; past 1,000 orders it follows every order event of the spot pools. Views go `loading`, `ready`, `stale` (last data, while a failed read or the event subscription recovers), `failed` and `closed`, and a view is never older than the one before it. Listeners of one account share one session, which stays 30 s after the last listener, and `stopLive()`, or an abort of the client's `signal`, ends every view with `closed`. After an abort, a later `subscribe` gets only `closed` and `getSnapshot` rejects with the signal's reason. While a live view is `ready`, `getSnapshot` returns it, and a read that finishes after the live view moved past it never returns the older block. - New exported types: `AccountSpotOrdersClient`, `AccountSpotOrder`, `AccountSpotOrdersData`, `AccountSpotUnreadPool`, `AccountSpotOrdersReadError`, `AccountSpotOrdersSnapshot`, `AccountSpotOrdersView` and its states, and the option and error types of both calls. - BREAKING: `SomniaMarketsClient` has four new required members, the account views: `accountOrders`, `accountSpotOrders`, `accountPositions` and `accountBalances`. Migration: callers need no change. If you implement `SomniaMarketsClient` yourself, or mock the whole interface, for example in a test double, add the four members. - Export SomniaMarketsClientGetCandlesError from the SDK root. The candle read reference describes its complete failure union, exact caller cancellation and validated response semantics. - BREAKING: Node RPC connections use the SDK-managed WebSocket transport instead of a replacement global WebSocket. Replace `globalThis.WebSocket = AppWebSocket; new SomniaMarkets(config)` with `new SomniaMarkets({ ...config, rpcWebSocketConstructor: AppWebSocket })` when your application needs a custom standard constructor with prompt owned disposal. Browser default selection is unchanged. - `SomniaMarkets` gains `unwatchMarket(ref)`. It releases one market's watch and streaming channels and keeps the exchange open. Before, the exchange held every watched market until `close()`, so a consumer that follows a rotating series kept every old market in memory and in each update. After the call, the next `watch*` call on the market opens a fresh watch. The client drops the market's orders and fills after its 30-second linger. A second call, or a call on a market that is not watched, does nothing. A pending `watchOrderBook`, `watchTrades`, `watchOrders` or `watchMyTrades` call on a released market now rejects with a `DOMException` named `AbortError`. This also applies to a call that is still hydrating the market. `close()` now does the same for every pending market and price wait. Before, `close()` left those promises pending forever, and a call that was still hydrating could open a new channel after `close()`. A loop that awaits a `watch*` call must handle this rejection when it releases or closes the exchange. - BREAKING: local-signer writes now send through `eth_sendRawTransactionSync`. Write results carry `executionTimeMicros` when the Somnia node reports it. `executionTimeMicros` is the node-side time in microseconds from receiving the send to answering. It covers mempool, finality and execution. It is absent on nodes that do not report it. `TxResult` gains an optional `executionTimeMicros?: number`. So does every trade result, such as `PlaceOrderResult`, `CancelOrdersResult` and the perp results. `Executed` gains the same field. A custom executor can set it on `Executed`, and the writer passes it through. The local-signer executor reads the field from the `eth_sendRawTransactionSync` receipt. The session-key executor reads it from the `somnia_sendSessionTransaction` receipt. `native.sendSessionTransaction` now returns a `SomniaTransactionReceipt`: a `TransactionReceipt` with the same optional field. The local-signer fast path covers trader, admin, lend and bridge writes. It now calls `eth_sendRawTransactionSync` (EIP-7966), the current name for Somnia's `realtime_sendRawTransaction`. It still returns the receipt in one round-trip. A node that does not serve the method gets `eth_sendRawTransaction` and a receipt wait, as before. An EIP-7966 error code `5` or `6` from this method is a refusal, because the node did not add the transaction to its mempool. Code `4` (Timeout) stays `TransactionOutcomeUnknownError`. Migration: a failed fast-path send now reports `operation: "eth_sendRawTransactionSync"` on its `RpcError` or `TransactionOutcomeUnknownError`. It no longer reports `"realtime_sendRawTransaction"`. Update checks for the old name. See the migration guide. - BREAKING: Latest-price and feed-info React hooks now acquire only current data. If you used one to populate a separate tick read, mount `useLivePriceTicks` or acquire a direct price watch. Direct price watches keep their tick tape by default and accept `ticks: false` to skip it. The last tick reader releases its tape after 30 seconds while current-price readers continue. - BREAKING: `MarginAccount` has a new required field `asOfBlock`, the block that `client.getMarginAccount` pinned its reads to. Migration: reads need no change. If you construct a `MarginAccount` yourself, for example in a test fixture, add `asOfBlock`. - BREAKING: on a multi-chain `SomniaMarkets`, reads now report failures per chain, and `ExchangeDataStatus.indexer` is optional. - Migration: `fetchDataStatus()` now resolves when some chains fail, and throws only when every chain fails. When the primary chain's measurement fails, `status.indexer` is absent (`undefined`). Read that chain's error from `status.indexerErrorByChain[primaryChainId]`. Code that reads `status.indexer` must handle `undefined`. `null` still means that the chain's metadata row is absent. - `fetchDataStatus()` adds `indexerErrorByChain`, keyed by chain id, with the original error of each chain that failed. It is present only when a chain failed. A failed chain has no key in `indexerByChain`. - `IndexerError` and `RpcError` add an optional `chainId`. An account read over two or more chains, or with no market and no chain filter, sets it on the original error to the chain that failed. `fetchDataStatus()` sets it when every chain fails. The class, message and `cause` do not change, so `instanceof` checks still work. Other errors keep their shape. - `fetchBalance`, `fetchOpenOrders`, `fetchMyTrades`, `fetchPositions`, `fetchOpenStopOrders` and `watchUser` accept a chain filter `{ chainId }` as their last argument. `fetchOrders` accepts `params.chainId`, and `getOrdersPage` and `getOrderHistoryPage` accept `options.chainId`. A filtered read calls only that chain, also when it loads markets on a new exchange. The filter type `SomniaMarketsChainFilter` is exported from the package root. A `chainId` the exchange does not span throws `NotConfiguredError`. A market on another chain than the filter throws `InvalidInputError`. - A chain bound with no `indexerUrl` no longer makes account reads throw. `fetchBalance` returns its ERC-20 and native balances without outcome holdings. `fetchOpenOrders` reads its resting orders from the chain. `fetchOrders`, `getOrdersPage`, `getOrderHistoryPage`, `fetchMyTrades`, `fetchOpenStopOrders` and `watchUser` skip it when the read also spans a chain with an indexer. This also applies to a single-chain exchange with no indexer: `fetchBalance` used to throw `NotConfiguredError` and now returns balances. The new `chainIdsWithoutIndexer` lists these chains. A read scoped to such a chain alone still throws `NotConfiguredError` when it needs the indexer. - `loadMarkets()` adds `registryReadErrorsByChain`, `perpDiscoveryErrorsByChain` and `perpDiscoveryPartialFailuresByChain`, keyed by chain id. `registryReadError`, `perpDiscoveryError` and `perpDiscoveryPartialFailure` still report the first failing chain, in the order of the `chains` config. A bare `loadMarkets()` no longer returns the cache when the last load failed: it reads the failed chains again, loads each chain that no load has read yet, and keeps the other chains' markets. A `NotConfiguredError` failure is not retried. - `fetchPositions` no longer throws when one pool's oracle reverts. It reads the pool state only for a held position. When that read fails, the position lists with the new `UnifiedPosition.perpState` set to `"degraded"`, and `markPrice` and `unrealizedPnl` are absent. `perpState` is `"ok"` on every other position. - `fetchOpenOrders` now reads its chains in parallel. The merged order is still the binding order. - `fetchPositions` now returns its rows grouped by chain, in binding order. The order of explicit markets on different chains can change. - A market symbol that two chains share can gain a chain suffix when the second chain loads later, for example after a chain-filtered read on a new exchange. The old symbol then throws `InvalidInputError`. - `listBinaryMarkets` takes `ListBinaryMarketsOptions`: the filter plus `limit` and `offset`. New filter fields `orderBy: "resolvedAt"` (earliest-settled first, ties broken by id) and `resolvedBefore` (exclusive upper bound on the terminal timestamp). Together with `resolvedSince` they page through settlements in the order they happened. - BREAKING: `client.getPoolBindings(pool)` is renamed `client.listPoolBindings(pool, options?)`, and it returns one page of a pool's binding history instead of the whole history. A recycled pool gains one binding for each market, so the old response had no upper bound. - Migration: rename the call. The first row is still the current binding, newest nonce first. To read more than 50 rows, pass `{ limit, offset }` and page until a page is shorter than `limit`. Deduplicate on `id`, because new bindings move older rows down between two requests. - `limit` is 50 by default and must be an integer from 1 to 1000. `offset` is 0 by default and must be a non-negative 32-bit integer (0 to 2147483647). A bad value throws `InvalidInputError` before the request. - The root exports the new `ListPoolBindingsOptions` and `SomniaMarketsClientListPoolBindingsError` types. `PoolBindingRecord` does not change. - Add perp credit-voucher support: read the live campaign, check whether an account can claim, read an account's house-money floor and expiry, and claim. `client.getPerpVoucherCampaign`, `client.getPerpVoucherEligibility` and `client.getPerpVoucher` are chain reads. They resolve the distributor from the MarginBank, so no deployment address is needed. `trader.claimPerpVoucher` sends the claim, and `trader.buildClaimPerpVoucher` returns it unsigned so a claim and a first order can go in one transaction. - Add optional `addresses.marginBank` to reduce modern perp discovery to two serial read rounds when the candidate matches each pool. Keep registration checks authoritative and fallback reads bounded at one block. - Expose protocol cancel reasons in paginated perpetual order history. - BREAKING: `IndexedPerpPosition.lastUpdateRealizedPnl` now holds the realized PnL since the position last opened from flat or flipped side. Before, it held the last update's PnL only, so a close that filled across several book levels reported only its last fill. The name is kept until the next breaking release. Migration: if you need the PnL of the latest update alone, read `realizedPnl` on the newest row from `client.listPerpPositionChanges({ account, pool, limit: 1 })`. Add `client.listPerpPositionChanges`: one row per perp position update, each with the realized PnL of that update alone, and with the price it closed at and the order behind it, so a closed trade's result can be read without replaying fills. Filter by `account`, `pool`, `txHash` and `kinds`, window by `from` and `to`, page with `limit` and `offset`. MarginBank emits one update per fill, per auto-deleveraging or market wind-down leg and per liquidation takeover side, so every way a position closes is covered. Both need an indexer that has reindexed with this release; an older one does not serve `PerpPositionChange`. - BREAKING: Price-feed connections on Node use the SDK-managed WebSocket transport instead of a replacement global WebSocket. `rpcWebSocketConstructor` now also opens price-feed sockets. Closing the exchange during a price-feed handshake no longer leaves a replacement connection open. When a price-feed socket cannot be created, `watchPrice` and `watchPrices` reject with the constructor's SDK error unchanged, or with `RpcError` for any other thrown value, as RPC reads do. To keep a custom constructor, replace `globalThis.WebSocket = AppWebSocket; new SomniaMarkets(config)` with `new SomniaMarkets({ ...config, rpcWebSocketConstructor: AppWebSocket })`. - Add `priceFeedScheduler` to `SomniaMarketsAddresses`: the Somnia price feed contract that the perp pools' oracles read. One contract holds every price under a symbol name, such as `ETH/USDC:PERP` for a perp's mark price. - `SOMNIA_TESTNET_ADDRESSES` sets it to `0x7ccAE31E45693475be1e4BAa2360D6997eB2D32B`, and `SOMNIA_HIDEKI_TESTNET_ADDRESSES` sets it to `0x1998C88D54a240b8C671493f27f58eC416b81D7F`. - `SOMNIA_MAINNET_ADDRESSES` does not set it, because mainnet has no perps yet. - `client.accountPositions.subscribe` uses it: the node pushes the account's positions on every price write. Without it, marks and PnL follow a read every 5 s. - BREAKING: `close()` and `stopLive()` now close a chain WebSocket that is still connecting, and each instance owns its own chain socket. Before, a socket that was still connecting when its owner closed could not be reached. viem then retried it for about ten seconds. A retry that connected stayed open with a keep-alive interval, which could keep a Node process running. The SDK now opens every chain socket itself. `close()` and `stopLive()` close it, and end a handshake that is still in progress. A socket that has died is replaced on the next request. Two instances on the same `wsRpcUrl` now open two sockets. Before, they shared one. The `viem` peer range is now `^2.18.5`. That is the first release that exports every viem API the SDK imports. The old range, `^2`, also allowed releases the SDK could not load. Migration: upgrade `viem` to 2.18.5 or later if you pin an older 2.x release. A viem client from `client.getViemClient()` now belongs to the connection it came from. After `stopLive()` or `close()`, its requests reject with viem's `SocketClosedError` instead of opening a new socket. Call `getViemClient()` again after `stopLive()` to get a working client. - `getUserFillsPage` accepts a new `marketType` option (`"SPOT"`, `"PERP"` or `"BINARY"`). The indexer filters on the fill's own market kind column. Omit the option to read every kind, as before. The kind is part of the cursor scope, so a cursor from one kind, or from no kind, is rejected by another. Cursors from pages without a kind stay valid. `fetchPortfolioAnalytics` now asks the indexer for SPOT fills only, so binary and perp fills are no longer transferred. On a multi-chain exchange it reads all chains at the same time, with one page in flight per chain. The results do not change. `maxFillPages` still counts pages across all chains. A failure on one chain rejects the call and stops the other chains at their next page; an `IndexerError` from a multi-chain read carries the failed `chainId`. - BREAKING: Ticker and perp-state SDK programming failures now reject with `InvariantError` and retain the exact original failure in `cause`. Replace native programming-error identity checks with an `InvariantError` check and inspect its `cause`. Already-typed SDK errors and caller cancellation reasons keep their exact identity. Tickers join both started tiers and reject with the first observed fatal failure; operational outages retain their documented tier states. - Export `SomniaMarketsFetchTickerError` from the SDK root. The public reference explains ticker failure containment, caller cancellation and the atomic or pinned perp-state read paths. - BREAKING: `watchMarkets()` now selects markets by lifecycle, not by age. It hydrates every SPOT and PERP market and every BINARY market that is not finalized. Before, it took the newest 500 markets, so finished binary markets pushed out older SPOT and PERP markets and unfinished binary markets that still receive events. A finalized BINARY market is no longer in the all-markets watch. Migration: read a finalized market with `getBinaryMarket(id)`. `watchMarket(pool)` applies no lifecycle filter, so it still hydrates a finalized market while its pool is not recycled onto a newer market. One snapshot still hydrates at most 500 eligible markets. When more eligible markets exist, the new `getLiveStatus().allMarketsTruncated` field is `true` while the all-markets watch is active, and the oldest eligible markets are not watched. Before, the cut was silent. The all-markets fill and open-order snapshot also skips finalized markets, so their rows no longer use the fill and order budgets. ### Patch Changes - `amendOrders` now returns the right id for each amendment when the pool rejects some of a batch and applies the rest. Before, every id after a rejected amendment was reported against the wrong amendment. Now `newOrderIds[i]` is `0n` exactly when amendment `i` placed nothing. That amendment holds no order, so place it again, unless it repeated an `oldOrderId` that an earlier amendment in the batch already amended. `amendOrders` and `amendOrder` also return the unchanged id on pools that amend an order in place. Pools that revert the whole batch on a rejection behave as before. - Live watches avoid repeated event-id scans on quiet heads. Certified closed blocks release replay state while unresolved blocks keep exact duplicate protection. - `listPerpPoolStatuses` now batches its per-pool reads. This also applies to `loadMarkets` perp discovery. Each per-pool stage reads the pool bank, the bank registration and, on a factory without status views, the base token and restriction. Each stage now sends one Multicall3 request per 100 pools. Before, it sent one request per pool. On a chain without Multicall3, the reads fall back to point reads with at most 8 in flight. The result shape is unchanged. Every read stays pinned to one block. A pool whose bank or registration read fails still keeps its own status, and other pools are not affected. With a configured `perpPoolFactory`, `loadMarkets` now starts the status read at the same time as the indexer registry read. A factory found through an indexed perp row still waits for that row. - Live order books now read only the target pool's orders. Before this change, each book derivation read every stored order in every watched pool. The cost of one pool's book no longer grows with the number of orders in other pools. Book contents do not change. - The live store now bounds expired open orders. On-chain expiry emits no event, so before this change an expired `Open` order stayed in the live store for the life of the watch. Each chain head is now the retention checkpoint. At the first head whose timestamp is strictly after an order's expiry, the order counts toward the per-pool cap of 400 history rows in `getLiveUserOrders`. A retained fill still keeps the order. An active `watchUser` keeps every expired open order of its user, including orders placed after the watch started, until the watch is released. The order keeps status `Open`, because the chain holds its escrow until a cancel or a sweep. A later cancel or sweep event for a removed order changes nothing. Book levels are unchanged: they already hid expired orders. - Verbs that load markets for you now retry a failed chain at most once in 30 seconds. This applies to `fetchBalance`, `fetchMyTrades`, `fetchPositions` and the chain read of `fetchOpenOrders`. Before, a persistent registry or perp discovery failure re-ran that chain's full registry sweep and perp discovery on every such verb. Between retries, these verbs use the cached markets, and the failure accessors keep the recorded error and failing chain. A bare `loadMarkets()`, `fetchMarkets()`, `ensureFunds` and `loadMarkets(true)` still retry at once. A load that starts while a retry of a chain it needs is running now waits for that retry and does not start a second one. - The live store now keeps market rows in bounded memory. Before this change, it kept every market row it saw for the whole session, so a recycled pool added one row per market. The store now always keeps a market that a pool is bound to or that a retained fill or order names. It keeps up to 500 other market rows and evicts the oldest ones first, with their address and settlement lookups and their book views. Settlement events now find their market by the settlement key, which is also how the indexer finds it. A delayed `Redeemed` or `MarketFinalized` event for a past market therefore never changes the pool's current market. An event for an evicted market does not change the live store; read that market from the indexer with `getMarket` or `getBinaryMarket`. - Live watches now keep a bounded cache of block timestamps. Before, a watched market with no events kept the timestamp of every new block. After about 130,000 blocks, the next watch failed with `Maximum call stack size exceeded`. The cache now keeps fewer than 512 recent blocks. A log from an older block reads its exact timestamp from the chain again. `stopLive()` and client disposal clear the cache. - Bound chain WebSocket connection, read and subscription waits. Replace silent connections so later reads can recover, while allowing slow log queries to complete. - A new client's first indexer read and first live hydration no longer wait for a separate request before they start. The client still checks once that its indexer serves the configured chain. That check now runs alongside the first request, and the client returns or stores no rows until it passes. An indexer for another chain still fails the read or watch with `NotConfiguredError`. A cold first read now takes about one request round trip instead of two. - Start the RPC head stream while all-market and perp-plane snapshots load. Preserve snapshot source authority and release the warmup stream when no source watch needs it. - The networks and endpoints reference now covers the Hideki testnet in its address and token tables. The address table gains a Hideki column. A new table lists the Hideki tokens and how to get them. The reference no longer says that the Hideki indexer skips the spot pools: it indexes them, except HBTT/USDso. - Cancel Node RPC connections without leaving an idle replacement connection after owner close or a handshake timeout. - Decode errors from price-feed market creation and resolution so failed transactions report the contract error name. - The live store no longer rescans every retained fill and order on each block once it holds more than 500 market rows. The cold-market check now runs only after something that can change its result: a new market row, a pool binding that is released or moved to another market, or a dropped fill or order that named a market other than its pool's current one. Blocks that change nothing else now cost the same with 700 market rows as with 400. Which market rows are kept is unchanged. - Live watches now group timestamp source checks by block to avoid repeated log-batch scans during recovery. Released or replaced watches still discard stale timestamp reads and events. - A chain head that changes no row no longer gives live readers new references. Before, each head cleared every live selector memo and rebuilt every watched book. `getLiveMarkets`, `getLiveFills`, `getLiveUserFills`, `getLiveUserOrders` and the live books then returned new references for the same rows, and each exchange `watchOrderBook` and `watchTrades` wait resolved with unchanged data. Now a head updates only the tail status. `subscribeLive` listeners still run on each head, and `getLiveStatus()` reports the new `headBlock`. A live book also keeps its reference when a new wall-clock second removes no expired order. It gets a new reference when its book block changes or an order expires. Other changes are as before: a row change, or a status change other than a head, still gives every live reader a new reference. - Malformed endpoint chain-binding responses now report typed indexer failures before a successful binding is cached. Reads preserve the invalid response evidence and can retry the binding after a corrected response. - Malformed GraphQL envelopes and indexing metadata now report typed indexer failures with the original response evidence. Ticker reads retain healthy chain values with unavailable statistics when these indexer responses fail validation. - `client.getTransactionActivity(txHash)` now reads the indexer first. It asks the chain for the transaction's receipt and block only when the indexer has no event for the hash and `opts.anchor` is unset. A transaction with indexed events now makes no RPC call, which removes two dependent chain reads from each such read. Results, the order-table fallback and errors do not change. - The live feed now applies amends on pools that amend an order in place. Such a pool emits only `OrderAmendedInPlace`: the order keeps its id, and no cancel or placement event follows. Before, `watchMarket` and `watchMarkets` kept each amended order at its old price and size until the next snapshot. Now the order takes the amended price, quantity, expiry and `userData` in the same block. Its `filledQuantity` reads 0 after the amend, because an amend resets the order's remaining quantity to its new full quantity. `liveEventsAbi` decodes the released five-field event; the six-field version that a pre-release build emitted on Hideki 2 is not applied. - `loadMarkets()` on a multi-chain `SomniaMarkets` now reads its chains at the same time. The first load takes as long as the slowest chain, not the sum of all chains. Market and symbol order does not change. `perpDiscoverySkipped` now judges each chain by its own registry read: a registry failure on one chain no longer hides the skip on another chain. - The 0.31.0 → 0.32.0 section of `docs/MIGRATING.md` now covers every breaking change in 0.32.0. It gains eight entries: the bounded terminal history of `getLiveUserOrders`, the required `chainId` on live and history rows, the multi-chain refusal of `exchange.client` and `exchange.trader`, chain binding checks, `BridgeDeliveryTimeoutError` and `BridgeLiquidityError`, `TransactionOutcomeUnknownError`, local signing for session seeds, and the removal of the generic Safe executor. The Safe entry was listed under "After 0.32.x" and now sits in the 0.32.0 section. Each new code example is compile-checked. This also corrects the 0.32.0-rc.1790343344035 release notes. The session-seed note says that `createExecutor({ safe, inner })` refuses an `inner` built by `createExecutor({ sessionSeed })`. The same release removes the `{ safe, inner }` spec, so only `createExecutor({ smartAccount, inner })` refuses it. - `getOrders` and `getOpenOrders` now sort by `placedAtTimestamp` descending, then by order `id` descending. Orders placed in the same block share a timestamp, so before this change a `limit`/`offset` page boundary inside that tie could repeat one order and skip another. Pages are now deterministic. Results are still newest first, and callers need no change. - Three account reads now send their independent requests at the same time. The results do not change. - `watchUser()` on a multi-chain `SomniaMarkets` opens the user watch on every chain at once. When a chain fails, the call waits for the other chains, stops every handle that opened, and throws the error of the first failing chain in binding order. - `fetchOpenOrders()` reads the binary, spot and perp portfolios of each indexed chain at once. The order of the result does not change. - `client.getClaimable()` reads the settlement fee of each unique winning market with at most eight reads in flight. After a fee read fails, no new fee read starts. - Balance reads start native and portfolio requests during market loading. Settled market lookups use bounded batches while preserving market identity, missing-row errors, and reload recovery. - Live watches now recover a long block gap faster. Before, seam and reconnect recovery read one 1,000-block chunk of logs, applied it, and only then read the next chunk. Now up to four chunk reads run at the same time. The chunks still apply in block order, and each source's coverage still moves only across applied chunks. If a chunk read fails, the earlier chunks stay applied, the later responses are dropped, and the retry starts at the failed chunk. Block timestamp reads for one batch are now limited to eight at a time. - Load markets sooner by preparing the owner connection during registry reads and overlapping independent token metadata, binary grids, and perp discovery. Keep complete results, contained failures, and cleanup across reloads. - Complete pending subscription cleanup through its owning socket close. Preserve failures delivered through the request callback, including other connections that share an endpoint. - Perp tickers start candle and chain reads together. The fixed chain-state aggregate cannot split, and fallback reads keep one block pin. - Include known MarginBank hints in the testnet and Hideki address presets so perp discovery can use its two-round path without manual address mapping. - Live books, fills, and funding views now preserve unchanged pool references when another pool updates. Pool-scoped fill and funding reads visit only that pool's rows, while order joins and expiry still refresh affected views. - Release live pool data through pool-local indexes so watch cleanup scales with the released rows. Preserve account history and data held by overlapping watches. - Keep price watches stopped when a configured warning callback closes their owner or releases their subscriptions. - Price watches now connect while snapshots load and retry stalled WebSocket handshakes. Shared asset streams keep their own cursors and close with their owner. - Live store retention now prunes only the pools that changed. Before this change, each changed block read and bucketed every stored fill, order and funding update in every watched pool. The cost of a block no longer grows with the rows held for other pools. A bucket at or under its cap is not sorted. The per-pool funding series cap of 500 rows now takes effect. Before this change, the cap removed no rows. - A released market watch now drops every live book view of its pool. Before this change, views from `getLiveBinaryOrderBookByMarket` stayed in memory after the watch released, with the book levels they were built from. A recycled pool serves many markets, so these views grew with each market. The purge now also finds views of past markets on the pool, and views whose market row is already gone. Views of other pools and account order history do not change. - `listRegistryMarkets` and `listRegistryMarketsChecked` now page by a cursor on creation time and id, not by row offset. Before, markets created in the same block could be skipped or returned twice across pages. The sweep now also keeps a binary market whose `finalized` flag is not set yet, the same as the all-markets watch. The sweep and the all-markets watch now ask the indexer for SPOT and PERP markets by name, which lets it use an index. - Counts on the public indexer role no longer repeat a failing `_aggregate` request. After Hasura rejects the `_aggregate` field, the client remembers this for five minutes for that endpoint, header set and table, and sends only the bounded id scan. Counts that start at the same time share the first request. Privileged clients still get exact aggregate counts. Only Hasura's exact missing-field error is remembered: other failures still throw, and an error that only mentions "aggregate" and "not found" now throws instead of falling back. `stopLive()` clears the memory, so a permission change applies at once. - Restore the original short price-feed market question format without technical capture-window text. Resolution rules and numeric precision remain unchanged. - Pool-scoped order, stop-order, and portfolio reads now keep a classified pool's market type until cache eviction. They no longer read it again after 60 seconds. A multi-pool order read classifies its unknown pools in one batched request instead of one request per pool. A pool that has no market yet, a failed request, and a pool missing from a short response are not cached. The next read asks again. - Preserve two-round perp discovery while sharing bounded contract reads with account-equity snapshots. - Price watches in one client now share one price-feed WebSocket per endpoint. Before, each watched asset opened its own socket. Watches that start in the same tick now share snapshot requests of up to 10 assets. Before, each asset sent its own request. Each asset keeps its own 200-tick snapshot, stream cursor, health and cleanup. A rejected subscription puts only its own asset into `"error"`. A dropped connection makes every asset on that socket `stale` until it reconnects. A failed snapshot request rejects only the watches it carried. Closing the client cancels queued and in-flight snapshots. - Live price tick tapes now keep the same array reference until that asset receives new ticks. A tick for another asset, or a price-feed connection change, no longer makes `getLivePriceTicks` build a new array, so `useLivePriceTicks` no longer re-renders charts for unrelated assets. When an asset's price watch is released, the SDK now also frees every cached tick tape for that asset. Every reader of the same asset and limit now shares one tick tape array. The SDK freezes it, so sorting or reversing it in place throws a `TypeError`. Copy the array first, for example `[...ticks].reverse()`. - Ticker reads preserve custom caller abort reasons, including IndexerError instances. Malformed indexer candle responses now report a typed failure, so healthy perp chain numbers remain available with unavailable statistics. - Preserve the original cause when ticker or perp-state programming failures cannot be classified safely. Such failures reject with `InvariantError` after started ticker work settles. Typed SDK errors and caller cancellation reasons retain their identity. - Preserve the caller's exact ticker cancellation reason, including NaN and signed zero, while waiting for already-started chain reads. - Resolve upgradeable price-feed market creators from deployment manifests while preserving existing market reads and event formats. - Resolve price-feed adapter proxy addresses from deployment manifests while retaining compatibility with earlier direct deployments. ## 0.32.0-rc.1790343344035 ### Minor Changes - BREAKING: the exchange bridge now waits for its own Hyperlane message, and a delivery timeout in `ensureFunds` is a typed error. `exchange.bridge.transfer` reads the message id from the origin receipt and resolves only when the destination Mailbox processes that id. Before, any `ReceivedTransferRemote` to the recipient ended the wait, including a delivery from another transfer. - `ExchangeBridgeResult` has a new required field, `messageId`. Code that builds this type, such as a test mock, must add it. - `exchange.ensureFunds` and `createOrder(…, { funding: "auto" })` throw the new `BridgeDeliveryTimeoutError` on a delivery timeout, with `messageId`, `bridgeHash`, `from`, `to`, `token`, and `amount`. Before, they threw `RpcError` with `operation: "ensureFunds"`. Replace a check for that `RpcError` with `instanceof BridgeDeliveryTimeoutError`, and call `delivered(messageId)` on the destination Mailbox before you bridge again. - `exchange.bridge.transfer` still resolves with `delivered: false` on a timeout, and now carries `messageId`. - The default `deliveryTimeoutMs` is 180 s instead of 120 s. It is counted from when the origin transaction is mined. - A mined origin receipt without exactly one Mailbox dispatch from the origin router to the destination throws `RpcError` with `operation: "bridge message id"`. - A native bridge route (STT) is now refused before anything is sent when the destination router cannot pay out. `exchange.bridge.transfer`, `exchange.ensureFunds`, and `createOrder(…, { funding: "auto" })` read the destination router's balance first. When it is below the amount, they throw the new `BridgeLiquidityError` with `token`, `from`, `to`, `router`, `amount`, and `available`. Before, the SDK sent the transfer, the funds stayed in escrow on the origin, and the call waited out the delivery timeout. `ExchangeBridgeTransferError` is the new exported union of the errors `exchange.bridge.transfer` can throw. A failed native balance read now throws `RpcError` with `operation: "getBalance"` instead of a raw viem error. - Query-key factories and five React hooks now respect the chain on a multi-chain exchange. - Every query-key factory except `syncStatusKey` takes an optional trailing `{ chainId }` (`QueryKeyChainOptions`). A key with a chain ends in a `{ chainId }` element. A key without one is unchanged, so existing caches keep their entries. On a multi-chain exchange, pass `chainId` on every key. - `useWatchPerpMarkets`, `useWatchPerpMarketsResult`, `useLiveLiquidations` and `useLiveFundingPayments` take `chainId` in their options object (`UseWatchPerpMarketsOptions`, `UseLiveLiquidationsOptions`, `UseLiveFundingPaymentsOptions`). Before, they always read the primary chain. - `useFundingRateSeries(pool, interval, window, { chainId })` now reads the series and the indexer sync status from that chain's engine. Before, it read them from the primary chain and used `chainId` only for its live updates. - BREAKING: live and history rows now name their chain in a required `chainId: number` field. - `LiveFill`, `LiveOrder`, `LiveFundingUpdate`, `LiquidationEvent` and `FundingPayment` carry `chainId`. It is the chain of the engine that built or read the row. These rows come from one chain's events or from its indexer, so their ids repeat across chains: key rows merged from two engines on `(chainId, id)`. - Every row source sets it: live events, the chain seed, the indexer snapshot, the perp plane, and the indexed reads `getFundingPayments`, `listLiquidations` and `getLiquidations`. - `PricePoint` is unchanged. A price feed is configured independently of the chain (`config.priceFeed`), and one feed endpoint can serve clients on two chains, so a tick is not a per-chain row. - Market rows from a chain seed or from a live `MarketCreated` now set the optional `Market.chainId` too, as indexer rows already did. - Migration: code that only reads these rows needs no change. Code that builds one of these objects, such as a test fixture or a mock, must add `chainId`. - BREAKING (multi-chain exchanges only): `exchange.client` and `exchange.trader` now throw `InvalidInputError` on an exchange with two or more chains. They name no chain, and a pool address or market id can name a different market on each chain. Before, they acted on the first chain without an error. Replace `exchange.client` with `exchange.engineFor(chainId)` and `exchange.trader` with the new public `exchange.traderFor(chainId)`. The error's `chainIds` field lists the chains the exchange spans. A single-chain exchange is unchanged. `traderFor(chainId)` writes through `account.executors[chainId]` when the signer is a `SomniaMarketsAccount`. `setSigner` now validates the new signer before it replaces the old one. After a throw, the previous signer stays bound. `createReactivity` now throws `InvalidInputError` when the wallet's `chain` is set and is not the engine's chain. A wallet with no `chain` is not checked. BREAKING (type only): `createReactivity` now reads `config` as well as `getViemClient` from its first argument. An engine from the SDK already has both. A hand-built `{ getViemClient }` object must add `config: { chain }`. - BREAKING: the generic Safe executor, `createExecutor({ safe, inner })`, is removed. It covered a threshold-1 Gnosis safe in owner or module mode, beside the smart-account executor that covers the accounts factory's safes. Two executors for one kind of account taught two paths, and no consumer used the generic one. - The `{ safe, inner, mode?, multiSend?, detectFailure? }` spec is gone. A spec with a `safe` key no longer type-checks, and `createExecutor` refuses it at runtime with `InvalidInputError`. - The root no longer exports `safeExecAbi`, `preValidatedSignature`, `packMultiSend`, `detectGnosisFailure`, `SafeExecutionMode`, `SafeFailureDetector` or `PackMultiSendError`. - This supersedes the session-seed note in the same release, which says `createExecutor({ safe, inner })` refuses an `inner` built by `createExecutor({ sessionSeed })`. That spec no longer exists; the refusal now applies to `createExecutor({ smartAccount, inner })` only. How to migrate: - For an account that the accounts factory deployed, use `createExecutor({ smartAccount, inner })`. It batches without a `multiSend` address. Build `inner` with `createExecutor({ privateKey })`. - For any other safe, write a custom `Executor` and pass it to `createTrader({ executor })`. `prevalidatedSignature` and `safeAbi` come from `@somnia-chain/accounts-sdk`. - BREAKING: `SomniaMarkets` now checks that each chain binding's endpoints serve the bound chain. - Construction throws `InvalidInputError` when two bindings name one indexer URL written two ways, for example with a different host case, a default port or a trailing slash. - The first indexed read of each engine, of any kind, first reads the indexer's `chain_metadata` once. When the indexer has no row for the bound chain, the read throws `NotConfiguredError`, and `what` names the chain, the URL and the chains the indexer reports. So `getSyncStatus` and `getIndexerFreshness` for the client's own chain throw instead of returning `null` for a missing row. - On Hideki (50383), the first chain read or session send on each connection first reads block 1. An RPC on the retired Hideki v1 genesis throws `NotConfiguredError`. - `getIndexerFreshness` throws `NotConfiguredError` when the indexer is more than 10,000 blocks past the RPC head, instead of reporting lag 0. `waitForIndexedBlock` for the client's own chain reads the RPC head once when it would resolve, and rejects with `NotConfiguredError` in the same case. A client with no chain endpoint skips that read. - A binding refusal on a write path crosses as the `NotConfiguredError` itself. The session executor previously read any non-node failure as "the transaction may have been sent" and reported `TransactionOutcomeUnknownError`; a check that refuses the endpoint never sends, so there is no hash to look up. - BREAKING: a session seed that controls an account now signs locally. The seed no longer goes to a node. `exchange.accounts.accountFor(accountId, index, { sessionSeed })` sent the raw seed to each chain's node (`somnia_sendSessionTransaction`) with every write. The seed is a private key in another form, and the session address is an owner of the account that holds the funds. Any RPC endpoint, proxy or request log on the way could read it. - `accountFor(..., { sessionSeed })` now derives the key with `sessionPrivateKey(seed)` and signs locally on each chain, on that engine's nonce lane. No request carries the seed. The session address does not change, so existing owner sets keep working. - `createExecutor({ smartAccount, inner })` and `createExecutor({ safe, inner })` throw `InvalidInputError` when `inner` was built by `createExecutor({ sessionSeed })`. `accountFor` refuses a per-chain factory that returns such an executor in the same way. - `createExecutor({ sessionSeed })` stays available for session accounts that hold gas dust only. How to migrate: - Build an account's inner with `createExecutor({ privateKey })`. For a Somnia session seed, the key is `sessionPrivateKey(seed)`, imported from `@somnia-chain/markets-sdk/native`. Across chains, `accountFor(accountId, index, { sessionSeed })` does this for you. - Do not mix local and node-side sends from the same seed. Both modes share one nonce space. After a seed signs locally, do not also send from it through `createExecutor({ sessionSeed })` or `sendSessionTransaction`. - BREAKING: a write whose signed transaction may have reached the node, and whose receipt was not observed, now throws the new `TransactionOutcomeUnknownError` instead of a plain `RpcError`. The built-in executors throw it when the realtime send times out or its socket drops, when the receipt wait fails after the transaction was sent, and when a session send loses its answer. It carries `chainId`, `operation` and `hash` (absent only for a session send). The transaction can still mine, so look up `hash` before you send the write again. A refusal the node answered stays a plain `RpcError` or `ContractRevertError`; a refusal is recognised by the mempool status byte the node puts in the error's `data`, since it reports every mempool verdict — including its own send timeout — as JSON-RPC `-32000`. Migration: test for `TransactionOutcomeUnknownError` before `RpcError` in every write's error handler, and do not retry it blindly. It extends `RpcError`, so an existing `instanceof RpcError` arm still catches it. Code that tests `err.name === "RpcError"` no longer matches these failures. A failed receipt wait now reports `operation: "waitForTransactionReceipt"`. After a lost realtime answer, the SDK reads the transaction by hash once. When it has mined, the write now resolves with its receipt instead of throwing. A session-key write with a gas limit of zero or less now throws `InvalidInputError` before it is sent. ### Patch Changes - The multi-chain collateral pre-check in `createOrder` now finds the collateral token by address, not by currency code. The check reads the balance of the exact token the order escrows. It finds the bridge route by that token's address in the bridge registry. A token the registry does not list has no route, even when its symbol matches a bridge token, so `CollateralOnWrongChainError` carries no plan and an empty `elsewhere`. A registry token under another market label, for example `bUSDso`, now gets its route. `elsewhere` now lists only the other chains the registry lists that token on. `ensureFunds` still takes a currency code. It resolves the code on the target chain to the native coin, then to the bridge registry's token for that code, then to a listed market's token, and continues by that token's address. When no token on the chain carries the code, `ensureFunds` now throws `InvalidInputError` for a positive amount. `CollateralOnWrongChainError.token` is now always lowercased, so it no longer differs in casing between the `ensureFunds` and `createOrder` entry points. - `exchange.ensureFunds` and `createOrder(…, { funding: "auto" })` no longer bridge the same shortfall twice. On one exchange, calls for the same account, chain and currency run one at a time, and an auto-funded order keeps its place in that queue until it is sent. Two concurrent auto-funded orders now bridge their combined shortfall once. Each queued call can wait a full delivery timeout. - A leg that timed out stays pending on the exchange. When the next `ensureFunds` or auto-funded order for the same account, chain and currency finds a shortfall, it waits for that message and sends no new leg for it. When the balance already covers the amount, it does not wait. The pending leg is in memory only, so a new exchange instance must still check `delivered(messageId)` before it bridges again. - `exchange.close()` now ends the delivery waits of every `bridge.transfer`, `ensureFunds` and auto-funded order that started before it. `bridge.transfer` resolves with `delivered: false`, and `ensureFunds` throws `BridgeDeliveryTimeoutError`. The leg stays pending. - Avoid scanning and sorting unrelated live orders on every user-order read. - Complete the migration guide from 0.30.0 to 0.32.0 in `docs/MIGRATING.md`. The guide now says that 0.31.0 was not published, so a consumer on 0.30.0 applies both of its sections. The 0.30.0 → 0.31.0 section gains three entries: the wallet executor's `sendTransaction`, the required `chainId` on `Unified*` values and `Tradable`, and per-engine nonce lanes. A new 0.31.0 → 0.32.0 section covers the four breaking changes in 0.32.0. Each code example in the guide is compile-checked. The guide also documents three points that earlier release notes stated incorrectly. The notes themselves are published and are not edited here: - `indexerUrl` is optional on every chain, including Hideki. A client used only for chain reads and writes needs none. - A `walletClient` signer must implement `sendTransaction`. The SDK does not call `writeContract`. An adapter that implements only `writeContract` fails every write with `RpcError`. - `UnifiedTicker.statsState` does not narrow `baseVolume` or `quoteVolume`. Check the volume for `undefined` too. Other documentation fixes: the two-chain example in `docs/EXCHANGE.md` imported `hidekiMainnet`, which does not exist, and now pairs `somniaShannon` with `hidekiTestnet`. `docs/reference/networks-and-endpoints.md` names the chain exports of `@somnia-chain/markets-sdk/chains`. `docs/explanation/about-the-exchange-instance.md` describes per-engine nonce lanes. - `SOMNIA_TESTNET_ADDRESSES` and `SOMNIA_HIDEKI_TESTNET_ADDRESSES` now set `perpPoolFactory`. A binding that uses either preset lists its perps from the chain, also when it has no `indexerUrl`. Before this change, a Shannon or Hideki binding without an indexer listed no perps. `SOMNIA_MAINNET_ADDRESSES` does not set `perpPoolFactory`. A configured `perpPoolFactory` takes precedence over the factory that the margin bank reports. At release, each preset value matched the factory that its bank reports. To use the bank's factory instead, remove `perpPoolFactory` from the addresses you pass. The networks and endpoints reference now lists the Hideki indexer, `https://hideki-dev.smk.somnia.host/v1/graphql`. It is development-grade and does not index the Hideki spot pools yet. - The smart-account executor (`createExecutor({ smartAccount, inner })`) now refuses an account that does not exist on the chain, and runs signed writes through one account one at a time. Before its first write, the executor reads the account's code once. An account with no code on the chain raises `InvalidInputError` before anything is signed or sent. Before this change, a write to such an account succeeded and executed nothing. A failed code read raises `RpcError`. Successful checks are cached. This is a new chain read on the path with no `signer` and no `through`, which read nothing before. On a client with no chain transport, that write now throws `NotConfiguredError` instead of sending. With a `signer` or `through`, the engine now runs one write at a time per account, including writes from two executors built for the same account. Before this change, two concurrent writes could sign the same account nonce. The account then refused the second write with `GS026`. The queue belongs to one engine: two processes that write through one account can still sign the same nonce. - The multi-chain collateral pre-check in `createOrder` now sizes a spot order by the pool's `getAutoPullRequirement`, the same number the pool pulls. The check now counts the pool's fee headroom and the account's pool vault balance. A spot order that the vault covers places with no wallet read and no `CollateralOnWrongChainError`. A wallet that holds only the principal on a fee-bearing pool now gets a typed shortfall before anything is sent, instead of a revert. A native-base sell is sized the same way. Under the pool's manual vault mode, a vault shortfall throws `CollateralOnWrongChainError` with no bridge plan, also under `funding: "auto"`, because the pool does not pull from the wallet. ## 0.32.0-rc.1790260897085 ### Minor Changes - `BinaryMarketFilter` gains two facets for counting settlements inside a window, and the binary count helpers no longer require a lifecycle phase. - `voided?: boolean` narrows to markets that settled by void (`true`) or to every other market (`false`). It reads the explicit `voided` flag every void path writes, not the event-derived `status`. - `resolvedSince?: number | bigint` is an inclusive lower bound, in unix seconds, on the market's terminal timestamp. It takes a `bigint` from a chain read or an indexed row as well as a `number` from the clock, and sends either as an exact decimal string. It implies `resolved: true`: a market with no terminal event never matches. - `countBinaryMarkets` and `countBinaryMarketsBounded` take `BinaryMarketCountOptions`, on which `phase` is now optional. Omitted, the count spans live and past markets alike. Existing calls that pass `phase` behave exactly as before. Together they give a venue's settlement count and void count over a trailing window in two server-side counts: `countBinaryMarkets({ venueId, resolvedSince })` and the same call with `voided: true`. Both are keyed on the terminal timestamp rather than on `expiry`, so a market voided before it expired is counted, which a `phase: "past"` count would miss. - BREAKING: `getLiveUserOrders` now keeps up to 400 recent unreferenced terminal orders per pool, along with every open order and orders needed by retained fills. Use `getOrders` for older terminal history. - Add `client.listVaultPayoutFallbacks(options)`: the vault-credit fallback log across every owner, filtered by `owner`, `token` and/or `pool`, windowed with `from`/`to`, ordered `asc` or `desc`, paginated. Each `VaultPayoutFallback` row now also carries `pool`, the pool whose vault holds the credit, so the live claimable amount can be read with `client.getVaultBalance` without a second lookup. `getVaultPayoutFallbacks` is unchanged apart from the new field. - Add `listMarketResolutionEvents` and `listMarketReferenceLinks` — cross-market sweeps over resolution health, where `getMarketResolution` reports one market. `listMarketResolutionEvents` filters on `market`, `kinds` (a new `MarketResolutionKind` union, matched exactly) and `sinceTimestamp`, so a caller watching for `"Skipped"` / `"Failed"` pays for its window instead of over-fetching. `listMarketReferenceLinks` filters on `pending` and `market`, and returns oldest first so the longest-stuck market heads the list. `MarketReferenceLink` now also carries `blockNumber` and `timestamp`; while `pending` is true, that timestamp is when the market went pending, which is the age of the stall. ## 0.32.0-rc.1790179106799 ### Minor Changes - Portfolio analytics now reads cursor-paged fill history and supports an optional page budget that rejects incomplete PnL. A short nonempty page uses one additional indexer request to check for continuation under a positive role row cap. - BREAKING: the bridge registry now describes the **Hideki 2** Hyperlane lane. Six Somnia-side core addresses change. The lane's public string identifiers change with them. **What was wrong.** Somnia Testnet (50312) hosts two independent Hyperlane deployments. One serves the Hideki 1 lane. The other serves the Hideki 2 lane. This separation is structural. Both Hideki chains use chain id 50383 and Hyperlane domain 50383. Lane isolation therefore rests on two things: each lane has its own core contracts, and each warp router binds to one mailbox. `ChainId.hidekiTestnet` has meant Hideki 2 since the 2026-09-17 regenesis. The Somnia-side routers moved to Hideki 2 at that time. The Somnia-side core block did not. It kept the Hideki 1 lane's contracts. `getBridgeNetwork(ChainId.somniaShannon)` therefore returned a mailbox, ISM, merkle hook, validator announce, ICA router and proxy admin from a different bridge than the routers beside them. `createBridgeTransfer` and `sendBridgeStep` were not affected. They use the router addresses, which were already correct. Only `getBridgeNetwork` and `listBridgeNetworks` exposed the wrong values. An explorer link, an ISM trust-model check or a monitoring probe reads those. | `getBridgeNetwork(ChainId.somniaShannon)` | was (Hideki 1 lane) | now (Hideki 2 lane) | | --- | --- | --- | | `mailbox` | `0x46bA2807…e0A9` | `0xF805734a…54f9` | | `interchainSecurityModule` | `0xa831F40f…1B6c` | `0x99e1D7E9…d843` | | `merkleTreeHook` | `0xD7C2E95c…332D` | `0x64aa1c6a…D50C` | | `validatorAnnounce` | `0xB0834b2d…F44e` | `0x81b741D6…1033` | | `interchainAccountRouter` | `0x1384Ed70…22eF` | `0x2fbF56e1…2E19` | | `proxyAdmin` | `0x735653D2…D775` | `0x79f22212…1FaE` | **Why this release is breaking.** The lane now uses the names the `hyperlane-bridge-infra` repo uses. A consumer can store or compare these values. - `SOMNIA_BRIDGE.id`: `"somniatestnet-hidekitestnet"` becomes `"somniatestnet-hideki2testnet"`. - Every `route.id`: `"WBTC/somniatestnet-hidekitestnet"` becomes `"WBTC/somniatestnet-hideki2testnet"`. - `getBridgeNetwork(ChainId.hidekiTestnet).network`: `"hidekitestnet"` becomes `"hideki2testnet"`. - `getBridgeNetwork(ChainId.hidekiTestnet).displayName`: `"Hideki Testnet"` becomes `"Hideki2 Testnet"`. - `SOMNIA_BRIDGE.displayName`: `"Somnia Testnet ↔ Hideki Testnet"` becomes `"Somnia Testnet ↔ Hideki2 Testnet"`. - `SOMNIA_BRIDGE.docs` points to the Hideki 2 deployment record. **How to migrate.** - **Stored lane ids or route ids.** Update them to the `hideki2testnet` spelling. Config files, dashboards, analytics keys and test fixtures hold these. Compare against `SOMNIA_BRIDGE.id` or `route.id` instead of a literal if you want this to survive the next rename. - **Cached Somnia-side core addresses.** Discard them. Read them again from `getBridgeNetwork`. A cached Hideki 1 mailbox does not fail loudly. It describes a live bridge. That bridge is not the one your routers use. - **Display strings.** `displayName` is presentation text. Pin your own string if you need a stable one. - **Nothing to do** if you call only `createBridgeTransfer`, `sendBridgeStep` or `getBridgeToken`. No router, token or decimal value changed. **Chain definitions do not change.** `hidekiTestnet` and `ChainId.hidekiTestnet` keep their names. Both still resolve to 50383. Only the bridge lane's identifiers move. This release adds no second chain export, because `somniaChains`, `ChainId` and the deployment manifests are keyed by chain id, and both Hideki chains use 50383. **One trap is now documented.** Every Hideki-side address on this lane equals Hideki 1's. The same deployer replayed its nonce sequence onto Hideki 2, and that chain restarted its nonces at genesis. The ISM is derived by CREATE2 from the factory and the shared validator set. A matching Hideki address therefore does not identify the lane. Only the Somnia-side addresses do. The registry header states this. **How this was verified.** Every value was read from the live chains on 2026-09-21. `test/bridge.e2e.test.ts` (`SOMNIA_E2E_BRIDGE=1`) now pins all six core addresses to the lane. It checks that each address holds code. It checks that every warp router binds to the registry's mailbox. It then checks that the mailbox's `defaultIsm`, `defaultHook` and ERC-1967 admin slot agree with the registry, and that `validatorAnnounce.mailbox()` and `interchainAccountRouter.mailbox()` return that same mailbox. It also checks the ISM is a 1-of-1 message-id multisig on the advertised validator. That test fails against the pre-fix registry. It passed before this change only because CI does not set the flag. A live STT round trip from Somnia to Hideki 2 and back delivered in 3.8 s and 3.0 s, and router liquidity returned to its starting values. - BREAKING: `UnifiedTicker.baseVolume` and `quoteVolume` are now optional, and `fetchTicker` no longer fails whole when the candle read fails. `fetchTicker` opened with `getCandles`, an indexer read, and did not contain it. An indexer outage threw before the perp enrichment ran, so a perp trader lost mark price, index price, funding rate, next funding time and open interest — five chain reads that would have worked — to a candle outage. Those numbers decide liquidation risk. The candle read is now contained the way the perp read already was. A new `statsState` field says which state the 24h half is in: - `"ok"` — the window was read. The volumes are present, and they are `0` when the window held no fills. That zero is a true value, unchanged from before. - `"unavailable"` — the candle request failed with an `IndexerError`, or the market is `indexed: false` and no request was sent. Then `open`, `high`, `low`, `change`, `percentage`, `statsAsOf`, `baseVolume` and `quoteVolume` are all absent, the call still resolves, and the perp block still runs. The containment is narrow. Only `IndexerError` is caught; every other error propagates. Migration: `baseVolume` and `quoteVolume` are now `number | undefined`. Read `statsState` before reading them. ```ts // Before console.log(ticker.baseVolume.toFixed(2)); // After if (ticker.statsState === "ok") console.log(ticker.baseVolume.toFixed(2)); else console.log("24h volume unavailable"); ``` The dreamDEX web ticker hook (`web/hooks/use-tickers.ts`) polls `fetchTicker` every 10 seconds and shows the 24h volume. It is the known consumer to update: it gains the outage-resilient perp header, and it must handle an absent volume. - BREAKING: Historical fill pages now require an indexer that serves the `FillDataset` generation. Deploy and reindex the producer before upgrading the SDK; handle `FillHistoryUnavailableError` as a producer rollout failure, and discard accumulated pages and start at page one on `FillHistoryRestartRequiredError`. - Add `SOMNIA_HIDEKI_TESTNET_ADDRESSES` for chain 50383 from the deployment manifest. The preset omits `collateralRouter` and `lend` because Hideki has neither deployment. Their helpers report not-configured. Hideki has no hosted indexer. Supply a consumer-operated `indexerUrl`, which the SDK requires even for chain reads and writes. The mainnet and Shannon presets are unchanged. - Bound how hard one client pushes its indexer. Every indexer read now passes three gates owned by the configured client: a cap on requests in flight, a circuit breaker fed by consecutive endpoint failures, and a jittered backoff schedule. Configure them with the new `ClientConfig.indexerLimits`; the defaults in `DEFAULT_INDEXER_LIMITS` are conservative and only ever reduce load. The gates exist because a slow API used to become a retry storm. A client that retried a 30-second timeout on a fixed 3-second interval added a query to the server instead of replacing one, because nothing cancels an abandoned query. Once every client was doing that, the load sustained itself after the original slow query stopped mattering. What each gate does: - **In-flight cap** (`maxInFlight`, default 12). Further reads queue inside the client, where they hold no indexer connection, and start as earlier reads settle. A queued read still honours its caller's abort signal. The queue is bounded too (`maxQueued`, default 100): past it a read is refused rather than parked, so a client whose arrivals outrun the cap sheds load instead of growing a backlog. - **Circuit breaker** (`breakerThreshold`, default 5 consecutive endpoint failures; `breakerCooldownMs`, default 30 s). While it is open the client sends nothing and fails at once with the new `IndexerUnavailableError`. After the cooling period one trial read decides whether to close the breaker. Only timeouts and the 429/502/503/504 family count: a GraphQL validation error proves the endpoint is alive and resets the count. - **Backoff schedule** (`baseDelayMs`, `maxDelayMs`, `maxAttempts`). The new `nextBackoffDelayMs` helper returns exponential backoff with full jitter, so clients that retry together do not come back together. The caller passes the random draw (`Math.random()`), so it is pure and a polling loop outside the SDK can pace itself on the same schedule. `IndexerUnavailableError` extends `IndexerError`, so existing `instanceof IndexerError` handling keeps working unchanged and every documented error union stays true. For `breaker-open`, `breaker-trial`, and `queue-full`, catch the narrower type to distinguish “we did not send” from “the indexer did not answer”. A `live-reset` can instead end a retry backoff after an earlier attempt was sent. Its `reason` says which gate refused, because the four cases want different things and only one of them is a fixed wait: | `reason` | Your move | | --- | --- | | `breaker-open` | Sleep `retryAfterMs`, then retry | | `breaker-trial` | Retry shortly; the trial read decides | | `queue-full` | Back off on your own schedule, or send less | | `live-reset` | Retry on the same client; `stopLive()` leaves it usable | `SomniaMarkets.close()` stops each engine before it awaits cached watches, so a hydration still queued for a slot is refused rather than waited on; shutdown no longer stretches to the queue ahead of it. `retryAfterMs` is meaningful only for `breaker-open`, and is `0` elsewhere — there the wait is the caller's own backoff, not a period the SDK knows. The boundary itself does not retry. `maxAttempts` defaults to 1, so this cannot multiply retries a caller already performs. Raise it only when nothing above the SDK retries. When a gate refuses a retry after an earlier attempt reached the endpoint, the read fails with that attempt's `IndexerError`, not an `IndexerUnavailableError`: a gate refusal always means nothing was sent. Breaker and cap state belong to the client that owns them. Two clients built against the same endpoint keep separate counters, so one client's outage never fails another client's reads. `stopLive()` closes the client's gate. A read already on the wire still runs to completion, but one waiting for an in-flight slot is rejected with `IndexerUnavailableError` — the client that would have admitted it is gone. Only a caller that exceeds `maxInFlight` concurrent reads can observe this, and the rejection is an `IndexerError`, which every indexer read could already throw. The gates cover live-tail hydration as well as ordinary reads, so a watch cannot add ungoverned indexer load. A limit that would make the governor unusable is rejected with `InvalidInputError` when the client is built: a count that is not a whole number of at least 1, a delay of `0` or less, or any non-finite value. `nextBackoffDelayMs` rejects the same inputs directly, since a zero, negative or `NaN` bound yields a delay every timer fires immediately. `DEFAULT_INDEXER_LIMITS` is frozen, so one consumer cannot retune every client in the process. - Add `client.getOpenOrdersOnchain(pools, owner, opts?)`, which lists an owner's full resting orders from chain at one pinned block, and give `getOwnOpenOrdersOnchain` a `blockNumber` option. `getOpenOrders` is the read a bot uses to list its own resting orders, and it is an indexer read. During an outage it throws, and the dangerous consumer pattern is to wrap it in a catch that returns an empty list: the bot then believes it has nothing resting, so it skips its cancel or quotes again on top of its own live orders. The SDK threw correctly, but offered no equally convenient chain read to point that consumer at. The chain already had every field. What a consumer could not do was join the two existing reads at one block, because `getOwnOpenOrdersOnchain` took no `blockNumber`, and a consumer-side join also cost one un-batched call per order. The new read does the join in two stages at one block: one direct id read per pool, then one batched `getOrder` for every id. The id read is never routed through Multicall3 — the pool's view answers for `msg.sender`, so through an aggregator it would return that contract's empty order set, which is silently wrong rather than a revert. Limits, stated in the TSDoc: the caller supplies the pool list, because resolving pools by symbol is itself an indexer read; rows carry `isBid` and no YES/NO side, which is not derivable on a binary pool; there is no market, symbol or history. `getOpenOrders` is unchanged and does not fall back to chain. - Add `client.getSeedStatus(pool)`, which reports one watched pool's own seed status. `getLiveStatus()` carries one set of seed fields for the whole tail, and each seed overwrites the last. With several watched pools neither flag could be trusted: pool B's seed wrote `seedTruncated: false` over pool A's `true`, and `fillsLoaded` turned `true` as soon as any pool's fill lane landed, even when another pool's lane had failed. Both flags exist to prevent a silent wrong answer, and both were silent again the moment a second pool was watched. `seedTruncated` says a book is short at its deep end. `fillsLoaded: false` says a tape was never loaded, which is a different state from a tape that is genuinely empty (SDK-IO-002). `getSeedStatus(pool)` returns `{ seedSource, seedBlock, seedTruncated, fillsLoaded }` for that pool, or `null` when the pool has no hydrated watch. An entry is written at every site that hydrates a pool: the scoped indexer snapshot, the chain seed, the discover-all snapshot, the fill lane, and a pool discovered live through `MarketCreated`. That last one takes no seed at all — it is born inside the stream — and is recorded as complete from its creation block. A released watch drops its entry, and `stopLive` clears them all, so `null` always means what the accessor says it means. The global fields stay as they are and are now documented as LATEST SEED rather than an aggregate. Deriving them from the per-pool values would have changed what existing single-pool consumers read. N pools is N `watchMarket` calls, which already worked and is now tested and documented as the supported route. The TSDoc gives the read cost: `2 × maxPages` contract reads per pool in the worst case, 80 at the defaults, plus one module read for the market row. - Add `exchange.perpDiscoverySkipped`, which reports that `loadMarkets` did not look for perp pools at all. `loadMarkets` finds the PerpPoolFactory either from a configured `addresses.perpPoolFactory`, or from the `marginBank` of an indexed perp row. With neither — a reachable indexer carrying no perp rows, and no configured address — discovery returned an empty perp set and every error accessor stayed `null`. Nothing failed, so nothing was reported, and a consumer could not tell "this venue has no perps" from "the SDK had no way to look". The new accessor is `{ reason: "no-factory-route" } | null`. It is a separate accessor rather than a variant of `perpDiscoveryError`, because that one is typed `SomniaMarketsError | null` and means something failed. The value resets on every load. It reports "the SDK had no way to look", so it is not set when a read failed. A failed registry read leaves it `null`, because an empty row set is then a symptom of that failure and `registryReadError` already names it. For one engine it is likewise never set together with `perpDiscoveryError`. On a multi-chain exchange both CAN be non-null at once, each reporting a different chain. It is informational, not an alert. A non-null value is normal and permanent on a venue with no perps plane, which is Somnia mainnet today and any local anvil deployment. The TSDoc says so. `config.ts` and `docs/reference/configuration.md` no longer describe `perpPoolFactory` as normally unnecessary without naming this consequence. - Add `client.getPoolMarketBinding(pool)`, which reads from chain alone which market a binary pool currently hosts. The chain seed, `watchMarket(pool, { seed: "chain", marketId })`, needs the pool's current `marketId`. The ordinary source of that binding is the indexer, which is the service that is unreachable when a chain seed is needed. This read closes that gap: the pool names its market and the module maps that address to its id, so a caller can cold-start a live book with no indexer request. Both chain reads are made at one block, and the result carries it. The read resolves to `null` only when the pool's market does not belong to the module; a failed read throws. It verifies the pair before returning it, so a module record that disagrees with the pool throws instead of yielding a binding that would filter a book on the wrong market. `client.getMarketIdOnchain(marketAddress)` is the same reverse lookup for a caller who holds only the market address, such as a market page whose route carries the contract address. The indexer answers that question through `getBinaryMarketByAddress`; this read survives an outage. A chain-seeded `watchMarket` now also rejects a `marketId` that is not the pool's current one. It checks both ways an id can be wrong: the module records that market on a different pool, or the id names a PREVIOUS generation of this same pool. A pool address is stable across recycles, so only the market nonce separates the generations — this is the module's own staleness test. Either mistake previously painted a book that could never advance, with nothing to say so. - Add provider-level chain seeding for selected binary pools and a React hook for each pool's seed status. Pool-watching hooks share the provider policy, so a binary book can start from chain while the indexer is unavailable regardless of hook mount order. - The unified exchange identifies binary rows by market id, not by pool. A binary pool is recycled across successive markets, so `fetchMyTrades`, `fetchOrders`, `fetchOpenOrders` and `fetchBalance` used to label rows from an earlier market with the symbol of the market currently on the pool, and a ref-scoped `fetchMyTrades` or `fetchOrders` on a binary market returned every market that ever used its pool. Rows now resolve and scope by market id. `fetchTrades` is scoped the same way, so the public tape of a live binary market no longer includes the fills of earlier markets on its pool. A settled binary market a wallet still holds a position, a fill or an order in stays resolvable: `fetchBalance` reports the holding under that market's own symbol and `redeem` accepts the symbol, while `fetchMarkets()` keeps listing only tradable markets. A position on a market the indexer cannot name now makes `fetchBalance` throw `IndexerError` instead of silently omitting the key. BREAKING: pool-routed verbs (`createOrder`, `mintSet`, `burnSet`, `fetchOrderBook`, `fetchTrades`, `fetchOHLCV`, `fetchTicker`, `watchOrderBook`, `watchTrades`, `watchOrders`, `watchMyTrades`) throw `InvalidInputError` on a settled binary market, because its pool now serves the next market in the series. Redeem, balances and history remain available. A caller that resolved a settled market by pool address should resolve it by market id or by its symbol from `fetchBalance`. ### Patch Changes - Document market-specific binary collateral decimals and use them in SDK order examples. - Document binary event emitters and chain-specific oracle question links. - Explain that binary pool addresses can host successive markets, and show market-scoped book and outcome-balance reads. - Clarify that Hideki requires a consumer-operated `indexerUrl`, including for clients used only for chain reads and writes. No hosted indexer is provided. Unreachable indexer requests fail with `IndexerError`. - Correct the `UnifiedTicker.lastSource` documentation and the read-tiers reference. `lastSource` is present on both paths that produce a `last`, not only on the candle path. The cached fallback stamps the market row's `lastTradeAt`, so the field is absent only when `last` is absent or that timestamp is null. The previous text described absence as the ordinary stale signal, which understated how narrow that case is. The `read-tiers.md` reference gains the same correction, a corrected `getOpenPositionsWithPnL` row, separate rows for `fetchPositions` and `fetchFundingRate`, and a new section that lists what still works during a main-indexer outage. - Refresh the revert-decoding table (`contractErrorsAbi`) against the current perps contracts. Adds the thirteen market wind-down errors a caller can now hit — `MarketSettled`, `SettlementAlreadyScheduled`, `DelistEscrowActive`, `MarketNotEmpty`, `SettlementParametersNotConfigured` and the rest — which previously reached callers as opaque hex. It also drops twelve signatures no deployed contract can throw: eleven belong to the oracle-adapter surface removed by the Oracle v2 redeploy, and the last corrects `NotExpired(uint64)` to the `NotExpired()` the protocol actually declares. - Document where a stop order's limit price is available. Perp stop rows from `listPerpStopOrders` don't include `limitPrice`; fetch it for live stops with `getPerpStopOrders`. A spot LIMIT stop's limit price can't be read back after placement, so `getSpotStopOrders` and `fetchOpenStopOrders` leave it out. Treat a missing limit price as unknown, never as zero. Documentation only; no behaviour changes. - Count reads now send fixed, named GraphQL documents. The count methods (`countMarkets`, `countBinaryMarkets`, their `Bounded` variants, `countOrders`, `countUserFills`, `countOperators` and `countVenues`) no longer build their query text at call time. Each counted entity has one document for the `_aggregate` count and one for the bounded row-count fallback. A Hasura query allowlist can therefore list every query that the SDK sends. The method signatures, the `{ count, truncated }` result, the 10,000-row fallback cap and the fallback on roles without `_aggregate` do not change. When a count read fails, `IndexerError.operation` now names the entity's own document, such as `MarketCount` or `OrderCountFallback`. Before, it was the shared `Count` or `CountFallback` for an indexer failure, and `aggregateCount` for a response that did not contain the counted field. Update any code that compares `operation` with the old names. ## 0.31.0 ### Minor Changes - Add an ordered batched read for known perpetual stop orders. The read pins every Multicall3 chunk or fallback point read to one block, and preserves known absence and per-order contract or transport failures without discarding neighboring results. - `BinaryMarketFilter` gains `resolved?: boolean` — narrow a binary-market list to markets with an indexed terminal event (`true`) or without one (`false`). Omitted, it does not constrain. It applies wherever the filter does: `listBinaryMarkets`, `listLiveBinaryMarkets`, `listPastBinaryMarkets`, `countBinaryMarkets`. It is a filter on `resolvedAtTimestamp`, not on `status`, and the distinction is the reason it exists. `status` is derived from lifecycle EVENTS, and the timestamp-implicit Listed → Trading → Settling transitions emit none: a market whose oracle never answers stays indexed as `"Trading"` for as long as it is stuck. Selecting unanswered markets by status therefore returns nothing in exactly the case a caller wants them — the query succeeds and the problem is invisible. Every terminal path (resolve or void, on the module event or the market contract's own) stamps `resolvedAtTimestamp`, so its absence is the durable "still waiting". `listPastBinaryMarkets({ resolved: false })` is the pair that names a stuck market: expired, and with no indexed terminal event yet. Note this says nothing about WHEN an unanswered market became late. The protocol's deadline is `expiry + settlementWindow`, and `settlementWindow` is frozen on each BinaryMarket clone at construction — read it from the market's own address, not from the creator's current series configuration, which a later `registerSeries` can change under markets that were already minted. - Books now carry block provenance, and unified trades expose their source block and log position. Chain book reads pin both sides to one block. Live tails recover intervening log ranges and expose failures in status. Optional reconciliation compares native levels at fixed chain checkpoints without replacing order history. Fill and trade provenance fields are optional for caller-built values. SDK reads populate them from the source. Native historical fill blocks use decimal strings; unified trade blocks use bigint. Missing caller provenance remains undefined. - BREAKING: Fill reads gain an inclusive block range, and a new read returns every fill of one market kind in that range across every pool. Custom `SomniaMarketsClient` implementations must add `getFillsPage` and `getLatestFillBlock`. `client.getFillsPage({ marketType, sinceBlock, toBlock })` returns the fills of one market kind in an inclusive block range, across every pool of that kind, oldest first by `(blockNumber, logIndex)`. This is the read behind a published trade feed: it takes no pool, so a consumer never enumerates them. Pages hold 500 rows by default and continue through `nextCursor`. The cursor binds the chain, the endpoint, the kind and both bounds, so a cursor from one range is refused by another. A full page always returns a cursor, because an indexer row limit truncates a page silently; a range that ends exactly on a page boundary therefore costs one empty final page. Prefer the block range to the `since`/`until` window in seconds whenever successive reads must tile the chain exactly. Several Somnia blocks can share a timestamp, so a seconds window drops or repeats the fills in them at every seam. Both block bounds include their block, and `(blockNumber, logIndex)` is a total order over the fill table, so `[a, b]` followed by `[b + 1, c]` reads every fill once. `client.getLatestFillBlock(marketType)` returns the newest block carrying an indexed fill of that kind, with the block timestamp, or `null` when none is indexed. It reads the fill data rather than the indexer's processed-block watermark, so it never names a block `getFillsPage` cannot yet serve. `FillsOptions.toBlock` bounds `getFills`, `getUserFills`, `countUserFills` and `getUserFillsPage` from above, alongside `sinceBlock`. Both bounds are inclusive and merge into one comparison. An inverted range rejects with `InvalidInputError` instead of answering with no rows. `Fill` rows now carry the market kind, and the indexer indexes `(marketType, blockNumber, logIndex)`, so the range read is one index seek and its rows arrive already ordered. Every amount field on `FillRow` stays as a raw decimal string. - Add `trader.cancelAllOrders(pool)`, a kill switch that pulls every resting order the signer holds on one pool. It reads the resting ids and cancels them in one call, so a bot that holds no local ids — after a restart, or after a send with an unknown outcome — does not have to chain a read and a cancel itself. The default source is one chain read. Pass `{ source: "live" }` to take the ids from the live tail instead, which makes the cancel the only round trip; that source needs an active market watch on the pool and throws `NotConfiguredError` without one, because an unwatched tail cannot tell an empty book from an unseen one. An empty book is a success: the result is then `{ sent: false, orderIds: [], outcomes: [] }` and no transaction goes out. Branch on `sent`; a sent result carries the same per-id `outcomes` as `cancelOrders`. The two sources differ on an expired order that still rests. The pool's own open-orders view hides it from the chain source, so the default source leaves it on the book — use `cancelExpiredOrders` for that residue. The live source still lists it and forwards its id, and the cancel goes through: the pool gates a best-effort cancel on owner and id rather than on expiry, so the order is removed and reported `cancelled: true` (its removal emits `OrderExpired`). Add `trader.buildCancelOrders` and `trader.buildReduceOrders`, which return the unsigned call instead of sending it, so a batch cancel or reduce can ride in the same transaction as other writes. Both encode through the same path as the sending verbs, so the calldata cannot differ. - BREAKING: `Candle` gains a required `lastFillId` field and `getCandles` now selects it. `lastFillId` is the id (`${blockNumber}_${logIndex}`, same key space as a fill's id) of the newest fill folded into the bucket — the exact fill-level watermark a live consumer needs to reconcile an open OHLCV bucket against the chain without a separate fills read. Migration: - Reindex first. `getCandles` selects `lastFillId`, so it errors against an indexer whose schema predates the field. Deploy the matching `indexer/schema.graphql` and run a full reindex before upgrading the SDK. - Constructing `Candle` values? Add `lastFillId` (a `${blockNumber}_${logIndex}` string). Read-only consumers just receive the new field. - BREAKING: `BinaryMarket.strike` and `BinaryMarket.mode` are now nullable. A chain-seeded row cannot know either, and reporting a guessed `"0"`/`"reference"` would mislabel a fixed-strike market as up/down. Narrow both before use; a row read from the indexer always carries them. A market watch can now start without the indexer. `watchMarket(pool, { seed: "chain", marketId })` seeds the book from the pool contract at one pinned block, so a trading view paints while the indexer is unreachable. The option is opt-in: a `watchMarket(pool)` call with no options keeps the indexer snapshot and the existing meaning of `snapshotBlock`. Supply the market id the pool currently serves, because pools are recycled across markets and book reads filter on it. The seed reads that market's row from the BinaryMarketsModule alongside the book, so a chain-seeded watch needs `addresses.binaryModule` configured and covers exactly one pool. The row is what every later order and fill event attributes through. It carries what the module answers — collateral, decimals, nonce and lifecycle state — while indexer-only fields (the question text, the creation history, the traded volume and the origin attribution) stay empty. These do NOT fill in later: a pool that has been seeded is not re-hydrated, and the row survives `stop()`, so the seeded row persists for the store's lifetime. Read the indexer directly when the prose is needed. A chain seed never blanks a richer row. If the same pool was watched from the indexer before, that row's indexer-only fields survive the seed field by field, while every field the module answers is replaced by the fresher chain read. `BinaryMarket.strike` and `BinaryMarket.mode` are now `string | null` and `BinaryResolutionMode | null`. A chain-seeded row carries null for both, because the pool contract answers neither and a strike of `0` is the protocol's sentinel for reference (up/down) mode — writing it would permanently mislabel a fixed-strike market. `boundaryPrice` returns `null` for such a row, which every surface already renders the same way it renders an unposted opening answer. An indexer-sourced row is unchanged and always carries both. Seeded orders likewise carry an empty `createdAt`/`txHash`, so they sort last in `userOrders` until a snapshot dates them. `getLiveStatus()` reports four new fields. `seedSource` says whether the book came from the indexer or the chain, and is `null` before the first watch. `seedBlock` is the block the seed was consistent to. `seedTruncated` is true only when a chain seed reaches its own page budget and read a shallow book. `fillsLoaded` says whether fill history was loaded, which a chain seed does not load: `getLiveFills` returning an empty array now means "never loaded" when `fillsLoaded` is false, and "no fills in the loaded range" when it is true. A failed fill read reports `failure.operation` as `"fill-history"` and leaves the chain-seeded book intact. `getAllOpenOrdersOnchain` accepts `blockNumber`, so a multi-page snapshot of one side can pin every page to one block. - Add `client.getBookTopsOnchain(markets, opts?)` and give `fetchTicker` provenance fields. `getBookTopsOnchain` reads the top of book for many markets from pool state, at one pinned block. It is the chain-sourced sibling of `getBookTops`, which keeps its signature and its indexed source. Use the new read where a quote must not lag behind the indexer, such as a market board or a mark for open positions. Pass the market rows a view already holds; each supplies its own `poolAddress`. The result carries the block every top was read at, plus a map keyed by lowercased market id. A market with an empty book is absent, the same contract the indexed read documents, so the two are comparable key for key. Where the chain declares a Multicall3 the reads collapse into one aggregate call; elsewhere they fan out at two reads per market, so keep the market set to what a view shows. `UnifiedTicker` gains three fields. `lastSource` carries the time of the trade behind `last`, and its absence now means `last` fell back to cached market metadata that can be stale. `statsAsOf` carries the 24h statistics' own as-of, derived from the candle window rather than the response time; `timestamp` is unchanged and remains the time the snapshot was assembled. `perpState` reports one of `"not-applicable"`, `"ok"`, or `"degraded"`, so a missing `markPrice` on a perp whose chain read failed is no longer indistinguishable from a spot market. The perp enrichment still degrades to the 24h statistics rather than failing the whole call. `getOpenPositionsWithPnL` now marks positions from the chain-sourced tops. It previously marked from indexed tops while `getBinaryPositionPnL` marked from chain, so the same position could report two different `unrealizedPnl` values when the indexer lagged. A client configured without chain access still marks on last price, as before. - Retain collateral balance read failures by chain in `CollateralOnWrongChainError.readErrors` and its aggregate cause. Inspect `exchange.collateralReadErrors` after a successful funding operation to detect an incomplete sweep. Sort `unread` in the error constructor, including for directly constructed errors. - Add `CollateralOnWrongChainError.unread` to list chains whose balance read failed. The error message also names these chains, so callers can distinguish failed reads from zero balances. `elsewhere` keeps successfully read balances. `unread` is empty when all reads succeed. - BREAKING: `FillRow` gains two required fields, `takerRemainingQuantity` and `makerRemainingQuantity`. `getFills` and `getUserFills` now return the post-fill remainders — the two raw-unit amounts the `OrderFilled` event carries. A caller can now tell how much of each order was left after a fill directly from the tape, without a second read and without keeping a running total. `getFill`, `getOrderFills` and `getTradeContext` already selected them, so every fill read is now consistent. Migration: code that READS a fill row needs no change. Code that CONSTRUCTS one — a test fixture, a mock, or a synthetic row passed to `pnlEventsFor` or `binaryFillsFor` — must add the two fields. Both are decimal strings in raw base/outcome units, and `"0"` means the fill completed that side's order. - Populate the Hideki (50383) deployment manifest with the live CLOB family, deployed at the same CREATE3 addresses as testnet and mainnet. The `hideki-testnet` deployment resolves real addresses instead of the sentinel-zero template; the CollateralRouter stays absent on Hideki (no wSTT/Permit2 there), so router-dependent helpers keep reporting not-configured on that chain. - BREAKING: `hidekiTestnet` (50383) now points at the **v2 regenesis**, a different genesis reached through different endpoints. Chain id `50383` has changed meaning: anything you cached, indexed or hard-coded against Hideki before 2026-09-17 describes a chain this definition no longer reaches. **Why it is breaking even though the id is unchanged.** Hideki was rebuilt on 2026-09-17 on the SAME chain id with fresh state, and the deployer's nonces restarted at genesis. So an address is no longer a stable identifier across the two chains: most v1 addresses hold a *different* contract on v2, while a few — the whole binary/CLOB family, because it is deployed via CREATE3, and the bridged USDso synthetic — come back at the *same* address. That mixture is the hazard. A stale cache does not fail loudly; it silently answers with the other chain's history. **Migration.** - **To move to v2 (recommended):** take the new definition and **discard every piece of Hideki state you hold** — indexer databases, address caches, block-height watermarks, saved deployment manifests. Do not reconcile v1 addresses against v2 ones, and do not carry a v1 start block across: v1 is past 586M blocks while v2 is near 2.4M, so a v1 watermark parks you at a head v2 will not reach for a long time. - **To stay on v1:** pin `@somnia-chain/markets-sdk` to the previous minor, or construct the chain yourself with viem's `defineChain` using the v1 host `api.hideki.infra.testnet.somnia.network` and Multicall3 `0x540B091b608f54E603c5dC19F6b2d955e1d2D131`. Note v1's `/ws` already answers 502, so a websocket consumer is already broken there. - **Tell the two apart by height, never by chain id** — nothing in a signature, a wallet or this definition distinguishes them. **What changed in the definition.** The `api.hideki2…` HTTP and websocket endpoints, and the **canonical** Multicall3 at `0xcA11bde0…`, which v2 predeploys at block 1 — the reverse of v1, where the canonical route was unreachable and the bridge infra deployed the same runtime at `0x540B091b…` (which holds no code on v2). `net_version` moved `50000258` → `50000259` and still is not the chain id. **The bridge lane was redeployed with it.** All five **Shannon-side collateral routers** moved (STT, USDso, WBTC, WETH, HBTT); everything on the Hideki side — every synthetic and the whole core deployment (mailbox, ISM, merkle hook, validator announce, ICA router, proxy admin) — came back at its v1 address, because the same deployer replayed its sequence onto a chain whose nonces had restarted. All ten routers were re-read on chain and enroll their counterpart in both directions. Relayer delivery on v2 is not yet proven. **Scope.** This injects the **perps** plane only. The binary/CLOB family was the other half of the problem when this branch was cut — every one of its addresses was codeless on v2 — and that is now **resolved on `main` by TRAD-363**, which replayed the CREATE3 salt generations so the family came back at the same shared addresses; nothing here overlaps it. Spot is the remaining gap and it is a gap in coverage, not a stale record: dreamDEX spot **is** live on v2 as of 2026-09-18, but `spot-tokens.hideki-testnet-development.json` is still the empty sentinel it has always been, so those markets are simply not indexed yet. That is left for the plane's owners rather than guessed at here. - Add `getOrderHistoryPage` as the preferred name for paginated indexed order history while retaining `getOrdersPage` as a compatibility name. - Add opt-in indexer freshness and response-associated read observations through `client.getIndexerFreshness()` and `client.createObservedReads()`. The existing `getSyncStatus()` remains metadata-only and `fetchStatus()` remains local-only; `fetchDataStatus()` combines independent data-source measurements. Add precise per-asset delivery health through `getPriceHealth()` and `listPriceHealth()`, alongside unchanged legacy price status. Add `useWatchMarketResult`, `useWatchUserResult`, and `useWatchPriceResult` for typed acquisition errors while existing hooks retain their background warning behavior. - `listLiquidations` returns liquidation events with an optional `kind` filter, applied server-side. It replaces `getLiquidations`, which returned an array under a `get*` name. The old name forwards the filters verbatim and is deprecated, the same one-release compatibility path `getFundingRateHistory` took. Liquidation rows now come back in a stable total order: `timestamp`, then `blockNumber`, then the `id` primary key. Previously the query sorted by `timestamp` alone. Every row a block emits carries that block's timestamp, so ties had no defined order between requests and an `offset` page boundary landing inside one block could skip a row or repeat it — silently, in the skip case, and even against a table nobody was writing to. This fixes the tie ordering. It does not make offset paging snapshot-consistent: these are live rows, so an event indexed ahead of your offset still repeats a row and a reorg that removes one still skips a row. Paging the read is approximate; narrow with `account`, `pool` and `kind` when it must not be. This changes the order of rows that tie, so a caller that depended on the previous arbitrary tie order sees a different sequence. No row is added or removed. `LiquidationEvent` rows are stages of one mechanism, not repetitions of one event. A single cascade therefore emits many rows. Without the filter, a page can be consumed entirely by one account's summary and per-position legs, and a caller looking for a different kind concludes there were none. `kind` is matched exactly, in the schema's own casing. It is typed as `LiquidationKind`, a new exported union over the `LIQUIDATION_KIND` values. A misspelling is now a compile error rather than a silently empty page. `LiquidationEvent.kind` stays a `string`: a row carries whatever the deployed indexer wrote, which can outrun a pinned SDK. The exported `isLiquidationKind` guard bridges the two — narrow a row's kind to switch on it, or to pass it back into the filter. `getPerpFeedStatus` reads whether a perp pool's mark feed is live and how much open interest is riding on it. Prefer it to `getPerpState` for MONITORING a feed. `getPerpState` batches with `allowFailure: false` and one of its legs is a bare `IOracle.getPrice()`, so a dead or rotated oracle rejects the whole read — losing the mark verdict in the one case it matters most. `getPerpFeedStatus` reads the mark verdict and the open interest as a required pair that no oracle can revert, and takes the index timestamp best effort: a reverting index leg leaves `indexUpdatedAt` undefined instead of failing the call. It returns `PerpFeedStatus` and throws `SomniaMarketsClientGetPerpFeedStatusError`. Read `getPerpState` instead when you want the full pricing and funding picture and a dead oracle is a legitimate reason to fail. `listRegistryMarketsChecked` is `listRegistryMarkets` with the rows the SDK could not parse counted, as `{ markets, dropped }`. A malformed row is dropped rather than failing the page, so the plain read's length cannot tell "no markets" from "markets this SDK could not parse". `dropped > 0` says the registry holds markets the sweep did not return. Use it wherever absence from the sweep is treated as proof - a monitor publishing coverage over it, say. It returns `RegistrySweep` and throws `SomniaMarketsClientListRegistryMarketsCheckedError`. The two new options (`kind`, and `offset` on the new name) are additive. The ordering change is not: it is the one thing in this release an existing `getLiquidations` caller can observe, and only for rows that share a timestamp. - BREAKING: Order and fill reads gain the filters a client needs to find its own work, and the client can wait for the indexer to catch up. Consumers that construct `OpenOrder` or `OrderRow` values must add `userData`. Custom `SomniaMarketsClient` implementations must add `waitForIndexedBlock`. `getOpenOrders` and `getOrders` now return each row's `userData`, the opaque uint64 tag the placement carried, as a decimal string. `OrdersOptions.userData` filters on it, so a client correlates rows with the orders it sent instead of keeping its own map. `OrdersOptions.market`, `OrdersOptions.pools` and `OrdersOptions.markets` scope one read to a market, or to several venues or markets at once, so the order scope now mirrors the fill scope in both forms. An empty array matches nothing. Supplying a singular scope with a list intersects both constraints. A list is capped at 100 entries to bound the generated GraphQL predicate; pool entries also require classification reads. Fill `market`, `markets` and `pool` identifiers must be non-empty strings. Invalid runtime values now reject with `InvalidInputError` instead of reaching the indexer or leaking a JavaScript type error. `getOpenOrders`, `getOrders` and `countOrders` now require a non-empty owner string before normalization. `getUserFills` and `countUserFills` require a non-empty account string. `getOrderFills` also requires a non-negative bigint or canonical unsigned decimal order id. Invalid runtime inputs reject with `InvalidInputError` before network I/O. `SomniaMarketsClientGetOrderFillsError` exports the complete `InvalidInputError | IndexerError` contract for the one-order fill lookup. `sinceBlock` bounds order and fill reads by block. On fills it bounds `Fill.blockNumber`, which is the exact resume point after processing up to a known block; a bound in seconds cannot be exact, because blocks share timestamps. On orders it bounds the PLACEMENT block. It does not report a later amend, fill or cancel of an order placed before the bound. `client.waitForIndexedBlock(chainId, block, opts)` resolves once the indexer has processed a block, reporting the wait and the lag it started from. It rejects with the new `IndexerWaitTimeoutError` when the deadline passes, which is a different decision from a failed read. The wait ends when the client's configured signal, its own `signal`, or the owning client's live-read lifecycle closes. A later wait relinks to the replacement owner signal. Indexer request deadlines remain enforced while a successful HTTP response body is being parsed. Caller cancellation still keeps its exact reason; an SDK-owned deadline rejects with `IndexerError`. None of these columns is indexed. Pair a tag or block bound with a pool scope and a status to keep the scan small. - Write the perp linked-wallet rail, not just read it: `proposePerpWalletLink` / `acceptPerpWalletLink` / `cancelPerpWalletLinkProposal` / `unlinkPerpWallet` for the two-sided consent handshake, and `repayPerpMainFunding` / `recallPerpMainFunding` for both routes home. Isolated margin on perps is one wallet per position, and linking a sub-account was previously reachable only through raw viem. - Size a linked child's perp order off both funding wallets, the way the pool funds it. `previewPerpOrderMargin` and `getMaxPerpOrderSize` under `autoPull` now resolve the funding payer and add the main's wallet capacity as a second leg, judge a partial pull by the margin gates rather than requiring full top-up coverage, and withhold the main's leg while the child still owes a prior payer. New `fundingPayer`, `mainWalletCapacity`, `ownWalletPull`, `mainWalletPull` and `mainFundingBlocked` fields, plus `"mainWallet"` and `"mainFundingBlocked"` on `limitedBy`. `topUpRequired` is the total requested, not one wallet's debit. - Add a watchable perp plane, so liquidations and funding settlement can be streamed. `client.watchPerpMarkets()` hydrates the recent liquidation waterfall legs and per-account funding settlements from the indexer once, backfills the seam from chain, then streams both from MarginBank and LiquidationEngine — the same snapshot-then-live guarantee `watchMarket` gives a book, and a reconnect heals from chain with no second indexer touch. Read them with `listLiveLiquidations` / `listLiveFundingPayments`, or the `useLiveLiquidations` / `useLiveFundingPayments` / `useWatchPerpMarkets` hooks. Rows are the same `LiquidationEvent` and `FundingPayment` types the one-shot reads serve, in the same order, so a live page and a paged history merge by `id`. They overlap, because the live window is hydrated from the newest indexed rows. All seventeen waterfall kinds are produced. One scope covers the whole venue rather than one per pool: both sources are singletons, and the account-level legs carry no pool, so filtering is at read time. `getPerpPlaneStatus()` reports the watch state, and `TailStatus.watchCount` now counts the plane as one scope so a plane-only session no longer reports itself idle while streaming. On a deployment with no plane — no perp market indexed, or a MarginBank naming no liquidation engine — the scope registers, answers empty and reports `"live"` rather than waiting forever on a stream that has no source. The indexed `getFundingPayments` now orders by `(timestamp, id)` rather than by timestamp alone, matching what `listLiquidations` already does for liquidations with `(timestamp, blockNumber, id)`. Settlements share a block timestamp, so without a tie-break which rows a `limit` kept at a page boundary was undefined and the live and indexed halves of the same feed could disagree about which rows exist. The live store and the plane hydration sort on each row type's own total key, so all three paths page identically. React: `useWatchPerpMarkets(opts?)` takes one options object (`enabled`, `marginBank`), the two read hooks take the same options object as their client members, and `useWatchPerpMarketsResult` exposes the typed acquisition failure that the warning-only hooks otherwise turn into an empty feed. `TailStatus.watchCount` also counts a discovery-only `watchAllMarkets(true)` as one scope, and a socket drop on such a session is now healed: the reconnect guard and its recovery set were built from pools alone, so a venue with no market yet held the factory socket, reported an idle tail, and never backfilled a `MarketCreated` missed during the outage. A released all-markets watch now stays counted and healed through its release linger too. A block whose only logs are ones the tail does not fold (such as MarginBank's `PositionUpdated` or `FeeCharged`) no longer wakes store subscribers. After `stopLive()` the plane's last rows stay readable, like every other live read; `getPerpPlaneStatus()` then reports `"unwatched"`. - `getPerpPositionAnalytics` and `listPerpPositionAnalytics` now return the position's signed `size` on an unpriceable row, alongside `pool` and `asOfBlock`. Only the pool's price read fails in that case; the MarginBank position read succeeds at the same block, so the size was already known and was being discarded. A caller could not tell a position nobody can price from no position at all, and the bank's `activePerpPools` list covers pools the account has orders in as well as pools it holds a position in — so an order-only pool with a stale mark feed arrived indistinguishable from a real position and rendered as an empty row. Filter on `size !== 0n` to drop those. - `getOrderOnchain` accepts a trailing options object with `blockNumber`, so an order can be read as of the end of a past block rather than only at head. `SomniaMarketsClientGetOrderOnchainError` is exported alongside it. This recovers a maker order a fill already removed. A fill deletes its maker order in the same transaction, and `OrderFilled` names the maker only by id, so at head that order is gone; `fill.blockNumber - 1n` is the last height holding it. What comes back exactly is the maker's identity - `isBid`, `owner`, `userData`, `expireTimestampNs` - which no fill log carries and a consumer cannot otherwise resolve for an order that rested before its process started. Everything else is already in the event: `fillPrice` (a fill executes at the maker's resting price, so this IS the maker's price), `quantityFilled` and `makerRemainingQuantity`. Both quantities are block-level, not per-fill. A block read cannot see between transactions, and an earlier transaction in the fill's own block can move either one: a fill lowers `quantityRemaining`, and `reduceOrder` lowers `fullQuantity` and `quantityRemaining` together under the same id. Two cases answer plausibly rather than failing, so both are documented on the option: a partial fill leaves the maker order in place with a smaller `quantityRemaining`, so a read at the fill's own block succeeds with different numbers; and an order placed and filled inside one block is absent one block earlier, where the placement is in that same block's `OrderPlaced`. A pinned read far behind head needs a node with archive state, so this suits a live tape rather than a backfill. A negative block is rejected with `InvalidInputError`. - `getVaultBalance` accepts an optional `blockNumber`, so a vault balance can be read as of a past block rather than only at head. A caller answering one request from several reads needs them all at one height. The withdrawable balance is contract state behind an append-only payout history, so no indexed entity reconstructs it at a past height, and serving this half at head mixed heights inside one response. A pinned read far behind head needs a node with archive state; a recent block answers against a full node. - BREAKING — `pnlEventsFor` now rejects rows carrying a malformed number `pnlEventsFor` reads each row's `id` for the first time, to order events by chain position, and throws `InvalidInputError` when it cannot: an `id` that is not `${blockNumber}_${logIndex}`. Previously `id` was not read at all and ordering used the timestamp alone, so a malformed `id` was simply ignored, leaving that row wherever the input happened to put it. It now has to be well-formed, because a row that cannot be placed in chain order cannot be folded into an average-cost roll without silently changing the result. The same check now covers every number the fold reads: `FillRow.quantity`, `FillRow.fillPrice`, and a router action's `amount`. These went through a bare `BigInt` before, which threw a native `SyntaxError` on `"abc"` — an error outside the documented error union of this function and of both client methods that wrap it, so a corrupt row escaped as something no consumer was told to catch. **Migration:** rows from `getUserFills` and the router-action reads already carry well-formed ids, so callers passing indexed data through are unaffected. A caller assembling its own `FillRow`/`RouterActionRecord` values — test fixtures especially — must set `id` to `` `${blockNumber}_${logIndex}` ``, and must give `quantity`, `fillPrice` and `amount` as unsigned decimal strings. EVERY one of those positions is now checked the same way: a sign (`"+1"`, `"-1"`), an `0x`/`0o`/`0b` prefix (`"0x10"`), surrounding whitespace (`" 7 "`), or an empty string is REJECTED rather than coerced. `BigInt` accepted all of them, so a fixture that happened to use any of those forms parsed before and throws now — normalize those fields to plain decimal digits. `timestamp` is no longer read at all, so its value cannot affect the fold. The new `PnlEventsForError` export names the error union. `getBinaryPositionPnL` and `getOpenPositionsWithPnL` fetch their own rows, so the same failure means the INDEXED data is corrupt rather than that the call was wrong. Those two report it as `IndexerError` — the retry-or-escalate path — with the parse failure on `cause`. A consumer catching `InvalidInputError` around either method to surface "bad arguments" must add an `IndexerError` arm. `SomniaMarketsClientGetBinaryPositionPnLError` is now exported and names that method's full error union. ### Fixed — same-second PnL events are folded in chain order `pnlEventsFor` ordered events by their unix-second timestamp alone. Fills are fetched newest-first and the sort is stable, so two events sharing a second stayed in reverse chain order: a buy and a sell in the same second were folded sell-first, the sale was clamped against a leg the buy had not yet opened, and the realized profit disappeared. Events are now ordered by the (block, log) position their ids already encode, as exact integers, and by nothing else. The timestamp is metadata rather than a chain position: leading with it lets a wrong one override the position it disagrees with, and it can never break a tie the position cannot, since a chain's timestamps are monotonic in block height. Figures change only where that ordering actually mattered: an account with two events whose result depends on which came first — a buy and a sell of the same outcome, or a mint and a sell of what it created. Two buys, or events on independent legs, or input that already happened to be oldest-first, are unaffected. ### Fixed — closing an outcome leg now releases all of its cost basis `computePositionPnL` released cost basis through a rounded average cost, so closing a leg completely could leave a sliver of cost behind on a leg that held nothing. A leg of 7 raw outcome-token units costing 5 raw collateral units has an average that does not divide evenly, and a full sale released only 4 of those 5 units. Two consequences, both small per trade and neither self-correcting: - The realized PnL of that sale was overstated by the unreleased remainder. - The remainder stayed on the leg after its quantity reached zero. Because average cost is total cost divided by quantity, anything bought on that leg afterwards inherited the leftover, so its average cost read high for the rest of the position's life. A sale that takes a leg's whole quantity now releases the leg's whole remaining cost, leaving it genuinely empty. Partial sales are unchanged and still release cost proportionally through the floored average. The size of a single correction scales with the position: the released cost was short by up to one raw unit per whole outcome token on the leg. A five-token close was off by units, a five-thousand-token close by thousands of them - a few thousandths of a collateral unit at six decimals - and a five-million-token close by around five whole units. Corrections accumulate across repeated full closes of the same outcome, so an account that has opened and closed a leg many times sees the largest movement, and any figure cached from a previous version may differ by that accumulated drift. - BREAKING: a live price watch now requires `priceFeed.quote`. Without it `watchPrice` rejects with a `NotConfiguredError` naming the key. To migrate, pin the feed to one quote - `priceFeed: { url, quote: "USDC" }`, which is what `SOMNIA_TESTNET_PRICE_FEED` and `SOMNIA_MAINNET_PRICE_FEED` already do, so configurations built from those constants need no change. The one-shot reads (snapshot, history, candles, batch prices, feed catalog) are unaffected and still work without a quote. The reason is the tick tape below: it now streams on a `requestId` cursor, and one upstream request prices every symbol in a tick, so that id is unique within a single feed but not across a base that spans several quotes. Such a stream can carry two rows sharing an id, and a batch boundary falling between them would skip the second permanently. An unpinned base was already double-counting on this path; refusing it is the alternative to silently losing ticks. The live price tape now streams only the ticks recorded since the last one delivered. It subscribed with a fixed window and re-sent that whole window on every push, so a watcher paid for one hundred rows to learn about one new tick. The tape is now a Hasura stream cursored on `requestId`, seeded from the newest tick in the hydration snapshot and advanced on every push, so a reconnect resumes exactly where it stopped with no gap and no re-delivery. The current price feed is unchanged: it is full-state, and re-subscribing is all it needs. The cursor is carried as an exact decimal string and compared as a `bigint`. A `requestId` past 2^53 would otherwise round when it went through a JavaScript number, and a cursor that rounds up skips the tick it lands past. A row whose `requestId` is unusable can no longer corrupt the cursor; it is ignored, and the rest of the batch still advances it. `PricePoint.raw.price` and `PricePoint.raw.ema` now report `"0"` for a tick the feed recorded with no price, matching the `price` and `ema` numbers beside them. They previously reported `null` despite being typed as strings. A price subscription the server rejects is now reported instead of passing unnoticed. Only a dropped socket was surfaced before, so a rejected subscription left the tape silent while the feed still read as connected. Rows arriving from the feed are checked against the fields the parsers actually read. A row short of the selection set, or carrying a non-string where a price belongs, is skipped rather than parsed into a malformed tick. - Two new root exports align a raw on-chain value onto a venue's grid: `snapRawToGrid(raw, step, direction)` and `roundPriceToTick(price, tickSize, direction)`. Both take and return `bigint`. They serve the engine path. A caller who holds raw values and calls `Trader.placeOrder` directly had no exported helper for this, so each one re-derived tick and lot rounding by hand. The unified facade already snaps for itself, so `exchange.createOrder` callers do not need these. `direction` is `"down"` or `"up"` and belongs to the value, not to the order side. A resting limit rounds toward the caller. A crossing limit rounds away so it still sweeps the levels it was priced against. Pass the direction you mean. Use `roundPriceToTick` for a price. It never returns `0`, which is the one price the pool always rejects: a positive price below one tick comes back as one whole tick. `snapRawToGrid` floors a quantity to `0` instead, because an empty size is a real answer the caller must branch on. Both throw `InvalidInputError` on a non-positive step. Neither checks `minQuantity`, which the pool tests before lot alignment. - BREAKING: four reads no longer report a failed read as an absent or a zero value. A read that did not complete now throws `RpcError`. Each read keeps its own documented `null` for a real absence, and that meaning is now narrow and stated per read below. - `getClosingPrice` returns `null` only for a pool that does not declare `closingPrice()`. A transport failure throws `RpcError`. A revert that names an error or a reason throws `ContractRevertError`. Migration: keep treating `null` as "this pool predates the capture surface", and handle a thrown error as a failed read. - `tryGetPerpLeverageImSurcharge` returns `null` only for a revert from the view itself, which is the case it is named for. A read that never reached the chain throws, and so does a `marginBank` address that does not declare the view. Migration: in a health sweep, catch the error per account if the sweep must continue, and report that account as unknown rather than as having no surcharge. - `getSystemInfo` throws when any configured contract read fails. The one exception is `settlement()`: a pre-v2 module does not declare that view, so its absence still reports `settlement: null` and the configured fallback. It no longer resolves with `marketCount: 0n`, `operatorId: 0`, and a zero `venueId`, which a dashboard could not tell apart from a freshly deployed creator. Five leaf fields are no longer nullable: `MarketCreatorInfo.owner`, `SystemInfo.oracle.owner`, `SystemInfo.oracle.binaryModule`, `SystemInfo.usdc.symbol`, and `SystemInfo.usdc.decimals`. Migration: handle a thrown error where the call is made. Keep the checks on the `marketCreator`, `oracle`, and `usdc` objects themselves, because each is still `null` when its contract is not configured; only the checks on the five leaf fields inside them can go. - `getTransactionSummary` returns `null` for a malformed hash and for a transaction the node reports as not found. A read that did not complete throws. Migration: if the previous best-effort behavior is what you want, add `.catch(() => null)` at the call site. `PriceFeedStatus` gains an `"error"` member. It means the price-feed server rejected or terminated that asset's subscription. This was previously dropped in silence: the status stayed `"live"` while the price froze. Reads keep answering with the last values they hold, so check the status before rendering a price. The rejection is also emitted as a `warn` event on the client's debug channel. The subscription is torn down, so stopping the watch and starting a new one re-subscribes from a fresh snapshot. Migration: handle `"error"` wherever you switch on `getPriceStatus` or `useWatchPrice`. - BREAKING: `loadMarkets()` no longer throws when the market registry read fails. It contains that failure and reports it through the new `registryReadError` accessor. An unreadable indexer used to end the whole load. That also ended perp discovery, which reads the PerpPoolFactory and needs no indexer once a factory is configured. An empty indexer was worse: it returned an empty market list that no caller could tell apart from a venue with no markets. Both cases are now answerable. `registryReadError` is `null` when the read completed and holds the typed error when it did not — in practice an `IndexerError` carrying `operation` and `detail`, with the underlying failure in `cause`. So `registryReadError === null` together with an empty `symbols` is the unambiguous "this venue has no markets". The value is cleared on every load that runs, so it always describes the last one. Retry with `loadMarkets(true)`, because a bare `loadMarkets()` early-returns the cached registry. Migration: replace a `try`/`catch` around `loadMarkets()` with a `registryReadError` check after it. ```ts await exchange.loadMarkets(); if (exchange.registryReadError) throw exchange.registryReadError; ``` Discovery coverage per market kind is now stated on `loadMarkets`. Perp is chain-complete and survives an empty or unreachable indexer, but with no indexed perp row the factory is found only from a configured `addresses.perpPoolFactory`. Binary and spot still come from the indexer in this method; new binary markets are discovered from the chain by `watchMarkets({ discover: true })`. - BREAKING: `IndexedOperator` and `IndexedVenue` gain a required `updatedAtBlock` field. Reading rows is unaffected — `listOperators`, `getOperator`, `listVenues` and `getVenue` simply return more than before. But if you CONSTRUCT either type anywhere (a test fixture, a mock, an adapter that builds rows by hand), that object literal no longer compiles: ``` error TS2741: Property 'updatedAtBlock' is missing in type '{ … }' but required in type 'IndexedOperator'. ``` To migrate, add the field to each literal you own. It is a `bigint`, so `updatedAtBlock: 0n` is enough where the value is not meaningful to your test. Reach for it when you need to tell two writes apart. Somnia blocks are ~100ms, so roughly ten consecutive blocks carry the same `updatedAtTimestamp` — a value that changes and changes back inside one second leaves the timestamp unmoved. The block number moves whenever a tracked write lands in a later block, so it is the field to difference against when "was this row written?" matters more than "when, roughly?". **It tracks CONTROL-PLANE writes, not every write to the row.** The indexer stamps it on the registry events and deliberately not on denormalized rollups, so market counts, cumulative volume and collected fees rewrite the row with this field unchanged. Without that exclusion ordinary trading would move it constantly. The consequence for you: an unmoved `updatedAtBlock` means no tracked control-plane event landed, NOT that the row was never written. **The two rows move on different events, and the sets are not the same.** `IndexedOperator.updatedAtBlock` moves on `OperatorRegistered`, `OperatorUpdated`, `OperatorEnabledChanged`, the three ownership-transfer events, and `VenueCreated` — that last one because creating a venue bumps the parent operator's `venueCount`. `IndexedVenue.updatedAtBlock` moves on `VenueCreated`, `VenueUpdated` and `VenueEnabledChanged`, and on no operator event at all: an ownership transfer leaves every venue marker where it was. Do not difference a venue marker expecting to see one. **It distinguishes updates across blocks, and nothing narrower.** It is not a write counter and does not identify a write: two updates in the SAME block carry the same number, so differencing this field sees them as one. If you must not miss a write within a single block, read the events rather than this column. It is typed `bigint`, not the raw decimal string its neighbouring columns use, because a block number is a protocol integer and `SDK-TYPE-007` requires those to be exact at the boundary — the generated wire string is now mapped to `bigint` behind the four reads above rather than handed through. The strings beside it predate that seam and are unchanged. A row whose `updatedAtBlock` is not an unsigned decimal integer now fails those four reads with `IndexerError`, naming the GraphQL document, rather than escaping the SDK as a native `SyntaxError` a caller narrowing on `IndexerError` could not catch. The shape is checked before conversion rather than relying on `BigInt` to throw, because `BigInt` accepts more than the wire format allows: `""` and whitespace become `0n`, `"0x10"` becomes 16, and a leading minus is taken as a sign. Those are the malformed values likeliest to arrive, and every one of them would otherwise have surfaced as a plausible block number. **This rejects the whole read, not the row.** A single malformed `updatedAtBlock` fails `listOperators` / `getOperator` / `listVenues` / `getVenue` outright; there is no partial result that skips the bad row. - BREAKING: `UnsignedCall` now carries `chainId` (and `functionName`). Pluggable transaction signing: the executor seam. Migration: every `build*` verb already stamps it, so a consumer that only READS unsigned calls changes nothing. Code that CONSTRUCTS an `UnsignedCall` itself (a custom relayer or bundler feeding calls to an executor, a test fixture pinning the shape) must add `chainId: `; every built-in executor refuses a call without a matching one. `functionName` is optional on a hand-built call (it only feeds error messages) and is set on every call the SDK builds. `createTrader({ executor })` takes any `Executor` — who signs and sends, separated from what the write is. The built-ins come from `client.createExecutor(spec)`: a private key or local account, a wallet client, a Somnia session seed, or a safe driven by any inner executor (with optional `MultiSend` batching, so an approval and the order it enables land in one safe transaction). Any object implementing `Executor` works too. - The writer hands an executor one or more `UnsignedCall`s and decodes the result from the receipt the executor returns; it never fetches a receipt it already has. A safe's swallowed revert surfaces as `ContractRevertError`, exactly like a reverted receipt. - An approval or operator grant a write needs rides in the SAME executor call as the write (one safe transaction through MultiSend; one transaction each, in order, for an executor that cannot batch — which stops at the first revert and never sends the calls behind it). - Whatever a custom executor or signer throws crosses the boundary as an SDK error with the original as `cause`; a local signer's own failure now surfaces as `RpcError` rather than raw. - `UnsignedCall` now carries `chainId`. Every built-in executor refuses a call for another chain with `InvalidInputError` before anything is signed (`assertCallsOnChain` is exported for custom executors). - `decodePlaceOrder`, `decodeAmendOrder` and `decodeAmendOrders` are exported pure functions over a receipt, byte-for-byte what the sending verbs produce. - `Trader.executor` names who sends. Verbs that sign rather than send (`signRedeemAuth`) need an executor with `signTypedData`; a safe or a session key has none and those verbs throw `SignerRequiredError`. - Existing callers change nothing, with one observable exception: a `walletClient` trader's pre-send rejection now reports `RpcError.operation === "sendTransaction"` (the wallet executor sends the encoded call) where the removed path reported `"writeContract"`. Code branching on that field updates the string. `privateKey` / `account` / `walletClient` otherwise build the matching built-in executor on the engine's own nonce lane and send ordering. - One exchange over every chain the account trades on — Somnia L1 and Hideki — with a safe as a first-class trader `SomniaMarkets` accepts `chains: [...]` and builds one engine per chain. Reads merge across chains, writes route to the chain a market lives on, and every struct names its `chainId`. The single-chain config is the one-element case and behaves as before. ```ts const exchange = new SomniaMarkets({ chains: [l1, hideki], account: safeAccount }); const bal = await exchange.fetchBalance(); // bal.USDso.total, bal.USDso.byChain[id] await exchange.createOrder("BTC/USDso:USDso", "limit", "buy", 1, 60_000); // routed ``` - **Chain-qualified identity.** `Market`, `Tradable`, every `Unified*` struct and every `UnsignedCall` carry `chainId`. `ChainMarketRef` / `ChainOrderRef` (`{ chainId, id }`) are accepted wherever a bare id is; a bare id that two chains know throws `InvalidInputError` instead of guessing. Symbols stay chain-free and unique across the union. - **The account.** `account: { address, executors: { [chainId]: Executor } }` — one address on every chain (the safe factory guarantees it), one executor per chain, built with `engine.createExecutor(spec)` (the executor seam shipped separately). - **Collateral placement.** A market's collateral must sit on its chain. On a multi-chain exchange `createOrder` checks the escrow balance there and throws `CollateralOnWrongChainError` — chain, token, shortfall, where the funds are, and a ready bridge plan — unless `{ funding: "auto" }` (bridge, wait, place) or `{ funding: "skip" }`. `exchange.bridge` plans and executes transfers between the exchange's chains through the account's executors; `exchange.ensureFunds` is the helper behind auto-funding. - **Portfolio netting.** `netInternalTransfers` drops bridge legs between the exchange's own chains from a funding list; `fetchPortfolioAnalytics` applies it, so a rebalance never reads as a withdrawal plus a deposit. - **React.** ``; every hook takes a trailing `{ chainId }`. - **Per-engine nonce lanes.** Each engine owns a nonce manager; the module-level fallback is gone. - Smart accounts, end to end: the factory account model of `@somnia-chain/accounts-sdk` (new runtime dependency) is reachable through this SDK alone — sending trades AS an account, and the whole account lifecycle around it. - **The executor.** `client.createExecutor({ smartAccount, inner, through?, signer? })` builds an executor whose actor is a factory account, including an account owned by other accounts. The accounts SDK builds the safe transaction (batching through its own MultiSend batcher from committed manifests — no `multiSend` knob); the `inner` executor sends the outer transaction, so writes keep the engine's realtime send, nonce lane and retry stance. With no `signer` and no `through`, nothing signs and nothing is read (the pre-validated path — the session-key arrangement); a `signer` or a `through` chain of parents switches to one owner signature the accounts SDK nests level by level, at any depth. An explicit `gas` below the accounts SDK's execution floor (2,000,000) is raised to it. - **`client.accounts`** — the one-chain capability: `addressOf`, `find`, `nextFreeIndex`, `ownersOf`, `custodyChain`, `authorityPath` (yields the executor's `through`), `tree`, `holdings`, `health`, and the build verbs `buildCreateAccount`, `buildAccountAuthorization` + `buildCreateAccounts` (one signature, every covered chain), `buildAddOwner` / `buildSwapOwner` / `buildRemoveOwner` / `buildChangeThreshold`, `buildTransfer`. Reads ride the client's own chain transport; every write returns `UnsignedCall`s for any executor to send — owner management and transfers through the account's own smart-account executor, so the account administers itself. - **`exchange.accounts`** — the multi-chain surface: `scan` across every covered configured chain, `buildCreateAccounts` fanning one signed authorization out into one creation call per chain, and `accountFor(accountId, index, { sessionSeed })` building the `SomniaMarketsAccount` the exchange trades through — one line from seed to tradable multi-chain account. - **Named failures.** A revert while sending through an account is attributed to the account's own `execTransaction`, and a best-effort replay names the actual cause: the account's `GS` code as a sentence (via the accounts SDK's catalogue), or — accounts being Safe 1.5.0, which propagates inner revert data — the inner call's real revert, including this SDK's own contract errors. - **Refusals before anything signs**: a chain the accounts SDK has no deployment for (5031 / 50312 / 50383 are covered), an `inner` on another chain, a `through` chain with no way to sign — all `InvalidInputError` at construction. - **Sign now, send later.** `client.accounts.buildExecution({ account, calls, signer, through? })` prepares an execution whose signer is not the sender: the owner signs the returned payload, and `toCall(signature)` is a finished call ANY funded key may broadcast (send it with at least `gasFloor` gas) — the sponsorship flow, without importing the accounts SDK. Pass `nonce` when it is already known (`0n` prepares an undeployed account's first action, read-free). - **`indexerUrl` is now optional.** A chain-only client (smart accounts, executors, on-chain reads) constructs without one; every indexer read and live watch on such a client throws `NotConfiguredError` naming `config.indexerUrl`. Nothing changes for clients that pass it. - New root exports: `SmartAccountsClient`, `ExchangeSmartAccounts`, `SmartAccountSigner`, `SmartAccountInner`, `PreparedAccountAuthorization`, `AccountAuthorizationTypedData`, `UnsignedAccountCreation`, the read/build error unions, the pure `describeCoverage` helper, and the accounts-SDK result types the members hand back. New docs: `docs/ACCOUNTS.md` and `docs/how-to/trade-through-a-smart-account.md`. - `listSeriesLatestMarkets` returns the most recent market of every rolling series that has rolled at least once - one row per series, joined server-side. A series row says how often it should roll; a market row says when it was created but not which series it belongs to. This read is the join between them: the question "when did each series last roll", answered server-side rather than by fetching every market and grouping client-side. It is PAGINATED, like every list in this SDK — one request per page of at most `limit` rows (default 100), not one request for all series however many there are. A caller with more series than a page must cursor through with `offset` until a short page comes back. It takes `ListSeriesLatestMarketsOptions` (`limit`, `offset`), returns `SeriesLatestMarket` rows (`series`, `market`, `createdAtTimestamp`) and throws `SomniaMarketsClientListSeriesLatestMarketsError`. A series that has never rolled has no row. Absence means "no market yet", not "stalled"; nothing indexed says when a first roll is due, so a caller should report such a series as unjudged rather than anchor on its registration time. - Add partial offset pagination for authenticated unified order history. Each row now carries immutable market and owner identity, while unresolved rows remain observable. ### Patch Changes - `getBookTops` is documented as covering every market kind, not only binary. A spot or perp pool address and a binary market id are both valid, and may be mixed in one call. - The multi-chain collateral pre-check now requires what the pool will lock `createOrder` on a multi-chain exchange checks the escrow balance on the market's chain before it sends anything. That check sized a binary buy as the human `amount × price`, a float, with no builder-fee headroom. An order with a `builderFeeBpsTimes1k` could pass the check with exactly the principal and then revert at the pool. The check now uses the same integer commitment the writer and `BinaryPool` use: ceil principal plus the builder fee's ceil-rounded headroom, so `CollateralOnWrongChainError.needed` is the amount the pool pulls. A spot buy's principal is sized by integer math too. - ### Fixed — a complete set now records the collateral it actually cost `computePositionPnL` priced each leg of a complete-set mint or merge at half a collateral unit per token, then multiplied back out. That representation loses the cost in two ways. On a **zero-decimal collateral** one whole unit is `1`, so half of it floors to `0` and the entire set was recorded as costing nothing. Every later sale of those tokens then realized pure profit, and the position's cost basis read zero for its whole life. On any collateral, an **odd raw quantity** floored a unit away on each leg: seven raw units booked three and three against a set that cost seven. Both legs now split the set's own aggregate quantity, with the odd unit going to the NO leg, so the two shares always sum to exactly what the set cost. A mint followed by a merge of the same amount nets to precisely zero. Releasing cost on a **partial** sale had the same defect from the other direction: it scaled a per-unit average through one whole collateral unit, which rounds to zero whenever a leg's cost is smaller than its quantity. A leg of five tokens costing two released nothing at all when four were sold, reporting the whole proceeds as profit and stranding the cost on the one token left. Partial releases now take the aggregate ratio directly. That is more exact rather than merely equivalent: the per-unit form rounds twice, so it differs at ordinary decimals as well, by up to one raw unit per whole token released. A **partial** merge is also credited correctly now. Where a merge names more tokens than the position holds, the proceeds split the quantity actually released rather than pro-rating the event's own half, which floored on each leg independently: merging seven against a position of six credited two plus three for six whole pairs and reported a loss of one unit that never happened. The reported **cost basis of an open position** had the same defect in the other direction: it scaled the held balance through a rounded per-unit average, which is zero whenever a leg's cost is smaller than its quantity. A zero-decimal position of five tokens costing two therefore reported no cost basis at all, even once the fold recorded it correctly. It is now taken from the aggregate. That also removes a rounding loss at ordinary decimals: a leg holding three raw units costing two reported a basis of one. A **partial** merge after an odd mint is exact now too. Because an odd amount splits unevenly between the legs, releasing each one's cost independently floored twice and lost a unit: minting five and merging four released three against four of proceeds, inventing a unit of profit and stranding a unit of basis on the set still held. The two legs are settled together when they are a genuine pair, so the cost released always matches what was merged. A zero-quantity merge event no longer throws when the account holds an open leg. It is treated as the no-op it is, as it was before complete sets were split per leg. Figures for accounts that minted or merged complete sets will move, and the moves compound across a position's history rather than being bounded per action. On a six-decimal collateral each partial release was short by up to one raw unit per whole token it released: a leg minted at 10,000,001 raw units and then sold down by 10,000,000 released 4,999,990 where it should have released 4,999,999, so realized PnL moves by nine units on that one sale. Repeated partial sales and merges accumulate. On a zero-decimal collateral the previous cost basis was absent entirely, so realized PnL for those markets was overstated by the full cost of every set. - Document the built-in Safe executor's supported accounts: a direct owner of a threshold-1 safe or an enabled module. Nested Safes are supported when their address has the required authorization. Module mode does not depend on the threshold. Other authorization schemes need a custom `Executor`. The SDK does not check the owner set or threshold. - Wait for complete market watch initialization before resolving concurrent callers. Failed initialization now releases unused subscriptions and allows a fresh retry while preserving separately watched markets. - `useFundingRateSeries` now waits for the indexer before it re-reads a new funding settlement. A settlement reaches the live tail as soon as its block is confirmed, which is before the indexer has processed that block. The hook previously re-read the rollups immediately, so the response could still be the old series and nothing retried until the next settlement. The hook now reads the indexer sync status about once a second until the processed block reaches the settlement's block, then re-reads the rollups once. The wait is bounded at 4 seconds. At the bound the hook re-reads the rollups anyway. The wait stops on unmount and on a pool change. It survives a `from`/`to` window roll and then re-reads the rolled window. A sync-status failure stops the wait and appears on the new optional `catchUpError` field of `IndexerQueryState`, typed `IndexerError | InvalidInputError`; the hook never treats a failed read as coverage. - Re-pin the Hideki (50383) deployment manifest to the hideki2 regenesis (2026-09-17): the chain was rebuilt on the same chain id with fresh state, and the CLOB family was redeployed at the SAME shared CREATE3 addresses, so no resolved address changes — only the deployment's `startBlock` moves (566759489 → 840122) and venue 1 carries fresh MarketCreatorV2/policy/TestUSDC instances. Consumers that scan from `startBlock` must pick up the new floor; the old one predates the regenesis and does not exist on the new chain. - Liquidation history documents how to read `stageReached`, and corrects two claims about `BadDebtAbsorbed`. `stageReached` is a 0-indexed enum, while the published waterfall numbers its stages 1 to 6, so the two need a mapping table; `LiquidationEvent` and the perps guide now carry it. The number names the deepest stage that acted, not the deepest consulted, and `CLOBPartial` is a fall-through marker that does not prove stage 3 filled anything — read `positionsProcessed` with it. `BadDebtAbsorbed` fires when the engine asks the insurance fund to cover, not when the fund pays, and its `counterparty` names the fund that was asked; both are recorded even when `insuranceCovered` is 0. Documentation only; no runtime behaviour changes. - Fix wrong decimal scale on binary markets the live tail discovers itself. A market built from a `MarketCreated` event was stamped with the 6-decimal fallback regardless of its collateral, so on a venue settling in an 18-decimal token every derived quote amount on that market — cumulative quote volume, per-fill quote value, and anything formatted from `quoteDecimals` — was off by a factor of 10^12 until an indexer snapshot happened to overwrite the row. Discovered markets now carry their collateral's real `decimals()`, matching what the indexer stores for the same event. - Derive a live order book once per block instead of once per depth. Reading the same pool at two depths, as a trading page does when it shows a mid price and an order book together, now costs one pass over the resting orders. The full sorted ladder is computed once and each depth is a slice of it. Repeated status updates that change nothing no longer discard the derived books. Book contents are unchanged, and an expired order still leaves the book without a removal event. A book read keeps returning the same value until something it depends on moves, which now includes the one-second expiry cutoff as well as new blocks, so a level that expires is dropped without waiting for the next block. Watching a second pool now always publishes that pool's rows, even while a connection failure is pending and even when the new pool shares the first one's indexer snapshot block. - Fix `close()` and `stopLive()` leaving the chain WebSocket and a background timer running. Both now release every socket and timer the instance opened, so a Node script that has touched the chain exits on its own — the `process.exit(0)` the guides used to require is no longer needed. The instance stays usable afterwards: a later read reopens a connection, and a later `close()` releases that one too. Instances configured with the same `wsRpcUrl` share one chain socket, which closes when the last of them closes. A socket your own application already had open on that URL is left untouched. - Document the linked-wallet funding rail in the perps guide: what isolated margin on perps actually is, how a child's order draws on its main's wallet, which read answers which question, why a pending link proposal is only visible in the indexed log, and why the two funding ledgers must not be summed together. - Read each cross-margin account view from one chain block so its balances, health, and status describe the same state. - Check that the safe and any required MultiSend address have code before sending. Missing code raises `InvalidInputError` before signing. Failed reads raise `RpcError`; existing SDK errors pass through. Successful checks are cached. Single calls do not use or check MultiSend. - Preserve `NotConfiguredError` from Safe code checks through Trader writes. Include it in the executor error contract and document the missing-transport condition. - The package now carries its documentation, and the README links to pages a reader can open. The source repository is private, so every relative link out of the README resolved to a 404 for anyone outside the organisation, and npm derived the package homepage from that same private repository. - The tarball now contains the 32 hand-written guides under `docs/`. They are the only copy a consumer can read offline. The generated API reference stays out, so the tarball has the same contents whether or not TypeDoc has run. - The 13 documentation links in the README are now absolute URLs on the public documentation site. Each one was requested and returns 200. - `homepage` now points at the public documentation site instead of the private repository. - The publish gate now fails if the documentation is absent from the packed artifact, or if the generated API reference leaks into it. - Clarify SDK read sources, startup dependencies, freshness, and transport costs. Complete the Engine read inventory and explain separate price-feed connections and mixed-source reads. - `Order.cancelReason` is documented as `SelfMatch` on every pool type, not perps only. Spot and binary pools now tag their `OrderCancelledSelfMatch` removals the same way perps always did, so a spot order the protocol pulled during matching is distinguishable from one its owner cancelled. On spot this also covers the removals the indexer RECONSTRUCTS for blocks that predate the pool emitting the event at all (DEX-1236). Before the fix those makers stayed `Open` in the index forever — 673 of them on mainnet USDC.e/USDso, locking that book at 1.0000. Reconstructed and reported removals carry the same status and the same reason on purpose: on chain they are the same removal, only one of them reported itself. - ### Fixed — baked testnet venue-2 creator/policy reconciled with chain truth The testnet manifest carried a stale `marketCreator` (`0x138CfA6b…`) and `marketCreatorPolicy` (`0x191BBBDc…`) for venue 2. Ground truth on-chain (TRAD-223, re-verified): `MarketsCore.getVenue(2, 0x679795a0…)` returns policy `0xa24822b8d4adCC770c8B071e0Dc8e19c39991204`, which authorizes only creator `0x94D963B6670AB96E78C8d0C46ca35D196d606EFE`. The baked `SOMNIA_TESTNET_ADDRESSES` bundle now carries those live values. Mainnet is unaffected. ## 0.30.0 ### Minor Changes - Three new client reads answer block-scoped questions: `getBlockActivity` returns what one block traded grouped by market, `getLatestActiveBlock` names the newest block with markets activity, and `getAdjacentActiveBlocks` names the closest such block on either side. All three anchor on the indexed timestamp columns rather than a block column, none of which the indexer schema indexes. `getTransactionActivity` takes an optional `anchor` (the transaction's block and timestamp). Supplying it skips the `Order.placedTxHash` probe, which is not index-served and ran past the gateway timeout — so a transaction that produced no event, meaning one that only placed or cancelled orders, could not be read at all. - New `SOMNIA_MAINNET_PRICE_FEED` constant. It is the mainnet price feed, `https://price-feed.prd.oracle.somnia.host/v1/graphql`, pinned to the `USDC` quote. The SDK previously exported one price feed, `SOMNIA_TESTNET_PRICE_FEED`. A mainnet consumer had to write the endpoint by hand. The two feeds are separate deployments. Each one indexes the `PriceFeedScheduler` on its own chain. Use the feed that matches the chain the client runs on. The mainnet feed carries fewer symbols than the testnet feed. Code written for the testnet asset list can therefore fail on mainnet. Read the feed catalog to learn which bases exist. A base the feed does not carry returns no rows. It does not raise an error. Existing callers keep their behaviour. `SOMNIA_TESTNET_PRICE_FEED` is unchanged and remains correct for a testnet client. - Publish the perp event ABIs, so a consumer can decode perp logs from chain. `perpPoolEventsAbi` is now root-exported, and `marginBankEventsAbi` and `liquidationEngineEventsAbi` are new (18 and 13 events). Before this, `perpPoolWriteAbi` and `marginBankWriteAbi` exported no events and there was no MarginBank or LiquidationEngine event ABI at all, so funding settlement, positions, collateral flow and the liquidation waterfall were undecodable without a hand-copied ABI. Every signature is pinned by topic0 against the compiled artifacts. `docs/PERPS.md` has a new "Watch from chain" section. - Add `client.getPerpFundingPremium(pool)`, which reads the premium the next funding settlement will charge. Use `timeWeightedPremium` to project a funding rate. Do not use `lastObservedPremium` for that: the contract getter behind it kept its signature and changed its meaning, and it is now only the standing instantaneous sample. Check `armed` before calling the figure a time-weighted average, because a market that has not settled since the upgrade is still charging a point sample. This read is deliberately separate from `getPerpState`, whose batch would fail as a whole against a pool too old to serve these getters. Correct the funding documentation for the reworked premium and catch-up horizon. `FundingRateUpdate.intervalsAccrued` is no longer `min(intervalsSettled, n)`. A settlement now charges at most one interval, however long the gap, and the excess is forgiven. Compare `intervalsAccrued` against `intervalsSettled` to see whether anything was forgiven; `intervalsSettled` alone does not tell you. `intervalsPerWindow` is the per-interval divisor only. On a historical row that caught up over several intervals, one settlement's charge is `fundingRate * intervalsAccrued / n` rather than `fundingRate / n`. The premium driving the rate is the time-weighted impact-price premium, not a book midpoint, so `emaPremium` can be non-zero on a one-sided book. - Read the three perp ledgers the reindex made available: `listPerpInsuranceFundEvents` (the InsuranceFund's tier ledger behind `getInsuranceFundState`), `listPerpWalletLinkEvents` / `listPerpMarginPulls` / `listPerpMainFundingEvents` (the linked-wallet rail's consent graph and both funding sides), and `listPerpOrderRejections` (orders refused inside a batch placement). Two aggregation hazards are documented on the types: the insurance ledger's `amount` must be folded by `kind` rather than summed, and the two funding sides record the same wei — summing them double-counts every transfer. - `listPerpStopOrders` now returns `siblingOrderId` (the live OCO partner), `intent` and `cancelReason`, so a stops table no longer needs a chain read per row to show pairing or intent. `cancelReason` separates the three routes to `CANCELLED`, which refund differently: `"Owner"` returns the SOMI in the same transaction, while `"LinkedFill"` and `"Inert"` only credit `unclaimedSomi` for `claimSomi()`. - ### BREAKING — the portfolio trades legs default to the last seven days `getSpotPortfolio`, `getPerpPortfolio` and `getPortfolio` now apply `since = now − 7 days` to their trades leg unless the caller passes `since`. A caller that passed no `since` and expected the whole history gets the last week. Each result gains a required `tradesSince` (unix seconds): the bound that was applied, so a UI can label the list and a caller can page further back. **Migration.** To keep the previous unbounded read, pass `since: 0` (or the earliest time you need). Hand-built `Portfolio` / `SpotPortfolio` / `PerpPortfolio` values (fixtures, adapters) need a `tradesSince` field. A UI that renders `trades` should read `tradesSince` and say so when the list is empty — "no trades since " is true where "no trades yet" is not. Why a window: the type scope below is fast when a wallet's fills are dense in the requested type and slow when they are sparse — a spot market-maker asking for its binary fills walks every fill it has rejecting each. Measured on the development indexer for such a wallet: 24.8 s unbounded, 208 ms with a seven-day bound. A window is the only shape that is fast for every wallet without a schema change. Treat it as the SDK's standing position on wallet-scoped trade history, not as a stopgap: the composite indexes that would let it be dropped are a separate ticket, and removing a default later would be a second breaking change. ### Fixed — type- and pool-scoped reads no longer filter through the `market` relationship Every read that scoped `Fill`, `Order` or `StopOrder` by market type or by pool did so with `market: { marketType: { _eq } }` or `market: { poolAddress: { _eq } }`. Hasura compiles a relationship predicate to a correlated `EXISTS` on `Market` per candidate row, which stops Postgres using the `maker` / `taker` / `owner` indexes. Measured on the development indexer for one wallet's fills at `limit: 50`: `stream timeout` with the predicate, 102 ms without it. Three such reads every five seconds per open wallet page saturated the shared one-vCPU database and stalled ingestion on both indexer slots. These reads now scope on the direct, indexed columns instead: `pool` on `Fill`, `market_id` on `Order` and `StopOrder`. A market type resolves to its set of pools — `_in` for SPOT and PERP, whose `Market.id` is the pool address; `_nin` over spot ∪ perp for BINARY, whose pools are recycled across thousands of markets. A spot or perp pool is its own market id — a direct `_eq`, no query. A single **binary** pool keeps the `market: { poolAddress }` relationship form: a recycled pool has hosted ~2,000 markets, so an `_in` of its ids would be the worse shape, and every Order read that takes a pool also carries `owner`, which bounds that one EXISTS to the wallet's rows — no worse than before. Pool and type compose in one place (`marketScope`). The two type sets are memoized per indexer URL for a minute, so the extra hop is paid once, not once per poll. Affected: `getSpotPortfolio`, `getPerpPortfolio`, `getPortfolio`, `getOpenOrders`, `getOrders`, `countOrders`, `getSpotStopOrders`, `listPerpStopOrders`, `listPerpOrderHistory`, `listSweepableOrders`. Rows returned are unchanged for every read except the three portfolio trades legs, where the new default window applies. `getSpotStopOrders` in particular keeps returning every pending stop a wallet holds, spot or perp, as it did. - BREAKING for constructors — `PortfolioAnalytics` gains a required `holdings` series: what the traded book is worth over time The result's `equity` field is cumulative window PnL. It starts at zero, it is signed, and it is not the account's value. Consumers read the name and plotted it as a level, so a losing window drew a spot wallet below zero. `holdings` is the level that name promised. Each sample sums `qty × mark` over the open positions, on the same grid as `equity`, so a chart can draw both against one x axis: ```ts const p = await exchange.fetchPortfolioAnalytics("7d"); p.holdings.at(-1); // what the traded book is worth now p.equity.at(-1); // what it gained over the window ``` The first point is the carried-in book valued at the window start, not zero. The series sums signed position value. Every book the fold keeps today is long-only, so today the value cannot go below zero — but do not lock a chart axis to that, because a signed book marks negative and the perp plane is committed to joining this fold. The marks are the caller's and are not validated, so a negative price carries through as it already does for the PnL figures. A position the sample cannot price is left OUT of the value rather than valued at zero, and `HoldingsPoint.unpricedMarkets` counts what was left out. A market with no candle sample and no last price has no mark, and standing a zero in its place would drop a held position out of the level with nothing to say it had. Check the count before presenting a sample as the whole book. **Migration.** Reading the result needs no change: every existing field keeps its name, its meaning and its value. Anything that CONSTRUCTS a `PortfolioAnalytics` — a hand-written response fixture, a test mock, an adapter building the object for an HTTP layer — must add `holdings: HoldingsPoint[]` or it stops compiling. `HoldingsPoint` is exported from the package root. An adapter that forwards both series also serves roughly twice the series payload. The series measures the TRADED BOOK, not the wallet. A token that arrived without a fill — bridged in, transferred in, minted — is not in the book, so it is not in this value. Idle quote balance is not a position, so it is not included either. Funding events refine the capital base only, so a deposit inside the window does not step the curve. Read balances from the chain when you need what the wallet itself is worth. - BREAKING: `redeem` no longer guesses a leg before resolution, and unified leg selection now uses an options object `trader.redeem({ market, amount })` previously derived the winning leg as the argmax of the market's payout vector. A void pays BOTH legs, so that argmax was meaningless: it returned leg 0 on the uniform `[D/2, D/2]` vector, and the higher-priced leg on a `CLOB_SNAPSHOT` `[p, D−p]` one. A holder of only the NO leg got a confusing `InsufficientBalance` revert. A holder of both legs — what one `mintSet` call produces — redeemed YES, received half, and never attempted NO, with no error and a successful transaction. The collateral was stranded, not destroyed: it stays claimable from `BinarySettlement` with an explicit leg. The auto-lookup is now resolution-only. It checks the market's terminal state before reading the immutable payout vector. On a voided market `redeem` throws `InvalidInputError` naming both legs and pointing at `redeemMany`; on an unresolved market it asks the caller to wait for settlement. Resolved markets are unchanged: `[D,0]` still resolves to YES and `[0,D]` to NO. `exchange.redeem(ref, amount)` gains an optional trailing `RedeemOptions` bag, so a NO-holder can claim through the unified API instead of hitting a dead end. Existing two-argument calls behave as before. Migration: replace `exchange.redeem(ref, amount, outcomeIdx)` with `exchange.redeem(ref, amount, { outcomeIdx })`. If the market may be voided, pass the leg you hold. To claim both legs, use `redeemMany` with one entry per leg — they settle in one transaction. - Restore concurrent market-discovery coordination and stable user-fill pagination after their review withdrawal. Paginated fill reads include configured indexer headers, and indexer response-body cancellation preserves the caller's reason. This restoration supersedes the earlier withdrawal note. Dataset-reset coordination remains required for persisted fill cursors. - Add bounded user-fill pages with stable numeric continuation. Cursors preserve account and filter scope while newer fills arrive. Existing reads remain unchanged. Reindex, repair and endpoint cutover still require callers to discard cached pages and cursors. - Reject partially present void payout vectors, floor each raw payout leg from its stored numerator, and verify omitted unified redemption legs against on-chain terminal state. ### Patch Changes - Approval documentation now distinguishes cached unlimited allowances from finite allowances that each order must check again. - Traders from one configured client now coordinate local sends for each account. A failed signer cannot reset a nonce that another trader is still broadcasting. - Share concurrent initial market discovery within each exchange. Explicit registry reloads run in order, including after a failed read, so an older discovery cannot overwrite newer market metadata. - Withdraw the unreleased stable user-fill pagination API and concurrent market-discovery coordination pending maintainer review. This withdrawal supersedes the queued release notes announcing those additions; existing offset-based fill reads and the previous discovery behavior remain available. - Documentation: a documentation map plus tutorials, how-to guides, reference pages, and explanations organised by reader need. Two tutorials take a newcomer from an empty directory to a cancelled order on the Shannon testnet. Eight how-to guides cover network configuration, testnet funds, a quoting loop, fill detection, browser-wallet signing, the React hooks, error handling, and debugging. Six reference pages list configuration fields and defaults, networks and endpoints, the symbol grammar, units and scales, read tiers, and the error classes with the contract error names they decode. Every TypeScript example has a compile-checked public-import counterpart, and the tutorials were executed against the testnet. Corrections to the existing guides, verified against the source: the reactivity guide no longer claims the package is on GitHub Packages; the binary guide's cadence list, lifecycle (`Finalized`), `intervalSec` provenance, and builder-fee revert names are corrected; the exchange guide's verb table gains the ten verbs it omitted and notes that `close()` leaves the WebSocket open; the engine guide's API links now target the docs site; `isTailing()` is no longer presented as the price-feed status; the bridge guide names `sendBridgeStep` as its one sender; and the two `errors` TSDoc examples now match the real signatures of `createOrder` and `cancelExpiredOrders`. - Forward configured indexer headers to paginated user fill reads and preserve the caller's cancellation reason when an indexer response body is interrupted. - `/native`: a rejected `sendSessionTransaction` or `getSessionAddress` no longer carries the session seed in its error. viem builds a request failure's `message` and `cause` from the request body, so a mempool rejection or a transport error used to print the seed — a private key in another shape — in every log line that showed `error.message`. Both calls now throw `RpcError`. When the node answered, `cause` is its own `{ code, message, data? }` and `getSomniaRpcError` / `mempoolStatus` keep working on it; when the transport failed first, `cause` is `{ name, message, status? }` with the seed blanked out of the request text. `isMethodNotFound` also stops matching the node's genuine `account does not exist` and `Block does not exist` failures, which the documented degrade-to-`null` idiom used to swallow. - A live-tailed client no longer re-fetches every block it has already been handed. The tail took its chain head from viem's `watchBlocks`, which answers a pushed `newHeads` frame by issuing an `eth_getBlockByNumber` for the very block the frame already carried - nothing new, at double the per-block RPC cost. It now reads the height and timestamp straight off the pushed frame over the same socket. A frame is read only when both the height and the timestamp arrive as hex quantities; if either is missing, is not hex, or is the empty quantity `"0x"`, the frame is skipped whole rather than half-read. A skipped frame still counts as proof the stream is alive, so the heads watchdog behaves as before and the next head restores the timestamp. - Operational failures are no longer converted into success, zero, or a caller error at seven sites. Admin writes through `OperatorAdmin`, `GovernanceAdmin`, `MarketCreatorAdmin`, `OracleHubAdmin` and the lend `Lender` now throw `ContractRevertError` on a mined-but-reverted receipt (with the contract's error name when a replay at the receipt's block recovers it) instead of resolving as if confirmed, or misreporting the revert as `RpcError("no event")`. `SomniaMarkets.fetchBalance` throws when a balance read fails instead of reporting `0` or omitting the key. `getBinaryPositionPnL` falls back to `lastPrice` only when no chain client is configured, not when the book read fails. `cachedErc20Decimals` (and every read built on it) returns the fallback only for a token without `decimals()`, not on an RPC failure. `createStopOrder` surfaces a failed mark read as the indexer's error instead of `InvalidInputError("pass triggerDirection")`. `depositVaultNative` no longer broadcasts when its preflight read failed. The receipt wait used by external-signer and fallback confirms rejects after three consecutive receipt-read failures instead of pending forever; "receipt not found yet" still waits. - Decode three perps reverts that previously surfaced as opaque hex — `InvalidFundingCapDivergenceMargin`, `InvalidPremiumImpactNotional` and `InvalidLinkedWalletRegistry`, all added by the 2026-08-31 testnet upgrade. - `createOrder` and `createStopOrder` never send a larger quantity than requested. Quantities are truncated to the base token precision before applying the lot-size floor. `amountToPrecision` now truncates the decimal text instead of its binary floating-point expansion. Exact inputs such as `1.2` therefore remain exact for a `0.1` lot size. Local writes now serialize nonce consumption, signing, and broadcast acceptance. A signer or broadcast failure can reset nonce tracking without invalidating another in-flight write. - Live-tailed clients now receive binary settlement finalization. The SDK declared `MarketFinalized` with a stale trailing argument, so its topic0 did not match the emitted event and `liveTail` discarded every settlement finalize — leaving `netBacking` null for the settled lifetime of every binary market. Indexer-backed reads were never affected. - ### Fixed — a hydrated fill now names its taker A watch scope hydrated from a snapshot reported no taker on any of its fills. `taker`, `takerSide` and `kind` were missing from the snapshot's fill selection set and hardcoded `undefined` when the rows were parsed, on a comment claiming the indexer does not carry them. It does: they are real columns, stamped by the indexer's `PendingTakerFill` bridge once the taker's placement event lands. On a market-scoped watch this was permanent, not a live-tail race. The client-side back-join recovers taker fields from the local order map, but that scope hydrates resting orders only — and a taker order that filled completely is `Filled`, never `Open`, so it never enters the map to be joined against. Every market order and every IOC was affected, and a freshly loaded binary trade tape showed no taker side on any row it hydrated. `taker`, `takerSide` and `kind` now come through from the indexer, so: - A binary fill reports its taker's address, its four-way `BUY_YES`/`SELL_YES`/ `BUY_NO`/`SELL_NO` taker side, and its `DIRECT_YES`/`DIRECT_NO`/`MINT_A_PAIR`/ `BURN_A_PAIR` kind — the vocabulary `takerIsBid` cannot express, being one boolean covering two sides at a time and carrying no kind at all. - A spot or perp fill reports its taker. `takerSide` and `kind` stay undefined there, which is correct rather than a regression: outcome side is a binary-only concept and every spot and perp adapter leaves it unset. - A row the bridge has not stamped yet still reads `undefined`, and the existing back-join still fills in whatever is missing when the rows are selected. No type changed: the live-fill shape already declared all three as optional, and the user-scoped path already resolved them through the back-join. This closes the SDK half deferred by the binary trade tape's taker-colour fix. - Native-base spot sells now send the pool's exact vault shortfall as `msg.value`. This includes fee headroom and prevents `InvalidMsgValue` reverts on fee-bearing pools. Spot approval checks now use the pool's worst-case reserve. An allowance that covers only the principal is increased before the pull. Binary buy commitments now use the collateral token's exact scale. They also include the builder-fee headroom that the pool locks. - ### Fixed — a voided market now values against the payout vector it actually stored `estPayoutFor`, `computePositionPnL` and `computeBinaryPnl` returned a hardcoded half for every voided binary market. A void pays whatever payout vector the market stored. Under the `UNIFORM` void policy that vector is `[D/2, D/2]`, so the half was right. Under `CLOB_SNAPSHOT` a void that captures a two-sided close stores `[p, D−p]` at the closing YES price, and there the half was wrong on both legs: at a 0.80 close the YES leg was understated by 37.5% and the NO leg overstated by 150%. `estPayoutFor` quoted a claim settlement would not pay. All three now read `payoutNumerators` / `payoutDenominator`, which the indexer already carries and the market selection set already requested: - `estPayoutFor` returns `amount × payoutNumerators[outcomeIdx] / payoutDenominator` on a void. A void is never fee-charged, so no settlement fee applies on that branch. - `computePositionPnL` and `computeBinaryPnl` mark each leg from the same vector. `computePositionPnL` derives the NO leg by subtraction, as it already does while trading, so both legs still sum to exactly one collateral unit. - `ClaimableInput` accepts optional `payoutNumerators` and `payoutDenominator`, and `PortfolioMarket` carries them, so `getClaimable` and `getOpenPositionsWithPnL` value a void from the vector too. The payout comes from the vector and never from `voidPolicy`. A `CLOB_SNAPSHOT` market stores the uniform vector on every fallback — no capture-capable pool, a reverting `closingPrice()`, a one-sided or empty book, any overflow guard — so reading the policy instead of the vector would misreport exactly those markets. When a legacy voided market has no stored vector, all three keep returning the half, unchanged from previous behaviour. A present but invalid vector now raises `InvalidInputError` instead of silently turning corrupt indexer data into a plausible payout. The vector arrives from the indexer rather than the chain, so it is validated before use — as a whole vector, not one leg at a time. It must have both legs, both parseable and positive, a positive denominator, and a sum equal to that denominator. This is the shape `BinaryMarket` writes for a void: uniform or `[p, D−p]`, with `p` clamped to `[1, D−1]`. Rejecting any other present vector keeps corrupt data distinct from the legacy no-vector fallback. - Clarify voided-market rounding: `computePositionPnL` calculates each leg as `floor(oneCollateral * payoutNumerators[outcomeIdx] / payoutDenominator)`. It does not derive the voided NO mark by subtraction. Independent rounding can leave the two marks below one collateral unit in total. This supersedes the earlier queued payout-vector note's claim that the voided marks always sum to exactly one collateral unit; the implementation already follows settlement's independent rounding. ## 0.29.0 ### Minor Changes - ### Added — a market's whole history, one transaction, and one trade The tape and the fills list were the only ways in. A market page could show trades and nothing else; a transaction or a single fill had no read at all. - **`getMarketActivity(market, opts)` — one market's whole transaction history.** The mints, merges, redemptions, oracle resolutions and lifecycle transitions a market accumulates were all indexed and reachable only one entity at a time, in five different shapes. This returns them interleaved by time as one discriminated union on `kind` (`MarketActivity`), newest first, in a single round-trip. Every row carries its `txHash`, so a caller can follow any row to the chain. Select the streams with `kinds`, page backwards with `until`, and pass `pool` to let the fill read use its `(pool, timestamp)` index. A spot or perp market returns `TRADE` rows only — the other four streams read binary-only entities, so asking for them there is empty rather than an error. Pair it with `getLiveFills` for zero-latency trades: a trade's id is `TRADE:` followed by the fill id on both paths, so the two merge on `id` — but merge FIELD BY FIELD, preferring whichever source has a value. Neither is a superset of the other: the tail leaves `taker`/`takerSide` undefined on the fills it hydrates, and the indexer's `takerIsBid` is null until its taker bridge lands. Cache identity comes from the new `marketActivityKey`. **Known limit:** `until` has one-second resolution and is inclusive, so the boundary second is re-read between pages and a second holding `limit` or more rows cannot be paged past. A composite `(timestamp, blockNumber, logIndex)` cursor is the fix; it needs `logIndex` on the three non-fill streams, which this release adds to the indexer schema but which reaches a deployment only after a reindex. - **`getTransactionActivity(txHash)` — everything the protocol did in one transaction.** The transaction-scoped counterpart: the same `MarketActivity` union with the same row ids, but selected by transaction and returned in LOG order, plus the orders the transaction placed, the fees it paid and the market rows its events touched. One oracle callback settles every market on a question, so this is how a caller sees that a single transaction resolved four markets at once. A hash the indexer has nothing for returns empty collections and a null `blockNumber` rather than throwing — including a transaction that only rested a limit order, which emits no event and is anchored on the order table instead. New public types `TransactionActivity`, `TransactionActivityOptions` and `TransactionOrder`, and the `transactionActivityKey` cache key. - **`getTradeContext(id)` — one trade in full, for a trade detail view.** Returns the fill (with its block position), the market it executed in, BOTH sides' orders, the protocol and builder fees its transaction charged, and the other fills that transaction produced — enough to answer why a trade priced where it did. `null` for an unknown id, which is a stale link rather than a failure. New public types `TradeContext` and `FillOrder`, and the `tradeContextKey` cache key. Both transaction-scoped reads used to scan `Fill` to select by hash. The indexer schema now carries `@index` on `Fill.txHash`, so that scan goes away once a reindex has run. - ### Added — single-entity detail reads, a transaction summary, and the network tape The indexer surface was list-shaped throughout: orders, fills and markets could be enumerated, but nothing addressed ONE of them. An explorer drilling into a row had no read to call. - **`getOrder(pool, orderId)`, `getFill(id)`, `getOrderFills(pool, orderId)`** — the three reads a detail view runs on. Order ids never repeat (a monotonic per-pool counter), so `(pool, orderId)` names one order permanently; a fill is named by its `${blockNumber}_${logIndex}` id. All three embed a `MarketRef` (symbols, decimals, routing identity) so a page renders from one read instead of a second lookup per row. `null` for an unindexed id — a just-executed fill can lag by a beat, which is a stale link rather than a failure. The embedded market is `marketRef`, not `market`: `FillRow.market` is already the bytes32 marketId string, and GraphQL will not select a relationship alongside an alias of the same name. - **`listMarketsByPool(pool)`** — every market a pool has hosted, newest first. A binary pool is recycled across successive markets, so this is its full history rather than the one market `getMarketByPool` resolves. Plus **`getSeries(creator, seriesId)`**, the single-row sibling of `listSeries`. - **`getTransactionSummary(hash)`** — sender, gas used and limit, effective gas price, fee paid, status. Chain-direct rather than indexed, so it answers for any hash the node still has; `null` for an unknown one, never a throw. - **`createNetworkTape()`** — a network-wide order-flow firehose. One topics-only chain-log subscription sees `OrderPlaced` / `OrderFilled` from every pool, including pools created after it starts, with no indexer on the hot path. Not the live tail: no books and no snapshots, just three newest-first ring buffers (`bids` / `fills` / `asks`) plus a session `(pool, orderId) → owner` map that attributes fills. Takers always resolve; makers resolve when they were quoted while the tape ran. Rows carry RAW units — join to `listMarkets` for symbols and decimals. Nothing connects until the first `subscribe`, and reconnect plus stall detection for Somnia's silently-dying subscriptions is built in. The indexer also materializes the Market→Series edge from `MarketCreator.SeriesRolled` (`Market.series` / `Market.seriesKey`), replacing a heuristic join on asset + venue + cadence. That reaches a deployment only after a reindex, and the SDK does not read the field yet. - ### Added — read a pool's operator permissions registry from your own call `spotPoolOperatorRegistryReadAbi` is now exported from the package root. It is the ABI fragment for `getOperatorPermissionsRegistry()`, the view that tells you WHICH `OperatorPermissionsRegistry` a SpotPool consults when it gates `placeOrderFor`, `cancelOrderFor` and `reduceOrderFor`. `client.getOperatorPermissionsRegistry(pool)` already answered that question and still does. Reach for the fragment instead when the method does not fit: - **Multicall composition.** The method issues its own `readContract`, so it cannot be batched into a caller-built multicall. With the fragment the registry read joins the same batch as the rest of your pool reads. - **No instantiated client on that code path.** The method resolves a chain client lazily and needs one; the fragment is a static value and needs nothing. Previously the only route was a hand-copied fragment, since the `exports` map has no wildcard and no deep import could reach it — which is how a signature drifts in silence. ```ts import { spotPoolOperatorRegistryReadAbi } from "@somnia-chain/markets-sdk"; // `allowFailure: false` so a failed read throws instead of yielding a // `{ status, result }` wrapper. With the default (`true`) each element is that // wrapper, and comparing it to an address silently reports every pool as wired. const [registry] = await publicClient.multicall({ allowFailure: false, contracts: [{ address: pool, abi: spotPoolOperatorRegistryReadAbi, functionName: "getOperatorPermissionsRegistry" }], }); const wired = registry.toLowerCase() !== "0x0000000000000000000000000000000000000000"; ``` Two differences from the method, both of which matter for an authorization check: - **`address(0)` reaches you raw.** The method returns `null` for an unwired pool; a direct read returns the zero address, which is truthy. Compare it yourself, as above — such a pool denies every operator call, so a grant written anywhere will not make `placeOrderFor` succeed. The SDK's own grant reads and writes reject a zero registry with `NotConfiguredError` rather than acting on it. - **Read per pool.** Two pools on one deployment may name different registries, and the pool is the authority: a grant written to any other registry is inert. Do not cache one answer for all pools. The other read-side ABIs stay internal. - ### Added — a portfolio says when its trade history is incomplete `Portfolio`, `SpotPortfolio` and `PerpPortfolio` gain a `tradesTruncated` boolean. The three portfolio reads cap `trades` at 50 by default and page newest-first, so the cap drops the OLDEST fills. The returned shape carried no sign of this. A caller that folds `trades` into Net PnL, win rate, volume or a trade count got a total over the recent slice only, and read it as the whole history. This is the same signal `FundingRateSeries.truncated` already gives the funding chart. `tradesTruncated` is `trades.length >= tradesLimit`. A full page means older fills exist and were not returned. An empty page is not truncated, and neither is `tradesLimit: 0`, which asks for no trades at all. The default cap is unchanged, and raising `tradesLimit` moves the cap rather than removing it, so check the flag instead of assuming a large limit is enough. ### Changed (breaking) — perp markets are discovered from the chain, not only the indexer `loadMarkets()` now unions the indexer's perp rows with the PerpPoolFactory's and derives `active` from the live tradeability gates, so a close-only market no longer reads as tradeable. New `perpStatus` / `perpDiscoveryError` surface. **Breaking:** `indexed` is a required property on `UnifiedMarket`, so code that CONSTRUCTS one (fixtures, adapters) must set it; reading is unaffected. ### Added — the spot stop-order lifecycle is decodable, and the write ABIs are importable Protocol signatures were being hand-copied into consumer repositories, because this package held them and did not publish them. A transcription drifts in silence: it decodes nothing, or decodes wrong, and the failure looks like a missing trigger rather than an error. - **`spotStopRegistryEventsAbi` now carries the whole pending-order lifecycle.** It held `PendingOrderCreated` alone. It gains `PendingOrderTriggered(uint128 indexed pendingOrderId, bool success, uint128 indexed spotOrderId)`, `PendingOrderCancelled(uint128 indexed orderId)` and `InertOrderCancelled(uint128 indexed orderId, address indexed owner, uint256 somiCredited)` — so a consumer can decode a stop order firing, being cancelled by its owner, or being swept as inert, from a receipt. A new test checks every fragment's topic0 and argument shape against the compiled artifact `indexer/abis/SpotStopOrderRegistry.json`. That artifact carries no `PROVENANCE.json` entry, so the guard proves SDK-vs-artifact agreement, not SDK-vs-deployed-bytecode. Do not reuse the perp registry's shapes: its `PendingOrderTriggered` carries a drop reason the spot one does not have, and the test pins that divergence. - **`client.getOperatorPermissionsRegistry(pool)`** — the registry a SpotPool gates its operator calls through, at chain head, or `null` when the pool is unwired and denies every operator call. This is discovery for a caller who has not configured `addresses.operatorPermissionsRegistry`. The grant writes and the two grant reads need that address and otherwise throw `NotConfiguredError`, and no deployment manifest carries the key yet. A configured address still wins where it is used: this adds a path, it does not redirect one. - **Seven ABIs on the root barrel** — `erc20WriteAbi`, `erc20VaultWriteAbi`, `orderBookBatchWriteAbi`, `marginBankWriteAbi`, `spotStopRegistryWriteAbi`, `spotStopRegistryEventsAbi` and `operatorRegistryWriteAbi`. Token approvals, vault funding, batch order management, perp collateral, the stop-order lifecycle, and operator delegation are now encodable by hand from published surface. ```ts import { spotStopRegistryEventsAbi } from "@somnia-chain/markets-sdk"; import { decodeEventLog } from "viem"; for (const log of receipt.logs) { if (log.address.toLowerCase() !== registry.toLowerCase()) continue; const event = decodeEventLog({ abi: spotStopRegistryEventsAbi, ...log }); if (event.eventName === "PendingOrderTriggered") render(event.args); } ``` The read-side ABIs stay internal. A root export is permanent public surface, so the set is limited to what consumers have needed. ### Added — every placement path honours `selfMatchingOption` - **`selfMatchingOption` is now a caller input on all four placement paths**, so `CANCEL_MAKER` is reachable without leaving the SDK. It was pinned to `0` (`CANCEL_TAKER`) in each encoder, on a parameter the pools have always taken: `placeSpotOrder` / `buildPlaceSpotOrder` (`PlaceSpotOrderParams`), `placeSpotOrders` (`SpotOrderRequest`, per request), `placePerpOrder` / `buildPlacePerpOrder` (`PlacePerpOrderParams`), and `placeOrder` / `buildPlaceOrder` (`PlaceOrderParams`). `amendOrder` and `amendOrders` already took it, so every placement and amend verb now shares one vocabulary — the exported `SELF_MATCHING_OPTION`. Omitting the field encodes `CANCEL_TAKER`, exactly as before, so no existing call changes behaviour. Note that this default is the SDK's own: the pool reads the value out of every order request and has no default to fall back on. ```ts await trader.placeSpotOrder({ ...order, selfMatchingOption: SELF_MATCHING_OPTION.CANCEL_MAKER, // drop MY resting order }); ``` - **`userData` is settable on single spot placement** (`PlaceSpotOrderParams`), which the batch request shape already allowed. The single verb pinned the tag to `0`, so the same order encoded differently depending on which verb placed it. - **`placeSpotOrders` forwards each request's `builder` and `builderFeeBpsTimes1k`** instead of zeroing them. Choosing the batch verb silently dropped the routing attribution that `placeSpotOrder` honours. ### Added — a both-leg binary position can finally show either row - **`BinaryPositionPnL.outcomes`** — the same position split into its YES and NO books, as `{ yes, no }` of the new exported `BinaryOutcomePositionPnL` (`balance`, `costBasis`, `avgCost`, `markPrice`, `markValue`, `unrealizedPnl`, `realizedPnl`, all RAW). `OpenPositionPnL` extends `BinaryPositionPnL`, so `getBinaryPositionPnL` and `getOpenPositionsWithPnL` both carry it with no extra request. Every money field on the result is blended across both outcomes, which is unusable for a wallet holding both. Buy 10 YES at `0.20` and 10 NO at `0.80`, mark YES at `0.40`: the legs are `+2.00` and `-2.00` and the blended `unrealizedPnl` is `0.00`. Neither position row could show that zero as its own PnL, and consumers were falling back to local math for both-leg wallets. The fold already kept the two books separately in bigint space, mints and merges included — it computed each leg and then summed the values away. This publishes them. ```ts const [row] = await client.getOpenPositionsWithPnL(account); for (const leg of [row.outcomes.yes, row.outcomes.no]) { if (leg.balance > 0n) render(leg); // per-outcome, not blended } ``` Legs sum EXACTLY to the totals: `costBasis`, `markValue`, `unrealizedPnl` and `realizedPnl` are stored per leg and added up, so integer division cannot make a published leg disagree with the published total. `avgCost` and `markPrice` are per-token rates and do not sum — though while trading the two marks add up to one whole collateral unit. ### Added — the linked-wallet funding rail, and the margin surcharge that explains a refused order - **`getPerpLeverageImSurcharge(marginBank, account)`** and **`tryGetPerpLeverageImSurcharge`** — the EXTRA initial margin an account's own leverage settings demand, summed over every market where it both holds a position and has set a stricter-than-market cap. This closes a real gap rather than refining an existing read. `quotePerpOrderTopUp` funds ONE order and measures the account's *unlocked* balance, while the admission gate measures whole-account *equity* — so an order can be fully funded on its own market and still be refused `InsufficientMarginForOrder` because of an override on a different one. Nothing in the SDK could name that amount. Note the magnitude before dismissing it: the order lock reserves only the market's margin factor, so a 5x setting on a 2%-IMF market needs 20% of notional, not 2%. The strict read reverts when a market that is both positioned and overridden is unpriceable; the `try` variant reports that as `null`, never `0n` — zero would under-state the requirement, which is the direction that produces an order the bank then refuses. - **The linked-wallet read surface** — `quotePerpFundingPayer`, `getPerpMainFunding`, `getPerpWalletPullCapacity`, `getPerpLinkedWalletRegistry`, `getPerpWalletLinkage`, `listPerpLinkedChildren`, `getPerpMaxLinkedChildren`. A linked child's position-increasing order draws its shortfall from its MAIN's wallet, and none of that was readable from the SDK. **`quotePerpFundingPayer` returns a discriminated union, not an address**, because the contract's single zero collapses three situations a UI must not render alike: the rail is `dormant` (the bank holds no registry, so the feature is off for everyone and linking would not help), the wallet is `unlinked` (the one case the user can fix), or the wallet `isMain` (linked, but funding flows main→child only). The arming check runs first and short-circuits, so a deployment with the rail off costs one read and cannot report a misleading "unlinked". **`getPerpMainFunding().payer` is the payer snapshotted at funding time**, not whoever is linked now. Both routes home settle against it, so an unlink or re-link between the pull and the repayment cannot misroute the money — read it rather than `mainOf` when the question is "who gets it back". `principal` is what the child may trade but not withdraw (`withdraw` frees at most `balance - principal`), and it is not a segregated bucket: the claim clamps to `min(principal, balance)` at flat moments, so the child's own contribution is the junior tranche and a loss eats it first. **`getPerpWalletLinkage` derives `isChild` / `isMain`** because the raw encoding is a trap — a main resolves to ITSELF in `mainOf`, so the natural test `main !== zero` is true for mains and children alike. `maturesAt` gates ADL netting only; the funding rail reads the raw graph, so a link can be fundable and not yet mature. All chain tier. The rail's five events are live on chain but not yet subscribed, so HISTORY is not available — these answer "what is true right now". Writes (`proposeLink` / `acceptLink` / `repayFunding` / `recallFromChild`) are deliberately not in this release. ### Added — a count can now say whether it is the whole truth - **`client.countMarketsBounded` / `client.countBinaryMarketsBounded`** — return `CountResult` (`{ count, truncated }`) instead of a bare number. `truncated: true` means `count` is a LOWER BOUND (10,000 rows): render it as "10,000+", and do not gate pagination on `rows.length < count`, which goes false while rows remain. Without the server-only `_aggregate` header every count falls back to a bounded row scan that returned *the rows it fetched* — so a scan that filled its cap reported exactly `10000`, indistinguishable from a real total. The fallback now requests `cap + 1` and reads the extra row as the signal; the probe row is never counted. `Fill` and `Order` are already past the cap in production and `Market` crosses it during 2026. The six bare-number count helpers are unchanged, and each now documents the bound on its client declaration — past the cap they return a lower bound as if it were exact. `countOperators`/`countVenues` get no variant: those tables are orders of magnitude below the cap. `countOrders`/`countUserFills` have none yet, and `Order`/`Fill` are both already past it — copy `countMarketsBounded` when a caller needs the signal. ### Added — a binary market's resolution mode, served - **`BinaryMarket.mode`** — `"reference"` (an up/down market whose threshold is another question's answer) or `"fixed"` (a threshold set at creation). Derived from `strike`, so it costs no extra query, and stamped on the live-tail path too — a market built from logs before the indexer has it reports the same mode it will report after. Exported as `BinaryResolutionMode`, with `binaryResolutionMode(strike)` as the one place that reading happens — both construction paths call it, so they cannot drift. Consumers previously inferred this from a LOOKUP MISS — "no opening answer, so it must be fixed-strike" — which conflates "no reference question" with "the reference question has not been answered yet". That reads as working code until an oracle is slow. - **`boundaryPrice(market, openingPrices)`** — the level a market's outcome is measured against, taken from whichever source `mode` names, with `posted` saying which. Only a posted answer has an "answered at" instant, so only it may be labelled as one; returning that alongside the value keeps a label and the number it labels derived from one decision. Returns the raw oracle value — scale stays the caller's to apply. ### Changed - **`BinaryMarketFilter.intervalSec` now matches a BAND, not an exact value.** A rolled market's indexed `intervalSec` is `expiry − tradingStart`, and trading routinely opens a second or two late, so one 15m series is indexed at 898s and 899s as well as 900s. The filter matched exactly, so it returned a fraction of a cadence and called it the whole thing — 5m was missing a quarter of its own markets. `listBinaryMarkets`, `listLiveBinaryMarkets`, `listPastBinaryMarkets` and `countBinaryMarkets` all inherit this, so **row counts change** for any caller passing `intervalSec`. The band is `± CADENCE_TOLERANCE_SEC` (5s); rungs are far enough apart that bands never overlap. A caller that genuinely wants one exact window should filter the returned rows itself. - **A non-finite or non-positive `intervalSec` now THROWS `RangeError`** rather than silently matching nothing. Previously `{ intervalSec: 0 }` sent `_eq: "0"` and `{ intervalSec: NaN }` sent `_eq: "NaN"`, both yielding `[]`. A filter that silently matches nothing reads as an empty result set rather than as the caller error it is, so it is rejected at the boundary. Callers parsing user input (a URL param, a form field) should validate first — the same way `operatorId` is already expected to be a positive integer. - **`marketIntervalLabel` snaps to the cadence ladder**, so `BinaryMarket.interval` and the labels stamped on trade-history and portfolio rows now read `"1m"` where they read `"56s"`, `"15m"` where they read `"898s"`. A window matching no rung is unchanged. ### Added - `BaseMarket.createdAtBlock` — the block a market was created in, the counterpart to `resolvedAtBlock`. A market is bracketed by two reads of an off-chain feed and only a block identifies which print each one saw: a `"fixed"`-mode market's `strike` is the feed's spot at `createdAtBlock`, and that print lands before `tradingStart` far more often than on it, so `createdAtTimestamp` cannot find it. **The field is required**, so any code constructing a `Market` by hand must supply it; it is non-null on the wire, so every indexed market carries one. - `getResolutionPrices(marketIds)` — batch settlement prices, the counterpart to `getOpeningPrices`. Joins each market's OWN `oracleQuestionId`, so fixed-strike markets (which have no reference question) are covered. **Read its note on scale before consuming it**: the value is a raw integer and the indexer does not carry the answer's decimals. - `getOnchainResolutionPrice(marketId)` and the `OnchainResolutionPrice` type — a chain fallback for a settled market whose answer the indexer never saw, because its oracle adapter is not the one the indexer ingests. Resolves the market's bound adapter through the module, so it works for any adapter, and carries its own `decimals` (adapters differ: 18 on the price-feed adapter, 2 on OracleHub). Returns null while the question is not final; a failed read throws rather than reporting absence. - `CADENCE_LADDER_SEC`, `CADENCE_TOLERANCE_SEC`, `snapToCadence` and `cadenceBandSec` — the SDK-canonical cadence rules, so grouping and filtering read one source instead of each re-deriving a tolerance. See the note on `CADENCE_LADDER_SEC`: these exist only because the markets indexer derives `intervalSec` from a market's own window instead of reading the exact value `MarketCreator.MarketCreated` already emits. ### Breaking — the MWRR capital base is deposited capital `mwrr.return` previously divided the window's gain by net trade flow: the carried-in position's value, plus buys, minus the full proceeds of sells. Net trade flow is not capital the account put in, so the figure overstated the return for an account trading a small part of its balance. An account that funded 10,000 USD, traded 500 USD and gained 50 USD reported 10 percent instead of 0.5 percent. Supply the new `funding` events to measure against real deposited capital; without them the base remains a trades-only proxy, and `mwrr.capitalBasis` now says which one produced the number. `mwrr.depositedUsd` keeps its name and its meaning of net capital, with one correction: a sell now returns only the capital it matched against a tracked position. Proceeds of tokens the account never bought on the venue no longer reduce the base, so an account selling externally-sourced tokens reads zero rather than a negative base — and receives a return where it previously received none. Returns computed from funding events are time-weighted (Modified Dietz): each deposit or withdrawal counts for the fraction of the window it was invested, so capital that arrived late no longer claims a whole window's return. Trade flows are deliberately not weighted. A trade moves capital that is already in the account, and weighting a sale as capital returned and a later repurchase as capital re-deployed would collapse the base for an account that merely rearranged what it held. A return can exceed 100 percent in either direction on the funding basis, because capital deployed for a few hours of a window earns or loses at a large period rate. That is what a money-weighted rate states. Read the new `weightedCapitalUsd` to see how much capital the figure is measured against before presenting it as a headline. Every account's reported return changes. Read `mwrr.capitalBasis` to detect which definition a build implements, rather than inferring it from the package version. ### Fixed - **Binary PnL no longer folds a recycled pool's PREVIOUS markets into the current market's cost basis.** A binary pool is reused by successive markets, and the PnL reads keyed a wallet's fills by pool address, so an earlier market's fills landed on whichever market held that pool next. `getBinaryPositionPnL` and `getOpenPositionsWithPnL` could both report a wrong `avgCost`, `costBasis`, `unrealizedPnl` and `realizedPnl`. They now key by the stable `market_id` that `FillRow.market` has carried since 0.28.0. **Reported money values change** for any wallet that traded more than one market on the same pool; spot and perp are unaffected, since their market id IS the pool address. - `getOpenPositionsWithPnL` now asks the indexer only for the fills and router actions of the markets it is folding. Both reads are capped at 1000 rows and the cap drops the OLDEST rows first — the buys and mints that establish the cost basis — so a wallet with a long cross-market history could be priced off a partial book. ### Fixed — portfolio analytics reject a non-finite sampling bound `computePortfolioAnalytics` sampled its equity curve until the sample time reached `asOf`. A non-finite `asOf`, or a window start derived from a non-finite event timestamp, left that comparison false forever while every iteration appended another point, so the call exhausted memory instead of reporting a bad argument. The fold now validates `asOf`, every event timestamp, and the derived window and bucket bounds before it sorts, allocates, or samples. An invalid value throws `InvalidInputError` naming the field. Nothing is substituted and no event is dropped: an invalid time is the caller's to fix, and guessing one would misplace money on the curve. The new `ComputePortfolioAnalyticsError` type states the errors the function can throw. `fetchPortfolioAnalytics` now skips a fill whose indexed timestamp is unusable — nullish, blank, or non-numeric — the same way it already skips a fill whose direction it cannot determine. The check runs before the market is recorded and before a candle range is derived, so one unusable row cannot move an `all`-window start. Blank strings matter here specifically: `Number("")` is zero, which would have placed a fill in 1970 rather than failing. A finite bound is not automatically a sampleable one. A fixed timeframe subtracts a constant, so its span is its own definition, but the `all` timeframe opens at the earliest event — one funding event dated far enough in the past asked the fold for millions of points and exhausted the heap. The fold now rejects a span above 100,000 sample points, naming the implied count. That is roughly 11 years of hourly buckets or 270 years of daily ones, so a real portfolio never meets it: a twenty-year history samples about 7,300 points and is unaffected. The sampling loop itself stays uncapped. Capping it would turn a loud failure into silently truncated financial output; refusing an impossible span is the loud version. ### Added - `getUserFills` / `countUserFills` take `market` (one bytes32 market id) and `markets` (several) alongside the existing `pool`, as the exported `FillsScope`. `getRouterActions` takes `markets` as `RouterActionsOptions`. The predicates run at the indexer, so a `limit` applies to the rows you asked for. Prefer `market` over `pool` on binary: a pool selects every market that ever used it. ### Added — closing-price snapshot voids (TRAD-106) Venue-selectable void payouts: a `CLOB_SNAPSHOT` venue's markets pay `[p, D−p]` at their closing YES price when they void, instead of the uniform 0.50 refund. - **Venue params v3**: `BinaryVenueParams` gains optional `voidPolicy` (`"UNIFORM"` default | `"CLOB_SNAPSHOT"`; exported as `VenueVoidPolicy`). `encodeBinaryVenueFeeParams` encodes v3 through the module's on-chain encoder; `decodeBinaryVenueFeeParams` (and the BINARY_V1 plugin codec) dual-decodes — legacy 192-byte v2 payloads stay valid with an implied `UNIFORM`. - **`trader.captureClose({ pool, maxSteps? })`** — the permissionless closing-price capture that lifts the pool's closing-book lock. Sweep keepers: a `CloseNotCaptured` revert on a post-expiry cancel/sweep means "capture first, then retry"; after a normal resolution the lock lifts by itself and capture is never needed. - **Reads**: `client.getClosingPrice(pool)` (returns `ClosingPriceState` or `null` on pre-capture pools — the selector doubles as the capability probe), `closingTop` in the pool read ABI, and `voidPolicy` on `getMarketOnchain` (null on pre-policy clones) + the indexed `BinaryMarket` row. - **Errors**: the contract-error table now decodes the capture surface (`CloseNotCaptured`, `CloseAlreadyCaptured`, `CaptureTooEarly`, `CaptureStepsExhausted`, `InvalidVenueVoidPolicy`, `InvalidVoidPolicy`). ### Added — dead-oracle recovery without `cast send` `Trader` gains the two entry points the recovery path was missing, so the whole chain is SDK-callable (`syncSettlement` / `finalizeMarket` / `releasePool` were already there): ```ts // 1. Always try the real answer first — voiding pays 1/N, resolution pays winners. await exchange.trader.pokeOracle({ oracleQuestionId }); // 2. Only once expiry + settlementWindow has lapsed: await exchange.trader.voidExpired({ marketId }); await exchange.trader.syncSettlement({ marketId }); // void bypasses the module await exchange.trader.finalizeMarket({ marketId }); await exchange.trader.releasePool({ marketId }); ``` - `pokeOracle` is keyed by **oracle question**, not market: the module fans out to every market bound to that question and resolves the ones whose adapter answers. A success is not "all bound markets resolved". `OracleNotAnswered` (none answered) and `UnknownOracleQuestion` (nothing bound) arrive as `ContractRevertError`, so a keeper loop can branch on `errorName`. - `voidExpired` writes to the market contract, bypassing the module — hence the `syncSettlement` follow-up to release the hub earmark. Before sending, it reads the market's state and throws `InvalidInputError` naming the exact unix second the window lapses, because the on-chain `SettlementWindowOpen` revert carries no timestamp. The gate is compared against the chain's `block.timestamp` (what the contract itself uses), not the local clock. Pass `skipPreflight: true` to send blind. OPERATOR-GUIDE §5's recovery runbook now shows these calls instead of `cast send`. ### Added — funding-aware capital base `mwrr` carries two new fields. `weightedCapitalUsd` is the Modified Dietz denominator. `capitalBasis` names the definition that produced the figures: `"funding"` when external capital movements bearing on the window were supplied, `"trades"` for the fallback proxy that cannot see capital which never passed through a trade. `fetchPortfolioAnalytics` accepts a `funding` option, and the new `PortfolioFundingEvent` type describes a deposit or withdrawal. Funding events refine the capital base only — they never enter the equity curve, profit and loss, or volume. They are caller-supplied, because the indexer has no wallet token-transfer entity and the public RPC caps log queries at 1,000 blocks. Source them from application records, bridge history, or a private RPC scan, and exclude transfers whose counterparty is a venue contract or the same capital is counted twice. `PortfolioFlowEvent` is now a union of `PortfolioTradeEvent` and `PortfolioFundingEvent`. Code that constructs trade events with an explicit `kind: "trade"` is unaffected. ### Fixed — `balanceFloor` proves its guarantee against the write path's conversion `balanceFloor` returns the largest number that does not exceed a raw balance, so a caller can size a "max" order without it reverting for insufficient funds. It verified that by round-tripping its candidate through `candidate.toFixed(decimals)`, which is not the conversion a write performs. A number-to-raw conversion prefers the shortest string that round-trips to the same double, so `1.001` converts as `"1.001"`, while `(1.001).toFixed(18)` is the smaller `"1.000999999999999890"`. Checking the smaller value passed candidates that the write path then converted to more raw units than the wallet held. Over a deterministic sweep of 4,004 balances around a lot boundary at 18 decimals, 407 results converted above the balance and 110 still exceeded it after the venue's lot floor, by up to 110 wei. Six-decimal balances were unaffected, so this reached only 18-decimal venues — currently the only mainnet precision. The check now uses the same conversion as the write path. Results move by at most a few hundred wei, and after the venue's lot floor the placeable maximum is unchanged for every balance in that sweep, so no caller loses usable size. `floorRawBalance` inherits the fix on its fallback path. `ceilRawAmount` was never affected: it returns `bigint` and never round-trips through a double. - Read one account's stored perp leverage cap in a single call: `getPerpMaxLeverage`. `MarginBank.getMaxLeverage(account, pool)` is one storage read, but the only published route to it was `getPerpLeverage`, which walks the whole account and throws when any market the account is active in has a stale mark. A per-position badge or leverage dialog can now read the cap alone — it survives dead feeds on other markets, accepts `opts.blockNumber` for pinned rows, and returns `0` unchanged when no cap is set (that means "no account cap", not zero leverage). - Report a perp pool's aggregator oracle address on `getPerpState`. `PerpStateOnchain` gains an `oracle` field, read from the pool's own `oracle()` view. A consumer building a perp market description from `getPerpState` no longer has to omit the oracle or keep a parity read of its own against the pool. The read joins the existing pinned multicall, so it costs no extra round trip on that path and one parallel read on the `ChainDoesNotSupportContract` fan-out. - ### Added — name a revert from your own call `decodeRevert`, `contractErrorsAbi` and the `RevertContext` type are now exported from the package root. The SDK could already name a protocol revert: `decodeRevert` turns whatever a node threw into a `ContractRevertError` carrying the Solidity error name, decoding against `contractErrorsAbi` (500 custom-error entries). Neither symbol was on the barrel, and the `exports` map has no wildcard, so a deep import could not reach them either. A consumer holding revert data from its own call had no path to a name and had to hand-copy error fragments — which is how a signature drifts in silence. `ContractRevertError` alone is not a substitute: it is a passive holder that decodes nothing, so constructing one from raw revert data yields `errorName: undefined`. ```ts import { decodeRevert } from "@somnia-chain/markets-sdk"; try { await publicClient.simulateContract(request); } catch (caught) { const err = decodeRevert(caught, { address: pool, functionName: "placeOrder" }); console.log(err.errorName); // e.g. "InvalidTriggerPrice", not opaque hex } ``` `contractErrorsAbi` is a generated artifact (`pnpm errors:gen`, verified in CI by `pnpm errors:check`). Publishing it freezes that artifact as public API deliberately: the point is that a consumer's copy stops drifting when ours is regenerated. Both values are pure over caller-held data — no transport, no client, no owner — so they sit at the root under `SDK-API-003` alongside the other ABI data. `toSdkError` and `isRevert` remain internal; ask if you need them. - ### Breaking — `UnifiedOrder.type` may be `undefined` `fetchOrders`, `fetchOpenOrders` and `watchOrders` now omit `type` instead of reporting `"limit"`. That `"limit"` was never a read value. The pools do not emit the order type — `OrderPlaced` carries a `placedOrder` struct with no order-type member, and binary pools emit only the YES/NO side — so the indexer has nothing to store and the SDK had nothing to read. Four read paths wrote the literal anyway, which made **every market order the SDK returned claim to be a limit order**, with no field a consumer could check to tell the difference. Omitting it is the honest answer: absent means unknown, where before a wrong value was indistinguishable from a right one. The field is still present wherever the value is genuinely known: - `createOrder()` echoes the caller's own argument. - `fetchOpenStopOrders()` reads it from the registry row — `PendingOrderCreated` does carry `orderType`. - `UnifiedStopOrder.type` is unchanged and still required. ```diff - if (order.type === "limit") { /* true for every order, including market ones */ } + if (order.type === "limit") { /* now only where the type is actually known */ } ``` If you branch on `order.type` from a read path, that branch was reading a fabricated value. Handle `undefined` explicitly; the honest source for how an order was placed is your own `createOrder` result. Populating it on read paths needs the pools to emit the order type, which is a contract change. - BREAKING — a binary market that has never traded reports NO mark, instead of a mark of zero `computePositionPnL` and `computeBinaryPnl` valued an unpriced market at a YES price of zero. A binary market has no price until its first trade: the indexer creates the row with no `lastPrice`, and only a fill writes one. `markYesPrice` reports that correctly as `null`. Both PnL folds discarded the `null` and put a confident `0` in its place. The NO leg is derived as the complement of the YES mark. A YES mark of zero is therefore a NO mark of the FULL collateral unit. On a 10.00 basis, a wallet holding only NO read as a 5.00 gain, and a wallet holding only YES read as a 5.00 total loss. A wallet holding both legs cancelled to 0.00, which hid the fault in the blended total while both legs stayed wrong. Minting a complete set is the ordinary way to take a position in a market that has not traded, so this state is reachable. The unknown price now travels as `null` rather than a number: - `BinaryOutcomePositionPnL.markPrice`, `.markValue` and `.unrealizedPnl` are `bigint | null`. - `BinaryPositionPnL.markValue` and `.unrealizedPnl` are `bigint | null`. - `BinaryOutcomePnl.mark`, `.value` and `.unrealized` are `number | null`. - `BinaryPnl.unrealized` and `.total` are `number | null`. **Migration.** Handle `null` on those fields before you display or total them. `null` means the market has no price to mark against — show it as unknown, and do not treat it as zero. Every field that does not depend on a mark keeps its old type and value: `balance`, `costBasis`, `avgCost` and `realizedPnl` stay exact. Nothing else changes. A market with a last trade or a resting quote marks as before. A resolved market still marks the winner at one whole collateral unit and the loser at zero, and a voided market still marks both legs at half, because a settlement payout needs no price. While a market is unresolved and priced, its two legs still sum to exactly one collateral unit. ### Patch Changes - Document `StaleMark` in the `Order.cancelReason` vocabulary. The indexer writes five cancel reasons but both published vocabularies listed four, so the served GraphQL description and the SDK contract told a consumer that a value it can actually receive does not exist. No behaviour or type change: `cancelReason` remains an open `string | null`. - ### Fixed — pre-fill maker cancellations now leave the live order book Deployed perp pools cancel resting maker orders before any fill — negative equity, stale mark, exceeds position, self-match — and the SDK's live tail dropped those events undecoded. The cancelled order stayed `Open` in the local store, so the order book kept counting its quantity at a price level the chain had already cleared: phantom depth in every rendered book and market-maker view, for the whole life of the watch. `OrderCancelledPreFill` (the base event for every pre-fill removal) and the perp reason tags `MakerOrderCancelledNegativeEquity` and `MakerOrderCancelledStaleMark` are now declared and terminate the order; `MakerOrderCancelledExceedsPosition` and `OrderCancelledSelfMatch` were already declared and are now covered by tests. Termination only — no store shape change and no new fields. - Account-scoped fill reads match indexed columns instead of joining through the taker's order Three reads found the fills an account participated in by OR-ing a predicate on the taker ORDER's `owner` alongside the denormalized columns: `getUserFills`/`countUserFills`, the binary `getPortfolio`, and the user snapshot the live store hydrates from. That arm is a relationship predicate, and Hasura compiles it to a correlated `EXISTS` subquery. Postgres cannot combine such a subquery with a bitmap OR over the `maker` and `taker` indexes: the plan walks the `timestamp` index backwards testing each row, and every row it walks past costs a lookup into `Order`. The penalty therefore falls hardest on ordinary wallets, which are sparse in a table that market makers fill. A busy maker reaches `limit` in a few rows and barely notices; a wallet with a handful of fills scans a long way and pays the subquery the whole way down. On the development indexer, four real single-fill wallets took 2.78s, 4.50s, 8.75s and 10.93s to sweep their tape, against 0.04s, 0.41s, 0.26s and 0.27s once the arm was gone. The arm found nothing the other two missed. `backfillTakerFills` in the indexer stamps the taker address from `OrderPlaced` for SPOT, PERP and binary alike, and the taker's order is placed in the same transaction as the fill it crosses — a resting order occupies the maker seat by definition — so no committed row can lack it. `takerOrder: Order!` is non-null in the schema for the same reason. The docblock that defended the arm described binary as leaving `taker` null; that stopped being true when binary side attribution moved to `BinaryOrderPlaced`. Reads that page a wallet's whole tape gain the most, because they pay the penalty once per page. The three also compound: a trader portfolio sweeps its fill tape, reads its binary positions and hydrates its snapshot, so one light wallet paid the scan three times over. Measured together on the development indexer, one page of each took 13.89s before and 0.27s after. No signature, option or row shape changes, and the rows returned are the same rows. `takerOrder { owner side }` is still SELECTED: `side` is what makes a binary row mappable when the fill's own `takerSide` copy is still lagging, and selecting a non-null relationship is an ordinary join rather than a filter. The invariant holds structurally: the indexer writes `Fill.taker` and `Order.owner` from the same local in the same handler, so the two cannot disagree — delegated and operator-placed orders included. `sdk-e2e` also asserts it against a live indexer, though that suite is hand-run rather than a CI gate. ## 0.28.1 (2026-08-21) A single fix on top of 0.28.0. ### Fixed — five perp names the client-scoped move left unreachable 0.28.0 internalized the connection-bound free functions, but five names left the root barrel without arriving on `SomniaMarketsClient`. They were reachable by no route at all: absent from the root, absent from the client, and not deep-importable, since the package publishes only `.` and `./react`. Each is now where the client-scoped rule puts it. Two are connection-bound, so they are client members: | Name | Use | |---|---| | `getPerpStopOrder` | `exchange.client.getPerpStopOrder({ registry, orderId })` | | `getPerpStopOrderSomiPayment` | `exchange.client.getPerpStopOrderSomiPayment(registry)` | `listPerpStopOrders` did become a client member in 0.28.0, so listing was unaffected. But `getPerpStopOrder` is the only way to read a LIMIT stop's `limitPrice`, its linked `siblingOrderId` and its `intent`: no event carries them, so `PerpStopOrder` cannot, and nothing off-chain could tell an opening bracket from a reduce-only stop. Three are pure functions — sync, no client, no `Writer` — so they are client-independent utilities and are exported from the root again: - `perpLiquidationPrice` - `perpOrderMarginQuote` - `perpPositionAnalytics` Their input and output types were never removed (`PerpLiquidationPriceInputs`, `PerpPositionAnalyticsInputs`, `PerpPositionMetrics`, `PerpOrderMarginQuote`, `PerpOrderMarginQuoteInputs`), nor was `PerpStopOrderOnChain`, whose only producer is `getPerpStopOrder`. No migration is needed for 0.28.0 code: every name above was unreachable, so nothing can depend on its old shape. The roster in `test/rootExports.test.ts` now covers all five, so the same gap fails CI instead of shipping. ## 0.28.0 (2026-08-20) The API is now client-scoped — the one breaking item — and the published package can be imported again. The write path aligns prices and amounts to the venue's own tick and lot grids, and gains build-only order verbs (unsigned calldata) plus single and batch order amendment. Reads gain the stable market id on order and fill rows and the liquidation-keeper views. ### Breaking — client-scoped entrypoints (53 free functions + the `./lend` subpath removed) Anything that uses a connection (indexer reads, chain reads) is now reachable ONLY through the client facade — the root barrel no longer exports free-function duplicates of `SomniaMarketsClient` members. The migration is mechanical: drop the trailing `indexerUrl`/`client` argument and call the same name on `exchange.client` (every removed function already existed as a same-named member; none changed behavior). Types, pure kernels, constants, and ABIs stay importable from the root. `./react` is untouched. The `@somnia-chain/markets-sdk/lend` entrypoint is REMOVED for the same reason: the lend entry is client-scoped (`client.lend`). Its standalone surface moved to the root — change `from "@somnia-chain/markets-sdk/lend"` to `from "@somnia-chain/markets-sdk"` for the lend types (`LendReserve`, `LendAccount`, …), the `SOMNIA_MAINNET_LEND` / `SOMNIA_TESTNET_LEND` deployment constants, the ray-math helpers (`lendRayRateToApy`, `rayMul`, `accrueLinear`, `accrueCompounded`, `RAY`), and the lend ABIs. `createLendWithDeps` is no longer public — the client builds the lend entry itself. ```ts // before import { getBookTops } from "@somnia-chain/markets-sdk"; const tops = await getBookTops(marketIds, indexerUrl); // after const tops = await exchange.client.getBookTops(marketIds); ``` | Removed root export | Use instead | |---|---| | `countOrders` | `exchange.client.countOrders(…)` | | `countUserFills` | `exchange.client.countUserFills(…)` | | `creditOf` | `exchange.client.creditOf(…)` | | `earmarkedOf` | `exchange.client.earmarkedOf(…)` | | `getAccountHealth` | `exchange.client.getAccountHealth(…)` | | `getAllOpenOrdersOnchain` | `exchange.client.getAllOpenOrdersOnchain(…)` | | `getBinaryBookParams` | `exchange.client.getBinaryBookParams(…)` | | `getBookTops` | `exchange.client.getBookTops(…)` | | `getFreePools` | `exchange.client.getFreePools(…)` | | `getFundingPayments` | `exchange.client.getFundingPayments(…)` | | `getFundingRateHistory` | `exchange.client.getFundingRateHistory(…)` | | `getLiquidationPrice` | `exchange.client.getLiquidationPrice(…)` | | `getLiquidations` | `exchange.client.getLiquidations(…)` | | `getMarginEvents` | `exchange.client.getMarginEvents(…)` | | `getMarketByPool` | `exchange.client.getMarketByPool(…)` | | `getMarketCreator` | `exchange.client.getMarketCreator(…)` | | `getMarketResolution` | `exchange.client.getMarketResolution(…)` | | `getOpenInterestHistory` | `exchange.client.getOpenInterestHistory(…)` | | `getOpeningPrices` | `exchange.client.getOpeningPrices(…)` | | `getOperatorHubAccount` | `exchange.client.getOperatorHubAccount(…)` | | `getOracleAdapter` | `exchange.client.getOracleAdapter(…)` | | `getOracleQuestion` | `exchange.client.getOracleQuestion(…)` | | `getOrderOnchain` | `exchange.client.getOrderOnchain(…)` | | `getOwnOpenOrdersOnchain` | `exchange.client.getOwnOpenOrdersOnchain(…)` | | `getPool` | `exchange.client.getPool(…)` | | `getPoolBindings` | `exchange.client.getPoolBindings(…)` | | `getPoolCreator` | `exchange.client.getPoolCreator(…)` | | `getRouterActions` | `exchange.client.getRouterActions(…)` | | `getSchedulingCost` | `exchange.client.getSchedulingCost(…)` | | `getVaultBalance` | `exchange.client.getVaultBalance(…)` | | `getVaultPayoutFallbacks` | `exchange.client.getVaultPayoutFallbacks(…)` | | `listBuilderApprovals` | `exchange.client.listBuilderApprovals(…)` | | `listBuilderFees` | `exchange.client.listBuilderFees(…)` | | `listFundingRateCandles` | `exchange.client.listFundingRateCandles(…)` | | `listFundingRateHistory` | `exchange.client.listFundingRateHistory(…)` | | `listMarketCreators` | `exchange.client.listMarketCreators(…)` | | `listOperatorHubAccounts` | `exchange.client.listOperatorHubAccounts(…)` | | `listOracleAdapters` | `exchange.client.listOracleAdapters(…)` | | `listOracleBinds` | `exchange.client.listOracleBinds(…)` | | `listOracleCallbacks` | `exchange.client.listOracleCallbacks(…)` | | `listOracleQuestions` | `exchange.client.listOracleQuestions(…)` | | `listPerpFees` | `exchange.client.listPerpFees(…)` | | `listPerpOrderHistory` | `exchange.client.listPerpOrderHistory(…)` | | `listPerpPositions` | `exchange.client.listPerpPositions(…)` | | `listPerpStopOrders` | `exchange.client.listPerpStopOrders(…)` | | `listProtocolFees` | `exchange.client.listProtocolFees(…)` | | `listSeries` | `exchange.client.listSeries(…)` | | `listSettlementFees` | `exchange.client.listSettlementFees(…)` | | `listSweepableOrders` | `exchange.client.listSweepableOrders(…)` | | `outstandingOf` | `exchange.client.outstandingOf(…)` | | `quoteCreateMarketValue` | `exchange.client.quoteCreateMarketValue(…)` | | `resolveReserve` | `exchange.client.resolveReserve(…)` | | `withdrawableOf` | `exchange.client.withdrawableOf(…)` | | `createLendWithDeps` (from `/lend`) | `exchange.client.lend` (built by the client) | | any other `/lend` import | same name, `from "@somnia-chain/markets-sdk"` | ### Fixed — a scoped or windowed `fetchMyTrades` returns the rows it was asked for `fetchMyTrades` read a wallet's history through three venue portfolio functions and called all three with no options, so the indexer capped fills at 50 per venue BEFORE any scoping happened and `since`/`limit`/`ref` were applied afterwards in memory. The visible symptom was not truncation. A market-scoped call returned an EMPTY list over fills that exist: if a wallet's fills on the asked-for market were older than its 50 newest fills overall, the query never returned them and there was nothing left to filter. `since` failed the same way — "my trades since last week" returned whichever of the newest 50 fell in last week, a smaller and different set. It now reads the unified fill tape (`getUserFills`), so the scope, the window and the limit are applied by the indexer. `limit` counts rows the caller receives: unresolvable rows are skipped and the read pages until the limit is satisfied or the tape is exhausted. Two behavioural changes worth knowing: - **Ordering.** An unscoped call now returns rows newest-first across all venues. It previously returned them grouped binary → spot → perp, so "the newest 50 trades" was a venue-ordered window rather than the newest 50. - **`UnifiedTrade.info`** is now the `FillRow` the tape returned, where it was previously a venue portfolio trade row. It remains typed `unknown` and documented as the native fill row. `since` stays in MILLISECONDS on this surface, matching `UnifiedTrade.timestamp`; the conversion to the indexer's unix seconds happens internally. ### Added — `fetchOpenOrders` accepts a row limit It took only `(ref?)`, so a caller could not raise the 200-per-venue default. It now takes `(ref?, limit?)` and forwards it to the query. The limit is per venue, not a merged total — an unscoped call reads all three. Note the spot portfolio query binds one limit variable to both its open orders and its pending stop orders; that coupling is now documented on the verb. ### Added — the stable market id on order and fill reads Every row of `getOrders`, `getOpenOrders`, `listSweepableOrders`, `getFills` and `getUserFills` now carries **`market`** — the bytes32 market id, the stable identity of the market the row belongs to. A pool address is not a market identity: on binary event contracts one pool is recycled by successive markets, so the same pool names a different market depending on when the order was placed. Pass the id straight to `client.getMarket(id)`; no new API. Order rows additionally carry **`marketInfo`** (`asset`, `question`, `expiry`, `tradingStart`, `quoteDecimals`, `intervalSec`, plus the derived `interval` label), so a history view can label rows without a second round-trip. ### Fixed — the published package can be imported Every release from 0.20.0 through 0.27.0 shipped a `dist` in which no relative import specifier carried a file extension. Node's ESM resolver requires them, so `import "@somnia-chain/markets-sdk"` threw `ERR_MODULE_NOT_FOUND` before any user code ran, on every declared subpath. `./chains` failed differently, with `ERR_UNSUPPORTED_DIR_IMPORT`, because two specifiers named a directory. This was not limited to plain Node. webpack — and therefore Next.js — failed too, with "resolved as fully specified" errors; a consumer could only compile by setting `resolve.fullySpecified: false`. esbuild, Rollup, Vite and `tsx` worked only by legacy extension guessing. There was no CommonJS fallback, and `src` in the tarball was unreachable behind a strict `exports` map. The package now compiles with `moduleResolution: "nodenext"`, so every relative specifier carries its extension and the compiler rejects the form that caused this. The public API, the exports map and the module format are unchanged — imports that used to throw now resolve. Nothing caught this for seven releases because vitest and `sdk-e2e` consume the SDK through the workspace `exports` block pointing at `src/*.ts`, so the test surface never loaded `dist`. Publishing is now gated on `scripts/publish-import-gate.mjs`, which packs the tarball, installs it into a bare `{"type":"module"}` project with no bundler, and imports every subpath the published exports map declares. ### Fixed — the write path now applies the venue's tick and lot grids `createOrder` converted the caller's price to raw units and sent it without ever snapping it to the market's tick grid — it never called `priceToPrecision`. So the pool rejected the order with `InvalidPrice` even when nothing had been lost in conversion: `createOrder(sym, "limit", "buy", 1, 0.25)` on a market with a `0.1` tick sent an off-tick price, and `0.25` is exactly representable as a double. Market orders were worse, because the caller never supplies the price. The protective limit was `best * (1 ± slippage)` computed in float and sent unaligned. Its binary clamp also bounded at `1 / 10 ** decimals` — one *wei*, not one tick — so on any venue whose tick exceeds a single raw unit the clamp's own bound was a value the pool rejects. `createOrder`, `createStopOrder` and the computed crossing price now align in bigint space against the same grid the precision helpers read. Aligning a value that is already on the grid returns it unchanged, so a caller who pre-snaps with `priceToPrecision` / `amountToPrecision` sees no behavioural change at all. **Direction is side-aware, and never moves a value against the caller.** A buy price rounds down, a sell price rounds up, and a quantity always rounds down — so an order is never larger, nor worse priced, than what was asked for. Rounding a sell price down (which is what `priceToPrecision` does, for either side) would be a real loss, so the write path and that helper can now differ by one tick on a sell; the helper's own spec pins its floor-only behaviour and is left alone. The one exception is a protective limit the SDK computes for a market order: that rounds *away* from the caller, so the cushion survives alignment and the order still crosses the level it was priced against. This matches how the stake and sell quote builders in `derivedReads` have always done it. Two visible consequences: - `UnifiedOrder.price` / `.amount` (and the stop-order equivalents) report what was actually placed, which may differ from the arguments by up to one tick or lot. `remaining` and `status` are derived from the placed amount, so an order that aligned down no longer reports a remainder that does not exist. - A quantity below one whole lot now throws `InvalidInputError` naming the market's minimum, instead of sending a zero-quantity order. Not breaking: every affected call previously either reverted on-chain or was already being pre-aligned by the caller to the same value. ### Fixed — a float price could not be placed on an 18-decimal venue `fromHuman` formatted numbers with `toFixed(decimals)`, which prints the float's binary expansion rather than the number the caller typed: at 18 decimals `(0.05).toFixed(18)` is `"0.050000000000000003"`. Three wei is enough to miss the live binary venue's 1e15 tick, so the pool rejected the order with `InvalidPrice`. Measured against the mainnet USDso venue: of fifteen ordinary probabilities only 0.25, 0.5 and 0.75 could be placed, the ones binary floating point represents exactly. A 6-decimal venue never shows it, so testnet stayed clean. Numbers now convert through their shortest round-tripping form (`String(n)`, i.e. `"0.05"`) whenever it fits the token's scale. `toFixed` still covers what it was there for: exponent notation and mantissas longer than `decimals` (which it rounds to scale, now stated in `fromHuman`'s docs rather than left implicit). The string path is unchanged. The same defect had been hand-rolled at two more sites, both fixed here so the write path has one definition of "the value the caller typed": - `Structs.toRaw`, which `createOrder` uses via `toNativePrice` — so the ccxt-shaped surface was broken too, and a fix to `fromHuman` alone would have left it that way. - `exchange.priceToPrecision` / `amountToPrecision`, the helpers a caller is told to use to AVOID off-tick values. They aligned to the grid in float space and then re-printed with `toFixed`, putting the expansion back — off-tick 16 times out of 19 on the live venue's ladder. Alignment now happens in bigint space, the only space the grid is defined in. Note that this fixes conversion, not arithmetic done before it: a caller who computes `3 * 0.05` still hands over `0.15000000000000002`, a genuinely different number. Snapping a computed price onto the venue's grid is `priceToPrecision`'s job. `priceToPrecision` keeps treating a value a hair BELOW a grid point as sitting on it, which the old float path did with a `+1e-9` nudge. A book mid like `(0.001 + 0.009) / 2` is `0.004999999999999999`, and flooring that would quote a whole tick away from what the caller asked for. The tolerance is now relative (one part in `2 ** 52`, the double's own resolution) rather than a fixed `1e-9`, so it scales with the price instead of being meaningless at 1e6 and overwhelming at 1e-9. `amountToPrecision` deliberately does NOT get that nudge — it snaps strictly down. An amount is usually bounded by something the caller cannot exceed (a wallet balance, a budget), and because the tolerance is relative it grows with magnitude: a balance one wei under a lot boundary would be rounded past it, producing the "insufficient balance" revert that `balanceFloor` / `floorRawBalance` exist to prevent. A price has no such ceiling. `Structs.snapToGrid` takes a `strict` option for callers in the first category, and under it the conversion INTO raw units truncates past `decimals` rather than rounding to scale — otherwise an over-precise number arrives already inflated and the floor starts from the wrong value (`amountToPrecision(0.1234567)` on a 6-decimal base returned `0.123457`, above the caller's own number). `fromHuman` and `probabilityToPrice` now reject non-finite numbers with `InvalidInputError` instead of letting viem's `InvalidDecimalNumberError` escape. `probabilityToPrice(NaN)` previously slipped past its own range check, since `NaN < 0` and `NaN > 1` are both false. ### Fixed — raw → human scaling no longer divides floats `Structs.toHumanNum` scaled with `Number(raw) / 10 ** decimals`. The division is a float operation, so it could land a few wei off the value it was handed: `19000000000000000000000` came back as `19000.000000000004`, which is not on a 1e15 grid. That silently undid the bigint alignment `priceToPrecision` had just performed — about a quarter of ordinary prices above ~1000 were returned OFF the grid they had been snapped onto, and `createOrder` re-converts that number before sending it, so the result was `InvalidPrice` again. Spot and perp prices live at exactly those magnitudes; the binary probabilities under 1 that the rest of this entry is about never showed it. It now scales through the exact decimal string, like `toHumanString` already did. The result is still a `number`, so it is still display-grade past ~15 significant digits — but it is the closest double to the true value rather than the closest double to a lossy quotient. This affects every `toHumanNum` caller, and only ever in the direction of accuracy: the two differ solely where the division was wrong. ### Fixed — `getPerpState` reads every field at ONE block `getPerpState` fanned out with `Promise.all` over nine independent `readContract` calls, so its values could come from different blocks. A mark price from block N beside a funding rate from block N+2 describes a market state that never existed — and a price bar rendering the pair presents it as if it did. Every read is now pinned to a single block number. The pin, not the multicall, is what provides the guarantee: viem splits a batch into several `eth_call`s once the calldata exceeds `batchSize`, and those can straddle blocks. Where the client's chain declares a Multicall3 — `viem/chains`' `somnia` and `somniaTestnet` both do — the nine reads also batch into one `aggregate3`, so the call costs two round-trips instead of nine. A chain built with a bare `defineChain` declares no contracts, so it keeps the fan-out and costs one MORE round-trip than before (the block pin). Correctness is identical either way. No API change — `PerpStateOnchain` and the `getPerpState(pool)` signature are unchanged. `tryGetMarkPrice` is still the mark read, so a stale feed still returns `markPriceOk: false` rather than throwing. `withTypedReadErrors` now also wraps `multicall`. Viem binds `client.multicall` to the raw client, so the existing `readContract` wrapper never fired for batched reads — without this, a revert read through multicall escaped as a bare viem error while the identical read through `readContract` threw a typed SDK error. ### Added — caller-set expiry and builder attribution on spot and perp orders `placeSpotOrder` and `placePerpOrder` now accept the expiry and builder fields the binary `placeOrder` already threaded. They were hardcoded before, so a caller could not set a spot order's expiry at all and could not attribute a spot or perp order to a builder: ```ts await exchange.trader.placeSpotOrder({ pool, isBid: true, price, quantity, baseDecimals, quoteToken, baseToken, expireTimestampNs: 1_800_000_000_000_000_000n, // was pinned to ~50y (GTC) builder: "0xYourFrontend", // was pinned to the zero address builderFeeBpsTimes1k: 25_000n, // was pinned to 0 }); ``` - `PlaceSpotOrderParams` gains `expireTimestampNs`, `builder`, and `builderFeeBpsTimes1k`. `PlacePerpOrderParams` gains the two builder fields (it already had `expireTimestampNs`). - Every field is optional and defaults to the value that was hardcoded before (`farFutureNs()`, the zero address, `0n`), so existing callers are unaffected. - A nonzero builder fee still requires a prior `Trader.approveBuilder` on that pool — the approval is stored **per pool**. See `docs/SPOT.md` and `docs/PERPS.md` for the fee ceiling and the two spot-expiry traps (a past expiry places nothing silently; an expired order does not auto-return its escrow). ### Fixed — `SomniaMarkets.createOrder` dropped builder attribution on spot and perp `createOrder` accepted `builder` / `builderFeeBpsTimes1k` but only forwarded them on binary markets. A spot or perp order placed through the unified surface rested with the zero-address builder: the call reported success, and no fee was attributed. Both fields now reach all three branches, and they are no longer documented as "BINARY only". ### Added — liquidation-keeper reads: `getPerpSideHolders` + `getBankruptcyPrice` The two MarginBank views the agreed permissionless liquidation-keeper design consumes, from head state alone — no off-chain indexer: - **`getPerpSideHolders({ marginBank, pool, isLong }, opts?)`** — every account holding an open position on one side of one perp market. Pages through the bank's bounded slice view (`getSideHoldersPaginated`; many holders per round-trip, never one call per holder) with every page pinned to ONE block — `opts.blockNumber`, or the head sampled once — and returns `{ holders, asOfBlock }`. Feed `asOfBlock` into `getBankruptcyPrice`'s `blockNumber` option (and the other side's call) so a sweep describes one consistent state. - **`getBankruptcyPrice({ marginBank, account, pool }, { blockNumber? })`** — the bank's OWN price at which the position's allocated equity is exhausted, verbatim. A different quantity from the client-side `getLiquidationPrice` estimate (where liquidation *triggers*): keepers settle and bid against this one. Reverts arrive **named** — `ContractRevertError` with `errorName: "NoOpenPosition"` when the account is flat in that pool (and `"AdlZeroNotional"` in the dust degenerate) — even though the generated error table does not cover MarginBank yet: the module fills the name in from the ABI-declared selectors, and the seam is a structural no-op once the table grows to cover the submodule's perp contracts. New exports: `PerpSideHolders`, `PerpSideHoldersRef`, `GetPerpSideHoldersOptions` (types only — the reads live on the client, deliberately not as standalone connection-bound exports, matching the client-scoped API direction). ### Added — build-only order placement (unsigned calldata) `trader.buildPlaceOrder`, `buildPlaceSpotOrder`, and `buildPlacePerpOrder` take the same parameters as their sending twins but hand back the unsigned call instead of signing and broadcasting it: ```ts const { order, approval } = await trader.buildPlaceOrder({ pool, side: "BUY_YES", price, quantity }); if (approval) await walletClient.sendTransaction({ ...approval, account }); // `order` is {to, data, value} — pre-sign it, batch it, relay it, or simulate it. ``` For pre-signing an ERC-4337 UserOp off the form input, batching a placement into a multicall, handing it to a relayer, or simulating it. Ordinary "place this now" stays on `placeOrder`. The approval is **returned, not sent** — `placeOrder` approves as a side effect, which a build-only verb cannot do. Send `approval` first when it is present. It is absent when nothing needs approving (a native-base spot sell, every perp placement). Also newly exported from the root, so a caller can encode a placement by hand: `ORDER_KIND`, `binaryPoolWriteAbi`, `spotPoolWriteAbi`, `perpPoolWriteAbi`, and the `UnsignedCall` / `UnsignedOrder` types. (A returned `approval` is a standard ERC-20 `approve` or ERC-6909 `setOperator` — decode it with viem's own `erc20Abi` or the already-exported `erc6909Abi`.) ### Added — `ceilRawAmount` The SDK could round an amount DOWN in a controlled way but not UP. Flooring exists because a "max" one ULP above the wallet is an insufficient-balance revert; a **minimum** has the mirror failure, rejected as under the minimum. - **`ceilRawAmount(raw, decimals, quantum)`** — `raw` rounded UP to a whole multiple of `quantum`. The mirror of `floorRawBalance`, for sizing minimums. It returns a `bigint` rather than a `number` like the floors do: a lot boundary such as `0.1` at 18 decimals has no exact `double`, and the nearest one sits below the minimum. Convert at the edge with `Number(formatUnits(...))` if you need a number. ### Fixed — stop-order and SpotPool reverts have names instead of hex A failed `placeSpotStopOrder` or `cancelStopOrder` reported opaque hex. The generated revert-decoding table was extracted from this repo's Foundry artifacts only, and forge emits artifacts for just the DEX contracts our own contracts import — so `SpotStopOrderRegistry` and `SpotPool`, which nothing here imports, had no artifacts and no decodable errors at all. `Trader`'s docs promised `errorName` would carry the protocol's own error; for those paths it never could. `errors:gen` now builds the DEX submodule as its own forge project and merges its artifacts, so what the SDK can name no longer depends on what this repo happens to reference. Newly decodable: - **All 28 `ISpotStopOrderRegistry` errors** — including `InsufficientVaultBalance`, `InsufficientSomiPayment`, `NoActiveSubscription`, `LimitPriceIncompatibleWithTrigger` and `ExceedsWithdrawableBalance`. - **The `ISpotPool`-only errors** — `OrderIdMismatch` (cancelling an order that already filled) and the builder-code family (`BuilderNotApproved`, `BuilderFeeExceedsApproval`, `BuilderCodesNotSupported`). - **The parameterless `QuantityBelowMinimum()`** the registry declares, alongside the CLOB's existing `QuantityBelowMinimum(uint256,uint256)`. Different selectors, so both entries are needed; only the two-argument one was present before. `errors:check` is unchanged and still hermetic — it recomputes the stamp from this repo's files plus the submodule's pinned commit, with neither forge nor a submodule checkout, so CI needs no access to the private DEX clone. Only `errors:gen` now requires the submodule checked out, and it fails loudly rather than quietly writing a smaller table. ### Added — `trader.amendOrders`, cancel and replace N orders atomically Amending a quote one order at a time costs two transactions per rung and leaves the book briefly one-sided. `amendOrders` does the whole set in a single transaction: every old order is cancelled and its replacement placed, or the batch reverts and nothing moves. `alwaysPlace` handles the race where an order you meant to amend has already filled or been cancelled by the time the tx lands. False (the default, and the industry-standard posture) reverts `AmendOldOrderGone`; true skips the cancel leg and places the replacement anyway. It only tolerates a *gone* order — someone else's live order still reverts `IncorrectSender`. `newOrderIds` comes back index-aligned with the submitted amendments. The pool returns those ids on-chain, but a mined receipt exposes only logs, so they are reconstructed from `OrderPlaced` — which fires for every accepted order, including one that fills completely without ever resting. A rejected replacement reverts `AmendReplacementRejected(requestIndex, reason)`, which names both the rung and the `OrderRejectionReason` — the signal to branch on. Note the cancel-all-first phase shields replacements from the orders they replace but **not from each other**: a new bid crossing a new ask self-matches and, being all-or-nothing, takes the whole re-ladder with it. Unlike `placeSpotOrder`, this does not auto-approve escrow. On an auto-pull pool the cancel leg returns freed tokens to the wallet and the place leg pulls them back, so a trader whose first call is `amendOrders` needs an allowance already in place. **SpotPool and PerpPool only.** A BinaryPool reverts `UseBinaryPlacement` for the whole transaction: binary placement carries the YES/NO kind through a transient slot that only `placeBinaryOrder` sets, and there is no batch binary entry. ### Added — `trader.amendOrder`, the single-order re-quote The batch `amendOrders` landed without its singular sibling, so re-quoting ONE order meant either a two-transaction cancel-then-place (which opens a gap on the book — the thing amend exists to prevent) or a one-element batch with a worse error surface. `amendOrder({ pool, oldOrderId, alwaysPlace?, newOrder })` calls the pool's own `amendOrder`, so a rejected replacement raises its landing-time reason directly (`PostOnlyWouldCross`, `SelfMatchCancelTaker`, `ImmediateOrCancelNoFill`, `FillOrKillNotFillable`, `OrderAlreadyExpired`). The batch wraps the same failure as `AmendReplacementRejected(requestIndex, reason)`, which for a single order is an index the caller already knew and a reason they then have to unwrap. Everything else matches the batch verb: `alwaysPlace` defaults to false and reverts `AmendOldOrderGone` on a vanished order (true opts into an upsert, and never tolerates an ownership failure); the replacement gets a NEW id and loses queue priority — use `reduceOrder` to shrink in place; and the call is non-payable, so native replacements need a manual-vault balance rather than auto-pull. ### Added — claim the SOMI a perp stop registry owes you `trader.claimPerpStopSomi` and `client.getUnclaimedPerpStopSomi` — the registry holds SOMI for an account in two cases, and neither was reachable from the SDK: a cancel whose direct refund transfer failed (a contract owner with no payable receiver), and an operator winding a registry down, which credits **every** owner including EOAs. Read the balance first; the claim reverts `NothingToClaim` on zero. Deliberately not wrapped: `withdrawSomi` is owner-only and cannot touch unclaimed balances, and `poke()` — permissionless, but the caller subsidises every eligible trigger, the `somiPaid` is consumed rather than reimbursed, and below the gas reserve it breaks out and the transaction succeeds having done nothing. ### Changed — faster log decoding on the live tail and the write path `decodeEventLog` memoizes nothing: for every log it walks the ABI deriving each candidate selector, and a log the ABI does NOT carry costs a full scan plus a thrown error that the caller immediately swallows. Foreign logs are the common case — any ERC-20 `Transfer` from a token touched in the same transaction lands in the same receipt — and the live tail decodes every block. A topic0 membership check now runs first. Against `liveEventsAbi` (29 events) an unmatched log cost 223 µs and now costs 0.054 µs; on a realistic block carrying four pool events among forty foreign logs, decoding went from **9.00 ms to 0.10 ms**. Internal only — no API change, and the decoded type is unchanged: the full ABI still does the decoding, so the discriminated union callers rely on is intact. ### Added — `useIndexerQuery` aborts superseded requests + exported query-key factories `useIndexerQuery` now creates an `AbortController` per run, hands its signal to the fetch function (the `fn` signature widened to `(client, signal)`, an additive change), and aborts on dependency change, refetch and unmount — a late settle from a superseded run never reaches state, even when `fn` ignores the signal. The root entry also exports query-key factories for callers wiring client reads into their own query library: `marketsKey`, `portfolioKey`, `candlesKey`, `marketFeesKey`, `operatorsKey`, `marketCreatorsKey`, `oracleAdaptersKey`, `syncStatusKey`, `maxVenueFeeBpsKey` and `marketOnchainKey`. Plain functions, no query-library dependency, returning JSON-serializable, case-folded, scope-prefixed arrays. Client reads themselves still take no per-call `AbortSignal` — cancellation remains client-scoped via `ClientConfig.signal`; the hook's signal benefits fetch functions that do their own fetching. ## 0.27.0 (2026-08-14) The first release since 0.25.0. Three new entry points (`/chains`, `/reactivity`, `/native`), the perp trading surface filled in end to end, and batch order writes. One breaking item — the `somnia-dex-protocol` re-pin. ### Breaking - **`somnia-dex-protocol` re-pinned to `main` (DEX-1761).** Reinstall to pick up the corrected revert ABI. Four order rejections that used to be silent — post-only would cross, self-match cancel, IOC no-fill, already-expired — are now named reverts. ### Added — new entry points - **`/chains`** — every Somnia network as a viem `Chain` (mainnet, Shannon, Elwood, Hideki, local). The only place chain definitions live. - **`/chains` bridge** — the Hyperlane warp-route registry plus `createBridgeTransfer` / `sendBridgeStep`. Pure: no client, no RPC. - **`/reactivity`** — the upstream `@somnia-chain/reactivity` package re-exported, as an optional peer dependency. - **`/native`** — the node's `somnia_*` namespace: native ledger blocks, statistics, node public keys, reactivity subscription reads and session transactions. ### Added — perps - **Per-position analytics.** Unrealised PnL, accrued funding, position margin and return on margin, instead of account-level equity that folded them together and exposed neither. - **Leverage and a projected liquidation price.** `getPerpLeverage`, plus `previewPerpLiquidationPrice` for an order not yet placed. - **Max order size.** `getMaxPerpOrderSize` — what a Max button should call. It binary-searches the pool's own sizing rule rather than keeping a second copy, so the size it returns cannot be one the pool then rejects. - **Close preview.** `previewPerpClosePnl` for a full or partial close. Handles the two subtleties: size snaps down to a lot multiple before anything realises, and a close settles funding on the whole position rather than the closed share. - **Auto-pull.** Placement can now fund itself from the wallet, so opening a position is one transaction rather than approve + deposit + place. Both order-form previews take an `autoPull` flag, and `quotePerpOrderTopUp` exposes the bank's own sizing. See `docs/PERPS.md` — the flag is opt-in because the pool gates it on `msg.sender == order.owner`, so it must stay off for `placeOrderFor` and operator-grant flows. - **Stop orders (DEX-2154).** `placePerpStopOrder` with linked one-cancels-other TP/SL pairs and opening triggers, plus `listPerpStopOrders` / `getPerpStopOrder`. - **Build-only writes.** `buildPlacePerpStopOrder`, `buildCancelPerpStopOrder(s)`, `buildDepositMargin` and `buildWithdrawMargin` return the unsigned call instead of broadcasting it, so writes that are only safe together — an order with TP/SL attached, approve + deposit, withdraw + forward — fit in one UserOp, Safe batch or multicall. Approvals come back rather than going out and must execute first; ids come from your own receipt via `decodePerpStopOrderIds`. See `docs/PERPS.md`. - **Funding-rate series (DEX-2025).** `buildFundingRateSeries` and the matching React hook, for a chart — poll plus live nudge. ### Added — everything else - **Batch order writes (MAR2-95).** `placeSpotOrders` / `cancelOrders` / `reduceOrders` — a ladder in one transaction instead of a loop of single sends. - **Operator grants (TRAD-140).** Grant/revoke writes plus registry approval views. - **Dead-oracle recovery.** The two entry points the recovery path was missing, so it no longer needs `cast send`. - **Vault funding.** Deposits, and the auto-pull opt-out. - **SpotPool funding + lock reads** are public. ### Fixed - **Short-side liquidation price.** `getLiquidationPrice` reported the liquidation further away than it actually is on shorts. No call-site change, but the numbers move. - **CLOB reverts are named** rather than surfacing opaque hex. - **Perp reverts are named too.** A failed `PerpPool` / `MarginBank` / `PerpStopOrderRegistry` call now reports `ContractRevertError.errorName` — `InsufficientCollateral`, `MarketRestricted`, `InsufficientSomiPayment` — across 122 error names, so an app no longer needs a hand-copied list that goes stale. - **`stopRegistry` reaches perp market rows.** Every perp stop write takes the per-pool registry as a required argument, and the SDK gave no way to find that address. ### Changed - **`AsyncCache` (MAR2-80)** for promise-memoizing maps. ### Perp stop orders — linked (OCO) TP/SL and opening triggers `placePerpStopOrder` is now the single create entry point for perp stops, and it covers three registry functions that a caller no longer has to choose between: - **A linked (OCO) pair** — pass `pair` with the second leg. Both are created and linked atomically, at twice the SOMI (one payment funds one trigger). When one leg triggers **and fills**, the registry cancels the other and credits its SOMI back. Legs are routed by operator, not by argument order, so it does not matter which you pass first. The result carries `pairedStopOrderId` alongside `stopOrderId`, caller's own leg first. - **An opening (non-reduce-only) trigger** — pass `intent: "opening"` for a stop-entry or breakout, gated on initial margin at creation so an order that could never margin itself is refused rather than burning its SOMI at trigger. - **Unchanged by default.** A call passing neither `intent` nor `pair` still routes to `createPendingOrder` and produces byte-identical calldata to before. Also **`linkPerpStopOrders`** (pair two stops that already exist, moving no SOMI), **`cancelPerpStopOrders`** (batch teardown in one transaction with a single refund — the way to close a pair), and **`getPerpStopOrder`** (read one stop straight from the registry). That last one closes a gap this SDK previously recorded as permanent: a LIMIT stop's `limitPrice` never leaves calldata and private storage, so nothing off-chain could show the limit a trader chose. It also answers during an indexer outage, and is currently the only way to read a pair's `siblingOrderId` and an order's `intent`. Cancelling one leg of a pair yourself cancels **one order** — the other stays armed and becomes unlinked. That is the registry's behaviour, not a limitation here. `getPerpStopOrder().intent` is `null`, not `"reduceOnly"`, when the registry reports an intent this SDK version does not know. The registry appends to its enums (`DropReason` just gained `NoFill`), and defaulting an unrecognized member to reduce-only would assert that an order cannot increase a position when a newer member might let it. `listPerpStopOrders` decodes the new `NoFill` drop reason, and now breaks a `createdAt` tie with `id` so its `offset` paging cannot repeat or lose a row — two stops created in the same block share a timestamp, which left the boundary between two pages undefined. The listing does **not** yet carry pairing, intent, or a cancellation cause. Those are new indexer columns behind a from-scratch reindex, and selecting a column the deployed Hasura does not serve is a validation error rather than a null — it would take the whole read down for every caller. They arrive in a follow-up release once that reindex has cut over. ## 0.25.0 (2026-08-07) `getOpenPositionsWithPnL(account)` — reliable avg-cost PnL for ALL of an account's open binary positions in one batched call. ### Added - `client.getOpenPositionsWithPnL(account)` → `OpenPositionPnL[]`: each open position's market joined with its `costBasis` / `avgCost` / `markValue` / `unrealizedPnl` / `realizedPnl`, computed identically to `getBinaryPositionPnL` (weighted-average cost from the account's own fills, marked to the book-clamped price) but for every open market at once, in a bounded number of indexer round-trips (fills + router actions + top-of-book batched) instead of a per-position loop. Prefer this over deriving PnL from book stats. - Exported the pure `computeOpenPositionsPnL` fold + the `OpenPositionPnL` type. Additive; no breaking changes. ## 0.24.0 (2026-08-07) The perp read surface, in one release: discovery, positions, orders, risk, margin preview and protocol state. Plus order state at chain head for every market type. **Requires a reindex from scratch.** The perp stop registries are newly subscribed, so their history only exists after a full re-run. ### Breaking — `SpotStopOrder.spotOrderId` is now `placedOrderId` The field is the id of the order the stop PLACED when it triggered, and both the spot and perp registries now record it. Rename at the call site; type and meaning are unchanged. The chain event keeps its own name — this is the SDK/entity field only. ### Added — perp market discovery `listPerpPoolStatuses({ factory })`, `listTradeablePerpPools(…)`, `isPerpPoolRegistered(…)`. An on-chain source of truth, so a market deployed after the indexer's curated manifest is still visible. **"Deployed" is not "tradeable"** — two independent gates: `restricted` (close-only; position-increasing orders revert, and it is reversible) and `registered` (the MarginBank has activated the pool). `tradeable` folds both. `getPoolTier` is not a substitute — it is itself gated on registration. Do not build a market list from the factory's raw pool list; that is deployment history and lists wound-down markets as tradeable. Each row carries its pool's own `marginBank`, ready for the reads that follow. Feature-detected by ERC-165 on `IPerpPoolFactoryMarketStatus` (`0xa874fb70`, exported) with a per-pool fallback; on a chain old enough to need that fallback, `registered`/`tradeable` come back `null` rather than guessed, and `listTradeablePerpPools` throws instead of returning a short list that looks authoritative. Only a missing-selector revert is treated this way — an RPC failure still propagates. ### Added — `listPerpPositions` `listPerpPositions(account, opts?)` returns every pool's position from the indexer, replacing a chain-read-per-market fan-out. Additive; `getPerpPosition` remains the authority for a single pool. Three fields are translated at this boundary because the raw shape would produce plausible wrong numbers: `size` is **signed** (folded from the entity's absolute size + `isLong`), `entryPriceX18` is exported as `avgEntryPrice` (the column name is a misnomer — the value is raw quote units per whole base, not 1e18-scaled), and `realizedPnl` is exported as `lastUpdateRealizedPnl` (most recent update only, not cumulative, not summable). Fully-closed positions are excluded by default (`includeFlat: true` returns them). Nothing is marked to market — unrealized PnL, liquidation price and margin health remain chain reads. ### Added — `listPerpOrderHistory` `getPerpPortfolio` hard-filters `status = "Open"`, so finished perp orders were unreadable. This is the other half, most-recently-**ended** first — a long-resting order that just filled belongs at the top of a history view, not at its placement date. Note `Closed` is terminal, not transitional: an IOC that partially filled without resting stays `Closed` forever. ### Added — perp stop orders are listable `listPerpStopOrders` over the indexed `PerpStopOrderRegistry` set. The registry keeps pending orders in private storage with no getter, so before this there was no read — chain or otherwise — answering "what stops do I have"; a trader could create stops but not see or cancel them. `builderFeeBpsTimes1k` is captured now precisely because it cannot be captured later: the registry deletes a pending order on every fire, so `PendingOrderCreated` is the only record the fee ever existed. One field stays out of reach — a LIMIT stop's `limitPrice` never leaves calldata and private storage. ### Added — per-market risk parameters `getPerpRiskParams(pool)`, `getPerpHealthSnapshot(pool)`, `getEffectiveImfBps(pool)`. `maintenanceMarginBps` was exposed nowhere, so a projected liquidation price for an unplaced order was impossible. **Initial margin is not a constant; maintenance margin is.** `initialMarginBps` is the floor of the curve — with dynamic IMF the pool scales it with open interest, and `effectiveImfBps` is what an order is actually charged. Sizing off the static base under-margins whenever OI has pushed the curve up. Maintenance margin deliberately does not scale. On testnet today both are 500 bps (dynamic IMF off), which is why the distinction is documented rather than left to be discovered. `getPerpHealthSnapshot` returns a discriminated union: an unpriceable market surfaces as `{ priceable: false }` rather than an all-zero snapshot, where a `maintenanceMarginBps` of `0` would read as "can never be liquidated". ### Added — `previewPerpOrderMargin` Predicts what a perp order will lock and whether the pool will accept it, before sending. The two contract probes are also exposed verbatim: `quoteMeetsPerpImForOrder`, `meetsPerpImForFill`. **`quoteMeetsIMForOrder` looks like the pre-trade check and is not.** It runs with `baseImReserved = true`, mirroring the check that happens *after* `lockCollateral` has already reserved the order's base margin — so called cold it collapses to "does existing equity cover existing positions" and returned `true` for 10^18 base units against a live testnet account. It cannot gate an order form. The preview instead ports `PerpPool._computeLockAmount` and the `MarginBank` gate it feeds: the reducing/increasing split, **the adverse mark-to-entry reserve** (the usual reason a naively-sized "max" order is rejected, and a term a `notional × IMF` estimate misses entirely), the OI-scaled effective IMF, the per-market leverage cap, and the credit-voucher floor. Two gates are reported separately because they fail for different reasons: `hasCollateralForLock` means "deposit more", `meetsInitialMargin` means "close something". Every read is pinned to one block and `asOfBlock` is returned — composed across blocks these values tear. A purely reducing order trips neither gate, so a close is reported as accepted even from an account below initial margin. Not modelled: tick/lot quantization, position/OI caps, market restriction, the resting-order cap, isolated margin — all reject independently of margin. Verified against a live testnet account to the unit: the computed boundary is accepted at `q` and rejected at `q + 1`, matching the chain's own `meetsIMForFill`. ### Added — perp protocol state `getPerpSystemConfig`, `getInsuranceFundState`, `getLiquidationEngineConfig`, `tryGetPerpAccountEquity`, `getPerpCollateralBasis`. `getPerpSystemConfig` is the entry point — every other contract in the plane is reachable from it, as the bank's own view of them, so nothing is hardcoded per chain. Its `fullyWired` flag covers the factory, liquidation engine and insurance fund but **not** `feeRecipient`, so a go-live check must look at that separately. Insurance-fund tiers are indexed `0..maxTiers` **inclusive** — `maxTiers` is the maximum index, not a count, so the fund has `maxTiers + 1` buckets. `totalBalance` is deliberately not described as absorbable bad debt: it includes tier 0, which never absorbs anything. `liquidationEngine` is the proxy, and that distinction bites — an implementation address answers with unset defaults (zero bidders, zero penalty), which looks like a configured-but-idle engine rather than the wrong address; the returned `marginBank` is the cross-check. `bidderCount === 0n` is an operational signal: with no stage-4 backstop bidders the waterfall reaches ADL sooner than the configuration implies. `tryGetPerpAccountEquity` returns `null` where `getAccountHealth` would revert. Null is "not computable right now", never "zero equity" — the two mean opposite things. `getPerpCollateralBasis` is the complement: one storage pair, no oracle, cannot revert. ### Added — perp fields on `fetchTicker` `UnifiedTicker` gains `markPrice`, `indexPrice`, `fundingRate`, `fundingTimestamp` and `openInterest`, populated on perp symbols and `undefined` elsewhere, so one call answers a market header. A non-perp spends no chain round-trip. `fundingRate` is on the **same per-8h axis** as `fetchFundingRate` — a header reading one basis beside a chart reading another is a wrong number that looks right. It is not the per-settlement amount. `markPrice` is omitted rather than reported when the feed is stale: the contract's `0` sentinel reads as a real price, and an unguarded `markPrice - entryPrice` becomes a 100% loss on every open position. ### Added — order state at chain head `getOrderOnchain`, `getOwnOpenOrdersOnchain`, `getAllOpenOrdersOnchain` — so a caller can read its own writes instead of waiting for the indexer. All three work for binary, spot and perp pools; fields are raw `bigint` units. `getOrderOnchain` returns `null` for any id the pool has no **active** order for (unknown, filled, cancelled, or replaced by `reduceOrder`) — chain head knows what is open now, the indexed `getOrders` keeps the history. `getOwnOpenOrdersOnchain` takes the owner explicitly and needs no signer. `getAllOpenOrdersOnchain` never forwards a configured signer — the pool accepts that view only from the zero address — and surfaces the contract's own pagination as-is. ### Added — `listSweepableOrders` `listSweepableOrders({ pool?, marketType?, owner?, asOfSec? })` returns orders past expiry that are **still resting**, across the whole book — the work-list for a permissionless sweep. Each row carries `orderId` for `cancelExpiredOrders` and `isBid` + `price` for `sweepExpiredAtLevel`. **Deliberately not `status: "Expired"`.** That status is written when the chain emits `OrderExpired` — i.e. once an order has already been swept. A keeper needs the opposite: still `Open`, already past expiry. The cutoff is scaled to nanoseconds; comparing against unix seconds would make every order look unexpired by a factor of a billion. GTC excludes itself (written as now + 50 years). Longest-overdue first. ### Fixed — binary precision comes from the pool, not a one-token guess `amountToPrecision` returned `0` for any binary amount below a whole token. Binary rows arrive from the indexer with `tickSize` / `lotSize` / `minQuantity` undefined, so the helper fell back to a one-whole-token lot — while every deployed venue enforces a 0.001-token lot: ```ts // BEFORE — 0.005 outcome tokens, on a venue whose lot is 0.001 exchange.amountToPrecision("BTC-UP/USDC#YES", 0.005); // 0 ✗ // AFTER exchange.amountToPrecision("BTC-UP/USDC#YES", 0.005); // 0.005 ``` `loadMarkets()` now reads each distinct binary pool's `getOrderBookParameters` (pipelined, cached per pool, re-read on `loadMarkets(true)`), feeding `amountToPrecision`, `priceToPrecision` and `UnifiedMarket.limits.amount`. If a pool's parameters cannot be read the helpers throw `InvalidInputError` for that market rather than quantizing against the fallback — callers that treated `0` as "too small to trade" should catch it. Spot and perp precision is unchanged. ### Fixed — the baked mainnet `marketCreator` pointed at a retired venue `SOMNIA_MAINNET_ADDRESSES.marketCreator` was `0xfc4Ecc01…` (venue 3, `status: "retired"`); the active venue 5 creator is `0xfe81C4e8…`. The venue-5 recovery changed `venues.json` without rerunning `pnpm gen:addresses`, so the stale value shipped in 0.21.0–0.23.0. Mainnet consumers using the baked addresses were filtering the realtime tail (`liveTail`) on a dead contract's events. Testnet was unaffected. ## 0.23.0 (2026-08-06) Perp data corrections (open interest, mark staleness, liquidation accounting), funding-rate series reads, vault funding writes, and a type-tightening sweep across the client surface. ### Breaking — perp open interest is ONE counter `PerpMarket.longOpenInterest` / `shortOpenInterest` and the same pair on `PerpStateOnchain` and `OpenInterestSnapshot` are replaced by a single `openInterest`. Not a simplification — a correction. The contract emits `OpenInterestUpdated(uint256)` and exposes `getOpenInterest() -> uint256`, because in a matched CLOB the short side is provably equal. The two-field form never matched the deployed ABI, so the indexed pair was null on every row (its subscription's `topic0` could not match) and the chain read **threw on every call**. ```diff - const oi = BigInt(m.longOpenInterest ?? 0) + BigInt(m.shortOpenInterest ?? 0) + const oi = BigInt(m.openInterest ?? 0) ``` ### Breaking — `UnifiedFundingRate.markPrice` may be `undefined` `fetchFundingRate()` now reports `undefined` rather than a number when the pool's mark feed is stale. The pool signals staleness with a **0 price word** (`tryGetMarkPrice` returns `(ok, price)`; the event uses a 0 sentinel). That was being converted straight to a number, putting a mark of `0` on the wire as though it were a real price — and downstream, an unguarded `markPrice - entryPrice` reads as a 100% loss on every open position. `info.markPriceOk` carries the flag if you want to distinguish "stale" from "not a perp". For marking a position rather than reporting a reading, `perpMarkForPnl(state)` falls back to the index price and tells you which it used. ### Breaking — `LiquidationEvent.badDebt` is narrower, and there are three new columns `badDebt` used to receive five different quantities while being documented as "safe to SUM". It now carries **only** the uncovered, insolvent hole (`ResidualBadDebt`, `AdlPriceCapacityExhausted`). Alongside it: | column | source | aggregation | |---|---|---| | `badDebt` | `ResidualBadDebt`, `AdlPriceCapacityExhausted` | a LEVEL — never SUM | | `insuranceCovered` | `BadDebtAbsorbed.covered` | a FLOW — SUM is exact | | `deficit` | `BadDebtAbsorbed.badDebt`, `ResidualBackedByOpenPnl` | a LEVEL — never SUM | | `coverageDeclined` | `CoverageDeclinedByEquityCap` | a FLOW — SUM is exact | If you were summing `badDebt` for a bad-debt figure, that total was double-counting: the gross hole and its uncovered remainder both landed there, as did a PnL-backed hole that is explicitly *not* bad debt and a coverage amount the equity cap deliberately deferred. The correct point-in-time figure is the sum of the **latest** `badDebt` row per account — a residual is a state sample, so successive liquidations on one account re-report the same hole. `BadDebtAbsorbed.covered` and `absorbedBy` are now exposed at all (they were being dropped); `absorbedBy` arrives on `counterparty`. ### Changed — `getFundingRateHistory` → `listFundingRateHistory` Renamed for the `get*` = value-or-null / `list*` = array convention in CONVENTIONS.md. The old name forwards verbatim for one release cycle and is marked `@deprecated`. New in the same read: `order: "asc" | "desc"`. The default `"desc"` returns the newest page, so `from` is a window bound; pass `"asc"` to make it a forward **cursor**. `fetchFundingRateHistory(symbol, since, limit)` now ascends whenever `since` is given, which is what makes the ccxt pagination idiom (`since = last.timestamp + 1`) terminate instead of re-reading the tail. ### Added — funding as a series `listFundingRateCandles(pool, intervalSeconds, opts)` serves 1h / 4h / 1d rollups for ranges the raw series is too dense for, plus `densifyFundingBuckets` to fill the grid slots a sparse query legitimately omits. Rollup buckets are **absent** where no settlement's span reached them and **revised** when a catch-up settlement reaches backwards; both are properties of lazy settlement, and the TSDoc on each says so. Funding-rate normalization helpers (`fundingRate8h`, `fundingRatePerInterval`, `annualizedFundingRate`, `realizedFundingPerBase`, …) are exported. A perp funding rate is per **calculation window** (28800s), not per interval and not annual, and `fundingWindowSec / fundingIntervalSec` is 96 on testnet against an expected 8 on mainnet — so the same rate value means a 12× different per-interval accrual. Normalize with the row's own `fundingWindowSec`; a hardcoded denominator produces a plausible-looking wrong chart rather than an error. ### Breaking — market addresses and hashes are `Address` / `Hex`, not `string` Every address and hash on `Market` (and its `SpotMarket` / `PerpMarket` / `BinaryMarket` variants) is now typed with viem's `Address` or `Hex` instead of `string`: - **`Address`** — `poolAddress`, `baseToken`, `quoteToken`, `stopRegistry`, `marginBank`, `marketAddress`, `collateral`, `creator` - **`Hex`** — `marketId`, `createdByTx`, `venueId`, `context` The live-book types follow for the same reason — a field fed from a typed source but declared `string` widens it straight back and keeps the casts this change exists to remove: - `Tradable.pool` (always `market.poolAddress`) - `LiveFill.pool` / `.maker` / `.taker`, `LiveOrder.pool` / `.owner` - `DecodedEvent.address` (viem already types the log source; the SDK was casting the type away and then widening it with `.toLowerCase()`) **Lookup-key parameters stay `string`** and are not part of this change: `getLiveFills(pool, …)`, `getLiveMarketByPool(pool)`, `getLiveSpotOrderBook(pool, …)` and the other live reads take a pool address as a map KEY, which they lowercase before lookup. Tightening those would break callers holding a plain string for no safety gain — the value is being matched, not carried. ```ts // BEFORE — cast at every use await client.getBinaryOrderBook(market.poolAddress as `0x${string}`); await client.getMarketOnchain(market.marketAddress as Address); // AFTER — the type is already right await client.getBinaryOrderBook(market.poolAddress); await client.getMarketOnchain(market.marketAddress); ``` Most code needs no change at all. Existing `as Address` casts keep compiling (now no-ops), and reading a field into a `string` still works, because `Address` *is* a `0x`-prefixed string. What breaks is only the reverse direction — assigning a plain `string` INTO one of these fields, e.g. building a `Market`-shaped object from untyped data: ```ts const m: SpotMarket = { ...rest, poolAddress: someString }; // now an error const m: SpotMarket = { ...rest, poolAddress: someString as Address }; // fix ``` Values are unchanged: still lowercase, exactly as the indexer stores them. viem's `getAddress` is deliberately not used — it returns EIP-55 checksummed addresses, and the SDK's own store keys its pool/market lookup maps on the lowercase form. `Address` is a template-literal type that lowercase satisfies, so nothing is normalized at runtime and there is no per-row cost. Not retyped, because they only look hex-ish: `yesTokenId`, `noTokenId`, `strike` and `nonce` are **decimal** uint256 strings, and `id` is a bytes32 for binary markets but a pool address for spot/perp. ### Breaking — the viem escape hatch is explicit, and undecorated `SomniaMarketsClient.publicClient` is replaced by `getViemClient()`. The property was typed `PublicClient` and named `publicClient`, but it was the client the SDK had **decorated** — `readContract` / `call` rethrow as typed SDK errors. So a consumer reading their **own** contract through it lost viem's error types silently: `catch (e) { if (e instanceof ContractFunctionRevertedError) … }` simply stopped matching, no error, no warning. Worse on a foreign contract, where the decoder can't match the selector and produces a `ContractRevertError` with `errorName: undefined` — viem's typed error traded for nothing. `getViemClient()` returns the **undecorated** client, over the same WebSocket (no second connection). Reads through it keep viem's error contract. Everything reachable from the client interface still uses the decorated one, so protocol reverts arrive decoded as before. ```diff - await client.publicClient.getBalance({ address }) + await client.getViemClient().getBalance({ address }) ``` A method rather than a property, so reaching outside the SDK's error contract is a visible act — and because the call opens the socket, which a field read hid. ### Breaking — `createLend` is no longer exported `client.lend` (backed by `config.addresses.lend`) is the only entry to the lend surface. The factory took a whole `SomniaMarketsClient` to use two of its members, and let one chain's addresses be grafted onto another chain's socket — `createLend(mainnetClient, SOMNIA_TESTNET_LEND)` type-checked and silently read nothing, which the `*_MAINNET_*` / `*_TESTNET_*` constants make an easy mistake. A different deployment means a different client: ```diff - const lend = createLend(exchange.client, SOMNIA_MAINNET_LEND); + const exchange = new SomniaMarkets({ chain, wsRpcUrl, addresses: { lend: SOMNIA_MAINNET_LEND } }); + const lend = exchange.client.lend; ``` `@somnia-chain/markets-sdk/lend` still publishes the types, `SomniaLendClient`, both deployment constants, the ray-math helpers and the ABIs — only the factory is gone. ### Breaking — three-identifier reads take one params object Six methods keyed on three positional identifiers. All the perp/vault fields are `Address`, so any two swapped still compiled — and two siblings even ordered the **same** triple differently (`getPerpPosition(marginBank, account, pool)` vs `getLiquidationPrice(marginBank, pool, account)`), making the silent swap the easy mistake rather than the freak one. Each now takes a single object; the field names carry the order. On `SomniaMarketsClient`: ```diff - client.getPerpPosition(marginBank, account, pool) - client.getLiquidationPrice(marginBank, pool, account) + client.getPerpPosition({ marginBank, account, pool }) // PerpPositionRef + client.getLiquidationPrice({ marginBank, account, pool }) // PerpPositionRef (same shape, same order) - client.getVaultBalance(vault, owner, token) + client.getVaultBalance({ vault, owner, token }) // GetVaultBalanceParams - client.getOutcomeBalance(outcomeToken, account, id) + client.getOutcomeBalance({ outcomeToken, account, id }) // GetOutcomeBalanceParams - client.getBuilderApproval(pool, user, builder) - client.getEffectiveBuilderApproval(pool, user, builder) + client.getBuilderApproval({ pool, user, builder }) // BuilderApprovalRef + client.getEffectiveBuilderApproval({ pool, user, builder }) // BuilderApprovalRef ``` `Trader.getBuilderApproval` / `Trader.getEffectiveBuilderApproval` change the same way. The param types are exported from the package root. ### Changed — input/config failures throw the typed classes everywhere The last ten `throw new Error(...)` sites in the SDK now throw the documented classes — `InvalidInputError` (stop-order validation, the `quote*` methods' `{pool | marketId}` target), `NotConfiguredError` (missing `config.addresses.lend` entries), `ContractRevertError` (a lend write mined but reverted), and `RpcError` (`createStopOrder` receipt missing `PendingOrderCreated`). Messages are unchanged; only the classes (and their fields) are new. Code that caught plain `Error` still works — every class extends it. ## 0.22.0 (2026-08-05) `getPortfolio` now carries the binary contract's series cadence on every active position and open order, so a Positions / Trade History row can show whether a contract is a 15m / 1h / 4h / 24h event. ### Added - `PortfolioMarket.intervalSec` (raw seconds) + `PortfolioMarket.interval` (the derived `"15m"`/`"1h"`/… label), populated on `portfolio.positions[].market` and `portfolio.openOrders[].market`. `portfolio.trades[].market.interval` and `BinaryMarket.interval` already carried it — this closes the gap on active positions/orders. Additive; no breaking changes. ## 0.21.0 (2026-08-05) The typed error tier, and indexer reads regenerated from the schema the indexer actually serves. ### Added — the typed error tier Every SDK failure now has a class: `SomniaMarketsError` and its six subclasses (`InvalidInputError`, `NotConfiguredError`, `SignerRequiredError`, `IndexerError`, `RpcError`, `ContractRevertError`), exported from the package root. `ContractRevertError` decodes revert data against the protocol's custom errors (the generated `contractErrorsAbi`, kept current by the offline `errors:check` CI gate), so a failed call can report its Solidity error name. Pure addition in this release — the SDK's own call sites adopt the classes in the follow-up refactor; nothing thrown today changes class. Indexer reads are now typed against the GraphQL schema the indexer actually serves, instead of hand-written types kept in sync by memory. Internal change — the exported type surface, its TSDoc, and every method signature are unchanged apart from the nullability corrections below. ### Notes — why the indexer types are generated Queries were template strings with self-declared result types, so both halves could drift silently: a query could select a field Hasura no longer had (throws on every call), and a type could promise a field the wire never carried (`tsc` green, consumer reads `undefined`). `0.16.0` shipped both at once. A committed snapshot of the served schema now drives generation, so a renamed, removed, or retyped field fails `pnpm gql:codegen` / `pnpm typecheck` in the same PR that changes the schema. Public types stay hand-written; the mapping between wire and public is compiler-checked. ### Breaking — types corrected to match what the indexer actually returns Each of these promised non-null on a column the indexer genuinely leaves unset: `indexer/schema.graphql` declares them nullable, and the handlers carry `prior?.` forward, so an event arriving before the row's creation event leaves them empty. - `IndexerSyncStatus.numEventsProcessed` → `number | null` - `IndexedOracleAdapter.createdAtTimestamp` → `string | null` - `IndexedMarketCreator.createdAtTimestamp` / `.factory` → nullable; `.createdAtBlock` → `number | null` - `IndexedSeries.createdAtTimestamp` / `.updatedAtTimestamp` → `string | null` Migration: handle `null` where you read these (usually `?? "—"` at the render site). If you formatted them as timestamps, you were rendering January 1970 for the absent case. ### Notes - `schema:pull` now refuses to run against a Hasura that has not finished being configured. envio creates relationships ~25s after it starts tracking tables, and a snapshot pulled in that window is silently missing every relationship field — which then validates cleanly against every offline check. ## 0.20.0 (2026-08-04) **First public npm release.** The package now publishes to the public npm registry (`registry.npmjs.org`) — install is just `pnpm add @somnia-chain/markets-sdk viem`, no GitHub Packages registry config or token. Versions ≤ 0.19.0 remain on GitHub Packages. ### Breaking — token symbols preserve case - **Token symbols preserve case.** `sanitizePart` and `loadMarkets` no longer uppercase ERC-20 `symbol()` reads. Venue token symbols are mixed-case identities (`USDso`, `USDC.e`) and consumers key on them verbatim, so forcing uppercase was silently breaking lookups. If you persisted symbol strings from an earlier SDK version (e.g. `"SOMI/USDSO"`), they will no longer match the registry (`"SOMI/USDso"`) — re-derive stored symbols from `loadMarkets()` after upgrading. Symbols that were already all-uppercase are unaffected. ### Added — baked-in per-chain addresses New `SOMNIA_TESTNET_ADDRESSES` / `SOMNIA_MAINNET_ADDRESSES` constants — the full `SomniaMarketsAddresses` map per chain (including the SomniaLend wiring), generated from the canonical deployment manifests at release time (`pnpm gen:addresses` → `src/addresses.ts`). External consumers get a zero-setup `config.addresses` without the monorepo's private deployments hub. ### Added — book-clamped PnL marks PnL helpers used to mark unresolved positions to `lastPrice` alone. On a one-sided book that freezes the mark at a stale print — we saw a deep-ITM position showing +7.4% uPnL when it was really down 60%, because the losing side's makers had pulled their quotes and only a far-side bid remained. - **`markYesPrice(top, lastPrice)`** (new, exported): returns the two-sided mid, or the last trade clamped into the surviving side's bound. A resting quote beyond the last print is live, executable information and supersedes it; a last print inside the bound still wins (so a bid-ask spread doesn't flash as a loss right after a taker clears the top of the book). - `computePositionPnL` and `computeBinaryPnl` accept an optional **`opts.bookTop`** (`YesBookTop`, best YES bid/ask) and mark with the clamped price. Omit it and behaviour is unchanged (mark to `lastPrice`). - `client.getBinaryPositionPnL` now fetches top-of-book alongside the indexer fan-out (one extra `eth_call`) and passes it through. An indexer-only client or a failed read falls back to `lastPrice` alone — no breaking change for existing callers. - `BinaryPnl` legs (now named **`BinaryOutcomePnl`**) additionally report **`avgCost`**, **`mark`**, and **`value`**, so a per-leg positions UI can render entry/mark/value/uPnL% straight from the SDK instead of hand-rolling the fold. - **`binaryFillsFromPortfolio(trades, decimals)`** (new): derive `BinaryPnlFill`s from one market's slice of `getPortfolio().trades`, which already carries the account's own side per fill. - The unified `markYesPrice` replaces the simpler mid-or-last version that shipped unreleased with the presentation tier. Same name, now `(top: YesBookTop, lastPrice)` with the one-sided clamp, exported from the barrel. `midYesPrice` (odds display) is unchanged. ### Added — the unified presentation tier A unified exchange facade (`SomniaMarkets`) for presentation apps — the surface a frontend needs to render tickers, order books, and account state without reaching into the low-level client. - **`fetchTicker`**: ticker snapshots (last price, bid/ask, 24h volume). - **`fetchOrders`**: open and historical orders with a unified shape. - **Stop orders**: place and track stop orders through the same facade. - **Portfolio analytics**: `getPortfolio()` returns trades and positions with enough context to compute per-market PnL without extra calls. - **`takerIsBid`** on live fills: know which side of the book a fill took liquidity from. - **`txHash`** on `UnifiedTrade` and `UnifiedOrder`: link trades and orders back to their on-chain transaction. ### Added — stake-sized binary quotes - **`quoteBinaryStake`**: get a buy quote sized by stake (the amount you want to spend) rather than by quantity. - **`quoteBinarySell`**: get a sell quote that walks the crossable bids — like the buy side walks the asks — so a thin book surfaces as a partial unwind up front instead of a silent IOC cancel. - `BinarySellQuote` gains **`fillableQuantity`** and **`estProceeds`**: see how much of your position the book can actually absorb and what you'd get for it before you submit. ## 0.19.0 (2026-08-03) **Market timeframe served with markets & trade history** — the series cadence a binary market trades on (15m / 1h / 4h / 24h) is now a ready-to-render label on every market AND every trade-history row, so consumers stop re-deriving `expiry − tradingStart` and hand-formatting it. ### Added - **`BinaryMarket.interval`** — a new derived field on every market the SDK returns (all list + point reads flow through it): the timeframe label (`"15m"` / `"1h"` / `"4h"` / `"24h"`), computed from `intervalSec` (falling back to `expiry − tradingStart`). The label uses the largest unit up to hours that divides the cadence cleanly, so a 10-minute market reads `"10m"`. `null` on SPOT/PERP. - **Trade history carries the market's timeframe.** `FillRow` (from `getFills` / `getUserFills`) gains a joined **`market`** context — `{ asset, intervalSec, interval, tradingStart, expiry }` (new `FillMarketContext` type) — so a trade row can show which timeframe the order was for without a second query. `PortfolioTrade.market` (from `getPortfolio`) likewise gains `intervalSec`, `interval`, `tradingStart`, `expiry`. - **New canonical cadence helpers** (exported from the barrel): `resolveIntervalSec`, `snapIntervalSec`, `formatIntervalLabel`, `marketIntervalLabel`, and the `IntervalSource` type — the single `intervalSec → "15m"` mapping the explorer and other UIs now share instead of each re-implementing it. ## 0.18.0 (2026-08-03) **SomniaLend integration** — the SDK now wraps the third-party SomniaLend money market (an Aave v3.0 fork on Somnia mainnet 5031 + testnet 50312) so trading capital can earn while idle: supply USDso between sessions, post collateral, borrow working capital against it. ### Added - **New `client.lend` namespace** on `SomniaMarketsClient`: `lend.listReserves()` (every listed asset — config, caps, live ray rates, indexes, liquidity, oracle price, one aggregated eth_call), `lend.getAccount(address)` (health factor, borrowing power, and every non-empty position, balances accrued to head with Aave's own interest math), and `lend.createLender(signer)` — the write surface (`supply` / `withdraw` / `borrow` / `repay` / `setUseAsCollateral` plus native-SOMI gateway variants `supplyNative` / `withdrawNative` / `borrowNative` / `repayNative`). Auto-approval follows the trader doctrine (one allowance read, `maxUint256` grant, in-memory cache); `borrowNative` additionally auto-delegates credit on the WSOMI variable-debt token. Variable rate only. - **New subpath entry `@somnia-chain/markets-sdk/lend`** — the same module standalone: `createLend(client, addresses)`, all types, the ray-math helpers (`lendRayRateToApy`, `rayMul`, `accrueLinear`, `accrueCompounded`, `RAY`), and the verified minimal ABIs. - **`addresses.lend?: LendAddresses`** on `ClientConfig` wires the deployment; the published addresses ship as `SOMNIA_MAINNET_LEND` / `SOMNIA_TESTNET_LEND` (NOT in the deployments manifests — SomniaLend is third-party; the testnet addresses are undocumented upstream, extracted from the official app's testnet mode and verified on-chain). Lend methods throw a clear error when the address they need is unset. - **React**: `useLendReserves()` / `useLendAccount(address)` in `@somnia-chain/markets-sdk/react`. - Docs: `docs/LEND.md` guide; ABI shapes pinned against the verified deployed contracts (UiPoolDataProviderV3's v3.0 54-field reserve tuple — do not hand-edit). ## 0.17.0 (2026-07-29) Two related price-feed changes: reads are pinned to a **quote asset**, and the feed's **freshness surface** becomes readable. ### Changed — quote pinning The feed publishes more than one pair per base — `BTC/USDC` and `BTC/USDT` both carry `base: "BTC"` — so `base` alone stopped being a unique feed key and every read matched two rows. The oracle has migrated to USDC: the USDC rows are live, the USDT rows froze on 2026-07-21, and they have since drifted ~5% apart. `Feed(where: {base})` returned both and the SDK took `[0]`, so the current price was a coin-flip between a live value and a week-stale one. - `PriceFeedConfig` gains an optional **`quote`** (case-insensitive, e.g. `"USDC"`). When set, every read — snapshot, feed info, history, candles, the catalog, and both live subscriptions — adds a `quote: {_eq}` clause, so a base resolves to exactly one feed. `$quote` is passed as a GraphQL variable, never interpolated. - `SOMNIA_TESTNET_PRICE_FEED` is now pinned to **`quote: "USDC"`**. - Leaving `quote` unset preserves the old unfiltered behaviour (correct only where each base has a single quote). Beyond the wrong current price, an unpinned multi-quote base merged two series into one candle response, producing duplicate `bucketStart` values — a hard error in charting libraries that require strictly ascending, unique timestamps (observed at `H1` and `D1`; `M1` windows are currently short enough to miss it). **Behaviour change:** an unfiltered `listPriceFeeds()` against the testnet feed no longer returns bases that lack a USDC pair — today that is `BCH` (USDT-only), so the catalog goes 31 → 30 rows. Pass `quote: undefined` to opt out. ### Added — freshness The SDK previously selected no timestamp beyond the block, so a consumer could not distinguish a 1-second-old price from a day-old one — and since a stalled feed simply stops pushing, a live subscription looks identical to a healthy one. On 2026-07-28 `SOMI/USDC` was 26.8h stale while every consumer reported it live. - `PriceFeedInfo` gains **`updatedAtMs`** (when the oracle last wrote the feed), **`sourceUpdatedAtMs`** (when the underlying market data was timestamped), and **`resynced`**. All unix **milliseconds**, null when unknown. - New **`useLivePriceFeedInfo(asset)`** hook — `getLivePriceFeedInfo` already existed on the client but had no reactive React binding. Comparing the two timestamps separates the two distinct failures: a large `updatedAtMs - sourceUpdatedAtMs` gap means the oracle is still writing but with stale source data, whereas a growing `updatedAtMs` age means it stopped writing. Both fields ride the shared selection set, so they populate from the snapshot **and** the live `Feed` subscription. Note that neither re-renders as a price ages: age must be computed against a local clock on a timer, because a stalled asset delivers no event to react to. ## 0.16.0 (2026-07-24) `listBuilderApprovals` / `BuilderApproval` reconciled with the indexer entity, plus follow-ups from the docs pass. ### Breaking — `listBuilderApprovals` matches the indexer entity The old query selected `pool` and `updatedAt` and ordered by `updatedAt`, none of which exist on the `BuilderApproval` entity — every call threw `field 'updatedAt' not found in type: 'BuilderApproval_order_by'` against a live indexer. - `BuilderApproval.updatedAt` → **`timestamp`** (unix seconds of the last `BuilderApproved` upsert; also the sort key, newest first). - New fields mirroring the entity: **`market`** (market id), **`blockNumber`**, **`txHash`**. `pool` is kept, now joined via the market row. - Migration: `approval.updatedAt` → `approval.timestamp`; everything else is additive. ### Changed — follow-ups from the docs pass - **`exchange.fetchStatus()` gains `"connecting"`** (union widening): a watch whose WS handshake hasn't delivered a head yet reports `"connecting"` instead of a false `"error"` — `"error"` now means a previously-live socket was LOST. Callers switching exhaustively on `status` must handle the new member. - `claimableFrom` now enforces the lowercased `pool` its result type documents (previously true only via the indexer wiring). - Docs: `RegisterSeriesParams.asset` / `SeriesOnchain.asset` corrected to a plain display ticker (`"BTC"`, not `"BTC/USDT"`) per the MarketCreator natspec — it must match the source exchanges' spot listing for candle sources; `binaryPoolImpl` doc no longer claims it is unread (the explorer renders it). - Dead code: unused `PortfolioTrade` import (exchange.ts) and unused `resolved` local (derivedReads.ts) removed. ## 0.15.0 (2026-07-21) A1 — resolution surplus refunded to the reserve-PAYER (the market creator) instead of the operator, and the autonomous MarketCreator self-reclaims its own surplus. Additive to the 0.14.0 OracleHub surface (no breaking changes). ### Added — OracleHub payer credit - **ABI** (`machineryAbi.ts`): `payerCreditOf(address)`, `payerOf(bytes32)`, `withdrawMyCredit(uint256,address)`; events `PayerSurplusCredited(address indexed payer, bytes32 indexed marketId, uint256)` + `PayerCreditWithdrawn(address indexed payer, address indexed to, uint256)`. - **Reads**: `client.payerCreditOf(payer)`, `client.payerOf(marketId)`. - **Write**: `createOracleHubAdmin().withdrawMyCredit({ amountWei, to })` — msg.sender-gated (the connected signer draws only its own accrued payer surplus). `WithdrawMyCreditParams`. ### Added — MarketCreator self-reclaim + migration - **ABI**: `reclaimOracleCredit()`, `armFirstRoll(uint32,uint256)`, plus reads `firstRollArmed`/`latestExpiryBySeriesId`/`armedBoundary`/`marketCount` and `withdrawNative`/`cancelSubscription`. - **Admin**: `createMarketCreatorAdmin().reclaimOracleCredit({ creator })` (manual sweep of the leftover surplus; runs automatically each roll cycle on-chain) and `.armFirstRoll({ creator, seriesId, firesAtSec })` (deferred first roll for a seamless MarketCreator migration — start the new creator exactly at the old market's expiry). `ReclaimOracleCreditParams` / `ArmFirstRollParams`. ## 0.14.0 (2026-07-20) Oracle v2 + Settlement v3 for the binary CLOB, plus the operator "market machinery" management layer. This entry consolidates ALL work since 0.13.0 — the branch-internal 0.15–0.18.x iterations (escrow → prepaid → earmark) were never released, so only the final state is described here. BREAKING for the OracleHub surface and the binary settlement/resolution reads. ### Breaking — Oracle v2: earmark-at-creation resolution funding - Funding is now EARMARK-AT-CREATION: the per-market resolution reserve is attached to the market-creation value and LOCKED per-market at `onBind` (never withdrawable while the market is live). At resolution the exact metered gas cost is charged against the earmark and the surplus (`reserve − charged`) is credited to the operator's WITHDRAWABLE credit. No prepaid pool, no per-bind escrow. Bounded-drain resolution (batched callback + self-armed `Schedule` continuation) + content-addressed question dedup. - **OracleHub ABI** (`machineryAbi.ts`): `earmarkedOf`/`creditOf`/`outstandingOf`/ `withdrawableOf(uint32)`, `resolveReserve()`, `reservedFor(bytes32)`, `operatorOf(bytes32)`, `marketsForQuestion`, `pendingResolves`, `continuationSubId`, `setDrainParams`. `withdraw(uint32,uint256,address)` is credit-only. The prepaid/escrow surface is removed. - **Events**: `ReserveEarmarked`, `SurplusCredited`, `CreditWithdrawn`, `MarketBound`, `MarketResolveCharged`, `AnswerDelivered`, `CallbackAccounted`, `DrainContinuation` (indexed-ness byte-for-byte with `OracleHub.sol`). - **`quoteCreateMarketValue(def)` = `getSchedulingCost(def) + resolveReserve()`** (both attached to the create; excess refunded). - **`syncSettlement(marketId)`** (module write): permissionless earmark reconcile for a market voided via `BinaryMarket.voidExpired()` (which bypasses the module, so the hub's earmark release never fires). Idempotent; reverts `MarketNotSettled` while still live. - **Indexer reads** (`query.ts`, matching `indexer/schema.graphql`): `OperatorHubAccountRecord` (`earmarked`/`credit`/`outstanding`), `getOperatorHubAccount`/`listOperatorHubAccounts`; reshaped `OracleBindRecord`/`OracleCallbackRecord`. `preflight.ts` gates on the full create value. Client surface (`oracleHub.ts`/`createClient.ts`/`somniaMarketsClient.ts`) follows. ### Breaking — Settlement v3: payout vectors - Binary markets settle to a payout VECTOR (`payoutNumerators`, denominator 10_000_000); redemption pays `amount × num[idx] / D` — one formula for win / loss / void (a losing redeem pays 0 without reverting). Reusable pools + a permanent `BinarySettlement` singleton. - **`getSettlement`** (`readsAbi.ts`) returns `(…, uint256[] payoutNumerators)` — removed the `uint8 winningOutcome` slot. `SettlementRecord` exposes `payoutNumerators` + a derived `winningOutcome`. - **`winningOutcome()` was REMOVED from BinaryMarket** — the SDK derives the winner as the argmax of `payoutNumerators` in `getMarketOnchain`, the `redeem()` auto-winner lookup, and the `Resolved(uint32 payoutDenominator, uint256[] payoutNumerators)` live-tail decode. ### Added — the operator "market machinery" management layer - Machinery admins (same signer doctrine as `createOperatorAdmin`): `createOracleAdapterAdmin` (create/fund/enableReactivity/gas params + adapter status), `createGovernanceAdmin` (`setAdapterApproved`, module-owner gating), `createMarketCreatorAdmin` (create/fund/registerSeries/updateSeries/triggerRoll/gas params + creator & series reads). - Indexer machinery reads (`listMarketCreators`/`getMarketCreator`/`listOracleAdapters`/ `getOracleAdapter`/`listSeries` + `Indexed*` mirrors), the `MarketTypePlugin` registry (fee-codec + machinery step descriptor keyed off the bytes4 `marketType`), and per-step preflight validators. - Deployments hub + SDK config gain `marketCreatorFactory`/`oracleAdapterFactory`/ `sharedOracleAdapter` (all optional, degrade cleanly when unset). ## 0.13.0 (2026-07-18) Settlement-extraction v2 — the binary release. A BinaryPool is no longer the permanent redemption custodian: on finalize its backing + resolution snapshot sweep to the ONE `BinarySettlement` singleton (the redemption home, forever), and the pool is recycled onto the next market — the same pool address serves SUCCESSIVE markets. This release also lands the full trader write surface, the pool-reuse / binding reads, the recycle-safe live order book, and the derived analytics bundle a frontend needs for a full-lifecycle up/down prediction-market product. Requires a v2 deployment (BinarySettlement + v2 pool impl + v2 OutcomeToken6909) and the WS5 indexer schema. Supersedes the 0.12.x binary surface; carries all of `main` through 0.12.3 (price-feed `PriceFeedScheduler` realignment + `PricePoint.requestId`). ### Breaking - **Outcome-id encoding changed** — v2 wedges the pool's per-market `nonce` between the pool address and the outcome index: `id = (uint160(pool) << 72) | (nonce << 8) | idx`; `marketKey = id >> 8 = (pool << 64) | nonce` keys settlement records. The old `outcomeIdFor(pool, idx)` (`pool << 8 | idx`) is REMOVED — use the new exported helpers `outcomeId(pool, nonce, idx)` / `decodeOutcomeId(id)` / `marketKey(outcomeId)` from the package root. Any cached v1 ids are invalid. - **`kindOf(isBid, userData)` is REMOVED** — v2 stopped encoding the YES/NO side in `userData` (it is opaque market-maker bookkeeping now, forwarded verbatim). The side comes from the pool's new `BinaryOrderPlaced(orderId, kind)` event; map the enum with the new `sideOfKind(kind)` / `ORDER_KIND_SIDE`. Never decode `userData`. - **`getMarketOnchain(marketId)` replaces `getMarketOnchain(marketAddress)`** — market identity is the module's bytes32 `marketId` (pools/market contracts are recycled/per-market). Resolves through `BinaryMarketsModule.markets(marketId)`; requires `addresses.binaryModule`. A 20-byte address argument throws loudly. The result gains `marketAddress` / `nonce` / `finalized`, and `backing` falls back to the settlement record's NET backing once finalized (the pool-side `market.backing()` reads 0 from then on). - **`Trader.redeem` routes through the module, keyed by `marketId`** — `RedeemParams.marketId` (bytes32) is required; `market` (address) is now only an optional lookup aid for `outcomeIdx`/`outcomeToken`. The module pulls the winning tokens under an ERC-6909 operator grant to the MODULE (auto-approved), finalizes-if-needed, and redeems via settlement. The v1 pool `redeem` no longer exists on-chain. - **Pool write ABI**: `placeOrder` → `placeBinaryOrder(kind, price, quantity, expireTimestampNs, orderType, selfMatchingOption, builder, builderFeeBpsTimes1k, userData)` (+`placeBinaryOrderFor`). The generic `placeOrder`/`placeOrderFor`/`amendOrder` REVERT (`UseBinaryPlacement`) on binary pools. `redeem` is gone from `binaryPoolWriteAbi`. - **Order expiry must satisfy `0 < expireNs ≤ pool.marketExpiryNs`** — the pool rejects never-expiring / beyond-market orders (`OrderExpiryBeyondMarket`). `Trader.placeOrder` now DEFAULTS the expiry to the market's expiry (one `marketExpiryNs` read) instead of ~50y; an explicit `expireTimestampNs` is forwarded verbatim (no silent clamping). - **Live-tail events**: the pool `Redeemed` / `SettlementFeeCharged` events no longer exist (redemption + the one-time fee skim live on the settlement singleton). New events consumed: pool `BinaryOrderPlaced` / `PoolFinalized` / `PoolRecycled`; module `MarketFinalized` / `PoolReleased`; settlement `MarketFinalized` / `SettlementFeeCharged` / `Redeemed` / `PayoutOwed` / `OwedClaimed`. The module `MarketCreated` gained a `nonce` field. - **Pool address is a TIME-VARYING market binding** — never key a market by pool address. `getMarketByPool` now returns the pool's NEWEST (current) market and documents the caveat; `BinaryMarket` rows expose `nonce` for disambiguation. ### Added **Settlement + redemption** - **Trader methods**: `redeemDirect({ outcomeId, amount, to? })` (settlement redemption by raw id), `claimOwed({ token })` (push-fallback pull), `finalizeMarket({ marketId })` + `releasePool({ marketId })` (permissionless keeper entries), `getSettlement(marketId)` → `SettlementRecord | null`. - **`trader.signRedeemAuth(params)` + `trader.redeemFor(params)`** — the relayed (gasless) redeem pair. `signRedeemAuth` has the position OWNER sign an EIP-712 `RedeemAuthorization` over the module's `REDEEM_AUTH_TYPEHASH` in the `SomniaMarkets`/`1` domain (`verifyingContract` = the `binaryModule`); no tx is sent. A relayer then submits it via `redeemFor` — they pay the gas, the module pins the payout to `owner` (never the relayer). New types `RedeemAuthorization`, `SignRedeemAuthParams`, `RedeemForParams`. - **Config**: `addresses.binarySettlement` (and `@somnia-chain/deployments` maps the manifest's `BinarySettlement` proxy key onto it). **Pool reuse / bindings** - **`client.getPoolBindings(pool)` → `PoolBindingRecord[]`** — a pool's full pool→market binding history from the indexer (WS5 `PoolBinding`; newest nonce first; `toBlock === null` marks the current binding; `closedBy` is `"Released" | "Rotated"`). - **`client.getPool(address)` → `IndexedPool | null`** — the indexer's per-pool aggregate (creator, collateral, `currentMarketId`, `currentNonce`, `generationCount`). - **`client.getPoolCreator(pool)` / `client.getFreePools(creator, collateral)`** — the chain reads previously only reachable through the signer-bearing trader, now on the unsigned client tier (an unconnected explorer can render them). Standalone `getPoolCreator` / `getFreePools` are exported from the package root too; the Trader methods (`poolCreator(pool)`, `getFreePools(creator, collateral)`) remain. Pool "sponsor" is uniformly "creator" across the SDK, matching the on-chain `poolCreator()` view and the indexer `Pool.creator` field. - **`"Finalized"` in `BinaryMarketStatus`** — the indexer's `ClobMarketStatus` terminal state (set when the market's backing + resolution sweep to the BinarySettlement singleton; supersedes Resolved/Voided). Flows through every `status` filter (`listBinaryMarkets` / `listLiveBinaryMarkets` / `listPastBinaryMarkets` / `countBinaryMarkets`). The live-tail reducer now also sets it on the module/settlement `MarketFinalized` events. The on-chain `BINARY_MARKET_STATUS` index map is deliberately unchanged (no on-chain enum member exists). **Trader writes** - **`trader.reduceOrder(params)`** — shrink a resting order's remaining quantity IN PLACE, keeping its price-time queue priority (unlike an amend, which re-queues at the back). Works on spot AND binary pools — `BinaryPool` implements the `_onOrderReduced` → `_refundPartial` hook, so the freed escrow returns to the owner. New `ReduceOrderParams`; new `reduceOrder` entry on `binaryPoolWriteAbi`. - **`trader.cancelExpiredOrders(params)` / `trader.sweepExpiredAtLevel(params)`** — the permissionless keeper drains for the resting book (inherited from the OrderBook base, callable by anyone on a binary pool). `cancelExpiredOrders` cleans an explicit list of expired ids; `sweepExpiredAtLevel` walks one price level from the best order cleaning up to `maxCount`. Each returns locked escrow to the order owner (best-effort — non-expired / stale entries are skipped on-chain). New `CancelExpiredOrdersParams`, `SweepExpiredAtLevelParams`; new `cancelExpiredOrders` / `sweepExpiredAtLevel` entries on `binaryPoolWriteAbi`. - **`PlaceOrderParams.userData?: bigint`** — opaque MM bookkeeping tag, default `0n`, forwarded verbatim (the SDK never sets or interprets it). **Live order book (recycle-safe)** - **`store.bookLevels(pool)` filters by the pool's CURRENT `market_id`, structurally.** A `BinaryPool` is recycled across markets (one pool serves successive markets, never concurrently). The live book now requires `o.market_id` to equal the pool's current binding (`marketByPool(pool)?.id`), and returns an EMPTY book when the pool has no current binding — the exclusion of a prior market's orders is now structural, not reliant on expiry timing. - **`client.getLiveBinaryOrderBookByMarket(marketId, { depth? })`** + the **`useLiveBinaryOrderBookByMarket`** hook — resolve a binary book by `marketId` rather than pool address. If `marketId` is no longer the pool's current binding (stale/ended), returns an EMPTY book so a stale page can't render the successor market's orders. Backed by the new `store.bookLevelsByMarket`. **Reads & analytics (derived; no new indexer field)** - **`client.quoteBinaryOrder({ pool | marketId, side, quantity, depth? })`** — a market-order preview over the live book (`{ avgPrice, cost, filledQuantity, wouldRest, levelsConsumed, slippageVsMid }`). BUY consumes asks, SELL consumes bids, respecting the YES/NO price inversion. Also exported as the kernel `quoteBinaryOrderOverBook`. New type `BinaryOrderQuote`. - **`client.getMarketStats24h({ pool | marketId })`** — trailing-24h `{ volume24h, trades24h, priceChange24h, high24h, low24h, openPrice24h }` summed from 1h candle buckets. Kernel `marketStats24hFromCandles`; new type `MarketStats24h`. - **`BinaryMarketFilter.orderBy`** (`"newest" | "closingSoon" | "volume" | "tradeCount"`) threaded into `listBinaryMarkets` + `listLiveBinaryMarkets` as the Hasura `order_by` (server-side). `listBinaryMarkets` still defaults to newest, `listLiveBinaryMarkets` to closingSoon; an explicit `orderBy` overrides. New type `BinaryMarketOrderBy`. - **`client.getBinaryPositionPnL(account, marketId)`** — avg-cost position PnL (`{ balanceYes, balanceNo, costBasis, avgCost, markValue, unrealizedPnl, realizedPnl }`, RAW units) reconstructed from the account's order-book fills folded with complete-set mints/merges, marked to `lastPrice` (or the settlement payout once resolved). Kernels `pnlEventsFor` + `computePositionPnL`; new types `BinaryPositionPnL`, `PnLEvent`. - **`client.getClaimable(account)`** — redeemable positions across settled (resolved/voided) markets, each shaped to feed `trader.redeemMany({ entries })`: `{ marketId, pool, outcomeIdx, amount, estPayout, status }`. Winner payout skims the settlement fee; voided pays half both sides; losers omitted. Kernels `claimableFrom` + `estPayoutFor`; new types `ClaimablePosition`, `ClaimableInput`. - `RouterActionRecord` now surfaces the existing indexer `amount` field (each outcome's set size) so mint/merge cost basis folds in; `PortfolioMarket` now carries `id` (the bytes32 marketId). - **`getMarketResolution`** now returns **`openingAnswer`** (the reference-question oracle answer — a reference-mode market's OPENING price) and **`closingAnswer`** (its own resolution answer — the CLOSING price) alongside the outcome. `oracleAnswer` is kept as a deprecated alias of `closingAnswer`. Requires the deployment manifest to record `OracleCore` (its `AnswerPosted.numericValue` is the price) — the deploy script now writes it. - **`client.getOpeningPrices(marketIds)`** — batch opening (reference) prices for many markets in one pair of round-trips (map `marketId → raw numericValue`), for list views that show each up/down market's opening price without an N+1 fan-out. **Id helpers, ABIs, rows, live tail** - **Id helpers** (single source of truth, exported): `outcomeId(pool, nonce, idx)`, `decodeOutcomeId(id)`, `marketKey(outcomeId)`, types `DecodedOutcomeId` / `OutcomeIdx`. - **ABIs**: `binarySettlementAbi` (redeem / finalizeAndRedeem / finalize / claimOwed / getSettlement / isFinalized / owed / isPoolApproved / poolRegistrar / outcomeToken), `binaryModuleWriteAbi` / `binaryModuleReadAbi` (module redeem rails + finalizeMarket / releasePool + settlement / poolCreator / getFreePools / freePoolCount / marketNonce / markets), pool v2 reads on `binaryPoolReadAbi` (marketNonce / settlement / finalized / booksEmpty / marketExpiryNs / setBacking / getBinaryPoolParams), event ABIs `binaryPoolEventsAbi` / `binarySettlementEventsAbi`. - **`BinaryMarket` rows**: `nonce` / `finalized` / `netBacking` (WS5 indexer schema). `MarketOnchain`: `marketAddress` / `nonce` / `finalized`. - **Live tail**: watches the module + settlement whenever any market is watched; reducer implements the v2 binding model (`MarketCreated` opens/re-points a pool→market binding, `PoolReleased` closes it — order events attribute via the pool's CURRENT binding), takes sides from `BinaryOrderPlaced`, zeroes pool backing on `PoolFinalized`, and tracks the settlement-side `netBacking` via the settlement `MarketFinalized` / `Redeemed`. ### Migration 1. Regenerate/refresh the deployment manifest (v2 deploy adds `BinarySettlement`); `addresses.binarySettlement` flows through `@somnia-chain/deployments` automatically. 2. Replace `outcomeIdFor(pool, idx)` with `outcomeId(pool, nonce, idx)` — get the nonce from `BinaryMarket.nonce`, `MarketOnchain.nonce`, or `pool.marketNonce()`. Purge any cached v1 ids. 3. Replace `kindOf(isBid, userData)` with `sideOfKind(kind)` joined from `BinaryOrderPlaced` (indexer rows keep serving `side` precomputed). 4. `client.getMarketOnchain(...)`: pass the bytes32 `marketId` (from `listBinaryMarkets` / `BinaryMarket.marketId`), not the market address. 5. `trader.redeem(...)`: pass `marketId` (+ optionally `outcomeIdx` to skip a read). Native redemption (`redeemNative`) and complete-set methods are unchanged. 6. Market makers: tag orders with `userData` freely — it round-trips verbatim and appears on indexed orders; it no longer selects the book side. ## 0.12.3 (2026-07-17) Re-add `PricePoint.requestId` — the on-chain feed re-added `requestId` to `PriceUpdated` (as an indexed arg) and the indexer now exposes it again, so the SDK surfaces it once more. Additive; the field 0.12.2 dropped is back. ### Added - `PricePoint.requestId` (decimal string) — the Somnia-Agents batch request that produced a tick. One request prices every symbol in the tick, so all of a tick's rows share the same `requestId` (group by it for a whole batch / join to agent provenance). Read via `getPriceHistory` / the live tick tape. ## 0.12.2 (2026-07-17) Price-feed schema realignment — the price feed is now the on-chain `PriceFeedScheduler` (spot index + EMA mark) rather than the per-asset EMA oracles. The `Feed` / `PricePoint` / `Candle` reads and the live tail target the scheduler's fields. Requires the price-feed indexer at the PriceFeedScheduler schema (a full reindex — already live on dev). ### Changed - Price-feed GraphQL reads map the feed's new server fields onto the SDK's stable shape: `price` ← `spot` (median spot index), `ema` ← `mark` (the EMA-smoothed perpetual mark), `emaClose` ← `markClose`. The public `LivePrice` / `PricePoint` / `PriceCandle` field names are unchanged, so consumers need no code changes. ### Removed - `PricePoint.requestId` — the scheduler batches every symbol into one agent request, so there is no per-tick request id. ## 0.12.1 (2026-07-16) Docs-only release — no API, ABI, or behavior change. ### Docs - Completed guide coverage for 20 previously reference-only client methods: the batch price reads (`watchPrices` / `getLivePrices` / `fetchPrices`) + `isTailing` (PRICES), spot discovery (`listSpotMarkets` / `getSpotMarket`, SPOT), binary discovery (`listBinaryAssets` / `countBinaryMarkets`, BINARY), and the cross-cutting reads (`getBalances`, `getOutcomeBalance`, `getErc20Metadata`, `getErc20Allowance`, `getContractMeta`, `getMaxVenueFeeBps`, `getOrders`, `getUserFills`, `getMarketStatusHistory`, `countMarkets` / `countVenues` / `countOperators`, ENGINE). Every public method was already in the generated API reference; these are the narrative-guide additions. - Fixed three broken `{@link}` cross-references in TSDoc (`binaryFillsFor`, `computeBinaryPnl`, `listBuilderApprovals`) that rendered as dead links in the API reference. ## 0.12.0 (2026-07-16) Coverage-gaps wave — close the read/write/hook gaps against the coverage-gaps indexer schema (new perp-account, fee-stream, resolution, router, and vault-credit entities). Requires an indexer at that schema (a full reindex). ### Added - **Router action history** — `getRouterActions(account, opts?)` → `RouterActionRecord[]` (redeem / mint / merge, from the indexer `RouterActionRecord`). - **Resolution visibility** — `getMarketResolution(marketId)` → `{ events, reference, oracleAnswer }` joining `MarketResolutionEvent` / `MarketReferenceLink` / `OracleAnswer` (by `oracleQuestionId`). - **Fee-record streams** — `listProtocolFees` / `listBuilderFees` / `listSettlementFees` (the per-fill streams behind `getMarketFees`' running total; support a `payer` filter). - **Builder-approval directory** — `listBuilderApprovals({ user?, builder?, … })` → `BuilderApproval[]`, complementing the on-chain point read `getBuilderApproval`. `ApproveBuilderParams` is now exported. - **Markets-by-creator** — `BinaryMarketFilter.creator` (applied across `listBinaryMarkets` / `listLiveBinaryMarkets` / `listPastBinaryMarkets` / `countBinaryMarkets`). - **Vault credits** — `getVaultPayoutFallbacks(owner, opts?)` (append-only credit history), `client.getVaultBalance(vault, owner, token)` (live claimable, `ERC20Vault.getWithdrawableBalance`), and `trader.withdrawVault({ vault, token, amount })` (`ERC20Vault.withdraw`). - **Perp margin health + liquidation price** — `getMarginAccount` now also returns `imReq`/`mmReq`/`cmReq`/`marginStatus` (from `MarginBank.getAccountHealth` / `getMarginStatus`); new `client.getAccountHealth(marginBank, account)` and `client.getLiquidationPrice(marginBank, pool, account)`. `MarginStatus` / `MARGIN_STATUS` / `AccountHealth` are exported. The unified `fetchPositions` now populates `UnifiedPosition.liquidationPrice`. - **Perp/funding history reads** — `getFundingPayments`, `getMarginEvents`, `getLiquidations`, `getFundingRateHistory`, `getOpenInterestHistory` (the indexer's perp-account + funding/OI history). - **Lookups + pagination totals** — `getMarketByPool(pool)` (resolve a market by pool address), and `countOrders(owner, opts?)` / `countUserFills(account, opts?)` (history-page totals via the `_aggregate` fallback helper, now extended to `Order`/`Fill`). - **Unified balances** — `fetchBalance` now includes binary YES/NO ERC-6909 holdings (keyed by tradable symbol). - **Pure helpers + constants** — `CANDLE_INTERVALS` (in lockstep with `indexer/src/intervals.ts`), and `computeBinaryPnl(fills, balances, market)` / `binaryFillsFor(account, fills)` (avg-cost basis, no indexer/chain dependency). - **React hooks** (`@somnia-chain/markets-sdk/react`) — a generic `useIndexerQuery(fn, deps)` plus `usePortfolio`, `useMarkets`, `useCandles`, `useMarketFees`, `useOperators`, and the live-store `useLiveMarkets`. ## 0.11.1 (2026-07-16) ### Fixed - Count helpers (`countMarkets`/`countBinaryMarkets`/`countOperators`/`countVenues`) now fall back to a bounded row count when Hasura `_aggregate` is not exposed to the requesting role (public role, no admin-secret header) instead of throwing `field 'X_aggregate' not found`. The fast aggregate path still runs when the privileged header is present. Real query/network errors still surface. ## 0.11.0 (2026-07-16) Operator/venue + fee refactor follow-up — resync the SDK to the post-refactor contracts, deployment hub, and indexer schema. ### Added - **Per-venue collateral.** `SomniaMarketsAddresses` gains `collateral` (the per-venue collateral ERC-20). `testUsdc` remains as a legacy/fallback alias. `getSystemInfo`, `trader.faucet`, and collateral reads now resolve `collateral ?? testUsdc`, so hub-fed testnet clients (where the protocol `addresses.json` no longer carries TestUSDC) work correctly. - **Settlement-fee backing.** The live tail now consumes `SettlementFeeCharged(address indexed feeRecipient, uint256 winningBacking, uint256 fee)` and debits the market's `backing` running total, so the tail tracks on-chain `setBacking` after settlement instead of overstating it by the fee. - **Module-created market discovery.** `watchAllMarkets({ discover: true })` now also discovers markets created via `BinaryMarketsModule.createMarket` (the 19-field module `MarketCreated`), not just the `MarketCreator` rolling series. ### Fixed - `getSystemInfo.binaryMarketImpl` no longer falls back to the (different) `binaryPoolImpl` address when the live factory read fails. ### Notes - Addresses are provided entirely by `@somnia-chain/deployments` (the single source of truth). `SomniaMarketsAddresses` now carries `marketsCore` and `collateralRouter` first-class, so consumers feed the hub map straight into `new SomniaMarkets({ addresses })` with no hand-mapping. --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/tutorials - [Stream a live order book](./stream-a-live-order-book) - [Place and cancel your first order](./place-and-cancel-your-first-order) --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/tutorials/stream-a-live-order-book In this tutorial we install the SDK in an empty project, load the markets on the Somnia testnet, and read an order book and the latest trades. Then we follow the top of the book live for thirty seconds. At the end you will have a running script that prints a line each time the best bid or ask on `SOMI/USDso` changes. No wallet and no funds are needed. Everything in this tutorial reads public data. ## Before you start You need: - Node.js 22 or newer. Check with `node --version`. - npm, which ships with Node.js. - An empty directory to work in. - A network connection to the Somnia testnet endpoints. We run TypeScript directly with `tsx`, so there is no build step. ## 1. Create the project In your empty directory, run these three commands: ```sh npm init -y npm pkg set type=module npm install @somnia-chain/markets-sdk@0.29.0 viem@2.55.10 tsx@4.23.1 ``` The first creates `package.json`. The second lets Node.js load our files as ES modules, which the SDK requires. The third installs the versions used to verify this tutorial: SDK 0.29.0, viem 2.55.10, and tsx 4.23.1. `tsx` runs TypeScript files directly but does not check types, so no `tsconfig.json` is needed. Your editor may underline `process.env` in the next tutorial because Node's type definitions are not installed; the scripts still run. You should see npm report the added packages, and a `node_modules` directory should exist. ## 2. Create the exchange and load the markets Create a file named `watch.ts` with this content: ```ts import { SomniaMarkets, SOMNIA_TESTNET_ADDRESSES } from "@somnia-chain/markets-sdk"; import { somniaTestnet } from "viem/chains"; const exchange = new SomniaMarkets({ indexerUrl: "https://dev.smk.somnia.host/v1/graphql", chain: somniaTestnet, wsRpcUrl: "wss://api.infra.testnet.somnia.network/ws", addresses: SOMNIA_TESTNET_ADDRESSES, }); const markets = await exchange.loadMarkets(); const spot = Object.values(markets).filter((m) => m.type === "spot"); console.log( `${exchange.symbols.length} symbols, ${spot.length} spot markets:`, spot.map((m) => m.symbol), ); await exchange.close(); ``` `SomniaMarkets` is the SDK's single entry point. The three fields point it at the testnet: the indexer URL for history, the chain definition, and the deployed contract addresses. `loadMarkets` fetches every market and builds the symbol table. Run it: ```sh npx tsx watch.ts ``` You should see one line naming the spot markets. The counts change as markets are created and expire; the three spot symbols are stable: ```text 597 symbols, 3 spot markets: [ 'WBTC/USDso', 'SOMI/USDso', 'WETH/USDso' ] ``` Notice that most symbols are binary markets, short-lived questions about BTC and ETH prices. We work with the spot market `SOMI/USDso`. SOMI is the market's name for the chain's native token, which the testnet calls STT. USDso is a test stablecoin. ## 3. Read the order book and the last trades Replace the `await exchange.close();` line with this, so the new code runs before the exchange closes: ```ts const book = await exchange.fetchOrderBook("SOMI/USDso", 3); console.log("bids", book.bids); console.log("asks", book.asks); const trades = await exchange.fetchTrades("SOMI/USDso", undefined, 3); for (const t of trades) console.log(t.datetime, t.side, t.amount, "@", t.price); await exchange.close(); ``` The middle argument of `fetchTrades` is a `since` timestamp we do not need. Run the file again. You should see three levels per side and three trades: ```text 597 symbols, 3 spot markets: [ 'WBTC/USDso', 'SOMI/USDso', 'WETH/USDso' ] bids [ [ 0.1153, 445 ], [ 0.1152, 1222 ], [ 0.1151, 752 ] ] asks [ [ 0.1154, 530 ], [ 0.1155, 1378 ], [ 0.1156, 1810.46 ] ] 2026-09-03T21:40:52.000Z sell 25 @ 0.1153 2026-09-03T21:40:43.000Z sell 25 @ 0.1153 2026-09-03T21:40:33.000Z sell 25 @ 0.1153 ``` Notice the shapes. A book level is a `[price, amount]` pair in plain numbers: 445 SOMI bid at 0.1153 USDso each. Trades come newest first with an ISO timestamp. `fetchOrderBook` read the pool contract on the chain; `fetchTrades` read the indexer. Both are one-shot reads. Notice also the timestamps: on the testnet a market-making bot trades this market every few seconds. That activity is what we watch next. ## 4. Follow the top of the book live Replace the `await exchange.close();` line again, this time with a loop: ```ts // Follow the book for 30 seconds. Print a line whenever the best bid or ask changes. const stop = new Promise((resolve) => setTimeout(() => resolve(null), 30_000)); let last = ""; while (true) { const live = await Promise.race([exchange.watchOrderBook("SOMI/USDso", 1), stop]); if (!live) break; const top = `bid ${live.bids[0]?.[0]} × ${live.bids[0]?.[1]} / ask ${live.asks[0]?.[0]} × ${live.asks[0]?.[1]}`; if (top !== last) console.log(new Date().toISOString(), top); last = top; } await exchange.close(); console.log("done"); ``` `watchOrderBook` is different from `fetchOrderBook`. The first call opens a live watch on the market, and every later call resolves the next time the book changes, with no request to any server. [Read tiers](../reference/read-tiers) describes how the watch works. The `Promise.race` against a 30-second timer is only there to end the tutorial. `close()` releases the watch and the WebSocket to the node, so the script prints `done` and exits. Run the file one more time. The first live line prints at once. After that, a line appears only when the best level changes; the testnet bot usually moves it every few seconds, so expect several lines in thirty seconds. On a quiet market you see one live line and then `done`. This run looked like this: ```text 597 symbols, 3 spot markets: [ 'WBTC/USDso', 'SOMI/USDso', 'WETH/USDso' ] bids [ [ 0.1153, 445 ], [ 0.1152, 1222 ], [ 0.1151, 752 ] ] asks [ [ 0.1154, 530 ], [ 0.1155, 1378 ], [ 0.1156, 1810.46 ] ] 2026-09-03T21:40:52.000Z sell 25 @ 0.1153 2026-09-03T21:40:43.000Z sell 25 @ 0.1153 2026-09-03T21:40:33.000Z sell 25 @ 0.1153 2026-09-03T21:41:03.545Z bid 0.1153 × 445 / ask 0.1154 × 1165.4 2026-09-03T21:41:03.974Z bid 0.1153 × 420 / ask 0.1154 × 1165.4 2026-09-03T21:41:10.083Z bid 0.1153 × 420 / ask 0.1154 × 635.4 2026-09-03T21:41:10.084Z bid 0.1152 × 470.5 / ask 0.1154 × 635.4 2026-09-03T21:41:13.866Z bid 0.1152 × 1010.1 / ask 0.1154 × 1164.9 2026-09-03T21:41:22.768Z bid 0.1152 × 985.1 / ask 0.1154 × 1164.9 2026-09-03T21:41:30.078Z bid 0.1152 × 470.5 / ask 0.1153 × 529.5 done ``` Notice that in this run the bid size dropped by 25 a few seconds in: a trade of the same size as the ones from step 3, seen from the chain event rather than from the indexer. Two lines can carry almost the same timestamp when one block moves more than one level. Notice too that the book copy changes more often than its top. The `if (top !== last)` check keeps the output to what changed at the best level. ## What you can do now You can create an exchange for the Somnia testnet, load its markets, and read any spot market's book and trades on demand. You can open a live watch and react each time the book changes, without a polling loop. The same `fetchOrderBook`, `fetchTrades`, and `watchOrderBook` calls work on every symbol `loadMarkets` returned, including the binary markets. Next, [Place and cancel your first order](./place-and-cancel-your-first-order) adds a signer to this project and puts an order on this book. To see which reads are live, on-chain, or indexed, read [Read tiers](../reference/read-tiers). --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/tutorials/place-and-cancel-your-first-order In this tutorial we place a real limit order on the Somnia testnet, watch it appear in our open orders, and cancel it. At the end you will have sent two transactions through the SDK and seen how a resting order shows up in the live data. The order tries to sell 1 SOMI at four times the current price. We submit it as post-only, so the pool either rests it on the book or rejects it; nothing is bought or sold. Your balance still changes temporarily while a resting order reserves its funding requirement, and permanently by the testnet gas you spend. ## Before you start You need: - The project from [Stream a live order book](./stream-a-live-order-book). We add one file to it. - A private key, including its `0x` prefix, for an account on the Somnia testnet that holds at least 2 STT. The testnet is called Shannon; it is the `somniaTestnet` chain we already import from viem. Create a fresh key for this tutorial and never use a key that holds real funds. [Get testnet funds](../how-to/get-testnet-funds) lists the faucets. Why 2 STT: the pool's required reserve includes the 1 SOMI quantity and any configured fee headroom. A native sell sends only the part not already credited to your pool vault. The transaction also needs room under the SDK's gas ceiling; unused gas is not charged. Put the key in your shell for this session. Replace the value with your own key. ```sh export SOMNIA_PRIVATE_KEY=0x… ``` We run the file twice in this tutorial: once after step 1, to check the key, and once after step 5, to place and cancel the order. Steps 2 to 4 only add code. ## 1. Create the exchange with a signer Create `trade.ts` next to `watch.ts`: ```ts import { isHex } from "viem"; import { somniaTestnet } from "viem/chains"; import { SomniaMarkets, SOMNIA_TESTNET_ADDRESSES } from "@somnia-chain/markets-sdk"; const privateKey = process.env.SOMNIA_PRIVATE_KEY; if (!privateKey || !isHex(privateKey, { strict: true }) || privateKey.length !== 66) { throw new Error("set SOMNIA_PRIVATE_KEY to a 32-byte 0x-prefixed key"); } const exchange = new SomniaMarkets({ indexerUrl: "https://dev.smk.somnia.host/v1/graphql", chain: somniaTestnet, wsRpcUrl: "wss://api.infra.testnet.somnia.network/ws", addresses: SOMNIA_TESTNET_ADDRESSES, privateKey, }); await exchange.loadMarkets(); console.log("trading as", exchange.walletAddress); const before = await exchange.fetchBalance(); console.log("STT before:", before.STT?.total); await exchange.close(); ``` The one new field is `privateKey`. With it, the SDK signs transactions locally and every authenticated method knows whose data to return. The closing `close()` is the same ending as `watch.ts`. Run it: ```sh npx tsx trade.ts ``` You should see your own address and your STT balance: ```text trading as 0x0a9753f040E0cC077eB514c207F8ba2c14230b42 STT before: 19997.22114964 ``` Notice that `fetchBalance` took no address. It answers for the signer. If the balance is below 2, stop here and fund the account before you continue. ## 2. Open the live watch Replace the closing `close()` line of `trade.ts` with this: ```ts const book = await exchange.watchOrderBook("SOMI/USDso", 1); const bestAsk = book.asks[0]?.[0]; if (bestAsk === undefined) throw new Error("the ask side is empty right now; run again in a moment"); console.log("best ask:", bestAsk); ``` We open the live watch before placing the order. From now on the SDK keeps a local copy of this market current from the chain's events, so our own order will appear in that copy in the block it lands. No need to run yet; step 3 uses `bestAsk`. ## 3. Place the order Append: ```ts const order = await exchange.createOrder("SOMI/USDso", "limit", "sell", 1, bestAsk * 4, { postOnly: true }); console.log("placed:", order.id, order.status, `${order.amount} @ ${order.price}`); ``` The first five arguments are the symbol, the order type, the side, the amount in SOMI, and the price in USDso per SOMI. `{ postOnly: true }` guarantees the order cannot take liquidity. Four times the best ask should rest far above the market, but the book can move before the transaction lands. If that makes the order cross, `createOrder` throws a `ContractRevertError` named `PostOnlyWouldCross`; run the script again so step 2 reads a fresh book. `createOrder` resolves only after the transaction is mined; there is nothing to wait for afterwards. ## 4. See the order in your open orders Append: ```ts let orders = await exchange.watchOrders("SOMI/USDso"); while (!orders.some((o) => o.id === order.id)) orders = await exchange.watchOrders("SOMI/USDso"); console.log("in my open orders:", orders.find((o) => o.id === order.id)?.status); const during = await exchange.fetchBalance(); console.log("STT while resting:", during.STT?.total); ``` `watchOrders` returns your orders on this market from the local copy the watch keeps current, with no request to a server. Its first call returns what the copy holds now, and each later call resolves when the copy changes. The `while` loop covers the moment between the transaction being mined and its event reaching the copy, which is usually zero calls. ## 5. Cancel the order Append: ```ts const cancel = await exchange.cancelOrder(order.id, "SOMI/USDso"); console.log("cancel:", cancel.status); const after = await exchange.watchOrders("SOMI/USDso"); console.log("after cancel:", after.find((o) => o.id === order.id)?.status); const final = await exchange.fetchBalance(); console.log("STT after:", final.STT?.total); await exchange.close(); ``` Run the file. It takes a few seconds longer than the first run, because two transactions are mined. The output should look like this, with your own address, order id, price, and balances: ```text trading as 0x0a9753f040E0cC077eB514c207F8ba2c14230b42 STT before: 19997.… best ask: 0.1147 placed: 793209995169529057822 open 1 @ 0.4588 in my open orders: open STT while resting: 19996.… cancel: canceled after cancel: canceled STT after: 19997.… ``` Notice five things. - `status` is `open` after placement: the order rests on the book. The `id` is the pool's order id, a long decimal string. - The price prints as `0.4588`, four times `0.1147` rounded onto the market's allowed price steps, in the direction that favours you. - `STT while resting` is below `STT before`. The pool reserves enough for the 1 SOMI sale and its configured fee headroom, less any native balance you already had in the pool vault, and gas was paid. SOMI is the market's name for the chain's native token, which the testnet calls STT. - The second `watchOrders` call returned the same order with status `canceled`. The local copy saw the cancel event and updated. - `STT after` is `STT before` minus the gas for two transactions. The unused reserve came back; the exact difference depends on current gas fees and the pool's fee settings. `close()` releases everything the instance opened, including the WebSocket to the node, so the script ends on its own. ## If a run stops halfway A run that fails after `placed:` leaves that order resting with 1 STT locked. Cancel it by id with a one-line script, replacing the id with the one the run printed: ```ts await exchange.cancelOrder("793209995169529057822", "SOMI/USDso"); ``` Use the same construction lines as `trade.ts` above it, and the same two closing lines below it. Cancelling an order that is already gone throws a `ContractRevertError` named `IncorrectSender`; that means there is nothing left to cancel. ## What you can do now You can place a limit order on any spot market the SDK lists, read it back from the live data, and cancel it. The same `createOrder` call with `"buy"` and a price above the best ask fills immediately and returns `status: "closed"` with the fills in `order.info`. To run this as a bot, continue with [Run a quoting loop](../how-to/run-a-quoting-loop). To learn when a resting order fills, read [Detect when an order fills](../how-to/detect-fills). To see what the SDK did during each step, add a debug sink as shown in [Debug what the SDK is doing](../how-to/debug-the-sdk). --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/how-to - [Configure for a network](./configure-for-a-network) - [Get testnet funds](./get-testnet-funds) - [Run a quoting loop](./run-a-quoting-loop) - [Detect when an order fills](./detect-fills) - [Follow an account's open orders](./follow-an-account) - [Sign with a browser wallet](./sign-with-a-browser-wallet) - [Trade through a smart account](./trade-through-a-smart-account) - [Use the React hooks](./use-the-react-hooks) - [Handle errors and reverts](./handle-errors) - [Debug the SDK](./debug-the-sdk) --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/how-to/configure-for-a-network This guide shows you how to build a `SomniaMarkets` instance for each network the SDK supports. It assumes you can install an npm package and write a TypeScript module. The field-by-field reference is [Configuration](../reference/configuration); the values are in [Networks and endpoints](../reference/networks-and-endpoints). ## Shannon testnet Use the testnet for development. Markets exist, bots trade on them, and funds are free. ```ts import { somniaTestnet } from "viem/chains"; import { SomniaMarkets, SOMNIA_TESTNET_ADDRESSES } from "@somnia-chain/markets-sdk"; const exchange = new SomniaMarkets({ indexerUrl: "https://dev.smk.somnia.host/v1/graphql", chain: somniaTestnet, wsRpcUrl: "wss://api.infra.testnet.somnia.network/ws", addresses: SOMNIA_TESTNET_ADDRESSES, }); ``` The viem chain definition does not carry a WebSocket URL. Set `wsRpcUrl` to the Somnia endpoint that the SDK must use for chain reads, writes, and watches. ## Mainnet Swap the three network-specific values. ```ts import { somnia } from "viem/chains"; import { SomniaMarkets, SOMNIA_MAINNET_ADDRESSES } from "@somnia-chain/markets-sdk"; const exchange = new SomniaMarkets({ indexerUrl: "https://prd.smk.somnia.host/v1/graphql", chain: somnia, wsRpcUrl: "wss://api.infra.mainnet.somnia.network/ws", addresses: SOMNIA_MAINNET_ADDRESSES, }); ``` ## Local anvil stack Start the stack from the repository root with `./demo-clob.sh up`. It deploys the contracts to anvil on chain id 31337, runs the indexer, and writes the address manifest. ```ts import { selectDeployment } from "@somnia-chain/deployments/local"; import { SomniaMarkets } from "@somnia-chain/markets-sdk"; import { defineChain } from "viem"; const somniaLocal = defineChain({ id: 31337, name: "Somnia Local", nativeCurrency: { name: "Somnia Test Token", symbol: "STT", decimals: 18 }, rpcUrls: { default: { http: ["http://127.0.0.1:8545"], webSocket: ["ws://127.0.0.1:8545"] } }, }); const deployment = selectDeployment("my-bot", "local"); // reads the anvil manifest const exchange = new SomniaMarkets({ indexerUrl: "http://localhost:8085/v1/graphql", chain: somniaLocal, wsRpcUrl: "ws://127.0.0.1:8545", addresses: deployment.addresses, }); ``` `@somnia-chain/deployments` is the workspace package that reads the manifests; it is available to packages inside the repository. Outside the repository, copy the addresses from `smart-contracts/deployments/31337/local/addresses.json` into an object instead. A restarted anvil is a new chain under the same id: redeploy and reindex, or the indexer rows point at contracts that no longer exist. ## Add a signer Pass one of three signer fields to unlock writes and the authenticated reads. Keep the key out of source control; read it from the environment. Validate the environment value before construction. The `privateKey` variable below must be a 32-byte, `0x`-prefixed hex string. The tutorial [Place and cancel your first order](../tutorials/place-and-cancel-your-first-order#create-the-exchange-with-a-signer) shows the guard. ```ts const exchange = new SomniaMarkets({ ...config, privateKey, }); ``` For a browser wallet, see [Sign with a browser wallet](./sign-with-a-browser-wallet). ## Add the price feed The price methods need a separate endpoint. On testnet: ```ts import { SOMNIA_TESTNET_PRICE_FEED } from "@somnia-chain/markets-sdk"; const exchange = new SomniaMarkets({ ...config, priceFeed: SOMNIA_TESTNET_PRICE_FEED }); ``` ## Run indexer-only No extra field is needed. The WebSocket opens on the first chain read, write, or watch, so an instance that only calls `listMarkets`, `getCandles`, or `getPortfolio` never opens one. This suits server-side rendering. The examples above set `wsRpcUrl` explicitly. The first chain touch throws `NotConfiguredError` when neither this field nor `chain.rpcUrls.default.webSocket[0]` is present. ## Cancel indexer reads from a request scope Pass an `AbortSignal` to stop in-flight indexer reads when the caller goes away, for example on a server per request. ```ts const controller = new AbortController(); const exchange = new SomniaMarkets({ ...config, signal: controller.signal }); // later, when the request is abandoned: controller.abort(); ``` An abort re-throws your own reason. Chain reads are not covered by the signal; they time out after 4 seconds on their own. ## Check the result `await exchange.fetchOrderBook(symbol)` proves the chain transport. `await exchange.fetchBalance()` proves the signer. Each one throws the class named in [Errors](../reference/errors) when its input is wrong. `loadMarkets()` does not prove the indexer URL, because it does not throw when the registry read fails. It contains that failure so an indexer outage still returns the perp markets the chain can enumerate. Check the accessor instead: ```ts await exchange.loadMarkets(); if (exchange.registryReadError) throw exchange.registryReadError; // the indexer URL is wrong or the indexer is down ``` --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/how-to/get-testnet-funds This guide shows you how to fund an account on the Shannon testnet for the three things trading needs: gas, spot and perp collateral, and binary collateral. It assumes you have a private key and know its address. ## Gas: STT Every transaction pays gas in STT. A write also needs the account to hold the SDK's gas ceiling times its fee ceiling on top of the transaction value: 0.6 STT at the defaults (see [Configuration](../reference/configuration#gas)). The chain refunds unused gas. Request STT from one of the faucets listed on the Somnia network page: the official faucet at `https://testnet.somnia.network/`, the Google Cloud faucet for Somnia Shannon, or the Stakely and Thirdweb faucets. Each faucet pays a fixed amount per request; repeat until the balance covers what you need. Some faucets require the requesting address to hold funds on another network first. If none works, ask in the `#dev-chat` channel of the Somnia Discord. Check the balance with a configured exchange: ```ts const balances = await exchange.fetchBalance(); console.log(balances.STT?.total); ``` `fetchBalance` needs a signer in the configuration. Without one, read `exchange.client.getNativeBalance(address)` instead; it returns wei as a `bigint`. ## Spot and perp collateral: USDso `USDso` is the quote token of the spot pairs and the settlement token of the perps. On Shannon it has a public `mint(address, uint256)` with 18 decimals. Mint it with viem: ```ts import { createWalletClient, http, parseUnits } from "viem"; import { privateKeyToAccount } from "viem/accounts"; const account = privateKeyToAccount(privateKey); const wallet = createWalletClient({ account, chain: exchange.client.config.chain, transport: http() }); const hash = await wallet.writeContract({ address: "0x9c32f3827a1a99f0cf9b213de8b53ec3d57bb171", // USDso on Shannon abi: [ { type: "function", name: "mint", stateMutability: "nonpayable", inputs: [ { name: "to", type: "address" }, { name: "amount", type: "uint256" }, ], outputs: [], }, ], functionName: "mint", args: [account.address, parseUnits("1000", 18)], gas: 500_000n, }); await exchange.client.getViemClient().waitForTransactionReceipt({ hash }); ``` The SDK does not wrap this call. USDso is a test token; the mainnet collateral has no mint. To sell SOMI on `SOMI/USDso`, no USDso is needed: the base of that pair is the native token, and a sell escrows STT. ## Binary collateral: tUSDC The binary markets on Shannon settle in `tUSDC`, a 6-decimal test token with a faucet. The SDK wraps it: ```ts await exchange.trader.faucet(); // mints 10,000 tUSDC to the signer ``` `faucet()` needs `addresses.collateral` (present in `SOMNIA_TESTNET_ADDRESSES`) and a signer. Pass `{ amount }` in raw units to mint less. The contract caps the amount per call; a larger request reverts with `FaucetCapExceeded`. ## Verify ```ts console.log(await exchange.fetchBalance()); ``` The result keys balances by currency code: `STT`, `USDso`, `tUSDC`, and one key per binary tradable you hold shares of, for example `BTC-0-04SEP26/tUSDC#YES`. ## Perps Perp orders draw margin from the MarginBank, not from the wallet. After minting USDso, deposit it: ```ts await exchange.depositMargin("BTC/USDso:USDso", 100); ``` See [Perps](../perps) for margin, leverage, and funding. --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/how-to/run-a-quoting-loop This guide shows you how to keep a two-sided quote on a spot market from a Node bot: read the live book, place a bid and an ask, re-quote when the market moves, and cancel everything on exit. It assumes a configured `SomniaMarkets` instance with a `privateKey` and funds on the market (see [Get testnet funds](./get-testnet-funds)). The example quotes `SOMI/USDso` on the Shannon testnet. ## Open the watch before you quote `watchOrderBook` opens the market watch on its first call and returns the current book. Every later call resolves when the book changes. Open it once before the loop so your own orders show up in `watchOrders` the moment they land. ```ts const symbol = "SOMI/USDso"; await exchange.loadMarkets(); let book = await exchange.watchOrderBook(symbol, 1); ``` ## Quote around the mid Use the exchange verbs for a simple loop. `createOrder` aligns price and amount to the market's tick and lot grids and echoes the aligned values, so compute freely and read the result. ```ts import { ContractRevertError } from "@somnia-chain/markets-sdk"; const size = 5; // SOMI per side const spread = 0.002; // 0.2 % each side let bidId: string | undefined; let askId: string | undefined; let running = true; function isPostOnlyWouldCross(error: unknown) { return error instanceof ContractRevertError && error.errorName === "PostOnlyWouldCross"; } async function cancelIfResting(orderId: string) { try { await exchange.cancelOrder(orderId, symbol); } catch (error) { if (error instanceof ContractRevertError && error.errorName === "IncorrectSender") return; throw error; } } async function requote(book: Awaited>) { const bestBid = book.bids[0]?.[0]; const bestAsk = book.asks[0]?.[0]; if (bestBid === undefined || bestAsk === undefined) return; // one-sided book: skip this tick const mid = (bestBid + bestAsk) / 2; if (bidId) { const previousBidId = bidId; await cancelIfResting(previousBidId); if (bidId === previousBidId) bidId = undefined; } if (askId) { const previousAskId = askId; await cancelIfResting(previousAskId); if (askId === previousAskId) askId = undefined; } if (!running) return; let bid: Awaited>; try { bid = await exchange.createOrder(symbol, "limit", "buy", size, mid * (1 - spread), { postOnly: true }); } catch (error) { if (isPostOnlyWouldCross(error)) return; throw error; } bidId = bid.status === "open" ? bid.id : undefined; if (!running) return; try { const ask = await exchange.createOrder(symbol, "limit", "sell", size, mid * (1 + spread), { postOnly: true }); askId = ask.status === "open" ? ask.id : undefined; } catch (error) { const failures: unknown[] = [error]; const placedBidId = bidId; if (placedBidId) { try { await cancelIfResting(placedBidId); if (bidId === placedBidId) bidId = undefined; } catch (cancelError) { failures.push(cancelError); } } if (failures.length > 1) throw new AggregateError(failures, "ask placement and bid cleanup failed"); if (isPostOnlyWouldCross(error)) return; throw error; } } ``` `postOnly: true` makes the pool reject a quote that would cross instead of taking liquidity; the rejection arrives as `ContractRevertError` with `errorName` `PostOnlyWouldCross`. This loop waits for the next book change after that outcome. If the ask fails after the bid rests, it cancels the bid before it continues. If that cancellation also fails, the loop preserves both errors and the bid id for final cleanup. See [Handle errors and reverts](./handle-errors). ## Re-quote on change Each `await exchange.watchOrderBook(symbol, 1)` resolves on the next change of the book. On a busy market that is every block, so compare the top of book and skip unchanged ticks. ```ts let requestStop: (() => void) | undefined; const stop = new Promise((resolve) => { requestStop = () => resolve(null); }); process.once("SIGINT", () => { running = false; requestStop?.(); }); async function cleanupQuotes() { const failures: unknown[] = []; if (bidId) { const orderId = bidId; try { await cancelIfResting(orderId); if (bidId === orderId) bidId = undefined; } catch (error) { failures.push(error); } } if (askId) { const orderId = askId; try { await cancelIfResting(orderId); if (askId === orderId) askId = undefined; } catch (error) { failures.push(error); } } try { await exchange.close(); } catch (error) { failures.push(error); } if (failures.length > 0) throw new AggregateError(failures, "quote cleanup failed"); } let lastTop = `${book.bids[0]?.[0]}/${book.asks[0]?.[0]}`; const failures: unknown[] = []; try { if (running) await requote(book); while (running) { const nextBook = await Promise.race([exchange.watchOrderBook(symbol, 1), stop]); if (nextBook === null || !running) break; book = nextBook; const top = `${book.bids[0]?.[0]}/${book.asks[0]?.[0]}`; if (top === lastTop) continue; lastTop = top; await requote(book); } } catch (error) { failures.push(error); } try { await cleanupQuotes(); } catch (error) { failures.push(error); } if (failures.length > 0) throw new AggregateError(failures, "quoting loop stopped with errors"); ``` Each `createOrder` and `cancelOrder` awaits its receipt. A full re-quote sends two cancellation transactions and two placement transactions. The SDK can also perform prerequisite reads: in particular, every native-base sell reads the pool's current funding requirement and vault shortfall before it sends. ## Re-quote in one transaction For lower latency, move to the engine and use the batch writes. `amendOrders` re-prices several orders in one transaction. It works on spot and perp pools, not on binary pools. ```ts import { ORDER_TYPE, fromHuman, isSpotMarket } from "@somnia-chain/markets-sdk"; const t = exchange.market(symbol); if (!isSpotMarket(t.market)) throw new Error("spot only"); const { quoteDecimals, baseDecimals } = t.market; if (!bidId || !askId) throw new Error("place both quotes before amending them"); const bestBid = book.bids[0]?.[0]; const bestAsk = book.asks[0]?.[0]; if (bestBid === undefined || bestAsk === undefined) throw new Error("two-sided book required"); const mid = (bestBid + bestAsk) / 2; const newBid = exchange.priceToPrecision(symbol, mid * (1 - spread)); const newAsk = exchange.priceToPrecision(symbol, mid * (1 + spread)); const newSize = exchange.amountToPrecision(symbol, size); const result = await exchange.trader.amendOrders({ pool: t.pool, amendments: [ { oldOrderId: bidId, newOrder: { isBid: true, price: fromHuman(newBid, quoteDecimals), quantity: fromHuman(newSize, baseDecimals), orderType: ORDER_TYPE.POST_ONLY, }, }, { oldOrderId: askId, newOrder: { isBid: false, price: fromHuman(newAsk, quoteDecimals), quantity: fromHuman(newSize, baseDecimals), orderType: ORDER_TYPE.POST_ONLY, }, }, ], }); // 0n: the pool rejected that amendment and the order is off the book. [bidId, askId] = result.newOrderIds.map((id) => (id === 0n ? undefined : String(id))); ``` Prices and quantities are raw `bigint` here; `fromHuman` scales the aligned values by the market's decimals. `ORDER_TYPE.POST_ONLY` preserves the quoting behavior of `createOrder(..., { postOnly: true })`. An unaligned value reverts with `PriceNotAlignedToTickSize` or `QuantityNotAlignedToLotSize`. What a rejected amendment does depends on the pool version. An all-or-nothing pool reverts the whole batch, with `AmendReplacementRejected` or, when the old order is already gone, `AmendOldOrderGone`. Other pools apply the remaining amendments and return `0n` for the rejected one, which then holds no order, so that side has no quote until you place one again. Pass `alwaysPlace: true` on an amendment to place the new order even when its old one is gone. To place a ladder, use `placeSpotOrders`; to pull one, use `cancelOrders`. To pull every quote you hold on a pool without tracking the ids, use `cancelAllOrders(pool)`. It reads the resting ids and cancels them in one call, which is what a restarted bot needs. Pass `{ source: "live" }` to take the ids from the live tail instead, so the cancel is the only round trip; that needs an active watch on the pool. An expired order that still rests is invisible to the chain source — clear that residue with `cancelExpiredOrders`. The live source does see it, forwards its id, and the cancel lands: the pool gates a best-effort cancel on owner and id, not on expiry, so the order is removed and reported `cancelled: true`. Batches are not payable, so on a market whose base is the native token a batched sell needs a vault balance deposited beforehand with `trader.depositVaultNative`. [Spot markets](../spot) covers the batch parameters and the vault. ## Set an expiry so a crashed bot's quotes die On the engine every order takes `expireTimestampNs`. Set it a few minutes ahead so a crashed bot's quotes die on their own. Expiry is lazy: an expired order stays on the book until a cancel or a fill attempt touches it, and a fill attempt reverts with `ExpiredOrderMustBeCancelled`. When unset, a spot or perp order expires in about 50 years and a binary order at its market's expiry. ```ts const inFiveMinutes = BigInt(Date.now() + 5 * 60_000) * 1_000_000n; ``` ## Stop cleanly The handler is installed before the first quote. The loop quotes once from the book already returned by the initial watch, then waits for later changes. The handler resolves a pending book read and stops new placements. If a placement is already in flight, the loop waits for its receipt and records its order id before cleanup starts. Cleanup preserves each id until its cancellation succeeds. It attempts both sides even if one cancellation fails. It closes the exchange and then reports every loop or cleanup failure. `cancelIfResting` contains only `IncorrectSender`, which means the known order is no longer cancellable. ## Know what filled Track fills with `watchMyTrades` or `watchOrders` in a second loop. See [Detect when an order fills](./detect-fills). --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/how-to/detect-fills This guide shows you how to learn that a resting order was filled, partially filled, or cancelled, with the least latency and without polling. It assumes a configured `SomniaMarkets` instance with a signer, and a market watch opened before the order was placed. ## Read the placement result first A `createOrder` that crosses the book fills in the same transaction. Its result already says so. ```ts const order = await exchange.createOrder("SOMI/USDso", "limit", "buy", 10, 0.116); if (order.status === "closed") { // fully filled in the placement transaction } else if (order.status === "open") { // resting: order.filled may still be > 0 for a partial fill } ``` `status` is `"closed"` when `remaining` is zero, `"open"` when a remainder rests, and `"canceled"` when an immediate-or-cancel or market order left an unfillable remainder. The raw fills are in `order.info.fills`. ## Wait for the status to flip `watchOrders(symbol)` returns your orders on the market from the live store. The first call returns the current list; each later call resolves when the list changes. Loop until your order is no longer open. ```ts let mine = order; while (mine.status === "open") { const orders = await exchange.watchOrders("SOMI/USDso"); mine = orders.find((o) => o.id === order.id) ?? mine; } console.log(mine.status, "filled", mine.filled, "of", mine.amount); ``` The list changes on every order event for your account on that market, including partial fills, so the loop also observes `filled` growing while `status` stays `"open"`. Open the market watch before placing. `watchOrderBook`, `watchTrades`, or `watchOrders` on the symbol all open it. The watch learns about an order placed after it opened from the chain event, in the block the order lands. It learns about an order placed before it opened from the indexer snapshot, which lags the chain by the indexing delay, typically a few seconds. ## Watch your trades `watchMyTrades(symbol)` returns your fills on the market, newest first, and resolves on each new one. Use it when you care about executions rather than order state, for example to update inventory. ```ts let seen = new Set(); while (true) { const trades = await exchange.watchMyTrades("SOMI/USDso", 20); for (const t of trades) { if (seen.has(t.id)) continue; seen.add(t.id); console.log(t.datetime, t.side, t.amount, "@", t.price, "cost", t.cost); } } ``` On spot and perp markets `side` is absent: the pools do not attribute a fill to a side in a way the SDK can map to you. Read `t.info.takerIsBid` and compare `t.info.maker` with your address instead. On binary markets `side` is your own side when the fill carries one. ## Use the engine for raw values `exchange.client.getLiveUserOrders(pool, owner)` and `getLiveUserFills(pool, owner)` are the synchronous views behind the two verbs. They return `LiveOrder` and `LiveFill` rows with exact raw values as decimal strings; wrap a value in `BigInt()` to compute with it. The API reference lists the fields. Subscribe to changes with `client.subscribeLive(listener)`. In React, `useLiveUserOrders` and `useLiveUserFills` re-render on the same changes. See [Use the React hooks](./use-the-react-hooks). ## Read your own writes at chain head When a write threw `RpcError` after sending, or when no watch is open, ask the pool directly. These reads answer from the contract in one round-trip and know only what is open now. ```ts const t = exchange.market("SOMI/USDso"); const owner = exchange.walletAddress; if (!owner) throw new Error("configure a signer before reading your open orders"); const openIds = await exchange.client.getOwnOpenOrdersOnchain(t.pool, owner); const stillOpen = openIds.some((id) => id.toString() === order.id); ``` `getOrderOnchain(pool, orderId)` returns one order's on-chain state. For history, including filled and cancelled orders, use the indexer reads `getOpenOrders`, `getOrders`, and `getOrderFills`; they lag the chain by the indexing delay. ## Read a maker order a fill already removed Decoding `OrderFilled` yourself gives you the fill's `fillPrice`, `quantityFilled` and `makerRemainingQuantity`, but names the maker only by id - and a fill removes the maker order in the same transaction, so at head it is gone. Pin the read one block earlier to recover the side the fill took: ```ts const maker = await exchange.client.getOrderOnchain(pool, makerOrderId, { blockNumber: fillBlock - 1n }); // `null` is an UNRESOLVED side, not a sell: an order placed and filled inside one // block has no state one block earlier, and its side is in that block's OrderPlaced. const takerBought = maker === null ? null : !maker.isBid; ``` What it answers, and what it does not: - The maker's identity - `isBid`, `owner`, `userData`, `expireTimestampNs` - cannot change under a given id, so the pinned read is exact for it, and no fill log carries it: `OrderFilled` names the maker by id alone. `OrderPlaced` carries it, but only a consumer that was already listening when the maker rested has that log, which is the case this read exists for. - **Not** the price. It is immutable too, but a fill executes at the maker's resting price and the book emits exactly that, so `OrderFilled.fillPrice` already is the maker's price. Reading for it is redundant work. - **Both** quantities are the values at the **block boundary**, not at your fill. `quantityRemaining` moves on every fill, and `reduceOrder` decrements `fullQuantity` and `quantityRemaining` together under the same id - so an earlier transaction in the fill's own block makes either value stale for your fill, and no block-level read can see between transactions. Take `quantityFilled` and `makerRemainingQuantity` from the event instead. Two edges answer plausibly rather than failing: - A **partial** fill leaves the maker order in place with a smaller `quantityRemaining`, so reading at the fill's own block succeeds with different numbers instead of returning `null`. - An order placed and filled inside **one** block does not exist at `fillBlock - 1n`. That read is `null`, and the placement is in the same block's `OrderPlaced`. A recent block answers against a full node. An older one needs archive state, so this suits a live tape rather than a backfill. ## Choose by need | Need | Use | Latency | | ----------------------------------------- | ------------------------------ | -------------- | | Did the placement itself fill | `createOrder` result | none | | Did a resting order change | `watchOrders` | next block | | What executed, for inventory | `watchMyTrades` | next block | | Is it still open, after a transport error | `getOwnOpenOrdersOnchain` | one round-trip | | Full history for a report | `getOrders`, `getOrderFills` | indexing delay | | Resume from where you stopped | `getUserFills({ sinceBlock })` | indexing delay | | Keep an account's whole history current | `getUserFillHistory` | indexing delay | | Every fill of a kind in a block range | `getFillsPage` | indexing delay | ## Resume from a block `getUserFills`, `getFills`, `countUserFills` and `getUserFillsPage` take `sinceBlock` and `toBlock`, the source block of the fill. Both bounds include their block. Pass one more than the last block you processed to avoid reading it again. Do not resume with the `since` timestamp: two blocks can carry the same second, so a seconds bound repeats rows or skips them. `getOrderFills(pool, orderId, { limit })` is different: it returns the bounded whole fill history for one known order, accepts only `limit`, and does not take `sinceBlock`. Use `getFills` to resume a pool tape or `getUserFills` to resume an account tape. ```ts const page = await exchange.client.getUserFills(owner, { sinceBlock: BigInt(lastProcessedBlock) + 1n }); ``` ## Walk the whole venue by block range `getUserFills` and `getFills` resume ONE account or ONE pool. A published feed needs every fill of a market kind, across every pool, in a range of blocks. `getFillsPage` answers that, and `getLatestFillBlock` says how far the fill data reaches. ```ts const head = await exchange.client.getLatestFillBlock("SPOT"); if (head === null) return; // nothing traded yet if (head.blockNumber <= BigInt(lastProcessedBlock)) return; // caught up: no new range let cursor: string | undefined; do { const page = await exchange.client.getFillsPage({ marketType: "SPOT", sinceBlock: BigInt(lastProcessedBlock) + 1n, toBlock: head.blockNumber, cursor, }); for (const fill of page.fills) console.log(fill.blockNumber, fill.logIndex, fill.quantity); cursor = page.nextCursor ?? undefined; } while (cursor !== undefined); ``` Both bounds include their block, and rows arrive oldest first by `(blockNumber, logIndex)`. That pair is a total order over the fill table, so `[a, b]` followed by `[b + 1, c]` reads every fill exactly once. Pages hold 500 rows by default. Drain a range until `nextCursor` is null; a null cursor means this read saw no further row IN THE RANGE, not that indexing is complete. Take the upper bound from `getLatestFillBlock` rather than from the chain head. It reads the fill data itself, so it never names a block the range read cannot yet serve. It returns `null` while no fill of that kind is indexed. The cursor binds the chain, the endpoint, the kind and both bounds. A cursor from one range is refused by another, so a range cannot be continued into a different one by accident. All amount fields on `FillRow` stay as raw decimal strings. Do not route a published feed through the human-unit market reads: those convert through `Number()`. ## Bounds on the order reads `getOrders` and `getOpenOrders` take `sinceBlock` as well, but it bounds the block the order was PLACED in. A later fill, reduction or cancellation of an older order does not select that order. An amend places a replacement under a new id, so the replacement is selected when its placement block meets the bound; the older order is not. For an order placed before the bound, a fill read recovers only its executions. It does not report a reduction or cancellation that produced no fill. Keep `watchOrders` active for those changes. After reconnecting, use `getOrder(pool, orderId)` to reconcile the indexed lifecycle of each known order, or use `getOrderOnchain(pool, orderId)` when only current active state is required. Wait for the indexer to reach the block first with `client.waitForIndexedBlock(chainId, block)`; it resolves when the indexer has processed it. --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/how-to/follow-an-account This guide shows you how to show one account's complete open orders and pending stops, and how to keep them current. It also shows how to tell a complete view from an empty or out-of-date one. It uses `client.accountOrders`, which reads the indexer's account view. It assumes a configured `SomniaMarkets` instance with an absolute `indexerUrl`. ## Read the account once `getSnapshot(account)` reads the account at one version. Use it for a single read, for example on a server. ```ts const snapshot = await exchange.client.accountOrders.getSnapshot(account); switch (snapshot.status) { case "ready": console.log(snapshot.data.orders.length, "orders at block", snapshot.data.version.blockNumber); break; case "unavailable": console.log("no account view on this network:", snapshot.reason, snapshot.detail); break; case "tooLarge": console.log("more", snapshot.collection, "than a view holds:", snapshot.capacity); break; } ``` `unavailable` means that the indexer does not serve the view on this network. `tooLarge` means that the account holds more than a view can prove complete. Neither result is an empty account. For both, keep the order reads that you use today. `getSnapshot` throws when a read does not complete. `accountViewFailureReason(error)` gives the error's code, which is the same code that a failed live view carries. ## Follow the account live `subscribe(account, onView)` gives you a new complete view each time that the account changes. It returns the function that stops the view. ```ts const stop = exchange.client.accountOrders.subscribe(account, (view) => { switch (view.status) { case "loading": showLoading(); break; case "ready": case "stale": showOrders(view.data, view.status === "stale"); break; case "failed": showError(view.reason, view.retrying); break; case "unavailable": case "tooLarge": useOtherReads(); break; case "closed": break; } }); // When the screen closes: stop(); ``` The view starts as `loading`. It becomes `ready` when the SDK accepts the first snapshot. Then each change that the indexer publishes for the account gives a new `ready` view. Changes that arrive together give one view. When the live connection breaks, the view becomes `stale` and keeps its last complete data. The SDK then reads a new snapshot. A `failed` view has no data. The SDK tries again, unless `retrying` is `false`. The SDK also watches an `unavailable` or `tooLarge` view, and reads the account again when the result can change. The indexer's changes reach the view a few seconds after their block. The stops do not wait for them: the SDK also follows every stop registry's events over the chain socket, so a TP/SL the account creates, links, unlinks or cancels, or that fires, shows within a block or so. While the view holds such a change and the indexer's version is behind it, `data.sync.chainStopsBlock` names its block. A stop created on chain shows `pending` values until the SDK reads them. The open orders stay the indexer's; for spot orders and perp orders at chain speed, see [Follow the account's spot orders](#follow-the-accounts-spot-orders) and [Follow the account's perp positions](#follow-the-accounts-perp-positions). A view arrives one microtask after the SDK makes it, and never during the `subscribe` call. All listeners of one account share one session. When the last listener stops, the session stays for 30 seconds, so a screen that comes back uses it again. ## Tell complete from current A `ready` view is complete at `data.version`. It contains every open order and pending stop that the account had at that version, and nothing else. It does not tell you how far the indexer is behind the chain. - `data.version.blockNumber` is the block of the newest account change that the view contains. - `data.sync.caughtUp` is `false` when the SDK knows about a newer version that it did not apply yet. - `data.sync.chainStopsBlock` is set when a live view's stops hold changes from the chain that are newer than its version. A snapshot that `getSnapshot` reads has the indexer's stops at its version. When a live view of the account is `ready`, `getSnapshot` returns that view, its chain changes included. - A `stale` view shows the account as it was at `data.version`. Show it as out of date. - The view does not contain orders that expire before `data.expiryCutoffNs`. Expiry uses the clock of this machine. A live view removes an order when the order expires. ## Check what the view covers A missing order is absent only when the view covers the source of that order. `data.coverage` tells you what the view covers. ```ts function coversEventContracts(data: AccountOrdersData): boolean { return data.coverage.kind === "known" && data.coverage.families.includes("BINARY_ORDER"); } ``` `coverage.families` lists the source families that the view covers, for example `"SPOT_ORDER"` and `"PERP_STOP"`. `coverage.exclusions` lists the parts of those families that the producer leaves out. When `coverage.kind` is `"unknownManifest"`, this SDK version does not know the producer's manifest, so the view cannot tell you what it covers. ## Read the venue and limit price of a stop No indexer event carries the venue or the limit price of a stop, so the SDK reads them. In a live view, `venue` and `limitPrice` are `pending` until those reads finish, and a later view carries the values. A value that the SDK cannot read is `unknown` with a reason. It is never zero. When a read fails, a live view reads the value again up to three times, after a backoff. ```ts function limitPriceText(stop: AccountStop): string { switch (stop.limitPrice.status) { case "known": return stop.limitPrice.value.toString(); case "notApplicable": return "market"; case "pending": return "reading"; case "unknown": return `unknown (${stop.limitPrice.reason})`; } } ``` ## Set the WebSocket endpoint The live view uses the indexer's GraphQL WebSocket endpoint. The SDK makes it from an absolute `indexerUrl`: `https://dev.smk.somnia.host/v1/graphql` gives `wss://dev.smk.somnia.host/v1/graphql`. When `indexerUrl` is a relative path, set `indexerWsUrl`. ```ts const browserExchange = new SomniaMarkets({ chain: somniaTestnet, indexerUrl: "/api/indexer/v1/graphql", indexerWsUrl: "wss://dev.smk.somnia.host/v1/graphql", }); ``` Without a WebSocket endpoint, `subscribe` throws `NotConfiguredError`. The live view never sends `indexerHeaders`. ## Follow the account's perp positions `client.accountPositions` gives one account's open perp positions and its margin in one MarginBank. It reads the chain, not the indexer, so it works on every network today. Pass the MarginBank the perp market rows name. ```ts const stopPositions = exchange.client.accountPositions.subscribe(marginBank, account, (view) => { if (view.status !== "ready" && view.status !== "stale") return; for (const position of view.data.positions) { const pnl = position.marks.status === "known" ? position.marks.unrealizedPnl : null; showPosition(position.pool, position.size, pnl, view.status === "stale"); } }); // When the screen closes: stopPositions(); ``` Every view is read at one block, `data.blockNumber`. After the first read, the Somnia node pushes the same read to the SDK (`somnia_watch`), run at the block of each change: - when the MarginBank emits an event that names the account, such as a fill or a deposit; - on every price write while the account holds positions, because the marks, PnL and liquidation prices move with the price. The price trigger needs the price feed's address in the configuration, `addresses.priceFeedScheduler`. The Shannon and Hideki address constants set it. Without it, the marks follow a read every 5 s. While the account holds positions and no push arrives for 5 s, the SDK reads the account itself. A node that does not push, such as a local node, leaves the SDK on its own reads: at once on the account's events, and every 5 s while it holds positions. A figure the chain cannot give is `unknown` with a reason, never zero. When a pool's price feed is stale, its position stays in the list with `marks.status` set to `"unknown"`. While any market the account holds cannot be priced, the account's health, and so every liquidation price, is unknown. The view also holds the account's resting perp orders, `data.orders`, read at the same block: every order in every pool the MarginBank lists, a pool with only orders included. Fills and cancels arrive in the push. A new order costs one full read, and whenever the orders change, the push opens again for them with one more read. The orders are `unknown` when one pool's could not be read, and the positions still stand. A pool keeps an order until it fills or someone cancels it, an expired one too, so compare `expireTimestampNs` with the time to tell an order that can still fill. An in-place perp amendment can change the pool order without changing its collateral reservation. Such an amendment emits `OrderAmendedInPlace` from the pool but may emit no MarginBank event naming the account. This view does not subscribe to pool order events. With open positions, the next configured price push or a five-second inactivity read picks up the change. An account holding only resting orders has no periodic read, so it can keep the old price, size or expiry until another account event or a session restart causes a read. A ready live `getSnapshot` reuses that view and does not force a refresh. `getSnapshot(marginBank, account)` makes the same read once. When a live view of the account is `ready`, it returns that view without a read. A read that finishes after the live view has moved past it hands back the live view, so a snapshot is never older than what the view's listeners already have. `client.accountOrders`, `client.accountSpotOrders` and `client.accountBalances` follow the same rule. ## Follow the account's spot orders `client.accountSpotOrders` gives one account's resting spot orders. It reads the orders from chain and discovers pools through the configured indexer. On a client with a chain socket, order events trigger a read of the affected pool. ```ts const stopSpotOrders = exchange.client.accountSpotOrders.subscribe(account, (view) => { if (view.status !== "ready" && view.status !== "stale") return; for (const order of view.data.orders) { showOrder(order.pool, order.isBid, order.price, order.quantityRemaining, view.status === "stale"); } // A pool the SDK could not read: its orders are unknown, not absent. for (const { pool, error } of view.data.unreadPools) showUnreadPool(pool, error); }); // When the screen closes: stopSpotOrders(); ``` The chain keeps no list of the pools an account trades in, so the SDK asks every spot pool that the indexer's market list names. Each pool keeps its own list of the account's open orders: that is one call per pool, sent as the account, then one Multicall3 call for the orders' details. The first read, and `getSnapshot`, read every pool at one block, `data.blockNumber`. The orders a read found ride the next read's first round. After the first read, the SDK subscribes over the chain socket to two kinds of event, and when one of them concerns the account, it reads again at once the pool that emitted it: - every placement in every spot pool, because only the placement names its owner. The SDK keeps the account's own; - the later events of the account's own orders, which name only order ids: a fill, cancel, expiry, reduce or amend, including an in-place amend that keeps the order id. The node sends only these, for the ids the SDK names. So the socket carries the spot pools' placements and the account's own order events, not the rest of the order flow. When the account gains an order, the subscription opens again with its id, and the old one stays until the node confirms the new one. Past 1,000 orders the subscription follows every order event of the spot pools instead. The other pools keep their last read, because no event says they changed, so `data.blockNumber` is the newest block the view read. A read that lands before the block of the event that asked for it reads that pool again. The SDK also reads every pool once the node confirms a subscription, because a change before that reached neither, and 30 s after it last read them all, so a subscription that stops without an error costs at most that. A lost subscription gives `stale`, and the SDK subscribes again after a backoff and reads every pool once the node confirms it. A pool whose orders cannot be read is in `data.unreadPools`, with its error, and its orders are missing from `data.orders`: they are unknown, not absent. `data.complete` is `false`, and a live view reads that pool again after a backoff while the other pools stay live. A read that can read no pool, or not even the head, fails: `getSnapshot` throws, and a live view keeps its last data as `stale`. A pool keeps an order until it fills or someone cancels it, an expired one too, so compare `expireTimestampNs` with the time. Each full refresh discovers the pool list again. Discovery that fails or returns an unproven empty list does not erase known orders. `getSnapshot` throws an `IndexerError`; a live view keeps its orders and subscriptions as `stale`, with `data.complete` set to `false`. Check `data.complete` even when `data.unreadPools` is empty: discovery coverage can still be unknown. Pool-specific event reads keep updating known orders during discovery loss, but only successful full discovery can restore complete coverage. An empty indexer list alone does not prove that the chain has no spot pools. Discovery requests follow caller cancellation and the owner's live-read lifecycle. Resting perp orders are on `client.accountPositions`, in `data.orders`, and pending stops (TP/SL) are on `client.accountOrders`. ## Follow the account's wallet balances `client.accountBalances` gives the wallet balances of one or more accounts: an ERC-20 balance for each token address you pass, and the native balance for `"native"`. It reads the chain, not the indexer. ```ts const stopBalances = exchange.client.accountBalances.subscribe([account], [usdso, "native"], (view) => { if (view.status !== "ready" && view.status !== "stale") return; for (const entry of view.data.balances) { const value = entry.balance.status === "known" ? entry.balance.value : null; showBalance(entry.token, value, view.status === "stale"); } }); // When the screen closes: stopBalances(); ``` Every view is read at one block, `data.blockNumber`: in one Multicall3 call, or, on a chain without Multicall3, one call per balance at that block, the native balance through `eth_getBalance`. The SDK reads again at once when one of the tokens emits a `Transfer` from or to one of the accounts. It also reads again every 10 s, because a native balance changes with no event, for example when the account pays gas. Balances are raw base units; scale them with each token's decimals. A balance the chain cannot give is `unknown` with its error, never zero. `getSnapshot(accounts, tokens)` makes the same read once, for at most 1,000 balances. Concurrent snapshots and live listeners for the same account/token sets share work within one owner, regardless of order, duplicates, or use of `"native"` instead of `NATIVE_TOKEN_SENTINEL`. Each caller's view keeps its requested order, duplicates, and native-token labels. Canceling one `getSnapshot` caller with `{ signal }` does not cancel another caller of the shared read. A live session remains for 30 seconds after its last listener stops. `stopLive()` or owner cancellation ends it immediately, including during that reuse period. ## Stop every live view `exchange.client.stopLive()` ends every live view, of orders, spot orders, positions and balances, with a `closed` view, and closes the indexer WebSocket. After that, a new `subscribe` call starts a new session. An abort of the client's own `signal` ends them the same way, but for good: a later `subscribe` gets only a `closed` view, and a later `getSnapshot` rejects with the signal's reason. --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/how-to/sign-with-a-browser-wallet This guide shows you how to let a user trade through an injected wallet such as MetaMask instead of a private key held by your code. It assumes a React app with wagmi or another source of a viem `WalletClient`. For a Node process, use `privateKey` instead; see [Configure the SDK](./configure-for-a-network). ## Construct without a signer, bind on connect A browser app has no signer at boot. Construct the exchange for public reads, then bind the wallet when it connects and unbind it when it disconnects. ```tsx import { useEffect } from "react"; import { useWalletClient } from "wagmi"; import { exchange } from "./exchange.js"; // the module-scope SomniaMarkets instance export function SignerBinding() { const { data: walletClient } = useWalletClient(); useEffect(() => { exchange.setSigner(walletClient ? { walletClient } : {}); }, [walletClient]); return null; } ``` `setSigner` replaces the trader that every authenticated verb uses and updates `exchange.walletAddress`. Live watches and market data are unaffected. After binding, the exchange verbs work as they do with a private key: ```ts const order = await exchange.createOrder("SOMI/USDso", "limit", "buy", 10, 0.11); ``` ## What differs from a local key The wallet signs after a user prompt, sends through `eth_sendTransaction`, and the SDK reads the receipt on the next block heads instead of in one round-trip. The first order per token also prompts for an approval. Both paths resolve to the same result shape once the transaction is mined. The [Configuration reference](../reference/configuration#signer-paths) tabulates the differences. ## Make sure the wallet is on the right chain The SDK does not switch chains. Check `walletClient.chain?.id` against `exchange.client.config.chain.id` before a write, and ask the wallet to switch with wagmi's `useSwitchChain` when they differ. A write from the wrong chain is rejected by the wallet or the node, not by the SDK. ## Use the owner-derived trader with a wallet After `setSigner` binds the wallet, use the trader owned by the same exchange. It inherits the exchange's chain, read client, addresses, and lifecycle. ```ts await exchange.trader.faucet(); ``` ## Show the connected account's data `fetchBalance`, `fetchOpenOrders`, `watchOrders`, and `watchMyTrades` answer for `exchange.walletAddress`. In React, prefer the hooks with the account from wagmi's `useAccount`: `useWatchUser(address)` plus `useLiveUserOrders(pool, address)`. See [Use the React hooks](./use-the-react-hooks). ## Handle a rejected prompt A user who dismisses the wallet prompt produces an error from the wallet, not from the SDK. It does not extend `SomniaMarketsError`, so keep a final `throw` or a wallet-specific branch in your handler. See [Handle errors and reverts](./handle-errors). --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/how-to/trade-through-a-smart-account This guide shows you how to send writes through a smart account from [`@somnia-chain/accounts-sdk`](https://www.npmjs.com/package/@somnia-chain/accounts-sdk) — a Gnosis-style safe the cross-chain factory deploys at the same address on every chain — so the account, not your key, is the actor every pool sees. It assumes the account already exists and your actor is set up on it; creating accounts, adding a session key as an owner, and finding an account's address are `client.accounts` — the [Smart accounts guide](../accounts) walks the whole lifecycle. The smart-account executor described here covers the factory's accounts through the accounts SDK itself: batching without configuration, and accounts owned by other accounts, at any depth. An account that the factory did not deploy needs a custom `Executor`. ## The account is the trader Build the executor with the account's address and an inner executor that sends the outer transaction. The usual arrangement is a session key added as an owner of the account, so nothing signs — the account honours the sender's presence as its approval. The session key signs locally, with `{ privateKey }`. Do not use a `{ sessionSeed }` executor as `inner`: it sends the seed to the node on every write, and the seed would then control the account's funds. The SDK refuses it with `InvalidInputError`. Use one sending mode per address, because local and node-side sends share one nonce space: ```ts const session = client.createExecutor({ privateKey: sessionKey }); const account = client.createExecutor({ smartAccount: ACCOUNT, inner: session }); const trader = client.createTrader({ executor: account }); await trader.placeOrder(params); // approval + order in ONE account transaction, nothing signs ``` Two properties come with this shape: - **Batching needs no configuration.** Several calls (an approval and the order it enables) become one account transaction through the accounts SDK's own MultiSend batcher, resolved from its committed deployment manifests. - **The no-signature path reads no nonce.** The account checks the sender against its owner set, never a planned hash, so no nonce is fetched and no wallet is prompted. For this to hold, `inner`'s actor must be a **direct owner** of the account and the account's threshold must be **1**. Neither is checkable offline; a violation reverts at the account (`GS025` / `GS020`) and surfaces as a `ContractRevertError` tied to the account's `execTransaction`. Before the first write, the executor reads the account's code on the chain, once. An account that does not exist on that chain yet is refused with `InvalidInputError` before anything is signed or sent. Without this check, a call to the empty address would succeed and execute nothing. Create the account on the chain first (see the [Smart accounts guide](../accounts)). ## When something must sign Pass a `signer` when the sending actor's presence is not authority enough — the sender is a relayer or sponsor, or the signing owner is a different key than the one that sends: ```ts const account = client.createExecutor({ smartAccount: ACCOUNT, inner: session, signer: { address: ownerAddress, signTypedData: (typedData) => ownerAccount.signTypedData(typedData) }, }); ``` The signer is shown the account's own Safe transaction (EIP-712, chain-bound), signs once per write, and any funded inner actor may send the result. Each signed write signs the account's current nonce, so the engine runs signed writes through one account one at a time, including writes from two executors built for the same account. The queue belongs to one engine. Two processes that write through the same account (for example a backend and a browser) can still sign the same nonce, and the account then refuses the second write with `GS026`. ## An account owned by another account `through` names the parent accounts between the signer and the acting account, nearest first. The accounts SDK nests the signature through each parent's EIP-1271 envelope; depth is bounded by gas, not by the SDK: ```ts const child = client.createExecutor({ smartAccount: CHILD_ACCOUNT, inner: session, // its actor owns MAIN_ACCOUNT and signs through it through: [MAIN_ACCOUNT], }); ``` With `through` set, something always signs. When `signer` is omitted, the inner executor's own typed-data signing stands in — its actor must then own the outermost parent. At depth the signer is shown the outermost parent's `SafeMessage` envelope rather than the transaction itself; a wallet can only render the hash, because that is genuinely all there is at that level. ## What the executor covers, and what it does not - **Covered:** a direct owner at threshold 1 (pre-validated, nothing signs); any topology one owner signature settles, including nested accounts at any depth (signed). - **Not covered:** an account whose threshold is above 1. Collecting several owners' signatures is a workflow, not an executor — build the call with the accounts SDK's `signExecutionWith` and drive it through a custom `Executor`. - **Typed-data verbs:** the executor has no `signTypedData` — an account's "signature" is an on-chain arrangement, and `signRedeemAuth` recovers a key. Those verbs throw `SignerRequiredError`; create that trader with a key or wallet executor instead. ## Check the chain The accounts SDK carries its own deployments (Somnia mainnet 5031, Somnia testnet 50312, Hideki testnet 50383 — the same addresses on each). A smart-account spec on any other chain is refused with `InvalidInputError` at `createExecutor`, before anything is signed or sent. --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/how-to/use-the-react-hooks This guide shows you how to wire `@somnia-chain/markets-sdk/react` into a React app: provide the client once, render live data that updates itself, and fetch indexer data with your own query library. It assumes you know React hooks and context. The hook signatures are in the API reference under "React hooks". ## Provide the client once Construct one exchange at module scope and pass its engine, `exchange.client`, to the provider near the root. Every hook reads the client from context. ```tsx import type React from "react"; import { SomniaMarkets, SOMNIA_TESTNET_ADDRESSES } from "@somnia-chain/markets-sdk"; import { SomniaMarketsProvider } from "@somnia-chain/markets-sdk/react"; import { somniaTestnet } from "viem/chains"; export const exchange = new SomniaMarkets({ indexerUrl: "https://dev.smk.somnia.host/v1/graphql", chain: somniaTestnet, wsRpcUrl: "wss://api.infra.testnet.somnia.network/ws", addresses: SOMNIA_TESTNET_ADDRESSES, }); export function App({ children }: { children: React.ReactNode }) { return {children}; } ``` Do not construct the exchange inside a component. A new instance per render means a new socket and a new store per render. ## Render a live order book The pool-keyed `useLive*` hooks open the market watch while the component is mounted and re-render on every change. Ten components on one pool share one watch. ```tsx import { toHuman } from "@somnia-chain/markets-sdk"; import { useLiveSpotOrderBook, useWatchMarket } from "@somnia-chain/markets-sdk/react"; export function SpotBook({ pool, baseDecimals, quoteDecimals, }: { pool: string; baseDecimals: number; quoteDecimals: number; }) { const status = useWatchMarket(pool); // "unwatched" | "hydrating" | "live" const book = useLiveSpotOrderBook(pool, 10); if (status !== "live") return

loading…

; return (
{book.asks.map((l) => ( ))} {book.bids.map((l) => ( ))}
{toHuman(l.price, quoteDecimals)} {toHuman(l.quantity, baseDecimals)}
{toHuman(l.price, quoteDecimals)} {toHuman(l.quantity, baseDecimals)}
); } ``` Live values are raw `bigint`. Convert at the edge with `toHuman` and the market's decimals. Read the decimals off the market row, from `useMarkets` or from `exchange.markets[symbol].info`. Pass `undefined` as the pool to render nothing and open no watch, for example while a route parameter loads. The other `useLive*` hooks follow the same pattern; the API reference under "React hooks" lists them. ## Show your own orders and fills Combine `useWatchUser` with the user-scoped hooks. `useWatchUser` hydrates the account's order and fill history; the pool watch keeps it current. ```tsx import { useLiveUserOrders, useWatchUser } from "@somnia-chain/markets-sdk/react"; export function MyOrders({ pool, account }: { pool: string; account: string }) { useWatchUser(account); const orders = useLiveUserOrders(pool, account, 50); return (
    {orders.map((o) => (
  • {o.orderId} {o.status}
  • ))}
); } ``` ## Fetch indexer data with TanStack Query or SWR The SDK ships no cache wrapper. Every client read is a promise, which is a ready-made `queryFn`, and the root entry exports one key factory per read so your keys never drift. ```tsx import { useQuery } from "@tanstack/react-query"; import { candlesKey } from "@somnia-chain/markets-sdk"; import { useSomniaMarketsClient } from "@somnia-chain/markets-sdk/react"; export function useCandles1h(pool: string) { const client = useSomniaMarketsClient(); return useQuery({ queryKey: candlesKey(pool, 3600, { limit: 500 }), queryFn: () => client.getCandles(pool, 3600, { limit: 500 }), refetchInterval: 15_000, }); } ``` After a write, invalidate with the same factory, for example `queryClient.invalidateQueries({ queryKey: portfolioKey(account) })`, or everything SDK-shaped with the `QUERY_KEY_SCOPE` prefix. On a multi-chain exchange, the same account or pool can exist on each chain. Give each key the chain of its read as the last argument: ```tsx export function useCandles1hOn(pool: string, chainId: number) { const client = useSomniaMarketsClient(chainId); return useQuery({ queryKey: candlesKey(pool, 3600, { limit: 500 }, { chainId }), queryFn: () => client.getCandles(pool, 3600, { limit: 500 }), }); } ``` A key without `chainId` is the same key a single-chain app uses. A key with a `chainId` is that key plus a last `{ chainId }` element. So `invalidateQueries` with a key without a chain matches the entries of every chain, because TanStack Query matches by prefix. A key with a `chainId` does not match an entry stored without one, even on the primary chain. So on a multi-chain exchange, pass `chainId` on every key you read with. Do not wrap the `useLive*` hooks in a query cache. They already read a shared, push-fed store. ## Fetch indexer data without a query library `useIndexerQuery(fn, deps)` re-runs `fn` when `deps` change and keeps `data`, `loading`, `error`, and `refetch`. It discards a superseded result. It also passes an `AbortSignal` to `fn`, but current client read methods do not accept a per-request signal, so the example below does not cancel its HTTP request. ```tsx import { useIndexerQuery } from "@somnia-chain/markets-sdk/react"; export function useOpenOrders(owner: string | undefined) { return useIndexerQuery((client) => (owner ? client.getOpenOrders(owner) : Promise.resolve([])), [owner]); } ``` The built-in indexer hooks such as `usePortfolio` and `useCandles` return the same shape. ## Write from a component Hooks read; writes go through the exchange or a trader. With a browser wallet, bind it first as shown in [Sign with a browser wallet](./sign-with-a-browser-wallet), then call `exchange.createOrder(...)` from an event handler. The live hooks pick up the resulting order and fills without any refetch. ## Render prices `useWatchPrice(asset)` opens the price watch; `useLivePrice(asset)` returns the latest `LivePrice` or `null`. These hooks acquire current data only. Add `useLivePriceTicks(asset)` to acquire historical ticks and their live stream. All price hooks need `priceFeed` in the configuration. `useLivePriceFeedInfo` does not re-render as a price ages; drive an age display from your own timer. --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/how-to/handle-errors This guide shows you how to branch on the errors the SDK throws so a bot or a UI reacts correctly to a bad input, an outage, and a rejected transaction. It assumes you write `try`/`catch` in TypeScript. The complete list of classes and contract error names is in [Errors](../reference/errors). ## Branch on the class, not the message Every SDK error has a concrete class. Test with `instanceof`, most specific first, and keep a final `throw` for errors the SDK does not own. `InvariantError` is the class for an SDK defect; report it and rethrow it instead of using it for control flow. ```ts import { ContractRevertError, FillHistoryRestartRequiredError, FillHistoryUnavailableError, IndexerError, IndexerWaitTimeoutError, InvariantError, InvalidInputError, PerpPositionChangesUnavailableError, RpcError, SignerRequiredError, SomniaMarketsError, TransactionOutcomeUnknownError, } from "@somnia-chain/markets-sdk"; try { await exchange.createOrder("SOMI/USDso", "limit", "buy", 10, 0.11); } catch (e) { if (e instanceof TransactionOutcomeUnknownError) { // the order may be on chain: look up e.hash before placing it again } else if (e instanceof ContractRevertError) { // the chain rejected it: read e.errorName } else if (e instanceof InvalidInputError) { // the call is wrong: fix the arguments, do not retry } else if (e instanceof SignerRequiredError) { // configure a privateKey, account, or walletClient } else if (e instanceof FillHistoryRestartRequiredError) { // getUserFillsPage: discard accumulated pages and request page one without a cursor } else if (e instanceof FillHistoryUnavailableError) { // deploy and reindex a producer that initializes the fill dataset generation } else if (e instanceof PerpPositionChangesUnavailableError) { // listPerpPositionChangesForFills: show each fill's own side until the indexer is reindexed } else if (e instanceof RpcError || e instanceof IndexerError) { // the request did not complete: retry with backoff, or degrade } else if (e instanceof InvariantError) { console.error(e); // report the SDK defect throw e; } else if (e instanceof SomniaMarketsError) { // NotConfiguredError and anything else the SDK raised } else { throw e; // not ours: a wallet or an application bug } } ``` `IndexerWaitTimeoutError` comes from the indexer wait, not from the write. Wrap `waitForIndexedBlock` when you need to branch on that deadline: ```ts const targetBlock = 1_234_567n; // the confirmed transaction's block try { await exchange.client.waitForIndexedBlock(exchange.client.config.chain.id, targetBlock); } catch (e) { if (e instanceof IndexerWaitTimeoutError) { // the indexer answered but is still behind e.targetBlock } else if (e instanceof IndexerError) { // the metadata read did not complete: retry with backoff, or degrade } else { throw e; } } ``` Message text is not stable. `errorName`, `operation`, `what`, `cursorGeneration`, and `servingGeneration` are. ## Decide from a revert `ContractRevertError.errorName` is the contract's own error name when the revert data matched a known error. Branch on it with a fallback arm; `errorName` is `undefined` for a bare `require` string or unknown data. ```ts import { ContractRevertError } from "@somnia-chain/markets-sdk"; try { await exchange.trader.placeOrder(params); } catch (e) { if (!(e instanceof ContractRevertError)) throw e; switch (e.errorName) { case "ExpiredOrderMustBeCancelled": { // An expired maker sits in the way. Sweep the expired orders, then retry. const expired = await exchange.client.listSweepableOrders({ pool: params.pool }); await exchange.trader.cancelExpiredOrders({ pool: params.pool, orderIds: expired.map((o) => o.orderId) }); break; } case "InsufficientBalance": case "ERC20InsufficientBalance": // top up, then retry break; case "PostOnlyWouldCross": // re-quote one tick away break; case undefined: console.error("unrecognised revert", e.reason ?? e.data); break; default: throw e; } } ``` `e.args` holds the decoded arguments in order. `IncorrectSender(sender, expected)`, for example, is what a cancel of an already-cancelled order returns; `expected` is the zero address because the order no longer exists. ## Retry only what can succeed Retry a read that throws `RpcError` or `IndexerError` with backoff; the request never reached a conclusion. A write is different; see the next section. An `IndexerUnavailableError` is the SDK refusing locally. `breaker-open`, `breaker-trial`, and `queue-full` mean nothing was sent. `live-reset` can stop a retry backoff after an earlier attempt was sent. Branch on `reason` rather than waiting a fixed period, because `retryAfterMs` is meaningful only for `breaker-open`. Sleep `retryAfterMs` for `breaker-open`; retry shortly for `breaker-trial`, where a trial read is already deciding; back off on your own schedule for `queue-full`, since this client is saturated and retrying at once is just re-shed; and retry `live-reset` on the same client, which `stopLive()` leaves usable. Retrying sooner than the reason allows is what turns a slow indexer into an outage. An `IndexerWaitTimeoutError` means the indexer answered but did not reach `targetBlock` before the deadline: wait longer, or read the required state from chain. An `AccountViewError` means the indexer answered an account-view read, but not with one complete account. It never means an empty account. Retry `snapshotUnstable`: the account kept changing while it was read. Retry `invalidResponse` and `scopeMismatch` later; when they persist, the indexer serves broken data. An `IncompleteHistoryError` means portfolio analytics stopped before reading every indexed fill: raise or omit `maxFillPages` when `reason` is `pageBudgetExceeded`, and investigate the indexer when it is `cursorCycle`; it never means an empty portfolio. A `FillHistoryUnavailableError` means the producer has not initialized its fill-dataset generation: deploy and reindex a compatible producer before trying again. A `PerpPositionChangesUnavailableError` means the indexer predates perp position changes: show each fill's own side, and pair the fills again once the indexer is reindexed; retrying the same indexer fails the same way. A `FillHistoryRestartRequiredError` means a fill-page cursor belongs to a replaced serving dataset: discard the pages already read and restart from page one; retrying with the same cursor fails again. On the smart-account indexed reads, an `AccountsIndexerUnavailableError` means the accounts indexer is rate-limited or overloaded: wait `retryAfterMs` when it is set, otherwise back off, and retry. An `AccountFeedRestartRequiredError` means that chain's accounts index was rebuilt since the cursor was issued: discard the pages and read again without a cursor. Retry a `ContractRevertError` only after the state that caused it changes; the same call reverts the same way. Never retry `InvalidInputError`, `NotConfiguredError`, `NotSupportedError`, or `SignerRequiredError`; fix the code or the configuration. A `CollateralOnWrongChainError` means the collateral is on another configured chain: bridge the shortfall — the error carries a plan when a route exists — and retry once it lands. A `BridgeLiquidityError` means a native bridge route's destination router holds less than the amount, so the SDK sent nothing: retry after the router is seeded, bridge at most `available` with `exchange.bridge.transfer`, or fund the destination another way. A `BridgeDeliveryTimeoutError` means the bridge leg was sent, but its message was not delivered within the timeout. Do not bridge again at once, or the funds move twice. Call `delivered(messageId)` on the destination Mailbox. When it returns `false`, wait longer: the message can still land. Two bridge `RpcError` operations also come after the origin leg was sent: `bridge message id` and `watch bridge delivery`. The funds may be in flight, so look up the origin transaction before you retry. For a binary wallet, `IncompletePortfolioError` means `getClaimable` observed more than 200 nonzero positions. The read returns no incomplete claim array. Use `getClaimablePage` and follow `nextOffset`, including when a page has no claims. Retrying the same unqualified call cannot recover omitted positions. ## Retry a write only after you know its outcome A write can fail after its signed transaction reached the node. The SDK then throws `TransactionOutcomeUnknownError`, a subclass of `RpcError`. The transaction can still mine, so sending the write again can place the order twice. Test for this class before `RpcError`. The error carries `chainId`, `operation` and `hash`. Look up `hash` before you send again: 1. Read the receipt for `hash`. If it exists, the transaction mined. Do not send it again. Its `status` tells you whether the write succeeded (`"success"`) or reverted (`"reverted"`). 2. If there is no receipt, or no `hash` (a session send gives none), read the state the write changes. For an order, read your open orders at chain head with `exchange.client.getOwnOpenOrdersOnchain(pool, owner)`; see [Detect when an order fills](./detect-fills). 3. Send again only when that state shows the write did not happen. ```ts import { TransactionOutcomeUnknownError } from "@somnia-chain/markets-sdk"; try { await exchange.trader.placeOrder(params); } catch (e) { if (!(e instanceof TransactionOutcomeUnknownError)) throw e; const viem = exchange.client.getViemClient(); const receipt = e.hash ? await viem.getTransactionReceipt({ hash: e.hash }).catch(() => null) : null; if (receipt === null) { // Unknown still: read your open orders at chain head before placing again. } } ``` From the local-signer and session-key executors, a plain `RpcError` from a write means the node refused the transaction or nothing was sent. Fix the cause (for example a stale nonce or an unfunded account), then send again. Other writes do not guarantee this. A wallet client can fail after it broadcast, and admin, lend and bridge writes do not make the distinction yet. After one of those throws `RpcError`, check the chain before you send again. ## Degrade an indexer read An indexer failure is never "no rows". Catch `IndexerError` deliberately when a page should render with stale or empty data rather than fail. ```ts import { IndexerError } from "@somnia-chain/markets-sdk"; const trades = await exchange .fetchTrades("SOMI/USDso", undefined, 50) .catch((e) => (e instanceof IndexerError ? [] : Promise.reject(e))); ``` Do not apply the same pattern to a chain read: a chain read throws only on real failure, and an empty fallback would hide it. ## Tell cancellation apart from failure When you pass `signal` in the configuration and abort it, in-flight indexer reads re-throw your abort reason, not an `IndexerError`. Check `e.name === "AbortError"` before the SDK classes. ## Decode a revert you sent yourself If you build a transaction with the exported ABIs and send it through your own client, `decodeRevert(caught)` turns the failure into a `ContractRevertError` with the same `errorName` decoding. It always returns an error and never throws. Call it only on failures you know are reverts; a transport error passed to it comes back without `errorName`. ```ts import { decodeRevert } from "@somnia-chain/markets-sdk"; try { await wallet.writeContract(request); } catch (caught) { const revert = decodeRevert(caught); console.error(revert.errorName ?? revert.reason); } ``` ## See the failure in context Pass a `debug` sink to see which span failed and with what arguments. See [Debug what the SDK is doing](./debug-the-sdk). --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/how-to/debug-the-sdk This guide shows you how to see every trader call, the sign-and-send pipeline, and live-tail hydration as structured events, and how to capture them in tests. It assumes a working `SomniaMarkets` configuration. The SDK is silent by default. A `debug` sink in the configuration receives `DebugEvent` values: log lines, and span start, annotate, and end events with ids, parent ids, and durations. The sink owns filtering and formatting. ## Print an indented span tree `consoleDebugSink()` renders the stream to the console as a tree reconstructed from `parentId`. ```ts import { SomniaMarkets, consoleDebugSink } from "@somnia-chain/markets-sdk"; const exchange = new SomniaMarkets({ ...config, debug: consoleDebugSink() }); ``` A `createOrder` on a spot market printed these lines on the testnet: ```text [sdk] ▶ trader.placeSpotOrder { params: { pool: '0x259f…fff4', isBid: false, price: 459600000000000000n, … } } [sdk] ▶ trade.execute { functionName: 'placeOrder', address: '0x259f…fff4' } [sdk] ▶ trade.signCall [sdk] ◀ trade.signCall 224.2ms [sdk] ▶ trade.broadcast [sdk] ◀ trade.broadcast 948.4ms [sdk] · trade.execute { hash: '0xc164…b18a17' } [sdk] ◀ trade.execute 1173.2ms [sdk] ◀ trader.placeSpotOrder 1174.5ms ``` The trader span and the `trade.execute` span are both roots: a parent is only recorded where the SDK sets one explicitly, and `trade.signCall`, `trade.broadcast`, and `trade.confirm` are the spans that carry one. Pass `{ prefix }` to change the `[sdk]` label. A span that ended with an error prints through `console.warn`. ## Toggle it from your app The toggle belongs to the app, not the SDK. Two common shapes: ```ts // Browser: flip on from devtools with localStorage.setItem("sdk-debug", "1") and reload. const exchange = new SomniaMarkets({ ...config, debug: localStorage.getItem("sdk-debug") ? consoleDebugSink() : undefined, }); ``` ```ts // Node bot: JSON lines behind an environment variable. const exchange = new SomniaMarkets({ ...config, debug: process.env.SDK_DEBUG ? (e) => console.log(JSON.stringify(e, (_, v) => (typeof v === "bigint" ? v.toString() : v))) : undefined, }); ``` Events carry `bigint` values, so a plain `JSON.stringify` throws without the replacer. ## Capture events in a test `debugCollector()` returns a sink and typed filters over what it received. Each collector is independent, so parallel tests can each have their own. ```ts import { SomniaMarkets, debugCollector } from "@somnia-chain/markets-sdk"; const c = debugCollector(); const exchange = new SomniaMarkets({ ...config, debug: c.sink }); await exchange.trader.placeOrder(params); expect(c.starts("trade.execute").length).toBeGreaterThan(0); // 2 when a first order also sent an approve expect(c.ends("trade.execute").every((e) => e.error === undefined)).toBe(true); ``` `c.starts(name?)`, `c.ends(name?)`, `c.annotations(name?)`, and `c.logs(scope?)` filter `c.events`. ## Send spans to a tracer The span events map onto OpenTelemetry (`name`, `data` as attributes, `error` as status, `parentId` as parent). A sink that keeps a `Map` from span id to tracer span calls `startSpan` on `phase: "start"` and `end()` on `phase: "end"`; the tracer dependency stays in your app. ## What to look for | Symptom | Where it shows | | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | A write is slow | `trade.execute` duration, split into `trade.signCall` and `trade.broadcast`; `trade.confirm` appears only on the external-wallet path, where the receipt is read separately | | A watch never goes live | The `liveTail.hydrate:` span ends with `error` set. In React, a `hooks` log line at level `warn` reads `watch failed for …` | | A first write does an extra round-trip | An `approve` transaction before the order: the allowance is cached per token and spender afterwards | The SDK swallows a sink that throws. A broken sink never breaks trading. --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/reference - [Configuration](./configuration) - [Networks and endpoints](./networks-and-endpoints) - [Symbols](./symbols) - [Units and scales](./units-and-scales) - [Read tiers](./read-tiers) - [Fill history pages](./fill-history-pages) - [Errors](./errors) --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/reference/configuration This page lists every field a `SomniaMarkets` instance accepts, with its type, whether it is required, and its default. Engine fields form `ClientConfig`; signer fields and `rpcWebSocketConstructor` belong to the configured root. For the values to put in these fields on each network, see [Networks and endpoints](./networks-and-endpoints). ```ts import { SomniaMarkets } from "@somnia-chain/markets-sdk"; const exchange = new SomniaMarkets(config); // config: SomniaMarketsConfig ``` ## `SomniaMarketsConfig` `SomniaMarketsConfig` accepts one chain configuration or a `chains` list, plus the signer fields `privateKey`, `account`, and `walletClient`, and the optional root RPC constructor. | Field | Type | Required | Default | Description | | ------------------------- | ----------------------------------- | -------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `indexerUrl` | `string` | no | none | Envio/Hasura GraphQL endpoint over HTTP. A same-origin relative path is accepted in a browser. Omit for a chain-only client (smart accounts, executors, on-chain reads): every indexer read and live watch then throws `NotConfiguredError` naming this field. | | `indexerWsUrl` | `string` | no | `indexerUrl` with `http` replaced by `ws` | The indexer's GraphQL WebSocket endpoint, for live account views (`client.accountOrders.subscribe`). Set it when `indexerUrl` is a relative path, or when subscriptions are served from another address. Without it and without an absolute `indexerUrl`, `subscribe` throws `NotConfiguredError` naming this field. | | `chain` | `Chain` (viem) | yes | none | The chain the markets live on. | | `wsRpcUrl` | `string` | no | `chain.rpcUrls.default.webSocket[0]` | The WebSocket RPC endpoint. It is the only chain transport: subscriptions, reads, and writes all use it. There is no HTTP fallback. Required when `chain` carries no WebSocket URL. | | `rpcWebSocketConstructor` | `SomniaMarketsWebSocketConstructor` | no | managed `ws` on Node; native/isows in browsers | Standard RPC constructor selected once per exchange, shared as a class across its chain engines. Each engine owns a separate created socket. Also opens price-feed sockets; indexer HTTP reads do not use it. | | `indexerHeaders` | `Record` | no | none | Extra headers on every indexer request, for example a Hasura admin secret. Server-side only. | | `signal` | `AbortSignal` | no | none | Cancels in-flight indexer reads and receipt confirmations. Client-wide, not per read. An indexer abort rethrows the caller's reason, not an `IndexerError`. A local or wallet write canceled before broadcast rethrows that reason. After broadcast, canceled confirmation throws `TransactionOutcomeUnknownError` with the transaction hash and the reason in `cause`. Check that hash before retrying the write. Other chain reads have their own 4-second request timeout. | | `fees` | `FixedFees` | no | `DEFAULT_FEES` | Fixed EIP-1559 fees for SDK-signed writes. | | `addresses` | `SomniaMarketsAddresses` | no | `{}` | Protocol contract addresses. Every entry is optional. A method that needs a missing address throws `NotConfiguredError` when it is called. | | `priceFeed` | `PriceFeedConfig` | no | none | The realtime price-feed endpoint. Required only by the price methods that fetch or watch (`watchPrice`, `fetchPrice`, `fetchPriceOHLCV`, and the client's `fetchPrice*` and `listPriceFeeds`). Its `quote` is mandatory to watch: an unpinned feed rejects `watchPrice`/`watchPrices` with `NotConfiguredError` because the tick tape's cursor is unique only within one quote; the one-shot reads still work unpinned. The `getLivePrice*` reads return `null` or `[]` until a watch is open. | | `debug` | `(event: DebugEvent) => void` | no | none | Receives structured debug events. When unset the SDK emits nothing and does no debug-only work. | | `privateKey` | `` `0x${string}` `` | no | none | A local signing key. The SDK signs locally and confirms in one round-trip. | | `account` | `Account \| Address` | no | none | A viem account, or a plain address to trade as through `walletClient`. | | `walletClient` | `WalletClient` | no | none | An external signer, for example an injected browser wallet. | ### Counts and `indexerHeaders` This section describes how the count methods (`countMarkets`, `countOrders` and the others) pick a query. A count first asks Hasura for `_aggregate`. Only a privileged role, such as the one an admin secret in `indexerHeaders` selects, has that field. On the public role Hasura rejects the field, and the count scans at most 10,001 ids instead. The client remembers that rejection for five minutes. The memory is per client, endpoint, header set and table. In that window a count of the same table sends only the scan. The first counts of a table that run at the same time share one probe. Only Hasura's exact `field '
_aggregate' not found in type: 'query_root'` error is remembered. Any other failure still throws and is not remembered. `stopLive()` clears the memory. After a role gains or loses the field, counts use the new permission within five minutes, or at once after `stopLive()`. ### RPC constructor selection Node connections use the SDK-managed `ws` transport by default. Replacing `globalThis.WebSocket` or isows on Node does not select an RPC constructor. Price-feed sockets use the same selection and the same explicit constructor. Browsers retain their native/global/isows selection, including replacements installed before or after SDK import. An explicit `rpcWebSocketConstructor` selects the constructor on either platform, independent of import order. The selected constructor receives only standard WebSocket arguments. RPC construction calls `new Constructor(url)`. Price-feed construction calls `new Constructor(url, "graphql-transport-ws")`. A protocol is always the standard second string or string-array argument. The SDK retains the exact returned socket and does not mutate the caller's constructor or prototype. Constructor selection is lazy. Closing the owner during dependency loading prevents construction, and an indexer-only exchange makes no RPC dial. A supplied constructor is trusted infrastructure. Every created socket belongs to this exchange and must promptly dispose connecting and open work through `close()`, stop replacement dials and safely observe errors during retirement. Sharing a class between exchanges must not share its created sockets. A standard interface proves the calling convention, not physical disposal. Node 22's native WebSocket does not satisfy this custom disposal obligation; use the managed Node default or a conforming implementation. **Migrating a Node override** Replace global assignment with a per-owner input. `AppWebSocket` below is your application's conforming constructor; `config` is its existing exchange config. ```ts // Before globalThis.WebSocket = AppWebSocket; const exchange = new SomniaMarkets(config); ``` ```ts // After const exchange = new SomniaMarkets({ ...config, rpcWebSocketConstructor: AppWebSocket, }); ``` A compile-checked factory can make that ownership explicit: ```ts import { SomniaMarkets, type SomniaMarketsConfig, type SomniaMarketsWebSocketConstructor, } from "@somnia-chain/markets-sdk"; function createExchange(config: SomniaMarketsConfig, AppWebSocket: SomniaMarketsWebSocketConstructor) { return new SomniaMarkets({ ...config, rpcWebSocketConstructor: AppWebSocket }); } ``` Modeled operations preserve concrete SDK constructor/member failures by identity; other transport failures become `RpcError` with the original value as cause. Price watches follow the same rule when a price-feed socket cannot be selected or constructed: `watchPrice` and `watchPrices` reject with the constructor's SDK error, or with `RpcError` for any other thrown value. Root validation rejects a non-function input with `InvalidInputError`. Unexpected configuration-access failures become `InvariantError` with their original cause. `close()` and the engine's `stopLive()` finish remaining cleanup before reporting the first disposal failure. A disposal failure following an already delivered operation timeout does not replace that timeout; it is retained for owner cleanup. A constructor that violates the disposal obligation cannot receive a physical release guarantee from the SDK. Undecorated viem operations retain their platform operational contract after access. ### Signer resolution A method that writes, or reads data scoped to the caller, resolves the caller's address in this order: `walletClient.account.address`, then `account.address`, then `account` when it is a string, then the address derived from `privateKey`. When none is set, `walletAddress` is `undefined` and every authenticated method throws `SignerRequiredError`. `setSigner(signer)` replaces the signer after construction. Passing `{}` removes it. Live watches and market data are unaffected. ### Signer paths | Aspect | `privateKey` or a signing `account` | `walletClient` | | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | Who signs | The SDK, locally | The wallet, after a user prompt | | Fees and gas | The fixed values from `fees` and the gas ceiling | The same fixed values, passed to the wallet as `maxFeePerGas`, `maxPriorityFeePerGas`, and `gas` | | Send path | `eth_sendRawTransactionSync`: send and receipt in one round-trip; falls back to `eth_sendRawTransaction` when the node does not serve it | `eth_sendTransaction` through the wallet; the receipt is read on heads and after 1 s idle, within a 60 s total confirmation deadline | | Reverts | Decoded by replaying the failed call at the receipt's block | Surfaced by the wallet's simulation before the prompt, with revert data | | Approvals | One `approve(maxUint256)` per token and spender, on the first order that needs it, cached for the trader's lifetime | The same, as a second wallet prompt | | Nonce | Tracked locally after one fetch; reset on a rejected send | Managed by the wallet | ### Lazy connections The viem chain WebSocket opens on the first chain read, write, or watch. An instance that only performs indexer reads never opens one. The price-feed WebSocket opens on the first `watchPrice`. `close()` stops the watches and channels and closes every socket the instance opened, both the price-feed WebSockets and the viem chain transport, so a Node process exits on its own afterwards. Each instance owns its own chain socket, even when several use the same `wsRpcUrl`, so closing one never affects another. ## `FixedFees` | Field | Type | Description | | ---------------------- | -------- | ----------------------------------------------------------------------------------- | | `maxFeePerGas` | `bigint` | Fee ceiling in wei per gas. The unspent margin above base fee plus tip is refunded. | | `maxPriorityFeePerGas` | `bigint` | Tip in wei per gas. | `DEFAULT_FEES` is `{ maxFeePerGas: 60_000_000_000n, maxPriorityFeePerGas: 0n }`: a 60 gwei ceiling and no tip. ### Gas Every SDK-signed write uses a fixed gas ceiling of 10,000,000 gas. Gas is never estimated. Unused gas is not charged. The mempool accepts a transaction only when the account holds `gas × maxFeePerGas` on top of the transaction value: 0.6 STT or SOMI at the defaults. A lower-level write accepts a per-call `gas` override only when its public parameter type declares one. Unified exchange methods such as `createOrder` and `cancelOrder` do not expose that override. ## `SomniaMarketsAddresses` Every field is optional and typed `Address`, except `lend`. | Field | Used by | | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `collateral` | The venue's collateral ERC-20. `faucet()` mints it on test networks. | | `testUsdc` | Legacy alias for `collateral`. `collateral` takes precedence. | | `binaryModule` | Complete-set mint and redeem, market creation, hub-approval reads, system diagnostics. | | `marketCreator` | The live tail watches `MarketCreated` on it to discover new binary markets. | | `clobFactory` | System diagnostics fallback. | | `binaryPoolImpl`, `binaryPoolBeacon` | Surfaced to apps. Not read by the SDK. | | `binarySettlement` | `redeemDirect`, `claimOwed`, `getSettlement`. | | `operatorPermissionsRegistry` | Operator approval of a stop-order registry. Discoverable at runtime with `getOperatorPermissionsRegistry(pool)`. | | `marketsCore` | Operator and venue reads, `createOperatorAdmin`. | | `collateralRouter` | The native-token and Permit2 complete-set path (`mintSetNative`, `mintSetPermit2`, `redeemNative`). | | `marketCreatorFactory`, `marketCreatorFactoryV2` | `createMarketCreatorAdmin`. | | `oracleHub` | OracleHub reads and `createOracleHubAdmin`. | | `perpPoolFactory` | Perp discovery when the indexer has no perp rows. `loadMarkets` otherwise resolves the factory from `MarginBank.getSystemConfig()` on an indexed perp row, so this is needed only when there is no such row. Without it, discovery is SKIPPED and reports through `perpDiscoverySkipped`. When set it takes precedence. | | `priceFeedScheduler` | The Somnia price feed that the perp pools' oracles read. `accountPositions.subscribe` has the node push the account's state on its price writes. Without it, marks follow a read every 5 s. The Shannon and Hideki constants set it. | | `lend` | `LendAddresses` for the third-party SomniaLend deployment. Backs `client.lend`. | | `fakeOracle` | `resolve` and `voidMarket` on demo stacks. | | `oracleAdapterFactory`, `sharedOracleAdapter` | Deprecated. Kept so old configurations type-check. Not read. | `SOMNIA_MAINNET_ADDRESSES`, `SOMNIA_TESTNET_ADDRESSES` and `SOMNIA_HIDEKI_TESTNET_ADDRESSES`, exported from the root entry, hold the current deployments. `SOMNIA_MAINNET_LEND` and `SOMNIA_TESTNET_LEND` hold the SomniaLend addresses and are already included in the mainnet and testnet constants. Hideki has no SomniaLend deployment, so its preset omits `lend`. ## `PriceFeedConfig` | Field | Type | Required | Default | Description | | ------- | -------- | -------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `url` | `string` | yes | none | HTTP GraphQL endpoint of the price-feed indexer. One endpoint serves every asset. | | `wsUrl` | `string` | no | `url` with `http` replaced by `ws` | WebSocket GraphQL endpoint for live subscriptions. | | `quote` | `string` | no | none | Quote asset to pin every read to, case-insensitive. When unset, a base that trades against two quotes is double-counted. | `SOMNIA_TESTNET_PRICE_FEED` is `{ url: "https://price-feed.dev.oracle.somnia.host/v1/graphql", quote: "USDC" }`. `SOMNIA_MAINNET_PRICE_FEED` is `{ url: "https://price-feed.prd.oracle.somnia.host/v1/graphql", quote: "USDC" }`. They index different chains; see [networks and endpoints](./networks-and-endpoints). ## Transport constants | Constant | Value | Where | | ------------------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | Ordinary RPC operation | 9,000 ms cold total | 4 s handshake, 4 s response and 1 s liveness probe; zero retries. | | Heavy `eth_getLogs` operation | 35,000 ms cold total | 4 s handshake, 30 s response and 1 s probe. | | Failed subscription observation | 30,000 ms lease; 64 IDs per socket | Separate from operation timeout; a late ID gets a bounded 4 s unsubscribe. Lease expiry, capacity or failed cleanup retires the socket. | | Indexer request timeout | 30,000 ms | Every indexer read. | | Default gas ceiling | 10,000,000 | Every SDK-signed write. Not exported. | | `DEFAULT_FEES.maxFeePerGas` | 60 gwei | Exported from the root entry. | | Reconnect backoff | 500 ms, doubling to an 8 s cap | Live watches after a socket error. | | Watch linger | about 30 s | Time a scope stays materialised after its last handle stops. | --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/reference/networks-and-endpoints This page lists the Somnia networks and the endpoint and address values that go into a [configuration](./configuration). The SDK accepts a viem `Chain`. The address constants come from the SDK root entry. The RPC endpoint chooses a server, not a shared socket. Each configured owner creates its own connection even when endpoint URLs match. Node uses a managed `ws` connection by default; browser defaults use native/global/isows selection. For an application-specific RPC constructor, pass `rpcWebSocketConstructor` to the root. Its [configuration contract](./configuration#rpc-constructor-selection) covers selection, ownership and migration from implicit Node globals. ## Chains Use the chain definitions that `@somnia-chain/markets-sdk/chains` exports. Each one carries its WebSocket URL, so `wsRpcUrl` is optional with them. Do not define these networks by hand: a hand-written definition keeps old endpoints when a network moves, as Hideki did at its 2026-09-17 regenesis. viem's own `somnia` and `somniaTestnet` describe the mainnet and Shannon networks but carry no WebSocket URL. If you use them, set `wsRpcUrl`. ```ts import { hidekiTestnet, somniaMainnet, somniaShannon } from "@somnia-chain/markets-sdk/chains"; ``` | SDK export | Chain id | Name | Native currency | Block time | HTTP RPC | WebSocket RPC | | --------------- | -------- | --------------------- | --------------- | ---------- | -------------------------------------------------- | --------------------------------------------------- | | `somniaMainnet` | 5031 | Somnia | SOMI (18) | 100 ms | `https://api.infra.mainnet.somnia.network` | `wss://api.infra.mainnet.somnia.network/ws` | | `somniaShannon` | 50312 | Somnia Testnet | STT (18) | 100 ms | `https://api.infra.testnet.somnia.network` | `wss://api.infra.testnet.somnia.network/ws` | | `somniaElwood` | 50313 | Somnia Elwood Testnet | STT (18) | 100 ms | `https://api.elwood.infra.testnet.somnia.network` | `wss://api.elwood.infra.testnet.somnia.network/ws` | | `hidekiTestnet` | 50383 | Hideki Testnet | STT (18) | 10 ms | `https://api.hideki2.infra.testnet.somnia.network` | `wss://api.hideki2.infra.testnet.somnia.network/ws` | | `somniaLocal` | 31337 | Somnia Local | STT (18) | none | `http://127.0.0.1:8545` | `ws://127.0.0.1:8545` | Shannon also serves `https://dream-rpc.somnia.network` and `wss://dream-rpc.somnia.network/ws` as secondary RPC URLs for the same network. Block explorers: `https://explorer.somnia.network` (mainnet) and `https://shannon-explorer.somnia.network` (Shannon). Elwood, Hideki, and local carry no explorer. Multicall3: mainnet `0x5e44F178E8cF9B2F5409B6f18ce936aB817C5a11`, Shannon `0x841b8199E6d3Db3C6f264f6C2bd8848b3cA64223`, Hideki `0xcA11bde05977b3631167028862bE2a173976CA11` (the canonical address; v2 predeploys it). Elwood and local carry none. ## Somnia Markets deployments | Network | Indexer GraphQL | Address constant | | ----------------------- | ---------------------------------------------------------------------- | -------------------------------------------------------------------------- | | Mainnet (5031) | `https://prd.smk.somnia.host/v1/graphql` | `SOMNIA_MAINNET_ADDRESSES` | | Shannon testnet (50312) | `https://dev.smk.somnia.host/v1/graphql` | `SOMNIA_TESTNET_ADDRESSES` | | Hideki testnet (50383) | `https://hideki-dev.smk.somnia.host/v1/graphql` (development-grade) | `SOMNIA_HIDEKI_TESTNET_ADDRESSES` | | Local anvil (31337) | `http://localhost:8085/v1/graphql` when started with `demo-clob.sh up` | Resolved from the local manifest through `@somnia-chain/deployments/local` | The mainnet and Shannon testnet constants share the protocol contracts (`binaryModule`, `binaryPoolBeacon`, `binaryPoolImpl`, `marketsCore`, `oracleHub`, `binarySettlement`, `clobFactory`, `collateralRouter`, `marketCreatorFactory`) and differ in `collateral`, `marketCreator`, `lend`, `perpPoolFactory`, `marginBank`, and `priceFeedScheduler`. The Hideki preset omits `collateralRouter` and `lend`. Hideki has no wSTT, Permit2, or SomniaLend deployment, so the corresponding helpers report not-configured. The Hideki indexer is development-grade. It runs in the development environment and has no alerting, so a stall can go unnoticed. It indexes the Hideki binary, perp and spot markets, except the HBTT/USDso spot pool. Omit `indexerUrl` for chain-only reads and writes; supply one for indexed discovery and history. Indexed reads without a configured URL fail with `NotConfiguredError`; requests to an unreachable indexer fail with `IndexerError`. The Shannon and Hideki presets set `perpPoolFactory` and a candidate `marginBank`. These values come from the canonical perp deployment records. With Multicall and a modern factory, discovery takes at most two serial rounds when every pool names the candidate bank. Each pool's own bank controls its registration gate. Unknown or different banks use authoritative fallback reads. `loadMarkets` therefore lists their perps from the chain, also when the binding has no `indexerUrl`. The mainnet preset has neither perp field because its deployment is deferred. | Field | Mainnet | Shannon | Hideki | | ------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | | `collateral` / `testUsdc` | `0x00000022dA000002656c64D9eA6011ea952D008A` | `0x70a86D8842FB63C4Ad2b7cdddF530eBf1BB25d8E` (tUSDC, 6 decimals, has `faucet`) | `0x9E9492cC533E8696A3b6afC7df730800DF75148f` (tUSDC, 6 decimals, has `faucet`) | | `marketCreator` | `0xfad8eDa4d50e876ba548BeD191Df1e2Af26783dF` | `0xD584e651008538b5C6B1C777cD4fbD54cC19a291` | `0xAd890D6Ac3c659E1Ed0fA72Cf557DB4cA26bdAff` | | `lend` | `SOMNIA_MAINNET_LEND` | `SOMNIA_TESTNET_LEND` | not set | Addresses change with deployments. The constants in the installed SDK version are the source of truth; the values above are the ones current at the time of writing. ## Testnet tokens | Token | Address on Shannon | Decimals | Role | How to obtain | | ----- | -------------------------------------------- | -------- | --------------------------------------------------- | --------------------------------------------------------------------- | | STT | native | 18 | Gas, and the base of `SOMI/USDso` | Faucets listed in [Get testnet funds](../how-to/get-testnet-funds) | | USDso | `0x9c32f3827a1a99f0cf9b213de8b53ec3d57bb171` | 18 | Quote of the spot pairs and settlement of the perps | Public `mint(address,uint256)` | | tUSDC | `0x70a86D8842FB63C4Ad2b7cdddF530eBf1BB25d8E` | 6 | Collateral of the binary markets | `exchange.trader.faucet()` | Hideki has its own token addresses. Send USDso and STT to Hideki from Shannon through the [bridge](../bridge). USDso on Hideki cannot be minted. | Token | Address on Hideki | Decimals | Role | How to obtain | | ----- | -------------------------------------------- | -------- | --------------------------------------------------- | -------------------------------------------------------------------- | | STT | native | 18 | Gas, and the base of the native spot pair | Bridge from Shannon | | USDso | `0xed7993A74dAe1e6656B704A424DcD8711aAf4F72` | 18 | Quote of the spot pairs and settlement of the perps | Bridge from Shannon | | tUSDC | `0x9E9492cC533E8696A3b6afC7df730800DF75148f` | 6 | Collateral of the binary markets | `faucet(uint256 amount)` on the token, at most 10,000 tUSDC per call | ## Price feed | Network | Endpoint | Constant | | --------------- | -------------------------------------------------------------------- | --------------------------- | | Somnia mainnet | `https://price-feed.prd.oracle.somnia.host/v1/graphql`, quote `USDC` | `SOMNIA_MAINNET_PRICE_FEED` | | Shannon testnet | `https://price-feed.dev.oracle.somnia.host/v1/graphql`, quote `USDC` | `SOMNIA_TESTNET_PRICE_FEED` | These are two separate deployments. Each one indexes the `PriceFeedScheduler` on its own chain. Use the feed that matches the chain the client runs on. A mainnet client that uses the testnet feed reads prices from chain 50312. The mainnet feed carries fewer symbols than the testnet feed. Code written for the testnet asset list can therefore fail on mainnet. Read the feed catalog to learn which bases exist. A base the feed does not carry returns no rows. It does not raise an error. The `PriceFeedScheduler` contract is at `0x7ccAE31E45693475be1e4BAa2360D6997eB2D32B` on Shannon, and at `0x1998C88D54a240b8C671493f27f58eC416b81D7F` on mainnet and on Hideki. It holds every price under a symbol name, and the perp pools' oracles read it. The Shannon and Hideki address constants set it as `priceFeedScheduler`. The mainnet constant does not, because mainnet has no perps manifest yet. ## Peer dependencies | Package | Range | Required | | -------------------------- | --------- | ----------------------------------------------- | | `viem` | `^2.18.5` | yes | | `react` | `>=18` | only for `@somnia-chain/markets-sdk/react` | | `@somnia-chain/reactivity` | `^0.2.1` | only for `@somnia-chain/markets-sdk/reactivity` | The package is published on the public npm registry from version 0.20.0. The chain transport uses the platform `WebSocket` where there is one, and the `ws` package on Node.js 20, which has none. --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/reference/symbols This page describes the symbol grammar of the exchange API (`SomniaMarkets`). A symbol names a market; a tradable symbol names something an order can be placed on. Every method that takes a symbol also takes a raw chain reference. ## Grammar | Market kind | Market symbol | Tradable symbols | Example | | ---------------------- | --------------------------- | ----------------- | ---------------------------- | | Spot | `BASE/QUOTE` | the market symbol | `SOMI/USDso` | | Perp | `BASE/QUOTE:SETTLE` | the market symbol | `BTC/USDso:USDso` | | Binary | `ASSET-STRIKE-EXPIRY/QUOTE` | `…#YES`, `…#NO` | `BTC-95000-31DEC26/USDC#YES` | | Categorical (reserved) | `NAME/QUOTE` | `…#OUTCOME` | `US-ELECTION-28/USDC#TRUMP` | The part before `#` follows the conventions of exchange tooling such as ccxt. The `#OUTCOME` suffix is specific to this SDK. `UnifiedMarket.type` is `"spot"`, `"swap"` (a linear perp), `"binary"`, or `"categorical"` (reserved). ## Synthesis rules The SDK synthesises symbols from the indexed market row. - Token codes are the ERC-20 `symbol()` values, stripped to `[A-Za-z0-9.]`. Case is preserved: `USDso` and `USDC.e` keep their casing. - `STRIKE` is the strike as a decimal string with trailing zeros removed. A series market with strike `0` renders `0`, for example `ETH-0-04SEP26/tUSDC`. - `EXPIRY` is `DDMONYY` in UTC, for example `31DEC26`. An expiry that is not at 00:00 UTC appends `-HHMM`, for example `03JUL26-0930`. - A collision between two markets appends `-XXXX` to the base side, where `XXXX` is the last four hexadecimal digits of the market id, upper-cased. ## Raw references Every exchange-API method accepts, in place of a symbol: | Reference | Format | Resolves to | | -------------------- | ------------------------------------- | ----------------------- | | Pool address | 40 hexadecimal digits with `0x` | The market on that pool | | Market id | 40 or 64 hexadecimal digits with `0x` | The market | | BinaryMarket address | 40 hexadecimal digits with `0x` | The binary market | A binary market addressed without an outcome resolves to outcome 0, `YES`. ## Resolution `exchange.market(ref)` returns a `Tradable`: | Field | Type | Description | | -------------- | --------------------- | ------------------------------------------------ | | `market` | `Market` | The native market row. Narrow on `marketType`. | | `marketSymbol` | `string` | The market symbol, without outcome suffix. | | `symbol` | `string` | The tradable symbol. | | `outcome` | `string \| undefined` | `"YES"` or `"NO"` on binary markets. | | `outcomeIndex` | `number \| undefined` | `0` for YES, `1` for NO. | | `pool` | `Address` | The pool that receives orders for this tradable. | `market(ref)` throws `InvalidInputError` when: - a chain reference is unknown, including before `loadMarkets()` has run: `unknown market ref … — call loadMarkets() first`; - a symbol is unknown, including before `loadMarkets()` has run: `unknown symbol … — call loadMarkets() first`; - an outcome suffix is given on a spot or perp market: `… is a SPOT market — it has no outcomes`; - the outcome does not exist: `… has no outcome "X" (has: YES, NO)`. `exchange.symbols` lists every tradable symbol. `exchange.markets` maps market symbols to `UnifiedMarket` rows. Both are populated by `loadMarkets()`. ## Prices and sides per tradable Numbers are expressed in the tradable's own terms. - Spot and perp: price is quote per base; `buy` buys the base. - Binary `#YES`: price is the YES probability in `(0, 1)`; `buy` buys YES shares. - Binary `#NO`: price is the NO probability, `1 − YES`; `buy` buys NO shares. The conversion to the pool's YES-terms book is internal. `mintSet`, `burnSet`, and `redeem` take the market symbol or either tradable; they act on the market. --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/reference/units-and-scales This page lists every numeric convention the SDK uses, which surface uses it, and the helpers that convert between them. The exchange API (`SomniaMarkets`) speaks in human units. The engine (`SomniaMarketsClient`, the trader) and the indexer speak in raw integers. ## Amounts and prices | Convention | Type | Used by | Example | | --------------- | --------------------------------------------------------------------- | -------------------------------------------------------------------- | ---------------------------------- | | Human units | `number` | Every `SomniaMarkets` method and struct | `10` shares, price `0.62` | | Raw token units | `bigint` on writes and chain reads; decimal `string` on indexer reads | `SomniaMarketsClient`, `Trader`, `Market` rows, every `info` payload | `620000n` for 0.62 with 6 decimals | A raw amount is scaled by the token's decimals. The scale differs per market kind: | Market kind | Quantity scale | Price scale | | ----------- | ----------------------------------------- | ------------------------------------------------------------------------------------------- | | Spot | `baseDecimals` of the pair | `quoteDecimals` of the pair (quote per one base) | | Perp | `baseDecimals` | `quoteDecimals` | | Binary | the selected market's collateral decimals | the same collateral decimals; one whole collateral unit prices a YES share at probability 1 | `SpotMarket.baseDecimals` and `quoteDecimals` come from the indexed row. Spot markets are not assumed to have 6 decimals: `SOMI/USDso` has 18 and 18, `WBTC/USDso` has 8 and 18. Binary collateral depends on the venue. The current deployment records show: | Network | Active binary venue collateral | Decimals | `oneCollateral` | | --------------- | ------------------------------ | -------- | --------------- | | Mainnet (5031) | USDso | 18 | `10n ** 18n` | | Shannon (50312) | tUSDC | 6 | `10n ** 6n` | | Hideki (50383) | tUSDC | 6 | `10n ** 6n` | These are current presets, not a rule for every market. A retired mainnet venue used six-decimal USDC.e. Custom venues may choose another token. Read the selected binary market's `quoteDecimals` from its indexed row; `baseDecimals` matches it. If only the `marketId` is available, `(await client.getMarketOnchain(marketId)).decimals` reads the collateral token scale. This method falls back to `DECIMALS` (6) only when the token lacks the `decimals()` view; other read failures throw. Verify a non-standard token's scale before writing. A binding without `indexerUrl` has no indexed `quoteDecimals`. With a known `marketId`, `getMarketOnchain(marketId)` supplies both `decimals` and `pool` for the chain-only order path. Omit `indexerUrl` for chain-only reads and writes; indexed discovery and history need an indexer. [Networks and endpoints](./networks-and-endpoints) lists the hosted indexers. Set `oneCollateral = 10n ** BigInt(decimals)`. Use `probabilityToPrice(0.62, decimals)` for a YES price and `fromHuman(10, decimals)` for an outcome quantity. `mintSet` and `burnSet` amounts also use raw collateral units. ### Grids | Field | Meaning | | ------------- | ---------------------------------------------------------------------------------------------- | | `tickSize` | Raw price step. `UnifiedMarket.precision.price` is the number of decimal places that step has. | | `lotSize` | Raw quantity step. `UnifiedMarket.precision.amount` is its decimal places. | | `minQuantity` | Raw minimum order quantity. `UnifiedMarket.limits.amount.min` is its human value. | `createOrder` aligns price and quantity to these grids before sending. A buy price rounds down, a sell price rounds up, a quantity rounds down. `priceToPrecision` and `amountToPrecision` round down for both sides. ### Conversion helpers All helpers are exported from the root entry and default `decimals` to `DECIMALS` (6). Pass the selected market's decimals for binary writes and displays. The default is only correct for a six-decimal market. | Helper | Signature | Behaviour | | ---------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `fromHuman` | `(human: number \| string, decimals?) => bigint` | Converts a human value to raw. A `number` with more fraction digits than `decimals` is rounded half away from zero: `fromHuman(0.1234567, 6)` is `123457n`. A `string` with at most `decimals` fraction digits is parsed exactly. Throws `InvalidInputError` on a non-finite number. | | `toHuman` | `(raw: bigint \| string, decimals?) => number` | Converts raw to a `number`. Not exact past about 15 significant digits; suitable for display. | | `toHumanString` | `(raw: bigint \| string, decimals?) => string` | Converts raw to an exact decimal string. | | `probabilityToPrice` | `(p: number, decimals?) => bigint` | A YES probability in `[0, 1]` to a raw YES price. Throws `InvalidInputError` outside the range, including `NaN`. | | `priceToProbability` | `(raw: bigint \| string, decimals?) => number` | A raw YES price to a probability. | | `balanceFloor` | `(raw: bigint, decimals?) => number` | The largest human value that converts back to at most `raw` through `fromHuman`. Spending `balanceFloor(raw)` never exceeds `raw`. | | `floorRawBalance`, `ceilRawAmount` | `(raw, decimals, quantum)` | Quantum-aligned floor and ceiling. | ## Basis points | Unit | Scale | Used by | | --------------------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | Basis points (bps) | 1 bps = 0.01 % | Venue fees: `getMaxVenueFeeBps`, `MarketFees`, `getMaxLeverage` limits, `cexRateBps` in portfolio analytics | | Basis points × 1000 | 1,000 = 1 bps | Builder fees: `builderFeeBpsTimes1k` on order placement, `maxFeeBpsTimes1k` on `approveBuilder`, `getMaxBuilderFeeBpsTimes1k` | | Leverage in bps of 1× | 10,000 = 1.00×, 200,000 = 20× | Perp leverage: `getPerpLeverage`, `setPerpLeverage`, `getPerpMaxLeverage` | `5_000n` as `maxFeeBpsTimes1k` allows up to 5 bps. ## Fixed-point rates | Unit | Scale | Used by | | ---------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | Wad | 1e18 | SomniaLend health factor (`maxUint256` when the account has no debt); perp funding rates (`FUNDING_PRECISION`, `1_000_000_000_000_000_000n`) | | Ray | 1e27 | SomniaLend rates and indexes. `lendRayRateToApy(rateRay)` returns a `number` APY; `RAY` is `10n ** 27n`. | | Price feed | 1e18 (`PRICE_FEED_DECIMALS`) | Oracle index prices. `LivePrice.price` and `ema` are `number`; `raw` is the exact string. | A perp funding rate is a fraction per calculation window, 28,800 seconds (8 hours) on every live pool. `fundingRate8h`, `fundingRate1h`, `fundingRatePerInterval`, and `annualizedFundingRate` renormalise it. `UnifiedFundingRate.fundingRate` from `fetchFundingRate` is already per 8 hours as a `number`. ## Time | Unit | Used by | | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Unix seconds, as `string` | Indexer rows: `createdAt`, `lastTradeAt`, `placedAtTimestamp`, candle `bucketStart` | | Unix milliseconds, as `number` | Every `SomniaMarkets` struct (`timestamp`), `datetime` as ISO-8601; `since` parameters of `fetchTrades`, `fetchMyTrades`, `fetchOHLCV`; reactivity schedule timestamps | | Unix milliseconds, as `bigint` | Native `SomniaBlock` consensus timestamps | | Unix nanoseconds, as `bigint` | Order expiry: `expireTimestampNs` on `placeSpotOrder`, `placePerpOrder`, and the batch requests. The SDK default is about 50 years ahead. | Candle intervals: `CANDLE_INTERVALS` is `[60, 300, 900, 3600, 14400, 86400]` seconds. `TIMEFRAMES` maps the exchange strings `1m`, `5m`, `15m`, `1h`, `4h`, `1d` to those seconds. Price-feed candles use `1m`, `1h`, `1d` on the exchange and `M1`, `H1`, `D1` on the client. `UnifiedOHLCV` rows are `[ms, open, high, low, close, volume]`. On `fetchOHLCV` the last element is base volume. On `fetchPriceOHLCV` it is the number of oracle updates in the bucket, not volume. ## Struct conventions | Struct | Convention | | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `UnifiedBalance` | `free === total` and `used` is `0` for spot and binary holdings. Funds locked in a resting order live in the pool, not the wallet, and do not appear. Perp collateral is locked in the MarginBank and does not appear either; `client.getMarginAccount(marginBank, account)` reads it. | | `UnifiedOrder.type` | Present only on the order `createOrder` returns. Absent on orders read from the indexer (`fetchOrders`, `fetchOpenOrders`, `watchOrders`): the pools do not emit the order type. | | `UnifiedStopOrder.type` | Always present: the stop registry emits it. | | `UnifiedOrder.id` | The pool's order id as a decimal string; the transaction hash when nothing rested. | | `info` | Structs that wrap a native row or write result carry the raw payload under `info`. Aggregate and tuple results such as `UnifiedBalance`, `UnifiedBalances`, and `UnifiedOHLCV` do not. | ## Identifiers | Identifier | Format | | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Order id | Decimal string on the exchange API (`UnifiedOrder.id`), on live-store rows, and on indexer rows; `bigint` on trader results and chain reads such as `getOwnOpenOrdersOnchain`. When nothing rested, `UnifiedOrder.id` falls back to the transaction hash. | | Fill id | `${blockNumber}_${logIndex}` | | Trade id in `MarketActivity` | `TRADE:` followed by the fill id | | ERC-6909 outcome id | `(pool << 72) \| (nonce << 8) \| outcomeIndex`; `outcomeId`, `decodeOutcomeId`, `marketKey` encode and decode it | | Market type tag | `bytes4`; `MARKET_TYPE_BINARY_V1` is `0x06c65d9f` | ## Order enumerations | Constant | Values | | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | `ORDER_TYPE` | `LIMIT: 0`, `FILL_OR_KILL: 1`, `MARKET: 2` (immediate-or-cancel), `POST_ONLY: 3` | | `SELF_MATCHING_OPTION` | `CANCEL_TAKER: 0` (SDK default), `CANCEL_MAKER: 1` | | `ORDER_KIND_SIDE` | `["BUY_YES", "SELL_YES", "BUY_NO", "SELL_NO"]`, indexed by the pool's `kind` | | `UnifiedOrderStatus` | `"open"`, `"closed"` (fully filled), `"canceled"` (explicit cancel, or an unfillable immediate-or-cancel remainder), `"expired"` | | `CreateOrderParams.timeInForce` | `"GTC"` (default), `"IOC"`, `"FOK"`, `"PO"` | --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/reference/read-tiers This reference lists the sources used by SDK reads, watches, and status accessors. Use it to budget startup dependencies and requests, and to decide whether a value is fresh enough for your application. [Architecture](../architecture) explains transport ownership. ## The tiers | Tier | Source and freshness | Startup requirements | Cost | | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Market live store | Main-indexer snapshot, or a chain seed on an opted-in watch, then applied chain events. Values reflect the last applied events, not a guarantee of the current head. | A market watch hydrates from the main indexer by default, and needs chain RPC for subscriptions and backfill. `watchMarket(pool, { seed: "chain", marketId })` seeds the book from the pool contract instead, so it starts with the main indexer unreachable. A user watch hydrates indexed history; live updates require watched markets. | Synchronous selectors make no requests. Cold hydration, backfill, ongoing subscriptions, and recovery have network cost. | | Chain | Chain RPC, normally over the owner's WebSocket. Reads reflect the blocks served by the node; a group of reads need not share one block. | A chain WebSocket endpoint and any method-specific contract addresses. | One or more RPC calls, sometimes cached or batched. Parallel calls do not guarantee one round-trip. | | Main indexer | Envio/Hasura GraphQL over HTTP. Values reflect indexed state and can lag the chain. | The main indexer endpoint; aggregate reads can require privileged headers. The live account view also needs the indexer's GraphQL WebSocket endpoint. | One or more HTTP requests, depending on pagination and composition. No fixed freshness delay is guaranteed. | | Price-feed indexer and live price store | Separate price-feed Hasura service. HTTP reads and subscription updates reflect its indexed prices, not direct chain prices. | Price-feed HTTP configuration; watches also need its Hasura WebSocket endpoint. | Fetches make HTTP requests. Watches hydrate over HTTP in batched snapshot requests and share one Hasura socket per client and endpoint. Store selectors are synchronous and make no requests. | `indexerUrl` is optional: a caller that only uses chain reads (smart accounts included) constructs without one, and the main-indexer methods below then throw `NotConfiguredError`. Configuring it does not mean that every method requests the main indexer. The chain socket opens lazily on chain I/O. Price-feed transport and failures are separate from the market store. A result can combine tiers with different timestamps. The SDK does not generally compare indexed state with the current chain head to certify freshness. Check the method-specific inputs below before treating a derived value as current. ## Return contract - An indexer point read resolves to `null` when the row does not exist and throws `IndexerError` when the request fails. - An indexer list read resolves to `[]` when there are no rows and throws `IndexerError` when the request fails. - A chain read throws on failure. It never returns `null` in place of a failure. - A live-store read returns empty data for a market with no active watch. `getWatchStatus(pool)` is `"unwatched"` in that state. - The live store keeps market rows in bounded memory. It always keeps a market that a pool is bound to or that a retained fill or order names. It keeps up to 500 other market rows (released or replaced markets) and evicts the earliest-discovered ones first. An evicted market is absent from `getLiveMarkets` and `getLiveMarketByAddress`, and a late settlement event for it does not change the live store. Read it from the indexer (`getMarket`, `getBinaryMarket`). - A chain-seeded watch loads no fill history, so `getLiveFills` can be empty while the book is complete. `getLiveStatus().fillsLoaded` separates the two states: `false` means the history was never loaded, `true` means there are genuinely no fills in the loaded range. A failed fill read sets `failure.operation` to `"fill-history"` and does not empty the book. - Orders a chain seed writes carry an empty `createdAt` and `txHash`: placement history is indexer-only and the seed does not invent it. `userOrders` sorts on `createdAt`, so seeded orders sort LAST behind any order carrying a real placement timestamp. A chain-seeded pool is not re-snapshotted while the watch lives, so they stay undated for the store's lifetime; read placement history from the indexer. - The market row a chain seed writes carries only what the module answers. Indexer-only fields — `question`, `asset`, `oracleQuestion`, `strike`/`mode`, `tradingStart`, `intervalSec`/`interval`, `createdAtTimestamp`/`createdAtBlock`, `createdByTx`, the volume counters, and the origin attribution — are empty, `null` or zero and STAY that way: a seeded pool is not re-hydrated by a later watch, and the row outlives `stop()` until cold-market eviction drops it. Read the indexer directly (`getBinaryMarket`) when those fields are needed. `strike`/`mode` are `null` rather than `"0"`/`"reference"`, because a strike of 0 is the protocol's sentinel for an up/down market and would mislabel a fixed-strike one. - A chain seed never blanks a row the indexer already supplied. Inside the cold-market allowance the row outlives `stop()`, so a pool watched from the indexer first and chain-seeded later keeps each indexer-only field listed above, while every field the module answers — `status`, `backing`, `nonce`, `finalized`, `voided`, `winningOutcome`, `collateral`, the decimals and the token ids — takes the fresher chain value. - `getLiveStatus()` reports `seedSource` (`"indexer"`, `"chain"`, or `null` before the first watch), `seedBlock`, and `seedTruncated` for the MOST RECENT seed only. With several watched pools those values cannot be attributed to a pool: a later seed overwrites them, and `fillsLoaded` turns true when any pool's fill lane lands. Read `getSeedStatus(pool)` for one pool's own four fields; it is `null` when that pool has no hydrated watch. The indexer snapshot applies a PER-SCOPE order budget that is SHARED across the scope's pools, not a per-pool floor, and it drops the oldest resting orders first. A chain seed has no such cap; `seedTruncated` is `true` only when the drain reaches its own page budget. - `watchMarkets` selects markets by lifecycle, not by age. It hydrates every SPOT and PERP market and every BINARY market that is not finalized. A finalized BINARY market is left out of the watch. Read it with `getBinaryMarket(id)`. `watchMarket(pool)` applies no lifecycle filter, but it hydrates the market the pool serves now, so it hydrates a finalized market only until the pool is recycled. One snapshot hydrates at most 500 eligible markets, newest first. When more eligible markets exist, `getLiveStatus().allMarketsTruncated` is `true` while the all-markets watch is active, and the oldest eligible markets are not watched. Only the all-markets watch writes that field, so a later pool or chain seed does not reset it. - A live price read keeps answering with its last values after the server rejects the asset's subscription. `getPriceStatus(asset)` is `"error"` in that state, and the rejection is also emitted as a `warn` event on the debug channel. ## Exchange API (`SomniaMarkets`) | Method | Tier | | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `loadMarkets`, `fetchMarkets` | Indexer, plus chain reads for perp discovery and token symbols. Neither source's failure throws: the registry read reports through `registryReadError` and perp discovery through `perpDiscoveryError`, so one source being down still returns the other's markets. | | `fetchOrderBook` | Chain | | `fetchTrades`, `fetchOHLCV`, `fetchOpenOrders`, `fetchOrders`, `fetchMyTrades`, `fetchOpenStopOrders`, `fetchPortfolioAnalytics`, `fetchFundingRateHistory` | Indexer | | `fetchTicker` | Cached market metadata and main-indexer candles; on perps, also best-effort chain state. An EMPTY candle window falls `last` back to the cached market row, which stamps `lastSource` from that row's `lastTradeAt`; `lastSource` is absent only when `last` itself is absent or that timestamp is null. A candle request FAILURE is CONTAINED: `statsState` becomes `"unavailable"`, every 24h field is absent, and the call still resolves so the perp chain numbers survive. A market with `indexed: false` reports the same state and sends no candle request. `statsAsOf` carries the 24h window's own as-of; `timestamp` remains response time, not source freshness. A failed perp state read leaves candle-derived data and reports `perpState: "degraded"`. | | `fetchBalance` | Loads market/currency metadata through `loadMarkets`, then reads chain token/native balances and main-indexer binary holdings. Cost depends on currencies and metadata discovery. | | `fetchFundingRate` | Chain | | `fetchPositions` | Chain per position, but the call first awaits `loadMarkets`, so it depends on a loaded perp registry on the first call and on every call while the registry is empty. That registry is an INDEXER dependency only when no `addresses.perpPoolFactory` is configured: with one, `loadMarkets` discovers the perps from the factory on chain. Called with no refs, it returns positions only for the perps the registry loaded: a short perp set gives a silently short result, not an error. Explicit refs do not remove the dependency — `loadMarkets` is awaited either way — but they turn a silently short result into a loud `InvalidInputError` for a symbol the registry never loaded. | | `fetchPrice`, `fetchPriceOHLCV` | Separate price-feed Hasura HTTP | | `market`, `priceToPrecision`, `amountToPrecision` | Local | | `watchOrderBook`, `watchTrades`, `watchOrders`, `watchMyTrades` | Live store. The first call opens the market watch. | | `watchPrice` | Live price store. The first call opens the price watch. | A `watch*` call resolves with the current value on its first call and on every later call resolves when the channel's value changes. `unwatchMarket(symbol)` releases one market's watch and its channels. A pending `watch*` call on that market rejects with a `DOMException` named `AbortError`. The next `watch*` call opens a fresh watch. ## Engine (`SomniaMarketsClient`) The tables in this section are the canonical Engine inventory. Names refer to `exchange.client` (or `exchange.engineFor(chainId)` — `exchange.client` names no chain and throws `InvalidInputError` on an exchange spanning more than one), not similarly named Exchange methods. The inventory guard checks membership only; source and freshness claims require source review. Configuration, cleanup, escape hatches, and capability factories are outside this read inventory. ### Live store | Group | Methods | | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Market selectors and status | `getLiveMarkets`, `getLiveMarketByPool`, `getLiveMarketByAddress`, `getLiveFills`, `getLiveUserFills`, `getLiveUserOrders`, `getLiveFundingUpdates`, `getLiveBinaryOrderBook`, `getLiveBinaryOrderBookByMarket`, `getLiveSpotOrderBook`, `getLiveStatus`, `getSeedStatus`, `getWatchStatus`, `isTailing`, `subscribeLive`, `listLiveLiquidations`, `listLiveFundingPayments`, `getPerpPlaneStatus` | | Price feed | `getLivePrice`, `getLivePrices`, `getLivePriceTicks`, `getLivePriceFeedInfo`, `getPriceStatus`, `subscribePrices` | | Watches | `watchMarket`, `watchMarkets`, `watchUser`, `watchPrice`, `watchPrices`, `watchPerpMarkets` | `getLiveUserOrders` keeps every unexpired open order, up to 400 recent unreferenced terminal orders per pool, and terminal orders needed by retained fills. Use `getOrders` for older order history. On-chain expiry emits no event, so each chain head is the retention checkpoint for expired orders. At the first head whose timestamp is strictly after an `Open` order's expiry, that order counts toward the 400-row cap. It keeps status `Open`: the chain holds its escrow until a cancel or a sweep, so removal from the live store does not mean a refund. An active `watchUser` keeps every expired open order of its user, including orders placed after the watch started, until the watch is released. A later cancel or sweep event for a removed order changes nothing. Accepted indexed `Closed` orders count toward the cap at hydration. Live `Closed` orders wait for a later block from the same pool so a delayed same-block `OrderRested` can still make them open. ### Chain | Group | Methods | | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Books and orders | `getBinaryOrderBook`, `getSpotOrderBook`, `getOrderOnchain`, `getBookTopsOnchain`, `getOwnOpenOrdersOnchain`, `getOpenOrdersOnchain`, `getAllOpenOrdersOnchain` | | Markets and system | `getMarketOnchain`, `getPoolMarketBinding`, `getMarketIdOnchain`, `getContractMeta`, `getHeadBlock`, `getSystemInfo`, `getTransactionSummary`, `getPoolCreator`, `getFreePools`, `getOnchainResolutionPrice`, `getMaxVenueFeeBps`, `encodeBinaryVenueFeeParams` | | Balances and allowances | `getErc20Balance`, `getErc20Metadata`, `getErc20Allowance`, `getBalances`, `getNativeBalance`, `getOutcomeBalance`, `getVaultBalance`, `getOwnLockedBalance`, `getLockedTokenBreakdown` | | Operators and builders | `getOperatorPermissionsRegistry`, `isOperatorAuthorized`, `isGloballyApproved`, `isApprovedForPool`, `getMaxBuilderFeeBpsTimes1k`, `getBuilderApproval`, `getEffectiveBuilderApproval`, `getStopOrderSomiPayment`, `getPerpStopOrderSomiPayment`, `getPerpStopOrder`, `getPerpStopOrders`, `getUnclaimedPerpStopSomi`, `getManualVaultMode`, `getAutoPullRequirement`, `convertToQuoteAtPriceCeil` | | Perps | `getPerpState`, `getPerpFeedStatus`, `getPerpPosition`, `getMarginAccount`, `getAccountHealth`, `getAccountEquity`, `listAccountEquities`, `getLiquidationPrice`, `previewPerpLiquidationPrice`, `getPerpLeverage`, `getPerpPositionAnalytics`, `listPerpPositionAnalytics`, `getMaxPerpOrderSize`, `previewPerpOrderMargin`, `previewPerpClosePnl`, `getPerpSideHolders`, `getBankruptcyPrice`, `getPerpSystemConfig`, `getInsuranceFundState`, `getLiquidationEngineConfig`, `tryGetPerpAccountEquity`, `getPerpCollateralBasis`, `listPerpPoolStatuses`, `listTradeablePerpPools`, `isPerpPoolRegistered`, `getPerpRiskParams`, `getPerpHealthSnapshot`, `getEffectiveImfBps`, `getPerpMaxLeverage`, `getPerpLeverageImSurcharge`, `tryGetPerpLeverageImSurcharge`, `readPerpMarketFromChain`, `quotePerpFundingPayer`, `getPerpMainFunding`, `getPerpLinkedWalletRegistry`, `getPerpWalletPullCapacity`, `getPerpWalletLinkage`, `listPerpLinkedChildren`, `getPerpMaxLinkedChildren`, `getPerpVoucherCampaign`, `getPerpVoucherEligibility`, `getPerpVoucher`, `getPerpFundingPremium`, `meetsPerpImForFill`, `quoteMeetsPerpImForOrder`, `quotePerpOrderTopUp` | | Oracle hub | `getSchedulingCost`, `earmarkedOf`, `creditOf`, `outstandingOf`, `withdrawableOf`, `payerCreditOf`, `payerOf`, `resolveReserve`, `quoteCreateMarketValue` | | SomniaLend | `client.lend.listReserves`, `client.lend.getAccount` | | Smart accounts | `client.accounts.find`, `client.accounts.nextFreeIndex`, `client.accounts.ownersOf`, `client.accounts.custodyChain`, `client.accounts.authorityPath`, `client.accounts.tree`, `client.accounts.holdings`, `client.accounts.health`, `client.accounts.buildExecution` | ### Indexer | Group | Methods | | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Markets | `listMarkets`, `listRegistryMarkets`, `listRegistryMarketsChecked`, `countMarkets`, `countMarketsBounded`, `getMarket`, `getMarketByPool`, `listMarketsByPool`, `listBinaryMarkets`, `listLiveBinaryMarkets`, `listPastBinaryMarkets`, `getBinaryMarket`, `getBinaryMarketByAddress`, `listBinaryVenueIds`, `listBinaryAssets`, `countBinaryMarkets`, `countBinaryMarketsBounded`, `listSpotMarkets`, `getSpotMarket`, `listPerpMarkets`, `getPerpMarket`, `getMarketFees`, `getMarketStatusHistory` | | History | `getCandles`, `getFills`, `getFill`, `getUserFills`, `countUserFills`, `getOrderFills`, `getOrder`, `getOrders`, `getOpenOrders`, `countOrders`, `getBookTops`, `listSweepableOrders`, `getMarketActivity`, `getTradeContext`, `getRouterActions`, [`getUserFillsPage`](./fill-history-pages), [`getUserFillHistory`](./fill-history-pages#keep-a-held-history-current), `getFillsPage`, `getLatestFillBlock`, `getLatestActiveBlock` | | Portfolios | `getPortfolio`, `getSpotPortfolio`, `getPerpPortfolio`, `getOutcomeBalances`, `getSpotStopOrders`, `listPerpStopOrders`, `listPerpOrderHistory`, `listPerpPositions` | | Resolution | `getMarketResolution`, `listMarketResolutionEvents`, `listMarketReferenceLinks`, `getOpeningPrices`, `getResolutionPrices` | | Fees and perps history | `listProtocolFees`, `listBuilderFees`, `listSettlementFees`, `listBuilderApprovals`, `getVaultPayoutFallbacks`, `listVaultPayoutFallbacks`, `getFundingPayments`, `getMarginEvents`, `listLiquidations`, `getLiquidations`, `listFundingRateHistory`, `listFundingRateCandles`, `listPerpFees`, `getOpenInterestHistory`, `getFundingRateHistory`, `listPerpOrderRejections`, `listPerpPositionChanges`, `listPerpPositionChangesForFills`, `listPerpInsuranceFundEvents`, `listPerpWalletLinkEvents`, `listPerpMarginPulls`, `listPerpMainFundingEvents` | | Control plane | `listOperators`, `getOperator`, `countOperators`, `listVenues`, `getVenue`, `countVenues`, `listMarketCreators`, `getMarketCreator`, `listOracleAdapters`, `getOracleAdapter`, `listSeries`, `listSeriesLatestMarkets`, `getSeries`, `getOracleQuestion`, `listOracleQuestions`, `getOperatorHubAccount`, `listOperatorHubAccounts`, `listOracleBinds`, `listOracleCallbacks`, `getSyncStatus`, `getPool`, `listPoolBindings` | | Smart accounts | `client.accounts.getIndexedAccount`, `client.accounts.listDerivedAccounts`, `client.accounts.listControlledAccounts`, `client.accounts.listOwnerHistory`, `client.accounts.getActivityPage`, `client.accounts.getIndexerStatus`, `client.accounts.listModuleHistory`, `client.accounts.getCreationAuthorization`, `client.accounts.getFactoryState`, `client.accounts.getAccountsPage`, `client.accounts.getNetworkActivityPage`, `client.accounts.getFactoryHistoryPage` | ### Price-feed HTTP reads | Group | Methods | | -------------------------------- | ------------------------------------------------------------------- | | Current prices and feed metadata | `fetchPrice`, `fetchPrices`, `fetchPriceFeedInfo`, `listPriceFeeds` | | Price history | `fetchPriceHistory`, `fetchPriceCandles` | ### Mixed and derived reads | Inputs and behavior | Methods | | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | | Main indexer plus a mandatory chain block timestamp lookup. The timestamp anchors indexed activity; it does not make indexed rows current. | `getBlockActivity`, `getAdjacentActiveBlocks` | | Main-indexer activity. Chain receipt and block lookups run only when no indexed event and no caller-supplied anchor place the transaction. Failure to obtain that anchor leaves the indexer order probe available. | `getTransactionActivity` | | Synchronous live binary book and stored market decimals. Needs a hydrated market watch for useful depth. | `quoteBinaryOrder` | | Live binary book plus cached chain tick/lot grid. A market-id target can add an indexer lookup when the store cannot resolve its pool. An empty live book is not replaced by an indexer book. | `quoteBinaryStake`, `quoteBinarySell` | | Chain grid read cached per pool for the client lifetime. Later administrative grid changes do not invalidate this cache. | `getBinaryBookParams` | | Chain closing-price view, including its capture state. | `getClosingPrice` | | Main-indexer candles; a market-id target also resolves its pool through the main indexer. | `getMarketStats24h` | | Main-indexer market metadata, fills, router actions, and outcome balances, plus chain book top for the mark clamp. Without configured chain access, uses indexed last price alone; an operational chain failure propagates. | `getBinaryPositionPnL` | | Main-indexer positions and market metadata, fills, and router actions, plus chain book tops for the mark, read at one pinned block. Without configured chain access, uses indexed last price alone; an operational chain failure propagates. This does not use the live book. | `getOpenPositionsWithPnL` | | Main-indexer positions and resolution data, plus indexed settlement fees for winning markets. This estimates claimable amounts from indexed state. | `getClaimable`, `getClaimablePage` | | Main-indexer account view of event-contract positions, as a guarded snapshot: two version reads bracket the row read, and the rows count only when the version did not move. Concurrent snapshot callers for one account share one read with independent cancellation, and a ready live view is reused without a request. The version certifies completeness, not freshness against the chain head. Until the indexer enables the view on a chain, the result is `unavailable`. | `client.accountOutcomePositions.getSnapshot` | | The same view, followed live: successful reads poll the account's version every 5 s and repeat the guarded snapshot when it moves. Recoverable failures retry after 5, 10, 20, 40, then 60 s, capped at 60 s. Unavailable capability rechecks use HTTP every 5 minutes, without the orders view's capability subscription. A missing indexer endpoint is terminal (`failed.retrying: false`). Recovery restores the 5 s cadence. Held data's clock-derived status updates are checked every 5 s during backoff. `tooLarge` watches the version without rereading unchanged oversized rows. The reader remains active for 30 s after the last unsubscribe; `stopLive()` or owner cancellation ends it immediately, including during reuse, and active subscribers receive a final `closed` view. | `client.accountOutcomePositions.subscribe` | | Independent RPC head, then main-indexer metadata plus processed-block time. Raw measurements, no SDK stale threshold. An indexer more than 10,000 blocks past the head follows an earlier genesis of the chain id, so the read throws. | `getIndexerFreshness` | | Main-indexer metadata, polled until the target block. For the client's own chain, one RPC head read when the wait would resolve; an indexer more than 10,000 blocks past that head rejects the wait. Without configured chain access, the head read is skipped. | `waitForIndexedBlock` | | One independent head for response-associated reads, using the same owner. | `createObservedReads` | | Per-asset price watch lifecycle, without I/O. | `getPriceHealth`, `listPriceHealth` | | Main-indexer account view, as a guarded snapshot: two version reads bracket the row read, and the rows count only when the version did not move. The version certifies completeness, not freshness against the chain head. Stop venues come from main-indexer market rows; a perp LIMIT stop's limit price comes from its chain registry at the head, so that read opens the chain socket when it is closed. It never sends `indexerHeaders`. | `client.accountOrders.getSnapshot` | | Main-indexer account view, followed live: one guarded snapshot, then the account's change stream over one indexer GraphQL WebSocket per client. After 10 s with no frame on that socket the client pings the indexer, and it closes the socket when nothing answers within 10 s more; the deployed indexers ping every 5 s, so the client normally sends none. Each batch of changes costs one HTTP read for its rows; batches that queue together share one. A new stop's venue and limit price are `pending` until the SDK reads them, as a snapshot does, and a failed read is tried again up to three times. The view keeps its last data as `stale` while it recovers, and never sends `indexerHeaders`. With a chain socket, the stops also follow the stop registries' events, ahead of the indexer: one `eth_subscribe("logs")` over every registry the main-indexer market list names (one request per market type at most once a minute, for the newest 1,000 markets of each), one `eth_getLogs` over at most 1,000 blocks each time it opens, and one block read per block of the account's stop changes. | `client.accountOrders.subscribe` | | Chain account view of perp positions: the MarginBank's own list of the account's pools, each position, its pool's price and health, the account's resting orders in those pools, and the account's margin, all at one pinned block, in up to three Multicall3 calls, or one when a previous read named the pools and orders. Each pool's order ids are one more call, sent as the account, because the pool answers for its caller. Position figures are the MarginBank's own math; liquidation prices are solved from the same block. No indexer. | `client.accountPositions.getSnapshot` | | Chain account view of perp positions, followed live: the same one-block read, pushed by the node (`somnia_watch` on the chain socket, one Multicall3 call per push) when the MarginBank emits an event naming the account in topic 1, 2 or 3 (one subscription each), and on every price write of `addresses.priceFeedScheduler` while the account holds positions. The client reads for the first view, once each time the push opens, after a new pool or order or a failure, and every 5 s when pushes stop. A node that does not push leaves the client reading on the account's events (`eth_subscribe("logs")`) and every 5 s. A failed read keeps the last data as `stale`. | `client.accountPositions.subscribe` | | Chain wallet balances of several accounts: `balanceOf` for each token and Multicall3's `getEthBalance` for the native token, all at one pinned block in one Multicall3 call, or one call per balance without Multicall3. No indexer. | `client.accountBalances.getSnapshot` | | Chain wallet balances of several accounts, followed live: account/token sets share work within one owner regardless of order, duplicates, or native/sentinel labels; each caller retains its requested order and labels. The same one-block read is repeated at once when one of the tokens emits a `Transfer` from or to one of the accounts (two `eth_subscribe("logs")` on the chain socket, the accounts as sender and as receiver), and every 10 s, because a native balance changes with no event. A failed read keeps the last data as `stale`. | `client.accountBalances.subscribe` | | Chain account view of resting spot orders: every spot pool's own list of the account's order ids, one call per pool sent as the account, then the orders' details in one Multicall3 call (one call per order without Multicall3), all at one pinned block. Orders a previous read found ride the first round. Which pools are spot pools comes from the main indexer's market rows, discovered again on each full refresh; nothing about the account does. Failed or unproven empty discovery throws an IndexerError rather than asserting an empty account. A pool whose orders cannot be read is listed in `data.unreadPools` with its error, and its orders are unknown; a read that can read no pool fails. | `client.accountSpotOrders.getSnapshot` | | Chain account view of resting spot orders, followed live: the same read, of the one pool that emitted it, at once when a spot pool emits an order event that concerns the account. `eth_subscribe("logs")` on the chain socket: every spot pool's `OrderPlaced`, whose data names the owner, and the later events of the account's own order ids (a fill, as taker or maker, a cancel, expiry, reduce or amend, including in-place amendments); past 1,000 orders, every order event. Every pool once the node confirms a subscription, and 30 s after the last read of every pool. A pool that cannot be read is read again after a backoff; a read that can read no pool keeps the last data as `stale`. Failed or unproven empty discovery retains known orders and subscriptions with `data.complete: false`, even if no pool is unread. Pool-specific updates cannot restore complete coverage until full discovery recovers. | `client.accountSpotOrders.subscribe` | | Chain ADL queue of one side of one perp market: the MarginBank's wiring, the engine's ranker and scan cap, the pool's status and every holder's state, all at one pinned block, then the armed ranker's `rank()` over the holders the engine would consider, at that block. No indexer. | `client.perpAdlQueue.getSnapshot` | | Chain ADL queue of one side, followed live: the same one-block read, repeated at once when the MarginBank reports a change that can move the queue (three `eth_subscribe("logs")` on the chain socket: the bank's position, takeover, settlement and funding events that name the pool, and the events that change any account's balance: deposits, withdrawals, credits, linked funding and close-out payments. Order placement and cancellation are left out: they move equity too, but come several times a second), and every 30 s, for a subscription that stops without an error and for what moves the order with no followed event. A failed read keeps the last data as `stale`. | `client.perpAdlQueue.subscribe` | ### Watch startup and updates Engine watches return a handle with `stop()`. Repeated handles for the same scope share its work. The scope normally remains for about 30 seconds after the last handle stops, so a quick remount can reuse it. After teardown, a new watch hydrates again. Market watches hydrate a main-indexer snapshot before chain backfill and live updates. `watchUser` hydrates the account's indexed order/fill history. It opens no subscription of its own. Subsequent account updates come only from market scopes watched by this client; it is not an account-wide chain subscription. A live account view (`client.accountOrders.subscribe`) starts `loading`, reads a guarded snapshot as `getSnapshot` does, and goes `ready` when the snapshot is accepted. Then it follows the account's change stream from the snapshot's version. The indexer pushes subscription updates at its own interval, 5 s on the deployments measured, so a change can reach the view that long after the indexer processes its block. The stream replays nothing that a lost connection missed. When the connection breaks, or a batch does not follow the version the view holds, the view goes `stale` with its last data, and the SDK reads a new snapshot after a backoff. An `unavailable` or `tooLarge` view stays under watch: the SDK reads again when the capability or the account changes, when the earliest order expires, and every 5 minutes. Listeners of one account share one session, which remains for 30 seconds after the last listener stops. Price watches hydrate metadata and recent prices from the separate price-feed HTTP service. Each asset key owns a Hasura socket for its price and tick subscriptions. A resolved watch means its snapshot landed, not that the socket has connected. Check `getPriceStatus` for state. Rejected subscriptions report `error`, while synchronous price reads retain stale values. These failures do not change market-watch status. ## React hooks | Hook family | Tier | | --------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `useLive*`, `useWatchMarket`, `useWatchUser`, `useLiveStatus`, `usePoolSeedStatus`, `useIsTailing` | Live store. The pool-keyed hooks open the market watch while mounted; `usePoolSeedStatus(pool)` reads one pool's seed state without opening a watch. | | `useLivePrice`, `useLivePriceTicks`, `useLivePriceFeedInfo`, `useWatchPrice` | Live price store | | `usePortfolio`, `useMarkets`, `useCandles`, `useFundingRateSeries`, `useMarketFees`, `useOperators`, `useMarketCreators`, `useOracleAdapters` | Indexer | | `useLendReserves`, `useLendAccount` | Chain | `useIndexerQuery` runs the supplied callback on mount, owner or dependency changes, and explicit `refetch`. It does not poll or automatically refresh on live events. The callback determines its source and freshness. The hook passes an abort signal and fences superseded responses; cancellation reaches the request only if the callback forwards that signal to an operation that supports it. Automatic hooks preserve background acquisition semantics: failures use the configured debug/console warning channel and do not throw during render. This includes `useFundingRateSeries`; its `state.error` still contains history query failures. Opt into `useWatchMarketResult`, `useWatchUserResult`, or `useWatchPriceResult` for a typed acquisition error, which a component may explicitly throw into an ErrorBoundary. Missing-provider validation still throws `InvalidInputError`. `SomniaMarketsProvider` accepts `chainSeedPools`, a fixed list of `{ chainId, pool }` for binary pools that must cold-start from chain. Every pool-watching React hook shares this choice, so effect order cannot choose the indexer seed first. The provider reads the current market binding via chain before opening a selected watch; the selected client must configure `addresses.binaryModule`. Remount the provider to change the list. Other pools use the normal indexer seed. The pool-specific `usePoolSeedStatus(pool, { chainId? })` returns `null` until that pool has seeded, then exposes `seedSource`, `seedBlock`, `seedTruncated`, and `fillsLoaded`. A chain-seeded book can be available with `fillsLoaded: false`; indexed history and market discovery still require the indexer. ## Response-associated indexer observations `client.createObservedReads()` observes one independent RPC head. Its `read` and `batch` methods accept the operations in `ObservedReadOperation`. The roster includes every ordinary main-indexer domain read, including mixed-tier derived reads. It leaves out `getUserFillHistory`: its result keeps rows from earlier calls, so no response watermark of one call describes it. `getSyncStatus`, `waitForIndexedBlock` and `getIndexerFreshness` are source diagnostics, not row reads. Live-store reads, chain-only reads, write capabilities and the separate price service are excluded. The envelope keeps the domain value in `data`. `observation.latestProcessedBlock` is the minimum metadata watermark across successful contributing responses. Missing metadata in any response leaves it null. `startedAt`, `completedAt` and `responseCount` describe the observation span. The metadata is selected beside the rows in the same GraphQL request, without a separate metadata request. A rejected privileged aggregate attempt does not contribute; its successful fallback does. Observed portfolio reads refresh their scope inside the logical read so a cached pool set cannot hide an older contributing response. These are indexing watermarks. They do not claim each entity changed at that block, that the RPC itself is current, or that separate requests share an atomic historical snapshot. Keep an envelope with its value; later reads cannot update its observation. Create a new observed capability to refresh the shared head. Closing the owner cancels pending observations; a stopped capability cannot reopen transports. `fetchStatus()` remains a local-only three-field chain-tail snapshot. Opt into `fetchDataStatus()` to read RPC and the main indexer once. Its top-level `status` retains chain-tail meaning; `indexer` contains independent measurements and `prices` lists precise per-asset `health`. API-only monitors keep using `client.getSyncStatus(chainId)` with no RPC dependency. `client.waitForIndexedBlock(chainId, block)` polls the same metadata until the indexer has processed a block, so a caller can read its own write instead of guessing a delay. The concrete `exchange.client` exposes the additive `SomniaMarketsClientWithObservations` contract. `SomniaMarketsClient` includes `waitForIndexedBlock`, and order-list selections include `userData`. Custom `SomniaMarketsClient` implementations must provide the wait, and a serving indexer must expose `Order.userData`. Other ordinary reads retain their document selections and role permissions. Only observed reads select the response watermark. `stopLive()` invalidates observed capabilities and cancels pending indexed-block waits without aborting ordinary pending indexer reads; a later wait relinks to the replacement owner signal. Caller cancellation still reaches those reads. ### Consumer example Use the configured exchange in dreamDEX or another consumer. The downstream dreamDEX hook edit remains outside this repository. ```ts const observed = await exchange.client.createObservedReads(); const [markets, count] = await observed.batch([ { operation: "listMarkets", args: [{ limit: 20 }] }, { operation: "countMarkets", args: [] }, ]); console.log(markets.data, markets.observation.latestProcessedBlock); console.log(count.data, count.observation.independentHead.blockNumber); const freshness = await exchange.client.getIndexerFreshness(exchange.client.config.chain.id); console.log(freshness?.lagBlocks, freshness?.lagSeconds); ``` For a local end-to-end observation, start `./demo-clob.sh up`, construct the exchange with the local chain and deployment configuration used by the explorer, and run this example against `http://127.0.0.1:8085/v1/graphql` and `ws://127.0.0.1:8545`. Use chain id 31337. No production configuration or transaction is required. ## What still works when the main indexer is down This section tells you which SDK capabilities survive a main-indexer outage. Use it to plan degraded operation. It groups capabilities by what they need, not by method name. The tier tables above remain the per-method reference. Source-derived at `ebe97c4874b5394a3f9bc8cb2ccd9f46b1b1bda8`; no outage was induced. The scope is the main markets indexer. The price-feed service is a separate endpoint with its own configuration and its own failures, so a main-indexer outage does not stop `fetchPrice`, `fetchPriceOHLCV`, or the price watches. A chain RPC endpoint must still be reachable for everything in "Works on chain". ### Works on chain These reads need chain RPC only. They do not send a main-indexer request. One precondition applies to every UNIFIED verb below, the ones named `fetch*`. They take a symbol, and resolving a symbol reads the in-memory registry that `loadMarkets` fills from the indexer. So they answer during an outage only for markets loaded BEFORE it began; on a cold start they throw `InvalidInputError` for an unknown symbol. The Engine reads, which take a pool or market address, have no such precondition. | Capability | Methods | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | Order books | `getBinaryOrderBook`, `getSpotOrderBook`, `getBookTopsOnchain`; `fetchOrderBook` too, but only for a symbol the registry already loaded | | Own orders on chain | `getOwnOpenOrdersOnchain` (ids), `getOpenOrdersOnchain` (full rows, several pools, one block), `getOrderOnchain` (one order), `getAllOpenOrdersOnchain` | | Perp positions and state | `getPerpPosition`, `getPerpState`, `getMarginAccount`, `getAccountHealth`, `getAccountEquity`, `listAccountEquities`, `getLiquidationPrice` | | Balances | `getErc20Balance`, `getNativeBalance`, `getBalances`, `getOutcomeBalance`, `getVaultBalance` | | Markets and system by id | `getMarketOnchain`, `getSystemInfo`, `getHeadBlock`, `getContractMeta`, `getPoolCreator` | | Pool to market binding | `getPoolMarketBinding`, `getMarketIdOnchain` | | Settlement and provenance | `getSettlement`, `poolCreator` | | Cancel every resting order | `cancelAllOrders` (default `source: "chain"`) | `cancelAllOrders` is the kill switch. Its default source reads the caller's resting order ids from the pool contract, so it works with the main indexer down. Two limits apply. The method belongs to `Trader` and acts on one pool, and the unified facade has no equivalent verb. The caller must therefore already hold the pool address: spot pool discovery by symbol is indexer-only, so a cold start during an outage cannot resolve one. ### Works from the live store while a watch is live A hydrated watch keeps answering from its store, because its updates arrive over chain subscriptions. - `getLive*` selectors, including `getLiveUserOrders`, `getLiveFills`, and the live order books. - `cancelAllOrders({ source: "live" })`. This mode throws `NotConfiguredError` unless the watch covering the pool is live. A watch that is still hydrating or is reconnecting is not live. A binary watch can also START with the main indexer down. Call `watchMarket(pool, { seed: "chain", marketId })`, which seeds the book from the pool contract. It covers one pool per call, so N pools need N calls. Read the `marketId` that call needs with `getPoolMarketBinding(pool)`, which is also chain-only, so the whole cold start needs no main-indexer request. The discover-all watch cannot start, because it has no binding list to seed from. ### Unavailable by design These capabilities are main-indexer reads. They throw `IndexerError` while it is unreachable. Absence of data is never substituted for the failure. - All history: candles, fills, fees, funding payments, liquidations, and market activity. - `getOpenOrders` and the other indexed order reads. - Portfolios and PnL: `getPortfolio`, `getSpotPortfolio`, `getPerpPortfolio`, `getBinaryPositionPnL`, `getOpenPositionsWithPnL`, `getClaimable`. - The discover-all watch, `watchMarkets`. - Every freshness surface: `getSyncStatus`, `getIndexerFreshness`, and `fetchDataStatus`. `fetchStatus()` is local and still answers. - On an exchange with two or more chains, `fetchDataStatus` resolves when some chains fail. It reports each failed chain's error in `indexerErrorByChain`, and it throws only when every chain fails. ### One whole-call failure One unified method fails completely, although part of its result comes from chain. Use the substitute instead. `fetchTicker` used to belong here. Its candle read is now contained, so the call resolves with `statsState: "unavailable"` and still reports the perp chain numbers. | Method | Why it fails | Substitute | | -------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------- | | `fetchBalance` | `getPortfolio` runs inside `Promise.all` with no catch. | Use `getErc20Balance`, `getNativeBalance`, or `getBalances` for the chain-held balances. | --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/reference/fill-history-pages `exchange.client.getUserFillsPage(account, options)` reads one bounded page from the configured Envio/Hasura indexer. `fills` retain the existing descending numeric `(timestamp, blockNumber, logIndex)` order. `nextCursor` is opaque and binds the chain, endpoint, normalized account, filters, sort position and `datasetGeneration`. A null cursor means this read observed no further row; it does not certify full historical coverage. Set `options.marketType` (`"SPOT"`, `"PERP"` or `"BINARY"`) to read one market kind. The indexer filters on the Fill's own `marketType` column. Omit it to read every kind. The kind is part of the cursor scope: a cursor from one kind, or from no kind, is invalid input for another. `datasetGeneration` comes from the `FillDataset/current` row in the same serving database as `Fill`. A preset block handler creates the row at the first indexed block, including on a database with no fills. The generation is a deterministic digest of the generated deployment config excluding RPC transport, entity schema, referenced ABIs, generated pool metadata, and production indexer source. Identical fresh replays keep the same generation; a fresh schema replay with changed hashed producer inputs rotates it. A code-only resume does not rewrite the row and is not a valid rollout for handler changes that affect stored fill data: those changes require a reindex. Blue and green slots have separate databases, so a same-hostname cutover can change the generation when their hashed producer inputs differ. All Hasura replicas for one slot read the same row. The SDK reads the row and page in one GraphQL request. If the row or field is absent, the page call fails with `FillHistoryUnavailableError` rather than returning an apparently valid page. Continuing a cursor from another generation throws `FillHistoryRestartRequiredError` with `cursorGeneration` and `servingGeneration`. Discard **every** accumulated page and request page one with the same scope and no cursor. A legacy cursor without a generation also requires a restart. A cursor from another account, chain, endpoint or filter scope is invalid input, even if its generation matches. New fills above an unchanged cursor still belong to a new page-one read. ```ts import { FillHistoryRestartRequiredError } from "@somnia-chain/markets-sdk"; try { const next = await exchange.client.getUserFillsPage(account, { limit: 100, cursor }); pages.push(next.fills); } catch (error) { if (!(error instanceof FillHistoryRestartRequiredError)) throw error; pages.length = 0; const first = await exchange.client.getUserFillsPage(account, { limit: 100 }); pages.push(first.fills); } ``` The generation is a dataset identity, not a completeness, freshness or chain-height watermark. The producer schema and SDK must be deployed together through a full standby reindex before this page API can serve that slot. Until then, absence of the generation is a closed failure; consumers must not infer a substitute from URL, block height or process startup. ## Keep a held history current A caller that holds an account's whole fill history between reads, such as a portfolio that polls, does not need to read all of it again each time. `exchange.client.getUserFillHistory(account, options)` takes the history the last call returned and brings it up to date. It reads with `getUserFillsPage`, so it adds no GraphQL document. The SDK keeps no state between calls: store the returned `history`, as it is or after a JSON round trip, and pass it back as `options.previous`. - **First read.** Without `previous`, the call pages back from the newest fill in pages of 999. It stops at the oldest fill or at `maxRows` (default 20,000). `truncated: true` means older fills exist that the history does not hold. The history is complete from its oldest held fill onwards. `maxRows` cuts by position, not by second, so other fills in that fill's second can be missing. - **Refresh.** With `previous`, the call reads again only the fills from `overlapSeconds` (default 60) before the newest held fill. Every request carries that `timestamp` floor. A refresh with nothing new is one page request and one small continuation probe, and one more request for each further 999 fills in the overlap. The overlap ends at the newest held fill, not at the current time, so an account with more than 999 fills in its overlap pays the extra requests on every call, also when nothing new arrives. Fills newer than the newest held fill go on top. The rest of what the call reads is the overlap, and it must match the held rows exactly. Older held rows are kept and are not read again. So do not edit a stored history: an edit to a row older than the overlap is not detected, and stays. When nothing changed, `history` is the `previous` object itself and `changed` is false. - **Indexer behind.** When the overlap lacks only the newest held fills, nothing newer is served, and every other held fill in the overlap still matches, the call drops those fills and does not read the whole history again. An indexer that is behind serves exactly that, for example after a cutover to a slot that is behind. The result is what the indexer serves, and `changed` is true. The dropped fills come back as new fills when the indexer catches up. A full read would differ only below the overlap, and the overlap check cannot see a reorg that deep anyway. - **Restart.** The call drops the held rows, reads the whole history again and returns `restarted: true`. `restartReason` says why. It is `"dataset"` when the serving `datasetGeneration` differs from the held one. It is `"overlap"` when the overlap does not match: a held fill there changed, a held fill is gone while an older held fill is still served, the newest held fills are gone while newer fills are served, a fill the history does not hold appears below the newest held one, or every fill of a truncated history is gone. It is `"previous"` for unusable state, below. The call makes at most two attempts: the first read or refresh, then one full read when the first cannot stand. A dataset change during that full read throws `FillHistoryRestartRequiredError`. Call again. - **Unusable state.** A `previous` for another account, chain, market kind or format version, or with rows out of order, repeated or without a usable position, is not an error. The call reads the whole history again and returns `restarted: true` and `restartReason: "previous"`. ```ts let held: UserFillHistory | null = null; // or the history you stored async function refresh(account: string): Promise { const read = await exchange.client.getUserFillHistory(account, { previous: held }); if (read.changed) held = read.history; // store it; it survives a JSON round trip } ``` A refresh is correct while the serving dataset and the call keep this contract: 1. Within one dataset generation, a fill row never changes after the indexer serves it, and fills appear in block order. The indexer fills in a fill's taker fields in the same transaction, and it commits whole blocks. The one exception is a chain reorg: the indexer rolls rows back in place under the same generation, so a fill can disappear or be replaced under the same id. 2. Every refresh reads again the fills from `overlapSeconds` before the newest held fill. When a held fill in that overlap changed, or a fill the history does not hold appears below its newest fill, the call reads the whole history again. When only the newest held fills are missing, the call drops them. 3. A new dataset generation means the call drops every held row and reads again. 4. The history says whether it stopped at `maxRows` (`truncated`). It is complete from its oldest held fill onwards. The SDK enforces rules 2 to 4. Rule 1 is the indexer's side. Because a fill never changes outside a reorg, the overlap is not there to pick up corrections. It is the reorg check. A reorg replaces the newest blocks, and the overlap always holds the newest held fill, so a reorg that reaches the account's held fills in the overlap changes what the check reads. Count the restarts with `restartReason: "overlap"`: outside a reorg, a changed fill or an unheld fill below the newest held one means the indexer broke rule 1. Three edge cases follow: - A reorg that forks more than `overlapSeconds` below the newest held fill is not detected when it serves the overlap again unchanged. The indexer team alerts on reorgs. - A serving indexer that falls behind drops the newest held fills from the history until it catches up. That costs a refresh, not a full read. - A larger `maxRows` on a truncated history does not load the older fills when the oldest held fill is below the overlap. When the oldest held fill is inside the overlap, the refresh reads past it, and the call reads the whole history again with `restartReason: "previous"`. Omit `previous` to read the older fills. --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/reference/errors This page lists the error classes the SDK throws, the fields each carries, and the contract error names the SDK decodes into `ContractRevertError`. For the decision logic that goes with them, see [Handle errors and reverts](../how-to/handle-errors). Every SDK error extends `SomniaMarketsError`, sets `name` to its class name, prefixes its message with `@somnia-chain/markets-sdk: `, and carries the underlying failure in `cause` when it wraps one. ## Classes All classes are exported from the root entry. `FillHistoryRestartRequiredError` is returned by `getUserFillsPage` when a continuation belongs to a replaced serving dataset or is a legacy cursor without a generation. Its readonly `cursorGeneration` and `servingGeneration` fields identify the mismatch; legacy cursors report `"legacy"` as `cursorGeneration`. Discard accumulated pages and [restart from page one](./fill-history-pages); retrying the old cursor cannot succeed. `getUserFillHistory` makes at most two attempts: its first read or refresh, then one full read when the first cannot stand. It throws this error when the dataset changes during that full read. Call it again. `FillHistoryUnavailableError` means the serving indexer has not initialized `FillDataset/current`. Deploy and reindex a compatible producer. Retrying the same deployment cannot make the contract appear. `PerpPositionChangesUnavailableError` means the serving indexer predates the `PerpPositionChange` table, so `listPerpPositionChangesForFills` has no position updates to pair fills with. Show each fill's own side until the indexer is reindexed by a compatible producer. Retrying the same deployment cannot make the table appear. | Class | Fields | Meaning | Retrying the same call | | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `SomniaMarketsError` | none | Base class. | depends on the subclass | | `InvalidInputError` | `chainIds?: readonly number[]` | The call cannot proceed: unknown symbol or reference, unknown timeframe, a method used on the wrong market kind, an amount below one lot, a limit order without a price, a market order against an empty opposite side, a probability outside `[0, 1]`, or a non-finite amount. Most cases are local validation. The empty-book market-order case follows a chain book read and depends on current liquidity. `chainIds` is set when a call names no chain on a multi-chain exchange (`exchange.client`, `exchange.trader`) and when `createReactivity` gets a wallet on another chain. | fails again, except an empty-book market order may succeed after liquidity appears | | `NotConfiguredError` | `what: string` | The client lacks a URL or address the feature needs: `indexerUrl`, `wsRpcUrl`, `priceFeed`, or an entry of `addresses`. Thrown at the first call that needs it, never at construction — a client built without an indexer still serves every chain-backed capability (smart accounts, executors, on-chain reads) and refuses indexer reads and live watches by feature. | fails again | | `NotSupportedError` | `feature: string` | The call is valid, but this SDK version does not support it yet, for example the `"spot"` and `"overall"` scopes of `getAccountEquity`. Thrown before any network request. | fails again | | `SignerRequiredError` | `operation: string` | An authenticated method was called without a `privateKey`, `account`, `walletClient`, or `executor`. Authenticated methods include `fetchBalance`, `fetchOpenOrders`, `fetchOrders`, `fetchMyTrades`, `fetchPositions`, `watchOrders`, `watchMyTrades`, `exchange.trader`, and every write. | fails again | | `CollateralOnWrongChainError` | `chainId`, `code`, `needed`, `held`, `shortfall`, `elsewhere`, `plan?` | The order's collateral sits on another configured chain | Bridge the shortfall, then retry | | `BridgeLiquidityError` | `token`, `from`, `to`, `router`, `amount`, `available` | A native bridge route (STT) pays out of the destination router's own balance, and that balance is below the amount. `exchange.bridge.transfer`, `exchange.ensureFunds`, and `createOrder(…, { funding: "auto" })` read it first and send nothing. | Retry after seeding, bridge at most `available`, or fund another way | | `BridgeDeliveryTimeoutError` | `messageId`, `bridgeHash`, `from`, `to`, `token`, `amount` | `exchange.ensureFunds` or `createOrder(…, { funding: "auto" })` sent the bridge leg, but the destination Mailbox did not process its message within the delivery timeout. The funds are in flight. `exchange.bridge.transfer` resolves with `delivered: false` and the same `messageId` instead of throwing. | Call `delivered(messageId)` on the destination Mailbox before you bridge again | | `IndexerError` | `operation: string` | An indexer request did not complete: endpoint down, bad URL, HTTP error, GraphQL error, or the 30-second request timeout. Never means "no such row". | may succeed | | `IncompleteHistoryError` | `operation`, `reason`, `pagesRead`, `maxFillPages?` | Portfolio analytics could not traverse all indexed fills: the caller's page budget ended or the cursor cycled. No partial PnL is returned. | Increase or omit the budget; investigate a cursor cycle. | | `IncompletePortfolioError` | `account`, `limit`, `nextOffset` | `getClaimable` observed more nonzero positions than its complete-read budget. No incomplete claim array is returned. | Use `getClaimablePage` and follow its raw-position `nextOffset`. | | `IndexerUnavailableError` | `reason: IndexerRefusalReason`, `retryAfterMs: number`, `consecutiveFailures: number` | The SDK refused the read locally. `reason` says which gate refused: `breaker-open` (repeated endpoint failures — timeouts or HTTP 429/502/503/504 — opened the breaker), `breaker-trial` (the cooling period ended and another read is the trial), `queue-full` (this client already has `maxQueued` reads waiting), or `live-reset` (`stopLive()` reset live reads while it queued or backed off). For the first three, nothing reached the network; if a gate refuses a retry after an earlier attempt was sent, the read fails with that attempt's `IndexerError` instead. `retryAfterMs` is meaningful only for `breaker-open` and is `0` for the rest. A subclass of `IndexerError`, so existing `instanceof IndexerError` handling still catches it. | Branch on `reason`: sleep `retryAfterMs` for `breaker-open`, retry shortly for `breaker-trial`, back off on your own schedule for `queue-full`, and retry `live-reset` on the same client | | `AccountViewError` | `reason: AccountViewErrorReason`, `chainId: number`, `operation: string` | An account-view read (`client.accountOrders`, `client.accountOutcomePositions`) got an answer the SDK cannot accept as one complete account. `invalidResponse`: the data breaks the account-view contract. `scopeMismatch`: the data belongs to another account, chain or dataset. `snapshotUnstable`: the account changed during every read attempt. It never means an empty account. A subclass of `IndexerError`, so existing `instanceof IndexerError` handling still catches it. | `snapshotUnstable` can succeed at once. The other two succeed only after the indexer serves correct data | | `FillHistoryUnavailableError` | `operation: string` | The serving indexer has no initialized fill-dataset generation. | requires a compatible producer deployment and reindex | | `PerpPositionChangesUnavailableError` | `operation: string` | The serving indexer predates the `PerpPositionChange` table, so fills cannot be paired with their position updates. | fails the same way until a compatible producer reindexes the indexer | | `FillHistoryRestartRequiredError` | `cursorGeneration: string`, `servingGeneration: string` | A fill-page cursor belongs to a different serving generation or predates generation binding. | requires a fresh first page without the old cursor | | `AccountsIndexerUnavailableError` | `operation: string`, `retryAfterMs: number \| undefined`, `chainId?: number` | A smart-account indexed read (`client.accounts.*`, `exchange.accounts.*`) found the accounts indexer rate-limited, overloaded or unreachable. `retryAfterMs` is how long it asked to wait, when it said. A subclass of `IndexerError`. | Wait `retryAfterMs` (or back off) and retry | | `AccountFeedRestartRequiredError` | `chainId: number` | A smart-account feed cursor (`getActivityPage`, `getAccountsPage`, `getNetworkActivityPage`, `getFactoryHistoryPage`) was issued before that chain's accounts index was rebuilt; continuing would skip or repeat rows. | Discard the pages read; start the read again without a cursor | | `IndexerWaitTimeoutError` | `targetBlock: number`, `latestProcessedBlock: number \| null`, `timeoutMs: number`, `waitedMs: number`, `polls: number` | The indexer did not reach the requested block before the wait deadline. The fields report the target, last observed processed block (or `null`), deadline, elapsed time and metadata polls; unlike `IndexerError`, the indexer answered but remained behind. | Retry with a longer deadline or handle the lag before reading. | | `RpcError` | `operation: string` | A JSON-RPC or WebSocket request did not complete: connection refused, 4-second request timeout, dropped subscription, unsupported method. From a write, it can also mean that the node refused the transaction (nonce too low, insufficient funds). Only the local-signer and session-key executors guarantee that nothing was sent otherwise; see [Handle errors](../how-to/handle-errors). | a read may succeed; a write may succeed after you fix the refusal | | `TransactionOutcomeUnknownError` | `chainId: number`, `operation: string`, `hash?` | A write signed a transaction that may have reached the node, and the SDK did not observe its receipt: the sync send timed out or its socket dropped, the receipt wait failed, or a session send lost its answer. The transaction can still mine. `hash` is absent only for a session send. A subclass of `RpcError`, so existing `instanceof RpcError` handling still catches it. | do not retry blindly: look up `hash` (or read the state the write changes) first, because a retry can execute the write twice | | `ContractRevertError` | `errorName?`, `args?`, `reason?`, `data?`, `address?`, `functionName?` | The chain rejected the call. Thrown on send-time rejection, pre-send simulation, a mined receipt with failed status, and `eth_call` reads. | fails again unless state changes | | `InvariantError` | none | The SDK contradicted an invariant that its own control flow had established. It indicates an SDK defect, not invalid caller input. | not applicable; report the SDK defect | Errors from code the SDK calls but does not own can surface unchanged. This includes a `walletClient` supplied by the app and the client returned by `getViemClient()`. Debug sink failures are different: the SDK contains them so diagnostic code cannot break an operation. ### `ContractRevertError` fields | Field | Type | Present when | | -------------- | -------------------- | -------------------------------------------------------------- | | `errorName` | `string` | The revert data matched a custom error in `contractErrorsAbi`. | | `args` | `readonly unknown[]` | `errorName` is set. Positional decoded arguments. | | `reason` | `string` | The revert carried a `require` or `revert` string. | | `data` | `string` | The node returned revert data. | | `address` | `string` | The reverting contract is known. | | `functionName` | `string` | The called function is known. | A mined transaction with `status: "reverted"` carries no revert data. The SDK replays the call as `eth_call` at the receipt's block to recover `errorName` or `reason`. When the replay yields nothing, the error is still a `ContractRevertError` and its message ends with `(no revert data recoverable)`. `decodeRevert(caught)` is exported for callers who send transactions themselves. It walks the `cause` chain, decodes the first revert data it finds against `contractErrorsAbi`, and always returns a `ContractRevertError`. It does not check that the input is a revert: a transport error passed to it returns a `ContractRevertError` without `errorName`. ## Absence versus failure | Outcome | Indexer point read (`get*`) | Indexer list read (`list*`) | Chain read | Live-store read | | -------------- | --------------------------- | --------------------------- | ------------------------------------------------------------------ | ------------------------------ | | Row exists | the row | the rows | the value | the value | | No such row | `null` | `[]` | throws `ContractRevertError` or returns the contract's empty value | `[]`, `null`, or an empty book | | Request failed | throws `IndexerError` | throws `IndexerError` | throws `RpcError` or `ContractRevertError` | not applicable | ## Contract error names `contractErrorsAbi`, exported from the root entry, is the generated ABI of every custom error the protocol contracts declare. `ContractRevertError.errorName` is one of its names. The names the exchange API and the trader raise most often: | Area | Error names | | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Order validation | `InvalidPrice`, `InvalidQuantity`, `PriceNotAlignedToTickSize`, `QuantityNotAlignedToLotSize`, `QuantityBelowMinimum`, `PriceOutOfBounds`, `PriceTooLarge`, `TooManyRestingOrders` | | Market state | `TradingNotActive`, `UseBinaryPlacement`, `MarketRestricted`, `MarketNotSettled`, `NotFinalized`, `StaleMarketId` | | Order lifecycle | `OrderDoesNotExist`, `IncorrectSender` (a cancel of an order the caller does not own, including an order already cancelled), `OrderAlreadyExpired`, `ExpiredOrderMustBeCancelled`, `NotExpired`, `OrderExpiryBeyondMarket` | | Time in force | `PostOnlyWouldCross`, `FillOrKillNotFillable`, `ImmediateOrCancelNoFill`, `SelfMatchCancelTaker` | | Amend and batch | `AmendOldOrderGone`, `AmendReplacementRejected`, `EmptyOrderBatch`, `BatchTooLarge`, `LengthMismatch` | | Funds | `InsufficientBalance`, `InsufficientVaultBalance`, `ExceedsWithdrawableBalance`, `ERC20InsufficientAllowance`, `ERC20InsufficientBalance`, `InvalidMsgValue`, `NativeAmountMismatch` | | Builder fees | `BuilderNotApproved`, `BuilderFeeExceedsApproval`, `BuilderFeeExceedsCap`, `InvalidBuilder` | | Binary settlement | `NothingToClaim`, `AlreadyClaimed`, `NothingOwed`, `AlreadyFinalized`, `OracleNotAnswered`, `QuestionNotFinal` | | Perps | `NoOpenPosition`, `InsufficientMargin`, `InsufficientMarginForOrder`, `InsufficientMarginAfterWithdrawal`, `MaxPositionSizeExceeded`, `OpenInterestCapExceeded`, `InvalidLeverage`, `MarkPriceUnavailable`, `OraclePriceStale`, `NotLiquidatable`, `PerpPoolNotRegistered` | | Stop orders | `InvalidTriggerPrice`, `LimitPriceIncompatibleWithTrigger`, `InsufficientSomiPayment`, `TriggerTooCloseToEma`, `RefundFailed` | | Operators and venues | `OperatorDisabled`, `OperatorNotActive`, `UnknownOperator`, `UnknownVenue`, `VenuePolicyDenied`, `InsufficientPermission`, `Unauthorized`, `NotOwner` | | Test collateral | `FaucetCapExceeded` | The full list is longer and changes with the contracts. Read it from `contractErrorsAbi` in the installed version. --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/explanation - [About the exchange instance](./about-the-exchange-instance) - [About symbols and human units](./about-symbols-and-human-units) - [Why a binary pool can name more than one market](./binary-pool-recycling) --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/explanation/about-the-exchange-instance This page discusses why the SDK has one constructible object, `SomniaMarkets`, and what that decision buys and costs. It covers ownership of connections, state, and signers. It does not cover the internals of the live watches; those are in [Architecture](../architecture). ## One root, no globals Many chain SDKs expose a set of free functions and a global configuration step: set the RPC once, then call anything from anywhere. This SDK does not. `new SomniaMarkets(config)` creates an instance, and every capability hangs off it: the exchange verbs, the engine at `exchange.client`, the trader at `exchange.trader`, the SomniaLend namespace at `exchange.client.lend`. Nothing that needs a connection is reachable without an instance. The root entry exports only the class, types, pure helpers, constants, and ABIs. The reason is isolation. An instance owns its configuration, its WebSocket, its live store, its watches, its approval cache, its signer, and its local-send queue. Two instances share none of those execution resources. Nonce state is one of them. Each engine owns one nonce lane, and a multi-chain exchange has one engine per chain. Two exchanges that sign with the same key on the same chain keep separate lanes and do not coordinate, so their concurrent writes can take the same nonce. Share one long-lived exchange per signing account per chain. A multi-chain bot configures one exchange with `chains`. Each chain has its own socket, nonce lane, and fee settings. A server reuses one long-lived exchange. An indexer-only request may use a short-lived instance because it never opens a socket. A test suite runs cases in parallel with one exchange each. With module-level state, each of those needs a workaround. The cost is one line of plumbing. A React app constructs the exchange once at module scope and passes `exchange.client` to the provider. A script constructs it at the top. There is no `configure()` call to forget and no hidden singleton to reason about. ## Sockets opened late Each configured chain has an engine with an indexer HTTP endpoint and a chain WebSocket. That socket carries log and head subscriptions, `eth_call` reads, and transaction sends. Reads on the same engine share its socket. A configured price feed uses separate GraphQL HTTP and WebSocket connections. Node RPC sockets use the SDK-managed `ws` transport. Browser RPC sockets use native/global/isows selection. A root may select a standard constructor explicitly with `rpcWebSocketConstructor`; the choice is snapshotted at construction and forwarded to every chain engine. Implicit Node globals no longer select it. The constructor remains caller-owned, while each created socket belongs to one engine and must meet the prompt disposal contract. See [Configuration](../reference/configuration#rpc-constructor-selection) for selection and migration details. Price-feed sockets use the same selection and constructor, but each is a separate connection. Each chain socket opens on that engine's first chain operation. Each price-feed socket opens on its first price watch. Construction and indexer-only reads do not open these sockets. A missing `wsRpcUrl` or chain WebSocket endpoint raises `NotConfiguredError` on the first chain operation. `close()` releases owned connections across all engines; see Lifecycle below. You can think of the instance as a small local node for the markets you care about. It hydrates a snapshot of a market from the indexer once, then follows the chain itself. After that the indexer is only asked for history. ## The signer belongs to the instance A signer is part of the configuration: `privateKey`, `account`, or `walletClient` — or, at the engine tier, any `Executor` (`createTrader({ executor })`), which is how a smart account trades. The instance derives `walletAddress` from it and uses it for every authenticated verb, read or write. `fetchBalance` and `watchOrders` are authenticated reads: they answer for the signer's address without taking an address parameter. That mirrors how exchange APIs behave, and it is why `SignerRequiredError` is thrown by reads as well as writes. Browser apps rarely have a signer at boot. `setSigner()` exists for them: construct the exchange for public reads, bind the wallet when the user connects, and pass `{}` on disconnect. Watches and market data are unaffected by a signer change; only the trader is rebuilt. An alternative would have been per-call signers, as in `createOrder(symbol, …, { signer })`. That design keeps the instance stateless but pushes the approval cache to the caller, and the cache needs to be per-signer to be correct. Holding the signer in the instance keeps it correct by construction. The trade-off is that one instance trades as one account; an app that trades as several accounts constructs several instances or calls `setSigner` between them. ## Lifecycle `close()` releases the watches, the streaming channels, and every socket and timer the instance opened, including the WebSocket to the node. That includes a socket that is still connecting: `close()` ends its handshake, and nothing retries it afterwards. With the default managed Node transport, a script that has touched the chain exits on its own once `close()` returns. A supplied constructor must meet the same disposal obligation. If a custom member fails, cleanup continues across engines and `close()` rejects with the first semantic failure; it does not promise release of resources a violating implementation retains. The instance stays usable afterwards: a later read opens a new connection, and a later `close()` releases that one too. A viem client you took from `getViemClient()` belongs to the connection it came from. After `stopLive()` or `close()`, its requests reject with viem's `SocketClosedError`, so call `getViemClient()` again for a working one. `unwatchMarket(symbol)` releases one market and keeps the instance open. Use it when a consumer moves from one market to the next, for example in a rotating binary series. It stops that market's watch and drops its streaming channels. A pending `watch*` call on the market rejects with a `DOMException` named `AbortError`. The next `watch*` call on the market opens a fresh watch. The client keeps the market's live data for about 30 seconds after the release, then drops its orders and fills. Without this call, the instance holds every market it has watched until `close()`. Each instance owns its own socket, even when two instances use the same WebSocket URL. Closing one instance never affects another, and never affects a socket your own application opened through viem. Still prefer a long-lived instance over one per server request: each instance holds a socket, a market cache, and local order-book state. ## Where the design shows through - `exchange.client` is the engine, not a second client. It shares the store and the socket with the exchange, so a live book read through a hook and through `watchOrderBook` are the same data. - `exchange.client.getViemClient()` returns the underlying viem client for anything the SDK does not cover. It is deliberately undecorated: errors from it are viem's, not the SDK's, and it opens the socket if it is not open yet. --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/explanation/about-symbols-and-human-units This page discusses two choices in the exchange API: markets are addressed by symbols such as `SOMI/USDso`, and quantities are decimal numbers rather than integers scaled by token decimals. Both choices trade exactness for familiarity, and the SDK keeps the exact values within reach. The grammar itself is in the [Symbols reference](../reference/symbols) and the scales in [Units and scales](../reference/units-and-scales). ## Why symbols Spot and perp markets use a pool address as their identity. A binary market has a stable `marketId` and a `BinaryMarket` contract address; its pool address can be [reused by later markets](./binary-pool-recycling). These identifiers are exact, but they mean little to a human reading a log line or a config file. Exchange tooling answered this question years ago: a market is `BASE/QUOTE`, a derivative carries its settlement currency after a colon, and a bot's configuration lists symbols. The exchange API adopts that grammar so that code written against ccxt-style venues transfers with little change. The design intent is that the verbs never change again: a new market kind is new data (a `type`, an `outcomes` list), not a new API. Binary markets have no precedent in that grammar. Each market has two things you can trade, YES and NO, and each needs its own price and its own side of the book. The SDK's answer is the `#OUTCOME` suffix: `BTC-95000-31DEC26/USDC#YES` and `…#NO` are two tradables on one market. A market symbol without a suffix resolves to YES, so callers who only think in YES terms can ignore the suffix entirely. The cost of synthesised symbols is that they are derived, not stored. Two markets can render the same symbol, for example two series markets with the same asset and expiry. The SDK breaks the tie deterministically with a four-hex-digit suffix from the market id. That keeps the mapping one-to-one, but it means a symbol can carry a suffix a human did not expect. For that reason every method also accepts the raw pool address or market id in place of a symbol. ## Why human units On chain, a quantity is an integer scaled by the token's decimals, and a binary price of 0.62 is `620000` when the collateral has six decimals. Correct code must carry the decimals with every number, and most bugs in trading code are decimals bugs. The exchange API takes and returns JavaScript numbers in the tradable's own terms: 10 shares at 0.62, 1 SOMI at 0.5 USDso. The SDK converts at the boundary using the decimals it already knows from the market row. The caller never sees a raw integer unless they ask for one. A NO price is the NO probability. The pool keeps a single book in YES terms, and a NO bid at 0.38 is a YES ask at 0.62 on chain. The SDK presents each outcome's own view (prices, sides, and candles are outcome-relative) and performs the complement internally. A caller who buys NO at 0.38 reasons about NO at 0.38. ## What it costs, and how the SDK pays A JavaScript number holds about 15 significant digits. An 18-decimal token amount does not fit. The conversion helper used for display, `toHuman`, is documented as lossy for that reason, and `toHumanString` exists for exact rendering. On the way in, `fromHuman` rounds a value with too many fraction digits rather than throwing. We judged that a computed mid price almost always has too many digits and that rejecting it would hurt more than rounding. A caller who needs exactness passes a string. The exchange structs that wrap a native row or result carry that payload under `info`. An order's `info` is the placement result with the receipt and the decoded fills in bigint. A market's `info` is the indexed row with `tickSize`, `lotSize`, and the token addresses. Aggregate and tuple results such as `UnifiedBalance`, `UnifiedBalances`, and `UnifiedOHLCV` do not have `info`. The engine at `exchange.client` preserves each backend's exact representation: chain reads and writes use bigint, while indexer and live-store numeric fields such as prices and quantities are decimal strings. The SDK aligns prices and quantities to the venue's tick and lot grids before it sends an order, and never against the caller. A buy price rounds down, a sell price rounds up, a quantity rounds down. The returned order echoes the aligned values. This is where the human-unit design most visibly diverges from what the caller typed. It is why the reference says to read `price` and `amount` back from the result rather than assuming the inputs. ## The alternative we did not take A `bigint`-only exchange API would be exact and would match the engine's chain boundary. We judged that it would also reproduce the decimals bugs it exists to remove: every caller would carry `baseDecimals` and `quoteDecimals` through their own code, and the ccxt idiom the API borrows would be lost. A decimal library would be exact and readable, but it would make the SDK impose a number type on every consumer. The chosen design keeps the surface familiar and keeps exactness one property access away. ## Where the protocol shows through Two conventions in the structs are protocol facts that the human-unit layer cannot hide, and the [Units and scales reference](../reference/units-and-scales#struct-conventions) records them. Wallet balances report `free === total`, because locked funds sit in the pool rather than the wallet. Orders read back from the indexer have no `type`, because the pools do not emit it. In both cases the SDK reports what it knows rather than guessing. --- # https://somnia-platform-docs.vercel.app/markets/somnia-markets/typescript/explanation/binary-pool-recycling A binary market has a stable `marketId`. Its `BinaryPool` address hosts the order book and can be reused after that market is finalized and the pool is released. When the pool is recycled, it hosts a new market at the same address. Keep the `marketId` when you need to read one market across its full life. ## What changes when a pool is recycled The `BinaryMarketsModule` keeps a record for each `marketId`. That record retains the market contract, pool address, and its `yesId` and `noId`. Recycling changes the pool's current market and increases its market nonce. The new nonce gives the next market different outcome-token ids, even though the pool address is the same. An earlier market's module record and outcome-token ids remain tied to that earlier market. For example, market A and market B can both name pool P. After P is recycled from A to B, `getBinaryPoolParams()` on P reports B's `market`, `yesId`, and `noId`. It cannot identify A's outcome tokens. Read the module record through `client.getMarketOnchain(marketId)` when you need A's ids. ## Choose the read by the identity you hold | Read | What it describes | | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | Pool contract `getBinaryPoolParams()` | The pool's current market, outcome ids, and parameters at the read block. | | `client.getPoolMarketBinding(pool)` | The pool's current `marketId` from chain, with a block pin. | | `client.getBinaryOrderBook(pool)` | The pool's resting book at chain head or a requested block. It does not check a `marketId`; after recycle, it can return the next market's book. | | `client.getLiveBinaryOrderBook(pool)` and `client.getLiveMarketByPool(pool)` | The watched pool's locally materialized book and market binding, through the last applied events. They need a watch and can lag chain head. | | `client.getMarketByPool(pool)` | The newest indexed market row for that pool. It can lag the chain; `listMarketsByPool(pool)` returns indexed rows for earlier occupants too. | | `client.getBinaryMarket(marketId)` or `client.getMarketOnchain(marketId)` | One market by its stable id, from the indexer or chain respectively. | | `client.getLiveBinaryOrderBookByMarket(marketId)` | The watched book only while that market is the pool's current binding. It returns an empty book after the pool moves on. | | React `useLiveBinaryOrderBookByMarket(marketId)` | A selected market's live book. It watches the pool only after the market is known to the live store; mount a pool-keyed watch for a cold screen. | The market-scoped live book needs an active watch. Its empty result for an older market is a safeguard against showing the successor's orders; it is not a historical order book. Pool-keyed fill and user-order reads can span successive markets. The live fill tape has no market filter; for one wallet's indexed fills, `getUserFills(account, { market: marketId })` filters by market id. Pool-keyed candles also span successive markets. `getCandles(pool, interval, { market: marketId })` and `getMarketStats24h({ marketId })` read one market's candles. ## Read a selected market For a one-shot chain snapshot, read the pool binding and book at the same block. If the binding is absent or names another market, return no book instead of showing the successor's liquidity. Read the selected market's outcome-token ids from its own chain record; its YES balance remains meaningful after recycle. ```ts import type { SomniaMarkets } from "@somnia-chain/markets-sdk"; import type { Address, Hex } from "viem"; async function readBinaryMarket(exchange: SomniaMarkets, marketId: Hex, account: Address) { const client = exchange.client; const market = await client.getMarketOnchain(marketId); const binding = await client.getPoolMarketBinding(market.pool); const book = binding?.marketId.toLowerCase() === marketId.toLowerCase() ? await client.getBinaryOrderBook(market.pool, { blockNumber: binding.blockNumber, decimals: market.decimals, }) : null; const yesBalance = await client.getOutcomeBalance({ outcomeToken: market.outcomeToken, account, id: market.yesId, }); return { yesBalance, book }; } ``` For a continuously updated view, keep `watchMarket(market.pool)` active for the screen's lifetime and call `getLiveBinaryOrderBookByMarket(marketId)` on updates. That market-scoped read returns an empty book when the watched pool's current binding differs, or when the market is not yet known to the live store. In React, mount `useWatchMarketResult(market.pool)` or another pool-keyed watch hook alongside `useLiveBinaryOrderBookByMarket(marketId)` to seed a cold store. The market-scoped hook can watch the pool itself only after the market is known. Do not take `yesId` or `noId` from the recycled pool's current `getBinaryPoolParams()` to check an older market's balance. The shared outcome-token contract's `balanceOf(account, id)` reads exactly the id you give it. If a wallet owns A's YES tokens but you pass B's `yesId`, the result can be zero. That zero describes B's token balance, not A's position.