{s}omniamarkets

Binary markets

A binary market is an order book over a YES/NO question ("Will BTC be above $95k at expiry?"). Each market deploys a BinaryMarket contract (lifecycle, resolution) and a BinaryPool (the CLOB + escrow) with a YES and a NO outcome token backed 1:1 by collateral. This guide covers reading and trading them; the shared client mechanics (watches, read tiers, signers) are in the engine guide.

The mental model

  • Prices are YES probabilities. Every price in the API is the YES price in raw collateral units per whole outcome token — 620000 with 6-decimal collateral means 0.62, i.e. a 62% implied probability. Convert with probabilityToPrice / priceToProbability, and fromHuman / toHuman at the UI edges. The NO side is always the complement: a NO at 0.38 is a YES at 0.62 — the pool keeps ONE book in YES terms.
  • Four sides, one book. BinarySide is BUY_YES | SELL_YES | BUY_NO | SELL_NO. Buys escrow collateral; sells escrow the outcome token you're selling. Opposite-side orders can match by minting a fresh YES+NO pair from collateral (two buyers) or burning one back to collateral (two sellers) — the BinaryFillKind on a fill tells you which.
  • Lifecycle. Listed → Trading → Locked → Settling → Resolved | Voided (BinaryMarketStatus). Trading is only possible in Trading; after resolution the winning token redeems 1:1 for collateral, net of the venue's one-time settlement fee if it configured one (voided markets refund both sides at 0.5, never fee'd).
  • Markets roll themselves. Each cadence (15m / 1h / 4h / 24h) is a series that a MarketCreator contract advances autonomously — at every wall-clock boundary it clones the next BinaryMarket + BinaryPool and schedules the oracle question. Series markets are reference-mode: there is no fixed strike (the event's strike is always 0) — a market resolves YES iff its own oracle answer is at/above the previous boundary's reference answer. Markets can also be created directly via BinaryMarketsModule.createMarket under an operator's venue (these can be bucket-mode, with a real strike). Discovery is event-driven either way: an all-markets watch with discover picks each new market up from the creation events (see below). You don't need to create markets — you watch, trade, and redeem them.

The series, and how a market is born

MarketCreator runs the whole protocol off one Somnia reactivity Schedule subscription (the precompile at 0x0100). When it fires, a single callback drains that boundary's whole batch of due series in a gas-bounded loop — rolling series until the gas envelope runs low — then re-arms the Schedule sub for the next-earliest pending boundary. A batch too large for one callback (the 00:00-UTC collision, where every cadence lands at once) saves a resume index and arms a sub for the next block to finish the spill. Exactly one subscription is ever live. (Historical note: an earlier one-series-per-hop RollNext reentrancy chain was tried and removed — on live Somnia the self-emitted event wasn't re-queued after a heavy roll, so it stalled after one hop. The gas-bounded loop with next-block spill replaced it.)

Each roll emits, from the MarketCreator:

MarketCreated(
  bytes32 marketId, address market, address pool,
  uint256 yesId, uint256 noId, address collateral, string asset,
  uint256 strike,           // always 0 — series markets are reference-mode (no fixed strike)
  uint64 tradingStart, uint64 expiry,
  uint256 oracleQuestionId, string question,
  uint64 intervalSec        // series cadence: 900 / 3600 / 14400 / 86400
)

Every market — series-rolled or not — ALSO fires the BinaryMarketsModule's own 19-field MarketCreated, the only creation event carrying the (operatorId, venueId) origin attribution. A discovery watch (watchMarkets({ discover: true })) listens to both, so module-created markets that never pass through a MarketCreator join the watch live too.

  • Read cadence from intervalSec, not the trading window. A series' first market is a bootstrap partial (trigger time → the next aligned boundary), so its expiry − tradingStart is shorter than the cadence. intervalSec is the true series period. On the SDK's BinaryMarket type it surfaces as intervalSec?: string | null (the series cadence, preferred over expiry − tradingStart; null when a market predates the field).

