Somnia Markets / TypeScript SDK

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 <Table>_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.

    // 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.

    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: <the chain the call runs on>; 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 UnsignedCalls 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.

    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. <SomniaMarketsProvider exchange={exchange}>; 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 UnsignedCalls 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:

    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.

    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.

      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.

      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.

      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):

    // 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.

    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.
    - 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.

// 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_calls 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:

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:

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:

// 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.

- 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.

// 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:

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.

- 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:

- 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:

- 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?.<field> 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 BinaryPnlFills 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.

Edit this page