{s}omniamarkets

Raw Smart-Contract Integration

Somnia Markets runs three market families on one on-chain order-book engine: spot pairs (SpotPool), binary prediction markets (BinaryPool), and perps (PerpPool). All three extend the same shared OrderBook core, so the placement surface, the order-lifecycle events, and the book-materialization technique are identical across every book; what differs per family is discovery, funding/escrow, and settlement.

This guide is how to trade with nothing but an RPC node and the contract ABIs — any language, any stack. It covers the shared core first, then each family in turn, then materializing a live local order book from the event stream. Pair it with Market Making if you are building a quoting system. (Building a JS/TypeScript app instead? The SDK wraps everything here.)

What you need to start: contract addresses and signatures. Protocol contracts (the BinaryMarketsModule market registry, the MarketsCore control plane, the OutcomeToken6909 singleton) are listed live per network on this explorer's System page; spot pairs and live prediction markets are listed on the markets page; per-market prediction addresses are read from the module's registry on-chain (below). The complete Solidity interfaces — everything needed to generate ABIs — are in the interface reference at the end of this guide.

The shared order-book core

Every book — spot, binary, perp — exposes the same placement entrypoint, covering all order types (GTC / NormalOrder, FillOrKill, ImmediateOrCancel, PostOnly):

function placeOrder(
    bool isBid, uint64 userData, uint256 price, uint256 quantity,
    uint64 expireTimestampNs, OrderType orderType,
    SelfMatchingOption selfMatchingOption,
    address builder, uint96 builderFeeBpsTimes1k
) external payable returns (bool success, OrderId id);
  • expireTimestampNs is mandatory and nanoseconds — a future unix-ns timestamp (now_seconds * 1e9 + lifetime_ns); zero or past values are rejected. There is no "no expiry" sentinel.
  • success == false without a revert is a normal outcome — a PostOnly that would cross, a FillOrKill that can't fully fill, an IOC that fills nothing. Always branch on it.
  • Quantize in integers. getOrderBookParameters() returns tickSize, lotSize, and minQuantity in raw token units; off-grid values revert InvalidPrice / InvalidQuantity.
  • builder / builderFeeBpsTimes1k attribute the order to a routing/builder frontend and pay it a per-order fee (basis-points × 1000). Pass (address(0), 0) for none. A non-zero fee requires the order owner to have opted the builder in first — approveBuilder(builder, maxFeeBpsTimes1k) on the pool (0 revokes) — and is capped by the pool's getMaxBuilderFeeBpsTimes1k(); charges emit BuilderFeeCharged. (Spot and binary pools; perp pools reject non-default builder values for now.)
  • cancelOrder(id) / reduceOrder(id, newQty) manage resting orders; both revert (rather than no-op) on already-terminal orders — check your local book or getOrder(id) first. cancelExpiredOrders(ids) and sweepExpiredAtLevel(isBid, price, maxCount) are permissionless cleanup.
  • OrderIds are unique per pool — always key by (pool, orderId).

Reading any book on demand, via eth_call:

ViewReturns
getBookLevels(isBid, numLevels)Aggregated (price, quantity) levels, best first
getAllOpenOrdersOffChain(isBid, maxCount, startCursor)Full Order structs, paginated (msg.sender must be address(0) — the eth_call default)
getOrder(orderId) / getOwnOpenOrders()Single order / caller's open ids

Polling these is fine for a dashboard; a trading system should take one snapshot and apply event deltas — see materialization.

Spot markets

Each trading pair is its own SpotPool — live pairs are listed on the markets page. getPoolParams() returns everything static in one call: (baseToken, quoteToken, makerFeeBpsTimes1k, takerFeeBpsTimes1k, tickSize, minQuantity, lotSize). A native-token side is reported as a sentinel token address (not address(0)) — resolve it from getPoolParams rather than assuming.

Spot semantics are the conventional ones: isBid = true buys base with quote, a fill is a base ↔ quote swap at the maker's resting price, and userData is a free 64-bit tag echoed back on the order struct and its OrderPlaced event — use it for strategy/generation ids (this is not true on the binary books — see below).

