Release notes
0.30.0
Minor Changes
-
Three new client reads answer block-scoped questions:
getBlockActivityreturns what one block traded grouped by market,getLatestActiveBlocknames the newest block with markets activity, andgetAdjacentActiveBlocksnames the closest such block on either side. All three anchor on the indexed timestamp columns rather than a block column, none of which the indexer schema indexes.getTransactionActivitytakes an optionalanchor(the transaction's block and timestamp). Supplying it skips theOrder.placedTxHashprobe, which is not index-served and ran past the gateway timeout — so a transaction that produced no event, meaning one that only placed or cancelled orders, could not be read at all. -
New
SOMNIA_MAINNET_PRICE_FEEDconstant. It is the mainnet price feed,https://price-feed.prd.oracle.somnia.host/v1/graphql, pinned to theUSDCquote.The SDK previously exported one price feed,
SOMNIA_TESTNET_PRICE_FEED. A mainnet consumer had to write the endpoint by hand.The two feeds are separate deployments. Each one indexes the
PriceFeedScheduleron its own chain. Use the feed that matches the chain the client runs on.The mainnet feed carries fewer symbols than the testnet feed. Code written for the testnet asset list can therefore fail on mainnet. Read the feed catalog to learn which bases exist. A base the feed does not carry returns no rows. It does not raise an error.
Existing callers keep their behaviour.
SOMNIA_TESTNET_PRICE_FEEDis unchanged and remains correct for a testnet client. -
Publish the perp event ABIs, so a consumer can decode perp logs from chain.
perpPoolEventsAbiis now root-exported, andmarginBankEventsAbiandliquidationEngineEventsAbiare new (18 and 13 events). Before this,perpPoolWriteAbiandmarginBankWriteAbiexported no events and there was no MarginBank or LiquidationEngine event ABI at all, so funding settlement, positions, collateral flow and the liquidation waterfall were undecodable without a hand-copied ABI. Every signature is pinned by topic0 against the compiled artifacts.docs/PERPS.mdhas a new "Watch from chain" section. -
Add
client.getPerpFundingPremium(pool), which reads the premium the next funding settlement will charge. UsetimeWeightedPremiumto project a funding rate. Do not uselastObservedPremiumfor that: the contract getter behind it kept its signature and changed its meaning, and it is now only the standing instantaneous sample. Checkarmedbefore calling the figure a time-weighted average, because a market that has not settled since the upgrade is still charging a point sample. This read is deliberately separate fromgetPerpState, whose batch would fail as a whole against a pool too old to serve these getters.Correct the funding documentation for the reworked premium and catch-up horizon.
FundingRateUpdate.intervalsAccruedis no longermin(intervalsSettled, n). A settlement now charges at most one interval, however long the gap, and the excess is forgiven. CompareintervalsAccruedagainstintervalsSettledto see whether anything was forgiven;intervalsSettledalone does not tell you.intervalsPerWindowis the per-interval divisor only. On a historical row that caught up over several intervals, one settlement's charge isfundingRate * intervalsAccrued / nrather thanfundingRate / n. The premium driving the rate is the time-weighted impact-price premium, not a book midpoint, soemaPremiumcan be non-zero on a one-sided book. -
Read the three perp ledgers the reindex made available:
listPerpInsuranceFundEvents(the InsuranceFund's tier ledger behindgetInsuranceFundState),listPerpWalletLinkEvents/listPerpMarginPulls/listPerpMainFundingEvents(the linked-wallet rail's consent graph and both funding sides), andlistPerpOrderRejections(orders refused inside a batch placement). Two aggregation hazards are documented on the types: the insurance ledger'samountmust be folded bykindrather than summed, and the two funding sides record the same wei — summing them double-counts every transfer. -
listPerpStopOrdersnow returnssiblingOrderId(the live OCO partner),intentandcancelReason, so a stops table no longer needs a chain read per row to show pairing or intent.cancelReasonseparates the three routes toCANCELLED, which refund differently:"Owner"returns the SOMI in the same transaction, while"LinkedFill"and"Inert"only creditunclaimedSomiforclaimSomi(). -
BREAKING — the portfolio trades legs default to the last seven days
getSpotPortfolio,getPerpPortfolioandgetPortfolionow applysince = now − 7 daysto their trades leg unless the caller passessince. A caller that passed nosinceand expected the whole history gets the last week. Each result gains a requiredtradesSince(unix seconds): the bound that was applied, so a UI can label the list and a caller can page further back.Migration. To keep the previous unbounded read, pass
since: 0(or the earliest time you need). Hand-builtPortfolio/SpotPortfolio/PerpPortfoliovalues (fixtures, adapters) need atradesSincefield. A UI that renderstradesshould readtradesSinceand say so when the list is empty — "no trades since <date>" is true where "no trades yet" is not.Why a window: the type scope below is fast when a wallet's fills are dense in the requested type and slow when they are sparse — a spot market-maker asking for its binary fills walks every fill it has rejecting each. Measured on the development indexer for such a wallet: 24.8 s unbounded, 208 ms with a seven-day bound. A window is the only shape that is fast for every wallet without a schema change. Treat it as the SDK's standing position on wallet-scoped trade history, not as a stopgap: the composite indexes that would let it be dropped are a separate ticket, and removing a default later would be a second breaking change.
Fixedtype- and pool-scoped reads no longer filter through the
marketrelationshipEvery read that scoped
Fill,OrderorStopOrderby market type or by pool did so withmarket: { marketType: { _eq } }ormarket: { poolAddress: { _eq } }. Hasura compiles a relationship predicate to a correlatedEXISTSonMarketper candidate row, which stops Postgres using themaker/taker/ownerindexes. Measured on the development indexer for one wallet's fills atlimit: 50:stream timeoutwith the predicate, 102 ms without it. Three such reads every five seconds per open wallet page saturated the shared one-vCPU database and stalled ingestion on both indexer slots.These reads now scope on the direct, indexed columns instead:
poolonFill,market_idonOrderandStopOrder. A market type resolves to its set of pools —_infor SPOT and PERP, whoseMarket.idis the pool address;_ninover spot ∪ perp for BINARY, whose pools are recycled across thousands of markets. A spot or perp pool is its own market id — a direct_eq, no query. A single binary pool keeps themarket: { poolAddress }relationship form: a recycled pool has hosted ~2,000 markets, so an_inof its ids would be the worse shape, and every Order read that takes a pool also carriesowner, which bounds that one EXISTS to the wallet's rows — no worse than before. Pool and type compose in one place (marketScope). The two type sets are memoized per indexer URL for a minute, so the extra hop is paid once, not once per poll.Affected:
getSpotPortfolio,getPerpPortfolio,getPortfolio,getOpenOrders,getOrders,countOrders,getSpotStopOrders,listPerpStopOrders,listPerpOrderHistory,listSweepableOrders. Rows returned are unchanged for every read except the three portfolio trades legs, where the new default window applies.getSpotStopOrdersin particular keeps returning every pending stop a wallet holds, spot or perp, as it did. -
BREAKING for constructors —
PortfolioAnalyticsgains a requiredholdingsseries: what the traded book is worth over timeThe result's
equityfield is cumulative window PnL. It starts at zero, it is signed, and it is not the account's value. Consumers read the name and plotted it as a level, so a losing window drew a spot wallet below zero.holdingsis the level that name promised. Each sample sumsqty × markover the open positions, on the same grid asequity, so a chart can draw both against one x axis:tsconst p = await exchange.fetchPortfolioAnalytics("7d"); p.holdings.at(-1); // what the traded book is worth now p.equity.at(-1); // what it gained over the windowThe first point is the carried-in book valued at the window start, not zero. The series sums signed position value. Every book the fold keeps today is long-only, so today the value cannot go below zero — but do not lock a chart axis to that, because a signed book marks negative and the perp plane is committed to joining this fold. The marks are the caller's and are not validated, so a negative price carries through as it already does for the PnL figures.
A position the sample cannot price is left OUT of the value rather than valued at zero, and
HoldingsPoint.unpricedMarketscounts what was left out. A market with no candle sample and no last price has no mark, and standing a zero in its place would drop a held position out of the level with nothing to say it had. Check the count before presenting a sample as the whole book.Migration. Reading the result needs no change: every existing field keeps its name, its meaning and its value. Anything that CONSTRUCTS a
PortfolioAnalytics— a hand-written response fixture, a test mock, an adapter building the object for an HTTP layer — must addholdings: HoldingsPoint[]or it stops compiling.HoldingsPointis exported from the package root. An adapter that forwards both series also serves roughly twice the series payload.The series measures the TRADED BOOK, not the wallet. A token that arrived without a fill — bridged in, transferred in, minted — is not in the book, so it is not in this value. Idle quote balance is not a position, so it is not included either. Funding events refine the capital base only, so a deposit inside the window does not step the curve. Read balances from the chain when you need what the wallet itself is worth.
-
BREAKING:
redeemno longer guesses a leg before resolution, and unified leg selection now uses an options objecttrader.redeem({ market, amount })previously derived the winning leg as the argmax of the market's payout vector. A void pays BOTH legs, so that argmax was meaningless: it returned leg 0 on the uniform[D/2, D/2]vector, and the higher-priced leg on aCLOB_SNAPSHOT[p, D−p]one. A holder of only the NO leg got a confusingInsufficientBalancerevert. A holder of both legs — what onemintSetcall produces — redeemed YES, received half, and never attempted NO, with no error and a successful transaction. The collateral was stranded, not destroyed: it stays claimable fromBinarySettlementwith an explicit leg.The auto-lookup is now resolution-only. It checks the market's terminal state before reading the immutable payout vector. On a voided market
redeemthrowsInvalidInputErrornaming both legs and pointing atredeemMany; on an unresolved market it asks the caller to wait for settlement. Resolved markets are unchanged:[D,0]still resolves to YES and[0,D]to NO.exchange.redeem(ref, amount)gains an optional trailingRedeemOptionsbag, so a NO-holder can claim through the unified API instead of hitting a dead end. Existing two-argument calls behave as before.Migration: replace
exchange.redeem(ref, amount, outcomeIdx)withexchange.redeem(ref, amount, { outcomeIdx }). If the market may be voided, pass the leg you hold. To claim both legs, useredeemManywith one entry per leg — they settle in one transaction. -
Restore concurrent market-discovery coordination and stable user-fill pagination after their review withdrawal. Paginated fill reads include configured indexer headers, and indexer response-body cancellation preserves the caller's reason. This restoration supersedes the earlier withdrawal note. Dataset-reset coordination remains required for persisted fill cursors.
-
Add bounded user-fill pages with stable numeric continuation. Cursors preserve account and filter scope while newer fills arrive. Existing reads remain unchanged. Reindex, repair and endpoint cutover still require callers to discard cached pages and cursors.
-
Reject partially present void payout vectors, floor each raw payout leg from its stored numerator, and verify omitted unified redemption legs against on-chain terminal state.
Patch Changes
-
Approval documentation now distinguishes cached unlimited allowances from finite allowances that each order must check again.
-
Traders from one configured client now coordinate local sends for each account. A failed signer cannot reset a nonce that another trader is still broadcasting.
-
Share concurrent initial market discovery within each exchange. Explicit registry reloads run in order, including after a failed read, so an older discovery cannot overwrite newer market metadata.
-
Withdraw the unreleased stable user-fill pagination API and concurrent market-discovery coordination pending maintainer review. This withdrawal supersedes the queued release notes announcing those additions; existing offset-based fill reads and the previous discovery behavior remain available.
-
Documentation: a documentation map plus tutorials, how-to guides, reference pages, and explanations organised by reader need. Two tutorials take a newcomer from an empty directory to a cancelled order on the Shannon testnet. Eight how-to guides cover network configuration, testnet funds, a quoting loop, fill detection, browser-wallet signing, the React hooks, error handling, and debugging. Six reference pages list configuration fields and defaults, networks and endpoints, the symbol grammar, units and scales, read tiers, and the error classes with the contract error names they decode. Every TypeScript example has a compile-checked public-import counterpart, and the tutorials were executed against the testnet.
Corrections to the existing guides, verified against the source: the reactivity guide no longer claims the package is on GitHub Packages; the binary guide's cadence list, lifecycle (
Finalized),intervalSecprovenance, and builder-fee revert names are corrected; the exchange guide's verb table gains the ten verbs it omitted and notes thatclose()leaves the WebSocket open; the engine guide's API links now target the docs site;isTailing()is no longer presented as the price-feed status; the bridge guide namessendBridgeStepas its one sender; and the twoerrorsTSDoc examples now match the real signatures ofcreateOrderandcancelExpiredOrders. -
Forward configured indexer headers to paginated user fill reads and preserve the caller's cancellation reason when an indexer response body is interrupted.
-
/native: a rejectedsendSessionTransactionorgetSessionAddressno longer carries the session seed in its error. viem builds a request failure'smessageandcausefrom the request body, so a mempool rejection or a transport error used to print the seed — a private key in another shape — in every log line that showederror.message. Both calls now throwRpcError. When the node answered,causeis its own{ code, message, data? }andgetSomniaRpcError/mempoolStatuskeep working on it; when the transport failed first,causeis{ name, message, status? }with the seed blanked out of the request text.isMethodNotFoundalso stops matching the node's genuineaccount does not existandBlock does not existfailures, which the documented degrade-to-nullidiom used to swallow. -
A live-tailed client no longer re-fetches every block it has already been handed. The tail took its chain head from viem's
watchBlocks, which answers a pushednewHeadsframe by issuing aneth_getBlockByNumberfor the very block the frame already carried - nothing new, at double the per-block RPC cost. It now reads the height and timestamp straight off the pushed frame over the same socket.A frame is read only when both the height and the timestamp arrive as hex quantities; if either is missing, is not hex, or is the empty quantity
"0x", the frame is skipped whole rather than half-read. A skipped frame still counts as proof the stream is alive, so the heads watchdog behaves as before and the next head restores the timestamp. -
Operational failures are no longer converted into success, zero, or a caller error at seven sites. Admin writes through
OperatorAdmin,GovernanceAdmin,MarketCreatorAdmin,OracleHubAdminand the lendLendernow throwContractRevertErroron a mined-but-reverted receipt (with the contract's error name when a replay at the receipt's block recovers it) instead of resolving as if confirmed, or misreporting the revert asRpcError("no event").SomniaMarkets.fetchBalancethrows when a balance read fails instead of reporting0or omitting the key.getBinaryPositionPnLfalls back tolastPriceonly when no chain client is configured, not when the book read fails.cachedErc20Decimals(and every read built on it) returns the fallback only for a token withoutdecimals(), not on an RPC failure.createStopOrdersurfaces a failed mark read as the indexer's error instead ofInvalidInputError("pass triggerDirection").depositVaultNativeno longer broadcasts when its preflight read failed. The receipt wait used by external-signer and fallback confirms rejects after three consecutive receipt-read failures instead of pending forever; "receipt not found yet" still waits. -
Decode three perps reverts that previously surfaced as opaque hex —
InvalidFundingCapDivergenceMargin,InvalidPremiumImpactNotionalandInvalidLinkedWalletRegistry, all added by the 2026-08-31 testnet upgrade. -
createOrderandcreateStopOrdernever send a larger quantity than requested. Quantities are truncated to the base token precision before applying the lot-size floor.amountToPrecisionnow truncates the decimal text instead of its binary floating-point expansion. Exact inputs such as1.2therefore remain exact for a0.1lot size.Local writes now serialize nonce consumption, signing, and broadcast acceptance. A signer or broadcast failure can reset nonce tracking without invalidating another in-flight write.
-
Live-tailed clients now receive binary settlement finalization. The SDK declared
MarketFinalizedwith a stale trailing argument, so its topic0 did not match the emitted event andliveTaildiscarded every settlement finalize — leavingnetBackingnull for the settled lifetime of every binary market. Indexer-backed reads were never affected. -
Fixeda hydrated fill now names its taker
A watch scope hydrated from a snapshot reported no taker on any of its fills.
taker,takerSideandkindwere missing from the snapshot's fill selection set and hardcodedundefinedwhen the rows were parsed, on a comment claiming the indexer does not carry them. It does: they are real columns, stamped by the indexer'sPendingTakerFillbridge once the taker's placement event lands.On a market-scoped watch this was permanent, not a live-tail race. The client-side back-join recovers taker fields from the local order map, but that scope hydrates resting orders only — and a taker order that filled completely is
Filled, neverOpen, so it never enters the map to be joined against. Every market order and every IOC was affected, and a freshly loaded binary trade tape showed no taker side on any row it hydrated.taker,takerSideandkindnow come through from the indexer, so:- A binary fill reports its taker's address, its four-way
BUY_YES/SELL_YES/BUY_NO/SELL_NOtaker side, and itsDIRECT_YES/DIRECT_NO/MINT_A_PAIR/BURN_A_PAIRkind — the vocabularytakerIsBidcannot express, being one boolean covering two sides at a time and carrying no kind at all. - A spot or perp fill reports its taker.
takerSideandkindstay undefined there, which is correct rather than a regression: outcome side is a binary-only concept and every spot and perp adapter leaves it unset. - A row the bridge has not stamped yet still reads
undefined, and the existing back-join still fills in whatever is missing when the rows are selected.
No type changed: the live-fill shape already declared all three as optional, and the user-scoped path already resolved them through the back-join. This closes the SDK half deferred by the binary trade tape's taker-colour fix.
- A binary fill reports its taker's address, its four-way
-
Native-base spot sells now send the pool's exact vault shortfall as
msg.value. This includes fee headroom and preventsInvalidMsgValuereverts on fee-bearing pools.Spot approval checks now use the pool's worst-case reserve. An allowance that covers only the principal is increased before the pull.
Binary buy commitments now use the collateral token's exact scale. They also include the builder-fee headroom that the pool locks.
-
Fixeda voided market now values against the payout vector it actually stored
estPayoutFor,computePositionPnLandcomputeBinaryPnlreturned a hardcoded half for every voided binary market. A void pays whatever payout vector the market stored. Under theUNIFORMvoid policy that vector is[D/2, D/2], so the half was right. UnderCLOB_SNAPSHOTa void that captures a two-sided close stores[p, D−p]at the closing YES price, and there the half was wrong on both legs: at a 0.80 close the YES leg was understated by 37.5% and the NO leg overstated by 150%.estPayoutForquoted a claim settlement would not pay.All three now read
payoutNumerators/payoutDenominator, which the indexer already carries and the market selection set already requested:estPayoutForreturnsamount × payoutNumerators[outcomeIdx] / payoutDenominatoron a void. A void is never fee-charged, so no settlement fee applies on that branch.computePositionPnLandcomputeBinaryPnlmark each leg from the same vector.computePositionPnLderives the NO leg by subtraction, as it already does while trading, so both legs still sum to exactly one collateral unit.ClaimableInputaccepts optionalpayoutNumeratorsandpayoutDenominator, andPortfolioMarketcarries them, sogetClaimableandgetOpenPositionsWithPnLvalue a void from the vector too.
The payout comes from the vector and never from
voidPolicy. ACLOB_SNAPSHOTmarket stores the uniform vector on every fallback — no capture-capable pool, a revertingclosingPrice(), a one-sided or empty book, any overflow guard — so reading the policy instead of the vector would misreport exactly those markets.When a legacy voided market has no stored vector, all three keep returning the half, unchanged from previous behaviour. A present but invalid vector now raises
InvalidInputErrorinstead of silently turning corrupt indexer data into a plausible payout.The vector arrives from the indexer rather than the chain, so it is validated before use — as a whole vector, not one leg at a time. It must have both legs, both parseable and positive, a positive denominator, and a sum equal to that denominator. This is the shape
BinaryMarketwrites for a void: uniform or[p, D−p], withpclamped to[1, D−1]. Rejecting any other present vector keeps corrupt data distinct from the legacy no-vector fallback. -
Clarify voided-market rounding:
computePositionPnLcalculates each leg asfloor(oneCollateral * payoutNumerators[outcomeIdx] / payoutDenominator). It does not derive the voided NO mark by subtraction. Independent rounding can leave the two marks below one collateral unit in total. This supersedes the earlier queued payout-vector note's claim that the voided marks always sum to exactly one collateral unit; the implementation already follows settlement's independent rounding.
0.29.0
Minor Changes
-
Addeda market's whole history, one transaction, and one trade
The tape and the fills list were the only ways in. A market page could show trades and nothing else; a transaction or a single fill had no read at all.
-
getMarketActivity(market, opts)— one market's whole transaction history. The mints, merges, redemptions, oracle resolutions and lifecycle transitions a market accumulates were all indexed and reachable only one entity at a time, in five different shapes. This returns them interleaved by time as one discriminated union onkind(MarketActivity), newest first, in a single round-trip. Every row carries itstxHash, so a caller can follow any row to the chain. Select the streams withkinds, page backwards withuntil, and passpoolto let the fill read use its(pool, timestamp)index. A spot or perp market returnsTRADErows only — the other four streams read binary-only entities, so asking for them there is empty rather than an error.Pair it with
getLiveFillsfor zero-latency trades: a trade's id isTRADE:followed by the fill id on both paths, so the two merge onid— but merge FIELD BY FIELD, preferring whichever source has a value. Neither is a superset of the other: the tail leavestaker/takerSideundefined on the fills it hydrates, and the indexer'stakerIsBidis null until its taker bridge lands. Cache identity comes from the newmarketActivityKey.Known limit:
untilhas one-second resolution and is inclusive, so the boundary second is re-read between pages and a second holdinglimitor more rows cannot be paged past. A composite(timestamp, blockNumber, logIndex)cursor is the fix; it needslogIndexon the three non-fill streams, which this release adds to the indexer schema but which reaches a deployment only after a reindex. -
getTransactionActivity(txHash)— everything the protocol did in one transaction. The transaction-scoped counterpart: the sameMarketActivityunion with the same row ids, but selected by transaction and returned in LOG order, plus the orders the transaction placed, the fees it paid and the market rows its events touched. One oracle callback settles every market on a question, so this is how a caller sees that a single transaction resolved four markets at once. A hash the indexer has nothing for returns empty collections and a nullblockNumberrather than throwing — including a transaction that only rested a limit order, which emits no event and is anchored on the order table instead. New public typesTransactionActivity,TransactionActivityOptionsandTransactionOrder, and thetransactionActivityKeycache key. -
getTradeContext(id)— one trade in full, for a trade detail view. Returns the fill (with its block position), the market it executed in, BOTH sides' orders, the protocol and builder fees its transaction charged, and the other fills that transaction produced — enough to answer why a trade priced where it did.nullfor an unknown id, which is a stale link rather than a failure. New public typesTradeContextandFillOrder, and thetradeContextKeycache key.
Both transaction-scoped reads used to scan
Fillto select by hash. The indexer schema now carries@indexonFill.txHash, so that scan goes away once a reindex has run. -
-
Addedsingle-entity detail reads, a transaction summary, and the network tape
The indexer surface was list-shaped throughout: orders, fills and markets could be enumerated, but nothing addressed ONE of them. An explorer drilling into a row had no read to call.
-
getOrder(pool, orderId),getFill(id),getOrderFills(pool, orderId)— the three reads a detail view runs on. Order ids never repeat (a monotonic per-pool counter), so(pool, orderId)names one order permanently; a fill is named by its${blockNumber}_${logIndex}id. All three embed aMarketRef(symbols, decimals, routing identity) so a page renders from one read instead of a second lookup per row.nullfor an unindexed id — a just-executed fill can lag by a beat, which is a stale link rather than a failure.The embedded market is
marketRef, notmarket:FillRow.marketis already the bytes32 marketId string, and GraphQL will not select a relationship alongside an alias of the same name. -
listMarketsByPool(pool)— every market a pool has hosted, newest first. A binary pool is recycled across successive markets, so this is its full history rather than the one marketgetMarketByPoolresolves. PlusgetSeries(creator, seriesId), the single-row sibling oflistSeries. -
getTransactionSummary(hash)— sender, gas used and limit, effective gas price, fee paid, status. Chain-direct rather than indexed, so it answers for any hash the node still has;nullfor an unknown one, never a throw. -
createNetworkTape()— a network-wide order-flow firehose. One topics-only chain-log subscription seesOrderPlaced/OrderFilledfrom every pool, including pools created after it starts, with no indexer on the hot path. Not the live tail: no books and no snapshots, just three newest-first ring buffers (bids/fills/asks) plus a session(pool, orderId) → ownermap that attributes fills. Takers always resolve; makers resolve when they were quoted while the tape ran. Rows carry RAW units — join tolistMarketsfor symbols and decimals. Nothing connects until the firstsubscribe, and reconnect plus stall detection for Somnia's silently-dying subscriptions is built in.
The indexer also materializes the Market→Series edge from
MarketCreator.SeriesRolled(Market.series/Market.seriesKey), replacing a heuristic join on asset + venue + cadence. That reaches a deployment only after a reindex, and the SDK does not read the field yet. -
-
Addedread a pool's operator permissions registry from your own call
spotPoolOperatorRegistryReadAbiis now exported from the package root. It is the ABI fragment forgetOperatorPermissionsRegistry(), the view that tells you WHICHOperatorPermissionsRegistrya SpotPool consults when it gatesplaceOrderFor,cancelOrderForandreduceOrderFor.client.getOperatorPermissionsRegistry(pool)already answered that question and still does. Reach for the fragment instead when the method does not fit:- Multicall composition. The method issues its own
readContract, so it cannot be batched into a caller-built multicall. With the fragment the registry read joins the same batch as the rest of your pool reads. - No instantiated client on that code path. The method resolves a chain client lazily and needs one; the fragment is a static value and needs nothing.
Previously the only route was a hand-copied fragment, since the
exportsmap has no wildcard and no deep import could reach it — which is how a signature drifts in silence.tsimport { spotPoolOperatorRegistryReadAbi } from "@somnia-chain/markets-sdk"; // `allowFailure: false` so a failed read throws instead of yielding a // `{ status, result }` wrapper. With the default (`true`) each element is that // wrapper, and comparing it to an address silently reports every pool as wired. const [registry] = await publicClient.multicall({ allowFailure: false, contracts: [{ address: pool, abi: spotPoolOperatorRegistryReadAbi, functionName: "getOperatorPermissionsRegistry" }], }); const wired = registry.toLowerCase() !== "0x0000000000000000000000000000000000000000";Two differences from the method, both of which matter for an authorization check:
address(0)reaches you raw. The method returnsnullfor an unwired pool; a direct read returns the zero address, which is truthy. Compare it yourself, as above — such a pool denies every operator call, so a grant written anywhere will not makeplaceOrderForsucceed. The SDK's own grant reads and writes reject a zero registry withNotConfiguredErrorrather than acting on it.- Read per pool. Two pools on one deployment may name different registries, and the pool is the authority: a grant written to any other registry is inert. Do not cache one answer for all pools.
The other read-side ABIs stay internal.
- Multicall composition. The method issues its own
-
Addeda portfolio says when its trade history is incomplete
Portfolio,SpotPortfolioandPerpPortfoliogain atradesTruncatedboolean.The three portfolio reads cap
tradesat 50 by default and page newest-first, so the cap drops the OLDEST fills. The returned shape carried no sign of this. A caller that foldstradesinto Net PnL, win rate, volume or a trade count got a total over the recent slice only, and read it as the whole history. This is the same signalFundingRateSeries.truncatedalready gives the funding chart.tradesTruncatedistrades.length >= tradesLimit. A full page means older fills exist and were not returned. An empty page is not truncated, and neither istradesLimit: 0, which asks for no trades at all. The default cap is unchanged, and raisingtradesLimitmoves the cap rather than removing it, so check the flag instead of assuming a large limit is enough.Changed (breaking) — perp markets are discovered from the chain, not only the indexer
loadMarkets()now unions the indexer's perp rows with the PerpPoolFactory's and derivesactivefrom the live tradeability gates, so a close-only market no longer reads as tradeable. NewperpStatus/perpDiscoveryErrorsurface. Breaking:indexedis a required property onUnifiedMarket, so code that CONSTRUCTS one (fixtures, adapters) must set it; reading is unaffected.Addedthe spot stop-order lifecycle is decodable, and the write ABIs are importable
Protocol signatures were being hand-copied into consumer repositories, because this package held them and did not publish them. A transcription drifts in silence: it decodes nothing, or decodes wrong, and the failure looks like a missing trigger rather than an error.
-
spotStopRegistryEventsAbinow carries the whole pending-order lifecycle. It heldPendingOrderCreatedalone. It gainsPendingOrderTriggered(uint128 indexed pendingOrderId, bool success, uint128 indexed spotOrderId),PendingOrderCancelled(uint128 indexed orderId)andInertOrderCancelled(uint128 indexed orderId, address indexed owner, uint256 somiCredited)— so a consumer can decode a stop order firing, being cancelled by its owner, or being swept as inert, from a receipt.A new test checks every fragment's topic0 and argument shape against the compiled artifact
indexer/abis/SpotStopOrderRegistry.json. That artifact carries noPROVENANCE.jsonentry, so the guard proves SDK-vs-artifact agreement, not SDK-vs-deployed-bytecode.Do not reuse the perp registry's shapes: its
PendingOrderTriggeredcarries a drop reason the spot one does not have, and the test pins that divergence. -
client.getOperatorPermissionsRegistry(pool)— the registry a SpotPool gates its operator calls through, at chain head, ornullwhen the pool is unwired and denies every operator call.This is discovery for a caller who has not configured
addresses.operatorPermissionsRegistry. The grant writes and the two grant reads need that address and otherwise throwNotConfiguredError, and no deployment manifest carries the key yet. A configured address still wins where it is used: this adds a path, it does not redirect one. -
Seven ABIs on the root barrel —
erc20WriteAbi,erc20VaultWriteAbi,orderBookBatchWriteAbi,marginBankWriteAbi,spotStopRegistryWriteAbi,spotStopRegistryEventsAbiandoperatorRegistryWriteAbi. Token approvals, vault funding, batch order management, perp collateral, the stop-order lifecycle, and operator delegation are now encodable by hand from published surface.tsimport { spotStopRegistryEventsAbi } from "@somnia-chain/markets-sdk"; import { decodeEventLog } from "viem"; for (const log of receipt.logs) { if (log.address.toLowerCase() !== registry.toLowerCase()) continue; const event = decodeEventLog({ abi: spotStopRegistryEventsAbi, ...log }); if (event.eventName === "PendingOrderTriggered") render(event.args); }The read-side ABIs stay internal. A root export is permanent public surface, so the set is limited to what consumers have needed.
Addedevery placement path honours
selfMatchingOption-
selfMatchingOptionis now a caller input on all four placement paths, soCANCEL_MAKERis reachable without leaving the SDK. It was pinned to0(CANCEL_TAKER) in each encoder, on a parameter the pools have always taken:placeSpotOrder/buildPlaceSpotOrder(PlaceSpotOrderParams),placeSpotOrders(SpotOrderRequest, per request),placePerpOrder/buildPlacePerpOrder(PlacePerpOrderParams), andplaceOrder/buildPlaceOrder(PlaceOrderParams).amendOrderandamendOrdersalready took it, so every placement and amend verb now shares one vocabulary — the exportedSELF_MATCHING_OPTION.Omitting the field encodes
CANCEL_TAKER, exactly as before, so no existing call changes behaviour. Note that this default is the SDK's own: the pool reads the value out of every order request and has no default to fall back on.tsawait trader.placeSpotOrder({ ...order, selfMatchingOption: SELF_MATCHING_OPTION.CANCEL_MAKER, // drop MY resting order }); -
userDatais settable on single spot placement (PlaceSpotOrderParams), which the batch request shape already allowed. The single verb pinned the tag to0, so the same order encoded differently depending on which verb placed it. -
placeSpotOrdersforwards each request'sbuilderandbuilderFeeBpsTimes1kinstead of zeroing them. Choosing the batch verb silently dropped the routing attribution thatplaceSpotOrderhonours.
Addeda both-leg binary position can finally show either row
-
BinaryPositionPnL.outcomes— the same position split into its YES and NO books, as{ yes, no }of the new exportedBinaryOutcomePositionPnL(balance,costBasis,avgCost,markPrice,markValue,unrealizedPnl,realizedPnl, all RAW).OpenPositionPnLextendsBinaryPositionPnL, sogetBinaryPositionPnLandgetOpenPositionsWithPnLboth carry it with no extra request.Every money field on the result is blended across both outcomes, which is unusable for a wallet holding both. Buy 10 YES at
0.20and 10 NO at0.80, mark YES at0.40: the legs are+2.00and-2.00and the blendedunrealizedPnlis0.00. Neither position row could show that zero as its own PnL, and consumers were falling back to local math for both-leg wallets.The fold already kept the two books separately in bigint space, mints and merges included — it computed each leg and then summed the values away. This publishes them.
tsconst [row] = await client.getOpenPositionsWithPnL(account); for (const leg of [row.outcomes.yes, row.outcomes.no]) { if (leg.balance > 0n) render(leg); // per-outcome, not blended }Legs sum EXACTLY to the totals:
costBasis,markValue,unrealizedPnlandrealizedPnlare stored per leg and added up, so integer division cannot make a published leg disagree with the published total.avgCostandmarkPriceare per-token rates and do not sum — though while trading the two marks add up to one whole collateral unit.
Addedthe linked-wallet funding rail, and the margin surcharge that explains a refused order
-
getPerpLeverageImSurcharge(marginBank, account)andtryGetPerpLeverageImSurcharge— the EXTRA initial margin an account's own leverage settings demand, summed over every market where it both holds a position and has set a stricter-than-market cap.This closes a real gap rather than refining an existing read.
quotePerpOrderTopUpfunds ONE order and measures the account's unlocked balance, while the admission gate measures whole-account equity — so an order can be fully funded on its own market and still be refusedInsufficientMarginForOrderbecause of an override on a different one. Nothing in the SDK could name that amount. Note the magnitude before dismissing it: the order lock reserves only the market's margin factor, so a 5x setting on a 2%-IMF market needs 20% of notional, not 2%.The strict read reverts when a market that is both positioned and overridden is unpriceable; the
tryvariant reports that asnull, never0n— zero would under-state the requirement, which is the direction that produces an order the bank then refuses. -
The linked-wallet read surface —
quotePerpFundingPayer,getPerpMainFunding,getPerpWalletPullCapacity,getPerpLinkedWalletRegistry,getPerpWalletLinkage,listPerpLinkedChildren,getPerpMaxLinkedChildren. A linked child's position-increasing order draws its shortfall from its MAIN's wallet, and none of that was readable from the SDK.quotePerpFundingPayerreturns a discriminated union, not an address, because the contract's single zero collapses three situations a UI must not render alike: the rail isdormant(the bank holds no registry, so the feature is off for everyone and linking would not help), the wallet isunlinked(the one case the user can fix), or the walletisMain(linked, but funding flows main→child only). The arming check runs first and short-circuits, so a deployment with the rail off costs one read and cannot report a misleading "unlinked".getPerpMainFunding().payeris the payer snapshotted at funding time, not whoever is linked now. Both routes home settle against it, so an unlink or re-link between the pull and the repayment cannot misroute the money — read it rather thanmainOfwhen the question is "who gets it back".principalis what the child may trade but not withdraw (withdrawfrees at mostbalance - principal), and it is not a segregated bucket: the claim clamps tomin(principal, balance)at flat moments, so the child's own contribution is the junior tranche and a loss eats it first.getPerpWalletLinkagederivesisChild/isMainbecause the raw encoding is a trap — a main resolves to ITSELF inmainOf, so the natural testmain !== zerois true for mains and children alike.maturesAtgates ADL netting only; the funding rail reads the raw graph, so a link can be fundable and not yet mature.All chain tier. The rail's five events are live on chain but not yet subscribed, so HISTORY is not available — these answer "what is true right now". Writes (
proposeLink/acceptLink/repayFunding/recallFromChild) are deliberately not in this release.
Addeda count can now say whether it is the whole truth
-
client.countMarketsBounded/client.countBinaryMarketsBounded— returnCountResult({ count, truncated }) instead of a bare number.truncated: truemeanscountis a LOWER BOUND (10,000 rows): render it as "10,000+", and do not gate pagination onrows.length < count, which goes false while rows remain.Without the server-only
_aggregateheader every count falls back to a bounded row scan that returned the rows it fetched — so a scan that filled its cap reported exactly10000, indistinguishable from a real total. The fallback now requestscap + 1and reads the extra row as the signal; the probe row is never counted.FillandOrderare already past the cap in production andMarketcrosses it during 2026.The six bare-number count helpers are unchanged, and each now documents the bound on its client declaration — past the cap they return a lower bound as if it were exact.
countOperators/countVenuesget no variant: those tables are orders of magnitude below the cap.countOrders/countUserFillshave none yet, andOrder/Fillare both already past it — copycountMarketsBoundedwhen a caller needs the signal.
Addeda binary market's resolution mode, served
-
BinaryMarket.mode—"reference"(an up/down market whose threshold is another question's answer) or"fixed"(a threshold set at creation). Derived fromstrike, so it costs no extra query, and stamped on the live-tail path too — a market built from logs before the indexer has it reports the same mode it will report after. Exported asBinaryResolutionMode, withbinaryResolutionMode(strike)as the one place that reading happens — both construction paths call it, so they cannot drift.Consumers previously inferred this from a LOOKUP MISS — "no opening answer, so it must be fixed-strike" — which conflates "no reference question" with "the reference question has not been answered yet". That reads as working code until an oracle is slow.
-
boundaryPrice(market, openingPrices)— the level a market's outcome is measured against, taken from whichever sourcemodenames, withpostedsaying which. Only a posted answer has an "answered at" instant, so only it may be labelled as one; returning that alongside the value keeps a label and the number it labels derived from one decision. Returns the raw oracle value — scale stays the caller's to apply.
Changed
BinaryMarketFilter.intervalSecnow matches a BAND, not an exact value. A rolled market's indexedintervalSecisexpiry − tradingStart, and trading routinely opens a second or two late, so one 15m series is indexed at 898s and 899s as well as 900s. The filter matched exactly, so it returned a fraction of a cadence and called it the whole thing — 5m was missing a quarter of its own markets.listBinaryMarkets,listLiveBinaryMarkets,listPastBinaryMarketsandcountBinaryMarketsall inherit this, so row counts change for any caller passingintervalSec. The band is± CADENCE_TOLERANCE_SEC(5s); rungs are far enough apart that bands never overlap. A caller that genuinely wants one exact window should filter the returned rows itself.- A non-finite or non-positive
intervalSecnow THROWSRangeErrorrather than silently matching nothing. Previously{ intervalSec: 0 }sent_eq: "0"and{ intervalSec: NaN }sent_eq: "NaN", both yielding[]. A filter that silently matches nothing reads as an empty result set rather than as the caller error it is, so it is rejected at the boundary. Callers parsing user input (a URL param, a form field) should validate first — the same wayoperatorIdis already expected to be a positive integer. marketIntervalLabelsnaps to the cadence ladder, soBinaryMarket.intervaland the labels stamped on trade-history and portfolio rows now read"1m"where they read"56s","15m"where they read"898s". A window matching no rung is unchanged.
Added
BaseMarket.createdAtBlock— the block a market was created in, the counterpart toresolvedAtBlock. A market is bracketed by two reads of an off-chain feed and only a block identifies which print each one saw: a"fixed"-mode market'sstrikeis the feed's spot atcreatedAtBlock, and that print lands beforetradingStartfar more often than on it, socreatedAtTimestampcannot find it. The field is required, so any code constructing aMarketby hand must supply it; it is non-null on the wire, so every indexed market carries one.getResolutionPrices(marketIds)— batch settlement prices, the counterpart togetOpeningPrices. Joins each market's OWNoracleQuestionId, so fixed-strike markets (which have no reference question) are covered. Read its note on scale before consuming it: the value is a raw integer and the indexer does not carry the answer's decimals.getOnchainResolutionPrice(marketId)and theOnchainResolutionPricetype — a chain fallback for a settled market whose answer the indexer never saw, because its oracle adapter is not the one the indexer ingests. Resolves the market's bound adapter through the module, so it works for any adapter, and carries its owndecimals(adapters differ: 18 on the price-feed adapter, 2 on OracleHub). Returns null while the question is not final; a failed read throws rather than reporting absence.CADENCE_LADDER_SEC,CADENCE_TOLERANCE_SEC,snapToCadenceandcadenceBandSec— the SDK-canonical cadence rules, so grouping and filtering read one source instead of each re-deriving a tolerance. See the note onCADENCE_LADDER_SEC: these exist only because the markets indexer derivesintervalSecfrom a market's own window instead of reading the exact valueMarketCreator.MarketCreatedalready emits.
Breakingthe MWRR capital base is deposited capital
mwrr.returnpreviously divided the window's gain by net trade flow: the carried-in position's value, plus buys, minus the full proceeds of sells. Net trade flow is not capital the account put in, so the figure overstated the return for an account trading a small part of its balance. An account that funded 10,000 USD, traded 500 USD and gained 50 USD reported 10 percent instead of 0.5 percent. Supply the newfundingevents to measure against real deposited capital; without them the base remains a trades-only proxy, andmwrr.capitalBasisnow says which one produced the number.mwrr.depositedUsdkeeps its name and its meaning of net capital, with one correction: a sell now returns only the capital it matched against a tracked position. Proceeds of tokens the account never bought on the venue no longer reduce the base, so an account selling externally-sourced tokens reads zero rather than a negative base — and receives a return where it previously received none.Returns computed from funding events are time-weighted (Modified Dietz): each deposit or withdrawal counts for the fraction of the window it was invested, so capital that arrived late no longer claims a whole window's return. Trade flows are deliberately not weighted. A trade moves capital that is already in the account, and weighting a sale as capital returned and a later repurchase as capital re-deployed would collapse the base for an account that merely rearranged what it held.
A return can exceed 100 percent in either direction on the funding basis, because capital deployed for a few hours of a window earns or loses at a large period rate. That is what a money-weighted rate states. Read the new
weightedCapitalUsdto see how much capital the figure is measured against before presenting it as a headline.Every account's reported return changes. Read
mwrr.capitalBasisto detect which definition a build implements, rather than inferring it from the package version.Fixed
- Binary PnL no longer folds a recycled pool's PREVIOUS markets into the
current market's cost basis. A binary pool is reused by successive markets,
and the PnL reads keyed a wallet's fills by pool address, so an earlier
market's fills landed on whichever market held that pool next.
getBinaryPositionPnLandgetOpenPositionsWithPnLcould both report a wrongavgCost,costBasis,unrealizedPnlandrealizedPnl. They now key by the stablemarket_idthatFillRow.markethas carried since 0.28.0. Reported money values change for any wallet that traded more than one market on the same pool; spot and perp are unaffected, since their market id IS the pool address. getOpenPositionsWithPnLnow asks the indexer only for the fills and router actions of the markets it is folding. Both reads are capped at 1000 rows and the cap drops the OLDEST rows first — the buys and mints that establish the cost basis — so a wallet with a long cross-market history could be priced off a partial book.
Fixedportfolio analytics reject a non-finite sampling bound
computePortfolioAnalyticssampled its equity curve until the sample time reachedasOf. A non-finiteasOf, or a window start derived from a non-finite event timestamp, left that comparison false forever while every iteration appended another point, so the call exhausted memory instead of reporting a bad argument.The fold now validates
asOf, every event timestamp, and the derived window and bucket bounds before it sorts, allocates, or samples. An invalid value throwsInvalidInputErrornaming the field. Nothing is substituted and no event is dropped: an invalid time is the caller's to fix, and guessing one would misplace money on the curve. The newComputePortfolioAnalyticsErrortype states the errors the function can throw.fetchPortfolioAnalyticsnow skips a fill whose indexed timestamp is unusable — nullish, blank, or non-numeric — the same way it already skips a fill whose direction it cannot determine. The check runs before the market is recorded and before a candle range is derived, so one unusable row cannot move anall-window start. Blank strings matter here specifically:Number("")is zero, which would have placed a fill in 1970 rather than failing.A finite bound is not automatically a sampleable one. A fixed timeframe subtracts a constant, so its span is its own definition, but the
alltimeframe opens at the earliest event — one funding event dated far enough in the past asked the fold for millions of points and exhausted the heap. The fold now rejects a span above 100,000 sample points, naming the implied count. That is roughly 11 years of hourly buckets or 270 years of daily ones, so a real portfolio never meets it: a twenty-year history samples about 7,300 points and is unaffected.The sampling loop itself stays uncapped. Capping it would turn a loud failure into silently truncated financial output; refusing an impossible span is the loud version.
Added
getUserFills/countUserFillstakemarket(one bytes32 market id) andmarkets(several) alongside the existingpool, as the exportedFillsScope.getRouterActionstakesmarketsasRouterActionsOptions. The predicates run at the indexer, so alimitapplies to the rows you asked for. Prefermarketoverpoolon binary: a pool selects every market that ever used it.
Addedclosing-price snapshot voids (TRAD-106)
Venue-selectable void payouts: a
CLOB_SNAPSHOTvenue's markets pay[p, D−p]at their closing YES price when they void, instead of the uniform 0.50 refund.- Venue params v3:
BinaryVenueParamsgains optionalvoidPolicy("UNIFORM"default |"CLOB_SNAPSHOT"; exported asVenueVoidPolicy).encodeBinaryVenueFeeParamsencodes v3 through the module's on-chain encoder;decodeBinaryVenueFeeParams(and the BINARY_V1 plugin codec) dual-decodes — legacy 192-byte v2 payloads stay valid with an impliedUNIFORM. trader.captureClose({ pool, maxSteps? })— the permissionless closing-price capture that lifts the pool's closing-book lock. Sweep keepers: aCloseNotCapturedrevert on a post-expiry cancel/sweep means "capture first, then retry"; after a normal resolution the lock lifts by itself and capture is never needed.- Reads:
client.getClosingPrice(pool)(returnsClosingPriceStateornullon pre-capture pools — the selector doubles as the capability probe),closingTopin the pool read ABI, andvoidPolicyongetMarketOnchain(null on pre-policy clones) + the indexedBinaryMarketrow. - Errors: the contract-error table now decodes the capture surface
(
CloseNotCaptured,CloseAlreadyCaptured,CaptureTooEarly,CaptureStepsExhausted,InvalidVenueVoidPolicy,InvalidVoidPolicy).
Addeddead-oracle recovery without
cast sendTradergains the two entry points the recovery path was missing, so the whole chain is SDK-callable (syncSettlement/finalizeMarket/releasePoolwere already there):ts// 1. Always try the real answer first — voiding pays 1/N, resolution pays winners. await exchange.trader.pokeOracle({ oracleQuestionId }); // 2. Only once expiry + settlementWindow has lapsed: await exchange.trader.voidExpired({ marketId }); await exchange.trader.syncSettlement({ marketId }); // void bypasses the module await exchange.trader.finalizeMarket({ marketId }); await exchange.trader.releasePool({ marketId });pokeOracleis keyed by oracle question, not market: the module fans out to every market bound to that question and resolves the ones whose adapter answers. A success is not "all bound markets resolved".OracleNotAnswered(none answered) andUnknownOracleQuestion(nothing bound) arrive asContractRevertError, so a keeper loop can branch onerrorName.voidExpiredwrites to the market contract, bypassing the module — hence thesyncSettlementfollow-up to release the hub earmark. Before sending, it reads the market's state and throwsInvalidInputErrornaming the exact unix second the window lapses, because the on-chainSettlementWindowOpenrevert carries no timestamp. The gate is compared against the chain'sblock.timestamp(what the contract itself uses), not the local clock. PassskipPreflight: trueto send blind.
OPERATOR-GUIDE §5's recovery runbook now shows these calls instead of
cast send.Addedfunding-aware capital base
mwrrcarries two new fields.weightedCapitalUsdis the Modified Dietz denominator.capitalBasisnames the definition that produced the figures:"funding"when external capital movements bearing on the window were supplied,"trades"for the fallback proxy that cannot see capital which never passed through a trade.fetchPortfolioAnalyticsaccepts afundingoption, and the newPortfolioFundingEventtype describes a deposit or withdrawal. Funding events refine the capital base only — they never enter the equity curve, profit and loss, or volume. They are caller-supplied, because the indexer has no wallet token-transfer entity and the public RPC caps log queries at 1,000 blocks. Source them from application records, bridge history, or a private RPC scan, and exclude transfers whose counterparty is a venue contract or the same capital is counted twice.PortfolioFlowEventis now a union ofPortfolioTradeEventandPortfolioFundingEvent. Code that constructs trade events with an explicitkind: "trade"is unaffected.Fixed
balanceFloorproves its guarantee against the write path's conversionbalanceFloorreturns the largest number that does not exceed a raw balance, so a caller can size a "max" order without it reverting for insufficient funds. It verified that by round-tripping its candidate throughcandidate.toFixed(decimals), which is not the conversion a write performs. A number-to-raw conversion prefers the shortest string that round-trips to the same double, so1.001converts as"1.001", while(1.001).toFixed(18)is the smaller"1.000999999999999890". Checking the smaller value passed candidates that the write path then converted to more raw units than the wallet held.Over a deterministic sweep of 4,004 balances around a lot boundary at 18 decimals, 407 results converted above the balance and 110 still exceeded it after the venue's lot floor, by up to 110 wei. Six-decimal balances were unaffected, so this reached only 18-decimal venues — currently the only mainnet precision.
The check now uses the same conversion as the write path. Results move by at most a few hundred wei, and after the venue's lot floor the placeable maximum is unchanged for every balance in that sweep, so no caller loses usable size.
floorRawBalanceinherits the fix on its fallback path.ceilRawAmountwas never affected: it returnsbigintand never round-trips through a double. -
-
Read one account's stored perp leverage cap in a single call:
getPerpMaxLeverage.MarginBank.getMaxLeverage(account, pool)is one storage read, but the only published route to it wasgetPerpLeverage, which walks the whole account and throws when any market the account is active in has a stale mark. A per-position badge or leverage dialog can now read the cap alone — it survives dead feeds on other markets, acceptsopts.blockNumberfor pinned rows, and returns0unchanged when no cap is set (that means "no account cap", not zero leverage). -
Report a perp pool's aggregator oracle address on
getPerpState.PerpStateOnchaingains anoraclefield, read from the pool's ownoracle()view. A consumer building a perp market description fromgetPerpStateno longer has to omit the oracle or keep a parity read of its own against the pool.The read joins the existing pinned multicall, so it costs no extra round trip on that path and one parallel read on the
ChainDoesNotSupportContractfan-out. -
Addedname a revert from your own call
decodeRevert,contractErrorsAbiand theRevertContexttype are now exported from the package root.The SDK could already name a protocol revert:
decodeRevertturns whatever a node threw into aContractRevertErrorcarrying the Solidity error name, decoding againstcontractErrorsAbi(500 custom-error entries). Neither symbol was on the barrel, and theexportsmap has no wildcard, so a deep import could not reach them either. A consumer holding revert data from its own call had no path to a name and had to hand-copy error fragments — which is how a signature drifts in silence.ContractRevertErroralone is not a substitute: it is a passive holder that decodes nothing, so constructing one from raw revert data yieldserrorName: undefined.tsimport { decodeRevert } from "@somnia-chain/markets-sdk"; try { await publicClient.simulateContract(request); } catch (caught) { const err = decodeRevert(caught, { address: pool, functionName: "placeOrder" }); console.log(err.errorName); // e.g. "InvalidTriggerPrice", not opaque hex }contractErrorsAbiis a generated artifact (pnpm errors:gen, verified in CI bypnpm errors:check). Publishing it freezes that artifact as public API deliberately: the point is that a consumer's copy stops drifting when ours is regenerated. Both values are pure over caller-held data — no transport, no client, no owner — so they sit at the root underSDK-API-003alongside the other ABI data.toSdkErrorandisRevertremain internal; ask if you need them. -
Breaking
UnifiedOrder.typemay beundefinedfetchOrders,fetchOpenOrdersandwatchOrdersnow omittypeinstead of reporting"limit".That
"limit"was never a read value. The pools do not emit the order type —OrderPlacedcarries aplacedOrderstruct with no order-type member, and binary pools emit only the YES/NO side — so the indexer has nothing to store and the SDK had nothing to read. Four read paths wrote the literal anyway, which made every market order the SDK returned claim to be a limit order, with no field a consumer could check to tell the difference.Omitting it is the honest answer: absent means unknown, where before a wrong value was indistinguishable from a right one.
The field is still present wherever the value is genuinely known:
createOrder()echoes the caller's own argument.fetchOpenStopOrders()reads it from the registry row —PendingOrderCreateddoes carryorderType.UnifiedStopOrder.typeis unchanged and still required.
diff- if (order.type === "limit") { /* true for every order, including market ones */ } + if (order.type === "limit") { /* now only where the type is actually known */ }If you branch on
order.typefrom a read path, that branch was reading a fabricated value. Handleundefinedexplicitly; the honest source for how an order was placed is your owncreateOrderresult. Populating it on read paths needs the pools to emit the order type, which is a contract change. -
BREAKING — a binary market that has never traded reports NO mark, instead of a mark of zero
computePositionPnLandcomputeBinaryPnlvalued an unpriced market at a YES price of zero. A binary market has no price until its first trade: the indexer creates the row with nolastPrice, and only a fill writes one.markYesPricereports that correctly asnull. Both PnL folds discarded thenulland put a confident0in its place.The NO leg is derived as the complement of the YES mark. A YES mark of zero is therefore a NO mark of the FULL collateral unit. On a 10.00 basis, a wallet holding only NO read as a 5.00 gain, and a wallet holding only YES read as a 5.00 total loss. A wallet holding both legs cancelled to 0.00, which hid the fault in the blended total while both legs stayed wrong. Minting a complete set is the ordinary way to take a position in a market that has not traded, so this state is reachable.
The unknown price now travels as
nullrather than a number:BinaryOutcomePositionPnL.markPrice,.markValueand.unrealizedPnlarebigint | null.BinaryPositionPnL.markValueand.unrealizedPnlarebigint | null.BinaryOutcomePnl.mark,.valueand.unrealizedarenumber | null.BinaryPnl.unrealizedand.totalarenumber | null.
Migration. Handle
nullon those fields before you display or total them.nullmeans the market has no price to mark against — show it as unknown, and do not treat it as zero. Every field that does not depend on a mark keeps its old type and value:balance,costBasis,avgCostandrealizedPnlstay exact.Nothing else changes. A market with a last trade or a resting quote marks as before. A resolved market still marks the winner at one whole collateral unit and the loser at zero, and a voided market still marks both legs at half, because a settlement payout needs no price. While a market is unresolved and priced, its two legs still sum to exactly one collateral unit.
Patch Changes
-
Document
StaleMarkin theOrder.cancelReasonvocabulary.The indexer writes five cancel reasons but both published vocabularies listed four, so the served GraphQL description and the SDK contract told a consumer that a value it can actually receive does not exist. No behaviour or type change:
cancelReasonremains an openstring | null. -
Fixedpre-fill maker cancellations now leave the live order book
Deployed perp pools cancel resting maker orders before any fill — negative equity, stale mark, exceeds position, self-match — and the SDK's live tail dropped those events undecoded. The cancelled order stayed
Openin the local store, so the order book kept counting its quantity at a price level the chain had already cleared: phantom depth in every rendered book and market-maker view, for the whole life of the watch.OrderCancelledPreFill(the base event for every pre-fill removal) and the perp reason tagsMakerOrderCancelledNegativeEquityandMakerOrderCancelledStaleMarkare now declared and terminate the order;MakerOrderCancelledExceedsPositionandOrderCancelledSelfMatchwere already declared and are now covered by tests. Termination only — no store shape change and no new fields. -
Account-scoped fill reads match indexed columns instead of joining through the taker's order
Three reads found the fills an account participated in by OR-ing a predicate on the taker ORDER's
owneralongside the denormalized columns:getUserFills/countUserFills, the binarygetPortfolio, and the user snapshot the live store hydrates from. That arm is a relationship predicate, and Hasura compiles it to a correlatedEXISTSsubquery. Postgres cannot combine such a subquery with a bitmap OR over themakerandtakerindexes: the plan walks thetimestampindex backwards testing each row, and every row it walks past costs a lookup intoOrder.The penalty therefore falls hardest on ordinary wallets, which are sparse in a table that market makers fill. A busy maker reaches
limitin a few rows and barely notices; a wallet with a handful of fills scans a long way and pays the subquery the whole way down. On the development indexer, four real single-fill wallets took 2.78s, 4.50s, 8.75s and 10.93s to sweep their tape, against 0.04s, 0.41s, 0.26s and 0.27s once the arm was gone.The arm found nothing the other two missed.
backfillTakerFillsin the indexer stamps the taker address fromOrderPlacedfor SPOT, PERP and binary alike, and the taker's order is placed in the same transaction as the fill it crosses — a resting order occupies the maker seat by definition — so no committed row can lack it.takerOrder: Order!is non-null in the schema for the same reason. The docblock that defended the arm described binary as leavingtakernull; that stopped being true when binary side attribution moved toBinaryOrderPlaced.Reads that page a wallet's whole tape gain the most, because they pay the penalty once per page. The three also compound: a trader portfolio sweeps its fill tape, reads its binary positions and hydrates its snapshot, so one light wallet paid the scan three times over. Measured together on the development indexer, one page of each took 13.89s before and 0.27s after.
No signature, option or row shape changes, and the rows returned are the same rows.
takerOrder { owner side }is still SELECTED:sideis what makes a binary row mappable when the fill's owntakerSidecopy is still lagging, and selecting a non-null relationship is an ordinary join rather than a filter. The invariant holds structurally: the indexer writesFill.takerandOrder.ownerfrom the same local in the same handler, so the two cannot disagree — delegated and operator-placed orders included.sdk-e2ealso asserts it against a live indexer, though that suite is hand-run rather than a CI gate.
v0.28.12026-08-21npm ↗
A single fix on top of 0.28.0.
Fixedfive perp names the client-scoped move left unreachable
0.28.0 internalized the connection-bound free functions, but five names left
the root barrel without arriving on SomniaMarketsClient. They were reachable
by no route at all: absent from the root, absent from the client, and not
deep-importable, since the package publishes only . and ./react. Each is
now where the client-scoped rule puts it.
Two are connection-bound, so they are client members:
| Name | Use |
|---|---|
getPerpStopOrder | exchange.client.getPerpStopOrder({ registry, orderId }) |
getPerpStopOrderSomiPayment | exchange.client.getPerpStopOrderSomiPayment(registry) |
listPerpStopOrders did become a client member in 0.28.0, so listing was
unaffected. But getPerpStopOrder is the only way to read a LIMIT stop's
limitPrice, its linked siblingOrderId and its intent: no event carries
them, so PerpStopOrder cannot, and nothing off-chain could tell an opening
bracket from a reduce-only stop.
Three are pure functions — sync, no client, no Writer — so they are
client-independent utilities and are exported from the root again:
perpLiquidationPriceperpOrderMarginQuoteperpPositionAnalytics
Their input and output types were never removed
(PerpLiquidationPriceInputs, PerpPositionAnalyticsInputs,
PerpPositionMetrics, PerpOrderMarginQuote, PerpOrderMarginQuoteInputs),
nor was PerpStopOrderOnChain, whose only producer is getPerpStopOrder.
No migration is needed for 0.28.0 code: every name above was unreachable, so nothing can depend on its old shape.
The roster in test/rootExports.test.ts now covers all five, so the same gap
fails CI instead of shipping.
v0.28.02026-08-20npm ↗
The API is now client-scoped — the one breaking item — and the published package can be imported again. The write path aligns prices and amounts to the venue's own tick and lot grids, and gains build-only order verbs (unsigned calldata) plus single and batch order amendment. Reads gain the stable market id on order and fill rows and the liquidation-keeper views.
Breakingclient-scoped entrypoints (53 free functions + the ./lend subpath removed)
Anything that uses a connection (indexer reads, chain reads) is now reachable
ONLY through the client facade — the root barrel no longer exports free-function
duplicates of SomniaMarketsClient members. The migration is mechanical: drop
the trailing indexerUrl/client argument and call the same name on
exchange.client (every removed function already existed as a same-named
member; none changed behavior). Types, pure kernels, constants, and ABIs stay
importable from the root. ./react is untouched.
The @somnia-chain/markets-sdk/lend entrypoint is REMOVED for the same reason:
the lend entry is client-scoped (client.lend). Its standalone surface moved to
the root — change from "@somnia-chain/markets-sdk/lend" to
from "@somnia-chain/markets-sdk" for the lend types (LendReserve,
LendAccount, …), the SOMNIA_MAINNET_LEND / SOMNIA_TESTNET_LEND deployment
constants, the ray-math helpers (lendRayRateToApy, rayMul, accrueLinear,
accrueCompounded, RAY), and the lend ABIs. createLendWithDeps is no longer
public — the client builds the lend entry itself.
// before
import { getBookTops } from "@somnia-chain/markets-sdk";
const tops = await getBookTops(marketIds, indexerUrl);
// after
const tops = await exchange.client.getBookTops(marketIds);
| Removed root export | Use instead |
|---|---|
countOrders | exchange.client.countOrders(…) |
countUserFills | exchange.client.countUserFills(…) |
creditOf | exchange.client.creditOf(…) |
earmarkedOf | exchange.client.earmarkedOf(…) |
getAccountHealth | exchange.client.getAccountHealth(…) |
getAllOpenOrdersOnchain | exchange.client.getAllOpenOrdersOnchain(…) |
getBinaryBookParams | exchange.client.getBinaryBookParams(…) |
getBookTops | exchange.client.getBookTops(…) |
getFreePools | exchange.client.getFreePools(…) |
getFundingPayments | exchange.client.getFundingPayments(…) |
getFundingRateHistory | exchange.client.getFundingRateHistory(…) |
getLiquidationPrice | exchange.client.getLiquidationPrice(…) |
getLiquidations | exchange.client.getLiquidations(…) |
getMarginEvents | exchange.client.getMarginEvents(…) |
getMarketByPool | exchange.client.getMarketByPool(…) |
getMarketCreator | exchange.client.getMarketCreator(…) |
getMarketResolution | exchange.client.getMarketResolution(…) |
getOpenInterestHistory | exchange.client.getOpenInterestHistory(…) |
getOpeningPrices | exchange.client.getOpeningPrices(…) |
getOperatorHubAccount | exchange.client.getOperatorHubAccount(…) |
getOracleAdapter | exchange.client.getOracleAdapter(…) |
getOracleQuestion | exchange.client.getOracleQuestion(…) |
getOrderOnchain | exchange.client.getOrderOnchain(…) |
getOwnOpenOrdersOnchain | exchange.client.getOwnOpenOrdersOnchain(…) |
getPool | exchange.client.getPool(…) |
getPoolBindings | exchange.client.getPoolBindings(…) |
getPoolCreator | exchange.client.getPoolCreator(…) |
getRouterActions | exchange.client.getRouterActions(…) |
getSchedulingCost | exchange.client.getSchedulingCost(…) |
getVaultBalance | exchange.client.getVaultBalance(…) |
getVaultPayoutFallbacks | exchange.client.getVaultPayoutFallbacks(…) |
listBuilderApprovals | exchange.client.listBuilderApprovals(…) |
listBuilderFees | exchange.client.listBuilderFees(…) |
listFundingRateCandles | exchange.client.listFundingRateCandles(…) |
listFundingRateHistory | exchange.client.listFundingRateHistory(…) |
listMarketCreators | exchange.client.listMarketCreators(…) |
listOperatorHubAccounts | exchange.client.listOperatorHubAccounts(…) |
listOracleAdapters | exchange.client.listOracleAdapters(…) |
listOracleBinds | exchange.client.listOracleBinds(…) |
listOracleCallbacks | exchange.client.listOracleCallbacks(…) |
listOracleQuestions | exchange.client.listOracleQuestions(…) |
listPerpFees | exchange.client.listPerpFees(…) |
listPerpOrderHistory | exchange.client.listPerpOrderHistory(…) |
listPerpPositions | exchange.client.listPerpPositions(…) |
listPerpStopOrders | exchange.client.listPerpStopOrders(…) |
listProtocolFees | exchange.client.listProtocolFees(…) |
listSeries | exchange.client.listSeries(…) |
listSettlementFees | exchange.client.listSettlementFees(…) |
listSweepableOrders | exchange.client.listSweepableOrders(…) |
outstandingOf | exchange.client.outstandingOf(…) |
quoteCreateMarketValue | exchange.client.quoteCreateMarketValue(…) |
resolveReserve | exchange.client.resolveReserve(…) |
withdrawableOf | exchange.client.withdrawableOf(…) |
createLendWithDeps (from /lend) | exchange.client.lend (built by the client) |
any other /lend import | same name, from "@somnia-chain/markets-sdk" |
Fixeda scoped or windowed fetchMyTrades returns the rows it was asked for
fetchMyTrades read a wallet's history through three venue portfolio functions
and called all three with no options, so the indexer capped fills at 50 per
venue BEFORE any scoping happened and since/limit/ref were applied
afterwards in memory.
The visible symptom was not truncation. A market-scoped call returned an EMPTY
list over fills that exist: if a wallet's fills on the asked-for market were
older than its 50 newest fills overall, the query never returned them and there
was nothing left to filter. since failed the same way — "my trades since last
week" returned whichever of the newest 50 fell in last week, a smaller and
different set.
It now reads the unified fill tape (getUserFills), so the scope, the window
and the limit are applied by the indexer. limit counts rows the caller
receives: unresolvable rows are skipped and the read pages until the limit is
satisfied or the tape is exhausted.
Two behavioural changes worth knowing:
- Ordering. An unscoped call now returns rows newest-first across all venues. It previously returned them grouped binary → spot → perp, so "the newest 50 trades" was a venue-ordered window rather than the newest 50.
UnifiedTrade.infois now theFillRowthe tape returned, where it was previously a venue portfolio trade row. It remains typedunknownand documented as the native fill row.
since stays in MILLISECONDS on this surface, matching
UnifiedTrade.timestamp; the conversion to the indexer's unix seconds happens
internally.
AddedfetchOpenOrders accepts a row limit
It took only (ref?), so a caller could not raise the 200-per-venue default.
It now takes (ref?, limit?) and forwards it to the query. The limit is per
venue, not a merged total — an unscoped call reads all three. Note the spot
portfolio query binds one limit variable to both its open orders and its
pending stop orders; that coupling is now documented on the verb.
Addedthe stable market id on order and fill reads
Every row of getOrders, getOpenOrders, listSweepableOrders, getFills
and getUserFills now carries market — the bytes32 market id, the stable
identity of the market the row belongs to. A pool address is not a market
identity: on binary event contracts one pool is recycled by successive markets,
so the same pool names a different market depending on when the order was
placed. Pass the id straight to client.getMarket(id); no new API.
Order rows additionally carry marketInfo (asset, question, expiry,
tradingStart, quoteDecimals, intervalSec, plus the derived interval
label), so a history view can label rows without a second round-trip.
Fixedthe published package can be imported
Every release from 0.20.0 through 0.27.0 shipped a dist in which no relative
import specifier carried a file extension. Node's ESM resolver requires them, so
import "@somnia-chain/markets-sdk" threw ERR_MODULE_NOT_FOUND before any user
code ran, on every declared subpath. ./chains failed differently, with
ERR_UNSUPPORTED_DIR_IMPORT, because two specifiers named a directory.
This was not limited to plain Node. webpack — and therefore Next.js — failed too,
with "resolved as fully specified" errors; a consumer could only compile by
setting resolve.fullySpecified: false. esbuild, Rollup, Vite and tsx worked
only by legacy extension guessing. There was no CommonJS fallback, and src in
the tarball was unreachable behind a strict exports map.
The package now compiles with moduleResolution: "nodenext", so every relative
specifier carries its extension and the compiler rejects the form that caused
this. The public API, the exports map and the module format are unchanged — imports
that used to throw now resolve.
Nothing caught this for seven releases because vitest and sdk-e2e consume the
SDK through the workspace exports block pointing at src/*.ts, so the test
surface never loaded dist. Publishing is now gated on
scripts/publish-import-gate.mjs, which packs the tarball, installs it into a
bare {"type":"module"} project with no bundler, and imports every subpath the
published exports map declares.
Fixedthe write path now applies the venue's tick and lot grids
createOrder converted the caller's price to raw units and sent it without ever
snapping it to the market's tick grid — it never called priceToPrecision. So the
pool rejected the order with InvalidPrice even when nothing had been lost in
conversion: createOrder(sym, "limit", "buy", 1, 0.25) on a market with a 0.1
tick sent an off-tick price, and 0.25 is exactly representable as a double.
Market orders were worse, because the caller never supplies the price. The
protective limit was best * (1 ± slippage) computed in float and sent unaligned.
Its binary clamp also bounded at 1 / 10 ** decimals — one wei, not one tick —
so on any venue whose tick exceeds a single raw unit the clamp's own bound was a
value the pool rejects.
createOrder, createStopOrder and the computed crossing price now align in
bigint space against the same grid the precision helpers read. Aligning a value
that is already on the grid returns it unchanged, so a caller who pre-snaps with
priceToPrecision / amountToPrecision sees no behavioural change at all.
Direction is side-aware, and never moves a value against the caller. A buy
price rounds down, a sell price rounds up, and a quantity always rounds down — so
an order is never larger, nor worse priced, than what was asked for. Rounding a
sell price down (which is what priceToPrecision does, for either side) would be
a real loss, so the write path and that helper can now differ by one tick on a
sell; the helper's own spec pins its floor-only behaviour and is left alone.
The one exception is a protective limit the SDK computes for a market order: that
rounds away from the caller, so the cushion survives alignment and the order
still crosses the level it was priced against. This matches how the stake and sell
quote builders in derivedReads have always done it.
Two visible consequences:
UnifiedOrder.price/.amount(and the stop-order equivalents) report what was actually placed, which may differ from the arguments by up to one tick or lot.remainingandstatusare derived from the placed amount, so an order that aligned down no longer reports a remainder that does not exist.- A quantity below one whole lot now throws
InvalidInputErrornaming the market's minimum, instead of sending a zero-quantity order.
Not breaking: every affected call previously either reverted on-chain or was already being pre-aligned by the caller to the same value.
Fixeda float price could not be placed on an 18-decimal venue
fromHuman formatted numbers with toFixed(decimals), which prints the float's
binary expansion rather than the number the caller typed: at 18 decimals
(0.05).toFixed(18) is "0.050000000000000003". Three wei is enough to miss the
live binary venue's 1e15 tick, so the pool rejected the order with
InvalidPrice.
Measured against the mainnet USDso venue: of fifteen ordinary probabilities only 0.25, 0.5 and 0.75 could be placed, the ones binary floating point represents exactly. A 6-decimal venue never shows it, so testnet stayed clean.
Numbers now convert through their shortest round-tripping form (String(n),
i.e. "0.05") whenever it fits the token's scale. toFixed still covers what it
was there for: exponent notation and mantissas longer than decimals (which it
rounds to scale, now stated in fromHuman's docs rather than left implicit). The
string path is unchanged.
The same defect had been hand-rolled at two more sites, both fixed here so the write path has one definition of "the value the caller typed":
Structs.toRaw, whichcreateOrderuses viatoNativePrice— so the ccxt-shaped surface was broken too, and a fix tofromHumanalone would have left it that way.exchange.priceToPrecision/amountToPrecision, the helpers a caller is told to use to AVOID off-tick values. They aligned to the grid in float space and then re-printed withtoFixed, putting the expansion back — off-tick 16 times out of 19 on the live venue's ladder. Alignment now happens in bigint space, the only space the grid is defined in.
Note that this fixes conversion, not arithmetic done before it: a caller who
computes 3 * 0.05 still hands over 0.15000000000000002, a genuinely different
number. Snapping a computed price onto the venue's grid is priceToPrecision's job.
priceToPrecision keeps treating a value a hair BELOW a grid point as sitting on
it, which the old float path did with a +1e-9 nudge. A book mid like
(0.001 + 0.009) / 2 is 0.004999999999999999, and flooring that would quote a
whole tick away from what the caller asked for. The tolerance is now relative (one
part in 2 ** 52, the double's own resolution) rather than a fixed 1e-9, so it
scales with the price instead of being meaningless at 1e6 and overwhelming at
1e-9.
amountToPrecision deliberately does NOT get that nudge — it snaps strictly down.
An amount is usually bounded by something the caller cannot exceed (a wallet
balance, a budget), and because the tolerance is relative it grows with magnitude:
a balance one wei under a lot boundary would be rounded past it, producing the
"insufficient balance" revert that balanceFloor / floorRawBalance exist to
prevent. A price has no such ceiling. Structs.snapToGrid takes a strict option
for callers in the first category, and under it the conversion INTO raw units
truncates past decimals rather than rounding to scale — otherwise an
over-precise number arrives already inflated and the floor starts from the wrong
value (amountToPrecision(0.1234567) on a 6-decimal base returned 0.123457,
above the caller's own number).
fromHuman and probabilityToPrice now reject non-finite numbers with
InvalidInputError instead of letting viem's InvalidDecimalNumberError escape.
probabilityToPrice(NaN) previously slipped past its own range check, since
NaN < 0 and NaN > 1 are both false.
Fixedraw → human scaling no longer divides floats
Structs.toHumanNum scaled with Number(raw) / 10 ** decimals. The division is a
float operation, so it could land a few wei off the value it was handed:
19000000000000000000000 came back as 19000.000000000004, which is not on a
1e15 grid. That silently undid the bigint alignment priceToPrecision had just
performed — about a quarter of ordinary prices above ~1000 were returned OFF the
grid they had been snapped onto, and createOrder re-converts that number before
sending it, so the result was InvalidPrice again. Spot and perp prices live at
exactly those magnitudes; the binary probabilities under 1 that the rest of this
entry is about never showed it.
It now scales through the exact decimal string, like toHumanString already did.
The result is still a number, so it is still display-grade past ~15 significant
digits — but it is the closest double to the true value rather than the closest
double to a lossy quotient. This affects every toHumanNum caller, and only ever
in the direction of accuracy: the two differ solely where the division was wrong.
FixedgetPerpState reads every field at ONE block
getPerpState fanned out with Promise.all over nine independent readContract
calls, so its values could come from different blocks. A mark price from block N
beside a funding rate from block N+2 describes a market state that never existed —
and a price bar rendering the pair presents it as if it did.
Every read is now pinned to a single block number. The pin, not the multicall, is
what provides the guarantee: viem splits a batch into several eth_calls once the
calldata exceeds batchSize, and those can straddle blocks.
Where the client's chain declares a Multicall3 — viem/chains' somnia and
somniaTestnet both do — the nine reads also batch into one aggregate3, so the
call costs two round-trips instead of nine. A chain built with a bare defineChain
declares no contracts, so it keeps the fan-out and costs one MORE round-trip than
before (the block pin). Correctness is identical either way.
No API change — PerpStateOnchain and the getPerpState(pool) signature are
unchanged. tryGetMarkPrice is still the mark read, so a stale feed still returns
markPriceOk: false rather than throwing.
withTypedReadErrors now also wraps multicall. Viem binds client.multicall to
the raw client, so the existing readContract wrapper never fired for batched
reads — without this, a revert read through multicall escaped as a bare viem error
while the identical read through readContract threw a typed SDK error.
Addedcaller-set expiry and builder attribution on spot and perp orders
placeSpotOrder and placePerpOrder now accept the expiry and builder fields
the binary placeOrder already threaded. They were hardcoded before, so a
caller could not set a spot order's expiry at all and could not attribute a
spot or perp order to a builder:
await exchange.trader.placeSpotOrder({
pool, isBid: true, price, quantity, baseDecimals, quoteToken, baseToken,
expireTimestampNs: 1_800_000_000_000_000_000n, // was pinned to ~50y (GTC)
builder: "0xYourFrontend", // was pinned to the zero address
builderFeeBpsTimes1k: 25_000n, // was pinned to 0
});
PlaceSpotOrderParamsgainsexpireTimestampNs,builder, andbuilderFeeBpsTimes1k.PlacePerpOrderParamsgains the two builder fields (it already hadexpireTimestampNs).- Every field is optional and defaults to the value that was hardcoded before
(
farFutureNs(), the zero address,0n), so existing callers are unaffected. - A nonzero builder fee still requires a prior
Trader.approveBuilderon that pool — the approval is stored per pool. Seedocs/SPOT.mdanddocs/PERPS.mdfor the fee ceiling and the two spot-expiry traps (a past expiry places nothing silently; an expired order does not auto-return its escrow).
FixedSomniaMarkets.createOrder dropped builder attribution on spot and perp
createOrder accepted builder / builderFeeBpsTimes1k but only forwarded
them on binary markets. A spot or perp order placed through the unified surface
rested with the zero-address builder: the call reported success, and no fee was
attributed. Both fields now reach all three branches, and they are no longer
documented as "BINARY only".
Addedliquidation-keeper reads: getPerpSideHolders + getBankruptcyPrice
The two MarginBank views the agreed permissionless liquidation-keeper design consumes, from head state alone — no off-chain indexer:
getPerpSideHolders({ marginBank, pool, isLong }, opts?)— every account holding an open position on one side of one perp market. Pages through the bank's bounded slice view (getSideHoldersPaginated; many holders per round-trip, never one call per holder) with every page pinned to ONE block —opts.blockNumber, or the head sampled once — and returns{ holders, asOfBlock }. FeedasOfBlockintogetBankruptcyPrice'sblockNumberoption (and the other side's call) so a sweep describes one consistent state.getBankruptcyPrice({ marginBank, account, pool }, { blockNumber? })— the bank's OWN price at which the position's allocated equity is exhausted, verbatim. A different quantity from the client-sidegetLiquidationPriceestimate (where liquidation triggers): keepers settle and bid against this one. Reverts arrive named —ContractRevertErrorwitherrorName: "NoOpenPosition"when the account is flat in that pool (and"AdlZeroNotional"in the dust degenerate) — even though the generated error table does not cover MarginBank yet: the module fills the name in from the ABI-declared selectors, and the seam is a structural no-op once the table grows to cover the submodule's perp contracts.
New exports: PerpSideHolders, PerpSideHoldersRef, GetPerpSideHoldersOptions
(types only — the reads live on the client, deliberately not as standalone
connection-bound exports, matching the client-scoped API direction).
Addedbuild-only order placement (unsigned calldata)
trader.buildPlaceOrder, buildPlaceSpotOrder, and buildPlacePerpOrder take
the same parameters as their sending twins but hand back the unsigned call
instead of signing and broadcasting it:
const { order, approval } = await trader.buildPlaceOrder({ pool, side: "BUY_YES", price, quantity });
if (approval) await walletClient.sendTransaction({ ...approval, account });
// `order` is {to, data, value} — pre-sign it, batch it, relay it, or simulate it.
For pre-signing an ERC-4337 UserOp off the form input, batching a placement into
a multicall, handing it to a relayer, or simulating it. Ordinary "place this now"
stays on placeOrder.
The approval is returned, not sent — placeOrder approves as a side effect,
which a build-only verb cannot do. Send approval first when it is present. It
is absent when nothing needs approving (a native-base spot sell, every perp
placement).
Also newly exported from the root, so a caller can encode a placement by hand:
ORDER_KIND, binaryPoolWriteAbi, spotPoolWriteAbi, perpPoolWriteAbi, and
the UnsignedCall / UnsignedOrder types. (A returned approval is a standard
ERC-20 approve or ERC-6909 setOperator — decode it with viem's own
erc20Abi or the already-exported erc6909Abi.)
AddedceilRawAmount
The SDK could round an amount DOWN in a controlled way but not UP. Flooring exists because a "max" one ULP above the wallet is an insufficient-balance revert; a minimum has the mirror failure, rejected as under the minimum.
ceilRawAmount(raw, decimals, quantum)—rawrounded UP to a whole multiple ofquantum. The mirror offloorRawBalance, for sizing minimums.
It returns a bigint rather than a number like the floors do: a lot boundary
such as 0.1 at 18 decimals has no exact double, and the nearest one sits
below the minimum. Convert at the edge with Number(formatUnits(...)) if you
need a number.
Fixedstop-order and SpotPool reverts have names instead of hex
A failed placeSpotStopOrder or cancelStopOrder reported opaque hex. The
generated revert-decoding table was extracted from this repo's Foundry artifacts
only, and forge emits artifacts for just the DEX contracts our own contracts
import — so SpotStopOrderRegistry and SpotPool, which nothing here imports,
had no artifacts and no decodable errors at all. Trader's docs promised
errorName would carry the protocol's own error; for those paths it never could.
errors:gen now builds the DEX submodule as its own forge project and merges its
artifacts, so what the SDK can name no longer depends on what this repo happens to
reference. Newly decodable:
- All 28
ISpotStopOrderRegistryerrors — includingInsufficientVaultBalance,InsufficientSomiPayment,NoActiveSubscription,LimitPriceIncompatibleWithTriggerandExceedsWithdrawableBalance. - The
ISpotPool-only errors —OrderIdMismatch(cancelling an order that already filled) and the builder-code family (BuilderNotApproved,BuilderFeeExceedsApproval,BuilderCodesNotSupported). - The parameterless
QuantityBelowMinimum()the registry declares, alongside the CLOB's existingQuantityBelowMinimum(uint256,uint256). Different selectors, so both entries are needed; only the two-argument one was present before.
errors:check is unchanged and still hermetic — it recomputes the stamp from this
repo's files plus the submodule's pinned commit, with neither forge nor a submodule
checkout, so CI needs no access to the private DEX clone. Only errors:gen now
requires the submodule checked out, and it fails loudly rather than quietly writing
a smaller table.
Addedtrader.amendOrders, cancel and replace N orders atomically
Amending a quote one order at a time costs two transactions per rung and leaves the
book briefly one-sided. amendOrders does the whole set in a single transaction:
every old order is cancelled and its replacement placed, or the batch reverts and
nothing moves.
alwaysPlace handles the race where an order you meant to amend has already filled
or been cancelled by the time the tx lands. False (the default, and the
industry-standard posture) reverts AmendOldOrderGone; true skips the cancel leg
and places the replacement anyway. It only tolerates a gone order — someone
else's live order still reverts IncorrectSender.
newOrderIds comes back index-aligned with the submitted amendments. The pool
returns those ids on-chain, but a mined receipt exposes only logs, so they are
reconstructed from OrderPlaced — which fires for every accepted order, including
one that fills completely without ever resting.
A rejected replacement reverts AmendReplacementRejected(requestIndex, reason), which
names both the rung and the OrderRejectionReason — the signal to branch on. Note the
cancel-all-first phase shields replacements from the orders they replace but not from
each other: a new bid crossing a new ask self-matches and, being all-or-nothing, takes
the whole re-ladder with it.
Unlike placeSpotOrder, this does not auto-approve escrow. On an auto-pull pool the
cancel leg returns freed tokens to the wallet and the place leg pulls them back, so a
trader whose first call is amendOrders needs an allowance already in place.
SpotPool and PerpPool only. A BinaryPool reverts UseBinaryPlacement for the
whole transaction: binary placement carries the YES/NO kind through a transient slot
that only placeBinaryOrder sets, and there is no batch binary entry.
Addedtrader.amendOrder, the single-order re-quote
The batch amendOrders landed without its singular sibling, so re-quoting ONE order
meant either a two-transaction cancel-then-place (which opens a gap on the book — the
thing amend exists to prevent) or a one-element batch with a worse error surface.
amendOrder({ pool, oldOrderId, alwaysPlace?, newOrder }) calls the pool's own
amendOrder, so a rejected replacement raises its landing-time reason directly
(PostOnlyWouldCross, SelfMatchCancelTaker, ImmediateOrCancelNoFill,
FillOrKillNotFillable, OrderAlreadyExpired). The batch wraps the same failure as
AmendReplacementRejected(requestIndex, reason), which for a single order is an index
the caller already knew and a reason they then have to unwrap.
Everything else matches the batch verb: alwaysPlace defaults to false and reverts
AmendOldOrderGone on a vanished order (true opts into an upsert, and never tolerates
an ownership failure); the replacement gets a NEW id and loses queue priority — use
reduceOrder to shrink in place; and the call is non-payable, so native replacements
need a manual-vault balance rather than auto-pull.
Addedclaim the SOMI a perp stop registry owes you
trader.claimPerpStopSomi and client.getUnclaimedPerpStopSomi — the registry holds
SOMI for an account in two cases, and neither was reachable from the SDK: a cancel whose
direct refund transfer failed (a contract owner with no payable receiver), and an
operator winding a registry down, which credits every owner including EOAs. Read the
balance first; the claim reverts NothingToClaim on zero.
Deliberately not wrapped: withdrawSomi is owner-only and cannot touch unclaimed
balances, and poke() — permissionless, but the caller subsidises every eligible
trigger, the somiPaid is consumed rather than reimbursed, and below the gas reserve it
breaks out and the transaction succeeds having done nothing.
Changedfaster log decoding on the live tail and the write path
decodeEventLog memoizes nothing: for every log it walks the ABI deriving each
candidate selector, and a log the ABI does NOT carry costs a full scan plus a thrown
error that the caller immediately swallows. Foreign logs are the common case — any
ERC-20 Transfer from a token touched in the same transaction lands in the same
receipt — and the live tail decodes every block.
A topic0 membership check now runs first. Against liveEventsAbi (29 events) an
unmatched log cost 223 µs and now costs 0.054 µs; on a realistic block carrying four
pool events among forty foreign logs, decoding went from 9.00 ms to 0.10 ms.
Internal only — no API change, and the decoded type is unchanged: the full ABI still does the decoding, so the discriminated union callers rely on is intact.
AddeduseIndexerQuery aborts superseded requests + exported query-key factories
useIndexerQuery now creates an AbortController per run, hands its signal to
the fetch function (the fn signature widened to (client, signal), an
additive change), and aborts on dependency change, refetch and unmount — a late
settle from a superseded run never reaches state, even when fn ignores the
signal.
The root entry also exports query-key factories for callers wiring client reads
into their own query library: marketsKey, portfolioKey, candlesKey,
marketFeesKey, operatorsKey, marketCreatorsKey, oracleAdaptersKey,
syncStatusKey, maxVenueFeeBpsKey and marketOnchainKey. Plain functions,
no query-library dependency, returning JSON-serializable, case-folded,
scope-prefixed arrays.
Client reads themselves still take no per-call AbortSignal — cancellation
remains client-scoped via ClientConfig.signal; the hook's signal benefits
fetch functions that do their own fetching.
v0.27.02026-08-14npm ↗
The first release since 0.25.0. Three new entry points (/chains, /reactivity,
/native), the perp trading surface filled in end to end, and batch order writes.
One breaking item — the somnia-dex-protocol re-pin.
Breaking
somnia-dex-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.
Perp stop orders — linked (OCO) TP/SL and opening triggers
placePerpStopOrder is now the single create entry point for perp stops, and it covers
three registry functions that a caller no longer has to choose between:
- A linked (OCO) pair — pass
pairwith the second leg. Both are created and linked atomically, at twice the SOMI (one payment funds one trigger). When one leg triggers and fills, the registry cancels the other and credits its SOMI back. Legs are routed by operator, not by argument order, so it does not matter which you pass first. The result carriespairedStopOrderIdalongsidestopOrderId, caller's own leg first. - An opening (non-reduce-only) trigger — pass
intent: "opening"for a stop-entry or breakout, gated on initial margin at creation so an order that could never margin itself is refused rather than burning its SOMI at trigger. - Unchanged by default. A call passing neither
intentnorpairstill routes tocreatePendingOrderand produces byte-identical calldata to before.
Also linkPerpStopOrders (pair two stops that already exist, moving no SOMI),
cancelPerpStopOrders (batch teardown in one transaction with a single refund — the
way to close a pair), and getPerpStopOrder (read one stop straight from the
registry). That last one closes a gap this SDK previously recorded as permanent: a LIMIT
stop's limitPrice never leaves calldata and private storage, so nothing off-chain could
show the limit a trader chose. It also answers during an indexer outage, and is currently
the only way to read a pair's siblingOrderId and an order's intent.
Cancelling one leg of a pair yourself cancels one order — the other stays armed and becomes unlinked. That is the registry's behaviour, not a limitation here.
getPerpStopOrder().intent is null, not "reduceOnly", when the registry reports an
intent this SDK version does not know. The registry appends to its enums (DropReason
just gained NoFill), and defaulting an unrecognized member to reduce-only would assert
that an order cannot increase a position when a newer member might let it.
listPerpStopOrders decodes the new NoFill drop reason, and now breaks a createdAt
tie with id so its offset paging cannot repeat or lose a row — two stops created in
the same block share a timestamp, which left the boundary between two pages undefined.
The listing does not yet carry pairing, intent, or a cancellation cause. Those are new indexer columns behind a from-scratch reindex, and selecting a column the deployed Hasura does not serve is a validation error rather than a null — it would take the whole read down for every caller. They arrive in a follow-up release once that reindex has cut over.
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.