Resolution is a separate loop. When a market expires, the ProphecyOracle posts an AnswerPosted(uint256 questionId, uint8 outcomeIdx, string outcomeLabel, int256 numericValue, bool voided, VoidReason reason, uint256[] receiptIds); the ProphecyOracleAdapter's event subscription catches it and routes to BinaryMarketsModule, which resolves the market (or voids it). The SDK sees this as a Resolved / Voided lifecycle event on the BinaryMarket — gate redemption on that, per the rule below.

Reading

Watch the market, then read the live store — synchronous, zero round-trips, current to the last block:

const watch = await client.watchMarket(market.poolAddress);

const book = client.getLiveBinaryOrderBook(market.poolAddress, { depth: 11 });
// { yesBids, yesAsks, noBids, noAsks } — NO sides derived as 1 − yesPrice
const tape = client.getLiveFills(market.poolAddress, { limit: 40 });
const live = client.getLiveMarketByPool(market.poolAddress); // status, lastPrice, volumes

In React the hooks watch automatically: useLiveBinaryOrderBook(pool), useLiveFills(pool), useLiveMarketByPool(pool), useLiveUserOrders(pool, account). Discovery and history come from the indexer tier: listLiveBinaryMarkets() / listPastBinaryMarkets({ limit, offset }) for the market lists, getCandles(pool, interval) for charts, getPortfolio(account) for a wallet's positions + orders + trades in one round-trip.

listLiveBinaryMarkets() with no argument returns every live market; pass a filter to narrow (all fields optional, applied server-side):

await client.listLiveBinaryMarkets();                            // all live markets
await client.listLiveBinaryMarkets({ operatorId: 1 });           // one operator's live board
await client.listLiveBinaryMarkets({ venueId: "0x5bc0…", asset: "BTC", intervalSec: 900 });
await client.listLiveBinaryMarkets({ status: "Trading" });       // active only

