# Release notes


## 0.32.1

### Patch Changes

- Live order books no longer fall behind the chain on busy pools. Before, the live tail made one missed-log check for each delivered block. A client far from the RPC then fell further behind every second while `getLiveStatus().wsConnected` stayed `true`. Now deliveries that arrive during a check share the next check. Each check also reads through the newest delivered block and applies what it finds with the delivered logs in chain order, so a log the subscription lost from a delivered block is applied in its correct place. When the RPC answers a check with an error, the tail retries it twice, after 250 ms and 500 ms, before it reconnects. This restores the 0.31.1 behavior.
- Live order books recover missed events when the RPC node is behind the subscription. Gap checks keep unproven blocks open for the next read, so an earlier missed fill is not discarded after a later event arrives.
- Live feeds now recover when the chain WebSocket still answers reads but stops pushing new blocks. Before, the live tail subscribed again on the same socket, so order books and other live data stayed frozen until the page or process restarted. Now, when the chain moves and no new block arrives, the tail closes that socket and subscribes on a new connection. This restores the 0.31.1 recovery behavior for this case. Sockets that close or stop answering already recovered in 0.32.0.

## 0.32.0

### Minor Changes

- BREAKING: Binary portfolios now expose position continuation fields. Add `positionsTruncated` and `positionsNextOffset` to caller-built portfolio fixtures. `getClaimable` rejects wallets with more than 200 nonzero positions instead of returning incomplete claims; use `client.getClaimablePage(account, { limit, offset })` and follow `nextOffset` to inspect larger wallets with bounded reads.
- Smart accounts gain indexed reads, through the accounts indexer the accounts SDK knows (no URL to pass).
  
  `client.accounts` (this chain): `getIndexedAccount` (whose account an address is), `listDerivedAccounts`, `listControlledAccounts`, `listOwnerHistory`, `listModuleHistory`, `getCreationAuthorization`, `getActivityPage` (one account), `getNetworkActivityPage` (every account), `getAccountsPage` (every account the factory made, newest first), `getFactoryState`, `getFactoryHistoryPage` and `getIndexerStatus`.
  
  `exchange.accounts` (every covered chain): `listIndexedAccounts`, `listDerivedAccounts`, `listControlledAccounts`, `listOwnerHistory`, `listModuleHistory`, `listCreationAuthorizations`, `listFactoryStates`, `getActivityPage`, `getNetworkActivityPage`, `getAccountsPage`, `getFactoryHistoryPage` and `listIndexerStatus`. Feeds merge chains by time.
  
  Pages are this SDK's own `{ items, nextCursor }` with an opaque `cursor` option, as for fills; `AccountsIndexerStatus` (with `lagSeconds`, `observedAt`, `ready`) and the option types are this SDK's own too. The `list*` reads follow every page, so they never return a silently shortened list. Row types are the accounts SDK's data shapes, re-exported.
  
  New errors: `AccountsIndexerUnavailableError` (the accounts indexer is rate-limited or overloaded; `retryAfterMs`; a subclass of `IndexerError`) and `AccountFeedRestartRequiredError` (the accounts index was rebuilt under a feed cursor). A chain without an accounts indexer throws `NotConfiguredError`; a failed request throws `IndexerError`. Requires `@somnia-chain/accounts-sdk` 0.4.0.
- Preserve binary payout vectors in live market watches so snapshot voids use their stored payout rates. Add settledPayoutNumerators and voidVectorSource to BinaryMarket, with consistent values in live and indexed reads. Keep resolution block and timestamp metadata in live updates.
- BREAKING: `SomniaMarketsClient` has a new required method, `listPerpPositionChangesForFills`. It pairs each of an account's perp fills with the position update that fill made, so a trade history can say "Open Long", "Close Short" or show a flip instead of only buy or sell.
    Pass one page of the account's fills, at most 1,000. It returns a `PerpPositionChange` or `null` for each fill, in the same order. It makes one indexed request for every 100 transactions, plus one more for every further 1,000 updates they hold. `null` means the fill is not the account's, is not a perp fill, or its update is not indexed. An indexer that predates position changes makes it throw the new `PerpPositionChangesUnavailableError`: show each fill's own side until the indexer is reindexed. The package root exports `PerpPositionChangesUnavailableError` and `SomniaMarketsClientListPerpPositionChangesForFillsError`. Custom `SomniaMarketsClient` implementations must add the method.
- BREAKING: `SomniaMarketsClient` has a new required method, `getUserFillHistory`. It keeps an account's whole fill history current for a caller that holds it between reads, such as a portfolio that polls, without reading all of it again.
  
  Pass the history the last call returned as `previous`. The call reads again only the fills from `overlapSeconds` (default 60) before the newest held fill, puts the new fills on top, and keeps the older held rows. When nothing changed, it returns `previous` itself with `changed: false`. That costs one page request and one small probe, and one more request for each further 999 fills in the overlap. A fill never changes once the indexer serves it, except in a chain reorg, so the overlap is a check: when a held fill in it changed, or a fill the history does not hold appears below its newest fill, the call reads the whole history again. When only the newest held fills are not served and nothing newer is, as from an indexer that is behind, the call drops them, and they come back when the indexer catches up. Newer fills while the newest held ones are gone mean a reorg, and the call reads the whole history again. Without `previous`, it reads the newest `maxRows` fills (default 20,000) and says `truncated: true` when older fills exist. A dataset change, or a `previous` for another account, chain, market kind or format, also makes it read the whole history again. Each full read again returns `restarted: true`, and `restartReason` says why: `"previous"`, `"dataset"` or `"overlap"`. The history is plain data that survives a JSON round trip, and the SDK keeps no state between calls.
  
  The package root exports `GetUserFillHistoryOptions`, `UserFillHistory`, `UserFillHistoryRead` and `SomniaMarketsClientGetUserFillHistoryError`. Custom `SomniaMarketsClient` implementations must add the method.

### Patch Changes

- Receipt confirmation now probes idle head streams and stops after 60 seconds or owner cancellation. Unconfirmed broadcasts retain their transaction hash in `TransactionOutcomeUnknownError`, so callers can check the outcome before retrying.
- Queued local lending and admin writes stop before nonce requests when their owner lifecycle ends. Canceled operations release the send lane so the same capability can write after `stopLive()` starts a fresh lifecycle.
- Hideki venue 1 now creates its BTC and ETH 5-minute and 10-minute markets with the price-feed creator. The deployment manifest adds the price-feed oracle adapter and creator addresses for the Hideki testnet.
- Preserve concurrent local-signer lending and admin transaction intents by sharing send ordering with the configured owner's Traders and executors.
- Document receipt cancellation and transaction-hash checks in the client configuration reference.
- Reject cached binary market orders, mints and burns when the pool already hosts a successor market. The preflight check runs before funding or signing and preserves historical redemption.
- Restrict scoped open-order reads to the requested market and binary outcome before applying the row limit, so unrelated orders cannot hide existing quotes.
- The contract-errors table now includes `SomiLock`'s custom errors, so the SDK decodes a reverted SomiLock call (for example `CooldownActive` or `NoWithdrawalRequest`) to its error name instead of raw revert data.
- When upgrading from `0.31.0` to stable `0.32.0`, follow the complete `0.31.0 → 0.32.0` migration guide in `docs/MIGRATING.md`, included in the npm package. Read every `0.32.0` prerelease changelog section as well. This stable changelog section covers changes since the last prerelease; the earlier sections contain additional breaking changes and required migration steps.
- Account views on Somnia Testnet now report known coverage. The SDK recognises the
  READY testnet orders manifest, which claims spot, perp and binary orders and stops,
  and lists its perp and binary exclusions, instead of reporting an unknown manifest.

## 0.32.0-rc.1791380050012

### Minor Changes

- Export the binary event ABIs from the package root: `binaryPoolEventsAbi`, `binaryMarketEventsAbi`, `binaryModuleEventsAbi`, `binarySettlementEventsAbi` and `marketCreatorEventsAbi`. A consumer that decodes binary market logs can now use the same signatures as the SDK and the indexer.
- `listLiquidations`, `listLiveLiquidations`, and `useLiveLiquidations` accept `kinds` so a wallet can read several liquidation stages in one bounded request.
- `listLiquidations`, `listLiveLiquidations`, and `useLiveLiquidations` accept `party` and `counterparty`. Use `party` to read liquidation rows where a wallet is on either side of a leg. The live read and hook also accept `kind` to select one stage.
- Add a `kinds` filter to `client.getMarginEvents`, so one bounded query returns only the margin events of the kinds you name.
    `getMarginEvents(account, { kinds: ["Deposit", "Credited", "Withdraw"] })` returns the money that moved into or out of the account. A portfolio-return calculation needs only these three kinds, so it no longer has to page through every order lock and unlock. The indexer applies the filter, so `limit` counts only matching rows. Without `kinds`, the read returns every kind, as before. An empty `kinds` array matches nothing, as in `listPerpPositionChanges`. A value outside `MarginEventKind` throws `InvalidInputError` before the request is sent. The package root exports `MarginEventKind`, `GetMarginEventsOptions` and `SomniaMarketsClientGetMarginEventsError`.
- BREAKING: once the indexer stores candles per market, `getCandles(pool, …)` on a recycled binary pool returns one row per market in a bucket that spans the recycle, so two rows can share a `bucketStart`. Rows in one bucket are ordered by their last fill, which is market order. To keep one row per bucket, pass the market: `getCandles(pool, interval, { market: marketId })`.
  
  `getCandles` accepts `market`, typed as the new `GetCandlesOptions`, and `candlesKey` and `useCandles` accept it too. An empty or non-string `market` throws `InvalidInputError`. `getMarketStats24h({ marketId })` reads only that market's candles. `getMarketStats24h({ pool })` reads every candle in the 24-hour window, however many markets the pool served. `fetchOHLCV` and `fetchTicker` read only the selected market's candles.
- BREAKING: `SomniaMarketsClient` has a new required member, `perpAdlQueue`: one side of one perp market's auto-deleveraging (ADL) queue, read from the chain at one block, and followed live.
  
  - `getSnapshot({ marginBank, pool, isLong })` reads the side. `data.queue` with `regime: "ranked"` lists every holder on the side. Each holder has its `eligibility`, and each candidate has its `adlRank` in the order that the engine's armed ranker gives. The read follows the liquidation engine. It reads the ranker and the scan cap from the engine at the pinned block. A side with more holders than the scan cap is `regime: "storageOrder"`, and a side that would be ranked but has more than `PERP_ADL_QUEUE_MAX_HOLDERS` (500) holders is `regime: "tooManyHolders"`. A ranker that reverts or returns an unusable order gives holder-list order, and `orderSource` says so. Holder pages that do not add up throw `RpcError`. The read follows the engine's claw-back arm. On the burn arm, the engine admits a position that is not at a loss at the bankruptcy price, and does not check equity. While a live view of the side is `ready`, `getSnapshot` returns that view and reads nothing.
  - `subscribe(ref, onView)` keeps the view live. It reads again at once when the MarginBank reports a change that can move the queue: any trader's fill, close, liquidation, takeover, forced settlement, ADL or funding settlement on this market, and any account's deposit, withdrawal, credit, linked funding or close-out payment, your own included. It also reads every 30 s, so a subscription that stops without an error costs at most that. A price move, a holder's equity changing on another market, and order placement and cancellation show at the next read. Each order locks or releases its margin, which moves the trader's equity, but the bank emits them several times a second, so they are not followed. Views go `loading`, `ready`, `stale`, `failed` and `closed`, as the account views do, and a view is never older than the one before it. Listeners of the same side share one session, which stays 30 s after the last listener, and `stopLive()`, or an abort of the client's `signal`, ends every view with `closed`.
  
  Migration: callers need no change. If you implement `SomniaMarketsClient` yourself, or mock the whole interface, for example in a test double, add the member.
- `getFills(pool, opts)` accepts `market` and `markets`, typed as the new `GetFillsOptions`. A binary pool serves successive markets, so the pool alone returns the fills of every market it has served. Pass `market` to read one market's fills. Each row's `kind` tells a direct trade from a complete-set mint or burn.

### Patch Changes

- `listFundingRateHistory` now orders rows that share a timestamp by `blockNumber`, then by `id`, in the requested direction. Two reads of an unchanged table return the same sequence, and the indexer database can serve the read from its `(pool, timestamp)` index.
- Update the mainnet operator 5 venue to its Safe-owned price-feed creator and adapter. Preserve the previous creator address for historical market replay.
- When you upgrade from 0.31.0, read every 0.32.0 prerelease changelog section, including this one, together with the migration guide. The count of four in the note on migration facts predates this section. The migration guide now lists `perpAdlQueue` among the members that a custom client implementation or a whole-interface test double must add, and it no longer gives a fixed number of prerelease sections.
- Update the testnet operator 2 venue to its Safe-owned price-feed creator and adapter. Preserve historical creator addresses for market replay.
- Preserve owned socket failures for every caller of a shared block-number read. Each read view now receives the original SDK error, including frozen errors, so a joined read cannot turn a connection failure into absent position data.
- Live account spot orders now refresh after in-place amendments. The view reads only the matching pool, catches up to the event block, and keeps its 30-second full refresh.
- Account spot orders now report unproven empty pool discovery as an indexer error. Live views keep known orders and subscriptions with incomplete coverage until discovery recovers.
- Outcome-position subscriptions reuse their reader for 30 seconds after the last unsubscribe. Failed reads use bounded backoff and report whether they retry. Unavailable capabilities recheck every five minutes. Snapshot callers share one read and reuse ready live views. Owner cleanup stops retained readers immediately.
- Share balance reads and live subscriptions across equivalent account and token lists. Each caller keeps its requested order, duplicate entries, native-token labels, and independent cancellation.
- Correct polling and publication descriptions, and expand migration guidance for client capabilities, socket ownership, multi-chain reads, margin provenance, realized PnL, ticker errors, and watch lifecycle.
  
  When upgrading from 0.31.0 to 0.32.0, read the “0.31.0 → 0.32.0” migration-guide section and all four 0.32.0 prerelease changelog sections. The stable section's new patch notes alone do not describe the full upgrade. The guide indexes every prerelease breaking item, including earlier migration repairs.
- Update Somnia testnet deployment metadata to use the upgradeable feed market creator while retaining historical creator addresses.
- Record the testnet price-feed adapter upgrade that restores concise market questions while preserving existing proxy addresses.
- Fix limited pool candle reads to select the newest fills when markets share a bucket. Compare fill positions numerically, including when block numbers or log indexes cross a digit boundary.

## 0.32.0-rc.1791215018402

### Minor Changes

- Add `client.accountBalances`: several accounts' wallet balances, read from the chain at one block.
  
  - `getSnapshot(accounts, tokens)` reads every account's balance of every token: `balanceOf` for an ERC-20 address, and the native balance for `"native"` (exported as `NATIVE_BALANCE_TOKEN`) or the protocol's `NATIVE_TOKEN_SENTINEL`. It is one Multicall3 call at one block, or one call per balance at that block on a chain without Multicall3. One call reads at most `ACCOUNT_BALANCES_MAX` (1,000) balances.
  - Balances are raw base units, in the order given: accounts, each with its tokens. A balance the chain cannot give is `unknown` with its error, never zero, and does not fail the others.
  - `subscribe(accounts, tokens, onView)` keeps the view live. It reads again at once when one of the tokens emits a `Transfer` from or to one of the accounts, and every 10 s, because a native balance changes with no event. Views go `loading`, `ready`, `stale` (last data, while a read or the transfer subscription recovers), `failed` and `closed`, and a view is never older than the one before it. Listeners of the same accounts and tokens share one session, which stays 30 s after the last listener, and `stopLive()`, or an abort of the client's `signal`, ends every view with `closed`. After an abort, a later `subscribe` gets only `closed` and `getSnapshot` rejects with the signal's reason. While a live view is `ready`, `getSnapshot` returns it, and a read that finishes after the live view moved past it never returns the older block.
- BREAKING: `SomniaMarketsClient` has two new required methods, `getAccountEquity` and `listAccountEquities`. `getAccountEquity` reads an account's live equity for one scope at one block.
    `getAccountEquity(account, { scope: "perps", marginBank })` returns `equity`, `lockedCollateral`, their sum `totalEquity` and `asOfBlock`. Pass `blockNumber` to read at a fixed block instead of the head. `client.listAccountEquities(accounts, params)` reads many accounts at one block in batched requests and reports an unpriceable account as `{ priceable: false }`. Score an account on `totalEquity`, because `equity` excludes the collateral locked behind resting orders. The `"spot"` and `"overall"` scopes throw the new `NotSupportedError`.
  
  Migration: callers need no change. If you implement `SomniaMarketsClient` yourself, or mock the whole interface, for example in a test double, add both methods.
- `client.accountOrders.subscribe` takes stop changes from the chain, ahead of the indexer.
  
  - On a client with a chain socket, the live view follows every stop registry's events. A stop the account creates, links, unlinks or cancels, or that fires, shows within a block or so, on top of the indexer's view, instead of after the indexer's lag and its 5 s push. A stop created on chain is rowed as the indexer rows it: its terms from the creation, its time from its block, its transaction from the log, and its venue and limit price `pending` until the SDK reads them.
  - Once the indexer's version reaches a change's block, the stops are the indexer's again. Each subscription that opens reads the registries' logs since the indexer's processed height, up to 1,000 blocks back, so a change made before it opened, or while it was down, is not lost. While it is down, the stops follow the indexer.
  - New field `AccountViewSync.chainStopsBlock`: the newest block of a stop change a live view took from the chain, while its version is behind it, else `null`. A snapshot that `getSnapshot` reads has the indexer's stops, so it is `null` there. When a live view of the account is `ready`, `getSnapshot` returns that view as it is, chain changes included.
  - Open orders stay the indexer's. Spot orders and perp orders are live from the chain in `client.accountSpotOrders` and `client.accountPositions`.
- Add `client.accountOrders.getSnapshot(account)`. It reads one account's complete open orders and pending stops from the indexer's account view, at one version the indexer certifies.
  
  - The result is `ready`, `unavailable` or `tooLarge`. `unavailable` means the indexer cannot serve an account view on the chain. `tooLarge` means the account holds more than a view can prove complete: more orders or stops than its capacity, or a response larger than the indexer's proven limit. Neither result is an empty account. Until the indexer enables account views on a chain, every read there returns `unavailable`.
  - Every number in a row is a `bigint`. A stop also carries its venue and the limit price of the order it places. A value the SDK cannot read is `unknown`, with the error that caused it, and never zero.
  - `data.coverage` names the source families the view covers, for example `SPOT_ORDER`, and what the indexer's manifest leaves out of them. Orders and stops outside them are unknown to the view, not absent. The view can hold event-contract orders while its manifest does not cover `BINARY_ORDER`: such an order is real, but a missing one proves nothing.
  - A failed read throws. The new `AccountViewError`, a subclass of `IndexerError`, means the indexer answered, but not with one complete account. `accountViewFailureReason(error)` maps every error the read throws to one failure code.
  - The package now depends on `zod` 4. The package root exports the zod schemas the view validates indexer rows with: `accountOrdersCapabilityWireSchema`, `accountOrdersGuardWireSchema`, `accountOrderRowWireSchema` and `accountStopRowWireSchema`.
  - The read never sends `indexerHeaders`. Concurrent calls for one account share one read. `stopLive()` ends a read in flight, which then throws `IndexerUnavailableError` with reason `live-reset`.
- Add `client.accountOrders.subscribe(account, onView)`. It keeps one account's complete open orders and pending stops live: it reads a guarded snapshot, then applies the indexer's account change stream in block order.
  
  - `onView` receives every state of the view: `loading`, `ready`, `stale`, `failed`, `unavailable`, `tooLarge` and `closed`. Each `ready` view is complete at its version. Changes that arrive together give one view, and a view waits at most 250 ms for more.
  - When the live connection breaks, or a change fails its checks or cannot be applied in order, the view becomes `stale` with its last complete data, and the SDK reads a new snapshot after a backoff. A read that fails with no data gives `failed`, with the same failure codes as `accountViewFailureReason`. The SDK watches an `unavailable` or `tooLarge` view and reads the account again when the result can change. None of these states is an empty account.
  - The `venue` and `limitPrice` of a new stop are `pending` until the SDK reads them. A later view carries them. `AccountStopVenue` and `AccountStopLimitPrice` have a `pending` state for this, which only a live view uses. A value whose read fails is `unknown`, and the SDK reads it again up to three times, after a backoff.
  - All listeners of one account share one session, which stays for 30 s after the last listener stops. `stopLive()`, or an abort of the client's `signal`, ends every live view with `closed`. After an abort, a later `subscribe` gets only `closed` and `getSnapshot` rejects with the signal's reason. While a live view is `ready` and none of its values is `pending`, `getSnapshot` for the same account returns that view and sends no request, and a read that finishes after the live view moved past it never returns the older version.
  - The new config field `indexerWsUrl` sets the indexer's GraphQL WebSocket endpoint. By default the SDK uses the WebSocket form of an absolute `indexerUrl`. The live view never sends `indexerHeaders`.
  - New exported types: `AccountOrdersView` and its states `AccountOrdersLoading`, `AccountOrdersStale`, `AccountOrdersFailed` and `AccountOrdersClosed`, and also `AccountOrdersStaleReason`, `AccountOrdersViewError` and `AccountOrdersClientSubscribeError`.
- BREAKING: `SomniaMarketsClient` has a new required member, `accountOutcomePositions`.
  
  Migration: callers need no change. If you implement `SomniaMarketsClient` yourself, or mock the whole interface, add the member.
- Add `client.accountOutcomePositions`: one account's complete event-contract positions on a chain, read from the indexer's account view.
  
  - `getSnapshot(account, { signal })` reads the view once. `subscribe(account, onView)` keeps it current and returns a function that stops it. Subscriptions to one account share one reader, which stops with the last of them. `stopLive()` ends every live view with `closed`, and a read in flight with `IndexerUnavailableError` `live-reset`.
  - The result is `ready`, `unavailable` or `tooLarge`. `unavailable` means the indexer cannot serve the view on the chain. `tooLarge` means the account holds more positions than a view can prove complete, or its response exceeds the indexer's byte limit. Neither is an empty account. Until the indexer enables the view on a chain, every read there returns `unavailable`.
  - Each position carries the market id, the side, the balance, the market's status and `claimable`: `unsettled`, `known` with the amount net of the settlement fee, or `unknown`. Every amount, token id and block number is a `bigint`.
  - A read that fails throws `AccountViewError` when the indexer's answer is not one complete account, and `accountViewFailureReason(error)` maps every error it throws to one failure code. A live view reports a failure as `failed`, or as `stale` with the last complete data.
- Add `client.accountPositions`: one account's open perp positions, resting perp orders and margin in one MarginBank, read from the chain at one block.
  
  - `getSnapshot(marginBank, account)` reads the MarginBank's own list of the account's pools, each position, its pool's price and health, and the account's collateral and health, all at one block. That is up to three Multicall3 calls, or one once a read has named the pools and orders, and one call per figure on a chain without Multicall3. It includes positions in unlisted and restricted pools, and positions the account did not open itself, such as an ADL.
  - Each position carries its signed size, entry price, leverage cap, its marked figures (mark price, notional, unrealized PnL, accrued funding, margin requirements, return on margin) and its liquidation price, solved from the same block. The margin carries collateral, health, withdrawable collateral, margin status and the leverage surcharge.
  - `data.orders` holds the account's resting orders in every pool the MarginBank lists, a pool with only orders included: id, pool, order id, side, price, full and remaining quantity, expiry and user data. The pool answers for its caller, so each pool's order ids are one call sent as the account. The orders are `unknown` when one pool's could not be read, and the positions still stand. A pool keeps an expired order until someone cancels it: compare `expireTimestampNs` with the time.
  - A figure the chain cannot give is `unknown` with a reason, never zero. A pool whose price feed is stale keeps its position, with its marked figures unknown. A failed account state or position read fails the whole read, so the list is never shorter than the account's.
  - `subscribe(marginBank, account, onView)` keeps the view live. After the first read, the Somnia node pushes the same read (`somnia_watch`) when the MarginBank emits an event naming the account, and on every price write while the account holds positions, so marks, PnL and liquidation prices follow the price. Fills and cancels of resting orders arrive in the push. A new order costs one full read, and whenever the orders change, the push opens again for them, with one read once the node confirms it. Until then the account's events make the client read, and a push the node does not confirm within 10 s opens again after a backoff. The price trigger needs `addresses.priceFeedScheduler`. While pushes arrive the client makes no reads; when they stop, it reads every 5 s while the account holds positions. A node that does not push, such as a local node, leaves the client reading on the account's events and every 5 s. Views go `loading`, `ready`, `stale` (last data, while a read or the event subscription recovers), `failed` and `closed`, and a view is never older than the one before it. Listeners of one account share one session, which stays 30 s after the last listener, and `stopLive()`, or an abort of the client's `signal`, ends every view with `closed`. After an abort, a later `subscribe` gets only `closed` and `getSnapshot` rejects with the signal's reason. While a live view is `ready`, `getSnapshot` returns it, and a read that finishes after the live view moved past it never returns the older block.
- Add `client.accountSpotOrders`: one account's resting spot orders, read from the chain.
  
  - `getSnapshot(account)` asks every spot pool for the account's open orders, one call per pool sent as the account because the pool answers for its caller, then reads the orders' details in one Multicall3 call, all at one block. It includes orders in unlisted markets. The spot pools come from the indexer's market list, kept for a minute. A pool whose orders cannot be read is listed in `data.unreadPools` with its error, its orders are missing from `data.orders`, and `data.complete` is `false`: those orders are unknown, not absent. A read that can read no pool fails. A pool keeps an expired order until someone cancels it: compare `expireTimestampNs` with the time.
  - Each order carries its id, pool, order id, side, price, full and remaining quantity, expiry and user data.
  - `subscribe(account, onView)` keeps the view live. After the first read, the SDK subscribes over the chain socket to every placement in the spot pools, because only a placement names its owner, and to the later events of the account's own orders by id. When one concerns the account, it reads again at once the pool that emitted it: an order the account placed, or a fill, cancel, expiry, reduce or amend of one of its orders. The other pools keep their last read, so `data.blockNumber` is the newest block read. A read that lands before the event's block reads that pool again. It reads every pool once the node confirms the subscription, and 30 s after it last read them all. A pool that cannot be read is read again after a backoff while the other pools stay live. The subscription opens again with each new order's id; past 1,000 orders it follows every order event of the spot pools. Views go `loading`, `ready`, `stale` (last data, while a failed read or the event subscription recovers), `failed` and `closed`, and a view is never older than the one before it. Listeners of one account share one session, which stays 30 s after the last listener, and `stopLive()`, or an abort of the client's `signal`, ends every view with `closed`. After an abort, a later `subscribe` gets only `closed` and `getSnapshot` rejects with the signal's reason. While a live view is `ready`, `getSnapshot` returns it, and a read that finishes after the live view moved past it never returns the older block.
  - New exported types: `AccountSpotOrdersClient`, `AccountSpotOrder`, `AccountSpotOrdersData`, `AccountSpotUnreadPool`, `AccountSpotOrdersReadError`, `AccountSpotOrdersSnapshot`, `AccountSpotOrdersView` and its states, and the option and error types of both calls.
- BREAKING: `SomniaMarketsClient` has four new required members, the account views: `accountOrders`, `accountSpotOrders`, `accountPositions` and `accountBalances`.
  
  Migration: callers need no change. If you implement `SomniaMarketsClient` yourself, or mock the whole interface, for example in a test double, add the four members.
- Export SomniaMarketsClientGetCandlesError from the SDK root. The candle read reference describes its complete failure union, exact caller cancellation and validated response semantics.
- BREAKING: Node RPC connections use the SDK-managed WebSocket transport instead of a replacement global WebSocket. Replace `globalThis.WebSocket = AppWebSocket; new SomniaMarkets(config)` with `new SomniaMarkets({ ...config, rpcWebSocketConstructor: AppWebSocket })` when your application needs a custom standard constructor with prompt owned disposal. Browser default selection is unchanged.
- `SomniaMarkets` gains `unwatchMarket(ref)`. It releases one market's watch and streaming channels and keeps the exchange open. Before, the exchange held every watched market until `close()`, so a consumer that follows a rotating series kept every old market in memory and in each update. After the call, the next `watch*` call on the market opens a fresh watch. The client drops the market's orders and fills after its 30-second linger. A second call, or a call on a market that is not watched, does nothing.
  
  A pending `watchOrderBook`, `watchTrades`, `watchOrders` or `watchMyTrades` call on a released market now rejects with a `DOMException` named `AbortError`. This also applies to a call that is still hydrating the market. `close()` now does the same for every pending market and price wait. Before, `close()` left those promises pending forever, and a call that was still hydrating could open a new channel after `close()`. A loop that awaits a `watch*` call must handle this rejection when it releases or closes the exchange.
- BREAKING: local-signer writes now send through `eth_sendRawTransactionSync`. Write results carry `executionTimeMicros` when the Somnia node reports it.
  
  `executionTimeMicros` is the node-side time in microseconds from receiving the send to answering. It covers mempool, finality and execution. It is absent on nodes that do not report it.
  
  `TxResult` gains an optional `executionTimeMicros?: number`. So does every trade result, such as `PlaceOrderResult`, `CancelOrdersResult` and the perp results. `Executed` gains the same field. A custom executor can set it on `Executed`, and the writer passes it through.
  
  The local-signer executor reads the field from the `eth_sendRawTransactionSync` receipt. The session-key executor reads it from the `somnia_sendSessionTransaction` receipt. `native.sendSessionTransaction` now returns a `SomniaTransactionReceipt`: a `TransactionReceipt` with the same optional field.
  
  The local-signer fast path covers trader, admin, lend and bridge writes. It now calls `eth_sendRawTransactionSync` (EIP-7966), the current name for Somnia's `realtime_sendRawTransaction`. It still returns the receipt in one round-trip. A node that does not serve the method gets `eth_sendRawTransaction` and a receipt wait, as before. An EIP-7966 error code `5` or `6` from this method is a refusal, because the node did not add the transaction to its mempool. Code `4` (Timeout) stays `TransactionOutcomeUnknownError`.
  
  Migration: a failed fast-path send now reports `operation: "eth_sendRawTransactionSync"` on its `RpcError` or `TransactionOutcomeUnknownError`. It no longer reports `"realtime_sendRawTransaction"`. Update checks for the old name. See the migration guide.
- BREAKING: Latest-price and feed-info React hooks now acquire only current data. If you used one to populate a separate tick read, mount `useLivePriceTicks` or acquire a direct price watch. Direct price watches keep their tick tape by default and accept `ticks: false` to skip it. The last tick reader releases its tape after 30 seconds while current-price readers continue.
- BREAKING: `MarginAccount` has a new required field `asOfBlock`, the block that `client.getMarginAccount` pinned its reads to.
  
  Migration: reads need no change. If you construct a `MarginAccount` yourself, for example in a test fixture, add `asOfBlock`.
- BREAKING: on a multi-chain `SomniaMarkets`, reads now report failures per chain, and `ExchangeDataStatus.indexer` is optional.
    - Migration: `fetchDataStatus()` now resolves when some chains fail, and throws only when every chain fails. When the primary chain's measurement fails, `status.indexer` is absent (`undefined`). Read that chain's error from `status.indexerErrorByChain[primaryChainId]`. Code that reads `status.indexer` must handle `undefined`. `null` still means that the chain's metadata row is absent.
    - `fetchDataStatus()` adds `indexerErrorByChain`, keyed by chain id, with the original error of each chain that failed. It is present only when a chain failed. A failed chain has no key in `indexerByChain`.
    - `IndexerError` and `RpcError` add an optional `chainId`. An account read over two or more chains, or with no market and no chain filter, sets it on the original error to the chain that failed. `fetchDataStatus()` sets it when every chain fails. The class, message and `cause` do not change, so `instanceof` checks still work. Other errors keep their shape.
    - `fetchBalance`, `fetchOpenOrders`, `fetchMyTrades`, `fetchPositions`, `fetchOpenStopOrders` and `watchUser` accept a chain filter `{ chainId }` as their last argument. `fetchOrders` accepts `params.chainId`, and `getOrdersPage` and `getOrderHistoryPage` accept `options.chainId`. A filtered read calls only that chain, also when it loads markets on a new exchange. The filter type `SomniaMarketsChainFilter` is exported from the package root. A `chainId` the exchange does not span throws `NotConfiguredError`. A market on another chain than the filter throws `InvalidInputError`.
    - A chain bound with no `indexerUrl` no longer makes account reads throw. `fetchBalance` returns its ERC-20 and native balances without outcome holdings. `fetchOpenOrders` reads its resting orders from the chain. `fetchOrders`, `getOrdersPage`, `getOrderHistoryPage`, `fetchMyTrades`, `fetchOpenStopOrders` and `watchUser` skip it when the read also spans a chain with an indexer. This also applies to a single-chain exchange with no indexer: `fetchBalance` used to throw `NotConfiguredError` and now returns balances. The new `chainIdsWithoutIndexer` lists these chains. A read scoped to such a chain alone still throws `NotConfiguredError` when it needs the indexer.
    - `loadMarkets()` adds `registryReadErrorsByChain`, `perpDiscoveryErrorsByChain` and `perpDiscoveryPartialFailuresByChain`, keyed by chain id. `registryReadError`, `perpDiscoveryError` and `perpDiscoveryPartialFailure` still report the first failing chain, in the order of the `chains` config. A bare `loadMarkets()` no longer returns the cache when the last load failed: it reads the failed chains again, loads each chain that no load has read yet, and keeps the other chains' markets. A `NotConfiguredError` failure is not retried.
    - `fetchPositions` no longer throws when one pool's oracle reverts. It reads the pool state only for a held position. When that read fails, the position lists with the new `UnifiedPosition.perpState` set to `"degraded"`, and `markPrice` and `unrealizedPnl` are absent. `perpState` is `"ok"` on every other position.
    - `fetchOpenOrders` now reads its chains in parallel. The merged order is still the binding order.
    - `fetchPositions` now returns its rows grouped by chain, in binding order. The order of explicit markets on different chains can change.
    - A market symbol that two chains share can gain a chain suffix when the second chain loads later, for example after a chain-filtered read on a new exchange. The old symbol then throws `InvalidInputError`.
