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.