Funding. By default the pool auto-pulls the required input from your wallet at placement (ERC-20 transferFrom after a one-time approval, or msg.value on native pools) and auto-delivers proceeds back on fill, cancel, or expiry. Size the pull with getAutoPullRequirement(owner, isBid, price, quantity, builderFeeBpsTimes1k). High-churn integrations should opt out with setManualVaultMode(true): deposit once, quote against the vault balance with no per-order token transfers, withdraw when rebalancing — for a loop that places and cancels constantly the gas savings compound.

Mark price. Spot pools push MarkPriceUpdated(asset, markPrice, rawMidpoint) whenever the book midpoint advances — an EMA-smoothed midpoint plus the raw (bestBid + bestAsk) / 2, an on-chain fair-price feed with no extra infrastructure.

Binary prediction markets

Discover markets

BinaryMarketsModule is the market registry (the MarketsCore control plane holds only operators, venues, and module bindings — no markets). Each market is a MarketRecord (read via module.markets(marketId)):

FieldUse
poolThe BinaryPool — the order book you trade on
marketThe BinaryMarket — lifecycle + resolution state
collateralThe ERC-20 quoted against (one per market)
originOperatorId / originVenueIdOrigin attribution: the uint32 operator id + opaque bytes32 venue id the market was created under
yesId / noIdERC-6909 ids of the outcome positions on the shared OutcomeToken6909 singleton
tradingStart / expiryTrading window (unix seconds)

Watch the module's MarketCreated event to discover new markets — every market fires it, whether created directly via createMarket or rolled by a venue's MarketCreator (venues run rolling series — hourly up/down and similar — that create markets continuously), and it is the only creation event carrying the (operatorId, venueId) origin. marketId is a module-scoped counter (bytes32(++marketSeq)) that doubles as the CREATE2 salt — unique by construction, not precomputable before the create tx. module.marketIdByAddress(addr) is the reverse lookup.

Every market carries an immutable kind tag, IMarket.marketType(). This section describes BINARY_SINGLE_BOOK — the only kind live today. Future kinds (dual-book, multi-outcome) will carry different tags and different book semantics: branch on the tag, never on assumptions.

Lifecycle: Listed → Trading → Locked → (Settling) → Resolved | Voided (BinaryMarket.StatusChanged, plus Resolved / Voided). Orders are only accepted while the market reports Trading — placement reverts TradingNotActive otherwise. Cancellation stays open through Locked; redeem opens at Resolved / Voided.

One book, two outcomes — the (isBid, userData) encoding

Each BinaryPool runs a single order book. Unlike spot, userData is not a tag field — it selects the book half, and the four order kinds are encoded in the (isBid, userData) pair:

userDataisBid = trueisBid = false
0 (YES book)BUY_YESSELL_YES
1 (NO book)SELL_NOBUY_NO

Any other userData value reverts InvalidUserData. The NO book is price-inverted so the base matcher can cross the two books without knowing about outcomes: a NO order is submitted at the YES-side complement, price = oneCollateral − noPrice, where oneCollateral = 10 ** collateral.decimals(). All prices must satisfy 0 < price < oneCollateral (PriceOutOfBounds) — a share can never be worth more than one collateral unit.

Escrow and funding

