Perpetuals

Perps are live on testnet: BTC/USDSO:USDSO and ETH/USDSO:USDSO, backed by PerpPool CLOBs riding the same shared OrderBook core as spot and binary.

The shape

Market is a three-way discriminated union — SpotMarket | PerpMarket | BinaryMarket, keyed on marketType (guards: isSpotMarket / isPerpMarket / isBinaryMarket). A PerpMarket is a plain base/quote book plus perp state: marginBank, initialMarginBps, fundingRate, cumulativeFundingPerUnit, indexPrice, openInterest, fundingWindowSec, fundingIntervalSec.

fundingRate is per CALCULATION WINDOW (fundingWindowSec, 28800s / 8h on every live pool) — not per settlement interval and not annualized. Each settlement accrues rate / n where n = fundingWindowSec / fundingIntervalSec: 96 on testnet (300s settlement) and expected 8 on mainnet (3600s). So the same value means a 12x different per-interval accrual between environments. Normalize with the helpers (fundingRate8h, fundingRate1h, fundingRatePerInterval, annualizedFundingRate), never with a hardcoded denominator.

openInterest replaced longOpenInterest + shortOpenInterest: the contract keeps ONE counter because the short side is provably equal in a matched CLOB. The removed pair was null on every row — the subscription feeding it was dead.

  • Watches and live reads are unchanged. Perp pools emit the same order events, so watchMarket(pool), getLiveSpotOrderBook-style depth, getLiveFills, getLiveUserOrders, and the React hooks just work. Funding (FundingUpdated) and open interest (OpenInterestUpdated) stream into the perp market row live.

  • Margin, not escrow. Collateral (USDso) lives cross-margin in the MarginBank: trader.depositMargin / withdrawMargin move it; trader.placePerpOrder (or plain createOrder on a perp symbol) locks margin from that balance — no per-order token approval.

  • Re-quoting a ladder? Amend it atomically. trader.amendOrders cancels N orders and places their replacements in one transaction (see SPOT.md for the full semantics). On a perp pool it needs no token fields at all — margin comes from the MarginBank, so there is no escrow to approve.

  • Builder attribution. placePerpOrder takes optional builder / builderFeeBpsTimes1k (alongside its existing expireTimestampNs), both defaulting to no attribution. A non-zero fee needs a prior trader.approveBuilder({ pool, builder, maxFeeBpsTimes1k }) and must stay within getMaxBuilderFeeBpsTimes1k(pool) — read it rather than assuming it, since it is owner-updatable on a PerpPool and the rail is off while it is 0. Perp pools implement the same builder calls as binary ones, but the approval is per pool: approving a builder on one pool grants nothing on another, and an unapproved placement reverts BuilderFeeExceedsApproval (a PerpPool has no separate "not approved" check — an absent approval is simply a zero cap).

  • Positions are on-chain reads, not indexed rows: client.getPerpPosition({ marginBank, account, pool }) and client.getMarginAccount(marginBank, account) — or the unified exchange.fetchPositions(). Live pricing/funding comes from client.getPerpState(pool) / exchange.fetchFundingRate(symbol).

  • …except when you want them all at once. client.listPerpPositions(account) returns every pool's position in ONE indexer round-trip instead of a chain read per market — the right read for a positions table. It is a snapshot as of each row's updatedAtBlock and is not marked to market: unrealized PnL, liquidation price and margin health still need the chain reads above. Two things the shape will not let you get wrong, both documented on the type: size is signed (the entity stores magnitude and direction separately; they are folded back together here so a short can't read as a long), and the entry funding index is absent by design — it is not on the schema production serves, so it waits on the next reindex — and anything funding-sensitive belongs on getPerpPosition. Fully-closed positions are excluded unless you pass includeFlat — upserted rows are never deleted, so they linger at size 0 forever.

  • Margin health is a chain read too. getMarginAccount now also returns the requirements + status (imReq / mmReq / cmReq / marginStatus, from MarginBank.getAccountHealth + getMarginStatus); client.getAccountHealth(marginBank, account) is the lighter standalone read when only health matters, and client.getLiquidationPrice({ marginBank, pool, account }) estimates the mark at which this pool's move alone would trip maintenance (null when flat). MarginStatus / MARGIN_STATUS / AccountHealth are exported; exchange.fetchPositions() now fills UnifiedPosition.liquidationPrice.

  • Liquidation price: both sides of the inequality move with the mark. Liquidation begins where equity == mmReq, and mmReq is recomputed on the current mark (ceil(|size| × mark × mmBps / (oneBase × 10000))), so it shrinks under a falling long and grows under a rising short. Solving equity(p) == mmReq(p) therefore carries a 10000 ∓ mmBps factor:

    text
    long    p = mark − (equity − mmReq) × oneBase × 10000 / (|size| × (10000 − mmBps))
    short   p = mark + (equity − mmReq) × oneBase × 10000 / (|size| × (10000 + mmBps))
    

    perpLiquidationPrice(...) is that solve, exported as a pure function — no client, no block — so you can re-run it against a live store or a hypothetical. Both getLiquidationPrice and previewPerpLiquidationPrice go through it, which is why they cannot disagree on identical inputs. It is a single-market solve: other markets' contributions are held at the value baked into equity/mmReq, so a correlated move across several markets liquidates sooner.

  • Where would an order put my liquidation price? client.previewPerpLiquidationPrice({ pool, marginBank, account, isBid, quantity, price, asMaker? }) answers that for an order not yet placed, returning currentLiquidationPrice beside projectedLiquidationPrice so a form can show the move. It ports all four of MarginBank.settleTrade's cases — open, increase (floored VWAP entry), reduce/close (realized PnL at the fill price, entry untouched), and flip (old side closed, remainder re-opened) — and charges the fill's fee, defaulting to the taker rate. A reduce moves the price away from the mark and an add moves it closer, so the four cases are not interchangeable.

    Note what it deliberately does not do: it applies the whole quantity rather than splitting it against getReducingCapacity (that split governs the collateral lock, not the position), and it does not judge whether the order would be accepted — that is previewPerpOrderMargin's job. An unpriceable market returns { priceable: false } rather than reverting, so an order form can still render.

  • How much can I actually place? client.getMaxPerpOrderSize({ pool, marginBank, account, isBid, price }) — what a Max button should call. The inverse of previewPerpOrderMargin, which the protocol does not offer.

    It does not re-derive the sizing rule; it binary-searches the forward one, so the two cannot disagree. That matters because a max computed by a second, subtly different rule reverts on placement — and the term such a rule most often drops is the adverse mark-to-entry reserve, which on a 10%-above-mark bid cuts the affordable size by roughly two thirds. maxQuantity is aligned down to the pool's lot grid.

    Check placeable. A size below the pool's minQuantity is a revert, not a small order. limitedBy names the binding gate — "collateral", "initialMargin", "maxPositionSize" or "voucherBlocked". Market-wide maxOpenInterest is deliberately not modelled: it is enforced at fill against a total every other trader moves, so no client-side number can be right about it for long. Nor is book depth — this is a placement limit, not a liquidity one. Nor are the two non-margin gates that reject the whole order rather than shrinking it: a close-only market (PerpPool.isRestricted()) and isolated-margin confinement.

    Pass autoPull: true when the transaction sender will be the order owner. The pool tops the in-bank balance up from the owner's wallet before it locks (_onOrderPlacedMarginBank.quoteOrderTopUpdepositFor), so an account with an empty bank and a funded, approved wallet goes from a max of 0n to whatever the wallet funds. msg.sender == order.owner is the pool's entire gate, and only the caller knows who will send — hence opt-in. Leave it off for placeOrderFor, an operator grant or the stop registry, where no pull happens.

    With it on, topUpRequired is the wallet spend to show beside the size, and limitedBy gains "walletBalance" / "walletAllowance" — different shortfalls with different fixes, so don't collapse them. "restricted" and "isolated" name the two gates that reject the whole order rather than resizing it.

    Note the max is the top of the contiguous placeable region. Auto-pull makes the initial-margin gate non-monotone in quantity — it reduces to (equity − unlocked) + feeHeadroom ≥ imRequirement, whose only size-dependent term grows — so an account whose existing positions sit below their own initial margin can be rejected at a middling size and accepted at a much larger one. previewPerpOrderMargin reports that faithfully; the max deliberately does not offer sizes out of the disconnected region, because a slider has to be placeable at every value below its maximum.

  • What do I get if I close? client.previewPerpClosePnl({ pool, marginBank, account, quantity?, price?, asMaker? }) — backs a close modal. Omit quantity for the whole position; price defaults to the mark, which is the right estimate for a market close.

    Two things it gets right that a hand-derived figure usually does not, and both are silent — the close succeeds, the number shown was just wrong:

    1. The size is aligned down to the lot grid first. "Close all" on a position that is not a lot multiple leaves a remainder open. A modal reporting the position as flat is wrong, and it reads as a bug in the close button.
    2. Funding settles on the whole position, not the closed share. settleTrade calls _settleFundingWithValues before it touches the position, and that uses the full pos.size. So a 10% close settles 100% of the accrued funding; pro-rating it — the intuitive move — under-states the cash impact by the other 90%.

    netProceeds is the number to show: realizedPnl − fundingSettled − fee, with fundingSettled positive when the account pays. realizedPnl floors toward −∞ (_realizedPnlForClose), which is the opposite of the truncation unrealized PnL uses — the same inputs give -1 here and 0 from getPerpPositionAnalytics, and both are correct.

    placeable is the pool minimum and nothing else. A close is only purely reducing up to _reducingCapacity|size| minus what is already resting on the reducing side — so closing out while a reduce order is down leaves an increasing remainder that locks collateral and must clear meetsIMForOrder. Read previewPerpOrderMargin beside this one on an account with resting orders. fee is the pool's own maker/taker rate; a builder fee attached at placement is charged on the same notional and lands on top.

  • What is this position actually doing? client.getPerpPositionAnalytics({ marginBank, pool, account }) for one, client.listPerpPositionAnalytics({ marginBank, account }) for a positions table. Two reads for one position, 1 + 2n for the table, all pinned to one block.

    This is the split getAccountHealth cannot give you: it returns one equity figure for the whole account, with every market's PnL and funding already summed and netted together, so a two-position trader cannot see which position carries the loss and cannot see funding at all. Back apart:

    FieldMeans
    unrealizedPnl(mark − entry) × size / oneBase — price only, excludes funding
    accruedFundingfunding owed since entry; positive means you pay
    equityContributionunrealizedPnl − accruedFunding — what this position adds to equity
    notional|size| × mark / oneBase
    initialMarginRequirement"position margin" — the only per-position margin the protocol defines
    maintenanceMarginRequirement / closeOutMarginRequirementthe liquidation and takeover thresholds' shares
    returnOnMarginBpsequityContribution over the initial requirement, net of funding; null when flat

    A direct port of MarginBank._computePositionMetrics and _marketHealthFromSnapshot, which is what lets the rows re-sum to the bank's own equity to the wei — a test pins that. The two roundings differ and are not interchangeable: unrealized PnL truncates toward zero, while funding ceils toward +∞ so a payer never underpays. For a negative quotient that is the opposite of rounding the magnitude up.

    accruedFunding uses the pool's projected cumulative index, so it includes intervals no one has settled yet — settlement is permissionless and lazy, and measuring against the settled index under-reports what the account already owes. An unpriceable market comes back as { priceable: false } rather than throwing, so one dead feed costs one row rather than the page. initialMarginRequirement uses the market's IMF only: an account leverage setting raises the bar for a new order but never appears in health, so use previewPerpOrderMargin for the order-gating figure. The pure core is exported as perpPositionAnalytics(...) — no client, no block.

  • How levered am I? client.getPerpLeverage({ marginBank, pool, account }). The protocol has no leverage view to callgetMaxLeverage / getMaxLeverageLimit / getVoucherLeverageCap are all cap configuration and none of them measures a position — so this derives it from mark notional over equity, and returns every denominator rather than picking one:

    FieldMeans
    positionLeverageBpsthis position's notional over account equity
    accountLeverageBpsΣ notional over account equity — the figure that governs risk under cross margin
    marketMaxLeverageBps10000² / effectiveImfBps — the most this market will open at, at live OI-scaled IMF
    accountMaxLeverageXthe account's own per-market cap, 0 when unset (what setPerpLeverage writes, finally readable)
    protocolMaxLeverageXthe ceiling that clamps it
    creditFloorthe account's credit-voucher floor; 0n on an ordinary account, and the switch that arms the two below
    voucherLeverageCapXwhat a voucher account is confined to when it increases; 0 is a block, not "uncapped"
    voucherMarketAllowedwhether this market is on the voucher allowlist

    The ceilings are not collapsed into one number, and they do not compose by taking a minimum. A voucher cap replaces an unset or looser accountMaxLeverageX before protocolMaxLeverageX clamps the result, and it applies only to a position increase — MarginBank._meetsIM gates the whole voucher branch on additionalSize > 0, so a voucher holder can always close out or place a stop, even on a market since removed from the allowlist. The load-bearing half is that a voucher turns an unset account cap into an enforced one: accountMaxLeverageX === 0 means "no cap" on an ordinary account and "confined to voucherLeverageCapX" on a voucher one. For whether a specific order passes, use previewPerpOrderMargin, which applies all of it and reports voucherBlocked.

    Every ratio is bps of 1x (10_000 = 1.00x, 200_000 = 20x), matching the protocol's own unit for every margin figure. Position and account leverage are null on non-positive equity — an account with no equity left is insolvent, not infinitely levered; read marginStatus for that. accountNotional costs two extra reads per other active market (the MarginBank exposes no aggregate, and imReq can't be inverted because each market applies its own IMF), so a single-market account pays nothing extra.

  • Liquidation-keeper reads — who holds, and what a bankrupt position is worth. The MarginBank keeps a per-(pool, side) holder array, so a keeper can find every open position from head state alone — no off-chain indexer:

    ts
    const { holders, asOfBlock } = await client.getPerpSideHolders({ marginBank, pool, isLong: true });
    const prices = await Promise.all(
      // Priced at the SNAPSHOT's block — at head, a holder that closed since
      // the enumeration would revert NoOpenPosition and reject the sweep.
      holders.map((h) => client.getBankruptcyPrice({ marginBank, account: h, pool }, { blockNumber: asOfBlock })),
    );
    

    getPerpSideHolders pages through the bank's bounded slice view (many holders per round-trip) with every page pinned to ONE block; feed asOfBlock into getBankruptcyPrice's blockNumber option (and into the other side's call) to keep a sweep on one consistent snapshot. The other position/health reads answer at head only.

    getBankruptcyPrice is the contract's own figure — the price at which the position's allocated share of the account's equity is exhausted — and it is a different quantity from getLiquidationPrice, not a better version of it: getLiquidationPrice is the SDK's client-side estimate of where liquidation triggers (use it for UI and monitoring), while getBankruptcyPrice is what the contract computes a bankrupt position to be worth (use it for anything that settles or bids — a keeper pays against this number, and a client-side estimate can drift from contract rounding). It reverts rather than returning a sentinel: ContractRevertError with errorName: "NoOpenPosition" when the account is flat in that pool (branch on errorName, never message text).

  • Protocol state — is the stack wired and solvent. The plane below any one account or market, mirroring what the protocol repo's perps:state / perps:health ops tasks read:

    ts
    const cfg  = await client.getPerpSystemConfig(marginBank);        // the address book
    const fund = await client.getInsuranceFundState(cfg.insuranceFund);
    const eng  = await client.getLiquidationEngineConfig(cfg.liquidationEngine);
    

    getPerpSystemConfig is the entry point: every other contract in the plane is reachable from it, so nothing is hardcoded per chain, and it carries fullyWired — a single flag for "some part of this stack is half-configured", which is the state where liquidation and settlement degrade silently rather than reverting.

    Point the other two at the addresses it returns. liquidationEngine is the proxy: an implementation address answers with unset defaults (zero bidders, zero penalty), which reads as a configured-but-idle engine rather than the wrong address. bidderCount === 0n is itself operational — with no registered backstop bidders the takeover stage has nobody to take a position over, so the waterfall reaches ADL sooner than the configuration implies.

    For account-level health when a feed may be down: tryGetPerpAccountEquity returns null rather than reverting (null is "not computable", never "zero"), and getPerpCollateralBasis is a solvency floor that reads one storage pair and cannot revert at all.

  • Take-profit / stop-loss listing. client.listPerpStopOrders({ account?, pool?, status? }). The PerpStopOrderRegistry keeps pending orders in private storage behind no enumeration getter, so there is no chain read that answers "what stops do I have" — creation and triggering both work, but without this a trader cannot see, price or cancel what they created. It is indexed, and there is no chain fallback.

    One call covers every scope: { account } for a trader's working stops (default status PENDING), { pool } with no account for a market's whole pending book, and status for history. account is optional deliberately — a market-wide view of what will fire is a legitimate monitoring read.

    Read dropReason before calling a TRIGGER_FAILED order a failure: ReduceOnly* means the stop was overtaken by events (position already closed, flipped, or below the minimum), which is ordinary; only PlacementFailed is a rejection. SOMI is consumed on every fire either way.

  • The registry may be holding SOMI for you. Placing a perp stop pre-pays the trigger gas in SOMI. Cancelling refunds it by direct transfer — but when that transfer fails, the registry credits an unclaimed balance instead and emits SomiRefundFailed. That is the ordinary outcome for a contract owner with no payable receiver (most multisigs and smart accounts). A registry wind-down credits the same balance to every owner, EOAs included.

    ts
    const owed = await client.getUnclaimedPerpStopSomi({ registry: perp.stopRegistry, account });
    if (owed > 0n) await trader.claimPerpStopSomi({ registry: perp.stopRegistry });
    

    Read before claiming — claimSomi reverts NothingToClaim on a zero balance, and the payout is a plain native transfer to the caller, so an owner that still cannot receive native reverts WithdrawalFailed and the balance stays put. Do not assume an EOA is owed nothing: the wind-down path reaches them too. Note the trigger path is the opposite of a refund — SOMI is consumed on every fire and never returned.

  • Batching writes that have to be atomic. One SDK write is one transaction, which is wrong for a flow that is only safe as one — an order with TP/SL attached (sent separately, the order can fill and sit with no stop on it), approve + deposit, withdraw + forward to the wallet. trader.buildPlacePerpStopOrder, buildCancelPerpStopOrder(s), buildDepositMargin and buildWithdrawMargin take the same parameters as their sending twins and return the unsigned call ({ to, data, value, description }) for you to pack into one UserOp / Safe batch / multicall.

    Two gotchas. Approvals come back rather than going out — operatorApproval on a stop, approval on a deposit — and must execute before the call they enable; the perp stop's grant especially, because without it the trigger reverts and the prepaid SOMI is spent having placed nothing. And no ids come back, because you hold the receipt: read them with decodePerpStopOrderIds(receipt.logs, registry).

  • Where the registry address comes from. Every stop write — placePerpStopOrder, cancelPerpStopOrder, cancelPerpStopOrders — takes the per-pool PerpStopOrderRegistry as a required registry argument. Read it off the market row: stopRegistry on PerpMarket, and on the market context attached to perp portfolio orders and fills. null there means the pool has no registry deployed, so TP/SL is unavailable on it — not that the address is elsewhere.

  • One call for a market header. exchange.fetchTicker(symbol) on a perp now returns markPrice, indexPrice, fundingRate, fundingTimestamp and openInterest alongside the 24h high/low/volume it already computed from candles. Those five are chain state, not candle-derived, so before this a header needed a second read the consumer had to know to make. They are undefined on spot and binary, and no chain round-trip is spent on a non-perp.

    fundingRate is on the same per-8h axis as fetchFundingRate and fetchFundingRateHistory — deliberately, because a header on one basis beside a chart on another is a wrong number that looks right. It is not the per-settlement amount; divide by fundingWindowSec / fundingIntervalSec (96 on testnet) for that, both of which ride on info.perp. markPrice is omitted rather than reported as 0 when the feed is stale.

  • Previewing an order before you send it. client.previewPerpOrderMargin({ pool, marginBank, account, isBid, quantity, price }) returns what the pool will actually lock and whether it will accept the order — the read behind an order form's "margin required" row and its submit gate.

    Do not reach for quoteMeetsIMForOrder: it runs with the order's base margin treated as already reserved (true on the real path, where lockCollateral runs first), so called cold it counts the order's margin nowhere and returns true for almost any size. meetsPerpImForFill does charge base margin, but neither models the lock's adverse mark-to-entry reserve — a buy above mark (or sell below) opens underwater by that gap and the pool reserves it on top of initial margin. That term is the usual reason a notional × IMF-sized "max" order gets rejected.

    Two gates are reported separately because they fail for different reasons and imply different fixes: hasCollateralForLock (can the lock be taken at all) versus meetsInitialMargin (does what remains still cover the requirement) — "deposit more" versus "close something".

    Only the increasing leg locks. An order that nets against an existing position locks nothing up to effectiveReducingCapacity, which resting opposite-side orders have already partly spoken for.

    Pass autoPull: true when the sender will be the order owner, and both gates describe the balance the pool will have topped up to from the wallet rather than the one already in the bank. topUpRequired is then the wallet spend to show beside the margin figure, and wallet carries the balance and MarginBank allowance it was measured against. feeHeadroom appears either way: it is not charged and not part of lockAmount, only an auto-pull addend that keeps a fresh max-leverage position out of MarginCall at birth.

    A topUpRequired of 0n means three different things — no pull needed, or one of the pool's declines: a purely reducing order, an account already in debt, or a voucher-blocked increase. Read it beside unlockedCollateral, not on its own.

    Every read is pinned to one block, so the result is a statement about that block; the adverse-gap term moves with the mark, so re-quote near send time for anything close to the edge.

  • Market discovery is a chain read, and "deployed" is not "tradeable." client.listPerpPoolStatuses({ factory }) enumerates the PerpPoolFactory and returns every market with the two independent gates that decide tradeability — they fail for unrelated reasons and both must pass:

    1. restricted — the market is close-only. Position-increasing orders revert MarketRestricted; closes, reduces and cancels still work. These stay listed on purpose (holders must still exit), and it is reversible.
    2. registered — the MarginBank has activated the pool. Coming from the factory only proves a pool is authentic; addPerpPool is what makes it usable. An unregistered pool rejects every quote view and settlement callback while reading as an ordinary market from the factory. (getPoolTier is not a substitute — it is itself gated on registration.)

    tradeable folds both together, and listTradeablePerpPools filters to it. You do not pass a MarginBank: it is a per-network singleton in practice, but each pool names its own and that is the bank its settlement path uses, so it is read per pool and returned on every row — ready for the getMarginAccount / getPerpPosition reads that follow.

    Do not build a market list from the factory's raw pool list: it is the deployment history, so listing it unfiltered presents wound-down markets as tradeable. Testnet today has 10 pools, four of them restricted SBTC* arena markets.

    This is also more complete than the indexer, whose perp set comes from a curated manifest — a market deployed after that manifest was written is invisible there and present here.

  • Per-market risk parameters are a chain read. client.getPerpRiskParams(pool) returns the pool's frozen config — initial / maintenance / close-out margin in bps, the OI and position caps, and the maker/taker rates. maintenanceMarginBps is exposed nowhere else (the indexed market row carries only initialMarginBps), and without it a client can show the real liquidation price of an open position but not the projected one for an order it hasn't placed. This read never reverts, so maintenance margin stays available when the mark feed is down — exactly when you most want to explain a liquidation.

  • Initial margin is not a constant. initialMarginBps is only the FLOOR of the curve: with dynamic IMF enabled the pool scales it with open interest, and client.getEffectiveImfBps(pool) is the rate actually charged. Sizing an order off the static base under-margins it whenever OI has pushed the curve up, and the pool rejects an order the client believed fit. Maintenance margin deliberately does not scale — a liquidation threshold must not move under a position because the market's OI grew.

  • One call for a health walk. client.getPerpHealthSnapshot(pool) returns oneBase, mark, projected cumulative funding, the effective IMF and both thresholds together; the contract added it so a cross-margin walk reads a market once instead of five times. It returns a discriminated union — an unpriceable market arrives as { priceable: false } rather than an all-zero struct, because a maintenanceMarginBps of 0 reads as "can never be liquidated". Narrow on priceable before touching a field.

History (indexer)

The CURRENT position/margin is the chain read above; the append-only history the chain doesn't expose is indexed (perp account plane), all one-shot indexer reads:

ts
const funding = await client.getFundingPayments(account, { pool, limit: 50 });   // signed funding paid/received
const margin  = await client.getMarginEvents(account, { limit: 50 });            // deposit/withdraw/lock/unlock
const liqs    = await client.getLiquidations({ account, pool });                 // liquidation events
const rates   = await client.listFundingRateHistory(pool, { from, to });          // per-pool funding-rate series
const candles = await client.listFundingRateCandles(pool, 3600, { from, to });    // 1h/4h/1d rollups for charting
const oi      = await client.getOpenInterestHistory(pool);                       // per-pool open-interest series

listFundingRateHistory / getOpenInterestHistory are the append-only counterparts to the overwrite-only fundingRate / openInterest fields on the perp Market row (which only carry the latest value). getFundingRateHistory is the deprecated alias of the first; it forwards verbatim.

Liquidation history is served by the LiquidationEngine subscription, which is live — the contract is deployed and indexed from block 436,735,800. There is no MarginBank-sourced fallback: MarginBank.Liquidated was deleted from the protocol, so the rows that claim used to describe cannot exist. Per-position detail comes from LiquidationEngine.PositionLiquidated, and the waterfall's other outcomes (ADL, takeover, close-out, the residual and declined-coverage markers) arrive as sibling rows sharing a txHash — read kind to tell them apart, and read the TSDoc on badDebt / insuranceCovered / deficit / coverageDeclined before aggregating any of them, because only the flows are summable.

Quick start

ts
const exchange = new SomniaMarkets({ chain, wsRpcUrl, indexerUrl, privateKey });
await exchange.loadMarkets();

await exchange.depositMargin("BTC/USDSO:USDSO", 1_000);        // USDso → MarginBank
await exchange.createOrder("BTC/USDSO:USDSO", "limit", "buy", 0.001, 62_000);
const [pos] = await exchange.fetchPositions();                  // long/short + uPnL
const { fundingRate, markPrice } = await exchange.fetchFundingRate("BTC/USDSO:USDSO");

One-shot reads (no watch)

For a plain fetch without a live tail:

ts
const perps = await client.listPerpMarkets({ baseSymbol: "WBTC", limit: 20 });
const one   = await client.getPerpMarket(perps[0].id);          // null if not a perp
const port  = await client.getPerpPortfolio(account, { ordersLimit: 50, tradesLimit: 50, since });
const hist  = await client.listPerpOrderHistory(account, { limit: 100 });  // FINISHED orders
const state = await client.getPerpState(one!.poolAddress);      // mark/index/funding/OI

listPerpMarkets takes a PerpMarketFilter (baseSymbol / quoteSymbol, all server-side); getPerpPortfolio takes the shared PortfolioOptions (ordersLimit / tradesLimit / since). Positions + collateral stay on-chain (getPerpPosition / getMarginAccount), not indexed rows.

getPerpPortfolio returns open orders only, so listPerpOrderHistory is the other half — finished orders, most-recently-ended first. It excludes working orders by default and sorts by when each order ended rather than when it was placed, so a long-resting order that just filled lands at the top of a history view instead of buried at its placement date. Pass status to narrow to particular outcomes.

Sort axis is selectable — orderBy: "ended" (default) or "placed".

Watch Closed: it is terminal, not transitional. Every pool places an order as Closed and a following OrderRested promotes it to Open, so an IOC that partially filled without resting stays Closed forever — reading it as "still working" shows a finished order as live.

Writing forward-compatible code

ts
const m = client.getLiveMarketByPool(pool);
switch (m?.marketType) {
  case "BINARY": /* YES/NO book */ break;
  case "SPOT":   /* base/quote book */ break;
  case "PERP":   /* base/quote book + funding/positions */ break;
}

Key on marketType (not on field presence), use the type guards, and read decimals off the market row rather than assuming.