How to detect when an order fills

This guide shows you how to learn that a resting order was filled, partially filled, or cancelled, with the least latency and without polling. It assumes a configured SomniaMarkets instance with a signer, and a market watch opened before the order was placed.

Read the placement result first

A createOrder that crosses the book fills in the same transaction. Its result already says so.

ts
const order = await exchange.createOrder("SOMI/USDso", "limit", "buy", 10, 0.116);
if (order.status === "closed") {
  // fully filled in the placement transaction
} else if (order.status === "open") {
  // resting: order.filled may still be > 0 for a partial fill
}

status is "closed" when remaining is zero, "open" when a remainder rests, and "canceled" when an immediate-or-cancel or market order left an unfillable remainder. The raw fills are in order.info.fills.

Wait for the status to flip

watchOrders(symbol) returns your orders on the market from the live store. The first call returns the current list; each later call resolves when the list changes. Loop until your order is no longer open.

ts
let mine = order;
while (mine.status === "open") {
  const orders = await exchange.watchOrders("SOMI/USDso");
  mine = orders.find((o) => o.id === order.id) ?? mine;
}
console.log(mine.status, "filled", mine.filled, "of", mine.amount);

The list changes on every order event for your account on that market, including partial fills, so the loop also observes filled growing while status stays "open".

Open the market watch before placing. watchOrderBook, watchTrades, or watchOrders on the symbol all open it. The watch learns about an order placed after it opened from the chain event, in the block the order lands. It learns about an order placed before it opened from the indexer snapshot, which lags the chain by the indexing delay, typically a few seconds.

Watch your trades

watchMyTrades(symbol) returns your fills on the market, newest first, and resolves on each new one. Use it when you care about executions rather than order state, for example to update inventory.

ts
let seen = new Set<string>();
while (true) {
  const trades = await exchange.watchMyTrades("SOMI/USDso", 20);
  for (const t of trades) {
    if (seen.has(t.id)) continue;
    seen.add(t.id);
    console.log(t.datetime, t.side, t.amount, "@", t.price, "cost", t.cost);
  }
}

On spot and perp markets side is absent: the pools do not attribute a fill to a side in a way the SDK can map to you. Read t.info.takerIsBid and compare t.info.maker with your address instead. On binary markets side is your own side when the fill carries one.

Use the engine for raw values

exchange.client.getLiveUserOrders(pool, owner) and getLiveUserFills(pool, owner) are the synchronous views behind the two verbs. They return LiveOrder and LiveFill rows with exact raw values as decimal strings; wrap a value in BigInt() to compute with it. The API reference lists the fields. Subscribe to changes with client.subscribeLive(listener).

In React, useLiveUserOrders and useLiveUserFills re-render on the same changes. See Use the React hooks.

Read your own writes at chain head

When a write threw RpcError after sending, or when no watch is open, ask the pool directly. These reads answer from the contract in one round-trip and know only what is open now.

ts
const t = exchange.market("SOMI/USDso");
const owner = exchange.walletAddress;
if (!owner) throw new Error("configure a signer before reading your open orders");
const openIds = await exchange.client.getOwnOpenOrdersOnchain(t.pool, owner);
const stillOpen = openIds.some((id) => id.toString() === order.id);

getOrderOnchain(pool, orderId) returns one order's on-chain state. For history, including filled and cancelled orders, use the indexer reads getOpenOrders, getOrders, and getOrderFills; they lag the chain by the indexing delay.

Read a maker order a fill already removed

Decoding OrderFilled yourself gives you the fill's fillPrice, quantityFilled and makerRemainingQuantity, but names the maker only by id - and a fill removes the maker order in the same transaction, so at head it is gone. Pin the read one block earlier to recover the side the fill took:

ts
const maker = await exchange.client.getOrderOnchain(pool, makerOrderId, { blockNumber: fillBlock - 1n });
// `null` is an UNRESOLVED side, not a sell: an order placed and filled inside one
// block has no state one block earlier, and its side is in that block's OrderPlaced.
const takerBought = maker === null ? null : !maker.isBid;

What it answers, and what it does not:

  • The maker's identity - isBid, owner, userData, expireTimestampNs - cannot change under a given id, so the pinned read is exact for it, and no fill log carries it: OrderFilled names the maker by id alone. OrderPlaced carries it, but only a consumer that was already listening when the maker rested has that log, which is the case this read exists for.
  • Not the price. It is immutable too, but a fill executes at the maker's resting price and the book emits exactly that, so OrderFilled.fillPrice already is the maker's price. Reading for it is redundant work.
  • Both quantities are the values at the block boundary, not at your fill. quantityRemaining moves on every fill, and reduceOrder decrements fullQuantity and quantityRemaining together under the same id - so an earlier transaction in the fill's own block makes either value stale for your fill, and no block-level read can see between transactions. Take quantityFilled and makerRemainingQuantity from the event instead.

Two edges answer plausibly rather than failing:

  • A partial fill leaves the maker order in place with a smaller quantityRemaining, so reading at the fill's own block succeeds with different numbers instead of returning null.
  • An order placed and filled inside one block does not exist at fillBlock - 1n. That read is null, and the placement is in the same block's OrderPlaced.

A recent block answers against a full node. An older one needs archive state, so this suits a live tape rather than a backfill.

Choose by need

NeedUseLatency
Did the placement itself fillcreateOrder resultnone
Did a resting order changewatchOrdersnext block
What executed, for inventorywatchMyTradesnext block
Is it still open, after a transport errorgetOwnOpenOrdersOnchainone round-trip
Full history for a reportgetOrders, getOrderFillsindexing delay