Spot markets

A spot market is a plain base/quote order book on a SpotPool (e.g. SOMI/USDC) — same OrderBook core as the binary pools, so the live machinery is identical; only the semantics differ. This guide covers reading and trading spot; the shared client mechanics (watches, read tiers, signers) are in the engine guide.

The mental model

  • Prices are quote-per-base. Raw quote units per whole base token, scaled by the market's own quoteDecimals/baseDecimals (spot markets are NOT assumed 6dp — read the decimals off the SpotMarket row).
  • Two sides. isBid: true buys base (escrows quote); false sells base (escrows base — or sends native SOMI as msg.value when baseIsNative).
  • Book constraints. Orders must respect the pool's tickSize, lotSize, and minQuantity (all on the SpotMarket row, kept live by the watch).
  • Mark price. Pools publish a smoothed markPrice (streamed live via MarkPriceUpdated) — it's what stop orders trigger on, distinct from lastPrice (last fill).

Reading

Discover markets first (indexer tier) — listSpotMarkets returns the board and getSpotMarket resolves one by id; both yield the SpotMarket rows the live reads key off:

ts
const markets = await client.listSpotMarkets({ limit: 50 }); // SpotMarket[]; filterable (base/quote/…)
const spot    = await client.getSpotMarket(id);              // SpotMarket | null
ts
const watch = await client.watchMarket(spot.poolAddress);

const book = client.getLiveSpotOrderBook(spot.poolAddress, { depth: 11 }); // { bids, asks }, best first
const tape = client.getLiveFills(spot.poolAddress, { limit: 40 });
const live = client.getLiveMarketByPool(spot.poolAddress);      // markPrice, tick/lot, stats

React: useLiveSpotOrderBook(pool), useLiveFills(pool), useLiveMarketByPool(pool) — all auto-watch while mounted. History and wallet views come from the indexer tier: getCandles(pool, interval), getSpotPortfolio(account) (open orders + pending stops + trades), and getSpotStopOrders(account, { pool }). Holdings are plain balances — read them on-chain with getErc20Balance / getNativeBalance, not from the indexer.

Trading

ts
const trader = client.createTrader({ privateKey });

// Rest a limit bid: buy 5 base at 1.25 quote.
const { orderId, fills } = await trader.placeSpotOrder({
  pool: spot.poolAddress,
  isBid: true,
  price: parseUnits("1.25", spot.quoteDecimals),
  quantity: parseUnits("5", spot.baseDecimals),
  baseDecimals: spot.baseDecimals,
  quoteToken: spot.quoteToken,
  baseToken: spot.baseToken,
  baseIsNative: spot.baseIsNative,
});

await trader.cancelOrder({ pool: spot.poolAddress, orderId }); // same core as binary

Expiry and builder attribution

placeSpotOrder takes three optional fields; each one defaults to today's behaviour, so an existing call is unaffected:

ts
await trader.placeSpotOrder({
  ...order,
  expireTimestampNs: BigInt(Date.now() + 86_400_000) * 1_000_000n, // default: ~50y (GTC)
  builder: "0xYourFrontend",     // default: the zero address (no attribution)
  builderFeeBpsTimes1k: 25_000n, // default: 0
});

The ceiling. A non-zero builderFeeBpsTimes1k must stay within getMaxBuilderFeeBpsTimes1k(pool). Read that cap rather than assuming it: it is owner-updatable on a SpotPool, and while it is 0 the pool rejects builder codes outright, so the rail is off on that venue.

The approval. A non-zero fee also needs a prior trader.approveBuilder({ pool, builder, maxFeeBpsTimes1k }). Spot pools implement the same builder calls as binary ones, but the approval is stored per pool — approving a builder on one pool grants nothing on another, and an unapproved placement reverts BuilderNotApproved.

A past expiry places nothing, silently. The pool skips a placement whose expireTimestampNs is already behind the chain clock and returns no order id — the transaction still succeeds. Check the returned orderId rather than assuming the tx receipt means the order rested.

Expiry does not refund by itself. When a spot order lapses its escrow stays locked in the pool until someone sweeps it — trader.cancelExpiredOrders({ pool, orderIds }) reclaims it, and is callable by anyone, not only the owner. Set an expiry deliberately.

