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 reverts. A placement whose
expireTimestampNsis already behind the chain clock fails withOrderAlreadyExpired. 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:placeSpotOrdersrejects the offending request on its own rather than taking the whole batch down, so there you checkoutcomes[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:
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:
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:
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)—requestIndexis the position in youramendmentsarray andreasonis theOrderRejectionReason. That is the signal to branch on;AmendOldOrderGoneis a different, earlier failure from the cancel leg. - Approve the escrow first. Unlike
placeSpotOrder,amendOrdersdoes 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 isamendOrdershitsERC20InsufficientAllowance. 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:
// 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.