Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Placing Orders

bz.trade.createOrder builds every kind of order Bazaar has: Market, Limit, StopLimit, TakeProfit and StopLoss. It checks the order the way the pair will (so the user sees a clear error before signing), finds the order’s place in the book, attaches a fresh Pyth price, and returns a PreparedCall to simulate and send.

This page shows each order type with a sample, then every option: time in force, post-only, slippage, expiry, keeper bounties, the integrator fee and book hints, and the pure helpers a form uses to validate as the user types. The rules themselves are the protocol’s; this page states what the SDK checks and links Orders for the full rules.

The samples assume bz connected as in Connecting with a price source, user the wallet’s address, eth a market, block the current block number, and const { TimeInForce, blocksIn, CLOSE_POSITION } = orders (the orders namespace of @liberta-labs/bazaar-sdk). Sending is as on Sending Transactions.

How createOrder Works

bz.trade.createOrder(pair, from, order, opts?):

  1. Checks the order’s shape (buildOrderSpec without any reads) and that pair is one of the deployment’s pairs. Either failure throws InputError naming the field.
  2. In parallel: fetches a fresh price update for the pair, reads the current block, the account’s position and order slots (readOrderState), and, for an order that can rest, the order it should rest behind (its hint).
  3. Checks the order again against what it read: the Limit placement band around the price, the $5 minimum, the expiry against the current block, the TP/SL rules and the per-account caps.
  4. Returns the call: summary is describeOrder(spec), priceTime the update’s publish time, value its Pyth fee, and pullsUsdc the keeper bounty a trigger order takes from the wallet (0n for the other types).

On chain the order is matched against the book in the same transaction: it fills whatever crosses, at each resting maker’s price, then rests the remainder (a Limit GTC, or an armed StopLimit) or cancels it (Market, IOC, a post-only order that would cross, a self-match, the walk budget, a dust remainder). A fill-or-kill order reverts unless it fills completely. A trigger order waits outside the book for its trigger, or arms in the same transaction if the price already meets it.

createOrder needs a price source on the connection: without one it throws ConnectError (“this call needs a fresh Pyth price”). Because the call carries a price update, send it within seconds of building it; see The 3-Second Price Window.

Option (opts)Meaning
hintsMatchHints to use instead of the ones the SDK finds. See Book Hints.
maxStepsThe arrival walk’s budget: how many resting orders it may visit (1 to 32; 0, the default, is the protocol default of 32). Ignored when hints is given (hints carry their own maxSteps).
permitDataA USDC permit covering the trigger bounty, executed before the pull. See Keeper Bounties.

The Order

order is an OrderInput. Fields a type does not use are ignored for it, and written as zero in the signed spec.

FieldTypesMeaning
orderTypeallOrderType.Market, .Limit, .StopLimit, .TakeProfit or .StopLoss (0 to 4).
isLongalltrue buys (goes long or reduces a short), false sells.
sizeallBase units, BAZAAR precision (parseUnits18("0.5") is half a unit). For a TakeProfit or StopLoss, CLOSE_POSITION closes the whole position.
limitPriceLimit, StopLimitThe price the order fills at or better, BAZAAR precision.
triggerPriceStopLimit, TakeProfit, StopLossThe oracle price that fires the order (the price alone; the bounty is its own field).
bountyStopLimit, TakeProfit, StopLossWhat the order pays whoever fires it, BAZAAR precision, a whole number of micro-USDC. Default 0.
maxSlippageBpMarket, TakeProfit, StopLossThe worst fill allowed, in basis points of the oracle price: 1 to 1000 (0.01% to 10%). Required for these types.
tifLimit, MarketTimeInForce.GTC (default), .IOC or .FOK.
isPostOnlyLimit GTCCancel instead of taking liquidity. Default false.
expirationBlockLimit GTC, StopLimitThe absolute L2 block the order expires after. Required for these.

Prices and sizes are BAZAAR precision: 1e18 is one unit of the base asset, or $1. See Units & Constants.

Market Orders

A Market order fills immediately against the book, at each maker’s price, within maxSlippageBp of the oracle price; what cannot fill within that bound is canceled. It never rests.

