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

Introduction

Bazaar is a permissionless perpetual futures protocol on Arbitrum: any asset with a Pyth price feed can be listed and traded on a fully on-chain central limit order book, margined in USDC. The contracts are immutable, with no owner, pauser or upgrade key, and every role an exchange keeps for itself (listing, insuring, liquidating, firing stops) is open to anyone.

The protocol is not yet deployed: there is no official deployment on any network, so these pages use placeholder factory addresses until Bazaar’s deployments page lists one.

This site documents the TypeScript tools for building on it. They come in two packages:

PackageWhat it isWho it is forRuns in
@liberta-labs/bazaar-sdkEverything an application needs: markets and accounts, live prices and events, every trading action (direct and gasless), USDC approvals and permits, previews, user-facing error messages, history, insurance, termination and settlement, listings and governanceFrontend developers, people writing trading bots and services, relayersBrowsers and Node 20+
@liberta-labs/bazaar-keeperThe keeper: Bazaar’s permissionless upkeep (liquidations, trigger orders, vault unloads, ADL, price refreshes, terminal settlement, collection), run from your own wallet for the bounties it earns, with the bazaar command-line toolKeeper operators; services that want the keeper’s engine as a libraryNode 20+

The keeper is built on the SDK: the chain client, the ABIs, the price layer, the formulas and the market reader are the same code a frontend uses.

What the SDK Does

  • Reads, batched into as few RPC calls as the node allows: the market list with labels, a market’s whole state at one block (prices, funding, margin, open interest, insurance, ADL, termination, the book), an account’s position, health, orders, withdrawable amount and fees, and a portfolio across every market.
  • Live data: a streamed, composed Pyth price per market with freshness and venue-closed flags, and typed contract events with refreshed snapshots, safe across reorgs.
  • Writes, checked before they are built: each builder validates its inputs the way the contract will, then returns a PreparedCall (target, calldata, ETH value, a one-line summary, the USDC it pulls) for your code to simulate and send.
  • Explanations: a revert decoded to a named error and a sentence for the user, for every custom error the contracts declare, and for the failures wallets raise.

What It Does Not Do

  • It never holds a key. It does not sign or send on its own. A write is a PreparedCall that you simulate and hand to the user’s wallet (or, in a bot, to a viem WalletClient holding the bot’s key). Signatures for gasless calls and USDC permits are requested from an EIP-1193 wallet with eth_signTypedData_v4, so the wallet shows the user what they sign.
  • It does not choose for you whom you serve, which markets you show, or how you present them. The contracts cannot tell who calls them; that decision belongs to whoever puts an interface in front of users (see Bazaar’s integrator guide).
  • It does not run a relayer or an indexer. It builds and verifies gasless requests and reads Bazaar’s subgraph; running those services is yours.
  • It does not re-explain the protocol. Margin, funding, liquidation, ADL and termination are defined in Bazaar’s own docs, which these pages link wherever a rule matters.

A Taste

Connect a browser wallet, read the user’s account on the ETH/USD market, and place a limit order, simulated before the wallet is asked to sign:

const [user] = (await window.ethereum.request({ method: "eth_requestAccounts" })) as Address[];
const bz = await Bazaar.connect({ transport: window.ethereum, factory: FACTORY, prices: { token: pythToken } }); // tokens from your backend

const eth = await bz.markets.getMarket("ETH/USD");
const me = await bz.account.accountSnapshot(eth, user!);
console.log(`position ${formatUnits18(me.position.size)}, margin ratio ${me.health?.marginRatio ?? "-"}`);

const call = await bz.trade.createOrder(eth.address, user!, {
  orderType: OrderType.Limit, isLong: true, size: parseUnits18("0.5"), limitPrice: parseUnits18("2400"),
  expirationBlock: me.block + orders.blocksIn(3600), // one hour of Arbitrum blocks
});
const sim = await bz.simulate(call); // as the user, before anything is signed
if (sim.ok) await bz.send(window.ethereum, call); // the wallet signs and sends
else console.error(errorMessage(sim.error)); // "This order needs $1,240.50 of collateral ..."

FACTORY is the deployment’s factory address, from Bazaar’s deployments page, and pythToken a function of your page that returns a short-lived Pyth Pro token from your backend (see Connecting). Amounts are 18-decimal bigints: parseUnits18("0.5") is half a unit, parseUnits18("2400") is $2,400.

A Map of These Docs

Getting Started

The Keeper: what it does, setting one up, its configuration, jobs and budgets, running it in production, and the keeper as a library.

Frontends & Bots: connecting, markets, accounts, live prices and live updates; sending transactions, collateral and permits, placing and managing orders, previews and errors; gasless trading and smart accounts; history; insurance, termination, listings and governance; building a bot and the low-level building blocks.

Reference: the API index, units and constants, security notes for integrators, and development of the packages themselves.

Installation

This page covers what the SDK needs, how to install it (from npm once it is published, from the repository until then), how it fits a project that already uses viem or wagmi (or neither), the TypeScript settings it expects, and how to install the keeper.

Requirements

WhereWhat it needs
Node20 or later (both packages declare "engines": { "node": ">=20" }).
BrowsersAny current browser. The package is compiled to ES2022 and uses BigInt, fetch with streamed response bodies, AbortSignal.timeout and TextDecoder.
BundlersAnything that reads ES modules and package exports: Vite, webpack 5, Rollup, esbuild, Next.js. No polyfills and no Node built-ins are needed in the browser: the SDK’s sources are typechecked with DOM types only, so a stray node: import or Buffer cannot slip in.
Dependenciesviem (^2.57.17) and nothing else.

ES modules only. The package is "type": "module" and its exports map has only an import condition. Import it with import; from CommonJS, load it with a dynamic await import("@liberta-labs/bazaar-sdk").

Tree-shaking. The package declares "sideEffects": ["./dist/errors/messages.js"], so a bundler drops every module you do not use. The one module kept for its side effect registers the user-facing error messages; connect() also registers them explicitly (installUserMessages()), so they survive even a bundler that ignores the flag. If you decode errors without ever connecting (a script that only calls decodeError), call installUserMessages() once yourself.

Installing the SDK

Once published:

npm install @liberta-labs/bazaar-sdk

The packages are not on npm yet. Until they are, build them from the repository:

git clone https://github.com/liberta-labs/bazaar-sdk && cd bazaar-sdk && npm install && npm run build

Then either pack the SDK into a tarball and install that in your project, which is what npm will later install:

npm pack -w @liberta-labs/bazaar-sdk          # writes liberta-labs-bazaar-sdk-0.1.0.tgz
cd ../my-app && npm install ../bazaar-sdk/liberta-labs-bazaar-sdk-0.1.0.tgz

or link the built package folder, which picks up rebuilds without reinstalling:

cd ../my-app && npm install ../bazaar-sdk/packages/sdk

Install viem in your project too if you import from it (the samples in these docs do, for types such as Address and for wallet clients). Keep one copy: the same major version the SDK uses.

With viem, wagmi, or Neither

The SDK talks to the chain through a Transport: anything with an EIP-1193 request({ method, params }). Three kinds of object fit, and you pick per project:

// No library at all: the wallet's own EIP-1193 provider in a browser...
const inBrowser = await Bazaar.connect({ transport: window.ethereum, factory: FACTORY });
// ...or a JSON-RPC URL in Node (the SDK's own HTTP client, with rate limiting and retries).
const inNode = await Bazaar.connect({ transport: "https://sepolia-rollup.arbitrum.io/rpc", factory: FACTORY });
  • No library at all. In a browser, pass the wallet’s provider (window.ethereum, or the provider an EIP-6963 picker hands you). In Node, pass an RPC URL: the SDK builds its own small HTTP client (JsonRpc), rate limited, retried on transient failures and never printing the URL’s key. Sending then goes through the same wallet with bz.send(wallet, call).
  • viem. A viem PublicClient or WalletClient is itself an EIP-1193 transport. Use one for reads and a WalletClient to send, turning each PreparedCall into a transaction request with toTransactionRequest:
// A viem client is an EIP-1193 transport too: reads go through it.
const publicClient = createPublicClient({ chain: arbitrumSepolia, transport: http() });
const bz = await Bazaar.connect({ transport: publicClient, factory: FACTORY });

// Writes come back as PreparedCalls; a viem WalletClient (or wagmi, which wraps one) sends them.
const walletClient = createWalletClient({ chain: arbitrumSepolia, transport: custom(window.ethereum) });
const [user] = await walletClient.requestAddresses();
const eth = await bz.markets.getMarket("ETH/USD");
const call = await bz.trade.claimFees(eth.address, user!);
const sim = await bz.simulate(call);
if (sim.ok) await walletClient.sendTransaction({ ...toTransactionRequest(call), account: user! });
  • wagmi. wagmi’s clients are viem clients: pass the connector’s client (from useConnectorClient or getConnectorClient) as the transport, or keep window.ethereum for reads, and send with wagmi’s sendTransaction and toTransactionRequest(call), exactly as with viem above. A connection is bound to a chain, not to an account: connect again when the user switches chain.
  • ethers. An ethers provider is not an EIP-1193 object (it has send, not request). Pass the underlying provider (window.ethereum) or an RPC URL to the SDK, and send a call’s to, data and value with an ethers signer if you prefer.

A bot holding its own key uses an RPC URL for reads and a viem WalletClient with a local account to send; Quick Start: a Bot shows it end to end, including the one detail that trips people up (the account field).

TypeScript

The package ships its own declarations (dist/index.d.ts). It is written with strict on and works best in a project that has it on too. The settings that matter:

SettingValueWhy
module / moduleResolutionNodeNext for Node, Bundler (or NodeNext) for a bundled appThe package is ESM with an exports map.
targetES2020 or laterAmounts are bigint (10n ** 18n needs ES2020).
lib / typesDOM in a browser project; @types/node (20+) in a Node oneThe declarations name fetch, Response and AbortSignal, which come from either.

Every protocol quantity is a bigint, which JSON.stringify refuses: convert to a decimal string (formatUnits18, or String(x)) before putting one in JSON, a URL or framework state that serializes. See Units & Constants.

Installing the Keeper

The keeper is a Node 20+ program with a command-line tool, bazaar. Once published:

npm install -g @liberta-labs/bazaar-keeper
bazaar keeper init

Until then, run it from the repository you built above:

cd bazaar-sdk
npx bazaar keeper init      # asks for the network, RPC, subgraph, price endpoint, wallet, jobs and markets
npx bazaar keeper doctor    # checks every connection, the wallet's ETH, and the jobs

The repository’s Dockerfile builds the same keeper as a container. The whole setup, wallets and configuration are in Setting Up a Keeper; using its engine from your own code is in The Keeper as a Library.

Next

Quick Start: a Frontend

This walkthrough builds a working trading page with no framework: it connects the user’s wallet, lists Bazaar’s markets with their prices and status, shows the user’s position on one, deposits collateral, and places a limit order, simulating every transaction before the wallet is asked to sign. At the end the page follows the account live. It is one TypeScript file and one HTML file, about 200 lines in all.

You need a browser wallet (MetaMask, Rabby, …) on Arbitrum Sepolia with a little ETH for gas and some of the deployment’s USDC, the deployment’s factory address (Bazaar’s deployments page), and a way for your page to get Pyth Pro prices without holding the API key: an endpoint of your backend that mints short-lived tokens from it (see Connecting).

1. Create the Page

npm create vite@latest bazaar-demo -- --template vanilla-ts
cd bazaar-demo
npm install @liberta-labs/bazaar-sdk viem

Until the SDK is on npm, install it from the repository instead, as in Installation.

Replace index.html with a page that has a few named elements:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Bazaar demo</title>
  </head>
  <body>
    <button id="connect">Connect wallet</button>
    <select id="market"></select>
    <pre id="position"></pre>

    <form id="deposit">
      <input name="amount" placeholder="USDC to deposit" />
      <button>Deposit</button>
    </form>

    <form id="order">
      <select name="side"><option value="buy">Buy</option><option value="sell">Sell</option></select>
      <input name="size" placeholder="Size" />
      <input name="price" placeholder="Limit price" />
      <button>Place limit order</button>
    </form>

    <p id="status"></p>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

Everything below goes into src/main.ts, in the order shown (delete what Vite put there). The complete file is docs/examples/getting-started/frontend.ts in the SDK’s repository.

2. Configure

const FACTORY: Address = "0x0000000000000000000000000000000000000000"; // from Bazaar's deployments page
const CHAIN_ID = 421614; // Arbitrum Sepolia
const TOKEN_URL = "/api/pyth-token"; // your backend mints short-lived Pyth Pro tokens: never a Pyth key in a bundle
const pythToken = cachedToken(() => fetch(TOKEN_URL).then((r) => r.json())); // answers { token, expiresAt }

The SDK reads everything else (the lens, the terminator, the oracle, USDC, the markets and their feeds) from the factory. The price endpoint serves the signed Pyth updates that order calls carry and the feed symbols the market list labels markets with; never put a Pyth API key in code a browser downloads.

3. Two Helpers

const el = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
const status = (text: string) => {
  el("status").textContent = text; // textContent, never innerHTML: chain and subgraph strings are untrusted
};

/** Run a click handler, showing any failure as one sentence for the user. */
function run(task: () => Promise<void>): void {
  task().catch((e: unknown) => {
    console.error(e);
    status(e instanceof UnitError ? "Enter amounts as plain numbers, like 12.5." : errorMessage(e));
  });
}

let bz: Bazaar;
let user: Address;
let market: markets.MarketInfo;

errorMessage(e) turns whatever a step throws (a decoded contract revert, a wallet rejection, an input the SDK refused) into one sentence for the user. Inputs are parsed with parseUnits18, which throws a UnitError for anything that is not a plain decimal number; the helper words that one itself. Text from the chain or the subgraph (market descriptions, for one) is untrusted: the page only ever sets textContent.

4. Connect

async function connect(): Promise<void> {
  const accounts = (await window.ethereum.request({ method: "eth_requestAccounts" })) as string[];
  user = getAddress(accounts[0]!);
  // Ask the wallet for the right network first: connect() refuses any other chain.
  await window.ethereum.request({ method: "wallet_switchEthereumChain", params: [{ chainId: toHex(CHAIN_ID) }] });
  bz = await Bazaar.connect({ transport: window.ethereum, factory: FACTORY, chainId: CHAIN_ID, prices: { token: pythToken } });
  status(`Connected as ${user}`);
}

eth_requestAccounts asks the wallet for the user’s address. wallet_switchEthereumChain moves the wallet to the right network (it fails with error 4902 when the wallet does not know the chain; add it first with wallet_addEthereumChain). Bazaar.connect then reads the deployment’s wiring from the factory in one batched call and checks the chain: with chainId set, it throws a ConnectError on any other. The result, bz, carries every area of the SDK with this connection bound (bz.markets, bz.account, bz.trade, …). All options are on Connecting.

5. List the Markets

async function showMarkets(): Promise<void> {
  const list = await bz.markets.listMarkets(); // every market the factory deployed, labelled; cached
  const snaps = await bz.markets.marketSnapshots(list); // their live state, read in one batch
  const select = el<HTMLSelectElement>("market");
  select.replaceChildren(
    ...list.map((m) => {
      const s = snaps.get(m.address);
      const price = s ? formatUnits18(s.price.spot, 2) : "?";
      return new Option(`${m.label}  ${price}  ${s?.status ?? ""}  (${m.hours})`, m.address);
    }),
  );
  market = list[0]!;
}

listMarkets() returns every market the factory has deployed, in the factory’s order, with its static facts: label (“ETH/USD”, from the feed’s Pyth symbol), address, hours (“24/7” or “trading-hours”), its feeds. It is cached on the connection, so later calls cost nothing. marketSnapshots(list) reads every market’s live state at one block in two batched calls; here the page shows the pair’s stored price and its status (live, adl, limbo, settling, terminated). Every field is on Markets.

6. Show the Position

async function showPosition(): Promise<void> {
  render(await bz.account.accountSnapshot(market, user)); // one market, one account, two batched reads
}

function render(me: account.AccountSnapshot): void {
  const p = me.position;
  const lines = [
    `${market.label}: ${me.status}`,
    `Collateral: ${formatUnits18(p.collateral, 2)} USDC (withdrawable now: ${formatUnits18(me.maxWithdrawable, 2)})`,
  ];
  if (me.hasPosition && me.health) {
    lines.push(
      `Position: ${p.isLong ? "long" : "short"} ${formatUnits18(p.size)} at ${formatUnits18(p.entryPrice, 2)}`,
      `PnL: ${formatUnits18(me.health.totalPnl, 2)} at ${formatUnits18(me.health.price, 2)}, margin ratio ${me.health.marginRatio.toFixed(2)}`,
    );
  } else {
    lines.push("No position");
  }
  for (const o of me.orders) {
    lines.push(`Order #${o.orderId}: ${o.typeName} ${o.isLong ? "buy" : "sell"} ${formatUnits18(o.remaining)} at ${formatUnits18(o.limitPrice, 2)}`);
  }
  if (me.withdrawalBlocked) lines.push(me.withdrawalBlocked);
  el("position").textContent = lines.join("\n");
}

accountSnapshot(market, user) reads the user’s position on one market and values it at the live price: the position bucket (size, entryPrice, collateral), health (PnL, equity, the margin ratio, below 1 of which anyone may liquidate), the live orders, maxWithdrawable, and withdrawalBlocked, the reason a withdrawal would be refused right now. Amounts are 18-decimal bigints; formatUnits18(x, 2) prints one with two decimals. Every field is on Accounts & Portfolios.

7. Simulate, Then Send

Every write the SDK builds is a PreparedCall: a target, calldata, an ETH value, the account it acts for and a one-line summary. The page runs each one through the same function:

/** Simulate as the user, then let the wallet sign and send, then wait for the receipt. */
async function simulateAndSend(call: PreparedCall): Promise<void> {
  const sim = await bz.simulate(call);
  if (!sim.ok) throw new CallError(sim.error!.message, sim.error); // errorMessage() shows its sentence
  status(`Confirm in your wallet: ${call.summary}`);
  const hash = await bz.send(window.ethereum, call);
  status(`Waiting for ${hash}...`);
  await bz.waitForReceipt(hash); // throws CallError if it reverted
  status(`Done: ${call.summary}`);
}

bz.simulate(call) runs the call as the user with eth_call and estimates its gas. If it would revert, the revert comes back decoded (sim.error.name is the contract’s custom error, and sim.error.message a sentence for the user), and the wallet is never asked. Otherwise bz.send hands the call to the wallet (eth_sendTransaction), and bz.waitForReceipt polls for the receipt, throwing a CallError if the transaction reverted after all. See Sending Transactions.

8. Deposit Collateral

Collateral is per market: each market (each pair contract) holds its own, and pulls the USDC from the wallet itself, so the wallet must allow that pair to take it.

async function deposit(amountText: string): Promise<void> {
  const amount = parseUnits18(amountText); // dollars, 18 decimals
  const funding = await bz.allowance.usdcFunding(user, market.address, amount);
  if (!funding.enoughBalance) {
    const has = allowance.formatUsdcUnits(funding.balance); // USDC's own units: 6 decimals
    return status(`This wallet holds ${has} USDC; the deposit needs ${allowance.formatUsdcUnits(funding.needUsdc)}.`);
  }
  // Each market pulls its own collateral: approve exactly what this deposit needs, if the allowance is short.
  if (funding.approveUsdc > 0n) await simulateAndSend(await bz.allowance.approveUsdc(user, market.address, funding.approveUsdc));
  await simulateAndSend(await bz.trade.depositCollateral(market.address, user, amount));
}

usdcFunding reads the wallet’s USDC balance and its allowance to the pair, and says how much to approve (approveUsdc, in USDC’s own 6-decimal units). approveUsdc builds an approval for exactly that amount; depositCollateral then builds the deposit (at least 5 USDC). A deposit can instead carry a USDC permit, a signature in place of the approval transaction: see Collateral, Approvals & Permits.

9. Place a Limit Order

async function placeLimitOrder(side: string, sizeText: string, priceText: string): Promise<void> {
  const order = {
    orderType: OrderType.Limit,
    isLong: side === "buy",
    size: parseUnits18(sizeText),
    limitPrice: parseUnits18(priceText),
    expirationBlock: (await bz.ctx.chain.blockNumber()) + orders.blocksIn(24 * 3600), // a day of L2 blocks
  };
  // createOrder checks the order as the pair will, and attaches a Pyth print the pair accepts for
  // 3 s: build it right before the wallet prompt, and rebuild it if simulating took too long.
  let call = await bz.trade.createOrder(market.address, user, order);
  const sim = await bz.simulate(call);
  if (!sim.ok) throw new CallError(sim.error!.message, sim.error);
  if (Date.now() / 1000 - (call.priceTime ?? 0) > 2) call = await bz.trade.createOrder(market.address, user, order);
  status(`Confirm in your wallet: ${call.summary}`);
  await bz.waitForReceipt(await bz.send(window.ethereum, call));
  status(`Placed: ${call.summary}`);
}

createOrder checks the order exactly as the pair will (a size and price, a limit within 50% of the market price, at least $5 of notional, an expiry at least about a minute away, the per-user order caps) and throws an InputError naming the field otherwise. It then finds the order’s place in the book and attaches a fresh signed Pyth price, which every order call carries.

That price is the catch: the pair accepts a print at most 3 seconds from the block’s time. The page builds the call right before the wallet prompt and rebuilds it if simulating took long enough that the print is more than 2 seconds old (call.priceTime is its publish time). A user who takes longer than that to approve in the wallet can still see the transaction revert on a quiet market; on an active one, another trade has usually refreshed the pair’s price within the window. Production apps either offer a retry with a freshly built call or trade gasless, where the relayer attaches the price when it submits (Gasless Trading & Relayers). The details are in The 3-Second Price Window.

expirationBlock counts Arbitrum blocks, about four a second: orders.blocksIn(seconds) converts. Every option of every order type is on Placing Orders.

10. Wire the Buttons

const form = (id: string) => new FormData(el<HTMLFormElement>(id));

el("connect").addEventListener("click", () =>
  run(async () => {
    await connect();
    await showMarkets();
    await showPosition();
    follow();
  }),
);
el<HTMLSelectElement>("market").addEventListener("change", (e) =>
  run(async () => {
    market = await bz.markets.getMarket((e.target as HTMLSelectElement).value as Address);
    await showPosition();
    follow();
  }),
);
el<HTMLFormElement>("deposit").addEventListener("submit", (e) => {
  e.preventDefault();
  run(async () => {
    await deposit(String(form("deposit").get("amount")));
    await showPosition();
  });
});
el<HTMLFormElement>("order").addEventListener("submit", (e) => {
  e.preventDefault();
  const f = form("order");
  run(async () => {
    await placeLimitOrder(String(f.get("side")), String(f.get("size")), String(f.get("price")));
    await showPosition();
  });
});

11. Keep It Current

// Keep the position current. Events for this account (fills, orders, collateral) and a
// timer refresh both re-read the snapshot; call stop() when the view goes away.
let watch: { stop(): void } | null = null;
function follow(): void {
  watch?.stop();
  watch = bz.live.watchAccount(user, [market], (u) => {
    if (u.type === "account") render(u.snapshot);
    if (u.type === "error") console.warn("live updates:", u.error);
  });
}

watchAccount follows the chain (on new-head notifications where the wallet’s provider pushes them, otherwise polling the head about once a second) and delivers the account’s own events and a refreshed AccountSnapshot for each market an event touched, plus one every 15 seconds as funding accrues and clocks run. The page re-renders from the snapshot it is handed, with no read of its own. See Live Updates.

12. Run It

npm run dev

Open the page, connect, deposit 20 USDC, and place a buy a little under the market price. The order appears in the position view as soon as the transaction lands.

What Can Go Wrong

What the user seesWhy
“You rejected the request in your wallet.”The user declined a prompt (EIP-1193 error 4001).
“This network has not been added to your wallet yet. Add it and try again.”wallet_switchEthereumChain failed with 4902.
“Something went wrong. Please try again.” on connectA ConnectError: the wallet is on another chain, or FACTORY is not a Bazaar factory on this chain. Log e.message for which.
“Something went wrong…” on the first market listThe price endpoint or the RPC could not be reached (labels fall back to short addresses when only the feed metadata fails).
A sentence naming a field, such as “limitPrice: A limit price must be within 50% of the market price: …”An InputError: the SDK refused the order before building it.
“This order needs $… of collateral …”The simulation reverted: not enough collateral for the order’s margin. Deposit first.
The order’s transaction reverts on chainThe attached price was more than 3 seconds old when it landed (a slow wallet confirmation on a quiet market). Build and send it again.

Next

  • Core Concepts: what is behind each step above.
  • Live Prices, to show a streaming price next to each market.
  • Previews, to show the fees, margin and liquidation price of an order before it is placed.
  • Managing Orders, to amend and cancel.

Quick Start: a Bot

This walkthrough writes a Node script that trades with its own key: it connects over an RPC URL, reads a market and its own account, tops up its collateral with a USDC permit, rests a post-only limit order and cancels it. Transactions are signed in the script with a viem WalletClient; the SDK builds and checks them, and never sees the key.

It is the skeleton every Bazaar bot starts from. Building a Bot goes further: live prices, events, nonces, many orders at once.

What You Need

  • Node 20 or later, and a project with the SDK and viem installed (Installation).
  • A private key for the bot, holding ETH for gas (and the Pyth fee that order calls send) and USDC on the deployment’s network.
  • An RPC URL. Any JSON-RPC provider works; a key inside the URL (Alchemy’s /v2/<key>, a ?key= query) is never printed by the SDK.
  • A Pyth Pro API key, for signed Pyth prices. A server may hold the key itself.
  • Node 22 or later if the bot streams prices (the stream runs over the global WebSocket); on Node 20 give the price client a WebSocket constructor, such as the ws package’s.
  • The deployment’s factory address, from Bazaar’s deployments page.

The script reads them from the environment:

RPC_URL=https://arb-sepolia.g.alchemy.com/v2/...
BAZAAR_FACTORY=0x...
PYTH_PRO_API_KEY=...
BOT_PRIVATE_KEY=0x...
MARKET=ETH/USD                # optional

Keep them in a .env file that is never committed, and load it with node --env-file=.env (Node 20.6+) or your process manager.

The complete script is docs/examples/getting-started/bot.ts in the SDK’s repository, with the wallet helper in local-wallet.ts beside it. Compile it with tsc, or run it with a TypeScript runner.

1. The Key and the Environment

function env(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`set ${name} in the environment`);
  return value;
}

const RPC_URL = env("RPC_URL"); // any JSON-RPC provider; a key in the URL never appears in errors
const account = privateKeyToAccount(env("BOT_PRIVATE_KEY") as Hex); // the key stays in this process
const me = account.address;

privateKeyToAccount makes a viem local account: it signs in this process, and nothing about the key leaves it.

2. Connect

const bz = await Bazaar.connect({
  transport: RPC_URL, // reads through the SDK's own JSON-RPC client (rate limited, retried)
  factory: env("BAZAAR_FACTORY") as Address,
  prices: { token: env("PYTH_PRO_API_KEY") }, // Pyth Pro: a server may hold the API key itself
});

const chain = [arbitrum, arbitrumSepolia, anvil].find((c) => c.id === bz.chainId);
if (!chain) throw new Error(`no viem chain for chain id ${bz.chainId}`);
const wallet = createWalletClient({ account, chain, transport: http(RPC_URL) }); // signs locally

With a URL as its transport, the SDK reads through its own JSON-RPC client: 25 requests a second at most, a 30-second timeout, and up to three retries with doubling delays on transient failures (never on a send). prices holds the Pyth Pro token; on a server it can be the API key itself, sent as Authorization: Bearer <key>. Connecting has every option, and how to change those defaults with a JsonRpc of your own.

The WalletClient is what signs and sends. It needs the chain object for the network (viem/chains has Arbitrum One, Arbitrum Sepolia and Anvil); the script picks it from the chain id the connection found.

3. Send a PreparedCall

Every write the SDK builds is a PreparedCall (to, data, value, from, summary, …). The bot sends each one the same way:

/** Simulate as the bot, send with the WalletClient, wait for the receipt. */
async function send(call: PreparedCall): Promise<Receipt> {
  const sim = await bz.simulate(call);
  if (!sim.ok) throw new CallError(`${call.summary}: ${sim.error!.message}`, sim.error);
  const hash = await wallet.sendTransaction({
    ...toTransactionRequest(call), // to, data, value (the Pyth fee, if any)
    account, // the local account itself: given only its address, viem would ask the node to sign
    ...(sim.gas !== null ? { gas: (sim.gas * 12n) / 10n } : {}), // the simulation's estimate, plus 20%
  });
  const receipt = await bz.waitForReceipt(hash); // throws CallError if it reverted
  console.log(`${call.summary}: ${hash} (block ${receipt.blockNumber})`);
  return receipt;
}

/**
 * For calls that carry a Pyth print (orders, amendments, withdrawals with a position): the pair
 * takes a print at most 3 s from the block's time, so build, simulate and send in one go, and
 * rebuild when the print is already more than 2 s old.
 */
async function sendPriced(build: () => Promise<PreparedCall>): Promise<Receipt> {
  for (let attempt = 1; ; attempt++) {
    const call = await build();
    if (attempt < 3 && Date.now() / 1000 - (call.priceTime ?? 0) > 2) continue;
    return send(call);
  }
}
  • Simulate first. bz.simulate(call) runs the call as the bot with eth_call and, when it passes, estimates its gas. A revert comes back as sim.error: the contract’s custom error, decoded, with a sentence saying what happened. Nothing is sent, and no gas is spent.
  • Then send with the WalletClient. toTransactionRequest(call) gives viem’s to, data and value (the ETH a call with a price update must send for Pyth’s fee), and account, set to the call’s from address. Override account with the local account object. viem treats an account given as a bare address as a JSON-RPC account and asks the node to sign with eth_sendTransaction, which a hosted RPC refuses; given the local account, it signs here and sends the raw transaction. Passing the simulation’s gas estimate (with some headroom) saves viem a second estimate.
  • Wait for the receipt. bz.waitForReceipt(hash) polls every 500 ms for up to two minutes and throws a CallError if the transaction reverted or never landed.

Calls that carry a price. Order creation, amendments, trigger executions and some withdrawals attach a signed Pyth print, and the pair accepts a print at most 3 seconds from the block’s time. sendPriced builds such a call, and rebuilds it (twice at most) when the print it got is already more than 2 seconds old; a bot signs locally, so the whole build, simulate, sign and send takes well under a second after that. See The 3-Second Price Window.

4. Read the Market and the Account

async function readState(label: string) {
  const market = await bz.markets.getMarket(label); // a label, a pair address or a pair id
  const snap = await bz.markets.marketSnapshot(market);
  console.log(
    `${market.label} is ${snap.status}: stored price ${formatUnits18(snap.price.spot, 2)} (${snap.price.age} s old),` +
      ` funding ${formatUnits18(snap.funding.currentRatePerHour * 100n, 4)}%/h, max leverage ${snap.margin.maxLeverage ?? "-"}x`,
  );
  if (snap.marketOrdersDisabled) console.log(snap.reasons.marketOrdersDisabled);

  const mine = await bz.account.accountSnapshot(market, me);
  console.log(
    `collateral ${formatUnits18(mine.position.collateral, 2)} USDC, wallet ${formatUnits18(fromUsdcUnits(mine.wallet.usdcBalance), 2)} USDC,` +
      ` ${mine.hasPosition ? `position ${formatUnits18(mine.position.size)}` : "flat"}, ${mine.orders.length} open orders`,
  );
  return { market, snap, mine };
}

getMarket accepts a label, a pair address or a pair id. marketSnapshot reads the market’s state at one block: its status, the pair’s stored price and its age, funding, margin rates, open interest, insurance, and flags such as marketOrdersDisabled with the reason for each. accountSnapshot reads the bot’s position, collateral, orders, what it can withdraw, its claimable fees and its wallet’s USDC balance (in USDC’s own 6-decimal units, hence fromUsdcUnits before printing). The fields are on Markets and Accounts & Portfolios.

5. Top Up Collateral With a Permit

Collateral is per market, and the market pulls it from the wallet, so the wallet must allow it. An approval is a transaction of its own; a USDC permit (ERC-2612) is a signature the deposit carries instead:

async function topUp(pair: Address, collateral: bigint): Promise<void> {
  const target = parseUnits18("50"); // keep $50 of collateral on this market
  if (collateral >= parseUnits18("20")) return;
  const amount = target - collateral;
  // A USDC permit instead of an approve transaction: a signature the deposit carries.
  const permit = await bz.permits.signPermit(localWallet(account), { owner: me, spender: pair, value: toUsdcUnitsCeil(amount) });
  await send(await bz.trade.depositCollateral(pair, me, amount, { permitData: permit.permitData }));
}

permits.signPermit builds the permit’s typed data and asks a wallet to sign it with eth_signTypedData_v4, as it would ask a browser wallet. A viem WalletClient would forward that request to the RPC node, which cannot sign, and the SDK has no local-key wallet of its own, so the bot uses a few lines that answer the request with the local account:

/**
 * Answers the two requests the SDK's signing helpers make, eth_accounts and eth_signTypedData_v4,
 * by signing with `account`'s key in this process. Everything else is refused with EIP-1193's
 * "unsupported method" (4200): send transactions with a viem WalletClient.
 */
export function localWallet(account: LocalAccount): Transport {
  return {
    async request({ method, params = [] }) {
      if (method === "eth_accounts" || method === "eth_requestAccounts") return [account.address];
      if (method === "eth_signTypedData_v4") {
        const [signer, typedData] = params as [string, string]; // the address, then the typed data as JSON
        if (getAddress(signer) !== account.address) {
          throw Object.assign(new Error(`this wallet signs only as ${account.address}`), { code: 4100 });
        }
        return account.signTypedData(JSON.parse(typedData));
      }
      throw Object.assign(new Error(`${method} is not supported by this wallet`), { code: 4200 });
    },
  };
}

The SDK checks every signature it gets back: it must recover to the signer, as a 65-byte low-s ECDSA signature. The same helper signs gasless requests (relay.signRelayed); see Gasless Trading & Relayers.

The permit covers toUsdcUnitsCeil(amount): the deposit is in 18 decimals, USDC counts in 6, and a permit must cover the pull rounded up. The minimum deposit is 5 USDC.

6. Place an Order and Cancel It

async function placeOrder(pair: Address): Promise<bigint | null> {
  const { price } = await bz.trade.livePairPrice(pair); // the composed Pyth price the pair will see
  const limitPrice = (price.spot * 98n) / 100n; // 2% under the market: it rests rather than fills
  const size = (parseUnits18("20") * BAZAAR_SCALE) / limitPrice; // about $20 of notional (the minimum is $5)
  const expirationBlock = (await bz.ctx.chain.blockNumber()) + orders.blocksIn(3600); // an hour of L2 blocks

  const receipt = await sendPriced(() =>
    bz.trade.createOrder(pair, me, { orderType: OrderType.Limit, isLong: true, size, limitPrice, expirationBlock, isPostOnly: true }),
  );
  // The new order's id is in its OrderUpdated event (action 0: created).
  for (const log of receipt.logs) {
    const e = live.decodeLiveLog(log);
    if (e?.source === "pair" && e.name === "OrderUpdated" && e.args.owner === me && e.args.payload.action === 0) return e.args.orderId;
  }
  return null;
}

async function cancel(pair: Address, orderId: bigint): Promise<void> {
  await send(await bz.trade.cancelOrders(pair, me, [orderId])); // any bounty returns to claimable fees
}

trade.livePairPrice fetches the signed update and composes the price the pair itself will compute from it (spot, and the confidence band). The order is a post-only limit 2% under it, so it rests in the book as a maker; a post-only order that would cross is canceled instead of filling. Its size is about $20 of notional (size × price / 1e18; the minimum is $5), and it expires in an hour of Arbitrum blocks (orders.blocksIn(3600), about four blocks a second).

The pair numbers orders itself; the new order’s id is in the OrderUpdated event of the receipt, which live.decodeLiveLog turns into a typed event (payload.action 0 is “created”). Canceling takes a list of up to 200 ids. Every order option is on Placing Orders, canceling and amending on Managing Orders.

7. Run It

async function main(): Promise<void> {
  const { market, mine } = await readState(process.env.MARKET ?? "ETH/USD");
  await topUp(market.address, mine.position.collateral);
  const orderId = await placeOrder(market.address);
  if (orderId === null) throw new Error("the order did not rest (a post-only order that would cross is canceled)");
  console.log(`order #${orderId} is resting`);
  await cancel(market.address, orderId);
  await readState(market.label);
}

main().catch((e: unknown) => {
  // The SDK's errors say what happened in their message (with any URL's key masked). A user-facing
  // app would show errorMessage(e) instead; an operator wants the detail.
  console.error(e instanceof Error ? `${e.name}: ${e.message}` : e);
  process.exitCode = 1;
});

A run prints the market and the account, then one line per transaction:

ETH/USD is live: stored price 3012.41 (2 s old), funding 0.0012%/h, max leverage 20x
collateral 0 USDC, wallet 250 USDC, flat, 0 open orders
Deposit 50 USDC as collateral: 0x5c1d... (block 98211873)
Buy 0.006774722395125601 at 2952.16378 (limit, GTC, post-only): 0x8e02... (block 98211881)
order #4182 is resting
Cancel order #4182: 0x1f7a... (block 98211889)
...

(The figures are illustrative.) A summary prints every amount exactly, never rounded: it is what the transaction does.

If anything fails, the script prints the error’s name and message and exits with code 1. The SDK’s messages are written to be read: a refused input names its field, a decoded revert says what happened, and a URL is printed with its key masked. A user-facing app shows errorMessage(e) instead (see Errors & Messages).

What Can Go Wrong

MessageWhy
Error: set RPC_URL in the environmentA variable is missing.
ConnectError: 0x... does not answer as a Bazaar factory on chain 421614BAZAAR_FACTORY is wrong for this RPC’s chain.
HttpError: HTTP 401 from https://.../v2/***: the provider refused the request; check the key in the URLThe RPC key is wrong.
ProError: price endpoint refused the request (HTTP 401); check the token (prices.token) and that it covers these feeds and this channelThe Pyth Pro key is wrong, missing, or not entitled to the market’s feeds.
CallError: Deposit 50 USDC as collateral: ...The simulation reverted; the rest of the message is the contract’s reason (for example, the wallet holds less USDC than the deposit).
CallError: Buy ...: This order needs $... of collateral ...Not enough collateral behind the order.
InputError: orderType: You already have 32 open limit and stop-limit orders on this market, ...The SDK refused the order before building it.
A viem error from eth_sendTransaction (an unknown account, a method the provider does not serve)account was left as the address from toTransactionRequest; pass the local account.
CallError: transaction 0x... revertedIt reverted on chain after a passing simulation: the state changed in between, or (for an order) its price print had gone stale.

Next

Core Concepts

The SDK is built around a handful of ideas that every page after this one assumes: a connection bound to one deployment, functions grouped by area, writes returned as prepared calls, one rule for units, a price window, errors written for users, and a short list of things it caches. This page explains each.

The Connection

A connection is a BazaarContext: one chain, one Bazaar deployment, and the optional services some functions need. Bazaar.connect(options) creates one (see Connecting); it is bz.ctx.

FieldWhat it is
chainThe SDK’s Chain over your transport: reads, batched reads, simulations, logs, receipts.
chainIdThe chain the transport is on, read at connect.
factoryThe deployment’s BazaarFactory, as you gave it.
lens, terminator, oracle, usdcThe deployment’s other contracts, read from the factory at connect.
pricesWhere signed Pyth updates come from, or null for a read-only connection. Every call that carries a price needs it.
subgraphBazaar’s subgraph, or null. Only history needs it.
integratorThe address every order this connection creates names as its integrator (the zero address for none).

Nothing in a context changes after it is made. A connection is bound to a chain, not to a user: every function takes the user’s address as an argument, so one connection serves any number of accounts. When the wallet switches chain, connect again.

The Facade and the Namespaces

Each area of the SDK is a module of plain functions that take the context first: markets.getMarket(ctx, "ETH/USD"), trade.createOrder(ctx, pair, user, order). The Bazaar object binds the context into every one of them, so the same calls read bz.markets.getMarket("ETH/USD") and bz.trade.createOrder(pair, user, order).

// The facade: one object, the connection bound into every function.
const bz = await Bazaar.connect({ transport: window.ethereum, factory: FACTORY });
const eth = await bz.markets.getMarket("ETH/USD");

// The same functions per area, taking the context first: what a library or a tree-shaken bundle uses.
const ctx = await connect({ transport: window.ethereum, factory: FACTORY });
const same = await markets.getMarket(ctx, "ETH/USD");

// Moving between them: bz.ctx is the context, and Bazaar.from wraps one.
const again = Bazaar.from(ctx);
console.log(bz.ctx.lens, again.deployment.usdc, same.label);

// Pure helpers, which need no connection, live only on the namespaces.
const expiry = (await bz.ctx.chain.blockNumber()) + orders.blocksIn(3600);
AreaOn bzWhat it covers
marketsbz.marketsThe market list, snapshots, the book
accountbz.accountAccount snapshots and portfolios
livebz.liveWatching a market or an account
tradebz.tradeCollateral, orders, fees, trigger execution
ordersbz.ordersOrder specs and their checks (mostly pure helpers)
allowance, permitsbz.allowance, bz.permitsUSDC balance, approvals, permits
signing, relaybz.signing, bz.relayGasless requests: signing, verifying, relaying
batchingbz.batchingSmart accounts and EIP-5792 batches
previewsbz.previewsWhat an order or a withdrawal would do
historybz.historyThe subgraph’s records
insurancebz.insuranceThe insurance fund
termination, listings, governance, umasameWinding markets down, listing them, UMA
priceFeeds(bz.livePrices())Pyth Pro sources and live prices
formatnoneFormatting amounts, prices, times

Use the facade in an application: it is shorter and cannot pass the wrong context. Use the namespaces in a library that takes a context from its caller, and for the pure helpers (packing, validation, formatting, type strings), which need no connection and are therefore not on bz: orders.blocksIn, orders.buildOrderSpec, format.formatUsd. Importing Bazaar brings every area into the bundle; an app that imports only a few namespace functions lets the bundler drop the rest. The facade also has bz.simulate, bz.send, bz.waitForReceipt, bz.livePrices, bz.chainId and bz.deployment.

The most used names (Bazaar, connect, errorMessage, the unit helpers, the constants, the error classes, the low-level chain and price clients) are exported flat from the package. The full list is in the API Index.

Prepared Calls: Simulate, Then Send

The SDK never sends anything and never holds a key. Every builder (trade.createOrder, trade.depositCollateral, allowance.approveUsdc, insurance.depositToInsurance, …) returns a PreparedCall, for any contract:

const call = await trade.createOrder(ctx, eth.address, user, {
  orderType: OrderType.Limit, isLong: false, size: parseUnits18("1"), limitPrice: parseUnits18("3100"), expirationBlock: expiry,
});
call.action; // "createOrder"
call.to; // the pair
call.data; // the calldata
call.value; // ETH for the Pyth update fee (wei)
call.from; // the user it acts for
call.summary; // "Sell 1 at 3100 (limit, GTC)"
call.priceTime; // the attached print's publish time (unix seconds)
call.pullsUsdc; // USDC it takes from the wallet (18 decimals): a trigger order's bounty, a deposit

const sim = await bz.simulate(call); // eth_call as `from`, then eth_estimateGas
if (sim.ok) console.log(`gas ${sim.gas}`);
else console.log(sim.error!.name, sim.error!.message); // the decoded custom error, and a sentence for the user
FieldMeaning
actionThe contract function, such as "createOrder".
to, dataThe contract and the calldata.
valueETH to send, in wei: the Pyth update fee when the call carries a price, else 0.
fromThe account the call acts for (the sender of a direct call), or null when unknown.
summaryOne line for a confirmation screen, with every amount printed exactly.
priceTimePresent when the call carries a Pyth update: the update’s publish time, in Unix seconds.
pullsUsdcPresent when known: the USDC the call takes from the wallet, in 18 decimals.

The flow is the same for every write:

  1. Build. The builder checks its inputs against what it can read (the account’s orders and position, the book, the live price) and throws an InputError naming the field for anything the contract would refuse. Before naming a market it asks the factory whether that address is one of its pairs (requirePair), so an address from a link cannot pass off another contract as a market.
  2. Simulate, as the user: bz.simulate(call) runs it with eth_call and estimates its gas. A revert comes back decoded, with a sentence for the user, before the wallet is asked anything.
  3. Show the summary, and what the call costs: value, pullsUsdc.
  4. Send with the user’s wallet: bz.send(wallet, call) over any EIP-1193 provider, or toTransactionRequest(call) for a viem or wagmi client, or a bot’s own key.
  5. Wait: bz.waitForReceipt(hash) throws a CallError if the transaction reverted.

Signatures follow the same principle: gasless requests and USDC permits are typed data the SDK asks the wallet to sign with eth_signTypedData_v4, so the wallet shows the user every field, and the SDK checks that the signature recovers to the signer before it uses it. See Sending Transactions and Gasless Trading & Relayers.

Units

One rule covers the whole SDK:

  • Protocol quantities are bigint: prices, sizes, notionals, collateral, fees, rewards, funding indices and rates, scores, basis-point rates.
  • Amounts are in 18 decimals (“BAZAAR precision”): 1e18 is $1, or one unit of the traded asset. parseUnits18("12.5") and formatUnits18(x, 2) convert to and from text.
  • USDC’s own calls are in 6 decimals, as the token counts: wallet balances and allowances (wallet.usdcBalance, usdcFunding, approveUsdc) and permits. The pair floors what it moves to whole micro-USDC. Convert with toUsdcUnits, toUsdcUnitsCeil (for an allowance that must cover a pull) and fromUsdcUnits. A name says USDC when it is in 6 decimals.
  • Times, blocks, counts and enum values are number: Unix seconds, block numbers, order types. The one exception is an order spec’s expirationBlock, a bigint because a take-profit or stop-loss carries 2^64 − 1 (“never”), which a number cannot hold. Order inputs accept either.
  • Ratios for display are number: a margin ratio, a leverage, a confidence in basis points.
const size = parseUnits18("0.25"); // 250000000000000000n: 0.25 ETH
const price = parseUnits18("3012.5"); // $3,012.50
const notional = (size * price) / 10n ** 18n; // $753.125, still 18 decimals
formatUnits18(notional, 2); // "753.12" (rounded toward zero)
toUsdcUnits(notional); // 753125000n: what USDC's own calls count (6 decimals), floored
fromUsdcUnits(1_000_000n); // 1000000000000000000n: one USDC back in 18 decimals

Every conversion and constant is on Units & Constants.

Price Updates and the 3-Second Window

Bazaar prices every market from Pyth. A call that needs the price (creating or amending an order, firing a trigger, a withdrawal with a position, an insurance exit, liquidations) carries a signed Pyth Pro update in its calldata, and the pair accepts a price generated at most 3 seconds from the block’s time (MAX_PRICE_STALENESS). The pair has the attached update verified and uses its price; a call that carries none runs on the pair’s own stored price while that is at most 3 seconds old, and otherwise reverts (or, on a market with trading hours, runs on its stored price where that call allows it). See Oracle.

What this means in code:

  • Builders that attach a price need a price source on the connection (prices); without one they throw a ConnectError.
  • call.value is the fee Pyth charges to verify the update; the pair refunds what it does not spend.
  • call.priceTime is the print’s publish time. The clock starts when the call is built: build right before sending, and rebuild a call whose print is more than a second or two old.
  • A human approving in a wallet can be slower than 3 seconds; on a quiet market such a call may revert on chain. Gasless calls avoid this: the relayer attaches the price when it submits.

Reads are different: they never need a print to be fresh. A market snapshot reports the pair’s stored price and its age; an account snapshot values the position at the freshest price it can get (the caller’s, a live stream’s, one Pyth Pro request, or the stored one) and says which (price.source). Live Prices streams the composed price of every market with a fresh flag.

User-Facing Errors

Failures come in a few shapes, and errorMessage(e) turns any of them into one sentence for the user:

ErrorWhenWhat it carries
InputErrorA builder refused an input before building anything.field, and a message prefixed with it (“size: Enter a size greater than zero.”).
DecodedErrorsimulate() found a revert: sim.error.The custom error’s name and args, and message, a sentence for the user. Every custom error the contracts declare has one.
CallErrorA send was rejected or the transaction reverted.message, and decoded when the revert could be decoded.
wallet errorsThe user declined, the wallet is on another network, has no ETH for gas, …errorMessage recognizes the EIP-1193 codes and viem’s and ethers’ error shapes.
ConnectErrorA connection problem: the wrong chain, not a factory, a missing price source or subgraph.A message for the developer.
ProError, SubgraphError, HttpError, RpcErrorAn endpoint failed.A message with any token, and any URL’s key, masked.
try {
  const c = await bz.trade.createOrder(eth.address, user, { orderType: OrderType.Market, isLong: true, size: parseUnits18("2") });
  const s = await bz.simulate(c);
  if (!s.ok) throw new CallError(s.error!.message, s.error);
  await bz.waitForReceipt(await bz.send(window.ethereum, c));
} catch (e) {
  if (e instanceof InputError) console.log(`field ${e.field} was refused before anything was built`);
  console.log(errorMessage(e)); // here: "maxSlippageBp: Set a slippage limit between 0.01% and 10%."
}

errorMessage falls back to “Something went wrong. Please try again.” for anything it does not recognize (a ConnectError, an endpoint failure, a UnitError from parsing a number), and never shows a raw URL. Log the error itself for the detail. See Errors & Messages.

Browser Safety

The SDK is written to run in a page an attacker can link to and feed data:

  • No secrets in the bundle. A Pyth Pro API key or a subgraph key in browser code is public. Give prices short-lived tokens your backend mints from the key (see Connecting), or leave prices to a relayer.
  • Only Pyth’s signature counts. Prices are read from the signed update itself, and a connection refuses updates not signed by a signer the deployment’s Pyth verifier trusts.
  • Keys are masked. Every error and display string built from an RPC, price or subgraph URL masks the parts that may be a key (redactUrl), and a Pyth Pro token never appears in one.
  • Untrusted addresses are checked. Every builder confirms a pair with the factory (requirePair) before building a call, an approval, a permit or a gasless request for it.
  • Untrusted text stays text. Market descriptions and proposal texts come from the chain or the subgraph: render them as text, never as HTML. Market labels come from Pyth’s feed symbols, never from a description.
  • Approvals are exact unless you ask for unlimited ones explicitly.
  • Signatures are checked. A signature that does not recover to the signer (a smart-contract wallet signing through ERC-1271, say) is refused before use, since the pair would act for whatever address it recovers.
  • Clocks are checked. Deadlines for permits and gasless requests are counted from the lower of the latest block’s time and this device’s clock, and the SDK refuses to ask for a signature (ClockSkewError) when the two differ by more than two minutes (local development chains excepted).

More in Security Notes for Integrators.

What Is Cached

The SDK reads the chain when you ask and keeps very little. What it keeps, it keeps per connection (per context object), except where noted:

WhatKeptRefreshed
The market list and each market’s static facts (pair id, feeds, calendar, label)Per connection, after the first listMarketslistMarkets({ refresh: true }); getMarket refreshes once by itself for a name it does not know. Static facts never change.
A pair’s Pyth feedsPer connectionNever (they cannot change); a failed read is not kept.
“This address is one of the factory’s pairs”Per connectionA refusal or a failed read is not kept, so a pair listed later is asked again.
Pyth feed metadata (symbols, about 1 MB)Per endpoint, for the whole page or processA failed fetch is not kept.
Whether the node supports state overrides, and whether Multicall3 existsPer ChainRe-detected if a node behind a load balancer stops honoring overrides.

Nothing else is cached. Snapshots, books, portfolios, nonces, balances and allowances are read fresh on every call, at one block, and returned as plain objects your UI can keep. A LivePrices stream holds the latest print per feed in memory, and a watcher holds the last snapshots it delivered; both are released when you stop them.

Next

What the Keeper Does

Bazaar needs someone to do its upkeep: liquidate positions that fall below their maintenance margin, fire stop orders when the price reaches them, sell the insurance vault’s inherited inventory, run ADL auctions, keep quiet markets’ prices fresh, settle terminated markets. None of it is restricted. Anyone may call these functions, and most of them pay the caller a bounty in USDC.

A keeper is a program that does this work for its bounties. @liberta-labs/bazaar-keeper is one: it watches the markets you choose, works out which calls are due, simulates each one against the chain to learn exactly what it would earn, and sends the ones worth sending from your own wallet. It runs as a command-line program (bazaar keeper start), in a container, or inside your own TypeScript service (The Keeper as a Library).

This page explains what the keeper does and what it needs. Setting Up a Keeper takes you from nothing to a running keeper.

The Jobs

Each job is one kind of upkeep. You switch each one on in the config and choose its markets.

JobWhat it doesWho pays you, and how much
liquidationseizes positions below their maintenance marginthe insurance fund: $0.05 per position seized, or the larger of $0.05 and 2 bp of the notional it retires when the seizure nets the vault’s two buckets; plus whatever 2 bp of the call’s inline unload exceeds that. A position worth less than $5 pays at most its own equity
triggersfires StopLimit, StopLoss and TakeProfit orders whose trigger the price reachedthe order’s owner, through the bounty they attached when they placed it: paid in proportion to what your call fills, the whole remainder once the order completes or rests in the book
unloadsells the vault’s liquidated inventory into the bookthe insurance fund: 2 bp of what sells, raised to a $0.25 floor when a call unloads $1,250 or more or clears the inventory
adlruns ADL batches while an ADL window is openthe insurance fund: 2 bp at the mark of what a batch closes, or 0.1% of the bad debt the close averts where that is larger, plus 2 bp of the overlap the batch’s netting retires
refreshstores a fresh price on quiet marketsthe insurance fund: $0.10 per refresh, when the refresh bounty’s gates hold (The Jobs)
terminalSettlementsettles positions at a terminated market’s fixed price, then finalizes it$0.05 per position settled for someone else, from that position’s collateral (the insurance fund tops up positions worth at least $5); finalizing pays nothing
collectclaims what your wallets have earned, and can forward the USDC elsewherenothing: it moves your earnings to your wallet
sweepExpiredunlinks expired orders and returns stranded bounties to their ownersnobody: a public service, run within a small daily loss budget

Every bounty is credited to your wallet’s claimable balance on the market that paid it, and stays there until it is claimed with claimFees() on that market. The collect job does that for you. Every payout from the insurance fund is capped at the fund’s free capital. The full rules are in Bazaar’s Earning on Bazaar and Parameters.

Besides the jobs, the keeper runs manual operations when you ask for them: terminating a market at its scheduled cutoff or after its oracle died, settling UMA claims and insurer votes, finalizing a termination, and the factory’s listing and identifier-upgrade housekeeping. They pay nothing and are never sent on their own. bazaar keeper opportunities lists the ones available now (Manual Operations).

Relaying signed meta-transactions is not a keeper job. Governance actions that post a bond or vote (termination and listing proposals, insurer votes) are not either: the SDK builds those for a person to sign (Termination & Settlement, Listing Markets).

How the Keeper Decides

The keeper never sends a call on an estimate. For each call it is about to make it does this:

  1. Find candidates. It keeps a local index of every open position and live order, built from the subgraph or from the chain’s logs and kept current from each new block’s logs. On each new price print and on each tick (every engine.tickSeconds, 0.5 s by default) every job checks its markets locally: is a position below its requirement at the live price, has a stop’s trigger been reached, does the vault hold inventory. Liquidation and triggers make no RPC request until a position or order crosses at the live price.
  2. Re-read from the chain. The index only finds candidates. Before acting, the job re-reads each one from the chain (the position’s bucket, the order record) and drops what no longer qualifies.
  3. Simulate through the probe. The call is run with eth_call from a small contract placed at a fixed address by a state override. Rewards accrue to the caller, so the difference in the probe’s claimable balance before and after the call is exactly what the call would earn, with every cap the contracts apply. A call that reverts in simulation is never sent.
  4. Price the gas. The gas comes from eth_estimateGas of the real transaction from the real wallet, priced at the current base fee and the live ETH/USD print.
  5. Apply the spend policy. A paid call goes out when reward − gas ≥ minProfitUsd and reward ≥ gas × gasSafetyMultiplier. A call that fails that test is sent only if you chose to take losses on that job, within per-call and daily caps. Every call, whatever decided it, must fit the daily spend caps and the fee ceilings. Budgets & Losing Calls has every rule.
  6. Send and book. The transaction goes out with priority fee 0 (Arbitrum’s sequencer orders transactions first come, first served). When its receipt arrives, the reward and claim it logged are written with the gas it actually cost to the earnings ledger, which bazaar keeper earnings reads.

A dry run (--dry-run) takes the same path and stops before sending, so what it reports is what a real run would do.

What You Need

RequirementWhyNotes
Node 20 or laterthe keeper is a Node programor Docker (Running in Production)
An Arbitrum RPC with eth_call state overridesthe probe is placed by a state override; reads are batched through itWithout overrides calls are simulated directly: reverts are still caught, but the reward is unknown, so paid jobs skip. bazaar keeper doctor checks. The node must also answer eth_getLogs (refused ranges are split automatically) and, when there is no subgraph and no startBlock, historical eth_getCode reads
A Pyth Pro price endpointevery price-touching call carries a signed Pyth Pro update, which Pyth’s verifier checks on chainpyth with your own Pyth Pro access token, or the URL of a Pyth Pro compatible service. Bazaar’s shared endpoint (bazaar) has no URLs yet. The keeper streams over a WebSocket (the ws package, so Node 20 works)
ETH on Arbitrumgas, and Pyth’s update feeno keeper call needs USDC or an approval
A walletto signan encrypted keystore (recommended), a private key, a mnemonic, or a remote signer (Wallets & Secrets). Without one the keeper is watch-only
The deployment’s factory addresseverything else is read from itBazaar’s network presets carry no official factory address yet, so the config names one
A subgraph (optional)a fast first startwithout one the first start replays Bazaar’s logs from the factory’s deployment block

How Fast It Is

Liquidations and trigger executions race other keepers, and what makes them due is usually a price move. So besides the tick, these two jobs plan a market the moment a newer print for one of its legs arrives. Each print plans only the markets whose feeds moved, checks every position or order against the live price locally, and makes RPC requests only for real candidates. The package README gives a median of 0.5 ms from print to planned with 200 markets of 2,000 positions each, and the end-to-end test reports the keeper’s latency from a price move to its send.

Prints and the tick share one plan per job and market at a time: a print that arrives during a plan queues one more plan, which then judges by the newest print. While the index is catching up (a sync step 64 or more blocks behind the head, as after a restart), the print path waits, so it never plans from a half-replayed index.

Latency wins races on Arbitrum, not fees. To be fast:

  • give the latency-critical jobs (liquidation, triggers) a wallet of their own, so their sends never wait behind another job’s nonce (Wallets & Secrets);
  • on Arbitrum One, submit straight to the sequencer with rpc.send: sequencer;
  • run close to your RPC and price endpoint.

Setting Up a Keeper

This page takes you from nothing to a running keeper: install it, answer the setup wizard, check every connection with doctor, fund the wallet, start it, and read what it is doing. It assumes Arbitrum Sepolia; the steps are the same on Arbitrum One. To try everything on a local chain first, see Testing Locally with Anvil.

Before you start, have at hand:

  • an Arbitrum RPC URL that supports eth_call state overrides (most hosted providers’ URLs carry your API key; the wizard keeps them out of the config file);
  • a Pyth Pro access token (from Pyth; a Pyth Core or Hermes key does not work), or the URL of another Pyth Pro compatible price service;
  • the factory address of the Bazaar deployment you want to keep (the network presets carry no official address yet);
  • a little ETH on that network for gas.

1. Install

The keeper needs Node 20 or later. Once the packages are published:

npm install -g @liberta-labs/bazaar-keeper
bazaar --help

Until then, install from the repository and run the built CLI with Node:

git clone https://github.com/liberta-labs/bazaar-sdk && cd bazaar-sdk
npm install && npm run build
alias bazaar="node $PWD/packages/keeper/dist/cli/main.js"
bazaar --help

The rest of this guide writes bazaar. Installation covers the SDK package as well. To run in a container instead, see Running in Production: the wizard runs inside the container the same way.

Make a directory for the keeper and work in it. Everything the keeper keeps (its config, .env, keystore and state) ends up there:

mkdir -p ~/keeper && cd ~/keeper

2. Answer the Wizard: bazaar keeper init

bazaar keeper init

The wizard asks what it cannot discover, checks each answer as you give it, and writes two files: bazaar-keeper.yaml (or the path given with -c) and a .env beside it. Secrets and every URL you type go to .env only; the YAML refers to them as ${NAME}. It needs an interactive terminal: run without one, it stops with init needs an interactive terminal (copy bazaar-keeper.example.yaml and edit it instead). Ctrl-C at any question cancels and writes no config.

If the config file already exists, the first question is whether to replace it (default no; answering no leaves everything as it was).

Network

AnswerWrites
Arbitrum Sepolia (testnet)network: arbitrum-sepolia
Arbitrum Onenetwork: arbitrum-one
Local Anvilnetwork: anvil
Custom factory addressnetwork: custom, then asks the chain id and writes chainId

Factory Address

Asked for a custom network, and for any preset without an official factory (at present all of them). It must be an address; it is written as factory: "0x…". Everything else (the lens, the terminator, the oracle, the markets and their feeds) is read from the factory.

For a custom network the wizard then asks the factory’s deployment block. Leave it empty and the keeper finds the block on chain when it needs it (a first start without a subgraph); give it and it is written as startBlock. See Configuration Reference.

RPC URL

Your node’s HTTPS URL (on Anvil the default is http://127.0.0.1:8545). The wizard calls eth_chainId and asks again until the URL answers with the network’s chain id, printing what went wrong (✘ that RPC is on chain 1, not 421614). It writes rpc.http: ${RPC_HTTP_URL} and puts the URL in .env as RPC_HTTP_URL.

On Arbitrum One, which has a sequencer endpoint preset, it then asks whether to submit transactions straight to the sequencer (default yes), which writes rpc.send: sequencer.

Subgraph

AnswerWrites
Bazaar’s subgraph for this network (offered only when the network preset has one; none does yet)subgraph.url: default
None: index from chain logssubgraph.url: none
Custom URLsubgraph.url: ${SUBGRAPH_URL}, the URL in .env

The subgraph only makes the first start faster. Without one, the first start replays every Bazaar log from the factory’s deployment block; later starts resume from the snapshot either way.

Price Endpoint

AnswerAsksWrites
Pyth Pro, with my own access tokenthe token (hidden)prices.endpoint: pyth, prices.token: ${PYTH_PRO_TOKEN}
Custom Pyth Pro compatible URL (bazaar mock-prices, a proxy)the URL (on Anvil, default http://127.0.0.1:8787), then whether it needs an access token, and the tokenprices.endpoint: ${PRICE_ENDPOINT_URL}, and prices.token: ${PRICE_TOKEN} if it needs one
Bazaar shared endpoint(not selectable: it has no URLs yet)

It then reads an ETH/USD print (Pyth Pro feed 2) from the request API to check the endpoint and the token: ✔ ETH/USD print 3,012.45, or a warning (⚠ could not read a print yet: …) that does not stop the wizard. The channel is left at its default, fixed_rate@200ms; set prices.channel to change it (Configuration).

Keeper Wallet

AnswerWhat happens
Create a new walletgenerates a key, asks for a keystore password twice, and writes an encrypted keystore to keys/default.json beside the config (default-2.json and so on if that exists)
Import a private keyasks for the key (hidden, checked), then a password twice, and writes it the same way
Use an existing keystore fileasks for its path, which must exist; nothing is written
No wallet: watch-onlyno wallets section: the keeper plans and simulates, and sends nothing

A new keystore prints its address: ✔ 0x14dC…9955 encrypted at /home/me/keeper/keys/default.json. Fund it with a little ETH for gas. The config gets:

wallets:
  default:
    type: keystore
    path: /home/me/keeper/keys/default.json

Then it asks whether to store the keystore password in .env (default yes). Yes adds password: ${KEEPER_KEYSTORE_PASSWORD} and puts the password in .env; for an existing keystore it asks for the password first (it does not check it: doctor will). No means the keeper asks for the password at every start, which only works in a terminal: a service or a detached container cannot answer. See Wallets & Secrets.

Jobs

A checklist of the eight jobs, each with what it pays. All are checked except sweepExpired, which pays nothing. If you pick any job besides collect, the wizard lists the factory’s markets.

Losses

If you picked a job that can lose money (anything but collect and sweepExpired), it asks whether any job should also send calls that cost more than they earn. Default no. Yes asks which jobs, the largest loss per call (default 0.05 USD) and the daily loss budget per job (default 1), and writes allowLoss: true, maxLossUsdPerCall and lossBudgetUsdPerDay under each chosen job. See Budgets & Losing Calls.

Markets for Each Job

For each picked job except collect, once markets were found:

AnswerWrites
All markets, including future listingsmarkets: all
Choose markets…the chosen markets’ addresses, each with its label as a YAML comment (choosing none means all)
Same as the previous jobthe previous job’s choice

Chosen markets are written by address because labels are not unique: a market relisted after a termination keeps its label, and a composite is labelled after its base. If the markets could not be listed, the wizard warns and every job runs on all markets.

Collect

If you picked collect: the balance at which a market is claimed (default 5 USD, written as thresholdUsd), and optionally an address to forward collected USDC to (forwardTo).

What It Writes

The config ends with state.dir set to state/ beside it, so the snapshot, the earnings ledger and the status file live in the same directory. Paths are written absolute, so the keeper can be started from anywhere. A typical result:

# Written by bazaar keeper init. Secrets live in .env next to this file.

network: arbitrum-sepolia
factory: "0x0000000000000000000000000000000000000000"

rpc:
  http: ${RPC_HTTP_URL}

subgraph:
  url: none

prices:
  endpoint: pyth
  token: ${PYTH_PRO_TOKEN}

wallets:
  default:
    type: keystore
    path: /home/me/keeper/keys/default.json
    password: ${KEEPER_KEYSTORE_PASSWORD}

jobs:
  liquidation:
    markets: all
  triggers:
    markets: all
  unload:
    markets: all
  adl:
    markets: all
  refresh:
    markets: all
  terminalSettlement:
    markets: all
  collect:
    thresholdUsd: 5

# The snapshot, the earnings ledger and the status file, so a restart resumes where it stopped.
state:
  dir: /home/me/keeper/state

and the .env beside it, owner-only (mode 0600) where the file system has POSIX permissions:

RPC_HTTP_URL=https://arb-sepolia.g.alchemy.com/v2/your-key
PYTH_PRO_TOKEN=your-pyth-pro-token
KEEPER_KEYSTORE_PASSWORD=your-password

Running init again over an existing .env keeps the NAME=value lines already there (comments are dropped) and replaces or adds the new ones. A job with nothing under it is written as job: {}: a job key with an empty value reads as null, which turns the job off.

The wizard ends with Next: bazaar keeper doctor, then bazaar keeper start.

3. Check Everything: bazaar keeper doctor

bazaar keeper doctor

doctor loads the config and the wallets (a keystore without a stored password is asked for here), then checks everything start relies on, one line each. It exits 0 when nothing failed (warnings allowed) and 1 otherwise. A config file that does not load (invalid YAML, a secret written into it, an unknown key) prints one line and exits 2. URLs and keys are masked in everything it prints.

✔ RPC            chain 421614, latency 84 ms, eth_call state overrides supported
✔ Factory        0x0000000000000000000000000000000000000000 not an official deployment, 3 market(s)
✔ Wiring         lens and terminator both point back at this factory
⚠ Subgraph       none configured: the first start indexes from chain logs
✔ Prices         3/3 markets have a print
✔ Prices         the fixed_rate@200ms stream is delivering
✔ Wallet default 0x14dC79964da2C08b23698B3D3cc7Ca32193d9955, 0.02000 ETH (roughly 3,333 calls at today's base fee)
✔ Jobs           liquidation, triggers, unload, adl, refresh, collect, terminalSettlement
LineWhat it checksWhen it fails
Configthe keeper can be built: the network, rpc.send, the price endpoint, the wallets (keystores decrypted)✘ Config with the reason, and nothing else is checked
RPCeth_chainId and its latency; whether eth_call state overrides workunreachable, or on another chain: stops here. No overrides is a warning: paid jobs will skip
Factorythe deployment can be read from the factory, and how many markets it lists; whether it is an official deploymentthe factory cannot be read (wrong address, no code): stops here
Wiringthe lens and the terminator report this factoryeither belongs to a different factory or reports none (start refuses to run)
Subgraphhow far the subgraph is synced and how far behind the head; indexing errorsunreachable: the keeper falls back to chain logs
Pricesa print for every market’s legs (plus ETH/USD) from the request API; which markets are not fresh right now (a closed session); whether a fresh, plausible ETH/USD print exists to price gas; then, with prices.stream on, whether the stream brings an ETH/USD print within 5 sthe request API fails (a bad token, a wrong URL). A stream that brings nothing is a warning: the keeper would poll the request API instead
Walleteach wallet’s address, ETH balance, and roughly how many calls it pays for at the current base fee (600,000 gas each)the balance is below budget.minEthBalance
Jobsthe enabled jobs; each job’s markets resolve; warnings for jobs that take losses and for sweepExpireda job names a market no market carries

Fix every ✘ before starting; Troubleshooting has each message and its fix.

4. Fund the Wallet

Send ETH on the keeper’s network to the wallet address doctor printed. No keeper call needs USDC or a token approval: gas and Pyth’s small update fee are paid in ETH, and bounties accrue to the wallet’s claimable balance on each market.

How much: Arbitrum gas is cheap, and the keeper never spends more than budget.maxSpendUsdPerDay (and budget.maxSpendEthPerDay) in a day. Keep comfortably more than budget.minEthBalance (0.002 ETH by default): below it, the keeper stops sending until the wallet is funded again. bazaar wallet list shows each wallet’s ETH and what it can claim.

5. Start

bazaar keeper start

The first start connects, checks the chain and the factory’s wiring, and builds its index: from the subgraph if one is configured, or by replaying the chain’s logs from the factory’s deployment block. Without startBlock, it first finds that block with about 30 historical eth_getCode reads. The log shows each step:

12:00:01 INFO    found the factory's deployment block, 123456789 (set startBlock: 123456789 in the config to skip the search)
12:00:01 INFO    indexing from chain logs, blocks 123456789 to 123987654
12:00:41 WARNING factory 0x0000000000000000000000000000000000000000 is not an official deployment
12:00:41 INFO    keeper running on arbitrum-sepolia (3 markets, jobs: liquidation, triggers, unload, adl, refresh, collect, terminalSettlement)

From then on it logs each sent call and its result, and each distinct skip reason at most every five minutes (the rest at debug level):

12:03:17 INFO    liquidation ETH/USD: sent 0x5c1e…a9 (earns $0.1000 for $0.0021 of gas)
12:03:18 INFO    liquidation ETH/USD: liquidate 2 account(s), $4,100 notional confirmed, earned $0.1000 for $0.0019
12:05:00 INFO    refresh BTC/USD: skipped refresh a quiet market's price: below the profit rule: earns $0.1000 for $0.1100 of gas, needs ×1.25 and $0.001 net (allowLoss is off)

To try it without sending anything, start with --dry-run: every call is planned and simulated, and logged as it would be sent. --only liquidation,triggers runs just those jobs.

The first Ctrl-C (or SIGTERM) stops cleanly: in-flight transactions get their receipts, collect claims your balances if its onShutdown is on (the default), and the snapshot is saved. A second Ctrl-C leaves at once. The next start resumes from the snapshot, replaying only the blocks it missed.

6. Read the Status

From another terminal, in the same directory (or with the same -c):

bazaar keeper status
Bazaar keeper · arbitrum-sepolia · block 123,987,700 · indexed 123,987,699 · prices live · running
factory 0x0000000000000000000000000000000000000000 is not an official deployment
 JOB                 MARKETS  LAST ACTION                                                  TODAY EARNED    GAS    NET
 liquidation               3  ETH/USD liquidate 2 account(s), $4,100 notional (confirmed)         $0.10  $0.00  $0.10
 triggers                  3  -                                                                   $0.00  $0.00  $0.00
 refresh                   3  -                                                                   $0.00  $0.00  $0.00
 collect                 all  -                                                                   $0.00  $0.00  $0.00
 WALLET   ADDRESS
 default  0x14dC79964da2C08b23698B3D3cc7Ca32193d9955

status reads the status.json the running keeper rewrites every 5 seconds; it does not connect to anything. The header shows the network, the chain’s head and the last block indexed, whether prices are live (or prices down: with the endpoint’s last error), and running, or not running (last seen N min ago) when the file is more than 30 seconds old. A red line under it means sending is halted (a wallet below budget.minEthBalance). Each job’s row counts the markets it covers (all for collect), its last sent call, and the last 24 hours from the ledger.

For longer periods, bazaar keeper earnings --since 7d sums the ledger by job or by market (Commands).

Next

Wallets & Secrets

The keeper signs its transactions with wallets you configure under wallets:, each under a name of your choosing. Jobs send from the wallet named default unless their wallet key names another. This page covers every wallet type, the bazaar wallet commands, and the rules that keep keys, passwords and API keys out of the config file.

Wallet Types

wallets:
  default:
    type: keystore                  # keystore | privateKey | mnemonic | remote
    path: ~/.bazaar/keys/default.json
    password: ${KEEPER_KEYSTORE_PASSWORD}
typeNeedsOptionalWhat it is
keystorepathpasswordan encrypted JSON keystore (version 3). Recommended: the key is encrypted at rest, and only the password needs protecting
privateKeykeya raw 32-byte private key, hex with or without 0x, from the environment
mnemonicphraseindex (default 0)a BIP39 phrase; the key is derived at m/44'/60'/0'/0/{index}
remoteurl, addressany signer that answers eth_signTransaction over JSON-RPC (Web3Signer, a KMS bridge)

A wallet missing what its type needs is a config error (wallets.default: a keystore wallet needs path). With no wallets section at all, the keeper is watch-only: it plans and simulates every call, logs what it would send, and sends nothing.

Keystore

path may start with ~/, which is your home directory. The keystore is decrypted once, when the keeper starts (Keeper.create in the library), so a wrong password fails at once with wallet default: wrong keystore password.

password is the keystore’s password, as a ${NAME} reference to .env or the environment. Left out, the keeper asks for it at start (Password for wallet default (default.json):) when it runs in a terminal. Without a terminal (a service, a detached container) it cannot ask, and stops with:

wallet default (default.json) needs a password: set wallets.default.password: ${KEEPER_KEYSTORE_PASSWORD} in the config and KEEPER_KEYSTORE_PASSWORD in .env or the environment, or start the keeper in a terminal to be asked for it

Private Key

wallets:
  fast:
    type: privateKey
    key: ${KEEPER_FAST_KEY}

The key must be 32 bytes of hex within the secp256k1 range. An invalid key fails with wallet fast: not a valid private key: …, which never quotes the key.

Mnemonic

wallets:
  second:
    type: mnemonic
    phrase: ${KEEPER_MNEMONIC}
    index: 1

The phrase is checked as eth-account checks it: 12, 15, 18, 21 or 24 words, every word from one BIP39 word list (English, Czech, French, Italian, Japanese, Korean, Portuguese, Spanish, simplified or traditional Chinese), and a matching checksum. Errors name the word’s position, never the word. index picks the account: 0 is the first address a wallet app shows for that phrase. BIP39 passphrases are not supported.

Remote Signer

wallets:
  default:
    type: remote
    url: ${SIGNER_URL}
    address: "0x14dC79964da2C08b23698B3D3cc7Ca32193d9955"

For each transaction the keeper posts one JSON-RPC request, eth_signTransaction, with an EIP-1559 transaction (type: 0x2, chainId, nonce, to, value, data, gas, maxFeePerGas, maxPriorityFeePerGas, from), and expects the signed raw transaction as result (a hex string, or an object with a raw field). It waits 15 seconds. The keeper then parses what came back and refuses it, sending nothing, unless it is exactly the transaction asked for (every field the same, no access list) and recovers to the configured address. The URL is masked in every message.

The bazaar wallet Commands

bazaar wallet new

Creates a random key and saves it as an encrypted keystore. It asks for a password twice.

bazaar wallet new                      # ~/.bazaar/keys/default.json
bazaar wallet new --name fast          # ~/.bazaar/keys/fast.json
bazaar wallet new --path ./keys/fast.json
✔ 0x14dC79964da2C08b23698B3D3cc7Ca32193d9955 saved encrypted at /home/me/.bazaar/keys/fast.json
Fund it with a little ETH for gas. No keeper call needs USDC or an approval.
In bazaar-keeper.yaml:
  wallets:
    fast:
      type: keystore
      path: /home/me/.bazaar/keys/fast.json
      password: ${KEEPER_KEYSTORE_PASSWORD}   # in .env, or leave out to be asked at start

It never overwrites a file (… already exists). Cancelling at a prompt writes nothing.

bazaar wallet import

The same, for a private key you paste (hidden as you type). A bad key fails before you choose a password.

bazaar wallet import --name fast

bazaar wallet list

Reads the config, loads every wallet (prompting for keystore passwords that are not stored), and prints each one’s ETH balance and what it can claim across the markets:

bazaar wallet list -c ~/keeper/bazaar-keeper.yaml
 WALLET   ADDRESS                                         ETH  CLAIMABLE
 default  0x14dC79964da2C08b23698B3D3cc7Ca32193d9955  0.02000  $12.40 across 2 market(s)
 fast     0x23618e81E3f5cdF7f54C3d65f7FBc0aBf5B21E8f  0.01000  $3.15 across 1 market(s)

list connects like start does, so with no snapshot yet it builds the index first.

Keystore Compatibility

Keystores are Ethereum’s JSON keystore, version 3, as geth and eth-account read and write them:

  • Reading: scrypt or PBKDF2 (HMAC-SHA256 or HMAC-SHA512) key derivation, AES-128-CTR, the MAC checked in constant time before anything is decrypted. Key names are matched without case, so old geth files with "Crypto" load too.
  • Writing: scrypt with n = 2^18, r = 8, p = 1, a 16-byte salt and a random 16-byte IV, in eth-account’s field order, written owner-only (0600) and never over an existing file.

So a version 3 keystore from geth, Foundry (cast wallet new) or the Python keeper opens here, and one written here opens in any tool that reads version 3 keystores.

Passwords and .env

The keeper reads .env from the config file’s directory before it reads the config. Variables already in the environment win over .env. bazaar keeper init writes .env owner-only (0600) and asks before putting the keystore password there.

Where the password livesStarts unattendedTrade-off
.env beside the config (password: ${KEEPER_KEYSTORE_PASSWORD})yesanyone who can read .env and the keystore has the key: keep the directory private
the service’s environment (systemd EnvironmentFile, a container secret, a secret manager that exports it)yesthe same, with the file kept elsewhere
left out, typed at each startnonothing on disk but the encrypted keystore

A hot wallet holds only what it needs: a little ETH for gas, and the bounties it earned until collect claims them. Use collect.forwardTo to move claimed USDC to a cold address (The Jobs).

The Secrets Rules

The config file is meant to be shareable: you can commit it, paste it into an issue, or show it on screen. So the loader refuses to read a config that holds a secret, and says how to fix it.

Secret Keys

The values of these keys, wherever they appear, must be a single ${NAME} reference (or be left out):

KeyWhere
keywallets.<name>.key
passwordwallets.<name>.password
phrasewallets.<name>.phrase
tokenprices.token
apiKeysubgraph.apiKey
botTokenalerts.telegram.botToken
webhookalerts.discord.webhook
prices.token holds a secret written into the file (a ${NAME:-default} default counts). Put it in .env as a variable and write token: ${NAME} here instead

URLs That Carry Keys

Hosted RPCs, indexers and price services often put the API key inside the URL: Alchemy’s /v2/<key>, Infura’s /v3/<id>, QuickNode’s /<token>/, a ?key= query, user:password@ before the host. The loader refuses any URL written into the file that may carry a credential, under any key. It judges by structure, not by what a key looks like: a URL passes only when every part of it is known to be public (short host labels or lowercase words joined by -; path segments that are well-known words such as rpc, api or stream, versions, short numbers, or lowercase words joined by - or _; the values of a few public query parameters). Everything else counts as a possible key.

rpc.http is a URL with a key in its path, written into the file. Put the URL in .env as a variable and write ${NAME} here instead, …

Two fixes:

rpc:
  http: ${RPC_HTTP_URL}                                        # the whole URL in .env
  # http: https://arb-mainnet.g.alchemy.com/v2/${ALCHEMY_KEY}  # or only the key in .env

The check runs on the text as written, with each reference’s default in its place, so https://arb-mainnet.g.alchemy.com/v2/${ALCHEMY_KEY} passes (the key is a reference) while a default holding the key does not. When in doubt, put the whole URL in .env, as init does for every URL you type.

${NAME} and ${NAME:-default}

Any string value in the config may hold references, filled from the environment (after .env is loaded):

FormWhen NAME is setWhen it is not
${NAME}its valuean error: rpc.http: environment variable RPC_HTTP_URL is not set (add it to .env or export it)
${NAME:-default}its valuedefault
${NAME:-}its valuethe empty string

A default is text in the file like any other, so a secret key’s value may use ${NAME:-} (empty when unset; an empty subgraph.apiKey sends no key, while an empty prices.token is refused: leave the line out for an endpoint that needs no token) but never ${NAME:-the-secret}. Names are letters, digits and _, not starting with a digit. References are filled in values only, never in keys.

Headers

prices.headers values go to the price endpoint with every request and usually carry its key, so each one is held to the same rule: a ${NAME} reference, optionally after an auth scheme.

prices:
  endpoint: ${PRICE_ENDPOINT_URL}
  headers:
    x-api-key: ${PRICE_HEADER_KEY}
    Authorization: Bearer ${PRICE_TOKEN}

prices.token is the shorter way to send Authorization: Bearer <token>. Neither the token nor the headers go to the symbols registry, which needs no credentials.

What the Keeper Prints

Keys, passwords and phrases never appear in an error or a log line. Every URL the CLI prints passes through the same structural redaction first (https://arb-sepolia.g.alchemy.com/v2/***), and doctor also masks the configured API keys and bot token wherever an error might quote them. Errors print one line; --debug adds the stack trace, also redacted. An address field that receives something the length of a private key says so without echoing it.

Several Wallets

Each wallet is its own nonce sequence: a send takes the wallet’s lock only to assign a nonce and sign, so two jobs on different wallets never wait for each other. Liquidations and trigger executions race other keepers; give them their own wallet so they never queue behind a collect, a sweep or a settlement:

wallets:
  default:
    type: keystore
    path: /home/me/keeper/keys/default.json
    password: ${KEEPER_KEYSTORE_PASSWORD}
  fast:
    type: keystore
    path: /home/me/keeper/keys/fast.json
    password: ${KEEPER_FAST_PASSWORD}

jobs:
  liquidation:
    wallet: fast
  triggers:
    wallet: fast
  unload: {}
  refresh: {}
  collect: {}

A job naming a wallet that is not under wallets is a config error (job liquidation uses wallet 'fast', which is not under wallets).

What every wallet shares:

  • The low-balance halt. The keeper checks every wallet’s ETH once a minute. If any one is below budget.minEthBalance, all sending stops until it is funded. Fund every wallet.
  • The budgets. Daily spend and loss caps count every wallet’s transactions together.
  • Collect. collect claims every wallet’s balances, each from the wallet that earned it, and forwardTo forwards from each wallet.
  • Manual operations send from default, or from the first wallet configured when there is no default.

Your own positions are never liquidated or settled by your keeper: every configured wallet’s address is skipped.

One process per set of wallets. Two keepers signing with the same wallet would fight over its nonces (each would see the other’s transactions take “its” nonce). The keeper recovers from a taken nonce by re-reading it and retrying once, which makes an occasional one-shot bazaar keeper run beside a running keeper workable, but two long-running processes on one wallet are not.

Configuration Reference

The keeper reads one YAML file, bazaar-keeper.yaml by default (-c <path> names another). This page lists every section and every key, with its type, its default and what it does. Defaults are the schema’s (packages/keeper/src/config/schema.ts): a key you leave out takes its default, and so does a whole section.

bazaar keeper init writes a working file for you (Setting Up a Keeper); the repository’s bazaar-keeper.example.yaml shows the common options with comments. A complete annotated example is at the end of this page.

How the File Is Read

  1. The .env file in the config’s directory is loaded into the environment. Variables already set in the environment win.
  2. The YAML is parsed. It must be a mapping.
  3. The secrets rules are checked on the text as written: secret keys must be ${NAME} references, URLs that may carry a key must come from the environment, and prices.headers values must be references.
  4. Every ${NAME} and ${NAME:-default} in a string value is filled from the environment.
  5. The result is validated against the schema. Unknown keys are refused everywhere, so a typo is an error, not a silently ignored setting. Every problem is listed at once:
the config is not valid:
  rpc: Unrecognized key(s) in object: 'chunk'
  wallets.default: a keystore wallet needs path
  config: job liquidation uses wallet 'fast', which is not under wallets

The library’s parseConfig(object) does step 5 only (The Keeper as a Library).

Three YAML details matter here:

  • Quote addresses. YAML reads an unquoted 0x… as a hexadecimal number. Write factory: "0x3Aa5ebB10DC797CAC828524e59A333d0A371443c".
  • An empty job turns it off. liquidation: with nothing under it is null, and a null job is disabled. Write liquidation: {} to enable a job with its defaults.
  • Use block style for references. ${NAME} inside a flow mapping ({ botToken: ${TG_BOT_TOKEN} }) does not parse; write the mapping on separate lines, or quote the value.

Where

KeyTypeDefaultMeaning
networkstringrequiredarbitrum-one, arbitrum-sepolia, anvil, or custom
chainIdintegerthe preset’srequired for custom; on a preset it replaces the preset’s chain id
factoryaddressthe preset’sthe deployment’s factory. Required for custom, and for any preset without an official factory (at present all of them)
startBlockinteger ≥ 0see belowwhere a first start without a subgraph begins reading logs: the factory’s deployment block
PresetChain idSequencer endpoint (rpc.send: sequencer)Official factory
arbitrum-one42161https://arb1-sequencer.arbitrum.io/rpcnot yet
arbitrum-sepolia421614nonenot yet
anvil31337nonenever (a local chain)

The keeper reads everything else from the factory: the lens, the terminator, the oracle, USDC, every market and its price feeds. It refuses to start if the lens or the terminator belongs to a different factory. A factory that is not a network’s official one is used all the same, with a warning (factory 0x… is not an official deployment) in the log, doctor and status.

startBlock left out. Nothing of Bazaar’s emits a log before its factory exists, so the scan can start at the factory’s deployment block. When startBlock is left out (and no official preset gives it), a first start without a subgraph finds that block on chain: a binary search on eth_getCode at historical blocks, about 30 reads on Arbitrum One. It logs found the factory's deployment block, N (set startBlock: N in the config to skip the search). The search needs an RPC that keeps historical state (hosted providers do). A node that has pruned it cannot answer, and the keeper refuses to start rather than scan from block 0:

could not find the factory's deployment block (…). Set startBlock in the config to the block the factory was deployed in, use an RPC that keeps historical state, or configure a subgraph

startBlock matters only for the first start without a subgraph (or after the state directory is emptied): later starts resume from the snapshot.

rpc

KeyTypeDefaultMeaning
httpstringrequiredthe JSON-RPC URL every read goes to. Usually holds a key: write ${RPC_HTTP_URL}
sendstringhttpwhere transactions are submitted: sequencer for the network’s sequencer endpoint (Arbitrum One only; on another network it is an error), or any URL
maxRequestsPerSecondnumber > 025the client’s rate limit on http
logChunkinteger > 02000blocks per eth_getLogs request

The node must support eth_call state overrides for rewards to be measured (What the Keeper Does). A log range it refuses (too many blocks or logs) is halved and asked for again, down to one block, and widened again after eight successes in a row; a lower logChunk avoids the refused requests. Rate-limit answers (HTTP 429), timeouts and connection failures are not refusals: the sync step fails and the next tick resumes after the last chunk applied.

subgraph

KeyTypeDefaultMeaning
urlstringdefaultdefault (the network preset’s subgraph; no preset has one yet, so this means none), none, or any deployment of Bazaar’s subgraph
apiKeystringnonesent as Authorization: Bearer <key>. A ${NAME} reference

The subgraph only finds accounts and orders for the first start: the keeper reads its _meta block, pages through open positions and live or bounty-holding orders pinned to that block, and tails the chain’s logs from the next block. Every decision is made from chain state. If the subgraph fails for any reason, the first start indexes from chain logs instead. The opportunities scan also uses it to find open termination claims, insurer votes and listings.

prices

Prices come from Pyth Pro: signed updates the keeper attaches to its calls and the pair’s oracle has verified on chain. Pyth Pro needs an access token, which you get from Pyth.

KeyTypeDefaultMeaning
endpointstringbazaarpyth (Pyth’s own Pyth Pro endpoints, with your own token), bazaar (Bazaar’s shared endpoint, which has no URLs yet: choosing it is an error), or the http(s) base URL of a Pyth Pro compatible service
tokenstringnonethe Pyth Pro access token, sent as Authorization: Bearer <token>. A ${NAME} reference
channelreal_time | fixed_rate@50ms | fixed_rate@200ms | fixed_rate@1000msfixed_rate@200msthe channel the keeper streams and polls
streamUrlslist of strings, at least onethe endpoint’sthe WebSocket stream endpoints, in place of the endpoint’s own; the keeper connects to every one
restUrlstringthe endpoint’sthe request API’s base URL (/v1/latest_price, /v1/price), in place of the endpoint’s own
symbolsUrlstringthe endpoint’sthe symbols registry’s base URL (/v1/symbols), in place of the endpoint’s own
headersmap of string{}extra request headers for the request API and the stream connections (a proxy’s, say); each value a ${NAME} reference, optionally after a scheme (Bearer ${NAME})
checkSignersbooleantrueuse only updates signed by a signer the deployment’s Pyth verifier trusts
allowInsecurebooleanfalsesend the token, the headers, and a user name, password or query a URL holds, over plain http/ws to an endpoint on another machine. Refused by default; an endpoint on this machine (exactly localhost, 127.0.0.0/8 or [::1]) never needs it
streambooleantruefollow the WebSocket price stream; when off, poll the request API for the latest price
pollIntervalnumber > 01.0seconds between polls when not streaming
streamIdleTimeoutSecondsnumber ≥ 010a stream that brings no new update for this long is dropped and reconnected; 0 waits forever
sendMarginSeconds0 ≤ number < 31.0a call carries a print only while it is this much younger than the pair’s 3 s limit, so it is still fresh when the transaction lands

The endpoint. pyth is three stream endpoints (wss://pyth-lazer-0.dourolabs.app/v1/stream, and -1 and -2), the request API at https://pyth-lazer.dourolabs.app and the symbols registry at https://pyth.dourolabs.app. A base URL names a service that answers the request API and the symbols registry at that URL and streams at its /v1/stream over ws or wss: http://127.0.0.1:8787 (where bazaar mock-prices listens) means the request API and the registry there and the stream at ws://127.0.0.1:8787/v1/stream. streamUrls, restUrl and symbolsUrl each replace one of the three, over a preset or a base URL. A URL that carries a key is a secret like any other: write it as ${NAME} (the secrets rules).

Plain http and ws. A token or a header sent over plain http or ws can be read by anyone on the path, so the keeper refuses to start when one would go that way to another machine: prices: the price request URL uses http://, which would send the price token and headers unencrypted to another machine: …. The same holds for a user name, a password or a query inside a URL, token or no token (prices: a price stream URL holds a user name, a password or a query and uses ws://, …); no message quotes the URL. Use https and wss. Set allowInsecure: true only for an endpoint on a network you trust end to end (a proxy beside the keeper, say). This machine is exactly localhost, an address in 127.0.0.0/8, or [::1]: a name under .localhost is resolved like any other name, so it counts as another machine. bazaar mock-prices on the same machine needs neither a token nor this.

A compatible service must serve POST /v1/latest_price, POST /v1/price (the one-second channel’s update for a past second, which terminate-scheduled settles with), GET /v1/symbols (feed symbols, for market labels; asked without the token) and the WebSocket stream.

The stream. Updates are judged against the chain’s clock, as their prices are: one stamped more than a minute ahead of it drops the endpoint that sent it. The keeper opens one subscription per watched market, for that market’s feeds together, plus one for ETH/USD (Pyth Pro feed 2, for pricing gas). The signed update a call attaches therefore carries exactly its pair’s feeds, both legs of a composite in one update, and costs one verification fee. The same subscriptions run on every stream endpoint and each update is taken once, from whichever endpoint brings it first; an endpoint that drops is reconnected while the others keep delivering. Three failures in a row mark prices unhealthy and switch a stream to polling for a minute. Each newer print wakes the liquidation and trigger planners. The stream runs on the ws package, on every supported Node version, bounded so an endpoint cannot make the keeper buffer more than it reads: no compression, no redirects, a 10 s handshake timeout, and a frame larger than 3 MiB ends the connection unread (the endpoint is reconnected like any that drops).

The channel. A faster channel shows a move sooner and brings more updates to read. A print is as old as its price, not as the update that carries it: a quiet feed’s updates carry its last price forward, and those read as stale. A feed Pyth does not serve on the chosen channel, or that the token does not cover, gets no print; the keeper warns once per feed and its markets get no price.

checkSigners. Once the deployment is read, the keeper asks its Pyth verifier whether it trusts the signer of each update (isValidSigner, each answer kept for a minute) and takes no price from an update signed by anyone else: a stream endpoint that sends one is dropped and reconnected while the others keep delivering, and a request API response that carries one is refused whole. The chain verifies every update a call carries either way; the ETH/USD print that prices gas reaches no contract, so this is the only check on it, and with it off the keeper warns at start (bazaar keeper doctor says so too). It costs one signature recovery per update taken, which adds up with many markets on a fast channel: turn it off, or pick a slower channel, if the keeper falls behind the stream.

wallets

A map from a name of your choosing to a wallet. Jobs use default unless they name another. Left out, the keeper is watch-only. Wallets & Secrets explains each type.

KeyTypeDefaultMeaning
typekeystore | privateKey | mnemonic | remoterequiredthe kind of wallet
pathstringnonekeystore: the file (~/ is your home directory). Required for keystore
passwordstringnonekeystore: its password, a ${NAME} reference; left out, asked for at start in a terminal
keystringnoneprivateKey: the key, a ${NAME} reference. Required for privateKey
phrasestringnonemnemonic: the phrase, a ${NAME} reference. Required for mnemonic
indexinteger ≥ 00mnemonic: the account, m/44'/60'/0'/0/{index}
urlstringnoneremote: the signer’s JSON-RPC URL. Required for remote
addressaddressnoneremote: the address it signs for. Required for remote

jobs

Each job is switched on by being present (liquidation: {} or with settings), and off by being left out, null, or enabled: false. Every job takes the common keys; most also have their own. The Jobs explains what each one does with them.

Common Keys

KeyTypeDefaultMeaning
enabledbooleantruefalse keeps the job’s settings but turns it off
marketsall | list of stringsallthe markets it runs on. all includes future listings. A list names markets by address, pair id, or label or description (case-insensitive)
excludelist of strings[]markets it never runs on, named the same ways
walletstringdefaultthe wallet it sends from; must be under wallets (when there are any)
dryRunbooleanfalseplan and simulate, never send (as start --dry-run does for every job)
minProfitUsdnumberprofit.minProfitUsdthis job’s profit rule
gasSafetyMultipliernumberprofit.gasSafetyMultiplierthis job’s profit rule
allowLossbooleanfalse (true for sweepExpired)also send calls that cost more than they earn, within the caps below
maxLossUsdPerCallnumber ≥ 00.05the most one losing call may lose
lossBudgetUsdPerDaynumber ≥ 01.0 (0.25 for sweepExpired)the most this job may lose in 24 hours; also the budget for its unpaid duties (finalize, health pokes)

Naming markets. An address names one market and must be one of the factory’s (0x… is not a market of this factory). A label such as BTC/USD names every market carrying it: labels are not unique, since a market relisted after a termination keeps its label and a composite is labelled after its base. Such a label covers all of them, each job acting only on those in a market state it runs in, and the keeper logs a warning naming their addresses. A name no market carries stops the start with no market called 'X'. Markets: …. bazaar keeper markets lists every market with its label and address.

liquidation

KeyTypeDefaultMeaning
watchBufferPctnumber ≥ 03.0positions within this many percent of their maintenance requirement count as at risk in status.json (the atRisk field); it does not change what is liquidated
maxAccountsPerCallinteger > 040accounts per liquidate call; more are split into several calls

triggers

KeyTypeDefaultMeaning
minBountyUsdnumber ≥ 00.01orders with a smaller bounty are not fired
includeUnbountiedbooleanfalsealso fire orders with no bounty at all (they pay nothing, so they send only with allowLoss)

unload

KeyTypeDefaultMeaning
maxStepsinteger, 1 to 3232the book walk’s step budget per unloadVault call
recheckSecondsnumber > 015how often a market holding inventory is simulated again when nothing has changed

adl

KeyTypeDefaultMeaning
maxWinnersPerSideinteger, 1 to 2525candidates per list in an executeAdl batch

refresh

KeyTypeDefaultMeaning
healthPokesbooleanfalsealso pay for unpaid refreshes that would open an ADL window or end a timed-out one. Turning it on is consent to pay for them
healthPokeIntervalSecondsnumber > 060how often each market holding inventory is checked for a health poke
healthPokeMaxCostUsdnumber ≥ 00.1the most one health poke may cost

terminalSettlement

KeyTypeDefaultMeaning
finalizebooleantruefinalize the termination once every position is settled or the 14-day escape opens. finalizeTermination pays nothing: true is consent to pay for it
finalizeMaxCostUsdnumber ≥ 00.5the most one finalize may cost
maxAccountsPerCallinteger > 0100positions settled per call; the next tick takes the rest

collect

KeyTypeDefaultMeaning
thresholdUsdnumber ≥ 05claim a market once a wallet’s balance there reaches this, and covers the claim’s gas × gasSafetyMultiplier
maxAgeDaysnumber > 07a balance first seen longer ago than this is claimed whenever it covers its gas, whatever its size
forwardToaddressnonemove each wallet’s USDC here once it holds forwardMinUsd
forwardMinUsdnumber ≥ 050the USDC balance at which a wallet forwards
onShutdownbooleantrueon a clean stop, claim every balance that covers its gas
intervalSecondsnumber > 0600how often balances are read (sooner, after 5 s, when the logs show a wallet’s balance changed)

sweepExpired

KeyTypeDefaultMeaning
allowLossbooleantruesweepExpired pays nothing, so every call is a loss: the job only runs with this on
lossBudgetUsdPerDaynumber ≥ 00.25the job’s daily budget
minBatchinteger > 010expired orders to wait for before sweeping (finished orders holding a bounty are swept at once)
maxOrdersPerCallinteger > 0100orders per call
includeBountyRefundsbooleantruealso sweep finished orders still holding a bounty, which returns it to the owner

profit

KeyTypeDefaultMeaning
minProfitUsdnumber0.001a paid call is sent when reward − gas ≥ this…
gasSafetyMultipliernumber ≥ 11.25…and reward ≥ gas × this

Each job can override both. Budgets & Losing Calls.

gas

KeyTypeDefaultMeaning
maxFeeGweinumbernonea hard ceiling on the fee per gas: nothing is sent while the base fee is above it, and maxFeePerGas is capped at it
backgroundMaxFeeGweinumber or null0.1the ceiling for background work (collect, forwarding, sweeps, finalize); null removes it
feeMultipliernumber ≥ 12.0maxFeePerGas = base fee × this (priority fee is always 0)
gasLimitMultipliernumber ≥ 11.25gas limit = estimate × this + 10,000
ethUsdFallbacknumber > 03000prices gas when the endpoint serves no fresh, plausible ETH/USD print
ethUsdFloornumber > 01000gas is never priced below this, so a bad feed cannot make calls look free
ethUsdMaxAgeSecondsnumber > 060an older ETH/USD print is not used; ethUsdFallback is

budget

KeyTypeDefaultMeaning
maxSpendUsdPerDaynumber ≥ 010all gas, every job, in the last 24 hours, sends in flight included
maxSpendEthPerDaynumber ≥ 0maxSpendUsdPerDay / gas.ethUsdFloor (0.01 ETH with the defaults)the same cap counted in wei, gas and update fees together, so no price feed can lift it
maxLossUsdPerDaynumber ≥ 02every loss-taking and unpaid call together
minEthBalancenumber ≥ 00.002ETH; while any wallet holds less, all sending stops

Budgets & Losing Calls explains how they combine.

safety

KeyTypeDefaultMeaning
pauseAfterRevertsinteger > 03this many on-chain reverts in a row pause a job on a market…
pauseMinutesnumber > 015…for this long, with a jobPaused alert

A revert on chain means the state changed between the simulation and the block (usually another keeper got there first). A success resets the count.

alerts

KeyTypeDefaultMeaning
telegram{ botToken, chatId }nonebotToken a ${NAME} reference; chatId a string
discord{ webhook }nonethe webhook URL, a ${NAME} reference
webhook{ url }noneany URL; receives POST {"kind", "message", "ts"} as JSON
notifylist[lowBalance, jobPaused, endpointDown, opportunity, dailySummary]which kinds are sent; action (one message per sent call) is the sixth kind

Running in Production describes each kind.

state

KeyTypeDefaultMeaning
dirstring~/.bazaarwhere the keeper keeps its snapshot, earnings ledger and status file, under keeper/<chainId>-<first 8 hex digits of the factory>/

So one state directory can hold several keepers (different networks or factories) side by side. init sets it to state/ beside the config.

log

KeyTypeDefaultMeaning
leveldebug | info | warning | errorinfothe lowest level shown
formatpretty | jsonprettypretty: 12:00:01 INFO message; json: one object per line, {"ts", "level", "logger", "msg", "exc"?}

engine

KeyTypeDefaultMeaning
tickSecondsnumber > 00.5how often the keeper reads the head and new logs and lets every job plan (liquidation and triggers also plan on each new print)
simCooldownSecondsnumber ≥ 02.0how long a candidate the chain refused waits before it is simulated again
idleRefreshSecondsnumber > 05.0how often a market’s state is re-read when no event marked it changed
snapshotSecondsnumber > 060how often the index is saved

A Complete Example

Every section, with the defaults written out where they are worth seeing. Secrets come from .env beside the file.

# ---- Where ----
network: arbitrum-one
factory: "0x0000000000000000000000000000000000000000"  # quoted: YAML reads bare 0x… as a number
startBlock: 123456789            # the factory's deployment block; leave out to have it found on chain

rpc:
  http: ${RPC_HTTP_URL}          # the provider's URL holds its key: it lives in .env
  send: sequencer                # Arbitrum One: submit straight to the sequencer
  maxRequestsPerSecond: 25
  logChunk: 2000                 # blocks per eth_getLogs; refused ranges are halved automatically

subgraph:
  url: ${SUBGRAPH_URL:-none}     # a Bazaar subgraph if SUBGRAPH_URL is set, else index from logs

prices:
  endpoint: pyth                 # Pyth Pro; or ${PRICE_ENDPOINT_URL} for another Pyth Pro compatible service
  token: ${PYTH_PRO_TOKEN}       # your Pyth Pro access token
  channel: fixed_rate@200ms
  checkSigners: true             # only updates signed by a signer the deployment's Pyth verifier trusts
  stream: true
  streamIdleTimeoutSeconds: 10
  sendMarginSeconds: 1.0

# ---- Who ----
wallets:
  default:                       # collect, refresh, unload, settlement, manual operations
    type: keystore
    path: /srv/keeper/keys/default.json
    password: ${KEEPER_KEYSTORE_PASSWORD}
  fast:                          # its own nonce queue for the jobs that race
    type: keystore
    path: /srv/keeper/keys/fast.json
    password: ${KEEPER_FAST_PASSWORD}

# ---- What ----
jobs:
  liquidation:
    wallet: fast
    markets: all
    allowLoss: true              # liquidate even at a small loss: it keeps the market solvent
    maxLossUsdPerCall: 0.10
    lossBudgetUsdPerDay: 2
  triggers:
    wallet: fast
    minBountyUsd: 0.02
  unload: {}                     # {} enables a job with its defaults; an empty key would disable it
  adl: {}
  refresh:
    exclude: ["0x0000000000000000000000000000000000000001"]   # a market's address: never refresh it
    healthPokes: true            # pay for refreshes that open or end an ADL window
    healthPokeMaxCostUsd: 0.10
  terminalSettlement:
    finalize: true               # unpaid, but it releases everyone's payout
    finalizeMaxCostUsd: 0.5
  collect:
    thresholdUsd: 10
    maxAgeDays: 7
    forwardTo: "0x0000000000000000000000000000000000000002"   # a cold wallet
    forwardMinUsd: 100
  sweepExpired:                  # unpaid public service, within its daily budget
    lossBudgetUsdPerDay: 0.25
    minBatch: 10

# ---- How much ----
profit:
  minProfitUsd: 0.001
  gasSafetyMultiplier: 1.25
gas:
  maxFeeGwei: 1                  # never pay more than 1 gwei per gas
  backgroundMaxFeeGwei: 0.1      # collect, sweeps, finalize and forwarding wait for a quiet chain
  feeMultiplier: 2.0
  ethUsdFallback: 3000
  ethUsdFloor: 1000
  ethUsdMaxAgeSeconds: 60
budget:
  maxSpendUsdPerDay: 20
  maxSpendEthPerDay: 0.02        # the same cap in ETH, which no price feed can lift
  maxLossUsdPerDay: 5
  minEthBalance: 0.005
safety:
  pauseAfterReverts: 3
  pauseMinutes: 15

# ---- Around it ----
alerts:
  telegram:
    botToken: ${TG_BOT_TOKEN}
    chatId: "123456789"
  notify: [lowBalance, jobPaused, endpointDown, opportunity, dailySummary]
state:
  dir: /srv/keeper/state
log:
  level: info
  format: json                   # one JSON object per line, for a log collector
engine:
  tickSeconds: 0.5
  simCooldownSeconds: 2.0
  idleRefreshSeconds: 5.0
  snapshotSeconds: 60

The Jobs

This page describes each automatic job in depth: when it plans, what it calls, how it is paid, the keys that change it, the market states it runs in, and its edge cases. Configuration Reference lists every key with its type and default; Budgets & Losing Calls explains the rule that decides whether a planned call is sent.

How Jobs Run

Every engine.tickSeconds (0.5 s) the keeper reads the head, applies the new blocks’ logs to its index, re-reads the state of the markets those logs touched (and of the others every engine.idleRefreshSeconds), and lets every enabled job plan. A plan returns calls; each one goes to the executor on its own, which simulates it, prices it, applies the spend policy and sends it.

  • In-flight keys. Each call carries keys (a position, an order, a market) and is not dispatched while any of them is already in flight, so the tick and the print path never send the same candidate twice. A send with no receipt after 90 seconds keeps its keys for 5 more minutes, in case it still lands.
  • Cooldowns. A candidate the chain refused (a revert in simulation, or a call the policy skipped) waits before it is simulated again: engine.simCooldownSeconds for most jobs, longer for some (each job says below). Nothing brings it back sooner.
  • Pauses. safety.pauseAfterReverts on-chain reverts in a row pause a job on a market for safety.pauseMinutes. Pauses are kept by market label, so markets sharing a label pause together.
  • Your own positions are never liquidated or settled by your keeper.

Market States

A market’s state, read from the chain, gates which jobs plan on it:

StateMeaningJobs that plan
livetrading normallyliquidation, triggers, unload, refresh, sweepExpired, collect
adlan ADL window is open: matching haltsliquidation, adl, refresh, collect
limbofrom 6 s before a scheduled cutoff until the settlement price is fixedcollect
settlingthe settlement price is fixed, not finalizedterminalSettlement, collect
terminatedfinalizedterminalSettlement, collect

A one-shot bazaar keeper run on a market in another state is skipped with the market is settling; this job runs while it is live.

Prices

A price-touching call judged at a fresh print carries that print’s signed Pyth Pro update: the update the market’s own subscription delivers, which carries exactly those feeds (both legs of a composite in one), so the call pays one verification fee. A call carries a print only while it is fresh enough to send: its price generated within the pair’s 3 s limit less prices.sendMarginSeconds, with confidence within the pair’s cap. The oracle’s fee for the update is sent as the call’s ETH value (and counts as its cost).

Nothing on chain holds a price but the pair itself. An update a call carries is verified and must be fresh: one that is stale or that Pyth’s verifier refuses reverts the call, so the keeper never attaches one it would not send. A call without an update runs on the pair’s stored price while that is at most 3 s old.

Planning, the simulation and the wallet’s queue all take time, so the price a call relies on (the print’s, or the stored price it is judged at) is checked once more just before the call is signed, against the chain’s time: one older than the pair’s 3 s limit less prices.sendMarginSeconds is not sent. Nothing refused the candidate, so it does not cool down: the next print plans it again.

A market with trading hours gets no fresh print while its venue is closed: Pyth’s updates keep arriving, but they carry the last price forward, and a price is as old as when it was generated. So only the jobs that work without a fresh print still act there: liquidation (judging by the stored price), sweepExpired, terminal settlement and collect.

A print without a confidence is never attached. The oracle reads a zero confidence as a missing one and reverts the call, so a market whose feed reports none is planned as one without a fresh print, and no confidence is made up for it.

The Price a Call Is Judged At

A pair stores the price a call carries only when that price was generated in a later second than the price it holds (for a composite, when its older leg was). Otherwise it judges the call at the price it holds: within one second, the first price stored stands.

The planners judge the same way. While a market’s stored price is from the print’s second or a later one, liquidation, triggers and ADL judge at the stored price:

  • a candidate that crosses only at the newer print is left for the next second’s print, with no simulation and no cooldown spent on it;
  • a candidate that crosses at the stored price is planned even though the print has moved back.

Such a call carries no update. The chain’s stored price is at least as new as the one the keeper read, and it never goes back, so the pair could never store that print: attaching it would only cost its fee, and revert the call once the print aged past the pair’s limit, where the call without it still runs on the stored price. No refresh bounty is planned in that second, since the pair would store nothing.

Liquidation

Plans on every newer print for one of the market’s legs, and on every tick. For each open position in the index it computes equity at the price the call would be judged at and the market’s projected funding index, against the effective maintenance rate (the lower of the market’s MMR and its non-zero 24-hour-lagged MMR). This runs on every position on every print and allocates nothing, so a print costs no RPC requests until a position crosses.

Re-reads each crossing account’s position bucket from the chain (alongside the update fee), updates the index, and keeps the accounts still below their requirement. A bucket found empty is dropped from the index.

Calls liquidate(accounts, priceUpdate), every confirmed account in one batch of up to maxAccountsPerCall (40), more batches for more accounts.

Is paid by the insurance fund: $0.05 per position seized, or max($0.05, 2 bp of the notional it retires) for a seizure that nets the vault’s two buckets, plus whatever 2 bp of the call’s inline unload exceeds that; an account worth less than $5 pays at most its own equity; capped at the fund’s free capital. Batching earns the same as splitting, for less gas. The probe measures the exact amount.

KeyEffect
maxAccountsPerCallaccounts per call
watchBufferPctonly the atRisk count in status.json
allowLoss, maxLossUsdPerCall, lossBudgetUsdPerDayliquidate even when gas costs more than the reward (Budgets)

Runs in live and adl.

Edge cases

  • Closed sessions. With no fresh print, a market with trading hours is judged at its stored price if that price is less than 9.5 minutes old (the pair accepts one up to 10 minutes old), and the call carries no update. Older than that, nothing is planned until the market reopens.
  • A refused account cools for engine.simCooldownSeconds (2 s).

Triggers

Plans on every newer print and on every tick. A candidate is a StopLimit, StopLoss or TakeProfit order that the index knows, that is not finished and not resting in the book, is unexpired at the next block, holds at least minBountyUsd of bounty (or none, with includeUnbountied), and whose trigger the live price reached (inclusive at the trigger; stops fire on a rise for buys and a fall for sells, take-profits the other way).

Re-reads each candidate’s order record. It drops orders that are cancelled, filled, now in the book or expired, and orders that rested in the current block: an execution in the block a stale-price creation left the order waiting in pays the bounty to the owner, not to you.

Calls executeTriggeredOrders(execs, priceUpdate) with up to 20 orders, highest bounty first. Each StopLimit gets a findPredecessor hint for where its limit rests once fired.

Is paid by the order’s owner, through the bounty escrowed at creation: in proportion to the size your call fills out of what remained, and the whole remainder once the order completes or rests.

KeyEffect
minBountyUsdthe smallest bounty worth a candidate
includeUnbountiedalso consider orders with no bounty (they pay nothing, so they send only with allowLoss)

Runs in live. A stale price can neither arm a stop nor bound its fill, so a closed market gets no trigger executions.

Edge cases: a refused batch cools for engine.simCooldownSeconds; a fired StopLimit the book could not finish stays live and is worth its unpaid remainder.

Unload

Plans on a market whose vault holds inventory in either bucket, at most every recheckSeconds (15 s), and sooner when the logs show the book changed or a liquidation happened on that market.

Calls unloadVault(maxSteps, priceUpdate), simulated first and sent only if it sells something (the book has nothing the vault may sell into at this price otherwise).

Is paid by the insurance fund: 2 bp of what it sells, raised to a $0.25 floor when one call sells $1,250 or more or clears the inventory.

KeyEffect
maxStepsthe walk’s step budget (1 to 32)
recheckSecondshow often a quiet market is re-simulated

Runs in live, with a fresh print (against a stale stored price the walk sells nothing and pays nothing).

Edge case: the floor slide. Bids within min(5%, MMR) of the oracle fill at once. A bid beyond that edge but above the estates’ bankruptcy price fills only once the vault’s floor has slid down to it, which takes up to 3 minutes from the bucket’s seizure stamp. So while a bucket is inside its slide, the job also re-checks one second after the slide completes, even if nothing else changed.

ADL

Plans while the market’s ADL window is open, on every tick, with a fresh print. It scores the positions that would close: shorts against the vault’s long bucket (while it holds inventory) and longs against the short bucket (likewise), counting only positions in profit at the live price. Each score is PnL over collateral less this window’s tagged deposit (read from the chain), floored at 1.

Keeps candidates whose score is at least the auction’s Dutch threshold, which falls quadratically from 25 to 1 over the 10-minute auction: 1 + 25e18 × (D − t)² / D² with D = 600 s and t the time since the auction started.

Calls executeAdl(longBucketWinners, shortBucketWinners, priceUpdate) with the top maxWinnersPerSide (25) of each list; the pair ranks and sorts each list itself at the price the call stores, so a newer tick re-ranks but never reverts the batch.

Is paid by the insurance fund: 2 bp at the mark of every unit a batch closes, or 0.1% of the bad debt that close averts where larger, plus 2 bp of the overlap the batch’s netting retires; capped at free capital.

KeyEffect
maxWinnersPerSidecandidates per list (1 to 25)

Runs in adl only. ADL refuses the stored price, so it needs a fresh print. A refused batch cools for engine.simCooldownSeconds. Bazaar’s ADL page explains the auction.

Refresh

Plans on every tick, with a fresh print, when the refresh bounty would pay. The bounty’s gates (RiskParamsLib.payRefreshBounty, copied into the SDK for scheduling; the probe still decides):

  • the print’s price was generated in a later second than the stored price;
  • the stored price is older than 5 minutes while the vault holds at least $1,250 of inventory and the fund more than $5,000 of free capital, or older than 1 day otherwise;
  • the fund’s free capital exceeds $1,000.

Calls refreshPrice(priceUpdate). Is paid $0.10 by the insurance fund.

Why it matters beyond the bounty. A pair whose stored price is more than 21 days old can be ended by anyone at that price (terminate-stale), and one refresh with a fresh update restarts the count. On a quiet market this job is what keeps a pair on a live feed out of reach. Where no bounty pays (a fund with $1,000 or less of free capital) the job plans nothing; bazaar keeper run refresh-price --market … --force sends one refresh regardless of the profit rule.

Health pokes. A refresh also judges the vault’s health: on a book too thin to trade, it is what opens an ADL window (and starts its 24-hour clock) once inherited inventory drifts past the trigger, and what ends a timed-out window by fixing the settlement price. Those refreshes may pay nothing. With healthPokes: true, on a market holding inventory, every healthPokeIntervalSeconds (60 s) the job simulates an unpaid refresh and sends it only if the probe shows it would open the ADL window (it was not pending before) or fix the settlement price. Turning health pokes on is consent to pay for them: each within healthPokeMaxCostUsd (0.10) and the job’s lossBudgetUsdPerDay, and the global budget.maxLossUsdPerDay. Otherwise it is skipped with the refresh would not change the vault's health state.

KeyEffect
healthPokessend unpaid refreshes that change the vault’s health state
healthPokeIntervalSecondshow often each market is checked for one
healthPokeMaxCostUsdthe most one may cost

Runs in live and adl. A refused refresh cools for 10 s.

Terminal Settlement

Plans on every tick on a market whose settlement price is fixed (settling or terminated).

  1. Settle. Every open position in the index, except your own wallets’ (their owner settles them by withdrawing), is re-read from the chain; the ones still open are settled with liquidate(accounts, []), which in settlement mode needs no price update and carries no ETH. One batch of up to maxAccountsPerCall (100) per tick; the next tick takes the rest.
  2. Finalize. With finalize: true (the default), once nothing is left to settle and the market is not yet terminated, and either both sides’ open interest is zero or 14 days have passed since the settlement price was fixed (the escape hatch), it calls finalizeTermination().

Is paid $0.05 per position settled for someone else, from the position’s remaining collateral; the insurance fund tops up only positions worth at least $5 at the settlement price, so a smaller one pays what its collateral covers. Settling is a paid call under the profit rule. Finalize pays nothing: finalize: true is consent to pay for it, within finalizeMaxCostUsd (0.50), the job’s lossBudgetUsdPerDay and budget.maxLossUsdPerDay. It is background work, so it also waits while the base fee is above gas.backgroundMaxFeeGwei.

KeyEffect
finalizefinalize once settled (or past the escape)
finalizeMaxCostUsdthe most one finalize may cost
maxAccountsPerCallpositions per settlement call

Runs in settling and terminated. A refused call cools for 30 s. Finalizing releases everyone’s payout; Bazaar’s Termination page explains the sequence. Terminating a market in the first place is a manual operation.

Sweep Expired

Plans on every tick on live markets, from the index:

  • expired orders: Limit and StopLimit orders, not finished, whose expiry block has passed;
  • refunds (with includeBountyRefunds): finished orders still holding a bounty, which the sweep returns to their owner.

It sends when there is any refund, or when expired orders number at least minBatch (10). It re-reads the orders first, drops what changed, and sweeps up to maxOrdersPerCall (100) with sweepExpired(orderIds).

Pays nothing: it is a public service that keeps books short and returns stranded bounties. So the job ships with allowLoss: true and a $0.25 lossBudgetUsdPerDay, each call within maxLossUsdPerCall (0.05), and it is background work under gas.backgroundMaxFeeGwei. It runs only if you enable it (init leaves it unchecked).

KeyEffect
minBatchexpired orders to wait for
maxOrdersPerCallorders per call
includeBountyRefundsalso sweep finished orders holding a bounty
lossBudgetUsdPerDay, maxLossUsdPerCallwhat the service may cost

Runs in live. A sweep that was not sent waits 5 minutes before the next.

Collect

Bounties accrue to each wallet’s claimable balance on the market that paid them. collect claims them.

Plans every intervalSeconds (600 s), and 5 seconds after the logs show one of your wallets’ claimable balances changed. It reads every wallet’s claimable balances across the markets and plans a claimFees() on each market in its scope where a wallet holds a balance, sent from the wallet that holds it.

Decides by its own rule, not the profit rule (claiming earns nothing new):

  • a balance of at least thresholdUsd (5) that covers the claim’s gas × gasSafetyMultiplier is claimed;
  • a balance first seen more than maxAgeDays (7) ago is claimed whenever it covers its gas, however small. The age counts from when this process first saw the balance, so a restart starts it again;
  • on a clean stop with onShutdown: true (the default), every balance that covers its gas is claimed.

Claims are simulated from the wallet itself (claimFees pays msg.sender), so a watch-only keeper cannot collect. Except on shutdown they are background work under gas.backgroundMaxFeeGwei.

Forwarding. With forwardTo set, each wallet whose USDC balance reaches forwardMinUsd (50) transfers its whole USDC balance to that address. Note that this moves any USDC in the wallet, not only claimed bounties. Forwarding is background work, recorded in the ledger as job forward, and fits the spend caps like any call.

KeyEffect
thresholdUsdthe balance worth a claim
maxAgeDaysclaim older balances regardless of size
forwardTo, forwardMinUsdmove USDC to another address
onShutdownclaim everything on a clean stop
intervalSecondshow often balances are read
markets, excludewhich markets’ balances are claimed
gasSafetyMultiplierthe cover the threshold rule asks for

Runs in every state: claiming works on a terminated market too.

Budgets & Losing Calls

Every call the keeper plans is simulated, priced and then judged by the spend policy: the rules that decide whether it is sent. This page gives every rule, in the order the keeper applies them, and the settings that change each one. The short version: by default only profitable calls are sent; taking losses is something you switch on per job, within caps you set; and every call, however it was decided, must fit the daily spend caps and the fee ceilings.

What a Call Earns and Costs

For each call the executor knows:

  • R, the reward in USD: exactly what the call would add to the wallet’s claimable balance on the market, measured by the probe simulation. Without eth_call state overrides it cannot be measured and counts as 0.
  • C, the cost in USD: the gas from eth_estimateGas of the real transaction from the real wallet, times the current base fee, plus the ETH the call carries (Pyth’s update fee), priced at ETH/USD.
  • the same cost in wei, for the ETH cap.

The policy then decides as follows.

1. The Daily Spend Caps

Every call must fit both caps, whoever decided it should go (the profit rule, a loss you allowed, a job’s own rule, --force, a manual operation):

SettingDefaultWhat it counts
budget.maxSpendUsdPerDay10the USD cost of every call sent in the last 24 hours, all jobs and wallets, plus the calls in flight
budget.maxSpendEthPerDaymaxSpendUsdPerDay / gas.ethUsdFloor (0.01 ETH with the defaults)the same in wei: gas and the update fees the calls carried

The ETH cap is counted in wei, so no ETH/USD price can lift it: a feed reporting ETH at $1 would make gas look free in USD, but not in wei. Set maxSpendEthPerDay yourself to decouple it from the floor.

A call that does not fit is skipped: daily gas budget reached ($9.98 of $10.00 spent) or daily ETH spend cap reached (0.00998 of 0.01 ETH spent; budget.maxSpendEthPerDay). The window slides: a call sent 24 hours ago stops counting. A call whose cost cannot be priced (not a finite number) is refused outright.

2. The Profit Rule

A paid call (one that earns a bounty) is sent when both hold:

R − C ≥ minProfitUsd          (default 0.001)
R ≥ C × gasSafetyMultiplier   (default 1.25)
SettingDefault
profit.minProfitUsd0.001the least net profit worth a call
profit.gasSafetyMultiplier1.25headroom against the gas estimate being low
jobs.<job>.minProfitUsd, jobs.<job>.gasSafetyMultiplierthe profit valuesper-job overrides

Otherwise it is skipped: below the profit rule: earns $0.0400 for $0.0500 of gas, needs ×1.25 and $0.001 net (allowLoss is off), unless the job takes losses.

3. Losing Calls

A keeper may want to send calls that cost more than they earn: liquidating a position before it turns into bad debt, opening an ADL window, or cleaning up books as a public service. That is your choice, per job, and always capped. A call that fails the profit rule is sent only with consent:

  • allowLoss: true on the job, or
  • an unpaid duty switched on by name: terminalSettlement.finalize (default on) or refresh.healthPokes (default off). Turning one on is consent to pay for that call.
jobs:
  liquidation:
    allowLoss: true            # also send calls that cost more than they earn
    maxLossUsdPerCall: 0.10    # never lose more than this on one call
    lossBudgetUsdPerDay: 2     # nor more than this per day for this job
budget:
  maxLossUsdPerDay: 5          # and never more than this across all jobs

The loss, C − R, must then fit three caps:

CapSettingDefault
per calljobs.<job>.maxLossUsdPerCall, or the duty’s own: terminalSettlement.finalizeMaxCostUsd, refresh.healthPokeMaxCostUsd0.05; 0.5 for finalize; 0.1 for health pokes
per job, per dayjobs.<job>.lossBudgetUsdPerDay (the duties count against their job’s)1.0; 0.25 for sweepExpired
all jobs, per daybudget.maxLossUsdPerDay2

Each refusal says which cap: would lose $0.1200, above the $0.1000 per-call cap, liquidation's daily loss budget is spent ($1.98 of $2.00), the global daily loss budget is spent ($2.00 of $2.00). A call that pays nothing and has no consent is skipped with pays nothing, and this job does not take losses (allowLoss is off).

sweepExpired pays nothing ever, so it ships with allowLoss: true and a $0.25 daily budget, and runs only if you enable the job. includeUnbountied trigger orders pay nothing too, so they send only with allowLoss.

Only calls the policy sent as losses count against the loss budgets (their ledger kind is loss or unpaid). A profitable call that turned out to lose on chain (the receipt’s gas higher than the estimate) counts toward the spend caps, not the loss budgets.

4. Collect and Forwarding

collect replaces the profit rule with its own (claiming earns nothing new): claim a balance once it reaches thresholdUsd and covers the gas × gasSafetyMultiplier; claim a balance older than maxAgeDays, or anything on shutdown, whenever it covers the gas. Forwarding sends whenever the wallet’s USDC reaches forwardMinUsd. Both still fit the spend caps and the fee ceilings. The Jobs.

5. The Fee Ceilings

Arbitrum’s sequencer orders transactions first come, first served, so a priority fee buys nothing. The keeper sends with priority fee 0 and maxFeePerGas = base fee × gas.feeMultiplier (2.0), and caps it:

SettingDefaultApplies to
gas.maxFeeGweinoneevery call: nothing is sent while the base fee is above it, and maxFeePerGas is capped at it
gas.backgroundMaxFeeGwei0.1background work (collect claims except on shutdown, forwarding, sweeps, finalize), on top of maxFeeGwei (the lower of the two wins); null removes it

While the base fee is above the ceiling, the call waits: base fee 0.1500 gwei is above the 0.1 gwei ceiling. --force and manual operations keep the ceilings. The gas limit is the estimate × gas.gasLimitMultiplier (1.25) + 10,000.

6. The Low-Balance Halt

The keeper reads every wallet’s ETH balance once a minute (and before the first send of a one-shot run). While any wallet holds less than budget.minEthBalance (0.002 ETH), all sending stops, on every wallet, with a lowBalance alert:

sending paused: fast (0x23618e81E3f5cdF7f54C3d65f7FBc0aBf5B21E8f) holds 0.00140 ETH, below budget.minEthBalance

It resumes on its own once every wallet is funded. Forced calls and manual operations stop too. When a send needs a fresh balance check (the first send, and one-shot runs) and the balances cannot be read, that call is not sent: could not read the wallets' balances, so nothing is sent: ….

Pricing Gas in USD

The USD budgets depend on the ETH/USD price, which never goes on chain, so nothing verifies it. The keeper guards it:

SettingDefault
gas.ethUsdMaxAgeSeconds60an ETH/USD print older than this is not used
gas.ethUsdFallback3000used when there is no fresh print, or the print is outside $100 to $1,000,000
gas.ethUsdFloor1000gas is never priced below this, whatever the print or fallback says

So the price used is the fresh, plausible print, else the fallback, and never below the floor. Each call uses one price for its decision and its ledger entry. doctor warns when no fresh, plausible ETH/USD print is available.

What --force Skips and Keeps

bazaar keeper run <operation> --force (and { force: true } in the library) is for one-shot calls you want sent even when the policy would not:

SkipsKeeps
the profit rulethe simulation: nothing that reverts is ever sent
allowLoss and the per-call, per-job and global loss capsa job’s own check of the simulation (an unload must sell something; a health poke must change the vault’s health)
the daily USD and ETH spend caps
the fee ceilings
the low-balance halt
the in-flight keys: a call the running keeper (in the same process) is already sending is not sent twice

A forced call is booked with kind forced and does not count against the loss budgets; it counts against the spend caps. Manual operations are always sent this way (they pay nothing and run because you asked). For collect, --force means something else: claim every balance that covers its gas, as on shutdown.

Reservations Under Concurrency

Each call is planned and sent in its own task, so many may be deciding at once. The moment a call is decided, its cost (and its loss, if it was sent as one) is reserved in the ledger, with no wait between the decision and the reservation. Every later decision sees it, so concurrent sends cannot together overshoot a budget. When the receipt arrives the reservation becomes the ledger entry, at the actual gas cost; a send that never went out releases it.

A send that got no answer and shows no receipt within 90 seconds is booked as unknown at its estimated cost: it may still land, so it keeps counting, and its candidate stays locked for 5 more minutes.

The Ledger and earnings

Every transaction the keeper sends becomes one line in ledger.jsonl in the state directory (<state.dir>/keeper/<chainId>-<factory>/ledger.jsonl), appended when its receipt arrives:

{"ts": 1762600998.12, "job": "liquidation", "market": "ETH/USD", "wallet": "0x23618e81E3f5cdF7f54C3d65f7FBc0aBf5B21E8f", "tx": "0x5c1e…", "status": "confirmed", "kind": "profit", "cost_usd": 0.0019, "reward_usd": 0.1, "expected_reward_usd": 0.1, "claimed_usd": 0, "gas_used": 412345, "summary": "liquidate 2 account(s), $4,100 notional", "extra": {"cost_wei": "1900000000000"}}
FieldMeaning
tsseconds since the epoch
jobthe job (forward for forwarding; the operation’s name for manual operations)
marketthe market’s label, or empty
walletthe sending wallet’s address
tx, statusthe transaction and confirmed, reverted or unknown
kindprofit, loss, unpaid, forced, collect or forward
cost_usdthe gas actually paid (or the estimate, for unknown)
reward_usd, claimed_usdthe FeeAccrued and FeesClaimed amounts the receipt logged for this wallet
expected_reward_usdwhat the simulation said
extra.cost_weiwei that left the wallet: gas, plus the ETH the call carried unless it reverted

The daily budgets are read from the ledger, so they survive restarts. Only sent transactions are written; skips and dry runs are not. The format is the Python keeper’s, so either reads the other’s ledger.

bazaar keeper earnings sums it:

bazaar keeper earnings --since 24h
bazaar keeper earnings --since 30d --by market
 JOB                 CALLS  REVERTED   EARNED      GAS      NET  CLAIMED
 collect                 2         0  $0.0000  $0.0004  $-0.0004   $12.40
 liquidation            14         1  $1.4000  $0.0310  $1.3690    $0.00
 triggers               31         3  $2.1700  $0.0620  $2.1080    $0.00

EARNED is what the calls accrued to your claimable balances, NET that less gas, and CLAIMED what collect moved to your wallet. --since takes 30m, 24h, 7d (a bare number is days; default 7d); --by is job (default) or market.

Choosing Your Numbers

  • Start with the defaults and start --dry-run for a while: the log shows what would be sent and why each skip was skipped.
  • Raise maxSpendUsdPerDay (and maxSpendEthPerDay) to what you are willing to spend on a bad day, not an average one.
  • Turn on allowLoss only for jobs whose work matters to you even unpaid, with small per-call caps.
  • Set gas.maxFeeGwei so a gas spike cannot make the keeper bid into it.

Running in Production

A keeper earns by being there when a call comes due, so it runs around the clock. This page covers what that takes: how many processes, where the keeper keeps its state and how restarts use it, the Docker image, systemd and pm2 samples, alerts, logs, upgrades, backups and monitoring.

One Process per Set of Wallets

Run one keeper process for each set of wallets. Each wallet is a nonce sequence the process manages itself; two processes signing with the same wallet would take each other’s nonces. To split work across machines, give each process its own wallets (and its own jobs or markets).

One process can run every job on every market. To keep latency-critical sends off the queue of slower work, give liquidation and triggers their own wallet in the same process (Wallets & Secrets).

The State Directory and Restarts

The keeper keeps three files under state.dir (~/.bazaar by default; state/ beside the config when init wrote it), in keeper/<chainId>-<first 8 hex digits of the factory>/:

FileWhat it holdsWritten
snapshot.jsonthe index: markets, open positions, live orders, and the block it is current toevery engine.snapshotSeconds (60 s) and on a clean stop, atomically (a temporary file renamed over it)
ledger.jsonlone line per sent transaction: the earnings record and what the daily budgets countappended after each receipt
status.jsonwhat bazaar keeper status readsevery 5 s, atomically

Restarts. A restart loads the snapshot and replays every block since it, from the chain’s logs (the subgraph is not asked again), so nothing is missed while the keeper was down. A snapshot for another chain, another factory or another snapshot format is ignored, and the keeper builds its index afresh. Deleting the snapshot forces that too. While the replay is 64 or more blocks behind the head, the price-driven planners wait for it to finish.

Stopping. SIGINT or SIGTERM stops cleanly: no new call is dispatched, in-flight sends get their receipts (up to 60 s), collect claims every balance that covers its gas if onShutdown is on, and the snapshot is saved. A second signal leaves at once (exit code 130). Give your process manager at least 90 seconds before it kills the process.

Exit codes. bazaar keeper start exits 0 after a clean stop, 2 for a configuration or wallet problem (fix the config; restarting will not help), and 1 for anything else (an RPC that cannot be reached at start, say).

Docker

The repository’s Dockerfile builds an image that runs the keeper as an unprivileged user, with everything it keeps in the directory mounted at /config:

git clone https://github.com/liberta-labs/bazaar-sdk && cd bazaar-sdk
docker build -t bazaar-keeper .

The image’s entry point is the bazaar command, and its default command is keeper start. Its working directory is /config, so the default config path, bazaar-keeper.yaml, is /config/bazaar-keeper.yaml. HOME is /config as well, so any ~/ path (the default state.dir, ~/.bazaar; bazaar wallet new’s ~/.bazaar/keys) also lands on the mount.

Set it up once, interactively, then run it detached:

mkdir -p keeper
# once: the wizard, inside the container
docker run --rm -it --user "$(id -u):$(id -g)" -v "$PWD/keeper:/config" bazaar-keeper keeper init
# check it
docker run --rm -it --user "$(id -u):$(id -g)" -v "$PWD/keeper:/config" bazaar-keeper keeper doctor
# run it
docker run -d --name keeper --restart unless-stopped --user "$(id -u):$(id -g)" \
  -v "$PWD/keeper:/config" bazaar-keeper

init run this way writes /config/bazaar-keeper.yaml and /config/.env, a new keystore under /config/keys/, and state.dir: /config/state: all of it in ./keeper on the host.

  • --user "$(id -u):$(id -g)" runs the keeper as you, so it can write to the mounted directory and you own what it writes. Without it, the container runs as uid 10001, and the directory must be writable by that user.
  • Store the keystore password in .env when init asks. A detached container has no terminal to ask for it, and stops with wallet default (default.json) needs a password: ….
  • docker stop keeper sends SIGTERM, which stops the keeper cleanly. Docker waits 10 seconds by default before killing; give it longer: docker stop -t 90 keeper (or --stop-timeout 90 on docker run).
  • Other commands run the same way: docker exec keeper node /app/packages/keeper/dist/cli/main.js keeper status, or docker run --rm --user … -v … bazaar-keeper keeper earnings --since 7d.
  • Logs: docker logs -f keeper. Set log.format: json for a log collector.

systemd

A unit for a keeper installed from the repository in /opt/bazaar-sdk, with its directory at /srv/keeper, running as a dedicated keeper user:

# /etc/systemd/system/bazaar-keeper.service
[Unit]
Description=Bazaar keeper
After=network-online.target
Wants=network-online.target

[Service]
User=keeper
Group=keeper
WorkingDirectory=/srv/keeper
ExecStart=/usr/bin/node /opt/bazaar-sdk/packages/keeper/dist/cli/main.js keeper start -c /srv/keeper/bazaar-keeper.yaml
# SIGTERM stops cleanly; give in-flight sends and the shutdown collect time to finish.
KillSignal=SIGTERM
TimeoutStopSec=90
Restart=on-failure
RestartSec=10
# A config or wallet error exits 2: restarting will not fix it.
RestartPreventExitStatus=2
# The keeper writes only its state directory (state.dir must be under /srv/keeper, as init sets it).
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/srv/keeper
PrivateTmp=true

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now bazaar-keeper
journalctl -u bazaar-keeper -f

The keystore password must come from .env (or an EnvironmentFile= the unit reads): a service cannot answer a prompt. With a global npm install, ExecStart=/usr/bin/env bazaar keeper start -c ….

pm2

// ecosystem.config.cjs
module.exports = {
  apps: [
    {
      name: "bazaar-keeper",
      script: "/opt/bazaar-sdk/packages/keeper/dist/cli/main.js",
      args: "keeper start -c /srv/keeper/bazaar-keeper.yaml",
      cwd: "/srv/keeper",
      // pm2 sends SIGINT, then kills after kill_timeout ms: leave time for a clean stop.
      kill_timeout: 90000,
      autorestart: true,
      restart_delay: 10000,
    },
  ],
};
pm2 start ecosystem.config.cjs
pm2 logs bazaar-keeper

Alerts

The keeper can send alerts to Telegram, Discord and any webhook. Each channel is its own request, so one that is down does not hold back the others. Every alert is also logged as a warning (alert (lowBalance): …), configured channels or not.

alerts:
  telegram:
    botToken: ${TG_BOT_TOKEN}
    chatId: "123456789"
  discord:
    webhook: ${DISCORD_WEBHOOK_URL}
  webhook:
    url: ${ALERT_WEBHOOK_URL}
  notify: [lowBalance, jobPaused, endpointDown, opportunity, dailySummary]
ChannelWhat it sends
telegramsendMessage to chatId as the bot, plain text (no parse mode)
discordthe message as content, with every mention disabled (no @everyone, roles or users)
webhookPOST JSON: {"kind": "lowBalance", "message": "…", "ts": 1762600998.1}

The bot token and the Discord webhook URL are secret keys, and a generic webhook URL usually carries its own token in its path: put all three in .env (the secrets rules). Messages are prefixed Bazaar keeper: and stripped of control characters.

notify chooses the kinds. Only the kinds listed are sent or logged.

KindWhenRepeats
lowBalancea wallet is below budget.minEthBalance and sending is pausedthe same message at most every 10 min
jobPauseda job was paused on a market after safety.pauseAfterReverts on-chain revertsevery 10 min at most
endpointDownthe send endpoint gave no answer and the transaction has no receipt (booked as unknown)every 10 min at most
opportunitya manual operation is available or will open (scanned every 5 min, only when a channel is configured)each one once a day
dailySummarythe last 24 hours’ net, once a day (the first at start, when the ledger has entries)daily
actionevery sent call’s result (not in the defaults)every time

A price endpoint that is down shows in the log (price endpoint: …) and in status (prices down: …), not as an alert; watch status.json for it (below).

Logs

The CLI logs to standard output. log.level is debug, info (default), warning or error; log.format is pretty (default) or json:

12:03:17 INFO    liquidation ETH/USD: sent 0x5c1e…a9 (earns $0.1000 for $0.0021 of gas)
{"ts":1762600997.512,"level":"info","logger":"bazaar_sdk.exec","msg":"liquidation ETH/USD: sent 0x5c1e…a9 (earns $0.1000 for $0.0021 of gas)"}

A JSON line carries exc with the stack trace when there is one. At info the keeper logs each sent call and its result, each distinct skip reason at most every five minutes (repeats at debug), new listings, reorgs, catch-ups and warnings. Repeated failures (a planning error, a failing engine step) are logged at most every 30 to 60 seconds. URLs and keys are never logged.

Upgrades

  1. Stop the keeper cleanly (SIGTERM, and wait for it to exit).
  2. Upgrade: npm install -g @liberta-labs/bazaar-keeper@latest once published; from the repository, git pull && npm install && npm run build; with Docker, rebuild the image.
  3. Run bazaar keeper doctor (a new version may refuse a key it no longer knows, or need a new one).
  4. Start it again. It resumes from the snapshot. If the new version uses another snapshot format, it ignores the old snapshot and builds its index afresh (from the subgraph or the logs), which takes as long as a first start.

The ledger keeps its format across versions, so earnings and the daily budgets carry over.

Backups

WhatWhyHow
the keystores (keys/)the wallets themselves: lose them and you lose what they hold and have not claimedcopy them anywhere; they are encrypted
the keystore passwords (.env)a keystore without its password is uselesskeep them apart from the keystores, in a password manager
ledger.jsonlyour earnings record, and what the daily budgets count after a restartcopy it now and then
bazaar-keeper.yamlyour settingsit holds no secrets: commit it if you like

The snapshot needs no backup: the keeper rebuilds it. A wallet’s unclaimed bounties stay on chain on each market’s claimable balance whatever happens to the machine; restoring the keystore anywhere lets you claim them (bazaar keeper run collect).

Monitoring

status.json in the state directory is rewritten every 5 seconds while the keeper runs, so it is the simplest thing to watch. bazaar keeper status renders it; a script can read it directly:

FieldMeaning
updatedwhen it was written (seconds since the epoch): older than 30 s means the keeper is not running
network, factory, officialwhat it keeps, and whether the factory is a network’s official one
block, indexedthe chain’s head and the last block whose logs are applied: a growing gap means the keeper is behind
prices{healthy, error, feeds}: whether prints are arriving, the endpoint’s last error, how many feeds have a print
markets[]per market: label, address, status (live, adl, limbo, settling, terminated, unknown), positions, orders, inventory (units), atRisk (positions within watchBufferPct of liquidation; null without the liquidation job), priceAge (seconds since the base leg’s print)
jobsper job: markets (count, or all), last (its latest sent call), today (the ledger’s last 24 h: calls, reward, cost, net, claimed, reverted)
walletsname to address
haltedempty, or why sending is paused (a wallet below minEthBalance)

A watchdog in TypeScript that exits non-zero when the keeper is unwell, for cron or a monitoring agent:

// The same directory the keeper writes to: <state.dir>/keeper/<chainId>-<first 8 hex digits of the factory>.
const cfg = loadConfig("/srv/keeper/bazaar-keeper.yaml");
const path = join(stateDirFor(cfg), "status.json");

const problems: string[] = [];
let s: KeeperStatus | null = null;
try {
  s = JSON.parse(readFileSync(path, "utf8")) as KeeperStatus;
} catch (e) {
  problems.push(`no readable status at ${path}: ${e instanceof Error ? e.message : String(e)}`);
}
if (s) {
  const age = Date.now() / 1000 - s.updated; // the keeper rewrites the file every 5 s
  if (age > 30) problems.push(`status is ${Math.round(age)} s old: the keeper is not running`);
  if (!s.prices.healthy) problems.push(`prices down: ${s.prices.error || "no print yet"}`);
  if (s.halted) problems.push(s.halted); // e.g. a wallet below budget.minEthBalance
  if (s.block - s.indexed > 64) problems.push(`the index is ${s.block - s.indexed} blocks behind the head`);
  // Not a problem in itself, but worth a look: positions close to their maintenance requirement.
  for (const m of s.markets) if (m.atRisk) console.log(`${m.label} (${m.address}): ${m.atRisk} position(s) near liquidation`);
}
for (const p of problems) console.error(p);
process.exitCode = problems.length ? 1 : 0;

Or in a shell, with jq:

S=/srv/keeper/state/keeper/42161-*/status.json
jq -e --argjson now "$(date +%s)" '($now - .updated) < 30 and .prices.healthy and (.halted == "")' $S >/dev/null \
  || echo "keeper unwell"

For metrics, run the keeper as a library and feed its action events to your metrics system, or serve keeper.statusDict() over HTTP (The Keeper as a Library).

Commands

Everything the bazaar command does. bazaar keeper <command> runs the keeper; bazaar wallet and bazaar mock-prices are shared tools. Every command has --help.

CommandWhat it does
keeper initthe setup wizard
keeper doctorchecks the RPC, the factory’s wiring, the subgraph, prices, wallets and jobs
keeper startruns the enabled jobs until stopped
keeper statuswhat a running keeper is doing
keeper marketsevery market with its label, status and open positions
keeper earningsrewards, gas and net from the ledger
keeper opportunitiesmanual operations available now
keeper runone operation now
walletcreate, import or list wallets
mock-pricesa mock Pyth Pro price endpoint for a local chain

Common Options

OptionMeaning
-c, --config <path>the keeper’s config file; default bazaar-keeper.yaml in the current directory (~/ works). Every keeper command and wallet list take it
--debug, --verboseprint the whole error with its stack trace when a command fails (bazaar --debug keeper start, or at the end of the line)
-h, --helpthe command’s usage

Errors print one readable line, with URLs and keys masked. Exit codes:

CodeMeaning
0done
1the command failed (an unreachable RPC, a failed doctor check), or setup was cancelled
2a usage, config or wallet problem: an unknown option, a missing --market, an invalid config, a wrong keystore password
130cancelled at a prompt, or stopped by a second Ctrl-C

keeper init

bazaar keeper init [-c bazaar-keeper.yaml]

Asks for the network, factory, RPC, subgraph, price endpoint, wallet, jobs and markets, checking each answer, and writes the config and a .env beside it (secrets and URLs only in .env), a new keystore in keys/ beside them, and state.dir as state/ beside them. Needs a terminal. Every question is explained in Setting Up a Keeper.

keeper doctor

bazaar keeper doctor [-c bazaar-keeper.yaml]

Checks everything start relies on. Exits 0 when nothing failed, 1 otherwise.

✔ RPC            chain 42161, latency 61 ms, eth_call state overrides supported
✔ Factory        0x0000000000000000000000000000000000000000 not an official deployment, 12 market(s)
✔ Wiring         lens and terminator both point back at this factory
✔ Subgraph       synced to block 271,402,118 (14 blocks behind)
✔ Prices         12/12 markets have a print; 4 not fresh right now (AAPL/USD, TSLA/USD, NVDA/USD, SPY/USD)
✔ Prices         the fixed_rate@200ms stream is delivering
✔ Wallet default 0x14dC79964da2C08b23698B3D3cc7Ca32193d9955, 0.05000 ETH (roughly 8,333 calls at today's base fee)
✔ Wallet fast    0x23618e81E3f5cdF7f54C3d65f7FBc0aBf5B21E8f, 0.03000 ETH (roughly 5,000 calls at today's base fee)
⚠ Jobs           liquidation takes losses: up to $0.1 a call, $2 a day
✔ Jobs           liquidation, triggers, unload, adl, refresh, collect, terminalSettlement

“Not fresh right now” is normal for markets with trading hours outside them. The first Prices line is one request to the request API; the second opens the price stream for ETH/USD and waits up to 5 seconds for a print (skipped when prices.stream is off). Each line is explained in Setting Up a Keeper; each failure in Troubleshooting.

keeper start

bazaar keeper start [-c bazaar-keeper.yaml] [--dry-run] [--only <jobs>]
OptionMeaning
--dry-runplan and simulate every call, log it, send nothing (sets every job’s dryRun)
--only <jobs>comma-separated jobs to run, out of those the config enables; the rest are switched off for this run. Names in any style: liquidation,triggers, terminal-settlement, sweep_expired

Runs until Ctrl-C or SIGTERM. The first signal stops cleanly (in-flight transactions finish, collect runs if its onShutdown is set, the snapshot is saved); a second leaves at once.

bazaar keeper start --dry-run --only liquidation,triggers

keeper status

bazaar keeper status [-c bazaar-keeper.yaml]

Reads the status.json of the keeper running with this config (it connects to nothing), so it works from any terminal on the same machine:

Bazaar keeper · arbitrum-one · block 271,402,140 · indexed 271,402,139 · prices live · running
factory 0x0000000000000000000000000000000000000000 is not an official deployment
 JOB                 MARKETS  LAST ACTION                                                  TODAY EARNED    GAS     NET
 liquidation              12  ETH/USD liquidate 1 account(s), $2,050 notional (confirmed)         $0.35  $0.01   $0.34
 triggers                 12  BTC/USD execute 2 trigger order(s), up to $0.40 of bounty (confirmed)  $1.20  $0.02   $1.18
 unload                   12  -                                                                   $0.00  $0.00   $0.00
 collect                 all  ETH/USD claim $5.12 (confirmed)                                     $0.00  $0.00  $-0.00
 WALLET   ADDRESS
 default  0x14dC79964da2C08b23698B3D3cc7Ca32193d9955
 fast     0x23618e81E3f5cdF7f54C3d65f7FBc0aBf5B21E8f

With no status file yet: No keeper has run with this config yet. Start one with bazaar keeper start. A file more than 30 seconds old shows not running (last seen N min ago). A red line under the header says sending is halted. Monitoring lists every field of the file.

keeper markets

bazaar keeper markets [-c bazaar-keeper.yaml]

Every market of the factory, sorted by label. It connects without loading wallets; with no snapshot yet it builds the index first, which takes as long as a first start.

 MARKET    ADDRESS                                     STATUS      HOURS    POSITIONS  LIVE ORDERS  INVENTORY
 AAPL/USD  0x5FbDB2315678afecb367f032d93F642f64180aa3  live        session         41           63          0
 BTC/USD   0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512  live        24/7           388          912      0.012
 ETH/USD   0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0  live        24/7           521         1204          0
 ETH/USD   0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9  terminated  24/7             0            0          0

STATUS is live, adl, limbo, settling or terminated; HOURS is 24/7 or session (a market with trading hours); INVENTORY is the vault’s inherited position, in units. Two rows with one label are two markets (here a terminated one and its relisting): name them by address.

keeper earnings

bazaar keeper earnings [-c bazaar-keeper.yaml] [--since 7d] [--by job|market]
OptionDefaultMeaning
--since <period>7dhow far back: 30m, 24h, 7d; a bare number is days
--by <group>jobone row per job or per market (market label; - for calls on no market)
 MARKET    CALLS  REVERTED   EARNED      GAS      NET  CLAIMED
 -             1         0  $0.0000  $0.0002  $-0.0002    $0.00
 BTC/USD      22         2  $3.4100  $0.0410  $3.3690    $0.00
 ETH/USD      19         1  $1.9500  $0.0380  $1.9120   $12.40

Reads ledger.jsonl only: it works whether or not the keeper is running. No transactions in that period. when there are none. The Ledger explains the columns.

keeper opportunities

bazaar keeper opportunities [-c bazaar-keeper.yaml]

Lists the manual operations a dry run shows would go through now, or that a “too early” revert shows will open later, each with the command that runs it (naming the market by address):

finalizeTermination ETH/USD: every position is settled, or the escape hatch has opened (ready now) — bazaar keeper run finalize --market 0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9
terminateScheduledPair TSLA/USD: its scheduled cutoff has passed (opens at 2026-11-09 14:30 UTC) — bazaar keeper run terminate-scheduled --market 0x5FC8d32690cc91D4c39d9d3abcBD16989F875707

Or Nothing is waiting for a manual call. Manual Operations lists what it looks for.

keeper run

bazaar keeper run <operation> [--market <m>] [options]

Runs one operation now. Every call is simulated first; nothing that reverts in simulation is sent.

OptionMeaningUsed by
--market <m>a market’s label, address or pair id; comma-separated for collectevery per-market operation (required), collect (optional)
--accounts <addresses>comma-separated accountsliquidate, settle-terminal
--orders <ids>comma-separated order idsexecute-triggers, sweep-expired
--assertion <id>a UMA assertion id (32 bytes, with or without 0x)settle-termination, settle-listing, settle-identifier-upgrade (required)
--proposal <id>an insurer proposal idexecute-insurer-termination (required)
--pair-id <id>a listing’s pair idexpire-listing (required)
--dry-runsimulate and report, send nothingall
--forcesend even when the profit and loss rules would skip itthe automatic operations
-y, --yessend a manual operation without askingthe manual operations
--stored-price <policy>when a settlement at the market’s stored price is planned once its last resort is open: whenAbsent (default), always or neverterminate-scheduled
--accept-stored-pricesend a settlement at the stored price, which is otherwise shown and not sent, --yes or notterminate-scheduled
OperationCallsNeeds
liquidateliquidate on the accounts given, or every account below its requirement--market
execute-triggersexecuteTriggeredOrders on the orders given, or every ready one--market
unloadunloadVault--market
execute-adlexecuteAdl with the planner’s picks--market
refresh-pricerefreshPrice--market
collectclaimFees on every market (or those given) where a wallet holds a balance
settle-terminalsettlement-mode liquidate on the accounts given, or every open position--market
sweep-expiredsweepExpired on the orders given, or every expired or finished one--market
finalizefinalizeTermination--market
terminate-stalethe terminator’s terminateStalePair--market
terminate-scheduledthe terminator’s terminateScheduledPair, with the archived Pyth Pro updates its rule asks for--market
settle-terminationthe terminator’s settleTerminationProposal--assertion
execute-insurer-terminationthe terminator’s executeInsurerTermination--market, --proposal
settle-listingthe factory’s settleDeploymentProposal--assertion
expire-listingthe factory’s expireStuckDeploymentProposal--pair-id
settle-identifier-upgradethe factory’s settleIdentifierUpgradeProposal--assertion
expire-identifier-upgradethe factory’s expireStuckIdentifierUpgradeProposal
activate-identifier-upgradethe factory’s activateIdentifierUpgrade

The automatic operations (the first eight) keep their job’s profit and loss rules unless --force. The manual ones show what would happen and ask Send it? (default no) before sending; without a terminal they print Not sent: pass --yes to send without confirming. A missing required option is a usage error (liquidate needs --market, exit 2).

Each result prints as a status, the job, the market and a summary, then its reason, what it earned or would earn, its transaction, and when a “too early” call opens:

$ bazaar keeper run liquidate --market ETH/USD --dry-run
simulated  liquidation  ETH/USD  liquidate 1 account(s), $2,050 notional
   earns $0.0500 for $0.0019 of gas
   would earn $0.0500 for about $0.0019 of gas

$ bazaar keeper run refresh-price --market BTC/USD
skipped  refresh  BTC/USD  refresh price
   below the profit rule: earns $0.0000 for $0.0021 of gas, needs ×1.25 and $0.001 net (allowLoss is off)

$ bazaar keeper run finalize --market 0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9
simulated  finalizeTermination  ETH/USD  finalize ETH/USD
   sent on request (profit and loss rules skipped)
   would earn $0.0000 for about $0.0031 of gas
? Send it? Yes
confirmed  finalizeTermination  ETH/USD  finalize ETH/USD
   sent on request (profit and loss rules skipped)
   earned $0.0000, gas $0.0029
   tx 0x8a3f…

The statuses: simulated (a dry run, or a preview), skipped (not sent, with why), confirmed, reverted (on chain), unknown (no receipt). Manual Operations covers each operation.

wallet

bazaar wallet new    [--name default] [--path <file>]
bazaar wallet import [--name default] [--path <file>]
bazaar wallet list   [-c bazaar-keeper.yaml]
OptionDefaultMeaning
--name <name>defaultthe keystore’s file name, and the name to put it under in wallets
--path <file>~/.bazaar/keys/<name>.jsonwhere to write the keystore

new creates a key and saves it as an encrypted keystore; import encrypts a private key you paste; both ask for a password twice and print the YAML to add. list loads the config’s wallets and prints each one’s ETH and claimable earnings. Wallets & Secrets.

mock-prices

bazaar mock-prices [--rpc http://127.0.0.1:8545] [--price <FEED_ID=USD>]... \
  [--host 127.0.0.1] [--port 8787] [--token-env <NAME>]
OptionDefaultMeaning
--rpc <url>http://127.0.0.1:8545the local chain, whose clock the updates follow
--price <FEED_ID=USD>ETH/USD at 2000a feed’s starting price; repeatable. The feed is its Pyth Pro number (2=3000 is ETH/USD) or the bytes32 a pair reports as its baseFeedId
--host <host>127.0.0.1the address to listen on. One other machines can reach (anything but localhost, 127.0.0.0/8 and ::1) is refused without --token-env
--port <port>8787the port
--token-env <NAME>nonethe environment variable that holds a token every client must present, the price controls included (the symbols registry needs none). It is read from the environment, never the command line, where other users’ process listings would show it

Serves a mock Pyth Pro endpoint until Ctrl-C: the price stream, the request API and the symbols registry. Its updates are signed with the public test key that only a local deployment’s mock verifier trusts, and stamped with the chain’s time. For local testing only: Testing Locally with Anvil.

mock Pyth Pro on http://127.0.0.1:8787 (stream ws://127.0.0.1:8787/v1/stream), on the clock of http://127.0.0.1:8545; feeds: 2

Manual Operations

bazaar keeper run <operation> runs one operation now, on a market you name. It covers two kinds of operation:

  • the automatic jobs, once: the same planning, simulation and spend policy the running keeper uses, on one market, when you want to act without waiting (or to see what the keeper would do);
  • manual operations: calls no job makes on its own because they end markets or settle governance, pay nothing, and should happen only when someone decides they should. The keeper builds and checks them; you send them.

bazaar keeper opportunities tells you when a manual operation is available. The same calls are keeper.run.* in the library (The Keeper as a Library).

How a Run Works

  1. The config is loaded and the wallets are unlocked (a keystore without a stored password is asked for).
  2. The keeper connects: it checks the chain and the factory’s wiring, loads the snapshot in the state directory (or, with none, builds the index as a first start does), and replays the blocks since.
  3. The market is resolved. --market takes a label (ETH/USD), a description, an address or a pair id. A label several markets carry is refused ('ETH/USD' matches several markets (0x…, 0x…); use an address); bazaar keeper markets shows the addresses.
  4. The market’s state is read and fresh prints are fetched for its legs.
  5. The call is planned, simulated, priced and judged; then sent, or (for a manual operation) shown and confirmed first.

Nothing that reverts in simulation is ever sent, and the daily spend caps, the low-balance halt and the fee ceilings apply to every run (Budgets & Losing Calls).

A one-shot run is a separate process from a running keeper. If it signs with a wallet the running keeper also uses, their nonces can collide: the run re-reads the nonce and retries once, so an occasional run works, but prefer running manual operations when the keeper is idle, or from a wallet of their own (they use default, see below).

Options

OptionEffect
--dry-runsimulate and report, send nothing
--forceautomatic operations only: send even when the profit and loss rules would skip it. Keeps everything else: the simulation, the spend caps, the fee ceilings, the low-balance halt (What --force skips and keeps)
-y, --yesmanual operations only: send without asking
--stored-price <policy>terminate-scheduled only: when a settlement at the market’s stored price is planned once its last resort is open, whenAbsent (default), always or never (The Last Resort)
--accept-stored-priceterminate-scheduled only: send a settlement at the stored price. Without it such a plan is shown and not sent, --yes or not

The Automatic Operations

OperationJobWith no listWith a list
liquidate --market M [--accounts a,b]liquidationevery account below its requirement at the live priceexactly those accounts, in one call
execute-triggers --market M [--orders 1,2]triggersevery order ready to fireexactly those orders (each StopLimit gets its hint)
unload --market Munloadthe vault’s inventory, if the book takes some
execute-adl --market Madlthe planner’s winners for each side(in the library: longBucketWinners, shortBucketWinners)
refresh-price --market Mrefresha refresh, judged by the profit rule (it pays $0.10 only when the bounty is due)
collect [--market M1,M2]collectevery market where a wallet holds a balancethose markets
settle-terminal --market M [--accounts a,b]terminalSettlementevery open position but your own (one batch)exactly those accounts
sweep-expired --market M [--orders 1,2]sweepExpiredevery expired order and every finished one holding a bountyexactly those orders

Each uses the job’s settings from the config, or the job’s defaults when the config leaves the job off, sending from the job’s wallet (or, for a job not configured, default or the first wallet). The job’s market states apply: on a market in another state the run is skipped with the market is settling; this job runs while it is live. Planning finds nothing: skipped … no position is below its maintenance margin.

With an explicit list the planner’s filters are skipped (the chain still decides in simulation), so --accounts can name an account the index does not know about. liquidate --accounts on a closed market uses the stored price when it is at most 9.5 minutes old (the pair accepts one up to 10). execute-triggers --orders and execute-adl need a fresh print.

For collect, --force means “claim every balance that covers its gas, as on shutdown” rather than skipping the rules.

bazaar keeper run liquidate --market ETH/USD --dry-run
bazaar keeper run execute-triggers --market 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0 --orders 1042,1043
bazaar keeper run sweep-expired --market BTC/USD --force
bazaar keeper run collect --market ETH/USD,BTC/USD

The Manual Operations

Manual operations pay the caller nothing. They are sent from the wallet named default, or the first wallet configured when there is none by that name, as forced calls: no profit rule, but the spend caps, the fee ceilings and the low-balance halt apply. Each run first simulates (simulated, with what it would cost), then asks Send it? (default no), or sends at once with --yes. Without a terminal and without --yes, it prints Not sent: pass --yes to send without confirming. A preview that would revert or cannot be sent is printed and nothing is asked.

OperationCallsAvailable when
finalize --market Mthe pair’s finalizeTermination()the settlement price is fixed and either every position is settled (both sides’ open interest zero) or 14 days have passed since the fix
terminate-stale --market Mthe terminator’s terminateStalePair(pair)the market’s stored price is more than 21 days old (a dead oracle)
terminate-scheduled --market Mthe terminator’s terminateScheduledPair(pair, priceUpdate)the market’s scheduled cutoff has passed: see below
settle-termination --assertion Athe terminator’s settleTerminationProposal(assertionId)a UMA termination claim’s liveness has ended
execute-insurer-termination --market M --proposal Pthe terminator’s executeInsurerTermination(pair, proposalId)an insurer vote on the market can be resolved
settle-listing --assertion Athe factory’s settleDeploymentProposal(assertionId)a listing proposal’s liveness has ended
expire-listing --pair-id Pthe factory’s expireStuckDeploymentProposal(pairId)a listing cannot settle
settle-identifier-upgrade --assertion Athe factory’s settleIdentifierUpgradeProposal(assertionId)an identifier upgrade is pending
expire-identifier-upgradethe factory’s expireStuckIdentifierUpgradeProposal()the pending upgrade cannot settle
activate-identifier-upgradethe factory’s activateIdentifierUpgrade()an approved upgrade is queued and its delay has passed

The contracts decide each condition; the simulation shows their answer. A call made too early comes back with when it opens (opens at 2026-11-09 14:30:00 UTC) where the revert says so. Bazaar’s Termination and Listing pages explain what each call does to the market.

finalize shares its in-flight key with the terminal-settlement job, so a run and a running keeper in the same process never finalize twice.

$ bazaar keeper run terminate-stale --market 0x5FC8d32690cc91D4c39d9d3abcBD16989F875707
simulated  terminateStalePair  GME/USD  end GME/USD (dead oracle)
   sent on request (profit and loss rules skipped)
   would earn $0.0000 for about $0.0042 of gas
? Send it? (y/N)

Terminate Scheduled

A market with a scheduled cutoff (from a UMA claim upheld or an insurer vote) is ended by terminateScheduledPair, which must carry archived Pyth Pro updates that prove the settlement price. All of them come from Pyth’s one-second channel (fixed_rate@1000ms), which issues one update per second, stamped on the second; the keeper asks the price endpoint’s request API (POST /v1/price) for the update stamped on each second it needs, so the token must cover that channel. The keeper builds the call with the SDK’s termination.terminateScheduledPair, which reads settlementRule(pair) from the terminator and fetches what the rule needs:

RuleUpdates
nothing (SETTLE_NOTHING)none: the market has no scheduled cutoff, or its price is already fixed. Skipped
UMA’s rule (SETTLE_TICK)the update stamped at the cutoff, carrying every leg (one update with both legs of a composite, or one per leg). The pair settles at the price it carries, whenever that price was generated: a feed that was quiet at the cutoff settles at its last price
the chain rule (SETTLE_CHAIN)five samples chained from the cutoff, each the earliest second whose update carries a price generated at or after its target (with the quiet second before it when that is not the target itself), the next target 600 s past the sample’s second (the later leg’s), every one no later than the rule’s latest publish time
awaiting a verdict (SETTLE_AWAIT_VERDICT), or an insurer vote backed by a UMA claim whose verdict nobody recordedthe call records the verdict itself, so the builder previews how UMA ruled and builds that rule’s set; when the verdict cannot be previewed it builds both sets and the contract’s simulation says which applies

Before offering a set, the builder has the pair’s own oracle verify it exactly as the terminator will (fetchPriceAtTick for UMA’s rule, fetchChainedMedian for the chain rule, the same arguments, the update fee as value, from the sending wallet), and the summary names the price that call returns:

simulated  terminateScheduledPair  TSLA/USD  terminate TSLA/USD at its cutoff under the chain rule (the median of five chained prices), settling at 251.37
   sent on request (profit and loss rules skipped)
   would earn $0.0000 for about $0.0088 of gas

This matters because past the grace or the last resort the terminator catches a refused set and settles at the stored price instead, so simulating terminateScheduledPair alone would pass either way and label a stored-price settlement as a proven one. The keeper never sends a set the oracle refused as a proven settlement; the result’s reason says why, for example under the chain rule (the median of five chained prices): The oracle would not verify these updates (…). The stored-price fallback is open, so a call carrying them would settle at the market's stored price (250.1) instead: they are not offered as a proven settlement.

When nothing can be sent yet, the run is skipped with the reasons, such as Under UMA's rule (the one-second update stamped at the cutoff): Pyth's archive has no one-second update for feed … at second … yet. The stored-price fallback opens after …. A feed that carries no price at the cutoff (one listed after it, say) builds no chain: the market waits for its last resort like any other that cannot be proven. A run before the cutoff, or on a market without one, is skipped with the terminator’s own reason.

The Last Resort

Past lastResortTs, a call with no updates settles at the stored price: for good, at a price no Pyth update proves. The keeper plans that call as --stored-price allows:

--stored-priceThe call at the stored price is planned
whenAbsent (default)only when every set failed because Pyth’s archive holds no usable update for it, and the archive is shown to work (it serves ETH/USD’s one-second update from a minute before the chain’s time). A timeout, a refusal, a wrong archive URL or a set the oracle refused may hide a proven price, so none of them leads to the stored price
alwayswhenever no verified set goes through
nevernever

Withheld, the run is skipped with why and what would change it: … The stored-price fallback is open but not offered: a proven settlement may still exist (…): try again later, or run with --stored-price always to settle at the stored price anyway. Planned, the preview says what it fixes, and the call is sent only with --accept-stored-price (and then --yes, or a yes at the prompt):

$ bazaar keeper run terminate-scheduled --market TSLA/USD --yes
simulated  terminateScheduledPair  TSLA/USD  terminate TSLA/USD at its cutoff at its stored price, 250.1 (the last resort is open)
   sent on request (profit and loss rules skipped); this settles the market irreversibly at its stored price, 250.1, stored 22 d 0 h ago (2026-10-18 14:30:00 UTC): no Pyth update proves it; under UMA's rule (…): …
   would earn $0.0000 for about $0.0061 of gas
Not sent: this settles the market at its stored price, which no Pyth update proves. Pass --accept-stored-price to send it.

In the library, keeper.run.terminateScheduledPair takes storedPrice and sends such a call only with acceptStoredPrice: true.

What Is Sent Is What Was Shown

The run previews the call, asks, and then builds the plan again just before sending: when the rule, the settlement price or the call differs from the preview’s (an archive that answered for the preview and not since, a verdict recorded in between), nothing is sent: the settlement changed since the preview (…): nothing was sent; run the command again.

Opportunities

bazaar keeper opportunities

Dry-runs every manual operation that might be due and lists the ones that would go through now (ready now), or that a “too early” revert shows will open later (opens at …), each with the command that runs it. Commands name markets by address, and only ids checked to be ids go into them.

Looks atOperation
every market past its scheduled cutoff, price not fixedterminateScheduledPair
every market with a fixed price, not terminated, with zero open interest or past the 14-day escapefinalizeTermination
every market whose stored price is more than 21 days oldterminateStalePair
with a subgraph: termination claims past their livenesssettleTerminationProposal
with a subgraph: insurer votesexecuteInsurerTermination
with a subgraph: listing proposals past their livenesssettleDeploymentProposal, expireStuckDeploymentProposal
the factory’s pending identifier upgradesettleIdentifierUpgradeProposal, expireStuckIdentifierUpgradeProposal
the factory’s queued identifier upgradeactivateIdentifierUpgrade

Without a subgraph, claims, votes and listings are not found (their ids are not in the keeper’s index); the market and factory checks still run.

A running keeper scans the same way every 5 minutes and sends each finding as an opportunity alert (at most once a day each), when an alert channel is configured and opportunity is in alerts.notify (Running in Production).

The Keeper as a Library

Everything the bazaar command does is available from TypeScript in @liberta-labs/bazaar-keeper: build a keeper from the same config, listen to what it does, run one call, or run it all inside your own service. Use the library when the keeper is part of something larger (a trading system that also keeps its markets, a service with its own health checks and metrics), or to script one-off operations.

The package runs in Node 20 or later (it reads files, holds keys and opens sockets: it is not for browsers). Install it as in Setting Up a Keeper; the samples import from @liberta-labs/bazaar-keeper and @liberta-labs/bazaar-sdk.

Creating a Keeper

// Reads bazaar-keeper.yaml, the .env beside it and the environment, with the same rules as the CLI.
const cfg = loadConfig("bazaar-keeper.yaml");

// The CLI configures logging itself; a program does it once, before the keeper logs anything.
setupLogging(cfg.log.level, cfg.log.format);

let keeper: Keeper;
try {
  // Loads the wallets now: a keystore without a password in the config asks for it on a terminal.
  keeper = await Keeper.create(cfg);
} catch (e) {
  if (e instanceof ConfigError || e instanceof WalletError) {
    console.error(e.message); // one readable line, never a key or a password
    process.exit(2);
  }
  throw e;
}
FunctionWhat it does
loadConfig(path?)reads the YAML file (default bazaar-keeper.yaml), the .env beside it and the environment, applies the secrets rules, fills ${NAME} references and validates. Throws ConfigError
parseConfig(object)validates an object against the same schema, with the same defaults. Throws ConfigError
Keeper.create(cfg, { loadWallets? })builds the keeper and loads its wallets (a keystore may prompt for its password when stdin is a terminal). loadWallets: false makes a watch-only keeper. Throws ConfigError for a config it cannot run with (rpc.send: sequencer on a network without one, prices.endpoint: bazaar), WalletError for a wallet it cannot load
setupLogging(level, format, write?)configures the keeper’s log output: level debug/info/warning/error, format pretty/json, lines to stdout or to write. Until it is called, warnings and errors go to stderr as bare messages and the rest is dropped

ConfigError and WalletError messages are written for the person fixing the problem and never contain a key or a password. new Keeper(cfg) builds a keeper without loading its wallets yet; they load on the first connect() (and so on start() or any run.* call).

Configuring in Code

parseConfig takes the same keys as the YAML file. It does not read .env, fill ${NAME} references or apply the file’s secrets rules: the object is used as given, so take secrets from the environment or a secret store yourself.

// The same schema as the YAML file, with the same defaults and the same refusal of unknown keys.
// parseConfig does not read .env, fill ${NAME} references or apply the file's secrets rule: the
// object is used as given, so take secrets from the environment (or a secret store) yourself.
const cfg = parseConfig({
  network: "arbitrum-sepolia",
  factory: "0x0000000000000000000000000000000000000000",
  rpc: { http: env("RPC_HTTP_URL") },
  subgraph: { url: "none" },
  prices: { endpoint: "pyth", token: env("PYTH_PRO_TOKEN") }, // channel defaults to fixed_rate@200ms
  wallets: {
    default: { type: "keystore", path: "/srv/keeper/keys/default.json", password: env("KEEPER_KEYSTORE_PASSWORD") },
    fast: { type: "privateKey", key: env("KEEPER_FAST_KEY") },
  },
  jobs: {
    liquidation: { wallet: "fast" }, // {} or an object turns a job on; leaving it out keeps it off
    triggers: { wallet: "fast", minBountyUsd: 0.05 },
    unload: {},
    refresh: {},
    collect: { thresholdUsd: 10 },
  },
  budget: { maxSpendUsdPerDay: 20, minEthBalance: 0.005 },
  state: { dir: "/srv/keeper/state" },
  log: { level: "info", format: "json" },
});

Events

// A sent transaction's result, once its receipt is in.
const offAction = keeper.on("action", (r) => {
  console.log(`${r.job} ${r.market}: ${r.status}, earned $${r.rewardUsd.toFixed(4)} for $${r.costUsd.toFixed(4)} (${r.txHash})`);
});
// The moment a transaction is submitted, before its receipt.
keeper.on("sent", ([action, txHash]) => console.log(`sent ${action.job} ${action.summary}: ${txHash}`));
// lowBalance, jobPaused and endpointDown, whether or not an alert channel is configured.
keeper.on("alert", ([kind, message]) => console.warn(`alert ${kind}: ${message}`));
// Each `on` returns the function that unsubscribes.
offAction();
EventPayloadWhen
actionActionResulta sent transaction’s result, once its receipt is in (confirmed, reverted or unknown)
sent[Action, txHash]a transaction was submitted
alert[kind, message]lowBalance, jobPaused or endpointDown, whether or not an alert channel is configured or the kind is in alerts.notify (the daily summary goes to the channels only)
opportunityOpportunitya manual operation found by the running keeper’s periodic scan. The scan runs only when an alert channel is configured and opportunity is in alerts.notify; otherwise call findOpportunities yourself

on(event, handler) returns a function that unsubscribes. A handler that throws or rejects is logged and never breaks the keeper. Skipped calls are not events (they repeat every tick); they are logged.

An ActionResult has:

FieldMeaning
statusconfirmed, reverted, unknown (sent, no receipt), skipped (not sent), simulated (dry run or preview)
job, market, summarythe job (or operation), the market’s label, a one-line description
reasonwhy it was sent or skipped (earns $0.1000 for $0.0021 of gas, daily gas budget reached …)
kindthe decision: profit, loss, unpaid, forced, skip, collect, forward
wouldSendwhether the policy would send it (meaningful for simulated)
senttrue for confirmed, reverted and unknown
txHash, gasUsedthe transaction, once sent
costUsdthe estimated gas cost, then the actual one after the receipt
expectedRewardUsdwhat the simulation said the call would earn
rewardUsd, claimedUsdwhat the receipt shows it earned and claimed
errora revert, decoded (toString() gives a readable line), or null
opensAtfor a “too early” revert, when the call opens (seconds since the epoch), or null
returnedthe call’s return data from the simulation

One-Shot Runs

keeper.run holds every automatic job once and every manual operation, each naming its market on the call (a label, an address or a pair id). Each connects and syncs first when needed, so it works on a keeper that is not running as well as on one that is. Manual Operations explains what each does.

// One call, the market named on the call. A dry run simulates and reports, and sends nothing.
for (const r of await keeper.run.liquidate("ETH/USD", { dryRun: true })) {
  console.log(r.status, r.summary, r.reason);
  if (r.status === "simulated" && r.wouldSend) {
    console.log(`would earn $${r.expectedRewardUsd.toFixed(4)} for about $${r.costUsd.toFixed(4)} of gas`);
  }
}

// A manual operation returns one result. Reverts in simulation come back decoded.
const fin = await keeper.run.finalizeTermination("0x0000000000000000000000000000000000000000", { dryRun: true });
if (fin.error) console.log(`would revert: ${fin.error.toString()}`);
if (fin.opensAt) console.log(`opens at ${new Date(fin.opensAt * 1000).toISOString()}`);

The automatic jobs return ActionResult[] (one per call planned, or one skipped result saying why nothing was planned) and take { dryRun?, force? }; the list-taking ones accept the list either as an argument or in the options:

MethodLists
liquidate(market, accounts?, opts?)accounts
executeTriggers(market, orderIds?, opts?)orderIds
unload(market, opts?)
executeAdl(market, longBucketWinners?, shortBucketWinners?, opts?)longBucketWinners, shortBucketWinners
refreshPrice(market, opts?)
collect(markets?, opts?)"all" (default) or a list of markets
settleTerminal(market, accounts?, opts?)accounts
sweepExpired(market, orderIds?, opts?)orderIds

The manual operations return one ActionResult and take { dryRun? }: finalizeTermination(market), terminateStalePair(market), terminateScheduledPair(market), settleTerminationProposal(assertionId), executeInsurerTermination(market, proposalId), and under run.factory: settleDeploymentProposal(assertionId), expireStuckDeploymentProposal(pairId), settleIdentifierUpgradeProposal(assertionId), expireStuckIdentifierUpgradeProposal(), activateIdentifierUpgrade(). Ids are 32-byte hex strings (with or without 0x) or bytes.

// Automatic jobs, once. Each keeps its job's profit and loss rules unless force is set.
for (const r of await keeper.run.executeTriggers(MARKET, { dryRun: true })) show(r);
for (const r of await keeper.run.liquidate(MARKET, ["0x0000000000000000000000000000000000000001"], { force: true })) show(r);
for (const r of await keeper.run.sweepExpired(MARKET, [12n, 13n], { dryRun: true })) show(r);
for (const r of await keeper.run.collect("all")) show(r);

A manual operation sends at once unless dryRun is set, so preview it first, as the CLI does:

// Manual operations: simulate first, look at the result, then send.
const preview = await keeper.run.terminateScheduledPair(MARKET, { dryRun: true });
show(preview);
if (preview.status === "simulated" && preview.wouldSend) {
  show(await keeper.run.terminateScheduledPair(MARKET));
}

// The factory's operations sit under run.factory.
show(await keeper.run.factory.settleDeploymentProposal(ASSERTION, { dryRun: true }));

findOpportunities(keeper) returns what bazaar keeper opportunities prints: each Opportunity has operation, market (a label, for people), detail, command (the CLI command, naming the market by address), ready and opensAt, and describe() for one line.

// Every manual operation that a dry run shows would go through now, or that opens later.
for (const o of await findOpportunities(keeper)) {
  console.log(o.describe());
  if (o.ready) console.log(`   bazaar keeper ${o.command}`);
}

A one-shot run takes the same in-flight keys as the running keeper’s jobs, so in one process it never sends a call the keeper is already sending (already in flight: the keeper is sending this call; try again once it lands).

Starting and Stopping

// start() connects, then runs until stop(); it resolves once the shutdown is done.
const stopOnce = (): void => {
  // Waits up to 60 s for in-flight sends, claims earnings if jobs.collect.onShutdown, saves the snapshot.
  void keeper.stop({ timeout: 60 });
};
process.once("SIGINT", stopOnce);
process.once("SIGTERM", stopOnce);

try {
  await keeper.start();
} finally {
  await keeper.close(); // releases the price stream, the subgraph and alert clients
}
MethodWhat it does
start()connects (unless connected), then runs the tick loop, the price stream and the print path until stop(). Resolves once the shutdown has finished; rejects if connecting fails (wrong chain, bad wiring, no deployment block found)
stop({ collect?, timeout? })stops dispatching, ends the price stream at once, waits up to timeout seconds (60) for the loop and the in-flight sends, claims earnings if collect (default jobs.collect.onShutdown), and saves the snapshot. Calling it again returns the same promise
close()releases the price stream, the subgraph and alert clients. Call it after stop(), or after one-shot use
connect()checks the chain and the wiring, loads the snapshot or builds the index, reads the watched markets’ state. Once; start() and run.* call it

A keeper runs once: after stop(), start() throws this keeper has already run and stopped; create a new Keeper to start again. Calling start() while it runs throws the keeper is already running.

Status and State

MemberWhat it is
statusDict()the KeeperStatus object status.json holds: head, index, prices, each market and job, the wallets, halted (fields)
writeStatus()writes status.json now (the running keeper does every 5 s)
runningtrue while start() runs
recentthe latest 50 sent results, newest first
ledgerthe earnings ledger: ledger.summary(sinceSeconds, "job" | "market"), ledger.entries, ledger.spentToday()
storethe index: store.markets (address to Market), store.positions, store.orders
pricesthe SDK’s PriceService: prices.latest, prices.healthy, prices.onPrints(listener)
jobsthe enabled jobs by name
cfg, netthe parsed config, and the resolved network (chainId, factory, official, startBlock)
stateDirwhere the snapshot, ledger and status file live
resolveMarket(ref), findMarkets(ref)a market by label, address or pair id (one, or every one carrying a label)

stateDirFor(cfg) gives the state directory without building a keeper, and listMarkets(cfg) lists the factory’s markets with their labels without indexing anything.

Embedding in a Service

A keeper inside a long-running service, with a health endpoint and running totals for metrics:

const keeper = await Keeper.create(cfg);

// Running totals for a metrics system, fed by the "action" event (sent transactions only).
const totals = { sent: 0, reverted: 0, rewardUsd: 0, gasUsd: 0 };
keeper.on("action", (r: ActionResult) => {
  totals.sent += 1;
  if (r.status === "reverted") totals.reverted += 1;
  totals.rewardUsd += r.rewardUsd;
  totals.gasUsd += r.costUsd;
});

// GET /health: 200 while the keeper is running, has live prices and is allowed to send.
// GET /status: what `bazaar keeper status` reads (the same object as status.json).
const server = createServer((req, res) => {
  const s = keeper.statusDict();
  if (req.url === "/status") {
    res.writeHead(200, { "content-type": "application/json" });
    res.end(JSON.stringify({ ...s, totals }));
    return;
  }
  const healthy = keeper.running && s.prices.healthy && !s.halted && s.block - s.indexed < 64;
  res.writeHead(healthy ? 200 : 503, { "content-type": "application/json" });
  res.end(JSON.stringify({ healthy, block: s.block, indexed: s.indexed, prices: s.prices, halted: s.halted }));
});
server.listen(9464, "127.0.0.1");

let stopping: Promise<void> | null = null;
const shutdown = (): void => {
  stopping ??= keeper.stop().finally(() => server.close());
};
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);

try {
  await keeper.start(); // rejects if connecting fails (wrong chain, bad wiring, no deployment block)
} catch (e) {
  console.error(`the keeper could not start: ${e instanceof Error ? e.message : String(e)}`);
  process.exitCode = 1;
  server.close();
} finally {
  await keeper.close();
}

Points to keep in mind:

  • One keeper per set of wallets, in one process (Running in Production).
  • Handle SIGTERM by calling stop(), and give the process time to finish (up to timeout, plus the shutdown collect).
  • Configure logging with setupLogging before creating the keeper; pass write to route lines into your own logger.
  • Event handlers run on the keeper’s event loop. Keep them short; hand slow work (a database write, a network call) to a queue rather than awaiting it in the handler.
  • The bazaar_sdk.* loggers never log keys or full URLs; your handlers should not either (pass URLs through the SDK’s redactUrl).

Testing Locally with Anvil

Before a keeper spends real gas, you can run it against a local chain: a Bazaar deployment on Anvil, prices from a mock Pyth Pro server whose updates the local deployment’s verifier accepts, and every job enabled. You move the price and the clock yourself and watch each job do its work. This page covers the fastest way (the repository’s end-to-end suite), a local deployment of your own, the mock price server, and a keeper config for Anvil.

You need Foundry’s anvil (and cast, for poking the chain) and the repository built from source (Setting Up a Keeper).

The End-to-End Suite

The repository’s end-to-end tests do everything for you: they replay a recorded deployment of Bazaar’s current contracts on a fresh Anvil (no Bazaar checkout and no compiler needed), list an ETH/USD market, build a book, a stop-loss and a leveraged position, then run the real keeper with every job enabled against the mock price server, moving the price and the clock to give each job its work, and check each outcome on chain. It also reports the keeper’s latency from a price move to its send.

# a fresh Anvil with the flags the suite expects
anvil --port 8546 --block-time 1 --base-fee 10000000 --code-size-limit 100000

# in another terminal, from the repository
npx vitest run -c vitest.e2e.config.ts packages/keeper/test/e2e/keeper.e2e.test.ts
FlagWhy
--block-time 1a block a second, as the scenario’s waits assume
--base-fee 100000000.01 gwei, under the keeper’s default gas.backgroundMaxFeeGwei (0.1), so background work runs
--code-size-limit 100000Bazaar’s contracts are larger than the 24 KB mainnet limit

BAZAAR_E2E_RPC points the suite at another URL (default http://127.0.0.1:8546); with no Anvil answering there, the suite is skipped. Running all three e2e files (npx vitest run -c vitest.e2e.config.ts) adds a deployment smoke test and a run of the frontend SDK through the same scenario. Each file snapshots the chain first and reverts to the snapshot at the end, so the next file replays on the same Anvil; it also means the deployment is gone afterwards.

The Fixtures

packages/keeper/test/e2e/fixtures/ holds what the suite replays:

FileWhat it is
deployment.jsonthe transactions Bazaar’s Anvil deployment (script/DeployBazaar.s.sol) sends, computed from a built Bazaar checkout’s artifacts
MockArbSys.jsonthe bytecode the replay places at the ArbSys precompile (0x…64), where the pair reads its block number
build.mjsproduces the two files from a built Bazaar checkout (node packages/keeper/test/e2e/fixtures/build.mjs <checkout>), with no chain and no compiler

The deployment is 21 transactions from Anvil’s first account: twelve libraries through the deterministic CREATE2 deployer, then MockPythLazer (Pyth’s verifier, with no fee) and the call that makes it trust the public test signer, MockUSDC, MockOptimisticOracleV3, BazaarOracle, the BazaarPair implementation, BazaarPairLens, BazaarPairTerminator and BazaarFactory. Replayed on a fresh Anvil they land at the same addresses:

ContractAddress
BazaarFactory0xc6e7DF5E7b4f2A278906862b61205850344D4e7d
MockPythLazer0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0
MockUSDC0x9A676e781A523b5d0C0e43731313A708CB607508

The fixtures’ README.md records the Bazaar commit they come from, and how to regenerate them whenever Bazaar’s contracts change (together with the SDK’s ABIs, npm run sync-abis).

A Local Deployment of Your Own

To keep a deployment running and point your own keeper at it, deploy from a Bazaar checkout:

# in a Bazaar checkout with its lib/ submodules
forge build script/DeployBazaar.s.sol test/mocks/MockArbSys.sol
anvil --port 8546 --block-time 1 --base-fee 10000000 --code-size-limit 100000

# Anvil's first default account (the public test mnemonic): a test key only
forge script script/DeployBazaar.s.sol --rpc-url http://127.0.0.1:8546 --broadcast \
  --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \
  --code-size-limit 100000

# the pair reads its block number from ArbSys: place the mock at the precompile's address
cast rpc --rpc-url http://127.0.0.1:8546 anvil_setCode 0x0000000000000000000000000000000000000064 \
  "$(jq -r .deployedBytecode.object out/MockArbSys.sol/MockArbSys.json)"

The contracts’ addresses are in broadcast/DeployBazaar.s.sol/31337/run-latest.json. The deployment’s Pyth verifier is MockPythLazer: it charges no fee and trusts one signer, whose key is derived from a public label (keccak256("bazaar.test.pyth-lazer-signer"), the SDK’s lazer.localSignerKey()), so anyone on the chain can sign a price. Only a chain with id 31337 may trust that key: the deployment script refuses, anywhere else, a verifier that trusts it.

A factory with no markets gives the keeper nothing to do, so list one next. The e2e scenario’s Scenario.setup in packages/keeper/test/e2e/chain.ts is a working recipe: it mints MockUSDC to Anvil’s accounts, proposes an ETH/USD listing (Pyth Pro feed 2) with a $5,000 seed (proposePairDeployment), warps 48 hours, settles it (settleDeploymentProposal, which a keeper can also run: bazaar keeper run settle-listing --assertion …), deposits collateral and insurance with updates it signs with the test key, and builds a book, a stop-loss and a position close to liquidation. Listing Markets builds the same listing calls with the SDK.

The Mock Price Server

Bazaar’s pairs have every price update verified by Pyth’s verifier, and on Anvil that is MockPythLazer, which trusts the public test signer. The mock price server is a Pyth Pro endpoint whose updates that key signs, so the keeper (and any SDK client) can use it as prices.endpoint.

bazaar mock-prices --rpc http://127.0.0.1:8546 --price 2=2000
mock Pyth Pro on http://127.0.0.1:8787 (stream ws://127.0.0.1:8787/v1/stream), on the clock of http://127.0.0.1:8546; feeds: 2

It serves what the keeper and the SDK use, each update built by the SDK’s encoder, signed with the test key and stamped with the chain’s time (the latest block’s timestamp plus the time since that block was seen), so Anvil accepts it as fresh whatever you do to the clock:

RouteWhat it serves
WebSocket /v1/streamper subscription, an update at once and one per interval of its channel (real_time runs at 50 ms)
POST /v1/latest_pricethe newest update on the requested channel, for the requested feeds
POST /v1/pricethe fixed_rate@1000ms update stamped on the requested second (for terminate-scheduled), or HTTP 404 for a second the chain has not reached
GET /v1/symbolsthe registry: each feed with its symbol (ETH/USD is Crypto.ETH/USD)
GET /mock/pricethe current prices, by feed number
POST /mock/priceset a feed’s price, pause or resume it, or fix its confidence

Feeds are Pyth Pro feed numbers; --price and POST /mock/price also take the bytes32 a pair reports (its baseFeedId). Prices have exponent −8 and a confidence of 0.1% of the price, never less than one unit. ETH/USD (feed 2, which the keeper prices gas with) starts at 2000 unless --price sets it. Only ETH/USD has a symbol from the command line (MockPro in TypeScript takes symbols for the others), so markets on other feeds are labelled by their listing description. A feed serves its starting price for every past second, so the archive answers for any cutoff.

Set a price while it runs:

curl -X POST localhost:8787/mock/price -H 'content-type: application/json' \
  -d '{"id": 2, "price": 1850.5}'

Add "paused": true to stop the feed generating prices, as a market closing for the night would: its updates keep coming and carry its last price forward with the time that price was generated, so they read as stale, as Pyth’s do. "paused": false resumes it. "confidence": 0 makes its updates carry no confidence (the oracle refuses those, and the keeper never attaches them); "confidence": null goes back to the default.

The server is for local testing only. A POST to /mock/price must be JSON (content-type: application/json); it and a stream connection must carry a Host header that names this machine (localhost, an IP address, or the --host it listens on) and come from no web page on another origin, so a site open in a browser can neither set your prices nor read the stream, directly or through DNS rebinding. A name under .localhost does not count as this machine: a resolver that answers for it can point it anywhere. Its updates verify only on a chain whose verifier trusts the public test key.

It listens on this machine only (localhost, 127.0.0.0/8 or ::1) unless it has a token, since anyone who reaches it can set the prices a keeper acts on. To reach it from another machine (a container, say), give it one from the environment and listen on an address that machine reaches:

MOCK_PRICES_TOKEN=made-up-local-token bazaar mock-prices --host 0.0.0.0 --token-env MOCK_PRICES_TOKEN

Every request but the symbols registry must then present it, the price controls included (-H "authorization: Bearer $MOCK_PRICES_TOKEN"). A keeper sends it as prices.token, names the server by its IP address (another name is refused as DNS rebinding, unless it is the --host itself), and needs prices.allowInsecure: true to send the token over plain http to another machine.

In the Same Process

From TypeScript, MockPro and startMockPro run the same server inside your script, and the MockPro object sets prices directly:

// Updates signed with the public test key the local verifier trusts, stamped with the chain's time
// so Anvil accepts them as fresh. ETH/USD is Pyth Pro feed 2.
const mock = new MockPro({ rpc: RPC, prices: { [ETH_USD_FEED]: 2000 }, symbols: { [ETH_USD_FEED]: "Crypto.ETH/USD" } });
const prices = await startMockPro(mock, { port: 0 }); // port 0: any free port
console.log(`mock Pyth Pro at ${prices.url}, streaming at ${prices.streamUrl}`);

new MockPro({ rpc, prices, symbols, confidenceBp, token }) takes the chain whose clock it follows (a URL, an EIP-1193 transport or an SDK Chain; left out, this machine’s clock, or a clock you give), starting prices and symbols by feed, the confidence in basis points (default 10), and a token every request and stream connection must then present. startMockPro(mock, { host?, port? }) resolves once it listens and returns its url, its streamUrl and a close(). mock.setPrice(feed, usd) moves a price, mock.pause(feed) and mock.resume(feed) stop and restart it, and mock.setConfidence(feed, c) fixes its confidence.

A Keeper Config for Anvil

network: anvil
factory: "0xc6e7DF5E7b4f2A278906862b61205850344D4e7d"

rpc:
  http: http://127.0.0.1:8546
  maxRequestsPerSecond: 200

subgraph:
  url: none                      # index from logs: on a fresh chain the scan is short

prices:
  endpoint: http://127.0.0.1:8787  # bazaar mock-prices: no token needed
  sendMarginSeconds: 0.5         # Anvil's one-second blocks land prints quickly

wallets:
  default:
    type: privateKey
    key: ${ANVIL_KEEPER_KEY}     # in .env: one of Anvil's test keys, never a real one

jobs:
  liquidation: {}
  triggers: {}
  unload:
    recheckSeconds: 2
  adl: {}
  refresh: {}
  terminalSettlement:
    finalizeMaxCostUsd: 1
  sweepExpired:
    minBatch: 1
  collect:
    thresholdUsd: 0.01
    intervalSeconds: 3

budget:
  minEthBalance: 0.001

state:
  dir: ./state-anvil             # separate from any real keeper's; relative to where you start it

engine:
  idleRefreshSeconds: 1.5
  simCooldownSeconds: 1
# .env beside it: Anvil's eighth default account, m/44'/60'/0'/0/7 of the public test mnemonic
ANVIL_KEEPER_KEY=0x4bbbf85ce3377467afe5d46f804f221813b2bb87f24d81f60f1fcdbf7cbf4356

The wallet key is a secret key, so even a public test key goes in .env. Anvil’s accounts start with 10,000 ETH. Then:

bazaar keeper doctor -c anvil.yaml
bazaar keeper start -c anvil.yaml

doctor and the log say factory … is not an official deployment: expected on Anvil. With startBlock left out, the first start finds the factory’s deployment block by searching the chain’s history, which Anvil keeps.

The same keeper in one script, with the mock server, moving the price and pausing the feed:

const cfg = parseConfig({
  network: "anvil",
  factory: FACTORY,
  rpc: { http: RPC, maxRequestsPerSecond: 200 },
  subgraph: { url: "none" }, // index from logs; on a fresh Anvil the scan is short
  prices: { endpoint: prices.url, sendMarginSeconds: 0.5 },
  wallets: { default: { type: "privateKey", key: KEEPER_KEY } },
  jobs: {
    liquidation: {},
    triggers: {},
    unload: { recheckSeconds: 2 },
    adl: {},
    refresh: {},
    terminalSettlement: { finalizeMaxCostUsd: 1 },
    sweepExpired: { minBatch: 1 },
    collect: { thresholdUsd: 0.01, intervalSeconds: 3 },
  },
  budget: { minEthBalance: 0.001 },
  state: { dir: mkdtempSync(join(tmpdir(), "bazaar-keeper-")) }, // a fresh index every run
  engine: { idleRefreshSeconds: 1.5, simCooldownSeconds: 1 },
});
setupLogging("info");
const keeper = await Keeper.create(cfg);
keeper.on("action", (r) => console.log(`${r.job}: ${r.status} ${r.summary}`));
const running = keeper.start();
// Move the price; the keeper sees it on the next update (the mock streams every 200 ms by default).
await new Promise((r) => setTimeout(r, 10_000));
mock.setPrice(ETH_USD_FEED, 1500);
// Stop the feed generating prices, as a market closing for the night would: its updates carry the
// last price forward, so the keeper attaches none of them.
mock.pause(ETH_USD_FEED);
await new Promise((r) => setTimeout(r, 10_000));
mock.resume(ETH_USD_FEED);

Moving the Clock

Many jobs wait for time: the refresh bounty for 5 minutes or a day, the unload floor for 3 minutes, an ADL auction for 10, a termination for its cutoff. Anvil lets you jump:

cast rpc --rpc-url http://127.0.0.1:8546 evm_increaseTime 3600   # one hour later…
cast rpc --rpc-url http://127.0.0.1:8546 evm_mine                # …from the next block

The keeper follows the chain’s clock (the latest block’s time), and the mock stamps every update with it, so prices stay fresh across a jump.

Two things to expect:

  • One price a second. A pair stores the price a call carries only when it was generated in a later second than the price it holds; within a second the first one stands. A move made in the second of the pair’s last stored price reaches the chain with the next second’s update, for any sender, and the keeper plans it then (The Price a Call Is Judged At). The e2e suite waits for the chain to pass that second before each move.
  • The stream’s cadence. The mock streams on the configured channel (fixed_rate@200ms by default), so a move takes up to one interval to reach the keeper before the keeper’s own reaction time.

Troubleshooting

What each doctor failure and each common warning means, and how to fix it. Messages are quoted as the keeper prints them; … stands for the part that varies. Run bazaar keeper doctor first: it checks everything start relies on and names the failing piece. Add --debug before the command (bazaar --debug keeper start) for a full stack trace.

The Config

no config at /home/me/keeper/bazaar-keeper.yaml; run bazaar keeper init to create one No file at the path. Run bazaar keeper init, pass -c <path>, or run from the config’s directory.

the config is not valid: followed by one line per problem The schema refused something. Each line names the key: Unrecognized key(s) in object: 'chunk' is a typo (unknown keys are always refused), a keystore wallet needs path a missing field, Expected number, received string a value of the wrong type. Every key is in Configuration Reference.

… has no official factory address in this release yet; set factory to the deployment you want to keep No network preset carries an official factory address yet. Add factory: "0x…" (quoted).

a custom network needs chainId and factory network: custom needs both.

… holds a secret written into the file (a ${NAME:-default} default counts) … A key, password, phrase, API key, bot token or Discord webhook is written into the YAML. Put the value in .env and write ${NAME} (the secrets rules).

rpc.http is a URL with a key in its path, written into the file … The URL may carry a credential. Put the whole URL in .env (http: ${RPC_HTTP_URL}), or only the key (https://arb-mainnet.g.alchemy.com/v2/${ALCHEMY_KEY}). (The printed suggestion may show the key part masked; write ${NAME} there.) The same applies to subgraph.url, prices.endpoint, alerts.webhook.url and a remote signer’s url.

prices.headers.X-Api-Key holds a value written into the file … Header values must be ${NAME}, or a scheme and a reference: Bearer ${NAME}.

rpc.http: environment variable RPC_HTTP_URL is not set (add it to .env or export it) The .env beside the config lacks the variable, or the keeper runs from a service whose environment does not have it. Check the .env is next to the config file (not the current directory).

… is not valid YAML: … Often a ${NAME} inside a flow mapping ({ botToken: ${TG_BOT_TOKEN} }): write the mapping in block style, one key per line. Or an unquoted value starting with a YAML indicator.

job liquidation uses wallet 'fast', which is not under wallets Add the wallet under wallets, or change the job’s wallet.

A job does not run, and nothing says why A job key with nothing under it (liquidation:) is null, which turns the job off. Write liquidation: {}. doctor’s Jobs line lists the jobs that are on.

prices.endpoint: the 'bazaar' price endpoint has no URLs yet; … bazaar is the default endpoint, and it does not exist yet. Set prices.endpoint: pyth with your own Pyth Pro access token in prices.token, or the URL of a Pyth Pro compatible service.

prices.apiKey is not a setting: the Pyth Pro access token goes in prices.token (a Pyth Core or Hermes key does not work), and prices.endpoint must be pyth or the URL of a Pyth Pro compatible service A config written for Pyth Core. It is reported before any ${NAME} is filled, so an old variable that is gone from .env does not hide it. Replace apiKey with token: ${PYTH_PRO_TOKEN} (a Pyth Pro access token, in .env), and set prices.endpoint to pyth or a Pyth Pro compatible URL.

prices.endpoint names a Hermes (Pyth Core) service, and the keeper sends its Pyth Pro token wherever prices are asked for: … (Or prices.restUrl, prices.streamUrls.) An endpoint left from a Pyth Core setup: the keeper would send its Pyth Pro token there. Set prices.endpoint to pyth, or to the URL of a Pyth Pro compatible service.

prices: the price token (prices.token) holds a line break or another character a request header cannot carry: re-enter it The token in .env was pasted with something around it. Paste it again, on one line.

rpc.send is 'sequencer', but arbitrum-sepolia has no sequencer endpoint preset Only Arbitrum One has one. Remove rpc.send, or give a URL.

The RPC

✘ RPC … unreachable: … The URL is wrong, the provider is down, or the key is invalid. The printed URL is masked; check the value in .env.

✘ RPC chain 1, but network arbitrum-one is chain 42161 (start: the RPC is on chain 1, but network arbitrum-one is chain 42161.) The URL is for another chain. Use the network’s URL, or fix network.

⚠ RPC … no eth_call state overrides: rewards cannot be measured before sending, so paid jobs will skip The node refused (or ignored) an eth_call with a state override, which the keeper needs to place its probe contract and measure rewards. The keeper still runs: calls are simulated directly, reverts are still caught, but every reward reads as 0, so paid calls fail the profit rule and only unpaid duties and jobs with allowLoss send. Use a provider whose eth_call accepts the state-override parameter (Arbitrum Nitro nodes do; some gateways strip it), and run doctor again.

could not find the factory's deployment block (…). Set startBlock in the config … A first start without a subgraph needs the factory’s deployment block, and the RPC could not answer historical eth_getCode reads (the node may not keep historical state). Set startBlock to the block the factory was deployed in (a block explorer shows it), use an RPC that keeps historical state, or configure a subgraph.

the node refused logs for 2000 blocks (…); asking for 1000 at a time. A lower rpc.logChunk avoids the refused requests Logged at info level, once per scan. The provider caps eth_getLogs ranges or results. The keeper halves the range until the node accepts it (down to one block) and widens it again after eight successes, so it keeps working; set rpc.logChunk to what your provider allows (often 500 to 10,000 blocks) to skip the refused requests.

engine step failed: … (at most every 30 s) A tick failed: often a rate limit (HTTP 429) or a timeout. The next tick resumes where it stopped. Persistent: lower rpc.maxRequestsPerSecond, raise engine.idleRefreshSeconds, or use a faster plan.

chain reorganized below block …; re-reading the last 64 blocks The stored head’s hash changed. Nothing to do: the keeper rewinds and re-applies.

The Factory

✘ Factory 0x…: … The factory cannot be read: the address is wrong, has no code on this chain, or is not a Bazaar factory. Check the address against the deployment and the network.

✘ Wiring the lens belongs to a different factory (or the terminator …, … does not report a factory) (start: refusing to start: ….) The factory points at a lens or terminator that does not point back at it. That is not a deployment the keeper will keep; check you have the right factory.

factory 0x… is not an official deployment (a warning) The factory is not a network preset’s official one. At present no preset has one, so every deployment shows this. It changes nothing in how the keeper runs.

The Subgraph

✔ Subgraph synced to block … (N blocks behind) A lagging subgraph is harmless: the keeper bootstraps from the subgraph’s own block and replays the chain’s logs from there, and every decision is made from chain state. A very large lag only makes the first replay longer.

⚠ Subgraph … with indexing errors (start: the subgraph reports indexing errors; chain reads will correct what it misses) The subgraph may be missing accounts or orders. The keeper uses what it has and re-reads every candidate from the chain; to be complete, configure a healthy subgraph or subgraph.url: none.

✘ Subgraph …; the keeper falls back to chain logs (start: subgraph unavailable (…); indexing from chain logs instead) The subgraph cannot be reached or returned something unusable. A first start indexes from logs instead (which needs startBlock or historical state); later starts do not use the subgraph.

Prices

✘ Prices … The price endpoint failed: a bad or missing token (prices.token; price endpoint refused the request (HTTP 401) or 403), a wrong URL, or a service down. A token that does not cover a feed or the channel can be refused the same way.

⚠ Prices the fixed_rate@200ms stream is not delivering (…): the keeper would poll the request API every 1 s instead The request API answered but the WebSocket stream did not: a stream URL is wrong (prices.streamUrls), a proxy or firewall blocks WebSockets, or the token is refused on the stream. The keeper still runs on polls, a step behind the stream.

price stream refused the subscription for feed(s) N on …, or price stream left out feeds it does not serve on … Pyth does not serve those feeds on the configured channel (prices.channel) for your token: a feed whose fastest channel is slower than the one asked for, or a feed the token does not cover. Choose a slower channel, or ask Pyth. Other feeds are unaffected.

price stream went quiet: no new update for 10 s No endpoint brought a new update for prices.streamIdleTimeoutSeconds: every fixed-rate channel sends one per interval whether or not a price changed, so the connections are dropped and reconnected.

a price update is signed by 0x…, which is not a signer Pyth's verifier trusts: refusing it With prices.checkSigners on, the keeper asks the deployment’s verifier whether it trusts each update’s signer, drops the stream endpoint that sent one it does not (it reconnects later), and refuses a request API response that carries one. An endpoint that is not Pyth’s (a proxy, a local mock) must pass Pyth’s signed bytes through unchanged.

prices.checkSigners is off: price updates are used whoever signed them. … At start, and in doctor’s Prices lines. Without the check, a forged update fails only on chain, and the ETH/USD print that prices gas reaches no contract. Turn it back on unless the keeper cannot keep up with the stream.

⚠ Prices N/M markets have a print, or at runtime price endpoint sent no print for feed 0x…: markets on it get no price until it does … The endpoint serves no print for some markets’ feeds: it does not carry that feed, the token does not cover it, Pyth has no price or no confidence for it (the oracle refuses a price without one, so the keeper never attaches it), or the feed is closed (a market with trading hours outside them). Markets on it get no fresh price: liquidation still works from a stored price up to 9.5 minutes old, other price-driven jobs wait. Check the endpoint serves the feed.

… not fresh right now (AAPL/USD, …) Those markets have a print, but not one recent enough to send: usually a session market outside its hours. Normal.

price endpoint: … (repeated), and status shows prices down: … The stream or the polls are failing. After three failures in a row prices are marked unhealthy and a stream falls back to polling for a minute, then tries the stream again. Calls that need a fresh print wait. Fix the endpoint; the keeper recovers on its own.

ignored a print for feed 0x… dated N s ahead of the clock … The endpoint’s print is in the future relative to the chain’s clock: the endpoint is wrong, or (on a local chain) the clock was moved backwards. Prints more than 5 s ahead are ignored.

⚠ Prices no fresh, plausible ETH/USD print: gas is priced at the $3000 fallback (never below gas.ethUsdFloor, $1000) The endpoint serves no ETH/USD print, or one older than gas.ethUsdMaxAgeSeconds, or one outside $100 to $1,000,000. Gas is priced at gas.ethUsdFallback, never below gas.ethUsdFloor. The keeper works, but the USD costs it books are only as good as the fallback: keep it near the real price, or fix the feed.

Wallets

wallet default: wrong keystore password The password in .env (or typed) does not open the keystore.

wallet default: cannot read keystore /…/default.json: No such file or directory The path is wrong, or (in Docker) the file is outside the mounted directory.

no wallet configured: watch-only, nothing will be sent There is no wallets section. Results end with (watch-only: no wallet configured), and collect says needs a wallet to simulate (watch-only).

Keystore Password Prompts in Docker

wallet default (default.json) needs a password: set wallets.default.password: ${KEEPER_KEYSTORE_PASSWORD} in the config and KEEPER_KEYSTORE_PASSWORD in .env or the environment, or start the keeper in a terminal to be asked for it The config has no password for a keystore, and the keeper has no terminal to ask on: a detached container, a systemd service, pm2. Add password: ${KEEPER_KEYSTORE_PASSWORD} under the wallet and KEEPER_KEYSTORE_PASSWORD=… to the .env beside the config (or the service’s environment). In Docker, docker run -it gives the keeper a terminal for a one-off run, but not a detached one; let init store the password when it asks.

Low Balance

✘ Wallet default 0x…, 0.00100 ETH: below budget.minEthBalance; fund it At runtime: a lowBalance alert and, in status, sending paused: default (0x…) holds 0.00100 ETH, below budget.minEthBalance. While any wallet is below budget.minEthBalance, nothing is sent from any wallet. Send ETH to the wallet; sending resumes within a minute. Lower minEthBalance only if you know your calls’ gas.

Markets

Labels Shared by Several Markets

jobs.liquidation.markets: 'ETH/USD' names 2 markets (0x…, 0x…); it covers each of them, in the statuses each job runs in. Write an address to choose one A label is not unique: a market relisted after a termination keeps its predecessor’s label, and a composite is labelled after its base. A job’s label covers every market carrying it; each job acts only on those in a state it runs in (so a live-market job acts on the live one, while terminal settlement and collect still reach the terminated one). To choose one, write its address. bazaar keeper markets lists them.

For a one-shot run a shared label is refused: 'ETH/USD' matches several markets (0x…, 0x…); use an address.

no market called 'XYZ/USD'. Markets: … No market carries that label or description. Check the spelling against bazaar keeper markets.

0x… is not a market of this factory An address under markets or exclude, or in --market, is not one of this factory’s markets.

the jobs' markets were not updated for the new listing: … A new market was listed while the keeper ran, and re-resolving the jobs’ markets failed. Each job keeps the markets it had; fix the config and restart to pick the listing up.

Skips and Pauses

A call that is not sent is logged with its reason (each distinct reason at most every five minutes at info level). The common ones:

ReasonMeaning
below the profit rule: earns $… for $… of gas, needs ×1.25 and $0.001 net (allowLoss is off)the call would not pay enough; normal for most candidates. See Budgets
pays nothing, and this job does not take losses (allowLoss is off)an unpaid call without consent
would lose $…, above the $… per-call cap / …'s daily loss budget is spent / the global daily loss budget is spenta loss cap
daily gas budget reached ($… of $… spent) / daily ETH spend cap reached …a spend cap: nothing more is sent until older calls leave the 24-hour window
base fee … gwei is above the … gwei ceilinggas.maxFeeGwei, or gas.backgroundMaxFeeGwei for background work
the call's cost could not be priced in USD; …no usable gas price: check the RPC and ETH/USD
the book has nothing the vault may sell into at this priceunload: no bid within reach yet
the refresh would not change the vault's health statea health poke that would change nothing
a decoded revert (BazaarPair__…)the call reverts in simulation: another keeper got there first, or the state is not what the index thought. Retried after a cooldown
already in flight: the keeper is sending this call; try again once it landsa one-shot run of a call the keeper in the same process is sending

Paused Jobs

liquidation paused on ETH/USD for 15 min after repeated reverts (a jobPaused alert) safety.pauseAfterReverts (3) transactions in a row reverted on chain: the state changed between the simulation and the block, usually because a faster keeper took the same work. The job stops on that market for safety.pauseMinutes (15), then resumes. Pauses are kept by label, so other markets sharing the label pause too. Frequent pauses mean you are losing races: give the job its own wallet, use rpc.send: sequencer on Arbitrum One, and run closer to your RPC.

the transaction reverted on chain (state changed after the simulation) One such revert. You paid its gas; the ledger books it as reverted.

no receipt within the timeout; the nonce is re-read before the next send The transaction was sent but no receipt arrived within 90 s. It is booked as unknown at its estimated cost, and its candidate stays locked for 5 minutes in case it lands. Check the hash on an explorer. If the send itself got no answer, an endpointDown alert says so.

send refused: … The node refused the signed transaction (insufficient funds, a fee too low for the node’s minimum). Nothing went out.

Status and Commands

No keeper has run with this config yet. Start one with bazaar keeper start. status found no status.json for this config’s network and factory. Check -c names the same config the running keeper uses (the state directory depends on state.dir, the chain and the factory).

not running (last seen N min ago) The status file is older than 30 seconds: the keeper stopped or hangs. Check its log.

init needs an interactive terminal. Copy bazaar-keeper.example.yaml and edit it instead. Run init in a terminal (in Docker, docker run -it), or write the config by hand.

Not sent: pass --yes to send without confirming. A manual operation without a terminal to confirm on. Add --yes once you have checked the preview.

liquidate needs --market (and the like) The operation needs that option (Commands).

Commands are slow before the first start markets, opportunities, run and wallet list connect as start does: with no snapshot yet they build the index first, and do not save it. Start the keeper once; afterwards they load its snapshot.

Connecting

Every use of the SDK starts with a connection: one chain, one Bazaar deployment, and optionally a price source and Bazaar’s subgraph. This page covers Bazaar.connect and connect() with every option, the transports they accept, price sources (and how to keep a price API key out of a browser), the subgraph, the integrator address, the chain check, checking market addresses, and the errors a connection can raise.

What a connection is, and what the SDK caches on it, is on Core Concepts.

Bazaar.connect

const bz = await Bazaar.connect({
  transport: window.ethereum, // an EIP-1193 provider, a viem client, a JsonRpc, or a JSON-RPC URL
  factory: FACTORY, // the deployment: everything else is read from it
  chainId: 42161, // optional: refuse any other chain
  prices: { token: pythToken }, // optional: Pyth Pro client options, a ProClient, or any source
  subgraph: "https://subgraph.example.com/bazaar", // optional: a URL, { url, apiKey }, or a source
  integrator: MY_FEE_ADDRESS, // optional: named on every order this connection creates
});

bz.chainId; // 42161
bz.deployment; // { factory, lens, terminator, oracle, usdc }
bz.ctx; // the BazaarContext every function runs on

Bazaar.connect(options) does four things:

  1. Registers the user-facing error messages (installUserMessages()), so a decoded revert reads as a sentence from then on.
  2. Wraps the transport in a Chain: a URL becomes the SDK’s own HTTP client (JsonRpc), anything else is used as given.
  3. Reads the chain id and, in one batched call alongside it, the factory’s lens, pairTerminator, oracle and usdc. (The first batch also detects how this node batches; see Transports.)
  4. Checks the chain id against chainId, when given, and that the factory answered all four reads.

It returns a Bazaar with the context bound into every area. Nothing else is read until you ask: the market list loads on the first listMarkets (or anything that resolves a market by name).

OptionTypeDefaultMeaning
transportTransport | stringrequiredAn EIP-1193 provider (a browser wallet’s, a viem client, a JsonRpc) or a JSON-RPC URL. See Transports.
factoryAddressrequiredThe deployment’s BazaarFactory. Everything else is read from it. Take it from Bazaar’s deployments page: anyone can deploy a fork.
chainIdnumbernoneRefuse to connect on any other chain.
pricesProClientOptions | ProClient | PriceUpdateSource | nullnullWhere signed Pyth Pro updates come from. Options ({ token, ... }) or a ProClient are made into a proPriceSource. Without one the connection is read-only for prices: every call that carries a price throws a ConnectError. See Prices.
checkSignersbooleantrueHave the price source use only updates signed by a signer the deployment’s Pyth verifier trusts. See Prices.
subgraphstring | { url, apiKey? } | SubgraphSource | nullnullBazaar’s subgraph. A string or { url, apiKey } is made into an httpSubgraph. Needed only for history (and for market descriptions in the market list). See The Subgraph.
integratorAddress | nullthe zero addressNamed on every order this connection creates; earns the integrator fee on their fills. See The Integrator Address.

The returned object has:

MemberWhat it is
bz.ctxThe BazaarContext: chain, chainId, factory, lens, terminator, oracle, usdc, prices, subgraph, integrator.
bz.chainIdThe chain id the transport reported.
bz.deployment{ factory, lens, terminator, oracle, usdc }.
bz.markets, bz.account, bz.trade, …Every area’s context-first functions, bound.
bz.simulate(call, opts?), bz.send(wallet, call, opts?), bz.waitForReceipt(hash, opts?)The write path (Sending Transactions).
bz.livePrices(opts?)A live price stream for every listed market (Live Prices).

connect() and Bazaar.from

connect(options) is what Bazaar.connect calls. It returns the bare BazaarContext, for code that uses the per-area functions (markets.getMarket(ctx, ...)), and takes sources only: its prices must be a PriceUpdateSource and its subgraph a SubgraphSource, not URLs. Bazaar.from(ctx) wraps a context in the facade.

// connect() is what Bazaar.connect calls: it takes sources, not URLs, and returns the bare context.
const ctx = await connect({
  transport: rpc,
  factory: FACTORY,
  chainId: 42161,
  prices: proPriceSource({ token: pythToken }),
  subgraph: history.httpSubgraph("https://subgraph.example.com/bazaar"),
  integrator: null, // zero address: no integrator fee on this connection's orders
});
const wrapped = Bazaar.from(ctx);
OptionTypeDefault
transportTransport | stringrequired
factoryAddressrequired
chainIdnumbernone
pricesPriceUpdateSource | nullnull
checkSignersbooleantrue
subgraphSubgraphSource | nullnull
integratorAddress | nullthe zero address

The SDK keeps its caches per context object. Two contexts built from the same options (or a copy made with { ...ctx, prices }) each list the markets once.

Transports

A Transport is anything with EIP-1193’s request({ method, params }). Four kinds are common:

// A browser wallet: reads, and the wallet the user signs with.
const viaWallet = await Bazaar.connect({ transport: window.ethereum, factory: FACTORY });

// A viem client (wagmi's clients are viem clients too).
const client = createPublicClient({ chain: arbitrum, transport: http("https://arb1.arbitrum.io/rpc") });
const viaViem = await Bazaar.connect({ transport: client, factory: FACTORY });

// A URL: the SDK builds a JsonRpc with its defaults.
const viaUrl = await Bazaar.connect({ transport: "https://arb1.arbitrum.io/rpc", factory: FACTORY });

// A JsonRpc of your own, to change those defaults.
const rpc = new JsonRpc(`https://arb-mainnet.g.alchemy.com/v2/${process.env.ALCHEMY_KEY}`, {
  perSecond: 10, // requests per second (default 25)
  timeoutMs: 15_000, // per request (default 30 s)
  retries: 5, // after transient failures, doubling from 500 ms (default 3); never for sends
  headers: { "x-client": "my-bot" }, // sent with every request
  maxResponseBytes: 32 * 1024 * 1024, // default 64 MiB
});
const viaJsonRpc = await Bazaar.connect({ transport: rpc, factory: FACTORY });
console.log(rpc.displayUrl); // "https://arb-mainnet.g.alchemy.com/v2/***": safe to log
  • A browser wallet’s provider (window.ethereum, or one from an EIP-6963 picker). Reads go through the wallet’s own RPC. The same object is the wallet you send and sign with.
  • A viem client. A PublicClient or WalletClient has a matching request; wagmi’s clients are viem clients.
  • A URL, http(s)://. The SDK makes a JsonRpc with its defaults.
  • A JsonRpc of your own, to change those defaults:
JsonRpc optionDefaultMeaning
perSecond25Requests per second the client allows itself; extra requests queue in order.
timeoutMs30,000Per request.
retries3Retries after a failed request (HTTP errors, connection errors, timeouts, unreadable answers), waiting 500 ms, then 1 s, 2 s, … Never for a send (eth_sendRawTransaction, eth_sendTransaction, …). A JSON-RPC error answer (a revert) is not retried.
headersnoneSent with every request. A value HTTP cannot carry (a line break) is refused when the client is made.
fetchglobalThis.fetchThe fetch to use.
maxResponseBytes64 MiBA larger answer is refused, not retried.

A JsonRpc never puts its URL or a header’s value into an error message: displayUrl is the URL with every part that may be a key masked. Its errors are HttpError (a non-2xx answer, with status), RpcError (the node’s JSON-RPC error, with code and revertData) and ResponseTooLargeError.

What the node needs. Batched reads go through the SDK’s probe contract placed with an eth_call state override where the node honors overrides, through Multicall3 where it is deployed, and as parallel eth_calls otherwise; the SDK detects which once per connection. Any node works. Live updates use eth_getLogs, and wake on eth_subscribe("newHeads") when the transport emits EIP-1193 "message" events (a JsonRpc does not, so a URL connection polls). See Live Updates.

Prices

Calls that need the price (order creation and amendment, trigger execution, a withdrawal with a position, an insurance exit) carry a signed Pyth Pro update, fetched from the connection’s PriceUpdateSource when the call is built. Reads use it too: the market list takes labels from Pyth Pro’s symbols registry, and snapshots ask the source for the price of trading-hours markets to tell whether their venue is open.

Pyth Pro needs a token on every request and every stream connection. There are two kinds: the long-lived API key, and short-lived tokens minted from it (they live at most 15 minutes). A server (a bot, a keeper, a relayer) may hold the key. A browser must not: code in a bundle is public. A frontend therefore either gets short-lived tokens from its own backend (below), or leaves prices out and routes orders through a relayer that attaches the update (Gasless Trading).

prices takes three forms:

  • Options for a client: { token }, where token is the API key (servers), or a function that returns a short-lived token and is called for each request and each connection attempt. The other ProClient options go in the same object.
  • A ProClient you made, to share it with a live stream.
  • A PriceUpdateSource you build: proPriceSource(...), a LivePrices stream, or your own.
// The options form of `prices` builds this source; build it yourself to keep hold of its client.
const prices = proPriceSource({
  token: process.env.PYTH_PRO_API_KEY, // sent as "Authorization: Bearer <key>": the key itself, on servers only
  channel: "fixed_rate@200ms", // the default; also "real_time", "fixed_rate@50ms", "fixed_rate@1000ms"
  timeout: 5, // seconds per request (default 10)
});
const withPrices = await Bazaar.connect({ transport: rpc, factory: FACTORY, prices });

// One ProClient can serve both the calls' updates and a live stream.
const pro = new ProClient({ token: pythToken });
const shared = await Bazaar.connect({ transport: window.ethereum, factory: FACTORY, prices: pro });
const live = await shared.livePrices(); // streams through the same client

// Connecting has the source refuse any update not signed by a signer the deployment's Pyth verifier
// trusts (one cached read per signer). The chain checks every update a call carries either way.
const unchecked = await Bazaar.connect({ transport: rpc, factory: FACTORY, prices: { token: pythToken }, checkSigners: false });

proPriceSource(options | ProClient) and new ProClient(options) take:

OptionDefaultMeaning
tokennoneA string, or () => string | Promise<string>. Sent as Authorization: Bearer <token>; on a stream connection in a browser, in the subprotocol list instead, since a browser cannot set a WebSocket’s headers.
channel"fixed_rate@200ms"The channel streamed and read by latest: real_time, fixed_rate@50ms, fixed_rate@200ms or fixed_rate@1000ms. A feed is served on its registry min_channel and every slower one.
streamUrlsPyth’s threeThe WebSocket endpoints. The stream connects to all of them, as Pyth requires, and yields each update once.
restUrlPyth’sBase URL of the request API (/v1/latest_price, /v1/price). A URL with a query or a fragment is refused: a key goes in token or headers.
symbolsUrlPyth’sBase URL of the symbols registry, which needs no token. No query or fragment either.
headersnoneExtra request headers for the request API and the stream (an endpoint behind your proxy), on a stream connection wherever the runtime lets a WebSocket set headers (not in a browser). Never sent to the symbols registry.
timeout10Seconds a request may take (the stream has no limit).
fetchglobalThis.fetchThe fetch to use.
WebSocketthe global oneThe WebSocket constructor to stream with. Node before 22 has none: pass a package’s, such as ws.
streamAuthby runtime"header" or "subprotocol": how a stream connection presents the token. Defaults to the subprotocol in a browser or a web worker, the header elsewhere.
isTrustedSignernone(signer) => boolean: when set, the signer of each update the client takes is recovered and an update it refuses is never used. connect sets it for you (below).
allowInsecurefalseSend credentials over plain http:// or ws:// to an endpoint that is not this machine: the token and headers, and a user name, password or query inside an endpoint’s URL. Refused otherwise. Only a host that is exactly localhost, an address in 127.0.0.0/8 or [::1] counts as this machine (a name under .localhost does not: it may be resolved through DNS).
clockthe system clockUnix seconds a streamed update’s own timestamp is judged against (a keeper passes the chain’s).
maxUpdateAhead60 (MAX_UPDATE_AHEAD)Seconds a streamed update may be stamped ahead of clock; an endpoint sending one further ahead is dropped and reconnected later.

The source has latest(feedIds) (one request for exactly those feeds, so the update carries them and nothing else, both legs of a composite in one; and the second the oldest of their prices was generated in), atTick(feedIds, second) (the one-second channel’s update for a past second, which settlement proofs are made of), prints(feedIds) (the parsed prints) and client, its ProClient. Every price is read from the signed update itself, never from the JSON an endpoint sends beside it, so a dishonest endpoint can only withhold prices, not change them, and every update is kept exactly as Pyth’s verifier reads it: bytes an endpoint appends to one never reach a call’s calldata.

The client keeps its credentials out of sight: the token, the headers and the configured URLs are private fields, so printing or serializing a client (or a context, or a PriceService) shows none of them; every message it produces has them masked and every URL in it redacted; and a request carrying a token or headers never follows a redirect.

Whose signature. Pyth signs every update, and the chain accepts one only from a signer its verifier contract trusts. connect gives the source the same test: it reads the verifier from the deployment’s oracle (pythLazer()) and asks it isValidSigner(signer) for each new signer, keeping the answer for a minute. An update signed by anyone else is refused before it becomes a price your page shows or a call carries. Checking costs one signature recovery per update; pass checkSigners: false to leave the source as you gave it. The chain verifies every update a call carries either way.

Feed IDs. A Pyth Pro feed is a number (BTC/USD is 1). The contracts hold it in a bytes32 (0x00…01), and the SDK uses that form everywhere a feed ID appears; feedIdHex(1) and proFeedId(id) convert. A composite market’s ID is a hash, and its two legs are raw feed IDs.

Tokens for a Browser

Your backend exchanges the API key for a short-lived token and hands that to your page:

const KEY = process.env.PYTH_PRO_API_KEY ?? ""; // stays on the server
const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN ?? "https://app.example.com";

// One token serves every visitor until a minute before it expires (Pyth's tokens live at most 15 minutes).
let held: MintedToken | null = null;
async function currentToken(): Promise<MintedToken> {
  const now = Date.now() / 1000;
  if (!held || (held.expiresAt ?? 0) - 60 < now) held = await mintProToken(KEY);
  return held;
}

createServer(async (req, res) => {
  res.setHeader("access-control-allow-origin", ALLOWED_ORIGIN);
  if (req.method !== "GET" || req.url !== "/api/pyth-token") {
    res.writeHead(404).end();
    return;
  }
  // Gate this like any endpoint of yours (a session, a rate limit): whoever is handed a token can
  // read prices on your key until it expires.
  try {
    const { token, expiresAt } = await currentToken();
    res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
    res.end(JSON.stringify({ token, expiresAt }));
  } catch (e) {
    console.error(e instanceof Error ? e.message : e); // the SDK's errors never hold the key
    res.writeHead(502).end();
  }
}).listen(8787);

The page asks for one when it needs it, and keeps it until shortly before it expires:

// In a browser: short-lived Pyth Pro tokens from your own backend, which holds the API key. The
// endpoint answers { token, expiresAt }; a new one is fetched a minute before the held one expires.
const pythToken = cachedToken(() => fetch("/api/pyth-token").then((r) => r.json()));

cachedToken(mint, { margin }) calls mint only when it holds no token or the one it holds expires within margin seconds (default 60), and shares one call among concurrent requests. A token whose expiry the endpoint does not give is reused for five minutes. Gate the endpoint like any other of yours: whoever is handed a token can read prices on your key until it expires.

Your Own Source

A PriceUpdateSource is one method, and two optional ones:

MethodReturns
latest(feedIds){ update: Hex[], publishTime }: the update a call’s priceUpdate takes, carrying those feeds, and the second the oldest of their prices was generated in (Unix seconds).
atTick?(feedIds, second)The one-second channel’s update stamped exactly second, as a ProUpdate, or null when there is none. Only termination settlement uses it.
useSignerCheck?(check)Called by connect with the deployment’s signer test. Leave it out if your source cannot check signers.

A source that is not a proPriceSource gives the SDK no symbols registry and no parsed prints: the market list then takes labels from Pyth’s own registry (PYTH_PRO.symbolsUrl), and snapshots judge a trading-hours venue only from live prices you pass them.

The Subgraph

History (trades, candles, orders and their events, positions, fees, liquidations, insurance, proposals and votes) comes from Bazaar’s subgraph. The market list also takes each market’s listing description from it, when it can; every other read comes from the chain.

const withHistory = await Bazaar.connect({
  transport: rpc,
  factory: FACTORY,
  subgraph: history.httpSubgraph("https://gateway.thegraph.com/api/subgraphs/id/...", process.env.GRAPH_API_KEY, {
    timeoutMs: 10_000, // per query (default 30 s)
  }),
});
const lag = await withHistory.history.subgraphLag(); // how far the index trails the chain

subgraph takes a GraphQL URL, { url, apiKey }, or a SubgraphSource (anything with query(query, variables)). The URL forms build history.httpSubgraph(url, apiKey?, options?), which POSTs { query, variables } and sends the key as Authorization: Bearer <key> (The Graph’s gateway also takes it in the URL path; such a URL is masked in errors). Its options:

OptionDefaultMeaning
timeoutMs30,000Per query.
headersnoneExtra request headers.
fetchglobalThis.fetchThe fetch to use.

The same rule about keys applies as for prices. Without a subgraph, every history function throws a ConnectError; the rest of the SDK is unaffected. See History.

The Integrator Address

An order names an integrator, an address that earns 0.25 bp of each fill’s notional from the side of the fill that carries it (see Bazaar’s integrator guide). The connection’s integrator is written into every order trade.createOrder and trade.prepareOrder build (and so into gasless order requests). It is your users’ surcharge as well as your revenue: leave it unset (the zero address) to charge none. Collect what it earns with trade.claimFees on each market.

Chain Checks

With chainId given, connect throws a ConnectError when the transport is on another chain. Give it in a frontend: a wallet can be on any network, and a factory address means nothing on the wrong one. The SDK does not follow the wallet’s chainChanged events: listen for them yourself and connect again.

Signing (permits, gasless requests) also checks the clocks: deadlines are counted from the lower of the latest block’s time and this device’s clock, and a ClockSkewError refuses to sign when they differ by more than two minutes (except on local development chains, ids 31337 and 1337). Its message tells the user to set their clock to update automatically.

Checking Market Addresses

A market address that reaches your code from a link, a query string or a subgraph row is untrusted. Every builder checks its pair with the factory (factory.isPair) before building anything for it, and requirePair makes the same check for your own code:

// An address from a link or a query string is untrusted: ask the factory before using it.
const fromLink = new URLSearchParams(location.search).get("market") as Address;
const pair = await requirePair(bz.ctx, fromLink); // checksummed, or an InputError on "pair"

requirePair(ctx, address) resolves to the checksummed address, or rejects with an InputError on pair (“… is not a market of this Bazaar deployment, so nothing will be built or signed for it.”). A confirmation is cached per connection; a refusal is not, so a market listed later is accepted. markets.getMarket resolves only the factory’s own markets, so a market found through it needs no further check.

What Can Go Wrong

try {
  await Bazaar.connect({ transport: window.ethereum, factory: FACTORY, chainId: 42161 });
} catch (e) {
  if (e instanceof ConnectError) {
    // "connected to chain 421614, expected 42161", or
    // "0x... does not answer as a Bazaar factory on chain 42161"
    console.error(e.message);
  } else {
    console.error(errorMessage(e)); // the wallet or the RPC failed
  }
}
ErrorMessageWhy
ConnectErrorconnected to chain 421614, expected 42161The transport (the wallet) is on another chain.
ConnectError0x... does not answer as a Bazaar factory on chain 42161The factory’s reads reverted: the address is some other contract.
viem’s AbiDecodingZeroDataErrorCannot decode zero data ("0x") ...The factory address has no contract on this chain at all (the reads “succeed” with empty answers).
viem’s InvalidAddressErrorAddress "..." is invalid.factory or integrator is not an address.
HttpErrorHTTP 401 from https://.../v2/***: the provider refused the request; check the key in the URLThe RPC refused the request.
TypeErrorJsonRpc: the ... header's value holds a line break, ...A header value (often from an .env file) has a stray character.
ProErrorthe price token (prices.token) holds a line break ...The same, for the Pyth Pro token. Raised when the client is made, or when a token provider returns one.
ConnectErrorthis call needs a fresh Pyth Pro price: connect with a price source (prices)Later, from a builder: the connection has no prices.
ConnectErrorhistory comes from Bazaar's subgraph: connect with a subgraph sourceLater, from history: the connection has no subgraph.

errorMessage(e) shows these as “Something went wrong. Please try again.” (they are not the user’s doing); log e.message for the detail.

Markets

This page covers reading markets: discovering them and their labels, a market’s whole live state at one block (its snapshot), the status and flags a UI gates its buttons on, funding for display, and the order book aggregated into price levels.

The samples assume bz connected as in Connecting, with a price source.

Listing Markets

const list = await bz.markets.listMarkets(); // cached per connection after the first call
for (const m of list) {
  console.log(m.label, m.address, m.hours, m.composite ? `composite of ${m.legSymbols.join(" and ")}` : m.legSymbols[0]);
}
const fresh = await bz.markets.listMarkets({ refresh: true }); // only markets listed since are described

const eth = await bz.markets.getMarket("eth/usd"); // a label (any case), a pair address, or a pair id
const byAddress = await bz.markets.getMarket(eth.address);

bz.markets.listMarkets(options?) returns every market the factory has deployed, in the factory’s order (factory.getAllPairs), each with the facts that never change about it. The first call reads the pair list, describes each pair (its pair id, feeds and trading calendar: two batched reads), takes the listing descriptions from the subgraph when the connection has one, and fetches the feeds’ Pyth symbols for the labels. The list is then cached on the connection: later calls make no request.

OptionDefaultMeaning
refreshfalseRead the factory’s pair list again. Markets already known keep their facts; only new ones are described.
metadatathe registry of the connection’s Pyth Pro client when it has one, else Pyth’s (PYTH_PRO.symbolsUrl)Where feed symbols come from: a symbols registry’s base URL, or an object with symbols() (a ProClient is one). A connection’s own registry is the only one asked, so a local mock’s feed numbers never get Pyth’s symbols. null skips symbols, and labels are then short addresses. A listing is kept for 10 minutes; an empty or failed one is not kept.

Each entry is a MarketInfo:

FieldTypeMeaning
addressAddressThe pair contract: what every other function takes.
labelstringFor display: “BTC/USD”, “AAPL/USD”, from the feed’s Pyth symbol. A composite (an asset priced through an FX leg) is labelled in USD. A market whose symbols cannot be fetched is labelled with the start of its address.
pairIdHexThe pair’s id in the factory.
baseFeedIdHexThe pair’s price id: a Pyth feed id, or a composite’s id.
legsHex[]The raw Pyth feeds: one, or base then quote for a composite.
legSymbolsstring[]Each leg’s Pyth symbol (“Crypto.BTC/USD”, “Equity.US.AAPL/USD”); "" where the registry has none.
legFeeds(ProSymbol | null)[]Each leg’s entry in Pyth Pro’s symbols registry: id, symbol, assetType, minChannel (the fastest channel it is served on), state, schedule. null where the registry has none or was not read.
compositebooleanPriced from two feeds composed by the oracle.
invertQuotebooleanA composite’s quote leg is inverted.
continuousbooleanTraded 24/7.
hours"24/7" | "trading-hours"The same, as text. A trading-hours market (stocks, FX) keeps running on its last price while its venue is closed, with fills held within 10% of that price, market orders refused and position collateral locked until the first fresh price at the reopen.
descriptionstringThe listing’s description from the subgraph, or "". It is unverified text: show it as text, never as HTML. A description that fails the factory’s own character and length rule is dropped.
indexnumberThe market’s position in the factory’s list.

Labels are never taken from descriptions, which anyone listing a market writes and which could name another market.

Finding One Market

bz.markets.getMarket(ref) takes a market’s label (any case), its pair address, its pair id, or a MarketInfo (returned as is). A name the cached list does not know makes one refresh of the pair list, so a market listed since the page loaded is found. It throws an InputError on market for a name that matches nothing (“There is no market called “XYZ/USD”. Markets: BTC/USD, ETH/USD.“) or several markets (”… matches several markets (…); choose one by its address.“). Every function that takes a market accepts any of these forms.

Market Snapshots

const snap = await bz.markets.marketSnapshot(eth); // one block, two batched reads
console.log(`${snap.market.label} at block ${snap.block}: ${snap.status}`);
console.log(`stored ${formatUnits18(snap.price.spot, 2)} (${snap.price.age} s old), mark ${formatUnits18(snap.markPrice, 2)}`);
console.log(`IMR ${snap.margin.imrBp} bp, max leverage ${snap.margin.maxLeverage?.toFixed(1) ?? "-"}x`);
console.log(`OI long ${formatUnits18(snap.openInterest.longNotional, 0)} / short ${formatUnits18(snap.openInterest.shortNotional, 0)} USD`);
console.log(`insurance ${formatUnits18(snap.insurance.fund, 0)} USD, ${formatUnits18(snap.insurance.freeCapital, 0)} free`);

// Many markets at one block, in the same two batched reads.
const all = await bz.markets.marketSnapshots(list);
const live = [...all.values()].filter((s) => s.status === Status.Live);

bz.markets.marketSnapshot(market, options?) reads a market’s live state at one block: the keeper’s own state read and the snapshot’s extra reads (mark price, funding rates, the stored price’s band, ADL clocks, termination figures, the vault’s ledger, the book’s heads), in two batched calls sent together. marketSnapshots(markets, options?) does the same for many markets at once, in the same two calls, and returns a Map from address to snapshot; a market whose core reads fail is left out of the map, where marketSnapshot throws instead.

OptionDefaultMeaning
blockthe latest{ number, timestamp } to read at (a watcher passes its head).
pricesnoneA LivePrices stream (or anything with price and venueClosed), to judge trading-hours venues without a request.
fetchPricestrueWithout live prices, ask the connection’s Pyth Pro source for the legs of trading-hours markets, in parallel with the chain read, to judge their venue.

A snapshot is a plain object: keep it in state and re-render from it. Its time-dependent fields can be re-derived without a read (see Status).

Snapshot Fields

FieldMeaning
marketThe MarketInfo.
block, timestampThe block read, and its timestamp: what the status, the clocks and the flags are judged at.
statuslive, adl, limbo, settling or terminated. See Status.
venueClosed, marketOrdersDisabled, positionWithdrawalsFrozen, allWithdrawalsPaused, reasonsThe flags. See Flags.
priceThe pair’s stored oracle price: what its next call falls back to without a fresh one.
markPriceThe mark at this block (the lens’s markPrice); 0 before any qualifying print formed one.
fundingFunding rates and the index. See Funding.
marginThe margin rates.
openInterestOpen interest and the vault’s inventory.
insuranceThe insurance fund and the vault’s ledger.
deficitRealized bad debt beyond the insurance fund: non-zero means the pair is insolvent and settles.
adlThe ADL window and auction.
terminationA scheduled cutoff, the settlement price, the terminal payout ratios.
bookThe book’s heads and order counts.
stateThe raw state read (MarketState), which account functions reuse.

price (StoredPrice):

FieldMeaning
spotThe stored spot (18 decimals).
low, highSpot less and plus the Pyth confidence.
updateTsThe stored print’s publish time (Unix seconds).
ageSeconds between the stored print and the snapshot’s block.
emaVarianceBpThe variance EMA the margin curve reads, in basis points.
updateCountPrices the pair has stored since it was created.

margin (MarginView), all in basis points of notional:

FieldMeaning
imrBpInitial margin: what opening, adding and withdrawing must keep.
mmrBpMaintenance margin now.
laggedMmrBpThe 24-hour-lagged maintenance rate (0 before any sample).
effectiveMmrBpWhat liquidation judges by: the lower of the current rate and a non-zero lagged one.
maxLeverage10,000 / imrBp, a number (null while imrBp is 0).
withdrawalFloorBpThe rate a position must keep as cash collateral when withdrawing: mmrBp on 24/7 markets, imrBp on trading-hours ones.

How these rates are set is in Margin.

openInterest (OpenInterestView): long and short (sizes), longNotional and shortNotional (at the stored spot), and longInventory and shortInventory (liquidated positions the vault holds, by the side they came from).

insurance (InsuranceView):

FieldMeaning
fundThe insurance fund’s balance.
seizedThe part earmarked against inventory the vault holds.
freeCapitalfund less seized.
targetRatioBpThe target the margin curve last computed, in basis points of open interest.
depositsLedgerEvery account’s collateral the pair holds.
ledgerArrearsWhat the deposits ledger owes the fund (cash winners already withdrew).

See Insurance.

adl (AdlView):

FieldMeaning
pendingAn ADL window is open.
epochAdvanced each time a window opens.
pendingSinceWhen the open window opened (0 when none).
auctionStartWhen the score threshold started decaying (restarts after a market closure).
auctionEndsAtWhen the threshold reaches its floor: auctionStart + 10 minutes (0 when none).
timeoutAtpendingSince + 24 hours: the timeout then arms termination.
terminationTsWhen an armed timeout may terminate the pair (0 while unarmed).
scoreThresholdThe score a winner must exceed at the snapshot’s block (MAX_UINT256 while no auction runs).

termination (TerminationView):

FieldMeaning
scheduledTsA scheduled cutoff (0 when none).
haltsAtWhen trading halts ahead of it: the cutoff less LIMBO_LEAD (6 s).
settlementFixedTsWhen the settlement price was fixed (0 while not).
settlementPriceThe fixed settlement price (0 while live).
finalizeByThe latest finalizeTermination can wait: settlementFixedTs + 14 days.
terminatedThe termination is finalized.
principalRatio, profitRatioCash paid per unit of collateral and per unit of profit claim after finalize (1e18 = 100%).
profitClaimsRegistered winner profit not yet paid out.
badDebtSettled losses beyond collateral, tallied in the settlement window.

See Termination & Settlement and Termination.

book (BookSummary): bidHead and askHead (the best orders’ ids, 0 for an empty side), and bidCount and askCount (orders resting on each side). For price levels, see The Book.

Status

snapshot.status is one value, judged at the snapshot’s block, in this order of precedence:

StatusMeaning
terminatedThe termination is finalized. Nothing trades; collateral and settled profits can be withdrawn.
settlingThe settlement price is fixed and positions are being settled at it (at most 14 days).
limboFrom LIMBO_LEAD (6 seconds) before a scheduled cutoff until its settlement price is fixed: trading has stopped.
adlAn ADL window is open: matching halts while liquidation and ADL run.
liveTrading normally.

The Status constant names them (Status.Live, …). In every status but live, every new order is refused, not only market orders. markets.marketStatusAt(snapshot, time) gives the status at another time without a read (for a countdown to a cutoff), and markets.adlScoreThresholdAt(snapshot, time) the ADL threshold.

Flags

The flags say what the pair refuses right now, each with the reason a UI can show next to a disabled button:

function tradeButtons(s: markets.MarketSnapshot) {
  return {
    marketOrder: { enabled: !s.marketOrdersDisabled, why: s.reasons.marketOrdersDisabled },
    // A position holder is frozen in more cases than a flat account.
    withdraw: (hasPosition: boolean) => {
      const blocked = s.allWithdrawalsPaused || (hasPosition && s.positionWithdrawalsFrozen);
      return { enabled: !blocked, why: s.allWithdrawalsPaused ? s.reasons.allWithdrawalsPaused : s.reasons.positionWithdrawalsFrozen };
    },
    // null: nothing could judge the venue (no live prices, no Pyth Pro source); false on a 24/7 market.
    venueClosed: s.venueClosed,
  };
}

// Countdowns without a new read: the status and the ADL threshold at another time.
const in30s = markets.marketStatusAt(snap, snap.timestamp + 30);
const threshold = markets.adlScoreThresholdAt(snap, snap.timestamp + 30);
FlagTrue whenReason shown
venueClosedA trading-hours market’s feed has generated no price for 15 seconds (VENUE_CLOSED_AFTER), judged from the live prices passed in or from one Pyth Pro request. null when nothing could judge it (no live prices, a price source that is not a proPriceSource, or a request that failed); always false on a 24/7 market.“The venue is closed: this market runs on its last price, with fills held within 10% of it, until the first fresh price at the reopen.”
marketOrdersDisabledThe market is terminated, settling, in limbo or in ADL, or its venue is closed.One of: terminated, settling, limbo, ADL (“Trading is paused while the market auto-deleverages.”), or “The venue is closed: market orders need a live price and resume at the reopen. Limit orders still work.”
positionWithdrawalsFrozenAn account holding a position cannot withdraw collateral: in ADL, while the venue is closed, and whenever all withdrawals are paused. Flat accounts are unaffected by the first two.The ADL, closed-venue or pause reason.
allWithdrawalsPausedNo account can withdraw: in limbo, and while settling.“Withdrawals are paused while every position is settled at the fixed price: …”, or the limbo text.

snapshot.reasons has one entry per flag, null where the flag is off. An account snapshot applies the flags to the account (withdrawalBlocked). markets.marketFlags(market, state, time, venueClosed) computes them from a state read yourself.

The pair’s rules behind each flag are in Bazaar’s Oracle (closed venues), ADL and Termination chapters.

Funding

const f = snap.funding;
const perHour = Number(f.currentRatePerHour) / 1e16; // percent per hour (1e18 = 100%)
const hoursPerYear = eth.hours === "24/7" ? 8_760 : 1_700; // a trading-hours market accrues only while open
const perYear = markets.annualizedFunding(f.currentRatePerHour, hoursPerYear) * 100; // percent a year
console.log(`${perHour.toFixed(4)}%/h (${perYear.toFixed(2)}% a year), ${f.currentRatePerHour > 0n ? "longs pay shorts" : "shorts pay longs"}`);
console.log(`cap ${formatUnits18(f.maxRatePerHour * 100n)}%/h; next stored price charges ${formatUnits18(f.accruingRatePerHour * 100n, 4)}%/h`);

snapshot.funding (FundingView):

FieldMeaning
currentRatePerHourThe rate the mark’s premium implies now, per hour, 18 decimals (1e18 = 100%): positive when longs pay shorts.
accruingRatePerHourThe rate the next stored price charges for the window since the index last accrued.
indexThe stored cumulative funding index, in price units.
projectedIndexThe index a price stored at this block would leave. Account health uses it.
lastAccrualTsWhen the index last accrued (Unix seconds).
maxRatePerHourThe cap: 0.1% an hour on 24/7 markets (MAX_FUNDING_RATE_CONTINUOUS), 0.5% on trading-hours ones (MAX_FUNDING_RATE_SESSION).

markets.annualizedFunding(ratePerHour, hoursPerYear?) gives a yearly fraction for display (0.0876 is 8.76% a year). A trading-hours market accrues funding only while its venue is open, so pass the hours it actually trades in a year (about 1,700 for a US equity) rather than the default 8,760. See Funding.

The Book

const { bids, asks } = await bz.markets.getBook(eth, 10); // ten levels a side, at one block
for (const l of asks.levels.slice().reverse()) console.log(`  ask ${formatUnits18(l.price, 2)}  ${formatUnits18(l.size, 4)}  (${l.orders})`);
for (const l of bids.levels) console.log(`  bid ${formatUnits18(l.price, 2)}  ${formatUnits18(l.size, 4)}  (${l.orders})`);
if (!bids.complete) console.log("more bids below");

const deep = await bz.markets.getBookDepth(eth, "bids", 100, { pageSize: 200, maxOrders: 5_000 });

bz.markets.getBook(market, levels?, options?) returns { bids, asks }, both sides read at one block. getBookDepth(market, side, levels?, options?) reads one side, "bids" or "asks". Each walks the side from its best order with the lens’s getBookDepth until it has seen enough distinct prices, reads the orders’ sizes in one batch, drops what is no longer live (filled, canceled, or past its expiry block but not yet swept), and sums each price level. Every read is pinned to one block.

Argument / optionDefaultMeaning
levels20Price levels wanted, 1 to 1,000 (an InputError otherwise).
pageSizelevels × 4, between 32 and 200Orders per lens page.
maxOrders2,000Stop after this many orders even with fewer levels (the last level may then be partial).
blockthe latest{ number } to read at.

A BookDepth has market, side, block, levels (best first: bids from the highest price down, asks from the lowest up) and complete (every level the side has is there). Each DepthLevel has price, size (remaining, 18 decimals), notional (size × price), orders (resting orders at the level) and cumulative (size from the best level through this one).

To keep a book current, re-read it when watchMarket reports bookChanged.

What Can Go Wrong

ErrorWhy
InputError on market: There is no market called "..."No label, address or pair id matches, even after a refresh.
InputError on market: "..." matches several marketsTwo markets share a label; pass the address.
Error: could not read the state of market ...: its price, margin or vault read failedmarketSnapshot (and accountSnapshot) could not read the market’s core state at that block.
InputError on side or levelsgetBookDepth was given another side or a level count outside 1 to 1,000.
Labels are short addressesThe feed metadata could not be fetched (the list itself still loads).
venueClosed is nullNothing could judge the venue: pass live prices, or connect with a Pyth Pro source.

Accounts & Portfolios

This page covers reading a trader’s state: on one market (accountSnapshot) and across every market where the account holds anything (portfolio). A snapshot has the position and its health at the live price, the live orders, what can be withdrawn now, claimable fees, the meta-transaction nonce, the terminal payout of a market that is winding down, and the wallet’s USDC balance and allowance.

The samples assume bz connected as in Connecting with a price source, user the trader’s address and eth a market from Markets.

One Market

const me = await bz.account.accountSnapshot(eth, user);
console.log(`valued at ${me.price ? formatUnits18(me.price.spot, 2) : "no price"} (${me.price?.source ?? "-"})`);
if (me.hasPosition && me.health) {
  const h = me.health;
  console.log(`${me.position.isLong ? "long" : "short"} ${formatUnits18(me.position.size)} from ${formatUnits18(me.position.entryPrice, 2)}`);
  console.log(`equity ${formatUnits18(h.equity, 2)}, PnL ${formatUnits18(h.unrealizedPnl, 2)} + funding ${formatUnits18(h.fundingPnl, 2)}`);
  console.log(`margin ratio ${h.marginRatio.toFixed(2)} (liquidatable below 1), leverage ${h.leverage?.toFixed(1) ?? "-"}x`);
}

bz.account.accountSnapshot(market, user, options?) reads the account in two batched calls: first the position, its live order ids, the pair’s stored price, the claimable fees and the wallet’s USDC balance; then everything that depends on those (the lens’s solvency and withdrawal figures at the account’s price, the order exposure, the nonce, the terminal entitlement, the allowance, and the orders themselves), sent together with the market’s state read.

The position is valued at the first price available, in this order:

  1. options.price, a price you already hold ({ spot, low?, high?, publishTime? });
  2. the live price from options.prices, a LivePrices stream;
  3. one Pyth Pro request for the market’s feeds, when the connection’s price source is a proPriceSource (and fetchPrices is not false);
  4. the pair’s stored price.

snapshot.price.source says which: "given", "live", "pro" or "stored".

OptionDefaultMeaning
blockthe latest{ number, timestamp } to read at.
pricesnoneLive prices to value the position at and to judge the venue with.
fetchPricestrueWithout live prices, ask the connection’s Pyth Pro source for the feeds, in parallel with the first read.
pricenoneA price you hold, used instead of a live one.
marketnoneThe market’s snapshot (or state read) at the same block, to skip reading it again. Its block is then the snapshot’s.

Snapshot Fields

FieldMeaning
market, userThe market’s MarketInfo, and the account (checksummed).
block, timestampThe block read and its time.
status, flagsThe market’s status and flags at that block.
stateThe market’s state read, kept for accountHealth.
positionThe position bucket. See below.
hasPositionposition.size > 0.
priceWhat the health and the withdrawal figure were valued at, with its source; null when no price was known at all.
healthThe position’s health at price.spot; null while flat or without a price.
solvencyThe lens’s own view (checkBucketSolvency) at price.spot and the stored funding index; null without a price.
ordersEvery live order: the Limit and StopLimit orders in the account’s active set, then the attached take-profit and stop-loss.
takeProfit, stopLossThe position’s attached TP and SL among orders, or null.
exposureResting orders’ remaining notional (at their limits) and size, per side.
maxWithdrawableThe most withdrawCollateral accepts now, per the lens. See Withdrawals and Fees.
withdrawalBlockedWhy this account cannot withdraw right now, or null.
claimableFeesWhat claimFees would pay this account on this market: refunded bounties, keeper rewards, relayer and integrator fees.
nonceThe account’s next meta-transaction nonce on this pair (gasless requests sign it).
terminalWhat a withdrawal pays while the market settles or after it terminated; null on a live market.
wallet{ usdcBalance, usdcAllowance }, in USDC’s own units (6 decimals): the wallet’s USDC and what this pair may pull.

position (AccountPosition), 18 decimals:

FieldMeaning
isLong, sizeThe net position: one per market, size 0 when flat.
entryValueThe entry notional.
entryPriceentryValue / size, the average entry price (0 while flat).
collateralRaw collateral: deposits plus realized PnL, less fees.
entryFundingIndexThe funding index at entry.
takeProfitOrderId, stopLossOrderIdThe attached TP and SL (0 for none).

terminal (TerminalPayout): collateral, registeredClaim, principalRatio and profitRatio (1e18 = 100%, 0 before finalize), and payout, what a withdrawal pays: collateral × principalRatio + registeredClaim × profitRatio. See Termination & Settlement.

solvency (LensBucketState) is the lens’s figures as the contract computes them: unrealizedPnl, fundingPnl, totalPnl, adjustedSize, currentNotional, entryValue, minRequiredCollateral, availableEquity, effectiveCollateral, adjustedEntryFundingIndex, isSolvent.

Health

snapshot.health (PositionHealth) values the position at a price by the pair’s own formula, at the projected funding index (what a price stored now would accrue to) and the effective maintenance rate:

FieldTypeMeaning
pricebigintThe spot it was valued at.
notionalbigintSize × price.
unrealizedPnlbigintPrice PnL since entry.
fundingPnlbigintFunding earned (positive) or owed (negative) since entry.
totalPnlbigintThe two together.
equitybigintCollateral plus total PnL (signed).
availableEquitybigintEquity floored at zero.
maintenanceRequiredbigintWhat maintenance margin requires at this price.
marginRationumberequity / maintenanceRequired. Below 1, anyone may liquidate the position.
leveragenumber | nullNotional over equity (null when equity is not positive).
isSolventbooleanThe pair’s own solvency verdict.

A margin ratio of 2 means equity is twice what maintenance requires. How maintenance margin and liquidation work is in Margin and Liquidations.

Following the Price

// Revalue at a new price without a read: for a display that follows the live price.
const prices = await bz.livePrices();
prices.subscribe((changed) => {
  const p = changed.get(eth.address);
  const h = p && account.accountHealth(me, p);
  if (h) console.log(`margin ratio ${h.marginRatio.toFixed(2)} at ${formatUnits18(h.price, 2)}`);
}, [eth]);

// Or value at a price you hold, with the market's state from a snapshot read at the same block.
const snap = await bz.markets.marketSnapshot(eth);
const atMine = await bz.account.accountSnapshot(eth, user, { market: snap, price: { spot: 2_900n * 10n ** 18n } });
const whatIf = account.positionHealth(atMine.position, atMine.state, 2_500n * 10n ** 18n);

account.accountHealth(snapshot, price) revalues a snapshot’s position at a new spot (a bigint, or anything with spot, such as a live MarketPrice) without any read: a display that follows the price between snapshots calls it on every tick. It returns null while flat. account.positionHealth(position, state, spot) is the same computation from a position and a market state, for “what if the price were …” figures.

The estimated liquidation price of a new or changed position is in Previews.

Orders

for (const o of me.orders) {
  const life = o.neverExpires ? "never expires" : `${o.blocksLeft} blocks left`;
  console.log(`#${o.orderId} ${o.typeName} ${o.isLong ? "buy" : "sell"} ${formatUnits18(o.remaining)} @ ${formatUnits18(o.limitPrice, 2)}, ${life}`);
}
if (me.stopLoss) console.log(`stop loss at ${formatUnits18(me.stopLoss.triggerPrice, 2)}, bounty ${formatUnits18(me.stopLoss.bounty)}`);
console.log(`resting buys ${formatUnits18(me.exposure.longNotional, 2)} USD, sells ${formatUnits18(me.exposure.shortNotional, 2)} USD`);

Each entry of snapshot.orders is an AccountOrder:

FieldMeaning
orderIdThe pair’s id for the order.
orderType, typeNameThe type (OrderType), and its name: “Market”, “Limit”, “StopLimit”, “TakeProfit”, “StopLoss”.
isLongA buy.
isPostOnly, tifPost-only, and the time in force (0 GTC, 1 IOC, 2 FOK).
size, filledThe order’s size and what has filled.
remainingWhat is left to fill; for a TP or SL, capped at the position it closes.
limitPrice, triggerPriceThe limit, and the trigger (StopLimit, TakeProfit, StopLoss; 0 otherwise).
bountyThe keeper bounty the order still holds in escrow.
maxSlippageBpThe slippage cap of a TP or SL.
expiryBlock, neverExpires, blocksLeftThe expiry (an Arbitrum block), whether it never expires (TP and SL), and blocks until it does (null when never).
inBookResting in the book: a Limit, or a StopLimit that has armed.
closesWholePositionA TP or SL sized to close the whole position, whatever it is when it fires.
attached"takeProfit", "stopLoss", or null.
integratorThe integrator the order names.
recordThe order as stored, every field.

Only live orders are listed: filled, canceled and expired ones are left out, even before a sweep has unlinked them. exposure (OrderExposure) has longNotional, shortNotional, longSize and shortSize: what the account’s resting orders would add. To amend or cancel, see Managing Orders; for the account’s past orders, History.

Withdrawals and Fees

// maxWithdrawable is the lens's figure; withdrawalBlocked is what the market's flags forbid now.
const canWithdraw = me.withdrawalBlocked ? 0n : me.maxWithdrawable;
console.log(me.withdrawalBlocked ?? `up to ${formatUnits18(canWithdraw, 2)} USDC can be withdrawn`);
console.log(`claimable fees ${formatUnits18(me.claimableFees, 2)} USDC`); // claim with bz.trade.claimFees
console.log(`wallet ${formatUnits18(fromUsdcUnits(me.wallet.usdcBalance), 2)} USDC, allowance ${me.wallet.usdcAllowance} units`);
if (me.terminal) console.log(`the market is winding down: a withdrawal pays ${formatUnits18(me.terminal.payout, 2)} USDC`);

maxWithdrawable is the lens’s getMaxWithdrawable, valued at the conservative side of the price for a position (spot less the confidence for a long, plus it for a short): the most the pair would accept now. The lens cannot see a closed venue or a paused market, so check withdrawalBlocked too: when it is set, a withdrawal reverts whatever maxWithdrawable says. withdrawalBlocked is the market’s allWithdrawalsPaused reason, or, for an account holding a position, its positionWithdrawalsFrozen reason. A flat account can withdraw during ADL and while the venue is closed.

claimableFees is what bz.trade.claimFees(market, user) would pay. The withdrawal and claim calls are on Collateral, Approvals & Permits; the exact limits and freezes of a withdrawal are in Previews (previewWithdrawal).

Portfolios

const book = await bz.account.portfolio(user); // every market where the account holds anything
for (const a of book.accounts) {
  console.log(`${a.market.label}: collateral ${formatUnits18(a.position.collateral, 2)}, ${a.orders.length} orders`);
}
const t = book.totals;
console.log(`equity ${formatUnits18(t.equity, 2)}, withdrawable ${formatUnits18(t.maxWithdrawable, 2)}, fees ${formatUnits18(t.claimableFees, 2)}`);
const two = await bz.account.portfolio(user, ["ETH/USD", "BTC/USD"], { prices }); // chosen markets, live prices

bz.account.portfolio(user, markets?, options?) returns a snapshot for every market where the account has collateral, a position, live orders or claimable fees, in the factory’s order, with totals. Without markets it covers the factory’s whole list, and takes the claimable fees from one getClaimableFeesAll read, which also catches a market listed since the list was cached. It costs about four calls whatever the number of markets: the head, one batch over every market, then one batch for the markets the account uses, sent with their state read. Its options are block, prices and fetchPrices, as for accountSnapshot.

FieldMeaning
user, block, timestampThe account and the block read.
accountsOne AccountSnapshot per market the account uses.
walletUsdcThe wallet’s USDC, in USDC units (6 decimals).
totalsSummed across accounts, 18 decimals: collateral, equity (collateral plus PnL where there is a position), unrealizedPnl, fundingPnl, claimableFees, and maxWithdrawable (0 for a market whose withdrawals are blocked).

Keeping It Current

bz.live.watchAccount(user, markets, onUpdate) delivers a fresh snapshot for each market an event touches (a fill, an order, a deposit, a liquidation) and for every market every 15 seconds. See Live Updates.

What Can Go Wrong

WhatWhy
Error: could not read the state of market ...The market’s own state read failed at that block.
price is null, health is null with a positionNo price at all: no live prices, no Pyth Pro source (or it failed), and the pair has never stored one.
price.source is "stored"No fresher price could be had; the stored price may be minutes old. Show price.publishTime.
maxWithdrawable is positive but a withdrawal revertswithdrawalBlocked is set (a closed venue, ADL, a pause), or the price moved. Simulate first.
An address erroruser is not an address (viem’s InvalidAddressError).

Live Prices

This page covers showing prices that move: LivePrices, a stream of every market’s composed Pyth price with freshness and venue flags; reading Pyth Pro directly; and PriceService, the engine under both the stream and the keeper.

Prices on the chain and prices on screen differ. A snapshot reports the pair’s stored price, which moves only when a transaction stores a new one; a live price is Pyth’s newest print, composed the way the pair’s oracle would compose it. Every on-chain price check uses the print a call carries (see The 3-Second Price Window).

The samples assume bz connected as in Connecting with a Pyth Pro price source, and eth a market. pythToken is the page’s source of short-lived tokens (Tokens for a Browser).

LivePrices

const prices = await bz.livePrices(); // every listed market, each streamed as its own set of feeds
await prices.ready; // the first request for each market's latest print has answered

const unsubscribe = prices.subscribe((changed) => {
  for (const [, p] of changed) {
    const flag = p.venueClosed ? "closed" : p.fresh ? "live" : `${p.age} s old`;
    render(`${formatUnits18(p.spot, 2)} ± ${p.confidenceBp.toFixed(1)} bp (${flag})`);
  }
}); // every watched market; pass [eth] as a second argument for one

const now = prices.price(eth); // from memory: null while a feed has no print yet
prices.fresh(eth); // inside the pair's 3 s window, and the confidence clears the 2% cap
prices.venueClosed(eth); // a trading-hours market whose feed stopped generating prices

// When the view goes away:
unsubscribe(); // one subscriber
prices.stop(); // the stream itself, for good

bz.livePrices(options?) lists the connection’s markets and streams them through the connection’s Pyth Pro client: one subscription per market, on every stream endpoint at once, so each market’s signed update carries exactly its own feeds (both legs of a composite in one update) and a dropped endpoint interrupts nothing. Before the stream it makes one request for every feed’s latest print, so every market shows a price at once; ready settles when that request has answered or failed. When the stream fails three times in a row, it polls for a minute, then tries the stream again.

The stream runs over a WebSocket. Browsers and Node 22 and later have one; on Node 20 give the client a constructor (new ProClient({ WebSocket }), with the ws package’s, say).

Everything a render needs is read from memory:

MethodReturns
price(market)The market’s MarketPrice, or null while a feed has no print yet (or the market is not watched).
all()A Map from address to MarketPrice, for every market with one.
fresh(market)Whether the price is inside the pair’s 3-second window and its confidence clears the 2% cap.
venueClosed(market)Whether a trading-hours market’s venue is closed. Always false for a 24/7 market.
subscribe(listener, markets?)Calls listener with a Map of the markets whose price changed after each batch of prints, and of those whose fresh or venueClosed flipped as time passed (checked every tickMs). Only markets when given. Returns the function that unsubscribes.
add(markets)Streams these markets too (already watched ones are ignored).
markets()The markets being streamed.
stop()Ends the stream, the ticker and every subscription, for good.
serviceThe PriceService underneath: its healthy, lastError and raw latest prints are public.

A listener is called synchronously, with a map it must not keep; a listener that throws does not stop the others or the stream. Call stop() when the view unmounts (in a React effect’s cleanup, say).

Each MarketPrice (18 decimals):

FieldMeaning
marketThe pair’s address.
spotThe composed price.
low, highSpot less and plus the confidence: what a long and a short are valued at when they withdraw.
confidenceThe band’s wider half: max(high − spot, spot − low).
confidenceBpconfidence / spot in basis points, a number for display.
confidenceOkEvery leg, and the composed band, sit inside the oracle’s 2% confidence cap.
publishTimeThe second the older leg’s price was generated in (Unix seconds): what the pair’s window judges.
ageSeconds since publishTime, at the service’s clock.
freshEvery leg’s price was generated at most 3 seconds from now, and confidenceOk: a call carrying this print would be accepted if it landed now.
venueClosedA trading-hours market whose feed has generated no price for closedAfter seconds while the endpoint delivers.
legsThe feeds’ prints (base first for a composite).

A price’s age is when it was generated. While a venue is shut Pyth keeps sending updates, each stamped with the current time and carrying the last price forward. The update’s own timestamp says nothing about the price: publishTime is the second the price was generated in (Pyth’s feed update timestamp), which is what the pair’s 3-second window judges and what fresh, age and venueClosed use. A carried-forward price reads as old, as it should.

How the oracle composes a price, and why a closed venue matters, is in Oracle.

Options

const tuned = await bz.livePrices({
  closedAfter: 30, // seconds without a newly generated price before a trading-hours venue reads closed (default 15)
  tickMs: 500, // how often fresh/venueClosed flips are re-checked for subscribers (default 1000)
  reuseAge: 1, // a price generated this recently is reused as a call's update (default 1 s)
  stream: true, // the WebSocket stream, falling back to polling (default true)
  pollInterval: 2, // seconds between polls while not streaming (default 1)
  maxFutureSkew: 120, // seconds a print may be dated ahead of this clock (default here 120)
  logger: null, // silence endpoint warnings (default console)
});

// Some markets only, with a Pyth Pro client of their own, without a connection:
const some = await livePrices([eth], { pro: { token: pythToken, channel: "fixed_rate@1000ms" } });
OptionDefaultMeaning
closedAfter15Seconds without a newly generated price after which a trading-hours market reads closed.
tickMs1000Milliseconds between re-checks of fresh and venueClosed for subscribers (0: never).
reuseAge1A stream update whose price was generated at most this many seconds ago is reused for a call (see Prices for Calls).
starttrueStart streaming at once; with false, call start().
streamtrueFollow the stream, falling back to polling when it fails. false polls only.
streamIdleTimeout10Seconds the stream may bring no new update before it is dropped and reconnected. null or 0 waits forever.
pollInterval1Seconds between polls when not streaming.
maxFutureSkew120Seconds a print may be dated ahead of this device’s clock before it is ignored. See below.
clockthe system clockThe time freshness is judged at (Unix seconds).
loggerconsoleWhere endpoint failures are reported (warn); null for silence.
prothe connection’sOptions for a ProClient of the stream’s own (another token, another channel).
clientthe connection’sA ProClient (or any PriceSource) to share.

PriceService’s sendMargin, ethUsdMaxAge and ethUsdBounds are accepted too; they do not change what LivePrices reports.

The channel. A client streams on one channel (default fixed_rate@200ms: an update every 200 ms per subscription and endpoint). A page that only shows prices can stream fixed_rate@1000ms and still fetch a fresh update when it builds a call. When the connection checks signers (the default), each new update costs one signature recovery, so a slower channel is also lighter on a phone.

The future-print skew. A print dated far ahead of this device’s clock would hold its feed until a newer print arrived, so prints dated more than maxFutureSkew seconds ahead are ignored, with a warning once per feed. The keeper’s bound is 5 seconds; a browser’s clock can run a minute slow, so LivePrices allows 120 seconds (BROWSER_FUTURE_SKEW). A device clock that is far off also makes age and fresh wrong: fresh judges against this device’s clock, the pair against the block’s.

Without a Connection

livePrices(markets, options) streams the given markets (MarketInfos, from a market list you already have) through client, or through a client it makes from pro; with a context instead of markets it lists them first, as bz.livePrices does. new LivePrices(markets, source, options) is the constructor underneath. A connection whose price source is not a Pyth Pro source has no client to stream from: pass pro or client, or livePrices throws a ConnectError.

Prices for Snapshots

Pass a LivePrices as the prices option of marketSnapshot, accountSnapshot, portfolio, watchMarket and watchAccount: they then value positions at the live price and judge venues from the stream, with no request of their own.

Prices for Calls

A LivePrices is also a PriceUpdateSource. Its latest(feeds) returns the stream’s own update for those feeds when every price in it was generated at most reuseAge seconds ago (no request), and fetches one otherwise. Give it to a facade over the same context, and that facade’s builders attach the stream’s updates:

// A LivePrices is also a PriceUpdateSource: give it to a second facade over the same context, and its
// order builders reuse the stream's updates instead of asking the endpoint again.
const trading = Bazaar.from({ ...bz.ctx, prices: tuned });
// ...then trading.trade.createOrder(...) attaches the stream's update when its price is at most 1 s old.

The copied context has its own caches (it lists the markets again once), and its price source is not a proPriceSource: keep the original bz for reads, which then still get the symbols registry and venue checks from the connection’s client. updateFor(market) returns the stream’s current update for one market, for code that encodes calls itself.

Pyth Pro Directly

// Pyth Pro directly: prints read from the signed update itself, never from the JSON beside it.
const source = proPriceSource({ token: pythToken });
const [print] = await source.prints(eth.legs); // feedId, price, conf, expo, publishTime, generatedUs, blob
const update = await source.latest(eth.legs); // { update: Hex[], publishTime }: what a call attaches

const client = new ProClient({ token: pythToken, timeout: 5 });
const ctl = new AbortController();
// One set of feeds: every update carries exactly the market's legs, from whichever endpoint is first.
for await (const batch of client.stream(eth.legs, ctl.signal, { idleTimeout: 10 })) {
  render(`${batch.length} prints`);
  ctl.abort(); // or break: the stream ends at once
}

proPriceSource(options | client) is the connection’s price source (see Connecting); its prints(feedIds) returns the parsed prints, for showing a price without a stream. ProClient is the client underneath:

MemberWhat it does
new ProClient(options)The options of Connecting: token, channel, the URLs, WebSocket, isTrustedSigner. A token or header with a line break is refused at once.
latest(ids)The latest prints for these feeds, all from one signed update (a feed without a usable live price is left out).
latestUpdate(ids)The same update whole, as a ProUpdate: blob, signer, timestampUs, channel, every feed’s entry (feeds), and its prints.
atTick(ids, second)The one-second channel’s update stamped exactly second, or null when there is none: what settlement proofs are made of.
symbols({ query?, assetType? })Pyth Pro’s symbols registry: each feed’s number, symbol, asset type, fastest channel, state and schedule. Needs no token.
stream(feeds, signal?, { idleTimeout?, onNotice? })An async iterator of print batches from every stream endpoint, each update yielded once. feeds is one set of feeds or a list of sets; each set is its own subscription. Aborting signal, breaking out of the loop or close() ends it. It throws a ProError when no endpoint can be reached, and when no new update arrives for idleTimeout seconds (default 10).
close()Ends every open stream.
displayUrlThe request API’s base URL with any key masked.

A Print has feedId (the bytes32 form), price, conf, expo, publishTime (the second the price was generated in), generatedUs (the same instant in microseconds), timestampUs and channel (the carrying update’s), signer (when signers are checked), blob (the signed update that carries it) and received. Every field but received and signer is read from the update’s signed payload, never from the JSON an endpoint sends beside it; signer is recovered from its signature.

A feed gets a print only when its entry holds a usable live price: a price, an exponent, a confidence above zero and a generation time. The oracle refuses a live update without any of them, so the SDK never offers one to a call; whyNotLive(entry) says which is missing.

A response over 16 MiB, or a stream message over 1 MiB, is refused. Errors mask the token, header secrets and any key in a URL.

PriceService

// PriceService: the engine under LivePrices and the keeper, for code that wants the raw parts.
const service = new PriceService(client, { stream: true, maxFutureSkew: 120 });
const model = marketFromStatic(eth); // the per-market functions take the SDK's Market model
service.watchMarkets([model]);
const stopListening = service.onPrints((feeds) => {
  const pp = service.price(model); // spot, low, high, publishTime, confidenceOk
  if (pp) render(`${feeds.size} feeds updated, spot ${formatUnits18(pp.spot, 2)}, fresh: ${service.fresh(model, null, 0)}`);
});
void service.run(); // follows the stream (or polls) until stop(); never rejects
// ... later
stopListening();
service.stop();

PriceService is the engine under LivePrices and the keeper’s price loop: it holds the newest print per feed and the newest update per watched set of feeds, and builds per-market prices and updates from them. Use it when LivePrices is too much (a server that tracks a few feeds) or too little (custom freshness margins).

MemberWhat it does
new PriceService(source, options?)source is a ProClient or any PriceSource (latest and stream). Options as in the table above, plus sendMargin (default 1). The default maxFutureSkew here is 5 seconds.
watchMarkets(markets), watchSets(sets), watch(feeds)Watch each market’s legs (or each set) as one subscription, or each feed on its own; the stream resubscribes at once. ETH/USD is always watched (the keeper prices gas with it).
run()Follow the stream (or poll) until stop(), through any failure: three in a row mark it unhealthy and switch to polling for a minute. Never rejects.
fetch(feeds?)One request for the latest prints, outside the loop.
ingest(prints)Take in prints from anywhere.
onPrints(listener)Called after each batch with the feeds that got a newly generated price; returns the remover.
nextPrints()Waits for the next newly generated price (one consumer only).
price(market), legs(market)The composed PairPrice (spot, low, high, publishTime, confidenceOk) and the prints, or null.
fresh(market, now?, margin?)Every leg’s price generated within 3 − margin seconds of now (default margin: sendMargin), and the confidence clears the cap.
closed(market, now?, after?)No price generated for after seconds (default 15) while the service is healthy.
updateFor(market)The signed update a call on the market attaches: the one update that carries its legs.
printsFor(feeds)The prints a call reading these feeds together would attach.
ethUsd(now?)USD per ETH from the newest ETH/USD print, or null when stale or out of bounds.
missing()Watched feeds with no print yet.
healthy, lastError, lastUpdate, latestState, readable.
stop()Ends run and its stream.

A market prices from its own set’s update, so a call attaches one update and pays one verification fee. Only while its set has none, or lags another update’s price for one of its legs by a second or more, does it fall back to each leg’s newest print (one update per leg).

The per-market methods take the SDK’s Market model: marketFromStatic(info) makes one from a MarketInfo.

What Can Go Wrong

WhatWhy
ConnectError: live prices need a Pyth Pro source: ...The connection’s price source is not a Pyth Pro source and no pro or client was given.
ProError: this runtime has no WebSocket ...Node before 22: give the client a WebSocket constructor.
price() stays nullA feed has no print: Pyth has no usable price for it (a warning names the feed), or ready has not settled.
Warning: price endpoint sent no print for feed 0x...The feed ID is wrong, the token does not cover the feed, the feed is not served on the channel, or Pyth has no price or no confidence for it. Other feeds are unaffected.
Warning: price stream refused the subscription for feed(s) ...What Pyth answered to a subscription, passed on once.
Warning: price stream endpoint ... dropped (...): reconnectingOne endpoint went away; the others keep delivering.
Warning: ignored a print for feed 0x... dated N s ahead of the clockThis device’s clock is behind, or the endpoint is wrong.
fresh is false while prices moveThis device’s clock is off, or the confidence is above 2% (confidenceOk).
ProError: price endpoint refused the request (HTTP 401); check the token (prices.token) ...The token is wrong, expired, or not entitled to these feeds or this channel.
ProError: a price update is signed by 0x..., which is not a signer Pyth's verifier trusts: refusing itThe update did not come from Pyth, or the deployment’s verifier no longer trusts that signer.
service.healthy is falseThree failures in a row; service.lastError says why. It keeps retrying.

Live Updates

This page covers following the chain: watchMarket and watchAccount, which deliver a market’s or an account’s contract events as they land, typed, with refreshed snapshots when something relevant changed; every update they deliver; how they handle reorgs, errors and stopping; and the LogFollower underneath, for following any contracts’ logs.

The samples assume bz connected as in Connecting, user an address and eth a market.

Watching a Market

const prices = await bz.livePrices(); // optional: judges the venue without a Pyth Pro request per snapshot

const watch = bz.live.watchMarket(
  eth,
  (u) => {
    switch (u.type) {
      case "event": // one typed log as it lands
        if (u.event.source === "pair" && u.event.name === "Trade") {
          show(`trade ${formatUnits18(u.event.args.fillSize)} at ${formatUnits18(u.event.args.price, 2)}`);
        }
        break;
      case "snapshot": // a fresh MarketSnapshot at the head
        show(`${u.snapshot.status} (${u.cause})`);
        if (u.bookChanged) void bz.markets.getBook(eth, 10, { block: { number: u.snapshot.block } });
        break;
      case "head":
        show(`block ${u.block}`);
        break;
      case "reorg": // logs from fromBlock on are read again; the next snapshot reflects the new chain
        show(`reorg back to ${u.fromBlock}`);
        break;
      case "error": // retried after retryInMs; null means the watch could not start
        show(`live updates: ${String(u.error)} (retry in ${u.retryInMs ?? "never"} ms)`);
        break;
      case "undecodable": // a log the SDK could not decode: skipped
        break;
    }
  },
  { prices, refreshMs: 15_000, minSnapshotMs: 1_000 },
);
await watch.ready; // started, first snapshot delivered (or the reason it could not be)

bz.live.watchMarket(market, onUpdate, options?) returns a handle at once and starts following: every event the market’s pair emits (and every terminator event about it) is delivered as it lands, and a fresh MarketSnapshot at the head is delivered when events landed (at most one per minSnapshotMs), every refreshMs without events (funding accrues and clocks run), and on refresh(). A snapshot after a Trade, OrderUpdated or OrderAmended has bookChanged: true, the cue to re-read the book.

Watching an Account

const mine = bz.live.watchAccount(user, [eth, "BTC/USD"], (u) => {
  if (u.type === "account") {
    const a = u.snapshot; // an AccountSnapshot for the market an event touched
    show(`${a.market.label}: ${a.orders.length} orders, collateral ${formatUnits18(a.position.collateral, 2)} (${u.cause})`);
  } else if (u.type === "event" && u.event.source === "pair") {
    if (u.event.name === "PositionLiquidated") show("liquidated");
    if (u.event.name === "OrderUpdated") show(`order #${u.event.args.orderId}: action ${u.event.args.payload.action}`);
  }
}, { prices });
await mine.ready;
await mine.refresh(); // read the head and every snapshot now (after the user's own transaction, say)

bz.live.watchAccount(user, markets, onUpdate, options?) follows one account on a set of markets. It delivers the events that concern the account (its fills, orders, position, collateral, fees, liquidation or deleverage, insurance, its meta-transactions and permits) and the market-wide ones that change its standing (ADL, settlement, termination, and every terminator event about those markets), and a fresh AccountSnapshot for each market an event touched, all of them read in one pass. Every market is refreshed every refreshMs, and all of them on refresh().

An event concerns the account when one of its address arguments is the account (maker or taker of a Trade, owner of an OrderUpdated, user of a CollateralDeposited, liquidatedUser or liquidator of a PositionLiquidated, …), and a TriggerBountyEscrowed when it is for one of the account’s orders.

Between snapshots, health can follow the live price with no read: pass each new price to account.accountHealth.

The Handle

MemberWhat it does
readySettles once the watch has started and delivered its first snapshots, or reported why it could not. Never rejects.
refresh()Reads the head and refreshes every snapshot now (after the user’s own transaction lands, say).
stop()Ends the watch: nothing is delivered after it returns.
stoppedWhether it has stopped.
// In a React effect: return () => watch.stop();
watch.stop(); // nothing is delivered after this returns
mine.stop();
prices.stop();

A watch runs until stopped. In a component, stop it when the component unmounts or its market or account changes; a watch that is never stopped keeps polling.

Updates

onUpdate receives one object per update, told apart by type:

typewatchMarketwatchAccountFields
"event"yesyesevent: a typed LiveEvent (below).
"snapshot"yessnapshot (a MarketSnapshot), cause, bookChanged.
"account"yessnapshot (an AccountSnapshot), cause.
"head"yesyesblock, timestamp: the follower read up to a new head.
"reorg"yesyesfromBlock, head: the chain was reorganized and logs are read again from fromBlock.
"error"yesyeserror, retryInMs: a tick failed and is retried after retryInMs; null means the watch gave up.
"undecodable"yesyeslog, error: a log from a watched contract that could not be decoded; it is skipped.

cause says why a snapshot was read: "initial" (the watch started), "events", "timer" or "manual" (refresh()). Updates for one tick arrive in chain order: the events, then the snapshots. A listener that throws does not stop the watch.

Events

A LiveEvent is a PairEvent (source: "pair") or a TerminatorEvent (source: "terminator"). Every event has name, args, market (the pair it concerns), blockNumber, blockHash, txHash and logIndex. Narrow on source and name to get a pair event’s typed args:

if (u.type === "event" && u.event.source === "pair" && u.event.name === "Trade") u.event.args.price;

Amounts are 18-decimal bigints; timestamps, block numbers, sequence numbers and enum values are numbers. The pair’s events:

GroupEvents
TradingTrade, OrderUpdated, OrderAmended, OrderAmendSkipped
Positions and collateralPositionBucketUpdated, PositionModified, CollateralDeposited, CollateralWithdrawn
Fees and bountiesFeeAccrued, FeesClaimed, TriggerBountyEscrowed, TriggerBountyReleased
PricesNewPriceUpdateAdded
Liquidation and ADLPositionLiquidated, AdlTriggered, AdlTerminationArmed, AdlResolved, AdlExecuted, UserAdld
Settlement and terminationInsolvencySettlementFixed, PairTerminated, TerminalRatiosRefrozen
Gasless callsMetaTransactionExecuted, PermitExecutionFailed
InsuranceInsuranceCredited, InsuranceDeposited, InsuranceWithdrawalRequested, InsuranceWithdrawalLocked, InsuranceWithdrawalExecuted, InsuranceShareEpochBumped
OtherInitialized

Their arguments are the pair’s own, typed in live.PairEventArgs. A few need a note:

  • OrderUpdated.payload is the order’s stored state at the event. payload.action is 0 (created), 1 (triggered) or 2 (canceled); payload.cancelReason is 0 None, 1 Owner, 2 Expired, 3 Unfilled, 4 BelowMinimum, 5 PostOnly, 6 SelfMatch, 7 StepLimit, 8 Margin, 9 PriceBand, 10 PositionClosed, 11 Liquidated, 12 Deleveraged. Its triggerPrice is the price alone: the bounty is not part of the stored order.
  • OrderAmendSkipped.reason is 0 Inactive, 1 Expired, 2 SizeFilled, 3 BelowMinimum, 4 WouldCross.
  • InsolvencySettlementFixed.reason is the vault-health verdict: 2 ADL timeout, 3 balance short, 4 bad debt.
  • InsuranceWithdrawalExecuted.amount is the payout in 18 decimals. The contract’s ABI calls it usdcAmount, but it is not in USDC units, so the SDK renames it.

Terminator events (proposals, schedules, settlement prices, insurer votes) are delivered with their decoded args untyped, for every pair the event names.

live.decodeLiveLog(log, terminator?) decodes one log the same way, and works on a receipt’s logs (see Quick Start: a Bot for finding a new order’s id). It returns null for a log that is neither a pair’s nor the terminator’s.

Options

const tuned = bz.live.watchMarket(eth, () => {}, {
  pollMs: 2_000, // head polls (default 1000); with a subscription, a backup poll every 10 x this
  subscribe: true, // eth_subscribe("newHeads") when the transport emits "message" events (default true)
  fromBlock: 250_000_000, // deliver from this block (default: only what lands after the start)
  reorgDepth: 32, // blocks re-read after a reorg (default 32)
  logChunk: 2_000, // blocks per eth_getLogs, halved while the node refuses (default 2000)
  maxBlocksPerTick: 20_000, // blocks one tick reads (default 10 x logChunk)
  maxBackoffMs: 30_000, // the longest wait between retries (default 30 s)
  refreshMs: 0, // snapshots on events only (default 15000)
  initial: false, // no snapshot at start (default true)
  fetchPrices: false, // never ask Pyth Pro for the venue (default true)
});
OptionDefaultMeaning
pricesnoneA LivePrices: the venue for the snapshots, and the price accounts are valued at.
fetchPricestrueWithout live prices, let snapshots ask the connection’s Pyth Pro source (one request per snapshot for trading-hours markets, and for accounts).
refreshMs15,000Refresh snapshots at least this often without events. 0: on events only.
minSnapshotMs1,000The least time between two event-driven refreshes: events in between are folded into one.
initialtrueDeliver snapshots as soon as the watch starts.
pollMs1,000Milliseconds between head polls. With a subscription, a backup poll runs every 10 × this.
subscribetrueWake on eth_subscribe("newHeads") when the transport supports it.
fromBlockthe block after the head at startThe first block to deliver events from.
reorgDepth32Blocks re-read after a reorg.
logChunk2,000The most blocks one eth_getLogs asks for; halved while the node refuses a range.
maxBlocksPerTick10 × logChunkThe most blocks one tick reads. A head further ahead is first checked (the follower reads the block it would stop at) and then caught up with in steps of this size.
maxBackoffMs30,000The longest wait between retries after errors.

With fromBlock in the past, a watch delivers the events since that block (in steps of maxBlocksPerTick) before following the head: a page can replay the last few minutes this way. For anything older, read History.

How It Works

One follower per watch reads every watched market and the terminator in one eth_getLogs per tick, from a block cursor that advances only once a range has been read. Each tick:

  1. reads the latest block;
  2. checks it extends what was read before (its parent hash, or the last head’s hash);
  3. reads the logs from the cursor up to it, delivers them in chain order, and moves the cursor;
  4. reads the snapshots that are due, at that block.

Subscriptions and Polling

The follower polls the head every pollMs. When the transport emits EIP-1193 "message" events (it has an on method) and accepts eth_subscribe("newHeads"), the follower ticks on each new head instead, with a backup poll every 10 × pollMs. A browser wallet’s provider may or may not offer subscriptions; the SDK’s JsonRpc (any URL connection) does not, so a server polls. Polling is the default because it works everywhere, and one eth_getLogs a second covers every watched market.

Reorgs

When a new head’s parent is not the last head read, or the last head is no longer on the chain, the follower rewinds its cursor up to reorgDepth blocks, reports a "reorg" update, and reads those blocks again. Logs already delivered from blocks that survived are not delivered twice. Events from blocks the reorg removed are not retracted: treat events as notifications and the snapshots as the truth (the next snapshot is read on the new chain). A node behind a load balancer that answers with an older head than the last one is treated as lagging, not as a reorg.

Errors and Backoff

A failed tick (the RPC is down, a snapshot read failed, a range the node refuses even at one block) is reported as an "error" update and retried after pollMs × 2^failures, capped at maxBackoffMs: 2 s, 4 s, 8 s, … with the defaults. Nothing is lost: the cursor advances only once a range has been read, and a market whose snapshot failed stays due and is read again on the next tick. A node that refuses a wide eth_getLogs range gets half of it, down to one block. A head the node reports but cannot serve (a bogus head number) is not followed until it can.

An "error" with retryInMs: null means the watch could not start (a market name that matches nothing): it has stopped.

LogFollower

// The follower alone: any contracts' raw logs from a cursor, reorg-safe, typed with decodeLiveLog.
const follower = new live.LogFollower(
  bz.ctx.chain,
  () => [eth.address, bz.ctx.terminator],
  (logs, head) => {
    for (const log of logs) {
      const e = live.decodeLiveLog(log, bz.ctx.terminator);
      if (e) show(`${e.name} on ${e.market} in block ${e.blockNumber} (head ${head.number})`);
    }
  },
  (notice) => {
    if (notice.type === "error") console.warn(notice.error);
  },
  { pollMs: 1_000 },
);
await follower.start();
follower.cursor; // the next block it reads from
follower.subscribed; // true when new heads are pushed rather than polled
follower.stop();

new live.LogFollower(chain, addresses, onLogs, onNotice, options?) is the follower both watches use, for following any contracts’ logs:

ArgumentMeaning
chainThe connection’s Chain (bz.ctx.chain).
addressesA function returning the contracts to follow, called each tick (so the set can change).
onLogs(logs, head)Each tick’s new raw logs in chain order (possibly none), with the block they were read up to. A rejection is reported as an error and retried with backoff; the logs it was given are not delivered again.
onNotice(notice)The "head", "reorg" and "error" notices.
optionspollMs, fromBlock, reorgDepth, logChunk, maxBlocksPerTick, subscribe, maxBackoffMs, as above.

start() subscribes if it can and runs the first tick, settling when it is done (it never rejects); tick() reads now; stop() ends it; cursor, subscribed and isStopped report its state.

What Can Go Wrong

WhatWhy
"error" updates with growing retryInMsThe RPC is failing; the watch keeps retrying and catches up when it recovers.
"error" with retryInMs: nullA market could not be resolved (getMarket failed): the watch stopped.
"error": The node reports block N as its head, ... but cannot serve block MA node reported a head it does not have; the follower waits until it can serve it.
"error": could not read the state of market ...A snapshot read failed; it is retried on the next tick.
Events arrive but no snapshotsrefreshMs: 0 and initial: false, or minSnapshotMs is folding them; or the snapshot reads are failing (see the errors).
"undecodable" updatesA log from a watched contract did not match the SDK’s ABI: an SDK older than the contracts.

Sending Transactions

Every write the SDK builds, on any Bazaar contract, comes back as one shape: a PreparedCall. The SDK never holds a user’s key and never sends anything by itself. You inspect the call, simulate it as the user, show its summary, and hand it to whatever signs: a browser wallet, a viem or wagmi client, or a bot’s local key.

This page covers that path end to end. The builders themselves (createOrder, depositCollateral, …) are on their own pages: Collateral, Placing Orders, Managing Orders. The gasless path, where the user signs and a relayer sends, is in Gasless Trading & Relayers.

The samples assume bz connected as in Connecting, user the wallet’s address, and eth a market from Markets.

The Flow

  1. Build the call with a builder. It checks its inputs against what it can read (the account’s orders, the book, the live price) and throws an InputError naming the field for anything it would refuse.
  2. Simulate it as the user (bz.simulate). A revert comes back decoded, with a sentence for the user, before anything is signed.
  3. Show call.summary (and, for a call that takes USDC, call.pullsUsdc) on the confirmation screen.
  4. Send it with the user’s wallet (bz.send, toTransactionRequest for viem and wagmi, or a local key in a bot).
  5. Wait for the receipt (bz.waitForReceipt).

A call that carries a Pyth price update has to land within 3 seconds of that update’s publish time, so for those calls steps 1 to 4 must happen in quick succession. See The 3-Second Price Window.

PreparedCall

const block = await bz.ctx.chain.blockNumber();
const call: PreparedCall = await bz.trade.createOrder(eth.address, user, {
  orderType: OrderType.Limit,
  isLong: true,
  size: parseUnits18("0.5"),
  limitPrice: parseUnits18("3000"),
  expirationBlock: block + orders.blocksIn(24 * 60 * 60), // about a day
});

call.action; // "createOrder"
call.to; // the pair
call.data; // the calldata
call.value; // the Pyth update fee in wei
call.from; // user
call.summary; // "Buy 0.5 at 3000 (limit, GTC)"
call.priceTime; // the update's publish time (unix seconds)
call.pullsUsdc; // 0n: a limit order pulls nothing from the wallet
FieldTypeMeaning
actionstringThe contract function: "createOrder", "depositCollateral", "approve", "execute", "proposeTermination", …
toAddressThe contract to call: the pair, USDC for an approval, the terminator or factory for governance calls.
dataHexThe calldata.
valuebigintETH to send, in wei: the Pyth update fee when the call carries a price update, otherwise 0n.
fromAddress | nullThe account the call acts for: the sender of a direct call. null when the builder does not know it (a relayed call encoded without relayer).
summarystringOne line for a confirmation screen, such as Deposit 100 USDC as collateral or Buy 0.5 at 3000 (limit, GTC). Amounts print exactly.
priceTimenumber | undefinedPresent when the call carries a Pyth update: the update’s publish time in unix seconds (the oldest leg’s, for a composite market).
noFreshPricestring | undefinedPresent when the call takes a Pyth update but carries none because the price source had none the oracle would accept: why. The pair then judges the call on its stored price.
pullsUsdcbigint | undefinedUSDC the call takes from the wallet, in BAZAAR precision (18 decimals): a deposit’s amount, a trigger order’s keeper bounty, 0n for calls that pull nothing. Check the wallet against it with usdcFunding.

The builders that read a price fetch the update from the connection’s prices source; a connection without one fails with ConnectError (“this call needs a fresh Pyth price”) on those builders. They attach it only when the oracle would accept it (priceUpdateFor, updateProblem): an update that fails verification reverts the whole call, while a call with none runs on the pair’s stored price. So when the source’s price is one Pyth carries forward from a closed venue, wider than the 2% confidence cap, or not a usable live price, a market with trading hours gets no update (the call’s noFreshPrice says why), and a continuously traded market, which has no stale fallback, fails with NoFreshPriceError instead of building a call that must revert.

BuilderCarries a price update
createOrder, amendOrders, executeTriggeredOrdersWhenever the oracle would accept one (above).
withdrawCollateralWhen a price source is connected and has one the oracle would accept (the pair refunds the fee when it does not read the price), or withPrice: true. A flat account’s withdrawal goes without one when there is none.
depositCollateral, cancelOrders, claimFees, approveUsdcNever.

The insurance and termination builders that read a price are described on Insurance and Termination & Settlement.

Simulating

const sim = await bz.simulate(call); // as call.from; pass { from } to simulate as someone else
if (!sim.ok) {
  show(sim.error!.message); // "A limit price must be within 50% of the market price..."
} else {
  console.log(`estimated gas: ${sim.gas}`);
}

bz.simulate(call, opts?) runs the call with eth_call as opts.from, else call.from, sending call.value, and nothing is signed or broadcast. It is simulate(bz.ctx, call, opts) with the context bound.

OptionDefaultMeaning
fromcall.fromThe account to simulate as.
estimateGastrueAlso run eth_estimateGas. Skipped when there is no from; a failed estimate leaves gas null rather than failing the simulation.

The result is a Simulation:

FieldTypeMeaning
okbooleanThe call would succeed against the current state.
returnDataHexThe call’s return data, or the revert data when ok is false ("0x" when the node returned none).
errorDecodedError | nullWhy it would revert: the custom error’s name, its decoded args, and message, a sentence for the user. Null when ok. See Errors & Messages.
gasbigint | nullThe eth_estimateGas answer, when it was asked for and succeeded.

A JSON-RPC error answer counts as a revert (ok: false). A transport failure (the RPC is down, an HTTP error, a timeout) is not a revert: simulate throws it.

A simulation is against the state at the latest block. The transaction lands later, after other transactions, so a successful simulation is a strong hint, not a guarantee. It also stores no price: the simulated call reads the update it carries exactly as the real one will.

Sending Through an EIP-1193 Wallet

try {
  const hash = await bz.send(window.ethereum, call); // eth_sendTransaction; resolves once the wallet accepts
  const receipt = await bz.waitForReceipt(hash); // throws CallError if it reverted
  console.log(`order placed in block ${receipt.blockNumber}`);
} catch (e) {
  show(errorMessage(e)); // "You rejected the request in your wallet.", a decoded revert, ...
}

bz.send(wallet, call, opts?) (the standalone sendCall) asks any EIP-1193 provider, such as window.ethereum, to eth_sendTransaction the call, and returns the transaction hash once the wallet has accepted it. The wallet estimates gas and fees itself.

OptionDefaultMeaning
fromcall.fromThe sending account. A call with neither fails with CallError("sending needs the user's address (from)").
gasnoneA gas limit to send instead of the wallet’s own estimate.

Every failure is thrown as a CallError: the user rejecting the prompt, the wallet refusing, or the wallet’s gas estimate reverting (in which case decoded holds the reason).

Sending With viem or wagmi

toTransactionRequest(call) returns { to, data, value, account } (without account when call.from is null), the shape viem’s sendTransaction takes:

const walletClient = createWalletClient({ chain: arbitrum, transport: custom(window.ethereum) });
const request = toTransactionRequest(call); // { to, data, value, account }
const viaViem = await walletClient.sendTransaction({ ...request, account: request.account ?? user });
await bz.waitForReceipt(viaViem);

wagmi’s sendTransaction action and useSendTransaction hook take the same fields, so sendTransaction(config, toTransactionRequest(call)) works there unchanged. Wait for the receipt with bz.waitForReceipt(hash) or with your library’s own receipt helper.

Sending From a Bot With a Local Key

A bot or a service signs with its own key. Connect bz with an RPC URL for reads and send with a viem wallet client that holds a local account:

const account = privateKeyToAccount(process.env.BOT_PRIVATE_KEY as Hex, { nonceManager });
const wallet = createWalletClient({ account, chain: arbitrum, transport: http(RPC_URL) });

const bz = await Bazaar.connect({
  transport: RPC_URL, // reads go through the SDK's own JSON-RPC client
  factory: FACTORY,
  chainId: arbitrum.id,
  prices: { token: process.env.PYTH_PRO_API_KEY }, // a server may hold the Pyth Pro key itself
});
const eth = await bz.markets.getMarket("ETH/USD");

const call = await bz.trade.createOrder(eth.address, account.address, {
  orderType: OrderType.Market,
  isLong: false,
  size: parseUnits18("0.1"),
  maxSlippageBp: 30n,
});

const sim = await bz.simulate(call);
if (!sim.ok) throw new Error(`would revert: ${sim.error!.message}`);

try {
  // Not toTransactionRequest(call): its `account` is the bare address, which would make viem ask the
  // node to sign (eth_sendTransaction). Leave `account` to the client's local account.
  const hash = await wallet.sendTransaction({ to: call.to, data: call.data, value: call.value });
  const receipt = await bz.waitForReceipt(hash);
  console.log(`${call.summary}: ${receipt.status} in block ${receipt.blockNumber}`);
} catch (e) {
  console.error(errorMessage(e));
}

Two details matter here:

  • Do not spread toTransactionRequest(call) into a local-account client: its account is the bare address, and viem then asks the node to sign with eth_sendTransaction, which a public RPC refuses. Pass to, data and value and let the client’s account sign.
  • nonceManager (from viem) hands out nonces locally, so two sends in quick succession do not reuse one. A bot that sends one transaction at a time and waits for each receipt does not need it, but it costs nothing.

Building a Bot puts this together with live prices, a quoting loop and error handling.

The 3-Second Price Window

The pair accepts a Pyth price only when it was generated at most 3 seconds from the block’s timestamp (MAX_PRICE_STALENESS). A call that carries an update is judged on it: the pair has it verified, and an update whose price is older than that reverts the call. A call that carries none runs on the pair’s own stored price while that is at most 3 seconds old, which it is only when another call stored one that recently. Pyth Pro keeps no price on chain, so nothing else stands in. See Oracle for the full rule, including the stale-price regime of markets with trading hours.

The update is fetched when the call is built, so the clock starts then: the time to simulate, to show a confirmation screen, for the user to approve in the wallet, and for the transaction to be included all count against those 3 seconds. In practice:

  • Build as late as possible. buildAndSend(wallet, build, opts?) takes a function that builds the call, calls it, and immediately sends the result through the wallet, returning { call, hash }. Simulate on the same build (as in Rebuilding) or accept that the wallet’s own gas estimate is the only check.
// The price update is fetched inside build(), right before the wallet prompt.
const placeOrder = () =>
  bz.trade.createOrder(eth.address, user, {
    orderType: OrderType.Market,
    isLong: true,
    size: parseUnits18("0.5"),
    maxSlippageBp: 50n, // 0.5%
  });

try {
  const { call: sent, hash } = await buildAndSend(window.ethereum, placeOrder);
  console.log(`${sent.summary}: ${hash}`);
  await bz.waitForReceipt(hash);
} catch (e) {
  show(errorMessage(e));
}
  • Rebuild a call whose priceTime is more than a second or two old before it reaches the wallet. The keeper does the same: its price service sends a print only while it is at least 1 second younger than the limit (sendMargin).

  • Expect direct sends from a slow wallet to revert. A browser wallet’s prompt can take longer than 3 seconds to approve, and the update was fixed when the call was built: once its price is older than the window, the call reverts on chain (BazaarOracle__StalePrice), whatever other traders did meanwhile. The wallet’s estimate was made when the prompt opened, so it does not catch this. A reverted receipt makes waitForReceipt throw; offer to try again with a freshly built call.

  • Gasless calls do not have the problem. The price update is outside the signed request, so the relayer attaches a fresh one when it submits. See Gasless Trading & Relayers.

  • Bots sign locally and send in milliseconds, so build, simulate and send in one go and they land well inside the window.

Rebuilding a Call

/** Whether a call's price update is still young enough to send (the pair allows 3 s at landing). */
function priceStillFresh(c: PreparedCall, maxAgeSeconds = 2): boolean {
  return c.priceTime === undefined || Date.now() / 1000 - c.priceTime <= maxAgeSeconds;
}

// Simulate first, then rebuild if the simulation took long enough for the print to age.
let order = await placeOrder();
const check = await bz.simulate(order);
if (check.ok && !priceStillFresh(order)) order = await placeOrder();

A stale print fails inside Pyth’s contract with Pyth’s own StalePrice error (selector 0x19abf40e). The SDK’s ABIs do not declare it, so it decodes as Unknown(0x19abf40e); when you see that name on a price-carrying call, rebuild it. Errors & Messages shows how to give it a message.

Waiting for the Receipt

bz.waitForReceipt(hash, opts?) polls eth_getTransactionReceipt until the transaction is mined.

OptionDefaultMeaning
timeoutMs120000How long to wait before giving up.
pollMs500Time between polls.

It returns a Receipt (transactionHash, blockNumber, status, gasUsed, effectiveGasPrice, from, to, logs), and throws CallError when the transaction reverted (“transaction 0x… reverted”) or has no receipt by the timeout (“… has no receipt after 120 s”). A revert seen in a receipt carries no reason: the node does not return it. To learn why, simulate the same call again, or read the events with Live Updates. A timeout does not mean the transaction failed: it may still be mined, so look it up by hash before sending it again.

CallError

try {
  const hash = await bz.send(window.ethereum, order);
  await bz.waitForReceipt(hash, { timeoutMs: 60_000, pollMs: 1_000 });
} catch (e) {
  if (e instanceof CallError && e.decoded) {
    // The wallet's own gas estimate reverted: the decoded custom error, with its arguments.
    console.log(e.decoded.name, e.decoded.args);
  }
  show(errorMessage(e));
}

CallError is what sendCall, buildAndSend, waitForReceipt and the batching senders throw:

FieldMeaning
messageThe decoded revert’s user message when there is one, else the wallet’s or node’s own message.
decodedThe DecodedError when the wallet’s error carried revert data (its gas estimate reverted), else null.
causeThe original error from the wallet or library, for logging.

Show errorMessage(e) to the user: it handles a CallError, a wallet rejection, a decoded revert and an InputError alike. Errors & Messages has the details, and the cases it does not cover.

Collateral, Approvals & Permits

Each Bazaar market (pair) keeps its own collateral for each account: USDC deposited into the account’s position bucket on that pair, which backs its position and its resting orders. This page covers moving USDC in and out, the allowance a deposit or a keeper bounty needs, USDC permits as a signed alternative to an approve transaction, and what stops a withdrawal.

The samples assume bz connected as in Connecting, user the wallet’s address, eth a market from Markets, and a run(call) helper that simulates, sends and waits as on Sending Transactions. For the margin rules that decide how much collateral a position needs, see Margin.

Units

Two precisions meet here:

  • BAZAAR precision (18 decimals, 1e18 = $1): every amount the pair takes or reports, including PreparedCall.pullsUsdc. The pair floors what it moves to whole micro-USDC (multiples of 1e12).
  • USDC units (6 decimals): what USDC’s own functions count in: balanceOf, allowance, approve, permit. Every function in the allowance and permits areas takes and returns USDC units.
const amount = parseUnits18("100"); // 100 USDC, BAZAAR precision: 100n * 10n ** 18n
toUsdcUnits(amount); // 100_000_000n: what USDC's own functions count in (6 decimals)
fromUsdcUnits(1_500_000n); // 1.5 USDC back in BAZAAR precision
toUsdcUnitsCeil(parseUnits18("0.0000005")); // 1n: rounded up, for an allowance that must cover it
formatUnits18(amount); // "100"
allowance.formatUsdcUnits(2_500_000n); // "2.5"
HelperConverts
parseUnits18("12.5")A decimal string to BAZAAR precision. Throws UnitError on more than 18 decimals.
formatUnits18(x, maxDecimals?)BAZAAR precision to a decimal string, trailing zeros trimmed.
toUsdcUnits(x)BAZAAR precision to USDC units, floored as the pair floors it.
toUsdcUnitsCeil(x)BAZAAR precision to USDC units, rounded up: for an allowance or permit that must cover a pull.
fromUsdcUnits(x)USDC units to BAZAAR precision.
floorToUsdc(x)BAZAAR precision floored to a multiple of 1e12: what the pair actually moves.
allowance.formatUsdcUnits(x)USDC units to a decimal string.

Units & Constants lists the rest.

Depositing

bz.trade.depositCollateral(pair, from, amount, opts?) builds a deposit of amount (BAZAAR precision) into from’s bucket on pair. The pair pulls the USDC from the wallet, so the wallet needs that much approved to the pair, or a permit carried in the call.

ArgumentMeaning
pairThe market’s pair address (eth.address). It must be one of the deployment’s pairs: any other address is refused with InputError on pair before anything is built.
fromThe depositing account. The collateral is credited to it.
amountBAZAAR precision. Floored to whole micro-USDC first; at least 5 USDC after flooring, or InputError on amount (“The minimum deposit is 5 USDC.”).
opts.permitDataA USDC permit (SignedPermit.permitData) the pair executes before it pulls. Default "0x", none.

The call’s pullsUsdc is the floored amount; its summary reads Deposit 100 USDC as collateral. It carries no price update and sends no ETH.

The pair refuses a deposit once the market is terminated (CollateralLib__DepositsAreBlocked), in the seconds before a scheduled termination cutoff, and while its settlement window is open. The builder does not check these; the simulation reports them.

Checking Balance and Allowance

const deposit = await bz.trade.depositCollateral(eth.address, user, amount);
const funding = await bz.allowance.usdcFunding(user, eth.address, deposit.pullsUsdc ?? 0n);

if (!funding.enoughBalance) {
  show(`You have ${allowance.formatUsdcUnits(funding.balance)} USDC; this deposit needs ${allowance.formatUsdcUnits(funding.needUsdc)}.`);
} else {
  // An approval replaces the previous one, so approve the whole amount the call pulls, not the difference.
  const approved = funding.enoughAllowance || (await run(await bz.allowance.approveUsdc(user, eth.address, funding.approveUsdc)));
  if (approved) await run(deposit); // "Deposit 100 USDC as collateral"
}
FunctionReturns
bz.allowance.usdcBalance(owner)The owner’s USDC balance, in USDC units.
bz.allowance.usdcAllowance(owner, spender)What spender (a pair) may pull from owner, in USDC units.
bz.allowance.usdcFunding(owner, spender, pullBazaar)Balance and allowance against a pull, in one batched read (below).

usdcFunding takes the pull in BAZAAR precision (pass call.pullsUsdc) and returns:

FieldMeaning
needUsdcThe pull in USDC units, rounded up.
balanceThe wallet’s USDC balance.
allowanceWhat the pair may pull now.
enoughBalancebalance >= needUsdc.
enoughAllowanceallowance >= needUsdc.
approveUsdcWhat to approve: needUsdc when the allowance falls short, else 0n. It is the whole amount, not the difference, because an approval replaces the previous one.

Without enough allowance the pull fails inside USDC’s transferFrom with the token’s own revert (on native USDC, ERC20: transfer amount exceeds allowance), so check before simulating, and wait for the approval’s receipt before simulating the deposit.

Approving USDC

bz.allowance.approveUsdc(owner, spender, amountUsdc, opts?) builds USDC’s approve(spender, value) from owner, as a PreparedCall to the USDC contract (action: "approve").

ArgumentMeaning
ownerThe wallet giving the allowance (the call’s from).
spenderA pair of this deployment. Anything else is refused with InputError on pair, revocations included, so a link cannot pass another contract off as a market.
amountUsdcUSDC units. 0n revokes. Ignored with unlimited.
opts.unlimitedApprove 2^256 - 1. Default false.
// Only on the user's explicit choice, with a way to undo it.
const unlimited = await bz.allowance.approveUsdc(user, eth.address, 0n, { unlimited: true });
unlimited.summary; // "Allow 0x... to take an unlimited amount of USDC from your wallet"

const revoke = await bz.allowance.approveUsdc(user, eth.address, 0n);
revoke.summary; // "Revoke 0x...'s USDC allowance"

Exact approvals are the default on purpose. An unlimited approval saves one approve transaction per deposit, but it leaves the whole wallet balance pullable by that contract for as long as the approval stands. Offer it only as the user’s explicit choice, say what it means, and give them a way to revoke it (approveUsdc(owner, pair, 0n)). Each pair pulls for itself, so every market needs its own allowance.

Approvals and deposits are two transactions. A permit makes them one.

USDC Permits

A permit (ERC-2612) is an approval the user signs instead of sending. The pair call carries it in permitData and executes it just before it pulls, so the user signs once and sends once, and on the gasless path needs no ETH at all.

// One signature instead of an approve transaction. A permit SETS the allowance, so skip it when the
// allowance already covers the deposit: signing one anyway would replace a larger approval.
const needUsdc = permits.permitValueFor(amount); // USDC units, rounded up
const current = await bz.allowance.usdcAllowance(user, eth.address);

let permitData: `0x${string}` = "0x";
if (current < needUsdc) {
  const permit = await bz.permits.signPermit(window.ethereum, {
    owner: user,
    spender: eth.address, // must be one of the deployment's pairs
    value: needUsdc,
    // deadline: unix seconds; default 30 minutes from now
  });
  permitData = permit.permitData;
}
await run(await bz.trade.depositCollateral(eth.address, user, amount, { permitData }));

bz.permits.signPermit(wallet, req) asks the wallet to sign a USDC permit with eth_signTypedData_v4, so the wallet shows every field.

req fieldDefaultMeaning
ownerrequiredThe signing wallet’s address.
spenderrequiredThe pair that will pull. Must be one of the deployment’s pairs, or InputError on pair before the wallet is asked.
valuerequiredUSDC units, more than zero. permits.permitValueFor(pullBazaar) converts a BAZAAR-precision pull, rounded up.
deadline30 minutesUnix seconds. The default is DEFAULT_PERMIT_WINDOW (1800 s) past the lower of the chain’s time and the device’s clock.
noncereadThe owner’s permit nonce on USDC; read from the token unless given.

It returns a SignedPermit: the message (owner, spender, value, nonce, deadline), the signature and its v, r, s, and permitData, the abi.encode(value, deadline, v, r, s) the pair call takes.

Rules a permit follows:

  • It sets the allowance; it does not add to it. value must cover everything the call pulls, and a permit signed when a larger allowance is in place replaces that allowance with the smaller one. Check usdcAllowance first, as the sample does. (signRelayed makes that check for you on the gasless path.)

  • It is best effort. A permit that fails (expired, already used, front-run) does not fail the call: the pair emits PermitExecutionFailed and falls back to the allowance already in place, so the call then succeeds or fails on that allowance.

  • One at a time. Each permit uses the owner’s next USDC nonce. Sign one, submit the call that carries it, and only then sign the next; two permits signed in a row share a nonce and the second fails.

  • What it must cover, by call:

    CallThe permit covers
    depositCollateral, depositToInsuranceThe deposit (plus the relayer fee when relayed).
    createOrderThe trigger order’s keeper bounty (plus the relayer fee when relayed).
    cancelOrders, amendOrders, requestInsuranceWithdrawal, the locking executeInsuranceWithdrawalThe relayer fee alone. A direct call of these pulls nothing and ignores a permit.
    withdrawCollateral, the paying executeInsuranceWithdrawalTakes no permit: the fee comes out of the payout.
  • Only an account’s own signature works. The SDK recovers the signature locally and refuses one that does not recover to owner (SigningError), which is what a smart-contract wallet signing through ERC-1271 produces. Smart accounts approve with an approve call instead, usually in the same batch (Smart Accounts & Batching).

  • The wallet must be on the deployment’s chain: the permit’s domain names the chain id, and wallets refuse to sign for another one.

  • The deadline is counted from a checked clock. The SDK takes the lower of the latest block’s timestamp and the device’s clock, and refuses with ClockSkewError when they disagree by more than 2 minutes (MAX_CLOCK_SKEW), so an RPC reporting a far-future block cannot stretch a permit’s life. Local development chains (chain ids 31337 and 1337) skip the check.

The lower-level pieces are in the permits namespace: usdcPermitDomain(ctx) (USDC’s EIP-712 domain, checked against the token’s own DOMAIN_SEPARATOR()), permitNonce(ctx, owner), permitTypedData(domain, msg), encodePermitData(value, deadline, v, r, s), decodePermitData(data), and checkPermit(ctx, permitData, { owner, spender, needUsdc }), which a relayer uses to check a permit it received (see Gasless Trading & Relayers).

Withdrawing

bz.trade.withdrawCollateral(pair, from, amount, opts?) withdraws amount (BAZAAR precision) from from’s bucket to the wallet, which receives it floored to micro-USDC.

const want = parseUnits18("40");
const preview = await bz.previews.previewWithdrawal(eth.address, user, want);

if (preview.freezes.length > 0) {
  show(preview.freezes[0]!.message); // why nothing can be withdrawn right now
} else if (!preview.ok) {
  show(`${preview.blocker!.message} (at most ${formatUnits18(preview.maxWithdrawable, 2)} USDC now)`);
} else {
  await run(await bz.trade.withdrawCollateral(eth.address, user, want));
}
ArgumentMeaning
amountMore than zero, or InputError on amount.
opts.withPriceWhether to attach a Pyth update. Default: attached whenever the connection has a price source with an update the oracle would accept (the pair refunds the fee when it does not read the price); without one, a flat account’s withdrawal goes without and one with exposure is refused on a continuously traded market (NoFreshPriceError). false attaches none. true requires a price source and a fresh price.

An account with a position or resting orders is priced fresh: the pair keeps the position’s margin, its resting orders’ reservation and a minimum behind, and refuses its stored fallback price for a position holder. A flat account with no orders reads no price. Without a price source, the builder refuses (ConnectError) for an account that needs a price.

The most an account can withdraw right now is previewWithdrawal(...).maxWithdrawable, also on the account snapshot as maxWithdrawable (Accounts & Portfolios). Use previewWithdrawal to tell a user why a larger amount is refused.

Withdrawal Freezes

Some states stop withdrawals whatever the account holds. previewWithdrawal lists the ones that apply in freezes, each with its error name, a user message and, when known, until (when it lifts at the latest, unix seconds). The account snapshot’s withdrawalBlocked and the market’s flags (positionWithdrawalsFrozen, allWithdrawalsPaused, with reasons) report the same states for a UI to grey out its button (Markets).

FreezeWhoLifts
An auto-deleveraging window is open (BazaarPair__WithdrawalsFrozenAdlPending)Accounts with a positionWhen ADL resolves, or the market terminates (at most 24 hours plus a 30-minute grace).
The settlement price is fixed and the termination is not finalized (BazaarPair__SweepWindowActive)EveryoneAt finalization, at most 14 days after the price was fixed.
Limbo, the seconds before a scheduled termination cutoff (CollateralLib__PairScheduledForTermination)EveryoneWhen the settlement price is fixed, the settlement window (the row above) takes over.
The vault has realized bad debt (CollateralLib__WithdrawalBlockedVaultUnhealthy)Accounts that are priced (a position or orders)When the market settles.
A market with trading hours whose venue is closed (CollateralLib__WithdrawalRequiresFreshPrice)Accounts with a positionWith the first fresh price at the reopen.

The ledger can also be short: no withdrawal may exceed the pair’s deposits ledger (CollateralLib__LedgerShortfall), which previewWithdrawal reports in ledgerAvailable. For the mechanics behind each freeze see Margin, Termination and Oracle.

Terminated-Pair Payouts

Once a market is terminated and finalized, a withdrawal pays amount × principalRatio, the share of collateral the market’s estate can pay (1e18 = 100%). withdrawCollateral reads the ratio and puts the payout in the call’s summary:

// On a terminated market a withdrawal pays amount x principalRatio; null on a live market.
const terminal = await trade.terminalWithdrawal(bz.ctx, eth.address, user, want);
if (terminal) {
  show(trade.terminalPayoutNote(terminal.payout, terminal.principalRatio));
  // "the market is terminated: it pays 38.5 USDC, 96.25% of the amount"
}

trade.terminalWithdrawal(ctx, pair, user, amount) returns { payout, principalRatio } on a terminated pair and null on a live one; it is not bound on bz, so call it with bz.ctx. A withdrawal that would pay nothing is refused (InputError on amount). Registered profit claims are paid at the separate profitRatio; previewWithdrawal(...).terminal and the account snapshot’s terminal give the whole entitlement. Termination & Settlement covers the rest.

What Can Go Wrong

You seeIt means
InputError on amount: “The minimum deposit is 5 USDC.”The deposit, floored to micro-USDC, is under 5 USDC.
InputError on pair: “… is not a market of this Bazaar deployment …”The address is not one of the factory’s pairs. Nothing was built or signed.
Error: “ERC20: transfer amount exceeds allowance” (or “… balance”)USDC refused the pull: approve or permit first, or the wallet holds too little.
CollateralLib__InvalidDepositAmountThe pair’s own minimum check.
CollateralLib__InsufficientMarginAfterWithdrawal, CollateralLib__MustRetainMinimumCollateralThe withdrawal would leave the position or orders under-margined. Withdraw less (maxWithdrawable).
ConnectError: “this call needs a fresh Pyth price”The account needs a priced withdrawal and the connection has no price source.
ClockSkewErrorThe device’s clock and the chain’s time disagree by more than 2 minutes; nothing was signed.
SigningError: “You declined the signature.”The user rejected the permit prompt.

See Errors & Messages for how to show these.

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.

Managing Orders

Once an order is placed, a trader can read it back, amend it in place, cancel it, fire it themselves if it is a trigger order, and collect what the pair owes them. Each of these is a builder on bz.trade that returns a PreparedCall.

The samples assume bz connected as in Connecting with a price source, user the wallet’s address, eth a market, and a run(call) helper that simulates, sends and waits as on Sending Transactions. The protocol’s rules are in Orders (Amending, Cancellation & cleanup).

Reading an Account’s Orders

bz.account.accountSnapshot(market, user) returns the account on one market, including every live order, decoded (Accounts & Portfolios has the whole snapshot):

const snap = await bz.account.accountSnapshot(eth, user);
for (const o of snap.orders) {
  console.log(
    `#${o.orderId} ${o.typeName} ${o.isLong ? "buy" : "sell"} ${formatUnits18(o.remaining)} of ${formatUnits18(o.size)}`,
    o.inBook ? `resting at ${formatUnits18(o.limitPrice)}` : `waiting for ${formatUnits18(o.triggerPrice)}`,
    o.neverExpires ? "" : `expires in ${o.blocksLeft} blocks`,
    o.bounty > 0n ? `bounty $${formatUnits18(o.bounty)}` : "",
  );
}
snap.takeProfit; // the position's attached TakeProfit, or null
snap.stopLoss;
snap.claimableFees; // refunded bounties, keeper rewards, relayer and integrator fees on this pair

snap.orders lists the live orders in the account’s active set (Limit GTC and StopLimit), then the attached TakeProfit and StopLoss. Each is an AccountOrder:

FieldMeaning
orderIdThe order’s id on the pair (bigint).
orderType, typeName0 to 4, and "Market", "Limit", "StopLimit", "TakeProfit" or "StopLoss".
isLong, isPostOnly, tifAs created (tif: 0 GTC, 1 IOC, 2 FOK).
size, filled, remainingTotal size, the part filled, and what is left (for a TP/SL, capped at the position it closes).
limitPrice, triggerPriceThe limit, and the trigger price for trigger types (0 for the others).
bountyWhat the keeper bounty escrow still holds for the order.
maxSlippageBpAs created.
expiryBlock, neverExpires, blocksLeftThe expiry block, whether it never expires (TP/SL), and blocks until it expires (null when it never does).
inBookResting in the book: a Limit, or a StopLimit that has armed. A trigger order still waiting is false.
closesWholePositionA TP/SL sized to close whatever the position is when it fires.
attached"takeProfit" or "stopLoss" for the position’s attached orders, else null.
integratorThe integrator the order names.
recordThe order as the pair stores it (pair.orders(id), decoded).

Other reads:

  • bz.orders.readOrderState(pair, user): just the position and the order slots (the live Limit + StopLimit ids and the attached TP and SL ids), in one batched read. The order form’s checks use it (Placing Orders).
  • bz.orders.readTriggerBounty(pair, orderId): what one order’s bounty escrow holds.
  • bz.live.watchAccount(user, markets, onUpdate): a snapshot after every event that touches the account, plus the events themselves (OrderUpdated, OrderAmended, OrderAmendSkipped, Trade, …). See Live Updates.
  • bz.history.orders({ account }) and bz.history.orderEvents(...): past orders and their lifecycle from the subgraph. See History.

Amending

bz.trade.amendOrders(pair, from, amends) changes resting Limit (GTC) orders in place: each gets a new price and a new total size, and keeps its id, expiry and integrator.

// Reprice order #42 to 2990 and make its total size 1.5 (filled part included).
await run(await bz.trade.amendOrders(eth.address, user, [{ orderId: 42n, limitPrice: parseUnits18("2990"), size: parseUnits18("1.5") }]));

Each amendment is an AmendInput:

FieldMeaning
orderIdOne of from’s resting Limit GTC orders.
limitPriceThe new limit price, BAZAAR precision.
sizeThe new total size, the filled part included: it must exceed what has filled.
predOptional: the order to rest directly behind at the new price (0 = none). Found for you when both hints are omitted.
pred2Optional: a second candidate, tried when the first has left the book.
bestEffortSkip this amendment instead of reverting the whole batch when the order has filled, been canceled or expired, the size is at or below what filled or leaves under $5, or the new price would cross. Default false.

How an amendment moves the order: a new price, or a larger size, sends it to the back of its new price level; a smaller size at the same price keeps its place. Up to 32 amendments per call (MAX_AMENDS_PER_CALL, the same as the active-order cap, so one call can requote every order). The same order may appear more than once; each amendment applies in turn. Margin is judged once, on the set of orders the batch leaves. A batch is all or nothing, except for best-effort amendments, which the pair skips and reports with OrderAmendSkipped (reason Inactive, Expired, SizeFilled, BelowMinimum or WouldCross).

// Requote every resting limit order 0.2% higher in one call. bestEffort: an order that filled or
// expired meanwhile is skipped (OrderAmendSkipped) instead of reverting the whole batch.
const resting = snap.orders.filter((o) => o.typeName === "Limit" && o.inBook);
// Moving up: amend the asks first, so a bid's new price is not judged against an ask about to move.
resting.sort((a, b) => Number(a.isLong) - Number(b.isLong));
const amends = resting.map((o) => ({
  orderId: o.orderId,
  limitPrice: (o.limitPrice * 10_020n) / 10_000n,
  size: o.size, // unchanged size and a new price: the order goes to the back of its new level
  bestEffort: true,
}));
if (amends.length > 0) await run(await bz.trade.amendOrders(eth.address, user, amends));

What the builder checks first, as the pair’s _amendOne will, throwing InputError:

  • the order exists, is from’s, and is a Limit GTC (“only those can be amended. Cancel it and place a new one.”);
  • it is still open and unexpired (not raised for a best-effort amendment);
  • the new price is within ±50% of the oracle price;
  • the new size exceeds what has filled, and what is left to fill is worth at least $5 (or is the whole position) (not raised for best effort);
  • the new price does not cross the best order on the other side (“Place a new order to trade now.”), unless that order is itself moved earlier in the same batch (not raised for best effort);
  • a move (new price or larger size) needs about a minute of life left (the minimum lifetime plus the SDK’s 40-block margin): “expires too soon to be moved. Cancel it and place a new one.”

Amendments are checked in order, each against the order as the earlier ones left it, so order them so no step crosses: when requoting upward, move the asks before the bids, as the sample does.

Hints are part of the amendment. Unlike an order’s MatchHints, an amendment’s hints are packed into its id word (orders.amendIdWord(orderId, pred, pred2, bestEffort): the id in bits 0 to 63, the hints in bits 64 to 191, the best-effort flag in bit 192), and the flat word array is what a gasless AmendOrders request signs. For a gasless amendment the hints are fixed when the user signs; a relayer cannot refresh them. orders.encodeAmends(amends) and orders.decodeAmends(words) convert between AmendInput[] and the flat array (one triple of id word, price and size per amendment).

amendOrders always carries a price update (the placement band and margin are judged at a fresh price), so it needs a price source and should be sent promptly.

bz.trade.prepareAmends(pair, owner, amends) runs the same checks and fills in hints without building a call:

// The same checks and hints without building a call (a gasless amendment signs prep.words).
try {
  const prep = await bz.trade.prepareAmends(eth.address, user, [{ orderId: 42n, limitPrice: parseUnits18("2990"), size: parseUnits18("1") }]);
  console.log(prep.amends[0]!.pred, prep.words.length); // the hint found, and 3 words per amendment
} catch (e) {
  if (e instanceof InputError) show(e.message); // "orderId: Order #42 is no longer open."
}

It returns { amends, words, price, currentBlock }: the amendments with hints, the flat words, and the price and block they were checked at.

Canceling

bz.trade.cancelOrders(pair, from, orderIds) cancels up to 200 of from’s orders in one call (MAX_CANCELS_PER_CALL). Each is unlinked from the book in the same transaction, and any keeper bounty it still holds returns to from’s claimable balance. There is no margin check and no price update.

await run(await bz.trade.cancelOrders(eth.address, user, [42n, 43n]));

// Everything at once: every live order, up to 200 per call.
const ids = snap.orders.map((o) => o.orderId);
if (ids.length > 0) await run(await bz.trade.cancelOrders(eth.address, user, ids.slice(0, 200)));

The builder refuses the following (trade.checkCancelIds(ids) runs the list checks alone, with no reads):

  • an empty list, more than 200 ids, a duplicate, or a non-positive id;
  • an order that does not exist or is not from’s;
  • an order that has already ended (filled, expired, auto-canceled) and holds no bounty: “Order #12 is already closed.” or “… has already expired.”.

An order that has ended but still holds a bounty in escrow can be “canceled” to reclaim it; the summary then says so (Cancel order #12 (reclaiming its bounty)).

Firing Your Own Trigger Orders

A trigger order with no bounty attracts no keeper, so its owner fires it. bz.trade.executeTriggeredOrders(pair, from, orderIds) executes up to 20 of from’s own StopLimit, TakeProfit and StopLoss orders (MAX_TRIGGER_EXECS_PER_CALL) against a fresh price:

// Fire this user's own trigger orders (the zero-bounty ones no keeper will) that the live price has reached.
const { price } = await bz.trade.livePairPrice(eth.address);
const due = snap.orders
  .filter((o) => !o.inBook && o.triggerPrice > 0n && triggerReached(o.orderType, o.isLong, o.triggerPrice, price.spot))
  .map((o) => o.orderId)
  .slice(0, 20);
if (due.length > 0) await run(await bz.trade.executeTriggeredOrders(eth.address, user, due));

Each order must be from’s, a trigger type, live, and still waiting outside the book (a StopLimit that has already armed is resting and refused). The builder reads the price the call will carry and refuses an order whose trigger it has not reached (“Order #7’s trigger (2700) has not been reached; the price is 2750.”); triggerReached(orderType, isLong, triggerPrice, price) is the same test, exported for a UI. A StopLimit gets a hint for the slot it will rest in. On chain the pair evaluates each trigger against the price the call carries and skips, without reverting, any that is not eligible then. An execution of your own order pays its bounty share to you.

Watching for the moment a trigger is reached is a job for live prices: subscribe to the market (Live Prices) and build the call when triggerReached turns true.

Claiming Fees

bz.trade.claimFees(pair, from) pays from everything the pair owes it: refunded trigger bounties, keeper rewards, relayer fees and integrator fees. It works in any pair state and has no gasless form: the caller pays the gas.

if (snap.claimableFees > 0n) {
  const claim = await bz.trade.claimFees(eth.address, user); // "Claim 1.25 USDC"
  await run(claim);
}

The builder reads the claimable amount from the lens first and refuses when it is zero (InputError on pair: “You have nothing to claim on this market.”); the summary names the amount. Balances are per pair: snap.claimableFees for one market, bz.account.portfolio(user) totals them across markets. CollateralLib__ClaimTransferFailed means USDC refused the transfer (the address cannot receive USDC).

What Can Go Wrong

You seeIt means
InputError on orderId: “Order #42 is not a resting limit order …”Only Limit GTC orders can be amended. Cancel and place a new one.
InputError on limitPrice: “… would cross the book (best ask 3001.5). Place a new order to trade now.”An amendment cannot take liquidity.
InputError on orderId: “Order #42 expires too soon to be moved.”Less than about a minute of life left.
InputError on amends: “At most 32 orders can be amended at once.”Split the batch.
InputError on orderIds: “Order #12 is already closed.”Nothing left to cancel or reclaim.
InputError on orderIds: “Order #7’s trigger … has not been reached”The price has not met the trigger yet.
OrderManagementLib__... from the simulationThe pair’s own refusal; the message says what to change.

Previews

A preview tells a user what an order or a withdrawal will do before they sign it: the fills it meets, each fill’s fee, the collateral it needs, the position it leaves and that position’s liquidation price, the most they can withdraw, and every reason the pair would refuse. Nothing is sent.

bz.previews.previewOrder and bz.previews.previewWithdrawal read the pair (two batched reads each), then run the inputs through TypeScript ports of the pair’s own arithmetic: the matching walk, the fee schedule, the per-fill margin gate, the position transition and the creation margin check. The pure functions underneath (previews.simulateOrder and the fee and margin helpers) take every input explicitly, for a UI that already holds the pair’s state and wants to re-run them on every keystroke.

The samples assume bz connected as in Connecting, user the wallet’s address and eth a market. For the mechanics behind the numbers see Margin, Fees and Matching.

Exact and Estimated

The integer math is the contract’s, so a preview matches the transaction whenever its inputs still hold when the transaction lands. They may not: the call stores a fresh price first, which can move the funding index, the fee rates and (at most once a minute) the margin rates, and other transactions can change the book in between.

Exact for the inputs readEstimates
The fee of each fill.Which makers an order meets: a maker’s own margin gate can cancel it mid-walk.
What the creation check requires of collateral.When a resting remainder fills: assumed later, at its limit price, as maker.
Each fill’s margin verdict.What a trigger order meets when it fires: assumed at its trigger price.
The resulting position.The liquidation price: funding is held where it is now.
The account’s current state (BazaarPairLens.checkBucketSolvency).
The withdrawal ceiling (BazaarPairLens.getMaxWithdrawable).

One more difference: a fill whose fees exceed what the pair’s deposits ledger can fund has its fees waived on chain; previews always charge them.

Previews value everything at the pair’s last stored price unless you pass the live one. Pass it: the call will carry a fresh print, and the stored price may be seconds or (on a quiet market) minutes old.

previewOrder

bz.previews.previewOrder(pair, account, order, opts?):

// Judge at the live price the call will carry, not the pair's last stored one.
const live = await bz.trade.livePairPrice(eth.address);
const price: previews.PriceInput = { spot: live.price.spot, low: live.price.low, high: live.price.high, publishTime: live.publishTime };

const p = await bz.previews.previewOrder(
  eth.address,
  user,
  { orderType: OrderType.Market, isLong: true, size: parseUnits18("2"), maxSlippageBp: 50n },
  { price },
);

if (p.blockers.length > 0) {
  console.log(p.blockers.map((b) => b.message)); // every reason the pair would refuse it
} else {
  console.log(`fills ${formatUnits18(p.takerSize)} at an average ${formatUnits18(p.takerAvgPrice ?? 0n, 2)}`);
  console.log(`fees ${usd(p.fees.total)}, ${usd(p.fees.toInsuranceFund)} of it to the insurance fund`);
  console.log(`needs ${usd(p.margin.requiredCollateral)} of collateral; has ${usd(p.margin.collateral)}`);
  console.log(`position after: ${formatUnits18(p.after.size)} ${p.after.isLong ? "long" : "short"}`);
  console.log(`liquidation price ~${p.after.liquidationPrice === null ? "none" : formatUnits18(p.after.liquidationPrice, 2)}`);
  if (p.canceledSize > 0n) console.log(`${formatUnits18(p.canceledSize)} canceled: ${p.cancelReason}`);
}

order is the preview’s OrderInput, the same fields as an order for createOrder with a few differences: orderType defaults to Market, tif is a number (0 GTC, 1 IOC, 2 FOK), a bounty packed into triggerPrice is ignored, there is no expiry, and integrator (default: the connection’s) can be set to see the fees with or without one.

OptionDefaultMeaning
pricethe pair’s stored priceThe price the call will carry: { spot, low?, high?, publishTime? }. publishTime lets the preview tell whether a market with trading hours has its venue closed.
walkBooktrueWalk the current book (up to 33 orders on the opposite side). false assumes one fill at fillPrice.
fillPricethe markThe assumed fill price when the book is not walked.

The preview throws InputError on price when the pair has no stored price yet and none was given.

The Result

previewOrder returns an OrderPreviewResult:

FieldMeaning
blockersEvery reason the pair would refuse the order outright: trading halted, validation, margin. Empty when it would go through. Each is a PreviewIssue (below).
before, after, afterArrivalThe position before; after everything fills (what fills now plus a resting remainder assumed filled later at its limit); and after what fills in this transaction only. Each is a PositionView.
fillsEach fill: price, size, notional, role ("taker" or "maker"), fee ({ insurance, integrator, total }), later (a resting remainder’s assumed fill), assumed (not met in the book read).
triggersNowA trigger order’s trigger is already met at the mark, so it fires on arrival.
takerSize, takerAvgPriceSize that fills in this transaction, and its average price (null when nothing fills).
restingSizeSize left resting in the book, or waiting outside it for a trigger order.
laterFillOkWhether the resting remainder would pass its margin gate if it filled at today’s mark; null when nothing rests. It is reserved in full at creation, so only a moved price, rate or funding can fail it.
canceledSize, cancelReasonSize canceled at the end of the walk, and why: Unfilled, BelowMinimum, PostOnly, SelfMatch, StepLimit, Margin, PriceBand or PositionClosed.
realizedPnl, settledFundingPrice PnL and funding realized on the size the order closes.
feesThe sum over every fill, with where it goes: insurance, integrator, total, and after the 1% bug-bounty skim toInsuranceFund, toIntegrator, toBugBounty.
opens, reduces, closes, flipsThe order adds exposure; trades against the held position; leaves it flat; leaves it on the other side.
marginThe margin view (below).
maxLeverageThe most leverage a new position can take now: 10000 / imrBp.
markThe price the preview judged at.
venueClosedA market with trading hours whose venue is closed; null when it cannot be told (no publishTime given).
feeRatesThe taker rates used: takerEbp and closingEbp (1/100 bp), and the insurance fund’s coverageRatioBp and targetRatioBp they come from.

margin:

FieldMeaning
imrBp, mmrBp, effectiveMmrBpInitial and maintenance margin rates, and the maintenance rate liquidation judges by (the lower of the current one and a non-zero 24-hour lagged one).
initialMarginInitial margin on the resulting position.
requiredCollateralWhat createOrder requires of collateral; 0 when it does not judge this order (a TP/SL, or an order that adds no exposure beyond the held position).
collateralThe account’s collateral now.
ok, shortfallWhether the collateral covers the requirement, and by how much it falls short.
retainedFloorBp, retainedFloorThe collateral the resulting position must keep as cash (unrealized profit does not count): the maintenance rate on 24/7 markets, the initial rate on markets with trading hours. Withdrawals stop there.

A PositionView (before, after, afterArrival):

FieldMeaning
isLong, size, entryValue, entryPriceThe position; entryPrice is entryValue / size, 0 when flat.
collateral, entryFundingIndexAs the pair would store them.
notional, unrealizedPnl, fundingPnl, equityValued at the mark.
maintenanceRequired, isSolventThe maintenance requirement at the effective rate, and whether equity covers it.
liquidationPriceEstimated (see liquidationPrice).
leverageNotional over equity; null when flat or without positive equity.

A PreviewIssue names the custom error the pair would revert with (error, its ABI name), its args, and message, the same user message a decoded revert shows (Errors & Messages). For a freeze, until is when it lifts at the latest (unix seconds) when known.

Orders That Rest

// A resting bid: what fills now, what rests, and whether the rest would pass its margin gate later.
const bid = await bz.previews.previewOrder(eth.address, user, {
  orderType: OrderType.Limit,
  isLong: true,
  size: parseUnits18("5"),
  limitPrice: parseUnits18("2950"),
});
bid.restingSize; // the part that rests in the book
bid.laterFillOk; // null when nothing rests
bid.afterArrival; // the position after this transaction only
bid.after; // ...and once the resting part has filled at its limit, as maker

For a Limit order the preview walks what crosses now, then treats the remainder: it rests (and is assumed to fill later at its limit, as maker, which after includes), or is canceled (IOC, post-only, a remainder under $5). A trigger order that has not fired is previewed at its trigger price, with assumed fills.

previewWithdrawal

bz.previews.previewWithdrawal(pair, account, amountBazaar?, opts?) gives the most the account can withdraw now and every freeze that applies, and, given an amount (BAZAAR precision), whether it goes through:

const w = await bz.previews.previewWithdrawal(eth.address, user, parseUnits18("250"), { price });
w.maxWithdrawable; // the most withdrawCollateral accepts now
w.freezes; // what stops every withdrawal now, each { error, args, message, until? }
w.ok; // whether 250 goes through
w.blocker?.message; // if not, the pair's reason: "With a position open you must keep $812.40 of collateral ..."

opts.price is the price the call will carry ({ spot, low?, high?, publishTime? }); a position is valued at the conservative edge of it, a long at low and a short at high, as the pair values a withdrawal. A negative amount throws InputError.

FieldMeaning
maxWithdrawableThe largest amount withdrawCollateral accepts now, from BazaarPairLens.getMaxWithdrawable at the conservative price. Exact for that price. 0 while a freeze applies.
collateralThe account’s collateral on the pair.
ledgerAvailableThe pair’s deposits ledger: no withdrawal may exceed it.
hasPosition, activeOrdersWhether the account holds a position, and how many live orders it has.
priceUsedThe price the ceiling was computed at; null for an account the pair does not price (flat with no orders, or a terminated pair).
venueClosedAs in previewOrder.
freezesWhat stops withdrawals now, each a PreviewIssue with until when known. See Withdrawal Freezes.
amountThe amount asked about, or null.
okWhether that amount goes through; null without an amount.
blockerThe PreviewIssue the pair would raise for it, or null: the first freeze, else the ceiling it runs into, in the order the pair checks them (insolvency, the collateral itself, the retained floor, initial margin after the withdrawal, the ledger). Which ceiling binds is re-derived with the pair’s arithmetic, so it is an estimate when the price moves before the call lands.
terminalOn a terminated market, the lens’s terminal entitlement: collateral, registeredClaim, principalRatio, profitRatio, payout. null otherwise.

Running the Arithmetic Yourself

previews.simulateOrder(input) is previewOrder without the reads: it applies createOrder’s validation, its margin check, the arrival walk against book (per-fill fees, margin gates and the position after each fill), and the remainder’s disposal, and returns an OrderPreview (the result above without mark, venueClosed and feeRates). Read the state once, then call it as often as a slider moves:

// A size slider: read the pair once, then re-run the arithmetic locally on every change.
const [snap, acct] = await Promise.all([bz.markets.marketSnapshot(eth), bz.account.accountSnapshot(eth, user)]);
const rates: previews.MarginRates = { imrBp: snap.margin.imrBp, mmrBp: snap.margin.mmrBp, laggedMmrBp: snap.margin.laggedMmrBp };

function quote(size: string): previews.OrderPreview {
  return previews.simulateOrder({
    order: { orderType: OrderType.Market, isLong: true, size: parseUnits18(size), maxSlippageBp: 50n },
    account: user,
    integrator: bz.ctx.integrator,
    bucket: acct.position,
    mark: price.spot,
    fundingIndex: snap.funding.projectedIndex,
    rates,
    fees: p.feeRates, // the taker rates the full preview above read
    continuous: snap.market.continuous,
  });
}
quote("0.5").margin.shortfall; // 0n when the collateral covers it
SimulateInput fieldMeaning
orderThe order, as for previewOrder.
account, integratorThe trader (to detect self-matches) and the default integrator for the fees.
bucketThe position as the pair stores it: isLong, size, entryValue, collateral, entryFundingIndex, optionally the TP and SL ids. An account snapshot’s position fits.
markThe price the call resolves.
fundingIndexThe funding index the call will see. A market snapshot’s funding.projectedIndex is the index a price stored now would leave.
ratesMarginRates: imrBp, mmrBp, laggedMmrBp.
feesTakerFeeRates: takerEbp, closingEbp (from takerInsuranceFeeRates, or a full preview’s feeRates).
continuousA 24/7 market (true) or one with trading hours.
staleThe call would run on the stale fallback (venue closed).
bookThe opposite side of the book, best first, as BookMakers (orderId, owner, price, remaining, expiryBlock). Without it, fills are assumed at fillPrice.
currentBlockThe block the book’s expiries are judged at.
resting, activeOrdersThe account’s resting orders reserved at the mark (restingReserve), and how many it has (for the 32-order cap).
fillPriceThe assumed fill price without a book, or for a trigger order that has not fired.
beforeStateThe account’s current state from the lens, for before.

Fees, Margin and Liquidation

const pos = acct.position;
previews.liquidationPrice(pos, snap.funding.projectedIndex, rates); // estimated; null when flat
previews.maxLeverage(rates.imrBp); // 25 at a 4% initial margin

const fee = previews.takerFillFee(parseUnits18("1"), parseUnits18("3000"), true, pos, p.feeRates, true);
previews.feeBreakdown(fee); // { insurance, integrator, total, toInsuranceFund, toIntegrator, toBugBounty }
previews.makerFillFee(parseUnits18("1"), parseUnits18("3000"), true).total; // 0.5 bp of $3,000 = $0.15

previews.takerInsuranceFeeRates(150n, 200n); // below the 2% target: the taker rate rises above the base
HelperWhat it computes
liquidationPrice(pos, fundingIndex, rates)Where the position’s equity meets its maintenance requirement at the effective rate. A long is liquidatable below it, a short above. null for a flat position or a long whose collateral covers its whole entry value; 0 for a short insolvent at any price. An estimate: funding accrues and rates move.
maxLeverage(imrBp)10000 / imrBp, or null when imrBp is 0.
takerFillFee(size, price, orderIsLong, position, rates, hasIntegrator)One taker fill’s fee: the insurance leg at the dynamic rate (the closing rate on the part that reduces the position) and the integrator leg at 0.25 bp.
makerFillFee(size, price, hasIntegrator)One maker fill’s fee: 0.25 bp insurance, plus 0.25 bp integrator when the order carries one.
feeBreakdown(fee)Where a fee goes: 1% of each leg to the bug bounty, the rest to the insurance fund and the integrator.
takerInsuranceFeeRates(coverageRatioBp, targetRatioBp)The taker’s insurance rates from the fund’s coverage: the base rate at or above target, rising as 1 + 49 × shortfall² below it, capped at 50 bp.
takerInsuranceFeeBase(targetRatioBp)The base rate: 0.75 bp at a 2% target, rising linearly to 4.25 bp at 10%.
positionState(pos, price, fundingIndex, rates)The pair’s own valuation of a position (BucketLib.calculateState).
applyFill(pos, orderIsLong, size, price, fee, fundingIndex)One fill’s effect on a position: realized PnL, settled funding, a flip.
fillMarginOk(...)The per-fill margin gate (_checkMargin). A refused fill auto-cancels the order (cancel reason Margin).
creationGate(order, pos, state, mark, imrBp, resting)What createOrder requires of collateral, and whether the account has it.
restingReserve(orders, mark), fillDebit(...), worstCaseNotional(...)How the pair reserves an account’s resting orders.

Rates are in EBP, 1/100 of a basis point (EBP_SCALE = 1,000,000). The constants they use are exported too (MAKER_INSURANCE_FEE_EBP, TAKER_INSURANCE_FEE_MIN_EBP, BUG_BOUNTY_TAX_BP, …).

Errors & Messages

Every failure a Bazaar call can raise has a sentence a user can act on. The SDK ships one message for every custom error the five Bazaar contracts declare, explains the failures wallets raise before anything reaches the chain, and refuses bad inputs with errors that name the field. This page covers what can be thrown, how to turn it into text, and the few cases you still have to handle yourself.

The samples assume bz connected as in Connecting, user the wallet’s address and eth a market.

The Short Version

try {
  const call = await bz.trade.createOrder(eth.address, user, {
    orderType: OrderType.Market,
    isLong: true,
    size: parseUnits18("1"),
    maxSlippageBp: 50n,
  });
  const sim = await bz.simulate(call);
  if (!sim.ok) throw sim.error!; // a DecodedError
  await bz.waitForReceipt(await bz.send(window.ethereum, call));
} catch (e) {
  show(errorMessage(e)); // one sentence, whatever failed
}

errorMessage(e) takes anything a call threw (or a simulation’s error) and returns one sentence. It never returns a raw URL or a stack trace. Use it as the default, and add the cases in A Complete Handler for the errors it does not explain.

What Can Be Thrown

ErrorFromMeans
InputErrorEvery builder, buildOrderSpec, parseRelayedAn argument the SDK refused before building or signing anything. field names it.
DecodedErrorSimulation.error, CallError.decoded, decodeError(data)A revert, decoded: the custom error’s name, arguments and user message. Not thrown by the SDK itself; a simulation returns it.
CallErrorsendCall (bz.send), buildAndSend, waitForReceipt, batching.sendCallsA send the wallet refused or that reverted, with decoded when the revert data came back.
SigningErrorsignTypedDataV4, signPermit, signRelayedThe wallet declined to sign, or returned a signature Bazaar cannot verify (a smart-contract wallet’s, a high-s one).
ClockSkewErrorsignPermit, signRelayed, chainDeadline, checkClockSkewThe chain time the RPC reports and the device’s clock disagree by more than 2 minutes, so nothing was signed. A SigningError.
ConnectErrorBazaar.connect, builders that need a price or the subgraphWrong chain, an address that is not a factory, or a connection without the prices or subgraph source the call needs.
UnitErrorparseUnits18, parseDecimalText that is not a decimal number, or has too many decimals.
PriceUpdateErrortrade.parsePriceUpdate, livePairPriceA price update that is not a Pyth Pro update, or one missing a leg of the market.
ProErrorThe Pyth Pro client, price sources, livePricesPyth Pro could not be reached or refused the request (status holds the HTTP status), sent an update that cannot be read or is not signed by a trusted signer, or has no usable live price for a feed. The message never holds a token, and every URL in it is redacted.
NoLivePriceErrorproPriceSource().latest, LivePrices.latestA ProError: the endpoint answered, but has no usable live price for a feed asked for (the message says why).
NoFreshPriceErrorBuilders that need a price (createOrder, a withdrawal with exposure, …)A ProError: no update the oracle would accept is available for a continuously traded market, so the call would revert. reason says why; errorMessage gives a sentence for users.
LazerErrorlazer.parseUpdate and the other codec functions, feedIdHex, proFeedIdBytes that are not a valid Pyth Pro update, or a value that is not a Pyth Pro feed ID.
RpcError, HttpError, SendErrorThe JSON-RPC clientThe node’s error, an HTTP failure, or a send whose outcome is unknown. See Low-Level Building Blocks.

errorMessage

errorMessage(e) checks, in order:

  1. A DecodedError: its message.
  2. An InputError: its message (which starts with the field: "amount: The minimum deposit is 5 USDC.").
  3. A NoFreshPriceError: “No fresh price is available for this market right now, and this action needs one. Try again in a moment.”
  4. An error carrying a decoded DecodedError (a CallError): that error’s message.
  5. A wallet-side failure (walletErrorMessage, below).
  6. An RPC error carrying revert data: the decoded revert’s message; with empty revert data, “The call reverted without a reason (often out of gas).”
  7. Anything else: “Something went wrong. Please try again.”

Step 7 catches more than it should: a SigningError or ClockSkewError (including “You declined the signature.”), a ConnectError, and a CallError without decoded data (a reverted receipt, a timeout) all come out as the generic sentence, although each carries a good message of its own. Handle them before calling errorMessage, as the handler below does.

Decoded Reverts

A simulation that fails returns a DecodedError:

const sim = await bz.simulate(await bz.trade.withdrawCollateral(eth.address, user, parseUnits18("1000000")));
if (sim.error) {
  sim.error.name; // e.g. "CollateralLib__InsufficientCollateral"
  sim.error.args; // { amount: 1000000000000000000000000n, available: 2500000000000000000000n }
  sim.error.message; // "You asked for $1,000,000.00, but only $2,500.00 is available."
  sim.error.hint; // the operator-facing hint, or ""
  sim.error.data; // the raw revert data
  sim.error.opensAt(); // for a "too early" error, when the call opens (unix seconds); else null
  String(sim.error); // "CollateralLib__InsufficientCollateral(amount=..., available=...)", for logs
}
MemberMeaning
nameThe custom error’s name in the ABI, such as "OrderManagementLib__InsufficientMarginAfterOrder".
argsIts decoded arguments by name (bigint for numbers).
messageThe user message (with the arguments formatted), else the operator hint, else the name.
hintThe operator-facing hint from KEEPER_HINTS, written for someone running the keeper, or "".
dataThe raw revert data.
opensAt()For a “too early” error, the time the call opens (from an argument named deadline, effectiveTs, expiryTs, opensAt or scheduledTs), else null.
toString()Name(arg=value, ...) and the hint: for logs.

decodeError(data) decodes any revert data:

Datanamemessage
A custom error one of the five ABIs declaresIts nameIts user message.
Error(string) (a require message, such as USDC’s ERC20: transfer amount exceeds allowance)"Error"The string itself, except the reasons Pyth’s verifier reverts with (invalid signer, invalid signature, Insufficient fee provided, input too short, invalid evm magic), which get a sentence from REASON_MESSAGES.
Panic(uint256)"Panic"Solidity panic 0x11. (the code in hex)
Empty, or shorter than a selector"Reverted"“The call reverted without a reason (often out of gas).”
Any other selector"Unknown(0x...)"“An error this package’s ABIs do not declare.”

User Messages

USER_MESSAGES holds one message for every custom error the five contracts declare (BazaarPair, and the libraries it links whose errors surface at its address, BazaarFactory, BazaarPairLens, BazaarPairTerminator and BazaarOracle): 187 errors, the Bazaar ones from BazaarPair__TradingHalted to Factory__ProposalNotFound and the OpenZeppelin ones the contracts inherit (SafeERC20FailedOperation, ECDSAInvalidSignature, ReentrancyGuardReentrantCall, the SafeCast overflows, …). The SDK’s test suite fails when an error in the ABIs has no message. Each says what happened and what to do, with the error’s arguments formatted: BAZAAR-precision amounts as dollars, prices with their decimals, basis points as percents, times as dates with a relative hint.

userMessage("OrderManagementLib__TooManyActiveLimitOrders");
// "You have the maximum of 32 open limit and stop-limit orders on this market. Cancel some first."
userMessage("MetaTx__InvalidNonce", { expected: 7n }); // arguments are formatted into the text
Object.keys(USER_MESSAGES).length; // one per custom error the five contracts declare

userMessage(name, args?) returns the message for an error name (the name itself when unknown). Bazaar.connect registers the messages with the decoder, so DecodedError.message returns them; installUserMessages() does the same explicitly, for code that decodes without connecting (it is idempotent).

The formatters the messages use are exported in the format namespace, for showing amounts the same way: formatUsd (“$1,234.56”; amounts under a cent keep six decimals), formatPrice (“$3,012.5”), formatAmount (“1.25”), formatBp (250 to “2.5%”), formatEth (“0.0001 ETH”), formatDuration (“12 min”) and formatTime (“2026-11-08 14:00 UTC (in 3 h)”).

Wallet Errors

walletErrorMessage(e) explains failures the wallet raised rather than the contract, and returns null for anything else. It walks cause chains, so it reads errors from viem, ethers and raw providers.

FailureMessage
4001, ACTION_REJECTED, UserRejectedRequestError, “user rejected”You rejected the request in your wallet.
4100Your wallet has not authorized this site for that account. …
4200Your wallet does not support this request.
4900Your wallet is disconnected. …
4901, ChainDisconnectedErrorYour wallet is not connected to this network. …
4902This network has not been added to your wallet yet. …
-32002Your wallet already has a request waiting. …
Insufficient fundsYour wallet does not have enough ETH for gas (plus any ETH the call sends). …
Nonce too lowYour wallet’s transaction count is out of date: another transaction already used it. …
Chain mismatchYour wallet is on a different network than this app. …
try {
  await bz.send(window.ethereum, await bz.trade.cancelOrders(eth.address, user, [1n]));
} catch (e) {
  const wallet = walletErrorMessage(e); // null unless the wallet itself refused
  if (wallet) show(wallet); // "You rejected the request in your wallet."
}

InputError

Builders check their arguments before reading the chain or asking for a signature, and throw InputError with field, the argument at fault ("amount", "limitPrice", "orderIds", "pair", …). The message is prefixed with the field; strip it to show the sentence next to that input:

try {
  await bz.trade.depositCollateral(eth.address, user, parseUnits18("2"));
} catch (e) {
  if (e instanceof InputError) {
    e.field; // "amount": put the message next to that input
    e.message; // "amount: The minimum deposit is 5 USDC."
    e.message.slice(e.field.length + 2); // "The minimum deposit is 5 USDC."
  }
}

The order form on Placing Orders does this on every keystroke with buildOrderSpec, which throws the same errors without any read.

CallError and ClockSkewError

CallError (see Sending Transactions) has message, decoded (the DecodedError when the wallet’s error carried revert data) and cause.

ClockSkewError has chainTime (the latest block’s timestamp as the RPC reported it) and localTime (the device’s clock), both unix seconds, and a message telling the user to set the device’s clock to update automatically or to connect through another RPC.

Errors the SDK Does Not Name

A few reverts come from contracts outside the five ABIs:

  • Pyth’s StalePrice() (Unknown(0x19abf40e)): a direct call’s price update aged past the 3-second window before the transaction landed. The most common failure of a slow direct send. Rebuild the call and send it again (Sending Transactions).
  • USDC’s require messages (Error): “ERC20: transfer amount exceeds allowance” or “… balance”. Check funding first with usdcFunding (Collateral).
  • The example trading account’s BazaarTradingAccount__NotOwner (an Unknown(...) selector): the sender is not the account’s owner (Smart Accounts & Batching).

A Complete Handler

/** Errors the SDK's ABIs do not declare, by the name decodeError gives them. */
const EXTRA_MESSAGES: Record<string, string> = {
  // Pyth's StalePrice(): the price update aged past the pair's 3 s window before the call landed.
  "Unknown(0x19abf40e)": "The price moved on before your transaction landed. Try again.",
};

/** What to show for any error a Bazaar call can throw. */
function explain(e: unknown): string {
  // A decoded revert the SDK has no sentence for (e.g. Pyth's own errors).
  const decoded = e instanceof DecodedError ? e : e instanceof CallError ? e.decoded : null;
  if (decoded && EXTRA_MESSAGES[decoded.name]) return EXTRA_MESSAGES[decoded.name]!;
  // The builders' input errors: the sentence without its "field: " prefix.
  if (e instanceof InputError) return e.message.slice(e.field.length + 2);
  // Signing (declined, clock skew, a smart-account signature) and connection errors carry their own
  // sentence, which errorMessage does not pass through.
  if (e instanceof signing.SigningError || e instanceof ConnectError) {
    return walletErrorMessage(e) ?? e.message;
  }
  // A receipt that reverted carries no reason: say so plainly.
  if (e instanceof CallError && !e.decoded && /reverted$/.test(e.message)) {
    return "The transaction was reverted on chain. Nothing changed; check the market and try again.";
  }
  return errorMessage(e);
}

explain(decodeError("0x19abf40e")); // "The price moved on before your transaction landed. Try again."

Showing Errors in a UI

  • Simulate before you ask for a signature, and show sim.error.message instead of opening the wallet. A user who never signs a doomed transaction never pays for one.
  • Put input errors next to their field, using InputError.field, and keep the submit button disabled while there is one.
  • Gate buttons on the market’s flags (marketOrdersDisabled, positionWithdrawalsFrozen, …, with reasons) rather than waiting for a revert (Markets).
  • Show the sentence, log the detail. Show explain(e); log String(decoded) or e.cause for support.
  • Treat a rejection as a choice, not an error. “You rejected the request in your wallet.” needs no red banner.
  • Offer a retry where a retry helps: a stale price, an expired gasless signature (MetaTx__ExpiredDeadline), a nonce that moved (MetaTx__InvalidNonce). Rebuild or re-sign; do not resend the same call.
  • Render on-chain strings as text, never as HTML (Security Notes).

Gasless Trading & Relayers

Eight actions on a pair can be signed instead of sent: the user signs an EIP-712 request in their wallet, and a relayer submits it from its own account. The relayer pays the gas and the Pyth fee in ETH; the pair credits it the relayerFee the user signed, in USDC, on its claimable balance. With a USDC permit riding along, a user needs no ETH and no approve transaction at all.

RelayableNot relayable
depositCollateral, withdrawCollateral, createOrder, cancelOrders, amendOrders, depositToInsurance, requestInsuranceWithdrawal, executeInsuranceWithdrawalclaimFees, executeTriggeredOrders, and everything outside the pair (approvals, termination, listings).

Gasless calls also solve the 3-second price window for browser users: the price update is not part of what the user signs, so the relayer attaches a fresh one when it submits, however long the user took to approve.

This page covers both sides: signing on the frontend, and a complete relayer server. The protocol’s rules are in Gasless Transactions. The samples assume bz connected as in Connecting, user the wallet’s address and eth a market.

How It Works

  1. The frontend calls bz.relay.signRelayed(input, opts). It checks the action against the chain (as the direct builders do), signs a USDC permit first if asked, then has the wallet sign the request, and returns a RelayedRequest.
  2. It posts relay.serializeRelayed(req) (JSON) to the relayer, at once: the request is valid for at most 10 seconds.
  3. The relayer parses it (relay.parseRelayed), checks it (bz.relay.verifyRelayed), builds the call with a fresh price update and fresh book hints (bz.relay.encodeRelayed), and sends it from its own key.
  4. The pair recovers the signer from the signature, acts as them, pulls the fee from their wallet (or nets it from a payout) and credits it to whoever submitted the transaction.
  5. The relayer collects its fees per pair with claimFees.

Signing a Request

/** POST a signed request to the relayer at once: it is valid for at most 10 seconds. */
async function submit(req: relay.RelayedRequest): Promise<Hex> {
  const res = await fetch(RELAYER_URL, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: relay.serializeRelayed(req),
  });
  const body = (await res.json()) as { hash?: Hex; error?: string };
  if (!res.ok || !body.hash) throw new Error(body.error ?? `the relayer answered ${res.status}`);
  return body.hash;
}
try {
  const block = await bz.ctx.chain.blockNumber();
  const req = await bz.relay.signRelayed(
    {
      action: "createOrder",
      order: {
        orderType: OrderType.Limit,
        isLong: true,
        size: parseUnits18("0.5"),
        limitPrice: parseUnits18("2950"),
        expirationBlock: block + orders.blocksIn(24 * 60 * 60),
      },
    },
    {
      wallet: window.ethereum,
      signer: user,
      pair: eth.address,
      relayerFee: parseUnits18("0.05"), // what this relayer charges, in USDC, at most $1
      permit: true, // sign a USDC permit for the fee too, unless the allowance already covers it
    },
  );
  const hash = await submit(req);
  await bz.waitForReceipt(hash);
} catch (e) {
  // A declined signature or a clock problem is a SigningError, whose message is the sentence to show.
  show(e instanceof signing.SigningError ? e.message : errorMessage(e));
}

bz.relay.signRelayed(input, opts) takes what to do as input, one of:

inputSigns
{ action: "createOrder", order }An OrderInput (Placing Orders), checked against the chain as prepareOrder does, with the connection’s integrator.
{ action: "amendOrders", amendments }AmendInput[] (Managing Orders), checked and given hints as prepareAmends does.
{ action: "depositCollateral", amount }, { action: "withdrawCollateral", amount }, { action: "depositToInsurance", amount }An amount in BAZAAR precision. Deposits are floored to micro-USDC first.
{ action: "requestInsuranceWithdrawal", shareAmount }, { action: "executeInsuranceWithdrawal" }See Insurance.
{ action: "cancelOrders", orderIds }1 to 200 distinct order ids.
{ action: "createOrder", spec }, { action: "amendOrders", amends }An already built OrderSpec, or the flat amendment words, signed as given.
OptionDefaultMeaning
walletrequiredThe user’s EIP-1193 provider.
signerrequiredThe user’s address. The wallet must sign as it.
pairrequiredThe market’s pair. It is the domain the user signs under and the permit’s spender, so it must be one of the deployment’s pairs (InputError on pair otherwise, before anything is asked).
relayerFee0nWhat the relayer charges, BAZAAR precision, at most $1. Floored to micro-USDC before signing, so the signed fee is exactly what is charged.
deadlineWindow10Seconds the signature stays valid, at most the deployment’s window (10).
noncereadThe nonce to sign. Read from the pair when omitted; pass it to sign several requests in a row.
permitnonetrue, or { value?, deadline?, always? }: also sign a USDC permit for what the call takes from the wallet. See Permits on the Gasless Path.

What it does, in order:

  1. Floors the fee, reads the deployment’s fee cap and deadline window from the lens (eip712Limits, once per connection), and confirms the pair.
  2. Resolves and checks the action: an order or amendments against the chain, a deposit’s floor, a withdrawal on a terminated market against what it pays (checkSignedFields, below).
  3. Signs the permit, when asked, as its own wallet prompt. This happens before the request’s deadline is stamped, so the time the user spends on the permit prompt does not count against the 10 seconds.
  4. Reads the nonce and stamps the deadline (chainDeadline).
  5. Asks the wallet to sign the typed data with eth_signTypedData_v4, normalizes the signature and recovers it locally: a signature that does not recover to signer is refused (SigningError).

It returns a RelayedRequest: the signed fields (action and the action’s own arguments), pair, chainId, signer, nonce, deadline (unix seconds), relayerFee (floored), signature and permitData ("0x" without a permit).

const cancel = await bz.relay.signRelayed({ action: "cancelOrders", orderIds: [42n] }, { wallet: window.ethereum, signer: user, pair: eth.address });

const amend = await bz.relay.signRelayed(
  { action: "amendOrders", amendments: [{ orderId: 42n, limitPrice: parseUnits18("2960"), size: parseUnits18("0.5") }] },
  { wallet: window.ethereum, signer: user, pair: eth.address, relayerFee: parseUnits18("0.02"), permit: true },
);

const withdraw = await bz.relay.signRelayed(
  { action: "withdrawCollateral", amount: parseUnits18("50") }, // the fee comes out of the payout: no permit
  { wallet: window.ethereum, signer: user, pair: eth.address, relayerFee: parseUnits18("0.05") },
);

The Deadline Window

The pair accepts a signature only before its deadline and only if the deadline is at most 10 seconds past the landing block’s timestamp (MAX_DEADLINE_WINDOW), so a signed request cannot be stored and used later. That makes the timing tight:

  • The deadline is counted from a checked clock: the lower of the latest block’s timestamp and the device’s clock. If the two differ by more than 2 minutes (MAX_CLOCK_SKEW), signRelayed throws ClockSkewError and nothing is signed: an RPC reporting a block far in the future must not stretch a deadline. A device clock so far behind the chain that the deadline would already have passed by chain time is refused the same way. Local development chains (31337, 1337) use their own time unchecked.
  • The user has the window to approve the prompt. The deadline is stamped just before the wallet is asked. If the user takes longer than the window, the request arrives expired: the relayer’s check reports it (deadline: expired 4 s ago) and the pair would refuse it (MetaTx__ExpiredDeadline). Ask the user to sign again; do not retry the same request.
  • A device clock behind the chain shortens the window: with the clock 4 seconds behind, a 10-second window leaves 6 seconds by chain time.
  • Post at once, and the relayer must verify, encode and send at once.

bz.signing.chainDeadline(windowSeconds?) returns such a deadline for your own signing code, and signing.checkClockSkew(chainNow, localNow?) the check alone.

Nonces

Each signer has one nonce per pair (metaTxNonces), shared by every action, strictly sequential: a request lands only at the signer’s current nonce, and each landed request uses one.

  • signRelayed reads it (bz.signing.readMetaTxNonce(pair, user)) unless you pass nonce.
  • To sign several requests before any lands, pass consecutive nonces and submit them in nonce order; the relayer must land each before the next is valid.
  • A request whose nonce was used (another request landed first) fails with MetaTx__InvalidNonce; sign it again.
// Two requests in a row: sign both against consecutive nonces, submit in nonce order.
const next = await bz.signing.readMetaTxNonce(eth.address, user);
const first = await bz.relay.signRelayed({ action: "cancelOrders", orderIds: [7n] }, { wallet: window.ethereum, signer: user, pair: eth.address, nonce: next });
const second = await bz.relay.signRelayed({ action: "cancelOrders", orderIds: [8n] }, { wallet: window.ethereum, signer: user, pair: eth.address, nonce: next + 1n });
await submit(first);
await submit(second);

Fees

The fee is the relayer’s price, agreed off chain: the frontend signs whatever the relayer charges, and the relayer refuses requests below its price. The pair caps it at $1 per action (MAX_RELAYER_FEE, checked against the lens’s maxRelayerFee) and charges it at its USDC floor (signing.floorRelayerFee).

Where it comes from depends on the action:

ActionThe signer’s wallet pays (relay.relayedPull)
depositCollateral, depositToInsuranceThe deposit plus the fee.
createOrderThe fee plus the trigger order’s keeper bounty.
cancelOrders, amendOrders, requestInsuranceWithdrawalThe fee.
executeInsuranceWithdrawalThe fee on the first, locking call; nothing on the paying call, whose fee comes out of the payout.
withdrawCollateralNothing: the fee comes out of the payout. On a terminated market whose payout ratio leaves 0, the fee comes from the wallet.

checkSignedFields(fields, relayerFee, terminalPayout?) enforces the bounds that need no read, and signRelayed and verifyRelayed both run it: deposits of at least 5 USDC that exceed the fee, a withdrawal larger than the fee (or, on a terminated market, a payout larger than the fee), a non-zero share amount, 1 to 200 distinct cancel ids, 1 to 32 well-formed amendments, and an order spec the order rules accept. A withdrawal on a terminated market that would pay nothing is refused.

Permits on the Gasless Path

permit: true signs a USDC permit for exactly what the call takes from the wallet (relayedPull, rounded up to USDC units), so a user who has never approved anything can deposit, trade, amend and cancel with signatures alone:

// A first deposit with no ETH and no approval: the permit covers the deposit plus the fee.
const deposit = await bz.relay.signRelayed(
  { action: "depositCollateral", amount: parseUnits18("100") },
  { wallet: window.ethereum, signer: user, pair: eth.address, relayerFee: parseUnits18("0.05"), permit: true },
);
await submit(deposit);
permitMeaning
trueSign a permit for the pull, unless the pair’s current allowance already covers it.
{ value }The permit’s value in USDC units, instead of the pull.
{ deadline }The permit’s expiry, unix seconds (default 30 minutes).
{ always: true }Sign one even when the allowance covers the pull.

The allowance check matters because a permit sets the allowance to its value: one signed anyway would replace a larger approval with this call’s amount. A withdrawal takes no permit (InputError on permit). See USDC Permits for the rest of the rules, including signing one permit at a time.

Smart-Contract Wallets Cannot Sign

The pair verifies with plain ECDSA (ECDSA.recover) and acts for whatever address the signature recovers to. A smart-contract wallet (a Safe, an ERC-4337 account) signs through ERC-1271, whose signature recovers to some other address or to none. signRelayed catches this: it recovers the signature locally and throws SigningError (“this wallet does not sign as a plain account. Gasless calls need an account’s own signature; send the transaction directly instead”). Smart accounts send their calls directly, and can batch them: see Smart Accounts & Batching.

The Wire Format

relay.serializeRelayed(req) is JSON.stringify with every bigint as a decimal string:

{
  "action": "cancelOrders",
  "orderIds": ["42"],
  "pair": "0x5FbDB2315678afecb367f032d93F642f64180aa3",
  "chainId": 42161,
  "signer": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
  "nonce": "3",
  "deadline": 1762612345,
  "relayerFee": "50000000000000000",
  "signature": "0x1b2c...",
  "permitData": "0x"
}

A createOrder request carries spec (the OrderSpec, with its numbers as strings) instead of orderIds; deposits and withdrawals carry amount, amendOrders amends, and requestInsuranceWithdrawal shareAmount.

relay.parseRelayed(body) takes the JSON string (or an already parsed object) and returns a RelayedRequest, checking every field’s type and shape (InputError naming the field otherwise: "nonce: expected a non-negative integer"). It canonicalizes the signature into the one form the pair accepts (65 bytes, v 27 or 28): a v of 0 or 1 and a 64-byte EIP-2098 signature are converted, and a high-s signature is refused. It does not verify the signature; that is verifyRelayed.

Running a Relayer

A relayer is a small service with a funded key: ETH for gas and Pyth fees on the deployment’s chain. It earns USDC per request. The complete server below uses Node’s own node:http; any HTTP framework works the same way.

Setup

const RPC_URL = process.env.RPC_URL ?? "http://127.0.0.1:8545";
const FACTORY = (process.env.BAZAAR_FACTORY ?? "0x0000000000000000000000000000000000000000") as Address;
const PORT = Number(process.env.PORT ?? 8787);
const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN ?? "https://app.example.com";

/** The least this relayer accepts per request, and the most Pyth fee it will pay for one. */
const MIN_FEE = parseUnits18("0.02");
const MAX_PYTH_FEE = parseEther("0.0005");

const account = privateKeyToAccount(process.env.RELAYER_PRIVATE_KEY as Hex, { nonceManager });
const wallet = createWalletClient({ account, chain: arbitrum, transport: http(RPC_URL) });

const bz = await Bazaar.connect({
  transport: RPC_URL,
  factory: FACTORY,
  chainId: arbitrum.id,
  prices: { token: process.env.PYTH_PRO_API_KEY }, // the relayer attaches fresh prices; a server may hold the key
});

// Refuse to start if what users sign is not what this deployment verifies.
const typehashes = await bz.signing.verifyTypehashes();
if (!typehashes.ok) throw new Error(`EIP-712 mismatch: ${typehashes.mismatches.join("; ")}`);

bz.signing.verifyTypehashes(pair?) compares every type string the SDK signs with the lens’s getTypehashes(), the domain’s name and version hashes and the fee and deadline caps with getEip712Constants(), and, given a pair, the domain separator with the pair’s DOMAIN_SEPARATOR(). It returns { ok, mismatches, typehashes, constants, domain? }. Run it once at startup.

Verifying

interface Answer {
  status: number;
  body: { hash?: Hex; error?: string; problems?: readonly { field: string; message: string }[] };
}

async function relayOne(body: string): Promise<Answer> {
  let req: relay.RelayedRequest;
  try {
    req = relay.parseRelayed(body); // shape and types; the signature canonicalized
  } catch (e) {
    return { status: 400, body: { error: e instanceof InputError ? e.message : "expected a JSON request" } };
  }
  if (req.relayerFee < MIN_FEE) {
    return { status: 402, body: { error: `this relayer charges at least ${formatUnits18(MIN_FEE)} USDC per request` } };
  }

  const check = await bz.relay.verifyRelayed(req); // signer, chain, deadline, nonce, fee cap, bounds, permit
  if (!check.ok) return { status: 422, body: { error: "the request would revert", problems: check.problems } };
  for (const w of check.warnings) console.warn(`${req.signer}: ${w}`);
  if (check.secondsLeft < 2) return { status: 422, body: { error: "the signature expires too soon: sign again" } };

  // Built now: a fresh price update and fresh book hints, outside the signature.
  const call = await bz.relay.encodeRelayed(req, { relayer: account.address });
  if (call.value > MAX_PYTH_FEE) return { status: 503, body: { error: "the price update fee is unusually high; try again" } };

  const sim = await bz.simulate(call);
  if (!sim.ok) return { status: 422, body: { error: sim.error!.message } };

  try {
    const hash = await wallet.sendTransaction({ to: call.to, data: call.data, value: call.value });
    console.log(`${call.summary}: ${hash}`);
    return { status: 202, body: { hash } };
  } catch (e) {
    return { status: 502, body: { error: errorMessage(e) } };
  }
}

bz.relay.verifyRelayed(req) checks everything that would make the call revert, and returns a RelayCheck instead of throwing:

FieldMeaning
okSafe to submit now: problems is empty.
signerThe address the signature recovers to, or null.
problems{ field, message } for each thing that would revert: the pair is not the deployment’s (nothing else is read then), the chain id, a signature that does not recover to signer over exactly these fields or is not in canonical form, a deadline that has passed or is more than 10 seconds ahead of chain time, a nonce that is not the signer’s current one (used, or ahead: “an earlier request has to land first”), a fee above the cap, and the action’s own bounds.
warningsWhat would not revert by itself: a permit that would fail (wrong signer, used nonce, expired, too small, bad v or s), after which the call needs an allowance already in place; a terminated-market withdrawal that pays nothing, whose fee then comes from the wallet.
secondsLeftSeconds until the deadline by chain time (negative once passed).

permits.checkPermit(ctx, permitData, { owner, spender, needUsdc }) is the permit part on its own, returning { ok, problems, value }.

Encoding and Sending

bz.relay.encodeRelayed(req, opts?) builds the PreparedCall the relayer sends: the signed fields, the signature and permit, plus what sits outside the signature, built now:

  • a fresh price update for the actions that take one (withdrawCollateral, depositToInsurance, executeInsuranceWithdrawal, createOrder, amendOrders), with value its Pyth fee, which the relayer pays. createOrder and amendOrders always carry one, so the relayer’s connection needs a price source;
  • fresh MatchHints for createOrder (or opts.hints). A relayed order always walks the protocol’s default budget. Amendment hints are inside the signed words and cannot change.
OptionMeaning
relayerThe relayer’s address, used as the call’s from (so bz.simulate(call) runs as the relayer). Without it from is null.
hintsMatchHints for a createOrder, instead of the ones the SDK finds.

The call’s pullsUsdc is what it takes from the signer’s wallet, and its summary reads Relay for 0x...: Buy 0.5 at 2950 (limit, GTC) (fee 0.05 USDC). It throws InputError for a request signed for another chain or a signature not in canonical form (parseRelayed produces the canonical form).

Send it at once, from the relayer’s own key, as on Sending From a Bot. Simulate first: the relayer pays the gas of a call that reverts.

Ordering

// A signer's requests on one pair land only in nonce order, so handle them one at a time, each after
// the previous one has been mined. Different signers run in parallel.
const queues = new Map<string, Promise<unknown>>();

function inOrder<T>(key: string, job: () => Promise<T>): Promise<T> {
  const prev = queues.get(key) ?? Promise.resolve();
  const run = prev.then(job, job);
  const tail = run.catch(() => undefined);
  queues.set(key, tail);
  void tail.then(() => {
    if (queues.get(key) === tail) queues.delete(key);
  });
  return run;
}

async function relayInOrder(body: string): Promise<Answer> {
  let key = "malformed";
  try {
    const o = JSON.parse(body) as { pair?: unknown; signer?: unknown };
    key = `${String(o.pair).toLowerCase()}:${String(o.signer).toLowerCase()}`;
  } catch {
    // relayOne answers 400
  }
  return inOrder(key, async () => {
    const answer = await relayOne(body);
    if (answer.body.hash) await bz.waitForReceipt(answer.body.hash, { timeoutMs: 30_000 }).catch(() => undefined);
    return answer;
  });
}

Requests from one signer on one pair can only land in nonce order, so the server handles them one at a time and waits for each to be mined before the next. Requests from different signers do not wait for each other. The relayer’s own transaction nonces are handled by viem’s nonceManager.

The Server

const MAX_BODY = 16 * 1024;
const perIp = new Map<string, { count: number; since: number }>();

function rateLimited(ip: string): boolean {
  const now = Date.now();
  const e = perIp.get(ip);
  if (!e || now - e.since > 60_000) {
    perIp.set(ip, { count: 1, since: now });
    return false;
  }
  return ++e.count > 30; // 30 requests a minute per address
}

function readBody(req: IncomingMessage): Promise<string> {
  return new Promise((resolve, reject) => {
    let size = 0;
    const chunks: Buffer[] = [];
    req.on("data", (c: Buffer) => {
      size += c.length;
      if (size > MAX_BODY) {
        reject(new Error("body too large"));
        req.destroy();
      } else chunks.push(c);
    });
    req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
    req.on("error", reject);
  });
}

function send(res: ServerResponse, status: number, body: unknown): void {
  res.writeHead(status, {
    "content-type": "application/json",
    "access-control-allow-origin": ALLOWED_ORIGIN,
    "access-control-allow-headers": "content-type",
  });
  res.end(status === 204 ? undefined : JSON.stringify(body));
}

createServer(async (req, res) => {
  if (req.method === "OPTIONS") return send(res, 204, {});
  if (req.method !== "POST" || req.url !== "/relay") return send(res, 404, { error: "POST /relay" });
  if (rateLimited(req.socket.remoteAddress ?? "?")) return send(res, 429, { error: "slow down" });
  try {
    const answer = await relayInOrder(await readBody(req));
    send(res, answer.status, answer.body);
  } catch (e) {
    console.error(e);
    send(res, 500, { error: "the relayer failed; try again" });
  }
}).listen(PORT, () => console.log(`relaying as ${account.address} on :${PORT}`));

Limit body size and request rate: a request costs the relayer chain reads before it can be refused. Set access-control-allow-origin to your frontend’s origin.

Claiming the Fees

// Every hour, collect the fees earned on each market (claimFees refuses when nothing is owed).
async function claimAll(): Promise<void> {
  for (const m of await bz.markets.listMarkets({ refresh: true })) {
    try {
      const call = await bz.trade.claimFees(m.address, account.address);
      const sim = await bz.simulate(call);
      if (!sim.ok) continue;
      const hash = await wallet.sendTransaction({ to: call.to, data: call.data, value: call.value });
      await bz.waitForReceipt(hash);
      console.log(`${m.label}: ${call.summary}`);
    } catch (e) {
      if (!(e instanceof InputError)) console.error(`${m.label}: ${errorMessage(e)}`);
    }
  }
}
setInterval(() => void claimAll(), 60 * 60 * 1000);

Fees accrue to the relayer’s claimable balance on each pair, alongside any integrator fees if the relayer’s address is also the frontend’s integrator. claimFees pays it all out and has no gasless form (Managing Orders).

Lower-Level Pieces

The signing namespace has everything signRelayed is built from, for a relayer in another language or a custom signer:

ExportWhat it is
TYPE_STRINGS, BAZAAR_TYPESThe eight primary types, as strings and as viem / eth_signTypedData_v4 field lists.
pairDomain(chainId, pair), domainSeparator(chainId, pair)The pair’s EIP-712 domain ("BazaarPair", version "1") and its separator.
typedDataFor(chainId, pair, fields, meta)The typed data a user signs. typedDataJson(td) is its JSON for eth_signTypedData_v4; relayedDigest(td) its digest.
typeHash(primaryType)keccak256 of a type string.
signTypedDataV4(wallet, signer, td)Sign through a wallet, normalized and checked to recover to signer.
normalizeSignature(sig)A signature in the 65-byte, v 27/28, low-s form the pair accepts.
encodePairWrite(fields, extra, meta)The calldata of any of the eight actions, direct or relayed.
RELAYED_ACTIONS, PRIMARY_TYPE, takesPriceUpdate(action)The relayable actions, their primary types, and which take a price update.
MAX_RELAYER_FEE, MAX_DEADLINE_WINDOW, MAX_CLOCK_SKEW, DEV_CHAIN_IDS$1, 10 s, 120 s, [31337, 1337].

In the relay namespace: describeRelayed(fields) (one line for a request), relayedPull and checkSignedFields (above).

What Can Go Wrong

You seeIt means
SigningError: “You declined the signature.”The user rejected the prompt.
SigningError: “… does not sign as a plain account …”A smart-contract wallet. Send directly instead.
ClockSkewErrorThe device’s clock and the RPC’s chain time disagree by more than 2 minutes.
InputError on relayerFee: “A relayer fee is at most $1 per action.”The fee cap.
InputError on relayerFee: “The relayer fee must be less than the deposit.”A deposit must exceed its fee.
MetaTx__ExpiredDeadline / deadline: expired ...The request took longer than its window. Sign again.
MetaTx__DeadlineTooFarA deadline more than 10 seconds ahead of the block.
MetaTx__InvalidNonce / nonce: ... is already usedAnother request from this signer landed first. Sign again.
nonce: ... is ahead of the signer's next nonceAn earlier request has to land first.
MetaTx__InvalidSignatureThe signature does not match the request (a field changed after signing).
MetaTx__RelayerFeeExceedsMaxThe fee is above the deployment’s cap.
BazaarPair__AmountLteRelayerFeeA withdrawal’s payout does not cover the fee.
permit: ... warningThe permit will fail; the call then needs an allowance in place.

Smart Accounts & Batching

Bazaar has no batch form of createOrder: each order is one call. (Canceling and amending are batched already: cancelOrders takes up to 200 ids and amendOrders up to 32 amendments.) To place several orders at once, or to approve, deposit and trade in one step, trade from a contract account that calls the pair several times in one transaction. That gets most of what a batch entry point would: one transaction’s overhead, warm storage for the later calls, and one price update for all of them.

The SDK supports two ways to submit such a batch:

  • a wallet that batches natively through EIP-5792 wallet_sendCalls (many smart wallets, ERC-4337 accounts and Safes speak it), with batching.sendCalls;
  • the example BazaarTradingAccount contract from Bazaar’s repository (examples/BazaarTradingAccount.sol), with batching.tradingAccountExecute.

Read Batching Orders in Bazaar’s docs for the pattern and its costs. The samples assume bz connected as in Connecting and eth a market.

The Account Is the Trader

The pair sees ordinary calls from the account’s address, so the account, not its owner, holds everything: collateral, positions, resting orders and claimable fees. That means:

  • Build every call with from = the account. With a smart wallet, the address the wallet connects as is the account.
  • Approve USDC to the pair from the account (approveUsdc(account, pair, ...)), in the same batch or before. A deposit or a bounty pulls from the account’s USDC.
  • Claim through the account, and move withdrawn USDC on to the owner inside the batch: withdrawCollateral and claimFees pay the caller.
  • The account is one trader: one net position per market, 32 active Limit + StopLimit orders, one TakeProfit and one StopLoss, shared by everything it does.
  • Its own orders never fill each other. A later order that crosses an earlier one is a self-match and is canceled. Quote both sides at prices that do not cross.
  • An auto-cancel does not stop the batch (margin, self-match, post-only): the call returns normally and the batch goes on. A fill-or-kill that cannot fill, a bad hint, or a margin failure at creation reverts that call, and with it the whole batch.
  • Gas per order is unchanged. A crossing order can walk 32 makers at roughly 90k gas each, so keep batches of taking orders short: about nine full walks reach Arbitrum’s 32M gas per-transaction limit.
  • Hints are computed per order, against the live book, when each call is built.
  • No gasless path. The relayed path checks a plain ECDSA signature, which a smart account cannot produce; it sends its calls directly (Gasless Trading & Relayers).

Building the Batch

// With a smart wallet, the connected address IS the account that trades: build every call from it.
const [account] = (await window.ethereum.request({ method: "eth_requestAccounts" })) as Address[];
const expiry = (await bz.ctx.chain.blockNumber()) + blocksIn(24 * 60 * 60);
const quote = (isLong: boolean, price: string) =>
  bz.trade.createOrder(eth.address, account, {
    orderType: OrderType.Limit,
    isLong,
    size: parseUnits18("1"),
    limitPrice: parseUnits18(price),
    isPostOnly: true,
    expirationBlock: expiry,
  });

const calls: PreparedCall[] = [
  await bz.allowance.approveUsdc(account, eth.address, toUsdcUnits(parseUnits18("2000"))),
  await bz.trade.depositCollateral(eth.address, account, parseUnits18("2000")),
  await quote(true, "2980"), // the first order carries the Pyth update...
  await quote(true, "2970"),
  await quote(false, "3020"), // ...the later ones are re-encoded without it
];
const batch = batching.shareOnePriceUpdate(calls);

batching.toBatchCalls(calls) turns prepared calls into the generic { to, data, value } shape smart wallets take, in order; batching.batchValue(calls) sums their ETH.

shareOnePriceUpdate

Every price-carrying builder attaches its own Pyth update and pays its own fee. In one transaction only the first is needed: the pair reads Pyth’s on-chain price first and parses an update only when that price is more than 3 seconds old, so once one call has stored a fresh price, the later calls find it.

batching.shareOnePriceUpdate(calls) keeps the update and fee on the first call to each pair that always stores a price (createOrder, amendOrders, executeTriggeredOrders, refreshPrice), and re-encodes the later calls to the same pair with an empty priceUpdate, value 0 and no priceTime. Calls to other contracts (an approval, say) and calls with no price update are left as they are. The result must run in one transaction, in this order.

EIP-5792 Wallets

try {
  const id = await batching.sendCalls(window.ethereum, bz.ctx, account, batch); // atomic by default
  for (;;) {
    const status = (await batching.getCallsStatus(window.ethereum, id)) as { status?: number; receipts?: { transactionHash: Hex }[] };
    if (status.status === 200) {
      show(`batch landed: ${status.receipts?.[0]?.transactionHash}`);
      break;
    }
    if (status.status !== undefined && status.status >= 400) {
      show("the batch failed; nothing in it was applied");
      break;
    }
    await new Promise((r) => setTimeout(r, 1000));
  }
} catch (e) {
  show(errorMessage(e)); // a CallError: the wallet refused, or does not support wallet_sendCalls
}

batching.sendCalls(wallet, ctx, from, calls, opts?) sends wallet_sendCalls and returns the wallet’s batch id. Track it with batching.getCallsStatus(wallet, id), which returns the wallet’s wallet_getCallsStatus answer as it gave it (in EIP-5792 2.0, status 100 is pending, 200 confirmed, 400 and above failed). Every failure, including a wallet that does not support the method, is thrown as a CallError. Build price-carrying calls right before sending.

bz.batching.walletSendCallsRequest(from, calls, opts?) returns the request itself, for a library that sends it (wagmi’s sendCalls, say):

const request = bz.batching.walletSendCallsRequest(account, batch, {
  atomicRequired: true, // the default
  capabilities: { paymasterService: { url: "https://paymaster.example.com" } }, // passed through as given
});
// { method: "wallet_sendCalls", params: [{ version: "2.0.0", chainId: "0xa4b1", from, atomicRequired, calls, capabilities }] }
OptionDefaultMeaning
atomicRequiredtrueRequire the wallet to run the calls atomically: a Bazaar batch is meant as one.
capabilitiesnoneWallet capabilities (a paymaster service and the like), passed through as given.
version"2.0.0"The EIP-5792 request version.

A batch from a smart wallet cannot be simulated with bz.simulate, which runs one call. Simulate each call that does not depend on an earlier one in the batch, or rely on the wallet’s own simulation.

BazaarTradingAccount

The example account is the smallest contract that does the job: an owner, and one function, execute(Call[] calls), which runs the calls in order as the account and returns each call’s return data. The first call that fails reverts the whole batch with that call’s own revert data, so a failure decodes like a direct call’s. It is yours to deploy, one per trader; it is not part of the protocol and has not been audited with it. Only its owner may call execute.

// The example BazaarTradingAccount: the owner's EOA sends one execute() carrying every call.
const tradingAccount: Address = "0x0000000000000000000000000000000000000002";
const [owner] = (await window.ethereum.request({ method: "eth_accounts" })) as Address[];
if (!isAddressEqual(await bz.batching.readTradingAccountOwner(tradingAccount), owner)) throw new Error("only the account's owner may call execute");

const inner = batching.shareOnePriceUpdate([
  await bz.allowance.approveUsdc(tradingAccount, eth.address, toUsdcUnits(parseUnits18("2000"))),
  await bz.trade.depositCollateral(eth.address, tradingAccount, parseUnits18("2000")),
  await bz.trade.createOrder(eth.address, tradingAccount, { orderType: OrderType.Market, isLong: true, size: parseUnits18("0.5"), maxSlippageBp: 50n }),
]);
const exec = batching.tradingAccountExecute(tradingAccount, owner, inner);
exec.value; // the inner calls' Pyth fees, summed
exec.pullsUsdc; // from the trading account's USDC, not the owner's

const sim = await bz.simulate(exec);
if (!sim.ok) {
  show(sim.error!.message); // the first failing inner call's own revert, decoded
} else {
  const results = batching.decodeTradingAccountResults(sim.returnData); // one return value per inner call
  show(`${results.length} calls would succeed`);
  await bz.waitForReceipt(await bz.send(window.ethereum, exec));
}

batching.tradingAccountExecute(account, owner, calls) returns one PreparedCall to the account:

FieldValue
action"execute"
toThe account.
fromThe owner, who sends it.
valueThe sum of the inner calls’ values (their Pyth fees). The account forwards each call’s own; the pair refunds any unspent fee to the account.
summaryThe inner call’s summary for one call, else "3 calls: ...; ...; ...".
priceTimeThe earliest of the inner calls’ price times.
pullsUsdcThe sum of what the inner calls pull, which comes from the account’s USDC and its allowance to each pair, not the owner’s wallet.

Simulate it as the owner (bz.simulate(exec) does, since from is the owner) and batching.decodeTradingAccountResults(sim.returnData) gives one Hex return value per inner call. bz.batching.readTradingAccountOwner(account) reads the owner. The lower-level pieces are batching.TRADING_ACCOUNT_ABI and batching.encodeTradingAccountExecute(batchCalls).

Getting Money Out

withdrawCollateral pays the account. To reach the owner, add a USDC transfer to the same batch. The SDK has no builder for a plain transfer, so encode it with viem:

// withdrawCollateral pays the account, so move the USDC on to the owner in the same batch. The SDK has
// no transfer builder; encode USDC's transfer with viem.
const amount = parseUnits18("500");
const transfer: PreparedCall = {
  action: "transfer",
  to: bz.ctx.usdc,
  data: encodeFunctionData({ abi: erc20Abi, functionName: "transfer", args: [owner, toUsdcUnits(amount)] }),
  value: 0n,
  from: tradingAccount,
  summary: `Send ${toUsdcUnits(amount) / 1_000_000n} USDC to ${owner}`,
};
const out = batching.tradingAccountExecute(tradingAccount, owner, [await bz.trade.withdrawCollateral(eth.address, tradingAccount, amount), transfer]);
if ((await bz.simulate(out)).ok) await bz.send(window.ethereum, out);

What Can Go Wrong

You seeIt means
CallError from sendCallsThe wallet refused, or does not support wallet_sendCalls (fall back to sending the calls one by one).
InputError on calls: “Nothing to send.”An empty batch.
Unknown(0x...) from executeMost often BazaarTradingAccount__NotOwner: the sender is not the account’s owner. The SDK’s decoder does not know the example contract’s errors.
Error: “ERC20: transfer amount exceeds allowance”The account (not the owner) lacks USDC or an allowance to the pair.
OrderManagementLib__InsufficientMarginAfterOrderThe account’s collateral, after the batch’s earlier deposits, does not cover the order.
MatchingEngineLib__FillOrKillUnfilledA fill-or-kill order in the batch could not fill, and the whole batch reverted.

History

bz.history reads Bazaar’s subgraph: the trade tape and candles, an account’s orders, positions and cash movements, fees and keeper rewards, liquidations and ADL, insurance, terminations, governance and listings. Use it for everything a UI shows about the past: a trade list, a chart, an order history with the reason each order was canceled, a portfolio’s realized PnL, an integrator’s revenue.

Use the chain, not the subgraph, for anything you act on. The subgraph lags the chain by a few blocks (more when its indexer is behind), and it does not index values the contracts do not emit: the mark price, the insurance share price, the vault’s inventory and an account’s health at the live price come from Markets and Accounts & Portfolios. For live events as they happen, see Live Updates.

Connecting a Subgraph

History needs a subgraph source on the connection. Pass a URL, { url, apiKey }, or any object with a query(query, variables) method:

const bz = await Bazaar.connect({
  transport: window.ethereum,
  factory: FACTORY,
  prices: { token: pythToken },
  // A URL, { url, apiKey }, or any object with query(query, variables).
  subgraph: "https://subgraph.example.com/subgraphs/name/bazaar-arbitrum-one",
});

Every function below is on bz.history with the connection bound, and on the history namespace with the context as its first argument (history.trades(bz.ctx, filter)). Calling one on a connection made without a subgraph throws ConnectError (“history comes from Bazaar’s subgraph: connect with a subgraph source”).

With { url, apiKey } the key is sent as Authorization: Bearer <key>. A key in a browser bundle is visible to every user: in a frontend, point subgraph at a proxy that adds the key on your server (see Security Notes).

httpSubgraph

The URL forms build their source with history.httpSubgraph(url, apiKey?, options?). Call it yourself to set its options, then pass the result as subgraph:

// A source of your own: a proxy that adds the key server-side, a longer timeout, extra headers.
const sg = history.httpSubgraph("https://your-app.example.com/api/subgraph", null, {
  timeoutMs: 10_000,
  headers: { "x-client": "my-frontend" },
});
const bz2 = await Bazaar.connect({ transport: window.ethereum, factory: FACTORY, subgraph: sg });
// The same functions without the facade: history.trades(ctx, filter).
const page = await history.trades(bz2.ctx, { pair: eth.address, first: 10 });
console.log(page.items.length);
OptionDefaultMeaning
timeoutMs30000Per-request timeout in milliseconds.
headersnoneExtra request headers.
fetchglobalThis.fetchThe fetch to use (a test double, or one with a custom agent).

Each query is a JSON POST of {query, variables}. The source works in browsers and in Node 20+. Its errors never carry the raw URL: The Graph’s gateway accepts the key in the URL path, so the URL is redacted (see redactUrl in Low-Level Building Blocks) in every message.

Units

Results are converted to SDK units on the way out:

KindTypeNotes
Prices, sizes, notionals, collateral, fees, PnL, shares, funding indices, bountiesbigintBAZAAR precision: 1e18 is $1 or one unit. Format with formatUnits18.
Fields whose names end in Usdc (seedRefundUsdc, seedRefundOwedUsdc)bigintUSDC units (6 decimals), as the subgraph stores them.
Rates whose names end in Bp (imrBp, mmrBp, maxSlippageBp, levyBp)bigintBasis points.
Payout ratios (principalRatio, profitRatio)bigint1e18 is 100%.
TimestampsnumberUnix seconds.
Block numbersnumberArbitrum L2 blocks: the numbers order expiry uses.
Countsnumber
Enums (order type, status, cancel reason, …)stringThe contract enum’s name, e.g. "StopLoss", "Canceled", "Margin".
AddressesAddressChecksummed.
Other bytes (ids, hashes, feed ids)HexLowercase.

An order’s triggerPrice is the trigger price alone: the contract stores the keeper bounty in the same word, and the SDK masks it off (the bounty is reported in bountyEscrowed, bountyHeld, bountyPaidToKeepers and bountyRefunded).

A field the subgraph returns empty where the type does not allow it throws SubgraphError (“the subgraph returned no …”); fields typed | null are null when the subgraph has no value.

Pages

Every list returns a Page<T>:

interface Page<T> {
  readonly items: T[];
  readonly next: string | null; // the cursor for the following page; null at the end
}

Pass page.next back as after, with the same filters, to read the next page:

// The 50 newest fills on ETH/USD.
const tape = await bz.history.trades({ pair: eth.address, first: 50 });
for (const t of tape.items) {
  const side = t.takerIsLong ? "buy" : "sell";
  console.log(new Date(t.timestamp * 1000).toISOString(), side, formatUnits18(t.size), "@", formatUnits18(t.price));
}

// The next 50: pass the page's cursor back as `after`.
if (tape.next) {
  const older = await bz.history.trades({ pair: eth.address, first: 50, after: tape.next });
  console.log(older.items.length);
}

Every list takes these options:

OptionDefaultMeaning
first100Rows per page, 1 to 1000. Anything else throws InputError.
afternoneThe next cursor of the previous page. A cursor from another list throws InputError (“is not a cursor this list returned”).
atBlockthe subgraph’s headRead the subgraph as of this block. Pin every page of a multi-page read to one block for a consistent snapshot.

Lists ordered by time also take:

OptionDefaultMeaning
fromnoneInclusive lower bound on the list’s time field, in Unix seconds.
tononeExclusive upper bound on the list’s time field.
order"desc" for event lists, "asc" for candles and day data"asc" (oldest first) or "desc" (newest first).

The time field is the list’s order field: the event’s timestamp for most lists, the period start for candles, the day for day data, and the creation, submission or opening time where the table below says so.

A cursor is the value of the order field on the page’s last row. Lists ordered by id page with an id_gt (or id_lt) filter; lists ordered by time page with a bound on the time and skip only the rows that share the last time of the previous page, so a page never repeats or drops a row while new history is appended at the head. Treat a cursor as opaque, and use it only with the list, filters and order that produced it.

To read a whole range, follow next until it is null:

/** Every row of a paginated history list, following `next` until it is null. */
async function readAll<T>(read: (after: string | null) => Promise<history.Page<T>>, maxPages = 50): Promise<T[]> {
  const out: T[] = [];
  let after: string | null = null;
  for (let i = 0; i < maxPages; i++) {
    const page: history.Page<T> = await read(after);
    out.push(...page.items);
    if (!page.next) return out;
    after = page.next;
  }
  throw new Error(`more than ${maxPages} pages: narrow the time range`);
}

// Every trade the user took part in (as maker or taker) in the last 7 days.
const now = Math.floor(Date.now() / 1000);
const myWeek = await readAll((after) =>
  bz.history.trades({ account: user, from: now - 7 * 86_400, to: now, first: 1000, after }),
);
console.log(`${myWeek.length} fills this week`);

For a list that keeps growing while you page through it (open positions, say), pin every page to one block. subgraphLag().indexedBlock is a block the subgraph has indexed:

// Pin every page to one block, so rows indexed while you page do not shift the pages.
const lag = await bz.history.subgraphLag();
const atBlock = lag.indexedBlock;
const openPositions = await readAll((after) =>
  bz.history.positions({ pair: eth.address, openOnly: true, first: 1000, after, atBlock }),
);
console.log(`${openPositions.length} open positions as of block ${atBlock}`);

An atBlock the subgraph has not indexed yet, or has pruned, is refused by the subgraph: that comes back as SubgraphError (“the subgraph refused the query: …”).

Market Data

FunctionFiltersOrderReturns
trades(filter?)pair, account (as maker or taker), maker, taker, time optionsnewest first by timestampPage<Trade>
candles(pair, interval, opts?)time options (bound the period start)oldest first by periodStartPage<Candle>
pairDayData(pair, opts?)time options (bound the day)oldest first by datePage<PairDayData>
protocolDayData(opts?)time optionsoldest first by datePage<ProtocolDayData>
bookLevels(pair, { side, ...page })side: "bid" or "ask"best price firstPage<BookLevel>

Trades. One Trade per fill: maker, taker, makerOrderId, takerOrderId, takerIsLong, size, price (the maker’s limit price), notional, each side’s fee (integrator and insurance legs together), what each order had left after the fill, each side’s integrator, and txFrom (the transaction’s sender). When the vault unloads liquidated inventory into a resting order, the fill names the pair itself as taker, isVaultFill is true and takerOrderId is null; txFrom is then the keeper that ran the unload. The account filter uses the subgraph’s or filter, which needs graph-node 0.30 or later.

Candles. interval is one of history.CANDLE_INTERVALS: 60, 300, 900, 3600, 14400 or 86400 seconds. Any other value throws InputError. Each Candle has open, high, low, close, volumeSize, volumeNotional and tradeCount. A period without fills has no candle; a chart expects one, so pass the rows through history.fillCandleGaps:

// One day of 15-minute candles, with the quiet periods filled in for the chart.
const interval = 900;
const to = now - (now % interval) + interval; // the end of the current period
const from = to - 86_400;
const rows = await readAll((after) => bz.history.candles(eth.address, interval, { from, to, first: 1000, after }));
// The last candle before the range, so a quiet start is filled at its close rather than left empty.
const before = await bz.history.candles(eth.address, interval, { to: from, order: "desc", first: 1 });
const chart = history.fillCandleGaps([...before.items, ...rows], interval, from, to);
for (const c of chart) {
  console.log(c.periodStart, formatUnits18(c.open), formatUnits18(c.high), formatUnits18(c.low), formatUnits18(c.close), c.tradeCount);
}

fillCandleGaps(rows, interval, from?, to?) returns the candles from from (rounded down to the period) to to (exclusive; by default the period after the last row), with each missing period filled by a flat candle at the previous close and zero volume. It is a pure function: it needs no connection. Periods before the first known candle stay empty, which is why the sample also reads the last candle before the range. It throws InputError when interval is not a candle interval, when from or to is not a whole non-negative number of seconds, when a row does not start on the interval’s grid, or when the span would take more than history.MAX_FILLED_CANDLES (100,000) candles: ask for a shorter range or a longer interval.

Day data. PairDayData holds one UTC day of a pair: volume, trade count, maker and taker fees, the day’s open, high, low and close (null on a day without fills), liquidations, and, as of the day’s last update, open interest on each side, the funding index and insurance deposited and withdrawn. ProtocolDayData holds the protocol’s totals for the day, including uniqueTraders.

const days = await bz.history.pairDayData(eth.address, { from: now - 30 * 86_400 });
for (const d of days.items) {
  console.log(new Date(d.date * 1000).toISOString().slice(0, 10), "volume $" + formatUnits18(d.volumeNotional, 0), "trades", d.tradeCount);
}

Book levels. Aggregated resting size and order count per price on one side, bids high to low and asks low to high. The subgraph counts resting orders whose expiry block has passed until a transaction removes them, and it lags: for what an order would fill against now, read the book from the chain (bz.markets.getBook, see Markets). side must be "bid" or "ask"; anything else throws InputError. Book levels take the page options only.

Orders

FunctionFiltersOrderReturns
orders(filter?)account, pair, status, openOnly, orderType, integrator, time options (bound the creation time)newest first by createdTimestampPage<HistoryOrder>
order(pair, orderId, atBlock?)HistoryOrder | null
orderEvents(filter?)account, pair, orderId (needs pair), action, time optionsnewest first by timestampPage<OrderEvent>

status is a list of history.ORDER_STATUSES names: Waiting (a trigger order not yet fired), Pending, Resting, Fired, Filled, Canceled. openOnly keeps the orders that can still trade (Waiting, Resting, Fired, which is history.OPEN_ORDER_STATUSES); with status as well it keeps the open statuses among those you named. orderType is a list of Market, Limit, StopLimit, TakeProfit, StopLoss. An unknown name, or an empty list, throws InputError; so does openOnly with a status list that names no open status.

// The user's orders that can still trade, newest first.
const open = await bz.history.orders({ account: user, openOnly: true });
for (const o of open.items) {
  console.log(o.orderId, o.orderType, o.isLong ? "long" : "short", formatUnits18(o.remaining), "left", o.status);
}

// Orders the protocol canceled, and why, in a sentence for the owner.
const canceled = await bz.history.orders({ account: user, status: ["Canceled"], first: 20 });
for (const o of canceled.items) {
  if (o.cancelReason === "Owner") continue;
  console.log(`Order ${o.orderId}: ${history.CANCEL_REASON_TEXT[o.cancelReason]}`);
}

A HistoryOrder holds the order as created (orderType, isLong, isPostOnly, timeInForce, integrator, triggerPrice, limitPrice, maxSlippageBp, expiryBlock), its progress (size, filledSize, remaining, status, inBook, fillCount, amendCount, triggerCount), its times (createdTimestamp, lastTriggeredTimestamp, canceledTimestamp, filledTimestamp, updatedTimestamp, and the matching blocks), its trigger bounty (bountyEscrowed, bountyHeld, bountyPaidToKeepers, bountyRefunded), and cancelReason. A TakeProfit or StopLoss reports the size its owner set at creation, and the size capped at the position once it fires. A resting order past its expiryBlock stays in the book until a transaction removes it.

Cancel Reasons

cancelReason is one of history.CANCEL_REASONS, and history.CANCEL_REASON_TEXT has a sentence for the owner for each:

ReasonMeaning
NoneNot canceled.
OwnerThe owner canceled it.
ExpiredIt reached its expiry block.
UnfilledThe book could not fill all of it on arrival; a market or immediate-or-cancel order cancels what is left.
BelowMinimumWhat was left was worth less than the $5 order minimum, so it could not rest.
PostOnlyIt was post-only and would have filled on arrival.
SelfMatchIt would have traded against another order of the same owner.
StepLimitIt crossed more resting orders than one transaction may walk; the rest was canceled.
MarginThe owner’s collateral could not cover the fill (its margin or its fee) when it was reached.
PriceBandWhile the market was closed it would have filled more than 10% away from the last price.
PositionClosedThe position it protected was closed or flipped.
LiquidatedThe position was liquidated.
DeleveragedThe position was closed in full by auto-deleveraging.

history.cancelReasonName(code) turns the numeric code an OrderUpdated log carries into its name (a code outside the list throws RangeError); use it for the numeric cancelReason of the order updates Live Updates delivers, or when you decode logs yourself. The protocol’s rules behind each reason are in Bazaar’s Orders.

Order Events

orderEvents lists one OrderEvent per OrderUpdated, OrderAmended or OrderAmendSkipped log. Its action is one of Created, Triggered, Canceled, Amended, AmendSkipped; a Canceled event carries the cancelReason, and an AmendSkipped event the amendSkipReason (Inactive, Expired, SizeFilled, BelowMinimum, WouldCross: why a best-effort amendment left its order as it was). Each event also has the order’s size, filledSize, limitPrice, triggerPrice and prioritySeq as the log reported them. For one order’s lifecycle pass pair and orderId (without pair, orderId throws InputError):

// One order's lifecycle: created, triggered, amended, amend skipped, canceled.
const orderId = 42n;
const events = await bz.history.orderEvents({ pair: eth.address, orderId, order: "asc" });
for (const e of events.items) {
  const why =
    e.action === "Canceled" ? `: ${history.CANCEL_REASON_TEXT[e.cancelReason]}`
    : e.action === "AmendSkipped" ? ` (${e.amendSkipReason})`
    : "";
  console.log(e.timestamp, e.action + why);
}
// The order itself, or null when the subgraph has not seen it yet.
const one = await bz.history.order(eth.address, orderId);
console.log(one?.status ?? "not indexed yet");

Positions and Collateral

FunctionFiltersOrderReturns
positions(filter?)account, pair, openOnlyby idPage<HistoryPosition>
positionChanges(filter?)account, pair, time optionsnewest firstPage<PositionChange>
positionSnapshots(filter?)account, pair, time optionsnewest firstPage<PositionSnapshot>
collateralTransfers(filter?)account, pair, time optionsnewest firstPage<CollateralTransfer>

positions returns one HistoryPosition per account and pair, open or closed: the stored position (isLong, size, entryValue, entryPrice, collateral, entryFundingIndex, imrBp, mmrBp, takeProfitOrderId, stopLossOrderId) and lifetime figures (realizedPnl, feesPaid, tradeCount, volumeSize, volumeNotional, collateralDeposited, collateralWithdrawn, liquidationCount, adlCount, openedAtTimestamp, closedAtTimestamp). realizedPnl is the price PnL realized by fills plus ADL credits; funding settles into collateral and is not counted in it. entryPrice is entryValue / size, and 0 when flat. positions takes the page options only.

positionChanges lists each trade’s effect on a position: the maker’s once per fill, the taker’s once per order walk at its volume-weighted price. sizeDelta is the new size less the old (both unsigned), so a flip reports less than it traded. positionSnapshots lists the full stored position after every change (each PositionBucketUpdated log), including liquidations, ADL and terminal settlement; imrBp and mmrBp are 0 when the log reported no margin rates (terminal settlement).

// The user's position on ETH/USD: lifetime figures and each trade's effect on it.
const [pos] = (await bz.history.positions({ account: user, pair: eth.address })).items;
if (pos) {
  console.log("realized PnL", formatUnits18(pos.realizedPnl), "fees paid", formatUnits18(pos.feesPaid));
}
const changes = await bz.history.positionChanges({ account: user, pair: eth.address, first: 20 });
for (const c of changes.items) {
  console.log(c.timestamp, formatUnits18(c.newSize), "@", formatUnits18(c.executionPrice), "pnl", formatUnits18(c.realizedPnl));
}
const cash = await bz.history.collateralTransfers({ account: user, pair: eth.address });
for (const t of cash.items) console.log(t.isDeposit ? "deposit" : "withdrawal", formatUnits18(t.amount));

Fees, Rewards and Relaying

FunctionFiltersOrderReturns
feeBalances(filter?)recipient, pair, claimableOnlyby idPage<FeeBalance>
feeAccruals(filter?)recipient, pair, time optionsnewest firstPage<FeeMovement>
feeClaims(filter?)recipient, pair, time optionsnewest firstPage<FeeMovement>
integratorStats(integrator, atBlock?)IntegratorStats | null
accountStats(account, atBlock?)AccountStats | null
accountDayData(account, opts?)time optionsoldest first by datePage<AccountDayData>
metaTransactions(filter?)user, relayer, pair, time optionsnewest firstPage<MetaTransaction>
permitFailures(filter?)owner, pair, time optionsnewest firstPage<PermitFailure>

A claimable balance on a pair belongs to a recipient: an integrator’s fees, a relayer’s fees, a keeper’s rewards, a refunded trigger bounty. feeBalances reports each one’s accrued, claimed and claimable; claimableOnly keeps those with something to claim. It takes the page options only: its type accepts from, to and order, but they are ignored. feeAccruals lists each credit (FeeAccrued) and feeClaims each claim (FeesClaimed).

integratorStats reports what one integrator address has routed: orderCount, fillCount (fill sides that carried it: a fill with it on both sides counts twice), maker and taker volume, firstOrderTimestamp and lastFillTimestamp. Its revenue is its feeBalances. It is null when the address has carried no order.

// What a frontend has routed, and what it can claim.
const routed = await bz.history.integratorStats(MY_FRONTEND);
if (routed) {
  console.log(routed.orderCount, "orders,", routed.fillCount, "fill sides");
  console.log("maker volume $" + formatUnits18(routed.makerVolumeNotional, 2), "taker volume $" + formatUnits18(routed.takerVolumeNotional, 2));
}
const balances = await bz.history.feeBalances({ recipient: MY_FRONTEND, claimableOnly: true });
for (const b of balances.items) console.log(b.pair, "claimable $" + formatUnits18(b.claimable, 2));

accountStats reports an account’s lifetime totals across every pair: trades, maker and taker volume, fees paid, realized PnL, liquidations suffered and executed, trigger bounties earned and executions, ADL batches executed, every credit and claim of a claimable balance, and seedRefundOwedUsdc (a listing seed owed back, in USDC units). It is null when the subgraph has not seen the account. accountDayData gives the same account’s figures per UTC day.

metaTransactions lists relayed (gasless) actions: the user, the relayer, the action’s EIP-712 typehash as functionId, the nonce and the relayerFee. permitFailures lists the ERC-2612 permits a pair could not execute (the call went on with the existing allowance). See Gasless Trading & Relayers and Collateral, Approvals & Permits.

Liquidation and ADL

FunctionFiltersOrderReturns
liquidations(filter?)account (the liquidated trader), liquidator, pair, time optionsnewest firstPage<Liquidation>
adlWindows(filter?)pair, time options (bound the opening time)newest first by openedAtPage<AdlWindow>
adlBatches(filter?)pair, window, transactionHash, time optionsnewest firstPage<AdlBatch>
adlFills(filter?)account, pair, transactionHash, time optionsnewest firstPage<AdlFill>

A Liquidation has the seized size, side, bankruptcyPrice, the price it ran at, notional and collateralSeized. An AdlWindow (id "<pair>-<epoch>") runs from the AdlTriggered that opened it to AdlResolved or the pair’s termination (endedByTermination). An AdlBatch is one executeAdl call: the bucket each leg closed, its settlement price (0 when that leg did not run) and what it netted. Join adlFills on the batch’s transactionHash for the winners it closed: each AdlFill has the closedSize, the credit to the winner’s collateral (PnL at the settlement price less withheld funding), adlScore and withheldFunding. window must be a window id; transactionHash must be 32 bytes of hex; anything else throws InputError.

Insurance

FunctionFiltersOrderReturns
insuranceProviders(filter?)pair, account, activeOnlyby idPage<InsuranceProvider>
insuranceEvents(filter?)account, pair, kind, time optionsnewest firstPage<InsuranceEvent>

An InsuranceProvider is one LP on one pair: live shares (a dead-pool reset sets every balance to 0), deposited, withdrawn, the withdrawal request in force (requestedShares, requestedAt), what the first execute call lockedAmount the shares at, when the paying call opens (claimOpensTs), and levies charged and received. activeOnly keeps providers holding shares. kind is a list of Deposit, WithdrawalRequested, WithdrawalLocked, WithdrawalExecuted, LevyCharged, LevyReceived; an event’s amount is 0 for requests and levies. The share price and what a withdrawal would pay now come from the chain: see Insurance.

Termination

FunctionFiltersOrderReturns
pairTerminations(filter?)pair, time optionsnewest firstPage<PairTermination>
terminalRatioUpdates(filter?)pair, time optionsnewest firstPage<TerminalRatioUpdate>
terminationProposals(filter?)pair, proposer, status, time options (bound submission)newest first by submittedAtPage<TerminationProposal>
insurerProposals(filter?)pair, proposer, status, time options (bound creation)newest first by createdAtPage<InsurerProposal>
insurerVotes(filter?)proposal, pair, voter, time optionsnewest firstPage<InsurerVote>
insurerLevies(filter?)proposal, pair, voter, time optionsnewest firstPage<InsurerLevy>

A PairTermination is a finalized termination with its payout figures: settlementPrice, owedToTraders, registeredClaims, badDebt, estateValue, pot, the principalRatio and profitRatio (1e18 = 100%), the insurance fund after, and sweepComplete (false when finalize ran through the escape hatch with positions still open). A TerminalRatioUpdate records a post-finalize settlement that re-derived the ratios on the entitlements still unpaid.

A TerminationProposal is a UMA termination claim (its id is the UMA assertion id): category, lastTradingTs (the cutoff it proposed), reason, disputed, acceptedCutoffTs, submittedAt, livenessEndsAt (from when an undisputed claim can be settled) and status: Pending, Disputed, Accepted, ScheduleRefused, Superseded or Rejected. Every claim also opens an insurer vote under the same id. An InsurerProposal has status Open, Scheduled, Failed, Preempted, Voided or Rejected, its cutoffTs, yesShares against snapshotTotalShares, thresholdMet, outcome (1 TRUE, 2 FALSE, 3 UNJUDGED), levyBp and bondRefundFailed. proposal filters must be 32 bytes of hex. Render reason as text, never as HTML. How to act on any of these: Termination & Settlement.

Listings and Governance

FunctionFiltersOrderReturns
listingProposals(filter?)deployer, status, time options (bound the proposal time)newest first by proposedAtPage<ListingProposal>
identifierUpgradeProposals(filter?)proposer, status, time optionsnewest first by proposedAtPage<IdentifierUpgradeProposal>

A ListingProposal (id: the UMA assertion id) has the proposed pairId, baseFeedId, description, seedAmount, status (Pending, Disputed, Deployed, Rejected, Expired, Voided), the deployed pair once deployed, and seedRefundUsdc (USDC units). An IdentifierUpgradeProposal has the newIdentifier, status (Pending, Disputed, Queued, Upgraded, Canceled, Expired, Rejected), and effectiveTs (when a queued upgrade’s timelock ends). A listing’s description is written by whoever proposed it: render it as text. See Listing Markets and UMA Disputes & Governance.

How Current Is It

subgraphLag() compares the subgraph’s _meta.block with the chain’s head:

FieldMeaning
indexedBlockThe last block the subgraph has indexed.
indexedTimestampIts timestamp, or null when the indexer does not report one.
chainBlock, chainTimestampThe head as the connection’s RPC sees it.
blocksBehindBlocks the subgraph trails the head (0 when level, or ahead of this RPC).
secondsBehindSeconds behind, or null without indexedTimestamp.
hasIndexingErrorsThe indexer hit an error: its history may stop advancing.

Show the user that recent trades may be missing when it falls behind:

try {
  const l = await bz.history.subgraphLag();
  if (l.hasIndexingErrors) console.warn("The history index hit an error and may have stopped.");
  else if (l.blocksBehind > 20) {
    const behind = l.secondsBehind !== null ? `${l.secondsBehind} s` : `${l.blocksBehind} blocks`;
    console.warn(`History is ${behind} behind the chain: the latest trades may not be listed yet.`);
  }
} catch (e) {
  // The subgraph is unreachable or refused the query: say so, and keep the page working.
  console.warn("History is unavailable right now.", e instanceof Error ? e.message : e);
}

What Can Go Wrong

ErrorWhen
ConnectErrorThe connection has no subgraph source.
InputErrorAn argument was refused before any query was sent: an address that is not one, first outside 1 to 1000, an unknown enum name, a cursor from another list, a candle interval that is not one of the six, orderId without pair, a window id or hash of the wrong shape. Its field names the argument, and errorMessage(e) shows its message.
history.SubgraphErrorThe subgraph is unreachable, answered with an HTTP error (status holds it; 401 and 403 add “check the API key”), refused the query (GraphQL errors, such as an atBlock it has not indexed), did not answer with JSON, or returned no data or a row without a required field. The message never carries the raw URL.
try {
  await bz.history.trades({ pair: eth.address });
} catch (e) {
  if (e instanceof history.SubgraphError) {
    // unreachable, an HTTP error (e.status), a GraphQL error, or an answer without data
    console.error("history unavailable:", e.message, e.status);
  } else {
    // InputError (a bad argument: errorMessage shows it), or ConnectError (connected without a subgraph)
    console.error(errorMessage(e));
  }
}

errorMessage(e) is written for contract and wallet failures; for a SubgraphError or ConnectError it returns its generic sentence, so show your own text (“History is unavailable right now”) and log e.message.

Every value is validated and written into the query as a literal, so nothing a user typed can change the query’s text.

Insurance

Each Bazaar market has its own insurance fund, owned by liquidity providers (LPs) who hold its shares. The fund earns the market’s insurance fees and absorbs what liquidations lose beyond maintenance margin. This page covers the LP flow from the SDK: reading an LP’s position, quoting at the live price, depositing, and the multi-step exit (request, cooldown, lock, paying call), with insuranceExitPlan telling a UI which step the LP is on.

Use it to build an “earn” or “provide insurance” screen, or a bot that manages an LP position. The mechanics (share pricing, the target, the rate limit, the headroom rule) are described in Bazaar’s Insurance Fund page; this page says what each SDK call does with them.

Everything here is on bz.insurance (with bz connected as in Connecting), and the same functions take a context first on the insurance namespace export (insurance.depositToInsurance(ctx, ...)), along with the constants and the pure helper claimOpensAt.

Before You Start

  • Connect a price source. Shares are priced at the fund’s value at the mark, so quotes, the budget and both exit calls on a live market use the live Pyth price. Without prices on Bazaar.connect, the quotes and the budget throw a ConnectError unless you pass a price, executeInsuranceWithdrawal throws one on a live market because it needs a price update, and the exit plan leaves its estimates null.
  • Units. USDC amounts are 18-decimal bigints (1e18 = $1), floored to whole micro-USDC by the pair; use parseUnits18("250") and formatUnits18(x, 2). Insurance shares are 18-decimal too. Times are unix seconds of the chain’s clock. See Units & Constants.
  • Writes are PreparedCalls. Simulate each as the LP, then send it with the wallet (Sending Transactions). Calls that carry a price update have a priceTime and must land within 3 seconds of it, so build them right before the wallet prompt (buildAndSend).

Reading an LP’s Position

const [sharePrice, myShares, myValue] = await Promise.all([
  bz.insurance.getInsuranceSharePrice(eth.address), // USDC per share at the stored mark
  bz.insurance.insuranceShares(eth.address, user!), // 18 decimals
  bz.insurance.getInsuranceDepositValue(eth.address, user!), // what they redeem for at the stored mark
]);
console.log(`1 share = ${formatUnits18(sharePrice, 6)} USDC`);
console.log(`you hold ${formatUnits18(myShares, 4)} shares, worth ${formatUnits18(myValue, 2)} USDC`);
FunctionReturns
getInsuranceSharePrice(pair)What one share redeems for at the pair’s stored mark (1.0 while no shares exist).
getInsuranceDepositValue(pair, user)What user’s shares redeem for at the stored mark.
insuranceShares(pair, user)user’s shares. 0 for a balance a fund recapitalization wiped (see Share Epochs).
quoteInsuranceDeposit(pair, amount, { price? })Shares a deposit would mint at the live price.
quoteInsuranceWithdrawal(pair, shareAmount, { price? })USDC a redemption would pay at the live price.

The first two read the lens at the price the pair last stored, which can be minutes old on a quiet market. For anything the LP will act on, use the quotes: they compose the live price from the price source the way the oracle composes it, so they match what a call carrying that price will do.

The market’s fund as a whole (balance, free capital, the seizure earmarked against inventory, the target ratio) is in marketSnapshot(...).insurance; see Markets.

Quotes

const amount = parseUnits18("250"); // 250 USDC, in 18 decimals
if (amount < insurance.MIN_INSURANCE_DEPOSIT) console.log("The minimum insurance deposit is 5 USDC.");
const quote = await bz.insurance.quoteInsuranceDeposit(eth.address, amount);
console.log(`250 USDC buys about ${formatUnits18(quote.value, 4)} shares at ${formatUnits18(quote.price, 2)}`);

Both quotes return an InsuranceQuote:

FieldMeaning
valueShares minted (deposit) or USDC paid (withdrawal), 18 decimals.
priceThe mark it was priced at.
publishTimeThe live update’s publish time, or null when you passed price.

Pass { price } to quote at a price of your own (for “what if” displays); it must be positive.

quoteInsuranceDeposit floors the amount to micro-USDC as the pair does and runs the lens’s quoteInsuranceDeposit, the pair’s own arithmetic. It ignores the deposit’s gates (the minimum, the market’s state, the rate limit) and returns 0 where the fund’s value is nil. quoteInsuranceWithdrawal is what the shares are worth now; a locked exit’s paying call pays the lower of this and the locked amount, and while the vault holds liquidated inventory no more than the fund’s headroom.

Depositing

async function deposit(amount: bigint): Promise<string> {
  // The deposit pulls USDC from the wallet: approve the pair for exactly what it pulls.
  const draft = await bz.insurance.depositToInsurance(eth.address, user!, amount);
  const funding = await bz.allowance.usdcFunding(user!, eth.address, draft.pullsUsdc!);
  if (!funding.enoughBalance) return "Your wallet does not hold enough USDC.";
  if (!funding.enoughAllowance) {
    const approve = await bz.allowance.approveUsdc(user!, eth.address, funding.approveUsdc);
    await bz.waitForReceipt(await bz.send(window.ethereum, approve));
  }
  // Build again right before the wallet prompt: a deposit made while the vault holds liquidated
  // inventory carries a price update that must still be fresh when it lands.
  const { hash } = await buildAndSend(window.ethereum, async () => {
    const call = await bz.insurance.depositToInsurance(eth.address, user!, amount);
    const sim = await bz.simulate(call);
    if (!sim.ok) throw new CallError(sim.error!.message, sim.error);
    return call;
  });
  await bz.waitForReceipt(hash);
  return "Deposited.";
}

try {
  console.log(await deposit(amount));
} catch (e) {
  console.error(errorMessage(e)); // one sentence for the LP
}

depositToInsurance(pair, from, amount, opts?) builds the deposit. The amount is floored to whole micro-USDC and pulled from the wallet, so the wallet needs that much USDC approved to the pair (the sample approves exactly pullsUsdc) or a USDC permit passed as permitData; see Collateral, Approvals & Permits.

OptionDefaultMeaning
withPriceattach when a price source is connectedfalse sends no price update. The pair reads a price only while its vault holds liquidated inventory, and then needs a fresh one; an update it does not read has its fee refunded. true requires one (a ConnectError without a price source).
permitData"0x"A signed USDC permit, so no separate approval transaction is needed.

The builder refuses up front, with an InputError:

  • an amount under 5 USDC (MIN_INSURANCE_DEPOSIT) or above 2^128 - 1 (MAX_INSURANCE_DEPOSIT);
  • a terminated market, a market whose settlement price is fixed, and a market within 6 seconds of a scheduled cutoff;
  • a market with an ADL window pending.

The contract also refuses more than 100 deposits per user in four days (InsuranceVaultLib__TooManyRecentInsuranceDeposits) and a deposit whose price would open an ADL window. Those show up in the simulation.

The returned call’s pullsUsdc is the floored amount, and value is the Pyth fee when a price update is attached.

Leaving the Fund

An exit on a live market takes three transactions, spread over at least 13 days:

  1. Request (requestInsuranceWithdrawal): starts a 13-day cooldown. Moves no USDC.
  2. Lock (the first executeInsuranceWithdrawal in the window): once the cooldown ends, an execution window opens: 2 days on a market that trades around the clock, 4 days on a market with trading hours. The first execute call in it records what the requested shares are worth and pays nothing.
  3. Pay (the next executeInsuranceWithdrawal): from 5 minutes after the lock, it burns the shares and pays the lower of the locked amount and what they are worth then. The paying call is pushed back to 5 minutes after the last stored price that jumped 2% or more, and to 30 minutes after the last stored price that ended a gap of an hour or more.

On a terminated market the request is followed by a single execute call that pays at once.

ConstantValueMeaning
INSURANCE_WITHDRAWAL_COOLDOWN13 daysFrom the request to the window.
INSURANCE_WITHDRAWAL_WINDOW_CONTINUOUS2 daysThe window on a 24/7 market.
INSURANCE_WITHDRAWAL_WINDOW_SESSION4 daysThe window on a market with trading hours.
INSURANCE_WITHDRAWAL_CLAIM_DELAY300 sFrom the lock (and from the last 2% jump) to the paying call.
INSURANCE_WITHDRAWAL_REOPEN_CLAIM_DELAY1,800 sFrom the end of a price gap to the paying call.
REOPEN_GAP_MIN_STALENESS3,600 sA stored price after this long without one ends a gap.

withdrawalWindowOf(continuous) returns the window for a market, and claimOpensAt(lockTs, lastGapTs, lastJumpTs) is the pair’s own rule for when the paying call opens.

Requesting

requestInsuranceWithdrawal(pair, from, shareAmount) files the request. shareAmount is in shares (18 decimals) and must be positive and no more than the LP holds; the builder refuses an LP with no shares. Filing again replaces a pending request and restarts its cooldown, and a lock the earlier request made no longer counts; the call’s summary says so when a request is pending.

Shares that back an insurer termination vote cannot leave while the vote holds them. Request no more than shares - lockedForVoting from the exit plan, or the execute calls are refused with InsuranceVaultLib__SharesLockedForVoting.

The Lock and the Paying Call

executeInsuranceWithdrawal(pair, from, opts?) builds whichever execute call comes next. It reads the exit plan first and refuses with an InputError carrying the plan’s message when no execute call can land now (still in the cooldown, waiting for the claim to open, the window closed, a freeze). The summary says which call it is:

  • the lock: “Lock your withdrawal of … shares (pays nothing; collect it from … later)”;
  • the paying call: “Collect your insurance withdrawal (at most … USDC)”;
  • on a terminated market: “Redeem … insurance shares”.

Both calls on a live market need a fresh price, so the update is attached when a price source is connected (withPrice: false sends none, and the call then reverts on a live market). On a market with trading hours that means they can land only while its venue trades. On a terminated market no price is read, so none is attached unless you pass withPrice: true.

While the vault holds liquidated inventory, a paying call is held to the fund’s headroom: it may pay part of the claim and leave the rest requested, under the same lock scaled to it, for another paying call later in the window. Run the exit plan again after each paying call.

The Exit Plan

const plan = await bz.insurance.insuranceExitPlan(eth.address, user!);
console.log(plan.step, plan.message); // e.g. "cooldown" "Your withdrawal's cooldown ends in 9 d 4 h; ..."
for (const note of plan.notes) console.log(`note: ${note}`);
console.log(`shares ${formatUnits18(plan.shares, 4)}, requested ${formatUnits18(plan.requestedShares, 4)}, locked by votes ${formatUnits18(plan.lockedForVoting, 4)}`);
console.log(`window ${at(plan.windowOpensTs)} to ${at(plan.windowEndTs)}; next call from ${at(plan.nextCallAt)} until ${at(plan.nextCallBy)}`);
if (plan.payoutEstimate !== null) console.log(`the paying call would pay at most ${formatUnits18(plan.payoutEstimate, 2)} USDC now`);

insuranceExitPlan(pair, user, { quote? }) reads everything the exit depends on in one batch (the shares, the request and its share epoch, the lock, the market’s state, the price-gap clocks, the vault’s inventory, the shares locked by votes) and says where the LP stands. step is one of:

StepMeaningnextAction
noneNo shares and no request.null
requestNo request yet, or only a voided one (see Share Epochs). On a terminated market the redemption then pays at once.requestInsuranceWithdrawal
cooldownRequested; the window opens at nextCallAt.executeInsuranceWithdrawal
lockThe window is open; the next execute locks and pays nothing.executeInsuranceWithdrawal
waitClaimLocked; the paying call opens at nextCallAt.executeInsuranceWithdrawal
claimLocked and open: the next execute pays.executeInsuranceWithdrawal
redeemTerminated market: the next execute pays.executeInsuranceWithdrawal
expiredThe window closed, too little of it is left to lock and collect, or a price move pushed the claim past its end. Request again.requestInsuranceWithdrawal
blockedNo exit call can land now: the settlement window, the 6 seconds before a cutoff, the settlement of open positions after finalize, or shares committed to a vote. nextCallAt says when, where it is known.varies

The plan’s fields:

FieldMeaning
messageOne line for the LP, with the waits written out (“9 d 4 h”).
nextActionThe function to call next, or null.
nextCallAtThe earliest chain time the next call can land (now when it can land now), or null.
nextCallByThe latest chain time it can land, or null for no limit. For a lock this is the window’s end less the 5-minute claim delay.
nowThe chain time of this reading.
shares, requestedSharesThe LP’s shares, and those in the live request (0 without one).
lockedForVotingShares committed to insurer votes, which the exit cannot take.
requestTs, windowOpensTs, windowEndTsThe request and its window, or null.
lockTs, lockedAmount, claimOpensTsThe lock, once made. For the lock step claimOpensTs is when a lock made now would open its claim.
payoutEstimateFor lock, waitClaim, claim and redeem, with a price source: the most the paying call would pay now (the lower of the locked amount and the live value).
budgetRemainingThe shared budget left, with a price source; null when unlimited or unknown.
notesWhat can still hold the paying call back: trading hours, an open ADL window, inventory in the vault, shares locked by a vote.

{ quote: false } skips the price reads (payoutEstimate and budgetRemaining stay null), which is what executeInsuranceWithdrawal does internally. Without a price source they are null too. Every time is the chain’s: compare against now, not the browser’s clock.

Taking the Next Step

/** Take the LP's next exit step, if one can land now. `shares` sizes a new request (all free shares by default). */
async function nextExitStep(shares?: bigint): Promise<string> {
  const p = await bz.insurance.insuranceExitPlan(eth.address, user!, { quote: false });
  if (p.nextAction === null || p.nextCallAt === null || p.nextCallAt > p.now) return p.message;
  let build: () => Promise<PreparedCall>;
  if (p.nextAction === "requestInsuranceWithdrawal") {
    const amount = shares ?? p.shares - p.lockedForVoting; // shares backing a vote cannot leave
    build = () => bz.insurance.requestInsuranceWithdrawal(eth.address, user!, amount);
  } else {
    // The lock and the paying call are the same function; both carry a fresh price on a live market.
    build = () => bz.insurance.executeInsuranceWithdrawal(eth.address, user!);
  }
  const { call, hash } = await buildAndSend(window.ethereum, async () => {
    const c = await build();
    const sim = await bz.simulate(c);
    if (!sim.ok) throw new CallError(sim.error!.message, sim.error);
    return c;
  });
  await bz.waitForReceipt(hash);
  return `Done: ${call.summary}`;
}

try {
  console.log(await nextExitStep());
} catch (e) {
  console.error(errorMessage(e));
}

A UI can run the plan on a timer and enable one button: “Request withdrawal” for request and expired, “Lock” for lock, “Collect” for claim and redeem, a countdown to nextCallAt for cooldown and waitClaim. Remind LPs that the window is short (2 or 4 days) and the lock and the paying call must both land inside it.

Budgets and Locks

const [budget, lock, k] = await Promise.all([
  bz.insurance.getInsuranceWithdrawalBudget(eth.address), // at the live price
  bz.insurance.getInsuranceWithdrawalLock(eth.address, user!),
  bz.insurance.getInsuranceWithdrawalConstants(),
]);
if (budget.unlimited) console.log("No withdrawal budget applies (terminated, or no open interest).");
else console.log(`${formatUnits18(budget.remaining, 2)} of ${formatUnits18(budget.cap, 2)} USDC left until ${at(budget.resetsAt)}`);
console.log(`coverage ${budget.coverageRatioBp} bp against a target of ${budget.targetRatioBp} bp`);
if (lock.isLocked) console.log(`locked at ${formatUnits18(lock.lockedAmount, 2)} USDC; collect from ${at(lock.claimOpensTs)} to ${at(lock.windowEndTs)}`);
console.log(`cooldown ${k.cooldown / 86_400} days; windows ${k.continuousWindow / 86_400} or ${k.sessionWindow / 86_400} days`);
// The claim delay, in the SDK's own terms: 5 minutes after the lock and after the last 2% jump,
// 30 minutes after the end of a price gap of an hour or more.
const opens = insurance.claimOpensAt(lock.lockTs, 0, 0);
console.log(`with no jump or gap since, the claim would open at ${at(opens)}`);

getInsuranceWithdrawalBudget(pair, { price? }) is the lens’s reading of the market’s shared 6-hour withdrawal budget at the live price (or price): the number a paying call is checked against, as of now. It is a reading, not a reservation. Every LP on the market draws on the same budget, and the check runs at the paying call, not at the request or the lock.

FieldMeaning
remainingWhat the current period still admits, 18 decimals.
capThe period’s budget.
resetsAtWhen the period ends and the budget refills; null when unlimited.
unlimitedNo limit applies: the market is terminated or has no open interest.
coverageRatioBpFree capital over open-interest notional, in basis points.
targetRatioBpThe live target; below it the stricter budget applies.
priceThe price it was read at.

getInsuranceWithdrawalLock(pair, user) is the lens’s view of the two execute calls: isLocked, lockedAmount (the most the paying call pays), lockTs, claimOpensTs (a later price jump or reopen pushes it back) and windowEndTs (0 without a request).

getInsuranceWithdrawalConstants() reads the protocol’s terms from the lens: cooldown, continuousWindow, sessionWindow and rateLimitPeriod in seconds, and rateLimitBp, aboveTargetRateLimitBp, aboveTargetFundCapBp and belowTargetFundCapBp in basis points.

Share Epochs and Voided Requests

When bad debt drains a fund below $1, the next deposit starts a new share epoch: balances from before the drain read as zero, and the rescuer is minted shares 1:1 against their own deposit. A withdrawal request is stamped with the epoch it was filed in, and the pair refuses to execute one from an earlier epoch (InsuranceVaultLib__WithdrawalRequestStaleEpoch).

The SDK reads the epoch with the request, so a voided request is no request at all: the plan reports request (or none when the LP holds no shares in the new epoch) and its message starts with “Your earlier withdrawal request was voided when the insurance fund was recapitalized.”, and requestInsuranceWithdrawal describes a new request rather than a replacement.

On a Terminated Market

After finalizeTermination, the fund is whatever remains once every trader is paid, and its shares redeem against that residual. The plan reports request and then redeem, and one execute call pays with no price update.

If finalize ran through the 14-day escape hatch with positions still open, redemptions wait until those positions are settled (anyone can settle them; see Termination & Settlement), or 28 days after the settlement price was fixed at the latest. The plan reports blocked with that time as nextCallAt.

Insurers also take part in ending a market: voting shares for an insurer termination locks them, and some outcomes levy part of them. See Insurer Votes.

Gasless

All three LP calls have relayed forms (depositToInsurance, requestInsuranceWithdrawal, executeInsuranceWithdrawal), signed with bz.relay.signRelayed like any other action; see Gasless Trading & Relayers. A relayed deposit pulls the amount plus the relayer fee from the wallet. A relayed request and a relayed lock pay their fee from the wallet too (the lock pays nothing out), so they need an allowance or a permit; a relayed paying call takes its fee out of the payout.

What Can Go Wrong

Builder refusals are InputErrors whose field names the argument (amount, shareAmount, pair); errorMessage(e) turns them and every decoded revert into one sentence. The contract errors an LP is most likely to see in a simulation:

ErrorMeaning
InsuranceVaultLib__CooldownNotElapsedThe 13-day cooldown has not ended.
InsuranceVaultLib__ClaimNotOpenLocked; the paying call opens at claimOpensTs.
InsuranceVaultLib__LockTooLateThe claim a lock would open could only open after the window closes. Request again.
InsuranceVaultLib__WithdrawalWindowExpiredThe window closed. Request again.
InsuranceVaultLib__NoWithdrawalRequestNo request to execute.
InsuranceVaultLib__WithdrawalRequestStaleEpochThe request predates a fund recapitalization.
InsuranceVaultLib__RateLimitExceededThe shared 6-hour budget is used up. Withdraw less or wait for resetsAt.
InsuranceVaultLib__AdlBlockingThe fund’s headroom cannot pay any of the claim (ADL due or running).
InsuranceVaultLib__VaultUnhealthyExits are paused while the market is unhealthy.
InsuranceVaultLib__SharesLockedForVotingShares in the request back an open termination vote.
InsuranceVaultLib__SweepIncompleteAfter an escape-hatch finalize, positions are still being settled.
InsuranceVaultLib__PairScheduledForTerminationWithin 6 seconds of a scheduled cutoff, or settling.
InsuranceVaultLib__TooManyRecentInsuranceDepositsMore than 100 deposits in four days.
InsuranceVaultLib__DepositTooSmallThe deposit buys no shares.
BazaarPair__InsufficientPythFeeThe price update’s fee changed; rebuild the call.

A call that carries a price update and lands more than 3 seconds after its priceTime is refused: rebuild it and send again. See Errors & Messages.

History

Bazaar’s subgraph records every insurance event: bz.history.insuranceEvents (deposits, requests, locks and payouts), insuranceProviders (each LP’s position per market) and insurerLevies. See History.

Termination & Settlement

Every Bazaar market can be wound down without anyone’s permission: when its feed is decommissioned, its asset is delisted, or its price series breaks. This page covers the SDK side of that: reading where a market stands, proposing termination through UMA or an insurer vote, fixing the settlement price (including fetching and proving the archived Pyth Pro updates it needs), settling positions, finalizing, and withdrawing what a terminated market pays.

Use it for a market-status banner, a “propose termination” or “vote” screen for insurers, a withdrawal screen on an ended market, or a bot that settles markets for their tips. The rules themselves (why the cutoff must be 12 hours out, how the chain rule picks its five prices, the settlement waterfall) are in Bazaar’s Termination & Settlement page; this page links to it rather than repeating it.

Everything here is on bz.termination (with bz connected as in Connecting). The termination namespace export has the same functions with a context first, plus the pure helpers and constants (reasonProblem, TerminationCategory, insurerProposalId, …).

How a Market Ends

There are four ways in, and every one of them goes through the same two stages.

PathWho starts itSDK
UMA claimAnyone, with a USDC bond (at least 1,000 USDC) and a 12-hour UMA livenessproposeTermination, settleTerminationProposal
Insurer voteInsurers whose matured shares reach 60% of the vote’s snapshotproposeInsurerTermination, voteForInsurerTermination, executeInsurerTermination
Stale oracleAnyone, once the market has stored no price for 21 daysterminateStalePair
AutonomousThe pair’s own insolvency checksnothing to call
  1. The cutoff. A UMA claim or an insurer vote schedules a cutoff. Trading, deposits and withdrawals stop 6 seconds before it (LIMBO_LEAD): the market is in limbo.
  2. Fixing the price. After the cutoff, anyone calls terminateScheduledPair with the archived Pyth Pro updates that prove the settlement price (the stale-oracle path fixes it directly at the last stored price). This opens the settlement window.
  3. Settling. In the window every open position is settled at the fixed price (liquidate on the pair, permissionless, tipped $0.05 per position settled for someone else).
  4. Finalizing. Once every position is settled, or 14 days after the fix, anyone calls finalizeTermination. The payout ratios freeze and withdrawals open for good.

Costed Calls

Every builder on this page returns a CostedCall (except terminateScheduledPair, which returns a plan holding one). A CostedCall is a PreparedCall (simulate it and send it as one) with four more fields:

FieldMeaning
costsEvery bond, fee, levy and share lock the call takes or puts at risk, each a Cost. Empty for a call that costs only gas.
approvalsERC-20 approvals to send first, as PreparedCalls, each for exactly the amount (never unlimited). Empty when the allowance already covers it. The SDK never assumes an approval exists.
requiresPreconditions in words for the UI to show: an allowance to send first, a balance short of the bond, a cap the contract enforces.
notesWhat will happen, in plain language, and warnings.

A Cost:

FieldMeaning
kind"bond", "seed", "fee", "levy" or "lock".
labelWhat it is, in a trader’s words (“UMA termination bond”).
amountIn unit’s own precision: USDC with 6 decimals, ETH in wei, shares with 18 decimals, "token" in the token’s units.
unit"USDC", "ETH", "shares" or "token" (an ERC-20 other than USDC; see token and decimals).
tokenThe ERC-20 it is paid in; null for ETH and shares.
decimalsFor "token": the token’s decimals.
payeeWho takes or holds it (the contract pulling a bond, the oracle), or null when nothing moves now.
returnedWhen and how it comes back, if ever.
contingenttrue for a worst case that depends on what happens later (a levy that depends on a verdict).

Note the units: a bond’s amount is in USDC’s 6 decimals, while the call’s pullsUsdc is the same bond in 18 decimals, like every other pullsUsdc. Format costs with uma.usdcText, uma.ethText and uma.sharesText, which print the exact amount.

A helper that shows a costed call, sends its approvals, simulates it and sends it:

/** A cost as text, in its own unit. */
function costText(c: uma.Cost): string {
  if (c.unit === "USDC") return uma.usdcText(c.amount);
  if (c.unit === "ETH") return uma.ethText(c.amount);
  if (c.unit === "shares") return uma.sharesText(c.amount);
  return `${formatDecimal(c.amount, c.decimals ?? 18)} of token ${c.token}`;
}

/** Show what a costed call takes, send its approvals, simulate it, then send it. */
async function sendCosted(call: uma.CostedCall): Promise<Receipt> {
  console.log(call.summary);
  for (const c of call.costs) {
    console.log(`${c.label}: ${costText(c)}${c.contingent ? " (worst case)" : ""}. ${c.returned}`);
  }
  for (const r of call.requires) console.log(`Needs: ${r}`);
  for (const n of call.notes) console.log(n);
  // Approvals first (each for the exact amount): the call itself reverts until they land.
  for (const a of call.approvals) await bz.waitForReceipt(await bz.send(window.ethereum, a));
  const sim = await bz.simulate(call);
  if (!sim.ok) throw new CallError(sim.error!.message, sim.error);
  return bz.waitForReceipt(await bz.send(window.ethereum, call));
}

Simulate after the approvals have landed: the call pulls the bond with transferFrom and reverts until the allowance is there. When the wallet already allows the spender more than the bond, no approval is returned, and a note warns that the contract takes what it charges when the call runs, up to that allowance; to cap it at the quote, approve exactly the bond first.

A bond quote read from the chain that is below the contract’s own floor (an RPC answering 0, for example) cannot be right: the builder throws uma.BondQuoteError and builds nothing, since an approval sized to it would be wrong.

Reading Where a Market Stands

const s = await bz.termination.terminationStatus(aapl.address);
console.log(`phase: ${s.phase}`); // live | scheduled | limbo | settling | terminated
if (s.scheduledTerminationTs) console.log(`cutoff ${uma.timeText(s.scheduledTerminationTs)}; trading halts ${uma.timeText(s.haltsAt!)}`);
console.log(s.settlementRule.key, "-", s.settlementRule.meaning);
if (s.settlementPrice !== null) console.log(`settlement price ${formatUnits18(s.settlementPrice)}`);
if (s.finalizeOpensAt !== null) console.log(`finalize opens ${uma.timeText(s.finalizeOpensAt)}`);
for (const tp of s.openProposals.termination) console.log(`UMA claim ${tp.assertionId}: ${tp.categoryName}, cutoff ${uma.timeText(tp.lastTradingTs)}`);
for (const ip of s.openProposals.insurer) console.log(`vote ${ip.id}: ${ip.state}, ${uma.sharesText(ip.yesShares)} yes`);
// Without a subgraph, find the ids in the terminator's logs instead: terminationStatus(pair, { fromBlock }).

terminationStatus(pair, search?) answers in one call. phase is:

PhaseMeaning
liveNo cutoff scheduled.
scheduledA cutoff is scheduled; trading continues until 6 seconds before it.
limboWithin 6 seconds of the cutoff, or past it, with no settlement price yet: nothing trades, deposits or withdraws until terminateScheduledPair fixes the price.
settlingThe settlement price is fixed; positions settle at it until finalizeTermination.
terminatedFinalized: withdrawals pay at the frozen ratios.
FieldMeaning
nowThe chain time the status was judged at.
scheduledTerminationTs, haltsAt, cutoffPassedThe cutoff, when trading halts (the cutoff less 6 s), and whether it has passed.
settlementPriceFixedTs, settlementPriceWhen the price was fixed and the price (18 decimals), or null.
isPairTerminatedFinalized.
settlementRuleHow terminateScheduledPair would settle now (see below).
acceptedTerminationAssertionIdThe accepted UMA claim whose cutoff is in force, or null.
umaCutoffCapWhile an insurer vote holds the schedule: the UMA cutoff that caps its settlement samples, or null.
insurerScheduled, insurerScheduleIdWhether an insurer vote set the cutoff, and which.
openInterest{ long, short }: finalize waits for both to reach zero, or for the 14-day escape hatch.
finalizeOpensAtWhen finalizeTermination can be called: now once every position is settled, else 14 days after the fix; null before the fix and after finalize.
openProposalsUnresolved UMA claims and insurer votes on the market.

The terminator keeps no list of proposals, so openProposals (also callable on its own as openProposals(pair, search?)) finds their ids, then reads each one on chain, so the answer reflects the chain rather than the index. Its source says where the ids came from:

sourceWhen
"subgraph"The connection has a subgraph: every page of pending and disputed claims and open votes, each id checked (32 bytes, strictly ascending, at most MAX_OPEN_PROPOSALS = 1,000 of each kind). An answer that breaks any of these throws SubgraphError rather than being half-used.
"logs"No subgraph, and search.fromBlock is given: one eth_getLogs over the terminator from fromBlock to search.toBlock (default: the latest block).
"ids"Only search.ids were given. Ids you pass are always read, whatever else runs.
"none"Nothing to search: both lists are empty.

Other reads:

FunctionReturns
settlementRule(pair)The terminator’s settlementRule, decoded (fields below).
terminationProposal(assertionId)A UMA claim: pair, proposer, lastTradingTs (its cutoff), reason, proposalTs, livenessEndsAt, resolved, category, categoryName; or null.
insurerProposal(id)An insurer vote (see Reading Votes), or null.
requiredTerminationBond()The bond a UMA claim posts now, USDC units: the larger of 1,000 USDC and UMA’s minimum.
terminatorConstants()The terminator’s constants read from the chain: category, outcome, verdict and rule numbers, the bond floor and the live bond, the liveness, the reason length, the sample count and spacing.

A market snapshot also carries the termination stamps (marketSnapshot(...).termination: the cutoff, the fixed price, the ratios, the bad debt) and a status; see Markets.

Ending a Market Through UMA

Proposing

// Validate as the user types: null means the terminator accepts the text.
const reason = "Delisting announced for 2026-12-01: https://example.com/notice?id=42";
const problem = termination.reasonProblem(reason);
if (problem) console.log(problem); // e.g. 'The character "[" is not allowed: ...'

// The four grounds, with a sentence each for a picker.
for (const [id, info] of Object.entries(termination.TERMINATION_CATEGORY_INFO)) {
  console.log(id, info.name, "-", info.description);
}
try {
  const now = await bz.uma.chainNow(); // the chain's clock, not the browser's
  const call = await bz.termination.proposeTermination({
    pair: aapl.address,
    category: termination.TerminationCategory.AssetCessation,
    lastTradingTs: now + 3 * 86_400, // at least 12 hours out, so UMA's acceptance alone enforces it
    reason,
    from: user!,
  });
  const receipt = await sendCosted(call);
  const { assertionId, insurerProposalId } = bz.termination.proposalIdsFromReceipt(receipt);
  console.log(`claim ${assertionId}; its insurer vote has the same id: ${insurerProposalId}`);
} catch (e) {
  console.error(e instanceof uma.BondQuoteError ? e.message : errorMessage(e));
}

proposeTermination(args) posts a claim to UMA’s Optimistic Oracle that the market should end.

ArgumentMeaning
pairThe market.
categoryOne of the four grounds, 1 to 4 (termination.TerminationCategory).
lastTradingTsThe proposed cutoff, unix seconds.
reasonThe grounds and evidence, 1 to 1,000 characters of a restricted charset.
fromThe proposer, who posts the bond.
slackSecondsHow long the transaction may take to land: the 2-hour minimum is checked this far ahead. Default 60.

The categories (termination.TERMINATION_CATEGORY_INFO has a name and a description of each, for a picker):

ValueNameGrounds
1 OracleDecommissionOracle decommissionThe market’s Pyth Pro feed (either leg of a composite) has stopped or is scheduled to stop publishing.
2 AssetCessationAsset cessationThe asset has been or will be delisted (delisting, merger, bankruptcy, an indefinite halt), once officially confirmed with a fixed date.
3 StructuralChangeStructural changeAn officially scheduled event breaks the price series: a split, spinoff, rights offering, special distribution over 5%, redenomination, index re-basing.
4 PythVerifierMigrationPyth verifier migrationThe Pyth Pro verifier contract the market’s oracle uses is deprecated, being replaced, or no longer verifies the updates Pyth distributes.

Market moves of any size are not grounds.

The reason. The terminator splices reason into the claim UMA’s voters read, between fences that its charset makes unforgeable, so it accepts only letters, digits, space and . , & / - : ? = # % _ ~ + (enough for evidence URLs). Every other character, including square brackets and anything non-ASCII, is refused. termination.reasonProblem(reason) returns why a reason would be refused, or null, so a form can check it as the user types. The claim names the market by its pair id and feed ids; the reason cannot change which market it is about.

The bond. The terminator pulls requiredTerminationBond() (at least 1,000 USDC) from the proposer and posts it to UMA as the assertion bond. UMA returns it when the claim is accepted, and pays it to the disputer if UMA’s voters reject the claim. The call’s costs has the bond, and its approvals the USDC approval for the terminator when the allowance is short. If UMA’s acceptance schedules the market, the proposer also earns 0.1% of its insurance fund, at most $100, claimable on the market with claimFees.

The cutoff. It must be at least 2 hours out (plus slackSeconds). Acceptance alone enforces it as proposed only when it is at least 12 hours out (UMA’s liveness), so prefer that. A nearer cutoff is met as proposed only if insurers vote it through an hour before it; otherwise the claim is accepted after it and the cutoff moves to 7 seconds after acceptance (MIN_CUTOFF_LEAD), and the call’s notes say so. For an event that has already happened, propose the earliest cutoff accepted. Once a UMA cutoff is in force, a new claim must name an earlier one. Filing also opens an insurer vote on the same cutoff, under the claim’s assertion id (its window opens 14 days before the cutoff, or at filing).

proposalIdsFromReceipt(receipt) reads the claim’s assertionId (and the insurer vote it opened, insurerProposalId, which is the same id) from the receipt’s terminator events.

The builder refuses up front, with an InputError: a market the terminator does not know, one already ended or with its price fixed, a category outside 1 to 4, a cutoff too soon or not earlier than the UMA cutoff in force, an invalid reason, and UMA’s identifier being off its live whitelist (claims are closed until Bazaar’s identifier upgrade completes; see UMA Disputes & Governance). The insurer-vote and stale-oracle paths still work then.

Settling the Claim

async function settleClaim(assertionId: `0x${string}`): Promise<string> {
  const st = await bz.uma.assertionStatus(assertionId, { home: "terminator" });
  console.log(uma.ASSERTION_PHASE_TEXT[st.phase]);
  if (st.phase === "liveness") return `It can be settled from ${uma.timeText(st.canSettleAt!)}.`;
  if (st.phase !== "settleable" && st.phase !== "disputed") return "Nothing to settle.";
  const call = await bz.termination.settleTerminationProposal({ assertionId, from: user! });
  // A disputed claim settles only once UMA's voters have ruled: the simulation says whether they have.
  await sendCosted(call);
  const tp = await bz.termination.terminationProposal(assertionId);
  return tp?.resolved ? "Settled." : "Not resolved yet.";
}

settleTerminationProposal({ assertionId, from? }) settles the assertion on the oracle it was made on and applies the verdict: an accepted claim schedules the market (or pulls its cutoff earlier, or caps an insurer schedule’s samples); a rejected one leaves the market alone and ends the claim’s vote. It costs only gas, and anyone may send it.

It refuses an unknown or resolved claim, and one still inside UMA’s liveness. A disputed claim settles only after UMA’s voters have ruled; until then the call reverts, so simulate it. Use bz.uma.assertionStatus(id, { home: "terminator" }) to show where the claim stands (liveness with canSettleAt, settleable, disputed, settledTrue, settledFalse), and see UMA Disputes & Governance for disputing a claim and previewing a DVM ruling.

Insurer Votes

An insurer vote ends a market when yes-votes from matured insurance shares reach 60% of the vote’s share snapshot, and someone executes it at least one hour before its cutoff. Every UMA claim opens one; an insurer can also file a bare proposal with no claim behind it.

A Bare Proposal

// A bare proposal: a cutoff 2 hours to 14 days out, a fixed 1,000 USDC bond, no UMA claim behind it.
const now = await bz.uma.chainNow();
const proposal = await bz.termination.proposeInsurerTermination({ pair: aapl.address, cutoffTs: now + 2 * 86_400, from: user! });
const receipt = await sendCosted(proposal);
const id = bz.termination.proposalIdsFromReceipt(receipt).insurerProposalId!;

proposeInsurerTermination({ pair, cutoffTs, from, slackSeconds? }) posts a fixed 1,000 USDC bond (INSURER_TERMINATION_BOND_USDC), held by the terminator until the vote resolves: refunded if the vote schedules the market, is preempted by an earlier cutoff, is voided by a drained fund, or fails having reached 60%; forfeited to the market’s insurance fund if it fails short of 60%.

The cutoff must be 2 hours to 14 days out and earlier than any cutoff the market already carries, and the proposer must hold shares deposited at least 4 days ago. Proposing does not vote the proposer’s shares. A bare vote settles under the chain rule, so its yes-voters pay a levy (below) unless the vote meets a cutoff UMA accepted. The id is fixed by the second the proposal lands; read it with proposalIdsFromReceipt(receipt).insurerProposalId (or compute it with termination.insurerProposalId(pair, proposer, timestamp)).

Voting

try {
  const vote = await bz.termination.voteForInsurerTermination({ pair: aapl.address, id, shares: parseUnits18("100"), from: user! });
  // costs: the lock on the voted shares, and (contingent) the worst-case levy if this vote ends the market.
  for (const c of vote.costs) console.log(`${c.label}: ${uma.sharesText(c.amount)}${c.contingent ? " at worst" : ""}. ${c.returned}`);
  await sendCosted(vote);
} catch (e) {
  console.error(errorMessage(e));
}

voteForInsurerTermination({ pair, id, shares, from }) votes yes with shares (18 decimals). Votes are never withdrawn. The same shares may back several proposals, up to 8 live ones per wallet on a market (INSURER_MAX_LIVE_VOTES, enforced by the contract and listed in requires).

Only shares held now that were deposited at least 4 days before the vote window opened count (termination.maturityCutoff(windowOpenTs) gives that instant), less what the wallet has already voted on this proposal. The builder refuses more than that, with the limit in the message.

The call’s costs describe what voting puts at stake:

  • a lock: the voted shares cannot be withdrawn from the insurance fund until the proposal’s cutoff or the market’s, whichever is first (a failed proposal releases them sooner only while the market has no cutoff); if this vote ends the market and its outcome is costed, until 13 days after the settlement price is fixed (INSURER_POST_FIX_LOCK);
  • a levy, contingent, at its worst case: shares forfeited at redemption to Bazaar’s bug bounty address if this vote ends the market.
Outcome (stamped at the fix)What the yes-voters owe
true: UMA upheld the claim behind the voteNothing; it settles under UMA’s rule.
false: UMA rejected the claimThe 13-day lock, and half of every voted share (INSURER_LEVY_BP = 5,000).
unjudged: a bare vote, or no verdict within 14 days of the claimThe 13-day lock, and half the dissent fraction of the voted shares, at least a tenth (termination.unjudgedLevyBp(snapshot, yes)), unless the vote met a cutoff UMA had accepted.

The notes also say how many shares the vote needs, how many it has with yours, and whether yours meet the threshold. termination.OUTCOME_TEXT has a sentence for each outcome.

Executing

async function executeVote(voteId: Hex): Promise<string> {
  const call = await bz.termination.executeInsurerTermination({ pair: aapl.address, id: voteId, from: user! });
  // outcome: "schedule" | "preempted" | "voided" | "failedRefund" | "failedForfeit"
  console.log(call.outcome, call.notes[0]);
  await sendCosted(call);
  return call.outcome;
}

console.log(await executeVote(id).catch(errorMessage));
// The levy an unjudged outcome would charge, for a vote that ended at 70% yes:
console.log(`${termination.unjudgedLevyBp(1_000n, 700n)} bp`); // 1500 bp: half the 30% that did not vote yes

A vote takes effect only when someone executes it. executeInsurerTermination({ pair, id, from? }) resolves it; anyone may send it, and it costs only gas. Its extra outcome field (and notes[0]) says what it will do now:

outcomeWhenA bare vote’s bond
scheduleAt or above 60% before the latch closes: the market is scheduled to stop at the cutoff, or its schedule pulled earlier.Refunded
preemptedThe price is already fixed, or a cutoff at or before this one is already scheduled.Refunded
voidedThe fund was drained and recapitalized since the snapshot.Refunded
failedRefundThe latch closed (one hour before the cutoff) without scheduling, having reached 60%.Refunded
failedForfeitThe latch closed short of 60%.Forfeited to the insurance fund

A claim-backed vote’s bond is UMA’s, so none of these moves one. The builder refuses an unknown or resolved vote, and one still open below its threshold (the contract reverts until the vote reaches 60% or its latch closes).

Reading Votes

const p = await bz.termination.insurerProposal(id);
if (p) {
  console.log(`${p.state}: ${uma.sharesText(p.yesShares)} of ${uma.sharesText(p.thresholdShares)} needed`);
  console.log(`voting ${uma.timeText(p.windowOpenTs)} to ${uma.timeText(p.votingClosesAt)}; cutoff ${uma.timeText(p.cutoffTs)}`);
  console.log(p.claimBacked ? "Backed by a UMA claim." : "A bare vote: it settles under the chain rule.");
}
const mine = await bz.termination.getLockedShares({ pair: aapl.address, user: user! });
console.log(`your shares locked by live votes: ${uma.sharesText(mine)}`);

insurerProposal(id) returns the vote with its state judged at the chain’s time:

stateMeaning
upcomingIts window has not opened (a claim filed more than 14 days before its cutoff).
votingOpen for votes and, once at the threshold, for execution.
expiredThe latch closed without the vote scheduling the market; executing it now resolves it as failed.
scheduledIt reached the threshold and set the market’s cutoff.
rejectedUMA rejected the claim behind it before it scheduled anything.
endedResolved otherwise (failed, preempted or voided); the events say which.

Its other fields: proposer, proposalTs, shareEpoch, cutoffTs, windowOpenTs, votingClosesAt (the cutoff less one hour), yesShares, snapshotTotalShares and snapshotTaken (the snapshot is taken at the first touch once the window is open), thresholdShares and thresholdMet, claimBacked, resolved, scheduled, costed, verdict/verdictName, outcome/outcomeName/outcomeText, and levyBp (stamped with the outcome).

getInsurerVote({ pair, voter, id }) is the shares a voter has in one vote, and getLockedShares({ pair, user }) the shares voted into live proposals, which an insurance withdrawal cannot take (the exit plan reports them as lockedForVoting).

Fixing the Settlement Price

Stale Oracle

try {
  // Only for a market whose oracle has been silent for more than 21 days.
  await simulateAndSend(await bz.termination.terminateStalePair({ pair: aapl.address, from: user! }));
} catch (e) {
  console.log(errorMessage(e)); // e.g. "pair: This market stored a price within the last 21 days: ..."
}

terminateStalePair({ pair, from? }) ends a market that has stored no price for more than 21 days (ORACLE_DEAD_THRESHOLD) at its last stored price, or at a nominal price of 1 when it never stored one (then aged from its creation). Anyone may call it; it pays nothing and costs only gas. The pair’s own stored price is the evidence: a pair stores one whenever a call lands on it with a fresh update, so one refreshPrice restarts the 21 days.

It refuses a market the terminator does not know, one with its price fixed, one whose vote-set cutoff has passed (that market settles through terminateScheduledPair, whose own fallback reaches the stored price), and one that stored a price within 21 days, with the time the path opens.

At a Scheduled Cutoff

const plan = await bz.termination.terminateScheduledPair({ pair: aapl.address, from: user! });
if (plan.call) {
  console.log(`settles under ${plan.under} at ${formatUnits18(plan.settlementPrice!)} with ${plan.updates.length} update(s)`);
  for (const n of plan.call.notes) console.log(n);
  // Already simulated as `from`; its value is the Pyth fee (plan.call.costs), any excess refunded.
  await bz.waitForReceipt(await bz.send(window.ethereum, plan.call));
} else {
  console.log(plan.reason);
  for (const a of plan.attempts) console.log(`${a.under}: ${a.ok ? "accepted" : a.reason}`);
  if (plan.retryAt !== null) console.log(`try again from ${uma.timeText(plan.retryAt)}`);
}

terminateScheduledPair({ pair, from, fetchTick?, estimateGas? }) fixes a scheduled market’s price with archived Pyth Pro updates. Pyth keeps no prices on chain, so every settlement price is submitted as a signed update and verified on the spot. All of them come from Pyth’s one-second channel (fixed_rate@1000ms), which issues one update per second, stamped on the second, and whose archive serves the update for any past second: the connection’s price source fetches them (atTick; without a Pyth Pro source the builder throws ConnectError). It returns a ScheduledSettlementPlan whose call is one the contract accepted in simulation as from, or null with the reason.

It reads settlementRule(pair), whose fields are:

FieldMeaning
rule, key0 nothing, 1 tick, 2 chain, 3 awaitVerdict.
meaningA sentence for the rule (termination.SETTLEMENT_RULE_TEXT).
firstTargetThe cutoff: the second whose update settles the market under UMA’s rule, the first sample’s target under the chain rule.
latestPublishTsUnder UMA’s rule the cutoff again; under the chain rule the latest second a sample’s update may be stamped with, null when unbounded.
lastResortTsAfter this a call with no updates settles at the stored price; null when not applicable.
verdictDeadlineWhile waiting for a verdict: when the vote stops waiting and settles unjudged.

and builds the set the rule needs:

RuleUpdatesFee
tick (UMA’s rule)The update stamped at the cutoff, carrying every feed leg: one update for both legs of a composite, or one per leg when the archive will not answer for them together. The market settles at the price it carries, whenever that price was generated: a quiet feed’s update carries its last price forward.getUpdateFee(updates.length)
chainFive samples. Each is the earliest second whose update carries a price generated at or after the sample’s target: the target’s own update when it does, otherwise two updates, the quiet second before and that second. The first target is the cutoff, each later one 600 s past the second of the sample before (the later leg’s, for a composite); every sample’s second no later than latestPublishTs. Per sample the base leg’s one or two updates come first, then the quote leg’s.getUpdateFee(updates.length)
awaitVerdictNothing yet: the vote that set the cutoff waits for UMA’s verdict on its claim.
nothingNothing to settle (no cutoff, or the price is fixed).

Finding a chain sample. termination.chainSample fetches the update stamped at the target. If its price was generated at or after the target, that one update is the sample. If not, the feed was quiet there, and since a price’s generation time never goes backwards from one second to the next, the first second with a new price is found by doubling the step from the target and then bisecting: a reopen days later takes a few dozen requests. The targets cannot be computed ahead, so the five samples are searched in sequence, and the last exists only once its price does, at least 40 minutes after the cutoff. A sample that would land past latestPublishTs, or that the feed has not produced yet, makes the builder return no call and say so.

Proving the set. Each set is verified by calling the pair’s oracle exactly as the terminator does (fetchPriceAtTick or fetchChainedMedian, as from, with the fee), and only a set the oracle accepts is offered. The price it returns is the plan’s settlementPrice. This matters once the stored-price fallback is open: the terminator then settles at the stored price instead of reverting on a refused set, so the contract’s simulation alone would accept bad updates and the call would be labelled with a price it does not fix.

Verdicts. settlementRule cannot see a verdict UMA’s DVM has reached on the claim behind a vote until someone records it, but terminateScheduledPair records it itself. For a disputed claim the builder previews the DVM’s ruling (uma.previewSettlement) and builds that rule’s set: UMA’s rule if the claim was upheld (whose 12-hour grace starts at this recording, so no fallback yet), the chain rule if not. When the ruling cannot be previewed, it builds both sets and returns the one the contract accepts. A claim still in its liveness returns no call, with retryAt set to the end of the liveness.

The last resort. Past lastResortTs, a call with no updates settles the market at its stored price, for good (12 hours after the cutoff or the verdict under UMA’s rule; 21 days after the cutoff under the chain rule, or 12 hours after a capping UMA cutoff, termination.chainLastResort). It is tried only when no proven set is accepted, and when it is offered depends on storedPrice:

storedPriceThe stored-price call is offered
"whenAbsent" (default)Only when every set failed because Pyth’s archive holds no usable update (none for the second, no price in it, no chain under its bound or none yet), and the archive is shown to work: it must serve ETH/USD’s one-second update from a minute before the chain’s time, or its “none” is not believed. A request that timed out or was refused, an archive at a wrong URL, a signer check that failed, and a set the oracle refuses may all hide a proven price, so the call is withheld and the plan says why.
"always"Whenever no proven set is accepted, for a caller who has decided. It needs no price source.
"never"Never.

The plan:

FieldMeaning
callThe CostedCall to send, or null. Its value is the Pyth fee in ETH (in costs; any excess is refunded); its notes state the price and the rule.
under"tick", "chain" or "storedPrice".
updatesThe call’s priceUpdate.
printsThe updates as read, feed by feed (feedId, update, tick, priced, price, generatedUs, publishTime): in calldata order under the chain rule, one per leg under UMA’s rule.
settlementPriceThe price the call fixes (18 decimals).
attemptsEach candidate tried: under, ok, reason, kind ("absent": the archive showed no proven settlement exists now; "unreachable": it could not be asked or relied on; "refused": the oracle or the contract refused the set; null when it was accepted), the decoded error, price.
reasonWhy there is no call.
retryAtWhen to try again, when known (the verdict, the fallback).
storedPriceWithheldWhy the stored-price call is not offered although its last resort is open; null when it is offered or not open.
rule, scheduledTerminationTs, nowWhat it was judged against.

The builder throws an InputError for a market the terminator does not know, one with its price fixed, one with no cutoff, and one whose cutoff has not passed yet. Updates are fetched with termination.tickFetcher(source); pass fetchTick (a TickFetcher: (feedIds, second) => Promise<ProUpdate | null>) to use another archive. The building blocks are exported too: tickPrints, chainSample, chainPrints, settlementPrint, generatedAtOrAfter, and SettlementPrintError for an update that cannot be fetched or a sample that cannot be proven, whose kind is "absent" or "unreachable" as in attempts. A price at or below zero, at the cutoff or in a chain sample, cannot settle the market: the oracle refuses it.

No keeper job fixes prices on its own; the keeper’s operator can run terminateStalePair and terminateScheduledPair as manual operations (see Manual Operations).

The Settlement Window

Once the price is fixed, trading, ADL, deposits and all withdrawals stay frozen while every open position is settled at it. Settling is the pair’s liquidate(accounts, []): anyone may call it, with no price update and no ETH, and settling someone else’s position earns a $0.05 tip, claimable with claimFees. Settling your own is free (a position also settles on its owner’s first withdrawal after finalize).

The SDK has no builder for this call; encode it with the pair’s codec:

// The SDK has no builder for settling positions in the settlement window: encode the pair's
// liquidate(accounts, []) directly. No price update and no ETH; each position you settle for
// someone else earns a $0.05 tip, claimable with claimFees.
async function settleOpenPositions(): Promise<void> {
  const accounts: Address[] = [];
  let after: string | null = null;
  do {
    const page = await bz.history.positions({ pair: aapl.address, openOnly: true, first: 1000, after });
    accounts.push(...page.items.map((p) => p.account));
    after = page.next;
  } while (after);
  for (let i = 0; i < accounts.length; i += 100) {
    const batch = accounts.slice(i, i + 100); // the keeper's default batch for this job
    await simulateAndSend({
      action: "liquidate",
      to: aapl.address,
      data: pairCodec().encode("liquidate", batch, []),
      value: 0n,
      from: user!,
      summary: `Settle ${batch.length} position(s) at the settlement price`,
    });
  }
}
if (s.phase === "settling") await settleOpenPositions();

The sample finds open positions in Bazaar’s subgraph (bz.history.positions, see History). The keeper’s terminalSettlement job does the same from its own index, then finalizes; see The Jobs.

Finalizing

try {
  await simulateAndSend(await bz.termination.finalizeTermination({ pair: aapl.address, from: user! }));
} catch (e) {
  console.log(errorMessage(e)); // e.g. positions still open before the 14-day escape hatch
}

finalizeTermination({ pair, from? }) retires the vault’s inherited inventory and freezes the payout ratios. Anyone may call it once every position is settled (open interest is zero) or, as the escape hatch, 14 days after the fix (TERMINAL_SETTLEMENT_ESCAPE); it costs only gas. It refuses a market with no price fixed, one already finalized, and one with positions still open before the escape hatch (with the time it opens).

Finalizing through the escape hatch with positions open is allowed, but a bankrupt position settled afterwards can lower the ratios for everyone still in, and insurance redemptions wait until those positions are settled. Settle first when you can.

Withdrawing After Termination

const ent = await bz.termination.getTerminalEntitlement({ pair: aapl.address, account: user! });
if (!ent.finalized) {
  console.log("Not finalized yet: the payout ratios are not frozen.");
} else if (ent.payout > 0n) {
  console.log(`principal ratio ${formatUnits18(ent.principalRatio * 100n, 2)}%, profit ratio ${formatUnits18(ent.profitRatio * 100n, 2)}%`);
  console.log(`withdrawing everything pays ${formatUnits18(ent.payout, 2)} USDC`);
  // withdrawCollateral takes collateral units; the largest amount it accepts is the preview's maximum.
  const w = await bz.previews.previewWithdrawal(aapl.address, user!);
  await simulateAndSend(await bz.trade.withdrawCollateral(aapl.address, user!, w.maxWithdrawable));
}
// Tips and rewards (settlement tips, a UMA proposer's reward) are claimed separately:
await simulateAndSend(await bz.trade.claimFees(aapl.address, user!));

getTerminalEntitlement({ pair, account }) reads what the market pays an account:

FieldMeaning
collateralThe account’s collateral.
registeredClaimIts profit claim (a winner settled before finalize).
principalRatio, profitRatioCash per unit of collateral and of claim (1e18 = 100%); 0 before finalize.
payoutcollateral × principal ratio + claim × profit ratio: what withdrawing everything pays now.
finalizedtrue once finalize has run.

Before finalize the ratios, and so the payout, read 0: show that as “not frozen yet”, not “worthless”. A position still open is not included until it is settled.

Withdraw with bz.trade.withdrawCollateral(pair, user, amount) as on a live market. The amount is in collateral units, the first withdrawal credits the registered claim into collateral, and the wallet receives the amount times the principal ratio; the call’s summary says what it pays, and a withdrawal that would pay nothing is refused. bz.previews.previewWithdrawal(pair, user) returns the largest amount accepted (maxWithdrawable) and the entitlement (terminal). Settlement tips and a UMA proposer’s reward are separate, in the fee balance (claimFees). Insurance LPs redeem through the insurance exit.

Events

termination.decodeTerminatorLog(log) decodes a terminator log (or returns null for another contract’s), and termination.TERMINATOR_EVENT_TEXT has a sentence for each event, for an activity feed. Every UMA claim ends in exactly one of TerminationProposalAccepted, TerminationScheduleRefused, TerminationProposalSuperseded or TerminationProposalRejected. For indexed history (terminationProposals, insurerProposals, insurerVotes, insurerLevies, pairTerminations, terminalRatioUpdates) see History. Live Updates reads the terminator’s logs alongside each watched market’s.

Constants

ConstantValue
TERMINATION_PROPOSAL_BOND_USDC1,000 USDC (the floor; the bond posted is requiredTerminationBond())
TERMINATION_PROPOSAL_LIVENESS12 hours
MAX_REASON_LENGTH1,000
MIN_CUTOFF_LEAD7 s (a late acceptance’s cutoff)
INSURER_TERMINATION_MIN_LEAD2 hours
INSURER_TERMINATION_LATCH_LEAD1 hour
INSURER_TERMINATION_MAX_LEAD14 days
INSURER_TERMINATION_THRESHOLD_BP6,000 (60%)
INSURER_TERMINATION_BOND_USDC1,000 USDC
INSURER_SHARE_MATURITY_PERIOD4 days
INSURER_MAX_LIVE_VOTES8
INSURER_LEVY_BP, INSURER_LEVY_FLOOR_BP5,000 and 1,000
CLAIM_VERDICT_GRACE14 days
INSURER_POST_FIX_LOCK13 days
SCHEDULED_TERMINATION_GRACE12 hours
NEVER_PRICED_SETTLEMENT_PRICE1, the smallest price in 18 decimals (a market that never stored a price)

These are on the termination namespace; LIMBO_LEAD, ORACLE_DEAD_THRESHOLD, TERMINAL_SETTLEMENT_ESCAPE, SETTLEMENT_SAMPLE_COUNT and SETTLEMENT_SAMPLE_SPACING are exported at the top level. See Units & Constants.

What Can Go Wrong

Builder refusals are InputErrors naming the argument; errorMessage(e) gives the sentence. A BondQuoteError, a SettlementPrintError, a SubgraphError or a ConnectError carries its own message: show that (errorMessage does not recognize them). Reverts a simulation can show:

ErrorMeaning
BazaarPairTerminator__LastTradingTsTooSoon, InsurerCutoffTooSoon, InsurerCutoffTooLateThe cutoff is outside its bounds; the time moved on while the user decided. Rebuild.
BazaarPairTerminator__CutoffNotEarlier, InsurerCutoffNotEarlierAn earlier or equal cutoff is already in force.
BazaarPairTerminator__UmaIdentifierNotLiveUMA claims are closed until the identifier upgrade.
BazaarPairTerminator__InsurerNoMatureShares, InsurerInsufficientUnlockedSharesNot enough shares held 4 days before the window opened.
BazaarPairTerminator__InsurerThresholdNotMetThe vote is below 60% and still open.
BazaarPairTerminator__InsurerTooManyLiveVotesThe wallet backs 8 live votes on this market.
BazaarPairTerminator__InsurerVotingClosed, InsurerVoteNotOpenOutside the vote’s window.
BazaarPairTerminator__InsurerVerdictPendingThe claim behind the vote has no verdict yet.
BazaarPairTerminator__OracleNotStaleEnoughThe market stored a price within 21 days.
BazaarPairTerminator__TerminationTimeNotReachedThe cutoff has not passed.
BazaarPairTerminator__InsufficientPythFeeNot enough ETH for the updates’ fee. Rebuild.
BazaarOracle__SampleCountMismatchThe chain set does not hold one update per leg for each of five samples.

Listing Markets

Anyone can list a new market on Bazaar. There is no vote and no whitelist: the proposer posts a USDC bond to UMA’s Optimistic Oracle with a claim that the asset meets Bazaar’s listing criteria, plus an insurance seed for the new market. If nobody disputes the claim within 48 hours, anyone can settle it and the factory deploys the market. The listings area builds every call in that flow and reads where a proposal stands.

Use this page to build a “propose a market” screen, a tool that settles listings when their liveness ends, or a dashboard of pending listings. What UMA’s verifiers check, and why the rules are what they are, is in Bazaar’s Markets & Listing page; this page covers the SDK side. Disputing a listing is a generic UMA action, covered in UMA Disputes & Governance.

The samples assume bz is connected as in Connecting with the user’s wallet as the transport, and user is the wallet’s address.

The Flow

  1. If the asset is not quoted in USD, register its composite feed on Bazaar’s oracle (registerComposite). A USD-quoted asset uses its Pyth Pro feed ID directly.
  2. Propose it (proposePairDeployment). The factory pulls totalAmount USDC from the proposer: the UMA bond (requiredDeploymentBond(), at least 1,000 USDC) is posted to UMA with the proposer as asserter, and the rest (at least 3,000 USDC) is escrowed as the market’s insurance seed.
  3. For 48 hours anyone may dispute the claim (uma.disputeAssertion, see Disputing an Assertion).
  4. Settle it (settleDeploymentProposal) once UMA can resolve it. Anyone may call it.
    • Undisputed: the pair is deployed with the seed as its insurance fund, the proposer receives the seed as insurance shares, and UMA returns the bond.
    • Undisputed, but UMA has since removed the identifier it was asserted under: the listing is voided. No pair is deployed, UMA returns the bond and the seed is credited back.
    • Disputed: UMA’s voters decide. Approved, it deploys as above. Rejected, the bond goes to the disputer and the seed is credited back.
  5. A seed credited back is collected with claimSeedRefund.
  6. A listing whose assertion cannot settle at all can be released with expireStuckDeploymentProposal, which frees the asset’s listing slot.

One market per asset: the factory refuses a proposal while a live (not terminated) market prices the same raw Pyth Pro feed, or while another proposal over that feed is pending. For a composite the raw feed is its base leg, so the same stock through a different FX leg counts as the same asset.

The Constants

const c = await bz.listings.deploymentConstants();
console.log(`deposit at least ${formatUnits18(c.minDeploymentAmount)} USD`); // "4000"
console.log(`the bond posted now: ${uma.usdcText(c.requiredDeploymentBondUsdc)}`); // "1,000 USDC" or UMA's minimum if higher
console.log(`liveness: ${uma.durationText(c.deploymentLiveness)}`); // "2 days"

deploymentConstants() reads the factory’s listing constants in one batched call:

FieldUnitValueMeaning
minDeploymentAmount18 decimals$4,000The least totalAmount a listing takes.
deploymentBondUsdcUSDC, 6 decimals1,000 USDCThe bond’s floor.
requiredDeploymentBondUsdcUSDC, 6 decimalsliveThe bond a listing posts now: the larger of the floor and UMA’s minimum bond for USDC.
deploymentLivenessseconds48 hoursUMA’s liveness: how long the claim can be disputed.
dvmDisputeGraceseconds14 daysExtra wait before a disputed listing that cannot settle may be released.
minInsuranceSeed18 decimals$3,000The least seed left after the bond. Not exposed on chain; the SDK’s constant.
maxDescriptionLengthbytes200The longest description.

The same values are exported as constants (listings.MIN_DEPLOYMENT_AMOUNT, listings.DEPLOYMENT_BOND_USDC, listings.DEPLOYMENT_LIVENESS, listings.DVM_DISPUTE_GRACE, listings.MIN_INSURANCE_SEED, listings.MAX_DESCRIPTION_LENGTH). Only the bond moves: quote requiredDeploymentBond() (or requiredDeploymentBondUsdc) rather than assuming 1,000 USDC, since it tracks UMA’s minimum upward.

Note the units: totalAmount and the seed constants are in Bazaar’s 18-decimal precision (parseUnits18("4000") is $4,000), while the bond and anything named ...Usdc are in USDC’s 6 decimals. See Units & Constants.

The Description

The description is the one proposer-written string in the claim UMA’s voters read, so the factory accepts only 1 to 200 bytes of letters, digits, space and . , & / -. Colons, quotes, brackets, accents and every other non-ASCII character are refused. listings.descriptionProblem(text) applies the same rule and returns a sentence for the user, or null when the text is accepted, so a form can check it as the user types:

// Check the description as the user types: null means the factory accepts it.
const problem = listings.descriptionProblem("Apple Inc. common stock on NASDAQ");
if (problem) console.log(problem);

Descriptions come back from the chain and the subgraph as plain strings written by whoever proposed the listing. Render them as text, never as HTML (see Security Notes for Integrators).

Composite Feeds

A Pyth Pro feed ID is a number (BTC/USD is 1), listed in Pyth Pro’s symbols registry. The contracts hold it in a bytes32, and every builder here takes either form: 1, "1", or 0x0000…0001 (listings.asFeedId converts). Pyth Pro’s IDs are a different namespace from Pyth Core’s 32-byte IDs, which the factory refuses.

An asset quoted in another currency (DAX in EUR, say) trades against a composite feed: the asset’s own feed times or divided by an FX feed. The composite is registered on Bazaar’s oracle, permissionlessly, and its id (a hash of its legs) is then used wherever a feed id is expected:

// A non-USD asset trades against a composite: DAX (in EUR) x EUR/USD. Register it once, then list
// the composite id. EUR/USD is CCY/USD, so the FX leg multiplies: invertQuote is false.
const id = listings.compositeId(DAX_EUR_FEED, EUR_USD_FEED, false);
if ((await bz.listings.getComposite(id)) === null) {
  const reg = await bz.listings.registerComposite({ baseId: DAX_EUR_FEED, quoteId: EUR_USD_FEED, invertQuote: false, from: user });
  await bz.waitForReceipt(await bz.send(window.ethereum, reg));
}
// `id` (also `reg.compositeId`) is now the baseFeedId to propose, with isContinuouslyTraded: false.
FunctionWhat it does
listings.compositeId(baseId, quoteId, invertQuote)keccak256(baseId ‖ quoteId ‖ invertQuote), the id the composite is registered and listed under. Pure; no connection needed.
getComposite(id)The legs (baseId, quoteId, invertQuote) id resolves to on the oracle, or null when it is not a registered composite.
registerComposite({ baseId, quoteId, invertQuote, from? })A CostedCall (plus compositeId) that registers it. Costs only gas and is idempotent: when the composite is already registered, notes says the call changes nothing.
rawBaseFeed(baseFeedId)The raw Pyth Pro feed a listing prices: the feed itself, or a composite’s base leg. This is the key one-market-per-asset is enforced on.
listings.asFeedId(field, value)The bytes32 feed ID for a feed number, decimal digits or a bytes32; an InputError on field for anything else. Pure.

invertQuote is false when the FX feed is CCY/USD (the price multiplies, as EUR/USD does) and true when it is USD/CCY (the price divides, as USD/JPY does). registerComposite refuses, as the oracle does, a leg that is not a raw Pyth Pro feed ID (which rules out nesting one composite in another) and the same feed twice. Register before proposing: the factory refuses an ID that is neither a raw feed ID nor a registered composite. A composite market is never continuously traded, because its FX leg does not trade around the clock.

Proposing a Market

proposePairDeployment builds the proposal and returns a CostedCall whose approvals must be sent first. A small helper sends them, then simulates and sends the call:

/** Send a call's approvals, then simulate the call itself and send it. */
async function sendWithApprovals(call: PreparedCall & { approvals: readonly PreparedCall[] }) {
  for (const approval of call.approvals) {
    await bz.waitForReceipt(await bz.send(window.ethereum, approval));
  }
  // Simulate only after the approvals have landed: before, transferFrom would revert.
  const sim = await bz.simulate(call);
  if (!sim.ok) throw new Error(sim.error!.message);
  return bz.waitForReceipt(await bz.send(window.ethereum, call));
}

Simulate only after the approvals have landed: until then the factory’s transferFrom would revert, and the simulation would report that rather than the real outcome.

try {
  const call = await bz.listings.proposePairDeployment({
    baseFeedId: AAPL_USD_FEED,
    isContinuouslyTraded: false, // a stock has sessions; true only for 24/7 assets (crypto)
    totalAmount: parseUnits18("5000"), // the bond plus the seed, 18 decimals
    description: "Apple Inc. common stock on NASDAQ",
    from: user,
  });

  // Show the user what this costs and what happens next before asking for a signature.
  console.log(call.summary);
  for (const cost of call.costs) console.log(`${cost.label}: ${uma.usdcText(cost.amount)}. ${cost.returned}`);
  for (const line of [...call.requires, ...call.notes]) console.log(line);

  const receipt = await sendWithApprovals(call);
  const listing = bz.listings.listingFromReceipt(receipt);
  console.log(`assertion ${listing?.assertionId}, pair id ${listing?.pairId}`);
} catch (e) {
  console.error(errorMessage(e)); // an InputError ("Another listing of this asset is in UMA's review until ..."), a revert, a rejection
}
ArgumentTypeMeaning
baseFeedIdnumber | string | HexA Pyth Pro feed ID (its number, or the bytes32 holding it), or a composite id registered on Bazaar’s oracle. It becomes the market’s pair id.
isContinuouslyTradedbooleantrue only for assets that trade around the clock (crypto); false for anything with sessions, and always for a composite. The flag decides the market’s margin and stale-price rules (Price Oracle).
totalAmountbigint, 18 decimalsThe whole deposit: the bond plus the seed. At least $4,000, and enough that the seed is at least $3,000 after the live bond. Floored to whole micro-USDC; the factory pulls only that.
descriptionstringWhat the asset is, e.g. "AAPL on NASDAQ". 1 to 200 bytes of the allowed characters.
fromAddressThe proposer: who pays, who is the UMA asserter, and who receives the seed as insurance shares.
registryURL, source or nullWhere to look the feeds up. Default: the symbols registry of the connection’s Pyth Pro client. null skips the check.

The registry check. The chain cannot see whether Pyth publishes a feed under an ID: that is the first thing the listing’s claim asks UMA to verify, and a listing of a feed that does not exist is disputed and loses its bond. So the builder looks each feed (both legs of a composite) up in Pyth Pro’s symbols registry when one is at hand, refuses an ID the registry does not list, and says in notes what it lists each feed as (symbol, asset type, state, fastest channel, schedule) so you can compare it with the description. Where the connection has no Pyth Pro source, or the registry cannot be read, the check is skipped; when it could not be read, notes says so.

Besides the CostedCall fields, the result carries pairId (the bytes32 baseFeedId), bondUsdc and seedAmountUsdc (both 6 decimals), pullsUsdc (18 decimals), and legFeeds (each leg’s registry entry, or null where it was not looked up). Its two costs are:

kindlabelComes back
bondUMA listing bondReturned by UMA when the listing is approved (deployed or voided); paid to the disputer if UMA’s voters reject it.
seedInsurance seedApproved, it becomes the market’s insurance fund and the proposer holds it as insurance shares, which absorb the market’s losses and redeem after the 13-day withdrawal cooldown (Insurance). Rejected, voided or released, it is credited back for claimSeedRefund.

notes says when the dispute window ends, how many insurance shares the seed will become, and whether the call will first settle or void another listing of the same asset. When the wallet already allows the factory more than the call pulls, a warning in notes says so: the contract takes what it charges when the call runs, up to the allowance, so a UI that wants the quote to be a cap should approve exactly the amount first. approvals holds one exact-amount USDC approval for the factory when the allowance is short, and requires lists a balance shortfall rather than throwing, since the user may top up before sending.

After the receipt, listingFromReceipt(receipt) returns the assertionId and pairId from the factory’s PairDeploymentProposed event (or null when the receipt has none). Keep the assertion id: every later call about the proposal takes it.

What proposePairDeployment Refuses

Each of these throws an InputError naming the field, before anything is built. errorMessage(e) returns its message.

FieldWhy
baseFeedIdNot a feed number or 32 bytes of hex, or zero.
totalAmountNot a bigint; below $4,000; not above the live bond; or leaving a seed below $3,000 once the live bond is taken (the message says how much to deposit).
descriptionFails descriptionProblem.
isContinuouslyTradedtrue for a composite.
baseFeedIdNeither a raw Pyth Pro feed ID nor a registered composite (a Pyth Core ID or an unregistered composite id lands here): for a non-USD asset, register its composite and list that.
baseFeedIdPyth Pro’s symbols registry lists no feed with this ID.
baseFeedIdUMA’s identifier is off its live whitelist: listings are closed until Bazaar’s identifier upgrade completes.
baseFeedIdA live market for this feed or this asset already exists (the message names its address). A terminated market does not block.
baseFeedIdAnother listing of the asset is in UMA’s review (with the time it ends); has passed review and deploys when settled; was disputed and approved by UMA’s voters; or is disputed and not yet ruled on.
fromNot a valid address.

A bond quote below the factory’s 1,000 USDC floor can only be a wrong answer from the node (an RPC returning 0, say). The SDK then throws uma.BondQuoteError and builds nothing, since an approval sized to a wrong quote would be wrong too. errorMessage does not recognize this class and returns its generic sentence, so show e.message for it.

If the call reverts anyway (the state changed between building and sending), the simulation’s error.message explains it, for example Factory__ProposalAlreadyPending (“Another listing proposal for this asset is still pending. Wait for it to resolve.”) or Factory__InvalidFeedId. See Errors & Messages.

Where a Proposal Stands

const p = await bz.listings.deploymentProposal(assertionId);
if (!p) {
  console.log("The factory has no listing with this id.");
} else {
  switch (p.status) {
    case "inReview":
      console.log(`Disputable until ${uma.timeText(p.livenessEndsAt)}.`);
      break;
    case "settleable":
      console.log("Past its liveness without a dispute: anyone can settle it now.");
      break;
    case "disputed":
      console.log("Disputed: UMA's voters decide it.");
      break;
    case "deployed":
      console.log(`Deployed as pair id ${p.pairId}.`);
      break;
    case "notDeployed":
      console.log("Rejected, voided or released: the seed was credited back.");
      break;
  }
}

// The listing pending for an asset, by pair id or by feed (which also finds a composite of the same asset).
const pending = await bz.listings.pendingDeployment({ baseFeedId: pairId });

deploymentProposal(assertionId) reads the factory’s record and returns null for an unknown id. pendingDeployment({ pairId }) or pendingDeployment({ baseFeedId }) returns the listing pending for an asset, or null; by baseFeedId it looks under the raw feed, so it also finds a composite of the same asset through another FX leg.

FieldMeaning
assertionIdThe UMA assertion id.
deployerThe proposer.
baseFeedId, pairIdThe feed (or composite) listed, and the pair id the market will have (the same value).
isContinuouslyTradedThe flag proposed.
seedAmount, seedAmountUsdcThe escrowed seed, in 18 decimals and in USDC units.
descriptionThe proposer’s text. Render it as text.
proposalTs, livenessEndsAtWhen it was proposed, and when UMA’s liveness ends (proposal time plus 48 hours).
resolved, deployed, disputedThe factory’s flags.
statusOne of the statuses below.
releaseOpensAtWhen expireStuckDeploymentProposal may release it: the end of liveness, plus 14 days if disputed.
statusMeaning
inReviewIn UMA’s liveness: disputable until it ends.
settleablePast liveness without a dispute: settling it deploys the market (or voids it, if UMA has since removed its identifier).
disputedUMA’s voters decide it. Use uma.previewSettlement to see whether they have ruled.
deployedThe market exists.
notDeployedRejected, voided or released; the seed was credited to seedRefundOwed.

The proposal’s UMA assertion itself (bond, asserter, dispute window) is read with uma.assertionStatus.

Every Open Listing

openListings(search?) returns every listing proposal not yet resolved (inReview, settleable or disputed), oldest first, with the same fields as deploymentProposal:

// fromBlock is used only without a subgraph: no listing can be older than the factory's deployment block.
const open = await bz.listings.openListings({ fromBlock: 250_000_000 });
for (const l of open.listings) {
  console.log(l.description, l.status, uma.timeText(l.livenessEndsAt)); // render the description as text, never HTML
}

The factory keeps proposals in maps keyed by pair id and by feed, which no call can list, so openListings finds their ids first, then reads each one on chain: the answer reflects the chain rather than the index, and a proposal the subgraph still shows as pending after the chain resolved it is left out. Its source says where the ids came from:

sourceWhen
"subgraph"The connection has a subgraph: every page of pending and disputed listings, each id checked (32 bytes, strictly ascending, at most MAX_OPEN_LISTINGS = 1,000). An answer that breaks any of these throws SubgraphError rather than being half-used.
"logs"No subgraph, and search.fromBlock is given: one eth_getLogs over the factory from fromBlock to search.toBlock (default: the latest block), keeping its PairDeploymentProposed events.
"ids"Only search.ids were given. Ids you pass are always read, whatever else runs.
"none"Nothing to search: listings is empty.

Without a subgraph, scan from the factory’s deployment block (or later): no listing is older. More than MAX_LISTING_IDS = 10,000 ids in all throws an InputError on fromBlock: scan from a later block.

Settling a Listing

try {
  const settle = await bz.listings.settleDeploymentProposal({ assertionId, from: user });
  console.log(settle.notes[0]); // what settling will do: deploy, void, or wait on UMA's voters
  const sim = await bz.simulate(settle);
  if (!sim.ok) console.error(sim.error!.message); // a disputed listing the DVM has not ruled on yet
  else await bz.waitForReceipt(await bz.send(window.ethereum, settle));
} catch (e) {
  console.error(errorMessage(e)); // "assertionId: UMA's liveness window is still open: ..."
}

settleDeploymentProposal({ assertionId, from? }) has the factory settle the assertion on UMA’s oracle, UMA pay the bond to the winning side, and the factory apply the verdict. It costs only gas and anyone can send it. notes[0] says what will happen: the market is deployed; it is voided (the listings.PAIR_DEPLOYMENT_PROPOSAL_VOIDED text); or, for a disputed listing, that the call succeeds only once UMA’s voters have ruled.

It throws an InputError on assertionId for an unknown or already resolved listing, and while UMA’s liveness is still open (with the time it can be settled). A disputed listing the voters have not ruled on cannot be told apart in advance: the simulation reverts in UMA’s oracle. Settling the assertion directly on the oracle (uma.settleAssertion) has the same effect.

Releasing a Stuck Listing

An assertion that can never settle on UMA’s oracle (a proposer who can no longer receive USDC, a dispute the DVM never resolves) would hold the asset’s listing slot forever. expireStuckDeploymentProposal releases it:

if (pending && pending.status !== "inReview") {
  try {
    // Takes the pair id, not the assertion id: it frees the asset's listing slot.
    const release = await bz.listings.expireStuckDeploymentProposal({ pairId, from: user });
    const sim = await bz.simulate(release);
    if (sim.ok) await bz.waitForReceipt(await bz.send(window.ethereum, release));
    else console.error(sim.error!.message);
  } catch (e) {
    console.error(errorMessage(e)); // "pairId: This listing cannot be released before 2026-11-24 14:00:00 UTC."
  }
}

It takes the pair id, not the assertion id. The factory first tries to settle the listing, so one that can still resolve simply resolves. Otherwise an undisputed listing past its liveness is approved (deployed, or voided if its identifier was removed), and a disputed one past the 14-day grace is discarded with its seed credited back. The assertion stays on UMA’s oracle, so the winning side can still collect its bond. It costs only gas.

It throws an InputError on pairId when no listing is pending for that pair id, and when it is not yet releasable, with the time it opens (releaseOpensAt). The one exception: a listing disputed after its identifier left UMA’s whitelist may be released at once, because no ruling can ever come. The factory keeps the timing of that dispute private, so when the identifier is off the whitelist the SDK builds the call with a note and leaves the outcome to the simulation (Factory__DeploymentNotExpired when it is too early).

Claiming a Seed Refund

A listing that did not deploy (rejected, voided or released) credits its seed to the proposer’s seedRefundOwed instead of sending it, so a failing transfer cannot block UMA’s settlement:

const owed = await bz.listings.seedRefundOwed(user); // USDC units, 6 decimals
if (owed > 0n) {
  const claim = await bz.listings.claimSeedRefund({ from: user });
  console.log(claim.summary); // "Withdraw your 4,000 USDC listing seed refund"
  await bz.waitForReceipt(await bz.send(window.ethereum, claim));
}

seedRefundOwed(account) is in USDC units. claimSeedRefund({ from }) pays only the caller, and returns amountUsdc with the call. It throws an InputError on from when nothing is owed.

Reading Listings

From the subgraph, history.listingProposals lists proposals newest first, filtered by deployer, status ("Pending", "Disputed", "Deployed", "Rejected", "Expired", "Voided") and time, and paginated:

const page = await bz.history.listingProposals({ status: ["Pending", "Disputed"], first: 50 });
for (const l of page.items) {
  console.log(l.description, l.status, uma.timeText(l.livenessEndsAt)); // render the description as text, never HTML
}
// page.next is the cursor for the next page: pass it as `after`.

Each row has the assertion id, pairId, deployer, baseFeedId, description, seedAmount, status, disputed, proposedAt, livenessEndsAt, resolvedAt, the deployed pair (once deployed), seedRefundUsdc (once credited back) and transactionHash. The subgraph can trail the chain; read a single proposal’s current state with deploymentProposal.

From the chain’s own events, listings.decodeFactoryLog(log) decodes any factory log (or returns null for another contract’s), and listings.LISTING_EVENT_TEXT has one sentence per listing event for an activity feed:

function describeFactoryLog(log: RawLog): string | null {
  const d = listings.decodeFactoryLog(log);
  return d ? (listings.LISTING_EVENT_TEXT[d.name] ?? null) : null;
}
EventMeaning
PairDeploymentProposedA listing was proposed: its UMA liveness started and its seed is escrowed.
PairDeploymentProposalDisputedThe listing was disputed: UMA’s voters will decide it.
PairDeploymentProposalResolvedUMA resolved the listing: approved and deployed, or rejected with the seed credited back.
PairDeployedThe market was deployed and seeded with its insurance fund.
PairDeploymentProposalExpiredA disputed listing that could not settle was released; its seed was credited back.
PairDeploymentProposalVoidedApproved without a dispute, but its identifier has since left UMA’s whitelist: released instead of deployed.
SeedRefundCreditedA listing’s seed was credited back to its proposer.
SeedRefundClaimedA proposer collected a seed refund.

markets.listMarkets caches its list per connection: after a listing deploys, call it with { refresh: true } to pick up the new market (see Markets).

CostedCall

Every builder on this page returns a CostedCall: a PreparedCall (simulate and send it as one) plus four lists for a confirmation screen.

FieldMeaning
costsEvery bond, seed, fee, levy and lock the call posts, each a Cost. Empty for calls that cost only gas.
approvalsERC-20 approvals (PreparedCalls) to send first, each for exactly the amount. Empty when the allowance already covers it. The SDK never assumes an approval exists.
requiresPreconditions in words: an allowance to send, a balance to top up.
notesWhat will happen, and warnings.

A Cost has kind ("bond", "seed", "fee", "levy" or "lock"), label, amount in unit’s own precision ("USDC" is 6 decimals), unit, token, payee (who takes or holds it), returned (when and how it comes back, if ever) and contingent (true for a worst case that depends on a later verdict). uma.usdcText(amount) prints a USDC amount exactly (“1,000 USDC”). The termination and governance builders return the same shape (Termination & Settlement, UMA Disputes & Governance).

Function Reference

On bz.listings (the connection bound), and as listings.*(ctx, ...):

FunctionReturns
deploymentConstants()DeploymentConstants
requiredDeploymentBond()The bond a listing posts now, USDC units.
deploymentProposal(assertionId)DeploymentProposalInfo | null
pendingDeployment({ pairId } | { baseFeedId })DeploymentProposalInfo | null
openListings(search?)OpenListings: { listings, source }
seedRefundOwed(account)USDC units owed.
getComposite(id)CompositeFeed | null
rawBaseFeed(baseFeedId)The raw Pyth Pro feed id, as a bytes32.
registerComposite(args)CostedCall & { compositeId }
proposePairDeployment(args)CostedCall & { pairId, bondUsdc, seedAmountUsdc }
settleDeploymentProposal({ assertionId, from? })CostedCall
expireStuckDeploymentProposal({ pairId, from? })CostedCall
claimSeedRefund({ from })CostedCall & { amountUsdc }
listingFromReceipt(receipt){ assertionId, pairId } | null

Pure helpers, on listings only: descriptionProblem, compositeId, decodeFactoryLog, LISTING_EVENT_TEXT, PAIR_DEPLOYMENT_PROPOSAL_VOIDED and the constants above.

UMA Disputes & Governance

Bazaar has no admin and no token vote. Every decision that cannot be made by a formula goes to UMA’s Optimistic Oracle V3 as an assertion: a claim posted with a USDC bond that anyone may dispute during its liveness window. Undisputed, it resolves true when the liveness ends. Disputed, UMA’s voters (the DVM) decide it, which takes days (up to about 12), and the losing side forfeits its bond. Bazaar’s contracts never hold the verdict themselves: the factory and the terminator learn it through the oracle’s callback when someone settles the assertion.

Three flows make assertions:

FlowMade byBond floorLivenessPage
Listing a marketthe factory1,000 USDC48 hoursListing Markets
Upgrading the UMA identifierthe factory5,000 USDC2 daysIdentifier Upgrades, below
Terminating a marketthe terminator1,000 USDCsee the termination pageTermination & Settlement

The SDK splits this into two areas:

  • uma (bz.uma): any assertion, whichever flow made it. Where it stands, disputing it with its bond, previewing what settling it would do, settling it, and decoding the oracle’s events.
  • governance (bz.governance): Bazaar’s one governance lever, swapping the UMA identifier every listing and termination is adjudicated under.

The samples assume bz is connected as in Connecting with the user’s wallet as the transport, and user is the wallet’s address. The mechanics, and why they are built this way, are in Bazaar’s Markets & Listing and Termination & Settlement pages.

Which Oracle

The oracle’s address comes from the deployment: factory.oo(), which is immutable. Each assertion also records the oracle it was made on: factory.assertionOo(id) for listings and identifier upgrades, terminator.assertionOo(id) for termination claims. The uma functions take a home option, "factory" (the default) or "terminator", that says which record to read; when the record is empty they fall back to factory.oo(). Pass home: "terminator" for a termination claim.

You can also pass oo to name the oracle yourself, but it is used only if it is factory.oo() or the oracle recorded for this assertion. Any other address is refused with an InputError on oo, because it would be the call’s target and, for a dispute, the spender of the bond. Leave it out and the SDK finds the right oracle. uma.optimisticOracle() returns factory.oo(), and uma.assertionOracle(id, home) the oracle an assertion was made on.

Assertion Status

const st = await bz.uma.assertionStatus(assertionId, { home: "factory" });
console.log(uma.ASSERTION_PHASE_TEXT[st.phase]);
if (st.phase === "liveness") {
  console.log(`Disputable until ${uma.timeText(st.disputableUntil!)}; settleable from ${uma.timeText(st.canSettleAt!)}.`);
}
if (st.assertion) {
  console.log(`bond ${uma.usdcText(st.assertion.bond)}, asserter ${st.assertion.asserter}, identifier ${uma.identifierText(st.assertion.identifier)}`);
}

assertionStatus(assertionId, { home?, oo? }) reads the assertion and the latest block’s time, and returns an AssertionStatus:

FieldMeaning
phaseWhere the assertion stands (below).
assertionThe oracle’s record (UmaAssertion), or null when the oracle has no such assertion.
canSettleAtWhen it can be settled: its expirationTime while in liveness, now when settleable, null when disputed or resolved.
disputableUntilThe last second a dispute is accepted (expirationTime - 1), or null when it can no longer be disputed.
nowThe chain time the status was judged at.
phaseMeaning
notFoundNo such assertion on this oracle.
livenessUndisputed and inside its liveness: disputable until expirationTime, settleable from it.
settleableUndisputed and past its liveness: it can only resolve true, and anyone may settle it now.
disputedWaiting on UMA’s voters. It settles once they have ruled, which this view cannot see; use previewSettlement.
settledTrueSettled: UMA accepted the claim.
settledFalseSettled: UMA rejected the claim.

uma.ASSERTION_PHASE_TEXT has one sentence per phase for a UI. Times are judged against the chain’s latest block, not the user’s clock, since that is what the contracts compare against.

The UmaAssertion record has assertionId, oo, asserter, assertionTime, settled, currency (USDC for every Bazaar assertion), expirationTime (from this instant the oracle refuses disputes and an undisputed assertion may be settled), settlementResolution (the verdict, meaningful only once settled), domainId, identifier, bond (in the currency’s units: USDC has 6 decimals), callbackRecipient, disputer (null while undisputed) and escalationManager (null when none). uma.getAssertion(oo, assertionId) returns it alone, and uma.assertionPhase(assertion, now) and uma.statusOf(assertion, now) compute the phase and status from a record you already hold.

The flows’ own reads (listings.deploymentProposal, governance.identifierGovernance, termination.terminationStatus) describe the Bazaar side of the same proposal.

Disputing an Assertion

A dispute matches the assertion’s bond and sends the claim to UMA’s voters. If they reject the claim, the disputer receives both bonds less the share UMA burns as its fee. If they uphold it, the dispute bond is gone.

try {
  const dispute = await bz.uma.disputeAssertion({ assertionId, from: user, home: "factory" });

  // The confirmation screen: the bond, what it wins or loses, and any warning.
  console.log(dispute.summary);
  for (const cost of dispute.costs) console.log(`${cost.label}: ${uma.usdcText(cost.amount)}. ${cost.returned}`);
  for (const line of [...dispute.requires, ...dispute.notes]) console.log(line);

  // The oracle pulls the bond with transferFrom: approve it first (exactly the bond), then dispute.
  for (const approval of dispute.approvals) await bz.waitForReceipt(await bz.send(window.ethereum, approval));
  const sim = await bz.simulate(dispute);
  if (!sim.ok) throw new Error(sim.error!.message);
  await bz.waitForReceipt(await bz.send(window.ethereum, dispute));
} catch (e) {
  console.error(errorMessage(e)); // "assertionId: UMA's dispute window closed at ...", a revert, a rejection
}

disputeAssertion takes:

ArgumentTypeMeaning
assertionIdHexThe assertion to dispute.
fromAddressWho sends the call and pays the bond.
disputerAddress, optionalWho is paid if the dispute wins. Defaults to from; cannot be the zero address.
home"factory" | "terminator", optionalWhich contract made the assertion. Default "factory".
ooAddress, optionalThe oracle, checked as in Which Oracle.

It returns a CostedCall (see CostedCall) whose one cost is the dispute bond (kind: "bond", payee the oracle). The oracle pulls the bond with transferFrom, so approvals holds an exact-amount approval for the oracle whenever the allowance is short; send it first, then simulate and send the dispute. notes says:

  • until when the claim can be disputed, and that UMA’s voters then take days;
  • what the disputer receives if the dispute wins, computed from the oracle’s burnedBondPercentage() (both bonds less UMA’s fee; 50% of one bond is burned on the live oracle), or in words when the oracle does not report it;
  • a warning when the claim’s identifier is no longer on UMA’s whitelist: UMA’s voters can then never rule on a dispute, and the bond would stay locked in the oracle for good;
  • a warning when the bond is not in USDC, since then it is not a claim Bazaar made;
  • a warning when the wallet already allows the oracle more than the bond.

How a dispute affects each flow: a listing or identifier upgrade waits on UMA’s voters. A termination claim’s insurer vote keeps running (see Termination & Settlement).

What disputeAssertion Refuses

FieldWhy
assertionIdNot 32 bytes of hex; no such assertion on the oracle; already disputed; already settled; or past its liveness (“UMA’s dispute window closed at …”): the oracle accepts disputes only while expirationTime is after the block’s time.
from, disputerNot a valid address, or a zero disputer.
ooNeither factory.oo() nor the oracle recorded for this assertion.

These throw InputError. A USDC bond below the 1,000 USDC every Bazaar claim posts, or a zero bond in another currency, can only be a wrong answer from the node: it throws uma.BondQuoteError and nothing is built. errorMessage does not recognize BondQuoteError and returns its generic sentence, so show e.message for it.

A dispute sent in the last seconds of the window can land in a block after expirationTime and revert in the oracle. Leave a margin.

Previewing a Settlement

Bazaar’s own views read a disputed claim as pending until someone settles it on chain, even after UMA’s voters have ruled. previewSettlement is the one way to see the ruling first: it simulates the oracle’s settleAndGetAssertionResult(assertionId) without sending anything.

// For a disputed assertion this is the only way to see whether UMA's voters have ruled, and how.
const pv = await bz.uma.previewSettlement(assertionId, { home: "factory", from: user });
if (pv.settleable) console.log(pv.result ? "Settling now accepts the claim." : "Settling now rejects the claim.");
else console.log(`Cannot settle yet: ${pv.error?.message}`); // UMA's oracle reverts until the DVM rules

It takes { home?, oo?, from? } and returns a SettlementPreview:

FieldMeaning
settleabletrue when a settle would go through now.
resultThe verdict a settle would apply (true: the claim is accepted), or null when it cannot settle yet.
errorWhy it cannot settle, decoded (DecodedError). A disputed assertion the DVM has not ruled on reverts in UMA’s oracle.

The simulation runs Bazaar’s callback too, so a callback that would revert shows up here as settleable: false with its decoded error.

Settling an Assertion

Settling pays the bond to the winning side, then calls the Bazaar contract that made the assertion, which applies the verdict: deploys or refunds a listing, queues an identifier upgrade, schedules or rejects a termination. Anyone may settle, and it costs only gas.

if (pv.settleable) {
  const settle = await bz.uma.settleAssertion({ assertionId, from: user, home: "factory" });
  await bz.waitForReceipt(await bz.send(window.ethereum, settle));
}

settleAssertion({ assertionId, from?, home?, oo?, getResult? }) settles on the oracle directly. With getResult: true it calls settleAndGetAssertionResult instead of settleAssertion, which also returns the verdict. It returns a plain PreparedCall. Each flow also has its own settle function on the Bazaar contract (listings.settleDeploymentProposal, governance.settleIdentifierUpgradeProposal, termination.settleTerminationProposal); either way works, and the flow’s own function adds notes about what will happen.

It throws an InputError on assertionId when the oracle has no such assertion, while the liveness is still open (with the time it can be settled), and once it is settled. A disputed assertion settles only after the DVM has ruled; until then the call reverts, which the builder cannot see in advance. Simulate it, or call previewSettlement first.

Other Reads

FunctionReturns
uma.getMinimumBond({ currency?, oo? })The smallest bond the oracle accepts in currency (USDC by default), in its units; 0 when the oracle has never cached it.
uma.defaultIdentifier({ oo? })The oracle’s default identifier. On the live oracle this is ASSERT_TRUTH, which UMA has de-whitelisted; Bazaar asserts under its own governed identifier instead.
uma.burnedBondPercentage({ oo? })The share of a disputed bond UMA burns as its fee, 1e18 = 100%, or null if the oracle does not report it.
uma.identifierWhitelisted(identifier, { oo? })Whether the identifier is on UMA’s live whitelist, resolved as the factory resolves it (oracle, then finder, then IdentifierWhitelist). null when any step fails to answer.

Oracle Events

uma.decodeAssertionLog(log) decodes the oracle’s AssertionMade, AssertionDisputed and AssertionSettled events (or returns null for another contract’s log), and uma.ASSERTION_EVENT_TEXT has a sentence for each:

function describeOracleLog(log: RawLog): string | null {
  const d = uma.decodeAssertionLog(log); // AssertionMade, AssertionDisputed, AssertionSettled
  return d ? (uma.ASSERTION_EVENT_TEXT[d.name] ?? null) : null;
}

Bazaar’s own events about the same proposals (PairDeploymentProposalDisputed, UmaIdentifierUpgradeQueued, the terminator’s resolution events) are decoded with listings.decodeFactoryLog and the termination area. For past proposals, the subgraph has history.listingProposals, history.identifierUpgradeProposals and history.terminationProposals (History).

Identifier Upgrades

UMA’s oracle address is fixed in the factory. What UMA changes over time is its whitelist of identifiers, so the identifier Bazaar asserts under (umaIdentifier, currently ASSERT_TRUTH2) is the one thing Bazaar’s governance can change:

  1. Anyone proposes a new identifier (proposeUmaIdentifierUpgrade) with a bond of at least 5,000 USDC. The candidate must be on UMA’s live whitelist and named ASSERT_TRUTH….
  2. For 2 days anyone may dispute it.
  3. Once UMA can resolve it, anyone settles it (settleIdentifierUpgradeProposal). Approval does not swap anything: it queues the upgrade behind a 7-day timelock (4 days once the incumbent is off UMA’s whitelist).
  4. After the timelock, anyone activates it (activateIdentifierUpgrade). The factory checks the whitelist again: an incoming identifier UMA has removed since cancels the upgrade.

One upgrade is pending at a time. Assertions already made keep the identifier they were made under.

While the incumbent is off UMA’s whitelist (umaIdentifierIsLive() is false), new listings and UMA termination claims are refused, and an upgrade proposal is asserted under the proposed identifier so a dispute on it can still be ruled on.

The Governance Status

const g = await bz.governance.identifierGovernance();
console.log(`in force: ${g.umaIdentifierText}, live on UMA's whitelist: ${g.umaIdentifierIsLive}`);
if (g.pending) {
  console.log(`proposed: ${g.pending.newIdentifierText}, ${uma.ASSERTION_PHASE_TEXT[g.pending.assertion.phase]}`);
}
if (g.queued) {
  console.log(`queued: ${g.queued.newIdentifierText}, activation opens ${uma.timeText(g.queued.activationOpensAt)}`);
  if (g.queued.newIdentifierWhitelisted === false) console.log("UMA has removed it since: activating cancels the upgrade.");
}

identifierGovernance() returns the whole track in one call:

FieldMeaning
nowThe chain time it was read at.
umaIdentifier, umaIdentifierTextThe identifier in force, as bytes32 and as text (null if not printable ASCII).
umaIdentifierIsLiveWhether it is on UMA’s live whitelist.
pendingThe proposal in UMA’s review, or null: an IdentifierUpgradeProposalInfo plus assertion, its assertion status.
queuedThe approved upgrade waiting out its timelock, or null (below).
constantsThe track’s constants (below).

A pending proposal (IdentifierUpgradeProposalInfo, also from identifierUpgradeProposal(assertionId)) has assertionId, proposer, newIdentifier, newIdentifierText, proposalTs, livenessEndsAt, resolved, settlementResolution (meaningful once resolved through settlement; an expired proposal reads false), disputed, and releaseOpensAt (the end of liveness, plus 14 days if disputed).

A queued upgrade (QueuedIdentifierUpgrade) has:

FieldMeaning
assertionId, newIdentifier, newIdentifierTextThe approved proposal.
effectiveTsWhen the full 7-day timelock ends.
approvedAteffectiveTs less 7 days.
activationOpensAteffectiveTs, or 4 days after approval while the incumbent is off UMA’s whitelist. A failed whitelist lookup keeps the full timelock, as the factory does.
canActivateWhether now has reached activationOpensAt.
newIdentifierWhitelistedWhether the incoming identifier is still whitelisted; activation cancels the upgrade if not. null if the lookup failed.

The Constants

identifierUpgradeConstants() reads them from the factory:

FieldValueMeaning
identifierUpgradeBondUsdc5,000 USDCThe bond’s floor (6 decimals).
requiredIdentifierUpgradeBondUsdcliveThe bond a proposal posts now: the larger of the floor and UMA’s minimum. Also requiredIdentifierUpgradeBond().
identifierUpgradeLiveness2 daysUMA’s liveness, in seconds.
identifierUpgradeTimelock7 daysFrom approval to activation.
identifierUpgradeMinNotice4 daysFrom approval to activation once the incumbent is off UMA’s whitelist.
dvmDisputeGrace14 daysExtra wait before a disputed proposal that cannot settle may be released.

The same values are exported as governance.IDENTIFIER_UPGRADE_BOND_USDC, IDENTIFIER_UPGRADE_LIVENESS, IDENTIFIER_UPGRADE_TIMELOCK and IDENTIFIER_UPGRADE_MIN_NOTICE.

Monitoring the Identifier

umaIdentifierIsLive() is the one read to alarm on: when it turns false, listings and UMA termination claims are closed until an upgrade activates.

// One read to alarm on: false means new listings and UMA termination claims are refused.
if (!(await bz.governance.umaIdentifierIsLive())) {
  console.warn("Bazaar's UMA identifier is off UMA's whitelist: an identifier upgrade is needed.");
}

umaIdentifier() returns the identifier in force as bytes32.

Proposing an Upgrade

try {
  const call = await bz.governance.proposeUmaIdentifierUpgrade({ newIdentifier: "ASSERT_TRUTH3", from: user });
  console.log(call.summary); // "... posts a 5,000 USDC bond, returned if UMA approves, lost to a disputer if it rejects"
  for (const line of [...call.requires, ...call.notes]) console.log(line);

  for (const approval of call.approvals) await bz.waitForReceipt(await bz.send(window.ethereum, approval));
  const sim = await bz.simulate(call);
  if (!sim.ok) throw new Error(sim.error!.message);
  await bz.waitForReceipt(await bz.send(window.ethereum, call));
} catch (e) {
  console.error(errorMessage(e)); // "newIdentifier: That identifier is not on UMA's live whitelist."
}

proposeUmaIdentifierUpgrade({ newIdentifier, from }) takes the identifier as its name ("ASSERT_TRUTH3") or as bytes32 hex. The factory pulls the bond from from with transferFrom, so approvals holds an exact-amount USDC approval for the factory when the allowance is short. The one cost is the bond: returned by UMA when the proposal is approved, paid to the disputer if UMA’s voters reject it. notes gives the dispute window and the timelock, says when the call will first settle a rejected proposal or activate a queued upgrade (the factory does both before taking a new proposal), and says when the proposal will be asserted under the proposed identifier because the incumbent is off the whitelist.

It throws an InputError on newIdentifier when the identifier:

  • is empty, or neither a name of 1 to 32 printable ASCII characters nor 32 bytes of hex;
  • does not begin with ASSERT_TRUTH (Bazaar adjudicates only under UMA truth identifiers);
  • is already the one in force (or the one a queued upgrade is about to activate);
  • is not on UMA’s live whitelist. When the whitelist cannot be read, the call is built with a note, since the factory checks it itself;

and when another proposal blocks: one still in UMA’s review, one past review that settling would queue, one disputed and approved by UMA’s voters, one disputed and not yet ruled on, or a queued upgrade still in its timelock. A bond quote below 5,000 USDC throws uma.BondQuoteError.

Settling, Releasing and Activating

const now = await bz.governance.identifierGovernance();
if (now.pending && now.pending.assertion.phase !== "liveness") {
  const settle = await bz.governance.settleIdentifierUpgradeProposal({ assertionId: now.pending.assertionId, from: user });
  const sim = await bz.simulate(settle);
  if (sim.ok) await bz.waitForReceipt(await bz.send(window.ethereum, settle));
  else console.error(sim.error!.message); // a disputed proposal the DVM has not ruled on
}

settleIdentifierUpgradeProposal({ assertionId, from? }) settles the proposal on UMA: UMA pays the bond to the winning side, and an approved upgrade is queued behind the timelock. It costs only gas. It throws an InputError on assertionId for an unknown or resolved proposal and while UMA’s liveness is open; a disputed one settles only after UMA’s voters have ruled.

if (now.queued?.canActivate) {
  const activate = await bz.governance.activateIdentifierUpgrade({ from: user });
  console.log(activate.notes[0]); // what changes, or that the upgrade will be canceled
  await bz.waitForReceipt(await bz.send(window.ethereum, activate));
}

activateIdentifierUpgrade({ from? }) activates the queued upgrade once activationOpensAt has passed. notes[0] says what will happen: from then on, new listings and termination claims are asserted under the new identifier, or, when UMA has removed it since, activating cancels the upgrade and keeps the incumbent. It throws an InputError when nothing is queued and before activation opens (with the time).

if (now.pending && now.now >= now.pending.releaseOpensAt) {
  const release = await bz.governance.expireStuckIdentifierUpgradeProposal({ from: user });
  const sim = await bz.simulate(release);
  if (sim.ok) await bz.waitForReceipt(await bz.send(window.ethereum, release));
}

expireStuckIdentifierUpgradeProposal({ from? }) releases the single pending slot when its assertion cannot settle (a proposer who can no longer receive USDC, a dispute the DVM never resolves). The factory first tries to settle it. Otherwise an undisputed proposal past liveness is approved and queued, and a disputed one past the 14-day grace is discarded. The assertion stays on UMA’s oracle, so the winning side can still collect its bond. It throws an InputError when nothing is pending and before releaseOpensAt, except that a proposal disputed after its identifier left UMA’s whitelist may be released at once; the factory keeps the timing of that dispute private, so the SDK then builds the call with a note and leaves the outcome to the simulation.

The InputErrors from activateIdentifierUpgrade and expireStuckIdentifierUpgradeProposal name the field assertionId, although neither function takes one.

Identifier Helpers and Events

governance.parseIdentifier("newIdentifier", "ASSERT_TRUTH3"); // "0x4153534552545f5452555448330000..."
governance.isAssertTruthIdentifier(uma.identifierFromText("ASSERT_TRUTH3")); // true
HelperWhat it does
governance.parseIdentifier(field, value)A name or bytes32 hex to bytes32; an InputError on field otherwise.
governance.isAssertTruthIdentifier(id)Whether the first 12 bytes are ASCII ASSERT_TRUTH, the factory’s rule.
uma.identifierFromText(text)"ASSERT_TRUTH2" to bytes32, as UMA stores identifiers.
uma.identifierText(id)bytes32 to its text, or null when it is not printable ASCII.

The factory’s governance events are decoded with listings.decodeFactoryLog, and governance.IDENTIFIER_UPGRADE_EVENT_TEXT has a sentence for each: UmaIdentifierUpgradeProposed, UmaIdentifierUpgradeDisputed, UmaIdentifierUpgradeQueued, UmaIdentifierUpgraded, UmaIdentifierUpgradeCanceled and UmaIdentifierUpgradeExpired. Past proposals are listed by history.identifierUpgradeProposals, filtered by proposer and status ("Pending", "Disputed", "Queued", "Upgraded", "Canceled", "Expired", "Rejected").

Errors

Builders throw InputError (with field) for anything they can check up front; errorMessage(e) returns its message. A call that passes the builder but reverts on chain comes back from bz.simulate with error.message, for example Factory__IdentifierNotWhitelisted, Factory__IdentifierUpgradeStillPending or Factory__IdentifierUpgradeTimelocked (“The approved identifier upgrade can be activated …”). See Errors & Messages.

ErrorWhereMeaning
InputErrorevery builderAn argument or a state the SDK checked first.
uma.BondQuoteErrordisputeAssertion, proposeUmaIdentifierUpgrade, proposePairDeploymentThe node reported a bond below the contract’s own floor. Check the RPC connection.
uma.ReadErrorany readA contract read reverted; decoded holds the reason.

The Shared Plumbing

bz.uma also exposes the helpers the listing, termination and governance builders are made of, for anyone building their own UMA-gated calls:

FunctionWhat it does
readMany(reads, { allowFailure? })Several contract reads ({ to, codec, fn, args }) in as few eth_calls as the node allows, decoded. A revert throws ReadError, or reads undefined with allowFailure.
readOne(to, codec, fn, ...args)One read.
chainNow()The latest block’s timestamp.
bondApprovals(owner, pulls)For a list of ERC-20 pulls ({ token, spender, spenderName, amount, purpose }): the exact-amount approvals to send, the requires lines (allowance and balance shortfalls) and warnings about larger existing allowances.

On uma only: approveCall(token, spender, amount, from, summary) (an ERC-20 approve as a PreparedCall), optimisticOracleV3Abi with optimisticOracleCodec() and erc20Codec(), and the formatters usdcText, sharesText, ethText (exact amounts), timeText (a unix time as “2026-11-08 14:00:00 UTC”) and durationText (“2 days”). See Low-Level Building Blocks for codecs.

Building a Bot

This page builds a complete trading bot with the SDK: a two-sided quoting bot (a simple market maker) for one market. It signs with its own key, follows the live Pyth price over one stream, keeps one post-only bid and one post-only ask resting around it, requotes both in one transaction when the price moves, limits its inventory, and cancels everything when it stops. The same structure fits a rebalancer, a hedger or a strategy that only takes.

The whole file is at the end of the page (The Complete Bot); the sections before it walk through it part by part. It builds on Sending Transactions, Placing Orders and Managing Orders.

The packages are not yet on npm. Once published, install with npm install @liberta-labs/bazaar-sdk viem; until then, build them from the repository (git clone https://github.com/liberta-labs/bazaar-sdk && cd bazaar-sdk && npm install && npm run build) and depend on packages/sdk. See Installation.

Structure

PartJob
ConfigurationEvery number the strategy uses, from the environment.
Connection and WalletReads through a rate-limited RPC client, a local key for sending, one price stream.
Account StateThe bot’s position and orders, kept current by a watcher.
ActingBuild, simulate, send, wait: one transaction at a time.
CollateralTop the account up to a target on start.
The StrategyWhere the quotes should be, and the calls that put them there.
The LoopRequote on price moves, and on a timer.
ShutdownCancel the quotes and stop cleanly.

Configuration

function env(name: string, fallback?: string): string {
  const v = process.env[name] ?? fallback;
  if (v === undefined || v === "") throw new Error(`set ${name}`);
  return v;
}

const config = {
  rpcUrl: env("RPC_URL"),
  pythKey: env("PYTH_PRO_API_KEY"),
  factory: env("BAZAAR_FACTORY") as Address,
  market: env("MARKET", "ETH/USD"),
  /** Each quote's distance from the live price, in basis points. */
  spreadBp: BigInt(env("SPREAD_BP", "20")),
  /** Quotes snap to this price grid (a choice of the bot, not a protocol tick). */
  tick: parseUnits18(env("TICK", "0.01")),
  /** Size of each quote, in base units. */
  size: parseUnits18(env("QUOTE_SIZE", "0.5")),
  /** Stop quoting the side that would grow the position past this. */
  maxPosition: parseUnits18(env("MAX_POSITION", "5")),
  /** Requote when the price has moved this far from the last quote, in basis points. */
  requoteBp: BigInt(env("REQUOTE_BP", "5")),
  /** Keep at least this much collateral on the market (USDC, BAZAAR precision). */
  collateral: parseUnits18(env("COLLATERAL", "2000")),
  /** How long each quote lives; replaced 10 minutes before it expires. */
  orderLifetimeSeconds: 6 * 60 * 60,
  /** The least time between two requotes. */
  minRequoteMs: 2_000,
};

All amounts are BAZAAR precision (parseUnits18): 18 decimals, 1e18 = one unit of the base asset or $1. The price grid (TICK) is the bot’s own choice: the pair takes any price, but rounding keeps quotes on levels other traders can see.

Connection and Wallet

const key = privateKeyToAccount(env("BOT_PRIVATE_KEY") as Hex, { nonceManager });
const wallet = createWalletClient({ account: key, chain: arbitrum, transport: http(config.rpcUrl) });
const me = key.address;

// One Pyth Pro client for the calls' updates and the stream. Node 22 and later have the WebSocket it
// streams over; on Node 20 pass one, e.g. the `ws` package's, as its WebSocket option.
const pro = new ProClient({ token: config.pythKey });
const base = await Bazaar.connect({
  transport: new JsonRpc(config.rpcUrl, { perSecond: 10 }), // stay under the RPC provider's rate limit
  factory: config.factory,
  chainId: arbitrum.id,
  prices: pro,
});
const market = await base.markets.getMarket(config.market);

// One price stream for the market. It is also a price source: calls built through `bz` reuse its
// update (a price generated at most 1 s ago) instead of asking Pyth Pro again.
const live = await livePrices([market], { client: pro });
await live.ready;
const bz = Bazaar.from({ ...base.ctx, prices: live });
  • The key. The bot signs with a local viem account, and sends with a viem wallet client: it never hands the key to the SDK. Use a key that holds only what the bot needs: ETH on the chain for gas and Pyth update fees, and the USDC it trades with. Load it from the environment or a secret store, never from the repository. Do not spread toTransactionRequest(call) into this client (see Sending From a Bot).
  • Reads. JsonRpc is the SDK’s own JSON-RPC client: it rate limits itself (perSecond, 25 by default), retries transient failures (retries, 3 by default, with doubling delays from 500 ms; never a send), times out (timeoutMs, 30 s), caps response sizes, and keeps the URL, which often holds a provider key, out of its error messages. Passing a URL string as transport creates one with the defaults. chainId makes connect refuse any other chain.
  • Prices. livePrices([market], { client }) streams the market’s feeds from Pyth Pro, one subscription on every stream endpoint (falling back to polling while the stream fails), and composes the pair’s price exactly as the oracle does. The stream runs over a WebSocket: Node 22 and later have one, and on Node 20 the ProClient takes a constructor (WebSocket, the ws package’s). live.price(market) reads it from memory: spot, low, high, publishTime, age, and fresh (inside the pair’s 3-second window and its confidence cap). See Live Prices.
  • Prices for calls. A LivePrices is also a price source. Bazaar.from({ ...base.ctx, prices: live }) makes the builders take their price updates from the stream (a price generated at most reuseAge, 1 second, ago) instead of making a Pyth Pro request per call, which saves time inside the 3-second window and requests.

For a bot that trades several markets, pass them all to livePrices and watch them all; for market facts (margin rates, funding, open interest, the book), read bz.markets.marketSnapshot(market) and bz.markets.getBook(market, levels) (Markets).

Account State

const state: { snap: account.AccountSnapshot | null; lastTxBlock: number; lastQuoted: bigint; busy: boolean; failures: number; stopping: boolean } = {
  snap: null, // the account on the market, refreshed by the watcher
  lastTxBlock: 0, // the block of the bot's last transaction
  lastQuoted: 0n, // the price the resting quotes were built around
  busy: false,
  failures: 0,
  stopping: false,
};

const watch = bz.live.watchAccount(
  me,
  [market],
  (u) => {
    if (u.type === "account") state.snap = u.snapshot;
    else if (u.type === "event" && u.event.source === "pair" && u.event.name === "Trade") {
      const t = u.event.args;
      if (t.maker === me) log(`filled ${formatUnits18(t.fillSize)} at ${formatUnits18(t.price)} (${t.takerIsLong ? "sold" : "bought"})`);
    } else if (u.type === "error") log("watch error:", errorMessage(u.error));
  },
  { prices: live, refreshMs: 10_000, pollMs: 2_000 },
);
await watch.ready;

bz.live.watchAccount(user, markets, onUpdate, opts) follows the pair’s events and delivers a fresh account snapshot (position, orders, collateral, health at the live price) after every event that touches the account, at most once a second (minSnapshotMs), and every refreshMs even when nothing happens. It handles reorgs. The bot keeps the latest snapshot and decides from it, rather than reading the chain before every decision. pollMs sets how often the watcher looks for a new block. See Live Updates.

state.lastTxBlock guards against acting on a snapshot older than the bot’s own last transaction: otherwise the bot could place a second quote before the snapshot shows the first.

Acting

/**
 * Build, simulate and send one call, then wait for it. `build` runs right before sending, so a call
 * that carries a price is inside the 3 s window; one that aged during simulation is rebuilt once.
 */
async function act(build: () => Promise<PreparedCall>): Promise<boolean> {
  try {
    let call = await build();
    let sim = await bz.simulate(call);
    if (sim.ok && call.priceTime !== undefined && Date.now() / 1000 - call.priceTime > 2) {
      call = await build();
      sim = await bz.simulate(call);
    }
    if (!sim.ok) {
      log(`skipped "${call.summary}": ${sim.error!.message}`);
      return false;
    }
    const hash = await wallet.sendTransaction({ to: call.to, data: call.data, value: call.value });
    const receipt = await bz.waitForReceipt(hash, { timeoutMs: 60_000 });
    state.lastTxBlock = receipt.blockNumber;
    state.failures = 0;
    log(`${call.summary} (block ${receipt.blockNumber})`);
    return true;
  } catch (e) {
    state.failures++;
    log(e instanceof InputError ? `refused: ${e.message}` : `failed: ${errorMessage(e)}`);
    return false;
  }
}

Every transaction goes through act:

  • Build late. build runs right before the send, so a price-carrying call’s update is fresh. If the simulation took long enough for the print to age past 2 seconds, it builds again. Signed locally and sent at once, the call lands well inside the pair’s 3-second window. See The 3-Second Price Window.
  • Simulate first. A call that would revert is skipped and logged with its reason, and costs no gas.
  • One at a time. act waits for each receipt before returning, and the loop never runs two rounds at once (state.busy), so each decision sees the result of the last. This also keeps transaction nonces simple; viem’s nonceManager hands them out locally in case the bot sends more than one at a time (the shutdown path, for example).
  • Errors. An InputError means the builder refused (an order that is no longer open, a price out of band): log it and move on. Everything else (a revert, an RPC failure, a receipt timeout) is logged with errorMessage(e) and counted, and the loop backs off as failures repeat. A receipt timeout does not mean the transaction failed: the next snapshot will show whether it landed.

Bazaar’s gasless nonces (metaTxNonces) play no part here: the bot sends directly.

Collateral

async function ensureCollateral(): Promise<void> {
  const s = state.snap!;
  const short = config.collateral - s.position.collateral;
  if (short < parseUnits18("5")) return; // the pair's minimum deposit is 5 USDC
  const funding = await bz.allowance.usdcFunding(me, market.address, short);
  if (!funding.enoughBalance) throw new Error(`the bot's wallet holds too little USDC to deposit ${formatUnits18(short)}`);
  if (!funding.enoughAllowance) await act(() => bz.allowance.approveUsdc(me, market.address, funding.approveUsdc)); // exact amount
  await act(() => bz.trade.depositCollateral(market.address, me, short));
}

On start the bot tops its collateral on the market up to COLLATERAL, approving exactly the amount it deposits (an approval replaces the previous one; see Approving USDC). The pair’s minimum deposit is 5 USDC. Resting orders are reserved against collateral at creation, so a bot with too little sees its createOrder and amendOrders simulations fail with OrderManagementLib__InsufficientMarginAfterOrder; previewOrder tells it how much is needed.

The Strategy

const snap = (p: bigint) => p - (p % config.tick);

/** Where the quotes should be, and how big, for a price and the current position. */
function targets(spot: bigint, s: account.AccountSnapshot) {
  const net = s.position.size === 0n ? 0n : s.position.isLong ? s.position.size : -s.position.size;
  return {
    bid: { price: snap((spot * (10_000n - config.spreadBp)) / 10_000n), size: net >= config.maxPosition ? 0n : config.size },
    ask: { price: snap((spot * (10_000n + config.spreadBp)) / 10_000n) + config.tick, size: -net >= config.maxPosition ? 0n : config.size },
  };
}

async function requote(spot: bigint): Promise<void> {
  const s = state.snap;
  if (!s || s.block < state.lastTxBlock) return; // wait for a snapshot that includes the last transaction
  if (s.status !== Status.Live) return log(`not quoting: the market is ${s.status}`);

  const t = targets(spot, s);
  const resting = s.orders.filter((o) => o.typeName === "Limit" && o.inBook);
  const bids = resting.filter((o) => o.isLong);
  const asks = resting.filter((o) => !o.isLong);

  // Cancel what should not be there: extra quotes, a side at its limit, quotes close to expiry.
  const expiring = (o: (typeof resting)[number]) => o.blocksLeft !== null && o.blocksLeft < orders.blocksIn(10 * 60);
  const drop = [
    ...bids.slice(1),
    ...asks.slice(1),
    ...(t.bid.size === 0n ? bids.slice(0, 1) : []),
    ...(t.ask.size === 0n ? asks.slice(0, 1) : []),
    ...resting.filter(expiring),
  ];
  if (drop.length > 0) {
    await act(() => bz.trade.cancelOrders(market.address, me, [...new Set(drop.map((o) => o.orderId))]));
    return; // the next round places replacements against the new snapshot
  }

  // Move what rests, in one call. Moving up, amend the ask first so the bid never crosses it.
  const amends: orders.AmendInput[] = [];
  const bid = bids[0];
  const ask = asks[0];
  const up = spot >= state.lastQuoted;
  const side = (o: typeof bid, q: { price: bigint; size: bigint }) =>
    o && o.limitPrice !== q.price ? [{ orderId: o.orderId, limitPrice: q.price, size: o.filled + q.size, bestEffort: true }] : [];
  amends.push(...(up ? [...side(ask, t.ask), ...side(bid, t.bid)] : [...side(bid, t.bid), ...side(ask, t.ask)]));
  if (amends.length > 0) await act(() => bz.trade.amendOrders(market.address, me, amends));

  // Place a missing side.
  const expiry = s.block + orders.blocksIn(config.orderLifetimeSeconds);
  for (const [isLong, q, existing] of [[true, t.bid, bid], [false, t.ask, ask]] as const) {
    if (existing || q.size === 0n) continue;
    await act(() =>
      bz.trade.createOrder(market.address, me, {
        orderType: OrderType.Limit,
        isLong,
        size: q.size,
        limitPrice: q.price,
        isPostOnly: true, // a quote that would cross is canceled, never filled as a taker
        expirationBlock: expiry,
      }),
    );
  }
  state.lastQuoted = spot;
}

Each round:

  1. Targets. A bid SPREAD_BP below the live price and an ask the same distance above, snapped to the grid. A side that would grow the net position past MAX_POSITION gets size 0.
  2. Cancel extra quotes (anything beyond one per side), a side whose size is now 0, and quotes with less than 10 minutes left: an order with less than about a minute left cannot be moved by an amendment, so they are replaced before that. One cancelOrders call takes up to 200 ids.
  3. Amend the resting quotes to the new prices in one amendOrders call. Each amendment sets the new total size, so filled + size restores a partly filled quote to full size. bestEffort: true means a quote that filled or expired in the meantime is skipped rather than reverting the batch. When the price moves up the ask is moved first, so the bid’s new price is never judged against the old ask.
  4. Place a missing side as a post-only Limit: if it would cross the book on arrival it is canceled rather than filled as a taker.

Rules this respects, from Orders:

  • Its own orders never fill each other: a crossing quote is canceled as a self-match. The spread keeps the bid below the ask.
  • One account has at most 32 live Limit and StopLimit orders per market.
  • A Limit’s price must stay within ±50% of the oracle price.
  • An order’s notional must be at least $5.
  • Fills cost the maker 0.25 bp of notional for insurance, plus 0.25 bp to an integrator when the order names one (this bot connects without one).

The Loop

let lastRequote = 0;

async function tick(force: boolean): Promise<void> {
  if (state.busy || state.stopping) return;
  const p = live.price(market);
  if (!p || !p.fresh) return; // no print yet, or the stream has fallen behind
  const moved = state.lastQuoted === 0n ? config.requoteBp : ((p.spot > state.lastQuoted ? p.spot - state.lastQuoted : state.lastQuoted - p.spot) * 10_000n) / state.lastQuoted;
  if (!force && (moved < config.requoteBp || Date.now() - lastRequote < config.minRequoteMs)) return;
  // Back off after repeated failures: 2 s, 4 s, ... up to a minute.
  if (state.failures > 0 && Date.now() - lastRequote < Math.min(60_000, 1_000 * 2 ** state.failures)) return;
  state.busy = true;
  lastRequote = Date.now();
  try {
    await requote(p.spot);
  } finally {
    state.busy = false;
  }
}

await ensureCollateral();
const unsubscribe = live.subscribe(() => void tick(false), [market]); // every new print
const timer = setInterval(() => void tick(true), 15_000); // expiries and missing sides, even when quiet
log(`quoting ${market.label} as ${me}`);

live.subscribe(listener, [market]) calls the listener after each batch of prints for the market, and when its fresh or venue flag flips. The bot requotes when the price has moved REQUOTE_BP from the last quote, at most once every minRequoteMs, and on a 15-second timer regardless, which catches expiries, a side that was canceled, and a snapshot that arrived late.

It does not quote while the price is not fresh (the stream has fallen behind, or a market with trading hours has its venue closed), or while the market is not live (ADL, the limbo before a scheduled termination, settlement, termination: snapshot.status).

Rate Limits

What the bot asks of its providers, and how to keep it down:

ProviderLoadControl
RPC readsThe watcher’s head poll and refreshes; each round’s builder reads (block, order state, hints) and simulation.JsonRpc’s perSecond; pollMs and refreshMs on the watcher; minRequoteMs.
RPC sendsAt most a few transactions per round.The requote threshold and throttle.
Pyth ProOne stream; one request per build only when the stream’s price is over 1 second old.reuseAge on livePrices; the client’s channel. A server holds its own API key (new ProClient({ token })).

A 429 from the RPC is retried with backoff by JsonRpc; a provider that keeps refusing shows up as act failures and the loop’s backoff.

Shutdown

async function shutdown(signal: string): Promise<void> {
  if (state.stopping) return;
  state.stopping = true;
  log(`${signal}: canceling quotes`);
  clearInterval(timer);
  unsubscribe();
  while (state.busy) await new Promise((r) => setTimeout(r, 100));
  await watch.refresh();
  const ids = (state.snap?.orders ?? []).filter((o) => o.typeName === "Limit").map((o) => o.orderId);
  if (ids.length > 0) await act(() => bz.trade.cancelOrders(market.address, me, ids.slice(0, orders.MAX_CANCELS_PER_CALL)));
  watch.stop();
  live.stop();
  process.exit(0);
}
process.on("SIGINT", () => void shutdown("SIGINT"));
process.on("SIGTERM", () => void shutdown("SIGTERM"));

On SIGINT or SIGTERM the bot stops quoting, waits for a round in flight, refreshes its snapshot, cancels its Limit orders, and stops the watcher and the price stream. A process manager sends SIGTERM on stop or restart, so quotes do not outlive the process that manages them. A crash or a killed process skips this: quotes keep resting until they expire, which is one reason to keep orderLifetimeSeconds short.

Running in Production

Compile the bot (tsc, with "module": "NodeNext" and Node 20 or later) or run it with a TypeScript runner, under a process manager that restarts it and sends SIGTERM on stop.

With systemd, a unit such as /etc/systemd/system/bazaar-mm.service:

[Unit]
Description=Bazaar market maker
After=network-online.target
Wants=network-online.target

[Service]
User=bazaar
WorkingDirectory=/opt/bazaar-mm
EnvironmentFile=/etc/bazaar-mm.env
ExecStart=/usr/bin/node dist/market-maker.js
Restart=always
RestartSec=10
KillSignal=SIGTERM
TimeoutStopSec=60

[Install]
WantedBy=multi-user.target

/etc/bazaar-mm.env holds RPC_URL, PYTH_PRO_API_KEY, BAZAAR_FACTORY, BOT_PRIVATE_KEY and the strategy’s settings; make it readable by the service’s user only (chmod 600). Then:

sudo systemctl daemon-reload
sudo systemctl enable --now bazaar-mm
journalctl -u bazaar-mm -f

With pm2:

pm2 start dist/market-maker.js --name bazaar-mm --kill-timeout 60000
pm2 save

Whatever runs it:

  • Run one instance per key. Two processes quoting from the same account fight over its orders and its nonces.
  • Watch the wallet’s ETH. Every transaction pays gas and, for price-carrying calls, a Pyth fee; a bot out of ETH stops with “Your wallet does not have enough ETH for gas”.
  • Alert on repeated failures (the failed: and skipped lines), on the position reaching its limit, and on the market’s status leaving live.
  • Claim what the account earns now and then with claimFees (refunded bounties, and integrator fees if the bot names its own address as integrator; see Claiming Fees).
  • Test against a local chain first: Testing Locally with Anvil sets up a deployment the bot can connect to.

The Complete Bot

// A two-sided quoting bot for one Bazaar market.
//
// It keeps one post-only bid and one post-only ask resting around the live Pyth price, requotes both in
// a single amendOrders call when the price moves, stops adding to a side once its position reaches a
// limit, replaces orders before they expire, and cancels everything on shutdown.
//
//   RPC_URL=https://arb1.example/rpc PYTH_PRO_API_KEY=... \
//   BAZAAR_FACTORY=0x... BOT_PRIVATE_KEY=0x... MARKET=ETH/USD node market-maker.js
import {
  Bazaar,
  InputError,
  JsonRpc,
  OrderType,
  type PreparedCall,
  ProClient,
  Status,
  errorMessage,
  formatUnits18,
  livePrices,
  orders,
  parseUnits18,
  account,
} from "@liberta-labs/bazaar-sdk";
import { type Address, type Hex, createWalletClient, http, nonceManager } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { arbitrum } from "viem/chains";

function env(name: string, fallback?: string): string {
  const v = process.env[name] ?? fallback;
  if (v === undefined || v === "") throw new Error(`set ${name}`);
  return v;
}

const config = {
  rpcUrl: env("RPC_URL"),
  pythKey: env("PYTH_PRO_API_KEY"),
  factory: env("BAZAAR_FACTORY") as Address,
  market: env("MARKET", "ETH/USD"),
  /** Each quote's distance from the live price, in basis points. */
  spreadBp: BigInt(env("SPREAD_BP", "20")),
  /** Quotes snap to this price grid (a choice of the bot, not a protocol tick). */
  tick: parseUnits18(env("TICK", "0.01")),
  /** Size of each quote, in base units. */
  size: parseUnits18(env("QUOTE_SIZE", "0.5")),
  /** Stop quoting the side that would grow the position past this. */
  maxPosition: parseUnits18(env("MAX_POSITION", "5")),
  /** Requote when the price has moved this far from the last quote, in basis points. */
  requoteBp: BigInt(env("REQUOTE_BP", "5")),
  /** Keep at least this much collateral on the market (USDC, BAZAAR precision). */
  collateral: parseUnits18(env("COLLATERAL", "2000")),
  /** How long each quote lives; replaced 10 minutes before it expires. */
  orderLifetimeSeconds: 6 * 60 * 60,
  /** The least time between two requotes. */
  minRequoteMs: 2_000,
};

const log = (...parts: unknown[]) => console.log(new Date().toISOString(), ...parts);

const key = privateKeyToAccount(env("BOT_PRIVATE_KEY") as Hex, { nonceManager });
const wallet = createWalletClient({ account: key, chain: arbitrum, transport: http(config.rpcUrl) });
const me = key.address;

// One Pyth Pro client for the calls' updates and the stream. Node 22 and later have the WebSocket it
// streams over; on Node 20 pass one, e.g. the `ws` package's, as its WebSocket option.
const pro = new ProClient({ token: config.pythKey });
const base = await Bazaar.connect({
  transport: new JsonRpc(config.rpcUrl, { perSecond: 10 }), // stay under the RPC provider's rate limit
  factory: config.factory,
  chainId: arbitrum.id,
  prices: pro,
});
const market = await base.markets.getMarket(config.market);

// One price stream for the market. It is also a price source: calls built through `bz` reuse its
// update (a price generated at most 1 s ago) instead of asking Pyth Pro again.
const live = await livePrices([market], { client: pro });
await live.ready;
const bz = Bazaar.from({ ...base.ctx, prices: live });

const state: { snap: account.AccountSnapshot | null; lastTxBlock: number; lastQuoted: bigint; busy: boolean; failures: number; stopping: boolean } = {
  snap: null, // the account on the market, refreshed by the watcher
  lastTxBlock: 0, // the block of the bot's last transaction
  lastQuoted: 0n, // the price the resting quotes were built around
  busy: false,
  failures: 0,
  stopping: false,
};

const watch = bz.live.watchAccount(
  me,
  [market],
  (u) => {
    if (u.type === "account") state.snap = u.snapshot;
    else if (u.type === "event" && u.event.source === "pair" && u.event.name === "Trade") {
      const t = u.event.args;
      if (t.maker === me) log(`filled ${formatUnits18(t.fillSize)} at ${formatUnits18(t.price)} (${t.takerIsLong ? "sold" : "bought"})`);
    } else if (u.type === "error") log("watch error:", errorMessage(u.error));
  },
  { prices: live, refreshMs: 10_000, pollMs: 2_000 },
);
await watch.ready;

/**
 * Build, simulate and send one call, then wait for it. `build` runs right before sending, so a call
 * that carries a price is inside the 3 s window; one that aged during simulation is rebuilt once.
 */
async function act(build: () => Promise<PreparedCall>): Promise<boolean> {
  try {
    let call = await build();
    let sim = await bz.simulate(call);
    if (sim.ok && call.priceTime !== undefined && Date.now() / 1000 - call.priceTime > 2) {
      call = await build();
      sim = await bz.simulate(call);
    }
    if (!sim.ok) {
      log(`skipped "${call.summary}": ${sim.error!.message}`);
      return false;
    }
    const hash = await wallet.sendTransaction({ to: call.to, data: call.data, value: call.value });
    const receipt = await bz.waitForReceipt(hash, { timeoutMs: 60_000 });
    state.lastTxBlock = receipt.blockNumber;
    state.failures = 0;
    log(`${call.summary} (block ${receipt.blockNumber})`);
    return true;
  } catch (e) {
    state.failures++;
    log(e instanceof InputError ? `refused: ${e.message}` : `failed: ${errorMessage(e)}`);
    return false;
  }
}

async function ensureCollateral(): Promise<void> {
  const s = state.snap!;
  const short = config.collateral - s.position.collateral;
  if (short < parseUnits18("5")) return; // the pair's minimum deposit is 5 USDC
  const funding = await bz.allowance.usdcFunding(me, market.address, short);
  if (!funding.enoughBalance) throw new Error(`the bot's wallet holds too little USDC to deposit ${formatUnits18(short)}`);
  if (!funding.enoughAllowance) await act(() => bz.allowance.approveUsdc(me, market.address, funding.approveUsdc)); // exact amount
  await act(() => bz.trade.depositCollateral(market.address, me, short));
}

const snap = (p: bigint) => p - (p % config.tick);

/** Where the quotes should be, and how big, for a price and the current position. */
function targets(spot: bigint, s: account.AccountSnapshot) {
  const net = s.position.size === 0n ? 0n : s.position.isLong ? s.position.size : -s.position.size;
  return {
    bid: { price: snap((spot * (10_000n - config.spreadBp)) / 10_000n), size: net >= config.maxPosition ? 0n : config.size },
    ask: { price: snap((spot * (10_000n + config.spreadBp)) / 10_000n) + config.tick, size: -net >= config.maxPosition ? 0n : config.size },
  };
}

async function requote(spot: bigint): Promise<void> {
  const s = state.snap;
  if (!s || s.block < state.lastTxBlock) return; // wait for a snapshot that includes the last transaction
  if (s.status !== Status.Live) return log(`not quoting: the market is ${s.status}`);

  const t = targets(spot, s);
  const resting = s.orders.filter((o) => o.typeName === "Limit" && o.inBook);
  const bids = resting.filter((o) => o.isLong);
  const asks = resting.filter((o) => !o.isLong);

  // Cancel what should not be there: extra quotes, a side at its limit, quotes close to expiry.
  const expiring = (o: (typeof resting)[number]) => o.blocksLeft !== null && o.blocksLeft < orders.blocksIn(10 * 60);
  const drop = [
    ...bids.slice(1),
    ...asks.slice(1),
    ...(t.bid.size === 0n ? bids.slice(0, 1) : []),
    ...(t.ask.size === 0n ? asks.slice(0, 1) : []),
    ...resting.filter(expiring),
  ];
  if (drop.length > 0) {
    await act(() => bz.trade.cancelOrders(market.address, me, [...new Set(drop.map((o) => o.orderId))]));
    return; // the next round places replacements against the new snapshot
  }

  // Move what rests, in one call. Moving up, amend the ask first so the bid never crosses it.
  const amends: orders.AmendInput[] = [];
  const bid = bids[0];
  const ask = asks[0];
  const up = spot >= state.lastQuoted;
  const side = (o: typeof bid, q: { price: bigint; size: bigint }) =>
    o && o.limitPrice !== q.price ? [{ orderId: o.orderId, limitPrice: q.price, size: o.filled + q.size, bestEffort: true }] : [];
  amends.push(...(up ? [...side(ask, t.ask), ...side(bid, t.bid)] : [...side(bid, t.bid), ...side(ask, t.ask)]));
  if (amends.length > 0) await act(() => bz.trade.amendOrders(market.address, me, amends));

  // Place a missing side.
  const expiry = s.block + orders.blocksIn(config.orderLifetimeSeconds);
  for (const [isLong, q, existing] of [[true, t.bid, bid], [false, t.ask, ask]] as const) {
    if (existing || q.size === 0n) continue;
    await act(() =>
      bz.trade.createOrder(market.address, me, {
        orderType: OrderType.Limit,
        isLong,
        size: q.size,
        limitPrice: q.price,
        isPostOnly: true, // a quote that would cross is canceled, never filled as a taker
        expirationBlock: expiry,
      }),
    );
  }
  state.lastQuoted = spot;
}

let lastRequote = 0;

async function tick(force: boolean): Promise<void> {
  if (state.busy || state.stopping) return;
  const p = live.price(market);
  if (!p || !p.fresh) return; // no print yet, or the stream has fallen behind
  const moved = state.lastQuoted === 0n ? config.requoteBp : ((p.spot > state.lastQuoted ? p.spot - state.lastQuoted : state.lastQuoted - p.spot) * 10_000n) / state.lastQuoted;
  if (!force && (moved < config.requoteBp || Date.now() - lastRequote < config.minRequoteMs)) return;
  // Back off after repeated failures: 2 s, 4 s, ... up to a minute.
  if (state.failures > 0 && Date.now() - lastRequote < Math.min(60_000, 1_000 * 2 ** state.failures)) return;
  state.busy = true;
  lastRequote = Date.now();
  try {
    await requote(p.spot);
  } finally {
    state.busy = false;
  }
}

await ensureCollateral();
const unsubscribe = live.subscribe(() => void tick(false), [market]); // every new print
const timer = setInterval(() => void tick(true), 15_000); // expiries and missing sides, even when quiet
log(`quoting ${market.label} as ${me}`);

async function shutdown(signal: string): Promise<void> {
  if (state.stopping) return;
  state.stopping = true;
  log(`${signal}: canceling quotes`);
  clearInterval(timer);
  unsubscribe();
  while (state.busy) await new Promise((r) => setTimeout(r, 100));
  await watch.refresh();
  const ids = (state.snap?.orders ?? []).filter((o) => o.typeName === "Limit").map((o) => o.orderId);
  if (ids.length > 0) await act(() => bz.trade.cancelOrders(market.address, me, ids.slice(0, orders.MAX_CANCELS_PER_CALL)));
  watch.stop();
  live.stop();
  process.exit(0);
}
process.on("SIGINT", () => void shutdown("SIGINT"));
process.on("SIGTERM", () => void shutdown("SIGTERM"));

Low-Level Building Blocks

The Bazaar facade is built from smaller pieces, and the package exports all of them: the chain client and its JSON-RPC transport, ABI codecs, revert decoding, the batched market reader, the subgraph client, the Pyth Pro client, update codec and price service, ports of the contract formulas, and the typed ABIs. The keeper runs on the same code.

Reach for them when the facade does too much or too little: a bot that reads every market’s state in one request per block, simulates its own calls through the probe, signs with its own key and submits exactly once; a service that indexes logs; a tool that needs a contract call the SDK has no builder for. A frontend rarely needs this page. bz.ctx.chain is the Chain a facade connection uses, so you can mix both.

Every piece here is browser-safe unless it says otherwise; the samples run in Node and read their URLs and keys from the environment.

Chain

Chain wraps any EIP-1193 transport (request({ method, params })): a browser wallet’s provider, a viem client, or the SDK’s own JsonRpc. Chain.fromUrl(url, options) builds one over HTTP, with an optional second endpoint for submitting transactions (a sequencer, a private relay):

// A server-side Chain over HTTP. Keep the URL (which often holds the provider's key) in the environment.
const chain = Chain.fromUrl(process.env.RPC_HTTP_URL!, {
  perSecond: 10, // this client's own rate limit (default 25)
  timeoutMs: 15_000, // per request (default 30 s)
  retries: 2, // transient failures (default 3); never a send
  sendUrl: process.env.SEQUENCER_URL ?? null, // optional: submit transactions elsewhere
});

console.log(await chain.chainId(), await chain.blockNumber());
const head = await chain.block("latest"); // number, timestamp, hash, parentHash, baseFee
console.log(head.timestamp);

Chain.fromUrl takes the JsonRpc options and sendUrl (default: send through the read URL). new Chain(transport, { send }) does the same with transports you build:

// The same Chain over any EIP-1193 transport: a browser wallet, a viem client, or a JsonRpc.
const rpc = new JsonRpc(process.env.RPC_HTTP_URL!, { headers: { "x-api-key": process.env.RPC_KEY ?? "" } });
const viaRpc = new Chain(rpc, { send: new JsonRpc(process.env.SEQUENCER_URL ?? process.env.RPC_HTTP_URL!) });
console.log(rpc.displayUrl, redactUrl(process.env.RPC_HTTP_URL!)); // both safe to log
const inBrowser = new Chain(window.ethereum);
console.log(viaRpc.rpc === rpc, inBrowser.sendRpc === inBrowser.rpc);

Methods

MethodReturnsNotes
request(method, params?, via?)the JSON-RPC resultAny method. A JSON-RPC error is rethrown as RpcError however deep the transport nested it. via defaults to the read transport.
chainId(), blockNumber()number
block(at = "latest")Blocknumber, timestamp, hash, parentHash, baseFee (bigint, 0 when absent). at is a number or "latest", "pending", "safe", "finalized", "earliest".
code(address, at = "latest")HexAn earlier block needs a node that keeps historical state.
balance(address)bigintWei, at the head.
nonce(address, at = "pending")number
gasPrice()bigint
call(to, data, { from, value, block, overrides })HexOne eth_call; overrides is an eth_call state override. A revert throws RpcError with revertData.
batch(reads, block = "latest", chunk = 200)[ok, data][]Many reads in as few calls as the node allows (below).
supportsOverrides()booleanWhether eth_call honours state overrides here; asked once and cached in overridesSupported.
probe(sender, target, data, value, lens, pair)ProbeResultSimulate one call from the probe and report what it earns on pair (below).
estimateGas(from, to, data, value = 0n)bigint
logs(fromBlock, toBlock, addresses)RawLog[]eth_getLogs for the addresses over the inclusive range, with no topic filter; removed (reorged) logs are dropped. Keep ranges within your provider’s limit.
sendRaw(raw)HexSubmit a signed transaction exactly once (below).
receipt(hash)Receipt | nullnull until mined. status is "success" or "reverted"; logs are RawLogs.

Batched Reads

batch takes { to, data } reads and returns one [ok, returnData] per read, in order. A failing read does not fail the batch: it comes back as [false, revertData]. It uses, in order of preference:

  1. the probe’s aggregate, placed at PROBE_ADDRESS by an eth_call state override, chunk reads per call, when the node honours overrides;
  2. Multicall3 (0xcA11bde05977b3631167028862bE2a173976CA11) aggregate3, when it is deployed;
  3. parallel eth_calls.

If a node that honoured overrides stops doing so (a load balancer moved you to another node), batch notices the empty answer, records overridesSupported = false and retries with the next strategy.

// Many reads in as few eth_calls as the node allows. A failing read reports [false, revertData].
const L = lens();
const reads = [
  { to: PAIR, data: P.encode("lastPairPrice") },
  { to: PAIR, data: P.encode("marginRequirements") },
  { to: LENS, data: L.encode("triggerBounty", PAIR, 42n) },
];
const results = await chain.batch(reads);
const [ok, data] = results[2]!;
if (ok) console.log("bounty", formatUnits18(L.decode<bigint>("triggerBounty", data)));
else console.log("read failed:", decodeError(data).toString());
console.log("state overrides:", await chain.supportsOverrides());

The Probe

The probe (contracts/KeeperProbe.sol) is never deployed: its runtime code is placed at PROBE_ADDRESS (0x0000000000000000000000000000000000bea7e5) with PROBE_BALANCE (100 ETH, for Pyth fees) through an eth_call state override (stateOverride()). probe(sender, target, data, value, lens, pair) runs target.call{value}(data) from it and returns:

FieldMeaning
ok, retWhether the call succeeded, and its return or revert data (decode it with decodeError).
claimBefore, claimAfterThe probe’s claimable balance on pair (through the lens) before and after.
rewardclaimAfter - claimBefore, floored at 0: exactly what the call would earn, in BAZAAR precision.
gasUsedGas the call used.
adlPendingAfter, settlementFixedAfterThe pair’s ADL flag and settlement-fixed time after the call.

Pass pair = null to skip the claimable reads. A node that ignores the override answers "0x", and probe throws (“the node ignored the eth_call state override, so the probe cannot simulate here; use an RPC that supports state overrides”).

// Simulate a call from the probe (placed by state override) and read what it would earn.
const update: Hex[] = []; // a signed Pyth update for the pair's legs, from a PriceService
const pythFee = 0n; // Reader.updateFee(update): the oracle's fee in wei
const refresh = P.encode("refreshPrice", update);
const sim = await chain.probe(null, PAIR, refresh, pythFee, LENS, PAIR);
if (!sim.ok) console.log("would revert:", decodeError(sim.ret).toString());
else console.log("reward $" + formatUnits18(sim.reward), "gas", sim.gasUsed);

This is how the keeper measures every reward before it sends: see What the Keeper Does.

Sending a Signed Transaction

sendRaw(raw) submits a signed transaction (its canonical envelope, as viem’s signTransaction returns it) through the send transport, exactly once. JsonRpc never retries eth_sendRawTransaction; a transport of your own must not either. The hash is computed locally as keccak256(raw), so an unknown outcome can always be settled by looking the hash up.

OutcomeResult
The node answered with the same hashreturns the hash
The node already holds it (“already known”, “known transaction”, “transaction already imported”, “AlreadyKnown”)returns the hash
“nonce too low” (or “nonce has already been used”) and eth_getTransactionByHash finds the hash, on the read transport or then the send transportreturns the hash
raw is not 0x-prefixed hex bytes, or is an EIP-4844 blob transaction (type 3)TypeError; nothing was sent
Any other JSON-RPC error (underpriced, insufficient funds, …), or “nonce too low” with no transaction foundRpcError, the node’s own error; the transaction is not in that node’s pool
The transport failed (connection error, timeout, non-2xx HTTP, oversized or unreadable answer), the node’s error reports a timeout, the node answered with another hash or with something that is not a hash, or the lookup after “nonce too low” failedSendError with hash and cause: the outcome is unknown and the transaction may still be mined

After a SendError, look up e.hash (receipt, eth_getTransactionByHash). Submitting the same signed bytes again is safe: it cannot run twice. Signing a new transaction for the same work is not, until the first is settled.

// Sign with your own key and submit exactly once; settle an unknown outcome by the hash.
const account = privateKeyToAccount(process.env.KEEPER_PRIVATE_KEY as Hex);
const gas = await chain.estimateGas(account.address, PAIR, refresh, pythFee);
const signed = await account.signTransaction({
  chainId: await chain.chainId(),
  type: "eip1559",
  to: PAIR,
  data: refresh,
  value: pythFee,
  nonce: await chain.nonce(account.address),
  gas: (gas * 12n) / 10n,
  maxFeePerGas: head.baseFee * 2n,
  maxPriorityFeePerGas: 0n,
});
try {
  const hash = await chain.sendRaw(signed);
  const receipt = await chain.receipt(hash); // null until mined: poll it
  console.log(hash, receipt?.status);
} catch (e) {
  if (e instanceof SendError) {
    // Unknown outcome: it may still be mined. Look up e.hash; resending `signed` is safe, signing anew is not.
    console.warn(e.message, e.hash);
  } else if (e instanceof RpcError) {
    console.error("refused:", e.rpcMessage); // underpriced, insufficient funds, ...: not in the pool
  } else throw e; // TypeError: not signed hex, or a blob transaction
}

A frontend sends through the user’s wallet instead: see Sending Transactions.

The Deployment Block

No Bazaar contract records the block it was deployed in. findDeploymentBlock(chain, address, { head, from }) finds it by binary search on eth_getCode(address, block) (code appears in exactly one block and never leaves, since Bazaar’s contracts cannot self-destruct): about 30 reads on Arbitrum One. It searches [from, head], by default [0, the chain's head]. It needs historical state (hosted providers such as Alchemy, Infura and QuickNode keep it); it throws DeploymentBlockError when there is no code at head, or when a read fails (a pruned node), rather than guess. The keeper uses it when its configuration leaves out startBlock.

// The block the factory was deployed in, by binary search on eth_getCode (needs historical state).
try {
  const startBlock = await findDeploymentBlock(chain, FACTORY);
  console.log("index from block", startBlock);
} catch (e) {
  if (e instanceof DeploymentBlockError) console.error(e.message); // pruned node, or no code there
  else throw e;
}

JsonRpc

JsonRpc(url, options) is a small JSON-RPC client over HTTP(S) that implements Transport. It rate limits itself, retries transient failures (never a send), caps response sizes, keeps revert data, and never puts its URL or a header’s value into an error message.

OptionDefaultMeaning
perSecond25Requests per second this client allows itself; concurrent callers queue in call order.
timeoutMs30000Per-request timeout.
retries3Retries after a failed request (HTTP error, connection error, timeout, unreadable answer), with delays doubling from 500 ms. Never for the methods in NEVER_RETRIED.
headersnoneSent with every request. A name or value HTTP cannot carry (a line break, NUL, …) throws TypeError at construction, naming the header, never its value.
fetchglobalThis.fetch
maxResponseBytes64 MiBThe most bytes one response may hold, counted after decompression; a larger one throws ResponseTooLargeError and is not retried.

request(args, { retries }) overrides the retry count for one request. displayUrl is the URL with any key masked (see URLs).

NEVER_RETRIED holds eth_sendRawTransaction, eth_sendTransaction, eth_sendRawTransactionConditional, eth_sendRawTransactionSync, eth_sendPrivateTransaction and eth_sendBundle: a lost answer to one of them does not mean it was not done.

ErrorWhen
RpcErrorThe node answered with a JSON-RPC error: code, rpcMessage, data, and revertData (the revert payload of a failed eth_call or eth_estimateGas, whatever shape the node uses; "0x" for a bare “execution reverted”; null when there is none). Not retried.
HttpErrorA non-2xx answer, after the retries: status; the message carries the redacted URL, and 401/403 add “check the key in the URL”.
ResponseTooLargeErrorThe body was over maxResponseBytes: limit.
ErrorAnything else that failed (connection, timeout, not JSON-RPC), after the retries, with its text scrubbed of the URL and header values.

toRpcError(e) turns whatever a transport threw into an RpcError when it carries a JSON-RPC error (EIP-1193 providers and viem nest it at different depths), or null. readTextCapped(response, maxBytes, what) is the capped body reader JsonRpc and SubgraphClient use; sleep(ms) is a promise timer.

Codecs

One ContractCodec per contract, built once and cached: factory(), pair(), lens(), terminator(), oracle(), or codec(name) with a ContractName. new ContractCodec(abi) builds one for any other ABI.

MemberMeaning
encode(name, ...args)Calldata for name(args). Structs may be objects of their fields or arrays. A wrong argument count throws TypeError.
decode<T>(name, data)A return value, named as the ABI names it: a struct decodes to an object of its fields, several named outputs to an object of the output names, a single output to the bare value.
decodeLog<A>(log)A RawLog decoded to { name, address, args, blockNumber, blockHash, txHash, logIndex }, or null when its topic0 is not one of the contract’s events. A dynamic indexed value (string, bytes, array, struct) is recoverable only as its topic hash.
topic(event), selector(fn), fn(name)topic0, a selector, a FunctionSpec. An unknown name throws.
functions, events, eventByName, errorsThe ABI’s specs, by name, topic0 and selector.

viem decodes integers of 48 bits or fewer as number and wider ones as bigint; both encode from either. allErrors() maps every custom error the five contracts declare by selector, and canonicalType(param) and signature(name, inputs) build the canonical signature strings selectors are hashed from.

// Encode a call, run it, decode the answer with the ABI's own names.
const P = pair();
const raw = await chain.call(PAIR, P.encode("lastPairPrice"));
const price = P.decode<{ spotPrice: bigint; updateTs: bigint }>("lastPairPrice", raw);
console.log("stored price", formatUnits18(price.spotPrice), "at", Number(price.updateTs));

// A struct output decodes to an object of its fields.
const bucket = P.decode<{ isLong: boolean; size: bigint; collateral: bigint }>(
  "positionBuckets",
  await chain.call(PAIR, P.encode("positionBuckets", user)),
);
console.log(bucket.isLong ? "long" : "short", formatUnits18(bucket.size));

The pair’s codec decodes the events of the libraries it links, which run in the pair’s context and log from its address:

// Raw logs for a block range, decoded by the pair's codec (which knows its libraries' events too).
const from = head.number - 1_000;
for (const log of await chain.logs(from, head.number, [PAIR])) {
  const ev = P.decodeLog<{ maker: Address; taker: Address; fillSize: bigint; price: bigint }>(log);
  if (ev?.name === "Trade") console.log(ev.blockNumber, ev.args.maker, formatUnits18(ev.args.fillSize), "@", formatUnits18(ev.args.price));
}
// topic0 for a filter of your own:
console.log(P.topic("Trade"));

decodeError

decodeError(data) turns revert data into a DecodedError:

Revert datanameargs
Shorter than a selector ("0x", out of gas)Reverted{}
Error(string)Error{ message }
Panic(uint256)Panic{ code }
A custom error any of the five contracts declaresits name, e.g. BazaarPair__TradingHaltedits arguments by name (arg0, … when unnamed)
Anything elseUnknown(0x12345678){}
MemberMeaning
messageThe text for an end user: the registered user message (from Errors & Messages), else the operator hint, else the name.
hintThe operator-facing hint from KEEPER_HINTS, or "".
opensAt()For a “too early” error, the time the call opens: the first of its deadline, effectiveTs, expiryTs, opensAt or scheduledTs arguments, or null.
toString()name(arg=value, ...): hint, for logs.
dataThe revert data, lowercase.
try {
  await chain.call(PAIR, P.encode("claimFees"), { from: user });
} catch (e) {
  if (e instanceof RpcError && e.revertData) {
    const err = decodeError(e.revertData);
    console.log(err.name, err.args); // e.g. CollateralLib__NothingToClaim {}
    console.log(err.message); // the user-facing sentence, or the operator hint, or the name
    console.log(err.toString()); // name(args): hint, for logs
    const opens = err.opensAt(); // for a "too early" error, when the call opens (unix seconds)
    if (opens !== null) console.log("retry after", new Date(opens * 1000).toISOString());
  } else throw e;
}

KEEPER_HINTS holds the sentences written for someone running the keeper. User messages are registered by installUserMessages() (which connect calls); registerUserMessages(map) adds your own. For any error a call can throw, use errorMessage(e).

Reader

Reader makes the keeper’s batched market reads through any object with call and batch (a Chain). Reads for many markets go out as one chain.batch, never one request per market. Call deployment(factory) first: every other read needs it (they throw “Reader.deployment() has not been called”).

MethodReturns
deployment(factory)Deployment: factory, lens, terminator, oracle, usdc. Throws when the address does not answer as a Bazaar factory.
wiringProblems()string[]: a lens or terminator that does not report a factory, or belongs to another. Empty when consistent.
pairAddresses()Every pair the factory deployed.
describe(addresses)Market[]: pairId, baseFeedId, legs (one raw Pyth feed, or two for a composite), invertQuote, continuous (traded 24/7). label and description are empty: fill them yourself. Throws when one of a pair’s reads fails.
marketStates(markets, block, timestamp)Map<Address, MarketState> at block: one batched read of 12 values per market. A market whose price, margin or vault read fails is left out.
buckets(pair, accounts)Map<Address, PositionBucket>: isLong, size, entryValue, collateral, entryFundingIndex, takeProfitOrderId, stopLossOrderId.
orders(pair, ids)Map<bigint, OrderRecord>: the stored order with its trigger bounty (0 when that read failed). A slot that never held an order has a zero creator; expiryBlock is 2^64 - 1 for an order that never expires.
windowDeposits(pair, accounts)Each account’s ADL score deposit (0 where the read fails).
predecessor(pair, isLong, limitPrice)The book-insertion hint lens.findPredecessor gives, followed through up to 8 pages of 64.
claimable(wallet)Map<Address, bigint>: what the wallet can claim, by pair; pairs owing nothing are left out.
updateFee(updates)The oracle’s fee in wei for having these updates verified: Pyth’s fee per update times their number (0 for none). The same fee applies to live calls and to settlement sets.
usdcBalance(account)USDC balance, in USDC units.
one(to, codec, fn, ...args)One call, decoded.
const reader = new Reader(chain);
const dep = await reader.deployment(FACTORY); // factory, lens, terminator, oracle, usdc
const problems = await reader.wiringProblems();
if (problems.length) throw new Error(`not a consistent Bazaar deployment: ${problems.join("; ")}`);

const markets = await reader.describe(await reader.pairAddresses()); // pairId, feed, legs, calendar
const head = await chain.block("latest");
const states = await reader.marketStates(markets, head.number, head.timestamp); // one batched read

for (const m of markets) {
  const s = states.get(m.address);
  if (!s) continue; // its price, margin or vault read failed
  const status = marketStatus(s, head.timestamp);
  console.log(m.address, status, "stored price", formatUnits18(s.spot), "free insurance", formatUnits18(freeCapital(s)));
  if (status === Status.Adl) console.log("  ADL window open since", s.adlAuctionStart);
}
console.log("oracle", dep.oracle);

Models

MarketState is one read of a market: block, timestamp, the stored price spot and its updateTs, imrBp, mmrBp, laggedMmrBp, the stored fundingIndex and the lens’s projectedFundingIndex (accrued to now), adlPending, adlAuctionStart, adlTerminationTs, settlementFixedTs, scheduledTs, terminated, open interest (longOi, shortOi), insurance, deficit, and the vault’s inventory on each side (longInv, shortInv: size, entryNotional, bankruptcyNotional, seized, entryFundingIndex, seizedTs). Times are seconds, zero when unset.

marketStatus(state, now) gives the market’s Status, checked in this order:

StatusWhen
terminatedThe pair is terminated.
settlingIts settlement price is fixed, not yet finalized.
limboA cutoff is scheduled and now + LIMBO_LEAD (6 s) has reached it: trading has halted, the price is not yet fixed.
adlAn ADL window is open: matching halts, liquidation and ADL run.
liveNone of the above.

freeCapital(state) is the insurance balance less the seizure earmarked against inventory the vault holds, and inventorySize(state) the inventory on both sides. Market adds mutable fields the keeper uses (state, dirty, lastRead); marketStatic, marketFromStatic, Position, Order, orderRemaining and emptyInventory are the keeper’s store types.

Formulas

Ports of the contract formulas, each with the Solidity’s integer rounding, so the SDK’s view of a position matches the contract’s whenever the inputs match. The chain still decides: use them to preview and to pick what to simulate.

FunctionPort ofReturns
bucketState(isLong, size, entryValue, collateral, entryFundingIndex, price, fundingIndex, mmrBp)BucketLib.calculateStateBucketState: unrealizedPnl, fundingPnl, totalPnl, currentNotional, minRequiredCollateral, availableEquity, equity, isSolvent. mmrBp must already be the effective rate.
effectiveMmrBp(mmrBp, laggedMmrBp)BucketLib.effectiveMmrBpThe lower of the current rate and a non-zero lagged one.
marginRatio(state)Equity over the maintenance requirement as a number: below 1 is liquidatable.
triggerReached(orderType, isLong, triggerPrice, price)MatchingEngineLib._triggerReachedWhether a trigger order fires at price (inclusive).
adlThreshold(auctionStartTs, now)AdlLib._getAdlScoreThresholdThe ADL score threshold, decaying quadratically from 25e18 to 1 over 10 minutes.
adlScore(totalPnl, collateral, windowDeposit)AdlLib._scoreCollateralA winner’s ADL score.
refreshBountyGap, refreshBountyDue, refreshBountyDueAtRiskParamsLib.payRefreshBountyWhen refreshPrice pays its bounty.
convertExponent(value, fromExpo, toExpo = -18)BazaarMathLib.convertExponentA Pyth price rescaled; RangeError past MAX_EXPONENT_SHIFT (77).
pairPrice(legs, invertQuote)BazaarOraclePairPrice (spot, low, high, publishTime, confidenceOk) composed from one or two legs, or null for a non-positive leg.
legConfOk(price, conf)Whether one leg’s confidence is within the 2% cap.
mulDiv, signedMulDivOpenZeppelin Math.mulDiv, BazaarMathLib.signedMulDivFloor, and rounding toward zero.

An account’s health with the market’s own rates, as the keeper’s liquidation job computes it:

// One account's health on each market, at the stored price, with the contract's own formula.
for (const m of markets) {
  const s = states.get(m.address);
  const b = (await reader.buckets(m.address, [user])).get(user);
  if (!s || !b || b.size === 0n) continue;
  const mmr = effectiveMmrBp(s.mmrBp, s.laggedMmrBp);
  const st = bucketState(b.isLong, b.size, b.entryValue, b.collateral, b.entryFundingIndex, s.spot, s.projectedFundingIndex, mmr);
  console.log(m.address, "equity", formatUnits18(st.equity), "needs", formatUnits18(st.minRequiredCollateral), "ratio", marginRatio(st).toFixed(2));
  if (!st.isSolvent) console.log("  below maintenance margin: liquidatable");
}

bz.account.accountSnapshot does this at the live price for you: see Accounts & Portfolios. Orders read straight from the pair:

const [first] = markets;
if (first) {
  const recs = await reader.orders(first.address, [1n, 2n, 3n]);
  for (const [id, o] of recs) {
    if (o.creator === "0x0000000000000000000000000000000000000000") continue; // never held an order
    console.log(id, o.orderType, formatUnits18(o.size - o.filledSize), "left, bounty", formatUnits18(o.bounty));
  }
  const owed = await reader.claimable(user); // pair -> claimable balance
  for (const [p, amount] of owed) console.log(p, "claimable", formatUnits18(amount));
}

SubgraphClient

SubgraphClient(url, apiKey?, options) is the keeper’s GraphQL client: raw queries, the indexer’s head, and paging pinned to one block. For typed, paginated history use History instead.

OptionDefaultMeaning
timeoutMs30000Per request.
fetchglobalThis.fetch
maxResponseBytes16 MiBThe most bytes one answer may hold.
maxPages1000The most pages pages reads for one entity (a million rows).
MethodReturns
query<T>(query, variables?)The answer’s data.
meta()[block, hash, hasIndexingErrors]: the indexer’s head.
pages<T>(entity, fields, where, block, bytesId = false)Every row of entity matching where (GraphQL filter text, may be empty) as of block, in id order, SUBGRAPH_PAGE (1000) rows a request. bytesId for entities whose id is Bytes.
descriptions(block)Each pair’s listing description, by address.
proposals()Open termination, insurer, listing and identifier-upgrade proposals, raw.
close()Nothing to release; there so callers can close every client alike.

apiKey is sent as Authorization: Bearer <key>. Every failure (unreachable, HTTP 400 or above, GraphQL errors, an unreadable or oversized answer, no data) throws this module’s SubgraphError, with the URL redacted and the key masked. pages also throws when the subgraph returns rows out of id order or without a valid id, or runs past maxPages, rather than page forever. The subgraph lags the chain and cannot see state that changes without an event: treat what it returns as discovery and re-read from the chain before acting.

const url = process.env.SUBGRAPH_URL!;
const sg = new SubgraphClient(url, process.env.SUBGRAPH_API_KEY ?? null, { timeoutMs: 20_000 });

try {
  const [block, , hasIndexingErrors] = await sg.meta();
  if (hasIndexingErrors) console.warn("the subgraph hit an indexing error");

  // Every open position at that block, 1000 rows a request, every page pinned to the same block.
  const rows = await sg.pages<{ id: string; account: { id: string }; size: string }>(
    "positions",
    "account { id } size",
    "isOpen: true",
    block,
  );
  for (const r of rows) console.log(getAddress(r.account.id), BigInt(r.size));

  // Any query of your own: its `data`.
  const d = await sg.query<{ pairs: { id: string; description: string }[] }>("{ pairs(first: 5) { id description } }");
  for (const p of d.pairs) console.log(p.id, p.description); // render descriptions as text, never HTML
} catch (e) {
  if (e instanceof SubgraphError) console.error(e.message); // the URL redacted, the key masked
  else throw e;
}

This SubgraphError (exported at the top level) is a different class from history.SubgraphError, which the history functions throw; test for the one that matches the client you use.

Prices

ProClient

new ProClient(options) reads Pyth Pro: its request API (the latest signed update for a set of feeds, and the one-second channel’s update for any past second), its WebSocket stream, and its symbols registry. The options are those of Connecting. A token or header that an HTTP header cannot carry, a base URL with a query or a fragment, and a credential bound for a plain http:// or ws:// endpoint on another machine without allowInsecure (the token, headers, or a user name, password or query in the endpoint’s URL; only exactly localhost, 127.0.0.0/8 and [::1] count as this machine) throw ProError at construction, naming the setting and quoting no token, header value or URL.

MethodReturns
latest(ids)Print[]: the latest print for each feed with a usable live price, all from one signed update.
latestUpdate(ids)That update as a ProUpdate (blob, signer, timestampUs, channel, feeds, prints), or null. More than 255 feeds (lazer.MAX_FEEDS_PER_UPDATE, all one update carries) throws.
atTick(ids, second)The fixed_rate@1000ms update stamped exactly second as a ProUpdate, or null when there is none (settlement proofs). Throws when the endpoint answers with another update.
symbols({ query?, assetType? })ProSymbol[] from the registry: id, feedId, symbol, assetType, minChannel, state, schedule, …, every text field printable and at most 256 characters. No token is sent. An answer that is not a list throws ProError.
stream(feeds, signal?, { idleTimeout, onNotice })An async generator of Print[] batches. feeds is one set or a list of sets; each distinct set is one subscription, so its updates carry exactly its feeds. It connects to every stream endpoint, yields each update once, and reconnects a dropped endpoint with a growing delay: one that drops, sends an update it cannot read, one stamped more than maxUpdateAhead seconds ahead of clock, one from an untrusted signer, or MAX_STRAY_MESSAGES (64) messages in a row that are not price updates. With a signer check, each set keeps only its newest update waiting and the sets take turns, so slow checks never starve a set; nothing counts as taken before its check passes. No new update for idleTimeout seconds (default STREAM_IDLE_TIMEOUT, 10; null or 0 waits forever) throws ProError.
close()Ends any open streams.
displayUrlThe request API’s base URL with any key masked.

Feed IDs are bytes32 everywhere in the SDK, as the contracts hold them; only this client converts them to Pyth’s numbers. feedIdHex(1) is BTC/USD’s 0x00…01, proFeedId(id) the number a raw ID holds, and isRawFeedId(id) the oracle’s own test (a non-zero 32-bit integer in a bytes32).

A Print has feedId, price, conf, expo, publishTime (the second the price was generated in), generatedUs, timestampUs, channel, signer, blob (the signed update to attach to a call) and received (seconds on a monotonic clock). Every field but received and signer is read from the update’s signed payload, never from the JSON beside it. A feed without a usable live price (no price, no confidence, a price at or below zero, a property the request left out, a price or confidence that does not scale to the oracle’s 18 decimals) yields no print; whyNotLive(entry) says why. An endpoint’s text that a message quotes (a body, a close reason) is put on one line and bounded. Responses over MAX_RESPONSE_BYTES (16 MiB) and stream messages over MAX_STREAM_MESSAGE (1 MiB) are refused. Errors are ProError: unreachable, refused (401/403: “check the token”), 404, another HTTP error, not JSON, an update that cannot be read, or one from a signer that is not trusted.

Signers. With isTrustedSigner set, the signer of each update the client takes is recovered and an update it does not accept is never used. verifierSignerCheck(chain, oracle) is the check connect installs: it asks the deployment’s Pyth verifier (oracle.pythLazer(), then isValidSigner) and keeps each answer for a minute (ttl); a “trusted” answer is then refreshed in the background and used meanwhile for up to maxAge (10 minutes). A failed check’s error is reduced to a short summary with every URL redacted, since the transport behind it may quote its own.

Tokens. mintProToken(apiKey) exchanges an API key for a short-lived token (server side): over https only, unless allowInsecure, and never following a redirect. cachedToken(mint) turns any such call into a token provider that mints only when the held token is about to expire; an expiry already past or nearer than the margin is treated as unknown (the token is reused for 5 minutes) rather than minting a token for every request.

// One client for Pyth Pro's request API, its stream endpoints and its symbols registry. The token is
// sent as a Bearer header and masked in every error. Node 22 and later have the WebSocket the stream
// runs over; on Node 20 pass one (the `ws` package's) as the WebSocket option.
const pro = new ProClient({
  token: process.env.PYTH_PRO_API_KEY, // a server may hold the API key itself
  timeout: 5,
  // Use only updates signed by a signer the deployment's Pyth verifier trusts.
  isTrustedSigner: verifierSignerCheck(chain, deployment.oracle),
});

try {
  // One request, one signed update carrying every feed asked for.
  const prints = await pro.latest(markets.flatMap((m) => m.legs));
  for (const p of prints) console.log(proFeedId(p.feedId), p.price, "e", p.expo, "generated in second", p.publishTime);
} catch (e) {
  if (e instanceof ProError) console.error(e.message); // unreachable, refused (401/403), an untrusted signer, not JSON
  else throw e;
}

// Labels from Pyth Pro's symbols registry: "ETH/USD", or "XAU/USD" for a composite.
const symbols = await fetchSymbols(pro, markets.flatMap((m) => m.legs));
for (const m of markets) m.label = marketLabel(symbols.get(m.legs[0]!), m.legs[1] ? symbols.get(m.legs[1]) : null);

fetchSymbols(client, feeds) maps feed ids to their Pyth symbols and fetchFeedInfo(client, feeds) to their whole registry entries (both empty when the registry cannot be read); feedLabel("Crypto.BTC/USD") is "BTC/USD", and marketLabel(base, quote?) labels a composite (one with a quote leg) in USD.

PriceService

PriceService(source, options) keeps the newest print of every watched feed and the newest update of every watched set of feeds, from a ProClient or any PriceSource (latest and stream), and builds per-market prices and updates from them. It is what the keeper runs; frontends use bz.livePrices() instead.

OptionDefaultMeaning
streamtrueFollow the stream, falling back to polling when it fails.
streamIdleTimeout10Seconds without a new update before the stream is dropped and reconnected; null or 0 waits forever.
pollInterval1Seconds between polls when not streaming.
clockthe system clockThe time freshness is judged at, in Unix seconds.
sendMargin1A price must be this many seconds younger than the 3 s limit to count as fresh.
maxFutureSkew5Seconds a print may be dated ahead of clock before it is ignored (with one warning per feed).
ethUsdMaxAge60Seconds after which ethUsd() stops trusting the ETH/USD print.
ethUsdBounds[100, 1000000]The ETH/USD prices ethUsd() accepts.
loggerconsoleWhere endpoint failures are reported; null for silence.
MemberMeaning
watchMarkets(markets), watchSets(sets), watch(feeds)Watch each market’s legs (or each set) as one subscription, or each feed on its own. ETH/USD (ETH_USD_FEED, Pyth Pro feed 2) is always watched, for gas. A new set restarts the stream.
run()Follow the stream (or poll) until stop(), through any failure: three in a row mark the service unhealthy and switch a streaming service to polling for a minute. Never rejects.
stop()End run at once and release nextPrints.
fetch(feeds?)One request for the latest prints, outside the loop.
ingest(prints)Take in prints from a source of your own.
onPrints(listener)Call listener(feeds) after each batch that brings a newly generated price; returns the function that removes it.
nextPrints()Wait for newly generated prices and return their feeds; for one consumer.
price(market)The composed PairPrice, or null while a leg has no print.
fresh(market, now?, margin?)Every leg’s price generated within 3 - sendMargin seconds of now and the price inside the 2% confidence cap.
closed(market, now?, after = 15)No price generated for after seconds while the endpoint is otherwise delivering: a closed session.
legs(market)The prints the market prices from, or null.
updateFor(market)The signed update a call on the market attaches: the one update that carries exactly its legs (one per leg only while its own subscription has delivered none or lags).
printsFor(feeds)The prints a call reading these feeds together would attach.
ethUsd(now?)USD per ETH for pricing gas, or null when missing, too old or out of bounds.
missing()Watched feeds without a print yet.
latest, healthy, lastError, lastUpdateThe prints by feed, and the endpoint’s health.

A print is newer when its price was generated later (generatedUs), several times a second on a fast channel. An update that only carries the same price forward under a later timestamp, as Pyth sends for a quiet feed, changes nothing, and its price stays as old as when it was generated.

const prices = new PriceService(pro, { sendMargin: 1, logger: console });
prices.watchMarkets(markets); // one subscription per market's legs (ETH/USD is always watched, for gas)
void prices.run(); // follows the stream, falls back to polling, never rejects; prices.stop() ends it

const off = prices.onPrints((feeds) => {
  for (const m of markets) {
    if (!m.legs.some((leg) => feeds.has(leg))) continue;
    const pp = prices.price(m); // the oracle's composition: spot, low, high, confidenceOk
    if (!pp) continue;
    const sendable = prices.fresh(m); // every leg generated within 3 s less sendMargin, and inside the 2% cap
    console.log(m.label, formatUnits18(pp.spot), sendable ? "fresh" : "stale");
  }
});

A call that needs a price attaches updateFor(market) and sends Reader.updateFee(update) wei with it:

// The signed update a call on one market attaches: the one update that carries exactly its legs.
const [m] = markets;
if (m && prices.fresh(m)) {
  const update = prices.updateFor(m);
  const fee = await reader.updateFee(update); // wei to send with the call: Pyth's fee per update
  console.log(update.length, "update(s), Pyth fee", fee);
}

Signed Updates

A Pyth Pro update is an envelope (a magic number, a signature, the payload’s size) around a signed payload: the update’s timestamp in microseconds, its channel, and per feed the properties the requester asked for. The lazer namespace reads and builds them: parseUpdate(blob) (the payload and the recovered signer), readUpdate(blob) (the payload alone), parsePayload, splitUpdate, recoverSigner, and for tests and local chains encodePayload, signPayload and buildUpdate. The parser is as strict as the oracle’s own (LazerPayloadLib): the same bytes are malformed, and each feed’s included bits tell a property that was left out of the request from one that is present and zero. Malformed input throws LazerError.

// Work with a signed update directly: read its payload, and recover who signed it.
const print = m ? prices.legs(m)?.[0] : undefined;
if (print) {
  const u = await lazer.parseUpdate(print.blob); // { signer, timestampUs, channel, feeds, payload }
  console.log(u.signer, lazer.channelName(u.channel), u.timestampUs);
  for (const f of u.feeds) console.log(f.id, f.price, f.exponent, f.confidence, f.feedUpdateTimestamp);
}
console.log(feedIdHex(1)); // Pyth Pro feed 1 (BTC/USD) as the bytes32 the contracts hold: 0x00…01
// The price a set of prints composes to, without a service:
if (print) console.log(pairPrice([print], false)?.spot);

URLs

Hosted RPCs, indexers and price services often put the API key in the URL: Alchemy’s /v2/<key>, Infura’s /v3/<id>, The Graph gateway’s /api/<key>/, a ?key= query, user:password@. redactUrl(url, mask = "***") keeps only the parts of a URL known not to be secret and masks the rest, whatever the key looks like:

  • a user name and password: always masked;
  • the host: a label under 16 characters is kept, a longer one only when it is lowercase words joined by -;
  • path segments: kept when empty, a word in PATH_WORDS (rpc, api, subgraphs, stream, arbitrum, …), a version (v2), a number of up to 8 digits, one or two characters, or lowercase words joined by - or _; every other segment is masked;
  • query values: masked, except plain values of the parameters in PUBLIC_PARAMS (network, chain, chainId, asset_type);
  • the fragment: masked.

A value that is not an http(s) or ws(s) URL is returned unchanged (isUrl tells). urlCredential(url) says where a URL may carry a credential, as a phrase, or null; it is non-null exactly when redactUrl would mask something. Every SDK error that mentions a URL passes it through redactUrl, and the keeper’s config loader refuses a URL with a credential written into its file.

// Before printing a URL that may hold a key:
console.log(redactUrl("https://arb-mainnet.g.alchemy.com/v2/abcdef0123456789")); // https://arb-mainnet.g.alchemy.com/v2/***
console.log(urlCredential("https://example.com/rpc?key=abc")); // "a key in its key parameter"
console.log(urlCredential("https://arb1.arbitrum.io/rpc")); // null

The Typed ABIs

The five contracts’ ABIs are vendored as as const modules from the Bazaar repository’s abi/ directory (MIT): bazaarFactoryAbi, bazaarOracleAbi, bazaarPairAbi, bazaarPairLensAbi, bazaarPairTerminatorAbi, and ABIS by ContractName (CONTRACT_NAMES lists them). bazaarPairAbi also carries the events and errors of the libraries the pair links: they run by DELEGATECALL in the pair’s context, so their logs carry the pair’s address and their reverts surface from its calls. Use them with viem, wagmi or any typed client:

const client = createPublicClient({ chain: arbitrum, transport: http(process.env.RPC_HTTP_URL) });

// `as const` ABIs: viem infers argument and return types from them.
const [spotPrice, , updateTs] = await client.readContract({ address: PAIR, abi: bazaarPairAbi, functionName: "lastPairPrice" });
const owed = await client.readContract({ address: LENS, abi: bazaarPairLensAbi, functionName: "getClaimableFeesAll", args: [user] });
console.log(spotPrice, updateTs, owed);

// The pair's ABI includes its linked libraries' events, which carry the pair's address.
const trades = await client.getContractEvents({ address: PAIR, abi: bazaarPairAbi, eventName: "Trade", fromBlock: (await client.getBlockNumber()) - 1_000n });
for (const t of trades) console.log(t.args.maker, t.args.fillSize, t.args.price);

console.log(CONTRACT_NAMES, Object.keys(ABIS)); // BazaarFactory, BazaarOracle, BazaarPair, BazaarPairLens, BazaarPairTerminator
console.log(`ABIs from Bazaar commit ${ABI_SOURCE.commit} (${ABI_SOURCE.date})`);

ABI_SOURCE records the Bazaar commit the ABIs were copied from (commit, date, dirty). npm run sync-abis refreshes them: see Development.

API Index

Every public export of the two packages, one line each, with a link to the page that covers it. The index is built from the packages’ sources (packages/sdk/src/index.ts, packages/keeper/src/index.ts and every module they re-export), so it lists exactly what an import can reach. The descriptions come from each export’s own documentation comment; the TSDoc in the source, which your editor shows on hover, has the full text.

How to Read This Index

  • Export is the name as you import it. A dotted name lives in a namespace: history.trades is import { history } from "@liberta-labs/bazaar-sdk", then history.trades(ctx, ...). The link goes to the page that explains it.
  • Kind is what the export is. const + type is a value and a type of the same name (OrderType, Status, the keeper’s zod schemas). bz marks a function that is also on the Bazaar object with the connection bound: bz.history.trades(...) is history.trades(bz.ctx, ...).
  • Amounts are bigint in 18 decimals (BAZAAR precision) unless a name says USDC (6 decimals); times, blocks and counts are number. See Units & Constants.

A namespace keeps the area’s names from colliding: orders.OrderInput and previews.OrderInput are two different types, and so are SubgraphError (the low-level client’s) and history.SubgraphError (the history queries’).

@liberta-labs/bazaar-sdk

import { Bazaar, OrderType, parseUnits18, errorMessage } from "@liberta-labs/bazaar-sdk"; // flat exports
import { history, termination } from "@liberta-labs/bazaar-sdk";                          // namespaces

The Bazaar Object

Bazaar.connect(options) returns one object with the connection bound to every context-first function of every area. Its members:

MemberWhat it is
Bazaar.connect(options)Connect to a deployment: reads its wiring from the factory and checks the chain. options is BazaarOptions.
Bazaar.from(ctx)Wrap a context you already have (from connect).
bz.ctxThe BazaarContext: chain, chain id, deployment addresses, price source, subgraph, integrator.
bz.chainIdThe connected chain’s id.
bz.deployment{ factory, lens, terminator, oracle, usdc }.
bz.simulate(call, opts?)Run a prepared call as the user without sending it (simulate); opts.from, opts.estimateGas.
bz.send(wallet, call, opts?)Send through an EIP-1193 wallet (sendCall); returns the transaction hash.
bz.waitForReceipt(hash, opts?)Poll for the receipt (waitForReceipt); throws CallError if it reverted.
bz.livePrices(opts?)Live prices for the listed markets (livePrices).
bz.markets, bz.account, bz.live, bz.orders, bz.trade, bz.insurance, bz.signing, bz.permits, bz.allowance, bz.relay, bz.batching, bz.history, bz.previews, bz.termination, bz.listings, bz.governance, bz.umaEach area’s context-first functions, bound: the rows marked bz below. The pure helpers stay on the namespaces.

The list of bound functions is generated from the modules (npm run gen-bindings, see Development), so a new context-first function appears on bz once the bindings are regenerated.

The Facade and the Connection

ExportKindWhat it is
BazaarclassThe SDK bound to one connection: Bazaar.connect(options), then bz.<area>.<function>(...) with the context already passed.
BazaarOptionsinterfaceWhat Bazaar.connect takes: ConnectOptions, with prices also accepted as Pyth Pro client options or a ProClient, and subgraph as a URL or { url, apiKey }.
requirePairfunctionpair, checksummed, once the context’s factory confirms it is one of its pairs (factory.isPair); an InputError on pair otherwise.
NetworkPresetinterfaceA built-in network: name, chain id, factory (null until launch), start block, subgraph URL, whether official, and the sequencer URL.
NETWORKSconstThe built-in networks: arbitrum-one, arbitrum-sepolia and anvil.
PRICE_ENDPOINTSconstprices.endpoint shortcuts, each a set of Pyth Pro endpoints (or null until the service exists).
ProEndpointsinterfaceWhere Pyth Pro is served: its stream endpoints, its request API, and its symbols registry.
PYTH_PROconstPyth’s own Pyth Pro endpoints.
PYTH_PRO_TOKEN_URLconstWhere a Pyth Pro API key is exchanged for a short-lived token.
connectfunctionRead the deployment’s wiring from its factory and return a context bound to it.
requirePricesfunctionThe context’s price source; throws ConnectError when the context was connected without one.
requireSubgraphfunctionThe context’s subgraph source; throws ConnectError when the context was connected without one.
PriceUpdateSourceinterfaceSigned Pyth Pro price updates, as the pair’s calls carry them: latest, and optionally atTick and useSignerCheck.
verifierSignerCheckfunctionA SignerCheck that asks a deployment’s Pyth verifier whether it trusts a signer, keeping each answer briefly.
VerifierSignerCheckOptionsinterfaceOptions of verifierSignerCheck: ttl, maxAge and clock.
VERIFIER_SIGNER_ABIconstThe verifier’s isValidSigner view.
SubgraphSourceinterfaceA GraphQL endpoint serving Bazaar’s subgraph.
BazaarContextinterfaceA connection: the Chain, the chain id, the deployment’s addresses (factory, lens, terminator, oracle, USDC), and the optional price source, subgraph and integrator.
ConnectOptionsinterfaceWhat connect takes: transport, factory, and the optional prices, subgraph, integrator, chainId and checkSigners.
ConnectErrorclassConnecting failed, or a call needs a price source or subgraph the context was connected without.

Calls, Price Updates and Units

ExportKindWhat it is
simulatefunctionRun the call as from (or call.from) without sending it.
toTransactionRequestfunctionThe call as a viem / wagmi transaction request.
sendCallfunctionSend through an EIP-1193 wallet (eth_sendTransaction).
waitForReceiptfunctionPoll for the receipt; throws CallError if it reverted or did not land in time.
buildAndSendfunctionBuild, then send at once: for calls carrying a price update, build as late as possible so the print is still inside the pair’s 3 s window when the transaction lands.
PreparedCallinterfaceA write ready to simulate and send: action, to, data, value, summary, pullsUsdc and the rest.
SimulationinterfaceWhat simulate returns: ok, the return data, the decoded error (with its user message) and the gas estimate.
InputErrorclassAn argument a builder refused before building anything: field names it.
CallErrorclassA send that reverted or was rejected, with the decoded reason when there is one.
pairFeedsfunctionThe pair’s feeds.
priceUpdateForfunctionThe freshest signed update for the pair’s feeds, and the ETH fee the oracle charges to verify it.
updateFeefunctionThe ETH fee the oracle charges to verify a price update (getUpdateFee); 0 for an empty update.
PairFeedsinterfaceA pair’s price id and its raw Pyth legs (one, or base then quote for a composite) and whether the quote is inverted.
FreshUpdateinterfaceWhat priceUpdateFor returns: the update bytes, the Pyth fee to send as value, and the oldest publish time among the legs.
toUsdcUnitsfunctionBAZAAR precision -> USDC units, floored as the pair floors it.
fromUsdcUnitsfunctionUSDC units -> BAZAAR precision.
toUsdcUnitsCeilfunctionBAZAAR precision rounded up to the next whole USDC unit (for allowances and permits that must cover it).
floorToUsdcfunctionFloor to what the pair actually moves: a multiple of 1e12 (one micro-USDC).
parseDecimalfunctionA decimal string (“12.5”, “1e-3” is refused) -> fixed point with decimals (default 18).
formatDecimalfunctionFixed point -> decimal string, trimming trailing zeros; maxDecimals rounds toward zero.
UnitErrorclassA decimal string or fixed-point value that cannot be converted (bad syntax, too many decimals).
parseUnits18const“12.34” dollars or units -> BAZAAR precision.
formatUnits18constBAZAAR precision -> “12.34”.
bpToFractionconstBasis points -> a fraction as a number (for display only).
toNumber18constA BAZAAR-precision value as a JS number (display only: loses precision past 2^53 / 1e18).

Errors and Messages

Also exported as the format namespace below, with the formatters.

ExportKindWhat it is
errorMessagefunctionThe text to show a user for any error a Bazaar call can throw.
installUserMessagesfunctionRegister USER_MESSAGES so DecodedError.message returns them.
userMessagefunctionThe user-facing message for a custom error by name, with its arguments; the name itself when unknown.
walletErrorMessagefunctionA message for a failure the wallet raised rather than the contract.
USER_MESSAGESconstEvery custom error’s user-facing message, by error name.
REASON_MESSAGESconstUser-facing messages for the plain reason strings Pyth’s verifier reverts with, by the string.

Live Prices

Also exported in the priceFeeds namespace below.

ExportKindWhat it is
proPriceSourcefunctionThe context’s price source over Pyth Pro (Pyth’s own endpoints, a proxy, or a local mock): latest, atTick, prints, and the client.
livePricesfunctionLive prices for every market of a context (listMarkets), or for the markets given.
LivePricesclassLive prices for a set of markets.

The Area Namespaces

One namespace per area of @liberta-labs/bazaar-sdk. Context-first functions take a BazaarContext (from connect, or bz.ctx) as their first argument.

markets

import { markets } from "@liberta-labs/bazaar-sdk". Market lists, snapshots, flags and the book.

ExportKindWhat it is
markets.readerForfunctionThe keeper’s batched Reader, wired to the context’s deployment (no factory read needed).
markets.listMarketsfunction, bzEvery market the context’s factory has deployed, in the factory’s order, with its static facts and a label for display (“BTC/USD”, “AAPL/USD”; a composite is labelled in USD).
markets.getMarketfunction, bzOne market, by its address, its pair id, or its label (case-insensitive; labels come from the feeds’ Pyth symbols, never from the subgraph’s description).
markets.resolvePricesfunctionEach market’s live price and venue state.
markets.pastTradingCutofffunctionWhether now is in limbo: a cutoff is scheduled and the block is within LIMBO_LEAD of it (BazaarPair._pastTradingCutoff).
markets.marketFlagsfunctionThe flags for a market state at now (a block timestamp), following the pair’s own gates.
markets.annualizedFundingfunctionA funding rate per hour (1e18 = 100%, as the lens reports it) as a yearly fraction for display (0.0876 = 8.76% a year).
markets.marketStatusAtfunctionThe snapshot’s status at another time (seconds), for countdowns without a new read.
markets.adlScoreThresholdAtfunctionThe ADL auction’s score threshold at another time, for a live display without a new read.
markets.headForfunctionThe block to read at: the caller’s, or the latest.
markets.marketSnapshotsfunction, bzSeveral markets’ live state at one block.
markets.marketSnapshotfunction, bzOne market’s live state at one block; see marketSnapshots.
markets.getBookDepthfunction, bzThe best levels price levels of one side of the book.
markets.getBookfunction, bzBoth sides of the book, read together; see getBookDepth.
markets.TradingHourstype“24/7” for a continuously traded market; “trading-hours” for one that follows its venue’s calendar (stocks, FX).
markets.MarketInfointerfaceWhat never changes about a market: what a market list and a market header show.
markets.MarketReftypeA market as callers name it: its info, its pair address, its pair id, or its label.
markets.SymbolSourceinterfaceWhere feed symbols come from: a symbols registry’s base URL, or anything that lists its entries (symbols()).
markets.ListMarketsOptionsinterfaceOptions of listMarkets: refresh (read the factory’s pair list again) and metadata (where feed symbols come from).
markets.VENUE_CLOSED_AFTERconstSeconds a trading-hours market’s feed may go without a print, while the price endpoint is otherwise delivering, before its venue counts as closed (PriceService.closed’s default).
markets.HeldPriceinterfaceA price a caller already holds, in BAZAAR precision.
markets.LivePriceQuoteinterfaceA composed live price for one market, as LivePrices.price returns it.
markets.LivePriceViewinterfaceWhat snapshots read live prices and the venue from: a LivePrices, or anything shaped like it.
markets.PriceQuoteinterfaceThe price a snapshot valued an account at, and where it came from.
markets.ResolvedPriceinterfaceWhat resolvePrices returns per market: the price quote (or null) and whether the venue is closed (null when unknown).
markets.ResolveOptionsinterfaceOptions of resolvePrices: live prices, a held price, whether to fetch from Pyth Pro, and whether a price is needed.
markets.MarketFlagReasonsinterfaceWhy something a trader may try is refused right now; null where it is not.
markets.MarketFlagsinterfaceWhat the pair refuses right now, as one set of flags a UI can gate its buttons on.
markets.StoredPriceinterfaceThe pair’s stored oracle price (lastPairPrice): what its next call falls back to without a fresh one.
markets.FundingViewinterfaceA snapshot’s funding: the current and accruing rates per hour (1e18 = 100%), the index and projected index, the last accrual and the cap.
markets.MarginViewinterfaceA snapshot’s margin rates in basis points: initial, maintenance, and the 24 h lagged maintenance rate.
markets.OpenInterestViewinterfaceA snapshot’s open interest: long and short sizes and notionals, and the liquidated inventory the vault holds.
markets.InsuranceViewinterfaceA snapshot’s insurance fund: its balance, free capital, the seizure earmarked against inventory, its target ratio and its ledgers.
markets.AdlViewinterfaceA snapshot’s ADL state: whether a window is pending, its epoch, when it opened, and the auction’s score threshold.
markets.TerminationViewinterfaceA snapshot’s termination state: the scheduled cutoff, when trading halts ahead of it, and when the settlement price was fixed.
markets.BookSummaryinterfaceA snapshot’s book heads: the best bid and ask order ids and the number of orders resting on each side.
markets.MarketSnapshotinterfaceA market’s live state at one block, with the one status and the flags a UI shows.
markets.SnapshotOptionsinterfaceOptions of marketSnapshot(s): the block to read at, live prices to judge the venue with, and whether to fetch prices.
markets.MAX_FUNDING_RATE_CONTINUOUSconstBazaarPairLens.fundingRate’s cap per hour, by calendar (FundingLib.MAX_FUNDING_RATE_*).
markets.MAX_FUNDING_RATE_SESSIONconstThe funding-rate cap per hour for a market with trading hours (1e18 = 100%).
markets.DepthLevelinterfaceOne price level of the book: every live resting order at that limit price.
markets.BookSidetype"bids" or "asks".
markets.BookDepthinterfaceWhat getBookDepth returns: the market, side and block, the levels best first, and whether every level is included.
markets.BookDepthOptionsinterfaceOptions of getBookDepth: the lens page size and the most orders to walk.

account

Accounts and portfolios.

ExportKindWhat it is
account.positionHealthfunctionThe position valued at spot with the state’s projected funding index and effective maintenance rate.
account.accountHealthfunctionThe snapshot’s position revalued at a new spot price (a live print), with no read: for a display that follows the price between snapshots.
account.accountSnapshotfunction, bzThe account user on one market.
account.portfoliofunction, bzEvery market where user has collateral, a position, live orders or claimable fees, as accountSnapshots, with totals.
account.accountSnapshotsfunctionSeveral accounts’ snapshots on chosen markets in one pass (used by watchAccount to refresh the markets an event touched).
account.AccountPositioninterfaceThe account’s position bucket on the pair (BazaarPairLens.getPositionBucket), BAZAAR precision.
account.PositionHealthinterfaceThe position valued at a price, by the pair’s own formula (BucketLib.calculateState) at the projected funding index (what a price stored now would accrue to) and the effective maintenance rate.
account.LensBucketStateinterfaceBazaarPairLens.checkBucketSolvency at the snapshot’s spot: the contract’s own view, at the stored funding index.
account.AccountOrderinterfaceOne of the account’s live orders, decoded from pair.orders(id).
account.OrderExposureinterfaceBazaarPairLens.outstandingOrderExposure: the live limit orders’ remaining notional (at their limits) and size per side.
account.TerminalPayoutinterfaceBazaarPairLens.getTerminalEntitlement: what a withdrawal pays on a market that is winding down.
account.WalletStateinterfaceThe wallet’s USDC balance and the allowance the pair may pull, in USDC units (6 decimals).
account.AccountSnapshotinterfaceOne account on one market at one block: position, health, orders, collateral, max withdrawable, fees and wallet state.
account.AccountOptionsinterfaceOptions shared by the account reads: the block to read at, live prices to value positions with, and whether to fetch prices.
account.AccountSnapshotOptionsinterfaceOptions of accountSnapshot: AccountOptions plus a held price and a market snapshot at the same block.
account.PortfolioTotalsinterfaceA portfolio’s totals across markets: collateral, equity, unrealized and funding PnL, claimable fees and max withdrawable.
account.PortfoliointerfaceWhat portfolio returns: one AccountSnapshot per market the account is active on, and the totals.

live

Watching markets and accounts through their events.

ExportKindWhat it is
live.decodeLiveLogfunctionA pair’s or the terminator’s log as a typed LiveEvent; null for a log that is neither (or a terminator event that names no pair).
live.watchMarketfunction, bzWatch one market: every pair event it emits (and terminator events about it) as typed LiveEvents, and a fresh MarketSnapshot at the head after events (at most one per minSnapshotMs).
live.watchAccountfunction, bzWatch one account on a set of markets.
live.OrderUpdatePayloadinterfaceOrderUpdated’s payload: the order’s stored state at the event.
live.PairEventArgsinterfaceEach pair event’s arguments, as the pair emits them (BAZAAR precision; timestamps, blocks and enums as numbers).
live.PairEventNametypeThe name of a pair event PairEventArgs describes.
live.LogPositioninterfaceWhere a log sits.
live.PairEventtypeA pair event; narrow on name for its typed args.
live.TerminatorEventinterfaceA terminator event about one market (proposals, schedules, settlement prices, insurer votes).
live.LiveEventtypeA PairEvent or a TerminatorEvent.
live.FollowOptionsinterfaceOptions of LogFollower and the watchers: the poll interval, the first block, and the rest.
live.FollowNoticetypeWhat a follower reports besides logs.
live.LogFollowerclassFollows the logs of a set of contracts from a block cursor.
live.WatchOptionsinterfaceOptions of watchMarket and watchAccount: FollowOptions plus live prices and price fetching for the snapshots.
live.SnapshotCausetypeWhy a watcher sent a snapshot: initial, events, timer or manual.
live.UndecodableLoginterfaceA log from a watched contract that could not be decoded (a malformed or unexpected event): it is reported here and skipped, and the rest of its batch is still delivered.
live.MarketUpdatetypeWhat watchMarket delivers: an event, a snapshot (with whether the book changed), an undecodable log or a follower notice.
live.AccountUpdatetypeWhat watchAccount delivers: an event, an account snapshot, an undecodable log or a follower notice.
live.WatchHandleinterfaceWhat a watcher returns: ready, refresh() and stop().

priceFeeds

Streamed prices and the price source over Pyth Pro (client/prices.ts).

ExportKindWhat it is
priceFeeds.updateFromPrintsfunctionThe signed updates a call attaches for these prints: each distinct update they came in, once.
priceFeeds.proPriceSourcefunctionThe context’s price source over Pyth Pro (Pyth’s own endpoints, a proxy, or a local mock): latest, atTick, prints, and the client.
priceFeeds.isProPriceSourcefunctionWhether a context’s price source is a proPriceSource (it then exposes its client).
priceFeeds.livePricesfunctionLive prices for every market of a context (listMarkets), or for the markets given.
priceFeeds.BROWSER_FUTURE_SKEWconstHow far ahead of this device’s clock a live print may be dated before it is ignored (seconds).
priceFeeds.ProPriceSourceinterfaceA PriceUpdateSource over Pyth Pro, with the client and parsed prints exposed.
priceFeeds.MarketPriceinterfaceOne market’s live price, composed as BazaarOracle composes it (BAZAAR precision).
priceFeeds.PriceListenertypeCalled with the markets whose price or flags changed (one map per batch; do not keep it).
priceFeeds.LivePricesOptionsinterfaceOptions of livePrices: the PriceService options plus pro (options for a client of its own) or client when no context source is given.
priceFeeds.LivePricesclassLive prices for a set of markets.

orders

Order specs, checks and packing, with no chain read except the read* functions.

ExportKindWhat it is
orders.joinsActiveSetfunctionWhether the order joins the account’s active set and its 32-order cap (OrderManagementLib._joinsActiveSet).
orders.canRestfunctionWhether the order can rest in the book and so wants an insertion hint: a Limit GTC, or a StopLimit once armed.
orders.packTriggerPricefunction(bounty << 128) | price: the OrderSpec.triggerPrice word of a trigger order.
orders.unpackTriggerPricefunctionSplit a triggerPrice word into its price (low 128 bits) and bounty (high 128 bits).
orders.specBountyfunctionThe bounty a spec carries: what createOrder pulls from the wallet on top of any relayer fee.
orders.blocksInfunctionL2 blocks in seconds (about four a second), for an expiry currentBlock + blocksIn(s).
orders.buildOrderSpecfunctionCheck an order the way OrderManagementLib.createOrder will, and return the spec to send or sign.
orders.checkOrderCapsfunctionThe per-user caps a UI must respect, against an account’s live orders: 32 active Limit (GTC) + StopLimit orders per pair, and one TakeProfit and one StopLoss per position.
orders.orderInputFromSpecfunctionThe order a spec describes, for re-validation (a relayer checking a signed spec) or display.
orders.validateOrderSpecfunctionCheck a spec built elsewhere (or received for relaying) against the same rules, including the one the builder enforces by construction: a non-trigger type must leave the bounty bits clear.
orders.describeOrderfunctionOne line for a confirmation screen: “Buy 1.5 at 2000 (limit, GTC, post-only)”.
orders.amendIdWordfunctionAn amendment’s id word, exactly as BazaarPairLens.amendIdWord / bestEffortAmendIdWord pack it: the order id in bits 0-63, hints in bits 64-127 and 128-191, the best-effort flag in bit 192.
orders.unpackAmendIdWordfunctionThe fields of an id word.
orders.encodeAmendsfunctionThe flat amends array amendOrders takes and AmendOrders signs: per amendment its id word, its new price, its new total size. 1 to MAX_AMENDS_PER_CALL amendments.
orders.decodeAmendsfunctionThe amendments a flat amends array holds (for a relayer showing or checking a signed batch).
orders.readOrderStatefunction, bzThe account facts the creation checks need, in one batched read.
orders.readTriggerBountyfunction, bzWhat a trigger order’s escrow still holds for whoever fires it (BAZAAR precision): lens.triggerBounty.
orders.readOrderLifetimeConstantsfunction, bzThe lifetime constants as the lens reports them (pure there; useful to confirm the SDK’s copy).
orders.TimeInForceconst + typeBazaarTypes.TimeInForce: what an arriving order does with the size the book could not fill.
orders.TIF_NAMESconstTime-in-force names by number: 0 GTC, 1 IOC, 2 FOK.
orders.MAX_SLIPPAGE_BPconstSlippage cap for Market, TakeProfit and StopLoss orders: 10%.
orders.MAX_PLACEMENT_DEVIATION_BPconstA Limit price must sit within ±50% of the oracle price at creation or amendment.
orders.MIN_ORDER_LIFETIME_BLOCKSconstMinimum life of an order that can rest, in Arbitrum L2 blocks (~1 minute at 4 blocks a second).
orders.MAX_ORDER_LIFETIME_BLOCKSconstMaximum life, in L2 blocks (~1 year); a later expiry is clamped to it by the pair.
orders.NEVER_EXPIRE_BLOCKconstThe expiry the pair stamps on TakeProfit and StopLoss orders: they never expire.
orders.MAX_ACTIVE_LIMIT_ORDERS_PER_USERconstActive Limit + StopLimit orders one account may hold on one pair.
orders.MAX_AMENDS_PER_CALLconstAmendments per amendOrders call (the per-user cap, so one batch can requote every order).
orders.MAX_CANCELS_PER_CALLconstOrder ids per cancelOrders call (BazaarPair.MAX_CANCELS_PER_CALL).
orders.AMEND_BEST_EFFORT_BITconstBit 192 of an amendment’s id word: skip the amendment instead of reverting the batch.
orders.CLOSE_POSITIONconstA TakeProfit or StopLoss of this size closes the whole position, whatever it is when it fires.
orders.L2_BLOCKS_PER_SECONDconstArbitrum produces about four L2 blocks a second; expiries are counted in them.
orders.EXPIRY_LANDING_MARGIN_BLOCKSconstBlocks of slack the SDK adds on top of the minimum lifetime when it checks an expiry: the pair measures the lifetime from the block the transaction lands in, a few seconds after it is built.
orders.OrderSpecinterfaceBazaarTypes.OrderSpec, field for field: what createOrder takes and the CreateOrder signature covers.
orders.MatchHintsinterfaceBazaarTypes.MatchHints: execution aids for the arrival walk and book insertion, outside every signature.
orders.NO_HINTSconstEmpty MatchHints: no predecessors, the default walk budget.
orders.OrderInputinterfaceAn order as a trader describes it.
orders.OrderEnvironmentinterfaceWhat the pair knows that an order is judged against.
orders.AccountOrderStateinterfaceThe account facts the creation checks need: its position and its order slots.
orders.isTriggerTypeconstWhether an order type is a trigger type (StopLimit, TakeProfit, StopLoss).
orders.AmendInputinterfaceOne amendment of a resting Limit (GTC) order: its new price and new TOTAL size (filled part included).
orders.bestEffortAmendIdWordconstBazaarPairLens.bestEffortAmendIdWord.

trade

Every trading write, and the price and hint helpers they use.

ExportKindWhat it is
trade.parsePriceUpdatefunctionThe live prices a priceUpdate: bytes[] carries, read from each Pyth Pro update’s signed payload.
trade.UpdatePriceinterfaceOne feed’s live price as a signed update carries it.
trade.pairPriceFromUpdatefunction, bzThe pair price an update proves, composed from the pair’s legs exactly as BazaarOracle composes it (formulas pairPrice).
trade.livePairPricefunction, bzThe live pair price from the context’s price source, with the update it came from.
trade.storedPairPricefunction, bzThe price the pair last stored (lastPairPrice): what it falls back to on a closed market.
trade.findPredecessorfunction, bzThe order a new entry at limitPrice on its side should rest directly behind (0 = the head).
trade.matchHintsForfunction, bzMatchHints for an order: the predecessor on its own side at its limit price when it can rest (a Limit GTC, or a StopLimit that arms on arrival), none otherwise.
trade.depositCollateralfunction, bzDeposit collateral: the pair floors amount (BAZAAR precision) to whole micro-USDC, adds it to the trader’s position bucket and pulls it from the wallet.
trade.terminalWithdrawalfunctionOn a terminated pair, what a withdrawal of amount (collateral, BAZAAR precision) pays out.
trade.withdrawCollateralfunction, bzWithdraw collateral (BAZAAR precision; the wallet receives it floored to micro-USDC).
trade.optionalUpdatefunction, bzA price update for a call that reads a price only sometimes: none when withPrice is false; attached when a price source is connected (the pair refunds the fee of an update it does not read).
trade.prepareOrderfunction, bzCheck an order against the account’s position and order slots, the current L2 block and the oracle price, and build its spec with the context’s integrator (orders.ts buildOrderSpec has the rules).
trade.createOrderfunction, bzCreate an order, matched against the book in the same transaction.
trade.prepareAmendsfunction, bzCheck amendments the way OrderManagementLib._amendOne will and fill in hints for the ones that move.
trade.amendOrdersfunction, bzAmend resting Limit (GTC) orders in place: each gets a new price and total size and keeps its id, expiry and integrator.
trade.checkCancelIdsfunction1 to 200 distinct order ids, as cancelOrders takes them.
trade.cancelOrdersfunction, bzCancel the trader’s orders (up to 200): each is unlinked from the book in the same write and its remaining keeper bounty returns to the trader’s claimable balance (claimFees).
trade.claimFeesfunction, bzPay the caller everything the pair owes it: refunded trigger bounties, keeper rewards, relayer and integrator fees.
trade.executeTriggeredOrdersfunction, bzFire the trader’s own trigger orders (StopLimit, TakeProfit, StopLoss) whose trigger the live price has reached: how a zero-bounty order is executed, by its owner.
trade.PriceUpdateErrorclassA price update that is not a Pyth Pro update, or does not cover the pair’s feeds.
trade.terminalPayoutNoteconstWhat a terminal payout of payout reads as on a confirmation screen.
trade.PreparedOrderinterfaceAn order checked against the chain: the spec to send or sign, and what it was checked against.
trade.CreateOrderOptionsinterfaceOptions of createOrder: hints to use instead of the ones the SDK finds, the walk budget maxSteps, and permitData.
trade.PreparedAmendsinterfaceAmendments checked against the orders, the book and the price, with insertion hints filled in.

insurance

Insurance deposits, the exit, quotes, budgets and locks.

ExportKindWhat it is
insurance.claimOpensAtfunctionBazaarPair.InsuranceVaultLib.claimOpensAt.
insurance.depositToInsurancefunction, bzDeposit USDC into the pair’s insurance fund for shares priced at the fund’s value at the mark.
insurance.requestInsuranceWithdrawalfunction, bzRequest to withdraw shareAmount insurance shares: starts the 13-day cooldown, after which the execution window opens (2 days, or 4 on a pair with trading hours).
insurance.executeInsuranceWithdrawalfunction, bzThe next step of a requested withdrawal.
insurance.getInsuranceSharePricefunction, bzWhat one share redeems for at the pair’s stored mark (BAZAAR precision; 1.0 while no shares exist).
insurance.getInsuranceDepositValuefunction, bzWhat user’s shares redeem for at the pair’s stored mark (BAZAAR precision).
insurance.insuranceSharesfunction, bzuser’s insurance shares (0 for a balance a fund recap wiped).
insurance.quoteInsuranceDepositfunction, bzShares a deposit of amount (BAZAAR precision, floored to micro-USDC as the pair floors it) would mint at the live price (or price): lens.quoteInsuranceDeposit, the pair’s own arithmetic.
insurance.quoteInsuranceWithdrawalfunction, bzUSDC (BAZAAR precision) a redemption of shareAmount would pay at the live price (or price): lens.quoteInsuranceWithdrawal.
insurance.getInsuranceWithdrawalLockfunction, bzlens.getInsuranceWithdrawalLock: where an LP’s two execute calls stand.
insurance.getInsuranceWithdrawalBudgetfunction, bzlens.getInsuranceWithdrawalBudget at the live price (or price): the number a paying call is checked against, as of now.
insurance.getInsuranceWithdrawalConstantsfunction, bzlens.getInsuranceWithdrawalConstants: durations in seconds, budget terms in bp.
insurance.insuranceExitPlanfunction, bzRead where user’s insurance exit on pair stands and when the next call can land.
insurance.MIN_INSURANCE_DEPOSITconstThe smallest insurance deposit: 5 USDC (BAZAAR precision).
insurance.MAX_INSURANCE_DEPOSITconstThe largest insurance deposit the pair accepts: 2^128 - 1 (BAZAAR precision).
insurance.INSURANCE_WITHDRAWAL_COOLDOWNconstSeconds between a withdrawal request and its execution window: 13 days.
insurance.INSURANCE_WITHDRAWAL_WINDOW_CONTINUOUSconstSeconds the execution window stays open on a 24/7 market: 2 days.
insurance.INSURANCE_WITHDRAWAL_WINDOW_SESSIONconstSeconds the execution window stays open on a market with trading hours: 4 days.
insurance.INSURANCE_WITHDRAWAL_CLAIM_DELAYconstThe paying call opens this long after the lock, and after the last stored 2% price jump.
insurance.INSURANCE_WITHDRAWAL_REOPEN_CLAIM_DELAYconstThe paying call also opens this long after the last stored price that ended a gap of an hour or more.
insurance.REOPEN_GAP_MIN_STALENESSconstRiskParamsLib.REOPEN_GAP_MIN_STALENESS: a store after this long without one ends a gap.
insurance.withdrawalWindowOfconstThe execution window for a market, by whether it trades continuously.
insurance.InsuranceQuoteinterfaceA deposit or redemption quote: the shares or USDC, the mark it was priced at, and the update’s publish time.
insurance.InsuranceWithdrawalLockinterfaceWhat getInsuranceWithdrawalLock returns: whether the request is locked, the locked amount, the lock time, when the claim opens and when the window ends.
insurance.InsuranceWithdrawalBudgetinterfaceWhat getInsuranceWithdrawalBudget returns: what the shared 6-hour budget still admits, its cap, and when it refills.
insurance.InsuranceWithdrawalConstantsinterfaceWhat getInsuranceWithdrawalConstants returns: the cooldown, the windows and the rate-limit terms.
insurance.InsuranceExitSteptypeWhere an LP’s exit stands: none, request, cooldown, lock, waitClaim, claim, redeem, expired or blocked.
insurance.InsuranceExitPlaninterfaceWhat insuranceExitPlan returns: the step, a message for the LP, the next action and when it can land.

signing

EIP-712 signing for gasless calls, deadlines and the clock-skew check.

ExportKindWhat it is
signing.pairDomainfunctionThe pair’s EIP-712 domain.
signing.domainSeparatorfunctionWhat pair.DOMAIN_SEPARATOR() should equal.
signing.typeHashfunctionkeccak256 of the primary type’s string: what lens.getTypehashes reports for it.
signing.typedDataForfunctionThe typed data a user signs for fields on pair.
signing.relayedDigestfunctionThe EIP-712 digest the pair recovers the signer from.
signing.typedDataJsonfunctionThe typed data as the JSON string eth_signTypedData_v4 takes (integers as decimal strings).
signing.floorRelayerFeefunctionCheck a relayer fee and floor it to USDC precision, as _resolveUser charges it: the signed fee is then exactly the USDC the user pays.
signing.readMetaTxNoncefunction, bzThe signer’s next nonce on the pair (pair.metaTxNonces(user)).
signing.eip712Limitsfunction, bzThe fee cap and deadline window the deployment enforces, from lens.getEip712Constants() (read once per context).
signing.chainTimefunction, bzThe latest block’s timestamp: the clock deadlines are judged against.
signing.checkClockSkewfunctionCheck the chain time an RPC reported against this device’s clock and return the lower of the two: the time every deadline the SDK signs is counted from.
signing.deadlineBasefunctionThe time a deadline is counted from: min(latest block’s timestamp, this device’s clock), checked by checkClockSkew.
signing.chainDeadlinefunction, bzA deadline windowSeconds (default: the whole window) past the lower of the latest block’s timestamp and this device’s clock, never more than the deployment’s maxDeadlineWindow (10 s).
signing.normalizeSignaturefunctionA wallet’s signature in the 65-byte r ‖ s ‖ v form OpenZeppelin’s ECDSA accepts: v as 27/28 (some wallets return 0/1), an EIP-2098 64-byte signature expanded, and a high-s signature refused.
signing.signTypedDataV4functionAsk an EIP-1193 wallet to sign typed data with eth_signTypedData_v4, so it shows the user every field it signs.
signing.takesPriceUpdatefunctionThe actions whose call takes priceUpdate: a relayer attaches a fresh one at submission.
signing.encodePairWritefunctionCalldata for one of the eight actions, direct (DIRECT_META) or relayed.
signing.verifyTypehashesfunction, bzConfirm that what the SDK signs is what the deployment verifies.
signing.BAZAAR_DOMAIN_NAMEconstThe pair’s EIP-712 domain name: "BazaarPair".
signing.BAZAAR_DOMAIN_VERSIONconstThe pair’s EIP-712 domain version: "1".
signing.MAX_RELAYER_FEEconstMetaTxLib.MAX_RELAYER_FEE: $1 per action, BAZAAR precision.
signing.MAX_DEADLINE_WINDOWconstMetaTxLib.MAX_DEADLINE_WINDOW: a deadline at most this many seconds past the block’s timestamp.
signing.EIP712_DOMAIN_TYPE_STRINGconstThe EIP-712 domain type string the pair uses (name, version, chainId, verifyingContract).
signing.TYPE_STRINGSconstThe primary types, verbatim from MetaTxLib (whose hashes lens.getTypehashes returns).
signing.PrimaryTypetypeThe name of one of the primary types in TYPE_STRINGS.
signing.EIP712_DOMAIN_FIELDSconstThe EIP-712 domain’s field list, as viem and eth_signTypedData_v4 take it.
signing.BAZAAR_TYPESconstThe same types as viem / eth_signTypedData_v4 field lists; they encode to TYPE_STRINGS.
signing.RELAYED_ACTIONSconstThe pair functions that take a signature.
signing.RelayedActiontypeOne of the pair functions in RELAYED_ACTIONS.
signing.PRIMARY_TYPEconstThe primary type each relayed action signs (depositCollateral signs DepositCollateral, …).
signing.SignedFieldstypeWhat each action signs besides (nonce, deadline, relayerFee): its own arguments.
signing.MetaFieldsinterfaceThe meta-transaction fields every signature covers: nonce, deadline and relayerFee.
signing.MetaArgsinterfaceThe (nonce, deadline, relayerFee, signature, permitData) tail of a pair call; all zero/empty for a direct call.
signing.DIRECT_METAconstThe all-zero meta tail of a direct (unsigned) pair call.
signing.TypedDataDomaininterfaceAn EIP-712 domain: name, version, chain id and verifying contract.
signing.PairTypedDatainterfaceTyped data as eth_signTypedData_v4 and viem take it.
signing.MAX_CLOCK_SKEWconstHow far, in seconds, the chain time the RPC reports may sit from this device’s clock before the SDK refuses to count a deadline from it.
signing.DEV_CHAIN_IDSconstLocal development chains (Anvil and Hardhat 31337, Ganache and geth –dev 1337), whose clocks tests move at will: their deadlines are counted from chain time unchecked.
signing.SigningErrorclassA signing step the wallet refused or could not do in the form Bazaar accepts.
signing.ClockSkewErrorclassThe RPC’s chain time and this device’s clock disagree too much to stamp a deadline (checkClockSkew, chainDeadline).
signing.TypehashReportinterfaceWhat verifyTypehashes returns: ok, one line per mismatch, and each type hash as the SDK and the lens compute it.

permits

USDC (ERC-2612) permits.

ExportKindWhat it is
permits.usdcPermitDomainfunction, bzUSDC’s EIP-712 domain: its name(), its version() (“2” on native Arbitrum USDC; “1” only when the token has no version()), the chain id, and its address.
permits.permitNoncefunction, bzThe owner’s current permit nonce on USDC.
permits.permitTypedDatafunctionThe typed data of a USDC permit.
permits.encodePermitDatafunctionabi.encode(uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s), as the pair decodes it.
permits.decodePermitDatafunctionDecode a permitData blob into its value, deadline, v, r and s.
permits.permitValueForfunctionThe permit value (USDC units, rounded up) that covers a pull of pullBazaar (BAZAAR precision).
permits.signPermitfunction, bzAsk the owner’s wallet (eth_signTypedData_v4) to sign a USDC permit for spender, which must be a pair of this deployment; valid 30 minutes by default.
permits.checkPermitfunction, bzCheck a permitData a relayer received: it decodes, it is signed by owner for spender at the owner’s current nonce, it has not expired, and it covers needUsdc.
permits.DEFAULT_PERMIT_WINDOWconstHow long a permit the SDK signs stays valid by default (seconds past the lower of chain time and the device’s clock).
permits.PERMIT_TYPESconstThe EIP-712 types of a USDC (ERC-2612) permit.
permits.PermitMessageinterfaceA permit’s message: owner, spender (the pair), value in USDC units, nonce and deadline.
permits.SignedPermitinterfaceA signed permit: the message, the signature and its parts, and the permitData a pair call takes.

allowance

USDC balance, allowance and approvals.

ExportKindWhat it is
allowance.erc20DatafunctionCalldata for one of the ERC-20 functions above.
allowance.usdcBalancefunction, bzThe owner’s USDC balance, in USDC units.
allowance.usdcAllowancefunction, bzWhat spender (a pair) may pull from owner, in USDC units.
allowance.approveUsdcfunction, bzApprove spender (a pair) to pull amountUsdc (USDC units) from owner’s wallet.
allowance.formatUsdcUnitsfunctionUSDC units -> “12.5”.
allowance.usdcFundingfunction, bzWhether owner can fund a pull of pullBazaar (BAZAAR precision, e.g. PreparedCall.pullsUsdc) to spender: balance and allowance in one read.
allowance.ERC20_ABIconstThe ERC-20 / ERC-2612 functions the SDK calls on USDC, hand-written (USDC is not a Bazaar ABI).
allowance.UsdcFundinginterfaceWhat usdcFunding returns: the USDC a call needs, the balance and allowance, and what to approve.

relay

Gasless requests: signing, checking, serializing and encoding them.

ExportKindWhat it is
relay.checkSignedFieldsfunctionThe bounds of each action that need no chain read, as the pair enforces them for a relayed call.
relay.relayedPullfunction, bzWhat a relayed call takes from the signer’s wallet, BAZAAR precision: a deposit plus the fee; the fee plus a trigger order’s bounty for createOrder.
relay.signRelayedfunction, bzHave the user sign a gasless request.
relay.verifyRelayedfunction, bzCheck a request before relaying it.
relay.encodeRelayedfunction, bzThe call a relayer sends for a request, from its own account (relayer).
relay.describeRelayedfunctionOne line describing what a request does.
relay.serializeRelayedfunctionThe request as JSON for the POST to a relayer: integers as decimal strings.
relay.parseRelayedfunctionA request from JSON (or an already-parsed body), every field checked for type and shape.
relay.RelayedRequesttypeA signed gasless request: every signed field, the signer, the signature and the permit.
relay.RelayedInputtypeWhat signRelayed takes: the signed fields themselves, or an order / amendments to check and build first.
relay.SignRelayedOptionsinterfaceOptions of signRelayed: the wallet, signer and pair, the relayer fee, the deadline window, and an optional nonce and permit.
relay.RelayCheckinterfaceWhat verifyRelayed returns: ok, the recovered signer, and every problem that would make the call revert.

batching

Several prepared calls in one transaction.

ExportKindWhat it is
batching.toBatchCallsfunctionThe calls in the generic shape, in order.
batching.batchValuefunctionETH the batch needs: the sum of the calls’ values (Pyth fees).
batching.shareOnePriceUpdatefunctionCarry the Pyth update once per pair: the first call that always stores a price (createOrder, amendOrders, executeTriggeredOrders, refreshPrice) keeps its update and fee.
batching.walletSendCallsRequestfunction, bzThe wallet_sendCalls request for these calls from from (the smart account).
batching.sendCallsfunctionSend the calls as one batch through an EIP-5792 wallet.
batching.getCallsStatusfunctionwallet_getCallsStatus for a batch id, as the wallet reports it.
batching.encodeTradingAccountExecutefunctionexecute(Call[] calls) calldata, each call as (target, value, data).
batching.tradingAccountExecutefunctionOne transaction through a BazaarTradingAccount: execute runs the calls in order as the account, with value the sum of their values.
batching.decodeTradingAccountResultsfunctionThe per-call return data execute returns.
batching.readTradingAccountOwnerfunction, bzThe account’s owner (who alone may call execute).
batching.BatchCallinterfaceThe generic call shape smart wallets take (ERC-4337 / Safe / EIP-5792).
batching.SendCallsOptionsinterfaceOptions of sendCalls and walletSendCallsRequest: whether the batch must be atomic, and wallet capabilities.
batching.TRADING_ACCOUNT_ABIconstThe example account’s interface, hand-written from examples/BazaarTradingAccount.sol.

history

Bazaar’s subgraph: every history query and its types.

ExportKindWhat it is
history.httpSubgraphfunctionA SubgraphSource over HTTP.
history.cancelReasonNamefunctionBazaarTypes.CancelReason’s code (as an OrderUpdated payload carries it) -> its name.
history.tradesfunction, bzThe trade tape: fills, newest first.
history.candlesfunction, bzOHLCV candles of one pair at one interval (seconds: 60, 300, 900, 3600, 14400 or 86400), oldest first; from/to bound the period start.
history.fillCandleGapsfunctionFill the periods between from and to (exclusive) that had no fills with flat candles at the previous close (zero volume), as a chart expects.
history.pairDayDatafunction, bzA pair’s daily figures (UTC days), oldest first.
history.protocolDayDatafunction, bzProtocol-wide daily figures, oldest first.
history.bookLevelsfunction, bzAggregated resting size per price on one side of a pair’s book, best price first (bids high to low, asks low to high).
history.ordersfunction, bzOrders, newest first by creation time (from/to bound the creation time).
history.orderfunction, bzOne order by pair and order id, or null when the subgraph has not seen it.
history.orderEventsfunction, bzOrder lifecycle events (created, triggered, canceled with its reason, amended, amend skipped), newest first.
history.positionsfunction, bzPositions (one per account and pair), by id.
history.positionChangesfunction, bzEach trade’s effect on a position, newest first.
history.positionSnapshotsfunction, bzThe full stored position after every change (PositionBucketUpdated), newest first.
history.collateralTransfersfunction, bzCollateral deposits and withdrawals, newest first.
history.feeBalancesfunction, bzClaimable balances per pair and recipient: an integrator’s or relayer’s revenue, a keeper’s rewards, refunded bounties.
history.feeAccrualsfunction, bzCredits to claimable balances (FeeAccrued), newest first.
history.feeClaimsfunction, bzClaims of claimable balances (FeesClaimed), newest first.
history.integratorStatsfunction, bzWhat an integrator address has routed, or null when it has carried no order.
history.accountStatsfunction, bzAn account’s lifetime totals, or null when the subgraph has not seen it.
history.accountDayDatafunction, bzAn account’s daily totals (UTC days), oldest first.
history.metaTransactionsfunction, bzRelayed (gasless) actions, newest first.
history.permitFailuresfunction, bzPermits the pair could not execute (the call went on with the existing allowance), newest first.
history.liquidationsfunction, bzLiquidations, newest first: account is the liquidated trader.
history.adlWindowsfunction, bzADL windows, newest first by opening time.
history.adlBatchesfunction, bzADL batches (executeAdl calls), newest first; window is an AdlWindow id.
history.adlFillsfunction, bzWinners ADL closed, newest first.
history.insuranceProvidersfunction, bzInsurance LPs per pair, by id; activeOnly keeps those holding shares.
history.insuranceEventsfunction, bzInsurance deposits, withdrawal requests, locks, payouts and levies, newest first.
history.pairTerminationsfunction, bzFinalized terminations with their payout figures, newest first.
history.terminalRatioUpdatesfunction, bzPayout ratios re-derived after finalize, newest first.
history.terminationProposalsfunction, bzUMA termination claims, newest first by submission.
history.insurerProposalsfunction, bzInsurer termination votes, newest first by creation.
history.insurerVotesfunction, bzYes-votes cast on insurer proposals, newest first.
history.insurerLeviesfunction, bzLevies charged on yes-voters’ redemptions after an insurer-vote termination, newest first.
history.listingProposalsfunction, bzListing (pair deployment) proposals, newest first.
history.identifierUpgradeProposalsfunction, bzUMA identifier-upgrade proposals, newest first.
history.subgraphLagfunction, bzHow far the subgraph’s history trails the chain: its _meta.block against the chain head, so a UI can say that trades and positions from the last moments may not be listed yet.
history.SubgraphErrorclassThe subgraph refused or failed a query.
history.HttpSubgraphOptionsinterfaceOptions of httpSubgraph: the per-request timeout, extra headers and the fetch to use.
history.ORDER_TYPESconstOrder type names as the subgraph stores them: Market, Limit, StopLimit, TakeProfit, StopLoss.
history.OrderTypeNametypeOne of ORDER_TYPES.
history.TIME_IN_FORCEconstTime-in-force names: GTC, IOC, FOK.
history.TimeInForceNametypeOne of TIME_IN_FORCE.
history.ORDER_STATUSESconstOrder statuses: Waiting, Pending, Resting, Fired, Filled, Canceled.
history.OrderStatustypeOne of ORDER_STATUSES.
history.OPEN_ORDER_STATUSESconstStatuses of an order that can still trade.
history.CANCEL_REASONSconstCancel reasons, in BazaarTypes.CancelReason order (None, Owner, Expired, … Deleveraged).
history.CancelReasontypeOne of CANCEL_REASONS.
history.AMEND_SKIP_REASONSconstWhy a best-effort amendment was skipped: Inactive, Expired, SizeFilled, BelowMinimum, WouldCross.
history.AmendSkipReasontypeOne of AMEND_SKIP_REASONS.
history.ORDER_EVENT_ACTIONSconstOrder event kinds: Created, Triggered, Canceled, Amended, AmendSkipped.
history.OrderEventActiontypeOne of ORDER_EVENT_ACTIONS.
history.PAIR_STATUSESconstPair statuses as the subgraph records them: Active, Scheduled, SettlementFixed, Terminated.
history.PairStatustypeOne of PAIR_STATUSES.
history.SETTLEMENT_SOURCESconstHow a settlement price was fixed: Cutoff, Stale, Insolvency.
history.SettlementSourcetypeOne of SETTLEMENT_SOURCES.
history.INSURANCE_EVENT_KINDSconstInsurance event kinds: Deposit, WithdrawalRequested, WithdrawalLocked, WithdrawalExecuted, LevyCharged, LevyReceived.
history.InsuranceEventKindtypeOne of INSURANCE_EVENT_KINDS.
history.LISTING_STATUSESconstListing statuses: Pending, Disputed, Deployed, Rejected, Expired, Voided.
history.ListingStatustypeOne of LISTING_STATUSES.
history.IDENTIFIER_UPGRADE_STATUSESconstIdentifier-upgrade statuses: Pending, Disputed, Queued, Upgraded, Canceled, Expired, Rejected.
history.IdentifierUpgradeStatustypeOne of IDENTIFIER_UPGRADE_STATUSES.
history.TERMINATION_PROPOSAL_STATUSESconstUMA termination claim statuses: Pending, Disputed, Accepted, ScheduleRefused, Superseded, Rejected.
history.TerminationProposalStatustypeOne of TERMINATION_PROPOSAL_STATUSES.
history.INSURER_PROPOSAL_STATUSESconstInsurer vote statuses: Open, Scheduled, Failed, Preempted, Voided, Rejected.
history.InsurerProposalStatustypeOne of INSURER_PROPOSAL_STATUSES.
history.CANCEL_REASON_TEXTconstWhy an order was canceled, in a sentence for its owner.
history.CANDLE_INTERVALSconstThe candle intervals the subgraph keeps, in seconds: 1m, 5m, 15m, 1h, 4h, 1d.
history.CandleIntervaltypeOne of CANDLE_INTERVALS (seconds).
history.TradeinterfaceOne fill.
history.CandleinterfaceOHLCV over fills.
history.PairDayDatainterfaceA pair’s figures for one UTC day: volume, trade count, fees and OHLC.
history.HistoryOrderinterfaceAn order as the subgraph records it: type, side, sizes and prices, status, integrator, cancel reason and times.
history.OrderEventinterfaceOne OrderUpdated, OrderAmended or OrderAmendSkipped log.
history.HistoryPositioninterfaceA position as the subgraph records it: side, size, entry value and price, collateral, funding index and totals.
history.PositionSnapshotinterfaceThe full stored position a PositionBucketUpdated log reported.
history.PositionChangeinterfaceA trade’s effect on one position: the maker’s per fill, the taker’s once per order walk at its VWAP.
history.CollateralTransferinterfaceOne collateral deposit or withdrawal.
history.FeeBalanceinterfaceA recipient’s claimable balance on one pair: integrator fees, keeper rewards, relayer fees, refunds.
history.FeeMovementinterfaceOne credit to a claimable balance (FeeAccrued), or one claim of it (FeesClaimed).
history.LiquidationinterfaceOne liquidation: the account, the liquidator, size, bankruptcy price, price, notional and collateral seized.
history.AdlWindowinterfaceOne ADL window, from the AdlTriggered that opened it to AdlResolved or the pair’s termination.
history.AdlBatchinterfaceOne executeAdl call.
history.AdlFillinterfaceOne winner an ADL batch closed, fully or in part.
history.InsuranceProviderinterfaceAn insurance LP on one pair: live shares, totals deposited and withdrawn, the pending request and lock, and levies.
history.InsuranceEventinterfaceOne insurance event: a deposit, request, lock, payout or levy, with amount and shares.
history.PairTerminationinterfaceA finalized termination: the settlement price, what is owed, bad debt, the pot and the payout ratios.
history.TerminalRatioUpdateinterfaceA post-finalize settlement re-derived the payout ratios on the entitlements still unpaid.
history.TerminationProposalinterfaceA UMA termination claim.
history.InsurerProposalinterfaceAn insurer termination vote: proposer, cutoff, window, whether a UMA claim backs it, the snapshot and the status.
history.InsurerVoteinterfaceOne yes-vote on an insurer proposal: voter, shares and the running total.
history.InsurerLevyinterfaceOne levy charged on a yes-voter’s shares after an insurer-vote termination.
history.ListingProposalinterfaceA listing proposal: pair id, deployer, feed, description, seed, status and times.
history.IdentifierUpgradeProposalinterfaceAn identifier-upgrade proposal: proposer, new identifier, status and times.
history.IntegratorStatsinterfaceWhat one integrator address has routed.
history.AccountStatsinterfaceAn account’s lifetime totals: trades, volumes, fees paid, realized PnL, liquidations and bounties.
history.AccountDayDatainterfaceAn account’s totals for one UTC day.
history.ProtocolDayDatainterfaceProtocol-wide totals for one UTC day: volume, trades, fees, liquidations and unique traders.
history.MetaTransactioninterfaceOne relayed (gasless) action: the user, the relayer, the action’s type hash, nonce and fee.
history.PermitFailureinterfaceAn ERC-2612 permit the pair could not execute; the call went on with the existing allowance.
history.BookLevelinterfaceResting size at one price on one side.
history.PageinterfaceA page of results; next is the cursor for the following page, null at the end.
history.PageOptionsinterfacePaging options every query takes: first (1 to 1000, default 100), after (a cursor) and atBlock.
history.TimeOptionsinterfacePageOptions plus from, to (seconds) and order.
history.TradeFilterinterfaceFilters of trades: pair, account, maker, taker, plus TimeOptions.
history.MAX_FILLED_CANDLESconstThe most candles fillCandleGaps returns: about 69 days of 1-minute candles, more than a chart shows.
history.BookLevelOptionsinterfaceOptions of bookLevels: the side ("bid" or "ask") plus PageOptions.
history.OrderFilterinterfaceFilters of orders: account, pair, status, openOnly, order type, integrator, plus TimeOptions.
history.OrderEventFilterinterfaceFilters of orderEvents: account, pair, order id (with pair), action, plus TimeOptions.
history.PositionFilterinterfaceFilters of positions: account, pair, openOnly, plus PageOptions.
history.AccountPairFilterinterfaceAn account and pair filter plus TimeOptions, used by several queries.
history.RecipientFilterinterfaceFilters of the fee queries: the recipient and pair, plus TimeOptions.
history.MetaTxFilterinterfaceFilters of metaTransactions: user, relayer, pair, plus TimeOptions.
history.SubgraphLaginterfaceWhat subgraphLag returns: the indexed block and time, the chain head, and how far behind the index is.

previews

What an order or a withdrawal would do, computed with the pair’s own arithmetic.

ExportKindWhat it is
previews.takerInsuranceFeeBasefunctionRiskParamsLib.getTakerInsuranceFeeBase: the taker’s base insurance rate (EBP) at a fund target ratio (bp).
previews.takerInsuranceFeeRatesfunctionRiskParamsLib.getInsuranceFeeRates: the taker insurance rates (EBP) from the fund’s coverage and its target ratio.
previews.takerFillFeefunctionOne taker fill’s fee (MatchingEngineLib._computePairFees, taker side).
previews.makerFillFeefunctionOne maker fill’s fee: a flat 0.25 bp insurance leg, plus 0.25 bp when the order carries an integrator.
previews.feeBreakdownfunctionWhere a fee goes (MatchingEngineLib._finalizeTx).
previews.applyFillfunctionMatchingEngineLib._applyFillCore: one fill’s effect on a position.
previews.positionStatefunctionBucketLib.calculateState for a bucket, at the effective maintenance rate.
previews.fillMarginOkfunctionMatchingEngineLib._checkMargin: whether a fill of fillSize at price (the maker’s limit) passes the account’s margin gate at the oracle mark.
previews.liquidationPricefunctionThe estimated liquidation price of a position.
previews.maxLeveragefunctionThe most leverage the pair allows a new position now: 1 / imrBp (25 at a 4% initial margin).
previews.worstCaseNotionalfunctionCollateralLib._worstCaseNotional: the position plus same-side orders, or the opposite orders’ overshoot into a flip.
previews.fillDebitfunctionCollateralLib._fillDebitOf.
previews.restingReservefunctionCollateralLib._sweepRestingReserve: the account’s live orders reserved at mark.
previews.creationGatefunctionOrderManagementLib._creationShortfall: what createOrder requires of the account’s collateral.
previews.simulateOrderfunctionRun an order through the pair’s arithmetic with every input given.
previews.previewOrderfunction, bzPreview an order for account on pair: the resulting position, the fills it meets in the current book with their fees, the collateral it requires, the liquidation price, the leverage allowed, and every reason the pair would refuse it.
previews.previewWithdrawalfunction, bzPreview a collateral withdrawal.
previews.MAKER_INSURANCE_FEE_EBPconstBazaarTypes.MAKER_INSURANCE_FEE_EBP: 0.25 bp, flat.
previews.MAKER_INTEGRATOR_FEE_EBPconstBazaarTypes.MAKER_INTEGRATOR_FEE_EBP: 0.25 bp, only on orders that carry an integrator.
previews.TAKER_INTEGRATOR_FEE_EBPconstBazaarTypes.TAKER_INTEGRATOR_FEE_EBP: 0.25 bp, only on orders that carry an integrator.
previews.TAKER_INSURANCE_FEE_MIN_EBPconstRiskParamsLib taker insurance curve: 0.75 bp at the 2% target, 4.25 bp at 10%, x(1 + 49 d^2) below target, 50 bp cap.
previews.TAKER_INSURANCE_FEE_MAX_EBPconstThe taker insurance rate at the 10% target ratio: 4.25 bp (EBP).
previews.TAKER_INSURANCE_FEE_MULTIPLIERconstThe below-target multiplier of the taker insurance curve: 49.
previews.TAKER_INSURANCE_FEE_CAP_EBPconstThe taker insurance rate’s cap: 50 bp (EBP).
previews.INSURANCE_TARGET_BASE_BPconstBazaarTypes.INSURANCE_TARGET_BASE / _MAX, in bp of open-interest notional.
previews.INSURANCE_TARGET_MAX_BPconstThe insurance fund’s largest target ratio: 10% of open-interest notional (bp).
previews.BUG_BOUNTY_TAX_BPconstBazaarTypes.BUG_BOUNTY_TAX_BP: 1% of the insurance and integrator fee streams.
previews.MAX_SLIPPAGE_BPconstBazaarTypes.MAX_SLIPPAGE_BP: 10%.
previews.MAX_PLACEMENT_DEVIATION_BPconstBazaarTypes.MAX_PLACEMENT_DEVIATION_BP: a Limit must sit within 50% of the price.
previews.MAX_STALE_DEVIATION_BPconstBazaarTypes.MAX_STALE_DEVIATION_BP: fills under the stale fallback stay within 10% of it.
previews.MAX_ACTIVE_LIMIT_ORDERSconstBazaarTypes.MAX_ACTIVE_LIMIT_ORDERS_PER_USER.
previews.TakerFeeRatesinterfaceTaker insurance rates (EBP): the dynamic rate for risk-adding notional and the base rate for risk-reducing notional.
previews.FillFeeinterfaceOne fill’s fee: the insurance leg, the integrator leg and the total.
previews.FeeBreakdowninterfaceA fee split into what goes to the insurance fund, the integrator and the bug bounty.
previews.BucketinterfaceA position bucket as the pair stores it (BAZAAR precision; entryFundingIndex signed).
previews.MarginRatesinterfaceMargin rates in bp: initial, maintenance and the lagged maintenance rate.
previews.FillResultinterfaceWhat applyFill returns: the new bucket, the realized price PnL and the funding settled.
previews.RestingOrderinterfaceA live resting order as the reservation reads it.
previews.RestingReserveinterfaceWhat restingReserve returns: the long and short exposure the live orders are reserved at, and the fill debit.
previews.CreationGateinterfaceWhat creationGate returns: the collateral createOrder requires and whether the account has it.
previews.OrderInputinterfaceAn order as the preview takes it.
previews.BookMakerinterfaceA resting order on the opposite side of the book, in book order.
previews.SimulateInputinterfaceWhat simulateOrder takes: the order, account, bucket, mark, funding index, margin rates, book and the rest.
previews.PreviewFillinterfaceOne fill in a preview: price, size, notional, role, fee, and whether it is a resting remainder assumed filled later.
previews.PositionViewinterfaceA position in a preview: side, size, entry value and price, collateral, PnL and margin at the mark.
previews.PreviewIssueinterfaceA reason the contract would refuse the call (or the walk would cancel the order), with its user message.
previews.OrderPreviewinterfaceA preview’s position before, after everything fills and after this transaction, with fills, fees and issues.
previews.PriceInputinterfaceA price to judge at: the spot (and bracket) of the update the call will carry.
previews.PreviewOptionsinterfaceOptions of previewOrder: the price the call will carry, walkBook, and the fillPrice to assume without the book.
previews.OrderPreviewResultinterfaceWhat previewOrder returns: the OrderPreview plus the mark, the venue, the fee rates and the margin rates.
previews.WithdrawalPreviewinterfaceWhat previewWithdrawal returns: the most that can be withdrawn now, and every freeze that applies.

termination

Ending a market, fixing its settlement price, finalizing it and withdrawing after.

ExportKindWhat it is
termination.terminatorConstantsfunction, bzThe terminator’s public constants (categories, outcomes, verdicts, settlement rules, bonds, liveness, reason length, sample count and spacing).
termination.requiredTerminationBondfunction, bzThe USDC bond (6 decimals) a UMA termination claim posts now: max(1,000 USDC, UMA’s minimum).
termination.settlementRulefunction, bzHow terminateScheduledPair would settle the market if called now, and what it needs.
termination.terminationProposalfunction, bzA UMA termination claim by its assertion id, or null when the terminator has none.
termination.insurerProposalfunction, bzAn insurer-termination proposal by id (a claim’s assertion id, or a bare proposal’s id), or null when none exists.
termination.getInsurerVotefunction, bzShares voter has voted into proposal id on pair (0 when the record is dead or absent); after the fix, the voted stake the levy still draws on.
termination.getLockedSharesfunction, bzShares user has voted into live proposals on pair: what the insurance withdrawal gate refuses to release.
termination.getTerminalEntitlementfunction, bzThe lens’s terminal entitlement for account on pair.
termination.openProposalsfunction, bzThe open proposals on pair: UMA claims not yet resolved and insurer votes not yet resolved.
termination.terminationStatusfunction, bzA market’s termination status in one call.
termination.reasonProblemfunctionWhy a termination reason would be refused, or null when the terminator accepts it (1 to 1,000 bytes of a restricted character set).
termination.unjudgedLevyBpfunctionThe levy (basis points) an unjudged outcome charges with yes of snapshot voted: 50% × dissent, at least 10%.
termination.proposeTerminationfunction, bzPropose ending a market through UMA.
termination.settleTerminationProposalfunction, bzSettle a UMA termination claim once UMA can resolve it.
termination.insurerProposalIdfunctionkeccak256(abi.encode(pair, proposer, timestamp)): a bare insurer proposal’s id, fixed by the second it is filed.
termination.proposeInsurerTerminationfunction, bzPropose that a market stop trading at cutoffTs with no UMA claim behind it (a bare insurer proposal).
termination.voteForInsurerTerminationfunction, bzVote yes on insurer proposal id by locking shares of your insurance shares.
termination.executeInsurerTerminationfunction, bzResolve insurer proposal id (anyone may).
termination.terminateStalePairfunction, bzEnd a market that has stored no price for more than 21 days, at its last stored price (or at a nominal price of 1 when it never stored one, aged from its creation).
termination.tickFetcherfunctionA TickFetcher over the context’s price source (its atTick).
termination.settlementPrintfunctionOne feed as a one-second update carries it; throws SettlementPrintError where the oracle would refuse the update for that feed.
termination.generatedAtOrAfterfunctionWhether a settlement print carries a price generated at or after a target second (BazaarOracle._pricedSince).
termination.tickPrintsfunctionUMA’s rule: Pyth’s one-second update stamped at the cutoff for every leg, one update for a composite when its legs can be asked for together.
termination.chainSamplefunctionOne leg of one chain sample: the earliest one-second update carrying a price generated at or after the target, with the quiet update before it when it is not the target’s own.
termination.chainPrintsfunctionThe chain rule’s sample set.
termination.terminateScheduledPairfunction, bzBuild terminateScheduledPair(pair, priceUpdate).
termination.chainLastResortfunctionThe chain rule’s last resort (BazaarPairTerminator._insurerSettlementBounds): 21 days after the cutoff, or 12 hours after a capping UMA cutoff when sooner.
termination.finalizeTerminationfunction, bzFinalize a terminated market (stage 2, on the pair).
termination.decodeTerminatorLogfunctionDecode a terminator log, or null for another contract’s.
termination.proposalIdsFromReceiptfunction, bzThe proposal ids a proposeTermination or proposeInsurerTermination receipt created.
termination.TERMINATION_PROPOSAL_BOND_USDCconstThe UMA termination bond’s floor (USDC units); the bond posted is requiredTerminationBond(), which tracks UMA’s own minimum upward.
termination.TERMINATION_PROPOSAL_LIVENESSconstUMA liveness of a termination claim (seconds).
termination.MAX_REASON_LENGTHconstLongest reason a termination claim takes, in bytes.
termination.MIN_CUTOFF_LEADconstA cutoff sooner than this after acceptance is moved to this far after it (BazaarTypes.MIN_CUTOFF_LEAD: limbo lead + 1 s).
termination.INSURER_TERMINATION_MIN_LEADconstEarliest a proposed cutoff may sit after the proposal, for a UMA claim and a bare insurer proposal alike (seconds).
termination.INSURER_TERMINATION_LATCH_LEADconstVoting and the latch close this long before a proposal’s cutoff (seconds).
termination.INSURER_TERMINATION_MAX_LEADconstVotes open this long before the cutoff (or at filing if later), and a bare cutoff may sit no further out (seconds).
termination.INSURER_TERMINATION_THRESHOLD_BPconstShare of the snapshot that must vote yes (basis points).
termination.INSURER_TERMINATION_BOND_USDCconstThe bond a bare insurer proposal posts (USDC units), a fixed figure.
termination.INSURER_SHARE_MATURITY_PERIODconstShares must be held this long before the vote window opens to vote (seconds).
termination.INSURER_MAX_LIVE_VOTESconstMost live proposals one voter may back on a pair.
termination.INSURER_LEVY_BPconstLevy on each yes-voted share when the claim behind the vote is rejected (basis points).
termination.INSURER_LEVY_FLOOR_BPconstThe least an unjudged outcome levies on each yes-voted share (basis points).
termination.CLAIM_VERDICT_GRACEconstHow long after a claim is filed its vote-scheduled pair waits for the verdict (seconds).
termination.INSURER_POST_FIX_LOCKconstYes-voters of a costed outcome stay locked this long after the price fix: the insurance withdrawal cooldown (seconds).
termination.SCHEDULED_TERMINATION_GRACEconstUnder UMA’s rule only the precise print settles for this long after the cutoff (or the verdict); then an empty call settles at the stored price (seconds).
termination.NEVER_PRICED_SETTLEMENT_PRICEconstThe settlement price (BAZAAR precision) a pair that never stored a price settles at under the stored-price paths (NEVER_PRICED_SETTLEMENT_PRICE).
termination.PAIR_SLOT_SHARE_EPOCHconstBazaarPair storage slot of shareEpoch, which the terminator reads with extsload (PAIR_SLOT_SHARE_EPOCH).
termination.TerminationCategoryconst + typeThe four grounds a UMA termination claim may declare.
termination.TERMINATION_CATEGORY_INFOconstWhat each category means, condensed from the claim text the terminator writes into UMA’s assertion.
termination.SettlementRuleKeytypeA settlement rule’s name: nothing, window, chain or awaitVerdict.
termination.SETTLEMENT_RULE_KEYSconstsettlementRule’s answers, by number.
termination.SETTLEMENT_RULE_TEXTconstWhat each settlement rule means.
termination.VerdictNametypeA UMA verdict’s name: pending, true or false.
termination.VERDICT_NAMESconstVerdict names by the terminator’s number.
termination.OutcomeNametypeAn insurer-vote outcome’s name: none, true, false or unjudged.
termination.OUTCOME_NAMESconstOutcome names by the terminator’s number.
termination.OUTCOME_TEXTconstWhat each insurer-vote outcome means for its yes-voters.
termination.TerminatorConstantsinterfaceThe terminator’s public constants, read from the chain, plus the live bond.
termination.SettlementRuleInfointerfacesettlementRule(pair), decoded.
termination.TerminationProposalInfointerfaceA UMA termination claim, from terminationProposals(assertionId).
termination.InsurerProposalStatetypeWhere an insurer vote stands at the chain’s time.
termination.InsurerProposalInfointerfaceAn insurer-termination proposal, from insurerProposals(id).
termination.TerminalEntitlementinterfaceWhat a terminated market pays an account.
termination.TerminationPhasetypeWhere a market is on its way to termination.
termination.OpenProposalsinterfaceProposals still open on a market, and where their ids came from.
termination.TerminationStatusinterfaceA market’s termination state in one call.
termination.ProposalSearchinterfaceWhere to look for a market’s open proposals.
termination.MAX_OPEN_PROPOSALSconstThe most open proposals of each kind (UMA claims, insurer votes) openProposals takes from the subgraph for one market.
termination.maturityCutoffconstThe maturity cutoff for a vote whose window opened at windowOpenTs: shares deposited at or before it vote.
termination.voteWindowOpensconstWhen a claim’s vote window opens: 14 days before its cutoff, or at filing (now) when that is later.
termination.InsurerExecutionOutcometypeWhat executing an insurer vote would do now.
termination.SettlementPrintinterfaceOne of Pyth’s one-second updates, read for one feed, as settlement evidence.
termination.TickFetchertypePyth’s one-second update stamped on a second, carrying the feeds asked for, or null when the archive has none.
termination.SettlementPrintErrorclassA settlement update that could not be fetched, or a sample that cannot be proven.
termination.TickProofinterfaceWhat UMA’s rule settles a market with: the cutoff’s update for every leg.
termination.ChainProofinterfaceWhat the chain rule settles a market with: the updates in calldata order, the targets and the samples’ seconds.
termination.ChainSearchBoundsinterfaceHow far a chain sample may be searched for: the latest second allowed, and the present.
termination.SettleUndertypeWhich rule a terminateScheduledPair call settles under: UMA’s cutoff update (tick), the chain’s median, or the stored-price last resort.
termination.SettlementAttemptinterfaceOne candidate the builder tried.
termination.ScheduledSettlementPlaninterfaceWhat terminateScheduledPair needs now.
termination.TERMINATOR_EVENT_TEXTconstWhat each terminator event means, for an activity feed.

listings

Proposing and settling new markets.

ExportKindWhat it is
listings.descriptionProblemfunctionWhy a listing description would be refused, or null when the factory accepts it: 1 to 200 bytes of letters, digits, space and . , & / -.
listings.compositeIdfunctionkeccak256(baseId ‖ quoteId ‖ invertQuote): the id a composite is registered and listed under (BazaarOracle.getCompositeId).
listings.asFeedIdfunctionThe bytes32 feed ID for a Pyth Pro feed number, decimal digits, or a bytes32; an InputError for anything else.
listings.FeedIdInputtypeA feed ID as a caller may give it: the Pyth Pro feed number or the bytes32 that holds it.
listings.getCompositefunction, bzThe legs id resolves to on the oracle, or null when it is not a registered composite (a plain feed id, or an unregistered one).
listings.registerCompositefunction, bzRegister a composite feed on Bazaar’s oracle, so compositeId(baseId, quoteId, invertQuote) can be listed.
listings.deploymentConstantsfunction, bzMIN_DEPLOYMENT_AMOUNT, DEPLOYMENT_BOND_USDC, the live requiredDeploymentBond, DEPLOYMENT_LIVENESS and DVM_DISPUTE_GRACE.
listings.requiredDeploymentBondfunction, bzThe USDC bond (6 decimals) a listing posts now: max(1,000 USDC, UMA’s minimum).
listings.deploymentProposalfunction, bzA listing proposal by its UMA assertion id, or null when the factory has none.
listings.rawBaseFeedfunction, bzThe raw Pyth feed a listing prices: the feed itself, or a composite’s base leg (the key one-market-per-asset is enforced on).
listings.pendingDeploymentfunction, bzThe listing proposal pending for an asset, or null.
listings.openListingsfunction, bzThe listing proposals not yet resolved, found through the subgraph, the factory’s logs or given ids, each read on chain.
listings.seedRefundOwedfunction, bzUSDC (6 decimals) account may collect with claimSeedRefund from listings that did not deploy.
listings.proposePairDeploymentfunction, bzPropose listing a market.
listings.settleDeploymentProposalfunction, bzSettle a listing once UMA can resolve it.
listings.expireStuckDeploymentProposalfunction, bzRelease an asset’s listing slot when its assertion cannot settle on UMA’s oracle (a blacklisted payee, a dispute the DVM never resolves).
listings.claimSeedRefundfunction, bzWithdraw the seed credited back to the caller by listings that did not deploy (rejected, voided or released).
listings.decodeFactoryLogfunctionDecode a factory log, or null for another contract’s.
listings.listingFromReceiptfunction, bzThe assertion id and pair id a proposePairDeployment receipt created, or null.
listings.MIN_DEPLOYMENT_AMOUNTconstLeast totalAmount a listing takes (BAZAAR precision): $1,000 bond + $3,000 seed.
listings.DEPLOYMENT_BOND_USDCconstThe listing bond’s floor (USDC units); the bond posted is requiredDeploymentBond(), which tracks UMA’s minimum upward.
listings.DEPLOYMENT_LIVENESSconstUMA liveness of a listing (seconds).
listings.DVM_DISPUTE_GRACEconstExtra wait before a disputed listing that cannot settle may be released (seconds).
listings.MIN_INSURANCE_SEEDconstLeast insurance seed a pair is deployed with (BAZAAR precision): what remains of totalAmount after the bond.
listings.MAX_DESCRIPTION_LENGTHconstLongest listing description, in bytes.
listings.PAIR_DEPLOYMENT_PROPOSAL_VOIDEDconstWhat PairDeploymentProposalVoided means.
listings.LISTING_EVENT_TEXTconstWhat each factory listing event means, for an activity feed.
listings.CompositeFeedinterfaceA composite feed’s legs.
listings.DeploymentConstantsinterfaceThe factory’s listing constants, read from the chain, plus the live bond.
listings.DeploymentStatustypeWhere a listing stands.
listings.DeploymentProposalInfointerfaceA listing proposal, from getDeploymentProposal(assertionId).
listings.ListingSearchinterfaceWhere openListings looks for listing proposals.
listings.OpenListingsinterfaceThe unresolved listing proposals and where their ids came from.
listings.MAX_OPEN_LISTINGSconstThe most open listings openListings takes from the subgraph.
listings.MAX_LISTING_IDSconstThe most listing ids openListings reads on chain in one call.

governance

UMA identifier upgrades.

ExportKindWhat it is
governance.isAssertTruthIdentifierfunctionTrue when identifier’s first 12 bytes are ASCII “ASSERT_TRUTH” (the factory’s truth-identifier rule).
governance.parseIdentifierfunctionAn identifier given as bytes32 hex or as its text (“ASSERT_TRUTH2”).
governance.identifierUpgradeConstantsfunction, bzIDENTIFIER_UPGRADE_BOND_USDC, the live requiredIdentifierUpgradeBond, LIVENESS, TIMELOCK, MIN_NOTICE and DVM_DISPUTE_GRACE.
governance.requiredIdentifierUpgradeBondfunction, bzThe USDC bond (6 decimals) an identifier-upgrade proposal posts now: max(5,000 USDC, UMA’s minimum).
governance.umaIdentifierfunction, bzThe identifier Bazaar’s assertions are made under, as bytes32.
governance.umaIdentifierIsLivefunction, bzWhether Bazaar’s identifier is on UMA’s live whitelist.
governance.identifierUpgradeProposalfunction, bzAn identifier-upgrade proposal by its assertion id, or null when the factory has none.
governance.identifierGovernancefunction, bzThe identifier in force, whether it is live on UMA’s whitelist, the pending upgrade (with where its UMA assertion stands) and the queued one (with when it may activate).
governance.proposeUmaIdentifierUpgradefunction, bzPropose switching Bazaar to newIdentifier.
governance.settleIdentifierUpgradeProposalfunction, bzSettle an identifier-upgrade proposal once UMA can resolve it: UMA pays the bond to the winning side, and an approved upgrade is queued behind the timelock.
governance.expireStuckIdentifierUpgradeProposalfunction, bzRelease the single pending-upgrade slot when its assertion cannot settle on UMA’s oracle.
governance.activateIdentifierUpgradefunction, bzActivate the queued identifier upgrade once its timelock has run (or 4 days after approval while the incumbent is off UMA’s whitelist).
governance.IDENTIFIER_UPGRADE_BOND_USDCconstThe identifier-upgrade bond’s floor (USDC units); the bond posted is requiredIdentifierUpgradeBond().
governance.IDENTIFIER_UPGRADE_LIVENESSconstUMA liveness of an identifier-upgrade proposal (seconds).
governance.IDENTIFIER_UPGRADE_TIMELOCKconstDelay between approval and activation (seconds).
governance.IDENTIFIER_UPGRADE_MIN_NOTICEconstDelay between approval and activation once the incumbent identifier is off UMA’s live whitelist (seconds).
governance.ASSERT_TRUTH_PREFIXconstEvery adjudication identifier begins with these 12 ASCII bytes.
governance.IdentifierUpgradeConstantsinterfaceThe governance track’s constants, read from the factory, plus the live bond.
governance.IdentifierUpgradeProposalInfointerfaceAn identifier-upgrade proposal.
governance.QueuedIdentifierUpgradeinterfaceAn approved upgrade waiting out its timelock.
governance.IdentifierGovernanceStatusinterfaceThe governance track in one call.
governance.IDENTIFIER_UPGRADE_EVENT_TEXTconstWhat each governance event means, for an activity feed (decode them with listings’ decodeFactoryLog).

uma

UMA assertions, disputes and settlement, and the shared helpers the costed builders use.

ExportKindWhat it is
uma.optimisticOracleCodecfunctionCodec for the Optimistic Oracle V3 fragment.
uma.erc20CodecfunctionCodec for ERC-20 (USDC’s approve, allowance, balanceOf, decimals).
uma.readManyfunction, bzSeveral reads in as few eth_calls as the node allows (Chain.batch), decoded.
uma.readOnefunction, bzOne read.
uma.chainNowfunction, bzThe latest block’s timestamp: what the contracts’ block.timestamp checks are judged against.
uma.usdcTextfunctionUSDC units (6 decimals) as “1,000 USDC”, exactly (“1,000.009999 USDC”).
uma.sharesTextfunctionInsurance shares (18 decimals) as “1,234.5678 shares”, exactly.
uma.ethTextfunctionETH in wei as “0.000001 ETH”, exactly (7 wei is “0.000000000000000007 ETH”).
uma.timeTextfunctionA unix time (seconds) as “2026-11-08 14:00:00 UTC”.
uma.durationTextfunctionA duration in seconds as “12 hours”, “14 days”, “7 seconds”.
uma.asBytes32functionA bytes32 field the caller typed, checked.
uma.asAddressfunctionAn address the caller typed, checked and checksummed.
uma.requireBondFloorfunctionThrows BondQuoteError when quoted (USDC units) is below floor, the contract’s own minimum for what.
uma.approveCallfunctionAn ERC-20 approve as a PreparedCall.
uma.bondApprovalsfunction, bzThe approvals owner must send before a call that pulls pulls, and what to tell them.
uma.identifierFromTextfunction“ASSERT_TRUTH2” -> its bytes32 (ASCII, right-padded with zeros), as UMA stores identifiers.
uma.identifierTextfunctionA bytes32 identifier as its ASCII text (“ASSERT_TRUTH2”), or null when it is not printable ASCII.
uma.optimisticOraclefunction, bzThe deployment’s Optimistic Oracle: factory.oo(), immutable.
uma.assertionOraclefunction, bzThe oracle assertionId was made on: factory.assertionOo(id) or terminator.assertionOo(id), falling back to factory.oo() when the record is empty (an id neither contract made).
uma.getAssertionfunction, bzThe assertion record on oo, or null for an unknown id (OOv3 returns an all-zero record).
uma.assertionPhasefunctionThe phase of an assertion at chain time now.
uma.statusOffunctionThe status of an assertion judged at now.
uma.assertionStatusfunction, bzWhere an assertion stands: pending its liveness (with the time it can settle), settleable, disputed (waiting on the DVM), or settled true or false.
uma.getMinimumBondfunction, bzThe smallest bond OOv3 accepts in currency (USDC by default), in its units; 0 when OOv3 has never cached it.
uma.defaultIdentifierfunction, bzOOv3’s default identifier.
uma.burnedBondPercentagefunction, bzThe share of a disputed bond UMA burns as its fee, 1e18 = 100% (50% on the live OOv3), or null if the oracle does not report it.
uma.identifierWhitelistedfunction, bzWhether identifier is on UMA’s live IdentifierWhitelist, resolved as the factory and OOv3 resolve it: oracle -> finder -> IdentifierWhitelist -> isIdentifierSupported.
uma.previewSettlementfunction, bzSimulate OOv3’s settleAndGetAssertionResult(assertionId) without sending it.
uma.previewSettlementOnfunctionpreviewSettlement on an oracle the caller already resolved with assertionOracle (no check of oo, so never pass one a user gave).
uma.decodeAssertionLogfunctionDecode an OOv3 log (AssertionMade, AssertionDisputed, AssertionSettled), or null for another contract’s.
uma.disputeAssertionfunction, bzDispute an assertion: you match its bond, and UMA’s DVM votes on the claim.
uma.settleAssertionfunction, bzSettle an assertion on the oracle directly.
uma.requireSettleablefunctionThrows the InputError a settle attempt deserves when the assertion cannot settle yet.
uma.optimisticOracleV3AbiconstThe slice of UMA’s OptimisticOracleV3Interface these flows need.
uma.isZero32constTrue for the all-zero bytes32 the contracts use as “none”.
uma.ContractReadinterfaceOne read for readMany.
uma.ReadErrorclassA read that reverted, with its decoded reason.
uma.CostKindtypeWhat a cost is: a bond (returned or forfeited by a verdict), an insurance seed, an ETH fee, a levy on shares, or shares locked.
uma.CostinterfaceOne thing a call costs or puts at risk, for a confirmation screen.
uma.CostedCallinterfaceA prepared write that posts a bond, moves money or locks shares, with what it costs.
uma.TokenPullinterfaceA bond or seed an ERC-20 transferFrom will pull from the caller.
uma.MIN_ASSERTION_BOND_USDCconstThe least bond any Bazaar assertion posts (USDC units): the listing and termination floors; an identifier upgrade’s is 5,000 USDC.
uma.BondQuoteErrorclassA bond quote read from the chain that cannot be right: below the floor the contract itself enforces (an RPC answering 0, say).
uma.AssertionHometypeWhere an assertion was made: the factory (listings, identifier upgrades) or the terminator (termination claims).
uma.UmaAssertioninterfaceAn OOv3 assertion record.
uma.AssertionPhasetypeWhere an assertion stands, for a UI.
uma.AssertionStatusinterfaceWhat assertionStatus returns: the phase, the assertion record, and when it can be settled.
uma.ASSERTION_PHASE_TEXTconstText for each assertion phase.
uma.SettlementPreviewinterfaceWhat settling an assertion would do right now.
uma.ASSERTION_EVENT_TEXTconstWhat each OOv3 event means, for an activity feed.

format

The error messages and the display formatters (errors/messages.ts).

ExportKindWhat it is
format.formatUsdfunctionA BAZAAR-precision amount as dollars: “$1,234.56”; amounts under a cent keep six decimals.
format.formatPricefunctionA BAZAAR-precision price: “$3,012.5”, “$1.08765”, “$0.00001234”.
format.formatAmountfunctionA BAZAAR-precision quantity (size, shares): “1.25”, “12,000”.
format.formatBpfunctionBasis points as percent: 250 -> “2.5%”.
format.formatEthfunctionWei as ETH: “0.0001 ETH”.
format.formatDurationfunctionA duration in seconds, roughly: “45 s”, “12 min”, “3 h”, “13 days”.
format.formatTimefunctionA Unix time (seconds) as “2026-11-08 14:00 UTC (in 3 h)”.
format.installUserMessagesfunctionRegister USER_MESSAGES so DecodedError.message returns them.
format.userMessagefunctionThe user-facing message for a custom error by name, with its arguments; the name itself when unknown.
format.walletErrorMessagefunctionA message for a failure the wallet raised rather than the contract.
format.errorMessagefunctionThe text to show a user for any error a Bazaar call can throw.
format.USER_MESSAGESconstEvery custom error’s user-facing message, by error name.

Low-Level Exports

The chain, ABI, price, formula and market layers the keeper and the areas are built on. See Low-Level Building Blocks.

Protocol Constants

ExportKindWhat it is
BAZAAR_SCALEconst1e18: BAZAAR precision, $1 or one unit.
BP_SCALEconst10,000: basis points in 100%.
EBP_SCALEconst1,000,000: EBP (1/100 bp) in 100%.
USDC_DECIMALSconst6.
USDC_TO_BAZAARconstBAZAAR precision carries twelve more decimals than USDC.
MAX_PRICE_STALENESSconstSeconds a print may sit from the block’s timestamp: 3.
LIMBO_LEADconstSeconds before a scheduled cutoff that trading halts: 6.
MIN_ORDER_AMOUNTconstThe smallest order notional: $5 (BAZAAR precision).
MIN_COLLATERAL_DEPOSITconstThe smallest collateral deposit: $5 (BAZAAR precision).
MAX_WALK_STEPSconstThe default and largest walk budget of an arriving order: 32.
MAX_TRIGGER_EXECS_PER_CALLconstTrigger orders one executeTriggeredOrders call fires at most: 20.
LIQUIDATION_FEE_EBPconstThe liquidation fee: 2 bp (EBP).
MIN_LIQUIDATOR_REWARDconstThe least a liquidation pays its caller: $0.05.
TERMINAL_SETTLEMENT_TIPconstThe tip for settling someone else’s position at the fixed settlement price: up to $0.05.
TERMINAL_SETTLEMENT_ESCAPEconstSeconds after the settlement price is fixed before finalizeTermination stops waiting for every position to be settled: 14 days.
UNLOAD_BOUNTY_MINconstThe floor of an unload’s reward: $0.25.
UNLOAD_BOUNTY_MIN_NOTIONALconstThe unload size at which the $0.25 floor applies, and the inventory notional for the fast refresh cadence: $1,250.
UNLOAD_FLOOR_DECAYconstSeconds over which the unload floor slides from the band edge to the fund-neutral price: 180.
ADL_AUCTION_DURATIONconstSeconds the ADL score threshold takes to decay: 600.
ADL_MAX_SCOREconstThe ADL score threshold at the start of the auction: 25 (BAZAAR precision).
ADL_TERMINATION_GRACEconstSeconds after the first price stored past an ADL timeout before the market winds down: 1,800.
MAX_LIQUIDATION_PRICE_AGEconstOldest stored price (seconds) liquidate judges solvency against without a fresh priceUpdate: 600.
ADL_TIMEOUT_DURATIONconstSeconds after which an open ADL window times out: 1 day.
MAX_ADL_WINNERS_PER_BATCHconstMost entries each winner list of an ADL batch may hold: 25.
REFRESH_BOUNTYconstWhat a paid refreshPrice earns: $0.10.
REFRESH_BOUNTY_MIN_GAPconstThe fast refresh cadence: a paid refresh at most every 300 seconds.
REFRESH_BOUNTY_HEARTBEATconstThe slow refresh cadence: a paid refresh at most every day.
REFRESH_BOUNTY_FAST_MIN_INSURANCEconstFree capital above which the fast cadence applies: $5,000.
REFRESH_BOUNTY_HEARTBEAT_MIN_INSURANCEconstFree capital the fund must exceed for a refresh to pay: $1,000.
MAX_CONFIDENCE_BPconstThe widest confidence interval a leg may carry: 2% of its price (bp).
BAZAAR_EXPONENTconst-18: the exponent the oracle converts prices to.
ORACLE_DEAD_THRESHOLDconstSeconds without a stored price after which a market can be terminated as stale: 21 days.
SETTLEMENT_SAMPLE_COUNTconstSamples the chain settlement rule takes: 5.
SETTLEMENT_SAMPLE_SPACINGconstSeconds between the chain rule’s samples: 600.
SETTLE_NOTHINGconstSettlement rule 0: nothing to settle.
SETTLE_TICKconstSettlement rule 1: UMA’s rule, the update stamped at the cutoff.
SETTLE_CHAINconstSettlement rule 2: the chain rule’s median of samples.
SETTLE_AWAIT_VERDICTconstSettlement rule 3: waiting for a UMA verdict.
OrderTypeconst + typeOrder types by name (OrderType.Limit is 1), and the type of their values.
MARKETconstOrder type 0.
LIMITconstOrder type 1.
STOP_LIMITconstOrder type 2.
TAKE_PROFITconstOrder type 3.
STOP_LOSSconstOrder type 4.
TRIGGER_TYPESconstThe trigger order types: StopLimit, TakeProfit, StopLoss.
ORDER_TYPE_NAMESconstOrder type names by number.
TRIGGER_PRICE_MASKconstAn order’s trigger price sits in the low 128 bits; its keeper bounty in the high 128.
ETH_USD_FEEDconstPyth Pro ETH/USD (feed 2) as a bytes32 feed id, used to price gas.
ZERO_ADDRESSconstThe zero address.
MAX_UINT256const2^256 - 1.

Typed ABIs

ExportKindWhat it is
bazaarFactoryAbiconstBazaarFactory’s ABI, typed as const for viem.
bazaarOracleAbiconstBazaarOracle’s ABI, typed as const for viem.
bazaarPairAbiconstBazaarPair’s ABI (with the events and errors of the libraries it links), typed as const for viem.
bazaarPairLensAbiconstBazaarPairLens’s ABI, typed as const for viem.
bazaarPairTerminatorAbiconstBazaarPairTerminator’s ABI, typed as const for viem.
ABI_SOURCEconstThe Bazaar commit and date the ABIs were synced from, and whether that tree was dirty.
ABISconstThe five ABIs by contract name.
ContractNametype"BazaarFactory", "BazaarOracle", "BazaarPair", "BazaarPairLens" or "BazaarPairTerminator".
CONTRACT_NAMESconstThe five contract names.

Chain, JSON-RPC and the Probe

ExportKindWhat it is
readTextCappedfunctionA response’s body as text, holding at most maxBytes of it.
toRpcErrorfunctionNormalize whatever a transport threw into an RpcError when it carries a JSON-RPC error.
sleepfunctionA promise that resolves after ms milliseconds.
RequestArgsinterfaceA JSON-RPC request: method and params.
TransportinterfaceEIP-1193’s request method: what a browser wallet, a viem client and JsonRpc all provide.
RpcErrorclassA JSON-RPC error: its code, the node’s message and data, and the revert payload when there is one.
HttpErrorclassA non-2xx HTTP response.
ResponseTooLargeErrorclassA response body over the reader’s byte limit.
JsonRpcOptionsinterfaceOptions of JsonRpc: requests per second, timeout, retries, headers, response size cap and fetch.
RequestOptionsinterfacePer-request options of JsonRpc.request.
NEVER_RETRIEDconstMethods JsonRpc sends at most once, whatever retries says.
JsonRpcclassA small JSON-RPC client over HTTP(S): rate limited, retried on transient failures except for sends, with response bodies capped.
SendErrorclassA send whose outcome is unknown: the signed transaction left this process, but nothing settled whether the node took it.
BlockinterfaceA block’s number, timestamp, hash, parent hash and base fee.
ProbeResultinterfaceA probe simulation’s result: ok, return data, claimable balance before and after, gas, and the state flags after.
ReadinterfaceOne call in a batched read.
ReceiptinterfaceA transaction receipt: hash, block, status, gas, sender, target and logs.
BlockTagtypeA block number, or latest, pending, safe, finalized or earliest.
ChainOptionsinterfaceOptions of Chain: send, a separate transport to submit transactions through.
ChainclassChain access over any Transport: reads, batched reads, probe simulations, gas, logs, receipts and raw sends.
findDeploymentBlockfunctionThe first block whose state holds code at address.
DeploymentBlockErrorclassfindDeploymentBlock could not find the block (no code at the head, or the node cannot read old state).
FindDeploymentOptionsinterfaceOptions of findDeploymentBlock: the block range to search.
probeCodecfunctionThe simulation probe’s codec (execute, aggregate).
stateOverridefunctionThe eth_call state override that places the probe at PROBE_ADDRESS.
PROBE_ADDRESSconstWhere the probe’s code is placed by state override.
PROBE_BALANCEconstETH the override gives the probe, to pay Pyth fees inside simulations.
runtimeCodeconstruntimeCode(): the probe’s runtime bytecode.
StateOverridetypeThe eth_call state override that places the probe.

Codecs and Revert Decoding

ExportKindWhat it is
canonicalTypefunctionAn ABI parameter’s canonical type string (tuples expanded).
signaturefunctionA function or event signature string, as hashed for selectors and topics.
codecfunctionThe cached ContractCodec for one of the five contracts.
allErrorsfunctionEvery custom error the five contracts declare, by selector.
FunctionSpecinterfaceA function in a codec: name, inputs, outputs, selector.
EventSpecinterfaceAn event in a codec: name, inputs, topic0.
ErrorSpecinterfaceA custom error in a codec: name, inputs, selector.
RawLoginterfaceA log as Chain.logs and Chain.receipt return it.
DecodedLoginterfaceA decoded log: name, address, args, and where it sits.
ContractCodecclassEncode and decode one contract’s calls, return values, logs and errors (encode, decode, decodeLog, …).
factoryconstfactory(): the BazaarFactory codec.
pairconstpair(): the BazaarPair codec.
lensconstlens(): the BazaarPairLens codec.
terminatorconstterminator(): the BazaarPairTerminator codec.
oracleconstoracle(): the BazaarOracle codec.
registerUserMessagesfunctionRegister user-facing messages by error name for DecodedError.message (what installUserMessages calls).
decodeErrorfunctionRevert data as a DecodedError: the error’s name, arguments and hint.
KEEPER_HINTSconstWhat each error means for a keeper, and what to do about it.
VERIFIER_REASON_HINTSconstWhat each reason string Pyth’s verifier reverts with means for a keeper.
registerReasonMessagesfunctionRegister user-facing messages for plain revert reasons, by the reason string.
DecodedErrorclassA decoded revert: name, args, hint, data, message (the user text), and opensAt() for a too-early error.

Reader, Models and Formulas

ExportKindWhat it is
freeCapitalfunctionThe insurance balance less the seizure earmarked against inventory the vault holds.
inventorySizefunctionThe vault’s liquidated inventory size on both sides of a MarketState.
marketStatusfunctionA MarketState’s Status at a time: terminated, settling, limbo, adl or live.
marketStaticfunctionThe part of a Market that never changes.
marketFromStaticfunctionA Market rebuilt from its static part, with no state read yet.
orderRemainingfunctionAn order’s unfilled size.
Statusconst + typeA market’s status (Status.Live is "live"; also adl, limbo, settling, terminated), and the type of its values.
InventoryinterfaceThe vault’s liquidated inventory on one side: size, entry and bankruptcy notional, seized collateral, funding index.
emptyInventoryconstAn Inventory with every field zero.
MarketStateinterfaceOne read of a market’s on-chain state (Reader.marketStates).
MarketinterfaceA market as the keeper and Reader track it: address, pair id, feeds, calendar, label, and its last state read.
MarketStaticinterfaceThe part of a Market that never changes, as stored in snapshots.
PositioninterfaceAn open position as the keeper indexes it.
OrderinterfaceA live order as the keeper indexes it.
mulDivfunctionOpenZeppelin Math.mulDiv for non-negative operands: floor(x * y / d).
signedMulDivfunctionBazaarMathLib.signedMulDiv: (x * y) / d rounded toward zero.
effectiveMmrBpfunctionBucketLib.effectiveMmrBp: the lower of the current rate and a non-zero lagged one.
bucketStatefunctionBucketLib.calculateState, with mmrBp already the effective rate.
marginRatiofunctionEquity over the maintenance requirement: below 1 is liquidatable.
triggerReachedfunctionMatchingEngineLib._triggerReached, inclusive at the trigger.
adlThresholdfunctionAdlLib._getAdlScoreThreshold: quadratic decay from ADL_MAX_SCORE to 1 over the auction.
adlScorefunctionPnL over the collateral less this window’s tagged deposit, floored at 1 (AdlLib._scoreCollateral).
refreshBountyGapfunctionThe minimum gap between paid refreshes for a market (fast or heartbeat cadence).
refreshBountyDuefunctionWhether refreshPrice would pay its bounty when it stores a print published at newPublishTs.
refreshBountyDueAtfunctionThe first block time at which the bounty is payable, or null while the fund is too small.
convertExponentfunctionBazaarMathLib.convertExponent (division truncates toward zero).
legConfOkfunctionWhether a leg’s confidence interval is within 2% of its price.
pairPricefunctionThe price BazaarOracle composes from one or two legs, and whether it clears the 2% cap.
BucketStateinterfaceWhat bucketState returns: PnL, funding PnL, notional, the maintenance requirement and available equity.
MAX_EXPONENT_SHIFTconstThe largest exponent difference convertExponent takes: 10^77 is the largest power of ten a uint256 holds, so the contract reverts past it (and an unbounded one would stall the caller).
LeginterfaceOne Pyth leg of a price: price, confidence, exponent, publish time.
PairPriceinterfaceWhat pairPrice returns: spot, the confidence bracket, the publish time, and whether confidence is within the cap.
DeploymentinterfaceThe contracts a factory is wired to.
OrderRecordinterfaceorders(id) on a pair, with the lens’s triggerBounty for it.
PositionBucketinterfacepositionBuckets(account) on a pair: an account’s position as the chain holds it now.
ReaderclassBatched chain reads: the deployment’s wiring, market discovery, per-block market state, positions and orders.

Subgraph Client

ExportKindWhat it is
SUBGRAPH_PAGEconstRows per page: the most a hosted graph-node returns for one first:.
SubgraphClientclassA GraphQL client for Bazaar’s subgraph: queries, the indexer’s head, and paging pinned to a block.
SubgraphErrorclassA SubgraphClient query the subgraph refused or failed (not the same class as history.SubgraphError).
SubgraphMetatypeThe indexer’s head: the block it has indexed, its hash, and whether it hit indexing errors.
SubgraphOptionsinterfaceOptions of SubgraphClient: timeoutMs, fetch, maxResponseBytes and maxPages.

Prices: Pyth Pro, PriceService and Signed Updates

The lazer namespace holds the whole update codec; its types, its error and the feed id helpers are also at the top level.

ExportKindWhat it is
lazer.parseUpdatefunctionReads a Pyth Pro update and recovers its signer: { signer, timestampUs, channel, feeds, payload }.
lazer.readUpdatefunctionReads an update without recovering its signer.
lazer.parsePayloadfunctionReads a signed payload as LazerPayloadLib.readFeeds does; throws where the contract reverts as malformed.
lazer.splitUpdatefunctionSplits an update into its signature and its payload, as Pyth’s verifier does.
lazer.recoverSignerfunctionThe address that signed an update.
lazer.encodePayloadfunctionEncodes a payload from feed fields (tests, local chains).
lazer.signPayloadfunctionWraps a payload in the EVM envelope, signed with a private key.
lazer.buildUpdatefunctionencodePayload, then signPayload.
lazer.localSignerKeyfunctionThe private key Bazaar’s local deployments sign updates with: public by construction.
lazer.LOCAL_SIGNER_LABELconstThe label the local signing key is derived from.
lazer.normalizeHexfunctionLowercase, 0x-prefixed form of hex given with or without 0x, or null when it is not whole bytes of hex.
lazer.channelNamefunctionA payload channel number’s name, or null.
lazer.ENVELOPE_MAGICconstThe first four bytes of an update in the EVM envelope.
lazer.PAYLOAD_MAGICconstThe first four bytes of a payload.
lazer.ENVELOPE_HEADER_BYTESconstEnvelope bytes before the payload: 71.
lazer.MAX_PAYLOAD_BYTESconstThe longest payload an envelope can frame.
lazer.MICROS_PER_SECONDconst1,000,000n.
lazer.HAS_PRICEconstBit of LazerFeed.included: the payload carries the price.
lazer.HAS_EXPONENTconstBit of LazerFeed.included: the payload carries the exponent.
lazer.HAS_CONFIDENCEconstBit of LazerFeed.included: the payload carries the confidence.
lazer.HAS_FEED_UPDATE_TIMESTAMPconstBit of LazerFeed.included: the payload carries the feed update timestamp.
lazer.LIVE_PROPERTIESconstWhat a feed must carry to price a live call.
lazer.TICK_PROPERTIESconstWhat a one-second update must carry for a feed to settle a pair at it.
lazer.CHAIN_PROPERTIESconstWhat a one-second update must carry to prove a chain sample.
lazer.EnvelopeinterfaceAn update’s envelope, split: r, s, the recovery id and the payload.
CHANNEL_IDSconstEach channel’s number in a payload.
CHANNEL_FIXED_RATE_1000constThe number of the channel whose updates are stamped once per second, on whole seconds.
ChanneltypeThe channels Pyth Pro produces updates on: real_time, fixed_rate@50ms, fixed_rate@200ms, fixed_rate@1000ms.
LazerFeedinterfaceOne feed’s fields in one payload, with which properties the payload carries (included).
LazerFeedInputinterfaceOne feed to encode; a property left undefined is left out of the payload.
LazerPayloadinterfaceA payload’s contents: timestamp, channel, feeds.
LazerPayloadInputinterfaceWhat encodePayload takes.
LazerUpdateinterfaceAn update, read from its bytes: the payload’s contents, the signer, the payload.
LazerErrorclassAn update or feed id that is not valid.
feedIdHexfunctionThe bytes32 feed id that holds a Pyth Pro feed number (1 → 0x00…01).
proFeedIdfunctionThe Pyth Pro feed number a raw bytes32 feed id holds; throws on anything else.
isRawFeedIdfunctionWhether an id has the form of a raw Pyth Pro feed id (BazaarOracle.isRawFeedId).
ProClientclassA client for Pyth Pro: latest, latestUpdate, atTick, stream, symbols and close.
ProClientOptionsinterfaceOptions of ProClient: the token, the endpoints, the channel, headers, timeout, fetch, WebSocket, streamAuth and isTrustedSigner.
ProErrorclassA Pyth Pro request or stream that failed; its message never shows a token.
ProUpdateinterfaceA signed update as the client read it: the blob, the signer, the timestamp, the channel, every feed’s entry, and the live prints.
ProSymbolinterfaceOne entry of Pyth Pro’s symbols registry.
PrintinterfaceOne feed’s price and the signed update that carries it.
SignerChecktypeSays whether updates signed by a signer may be used.
TokenProvidertypeReturns the token to present: an API key (servers only) or a short-lived token minted from one.
MintedTokeninterfaceA short-lived token minted from an API key: the token, when it expires, its type.
mintProTokenfunctionExchange a Pyth Pro API key for a short-lived token (server side).
cachedTokenfunctionA token provider that mints only when the token it holds is about to expire.
UNKNOWN_EXPIRY_LIFETIMEconstSeconds a token whose expiry is unknown is reused for by cachedToken.
WebSocketConstructortypeA WebSocket constructor: the global one, or a package’s.
WebSocketLikeinterfaceThe parts of a WebSocket the client uses.
AUTH_SUBPROTOCOLconstThe subprotocol that marks the next entry of a WebSocket’s subprotocol list as the token.
DEFAULT_CHANNELconstThe channel a client streams and reads the latest price on unless told otherwise.
PRO_PROPERTIESconstThe properties every request asks for.
MAX_RESPONSE_BYTESconstThe largest response body a request may return, in bytes (16 MiB).
MAX_STREAM_MESSAGEconstThe longest message a price stream may send, in characters (1 MiB).
MAX_STREAM_NOTICESconstHow many distinct notices one stream passes to its onNotice before it drops the rest.
STREAM_IDLE_TIMEOUTconstSeconds a price stream may bring no new update before it is abandoned and reconnected.
StreamOptionsinterfaceOptions of ProClient.stream: idleTimeout and onNotice.
isLivePrintfunctionWhether a feed entry holds a price a live call could use.
whyNotLivefunctionWhy a feed entry holds no price a live call could use, or null when it holds one.
printsOffunctionThe prints a parsed update carries: one per feed that holds a live price.
ETH_USD_BOUNDSconstDefault PriceServiceOptions.ethUsdBounds.
ETH_USD_MAX_AGEconstDefault PriceServiceOptions.ethUsdMaxAge.
MAX_FUTURE_SKEWconstDefault PriceServiceOptions.maxFutureSkew.
PriceLoggerinterfaceWhere a PriceService reports warnings.
PriceServiceclassLive prints for every watched feed (streamed, or polled as a fallback), and per-market prices and updates.
PriceServiceOptionsinterfaceOptions of PriceService: streaming, idle timeout, poll interval, clock, send margin, future skew, ETH/USD age and bounds, and logger.
PriceSourceinterfaceWhere a PriceService gets its prints: a ProClient, or anything shaped like one (latest, and stream over sets of feeds).
PrintListenertypeCalled after each batch of prints with the feeds that got a newer print in it.
distinctBlobsfunctionThe distinct signed updates a list of prints came in, in the prints’ order.
feedLabelfunction‘Crypto.BTC/USD’ -> ‘BTC/USD’, ‘Equity.US.AAPL/USD’ -> ‘AAPL/USD’.
fetchFeedInfofunctionPyth Pro’s registry entries for feeds, by feed id; empty when the registry cannot be read.
fetchSymbolsfunctionPyth symbols for feeds, from the symbols registry; empty when it cannot be read.
marketLabelfunctionA market’s label from its legs’ Pyth symbols; a composite (with a quote leg) is priced in USD.

URLs

ExportKindWhat it is
isUrlfunctionWhether value is an http(s) or ws(s) URL, as fetch would read it.
urlCredentialfunctionWhere a URL may carry a credential, as a phrase (a key in its path, …), or null when it carries none.
redactUrlfunctionThe URL with every part not known to be public replaced by mask, safe to print or log (see the module’s rules).
PATH_WORDSconstPath segments that are never secret (compared without case).
PUBLIC_PARAMSconstQuery parameters whose (plain) values are never secret (compared without case).

@liberta-labs/bazaar-keeper

import { Keeper, loadConfig, jobs } from "@liberta-labs/bazaar-keeper";

See The Keeper as a Library for how the pieces fit.

The Keeper Engine

ExportKindWhat it is
VERSIONconstThe keeper package’s version.
CATCH_UP_BLOCKSconstA sync step that starts this many blocks behind the head is a catch-up (after a restart or an outage): the print path waits for it, so it does not plan from an index still being replayed.
JobStatusEntryinterfaceOne job in the status file.
KeeperclassThe keeper: Keeper.create(config), start(), stop(), close(), on(event), and run for one-shot operations.
KeeperEventtypeThe name of a KeeperEvents event.
KeeperEventsinterfaceWhat Keeper.on delivers, by event.
KeeperListenertypeA listener for one KeeperEvents event.
KeeperOptionsinterfaceOptions of Keeper.create: loadWallets (false for a watch-only keeper).
KeeperStatusinterfacestatus.json, as the Python keeper writes it (the same keys), for bazaar keeper status.
MarketStatusEntryinterfaceOne market in the status file.
SignalclassA settable event: set, clear, and wait until set.
StopOptionsinterfaceOptions of Keeper.stop: whether to collect first, and the timeout.
listMarketsfunctionThe factory’s markets with their labels, without indexing anything (for setup and listings).
stateDirForfunctionWhere a keeper for this network and factory keeps its snapshot, ledger and status.

Configuration

ExportKindWhat it is
ConfigErrorclassA configuration problem, phrased for the person fixing it.
DEFAULT_CONFIGconstThe default config file name: bazaar-keeper.yaml.
SECRET_KEYSconstKeys whose values are secrets.
expandHomefunctionA path with a leading ~ expanded to the home directory.
loadConfigfunctionRead a config file, its sibling .env, and the environment.
parseConfigfunctionValidate an already-interpolated config object.
enabledJobsfunctionThe jobs switched on, by name.
priceEndpointsfunctionWhere the keeper reads prices: the stream URLs, the request API and the symbols registry prices.endpoint names, each replaced by prices.streamUrls, prices.restUrl or prices.symbolsUrl when set.
resolvedNetworkfunctionThe network a config names, with its preset applied: chain id, factory, start block, subgraph and whether official.
subgraphUrlfunctionThe subgraph URL a config resolves to, or null for none.
RpcConfigconst + typeThe rpc section’s zod schema and type.
SubgraphConfigconst + typeThe subgraph section’s zod schema and type.
PricesConfigconst + typeThe prices section’s zod schema and type.
WalletConfigconst + typeOne entry of wallets: its zod schema and type.
JobConfigconst + typeThe options every job shares: zod schema and type.
LiquidationJobconst + typeThe jobs.liquidation section’s zod schema and type.
TriggersJobconst + typeThe jobs.triggers section’s zod schema and type.
UnloadJobconst + typeThe jobs.unload section’s zod schema and type.
AdlJobconst + typeThe jobs.adl section’s zod schema and type.
RefreshJobconst + typeThe jobs.refresh section’s zod schema and type.
CollectJobconst + typeThe jobs.collect section’s zod schema and type.
TerminalSettlementJobconst + typeThe jobs.terminalSettlement section’s zod schema and type.
SweepExpiredJobconst + typeThe jobs.sweepExpired section’s zod schema and type.
JobsConfigconst + typeThe jobs section’s zod schema and type.
ProfitConfigconst + typeThe profit section’s zod schema and type.
GasConfigconst + typeThe gas section’s zod schema and type.
BudgetConfigconst + typeThe budget section’s zod schema and type.
SafetyConfigconst + typeThe safety section’s zod schema and type.
ALERT_KINDSconstThe alert kinds alerts.notify can name: lowBalance, jobPaused, endpointDown, opportunity, dailySummary, action.
AlertKindtypeOne of ALERT_KINDS.
AlertsConfigconst + typeThe alerts section’s zod schema and type.
StateConfigconst + typeThe state section’s zod schema and type.
LogConfigconst + typeThe log section’s zod schema and type.
EngineConfigconst + typeThe engine section’s zod schema and type.
KeeperConfigconst + typeThe whole keeper config’s zod schema and type.
JOB_NAMESconstThe jobs in the order the keeper runs and reports them, by their config key.
JobNametypeOne of JOB_NAMES.
ResolvedNetworkinterfaceWhat resolvedNetwork returns.

Wallets and Signers

ExportKindWhat it is
KEYS_DIRconstWhere bazaar wallet new puts keystores (expand with expandHome).
KeystoreV3interfaceA v3 JSON keystore.
LocalSignerclassSigns with a key held in this process.
RemoteSignerclassAny signer that answers eth_signTransaction over JSON-RPC (Web3Signer, a KMS bridge).
RemoteSignerOptionsinterfaceOptions of RemoteSigner: timeout and fetch.
SignerinterfaceSomething that signs keeper transactions: an address and sign(tx).
TxRequestinterfaceAn EIP-1559 (type 2) transaction, as the executor builds it.
WalletErrorclassA wallet that cannot be loaded or used (a wrong password, a bad key or phrase, a remote signer’s failure).
checkMnemonicfunctionThe phrase, normalized, after the checks eth-account makes: a known word count, every word from one language’s list, and a matching checksum.
decryptKeystorefunctionThe private key in a v3 keystore.
encryptKeystorefunctionA v3 keystore for key, with the Python keeper’s parameters and field order.
loadSignerfunctionThe signer for one configured wallet.
newKeyfunctionA new random private key.
writeKeystorefunctionEncrypt key to a JSON keystore at path (owner-only permissions where the OS has them); returns the address.

Execution, Ledger and Spend Policy

ExportKindWhat it is
ActionclassA call a job wants made: target, data, value, summary, locks, wallet and how it is paid.
ActionInitinterfaceWhat an Action is built from.
ActionResultclassWhat happened to an action: status, tx hash, gas, cost, reward, claimed, and the decoded error.
ActionResultInitinterfaceWhat an ActionResult is built from.
EmittypeHow the executor reports events (ExecutorEvents).
ExecutorclassSimulate, decide, send and track: the path every keeper action takes, with one nonce lane per wallet.
ExecutorEventsinterfaceWhat the executor reports: “alert” [kind, message], “sent” [action, txHash], “action” (a sent call’s result).
LaneclassOne wallet: its signer and its nonce sequence.
MutexclassA small FIFO mutex: acquire() returns nothing when the lock is free, so the free path never waits.
ResultStatustypeAn action’s outcome: confirmed, reverted, unknown, skipped or simulated.
RunOptionsinterfaceOptions of Executor.run: dryRun, force and the job’s config.
lockKeyfunctionOne key in the executor’s in-flight set, such as lockKey("liq", market.address, account).
DAYconst86,400 seconds.
EntryclassOne ledger line: a transaction the keeper sent, with its cost, reward and kind.
EntryInitinterfaceWhat an Entry is built from.
EntryKindtypeWhat an entry is: profit | loss | unpaid | collect | forward | manual | forced.
EntryStatustypeconfirmed, reverted or unknown.
LedgerclassThe earnings ledger (ledger.jsonl): every transaction sent, and the daily budgets read from it.
SummaryRowinterfaceOne row of the ledger’s summary: calls, reward, cost, net, claimed and reverted.
DecideOptionsinterfaceOptions of SpendPolicy.decide: whether the call is paid, an unpaid duty’s cap, and force.
DecisioninterfaceWhat SpendPolicy.decide returns: whether to send, the kind, and the reason.
DecisionKindtypeprofit | loss | unpaid | forced | skip, or a job’s own (collect, forward).
QuoteclassWhat a simulated call would earn and cost, in USD, and what it costs in wei.
SpendPolicyclassWhen a simulated call is worth sending: the profit rule, loss budgets and daily spend caps.

Jobs

The job classes are also on the jobs namespace (import { jobs } from "@liberta-labs/bazaar-keeper").

ExportKindWhat it is
ActionExtrastypeEverything an Action takes besides what Job.action fills in.
JOBSconstConfig key (camelCase, as JobsConfig names them) -> job class.
JobclassThe base class of a keeper job: its scope, cooldowns, the price it acts on, and plan (or planGlobal).
JobClasstypeThe constructor every job class has (the config is the job’s own schema type).
PricedCallclassThe price a planner judges by and the update payload its call carries.
jobs.AdlJobclassThe adl job.
jobs.ALL_STATUSESconstEvery market status (for a job that runs in all of them).
jobs.ActionExtrastypeEverything an Action takes besides what Job.action fills in.
jobs.JobclassThe base class of a keeper job: its scope, cooldowns, the price it acts on, and plan (or planGlobal).
jobs.JobClasstypeThe constructor every job class has (the config is the job’s own schema type).
jobs.LIVEconstThe statuses most jobs run in.
jobs.LIVE_OR_ADLconstLive, or with an ADL window open (matching halts; liquidation, ADL and refresh still run).
jobs.PricedCallclassThe price a planner judges by and the update payload its call carries.
jobs.monotonicconstSeconds on a monotonic clock, for cooldowns and schedules.
jobs.CollectJobclassThe collect job.
jobs.LiquidationJobclassThe liquidation job.
jobs.belowMaintenancefunctionWhether p is below its maintenance requirement at price and fundingIndex.
jobs.RefreshJobclassThe refresh job.
jobs.SweepExpiredJobclassThe sweepExpired job.
jobs.TerminalSettlementJobclassThe terminalSettlement job.
jobs.TriggersJobclassThe triggers job.
jobs.UnloadJobclassThe unload job.
jobs.JOBSconstConfig key (camelCase, as JobsConfig names them) -> job class.

Manual Operations

ExportKindWhat it is
AdlOptionsinterfaceOptions of keeper.run.executeAdl: the winners to close on each side.
CollectOptionsinterfaceOptions of keeper.run.collect: the markets to claim on.
FactoryOperationsclassThe factory’s permissionless operations: listings and identifier upgrades.
LiquidateOptionsinterfaceOptions of keeper.run.liquidate: the accounts to liquidate.
ManualOptionsinterfaceHow a manual operation goes.
OpOptionsinterfaceHow a one-shot run of an automatic job goes.
Operationsclasskeeper.run: every job once, on one market, and the manual operations.
SettleOptionsinterfaceOptions of keeper.run.settleTerminal: the accounts to settle.
SweepOptionsinterfaceOptions of keeper.run.sweepExpired: the orders to sweep.
TriggerOptionsinterfaceOptions of keeper.run.executeTriggers: the orders to fire.
OpportunityclassA manual operation someone could run: what, where (a market’s label, for people), why, and the CLI command that runs it (naming the market by its address, and only ids checked to be ids).
findOpportunitiesfunctionEvery manual operation that a dry run shows would go through now, or that a “too early” revert shows will open later.

Index: Store, Log Tail and Bootstrap

ExportKindWhat it is
EffectsclassWhat applying logs changed, for the engine to act on.
SNAPSHOT_VERSIONconstThe version of the keeper’s snapshot format (the Python keeper’s is 1; each ignores the other’s and bootstraps afresh).
StoreclassThe keeper’s local index: markets, open positions and live orders, kept current from logs.
handledPairEventsfunctionThe names of the pair events the store reacts to (for tests that check them against the ABI).
bootstrapfunctionLoad open positions and live orders as of block into the store, for the markets it holds, and return how many of each it loaded.
LogTailclassFollows the logs of the factory, the terminator and every market into a Store, handling reorgs and refused ranges.
REGROW_AFTERconstLog requests in a row that succeed at a reduced range before the tail tries one twice as wide.
REORG_REWINDconstBlocks the tail re-reads when the stored head’s hash no longer matches the chain.
TailDeploymentinterfaceThe contracts the tail follows besides the markets (the SDK’s Deployment fits).
TailLoggerinterfaceWhere the tail reports what it does.
refusedfunctionWhether the node answered and turned the request down, as providers do when a log query spans too many blocks or would return too many logs.

Logging and Alerts

ExportKindWhat it is
AlerterclassSends alerts to Telegram, Discord or a webhook, with repeats suppressed.
AlerterOptionsinterfaceOptions of Alerter: timeout and fetch.
SendOptionsinterfaceOptions of Alerter.send: repeatAfter.
LogFormattypepretty or json.
LogLeveltypedebug, info, warning or error.
LoggerinterfaceA named logger: debug, info, warning, error, exception and isEnabledFor.
formatMessagefunctionFill a %-template; returns the text and the exception argument, if any.
getLoggerfunctionThe logger with this name, created on first use.
setupfunctionConfigure every logger: the lowest level shown, and the output format.
setupLoggingfunctionConfigure every logger: the lowest level shown, and the output format.

CLI and Dev Tools

ExportKindWhat it is
runDoctorfunctionRun every check; true when nothing failed (warnings allowed).
runWizardfunctionAsk for the network, RPC, subgraph, price endpoint, wallet, jobs and markets, checking each answer, then write the config at configPath and a .env beside it.
MockProclassA Pyth Pro compatible price server for local chains (bazaar mock-prices): updates built by the SDK’s encoder, signed with the local signing key, stamped with the chain’s time; prices set, paused and resumed per feed.
MockProOptionsinterfaceWhat new MockPro takes: starting prices and symbols, the chain (or clock) to follow, the signing key, the default confidence, a token to require.
MockProServerinterfaceA running server: its url, streamUrl, host, port and close().
MockClockinterfaceThe time a MockPro stamps updates with.
MockUpdateinterfaceA signed update the mock built, and the feeds it carries.
MockUpdateSpecinterfaceWhat an update is asked to hold: its stamp, channel and properties.
MockFeedStateinterfaceOne feed as an update stamped at some instant carries it.
FeedReftypeA feed as the mock takes it: its Pyth Pro number, decimal digits, or the bytes32 that holds it.
FeedEntriestypeFeed -> value, as a record, a Map or entries.
feedNumberfunctionThe Pyth Pro feed number a FeedRef names.
startMockProfunctionServe a MockPro in the background (port 0 picks a free one); resolves once it listens.
serveMockProfunctionbazaar mock-prices: serve in the foreground until Ctrl-C (or SIGTERM).

Units & Constants

This page is the reference for the SDK’s units: which type and scale each kind of quantity uses, the functions that convert between them, the constants a frontend or a bot needs (with their values), and the values of order types, time in force and market status.

The Rule

QuantityTypeScale
Prices, sizes, notionals, collateral, PnL, fees, rewards, bounties, funding indicesbigint18 decimals (“BAZAAR precision”): 1e18 is $1, or one unit of the traded asset
USDC as the token counts it: wallet balances, allowances, approvals, permits, bonds named ..._USDCbigint6 decimals: 1_000_000n is 1 USDC
Rates and fees in basis points (imrBp, maxSlippageBp)bigint10_000n is 100%
Rates in “extended” basis points (..._EBP)bigint1_000_000n is 100%
Funding rates per hour, terminal payout ratiosbigint18 decimals: 1e18 is 100%
Times (Unix seconds), durations (seconds), block numbers, counts, enum valuesnumber
An order spec’s expirationBlockbigintA block number, but a bigint: take-profit and stop-loss orders carry 2^64 − 1 (“never”), which a number cannot hold
Display ratios: margin ratio, leverage, confidence in bpnumber

The pair takes and reports amounts in 18 decimals and floors what it moves to whole micro-USDC (multiples of 1e12). A name says USDC when an amount is in 6 decimals; any other amount is in 18. bigint values do not survive JSON.stringify: convert them to decimal strings first.

Parsing and Formatting

parseUnits18("12.5"); // 12500000000000000000n
parseUnits18("-0.75"); // -750000000000000000n
parseDecimal("2.5", 6); // 2500000n: any number of decimals
try {
  parseUnits18("1e-3"); // exponents, commas, "+", and more than 18 decimals are refused
} catch (e) {
  if (e instanceof UnitError) console.log(e.message); // "\"1e-3\" is not a decimal number"
}
formatUnits18(12_345_678_900_000_000_000n); // "12.3456789"
formatUnits18(12_345_678_900_000_000_000n, 2); // "12.34": cut toward zero, never rounded up
formatDecimal(2_500_000n, 6); // "2.5"
toNumber18(parseUnits18("0.1")); // 0.1, for charts and arithmetic on screen only
bpToFraction(250n); // 0.025

format.formatUsd(parseUnits18("1234.5")); // "$1,234.50"
format.formatPrice(parseUnits18("1.08765")); // "$1.08765"
format.formatBp(250n); // "2.5%"
format.formatDuration(3000); // "50 min"
FunctionDoes
parseUnits18(text)A decimal string (“12.5”, “-0.75”, “.5”) to 18 decimals. Throws UnitError on anything else: an exponent, a +, a thousands separator, more than 18 decimals. Surrounding spaces are ignored.
formatUnits18(value, maxDecimals?)18 decimals to a decimal string, trailing zeros trimmed; maxDecimals cuts toward zero (never rounds up).
parseDecimal(text, decimals = 18), formatDecimal(value, decimals = 18, maxDecimals?)The same at any number of decimals.
toNumber18(value)A JavaScript number, for display and charts only (loses precision past 2^53 / 1e18).
bpToFraction(bp)Basis points as a fraction (250n is 0.025), for display.

The format namespace has the formatters the SDK’s own messages use:

FunctionExample
format.formatUsd(amount)“$1,234.50”; amounts under a cent keep six decimals
format.formatPrice(price)“$3,012.50”, “$1.08765”, “$0.00001234”: decimals by magnitude
format.formatAmount(quantity, maxDecimals = 6)“1.25”, “12,000”
format.formatBp(bp)“2.5%”
format.formatEth(wei)“0.0001 ETH”
format.formatDuration(seconds)“45 s”, “50 min”, “3 h”, “13 days”
format.formatTime(unixSeconds, now?)“2026-11-08 14:00 UTC (in 3 h)”

USDC Conversions

const amount = parseUnits18("10.0000005"); // more precision than USDC has
floorToUsdc(amount); // 10000000000000000000n: what the pair actually moves (whole micro-USDC)
toUsdcUnits(amount); // 10000000n: floored, as the pair floors it
toUsdcUnitsCeil(amount); // 10000001n: rounded up, for an allowance or a permit that must cover it
fromUsdcUnits(10_000_000n); // 10000000000000000000n
FunctionDoes
toUsdcUnits(amount)18 decimals to USDC units, floored as the pair floors it.
toUsdcUnitsCeil(amount)18 decimals to USDC units, rounded up: for an allowance or a permit that must cover a pull.
fromUsdcUnits(units)USDC units to 18 decimals.
floorToUsdc(amount)18 decimals floored to a multiple of 1e12: what the pair actually moves.
allowance.formatUsdcUnits(units)USDC units as a decimal string (“12.5”).

PreparedCall.pullsUsdc is in 18 decimals; allowance.usdcFunding(owner, pair, pull) converts it and compares it with the wallet’s balance and allowance.

Constants

Exported from the package, each named after the Bazaar constant it copies. The SDK reads a value from the chain wherever the lens exposes it; these are for planning, validation and display.

Scales

ConstantValue
BAZAAR_SCALE10n ** 18n
BP_SCALE10_000n
EBP_SCALE1_000_000n
USDC_DECIMALS6
USDC_TO_BAZAAR10n ** 12n: 18 decimals carry twelve more than USDC
BAZAAR_EXPONENT-18: the oracle’s price exponent
MAX_UINT2562n ** 256n - 1n
ZERO_ADDRESS0x0000000000000000000000000000000000000000

Trading

ConstantValueMeaning
MAX_PRICE_STALENESS3Seconds a Pyth print may sit from the block’s timestamp.
LIMBO_LEAD6Seconds before a scheduled cutoff at which trading halts.
MIN_ORDER_AMOUNT5n * BAZAAR_SCALE$5: the smallest order notional (a full close is exempt).
MIN_COLLATERAL_DEPOSIT5n * BAZAAR_SCALE5 USDC: the smallest deposit.
MAX_WALK_STEPS32Resting orders an arriving order’s walk visits at most (the default budget).
MAX_TRIGGER_EXECS_PER_CALL20Trigger orders one executeTriggeredOrders fires.
MAX_CONFIDENCE_BP200n2%: the widest Pyth confidence the oracle accepts.
TRIGGER_PRICE_MASK2n ** 128n - 1nA trigger order’s price sits in the low 128 bits of its triggerPrice word, its keeper bounty in the high 128.
ETH_USD_FEED0x0000...0002Pyth Pro’s ETH/USD feed (feed 2, as a bytes32), used to price gas.

On the orders namespace:

ConstantValueMeaning
orders.MAX_SLIPPAGE_BP1000n10%: the widest slippage cap of a Market, TakeProfit or StopLoss order.
orders.MAX_PLACEMENT_DEVIATION_BP5000nA limit price must sit within ±50% of the oracle price.
orders.MIN_ORDER_LIFETIME_BLOCKS240About a minute of Arbitrum blocks: the shortest life of an order that rests.
orders.EXPIRY_LANDING_MARGIN_BLOCKS40Slack the SDK adds to the minimum when it checks an expiry.
orders.MAX_ORDER_LIFETIME_BLOCKS126_144_000About a year; a later expiry is clamped by the pair.
orders.NEVER_EXPIRE_BLOCK2n ** 64n - 1nThe expiry the pair stamps on TP and SL orders.
orders.L2_BLOCKS_PER_SECOND4Arbitrum’s block rate, for orders.blocksIn(seconds).
orders.MAX_ACTIVE_LIMIT_ORDERS_PER_USER32Live Limit (GTC) and StopLimit orders per account per market.
orders.MAX_AMENDS_PER_CALL32Amendments per amendOrders.
orders.MAX_CANCELS_PER_CALL200Order ids per cancelOrders.
orders.CLOSE_POSITIONMAX_UINT256The size of a TP or SL that closes the whole position.
orders.AMEND_BEST_EFFORT_BIT2n ** 192nThe flag in an amendment’s id word that skips rather than reverts.

Gasless Calls and Permits

ConstantValueMeaning
signing.MAX_RELAYER_FEEBAZAAR_SCALE$1: the most a relayer may charge per action.
signing.MAX_DEADLINE_WINDOW10Seconds a gasless signature may be valid past chain time.
signing.MAX_CLOCK_SKEW120Seconds this device’s clock may differ from the chain’s before signing is refused.
permits.DEFAULT_PERMIT_WINDOW1800Seconds a USDC permit is valid by default.

Markets and Prices

ConstantValueMeaning
markets.VENUE_CLOSED_AFTER15Seconds without a print before a trading-hours venue counts as closed.
markets.MAX_FUNDING_RATE_CONTINUOUSBAZAAR_SCALE / 1000n0.1% an hour: the funding cap on 24/7 markets.
markets.MAX_FUNDING_RATE_SESSIONBAZAAR_SCALE / 200n0.5% an hour: the cap on trading-hours markets.
priceFeeds.BROWSER_FUTURE_SKEW120Seconds a live print may be dated ahead of this device’s clock.
MAX_FUTURE_SKEW5The same, for PriceService (the keeper’s default).

Liquidation, ADL, Settlement

Mostly the keeper’s concern, exported for completeness:

ConstantValueMeaning
LIQUIDATION_FEE_EBP200n2 bp.
MIN_LIQUIDATOR_REWARDBAZAAR_SCALE / 20n$0.05.
MAX_LIQUIDATION_PRICE_AGE600Seconds a stored price may be old and still seize collateral.
UNLOAD_BOUNTY_MINBAZAAR_SCALE / 4n$0.25.
UNLOAD_BOUNTY_MIN_NOTIONAL1_250n * BAZAAR_SCALE$1,250.
UNLOAD_FLOOR_DECAY180Seconds.
ADL_AUCTION_DURATION600Seconds the ADL score threshold takes to decay to its floor.
ADL_MAX_SCORE25n * BAZAAR_SCALE
ADL_TIMEOUT_DURATION86_400Seconds an ADL window may stay open.
ADL_TERMINATION_GRACE1_800Seconds after an ADL timeout before it may terminate the pair.
MAX_ADL_WINNERS_PER_BATCH25
REFRESH_BOUNTYBAZAAR_SCALE / 10n$0.10.
REFRESH_BOUNTY_MIN_GAP300Seconds.
REFRESH_BOUNTY_HEARTBEAT86_400Seconds.
REFRESH_BOUNTY_FAST_MIN_INSURANCE5_000n * BAZAAR_SCALE$5,000.
REFRESH_BOUNTY_HEARTBEAT_MIN_INSURANCE1_000n * BAZAAR_SCALE$1,000.
TERMINAL_SETTLEMENT_TIPBAZAAR_SCALE / 20n$0.05 per position settled.
TERMINAL_SETTLEMENT_ESCAPE14 * 86_400Seconds: the longest a settlement window lasts.
ORACLE_DEAD_THRESHOLD21 * 86_400Seconds without a stored price before a pair counts as stale.
SETTLEMENT_SAMPLE_COUNT, SETTLEMENT_SAMPLE_SPACING5, 600The chain rule’s samples and their spacing (seconds).
SETTLE_NOTHING, SETTLE_TICK, SETTLE_CHAIN, SETTLE_AWAIT_VERDICT0 to 3Settlement rules.

What each of these governs is in Bazaar’s Parameters.

Order Types, Time in Force, Status

OrderType.Limit; // 1
ORDER_TYPE_NAMES[OrderType.StopLoss]; // "StopLoss"
orders.TimeInForce.FOK; // 2
orders.TIF_NAMES[orders.TimeInForce.IOC]; // "IOC"
orders.CLOSE_POSITION; // a TakeProfit or StopLoss of this size closes the whole position
const notional = (parseUnits18("0.002") * parseUnits18("2400")) / BAZAAR_SCALE; // $4.80
notional >= MIN_ORDER_AMOUNT; // false: the minimum is $5
OrderTypeValueAlso exported as
Market0MARKET
Limit1LIMIT
StopLimit2STOP_LIMIT
TakeProfit3TAKE_PROFIT
StopLoss4STOP_LOSS

ORDER_TYPE_NAMES[type] gives the name; TRIGGER_TYPES lists the three trigger types.

orders.TimeInForceValueMeaning
GTC0Rest the remainder in the book (only Limit and StopLimit rest). The default.
IOC1Fill what crosses on arrival, cancel the rest. Limit only.
FOK2Fill the whole size on arrival or revert. Limit and Market only.

orders.TIF_NAMES[tif] gives the name. The rules for each are in Orders.

StatusValue
Status.Live"live"
Status.Adl"adl"
Status.Limbo"limbo"
Status.Settling"settling"
Status.Terminated"terminated"

What each status means is on Markets.

Security Notes for Integrators

What a frontend, a relayer, a bot or a keeper operator has to do to use the SDK safely, and what the SDK already does for you. Bazaar’s contracts have no owner, no pauser and no allowlist: whatever an interface puts in front of a user is the interface’s responsibility. The SDK is built so that the safe path is the default one, but several duties stay with you, and this page lists them.

The short version:

  1. Simulate every call as the user before asking for a signature, and show what it does and costs.
  2. Approve exact amounts. Never default to an unlimited approval.
  3. Render every string that comes from the chain or the subgraph as text, never as HTML.
  4. Let the SDK check every market address; never build a call for a pair from a link by hand.
  5. Keep API keys out of the browser: put a proxy you control in front of keyed endpoints.
  6. Do not override the clock-skew check; tell the user to fix their clock or RPC instead.
  7. Know which calls a smart-contract wallet can make (direct ones) and which it cannot (gasless ones).
  8. Keep keeper keys in .env or a keystore, in a directory only the keeper’s user can read.

What the SDK Never Does

  • It never holds a user’s key. Every write is a PreparedCall (to, data, value, summary, and the rest) that your wallet stack sends, and every signature (a gasless request, a USDC permit) is requested from the user’s wallet with eth_signTypedData_v4, so the wallet shows every field it signs. See Sending Transactions.
  • It never assumes an approval exists. A call that pulls tokens reports how much (pullsUsdc, or a costed call’s approvals), and the approvals it builds are for the exact amount.
  • It never prints a raw URL or a token. Errors from JsonRpc, ProClient, SubgraphClient and the history queries carry the endpoint through redactUrl, which keeps only the parts of a URL known to be public and masks the rest (an Alchemy /v2/<key>, a ?key= query, user:password@). See Low-Level Building Blocks.

Simulate Before Signing

Run every call through bz.simulate(call) as the user before the wallet opens. A call that would revert comes back with ok: false and a decoded error whose message is a sentence for the user (every custom error the contracts declare has one; see Errors & Messages). Nothing has been signed or sent at that point, so the user loses nothing.

Simulation is not a guarantee: the chain can move between the simulation and the block the transaction lands in. Two cases need care:

  • Calls that carry a price. The pair refuses a Pyth print more than 3 seconds from the block’s time. A PreparedCall that carries one has priceTime; build it as late as possible (buildAndSend), and rebuild one whose priceTime is more than a second or two old. See Sending Transactions.
  • A step that depends on an earlier one. A deposit cannot be simulated until the approval it needs has landed: simulate each step right before you send it, as below.

Show What It Does and Costs

Show call.summary (one line written by the SDK from the call’s own arguments, such as “Deposit 100 USDC as collateral”) on the confirmation screen, with the exact amounts the call moves:

  • pullsUsdc: the USDC the call takes from the wallet (18 decimals). allowance.usdcFunding turns it into what the wallet holds, what it has approved, and what to approve.
  • value: the ETH the call sends, which is the Pyth update fee when a price update is attached.
  • For the calls that post a bond, a seed or a levy (termination, insurer votes, listings, identifier upgrades, disputes), the CostedCall fields: costs (each amount, who holds it, when it comes back, and whether it is a worst case), approvals (to send first), requires (preconditions in words) and notes. Show all four. See Termination & Settlement for the shape.
/** The lines a confirmation screen shows for a call that posts a bond, a seed or a levy. */
function costLines(call: uma.CostedCall): string[] {
  const amount = (c: uma.Cost) =>
    c.unit === "USDC" ? uma.usdcText(c.amount) : c.unit === "ETH" ? uma.ethText(c.amount) : c.unit === "shares" ? uma.sharesText(c.amount) : `${c.amount} units of ${c.token}`;
  return [
    call.summary,
    ...call.costs.map((c) => `${c.contingent ? "At most " : ""}${amount(c)}: ${c.label}. ${c.returned}`),
    ...call.requires.map((r) => `Needs: ${r}`),
    ...call.notes,
    ...call.approvals.map((a) => `First: ${a.summary}`),
  ];
}

A bond is real money at risk: a disputed listing or termination claim that loses forfeits it. Do not hide costs behind a “details” link.

Approve Exact Amounts

allowance.approveUsdc(owner, pair, amountUsdc) approves exactly amountUsdc. An unlimited approval ({ unlimited: true }) leaves the whole wallet balance pullable by that contract for as long as the approval stands; offer it only as the user’s explicit choice, say what it means, and let them revoke it (approveUsdc(owner, pair, 0n)). The costed builders’ approvals are always for the exact amount, and when the wallet already allows the spender more than the quote, the call’s notes say so: the contract takes what it charges when the call runs, up to that allowance.

A USDC permit (permits.signPermit) is the alternative to an approval: it rides inside the call, covers one amount, and expires (30 minutes after it is signed by default).

The whole flow, with the market from a link, the exact approval, and simulation before each send:

async function deposit(amount: string): Promise<void> {
  const call = await bz.trade.depositCollateral(market.address, user, parseUnits18(amount));

  // 1. Exact approval: the shortfall only, never unlimited unless the user chose it.
  const funding = await bz.allowance.usdcFunding(user, call.to, call.pullsUsdc ?? 0n);
  if (!funding.enoughBalance) return show("Your wallet does not hold enough USDC for this deposit.");
  const steps: PreparedCall[] = [];
  if (funding.approveUsdc > 0n) steps.push(await bz.allowance.approveUsdc(user, call.to, funding.approveUsdc));
  steps.push(call);

  // 2. Show every step before the wallet opens: the summary is the SDK's, the amounts are exact.
  if (!(await confirm(steps.map((s) => s.summary)))) return;

  for (const step of steps) {
    // 3. Simulate as the user. The deposit is simulated after its approval has landed, since
    //    simulating it before would fail on the allowance it is about to get.
    const sim = await bz.simulate(step);
    if (!sim.ok) return show(sim.error!.message);
    try {
      await bz.waitForReceipt(await bz.send(window.ethereum, step));
    } catch (e) {
      return show(errorMessage(e));
    }
  }
}

This assumes bz connected as in Connecting, user the wallet’s account and market resolved as in the next section. See Collateral, Approvals & Permits for the functions.

The Pair Check

A market address reaches a frontend from a link, a query string, a subgraph row or a cached list, so it is untrusted. A builder that took it on faith would show “Deposit 1000 USDC” for a call to any contract, approve that contract as a USDC spender, or have the user sign a permit or a gasless request for it.

The SDK checks every pair a write names before anything is built or signed:

  • The trading, collateral, insurance, approval, permit and gasless builders call requirePair, which asks the deployment’s factory (factory.isPair) and throws InputError on pair for anything else. The answer is cached per connection; a refusal is not, so a market listed later is asked again.
  • The termination builders that name a pair check it with the terminator’s own isPair.
  • A relayer’s verifyRelayed checks the request’s pair the same way before reading anything else from it.

finalizeTermination is the exception: it reads the pair directly and does not ask the factory, so pass it a market from listMarkets or getMarket, not an address from a link.

Resolve a market from a link through getMarket, which only ever returns one of the factory’s own markets:

// A market named in a link (?market=0x...) is untrusted. getMarket resolves it against the factory's
// own pair list and throws InputError for anything else; every builder checks it again (requirePair).
const fromLink = new URLSearchParams(location.search).get("market") ?? "ETH/USD";
let market: markets.MarketInfo;
try {
  market = await bz.markets.getMarket(fromLink);
} catch (e) {
  show(errorMessage(e)); // "There is no market called ..."
  throw e;
}

Market labels (“ETH/USD”) come from the Pyth symbols of the market’s feeds, never from the subgraph’s description, which is unverified and could name another market. A label is not unique either: a market relisted after a termination keeps its label. Use the address as the identity, the label for display.

Connect with chainId set to the chain you expect (Bazaar.connect({ ..., chainId: 42161 })): the connection then fails on any other chain instead of reading another network’s contracts.

Render Strings as Text

Market descriptions, termination reasons and every other string from the chain or the subgraph are user input. Put them into the page as text (textContent, or your framework’s default escaping), never as HTML (innerHTML, dangerouslySetInnerHTML, v-html).

/** On-chain and subgraph strings (descriptions, reasons) go into the page as text, never as HTML. */
function renderReason(el: HTMLElement, reason: string): void {
  el.textContent = reason; // never el.innerHTML = reason
}

The contracts restrict these strings: a listing description is 1 to 200 bytes of letters, digits, space and . , & / -, and a termination reason 1 to 1,000 bytes of letters, digits, space and . , & / - : ? = # % _ ~ + (listings.descriptionProblem, termination.reasonProblem). That keeps them from forging the claim text UMA’s voters read, but it is not HTML escaping, and the subgraph’s copy is not checked against the chain at all. The SDK keeps a subgraph description only when it passes the factory’s own rule, as decoration; treat it as such.

Keep API Keys Off the Browser

Anything in a browser bundle is public. A Pyth Pro API key, a subgraph gateway key or a keyed RPC URL in frontend code is a key you have published.

  • Prices. Give prices short-lived tokens your backend mints from the API key (Connecting has the endpoint and the page’s side), or leave prices out and send orders through a relayer that attaches the update. A token expires within 15 minutes; the key never leaves your server.
  • Whose prices. Every price is read from Pyth’s signed update, and a connection refuses updates not signed by a signer the deployment’s Pyth verifier trusts, so a compromised endpoint or proxy can withhold prices but cannot make the page show, or a call carry, one Pyth did not sign.
  • Subgraph. The same applies to subgraph: { url, apiKey }: in a browser, use a gateway key restricted to your domain, or a proxy.
  • RPC. In a browser, use the user’s wallet as the transport (transport: window.ethereum), or an RPC URL meant to be public.

On a server the keys are fine, and the SDK keeps them out of its errors (see above). The keeper’s config refuses them in its YAML (see Keeper Keys below).

The prints a price endpoint serves are not trusted either: the SDK reads each price from the signed update bytes themselves (what the chain verifies), never from the endpoint’s JSON, and a live view ignores prints dated more than priceFeeds.BROWSER_FUTURE_SKEW (120) seconds ahead of the device’s clock.

The Clock-Skew Check

Gasless requests and USDC permits carry a deadline. The SDK counts every deadline it signs from the lower of two clocks: the latest block’s timestamp as the RPC reports it, and this device’s clock. If the two differ by more than signing.MAX_CLOCK_SKEW (120 seconds), it refuses to sign and throws signing.ClockSkewError. An RPC that reported a block far in the future would otherwise have the user sign a request or a permit that stays valid long after they meant it to, a signature someone could store and use later.

Show the error’s own message, which tells the user to set their clock to update automatically or to use another RPC. errorMessage(e) returns a generic sentence for this error, so check for it first:

async function permitFor(amountUsdc: bigint) {
  try {
    return await bz.permits.signPermit(window.ethereum, { owner: user, spender: market.address, value: amountUsdc });
  } catch (e) {
    // The RPC's chain time and this device's clock disagree by more than signing.MAX_CLOCK_SKEW
    // seconds: nothing was signed. Its message says what to fix; errorMessage() would not.
    if (e instanceof signing.ClockSkewError) show(e.message);
    else show(errorMessage(e));
    return null;
  }
}

Local development chains (chain ids 31337 and 1337, signing.DEV_CHAIN_IDS) are exempt, since tests move their clocks; a signature made for one is void on every public chain, because the chain id is part of every domain the SDK signs under.

Smart-Contract Wallets and Gasless Calls

The pair verifies relayed calls with plain ECDSA: it acts for whatever address the signature recovers to. A smart-contract wallet (a Safe, an ERC-4337 account) that signs through ERC-1271 produces a signature that recovers to some other address, so the SDK refuses it: signing.signTypedDataV4 recovers every signature locally and throws SigningError when it does not recover to the signer. The same check covers USDC permits, which the pair also decodes as (v, r, s).

So a smart-contract wallet:

  • sends direct calls (it pays gas itself), with an approval instead of a permit;
  • can batch several calls into one transaction through wallet_sendCalls (EIP-5792) or Bazaar’s example trading account; see Smart Accounts & Batching;
  • cannot sign gasless requests.

Detect this before you offer a gasless flow, or catch the SigningError and fall back to a direct call.

For Relayers

A relayer receives signed requests from the network, so it treats every field as untrusted. relay.parseRelayed checks the body’s types and shapes and canonicalizes the signature; relay.verifyRelayed checks the pair, the chain id, that the signature recovers to the signer over exactly these fields, the deadline (ahead, and no more than 10 seconds out by chain time), the nonce, the fee cap and the action’s own bounds, and the permit (as a warning: the pair executes a permit best-effort and falls back to the existing allowance). Submit only a request whose check is ok, and submit it at once: the deadline is seconds away. relay.encodeRelayed attaches a fresh price update whose Pyth fee the relayer pays. See Gasless Trading & Relayers.

UMA Oracles

The dispute, settlement and status functions find the oracle an assertion was made on themselves. An oo option, when given, must be Bazaar’s own Optimistic Oracle or the one the assertion was recorded on, or the call throws InputError on oo. uma.previewSettlementOn skips that check: never pass it an oracle address a user typed. See UMA Disputes & Governance.

Keeper Keys

A keeper holds a hot key: it signs and sends transactions unattended. The keeper package is built to keep that key out of files you might share:

  • Secrets live only in .env. The config loader refuses a private key, password, mnemonic phrase, API key, bot token or webhook written into the YAML (the keys in SECRET_KEYS), as the default of a ${NAME:-default}, in a prices.headers value, or inside a URL (Alchemy’s /v2/<key>, a ?key= query). Write ${NAME} and put the value in .env. bazaar keeper init writes .env owner-only.
  • Prefer a keystore. bazaar wallet new writes an encrypted v3 keystore with owner-only permissions where the OS has them, and refuses to overwrite an existing file. The keystore password can be prompted for at start, or stored in .env for an unattended keeper. A remote signer (any service that answers eth_signTransaction, such as Web3Signer or a KMS bridge) keeps the key out of the keeper’s process entirely.
  • Nothing prints a key. Keys, passwords and phrases never appear in an error or a log line; a remote signer’s URL appears only redacted.
  • Run it as its own user. The repository’s Dockerfile runs the keeper as an unprivileged user that owns nothing but the mounted config directory. Outside Docker, give the keeper’s directory to a user that runs nothing else.
  • Fund it for gas only. The keeper wallet needs ETH for gas; its earnings accrue on the pairs and collect can forward them elsewhere (forwardTo). budget.maxSpendUsdPerDay and budget.maxSpendEthPerDay cap what it can spend in a day whatever the price feeds say.

The .gitignore excludes .env, bazaar-keeper.yaml, /keys/ and /state/; keep it that way in your own deployment repository. See Wallets & Secrets and Running in Production.

The local price server (bazaar mock-prices, for Anvil) accepts a price change (POST /mock/price) only as JSON whose Host names this machine and that no web page on another origin sent, so a site open in a browser cannot set its prices; its Pyth Pro routes change nothing. It signs every update with the public test key, which only a local chain’s mock verifier trusts (the deployment script refuses such a verifier anywhere but chain id 31337), so its prices verify nowhere else. It is for local testing only.

Your Users, Your Jurisdiction

The contracts cannot tell who calls them. Which users, regions and markets you serve, and how you enforce that, is your decision and your responsibility. Read Bazaar’s integrator guide before you launch.

What Can Go Wrong

What the user seesWhat it means
InputError on pair: “… is not a market of this Bazaar deployment”The address is not one of the factory’s pairs. Check the link; nothing was built or signed.
ConnectError: “connected to chain …, expected …”The wallet or RPC is on another chain than chainId. Ask the user to switch networks.
signing.ClockSkewErrorThe RPC’s time and the device’s clock differ by more than 120 seconds. Nothing was signed.
signing.SigningError: “this wallet does not sign as a plain account”A smart-contract wallet tried a gasless request or a permit. Use a direct call and an approval.
A costed call’s notes warn of a larger allowanceThe wallet already allows the spender more than the quote. Approve the exact amount first to cap it.
uma.BondQuoteErrorA bond read from the chain is below the contract’s own floor (an RPC answering 0). Nothing was built; try another RPC.
ConfigError naming a key in the YAMLThe keeper refused a secret written into its config file. Move it to .env.

Development

How to work on the SDK and the keeper themselves: the workspace, building, the tests (unit and end to end on Anvil), the generator scripts, these docs, and releasing. To use the packages in your own project, see Installation instead.

The Workspace

The repository is one npm workspace with two packages:

PathPackageWhat it is
packages/sdk@liberta-labs/bazaar-sdkThe SDK. Runs in browsers and Node; its only dependency is viem.
packages/keeper@liberta-labs/bazaar-keeperThe keeper and the bazaar CLI. Node 20 or later; depends on the SDK.

Beside them:

PathWhat it holds
contracts/KeeperProbe.solThe simulation probe, compiled into the SDK by build-probe (never deployed).
scripts/sync-abis.mjs, build-probe.mjs, gen-bindings.mjs.
docs/This book (mdBook) and its code samples.
tsconfig.base.jsonThe compiler options both packages share (ES2022, NodeNext modules, strict).
tsconfig.check.json, tsconfig.check-sdk.jsonTypecheck-only configs (below).
vitest.config.ts, vitest.e2e.config.tsThe unit and end-to-end test configs.
DockerfileThe keeper as a container, run as an unprivileged user.
DESIGN.mdHow the keeper works and why it is built this way.

Set up a checkout with Node 20 or later:

git clone https://github.com/liberta-labs/bazaar-sdk
cd bazaar-sdk
npm install
npm run build

Building

npm run build     # tsc -b packages/sdk packages/keeper
npm run clean     # tsc -b --clean packages/sdk packages/keeper

npm run build is a TypeScript project build: each package compiles src/ into dist/ (JavaScript, declarations, declaration maps and source maps), and the keeper’s tsconfig.json references the SDK, so the SDK is built first. npm run typecheck runs the same build.

Two typecheck-only configs emit nothing, so they are safe to run while a build or a watcher is going:

CommandWhat it checks
npx tsc -p tsconfig.check.jsonBoth packages’ sources and tests, and the vitest configs, with Node types. The SDK resolves from its sources, so no build is needed first.
npx tsc -p tsconfig.check-sdk.jsonThe SDK’s sources with DOM types and no Node types: a stray node: import or a Buffer fails here, which keeps the SDK browser-safe.

npm run typecheck:check runs both.

Unit Tests

npm test                                          # vitest run: every packages/*/test/**/*.test.ts
npm run test:watch                                # vitest in watch mode
npx vitest run packages/sdk/test/codec.test.ts    # one file

The tests import the SDK from its sources (vitest.config.ts aliases @liberta-labs/bazaar-sdk to packages/sdk/src/index.ts), so they need no build. They need no chain and no network either: the SDK’s tests run against fake EIP-1193 transports that answer eth_call by contract and selector, decoding the calldata with the SDK’s own ABIs (client-reads-fakes.ts, client-writes-fake.ts, termination-fakes.ts), and the keeper’s job and engine tests run a Keeper over hand-built state (keeper-harness.ts). Each test has 20 seconds.

One unit test also checks that the facade’s generated bindings are current (it runs scripts/gen-bindings.mjs --check), so adding a client function without regenerating them fails npm test.

End-to-End Tests on Anvil

The end-to-end suite runs the real keeper and the frontend SDK against a real chain: a fresh Anvil with a Bazaar deployment replayed onto it.

Start Anvil with one-second blocks, a low base fee and Bazaar’s contract sizes, then run the suite:

anvil --port 8546 --block-time 1 --base-fee 10000000 --code-size-limit 100000
npm run test:e2e                                            # every packages/*/test/e2e/**/*.e2e.test.ts
BAZAAR_E2E_RPC=http://127.0.0.1:8546 npm run test:e2e       # another Anvil URL (this is the default)
npx vitest run -c vitest.e2e.config.ts packages/keeper/test/e2e/keeper.e2e.test.ts   # one file

With no Anvil answering at BAZAAR_E2E_RPC, the suite is skipped with a warning rather than failing. The files share one chain and one deployer, so vitest.e2e.config.ts runs them one at a time, in order, with ten minutes per test.

FileWhat it does
deploy.e2e.test.tsReplays the deployment, builds the ETH/USD scenario, and checks through the SDK’s reads that the chain holds what the scenario meant to build.
keeper.e2e.test.tsRuns the keeper in-process with every automatic job, prices streamed from the mock Pyth Pro server (its updates signed with the test key the local verifier trusts) and no subgraph. Each step moves the price or the clock to give one job its work and checks the result on chain; it also measures how fast the keeper reacts to a price move.
frontend.e2e.test.tsConnects the SDK over JsonRpc, takes prices from the mock Pyth Pro server (proPriceSource), and signs and sends through small test wallets: every write is simulated first, then sent and checked on chain, through gasless calls, insurance exits, a listing and a termination settled under the chain rule from the mock’s archived one-second updates.
chain.tsThe helpers: the replay, the scenario, Anvil’s dev accounts.

Each file snapshots the chain before the replay and reverts to it afterwards, so the next file can replay on the same Anvil. Every account is one of Anvil’s ten unlocked dev accounts (the public test mnemonic).

The Fixtures

The deployment is replayed, not compiled: packages/keeper/test/e2e/fixtures/ holds what the replay reads, so the suite needs no Bazaar checkout and no Solidity compiler.

FileWhat it is
deployment.jsonThe transactions Bazaar’s Anvil deployment (script/DeployBazaar.s.sol with HelperConfig’s Anvil mocks) sends from Anvil’s first account, each with its contract name, address, and from, to, gas, input and nonce.
MockArbSys.jsonThe MockArbSys runtime bytecode, which the replay places at the ArbSys precompile (0x…64), where the pair reads its block number.
build.mjsProduces the two files above from a built Bazaar checkout’s out/ artifacts.
README.mdWhich Bazaar commit the artifacts came from.

build.mjs computes the deployment without a chain: it links the twelve external libraries in dependency order and places each at its CREATE2 address through the deterministic deployer Anvil predeploys, then deploys MockPythLazer (no fee) and has it trust the public test signer, then MockUSDC, MockOptimisticOracleV3, BazaarOracle, the BazaarPair implementation, and the lens and the terminator against the factory’s predicted address, then BazaarFactory, with the constructor arguments the deployment script passes on Anvil. Replaying the same transactions from the same account on a fresh Anvil reproduces the same addresses.

To regenerate the fixtures after a contract change, build the contracts in a Bazaar checkout (forge build), then, from this repository:

node packages/keeper/test/e2e/fixtures/build.mjs <path to that Bazaar checkout>

record the Bazaar commit in fixtures/README.md, and run the suite against a fresh Anvil. Regenerate the fixtures together with the SDK’s ABIs (sync-abis, below), so the two come from the same contracts.

To run the keeper by hand against a local deployment, see Testing Locally with Anvil.

Scripts

npm run sync-abis -- <bazaar checkout>

Copies the five contract ABIs (BazaarFactory, BazaarOracle, BazaarPair, BazaarPairLens, BazaarPairTerminator) from a Bazaar checkout’s abi/ directory (the MIT-licensed files its make abis writes) into packages/sdk/src/abi/<Name>.ts as as const arrays, so viem infers every function’s argument and return types. It prints every function, event and error signature added or removed, and records the Bazaar commit the ABIs came from (and whether that checkout’s abi/ had uncommitted changes) in packages/sdk/src/abi/source.ts, which the SDK exports as ABI_SOURCE.

npm run sync-abis -- ../bazaar
npm run build && npm test

The script runs git in the checkout with its fsmonitor and hooks disabled, so an untrusted checkout cannot run code through it.

npm run build-probe [-- <path to solc>]

Compiles contracts/KeeperProbe.sol and writes packages/sdk/src/probe/artifact.ts (the probe’s ABI and runtime bytecode). The probe is never deployed: the SDK places its code at PROBE_ADDRESS through an eth_call state override to measure exactly what a call would earn and to batch reads (see Low-Level Building Blocks). The artifact is committed, so users of the package never compile it.

It needs solc 0.8.34 (the pragma is exact) and compiles with --optimize --evm-version paris, an EVM version every Arbitrum chain and Anvil accept. Without an argument it uses solc from PATH; failing that, Foundry’s copy at ~/.svm/0.8.34/solc-0.8.34, which on Windows it runs through WSL.

npm run gen-bindings

Writes packages/sdk/src/client/bindings.generated.ts: for each client module, every exported function whose first parameter is ctx: BazaarContext (except those tagged @internal). The Bazaar object binds exactly these, so bz.trade.createOrder(...) is trade.createOrder(ctx, ...). Run it after adding, renaming or removing a context-first function; node scripts/gen-bindings.mjs --check exits 1 when the file is out of date, and a unit test runs that check.

The Docs

This book is an mdBook in docs/:

PathWhat it is
docs/book.tomlThe book’s settings. create-missing = false: a link in SUMMARY.md to a page that does not exist fails the build.
docs/src/SUMMARY.mdThe table of contents: every page, in order.
docs/src/The pages, one Markdown file each.
docs/examples/Every TypeScript sample the pages show, as complete modules.
docs/theme/, docs/*.css, docs/*.jsThe Bazaar theme and the page actions.
npm run docs:check   # typecheck every sample against the packages' sources
npm run docs:build   # docs:check, then mdbook build docs (into docs/book/)
npm run docs:serve   # mdbook serve docs --open: rebuilds and reloads as you edit

docs:build and docs:serve need mdbook on your PATH; CI uses mdBook 0.5.4, so use the same version locally. docs/book/ is ignored by git.

How Samples Are Included

A page never holds TypeScript inline. Each sample is a file under docs/examples/<area>/<topic>.ts: a complete module with its imports at the top and its setup (constants, the bz connection) in the file, importing from the package names exactly as a user would. The part a page shows sits between anchor comments:

// ANCHOR: options
const bz = await Bazaar.connect({ transport: window.ethereum, factory: FACTORY, chainId: 42161 });
// ANCHOR_END: options

and the page includes it by its path relative to the page (a page in docs/src/sdk/ starts with ../../examples/) and the anchor name:

```ts
{{#include ../../examples/reads/connecting.ts:options}}
```

Leave the anchor name out to include the whole file. A file may hold several anchors.

docs/examples/tsconfig.json maps @liberta-labs/bazaar-sdk and @liberta-labs/bazaar-keeper to the packages’ sources, and docs/examples/env.d.ts declares window.ethereum for browser samples. So npm run docs:check typechecks every sample against the code as it is: a sample that no longer matches the SDK fails the check instead of misleading a reader. YAML, shell and JSON samples stay inline in the pages.

Publishing

.github/workflows/docs.yml builds and publishes the book to GitHub Pages (sdk-docs.bazaar.finance) on every push to main that touches docs/, a package’s src/, or the workflow itself. It typechecks the samples first (npm run docs:check), so a broken sample stops the deploy. It also publishes each page’s Markdown source beside its HTML, which the page actions (copy the page, open it in an assistant) use.

Releasing

The packages are not yet on npm. Until they are, users install from a checkout (npm install && npm run build); once published, npm install @liberta-labs/bazaar-sdk and npm install @liberta-labs/bazaar-keeper.

Each package’s prepack script runs tsc -b, so npm pack and npm publish build dist/ first. A package ships only dist/, its README.md and its LICENSE (the files field), as an ES module with its types (exports points both at dist/index.*). The keeper also installs the bazaar command (bin: dist/cli/main.js).

To release:

  1. Bump version in packages/sdk/package.json and packages/keeper/package.json, the keeper’s dependency on @liberta-labs/bazaar-sdk (pinned to an exact version), and VERSION in packages/keeper/src/index.ts, which is kept by hand.
  2. Run the checks: npm run build, npm test, npm run typecheck:check, the end-to-end suite on a fresh Anvil, and npm run docs:check.
  3. Publish the SDK first, then the keeper, which depends on that exact SDK version, for example:
npm publish -w packages/sdk --access public
npm publish -w packages/keeper --access public

The SDK’s package.json marks dist/errors/messages.js as its one module with side effects: loading it registers the user-facing error messages (installUserMessages), so a bundler must not drop it when tree-shaking.

License

The Code

Both packages, @liberta-labs/bazaar-sdk and @liberta-labs/bazaar-keeper, are licensed under the MIT License, as is everything else in the repository outside these docs’ text: the simulation probe contract (contracts/KeeperProbe.sol), the scripts, the tests and the Dockerfile. The full text is in LICENSE at the repository’s root, and each package’s package.json declares "license": "MIT".

You may use, copy, modify and distribute the code, in open or closed products, for any purpose, including commercial ones. The license’s one condition is that its copyright and permission notice stays with copies of the code.

Using the SDK to talk to Bazaar’s deployed contracts puts no license conditions on your product either. The protocol’s own contracts are licensed separately (AGPL-3.0-only, with MIT-licensed interfaces and ABIs); see Bazaar’s License page.

This Documentation

The text of these pages (docs/src in the repository) is licensed under the Creative Commons Attribution 4.0 International License (CC BY 4.0): you may share and adapt it, for any purpose, with attribution.

The code samples in the text, and the example programs in docs/examples, are licensed under the MIT License, like the packages: copy them into your own code on the same terms. Both license grants are stated in docs/LICENSE.