Release notes

0.30.0

Minor Changes

  • Three new client reads answer block-scoped questions: getBlockActivity returns what one block traded grouped by market, getLatestActiveBlock names the newest block with markets activity, and getAdjacentActiveBlocks names the closest such block on either side. All three anchor on the indexed timestamp columns rather than a block column, none of which the indexer schema indexes.

    getTransactionActivity takes an optional anchor (the transaction's block and timestamp). Supplying it skips the Order.placedTxHash probe, which is not index-served and ran past the gateway timeout — so a transaction that produced no event, meaning one that only placed or cancelled orders, could not be read at all.

  • New SOMNIA_MAINNET_PRICE_FEED constant. It is the mainnet price feed, https://price-feed.prd.oracle.somnia.host/v1/graphql, pinned to the USDC quote.

    The SDK previously exported one price feed, SOMNIA_TESTNET_PRICE_FEED. A mainnet consumer had to write the endpoint by hand.

    The two feeds are separate deployments. Each one indexes the PriceFeedScheduler on its own chain. Use the feed that matches the chain the client runs on.

    The mainnet feed carries fewer symbols than the testnet feed. Code written for the testnet asset list can therefore fail on mainnet. Read the feed catalog to learn which bases exist. A base the feed does not carry returns no rows. It does not raise an error.

    Existing callers keep their behaviour. SOMNIA_TESTNET_PRICE_FEED is unchanged and remains correct for a testnet client.

  • Publish the perp event ABIs, so a consumer can decode perp logs from chain. perpPoolEventsAbi is now root-exported, and marginBankEventsAbi and liquidationEngineEventsAbi are new (18 and 13 events). Before this, perpPoolWriteAbi and marginBankWriteAbi exported no events and there was no MarginBank or LiquidationEngine event ABI at all, so funding settlement, positions, collateral flow and the liquidation waterfall were undecodable without a hand-copied ABI. Every signature is pinned by topic0 against the compiled artifacts. docs/PERPS.md has a new "Watch from chain" section.

  • Add client.getPerpFundingPremium(pool), which reads the premium the next funding settlement will charge. Use timeWeightedPremium to project a funding rate. Do not use lastObservedPremium for that: the contract getter behind it kept its signature and changed its meaning, and it is now only the standing instantaneous sample. Check armed before calling the figure a time-weighted average, because a market that has not settled since the upgrade is still charging a point sample. This read is deliberately separate from getPerpState, whose batch would fail as a whole against a pool too old to serve these getters.

    Correct the funding documentation for the reworked premium and catch-up horizon. FundingRateUpdate.intervalsAccrued is no longer min(intervalsSettled, n). A settlement now charges at most one interval, however long the gap, and the excess is forgiven. Compare intervalsAccrued against intervalsSettled to see whether anything was forgiven; intervalsSettled alone does not tell you. intervalsPerWindow is the per-interval divisor only. On a historical row that caught up over several intervals, one settlement's charge is fundingRate * intervalsAccrued / n rather than fundingRate / n. The premium driving the rate is the time-weighted impact-price premium, not a book midpoint, so emaPremium can be non-zero on a one-sided book.

  • Read the three perp ledgers the reindex made available: listPerpInsuranceFundEvents (the InsuranceFund's tier ledger behind getInsuranceFundState), listPerpWalletLinkEvents / listPerpMarginPulls / listPerpMainFundingEvents (the linked-wallet rail's consent graph and both funding sides), and listPerpOrderRejections (orders refused inside a batch placement). Two aggregation hazards are documented on the types: the insurance ledger's amount must be folded by kind rather than summed, and the two funding sides record the same wei — summing them double-counts every transfer.

  • listPerpStopOrders now returns siblingOrderId (the live OCO partner), intent and cancelReason, so a stops table no longer needs a chain read per row to show pairing or intent. cancelReason separates the three routes to CANCELLED, which refund differently: "Owner" returns the SOMI in the same transaction, while "LinkedFill" and "Inert" only credit unclaimedSomi for claimSomi().

  • BREAKING — the portfolio trades legs default to the last seven days

    getSpotPortfolio, getPerpPortfolio and getPortfolio now apply since = now − 7 days to their trades leg unless the caller passes since. A caller that passed no since and expected the whole history gets the last week. Each result gains a required tradesSince (unix seconds): the bound that was applied, so a UI can label the list and a caller can page further back.

    Migration. To keep the previous unbounded read, pass since: 0 (or the earliest time you need). Hand-built Portfolio / SpotPortfolio / PerpPortfolio values (fixtures, adapters) need a tradesSince field. A UI that renders trades should read tradesSince and say so when the list is empty — "no trades since <date>" 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.

    Fixedtype- and pool-scoped reads no longer filter through the market relationship

    Every read that scoped Fill, Order or StopOrder by market type or by pool did so with market: { marketType: { _eq } } or market: { poolAddress: { _eq } }. Hasura compiles a relationship predicate to a correlated EXISTS on Market per candidate row, which stops Postgres using the maker / taker / owner indexes. Measured on the development indexer for one wallet's fills at limit: 50: stream timeout with the predicate, 102 ms without it. Three such reads every five seconds per open wallet page saturated the shared one-vCPU database and stalled ingestion on both indexer slots.

    These reads now scope on the direct, indexed columns instead: pool on Fill, market_id on Order and StopOrder. A market type resolves to its set of pools — _in for SPOT and PERP, whose Market.id is the pool address; _nin over spot ∪ perp for BINARY, whose pools are recycled across thousands of markets. A spot or perp pool is its own market id — a direct _eq, no query. A single binary pool keeps the market: { poolAddress } relationship form: a recycled pool has hosted ~2,000 markets, so an _in of its ids would be the worse shape, and every Order read that takes a pool also carries owner, which bounds that one EXISTS to the wallet's rows — no worse than before. Pool and type compose in one place (marketScope). The two type sets are memoized per indexer URL for a minute, so the extra hop is paid once, not once per poll.

    Affected: getSpotPortfolio, getPerpPortfolio, getPortfolio, getOpenOrders, getOrders, countOrders, getSpotStopOrders, listPerpStopOrders, listPerpOrderHistory, listSweepableOrders. Rows returned are unchanged for every read except the three portfolio trades legs, where the new default window applies. getSpotStopOrders in particular keeps returning every pending stop a wallet holds, spot or perp, as it did.

  • BREAKING for constructors — PortfolioAnalytics gains a required holdings series: what the traded book is worth over time

    The result's equity field is cumulative window PnL. It starts at zero, it is signed, and it is not the account's value. Consumers read the name and plotted it as a level, so a losing window drew a spot wallet below zero.

    holdings is the level that name promised. Each sample sums qty × mark over the open positions, on the same grid as equity, so a chart can draw both against one x axis:

    ts
    const p = await exchange.fetchPortfolioAnalytics("7d");
    p.holdings.at(-1); // what the traded book is worth now
    p.equity.at(-1); // what it gained over the window
    

    The first point is the carried-in book valued at the window start, not zero. The series sums signed position value. Every book the fold keeps today is long-only, so today the value cannot go below zero — but do not lock a chart axis to that, because a signed book marks negative and the perp plane is committed to joining this fold. The marks are the caller's and are not validated, so a negative price carries through as it already does for the PnL figures.

    A position the sample cannot price is left OUT of the value rather than valued at zero, and HoldingsPoint.unpricedMarkets counts what was left out. A market with no candle sample and no last price has no mark, and standing a zero in its place would drop a held position out of the level with nothing to say it had. Check the count before presenting a sample as the whole book.

    Migration. Reading the result needs no change: every existing field keeps its name, its meaning and its value. Anything that CONSTRUCTS a PortfolioAnalytics — a hand-written response fixture, a test mock, an adapter building the object for an HTTP layer — must add holdings: HoldingsPoint[] or it stops compiling. HoldingsPoint is exported from the package root. An adapter that forwards both series also serves roughly twice the series payload.

    The series measures the TRADED BOOK, not the wallet. A token that arrived without a fill — bridged in, transferred in, minted — is not in the book, so it is not in this value. Idle quote balance is not a position, so it is not included either. Funding events refine the capital base only, so a deposit inside the window does not step the curve. Read balances from the chain when you need what the wallet itself is worth.

  • BREAKING: redeem no longer guesses a leg before resolution, and unified leg selection now uses an options object

    trader.redeem({ market, amount }) previously derived the winning leg as the argmax of the market's payout vector. A void pays BOTH legs, so that argmax was meaningless: it returned leg 0 on the uniform [D/2, D/2] vector, and the higher-priced leg on a CLOB_SNAPSHOT [p, D−p] one. A holder of only the NO leg got a confusing InsufficientBalance revert. A holder of both legs — what one mintSet call produces — redeemed YES, received half, and never attempted NO, with no error and a successful transaction. The collateral was stranded, not destroyed: it stays claimable from BinarySettlement with an explicit leg.

    The auto-lookup is now resolution-only. It checks the market's terminal state before reading the immutable payout vector. On a voided market redeem throws InvalidInputError naming both legs and pointing at redeemMany; on an unresolved market it asks the caller to wait for settlement. Resolved markets are unchanged: [D,0] still resolves to YES and [0,D] to NO.

    exchange.redeem(ref, amount) gains an optional trailing RedeemOptions bag, so a NO-holder can claim through the unified API instead of hitting a dead end. Existing two-argument calls behave as before.

    Migration: replace exchange.redeem(ref, amount, outcomeIdx) with exchange.redeem(ref, amount, { outcomeIdx }). If the market may be voided, pass the leg you hold. To claim both legs, use redeemMany with one entry per leg — they settle in one transaction.

  • Restore concurrent market-discovery coordination and stable user-fill pagination after their review withdrawal. Paginated fill reads include configured indexer headers, and indexer response-body cancellation preserves the caller's reason. This restoration supersedes the earlier withdrawal note. Dataset-reset coordination remains required for persisted fill cursors.

  • Add bounded user-fill pages with stable numeric continuation. Cursors preserve account and filter scope while newer fills arrive. Existing reads remain unchanged. Reindex, repair and endpoint cutover still require callers to discard cached pages and cursors.

  • Reject partially present void payout vectors, floor each raw payout leg from its stored numerator, and verify omitted unified redemption legs against on-chain terminal state.

Patch Changes

  • Approval documentation now distinguishes cached unlimited allowances from finite allowances that each order must check again.

  • Traders from one configured client now coordinate local sends for each account. A failed signer cannot reset a nonce that another trader is still broadcasting.

  • Share concurrent initial market discovery within each exchange. Explicit registry reloads run in order, including after a failed read, so an older discovery cannot overwrite newer market metadata.

  • Withdraw the unreleased stable user-fill pagination API and concurrent market-discovery coordination pending maintainer review. This withdrawal supersedes the queued release notes announcing those additions; existing offset-based fill reads and the previous discovery behavior remain available.

  • Documentation: a documentation map plus tutorials, how-to guides, reference pages, and explanations organised by reader need. Two tutorials take a newcomer from an empty directory to a cancelled order on the Shannon testnet. Eight how-to guides cover network configuration, testnet funds, a quoting loop, fill detection, browser-wallet signing, the React hooks, error handling, and debugging. Six reference pages list configuration fields and defaults, networks and endpoints, the symbol grammar, units and scales, read tiers, and the error classes with the contract error names they decode. Every TypeScript example has a compile-checked public-import counterpart, and the tutorials were executed against the testnet.

    Corrections to the existing guides, verified against the source: the reactivity guide no longer claims the package is on GitHub Packages; the binary guide's cadence list, lifecycle (Finalized), intervalSec provenance, and builder-fee revert names are corrected; the exchange guide's verb table gains the ten verbs it omitted and notes that close() leaves the WebSocket open; the engine guide's API links now target the docs site; isTailing() is no longer presented as the price-feed status; the bridge guide names sendBridgeStep as its one sender; and the two errors TSDoc examples now match the real signatures of createOrder and cancelExpiredOrders.

  • Forward configured indexer headers to paginated user fill reads and preserve the caller's cancellation reason when an indexer response body is interrupted.

  • /native: a rejected sendSessionTransaction or getSessionAddress no longer carries the session seed in its error. viem builds a request failure's message and cause from the request body, so a mempool rejection or a transport error used to print the seed — a private key in another shape — in every log line that showed error.message. Both calls now throw RpcError. When the node answered, cause is its own { code, message, data? } and getSomniaRpcError / mempoolStatus keep working on it; when the transport failed first, cause is { name, message, status? } with the seed blanked out of the request text. isMethodNotFound also stops matching the node's genuine account does not exist and Block does not exist failures, which the documented degrade-to-null idiom used to swallow.

  • A live-tailed client no longer re-fetches every block it has already been handed. The tail took its chain head from viem's watchBlocks, which answers a pushed newHeads frame by issuing an eth_getBlockByNumber for the very block the frame already carried - nothing new, at double the per-block RPC cost. It now reads the height and timestamp straight off the pushed frame over the same socket.

    A frame is read only when both the height and the timestamp arrive as hex quantities; if either is missing, is not hex, or is the empty quantity "0x", the frame is skipped whole rather than half-read. A skipped frame still counts as proof the stream is alive, so the heads watchdog behaves as before and the next head restores the timestamp.

  • Operational failures are no longer converted into success, zero, or a caller error at seven sites. Admin writes through OperatorAdmin, GovernanceAdmin, MarketCreatorAdmin, OracleHubAdmin and the lend Lender now throw ContractRevertError on a mined-but-reverted receipt (with the contract's error name when a replay at the receipt's block recovers it) instead of resolving as if confirmed, or misreporting the revert as RpcError("no event"). SomniaMarkets.fetchBalance throws when a balance read fails instead of reporting 0 or omitting the key. getBinaryPositionPnL falls back to lastPrice only when no chain client is configured, not when the book read fails. cachedErc20Decimals (and every read built on it) returns the fallback only for a token without decimals(), not on an RPC failure. createStopOrder surfaces a failed mark read as the indexer's error instead of InvalidInputError("pass triggerDirection"). depositVaultNative no longer broadcasts when its preflight read failed. The receipt wait used by external-signer and fallback confirms rejects after three consecutive receipt-read failures instead of pending forever; "receipt not found yet" still waits.

  • Decode three perps reverts that previously surfaced as opaque hex — InvalidFundingCapDivergenceMargin, InvalidPremiumImpactNotional and InvalidLinkedWalletRegistry, all added by the 2026-08-31 testnet upgrade.

  • createOrder and createStopOrder never send a larger quantity than requested. Quantities are truncated to the base token precision before applying the lot-size floor.

    amountToPrecision now truncates the decimal text instead of its binary floating-point expansion. Exact inputs such as 1.2 therefore remain exact for a 0.1 lot size.

    Local writes now serialize nonce consumption, signing, and broadcast acceptance. A signer or broadcast failure can reset nonce tracking without invalidating another in-flight write.

  • Live-tailed clients now receive binary settlement finalization. The SDK declared MarketFinalized with a stale trailing argument, so its topic0 did not match the emitted event and liveTail discarded every settlement finalize — leaving netBacking null for the settled lifetime of every binary market. Indexer-backed reads were never affected.

  • Fixeda hydrated fill now names its taker

    A watch scope hydrated from a snapshot reported no taker on any of its fills. taker, takerSide and kind were missing from the snapshot's fill selection set and hardcoded undefined when the rows were parsed, on a comment claiming the indexer does not carry them. It does: they are real columns, stamped by the indexer's PendingTakerFill bridge once the taker's placement event lands.

    On a market-scoped watch this was permanent, not a live-tail race. The client-side back-join recovers taker fields from the local order map, but that scope hydrates resting orders only — and a taker order that filled completely is Filled, never Open, so it never enters the map to be joined against. Every market order and every IOC was affected, and a freshly loaded binary trade tape showed no taker side on any row it hydrated.

    taker, takerSide and kind now come through from the indexer, so:

    • A binary fill reports its taker's address, its four-way BUY_YES/SELL_YES/ BUY_NO/SELL_NO taker side, and its DIRECT_YES/DIRECT_NO/MINT_A_PAIR/ BURN_A_PAIR kind — the vocabulary takerIsBid cannot express, being one boolean covering two sides at a time and carrying no kind at all.
    • A spot or perp fill reports its taker. takerSide and kind stay undefined there, which is correct rather than a regression: outcome side is a binary-only concept and every spot and perp adapter leaves it unset.
    • A row the bridge has not stamped yet still reads undefined, and the existing back-join still fills in whatever is missing when the rows are selected.

    No type changed: the live-fill shape already declared all three as optional, and the user-scoped path already resolved them through the back-join. This closes the SDK half deferred by the binary trade tape's taker-colour fix.

  • Native-base spot sells now send the pool's exact vault shortfall as msg.value. This includes fee headroom and prevents InvalidMsgValue reverts on fee-bearing pools.

    Spot approval checks now use the pool's worst-case reserve. An allowance that covers only the principal is increased before the pull.

    Binary buy commitments now use the collateral token's exact scale. They also include the builder-fee headroom that the pool locks.

  • Fixeda voided market now values against the payout vector it actually stored

    estPayoutFor, computePositionPnL and computeBinaryPnl returned a hardcoded half for every voided binary market. A void pays whatever payout vector the market stored. Under the UNIFORM void policy that vector is [D/2, D/2], so the half was right. Under CLOB_SNAPSHOT a void that captures a two-sided close stores [p, D−p] at the closing YES price, and there the half was wrong on both legs: at a 0.80 close the YES leg was understated by 37.5% and the NO leg overstated by 150%. estPayoutFor quoted a claim settlement would not pay.

    All three now read payoutNumerators / payoutDenominator, which the indexer already carries and the market selection set already requested:

    • estPayoutFor returns amount × payoutNumerators[outcomeIdx] / payoutDenominator on a void. A void is never fee-charged, so no settlement fee applies on that branch.
    • computePositionPnL and computeBinaryPnl mark each leg from the same vector. computePositionPnL derives the NO leg by subtraction, as it already does while trading, so both legs still sum to exactly one collateral unit.
    • ClaimableInput accepts optional payoutNumerators and payoutDenominator, and PortfolioMarket carries them, so getClaimable and getOpenPositionsWithPnL value a void from the vector too.

    The payout comes from the vector and never from voidPolicy. A CLOB_SNAPSHOT market stores the uniform vector on every fallback — no capture-capable pool, a reverting closingPrice(), a one-sided or empty book, any overflow guard — so reading the policy instead of the vector would misreport exactly those markets.

    When a legacy voided market has no stored vector, all three keep returning the half, unchanged from previous behaviour. A present but invalid vector now raises InvalidInputError instead of silently turning corrupt indexer data into a plausible payout.

    The vector arrives from the indexer rather than the chain, so it is validated before use — as a whole vector, not one leg at a time. It must have both legs, both parseable and positive, a positive denominator, and a sum equal to that denominator. This is the shape BinaryMarket writes for a void: uniform or [p, D−p], with p clamped to [1, D−1]. Rejecting any other present vector keeps corrupt data distinct from the legacy no-vector fallback.

  • Clarify voided-market rounding: computePositionPnL calculates each leg as floor(oneCollateral * payoutNumerators[outcomeIdx] / payoutDenominator). It does not derive the voided NO mark by subtraction. Independent rounding can leave the two marks below one collateral unit in total. This supersedes the earlier queued payout-vector note's claim that the voided marks always sum to exactly one collateral unit; the implementation already follows settlement's independent rounding.

0.29.0

Minor Changes

  • Addeda market's whole history, one transaction, and one trade

    The tape and the fills list were the only ways in. A market page could show trades and nothing else; a transaction or a single fill had no read at all.

    • getMarketActivity(market, opts) — one market's whole transaction history. The mints, merges, redemptions, oracle resolutions and lifecycle transitions a market accumulates were all indexed and reachable only one entity at a time, in five different shapes. This returns them interleaved by time as one discriminated union on kind (MarketActivity), newest first, in a single round-trip. Every row carries its txHash, so a caller can follow any row to the chain. Select the streams with kinds, page backwards with until, and pass pool to let the fill read use its (pool, timestamp) index. A spot or perp market returns TRADE rows only — the other four streams read binary-only entities, so asking for them there is empty rather than an error.

      Pair it with getLiveFills for zero-latency trades: a trade's id is TRADE: followed by the fill id on both paths, so the two merge on id — but merge FIELD BY FIELD, preferring whichever source has a value. Neither is a superset of the other: the tail leaves taker/takerSide undefined on the fills it hydrates, and the indexer's takerIsBid is null until its taker bridge lands. Cache identity comes from the new marketActivityKey.

      Known limit: until has one-second resolution and is inclusive, so the boundary second is re-read between pages and a second holding limit or more rows cannot be paged past. A composite (timestamp, blockNumber, logIndex) cursor is the fix; it needs logIndex on the three non-fill streams, which this release adds to the indexer schema but which reaches a deployment only after a reindex.

    • getTransactionActivity(txHash) — everything the protocol did in one transaction. The transaction-scoped counterpart: the same MarketActivity union with the same row ids, but selected by transaction and returned in LOG order, plus the orders the transaction placed, the fees it paid and the market rows its events touched. One oracle callback settles every market on a question, so this is how a caller sees that a single transaction resolved four markets at once. A hash the indexer has nothing for returns empty collections and a null blockNumber rather than throwing — including a transaction that only rested a limit order, which emits no event and is anchored on the order table instead. New public types TransactionActivity, TransactionActivityOptions and TransactionOrder, and the transactionActivityKey cache key.

    • getTradeContext(id) — one trade in full, for a trade detail view. Returns the fill (with its block position), the market it executed in, BOTH sides' orders, the protocol and builder fees its transaction charged, and the other fills that transaction produced — enough to answer why a trade priced where it did. null for an unknown id, which is a stale link rather than a failure. New public types TradeContext and FillOrder, and the tradeContextKey cache key.

    Both transaction-scoped reads used to scan Fill to select by hash. The indexer schema now carries @index on Fill.txHash, so that scan goes away once a reindex has run.

  • Addedsingle-entity detail reads, a transaction summary, and the network tape

    The indexer surface was list-shaped throughout: orders, fills and markets could be enumerated, but nothing addressed ONE of them. An explorer drilling into a row had no read to call.

    • getOrder(pool, orderId), getFill(id), getOrderFills(pool, orderId) — the three reads a detail view runs on. Order ids never repeat (a monotonic per-pool counter), so (pool, orderId) names one order permanently; a fill is named by its ${blockNumber}_${logIndex} id. All three embed a MarketRef (symbols, decimals, routing identity) so a page renders from one read instead of a second lookup per row. null for an unindexed id — a just-executed fill can lag by a beat, which is a stale link rather than a failure.

      The embedded market is marketRef, not market: FillRow.market is already the bytes32 marketId string, and GraphQL will not select a relationship alongside an alias of the same name.

    • listMarketsByPool(pool) — every market a pool has hosted, newest first. A binary pool is recycled across successive markets, so this is its full history rather than the one market getMarketByPool resolves. Plus getSeries(creator, seriesId), the single-row sibling of listSeries.

    • getTransactionSummary(hash) — sender, gas used and limit, effective gas price, fee paid, status. Chain-direct rather than indexed, so it answers for any hash the node still has; null for an unknown one, never a throw.

    • createNetworkTape() — a network-wide order-flow firehose. One topics-only chain-log subscription sees OrderPlaced / OrderFilled from every pool, including pools created after it starts, with no indexer on the hot path. Not the live tail: no books and no snapshots, just three newest-first ring buffers (bids / fills / asks) plus a session (pool, orderId) → owner map that attributes fills. Takers always resolve; makers resolve when they were quoted while the tape ran. Rows carry RAW units — join to listMarkets for symbols and decimals. Nothing connects until the first subscribe, and reconnect plus stall detection for Somnia's silently-dying subscriptions is built in.

    The indexer also materializes the Market→Series edge from MarketCreator.SeriesRolled (Market.series / Market.seriesKey), replacing a heuristic join on asset + venue + cadence. That reaches a deployment only after a reindex, and the SDK does not read the field yet.

  • Addedread a pool's operator permissions registry from your own call

    spotPoolOperatorRegistryReadAbi is now exported from the package root. It is the ABI fragment for getOperatorPermissionsRegistry(), the view that tells you WHICH OperatorPermissionsRegistry a SpotPool consults when it gates placeOrderFor, cancelOrderFor and reduceOrderFor.

    client.getOperatorPermissionsRegistry(pool) already answered that question and still does. Reach for the fragment instead when the method does not fit:

    • Multicall composition. The method issues its own readContract, so it cannot be batched into a caller-built multicall. With the fragment the registry read joins the same batch as the rest of your pool reads.
    • No instantiated client on that code path. The method resolves a chain client lazily and needs one; the fragment is a static value and needs nothing.

    Previously the only route was a hand-copied fragment, since the exports map has no wildcard and no deep import could reach it — which is how a signature drifts in silence.

    ts
    import { spotPoolOperatorRegistryReadAbi } from "@somnia-chain/markets-sdk";
    
    // `allowFailure: false` so a failed read throws instead of yielding a
    // `{ status, result }` wrapper. With the default (`true`) each element is that
    // wrapper, and comparing it to an address silently reports every pool as wired.
    const [registry] = await publicClient.multicall({
      allowFailure: false,
      contracts: [{ address: pool, abi: spotPoolOperatorRegistryReadAbi, functionName: "getOperatorPermissionsRegistry" }],
    });
    
    const wired = registry.toLowerCase() !== "0x0000000000000000000000000000000000000000";
    

    Two differences from the method, both of which matter for an authorization check:

    • address(0) reaches you raw. The method returns null for an unwired pool; a direct read returns the zero address, which is truthy. Compare it yourself, as above — such a pool denies every operator call, so a grant written anywhere will not make placeOrderFor succeed. The SDK's own grant reads and writes reject a zero registry with NotConfiguredError rather than acting on it.
    • Read per pool. Two pools on one deployment may name different registries, and the pool is the authority: a grant written to any other registry is inert. Do not cache one answer for all pools.

    The other read-side ABIs stay internal.

  • Addeda portfolio says when its trade history is incomplete

    Portfolio, SpotPortfolio and PerpPortfolio gain a tradesTruncated boolean.

    The three portfolio reads cap trades at 50 by default and page newest-first, so the cap drops the OLDEST fills. The returned shape carried no sign of this. A caller that folds trades into Net PnL, win rate, volume or a trade count got a total over the recent slice only, and read it as the whole history. This is the same signal FundingRateSeries.truncated already gives the funding chart.

    tradesTruncated is trades.length >= tradesLimit. A full page means older fills exist and were not returned. An empty page is not truncated, and neither is tradesLimit: 0, which asks for no trades at all. The default cap is unchanged, and raising tradesLimit moves the cap rather than removing it, so check the flag instead of assuming a large limit is enough.

    Changed (breaking) — perp markets are discovered from the chain, not only the indexer

    loadMarkets() now unions the indexer's perp rows with the PerpPoolFactory's and derives active from the live tradeability gates, so a close-only market no longer reads as tradeable. New perpStatus / perpDiscoveryError surface. Breaking: indexed is a required property on UnifiedMarket, so code that CONSTRUCTS one (fixtures, adapters) must set it; reading is unaffected.

    Addedthe spot stop-order lifecycle is decodable, and the write ABIs are importable

    Protocol signatures were being hand-copied into consumer repositories, because this package held them and did not publish them. A transcription drifts in silence: it decodes nothing, or decodes wrong, and the failure looks like a missing trigger rather than an error.

    • spotStopRegistryEventsAbi now carries the whole pending-order lifecycle. It held PendingOrderCreated alone. It gains PendingOrderTriggered(uint128 indexed pendingOrderId, bool success, uint128 indexed spotOrderId), PendingOrderCancelled(uint128 indexed orderId) and InertOrderCancelled(uint128 indexed orderId, address indexed owner, uint256 somiCredited) — so a consumer can decode a stop order firing, being cancelled by its owner, or being swept as inert, from a receipt.

      A new test checks every fragment's topic0 and argument shape against the compiled artifact indexer/abis/SpotStopOrderRegistry.json. That artifact carries no PROVENANCE.json entry, so the guard proves SDK-vs-artifact agreement, not SDK-vs-deployed-bytecode.

      Do not reuse the perp registry's shapes: its PendingOrderTriggered carries a drop reason the spot one does not have, and the test pins that divergence.

    • client.getOperatorPermissionsRegistry(pool) — the registry a SpotPool gates its operator calls through, at chain head, or null when the pool is unwired and denies every operator call.

      This is discovery for a caller who has not configured addresses.operatorPermissionsRegistry. The grant writes and the two grant reads need that address and otherwise throw NotConfiguredError, and no deployment manifest carries the key yet. A configured address still wins where it is used: this adds a path, it does not redirect one.

    • Seven ABIs on the root barrelerc20WriteAbi, erc20VaultWriteAbi, orderBookBatchWriteAbi, marginBankWriteAbi, spotStopRegistryWriteAbi, spotStopRegistryEventsAbi and operatorRegistryWriteAbi. Token approvals, vault funding, batch order management, perp collateral, the stop-order lifecycle, and operator delegation are now encodable by hand from published surface.

      ts
      import { spotStopRegistryEventsAbi } from "@somnia-chain/markets-sdk";
      import { decodeEventLog } from "viem";
      
      for (const log of receipt.logs) {
        if (log.address.toLowerCase() !== registry.toLowerCase()) continue;
        const event = decodeEventLog({ abi: spotStopRegistryEventsAbi, ...log });
        if (event.eventName === "PendingOrderTriggered") render(event.args);
      }
      

      The read-side ABIs stay internal. A root export is permanent public surface, so the set is limited to what consumers have needed.

    Addedevery placement path honours selfMatchingOption

    • selfMatchingOption is now a caller input on all four placement paths, so CANCEL_MAKER is reachable without leaving the SDK. It was pinned to 0 (CANCEL_TAKER) in each encoder, on a parameter the pools have always taken: placeSpotOrder / buildPlaceSpotOrder (PlaceSpotOrderParams), placeSpotOrders (SpotOrderRequest, per request), placePerpOrder / buildPlacePerpOrder (PlacePerpOrderParams), and placeOrder / buildPlaceOrder (PlaceOrderParams). amendOrder and amendOrders already took it, so every placement and amend verb now shares one vocabulary — the exported SELF_MATCHING_OPTION.

      Omitting the field encodes CANCEL_TAKER, exactly as before, so no existing call changes behaviour. Note that this default is the SDK's own: the pool reads the value out of every order request and has no default to fall back on.

      ts
      await trader.placeSpotOrder({
        ...order,
        selfMatchingOption: SELF_MATCHING_OPTION.CANCEL_MAKER, // drop MY resting order
      });
      
    • userData is settable on single spot placement (PlaceSpotOrderParams), which the batch request shape already allowed. The single verb pinned the tag to 0, so the same order encoded differently depending on which verb placed it.

    • placeSpotOrders forwards each request's builder and builderFeeBpsTimes1k instead of zeroing them. Choosing the batch verb silently dropped the routing attribution that placeSpotOrder honours.

    Addeda both-leg binary position can finally show either row

    • BinaryPositionPnL.outcomes — the same position split into its YES and NO books, as { yes, no } of the new exported BinaryOutcomePositionPnL (balance, costBasis, avgCost, markPrice, markValue, unrealizedPnl, realizedPnl, all RAW). OpenPositionPnL extends BinaryPositionPnL, so getBinaryPositionPnL and getOpenPositionsWithPnL both carry it with no extra request.

      Every money field on the result is blended across both outcomes, which is unusable for a wallet holding both. Buy 10 YES at 0.20 and 10 NO at 0.80, mark YES at 0.40: the legs are +2.00 and -2.00 and the blended unrealizedPnl is 0.00. Neither position row could show that zero as its own PnL, and consumers were falling back to local math for both-leg wallets.

      The fold already kept the two books separately in bigint space, mints and merges included — it computed each leg and then summed the values away. This publishes them.

      ts
      const [row] = await client.getOpenPositionsWithPnL(account);
      for (const leg of [row.outcomes.yes, row.outcomes.no]) {
        if (leg.balance > 0n) render(leg); // per-outcome, not blended
      }
      

      Legs sum EXACTLY to the totals: costBasis, markValue, unrealizedPnl and realizedPnl are stored per leg and added up, so integer division cannot make a published leg disagree with the published total. avgCost and markPrice are per-token rates and do not sum — though while trading the two marks add up to one whole collateral unit.

    Addedthe linked-wallet funding rail, and the margin surcharge that explains a refused order

    • getPerpLeverageImSurcharge(marginBank, account) and tryGetPerpLeverageImSurcharge — the EXTRA initial margin an account's own leverage settings demand, summed over every market where it both holds a position and has set a stricter-than-market cap.

      This closes a real gap rather than refining an existing read. quotePerpOrderTopUp funds ONE order and measures the account's unlocked balance, while the admission gate measures whole-account equity — so an order can be fully funded on its own market and still be refused InsufficientMarginForOrder because of an override on a different one. Nothing in the SDK could name that amount. Note the magnitude before dismissing it: the order lock reserves only the market's margin factor, so a 5x setting on a 2%-IMF market needs 20% of notional, not 2%.

      The strict read reverts when a market that is both positioned and overridden is unpriceable; the try variant reports that as null, never 0n — zero would under-state the requirement, which is the direction that produces an order the bank then refuses.

    • The linked-wallet read surfacequotePerpFundingPayer, getPerpMainFunding, getPerpWalletPullCapacity, getPerpLinkedWalletRegistry, getPerpWalletLinkage, listPerpLinkedChildren, getPerpMaxLinkedChildren. A linked child's position-increasing order draws its shortfall from its MAIN's wallet, and none of that was readable from the SDK.

      quotePerpFundingPayer returns a discriminated union, not an address, because the contract's single zero collapses three situations a UI must not render alike: the rail is dormant (the bank holds no registry, so the feature is off for everyone and linking would not help), the wallet is unlinked (the one case the user can fix), or the wallet isMain (linked, but funding flows main→child only). The arming check runs first and short-circuits, so a deployment with the rail off costs one read and cannot report a misleading "unlinked".

      getPerpMainFunding().payer is the payer snapshotted at funding time, not whoever is linked now. Both routes home settle against it, so an unlink or re-link between the pull and the repayment cannot misroute the money — read it rather than mainOf when the question is "who gets it back". principal is what the child may trade but not withdraw (withdraw frees at most balance - principal), and it is not a segregated bucket: the claim clamps to min(principal, balance) at flat moments, so the child's own contribution is the junior tranche and a loss eats it first.

      getPerpWalletLinkage derives isChild / isMain because the raw encoding is a trap — a main resolves to ITSELF in mainOf, so the natural test main !== zero is true for mains and children alike. maturesAt gates ADL netting only; the funding rail reads the raw graph, so a link can be fundable and not yet mature.

      All chain tier. The rail's five events are live on chain but not yet subscribed, so HISTORY is not available — these answer "what is true right now". Writes (proposeLink / acceptLink / repayFunding / recallFromChild) are deliberately not in this release.

    Addeda count can now say whether it is the whole truth

    • client.countMarketsBounded / client.countBinaryMarketsBounded — return CountResult ({ count, truncated }) instead of a bare number. truncated: true means count is a LOWER BOUND (10,000 rows): render it as "10,000+", and do not gate pagination on rows.length < count, which goes false while rows remain.

      Without the server-only _aggregate header every count falls back to a bounded row scan that returned the rows it fetched — so a scan that filled its cap reported exactly 10000, indistinguishable from a real total. The fallback now requests cap + 1 and reads the extra row as the signal; the probe row is never counted. Fill and Order are already past the cap in production and Market crosses it during 2026.

      The six bare-number count helpers are unchanged, and each now documents the bound on its client declaration — past the cap they return a lower bound as if it were exact. countOperators/countVenues get no variant: those tables are orders of magnitude below the cap. countOrders/countUserFills have none yet, and Order/Fill are both already past it — copy countMarketsBounded when a caller needs the signal.

    Addeda binary market's resolution mode, served

    • BinaryMarket.mode"reference" (an up/down market whose threshold is another question's answer) or "fixed" (a threshold set at creation). Derived from strike, so it costs no extra query, and stamped on the live-tail path too — a market built from logs before the indexer has it reports the same mode it will report after. Exported as BinaryResolutionMode, with binaryResolutionMode(strike) as the one place that reading happens — both construction paths call it, so they cannot drift.

      Consumers previously inferred this from a LOOKUP MISS — "no opening answer, so it must be fixed-strike" — which conflates "no reference question" with "the reference question has not been answered yet". That reads as working code until an oracle is slow.

    • boundaryPrice(market, openingPrices) — the level a market's outcome is measured against, taken from whichever source mode names, with posted saying which. Only a posted answer has an "answered at" instant, so only it may be labelled as one; returning that alongside the value keeps a label and the number it labels derived from one decision. Returns the raw oracle value — scale stays the caller's to apply.

    Changed

    • BinaryMarketFilter.intervalSec now matches a BAND, not an exact value. A rolled market's indexed intervalSec is expiry − tradingStart, and trading routinely opens a second or two late, so one 15m series is indexed at 898s and 899s as well as 900s. The filter matched exactly, so it returned a fraction of a cadence and called it the whole thing — 5m was missing a quarter of its own markets. listBinaryMarkets, listLiveBinaryMarkets, listPastBinaryMarkets and countBinaryMarkets all inherit this, so row counts change for any caller passing intervalSec. The band is ± CADENCE_TOLERANCE_SEC (5s); rungs are far enough apart that bands never overlap. A caller that genuinely wants one exact window should filter the returned rows itself.
    • A non-finite or non-positive intervalSec now THROWS RangeError rather than silently matching nothing. Previously { intervalSec: 0 } sent _eq: "0" and { intervalSec: NaN } sent _eq: "NaN", both yielding []. A filter that silently matches nothing reads as an empty result set rather than as the caller error it is, so it is rejected at the boundary. Callers parsing user input (a URL param, a form field) should validate first — the same way operatorId is already expected to be a positive integer.
    • marketIntervalLabel snaps to the cadence ladder, so BinaryMarket.interval and the labels stamped on trade-history and portfolio rows now read "1m" where they read "56s", "15m" where they read "898s". A window matching no rung is unchanged.

    Added

    • BaseMarket.createdAtBlock — the block a market was created in, the counterpart to resolvedAtBlock. A market is bracketed by two reads of an off-chain feed and only a block identifies which print each one saw: a "fixed"-mode market's strike is the feed's spot at createdAtBlock, and that print lands before tradingStart far more often than on it, so createdAtTimestamp cannot find it. The field is required, so any code constructing a Market by hand must supply it; it is non-null on the wire, so every indexed market carries one.
    • getResolutionPrices(marketIds) — batch settlement prices, the counterpart to getOpeningPrices. Joins each market's OWN oracleQuestionId, so fixed-strike markets (which have no reference question) are covered. Read its note on scale before consuming it: the value is a raw integer and the indexer does not carry the answer's decimals.
    • getOnchainResolutionPrice(marketId) and the OnchainResolutionPrice type — a chain fallback for a settled market whose answer the indexer never saw, because its oracle adapter is not the one the indexer ingests. Resolves the market's bound adapter through the module, so it works for any adapter, and carries its own decimals (adapters differ: 18 on the price-feed adapter, 2 on OracleHub). Returns null while the question is not final; a failed read throws rather than reporting absence.
    • CADENCE_LADDER_SEC, CADENCE_TOLERANCE_SEC, snapToCadence and cadenceBandSec — the SDK-canonical cadence rules, so grouping and filtering read one source instead of each re-deriving a tolerance. See the note on CADENCE_LADDER_SEC: these exist only because the markets indexer derives intervalSec from a market's own window instead of reading the exact value MarketCreator.MarketCreated already emits.

    Breakingthe MWRR capital base is deposited capital

    mwrr.return previously divided the window's gain by net trade flow: the carried-in position's value, plus buys, minus the full proceeds of sells. Net trade flow is not capital the account put in, so the figure overstated the return for an account trading a small part of its balance. An account that funded 10,000 USD, traded 500 USD and gained 50 USD reported 10 percent instead of 0.5 percent. Supply the new funding events to measure against real deposited capital; without them the base remains a trades-only proxy, and mwrr.capitalBasis now says which one produced the number.

    mwrr.depositedUsd keeps its name and its meaning of net capital, with one correction: a sell now returns only the capital it matched against a tracked position. Proceeds of tokens the account never bought on the venue no longer reduce the base, so an account selling externally-sourced tokens reads zero rather than a negative base — and receives a return where it previously received none.

    Returns computed from funding events are time-weighted (Modified Dietz): each deposit or withdrawal counts for the fraction of the window it was invested, so capital that arrived late no longer claims a whole window's return. Trade flows are deliberately not weighted. A trade moves capital that is already in the account, and weighting a sale as capital returned and a later repurchase as capital re-deployed would collapse the base for an account that merely rearranged what it held.

    A return can exceed 100 percent in either direction on the funding basis, because capital deployed for a few hours of a window earns or loses at a large period rate. That is what a money-weighted rate states. Read the new weightedCapitalUsd to see how much capital the figure is measured against before presenting it as a headline.

    Every account's reported return changes. Read mwrr.capitalBasis to detect which definition a build implements, rather than inferring it from the package version.

    Fixed

    • Binary PnL no longer folds a recycled pool's PREVIOUS markets into the current market's cost basis. A binary pool is reused by successive markets, and the PnL reads keyed a wallet's fills by pool address, so an earlier market's fills landed on whichever market held that pool next. getBinaryPositionPnL and getOpenPositionsWithPnL could both report a wrong avgCost, costBasis, unrealizedPnl and realizedPnl. They now key by the stable market_id that FillRow.market has carried since 0.28.0. Reported money values change for any wallet that traded more than one market on the same pool; spot and perp are unaffected, since their market id IS the pool address.
    • getOpenPositionsWithPnL now asks the indexer only for the fills and router actions of the markets it is folding. Both reads are capped at 1000 rows and the cap drops the OLDEST rows first — the buys and mints that establish the cost basis — so a wallet with a long cross-market history could be priced off a partial book.

    Fixedportfolio analytics reject a non-finite sampling bound

    computePortfolioAnalytics sampled its equity curve until the sample time reached asOf. A non-finite asOf, or a window start derived from a non-finite event timestamp, left that comparison false forever while every iteration appended another point, so the call exhausted memory instead of reporting a bad argument.

    The fold now validates asOf, every event timestamp, and the derived window and bucket bounds before it sorts, allocates, or samples. An invalid value throws InvalidInputError naming the field. Nothing is substituted and no event is dropped: an invalid time is the caller's to fix, and guessing one would misplace money on the curve. The new ComputePortfolioAnalyticsError type states the errors the function can throw.

    fetchPortfolioAnalytics now skips a fill whose indexed timestamp is unusable — nullish, blank, or non-numeric — the same way it already skips a fill whose direction it cannot determine. The check runs before the market is recorded and before a candle range is derived, so one unusable row cannot move an all-window start. Blank strings matter here specifically: Number("") is zero, which would have placed a fill in 1970 rather than failing.

    A finite bound is not automatically a sampleable one. A fixed timeframe subtracts a constant, so its span is its own definition, but the all timeframe opens at the earliest event — one funding event dated far enough in the past asked the fold for millions of points and exhausted the heap. The fold now rejects a span above 100,000 sample points, naming the implied count. That is roughly 11 years of hourly buckets or 270 years of daily ones, so a real portfolio never meets it: a twenty-year history samples about 7,300 points and is unaffected.

    The sampling loop itself stays uncapped. Capping it would turn a loud failure into silently truncated financial output; refusing an impossible span is the loud version.

    Added

    • getUserFills / countUserFills take market (one bytes32 market id) and markets (several) alongside the existing pool, as the exported FillsScope. getRouterActions takes markets as RouterActionsOptions. The predicates run at the indexer, so a limit applies to the rows you asked for. Prefer market over pool on binary: a pool selects every market that ever used it.

    Addedclosing-price snapshot voids (TRAD-106)

    Venue-selectable void payouts: a CLOB_SNAPSHOT venue's markets pay [p, D−p] at their closing YES price when they void, instead of the uniform 0.50 refund.

    • Venue params v3: BinaryVenueParams gains optional voidPolicy ("UNIFORM" default | "CLOB_SNAPSHOT"; exported as VenueVoidPolicy). encodeBinaryVenueFeeParams encodes v3 through the module's on-chain encoder; decodeBinaryVenueFeeParams (and the BINARY_V1 plugin codec) dual-decodes — legacy 192-byte v2 payloads stay valid with an implied UNIFORM.
    • trader.captureClose({ pool, maxSteps? }) — the permissionless closing-price capture that lifts the pool's closing-book lock. Sweep keepers: a CloseNotCaptured revert on a post-expiry cancel/sweep means "capture first, then retry"; after a normal resolution the lock lifts by itself and capture is never needed.
    • Reads: client.getClosingPrice(pool) (returns ClosingPriceState or null on pre-capture pools — the selector doubles as the capability probe), closingTop in the pool read ABI, and voidPolicy on getMarketOnchain (null on pre-policy clones) + the indexed BinaryMarket row.
    • Errors: the contract-error table now decodes the capture surface (CloseNotCaptured, CloseAlreadyCaptured, CaptureTooEarly, CaptureStepsExhausted, InvalidVenueVoidPolicy, InvalidVoidPolicy).

    Addeddead-oracle recovery without cast send

    Trader gains the two entry points the recovery path was missing, so the whole chain is SDK-callable (syncSettlement / finalizeMarket / releasePool were already there):

    ts
    // 1. Always try the real answer first — voiding pays 1/N, resolution pays winners.
    await exchange.trader.pokeOracle({ oracleQuestionId });
    
    // 2. Only once expiry + settlementWindow has lapsed:
    await exchange.trader.voidExpired({ marketId });
    await exchange.trader.syncSettlement({ marketId });  // void bypasses the module
    await exchange.trader.finalizeMarket({ marketId });
    await exchange.trader.releasePool({ marketId });
    
    • pokeOracle is keyed by oracle question, not market: the module fans out to every market bound to that question and resolves the ones whose adapter answers. A success is not "all bound markets resolved". OracleNotAnswered (none answered) and UnknownOracleQuestion (nothing bound) arrive as ContractRevertError, so a keeper loop can branch on errorName.
    • voidExpired writes to the market contract, bypassing the module — hence the syncSettlement follow-up to release the hub earmark. Before sending, it reads the market's state and throws InvalidInputError naming the exact unix second the window lapses, because the on-chain SettlementWindowOpen revert carries no timestamp. The gate is compared against the chain's block.timestamp (what the contract itself uses), not the local clock. Pass skipPreflight: true to send blind.

    OPERATOR-GUIDE §5's recovery runbook now shows these calls instead of cast send.

    Addedfunding-aware capital base

    mwrr carries two new fields. weightedCapitalUsd is the Modified Dietz denominator. capitalBasis names the definition that produced the figures: "funding" when external capital movements bearing on the window were supplied, "trades" for the fallback proxy that cannot see capital which never passed through a trade.

    fetchPortfolioAnalytics accepts a funding option, and the new PortfolioFundingEvent type describes a deposit or withdrawal. Funding events refine the capital base only — they never enter the equity curve, profit and loss, or volume. They are caller-supplied, because the indexer has no wallet token-transfer entity and the public RPC caps log queries at 1,000 blocks. Source them from application records, bridge history, or a private RPC scan, and exclude transfers whose counterparty is a venue contract or the same capital is counted twice.

    PortfolioFlowEvent is now a union of PortfolioTradeEvent and PortfolioFundingEvent. Code that constructs trade events with an explicit kind: "trade" is unaffected.

    FixedbalanceFloor proves its guarantee against the write path's conversion

    balanceFloor returns the largest number that does not exceed a raw balance, so a caller can size a "max" order without it reverting for insufficient funds. It verified that by round-tripping its candidate through candidate.toFixed(decimals), which is not the conversion a write performs. A number-to-raw conversion prefers the shortest string that round-trips to the same double, so 1.001 converts as "1.001", while (1.001).toFixed(18) is the smaller "1.000999999999999890". Checking the smaller value passed candidates that the write path then converted to more raw units than the wallet held.

    Over a deterministic sweep of 4,004 balances around a lot boundary at 18 decimals, 407 results converted above the balance and 110 still exceeded it after the venue's lot floor, by up to 110 wei. Six-decimal balances were unaffected, so this reached only 18-decimal venues — currently the only mainnet precision.

    The check now uses the same conversion as the write path. Results move by at most a few hundred wei, and after the venue's lot floor the placeable maximum is unchanged for every balance in that sweep, so no caller loses usable size. floorRawBalance inherits the fix on its fallback path. ceilRawAmount was never affected: it returns bigint and never round-trips through a double.

  • Read one account's stored perp leverage cap in a single call: getPerpMaxLeverage.

    MarginBank.getMaxLeverage(account, pool) is one storage read, but the only published route to it was getPerpLeverage, which walks the whole account and throws when any market the account is active in has a stale mark. A per-position badge or leverage dialog can now read the cap alone — it survives dead feeds on other markets, accepts opts.blockNumber for pinned rows, and returns 0 unchanged when no cap is set (that means "no account cap", not zero leverage).

  • Report a perp pool's aggregator oracle address on getPerpState.

    PerpStateOnchain gains an oracle field, read from the pool's own oracle() view. A consumer building a perp market description from getPerpState no longer has to omit the oracle or keep a parity read of its own against the pool.

    The read joins the existing pinned multicall, so it costs no extra round trip on that path and one parallel read on the ChainDoesNotSupportContract fan-out.

  • Addedname a revert from your own call

    decodeRevert, contractErrorsAbi and the RevertContext type are now exported from the package root.

    The SDK could already name a protocol revert: decodeRevert turns whatever a node threw into a ContractRevertError carrying the Solidity error name, decoding against contractErrorsAbi (500 custom-error entries). Neither symbol was on the barrel, and the exports map has no wildcard, so a deep import could not reach them either. A consumer holding revert data from its own call had no path to a name and had to hand-copy error fragments — which is how a signature drifts in silence. ContractRevertError alone is not a substitute: it is a passive holder that decodes nothing, so constructing one from raw revert data yields errorName: undefined.

    ts
    import { decodeRevert } from "@somnia-chain/markets-sdk";
    
    try {
      await publicClient.simulateContract(request);
    } catch (caught) {
      const err = decodeRevert(caught, { address: pool, functionName: "placeOrder" });
      console.log(err.errorName); // e.g. "InvalidTriggerPrice", not opaque hex
    }
    

    contractErrorsAbi is a generated artifact (pnpm errors:gen, verified in CI by pnpm errors:check). Publishing it freezes that artifact as public API deliberately: the point is that a consumer's copy stops drifting when ours is regenerated. Both values are pure over caller-held data — no transport, no client, no owner — so they sit at the root under SDK-API-003 alongside the other ABI data. toSdkError and isRevert remain internal; ask if you need them.

  • BreakingUnifiedOrder.type may be undefined

    fetchOrders, fetchOpenOrders and watchOrders now omit type instead of reporting "limit".

    That "limit" was never a read value. The pools do not emit the order type — OrderPlaced carries a placedOrder struct with no order-type member, and binary pools emit only the YES/NO side — so the indexer has nothing to store and the SDK had nothing to read. Four read paths wrote the literal anyway, which made every market order the SDK returned claim to be a limit order, with no field a consumer could check to tell the difference.

    Omitting it is the honest answer: absent means unknown, where before a wrong value was indistinguishable from a right one.

    The field is still present wherever the value is genuinely known:

    • createOrder() echoes the caller's own argument.
    • fetchOpenStopOrders() reads it from the registry row — PendingOrderCreated does carry orderType.
    • UnifiedStopOrder.type is unchanged and still required.
    diff
    - if (order.type === "limit") { /* true for every order, including market ones */ }
    + if (order.type === "limit") { /* now only where the type is actually known */ }
    

    If you branch on order.type from a read path, that branch was reading a fabricated value. Handle undefined explicitly; the honest source for how an order was placed is your own createOrder result. Populating it on read paths needs the pools to emit the order type, which is a contract change.

  • BREAKING — a binary market that has never traded reports NO mark, instead of a mark of zero

    computePositionPnL and computeBinaryPnl valued an unpriced market at a YES price of zero. A binary market has no price until its first trade: the indexer creates the row with no lastPrice, and only a fill writes one. markYesPrice reports that correctly as null. Both PnL folds discarded the null and put a confident 0 in its place.

    The NO leg is derived as the complement of the YES mark. A YES mark of zero is therefore a NO mark of the FULL collateral unit. On a 10.00 basis, a wallet holding only NO read as a 5.00 gain, and a wallet holding only YES read as a 5.00 total loss. A wallet holding both legs cancelled to 0.00, which hid the fault in the blended total while both legs stayed wrong. Minting a complete set is the ordinary way to take a position in a market that has not traded, so this state is reachable.

    The unknown price now travels as null rather than a number:

    • BinaryOutcomePositionPnL.markPrice, .markValue and .unrealizedPnl are bigint | null.
    • BinaryPositionPnL.markValue and .unrealizedPnl are bigint | null.
    • BinaryOutcomePnl.mark, .value and .unrealized are number | null.
    • BinaryPnl.unrealized and .total are number | null.

    Migration. Handle null on those fields before you display or total them. null means the market has no price to mark against — show it as unknown, and do not treat it as zero. Every field that does not depend on a mark keeps its old type and value: balance, costBasis, avgCost and realizedPnl stay exact.

    Nothing else changes. A market with a last trade or a resting quote marks as before. A resolved market still marks the winner at one whole collateral unit and the loser at zero, and a voided market still marks both legs at half, because a settlement payout needs no price. While a market is unresolved and priced, its two legs still sum to exactly one collateral unit.

Patch Changes

  • Document StaleMark in the Order.cancelReason vocabulary.

    The indexer writes five cancel reasons but both published vocabularies listed four, so the served GraphQL description and the SDK contract told a consumer that a value it can actually receive does not exist. No behaviour or type change: cancelReason remains an open string | null.

  • Fixedpre-fill maker cancellations now leave the live order book

    Deployed perp pools cancel resting maker orders before any fill — negative equity, stale mark, exceeds position, self-match — and the SDK's live tail dropped those events undecoded. The cancelled order stayed Open in the local store, so the order book kept counting its quantity at a price level the chain had already cleared: phantom depth in every rendered book and market-maker view, for the whole life of the watch.

    OrderCancelledPreFill (the base event for every pre-fill removal) and the perp reason tags MakerOrderCancelledNegativeEquity and MakerOrderCancelledStaleMark are now declared and terminate the order; MakerOrderCancelledExceedsPosition and OrderCancelledSelfMatch were already declared and are now covered by tests. Termination only — no store shape change and no new fields.

  • Account-scoped fill reads match indexed columns instead of joining through the taker's order

    Three reads found the fills an account participated in by OR-ing a predicate on the taker ORDER's owner alongside the denormalized columns: getUserFills/countUserFills, the binary getPortfolio, and the user snapshot the live store hydrates from. That arm is a relationship predicate, and Hasura compiles it to a correlated EXISTS subquery. Postgres cannot combine such a subquery with a bitmap OR over the maker and taker indexes: the plan walks the timestamp index backwards testing each row, and every row it walks past costs a lookup into Order.

    The penalty therefore falls hardest on ordinary wallets, which are sparse in a table that market makers fill. A busy maker reaches limit in a few rows and barely notices; a wallet with a handful of fills scans a long way and pays the subquery the whole way down. On the development indexer, four real single-fill wallets took 2.78s, 4.50s, 8.75s and 10.93s to sweep their tape, against 0.04s, 0.41s, 0.26s and 0.27s once the arm was gone.

    The arm found nothing the other two missed. backfillTakerFills in the indexer stamps the taker address from OrderPlaced for SPOT, PERP and binary alike, and the taker's order is placed in the same transaction as the fill it crosses — a resting order occupies the maker seat by definition — so no committed row can lack it. takerOrder: Order! is non-null in the schema for the same reason. The docblock that defended the arm described binary as leaving taker null; that stopped being true when binary side attribution moved to BinaryOrderPlaced.

    Reads that page a wallet's whole tape gain the most, because they pay the penalty once per page. The three also compound: a trader portfolio sweeps its fill tape, reads its binary positions and hydrates its snapshot, so one light wallet paid the scan three times over. Measured together on the development indexer, one page of each took 13.89s before and 0.27s after.

    No signature, option or row shape changes, and the rows returned are the same rows. takerOrder { owner side } is still SELECTED: side is what makes a binary row mappable when the fill's own takerSide copy is still lagging, and selecting a non-null relationship is an ordinary join rather than a filter. The invariant holds structurally: the indexer writes Fill.taker and Order.owner from the same local in the same handler, so the two cannot disagree — delegated and operator-placed orders included. sdk-e2e also asserts it against a live indexer, though that suite is hand-run rather than a CI gate.

v0.28.12026-08-21npm ↗

A single fix on top of 0.28.0.

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

NameUse
getPerpStopOrderexchange.client.getPerpStopOrder({ registry, orderId })
getPerpStopOrderSomiPaymentexchange.client.getPerpStopOrderSomiPayment(registry)

listPerpStopOrders did become a client member in 0.28.0, so listing was unaffected. But getPerpStopOrder is the only way to read a LIMIT stop's limitPrice, its linked siblingOrderId and its intent: no event carries them, so PerpStopOrder cannot, and nothing off-chain could tell an opening bracket from a reduce-only stop.

Three are pure functions — sync, no client, no Writer — so they are client-independent utilities and are exported from the root again:

  • perpLiquidationPrice
  • perpOrderMarginQuote
  • perpPositionAnalytics

Their input and output types were never removed (PerpLiquidationPriceInputs, PerpPositionAnalyticsInputs, PerpPositionMetrics, PerpOrderMarginQuote, PerpOrderMarginQuoteInputs), nor was PerpStopOrderOnChain, whose only producer is getPerpStopOrder.

No migration is needed for 0.28.0 code: every name above was unreachable, so nothing can depend on its old shape.

The roster in test/rootExports.test.ts now covers all five, so the same gap fails CI instead of shipping.

v0.28.02026-08-20npm ↗

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.

Breakingclient-scoped entrypoints (53 free functions + the ./lend subpath removed)

Anything that uses a connection (indexer reads, chain reads) is now reachable ONLY through the client facade — the root barrel no longer exports free-function duplicates of SomniaMarketsClient members. The migration is mechanical: drop the trailing indexerUrl/client argument and call the same name on exchange.client (every removed function already existed as a same-named member; none changed behavior). Types, pure kernels, constants, and ABIs stay importable from the root. ./react is untouched.

The @somnia-chain/markets-sdk/lend entrypoint is REMOVED for the same reason: the lend entry is client-scoped (client.lend). Its standalone surface moved to the root — change from "@somnia-chain/markets-sdk/lend" to from "@somnia-chain/markets-sdk" for the lend types (LendReserve, LendAccount, …), the SOMNIA_MAINNET_LEND / SOMNIA_TESTNET_LEND deployment constants, the ray-math helpers (lendRayRateToApy, rayMul, accrueLinear, accrueCompounded, RAY), and the lend ABIs. createLendWithDeps is no longer public — the client builds the lend entry itself.

ts
// before
import { getBookTops } from "@somnia-chain/markets-sdk";
const tops = await getBookTops(marketIds, indexerUrl);

// after
const tops = await exchange.client.getBookTops(marketIds);
Removed root exportUse instead
countOrdersexchange.client.countOrders(…)
countUserFillsexchange.client.countUserFills(…)
creditOfexchange.client.creditOf(…)
earmarkedOfexchange.client.earmarkedOf(…)
getAccountHealthexchange.client.getAccountHealth(…)
getAllOpenOrdersOnchainexchange.client.getAllOpenOrdersOnchain(…)
getBinaryBookParamsexchange.client.getBinaryBookParams(…)
getBookTopsexchange.client.getBookTops(…)
getFreePoolsexchange.client.getFreePools(…)
getFundingPaymentsexchange.client.getFundingPayments(…)
getFundingRateHistoryexchange.client.getFundingRateHistory(…)
getLiquidationPriceexchange.client.getLiquidationPrice(…)
getLiquidationsexchange.client.getLiquidations(…)
getMarginEventsexchange.client.getMarginEvents(…)
getMarketByPoolexchange.client.getMarketByPool(…)
getMarketCreatorexchange.client.getMarketCreator(…)
getMarketResolutionexchange.client.getMarketResolution(…)
getOpenInterestHistoryexchange.client.getOpenInterestHistory(…)
getOpeningPricesexchange.client.getOpeningPrices(…)
getOperatorHubAccountexchange.client.getOperatorHubAccount(…)
getOracleAdapterexchange.client.getOracleAdapter(…)
getOracleQuestionexchange.client.getOracleQuestion(…)
getOrderOnchainexchange.client.getOrderOnchain(…)
getOwnOpenOrdersOnchainexchange.client.getOwnOpenOrdersOnchain(…)
getPoolexchange.client.getPool(…)
getPoolBindingsexchange.client.getPoolBindings(…)
getPoolCreatorexchange.client.getPoolCreator(…)
getRouterActionsexchange.client.getRouterActions(…)
getSchedulingCostexchange.client.getSchedulingCost(…)
getVaultBalanceexchange.client.getVaultBalance(…)
getVaultPayoutFallbacksexchange.client.getVaultPayoutFallbacks(…)
listBuilderApprovalsexchange.client.listBuilderApprovals(…)
listBuilderFeesexchange.client.listBuilderFees(…)
listFundingRateCandlesexchange.client.listFundingRateCandles(…)
listFundingRateHistoryexchange.client.listFundingRateHistory(…)
listMarketCreatorsexchange.client.listMarketCreators(…)
listOperatorHubAccountsexchange.client.listOperatorHubAccounts(…)
listOracleAdaptersexchange.client.listOracleAdapters(…)
listOracleBindsexchange.client.listOracleBinds(…)
listOracleCallbacksexchange.client.listOracleCallbacks(…)
listOracleQuestionsexchange.client.listOracleQuestions(…)
listPerpFeesexchange.client.listPerpFees(…)
listPerpOrderHistoryexchange.client.listPerpOrderHistory(…)
listPerpPositionsexchange.client.listPerpPositions(…)
listPerpStopOrdersexchange.client.listPerpStopOrders(…)
listProtocolFeesexchange.client.listProtocolFees(…)
listSeriesexchange.client.listSeries(…)
listSettlementFeesexchange.client.listSettlementFees(…)
listSweepableOrdersexchange.client.listSweepableOrders(…)
outstandingOfexchange.client.outstandingOf(…)
quoteCreateMarketValueexchange.client.quoteCreateMarketValue(…)
resolveReserveexchange.client.resolveReserve(…)
withdrawableOfexchange.client.withdrawableOf(…)
createLendWithDeps (from /lend)exchange.client.lend (built by the client)
any other /lend importsame name, from "@somnia-chain/markets-sdk"

Fixeda scoped or windowed fetchMyTrades returns the rows it was asked for

fetchMyTrades read a wallet's history through three venue portfolio functions and called all three with no options, so the indexer capped fills at 50 per venue BEFORE any scoping happened and since/limit/ref were applied afterwards in memory.

The visible symptom was not truncation. A market-scoped call returned an EMPTY list over fills that exist: if a wallet's fills on the asked-for market were older than its 50 newest fills overall, the query never returned them and there was nothing left to filter. since failed the same way — "my trades since last week" returned whichever of the newest 50 fell in last week, a smaller and different set.

It now reads the unified fill tape (getUserFills), so the scope, the window and the limit are applied by the indexer. limit counts rows the caller receives: unresolvable rows are skipped and the read pages until the limit is satisfied or the tape is exhausted.

Two behavioural changes worth knowing:

  • Ordering. An unscoped call now returns rows newest-first across all venues. It previously returned them grouped binary → spot → perp, so "the newest 50 trades" was a venue-ordered window rather than the newest 50.
  • UnifiedTrade.info is now the FillRow the tape returned, where it was previously a venue portfolio trade row. It remains typed unknown and documented as the native fill row.

since stays in MILLISECONDS on this surface, matching UnifiedTrade.timestamp; the conversion to the indexer's unix seconds happens internally.

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

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

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

Fixedthe write path now applies the venue's tick and lot grids

createOrder converted the caller's price to raw units and sent it without ever snapping it to the market's tick grid — it never called priceToPrecision. So the pool rejected the order with InvalidPrice even when nothing had been lost in conversion: createOrder(sym, "limit", "buy", 1, 0.25) on a market with a 0.1 tick sent an off-tick price, and 0.25 is exactly representable as a double.

Market orders were worse, because the caller never supplies the price. The protective limit was best * (1 ± slippage) computed in float and sent unaligned. Its binary clamp also bounded at 1 / 10 ** decimals — one wei, not one tick — so on any venue whose tick exceeds a single raw unit the clamp's own bound was a value the pool rejects.

createOrder, createStopOrder and the computed crossing price now align in bigint space against the same grid the precision helpers read. Aligning a value that is already on the grid returns it unchanged, so a caller who pre-snaps with priceToPrecision / amountToPrecision sees no behavioural change at all.

Direction is side-aware, and never moves a value against the caller. A buy price rounds down, a sell price rounds up, and a quantity always rounds down — so an order is never larger, nor worse priced, than what was asked for. Rounding a sell price down (which is what priceToPrecision does, for either side) would be a real loss, so the write path and that helper can now differ by one tick on a sell; the helper's own spec pins its floor-only behaviour and is left alone.

The one exception is a protective limit the SDK computes for a market order: that rounds away from the caller, so the cushion survives alignment and the order still crosses the level it was priced against. This matches how the stake and sell quote builders in derivedReads have always done it.

Two visible consequences:

  • UnifiedOrder.price / .amount (and the stop-order equivalents) report what was actually placed, which may differ from the arguments by up to one tick or lot. remaining and status are derived from the placed amount, so an order that aligned down no longer reports a remainder that does not exist.
  • A quantity below one whole lot now throws InvalidInputError naming the market's minimum, instead of sending a zero-quantity order.

Not breaking: every affected call previously either reverted on-chain or was already being pre-aligned by the caller to the same value.

Fixeda float price could not be placed on an 18-decimal venue

fromHuman formatted numbers with toFixed(decimals), which prints the float's binary expansion rather than the number the caller typed: at 18 decimals (0.05).toFixed(18) is "0.050000000000000003". Three wei is enough to miss the live binary venue's 1e15 tick, so the pool rejected the order with InvalidPrice.

Measured against the mainnet USDso venue: of fifteen ordinary probabilities only 0.25, 0.5 and 0.75 could be placed, the ones binary floating point represents exactly. A 6-decimal venue never shows it, so testnet stayed clean.

Numbers now convert through their shortest round-tripping form (String(n), i.e. "0.05") whenever it fits the token's scale. toFixed still covers what it was there for: exponent notation and mantissas longer than decimals (which it rounds to scale, now stated in fromHuman's docs rather than left implicit). The string path is unchanged.

The same defect had been hand-rolled at two more sites, both fixed here so the write path has one definition of "the value the caller typed":

  • Structs.toRaw, which createOrder uses via toNativePrice — so the ccxt-shaped surface was broken too, and a fix to fromHuman alone would have left it that way.
  • exchange.priceToPrecision / amountToPrecision, the helpers a caller is told to use to AVOID off-tick values. They aligned to the grid in float space and then re-printed with toFixed, putting the expansion back — off-tick 16 times out of 19 on the live venue's ladder. Alignment now happens in bigint space, the only space the grid is defined in.

Note that this fixes conversion, not arithmetic done before it: a caller who computes 3 * 0.05 still hands over 0.15000000000000002, a genuinely different number. Snapping a computed price onto the venue's grid is priceToPrecision's job.

priceToPrecision keeps treating a value a hair BELOW a grid point as sitting on it, which the old float path did with a +1e-9 nudge. A book mid like (0.001 + 0.009) / 2 is 0.004999999999999999, and flooring that would quote a whole tick away from what the caller asked for. The tolerance is now relative (one part in 2 ** 52, the double's own resolution) rather than a fixed 1e-9, so it scales with the price instead of being meaningless at 1e6 and overwhelming at 1e-9.

amountToPrecision deliberately does NOT get that nudge — it snaps strictly down. An amount is usually bounded by something the caller cannot exceed (a wallet balance, a budget), and because the tolerance is relative it grows with magnitude: a balance one wei under a lot boundary would be rounded past it, producing the "insufficient balance" revert that balanceFloor / floorRawBalance exist to prevent. A price has no such ceiling. Structs.snapToGrid takes a strict option for callers in the first category, and under it the conversion INTO raw units truncates past decimals rather than rounding to scale — otherwise an over-precise number arrives already inflated and the floor starts from the wrong value (amountToPrecision(0.1234567) on a 6-decimal base returned 0.123457, above the caller's own number).

fromHuman and probabilityToPrice now reject non-finite numbers with InvalidInputError instead of letting viem's InvalidDecimalNumberError escape. probabilityToPrice(NaN) previously slipped past its own range check, since NaN < 0 and NaN > 1 are both false.

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

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

Addedcaller-set expiry and builder attribution on spot and perp orders

placeSpotOrder and placePerpOrder now accept the expiry and builder fields the binary placeOrder already threaded. They were hardcoded before, so a caller could not set a spot order's expiry at all and could not attribute a spot or perp order to a builder:

ts
await exchange.trader.placeSpotOrder({
  pool, isBid: true, price, quantity, baseDecimals, quoteToken, baseToken,
  expireTimestampNs: 1_800_000_000_000_000_000n, // was pinned to ~50y (GTC)
  builder: "0xYourFrontend",                     // was pinned to the zero address
  builderFeeBpsTimes1k: 25_000n,                 // was pinned to 0
});
  • PlaceSpotOrderParams gains expireTimestampNs, builder, and builderFeeBpsTimes1k. PlacePerpOrderParams gains the two builder fields (it already had expireTimestampNs).
  • Every field is optional and defaults to the value that was hardcoded before (farFutureNs(), the zero address, 0n), so existing callers are unaffected.
  • A nonzero builder fee still requires a prior Trader.approveBuilder on that pool — the approval is stored per pool. See docs/SPOT.md and docs/PERPS.md for the fee ceiling and the two spot-expiry traps (a past expiry places nothing silently; an expired order does not auto-return its escrow).

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

Addedliquidation-keeper reads: getPerpSideHolders + getBankruptcyPrice

The two MarginBank views the agreed permissionless liquidation-keeper design consumes, from head state alone — no off-chain indexer:

  • getPerpSideHolders({ marginBank, pool, isLong }, opts?) — every account holding an open position on one side of one perp market. Pages through the bank's bounded slice view (getSideHoldersPaginated; many holders per round-trip, never one call per holder) with every page pinned to ONE block — opts.blockNumber, or the head sampled once — and returns { holders, asOfBlock }. Feed asOfBlock into getBankruptcyPrice's blockNumber option (and the other side's call) so a sweep describes one consistent state.
  • getBankruptcyPrice({ marginBank, account, pool }, { blockNumber? }) — the bank's OWN price at which the position's allocated equity is exhausted, verbatim. A different quantity from the client-side getLiquidationPrice estimate (where liquidation triggers): keepers settle and bid against this one. Reverts arrive namedContractRevertError with errorName: "NoOpenPosition" when the account is flat in that pool (and "AdlZeroNotional" in the dust degenerate) — even though the generated error table does not cover MarginBank yet: the module fills the name in from the ABI-declared selectors, and the seam is a structural no-op once the table grows to cover the submodule's perp contracts.

New exports: PerpSideHolders, PerpSideHoldersRef, GetPerpSideHoldersOptions (types only — the reads live on the client, deliberately not as standalone connection-bound exports, matching the client-scoped API direction).

Addedbuild-only order placement (unsigned calldata)

trader.buildPlaceOrder, buildPlaceSpotOrder, and buildPlacePerpOrder take the same parameters as their sending twins but hand back the unsigned call instead of signing and broadcasting it:

ts
const { order, approval } = await trader.buildPlaceOrder({ pool, side: "BUY_YES", price, quantity });
if (approval) await walletClient.sendTransaction({ ...approval, account });
// `order` is {to, data, value} — pre-sign it, batch it, relay it, or simulate it.

For pre-signing an ERC-4337 UserOp off the form input, batching a placement into a multicall, handing it to a relayer, or simulating it. Ordinary "place this now" stays on placeOrder.

The approval is returned, not sentplaceOrder 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.)

AddedceilRawAmount

The SDK could round an amount DOWN in a controlled way but not UP. Flooring exists because a "max" one ULP above the wallet is an insufficient-balance revert; a minimum has the mirror failure, rejected as under the minimum.

  • ceilRawAmount(raw, decimals, quantum)raw rounded UP to a whole multiple of quantum. The mirror of floorRawBalance, for sizing minimums.

It returns a bigint rather than a number like the floors do: a lot boundary such as 0.1 at 18 decimals has no exact double, and the nearest one sits below the minimum. Convert at the edge with Number(formatUnits(...)) if you need a number.

Fixedstop-order and SpotPool reverts have names instead of hex

A failed placeSpotStopOrder or cancelStopOrder reported opaque hex. The generated revert-decoding table was extracted from this repo's Foundry artifacts only, and forge emits artifacts for just the DEX contracts our own contracts import — so SpotStopOrderRegistry and SpotPool, which nothing here imports, had no artifacts and no decodable errors at all. Trader's docs promised errorName would carry the protocol's own error; for those paths it never could.

errors:gen now builds the DEX submodule as its own forge project and merges its artifacts, so what the SDK can name no longer depends on what this repo happens to reference. Newly decodable:

  • All 28 ISpotStopOrderRegistry errors — including InsufficientVaultBalance, InsufficientSomiPayment, NoActiveSubscription, LimitPriceIncompatibleWithTrigger and ExceedsWithdrawableBalance.
  • The ISpotPool-only errorsOrderIdMismatch (cancelling an order that already filled) and the builder-code family (BuilderNotApproved, BuilderFeeExceedsApproval, BuilderCodesNotSupported).
  • The parameterless QuantityBelowMinimum() the registry declares, alongside the CLOB's existing QuantityBelowMinimum(uint256,uint256). Different selectors, so both entries are needed; only the two-argument one was present before.

errors:check is unchanged and still hermetic — it recomputes the stamp from this repo's files plus the submodule's pinned commit, with neither forge nor a submodule checkout, so CI needs no access to the private DEX clone. Only errors:gen now requires the submodule checked out, and it fails loudly rather than quietly writing a smaller table.

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

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

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

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

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

v0.27.02026-08-14npm ↗

The first release since 0.25.0. Three new entry points (/chains, /reactivity, /native), the perp trading surface filled in end to end, and batch order writes. One breaking item — the somnia-dex-protocol re-pin.

Breaking

  • somnia-dex-protocol re-pinned to main (DEX-1761). Reinstall to pick up the corrected revert ABI. Four order rejections that used to be silent — post-only would cross, self-match cancel, IOC no-fill, already-expired — are now named reverts.

Addednew entry points

  • /chains — every Somnia network as a viem Chain (mainnet, Shannon, Elwood, Hideki, local). The only place chain definitions live.
  • /chains bridge — the Hyperlane warp-route registry plus createBridgeTransfer / sendBridgeStep. Pure: no client, no RPC.
  • /reactivity — the upstream @somnia-chain/reactivity package re-exported, as an optional peer dependency.
  • /native — the node's somnia_* namespace: native ledger blocks, statistics, node public keys, reactivity subscription reads and session transactions.

Addedperps

  • Per-position analytics. Unrealised PnL, accrued funding, position margin and return on margin, instead of account-level equity that folded them together and exposed neither.
  • Leverage and a projected liquidation price. getPerpLeverage, plus previewPerpLiquidationPrice for an order not yet placed.
  • Max order size. getMaxPerpOrderSize — what a Max button should call. It binary-searches the pool's own sizing rule rather than keeping a second copy, so the size it returns cannot be one the pool then rejects.
  • Close preview. previewPerpClosePnl for a full or partial close. Handles the two subtleties: size snaps down to a lot multiple before anything realises, and a close settles funding on the whole position rather than the closed share.
  • Auto-pull. Placement can now fund itself from the wallet, so opening a position is one transaction rather than approve + deposit + place. Both order-form previews take an autoPull flag, and quotePerpOrderTopUp exposes the bank's own sizing. See docs/PERPS.md — the flag is opt-in because the pool gates it on msg.sender == order.owner, so it must stay off for placeOrderFor and operator-grant flows.
  • Stop orders (DEX-2154). placePerpStopOrder with linked one-cancels-other TP/SL pairs and opening triggers, plus listPerpStopOrders / getPerpStopOrder.
  • Build-only writes. buildPlacePerpStopOrder, buildCancelPerpStopOrder(s), buildDepositMargin and buildWithdrawMargin return the unsigned call instead of broadcasting it, so writes that are only safe together — an order with TP/SL attached, approve + deposit, withdraw + forward — fit in one UserOp, Safe batch or multicall. Approvals come back rather than going out and must execute first; ids come from your own receipt via decodePerpStopOrderIds. See docs/PERPS.md.
  • Funding-rate series (DEX-2025). buildFundingRateSeries and the matching React hook, for a chart — poll plus live nudge.

Addedeverything else

  • Batch order writes (MAR2-95). placeSpotOrders / cancelOrders / reduceOrders — a ladder in one transaction instead of a loop of single sends.
  • Operator grants (TRAD-140). Grant/revoke writes plus registry approval views.
  • Dead-oracle recovery. The two entry points the recovery path was missing, so it no longer needs cast send.
  • Vault funding. Deposits, and the auto-pull opt-out.
  • SpotPool funding + lock reads are public.

Fixed

  • Short-side liquidation price. getLiquidationPrice reported the liquidation further away than it actually is on shorts. No call-site change, but the numbers move.
  • CLOB reverts are named rather than surfacing opaque hex.
  • Perp reverts are named too. A failed PerpPool / MarginBank / PerpStopOrderRegistry call now reports ContractRevertError.errorNameInsufficientCollateral, MarketRestricted, InsufficientSomiPayment — across 122 error names, so an app no longer needs a hand-copied list that goes stale.
  • stopRegistry reaches perp market rows. Every perp stop write takes the per-pool registry as a required argument, and the SDK gave no way to find that address.

Changed

  • AsyncCache (MAR2-80) for promise-memoizing maps.

Perp stop orders — linked (OCO) TP/SL and opening triggers

placePerpStopOrder is now the single create entry point for perp stops, and it covers three registry functions that a caller no longer has to choose between:

  • A linked (OCO) pair — pass pair with the second leg. Both are created and linked atomically, at twice the SOMI (one payment funds one trigger). When one leg triggers and fills, the registry cancels the other and credits its SOMI back. Legs are routed by operator, not by argument order, so it does not matter which you pass first. The result carries pairedStopOrderId alongside stopOrderId, caller's own leg first.
  • An opening (non-reduce-only) trigger — pass intent: "opening" for a stop-entry or breakout, gated on initial margin at creation so an order that could never margin itself is refused rather than burning its SOMI at trigger.
  • Unchanged by default. A call passing neither intent nor pair still routes to createPendingOrder and produces byte-identical calldata to before.

Also linkPerpStopOrders (pair two stops that already exist, moving no SOMI), cancelPerpStopOrders (batch teardown in one transaction with a single refund — the way to close a pair), and getPerpStopOrder (read one stop straight from the registry). That last one closes a gap this SDK previously recorded as permanent: a LIMIT stop's limitPrice never leaves calldata and private storage, so nothing off-chain could show the limit a trader chose. It also answers during an indexer outage, and is currently the only way to read a pair's siblingOrderId and an order's intent.

Cancelling one leg of a pair yourself cancels one order — the other stays armed and becomes unlinked. That is the registry's behaviour, not a limitation here.

getPerpStopOrder().intent is null, not "reduceOnly", when the registry reports an intent this SDK version does not know. The registry appends to its enums (DropReason just gained NoFill), and defaulting an unrecognized member to reduce-only would assert that an order cannot increase a position when a newer member might let it.

listPerpStopOrders decodes the new NoFill drop reason, and now breaks a createdAt tie with id so its offset paging cannot repeat or lose a row — two stops created in the same block share a timestamp, which left the boundary between two pages undefined.

The listing does not yet carry pairing, intent, or a cancellation cause. Those are new indexer columns behind a from-scratch reindex, and selecting a column the deployed Hasura does not serve is a validation error rather than a null — it would take the whole read down for every caller. They arrive in a follow-up release once that reindex has cut over.

v0.25.02026-08-07npm ↗

getOpenPositionsWithPnL(account) — reliable avg-cost PnL for ALL of an account's open binary positions in one batched call.

Added

  • client.getOpenPositionsWithPnL(account)OpenPositionPnL[]: each open position's market joined with its costBasis / avgCost / markValue / unrealizedPnl / realizedPnl, computed identically to getBinaryPositionPnL (weighted-average cost from the account's own fills, marked to the book-clamped price) but for every open market at once, in a bounded number of indexer round-trips (fills + router actions + top-of-book batched) instead of a per-position loop. Prefer this over deriving PnL from book stats.
  • Exported the pure computeOpenPositionsPnL fold + the OpenPositionPnL type.

Additive; no breaking changes.

v0.24.02026-08-07npm ↗

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.

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

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

AddedlistPerpPositions

listPerpPositions(account, opts?) returns every pool's position from the indexer, replacing a chain-read-per-market fan-out. Additive; getPerpPosition remains the authority for a single pool.

Three fields are translated at this boundary because the raw shape would produce plausible wrong numbers: size is signed (folded from the entity's absolute size

  • isLong), entryPriceX18 is exported as avgEntryPrice (the column name is a misnomer — the value is raw quote units per whole base, not 1e18-scaled), and realizedPnl is exported as lastUpdateRealizedPnl (most recent update only, not cumulative, not summable).

Fully-closed positions are excluded by default (includeFlat: true returns them). Nothing is marked to market — unrealized PnL, liquidation price and margin health remain chain reads.

AddedlistPerpOrderHistory

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.

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

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

AddedpreviewPerpOrderMargin

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.

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

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

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

AddedlistSweepableOrders

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.

Fixedbinary precision comes from the pool, not a one-token guess

amountToPrecision returned 0 for any binary amount below a whole token. Binary rows arrive from the indexer with tickSize / lotSize / minQuantity undefined, so the helper fell back to a one-whole-token lot — while every deployed venue enforces a 0.001-token lot:

ts
// BEFORE — 0.005 outcome tokens, on a venue whose lot is 0.001
exchange.amountToPrecision("BTC-UP/USDC#YES", 0.005); // 0  ✗
// AFTER
exchange.amountToPrecision("BTC-UP/USDC#YES", 0.005); // 0.005

loadMarkets() now reads each distinct binary pool's getOrderBookParameters (pipelined, cached per pool, re-read on loadMarkets(true)), feeding amountToPrecision, priceToPrecision and UnifiedMarket.limits.amount. If a pool's parameters cannot be read the helpers throw InvalidInputError for that market rather than quantizing against the fallback — callers that treated 0 as "too small to trade" should catch it. Spot and perp precision is unchanged.

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

v0.23.02026-08-06npm ↗

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.

Breakingperp open interest is ONE counter

PerpMarket.longOpenInterest / shortOpenInterest and the same pair on PerpStateOnchain and OpenInterestSnapshot are replaced by a single openInterest.

Not a simplification — a correction. The contract emits OpenInterestUpdated(uint256) and exposes getOpenInterest() -> uint256, because in a matched CLOB the short side is provably equal. The two-field form never matched the deployed ABI, so the indexed pair was null on every row (its subscription's topic0 could not match) and the chain read threw on every call.

diff
- const oi = BigInt(m.longOpenInterest ?? 0) + BigInt(m.shortOpenInterest ?? 0)
+ const oi = BigInt(m.openInterest ?? 0)

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

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

columnsourceaggregation
badDebtResidualBadDebt, AdlPriceCapacityExhausteda LEVEL — never SUM
insuranceCoveredBadDebtAbsorbed.covereda FLOW — SUM is exact
deficitBadDebtAbsorbed.badDebt, ResidualBackedByOpenPnla LEVEL — never SUM
coverageDeclinedCoverageDeclinedByEquityCapa 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.

ChangedgetFundingRateHistorylistFundingRateHistory

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.

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

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

  • AddresspoolAddress, baseToken, quoteToken, stopRegistry, marginBank, marketAddress, collateral, creator
  • HexmarketId, createdByTx, venueId, context

The live-book types follow for the same reason — a field fed from a typed source but declared string widens it straight back and keeps the casts this change exists to remove:

  • Tradable.pool (always market.poolAddress)
  • LiveFill.pool / .maker / .taker, LiveOrder.pool / .owner
  • DecodedEvent.address (viem already types the log source; the SDK was casting the type away and then widening it with .toLowerCase())

Lookup-key parameters stay string and are not part of this change: getLiveFills(pool, …), getLiveMarketByPool(pool), getLiveSpotOrderBook(pool, …) and the other live reads take a pool address as a map KEY, which they lowercase before lookup. Tightening those would break callers holding a plain string for no safety gain — the value is being matched, not carried.

ts
// BEFORE — cast at every use
await client.getBinaryOrderBook(market.poolAddress as `0x${string}`);
await client.getMarketOnchain(market.marketAddress as Address);

// AFTER — the type is already right
await client.getBinaryOrderBook(market.poolAddress);
await client.getMarketOnchain(market.marketAddress);

Most code needs no change at all. Existing as Address casts keep compiling (now no-ops), and reading a field into a string still works, because Address is a 0x-prefixed string. What breaks is only the reverse direction — assigning a plain string INTO one of these fields, e.g. building a Market-shaped object from untyped data:

ts
const m: SpotMarket = { ...rest, poolAddress: someString };  // now an error
const m: SpotMarket = { ...rest, poolAddress: someString as Address };  // fix

Values are unchanged: still lowercase, exactly as the indexer stores them. viem's getAddress is deliberately not used — it returns EIP-55 checksummed addresses, and the SDK's own store keys its pool/market lookup maps on the lowercase form. Address is a template-literal type that lowercase satisfies, so nothing is normalized at runtime and there is no per-row cost.

Not retyped, because they only look hex-ish: yesTokenId, noTokenId, strike and nonce are decimal uint256 strings, and id is a bytes32 for binary markets but a pool address for spot/perp.

Breakingthe 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 decoratedreadContract / call rethrow as typed SDK errors. So a consumer reading their own contract through it lost viem's error types silently: catch (e) { if (e instanceof ContractFunctionRevertedError) … } simply stopped matching, no error, no warning. Worse on a foreign contract, where the decoder can't match the selector and produces a ContractRevertError with errorName: undefined — viem's typed error traded for nothing.

getViemClient() returns the undecorated client, over the same WebSocket (no second connection). Reads through it keep viem's error contract. Everything reachable from the client interface still uses the decorated one, so protocol reverts arrive decoded as before.

diff
- await client.publicClient.getBalance({ address })
+ await client.getViemClient().getBalance({ address })

A method rather than a property, so reaching outside the SDK's error contract is a visible act — and because the call opens the socket, which a field read hid.

BreakingcreateLend is no longer exported

client.lend (backed by config.addresses.lend) is the only entry to the lend surface. The factory took a whole SomniaMarketsClient to use two of its members, and let one chain's addresses be grafted onto another chain's socket — createLend(mainnetClient, SOMNIA_TESTNET_LEND) type-checked and silently read nothing, which the *_MAINNET_* / *_TESTNET_* constants make an easy mistake.

A different deployment means a different client:

diff
- const lend = createLend(exchange.client, SOMNIA_MAINNET_LEND);
+ const exchange = new SomniaMarkets({ chain, wsRpcUrl, addresses: { lend: SOMNIA_MAINNET_LEND } });
+ const lend = exchange.client.lend;

@somnia-chain/markets-sdk/lend still publishes the types, SomniaLendClient, both deployment constants, the ray-math helpers and the ABIs — only the factory is gone.

Breakingthree-identifier reads take one params object

Six methods keyed on three positional identifiers. All the perp/vault fields are Address, so any two swapped still compiled — and two siblings even ordered the same triple differently (getPerpPosition(marginBank, account, pool) vs getLiquidationPrice(marginBank, pool, account)), making the silent swap the easy mistake rather than the freak one. Each now takes a single object; the field names carry the order.

On SomniaMarketsClient:

diff
- client.getPerpPosition(marginBank, account, pool)
- client.getLiquidationPrice(marginBank, pool, account)
+ client.getPerpPosition({ marginBank, account, pool })     // PerpPositionRef
+ client.getLiquidationPrice({ marginBank, account, pool }) // PerpPositionRef (same shape, same order)
- client.getVaultBalance(vault, owner, token)
+ client.getVaultBalance({ vault, owner, token })           // GetVaultBalanceParams
- client.getOutcomeBalance(outcomeToken, account, id)
+ client.getOutcomeBalance({ outcomeToken, account, id })   // GetOutcomeBalanceParams
- client.getBuilderApproval(pool, user, builder)
- client.getEffectiveBuilderApproval(pool, user, builder)
+ client.getBuilderApproval({ pool, user, builder })          // BuilderApprovalRef
+ client.getEffectiveBuilderApproval({ pool, user, builder }) // BuilderApprovalRef

Trader.getBuilderApproval / Trader.getEffectiveBuilderApproval change the same way. The param types are exported from the package root.

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

v0.22.02026-08-05npm ↗

getPortfolio now carries the binary contract's series cadence on every active position and open order, so a Positions / Trade History row can show whether a contract is a 15m / 1h / 4h / 24h event.

Added

  • PortfolioMarket.intervalSec (raw seconds) + PortfolioMarket.interval (the derived "15m"/"1h"/… label), populated on portfolio.positions[].market and portfolio.openOrders[].market. portfolio.trades[].market.interval and BinaryMarket.interval already carried it — this closes the gap on active positions/orders. Additive; no breaking changes.

v0.21.02026-08-05npm ↗

The typed error tier, and indexer reads regenerated from the schema the indexer actually serves.

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

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

Breakingtypes 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.numEventsProcessednumber | null
  • IndexedOracleAdapter.createdAtTimestampstring | null
  • IndexedMarketCreator.createdAtTimestamp / .factory → nullable; .createdAtBlocknumber | null
  • IndexedSeries.createdAtTimestamp / .updatedAtTimestampstring | null

Migration: handle null where you read these (usually ?? "—" at the render site). If you formatted them as timestamps, you were rendering January 1970 for the absent case.

Notes

  • schema:pull now refuses to run against a Hasura that has not finished being configured. envio creates relationships ~25s after it starts tracking tables, and a snapshot pulled in that window is silently missing every relationship field — which then validates cleanly against every offline check.

v0.20.02026-08-04npm ↗

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.

Breakingtoken symbols preserve case

  • Token symbols preserve case. sanitizePart and loadMarkets no longer uppercase ERC-20 symbol() reads. Venue token symbols are mixed-case identities (USDso, USDC.e) and consumers key on them verbatim, so forcing uppercase was silently breaking lookups. If you persisted symbol strings from an earlier SDK version (e.g. "SOMI/USDSO"), they will no longer match the registry ("SOMI/USDso") — re-derive stored symbols from loadMarkets() after upgrading. Symbols that were already all-uppercase are unaffected.

Addedbaked-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:addressessrc/addresses.ts). External consumers get a zero-setup config.addresses without the monorepo's private deployments hub.

Addedbook-clamped PnL marks

PnL helpers used to mark unresolved positions to lastPrice alone. On a one-sided book that freezes the mark at a stale print — we saw a deep-ITM position showing +7.4% uPnL when it was really down 60%, because the losing side's makers had pulled their quotes and only a far-side bid remained.

  • markYesPrice(top, lastPrice) (new, exported): returns the two-sided mid, or the last trade clamped into the surviving side's bound. A resting quote beyond the last print is live, executable information and supersedes it; a last print inside the bound still wins (so a bid-ask spread doesn't flash as a loss right after a taker clears the top of the book).
  • computePositionPnL and computeBinaryPnl accept an optional opts.bookTop (YesBookTop, best YES bid/ask) and mark with the clamped price. Omit it and behaviour is unchanged (mark to lastPrice).
  • client.getBinaryPositionPnL now fetches top-of-book alongside the indexer fan-out (one extra eth_call) and passes it through. An indexer-only client or a failed read falls back to lastPrice alone — no breaking change for existing callers.
  • BinaryPnl legs (now named BinaryOutcomePnl) additionally report avgCost, mark, and value, so a per-leg positions UI can render entry/mark/value/uPnL% straight from the SDK instead of hand-rolling the fold.
  • binaryFillsFromPortfolio(trades, decimals) (new): derive BinaryPnlFills from one market's slice of getPortfolio().trades, which already carries the account's own side per fill.
  • The unified markYesPrice replaces the simpler mid-or-last version that shipped unreleased with the presentation tier. Same name, now (top: YesBookTop, lastPrice) with the one-sided clamp, exported from the barrel. midYesPrice (odds display) is unchanged.

Addedthe unified presentation tier

A unified exchange facade (SomniaMarkets) for presentation apps — the surface a frontend needs to render tickers, order books, and account state without reaching into the low-level client.

  • fetchTicker: ticker snapshots (last price, bid/ask, 24h volume).
  • fetchOrders: open and historical orders with a unified shape.
  • Stop orders: place and track stop orders through the same facade.
  • Portfolio analytics: getPortfolio() returns trades and positions with enough context to compute per-market PnL without extra calls.
  • takerIsBid on live fills: know which side of the book a fill took liquidity from.
  • txHash on UnifiedTrade and UnifiedOrder: link trades and orders back to their on-chain transaction.

Addedstake-sized binary quotes

  • quoteBinaryStake: get a buy quote sized by stake (the amount you want to spend) rather than by quantity.
  • quoteBinarySell: get a sell quote that walks the crossable bids — like the buy side walks the asks — so a thin book surfaces as a partial unwind up front instead of a silent IOC cancel.
  • BinarySellQuote gains fillableQuantity and estProceeds: see how much of your position the book can actually absorb and what you'd get for it before you submit.

v0.19.02026-08-03

Market timeframe served with markets & trade history — the series cadence a binary market trades on (15m / 1h / 4h / 24h) is now a ready-to-render label on every market AND every trade-history row, so consumers stop re-deriving expiry − tradingStart and hand-formatting it.

Added

  • BinaryMarket.interval — a new derived field on every market the SDK returns (all list + point reads flow through it): the timeframe label ("15m" / "1h" / "4h" / "24h"), computed from intervalSec (falling back to expiry − tradingStart). The label uses the largest unit up to hours that divides the cadence cleanly, so a 10-minute market reads "10m". null on SPOT/PERP.
  • Trade history carries the market's timeframe. FillRow (from getFills / getUserFills) gains a joined market context — { asset, intervalSec, interval, tradingStart, expiry } (new FillMarketContext type) — so a trade row can show which timeframe the order was for without a second query. PortfolioTrade.market (from getPortfolio) likewise gains intervalSec, interval, tradingStart, expiry.
  • New canonical cadence helpers (exported from the barrel): resolveIntervalSec, snapIntervalSec, formatIntervalLabel, marketIntervalLabel, and the IntervalSource type — the single intervalSec → "15m" mapping the explorer and other UIs now share instead of each re-implementing it.

v0.18.02026-08-03

SomniaLend integration — the SDK now wraps the third-party SomniaLend money market (an Aave v3.0 fork on Somnia mainnet 5031 + testnet 50312) so trading capital can earn while idle: supply USDso between sessions, post collateral, borrow working capital against it.

Added

  • New client.lend namespace on SomniaMarketsClient: lend.listReserves() (every listed asset — config, caps, live ray rates, indexes, liquidity, oracle price, one aggregated eth_call), lend.getAccount(address) (health factor, borrowing power, and every non-empty position, balances accrued to head with Aave's own interest math), and lend.createLender(signer) — the write surface (supply / withdraw / borrow / repay / setUseAsCollateral plus native-SOMI gateway variants supplyNative / withdrawNative / borrowNative / repayNative). Auto-approval follows the trader doctrine (one allowance read, maxUint256 grant, in-memory cache); borrowNative additionally auto-delegates credit on the WSOMI variable-debt token. Variable rate only.
  • New subpath entry @somnia-chain/markets-sdk/lend — the same module standalone: createLend(client, addresses), all types, the ray-math helpers (lendRayRateToApy, rayMul, accrueLinear, accrueCompounded, RAY), and the verified minimal ABIs.
  • addresses.lend?: LendAddresses on ClientConfig wires the deployment; the published addresses ship as SOMNIA_MAINNET_LEND / SOMNIA_TESTNET_LEND (NOT in the deployments manifests — SomniaLend is third-party; the testnet addresses are undocumented upstream, extracted from the official app's testnet mode and verified on-chain). Lend methods throw a clear error when the address they need is unset.
  • React: useLendReserves() / useLendAccount(address) in @somnia-chain/markets-sdk/react.
  • Docs: docs/LEND.md guide; ABI shapes pinned against the verified deployed contracts (UiPoolDataProviderV3's v3.0 54-field reserve tuple — do not hand-edit).

v0.17.02026-07-29

Two related price-feed changes: reads are pinned to a quote asset, and the feed's freshness surface becomes readable.

Changedquote pinning

The feed publishes more than one pair per base — BTC/USDC and BTC/USDT both carry base: "BTC" — so base alone stopped being a unique feed key and every read matched two rows. The oracle has migrated to USDC: the USDC rows are live, the USDT rows froze on 2026-07-21, and they have since drifted ~5% apart. Feed(where: {base}) returned both and the SDK took [0], so the current price was a coin-flip between a live value and a week-stale one.

  • PriceFeedConfig gains an optional quote (case-insensitive, e.g. "USDC"). When set, every read — snapshot, feed info, history, candles, the catalog, and both live subscriptions — adds a quote: {_eq} clause, so a base resolves to exactly one feed. $quote is passed as a GraphQL variable, never interpolated.
  • SOMNIA_TESTNET_PRICE_FEED is now pinned to quote: "USDC".
  • Leaving quote unset preserves the old unfiltered behaviour (correct only where each base has a single quote).

Beyond the wrong current price, an unpinned multi-quote base merged two series into one candle response, producing duplicate bucketStart values — a hard error in charting libraries that require strictly ascending, unique timestamps (observed at H1 and D1; M1 windows are currently short enough to miss it).

Behaviour change: an unfiltered listPriceFeeds() against the testnet feed no longer returns bases that lack a USDC pair — today that is BCH (USDT-only), so the catalog goes 31 → 30 rows. Pass quote: undefined to opt out.

Addedfreshness

The SDK previously selected no timestamp beyond the block, so a consumer could not distinguish a 1-second-old price from a day-old one — and since a stalled feed simply stops pushing, a live subscription looks identical to a healthy one. On 2026-07-28 SOMI/USDC was 26.8h stale while every consumer reported it live.

  • PriceFeedInfo gains updatedAtMs (when the oracle last wrote the feed), sourceUpdatedAtMs (when the underlying market data was timestamped), and resynced. All unix milliseconds, null when unknown.
  • New useLivePriceFeedInfo(asset) hook — getLivePriceFeedInfo already existed on the client but had no reactive React binding.

Comparing the two timestamps separates the two distinct failures: a large updatedAtMs - sourceUpdatedAtMs gap means the oracle is still writing but with stale source data, whereas a growing updatedAtMs age means it stopped writing.

Both fields ride the shared selection set, so they populate from the snapshot and the live Feed subscription. Note that neither re-renders as a price ages: age must be computed against a local clock on a timer, because a stalled asset delivers no event to react to.

v0.16.02026-07-24

listBuilderApprovals / BuilderApproval reconciled with the indexer entity, plus follow-ups from the docs pass.

BreakinglistBuilderApprovals 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.updatedAttimestamp (unix seconds of the last BuilderApproved upsert; also the sort key, newest first).
  • New fields mirroring the entity: market (market id), blockNumber, txHash. pool is kept, now joined via the market row.
  • Migration: approval.updatedAtapproval.timestamp; everything else is additive.

Changedfollow-ups from the docs pass

  • exchange.fetchStatus() gains "connecting" (union widening): a watch whose WS handshake hasn't delivered a head yet reports "connecting" instead of a false "error""error" now means a previously-live socket was LOST. Callers switching exhaustively on status must handle the new member.
  • claimableFrom now enforces the lowercased pool its result type documents (previously true only via the indexer wiring).
  • Docs: RegisterSeriesParams.asset / SeriesOnchain.asset corrected to a plain display ticker ("BTC", not "BTC/USDT") per the MarketCreator natspec — it must match the source exchanges' spot listing for candle sources; binaryPoolImpl doc no longer claims it is unread (the explorer renders it).
  • Dead code: unused PortfolioTrade import (exchange.ts) and unused resolved local (derivedReads.ts) removed.

v0.15.02026-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).

AddedOracleHub payer credit

  • ABI (machineryAbi.ts): payerCreditOf(address), payerOf(bytes32), withdrawMyCredit(uint256,address); events PayerSurplusCredited(address indexed payer, bytes32 indexed marketId, uint256) + PayerCreditWithdrawn(address indexed payer, address indexed to, uint256).
  • Reads: client.payerCreditOf(payer), client.payerOf(marketId).
  • Write: createOracleHubAdmin().withdrawMyCredit({ amountWei, to }) — msg.sender-gated (the connected signer draws only its own accrued payer surplus). WithdrawMyCreditParams.

AddedMarketCreator self-reclaim + migration

  • ABI: reclaimOracleCredit(), armFirstRoll(uint32,uint256), plus reads firstRollArmed/latestExpiryBySeriesId/armedBoundary/marketCount and withdrawNative/cancelSubscription.
  • Admin: createMarketCreatorAdmin().reclaimOracleCredit({ creator }) (manual sweep of the leftover surplus; runs automatically each roll cycle on-chain) and .armFirstRoll({ creator, seriesId, firesAtSec }) (deferred first roll for a seamless MarketCreator migration — start the new creator exactly at the old market's expiry). ReclaimOracleCreditParams / ArmFirstRollParams.

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

BreakingOracle v2: earmark-at-creation resolution funding

  • Funding is now EARMARK-AT-CREATION: the per-market resolution reserve is attached to the market-creation value and LOCKED per-market at onBind (never withdrawable while the market is live). At resolution the exact metered gas cost is charged against the earmark and the surplus (reserve − charged) is credited to the operator's WITHDRAWABLE credit. No prepaid pool, no per-bind escrow. Bounded-drain resolution (batched callback
    • self-armed Schedule continuation) + content-addressed question dedup.
  • OracleHub ABI (machineryAbi.ts): earmarkedOf/creditOf/outstandingOf/ withdrawableOf(uint32), resolveReserve(), reservedFor(bytes32), operatorOf(bytes32), marketsForQuestion, pendingResolves, continuationSubId, setDrainParams. withdraw(uint32,uint256,address) is credit-only. The prepaid/escrow surface is removed.
  • Events: ReserveEarmarked, SurplusCredited, CreditWithdrawn, MarketBound, MarketResolveCharged, AnswerDelivered, CallbackAccounted, DrainContinuation (indexed-ness byte-for-byte with OracleHub.sol).
  • quoteCreateMarketValue(def) = getSchedulingCost(def) + resolveReserve() (both attached to the create; excess refunded).
  • syncSettlement(marketId) (module write): permissionless earmark reconcile for a market voided via BinaryMarket.voidExpired() (which bypasses the module, so the hub's earmark release never fires). Idempotent; reverts MarketNotSettled while still live.
  • Indexer reads (query.ts, matching indexer/schema.graphql): OperatorHubAccountRecord (earmarked/credit/outstanding), getOperatorHubAccount/listOperatorHubAccounts; reshaped OracleBindRecord/OracleCallbackRecord. preflight.ts gates on the full create value. Client surface (oracleHub.ts/createClient.ts/somniaMarketsClient.ts) follows.

BreakingSettlement v3: payout vectors

  • Binary markets settle to a payout VECTOR (payoutNumerators, denominator 10_000_000); redemption pays amount × num[idx] / D — one formula for win / loss / void (a losing redeem pays 0 without reverting). Reusable pools + a permanent BinarySettlement singleton.
  • getSettlement (readsAbi.ts) returns (…, uint256[] payoutNumerators) — removed the uint8 winningOutcome slot. SettlementRecord exposes payoutNumerators + a derived winningOutcome.
  • winningOutcome() was REMOVED from BinaryMarket — the SDK derives the winner as the argmax of payoutNumerators in getMarketOnchain, the redeem() auto-winner lookup, and the Resolved(uint32 payoutDenominator, uint256[] payoutNumerators) live-tail decode.

Addedthe operator "market machinery" management layer

  • Machinery admins (same signer doctrine as createOperatorAdmin): createOracleAdapterAdmin (create/fund/enableReactivity/gas params + adapter status), createGovernanceAdmin (setAdapterApproved, module-owner gating), createMarketCreatorAdmin (create/fund/registerSeries/updateSeries/triggerRoll/gas params
    • creator & series reads).
  • Indexer machinery reads (listMarketCreators/getMarketCreator/listOracleAdapters/ getOracleAdapter/listSeries + Indexed* mirrors), the MarketTypePlugin registry (fee-codec + machinery step descriptor keyed off the bytes4 marketType), and per-step preflight validators.
  • Deployments hub + SDK config gain marketCreatorFactory/oracleAdapterFactory/ sharedOracleAdapter (all optional, degrade cleanly when unset).

v0.13.02026-07-18

Settlement-extraction v2 — the binary release. A BinaryPool is no longer the permanent redemption custodian: on finalize its backing + resolution snapshot sweep to the ONE BinarySettlement singleton (the redemption home, forever), and the pool is recycled onto the next market — the same pool address serves SUCCESSIVE markets. This release also lands the full trader write surface, the pool-reuse / binding reads, the recycle-safe live order book, and the derived analytics bundle a frontend needs for a full-lifecycle up/down prediction-market product. Requires a v2 deployment (BinarySettlement + v2 pool impl + v2 OutcomeToken6909) and the WS5 indexer schema. Supersedes the 0.12.x binary surface; carries all of main through 0.12.3 (price-feed PriceFeedScheduler realignment + PricePoint.requestId).

Breaking

  • Outcome-id encoding changed — v2 wedges the pool's per-market nonce between the pool address and the outcome index: id = (uint160(pool) << 72) | (nonce << 8) | idx; marketKey = id >> 8 = (pool << 64) | nonce keys settlement records. The old outcomeIdFor(pool, idx) (pool << 8 | idx) is REMOVED — use the new exported helpers outcomeId(pool, nonce, idx) / decodeOutcomeId(id) / marketKey(outcomeId) from the package root. Any cached v1 ids are invalid.
  • kindOf(isBid, userData) is REMOVED — v2 stopped encoding the YES/NO side in userData (it is opaque market-maker bookkeeping now, forwarded verbatim). The side comes from the pool's new BinaryOrderPlaced(orderId, kind) event; map the enum with the new sideOfKind(kind) / ORDER_KIND_SIDE. Never decode userData.
  • getMarketOnchain(marketId) replaces getMarketOnchain(marketAddress) — market identity is the module's bytes32 marketId (pools/market contracts are recycled/per-market). Resolves through BinaryMarketsModule.markets(marketId); requires addresses.binaryModule. A 20-byte address argument throws loudly. The result gains marketAddress / nonce / finalized, and backing falls back to the settlement record's NET backing once finalized (the pool-side market.backing() reads 0 from then on).
  • Trader.redeem routes through the module, keyed by marketIdRedeemParams.marketId (bytes32) is required; market (address) is now only an optional lookup aid for outcomeIdx/outcomeToken. The module pulls the winning tokens under an ERC-6909 operator grant to the MODULE (auto-approved), finalizes-if-needed, and redeems via settlement. The v1 pool redeem no longer exists on-chain.
  • Pool write ABI: placeOrderplaceBinaryOrder(kind, price, quantity, expireTimestampNs, orderType, selfMatchingOption, builder, builderFeeBpsTimes1k, userData) (+placeBinaryOrderFor). The generic placeOrder/placeOrderFor/amendOrder REVERT (UseBinaryPlacement) on binary pools. redeem is gone from binaryPoolWriteAbi.
  • Order expiry must satisfy 0 < expireNs ≤ pool.marketExpiryNs — the pool rejects never-expiring / beyond-market orders (OrderExpiryBeyondMarket). Trader.placeOrder now DEFAULTS the expiry to the market's expiry (one marketExpiryNs read) instead of ~50y; an explicit expireTimestampNs is forwarded verbatim (no silent clamping).
  • Live-tail events: the pool Redeemed / SettlementFeeCharged events no longer exist (redemption + the one-time fee skim live on the settlement singleton). New events consumed: pool BinaryOrderPlaced / PoolFinalized / PoolRecycled; module MarketFinalized / PoolReleased; settlement MarketFinalized / SettlementFeeCharged / Redeemed / PayoutOwed / OwedClaimed. The module MarketCreated gained a nonce field.
  • Pool address is a TIME-VARYING market binding — never key a market by pool address. getMarketByPool now returns the pool's NEWEST (current) market and documents the caveat; BinaryMarket rows expose nonce for disambiguation.

Added

Settlement + redemption

  • Trader methods: redeemDirect({ outcomeId, amount, to? }) (settlement redemption by raw id), claimOwed({ token }) (push-fallback pull), finalizeMarket({ marketId }) + releasePool({ marketId }) (permissionless keeper entries), getSettlement(marketId)SettlementRecord | null.
  • trader.signRedeemAuth(params) + trader.redeemFor(params) — the relayed (gasless) redeem pair. signRedeemAuth has the position OWNER sign an EIP-712 RedeemAuthorization over the module's REDEEM_AUTH_TYPEHASH in the SomniaMarkets/1 domain (verifyingContract = the binaryModule); no tx is sent. A relayer then submits it via redeemFor — they pay the gas, the module pins the payout to owner (never the relayer). New types RedeemAuthorization, SignRedeemAuthParams, RedeemForParams.
  • Config: addresses.binarySettlement (and @somnia-chain/deployments maps the manifest's BinarySettlement proxy key onto it).

Pool reuse / bindings

  • client.getPoolBindings(pool)PoolBindingRecord[] — a pool's full pool→market binding history from the indexer (WS5 PoolBinding; newest nonce first; toBlock === null marks the current binding; closedBy is "Released" | "Rotated").
  • client.getPool(address)IndexedPool | null — the indexer's per-pool aggregate (creator, collateral, currentMarketId, currentNonce, generationCount).
  • client.getPoolCreator(pool) / client.getFreePools(creator, collateral) — the chain reads previously only reachable through the signer-bearing trader, now on the unsigned client tier (an unconnected explorer can render them). Standalone getPoolCreator / getFreePools are exported from the package root too; the Trader methods (poolCreator(pool), getFreePools(creator, collateral)) remain. Pool "sponsor" is uniformly "creator" across the SDK, matching the on-chain poolCreator() view and the indexer Pool.creator field.
  • "Finalized" in BinaryMarketStatus — the indexer's ClobMarketStatus terminal state (set when the market's backing + resolution sweep to the BinarySettlement singleton; supersedes Resolved/Voided). Flows through every status filter (listBinaryMarkets / listLiveBinaryMarkets / listPastBinaryMarkets / countBinaryMarkets). The live-tail reducer now also sets it on the module/settlement MarketFinalized events. The on-chain BINARY_MARKET_STATUS index map is deliberately unchanged (no on-chain enum member exists).

Trader writes

  • trader.reduceOrder(params) — shrink a resting order's remaining quantity IN PLACE, keeping its price-time queue priority (unlike an amend, which re-queues at the back). Works on spot AND binary pools — BinaryPool implements the _onOrderReduced_refundPartial hook, so the freed escrow returns to the owner. New ReduceOrderParams; new reduceOrder entry on binaryPoolWriteAbi.
  • trader.cancelExpiredOrders(params) / trader.sweepExpiredAtLevel(params) — the permissionless keeper drains for the resting book (inherited from the OrderBook base, callable by anyone on a binary pool). cancelExpiredOrders cleans an explicit list of expired ids; sweepExpiredAtLevel walks one price level from the best order cleaning up to maxCount. Each returns locked escrow to the order owner (best-effort — non-expired / stale entries are skipped on-chain). New CancelExpiredOrdersParams, SweepExpiredAtLevelParams; new cancelExpiredOrders / sweepExpiredAtLevel entries on binaryPoolWriteAbi.
  • PlaceOrderParams.userData?: bigint — opaque MM bookkeeping tag, default 0n, forwarded verbatim (the SDK never sets or interprets it).

Live order book (recycle-safe)

  • store.bookLevels(pool) filters by the pool's CURRENT market_id, structurally. A BinaryPool is recycled across markets (one pool serves successive markets, never concurrently). The live book now requires o.market_id to equal the pool's current binding (marketByPool(pool)?.id), and returns an EMPTY book when the pool has no current binding — the exclusion of a prior market's orders is now structural, not reliant on expiry timing.
  • client.getLiveBinaryOrderBookByMarket(marketId, { depth? }) + the useLiveBinaryOrderBookByMarket hook — resolve a binary book by marketId rather than pool address. If marketId is no longer the pool's current binding (stale/ended), returns an EMPTY book so a stale page can't render the successor market's orders. Backed by the new store.bookLevelsByMarket.

Reads & analytics (derived; no new indexer field)

  • client.quoteBinaryOrder({ pool | marketId, side, quantity, depth? }) — a market-order preview over the live book ({ avgPrice, cost, filledQuantity, wouldRest, levelsConsumed, slippageVsMid }). BUY consumes asks, SELL consumes bids, respecting the YES/NO price inversion. Also exported as the kernel quoteBinaryOrderOverBook. New type BinaryOrderQuote.
  • client.getMarketStats24h({ pool | marketId }) — trailing-24h { volume24h, trades24h, priceChange24h, high24h, low24h, openPrice24h } summed from 1h candle buckets. Kernel marketStats24hFromCandles; new type MarketStats24h.
  • BinaryMarketFilter.orderBy ("newest" | "closingSoon" | "volume" | "tradeCount") threaded into listBinaryMarkets + listLiveBinaryMarkets as the Hasura order_by (server-side). listBinaryMarkets still defaults to newest, listLiveBinaryMarkets to closingSoon; an explicit orderBy overrides. New type BinaryMarketOrderBy.
  • client.getBinaryPositionPnL(account, marketId) — avg-cost position PnL ({ balanceYes, balanceNo, costBasis, avgCost, markValue, unrealizedPnl, realizedPnl }, RAW units) reconstructed from the account's order-book fills folded with complete-set mints/merges, marked to lastPrice (or the settlement payout once resolved). Kernels pnlEventsFor + computePositionPnL; new types BinaryPositionPnL, PnLEvent.
  • client.getClaimable(account) — redeemable positions across settled (resolved/voided) markets, each shaped to feed trader.redeemMany({ entries }): { marketId, pool, outcomeIdx, amount, estPayout, status }. Winner payout skims the settlement fee; voided pays half both sides; losers omitted. Kernels claimableFrom + estPayoutFor; new types ClaimablePosition, ClaimableInput.
  • RouterActionRecord now surfaces the existing indexer amount field (each outcome's set size) so mint/merge cost basis folds in; PortfolioMarket now carries id (the bytes32 marketId).
  • getMarketResolution now returns openingAnswer (the reference-question oracle answer — a reference-mode market's OPENING price) and closingAnswer (its own resolution answer — the CLOSING price) alongside the outcome. oracleAnswer is kept as a deprecated alias of closingAnswer. Requires the deployment manifest to record OracleCore (its AnswerPosted.numericValue is the price) — the deploy script now writes it.
  • client.getOpeningPrices(marketIds) — batch opening (reference) prices for many markets in one pair of round-trips (map marketId → raw numericValue), for list views that show each up/down market's opening price without an N+1 fan-out.

Id helpers, ABIs, rows, live tail

  • Id helpers (single source of truth, exported): outcomeId(pool, nonce, idx), decodeOutcomeId(id), marketKey(outcomeId), types DecodedOutcomeId / OutcomeIdx.
  • ABIs: binarySettlementAbi (redeem / finalizeAndRedeem / finalize / claimOwed / getSettlement / isFinalized / owed / isPoolApproved / poolRegistrar / outcomeToken), binaryModuleWriteAbi / binaryModuleReadAbi (module redeem rails + finalizeMarket / releasePool + settlement / poolCreator / getFreePools / freePoolCount / marketNonce / markets), pool v2 reads on binaryPoolReadAbi (marketNonce / settlement / finalized / booksEmpty / marketExpiryNs / setBacking / getBinaryPoolParams), event ABIs binaryPoolEventsAbi / binarySettlementEventsAbi.
  • BinaryMarket rows: nonce / finalized / netBacking (WS5 indexer schema). MarketOnchain: marketAddress / nonce / finalized.
  • Live tail: watches the module + settlement whenever any market is watched; reducer implements the v2 binding model (MarketCreated opens/re-points a pool→market binding, PoolReleased closes it — order events attribute via the pool's CURRENT binding), takes sides from BinaryOrderPlaced, zeroes pool backing on PoolFinalized, and tracks the settlement-side netBacking via the settlement MarketFinalized / Redeemed.

Migration

  1. Regenerate/refresh the deployment manifest (v2 deploy adds BinarySettlement); addresses.binarySettlement flows through @somnia-chain/deployments automatically.
  2. Replace outcomeIdFor(pool, idx) with outcomeId(pool, nonce, idx) — get the nonce from BinaryMarket.nonce, MarketOnchain.nonce, or pool.marketNonce(). Purge any cached v1 ids.
  3. Replace kindOf(isBid, userData) with sideOfKind(kind) joined from BinaryOrderPlaced (indexer rows keep serving side precomputed).
  4. client.getMarketOnchain(...): pass the bytes32 marketId (from listBinaryMarkets / BinaryMarket.marketId), not the market address.
  5. trader.redeem(...): pass marketId (+ optionally outcomeIdx to skip a read). Native redemption (redeemNative) and complete-set methods are unchanged.
  6. Market makers: tag orders with userData freely — it round-trips verbatim and appears on indexed orders; it no longer selects the book side.

v0.12.32026-07-17

Re-add PricePoint.requestId — the on-chain feed re-added requestId to PriceUpdated (as an indexed arg) and the indexer now exposes it again, so the SDK surfaces it once more. Additive; the field 0.12.2 dropped is back.

Added

  • PricePoint.requestId (decimal string) — the Somnia-Agents batch request that produced a tick. One request prices every symbol in the tick, so all of a tick's rows share the same requestId (group by it for a whole batch / join to agent provenance). Read via getPriceHistory / the live tick tape.

v0.12.22026-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: pricespot (median spot index), emamark (the EMA-smoothed perpetual mark), emaClosemarkClose. The public LivePrice / PricePoint / PriceCandle field names are unchanged, so consumers need no code changes.

Removed

  • PricePoint.requestId — the scheduler batches every symbol into one agent request, so there is no per-tick request id.

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

v0.12.02026-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 historygetRouterActions(account, opts?)RouterActionRecord[] (redeem / mint / merge, from the indexer RouterActionRecord).
  • Resolution visibilitygetMarketResolution(marketId){ events, reference, oracleAnswer } joining MarketResolutionEvent / MarketReferenceLink / OracleAnswer (by oracleQuestionId).
  • Fee-record streamslistProtocolFees / listBuilderFees / listSettlementFees (the per-fill streams behind getMarketFees' running total; support a payer filter).
  • Builder-approval directorylistBuilderApprovals({ user?, builder?, … })BuilderApproval[], complementing the on-chain point read getBuilderApproval. ApproveBuilderParams is now exported.
  • Markets-by-creatorBinaryMarketFilter.creator (applied across listBinaryMarkets / listLiveBinaryMarkets / listPastBinaryMarkets / countBinaryMarkets).
  • Vault creditsgetVaultPayoutFallbacks(owner, opts?) (append-only credit history), client.getVaultBalance(vault, owner, token) (live claimable, ERC20Vault.getWithdrawableBalance), and trader.withdrawVault({ vault, token, amount }) (ERC20Vault.withdraw).
  • Perp margin health + liquidation pricegetMarginAccount now also returns imReq/mmReq/cmReq/marginStatus (from MarginBank.getAccountHealth / getMarginStatus); new client.getAccountHealth(marginBank, account) and client.getLiquidationPrice(marginBank, pool, account). MarginStatus / MARGIN_STATUS / AccountHealth are exported. The unified fetchPositions now populates UnifiedPosition.liquidationPrice.
  • Perp/funding history readsgetFundingPayments, getMarginEvents, getLiquidations, getFundingRateHistory, getOpenInterestHistory (the indexer's perp-account + funding/OI history).
  • Lookups + pagination totalsgetMarketByPool(pool) (resolve a market by pool address), and countOrders(owner, opts?) / countUserFills(account, opts?) (history-page totals via the _aggregate fallback helper, now extended to Order/Fill).
  • Unified balancesfetchBalance now includes binary YES/NO ERC-6909 holdings (keyed by tradable symbol).
  • Pure helpers + constantsCANDLE_INTERVALS (in lockstep with indexer/src/intervals.ts), and computeBinaryPnl(fills, balances, market) / binaryFillsFor(account, fills) (avg-cost basis, no indexer/chain dependency).
  • React hooks (@somnia-chain/markets-sdk/react) — a generic useIndexerQuery(fn, deps) plus usePortfolio, useMarkets, useCandles, useMarketFees, useOperators, and the live-store useLiveMarkets.

v0.11.12026-07-16

Fixed

  • Count helpers (countMarkets/countBinaryMarkets/countOperators/countVenues) now fall back to a bounded row count when Hasura _aggregate is not exposed to the requesting role (public role, no admin-secret header) instead of throwing field 'X_aggregate' not found. The fast aggregate path still runs when the privileged header is present. Real query/network errors still surface.

v0.11.02026-07-16

Operator/venue + fee refactor follow-up — resync the SDK to the post-refactor contracts, deployment hub, and indexer schema.

Added

  • Per-venue collateral. SomniaMarketsAddresses gains collateral (the per-venue collateral ERC-20). testUsdc remains as a legacy/fallback alias. getSystemInfo, trader.faucet, and collateral reads now resolve collateral ?? testUsdc, so hub-fed testnet clients (where the protocol addresses.json no longer carries TestUSDC) work correctly.
  • Settlement-fee backing. The live tail now consumes SettlementFeeCharged(address indexed feeRecipient, uint256 winningBacking, uint256 fee) and debits the market's backing running total, so the tail tracks on-chain setBacking after settlement instead of overstating it by the fee.
  • Module-created market discovery. watchAllMarkets({ discover: true }) now also discovers markets created via BinaryMarketsModule.createMarket (the 19-field module MarketCreated), not just the MarketCreator rolling series.

Fixed

  • getSystemInfo.binaryMarketImpl no longer falls back to the (different) binaryPoolImpl address when the live factory read fails.

Notes

  • Addresses are provided entirely by @somnia-chain/deployments (the single source of truth). SomniaMarketsAddresses now carries marketsCore and collateralRouter first-class, so consumers feed the hub map straight into new SomniaMarkets({ addresses }) with no hand-mapping.