// Buy 0.5 ETH now, filling no worse than 0.5% above the oracle price; what cannot fill is canceled.
const market = await bz.trade.createOrder(eth.address, user, {
  orderType: OrderType.Market,
  isLong: true,
  size: parseUnits18("0.5"),
  maxSlippageBp: 50n,
});

With tif: TimeInForce.FOK it is all or nothing: the transaction reverts (MatchingEngineLib__FillOrKillUnfilled) unless the whole size fills, and the gas is spent either way.

// All or nothing: the transaction reverts unless the whole size fills on arrival.
const marketFok = await bz.trade.createOrder(eth.address, user, {
  orderType: OrderType.Market,
  isLong: false,
  size: parseUnits18("2"),
  maxSlippageBp: 100n,
  tif: TimeInForce.FOK,
});

The pair refuses Market orders while a market with trading hours has its venue closed (it would have only a stale price to bound them by); Limit orders still work then. The market snapshot’s marketOrdersDisabled flag says so, with a reason (Markets).

Limit Orders

A Limit order fills what crosses at its limit price or better, then rests the remainder in the book until it fills, is canceled, or expires.

// Rest a bid at 2950 for a day; whatever crosses on arrival fills first.
const limit = await bz.trade.createOrder(eth.address, user, {
  orderType: OrderType.Limit,
  isLong: true,
  size: parseUnits18("1"),
  limitPrice: parseUnits18("2950"),
  expirationBlock: block + blocksIn(24 * 60 * 60),
});

Its limit price must sit within ±50% of the oracle price (MAX_PLACEMENT_DEVIATION_BP): the builder checks it against the live price and names the allowed range. A resting Limit takes one of the account’s 32 order slots.

With tif: TimeInForce.IOC it takes what is available now and cancels the rest; it never rests, needs no expiry, and takes no slot:

// Take what is available at 3010 or better now; cancel the rest. No expiry: it never rests.
const ioc = await bz.trade.createOrder(eth.address, user, {
  orderType: OrderType.Limit,
  isLong: true,
  size: parseUnits18("1"),
  limitPrice: parseUnits18("3010"),
  tif: TimeInForce.IOC,
});

TimeInForce.FOK on a Limit fills the whole size at the limit or better, or reverts.

Post-Only

isPostOnly: true makes a Limit GTC a maker-only order: if any part of it would cross the book on arrival, it is canceled (cancel reason PostOnly) instead of filling. Only Limit orders take it, and only with GTC.

// A maker-only ask: canceled (not filled) if it would cross the book on arrival.
const postOnly = await bz.trade.createOrder(eth.address, user, {
  orderType: OrderType.Limit,
  isLong: false,
  size: parseUnits18("1"),
  limitPrice: parseUnits18("3100"),
  isPostOnly: true,
  expirationBlock: block + blocksIn(60 * 60),
});

Stop-Limit Orders

A StopLimit waits outside the book until the oracle price reaches triggerPrice, then becomes a Limit order at limitPrice: it fills what crosses and rests the remainder. A buy’s limit must be at or above its trigger, a sell’s at or below (otherwise it could never fill there). It carries an expiry and takes an order slot from creation, and may carry a keeper bounty.

// Once the oracle price reaches 3200, rest a buy at up to 3210 (a breakout entry). It pays a keeper
// 0.25 USDC to fire it, pulled from the wallet now.
const stopLimit = await bz.trade.createOrder(eth.address, user, {
  orderType: OrderType.StopLimit,
  isLong: true,
  size: parseUnits18("1"),
  triggerPrice: parseUnits18("3200"),
  limitPrice: parseUnits18("3210"),
  bounty: parseUnits18("0.25"),
  expirationBlock: block + blocksIn(7 * 24 * 60 * 60),
});
stopLimit.pullsUsdc; // 250000000000000000n: the bounty, in BAZAAR precision

A StopLimit that fires with less than the minimum lifetime left does not rest; give it a long enough expiry to rest when it fires.

Take-Profit and Stop-Loss Orders

A TakeProfit fires when the price moves in the position’s favor, a StopLoss when it moves against it. On firing either executes market-style within maxSlippageBp of the oracle price; it never rests in the book, and a part the book cannot fill stays live outside the book for the next execution.

