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.