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-protocolre-pinned tomain(DEX-1761). Reinstall to pick up the corrected revert ABI. Four order rejections that used to be silent — post-only would cross, self-match cancel, IOC no-fill, already-expired — are now named reverts.
Addednew entry points
/chains— every Somnia network as a viemChain(mainnet, Shannon, Elwood, Hideki, local). The only place chain definitions live./chainsbridge — the Hyperlane warp-route registry pluscreateBridgeTransfer/sendBridgeStep. Pure: no client, no RPC./reactivity— the upstream@somnia-chain/reactivitypackage re-exported, as an optional peer dependency./native— the node'ssomnia_*namespace: native ledger blocks, statistics, node public keys, reactivity subscription reads and session transactions.
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, pluspreviewPerpLiquidationPricefor an order not yet placed. - Max order size.
getMaxPerpOrderSize— what a Max button should call. It binary-searches the pool's own sizing rule rather than keeping a second copy, so the size it returns cannot be one the pool then rejects. - Close preview.
previewPerpClosePnlfor a full or partial close. Handles the two subtleties: size snaps down to a lot multiple before anything realises, and a close settles funding on the whole position rather than the closed share. - Auto-pull. Placement can now fund itself from the wallet, so opening a position is
one transaction rather than approve + deposit + place. Both order-form previews take an
autoPullflag, andquotePerpOrderTopUpexposes the bank's own sizing. Seedocs/PERPS.md— the flag is opt-in because the pool gates it onmsg.sender == order.owner, so it must stay off forplaceOrderForand operator-grant flows. - Stop orders (DEX-2154).
placePerpStopOrderwith linked one-cancels-other TP/SL pairs and opening triggers, pluslistPerpStopOrders/getPerpStopOrder. - Build-only writes.
buildPlacePerpStopOrder,buildCancelPerpStopOrder(s),buildDepositMarginandbuildWithdrawMarginreturn the unsigned call instead of broadcasting it, so writes that are only safe together — an order with TP/SL attached, approve + deposit, withdraw + forward — fit in one UserOp, Safe batch or multicall. Approvals come back rather than going out and must execute first; ids come from your own receipt viadecodePerpStopOrderIds. Seedocs/PERPS.md. - Funding-rate series (DEX-2025).
buildFundingRateSeriesand the matching React hook, for a chart — poll plus live nudge.
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.
getLiquidationPricereported the liquidation further away than it actually is on shorts. No call-site change, but the numbers move. - CLOB reverts are named rather than surfacing opaque hex.
- Perp reverts are named too. A failed
PerpPool/MarginBank/PerpStopOrderRegistrycall now reportsContractRevertError.errorName—InsufficientCollateral,MarketRestricted,InsufficientSomiPayment— across 122 error names, so an app no longer needs a hand-copied list that goes stale. stopRegistryreaches perp market rows. Every perp stop write takes the per-pool registry as a required argument, and the SDK gave no way to find that address.
Changed
AsyncCache(MAR2-80) for promise-memoizing maps.
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 itscostBasis/avgCost/markValue/unrealizedPnl/realizedPnl, computed identically togetBinaryPositionPnL(weighted-average cost from the account's own fills, marked to the book-clamped price) but for every open market at once, in a bounded number of indexer round-trips (fills + router actions + top-of-book batched) instead of a per-position loop. Prefer this over deriving PnL from book stats.- Exported the pure
computeOpenPositionsPnLfold + theOpenPositionPnLtype.
Additive; no breaking changes.
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),entryPriceX18is exported asavgEntryPrice(the column name is a misnomer — the value is raw quote units per whole base, not 1e18-scaled), andrealizedPnlis exported aslastUpdateRealizedPnl(most recent update only, not cumulative, not summable).
Fully-closed positions are excluded by default (includeFlat: true returns them).
Nothing is marked to market — unrealized PnL, liquidation price and margin health
remain chain reads.
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 inclusive — maxTiers is the
maximum index, not a count, so the fund has maxTiers + 1 buckets. totalBalance is
deliberately not described as absorbable bad debt: it includes tier 0, which never
absorbs anything.
liquidationEngine is the proxy, and that distinction bites — an implementation
address answers with unset defaults (zero bidders, zero penalty), which looks like a
configured-but-idle engine rather than the wrong address; the returned marginBank is
the cross-check. bidderCount === 0n is an operational signal: with no stage-4
backstop bidders the waterfall reaches ADL sooner than the configuration implies.
tryGetPerpAccountEquity returns null where getAccountHealth would revert. Null is
"not computable right now", never "zero equity" — the two mean opposite things.
getPerpCollateralBasis is the complement: one storage pair, no oracle, cannot revert.
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:
// 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.
- 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:
| column | source | aggregation |
|---|---|---|
badDebt | ResidualBadDebt, AdlPriceCapacityExhausted | a LEVEL — never SUM |
insuranceCovered | BadDebtAbsorbed.covered | a FLOW — SUM is exact |
deficit | BadDebtAbsorbed.badDebt, ResidualBackedByOpenPnl | a LEVEL — never SUM |
coverageDeclined | CoverageDeclinedByEquityCap | a FLOW — SUM is exact |
If you were summing badDebt for a bad-debt figure, that total was
double-counting: the gross hole and its uncovered remainder both landed there, as
did a PnL-backed hole that is explicitly not bad debt and a coverage amount the
equity cap deliberately deferred. The correct point-in-time figure is the sum of
the latest badDebt row per account — a residual is a state sample, so
successive liquidations on one account re-report the same hole.
BadDebtAbsorbed.covered and absorbedBy are now exposed at all (they were
being dropped); absorbedBy arrives on counterparty.
ChangedgetFundingRateHistory → listFundingRateHistory
Renamed for the get* = value-or-null / list* = array convention in
CONVENTIONS.md. The old name forwards verbatim for one release cycle and is
marked @deprecated.
New in the same read: order: "asc" | "desc". The default "desc" returns the
newest page, so from is a window bound; pass "asc" to make it a forward
cursor. fetchFundingRateHistory(symbol, since, limit) now ascends whenever
since is given, which is what makes the ccxt pagination idiom
(since = last.timestamp + 1) terminate instead of re-reading the tail.
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:
Address—poolAddress,baseToken,quoteToken,stopRegistry,marginBank,marketAddress,collateral,creatorHex—marketId,createdByTx,venueId,context
The live-book types follow for the same reason — a field fed from a typed source
but declared string widens it straight back and keeps the casts this change
exists to remove:
Tradable.pool(alwaysmarket.poolAddress)LiveFill.pool/.maker/.taker,LiveOrder.pool/.ownerDecodedEvent.address(viem already types the log source; the SDK was casting the type away and then widening it with.toLowerCase())
Lookup-key parameters stay string and are not part of this change:
getLiveFills(pool, …), getLiveMarketByPool(pool), getLiveSpotOrderBook(pool, …) and the other live reads take a pool address as a map KEY, which they
lowercase before lookup. Tightening those would break callers holding a plain
string for no safety gain — the value is being matched, not carried.
// BEFORE — cast at every use
await client.getBinaryOrderBook(market.poolAddress as `0x${string}`);
await client.getMarketOnchain(market.marketAddress as Address);
// AFTER — the type is already right
await client.getBinaryOrderBook(market.poolAddress);
await client.getMarketOnchain(market.marketAddress);
Most code needs no change at all. Existing as Address casts keep compiling (now
no-ops), and reading a field into a string still works, because Address is a
0x-prefixed string. What breaks is only the reverse direction — assigning a
plain string INTO one of these fields, e.g. building a Market-shaped object
from untyped data:
const m: SpotMarket = { ...rest, poolAddress: someString }; // now an error
const m: SpotMarket = { ...rest, poolAddress: someString as Address }; // fix
Values are unchanged: still lowercase, exactly as the indexer stores them. viem's
getAddress is deliberately not used — it returns EIP-55 checksummed addresses,
and the SDK's own store keys its pool/market lookup maps on the lowercase form.
Address is a template-literal type that lowercase satisfies, so nothing is
normalized at runtime and there is no per-row cost.
Not retyped, because they only look hex-ish: yesTokenId, noTokenId, strike
and nonce are decimal uint256 strings, and id is a bytes32 for binary
markets but a pool address for spot/perp.
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 decorated — readContract / call rethrow as typed SDK
errors. So a consumer reading their own contract through it lost viem's error
types silently: catch (e) { if (e instanceof ContractFunctionRevertedError) … }
simply stopped matching, no error, no warning. Worse on a foreign contract, where
the decoder can't match the selector and produces a ContractRevertError with
errorName: undefined — viem's typed error traded for nothing.
getViemClient() returns the undecorated client, over the same WebSocket (no
second connection). Reads through it keep viem's error contract. Everything
reachable from the client interface still uses the decorated one, so protocol
reverts arrive decoded as before.
- await client.publicClient.getBalance({ address })
+ await client.getViemClient().getBalance({ address })
A method rather than a property, so reaching outside the SDK's error contract is a visible act — and because the call opens the socket, which a field read hid.
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:
- 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:
- 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 onportfolio.positions[].marketandportfolio.openOrders[].market.portfolio.trades[].market.intervalandBinaryMarket.intervalalready 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.numEventsProcessed→number | nullIndexedOracleAdapter.createdAtTimestamp→string | nullIndexedMarketCreator.createdAtTimestamp/.factory→ nullable;.createdAtBlock→number | nullIndexedSeries.createdAtTimestamp/.updatedAtTimestamp→string | null
Migration: handle null where you read these (usually ?? "—" at the render
site). If you formatted them as timestamps, you were rendering January 1970 for
the absent case.
Notes
schema:pullnow refuses to run against a Hasura that has not finished being configured. envio creates relationships ~25s after it starts tracking tables, and a snapshot pulled in that window is silently missing every relationship field — which then validates cleanly against every offline check.
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.
sanitizePartandloadMarketsno longer uppercase ERC-20symbol()reads. Venue token symbols are mixed-case identities (USDso,USDC.e) and consumers key on them verbatim, so forcing uppercase was silently breaking lookups. If you persisted symbol strings from an earlier SDK version (e.g."SOMI/USDSO"), they will no longer match the registry ("SOMI/USDso") — re-derive stored symbols fromloadMarkets()after upgrading. Symbols that were already all-uppercase are unaffected.
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:addresses → src/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).computePositionPnLandcomputeBinaryPnlaccept an optionalopts.bookTop(YesBookTop, best YES bid/ask) and mark with the clamped price. Omit it and behaviour is unchanged (mark tolastPrice).client.getBinaryPositionPnLnow fetches top-of-book alongside the indexer fan-out (one extraeth_call) and passes it through. An indexer-only client or a failed read falls back tolastPricealone — no breaking change for existing callers.BinaryPnllegs (now namedBinaryOutcomePnl) additionally reportavgCost,mark, andvalue, so a per-leg positions UI can render entry/mark/value/uPnL% straight from the SDK instead of hand-rolling the fold.binaryFillsFromPortfolio(trades, decimals)(new): deriveBinaryPnlFills from one market's slice ofgetPortfolio().trades, which already carries the account's own side per fill.- The unified
markYesPricereplaces the simpler mid-or-last version that shipped unreleased with the presentation tier. Same name, now(top: YesBookTop, lastPrice)with the one-sided clamp, exported from the barrel.midYesPrice(odds display) is unchanged.
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. takerIsBidon live fills: know which side of the book a fill took liquidity from.txHashonUnifiedTradeandUnifiedOrder: link trades and orders back to their on-chain transaction.
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.BinarySellQuotegainsfillableQuantityandestProceeds: 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 fromintervalSec(falling back toexpiry − tradingStart). The label uses the largest unit up to hours that divides the cadence cleanly, so a 10-minute market reads"10m".nullon SPOT/PERP.- Trade history carries the market's timeframe.
FillRow(fromgetFills/getUserFills) gains a joinedmarketcontext —{ asset, intervalSec, interval, tradingStart, expiry }(newFillMarketContexttype) — so a trade row can show which timeframe the order was for without a second query.PortfolioTrade.market(fromgetPortfolio) likewise gainsintervalSec,interval,tradingStart,expiry. - New canonical cadence helpers (exported from the barrel):
resolveIntervalSec,snapIntervalSec,formatIntervalLabel,marketIntervalLabel, and theIntervalSourcetype — the singleintervalSec → "15m"mapping the explorer and other UIs now share instead of each re-implementing it.
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.lendnamespace onSomniaMarketsClient:lend.listReserves()(every listed asset — config, caps, live ray rates, indexes, liquidity, oracle price, one aggregated eth_call),lend.getAccount(address)(health factor, borrowing power, and every non-empty position, balances accrued to head with Aave's own interest math), andlend.createLender(signer)— the write surface (supply/withdraw/borrow/repay/setUseAsCollateralplus native-SOMI gateway variantssupplyNative/withdrawNative/borrowNative/repayNative). Auto-approval follows the trader doctrine (one allowance read,maxUint256grant, in-memory cache);borrowNativeadditionally auto-delegates credit on the WSOMI variable-debt token. Variable rate only. - New subpath entry
@somnia-chain/markets-sdk/lend— the same module standalone:createLend(client, addresses), all types, the ray-math helpers (lendRayRateToApy,rayMul,accrueLinear,accrueCompounded,RAY), and the verified minimal ABIs. addresses.lend?: LendAddressesonClientConfigwires the deployment; the published addresses ship asSOMNIA_MAINNET_LEND/SOMNIA_TESTNET_LEND(NOT in the deployments manifests — SomniaLend is third-party; the testnet addresses are undocumented upstream, extracted from the official app's testnet mode and verified on-chain). Lend methods throw a clear error when the address they need is unset.- React:
useLendReserves()/useLendAccount(address)in@somnia-chain/markets-sdk/react. - Docs:
docs/LEND.mdguide; ABI shapes pinned against the verified deployed contracts (UiPoolDataProviderV3's v3.0 54-field reserve tuple — do not hand-edit).
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.
PriceFeedConfiggains an optionalquote(case-insensitive, e.g."USDC"). When set, every read — snapshot, feed info, history, candles, the catalog, and both live subscriptions — adds aquote: {_eq}clause, so a base resolves to exactly one feed.$quoteis passed as a GraphQL variable, never interpolated.SOMNIA_TESTNET_PRICE_FEEDis now pinned toquote: "USDC".- Leaving
quoteunset preserves the old unfiltered behaviour (correct only where each base has a single quote).
Beyond the wrong current price, an unpinned multi-quote base merged two series
into one candle response, producing duplicate bucketStart values — a hard
error in charting libraries that require strictly ascending, unique timestamps
(observed at H1 and D1; M1 windows are currently short enough to miss it).
Behaviour change: an unfiltered listPriceFeeds() against the testnet feed
no longer returns bases that lack a USDC pair — today that is BCH (USDT-only),
so the catalog goes 31 → 30 rows. Pass quote: undefined to opt out.
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.
PriceFeedInfogainsupdatedAtMs(when the oracle last wrote the feed),sourceUpdatedAtMs(when the underlying market data was timestamped), andresynced. All unix milliseconds, null when unknown.- New
useLivePriceFeedInfo(asset)hook —getLivePriceFeedInfoalready existed on the client but had no reactive React binding.
Comparing the two timestamps separates the two distinct failures: a large
updatedAtMs - sourceUpdatedAtMs gap means the oracle is still writing but with
stale source data, whereas a growing updatedAtMs age means it stopped writing.
Both fields ride the shared selection set, so they populate from the snapshot
and the live Feed subscription. Note that neither re-renders as a price
ages: age must be computed against a local clock on a timer, because a stalled
asset delivers no event to react to.
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.updatedAt→timestamp(unix seconds of the lastBuilderApprovedupsert; also the sort key, newest first).- New fields mirroring the entity:
market(market id),blockNumber,txHash.poolis kept, now joined via the market row. - Migration:
approval.updatedAt→approval.timestamp; everything else is additive.
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 onstatusmust handle the new member.claimableFromnow enforces the lowercasedpoolits result type documents (previously true only via the indexer wiring).- Docs:
RegisterSeriesParams.asset/SeriesOnchain.assetcorrected to a plain display ticker ("BTC", not"BTC/USDT") per the MarketCreator natspec — it must match the source exchanges' spot listing for candle sources;binaryPoolImpldoc no longer claims it is unread (the explorer renders it). - Dead code: unused
PortfolioTradeimport (exchange.ts) and unusedresolvedlocal (derivedReads.ts) removed.
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); eventsPayerSurplusCredited(address indexed payer, bytes32 indexed marketId, uint256)+PayerCreditWithdrawn(address indexed payer, address indexed to, uint256). - Reads:
client.payerCreditOf(payer),client.payerOf(marketId). - Write:
createOracleHubAdmin().withdrawMyCredit({ amountWei, to })— msg.sender-gated (the connected signer draws only its own accrued payer surplus).WithdrawMyCreditParams.
AddedMarketCreator self-reclaim + migration
- ABI:
reclaimOracleCredit(),armFirstRoll(uint32,uint256), plus readsfirstRollArmed/latestExpiryBySeriesId/armedBoundary/marketCountandwithdrawNative/cancelSubscription. - Admin:
createMarketCreatorAdmin().reclaimOracleCredit({ creator })(manual sweep of the leftover surplus; runs automatically each roll cycle on-chain) and.armFirstRoll({ creator, seriesId, firesAtSec })(deferred first roll for a seamless MarketCreator migration — start the new creator exactly at the old market's expiry).ReclaimOracleCreditParams/ArmFirstRollParams.
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
Schedulecontinuation) + content-addressed question dedup.
- self-armed
- OracleHub ABI (
machineryAbi.ts):earmarkedOf/creditOf/outstandingOf/withdrawableOf(uint32),resolveReserve(),reservedFor(bytes32),operatorOf(bytes32),marketsForQuestion,pendingResolves,continuationSubId,setDrainParams.withdraw(uint32,uint256,address)is credit-only. The prepaid/escrow surface is removed. - Events:
ReserveEarmarked,SurplusCredited,CreditWithdrawn,MarketBound,MarketResolveCharged,AnswerDelivered,CallbackAccounted,DrainContinuation(indexed-ness byte-for-byte withOracleHub.sol). quoteCreateMarketValue(def)=getSchedulingCost(def) + resolveReserve()(both attached to the create; excess refunded).syncSettlement(marketId)(module write): permissionless earmark reconcile for a market voided viaBinaryMarket.voidExpired()(which bypasses the module, so the hub's earmark release never fires). Idempotent; revertsMarketNotSettledwhile still live.- Indexer reads (
query.ts, matchingindexer/schema.graphql):OperatorHubAccountRecord(earmarked/credit/outstanding),getOperatorHubAccount/listOperatorHubAccounts; reshapedOracleBindRecord/OracleCallbackRecord.preflight.tsgates on the full create value. Client surface (oracleHub.ts/createClient.ts/somniaMarketsClient.ts) follows.
BreakingSettlement v3: payout vectors
- Binary markets settle to a payout VECTOR (
payoutNumerators, denominator 10_000_000); redemption paysamount × num[idx] / D— one formula for win / loss / void (a losing redeem pays 0 without reverting). Reusable pools + a permanentBinarySettlementsingleton. getSettlement(readsAbi.ts) returns(…, uint256[] payoutNumerators)— removed theuint8 winningOutcomeslot.SettlementRecordexposespayoutNumerators+ a derivedwinningOutcome.winningOutcome()was REMOVED from BinaryMarket — the SDK derives the winner as the argmax ofpayoutNumeratorsingetMarketOnchain, theredeem()auto-winner lookup, and theResolved(uint32 payoutDenominator, uint256[] payoutNumerators)live-tail decode.
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), theMarketTypePluginregistry (fee-codec + machinery step descriptor keyed off the bytes4marketType), and per-step preflight validators. - Deployments hub + SDK config gain
marketCreatorFactory/oracleAdapterFactory/sharedOracleAdapter(all optional, degrade cleanly when unset).
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
noncebetween the pool address and the outcome index:id = (uint160(pool) << 72) | (nonce << 8) | idx;marketKey = id >> 8 = (pool << 64) | noncekeys settlement records. The oldoutcomeIdFor(pool, idx)(pool << 8 | idx) is REMOVED — use the new exported helpersoutcomeId(pool, nonce, idx)/decodeOutcomeId(id)/marketKey(outcomeId)from the package root. Any cached v1 ids are invalid. kindOf(isBid, userData)is REMOVED — v2 stopped encoding the YES/NO side inuserData(it is opaque market-maker bookkeeping now, forwarded verbatim). The side comes from the pool's newBinaryOrderPlaced(orderId, kind)event; map the enum with the newsideOfKind(kind)/ORDER_KIND_SIDE. Never decodeuserData.getMarketOnchain(marketId)replacesgetMarketOnchain(marketAddress)— market identity is the module's bytes32marketId(pools/market contracts are recycled/per-market). Resolves throughBinaryMarketsModule.markets(marketId); requiresaddresses.binaryModule. A 20-byte address argument throws loudly. The result gainsmarketAddress/nonce/finalized, andbackingfalls back to the settlement record's NET backing once finalized (the pool-sidemarket.backing()reads 0 from then on).Trader.redeemroutes through the module, keyed bymarketId—RedeemParams.marketId(bytes32) is required;market(address) is now only an optional lookup aid foroutcomeIdx/outcomeToken. The module pulls the winning tokens under an ERC-6909 operator grant to the MODULE (auto-approved), finalizes-if-needed, and redeems via settlement. The v1 poolredeemno longer exists on-chain.- Pool write ABI:
placeOrder→placeBinaryOrder(kind, price, quantity, expireTimestampNs, orderType, selfMatchingOption, builder, builderFeeBpsTimes1k, userData)(+placeBinaryOrderFor). The genericplaceOrder/placeOrderFor/amendOrderREVERT (UseBinaryPlacement) on binary pools.redeemis gone frombinaryPoolWriteAbi. - Order expiry must satisfy
0 < expireNs ≤ pool.marketExpiryNs— the pool rejects never-expiring / beyond-market orders (OrderExpiryBeyondMarket).Trader.placeOrdernow DEFAULTS the expiry to the market's expiry (onemarketExpiryNsread) instead of ~50y; an explicitexpireTimestampNsis forwarded verbatim (no silent clamping). - Live-tail events: the pool
Redeemed/SettlementFeeChargedevents no longer exist (redemption + the one-time fee skim live on the settlement singleton). New events consumed: poolBinaryOrderPlaced/PoolFinalized/PoolRecycled; moduleMarketFinalized/PoolReleased; settlementMarketFinalized/SettlementFeeCharged/Redeemed/PayoutOwed/OwedClaimed. The moduleMarketCreatedgained anoncefield. - Pool address is a TIME-VARYING market binding — never key a market by pool
address.
getMarketByPoolnow returns the pool's NEWEST (current) market and documents the caveat;BinaryMarketrows exposenoncefor disambiguation.
Added
Settlement + redemption
- Trader methods:
redeemDirect({ outcomeId, amount, to? })(settlement redemption by raw id),claimOwed({ token })(push-fallback pull),finalizeMarket({ marketId })+releasePool({ marketId })(permissionless keeper entries),getSettlement(marketId)→SettlementRecord | null. trader.signRedeemAuth(params)+trader.redeemFor(params)— the relayed (gasless) redeem pair.signRedeemAuthhas the position OWNER sign an EIP-712RedeemAuthorizationover the module'sREDEEM_AUTH_TYPEHASHin theSomniaMarkets/1domain (verifyingContract= thebinaryModule); no tx is sent. A relayer then submits it viaredeemFor— they pay the gas, the module pins the payout toowner(never the relayer). New typesRedeemAuthorization,SignRedeemAuthParams,RedeemForParams.- Config:
addresses.binarySettlement(and@somnia-chain/deploymentsmaps the manifest'sBinarySettlementproxy key onto it).
Pool reuse / bindings
client.getPoolBindings(pool)→PoolBindingRecord[]— a pool's full pool→market binding history from the indexer (WS5PoolBinding; newest nonce first;toBlock === nullmarks the current binding;closedByis"Released" | "Rotated").client.getPool(address)→IndexedPool | null— the indexer's per-pool aggregate (creator, collateral,currentMarketId,currentNonce,generationCount).client.getPoolCreator(pool)/client.getFreePools(creator, collateral)— the chain reads previously only reachable through the signer-bearing trader, now on the unsigned client tier (an unconnected explorer can render them). StandalonegetPoolCreator/getFreePoolsare exported from the package root too; the Trader methods (poolCreator(pool),getFreePools(creator, collateral)) remain. Pool "sponsor" is uniformly "creator" across the SDK, matching the on-chainpoolCreator()view and the indexerPool.creatorfield."Finalized"inBinaryMarketStatus— the indexer'sClobMarketStatusterminal state (set when the market's backing + resolution sweep to the BinarySettlement singleton; supersedes Resolved/Voided). Flows through everystatusfilter (listBinaryMarkets/listLiveBinaryMarkets/listPastBinaryMarkets/countBinaryMarkets). The live-tail reducer now also sets it on the module/settlementMarketFinalizedevents. The on-chainBINARY_MARKET_STATUSindex map is deliberately unchanged (no on-chain enum member exists).
Trader writes
trader.reduceOrder(params)— shrink a resting order's remaining quantity IN PLACE, keeping its price-time queue priority (unlike an amend, which re-queues at the back). Works on spot AND binary pools —BinaryPoolimplements the_onOrderReduced→_refundPartialhook, so the freed escrow returns to the owner. NewReduceOrderParams; newreduceOrderentry onbinaryPoolWriteAbi.trader.cancelExpiredOrders(params)/trader.sweepExpiredAtLevel(params)— the permissionless keeper drains for the resting book (inherited from the OrderBook base, callable by anyone on a binary pool).cancelExpiredOrderscleans an explicit list of expired ids;sweepExpiredAtLevelwalks one price level from the best order cleaning up tomaxCount. Each returns locked escrow to the order owner (best-effort — non-expired / stale entries are skipped on-chain). NewCancelExpiredOrdersParams,SweepExpiredAtLevelParams; newcancelExpiredOrders/sweepExpiredAtLevelentries onbinaryPoolWriteAbi.PlaceOrderParams.userData?: bigint— opaque MM bookkeeping tag, default0n, forwarded verbatim (the SDK never sets or interprets it).
Live order book (recycle-safe)
store.bookLevels(pool)filters by the pool's CURRENTmarket_id, structurally. ABinaryPoolis recycled across markets (one pool serves successive markets, never concurrently). The live book now requireso.market_idto equal the pool's current binding (marketByPool(pool)?.id), and returns an EMPTY book when the pool has no current binding — the exclusion of a prior market's orders is now structural, not reliant on expiry timing.client.getLiveBinaryOrderBookByMarket(marketId, { depth? })+ theuseLiveBinaryOrderBookByMarkethook — resolve a binary book bymarketIdrather than pool address. IfmarketIdis no longer the pool's current binding (stale/ended), returns an EMPTY book so a stale page can't render the successor market's orders. Backed by the newstore.bookLevelsByMarket.
Reads & analytics (derived; no new indexer field)
client.quoteBinaryOrder({ pool | marketId, side, quantity, depth? })— a market-order preview over the live book ({ avgPrice, cost, filledQuantity, wouldRest, levelsConsumed, slippageVsMid }). BUY consumes asks, SELL consumes bids, respecting the YES/NO price inversion. Also exported as the kernelquoteBinaryOrderOverBook. New typeBinaryOrderQuote.client.getMarketStats24h({ pool | marketId })— trailing-24h{ volume24h, trades24h, priceChange24h, high24h, low24h, openPrice24h }summed from 1h candle buckets. KernelmarketStats24hFromCandles; new typeMarketStats24h.BinaryMarketFilter.orderBy("newest" | "closingSoon" | "volume" | "tradeCount") threaded intolistBinaryMarkets+listLiveBinaryMarketsas the Hasuraorder_by(server-side).listBinaryMarketsstill defaults to newest,listLiveBinaryMarketsto closingSoon; an explicitorderByoverrides. New typeBinaryMarketOrderBy.client.getBinaryPositionPnL(account, marketId)— avg-cost position PnL ({ balanceYes, balanceNo, costBasis, avgCost, markValue, unrealizedPnl, realizedPnl }, RAW units) reconstructed from the account's order-book fills folded with complete-set mints/merges, marked tolastPrice(or the settlement payout once resolved). KernelspnlEventsFor+computePositionPnL; new typesBinaryPositionPnL,PnLEvent.client.getClaimable(account)— redeemable positions across settled (resolved/voided) markets, each shaped to feedtrader.redeemMany({ entries }):{ marketId, pool, outcomeIdx, amount, estPayout, status }. Winner payout skims the settlement fee; voided pays half both sides; losers omitted. KernelsclaimableFrom+estPayoutFor; new typesClaimablePosition,ClaimableInput.RouterActionRecordnow surfaces the existing indexeramountfield (each outcome's set size) so mint/merge cost basis folds in;PortfolioMarketnow carriesid(the bytes32 marketId).getMarketResolutionnow returnsopeningAnswer(the reference-question oracle answer — a reference-mode market's OPENING price) andclosingAnswer(its own resolution answer — the CLOSING price) alongside the outcome.oracleAnsweris kept as a deprecated alias ofclosingAnswer. Requires the deployment manifest to recordOracleCore(itsAnswerPosted.numericValueis the price) — the deploy script now writes it.client.getOpeningPrices(marketIds)— batch opening (reference) prices for many markets in one pair of round-trips (mapmarketId → raw numericValue), for list views that show each up/down market's opening price without an N+1 fan-out.
Id helpers, ABIs, rows, live tail
- Id helpers (single source of truth, exported):
outcomeId(pool, nonce, idx),decodeOutcomeId(id),marketKey(outcomeId), typesDecodedOutcomeId/OutcomeIdx. - ABIs:
binarySettlementAbi(redeem / finalizeAndRedeem / finalize / claimOwed / getSettlement / isFinalized / owed / isPoolApproved / poolRegistrar / outcomeToken),binaryModuleWriteAbi/binaryModuleReadAbi(module redeem rails + finalizeMarket / releasePool + settlement / poolCreator / getFreePools / freePoolCount / marketNonce / markets), pool v2 reads onbinaryPoolReadAbi(marketNonce / settlement / finalized / booksEmpty / marketExpiryNs / setBacking / getBinaryPoolParams), event ABIsbinaryPoolEventsAbi/binarySettlementEventsAbi. BinaryMarketrows:nonce/finalized/netBacking(WS5 indexer schema).MarketOnchain:marketAddress/nonce/finalized.- Live tail: watches the module + settlement whenever any market is watched;
reducer implements the v2 binding model (
MarketCreatedopens/re-points a pool→market binding,PoolReleasedcloses it — order events attribute via the pool's CURRENT binding), takes sides fromBinaryOrderPlaced, zeroes pool backing onPoolFinalized, and tracks the settlement-sidenetBackingvia the settlementMarketFinalized/Redeemed.
Migration
- Regenerate/refresh the deployment manifest (v2 deploy adds
BinarySettlement);addresses.binarySettlementflows through@somnia-chain/deploymentsautomatically. - Replace
outcomeIdFor(pool, idx)withoutcomeId(pool, nonce, idx)— get the nonce fromBinaryMarket.nonce,MarketOnchain.nonce, orpool.marketNonce(). Purge any cached v1 ids. - Replace
kindOf(isBid, userData)withsideOfKind(kind)joined fromBinaryOrderPlaced(indexer rows keep servingsideprecomputed). client.getMarketOnchain(...): pass the bytes32marketId(fromlistBinaryMarkets/BinaryMarket.marketId), not the market address.trader.redeem(...): passmarketId(+ optionallyoutcomeIdxto skip a read). Native redemption (redeemNative) and complete-set methods are unchanged.- Market makers: tag orders with
userDatafreely — it round-trips verbatim and appears on indexed orders; it no longer selects the book side.
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 samerequestId(group by it for a whole batch / join to agent provenance). Read viagetPriceHistory/ 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:
price←spot(median spot index),ema←mark(the EMA-smoothed perpetual mark),emaClose←markClose. The publicLivePrice/PricePoint/PriceCandlefield names are unchanged, so consumers need no code changes.
Removed
PricePoint.requestId— the scheduler batches every symbol into one agent request, so there is no per-tick request id.
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 history —
getRouterActions(account, opts?)→RouterActionRecord[](redeem / mint / merge, from the indexerRouterActionRecord). - Resolution visibility —
getMarketResolution(marketId)→{ events, reference, oracleAnswer }joiningMarketResolutionEvent/MarketReferenceLink/OracleAnswer(byoracleQuestionId). - Fee-record streams —
listProtocolFees/listBuilderFees/listSettlementFees(the per-fill streams behindgetMarketFees' running total; support apayerfilter). - Builder-approval directory —
listBuilderApprovals({ user?, builder?, … })→BuilderApproval[], complementing the on-chain point readgetBuilderApproval.ApproveBuilderParamsis now exported. - Markets-by-creator —
BinaryMarketFilter.creator(applied acrosslistBinaryMarkets/listLiveBinaryMarkets/listPastBinaryMarkets/countBinaryMarkets). - Vault credits —
getVaultPayoutFallbacks(owner, opts?)(append-only credit history),client.getVaultBalance(vault, owner, token)(live claimable,ERC20Vault.getWithdrawableBalance), andtrader.withdrawVault({ vault, token, amount })(ERC20Vault.withdraw). - Perp margin health + liquidation price —
getMarginAccountnow also returnsimReq/mmReq/cmReq/marginStatus(fromMarginBank.getAccountHealth/getMarginStatus); newclient.getAccountHealth(marginBank, account)andclient.getLiquidationPrice(marginBank, pool, account).MarginStatus/MARGIN_STATUS/AccountHealthare exported. The unifiedfetchPositionsnow populatesUnifiedPosition.liquidationPrice. - Perp/funding history reads —
getFundingPayments,getMarginEvents,getLiquidations,getFundingRateHistory,getOpenInterestHistory(the indexer's perp-account + funding/OI history). - Lookups + pagination totals —
getMarketByPool(pool)(resolve a market by pool address), andcountOrders(owner, opts?)/countUserFills(account, opts?)(history-page totals via the_aggregatefallback helper, now extended toOrder/Fill). - Unified balances —
fetchBalancenow includes binary YES/NO ERC-6909 holdings (keyed by tradable symbol). - Pure helpers + constants —
CANDLE_INTERVALS(in lockstep withindexer/src/intervals.ts), andcomputeBinaryPnl(fills, balances, market)/binaryFillsFor(account, fills)(avg-cost basis, no indexer/chain dependency). - React hooks (
@somnia-chain/markets-sdk/react) — a genericuseIndexerQuery(fn, deps)plususePortfolio,useMarkets,useCandles,useMarketFees,useOperators, and the live-storeuseLiveMarkets.
v0.11.12026-07-16
Fixed
- Count helpers (
countMarkets/countBinaryMarkets/countOperators/countVenues) now fall back to a bounded row count when Hasura_aggregateis not exposed to the requesting role (public role, no admin-secret header) instead of throwingfield 'X_aggregate' not found. The fast aggregate path still runs when the privileged header is present. Real query/network errors still surface.
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.
SomniaMarketsAddressesgainscollateral(the per-venue collateral ERC-20).testUsdcremains as a legacy/fallback alias.getSystemInfo,trader.faucet, and collateral reads now resolvecollateral ?? testUsdc, so hub-fed testnet clients (where the protocoladdresses.jsonno longer carries TestUSDC) work correctly. - Settlement-fee backing. The live tail now consumes
SettlementFeeCharged(address indexed feeRecipient, uint256 winningBacking, uint256 fee)and debits the market'sbackingrunning total, so the tail tracks on-chainsetBackingafter settlement instead of overstating it by the fee. - Module-created market discovery.
watchAllMarkets({ discover: true })now also discovers markets created viaBinaryMarketsModule.createMarket(the 19-field moduleMarketCreated), not just theMarketCreatorrolling series.
Fixed
getSystemInfo.binaryMarketImplno longer falls back to the (different)binaryPoolImpladdress when the live factory read fails.
Notes
- Addresses are provided entirely by
@somnia-chain/deployments(the single source of truth).SomniaMarketsAddressesnow carriesmarketsCoreandcollateralRouterfirst-class, so consumers feed the hub map straight intonew SomniaMarkets({ addresses })with no hand-mapping.