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 theSpotMarketrow). - Two sides.
isBid: truebuys base (escrows quote);falsesells base (escrows base — or sends native SOMI asmsg.valuewhenbaseIsNative). - Book constraints. Orders must respect the pool's
tickSize,lotSize, andminQuantity(all on theSpotMarketrow, kept live by the watch). - Mark price. Pools publish a smoothed
markPrice(streamed live viaMarkPriceUpdated) — it's what stop orders trigger on, distinct fromlastPrice(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:
const markets = await client.listSpotMarkets({ limit: 50 }); // SpotMarket[]; filterable (base/quote/…)
const spot = await client.getSpotMarket(id); // SpotMarket | null
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
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:
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
expireTimestampNsis already behind the chain clock and returns no order id — the transaction still succeeds. Check the returnedorderIdrather 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:
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:
// 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 nomsg.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. placeSpotOrdersis spot-only. Binary pools reject generic placement withUseBinaryPlacement(the YES/NO kind must be explicit) — useplaceOrderthere.cancelOrdersandreduceOrdersare inherited from the shared order book, so they work on binary pools too.- Cancel is best-effort, reduce is atomic. A stale id in
cancelOrdersis skipped; a single invalid reduction inreduceOrdersreverts the whole batch. A cancelfalsesays 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
userDataif exact attribution matters. Outcomes are matched to requests on every field theOrderPlacedevent 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 distinctuserDataper 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:
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.