- `listBinaryMarkets` takes `ListBinaryMarketsOptions`: the filter plus `limit` and `offset`. New filter fields `orderBy: "resolvedAt"` (earliest-settled first, ties broken by id) and `resolvedBefore` (exclusive upper bound on the terminal timestamp). Together with `resolvedSince` they page through settlements in the order they happened.
- BREAKING: `client.getPoolBindings(pool)` is renamed `client.listPoolBindings(pool, options?)`, and it returns one page of a pool's binding history instead of the whole history. A recycled pool gains one binding for each market, so the old response had no upper bound.
  
  - Migration: rename the call. The first row is still the current binding, newest nonce first. To read more than 50 rows, pass `{ limit, offset }` and page until a page is shorter than `limit`. Deduplicate on `id`, because new bindings move older rows down between two requests.
  - `limit` is 50 by default and must be an integer from 1 to 1000. `offset` is 0 by default and must be a non-negative 32-bit integer (0 to 2147483647). A bad value throws `InvalidInputError` before the request.
  - The root exports the new `ListPoolBindingsOptions` and `SomniaMarketsClientListPoolBindingsError` types. `PoolBindingRecord` does not change.
- Add perp credit-voucher support: read the live campaign, check whether an account can claim, read an account's house-money floor and expiry, and claim.
    `client.getPerpVoucherCampaign`, `client.getPerpVoucherEligibility` and `client.getPerpVoucher` are chain reads. They resolve the distributor from the MarginBank, so no deployment address is needed. `trader.claimPerpVoucher` sends the claim, and `trader.buildClaimPerpVoucher` returns it unsigned so a claim and a first order can go in one transaction.
- Add optional `addresses.marginBank` to reduce modern perp discovery to two serial read rounds when the candidate matches each pool. Keep registration checks authoritative and fallback reads bounded at one block.
- Expose protocol cancel reasons in paginated perpetual order history.
- BREAKING: `IndexedPerpPosition.lastUpdateRealizedPnl` now holds the realized PnL since the position last opened from flat or flipped side. Before, it held the last update's PnL only, so a close that filled across several book levels reported only its last fill. The name is kept until the next breaking release.
  
  Migration: if you need the PnL of the latest update alone, read `realizedPnl` on the newest row from `client.listPerpPositionChanges({ account, pool, limit: 1 })`.
  
  Add `client.listPerpPositionChanges`: one row per perp position update, each with the realized PnL of that update alone, and with the price it closed at and the order behind it, so a closed trade's result can be read without replaying fills. Filter by `account`, `pool`, `txHash` and `kinds`, window by `from` and `to`, page with `limit` and `offset`. MarginBank emits one update per fill, per auto-deleveraging or market wind-down leg and per liquidation takeover side, so every way a position closes is covered.
  
  Both need an indexer that has reindexed with this release; an older one does not serve `PerpPositionChange`.
- BREAKING: Price-feed connections on Node use the SDK-managed WebSocket transport instead of a replacement global WebSocket. `rpcWebSocketConstructor` now also opens price-feed sockets. Closing the exchange during a price-feed handshake no longer leaves a replacement connection open. When a price-feed socket cannot be created, `watchPrice` and `watchPrices` reject with the constructor's SDK error unchanged, or with `RpcError` for any other thrown value, as RPC reads do. To keep a custom constructor, replace `globalThis.WebSocket = AppWebSocket; new SomniaMarkets(config)` with `new SomniaMarkets({ ...config, rpcWebSocketConstructor: AppWebSocket })`.
- Add `priceFeedScheduler` to `SomniaMarketsAddresses`: the Somnia price feed contract that the perp pools' oracles read. One contract holds every price under a symbol name, such as `ETH/USDC:PERP` for a perp's mark price.
  
  - `SOMNIA_TESTNET_ADDRESSES` sets it to `0x7ccAE31E45693475be1e4BAa2360D6997eB2D32B`, and `SOMNIA_HIDEKI_TESTNET_ADDRESSES` sets it to `0x1998C88D54a240b8C671493f27f58eC416b81D7F`.
  - `SOMNIA_MAINNET_ADDRESSES` does not set it, because mainnet has no perps yet.
  - `client.accountPositions.subscribe` uses it: the node pushes the account's positions on every price write. Without it, marks and PnL follow a read every 5 s.
- BREAKING: `close()` and `stopLive()` now close a chain WebSocket that is still connecting, and each instance owns its own chain socket.
    Before, a socket that was still connecting when its owner closed could not be reached. viem then retried it for about ten seconds. A retry that connected stayed open with a keep-alive interval, which could keep a Node process running.
  
    The SDK now opens every chain socket itself. `close()` and `stopLive()` close it, and end a handshake that is still in progress. A socket that has died is replaced on the next request. Two instances on the same `wsRpcUrl` now open two sockets. Before, they shared one.
  
    The `viem` peer range is now `^2.18.5`. That is the first release that exports every viem API the SDK imports. The old range, `^2`, also allowed releases the SDK could not load.
  
    Migration: upgrade `viem` to 2.18.5 or later if you pin an older 2.x release. A viem client from `client.getViemClient()` now belongs to the connection it came from. After `stopLive()` or `close()`, its requests reject with viem's `SocketClosedError` instead of opening a new socket. Call `getViemClient()` again after `stopLive()` to get a working client.
- `getUserFillsPage` accepts a new `marketType` option (`"SPOT"`, `"PERP"` or `"BINARY"`). The indexer filters on the fill's own market kind column. Omit the option to read every kind, as before. The kind is part of the cursor scope, so a cursor from one kind, or from no kind, is rejected by another. Cursors from pages without a kind stay valid.
  
  `fetchPortfolioAnalytics` now asks the indexer for SPOT fills only, so binary and perp fills are no longer transferred. On a multi-chain exchange it reads all chains at the same time, with one page in flight per chain. The results do not change. `maxFillPages` still counts pages across all chains. A failure on one chain rejects the call and stops the other chains at their next page; an `IndexerError` from a multi-chain read carries the failed `chainId`.
- BREAKING: Ticker and perp-state SDK programming failures now reject with `InvariantError` and retain the exact original failure in `cause`. Replace native programming-error identity checks with an `InvariantError` check and inspect its `cause`. Already-typed SDK errors and caller cancellation reasons keep their exact identity. Tickers join both started tiers and reject with the first observed fatal failure; operational outages retain their documented tier states.
- Export `SomniaMarketsFetchTickerError` from the SDK root. The public reference explains ticker failure containment, caller cancellation and the atomic or pinned perp-state read paths.
- BREAKING: `watchMarkets()` now selects markets by lifecycle, not by age. It hydrates every SPOT and PERP market and every BINARY market that is not finalized. Before, it took the newest 500 markets, so finished binary markets pushed out older SPOT and PERP markets and unfinished binary markets that still receive events. A finalized BINARY market is no longer in the all-markets watch.
    Migration: read a finalized market with `getBinaryMarket(id)`. `watchMarket(pool)` applies no lifecycle filter, so it still hydrates a finalized market while its pool is not recycled onto a newer market.
    One snapshot still hydrates at most 500 eligible markets. When more eligible markets exist, the new `getLiveStatus().allMarketsTruncated` field is `true` while the all-markets watch is active, and the oldest eligible markets are not watched. Before, the cut was silent. The all-markets fill and open-order snapshot also skips finalized markets, so their rows no longer use the fill and order budgets.

### Patch Changes

- `amendOrders` now returns the right id for each amendment when the pool rejects some of a batch and applies the rest. Before, every id after a rejected amendment was reported against the wrong amendment. Now `newOrderIds[i]` is `0n` exactly when amendment `i` placed nothing. That amendment holds no order, so place it again, unless it repeated an `oldOrderId` that an earlier amendment in the batch already amended. `amendOrders` and `amendOrder` also return the unchanged id on pools that amend an order in place. Pools that revert the whole batch on a rejection behave as before.
- Live watches avoid repeated event-id scans on quiet heads. Certified closed blocks release replay state while unresolved blocks keep exact duplicate protection.
- `listPerpPoolStatuses` now batches its per-pool reads. This also applies to `loadMarkets` perp discovery. Each per-pool stage reads the pool bank, the bank registration and, on a factory without status views, the base token and restriction. Each stage now sends one Multicall3 request per 100 pools. Before, it sent one request per pool. On a chain without Multicall3, the reads fall back to point reads with at most 8 in flight. The result shape is unchanged. Every read stays pinned to one block. A pool whose bank or registration read fails still keeps its own status, and other pools are not affected. With a configured `perpPoolFactory`, `loadMarkets` now starts the status read at the same time as the indexer registry read. A factory found through an indexed perp row still waits for that row.
- Live order books now read only the target pool's orders. Before this change, each book derivation read every stored order in every watched pool. The cost of one pool's book no longer grows with the number of orders in other pools. Book contents do not change.
- The live store now bounds expired open orders. On-chain expiry emits no event, so before this change an expired `Open` order stayed in the live store for the life of the watch. Each chain head is now the retention checkpoint. At the first head whose timestamp is strictly after an order's expiry, the order counts toward the per-pool cap of 400 history rows in `getLiveUserOrders`. A retained fill still keeps the order. An active `watchUser` keeps every expired open order of its user, including orders placed after the watch started, until the watch is released. The order keeps status `Open`, because the chain holds its escrow until a cancel or a sweep. A later cancel or sweep event for a removed order changes nothing. Book levels are unchanged: they already hid expired orders.
- Verbs that load markets for you now retry a failed chain at most once in 30 seconds. This applies to `fetchBalance`, `fetchMyTrades`, `fetchPositions` and the chain read of `fetchOpenOrders`. Before, a persistent registry or perp discovery failure re-ran that chain's full registry sweep and perp discovery on every such verb. Between retries, these verbs use the cached markets, and the failure accessors keep the recorded error and failing chain. A bare `loadMarkets()`, `fetchMarkets()`, `ensureFunds` and `loadMarkets(true)` still retry at once. A load that starts while a retry of a chain it needs is running now waits for that retry and does not start a second one.
- The live store now keeps market rows in bounded memory. Before this change, it kept every market row it saw for the whole session, so a recycled pool added one row per market. The store now always keeps a market that a pool is bound to or that a retained fill or order names. It keeps up to 500 other market rows and evicts the oldest ones first, with their address and settlement lookups and their book views. Settlement events now find their market by the settlement key, which is also how the indexer finds it. A delayed `Redeemed` or `MarketFinalized` event for a past market therefore never changes the pool's current market. An event for an evicted market does not change the live store; read that market from the indexer with `getMarket` or `getBinaryMarket`.
- Live watches now keep a bounded cache of block timestamps. Before, a watched market with no events kept the timestamp of every new block. After about 130,000 blocks, the next watch failed with `Maximum call stack size exceeded`. The cache now keeps fewer than 512 recent blocks. A log from an older block reads its exact timestamp from the chain again. `stopLive()` and client disposal clear the cache.
- Bound chain WebSocket connection, read and subscription waits. Replace silent connections so later reads can recover, while allowing slow log queries to complete.
- A new client's first indexer read and first live hydration no longer wait for a separate request before they start. The client still checks once that its indexer serves the configured chain. That check now runs alongside the first request, and the client returns or stores no rows until it passes. An indexer for another chain still fails the read or watch with `NotConfiguredError`. A cold first read now takes about one request round trip instead of two.
- Start the RPC head stream while all-market and perp-plane snapshots load. Preserve snapshot source authority and release the warmup stream when no source watch needs it.
- The networks and endpoints reference now covers the Hideki testnet in its address and token tables. The address table gains a Hideki column. A new table lists the Hideki tokens and how to get them. The reference no longer says that the Hideki indexer skips the spot pools: it indexes them, except HBTT/USDso.
- Cancel Node RPC connections without leaving an idle replacement connection after owner close or a handshake timeout.
- Decode errors from price-feed market creation and resolution so failed transactions report the contract error name.
- The live store no longer rescans every retained fill and order on each block once it holds more than 500 market rows. The cold-market check now runs only after something that can change its result: a new market row, a pool binding that is released or moved to another market, or a dropped fill or order that named a market other than its pool's current one. Blocks that change nothing else now cost the same with 700 market rows as with 400. Which market rows are kept is unchanged.
- Live watches now group timestamp source checks by block to avoid repeated log-batch scans during recovery. Released or replaced watches still discard stale timestamp reads and events.
- A chain head that changes no row no longer gives live readers new references. Before, each head cleared every live selector memo and rebuilt every watched book. `getLiveMarkets`, `getLiveFills`, `getLiveUserFills`, `getLiveUserOrders` and the live books then returned new references for the same rows, and each exchange `watchOrderBook` and `watchTrades` wait resolved with unchanged data. Now a head updates only the tail status. `subscribeLive` listeners still run on each head, and `getLiveStatus()` reports the new `headBlock`. A live book also keeps its reference when a new wall-clock second removes no expired order. It gets a new reference when its book block changes or an order expires. Other changes are as before: a row change, or a status change other than a head, still gives every live reader a new reference.
- Malformed endpoint chain-binding responses now report typed indexer failures before a successful binding is cached. Reads preserve the invalid response evidence and can retry the binding after a corrected response.
- Malformed GraphQL envelopes and indexing metadata now report typed indexer failures with the original response evidence. Ticker reads retain healthy chain values with unavailable statistics when these indexer responses fail validation.
- `client.getTransactionActivity(txHash)` now reads the indexer first. It asks the chain for the transaction's receipt and block only when the indexer has no event for the hash and `opts.anchor` is unset. A transaction with indexed events now makes no RPC call, which removes two dependent chain reads from each such read. Results, the order-table fallback and errors do not change.
- The live feed now applies amends on pools that amend an order in place. Such a pool emits only `OrderAmendedInPlace`: the order keeps its id, and no cancel or placement event follows. Before, `watchMarket` and `watchMarkets` kept each amended order at its old price and size until the next snapshot. Now the order takes the amended price, quantity, expiry and `userData` in the same block. Its `filledQuantity` reads 0 after the amend, because an amend resets the order's remaining quantity to its new full quantity. `liveEventsAbi` decodes the released five-field event; the six-field version that a pre-release build emitted on Hideki 2 is not applied.
- `loadMarkets()` on a multi-chain `SomniaMarkets` now reads its chains at the same time. The first load takes as long as the slowest chain, not the sum of all chains. Market and symbol order does not change. `perpDiscoverySkipped` now judges each chain by its own registry read: a registry failure on one chain no longer hides the skip on another chain.
- The 0.31.0 → 0.32.0 section of `docs/MIGRATING.md` now covers every breaking change in 0.32.0. It gains eight entries: the bounded terminal history of `getLiveUserOrders`, the required `chainId` on live and history rows, the multi-chain refusal of `exchange.client` and `exchange.trader`, chain binding checks, `BridgeDeliveryTimeoutError` and `BridgeLiquidityError`, `TransactionOutcomeUnknownError`, local signing for session seeds, and the removal of the generic Safe executor. The Safe entry was listed under "After 0.32.x" and now sits in the 0.32.0 section. Each new code example is compile-checked.
  
  This also corrects the 0.32.0-rc.1790343344035 release notes. The session-seed note says that `createExecutor({ safe, inner })` refuses an `inner` built by `createExecutor({ sessionSeed })`. The same release removes the `{ safe, inner }` spec, so only `createExecutor({ smartAccount, inner })` refuses it.
- `getOrders` and `getOpenOrders` now sort by `placedAtTimestamp` descending, then by order `id` descending. Orders placed in the same block share a timestamp, so before this change a `limit`/`offset` page boundary inside that tie could repeat one order and skip another. Pages are now deterministic. Results are still newest first, and callers need no change.
- Three account reads now send their independent requests at the same time. The results do not change.
  
  - `watchUser()` on a multi-chain `SomniaMarkets` opens the user watch on every chain at once. When a chain fails, the call waits for the other chains, stops every handle that opened, and throws the error of the first failing chain in binding order.
  - `fetchOpenOrders()` reads the binary, spot and perp portfolios of each indexed chain at once. The order of the result does not change.
  - `client.getClaimable()` reads the settlement fee of each unique winning market with at most eight reads in flight. After a fee read fails, no new fee read starts.
- Balance reads start native and portfolio requests during market loading. Settled market lookups use bounded batches while preserving market identity, missing-row errors, and reload recovery.
- Live watches now recover a long block gap faster. Before, seam and reconnect recovery read one 1,000-block chunk of logs, applied it, and only then read the next chunk. Now up to four chunk reads run at the same time. The chunks still apply in block order, and each source's coverage still moves only across applied chunks. If a chunk read fails, the earlier chunks stay applied, the later responses are dropped, and the retry starts at the failed chunk. Block timestamp reads for one batch are now limited to eight at a time.
- Load markets sooner by preparing the owner connection during registry reads and overlapping independent token metadata, binary grids, and perp discovery. Keep complete results, contained failures, and cleanup across reloads.
- Complete pending subscription cleanup through its owning socket close. Preserve failures delivered through the request callback, including other connections that share an endpoint.
- Perp tickers start candle and chain reads together. The fixed chain-state aggregate cannot split, and fallback reads keep one block pin.
- Include known MarginBank hints in the testnet and Hideki address presets so perp discovery can use its two-round path without manual address mapping.
- Live books, fills, and funding views now preserve unchanged pool references when another pool updates. Pool-scoped fill and funding reads visit only that pool's rows, while order joins and expiry still refresh affected views.
- Release live pool data through pool-local indexes so watch cleanup scales with the released rows. Preserve account history and data held by overlapping watches.
- Keep price watches stopped when a configured warning callback closes their owner or releases their subscriptions.
- Price watches now connect while snapshots load and retry stalled WebSocket handshakes. Shared asset streams keep their own cursors and close with their owner.
- Live store retention now prunes only the pools that changed. Before this change, each changed block read and bucketed every stored fill, order and funding update in every watched pool. The cost of a block no longer grows with the rows held for other pools. A bucket at or under its cap is not sorted. The per-pool funding series cap of 500 rows now takes effect. Before this change, the cap removed no rows.
- A released market watch now drops every live book view of its pool. Before this change, views from `getLiveBinaryOrderBookByMarket` stayed in memory after the watch released, with the book levels they were built from. A recycled pool serves many markets, so these views grew with each market. The purge now also finds views of past markets on the pool, and views whose market row is already gone. Views of other pools and account order history do not change.
- `listRegistryMarkets` and `listRegistryMarketsChecked` now page by a cursor on creation time and id, not by row offset. Before, markets created in the same block could be skipped or returned twice across pages. The sweep now also keeps a binary market whose `finalized` flag is not set yet, the same as the all-markets watch. The sweep and the all-markets watch now ask the indexer for SPOT and PERP markets by name, which lets it use an index.
- Counts on the public indexer role no longer repeat a failing `_aggregate` request. After Hasura rejects the `<Table>_aggregate` field, the client remembers this for five minutes for that endpoint, header set and table, and sends only the bounded id scan. Counts that start at the same time share the first request. Privileged clients still get exact aggregate counts. Only Hasura's exact missing-field error is remembered: other failures still throw, and an error that only mentions "aggregate" and "not found" now throws instead of falling back. `stopLive()` clears the memory, so a permission change applies at once.
- Restore the original short price-feed market question format without technical capture-window text. Resolution rules and numeric precision remain unchanged.
- Pool-scoped order, stop-order, and portfolio reads now keep a classified pool's market type until cache eviction. They no longer read it again after 60 seconds. A multi-pool order read classifies its unknown pools in one batched request instead of one request per pool. A pool that has no market yet, a failed request, and a pool missing from a short response are not cached. The next read asks again.
- Preserve two-round perp discovery while sharing bounded contract reads with account-equity snapshots.
- Price watches in one client now share one price-feed WebSocket per endpoint. Before, each watched asset opened its own socket. Watches that start in the same tick now share snapshot requests of up to 10 assets. Before, each asset sent its own request. Each asset keeps its own 200-tick snapshot, stream cursor, health and cleanup. A rejected subscription puts only its own asset into `"error"`. A dropped connection makes every asset on that socket `stale` until it reconnects. A failed snapshot request rejects only the watches it carried. Closing the client cancels queued and in-flight snapshots.
- Live price tick tapes now keep the same array reference until that asset receives new ticks. A tick for another asset, or a price-feed connection change, no longer makes `getLivePriceTicks` build a new array, so `useLivePriceTicks` no longer re-renders charts for unrelated assets. When an asset's price watch is released, the SDK now also frees every cached tick tape for that asset.
  
  Every reader of the same asset and limit now shares one tick tape array. The SDK freezes it, so sorting or reversing it in place throws a `TypeError`. Copy the array first, for example `[...ticks].reverse()`.
- Ticker reads preserve custom caller abort reasons, including IndexerError instances. Malformed indexer candle responses now report a typed failure, so healthy perp chain numbers remain available with unavailable statistics.
- Preserve the original cause when ticker or perp-state programming failures cannot be classified safely. Such failures reject with `InvariantError` after started ticker work settles. Typed SDK errors and caller cancellation reasons retain their identity.
- Preserve the caller's exact ticker cancellation reason, including NaN and signed zero, while waiting for already-started chain reads.
- Resolve upgradeable price-feed market creators from deployment manifests while preserving existing market reads and event formats.
- Resolve price-feed adapter proxy addresses from deployment manifests while retaining compatibility with earlier direct deployments.

## 0.32.0-rc.1790343344035

### Minor Changes

- BREAKING: the exchange bridge now waits for its own Hyperlane message, and a delivery timeout in `ensureFunds` is a typed error. `exchange.bridge.transfer` reads the message id from the origin receipt and resolves only when the destination Mailbox processes that id. Before, any `ReceivedTransferRemote` to the recipient ended the wait, including a delivery from another transfer.
  
  - `ExchangeBridgeResult` has a new required field, `messageId`. Code that builds this type, such as a test mock, must add it.
  - `exchange.ensureFunds` and `createOrder(…, { funding: "auto" })` throw the new `BridgeDeliveryTimeoutError` on a delivery timeout, with `messageId`, `bridgeHash`, `from`, `to`, `token`, and `amount`. Before, they threw `RpcError` with `operation: "ensureFunds"`. Replace a check for that `RpcError` with `instanceof BridgeDeliveryTimeoutError`, and call `delivered(messageId)` on the destination Mailbox before you bridge again.
  - `exchange.bridge.transfer` still resolves with `delivered: false` on a timeout, and now carries `messageId`.
  - The default `deliveryTimeoutMs` is 180 s instead of 120 s. It is counted from when the origin transaction is mined.
  - A mined origin receipt without exactly one Mailbox dispatch from the origin router to the destination throws `RpcError` with `operation: "bridge message id"`.
- A native bridge route (STT) is now refused before anything is sent when the destination router cannot pay out. `exchange.bridge.transfer`, `exchange.ensureFunds`, and `createOrder(…, { funding: "auto" })` read the destination router's balance first. When it is below the amount, they throw the new `BridgeLiquidityError` with `token`, `from`, `to`, `router`, `amount`, and `available`. Before, the SDK sent the transfer, the funds stayed in escrow on the origin, and the call waited out the delivery timeout.
  
  `ExchangeBridgeTransferError` is the new exported union of the errors `exchange.bridge.transfer` can throw. A failed native balance read now throws `RpcError` with `operation: "getBalance"` instead of a raw viem error.
- Query-key factories and five React hooks now respect the chain on a multi-chain exchange.
  
  - Every query-key factory except `syncStatusKey` takes an optional trailing `{ chainId }` (`QueryKeyChainOptions`). A key with a chain ends in a `{ chainId }` element. A key without one is unchanged, so existing caches keep their entries. On a multi-chain exchange, pass `chainId` on every key.
  - `useWatchPerpMarkets`, `useWatchPerpMarketsResult`, `useLiveLiquidations` and `useLiveFundingPayments` take `chainId` in their options object (`UseWatchPerpMarketsOptions`, `UseLiveLiquidationsOptions`, `UseLiveFundingPaymentsOptions`). Before, they always read the primary chain.
  - `useFundingRateSeries(pool, interval, window, { chainId })` now reads the series and the indexer sync status from that chain's engine. Before, it read them from the primary chain and used `chainId` only for its live updates.
- BREAKING: live and history rows now name their chain in a required `chainId: number` field.
  
  - `LiveFill`, `LiveOrder`, `LiveFundingUpdate`, `LiquidationEvent` and `FundingPayment` carry `chainId`. It is the chain of the engine that built or read the row. These rows come from one chain's events or from its indexer, so their ids repeat across chains: key rows merged from two engines on `(chainId, id)`.
  - Every row source sets it: live events, the chain seed, the indexer snapshot, the perp plane, and the indexed reads `getFundingPayments`, `listLiquidations` and `getLiquidations`.
  - `PricePoint` is unchanged. A price feed is configured independently of the chain (`config.priceFeed`), and one feed endpoint can serve clients on two chains, so a tick is not a per-chain row.
  - Market rows from a chain seed or from a live `MarketCreated` now set the optional `Market.chainId` too, as indexer rows already did.
  - Migration: code that only reads these rows needs no change. Code that builds one of these objects, such as a test fixture or a mock, must add `chainId`.
- BREAKING (multi-chain exchanges only): `exchange.client` and `exchange.trader` now throw `InvalidInputError` on an exchange with two or more chains.
  They name no chain, and a pool address or market id can name a different market on each chain. Before, they acted on the first chain without an error.
  Replace `exchange.client` with `exchange.engineFor(chainId)` and `exchange.trader` with the new public `exchange.traderFor(chainId)`.
  The error's `chainIds` field lists the chains the exchange spans. A single-chain exchange is unchanged.
  `traderFor(chainId)` writes through `account.executors[chainId]` when the signer is a `SomniaMarketsAccount`.
  `setSigner` now validates the new signer before it replaces the old one. After a throw, the previous signer stays bound.
  `createReactivity` now throws `InvalidInputError` when the wallet's `chain` is set and is not the engine's chain. A wallet with no `chain` is not checked.
  BREAKING (type only): `createReactivity` now reads `config` as well as `getViemClient` from its first argument. An engine from the SDK already has both. A hand-built `{ getViemClient }` object must add `config: { chain }`.
- BREAKING: the generic Safe executor, `createExecutor({ safe, inner })`, is removed.
  
  It covered a threshold-1 Gnosis safe in owner or module mode, beside the smart-account executor that covers the accounts factory's safes. Two executors for one kind of account taught two paths, and no consumer used the generic one.
  
  - The `{ safe, inner, mode?, multiSend?, detectFailure? }` spec is gone. A spec with a `safe` key no longer type-checks, and `createExecutor` refuses it at runtime with `InvalidInputError`.
  - The root no longer exports `safeExecAbi`, `preValidatedSignature`, `packMultiSend`, `detectGnosisFailure`, `SafeExecutionMode`, `SafeFailureDetector` or `PackMultiSendError`.
  - This supersedes the session-seed note in the same release, which says `createExecutor({ safe, inner })` refuses an `inner` built by `createExecutor({ sessionSeed })`. That spec no longer exists; the refusal now applies to `createExecutor({ smartAccount, inner })` only.
  
  How to migrate:
  
  - For an account that the accounts factory deployed, use `createExecutor({ smartAccount, inner })`. It batches without a `multiSend` address. Build `inner` with `createExecutor({ privateKey })`.
  - For any other safe, write a custom `Executor` and pass it to `createTrader({ executor })`. `prevalidatedSignature` and `safeAbi` come from `@somnia-chain/accounts-sdk`.
- BREAKING: `SomniaMarkets` now checks that each chain binding's endpoints serve the bound chain.
    - Construction throws `InvalidInputError` when two bindings name one indexer URL written two ways, for example with a different host case, a default port or a trailing slash.
    - The first indexed read of each engine, of any kind, first reads the indexer's `chain_metadata` once. When the indexer has no row for the bound chain, the read throws `NotConfiguredError`, and `what` names the chain, the URL and the chains the indexer reports. So `getSyncStatus` and `getIndexerFreshness` for the client's own chain throw instead of returning `null` for a missing row.
    - On Hideki (50383), the first chain read or session send on each connection first reads block 1. An RPC on the retired Hideki v1 genesis throws `NotConfiguredError`.
    - `getIndexerFreshness` throws `NotConfiguredError` when the indexer is more than 10,000 blocks past the RPC head, instead of reporting lag 0. `waitForIndexedBlock` for the client's own chain reads the RPC head once when it would resolve, and rejects with `NotConfiguredError` in the same case. A client with no chain endpoint skips that read.
    - A binding refusal on a write path crosses as the `NotConfiguredError` itself. The session executor previously read any non-node failure as "the transaction may have been sent" and reported `TransactionOutcomeUnknownError`; a check that refuses the endpoint never sends, so there is no hash to look up.
- BREAKING: a session seed that controls an account now signs locally. The seed no longer goes to a node.
  
  `exchange.accounts.accountFor(accountId, index, { sessionSeed })` sent the raw seed to each chain's node (`somnia_sendSessionTransaction`) with every write. The seed is a private key in another form, and the session address is an owner of the account that holds the funds. Any RPC endpoint, proxy or request log on the way could read it.
  
  - `accountFor(..., { sessionSeed })` now derives the key with `sessionPrivateKey(seed)` and signs locally on each chain, on that engine's nonce lane. No request carries the seed. The session address does not change, so existing owner sets keep working.
  - `createExecutor({ smartAccount, inner })` and `createExecutor({ safe, inner })` throw `InvalidInputError` when `inner` was built by `createExecutor({ sessionSeed })`. `accountFor` refuses a per-chain factory that returns such an executor in the same way.
  - `createExecutor({ sessionSeed })` stays available for session accounts that hold gas dust only.
  
  How to migrate:
  
  - Build an account's inner with `createExecutor({ privateKey })`. For a Somnia session seed, the key is `sessionPrivateKey(seed)`, imported from `@somnia-chain/markets-sdk/native`. Across chains, `accountFor(accountId, index, { sessionSeed })` does this for you.
  - Do not mix local and node-side sends from the same seed. Both modes share one nonce space. After a seed signs locally, do not also send from it through `createExecutor({ sessionSeed })` or `sendSessionTransaction`.
- BREAKING: a write whose signed transaction may have reached the node, and whose receipt was not observed, now throws the new `TransactionOutcomeUnknownError` instead of a plain `RpcError`.
    The built-in executors throw it when the realtime send times out or its socket drops, when the receipt wait fails after the transaction was sent, and when a session send loses its answer. It carries `chainId`, `operation` and `hash` (absent only for a session send). The transaction can still mine, so look up `hash` before you send the write again. A refusal the node answered stays a plain `RpcError` or `ContractRevertError`; a refusal is recognised by the mempool status byte the node puts in the error's `data`, since it reports every mempool verdict — including its own send timeout — as JSON-RPC `-32000`.
    Migration: test for `TransactionOutcomeUnknownError` before `RpcError` in every write's error handler, and do not retry it blindly. It extends `RpcError`, so an existing `instanceof RpcError` arm still catches it. Code that tests `err.name === "RpcError"` no longer matches these failures. A failed receipt wait now reports `operation: "waitForTransactionReceipt"`.
    After a lost realtime answer, the SDK reads the transaction by hash once. When it has mined, the write now resolves with its receipt instead of throwing.
    A session-key write with a gas limit of zero or less now throws `InvalidInputError` before it is sent.

### Patch Changes

- The multi-chain collateral pre-check in `createOrder` now finds the collateral token by address, not by currency code.
    The check reads the balance of the exact token the order escrows. It finds the bridge route by that token's address in the bridge registry. A token the registry does not list has no route, even when its symbol matches a bridge token, so `CollateralOnWrongChainError` carries no plan and an empty `elsewhere`. A registry token under another market label, for example `bUSDso`, now gets its route. `elsewhere` now lists only the other chains the registry lists that token on. `ensureFunds` still takes a currency code. It resolves the code on the target chain to the native coin, then to the bridge registry's token for that code, then to a listed market's token, and continues by that token's address. When no token on the chain carries the code, `ensureFunds` now throws `InvalidInputError` for a positive amount. `CollateralOnWrongChainError.token` is now always lowercased, so it no longer differs in casing between the `ensureFunds` and `createOrder` entry points.
