Bot Quickstart — Zero to Functional Grid Bot
This page walks you from a freshly funded Boros root account to a running grid bot that places, cancels, and refills real limit orders on Arbitrum mainnet. The bot is built with @pendle/boros-sdk-public — a TypeScript wrapper that handles calldata building, EIP-712 signing, and Send Txs Bot dispatch on your behalf.
The strategy at the end is a grid bot — a fixed ladder of resting limit orders around the current market rate. Grid bots are pedagogical, not production: they lose money in trending markets and accrue funding-rate settlements while open. Use tiny size ($10–$15 per level) and run for short windows.
The bot is built around five steps that work for any limit-order strategy:
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ read │ │ decide │ │ diff │ │ act │ │ react │
│ state │→ │ desired │→ │ vs open │→ │ (place/ │→ │ (fills) │──┐
│ │ │ ladder │ │ orders │ │ cancel) │ │ │ │
└─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
▲ │
│ │
└───────────────────────────────────────────────────────────┘
The grid bot at the end of this page is just a desiredLadder() function plugged into that loop. Swap the desiredLadder for any other strategy and the rest of the code is reusable.
If you want raw HTTP + manual signing (e.g. for a non-TypeScript port), see API — that page documents the wire-level flow that the SDK wraps. Full bot source is embedded at the bottom of this page in Full code.
What you'll build
A bot that maintains a symmetric ladder of resting limit orders around the current top-of-book on a single market:
sells: bestShort + 2*step
bestShort + 1*step ← best ask
──────────────────────────────────────── current spread
bestLong - 1*step ← best bid
buys: bestLong - 2*step
When the market rate drifts, the bot cancels the now-stale ladder and re-quotes around the new top-of-book. Fills are recovered on the next reconcile (the bot reads the orderbook + its open orders, computes the diff, and places whatever's missing).
- Grids lose money in any trending market.
- Boros markets pay periodic funding-rate settlements. A net-long grid pays funding when underlying funding is positive, regardless of fill symmetry — see Mechanics → Settlement.
- Markets can hit their
softOICapand reject opens (reduce-only mode) without warning. - Use $10–$15 size per level and run for short windows.
Prerequisites
| Requirement | Notes |
|---|---|
| Funded Boros root account | Deposit ~$50 collateral via boros.pendle.finance. Onboarding (signup, KYC, deposit) is UI-first and out of scope for this page. |
| Gas balance topped up | $5 – $10 in your gas treasury. Top up via the Boros UI's Gas Balance button, or call exchange.payTreasury(...) once you have an agent (see SDK page). |
| Arbitrum RPC URL | Any provider works (Alchemy, Infura, public). |
Node 20+ and npm |
npm install @pendle/boros-sdk-public viem dotenv
That's the entire dependency footprint — the SDK absorbs axios, EIP-712 signing, and Send Txs Bot calls.
Step 1 — Generate and approve an agent
The agent is a separate keypair with permission to sign trading actions on behalf of your root. Trading actions are signed by the agent and submitted via the Send Txs Bot; sensitive actions (deposit, withdraw, agent approve/revoke) are signed by your root. The agent never has withdrawal rights.
import { createWalletClient, http } from 'viem';
import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts';
import { arbitrum } from 'viem/chains';
import { Agent, Exchange } from '@pendle/boros-sdk-public';
const agentPk = process.env.AGENT_PK || generatePrivateKey(); // persist this
const rootAccount = privateKeyToAccount(process.env.ROOT_PK as `0x${string}`);
const walletClient = createWalletClient({
account: rootAccount,
transport: http(process.env.RPC_URL),
chain: arbitrum,
});
const agent = Agent.createFromPrivateKey(agentPk as `0x${string}`);
const exchange = new Exchange(walletClient, rootAccount.address, 0, [process.env.RPC_URL!], agent);
const expirySec = Math.floor(Date.now() / 1000) + 365 * 86_400;
await exchange.approveAgent(agent, undefined, expirySec);
// Verify
const onChain = await exchange.getAgentExpiryTime();
console.log('agent expiry:', new Date(onChain * 1000).toISOString());
Save the AGENT_PK somewhere safe — losing it means re-running approval. After this one-time setup the bot only needs AGENT_PK (the root key can be removed from .env).
Step 2 — Pick a market
exchange.getAllMarkets({ isUiWhitelisted: true }) returns every market the UI shows, paginated automatically and cached for 5 min. Filter to liquid, non-near-maturity ones:
const markets = await exchange.getAllMarkets({ isUiWhitelisted: true });
const now = Math.floor(Date.now() / 1000);
const candidates = markets
.filter((m) => m.config.status === 2) // 2 = trading; 0/1 = halted/CLO
.filter((m) => m.imData.maturity > now + 7 * 86_400) // ≥ 1 week to maturity
.filter((m) => m.data.bestBid != null && m.data.bestAsk != null)
.sort((a, b) => (b.data.volume24h ?? 0) - (a.data.volume24h ?? 0));
const market = candidates[0];
console.log({ id: market.marketId, symbol: market.imData.symbol, tokenId: market.tokenId });
Spread markets (m.metadata.isSpreadMarket === true) also come back from this call. Their underlying is the gap between two exchanges' funding rates, so it sits near zero and can turn negative. Drop them with .filter((m) => !m.metadata.isSpreadMarket) if your strategy assumes a single-venue funding rate.
The reference bot has fuller filters (spread bounds, OI utilization) — see src/pick-market.ts in Full code below.
Step 3 — Read state
The reconcile loop needs three reads: top-of-book, your active orders, and the market's tickStep (already on market.imData.tickStep from step 2).
const ob = await exchange.getOrderBook({ marketId: market.marketId, tickSize: 0.001 });
const bestLongTick = ob.long?.ia?.[0]; // top bid (highest tick a long is willing to take)
const bestShortTick = ob.short?.ia?.[0]; // top ask (lowest tick a short is willing to give)
tickSize is the display bucket — orderbook entries are grouped at this granularity (in APR units). The numbers in ob.long.ia[] are bucket-ticks at that resolution; the numbers a market accepts on placeOrder are contract ticks measured in tickStep units. The SDK ships both conversions:
import { estimateTickForRate, getRateAtTick } from '@pendle/boros-sdk-public';
import { FixedX18 } from '@pendle/boros-offchain-math';
// APR (number) → contract tick (bigint, then narrow to number for placeOrder)
const tick = Number(estimateTickForRate(FixedX18.fromNumber(apr), BigInt(tickStep), false));
// contract tick → APR
const apr = getRateAtTick(BigInt(tick), BigInt(tickStep)).toNumber();
Under the hood both helpers implement — the canonical Pendle tick math. Reach for the SDK rather than hand-rolling so a future tickStep-formula change doesn't silently desync your bot.
For your active orders:
import { getOpenApiSdk } from '@pendle/boros-sdk-public';
const sdk = getOpenApiSdk();
let resumeToken: string | undefined;
const orders = [];
do {
const { data } = await sdk.accounts.accountsV2ControllerGetOrders({
root: rootAccount.address,
accountId: 0,
marketId: market.marketId,
isActive: true,
orderType: '0',
limit: 200,
resumeToken,
});
orders.push(...data.results);
resumeToken = data.resumeToken ?? undefined;
} while (resumeToken);
The SDK's exchange.getOrdersPage(...) is a thinner wrapper that returns one page at a time; the bot uses the codegen escape hatch directly to keep pagination control explicit.
Step 4 — Decide + diff
Compute the desired ladder and compare against open orders. Both arrays use bucket-ticks (the tickSize-grouped units from the orderbook), so the diff is direct:
import { Side } from '@pendle/boros-sdk-public';
export type GridLevel = { side: Side; tick: number; size: bigint };
export function desiredLadder(p: {
bestBidTick: number; bestAskTick: number;
stepTicks: number; levels: number; sizePerLevel: bigint;
}): GridLevel[] {
const out: GridLevel[] = [];
for (let i = 1; i <= p.levels; i++) {
out.push({ side: Side.LONG, tick: p.bestBidTick - i * p.stepTicks, size: p.sizePerLevel });
out.push({ side: Side.SHORT, tick: p.bestAskTick + i * p.stepTicks, size: p.sizePerLevel });
}
return out;
}
export type ExistingOrder = { orderId: string; side: Side; tick: number; size: bigint };
export function diff(desired: GridLevel[], existing: ExistingOrder[]) {
const desiredKeys = new Map(desired.map((d) => [`${d.side}:${d.tick}`, d]));
const seen = new Set<string>();
const toCancel = existing.filter((e) => {
const k = `${e.side}:${e.tick}`;
if (desiredKeys.has(k) && !seen.has(k)) { seen.add(k); return false; }
return true;
});
const toPlace = [...desiredKeys].flatMap(([k, lvl]) => (seen.has(k) ? [] : [lvl]));
return { toPlace, toCancel };
}
Step 5 — Act (place + cancel)
The SDK collapses calldata-build + sign + submit into one call per primitive. To cancel stale levels and place new ones in the same reconcile:
import { TimeInForce, MarketAccLib, CROSS_MARKET_ID } from '@pendle/boros-sdk-public';
const marketAcc = MarketAccLib.pack(rootAccount.address, 0, market.tokenId, CROSS_MARKET_ID);
if (d.toCancel.length > 0) {
await exchange.cancelOrders({
marketAcc,
marketId: market.marketId,
cancelAll: false,
orderIds: d.toCancel.map((o) => o.orderId),
});
}
if (d.toPlace.length > 0) {
const tickSizeNum = 0.001;
const tickStep = market.imData.tickStep;
await exchange.bulkPlaceOrders({
orderRequests: d.toPlace.map((lvl) => ({
marketAcc,
marketId: market.marketId,
side: lvl.side,
size: lvl.size,
limitTick: Number(estimateTickForRate(FixedX18.fromNumber(lvl.tick * tickSizeNum), BigInt(tickStep), false)),
tif: TimeInForce.ADD_LIQUIDITY_ONLY,
ammId: 0,
slippage: 0.5,
})),
});
}
TimeInForce.ADD_LIQUIDITY_ONLY is post-only — if your computed limitTick would cross the book the order is rejected rather than executed at a worse price. Critical for grid strategies, where a crossed limit would immediately fill at the spread instead of resting.
Step 6 — Enter the market
A cross-margin sub-account must explicitly enter a market before it can trade there. Idempotent — call once at startup:
const sdk = getOpenApiSdk();
const crossAcc = MarketAccLib.pack(rootAccount.address, 0, market.tokenId, CROSS_MARKET_ID);
const { data: entered } = await sdk.accounts.accountsV2ControllerGetEnteredMarkets({
marketAcc: crossAcc,
});
if (!entered.results.some((r) => r.marketId === market.marketId)) {
await exchange.enterMarkets(true, [market.marketId]);
}
See FAQ → cross-margin vs isolated-margin for why cross accounts need this.
Step 7 — The reconcile loop
Stitch it together:
async function reconcile() {
const ob = await exchange.getOrderBook({ marketId: market.marketId, tickSize: 0.001 });
const bestLong = ob.long?.ia?.[0];
const bestShort = ob.short?.ia?.[0];
if (bestLong == null || bestShort == null) return;
const orders = await fetchActiveOrders(); // step 3
const existing = orders.map((o) => ({
orderId: o.orderId,
side: o.side as Side,
tick: Math.round(getRateAtTick(BigInt(o.tick), BigInt(market.imData.tickStep)).toNumber() / 0.001),
size: BigInt(o.placedSize),
}));
const desired = desiredLadder({
bestBidTick: bestLong, bestAskTick: bestShort,
stepTicks: 2, levels: 2, sizePerLevel: 15n * 10n ** 18n,
});
const d = diff(desired, existing);
if (d.toCancel.length > 0) await exchange.cancelOrders({ /* ... */ });
if (d.toPlace.length > 0) await exchange.bulkPlaceOrders({ /* ... */ });
}
while (running) {
try { await reconcile(); } catch (err) { console.error(err); }
await new Promise((r) => setTimeout(r, 15_000));
}
Plus a SIGINT handler that calls cancel-all for the market before exiting:
const onStop = async () => {
await exchange.cancelOrders({
marketAcc, marketId: market.marketId, cancelAll: true, orderIds: [],
});
process.exit(0);
};
process.on('SIGINT', onStop);
process.on('SIGTERM', onStop);