PositionOrder sideStopLoss fires whenTakeProfit fires when
Longsell (isLong: false)price ≤ triggerprice ≥ trigger
Shortbuy (isLong: true)price ≥ triggerprice ≤ trigger
// For a long position: sell the whole position when the price reaches 3600, at most 1% below it.
const takeProfit = await bz.trade.createOrder(eth.address, user, {
  orderType: OrderType.TakeProfit,
  isLong: false, // the opposite side of the position
  size: CLOSE_POSITION, // whatever the position is when it fires
  triggerPrice: parseUnits18("3600"),
  maxSlippageBp: 100n,
  bounty: parseUnits18("0.5"),
});
// For the same long: sell it all if the price falls to 2700, with up to 2% slippage. No bounty:
// this user fires it themselves (see Managing Orders).
const stopLoss = await bz.trade.createOrder(eth.address, user, {
  orderType: OrderType.StopLoss,
  isLong: false,
  size: CLOSE_POSITION,
  triggerPrice: parseUnits18("2700"),
  maxSlippageBp: 200n,
});

What the SDK checks for them:

  • They need an open position, and must be on its opposite side. Otherwise InputError on orderType (“… need an open position”) or on isLong.
  • One of each per position. A second TakeProfit or StopLoss is refused (checkOrderCaps); cancel the existing one first.
  • CLOSE_POSITION closes the whole position, whatever it is when the order fires. The size is capped at the live position when the order fires, so any size at or above it closes exactly the position and never opens the other side. A position that later shrinks leaves the order armed for what remains; one that goes flat or flips cancels it (cancel reason PositionClosed). The $5 minimum is measured on what the order could close, so a full close is always allowed.
  • They never expire (expirationBlock is ignored and stamped NEVER_EXPIRE_BLOCK), take no order slot, and skip the creation margin check: a protective order is never refused for margin.

describeOrder prints a CLOSE_POSITION size as “the whole position”.

Time in Force

tifWhat happens to the size the book cannot fill on arrivalAccepted on
TimeInForce.GTC (0, default)Rests in the book.Every type; the types that never rest ignore it.
TimeInForce.IOC (1)Canceled; the partial fill stands.Limit.
TimeInForce.FOK (2)The transaction reverts: no fill, no order.Limit and Market.

Post-only cannot be combined with IOC or FOK. orders.TIF_NAMES maps the values to “GTC”, “IOC” and “FOK”.

Slippage

maxSlippageBp bounds how far from the oracle price a Market, TakeProfit or StopLoss may fill: a buy fills at most at oracle × (1 + bp/10000), a sell at least at oracle × (1 − bp/10000). It must be 1 to 1000 (orders.MAX_SLIPPAGE_BP, 10%); anything else is InputError on maxSlippageBp (“Set a slippage limit between 0.01% and 10%.”). Limit and StopLimit orders are bounded by their limit price instead and ignore it.

Expiry Blocks

Orders that can rest (Limit GTC and StopLimit) expire after an absolute Arbitrum L2 block. Arbitrum produces about four L2 blocks a second (orders.L2_BLOCKS_PER_SECOND), and orders.blocksIn(seconds) converts a duration:

const expirationBlock = (await bz.ctx.chain.blockNumber()) + orders.blocksIn(24 * 60 * 60); // about a day
ConstantValueMeaning
MIN_ORDER_LIFETIME_BLOCKS240The shortest life the pair accepts (~1 minute).
EXPIRY_LANDING_MARGIN_BLOCKS40Slack the SDK adds on top: the pair measures the lifetime from the block the transaction lands in.
MAX_ORDER_LIFETIME_BLOCKS126,144,000~1 year. A later expiry is clamped to it by the pair.
NEVER_EXPIRE_BLOCK2^64 − 1What the pair stamps on TakeProfit and StopLoss orders.

So the builder refuses an expiry before currentBlock + 280 (“An order must stay open for at least about a minute.”). Market, IOC and FOK orders end within their own transaction and get expiry 0. bz.orders.readOrderLifetimeConstants() reads the lifetime constants from the lens, to confirm the SDK’s copies. Amending an order keeps its expiry, and an order with less than about a minute left cannot be moved (Managing Orders).