(operatorId is the uint32 operator id; venueId is the venue's opaque bytes32 hex id — enumerate the pairs in play with listBinaryVenueIds().) For the underlying-asset universe and board size, use listBinaryAssets() (the distinct assets with markets, e.g. ["BTC","ETH"]) and countBinaryMarkets({ operatorId?, venueId?, asset?, status? }) (the total behind a filtered board, for pagination headers). Each row carries marketAddress, poolAddress, operatorId, and venueId, so a multi-venue UI can group the live board by origin without any contract-level call — market discovery is the indexer's job, not the MarketCreator's.

One correctness rule: gate writes on live or on-chain status, never indexer statusgetLiveMarketByPool(...).status (chain events) or getMarketOnchain(marketAddress) are authoritative; the indexed status lags.

History + attribution (indexer)

Beyond the live board and portfolio, the indexer serves the fee, resolution, router, and vault-credit history the live tail doesn't materialize — all one-shot reads:

// How a market resolves: lifecycle events + the oracle reference link + the
// posted numeric oracle answer (joined by oracleQuestionId), in one round-trip.
const { events, reference, oracleAnswer } = await client.getMarketResolution(marketId);

// Router-level collateral flow for a wallet (redeem / mint / merge complete set),
// attributed to the TRUE end user even through the native/Permit2 periphery.
const actions = await client.getRouterActions(account, { market, limit: 50 });

// The per-fill fee streams behind getMarketFees' running total (all filter by
// `payer` — the order owner who funded the fee — plus recipient/market/pool).
const protoFees  = await client.listProtocolFees({ market, payer });
const bldFees    = await client.listBuilderFees({ builder });
const settleFees = await client.listSettlementFees({ market });

// Builder-approval directory (the indexed complement to the on-chain point read
// getBuilderApproval) — every user→builder cap, filterable by user or builder.
const approvals = await client.listBuilderApprovals({ user });

Vault credits. A payout that can't be delivered to the wallet is credited to the owner's ERC20Vault balance instead. That history is append-only — client.getVaultPayoutFallbacks(owner, { token }) lists the credit log — but the vault's own credit/debit is silent (no event), so the live claimable balance is a chain read: client.getVaultBalance(vault, owner, token) (ERC20Vault.getWithdrawableBalance). Withdraw with trader.withdrawVault({ vault, token, amount }).

Trading

const trader = client.createTrader({ privateKey }); // Node — or { walletClient } in the browser

// Rest a limit order: buy 10 YES at 0.62.
const { orderId, fills, receipt } = await trader.placeOrder({
  pool: market.poolAddress,
  side: "BUY_YES",
  price: probabilityToPrice(0.62),   // raw collateral units per whole token
  quantity: fromHuman(10),           // raw outcome-token units
});
if (fills.length) console.log("crossed immediately:", fills);
else console.log("resting as order", orderId);

await trader.cancelOrder({ pool: market.poolAddress, orderId });

Every write awaits its receipt — there is no bare hash to babysit, and placeOrder resolves with the decoded orderId + fills from the same round-trip. The escrow token (collateral for buys, YES/NO for sells) is approved automatically on first use; pass autoApprove: false to manage approvals yourself.

  • Market orders are orderType: ORDER_TYPE.MARKET (an IOC) placed at a crossing price — cross the best opposite level ± slippage so it sweeps and the remainder cancels. ORDER_TYPE also has FILL_OR_KILL and POST_ONLY.
  • Sides and prices: all four BinarySides take the YES-terms price. A BUY_NO at YES-price 0.62 escrows quantity × (1 − 0.62) collateral.
  • Token wiring resolves from the pool contract automatically (cached); pass outcomeToken/yesId/noId/collateral explicitly to skip even that one-time read.

Builder / routing fees (optional)

A frontend that routes orders can attach itself to each order and charge a per-order fee — but only after the trader has opted it in on that pool:

await trader.approveBuilder({ pool, builder, maxFeeBpsTimes1k: 5_000n }); // allow up to 5 bps; 0 revokes

await trader.placeOrder({
  pool, side: "BUY_YES", price, quantity,
  builder,                        // the routing/builder frontend
  builderFeeBpsTimes1k: 5_000n,   // per-order fee, pool bps×1000 unit
});

A non-zero builderFeeBpsTimes1k without a prior approval reverts. The enforced ceiling is the trader's approval clamped by the pool's protocol-wide cap — read it with trader.getEffectiveBuilderApproval(pool, user, builder) (the pool-wide cap alone is getMaxBuilderFeeBpsTimes1k(pool)). On the exchange surface the same rides createOrder's params.builder / params.builderFeeBpsTimes1k (binary markets only).

Complete sets and settlement

await trader.mintSet({ pool, amount });        // collateral → equal YES + NO
await trader.burnSet({ pool, amount });        // YES + NO → collateral back
await trader.redeem({ market: marketAddress, amount }); // winning token → collateral (post-resolution)

mintSet/burnSet are how you take (or unwind) a both-sides position without touching the book; redeem pays out after resolution (it looks up the winning outcome on-chain if you don't pass it). If the venue configured a settlement fee, the pool skims it ONCE from the whole winning backing at the first winning redeem (SettlementFeeCharged) and every winner redeems for 1 − fee — voided markets refund both sides at 0.5 with no fee. Demo-stack extras: faucet() mints the test collateral; resolve/voidMarket drive the FakeOracle.

A maker loop, end to end

const watch = await client.watchMarket(pool);
const trader = client.createTrader({ privateKey });

client.subscribeLive(async () => {
  const book = client.getLiveBinaryOrderBook(pool, { depth: 1 });      // zero RTT
  const mine = client.getLiveUserOrders(pool, me, { limit: 50 })
    .filter((o) => o.status === "Open");                          // zero RTT
  // decide → place/cancel; each write confirms in one round-trip (~500ms)
});

The live store is the only state a quoting loop needs: the book, your working orders, and fills all update the moment the chain emits them.