Release notes

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.

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.