Keeper Bounties

A trigger order (StopLimit, TakeProfit, StopLoss) waits until someone fires it with a fresh price through executeTriggeredOrders. Keepers do that for the bounty the order carries.

  • Funding. The bounty is pulled from the wallet (not collateral) when the order is created, so createOrder reports it in pullsUsdc, and the wallet needs that much USDC allowed to the pair, or a permit in opts.permitData:
// A trigger order's bounty comes from the wallet, so it needs an allowance or a permit.
const funding = await bz.allowance.usdcFunding(user, eth.address, takeProfit.pullsUsdc ?? 0n);
let tpCall = takeProfit;
if (!funding.enoughAllowance) {
  const permit = await bz.permits.signPermit(window.ethereum, {
    owner: user,
    spender: eth.address,
    value: permits.permitValueFor(takeProfit.pullsUsdc ?? 0n),
  });
  tpCall = await bz.trade.createOrder(
    eth.address,
    user,
    { orderType: OrderType.TakeProfit, isLong: false, size: CLOSE_POSITION, triggerPrice: parseUnits18("3600"), maxSlippageBp: 100n, bounty: parseUnits18("0.5") },
    { permitData: permit.permitData },
  );
}
  • Units. BAZAAR precision and a whole number of micro-USDC (a multiple of 1e12); otherwise InputError on bounty (“The bounty must be a whole amount of USDC (at most 6 decimals).”). Only trigger types may carry one.
  • Packing. In the signed spec the bounty rides in the high 128 bits of triggerPrice: orders.packTriggerPrice(price, bounty) is (bounty << 128) | price, orders.unpackTriggerPrice(word) splits it, and orders.specBounty(spec) reads it from a spec. buildOrderSpec packs it for you.
  • Escrow. Once created, the bounty sits in the pair’s fee escrow, not in the order: an order read back from the pair (orders(id), OrderUpdated) carries only the trigger price. bz.orders.readTriggerBounty(pair, orderId) returns what the escrow still holds. Each execution pays its executor the share of the bounty it filled; what is left when the order is canceled, expires or is auto-canceled returns to the owner’s claimable balance, collected with claimFees.
  • Firing your own. A bounty of 0 means the owner fires the order: build executeTriggeredOrders when the price reaches the trigger. An execution of your own order pays its bounty share to you, and so does the creation itself when a fresh price already meets the trigger.

How keepers choose which orders to fire is on The Jobs.

The Integrator Fee

An order can name an integrator address, which earns a referral fee of 0.25 bp of each fill’s notional from the side that carries it (both sides when both orders carry it), less the 1% bug-bounty skim. The SDK writes the connection’s integrator into every order it builds:

const bz = await Bazaar.connect({
  transport: window.ethereum,
  factory: FACTORY,
  prices: { token: pythToken },
  integrator: MY_FRONTEND, // every order built through bz names it, and it earns the referral fee
});

It is part of the signed spec and cannot change after creation. The fees accrue to the integrator’s claimable balance on each pair, withdrawn with claimFees from the integrator’s address. Without an integrator on the connection, orders carry the zero address and pay no integrator fee. The fee is a surcharge on your users’ fills, so it is your decision; see Integrators and Fees.

Book Hints

A resting order is inserted into a sorted book. Rather than walk the book on chain, the call carries MatchHints: pred, the order the new one should rest directly behind (0 = the head of its side), and fallbacks pred2 to pred4 tried when earlier ones have left the book, plus maxSteps, the arrival walk’s budget. Every hint is verified against the real neighbors, so a wrong one can fail a placement but never misplace an order. Hints are outside every signature.

createOrder finds them for you (bz.trade.matchHintsFor(pair, order, maxSteps?)): for an order that can rest, the predecessor at its limit price on its side (bz.trade.findPredecessor(pair, isLong, limitPrice), which follows the lens’s findPredecessor through up to 8 pages of 64 orders); for one that cannot, NO_HINTS. Pass your own when you have them:

