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). ERC-20 approval checks userequiredAmount, which is the pool's worst-case reserve including fee headroom. A native sell sendsdelta, which subtracts the owner's current vault balance from that reserve. Sending only the bare quantity can revert withInvalidMsgValueon a fee-bearing pool. - 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 + the last seven days of
trades by default — tradesSince says which; pass since to widen), 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),
quoteToken: spot.quoteToken,
baseToken: spot.baseToken,
baseIsNative: spot.baseIsNative,
});
await trader.cancelOrder({ pool: spot.poolAddress, orderId }); // same core as binary
Per-order fields: expiry, attribution, self-match, user data
placeSpotOrder takes these per-order fields as optional inputs. Each one has
an SDK default that keeps 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
selfMatchingOption: SELF_MATCHING_OPTION.CANCEL_MAKER, // default: CANCEL_TAKER
userData: 7n, // default: 0 — an opaque tag, never interpreted
});
placeSpotOrders, placeOrder (binary), placePerpOrder, amendOrder and
amendOrders take the same selfMatchingOption, and the batch verbs take it
per request.
Self-match. A pool matches your incoming order against your own resting
order, and selfMatchingOption says which side loses. CANCEL_TAKER (0) drops
the rest of the incoming order and leaves the resting one on the book;
CANCEL_MAKER (1) cancels the whole resting order and lets the incoming one
continue. The 0 is the SDK's default, not the pool's — the pool reads the
value out of every order request and has no default of its own, so a raw
integration must pass it. Reach for CANCEL_MAKER when the new quote matters
more than the old one, such as re-pricing a ladder into your own resting rungs;
keep CANCEL_TAKER when a self-cross means you made a mistake and want the
order rejected. In a batch a CANCEL_TAKER self-match is one of the benign
non-placements: the rung reports outcomes[i].success === false rather than
taking the batch down.
The ceiling. A non-zero builderFeeBpsTimes1k must stay within
trader.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,
CANCEL_TAKER(the SDK's default when you omitselfMatchingOption) rejects it, and because amend is all-or-nothing the whole re-ladder reverts. Keep the new set uncrossed, or setselfMatchingOptionper replacement. - The revert names the rung. A rejected replacement reverts
AmendReplacementRejected(requestIndex, reason)—requestIndexis the position in youramendmentsarray andreasonis the pool'suint8rejection code (the SDK does not export a name table for it). 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,
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. - Every per-order field is per rung.
orderType,expireTimestampNs,selfMatchingOption,userData,builderandbuilderFeeBpsTimes1kare read off each request, so a ladder can attribute one rung to a builder and leave the next unattributed. A rung that omits a field gets the same default the single verb applies. - 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.