- `exchange.ensureFunds` and `createOrder(…, { funding: "auto" })` no longer bridge the same shortfall twice. On one exchange, calls for the same account, chain and currency run one at a time, and an auto-funded order keeps its place in that queue until it is sent. Two concurrent auto-funded orders now bridge their combined shortfall once. Each queued call can wait a full delivery timeout.
  
  - A leg that timed out stays pending on the exchange. When the next `ensureFunds` or auto-funded order for the same account, chain and currency finds a shortfall, it waits for that message and sends no new leg for it. When the balance already covers the amount, it does not wait. The pending leg is in memory only, so a new exchange instance must still check `delivered(messageId)` before it bridges again.
  - `exchange.close()` now ends the delivery waits of every `bridge.transfer`, `ensureFunds` and auto-funded order that started before it. `bridge.transfer` resolves with `delivered: false`, and `ensureFunds` throws `BridgeDeliveryTimeoutError`. The leg stays pending.
- Avoid scanning and sorting unrelated live orders on every user-order read.
- Complete the migration guide from 0.30.0 to 0.32.0 in `docs/MIGRATING.md`.
  
  The guide now says that 0.31.0 was not published, so a consumer on 0.30.0 applies both of its sections. The 0.30.0 → 0.31.0 section gains three entries: the wallet executor's `sendTransaction`, the required `chainId` on `Unified*` values and `Tradable`, and per-engine nonce lanes. A new 0.31.0 → 0.32.0 section covers the four breaking changes in 0.32.0. Each code example in the guide is compile-checked.
  
  The guide also documents three points that earlier release notes stated incorrectly. The notes themselves are published and are not edited here:
  
  - `indexerUrl` is optional on every chain, including Hideki. A client used only for chain reads and writes needs none.
  - A `walletClient` signer must implement `sendTransaction`. The SDK does not call `writeContract`. An adapter that implements only `writeContract` fails every write with `RpcError`.
  - `UnifiedTicker.statsState` does not narrow `baseVolume` or `quoteVolume`. Check the volume for `undefined` too.
  
  Other documentation fixes: the two-chain example in `docs/EXCHANGE.md` imported `hidekiMainnet`, which does not exist, and now pairs `somniaShannon` with `hidekiTestnet`. `docs/reference/networks-and-endpoints.md` names the chain exports of `@somnia-chain/markets-sdk/chains`. `docs/explanation/about-the-exchange-instance.md` describes per-engine nonce lanes.
- `SOMNIA_TESTNET_ADDRESSES` and `SOMNIA_HIDEKI_TESTNET_ADDRESSES` now set `perpPoolFactory`. A binding that uses either preset lists its perps from the chain, also when it has no `indexerUrl`. Before this change, a Shannon or Hideki binding without an indexer listed no perps. `SOMNIA_MAINNET_ADDRESSES` does not set `perpPoolFactory`.
  
  A configured `perpPoolFactory` takes precedence over the factory that the margin bank reports. At release, each preset value matched the factory that its bank reports. To use the bank's factory instead, remove `perpPoolFactory` from the addresses you pass.
  
  The networks and endpoints reference now lists the Hideki indexer, `https://hideki-dev.smk.somnia.host/v1/graphql`. It is development-grade and does not index the Hideki spot pools yet.
- The smart-account executor (`createExecutor({ smartAccount, inner })`) now refuses an account that does not exist on the chain, and runs signed writes through one account one at a time.
    Before its first write, the executor reads the account's code once. An account with no code on the chain raises `InvalidInputError` before anything is signed or sent. Before this change, a write to such an account succeeded and executed nothing. A failed code read raises `RpcError`. Successful checks are cached.
    This is a new chain read on the path with no `signer` and no `through`, which read nothing before. On a client with no chain transport, that write now throws `NotConfiguredError` instead of sending.
    With a `signer` or `through`, the engine now runs one write at a time per account, including writes from two executors built for the same account. Before this change, two concurrent writes could sign the same account nonce. The account then refused the second write with `GS026`. The queue belongs to one engine: two processes that write through one account can still sign the same nonce.
- The multi-chain collateral pre-check in `createOrder` now sizes a spot order by the pool's `getAutoPullRequirement`, the same number the pool pulls.
    The check now counts the pool's fee headroom and the account's pool vault balance. A spot order that the vault covers places with no wallet read and no `CollateralOnWrongChainError`. A wallet that holds only the principal on a fee-bearing pool now gets a typed shortfall before anything is sent, instead of a revert. A native-base sell is sized the same way. Under the pool's manual vault mode, a vault shortfall throws `CollateralOnWrongChainError` with no bridge plan, also under `funding: "auto"`, because the pool does not pull from the wallet.

## 0.32.0-rc.1790260897085

### Minor Changes

- `BinaryMarketFilter` gains two facets for counting settlements inside a window,
  and the binary count helpers no longer require a lifecycle phase.
  
  - `voided?: boolean` narrows to markets that settled by void (`true`) or to
    every other market (`false`). It reads the explicit `voided` flag every void
    path writes, not the event-derived `status`.
  - `resolvedSince?: number | bigint` is an inclusive lower bound, in unix
    seconds, on the market's terminal timestamp. It takes a `bigint` from a chain
    read or an indexed row as well as a `number` from the clock, and sends either
    as an exact decimal string. It implies `resolved: true`: a market with no
    terminal event never matches.
  - `countBinaryMarkets` and `countBinaryMarketsBounded` take
    `BinaryMarketCountOptions`, on which `phase` is now optional. Omitted, the
    count spans live and past markets alike. Existing calls that pass `phase`
    behave exactly as before.
  
  Together they give a venue's settlement count and void count over a trailing
  window in two server-side counts:
  `countBinaryMarkets({ venueId, resolvedSince })` and the same call with
  `voided: true`. Both are keyed on the terminal timestamp rather than on
  `expiry`, so a market voided before it expired is counted, which a
  `phase: "past"` count would miss.
- BREAKING: `getLiveUserOrders` now keeps up to 400 recent unreferenced terminal
  orders per pool, along with every open order and orders needed by retained
  fills. Use `getOrders` for older terminal history.
- Add `client.listVaultPayoutFallbacks(options)`: the vault-credit fallback log
  across every owner, filtered by `owner`, `token` and/or `pool`, windowed with
  `from`/`to`, ordered `asc` or `desc`, paginated. Each `VaultPayoutFallback`
  row now also carries `pool`, the pool whose vault holds the credit, so the
  live claimable amount can be read with `client.getVaultBalance` without a
  second lookup. `getVaultPayoutFallbacks` is unchanged apart from the new field.
- Add `listMarketResolutionEvents` and `listMarketReferenceLinks` — cross-market
  sweeps over resolution health, where `getMarketResolution` reports one market.
  `listMarketResolutionEvents` filters on `market`, `kinds` (a new
  `MarketResolutionKind` union, matched exactly) and `sinceTimestamp`, so a caller
  watching for `"Skipped"` / `"Failed"` pays for its window instead of
  over-fetching. `listMarketReferenceLinks` filters on `pending` and `market`, and
  returns oldest first so the longest-stuck market heads the list.
  `MarketReferenceLink` now also carries `blockNumber` and `timestamp`; while
  `pending` is true, that timestamp is when the market went pending, which is the
  age of the stall.

## 0.32.0-rc.1790179106799

### Minor Changes

- Portfolio analytics now reads cursor-paged fill history and supports an optional page budget that rejects incomplete PnL. A short nonempty page uses one additional indexer request to check for continuation under a positive role row cap.
- BREAKING: the bridge registry now describes the **Hideki 2** Hyperlane lane. Six Somnia-side core addresses change. The lane's public string identifiers change with them.
  
  **What was wrong.** Somnia Testnet (50312) hosts two independent Hyperlane deployments. One serves the Hideki 1 lane. The other serves the Hideki 2 lane. This separation is structural. Both Hideki chains use chain id 50383 and Hyperlane domain 50383. Lane isolation therefore rests on two things: each lane has its own core contracts, and each warp router binds to one mailbox.
  
  `ChainId.hidekiTestnet` has meant Hideki 2 since the 2026-09-17 regenesis. The Somnia-side routers moved to Hideki 2 at that time. The Somnia-side core block did not. It kept the Hideki 1 lane's contracts. `getBridgeNetwork(ChainId.somniaShannon)` therefore returned a mailbox, ISM, merkle hook, validator announce, ICA router and proxy admin from a different bridge than the routers beside them.
  
  `createBridgeTransfer` and `sendBridgeStep` were not affected. They use the router addresses, which were already correct. Only `getBridgeNetwork` and `listBridgeNetworks` exposed the wrong values. An explorer link, an ISM trust-model check or a monitoring probe reads those.
  
  | `getBridgeNetwork(ChainId.somniaShannon)` | was (Hideki 1 lane) | now (Hideki 2 lane) |
  | --- | --- | --- |
  | `mailbox` | `0x46bA2807…e0A9` | `0xF805734a…54f9` |
  | `interchainSecurityModule` | `0xa831F40f…1B6c` | `0x99e1D7E9…d843` |
  | `merkleTreeHook` | `0xD7C2E95c…332D` | `0x64aa1c6a…D50C` |
  | `validatorAnnounce` | `0xB0834b2d…F44e` | `0x81b741D6…1033` |
  | `interchainAccountRouter` | `0x1384Ed70…22eF` | `0x2fbF56e1…2E19` |
  | `proxyAdmin` | `0x735653D2…D775` | `0x79f22212…1FaE` |
  
  **Why this release is breaking.** The lane now uses the names the `hyperlane-bridge-infra` repo uses. A consumer can store or compare these values.
  
  - `SOMNIA_BRIDGE.id`: `"somniatestnet-hidekitestnet"` becomes `"somniatestnet-hideki2testnet"`.
  - Every `route.id`: `"WBTC/somniatestnet-hidekitestnet"` becomes `"WBTC/somniatestnet-hideki2testnet"`.
  - `getBridgeNetwork(ChainId.hidekiTestnet).network`: `"hidekitestnet"` becomes `"hideki2testnet"`.
  - `getBridgeNetwork(ChainId.hidekiTestnet).displayName`: `"Hideki Testnet"` becomes `"Hideki2 Testnet"`.
  - `SOMNIA_BRIDGE.displayName`: `"Somnia Testnet ↔ Hideki Testnet"` becomes `"Somnia Testnet ↔ Hideki2 Testnet"`.
  - `SOMNIA_BRIDGE.docs` points to the Hideki 2 deployment record.
  
  **How to migrate.**
  
  - **Stored lane ids or route ids.** Update them to the `hideki2testnet` spelling. Config files, dashboards, analytics keys and test fixtures hold these. Compare against `SOMNIA_BRIDGE.id` or `route.id` instead of a literal if you want this to survive the next rename.
  - **Cached Somnia-side core addresses.** Discard them. Read them again from `getBridgeNetwork`. A cached Hideki 1 mailbox does not fail loudly. It describes a live bridge. That bridge is not the one your routers use.
  - **Display strings.** `displayName` is presentation text. Pin your own string if you need a stable one.
  - **Nothing to do** if you call only `createBridgeTransfer`, `sendBridgeStep` or `getBridgeToken`. No router, token or decimal value changed.
  
  **Chain definitions do not change.** `hidekiTestnet` and `ChainId.hidekiTestnet` keep their names. Both still resolve to 50383. Only the bridge lane's identifiers move. This release adds no second chain export, because `somniaChains`, `ChainId` and the deployment manifests are keyed by chain id, and both Hideki chains use 50383.
  
  **One trap is now documented.** Every Hideki-side address on this lane equals Hideki 1's. The same deployer replayed its nonce sequence onto Hideki 2, and that chain restarted its nonces at genesis. The ISM is derived by CREATE2 from the factory and the shared validator set. A matching Hideki address therefore does not identify the lane. Only the Somnia-side addresses do. The registry header states this.
  
  **How this was verified.** Every value was read from the live chains on 2026-09-21. `test/bridge.e2e.test.ts` (`SOMNIA_E2E_BRIDGE=1`) now pins all six core addresses to the lane. It checks that each address holds code. It checks that every warp router binds to the registry's mailbox. It then checks that the mailbox's `defaultIsm`, `defaultHook` and ERC-1967 admin slot agree with the registry, and that `validatorAnnounce.mailbox()` and `interchainAccountRouter.mailbox()` return that same mailbox. It also checks the ISM is a 1-of-1 message-id multisig on the advertised validator. That test fails against the pre-fix registry. It passed before this change only because CI does not set the flag. A live STT round trip from Somnia to Hideki 2 and back delivered in 3.8 s and 3.0 s, and router liquidity returned to its starting values.
- BREAKING: `UnifiedTicker.baseVolume` and `quoteVolume` are now optional, and
  `fetchTicker` no longer fails whole when the candle read fails.
  
  `fetchTicker` opened with `getCandles`, an indexer read, and did not contain it.
  An indexer outage threw before the perp enrichment ran, so a perp trader lost
  mark price, index price, funding rate, next funding time and open interest —
  five chain reads that would have worked — to a candle outage. Those numbers
  decide liquidation risk.
  
  The candle read is now contained the way the perp read already was. A new
  `statsState` field says which state the 24h half is in:
  
  - `"ok"` — the window was read. The volumes are present, and they are `0` when
    the window held no fills. That zero is a true value, unchanged from before.
  - `"unavailable"` — the candle request failed with an `IndexerError`, or the
    market is `indexed: false` and no request was sent. Then `open`, `high`,
    `low`, `change`, `percentage`, `statsAsOf`, `baseVolume` and `quoteVolume` are
    all absent, the call still resolves, and the perp block still runs.
  
  The containment is narrow. Only `IndexerError` is caught; every other error
  propagates.
  
  Migration: `baseVolume` and `quoteVolume` are now `number | undefined`. Read
  `statsState` before reading them.
  
  ```ts
  // Before
  console.log(ticker.baseVolume.toFixed(2));
  
  // After
  if (ticker.statsState === "ok") console.log(ticker.baseVolume.toFixed(2));
  else console.log("24h volume unavailable");
  ```
  
  The dreamDEX web ticker hook (`web/hooks/use-tickers.ts`) polls `fetchTicker`
  every 10 seconds and shows the 24h volume. It is the known consumer to update:
  it gains the outage-resilient perp header, and it must handle an absent volume.
- BREAKING: Historical fill pages now require an indexer that serves the `FillDataset` generation. Deploy and reindex the producer before upgrading the SDK; handle `FillHistoryUnavailableError` as a producer rollout failure, and discard accumulated pages and start at page one on `FillHistoryRestartRequiredError`.
- Add `SOMNIA_HIDEKI_TESTNET_ADDRESSES` for chain 50383 from the deployment manifest. The preset omits `collateralRouter` and `lend` because Hideki has neither deployment. Their helpers report not-configured.
  
  Hideki has no hosted indexer. Supply a consumer-operated `indexerUrl`, which the SDK requires even for chain reads and writes. The mainnet and Shannon presets are unchanged.
- Bound how hard one client pushes its indexer. Every indexer read now passes
  three gates owned by the configured client: a cap on requests in flight, a
  circuit breaker fed by consecutive endpoint failures, and a jittered backoff
  schedule. Configure them with the new `ClientConfig.indexerLimits`; the defaults
  in `DEFAULT_INDEXER_LIMITS` are conservative and only ever reduce load.
  
  The gates exist because a slow API used to become a retry storm. A client that
  retried a 30-second timeout on a fixed 3-second interval added a query to the
  server instead of replacing one, because nothing cancels an abandoned query.
  Once every client was doing that, the load sustained itself after the original
  slow query stopped mattering.
  
  What each gate does:
  
  - **In-flight cap** (`maxInFlight`, default 12). Further reads queue inside the
    client, where they hold no indexer connection, and start as earlier reads
    settle. A queued read still honours its caller's abort signal. The queue is
    bounded too (`maxQueued`, default 100): past it a read is refused rather than
    parked, so a client whose arrivals outrun the cap sheds load instead of
    growing a backlog.
  - **Circuit breaker** (`breakerThreshold`, default 5 consecutive endpoint failures;
    `breakerCooldownMs`, default 30 s). While it is open the client sends nothing
    and fails at once with the new `IndexerUnavailableError`. After the cooling
    period one trial read decides whether to close the breaker. Only timeouts and
    the 429/502/503/504 family count: a GraphQL validation error proves the
    endpoint is alive and resets the count.
  - **Backoff schedule** (`baseDelayMs`, `maxDelayMs`, `maxAttempts`). The new
    `nextBackoffDelayMs` helper returns exponential backoff with full jitter, so
    clients that retry together do not come back together. The caller passes the
    random draw (`Math.random()`), so it is pure and a polling loop outside the
    SDK can pace itself on the same schedule.
  
  `IndexerUnavailableError` extends `IndexerError`, so existing `instanceof
  IndexerError` handling keeps working unchanged and every documented error union
  stays true. For `breaker-open`, `breaker-trial`, and `queue-full`, catch the
  narrower type to distinguish “we did not send” from “the indexer did not
  answer”. A `live-reset` can instead end a retry backoff after an earlier attempt
  was sent.
  
  Its `reason` says which gate refused, because the four cases want different
  things and only one of them is a fixed wait:
  
  | `reason` | Your move |
  | --- | --- |
  | `breaker-open` | Sleep `retryAfterMs`, then retry |
  | `breaker-trial` | Retry shortly; the trial read decides |
  | `queue-full` | Back off on your own schedule, or send less |
  | `live-reset` | Retry on the same client; `stopLive()` leaves it usable |
  
  `SomniaMarkets.close()` stops each engine before it awaits cached watches, so a
  hydration still queued for a slot is refused rather than waited on; shutdown no
  longer stretches to the queue ahead of it.
  
  `retryAfterMs` is meaningful only for `breaker-open`, and is `0` elsewhere —
  there the wait is the caller's own backoff, not a period the SDK knows.
  
  The boundary itself does not retry. `maxAttempts` defaults to 1, so this cannot
  multiply retries a caller already performs. Raise it only when nothing above the
  SDK retries. When a gate refuses a retry after an earlier attempt reached the
  endpoint, the read fails with that attempt's `IndexerError`, not an
  `IndexerUnavailableError`: a gate refusal always means nothing was sent.
  
  Breaker and cap state belong to the client that owns them. Two clients built
  against the same endpoint keep separate counters, so one client's outage never
  fails another client's reads.
  
  `stopLive()` closes the client's gate. A read already on the wire still runs to
  completion, but one waiting for an in-flight slot is rejected with
  `IndexerUnavailableError` — the client that would have admitted it is gone.
  Only a caller that exceeds `maxInFlight` concurrent reads can observe this, and
  the rejection is an `IndexerError`, which every indexer read could already
  throw.
  
  The gates cover live-tail hydration as well as ordinary reads, so a watch cannot
  add ungoverned indexer load. A limit that would make the governor unusable is
  rejected with `InvalidInputError` when the client is built: a count that is not a
  whole number of at least 1, a delay of `0` or less, or any non-finite value. `nextBackoffDelayMs` rejects the
  same inputs directly, since a zero, negative or `NaN` bound yields a delay every
  timer fires immediately. `DEFAULT_INDEXER_LIMITS` is frozen, so one consumer cannot
  retune every client in the process.
- Add `client.getOpenOrdersOnchain(pools, owner, opts?)`, which lists an owner's
  full resting orders from chain at one pinned block, and give
  `getOwnOpenOrdersOnchain` a `blockNumber` option.
  
  `getOpenOrders` is the read a bot uses to list its own resting orders, and it is
  an indexer read. During an outage it throws, and the dangerous consumer pattern
  is to wrap it in a catch that returns an empty list: the bot then believes it
  has nothing resting, so it skips its cancel or quotes again on top of its own
  live orders. The SDK threw correctly, but offered no equally convenient chain
  read to point that consumer at.
  
  The chain already had every field. What a consumer could not do was join the two
  existing reads at one block, because `getOwnOpenOrdersOnchain` took no
  `blockNumber`, and a consumer-side join also cost one un-batched call per order.
  
  The new read does the join in two stages at one block: one direct id read per
  pool, then one batched `getOrder` for every id. The id read is never routed
  through Multicall3 — the pool's view answers for `msg.sender`, so through an
  aggregator it would return that contract's empty order set, which is silently
  wrong rather than a revert.
  
  Limits, stated in the TSDoc: the caller supplies the pool list, because
  resolving pools by symbol is itself an indexer read; rows carry `isBid` and no
  YES/NO side, which is not derivable on a binary pool; there is no market, symbol
  or history. `getOpenOrders` is unchanged and does not fall back to chain.
- Add `client.getSeedStatus(pool)`, which reports one watched pool's own seed
  status.
  
  `getLiveStatus()` carries one set of seed fields for the whole tail, and each
  seed overwrites the last. With several watched pools neither flag could be
  trusted: pool B's seed wrote `seedTruncated: false` over pool A's `true`, and
  `fillsLoaded` turned `true` as soon as any pool's fill lane landed, even when
  another pool's lane had failed.
  
  Both flags exist to prevent a silent wrong answer, and both were silent again
  the moment a second pool was watched. `seedTruncated` says a book is short at
  its deep end. `fillsLoaded: false` says a tape was never loaded, which is a
  different state from a tape that is genuinely empty (SDK-IO-002).
  
  `getSeedStatus(pool)` returns `{ seedSource, seedBlock, seedTruncated,
  fillsLoaded }` for that pool, or `null` when the pool has no hydrated watch.
  
  An entry is written at every site that hydrates a pool: the scoped indexer
  snapshot, the chain seed, the discover-all snapshot, the fill lane, and a pool
  discovered live through `MarketCreated`. That last one takes no seed at all —
  it is born inside the stream — and is recorded as complete from its creation
  block. A released watch drops its entry, and `stopLive` clears them all, so
  `null` always means what the accessor says it means.
  
  The global fields stay as they are and are now documented as LATEST SEED rather
  than an aggregate. Deriving them from the per-pool values would have changed
  what existing single-pool consumers read.
  
  N pools is N `watchMarket` calls, which already worked and is now tested and
  documented as the supported route. The TSDoc gives the read cost: `2 × maxPages`
  contract reads per pool in the worst case, 80 at the defaults, plus one module
  read for the market row.
- Add `exchange.perpDiscoverySkipped`, which reports that `loadMarkets` did not
  look for perp pools at all.
  
  `loadMarkets` finds the PerpPoolFactory either from a configured
  `addresses.perpPoolFactory`, or from the `marginBank` of an indexed perp row.
  With neither — a reachable indexer carrying no perp rows, and no configured
  address — discovery returned an empty perp set and every error accessor stayed
  `null`. Nothing failed, so nothing was reported, and a consumer could not tell
  "this venue has no perps" from "the SDK had no way to look".
  
  The new accessor is `{ reason: "no-factory-route" } | null`. It is a separate
  accessor rather than a variant of `perpDiscoveryError`, because that one is
  typed `SomniaMarketsError | null` and means something failed. The value resets
  on every load.
  
  It reports "the SDK had no way to look", so it is not set when a read failed.
  A failed registry read leaves it `null`, because an empty row set is then a
  symptom of that failure and `registryReadError` already names it. For one
  engine it is likewise never set together with `perpDiscoveryError`. On a
  multi-chain exchange both CAN be non-null at once, each reporting a different
  chain.
  
  It is informational, not an alert. A non-null value is normal and permanent on a
  venue with no perps plane, which is Somnia mainnet today and any local anvil
  deployment. The TSDoc says so.
  
  `config.ts` and `docs/reference/configuration.md` no longer describe
  `perpPoolFactory` as normally unnecessary without naming this consequence.
- Add `client.getPoolMarketBinding(pool)`, which reads from chain alone which
  market a binary pool currently hosts.
  
  The chain seed, `watchMarket(pool, { seed: "chain", marketId })`, needs the
  pool's current `marketId`. The ordinary source of that binding is the indexer,
  which is the service that is unreachable when a chain seed is needed. This read
  closes that gap: the pool names its market and the module maps that address to
  its id, so a caller can cold-start a live book with no indexer request.
  
  Both chain reads are made at one block, and the result carries it. The read
  resolves to `null` only when the pool's market does not belong to the module; a
  failed read throws. It verifies the pair before returning it, so a module record
  that disagrees with the pool throws instead of yielding a binding that would
  filter a book on the wrong market.
  
  `client.getMarketIdOnchain(marketAddress)` is the same reverse lookup for a
  caller who holds only the market address, such as a market page whose route
  carries the contract address. The indexer answers that question through
  `getBinaryMarketByAddress`; this read survives an outage.
  
  A chain-seeded `watchMarket` now also rejects a `marketId` that is not the
  pool's current one. It checks both ways an id can be wrong: the module records
  that market on a different pool, or the id names a PREVIOUS generation of this
  same pool. A pool address is stable across recycles, so only the market nonce
  separates the generations — this is the module's own staleness test. Either
  mistake previously painted a book that could never advance, with nothing to say
  so.
- Add provider-level chain seeding for selected binary pools and a React hook for
  each pool's seed status. Pool-watching hooks share the provider policy, so a
  binary book can start from chain while the indexer is unavailable regardless
  of hook mount order.
- The unified exchange identifies binary rows by market id, not by pool. A binary pool is recycled across successive markets, so `fetchMyTrades`, `fetchOrders`, `fetchOpenOrders` and `fetchBalance` used to label rows from an earlier market with the symbol of the market currently on the pool, and a ref-scoped `fetchMyTrades` or `fetchOrders` on a binary market returned every market that ever used its pool. Rows now resolve and scope by market id. `fetchTrades` is scoped the same way, so the public tape of a live binary market no longer includes the fills of earlier markets on its pool.
  
  A settled binary market a wallet still holds a position, a fill or an order in stays resolvable: `fetchBalance` reports the holding under that market's own symbol and `redeem` accepts the symbol, while `fetchMarkets()` keeps listing only tradable markets. A position on a market the indexer cannot name now makes `fetchBalance` throw `IndexerError` instead of silently omitting the key.
  
  BREAKING: pool-routed verbs (`createOrder`, `mintSet`, `burnSet`, `fetchOrderBook`, `fetchTrades`, `fetchOHLCV`, `fetchTicker`, `watchOrderBook`, `watchTrades`, `watchOrders`, `watchMyTrades`) throw `InvalidInputError` on a settled binary market, because its pool now serves the next market in the series. Redeem, balances and history remain available. A caller that resolved a settled market by pool address should resolve it by market id or by its symbol from `fetchBalance`.

### Patch Changes

- Document market-specific binary collateral decimals and use them in SDK order examples.
- Document binary event emitters and chain-specific oracle question links.
- Explain that binary pool addresses can host successive markets, and show market-scoped book and outcome-balance reads.
- Clarify that Hideki requires a consumer-operated `indexerUrl`, including for clients used only for chain reads and writes. No hosted indexer is provided. Unreachable indexer requests fail with `IndexerError`.
- Correct the `UnifiedTicker.lastSource` documentation and the read-tiers reference.
  
  `lastSource` is present on both paths that produce a `last`, not only on the
  candle path. The cached fallback stamps the market row's `lastTradeAt`, so the
  field is absent only when `last` is absent or that timestamp is null. The
  previous text described absence as the ordinary stale signal, which understated
  how narrow that case is. The `read-tiers.md` reference gains the same
  correction, a corrected `getOpenPositionsWithPnL` row, separate rows for
  `fetchPositions` and `fetchFundingRate`, and a new section that lists what
  still works during a main-indexer outage.
- Refresh the revert-decoding table (`contractErrorsAbi`) against the current perps
  contracts. Adds the thirteen market wind-down errors a caller can now hit —
  `MarketSettled`, `SettlementAlreadyScheduled`, `DelistEscrowActive`, `MarketNotEmpty`,
  `SettlementParametersNotConfigured` and the rest — which previously reached callers as
  opaque hex. It also drops twelve signatures no deployed contract can throw: eleven
  belong to the oracle-adapter surface removed by the Oracle v2 redeploy, and the last
  corrects `NotExpired(uint64)` to the `NotExpired()` the protocol actually declares.
- Document where a stop order's limit price is available. Perp stop rows from
  `listPerpStopOrders` don't include `limitPrice`; fetch it for live stops with
  `getPerpStopOrders`. A spot LIMIT stop's limit price can't be read back after
  placement, so `getSpotStopOrders` and `fetchOpenStopOrders` leave it out. Treat a
  missing limit price as unknown, never as zero. Documentation only; no behaviour
  changes.
- Count reads now send fixed, named GraphQL documents.
  
  The count methods (`countMarkets`, `countBinaryMarkets`, their `Bounded`
  variants, `countOrders`, `countUserFills`, `countOperators` and `countVenues`)
  no longer build their query text at call time. Each counted entity has one
  document for the `_aggregate` count and one for the bounded row-count fallback.
  A Hasura query allowlist can therefore list every query that the SDK sends. The
  method signatures, the `{ count, truncated }` result, the 10,000-row fallback
  cap and the fallback on roles without `_aggregate` do not change.
  
  When a count read fails, `IndexerError.operation` now names the entity's own
  document, such as `MarketCount` or `OrderCountFallback`. Before, it was the
  shared `Count` or `CountFallback` for an indexer failure, and `aggregateCount`
  for a response that did not contain the counted field. Update any code that
  compares `operation` with the old names.

## 0.31.0

### Minor Changes