// createOrder finds the insertion hint itself. To bound the arrival walk, pass maxSteps; to reuse
// hints you computed (several orders against one book read), pass hints.
const shortWalk = await bz.trade.createOrder(
  eth.address,
  user,
  { orderType: OrderType.Limit, isLong: true, size: parseUnits18("3"), limitPrice: parseUnits18("3005"), expirationBlock: block + blocksIn(3600) },
  { maxSteps: 8 }, // visit at most 8 resting orders on arrival, then rest or cancel the remainder
);

const pred = await bz.trade.findPredecessor(eth.address, true, parseUnits18("2950"));
const hinted = await bz.trade.createOrder(
  eth.address,
  user,
  { orderType: OrderType.Limit, isLong: true, size: parseUnits18("1"), limitPrice: parseUnits18("2950"), expirationBlock: block + blocksIn(3600) },
  { hints: { ...orders.NO_HINTS, pred } },
);

Hints go stale as the book changes, so compute them right before sending, as createOrder does. A relayed order always walks the protocol default, since only its signer may bound how much of it executes.

Validating as the User Types

orders.buildOrderSpec(input, integrator, env?) is pure: it checks an OrderInput the way the pair’s createOrder will and returns the OrderSpec to send or sign, or throws InputError naming the field. Run it on every change in an order form:

/** The form's fields as the user typed them. */
interface OrderForm {
  side: "buy" | "sell";
  size: string;
  limitPrice: string;
  hours: string;
}

/** Field -> message, or {} when the order is fine. Pure, so it runs on every keystroke. */
function validate(form: OrderForm, env: orders.OrderEnvironment): Record<string, string> {
  try {
    orders.buildOrderSpec(
      {
        orderType: OrderType.Limit,
        isLong: form.side === "buy",
        size: parseUnits18(form.size || "0"),
        limitPrice: parseUnits18(form.limitPrice || "0"),
        expirationBlock: (env.currentBlock ?? 0) + blocksIn(Number(form.hours || "0") * 3600),
      },
      bz.ctx.integrator,
      env,
    );
    return {};
  } catch (e) {
    if (e instanceof InputError) return { [e.field]: e.message.slice(e.field.length + 2) };
    return { size: "Enter a number." }; // parseUnits18 refused the text (UnitError)
  }
}

// What the checks are judged against: read once when the form opens, refreshed now and then.
const env: orders.OrderEnvironment = {
  currentBlock: await bz.ctx.chain.blockNumber(),
  price: (await bz.trade.livePairPrice(eth.address)).price.spot,
  account: await bz.orders.readOrderState(eth.address, user),
};
validate({ side: "buy", size: "0.001", limitPrice: "3000", hours: "24" }, env);
// { size: "This order is worth $3; the minimum is $5 (closing your whole position is exempt)." }

InputError.message is prefixed with the field ("size: Enter a size greater than zero."); the sample strips the prefix to show the sentence next to the field.

env is an OrderEnvironment; every field is optional, and a check that needs a missing one is skipped:

FieldUsed for
currentBlockThe expiry against the minimum lifetime.
priceThe Limit placement band, and a Market order’s notional. Take it from live prices (Live Prices) or bz.trade.livePairPrice(pair).
accountThe TP/SL rules and the caps. bz.orders.readOrderState(pair, user) reads it in one batch: the position, the live Limit + StopLimit order ids, and the attached TP and SL ids.

What it refuses, with the field named:

  • a missing or zero size or price, or one too large for the order’s 128-bit fields;
  • a time in force or post-only flag the type does not take (IOC on a non-Limit, FOK on a trigger type, post-only with IOC or FOK or on a non-Limit);
  • a StopLimit’s limit on the wrong side of its trigger;
  • a slippage outside 1 to 1000 bp on a Market, TakeProfit or StopLoss;
  • a bounty on a type that cannot carry one, a negative one, or one that is not whole micro-USDC;
  • a Limit price outside ±50% of env.price;
  • a notional under $5 that is not a full close (measured at the limit price, else the trigger price, else env.price for a Market; for a TP/SL on what it could close);
  • a missing expiry on a Limit GTC or StopLimit, or one under the minimum lifetime from env.currentBlock;
  • a TP/SL with no position, or on the position’s own side;
  • the caps below.

