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:
| Package | What it is | Who it is for | Runs in |
|---|---|---|---|
@liberta-labs/bazaar-sdk | Everything 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 governance | Frontend developers, people writing trading bots and services, relayers | Browsers and Node 20+ |
@liberta-labs/bazaar-keeper | The 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 tool | Keeper operators; services that want the keeper’s engine as a library | Node 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
PreparedCallthat you simulate and hand to the user’s wallet (or, in a bot, to a viemWalletClientholding the bot’s key). Signatures for gasless calls and USDC permits are requested from an EIP-1193 wallet witheth_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
- Installation: requirements, installing, TypeScript settings.
- Quick Start: a Frontend: a plain web page from connect to a placed order.
- Quick Start: a Bot: a Node script with its own key.
- Core Concepts: the connection, prepared calls, units, prices, errors, what is cached. Read this before going further.
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
| Where | What it needs |
|---|---|
| Node | 20 or later (both packages declare "engines": { "node": ">=20" }). |
| Browsers | Any current browser. The package is compiled to ES2022 and uses BigInt, fetch with streamed response bodies, AbortSignal.timeout and TextDecoder. |
| Bundlers | Anything 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. |
| Dependencies | viem (^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 withbz.send(wallet, call). - viem. A viem
PublicClientorWalletClientis itself an EIP-1193 transport. Use one for reads and aWalletClientto send, turning eachPreparedCallinto a transaction request withtoTransactionRequest:
// 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
useConnectorClientorgetConnectorClient) as the transport, or keepwindow.ethereumfor reads, and send with wagmi’ssendTransactionandtoTransactionRequest(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, notrequest). Pass the underlying provider (window.ethereum) or an RPC URL to the SDK, and send a call’sto,dataandvaluewith 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:
| Setting | Value | Why |
|---|---|---|
module / moduleResolution | NodeNext for Node, Bundler (or NodeNext) for a bundled app | The package is ESM with an exports map. |
target | ES2020 or later | Amounts are bigint (10n ** 18n needs ES2020). |
lib / types | DOM in a browser project; @types/node (20+) in a Node one | The 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 or Quick Start: a Bot.
- Core Concepts, before building anything larger.
- Connecting, for every connection option.
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 sees | Why |
|---|---|
| “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 connect | A 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 list | The 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 chain | The 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 thewspackage’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 witheth_calland, when it passes, estimates its gas. A revert comes back assim.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’sto,dataandvalue(the ETH a call with a price update must send for Pyth’s fee), andaccount, set to the call’sfromaddress. Overrideaccountwith the local account object. viem treats anaccountgiven as a bare address as a JSON-RPC account and asks the node to sign witheth_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 aCallErrorif 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
| Message | Why |
|---|---|
Error: set RPC_URL in the environment | A variable is missing. |
ConnectError: 0x... does not answer as a Bazaar factory on chain 421614 | BAZAAR_FACTORY is wrong for this RPC’s chain. |
HttpError: HTTP 401 from https://.../v2/***: the provider refused the request; check the key in the URL | The 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 channel | The 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... reverted | It 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.
- Building a Bot: live prices and events, nonce handling, quoting many orders.
- Live Updates, to react to fills instead of polling.
- Errors & Messages.
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.
| Field | What it is |
|---|---|
chain | The SDK’s Chain over your transport: reads, batched reads, simulations, logs, receipts. |
chainId | The chain the transport is on, read at connect. |
factory | The deployment’s BazaarFactory, as you gave it. |
lens, terminator, oracle, usdc | The deployment’s other contracts, read from the factory at connect. |
prices | Where signed Pyth updates come from, or null for a read-only connection. Every call that carries a price needs it. |
subgraph | Bazaar’s subgraph, or null. Only history needs it. |
integrator | The 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);
| Area | On bz | What it covers |
|---|---|---|
markets | bz.markets | The market list, snapshots, the book |
account | bz.account | Account snapshots and portfolios |
live | bz.live | Watching a market or an account |
trade | bz.trade | Collateral, orders, fees, trigger execution |
orders | bz.orders | Order specs and their checks (mostly pure helpers) |
allowance, permits | bz.allowance, bz.permits | USDC balance, approvals, permits |
signing, relay | bz.signing, bz.relay | Gasless requests: signing, verifying, relaying |
batching | bz.batching | Smart accounts and EIP-5792 batches |
previews | bz.previews | What an order or a withdrawal would do |
history | bz.history | The subgraph’s records |
insurance | bz.insurance | The insurance fund |
termination, listings, governance, uma | same | Winding markets down, listing them, UMA |
priceFeeds | (bz.livePrices()) | Pyth Pro sources and live prices |
format | none | Formatting 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
| Field | Meaning |
|---|---|
action | The contract function, such as "createOrder". |
to, data | The contract and the calldata. |
value | ETH to send, in wei: the Pyth update fee when the call carries a price, else 0. |
from | The account the call acts for (the sender of a direct call), or null when unknown. |
summary | One line for a confirmation screen, with every amount printed exactly. |
priceTime | Present when the call carries a Pyth update: the update’s publish time, in Unix seconds. |
pullsUsdc | Present when known: the USDC the call takes from the wallet, in 18 decimals. |
The flow is the same for every write:
- 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
InputErrornaming 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. - Simulate, as the user:
bz.simulate(call)runs it witheth_calland estimates its gas. A revert comes back decoded, with a sentence for the user, before the wallet is asked anything. - Show the
summary, and what the call costs:value,pullsUsdc. - Send with the user’s wallet:
bz.send(wallet, call)over any EIP-1193 provider, ortoTransactionRequest(call)for a viem or wagmi client, or a bot’s own key. - Wait:
bz.waitForReceipt(hash)throws aCallErrorif 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”):
1e18is $1, or one unit of the traded asset.parseUnits18("12.5")andformatUnits18(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 withtoUsdcUnits,toUsdcUnitsCeil(for an allowance that must cover a pull) andfromUsdcUnits. 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’sexpirationBlock, abigintbecause a take-profit or stop-loss carries 2^64 − 1 (“never”), which anumbercannot 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 aConnectError. call.valueis the fee Pyth charges to verify the update; the pair refunds what it does not spend.call.priceTimeis 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:
| Error | When | What it carries |
|---|---|---|
InputError | A builder refused an input before building anything. | field, and a message prefixed with it (“size: Enter a size greater than zero.”). |
DecodedError | simulate() 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. |
CallError | A send was rejected or the transaction reverted. | message, and decoded when the revert could be decoded. |
| wallet errors | The 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. |
ConnectError | A connection problem: the wrong chain, not a factory, a missing price source or subgraph. | A message for the developer. |
ProError, SubgraphError, HttpError, RpcError | An 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
pricesshort-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:
| What | Kept | Refreshed |
|---|---|---|
| The market list and each market’s static facts (pair id, feeds, calendar, label) | Per connection, after the first listMarkets | listMarkets({ refresh: true }); getMarket refreshes once by itself for a name it does not know. Static facts never change. |
| A pair’s Pyth feeds | Per connection | Never (they cannot change); a failed read is not kept. |
| “This address is one of the factory’s pairs” | Per connection | A 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 process | A failed fetch is not kept. |
| Whether the node supports state overrides, and whether Multicall3 exists | Per Chain | Re-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
- Connecting: every connection option.
- Markets and Accounts & Portfolios: what reads return.
- Sending Transactions: the write path in depth.
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.
| Job | What it does | Who pays you, and how much |
|---|---|---|
liquidation | seizes positions below their maintenance margin | the 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 |
triggers | fires StopLimit, StopLoss and TakeProfit orders whose trigger the price reached | the 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 |
unload | sells the vault’s liquidated inventory into the book | the 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 |
adl | runs ADL batches while an ADL window is open | the 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 |
refresh | stores a fresh price on quiet markets | the insurance fund: $0.10 per refresh, when the refresh bounty’s gates hold (The Jobs) |
terminalSettlement | settles 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 |
collect | claims what your wallets have earned, and can forward the USDC elsewhere | nothing: it moves your earnings to your wallet |
sweepExpired | unlinks expired orders and returns stranded bounties to their owners | nobody: 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:
- 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. - 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.
- Simulate through the probe. The call is run with
eth_callfrom 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. - Price the gas. The gas comes from
eth_estimateGasof the real transaction from the real wallet, priced at the current base fee and the live ETH/USD print. - Apply the spend policy. A paid call goes out when reward − gas ≥
minProfitUsdand 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. - 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 earningsreads.
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
| Requirement | Why | Notes |
|---|---|---|
| Node 20 or later | the keeper is a Node program | or Docker (Running in Production) |
An Arbitrum RPC with eth_call state overrides | the probe is placed by a state override; reads are batched through it | Without 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 endpoint | every price-touching call carries a signed Pyth Pro update, which Pyth’s verifier checks on chain | pyth 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 Arbitrum | gas, and Pyth’s update fee | no keeper call needs USDC or an approval |
| A wallet | to sign | an 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 address | everything else is read from it | Bazaar’s network presets carry no official factory address yet, so the config names one |
| A subgraph (optional) | a fast first start | without 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.
Related Pages
- Setting Up a Keeper: install, configure, check, fund, start.
- The Jobs: each job in depth.
- Budgets & Losing Calls: the spend policy.
- Configuration Reference: every option.
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_callstate 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
| Answer | Writes |
|---|---|
| Arbitrum Sepolia (testnet) | network: arbitrum-sepolia |
| Arbitrum One | network: arbitrum-one |
| Local Anvil | network: anvil |
| Custom factory address | network: 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
| Answer | Writes |
|---|---|
| Bazaar’s subgraph for this network (offered only when the network preset has one; none does yet) | subgraph.url: default |
| None: index from chain logs | subgraph.url: none |
| Custom URL | subgraph.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
| Answer | Asks | Writes |
|---|---|---|
| Pyth Pro, with my own access token | the 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 token | prices.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
| Answer | What happens |
|---|---|
| Create a new wallet | generates 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 key | asks for the key (hidden, checked), then a password twice, and writes it the same way |
| Use an existing keystore file | asks for its path, which must exist; nothing is written |
| No wallet: watch-only | no 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:
| Answer | Writes |
|---|---|
| All markets, including future listings | markets: all |
| Choose markets… | the chosen markets’ addresses, each with its label as a YAML comment (choosing none means all) |
| Same as the previous job | the 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
| Line | What it checks | When it fails |
|---|---|---|
| Config | the keeper can be built: the network, rpc.send, the price endpoint, the wallets (keystores decrypted) | ✘ Config with the reason, and nothing else is checked |
| RPC | eth_chainId and its latency; whether eth_call state overrides work | unreachable, or on another chain: stops here. No overrides is a warning: paid jobs will skip |
| Factory | the deployment can be read from the factory, and how many markets it lists; whether it is an official deployment | the factory cannot be read (wrong address, no code): stops here |
| Wiring | the lens and the terminator report this factory | either belongs to a different factory or reports none (start refuses to run) |
| Subgraph | how far the subgraph is synced and how far behind the head; indexing errors | unreachable: the keeper falls back to chain logs |
| Prices | a 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 s | the 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 |
| Wallet | each 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 |
| Jobs | the enabled jobs; each job’s markets resolve; warnings for jobs that take losses and for sweepExpired | a 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
- Configuration Reference for everything the wizard did not ask.
- Running in Production to run it around the clock.
- Budgets & Losing Calls before raising any cap.
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}
type | Needs | Optional | What it is |
|---|---|---|---|
keystore | path | password | an encrypted JSON keystore (version 3). Recommended: the key is encrypted at rest, and only the password needs protecting |
privateKey | key | a raw 32-byte private key, hex with or without 0x, from the environment | |
mnemonic | phrase | index (default 0) | a BIP39 phrase; the key is derived at m/44'/60'/0'/0/{index} |
remote | url, address | any 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 lives | Starts unattended | Trade-off |
|---|---|---|
.env beside the config (password: ${KEEPER_KEYSTORE_PASSWORD}) | yes | anyone 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) | yes | the same, with the file kept elsewhere |
| left out, typed at each start | no | nothing 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):
| Key | Where |
|---|---|
key | wallets.<name>.key |
password | wallets.<name>.password |
phrase | wallets.<name>.phrase |
token | prices.token |
apiKey | subgraph.apiKey |
botToken | alerts.telegram.botToken |
webhook | alerts.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):
| Form | When NAME is set | When it is not |
|---|---|---|
${NAME} | its value | an error: rpc.http: environment variable RPC_HTTP_URL is not set (add it to .env or export it) |
${NAME:-default} | its value | default |
${NAME:-} | its value | the 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.
collectclaims every wallet’s balances, each from the wallet that earned it, andforwardToforwards from each wallet. - Manual operations send from
default, or from the first wallet configured when there is nodefault.
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.
Related Pages
- Configuration Reference for every wallet key.
- Running in Production for backing up keystores.
- Troubleshooting for password prompts in containers.
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
- The
.envfile in the config’s directory is loaded into the environment. Variables already set in the environment win. - The YAML is parsed. It must be a mapping.
- 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, andprices.headersvalues must be references. - Every
${NAME}and${NAME:-default}in a string value is filled from the environment. - 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. Writefactory: "0x3Aa5ebB10DC797CAC828524e59A333d0A371443c". - An empty job turns it off.
liquidation:with nothing under it is null, and a null job is disabled. Writeliquidation: {}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
| Key | Type | Default | Meaning |
|---|---|---|---|
network | string | required | arbitrum-one, arbitrum-sepolia, anvil, or custom |
chainId | integer | the preset’s | required for custom; on a preset it replaces the preset’s chain id |
factory | address | the preset’s | the deployment’s factory. Required for custom, and for any preset without an official factory (at present all of them) |
startBlock | integer ≥ 0 | see below | where a first start without a subgraph begins reading logs: the factory’s deployment block |
| Preset | Chain id | Sequencer endpoint (rpc.send: sequencer) | Official factory |
|---|---|---|---|
arbitrum-one | 42161 | https://arb1-sequencer.arbitrum.io/rpc | not yet |
arbitrum-sepolia | 421614 | none | not yet |
anvil | 31337 | none | never (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
| Key | Type | Default | Meaning |
|---|---|---|---|
http | string | required | the JSON-RPC URL every read goes to. Usually holds a key: write ${RPC_HTTP_URL} |
send | string | http | where transactions are submitted: sequencer for the network’s sequencer endpoint (Arbitrum One only; on another network it is an error), or any URL |
maxRequestsPerSecond | number > 0 | 25 | the client’s rate limit on http |
logChunk | integer > 0 | 2000 | blocks 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
| Key | Type | Default | Meaning |
|---|---|---|---|
url | string | default | default (the network preset’s subgraph; no preset has one yet, so this means none), none, or any deployment of Bazaar’s subgraph |
apiKey | string | none | sent 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.
| Key | Type | Default | Meaning |
|---|---|---|---|
endpoint | string | bazaar | pyth (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 |
token | string | none | the Pyth Pro access token, sent as Authorization: Bearer <token>. A ${NAME} reference |
channel | real_time | fixed_rate@50ms | fixed_rate@200ms | fixed_rate@1000ms | fixed_rate@200ms | the channel the keeper streams and polls |
streamUrls | list of strings, at least one | the endpoint’s | the WebSocket stream endpoints, in place of the endpoint’s own; the keeper connects to every one |
restUrl | string | the endpoint’s | the request API’s base URL (/v1/latest_price, /v1/price), in place of the endpoint’s own |
symbolsUrl | string | the endpoint’s | the symbols registry’s base URL (/v1/symbols), in place of the endpoint’s own |
headers | map 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}) |
checkSigners | boolean | true | use only updates signed by a signer the deployment’s Pyth verifier trusts |
allowInsecure | boolean | false | send 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 |
stream | boolean | true | follow the WebSocket price stream; when off, poll the request API for the latest price |
pollInterval | number > 0 | 1.0 | seconds between polls when not streaming |
streamIdleTimeoutSeconds | number ≥ 0 | 10 | a stream that brings no new update for this long is dropped and reconnected; 0 waits forever |
sendMarginSeconds | 0 ≤ number < 3 | 1.0 | a 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.
| Key | Type | Default | Meaning |
|---|---|---|---|
type | keystore | privateKey | mnemonic | remote | required | the kind of wallet |
path | string | none | keystore: the file (~/ is your home directory). Required for keystore |
password | string | none | keystore: its password, a ${NAME} reference; left out, asked for at start in a terminal |
key | string | none | privateKey: the key, a ${NAME} reference. Required for privateKey |
phrase | string | none | mnemonic: the phrase, a ${NAME} reference. Required for mnemonic |
index | integer ≥ 0 | 0 | mnemonic: the account, m/44'/60'/0'/0/{index} |
url | string | none | remote: the signer’s JSON-RPC URL. Required for remote |
address | address | none | remote: 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
| Key | Type | Default | Meaning |
|---|---|---|---|
enabled | boolean | true | false keeps the job’s settings but turns it off |
markets | all | list of strings | all | the markets it runs on. all includes future listings. A list names markets by address, pair id, or label or description (case-insensitive) |
exclude | list of strings | [] | markets it never runs on, named the same ways |
wallet | string | default | the wallet it sends from; must be under wallets (when there are any) |
dryRun | boolean | false | plan and simulate, never send (as start --dry-run does for every job) |
minProfitUsd | number | profit.minProfitUsd | this job’s profit rule |
gasSafetyMultiplier | number | profit.gasSafetyMultiplier | this job’s profit rule |
allowLoss | boolean | false (true for sweepExpired) | also send calls that cost more than they earn, within the caps below |
maxLossUsdPerCall | number ≥ 0 | 0.05 | the most one losing call may lose |
lossBudgetUsdPerDay | number ≥ 0 | 1.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
| Key | Type | Default | Meaning |
|---|---|---|---|
watchBufferPct | number ≥ 0 | 3.0 | positions 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 |
maxAccountsPerCall | integer > 0 | 40 | accounts per liquidate call; more are split into several calls |
triggers
| Key | Type | Default | Meaning |
|---|---|---|---|
minBountyUsd | number ≥ 0 | 0.01 | orders with a smaller bounty are not fired |
includeUnbountied | boolean | false | also fire orders with no bounty at all (they pay nothing, so they send only with allowLoss) |
unload
| Key | Type | Default | Meaning |
|---|---|---|---|
maxSteps | integer, 1 to 32 | 32 | the book walk’s step budget per unloadVault call |
recheckSeconds | number > 0 | 15 | how often a market holding inventory is simulated again when nothing has changed |
adl
| Key | Type | Default | Meaning |
|---|---|---|---|
maxWinnersPerSide | integer, 1 to 25 | 25 | candidates per list in an executeAdl batch |
refresh
| Key | Type | Default | Meaning |
|---|---|---|---|
healthPokes | boolean | false | also 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 |
healthPokeIntervalSeconds | number > 0 | 60 | how often each market holding inventory is checked for a health poke |
healthPokeMaxCostUsd | number ≥ 0 | 0.1 | the most one health poke may cost |
terminalSettlement
| Key | Type | Default | Meaning |
|---|---|---|---|
finalize | boolean | true | finalize the termination once every position is settled or the 14-day escape opens. finalizeTermination pays nothing: true is consent to pay for it |
finalizeMaxCostUsd | number ≥ 0 | 0.5 | the most one finalize may cost |
maxAccountsPerCall | integer > 0 | 100 | positions settled per call; the next tick takes the rest |
collect
| Key | Type | Default | Meaning |
|---|---|---|---|
thresholdUsd | number ≥ 0 | 5 | claim a market once a wallet’s balance there reaches this, and covers the claim’s gas × gasSafetyMultiplier |
maxAgeDays | number > 0 | 7 | a balance first seen longer ago than this is claimed whenever it covers its gas, whatever its size |
forwardTo | address | none | move each wallet’s USDC here once it holds forwardMinUsd |
forwardMinUsd | number ≥ 0 | 50 | the USDC balance at which a wallet forwards |
onShutdown | boolean | true | on a clean stop, claim every balance that covers its gas |
intervalSeconds | number > 0 | 600 | how often balances are read (sooner, after 5 s, when the logs show a wallet’s balance changed) |
sweepExpired
| Key | Type | Default | Meaning |
|---|---|---|---|
allowLoss | boolean | true | sweepExpired pays nothing, so every call is a loss: the job only runs with this on |
lossBudgetUsdPerDay | number ≥ 0 | 0.25 | the job’s daily budget |
minBatch | integer > 0 | 10 | expired orders to wait for before sweeping (finished orders holding a bounty are swept at once) |
maxOrdersPerCall | integer > 0 | 100 | orders per call |
includeBountyRefunds | boolean | true | also sweep finished orders still holding a bounty, which returns it to the owner |
profit
| Key | Type | Default | Meaning |
|---|---|---|---|
minProfitUsd | number | 0.001 | a paid call is sent when reward − gas ≥ this… |
gasSafetyMultiplier | number ≥ 1 | 1.25 | …and reward ≥ gas × this |
Each job can override both. Budgets & Losing Calls.
gas
| Key | Type | Default | Meaning |
|---|---|---|---|
maxFeeGwei | number | none | a hard ceiling on the fee per gas: nothing is sent while the base fee is above it, and maxFeePerGas is capped at it |
backgroundMaxFeeGwei | number or null | 0.1 | the ceiling for background work (collect, forwarding, sweeps, finalize); null removes it |
feeMultiplier | number ≥ 1 | 2.0 | maxFeePerGas = base fee × this (priority fee is always 0) |
gasLimitMultiplier | number ≥ 1 | 1.25 | gas limit = estimate × this + 10,000 |
ethUsdFallback | number > 0 | 3000 | prices gas when the endpoint serves no fresh, plausible ETH/USD print |
ethUsdFloor | number > 0 | 1000 | gas is never priced below this, so a bad feed cannot make calls look free |
ethUsdMaxAgeSeconds | number > 0 | 60 | an older ETH/USD print is not used; ethUsdFallback is |
budget
| Key | Type | Default | Meaning |
|---|---|---|---|
maxSpendUsdPerDay | number ≥ 0 | 10 | all gas, every job, in the last 24 hours, sends in flight included |
maxSpendEthPerDay | number ≥ 0 | maxSpendUsdPerDay / 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 |
maxLossUsdPerDay | number ≥ 0 | 2 | every loss-taking and unpaid call together |
minEthBalance | number ≥ 0 | 0.002 | ETH; while any wallet holds less, all sending stops |
Budgets & Losing Calls explains how they combine.
safety
| Key | Type | Default | Meaning |
|---|---|---|---|
pauseAfterReverts | integer > 0 | 3 | this many on-chain reverts in a row pause a job on a market… |
pauseMinutes | number > 0 | 15 | …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
| Key | Type | Default | Meaning |
|---|---|---|---|
telegram | { botToken, chatId } | none | botToken a ${NAME} reference; chatId a string |
discord | { webhook } | none | the webhook URL, a ${NAME} reference |
webhook | { url } | none | any URL; receives POST {"kind", "message", "ts"} as JSON |
notify | list | [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
| Key | Type | Default | Meaning |
|---|---|---|---|
dir | string | ~/.bazaar | where 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
| Key | Type | Default | Meaning |
|---|---|---|---|
level | debug | info | warning | error | info | the lowest level shown |
format | pretty | json | pretty | pretty: 12:00:01 INFO message; json: one object per line, {"ts", "level", "logger", "msg", "exc"?} |
engine
| Key | Type | Default | Meaning |
|---|---|---|---|
tickSeconds | number > 0 | 0.5 | how often the keeper reads the head and new logs and lets every job plan (liquidation and triggers also plan on each new print) |
simCooldownSeconds | number ≥ 0 | 2.0 | how long a candidate the chain refused waits before it is simulated again |
idleRefreshSeconds | number > 0 | 5.0 | how often a market’s state is re-read when no event marked it changed |
snapshotSeconds | number > 0 | 60 | how 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
Related Pages
- Wallets & Secrets: wallet types and the secrets rules.
- The Jobs: what each job’s keys change.
- Budgets & Losing Calls:
profit,gasandbudgettogether. - Troubleshooting: every config error.
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.simCooldownSecondsfor most jobs, longer for some (each job says below). Nothing brings it back sooner. - Pauses.
safety.pauseAfterRevertson-chain reverts in a row pause a job on a market forsafety.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:
| State | Meaning | Jobs that plan |
|---|---|---|
| live | trading normally | liquidation, triggers, unload, refresh, sweepExpired, collect |
| adl | an ADL window is open: matching halts | liquidation, adl, refresh, collect |
| limbo | from 6 s before a scheduled cutoff until the settlement price is fixed | collect |
| settling | the settlement price is fixed, not finalized | terminalSettlement, collect |
| terminated | finalized | terminalSettlement, 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.
| Key | Effect |
|---|---|
maxAccountsPerCall | accounts per call |
watchBufferPct | only the atRisk count in status.json |
allowLoss, maxLossUsdPerCall, lossBudgetUsdPerDay | liquidate 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.
| Key | Effect |
|---|---|
minBountyUsd | the smallest bounty worth a candidate |
includeUnbountied | also 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.
| Key | Effect |
|---|---|
maxSteps | the walk’s step budget (1 to 32) |
recheckSeconds | how 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.
| Key | Effect |
|---|---|
maxWinnersPerSide | candidates 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.
| Key | Effect |
|---|---|
healthPokes | send unpaid refreshes that change the vault’s health state |
healthPokeIntervalSeconds | how often each market is checked for one |
healthPokeMaxCostUsd | the 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).
- 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 tomaxAccountsPerCall(100) per tick; the next tick takes the rest. - 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 callsfinalizeTermination().
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.
| Key | Effect |
|---|---|
finalize | finalize once settled (or past the escape) |
finalizeMaxCostUsd | the most one finalize may cost |
maxAccountsPerCall | positions 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).
| Key | Effect |
|---|---|
minBatch | expired orders to wait for |
maxOrdersPerCall | orders per call |
includeBountyRefunds | also sweep finished orders holding a bounty |
lossBudgetUsdPerDay, maxLossUsdPerCall | what 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 ×gasSafetyMultiplieris 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.
| Key | Effect |
|---|---|
thresholdUsd | the balance worth a claim |
maxAgeDays | claim older balances regardless of size |
forwardTo, forwardMinUsd | move USDC to another address |
onShutdown | claim everything on a clean stop |
intervalSeconds | how often balances are read |
markets, exclude | which markets’ balances are claimed |
gasSafetyMultiplier | the cover the threshold rule asks for |
Runs in every state: claiming works on a terminated market too.
Related Pages
- Budgets & Losing Calls: what decides whether a planned call is sent.
- Manual Operations: one-shot runs of each job, and the operations no job runs.
- Configuration Reference: every job key.
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_callstate overrides it cannot be measured and counts as 0. - C, the cost in USD: the gas from
eth_estimateGasof 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):
| Setting | Default | What it counts |
|---|---|---|
budget.maxSpendUsdPerDay | 10 | the USD cost of every call sent in the last 24 hours, all jobs and wallets, plus the calls in flight |
budget.maxSpendEthPerDay | maxSpendUsdPerDay / 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)
| Setting | Default | |
|---|---|---|
profit.minProfitUsd | 0.001 | the least net profit worth a call |
profit.gasSafetyMultiplier | 1.25 | headroom against the gas estimate being low |
jobs.<job>.minProfitUsd, jobs.<job>.gasSafetyMultiplier | the profit values | per-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: trueon the job, or- an unpaid duty switched on by name:
terminalSettlement.finalize(default on) orrefresh.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:
| Cap | Setting | Default |
|---|---|---|
| per call | jobs.<job>.maxLossUsdPerCall, or the duty’s own: terminalSettlement.finalizeMaxCostUsd, refresh.healthPokeMaxCostUsd | 0.05; 0.5 for finalize; 0.1 for health pokes |
| per job, per day | jobs.<job>.lossBudgetUsdPerDay (the duties count against their job’s) | 1.0; 0.25 for sweepExpired |
| all jobs, per day | budget.maxLossUsdPerDay | 2 |
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:
| Setting | Default | Applies to |
|---|---|---|
gas.maxFeeGwei | none | every call: nothing is sent while the base fee is above it, and maxFeePerGas is capped at it |
gas.backgroundMaxFeeGwei | 0.1 | background 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:
| Setting | Default | |
|---|---|---|
gas.ethUsdMaxAgeSeconds | 60 | an ETH/USD print older than this is not used |
gas.ethUsdFallback | 3000 | used when there is no fresh print, or the print is outside $100 to $1,000,000 |
gas.ethUsdFloor | 1000 | gas 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:
| Skips | Keeps |
|---|---|
| the profit rule | the simulation: nothing that reverts is ever sent |
allowLoss and the per-call, per-job and global loss caps | a 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"}}
| Field | Meaning |
|---|---|
ts | seconds since the epoch |
job | the job (forward for forwarding; the operation’s name for manual operations) |
market | the market’s label, or empty |
wallet | the sending wallet’s address |
tx, status | the transaction and confirmed, reverted or unknown |
kind | profit, loss, unpaid, forced, collect or forward |
cost_usd | the gas actually paid (or the estimate, for unknown) |
reward_usd, claimed_usd | the FeeAccrued and FeesClaimed amounts the receipt logged for this wallet |
expected_reward_usd | what the simulation said |
extra.cost_wei | wei 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-runfor a while: the log shows what would be sent and why each skip was skipped. - Raise
maxSpendUsdPerDay(andmaxSpendEthPerDay) to what you are willing to spend on a bad day, not an average one. - Turn on
allowLossonly for jobs whose work matters to you even unpaid, with small per-call caps. - Set
gas.maxFeeGweiso a gas spike cannot make the keeper bid into it.
Related Pages
- The Jobs: which calls are paid and which are not.
- Configuration Reference: every key.
- Commands:
earningsin full.
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>/:
| File | What it holds | Written |
|---|---|---|
snapshot.json | the index: markets, open positions, live orders, and the block it is current to | every engine.snapshotSeconds (60 s) and on a clean stop, atomically (a temporary file renamed over it) |
ledger.jsonl | one line per sent transaction: the earnings record and what the daily budgets count | appended after each receipt |
status.json | what bazaar keeper status reads | every 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
.envwheninitasks. A detached container has no terminal to ask for it, and stops withwallet default (default.json) needs a password: …. docker stop keepersends 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 90ondocker run).- Other commands run the same way:
docker exec keeper node /app/packages/keeper/dist/cli/main.js keeper status, ordocker run --rm --user … -v … bazaar-keeper keeper earnings --since 7d. - Logs:
docker logs -f keeper. Setlog.format: jsonfor 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]
| Channel | What it sends |
|---|---|
telegram | sendMessage to chatId as the bot, plain text (no parse mode) |
discord | the message as content, with every mention disabled (no @everyone, roles or users) |
webhook | POST 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.
| Kind | When | Repeats |
|---|---|---|
lowBalance | a wallet is below budget.minEthBalance and sending is paused | the same message at most every 10 min |
jobPaused | a job was paused on a market after safety.pauseAfterReverts on-chain reverts | every 10 min at most |
endpointDown | the send endpoint gave no answer and the transaction has no receipt (booked as unknown) | every 10 min at most |
opportunity | a manual operation is available or will open (scanned every 5 min, only when a channel is configured) | each one once a day |
dailySummary | the last 24 hours’ net, once a day (the first at start, when the ledger has entries) | daily |
action | every 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
- Stop the keeper cleanly (SIGTERM, and wait for it to exit).
- Upgrade:
npm install -g @liberta-labs/bazaar-keeper@latestonce published; from the repository,git pull && npm install && npm run build; with Docker, rebuild the image. - Run
bazaar keeper doctor(a new version may refuse a key it no longer knows, or need a new one). - 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
| What | Why | How |
|---|---|---|
the keystores (keys/) | the wallets themselves: lose them and you lose what they hold and have not claimed | copy them anywhere; they are encrypted |
the keystore passwords (.env) | a keystore without its password is useless | keep them apart from the keystores, in a password manager |
ledger.jsonl | your earnings record, and what the daily budgets count after a restart | copy it now and then |
bazaar-keeper.yaml | your settings | it 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:
| Field | Meaning |
|---|---|
updated | when it was written (seconds since the epoch): older than 30 s means the keeper is not running |
network, factory, official | what it keeps, and whether the factory is a network’s official one |
block, indexed | the 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) |
jobs | per job: markets (count, or all), last (its latest sent call), today (the ledger’s last 24 h: calls, reward, cost, net, claimed, reverted) |
wallets | name to address |
halted | empty, 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).
Related Pages
- Setting Up a Keeper: the first run.
- Wallets & Secrets: keystores and passwords.
- Troubleshooting: what each warning means.
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.
| Command | What it does |
|---|---|
keeper init | the setup wizard |
keeper doctor | checks the RPC, the factory’s wiring, the subgraph, prices, wallets and jobs |
keeper start | runs the enabled jobs until stopped |
keeper status | what a running keeper is doing |
keeper markets | every market with its label, status and open positions |
keeper earnings | rewards, gas and net from the ledger |
keeper opportunities | manual operations available now |
keeper run | one operation now |
wallet | create, import or list wallets |
mock-prices | a mock Pyth Pro price endpoint for a local chain |
Common Options
| Option | Meaning |
|---|---|
-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, --verbose | print the whole error with its stack trace when a command fails (bazaar --debug keeper start, or at the end of the line) |
-h, --help | the command’s usage |
Errors print one readable line, with URLs and keys masked. Exit codes:
| Code | Meaning |
|---|---|
| 0 | done |
| 1 | the command failed (an unreachable RPC, a failed doctor check), or setup was cancelled |
| 2 | a usage, config or wallet problem: an unknown option, a missing --market, an invalid config, a wrong keystore password |
| 130 | cancelled 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>]
| Option | Meaning |
|---|---|
--dry-run | plan 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]
| Option | Default | Meaning |
|---|---|---|
--since <period> | 7d | how far back: 30m, 24h, 7d; a bare number is days |
--by <group> | job | one 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.
| Option | Meaning | Used by |
|---|---|---|
--market <m> | a market’s label, address or pair id; comma-separated for collect | every per-market operation (required), collect (optional) |
--accounts <addresses> | comma-separated accounts | liquidate, settle-terminal |
--orders <ids> | comma-separated order ids | execute-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 id | execute-insurer-termination (required) |
--pair-id <id> | a listing’s pair id | expire-listing (required) |
--dry-run | simulate and report, send nothing | all |
--force | send even when the profit and loss rules would skip it | the automatic operations |
-y, --yes | send a manual operation without asking | the 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 never | terminate-scheduled |
--accept-stored-price | send a settlement at the stored price, which is otherwise shown and not sent, --yes or not | terminate-scheduled |
| Operation | Calls | Needs |
|---|---|---|
liquidate | liquidate on the accounts given, or every account below its requirement | --market |
execute-triggers | executeTriggeredOrders on the orders given, or every ready one | --market |
unload | unloadVault | --market |
execute-adl | executeAdl with the planner’s picks | --market |
refresh-price | refreshPrice | --market |
collect | claimFees on every market (or those given) where a wallet holds a balance | |
settle-terminal | settlement-mode liquidate on the accounts given, or every open position | --market |
sweep-expired | sweepExpired on the orders given, or every expired or finished one | --market |
finalize | finalizeTermination | --market |
terminate-stale | the terminator’s terminateStalePair | --market |
terminate-scheduled | the terminator’s terminateScheduledPair, with the archived Pyth Pro updates its rule asks for | --market |
settle-termination | the terminator’s settleTerminationProposal | --assertion |
execute-insurer-termination | the terminator’s executeInsurerTermination | --market, --proposal |
settle-listing | the factory’s settleDeploymentProposal | --assertion |
expire-listing | the factory’s expireStuckDeploymentProposal | --pair-id |
settle-identifier-upgrade | the factory’s settleIdentifierUpgradeProposal | --assertion |
expire-identifier-upgrade | the factory’s expireStuckIdentifierUpgradeProposal | |
activate-identifier-upgrade | the 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]
| Option | Default | Meaning |
|---|---|---|
--name <name> | default | the keystore’s file name, and the name to put it under in wallets |
--path <file> | ~/.bazaar/keys/<name>.json | where 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>]
| Option | Default | Meaning |
|---|---|---|
--rpc <url> | http://127.0.0.1:8545 | the local chain, whose clock the updates follow |
--price <FEED_ID=USD> | ETH/USD at 2000 | a 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.1 | the 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> | 8787 | the port |
--token-env <NAME> | none | the 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
- The config is loaded and the wallets are unlocked (a keystore without a stored password is asked for).
- 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.
- The market is resolved.
--markettakes 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 marketsshows the addresses. - The market’s state is read and fresh prints are fetched for its legs.
- 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
| Option | Effect |
|---|---|
--dry-run | simulate and report, send nothing |
--force | automatic 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, --yes | manual 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-price | terminate-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
| Operation | Job | With no list | With a list |
|---|---|---|---|
liquidate --market M [--accounts a,b] | liquidation | every account below its requirement at the live price | exactly those accounts, in one call |
execute-triggers --market M [--orders 1,2] | triggers | every order ready to fire | exactly those orders (each StopLimit gets its hint) |
unload --market M | unload | the vault’s inventory, if the book takes some | |
execute-adl --market M | adl | the planner’s winners for each side | (in the library: longBucketWinners, shortBucketWinners) |
refresh-price --market M | refresh | a refresh, judged by the profit rule (it pays $0.10 only when the bounty is due) | |
collect [--market M1,M2] | collect | every market where a wallet holds a balance | those markets |
settle-terminal --market M [--accounts a,b] | terminalSettlement | every open position but your own (one batch) | exactly those accounts |
sweep-expired --market M [--orders 1,2] | sweepExpired | every expired order and every finished one holding a bounty | exactly 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.
| Operation | Calls | Available when |
|---|---|---|
finalize --market M | the 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 M | the terminator’s terminateStalePair(pair) | the market’s stored price is more than 21 days old (a dead oracle) |
terminate-scheduled --market M | the terminator’s terminateScheduledPair(pair, priceUpdate) | the market’s scheduled cutoff has passed: see below |
settle-termination --assertion A | the terminator’s settleTerminationProposal(assertionId) | a UMA termination claim’s liveness has ended |
execute-insurer-termination --market M --proposal P | the terminator’s executeInsurerTermination(pair, proposalId) | an insurer vote on the market can be resolved |
settle-listing --assertion A | the factory’s settleDeploymentProposal(assertionId) | a listing proposal’s liveness has ended |
expire-listing --pair-id P | the factory’s expireStuckDeploymentProposal(pairId) | a listing cannot settle |
settle-identifier-upgrade --assertion A | the factory’s settleIdentifierUpgradeProposal(assertionId) | an identifier upgrade is pending |
expire-identifier-upgrade | the factory’s expireStuckIdentifierUpgradeProposal() | the pending upgrade cannot settle |
activate-identifier-upgrade | the 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:
| Rule | Updates |
|---|---|
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 recorded | the 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-price | The 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 |
always | whenever no verified set goes through |
never | never |
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 at | Operation |
|---|---|
| every market past its scheduled cutoff, price not fixed | terminateScheduledPair |
| every market with a fixed price, not terminated, with zero open interest or past the 14-day escape | finalizeTermination |
| every market whose stored price is more than 21 days old | terminateStalePair |
| with a subgraph: termination claims past their liveness | settleTerminationProposal |
| with a subgraph: insurer votes | executeInsurerTermination |
| with a subgraph: listing proposals past their liveness | settleDeploymentProposal, expireStuckDeploymentProposal |
| the factory’s pending identifier upgrade | settleIdentifierUpgradeProposal, expireStuckIdentifierUpgradeProposal |
| the factory’s queued identifier upgrade | activateIdentifierUpgrade |
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).
Related Pages
- Commands: every
runoption. - Budgets & Losing Calls: what applies to forced calls.
- Termination & Settlement: building the governance calls that schedule a termination.
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;
}
| Function | What 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();
| Event | Payload | When |
|---|---|---|
action | ActionResult | a 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) |
opportunity | Opportunity | a 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:
| Field | Meaning |
|---|---|
status | confirmed, reverted, unknown (sent, no receipt), skipped (not sent), simulated (dry run or preview) |
job, market, summary | the job (or operation), the market’s label, a one-line description |
reason | why it was sent or skipped (earns $0.1000 for $0.0021 of gas, daily gas budget reached …) |
kind | the decision: profit, loss, unpaid, forced, skip, collect, forward |
wouldSend | whether the policy would send it (meaningful for simulated) |
sent | true for confirmed, reverted and unknown |
txHash, gasUsed | the transaction, once sent |
costUsd | the estimated gas cost, then the actual one after the receipt |
expectedRewardUsd | what the simulation said the call would earn |
rewardUsd, claimedUsd | what the receipt shows it earned and claimed |
error | a revert, decoded (toString() gives a readable line), or null |
opensAt | for a “too early” revert, when the call opens (seconds since the epoch), or null |
returned | the 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:
| Method | Lists |
|---|---|
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
}
| Method | What 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
| Member | What 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) |
running | true while start() runs |
recent | the latest 50 sent results, newest first |
ledger | the earnings ledger: ledger.summary(sinceSeconds, "job" | "market"), ledger.entries, ledger.spentToday() |
store | the index: store.markets (address to Market), store.positions, store.orders |
prices | the SDK’s PriceService: prices.latest, prices.healthy, prices.onPrints(listener) |
jobs | the enabled jobs by name |
cfg, net | the parsed config, and the resolved network (chainId, factory, official, startBlock) |
stateDir | where 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 totimeout, plus the shutdown collect). - Configure logging with
setupLoggingbefore creating the keeper; passwriteto 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’sredactUrl).
Related Pages
- Configuration Reference: every key
parseConfigtakes. - Manual Operations: what each
run.*call does. - Testing Locally with Anvil: a keeper and a mock price server in one script.
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
| Flag | Why |
|---|---|
--block-time 1 | a block a second, as the scenario’s waits assume |
--base-fee 10000000 | 0.01 gwei, under the keeper’s default gas.backgroundMaxFeeGwei (0.1), so background work runs |
--code-size-limit 100000 | Bazaar’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:
| File | What it is |
|---|---|
deployment.json | the transactions Bazaar’s Anvil deployment (script/DeployBazaar.s.sol) sends, computed from a built Bazaar checkout’s artifacts |
MockArbSys.json | the bytecode the replay places at the ArbSys precompile (0x…64), where the pair reads its block number |
build.mjs | produces 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:
| Contract | Address |
|---|---|
BazaarFactory | 0xc6e7DF5E7b4f2A278906862b61205850344D4e7d |
MockPythLazer | 0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0 |
MockUSDC | 0x9A676e781A523b5d0C0e43731313A708CB607508 |
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:
| Route | What it serves |
|---|---|
WebSocket /v1/stream | per subscription, an update at once and one per interval of its channel (real_time runs at 50 ms) |
POST /v1/latest_price | the newest update on the requested channel, for the requested feeds |
POST /v1/price | the 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/symbols | the registry: each feed with its symbol (ETH/USD is Crypto.ETH/USD) |
GET /mock/price | the current prices, by feed number |
POST /mock/price | set 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@200msby default), so a move takes up to one interval to reach the keeper before the keeper’s own reaction time.
Related Pages
- Commands: every
mock-pricesoption. - Configuration Reference: what each Anvil-specific value changes.
- Development: the repository’s other tests.
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:
| Reason | Meaning |
|---|---|
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 spent | a 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 ceiling | gas.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 price | unload: no bid within reach yet |
the refresh would not change the vault's health state | a 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 lands | a 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.
Related Pages
- Setting Up a Keeper: what
doctorchecks. - Budgets & Losing Calls: every skip rule.
- Running in Production: watching for these in
status.json.
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:
- Registers the user-facing error messages (
installUserMessages()), so a decoded revert reads as a sentence from then on. - Wraps the transport in a
Chain: a URL becomes the SDK’s own HTTP client (JsonRpc), anything else is used as given. - Reads the chain id and, in one batched call alongside it, the factory’s
lens,pairTerminator,oracleandusdc. (The first batch also detects how this node batches; see Transports.) - 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).
| Option | Type | Default | Meaning |
|---|---|---|---|
transport | Transport | string | required | An EIP-1193 provider (a browser wallet’s, a viem client, a JsonRpc) or a JSON-RPC URL. See Transports. |
factory | Address | required | The deployment’s BazaarFactory. Everything else is read from it. Take it from Bazaar’s deployments page: anyone can deploy a fork. |
chainId | number | none | Refuse to connect on any other chain. |
prices | ProClientOptions | ProClient | PriceUpdateSource | null | null | Where 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. |
checkSigners | boolean | true | Have the price source use only updates signed by a signer the deployment’s Pyth verifier trusts. See Prices. |
subgraph | string | { url, apiKey? } | SubgraphSource | null | null | Bazaar’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. |
integrator | Address | null | the zero address | Named on every order this connection creates; earns the integrator fee on their fills. See The Integrator Address. |
The returned object has:
| Member | What it is |
|---|---|
bz.ctx | The BazaarContext: chain, chainId, factory, lens, terminator, oracle, usdc, prices, subgraph, integrator. |
bz.chainId | The 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);
| Option | Type | Default |
|---|---|---|
transport | Transport | string | required |
factory | Address | required |
chainId | number | none |
prices | PriceUpdateSource | null | null |
checkSigners | boolean | true |
subgraph | SubgraphSource | null | null |
integrator | Address | null | the 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 thewalletyou send and sign with. - A viem client. A
PublicClientorWalletClienthas a matchingrequest; wagmi’s clients are viem clients. - A URL,
http(s)://. The SDK makes aJsonRpcwith its defaults. - A
JsonRpcof your own, to change those defaults:
JsonRpc option | Default | Meaning |
|---|---|---|
perSecond | 25 | Requests per second the client allows itself; extra requests queue in order. |
timeoutMs | 30,000 | Per request. |
retries | 3 | Retries 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. |
headers | none | Sent with every request. A value HTTP cannot carry (a line break) is refused when the client is made. |
fetch | globalThis.fetch | The fetch to use. |
maxResponseBytes | 64 MiB | A 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 }, wheretokenis the API key (servers), or a function that returns a short-lived token and is called for each request and each connection attempt. The otherProClientoptions go in the same object. - A
ProClientyou made, to share it with a live stream. - A
PriceUpdateSourceyou build:proPriceSource(...), aLivePricesstream, 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:
| Option | Default | Meaning |
|---|---|---|
token | none | A 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. |
streamUrls | Pyth’s three | The WebSocket endpoints. The stream connects to all of them, as Pyth requires, and yields each update once. |
restUrl | Pyth’s | Base 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. |
symbolsUrl | Pyth’s | Base URL of the symbols registry, which needs no token. No query or fragment either. |
headers | none | Extra 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. |
timeout | 10 | Seconds a request may take (the stream has no limit). |
fetch | globalThis.fetch | The fetch to use. |
WebSocket | the global one | The WebSocket constructor to stream with. Node before 22 has none: pass a package’s, such as ws. |
streamAuth | by 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. |
isTrustedSigner | none | (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). |
allowInsecure | false | Send 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). |
clock | the system clock | Unix seconds a streamed update’s own timestamp is judged against (a keeper passes the chain’s). |
maxUpdateAhead | 60 (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:
| Method | Returns |
|---|---|
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:
| Option | Default | Meaning |
|---|---|---|
timeoutMs | 30,000 | Per query. |
headers | none | Extra request headers. |
fetch | globalThis.fetch | The 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
}
}
| Error | Message | Why |
|---|---|---|
ConnectError | connected to chain 421614, expected 42161 | The transport (the wallet) is on another chain. |
ConnectError | 0x... does not answer as a Bazaar factory on chain 42161 | The factory’s reads reverted: the address is some other contract. |
viem’s AbiDecodingZeroDataError | Cannot decode zero data ("0x") ... | The factory address has no contract on this chain at all (the reads “succeed” with empty answers). |
viem’s InvalidAddressError | Address "..." is invalid. | factory or integrator is not an address. |
HttpError | HTTP 401 from https://.../v2/***: the provider refused the request; check the key in the URL | The RPC refused the request. |
TypeError | JsonRpc: the ... header's value holds a line break, ... | A header value (often from an .env file) has a stray character. |
ProError | the 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. |
ConnectError | this call needs a fresh Pyth Pro price: connect with a price source (prices) | Later, from a builder: the connection has no prices. |
ConnectError | history comes from Bazaar's subgraph: connect with a subgraph source | Later, 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.
Related Pages
- Core Concepts
- Markets, Accounts & Portfolios
- Live Prices, Live Updates
- Sending Transactions
- Security Notes for Integrators
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.
| Option | Default | Meaning |
|---|---|---|
refresh | false | Read the factory’s pair list again. Markets already known keep their facts; only new ones are described. |
metadata | the 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:
| Field | Type | Meaning |
|---|---|---|
address | Address | The pair contract: what every other function takes. |
label | string | For 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. |
pairId | Hex | The pair’s id in the factory. |
baseFeedId | Hex | The pair’s price id: a Pyth feed id, or a composite’s id. |
legs | Hex[] | The raw Pyth feeds: one, or base then quote for a composite. |
legSymbols | string[] | 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. |
composite | boolean | Priced from two feeds composed by the oracle. |
invertQuote | boolean | A composite’s quote leg is inverted. |
continuous | boolean | Traded 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. |
description | string | The 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. |
index | number | The 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.
| Option | Default | Meaning |
|---|---|---|
block | the latest | { number, timestamp } to read at (a watcher passes its head). |
prices | none | A LivePrices stream (or anything with price and venueClosed), to judge trading-hours venues without a request. |
fetchPrices | true | Without 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
| Field | Meaning |
|---|---|
market | The MarketInfo. |
block, timestamp | The block read, and its timestamp: what the status, the clocks and the flags are judged at. |
status | live, adl, limbo, settling or terminated. See Status. |
venueClosed, marketOrdersDisabled, positionWithdrawalsFrozen, allWithdrawalsPaused, reasons | The flags. See Flags. |
price | The pair’s stored oracle price: what its next call falls back to without a fresh one. |
markPrice | The mark at this block (the lens’s markPrice); 0 before any qualifying print formed one. |
funding | Funding rates and the index. See Funding. |
margin | The margin rates. |
openInterest | Open interest and the vault’s inventory. |
insurance | The insurance fund and the vault’s ledger. |
deficit | Realized bad debt beyond the insurance fund: non-zero means the pair is insolvent and settles. |
adl | The ADL window and auction. |
termination | A scheduled cutoff, the settlement price, the terminal payout ratios. |
book | The book’s heads and order counts. |
state | The raw state read (MarketState), which account functions reuse. |
price (StoredPrice):
| Field | Meaning |
|---|---|
spot | The stored spot (18 decimals). |
low, high | Spot less and plus the Pyth confidence. |
updateTs | The stored print’s publish time (Unix seconds). |
age | Seconds between the stored print and the snapshot’s block. |
emaVarianceBp | The variance EMA the margin curve reads, in basis points. |
updateCount | Prices the pair has stored since it was created. |
margin (MarginView), all in basis points of notional:
| Field | Meaning |
|---|---|
imrBp | Initial margin: what opening, adding and withdrawing must keep. |
mmrBp | Maintenance margin now. |
laggedMmrBp | The 24-hour-lagged maintenance rate (0 before any sample). |
effectiveMmrBp | What liquidation judges by: the lower of the current rate and a non-zero lagged one. |
maxLeverage | 10,000 / imrBp, a number (null while imrBp is 0). |
withdrawalFloorBp | The 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):
| Field | Meaning |
|---|---|
fund | The insurance fund’s balance. |
seized | The part earmarked against inventory the vault holds. |
freeCapital | fund less seized. |
targetRatioBp | The target the margin curve last computed, in basis points of open interest. |
depositsLedger | Every account’s collateral the pair holds. |
ledgerArrears | What the deposits ledger owes the fund (cash winners already withdrew). |
See Insurance.
adl (AdlView):
| Field | Meaning |
|---|---|
pending | An ADL window is open. |
epoch | Advanced each time a window opens. |
pendingSince | When the open window opened (0 when none). |
auctionStart | When the score threshold started decaying (restarts after a market closure). |
auctionEndsAt | When the threshold reaches its floor: auctionStart + 10 minutes (0 when none). |
timeoutAt | pendingSince + 24 hours: the timeout then arms termination. |
terminationTs | When an armed timeout may terminate the pair (0 while unarmed). |
scoreThreshold | The score a winner must exceed at the snapshot’s block (MAX_UINT256 while no auction runs). |
termination (TerminationView):
| Field | Meaning |
|---|---|
scheduledTs | A scheduled cutoff (0 when none). |
haltsAt | When trading halts ahead of it: the cutoff less LIMBO_LEAD (6 s). |
settlementFixedTs | When the settlement price was fixed (0 while not). |
settlementPrice | The fixed settlement price (0 while live). |
finalizeBy | The latest finalizeTermination can wait: settlementFixedTs + 14 days. |
terminated | The termination is finalized. |
principalRatio, profitRatio | Cash paid per unit of collateral and per unit of profit claim after finalize (1e18 = 100%). |
profitClaims | Registered winner profit not yet paid out. |
badDebt | Settled 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:
| Status | Meaning |
|---|---|
terminated | The termination is finalized. Nothing trades; collateral and settled profits can be withdrawn. |
settling | The settlement price is fixed and positions are being settled at it (at most 14 days). |
limbo | From LIMBO_LEAD (6 seconds) before a scheduled cutoff until its settlement price is fixed: trading has stopped. |
adl | An ADL window is open: matching halts while liquidation and ADL run. |
live | Trading 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);
| Flag | True when | Reason shown |
|---|---|---|
venueClosed | A 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.” |
marketOrdersDisabled | The 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.” |
positionWithdrawalsFrozen | An 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. |
allWithdrawalsPaused | No 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):
| Field | Meaning |
|---|---|
currentRatePerHour | The rate the mark’s premium implies now, per hour, 18 decimals (1e18 = 100%): positive when longs pay shorts. |
accruingRatePerHour | The rate the next stored price charges for the window since the index last accrued. |
index | The stored cumulative funding index, in price units. |
projectedIndex | The index a price stored at this block would leave. Account health uses it. |
lastAccrualTs | When the index last accrued (Unix seconds). |
maxRatePerHour | The 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 / option | Default | Meaning |
|---|---|---|
levels | 20 | Price levels wanted, 1 to 1,000 (an InputError otherwise). |
pageSize | levels × 4, between 32 and 200 | Orders per lens page. |
maxOrders | 2,000 | Stop after this many orders even with fewer levels (the last level may then be partial). |
block | the 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
| Error | Why |
|---|---|
InputError on market: There is no market called "..." | No label, address or pair id matches, even after a refresh. |
InputError on market: "..." matches several markets | Two markets share a label; pass the address. |
Error: could not read the state of market ...: its price, margin or vault read failed | marketSnapshot (and accountSnapshot) could not read the market’s core state at that block. |
InputError on side or levels | getBookDepth was given another side or a level count outside 1 to 1,000. |
| Labels are short addresses | The feed metadata could not be fetched (the list itself still loads). |
venueClosed is null | Nothing could judge the venue: pass live prices, or connect with a Pyth Pro source. |
Related Pages
- Accounts & Portfolios
- Live Prices, Live Updates
- Previews
- History, for candles and trades
- Units & Constants
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:
options.price, a price you already hold ({ spot, low?, high?, publishTime? });- the live price from
options.prices, aLivePricesstream; - one Pyth Pro request for the market’s feeds, when the connection’s price source is a
proPriceSource(andfetchPricesis notfalse); - the pair’s stored price.
snapshot.price.source says which: "given", "live", "pro" or "stored".
| Option | Default | Meaning |
|---|---|---|
block | the latest | { number, timestamp } to read at. |
prices | none | Live prices to value the position at and to judge the venue with. |
fetchPrices | true | Without live prices, ask the connection’s Pyth Pro source for the feeds, in parallel with the first read. |
price | none | A price you hold, used instead of a live one. |
market | none | The market’s snapshot (or state read) at the same block, to skip reading it again. Its block is then the snapshot’s. |
Snapshot Fields
| Field | Meaning |
|---|---|
market, user | The market’s MarketInfo, and the account (checksummed). |
block, timestamp | The block read and its time. |
status, flags | The market’s status and flags at that block. |
state | The market’s state read, kept for accountHealth. |
position | The position bucket. See below. |
hasPosition | position.size > 0. |
price | What the health and the withdrawal figure were valued at, with its source; null when no price was known at all. |
health | The position’s health at price.spot; null while flat or without a price. |
solvency | The lens’s own view (checkBucketSolvency) at price.spot and the stored funding index; null without a price. |
orders | Every live order: the Limit and StopLimit orders in the account’s active set, then the attached take-profit and stop-loss. |
takeProfit, stopLoss | The position’s attached TP and SL among orders, or null. |
exposure | Resting orders’ remaining notional (at their limits) and size, per side. |
maxWithdrawable | The most withdrawCollateral accepts now, per the lens. See Withdrawals and Fees. |
withdrawalBlocked | Why this account cannot withdraw right now, or null. |
claimableFees | What claimFees would pay this account on this market: refunded bounties, keeper rewards, relayer and integrator fees. |
nonce | The account’s next meta-transaction nonce on this pair (gasless requests sign it). |
terminal | What 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:
| Field | Meaning |
|---|---|
isLong, size | The net position: one per market, size 0 when flat. |
entryValue | The entry notional. |
entryPrice | entryValue / size, the average entry price (0 while flat). |
collateral | Raw collateral: deposits plus realized PnL, less fees. |
entryFundingIndex | The funding index at entry. |
takeProfitOrderId, stopLossOrderId | The 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:
| Field | Type | Meaning |
|---|---|---|
price | bigint | The spot it was valued at. |
notional | bigint | Size × price. |
unrealizedPnl | bigint | Price PnL since entry. |
fundingPnl | bigint | Funding earned (positive) or owed (negative) since entry. |
totalPnl | bigint | The two together. |
equity | bigint | Collateral plus total PnL (signed). |
availableEquity | bigint | Equity floored at zero. |
maintenanceRequired | bigint | What maintenance margin requires at this price. |
marginRatio | number | equity / maintenanceRequired. Below 1, anyone may liquidate the position. |
leverage | number | null | Notional over equity (null when equity is not positive). |
isSolvent | boolean | The 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:
| Field | Meaning |
|---|---|
orderId | The pair’s id for the order. |
orderType, typeName | The type (OrderType), and its name: “Market”, “Limit”, “StopLimit”, “TakeProfit”, “StopLoss”. |
isLong | A buy. |
isPostOnly, tif | Post-only, and the time in force (0 GTC, 1 IOC, 2 FOK). |
size, filled | The order’s size and what has filled. |
remaining | What is left to fill; for a TP or SL, capped at the position it closes. |
limitPrice, triggerPrice | The limit, and the trigger (StopLimit, TakeProfit, StopLoss; 0 otherwise). |
bounty | The keeper bounty the order still holds in escrow. |
maxSlippageBp | The slippage cap of a TP or SL. |
expiryBlock, neverExpires, blocksLeft | The expiry (an Arbitrum block), whether it never expires (TP and SL), and blocks until it does (null when never). |
inBook | Resting in the book: a Limit, or a StopLimit that has armed. |
closesWholePosition | A TP or SL sized to close the whole position, whatever it is when it fires. |
attached | "takeProfit", "stopLoss", or null. |
integrator | The integrator the order names. |
record | The 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.
| Field | Meaning |
|---|---|
user, block, timestamp | The account and the block read. |
accounts | One AccountSnapshot per market the account uses. |
walletUsdc | The wallet’s USDC, in USDC units (6 decimals). |
totals | Summed 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
| What | Why |
|---|---|
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 position | No 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 reverts | withdrawalBlocked is set (a closed venue, ADL, a pause), or the price moved. Simulate first. |
| An address error | user is not an address (viem’s InvalidAddressError). |
Related Pages
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:
| Method | Returns |
|---|---|
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. |
service | The 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):
| Field | Meaning |
|---|---|
market | The pair’s address. |
spot | The composed price. |
low, high | Spot less and plus the confidence: what a long and a short are valued at when they withdraw. |
confidence | The band’s wider half: max(high − spot, spot − low). |
confidenceBp | confidence / spot in basis points, a number for display. |
confidenceOk | Every leg, and the composed band, sit inside the oracle’s 2% confidence cap. |
publishTime | The second the older leg’s price was generated in (Unix seconds): what the pair’s window judges. |
age | Seconds since publishTime, at the service’s clock. |
fresh | Every 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. |
venueClosed | A trading-hours market whose feed has generated no price for closedAfter seconds while the endpoint delivers. |
legs | The 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" } });
| Option | Default | Meaning |
|---|---|---|
closedAfter | 15 | Seconds without a newly generated price after which a trading-hours market reads closed. |
tickMs | 1000 | Milliseconds between re-checks of fresh and venueClosed for subscribers (0: never). |
reuseAge | 1 | A stream update whose price was generated at most this many seconds ago is reused for a call (see Prices for Calls). |
start | true | Start streaming at once; with false, call start(). |
stream | true | Follow the stream, falling back to polling when it fails. false polls only. |
streamIdleTimeout | 10 | Seconds the stream may bring no new update before it is dropped and reconnected. null or 0 waits forever. |
pollInterval | 1 | Seconds between polls when not streaming. |
maxFutureSkew | 120 | Seconds a print may be dated ahead of this device’s clock before it is ignored. See below. |
clock | the system clock | The time freshness is judged at (Unix seconds). |
logger | console | Where endpoint failures are reported (warn); null for silence. |
pro | the connection’s | Options for a ProClient of the stream’s own (another token, another channel). |
client | the connection’s | A 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:
| Member | What 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. |
displayUrl | The 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).
| Member | What 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, latest | State, 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
| What | Why |
|---|---|
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 null | A 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 (...): reconnecting | One endpoint went away; the others keep delivering. |
Warning: ignored a print for feed 0x... dated N s ahead of the clock | This device’s clock is behind, or the endpoint is wrong. |
fresh is false while prices move | This 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 it | The update did not come from Pyth, or the deployment’s verifier no longer trusts that signer. |
service.healthy is false | Three failures in a row; service.lastError says why. It keeps retrying. |
Related Pages
- Connecting
- Markets, Accounts & Portfolios
- Live Updates
- Sending Transactions
- Low-Level Building Blocks
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
| Member | What it does |
|---|---|
ready | Settles 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. |
stopped | Whether 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:
type | watchMarket | watchAccount | Fields |
|---|---|---|---|
"event" | yes | yes | event: a typed LiveEvent (below). |
"snapshot" | yes | snapshot (a MarketSnapshot), cause, bookChanged. | |
"account" | yes | snapshot (an AccountSnapshot), cause. | |
"head" | yes | yes | block, timestamp: the follower read up to a new head. |
"reorg" | yes | yes | fromBlock, head: the chain was reorganized and logs are read again from fromBlock. |
"error" | yes | yes | error, retryInMs: a tick failed and is retried after retryInMs; null means the watch gave up. |
"undecodable" | yes | yes | log, 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:
| Group | Events |
|---|---|
| Trading | Trade, OrderUpdated, OrderAmended, OrderAmendSkipped |
| Positions and collateral | PositionBucketUpdated, PositionModified, CollateralDeposited, CollateralWithdrawn |
| Fees and bounties | FeeAccrued, FeesClaimed, TriggerBountyEscrowed, TriggerBountyReleased |
| Prices | NewPriceUpdateAdded |
| Liquidation and ADL | PositionLiquidated, AdlTriggered, AdlTerminationArmed, AdlResolved, AdlExecuted, UserAdld |
| Settlement and termination | InsolvencySettlementFixed, PairTerminated, TerminalRatiosRefrozen |
| Gasless calls | MetaTransactionExecuted, PermitExecutionFailed |
| Insurance | InsuranceCredited, InsuranceDeposited, InsuranceWithdrawalRequested, InsuranceWithdrawalLocked, InsuranceWithdrawalExecuted, InsuranceShareEpochBumped |
| Other | Initialized |
Their arguments are the pair’s own, typed in live.PairEventArgs. A few need a note:
OrderUpdated.payloadis the order’s stored state at the event.payload.actionis 0 (created), 1 (triggered) or 2 (canceled);payload.cancelReasonis 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. ItstriggerPriceis the price alone: the bounty is not part of the stored order.OrderAmendSkipped.reasonis 0 Inactive, 1 Expired, 2 SizeFilled, 3 BelowMinimum, 4 WouldCross.InsolvencySettlementFixed.reasonis the vault-health verdict: 2 ADL timeout, 3 balance short, 4 bad debt.InsuranceWithdrawalExecuted.amountis the payout in 18 decimals. The contract’s ABI calls itusdcAmount, 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)
});
| Option | Default | Meaning |
|---|---|---|
prices | none | A LivePrices: the venue for the snapshots, and the price accounts are valued at. |
fetchPrices | true | Without live prices, let snapshots ask the connection’s Pyth Pro source (one request per snapshot for trading-hours markets, and for accounts). |
refreshMs | 15,000 | Refresh snapshots at least this often without events. 0: on events only. |
minSnapshotMs | 1,000 | The least time between two event-driven refreshes: events in between are folded into one. |
initial | true | Deliver snapshots as soon as the watch starts. |
pollMs | 1,000 | Milliseconds between head polls. With a subscription, a backup poll runs every 10 × this. |
subscribe | true | Wake on eth_subscribe("newHeads") when the transport supports it. |
fromBlock | the block after the head at start | The first block to deliver events from. |
reorgDepth | 32 | Blocks re-read after a reorg. |
logChunk | 2,000 | The most blocks one eth_getLogs asks for; halved while the node refuses a range. |
maxBlocksPerTick | 10 × logChunk | The 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. |
maxBackoffMs | 30,000 | The 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:
- reads the latest block;
- checks it extends what was read before (its parent hash, or the last head’s hash);
- reads the logs from the cursor up to it, delivers them in chain order, and moves the cursor;
- 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:
| Argument | Meaning |
|---|---|
chain | The connection’s Chain (bz.ctx.chain). |
addresses | A 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. |
options | pollMs, 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
| What | Why |
|---|---|
"error" updates with growing retryInMs | The RPC is failing; the watch keeps retrying and catches up when it recovers. |
"error" with retryInMs: null | A market could not be resolved (getMarket failed): the watch stopped. |
"error": The node reports block N as its head, ... but cannot serve block M | A 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 snapshots | refreshMs: 0 and initial: false, or minSnapshotMs is folding them; or the snapshot reads are failing (see the errors). |
"undecodable" updates | A log from a watched contract did not match the SDK’s ABI: an SDK older than the contracts. |
Related Pages
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
- 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
InputErrornaming the field for anything it would refuse. - Simulate it as the user (
bz.simulate). A revert comes back decoded, with a sentence for the user, before anything is signed. - Show
call.summary(and, for a call that takes USDC,call.pullsUsdc) on the confirmation screen. - Send it with the user’s wallet (
bz.send,toTransactionRequestfor viem and wagmi, or a local key in a bot). - 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
| Field | Type | Meaning |
|---|---|---|
action | string | The contract function: "createOrder", "depositCollateral", "approve", "execute", "proposeTermination", … |
to | Address | The contract to call: the pair, USDC for an approval, the terminator or factory for governance calls. |
data | Hex | The calldata. |
value | bigint | ETH to send, in wei: the Pyth update fee when the call carries a price update, otherwise 0n. |
from | Address | null | The 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). |
summary | string | One line for a confirmation screen, such as Deposit 100 USDC as collateral or Buy 0.5 at 3000 (limit, GTC). Amounts print exactly. |
priceTime | number | undefined | Present when the call carries a Pyth update: the update’s publish time in unix seconds (the oldest leg’s, for a composite market). |
noFreshPrice | string | undefined | Present 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. |
pullsUsdc | bigint | undefined | USDC 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.
| Builder | Carries a price update |
|---|---|
createOrder, amendOrders, executeTriggeredOrders | Whenever the oracle would accept one (above). |
withdrawCollateral | When 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, approveUsdc | Never. |
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.
| Option | Default | Meaning |
|---|---|---|
from | call.from | The account to simulate as. |
estimateGas | true | Also 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:
| Field | Type | Meaning |
|---|---|---|
ok | boolean | The call would succeed against the current state. |
returnData | Hex | The call’s return data, or the revert data when ok is false ("0x" when the node returned none). |
error | DecodedError | null | Why it would revert: the custom error’s name, its decoded args, and message, a sentence for the user. Null when ok. See Errors & Messages. |
gas | bigint | null | The 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.
| Option | Default | Meaning |
|---|---|---|
from | call.from | The sending account. A call with neither fails with CallError("sending needs the user's address (from)"). |
gas | none | A 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: itsaccountis the bare address, and viem then asks the node to sign witheth_sendTransaction, which a public RPC refuses. Passto,dataandvalueand 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
priceTimeis 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 makeswaitForReceiptthrow; 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.
| Option | Default | Meaning |
|---|---|---|
timeoutMs | 120000 | How long to wait before giving up. |
pollMs | 500 | Time 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:
| Field | Meaning |
|---|---|
message | The decoded revert’s user message when there is one, else the wallet’s or node’s own message. |
decoded | The DecodedError when the wallet’s error carried revert data (its gas estimate reverted), else null. |
cause | The 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.
Related Pages
- Collateral, Approvals & Permits: the USDC a call pulls, and how to allow it.
- Errors & Messages: everything a call can throw and what to show.
- Gasless Trading & Relayers: the path where the user signs and someone else sends.
- Smart Accounts & Batching: several prepared calls in one transaction.
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, includingPreparedCall.pullsUsdc. The pair floors what it moves to whole micro-USDC (multiples of1e12). - USDC units (6 decimals): what USDC’s own functions count in:
balanceOf,allowance,approve,permit. Every function in theallowanceandpermitsareas 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"
| Helper | Converts |
|---|---|
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.
| Argument | Meaning |
|---|---|
pair | The 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. |
from | The depositing account. The collateral is credited to it. |
amount | BAZAAR precision. Floored to whole micro-USDC first; at least 5 USDC after flooring, or InputError on amount (“The minimum deposit is 5 USDC.”). |
opts.permitData | A 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"
}
| Function | Returns |
|---|---|
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:
| Field | Meaning |
|---|---|
needUsdc | The pull in USDC units, rounded up. |
balance | The wallet’s USDC balance. |
allowance | What the pair may pull now. |
enoughBalance | balance >= needUsdc. |
enoughAllowance | allowance >= needUsdc. |
approveUsdc | What 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").
| Argument | Meaning |
|---|---|
owner | The wallet giving the allowance (the call’s from). |
spender | A 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. |
amountUsdc | USDC units. 0n revokes. Ignored with unlimited. |
opts.unlimited | Approve 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 field | Default | Meaning |
|---|---|---|
owner | required | The signing wallet’s address. |
spender | required | The pair that will pull. Must be one of the deployment’s pairs, or InputError on pair before the wallet is asked. |
value | required | USDC units, more than zero. permits.permitValueFor(pullBazaar) converts a BAZAAR-precision pull, rounded up. |
deadline | 30 minutes | Unix seconds. The default is DEFAULT_PERMIT_WINDOW (1800 s) past the lower of the chain’s time and the device’s clock. |
nonce | read | The 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.
valuemust cover everything the call pulls, and a permit signed when a larger allowance is in place replaces that allowance with the smaller one. CheckusdcAllowancefirst, as the sample does. (signRelayedmakes 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
PermitExecutionFailedand 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:
Call The 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 lockingexecuteInsuranceWithdrawalThe relayer fee alone. A direct call of these pulls nothing and ignores a permit. withdrawCollateral, the payingexecuteInsuranceWithdrawalTakes 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 anapprovecall 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
ClockSkewErrorwhen 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));
}
| Argument | Meaning |
|---|---|
amount | More than zero, or InputError on amount. |
opts.withPrice | Whether 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).
| Freeze | Who | Lifts |
|---|---|---|
An auto-deleveraging window is open (BazaarPair__WithdrawalsFrozenAdlPending) | Accounts with a position | When 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) | Everyone | At finalization, at most 14 days after the price was fixed. |
Limbo, the seconds before a scheduled termination cutoff (CollateralLib__PairScheduledForTermination) | Everyone | When 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 position | With 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 see | It 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__InvalidDepositAmount | The pair’s own minimum check. |
CollateralLib__InsufficientMarginAfterWithdrawal, CollateralLib__MustRetainMinimumCollateral | The 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. |
ClockSkewError | The 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.
Related Pages
- Placing Orders: orders pull a keeper bounty from the wallet, too.
- Previews: the withdrawal ceiling and every reason it binds.
- Gasless Trading & Relayers: deposits and withdrawals without ETH.
- Insurance: the other pool of USDC on each pair.
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?):
- Checks the order’s shape (
buildOrderSpecwithout any reads) and thatpairis one of the deployment’s pairs. Either failure throwsInputErrornaming the field. - 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). - 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.
- Returns the call:
summaryisdescribeOrder(spec),priceTimethe update’s publish time,valueits Pyth fee, andpullsUsdcthe keeper bounty a trigger order takes from the wallet (0nfor 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 |
|---|---|
hints | MatchHints to use instead of the ones the SDK finds. See Book Hints. |
maxSteps | The 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). |
permitData | A 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.
| Field | Types | Meaning |
|---|---|---|
orderType | all | OrderType.Market, .Limit, .StopLimit, .TakeProfit or .StopLoss (0 to 4). |
isLong | all | true buys (goes long or reduces a short), false sells. |
size | all | Base units, BAZAAR precision (parseUnits18("0.5") is half a unit). For a TakeProfit or StopLoss, CLOSE_POSITION closes the whole position. |
limitPrice | Limit, StopLimit | The price the order fills at or better, BAZAAR precision. |
triggerPrice | StopLimit, TakeProfit, StopLoss | The oracle price that fires the order (the price alone; the bounty is its own field). |
bounty | StopLimit, TakeProfit, StopLoss | What the order pays whoever fires it, BAZAAR precision, a whole number of micro-USDC. Default 0. |
maxSlippageBp | Market, TakeProfit, StopLoss | The worst fill allowed, in basis points of the oracle price: 1 to 1000 (0.01% to 10%). Required for these types. |
tif | Limit, Market | TimeInForce.GTC (default), .IOC or .FOK. |
isPostOnly | Limit GTC | Cancel instead of taking liquidity. Default false. |
expirationBlock | Limit GTC, StopLimit | The 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.
| Position | Order side | StopLoss fires when | TakeProfit fires when |
|---|---|---|---|
| Long | sell (isLong: false) | price ≤ trigger | price ≥ trigger |
| Short | buy (isLong: true) | price ≥ trigger | price ≤ 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
InputErroronorderType(“… need an open position”) or onisLong. - One of each per position. A second TakeProfit or StopLoss is refused (
checkOrderCaps); cancel the existing one first. CLOSE_POSITIONcloses 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 reasonPositionClosed). The $5 minimum is measured on what the order could close, so a full close is always allowed.- They never expire (
expirationBlockis ignored and stampedNEVER_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
tif | What happens to the size the book cannot fill on arrival | Accepted 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
| Constant | Value | Meaning |
|---|---|---|
MIN_ORDER_LIFETIME_BLOCKS | 240 | The shortest life the pair accepts (~1 minute). |
EXPIRY_LANDING_MARGIN_BLOCKS | 40 | Slack the SDK adds on top: the pair measures the lifetime from the block the transaction lands in. |
MAX_ORDER_LIFETIME_BLOCKS | 126,144,000 | ~1 year. A later expiry is clamped to it by the pair. |
NEVER_EXPIRE_BLOCK | 2^64 − 1 | What 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
createOrderreports it inpullsUsdc, and the wallet needs that much USDC allowed to the pair, or a permit inopts.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); otherwiseInputErroronbounty(“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, andorders.specBounty(spec)reads it from a spec.buildOrderSpecpacks 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 withclaimFees. - Firing your own. A bounty of 0 means the owner fires the order: build
executeTriggeredOrderswhen 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:
| Field | Used for |
|---|---|
currentBlock | The expiry against the minimum lifetime. |
price | The Limit placement band, and a Market order’s notional. Take it from live prices (Live Prices) or bz.trade.livePairPrice(pair). |
account | The 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.pricefor 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
| Cap | Limit | Counts |
|---|---|---|
| Active orders | 32 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. |
| TakeProfit | 1 per position | |
| StopLoss | 1 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)"
| Type | Example |
|---|---|
| Market | Buy 0.5 at market (0.5% max slippage), with , fill-or-kill for FOK |
| Limit | Buy 1 at 2950 (limit, GTC, post-only) |
| StopLimit | Buy 1 at 3210 once the price reaches 3200 (stop-limit, $0.25 keeper bounty) |
| TakeProfit | Take profit: sell the whole position when the price reaches 3600 (1% max slippage, $0.5 keeper bounty) |
| StopLoss | Stop loss: sell 0.5 when the price reaches 2700 (2% max slippage) |
What Can Go Wrong
| You see | It 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__FillOrKillUnfilled | A FOK order could not fill completely; nothing happened. |
MatchingEngineLib__BlockedOracleStale | A Market order on a market whose venue is closed. Use a Limit order. |
BazaarPair__TradingHalted | ADL, 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 canceled | An 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.
Related Pages
- Managing Orders: amend, cancel, fire your own triggers, claim fees.
- Previews: fills, fees, margin and the resulting position before sending.
- Collateral, Approvals & Permits: funding the account and the bounty.
- Orders and Matching in Bazaar’s docs: the rules.
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:
| Field | Meaning |
|---|---|
orderId | The order’s id on the pair (bigint). |
orderType, typeName | 0 to 4, and "Market", "Limit", "StopLimit", "TakeProfit" or "StopLoss". |
isLong, isPostOnly, tif | As created (tif: 0 GTC, 1 IOC, 2 FOK). |
size, filled, remaining | Total size, the part filled, and what is left (for a TP/SL, capped at the position it closes). |
limitPrice, triggerPrice | The limit, and the trigger price for trigger types (0 for the others). |
bounty | What the keeper bounty escrow still holds for the order. |
maxSlippageBp | As created. |
expiryBlock, neverExpires, blocksLeft | The expiry block, whether it never expires (TP/SL), and blocks until it expires (null when it never does). |
inBook | Resting in the book: a Limit, or a StopLimit that has armed. A trigger order still waiting is false. |
closesWholePosition | A TP/SL sized to close whatever the position is when it fires. |
attached | "takeProfit" or "stopLoss" for the position’s attached orders, else null. |
integrator | The integrator the order names. |
record | The 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 })andbz.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:
| Field | Meaning |
|---|---|
orderId | One of from’s resting Limit GTC orders. |
limitPrice | The new limit price, BAZAAR precision. |
size | The new total size, the filled part included: it must exceed what has filled. |
pred | Optional: the order to rest directly behind at the new price (0 = none). Found for you when both hints are omitted. |
pred2 | Optional: a second candidate, tried when the first has left the book. |
bestEffort | Skip 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 see | It 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 simulation | The pair’s own refusal; the message says what to change. |
Related Pages
- Placing Orders: the order types and their options.
- Previews: what an order will do before it is placed.
- Live Updates: order events as they happen.
- Gasless Trading & Relayers: cancel and amend without ETH.
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 read | Estimates |
|---|---|
| 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.
| Option | Default | Meaning |
|---|---|---|
price | the pair’s stored price | The price the call will carry: { spot, low?, high?, publishTime? }. publishTime lets the preview tell whether a market with trading hours has its venue closed. |
walkBook | true | Walk the current book (up to 33 orders on the opposite side). false assumes one fill at fillPrice. |
fillPrice | the mark | The 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:
| Field | Meaning |
|---|---|
blockers | Every 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, afterArrival | The 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. |
fills | Each 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). |
triggersNow | A trigger order’s trigger is already met at the mark, so it fires on arrival. |
takerSize, takerAvgPrice | Size that fills in this transaction, and its average price (null when nothing fills). |
restingSize | Size left resting in the book, or waiting outside it for a trigger order. |
laterFillOk | Whether 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, cancelReason | Size canceled at the end of the walk, and why: Unfilled, BelowMinimum, PostOnly, SelfMatch, StepLimit, Margin, PriceBand or PositionClosed. |
realizedPnl, settledFunding | Price PnL and funding realized on the size the order closes. |
fees | The sum over every fill, with where it goes: insurance, integrator, total, and after the 1% bug-bounty skim toInsuranceFund, toIntegrator, toBugBounty. |
opens, reduces, closes, flips | The order adds exposure; trades against the held position; leaves it flat; leaves it on the other side. |
margin | The margin view (below). |
maxLeverage | The most leverage a new position can take now: 10000 / imrBp. |
mark | The price the preview judged at. |
venueClosed | A market with trading hours whose venue is closed; null when it cannot be told (no publishTime given). |
feeRates | The taker rates used: takerEbp and closingEbp (1/100 bp), and the insurance fund’s coverageRatioBp and targetRatioBp they come from. |
margin:
| Field | Meaning |
|---|---|
imrBp, mmrBp, effectiveMmrBp | Initial 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). |
initialMargin | Initial margin on the resulting position. |
requiredCollateral | What 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). |
collateral | The account’s collateral now. |
ok, shortfall | Whether the collateral covers the requirement, and by how much it falls short. |
retainedFloorBp, retainedFloor | The 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):
| Field | Meaning |
|---|---|
isLong, size, entryValue, entryPrice | The position; entryPrice is entryValue / size, 0 when flat. |
collateral, entryFundingIndex | As the pair would store them. |
notional, unrealizedPnl, fundingPnl, equity | Valued at the mark. |
maintenanceRequired, isSolvent | The maintenance requirement at the effective rate, and whether equity covers it. |
liquidationPrice | Estimated (see liquidationPrice). |
leverage | Notional 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.
| Field | Meaning |
|---|---|
maxWithdrawable | The largest amount withdrawCollateral accepts now, from BazaarPairLens.getMaxWithdrawable at the conservative price. Exact for that price. 0 while a freeze applies. |
collateral | The account’s collateral on the pair. |
ledgerAvailable | The pair’s deposits ledger: no withdrawal may exceed it. |
hasPosition, activeOrders | Whether the account holds a position, and how many live orders it has. |
priceUsed | The price the ceiling was computed at; null for an account the pair does not price (flat with no orders, or a terminated pair). |
venueClosed | As in previewOrder. |
freezes | What stops withdrawals now, each a PreviewIssue with until when known. See Withdrawal Freezes. |
amount | The amount asked about, or null. |
ok | Whether that amount goes through; null without an amount. |
blocker | The 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. |
terminal | On 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 field | Meaning |
|---|---|
order | The order, as for previewOrder. |
account, integrator | The trader (to detect self-matches) and the default integrator for the fees. |
bucket | The position as the pair stores it: isLong, size, entryValue, collateral, entryFundingIndex, optionally the TP and SL ids. An account snapshot’s position fits. |
mark | The price the call resolves. |
fundingIndex | The funding index the call will see. A market snapshot’s funding.projectedIndex is the index a price stored now would leave. |
rates | MarginRates: imrBp, mmrBp, laggedMmrBp. |
fees | TakerFeeRates: takerEbp, closingEbp (from takerInsuranceFeeRates, or a full preview’s feeRates). |
continuous | A 24/7 market (true) or one with trading hours. |
stale | The call would run on the stale fallback (venue closed). |
book | The opposite side of the book, best first, as BookMakers (orderId, owner, price, remaining, expiryBlock). Without it, fills are assumed at fillPrice. |
currentBlock | The block the book’s expiries are judged at. |
resting, activeOrders | The account’s resting orders reserved at the mark (restingReserve), and how many it has (for the 32-order cap). |
fillPrice | The assumed fill price without a book, or for a trigger order that has not fired. |
beforeState | The 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
| Helper | What 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, …).
Related Pages
- Placing Orders and Collateral: the calls these preview.
- Accounts & Portfolios: the account’s live health between previews.
- Markets: margin rates, funding and the insurance fund on a market snapshot.
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
| Error | From | Means |
|---|---|---|
InputError | Every builder, buildOrderSpec, parseRelayed | An argument the SDK refused before building or signing anything. field names it. |
DecodedError | Simulation.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. |
CallError | sendCall (bz.send), buildAndSend, waitForReceipt, batching.sendCalls | A send the wallet refused or that reverted, with decoded when the revert data came back. |
SigningError | signTypedDataV4, signPermit, signRelayed | The wallet declined to sign, or returned a signature Bazaar cannot verify (a smart-contract wallet’s, a high-s one). |
ClockSkewError | signPermit, signRelayed, chainDeadline, checkClockSkew | The chain time the RPC reports and the device’s clock disagree by more than 2 minutes, so nothing was signed. A SigningError. |
ConnectError | Bazaar.connect, builders that need a price or the subgraph | Wrong chain, an address that is not a factory, or a connection without the prices or subgraph source the call needs. |
UnitError | parseUnits18, parseDecimal | Text that is not a decimal number, or has too many decimals. |
PriceUpdateError | trade.parsePriceUpdate, livePairPrice | A price update that is not a Pyth Pro update, or one missing a leg of the market. |
ProError | The Pyth Pro client, price sources, livePrices | Pyth 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. |
NoLivePriceError | proPriceSource().latest, LivePrices.latest | A ProError: the endpoint answered, but has no usable live price for a feed asked for (the message says why). |
NoFreshPriceError | Builders 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. |
LazerError | lazer.parseUpdate and the other codec functions, feedIdHex, proFeedId | Bytes that are not a valid Pyth Pro update, or a value that is not a Pyth Pro feed ID. |
RpcError, HttpError, SendError | The JSON-RPC client | The node’s error, an HTTP failure, or a send whose outcome is unknown. See Low-Level Building Blocks. |
errorMessage
errorMessage(e) checks, in order:
- A
DecodedError: itsmessage. - An
InputError: itsmessage(which starts with the field:"amount: The minimum deposit is 5 USDC."). - A
NoFreshPriceError: “No fresh price is available for this market right now, and this action needs one. Try again in a moment.” - An error carrying a
decodedDecodedError(aCallError): that error’s message. - A wallet-side failure (
walletErrorMessage, below). - 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).”
- 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
}
| Member | Meaning |
|---|---|
name | The custom error’s name in the ABI, such as "OrderManagementLib__InsufficientMarginAfterOrder". |
args | Its decoded arguments by name (bigint for numbers). |
message | The user message (with the arguments formatted), else the operator hint, else the name. |
hint | The operator-facing hint from KEEPER_HINTS, written for someone running the keeper, or "". |
data | The 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:
| Data | name | message |
|---|---|---|
| A custom error one of the five ABIs declares | Its name | Its 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.
| Failure | Message |
|---|---|
4001, ACTION_REJECTED, UserRejectedRequestError, “user rejected” | You rejected the request in your wallet. |
| 4100 | Your wallet has not authorized this site for that account. … |
| 4200 | Your wallet does not support this request. |
| 4900 | Your wallet is disconnected. … |
4901, ChainDisconnectedError | Your wallet is not connected to this network. … |
| 4902 | This network has not been added to your wallet yet. … |
| -32002 | Your wallet already has a request waiting. … |
| Insufficient funds | Your wallet does not have enough ETH for gas (plus any ETH the call sends). … |
| Nonce too low | Your wallet’s transaction count is out of date: another transaction already used it. … |
| Chain mismatch | Your 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
requiremessages (Error): “ERC20: transfer amount exceeds allowance” or “… balance”. Check funding first withusdcFunding(Collateral). - The example trading account’s
BazaarTradingAccount__NotOwner(anUnknown(...)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.messageinstead 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, …, withreasons) rather than waiting for a revert (Markets). - Show the sentence, log the detail. Show
explain(e); logString(decoded)ore.causefor 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).
Related Pages
- Sending Transactions: simulate, send,
CallError. - Previews: the same messages, before a call is even built.
- Gasless Trading & Relayers: the
MetaTx__errors and the relayer’s checks.
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.
| Relayable | Not relayable |
|---|---|
depositCollateral, withdrawCollateral, createOrder, cancelOrders, amendOrders, depositToInsurance, requestInsuranceWithdrawal, executeInsuranceWithdrawal | claimFees, 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
- 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 aRelayedRequest. - It posts
relay.serializeRelayed(req)(JSON) to the relayer, at once: the request is valid for at most 10 seconds. - 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. - 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.
- 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:
input | Signs |
|---|---|
{ 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. |
| Option | Default | Meaning |
|---|---|---|
wallet | required | The user’s EIP-1193 provider. |
signer | required | The user’s address. The wallet must sign as it. |
pair | required | The 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). |
relayerFee | 0n | What the relayer charges, BAZAAR precision, at most $1. Floored to micro-USDC before signing, so the signed fee is exactly what is charged. |
deadlineWindow | 10 | Seconds the signature stays valid, at most the deployment’s window (10). |
nonce | read | The nonce to sign. Read from the pair when omitted; pass it to sign several requests in a row. |
permit | none | true, 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:
- Floors the fee, reads the deployment’s fee cap and deadline window from the lens
(
eip712Limits, once per connection), and confirms the pair. - 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). - 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.
- Reads the nonce and stamps the deadline (
chainDeadline). - 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 tosigneris 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),signRelayedthrowsClockSkewErrorand 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.
signRelayedreads it (bz.signing.readMetaTxNonce(pair, user)) unless you passnonce.- 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:
| Action | The signer’s wallet pays (relay.relayedPull) |
|---|---|
depositCollateral, depositToInsurance | The deposit plus the fee. |
createOrder | The fee plus the trigger order’s keeper bounty. |
cancelOrders, amendOrders, requestInsuranceWithdrawal | The fee. |
executeInsuranceWithdrawal | The fee on the first, locking call; nothing on the paying call, whose fee comes out of the payout. |
withdrawCollateral | Nothing: 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);
permit | Meaning |
|---|---|
true | Sign 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:
| Field | Meaning |
|---|---|
ok | Safe to submit now: problems is empty. |
signer | The 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. |
warnings | What 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. |
secondsLeft | Seconds 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), withvalueits Pyth fee, which the relayer pays.createOrderandamendOrdersalways carry one, so the relayer’s connection needs a price source; - fresh
MatchHintsforcreateOrder(oropts.hints). A relayed order always walks the protocol’s default budget. Amendment hints are inside the signed words and cannot change.
| Option | Meaning |
|---|---|
relayer | The relayer’s address, used as the call’s from (so bz.simulate(call) runs as the relayer). Without it from is null. |
hints | MatchHints 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:
| Export | What it is |
|---|---|
TYPE_STRINGS, BAZAAR_TYPES | The 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 see | It 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. |
ClockSkewError | The 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__DeadlineTooFar | A deadline more than 10 seconds ahead of the block. |
MetaTx__InvalidNonce / nonce: ... is already used | Another request from this signer landed first. Sign again. |
nonce: ... is ahead of the signer's next nonce | An earlier request has to land first. |
MetaTx__InvalidSignature | The signature does not match the request (a field changed after signing). |
MetaTx__RelayerFeeExceedsMax | The fee is above the deployment’s cap. |
BazaarPair__AmountLteRelayerFee | A withdrawal’s payout does not cover the fee. |
permit: ... warning | The permit will fail; the call then needs an allowance in place. |
Related Pages
- Collateral, Approvals & Permits: permits in detail.
- Placing Orders and Managing Orders: the actions being signed.
- Errors & Messages: showing these errors.
- Security Notes for Integrators.
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), withbatching.sendCalls; - the example BazaarTradingAccount contract from Bazaar’s repository
(
examples/BazaarTradingAccount.sol), withbatching.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:
withdrawCollateralandclaimFeespay 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 }] }
| Option | Default | Meaning |
|---|---|---|
atomicRequired | true | Require the wallet to run the calls atomically: a Bazaar batch is meant as one. |
capabilities | none | Wallet 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:
| Field | Value |
|---|---|
action | "execute" |
to | The account. |
from | The owner, who sends it. |
value | The 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. |
summary | The inner call’s summary for one call, else "3 calls: ...; ...; ...". |
priceTime | The earliest of the inner calls’ price times. |
pullsUsdc | The 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 see | It means |
|---|---|
CallError from sendCalls | The 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 execute | Most 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__InsufficientMarginAfterOrder | The account’s collateral, after the batch’s earlier deposits, does not cover the order. |
MatchingEngineLib__FillOrKillUnfilled | A fill-or-kill order in the batch could not fill, and the whole batch reverted. |
Related Pages
- Building a Bot: a quoting loop from an EOA.
- Placing Orders and Collateral: the calls in a batch.
- Sending Transactions: one call at a time.
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);
| Option | Default | Meaning |
|---|---|---|
timeoutMs | 30000 | Per-request timeout in milliseconds. |
headers | none | Extra request headers. |
fetch | globalThis.fetch | The 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:
| Kind | Type | Notes |
|---|---|---|
| Prices, sizes, notionals, collateral, fees, PnL, shares, funding indices, bounties | bigint | BAZAAR precision: 1e18 is $1 or one unit. Format with formatUnits18. |
Fields whose names end in Usdc (seedRefundUsdc, seedRefundOwedUsdc) | bigint | USDC units (6 decimals), as the subgraph stores them. |
Rates whose names end in Bp (imrBp, mmrBp, maxSlippageBp, levyBp) | bigint | Basis points. |
Payout ratios (principalRatio, profitRatio) | bigint | 1e18 is 100%. |
| Timestamps | number | Unix seconds. |
| Block numbers | number | Arbitrum L2 blocks: the numbers order expiry uses. |
| Counts | number | |
| Enums (order type, status, cancel reason, …) | string | The contract enum’s name, e.g. "StopLoss", "Canceled", "Margin". |
| Addresses | Address | Checksummed. |
| Other bytes (ids, hashes, feed ids) | Hex | Lowercase. |
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:
| Option | Default | Meaning |
|---|---|---|
first | 100 | Rows per page, 1 to 1000. Anything else throws InputError. |
after | none | The next cursor of the previous page. A cursor from another list throws InputError (“is not a cursor this list returned”). |
atBlock | the subgraph’s head | Read 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:
| Option | Default | Meaning |
|---|---|---|
from | none | Inclusive lower bound on the list’s time field, in Unix seconds. |
to | none | Exclusive 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
| Function | Filters | Order | Returns |
|---|---|---|---|
trades(filter?) | pair, account (as maker or taker), maker, taker, time options | newest first by timestamp | Page<Trade> |
candles(pair, interval, opts?) | time options (bound the period start) | oldest first by periodStart | Page<Candle> |
pairDayData(pair, opts?) | time options (bound the day) | oldest first by date | Page<PairDayData> |
protocolDayData(opts?) | time options | oldest first by date | Page<ProtocolDayData> |
bookLevels(pair, { side, ...page }) | side: "bid" or "ask" | best price first | Page<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
| Function | Filters | Order | Returns |
|---|---|---|---|
orders(filter?) | account, pair, status, openOnly, orderType, integrator, time options (bound the creation time) | newest first by createdTimestamp | Page<HistoryOrder> |
order(pair, orderId, atBlock?) | HistoryOrder | null | ||
orderEvents(filter?) | account, pair, orderId (needs pair), action, time options | newest first by timestamp | Page<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:
| Reason | Meaning |
|---|---|
None | Not canceled. |
Owner | The owner canceled it. |
Expired | It reached its expiry block. |
Unfilled | The book could not fill all of it on arrival; a market or immediate-or-cancel order cancels what is left. |
BelowMinimum | What was left was worth less than the $5 order minimum, so it could not rest. |
PostOnly | It was post-only and would have filled on arrival. |
SelfMatch | It would have traded against another order of the same owner. |
StepLimit | It crossed more resting orders than one transaction may walk; the rest was canceled. |
Margin | The owner’s collateral could not cover the fill (its margin or its fee) when it was reached. |
PriceBand | While the market was closed it would have filled more than 10% away from the last price. |
PositionClosed | The position it protected was closed or flipped. |
Liquidated | The position was liquidated. |
Deleveraged | The 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
| Function | Filters | Order | Returns |
|---|---|---|---|
positions(filter?) | account, pair, openOnly | by id | Page<HistoryPosition> |
positionChanges(filter?) | account, pair, time options | newest first | Page<PositionChange> |
positionSnapshots(filter?) | account, pair, time options | newest first | Page<PositionSnapshot> |
collateralTransfers(filter?) | account, pair, time options | newest first | Page<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
| Function | Filters | Order | Returns |
|---|---|---|---|
feeBalances(filter?) | recipient, pair, claimableOnly | by id | Page<FeeBalance> |
feeAccruals(filter?) | recipient, pair, time options | newest first | Page<FeeMovement> |
feeClaims(filter?) | recipient, pair, time options | newest first | Page<FeeMovement> |
integratorStats(integrator, atBlock?) | IntegratorStats | null | ||
accountStats(account, atBlock?) | AccountStats | null | ||
accountDayData(account, opts?) | time options | oldest first by date | Page<AccountDayData> |
metaTransactions(filter?) | user, relayer, pair, time options | newest first | Page<MetaTransaction> |
permitFailures(filter?) | owner, pair, time options | newest first | Page<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
| Function | Filters | Order | Returns |
|---|---|---|---|
liquidations(filter?) | account (the liquidated trader), liquidator, pair, time options | newest first | Page<Liquidation> |
adlWindows(filter?) | pair, time options (bound the opening time) | newest first by openedAt | Page<AdlWindow> |
adlBatches(filter?) | pair, window, transactionHash, time options | newest first | Page<AdlBatch> |
adlFills(filter?) | account, pair, transactionHash, time options | newest first | Page<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
| Function | Filters | Order | Returns |
|---|---|---|---|
insuranceProviders(filter?) | pair, account, activeOnly | by id | Page<InsuranceProvider> |
insuranceEvents(filter?) | account, pair, kind, time options | newest first | Page<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
| Function | Filters | Order | Returns |
|---|---|---|---|
pairTerminations(filter?) | pair, time options | newest first | Page<PairTermination> |
terminalRatioUpdates(filter?) | pair, time options | newest first | Page<TerminalRatioUpdate> |
terminationProposals(filter?) | pair, proposer, status, time options (bound submission) | newest first by submittedAt | Page<TerminationProposal> |
insurerProposals(filter?) | pair, proposer, status, time options (bound creation) | newest first by createdAt | Page<InsurerProposal> |
insurerVotes(filter?) | proposal, pair, voter, time options | newest first | Page<InsurerVote> |
insurerLevies(filter?) | proposal, pair, voter, time options | newest first | Page<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
| Function | Filters | Order | Returns |
|---|---|---|---|
listingProposals(filter?) | deployer, status, time options (bound the proposal time) | newest first by proposedAt | Page<ListingProposal> |
identifierUpgradeProposals(filter?) | proposer, status, time options | newest first by proposedAt | Page<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:
| Field | Meaning |
|---|---|
indexedBlock | The last block the subgraph has indexed. |
indexedTimestamp | Its timestamp, or null when the indexer does not report one. |
chainBlock, chainTimestamp | The head as the connection’s RPC sees it. |
blocksBehind | Blocks the subgraph trails the head (0 when level, or ahead of this RPC). |
secondsBehind | Seconds behind, or null without indexedTimestamp. |
hasIndexingErrors | The 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
| Error | When |
|---|---|
ConnectError | The connection has no subgraph source. |
InputError | An 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.SubgraphError | The 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.
Related
- Live Updates: the same events as they happen, from the chain.
- Accounts & Portfolios: an account’s state now, at the live price.
- Low-Level Building Blocks:
SubgraphClient, for queries of your own and whole-entity snapshots. - Bazaar’s integrator guide: which events each piece of history comes from.
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
pricesonBazaar.connect, the quotes and the budget throw aConnectErrorunless you pass aprice,executeInsuranceWithdrawalthrows one on a live market because it needs a price update, and the exit plan leaves its estimatesnull. - Units. USDC amounts are 18-decimal
bigints (1e18 = $1), floored to whole micro-USDC by the pair; useparseUnits18("250")andformatUnits18(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 apriceTimeand 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`);
| Function | Returns |
|---|---|
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:
| Field | Meaning |
|---|---|
value | Shares minted (deposit) or USDC paid (withdrawal), 18 decimals. |
price | The mark it was priced at. |
publishTime | The 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.
| Option | Default | Meaning |
|---|---|---|
withPrice | attach when a price source is connected | false 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:
- Request (
requestInsuranceWithdrawal): starts a 13-day cooldown. Moves no USDC. - Lock (the first
executeInsuranceWithdrawalin 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. - 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.
| Constant | Value | Meaning |
|---|---|---|
INSURANCE_WITHDRAWAL_COOLDOWN | 13 days | From the request to the window. |
INSURANCE_WITHDRAWAL_WINDOW_CONTINUOUS | 2 days | The window on a 24/7 market. |
INSURANCE_WITHDRAWAL_WINDOW_SESSION | 4 days | The window on a market with trading hours. |
INSURANCE_WITHDRAWAL_CLAIM_DELAY | 300 s | From the lock (and from the last 2% jump) to the paying call. |
INSURANCE_WITHDRAWAL_REOPEN_CLAIM_DELAY | 1,800 s | From the end of a price gap to the paying call. |
REOPEN_GAP_MIN_STALENESS | 3,600 s | A 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:
| Step | Meaning | nextAction |
|---|---|---|
none | No shares and no request. | null |
request | No request yet, or only a voided one (see Share Epochs). On a terminated market the redemption then pays at once. | requestInsuranceWithdrawal |
cooldown | Requested; the window opens at nextCallAt. | executeInsuranceWithdrawal |
lock | The window is open; the next execute locks and pays nothing. | executeInsuranceWithdrawal |
waitClaim | Locked; the paying call opens at nextCallAt. | executeInsuranceWithdrawal |
claim | Locked and open: the next execute pays. | executeInsuranceWithdrawal |
redeem | Terminated market: the next execute pays. | executeInsuranceWithdrawal |
expired | The 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 |
blocked | No 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:
| Field | Meaning |
|---|---|
message | One line for the LP, with the waits written out (“9 d 4 h”). |
nextAction | The function to call next, or null. |
nextCallAt | The earliest chain time the next call can land (now when it can land now), or null. |
nextCallBy | The 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. |
now | The chain time of this reading. |
shares, requestedShares | The LP’s shares, and those in the live request (0 without one). |
lockedForVoting | Shares committed to insurer votes, which the exit cannot take. |
requestTs, windowOpensTs, windowEndTs | The request and its window, or null. |
lockTs, lockedAmount, claimOpensTs | The lock, once made. For the lock step claimOpensTs is when a lock made now would open its claim. |
payoutEstimate | For 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). |
budgetRemaining | The shared budget left, with a price source; null when unlimited or unknown. |
notes | What 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.
| Field | Meaning |
|---|---|
remaining | What the current period still admits, 18 decimals. |
cap | The period’s budget. |
resetsAt | When the period ends and the budget refills; null when unlimited. |
unlimited | No limit applies: the market is terminated or has no open interest. |
coverageRatioBp | Free capital over open-interest notional, in basis points. |
targetRatioBp | The live target; below it the stricter budget applies. |
price | The 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:
| Error | Meaning |
|---|---|
InsuranceVaultLib__CooldownNotElapsed | The 13-day cooldown has not ended. |
InsuranceVaultLib__ClaimNotOpen | Locked; the paying call opens at claimOpensTs. |
InsuranceVaultLib__LockTooLate | The claim a lock would open could only open after the window closes. Request again. |
InsuranceVaultLib__WithdrawalWindowExpired | The window closed. Request again. |
InsuranceVaultLib__NoWithdrawalRequest | No request to execute. |
InsuranceVaultLib__WithdrawalRequestStaleEpoch | The request predates a fund recapitalization. |
InsuranceVaultLib__RateLimitExceeded | The shared 6-hour budget is used up. Withdraw less or wait for resetsAt. |
InsuranceVaultLib__AdlBlocking | The fund’s headroom cannot pay any of the claim (ADL due or running). |
InsuranceVaultLib__VaultUnhealthy | Exits are paused while the market is unhealthy. |
InsuranceVaultLib__SharesLockedForVoting | Shares in the request back an open termination vote. |
InsuranceVaultLib__SweepIncomplete | After an escape-hatch finalize, positions are still being settled. |
InsuranceVaultLib__PairScheduledForTermination | Within 6 seconds of a scheduled cutoff, or settling. |
InsuranceVaultLib__TooManyRecentInsuranceDeposits | More than 100 deposits in four days. |
InsuranceVaultLib__DepositTooSmall | The deposit buys no shares. |
BazaarPair__InsufficientPythFee | The 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.
Related
- Termination & Settlement: insurer votes, and what a terminated market pays.
- Markets: the fund’s balance, target and coverage in a market snapshot.
- Collateral, Approvals & Permits: approving USDC, or signing a permit.
- Insurance Fund in Bazaar’s docs.
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.
| Path | Who starts it | SDK |
|---|---|---|
| UMA claim | Anyone, with a USDC bond (at least 1,000 USDC) and a 12-hour UMA liveness | proposeTermination, settleTerminationProposal |
| Insurer vote | Insurers whose matured shares reach 60% of the vote’s snapshot | proposeInsurerTermination, voteForInsurerTermination, executeInsurerTermination |
| Stale oracle | Anyone, once the market has stored no price for 21 days | terminateStalePair |
| Autonomous | The pair’s own insolvency checks | nothing to call |
- 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. - Fixing the price. After the cutoff, anyone calls
terminateScheduledPairwith 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. - Settling. In the window every open position is settled at the fixed price (
liquidateon the pair, permissionless, tipped $0.05 per position settled for someone else). - 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:
| Field | Meaning |
|---|---|
costs | Every bond, fee, levy and share lock the call takes or puts at risk, each a Cost. Empty for a call that costs only gas. |
approvals | ERC-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. |
requires | Preconditions in words for the UI to show: an allowance to send first, a balance short of the bond, a cap the contract enforces. |
notes | What will happen, in plain language, and warnings. |
A Cost:
| Field | Meaning |
|---|---|
kind | "bond", "seed", "fee", "levy" or "lock". |
label | What it is, in a trader’s words (“UMA termination bond”). |
amount | In 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). |
token | The ERC-20 it is paid in; null for ETH and shares. |
decimals | For "token": the token’s decimals. |
payee | Who takes or holds it (the contract pulling a bond, the oracle), or null when nothing moves now. |
returned | When and how it comes back, if ever. |
contingent | true 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:
| Phase | Meaning |
|---|---|
live | No cutoff scheduled. |
scheduled | A cutoff is scheduled; trading continues until 6 seconds before it. |
limbo | Within 6 seconds of the cutoff, or past it, with no settlement price yet: nothing trades, deposits or withdraws until terminateScheduledPair fixes the price. |
settling | The settlement price is fixed; positions settle at it until finalizeTermination. |
terminated | Finalized: withdrawals pay at the frozen ratios. |
| Field | Meaning |
|---|---|
now | The chain time the status was judged at. |
scheduledTerminationTs, haltsAt, cutoffPassed | The cutoff, when trading halts (the cutoff less 6 s), and whether it has passed. |
settlementPriceFixedTs, settlementPrice | When the price was fixed and the price (18 decimals), or null. |
isPairTerminated | Finalized. |
settlementRule | How terminateScheduledPair would settle now (see below). |
acceptedTerminationAssertionId | The accepted UMA claim whose cutoff is in force, or null. |
umaCutoffCap | While an insurer vote holds the schedule: the UMA cutoff that caps its settlement samples, or null. |
insurerScheduled, insurerScheduleId | Whether 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. |
finalizeOpensAt | When finalizeTermination can be called: now once every position is settled, else 14 days after the fix; null before the fix and after finalize. |
openProposals | Unresolved 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:
source | When |
|---|---|
"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:
| Function | Returns |
|---|---|
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.
| Argument | Meaning |
|---|---|
pair | The market. |
category | One of the four grounds, 1 to 4 (termination.TerminationCategory). |
lastTradingTs | The proposed cutoff, unix seconds. |
reason | The grounds and evidence, 1 to 1,000 characters of a restricted charset. |
from | The proposer, who posts the bond. |
slackSeconds | How 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):
| Value | Name | Grounds |
|---|---|---|
1 OracleDecommission | Oracle decommission | The market’s Pyth Pro feed (either leg of a composite) has stopped or is scheduled to stop publishing. |
2 AssetCessation | Asset cessation | The asset has been or will be delisted (delisting, merger, bankruptcy, an indefinite halt), once officially confirmed with a fixed date. |
3 StructuralChange | Structural change | An officially scheduled event breaks the price series: a split, spinoff, rights offering, special distribution over 5%, redenomination, index re-basing. |
4 PythVerifierMigration | Pyth verifier migration | The 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 vote | Nothing; it settles under UMA’s rule. |
false: UMA rejected the claim | The 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 claim | The 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:
outcome | When | A bare vote’s bond |
|---|---|---|
schedule | At or above 60% before the latch closes: the market is scheduled to stop at the cutoff, or its schedule pulled earlier. | Refunded |
preempted | The price is already fixed, or a cutoff at or before this one is already scheduled. | Refunded |
voided | The fund was drained and recapitalized since the snapshot. | Refunded |
failedRefund | The latch closed (one hour before the cutoff) without scheduling, having reached 60%. | Refunded |
failedForfeit | The 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:
state | Meaning |
|---|---|
upcoming | Its window has not opened (a claim filed more than 14 days before its cutoff). |
voting | Open for votes and, once at the threshold, for execution. |
expired | The latch closed without the vote scheduling the market; executing it now resolves it as failed. |
scheduled | It reached the threshold and set the market’s cutoff. |
rejected | UMA rejected the claim behind it before it scheduled anything. |
ended | Resolved 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:
| Field | Meaning |
|---|---|
rule, key | 0 nothing, 1 tick, 2 chain, 3 awaitVerdict. |
meaning | A sentence for the rule (termination.SETTLEMENT_RULE_TEXT). |
firstTarget | The cutoff: the second whose update settles the market under UMA’s rule, the first sample’s target under the chain rule. |
latestPublishTs | Under UMA’s rule the cutoff again; under the chain rule the latest second a sample’s update may be stamped with, null when unbounded. |
lastResortTs | After this a call with no updates settles at the stored price; null when not applicable. |
verdictDeadline | While waiting for a verdict: when the vote stops waiting and settles unjudged. |
and builds the set the rule needs:
| Rule | Updates | Fee |
|---|---|---|
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) |
chain | Five 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) |
awaitVerdict | Nothing yet: the vote that set the cutoff waits for UMA’s verdict on its claim. | |
nothing | Nothing 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:
storedPrice | The 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:
| Field | Meaning |
|---|---|
call | The 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". |
updates | The call’s priceUpdate. |
prints | The 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. |
settlementPrice | The price the call fixes (18 decimals). |
attempts | Each 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. |
reason | Why there is no call. |
retryAt | When to try again, when known (the verdict, the fallback). |
storedPriceWithheld | Why the stored-price call is not offered although its last resort is open; null when it is offered or not open. |
rule, scheduledTerminationTs, now | What 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:
| Field | Meaning |
|---|---|
collateral | The account’s collateral. |
registeredClaim | Its profit claim (a winner settled before finalize). |
principalRatio, profitRatio | Cash per unit of collateral and of claim (1e18 = 100%); 0 before finalize. |
payout | collateral × principal ratio + claim × profit ratio: what withdrawing everything pays now. |
finalized | true 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
| Constant | Value |
|---|---|
TERMINATION_PROPOSAL_BOND_USDC | 1,000 USDC (the floor; the bond posted is requiredTerminationBond()) |
TERMINATION_PROPOSAL_LIVENESS | 12 hours |
MAX_REASON_LENGTH | 1,000 |
MIN_CUTOFF_LEAD | 7 s (a late acceptance’s cutoff) |
INSURER_TERMINATION_MIN_LEAD | 2 hours |
INSURER_TERMINATION_LATCH_LEAD | 1 hour |
INSURER_TERMINATION_MAX_LEAD | 14 days |
INSURER_TERMINATION_THRESHOLD_BP | 6,000 (60%) |
INSURER_TERMINATION_BOND_USDC | 1,000 USDC |
INSURER_SHARE_MATURITY_PERIOD | 4 days |
INSURER_MAX_LIVE_VOTES | 8 |
INSURER_LEVY_BP, INSURER_LEVY_FLOOR_BP | 5,000 and 1,000 |
CLAIM_VERDICT_GRACE | 14 days |
INSURER_POST_FIX_LOCK | 13 days |
SCHEDULED_TERMINATION_GRACE | 12 hours |
NEVER_PRICED_SETTLEMENT_PRICE | 1, 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:
| Error | Meaning |
|---|---|
BazaarPairTerminator__LastTradingTsTooSoon, InsurerCutoffTooSoon, InsurerCutoffTooLate | The cutoff is outside its bounds; the time moved on while the user decided. Rebuild. |
BazaarPairTerminator__CutoffNotEarlier, InsurerCutoffNotEarlier | An earlier or equal cutoff is already in force. |
BazaarPairTerminator__UmaIdentifierNotLive | UMA claims are closed until the identifier upgrade. |
BazaarPairTerminator__InsurerNoMatureShares, InsurerInsufficientUnlockedShares | Not enough shares held 4 days before the window opened. |
BazaarPairTerminator__InsurerThresholdNotMet | The vote is below 60% and still open. |
BazaarPairTerminator__InsurerTooManyLiveVotes | The wallet backs 8 live votes on this market. |
BazaarPairTerminator__InsurerVotingClosed, InsurerVoteNotOpen | Outside the vote’s window. |
BazaarPairTerminator__InsurerVerdictPending | The claim behind the vote has no verdict yet. |
BazaarPairTerminator__OracleNotStaleEnough | The market stored a price within 21 days. |
BazaarPairTerminator__TerminationTimeNotReached | The cutoff has not passed. |
BazaarPairTerminator__InsufficientPythFee | Not enough ETH for the updates’ fee. Rebuild. |
BazaarOracle__SampleCountMismatch | The chain set does not hold one update per leg for each of five samples. |
Related
- Insurance: LP shares, which vote, and redeeming after termination.
- UMA Disputes & Governance: disputing a claim, previewing a ruling, the identifier upgrade.
- Listing Markets: the other UMA flow, with the same
CostedCallshape. - Termination & Settlement in Bazaar’s docs.
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
- 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. - Propose it (
proposePairDeployment). The factory pullstotalAmountUSDC 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. - For 48 hours anyone may dispute the claim (
uma.disputeAssertion, see Disputing an Assertion). - 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.
- A seed credited back is collected with
claimSeedRefund. - 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:
| Field | Unit | Value | Meaning |
|---|---|---|---|
minDeploymentAmount | 18 decimals | $4,000 | The least totalAmount a listing takes. |
deploymentBondUsdc | USDC, 6 decimals | 1,000 USDC | The bond’s floor. |
requiredDeploymentBondUsdc | USDC, 6 decimals | live | The bond a listing posts now: the larger of the floor and UMA’s minimum bond for USDC. |
deploymentLiveness | seconds | 48 hours | UMA’s liveness: how long the claim can be disputed. |
dvmDisputeGrace | seconds | 14 days | Extra wait before a disputed listing that cannot settle may be released. |
minInsuranceSeed | 18 decimals | $3,000 | The least seed left after the bond. Not exposed on chain; the SDK’s constant. |
maxDescriptionLength | bytes | 200 | The 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.
| Function | What 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
}
| Argument | Type | Meaning |
|---|---|---|
baseFeedId | number | string | Hex | A 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. |
isContinuouslyTraded | boolean | true 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). |
totalAmount | bigint, 18 decimals | The 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. |
description | string | What the asset is, e.g. "AAPL on NASDAQ". 1 to 200 bytes of the allowed characters. |
from | Address | The proposer: who pays, who is the UMA asserter, and who receives the seed as insurance shares. |
registry | URL, source or null | Where 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:
kind | label | Comes back |
|---|---|---|
bond | UMA listing bond | Returned by UMA when the listing is approved (deployed or voided); paid to the disputer if UMA’s voters reject it. |
seed | Insurance seed | Approved, 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.
| Field | Why |
|---|---|
baseFeedId | Not a feed number or 32 bytes of hex, or zero. |
totalAmount | Not 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). |
description | Fails descriptionProblem. |
isContinuouslyTraded | true for a composite. |
baseFeedId | Neither 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. |
baseFeedId | Pyth Pro’s symbols registry lists no feed with this ID. |
baseFeedId | UMA’s identifier is off its live whitelist: listings are closed until Bazaar’s identifier upgrade completes. |
baseFeedId | A live market for this feed or this asset already exists (the message names its address). A terminated market does not block. |
baseFeedId | Another 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. |
from | Not 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.
| Field | Meaning |
|---|---|
assertionId | The UMA assertion id. |
deployer | The proposer. |
baseFeedId, pairId | The feed (or composite) listed, and the pair id the market will have (the same value). |
isContinuouslyTraded | The flag proposed. |
seedAmount, seedAmountUsdc | The escrowed seed, in 18 decimals and in USDC units. |
description | The proposer’s text. Render it as text. |
proposalTs, livenessEndsAt | When it was proposed, and when UMA’s liveness ends (proposal time plus 48 hours). |
resolved, deployed, disputed | The factory’s flags. |
status | One of the statuses below. |
releaseOpensAt | When expireStuckDeploymentProposal may release it: the end of liveness, plus 14 days if disputed. |
status | Meaning |
|---|---|
inReview | In UMA’s liveness: disputable until it ends. |
settleable | Past liveness without a dispute: settling it deploys the market (or voids it, if UMA has since removed its identifier). |
disputed | UMA’s voters decide it. Use uma.previewSettlement to see whether they have ruled. |
deployed | The market exists. |
notDeployed | Rejected, 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:
source | When |
|---|---|
"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;
}
| Event | Meaning |
|---|---|
PairDeploymentProposed | A listing was proposed: its UMA liveness started and its seed is escrowed. |
PairDeploymentProposalDisputed | The listing was disputed: UMA’s voters will decide it. |
PairDeploymentProposalResolved | UMA resolved the listing: approved and deployed, or rejected with the seed credited back. |
PairDeployed | The market was deployed and seeded with its insurance fund. |
PairDeploymentProposalExpired | A disputed listing that could not settle was released; its seed was credited back. |
PairDeploymentProposalVoided | Approved without a dispute, but its identifier has since left UMA’s whitelist: released instead of deployed. |
SeedRefundCredited | A listing’s seed was credited back to its proposer. |
SeedRefundClaimed | A 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.
| Field | Meaning |
|---|---|
costs | Every bond, seed, fee, levy and lock the call posts, each a Cost. Empty for calls that cost only gas. |
approvals | ERC-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. |
requires | Preconditions in words: an allowance to send, a balance to top up. |
notes | What 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, ...):
| Function | Returns |
|---|---|
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.
Related
- UMA Disputes & Governance: disputing a listing, previewing a DVM ruling, the identifier that listings are asserted under.
- Insurance: the seed becomes insurance shares.
- Collateral, Approvals & Permits: USDC approvals.
- Bazaar’s Markets & Listing: the listing criteria UMA’s verifiers check.
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:
| Flow | Made by | Bond floor | Liveness | Page |
|---|---|---|---|---|
| Listing a market | the factory | 1,000 USDC | 48 hours | Listing Markets |
| Upgrading the UMA identifier | the factory | 5,000 USDC | 2 days | Identifier Upgrades, below |
| Terminating a market | the terminator | 1,000 USDC | see the termination page | Termination & 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:
| Field | Meaning |
|---|---|
phase | Where the assertion stands (below). |
assertion | The oracle’s record (UmaAssertion), or null when the oracle has no such assertion. |
canSettleAt | When it can be settled: its expirationTime while in liveness, now when settleable, null when disputed or resolved. |
disputableUntil | The last second a dispute is accepted (expirationTime - 1), or null when it can no longer be disputed. |
now | The chain time the status was judged at. |
phase | Meaning |
|---|---|
notFound | No such assertion on this oracle. |
liveness | Undisputed and inside its liveness: disputable until expirationTime, settleable from it. |
settleable | Undisputed and past its liveness: it can only resolve true, and anyone may settle it now. |
disputed | Waiting on UMA’s voters. It settles once they have ruled, which this view cannot see; use previewSettlement. |
settledTrue | Settled: UMA accepted the claim. |
settledFalse | Settled: 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:
| Argument | Type | Meaning |
|---|---|---|
assertionId | Hex | The assertion to dispute. |
from | Address | Who sends the call and pays the bond. |
disputer | Address, optional | Who is paid if the dispute wins. Defaults to from; cannot be the zero address. |
home | "factory" | "terminator", optional | Which contract made the assertion. Default "factory". |
oo | Address, optional | The 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
| Field | Why |
|---|---|
assertionId | Not 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, disputer | Not a valid address, or a zero disputer. |
oo | Neither 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:
| Field | Meaning |
|---|---|
settleable | true when a settle would go through now. |
result | The verdict a settle would apply (true: the claim is accepted), or null when it cannot settle yet. |
error | Why 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
| Function | Returns |
|---|---|
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:
- 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 namedASSERT_TRUTH…. - For 2 days anyone may dispute it.
- 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). - 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:
| Field | Meaning |
|---|---|
now | The chain time it was read at. |
umaIdentifier, umaIdentifierText | The identifier in force, as bytes32 and as text (null if not printable ASCII). |
umaIdentifierIsLive | Whether it is on UMA’s live whitelist. |
pending | The proposal in UMA’s review, or null: an IdentifierUpgradeProposalInfo plus assertion, its assertion status. |
queued | The approved upgrade waiting out its timelock, or null (below). |
constants | The 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:
| Field | Meaning |
|---|---|
assertionId, newIdentifier, newIdentifierText | The approved proposal. |
effectiveTs | When the full 7-day timelock ends. |
approvedAt | effectiveTs less 7 days. |
activationOpensAt | effectiveTs, 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. |
canActivate | Whether now has reached activationOpensAt. |
newIdentifierWhitelisted | Whether 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:
| Field | Value | Meaning |
|---|---|---|
identifierUpgradeBondUsdc | 5,000 USDC | The bond’s floor (6 decimals). |
requiredIdentifierUpgradeBondUsdc | live | The bond a proposal posts now: the larger of the floor and UMA’s minimum. Also requiredIdentifierUpgradeBond(). |
identifierUpgradeLiveness | 2 days | UMA’s liveness, in seconds. |
identifierUpgradeTimelock | 7 days | From approval to activation. |
identifierUpgradeMinNotice | 4 days | From approval to activation once the incumbent is off UMA’s whitelist. |
dvmDisputeGrace | 14 days | Extra 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
| Helper | What 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.
| Error | Where | Meaning |
|---|---|---|
InputError | every builder | An argument or a state the SDK checked first. |
uma.BondQuoteError | disputeAssertion, proposeUmaIdentifierUpgrade, proposePairDeployment | The node reported a bond below the contract’s own floor. Check the RPC connection. |
uma.ReadError | any read | A 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:
| Function | What 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.
Related
- Listing Markets: the listing flow, and the
CostedCallshape. - Termination & Settlement: UMA termination claims and insurer votes.
- Sending Transactions: simulating and sending a
PreparedCall. - Bazaar’s Markets & Listing: why the identifier is the one governed parameter, degraded mode, and the release rules.
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
| Part | Job |
|---|---|
| Configuration | Every number the strategy uses, from the environment. |
| Connection and Wallet | Reads through a rate-limited RPC client, a local key for sending, one price stream. |
| Account State | The bot’s position and orders, kept current by a watcher. |
| Acting | Build, simulate, send, wait: one transaction at a time. |
| Collateral | Top the account up to a target on start. |
| The Strategy | Where the quotes should be, and the calls that put them there. |
| The Loop | Requote on price moves, and on a timer. |
| Shutdown | Cancel 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.
JsonRpcis 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 astransportcreates one with the defaults.chainIdmakesconnectrefuse 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 theProClienttakes a constructor (WebSocket, thewspackage’s).live.price(market)reads it from memory:spot,low,high,publishTime,age, andfresh(inside the pair’s 3-second window and its confidence cap). See Live Prices. - Prices for calls. A
LivePricesis also a price source.Bazaar.from({ ...base.ctx, prices: live })makes the builders take their price updates from the stream (a price generated at mostreuseAge, 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.
buildruns 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.
actwaits 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’snonceManagerhands them out locally in case the bot sends more than one at a time (the shutdown path, for example). - Errors. An
InputErrormeans 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 witherrorMessage(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:
- Targets. A bid
SPREAD_BPbelow the live price and an ask the same distance above, snapped to the grid. A side that would grow the net position pastMAX_POSITIONgets size 0. - 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
cancelOrderscall takes up to 200 ids. - Amend the resting quotes to the new prices in one
amendOrderscall. Each amendment sets the new total size, sofilled + sizerestores a partly filled quote to full size.bestEffort: truemeans 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. - 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:
| Provider | Load | Control |
|---|---|---|
| RPC reads | The 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 sends | At most a few transactions per round. | The requote threshold and throttle. |
| Pyth Pro | One 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:andskippedlines), on the position reaching its limit, and on the market’s status leavinglive. - 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"));
Related Pages
- Sending Transactions: the price window, and sending with a local key.
- Live Prices and Live Updates: the stream and the watcher.
- Smart Accounts & Batching: quoting several levels in one transaction from a contract account.
- The Keeper as a Library: the keeper’s own long-running service, for comparison.
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
| Method | Returns | Notes |
|---|---|---|
request(method, params?, via?) | the JSON-RPC result | Any 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") | Block | number, timestamp, hash, parentHash, baseFee (bigint, 0 when absent). at is a number or "latest", "pending", "safe", "finalized", "earliest". |
code(address, at = "latest") | Hex | An earlier block needs a node that keeps historical state. |
balance(address) | bigint | Wei, at the head. |
nonce(address, at = "pending") | number | |
gasPrice() | bigint | |
call(to, data, { from, value, block, overrides }) | Hex | One 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() | boolean | Whether eth_call honours state overrides here; asked once and cached in overridesSupported. |
probe(sender, target, data, value, lens, pair) | ProbeResult | Simulate 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) | Hex | Submit a signed transaction exactly once (below). |
receipt(hash) | Receipt | null | null 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:
- the probe’s
aggregate, placed atPROBE_ADDRESSby aneth_callstate override,chunkreads per call, when the node honours overrides; - Multicall3 (
0xcA11bde05977b3631167028862bE2a173976CA11)aggregate3, when it is deployed; - 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:
| Field | Meaning |
|---|---|
ok, ret | Whether the call succeeded, and its return or revert data (decode it with decodeError). |
claimBefore, claimAfter | The probe’s claimable balance on pair (through the lens) before and after. |
reward | claimAfter - claimBefore, floored at 0: exactly what the call would earn, in BAZAAR precision. |
gasUsed | Gas the call used. |
adlPendingAfter, settlementFixedAfter | The 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.
| Outcome | Result |
|---|---|
| The node answered with the same hash | returns 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 transport | returns 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 found | RpcError, 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” failed | SendError 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.
| Option | Default | Meaning |
|---|---|---|
perSecond | 25 | Requests per second this client allows itself; concurrent callers queue in call order. |
timeoutMs | 30000 | Per-request timeout. |
retries | 3 | Retries after a failed request (HTTP error, connection error, timeout, unreadable answer), with delays doubling from 500 ms. Never for the methods in NEVER_RETRIED. |
headers | none | Sent with every request. A name or value HTTP cannot carry (a line break, NUL, …) throws TypeError at construction, naming the header, never its value. |
fetch | globalThis.fetch | |
maxResponseBytes | 64 MiB | The 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.
| Error | When |
|---|---|
RpcError | The 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. |
HttpError | A non-2xx answer, after the retries: status; the message carries the redacted URL, and 401/403 add “check the key in the URL”. |
ResponseTooLargeError | The body was over maxResponseBytes: limit. |
Error | Anything 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.
| Member | Meaning |
|---|---|
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, errors | The 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 data | name | args |
|---|---|---|
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 declares | its name, e.g. BazaarPair__TradingHalted | its arguments by name (arg0, … when unnamed) |
| Anything else | Unknown(0x12345678) | {} |
| Member | Meaning |
|---|---|
message | The text for an end user: the registered user message (from Errors & Messages), else the operator hint, else the name. |
hint | The 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. |
data | The 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”).
| Method | Returns |
|---|---|
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:
| Status | When |
|---|---|
terminated | The pair is terminated. |
settling | Its settlement price is fixed, not yet finalized. |
limbo | A cutoff is scheduled and now + LIMBO_LEAD (6 s) has reached it: trading has halted, the price is not yet fixed. |
adl | An ADL window is open: matching halts, liquidation and ADL run. |
live | None 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.
| Function | Port of | Returns |
|---|---|---|
bucketState(isLong, size, entryValue, collateral, entryFundingIndex, price, fundingIndex, mmrBp) | BucketLib.calculateState | BucketState: unrealizedPnl, fundingPnl, totalPnl, currentNotional, minRequiredCollateral, availableEquity, equity, isSolvent. mmrBp must already be the effective rate. |
effectiveMmrBp(mmrBp, laggedMmrBp) | BucketLib.effectiveMmrBp | The 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._triggerReached | Whether a trigger order fires at price (inclusive). |
adlThreshold(auctionStartTs, now) | AdlLib._getAdlScoreThreshold | The ADL score threshold, decaying quadratically from 25e18 to 1 over 10 minutes. |
adlScore(totalPnl, collateral, windowDeposit) | AdlLib._scoreCollateral | A winner’s ADL score. |
refreshBountyGap, refreshBountyDue, refreshBountyDueAt | RiskParamsLib.payRefreshBounty | When refreshPrice pays its bounty. |
convertExponent(value, fromExpo, toExpo = -18) | BazaarMathLib.convertExponent | A Pyth price rescaled; RangeError past MAX_EXPONENT_SHIFT (77). |
pairPrice(legs, invertQuote) | BazaarOracle | PairPrice (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, signedMulDiv | OpenZeppelin Math.mulDiv, BazaarMathLib.signedMulDiv | Floor, 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.
| Option | Default | Meaning |
|---|---|---|
timeoutMs | 30000 | Per request. |
fetch | globalThis.fetch | |
maxResponseBytes | 16 MiB | The most bytes one answer may hold. |
maxPages | 1000 | The most pages pages reads for one entity (a million rows). |
| Method | Returns |
|---|---|
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.
| Method | Returns |
|---|---|
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. |
displayUrl | The 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.
| Option | Default | Meaning |
|---|---|---|
stream | true | Follow the stream, falling back to polling when it fails. |
streamIdleTimeout | 10 | Seconds without a new update before the stream is dropped and reconnected; null or 0 waits forever. |
pollInterval | 1 | Seconds between polls when not streaming. |
clock | the system clock | The time freshness is judged at, in Unix seconds. |
sendMargin | 1 | A price must be this many seconds younger than the 3 s limit to count as fresh. |
maxFutureSkew | 5 | Seconds a print may be dated ahead of clock before it is ignored (with one warning per feed). |
ethUsdMaxAge | 60 | Seconds after which ethUsd() stops trusting the ETH/USD print. |
ethUsdBounds | [100, 1000000] | The ETH/USD prices ethUsd() accepts. |
logger | console | Where endpoint failures are reported; null for silence. |
| Member | Meaning |
|---|---|
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, lastUpdate | The 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.
Related
- Building a Bot: these pieces put together.
- The Keeper as a Library: the keeper’s engine on top of them.
- Errors & Messages:
errorMessageand the user messages behindDecodedError.message. - Units & Constants: the constants the formulas use.
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.tradesisimport { history } from "@liberta-labs/bazaar-sdk", thenhistory.trades(ctx, ...). The link goes to the page that explains it. - Kind is what the export is.
const + typeis a value and a type of the same name (OrderType,Status, the keeper’s zod schemas). bz marks a function that is also on theBazaarobject with the connection bound:bz.history.trades(...)ishistory.trades(bz.ctx, ...). - Amounts are
bigintin 18 decimals (BAZAAR precision) unless a name says USDC (6 decimals); times, blocks and counts arenumber. 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:
| Member | What 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.ctx | The BazaarContext: chain, chain id, deployment addresses, price source, subgraph, integrator. |
bz.chainId | The 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.uma | Each 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
| Export | Kind | What it is |
|---|---|---|
Bazaar | class | The SDK bound to one connection: Bazaar.connect(options), then bz.<area>.<function>(...) with the context already passed. |
BazaarOptions | interface | What Bazaar.connect takes: ConnectOptions, with prices also accepted as Pyth Pro client options or a ProClient, and subgraph as a URL or { url, apiKey }. |
requirePair | function | pair, checksummed, once the context’s factory confirms it is one of its pairs (factory.isPair); an InputError on pair otherwise. |
NetworkPreset | interface | A built-in network: name, chain id, factory (null until launch), start block, subgraph URL, whether official, and the sequencer URL. |
NETWORKS | const | The built-in networks: arbitrum-one, arbitrum-sepolia and anvil. |
PRICE_ENDPOINTS | const | prices.endpoint shortcuts, each a set of Pyth Pro endpoints (or null until the service exists). |
ProEndpoints | interface | Where Pyth Pro is served: its stream endpoints, its request API, and its symbols registry. |
PYTH_PRO | const | Pyth’s own Pyth Pro endpoints. |
PYTH_PRO_TOKEN_URL | const | Where a Pyth Pro API key is exchanged for a short-lived token. |
connect | function | Read the deployment’s wiring from its factory and return a context bound to it. |
requirePrices | function | The context’s price source; throws ConnectError when the context was connected without one. |
requireSubgraph | function | The context’s subgraph source; throws ConnectError when the context was connected without one. |
PriceUpdateSource | interface | Signed Pyth Pro price updates, as the pair’s calls carry them: latest, and optionally atTick and useSignerCheck. |
verifierSignerCheck | function | A SignerCheck that asks a deployment’s Pyth verifier whether it trusts a signer, keeping each answer briefly. |
VerifierSignerCheckOptions | interface | Options of verifierSignerCheck: ttl, maxAge and clock. |
VERIFIER_SIGNER_ABI | const | The verifier’s isValidSigner view. |
SubgraphSource | interface | A GraphQL endpoint serving Bazaar’s subgraph. |
BazaarContext | interface | A connection: the Chain, the chain id, the deployment’s addresses (factory, lens, terminator, oracle, USDC), and the optional price source, subgraph and integrator. |
ConnectOptions | interface | What connect takes: transport, factory, and the optional prices, subgraph, integrator, chainId and checkSigners. |
ConnectError | class | Connecting failed, or a call needs a price source or subgraph the context was connected without. |
Calls, Price Updates and Units
| Export | Kind | What it is |
|---|---|---|
simulate | function | Run the call as from (or call.from) without sending it. |
toTransactionRequest | function | The call as a viem / wagmi transaction request. |
sendCall | function | Send through an EIP-1193 wallet (eth_sendTransaction). |
waitForReceipt | function | Poll for the receipt; throws CallError if it reverted or did not land in time. |
buildAndSend | function | Build, 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. |
PreparedCall | interface | A write ready to simulate and send: action, to, data, value, summary, pullsUsdc and the rest. |
Simulation | interface | What simulate returns: ok, the return data, the decoded error (with its user message) and the gas estimate. |
InputError | class | An argument a builder refused before building anything: field names it. |
CallError | class | A send that reverted or was rejected, with the decoded reason when there is one. |
pairFeeds | function | The pair’s feeds. |
priceUpdateFor | function | The freshest signed update for the pair’s feeds, and the ETH fee the oracle charges to verify it. |
updateFee | function | The ETH fee the oracle charges to verify a price update (getUpdateFee); 0 for an empty update. |
PairFeeds | interface | A pair’s price id and its raw Pyth legs (one, or base then quote for a composite) and whether the quote is inverted. |
FreshUpdate | interface | What priceUpdateFor returns: the update bytes, the Pyth fee to send as value, and the oldest publish time among the legs. |
toUsdcUnits | function | BAZAAR precision -> USDC units, floored as the pair floors it. |
fromUsdcUnits | function | USDC units -> BAZAAR precision. |
toUsdcUnitsCeil | function | BAZAAR precision rounded up to the next whole USDC unit (for allowances and permits that must cover it). |
floorToUsdc | function | Floor to what the pair actually moves: a multiple of 1e12 (one micro-USDC). |
parseDecimal | function | A decimal string (“12.5”, “1e-3” is refused) -> fixed point with decimals (default 18). |
formatDecimal | function | Fixed point -> decimal string, trimming trailing zeros; maxDecimals rounds toward zero. |
UnitError | class | A decimal string or fixed-point value that cannot be converted (bad syntax, too many decimals). |
parseUnits18 | const | “12.34” dollars or units -> BAZAAR precision. |
formatUnits18 | const | BAZAAR precision -> “12.34”. |
bpToFraction | const | Basis points -> a fraction as a number (for display only). |
toNumber18 | const | A 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.
| Export | Kind | What it is |
|---|---|---|
errorMessage | function | The text to show a user for any error a Bazaar call can throw. |
installUserMessages | function | Register USER_MESSAGES so DecodedError.message returns them. |
userMessage | function | The user-facing message for a custom error by name, with its arguments; the name itself when unknown. |
walletErrorMessage | function | A message for a failure the wallet raised rather than the contract. |
USER_MESSAGES | const | Every custom error’s user-facing message, by error name. |
REASON_MESSAGES | const | User-facing messages for the plain reason strings Pyth’s verifier reverts with, by the string. |
Live Prices
Also exported in the priceFeeds namespace below.
| Export | Kind | What it is |
|---|---|---|
proPriceSource | function | The context’s price source over Pyth Pro (Pyth’s own endpoints, a proxy, or a local mock): latest, atTick, prints, and the client. |
livePrices | function | Live prices for every market of a context (listMarkets), or for the markets given. |
LivePrices | class | Live 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.
| Export | Kind | What it is |
|---|---|---|
markets.readerFor | function | The keeper’s batched Reader, wired to the context’s deployment (no factory read needed). |
markets.listMarkets | function, bz | Every 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.getMarket | function, bz | One 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.resolvePrices | function | Each market’s live price and venue state. |
markets.pastTradingCutoff | function | Whether now is in limbo: a cutoff is scheduled and the block is within LIMBO_LEAD of it (BazaarPair._pastTradingCutoff). |
markets.marketFlags | function | The flags for a market state at now (a block timestamp), following the pair’s own gates. |
markets.annualizedFunding | function | A funding rate per hour (1e18 = 100%, as the lens reports it) as a yearly fraction for display (0.0876 = 8.76% a year). |
markets.marketStatusAt | function | The snapshot’s status at another time (seconds), for countdowns without a new read. |
markets.adlScoreThresholdAt | function | The ADL auction’s score threshold at another time, for a live display without a new read. |
markets.headFor | function | The block to read at: the caller’s, or the latest. |
markets.marketSnapshots | function, bz | Several markets’ live state at one block. |
markets.marketSnapshot | function, bz | One market’s live state at one block; see marketSnapshots. |
markets.getBookDepth | function, bz | The best levels price levels of one side of the book. |
markets.getBook | function, bz | Both sides of the book, read together; see getBookDepth. |
markets.TradingHours | type | “24/7” for a continuously traded market; “trading-hours” for one that follows its venue’s calendar (stocks, FX). |
markets.MarketInfo | interface | What never changes about a market: what a market list and a market header show. |
markets.MarketRef | type | A market as callers name it: its info, its pair address, its pair id, or its label. |
markets.SymbolSource | interface | Where feed symbols come from: a symbols registry’s base URL, or anything that lists its entries (symbols()). |
markets.ListMarketsOptions | interface | Options of listMarkets: refresh (read the factory’s pair list again) and metadata (where feed symbols come from). |
markets.VENUE_CLOSED_AFTER | const | Seconds 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.HeldPrice | interface | A price a caller already holds, in BAZAAR precision. |
markets.LivePriceQuote | interface | A composed live price for one market, as LivePrices.price returns it. |
markets.LivePriceView | interface | What snapshots read live prices and the venue from: a LivePrices, or anything shaped like it. |
markets.PriceQuote | interface | The price a snapshot valued an account at, and where it came from. |
markets.ResolvedPrice | interface | What resolvePrices returns per market: the price quote (or null) and whether the venue is closed (null when unknown). |
markets.ResolveOptions | interface | Options of resolvePrices: live prices, a held price, whether to fetch from Pyth Pro, and whether a price is needed. |
markets.MarketFlagReasons | interface | Why something a trader may try is refused right now; null where it is not. |
markets.MarketFlags | interface | What the pair refuses right now, as one set of flags a UI can gate its buttons on. |
markets.StoredPrice | interface | The pair’s stored oracle price (lastPairPrice): what its next call falls back to without a fresh one. |
markets.FundingView | interface | A snapshot’s funding: the current and accruing rates per hour (1e18 = 100%), the index and projected index, the last accrual and the cap. |
markets.MarginView | interface | A snapshot’s margin rates in basis points: initial, maintenance, and the 24 h lagged maintenance rate. |
markets.OpenInterestView | interface | A snapshot’s open interest: long and short sizes and notionals, and the liquidated inventory the vault holds. |
markets.InsuranceView | interface | A snapshot’s insurance fund: its balance, free capital, the seizure earmarked against inventory, its target ratio and its ledgers. |
markets.AdlView | interface | A snapshot’s ADL state: whether a window is pending, its epoch, when it opened, and the auction’s score threshold. |
markets.TerminationView | interface | A snapshot’s termination state: the scheduled cutoff, when trading halts ahead of it, and when the settlement price was fixed. |
markets.BookSummary | interface | A snapshot’s book heads: the best bid and ask order ids and the number of orders resting on each side. |
markets.MarketSnapshot | interface | A market’s live state at one block, with the one status and the flags a UI shows. |
markets.SnapshotOptions | interface | Options of marketSnapshot(s): the block to read at, live prices to judge the venue with, and whether to fetch prices. |
markets.MAX_FUNDING_RATE_CONTINUOUS | const | BazaarPairLens.fundingRate’s cap per hour, by calendar (FundingLib.MAX_FUNDING_RATE_*). |
markets.MAX_FUNDING_RATE_SESSION | const | The funding-rate cap per hour for a market with trading hours (1e18 = 100%). |
markets.DepthLevel | interface | One price level of the book: every live resting order at that limit price. |
markets.BookSide | type | "bids" or "asks". |
markets.BookDepth | interface | What getBookDepth returns: the market, side and block, the levels best first, and whether every level is included. |
markets.BookDepthOptions | interface | Options of getBookDepth: the lens page size and the most orders to walk. |
account
Accounts and portfolios.
| Export | Kind | What it is |
|---|---|---|
account.positionHealth | function | The position valued at spot with the state’s projected funding index and effective maintenance rate. |
account.accountHealth | function | The 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.accountSnapshot | function, bz | The account user on one market. |
account.portfolio | function, bz | Every market where user has collateral, a position, live orders or claimable fees, as accountSnapshots, with totals. |
account.accountSnapshots | function | Several accounts’ snapshots on chosen markets in one pass (used by watchAccount to refresh the markets an event touched). |
account.AccountPosition | interface | The account’s position bucket on the pair (BazaarPairLens.getPositionBucket), BAZAAR precision. |
account.PositionHealth | interface | The 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.LensBucketState | interface | BazaarPairLens.checkBucketSolvency at the snapshot’s spot: the contract’s own view, at the stored funding index. |
account.AccountOrder | interface | One of the account’s live orders, decoded from pair.orders(id). |
account.OrderExposure | interface | BazaarPairLens.outstandingOrderExposure: the live limit orders’ remaining notional (at their limits) and size per side. |
account.TerminalPayout | interface | BazaarPairLens.getTerminalEntitlement: what a withdrawal pays on a market that is winding down. |
account.WalletState | interface | The wallet’s USDC balance and the allowance the pair may pull, in USDC units (6 decimals). |
account.AccountSnapshot | interface | One account on one market at one block: position, health, orders, collateral, max withdrawable, fees and wallet state. |
account.AccountOptions | interface | Options shared by the account reads: the block to read at, live prices to value positions with, and whether to fetch prices. |
account.AccountSnapshotOptions | interface | Options of accountSnapshot: AccountOptions plus a held price and a market snapshot at the same block. |
account.PortfolioTotals | interface | A portfolio’s totals across markets: collateral, equity, unrealized and funding PnL, claimable fees and max withdrawable. |
account.Portfolio | interface | What portfolio returns: one AccountSnapshot per market the account is active on, and the totals. |
live
Watching markets and accounts through their events.
| Export | Kind | What it is |
|---|---|---|
live.decodeLiveLog | function | A 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.watchMarket | function, bz | Watch 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.watchAccount | function, bz | Watch one account on a set of markets. |
live.OrderUpdatePayload | interface | OrderUpdated’s payload: the order’s stored state at the event. |
live.PairEventArgs | interface | Each pair event’s arguments, as the pair emits them (BAZAAR precision; timestamps, blocks and enums as numbers). |
live.PairEventName | type | The name of a pair event PairEventArgs describes. |
live.LogPosition | interface | Where a log sits. |
live.PairEvent | type | A pair event; narrow on name for its typed args. |
live.TerminatorEvent | interface | A terminator event about one market (proposals, schedules, settlement prices, insurer votes). |
live.LiveEvent | type | A PairEvent or a TerminatorEvent. |
live.FollowOptions | interface | Options of LogFollower and the watchers: the poll interval, the first block, and the rest. |
live.FollowNotice | type | What a follower reports besides logs. |
live.LogFollower | class | Follows the logs of a set of contracts from a block cursor. |
live.WatchOptions | interface | Options of watchMarket and watchAccount: FollowOptions plus live prices and price fetching for the snapshots. |
live.SnapshotCause | type | Why a watcher sent a snapshot: initial, events, timer or manual. |
live.UndecodableLog | interface | A 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.MarketUpdate | type | What watchMarket delivers: an event, a snapshot (with whether the book changed), an undecodable log or a follower notice. |
live.AccountUpdate | type | What watchAccount delivers: an event, an account snapshot, an undecodable log or a follower notice. |
live.WatchHandle | interface | What a watcher returns: ready, refresh() and stop(). |
priceFeeds
Streamed prices and the price source over Pyth Pro (client/prices.ts).
| Export | Kind | What it is |
|---|---|---|
priceFeeds.updateFromPrints | function | The signed updates a call attaches for these prints: each distinct update they came in, once. |
priceFeeds.proPriceSource | function | The context’s price source over Pyth Pro (Pyth’s own endpoints, a proxy, or a local mock): latest, atTick, prints, and the client. |
priceFeeds.isProPriceSource | function | Whether a context’s price source is a proPriceSource (it then exposes its client). |
priceFeeds.livePrices | function | Live prices for every market of a context (listMarkets), or for the markets given. |
priceFeeds.BROWSER_FUTURE_SKEW | const | How far ahead of this device’s clock a live print may be dated before it is ignored (seconds). |
priceFeeds.ProPriceSource | interface | A PriceUpdateSource over Pyth Pro, with the client and parsed prints exposed. |
priceFeeds.MarketPrice | interface | One market’s live price, composed as BazaarOracle composes it (BAZAAR precision). |
priceFeeds.PriceListener | type | Called with the markets whose price or flags changed (one map per batch; do not keep it). |
priceFeeds.LivePricesOptions | interface | Options of livePrices: the PriceService options plus pro (options for a client of its own) or client when no context source is given. |
priceFeeds.LivePrices | class | Live prices for a set of markets. |
orders
Order specs, checks and packing, with no chain read except the read* functions.
| Export | Kind | What it is |
|---|---|---|
orders.joinsActiveSet | function | Whether the order joins the account’s active set and its 32-order cap (OrderManagementLib._joinsActiveSet). |
orders.canRest | function | Whether the order can rest in the book and so wants an insertion hint: a Limit GTC, or a StopLimit once armed. |
orders.packTriggerPrice | function | (bounty << 128) | price: the OrderSpec.triggerPrice word of a trigger order. |
orders.unpackTriggerPrice | function | Split a triggerPrice word into its price (low 128 bits) and bounty (high 128 bits). |
orders.specBounty | function | The bounty a spec carries: what createOrder pulls from the wallet on top of any relayer fee. |
orders.blocksIn | function | L2 blocks in seconds (about four a second), for an expiry currentBlock + blocksIn(s). |
orders.buildOrderSpec | function | Check an order the way OrderManagementLib.createOrder will, and return the spec to send or sign. |
orders.checkOrderCaps | function | The 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.orderInputFromSpec | function | The order a spec describes, for re-validation (a relayer checking a signed spec) or display. |
orders.validateOrderSpec | function | Check 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.describeOrder | function | One line for a confirmation screen: “Buy 1.5 at 2000 (limit, GTC, post-only)”. |
orders.amendIdWord | function | An 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.unpackAmendIdWord | function | The fields of an id word. |
orders.encodeAmends | function | The 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.decodeAmends | function | The amendments a flat amends array holds (for a relayer showing or checking a signed batch). |
orders.readOrderState | function, bz | The account facts the creation checks need, in one batched read. |
orders.readTriggerBounty | function, bz | What a trigger order’s escrow still holds for whoever fires it (BAZAAR precision): lens.triggerBounty. |
orders.readOrderLifetimeConstants | function, bz | The lifetime constants as the lens reports them (pure there; useful to confirm the SDK’s copy). |
orders.TimeInForce | const + type | BazaarTypes.TimeInForce: what an arriving order does with the size the book could not fill. |
orders.TIF_NAMES | const | Time-in-force names by number: 0 GTC, 1 IOC, 2 FOK. |
orders.MAX_SLIPPAGE_BP | const | Slippage cap for Market, TakeProfit and StopLoss orders: 10%. |
orders.MAX_PLACEMENT_DEVIATION_BP | const | A Limit price must sit within ±50% of the oracle price at creation or amendment. |
orders.MIN_ORDER_LIFETIME_BLOCKS | const | Minimum life of an order that can rest, in Arbitrum L2 blocks (~1 minute at 4 blocks a second). |
orders.MAX_ORDER_LIFETIME_BLOCKS | const | Maximum life, in L2 blocks (~1 year); a later expiry is clamped to it by the pair. |
orders.NEVER_EXPIRE_BLOCK | const | The expiry the pair stamps on TakeProfit and StopLoss orders: they never expire. |
orders.MAX_ACTIVE_LIMIT_ORDERS_PER_USER | const | Active Limit + StopLimit orders one account may hold on one pair. |
orders.MAX_AMENDS_PER_CALL | const | Amendments per amendOrders call (the per-user cap, so one batch can requote every order). |
orders.MAX_CANCELS_PER_CALL | const | Order ids per cancelOrders call (BazaarPair.MAX_CANCELS_PER_CALL). |
orders.AMEND_BEST_EFFORT_BIT | const | Bit 192 of an amendment’s id word: skip the amendment instead of reverting the batch. |
orders.CLOSE_POSITION | const | A TakeProfit or StopLoss of this size closes the whole position, whatever it is when it fires. |
orders.L2_BLOCKS_PER_SECOND | const | Arbitrum produces about four L2 blocks a second; expiries are counted in them. |
orders.EXPIRY_LANDING_MARGIN_BLOCKS | const | Blocks 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.OrderSpec | interface | BazaarTypes.OrderSpec, field for field: what createOrder takes and the CreateOrder signature covers. |
orders.MatchHints | interface | BazaarTypes.MatchHints: execution aids for the arrival walk and book insertion, outside every signature. |
orders.NO_HINTS | const | Empty MatchHints: no predecessors, the default walk budget. |
orders.OrderInput | interface | An order as a trader describes it. |
orders.OrderEnvironment | interface | What the pair knows that an order is judged against. |
orders.AccountOrderState | interface | The account facts the creation checks need: its position and its order slots. |
orders.isTriggerType | const | Whether an order type is a trigger type (StopLimit, TakeProfit, StopLoss). |
orders.AmendInput | interface | One amendment of a resting Limit (GTC) order: its new price and new TOTAL size (filled part included). |
orders.bestEffortAmendIdWord | const | BazaarPairLens.bestEffortAmendIdWord. |
trade
Every trading write, and the price and hint helpers they use.
| Export | Kind | What it is |
|---|---|---|
trade.parsePriceUpdate | function | The live prices a priceUpdate: bytes[] carries, read from each Pyth Pro update’s signed payload. |
trade.UpdatePrice | interface | One feed’s live price as a signed update carries it. |
trade.pairPriceFromUpdate | function, bz | The pair price an update proves, composed from the pair’s legs exactly as BazaarOracle composes it (formulas pairPrice). |
trade.livePairPrice | function, bz | The live pair price from the context’s price source, with the update it came from. |
trade.storedPairPrice | function, bz | The price the pair last stored (lastPairPrice): what it falls back to on a closed market. |
trade.findPredecessor | function, bz | The order a new entry at limitPrice on its side should rest directly behind (0 = the head). |
trade.matchHintsFor | function, bz | MatchHints 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.depositCollateral | function, bz | Deposit 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.terminalWithdrawal | function | On a terminated pair, what a withdrawal of amount (collateral, BAZAAR precision) pays out. |
trade.withdrawCollateral | function, bz | Withdraw collateral (BAZAAR precision; the wallet receives it floored to micro-USDC). |
trade.optionalUpdate | function, bz | A 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.prepareOrder | function, bz | Check 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.createOrder | function, bz | Create an order, matched against the book in the same transaction. |
trade.prepareAmends | function, bz | Check amendments the way OrderManagementLib._amendOne will and fill in hints for the ones that move. |
trade.amendOrders | function, bz | Amend resting Limit (GTC) orders in place: each gets a new price and total size and keeps its id, expiry and integrator. |
trade.checkCancelIds | function | 1 to 200 distinct order ids, as cancelOrders takes them. |
trade.cancelOrders | function, bz | Cancel 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.claimFees | function, bz | Pay the caller everything the pair owes it: refunded trigger bounties, keeper rewards, relayer and integrator fees. |
trade.executeTriggeredOrders | function, bz | Fire 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.PriceUpdateError | class | A price update that is not a Pyth Pro update, or does not cover the pair’s feeds. |
trade.terminalPayoutNote | const | What a terminal payout of payout reads as on a confirmation screen. |
trade.PreparedOrder | interface | An order checked against the chain: the spec to send or sign, and what it was checked against. |
trade.CreateOrderOptions | interface | Options of createOrder: hints to use instead of the ones the SDK finds, the walk budget maxSteps, and permitData. |
trade.PreparedAmends | interface | Amendments checked against the orders, the book and the price, with insertion hints filled in. |
insurance
Insurance deposits, the exit, quotes, budgets and locks.
| Export | Kind | What it is |
|---|---|---|
insurance.claimOpensAt | function | BazaarPair.InsuranceVaultLib.claimOpensAt. |
insurance.depositToInsurance | function, bz | Deposit USDC into the pair’s insurance fund for shares priced at the fund’s value at the mark. |
insurance.requestInsuranceWithdrawal | function, bz | Request 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.executeInsuranceWithdrawal | function, bz | The next step of a requested withdrawal. |
insurance.getInsuranceSharePrice | function, bz | What one share redeems for at the pair’s stored mark (BAZAAR precision; 1.0 while no shares exist). |
insurance.getInsuranceDepositValue | function, bz | What user’s shares redeem for at the pair’s stored mark (BAZAAR precision). |
insurance.insuranceShares | function, bz | user’s insurance shares (0 for a balance a fund recap wiped). |
insurance.quoteInsuranceDeposit | function, bz | Shares 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.quoteInsuranceWithdrawal | function, bz | USDC (BAZAAR precision) a redemption of shareAmount would pay at the live price (or price): lens.quoteInsuranceWithdrawal. |
insurance.getInsuranceWithdrawalLock | function, bz | lens.getInsuranceWithdrawalLock: where an LP’s two execute calls stand. |
insurance.getInsuranceWithdrawalBudget | function, bz | lens.getInsuranceWithdrawalBudget at the live price (or price): the number a paying call is checked against, as of now. |
insurance.getInsuranceWithdrawalConstants | function, bz | lens.getInsuranceWithdrawalConstants: durations in seconds, budget terms in bp. |
insurance.insuranceExitPlan | function, bz | Read where user’s insurance exit on pair stands and when the next call can land. |
insurance.MIN_INSURANCE_DEPOSIT | const | The smallest insurance deposit: 5 USDC (BAZAAR precision). |
insurance.MAX_INSURANCE_DEPOSIT | const | The largest insurance deposit the pair accepts: 2^128 - 1 (BAZAAR precision). |
insurance.INSURANCE_WITHDRAWAL_COOLDOWN | const | Seconds between a withdrawal request and its execution window: 13 days. |
insurance.INSURANCE_WITHDRAWAL_WINDOW_CONTINUOUS | const | Seconds the execution window stays open on a 24/7 market: 2 days. |
insurance.INSURANCE_WITHDRAWAL_WINDOW_SESSION | const | Seconds the execution window stays open on a market with trading hours: 4 days. |
insurance.INSURANCE_WITHDRAWAL_CLAIM_DELAY | const | The paying call opens this long after the lock, and after the last stored 2% price jump. |
insurance.INSURANCE_WITHDRAWAL_REOPEN_CLAIM_DELAY | const | The paying call also opens this long after the last stored price that ended a gap of an hour or more. |
insurance.REOPEN_GAP_MIN_STALENESS | const | RiskParamsLib.REOPEN_GAP_MIN_STALENESS: a store after this long without one ends a gap. |
insurance.withdrawalWindowOf | const | The execution window for a market, by whether it trades continuously. |
insurance.InsuranceQuote | interface | A deposit or redemption quote: the shares or USDC, the mark it was priced at, and the update’s publish time. |
insurance.InsuranceWithdrawalLock | interface | What getInsuranceWithdrawalLock returns: whether the request is locked, the locked amount, the lock time, when the claim opens and when the window ends. |
insurance.InsuranceWithdrawalBudget | interface | What getInsuranceWithdrawalBudget returns: what the shared 6-hour budget still admits, its cap, and when it refills. |
insurance.InsuranceWithdrawalConstants | interface | What getInsuranceWithdrawalConstants returns: the cooldown, the windows and the rate-limit terms. |
insurance.InsuranceExitStep | type | Where an LP’s exit stands: none, request, cooldown, lock, waitClaim, claim, redeem, expired or blocked. |
insurance.InsuranceExitPlan | interface | What 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.
| Export | Kind | What it is |
|---|---|---|
signing.pairDomain | function | The pair’s EIP-712 domain. |
signing.domainSeparator | function | What pair.DOMAIN_SEPARATOR() should equal. |
signing.typeHash | function | keccak256 of the primary type’s string: what lens.getTypehashes reports for it. |
signing.typedDataFor | function | The typed data a user signs for fields on pair. |
signing.relayedDigest | function | The EIP-712 digest the pair recovers the signer from. |
signing.typedDataJson | function | The typed data as the JSON string eth_signTypedData_v4 takes (integers as decimal strings). |
signing.floorRelayerFee | function | Check 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.readMetaTxNonce | function, bz | The signer’s next nonce on the pair (pair.metaTxNonces(user)). |
signing.eip712Limits | function, bz | The fee cap and deadline window the deployment enforces, from lens.getEip712Constants() (read once per context). |
signing.chainTime | function, bz | The latest block’s timestamp: the clock deadlines are judged against. |
signing.checkClockSkew | function | Check 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.deadlineBase | function | The time a deadline is counted from: min(latest block’s timestamp, this device’s clock), checked by checkClockSkew. |
signing.chainDeadline | function, bz | A 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.normalizeSignature | function | A 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.signTypedDataV4 | function | Ask an EIP-1193 wallet to sign typed data with eth_signTypedData_v4, so it shows the user every field it signs. |
signing.takesPriceUpdate | function | The actions whose call takes priceUpdate: a relayer attaches a fresh one at submission. |
signing.encodePairWrite | function | Calldata for one of the eight actions, direct (DIRECT_META) or relayed. |
signing.verifyTypehashes | function, bz | Confirm that what the SDK signs is what the deployment verifies. |
signing.BAZAAR_DOMAIN_NAME | const | The pair’s EIP-712 domain name: "BazaarPair". |
signing.BAZAAR_DOMAIN_VERSION | const | The pair’s EIP-712 domain version: "1". |
signing.MAX_RELAYER_FEE | const | MetaTxLib.MAX_RELAYER_FEE: $1 per action, BAZAAR precision. |
signing.MAX_DEADLINE_WINDOW | const | MetaTxLib.MAX_DEADLINE_WINDOW: a deadline at most this many seconds past the block’s timestamp. |
signing.EIP712_DOMAIN_TYPE_STRING | const | The EIP-712 domain type string the pair uses (name, version, chainId, verifyingContract). |
signing.TYPE_STRINGS | const | The primary types, verbatim from MetaTxLib (whose hashes lens.getTypehashes returns). |
signing.PrimaryType | type | The name of one of the primary types in TYPE_STRINGS. |
signing.EIP712_DOMAIN_FIELDS | const | The EIP-712 domain’s field list, as viem and eth_signTypedData_v4 take it. |
signing.BAZAAR_TYPES | const | The same types as viem / eth_signTypedData_v4 field lists; they encode to TYPE_STRINGS. |
signing.RELAYED_ACTIONS | const | The pair functions that take a signature. |
signing.RelayedAction | type | One of the pair functions in RELAYED_ACTIONS. |
signing.PRIMARY_TYPE | const | The primary type each relayed action signs (depositCollateral signs DepositCollateral, …). |
signing.SignedFields | type | What each action signs besides (nonce, deadline, relayerFee): its own arguments. |
signing.MetaFields | interface | The meta-transaction fields every signature covers: nonce, deadline and relayerFee. |
signing.MetaArgs | interface | The (nonce, deadline, relayerFee, signature, permitData) tail of a pair call; all zero/empty for a direct call. |
signing.DIRECT_META | const | The all-zero meta tail of a direct (unsigned) pair call. |
signing.TypedDataDomain | interface | An EIP-712 domain: name, version, chain id and verifying contract. |
signing.PairTypedData | interface | Typed data as eth_signTypedData_v4 and viem take it. |
signing.MAX_CLOCK_SKEW | const | How 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_IDS | const | Local 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.SigningError | class | A signing step the wallet refused or could not do in the form Bazaar accepts. |
signing.ClockSkewError | class | The RPC’s chain time and this device’s clock disagree too much to stamp a deadline (checkClockSkew, chainDeadline). |
signing.TypehashReport | interface | What verifyTypehashes returns: ok, one line per mismatch, and each type hash as the SDK and the lens compute it. |
permits
USDC (ERC-2612) permits.
| Export | Kind | What it is |
|---|---|---|
permits.usdcPermitDomain | function, bz | USDC’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.permitNonce | function, bz | The owner’s current permit nonce on USDC. |
permits.permitTypedData | function | The typed data of a USDC permit. |
permits.encodePermitData | function | abi.encode(uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s), as the pair decodes it. |
permits.decodePermitData | function | Decode a permitData blob into its value, deadline, v, r and s. |
permits.permitValueFor | function | The permit value (USDC units, rounded up) that covers a pull of pullBazaar (BAZAAR precision). |
permits.signPermit | function, bz | Ask 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.checkPermit | function, bz | Check 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_WINDOW | const | How long a permit the SDK signs stays valid by default (seconds past the lower of chain time and the device’s clock). |
permits.PERMIT_TYPES | const | The EIP-712 types of a USDC (ERC-2612) permit. |
permits.PermitMessage | interface | A permit’s message: owner, spender (the pair), value in USDC units, nonce and deadline. |
permits.SignedPermit | interface | A signed permit: the message, the signature and its parts, and the permitData a pair call takes. |
allowance
USDC balance, allowance and approvals.
| Export | Kind | What it is |
|---|---|---|
allowance.erc20Data | function | Calldata for one of the ERC-20 functions above. |
allowance.usdcBalance | function, bz | The owner’s USDC balance, in USDC units. |
allowance.usdcAllowance | function, bz | What spender (a pair) may pull from owner, in USDC units. |
allowance.approveUsdc | function, bz | Approve spender (a pair) to pull amountUsdc (USDC units) from owner’s wallet. |
allowance.formatUsdcUnits | function | USDC units -> “12.5”. |
allowance.usdcFunding | function, bz | Whether owner can fund a pull of pullBazaar (BAZAAR precision, e.g. PreparedCall.pullsUsdc) to spender: balance and allowance in one read. |
allowance.ERC20_ABI | const | The ERC-20 / ERC-2612 functions the SDK calls on USDC, hand-written (USDC is not a Bazaar ABI). |
allowance.UsdcFunding | interface | What usdcFunding returns: the USDC a call needs, the balance and allowance, and what to approve. |
relay
Gasless requests: signing, checking, serializing and encoding them.
| Export | Kind | What it is |
|---|---|---|
relay.checkSignedFields | function | The bounds of each action that need no chain read, as the pair enforces them for a relayed call. |
relay.relayedPull | function, bz | What 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.signRelayed | function, bz | Have the user sign a gasless request. |
relay.verifyRelayed | function, bz | Check a request before relaying it. |
relay.encodeRelayed | function, bz | The call a relayer sends for a request, from its own account (relayer). |
relay.describeRelayed | function | One line describing what a request does. |
relay.serializeRelayed | function | The request as JSON for the POST to a relayer: integers as decimal strings. |
relay.parseRelayed | function | A request from JSON (or an already-parsed body), every field checked for type and shape. |
relay.RelayedRequest | type | A signed gasless request: every signed field, the signer, the signature and the permit. |
relay.RelayedInput | type | What signRelayed takes: the signed fields themselves, or an order / amendments to check and build first. |
relay.SignRelayedOptions | interface | Options of signRelayed: the wallet, signer and pair, the relayer fee, the deadline window, and an optional nonce and permit. |
relay.RelayCheck | interface | What verifyRelayed returns: ok, the recovered signer, and every problem that would make the call revert. |
batching
Several prepared calls in one transaction.
| Export | Kind | What it is |
|---|---|---|
batching.toBatchCalls | function | The calls in the generic shape, in order. |
batching.batchValue | function | ETH the batch needs: the sum of the calls’ values (Pyth fees). |
batching.shareOnePriceUpdate | function | Carry the Pyth update once per pair: the first call that always stores a price (createOrder, amendOrders, executeTriggeredOrders, refreshPrice) keeps its update and fee. |
batching.walletSendCallsRequest | function, bz | The wallet_sendCalls request for these calls from from (the smart account). |
batching.sendCalls | function | Send the calls as one batch through an EIP-5792 wallet. |
batching.getCallsStatus | function | wallet_getCallsStatus for a batch id, as the wallet reports it. |
batching.encodeTradingAccountExecute | function | execute(Call[] calls) calldata, each call as (target, value, data). |
batching.tradingAccountExecute | function | One transaction through a BazaarTradingAccount: execute runs the calls in order as the account, with value the sum of their values. |
batching.decodeTradingAccountResults | function | The per-call return data execute returns. |
batching.readTradingAccountOwner | function, bz | The account’s owner (who alone may call execute). |
batching.BatchCall | interface | The generic call shape smart wallets take (ERC-4337 / Safe / EIP-5792). |
batching.SendCallsOptions | interface | Options of sendCalls and walletSendCallsRequest: whether the batch must be atomic, and wallet capabilities. |
batching.TRADING_ACCOUNT_ABI | const | The example account’s interface, hand-written from examples/BazaarTradingAccount.sol. |
history
Bazaar’s subgraph: every history query and its types.
| Export | Kind | What it is |
|---|---|---|
history.httpSubgraph | function | A SubgraphSource over HTTP. |
history.cancelReasonName | function | BazaarTypes.CancelReason’s code (as an OrderUpdated payload carries it) -> its name. |
history.trades | function, bz | The trade tape: fills, newest first. |
history.candles | function, bz | OHLCV candles of one pair at one interval (seconds: 60, 300, 900, 3600, 14400 or 86400), oldest first; from/to bound the period start. |
history.fillCandleGaps | function | Fill 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.pairDayData | function, bz | A pair’s daily figures (UTC days), oldest first. |
history.protocolDayData | function, bz | Protocol-wide daily figures, oldest first. |
history.bookLevels | function, bz | Aggregated resting size per price on one side of a pair’s book, best price first (bids high to low, asks low to high). |
history.orders | function, bz | Orders, newest first by creation time (from/to bound the creation time). |
history.order | function, bz | One order by pair and order id, or null when the subgraph has not seen it. |
history.orderEvents | function, bz | Order lifecycle events (created, triggered, canceled with its reason, amended, amend skipped), newest first. |
history.positions | function, bz | Positions (one per account and pair), by id. |
history.positionChanges | function, bz | Each trade’s effect on a position, newest first. |
history.positionSnapshots | function, bz | The full stored position after every change (PositionBucketUpdated), newest first. |
history.collateralTransfers | function, bz | Collateral deposits and withdrawals, newest first. |
history.feeBalances | function, bz | Claimable balances per pair and recipient: an integrator’s or relayer’s revenue, a keeper’s rewards, refunded bounties. |
history.feeAccruals | function, bz | Credits to claimable balances (FeeAccrued), newest first. |
history.feeClaims | function, bz | Claims of claimable balances (FeesClaimed), newest first. |
history.integratorStats | function, bz | What an integrator address has routed, or null when it has carried no order. |
history.accountStats | function, bz | An account’s lifetime totals, or null when the subgraph has not seen it. |
history.accountDayData | function, bz | An account’s daily totals (UTC days), oldest first. |
history.metaTransactions | function, bz | Relayed (gasless) actions, newest first. |
history.permitFailures | function, bz | Permits the pair could not execute (the call went on with the existing allowance), newest first. |
history.liquidations | function, bz | Liquidations, newest first: account is the liquidated trader. |
history.adlWindows | function, bz | ADL windows, newest first by opening time. |
history.adlBatches | function, bz | ADL batches (executeAdl calls), newest first; window is an AdlWindow id. |
history.adlFills | function, bz | Winners ADL closed, newest first. |
history.insuranceProviders | function, bz | Insurance LPs per pair, by id; activeOnly keeps those holding shares. |
history.insuranceEvents | function, bz | Insurance deposits, withdrawal requests, locks, payouts and levies, newest first. |
history.pairTerminations | function, bz | Finalized terminations with their payout figures, newest first. |
history.terminalRatioUpdates | function, bz | Payout ratios re-derived after finalize, newest first. |
history.terminationProposals | function, bz | UMA termination claims, newest first by submission. |
history.insurerProposals | function, bz | Insurer termination votes, newest first by creation. |
history.insurerVotes | function, bz | Yes-votes cast on insurer proposals, newest first. |
history.insurerLevies | function, bz | Levies charged on yes-voters’ redemptions after an insurer-vote termination, newest first. |
history.listingProposals | function, bz | Listing (pair deployment) proposals, newest first. |
history.identifierUpgradeProposals | function, bz | UMA identifier-upgrade proposals, newest first. |
history.subgraphLag | function, bz | How 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.SubgraphError | class | The subgraph refused or failed a query. |
history.HttpSubgraphOptions | interface | Options of httpSubgraph: the per-request timeout, extra headers and the fetch to use. |
history.ORDER_TYPES | const | Order type names as the subgraph stores them: Market, Limit, StopLimit, TakeProfit, StopLoss. |
history.OrderTypeName | type | One of ORDER_TYPES. |
history.TIME_IN_FORCE | const | Time-in-force names: GTC, IOC, FOK. |
history.TimeInForceName | type | One of TIME_IN_FORCE. |
history.ORDER_STATUSES | const | Order statuses: Waiting, Pending, Resting, Fired, Filled, Canceled. |
history.OrderStatus | type | One of ORDER_STATUSES. |
history.OPEN_ORDER_STATUSES | const | Statuses of an order that can still trade. |
history.CANCEL_REASONS | const | Cancel reasons, in BazaarTypes.CancelReason order (None, Owner, Expired, … Deleveraged). |
history.CancelReason | type | One of CANCEL_REASONS. |
history.AMEND_SKIP_REASONS | const | Why a best-effort amendment was skipped: Inactive, Expired, SizeFilled, BelowMinimum, WouldCross. |
history.AmendSkipReason | type | One of AMEND_SKIP_REASONS. |
history.ORDER_EVENT_ACTIONS | const | Order event kinds: Created, Triggered, Canceled, Amended, AmendSkipped. |
history.OrderEventAction | type | One of ORDER_EVENT_ACTIONS. |
history.PAIR_STATUSES | const | Pair statuses as the subgraph records them: Active, Scheduled, SettlementFixed, Terminated. |
history.PairStatus | type | One of PAIR_STATUSES. |
history.SETTLEMENT_SOURCES | const | How a settlement price was fixed: Cutoff, Stale, Insolvency. |
history.SettlementSource | type | One of SETTLEMENT_SOURCES. |
history.INSURANCE_EVENT_KINDS | const | Insurance event kinds: Deposit, WithdrawalRequested, WithdrawalLocked, WithdrawalExecuted, LevyCharged, LevyReceived. |
history.InsuranceEventKind | type | One of INSURANCE_EVENT_KINDS. |
history.LISTING_STATUSES | const | Listing statuses: Pending, Disputed, Deployed, Rejected, Expired, Voided. |
history.ListingStatus | type | One of LISTING_STATUSES. |
history.IDENTIFIER_UPGRADE_STATUSES | const | Identifier-upgrade statuses: Pending, Disputed, Queued, Upgraded, Canceled, Expired, Rejected. |
history.IdentifierUpgradeStatus | type | One of IDENTIFIER_UPGRADE_STATUSES. |
history.TERMINATION_PROPOSAL_STATUSES | const | UMA termination claim statuses: Pending, Disputed, Accepted, ScheduleRefused, Superseded, Rejected. |
history.TerminationProposalStatus | type | One of TERMINATION_PROPOSAL_STATUSES. |
history.INSURER_PROPOSAL_STATUSES | const | Insurer vote statuses: Open, Scheduled, Failed, Preempted, Voided, Rejected. |
history.InsurerProposalStatus | type | One of INSURER_PROPOSAL_STATUSES. |
history.CANCEL_REASON_TEXT | const | Why an order was canceled, in a sentence for its owner. |
history.CANDLE_INTERVALS | const | The candle intervals the subgraph keeps, in seconds: 1m, 5m, 15m, 1h, 4h, 1d. |
history.CandleInterval | type | One of CANDLE_INTERVALS (seconds). |
history.Trade | interface | One fill. |
history.Candle | interface | OHLCV over fills. |
history.PairDayData | interface | A pair’s figures for one UTC day: volume, trade count, fees and OHLC. |
history.HistoryOrder | interface | An order as the subgraph records it: type, side, sizes and prices, status, integrator, cancel reason and times. |
history.OrderEvent | interface | One OrderUpdated, OrderAmended or OrderAmendSkipped log. |
history.HistoryPosition | interface | A position as the subgraph records it: side, size, entry value and price, collateral, funding index and totals. |
history.PositionSnapshot | interface | The full stored position a PositionBucketUpdated log reported. |
history.PositionChange | interface | A trade’s effect on one position: the maker’s per fill, the taker’s once per order walk at its VWAP. |
history.CollateralTransfer | interface | One collateral deposit or withdrawal. |
history.FeeBalance | interface | A recipient’s claimable balance on one pair: integrator fees, keeper rewards, relayer fees, refunds. |
history.FeeMovement | interface | One credit to a claimable balance (FeeAccrued), or one claim of it (FeesClaimed). |
history.Liquidation | interface | One liquidation: the account, the liquidator, size, bankruptcy price, price, notional and collateral seized. |
history.AdlWindow | interface | One ADL window, from the AdlTriggered that opened it to AdlResolved or the pair’s termination. |
history.AdlBatch | interface | One executeAdl call. |
history.AdlFill | interface | One winner an ADL batch closed, fully or in part. |
history.InsuranceProvider | interface | An insurance LP on one pair: live shares, totals deposited and withdrawn, the pending request and lock, and levies. |
history.InsuranceEvent | interface | One insurance event: a deposit, request, lock, payout or levy, with amount and shares. |
history.PairTermination | interface | A finalized termination: the settlement price, what is owed, bad debt, the pot and the payout ratios. |
history.TerminalRatioUpdate | interface | A post-finalize settlement re-derived the payout ratios on the entitlements still unpaid. |
history.TerminationProposal | interface | A UMA termination claim. |
history.InsurerProposal | interface | An insurer termination vote: proposer, cutoff, window, whether a UMA claim backs it, the snapshot and the status. |
history.InsurerVote | interface | One yes-vote on an insurer proposal: voter, shares and the running total. |
history.InsurerLevy | interface | One levy charged on a yes-voter’s shares after an insurer-vote termination. |
history.ListingProposal | interface | A listing proposal: pair id, deployer, feed, description, seed, status and times. |
history.IdentifierUpgradeProposal | interface | An identifier-upgrade proposal: proposer, new identifier, status and times. |
history.IntegratorStats | interface | What one integrator address has routed. |
history.AccountStats | interface | An account’s lifetime totals: trades, volumes, fees paid, realized PnL, liquidations and bounties. |
history.AccountDayData | interface | An account’s totals for one UTC day. |
history.ProtocolDayData | interface | Protocol-wide totals for one UTC day: volume, trades, fees, liquidations and unique traders. |
history.MetaTransaction | interface | One relayed (gasless) action: the user, the relayer, the action’s type hash, nonce and fee. |
history.PermitFailure | interface | An ERC-2612 permit the pair could not execute; the call went on with the existing allowance. |
history.BookLevel | interface | Resting size at one price on one side. |
history.Page | interface | A page of results; next is the cursor for the following page, null at the end. |
history.PageOptions | interface | Paging options every query takes: first (1 to 1000, default 100), after (a cursor) and atBlock. |
history.TimeOptions | interface | PageOptions plus from, to (seconds) and order. |
history.TradeFilter | interface | Filters of trades: pair, account, maker, taker, plus TimeOptions. |
history.MAX_FILLED_CANDLES | const | The most candles fillCandleGaps returns: about 69 days of 1-minute candles, more than a chart shows. |
history.BookLevelOptions | interface | Options of bookLevels: the side ("bid" or "ask") plus PageOptions. |
history.OrderFilter | interface | Filters of orders: account, pair, status, openOnly, order type, integrator, plus TimeOptions. |
history.OrderEventFilter | interface | Filters of orderEvents: account, pair, order id (with pair), action, plus TimeOptions. |
history.PositionFilter | interface | Filters of positions: account, pair, openOnly, plus PageOptions. |
history.AccountPairFilter | interface | An account and pair filter plus TimeOptions, used by several queries. |
history.RecipientFilter | interface | Filters of the fee queries: the recipient and pair, plus TimeOptions. |
history.MetaTxFilter | interface | Filters of metaTransactions: user, relayer, pair, plus TimeOptions. |
history.SubgraphLag | interface | What 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.
| Export | Kind | What it is |
|---|---|---|
previews.takerInsuranceFeeBase | function | RiskParamsLib.getTakerInsuranceFeeBase: the taker’s base insurance rate (EBP) at a fund target ratio (bp). |
previews.takerInsuranceFeeRates | function | RiskParamsLib.getInsuranceFeeRates: the taker insurance rates (EBP) from the fund’s coverage and its target ratio. |
previews.takerFillFee | function | One taker fill’s fee (MatchingEngineLib._computePairFees, taker side). |
previews.makerFillFee | function | One maker fill’s fee: a flat 0.25 bp insurance leg, plus 0.25 bp when the order carries an integrator. |
previews.feeBreakdown | function | Where a fee goes (MatchingEngineLib._finalizeTx). |
previews.applyFill | function | MatchingEngineLib._applyFillCore: one fill’s effect on a position. |
previews.positionState | function | BucketLib.calculateState for a bucket, at the effective maintenance rate. |
previews.fillMarginOk | function | MatchingEngineLib._checkMargin: whether a fill of fillSize at price (the maker’s limit) passes the account’s margin gate at the oracle mark. |
previews.liquidationPrice | function | The estimated liquidation price of a position. |
previews.maxLeverage | function | The most leverage the pair allows a new position now: 1 / imrBp (25 at a 4% initial margin). |
previews.worstCaseNotional | function | CollateralLib._worstCaseNotional: the position plus same-side orders, or the opposite orders’ overshoot into a flip. |
previews.fillDebit | function | CollateralLib._fillDebitOf. |
previews.restingReserve | function | CollateralLib._sweepRestingReserve: the account’s live orders reserved at mark. |
previews.creationGate | function | OrderManagementLib._creationShortfall: what createOrder requires of the account’s collateral. |
previews.simulateOrder | function | Run an order through the pair’s arithmetic with every input given. |
previews.previewOrder | function, bz | Preview 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.previewWithdrawal | function, bz | Preview a collateral withdrawal. |
previews.MAKER_INSURANCE_FEE_EBP | const | BazaarTypes.MAKER_INSURANCE_FEE_EBP: 0.25 bp, flat. |
previews.MAKER_INTEGRATOR_FEE_EBP | const | BazaarTypes.MAKER_INTEGRATOR_FEE_EBP: 0.25 bp, only on orders that carry an integrator. |
previews.TAKER_INTEGRATOR_FEE_EBP | const | BazaarTypes.TAKER_INTEGRATOR_FEE_EBP: 0.25 bp, only on orders that carry an integrator. |
previews.TAKER_INSURANCE_FEE_MIN_EBP | const | RiskParamsLib 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_EBP | const | The taker insurance rate at the 10% target ratio: 4.25 bp (EBP). |
previews.TAKER_INSURANCE_FEE_MULTIPLIER | const | The below-target multiplier of the taker insurance curve: 49. |
previews.TAKER_INSURANCE_FEE_CAP_EBP | const | The taker insurance rate’s cap: 50 bp (EBP). |
previews.INSURANCE_TARGET_BASE_BP | const | BazaarTypes.INSURANCE_TARGET_BASE / _MAX, in bp of open-interest notional. |
previews.INSURANCE_TARGET_MAX_BP | const | The insurance fund’s largest target ratio: 10% of open-interest notional (bp). |
previews.BUG_BOUNTY_TAX_BP | const | BazaarTypes.BUG_BOUNTY_TAX_BP: 1% of the insurance and integrator fee streams. |
previews.MAX_SLIPPAGE_BP | const | BazaarTypes.MAX_SLIPPAGE_BP: 10%. |
previews.MAX_PLACEMENT_DEVIATION_BP | const | BazaarTypes.MAX_PLACEMENT_DEVIATION_BP: a Limit must sit within 50% of the price. |
previews.MAX_STALE_DEVIATION_BP | const | BazaarTypes.MAX_STALE_DEVIATION_BP: fills under the stale fallback stay within 10% of it. |
previews.MAX_ACTIVE_LIMIT_ORDERS | const | BazaarTypes.MAX_ACTIVE_LIMIT_ORDERS_PER_USER. |
previews.TakerFeeRates | interface | Taker insurance rates (EBP): the dynamic rate for risk-adding notional and the base rate for risk-reducing notional. |
previews.FillFee | interface | One fill’s fee: the insurance leg, the integrator leg and the total. |
previews.FeeBreakdown | interface | A fee split into what goes to the insurance fund, the integrator and the bug bounty. |
previews.Bucket | interface | A position bucket as the pair stores it (BAZAAR precision; entryFundingIndex signed). |
previews.MarginRates | interface | Margin rates in bp: initial, maintenance and the lagged maintenance rate. |
previews.FillResult | interface | What applyFill returns: the new bucket, the realized price PnL and the funding settled. |
previews.RestingOrder | interface | A live resting order as the reservation reads it. |
previews.RestingReserve | interface | What restingReserve returns: the long and short exposure the live orders are reserved at, and the fill debit. |
previews.CreationGate | interface | What creationGate returns: the collateral createOrder requires and whether the account has it. |
previews.OrderInput | interface | An order as the preview takes it. |
previews.BookMaker | interface | A resting order on the opposite side of the book, in book order. |
previews.SimulateInput | interface | What simulateOrder takes: the order, account, bucket, mark, funding index, margin rates, book and the rest. |
previews.PreviewFill | interface | One fill in a preview: price, size, notional, role, fee, and whether it is a resting remainder assumed filled later. |
previews.PositionView | interface | A position in a preview: side, size, entry value and price, collateral, PnL and margin at the mark. |
previews.PreviewIssue | interface | A reason the contract would refuse the call (or the walk would cancel the order), with its user message. |
previews.OrderPreview | interface | A preview’s position before, after everything fills and after this transaction, with fills, fees and issues. |
previews.PriceInput | interface | A price to judge at: the spot (and bracket) of the update the call will carry. |
previews.PreviewOptions | interface | Options of previewOrder: the price the call will carry, walkBook, and the fillPrice to assume without the book. |
previews.OrderPreviewResult | interface | What previewOrder returns: the OrderPreview plus the mark, the venue, the fee rates and the margin rates. |
previews.WithdrawalPreview | interface | What 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.
| Export | Kind | What it is |
|---|---|---|
termination.terminatorConstants | function, bz | The terminator’s public constants (categories, outcomes, verdicts, settlement rules, bonds, liveness, reason length, sample count and spacing). |
termination.requiredTerminationBond | function, bz | The USDC bond (6 decimals) a UMA termination claim posts now: max(1,000 USDC, UMA’s minimum). |
termination.settlementRule | function, bz | How terminateScheduledPair would settle the market if called now, and what it needs. |
termination.terminationProposal | function, bz | A UMA termination claim by its assertion id, or null when the terminator has none. |
termination.insurerProposal | function, bz | An insurer-termination proposal by id (a claim’s assertion id, or a bare proposal’s id), or null when none exists. |
termination.getInsurerVote | function, bz | Shares 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.getLockedShares | function, bz | Shares user has voted into live proposals on pair: what the insurance withdrawal gate refuses to release. |
termination.getTerminalEntitlement | function, bz | The lens’s terminal entitlement for account on pair. |
termination.openProposals | function, bz | The open proposals on pair: UMA claims not yet resolved and insurer votes not yet resolved. |
termination.terminationStatus | function, bz | A market’s termination status in one call. |
termination.reasonProblem | function | Why a termination reason would be refused, or null when the terminator accepts it (1 to 1,000 bytes of a restricted character set). |
termination.unjudgedLevyBp | function | The levy (basis points) an unjudged outcome charges with yes of snapshot voted: 50% × dissent, at least 10%. |
termination.proposeTermination | function, bz | Propose ending a market through UMA. |
termination.settleTerminationProposal | function, bz | Settle a UMA termination claim once UMA can resolve it. |
termination.insurerProposalId | function | keccak256(abi.encode(pair, proposer, timestamp)): a bare insurer proposal’s id, fixed by the second it is filed. |
termination.proposeInsurerTermination | function, bz | Propose that a market stop trading at cutoffTs with no UMA claim behind it (a bare insurer proposal). |
termination.voteForInsurerTermination | function, bz | Vote yes on insurer proposal id by locking shares of your insurance shares. |
termination.executeInsurerTermination | function, bz | Resolve insurer proposal id (anyone may). |
termination.terminateStalePair | function, bz | End 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.tickFetcher | function | A TickFetcher over the context’s price source (its atTick). |
termination.settlementPrint | function | One feed as a one-second update carries it; throws SettlementPrintError where the oracle would refuse the update for that feed. |
termination.generatedAtOrAfter | function | Whether a settlement print carries a price generated at or after a target second (BazaarOracle._pricedSince). |
termination.tickPrints | function | UMA’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.chainSample | function | One 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.chainPrints | function | The chain rule’s sample set. |
termination.terminateScheduledPair | function, bz | Build terminateScheduledPair(pair, priceUpdate). |
termination.chainLastResort | function | The chain rule’s last resort (BazaarPairTerminator._insurerSettlementBounds): 21 days after the cutoff, or 12 hours after a capping UMA cutoff when sooner. |
termination.finalizeTermination | function, bz | Finalize a terminated market (stage 2, on the pair). |
termination.decodeTerminatorLog | function | Decode a terminator log, or null for another contract’s. |
termination.proposalIdsFromReceipt | function, bz | The proposal ids a proposeTermination or proposeInsurerTermination receipt created. |
termination.TERMINATION_PROPOSAL_BOND_USDC | const | The UMA termination bond’s floor (USDC units); the bond posted is requiredTerminationBond(), which tracks UMA’s own minimum upward. |
termination.TERMINATION_PROPOSAL_LIVENESS | const | UMA liveness of a termination claim (seconds). |
termination.MAX_REASON_LENGTH | const | Longest reason a termination claim takes, in bytes. |
termination.MIN_CUTOFF_LEAD | const | A 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_LEAD | const | Earliest a proposed cutoff may sit after the proposal, for a UMA claim and a bare insurer proposal alike (seconds). |
termination.INSURER_TERMINATION_LATCH_LEAD | const | Voting and the latch close this long before a proposal’s cutoff (seconds). |
termination.INSURER_TERMINATION_MAX_LEAD | const | Votes 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_BP | const | Share of the snapshot that must vote yes (basis points). |
termination.INSURER_TERMINATION_BOND_USDC | const | The bond a bare insurer proposal posts (USDC units), a fixed figure. |
termination.INSURER_SHARE_MATURITY_PERIOD | const | Shares must be held this long before the vote window opens to vote (seconds). |
termination.INSURER_MAX_LIVE_VOTES | const | Most live proposals one voter may back on a pair. |
termination.INSURER_LEVY_BP | const | Levy on each yes-voted share when the claim behind the vote is rejected (basis points). |
termination.INSURER_LEVY_FLOOR_BP | const | The least an unjudged outcome levies on each yes-voted share (basis points). |
termination.CLAIM_VERDICT_GRACE | const | How long after a claim is filed its vote-scheduled pair waits for the verdict (seconds). |
termination.INSURER_POST_FIX_LOCK | const | Yes-voters of a costed outcome stay locked this long after the price fix: the insurance withdrawal cooldown (seconds). |
termination.SCHEDULED_TERMINATION_GRACE | const | Under 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_PRICE | const | The 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_EPOCH | const | BazaarPair storage slot of shareEpoch, which the terminator reads with extsload (PAIR_SLOT_SHARE_EPOCH). |
termination.TerminationCategory | const + type | The four grounds a UMA termination claim may declare. |
termination.TERMINATION_CATEGORY_INFO | const | What each category means, condensed from the claim text the terminator writes into UMA’s assertion. |
termination.SettlementRuleKey | type | A settlement rule’s name: nothing, window, chain or awaitVerdict. |
termination.SETTLEMENT_RULE_KEYS | const | settlementRule’s answers, by number. |
termination.SETTLEMENT_RULE_TEXT | const | What each settlement rule means. |
termination.VerdictName | type | A UMA verdict’s name: pending, true or false. |
termination.VERDICT_NAMES | const | Verdict names by the terminator’s number. |
termination.OutcomeName | type | An insurer-vote outcome’s name: none, true, false or unjudged. |
termination.OUTCOME_NAMES | const | Outcome names by the terminator’s number. |
termination.OUTCOME_TEXT | const | What each insurer-vote outcome means for its yes-voters. |
termination.TerminatorConstants | interface | The terminator’s public constants, read from the chain, plus the live bond. |
termination.SettlementRuleInfo | interface | settlementRule(pair), decoded. |
termination.TerminationProposalInfo | interface | A UMA termination claim, from terminationProposals(assertionId). |
termination.InsurerProposalState | type | Where an insurer vote stands at the chain’s time. |
termination.InsurerProposalInfo | interface | An insurer-termination proposal, from insurerProposals(id). |
termination.TerminalEntitlement | interface | What a terminated market pays an account. |
termination.TerminationPhase | type | Where a market is on its way to termination. |
termination.OpenProposals | interface | Proposals still open on a market, and where their ids came from. |
termination.TerminationStatus | interface | A market’s termination state in one call. |
termination.ProposalSearch | interface | Where to look for a market’s open proposals. |
termination.MAX_OPEN_PROPOSALS | const | The most open proposals of each kind (UMA claims, insurer votes) openProposals takes from the subgraph for one market. |
termination.maturityCutoff | const | The maturity cutoff for a vote whose window opened at windowOpenTs: shares deposited at or before it vote. |
termination.voteWindowOpens | const | When a claim’s vote window opens: 14 days before its cutoff, or at filing (now) when that is later. |
termination.InsurerExecutionOutcome | type | What executing an insurer vote would do now. |
termination.SettlementPrint | interface | One of Pyth’s one-second updates, read for one feed, as settlement evidence. |
termination.TickFetcher | type | Pyth’s one-second update stamped on a second, carrying the feeds asked for, or null when the archive has none. |
termination.SettlementPrintError | class | A settlement update that could not be fetched, or a sample that cannot be proven. |
termination.TickProof | interface | What UMA’s rule settles a market with: the cutoff’s update for every leg. |
termination.ChainProof | interface | What the chain rule settles a market with: the updates in calldata order, the targets and the samples’ seconds. |
termination.ChainSearchBounds | interface | How far a chain sample may be searched for: the latest second allowed, and the present. |
termination.SettleUnder | type | Which rule a terminateScheduledPair call settles under: UMA’s cutoff update (tick), the chain’s median, or the stored-price last resort. |
termination.SettlementAttempt | interface | One candidate the builder tried. |
termination.ScheduledSettlementPlan | interface | What terminateScheduledPair needs now. |
termination.TERMINATOR_EVENT_TEXT | const | What each terminator event means, for an activity feed. |
listings
Proposing and settling new markets.
| Export | Kind | What it is |
|---|---|---|
listings.descriptionProblem | function | Why a listing description would be refused, or null when the factory accepts it: 1 to 200 bytes of letters, digits, space and . , & / -. |
listings.compositeId | function | keccak256(baseId ‖ quoteId ‖ invertQuote): the id a composite is registered and listed under (BazaarOracle.getCompositeId). |
listings.asFeedId | function | The bytes32 feed ID for a Pyth Pro feed number, decimal digits, or a bytes32; an InputError for anything else. |
listings.FeedIdInput | type | A feed ID as a caller may give it: the Pyth Pro feed number or the bytes32 that holds it. |
listings.getComposite | function, bz | The 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.registerComposite | function, bz | Register a composite feed on Bazaar’s oracle, so compositeId(baseId, quoteId, invertQuote) can be listed. |
listings.deploymentConstants | function, bz | MIN_DEPLOYMENT_AMOUNT, DEPLOYMENT_BOND_USDC, the live requiredDeploymentBond, DEPLOYMENT_LIVENESS and DVM_DISPUTE_GRACE. |
listings.requiredDeploymentBond | function, bz | The USDC bond (6 decimals) a listing posts now: max(1,000 USDC, UMA’s minimum). |
listings.deploymentProposal | function, bz | A listing proposal by its UMA assertion id, or null when the factory has none. |
listings.rawBaseFeed | function, bz | The 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.pendingDeployment | function, bz | The listing proposal pending for an asset, or null. |
listings.openListings | function, bz | The listing proposals not yet resolved, found through the subgraph, the factory’s logs or given ids, each read on chain. |
listings.seedRefundOwed | function, bz | USDC (6 decimals) account may collect with claimSeedRefund from listings that did not deploy. |
listings.proposePairDeployment | function, bz | Propose listing a market. |
listings.settleDeploymentProposal | function, bz | Settle a listing once UMA can resolve it. |
listings.expireStuckDeploymentProposal | function, bz | Release an asset’s listing slot when its assertion cannot settle on UMA’s oracle (a blacklisted payee, a dispute the DVM never resolves). |
listings.claimSeedRefund | function, bz | Withdraw the seed credited back to the caller by listings that did not deploy (rejected, voided or released). |
listings.decodeFactoryLog | function | Decode a factory log, or null for another contract’s. |
listings.listingFromReceipt | function, bz | The assertion id and pair id a proposePairDeployment receipt created, or null. |
listings.MIN_DEPLOYMENT_AMOUNT | const | Least totalAmount a listing takes (BAZAAR precision): $1,000 bond + $3,000 seed. |
listings.DEPLOYMENT_BOND_USDC | const | The listing bond’s floor (USDC units); the bond posted is requiredDeploymentBond(), which tracks UMA’s minimum upward. |
listings.DEPLOYMENT_LIVENESS | const | UMA liveness of a listing (seconds). |
listings.DVM_DISPUTE_GRACE | const | Extra wait before a disputed listing that cannot settle may be released (seconds). |
listings.MIN_INSURANCE_SEED | const | Least insurance seed a pair is deployed with (BAZAAR precision): what remains of totalAmount after the bond. |
listings.MAX_DESCRIPTION_LENGTH | const | Longest listing description, in bytes. |
listings.PAIR_DEPLOYMENT_PROPOSAL_VOIDED | const | What PairDeploymentProposalVoided means. |
listings.LISTING_EVENT_TEXT | const | What each factory listing event means, for an activity feed. |
listings.CompositeFeed | interface | A composite feed’s legs. |
listings.DeploymentConstants | interface | The factory’s listing constants, read from the chain, plus the live bond. |
listings.DeploymentStatus | type | Where a listing stands. |
listings.DeploymentProposalInfo | interface | A listing proposal, from getDeploymentProposal(assertionId). |
listings.ListingSearch | interface | Where openListings looks for listing proposals. |
listings.OpenListings | interface | The unresolved listing proposals and where their ids came from. |
listings.MAX_OPEN_LISTINGS | const | The most open listings openListings takes from the subgraph. |
listings.MAX_LISTING_IDS | const | The most listing ids openListings reads on chain in one call. |
governance
UMA identifier upgrades.
| Export | Kind | What it is |
|---|---|---|
governance.isAssertTruthIdentifier | function | True when identifier’s first 12 bytes are ASCII “ASSERT_TRUTH” (the factory’s truth-identifier rule). |
governance.parseIdentifier | function | An identifier given as bytes32 hex or as its text (“ASSERT_TRUTH2”). |
governance.identifierUpgradeConstants | function, bz | IDENTIFIER_UPGRADE_BOND_USDC, the live requiredIdentifierUpgradeBond, LIVENESS, TIMELOCK, MIN_NOTICE and DVM_DISPUTE_GRACE. |
governance.requiredIdentifierUpgradeBond | function, bz | The USDC bond (6 decimals) an identifier-upgrade proposal posts now: max(5,000 USDC, UMA’s minimum). |
governance.umaIdentifier | function, bz | The identifier Bazaar’s assertions are made under, as bytes32. |
governance.umaIdentifierIsLive | function, bz | Whether Bazaar’s identifier is on UMA’s live whitelist. |
governance.identifierUpgradeProposal | function, bz | An identifier-upgrade proposal by its assertion id, or null when the factory has none. |
governance.identifierGovernance | function, bz | The 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.proposeUmaIdentifierUpgrade | function, bz | Propose switching Bazaar to newIdentifier. |
governance.settleIdentifierUpgradeProposal | function, bz | Settle 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.expireStuckIdentifierUpgradeProposal | function, bz | Release the single pending-upgrade slot when its assertion cannot settle on UMA’s oracle. |
governance.activateIdentifierUpgrade | function, bz | Activate 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_USDC | const | The identifier-upgrade bond’s floor (USDC units); the bond posted is requiredIdentifierUpgradeBond(). |
governance.IDENTIFIER_UPGRADE_LIVENESS | const | UMA liveness of an identifier-upgrade proposal (seconds). |
governance.IDENTIFIER_UPGRADE_TIMELOCK | const | Delay between approval and activation (seconds). |
governance.IDENTIFIER_UPGRADE_MIN_NOTICE | const | Delay between approval and activation once the incumbent identifier is off UMA’s live whitelist (seconds). |
governance.ASSERT_TRUTH_PREFIX | const | Every adjudication identifier begins with these 12 ASCII bytes. |
governance.IdentifierUpgradeConstants | interface | The governance track’s constants, read from the factory, plus the live bond. |
governance.IdentifierUpgradeProposalInfo | interface | An identifier-upgrade proposal. |
governance.QueuedIdentifierUpgrade | interface | An approved upgrade waiting out its timelock. |
governance.IdentifierGovernanceStatus | interface | The governance track in one call. |
governance.IDENTIFIER_UPGRADE_EVENT_TEXT | const | What 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.
| Export | Kind | What it is |
|---|---|---|
uma.optimisticOracleCodec | function | Codec for the Optimistic Oracle V3 fragment. |
uma.erc20Codec | function | Codec for ERC-20 (USDC’s approve, allowance, balanceOf, decimals). |
uma.readMany | function, bz | Several reads in as few eth_calls as the node allows (Chain.batch), decoded. |
uma.readOne | function, bz | One read. |
uma.chainNow | function, bz | The latest block’s timestamp: what the contracts’ block.timestamp checks are judged against. |
uma.usdcText | function | USDC units (6 decimals) as “1,000 USDC”, exactly (“1,000.009999 USDC”). |
uma.sharesText | function | Insurance shares (18 decimals) as “1,234.5678 shares”, exactly. |
uma.ethText | function | ETH in wei as “0.000001 ETH”, exactly (7 wei is “0.000000000000000007 ETH”). |
uma.timeText | function | A unix time (seconds) as “2026-11-08 14:00:00 UTC”. |
uma.durationText | function | A duration in seconds as “12 hours”, “14 days”, “7 seconds”. |
uma.asBytes32 | function | A bytes32 field the caller typed, checked. |
uma.asAddress | function | An address the caller typed, checked and checksummed. |
uma.requireBondFloor | function | Throws BondQuoteError when quoted (USDC units) is below floor, the contract’s own minimum for what. |
uma.approveCall | function | An ERC-20 approve as a PreparedCall. |
uma.bondApprovals | function, bz | The approvals owner must send before a call that pulls pulls, and what to tell them. |
uma.identifierFromText | function | “ASSERT_TRUTH2” -> its bytes32 (ASCII, right-padded with zeros), as UMA stores identifiers. |
uma.identifierText | function | A bytes32 identifier as its ASCII text (“ASSERT_TRUTH2”), or null when it is not printable ASCII. |
uma.optimisticOracle | function, bz | The deployment’s Optimistic Oracle: factory.oo(), immutable. |
uma.assertionOracle | function, bz | The 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.getAssertion | function, bz | The assertion record on oo, or null for an unknown id (OOv3 returns an all-zero record). |
uma.assertionPhase | function | The phase of an assertion at chain time now. |
uma.statusOf | function | The status of an assertion judged at now. |
uma.assertionStatus | function, bz | Where an assertion stands: pending its liveness (with the time it can settle), settleable, disputed (waiting on the DVM), or settled true or false. |
uma.getMinimumBond | function, bz | The smallest bond OOv3 accepts in currency (USDC by default), in its units; 0 when OOv3 has never cached it. |
uma.defaultIdentifier | function, bz | OOv3’s default identifier. |
uma.burnedBondPercentage | function, bz | The 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.identifierWhitelisted | function, bz | Whether identifier is on UMA’s live IdentifierWhitelist, resolved as the factory and OOv3 resolve it: oracle -> finder -> IdentifierWhitelist -> isIdentifierSupported. |
uma.previewSettlement | function, bz | Simulate OOv3’s settleAndGetAssertionResult(assertionId) without sending it. |
uma.previewSettlementOn | function | previewSettlement on an oracle the caller already resolved with assertionOracle (no check of oo, so never pass one a user gave). |
uma.decodeAssertionLog | function | Decode an OOv3 log (AssertionMade, AssertionDisputed, AssertionSettled), or null for another contract’s. |
uma.disputeAssertion | function, bz | Dispute an assertion: you match its bond, and UMA’s DVM votes on the claim. |
uma.settleAssertion | function, bz | Settle an assertion on the oracle directly. |
uma.requireSettleable | function | Throws the InputError a settle attempt deserves when the assertion cannot settle yet. |
uma.optimisticOracleV3Abi | const | The slice of UMA’s OptimisticOracleV3Interface these flows need. |
uma.isZero32 | const | True for the all-zero bytes32 the contracts use as “none”. |
uma.ContractRead | interface | One read for readMany. |
uma.ReadError | class | A read that reverted, with its decoded reason. |
uma.CostKind | type | What 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.Cost | interface | One thing a call costs or puts at risk, for a confirmation screen. |
uma.CostedCall | interface | A prepared write that posts a bond, moves money or locks shares, with what it costs. |
uma.TokenPull | interface | A bond or seed an ERC-20 transferFrom will pull from the caller. |
uma.MIN_ASSERTION_BOND_USDC | const | The least bond any Bazaar assertion posts (USDC units): the listing and termination floors; an identifier upgrade’s is 5,000 USDC. |
uma.BondQuoteError | class | A bond quote read from the chain that cannot be right: below the floor the contract itself enforces (an RPC answering 0, say). |
uma.AssertionHome | type | Where an assertion was made: the factory (listings, identifier upgrades) or the terminator (termination claims). |
uma.UmaAssertion | interface | An OOv3 assertion record. |
uma.AssertionPhase | type | Where an assertion stands, for a UI. |
uma.AssertionStatus | interface | What assertionStatus returns: the phase, the assertion record, and when it can be settled. |
uma.ASSERTION_PHASE_TEXT | const | Text for each assertion phase. |
uma.SettlementPreview | interface | What settling an assertion would do right now. |
uma.ASSERTION_EVENT_TEXT | const | What each OOv3 event means, for an activity feed. |
format
The error messages and the display formatters (errors/messages.ts).
| Export | Kind | What it is |
|---|---|---|
format.formatUsd | function | A BAZAAR-precision amount as dollars: “$1,234.56”; amounts under a cent keep six decimals. |
format.formatPrice | function | A BAZAAR-precision price: “$3,012.5”, “$1.08765”, “$0.00001234”. |
format.formatAmount | function | A BAZAAR-precision quantity (size, shares): “1.25”, “12,000”. |
format.formatBp | function | Basis points as percent: 250 -> “2.5%”. |
format.formatEth | function | Wei as ETH: “0.0001 ETH”. |
format.formatDuration | function | A duration in seconds, roughly: “45 s”, “12 min”, “3 h”, “13 days”. |
format.formatTime | function | A Unix time (seconds) as “2026-11-08 14:00 UTC (in 3 h)”. |
format.installUserMessages | function | Register USER_MESSAGES so DecodedError.message returns them. |
format.userMessage | function | The user-facing message for a custom error by name, with its arguments; the name itself when unknown. |
format.walletErrorMessage | function | A message for a failure the wallet raised rather than the contract. |
format.errorMessage | function | The text to show a user for any error a Bazaar call can throw. |
format.USER_MESSAGES | const | Every 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
| Export | Kind | What it is |
|---|---|---|
BAZAAR_SCALE | const | 1e18: BAZAAR precision, $1 or one unit. |
BP_SCALE | const | 10,000: basis points in 100%. |
EBP_SCALE | const | 1,000,000: EBP (1/100 bp) in 100%. |
USDC_DECIMALS | const | 6. |
USDC_TO_BAZAAR | const | BAZAAR precision carries twelve more decimals than USDC. |
MAX_PRICE_STALENESS | const | Seconds a print may sit from the block’s timestamp: 3. |
LIMBO_LEAD | const | Seconds before a scheduled cutoff that trading halts: 6. |
MIN_ORDER_AMOUNT | const | The smallest order notional: $5 (BAZAAR precision). |
MIN_COLLATERAL_DEPOSIT | const | The smallest collateral deposit: $5 (BAZAAR precision). |
MAX_WALK_STEPS | const | The default and largest walk budget of an arriving order: 32. |
MAX_TRIGGER_EXECS_PER_CALL | const | Trigger orders one executeTriggeredOrders call fires at most: 20. |
LIQUIDATION_FEE_EBP | const | The liquidation fee: 2 bp (EBP). |
MIN_LIQUIDATOR_REWARD | const | The least a liquidation pays its caller: $0.05. |
TERMINAL_SETTLEMENT_TIP | const | The tip for settling someone else’s position at the fixed settlement price: up to $0.05. |
TERMINAL_SETTLEMENT_ESCAPE | const | Seconds after the settlement price is fixed before finalizeTermination stops waiting for every position to be settled: 14 days. |
UNLOAD_BOUNTY_MIN | const | The floor of an unload’s reward: $0.25. |
UNLOAD_BOUNTY_MIN_NOTIONAL | const | The unload size at which the $0.25 floor applies, and the inventory notional for the fast refresh cadence: $1,250. |
UNLOAD_FLOOR_DECAY | const | Seconds over which the unload floor slides from the band edge to the fund-neutral price: 180. |
ADL_AUCTION_DURATION | const | Seconds the ADL score threshold takes to decay: 600. |
ADL_MAX_SCORE | const | The ADL score threshold at the start of the auction: 25 (BAZAAR precision). |
ADL_TERMINATION_GRACE | const | Seconds after the first price stored past an ADL timeout before the market winds down: 1,800. |
MAX_LIQUIDATION_PRICE_AGE | const | Oldest stored price (seconds) liquidate judges solvency against without a fresh priceUpdate: 600. |
ADL_TIMEOUT_DURATION | const | Seconds after which an open ADL window times out: 1 day. |
MAX_ADL_WINNERS_PER_BATCH | const | Most entries each winner list of an ADL batch may hold: 25. |
REFRESH_BOUNTY | const | What a paid refreshPrice earns: $0.10. |
REFRESH_BOUNTY_MIN_GAP | const | The fast refresh cadence: a paid refresh at most every 300 seconds. |
REFRESH_BOUNTY_HEARTBEAT | const | The slow refresh cadence: a paid refresh at most every day. |
REFRESH_BOUNTY_FAST_MIN_INSURANCE | const | Free capital above which the fast cadence applies: $5,000. |
REFRESH_BOUNTY_HEARTBEAT_MIN_INSURANCE | const | Free capital the fund must exceed for a refresh to pay: $1,000. |
MAX_CONFIDENCE_BP | const | The widest confidence interval a leg may carry: 2% of its price (bp). |
BAZAAR_EXPONENT | const | -18: the exponent the oracle converts prices to. |
ORACLE_DEAD_THRESHOLD | const | Seconds without a stored price after which a market can be terminated as stale: 21 days. |
SETTLEMENT_SAMPLE_COUNT | const | Samples the chain settlement rule takes: 5. |
SETTLEMENT_SAMPLE_SPACING | const | Seconds between the chain rule’s samples: 600. |
SETTLE_NOTHING | const | Settlement rule 0: nothing to settle. |
SETTLE_TICK | const | Settlement rule 1: UMA’s rule, the update stamped at the cutoff. |
SETTLE_CHAIN | const | Settlement rule 2: the chain rule’s median of samples. |
SETTLE_AWAIT_VERDICT | const | Settlement rule 3: waiting for a UMA verdict. |
OrderType | const + type | Order types by name (OrderType.Limit is 1), and the type of their values. |
MARKET | const | Order type 0. |
LIMIT | const | Order type 1. |
STOP_LIMIT | const | Order type 2. |
TAKE_PROFIT | const | Order type 3. |
STOP_LOSS | const | Order type 4. |
TRIGGER_TYPES | const | The trigger order types: StopLimit, TakeProfit, StopLoss. |
ORDER_TYPE_NAMES | const | Order type names by number. |
TRIGGER_PRICE_MASK | const | An order’s trigger price sits in the low 128 bits; its keeper bounty in the high 128. |
ETH_USD_FEED | const | Pyth Pro ETH/USD (feed 2) as a bytes32 feed id, used to price gas. |
ZERO_ADDRESS | const | The zero address. |
MAX_UINT256 | const | 2^256 - 1. |
Typed ABIs
| Export | Kind | What it is |
|---|---|---|
bazaarFactoryAbi | const | BazaarFactory’s ABI, typed as const for viem. |
bazaarOracleAbi | const | BazaarOracle’s ABI, typed as const for viem. |
bazaarPairAbi | const | BazaarPair’s ABI (with the events and errors of the libraries it links), typed as const for viem. |
bazaarPairLensAbi | const | BazaarPairLens’s ABI, typed as const for viem. |
bazaarPairTerminatorAbi | const | BazaarPairTerminator’s ABI, typed as const for viem. |
ABI_SOURCE | const | The Bazaar commit and date the ABIs were synced from, and whether that tree was dirty. |
ABIS | const | The five ABIs by contract name. |
ContractName | type | "BazaarFactory", "BazaarOracle", "BazaarPair", "BazaarPairLens" or "BazaarPairTerminator". |
CONTRACT_NAMES | const | The five contract names. |
Chain, JSON-RPC and the Probe
| Export | Kind | What it is |
|---|---|---|
readTextCapped | function | A response’s body as text, holding at most maxBytes of it. |
toRpcError | function | Normalize whatever a transport threw into an RpcError when it carries a JSON-RPC error. |
sleep | function | A promise that resolves after ms milliseconds. |
RequestArgs | interface | A JSON-RPC request: method and params. |
Transport | interface | EIP-1193’s request method: what a browser wallet, a viem client and JsonRpc all provide. |
RpcError | class | A JSON-RPC error: its code, the node’s message and data, and the revert payload when there is one. |
HttpError | class | A non-2xx HTTP response. |
ResponseTooLargeError | class | A response body over the reader’s byte limit. |
JsonRpcOptions | interface | Options of JsonRpc: requests per second, timeout, retries, headers, response size cap and fetch. |
RequestOptions | interface | Per-request options of JsonRpc.request. |
NEVER_RETRIED | const | Methods JsonRpc sends at most once, whatever retries says. |
JsonRpc | class | A small JSON-RPC client over HTTP(S): rate limited, retried on transient failures except for sends, with response bodies capped. |
SendError | class | A send whose outcome is unknown: the signed transaction left this process, but nothing settled whether the node took it. |
Block | interface | A block’s number, timestamp, hash, parent hash and base fee. |
ProbeResult | interface | A probe simulation’s result: ok, return data, claimable balance before and after, gas, and the state flags after. |
Read | interface | One call in a batched read. |
Receipt | interface | A transaction receipt: hash, block, status, gas, sender, target and logs. |
BlockTag | type | A block number, or latest, pending, safe, finalized or earliest. |
ChainOptions | interface | Options of Chain: send, a separate transport to submit transactions through. |
Chain | class | Chain access over any Transport: reads, batched reads, probe simulations, gas, logs, receipts and raw sends. |
findDeploymentBlock | function | The first block whose state holds code at address. |
DeploymentBlockError | class | findDeploymentBlock could not find the block (no code at the head, or the node cannot read old state). |
FindDeploymentOptions | interface | Options of findDeploymentBlock: the block range to search. |
probeCodec | function | The simulation probe’s codec (execute, aggregate). |
stateOverride | function | The eth_call state override that places the probe at PROBE_ADDRESS. |
PROBE_ADDRESS | const | Where the probe’s code is placed by state override. |
PROBE_BALANCE | const | ETH the override gives the probe, to pay Pyth fees inside simulations. |
runtimeCode | const | runtimeCode(): the probe’s runtime bytecode. |
StateOverride | type | The eth_call state override that places the probe. |
Codecs and Revert Decoding
| Export | Kind | What it is |
|---|---|---|
canonicalType | function | An ABI parameter’s canonical type string (tuples expanded). |
signature | function | A function or event signature string, as hashed for selectors and topics. |
codec | function | The cached ContractCodec for one of the five contracts. |
allErrors | function | Every custom error the five contracts declare, by selector. |
FunctionSpec | interface | A function in a codec: name, inputs, outputs, selector. |
EventSpec | interface | An event in a codec: name, inputs, topic0. |
ErrorSpec | interface | A custom error in a codec: name, inputs, selector. |
RawLog | interface | A log as Chain.logs and Chain.receipt return it. |
DecodedLog | interface | A decoded log: name, address, args, and where it sits. |
ContractCodec | class | Encode and decode one contract’s calls, return values, logs and errors (encode, decode, decodeLog, …). |
factory | const | factory(): the BazaarFactory codec. |
pair | const | pair(): the BazaarPair codec. |
lens | const | lens(): the BazaarPairLens codec. |
terminator | const | terminator(): the BazaarPairTerminator codec. |
oracle | const | oracle(): the BazaarOracle codec. |
registerUserMessages | function | Register user-facing messages by error name for DecodedError.message (what installUserMessages calls). |
decodeError | function | Revert data as a DecodedError: the error’s name, arguments and hint. |
KEEPER_HINTS | const | What each error means for a keeper, and what to do about it. |
VERIFIER_REASON_HINTS | const | What each reason string Pyth’s verifier reverts with means for a keeper. |
registerReasonMessages | function | Register user-facing messages for plain revert reasons, by the reason string. |
DecodedError | class | A decoded revert: name, args, hint, data, message (the user text), and opensAt() for a too-early error. |
Reader, Models and Formulas
| Export | Kind | What it is |
|---|---|---|
freeCapital | function | The insurance balance less the seizure earmarked against inventory the vault holds. |
inventorySize | function | The vault’s liquidated inventory size on both sides of a MarketState. |
marketStatus | function | A MarketState’s Status at a time: terminated, settling, limbo, adl or live. |
marketStatic | function | The part of a Market that never changes. |
marketFromStatic | function | A Market rebuilt from its static part, with no state read yet. |
orderRemaining | function | An order’s unfilled size. |
Status | const + type | A market’s status (Status.Live is "live"; also adl, limbo, settling, terminated), and the type of its values. |
Inventory | interface | The vault’s liquidated inventory on one side: size, entry and bankruptcy notional, seized collateral, funding index. |
emptyInventory | const | An Inventory with every field zero. |
MarketState | interface | One read of a market’s on-chain state (Reader.marketStates). |
Market | interface | A market as the keeper and Reader track it: address, pair id, feeds, calendar, label, and its last state read. |
MarketStatic | interface | The part of a Market that never changes, as stored in snapshots. |
Position | interface | An open position as the keeper indexes it. |
Order | interface | A live order as the keeper indexes it. |
mulDiv | function | OpenZeppelin Math.mulDiv for non-negative operands: floor(x * y / d). |
signedMulDiv | function | BazaarMathLib.signedMulDiv: (x * y) / d rounded toward zero. |
effectiveMmrBp | function | BucketLib.effectiveMmrBp: the lower of the current rate and a non-zero lagged one. |
bucketState | function | BucketLib.calculateState, with mmrBp already the effective rate. |
marginRatio | function | Equity over the maintenance requirement: below 1 is liquidatable. |
triggerReached | function | MatchingEngineLib._triggerReached, inclusive at the trigger. |
adlThreshold | function | AdlLib._getAdlScoreThreshold: quadratic decay from ADL_MAX_SCORE to 1 over the auction. |
adlScore | function | PnL over the collateral less this window’s tagged deposit, floored at 1 (AdlLib._scoreCollateral). |
refreshBountyGap | function | The minimum gap between paid refreshes for a market (fast or heartbeat cadence). |
refreshBountyDue | function | Whether refreshPrice would pay its bounty when it stores a print published at newPublishTs. |
refreshBountyDueAt | function | The first block time at which the bounty is payable, or null while the fund is too small. |
convertExponent | function | BazaarMathLib.convertExponent (division truncates toward zero). |
legConfOk | function | Whether a leg’s confidence interval is within 2% of its price. |
pairPrice | function | The price BazaarOracle composes from one or two legs, and whether it clears the 2% cap. |
BucketState | interface | What bucketState returns: PnL, funding PnL, notional, the maintenance requirement and available equity. |
MAX_EXPONENT_SHIFT | const | The 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). |
Leg | interface | One Pyth leg of a price: price, confidence, exponent, publish time. |
PairPrice | interface | What pairPrice returns: spot, the confidence bracket, the publish time, and whether confidence is within the cap. |
Deployment | interface | The contracts a factory is wired to. |
OrderRecord | interface | orders(id) on a pair, with the lens’s triggerBounty for it. |
PositionBucket | interface | positionBuckets(account) on a pair: an account’s position as the chain holds it now. |
Reader | class | Batched chain reads: the deployment’s wiring, market discovery, per-block market state, positions and orders. |
Subgraph Client
| Export | Kind | What it is |
|---|---|---|
SUBGRAPH_PAGE | const | Rows per page: the most a hosted graph-node returns for one first:. |
SubgraphClient | class | A GraphQL client for Bazaar’s subgraph: queries, the indexer’s head, and paging pinned to a block. |
SubgraphError | class | A SubgraphClient query the subgraph refused or failed (not the same class as history.SubgraphError). |
SubgraphMeta | type | The indexer’s head: the block it has indexed, its hash, and whether it hit indexing errors. |
SubgraphOptions | interface | Options 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.
| Export | Kind | What it is |
|---|---|---|
lazer.parseUpdate | function | Reads a Pyth Pro update and recovers its signer: { signer, timestampUs, channel, feeds, payload }. |
lazer.readUpdate | function | Reads an update without recovering its signer. |
lazer.parsePayload | function | Reads a signed payload as LazerPayloadLib.readFeeds does; throws where the contract reverts as malformed. |
lazer.splitUpdate | function | Splits an update into its signature and its payload, as Pyth’s verifier does. |
lazer.recoverSigner | function | The address that signed an update. |
lazer.encodePayload | function | Encodes a payload from feed fields (tests, local chains). |
lazer.signPayload | function | Wraps a payload in the EVM envelope, signed with a private key. |
lazer.buildUpdate | function | encodePayload, then signPayload. |
lazer.localSignerKey | function | The private key Bazaar’s local deployments sign updates with: public by construction. |
lazer.LOCAL_SIGNER_LABEL | const | The label the local signing key is derived from. |
lazer.normalizeHex | function | Lowercase, 0x-prefixed form of hex given with or without 0x, or null when it is not whole bytes of hex. |
lazer.channelName | function | A payload channel number’s name, or null. |
lazer.ENVELOPE_MAGIC | const | The first four bytes of an update in the EVM envelope. |
lazer.PAYLOAD_MAGIC | const | The first four bytes of a payload. |
lazer.ENVELOPE_HEADER_BYTES | const | Envelope bytes before the payload: 71. |
lazer.MAX_PAYLOAD_BYTES | const | The longest payload an envelope can frame. |
lazer.MICROS_PER_SECOND | const | 1,000,000n. |
lazer.HAS_PRICE | const | Bit of LazerFeed.included: the payload carries the price. |
lazer.HAS_EXPONENT | const | Bit of LazerFeed.included: the payload carries the exponent. |
lazer.HAS_CONFIDENCE | const | Bit of LazerFeed.included: the payload carries the confidence. |
lazer.HAS_FEED_UPDATE_TIMESTAMP | const | Bit of LazerFeed.included: the payload carries the feed update timestamp. |
lazer.LIVE_PROPERTIES | const | What a feed must carry to price a live call. |
lazer.TICK_PROPERTIES | const | What a one-second update must carry for a feed to settle a pair at it. |
lazer.CHAIN_PROPERTIES | const | What a one-second update must carry to prove a chain sample. |
lazer.Envelope | interface | An update’s envelope, split: r, s, the recovery id and the payload. |
CHANNEL_IDS | const | Each channel’s number in a payload. |
CHANNEL_FIXED_RATE_1000 | const | The number of the channel whose updates are stamped once per second, on whole seconds. |
Channel | type | The channels Pyth Pro produces updates on: real_time, fixed_rate@50ms, fixed_rate@200ms, fixed_rate@1000ms. |
LazerFeed | interface | One feed’s fields in one payload, with which properties the payload carries (included). |
LazerFeedInput | interface | One feed to encode; a property left undefined is left out of the payload. |
LazerPayload | interface | A payload’s contents: timestamp, channel, feeds. |
LazerPayloadInput | interface | What encodePayload takes. |
LazerUpdate | interface | An update, read from its bytes: the payload’s contents, the signer, the payload. |
LazerError | class | An update or feed id that is not valid. |
feedIdHex | function | The bytes32 feed id that holds a Pyth Pro feed number (1 → 0x00…01). |
proFeedId | function | The Pyth Pro feed number a raw bytes32 feed id holds; throws on anything else. |
isRawFeedId | function | Whether an id has the form of a raw Pyth Pro feed id (BazaarOracle.isRawFeedId). |
ProClient | class | A client for Pyth Pro: latest, latestUpdate, atTick, stream, symbols and close. |
ProClientOptions | interface | Options of ProClient: the token, the endpoints, the channel, headers, timeout, fetch, WebSocket, streamAuth and isTrustedSigner. |
ProError | class | A Pyth Pro request or stream that failed; its message never shows a token. |
ProUpdate | interface | A signed update as the client read it: the blob, the signer, the timestamp, the channel, every feed’s entry, and the live prints. |
ProSymbol | interface | One entry of Pyth Pro’s symbols registry. |
Print | interface | One feed’s price and the signed update that carries it. |
SignerCheck | type | Says whether updates signed by a signer may be used. |
TokenProvider | type | Returns the token to present: an API key (servers only) or a short-lived token minted from one. |
MintedToken | interface | A short-lived token minted from an API key: the token, when it expires, its type. |
mintProToken | function | Exchange a Pyth Pro API key for a short-lived token (server side). |
cachedToken | function | A token provider that mints only when the token it holds is about to expire. |
UNKNOWN_EXPIRY_LIFETIME | const | Seconds a token whose expiry is unknown is reused for by cachedToken. |
WebSocketConstructor | type | A WebSocket constructor: the global one, or a package’s. |
WebSocketLike | interface | The parts of a WebSocket the client uses. |
AUTH_SUBPROTOCOL | const | The subprotocol that marks the next entry of a WebSocket’s subprotocol list as the token. |
DEFAULT_CHANNEL | const | The channel a client streams and reads the latest price on unless told otherwise. |
PRO_PROPERTIES | const | The properties every request asks for. |
MAX_RESPONSE_BYTES | const | The largest response body a request may return, in bytes (16 MiB). |
MAX_STREAM_MESSAGE | const | The longest message a price stream may send, in characters (1 MiB). |
MAX_STREAM_NOTICES | const | How many distinct notices one stream passes to its onNotice before it drops the rest. |
STREAM_IDLE_TIMEOUT | const | Seconds a price stream may bring no new update before it is abandoned and reconnected. |
StreamOptions | interface | Options of ProClient.stream: idleTimeout and onNotice. |
isLivePrint | function | Whether a feed entry holds a price a live call could use. |
whyNotLive | function | Why a feed entry holds no price a live call could use, or null when it holds one. |
printsOf | function | The prints a parsed update carries: one per feed that holds a live price. |
ETH_USD_BOUNDS | const | Default PriceServiceOptions.ethUsdBounds. |
ETH_USD_MAX_AGE | const | Default PriceServiceOptions.ethUsdMaxAge. |
MAX_FUTURE_SKEW | const | Default PriceServiceOptions.maxFutureSkew. |
PriceLogger | interface | Where a PriceService reports warnings. |
PriceService | class | Live prints for every watched feed (streamed, or polled as a fallback), and per-market prices and updates. |
PriceServiceOptions | interface | Options of PriceService: streaming, idle timeout, poll interval, clock, send margin, future skew, ETH/USD age and bounds, and logger. |
PriceSource | interface | Where a PriceService gets its prints: a ProClient, or anything shaped like one (latest, and stream over sets of feeds). |
PrintListener | type | Called after each batch of prints with the feeds that got a newer print in it. |
distinctBlobs | function | The distinct signed updates a list of prints came in, in the prints’ order. |
feedLabel | function | ‘Crypto.BTC/USD’ -> ‘BTC/USD’, ‘Equity.US.AAPL/USD’ -> ‘AAPL/USD’. |
fetchFeedInfo | function | Pyth Pro’s registry entries for feeds, by feed id; empty when the registry cannot be read. |
fetchSymbols | function | Pyth symbols for feeds, from the symbols registry; empty when it cannot be read. |
marketLabel | function | A market’s label from its legs’ Pyth symbols; a composite (with a quote leg) is priced in USD. |
URLs
| Export | Kind | What it is |
|---|---|---|
isUrl | function | Whether value is an http(s) or ws(s) URL, as fetch would read it. |
urlCredential | function | Where a URL may carry a credential, as a phrase (a key in its path, …), or null when it carries none. |
redactUrl | function | The URL with every part not known to be public replaced by mask, safe to print or log (see the module’s rules). |
PATH_WORDS | const | Path segments that are never secret (compared without case). |
PUBLIC_PARAMS | const | Query 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
| Export | Kind | What it is |
|---|---|---|
VERSION | const | The keeper package’s version. |
CATCH_UP_BLOCKS | const | A 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. |
JobStatusEntry | interface | One job in the status file. |
Keeper | class | The keeper: Keeper.create(config), start(), stop(), close(), on(event), and run for one-shot operations. |
KeeperEvent | type | The name of a KeeperEvents event. |
KeeperEvents | interface | What Keeper.on delivers, by event. |
KeeperListener | type | A listener for one KeeperEvents event. |
KeeperOptions | interface | Options of Keeper.create: loadWallets (false for a watch-only keeper). |
KeeperStatus | interface | status.json, as the Python keeper writes it (the same keys), for bazaar keeper status. |
MarketStatusEntry | interface | One market in the status file. |
Signal | class | A settable event: set, clear, and wait until set. |
StopOptions | interface | Options of Keeper.stop: whether to collect first, and the timeout. |
listMarkets | function | The factory’s markets with their labels, without indexing anything (for setup and listings). |
stateDirFor | function | Where a keeper for this network and factory keeps its snapshot, ledger and status. |
Configuration
| Export | Kind | What it is |
|---|---|---|
ConfigError | class | A configuration problem, phrased for the person fixing it. |
DEFAULT_CONFIG | const | The default config file name: bazaar-keeper.yaml. |
SECRET_KEYS | const | Keys whose values are secrets. |
expandHome | function | A path with a leading ~ expanded to the home directory. |
loadConfig | function | Read a config file, its sibling .env, and the environment. |
parseConfig | function | Validate an already-interpolated config object. |
enabledJobs | function | The jobs switched on, by name. |
priceEndpoints | function | Where 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. |
resolvedNetwork | function | The network a config names, with its preset applied: chain id, factory, start block, subgraph and whether official. |
subgraphUrl | function | The subgraph URL a config resolves to, or null for none. |
RpcConfig | const + type | The rpc section’s zod schema and type. |
SubgraphConfig | const + type | The subgraph section’s zod schema and type. |
PricesConfig | const + type | The prices section’s zod schema and type. |
WalletConfig | const + type | One entry of wallets: its zod schema and type. |
JobConfig | const + type | The options every job shares: zod schema and type. |
LiquidationJob | const + type | The jobs.liquidation section’s zod schema and type. |
TriggersJob | const + type | The jobs.triggers section’s zod schema and type. |
UnloadJob | const + type | The jobs.unload section’s zod schema and type. |
AdlJob | const + type | The jobs.adl section’s zod schema and type. |
RefreshJob | const + type | The jobs.refresh section’s zod schema and type. |
CollectJob | const + type | The jobs.collect section’s zod schema and type. |
TerminalSettlementJob | const + type | The jobs.terminalSettlement section’s zod schema and type. |
SweepExpiredJob | const + type | The jobs.sweepExpired section’s zod schema and type. |
JobsConfig | const + type | The jobs section’s zod schema and type. |
ProfitConfig | const + type | The profit section’s zod schema and type. |
GasConfig | const + type | The gas section’s zod schema and type. |
BudgetConfig | const + type | The budget section’s zod schema and type. |
SafetyConfig | const + type | The safety section’s zod schema and type. |
ALERT_KINDS | const | The alert kinds alerts.notify can name: lowBalance, jobPaused, endpointDown, opportunity, dailySummary, action. |
AlertKind | type | One of ALERT_KINDS. |
AlertsConfig | const + type | The alerts section’s zod schema and type. |
StateConfig | const + type | The state section’s zod schema and type. |
LogConfig | const + type | The log section’s zod schema and type. |
EngineConfig | const + type | The engine section’s zod schema and type. |
KeeperConfig | const + type | The whole keeper config’s zod schema and type. |
JOB_NAMES | const | The jobs in the order the keeper runs and reports them, by their config key. |
JobName | type | One of JOB_NAMES. |
ResolvedNetwork | interface | What resolvedNetwork returns. |
Wallets and Signers
| Export | Kind | What it is |
|---|---|---|
KEYS_DIR | const | Where bazaar wallet new puts keystores (expand with expandHome). |
KeystoreV3 | interface | A v3 JSON keystore. |
LocalSigner | class | Signs with a key held in this process. |
RemoteSigner | class | Any signer that answers eth_signTransaction over JSON-RPC (Web3Signer, a KMS bridge). |
RemoteSignerOptions | interface | Options of RemoteSigner: timeout and fetch. |
Signer | interface | Something that signs keeper transactions: an address and sign(tx). |
TxRequest | interface | An EIP-1559 (type 2) transaction, as the executor builds it. |
WalletError | class | A wallet that cannot be loaded or used (a wrong password, a bad key or phrase, a remote signer’s failure). |
checkMnemonic | function | The phrase, normalized, after the checks eth-account makes: a known word count, every word from one language’s list, and a matching checksum. |
decryptKeystore | function | The private key in a v3 keystore. |
encryptKeystore | function | A v3 keystore for key, with the Python keeper’s parameters and field order. |
loadSigner | function | The signer for one configured wallet. |
newKey | function | A new random private key. |
writeKeystore | function | Encrypt key to a JSON keystore at path (owner-only permissions where the OS has them); returns the address. |
Execution, Ledger and Spend Policy
| Export | Kind | What it is |
|---|---|---|
Action | class | A call a job wants made: target, data, value, summary, locks, wallet and how it is paid. |
ActionInit | interface | What an Action is built from. |
ActionResult | class | What happened to an action: status, tx hash, gas, cost, reward, claimed, and the decoded error. |
ActionResultInit | interface | What an ActionResult is built from. |
Emit | type | How the executor reports events (ExecutorEvents). |
Executor | class | Simulate, decide, send and track: the path every keeper action takes, with one nonce lane per wallet. |
ExecutorEvents | interface | What the executor reports: “alert” [kind, message], “sent” [action, txHash], “action” (a sent call’s result). |
Lane | class | One wallet: its signer and its nonce sequence. |
Mutex | class | A small FIFO mutex: acquire() returns nothing when the lock is free, so the free path never waits. |
ResultStatus | type | An action’s outcome: confirmed, reverted, unknown, skipped or simulated. |
RunOptions | interface | Options of Executor.run: dryRun, force and the job’s config. |
lockKey | function | One key in the executor’s in-flight set, such as lockKey("liq", market.address, account). |
DAY | const | 86,400 seconds. |
Entry | class | One ledger line: a transaction the keeper sent, with its cost, reward and kind. |
EntryInit | interface | What an Entry is built from. |
EntryKind | type | What an entry is: profit | loss | unpaid | collect | forward | manual | forced. |
EntryStatus | type | confirmed, reverted or unknown. |
Ledger | class | The earnings ledger (ledger.jsonl): every transaction sent, and the daily budgets read from it. |
SummaryRow | interface | One row of the ledger’s summary: calls, reward, cost, net, claimed and reverted. |
DecideOptions | interface | Options of SpendPolicy.decide: whether the call is paid, an unpaid duty’s cap, and force. |
Decision | interface | What SpendPolicy.decide returns: whether to send, the kind, and the reason. |
DecisionKind | type | profit | loss | unpaid | forced | skip, or a job’s own (collect, forward). |
Quote | class | What a simulated call would earn and cost, in USD, and what it costs in wei. |
SpendPolicy | class | When 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").
| Export | Kind | What it is |
|---|---|---|
ActionExtras | type | Everything an Action takes besides what Job.action fills in. |
JOBS | const | Config key (camelCase, as JobsConfig names them) -> job class. |
Job | class | The base class of a keeper job: its scope, cooldowns, the price it acts on, and plan (or planGlobal). |
JobClass | type | The constructor every job class has (the config is the job’s own schema type). |
PricedCall | class | The price a planner judges by and the update payload its call carries. |
jobs.AdlJob | class | The adl job. |
jobs.ALL_STATUSES | const | Every market status (for a job that runs in all of them). |
jobs.ActionExtras | type | Everything an Action takes besides what Job.action fills in. |
jobs.Job | class | The base class of a keeper job: its scope, cooldowns, the price it acts on, and plan (or planGlobal). |
jobs.JobClass | type | The constructor every job class has (the config is the job’s own schema type). |
jobs.LIVE | const | The statuses most jobs run in. |
jobs.LIVE_OR_ADL | const | Live, or with an ADL window open (matching halts; liquidation, ADL and refresh still run). |
jobs.PricedCall | class | The price a planner judges by and the update payload its call carries. |
jobs.monotonic | const | Seconds on a monotonic clock, for cooldowns and schedules. |
jobs.CollectJob | class | The collect job. |
jobs.LiquidationJob | class | The liquidation job. |
jobs.belowMaintenance | function | Whether p is below its maintenance requirement at price and fundingIndex. |
jobs.RefreshJob | class | The refresh job. |
jobs.SweepExpiredJob | class | The sweepExpired job. |
jobs.TerminalSettlementJob | class | The terminalSettlement job. |
jobs.TriggersJob | class | The triggers job. |
jobs.UnloadJob | class | The unload job. |
jobs.JOBS | const | Config key (camelCase, as JobsConfig names them) -> job class. |
Manual Operations
| Export | Kind | What it is |
|---|---|---|
AdlOptions | interface | Options of keeper.run.executeAdl: the winners to close on each side. |
CollectOptions | interface | Options of keeper.run.collect: the markets to claim on. |
FactoryOperations | class | The factory’s permissionless operations: listings and identifier upgrades. |
LiquidateOptions | interface | Options of keeper.run.liquidate: the accounts to liquidate. |
ManualOptions | interface | How a manual operation goes. |
OpOptions | interface | How a one-shot run of an automatic job goes. |
Operations | class | keeper.run: every job once, on one market, and the manual operations. |
SettleOptions | interface | Options of keeper.run.settleTerminal: the accounts to settle. |
SweepOptions | interface | Options of keeper.run.sweepExpired: the orders to sweep. |
TriggerOptions | interface | Options of keeper.run.executeTriggers: the orders to fire. |
Opportunity | class | A 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). |
findOpportunities | function | Every 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
| Export | Kind | What it is |
|---|---|---|
Effects | class | What applying logs changed, for the engine to act on. |
SNAPSHOT_VERSION | const | The version of the keeper’s snapshot format (the Python keeper’s is 1; each ignores the other’s and bootstraps afresh). |
Store | class | The keeper’s local index: markets, open positions and live orders, kept current from logs. |
handledPairEvents | function | The names of the pair events the store reacts to (for tests that check them against the ABI). |
bootstrap | function | Load open positions and live orders as of block into the store, for the markets it holds, and return how many of each it loaded. |
LogTail | class | Follows the logs of the factory, the terminator and every market into a Store, handling reorgs and refused ranges. |
REGROW_AFTER | const | Log requests in a row that succeed at a reduced range before the tail tries one twice as wide. |
REORG_REWIND | const | Blocks the tail re-reads when the stored head’s hash no longer matches the chain. |
TailDeployment | interface | The contracts the tail follows besides the markets (the SDK’s Deployment fits). |
TailLogger | interface | Where the tail reports what it does. |
refused | function | Whether 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
| Export | Kind | What it is |
|---|---|---|
Alerter | class | Sends alerts to Telegram, Discord or a webhook, with repeats suppressed. |
AlerterOptions | interface | Options of Alerter: timeout and fetch. |
SendOptions | interface | Options of Alerter.send: repeatAfter. |
LogFormat | type | pretty or json. |
LogLevel | type | debug, info, warning or error. |
Logger | interface | A named logger: debug, info, warning, error, exception and isEnabledFor. |
formatMessage | function | Fill a %-template; returns the text and the exception argument, if any. |
getLogger | function | The logger with this name, created on first use. |
setup | function | Configure every logger: the lowest level shown, and the output format. |
setupLogging | function | Configure every logger: the lowest level shown, and the output format. |
CLI and Dev Tools
| Export | Kind | What it is |
|---|---|---|
runDoctor | function | Run every check; true when nothing failed (warnings allowed). |
runWizard | function | Ask 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. |
MockPro | class | A 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. |
MockProOptions | interface | What new MockPro takes: starting prices and symbols, the chain (or clock) to follow, the signing key, the default confidence, a token to require. |
MockProServer | interface | A running server: its url, streamUrl, host, port and close(). |
MockClock | interface | The time a MockPro stamps updates with. |
MockUpdate | interface | A signed update the mock built, and the feeds it carries. |
MockUpdateSpec | interface | What an update is asked to hold: its stamp, channel and properties. |
MockFeedState | interface | One feed as an update stamped at some instant carries it. |
FeedRef | type | A feed as the mock takes it: its Pyth Pro number, decimal digits, or the bytes32 that holds it. |
FeedEntries | type | Feed -> value, as a record, a Map or entries. |
feedNumber | function | The Pyth Pro feed number a FeedRef names. |
startMockPro | function | Serve a MockPro in the background (port 0 picks a free one); resolves once it listens. |
serveMockPro | function | bazaar mock-prices: serve in the foreground until Ctrl-C (or SIGTERM). |
Related Pages
- Connecting: the
Bazaarobject and the context. - Low-Level Building Blocks: the chain, codec, price and subgraph layers.
- The Keeper as a Library:
Keeperand its parts. - Development: regenerating the bindings and the ABIs.
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
| Quantity | Type | Scale |
|---|---|---|
| Prices, sizes, notionals, collateral, PnL, fees, rewards, bounties, funding indices | bigint | 18 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 ..._USDC | bigint | 6 decimals: 1_000_000n is 1 USDC |
Rates and fees in basis points (imrBp, maxSlippageBp) | bigint | 10_000n is 100% |
Rates in “extended” basis points (..._EBP) | bigint | 1_000_000n is 100% |
| Funding rates per hour, terminal payout ratios | bigint | 18 decimals: 1e18 is 100% |
| Times (Unix seconds), durations (seconds), block numbers, counts, enum values | number | |
An order spec’s expirationBlock | bigint | A 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 bp | number |
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"
| Function | Does |
|---|---|
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:
| Function | Example |
|---|---|
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
| Function | Does |
|---|---|
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
| Constant | Value |
|---|---|
BAZAAR_SCALE | 10n ** 18n |
BP_SCALE | 10_000n |
EBP_SCALE | 1_000_000n |
USDC_DECIMALS | 6 |
USDC_TO_BAZAAR | 10n ** 12n: 18 decimals carry twelve more than USDC |
BAZAAR_EXPONENT | -18: the oracle’s price exponent |
MAX_UINT256 | 2n ** 256n - 1n |
ZERO_ADDRESS | 0x0000000000000000000000000000000000000000 |
Trading
| Constant | Value | Meaning |
|---|---|---|
MAX_PRICE_STALENESS | 3 | Seconds a Pyth print may sit from the block’s timestamp. |
LIMBO_LEAD | 6 | Seconds before a scheduled cutoff at which trading halts. |
MIN_ORDER_AMOUNT | 5n * BAZAAR_SCALE | $5: the smallest order notional (a full close is exempt). |
MIN_COLLATERAL_DEPOSIT | 5n * BAZAAR_SCALE | 5 USDC: the smallest deposit. |
MAX_WALK_STEPS | 32 | Resting orders an arriving order’s walk visits at most (the default budget). |
MAX_TRIGGER_EXECS_PER_CALL | 20 | Trigger orders one executeTriggeredOrders fires. |
MAX_CONFIDENCE_BP | 200n | 2%: the widest Pyth confidence the oracle accepts. |
TRIGGER_PRICE_MASK | 2n ** 128n - 1n | A trigger order’s price sits in the low 128 bits of its triggerPrice word, its keeper bounty in the high 128. |
ETH_USD_FEED | 0x0000...0002 | Pyth Pro’s ETH/USD feed (feed 2, as a bytes32), used to price gas. |
On the orders namespace:
| Constant | Value | Meaning |
|---|---|---|
orders.MAX_SLIPPAGE_BP | 1000n | 10%: the widest slippage cap of a Market, TakeProfit or StopLoss order. |
orders.MAX_PLACEMENT_DEVIATION_BP | 5000n | A limit price must sit within ±50% of the oracle price. |
orders.MIN_ORDER_LIFETIME_BLOCKS | 240 | About a minute of Arbitrum blocks: the shortest life of an order that rests. |
orders.EXPIRY_LANDING_MARGIN_BLOCKS | 40 | Slack the SDK adds to the minimum when it checks an expiry. |
orders.MAX_ORDER_LIFETIME_BLOCKS | 126_144_000 | About a year; a later expiry is clamped by the pair. |
orders.NEVER_EXPIRE_BLOCK | 2n ** 64n - 1n | The expiry the pair stamps on TP and SL orders. |
orders.L2_BLOCKS_PER_SECOND | 4 | Arbitrum’s block rate, for orders.blocksIn(seconds). |
orders.MAX_ACTIVE_LIMIT_ORDERS_PER_USER | 32 | Live Limit (GTC) and StopLimit orders per account per market. |
orders.MAX_AMENDS_PER_CALL | 32 | Amendments per amendOrders. |
orders.MAX_CANCELS_PER_CALL | 200 | Order ids per cancelOrders. |
orders.CLOSE_POSITION | MAX_UINT256 | The size of a TP or SL that closes the whole position. |
orders.AMEND_BEST_EFFORT_BIT | 2n ** 192n | The flag in an amendment’s id word that skips rather than reverts. |
Gasless Calls and Permits
| Constant | Value | Meaning |
|---|---|---|
signing.MAX_RELAYER_FEE | BAZAAR_SCALE | $1: the most a relayer may charge per action. |
signing.MAX_DEADLINE_WINDOW | 10 | Seconds a gasless signature may be valid past chain time. |
signing.MAX_CLOCK_SKEW | 120 | Seconds this device’s clock may differ from the chain’s before signing is refused. |
permits.DEFAULT_PERMIT_WINDOW | 1800 | Seconds a USDC permit is valid by default. |
Markets and Prices
| Constant | Value | Meaning |
|---|---|---|
markets.VENUE_CLOSED_AFTER | 15 | Seconds without a print before a trading-hours venue counts as closed. |
markets.MAX_FUNDING_RATE_CONTINUOUS | BAZAAR_SCALE / 1000n | 0.1% an hour: the funding cap on 24/7 markets. |
markets.MAX_FUNDING_RATE_SESSION | BAZAAR_SCALE / 200n | 0.5% an hour: the cap on trading-hours markets. |
priceFeeds.BROWSER_FUTURE_SKEW | 120 | Seconds a live print may be dated ahead of this device’s clock. |
MAX_FUTURE_SKEW | 5 | The same, for PriceService (the keeper’s default). |
Liquidation, ADL, Settlement
Mostly the keeper’s concern, exported for completeness:
| Constant | Value | Meaning |
|---|---|---|
LIQUIDATION_FEE_EBP | 200n | 2 bp. |
MIN_LIQUIDATOR_REWARD | BAZAAR_SCALE / 20n | $0.05. |
MAX_LIQUIDATION_PRICE_AGE | 600 | Seconds a stored price may be old and still seize collateral. |
UNLOAD_BOUNTY_MIN | BAZAAR_SCALE / 4n | $0.25. |
UNLOAD_BOUNTY_MIN_NOTIONAL | 1_250n * BAZAAR_SCALE | $1,250. |
UNLOAD_FLOOR_DECAY | 180 | Seconds. |
ADL_AUCTION_DURATION | 600 | Seconds the ADL score threshold takes to decay to its floor. |
ADL_MAX_SCORE | 25n * BAZAAR_SCALE | |
ADL_TIMEOUT_DURATION | 86_400 | Seconds an ADL window may stay open. |
ADL_TERMINATION_GRACE | 1_800 | Seconds after an ADL timeout before it may terminate the pair. |
MAX_ADL_WINNERS_PER_BATCH | 25 | |
REFRESH_BOUNTY | BAZAAR_SCALE / 10n | $0.10. |
REFRESH_BOUNTY_MIN_GAP | 300 | Seconds. |
REFRESH_BOUNTY_HEARTBEAT | 86_400 | Seconds. |
REFRESH_BOUNTY_FAST_MIN_INSURANCE | 5_000n * BAZAAR_SCALE | $5,000. |
REFRESH_BOUNTY_HEARTBEAT_MIN_INSURANCE | 1_000n * BAZAAR_SCALE | $1,000. |
TERMINAL_SETTLEMENT_TIP | BAZAAR_SCALE / 20n | $0.05 per position settled. |
TERMINAL_SETTLEMENT_ESCAPE | 14 * 86_400 | Seconds: the longest a settlement window lasts. |
ORACLE_DEAD_THRESHOLD | 21 * 86_400 | Seconds without a stored price before a pair counts as stale. |
SETTLEMENT_SAMPLE_COUNT, SETTLEMENT_SAMPLE_SPACING | 5, 600 | The chain rule’s samples and their spacing (seconds). |
SETTLE_NOTHING, SETTLE_TICK, SETTLE_CHAIN, SETTLE_AWAIT_VERDICT | 0 to 3 | Settlement 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
OrderType | Value | Also exported as |
|---|---|---|
Market | 0 | MARKET |
Limit | 1 | LIMIT |
StopLimit | 2 | STOP_LIMIT |
TakeProfit | 3 | TAKE_PROFIT |
StopLoss | 4 | STOP_LOSS |
ORDER_TYPE_NAMES[type] gives the name; TRIGGER_TYPES lists the three trigger types.
orders.TimeInForce | Value | Meaning |
|---|---|---|
GTC | 0 | Rest the remainder in the book (only Limit and StopLimit rest). The default. |
IOC | 1 | Fill what crosses on arrival, cancel the rest. Limit only. |
FOK | 2 | Fill 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.
Status | Value |
|---|---|
Status.Live | "live" |
Status.Adl | "adl" |
Status.Limbo | "limbo" |
Status.Settling | "settling" |
Status.Terminated | "terminated" |
What each status means is on Markets.
Related Pages
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:
- Simulate every call as the user before asking for a signature, and show what it does and costs.
- Approve exact amounts. Never default to an unlimited approval.
- Render every string that comes from the chain or the subgraph as text, never as HTML.
- Let the SDK check every market address; never build a call for a pair from a link by hand.
- Keep API keys out of the browser: put a proxy you control in front of keyed endpoints.
- Do not override the clock-skew check; tell the user to fix their clock or RPC instead.
- Know which calls a smart-contract wallet can make (direct ones) and which it cannot (gasless ones).
- Keep keeper keys in
.envor 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 witheth_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’sapprovals), and the approvals it builds are for the exact amount. - It never prints a raw URL or a token. Errors from
JsonRpc,ProClient,SubgraphClientand the history queries carry the endpoint throughredactUrl, 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
PreparedCallthat carries one haspriceTime; build it as late as possible (buildAndSend), and rebuild one whosepriceTimeis 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.usdcFundingturns 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
CostedCallfields: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) andnotes. 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 throwsInputErroronpairfor 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
verifyRelayedchecks 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
pricesshort-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 inSECRET_KEYS), as the default of a${NAME:-default}, in aprices.headersvalue, or inside a URL (Alchemy’s/v2/<key>, a?key=query). Write${NAME}and put the value in.env.bazaar keeper initwrites.envowner-only. - Prefer a keystore.
bazaar wallet newwrites 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.envfor an unattended keeper. Aremotesigner (any service that answerseth_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
Dockerfileruns 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
collectcan forward them elsewhere (forwardTo).budget.maxSpendUsdPerDayandbudget.maxSpendEthPerDaycap 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 sees | What 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.ClockSkewError | The 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 allowance | The wallet already allows the spender more than the quote. Approve the exact amount first to cap it. |
uma.BondQuoteError | A 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 YAML | The keeper refused a secret written into its config file. Move it to .env. |
Related Pages
- Sending Transactions: simulation, the price window, sending.
- Collateral, Approvals & Permits: exact approvals and permits.
- Gasless Trading & Relayers: signing and relaying.
- Termination & Settlement: the
CostedCallshape. - Errors & Messages: what to show for each error.
- Wallets & Secrets: keeper keys.
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:
| Path | Package | What it is |
|---|---|---|
packages/sdk | @liberta-labs/bazaar-sdk | The SDK. Runs in browsers and Node; its only dependency is viem. |
packages/keeper | @liberta-labs/bazaar-keeper | The keeper and the bazaar CLI. Node 20 or later; depends on the SDK. |
Beside them:
| Path | What it holds |
|---|---|
contracts/KeeperProbe.sol | The 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.json | The compiler options both packages share (ES2022, NodeNext modules, strict). |
tsconfig.check.json, tsconfig.check-sdk.json | Typecheck-only configs (below). |
vitest.config.ts, vitest.e2e.config.ts | The unit and end-to-end test configs. |
Dockerfile | The keeper as a container, run as an unprivileged user. |
DESIGN.md | How 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:
| Command | What it checks |
|---|---|
npx tsc -p tsconfig.check.json | Both 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.json | The 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.
| File | What it does |
|---|---|
deploy.e2e.test.ts | Replays 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.ts | Runs 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.ts | Connects 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.ts | The 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.
| File | What it is |
|---|---|
deployment.json | The 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.json | The MockArbSys runtime bytecode, which the replay places at the ArbSys precompile (0x…64), where the pair reads its block number. |
build.mjs | Produces the two files above from a built Bazaar checkout’s out/ artifacts. |
README.md | Which 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/:
| Path | What it is |
|---|---|
docs/book.toml | The book’s settings. create-missing = false: a link in SUMMARY.md to a page that does not exist fails the build. |
docs/src/SUMMARY.md | The 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/*.js | The 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:
- Bump
versioninpackages/sdk/package.jsonandpackages/keeper/package.json, the keeper’s dependency on@liberta-labs/bazaar-sdk(pinned to an exact version), andVERSIONinpackages/keeper/src/index.ts, which is kept by hand. - Run the checks:
npm run build,npm test,npm run typecheck:check, the end-to-end suite on a fresh Anvil, andnpm run docs:check. - 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.
Related Pages
- Installation: using the packages.
- Testing Locally with Anvil: a keeper against a local deployment.
- Low-Level Building Blocks: the probe and the typed ABIs.
- API Index: every export.
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.