Somnia Markets / TypeScript SDK
Release notes
0.32.0
Minor Changes
BREAKING: Binary portfolios now expose position continuation fields. Add
positionsTruncatedandpositionsNextOffsetto caller-built portfolio fixtures.getClaimablerejects wallets with more than 200 nonzero positions instead of returning incomplete claims; useclient.getClaimablePage(account, { limit, offset })and follownextOffsetto 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,getFactoryHistoryPageandgetIndexerStatus.exchange.accounts(every covered chain):listIndexedAccounts,listDerivedAccounts,listControlledAccounts,listOwnerHistory,listModuleHistory,listCreationAuthorizations,listFactoryStates,getActivityPage,getNetworkActivityPage,getAccountsPage,getFactoryHistoryPageandlistIndexerStatus. Feeds merge chains by time.Pages are this SDK's own
{ items, nextCursor }with an opaquecursoroption, as for fills;AccountsIndexerStatus(withlagSeconds,observedAt,ready) and the option types are this SDK's own too. Thelist*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 ofIndexerError) andAccountFeedRestartRequiredError(the accounts index was rebuilt under a feed cursor). A chain without an accounts indexer throwsNotConfiguredError; a failed request throwsIndexerError. Requires@somnia-chain/accounts-sdk0.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:
SomniaMarketsClienthas 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 aPerpPositionChangeornullfor 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.nullmeans 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 newPerpPositionChangesUnavailableError: show each fill's own side until the indexer is reindexed. The package root exportsPerpPositionChangesUnavailableErrorandSomniaMarketsClientListPerpPositionChangesForFillsError. CustomSomniaMarketsClientimplementations must add the method.BREAKING:
SomniaMarketsClienthas 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 fromoverlapSeconds(default 60) before the newest held fill, puts the new fills on top, and keeps the older held rows. When nothing changed, it returnspreviousitself withchanged: 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. Withoutprevious, it reads the newestmaxRowsfills (default 20,000) and saystruncated: truewhen older fills exist. A dataset change, or apreviousfor another account, chain, market kind or format, also makes it read the whole history again. Each full read again returnsrestarted: true, andrestartReasonsays 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,UserFillHistoryReadandSomniaMarketsClientGetUserFillHistoryError. CustomSomniaMarketsClientimplementations 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 exampleCooldownActiveorNoWithdrawalRequest) to its error name instead of raw revert data. - When upgrading from
0.31.0to stable0.32.0, follow the complete0.31.0 → 0.32.0migration guide indocs/MIGRATING.md, included in the npm package. Read every0.32.0prerelease 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,binarySettlementEventsAbiandmarketCreatorEventsAbi. A consumer that decodes binary market logs can now use the same signatures as the SDK and the indexer.listLiquidations,listLiveLiquidations, anduseLiveLiquidationsacceptkindsso a wallet can read several liquidation stages in one bounded request.listLiquidations,listLiveLiquidations, anduseLiveLiquidationsacceptpartyandcounterparty. Usepartyto read liquidation rows where a wallet is on either side of a leg. The live read and hook also acceptkindto select one stage.Add a
kindsfilter toclient.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, solimitcounts only matching rows. Withoutkinds, the read returns every kind, as before. An emptykindsarray matches nothing, as inlistPerpPositionChanges. A value outsideMarginEventKindthrowsInvalidInputErrorbefore the request is sent. The package root exportsMarginEventKind,GetMarginEventsOptionsandSomniaMarketsClientGetMarginEventsError.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 abucketStart. 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 }).getCandlesacceptsmarket, typed as the newGetCandlesOptions, andcandlesKeyanduseCandlesaccept it too. An empty or non-stringmarketthrowsInvalidInputError.getMarketStats24h({ marketId })reads only that market's candles.getMarketStats24h({ pool })reads every candle in the 24-hour window, however many markets the pool served.fetchOHLCVandfetchTickerread only the selected market's candles.BREAKING:
SomniaMarketsClienthas 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.queuewithregime: "ranked"lists every holder on the side. Each holder has itseligibility, and each candidate has itsadlRankin 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 isregime: "storageOrder", and a side that would be ranked but has more thanPERP_ADL_QUEUE_MAX_HOLDERS(500) holders isregime: "tooManyHolders". A ranker that reverts or returns an unusable order gives holder-list order, andorderSourcesays so. Holder pages that do not add up throwRpcError. 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 isready,getSnapshotreturns 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 goloading,ready,stale,failedandclosed, 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, andstopLive(), or an abort of the client'ssignal, ends every view withclosed.
Migration: callers need no change. If you implement
SomniaMarketsClientyourself, or mock the whole interface, for example in a test double, add the member.getFills(pool, opts)acceptsmarketandmarkets, typed as the newGetFillsOptions. A binary pool serves successive markets, so the pool alone returns the fills of every market it has served. Passmarketto read one market's fills. Each row'skindtells a direct trade from a complete-set mint or burn.
Patch Changes
listFundingRateHistorynow orders rows that share a timestamp byblockNumber, then byid, 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
perpAdlQueueamong 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:balanceOffor an ERC-20 address, and the native balance for"native"(exported asNATIVE_BALANCE_TOKEN) or the protocol'sNATIVE_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 mostACCOUNT_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
unknownwith 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 aTransferfrom or to one of the accounts, and every 10 s, because a native balance changes with no event. Views goloading,ready,stale(last data, while a read or the transfer subscription recovers),failedandclosed, 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, andstopLive(), or an abort of the client'ssignal, ends every view withclosed. After an abort, a latersubscribegets onlyclosedandgetSnapshotrejects with the signal's reason. While a live view isready,getSnapshotreturns it, and a read that finishes after the live view moved past it never returns the older block.
BREAKING:
SomniaMarketsClienthas two new required methods,getAccountEquityandlistAccountEquities.getAccountEquityreads an account's live equity for one scope at one block.getAccountEquity(account, { scope: "perps", marginBank })returnsequity,lockedCollateral, their sumtotalEquityandasOfBlock. PassblockNumberto 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 ontotalEquity, becauseequityexcludes the collateral locked behind resting orders. The"spot"and"overall"scopes throw the newNotSupportedError.Migration: callers need no change. If you implement
SomniaMarketsClientyourself, or mock the whole interface, for example in a test double, add both methods.client.accountOrders.subscribetakes 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
pendinguntil 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, elsenull. A snapshot thatgetSnapshotreads has the indexer's stops, so it isnullthere. When a live view of the account isready,getSnapshotreturns 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.accountSpotOrdersandclient.accountPositions.
- 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
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,unavailableortooLarge.unavailablemeans the indexer cannot serve an account view on the chain.tooLargemeans 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 returnsunavailable. - 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 isunknown, with the error that caused it, and never zero. data.coveragenames the source families the view covers, for exampleSPOT_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 coverBINARY_ORDER: such an order is real, but a missing one proves nothing.- A failed read throws. The new
AccountViewError, a subclass ofIndexerError, 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
zod4. The package root exports the zod schemas the view validates indexer rows with:accountOrdersCapabilityWireSchema,accountOrdersGuardWireSchema,accountOrderRowWireSchemaandaccountStopRowWireSchema. - The read never sends
indexerHeaders. Concurrent calls for one account share one read.stopLive()ends a read in flight, which then throwsIndexerUnavailableErrorwith reasonlive-reset.
- The result is
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.onViewreceives every state of the view:loading,ready,stale,failed,unavailable,tooLargeandclosed. Eachreadyview 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
stalewith its last complete data, and the SDK reads a new snapshot after a backoff. A read that fails with no data givesfailed, with the same failure codes asaccountViewFailureReason. The SDK watches anunavailableortooLargeview and reads the account again when the result can change. None of these states is an empty account. - The
venueandlimitPriceof a new stop arependinguntil the SDK reads them. A later view carries them.AccountStopVenueandAccountStopLimitPricehave apendingstate for this, which only a live view uses. A value whose read fails isunknown, 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'ssignal, ends every live view withclosed. After an abort, a latersubscribegets onlyclosedandgetSnapshotrejects with the signal's reason. While a live view isreadyand none of its values ispending,getSnapshotfor 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
indexerWsUrlsets the indexer's GraphQL WebSocket endpoint. By default the SDK uses the WebSocket form of an absoluteindexerUrl. The live view never sendsindexerHeaders. - New exported types:
AccountOrdersViewand its statesAccountOrdersLoading,AccountOrdersStale,AccountOrdersFailedandAccountOrdersClosed, and alsoAccountOrdersStaleReason,AccountOrdersViewErrorandAccountOrdersClientSubscribeError.
BREAKING:
SomniaMarketsClienthas a new required member,accountOutcomePositions.Migration: callers need no change. If you implement
SomniaMarketsClientyourself, 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 withclosed, and a read in flight withIndexerUnavailableErrorlive-reset.- The result is
ready,unavailableortooLarge.unavailablemeans the indexer cannot serve the view on the chain.tooLargemeans 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 returnsunavailable. - Each position carries the market id, the side, the balance, the market's status and
claimable:unsettled,knownwith the amount net of the settlement fee, orunknown. Every amount, token id and block number is abigint. - A read that fails throws
AccountViewErrorwhen the indexer's answer is not one complete account, andaccountViewFailureReason(error)maps every error it throws to one failure code. A live view reports a failure asfailed, or asstalewith 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.ordersholds 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 areunknownwhen one pool's could not be read, and the positions still stand. A pool keeps an expired order until someone cancels it: compareexpireTimestampNswith the time.- A figure the chain cannot give is
unknownwith 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 needsaddresses.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 goloading,ready,stale(last data, while a read or the event subscription recovers),failedandclosed, 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, andstopLive(), or an abort of the client'ssignal, ends every view withclosed. After an abort, a latersubscribegets onlyclosedandgetSnapshotrejects with the signal's reason. While a live view isready,getSnapshotreturns 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 indata.unreadPoolswith its error, its orders are missing fromdata.orders, anddata.completeisfalse: those orders are unknown, not absent. A read that can read no pool fails. A pool keeps an expired order until someone cancels it: compareexpireTimestampNswith 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, sodata.blockNumberis 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 goloading,ready,stale(last data, while a failed read or the event subscription recovers),failedandclosed, 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, andstopLive(), or an abort of the client'ssignal, ends every view withclosed. After an abort, a latersubscribegets onlyclosedandgetSnapshotrejects with the signal's reason. While a live view isready,getSnapshotreturns 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,AccountSpotOrdersViewand its states, and the option and error types of both calls.
BREAKING:
SomniaMarketsClienthas four new required members, the account views:accountOrders,accountSpotOrders,accountPositionsandaccountBalances.Migration: callers need no change. If you implement
SomniaMarketsClientyourself, 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)withnew SomniaMarkets({ ...config, rpcWebSocketConstructor: AppWebSocket })when your application needs a custom standard constructor with prompt owned disposal. Browser default selection is unchanged.SomniaMarketsgainsunwatchMarket(ref). It releases one market's watch and streaming channels and keeps the exchange open. Before, the exchange held every watched market untilclose(), so a consumer that follows a rotating series kept every old market in memory and in each update. After the call, the nextwatch*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,watchOrdersorwatchMyTradescall on a released market now rejects with aDOMExceptionnamedAbortError. 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 afterclose(). A loop that awaits awatch*call must handle this rejection when it releases or closes the exchange.BREAKING: local-signer writes now send through
eth_sendRawTransactionSync. Write results carryexecutionTimeMicroswhen the Somnia node reports it.executionTimeMicrosis 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.TxResultgains an optionalexecutionTimeMicros?: number. So does every trade result, such asPlaceOrderResult,CancelOrdersResultand the perp results.Executedgains the same field. A custom executor can set it onExecuted, and the writer passes it through.The local-signer executor reads the field from the
eth_sendRawTransactionSyncreceipt. The session-key executor reads it from thesomnia_sendSessionTransactionreceipt.native.sendSessionTransactionnow returns aSomniaTransactionReceipt: aTransactionReceiptwith 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'srealtime_sendRawTransaction. It still returns the receipt in one round-trip. A node that does not serve the method getseth_sendRawTransactionand a receipt wait, as before. An EIP-7966 error code5or6from this method is a refusal, because the node did not add the transaction to its mempool. Code4(Timeout) staysTransactionOutcomeUnknownError.Migration: a failed fast-path send now reports
operation: "eth_sendRawTransactionSync"on itsRpcErrororTransactionOutcomeUnknownError. 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
useLivePriceTicksor acquire a direct price watch. Direct price watches keep their tick tape by default and acceptticks: falseto skip it. The last tick reader releases its tape after 30 seconds while current-price readers continue.BREAKING:
MarginAccounthas a new required fieldasOfBlock, the block thatclient.getMarginAccountpinned its reads to.Migration: reads need no change. If you construct a
MarginAccountyourself, for example in a test fixture, addasOfBlock.BREAKING: on a multi-chain
SomniaMarkets, reads now report failures per chain, andExchangeDataStatus.indexeris optional.- Migration:
fetchDataStatus()now resolves when some chains fail, and throws only when every chain fails. When the primary chain's measurement fails,status.indexeris absent (undefined). Read that chain's error fromstatus.indexerErrorByChain[primaryChainId]. Code that readsstatus.indexermust handleundefined.nullstill means that the chain's metadata row is absent. fetchDataStatus()addsindexerErrorByChain, 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 inindexerByChain.IndexerErrorandRpcErroradd an optionalchainId. 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 andcausedo not change, soinstanceofchecks still work. Other errors keep their shape.fetchBalance,fetchOpenOrders,fetchMyTrades,fetchPositions,fetchOpenStopOrdersandwatchUseraccept a chain filter{ chainId }as their last argument.fetchOrdersacceptsparams.chainId, andgetOrdersPageandgetOrderHistoryPageacceptoptions.chainId. A filtered read calls only that chain, also when it loads markets on a new exchange. The filter typeSomniaMarketsChainFilteris exported from the package root. AchainIdthe exchange does not span throwsNotConfiguredError. A market on another chain than the filter throwsInvalidInputError.- A chain bound with no
indexerUrlno longer makes account reads throw.fetchBalancereturns its ERC-20 and native balances without outcome holdings.fetchOpenOrdersreads its resting orders from the chain.fetchOrders,getOrdersPage,getOrderHistoryPage,fetchMyTrades,fetchOpenStopOrdersandwatchUserskip it when the read also spans a chain with an indexer. This also applies to a single-chain exchange with no indexer:fetchBalanceused to throwNotConfiguredErrorand now returns balances. The newchainIdsWithoutIndexerlists these chains. A read scoped to such a chain alone still throwsNotConfiguredErrorwhen it needs the indexer. loadMarkets()addsregistryReadErrorsByChain,perpDiscoveryErrorsByChainandperpDiscoveryPartialFailuresByChain, keyed by chain id.registryReadError,perpDiscoveryErrorandperpDiscoveryPartialFailurestill report the first failing chain, in the order of thechainsconfig. A bareloadMarkets()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. ANotConfiguredErrorfailure is not retried.fetchPositionsno 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 newUnifiedPosition.perpStateset to"degraded", andmarkPriceandunrealizedPnlare absent.perpStateis"ok"on every other position.fetchOpenOrdersnow reads its chains in parallel. The merged order is still the binding order.fetchPositionsnow 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.
- Migration:
listBinaryMarketstakesListBinaryMarketsOptions: the filter pluslimitandoffset. New filter fieldsorderBy: "resolvedAt"(earliest-settled first, ties broken by id) andresolvedBefore(exclusive upper bound on the terminal timestamp). Together withresolvedSincethey page through settlements in the order they happened.BREAKING:
client.getPoolBindings(pool)is renamedclient.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 thanlimit. Deduplicate onid, because new bindings move older rows down between two requests. limitis 50 by default and must be an integer from 1 to 1000.offsetis 0 by default and must be a non-negative 32-bit integer (0 to 2147483647). A bad value throwsInvalidInputErrorbefore the request.- The root exports the new
ListPoolBindingsOptionsandSomniaMarketsClientListPoolBindingsErrortypes.PoolBindingRecorddoes not change.
- Migration: rename the call. The first row is still the current binding, newest nonce first. To read more than 50 rows, pass
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.getPerpVoucherEligibilityandclient.getPerpVoucherare chain reads. They resolve the distributor from the MarginBank, so no deployment address is needed.trader.claimPerpVouchersends the claim, andtrader.buildClaimPerpVoucherreturns it unsigned so a claim and a first order can go in one transaction.Add optional
addresses.marginBankto 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.lastUpdateRealizedPnlnow 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
realizedPnlon the newest row fromclient.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 byaccount,pool,txHashandkinds, window byfromandto, page withlimitandoffset. 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.
rpcWebSocketConstructornow 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,watchPriceandwatchPricesreject with the constructor's SDK error unchanged, or withRpcErrorfor any other thrown value, as RPC reads do. To keep a custom constructor, replaceglobalThis.WebSocket = AppWebSocket; new SomniaMarkets(config)withnew SomniaMarkets({ ...config, rpcWebSocketConstructor: AppWebSocket }).Add
priceFeedSchedulertoSomniaMarketsAddresses: the Somnia price feed contract that the perp pools' oracles read. One contract holds every price under a symbol name, such asETH/USDC:PERPfor a perp's mark price.SOMNIA_TESTNET_ADDRESSESsets it to0x7ccAE31E45693475be1e4BAa2360D6997eB2D32B, andSOMNIA_HIDEKI_TESTNET_ADDRESSESsets it to0x1998C88D54a240b8C671493f27f58eC416b81D7F.SOMNIA_MAINNET_ADDRESSESdoes not set it, because mainnet has no perps yet.client.accountPositions.subscribeuses 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()andstopLive()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()andstopLive()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 samewsRpcUrlnow open two sockets. Before, they shared one.The
viempeer 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
viemto 2.18.5 or later if you pin an older 2.x release. A viem client fromclient.getViemClient()now belongs to the connection it came from. AfterstopLive()orclose(), its requests reject with viem'sSocketClosedErrorinstead of opening a new socket. CallgetViemClient()again afterstopLive()to get a working client.getUserFillsPageaccepts a newmarketTypeoption ("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.fetchPortfolioAnalyticsnow 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.maxFillPagesstill counts pages across all chains. A failure on one chain rejects the call and stops the other chains at their next page; anIndexerErrorfrom a multi-chain read carries the failedchainId.BREAKING: Ticker and perp-state SDK programming failures now reject with
InvariantErrorand retain the exact original failure incause. Replace native programming-error identity checks with anInvariantErrorcheck and inspect itscause. 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
SomniaMarketsFetchTickerErrorfrom 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 withgetBinaryMarket(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 newgetLiveStatus().allMarketsTruncatedfield istruewhile 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
amendOrdersnow 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. NownewOrderIds[i]is0nexactly when amendmentiplaced nothing. That amendment holds no order, so place it again, unless it repeated anoldOrderIdthat an earlier amendment in the batch already amended.amendOrdersandamendOrderalso 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.
listPerpPoolStatusesnow batches its per-pool reads. This also applies toloadMarketsperp 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 configuredperpPoolFactory,loadMarketsnow 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
Openorder 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 ingetLiveUserOrders. A retained fill still keeps the order. An activewatchUserkeeps every expired open order of its user, including orders placed after the watch started, until the watch is released. The order keeps statusOpen, 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,fetchPositionsand the chain read offetchOpenOrders. 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 bareloadMarkets(),fetchMarkets(),ensureFundsandloadMarkets(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
RedeemedorMarketFinalizedevent 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 withgetMarketorgetBinaryMarket.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,getLiveUserOrdersand the live books then returned new references for the same rows, and each exchangewatchOrderBookandwatchTradeswait resolved with unchanged data. Now a head updates only the tail status.subscribeLivelisteners still run on each head, andgetLiveStatus()reports the newheadBlock. 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 andopts.anchoris 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,watchMarketandwatchMarketskept each amended order at its old price and size until the next snapshot. Now the order takes the amended price, quantity, expiry anduserDatain the same block. ItsfilledQuantityreads 0 after the amend, because an amend resets the order's remaining quantity to its new full quantity.liveEventsAbidecodes 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-chainSomniaMarketsnow 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.perpDiscoverySkippednow 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.mdnow covers every breaking change in 0.32.0. It gains eight entries: the bounded terminal history ofgetLiveUserOrders, the requiredchainIdon live and history rows, the multi-chain refusal ofexchange.clientandexchange.trader, chain binding checks,BridgeDeliveryTimeoutErrorandBridgeLiquidityError,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 aninnerbuilt bycreateExecutor({ sessionSeed }). The same release removes the{ safe, inner }spec, so onlycreateExecutor({ smartAccount, inner })refuses it.getOrdersandgetOpenOrdersnow sort byplacedAtTimestampdescending, then by orderiddescending. Orders placed in the same block share a timestamp, so before this change alimit/offsetpage 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-chainSomniaMarketsopens 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
getLiveBinaryOrderBookByMarketstayed 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.listRegistryMarketsandlistRegistryMarketsCheckednow 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 whosefinalizedflag 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
_aggregaterequest. After Hasura rejects the<Table>_aggregatefield, 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 socketstaleuntil 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
getLivePriceTicksbuild a new array, souseLivePriceTicksno 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
InvariantErrorafter 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
ensureFundsis a typed error.exchange.bridge.transferreads the message id from the origin receipt and resolves only when the destination Mailbox processes that id. Before, anyReceivedTransferRemoteto the recipient ended the wait, including a delivery from another transfer.ExchangeBridgeResulthas a new required field,messageId. Code that builds this type, such as a test mock, must add it.exchange.ensureFundsandcreateOrder(…, { funding: "auto" })throw the newBridgeDeliveryTimeoutErroron a delivery timeout, withmessageId,bridgeHash,from,to,token, andamount. Before, they threwRpcErrorwithoperation: "ensureFunds". Replace a check for thatRpcErrorwithinstanceof BridgeDeliveryTimeoutError, and calldelivered(messageId)on the destination Mailbox before you bridge again.exchange.bridge.transferstill resolves withdelivered: falseon a timeout, and now carriesmessageId.- The default
deliveryTimeoutMsis 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
RpcErrorwithoperation: "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, andcreateOrder(…, { funding: "auto" })read the destination router's balance first. When it is below the amount, they throw the newBridgeLiquidityErrorwithtoken,from,to,router,amount, andavailable. Before, the SDK sent the transfer, the funds stayed in escrow on the origin, and the call waited out the delivery timeout.ExchangeBridgeTransferErroris the new exported union of the errorsexchange.bridge.transfercan throw. A failed native balance read now throwsRpcErrorwithoperation: "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
syncStatusKeytakes 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, passchainIdon every key. useWatchPerpMarkets,useWatchPerpMarketsResult,useLiveLiquidationsanduseLiveFundingPaymentstakechainIdin 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 usedchainIdonly for its live updates.
- Every query-key factory except
BREAKING: live and history rows now name their chain in a required
chainId: numberfield.LiveFill,LiveOrder,LiveFundingUpdate,LiquidationEventandFundingPaymentcarrychainId. 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,listLiquidationsandgetLiquidations. PricePointis 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
MarketCreatednow set the optionalMarket.chainIdtoo, 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.clientandexchange.tradernow throwInvalidInputErroron 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. Replaceexchange.clientwithexchange.engineFor(chainId)andexchange.traderwith the new publicexchange.traderFor(chainId). The error'schainIdsfield lists the chains the exchange spans. A single-chain exchange is unchanged.traderFor(chainId)writes throughaccount.executors[chainId]when the signer is aSomniaMarketsAccount.setSignernow validates the new signer before it replaces the old one. After a throw, the previous signer stays bound.createReactivitynow throwsInvalidInputErrorwhen the wallet'schainis set and is not the engine's chain. A wallet with nochainis not checked. BREAKING (type only):createReactivitynow readsconfigas well asgetViemClientfrom its first argument. An engine from the SDK already has both. A hand-built{ getViemClient }object must addconfig: { 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 asafekey no longer type-checks, andcreateExecutorrefuses it at runtime withInvalidInputError. - The root no longer exports
safeExecAbi,preValidatedSignature,packMultiSend,detectGnosisFailure,SafeExecutionMode,SafeFailureDetectororPackMultiSendError. - This supersedes the session-seed note in the same release, which says
createExecutor({ safe, inner })refuses aninnerbuilt bycreateExecutor({ sessionSeed }). That spec no longer exists; the refusal now applies tocreateExecutor({ smartAccount, inner })only.
How to migrate:
- For an account that the accounts factory deployed, use
createExecutor({ smartAccount, inner }). It batches without amultiSendaddress. BuildinnerwithcreateExecutor({ privateKey }). - For any other safe, write a custom
Executorand pass it tocreateTrader({ executor }).prevalidatedSignatureandsafeAbicome from@somnia-chain/accounts-sdk.
- The
BREAKING:
SomniaMarketsnow checks that each chain binding's endpoints serve the bound chain.- Construction throws
InvalidInputErrorwhen 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_metadataonce. When the indexer has no row for the bound chain, the read throwsNotConfiguredError, andwhatnames the chain, the URL and the chains the indexer reports. SogetSyncStatusandgetIndexerFreshnessfor the client's own chain throw instead of returningnullfor 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. getIndexerFreshnessthrowsNotConfiguredErrorwhen the indexer is more than 10,000 blocks past the RPC head, instead of reporting lag 0.waitForIndexedBlockfor the client's own chain reads the RPC head once when it would resolve, and rejects withNotConfiguredErrorin the same case. A client with no chain endpoint skips that read.- A binding refusal on a write path crosses as the
NotConfiguredErroritself. The session executor previously read any non-node failure as "the transaction may have been sent" and reportedTransactionOutcomeUnknownError; a check that refuses the endpoint never sends, so there is no hash to look up.
- Construction throws
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 withsessionPrivateKey(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 })andcreateExecutor({ safe, inner })throwInvalidInputErrorwheninnerwas built bycreateExecutor({ sessionSeed }).accountForrefuses 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 issessionPrivateKey(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 })orsendSessionTransaction.
BREAKING: a write whose signed transaction may have reached the node, and whose receipt was not observed, now throws the new
TransactionOutcomeUnknownErrorinstead of a plainRpcError. 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 carrieschainId,operationandhash(absent only for a session send). The transaction can still mine, so look uphashbefore you send the write again. A refusal the node answered stays a plainRpcErrororContractRevertError; a refusal is recognised by the mempool status byte the node puts in the error'sdata, since it reports every mempool verdict — including its own send timeout — as JSON-RPC-32000. Migration: test forTransactionOutcomeUnknownErrorbeforeRpcErrorin every write's error handler, and do not retry it blindly. It extendsRpcError, so an existinginstanceof RpcErrorarm still catches it. Code that testserr.name === "RpcError"no longer matches these failures. A failed receipt wait now reportsoperation: "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 throwsInvalidInputErrorbefore it is sent.
Patch Changes
The multi-chain collateral pre-check in
createOrdernow 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, soCollateralOnWrongChainErrorcarries no plan and an emptyelsewhere. A registry token under another market label, for examplebUSDso, now gets its route.elsewherenow lists only the other chains the registry lists that token on.ensureFundsstill 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,ensureFundsnow throwsInvalidInputErrorfor a positive amount.CollateralOnWrongChainError.tokenis now always lowercased, so it no longer differs in casing between theensureFundsandcreateOrderentry points.exchange.ensureFundsandcreateOrder(…, { 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
ensureFundsor 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 checkdelivered(messageId)before it bridges again. exchange.close()now ends the delivery waits of everybridge.transfer,ensureFundsand auto-funded order that started before it.bridge.transferresolves withdelivered: false, andensureFundsthrowsBridgeDeliveryTimeoutError. The leg stays pending.
- A leg that timed out stays pending on the exchange. When the next
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 requiredchainIdonUnified*values andTradable, 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:
indexerUrlis optional on every chain, including Hideki. A client used only for chain reads and writes needs none.- A
walletClientsigner must implementsendTransaction. The SDK does not callwriteContract. An adapter that implements onlywriteContractfails every write withRpcError. UnifiedTicker.statsStatedoes not narrowbaseVolumeorquoteVolume. Check the volume forundefinedtoo.
Other documentation fixes: the two-chain example in
docs/EXCHANGE.mdimportedhidekiMainnet, which does not exist, and now pairssomniaShannonwithhidekiTestnet.docs/reference/networks-and-endpoints.mdnames the chain exports of@somnia-chain/markets-sdk/chains.docs/explanation/about-the-exchange-instance.mddescribes per-engine nonce lanes.SOMNIA_TESTNET_ADDRESSESandSOMNIA_HIDEKI_TESTNET_ADDRESSESnow setperpPoolFactory. A binding that uses either preset lists its perps from the chain, also when it has noindexerUrl. Before this change, a Shannon or Hideki binding without an indexer listed no perps.SOMNIA_MAINNET_ADDRESSESdoes not setperpPoolFactory.A configured
perpPoolFactorytakes 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, removeperpPoolFactoryfrom 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 raisesInvalidInputErrorbefore anything is signed or sent. Before this change, a write to such an account succeeded and executed nothing. A failed code read raisesRpcError. Successful checks are cached. This is a new chain read on the path with nosignerand nothrough, which read nothing before. On a client with no chain transport, that write now throwsNotConfiguredErrorinstead of sending. With asignerorthrough, 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 withGS026. 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
createOrdernow sizes a spot order by the pool'sgetAutoPullRequirement, 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 noCollateralOnWrongChainError. 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 throwsCollateralOnWrongChainErrorwith no bridge plan, also underfunding: "auto", because the pool does not pull from the wallet.
0.32.0-rc.1790260897085
Minor Changes
BinaryMarketFiltergains two facets for counting settlements inside a window, and the binary count helpers no longer require a lifecycle phase.voided?: booleannarrows to markets that settled by void (true) or to every other market (false). It reads the explicitvoidedflag every void path writes, not the event-derivedstatus.resolvedSince?: number | bigintis an inclusive lower bound, in unix seconds, on the market's terminal timestamp. It takes abigintfrom a chain read or an indexed row as well as anumberfrom the clock, and sends either as an exact decimal string. It impliesresolved: true: a market with no terminal event never matches.countBinaryMarketsandcountBinaryMarketsBoundedtakeBinaryMarketCountOptions, on whichphaseis now optional. Omitted, the count spans live and past markets alike. Existing calls that passphasebehave 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 withvoided: true. Both are keyed on the terminal timestamp rather than onexpiry, so a market voided before it expired is counted, which aphase: "past"count would miss.BREAKING:
getLiveUserOrdersnow keeps up to 400 recent unreferenced terminal orders per pool, along with every open order and orders needed by retained fills. UsegetOrdersfor older terminal history.Add
client.listVaultPayoutFallbacks(options): the vault-credit fallback log across every owner, filtered byowner,tokenand/orpool, windowed withfrom/to, orderedascordesc, paginated. EachVaultPayoutFallbackrow now also carriespool, the pool whose vault holds the credit, so the live claimable amount can be read withclient.getVaultBalancewithout a second lookup.getVaultPayoutFallbacksis unchanged apart from the new field.Add
listMarketResolutionEventsandlistMarketReferenceLinks— cross-market sweeps over resolution health, wheregetMarketResolutionreports one market.listMarketResolutionEventsfilters onmarket,kinds(a newMarketResolutionKindunion, matched exactly) andsinceTimestamp, so a caller watching for"Skipped"/"Failed"pays for its window instead of over-fetching.listMarketReferenceLinksfilters onpendingandmarket, and returns oldest first so the longest-stuck market heads the list.MarketReferenceLinknow also carriesblockNumberandtimestamp; whilependingis 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.hidekiTestnethas 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.createBridgeTransferandsendBridgeStepwere not affected. They use the router addresses, which were already correct. OnlygetBridgeNetworkandlistBridgeNetworksexposed 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) mailbox0x46bA2807…e0A90xF805734a…54f9interchainSecurityModule0xa831F40f…1B6c0x99e1D7E9…d843merkleTreeHook0xD7C2E95c…332D0x64aa1c6a…D50CvalidatorAnnounce0xB0834b2d…F44e0x81b741D6…1033interchainAccountRouter0x1384Ed70…22eF0x2fbF56e1…2E19proxyAdmin0x735653D2…D7750x79f22212…1FaEWhy this release is breaking. The lane now uses the names the
hyperlane-bridge-infrarepo 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.docspoints to the Hideki 2 deployment record.
How to migrate.
- Stored lane ids or route ids. Update them to the
hideki2testnetspelling. Config files, dashboards, analytics keys and test fixtures hold these. Compare againstSOMNIA_BRIDGE.idorroute.idinstead 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.
displayNameis presentation text. Pin your own string if you need a stable one. - Nothing to do if you call only
createBridgeTransfer,sendBridgeSteporgetBridgeToken. No router, token or decimal value changed.
Chain definitions do not change.
hidekiTestnetandChainId.hidekiTestnetkeep their names. Both still resolve to 50383. Only the bridge lane's identifiers move. This release adds no second chain export, becausesomniaChains,ChainIdand 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'sdefaultIsm,defaultHookand ERC-1967 admin slot agree with the registry, and thatvalidatorAnnounce.mailbox()andinterchainAccountRouter.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.baseVolumeandquoteVolumeare now optional, andfetchTickerno longer fails whole when the candle read fails.fetchTickeropened withgetCandles, 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
statsStatefield says which state the 24h half is in:"ok"— the window was read. The volumes are present, and they are0when the window held no fills. That zero is a true value, unchanged from before."unavailable"— the candle request failed with anIndexerError, or the market isindexed: falseand no request was sent. Thenopen,high,low,change,percentage,statsAsOf,baseVolumeandquoteVolumeare all absent, the call still resolves, and the perp block still runs.
The containment is narrow. Only
IndexerErroris caught; every other error propagates.Migration:
baseVolumeandquoteVolumeare nownumber | undefined. ReadstatsStatebefore 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) pollsfetchTickerevery 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
FillDatasetgeneration. Deploy and reindex the producer before upgrading the SDK; handleFillHistoryUnavailableErroras a producer rollout failure, and discard accumulated pages and start at page one onFillHistoryRestartRequiredError.Add
SOMNIA_HIDEKI_TESTNET_ADDRESSESfor chain 50383 from the deployment manifest. The preset omitscollateralRouterandlendbecause 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 inDEFAULT_INDEXER_LIMITSare 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 newIndexerUnavailableError. 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 newnextBackoffDelayMshelper 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.
IndexerUnavailableErrorextendsIndexerError, so existinginstanceof IndexerErrorhandling keeps working unchanged and every documented error union stays true. Forbreaker-open,breaker-trial, andqueue-full, catch the narrower type to distinguish “we did not send” from “the indexer did not answer”. Alive-resetcan instead end a retry backoff after an earlier attempt was sent.Its
reasonsays which gate refused, because the four cases want different things and only one of them is a fixed wait:reasonYour move breaker-openSleep retryAfterMs, then retrybreaker-trialRetry shortly; the trial read decides queue-fullBack off on your own schedule, or send less live-resetRetry on the same client; stopLive()leaves it usableSomniaMarkets.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.retryAfterMsis meaningful only forbreaker-open, and is0elsewhere — there the wait is the caller's own backoff, not a period the SDK knows.The boundary itself does not retry.
maxAttemptsdefaults 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'sIndexerError, not anIndexerUnavailableError: 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 withIndexerUnavailableError— the client that would have admitted it is gone. Only a caller that exceedsmaxInFlightconcurrent reads can observe this, and the rejection is anIndexerError, 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
InvalidInputErrorwhen the client is built: a count that is not a whole number of at least 1, a delay of0or less, or any non-finite value.nextBackoffDelayMsrejects the same inputs directly, since a zero, negative orNaNbound yields a delay every timer fires immediately.DEFAULT_INDEXER_LIMITSis frozen, so one consumer cannot retune every client in the process.- In-flight cap (
Add
client.getOpenOrdersOnchain(pools, owner, opts?), which lists an owner's full resting orders from chain at one pinned block, and givegetOwnOpenOrdersOnchainablockNumberoption.getOpenOrdersis 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
getOwnOpenOrdersOnchaintook noblockNumber, 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
getOrderfor every id. The id read is never routed through Multicall3 — the pool's view answers formsg.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
isBidand no YES/NO side, which is not derivable on a binary pool; there is no market, symbol or history.getOpenOrdersis 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 wroteseedTruncated: falseover pool A'strue, andfillsLoadedturnedtrueas 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.
seedTruncatedsays a book is short at its deep end.fillsLoaded: falsesays 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, ornullwhen 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, andstopLiveclears them all, sonullalways 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
watchMarketcalls, which already worked and is now tested and documented as the supported route. The TSDoc gives the read cost:2 × maxPagescontract reads per pool in the worst case, 80 at the defaults, plus one module read for the market row.Add
exchange.perpDiscoverySkipped, which reports thatloadMarketsdid not look for perp pools at all.loadMarketsfinds the PerpPoolFactory either from a configuredaddresses.perpPoolFactory, or from themarginBankof 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 stayednull. 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 ofperpDiscoveryError, because that one is typedSomniaMarketsError | nulland 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 andregistryReadErroralready names it. For one engine it is likewise never set together withperpDiscoveryError. 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.tsanddocs/reference/configuration.mdno longer describeperpPoolFactoryas 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 currentmarketId. 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
nullonly 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 throughgetBinaryMarketByAddress; this read survives an outage.A chain-seeded
watchMarketnow also rejects amarketIdthat 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,fetchOpenOrdersandfetchBalanceused to label rows from an earlier market with the symbol of the market currently on the pool, and a ref-scopedfetchMyTradesorfetchOrderson a binary market returned every market that ever used its pool. Rows now resolve and scope by market id.fetchTradesis 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:
fetchBalancereports the holding under that market's own symbol andredeemaccepts the symbol, whilefetchMarkets()keeps listing only tradable markets. A position on a market the indexer cannot name now makesfetchBalancethrowIndexerErrorinstead of silently omitting the key.BREAKING: pool-routed verbs (
createOrder,mintSet,burnSet,fetchOrderBook,fetchTrades,fetchOHLCV,fetchTicker,watchOrderBook,watchTrades,watchOrders,watchMyTrades) throwInvalidInputErroron 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 fromfetchBalance.
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 withIndexerError.Correct the
UnifiedTicker.lastSourcedocumentation and the read-tiers reference.lastSourceis present on both paths that produce alast, not only on the candle path. The cached fallback stamps the market row'slastTradeAt, so the field is absent only whenlastis absent or that timestamp is null. The previous text described absence as the ordinary stale signal, which understated how narrow that case is. Theread-tiers.mdreference gains the same correction, a correctedgetOpenPositionsWithPnLrow, separate rows forfetchPositionsandfetchFundingRate, 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,SettlementParametersNotConfiguredand 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 correctsNotExpired(uint64)to theNotExpired()the protocol actually declares.Document where a stop order's limit price is available. Perp stop rows from
listPerpStopOrdersdon't includelimitPrice; fetch it for live stops withgetPerpStopOrders. A spot LIMIT stop's limit price can't be read back after placement, sogetSpotStopOrdersandfetchOpenStopOrdersleave 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, theirBoundedvariants,countOrders,countUserFills,countOperatorsandcountVenues) no longer build their query text at call time. Each counted entity has one document for the_aggregatecount 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_aggregatedo not change.When a count read fails,
IndexerError.operationnow names the entity's own document, such asMarketCountorOrderCountFallback. Before, it was the sharedCountorCountFallbackfor an indexer failure, andaggregateCountfor a response that did not contain the counted field. Update any code that comparesoperationwith 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.
BinaryMarketFiltergainsresolved?: 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 onstatus, and the distinction is the reason it exists.statusis 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) stampsresolvedAtTimestamp, 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, andsettlementWindowis 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 laterregisterSeriescan 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
SomniaMarketsClientimplementations must addgetFillsPageandgetLatestFillBlock.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 throughnextCursor. 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/untilwindow 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, ornullwhen none is indexed. It reads the fill data rather than the indexer's processed-block watermark, so it never names a blockgetFillsPagecannot yet serve.FillsOptions.toBlockboundsgetFills,getUserFills,countUserFillsandgetUserFillsPagefrom above, alongsidesinceBlock. Both bounds are inclusive and merge into one comparison. An inverted range rejects withInvalidInputErrorinstead of answering with no rows.Fillrows 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 onFillRowstays 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 throwsNotConfiguredErrorwithout 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 onsent; a sent result carries the same per-idoutcomesascancelOrders. 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 — usecancelExpiredOrdersfor 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 reportedcancelled: true(its removal emitsOrderExpired).Add
trader.buildCancelOrdersandtrader.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:
Candlegains a requiredlastFillIdfield andgetCandlesnow selects it.lastFillIdis 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.
getCandlesselectslastFillId, so it errors against an indexer whose schema predates the field. Deploy the matchingindexer/schema.graphqland run a full reindex before upgrading the SDK. - Constructing
Candlevalues? AddlastFillId(a${blockNumber}_${logIndex}string). Read-only consumers just receive the new field.
- Reindex first.
BREAKING:
BinaryMarket.strikeandBinaryMarket.modeare 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: awatchMarket(pool)call with no options keeps the indexer snapshot and the existing meaning ofsnapshotBlock. 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.binaryModuleconfigured 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 survivesstop(), 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.strikeandBinaryMarket.modeare nowstring | nullandBinaryResolutionMode | null. A chain-seeded row carries null for both, because the pool contract answers neither and a strike of0is the protocol's sentinel for reference (up/down) mode — writing it would permanently mislabel a fixed-strike market.boundaryPricereturnsnullfor 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 emptycreatedAt/txHash, so they sort last inuserOrdersuntil a snapshot dates them.getLiveStatus()reports four new fields.seedSourcesays whether the book came from the indexer or the chain, and isnullbefore the first watch.seedBlockis the block the seed was consistent to.seedTruncatedis true only when a chain seed reaches its own page budget and read a shallow book.fillsLoadedsays whether fill history was loaded, which a chain seed does not load:getLiveFillsreturning an empty array now means "never loaded" whenfillsLoadedis false, and "no fills in the loaded range" when it is true. A failed fill read reportsfailure.operationas"fill-history"and leaves the chain-seeded book intact.getAllOpenOrdersOnchainacceptsblockNumber, so a multi-page snapshot of one side can pin every page to one block.Add
client.getBookTopsOnchain(markets, opts?)and givefetchTickerprovenance fields.getBookTopsOnchainreads the top of book for many markets from pool state, at one pinned block. It is the chain-sourced sibling ofgetBookTops, 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 ownpoolAddress. 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.UnifiedTickergains three fields.lastSourcecarries the time of the trade behindlast, and its absence now meanslastfell back to cached market metadata that can be stale.statsAsOfcarries the 24h statistics' own as-of, derived from the candle window rather than the response time;timestampis unchanged and remains the time the snapshot was assembled.perpStatereports one of"not-applicable","ok", or"degraded", so a missingmarkPriceon 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.getOpenPositionsWithPnLnow marks positions from the chain-sourced tops. It previously marked from indexed tops whilegetBinaryPositionPnLmarked from chain, so the same position could report two differentunrealizedPnlvalues 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.readErrorsand its aggregate cause. Inspectexchange.collateralReadErrorsafter a successful funding operation to detect an incomplete sweep. Sortunreadin the error constructor, including for directly constructed errors.Add
CollateralOnWrongChainError.unreadto list chains whose balance read failed. The error message also names these chains, so callers can distinguish failed reads from zero balances.elsewherekeeps successfully read balances.unreadis empty when all reads succeed.BREAKING:
FillRowgains two required fields,takerRemainingQuantityandmakerRemainingQuantity.getFillsandgetUserFillsnow return the post-fill remainders — the two raw-unit amounts theOrderFilledevent 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,getOrderFillsandgetTradeContextalready 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
pnlEventsFororbinaryFillsFor— 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-testnetdeployment 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 id50383has 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-sdkto the previous minor, or construct the chain yourself with viem'sdefineChainusing the v1 hostapi.hideki.infra.testnet.somnia.networkand Multicall30x540B091b608f54E603c5dC19F6b2d955e1d2D131. Note v1's/wsalready 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 at0xcA11bde0…, which v2 predeploys at block 1 — the reverse of v1, where the canonical route was unreachable and the bridge infra deployed the same runtime at0x540B091b…(which holds no code on v2).net_versionmoved50000258→50000259and 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
mainby 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, butspot-tokens.hideki-testnet-development.jsonis 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
getOrderHistoryPageas the preferred name for paginated indexed order history while retaininggetOrdersPageas a compatibility name.Add opt-in indexer freshness and response-associated read observations through
client.getIndexerFreshness()andclient.createObservedReads(). The existinggetSyncStatus()remains metadata-only andfetchStatus()remains local-only;fetchDataStatus()combines independent data-source measurements.Add precise per-asset delivery health through
getPriceHealth()andlistPriceHealth(), alongside unchanged legacy price status. AdduseWatchMarketResult,useWatchUserResult, anduseWatchPriceResultfor typed acquisition errors while existing hooks retain their background warning behavior.listLiquidationsreturns liquidation events with an optionalkindfilter, applied server-side.It replaces
getLiquidations, which returned an array under aget*name. The old name forwards the filters verbatim and is deprecated, the same one-release compatibility pathgetFundingRateHistorytook.Liquidation rows now come back in a stable total order:
timestamp, thenblockNumber, then theidprimary key.Previously the query sorted by
timestampalone. Every row a block emits carries that block's timestamp, so ties had no defined order between requests and anoffsetpage 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,poolandkindwhen 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.
LiquidationEventrows 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.kindis matched exactly, in the schema's own casing.It is typed as
LiquidationKind, a new exported union over theLIQUIDATION_KINDvalues. A misspelling is now a compile error rather than a silently empty page.LiquidationEvent.kindstays astring: a row carries whatever the deployed indexer wrote, which can outrun a pinned SDK.The exported
isLiquidationKindguard bridges the two — narrow a row's kind to switch on it, or to pass it back into the filter.getPerpFeedStatusreads whether a perp pool's mark feed is live and how much open interest is riding on it.Prefer it to
getPerpStatefor MONITORING a feed.getPerpStatebatches withallowFailure: falseand one of its legs is a bareIOracle.getPrice(), so a dead or rotated oracle rejects the whole read — losing the mark verdict in the one case it matters most.getPerpFeedStatusreads 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 leavesindexUpdatedAtundefined instead of failing the call.It returns
PerpFeedStatusand throwsSomniaMarketsClientGetPerpFeedStatusError. ReadgetPerpStateinstead when you want the full pricing and funding picture and a dead oracle is a legitimate reason to fail.listRegistryMarketsCheckedislistRegistryMarketswith 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 > 0says 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 returnsRegistrySweepand throwsSomniaMarketsClientListRegistryMarketsCheckedError.The two new options (
kind, andoffseton the new name) are additive. The ordering change is not: it is the one thing in this release an existinggetLiquidationscaller 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
OpenOrderorOrderRowvalues must adduserData. CustomSomniaMarketsClientimplementations must addwaitForIndexedBlock.getOpenOrdersandgetOrdersnow return each row'suserData, the opaque uint64 tag the placement carried, as a decimal string.OrdersOptions.userDatafilters on it, so a client correlates rows with the orders it sent instead of keeping its own map.OrdersOptions.market,OrdersOptions.poolsandOrdersOptions.marketsscope 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,marketsandpoolidentifiers must be non-empty strings. Invalid runtime values now reject withInvalidInputErrorinstead of reaching the indexer or leaking a JavaScript type error.getOpenOrders,getOrdersandcountOrdersnow require a non-empty owner string before normalization.getUserFillsandcountUserFillsrequire a non-empty account string.getOrderFillsalso requires a non-negative bigint or canonical unsigned decimal order id. Invalid runtime inputs reject withInvalidInputErrorbefore network I/O.SomniaMarketsClientGetOrderFillsErrorexports the completeInvalidInputError | IndexerErrorcontract for the one-order fill lookup.sinceBlockbounds order and fill reads by block. On fills it boundsFill.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 newIndexerWaitTimeoutErrorwhen the deadline passes, which is a different decision from a failed read. The wait ends when the client's configured signal, its ownsignal, 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/unlinkPerpWalletfor the two-sided consent handshake, andrepayPerpMainFunding/recallPerpMainFundingfor 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.
previewPerpOrderMarginandgetMaxPerpOrderSizeunderautoPullnow 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. NewfundingPayer,mainWalletCapacity,ownWalletPull,mainWalletPullandmainFundingBlockedfields, plus"mainWallet"and"mainFundingBlocked"onlimitedBy.topUpRequiredis 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 guaranteewatchMarketgives a book, and a reconnect heals from chain with no second indexer touch. Read them withlistLiveLiquidations/listLiveFundingPayments, or theuseLiveLiquidations/useLiveFundingPayments/useWatchPerpMarketshooks.Rows are the same
LiquidationEventandFundingPaymenttypes the one-shot reads serve, in the same order, so a live page and a paged history merge byid. 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, andTailStatus.watchCountnow 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
getFundingPaymentsnow orders by(timestamp, id)rather than by timestamp alone, matching whatlistLiquidationsalready does for liquidations with(timestamp, blockNumber, id). Settlements share a block timestamp, so without a tie-break which rows alimitkept 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, anduseWatchPerpMarketsResultexposes the typed acquisition failure that the warning-only hooks otherwise turn into an empty feed.TailStatus.watchCountalso counts a discovery-onlywatchAllMarkets(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 aMarketCreatedmissed 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
PositionUpdatedorFeeCharged) no longer wakes store subscribers.After
stopLive()the plane's last rows stay readable, like every other live read;getPerpPlaneStatus()then reports"unwatched".getPerpPositionAnalyticsandlistPerpPositionAnalyticsnow return the position's signedsizeon an unpriceable row, alongsidepoolandasOfBlock. 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'sactivePerpPoolslist 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 onsize !== 0nto drop those.getOrderOnchainaccepts a trailing options object withblockNumber, so an order can be read as of the end of a past block rather than only at head.SomniaMarketsClientGetOrderOnchainErroris exported alongside it.This recovers a maker order a fill already removed. A fill deletes its maker order in the same transaction, and
OrderFillednames the maker only by id, so at head that order is gone;fill.blockNumber - 1nis 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),quantityFilledandmakerRemainingQuantity.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, andreduceOrderlowersfullQuantityandquantityRemainingtogether 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'sOrderPlaced.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.getVaultBalanceaccepts an optionalblockNumber, 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 —
pnlEventsFornow rejects rows carrying a malformed numberpnlEventsForreads each row'sidfor the first time, to order events by chain position, and throwsInvalidInputErrorwhen it cannot: anidthat is not${blockNumber}_${logIndex}.Previously
idwas not read at all and ordering used the timestamp alone, so a malformedidwas 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'samount. These went through a bareBigIntbefore, which threw a nativeSyntaxErroron"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
getUserFillsand the router-action reads already carry well-formed ids, so callers passing indexed data through are unaffected. A caller assembling its ownFillRow/RouterActionRecordvalues — test fixtures especially — must setidto`${blockNumber}_${logIndex}`, and must givequantity,fillPriceandamountas unsigned decimal strings.EVERY one of those positions is now checked the same way: a sign (
"+1","-1"), an0x/0o/0bprefix ("0x10"), surrounding whitespace (" 7 "), or an empty string is REJECTED rather than coerced.BigIntaccepted 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.timestampis no longer read at all, so its value cannot affect the fold. The newPnlEventsForErrorexport names the error union.getBinaryPositionPnLandgetOpenPositionsWithPnLfetch their own rows, so the same failure means the INDEXED data is corrupt rather than that the call was wrong. Those two report it asIndexerError— the retry-or-escalate path — with the parse failure oncause. A consumer catchingInvalidInputErroraround either method to surface "bad arguments" must add anIndexerErrorarm.SomniaMarketsClientGetBinaryPositionPnLErroris now exported and names that method's full error union.Fixed — same-second PnL events are folded in chain order
pnlEventsForordered 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
computePositionPnLreleased 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 itwatchPricerejects with aNotConfiguredErrornaming the key. To migrate, pin the feed to one quote -priceFeed: { url, quote: "USDC" }, which is whatSOMNIA_TESTNET_PRICE_FEEDandSOMNIA_MAINNET_PRICE_FEEDalready 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
requestIdcursor, 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. ArequestIdpast 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 whoserequestIdis unusable can no longer corrupt the cursor; it is ignored, and the rest of the batch still advances it.PricePoint.raw.priceandPricePoint.raw.emanow report"0"for a tick the feed recorded with no price, matching thepriceandemanumbers beside them. They previously reportednulldespite 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)androundPriceToTick(price, tickSize, direction). Both take and returnbigint.They serve the engine path. A caller who holds raw values and calls
Trader.placeOrderdirectly had no exported helper for this, so each one re-derived tick and lot rounding by hand. The unified facade already snaps for itself, soexchange.createOrdercallers do not need these.directionis"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
roundPriceToTickfor a price. It never returns0, which is the one price the pool always rejects: a positive price below one tick comes back as one whole tick.snapRawToGridfloors a quantity to0instead, because an empty size is a real answer the caller must branch on.Both throw
InvalidInputErroron a non-positive step. Neither checksminQuantity, 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 documentednullfor a real absence, and that meaning is now narrow and stated per read below.getClosingPricereturnsnullonly for a pool that does not declareclosingPrice(). A transport failure throwsRpcError. A revert that names an error or a reason throwsContractRevertError. Migration: keep treatingnullas "this pool predates the capture surface", and handle a thrown error as a failed read.tryGetPerpLeverageImSurchargereturnsnullonly 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 amarginBankaddress 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.getSystemInfothrows when any configured contract read fails. The one exception issettlement(): a pre-v2 module does not declare that view, so its absence still reportssettlement: nulland the configured fallback. It no longer resolves withmarketCount: 0n,operatorId: 0, and a zerovenueId, 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, andSystemInfo.usdc.decimals. Migration: handle a thrown error where the call is made. Keep the checks on themarketCreator,oracle, andusdcobjects themselves, because each is stillnullwhen its contract is not configured; only the checks on the five leaf fields inside them can go.getTransactionSummaryreturnsnullfor 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.
PriceFeedStatusgains 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 awarnevent 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 ongetPriceStatusoruseWatchPrice.BREAKING:
loadMarkets()no longer throws when the market registry read fails. It contains that failure and reports it through the newregistryReadErroraccessor.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.
registryReadErrorisnullwhen the read completed and holds the typed error when it did not — in practice anIndexerErrorcarryingoperationanddetail, with the underlying failure incause. SoregistryReadError === nulltogether with an emptysymbolsis the unambiguous "this venue has no markets". The value is cleared on every load that runs, so it always describes the last one. Retry withloadMarkets(true), because a bareloadMarkets()early-returns the cached registry.Migration: replace a
try/catcharoundloadMarkets()with aregistryReadErrorcheck 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 configuredaddresses.perpPoolFactory. Binary and spot still come from the indexer in this method; new binary markets are discovered from the chain bywatchMarkets({ discover: true }).BREAKING:
IndexedOperatorandIndexedVenuegain a requiredupdatedAtBlockfield. Reading rows is unaffected —listOperators,getOperator,listVenuesandgetVenuesimply 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, soupdatedAtBlock: 0nis 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
updatedAtBlockmeans 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.updatedAtBlockmoves onOperatorRegistered,OperatorUpdated,OperatorEnabledChanged, the three ownership-transfer events, andVenueCreated— that last one because creating a venue bumps the parent operator'svenueCount.IndexedVenue.updatedAtBlockmoves onVenueCreated,VenueUpdatedandVenueEnabledChanged, 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 andSDK-TYPE-007requires those to be exact at the boundary — the generated wire string is now mapped tobigintbehind the four reads above rather than handed through. The strings beside it predate that seam and are unchanged.A row whose
updatedAtBlockis not an unsigned decimal integer now fails those four reads withIndexerError, naming the GraphQL document, rather than escaping the SDK as a nativeSyntaxErrora caller narrowing onIndexerErrorcould not catch. The shape is checked before conversion rather than relying onBigIntto throw, becauseBigIntaccepts more than the wire format allows:""and whitespace become0n,"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
updatedAtBlockfailslistOperators/getOperator/listVenues/getVenueoutright; there is no partial result that skips the bad row.BREAKING:
UnsignedCallnow carrieschainId(andfunctionName). 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 anUnsignedCallitself (a custom relayer or bundler feeding calls to an executor, a test fixture pinning the shape) must addchainId: <the chain the call runs on>; every built-in executor refuses a call without a matching one.functionNameis optional on a hand-built call (it only feeds error messages) and is set on every call the SDK builds.createTrader({ executor })takes anyExecutor— who signs and sends, separated from what the write is. The built-ins come fromclient.createExecutor(spec): a private key or local account, a wallet client, a Somnia session seed, or a safe driven by any inner executor (with optionalMultiSendbatching, so an approval and the order it enables land in one safe transaction). Any object implementingExecutorworks 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 asContractRevertError, 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 asRpcErrorrather than raw. UnsignedCallnow carrieschainId. Every built-in executor refuses a call for another chain withInvalidInputErrorbefore anything is signed (assertCallsOnChainis exported for custom executors).decodePlaceOrder,decodeAmendOrderanddecodeAmendOrdersare exported pure functions over a receipt, byte-for-byte what the sending verbs produce.Trader.executornames who sends. Verbs that sign rather than send (signRedeemAuth) need an executor withsignTypedData; a safe or a session key has none and those verbs throwSignerRequiredError.- Existing callers change nothing, with one observable exception: a
walletClienttrader's pre-send rejection now reportsRpcError.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/walletClientotherwise build the matching built-in executor on the engine's own nonce lane and send ordering.
- The writer hands an executor one or more
One exchange over every chain the account trades on — Somnia L1 and Hideki — with a safe as a first-class trader
SomniaMarketsacceptschains: [...]and builds one engine per chain. Reads merge across chains, writes route to the chain a market lives on, and every struct names itschainId. 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, everyUnified*struct and everyUnsignedCallcarrychainId.ChainMarketRef/ChainOrderRef({ chainId, id }) are accepted wherever a bare id is; a bare id that two chains know throwsInvalidInputErrorinstead 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 withengine.createExecutor(spec)(the executor seam shipped separately). - Collateral placement. A market's collateral must sit on its chain. On a
multi-chain exchange
createOrderchecks the escrow balance there and throwsCollateralOnWrongChainError— chain, token, shortfall, where the funds are, and a ready bridge plan — unless{ funding: "auto" }(bridge, wait, place) or{ funding: "skip" }.exchange.bridgeplans and executes transfers between the exchange's chains through the account's executors;exchange.ensureFundsis the helper behind auto-funding. - Portfolio netting.
netInternalTransfersdrops bridge legs between the exchange's own chains from a funding list;fetchPortfolioAnalyticsapplies 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.
- Chain-qualified identity.
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 — nomultiSendknob); theinnerexecutor sends the outer transaction, so writes keep the engine's realtime send, nonce lane and retry stance. With nosignerand nothrough, nothing signs and nothing is read (the pre-validated path — the session-key arrangement); asigneror athroughchain of parents switches to one owner signature the accounts SDK nests level by level, at any depth. An explicitgasbelow 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'sthrough),tree,holdings,health, and the build verbsbuildCreateAccount,buildAccountAuthorization+buildCreateAccounts(one signature, every covered chain),buildAddOwner/buildSwapOwner/buildRemoveOwner/buildChangeThreshold,buildTransfer. Reads ride the client's own chain transport; every write returnsUnsignedCalls 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:scanacross every covered configured chain,buildCreateAccountsfanning one signed authorization out into one creation call per chain, andaccountFor(accountId, index, { sessionSeed })building theSomniaMarketsAccountthe 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'sGScode 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
inneron another chain, athroughchain with no way to sign — allInvalidInputErrorat 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, andtoCall(signature)is a finished call ANY funded key may broadcast (send it with at leastgasFloorgas) — the sponsorship flow, without importing the accounts SDK. Passnoncewhen it is already known (0nprepares an undeployed account's first action, read-free). indexerUrlis 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 throwsNotConfiguredErrornamingconfig.indexerUrl. Nothing changes for clients that pass it.- New root exports:
SmartAccountsClient,ExchangeSmartAccounts,SmartAccountSigner,SmartAccountInner,PreparedAccountAuthorization,AccountAuthorizationTypedData,UnsignedAccountCreation, the read/build error unions, the puredescribeCoveragehelper, and the accounts-SDK result types the members hand back. New docs:docs/ACCOUNTS.mdanddocs/how-to/trade-through-a-smart-account.md.
- The executor.
listSeriesLatestMarketsreturns 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
limitrows (default 100), not one request for all series however many there are. A caller with more series than a page must cursor through withoffsetuntil a short page comes back.It takes
ListSeriesLatestMarketsOptions(limit,offset), returnsSeriesLatestMarketrows (series,market,createdAtTimestamp) and throwsSomniaMarketsClientListSeriesLatestMarketsError.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
getBookTopsis 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
createOrderon 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 humanamount × price, a float, with no builder-fee headroom. An order with abuilderFeeBpsTimes1kcould pass the check with exactly the principal and then revert at the pool. The check now uses the same integer commitment the writer andBinaryPooluse: ceil principal plus the builder fee's ceil-rounded headroom, soCollateralOnWrongChainError.neededis 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
computePositionPnLpriced 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 to0and 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.
useFundingRateSeriesnow 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 afrom/towindow roll and then re-reads the rolled window. A sync-status failure stops the wait and appears on the new optionalcatchUpErrorfield ofIndexerQueryState, typedIndexerError | 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
startBlockmoves (566759489 → 840122) and venue 1 carries fresh MarketCreatorV2/policy/TestUSDC instances. Consumers that scan fromstartBlockmust 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 aboutBadDebtAbsorbed.stageReachedis a 0-indexed enum, while the published waterfall numbers its stages 1 to 6, so the two need a mapping table;LiquidationEventand the perps guide now carry it. The number names the deepest stage that acted, not the deepest consulted, andCLOBPartialis a fall-through marker that does not prove stage 3 filled anything — readpositionsProcessedwith it.BadDebtAbsorbedfires when the engine asks the insurance fund to cover, not when the fund pays, and itscounterpartynames the fund that was asked; both are recorded even wheninsuranceCoveredis 0. Documentation only; no runtime behaviour changes.Fix wrong decimal scale on binary markets the live tail discovers itself. A market built from a
MarketCreatedevent 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 fromquoteDecimals— was off by a factor of 10^12 until an indexer snapshot happened to overwrite the row. Discovered markets now carry their collateral's realdecimals(), 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()andstopLive()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 — theprocess.exit(0)the guides used to require is no longer needed. The instance stays usable afterwards: a later read reopens a connection, and a laterclose()releases that one too. Instances configured with the samewsRpcUrlshare 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
InvalidInputErrorbefore signing. Failed reads raiseRpcError; existing SDK errors pass through. Successful checks are cached. Single calls do not use or check MultiSend.Preserve
NotConfiguredErrorfrom 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.
homepagenow 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.
- The tarball now contains the 32 hand-written guides under
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.cancelReasonis documented asSelfMatchon every pool type, not perps only. Spot and binary pools now tag theirOrderCancelledSelfMatchremovals 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
Openin 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…) andmarketCreatorPolicy(0x191BBBDc…) for venue 2. Ground truth on-chain (TRAD-223, re-verified):MarketsCore.getVenue(2, 0x679795a0…)returns policy0xa24822b8d4adCC770c8B071e0Dc8e19c39991204, which authorizes only creator0x94D963B6670AB96E78C8d0C46ca35D196d606EFE. The bakedSOMNIA_TESTNET_ADDRESSESbundle now carries those live values. Mainnet is unaffected.
0.30.0
Minor Changes
Three new client reads answer block-scoped questions:
getBlockActivityreturns what one block traded grouped by market,getLatestActiveBlocknames the newest block with markets activity, andgetAdjacentActiveBlocksnames 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.getTransactionActivitytakes an optionalanchor(the transaction's block and timestamp). Supplying it skips theOrder.placedTxHashprobe, 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_FEEDconstant. It is the mainnet price feed,https://price-feed.prd.oracle.somnia.host/v1/graphql, pinned to theUSDCquote.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
PriceFeedScheduleron 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_FEEDis unchanged and remains correct for a testnet client.Publish the perp event ABIs, so a consumer can decode perp logs from chain.
perpPoolEventsAbiis now root-exported, andmarginBankEventsAbiandliquidationEngineEventsAbiare new (18 and 13 events). Before this,perpPoolWriteAbiandmarginBankWriteAbiexported 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.mdhas a new "Watch from chain" section.Add
client.getPerpFundingPremium(pool), which reads the premium the next funding settlement will charge. UsetimeWeightedPremiumto project a funding rate. Do not uselastObservedPremiumfor that: the contract getter behind it kept its signature and changed its meaning, and it is now only the standing instantaneous sample. Checkarmedbefore 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 fromgetPerpState, 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.intervalsAccruedis no longermin(intervalsSettled, n). A settlement now charges at most one interval, however long the gap, and the excess is forgiven. CompareintervalsAccruedagainstintervalsSettledto see whether anything was forgiven;intervalsSettledalone does not tell you.intervalsPerWindowis the per-interval divisor only. On a historical row that caught up over several intervals, one settlement's charge isfundingRate * intervalsAccrued / nrather thanfundingRate / n. The premium driving the rate is the time-weighted impact-price premium, not a book midpoint, soemaPremiumcan be non-zero on a one-sided book.Read the three perp ledgers the reindex made available:
listPerpInsuranceFundEvents(the InsuranceFund's tier ledger behindgetInsuranceFundState),listPerpWalletLinkEvents/listPerpMarginPulls/listPerpMainFundingEvents(the linked-wallet rail's consent graph and both funding sides), andlistPerpOrderRejections(orders refused inside a batch placement). Two aggregation hazards are documented on the types: the insurance ledger'samountmust be folded bykindrather than summed, and the two funding sides record the same wei — summing them double-counts every transfer.listPerpStopOrdersnow returnssiblingOrderId(the live OCO partner),intentandcancelReason, so a stops table no longer needs a chain read per row to show pairing or intent.cancelReasonseparates the three routes toCANCELLED, which refund differently:"Owner"returns the SOMI in the same transaction, while"LinkedFill"and"Inert"only creditunclaimedSomiforclaimSomi().BREAKING — the portfolio trades legs default to the last seven days
getSpotPortfolio,getPerpPortfolioandgetPortfolionow applysince = now − 7 daysto their trades leg unless the caller passessince. A caller that passed nosinceand expected the whole history gets the last week. Each result gains a requiredtradesSince(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-builtPortfolio/SpotPortfolio/PerpPortfoliovalues (fixtures, adapters) need atradesSincefield. A UI that renderstradesshould readtradesSinceand 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
marketrelationshipEvery read that scoped
Fill,OrderorStopOrderby market type or by pool did so withmarket: { marketType: { _eq } }ormarket: { poolAddress: { _eq } }. Hasura compiles a relationship predicate to a correlatedEXISTSonMarketper candidate row, which stops Postgres using themaker/taker/ownerindexes. Measured on the development indexer for one wallet's fills atlimit: 50:stream timeoutwith 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:
poolonFill,market_idonOrderandStopOrder. A market type resolves to its set of pools —_infor SPOT and PERP, whoseMarket.idis the pool address;_ninover 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 themarket: { poolAddress }relationship form: a recycled pool has hosted ~2,000 markets, so an_inof its ids would be the worse shape, and every Order read that takes a pool also carriesowner, 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.getSpotStopOrdersin particular keeps returning every pending stop a wallet holds, spot or perp, as it did.BREAKING for constructors —
PortfolioAnalyticsgains a requiredholdingsseries: what the traded book is worth over timeThe result's
equityfield 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.holdingsis the level that name promised. Each sample sumsqty × markover the open positions, on the same grid asequity, 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 windowThe 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.unpricedMarketscounts 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 addholdings: HoldingsPoint[]or it stops compiling.HoldingsPointis 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:
redeemno longer guesses a leg before resolution, and unified leg selection now uses an options objecttrader.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 aCLOB_SNAPSHOT[p, D−p]one. A holder of only the NO leg got a confusingInsufficientBalancerevert. A holder of both legs — what onemintSetcall 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 fromBinarySettlementwith 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
redeemthrowsInvalidInputErrornaming both legs and pointing atredeemMany; 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 trailingRedeemOptionsbag, 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)withexchange.redeem(ref, amount, { outcomeIdx }). If the market may be voided, pass the leg you hold. To claim both legs, useredeemManywith 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),intervalSecprovenance, and builder-fee revert names are corrected; the exchange guide's verb table gains the ten verbs it omitted and notes thatclose()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 namessendBridgeStepas its one sender; and the twoerrorsTSDoc examples now match the real signatures ofcreateOrderandcancelExpiredOrders.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 rejectedsendSessionTransactionorgetSessionAddressno longer carries the session seed in its error. viem builds a request failure'smessageandcausefrom 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 showederror.message. Both calls now throwRpcError. When the node answered,causeis its own{ code, message, data? }andgetSomniaRpcError/mempoolStatuskeep working on it; when the transport failed first,causeis{ name, message, status? }with the seed blanked out of the request text.isMethodNotFoundalso stops matching the node's genuineaccount does not existandBlock does not existfailures, which the documented degrade-to-nullidiom 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 pushednewHeadsframe by issuing aneth_getBlockByNumberfor 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,OracleHubAdminand the lendLendernow throwContractRevertErroron 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 asRpcError("no event").SomniaMarkets.fetchBalancethrows when a balance read fails instead of reporting0or omitting the key.getBinaryPositionPnLfalls back tolastPriceonly 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 withoutdecimals(), not on an RPC failure.createStopOrdersurfaces a failed mark read as the indexer's error instead ofInvalidInputError("pass triggerDirection").depositVaultNativeno 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,InvalidPremiumImpactNotionalandInvalidLinkedWalletRegistry, all added by the 2026-08-31 testnet upgrade.createOrderandcreateStopOrdernever send a larger quantity than requested. Quantities are truncated to the base token precision before applying the lot-size floor.amountToPrecisionnow truncates the decimal text instead of its binary floating-point expansion. Exact inputs such as1.2therefore remain exact for a0.1lot 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
MarketFinalizedwith a stale trailing argument, so its topic0 did not match the emitted event andliveTaildiscarded every settlement finalize — leavingnetBackingnull 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,takerSideandkindwere missing from the snapshot's fill selection set and hardcodedundefinedwhen the rows were parsed, on a comment claiming the indexer does not carry them. It does: they are real columns, stamped by the indexer'sPendingTakerFillbridge 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, neverOpen, 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,takerSideandkindnow come through from the indexer, so:- A binary fill reports its taker's address, its four-way
BUY_YES/SELL_YES/BUY_NO/SELL_NOtaker side, and itsDIRECT_YES/DIRECT_NO/MINT_A_PAIR/BURN_A_PAIRkind — the vocabularytakerIsBidcannot express, being one boolean covering two sides at a time and carrying no kind at all. - A spot or perp fill reports its taker.
takerSideandkindstay 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.
- A binary fill reports its taker's address, its four-way
Native-base spot sells now send the pool's exact vault shortfall as
msg.value. This includes fee headroom and preventsInvalidMsgValuereverts 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,computePositionPnLandcomputeBinaryPnlreturned a hardcoded half for every voided binary market. A void pays whatever payout vector the market stored. Under theUNIFORMvoid policy that vector is[D/2, D/2], so the half was right. UnderCLOB_SNAPSHOTa 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%.estPayoutForquoted a claim settlement would not pay.All three now read
payoutNumerators/payoutDenominator, which the indexer already carries and the market selection set already requested:estPayoutForreturnsamount × payoutNumerators[outcomeIdx] / payoutDenominatoron a void. A void is never fee-charged, so no settlement fee applies on that branch.computePositionPnLandcomputeBinaryPnlmark each leg from the same vector.computePositionPnLderives the NO leg by subtraction, as it already does while trading, so both legs still sum to exactly one collateral unit.ClaimableInputaccepts optionalpayoutNumeratorsandpayoutDenominator, andPortfolioMarketcarries them, sogetClaimableandgetOpenPositionsWithPnLvalue a void from the vector too.
The payout comes from the vector and never from
voidPolicy. ACLOB_SNAPSHOTmarket stores the uniform vector on every fallback — no capture-capable pool, a revertingclosingPrice(), 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
InvalidInputErrorinstead 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
BinaryMarketwrites for a void: uniform or[p, D−p], withpclamped to[1, D−1]. Rejecting any other present vector keeps corrupt data distinct from the legacy no-vector fallback.Clarify voided-market rounding:
computePositionPnLcalculates each leg asfloor(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 onkind(MarketActivity), newest first, in a single round-trip. Every row carries itstxHash, so a caller can follow any row to the chain. Select the streams withkinds, page backwards withuntil, and passpoolto let the fill read use its(pool, timestamp)index. A spot or perp market returnsTRADErows only — the other four streams read binary-only entities, so asking for them there is empty rather than an error.Pair it with
getLiveFillsfor zero-latency trades: a trade's id isTRADE:followed by the fill id on both paths, so the two merge onid— but merge FIELD BY FIELD, preferring whichever source has a value. Neither is a superset of the other: the tail leavestaker/takerSideundefined on the fills it hydrates, and the indexer'stakerIsBidis null until its taker bridge lands. Cache identity comes from the newmarketActivityKey.Known limit:
untilhas one-second resolution and is inclusive, so the boundary second is re-read between pages and a second holdinglimitor more rows cannot be paged past. A composite(timestamp, blockNumber, logIndex)cursor is the fix; it needslogIndexon 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 sameMarketActivityunion 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 nullblockNumberrather 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 typesTransactionActivity,TransactionActivityOptionsandTransactionOrder, and thetransactionActivityKeycache 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.nullfor an unknown id, which is a stale link rather than a failure. New public typesTradeContextandFillOrder, and thetradeContextKeycache key.
Both transaction-scoped reads used to scan
Fillto select by hash. The indexer schema now carries@indexonFill.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 aMarketRef(symbols, decimals, routing identity) so a page renders from one read instead of a second lookup per row.nullfor 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, notmarket:FillRow.marketis 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 marketgetMarketByPoolresolves. PlusgetSeries(creator, seriesId), the single-row sibling oflistSeries.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;nullfor an unknown one, never a throw.createNetworkTape()— a network-wide order-flow firehose. One topics-only chain-log subscription seesOrderPlaced/OrderFilledfrom 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) → ownermap that attributes fills. Takers always resolve; makers resolve when they were quoted while the tape ran. Rows carry RAW units — join tolistMarketsfor symbols and decimals. Nothing connects until the firstsubscribe, 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
spotPoolOperatorRegistryReadAbiis now exported from the package root. It is the ABI fragment forgetOperatorPermissionsRegistry(), the view that tells you WHICHOperatorPermissionsRegistrya SpotPool consults when it gatesplaceOrderFor,cancelOrderForandreduceOrderFor.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
exportsmap 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 returnsnullfor 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 makeplaceOrderForsucceed. The SDK's own grant reads and writes reject a zero registry withNotConfiguredErrorrather 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.
- Multicall composition. The method issues its own
Added — a portfolio says when its trade history is incomplete
Portfolio,SpotPortfolioandPerpPortfoliogain atradesTruncatedboolean.The three portfolio reads cap
tradesat 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 foldstradesinto 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 signalFundingRateSeries.truncatedalready gives the funding chart.tradesTruncatedistrades.length >= tradesLimit. A full page means older fills exist and were not returned. An empty page is not truncated, and neither istradesLimit: 0, which asks for no trades at all. The default cap is unchanged, and raisingtradesLimitmoves 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 derivesactivefrom the live tradeability gates, so a close-only market no longer reads as tradeable. NewperpStatus/perpDiscoveryErrorsurface. Breaking:indexedis a required property onUnifiedMarket, 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.
spotStopRegistryEventsAbinow carries the whole pending-order lifecycle. It heldPendingOrderCreatedalone. It gainsPendingOrderTriggered(uint128 indexed pendingOrderId, bool success, uint128 indexed spotOrderId),PendingOrderCancelled(uint128 indexed orderId)andInertOrderCancelled(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 noPROVENANCE.jsonentry, so the guard proves SDK-vs-artifact agreement, not SDK-vs-deployed-bytecode.Do not reuse the perp registry's shapes: its
PendingOrderTriggeredcarries 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, ornullwhen 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 throwNotConfiguredError, 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,spotStopRegistryEventsAbiandoperatorRegistryWriteAbi. 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
selfMatchingOptionselfMatchingOptionis now a caller input on all four placement paths, soCANCEL_MAKERis reachable without leaving the SDK. It was pinned to0(CANCEL_TAKER) in each encoder, on a parameter the pools have always taken:placeSpotOrder/buildPlaceSpotOrder(PlaceSpotOrderParams),placeSpotOrders(SpotOrderRequest, per request),placePerpOrder/buildPlacePerpOrder(PlacePerpOrderParams), andplaceOrder/buildPlaceOrder(PlaceOrderParams).amendOrderandamendOrdersalready took it, so every placement and amend verb now shares one vocabulary — the exportedSELF_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 });userDatais settable on single spot placement (PlaceSpotOrderParams), which the batch request shape already allowed. The single verb pinned the tag to0, so the same order encoded differently depending on which verb placed it.placeSpotOrdersforwards each request'sbuilderandbuilderFeeBpsTimes1kinstead of zeroing them. Choosing the batch verb silently dropped the routing attribution thatplaceSpotOrderhonours.
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 exportedBinaryOutcomePositionPnL(balance,costBasis,avgCost,markPrice,markValue,unrealizedPnl,realizedPnl, all RAW).OpenPositionPnLextendsBinaryPositionPnL, sogetBinaryPositionPnLandgetOpenPositionsWithPnLboth 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.20and 10 NO at0.80, mark YES at0.40: the legs are+2.00and-2.00and the blendedunrealizedPnlis0.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,unrealizedPnlandrealizedPnlare stored per leg and added up, so integer division cannot make a published leg disagree with the published total.avgCostandmarkPriceare 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)andtryGetPerpLeverageImSurcharge— 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.
quotePerpOrderTopUpfunds 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 refusedInsufficientMarginForOrderbecause 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
tryvariant reports that asnull, never0n— 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.quotePerpFundingPayerreturns a discriminated union, not an address, because the contract's single zero collapses three situations a UI must not render alike: the rail isdormant(the bank holds no registry, so the feature is off for everyone and linking would not help), the wallet isunlinked(the one case the user can fix), or the walletisMain(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().payeris 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 thanmainOfwhen the question is "who gets it back".principalis what the child may trade but not withdraw (withdrawfrees at mostbalance - principal), and it is not a segregated bucket: the claim clamps tomin(principal, balance)at flat moments, so the child's own contribution is the junior tranche and a loss eats it first.getPerpWalletLinkagederivesisChild/isMainbecause the raw encoding is a trap — a main resolves to ITSELF inmainOf, so the natural testmain !== zerois true for mains and children alike.maturesAtgates 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— returnCountResult({ count, truncated }) instead of a bare number.truncated: truemeanscountis a LOWER BOUND (10,000 rows): render it as "10,000+", and do not gate pagination onrows.length < count, which goes false while rows remain.Without the server-only
_aggregateheader every count falls back to a bounded row scan that returned the rows it fetched — so a scan that filled its cap reported exactly10000, indistinguishable from a real total. The fallback now requestscap + 1and reads the extra row as the signal; the probe row is never counted.FillandOrderare already past the cap in production andMarketcrosses 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/countVenuesget no variant: those tables are orders of magnitude below the cap.countOrders/countUserFillshave none yet, andOrder/Fillare both already past it — copycountMarketsBoundedwhen 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 fromstrike, 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 asBinaryResolutionMode, withbinaryResolutionMode(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 sourcemodenames, withpostedsaying 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.intervalSecnow matches a BAND, not an exact value. A rolled market's indexedintervalSecisexpiry − 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,listPastBinaryMarketsandcountBinaryMarketsall inherit this, so row counts change for any caller passingintervalSec. 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
intervalSecnow THROWSRangeErrorrather 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 wayoperatorIdis already expected to be a positive integer. marketIntervalLabelsnaps to the cadence ladder, soBinaryMarket.intervaland 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 toresolvedAtBlock. 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'sstrikeis the feed's spot atcreatedAtBlock, and that print lands beforetradingStartfar more often than on it, socreatedAtTimestampcannot find it. The field is required, so any code constructing aMarketby hand must supply it; it is non-null on the wire, so every indexed market carries one.getResolutionPrices(marketIds)— batch settlement prices, the counterpart togetOpeningPrices. Joins each market's OWNoracleQuestionId, 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 theOnchainResolutionPricetype — 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 owndecimals(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,snapToCadenceandcadenceBandSec— the SDK-canonical cadence rules, so grouping and filtering read one source instead of each re-deriving a tolerance. See the note onCADENCE_LADDER_SEC: these exist only because the markets indexer derivesintervalSecfrom a market's own window instead of reading the exact valueMarketCreator.MarketCreatedalready emits.
Breaking — the MWRR capital base is deposited capital
mwrr.returnpreviously 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 newfundingevents to measure against real deposited capital; without them the base remains a trades-only proxy, andmwrr.capitalBasisnow says which one produced the number.mwrr.depositedUsdkeeps 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
weightedCapitalUsdto see how much capital the figure is measured against before presenting it as a headline.Every account's reported return changes. Read
mwrr.capitalBasisto 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.
getBinaryPositionPnLandgetOpenPositionsWithPnLcould both report a wrongavgCost,costBasis,unrealizedPnlandrealizedPnl. They now key by the stablemarket_idthatFillRow.markethas 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. getOpenPositionsWithPnLnow 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
computePortfolioAnalyticssampled its equity curve until the sample time reachedasOf. A non-finiteasOf, 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 throwsInvalidInputErrornaming 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 newComputePortfolioAnalyticsErrortype states the errors the function can throw.fetchPortfolioAnalyticsnow 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 anall-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
alltimeframe 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/countUserFillstakemarket(one bytes32 market id) andmarkets(several) alongside the existingpool, as the exportedFillsScope.getRouterActionstakesmarketsasRouterActionsOptions. The predicates run at the indexer, so alimitapplies to the rows you asked for. Prefermarketoverpoolon binary: a pool selects every market that ever used it.
Added — closing-price snapshot voids (TRAD-106)
Venue-selectable void payouts: a
CLOB_SNAPSHOTvenue's markets pay[p, D−p]at their closing YES price when they void, instead of the uniform 0.50 refund.- Venue params v3:
BinaryVenueParamsgains optionalvoidPolicy("UNIFORM"default |"CLOB_SNAPSHOT"; exported asVenueVoidPolicy).encodeBinaryVenueFeeParamsencodes 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 impliedUNIFORM. trader.captureClose({ pool, maxSteps? })— the permissionless closing-price capture that lifts the pool's closing-book lock. Sweep keepers: aCloseNotCapturedrevert 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)(returnsClosingPriceStateornullon pre-capture pools — the selector doubles as the capability probe),closingTopin the pool read ABI, andvoidPolicyongetMarketOnchain(null on pre-policy clones) + the indexedBinaryMarketrow. - Errors: the contract-error table now decodes the capture surface
(
CloseNotCaptured,CloseAlreadyCaptured,CaptureTooEarly,CaptureStepsExhausted,InvalidVenueVoidPolicy,InvalidVoidPolicy).
Added — dead-oracle recovery without
cast sendTradergains the two entry points the recovery path was missing, so the whole chain is SDK-callable (syncSettlement/finalizeMarket/releasePoolwere 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 });pokeOracleis 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) andUnknownOracleQuestion(nothing bound) arrive asContractRevertError, so a keeper loop can branch onerrorName.voidExpiredwrites to the market contract, bypassing the module — hence thesyncSettlementfollow-up to release the hub earmark. Before sending, it reads the market's state and throwsInvalidInputErrornaming the exact unix second the window lapses, because the on-chainSettlementWindowOpenrevert carries no timestamp. The gate is compared against the chain'sblock.timestamp(what the contract itself uses), not the local clock. PassskipPreflight: trueto send blind.
OPERATOR-GUIDE §5's recovery runbook now shows these calls instead of
cast send.Added — funding-aware capital base
mwrrcarries two new fields.weightedCapitalUsdis the Modified Dietz denominator.capitalBasisnames 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.fetchPortfolioAnalyticsaccepts afundingoption, and the newPortfolioFundingEventtype 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.PortfolioFlowEventis now a union ofPortfolioTradeEventandPortfolioFundingEvent. Code that constructs trade events with an explicitkind: "trade"is unaffected.Fixed —
balanceFloorproves its guarantee against the write path's conversionbalanceFloorreturns 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 throughcandidate.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, so1.001converts 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.
floorRawBalanceinherits the fix on its fallback path.ceilRawAmountwas never affected: it returnsbigintand 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 wasgetPerpLeverage, 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, acceptsopts.blockNumberfor pinned rows, and returns0unchanged when no cap is set (that means "no account cap", not zero leverage).Report a perp pool's aggregator oracle address on
getPerpState.PerpStateOnchaingains anoraclefield, read from the pool's ownoracle()view. A consumer building a perp market description fromgetPerpStateno 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
ChainDoesNotSupportContractfan-out.Added — name a revert from your own call
decodeRevert,contractErrorsAbiand theRevertContexttype are now exported from the package root.The SDK could already name a protocol revert:
decodeRevertturns whatever a node threw into aContractRevertErrorcarrying the Solidity error name, decoding againstcontractErrorsAbi(500 custom-error entries). Neither symbol was on the barrel, and theexportsmap 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.ContractRevertErroralone is not a substitute: it is a passive holder that decodes nothing, so constructing one from raw revert data yieldserrorName: 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 }contractErrorsAbiis a generated artifact (pnpm errors:gen, verified in CI bypnpm 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 underSDK-API-003alongside the other ABI data.toSdkErrorandisRevertremain internal; ask if you need them.Breaking —
UnifiedOrder.typemay beundefinedfetchOrders,fetchOpenOrdersandwatchOrdersnow omittypeinstead of reporting"limit".That
"limit"was never a read value. The pools do not emit the order type —OrderPlacedcarries aplacedOrderstruct 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 —PendingOrderCreateddoes carryorderType.UnifiedStopOrder.typeis 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.typefrom a read path, that branch was reading a fabricated value. Handleundefinedexplicitly; the honest source for how an order was placed is your owncreateOrderresult. 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
computePositionPnLandcomputeBinaryPnlvalued 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 nolastPrice, and only a fill writes one.markYesPricereports that correctly asnull. Both PnL folds discarded thenulland put a confident0in 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
nullrather than a number:BinaryOutcomePositionPnL.markPrice,.markValueand.unrealizedPnlarebigint | null.BinaryPositionPnL.markValueand.unrealizedPnlarebigint | null.BinaryOutcomePnl.mark,.valueand.unrealizedarenumber | null.BinaryPnl.unrealizedand.totalarenumber | null.
Migration. Handle
nullon those fields before you display or total them.nullmeans 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,avgCostandrealizedPnlstay 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
StaleMarkin theOrder.cancelReasonvocabulary.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:
cancelReasonremains an openstring | 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
Openin 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 tagsMakerOrderCancelledNegativeEquityandMakerOrderCancelledStaleMarkare now declared and terminate the order;MakerOrderCancelledExceedsPositionandOrderCancelledSelfMatchwere 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
owneralongside the denormalized columns:getUserFills/countUserFills, the binarygetPortfolio, and the user snapshot the live store hydrates from. That arm is a relationship predicate, and Hasura compiles it to a correlatedEXISTSsubquery. Postgres cannot combine such a subquery with a bitmap OR over themakerandtakerindexes: the plan walks thetimestampindex backwards testing each row, and every row it walks past costs a lookup intoOrder.The penalty therefore falls hardest on ordinary wallets, which are sparse in a table that market makers fill. A busy maker reaches
limitin 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.
backfillTakerFillsin the indexer stamps the taker address fromOrderPlacedfor 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 leavingtakernull; that stopped being true when binary side attribution moved toBinaryOrderPlaced.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:sideis what makes a binary row mappable when the fill's owntakerSidecopy is still lagging, and selecting a non-null relationship is an ordinary join rather than a filter. The invariant holds structurally: the indexer writesFill.takerandOrder.ownerfrom the same local in the same handler, so the two cannot disagree — delegated and operator-placed orders included.sdk-e2ealso 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:
perpLiquidationPriceperpOrderMarginQuoteperpPositionAnalytics
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.infois now theFillRowthe tape returned, where it was previously a venue portfolio trade row. It remains typedunknownand 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.remainingandstatusare 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
InvalidInputErrornaming 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, whichcreateOrderuses viatoNativePrice— so the ccxt-shaped surface was broken too, and a fix tofromHumanalone 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 withtoFixed, 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
});
PlaceSpotOrderParamsgainsexpireTimestampNs,builder, andbuilderFeeBpsTimes1k.PlacePerpOrderParamsgains the two builder fields (it already hadexpireTimestampNs).- 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.approveBuilderon that pool — the approval is stored per pool. Seedocs/SPOT.mdanddocs/PERPS.mdfor 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 }. FeedasOfBlockintogetBankruptcyPrice'sblockNumberoption (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-sidegetLiquidationPriceestimate (where liquidation triggers): keepers settle and bid against this one. Reverts arrive named —ContractRevertErrorwitherrorName: "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)—rawrounded UP to a whole multiple ofquantum. The mirror offloorRawBalance, 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
ISpotStopOrderRegistryerrors — includingInsufficientVaultBalance,InsufficientSomiPayment,NoActiveSubscription,LimitPriceIncompatibleWithTriggerandExceedsWithdrawableBalance. - 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 existingQuantityBelowMinimum(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-protocolre-pinned tomain(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 viemChain(mainnet, Shannon, Elwood, Hideki, local). The only place chain definitions live./chainsbridge — the Hyperlane warp-route registry pluscreateBridgeTransfer/sendBridgeStep. Pure: no client, no RPC./reactivity— the upstream@somnia-chain/reactivitypackage re-exported, as an optional peer dependency./native— the node'ssomnia_*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, pluspreviewPerpLiquidationPricefor 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.
previewPerpClosePnlfor 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
autoPullflag, andquotePerpOrderTopUpexposes the bank's own sizing. Seedocs/PERPS.md— the flag is opt-in because the pool gates it onmsg.sender == order.owner, so it must stay off forplaceOrderForand operator-grant flows. - Stop orders (DEX-2154).
placePerpStopOrderwith linked one-cancels-other TP/SL pairs and opening triggers, pluslistPerpStopOrders/getPerpStopOrder. - Build-only writes.
buildPlacePerpStopOrder,buildCancelPerpStopOrder(s),buildDepositMarginandbuildWithdrawMarginreturn 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 viadecodePerpStopOrderIds. Seedocs/PERPS.md. - Funding-rate series (DEX-2025).
buildFundingRateSeriesand 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.
getLiquidationPricereported 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/PerpStopOrderRegistrycall now reportsContractRevertError.errorName—InsufficientCollateral,MarketRestricted,InsufficientSomiPayment— across 122 error names, so an app no longer needs a hand-copied list that goes stale. stopRegistryreaches 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
pairwith 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 carriespairedStopOrderIdalongsidestopOrderId, 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
intentnorpairstill routes tocreatePendingOrderand 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 itscostBasis/avgCost/markValue/unrealizedPnl/realizedPnl, computed identically togetBinaryPositionPnL(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
computeOpenPositionsPnLfold + theOpenPositionPnLtype.
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),entryPriceX18is exported asavgEntryPrice(the column name is a misnomer — the value is raw quote units per whole base, not 1e18-scaled), andrealizedPnlis exported aslastUpdateRealizedPnl(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,creatorHex—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(alwaysmarket.poolAddress)LiveFill.pool/.maker/.taker,LiveOrder.pool/.ownerDecodedEvent.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 onportfolio.positions[].marketandportfolio.openOrders[].market.portfolio.trades[].market.intervalandBinaryMarket.intervalalready 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 | nullIndexedOracleAdapter.createdAtTimestamp→string | nullIndexedMarketCreator.createdAtTimestamp/.factory→ nullable;.createdAtBlock→number | nullIndexedSeries.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:pullnow 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.
sanitizePartandloadMarketsno longer uppercase ERC-20symbol()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 fromloadMarkets()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).computePositionPnLandcomputeBinaryPnlaccept an optionalopts.bookTop(YesBookTop, best YES bid/ask) and mark with the clamped price. Omit it and behaviour is unchanged (mark tolastPrice).client.getBinaryPositionPnLnow fetches top-of-book alongside the indexer fan-out (one extraeth_call) and passes it through. An indexer-only client or a failed read falls back tolastPricealone — no breaking change for existing callers.BinaryPnllegs (now namedBinaryOutcomePnl) additionally reportavgCost,mark, andvalue, 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): deriveBinaryPnlFills from one market's slice ofgetPortfolio().trades, which already carries the account's own side per fill.- The unified
markYesPricereplaces 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. takerIsBidon live fills: know which side of the book a fill took liquidity from.txHashonUnifiedTradeandUnifiedOrder: 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.BinarySellQuotegainsfillableQuantityandestProceeds: 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 fromintervalSec(falling back toexpiry − tradingStart). The label uses the largest unit up to hours that divides the cadence cleanly, so a 10-minute market reads"10m".nullon SPOT/PERP.- Trade history carries the market's timeframe.
FillRow(fromgetFills/getUserFills) gains a joinedmarketcontext —{ asset, intervalSec, interval, tradingStart, expiry }(newFillMarketContexttype) — so a trade row can show which timeframe the order was for without a second query.PortfolioTrade.market(fromgetPortfolio) likewise gainsintervalSec,interval,tradingStart,expiry. - New canonical cadence helpers (exported from the barrel):
resolveIntervalSec,snapIntervalSec,formatIntervalLabel,marketIntervalLabel, and theIntervalSourcetype — the singleintervalSec → "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.lendnamespace onSomniaMarketsClient: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), andlend.createLender(signer)— the write surface (supply/withdraw/borrow/repay/setUseAsCollateralplus native-SOMI gateway variantssupplyNative/withdrawNative/borrowNative/repayNative). Auto-approval follows the trader doctrine (one allowance read,maxUint256grant, in-memory cache);borrowNativeadditionally 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?: LendAddressesonClientConfigwires the deployment; the published addresses ship asSOMNIA_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.mdguide; 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.
PriceFeedConfiggains an optionalquote(case-insensitive, e.g."USDC"). When set, every read — snapshot, feed info, history, candles, the catalog, and both live subscriptions — adds aquote: {_eq}clause, so a base resolves to exactly one feed.$quoteis passed as a GraphQL variable, never interpolated.SOMNIA_TESTNET_PRICE_FEEDis now pinned toquote: "USDC".- Leaving
quoteunset 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.
PriceFeedInfogainsupdatedAtMs(when the oracle last wrote the feed),sourceUpdatedAtMs(when the underlying market data was timestamped), andresynced. All unix milliseconds, null when unknown.- New
useLivePriceFeedInfo(asset)hook —getLivePriceFeedInfoalready 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 lastBuilderApprovedupsert; also the sort key, newest first).- New fields mirroring the entity:
market(market id),blockNumber,txHash.poolis 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 onstatusmust handle the new member.claimableFromnow enforces the lowercasedpoolits result type documents (previously true only via the indexer wiring).- Docs:
RegisterSeriesParams.asset/SeriesOnchain.assetcorrected to a plain display ticker ("BTC", not"BTC/USDT") per the MarketCreator natspec — it must match the source exchanges' spot listing for candle sources;binaryPoolImpldoc no longer claims it is unread (the explorer renders it). - Dead code: unused
PortfolioTradeimport (exchange.ts) and unusedresolvedlocal (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); eventsPayerSurplusCredited(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 readsfirstRollArmed/latestExpiryBySeriesId/armedBoundary/marketCountandwithdrawNative/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
Schedulecontinuation) + content-addressed question dedup.
- self-armed
- 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 withOracleHub.sol). quoteCreateMarketValue(def)=getSchedulingCost(def) + resolveReserve()(both attached to the create; excess refunded).syncSettlement(marketId)(module write): permissionless earmark reconcile for a market voided viaBinaryMarket.voidExpired()(which bypasses the module, so the hub's earmark release never fires). Idempotent; revertsMarketNotSettledwhile still live.- Indexer reads (
query.ts, matchingindexer/schema.graphql):OperatorHubAccountRecord(earmarked/credit/outstanding),getOperatorHubAccount/listOperatorHubAccounts; reshapedOracleBindRecord/OracleCallbackRecord.preflight.tsgates 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 paysamount × num[idx] / D— one formula for win / loss / void (a losing redeem pays 0 without reverting). Reusable pools + a permanentBinarySettlementsingleton. getSettlement(readsAbi.ts) returns(…, uint256[] payoutNumerators)— removed theuint8 winningOutcomeslot.SettlementRecordexposespayoutNumerators+ a derivedwinningOutcome.winningOutcome()was REMOVED from BinaryMarket — the SDK derives the winner as the argmax ofpayoutNumeratorsingetMarketOnchain, theredeem()auto-winner lookup, and theResolved(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), theMarketTypePluginregistry (fee-codec + machinery step descriptor keyed off the bytes4marketType), 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
noncebetween the pool address and the outcome index:id = (uint160(pool) << 72) | (nonce << 8) | idx;marketKey = id >> 8 = (pool << 64) | noncekeys settlement records. The oldoutcomeIdFor(pool, idx)(pool << 8 | idx) is REMOVED — use the new exported helpersoutcomeId(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 inuserData(it is opaque market-maker bookkeeping now, forwarded verbatim). The side comes from the pool's newBinaryOrderPlaced(orderId, kind)event; map the enum with the newsideOfKind(kind)/ORDER_KIND_SIDE. Never decodeuserData.getMarketOnchain(marketId)replacesgetMarketOnchain(marketAddress)— market identity is the module's bytes32marketId(pools/market contracts are recycled/per-market). Resolves throughBinaryMarketsModule.markets(marketId); requiresaddresses.binaryModule. A 20-byte address argument throws loudly. The result gainsmarketAddress/nonce/finalized, andbackingfalls back to the settlement record's NET backing once finalized (the pool-sidemarket.backing()reads 0 from then on).Trader.redeemroutes through the module, keyed bymarketId—RedeemParams.marketId(bytes32) is required;market(address) is now only an optional lookup aid foroutcomeIdx/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 poolredeemno longer exists on-chain.- Pool write ABI:
placeOrder→placeBinaryOrder(kind, price, quantity, expireTimestampNs, orderType, selfMatchingOption, builder, builderFeeBpsTimes1k, userData)(+placeBinaryOrderFor). The genericplaceOrder/placeOrderFor/amendOrderREVERT (UseBinaryPlacement) on binary pools.redeemis gone frombinaryPoolWriteAbi. - Order expiry must satisfy
0 < expireNs ≤ pool.marketExpiryNs— the pool rejects never-expiring / beyond-market orders (OrderExpiryBeyondMarket).Trader.placeOrdernow DEFAULTS the expiry to the market's expiry (onemarketExpiryNsread) instead of ~50y; an explicitexpireTimestampNsis forwarded verbatim (no silent clamping). - Live-tail events: the pool
Redeemed/SettlementFeeChargedevents no longer exist (redemption + the one-time fee skim live on the settlement singleton). New events consumed: poolBinaryOrderPlaced/PoolFinalized/PoolRecycled; moduleMarketFinalized/PoolReleased; settlementMarketFinalized/SettlementFeeCharged/Redeemed/PayoutOwed/OwedClaimed. The moduleMarketCreatedgained anoncefield. - Pool address is a TIME-VARYING market binding — never key a market by pool
address.
getMarketByPoolnow returns the pool's NEWEST (current) market and documents the caveat;BinaryMarketrows exposenoncefor 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.signRedeemAuthhas the position OWNER sign an EIP-712RedeemAuthorizationover the module'sREDEEM_AUTH_TYPEHASHin theSomniaMarkets/1domain (verifyingContract= thebinaryModule); no tx is sent. A relayer then submits it viaredeemFor— they pay the gas, the module pins the payout toowner(never the relayer). New typesRedeemAuthorization,SignRedeemAuthParams,RedeemForParams.- Config:
addresses.binarySettlement(and@somnia-chain/deploymentsmaps the manifest'sBinarySettlementproxy key onto it).
Pool reuse / bindings
client.getPoolBindings(pool)→PoolBindingRecord[]— a pool's full pool→market binding history from the indexer (WS5PoolBinding; newest nonce first;toBlock === nullmarks the current binding;closedByis"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). StandalonegetPoolCreator/getFreePoolsare 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-chainpoolCreator()view and the indexerPool.creatorfield."Finalized"inBinaryMarketStatus— the indexer'sClobMarketStatusterminal state (set when the market's backing + resolution sweep to the BinarySettlement singleton; supersedes Resolved/Voided). Flows through everystatusfilter (listBinaryMarkets/listLiveBinaryMarkets/listPastBinaryMarkets/countBinaryMarkets). The live-tail reducer now also sets it on the module/settlementMarketFinalizedevents. The on-chainBINARY_MARKET_STATUSindex 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 —BinaryPoolimplements the_onOrderReduced→_refundPartialhook, so the freed escrow returns to the owner. NewReduceOrderParams; newreduceOrderentry onbinaryPoolWriteAbi.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).cancelExpiredOrderscleans an explicit list of expired ids;sweepExpiredAtLevelwalks one price level from the best order cleaning up tomaxCount. Each returns locked escrow to the order owner (best-effort — non-expired / stale entries are skipped on-chain). NewCancelExpiredOrdersParams,SweepExpiredAtLevelParams; newcancelExpiredOrders/sweepExpiredAtLevelentries onbinaryPoolWriteAbi.PlaceOrderParams.userData?: bigint— opaque MM bookkeeping tag, default0n, forwarded verbatim (the SDK never sets or interprets it).
Live order book (recycle-safe)
store.bookLevels(pool)filters by the pool's CURRENTmarket_id, structurally. ABinaryPoolis recycled across markets (one pool serves successive markets, never concurrently). The live book now requireso.market_idto 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? })+ theuseLiveBinaryOrderBookByMarkethook — resolve a binary book bymarketIdrather than pool address. IfmarketIdis 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 newstore.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 kernelquoteBinaryOrderOverBook. New typeBinaryOrderQuote.client.getMarketStats24h({ pool | marketId })— trailing-24h{ volume24h, trades24h, priceChange24h, high24h, low24h, openPrice24h }summed from 1h candle buckets. KernelmarketStats24hFromCandles; new typeMarketStats24h.BinaryMarketFilter.orderBy("newest" | "closingSoon" | "volume" | "tradeCount") threaded intolistBinaryMarkets+listLiveBinaryMarketsas the Hasuraorder_by(server-side).listBinaryMarketsstill defaults to newest,listLiveBinaryMarketsto closingSoon; an explicitorderByoverrides. New typeBinaryMarketOrderBy.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 tolastPrice(or the settlement payout once resolved). KernelspnlEventsFor+computePositionPnL; new typesBinaryPositionPnL,PnLEvent.client.getClaimable(account)— redeemable positions across settled (resolved/voided) markets, each shaped to feedtrader.redeemMany({ entries }):{ marketId, pool, outcomeIdx, amount, estPayout, status }. Winner payout skims the settlement fee; voided pays half both sides; losers omitted. KernelsclaimableFrom+estPayoutFor; new typesClaimablePosition,ClaimableInput.RouterActionRecordnow surfaces the existing indexeramountfield (each outcome's set size) so mint/merge cost basis folds in;PortfolioMarketnow carriesid(the bytes32 marketId).getMarketResolutionnow returnsopeningAnswer(the reference-question oracle answer — a reference-mode market's OPENING price) andclosingAnswer(its own resolution answer — the CLOSING price) alongside the outcome.oracleAnsweris kept as a deprecated alias ofclosingAnswer. Requires the deployment manifest to recordOracleCore(itsAnswerPosted.numericValueis the price) — the deploy script now writes it.client.getOpeningPrices(marketIds)— batch opening (reference) prices for many markets in one pair of round-trips (mapmarketId → 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), typesDecodedOutcomeId/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 onbinaryPoolReadAbi(marketNonce / settlement / finalized / booksEmpty / marketExpiryNs / setBacking / getBinaryPoolParams), event ABIsbinaryPoolEventsAbi/binarySettlementEventsAbi. BinaryMarketrows: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 (
MarketCreatedopens/re-points a pool→market binding,PoolReleasedcloses it — order events attribute via the pool's CURRENT binding), takes sides fromBinaryOrderPlaced, zeroes pool backing onPoolFinalized, and tracks the settlement-sidenetBackingvia the settlementMarketFinalized/Redeemed.
Migration
- Regenerate/refresh the deployment manifest (v2 deploy adds
BinarySettlement);addresses.binarySettlementflows through@somnia-chain/deploymentsautomatically. - Replace
outcomeIdFor(pool, idx)withoutcomeId(pool, nonce, idx)— get the nonce fromBinaryMarket.nonce,MarketOnchain.nonce, orpool.marketNonce(). Purge any cached v1 ids. - Replace
kindOf(isBid, userData)withsideOfKind(kind)joined fromBinaryOrderPlaced(indexer rows keep servingsideprecomputed). client.getMarketOnchain(...): pass the bytes32marketId(fromlistBinaryMarkets/BinaryMarket.marketId), not the market address.trader.redeem(...): passmarketId(+ optionallyoutcomeIdxto skip a read). Native redemption (redeemNative) and complete-set methods are unchanged.- Market makers: tag orders with
userDatafreely — 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 samerequestId(group by it for a whole batch / join to agent provenance). Read viagetPriceHistory/ 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 publicLivePrice/PricePoint/PriceCandlefield 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 indexerRouterActionRecord). - Resolution visibility —
getMarketResolution(marketId)→{ events, reference, oracleAnswer }joiningMarketResolutionEvent/MarketReferenceLink/OracleAnswer(byoracleQuestionId). - Fee-record streams —
listProtocolFees/listBuilderFees/listSettlementFees(the per-fill streams behindgetMarketFees' running total; support apayerfilter). - Builder-approval directory —
listBuilderApprovals({ user?, builder?, … })→BuilderApproval[], complementing the on-chain point readgetBuilderApproval.ApproveBuilderParamsis now exported. - Markets-by-creator —
BinaryMarketFilter.creator(applied acrosslistBinaryMarkets/listLiveBinaryMarkets/listPastBinaryMarkets/countBinaryMarkets). - Vault credits —
getVaultPayoutFallbacks(owner, opts?)(append-only credit history),client.getVaultBalance(vault, owner, token)(live claimable,ERC20Vault.getWithdrawableBalance), andtrader.withdrawVault({ vault, token, amount })(ERC20Vault.withdraw). - Perp margin health + liquidation price —
getMarginAccountnow also returnsimReq/mmReq/cmReq/marginStatus(fromMarginBank.getAccountHealth/getMarginStatus); newclient.getAccountHealth(marginBank, account)andclient.getLiquidationPrice(marginBank, pool, account).MarginStatus/MARGIN_STATUS/AccountHealthare exported. The unifiedfetchPositionsnow populatesUnifiedPosition.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), andcountOrders(owner, opts?)/countUserFills(account, opts?)(history-page totals via the_aggregatefallback helper, now extended toOrder/Fill). - Unified balances —
fetchBalancenow includes binary YES/NO ERC-6909 holdings (keyed by tradable symbol). - Pure helpers + constants —
CANDLE_INTERVALS(in lockstep withindexer/src/intervals.ts), andcomputeBinaryPnl(fills, balances, market)/binaryFillsFor(account, fills)(avg-cost basis, no indexer/chain dependency). - React hooks (
@somnia-chain/markets-sdk/react) — a genericuseIndexerQuery(fn, deps)plususePortfolio,useMarkets,useCandles,useMarketFees,useOperators, and the live-storeuseLiveMarkets.
0.11.1 (2026-07-16)
Fixed
- Count helpers (
countMarkets/countBinaryMarkets/countOperators/countVenues) now fall back to a bounded row count when Hasura_aggregateis not exposed to the requesting role (public role, no admin-secret header) instead of throwingfield '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.
SomniaMarketsAddressesgainscollateral(the per-venue collateral ERC-20).testUsdcremains as a legacy/fallback alias.getSystemInfo,trader.faucet, and collateral reads now resolvecollateral ?? testUsdc, so hub-fed testnet clients (where the protocoladdresses.jsonno 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'sbackingrunning total, so the tail tracks on-chainsetBackingafter settlement instead of overstating it by the fee. - Module-created market discovery.
watchAllMarkets({ discover: true })now also discovers markets created viaBinaryMarketsModule.createMarket(the 19-field moduleMarketCreated), not just theMarketCreatorrolling series.
Fixed
getSystemInfo.binaryMarketImplno longer falls back to the (different)binaryPoolImpladdress when the live factory read fails.
Notes
- Addresses are provided entirely by
@somnia-chain/deployments(the single source of truth).SomniaMarketsAddressesnow carriesmarketsCoreandcollateralRouterfirst-class, so consumers feed the hub map straight intonew SomniaMarkets({ addresses })with no hand-mapping.