Placement escrows worst-case value up front:

  • BUY orders (BUY_YES, BUY_NO) lock collateral (ceil-rounded price × quantity in the order's own frame). The pull is vault-first: free vault balance is consumed before transferFrom on your wallet — so collateral.approve(pool, …) once per pool. (There is no auto-pull/manual-vault toggle here; this is the only mode.)
  • SELL orders (SELL_YES, SELL_NO) lock the outcome tokens themselves, pulled from your ERC-6909 balance on the singleton — call outcomeToken.setOperator(pool, true) once; it covers every market, since ids are namespaced by pool.

Money flows back through two channels worth knowing:

  • Refunds land in your vault balance — taker surplus (you locked at your limit price, filled at a better maker price) and cancel/reduce/expiry refunds credit the pool vault, not your wallet. Because placement is vault-first, a quoting loop recycles these automatically; withdraw(collateral, amount) only when rebalancing. Read it with getWithdrawableBalance(owner, collateral).
  • Fill proceeds deliver wallet-first with vault fallback — if the wallet transfer fails the pool credits the vault and emits PayoutFallbackToVault.

Inventory comes from complete sets: module.mintCompleteSet(operatorId, venueId, marketId, amount) turns amount collateral into amount YES + amount NO (setOperator(module, true) once for the merge/redeem directions; operatorId/venueId are routing attribution only — pass 0 / bytes32(0) for none); mergeCompleteSet(operatorId, venueId, marketId, amount) is the inverse; redeem(operatorId, venueId, marketId, outcomeIdx, amount) burns the winning side 1:1 after resolution (or either side at 1/2 on void). You can also call the pool's mintSet / burnSet / redeem directly. Note you often need no inventory at all to quote — see the fill paths below.

On a resolved market (never a voided one), the pool skims the venue's frozen settlementFeeBpsTimes1k ONCE from the whole winning backing when the first winning redeem lands (emitting SettlementFeeCharged(feeRecipient, winningBacking, fee)); every winner then redeems for 1 − fee per token. Per-fill maker/taker fees (ProtocolFeeCharged) and the per-order builder fee (BuilderFeeCharged) are the other two fee rails — all rates are frozen into the pool at creation from the origin venue's config and readable via getBinaryPoolParams().

The four fill paths

Two opposite-kind orders crossing settle by one of four paths (dispatched on the unordered pair of order kinds):

Crossing pairPathSettlement
BUY_YES × SELL_YESDIRECT_YESYES ↔ collateral swap
SELL_NO × BUY_NODIRECT_NONO ↔ collateral swap
BUY_YES × BUY_NOMINT_A_PAIRBoth pay collateral; pool mints a fresh pair, one side each (emits SetMinted)
SELL_YES × SELL_NOBURN_A_PAIRBoth escrows burned as a pair; each seller paid their share (emits SetBurned)

The fill price on OrderFilled is always the maker's resting price, in the YES frame; the NO-side display price is oneCollateral − fillPrice. There is no on-chain "fill kind" field — consumers derive it from the two orders' kinds — the SDK exports kindOf and fillKind for exactly this derivation.

getOrderInfo(orderId) exposes the prediction-side escrow state per order (lockedCollateral / lockedOutcome, kind).

Perps

PerpPool extends the same OrderBook core, so everything in the shared core and materialization carries over unchanged. On top it adds margin accounting, funding, and liquidations — with FundingUpdated / OpenInterestUpdated events, and a dedicated MakerOrderCancelledExceedsPosition removal event (a maker order erased pre-fill because it would breach the owner's position limit — treat it as a removal in a book reducer). BTC and ETH perps are live on testnet — see Perps.

Materialize the order book from events

Every book mutation emits an event, so one pinned snapshot plus the log stream reproduces the full book — for any of the three families. This is exactly how the protocol's own indexer and the SDK's live watches work — the reducer below is the same one they run (see Architecture for the SDK's implementation of it).

Snapshot, then stream

  1. Pin a block N (eth_blockNumber).
  2. Snapshot both sides with getAllOpenOrdersOffChain at block N (pass the block tag explicitly so pagination pages are mutually consistent). Drop orders whose expireTimestampNs has already passed.
  3. Stream the pool's logs from N + 1 (eth_subscribe("logs") over WSS, or an eth_getLogs loop) and apply them strictly in (blockNumber, logIndex) order.

The event reducer

Keep a map orderId → Order plus per-side level aggregates:

EventAction
OrderPlaced(orderId, placedOrder)Cache the struct; do not insert. Fires for every accepted order — placedOrder.quantityRemaining is already the post-match residual (the matcher runs before the event), so zero remaining means it fully filled on entry.
OrderRested(orderId)Insert the cached order into the book. This is the only insert signal.
OrderFilled(takerId, makerId, qty, takerRem, makerRem, fillPrice)Patch the maker: remaining = makerRem, remove at zero. Ignore the taker leg — its state arrives on its own OrderPlaced/OrderRested later in the same tx.
OrderReduced(orderId, newQuantity)Set remaining = newQuantity (not a fill).
OrderCancelled(orderId) / OrderExpired(orderId)Remove (idempotently).
OrderCancelledSelfMatch(orderId)Remove — a same-owner resting maker erased by a CancelMaker self-match.

Family extras, none of which mutate book state: spot pools also emit MarkPriceUpdated (fair-price telemetry); binary pools emit SetMinted / SetBurned / Redeemed / SettlementFeeCharged (outcome-supply / settlement telemetry — mint-a-pair and burn-a-pair fills emit the first two alongside OrderFilled) and BinaryMarket.StatusChanged (leaves Trading → no new fills, only cancels from here); perp pools add MakerOrderCancelledExceedsPosition, which does mutate the book — treat it as a removal.

Ordering inside a placement tx: settlement hooks and one OrderFilled per matched maker fire first, then the taker's OrderPlaced (already residual-adjusted), then OrderRested iff the residual rested. A reducer that only inserts on OrderRested handles this for free.

The sharp edges

  • Self-match removals have their own event. With SelfMatchingOption.CancelMaker, the matcher erases the same-owner resting maker and emits OrderCancelledSelfMatch(orderId) (on every book — spot, binary, perp). Handle it as a removal or your reducer carries a phantom order whenever any trader self-crosses with CancelMaker. With CancelTaker the incoming order is rejected instead (success == false).
  • Expiry is lazy. Matching walks past expired makers without cleanup; OrderExpired only fires later (owner's next placement, or the permissionless sweeps). Prune locally the moment expireTimestampNs passes — an expired order can never fill — and treat the eventual event as a no-op.
  • Make removals idempotent. Between local pruning and the several removal paths, "remove an already-absent order" must be a no-op.
  • Reorgs / stream gaps. On WSS reconnect, backfill the missed range with eth_getLogs before resuming. Note Somnia's public RPC omits the removed field on logs, so don't rely on it to detect reorgs — on any parent-hash discontinuity, re-snapshot rather than trying to unwind.

Track your own orders and fills

Both order-id parameters on OrderFilled are indexed topics, so you can filter your fills by the ids your placeOrder calls returned (or getOwnOpenOrders() after a restart). Alert on PayoutFallbackToVault — a delivery to your wallet failed and the proceeds are parked in your vault balance. Reconcile funds with getWithdrawableBalance(owner, token) and, on binary markets, outcomeToken.balanceOf(owner, yesId | noId) for positions.

Interface reference

ABI-faithful excerpts of every function and event used in this guide, taken from the deployed contracts. Admin/venue-governance entrypoints are omitted; external interface types (IERC20, adapters) are shown as address — the ABI encoding is identical.

Shared order-book core (every pool)

type OrderId is uint128;

enum OrderType { NormalOrder, FillOrKill, ImmediateOrCancel, PostOnly }
enum SelfMatchingOption { CancelTaker, CancelMaker }

struct Order {
    OrderId orderId;
    bool isBid;
    address owner;
    uint64 userData;
    uint256 price;
    uint256 fullQuantity;
    uint256 quantityRemaining;
    uint64 expireTimestampNs;
}

struct OrderBookLevel {
    uint256 price;
    uint256 quantity;
}

struct OrderBookParameters {
    uint256 tickSize;      // min price increment, raw quote/collateral units
    uint256 minQuantity;   // min order quantity, raw base units
    uint256 lotSize;       // min quantity increment, raw base units
}

interface IOrderBook {
    event OrderPlaced(OrderId indexed orderId, Order placedOrder);
    event OrderRested(OrderId indexed orderId);
    event OrderFilled(
        OrderId indexed takerOrderId,
        OrderId indexed makerOrderId,
        uint256 quantityFilled,
        uint256 takerRemainingQuantity,
        uint256 makerRemainingQuantity,
        uint256 fillPrice
    );
    event OrderCancelled(OrderId indexed orderId);
    event OrderCancelledSelfMatch(OrderId indexed orderId);
    event OrderExpired(OrderId indexed orderId);
    event OrderReduced(OrderId indexed orderId, uint256 newQuantity);
    event OrderBookParametersUpdated(OrderBookParameters newParameters);

    function placeOrder(
        bool isBid,
        uint64 userData,
        uint256 price,
        uint256 quantity,
        uint64 expireTimestampNs,
        OrderType orderType,
        SelfMatchingOption selfMatchingOption,
        address builder,
        uint96 builderFeeBpsTimes1k
    ) external payable returns (bool success, OrderId id);

    function cancelOrder(OrderId orderId) external;
    function reduceOrder(OrderId orderId, uint256 newQuantityRemaining) external;
    function cancelExpiredOrders(OrderId[] calldata orderIds) external;
    function sweepExpiredAtLevel(bool isBid, uint256 price, uint256 maxCount)
        external returns (uint256 cleaned);

    function getOrder(OrderId orderId) external view returns (Order memory);
    function getOwnOpenOrders() external view returns (OrderId[] memory);
    function getBookLevels(bool isBid, uint64 numLevels)
        external view returns (OrderBookLevel[] memory);
    function getOrderBookParameters() external view returns (OrderBookParameters memory);
    // eth_call only — msg.sender must be address(0)
    function getAllOpenOrdersOffChain(bool isBid, uint256 maxCount, uint64 startCursor)
        external view returns (Order[] memory orders, bool hasMoreOrders, uint64 nextCursor);
}

// The per-user vault, inherited by every pool.
interface IVault {
    function deposit(address token, uint256 amount) external;
    function depositNative() external payable;
    function withdraw(address token, uint256 amount) external;
    function getWithdrawableBalance(address owner, address token)
        external view returns (uint256);
}

SpotPool additions

interface ISpotPool /* is IOrderBook, IVault */ {
    event MarkPriceUpdated(address indexed asset, uint256 markPrice, uint256 rawMidpoint);
    event ManualVaultModeUpdated(address indexed user, bool enabled);
    event PayoutFallbackToVault(address indexed owner, address indexed token, uint256 amount);

    function setManualVaultMode(bool enabled) external;

    function getPoolParams() external view returns (
        address baseToken,
        address quoteToken,
        uint256 makerFeeBpsTimes1k,
        uint256 takerFeeBpsTimes1k,
        uint256 tickSize,
        uint256 minQuantity,
        uint256 lotSize
    );
    function getManualVaultMode(address user) external view returns (bool enabled);
    function getAutoPullRequirement(
        address owner,
        bool isBid,
        uint256 price,
        uint256 quantity,
        uint96 builderFeeBpsTimes1k
    ) external view returns (address inputToken, uint256 requiredAmount, uint256 delta);
    function getMidpointEmaState() external view returns (uint256 emaValue, uint64 lastUpdateNs);
}

BinaryPool additions

struct BinaryPoolOrderInfo {
    OrderId orderId;
    uint256 lockedCollateral;   // BUY escrow (mutually exclusive with lockedOutcome)
    uint256 lockedOutcome;      // SELL escrow
    uint8 outcomeIdx;           // 0 = YES, 1 = NO
    bool isBuy;
    address builder;
    uint96 builderFeeBpsTimes1k;
}

struct BinaryPoolInfo {
    address collateralToken;
    address market;
    address outcomeToken;
    uint256 yesId;
    uint256 noId;
    uint256 oneCollateral;      // 10 ** collateral.decimals()
    uint256 setBacking;
    address feeRecipient;
    uint256 makerFeeBpsTimes1k;
    uint256 takerFeeBpsTimes1k;
    uint256 maxBuilderFeeBpsTimes1k;
    uint256 settlementFeeBpsTimes1k;
}

interface IBinaryPool /* is IOrderBook, IVault */ {
    event SetMinted(address indexed payer, address indexed yesTo, address indexed noTo, uint256 amount);
    event SetBurned(address indexed holder, uint256 amount);
    event Redeemed(
        address indexed holder,
        address indexed to,
        uint8 outcomeIdx,
        uint256 amountBurned,
        uint256 collateralOut
    );
    // Fee rail: per-fill protocol fees, per-order builder fees (opt-in via
    // approveBuilder), and the one-time settlement fee on the winning backing.
    event ProtocolFeeCharged(
        OrderId indexed orderId, address indexed recipient, address indexed token, uint256 amount, bool isTakerSide
    );
    event BuilderApproved(address indexed user, address indexed builder, uint256 maxFeeBpsTimes1k);
    event BuilderFeeCharged(OrderId indexed orderId, address indexed builder, address indexed token, uint256 amount);
    event SettlementFeeCharged(address indexed feeRecipient, uint256 winningBacking, uint256 fee);
    event PayoutFallbackToVault(address indexed owner, address indexed token, uint256 amount);

    function approveBuilder(address builder, uint256 maxFeeBpsTimes1k) external;
    function getMaxBuilderFeeBpsTimes1k() external view returns (uint256 maxFeeBpsTimes1k);
    function getBuilderApproval(address user, address builder) external view returns (uint256 maxFeeBpsTimes1k);
    function getEffectiveBuilderApproval(address user, address builder) external view returns (uint256 maxFeeBpsTimes1k);

    function mintSet(address yesTo, address noTo, uint256 amount) external;
    function burnSet(uint256 amount) external;
    function redeem(uint256 amount, uint8 outcomeIdx, address to)
        external returns (uint256 collateralOut);

    function collateralToken() external view returns (address);
    function outcomeToken() external view returns (address);
    function outcomeId(uint8 outcomeIdx) external view returns (uint256);
    function market() external view returns (address);
    function setBacking() external view returns (uint256);
    function getBinaryPoolParams() external view returns (BinaryPoolInfo memory info);
    function getOrderInfo(OrderId orderId) external view returns (BinaryPoolOrderInfo memory info);
}

BinaryMarketsModule (the market registry)

enum VoidPolicy { UNIFORM, AMM_SNAPSHOT }  // AMM_SNAPSHOT is a dead legacy slot
enum MarketType { BINARY_SINGLE_BOOK, BINARY_DUAL_BOOK, NATIVE_MULTI }

interface IBinaryMarketsModule {
    event MarketCreated(
        bytes32 indexed marketId,
        address indexed market,
        address indexed pool,
        uint256 oracleQuestionId,
        uint32 operatorId,
        bytes32 venueId,
        address creator,
        address collateral,
        uint256 yesId,
        uint256 noId,
        uint8 outcomeSlotCount,
        MarketType marketType,
        uint64 tradingStart,
        uint64 expiry,
        VoidPolicy voidPolicy,
        string asset,
        uint256 strike,        // 0 for reference-mode markets (no fixed strike)
        string question,
        bytes context
    );

    function markets(bytes32 marketId) external view returns (
        uint256 oracleQuestionId,
        uint8 outcomeSlotCount,
        VoidPolicy voidPolicy,
        address collateral,
        uint32 originOperatorId,
        bytes32 originVenueId,
        address oracleAdapter,
        address creator,
        address market,
        address pool,
        uint256 yesId,
        uint256 noId,
        uint64 tradingStart,
        uint64 expiry
    );
    function marketIdByAddress(address market) external view returns (bytes32 marketId);

    // operatorId / venueId are routing attribution only (0 / bytes32(0) = none).
    function mintCompleteSet(uint32 operatorId, bytes32 venueId, bytes32 marketId, uint256 amount) external;
    function mergeCompleteSet(uint32 operatorId, bytes32 venueId, bytes32 marketId, uint256 amount) external;
    function redeem(uint32 operatorId, bytes32 venueId, bytes32 marketId, uint8 outcomeIdx, uint256 amount) external;
}

BinaryMarket (lifecycle) and the outcome-token singleton

enum MarketStatus { Listed, Trading, Locked, Settling, Resolved, Voided }

interface IBinaryMarket {
    event StatusChanged(MarketStatus indexed oldStatus, MarketStatus indexed newStatus);
    event Resolved(uint8 indexed winningOutcome);
    event Voided();

    function marketType() external view returns (MarketType);
    function tradingActive() external view returns (bool);
    function isResolved() external view returns (bool);
    function isVoided() external view returns (bool);
    function winningOutcome() external view returns (uint8);
}

// ERC-6909 subset. One singleton for all markets; id = (uint160(pool) << 8) | outcomeIdx.
interface IOutcomeToken6909 {
    event Transfer(
        address caller,
        address indexed sender,
        address indexed receiver,
        uint256 indexed id,
        uint256 amount
    );

    function setOperator(address spender, bool approved) external returns (bool);
    function transferFrom(address from, address to, uint256 id, uint256 amount)
        external returns (bool);
    function balanceOf(address owner, uint256 id) external view returns (uint256);
}