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 —
620000with 6-decimal collateral means 0.62, i.e. a 62% implied probability. Convert withprobabilityToPrice/priceToProbability, andfromHuman/toHumanat 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.
BinarySideisBUY_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) — theBinaryFillKindon a fill tells you which. - Lifecycle.
Listed → Trading → Locked → Settling → Resolved | Voided(BinaryMarketStatus). Trading is only possible inTrading; 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
MarketCreatorcontract advances autonomously — at every wall-clock boundary it clones the nextBinaryMarket+BinaryPooland schedules the oracle question. Series markets are reference-mode: there is no fixed strike (the event'sstrikeis 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 viaBinaryMarketsModule.createMarketunder an operator's venue (these can be bucket-mode, with a real strike). Discovery is event-driven either way: an all-markets watch withdiscoverpicks 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 itsexpiry − tradingStartis shorter than the cadence.intervalSecis the true series period. On the SDK'sBinaryMarkettype it surfaces asintervalSec?: string | null(the series cadence, preferred overexpiry − tradingStart;nullwhen 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
status — getLiveMarketByPool(...).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_TYPEalso hasFILL_OR_KILLandPOST_ONLY. - Sides and prices: all four
BinarySides take the YES-terms price. ABUY_NOat YES-price 0.62 escrowsquantity × (1 − 0.62)collateral. - Token wiring resolves from the pool contract automatically (cached); pass
outcomeToken/yesId/noId/collateralexplicitly 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.