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.
fundingRateis per CALCULATION WINDOW (fundingWindowSec, 28800s / 8h on every live pool) — not per settlement interval and not annualized. Each settlement accruesrate / nwheren = fundingWindowSec / fundingIntervalSec: 8 on every live pool (3600s settlement). It has been 96 at a 300s cadence, and the same value means a 12x different per-interval accrual across that boundary — which still reaches anyone reading indexed history, so readnoff each row rather than assuming it. Normalize with the helpers (fundingRate8h,fundingRate1h,fundingRatePerInterval,annualizedFundingRate), never with a hardcoded denominator.
openInterestreplacedlongOpenInterest+shortOpenInterest: the contract keeps ONE counter because the short side is provably equal in a matched CLOB. The removed pair wasnullon 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/withdrawMarginmove it;trader.placePerpOrder(or plaincreateOrderon a perp symbol) locks margin from that balance — no per-order token approval. -
Re-quoting a ladder? Amend it atomically.
trader.amendOrderscancels 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.
placePerpOrdertakes optionalbuilder/builderFeeBpsTimes1k(alongside its existingexpireTimestampNs), both defaulting to no attribution. A non-zero fee needs a priortrader.approveBuilder({ pool, builder, maxFeeBpsTimes1k })and must stay withintrader.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 revertsBuilderFeeExceedsApproval(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 })andclient.getMarginAccount(marginBank, account)— or the unifiedexchange.fetchPositions(). Live pricing/funding comes fromclient.getPerpState(pool)/exchange.fetchFundingRate(symbol). -
MONITORING a mark feed is a different read.
client.getPerpFeedStatus(pool)answers whether the mark is live and how much open interest rides on it.getPerpStatebatches withallowFailure: falseand one of its legs is a bareIOracle.getPrice(), so a dead or rotated oracle rejects the whole read — losing the verdict in the one case it matters most.getPerpFeedStatuskeeps the mark verdict and open interest as a pair no oracle can revert, and degrades the index timestamp toundefinedinstead. UsegetPerpStatewhen you want the full pricing and funding picture and a dead oracle should fail the call. -
…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'supdatedAtBlockand 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:sizeis 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 ongetPerpPosition. Fully-closed positions are excluded unless you passincludeFlat— upserted rows are never deleted, so they linger at size 0 forever. -
Margin health is a chain read too.
getMarginAccountnow also returns the requirements + status (imReq/mmReq/cmReq/marginStatus, fromMarginBank.getAccountHealth+getMarginStatus);client.getAccountHealth(marginBank, account)is the lighter standalone read when only health matters, andclient.getLiquidationPrice({ marginBank, pool, account })estimates the mark at which this pool's move alone would trip maintenance (null when flat).MarginStatus/MARGIN_STATUS/AccountHealthare exported;exchange.fetchPositions()now fillsUnifiedPosition.liquidationPrice. -
Liquidation price: both sides of the inequality move with the mark. Liquidation begins where
equity == mmReq, andmmReqis recomputed on the current mark (ceil(|size| × mark × mmBps / (oneBase × 10000))), so it shrinks under a falling long and grows under a rising short. Solvingequity(p) == mmReq(p)therefore carries a10000 ∓ mmBpsfactor:textlong 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. BothgetLiquidationPriceandpreviewPerpLiquidationPricego 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 intoequity/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, returningcurrentLiquidationPricebesideprojectedLiquidationPriceso a form can show the move. It ports all four ofMarginBank.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 ispreviewPerpOrderMargin'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 ofpreviewPerpOrderMargin, 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.
maxQuantityis aligned down to the pool's lot grid.Check
placeable. A size below the pool'sminQuantityis a revert, not a small order.limitedBynames the binding gate —"collateral","initialMargin","maxPositionSize"or"voucherBlocked". Market-widemaxOpenInterestis 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: truewhen the transaction sender will be the order owner. The pool tops the in-bank balance up from the owner's wallet before it locks (_onOrderPlaced→MarginBank.quoteOrderTopUp→depositFor), so an account with an empty bank and a funded, approved wallet goes from a max of0nto whatever the wallet funds.msg.sender == order.owneris the pool's entire gate, and only the caller knows who will send — hence opt-in. Leave it off forplaceOrderFor, an operator grant or the stop registry, where no pull happens.With it on,
topUpRequiredis the wallet spend to show beside the size, andlimitedBygains"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.A linked child is funded by two wallets, and both count. The pool spends the owner's own wallet first and takes the residual from the main it is linked to, so
autoPullreadsMarginBank.quoteFundingPayerand adds the main'squoteWalletCapacityas a second leg. That matters most for the account the rail exists for: an isolated sub-account is meant to hold nothing, and sized off its own wallet alone it reads as unable to place anything at all.fundingPayernames the main eligible to fund a shortfall, or isnullwhen the account funds itself — unlinked, a main itself, or the rail dormant on this deployment. Eligibility is not a debit: it resolves for every linked account, including an order needing no top-up and one whose own wallet covers the whole pull.ownWalletPullandmainWalletPullsplittopUpRequiredbetween the two wallets in the order the pool spends them, and onlymainWalletPull > 0nproves a second wallet actually moves — that is the figure to show before the trader signs.Read
topUpRequiredas the total REQUESTED, not one wallet's spend. Showing it as the child's debit over-states it by exactly the main's leg.limitedBygains"mainWallet", reported ahead of the twowallet*values whenever a main is in play: at a main-funded ceiling the child's own capacity is fully consumed, so"walletBalance"would also be literally true and would send a trader to fund a sub-account that is empty by design. It names the preferred remedy, not the only one — the two capacities add, so raising the child's balance or allowance lifts the ceiling just as well.A partial pull is not a rejection on this path. The unlinked pool asks for a fixed
topUpRequiredand the token reverts if the wallet is short. Both linked legs aremin(...)-sized and neither can revert, so the pool pulls what it can and the margin gates judge the result — andtopUpRequiredincludes the fee headroom, which is a reserve rather than part of the lock. So capacities that cover the lock but not the headroom fund an order the chain accepts, andwalletCoversTopUpis informational there rather than a gate: read afalseas "the position may be born close to its own initial margin", not as "this will be rejected".One case withholds the main's leg entirely.
MarginBank.depositForFromMainallows one payer at a time, so while the child still owes a prior payer the pull revertsPriorFundingPayerOutstanding— reachable in one ordinary sequence: funded by main A, unlink, re-link to main B.mainFundingBlockedreports it andlimitedBysays"mainFundingBlocked"; the fix is neither wallet buttrader.repayPerpMainFundingagainst the old claim.It costs one extra read for an unlinked account and four for a linked one. The main's capacity arrives as a single
min(balance, allowance), which is all the bank exposes for another wallet, so a main-side shortfall does not say which of the two bound; read the main's token balance and allowance directly if you need that.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.previewPerpOrderMarginreports 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. Omitquantityfor the whole position;pricedefaults 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:
- 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.
- Funding settles on the whole position, not the closed share.
settleTradecalls_settleFundingWithValuesbefore it touches the position, and that uses the fullpos.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%.
netProceedsis the number to show:realizedPnl − fundingSettled − fee, withfundingSettledpositive when the account pays.realizedPnlfloors toward −∞ (_realizedPnlForClose), which is the opposite of the truncation unrealized PnL uses — the same inputs give-1here and0fromgetPerpPositionAnalytics, and both are correct.placeableis 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 clearmeetsIMForOrder. ReadpreviewPerpOrderMarginbeside this one on an account with resting orders.feeis 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 + 2nfor the table, all pinned to one block.This is the split
getAccountHealthcannot 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:Field Means unrealizedPnl(mark − entry) × size / oneBase— price only, excludes fundingaccruedFundingfunding owed since entry; positive means you pay equityContributionunrealizedPnl − accruedFunding— what this position adds to equitynotional|size| × mark / oneBaseinitialMarginRequirement"position margin" — the only per-position margin the protocol defines maintenanceMarginRequirement/closeOutMarginRequirementthe liquidation and takeover thresholds' shares returnOnMarginBpsequityContributionover the initial requirement, net of funding;nullwhen flatA direct port of
MarginBank._computePositionMetricsand_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.accruedFundinguses 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.initialMarginRequirementuses the market's IMF only: an account leverage setting raises the bar for a new order but never appears in health, so usepreviewPerpOrderMarginfor the order-gating figure. The pure core is exported asperpPositionAnalytics(...)— no client, no block. -
How levered am I?
client.getPerpLeverage({ marginBank, pool, account }). The protocol has no leverage view to call —getMaxLeverage/getMaxLeverageLimit/getVoucherLeverageCapare 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:Field Means 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 IMFaccountMaxLeverageXthe account's own per-market cap, 0when unset (whatsetPerpLeveragewrites, finally readable)protocolMaxLeverageXthe ceiling that clamps it creditFloorthe account's credit-voucher floor; 0non an ordinary account, and the switch that arms the two belowvoucherLeverageCapXwhat a voucher account is confined to when it increases; 0is 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
accountMaxLeverageXbeforeprotocolMaxLeverageXclamps the result, and it applies only to a position increase —MarginBank._meetsIMgates the whole voucher branch onadditionalSize > 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 === 0means "no cap" on an ordinary account and "confined tovoucherLeverageCapX" on a voucher one. For whether a specific order passes, usepreviewPerpOrderMargin, which applies all of it and reportsvoucherBlocked.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 arenullon non-positive equity — an account with no equity left is insolvent, not infinitely levered; readmarginStatusfor that.accountNotionalcosts two extra reads per other active market (the MarginBank exposes no aggregate, andimReqcan'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:
tsconst { 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 })), );getPerpSideHolderspages through the bank's bounded slice view (many holders per round-trip) with every page pinned to ONE block; feedasOfBlockintogetBankruptcyPrice'sblockNumberoption (and into the other side's call) to keep a sweep on one consistent snapshot. The other position/health reads answer at head only.getBankruptcyPriceis 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 fromgetLiquidationPrice, not a better version of it:getLiquidationPriceis the SDK's client-side estimate of where liquidation triggers (use it for UI and monitoring), whilegetBankruptcyPriceis 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:ContractRevertErrorwitherrorName: "NoOpenPosition"when the account is flat in that pool (branch onerrorName, 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:healthops tasks read:tsconst cfg = await client.getPerpSystemConfig(marginBank); // the address book const fund = await client.getInsuranceFundState(cfg.insuranceFund); const eng = await client.getLiquidationEngineConfig(cfg.liquidationEngine);getPerpSystemConfigis the entry point: every other contract in the plane is reachable from it, so nothing is hardcoded per chain, and it carriesfullyWired— 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.
liquidationEngineis 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 === 0nis 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:
tryGetPerpAccountEquityreturnsnullrather than reverting (null is "not computable", never "zero"), andgetPerpCollateralBasisis 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 statusPENDING),{ pool }with no account for a market's whole pending book, andstatusfor history.accountis optional deliberately — a market-wide view of what will fire is a legitimate monitoring read.Read
dropReasonbefore calling aTRIGGER_FAILEDorder a failure:ReduceOnly*means the stop was overtaken by events (position already closed, flipped, or below the minimum), which is ordinary; onlyPlacementFailedis 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.tsconst owed = await client.getUnclaimedPerpStopSomi({ registry: perp.stopRegistry, account }); if (owed > 0n) await trader.claimPerpStopSomi({ registry: perp.stopRegistry });Read before claiming —
claimSomirevertsNothingToClaimon a zero balance, and the payout is a plain native transfer to the caller, so an owner that still cannot receive native revertsWithdrawalFailedand 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),buildDepositMarginandbuildWithdrawMargintake 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 —
operatorApprovalon a stop,approvalon 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 withdecodePerpStopOrderIds(receipt.logs, registry). -
Where the registry address comes from. Every stop write —
placePerpStopOrder,cancelPerpStopOrder,cancelPerpStopOrders— takes the per-poolPerpStopOrderRegistryas a requiredregistryargument. Read it off the market row:stopRegistryonPerpMarket, and on the market context attached to perp portfolio orders and fills.nullthere 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 returnsmarkPrice,indexPrice,fundingRate,fundingTimestampandopenInterestalongside 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 areundefinedon spot and binary, and no chain round-trip is spent on a non-perp.fundingRateis on the same per-8h axis asfetchFundingRateandfetchFundingRateHistory— 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. For one settlement's charge, divide byn = fundingWindowSec / fundingIntervalSec(8 on every live pool) — and for a HISTORICAL row that caught up over several intervals, multiply that by the row'sintervalsAccrued, since one settlement can charge more than one interval's worth. Both figures ride oninfo.perp.markPriceis 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, wherelockCollateralruns first), so called cold it counts the order's margin nowhere and returnstruefor almost any size.meetsPerpImForFilldoes 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 anotional × 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) versusmeetsInitialMargin(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: truewhen 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.topUpRequiredis then the wallet spend to show beside the margin figure, andwalletcarries the balance and MarginBank allowance it was measured against.feeHeadroomappears either way: it is not charged and not part oflockAmount, only an auto-pull addend that keeps a fresh max-leverage position out ofMarginCallat birth.A
topUpRequiredof0nmeans 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 besideunlockedCollateral, 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 thePerpPoolFactoryand returns every market with the two independent gates that decide tradeability — they fail for unrelated reasons and both must pass:restricted— the market is close-only. Position-increasing orders revertMarketRestricted; closes, reduces and cancels still work. These stay listed on purpose (holders must still exit), and it is reversible.registered— the MarginBank has activated the pool. Coming from the factory only proves a pool is authentic;addPerpPoolis what makes it usable. An unregistered pool rejects every quote view and settlement callback while reading as an ordinary market from the factory. (getPoolTieris not a substitute — it is itself gated on registration.)
tradeablefolds both together, andlistTradeablePerpPoolsfilters 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 thegetMarginAccount/getPerpPositionreads 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.maintenanceMarginBpsis exposed nowhere else (the indexed market row carries onlyinitialMarginBps), 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.
initialMarginBpsis only the FLOOR of the curve: with dynamic IMF enabled the pool scales it with open interest, andclient.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)returnsoneBase, 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 amaintenanceMarginBpsof0reads as "can never be liquidated". Narrow onpriceablebefore touching a field.
Linked wallets (isolated margin)
Isolated margin on perps is one wallet per position. The MarginBank is cross-margin,
so a single account's collateral backs every position it holds; the only way to give
one position its own collateral bucket is to open it from its own wallet. setIsolated
does not do this — it is single-market confinement, which caps how many markets one
account may touch and changes no margin math.
One wallet per position used to mean one treasury operation per position. The linked-wallet rail removes that. A wallet links to a main wallet, and from then on the child's position-increasing orders draw their margin shortfall from the main's wallet. One funded main serves every child linked to it.
Two layers, and they answer different questions:
LinkedWalletRegistryis the consent graph. It records who is linked to whom and grants no authority over funds.MarginBankis the money layer, and it is armed separately. Until the bank holds a registry address the rail is dormant and no child can draw on any main, whatever the registry says.
Linking a sub-account
A link takes a two-sided handshake and both wallets sign. The main offers, the child accepts, and no wallet can act for the other:
// The MAIN offers. Its own deposit leaves the standing allowance the rail spends.
await main.trader.depositMargin({ marginBank, amount: fromHuman(1000, 18) });
await main.trader.proposePerpWalletLink({ marginBank, child: childAddress });
// The CHILD accepts, with its own signer and its own transaction.
await child.trader.acceptPerpWalletLink({ marginBank, main: mainAddress });
// The child's own placement now pulls the shortfall from the main's wallet.
await child.trader.placePerpOrder({ pool, isBid: true, price, quantity });
Pass registry, marginBank or pool to any of the four consent writes. Only
registry skips a chain read: the bank names the registry it treats as authoritative,
so the SDK reads it from the bank rather than a manifest, and a bank holding none throws
NotConfiguredError rather than sending consent nowhere. That read is never cached,
because the bank's owner can rotate the registry.
How the funding actually happens
There is no funding call, and this is the part that surprises people. The main supplies
USDso and an ERC-20 allowance to the MarginBank, and then sends nothing. PerpPool
performs the pull inside the child's own order placement:
- The child's own wallet pays first, sized by
min(balance, allowance). - The main's wallet pays the residual, sized the same way.
- The pool's entire gate is
msg.sender == order.owner. An order routed throughplaceOrderFor, an operator grant, a router or the stop registry pulls nothing, from either wallet.
So the child must send its own orders, and it needs native SOMI for gas. Revoking the
main's allowance stops the funding without touching the link. Funding does not wait for
link maturity: the rail reads the raw graph, and maturesAt gates ADL netting only.
What a main funds is a claim, not a gift. The bank records the funded principal
against the child, and the child's withdraw frees at most balance - principal — so a
compromised child key can trade the money and lose it, but cannot take it out. The
child's own money is the junior tranche: a loss eats it first, and a child that
genuinely lost the principal owes nothing.
Getting the money back
Read the outstanding claim with client.getPerpMainFunding(marginBank, account). Two
routes send it home, and they differ only in who signs:
| Write | Signer | Use it when |
|---|---|---|
trader.repayPerpMainFunding({ amount }) | the child | the child is finished and returns the money |
trader.recallPerpMainFunding({ child, amount }) | the payer | the main pulls its capital back |
Both pay the payer the bank snapshotted at funding time, not whoever is linked now, so
an unlink or a re-link in between cannot misroute the money. Both clamp the amount
to min(amount, outstanding, present balance), so over-asking is safe and only a clamp
that reaches zero reverts. Both recover principal and never winnings.
trader.unlinkPerpWallet({ counterparty }) dissolves the link from either end. It
settles no money, so clear the claim first if the intent is to part cleanly.
PerpsUnlinkGuard vetoes it while the leaver holds open positions and is at
PartialLiquidation or worse; a healthy or flat wallet can always leave.
Batching the two steps that need it
Two steps have an ordering dependency inside one transaction, and each has a build-only twin for it:
trader.buildAcceptPerpWalletLink— accept, then open the isolated position, in one signature.trader.buildRepayPerpMainFunding— repay, then withdraw the remainder. The withdrawal's own gate reads the claim the repayment clears, so it has to follow.
The other four writes are standalone administrative calls and have no twin.
Which read answers which question
The six chain reads are live contract STATE — a value as of a block, which no log can reconstruct. The history is the opposite: the contracts expose no getter for any of it, so the events in the chain's logs are the only record, and the indexer is what makes them queryable.
| Question | Read | Tier |
|---|---|---|
| Is the rail on at all on this deployment? | client.getPerpLinkedWalletRegistry(marginBank) | chain |
| Is a main eligible to fund this account, and which? | client.quotePerpFundingPayer(marginBank, account) | chain |
| What is outstanding, and to whom? | client.getPerpMainFunding(marginBank, account) | chain |
| What can a wallet actually contribute right now? | client.getPerpWalletPullCapacity(marginBank, wallet) | chain |
| Who is in my group, and when did it mature? | client.getPerpWalletLinkage(registry, wallet) | chain |
| Which children does this main have? | client.listPerpLinkedChildren(registry, main) | chain |
| How many children may one main hold? | client.getPerpMaxLinkedChildren(registry) | chain |
| How did it get to this state? | the three list* ledgers below | indexer |
Eligibility is not a prediction. quotePerpFundingPayer takes no order and no
sender, so it cannot say whether a given order will debit the main. Four things stop an
eligible main from paying anything: the order needs no top-up, the child's own wallet
covers the whole pull, the order is routed by someone other than its owner, or the main
has no spendable capacity. Size the order with client.previewPerpOrderMargin({ autoPull: true }) and read its mainWalletPull for the amount that would actually
move.
quotePerpFundingPayer returns a discriminated union rather than an address, because
the contract's single zero collapses three situations a UI must not render alike.
Narrow on funded first:
const payer = await client.quotePerpFundingPayer(marginBank, account);
if (payer.funded) {
// A main is ELIGIBLE to cover what this account's own wallet cannot. Whether any
// given order debits it is `previewPerpOrderMargin({ autoPull: true }).mainWalletPull`.
console.log("eligible payer", payer.payer);
} else if (payer.reason === "unlinked") {
// The one case the user can fix, by linking.
} else if (payer.reason === "dormant") {
// The rail is off for everyone here. Linking would not help.
} else {
// "isMain" — linked, but funding flows main->child only.
}
getPerpMainFunding reports the claim and the payer snapshotted at funding time,
which is not necessarily whoever is linked now. When the two disagree, the snapshot is
who gets repaid. It also carries withdrawableFromPrincipal, which is always 0n — a
field rather than prose because "why can I not withdraw my balance" is the commonest
question this surface has to answer.
Finding an incoming proposal
The registry keeps pending proposals in a hashed map with no getter. So a child
cannot discover from any chain read that a main has offered it a link, and
client.listPerpWalletLinkEvents is the only evidence such an offer exists.
That list yields candidates, not accepts that will succeed. acceptLink applies
five live-state guards, and each is a current-state rather than an ever-happened
question:
- An offer stands only while the pair's newest row is the
Proposeditself. Mind the direction: an unlink clears the pending proposal in both directions, so anUnlinkednaming this wallet as the main can retire an offer it holds as the child. - The child must be free right now (
AlreadyLinked). Accepting one main leaves the losing mains' proposals in storage, dead only while that link stands. - The main must not itself be a child right now (
CallerIsChild). - The accepting wallet must not itself be a main (
CallerIsMain). - The main must be under its child cap (
MaxChildrenReached).
All five are derivable in principle from a complete Linked / Unlinked replay —
except the cap, whose value is owner-tunable and whose MaxChildrenUpdated event is
deliberately not subscribed. In practice a paginated page of rows is not a complete
replay, which is the real reason not to decide an accept off this list.
So confirm on chain. client.getPerpWalletLinkage(registry, wallet) settles guards 2,
3 and 4 — read it on both parties, since main being non-zero means the wallet is
already in a group. It does not carry the cap: that is
client.getPerpMaxLinkedChildren(registry) against
client.listPerpLinkedChildren(registry, main).
The two funding ledgers, which must not be summed
Every proposal, link, pull, settle and return is indexed, because the chain keeps no history of any of it:
const links = await client.listPerpWalletLinkEvents({ child: account }); // Proposed | ProposalCancelled | Linked | Unlinked
const pulls = await client.listPerpMarginPulls({ account, source: "Main" }); // the POOL side — names the order
const claims = await client.listPerpMainFundingEvents({ account }); // the BANK side — carries the running claim
A MAIN-funded leg emits a row on each side for the same wei. Adding
listPerpMarginPulls to listPerpMainFundingEvents therefore double-counts every
main-funded transfer. Read one or the other, never both as one total, and pick by the
question: the pool side names the order, the bank side carries the claim.
The overlap is exactly the main legs, and no more. An own-wallet pull has no bank-side
twin: the bank's DepositedFor is deliberately not subscribed, because
PerpMarginPull(source: "OwnWallet") already carries the same amount plus the order id.
So PerpMainFundingEvent is the main legs only, while PerpMarginPull is both.
One placement can produce two PerpMarginPull rows — an OwnWallet leg and a Main
leg, in that order — because the child's own wallet contributes what it can before the
main is touched. Filter on source: "Main" to see only what a main actually paid for.
On PerpMainFundingEvent, a Settled row moved no cash: it records the child's losses
discharging part of the claim, so its amount is null while
outstandingPrincipal still drops. Fold by kind rather than summing amount.
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:
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.listLiquidations({ 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.
Reading a liquidation's stage
AccountLiquidated rows carry stageReached. It tells you how far down the
waterfall a liquidation went. The number needs a mapping, because the protocol
enum is 0-indexed with six members while the published waterfall numbers its
stages 1 to 6:
stageReached | Enum member | Published stage |
|---|---|---|
| 0 | OrderCancellation | before stage 3 — cancelling the account's resting orders alone restored health |
| 1 | CLOBPartial | stage 3 |
| 2 | BidderTakeover | stage 4 |
| 3 | InsuranceFund | stage 5 |
| 4 | ADL | stage 6 |
| 5 | Deferred | not a stage — stage 3 hit its per-block rate limit, and stages 4 to 6 were not consulted. Ships dormant, so it does not appear yet |
Published stages 1 (Healthy) and 2 (Margin Call) are account states, not actions. The engine cannot reach them, so no value maps to them.
Two rules govern the number, and neither is visible in the value itself.
It names the deepest stage that acted, not the deepest stage consulted. A
stage that ran and moved nothing does not promote it. So a BidderTakeover (2)
row sitting beside a BadDebtAbsorbed row is consistent. It means the insurance
fund was asked and paid nothing. BadDebtAbsorbed fires on the request, not on
the payment, so read insuranceCovered on that row to see whether the fund
actually paid. A market the fund does not cover produces exactly this shape: the
loss falls through as ResidualBadDebt.
CLOBPartial (1) is a fall-through marker. It means stages 4 to 6 were
consulted and none of them acted. It does not prove stage 3 filled anything.
Against an empty book you get CLOBPartial with a positionsProcessed of 0,
which means the account's orders were cancelled and no position was closed. Read
positionsProcessed with it:
const rows = await client.getLiquidations({ account });
const summary = rows.find((r) => r.kind === "AccountLiquidated");
// A real CLOB partial fill, not an empty-book no-op.
const clobFilled = summary?.stageReached === 1 && Number(summary.positionsProcessed) > 0;
counterparty on a BadDebtAbsorbed row names the fund the engine asked. The
address is recorded even when insuranceCovered is 0, so it does not mean the
fund paid either.
marginStatusAfter needs the same care. A flat account that still owes bad debt
reports CloseOut (3) so the debt stays visible to monitoring. That is a
terminal state, not an account that is still liquidatable. A ResidualBadDebt
row in the same transaction identifies it.
If you query the indexer's GraphQL directly rather than through the SDK, note
that its own field descriptions still carry the older wording for kind,
counterparty and stageReached. Correcting them changes the served schema and
so needs a reindex; it is tracked separately. This section is the current
reference.
Watch from chain (event ABIs)
Indexer history is one-shot and the chain reads are point-in-time. To see a perp event as it lands, decode the log yourself. The SDK publishes the event ABIs so your decoder uses the same signatures the SDK and the indexer use:
| ABI | Contract | Scope | Carries |
|---|---|---|---|
perpPoolEventsAbi | PerpPool | one per market | funding rate, open interest, the maker pre-fill reason tags |
marginBankEventsAbi | MarginBank | singleton, all pools | positions, collateral flow, funding settlement, fees, the liquidation settlement legs |
liquidationEngineEventsAbi | LiquidationEngine | singleton, all pools | the liquidation waterfall stages and their costs |
Both singletons serve every pool and every account, so a subscription is one stream. The pool and the account are indexed topics, not the log source. Get the addresses from the market row and the system config:
import { getAbiItem } from "viem";
import { liquidationEngineEventsAbi, marginBankEventsAbi } from "@somnia-chain/markets-sdk";
const market = await client.getPerpMarket(perpMarketId);
const { liquidationEngine } = await client.getPerpSystemConfig(market!.marginBank);
const viem = client.getViemClient(); // shares the SDK's WebSocket
// Per-wallet funding settlement. `payment` is signed: positive is what the
// account PAYS.
const unwatchFunding = viem.watchEvent({
address: market!.marginBank,
event: getAbiItem({ abi: marginBankEventsAbi, name: "FundingSettled" }),
args: { account },
onLogs: (logs) => {
for (const log of logs) console.log(log.args.perpPool, log.args.payment);
},
});
// Every liquidation on the venue. `PositionLiquidated` is pool-scoped, so filter
// by pool here if you only want one market.
const unwatchLiquidations = viem.watchEvent({
address: liquidationEngine,
event: getAbiItem({ abi: liquidationEngineEventsAbi, name: "PositionLiquidated" }),
onLogs: (logs) => {
for (const log of logs) console.log(log.args.account, log.args.sizeDelta, log.args.markPrice);
},
});
Three things to know before you build on this.
The waterfall is split across both singletons. LiquidationEngine emits the
stages and their costs. MarginBank emits the settlement legs of the same
liquidation — AutoDeleveraged, PositionTransferred, CloseOutMarginSettled
and BadDebtAbsorbed. Subscribe both, or you see half of each event.
Account-level events carry no pool. AccountLiquidated, ResidualBadDebt,
AdlPriceCapacityExhausted and their siblings describe the account, not one
market. A per-pool liquidation filter therefore drops them. Filter by pool only
on the per-position events.
Not every field is summable. BadDebtAbsorbed reports badDebt (the full
negative balance, before coverage) beside covered (the wei the insurance fund
moved). ResidualBadDebt reports the uncovered remainder of the same hole.
Adding them counts one loss twice. The same applies to collateralTransferred on
PositionTransferred and amount on CloseOutMarginSettled: both are flows
between accounts, not losses.
The signatures are pinned by topic0 against the compiled artifacts in
test/perpEventsAbi.test.ts. A wrong signature is a different topic0, which means
watchEvent filters on something no contract emits and your handler never fires,
with no error to see. Use the published ABI rather than a copy.
Note the SDK's own live tail does not subscribe either singleton yet, so
client.watchMarket(pool) gives you funding-RATE updates (FundingUpdated on the
pool) but not funding settlement, positions or liquidations. Those are the raw
watch above until the tail covers them.
Quick start
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:
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). Trades default to the last seven days —
the result's tradesSince is the bound that was applied; pass since to widen it.
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
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.