Recipes

Every action an event-contract bot needs, as a short snippet. All of these assume an exchange built as in Building on Event Contracts, and a signer for anything that writes. The snippets run inside your bot's per-market async function — that's where market, onchain, and early exits like return live — and everything else they name comes from the same package:

ts
import { isBinaryMarket, ORDER_TYPE, type PlaceOrderResult } from "@somnia-chain/markets-sdk";

Three tiers are available and you will use all of them:

TierReach it withUse it for
Unifiedexchange.*Trading by symbol in human units. Most of your bot.
Client (reads)exchange.client.*On-chain truth: market status, outcome balances.
Trader (writes)exchange.trader.*The few writes the unified tier does not model, notably redeeming a specific outcome.

Find a market worth trading

Gate on the on-chain status, and skip windows that are about to close.

ts
const now = Date.now() / 1000;
const candidates = [];

for (const m of Object.values(await exchange.loadMarkets(true))) {
  // `info` is a union across market kinds; isBinaryMarket narrows it so
  // marketId is reachable.
  if (!m.active || !isBinaryMarket(m.info)) continue;
  // Row ids are plain strings; the client wants them hex-typed.
  const onchain = await exchange.client.getMarketOnchain(m.info.marketId as `0x${string}`);
  if (onchain.status !== 1) continue;                 // 1 = Trading
  const secondsLeft = Number(onchain.expiry) - now;
  if (secondsLeft < 300) continue;                    // no time for anything useful
  candidates.push({ market: m, onchain, secondsLeft });
}

Keep the onchain snapshot you validated and reuse it for the rest of the pass. Pools are recycled between windows, so a snapshot taken now is the one generation your reads and writes agree on.

Read the book

ts
const [up, down] = market.outcomes ?? [];
if (!up || !down) return;                       // not a binary market
const { yes, no } = { yes: up.symbol, no: down.symbol };
const book = await exchange.fetchOrderBook(yes, 5);
const bestBid = book.bids[0]?.[0];
const bestAsk = book.asks[0]?.[0];

Prices are Up probabilities in (0, 1). The Down book is the same book read from the other side: quote no and the SDK converts to Up terms for you.

Read a market's volume

Every market row carries its own traded volume, so per-contract volume is a read rather than something you aggregate yourself:

ts
const SCALE = 1e18; // the collateral's decimals: 1e18 on mainnet USDso, 1e6 on the testnet faucet token

const rows = await exchange.client.listBinaryMarkets({ status: "Finalized", limit: 60 });

for (const m of rows.filter((r) => Number(r.tradeCount) > 0)) {
  console.log({
    asset: m.asset,                                       // "BTC" | "ETH"
    cadence: Number(m.intervalSec) / 60 + "m",
    volume: Number(m.cumulativeQuoteVolume) / SCALE,      // collateral, USDso
    contracts: Number(m.cumulativeBaseVolume) / SCALE,
    trades: Number(m.tradeCount),
    lastPrice: m.lastPrice ? Number(m.lastPrice) / SCALE : null,
    lastTradeAt: m.lastTradeAt,
  });
}

To rank markets by volume rather than scan for it, pass orderBy: "volume". The sort runs server-side; the keys are newest, closingSoon, volume and tradeCount.

cumulativeQuoteVolume is the collateral that changed hands, counting each fill once: a direct fill is worth one side's notional, and a mint or burn is worth the whole contract because the two sides each pay their share of it. Summing your own per-trader legs instead gives a larger number, because a direct fill has both a payer and a receiver.

SCALE is the collateral's decimals, not a universal constant: 18 on mainnet USDso, 6 on the testnet faucet token — set it to the venue you're reading.

For a ccxt-shaped view of the same numbers, fetchTicker(outcomeSymbol) returns baseVolume and quoteVolume already scaled.

Size to the venue's lot grid

From markets-sdk 0.24.0 amountToPrecision reads the pool's lot size, so the unified verbs size correctly on their own. You still quantize by hand when you build params for the raw trader tier, which takes exact units:

ts
const LOT = 1_000_000_000_000_000n;           // 1e15 on an 18-decimal venue
const decimals = 18;

