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:

ts
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):

ts
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:

ts
// 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

ts
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.

Stake-sized market orders ("bet $50 on Up")

A stake-first UI usually asks for a collateral stake, not a share quantity. quoteBinaryStake is the inverse of quoteBinaryOrder: it walks the live asks cheapest-first, sizing the largest quantity whose escrow at the worst level touched stays within the stake — so the quoted shares and payout match what actually fills, not a top-of-book estimate. The protective limit is padded with a slippage cushion (default 3%, min 10 ticks) so the IOC still crosses a moving book, aligned to the pool's on-chain tick grid, and the quantity is lot-aligned with escrow re-fit under the stake — off-grid prices and non-lot quantities are the pool's two rejection reasons, handled for you:

ts
const quote = await client.quoteBinaryStake({
  pool: market.poolAddress,
  side: "BUY_YES",                       // Up; "BUY_NO" for Down
  stake: fromHuman(50),                  // raw collateral — the max loss
});
if (quote) {
  await trader.placeOrder({
    pool: market.poolAddress,
    side: quote.side,
    price: quote.yesPrice,               // protective limit, YES terms
    quantity: quote.quantity,            // shares = payout if this side wins
    orderType: ORDER_TYPE.MARKET,
  });
}

null means nothing is fillable (empty book, or a stake too small for one lot) — disable the control rather than sending a doomed order. Unwind a position with the sell-side sibling, which cushions a floor below the best bid the same way:

ts
const sell = await client.quoteBinarySell({
  pool: market.poolAddress,
  side: "SELL_YES",
  quantity: position.balance,            // lot-aligned down automatically
});
if (sell) {
  await trader.placeOrder({
    pool: market.poolAddress,
    side: sell.side,
    price: sell.yesPrice,
    quantity: sell.quantity,
    orderType: ORDER_TYPE.MARKET,
  });
}

Unlike the buy side, a sell's quantity is your position, not a book-sized fit — on a thin book the IOC fills what rests within the cushion and cancels the rest. Compare sell.fillableQuantity (with sell.estProceeds, the collateral it would raise) against sell.quantity and warn before submitting when the unwind would be partial.

Both quotes read the live book (needs an active watch) plus the pool's tick/lot grid via one cached eth_call (getBinaryBookParams). The pure kernels — quoteBinaryStakeOverBook / quoteBinarySellOverBook / slippageForCrossing — are exported for callers that already hold a book.

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:

ts
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, on binary, spot and perp alike — each pool holds its own approval, so opt in once per pool.

Complete sets and settlement

ts
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

ts
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.