Escrow is approved automatically (quote on buys, base on non-native sells; native-base sells pay via msg.value instead). A market order is orderType: ORDER_TYPE.MARKET with a crossing price — take the live book's best opposite level ± slippage, tick-aligned, so it sweeps and the remainder cancels:

ts
const best = client.getLiveSpotOrderBook(pool, { depth: 1 });   // zero RTT, last-block fresh
const crossing = (best.asks[0].price * 10100n) / 10000n; // +1% slippage bound

Batches — place a ladder, pull a ladder

Market making means many orders at once. placeSpotOrders, cancelOrders and reduceOrders each do a whole ladder in ONE transaction instead of a loop of sends:

ts
// Place a three-rung sell ladder.
const placed = await trader.placeSpotOrders({
  pool: spot.poolAddress,
  baseDecimals: spot.baseDecimals,
  quoteToken: spot.quoteToken,
  baseToken: spot.baseToken,
  orders: [1.01, 1.02, 1.03].map((p) => ({
    isBid: false,
    price: parseUnits(String(p), spot.quoteDecimals),
    quantity: parseUnits("1", spot.baseDecimals),
  })),
});

// outcomes is index-aligned with `orders`; a rung that did not place is
// success:false (e.g. a PostOnly that would have crossed), NOT an error.
const ids = placed.outcomes.flatMap((o) => (o.success ? [o.orderId!] : []));

// Pull what is left. Best-effort: an id that filled meanwhile is skipped, so
// the other rungs still come off the book.
const pulled = await trader.cancelOrders({ pool: spot.poolAddress, orderIds: ids });
const skipped = pulled.outcomes.filter((o) => !o.cancelled).map((o) => o.orderId);

Three things to know before using them:

  • They are non-payable. Unlike placeSpotOrder, a batch sends no msg.value, so a native-base sell funds from the pool's vault balance — pre-deposit native to the vault first. ERC-20 auto-pull works normally, and the batch approves each escrow token once for the whole batch's total.
  • placeSpotOrders is spot-only. Binary pools reject generic placement with UseBinaryPlacement (the YES/NO kind must be explicit) — use placeOrder there. cancelOrders and reduceOrders are inherited from the shared order book, so they work on binary pools too.
  • Cancel is best-effort, reduce is atomic. A stale id in cancelOrders is skipped; a single invalid reduction in reduceOrders reverts the whole batch. A cancel false says the id emitted no event — it does not say why, so a benign fill race and a wrong id look the same.
  • Tag rungs with userData if exact attribution matters. Outcomes are matched to requests on every field the OrderPlaced event echoes (side, price, quantity, userData, expiry). Two byte-identical adjacent rungs with different outcomes are indistinguishable from logs — the earlier index gets the credit; a distinct userData per rung removes the ambiguity.

Stop orders

Spot pools with a stopRegistry support stop-loss / take-profit orders that rest OFF the book and fire when the mark price crosses the trigger:

ts
await trader.placeSpotStopOrder({
  registry: spot.stopRegistry,
  pool: spot.poolAddress,
  isBid: false,                       // sell when the market drops…
  quantity: parseUnits("5", spot.baseDecimals),
  triggerPrice: parseUnits("1.10", spot.quoteDecimals),
  triggerOperator: 1,                 // 1 = LTE (mark ≤ trigger), 0 = GTE
  stopOrderType: 1,                   // 1 = MARKET at trigger, 0 = LIMIT (needs limitPrice)
  quoteToken: spot.quoteToken,
  baseToken: spot.baseToken,
  baseIsNative: spot.baseIsNative,
});

await trader.cancelStopOrder({ registry: spot.stopRegistry, orderId });

Under the hood the first stop order per account performs a one-time operator approval (so the registry may place the triggered order for you), funds the trigger gas with a small SOMI payment (msg.value, refunded on cancel), and ensures the pool can pull the escrow at trigger time — including pre-loading the pool vault for native-base sells. The SDK handles all of it; list pending stops with getSpotStopOrders(account, { pool }) and stream their market context via the watch.