The chain still decides: the creation margin check, the book and the price at landing are not in buildOrderSpec. Simulate, or preview with previewOrder, for those.

Two related helpers: orders.validateOrderSpec(spec, env?) checks a spec built elsewhere (a relayer uses it on a signed order) and also refuses bounty bits on a non-trigger type, and orders.orderInputFromSpec(spec) turns a spec back into an OrderInput. bz.trade.prepareOrder(pair, owner, order, opts?) runs the full check against the chain without building a call, for the gasless path (Gasless Trading & Relayers); its opts.integrator overrides the connection’s.

Order Caps

orders.checkOrderCaps(order, account) throws InputError on orderType when the account is at a per-account cap on the pair:

const state = await bz.orders.readOrderState(eth.address, user);
try {
  orders.checkOrderCaps({ orderType: OrderType.TakeProfit }, state);
} catch (e) {
  if (e instanceof InputError) console.log(e.message); // "...already has a take-profit order..."
}
state.activeLimitOrderIds.length; // live Limit GTC + StopLimit orders, of at most 32
CapLimitCounts
Active orders32 per account per pair (MAX_ACTIVE_LIMIT_ORDERS_PER_USER)Live Limit GTC and StopLimit orders. IOC and FOK limits and Market orders never rest, so they take no slot.
TakeProfit1 per position
StopLoss1 per position

buildOrderSpec applies it whenever env.account is given. Disable the order types a user cannot place rather than letting them fill in a form that will be refused.

Describing an Order

orders.describeOrder(spec) is one line for a confirmation screen, and what createOrder puts in summary. Every amount prints exactly (trailing zeros trimmed), never rounded: the size, the prices and the bounty the user signs.

const spec = orders.buildOrderSpec(
  { orderType: OrderType.StopLoss, isLong: false, size: CLOSE_POSITION, triggerPrice: parseUnits18("2700"), maxSlippageBp: 200n, bounty: parseUnits18("0.5") },
  ZERO_ADDRESS,
);
orders.describeOrder(spec);
// "Stop loss: sell the whole position when the price reaches 2700 (2% max slippage, $0.5 keeper bounty)"
TypeExample
MarketBuy 0.5 at market (0.5% max slippage), with , fill-or-kill for FOK
LimitBuy 1 at 2950 (limit, GTC, post-only)
StopLimitBuy 1 at 3210 once the price reaches 3200 (stop-limit, $0.25 keeper bounty)
TakeProfitTake profit: sell the whole position when the price reaches 3600 (1% max slippage, $0.5 keeper bounty)
StopLossStop loss: sell 0.5 when the price reaches 2700 (2% max slippage)

What Can Go Wrong

You seeIt means
InputError on limitPrice: “A limit price must be within 50% of the market price: between … and …”The Limit is outside the placement band around the live price.
InputError on size: “This order is worth $3; the minimum is $5 …”The notional is under $5 and the order is not a full close.
InputError on expirationBlock: “Choose when the order expires.” / “… at least about a minute.”A resting order needs an expiry at least 280 blocks ahead.
InputError on orderType: “You already have 32 open limit and stop-limit orders …”The active-order cap. Cancel one first.
ConnectError: “this call needs a fresh Pyth price …”The connection has no prices source.
OrderManagementLib__InsufficientMarginAfterOrder (simulation)The account’s collateral does not cover the order’s worst-case exposure. Deposit, or reduce the size. Preview the requirement with previewOrder.
MatchingEngineLib__FillOrKillUnfilledA FOK order could not fill completely; nothing happened.
MatchingEngineLib__BlockedOracleStaleA Market order on a market whose venue is closed. Use a Limit order.
BazaarPair__TradingHaltedADL, the settlement window or termination: nothing trades.
Unknown(0x19abf40e)Pyth’s StalePrice: the price update aged past 3 seconds before landing. Rebuild and send again.
An order that was placed but shows canceledAn auto-cancel: post-only, self-match, margin, walk budget or dust. The OrderUpdated event’s cancelReason says which (Live Updates).

Errors & Messages explains how to show all of these.