Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Quick Start: a Bot

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

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

What You Need

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

The script reads them from the environment:

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

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

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

1. The Key and the Environment

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

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

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

2. Connect

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

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

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

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

3. Send a PreparedCall

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

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

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

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

4. Read the Market and the Account

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

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

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

5. Top Up Collateral With a Permit

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

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

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

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

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

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

6. Place an Order and Cancel It

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

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

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

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

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

7. Run It

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

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

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

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

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

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

What Can Go Wrong

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

Next