function quantize(human: number): number {
  const raw = BigInt(Math.floor(human * 10 ** decimals));
  const snapped = (raw / LOT) * LOT;
  return Number(snapped) / 10 ** decimals;    // 0 means "below one lot, skip"
}

Price on the tick grid, as integers

Do this before you place anything on an 18-decimal venue.

createOrder converts your price with parseUnits(price.toFixed(18), 18), and (0.05).toFixed(18) is "0.050000000000000003" — three wei off the tick grid, which the pool rejects with InvalidPrice. Of fifteen ordinary probabilities only 0.25, 0.5 and 0.75 survive that conversion, because those are the ones binary floating point represents exactly. A 6-decimal venue never shows it.

So snap the price to a whole number of ticks and send it as a bigint through the raw trader tier, which takes exact units:

ts
const ONE = 10n ** 18n;                 // collateral scale, 1e6 on testnet
const TICK = 1_000_000_000_000_000n;    // 1e15 = 0.001 here, 1e3 on testnet
const LOT = TICK;

// Multiplying by ticksPerOne (1000) is small enough that one round absorbs the
// float error; multiplying by 1e18 is what breaks.
const ticks = (p: number) => BigInt(Math.round(p * Number(ONE / TICK))) * TICK;
const lots = (q: number) => BigInt(Math.floor(q * Number(ONE / LOT) + 1e-9)) * LOT;

await exchange.trader.placeOrder({
  pool: onchain.pool,
  side: "BUY_YES",                      // or SELL_YES / BUY_NO / SELL_NO
  price: ticks(0.05),                   // always in YES terms: a NO price is ONE - ticks(p)
  quantity: lots(5),
  orderType: ORDER_TYPE.POST_ONLY,      // LIMIT | MARKET (IOC) | FILL_OR_KILL | POST_ONLY
  expireTimestampNs: BigInt(Math.floor(Date.now() / 1000) + 300) * 1_000_000_000n,
});

The snippets below use the unified verbs for brevity; on an 18-decimal venue route the actual placement through the pattern above.

Take liquidity

Cross the touch with IOC so the remainder never rests behind your back.

ts
const size = quantize(5);
if (size > 0 && bestAsk !== undefined) {
  const order = await exchange.createOrder(yes, "limit", "buy", size, bestAsk + 0.02, {
    timeInForce: "IOC",
  });
  // The unified result has no `receipt` of its own: it wraps the raw tx result
  // in `info`, and that is where the on-chain status lives.
  const { receipt } = order.info as PlaceOrderResult;
  if (receipt.status === "reverted") throw new Error("reverted on-chain");
  console.log(`filled ${order.filled} of ${order.amount}`);
}

Rest a quote

Post-only means the order is rejected instead of crossing, so a quoting loop never pays the spread. A rejected post-only does not revert: the order simply never rests, so check the status.

ts
const bid = await exchange.createOrder(yes, "limit", "buy", size, 0.45, { postOnly: true });
if (bid.status !== "open") console.log("post-only did not rest, the book moved into us");

Every order carries an expiry capped at the market's own. Set it just past your requote interval and a crashed bot's orders age off the book on their own.

Get inventory so you can sell

You can only sell an outcome you hold, and there is no naked short. New tokens come from minting a complete set: collateral in, one Up plus one Down out.

ts
await exchange.mintSet(market.symbol, 10);    // 10 collateral -> 10 Up + 10 Down
// ...later, to unwind an unsold pair back to collateral:
await exchange.burnSet(market.symbol, 10);

You do not need this to quote both sides. Two opposite-side buyers cross with no seller at all (the pool mints the pair from their combined collateral), so a resting Buy Up at p plus a Buy Down at 1 − p is already a two-sided quote with zero inventory.

Manage working orders

ts
const open = await exchange.fetchOpenOrders(yes);
for (const o of open) await exchange.cancelOrder(o.id, yes);

Cancel refunds return to your wallet, in the exact amount that was escrowed, so reconcile there. The per-pool vault is a payout fallback and normally reads 0, though placement draws it first when it does hold something.

Know what actually filled