- Add an ordered batched read for known perpetual stop orders. The read pins every Multicall3 chunk or fallback point read to one block, and preserves known absence and per-order contract or transport failures without discarding neighboring results.
- `BinaryMarketFilter` gains `resolved?: boolean` — narrow a binary-market list to
  markets with an indexed terminal event (`true`) or without one (`false`).
  Omitted, it does not constrain. It applies wherever the filter does: `listBinaryMarkets`,
  `listLiveBinaryMarkets`, `listPastBinaryMarkets`, `countBinaryMarkets`.
  
  It is a filter on `resolvedAtTimestamp`, not on `status`, and the distinction is
  the reason it exists. `status` is derived from lifecycle EVENTS, and the
  timestamp-implicit Listed → Trading → Settling transitions emit none: a market
  whose oracle never answers stays indexed as `"Trading"` for as long as it is
  stuck. Selecting unanswered markets by status therefore returns nothing in
  exactly the case a caller wants them — the query succeeds and the problem is
  invisible. Every terminal path (resolve or void, on the module event or the
  market contract's own) stamps `resolvedAtTimestamp`, so its absence is the
  durable "still waiting".
  
  `listPastBinaryMarkets({ resolved: false })` is the pair that names a stuck
  market: expired, and with no indexed terminal event yet.
  
  Note this says nothing about WHEN an unanswered market became late. The
  protocol's deadline is `expiry + settlementWindow`, and `settlementWindow` is
  frozen on each BinaryMarket clone at construction — read it from the market's
  own address, not from the creator's current series configuration, which a later
  `registerSeries` can change under markets that were already minted.
- Books now carry block provenance, and unified trades expose their source block and log position. Chain book reads pin both sides to one block. Live tails recover intervening log ranges and expose failures in status. Optional reconciliation compares native levels at fixed chain checkpoints without replacing order history.
  
  Fill and trade provenance fields are optional for caller-built values. SDK reads populate them from the source. Native historical fill blocks use decimal strings; unified trade blocks use bigint. Missing caller provenance remains undefined.
- BREAKING: Fill reads gain an inclusive block range, and a new read returns every fill of one market kind in that range across every pool. Custom `SomniaMarketsClient` implementations must add `getFillsPage` and `getLatestFillBlock`.
  
  `client.getFillsPage({ marketType, sinceBlock, toBlock })` returns the fills of one market kind in an inclusive block range, across every pool of that kind, oldest first by `(blockNumber, logIndex)`. This is the read behind a published trade feed: it takes no pool, so a consumer never enumerates them. Pages hold 500 rows by default and continue through `nextCursor`. The cursor binds the chain, the endpoint, the kind and both bounds, so a cursor from one range is refused by another. A full page always returns a cursor, because an indexer row limit truncates a page silently; a range that ends exactly on a page boundary therefore costs one empty final page.
  
  Prefer the block range to the `since`/`until` window in seconds whenever successive reads must tile the chain exactly. Several Somnia blocks can share a timestamp, so a seconds window drops or repeats the fills in them at every seam. Both block bounds include their block, and `(blockNumber, logIndex)` is a total order over the fill table, so `[a, b]` followed by `[b + 1, c]` reads every fill once.
  
  `client.getLatestFillBlock(marketType)` returns the newest block carrying an indexed fill of that kind, with the block timestamp, or `null` when none is indexed. It reads the fill data rather than the indexer's processed-block watermark, so it never names a block `getFillsPage` cannot yet serve.
  
  `FillsOptions.toBlock` bounds `getFills`, `getUserFills`, `countUserFills` and `getUserFillsPage` from above, alongside `sinceBlock`. Both bounds are inclusive and merge into one comparison. An inverted range rejects with `InvalidInputError` instead of answering with no rows.
  
  `Fill` rows now carry the market kind, and the indexer indexes `(marketType, blockNumber, logIndex)`, so the range read is one index seek and its rows arrive already ordered. Every amount field on `FillRow` stays as a raw decimal string.
- Add `trader.cancelAllOrders(pool)`, a kill switch that pulls every resting order the signer holds on one pool. It reads the resting ids and cancels them in one call, so a bot that holds no local ids — after a restart, or after a send with an unknown outcome — does not have to chain a read and a cancel itself. The default source is one chain read. Pass `{ source: "live" }` to take the ids from the live tail instead, which makes the cancel the only round trip; that source needs an active market watch on the pool and throws `NotConfiguredError` without one, because an unwatched tail cannot tell an empty book from an unseen one. An empty book is a success: the result is then `{ sent: false, orderIds: [], outcomes: [] }` and no transaction goes out. Branch on `sent`; a sent result carries the same per-id `outcomes` as `cancelOrders`. The two sources differ on an expired order that still rests. The pool's own open-orders view hides it from the chain source, so the default source leaves it on the book — use `cancelExpiredOrders` for that residue. The live source still lists it and forwards its id, and the cancel goes through: the pool gates a best-effort cancel on owner and id rather than on expiry, so the order is removed and reported `cancelled: true` (its removal emits `OrderExpired`).
  
  Add `trader.buildCancelOrders` and `trader.buildReduceOrders`, which return the unsigned call instead of sending it, so a batch cancel or reduce can ride in the same transaction as other writes. Both encode through the same path as the sending verbs, so the calldata cannot differ.
- BREAKING: `Candle` gains a required `lastFillId` field and `getCandles` now selects it.
  
  `lastFillId` is the id (`${blockNumber}_${logIndex}`, same key space as a fill's id) of the newest fill folded into the bucket — the exact fill-level watermark a live consumer needs to reconcile an open OHLCV bucket against the chain without a separate fills read.
  
  Migration:
  - Reindex first. `getCandles` selects `lastFillId`, so it errors against an indexer whose schema predates the field. Deploy the matching `indexer/schema.graphql` and run a full reindex before upgrading the SDK.
  - Constructing `Candle` values? Add `lastFillId` (a `${blockNumber}_${logIndex}` string). Read-only consumers just receive the new field.
- BREAKING: `BinaryMarket.strike` and `BinaryMarket.mode` are now nullable. A chain-seeded row cannot know either, and reporting a guessed `"0"`/`"reference"` would mislabel a fixed-strike market as up/down. Narrow both before use; a row read from the indexer always carries them.
  
  A market watch can now start without the indexer. `watchMarket(pool, { seed: "chain", marketId })` seeds the book from the pool contract at one pinned block, so a trading view paints while the indexer is unreachable. The option is opt-in: a `watchMarket(pool)` call with no options keeps the indexer snapshot and the existing meaning of `snapshotBlock`. Supply the market id the pool currently serves, because pools are recycled across markets and book reads filter on it.
  
  The seed reads that market's row from the BinaryMarketsModule alongside the book, so a chain-seeded watch needs `addresses.binaryModule` configured and covers exactly one pool. The row is what every later order and fill event attributes through. It carries what the module answers — collateral, decimals, nonce and lifecycle state — while indexer-only fields (the question text, the creation history, the traded volume and the origin attribution) stay empty. These do NOT fill in later: a pool that has been seeded is not re-hydrated, and the row survives `stop()`, so the seeded row persists for the store's lifetime. Read the indexer directly when the prose is needed.
  
  A chain seed never blanks a richer row. If the same pool was watched from the indexer before, that row's indexer-only fields survive the seed field by field, while every field the module answers is replaced by the fresher chain read.
  
  `BinaryMarket.strike` and `BinaryMarket.mode` are now `string | null` and `BinaryResolutionMode | null`. A chain-seeded row carries null for both, because the pool contract answers neither and a strike of `0` is the protocol's sentinel for reference (up/down) mode — writing it would permanently mislabel a fixed-strike market. `boundaryPrice` returns `null` for such a row, which every surface already renders the same way it renders an unposted opening answer. An indexer-sourced row is unchanged and always carries both. Seeded orders likewise carry an empty `createdAt`/`txHash`, so they sort last in `userOrders` until a snapshot dates them.
  
  `getLiveStatus()` reports four new fields. `seedSource` says whether the book came from the indexer or the chain, and is `null` before the first watch. `seedBlock` is the block the seed was consistent to. `seedTruncated` is true only when a chain seed reaches its own page budget and read a shallow book. `fillsLoaded` says whether fill history was loaded, which a chain seed does not load: `getLiveFills` returning an empty array now means "never loaded" when `fillsLoaded` is false, and "no fills in the loaded range" when it is true. A failed fill read reports `failure.operation` as `"fill-history"` and leaves the chain-seeded book intact.
  
  `getAllOpenOrdersOnchain` accepts `blockNumber`, so a multi-page snapshot of one side can pin every page to one block.
- Add `client.getBookTopsOnchain(markets, opts?)` and give `fetchTicker` provenance fields.
  
  `getBookTopsOnchain` reads the top of book for many markets from pool state, at one pinned block. It is the chain-sourced sibling of `getBookTops`, which keeps its signature and its indexed source. Use the new read where a quote must not lag behind the indexer, such as a market board or a mark for open positions. Pass the market rows a view already holds; each supplies its own `poolAddress`. The result carries the block every top was read at, plus a map keyed by lowercased market id. A market with an empty book is absent, the same contract the indexed read documents, so the two are comparable key for key. Where the chain declares a Multicall3 the reads collapse into one aggregate call; elsewhere they fan out at two reads per market, so keep the market set to what a view shows.
  
  `UnifiedTicker` gains three fields. `lastSource` carries the time of the trade behind `last`, and its absence now means `last` fell back to cached market metadata that can be stale. `statsAsOf` carries the 24h statistics' own as-of, derived from the candle window rather than the response time; `timestamp` is unchanged and remains the time the snapshot was assembled. `perpState` reports one of `"not-applicable"`, `"ok"`, or `"degraded"`, so a missing `markPrice` on a perp whose chain read failed is no longer indistinguishable from a spot market. The perp enrichment still degrades to the 24h statistics rather than failing the whole call.
  
  `getOpenPositionsWithPnL` now marks positions from the chain-sourced tops. It previously marked from indexed tops while `getBinaryPositionPnL` marked from chain, so the same position could report two different `unrealizedPnl` values when the indexer lagged. A client configured without chain access still marks on last price, as before.
- Retain collateral balance read failures by chain in `CollateralOnWrongChainError.readErrors` and its aggregate cause. Inspect `exchange.collateralReadErrors` after a successful funding operation to detect an incomplete sweep. Sort `unread` in the error constructor, including for directly constructed errors.
- Add `CollateralOnWrongChainError.unread` to list chains whose balance read failed. The error message also names these chains, so callers can distinguish failed reads from zero balances.
  
  `elsewhere` keeps successfully read balances. `unread` is empty when all reads succeed.
- BREAKING: `FillRow` gains two required fields, `takerRemainingQuantity` and
  `makerRemainingQuantity`.
  
  `getFills` and `getUserFills` now return the post-fill remainders — the two
  raw-unit amounts the `OrderFilled` event carries. A caller can now tell how much
  of each order was left after a fill directly from the tape, without a second
  read and without keeping a running total. `getFill`, `getOrderFills` and
  `getTradeContext` already selected them, so every fill read is now consistent.
  
  Migration: code that READS a fill row needs no change. Code that CONSTRUCTS one
  — a test fixture, a mock, or a synthetic row passed to `pnlEventsFor` or
  `binaryFillsFor` — must add the two fields. Both are decimal strings in raw
  base/outcome units, and `"0"` means the fill completed that side's order.
- Populate the Hideki (50383) deployment manifest with the live CLOB family, deployed at the same CREATE3 addresses as testnet and mainnet. The `hideki-testnet` deployment resolves real addresses instead of the sentinel-zero template; the CollateralRouter stays absent on Hideki (no wSTT/Permit2 there), so router-dependent helpers keep reporting not-configured on that chain.
- BREAKING: `hidekiTestnet` (50383) now points at the **v2 regenesis**, a different genesis reached through different endpoints. Chain id `50383` has changed meaning: anything you cached, indexed or hard-coded against Hideki before 2026-09-17 describes a chain this definition no longer reaches.
  
  **Why it is breaking even though the id is unchanged.** Hideki was rebuilt on 2026-09-17 on the SAME chain id with fresh state, and the deployer's nonces restarted at genesis. So an address is no longer a stable identifier across the two chains: most v1 addresses hold a *different* contract on v2, while a few — the whole binary/CLOB family, because it is deployed via CREATE3, and the bridged USDso synthetic — come back at the *same* address. That mixture is the hazard. A stale cache does not fail loudly; it silently answers with the other chain's history.
  
  **Migration.**
  
  - **To move to v2 (recommended):** take the new definition and **discard every piece of Hideki state you hold** — indexer databases, address caches, block-height watermarks, saved deployment manifests. Do not reconcile v1 addresses against v2 ones, and do not carry a v1 start block across: v1 is past 586M blocks while v2 is near 2.4M, so a v1 watermark parks you at a head v2 will not reach for a long time.
  - **To stay on v1:** pin `@somnia-chain/markets-sdk` to the previous minor, or construct the chain yourself with viem's `defineChain` using the v1 host `api.hideki.infra.testnet.somnia.network` and Multicall3 `0x540B091b608f54E603c5dC19F6b2d955e1d2D131`. Note v1's `/ws` already answers 502, so a websocket consumer is already broken there.
  - **Tell the two apart by height, never by chain id** — nothing in a signature, a wallet or this definition distinguishes them.
  
  **What changed in the definition.** The `api.hideki2…` HTTP and websocket endpoints, and the **canonical** Multicall3 at `0xcA11bde0…`, which v2 predeploys at block 1 — the reverse of v1, where the canonical route was unreachable and the bridge infra deployed the same runtime at `0x540B091b…` (which holds no code on v2). `net_version` moved `50000258` → `50000259` and still is not the chain id.
  
  **The bridge lane was redeployed with it.** All five **Shannon-side collateral routers** moved (STT, USDso, WBTC, WETH, HBTT); everything on the Hideki side — every synthetic and the whole core deployment (mailbox, ISM, merkle hook, validator announce, ICA router, proxy admin) — came back at its v1 address, because the same deployer replayed its sequence onto a chain whose nonces had restarted. All ten routers were re-read on chain and enroll their counterpart in both directions. Relayer delivery on v2 is not yet proven.
  
  **Scope.** This injects the **perps** plane only. The binary/CLOB family was the other half of the problem when this branch was cut — every one of its addresses was codeless on v2 — and that is now **resolved on `main` by TRAD-363**, which replayed the CREATE3 salt generations so the family came back at the same shared addresses; nothing here overlaps it. Spot is the remaining gap and it is a gap in coverage, not a stale record: dreamDEX spot **is** live on v2 as of 2026-09-18, but `spot-tokens.hideki-testnet-development.json` is still the empty sentinel it has always been, so those markets are simply not indexed yet. That is left for the plane's owners rather than guessed at here.
- Add `getOrderHistoryPage` as the preferred name for paginated indexed order history while retaining `getOrdersPage` as a compatibility name.
- Add opt-in indexer freshness and response-associated read observations through `client.getIndexerFreshness()` and `client.createObservedReads()`. The existing `getSyncStatus()` remains metadata-only and `fetchStatus()` remains local-only; `fetchDataStatus()` combines independent data-source measurements.
  
  Add precise per-asset delivery health through `getPriceHealth()` and `listPriceHealth()`, alongside unchanged legacy price status. Add `useWatchMarketResult`, `useWatchUserResult`, and `useWatchPriceResult` for typed acquisition errors while existing hooks retain their background warning behavior.
- `listLiquidations` returns liquidation events with an optional `kind` filter, applied server-side.
  
  It replaces `getLiquidations`, which returned an array under a `get*` name. The old name forwards the filters verbatim and is deprecated, the same one-release compatibility path `getFundingRateHistory` took.
  
  Liquidation rows now come back in a stable total order: `timestamp`, then `blockNumber`, then the `id` primary key.
  
  Previously the query sorted by `timestamp` alone. Every row a block emits carries that block's timestamp, so ties had no defined order between requests and an `offset` page boundary landing inside one block could skip a row or repeat it — silently, in the skip case, and even against a table nobody was writing to.
  
  This fixes the tie ordering. It does not make offset paging snapshot-consistent: these are live rows, so an event indexed ahead of your offset still repeats a row and a reorg that removes one still skips a row. Paging the read is approximate; narrow with `account`, `pool` and `kind` when it must not be.
  
  This changes the order of rows that tie, so a caller that depended on the previous arbitrary tie order sees a different sequence. No row is added or removed.
  
  `LiquidationEvent` rows are stages of one mechanism, not repetitions of one event. A single cascade therefore emits many rows. Without the filter, a page can be consumed entirely by one account's summary and per-position legs, and a caller looking for a different kind concludes there were none.
  
  `kind` is matched exactly, in the schema's own casing.
  
  It is typed as `LiquidationKind`, a new exported union over the `LIQUIDATION_KIND` values. A misspelling is now a compile error rather than a silently empty page.
  
  `LiquidationEvent.kind` stays a `string`: a row carries whatever the deployed indexer wrote, which can outrun a pinned SDK.
  
  The exported `isLiquidationKind` guard bridges the two — narrow a row's kind to switch on it, or to pass it back into the filter.
  
  `getPerpFeedStatus` reads whether a perp pool's mark feed is live and how much open interest is riding on it.
  
  Prefer it to `getPerpState` for MONITORING a feed. `getPerpState` batches with `allowFailure: false` and one of its legs is a bare `IOracle.getPrice()`, so a dead or rotated oracle rejects the whole read — losing the mark verdict in the one case it matters most. `getPerpFeedStatus` reads the mark verdict and the open interest as a required pair that no oracle can revert, and takes the index timestamp best effort: a reverting index leg leaves `indexUpdatedAt` undefined instead of failing the call.
  
  It returns `PerpFeedStatus` and throws `SomniaMarketsClientGetPerpFeedStatusError`. Read `getPerpState` instead when you want the full pricing and funding picture and a dead oracle is a legitimate reason to fail.
  
  `listRegistryMarketsChecked` is `listRegistryMarkets` with the rows the SDK could not parse counted, as `{ markets, dropped }`.
  
  A malformed row is dropped rather than failing the page, so the plain read's length cannot tell "no markets" from "markets this SDK could not parse". `dropped > 0` says the registry holds markets the sweep did not return. Use it wherever absence from the sweep is treated as proof - a monitor publishing coverage over it, say. It returns `RegistrySweep` and throws `SomniaMarketsClientListRegistryMarketsCheckedError`.
  
  The two new options (`kind`, and `offset` on the new name) are additive. The ordering change is not: it is the one thing in this release an existing `getLiquidations` caller can observe, and only for rows that share a timestamp.
- BREAKING: Order and fill reads gain the filters a client needs to find its own work, and the client can wait for the indexer to catch up. Consumers that construct `OpenOrder` or `OrderRow` values must add `userData`. Custom `SomniaMarketsClient` implementations must add `waitForIndexedBlock`.
  
  `getOpenOrders` and `getOrders` now return each row's `userData`, the opaque uint64 tag the placement carried, as a decimal string. `OrdersOptions.userData` filters on it, so a client correlates rows with the orders it sent instead of keeping its own map.
  
  `OrdersOptions.market`, `OrdersOptions.pools` and `OrdersOptions.markets` scope one read to a market, or to several venues or markets at once, so the order scope now mirrors the fill scope in both forms. An empty array matches nothing. Supplying a singular scope with a list intersects both constraints. A list is capped at 100 entries to bound the generated GraphQL predicate; pool entries also require classification reads.
  
  Fill `market`, `markets` and `pool` identifiers must be non-empty strings. Invalid runtime values now reject with `InvalidInputError` instead of reaching the indexer or leaking a JavaScript type error.
  
  `getOpenOrders`, `getOrders` and `countOrders` now require a non-empty owner string before normalization. `getUserFills` and `countUserFills` require a non-empty account string. `getOrderFills` also requires a non-negative bigint or canonical unsigned decimal order id. Invalid runtime inputs reject with `InvalidInputError` before network I/O.
  
  `SomniaMarketsClientGetOrderFillsError` exports the complete `InvalidInputError | IndexerError` contract for the one-order fill lookup.
  
  `sinceBlock` bounds order and fill reads by block. On fills it bounds `Fill.blockNumber`, which is the exact resume point after processing up to a known block; a bound in seconds cannot be exact, because blocks share timestamps. On orders it bounds the PLACEMENT block. It does not report a later amend, fill or cancel of an order placed before the bound.
  
  `client.waitForIndexedBlock(chainId, block, opts)` resolves once the indexer has processed a block, reporting the wait and the lag it started from. It rejects with the new `IndexerWaitTimeoutError` when the deadline passes, which is a different decision from a failed read. The wait ends when the client's configured signal, its own `signal`, or the owning client's live-read lifecycle closes. A later wait relinks to the replacement owner signal.
  
  Indexer request deadlines remain enforced while a successful HTTP response body is being parsed. Caller cancellation still keeps its exact reason; an SDK-owned deadline rejects with `IndexerError`.
  
  None of these columns is indexed. Pair a tag or block bound with a pool scope and a status to keep the scan small.
- Write the perp linked-wallet rail, not just read it: `proposePerpWalletLink` /
  `acceptPerpWalletLink` / `cancelPerpWalletLinkProposal` / `unlinkPerpWallet` for the
  two-sided consent handshake, and `repayPerpMainFunding` / `recallPerpMainFunding`
  for both routes home. Isolated margin on perps is one wallet per position, and
  linking a sub-account was previously reachable only through raw viem.
- Size a linked child's perp order off both funding wallets, the way the pool funds it.
  `previewPerpOrderMargin` and `getMaxPerpOrderSize` under `autoPull` now resolve the
  funding payer and add the main's wallet capacity as a second leg, judge a partial pull
  by the margin gates rather than requiring full top-up coverage, and withhold the main's
  leg while the child still owes a prior payer. New `fundingPayer`, `mainWalletCapacity`,
  `ownWalletPull`, `mainWalletPull` and `mainFundingBlocked` fields, plus `"mainWallet"`
  and `"mainFundingBlocked"` on `limitedBy`. `topUpRequired` is the total requested, not
  one wallet's debit.
- Add a watchable perp plane, so liquidations and funding settlement can be
  streamed. `client.watchPerpMarkets()` hydrates the recent liquidation waterfall
  legs and per-account funding settlements from the indexer once, backfills the
  seam from chain, then streams both from MarginBank and LiquidationEngine —
  the same snapshot-then-live guarantee `watchMarket` gives a book, and a
  reconnect heals from chain with no second indexer touch. Read them with
  `listLiveLiquidations` / `listLiveFundingPayments`, or the `useLiveLiquidations` /
  `useLiveFundingPayments` / `useWatchPerpMarkets` hooks.
  
  Rows are the same `LiquidationEvent` and `FundingPayment` types the one-shot
  reads serve, in the same order, so a live page and a paged history merge by
  `id`. They overlap, because the live window is hydrated from the newest indexed
  rows.
  All seventeen waterfall kinds are produced.
  
  One scope covers the whole venue rather than one per pool: both sources are
  singletons, and the account-level legs carry no pool, so filtering is at read
  time. `getPerpPlaneStatus()` reports the watch state, and `TailStatus.watchCount`
  now counts the plane as one scope so a plane-only session no longer reports
  itself idle while streaming. On a deployment with no plane — no perp market
  indexed, or a MarginBank naming no liquidation engine — the scope registers,
  answers empty and reports `"live"` rather than waiting forever on a stream that
  has no source.
  
  The indexed `getFundingPayments` now orders by `(timestamp, id)` rather than by
  timestamp alone, matching what `listLiquidations` already does for liquidations
  with `(timestamp, blockNumber, id)`. Settlements share a block timestamp, so
  without a tie-break which rows a `limit` kept at a page boundary was undefined
  and the live and indexed halves of the same feed could disagree about which rows
  exist. The live store and the plane hydration sort on each row type's own total
  key, so all three paths page identically.
  
  React: `useWatchPerpMarkets(opts?)` takes one options object (`enabled`,
  `marginBank`), the two read hooks take the same options object as their client
  members, and `useWatchPerpMarketsResult` exposes the typed acquisition failure
  that the warning-only hooks otherwise turn into an empty feed.
  
  `TailStatus.watchCount` also counts a discovery-only `watchAllMarkets(true)` as
  one scope, and a socket drop on such a session is now healed: the reconnect
  guard and its recovery set were built from pools alone, so a venue with no
  market yet held the factory socket, reported an idle tail, and never backfilled
  a `MarketCreated` missed during the outage. A released all-markets watch now
  stays counted and healed through its release linger too.
  
  A block whose only logs are ones the tail does not fold (such as MarginBank's
  `PositionUpdated` or `FeeCharged`) no longer wakes store subscribers.
  
  After `stopLive()` the plane's last rows stay readable, like every other live
  read; `getPerpPlaneStatus()` then reports `"unwatched"`.
- `getPerpPositionAnalytics` and `listPerpPositionAnalytics` now return the position's signed `size` on an unpriceable row, alongside `pool` and `asOfBlock`. Only the pool's price read fails in that case; the MarginBank position read succeeds at the same block, so the size was already known and was being discarded. A caller could not tell a position nobody can price from no position at all, and the bank's `activePerpPools` list covers pools the account has orders in as well as pools it holds a position in — so an order-only pool with a stale mark feed arrived indistinguishable from a real position and rendered as an empty row. Filter on `size !== 0n` to drop those.
- `getOrderOnchain` accepts a trailing options object with `blockNumber`, so an order
  can be read as of the end of a past block rather than only at head.
  `SomniaMarketsClientGetOrderOnchainError` is exported alongside it.
  
  This recovers a maker order a fill already removed. A fill deletes its maker order in
  the same transaction, and `OrderFilled` names the maker only by id, so at head that
  order is gone; `fill.blockNumber - 1n` is the last height holding it. What comes back
  exactly is the maker's identity - `isBid`, `owner`, `userData`, `expireTimestampNs` -
  which no fill log carries and a consumer cannot otherwise resolve for an order that
  rested before its process started. Everything else is already in the event:
  `fillPrice` (a fill executes at the maker's resting price, so this IS the maker's
  price), `quantityFilled` and `makerRemainingQuantity`.
  
  Both quantities are block-level, not per-fill. A block read cannot see between
  transactions, and an earlier transaction in the fill's own block can move either one:
  a fill lowers `quantityRemaining`, and `reduceOrder` lowers `fullQuantity` and
  `quantityRemaining` together under the same id.
  
  Two cases answer plausibly rather than failing, so both are documented on the option:
  a partial fill leaves the maker order in place with a smaller `quantityRemaining`, so
  a read at the fill's own block succeeds with different numbers; and an order placed
  and filled inside one block is absent one block earlier, where the placement is in
  that same block's `OrderPlaced`.
  
  A pinned read far behind head needs a node with archive state, so this suits a live
  tape rather than a backfill. A negative block is rejected with `InvalidInputError`.
- `getVaultBalance` accepts an optional `blockNumber`, so a vault balance can be read
  as of a past block rather than only at head.
  
  A caller answering one request from several reads needs them all at one height. The
  withdrawable balance is contract state behind an append-only payout history, so no
  indexed entity reconstructs it at a past height, and serving this half at head mixed
  heights inside one response. A pinned read far behind head needs a node with archive
  state; a recent block answers against a full node.
- BREAKING — `pnlEventsFor` now rejects rows carrying a malformed number
  
  `pnlEventsFor` reads each row's `id` for the first time, to order events by chain
  position, and throws `InvalidInputError` when it cannot: an `id` that is not
  `${blockNumber}_${logIndex}`.
  
  Previously `id` was not read at all and ordering used the timestamp alone, so a
  malformed `id` was simply ignored, leaving that row wherever the input happened to put
  it. It now has to be well-formed, because a row that cannot be placed in chain order
  cannot be folded into an average-cost roll without silently changing the result.
  
  The same check now covers every number the fold reads: `FillRow.quantity`,
  `FillRow.fillPrice`, and a router action's `amount`. These went through a bare `BigInt`
  before, which threw a native `SyntaxError` on `"abc"` — an error outside the documented
  error union of this function and of both client methods that wrap it, so a corrupt row
  escaped as something no consumer was told to catch.
  
  **Migration:** rows from `getUserFills` and the router-action reads already carry
  well-formed ids, so callers passing indexed data through are unaffected. A caller
  assembling its own `FillRow`/`RouterActionRecord` values — test fixtures especially —
  must set `id` to `` `${blockNumber}_${logIndex}` ``, and must give `quantity`,
  `fillPrice` and `amount` as unsigned decimal strings.
  
  EVERY one of those positions is now checked the same way: a sign (`"+1"`, `"-1"`), an
  `0x`/`0o`/`0b` prefix (`"0x10"`), surrounding whitespace (`" 7 "`), or an empty string is
  REJECTED rather than coerced. `BigInt` accepted all of them, so a fixture that happened
  to use any of those forms parsed before and throws now — normalize those fields to plain
  decimal digits. `timestamp` is no longer read at all, so its value cannot affect the
  fold. The new `PnlEventsForError` export names the error union.
  
  `getBinaryPositionPnL` and `getOpenPositionsWithPnL` fetch their own rows, so the same
  failure means the INDEXED data is corrupt rather than that the call was wrong. Those
  two report it as `IndexerError` — the retry-or-escalate path — with the parse failure
  on `cause`. A consumer catching
  `InvalidInputError` around either method to surface "bad arguments" must add an
  `IndexerError` arm. `SomniaMarketsClientGetBinaryPositionPnLError` is now exported and
  names that method's full error union.
  
  ### Fixed — same-second PnL events are folded in chain order
  
  `pnlEventsFor` ordered events by their unix-second timestamp alone. Fills are fetched
  newest-first and the sort is stable, so two events sharing a second stayed in reverse
  chain order: a buy and a sell in the same second were folded sell-first, the sale was
  clamped against a leg the buy had not yet opened, and the realized profit disappeared.
  
  Events are now ordered by the (block, log) position their ids already encode, as exact
  integers, and by nothing else. The timestamp is metadata rather than a chain position:
  leading with it lets a wrong one override the position it disagrees with, and it can
  never break a tie the position cannot, since a chain's timestamps are monotonic in block
  height.
  
  Figures change only where that ordering actually mattered: an account with two events
  whose result depends on which came first — a buy and a sell of the same outcome, or a
  mint and a sell of what it created. Two buys, or events on independent legs, or input
  that already happened to be oldest-first, are unaffected.
  
  
  
  ### Fixed — closing an outcome leg now releases all of its cost basis
  
  `computePositionPnL` released cost basis through a rounded average cost, so closing
  a leg completely could leave a sliver of cost behind on a leg that held nothing. A
  leg of 7 raw outcome-token units costing 5 raw collateral units has an average that
  does not divide evenly, and a full sale released only 4 of those 5 units.
  
  Two consequences, both small per trade and neither self-correcting:
  
  - The realized PnL of that sale was overstated by the unreleased remainder.
  - The remainder stayed on the leg after its quantity reached zero. Because average
    cost is total cost divided by quantity, anything bought on that leg afterwards
    inherited the leftover, so its average cost read high for the rest of the
    position's life.
  
  A sale that takes a leg's whole quantity now releases the leg's whole remaining
  cost, leaving it genuinely empty. Partial sales are unchanged and still release
  cost proportionally through the floored average.
  
  The size of a single correction scales with the position: the released cost was
  short by up to one raw unit per whole outcome token on the leg. A five-token close
  was off by units, a five-thousand-token close by thousands of them - a few
  thousandths of a collateral unit at six decimals - and a five-million-token close
  by around five whole units. Corrections accumulate across repeated full closes of
  the same outcome, so an account that has opened and closed a leg many times sees
  the largest movement, and any figure cached from a previous version may differ by
  that accumulated drift.
- BREAKING: a live price watch now requires `priceFeed.quote`. Without it `watchPrice` rejects
  with a `NotConfiguredError` naming the key. To migrate, pin the feed to one quote -
  `priceFeed: { url, quote: "USDC" }`, which is what `SOMNIA_TESTNET_PRICE_FEED` and
  `SOMNIA_MAINNET_PRICE_FEED` already do, so configurations built from those constants need no
  change. The one-shot reads (snapshot, history, candles, batch prices, feed catalog) are
  unaffected and still work without a quote.
  
  The reason is the tick tape below: it now streams on a `requestId` cursor, and one upstream
  request prices every symbol in a tick, so that id is unique within a single feed but not
  across a base that spans several quotes. Such a stream can carry two rows sharing an id, and
  a batch boundary falling between them would skip the second permanently. An unpinned base was
  already double-counting on this path; refusing it is the alternative to silently losing ticks.
  
  The live price tape now streams only the ticks recorded since the last one delivered. It
  subscribed with a fixed window and re-sent that whole window on every push, so a watcher paid
  for one hundred rows to learn about one new tick. The tape is now a Hasura stream cursored on
  `requestId`, seeded from the newest tick in the hydration snapshot and advanced on every push,
  so a reconnect resumes exactly where it stopped with no gap and no re-delivery. The current
  price feed is unchanged: it is full-state, and re-subscribing is all it needs.
  
  The cursor is carried as an exact decimal string and compared as a `bigint`. A `requestId`
  past 2^53 would otherwise round when it went through a JavaScript number, and a cursor that
  rounds up skips the tick it lands past. A row whose `requestId` is unusable can no longer
  corrupt the cursor; it is ignored, and the rest of the batch still advances it.
  
  `PricePoint.raw.price` and `PricePoint.raw.ema` now report `"0"` for a tick the feed recorded
  with no price, matching the `price` and `ema` numbers beside them. They previously reported
  `null` despite being typed as strings.
  
  A price subscription the server rejects is now reported instead of passing unnoticed. Only a
  dropped socket was surfaced before, so a rejected subscription left the tape silent while the
  feed still read as connected.
  
  Rows arriving from the feed are checked against the fields the parsers actually read. A row
  short of the selection set, or carrying a non-string where a price belongs, is skipped rather
  than parsed into a malformed tick.
- Two new root exports align a raw on-chain value onto a venue's grid: `snapRawToGrid(raw, step, direction)` and `roundPriceToTick(price, tickSize, direction)`. Both take and return `bigint`.
  
  They serve the engine path. A caller who holds raw values and calls `Trader.placeOrder` directly had no exported helper for this, so each one re-derived tick and lot rounding by hand. The unified facade already snaps for itself, so `exchange.createOrder` callers do not need these.
  
  `direction` is `"down"` or `"up"` and belongs to the value, not to the order side. A resting limit rounds toward the caller. A crossing limit rounds away so it still sweeps the levels it was priced against. Pass the direction you mean.
  
  Use `roundPriceToTick` for a price. It never returns `0`, which is the one price the pool always rejects: a positive price below one tick comes back as one whole tick. `snapRawToGrid` floors a quantity to `0` instead, because an empty size is a real answer the caller must branch on.
  
  Both throw `InvalidInputError` on a non-positive step. Neither checks `minQuantity`, which the pool tests before lot alignment.
- BREAKING: four reads no longer report a failed read as an absent or a zero value. A read that did not complete now throws `RpcError`. Each read keeps its own documented `null` for a real absence, and that meaning is now narrow and stated per read below.
  
  - `getClosingPrice` returns `null` only for a pool that does not declare `closingPrice()`. A transport failure throws `RpcError`. A revert that names an error or a reason throws `ContractRevertError`. Migration: keep treating `null` as "this pool predates the capture surface", and handle a thrown error as a failed read.
  - `tryGetPerpLeverageImSurcharge` returns `null` only for a revert from the view itself, which is the case it is named for. A read that never reached the chain throws, and so does a `marginBank` address that does not declare the view. Migration: in a health sweep, catch the error per account if the sweep must continue, and report that account as unknown rather than as having no surcharge.
  - `getSystemInfo` throws when any configured contract read fails. The one exception is `settlement()`: a pre-v2 module does not declare that view, so its absence still reports `settlement: null` and the configured fallback. It no longer resolves with `marketCount: 0n`, `operatorId: 0`, and a zero `venueId`, which a dashboard could not tell apart from a freshly deployed creator. Five leaf fields are no longer nullable: `MarketCreatorInfo.owner`, `SystemInfo.oracle.owner`, `SystemInfo.oracle.binaryModule`, `SystemInfo.usdc.symbol`, and `SystemInfo.usdc.decimals`. Migration: handle a thrown error where the call is made. Keep the checks on the `marketCreator`, `oracle`, and `usdc` objects themselves, because each is still `null` when its contract is not configured; only the checks on the five leaf fields inside them can go.
  - `getTransactionSummary` returns `null` for a malformed hash and for a transaction the node reports as not found. A read that did not complete throws. Migration: if the previous best-effort behavior is what you want, add `.catch(() => null)` at the call site.
  
  `PriceFeedStatus` gains an `"error"` member. It means the price-feed server rejected or terminated that asset's subscription. This was previously dropped in silence: the status stayed `"live"` while the price froze. Reads keep answering with the last values they hold, so check the status before rendering a price. The rejection is also emitted as a `warn` event on the client's debug channel. The subscription is torn down, so stopping the watch and starting a new one re-subscribes from a fresh snapshot. Migration: handle `"error"` wherever you switch on `getPriceStatus` or `useWatchPrice`.
- BREAKING: `loadMarkets()` no longer throws when the market registry read fails. It contains that failure and reports it through the new `registryReadError` accessor.
  
  An unreadable indexer used to end the whole load. That also ended perp discovery, which reads the PerpPoolFactory and needs no indexer once a factory is configured. An empty indexer was worse: it returned an empty market list that no caller could tell apart from a venue with no markets.
  
  Both cases are now answerable. `registryReadError` is `null` when the read completed and holds the typed error when it did not — in practice an `IndexerError` carrying `operation` and `detail`, with the underlying failure in `cause`. So `registryReadError === null` together with an empty `symbols` is the unambiguous "this venue has no markets". The value is cleared on every load that runs, so it always describes the last one. Retry with `loadMarkets(true)`, because a bare `loadMarkets()` early-returns the cached registry.
  
  Migration: replace a `try`/`catch` around `loadMarkets()` with a `registryReadError` check after it.
  
  ```ts
  await exchange.loadMarkets();
  if (exchange.registryReadError) throw exchange.registryReadError;
  ```
  
  Discovery coverage per market kind is now stated on `loadMarkets`. Perp is chain-complete and survives an empty or unreachable indexer, but with no indexed perp row the factory is found only from a configured `addresses.perpPoolFactory`. Binary and spot still come from the indexer in this method; new binary markets are discovered from the chain by `watchMarkets({ discover: true })`.
- BREAKING: `IndexedOperator` and `IndexedVenue` gain a required `updatedAtBlock`
  field. Reading rows is unaffected — `listOperators`, `getOperator`, `listVenues`
  and `getVenue` simply return more than before. But if you CONSTRUCT either type
  anywhere (a test fixture, a mock, an adapter that builds rows by hand), that
  object literal no longer compiles:
  
  ```
  error TS2741: Property 'updatedAtBlock' is missing in type '{ … }'
    but required in type 'IndexedOperator'.
  ```
  
  To migrate, add the field to each literal you own. It is a `bigint`, so
  `updatedAtBlock: 0n` is enough where the value is not meaningful to your test.
  
  Reach for it when you need to tell two writes apart. Somnia blocks are ~100ms,
  so roughly ten consecutive blocks carry the same `updatedAtTimestamp` — a value
  that changes and changes back inside one second leaves the timestamp unmoved.
  The block number moves whenever a tracked write lands in a later block, so it is
  the field to difference against when "was this row written?" matters more than
  "when, roughly?".
  
  **It tracks CONTROL-PLANE writes, not every write to the row.** The indexer
  stamps it on the registry events and deliberately not on denormalized rollups,
  so market counts, cumulative volume and collected fees rewrite the row with this
  field unchanged. Without that exclusion ordinary trading would move it
  constantly. The consequence for you: an unmoved `updatedAtBlock` means no
  tracked control-plane event landed, NOT that the row was never written.
  
  **The two rows move on different events, and the sets are not the same.**
  `IndexedOperator.updatedAtBlock` moves on `OperatorRegistered`,
  `OperatorUpdated`, `OperatorEnabledChanged`, the three ownership-transfer events,
  and `VenueCreated` — that last one because creating a venue bumps the parent
  operator's `venueCount`. `IndexedVenue.updatedAtBlock` moves on `VenueCreated`,
  `VenueUpdated` and `VenueEnabledChanged`, and on no operator event at all: an
  ownership transfer leaves every venue marker where it was. Do not difference a
  venue marker expecting to see one.
  
  **It distinguishes updates across blocks, and nothing narrower.** It is not a
  write counter and does not identify a write: two updates in the SAME block carry
  the same number, so differencing this field sees them as one. If you must not
  miss a write within a single block, read the events rather than this column.
  
  It is typed `bigint`, not the raw decimal string its neighbouring columns use,
  because a block number is a protocol integer and `SDK-TYPE-007` requires those
  to be exact at the boundary — the generated wire string is now mapped to
  `bigint` behind the four reads above rather than handed through. The strings
  beside it predate that seam and are unchanged.
  
  A row whose `updatedAtBlock` is not an unsigned decimal integer now fails those
  four reads with `IndexerError`, naming the GraphQL document, rather than
  escaping the SDK as a native `SyntaxError` a caller narrowing on `IndexerError`
  could not catch. The shape is checked before conversion rather than relying on
  `BigInt` to throw, because `BigInt` accepts more than the wire format allows:
  `""` and whitespace become `0n`, `"0x10"` becomes 16, and a leading minus is
  taken as a sign. Those are the malformed values likeliest to arrive, and every
  one of them would otherwise have surfaced as a plausible block number.
  
  **This rejects the whole read, not the row.** A single malformed
  `updatedAtBlock` fails `listOperators` / `getOperator` / `listVenues` /
  `getVenue` outright; there is no partial result that skips the bad row.
- BREAKING: `UnsignedCall` now carries `chainId` (and `functionName`). Pluggable transaction signing: the executor seam.
  
  Migration: every `build*` verb already stamps it, so a consumer that only READS unsigned calls changes nothing. Code that CONSTRUCTS an `UnsignedCall` itself (a custom relayer or bundler feeding calls to an executor, a test fixture pinning the shape) must add `chainId: <the chain the call runs on>`; every built-in executor refuses a call without a matching one. `functionName` is optional on a hand-built call (it only feeds error messages) and is set on every call the SDK builds.
  
  `createTrader({ executor })` takes any `Executor` — who signs and sends, separated from what the write is. The built-ins come from `client.createExecutor(spec)`: a private key or local account, a wallet client, a Somnia session seed, or a safe driven by any inner executor (with optional `MultiSend` batching, so an approval and the order it enables land in one safe transaction). Any object implementing `Executor` works too.
  
  - The writer hands an executor one or more `UnsignedCall`s and decodes the result from the receipt the executor returns; it never fetches a receipt it already has. A safe's swallowed revert surfaces as `ContractRevertError`, exactly like a reverted receipt.
  - An approval or operator grant a write needs rides in the SAME executor call as the write (one safe transaction through MultiSend; one transaction each, in order, for an executor that cannot batch — which stops at the first revert and never sends the calls behind it).
  - Whatever a custom executor or signer throws crosses the boundary as an SDK error with the original as `cause`; a local signer's own failure now surfaces as `RpcError` rather than raw.
  - `UnsignedCall` now carries `chainId`. Every built-in executor refuses a call for another chain with `InvalidInputError` before anything is signed (`assertCallsOnChain` is exported for custom executors).
  - `decodePlaceOrder`, `decodeAmendOrder` and `decodeAmendOrders` are exported pure functions over a receipt, byte-for-byte what the sending verbs produce.
  - `Trader.executor` names who sends. Verbs that sign rather than send (`signRedeemAuth`) need an executor with `signTypedData`; a safe or a session key has none and those verbs throw `SignerRequiredError`.
  - Existing callers change nothing, with one observable exception: a `walletClient` trader's pre-send rejection now reports `RpcError.operation === "sendTransaction"` (the wallet executor sends the encoded call) where the removed path reported `"writeContract"`. Code branching on that field updates the string. `privateKey` / `account` / `walletClient` otherwise build the matching built-in executor on the engine's own nonce lane and send ordering.
- One exchange over every chain the account trades on — Somnia L1 and Hideki — with a safe as a first-class trader
  
  `SomniaMarkets` accepts `chains: [...]` and builds one engine per chain. Reads
  merge across chains, writes route to the chain a market lives on, and every
  struct names its `chainId`. The single-chain config is the one-element case
  and behaves as before.
  
  ```ts
  const exchange = new SomniaMarkets({ chains: [l1, hideki], account: safeAccount });
  const bal = await exchange.fetchBalance();          // bal.USDso.total, bal.USDso.byChain[id]
  await exchange.createOrder("BTC/USDso:USDso", "limit", "buy", 1, 60_000);  // routed
  ```
  
  - **Chain-qualified identity.** `Market`, `Tradable`, every `Unified*` struct
    and every `UnsignedCall` carry `chainId`. `ChainMarketRef` / `ChainOrderRef`
    (`{ chainId, id }`) are accepted wherever a bare id is; a bare id that two
    chains know throws `InvalidInputError` instead of guessing. Symbols stay
    chain-free and unique across the union.
  - **The account.** `account: { address, executors: { [chainId]: Executor } }`
    — one address on every chain (the safe factory guarantees it), one executor
    per chain, built with `engine.createExecutor(spec)` (the executor seam shipped
    separately).
  - **Collateral placement.** A market's collateral must sit on its chain. On a
    multi-chain exchange `createOrder` checks the escrow balance there and throws
    `CollateralOnWrongChainError` — chain, token, shortfall, where the funds are,
    and a ready bridge plan — unless `{ funding: "auto" }` (bridge, wait, place)
    or `{ funding: "skip" }`. `exchange.bridge` plans and executes transfers
    between the exchange's chains through the account's executors;
    `exchange.ensureFunds` is the helper behind auto-funding.
  - **Portfolio netting.** `netInternalTransfers` drops bridge legs between the
    exchange's own chains from a funding list; `fetchPortfolioAnalytics` applies
    it, so a rebalance never reads as a withdrawal plus a deposit.
  - **React.** `<SomniaMarketsProvider exchange={exchange}>`; every hook takes a
    trailing `{ chainId }`.
  - **Per-engine nonce lanes.** Each engine owns a nonce manager; the module-level
    fallback is gone.
- Smart accounts, end to end: the factory account model of `@somnia-chain/accounts-sdk` (new runtime dependency) is reachable through this SDK alone — sending trades AS an account, and the whole account lifecycle around it.
  
  - **The executor.** `client.createExecutor({ smartAccount, inner, through?, signer? })` builds an executor whose actor is a factory account, including an account owned by other accounts. The accounts SDK builds the safe transaction (batching through its own MultiSend batcher from committed manifests — no `multiSend` knob); the `inner` executor sends the outer transaction, so writes keep the engine's realtime send, nonce lane and retry stance. With no `signer` and no `through`, nothing signs and nothing is read (the pre-validated path — the session-key arrangement); a `signer` or a `through` chain of parents switches to one owner signature the accounts SDK nests level by level, at any depth. An explicit `gas` below the accounts SDK's execution floor (2,000,000) is raised to it.
  - **`client.accounts`** — the one-chain capability: `addressOf`, `find`, `nextFreeIndex`, `ownersOf`, `custodyChain`, `authorityPath` (yields the executor's `through`), `tree`, `holdings`, `health`, and the build verbs `buildCreateAccount`, `buildAccountAuthorization` + `buildCreateAccounts` (one signature, every covered chain), `buildAddOwner` / `buildSwapOwner` / `buildRemoveOwner` / `buildChangeThreshold`, `buildTransfer`. Reads ride the client's own chain transport; every write returns `UnsignedCall`s for any executor to send — owner management and transfers through the account's own smart-account executor, so the account administers itself.
  - **`exchange.accounts`** — the multi-chain surface: `scan` across every covered configured chain, `buildCreateAccounts` fanning one signed authorization out into one creation call per chain, and `accountFor(accountId, index, { sessionSeed })` building the `SomniaMarketsAccount` the exchange trades through — one line from seed to tradable multi-chain account.
  - **Named failures.** A revert while sending through an account is attributed to the account's own `execTransaction`, and a best-effort replay names the actual cause: the account's `GS` code as a sentence (via the accounts SDK's catalogue), or — accounts being Safe 1.5.0, which propagates inner revert data — the inner call's real revert, including this SDK's own contract errors.
  - **Refusals before anything signs**: a chain the accounts SDK has no deployment for (5031 / 50312 / 50383 are covered), an `inner` on another chain, a `through` chain with no way to sign — all `InvalidInputError` at construction.
  - **Sign now, send later.** `client.accounts.buildExecution({ account, calls, signer, through? })` prepares an execution whose signer is not the sender: the owner signs the returned payload, and `toCall(signature)` is a finished call ANY funded key may broadcast (send it with at least `gasFloor` gas) — the sponsorship flow, without importing the accounts SDK. Pass `nonce` when it is already known (`0n` prepares an undeployed account's first action, read-free).
  - **`indexerUrl` is now optional.** A chain-only client (smart accounts, executors, on-chain reads) constructs without one; every indexer read and live watch on such a client throws `NotConfiguredError` naming `config.indexerUrl`. Nothing changes for clients that pass it.
  - New root exports: `SmartAccountsClient`, `ExchangeSmartAccounts`, `SmartAccountSigner`, `SmartAccountInner`, `PreparedAccountAuthorization`, `AccountAuthorizationTypedData`, `UnsignedAccountCreation`, the read/build error unions, the pure `describeCoverage` helper, and the accounts-SDK result types the members hand back. New docs: `docs/ACCOUNTS.md` and `docs/how-to/trade-through-a-smart-account.md`.
- `listSeriesLatestMarkets` returns the most recent market of every rolling series that has rolled at least once - one row per series, joined server-side.
  
  A series row says how often it should roll; a market row says when it was created but not which series it belongs to. This read is the join between them: the question "when did each series last roll", answered server-side rather than by fetching every market and grouping client-side.
  
  It is PAGINATED, like every list in this SDK — one request per page of at most `limit` rows (default 100), not one request for all series however many there are. A caller with more series than a page must cursor through with `offset` until a short page comes back.
  
  It takes `ListSeriesLatestMarketsOptions` (`limit`, `offset`), returns `SeriesLatestMarket` rows (`series`, `market`, `createdAtTimestamp`) and throws `SomniaMarketsClientListSeriesLatestMarketsError`.
  
  A series that has never rolled has no row. Absence means "no market yet", not "stalled"; nothing indexed says when a first roll is due, so a caller should report such a series as unjudged rather than anchor on its registration time.
- Add partial offset pagination for authenticated unified order history. Each row now carries immutable market and owner identity, while unresolved rows remain observable.

### Patch Changes

- `getBookTops` is documented as covering every market kind, not only binary. A spot or perp pool address and a binary market id are both valid, and may be mixed in one call.
- The multi-chain collateral pre-check now requires what the pool will lock
  
  `createOrder` on a multi-chain exchange checks the escrow balance on the market's
  chain before it sends anything. That check sized a binary buy as the human
  `amount × price`, a float, with no builder-fee headroom. An order with a
  `builderFeeBpsTimes1k` could pass the check with exactly the principal and then
  revert at the pool. The check now uses the same integer commitment the writer
  and `BinaryPool` use: ceil principal plus the builder fee's ceil-rounded
  headroom, so `CollateralOnWrongChainError.needed` is the amount the pool pulls.
  A spot buy's principal is sized by integer math too.
- ### Fixed — a complete set now records the collateral it actually cost
  
  `computePositionPnL` priced each leg of a complete-set mint or merge at half a
  collateral unit per token, then multiplied back out. That representation loses the
  cost in two ways.
  
  On a **zero-decimal collateral** one whole unit is `1`, so half of it floors to `0`
  and the entire set was recorded as costing nothing. Every later sale of those tokens
  then realized pure profit, and the position's cost basis read zero for its whole life.
  
  On any collateral, an **odd raw quantity** floored a unit away on each leg: seven
  raw units booked three and three against a set that cost seven.
  
  Both legs now split the set's own aggregate quantity, with the odd unit going to the
  NO leg, so the two shares always sum to exactly what the set cost. A mint followed by
  a merge of the same amount nets to precisely zero.
  
  Releasing cost on a **partial** sale had the same defect from the other direction: it
  scaled a per-unit average through one whole collateral unit, which rounds to zero
  whenever a leg's cost is smaller than its quantity. A leg of five tokens costing two
  released nothing at all when four were sold, reporting the whole proceeds as profit
  and stranding the cost on the one token left. Partial releases now take the aggregate
  ratio directly. That is more exact rather than merely equivalent: the per-unit form
  rounds twice, so it differs at ordinary decimals as well, by up to one raw unit per
  whole token released.
  
  A **partial** merge is also credited correctly now. Where a merge names more tokens
  than the position holds, the proceeds split the quantity actually released rather than
  pro-rating the event's own half, which floored on each leg independently: merging seven
  against a position of six credited two plus three for six whole pairs and reported a
  loss of one unit that never happened.
  
  The reported **cost basis of an open position** had the same defect in the other
  direction: it scaled the held balance through a rounded per-unit average, which is
  zero whenever a leg's cost is smaller than its quantity. A zero-decimal position of
  five tokens costing two therefore reported no cost basis at all, even once the fold
  recorded it correctly. It is now taken from the aggregate.
  
  That also removes a rounding loss at ordinary decimals: a leg holding three raw units
  costing two reported a basis of one.
  
  A **partial** merge after an odd mint is exact now too. Because an odd amount splits
  unevenly between the legs, releasing each one's cost independently floored twice and
  lost a unit: minting five and merging four released three against four of proceeds,
  inventing a unit of profit and stranding a unit of basis on the set still held. The
  two legs are settled together when they are a genuine pair, so the cost released
  always matches what was merged.
  
  A zero-quantity merge event no longer throws when the account holds an open leg. It is
  treated as the no-op it is, as it was before complete sets were split per leg.
  
  Figures for accounts that minted or merged complete sets will move, and the moves
  compound across a position's history rather than being bounded per action.
  
  On a six-decimal collateral each partial release was short by up to one raw unit per
  whole token it released: a leg minted at 10,000,001 raw units and then sold down by
  10,000,000 released 4,999,990 where it should have released 4,999,999, so realized PnL
  moves by nine units on that one sale. Repeated partial sales and merges accumulate.
  
  On a zero-decimal collateral the previous cost basis was absent entirely, so realized
  PnL for those markets was overstated by the full cost of every set.
- Document the built-in Safe executor's supported accounts: a direct owner of a threshold-1 safe or an enabled module. Nested Safes are supported when their address has the required authorization. Module mode does not depend on the threshold. Other authorization schemes need a custom `Executor`. The SDK does not check the owner set or threshold.
- Wait for complete market watch initialization before resolving concurrent callers. Failed initialization now releases unused subscriptions and allows a fresh retry while preserving separately watched markets.
- `useFundingRateSeries` now waits for the indexer before it re-reads a new funding settlement. A settlement reaches the live tail as soon as its block is confirmed, which is before the indexer has processed that block. The hook previously re-read the rollups immediately, so the response could still be the old series and nothing retried until the next settlement. The hook now reads the indexer sync status about once a second until the processed block reaches the settlement's block, then re-reads the rollups once. The wait is bounded at 4 seconds. At the bound the hook re-reads the rollups anyway. The wait stops on unmount and on a pool change. It survives a `from`/`to` window roll and then re-reads the rolled window. A sync-status failure stops the wait and appears on the new optional `catchUpError` field of `IndexerQueryState`, typed `IndexerError | InvalidInputError`; the hook never treats a failed read as coverage.
- Re-pin the Hideki (50383) deployment manifest to the hideki2 regenesis (2026-09-17): the chain was rebuilt on the same chain id with fresh state, and the CLOB family was redeployed at the SAME shared CREATE3 addresses, so no resolved address changes — only the deployment's `startBlock` moves (566759489 → 840122) and venue 1 carries fresh MarketCreatorV2/policy/TestUSDC instances. Consumers that scan from `startBlock` must pick up the new floor; the old one predates the regenesis and does not exist on the new chain.
- Liquidation history documents how to read `stageReached`, and corrects two claims about `BadDebtAbsorbed`. `stageReached` is a 0-indexed enum, while the published waterfall numbers its stages 1 to 6, so the two need a mapping table; `LiquidationEvent` and the perps guide now carry it. The number names the deepest stage that acted, not the deepest consulted, and `CLOBPartial` is a fall-through marker that does not prove stage 3 filled anything — read `positionsProcessed` with it. `BadDebtAbsorbed` fires when the engine asks the insurance fund to cover, not when the fund pays, and its `counterparty` names the fund that was asked; both are recorded even when `insuranceCovered` is 0. Documentation only; no runtime behaviour changes.
- Fix wrong decimal scale on binary markets the live tail discovers itself. A market
  built from a `MarketCreated` event was stamped with the 6-decimal fallback
  regardless of its collateral, so on a venue settling in an 18-decimal token every
  derived quote amount on that market — cumulative quote volume, per-fill quote
  value, and anything formatted from `quoteDecimals` — was off by a factor of
  10^12 until an indexer snapshot happened to overwrite the row. Discovered markets
  now carry their collateral's real `decimals()`, matching what the indexer stores
  for the same event.
- Derive a live order book once per block instead of once per depth. Reading the
  same pool at two depths, as a trading page does when it shows a mid price and an
  order book together, now costs one pass over the resting orders. The full sorted
  ladder is computed once and each depth is a slice of it. Repeated status updates
  that change nothing no longer discard the derived books.
  
  Book contents are unchanged, and an expired order still leaves the book without
  a removal event. A book read keeps returning the same value until something it
  depends on moves, which now includes the one-second expiry cutoff as well as new
  blocks, so a level that expires is dropped without waiting for the next block.
  
  Watching a second pool now always publishes that pool's rows, even while a
  connection failure is pending and even when the new pool shares the first one's
  indexer snapshot block.
- Fix `close()` and `stopLive()` leaving the chain WebSocket and a background
  timer running. Both now release every socket and timer the instance opened, so
  a Node script that has touched the chain exits on its own — the
  `process.exit(0)` the guides used to require is no longer needed. The instance
  stays usable afterwards: a later read reopens a connection, and a later
  `close()` releases that one too. Instances configured with the same
  `wsRpcUrl` share one chain socket, which closes when the last of them closes.
  A socket your own application already had open on that URL is left untouched.
- Document the linked-wallet funding rail in the perps guide: what isolated margin on
  perps actually is, how a child's order draws on its main's wallet, which read answers
  which question, why a pending link proposal is only visible in the indexed log, and why
  the two funding ledgers must not be summed together.
- Read each cross-margin account view from one chain block so its balances, health, and status describe the same state.
- Check that the safe and any required MultiSend address have code before sending. Missing code raises `InvalidInputError` before signing. Failed reads raise `RpcError`; existing SDK errors pass through. Successful checks are cached. Single calls do not use or check MultiSend.
- Preserve `NotConfiguredError` from Safe code checks through Trader writes. Include it in the executor error contract and document the missing-transport condition.
- The package now carries its documentation, and the README links to pages a reader can open. The source repository is private, so every relative link out of the README resolved to a 404 for anyone outside the organisation, and npm derived the package homepage from that same private repository.
  
  - The tarball now contains the 32 hand-written guides under `docs/`. They are the only copy a consumer can read offline. The generated API reference stays out, so the tarball has the same contents whether or not TypeDoc has run.
  - The 13 documentation links in the README are now absolute URLs on the public documentation site. Each one was requested and returns 200.
  - `homepage` now points at the public documentation site instead of the private repository.
  - The publish gate now fails if the documentation is absent from the packed artifact, or if the generated API reference leaks into it.
- Clarify SDK read sources, startup dependencies, freshness, and transport costs. Complete the Engine read inventory and explain separate price-feed connections and mixed-source reads.
- `Order.cancelReason` is documented as `SelfMatch` on every pool type, not perps only. Spot and binary pools now tag their `OrderCancelledSelfMatch` removals the same way perps always did, so a spot order the protocol pulled during matching is distinguishable from one its owner cancelled.
  
  On spot this also covers the removals the indexer RECONSTRUCTS for blocks that predate the pool emitting the event at all (DEX-1236). Before the fix those makers stayed `Open` in the index forever — 673 of them on mainnet USDC.e/USDso, locking that book at 1.0000. Reconstructed and reported removals carry the same status and the same reason on purpose: on chain they are the same removal, only one of them reported itself.
- ### Fixed — baked testnet venue-2 creator/policy reconciled with chain truth
  
  The testnet manifest carried a stale `marketCreator`
  (`0x138CfA6b…`) and `marketCreatorPolicy` (`0x191BBBDc…`) for venue 2. Ground
  truth on-chain (TRAD-223, re-verified): `MarketsCore.getVenue(2, 0x679795a0…)`
  returns policy `0xa24822b8d4adCC770c8B071e0Dc8e19c39991204`, which authorizes
  only creator `0x94D963B6670AB96E78C8d0C46ca35D196d606EFE`. The baked
  `SOMNIA_TESTNET_ADDRESSES` bundle now carries those live values. Mainnet is
  unaffected.

## 0.30.0

### Minor Changes

- Three new client reads answer block-scoped questions: `getBlockActivity` returns what one block traded grouped by market, `getLatestActiveBlock` names the newest block with markets activity, and `getAdjacentActiveBlocks` names the closest such block on either side. All three anchor on the indexed timestamp columns rather than a block column, none of which the indexer schema indexes.
  
  `getTransactionActivity` takes an optional `anchor` (the transaction's block and timestamp). Supplying it skips the `Order.placedTxHash` probe, which is not index-served and ran past the gateway timeout — so a transaction that produced no event, meaning one that only placed or cancelled orders, could not be read at all.
- New `SOMNIA_MAINNET_PRICE_FEED` constant. It is the mainnet price feed, `https://price-feed.prd.oracle.somnia.host/v1/graphql`, pinned to the `USDC` quote.
  
  The SDK previously exported one price feed, `SOMNIA_TESTNET_PRICE_FEED`. A mainnet consumer had to write the endpoint by hand.
  
  The two feeds are separate deployments. Each one indexes the `PriceFeedScheduler` on its own chain. Use the feed that matches the chain the client runs on.
  
  The mainnet feed carries fewer symbols than the testnet feed. Code written for the testnet asset list can therefore fail on mainnet. Read the feed catalog to learn which bases exist. A base the feed does not carry returns no rows. It does not raise an error.
  
  Existing callers keep their behaviour. `SOMNIA_TESTNET_PRICE_FEED` is unchanged and remains correct for a testnet client.
- Publish the perp event ABIs, so a consumer can decode perp logs from chain.
  `perpPoolEventsAbi` is now root-exported, and `marginBankEventsAbi` and
  `liquidationEngineEventsAbi` are new (18 and 13 events). Before this, `perpPoolWriteAbi` and
  `marginBankWriteAbi` exported no events and there was no MarginBank or
  LiquidationEngine event ABI at all, so funding settlement, positions, collateral
  flow and the liquidation waterfall were undecodable without a hand-copied ABI.
  Every signature is pinned by topic0 against the compiled artifacts.
  `docs/PERPS.md` has a new "Watch from chain" section.
- Add `client.getPerpFundingPremium(pool)`, which reads the premium the next funding
  settlement will charge. Use `timeWeightedPremium` to project a funding rate. Do not use
  `lastObservedPremium` for that: the contract getter behind it kept its signature and
  changed its meaning, and it is now only the standing instantaneous sample. Check `armed`
  before calling the figure a time-weighted average, because a market that has not settled
  since the upgrade is still charging a point sample. This read is deliberately separate
  from `getPerpState`, whose batch would fail as a whole against a pool too old to serve
  these getters.
  
  Correct the funding documentation for the reworked premium and catch-up horizon.
  `FundingRateUpdate.intervalsAccrued` is no longer `min(intervalsSettled, n)`. A
  settlement now charges at most one interval, however long the gap, and the excess is
  forgiven. Compare `intervalsAccrued` against `intervalsSettled` to see whether anything
  was forgiven; `intervalsSettled` alone does not tell you. `intervalsPerWindow` is the
  per-interval divisor only. On a historical row that caught up over several intervals, one
  settlement's charge is `fundingRate * intervalsAccrued / n` rather than `fundingRate / n`.
  The premium driving the rate is the time-weighted impact-price premium, not a book
  midpoint, so `emaPremium` can be non-zero on a one-sided book.
- Read the three perp ledgers the reindex made available: `listPerpInsuranceFundEvents`
  (the InsuranceFund's tier ledger behind `getInsuranceFundState`),
  `listPerpWalletLinkEvents` / `listPerpMarginPulls` / `listPerpMainFundingEvents` (the
  linked-wallet rail's consent graph and both funding sides), and
  `listPerpOrderRejections` (orders refused inside a batch placement). Two aggregation
  hazards are documented on the types: the insurance ledger's `amount` must be folded by
  `kind` rather than summed, and the two funding sides record the same wei — summing them
  double-counts every transfer.
- `listPerpStopOrders` now returns `siblingOrderId` (the live OCO partner), `intent`
  and `cancelReason`, so a stops table no longer needs a chain read per row to show
  pairing or intent. `cancelReason` separates the three routes to `CANCELLED`, which
  refund differently: `"Owner"` returns the SOMI in the same transaction, while
  `"LinkedFill"` and `"Inert"` only credit `unclaimedSomi` for `claimSomi()`.
- ### BREAKING — the portfolio trades legs default to the last seven days
  
  `getSpotPortfolio`, `getPerpPortfolio` and `getPortfolio` now apply `since = now − 7 days` to their
  trades leg unless the caller passes `since`. A caller that passed no `since` and expected the whole
  history gets the last week. Each result gains a required `tradesSince` (unix seconds): the bound
  that was applied, so a UI can label the list and a caller can page further back.
  
  **Migration.** To keep the previous unbounded read, pass `since: 0` (or the earliest time you need).
  Hand-built `Portfolio` / `SpotPortfolio` / `PerpPortfolio` values (fixtures, adapters) need a
  `tradesSince` field. A UI that renders `trades` should read `tradesSince` and say so when the list
  is empty — "no trades since <date>" is true where "no trades yet" is not.
  
  Why a window: the type scope below is fast when a wallet's fills are dense in the requested type
  and slow when they are sparse — a spot market-maker asking for its binary fills walks every fill
  it has rejecting each. Measured on the development indexer for such a wallet: 24.8 s unbounded,
  208 ms with a seven-day bound. A window is the only shape that is fast for every wallet without a
  schema change. Treat it as the SDK's standing position on wallet-scoped trade history, not as a
  stopgap: the composite indexes that would let it be dropped are a separate ticket, and removing a
  default later would be a second breaking change.
  
  ### Fixed — type- and pool-scoped reads no longer filter through the `market` relationship
  
  Every read that scoped `Fill`, `Order` or `StopOrder` by market type or by pool did so with
  `market: { marketType: { _eq } }` or `market: { poolAddress: { _eq } }`. Hasura compiles a
  relationship predicate to a correlated `EXISTS` on `Market` per candidate row, which stops
  Postgres using the `maker` / `taker` / `owner` indexes. Measured on the development indexer for
  one wallet's fills at `limit: 50`: `stream timeout` with the predicate, 102 ms without it. Three
  such reads every five seconds per open wallet page saturated the shared one-vCPU database and
  stalled ingestion on both indexer slots.
  
  These reads now scope on the direct, indexed columns instead: `pool` on `Fill`, `market_id` on
  `Order` and `StopOrder`. A market type resolves to its set of pools — `_in` for SPOT and PERP,
  whose `Market.id` is the pool address; `_nin` over spot ∪ perp for BINARY, whose pools are
  recycled across thousands of markets. A spot or perp pool is its own market id — a direct `_eq`,
  no query. A single **binary** pool keeps the `market: { poolAddress }` relationship form: a
  recycled pool has hosted ~2,000 markets, so an `_in` of its ids would be the worse shape, and
  every Order read that takes a pool also carries `owner`, which bounds that one EXISTS to the
  wallet's rows — no worse than before. Pool and type compose in one place (`marketScope`). The two
  type sets are memoized per indexer URL for a minute, so the extra hop is paid once, not once per poll.
  
  Affected: `getSpotPortfolio`, `getPerpPortfolio`, `getPortfolio`, `getOpenOrders`, `getOrders`,
  `countOrders`, `getSpotStopOrders`, `listPerpStopOrders`, `listPerpOrderHistory`,
  `listSweepableOrders`. Rows returned are unchanged for every read except the three portfolio
  trades legs, where the new default window applies. `getSpotStopOrders` in particular keeps
  returning every pending stop a wallet holds, spot or perp, as it did.
- BREAKING for constructors — `PortfolioAnalytics` gains a required `holdings` series: what the traded book is worth over time
  
  The result's `equity` field is cumulative window PnL. It starts at zero, it is
  signed, and it is not the account's value. Consumers read the name and plotted
  it as a level, so a losing window drew a spot wallet below zero.
  
  `holdings` is the level that name promised. Each sample sums `qty × mark` over
  the open positions, on the same grid as `equity`, so a chart can draw both
  against one x axis:
  
  ```ts
  const p = await exchange.fetchPortfolioAnalytics("7d");
  p.holdings.at(-1); // what the traded book is worth now
  p.equity.at(-1); // what it gained over the window
  ```
  
  The first point is the carried-in book valued at the window start, not zero.
  The series sums signed position value. Every book the fold keeps today is
  long-only, so today the value cannot go below zero — but do not lock a chart
  axis to that, because a signed book marks negative and the perp plane is
  committed to joining this fold. The marks are the caller's and are not
  validated, so a negative price carries through as it already does for the PnL
  figures.
  
  A position the sample cannot price is left OUT of the value rather than valued
  at zero, and `HoldingsPoint.unpricedMarkets` counts what was left out. A market
  with no candle sample and no last price has no mark, and standing a zero in its
  place would drop a held position out of the level with nothing to say it had.
  Check the count before presenting a sample as the whole book.
  
  **Migration.** Reading the result needs no change: every existing field keeps
  its name, its meaning and its value. Anything that CONSTRUCTS a
  `PortfolioAnalytics` — a hand-written response fixture, a test mock, an adapter
  building the object for an HTTP layer — must add `holdings: HoldingsPoint[]` or
  it stops compiling. `HoldingsPoint` is exported from the package root. An
  adapter that forwards both series also serves roughly twice the series payload.
  
  The series measures the TRADED BOOK, not the wallet. A token that arrived
  without a fill — bridged in, transferred in, minted — is not in the book, so it
  is not in this value. Idle quote balance is not a position, so it is not
  included either. Funding events refine the capital base only, so a deposit
  inside the window does not step the curve. Read balances from the chain when
  you need what the wallet itself is worth.
- BREAKING: `redeem` no longer guesses a leg before resolution, and unified leg selection now uses an options object
  
  `trader.redeem({ market, amount })` previously derived the winning leg as the
  argmax of the market's payout vector. A void pays BOTH legs, so that argmax was
  meaningless:
  it returned leg 0 on the uniform `[D/2, D/2]` vector, and the higher-priced leg
  on a `CLOB_SNAPSHOT` `[p, D−p]` one. A holder of only the NO leg got a confusing
  `InsufficientBalance` revert. A holder of both legs — what one `mintSet` call
  produces — redeemed YES, received half, and never attempted NO, with no error
  and a successful transaction. The collateral was stranded, not destroyed: it
  stays claimable from `BinarySettlement` with an explicit leg.
  
  The auto-lookup is now resolution-only. It checks the market's terminal state
  before reading the immutable payout vector. On a voided market `redeem` throws
  `InvalidInputError` naming both legs and pointing at `redeemMany`; on an
  unresolved market it asks the caller to wait for settlement. Resolved markets
  are unchanged: `[D,0]` still resolves to YES and `[0,D]` to NO.
  
  `exchange.redeem(ref, amount)` gains an optional trailing `RedeemOptions` bag,
  so a NO-holder can claim through the unified API instead of hitting a dead end.
  Existing two-argument calls behave as before.
  
  Migration: replace `exchange.redeem(ref, amount, outcomeIdx)` with
  `exchange.redeem(ref, amount, { outcomeIdx })`. If the market may be voided,
  pass the leg you hold. To claim both legs, use `redeemMany` with one entry per
  leg — they settle in one transaction.
- Restore concurrent market-discovery coordination and stable user-fill pagination after their review withdrawal. Paginated fill reads include configured indexer headers, and indexer response-body cancellation preserves the caller's reason. This restoration supersedes the earlier withdrawal note. Dataset-reset coordination remains required for persisted fill cursors.
- Add bounded user-fill pages with stable numeric continuation. Cursors preserve account and filter scope while newer fills arrive. Existing reads remain unchanged. Reindex, repair and endpoint cutover still require callers to discard cached pages and cursors.
- Reject partially present void payout vectors, floor each raw payout leg from its stored numerator, and verify omitted unified redemption legs against on-chain terminal state.

### Patch Changes

- Approval documentation now distinguishes cached unlimited allowances from finite allowances that each order must check again.
- Traders from one configured client now coordinate local sends for each account. A failed signer cannot reset a nonce that another trader is still broadcasting.
- Share concurrent initial market discovery within each exchange. Explicit registry reloads run in order, including after a failed read, so an older discovery cannot overwrite newer market metadata.
- Withdraw the unreleased stable user-fill pagination API and concurrent market-discovery coordination pending maintainer review. This withdrawal supersedes the queued release notes announcing those additions; existing offset-based fill reads and the previous discovery behavior remain available.
- Documentation: a documentation map plus tutorials, how-to guides, reference pages, and explanations organised by reader need. Two tutorials take a newcomer from an empty directory to a cancelled order on the Shannon testnet. Eight how-to guides cover network configuration, testnet funds, a quoting loop, fill detection, browser-wallet signing, the React hooks, error handling, and debugging. Six reference pages list configuration fields and defaults, networks and endpoints, the symbol grammar, units and scales, read tiers, and the error classes with the contract error names they decode. Every TypeScript example has a compile-checked public-import counterpart, and the tutorials were executed against the testnet.
  
  Corrections to the existing guides, verified against the source: the reactivity guide no longer claims the package is on GitHub Packages; the binary guide's cadence list, lifecycle (`Finalized`), `intervalSec` provenance, and builder-fee revert names are corrected; the exchange guide's verb table gains the ten verbs it omitted and notes that `close()` leaves the WebSocket open; the engine guide's API links now target the docs site; `isTailing()` is no longer presented as the price-feed status; the bridge guide names `sendBridgeStep` as its one sender; and the two `errors` TSDoc examples now match the real signatures of `createOrder` and `cancelExpiredOrders`.
- Forward configured indexer headers to paginated user fill reads and preserve the caller's cancellation reason when an indexer response body is interrupted.
- `/native`: a rejected `sendSessionTransaction` or `getSessionAddress` no longer carries the session seed in its error. viem builds a request failure's `message` and `cause` from the request body, so a mempool rejection or a transport error used to print the seed — a private key in another shape — in every log line that showed `error.message`. Both calls now throw `RpcError`. When the node answered, `cause` is its own `{ code, message, data? }` and `getSomniaRpcError` / `mempoolStatus` keep working on it; when the transport failed first, `cause` is `{ name, message, status? }` with the seed blanked out of the request text. `isMethodNotFound` also stops matching the node's genuine `account does not exist` and `Block does not exist` failures, which the documented degrade-to-`null` idiom used to swallow.
- A live-tailed client no longer re-fetches every block it has already been handed. The tail
  took its chain head from viem's `watchBlocks`, which answers a pushed `newHeads` frame by
  issuing an `eth_getBlockByNumber` for the very block the frame already carried - nothing new,
  at double the per-block RPC cost. It now reads the height and timestamp straight off the
  pushed frame over the same socket.
  
  A frame is read only when both the height and the timestamp arrive as hex quantities; if
  either is missing, is not hex, or is the empty quantity `"0x"`, the frame is skipped whole
  rather than half-read. A skipped frame still counts as proof the stream is alive, so the
  heads watchdog behaves as before and the next head restores the timestamp.
- Operational failures are no longer converted into success, zero, or a caller error at seven sites. Admin writes through `OperatorAdmin`, `GovernanceAdmin`, `MarketCreatorAdmin`, `OracleHubAdmin` and the lend `Lender` now throw `ContractRevertError` on a mined-but-reverted receipt (with the contract's error name when a replay at the receipt's block recovers it) instead of resolving as if confirmed, or misreporting the revert as `RpcError("no event")`. `SomniaMarkets.fetchBalance` throws when a balance read fails instead of reporting `0` or omitting the key. `getBinaryPositionPnL` falls back to `lastPrice` only when no chain client is configured, not when the book read fails. `cachedErc20Decimals` (and every read built on it) returns the fallback only for a token without `decimals()`, not on an RPC failure. `createStopOrder` surfaces a failed mark read as the indexer's error instead of `InvalidInputError("pass triggerDirection")`. `depositVaultNative` no longer broadcasts when its preflight read failed. The receipt wait used by external-signer and fallback confirms rejects after three consecutive receipt-read failures instead of pending forever; "receipt not found yet" still waits.
- Decode three perps reverts that previously surfaced as opaque hex —
  `InvalidFundingCapDivergenceMargin`, `InvalidPremiumImpactNotional` and
  `InvalidLinkedWalletRegistry`, all added by the 2026-08-31 testnet upgrade.
- `createOrder` and `createStopOrder` never send a larger quantity than requested. Quantities are truncated to the base token precision before applying the lot-size floor.
  
  `amountToPrecision` now truncates the decimal text instead of its binary floating-point expansion. Exact inputs such as `1.2` therefore remain exact for a `0.1` lot size.
  
  Local writes now serialize nonce consumption, signing, and broadcast acceptance. A signer or broadcast failure can reset nonce tracking without invalidating another in-flight write.
- Live-tailed clients now receive binary settlement finalization. The SDK declared
  `MarketFinalized` with a stale trailing argument, so its topic0 did not match the emitted
  event and `liveTail` discarded every settlement finalize — leaving `netBacking` null for
  the settled lifetime of every binary market. Indexer-backed reads were never affected.
- ### Fixed — a hydrated fill now names its taker
  
  A watch scope hydrated from a snapshot reported no taker on any of its fills.
  `taker`, `takerSide` and `kind` were missing from the snapshot's fill selection
  set and hardcoded `undefined` when the rows were parsed, on a comment claiming
  the indexer does not carry them. It does: they are real columns, stamped by the
  indexer's `PendingTakerFill` bridge once the taker's placement event lands.
  
  On a market-scoped watch this was permanent, not a live-tail race. The
  client-side back-join recovers taker fields from the local order map, but that
  scope hydrates resting orders only — and a taker order that filled completely is
  `Filled`, never `Open`, so it never enters the map to be joined against. Every
  market order and every IOC was affected, and a freshly loaded binary trade tape
  showed no taker side on any row it hydrated.
  
  `taker`, `takerSide` and `kind` now come through from the indexer, so:
  
  - A binary fill reports its taker's address, its four-way `BUY_YES`/`SELL_YES`/
    `BUY_NO`/`SELL_NO` taker side, and its `DIRECT_YES`/`DIRECT_NO`/`MINT_A_PAIR`/
    `BURN_A_PAIR` kind — the vocabulary `takerIsBid` cannot express, being one
    boolean covering two sides at a time and carrying no kind at all.
  - A spot or perp fill reports its taker. `takerSide` and `kind` stay undefined
    there, which is correct rather than a regression: outcome side is a binary-only
    concept and every spot and perp adapter leaves it unset.
  - A row the bridge has not stamped yet still reads `undefined`, and the existing
    back-join still fills in whatever is missing when the rows are selected.
  
  No type changed: the live-fill shape already declared all three as optional, and
  the user-scoped path already resolved them through the back-join. This closes the
  SDK half deferred by the binary trade tape's taker-colour fix.
- Native-base spot sells now send the pool's exact vault shortfall as `msg.value`. This includes fee headroom and prevents `InvalidMsgValue` reverts on fee-bearing pools.
  
  Spot approval checks now use the pool's worst-case reserve. An allowance that covers only the principal is increased before the pull.
  
  Binary buy commitments now use the collateral token's exact scale. They also include the builder-fee headroom that the pool locks.
- ### Fixed — a voided market now values against the payout vector it actually stored
  
  `estPayoutFor`, `computePositionPnL` and `computeBinaryPnl` returned a hardcoded
  half for every voided binary market. A void pays whatever payout vector the
  market stored. Under the `UNIFORM` void policy that vector is `[D/2, D/2]`, so
  the half was right. Under `CLOB_SNAPSHOT` a void that captures a two-sided close
  stores `[p, D−p]` at the closing YES price, and there the half was wrong on both
  legs: at a 0.80 close the YES leg was understated by 37.5% and the NO leg
  overstated by 150%. `estPayoutFor` quoted a claim settlement would not pay.
  
  All three now read `payoutNumerators` / `payoutDenominator`, which the indexer
  already carries and the market selection set already requested:
  
  - `estPayoutFor` returns `amount × payoutNumerators[outcomeIdx] /
    payoutDenominator` on a void. A void is never fee-charged, so no settlement fee
    applies on that branch.
  - `computePositionPnL` and `computeBinaryPnl` mark each leg from the same vector.
    `computePositionPnL` derives the NO leg by subtraction, as it already does
    while trading, so both legs still sum to exactly one collateral unit.
  - `ClaimableInput` accepts optional `payoutNumerators` and `payoutDenominator`,
    and `PortfolioMarket` carries them, so `getClaimable` and
    `getOpenPositionsWithPnL` value a void from the vector too.
  
  The payout comes from the vector and never from `voidPolicy`. A `CLOB_SNAPSHOT`
  market stores the uniform vector on every fallback — no capture-capable pool, a
  reverting `closingPrice()`, a one-sided or empty book, any overflow guard — so
  reading the policy instead of the vector would misreport exactly those markets.
  
  When a legacy voided market has no stored vector, all three keep returning the
  half, unchanged from previous behaviour. A present but invalid vector now raises
  `InvalidInputError` instead of silently turning corrupt indexer data into a
  plausible payout.
  
  The vector arrives from the indexer rather than the chain, so it is validated
  before use — as a whole vector, not one leg at a time. It must have both legs,
  both parseable and positive, a positive denominator, and a sum equal to that
  denominator. This is the shape `BinaryMarket` writes for a void: uniform or
  `[p, D−p]`, with `p` clamped to `[1, D−1]`. Rejecting any other present
  vector keeps corrupt data distinct from the legacy no-vector fallback.
- Clarify voided-market rounding: `computePositionPnL` calculates each leg as `floor(oneCollateral * payoutNumerators[outcomeIdx] / payoutDenominator)`. It does not derive the voided NO mark by subtraction. Independent rounding can leave the two marks below one collateral unit in total. This supersedes the earlier queued payout-vector note's claim that the voided marks always sum to exactly one collateral unit; the implementation already follows settlement's independent rounding.

## 0.29.0

### Minor Changes

- ### Added — a market's whole history, one transaction, and one trade
  
  The tape and the fills list were the only ways in. A market page could show
  trades and nothing else; a transaction or a single fill had no read at all.
  
  - **`getMarketActivity(market, opts)` — one market's whole transaction history.**
    The mints, merges, redemptions, oracle resolutions and lifecycle transitions a
    market accumulates were all indexed and reachable only one entity at a time, in
    five different shapes. This returns them interleaved by time as one
    discriminated union on `kind` (`MarketActivity`), newest first, in a single
    round-trip. Every row carries its `txHash`, so a caller can follow any row to
    the chain. Select the streams with `kinds`, page backwards with `until`, and
    pass `pool` to let the fill read use its `(pool, timestamp)` index. A spot or
    perp market returns `TRADE` rows only — the other four streams read
    binary-only entities, so asking for them there is empty rather than an error.
  
    Pair it with `getLiveFills` for zero-latency trades: a trade's id is `TRADE:`
    followed by the fill id on both paths, so the two merge on `id` — but merge
    FIELD BY FIELD, preferring whichever source has a value. Neither is a superset
    of the other: the tail leaves `taker`/`takerSide` undefined on the fills it
    hydrates, and the indexer's `takerIsBid` is null until its taker bridge lands.
    Cache identity comes from the new `marketActivityKey`.
  
    **Known limit:** `until` has one-second resolution and is inclusive, so the
    boundary second is re-read between pages and a second holding `limit` or more
    rows cannot be paged past. A composite `(timestamp, blockNumber, logIndex)`
    cursor is the fix; it needs `logIndex` on the three non-fill streams, which
    this release adds to the indexer schema but which reaches a deployment only
    after a reindex.
  
  - **`getTransactionActivity(txHash)` — everything the protocol did in one
    transaction.** The transaction-scoped counterpart: the same `MarketActivity`
    union with the same row ids, but selected by transaction and returned in LOG
    order, plus the orders the transaction placed, the fees it paid and the market
    rows its events touched. One oracle callback settles every market on a
    question, so this is how a caller sees that a single transaction resolved four
    markets at once. A hash the indexer has nothing for returns empty collections
    and a null `blockNumber` rather than throwing — including a transaction that
    only rested a limit order, which emits no event and is anchored on the order
    table instead. New public types `TransactionActivity`,
    `TransactionActivityOptions` and `TransactionOrder`, and the
    `transactionActivityKey` cache key.
  
  - **`getTradeContext(id)` — one trade in full, for a trade detail view.** Returns
    the fill (with its block position), the market it executed in, BOTH sides'
    orders, the protocol and builder fees its transaction charged, and the other
    fills that transaction produced — enough to answer why a trade priced where it
    did. `null` for an unknown id, which is a stale link rather than a failure.
    New public types `TradeContext` and `FillOrder`, and the `tradeContextKey`
    cache key.
  
  Both transaction-scoped reads used to scan `Fill` to select by hash. The
  indexer schema now carries `@index` on `Fill.txHash`, so that scan goes away
  once a reindex has run.
- ### Added — single-entity detail reads, a transaction summary, and the network tape
  
  The indexer surface was list-shaped throughout: orders, fills and markets could
  be enumerated, but nothing addressed ONE of them. An explorer drilling into a
  row had no read to call.
  
  - **`getOrder(pool, orderId)`, `getFill(id)`, `getOrderFills(pool, orderId)`** —
    the three reads a detail view runs on. Order ids never repeat (a monotonic
    per-pool counter), so `(pool, orderId)` names one order permanently; a fill is
    named by its `${blockNumber}_${logIndex}` id. All three embed a `MarketRef`
    (symbols, decimals, routing identity) so a page renders from one read instead
    of a second lookup per row. `null` for an unindexed id — a just-executed fill
    can lag by a beat, which is a stale link rather than a failure.
  
    The embedded market is `marketRef`, not `market`: `FillRow.market` is already
    the bytes32 marketId string, and GraphQL will not select a relationship
    alongside an alias of the same name.
  
  - **`listMarketsByPool(pool)`** — every market a pool has hosted, newest first.
    A binary pool is recycled across successive markets, so this is its full
    history rather than the one market `getMarketByPool` resolves. Plus
    **`getSeries(creator, seriesId)`**, the single-row sibling of `listSeries`.
  
  - **`getTransactionSummary(hash)`** — sender, gas used and limit, effective gas
    price, fee paid, status. Chain-direct rather than indexed, so it answers for
    any hash the node still has; `null` for an unknown one, never a throw.
  
  - **`createNetworkTape()`** — a network-wide order-flow firehose. One
    topics-only chain-log subscription sees `OrderPlaced` / `OrderFilled` from
    every pool, including pools created after it starts, with no indexer on the
    hot path. Not the live tail: no books and no snapshots, just three
    newest-first ring buffers (`bids` / `fills` / `asks`) plus a session
    `(pool, orderId) → owner` map that attributes fills. Takers always resolve;
    makers resolve when they were quoted while the tape ran. Rows carry RAW units
    — join to `listMarkets` for symbols and decimals. Nothing connects until the
    first `subscribe`, and reconnect plus stall detection for Somnia's
    silently-dying subscriptions is built in.
  
  The indexer also materializes the Market→Series edge from
  `MarketCreator.SeriesRolled` (`Market.series` / `Market.seriesKey`), replacing a
  heuristic join on asset + venue + cadence. That reaches a deployment only after
  a reindex, and the SDK does not read the field yet.
- ### Added — read a pool's operator permissions registry from your own call
  
  `spotPoolOperatorRegistryReadAbi` is now exported from the package root. It is the
  ABI fragment for `getOperatorPermissionsRegistry()`, the view that tells you WHICH
  `OperatorPermissionsRegistry` a SpotPool consults when it gates `placeOrderFor`,
  `cancelOrderFor` and `reduceOrderFor`.
  
  `client.getOperatorPermissionsRegistry(pool)` already answered that question and
  still does. Reach for the fragment instead when the method does not fit:
  
  - **Multicall composition.** The method issues its own `readContract`, so it cannot
    be batched into a caller-built multicall. With the fragment the registry read
    joins the same batch as the rest of your pool reads.
  - **No instantiated client on that code path.** The method resolves a chain client
    lazily and needs one; the fragment is a static value and needs nothing.
  
  Previously the only route was a hand-copied fragment, since the `exports` map has
  no wildcard and no deep import could reach it — which is how a signature drifts in
  silence.
  
  ```ts
  import { spotPoolOperatorRegistryReadAbi } from "@somnia-chain/markets-sdk";
  
  // `allowFailure: false` so a failed read throws instead of yielding a
  // `{ status, result }` wrapper. With the default (`true`) each element is that
  // wrapper, and comparing it to an address silently reports every pool as wired.
  const [registry] = await publicClient.multicall({
    allowFailure: false,
    contracts: [{ address: pool, abi: spotPoolOperatorRegistryReadAbi, functionName: "getOperatorPermissionsRegistry" }],
  });
  
  const wired = registry.toLowerCase() !== "0x0000000000000000000000000000000000000000";
  ```
  
  Two differences from the method, both of which matter for an authorization check:
  
  - **`address(0)` reaches you raw.** The method returns `null` for an unwired pool;
    a direct read returns the zero address, which is truthy. Compare it yourself, as
    above — such a pool denies every operator call, so a grant written anywhere will
    not make `placeOrderFor` succeed. The SDK's own grant reads and writes reject a
    zero registry with `NotConfiguredError` rather than acting on it.
  - **Read per pool.** Two pools on one deployment may name different registries, and
    the pool is the authority: a grant written to any other registry is inert. Do not
    cache one answer for all pools.
  
  The other read-side ABIs stay internal.
- ### Added — a portfolio says when its trade history is incomplete
  
  `Portfolio`, `SpotPortfolio` and `PerpPortfolio` gain a `tradesTruncated` boolean.
  
  The three portfolio reads cap `trades` at 50 by default and page newest-first, so
  the cap drops the OLDEST fills. The returned shape carried no sign of this. A
  caller that folds `trades` into Net PnL, win rate, volume or a trade count got a
  total over the recent slice only, and read it as the whole history. This is the
  same signal `FundingRateSeries.truncated` already gives the funding chart.
  
  `tradesTruncated` is `trades.length >= tradesLimit`. A full page means older fills
  exist and were not returned. An empty page is not truncated, and neither is
  `tradesLimit: 0`, which asks for no trades at all. The default cap is unchanged,
  and raising `tradesLimit` moves the cap rather than removing it, so check the flag
  instead of assuming a large limit is enough.
  
  ### Changed (breaking) — perp markets are discovered from the chain, not only the indexer
  
  `loadMarkets()` now unions the indexer's perp rows with the PerpPoolFactory's and
  derives `active` from the live tradeability gates, so a close-only market no longer
  reads as tradeable. New `perpStatus` / `perpDiscoveryError` surface. **Breaking:**
  `indexed` is a required property on `UnifiedMarket`, so code that CONSTRUCTS one
  (fixtures, adapters) must set it; reading is unaffected.
  
  ### Added — the spot stop-order lifecycle is decodable, and the write ABIs are importable
  
  Protocol signatures were being hand-copied into consumer repositories, because
  this package held them and did not publish them. A transcription drifts in
  silence: it decodes nothing, or decodes wrong, and the failure looks like a
  missing trigger rather than an error.
  
  - **`spotStopRegistryEventsAbi` now carries the whole pending-order lifecycle.**
    It held `PendingOrderCreated` alone. It gains
    `PendingOrderTriggered(uint128 indexed pendingOrderId, bool success, uint128 indexed spotOrderId)`,
    `PendingOrderCancelled(uint128 indexed orderId)` and
    `InertOrderCancelled(uint128 indexed orderId, address indexed owner, uint256 somiCredited)`
    — so a consumer can decode a stop order firing, being cancelled by its owner,
    or being swept as inert, from a receipt.
  
    A new test checks every fragment's topic0 and argument shape against the
    compiled artifact `indexer/abis/SpotStopOrderRegistry.json`. That artifact
    carries no `PROVENANCE.json` entry, so the guard proves SDK-vs-artifact
    agreement, not SDK-vs-deployed-bytecode.
  
    Do not reuse the perp registry's shapes: its `PendingOrderTriggered` carries a
    drop reason the spot one does not have, and the test pins that divergence.
  
  - **`client.getOperatorPermissionsRegistry(pool)`** — the registry a SpotPool
    gates its operator calls through, at chain head, or `null` when the pool is
    unwired and denies every operator call.
  
    This is discovery for a caller who has not configured
    `addresses.operatorPermissionsRegistry`. The grant writes and the two grant
    reads need that address and otherwise throw `NotConfiguredError`, and no
    deployment manifest carries the key yet. A configured address still wins where
    it is used: this adds a path, it does not redirect one.
  
  - **Seven ABIs on the root barrel** — `erc20WriteAbi`, `erc20VaultWriteAbi`,
    `orderBookBatchWriteAbi`, `marginBankWriteAbi`, `spotStopRegistryWriteAbi`,
    `spotStopRegistryEventsAbi` and `operatorRegistryWriteAbi`. Token approvals,
    vault funding, batch order management, perp collateral, the stop-order
    lifecycle, and operator delegation are now encodable by hand from published
    surface.
  
    ```ts
    import { spotStopRegistryEventsAbi } from "@somnia-chain/markets-sdk";
    import { decodeEventLog } from "viem";
  
    for (const log of receipt.logs) {
      if (log.address.toLowerCase() !== registry.toLowerCase()) continue;
      const event = decodeEventLog({ abi: spotStopRegistryEventsAbi, ...log });
      if (event.eventName === "PendingOrderTriggered") render(event.args);
    }
    ```
  
    The read-side ABIs stay internal. A root export is permanent public surface,
    so the set is limited to what consumers have needed.
  
  ### Added — every placement path honours `selfMatchingOption`
  
  - **`selfMatchingOption` is now a caller input on all four placement paths**, so
    `CANCEL_MAKER` is reachable without leaving the SDK. It was pinned to `0`
    (`CANCEL_TAKER`) in each encoder, on a parameter the pools have always taken:
    `placeSpotOrder` / `buildPlaceSpotOrder` (`PlaceSpotOrderParams`),
    `placeSpotOrders` (`SpotOrderRequest`, per request),
    `placePerpOrder` / `buildPlacePerpOrder` (`PlacePerpOrderParams`), and
    `placeOrder` / `buildPlaceOrder` (`PlaceOrderParams`). `amendOrder` and
    `amendOrders` already took it, so every placement and amend verb now shares one
    vocabulary — the exported `SELF_MATCHING_OPTION`.
  
    Omitting the field encodes `CANCEL_TAKER`, exactly as before, so no existing
    call changes behaviour. Note that this default is the SDK's own: the pool reads
    the value out of every order request and has no default to fall back on.
  
    ```ts
    await trader.placeSpotOrder({
      ...order,
      selfMatchingOption: SELF_MATCHING_OPTION.CANCEL_MAKER, // drop MY resting order
    });
    ```
  
  - **`userData` is settable on single spot placement** (`PlaceSpotOrderParams`),
    which the batch request shape already allowed. The single verb pinned the tag
    to `0`, so the same order encoded differently depending on which verb placed
    it.
  
  - **`placeSpotOrders` forwards each request's `builder` and
    `builderFeeBpsTimes1k`** instead of zeroing them. Choosing the batch verb
    silently dropped the routing attribution that `placeSpotOrder` honours.
  
  ### Added — a both-leg binary position can finally show either row
  
  - **`BinaryPositionPnL.outcomes`** — the same position split into its YES and NO
    books, as `{ yes, no }` of the new exported `BinaryOutcomePositionPnL`
    (`balance`, `costBasis`, `avgCost`, `markPrice`, `markValue`,
    `unrealizedPnl`, `realizedPnl`, all RAW). `OpenPositionPnL` extends
    `BinaryPositionPnL`, so `getBinaryPositionPnL` and `getOpenPositionsWithPnL`
    both carry it with no extra request.
  
    Every money field on the result is blended across both outcomes, which is
    unusable for a wallet holding both. Buy 10 YES at `0.20` and 10 NO at `0.80`,
    mark YES at `0.40`: the legs are `+2.00` and `-2.00` and the blended
    `unrealizedPnl` is `0.00`. Neither position row could show that zero as its
    own PnL, and consumers were falling back to local math for both-leg wallets.
  
    The fold already kept the two books separately in bigint space, mints and
    merges included — it computed each leg and then summed the values away. This
    publishes them.
  
    ```ts
    const [row] = await client.getOpenPositionsWithPnL(account);
    for (const leg of [row.outcomes.yes, row.outcomes.no]) {
      if (leg.balance > 0n) render(leg); // per-outcome, not blended
    }
    ```
  
    Legs sum EXACTLY to the totals: `costBasis`, `markValue`, `unrealizedPnl` and
    `realizedPnl` are stored per leg and added up, so integer division cannot make
    a published leg disagree with the published total. `avgCost` and `markPrice`
    are per-token rates and do not sum — though while trading the two marks add up
    to one whole collateral unit.
  
  ### Added — the linked-wallet funding rail, and the margin surcharge that explains a refused order
  
  - **`getPerpLeverageImSurcharge(marginBank, account)`** and
    **`tryGetPerpLeverageImSurcharge`** — the EXTRA initial margin an account's own
    leverage settings demand, summed over every market where it both holds a
    position and has set a stricter-than-market cap.
  
    This closes a real gap rather than refining an existing read.
    `quotePerpOrderTopUp` funds ONE order and measures the account's *unlocked*
    balance, while the admission gate measures whole-account *equity* — so an order
    can be fully funded on its own market and still be refused
    `InsufficientMarginForOrder` because of an override on a different one. Nothing
    in the SDK could name that amount. Note the magnitude before dismissing it: the
    order lock reserves only the market's margin factor, so a 5x setting on a
    2%-IMF market needs 20% of notional, not 2%.
  
    The strict read reverts when a market that is both positioned and overridden is
    unpriceable; the `try` variant reports that as `null`, never `0n` — zero would
    under-state the requirement, which is the direction that produces an order the
    bank then refuses.
  
  - **The linked-wallet read 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.
  
    **`quotePerpFundingPayer` returns a discriminated union, not an address**,
    because the contract's single zero collapses three situations a UI must not
    render alike: the rail is `dormant` (the bank holds no registry, so the feature
    is off for everyone and linking would not help), the wallet is `unlinked` (the
    one case the user can fix), or the wallet `isMain` (linked, but funding flows
    main→child only). The arming check runs first and short-circuits, so a
    deployment with the rail off costs one read and cannot report a misleading
    "unlinked".
  
    **`getPerpMainFunding().payer` is the payer snapshotted at funding time**, not
    whoever is linked now. Both routes home settle against it, so an unlink or
    re-link between the pull and the repayment cannot misroute the money — read it
    rather than `mainOf` when the question is "who gets it back". `principal` is
    what the child may trade but not withdraw (`withdraw` frees at most
    `balance - principal`), and it is not a segregated bucket: the claim clamps to
    `min(principal, balance)` at flat moments, so the child's own contribution is
    the junior tranche and a loss eats it first.
  
    **`getPerpWalletLinkage` derives `isChild` / `isMain`** because the raw
    encoding is a trap — a main resolves to ITSELF in `mainOf`, so the natural test
    `main !== zero` is true for mains and children alike. `maturesAt` gates ADL
    netting only; the funding rail reads the raw graph, so a link can be fundable
    and not yet mature.
  
    All chain tier. The rail's five events are live on chain but not yet
    subscribed, so HISTORY is not available — these answer "what is true right
    now". Writes (`proposeLink` / `acceptLink` / `repayFunding` /
    `recallFromChild`) are deliberately not in this release.
  
  ### Added — a count can now say whether it is the whole truth
  
  - **`client.countMarketsBounded` / `client.countBinaryMarketsBounded`** — return
    `CountResult` (`{ count, truncated }`) instead of a bare number. `truncated:
    true` means `count` is a LOWER BOUND (10,000 rows):
    render it as "10,000+", and do not gate pagination on `rows.length < count`,
    which goes false while rows remain.
  
    Without the server-only `_aggregate` header every count falls back to a bounded
    row scan that returned *the rows it fetched* — so a scan that filled its cap
    reported exactly `10000`, indistinguishable from a real total. The fallback now
    requests `cap + 1` and reads the extra row as the signal; the probe row is
    never counted. `Fill` and `Order` are already past the cap in production and
    `Market` crosses it during 2026.
  
    The six bare-number count helpers are unchanged, and each now documents the
    bound on its client declaration — past the cap they return a lower bound as if
    it were exact. `countOperators`/`countVenues` get no variant: those tables are
    orders of magnitude below the cap. `countOrders`/`countUserFills` have none
    yet, and `Order`/`Fill` are both already past it — copy `countMarketsBounded`
    when a caller needs the signal.
  
  ### Added — a binary market's resolution mode, served
  
  - **`BinaryMarket.mode`** — `"reference"` (an up/down market whose threshold is
    another question's answer) or `"fixed"` (a threshold set at creation).
    Derived from `strike`, so it costs no extra query, and stamped on the live-tail
    path too — a market built from logs before the indexer has it reports the same
    mode it will report after. Exported as `BinaryResolutionMode`, with
    `binaryResolutionMode(strike)` as the one place that reading happens — both
    construction paths call it, so they cannot drift.
  
    Consumers previously inferred this from a LOOKUP MISS — "no opening answer, so
    it must be fixed-strike" — which conflates "no reference question" with "the
    reference question has not been answered yet". That reads as working code until
    an oracle is slow.
  
  - **`boundaryPrice(market, openingPrices)`** — the level a market's outcome is
    measured against, taken from whichever source `mode` names, with `posted`
    saying which. Only a posted answer has an "answered at" instant, so only it may
    be labelled as one; returning that alongside the value keeps a label and the
    number it labels derived from one decision. Returns the raw oracle value —
    scale stays the caller's to apply.
  
  ### Changed
  
  - **`BinaryMarketFilter.intervalSec` now matches a BAND, not an exact value.**
    A rolled market's indexed `intervalSec` is `expiry − tradingStart`, and
    trading routinely opens a second or two late, so one 15m series is indexed at
    898s and 899s as well as 900s. The filter matched exactly, so it returned a
    fraction of a cadence and called it the whole thing — 5m was missing a
    quarter of its own markets. `listBinaryMarkets`, `listLiveBinaryMarkets`,
    `listPastBinaryMarkets` and `countBinaryMarkets` all inherit this, so **row
    counts change** for any caller passing `intervalSec`. The band is
    `± CADENCE_TOLERANCE_SEC` (5s); rungs are far enough apart that bands never
    overlap. A caller that genuinely wants one exact window should filter the
    returned rows itself.
  - **A non-finite or non-positive `intervalSec` now THROWS `RangeError`** rather
    than silently matching nothing. Previously `{ intervalSec: 0 }` sent
    `_eq: "0"` and `{ intervalSec: NaN }` sent `_eq: "NaN"`, both yielding `[]`.
    A filter that silently matches nothing reads as an empty result set rather
    than as the caller error it is, so it is rejected at the boundary. Callers
    parsing user input (a URL param, a form field) should validate first — the
    same way `operatorId` is already expected to be a positive integer.
  - **`marketIntervalLabel` snaps to the cadence ladder**, so `BinaryMarket.interval`
    and the labels stamped on trade-history and portfolio rows now read `"1m"`
    where they read `"56s"`, `"15m"` where they read `"898s"`. A window matching
    no rung is unchanged.
  
  ### Added
  
  - `BaseMarket.createdAtBlock` — the block a market was created in, the
    counterpart to `resolvedAtBlock`. A market is bracketed by two reads of an
    off-chain feed and only a block identifies which print each one saw: a
    `"fixed"`-mode market's `strike` is the feed's spot at `createdAtBlock`, and
    that print lands before `tradingStart` far more often than on it, so
    `createdAtTimestamp` cannot find it. **The field is required**, so any code
    constructing a `Market` by hand must supply it; it is non-null on the wire, so
    every indexed market carries one.
  - `getResolutionPrices(marketIds)` — batch settlement prices, the counterpart to
    `getOpeningPrices`. Joins each market's OWN `oracleQuestionId`, so fixed-strike
    markets (which have no reference question) are covered. **Read its note on
    scale before consuming it**: the value is a raw integer and the indexer does
    not carry the answer's decimals.
  - `getOnchainResolutionPrice(marketId)` and the `OnchainResolutionPrice` type —
    a chain fallback for a settled market whose answer the indexer never saw,
    because its oracle adapter is not the one the indexer ingests. Resolves the
    market's bound adapter through the module, so it works for any adapter, and
    carries its own `decimals` (adapters differ: 18 on the price-feed adapter, 2
    on OracleHub). Returns null while the question is not final; a failed read
    throws rather than reporting absence.
  - `CADENCE_LADDER_SEC`, `CADENCE_TOLERANCE_SEC`, `snapToCadence` and
    `cadenceBandSec` — the SDK-canonical cadence rules, so grouping and filtering
    read one source instead of each re-deriving a tolerance. See the note on
    `CADENCE_LADDER_SEC`: these exist only because the markets indexer derives
    `intervalSec` from a market's own window instead of reading the exact value
    `MarketCreator.MarketCreated` already emits.
  
  ### Breaking — the MWRR capital base is deposited capital
  
  `mwrr.return` previously divided the window's gain by net trade flow: the
  carried-in position's value, plus buys, minus the full proceeds of sells. Net
  trade flow is not capital the account put in, so the figure overstated the return
  for an account trading a small part of its balance. An account that funded 10,000
  USD, traded 500 USD and gained 50 USD reported 10 percent instead of 0.5 percent.
  Supply the new `funding` events to measure against real deposited capital; without
  them the base remains a trades-only proxy, and `mwrr.capitalBasis` now says which
  one produced the number.
  
  `mwrr.depositedUsd` keeps its name and its meaning of net capital, with one
  correction: a sell now returns only the capital it matched against a tracked
  position. Proceeds of tokens the account never bought on the venue no longer
  reduce the base, so an account selling externally-sourced tokens reads zero
  rather than a negative base — and receives a return where it previously received
  none.
  
  Returns computed from funding events are time-weighted (Modified Dietz): each
  deposit or withdrawal counts for the fraction of the window it was invested, so
  capital that arrived late no longer claims a whole window's return. Trade flows
  are deliberately not weighted. A trade moves capital that is already in the
  account, and weighting a sale as capital returned and a later repurchase as
  capital re-deployed would collapse the base for an account that merely rearranged
  what it held.
  
  A return can exceed 100 percent in either direction on the funding basis, because
  capital deployed for a few hours of a window earns or loses at a large period
  rate. That is what a money-weighted rate states. Read the new
  `weightedCapitalUsd` to see how much capital the figure is measured against
  before presenting it as a headline.
  
  Every account's reported return changes. Read `mwrr.capitalBasis` to detect which
  definition a build implements, rather than inferring it from the package version.
  
  ### Fixed
  
  - **Binary PnL no longer folds a recycled pool's PREVIOUS markets into the
    current market's cost basis.** A binary pool is reused by successive markets,
    and the PnL reads keyed a wallet's fills by pool address, so an earlier
    market's fills landed on whichever market held that pool next.
    `getBinaryPositionPnL` and `getOpenPositionsWithPnL` could both report a wrong
    `avgCost`, `costBasis`, `unrealizedPnl` and `realizedPnl`. They now key by the
    stable `market_id` that `FillRow.market` has carried since 0.28.0. **Reported
    money values change** for any wallet that traded more than one market on the
    same pool; spot and perp are unaffected, since their market id IS the pool
    address.
  - `getOpenPositionsWithPnL` now asks the indexer only for the fills and router
    actions of the markets it is folding. Both reads are capped at 1000 rows and
    the cap drops the OLDEST rows first — the buys and mints that establish the
    cost basis — so a wallet with a long cross-market history could be priced off
    a partial book.
  
  ### Fixed — portfolio analytics reject a non-finite sampling bound
  
  `computePortfolioAnalytics` sampled its equity curve until the sample time
  reached `asOf`. A non-finite `asOf`, or a window start derived from a non-finite
  event timestamp, left that comparison false forever while every iteration
  appended another point, so the call exhausted memory instead of reporting a bad
  argument.
  
  The fold now validates `asOf`, every event timestamp, and the derived window and
  bucket bounds before it sorts, allocates, or samples. An invalid value throws
  `InvalidInputError` naming the field. Nothing is substituted and no event is
  dropped: an invalid time is the caller's to fix, and guessing one would misplace
  money on the curve. The new `ComputePortfolioAnalyticsError` type states the
  errors the function can throw.
  
  `fetchPortfolioAnalytics` now skips a fill whose indexed timestamp is unusable —
  nullish, blank, or non-numeric — the same way it already skips a fill whose
  direction it cannot determine. The check runs before the market is recorded and
  before a candle range is derived, so one unusable row cannot move an `all`-window
  start. Blank strings matter here specifically: `Number("")` is zero, which would
  have placed a fill in 1970 rather than failing.
  
  A finite bound is not automatically a sampleable one. A fixed timeframe subtracts
  a constant, so its span is its own definition, but the `all` timeframe opens at
  the earliest event — one funding event dated far enough in the past asked the fold
  for millions of points and exhausted the heap. The fold now rejects a span above
  100,000 sample points, naming the implied count. That is roughly 11 years of
  hourly buckets or 270 years of daily ones, so a real portfolio never meets it: a
  twenty-year history samples about 7,300 points and is unaffected.
  
  The sampling loop itself stays uncapped. Capping it would turn a loud failure into
  silently truncated financial output; refusing an impossible span is the loud
  version.
  
  ### Added
  
  - `getUserFills` / `countUserFills` take `market` (one bytes32 market id) and
    `markets` (several) alongside the existing `pool`, as the exported
    `FillsScope`. `getRouterActions` takes `markets` as
    `RouterActionsOptions`. The predicates run at the indexer, so a `limit`
    applies to the rows you asked for. Prefer `market` over `pool` on binary: a
    pool selects every market that ever used it.
  
  ### Added — closing-price snapshot voids (TRAD-106)
  
  Venue-selectable void payouts: a `CLOB_SNAPSHOT` venue's markets pay `[p, D−p]` at their closing
  YES price when they void, instead of the uniform 0.50 refund.
  
  - **Venue params v3**: `BinaryVenueParams` gains optional `voidPolicy` (`"UNIFORM"` default |
    `"CLOB_SNAPSHOT"`; exported as `VenueVoidPolicy`). `encodeBinaryVenueFeeParams` encodes v3
    through the module's on-chain encoder; `decodeBinaryVenueFeeParams` (and the BINARY_V1 plugin
    codec) dual-decodes — legacy 192-byte v2 payloads stay valid with an implied `UNIFORM`.
  - **`trader.captureClose({ pool, maxSteps? })`** — the permissionless closing-price capture that
    lifts the pool's closing-book lock. Sweep keepers: a `CloseNotCaptured` revert on a post-expiry
    cancel/sweep means "capture first, then retry"; after a normal resolution the lock lifts by
    itself and capture is never needed.
  - **Reads**: `client.getClosingPrice(pool)` (returns `ClosingPriceState` or `null` on pre-capture
    pools — the selector doubles as the capability probe), `closingTop` in the pool read ABI, and
    `voidPolicy` on `getMarketOnchain` (null on pre-policy clones) + the indexed `BinaryMarket` row.
  - **Errors**: the contract-error table now decodes the capture surface
    (`CloseNotCaptured`, `CloseAlreadyCaptured`, `CaptureTooEarly`, `CaptureStepsExhausted`,
    `InvalidVenueVoidPolicy`, `InvalidVoidPolicy`).
  
  ### Added — dead-oracle recovery without `cast send`
  
  `Trader` gains the two entry points the recovery path was missing, so the whole
  chain is SDK-callable (`syncSettlement` / `finalizeMarket` / `releasePool` were
  already there):
  
  ```ts
  // 1. Always try the real answer first — voiding pays 1/N, resolution pays winners.
  await exchange.trader.pokeOracle({ oracleQuestionId });
  
  // 2. Only once expiry + settlementWindow has lapsed:
  await exchange.trader.voidExpired({ marketId });
  await exchange.trader.syncSettlement({ marketId });  // void bypasses the module
  await exchange.trader.finalizeMarket({ marketId });
  await exchange.trader.releasePool({ marketId });
  ```
  
  - `pokeOracle` is keyed by **oracle question**, not market: the module fans out
    to every market bound to that question and resolves the ones whose adapter
    answers. A success is not "all bound markets resolved". `OracleNotAnswered`
    (none answered) and `UnknownOracleQuestion` (nothing bound) arrive as
    `ContractRevertError`, so a keeper loop can branch on `errorName`.
  - `voidExpired` writes to the market contract, bypassing the module — hence the
    `syncSettlement` follow-up to release the hub earmark. Before sending, it reads
    the market's state and throws `InvalidInputError` naming the exact unix second
    the window lapses, because the on-chain `SettlementWindowOpen` revert carries no
    timestamp. The gate is compared against the chain's `block.timestamp` (what the
    contract itself uses), not the local clock. Pass `skipPreflight: true` to send
    blind.
  
  OPERATOR-GUIDE §5's recovery runbook now shows these calls instead of
  `cast send`.
  
  ### Added — funding-aware capital base
  
  `mwrr` carries two new fields. `weightedCapitalUsd` is the Modified Dietz
  denominator. `capitalBasis` names the definition that produced the figures:
  `"funding"` when external capital movements bearing on the window were supplied,
  `"trades"` for the fallback proxy that cannot see capital which never passed
  through a trade.
  
  `fetchPortfolioAnalytics` accepts a `funding` option, and the new
  `PortfolioFundingEvent` type describes a deposit or withdrawal. Funding events
  refine the capital base only — they never enter the equity curve, profit and
  loss, or volume. They are caller-supplied, because the indexer has no wallet
  token-transfer entity and the public RPC caps log queries at 1,000 blocks.
  Source them from application records, bridge history, or a private RPC scan, and
  exclude transfers whose counterparty is a venue contract or the same capital is
  counted twice.
  
  `PortfolioFlowEvent` is now a union of `PortfolioTradeEvent` and
  `PortfolioFundingEvent`. Code that constructs trade events with an explicit
  `kind: "trade"` is unaffected.
  
  ### Fixed — `balanceFloor` proves its guarantee against the write path's conversion
  
  `balanceFloor` returns the largest number that does not exceed a raw balance, so
  a caller can size a "max" order without it reverting for insufficient funds. It
  verified that by round-tripping its candidate through
  `candidate.toFixed(decimals)`, which is not the conversion a write performs. A
  number-to-raw conversion prefers the shortest string that round-trips to the same
  double, so `1.001` converts as `"1.001"`, while `(1.001).toFixed(18)` is the
  smaller `"1.000999999999999890"`. Checking the smaller value passed candidates
  that the write path then converted to more raw units than the wallet held.
  
  Over a deterministic sweep of 4,004 balances around a lot boundary at 18
  decimals, 407 results converted above the balance and 110 still exceeded it after
  the venue's lot floor, by up to 110 wei. Six-decimal balances were unaffected, so
  this reached only 18-decimal venues — currently the only mainnet precision.
  
  The check now uses the same conversion as the write path. Results move by at most
  a few hundred wei, and after the venue's lot floor the placeable maximum is
  unchanged for every balance in that sweep, so no caller loses usable size.
  `floorRawBalance` inherits the fix on its fallback path. `ceilRawAmount` was
  never affected: it returns `bigint` and never round-trips through a double.
- Read one account's stored perp leverage cap in a single call: `getPerpMaxLeverage`.
  
  `MarginBank.getMaxLeverage(account, pool)` is one storage read, but the only
  published route to it was `getPerpLeverage`, which walks the whole account and
  throws when any market the account is active in has a stale mark. A per-position
  badge or leverage dialog can now read the cap alone — it survives dead feeds on
  other markets, accepts `opts.blockNumber` for pinned rows, and returns `0`
  unchanged when no cap is set (that means "no account cap", not zero leverage).
- Report a perp pool's aggregator oracle address on `getPerpState`.
  
  `PerpStateOnchain` gains an `oracle` field, read from the pool's own `oracle()` view.
  A consumer building a perp market description from `getPerpState` no longer has to omit
  the oracle or keep a parity read of its own against the pool.
  
  The read joins the existing pinned multicall, so it costs no extra round trip on that
  path and one parallel read on the `ChainDoesNotSupportContract` fan-out.
- ### Added — name a revert from your own call
  
  `decodeRevert`, `contractErrorsAbi` and the `RevertContext` type are now exported
  from the package root.
  
  The SDK could already name a protocol revert: `decodeRevert` turns whatever a node
  threw into a `ContractRevertError` carrying the Solidity error name, decoding
  against `contractErrorsAbi` (500 custom-error entries). Neither symbol was on the
  barrel, and the `exports` map has no wildcard, so a deep import could not reach
  them either. A consumer holding revert data from its own call had no path to a name
  and had to hand-copy error fragments — which is how a signature drifts in silence.
  `ContractRevertError` alone is not a substitute: it is a passive holder that decodes
  nothing, so constructing one from raw revert data yields `errorName: undefined`.
  
  ```ts
  import { decodeRevert } from "@somnia-chain/markets-sdk";
  
  try {
    await publicClient.simulateContract(request);
  } catch (caught) {
    const err = decodeRevert(caught, { address: pool, functionName: "placeOrder" });
    console.log(err.errorName); // e.g. "InvalidTriggerPrice", not opaque hex
  }
  ```
  
  `contractErrorsAbi` is a generated artifact (`pnpm errors:gen`, verified in CI by
  `pnpm errors:check`). Publishing it freezes that artifact as public API
  deliberately: the point is that a consumer's copy stops drifting when ours is
  regenerated. Both values are pure over caller-held data — no transport, no client,
  no owner — so they sit at the root under `SDK-API-003` alongside the other ABI
  data. `toSdkError` and `isRevert` remain internal; ask if you need them.
- ### Breaking — `UnifiedOrder.type` may be `undefined`
  
  `fetchOrders`, `fetchOpenOrders` and `watchOrders` now omit `type` instead of
  reporting `"limit"`.
  
  That `"limit"` was never a read value. The pools do not emit the order type —
  `OrderPlaced` carries a `placedOrder` struct with no order-type member, and
  binary pools emit only the YES/NO side — so the indexer has nothing to store and
  the SDK had nothing to read. Four read paths wrote the literal anyway, which
  made **every market order the SDK returned claim to be a limit order**, with no
  field a consumer could check to tell the difference.
  
  Omitting it is the honest answer: absent means unknown, where before a wrong
  value was indistinguishable from a right one.
  
  The field is still present wherever the value is genuinely known:
  
  - `createOrder()` echoes the caller's own argument.
  - `fetchOpenStopOrders()` reads it from the registry row — `PendingOrderCreated`
    does carry `orderType`.
  - `UnifiedStopOrder.type` is unchanged and still required.
  
  ```diff
  - if (order.type === "limit") { /* true for every order, including market ones */ }
  + if (order.type === "limit") { /* now only where the type is actually known */ }
  ```
  
  If you branch on `order.type` from a read path, that branch was reading a
  fabricated value. Handle `undefined` explicitly; the honest source for how an
  order was placed is your own `createOrder` result. Populating it on read paths
  needs the pools to emit the order type, which is a contract change.
- BREAKING — a binary market that has never traded reports NO mark, instead of a mark of zero
  
  `computePositionPnL` and `computeBinaryPnl` valued an unpriced market at a YES
  price of zero. A binary market has no price until its first trade: the indexer
  creates the row with no `lastPrice`, and only a fill writes one. `markYesPrice`
  reports that correctly as `null`. Both PnL folds discarded the `null` and put a
  confident `0` in its place.
  
  The NO leg is derived as the complement of the YES mark. A YES mark of zero is
  therefore a NO mark of the FULL collateral unit. On a 10.00 basis, a wallet
  holding only NO read as a 5.00 gain, and a wallet holding only YES read as a
  5.00 total loss. A wallet holding both legs cancelled to 0.00, which hid the
  fault in the blended total while both legs stayed wrong. Minting a complete set
  is the ordinary way to take a position in a market that has not traded, so this
  state is reachable.
  
  The unknown price now travels as `null` rather than a number:
  
  - `BinaryOutcomePositionPnL.markPrice`, `.markValue` and `.unrealizedPnl` are
    `bigint | null`.
  - `BinaryPositionPnL.markValue` and `.unrealizedPnl` are `bigint | null`.
  - `BinaryOutcomePnl.mark`, `.value` and `.unrealized` are `number | null`.
  - `BinaryPnl.unrealized` and `.total` are `number | null`.
  
  **Migration.** Handle `null` on those fields before you display or total them.
  `null` means the market has no price to mark against — show it as unknown, and
  do not treat it as zero. Every field that does not depend on a mark keeps its
  old type and value: `balance`, `costBasis`, `avgCost` and `realizedPnl` stay
  exact.
  
  Nothing else changes. A market with a last trade or a resting quote marks as
  before. A resolved market still marks the winner at one whole collateral unit
  and the loser at zero, and a voided market still marks both legs at half,
  because a settlement payout needs no price. While a market is unresolved and
  priced, its two legs still sum to exactly one collateral unit.

### Patch Changes

- Document `StaleMark` in the `Order.cancelReason` vocabulary.
  
  The indexer writes five cancel reasons but both published vocabularies listed four, so
  the served GraphQL description and the SDK contract told a consumer that a value it can
  actually receive does not exist. No behaviour or type change: `cancelReason` remains an
  open `string | null`.
- ### Fixed — pre-fill maker cancellations now leave the live order book
  
  Deployed perp pools cancel resting maker orders before any fill — negative
  equity, stale mark, exceeds position, self-match — and the SDK's live tail
  dropped those events undecoded. The cancelled order stayed `Open` in the local
  store, so the order book kept counting its quantity at a price level the chain
  had already cleared: phantom depth in every rendered book and market-maker
  view, for the whole life of the watch.
  
  `OrderCancelledPreFill` (the base event for every pre-fill removal) and the
  perp reason tags `MakerOrderCancelledNegativeEquity` and
  `MakerOrderCancelledStaleMark` are now declared and terminate the order;
  `MakerOrderCancelledExceedsPosition` and `OrderCancelledSelfMatch` were already
  declared and are now covered by tests. Termination only — no store shape
  change and no new fields.
- Account-scoped fill reads match indexed columns instead of joining through the taker's order
  
  Three reads found the fills an account participated in by OR-ing a predicate on
  the taker ORDER's `owner` alongside the denormalized columns:
  `getUserFills`/`countUserFills`, the binary `getPortfolio`, and the user
  snapshot the live store hydrates from. That arm is a relationship predicate, and
  Hasura compiles it to a correlated `EXISTS` subquery. Postgres cannot combine such a subquery with a bitmap OR over the
  `maker` and `taker` indexes: the plan walks the `timestamp` index backwards
  testing each row, and every row it walks past costs a lookup into `Order`.
  
  The penalty therefore falls hardest on ordinary wallets, which are sparse in a
  table that market makers fill. A busy maker reaches `limit` in a few rows and
  barely notices; a wallet with a handful of fills scans a long way and pays the
  subquery the whole way down. On the development indexer, four real single-fill
  wallets took 2.78s, 4.50s, 8.75s and 10.93s to sweep their tape, against 0.04s,
  0.41s, 0.26s and 0.27s once the arm was gone.
  
  The arm found nothing the other two missed. `backfillTakerFills` in the indexer
  stamps the taker address from `OrderPlaced` for SPOT, PERP and binary alike, and
  the taker's order is placed in the same transaction as the fill it crosses — a
  resting order occupies the maker seat by definition — so no committed row can
  lack it. `takerOrder: Order!` is non-null in the schema for the same reason. The
  docblock that defended the arm described binary as leaving `taker` null; that
  stopped being true when binary side attribution moved to `BinaryOrderPlaced`.
  
  Reads that page a wallet's whole tape gain the most, because they pay the
  penalty once per page. The three also compound: a trader portfolio sweeps its
  fill tape, reads its binary positions and hydrates its snapshot, so one light
  wallet paid the scan three times over. Measured together on the development
  indexer, one page of each took 13.89s before and 0.27s after.
  
  No signature, option or row shape changes, and the rows returned are the same
  rows. `takerOrder { owner side }` is still SELECTED: `side` is what makes a
  binary row mappable when the fill's own `takerSide` copy is still lagging, and
  selecting a non-null relationship is an ordinary join rather than a filter.
  The invariant holds structurally: the indexer writes `Fill.taker` and
  `Order.owner` from the same local in the same handler, so the two cannot
  disagree — delegated and operator-placed orders included. `sdk-e2e` also asserts
  it against a live indexer, though that suite is hand-run rather than a CI gate.

## 0.28.1 (2026-08-21)

A single fix on top of 0.28.0.

### Fixed — five perp names the client-scoped move left unreachable

0.28.0 internalized the connection-bound free functions, but five names left
the root barrel without arriving on `SomniaMarketsClient`. They were reachable
by no route at all: absent from the root, absent from the client, and not
deep-importable, since the package publishes only `.` and `./react`. Each is
now where the client-scoped rule puts it.

Two are connection-bound, so they are client members:

| Name | Use |
|---|---|
| `getPerpStopOrder` | `exchange.client.getPerpStopOrder({ registry, orderId })` |
| `getPerpStopOrderSomiPayment` | `exchange.client.getPerpStopOrderSomiPayment(registry)` |

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

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

- `perpLiquidationPrice`
- `perpOrderMarginQuote`
- `perpPositionAnalytics`

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

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

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

## 0.28.0 (2026-08-20)

The API is now client-scoped — the one breaking item — and the published
package can be imported again. The write path aligns prices and amounts to the
venue's own tick and lot grids, and gains build-only order verbs (unsigned
calldata) plus single and batch order amendment. Reads gain the stable market
id on order and fill rows and the liquidation-keeper views.

### Breaking — client-scoped entrypoints (53 free functions + the `./lend` subpath removed)

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

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

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

// after
const tops = await exchange.client.getBookTops(marketIds);
```

| Removed root export | Use instead |
|---|---|
| `countOrders` | `exchange.client.countOrders(…)` |
| `countUserFills` | `exchange.client.countUserFills(…)` |
| `creditOf` | `exchange.client.creditOf(…)` |
| `earmarkedOf` | `exchange.client.earmarkedOf(…)` |
| `getAccountHealth` | `exchange.client.getAccountHealth(…)` |
| `getAllOpenOrdersOnchain` | `exchange.client.getAllOpenOrdersOnchain(…)` |
| `getBinaryBookParams` | `exchange.client.getBinaryBookParams(…)` |
| `getBookTops` | `exchange.client.getBookTops(…)` |
| `getFreePools` | `exchange.client.getFreePools(…)` |
| `getFundingPayments` | `exchange.client.getFundingPayments(…)` |
| `getFundingRateHistory` | `exchange.client.getFundingRateHistory(…)` |
| `getLiquidationPrice` | `exchange.client.getLiquidationPrice(…)` |
| `getLiquidations` | `exchange.client.getLiquidations(…)` |
| `getMarginEvents` | `exchange.client.getMarginEvents(…)` |
| `getMarketByPool` | `exchange.client.getMarketByPool(…)` |
| `getMarketCreator` | `exchange.client.getMarketCreator(…)` |
| `getMarketResolution` | `exchange.client.getMarketResolution(…)` |
| `getOpenInterestHistory` | `exchange.client.getOpenInterestHistory(…)` |
| `getOpeningPrices` | `exchange.client.getOpeningPrices(…)` |
| `getOperatorHubAccount` | `exchange.client.getOperatorHubAccount(…)` |
| `getOracleAdapter` | `exchange.client.getOracleAdapter(…)` |
| `getOracleQuestion` | `exchange.client.getOracleQuestion(…)` |
| `getOrderOnchain` | `exchange.client.getOrderOnchain(…)` |
| `getOwnOpenOrdersOnchain` | `exchange.client.getOwnOpenOrdersOnchain(…)` |
| `getPool` | `exchange.client.getPool(…)` |
| `getPoolBindings` | `exchange.client.getPoolBindings(…)` |
| `getPoolCreator` | `exchange.client.getPoolCreator(…)` |
| `getRouterActions` | `exchange.client.getRouterActions(…)` |
| `getSchedulingCost` | `exchange.client.getSchedulingCost(…)` |
| `getVaultBalance` | `exchange.client.getVaultBalance(…)` |
| `getVaultPayoutFallbacks` | `exchange.client.getVaultPayoutFallbacks(…)` |
| `listBuilderApprovals` | `exchange.client.listBuilderApprovals(…)` |
| `listBuilderFees` | `exchange.client.listBuilderFees(…)` |
| `listFundingRateCandles` | `exchange.client.listFundingRateCandles(…)` |
| `listFundingRateHistory` | `exchange.client.listFundingRateHistory(…)` |
| `listMarketCreators` | `exchange.client.listMarketCreators(…)` |
| `listOperatorHubAccounts` | `exchange.client.listOperatorHubAccounts(…)` |
| `listOracleAdapters` | `exchange.client.listOracleAdapters(…)` |
| `listOracleBinds` | `exchange.client.listOracleBinds(…)` |
| `listOracleCallbacks` | `exchange.client.listOracleCallbacks(…)` |
| `listOracleQuestions` | `exchange.client.listOracleQuestions(…)` |
| `listPerpFees` | `exchange.client.listPerpFees(…)` |
| `listPerpOrderHistory` | `exchange.client.listPerpOrderHistory(…)` |
| `listPerpPositions` | `exchange.client.listPerpPositions(…)` |
| `listPerpStopOrders` | `exchange.client.listPerpStopOrders(…)` |
| `listProtocolFees` | `exchange.client.listProtocolFees(…)` |
| `listSeries` | `exchange.client.listSeries(…)` |
| `listSettlementFees` | `exchange.client.listSettlementFees(…)` |
| `listSweepableOrders` | `exchange.client.listSweepableOrders(…)` |
| `outstandingOf` | `exchange.client.outstandingOf(…)` |
| `quoteCreateMarketValue` | `exchange.client.quoteCreateMarketValue(…)` |
| `resolveReserve` | `exchange.client.resolveReserve(…)` |
| `withdrawableOf` | `exchange.client.withdrawableOf(…)` |
| `createLendWithDeps` (from `/lend`) | `exchange.client.lend` (built by the client) |
| any other `/lend` import | same name, `from "@somnia-chain/markets-sdk"` |

### Fixed — a scoped or windowed `fetchMyTrades` returns the rows it was asked for

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

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

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

Two behavioural changes worth knowing:

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

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

### Added — `fetchOpenOrders` accepts a row limit

It took only `(ref?)`, so a caller could not raise the 200-per-venue default.
It now takes `(ref?, limit?)` and forwards it to the query. The limit is per
venue, not a merged total — an unscoped call reads all three. Note the spot
portfolio query binds one limit variable to both its open orders and its
pending stop orders; that coupling is now documented on the verb.

### Added — the stable market id on order and fill reads

Every row of `getOrders`, `getOpenOrders`, `listSweepableOrders`, `getFills`
and `getUserFills` now carries **`market`** — the bytes32 market id, the stable
identity of the market the row belongs to. A pool address is not a market
identity: on binary event contracts one pool is recycled by successive markets,
so the same pool names a different market depending on when the order was
placed. Pass the id straight to `client.getMarket(id)`; no new API.

Order rows additionally carry **`marketInfo`** (`asset`, `question`, `expiry`,
`tradingStart`, `quoteDecimals`, `intervalSec`, plus the derived `interval`
label), so a history view can label rows without a second round-trip.

### Fixed — the published package can be imported

Every release from 0.20.0 through 0.27.0 shipped a `dist` in which no relative
import specifier carried a file extension. Node's ESM resolver requires them, so
`import "@somnia-chain/markets-sdk"` threw `ERR_MODULE_NOT_FOUND` before any user
code ran, on every declared subpath. `./chains` failed differently, with
`ERR_UNSUPPORTED_DIR_IMPORT`, because two specifiers named a directory.

This was not limited to plain Node. webpack — and therefore Next.js — failed too,
with "resolved as fully specified" errors; a consumer could only compile by
setting `resolve.fullySpecified: false`. esbuild, Rollup, Vite and `tsx` worked
only by legacy extension guessing. There was no CommonJS fallback, and `src` in
the tarball was unreachable behind a strict `exports` map.

The package now compiles with `moduleResolution: "nodenext"`, so every relative
specifier carries its extension and the compiler rejects the form that caused
this. The public API, the exports map and the module format are unchanged — imports
that used to throw now resolve.

Nothing caught this for seven releases because vitest and `sdk-e2e` consume the
SDK through the workspace `exports` block pointing at `src/*.ts`, so the test
surface never loaded `dist`. Publishing is now gated on
`scripts/publish-import-gate.mjs`, which packs the tarball, installs it into a
bare `{"type":"module"}` project with no bundler, and imports every subpath the
published exports map declares.

### Fixed — the write path now applies the venue's tick and lot grids

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

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

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

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

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

Two visible consequences:

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

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

### Fixed — a float price could not be placed on an 18-decimal venue

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

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

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

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

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

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

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

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

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

### Fixed — raw → human scaling no longer divides floats

`Structs.toHumanNum` scaled with `Number(raw) / 10 ** decimals`. The division is a
float operation, so it could land a few wei off the value it was handed:
`19000000000000000000000` came back as `19000.000000000004`, which is not on a
1e15 grid. That silently undid the bigint alignment `priceToPrecision` had just
performed — about a quarter of ordinary prices above ~1000 were returned OFF the
grid they had been snapped onto, and `createOrder` re-converts that number before
sending it, so the result was `InvalidPrice` again. Spot and perp prices live at
exactly those magnitudes; the binary probabilities under 1 that the rest of this
entry is about never showed it.

It now scales through the exact decimal string, like `toHumanString` already did.
The result is still a `number`, so it is still display-grade past ~15 significant
digits — but it is the closest double to the true value rather than the closest
double to a lossy quotient. This affects every `toHumanNum` caller, and only ever
in the direction of accuracy: the two differ solely where the division was wrong.

### Fixed — `getPerpState` reads every field at ONE block

`getPerpState` fanned out with `Promise.all` over nine independent `readContract`
calls, so its values could come from different blocks. A mark price from block N
beside a funding rate from block N+2 describes a market state that never existed —
and a price bar rendering the pair presents it as if it did.

Every read is now pinned to a single block number. The pin, not the multicall, is
what provides the guarantee: viem splits a batch into several `eth_call`s once the
calldata exceeds `batchSize`, and those can straddle blocks.

Where the client's chain declares a Multicall3 — `viem/chains`' `somnia` and
`somniaTestnet` both do — the nine reads also batch into one `aggregate3`, so the
call costs two round-trips instead of nine. A chain built with a bare `defineChain`
declares no contracts, so it keeps the fan-out and costs one MORE round-trip than
before (the block pin). Correctness is identical either way.

No API change — `PerpStateOnchain` and the `getPerpState(pool)` signature are
unchanged. `tryGetMarkPrice` is still the mark read, so a stale feed still returns
`markPriceOk: false` rather than throwing.

`withTypedReadErrors` now also wraps `multicall`. Viem binds `client.multicall` to
the raw client, so the existing `readContract` wrapper never fired for batched
reads — without this, a revert read through multicall escaped as a bare viem error
while the identical read through `readContract` threw a typed SDK error.

### Added — caller-set expiry and builder attribution on spot and perp orders

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

```ts
await exchange.trader.placeSpotOrder({
  pool, isBid: true, price, quantity, baseDecimals, quoteToken, baseToken,
  expireTimestampNs: 1_800_000_000_000_000_000n, // was pinned to ~50y (GTC)
  builder: "0xYourFrontend",                     // was pinned to the zero address
  builderFeeBpsTimes1k: 25_000n,                 // was pinned to 0
});
```

- `PlaceSpotOrderParams` gains `expireTimestampNs`, `builder`, and
  `builderFeeBpsTimes1k`. `PlacePerpOrderParams` gains the two builder fields
  (it already had `expireTimestampNs`).
- Every field is optional and defaults to the value that was hardcoded before
  (`farFutureNs()`, the zero address, `0n`), so existing callers are unaffected.
- A nonzero builder fee still requires a prior `Trader.approveBuilder` on that
  pool — the approval is stored **per pool**. See `docs/SPOT.md` and
  `docs/PERPS.md` for the fee ceiling and the two spot-expiry traps (a past
  expiry places nothing silently; an expired order does not auto-return its
  escrow).

### Fixed — `SomniaMarkets.createOrder` dropped builder attribution on spot and perp

`createOrder` accepted `builder` / `builderFeeBpsTimes1k` but only forwarded
them on binary markets. A spot or perp order placed through the unified surface
rested with the zero-address builder: the call reported success, and no fee was
attributed. Both fields now reach all three branches, and they are no longer
documented as "BINARY only".

### Added — liquidation-keeper reads: `getPerpSideHolders` + `getBankruptcyPrice`

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

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

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

### Added — build-only order placement (unsigned calldata)

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

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

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

The approval is **returned, not sent** — `placeOrder` approves as a side effect,
which a build-only verb cannot do. Send `approval` first when it is present. It
is absent when nothing needs approving (a native-base spot sell, every perp
placement).

Also newly exported from the root, so a caller can encode a placement by hand:
`ORDER_KIND`, `binaryPoolWriteAbi`, `spotPoolWriteAbi`, `perpPoolWriteAbi`, and
the `UnsignedCall` / `UnsignedOrder` types. (A returned `approval` is a standard
ERC-20 `approve` or ERC-6909 `setOperator` — decode it with viem's own
`erc20Abi` or the already-exported `erc6909Abi`.)

### Added — `ceilRawAmount`

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

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

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

### Fixed — stop-order and SpotPool reverts have names instead of hex

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

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

- **All 28 `ISpotStopOrderRegistry` errors** — including `InsufficientVaultBalance`,
  `InsufficientSomiPayment`, `NoActiveSubscription`,
  `LimitPriceIncompatibleWithTrigger` and `ExceedsWithdrawableBalance`.
- **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 existing `QuantityBelowMinimum(uint256,uint256)`. Different selectors,
  so both entries are needed; only the two-argument one was present before.

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

### Added — `trader.amendOrders`, cancel and replace N orders atomically

Amending a quote one order at a time costs two transactions per rung and leaves the
book briefly one-sided. `amendOrders` does the whole set in a single transaction:
every old order is cancelled and its replacement placed, or the batch reverts and
nothing moves.

`alwaysPlace` handles the race where an order you meant to amend has already filled
or been cancelled by the time the tx lands. False (the default, and the
industry-standard posture) reverts `AmendOldOrderGone`; true skips the cancel leg
and places the replacement anyway. It only tolerates a *gone* order — someone
else's live order still reverts `IncorrectSender`.

`newOrderIds` comes back index-aligned with the submitted amendments. The pool
returns those ids on-chain, but a mined receipt exposes only logs, so they are
reconstructed from `OrderPlaced` — which fires for every accepted order, including
one that fills completely without ever resting.

A rejected replacement reverts `AmendReplacementRejected(requestIndex, reason)`, which
names both the rung and the `OrderRejectionReason` — the signal to branch on. Note the
cancel-all-first phase shields replacements from the orders they replace but **not from
each other**: a new bid crossing a new ask self-matches and, being all-or-nothing, takes
the whole re-ladder with it.

Unlike `placeSpotOrder`, this does not auto-approve escrow. On an auto-pull pool the
cancel leg returns freed tokens to the wallet and the place leg pulls them back, so a
trader whose first call is `amendOrders` needs an allowance already in place.

**SpotPool and PerpPool only.** A BinaryPool reverts `UseBinaryPlacement` for the
whole transaction: binary placement carries the YES/NO kind through a transient slot
that only `placeBinaryOrder` sets, and there is no batch binary entry.

### Added — `trader.amendOrder`, the single-order re-quote

The batch `amendOrders` landed without its singular sibling, so re-quoting ONE order
meant either a two-transaction cancel-then-place (which opens a gap on the book — the
thing amend exists to prevent) or a one-element batch with a worse error surface.

`amendOrder({ pool, oldOrderId, alwaysPlace?, newOrder })` calls the pool's own
`amendOrder`, so a rejected replacement raises its landing-time reason directly
(`PostOnlyWouldCross`, `SelfMatchCancelTaker`, `ImmediateOrCancelNoFill`,
`FillOrKillNotFillable`, `OrderAlreadyExpired`). The batch wraps the same failure as
`AmendReplacementRejected(requestIndex, reason)`, which for a single order is an index
the caller already knew and a reason they then have to unwrap.

Everything else matches the batch verb: `alwaysPlace` defaults to false and reverts
`AmendOldOrderGone` on a vanished order (true opts into an upsert, and never tolerates
an ownership failure); the replacement gets a NEW id and loses queue priority — use
`reduceOrder` to shrink in place; and the call is non-payable, so native replacements
need a manual-vault balance rather than auto-pull.

### Added — claim the SOMI a perp stop registry owes you

`trader.claimPerpStopSomi` and `client.getUnclaimedPerpStopSomi` — the registry holds
SOMI for an account in two cases, and neither was reachable from the SDK: a cancel whose
direct refund transfer failed (a contract owner with no payable receiver), and an
operator winding a registry down, which credits **every** owner including EOAs. Read the
balance first; the claim reverts `NothingToClaim` on zero.

Deliberately not wrapped: `withdrawSomi` is owner-only and cannot touch unclaimed
balances, and `poke()` — permissionless, but the caller subsidises every eligible
trigger, the `somiPaid` is consumed rather than reimbursed, and below the gas reserve it
breaks out and the transaction succeeds having done nothing.

### Changed — faster log decoding on the live tail and the write path

`decodeEventLog` memoizes nothing: for every log it walks the ABI deriving each
candidate selector, and a log the ABI does NOT carry costs a full scan plus a thrown
error that the caller immediately swallows. Foreign logs are the common case — any
ERC-20 `Transfer` from a token touched in the same transaction lands in the same
receipt — and the live tail decodes every block.

A topic0 membership check now runs first. Against `liveEventsAbi` (29 events) an
unmatched log cost 223 µs and now costs 0.054 µs; on a realistic block carrying four
pool events among forty foreign logs, decoding went from **9.00 ms to 0.10 ms**.

Internal only — no API change, and the decoded type is unchanged: the full ABI still
does the decoding, so the discriminated union callers rely on is intact.

### Added — `useIndexerQuery` aborts superseded requests + exported query-key factories

`useIndexerQuery` now creates an `AbortController` per run, hands its signal to
the fetch function (the `fn` signature widened to `(client, signal)`, an
additive change), and aborts on dependency change, refetch and unmount — a late
settle from a superseded run never reaches state, even when `fn` ignores the
signal.

The root entry also exports query-key factories for callers wiring client reads
into their own query library: `marketsKey`, `portfolioKey`, `candlesKey`,
`marketFeesKey`, `operatorsKey`, `marketCreatorsKey`, `oracleAdaptersKey`,
`syncStatusKey`, `maxVenueFeeBpsKey` and `marketOnchainKey`. Plain functions,
no query-library dependency, returning JSON-serializable, case-folded,
scope-prefixed arrays.

Client reads themselves still take no per-call `AbortSignal` — cancellation
remains client-scoped via `ClientConfig.signal`; the hook's signal benefits
fetch functions that do their own fetching.

## 0.27.0 (2026-08-14)

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

### Breaking

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

### Added — new entry points

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

### Added — perps

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

### Added — everything else

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

### Fixed

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

### Changed

- **`AsyncCache` (MAR2-80)** for promise-memoizing maps.


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

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

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

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

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

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

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

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

## 0.25.0 (2026-08-07)

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

### Added

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

Additive; no breaking changes.

## 0.24.0 (2026-08-07)

The perp read surface, in one release: discovery, positions, orders, risk, margin
preview and protocol state. Plus order state at chain head for every market type.

**Requires a reindex from scratch.** The perp stop registries are newly subscribed,
so their history only exists after a full re-run.

### Breaking — `SpotStopOrder.spotOrderId` is now `placedOrderId`

The field is the id of the order the stop PLACED when it triggered, and both the spot
and perp registries now record it. Rename at the call site; type and meaning are
unchanged. The chain event keeps its own name — this is the SDK/entity field only.

### Added — perp market discovery

`listPerpPoolStatuses({ factory })`, `listTradeablePerpPools(…)`,
`isPerpPoolRegistered(…)`. An on-chain source of truth, so a market deployed after the
indexer's curated manifest is still visible.

**"Deployed" is not "tradeable"** — two independent gates: `restricted` (close-only;
position-increasing orders revert, and it is reversible) and `registered` (the
MarginBank has activated the pool). `tradeable` folds both. `getPoolTier` is not a
substitute — it is itself gated on registration. Do not build a market list from the
factory's raw pool list; that is deployment history and lists wound-down markets as
tradeable.

Each row carries its pool's own `marginBank`, ready for the reads that follow.
Feature-detected by ERC-165 on `IPerpPoolFactoryMarketStatus` (`0xa874fb70`,
exported) with a per-pool fallback; on a chain old enough to need that fallback,
`registered`/`tradeable` come back `null` rather than guessed, and
`listTradeablePerpPools` throws instead of returning a short list that looks
authoritative. Only a missing-selector revert is treated this way — an RPC failure
still propagates.

### Added — `listPerpPositions`

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

Three fields are translated at this boundary because the raw shape would produce
plausible wrong numbers: `size` is **signed** (folded from the entity's absolute size
+ `isLong`), `entryPriceX18` is exported as `avgEntryPrice` (the column name is a
misnomer — the value is raw quote units per whole base, not 1e18-scaled), and
`realizedPnl` is exported as `lastUpdateRealizedPnl` (most recent update only, not
cumulative, not summable).

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

### Added — `listPerpOrderHistory`

`getPerpPortfolio` hard-filters `status = "Open"`, so finished perp orders were
unreadable. This is the other half, most-recently-**ended** first — a long-resting
order that just filled belongs at the top of a history view, not at its placement
date. Note `Closed` is terminal, not transitional: an IOC that partially filled
without resting stays `Closed` forever.

### Added — perp stop orders are listable

`listPerpStopOrders` over the indexed `PerpStopOrderRegistry` set. The registry keeps
pending orders in private storage with no getter, so before this there was no read —
chain or otherwise — answering "what stops do I have"; a trader could create stops but
not see or cancel them.

`builderFeeBpsTimes1k` is captured now precisely because it cannot be captured later:
the registry deletes a pending order on every fire, so `PendingOrderCreated` is the
only record the fee ever existed. One field stays out of reach — a LIMIT stop's
`limitPrice` never leaves calldata and private storage.

### Added — per-market risk parameters

`getPerpRiskParams(pool)`, `getPerpHealthSnapshot(pool)`, `getEffectiveImfBps(pool)`.
`maintenanceMarginBps` was exposed nowhere, so a projected liquidation price for an
unplaced order was impossible.

**Initial margin is not a constant; maintenance margin is.** `initialMarginBps` is the
floor of the curve — with dynamic IMF the pool scales it with open interest, and
`effectiveImfBps` is what an order is actually charged. Sizing off the static base
under-margins whenever OI has pushed the curve up. Maintenance margin deliberately
does not scale. On testnet today both are 500 bps (dynamic IMF off), which is why the
distinction is documented rather than left to be discovered.

`getPerpHealthSnapshot` returns a discriminated union: an unpriceable market surfaces
as `{ priceable: false }` rather than an all-zero snapshot, where a
`maintenanceMarginBps` of `0` would read as "can never be liquidated".

### Added — `previewPerpOrderMargin`

Predicts what a perp order will lock and whether the pool will accept it, before
sending. The two contract probes are also exposed verbatim:
`quoteMeetsPerpImForOrder`, `meetsPerpImForFill`.

**`quoteMeetsIMForOrder` looks like the pre-trade check and is not.** It runs with
`baseImReserved = true`, mirroring the check that happens *after* `lockCollateral` has
already reserved the order's base margin — so called cold it collapses to "does
existing equity cover existing positions" and returned `true` for 10^18 base units
against a live testnet account. It cannot gate an order form.

The preview instead ports `PerpPool._computeLockAmount` and the `MarginBank` gate it
feeds: the reducing/increasing split, **the adverse mark-to-entry reserve** (the usual
reason a naively-sized "max" order is rejected, and a term a `notional × IMF` estimate
misses entirely), the OI-scaled effective IMF, the per-market leverage cap, and the
credit-voucher floor. Two gates are reported separately because they fail for
different reasons: `hasCollateralForLock` means "deposit more",
`meetsInitialMargin` means "close something".

Every read is pinned to one block and `asOfBlock` is returned — composed across blocks
these values tear. A purely reducing order trips neither gate, so a close is reported
as accepted even from an account below initial margin. Not modelled: tick/lot
quantization, position/OI caps, market restriction, the resting-order cap, isolated
margin — all reject independently of margin.

Verified against a live testnet account to the unit: the computed boundary is accepted
at `q` and rejected at `q + 1`, matching the chain's own `meetsIMForFill`.

### Added — perp protocol state

`getPerpSystemConfig`, `getInsuranceFundState`, `getLiquidationEngineConfig`,
`tryGetPerpAccountEquity`, `getPerpCollateralBasis`.

`getPerpSystemConfig` is the entry point — every other contract in the plane is
reachable from it, as the bank's own view of them, so nothing is hardcoded per chain.
Its `fullyWired` flag covers the factory, liquidation engine and insurance fund but
**not** `feeRecipient`, so a go-live check must look at that separately.

Insurance-fund tiers are indexed `0..maxTiers` **inclusive** — `maxTiers` is the
maximum index, not a count, so the fund has `maxTiers + 1` buckets. `totalBalance` is
deliberately not described as absorbable bad debt: it includes tier 0, which never
absorbs anything.

`liquidationEngine` is the proxy, and that distinction bites — an implementation
address answers with unset defaults (zero bidders, zero penalty), which looks like a
configured-but-idle engine rather than the wrong address; the returned `marginBank` is
the cross-check. `bidderCount === 0n` is an operational signal: with no stage-4
backstop bidders the waterfall reaches ADL sooner than the configuration implies.

`tryGetPerpAccountEquity` returns `null` where `getAccountHealth` would revert. Null is
"not computable right now", never "zero equity" — the two mean opposite things.
`getPerpCollateralBasis` is the complement: one storage pair, no oracle, cannot revert.

### Added — perp fields on `fetchTicker`

`UnifiedTicker` gains `markPrice`, `indexPrice`, `fundingRate`, `fundingTimestamp` and
`openInterest`, populated on perp symbols and `undefined` elsewhere, so one call
answers a market header. A non-perp spends no chain round-trip.

`fundingRate` is on the **same per-8h axis** as `fetchFundingRate` — a header reading
one basis beside a chart reading another is a wrong number that looks right. It is not
the per-settlement amount. `markPrice` is omitted rather than reported when the feed is
stale: the contract's `0` sentinel reads as a real price, and an unguarded
`markPrice - entryPrice` becomes a 100% loss on every open position.

### Added — order state at chain head

`getOrderOnchain`, `getOwnOpenOrdersOnchain`, `getAllOpenOrdersOnchain` — so a caller
can read its own writes instead of waiting for the indexer. All three work for binary,
spot and perp pools; fields are raw `bigint` units.

`getOrderOnchain` returns `null` for any id the pool has no **active** order for
(unknown, filled, cancelled, or replaced by `reduceOrder`) — chain head knows what is
open now, the indexed `getOrders` keeps the history. `getOwnOpenOrdersOnchain` takes
the owner explicitly and needs no signer. `getAllOpenOrdersOnchain` never forwards a
configured signer — the pool accepts that view only from the zero address — and
surfaces the contract's own pagination as-is.

### Added — `listSweepableOrders`

`listSweepableOrders({ pool?, marketType?, owner?, asOfSec? })` returns orders past
expiry that are **still resting**, across the whole book — the work-list for a
permissionless sweep. Each row carries `orderId` for `cancelExpiredOrders` and
`isBid` + `price` for `sweepExpiredAtLevel`.

**Deliberately not `status: "Expired"`.** That status is written when the chain emits
`OrderExpired` — i.e. once an order has already been swept. A keeper needs the
opposite: still `Open`, already past expiry. The cutoff is scaled to nanoseconds;
comparing against unix seconds would make every order look unexpired by a factor of a
billion. GTC excludes itself (written as now + 50 years). Longest-overdue first.

### Fixed — binary precision comes from the pool, not a one-token guess

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

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

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

### Fixed — the baked mainnet `marketCreator` pointed at a retired venue

`SOMNIA_MAINNET_ADDRESSES.marketCreator` was `0xfc4Ecc01…` (venue 3, `status:
"retired"`); the active venue 5 creator is `0xfe81C4e8…`. The venue-5 recovery changed
`venues.json` without rerunning `pnpm gen:addresses`, so the stale value shipped in
0.21.0–0.23.0. Mainnet consumers using the baked addresses were filtering the realtime
tail (`liveTail`) on a dead contract's events. Testnet was unaffected.

## 0.23.0 (2026-08-06)

Perp data corrections (open interest, mark staleness, liquidation accounting),
funding-rate series reads, vault funding writes, and a type-tightening sweep
across the client surface.

### Breaking — perp open interest is ONE counter

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

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

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

### Breaking — `UnifiedFundingRate.markPrice` may be `undefined`

`fetchFundingRate()` now reports `undefined` rather than a number when the pool's
mark feed is stale.

The pool signals staleness with a **0 price word** (`tryGetMarkPrice` returns
`(ok, price)`; the event uses a 0 sentinel). That was being converted straight to
a number, putting a mark of `0` on the wire as though it were a real price — and
downstream, an unguarded `markPrice - entryPrice` reads as a 100% loss on every
open position. `info.markPriceOk` carries the flag if you want to distinguish
"stale" from "not a perp".

For marking a position rather than reporting a reading, `perpMarkForPnl(state)`
falls back to the index price and tells you which it used.

### Breaking — `LiquidationEvent.badDebt` is narrower, and there are three new columns

`badDebt` used to receive five different quantities while being documented as
"safe to SUM". It now carries **only** the uncovered, insolvent hole
(`ResidualBadDebt`, `AdlPriceCapacityExhausted`). Alongside it:

| column | source | aggregation |
|---|---|---|
| `badDebt` | `ResidualBadDebt`, `AdlPriceCapacityExhausted` | a LEVEL — never SUM |
| `insuranceCovered` | `BadDebtAbsorbed.covered` | a FLOW — SUM is exact |
| `deficit` | `BadDebtAbsorbed.badDebt`, `ResidualBackedByOpenPnl` | a LEVEL — never SUM |
| `coverageDeclined` | `CoverageDeclinedByEquityCap` | a FLOW — SUM is exact |

If you were summing `badDebt` for a bad-debt figure, that total was
double-counting: the gross hole and its uncovered remainder both landed there, as
did a PnL-backed hole that is explicitly *not* bad debt and a coverage amount the
equity cap deliberately deferred. The correct point-in-time figure is the sum of
the **latest** `badDebt` row per account — a residual is a state sample, so
successive liquidations on one account re-report the same hole.

`BadDebtAbsorbed.covered` and `absorbedBy` are now exposed at all (they were
being dropped); `absorbedBy` arrives on `counterparty`.

### Changed — `getFundingRateHistory` → `listFundingRateHistory`

Renamed for the `get*` = value-or-null / `list*` = array convention in
CONVENTIONS.md. The old name forwards verbatim for one release cycle and is
marked `@deprecated`.

New in the same read: `order: "asc" | "desc"`. The default `"desc"` returns the
newest page, so `from` is a window bound; pass `"asc"` to make it a forward
**cursor**. `fetchFundingRateHistory(symbol, since, limit)` now ascends whenever
`since` is given, which is what makes the ccxt pagination idiom
(`since = last.timestamp + 1`) terminate instead of re-reading the tail.

### Added — funding as a series

`listFundingRateCandles(pool, intervalSeconds, opts)` serves 1h / 4h / 1d rollups
for ranges the raw series is too dense for, plus `densifyFundingBuckets` to fill
the grid slots a sparse query legitimately omits. Rollup buckets are **absent**
where no settlement's span reached them and **revised** when a catch-up
settlement reaches backwards; both are properties of lazy settlement, and the
TSDoc on each says so.

Funding-rate normalization helpers (`fundingRate8h`, `fundingRatePerInterval`,
`annualizedFundingRate`, `realizedFundingPerBase`, …) are exported. A perp
funding rate is per **calculation window** (28800s), not per interval and not
annual, and `fundingWindowSec / fundingIntervalSec` is 96 on testnet against an
expected 8 on mainnet — so the same rate value means a 12× different per-interval
accrual. Normalize with the row's own `fundingWindowSec`; a hardcoded denominator
produces a plausible-looking wrong chart rather than an error.

### Breaking — market addresses and hashes are `Address` / `Hex`, not `string`

Every address and hash on `Market` (and its `SpotMarket` / `PerpMarket` /
`BinaryMarket` variants) is now typed with viem's `Address` or `Hex` instead of
`string`:

- **`Address`** — `poolAddress`, `baseToken`, `quoteToken`, `stopRegistry`,
  `marginBank`, `marketAddress`, `collateral`, `creator`
- **`Hex`** — `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` (always `market.poolAddress`)
- `LiveFill.pool` / `.maker` / `.taker`, `LiveOrder.pool` / `.owner`
- `DecodedEvent.address` (viem already types the log source; the SDK was casting
  the type away and then widening it with `.toLowerCase()`)

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

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

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

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

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

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

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

### Breaking — the viem escape hatch is explicit, and undecorated

`SomniaMarketsClient.publicClient` is replaced by `getViemClient()`.

The property was typed `PublicClient` and named `publicClient`, but it was the
client the SDK had **decorated** — `readContract` / `call` rethrow as typed SDK
errors. So a consumer reading their **own** contract through it lost viem's error
types silently: `catch (e) { if (e instanceof ContractFunctionRevertedError) … }`
simply stopped matching, no error, no warning. Worse on a foreign contract, where
the decoder can't match the selector and produces a `ContractRevertError` with
`errorName: undefined` — viem's typed error traded for nothing.

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

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

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

### Breaking — `createLend` is no longer exported

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

A different deployment means a different client:

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

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

### Breaking — three-identifier reads take one params object

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

On `SomniaMarketsClient`:

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

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

### Changed — input/config failures throw the typed classes everywhere

The last ten `throw new Error(...)` sites in the SDK now throw the documented
classes — `InvalidInputError` (stop-order validation, the `quote*` methods'
`{pool | marketId}` target), `NotConfiguredError` (missing
`config.addresses.lend` entries),
`ContractRevertError` (a lend write mined but reverted), and `RpcError`
(`createStopOrder` receipt missing `PendingOrderCreated`). Messages are
unchanged; only the classes (and their fields) are new. Code that caught plain
`Error` still works — every class extends it.

## 0.22.0 (2026-08-05)

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

### Added

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

## 0.21.0 (2026-08-05)

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

### Added — the typed error tier

Every SDK failure now has a class: `SomniaMarketsError` and its six subclasses
(`InvalidInputError`, `NotConfiguredError`, `SignerRequiredError`,
`IndexerError`, `RpcError`, `ContractRevertError`), exported from the package
root. `ContractRevertError` decodes revert data against the protocol's custom
errors (the generated `contractErrorsAbi`, kept current by the offline
`errors:check` CI gate), so a failed call can report its Solidity error name.
Pure addition in this release — the SDK's own call sites adopt the classes in
the follow-up refactor; nothing thrown today changes class.

Indexer reads are now typed against the GraphQL schema the indexer actually
serves, instead of hand-written types kept in sync by memory. Internal change —
the exported type surface, its TSDoc, and every method signature are unchanged
apart from the nullability corrections below.

### Notes — why the indexer types are generated

Queries were template strings with self-declared result types, so both halves
could drift silently: a query could select a field Hasura no longer had (throws
on every call), and a type could promise a field the wire never carried (`tsc`
green, consumer reads `undefined`). `0.16.0` shipped both at once.

A committed snapshot of the served schema now drives generation, so a renamed,
removed, or retyped field fails `pnpm gql:codegen` / `pnpm typecheck` in the same
PR that changes the schema. Public types stay hand-written; the mapping between
wire and public is compiler-checked.

### Breaking — types corrected to match what the indexer actually returns

Each of these promised non-null on a column the indexer genuinely leaves unset:
`indexer/schema.graphql` declares them nullable, and the handlers carry
`prior?.<field>` forward, so an event arriving before the row's creation event
leaves them empty.

- `IndexerSyncStatus.numEventsProcessed` → `number | null`
- `IndexedOracleAdapter.createdAtTimestamp` → `string | null`
- `IndexedMarketCreator.createdAtTimestamp` / `.factory` → nullable;
  `.createdAtBlock` → `number | null`
- `IndexedSeries.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:pull` now refuses to run against a Hasura that has not finished being
  configured. envio creates relationships ~25s after it starts tracking tables, and
  a snapshot pulled in that window is silently missing every relationship field —
  which then validates cleanly against every offline check.

## 0.20.0 (2026-08-04)

**First public npm release.** The package now publishes to the public npm
registry (`registry.npmjs.org`) — install is just
`pnpm add @somnia-chain/markets-sdk viem`, no GitHub Packages registry
config or token. Versions ≤ 0.19.0 remain on GitHub Packages.

### Breaking — token symbols preserve case

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

### Added — baked-in per-chain addresses

New `SOMNIA_TESTNET_ADDRESSES` / `SOMNIA_MAINNET_ADDRESSES` constants — the
full `SomniaMarketsAddresses` map per chain (including the SomniaLend wiring),
generated from the canonical deployment manifests at release time
(`pnpm gen:addresses` → `src/addresses.ts`). External consumers get a
zero-setup `config.addresses` without the monorepo's private deployments hub.

### Added — book-clamped PnL marks

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

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

### Added — the unified presentation tier

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

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

### Added — stake-sized binary quotes

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

## 0.19.0 (2026-08-03)

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

### Added

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

## 0.18.0 (2026-08-03)

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

### Added

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

## 0.17.0 (2026-07-29)

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

### Changed — quote pinning

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

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

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

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

### Added — freshness

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

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

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

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

## 0.16.0 (2026-07-24)

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

### Breaking — `listBuilderApprovals` matches the indexer entity

The old query selected `pool` and `updatedAt` and ordered by
`updatedAt`, none of which exist on the `BuilderApproval` entity — every call
threw `field 'updatedAt' not found in type: 'BuilderApproval_order_by'` against
a live indexer.

- `BuilderApproval.updatedAt` → **`timestamp`** (unix seconds of the last
  `BuilderApproved` upsert; also the sort key, newest first).
- New fields mirroring the entity: **`market`** (market id), **`blockNumber`**,
  **`txHash`**. `pool` is kept, now joined via the market row.
- Migration: `approval.updatedAt` → `approval.timestamp`; everything else is
  additive.

### Changed — follow-ups from the docs pass

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

## 0.15.0 (2026-07-21)

A1 — resolution surplus refunded to the reserve-PAYER (the market creator) instead
of the operator, and the autonomous MarketCreator self-reclaims its own surplus. Additive
to the 0.14.0 OracleHub surface (no breaking changes).

### Added — OracleHub payer credit

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

### Added — MarketCreator self-reclaim + migration

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

## 0.14.0 (2026-07-20)

Oracle v2 + Settlement v3 for the binary CLOB, plus the operator "market machinery"
management layer. This entry consolidates ALL work since 0.13.0 — the branch-internal
0.15–0.18.x iterations (escrow → prepaid → earmark) were never released, so only the
final state is described here. BREAKING for the OracleHub surface and the binary
settlement/resolution reads.

### Breaking — Oracle v2: earmark-at-creation resolution funding

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

### Breaking — Settlement v3: payout vectors

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

### Added — the operator "market machinery" management layer

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

## 0.13.0 (2026-07-18)

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

### Breaking

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

### Added

**Settlement + redemption**

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

**Pool reuse / bindings**

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

**Trader writes**

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

**Live order book (recycle-safe)**

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

**Reads & analytics (derived; no new indexer field)**

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

**Id helpers, ABIs, rows, live tail**

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

### Migration

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

## 0.12.3 (2026-07-17)

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

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

## 0.12.2 (2026-07-17)

Price-feed schema realignment — the price feed is now the on-chain
`PriceFeedScheduler` (spot index + EMA mark) rather than the per-asset EMA
oracles. The `Feed` / `PricePoint` / `Candle` reads and the live tail target the
scheduler's fields. Requires the price-feed indexer at the PriceFeedScheduler
schema (a full reindex — already live on dev).

### Changed
- Price-feed GraphQL reads map the feed's new server fields onto the SDK's stable
  shape: `price` ← `spot` (median spot index), `ema` ← `mark` (the EMA-smoothed
  perpetual mark), `emaClose` ← `markClose`. The public `LivePrice` / `PricePoint`
  / `PriceCandle` field names are unchanged, so consumers need no code changes.

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

## 0.12.1 (2026-07-16)

Docs-only release — no API, ABI, or behavior change.

### Docs
- Completed guide coverage for 20 previously reference-only client methods: the
  batch price reads (`watchPrices` / `getLivePrices` / `fetchPrices`) + `isTailing`
  (PRICES), spot discovery (`listSpotMarkets` / `getSpotMarket`, SPOT), binary
  discovery (`listBinaryAssets` / `countBinaryMarkets`, BINARY), and the
  cross-cutting reads (`getBalances`, `getOutcomeBalance`, `getErc20Metadata`,
  `getErc20Allowance`, `getContractMeta`, `getMaxVenueFeeBps`, `getOrders`,
  `getUserFills`, `getMarketStatusHistory`, `countMarkets` / `countVenues` /
  `countOperators`, ENGINE). Every public method was already in the generated API
  reference; these are the narrative-guide additions.
- Fixed three broken `{@link}` cross-references in TSDoc (`binaryFillsFor`,
  `computeBinaryPnl`, `listBuilderApprovals`) that rendered as dead links in the
  API reference.

## 0.12.0 (2026-07-16)

Coverage-gaps wave — close the read/write/hook gaps against the coverage-gaps
indexer schema (new perp-account, fee-stream, resolution, router, and
vault-credit entities). Requires an indexer at that schema (a full reindex).

### Added

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

## 0.11.1 (2026-07-16)

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

## 0.11.0 (2026-07-16)

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

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

### Fixed
- `getSystemInfo.binaryMarketImpl` no longer falls back to the (different)
  `binaryPoolImpl` address when the live factory read fails.

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

---

Every page is also Markdown: append `.md` to its URL, or ask for `text/markdown`. Found something wrong, missing or confusing in these docs? Tell us: `POST /api/feedback` on this site with JSON `{"message": "…", "context": "what you were doing and what happened", "page": "…"}`, or a plain-text body. `GET /api/feedback` gives the JSON Schema. The team reads every one.
