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 reverts. A placement whose expireTimestampNs is already behind the chain clock fails with OrderAlreadyExpired. Earlier protocol versions accepted it silently — the pool skipped the placement and returned no order id, so the transaction still succeeded — but it now rejects outright. The batch verb differs: placeSpotOrders rejects the offending request on its own rather than taking the whole batch down, so there you check outcomes[i].success — an already-expired expiry is one of the benign non-placements it reports.

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

Amending a quote set

Re-pricing a ladder one order at a time costs two transactions per rung and leaves the book briefly one-sided. amendOrders cancels each old order and places its replacement in a single transaction:

ts
const { newOrderIds } = await trader.amendOrders({
  pool: spot.poolAddress,
  amendments: [
    { oldOrderId: bid1, newOrder: { isBid: true, price: newBid1, quantity: qty } },
    { oldOrderId: bid2, newOrder: { isBid: true, price: newBid2, quantity: qty }, alwaysPlace: true },
  ],
});

It is all-or-nothing — any bad request reverts the whole batch, so the book never sees a partial re-quote. newOrderIds is index-aligned with amendments.

alwaysPlace handles the race where the order you meant to amend already filled or was cancelled. False (the default) reverts AmendOldOrderGone; true skips the cancel leg and places the replacement anyway. It never tolerates an ownership failure — a live order owned by someone else still reverts.

For ONE order, use amendOrder rather than a one-element batch:

ts
const { newOrderId } = await trader.amendOrder({
  pool: spot.poolAddress,
  oldOrderId: bid1,
  newOrder: { isBid: true, price: newBid1, quantity: qty },
});

The difference is the error you get back. The singular raises the replacement's own landing-time reason — PostOnlyWouldCross, FillOrKillNotFillable and friends — where the batch wraps it as AmendReplacementRejected(requestIndex, reason). With one order that index tells you nothing you did not already know, and you have to unwrap it to find the reason. Everything else matches: same alwaysPlace race rule, same lost queue priority, same non-payable funding constraint.

Amend re-inserts at the back of the price-time queue. To shrink an order without losing queue priority, use reduceOrder instead.

Three things to know before re-laddering with it:

  • Replacements are not shielded from each other. Cancelling all the old orders first protects a replacement from the order it replaces — but not from the other replacements in the same batch. If a new bid crosses a new ask, the default selfMatchingOption (CANCEL_TAKER) rejects it, and because amend is all-or-nothing the whole re-ladder reverts. Keep the new set uncrossed.
  • The revert names the rung. A rejected replacement reverts AmendReplacementRejected(requestIndex, reason)requestIndex is the position in your amendments array and reason is the OrderRejectionReason. That is the signal to branch on; AmendOldOrderGone is a different, earlier failure from the cancel leg.
  • Approve the escrow first. Unlike placeSpotOrder, amendOrders does not auto-approve. On an auto-pull pool the cancel leg returns the freed tokens to your wallet and the place leg pulls them back, which needs an allowance — so a trader whose first call is amendOrders hits ERC20InsufficientAllowance. Place once (or approve manually) before amending.

Not for BinaryPools — amend places, and binary pools reject generic placement with UseBinaryPlacement. Spot and perp only. Note that error is what you hit on a live binary market; a locked one reverts TradingNotActive and a malformed replacement reverts on validation first, so don't branch on UseBinaryPlacement alone to detect the wrong pool kind.

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.