Treat your own trade history as the source of truth for position, not what you asked for.

ts
const trades = await exchange.fetchMyTrades(yes, since);
const shares = trades.filter((t) => t.side !== "sell").reduce((n, t) => n + t.amount, 0);

Indexer rows land a few seconds after the transaction confirms, so poll with a deadline rather than trusting a single read.

Check your positions

Outcome tokens are ids on one shared ERC-6909 contract, not per-market ERC-20s, so read them by id:

ts
const me = exchange.walletAddress;
if (!me) throw new Error("no signer");
const up = await exchange.client.getOutcomeBalance(onchain.outcomeToken, me, onchain.yesId);
const down = await exchange.client.getOutcomeBalance(onchain.outcomeToken, me, onchain.noId);

Redeem after settlement

This is the step people miss, and loadMarkets() will not help you find it.

A settled market leaves the live list, and the registry sweep behind loadMarkets() skips finalized binary markets outright — so filtering it for inactive rows returns an empty set and a redeem-by-scan bot silently reports nothing to claim while real winnings sit unredeemed.

The binary tier still has them, under the terminal status "Finalized":

ts
// venueId: the venue you scoped your bot to — read it off any of your
// markets' rows (see "Scope to the venue" in Gotchas).
const settled = await exchange.client.listBinaryMarkets({
  venueId,
  status: "Finalized",
  limit: 120,
});
// Check every row you fetched. Any market you drop here is a market you never
// look at, which is the silent-unredeemed-winnings failure this section is
// about — so don't trim the list to a "recent" window. Sorting newest-expired
// first only decides the order you work through them in (the server sorts
// newest-created, which agrees within a series but not across cadences).
const settledMarketIds = settled
  .sort((a, b) => Number(b.expiry ?? 0) - Number(a.expiry ?? 0))
  .map((m) => m.marketId);

limit is a page size, not a safety valve: if you have more finalized markets than you asked for, page until the venue is exhausted, or drive the loop from the market ids your bot recorded when it opened each position. A bot that has been running longer than one page of history needs one of those two.

Then redeem through the trader with an explicit outcome index. The convenience method infers the winner from the market, which is meaningless on a voided market where both sides pay 0.5.

ts
type OutcomeIdx = 0 | 1;
const UP: OutcomeIdx = 0, DOWN: OutcomeIdx = 1;

// marketIds from the query above.
for (const marketId of settledMarketIds) {
  const oc = await exchange.client.getMarketOnchain(marketId as `0x${string}`);
  if (!oc.isResolved && !oc.isVoided) continue;

  const held: Record<OutcomeIdx, bigint> = {
    [UP]: await exchange.client.getOutcomeBalance(oc.outcomeToken, me, oc.yesId),
    [DOWN]: await exchange.client.getOutcomeBalance(oc.outcomeToken, me, oc.noId),
  };

  // Voided: claim both sides at 0.5. Resolved: only the winning side pays.
  const toClaim: OutcomeIdx[] = oc.isVoided ? [UP, DOWN] : [oc.winningOutcome === 0 ? UP : DOWN];

  for (const outcome of toClaim) {
    if (held[outcome] === 0n) continue;
    const res = await exchange.trader.redeem({
      marketId: marketId as `0x${string}`,
      market: oc.marketAddress,
      outcomeToken: oc.outcomeToken,
      outcomeIdx: outcome,
      amount: held[outcome],
    });
    if (res.receipt?.status === "reverted") throw new Error("redeem reverted");
  }
}

Redeeming a losing position does not revert. It succeeds and pays nothing, so check the outcome before you spend gas.

Follow a series as it rolls

Windows expire on a schedule and the venue opens a successor automatically. Key your state by marketId or by symbol, never by pool address, and re-resolve the current window each cycle rather than caching it.

ts
// Every cycle: re-read the market list, pick the live window for your series,
// and start a fresh position count when the symbol changes.
if (currentSymbol !== previousSymbol) resetPositionState();

Where to go next

The full API surface, including realtime watches and the React hooks, is documented in the package README on npm. Types ship with the package, so an editor with TypeScript will autocomplete everything above.

Read the Gotchas before sending a real order.