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