Bot Architecture Patterns
Order placement logic is the smallest part of a bot that runs unattended. This page covers the infrastructure around it: staying subscribed, staying inside the compute and fee budget, noticing when the bot's own account is in trouble, and shutting down without leaving quotes on the book. The patterns come from Velocity's keeper-bots-v2 and apply to market making, filling, and triggering bots alike.
Subscription loop + resubscribe
WebSocket connections drop. RPC nodes restart. A bot must handle reconnections automatically. The OrderSubscriber supports automatic resubscription and periodic resync to catch any missed updates.
import { OrderSubscriber } from "@velocity-exchange/sdk";
const orderSubscriber = new OrderSubscriber({
velocityClient,
subscriptionConfig: {
type: "websocket",
resubTimeoutMs: 30_000, // resubscribe if no update in 30s
resyncIntervalMs: 300_000, // full resync every 5 minutes
},
});
await orderSubscriber.subscribe();Why resyncIntervalMs? WebSocket subscriptions can silently miss updates (dropped messages, RPC hiccups). Periodic resync keeps local state from drifting away from onchain reality.
Periodic tasks + mutex guard
Many bot operations (refreshing quotes, running risk checks, rebalancing) should run on a timer but must not overlap. Use a mutex to prevent concurrent execution:
import { Mutex } from "async-mutex";
const periodicTaskMutex = new Mutex();
let lastWatchdogTs = Date.now();
setInterval(async () => {
if (periodicTaskMutex.isLocked()) {
console.log("Previous cycle still running, skipping");
return;
}
const release = await periodicTaskMutex.acquire();
try {
await refreshQuotes();
await runRiskChecks();
lastWatchdogTs = Date.now();
} catch (err) {
console.error("Periodic task error:", err);
} finally {
release();
}
}, 10_000);Throttling / backoff
When filling auctions or reacting to events, avoid hammering the same order repeatedly. Track recent attempts and enforce a cooldown:
const throttledNodes = new Map<string, number>();
function shouldAttemptFill(nodeKey: string, cooldownMs = 1000): boolean {
const lastAttempt = throttledNodes.get(nodeKey) ?? 0;
if (lastAttempt + cooldownMs > Date.now()) return false;
throttledNodes.set(nodeKey, Date.now());
return true;
}Priority fees and compute budget
Solana transactions compete for block space via priority fees. During congestion, transactions without adequate fees are dropped. The SDK provides a PriorityFeeSubscriber that tracks recent fee levels.
import { ComputeBudgetProgram } from "@solana/web3.js";
import { PriorityFeeSubscriber } from "@velocity-exchange/sdk";
// Subscribe to priority fee data. maxFeeMicroLamports caps every
// getCustomStrategyResult() return value after priorityFeeMultiplier
// is applied, so the subscriber enforces the ceiling itself.
const priorityFeeSubscriber = new PriorityFeeSubscriber({
connection,
frequencyMs: 5000,
maxFeeMicroLamports: 100_000,
priorityFeeMultiplier: 1.0,
});
await priorityFeeSubscriber.subscribe();
// When building transactions, set compute budget
const priorityFee = priorityFeeSubscriber.getCustomStrategyResult();
const computeBudgetIx = ComputeBudgetProgram.setComputeUnitPrice({
microLamports: Math.floor(
priorityFee * velocityClient.txSender.getSuggestedPriorityFeeMultiplier()
),
});
// Also set compute unit limit to avoid overpaying
const computeLimitIx = ComputeBudgetProgram.setComputeUnitLimit({
units: 400_000, // typical for place-and-make
});Tip: Place-and-make transactions typically use 200-400k compute units. Set the limit explicitly rather than defaulting to 200k (which may fail) or 1.4M (which overpays). This is not a hard ceiling: the JitMaker bot runs a heavier place-and-make path and defaults to 800k CU, see JIT Auctions gotchas.
Address Lookup Tables (ALTs) and blockhash
Velocity transactions reference many accounts. Use Address Lookup Tables to compress transaction size and stay under Solana's 1232-byte limit:
// Fetch ALTs once at startup (or periodically refresh)
const lookupTableAccounts = await velocityClient.fetchAllLookupTableAccounts();
// Get fresh blockhash for each transaction
const { blockhash } = await velocityClient.connection.getLatestBlockhash({
commitment: "confirmed",
});Health monitoring
A bot should continuously monitor its own account health and cancel orders before liquidation:
import { QUOTE_PRECISION, convertToNumber, BN } from "@velocity-exchange/sdk";
async function checkHealth() {
const user = velocityClient.getUser();
// Check free collateral
const freeCollateral = convertToNumber(
user.getFreeCollateral(),
QUOTE_PRECISION
);
// Check leverage
const leverage = convertToNumber(user.getLeverage(), new BN(10_000)); // 4 decimals
// Check margin ratio
const marginRatio = user.getMarginRatio();
console.log(`Free collateral: $${freeCollateral.toFixed(2)}, Leverage: ${leverage.toFixed(2)}x`);
// Emergency cancel if health is deteriorating
if (freeCollateral < MIN_FREE_COLLATERAL || leverage > MAX_LEVERAGE) {
console.warn("⚠️ Health threshold breached, cancelling all orders");
await velocityClient.cancelOrders(); // cancel ALL orders across all markets
return false;
}
return true;
}
// Run health check every cycle
setInterval(checkHealth, 5_000);Graceful shutdown
When the bot stops (deploy, crash, SIGINT), it should cancel all resting orders to avoid being picked off while offline:
let isShuttingDown = false;
async function gracefulShutdown(signal: string) {
if (isShuttingDown) return;
isShuttingDown = true;
console.log(`Received ${signal}, shutting down gracefully...`);
try {
// Cancel all orders across all markets
console.log("Cancelling all orders...");
await velocityClient.cancelOrders();
console.log("All orders cancelled");
// Unsubscribe from feeds
await orderSubscriber.unsubscribe();
await velocityClient.unsubscribe();
console.log("Shutdown complete");
} catch (err) {
console.error("Error during shutdown:", err);
} finally {
process.exit(0);
}
}
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
// Also handle uncaught errors
process.on("uncaughtException", async (err) => {
console.error("Uncaught exception:", err);
await gracefulShutdown("uncaughtException");
});Why this matters: a bot that crashes with orders still on the book leaves those orders to fill at stale prices while nothing is watching them, which is classic adverse selection.
Error handling patterns
Solana transactions fail for many reasons. A bot should handle these gracefully:
async function sendWithRetry(
fn: () => Promise<string>,
maxRetries = 3,
baseDelayMs = 500
): Promise<string | null> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn();
} catch (err: any) {
const errMsg = err?.message || String(err);
// Transaction expired, blockhash is stale, just retry
if (errMsg.includes("BlockhashNotFound") || errMsg.includes("block height exceeded")) {
console.log(`Blockhash expired (attempt ${attempt + 1}), retrying...`);
continue;
}
// Compute budget exceeded, increase CU limit
if (errMsg.includes("exceeded CUs meter")) {
console.warn("Compute budget exceeded, consider raising CU limit");
continue;
}
// Insufficient funds for priority fee
if (errMsg.includes("insufficient lamports")) {
console.error("Insufficient SOL for transaction fees");
return null; // don't retry, need to top up
}
// Order would cross (post-only rejection), price moved, skip.
// Related errors: MakerOrderMustBePostOnly, InvalidOrderIOCPostOnly.
if (errMsg.includes("PlacePostOnlyLimitFailure")) {
console.log("Post-only order would cross, skipping");
return null;
}
// Unknown error, log and retry with backoff
console.error(`Tx error (attempt ${attempt + 1}):`, errMsg);
await new Promise(r => setTimeout(r, baseDelayMs * Math.pow(2, attempt)));
}
}
console.error("Max retries exceeded");
return null;
}Risk and filtering
For JIT and SWIFT flows, apply these filters before filling to avoid adverse selection and stay within risk limits.
Oracle validation: Reject if oracle is stale, invalid, or confidence is too wide. MMOraclePriceData has no isValid field, so use isOracleValid (the same AMM-fill-oriented gate the program uses) against the market and the state's oracle guard rails. Pass the live slot to both calls: getMMOracleDataForPerpMarket needs it to classify a staged slot-duration switch correctly (omitting it falls back to a best-effort observed slot that can stall while a market is idle), and isOracleValid needs it plus the state's configured slot duration for its staleness window, so the threshold matches the onchain check once a shorter slot duration is staged:
import { isOracleValid, activeSlotDurationFromState } from "@velocity-exchange/sdk";
const perpMarket = velocityClient.getPerpMarketAccount(marketIndex);
const currentSlot = new BN(slotSubscriber.getSlot());
const oracle = velocityClient.getMMOracleDataForPerpMarket(marketIndex, currentSlot.toNumber());
const oracleIsValid = isOracleValid(
perpMarket,
oracle,
velocityClient.getStateAccount().oracleGuardRails,
currentSlot.toNumber(),
activeSlotDurationFromState(velocityClient.getStateAccount(), currentSlot)
);
if (!oracleIsValid) return;
if (oracle.confidence.gt(maxConfidence)) return; // confidence is a BN, PRICE_PRECISIONPosition limits: don't fill if the result would exceed the configured max position or leverage. perpPositions is a fixed-size array of position slots, not indexed by market index: look it up with getPerpPosition(marketIndex) (or getPerpPositionOrEmpty for a zeroed placeholder instead of undefined), and use BN arithmetic throughout:
import { convertToNumber, BASE_PRECISION } from "@velocity-exchange/sdk";
const user = velocityClient.getUser();
const currentPosition = user.getPerpPositionOrEmpty(marketIndex);
const newPositionSize = convertToNumber(
currentPosition.baseAssetAmount.add(fillSize), // fillSize: BN, same direction sign convention
BASE_PRECISION
);
if (Math.abs(newPositionSize) > maxPositionSize) return;Toxic flow: skip orders that increase risk (e.g. a direction that worsens the bot's inventory) and skip when oracle/index divergence is high. Note the SDK's own isOrderRiskIncreasing/isOrderRiskIncreasingInSameDirection helpers were removed as dead exports: write this check against the bot's own position and direction, e.g.:
function isRiskIncreasing(order, currentPosition) {
const isLong = isVariant(order.direction, "long");
const positionIsLong = currentPosition.baseAssetAmount.gte(ZERO);
// Increasing risk if the fill would grow a position in the same direction it already faces
return currentPosition.baseAssetAmount.isZero() || isLong === positionIsLong;
}
if (isRiskIncreasing(order, currentPosition)) return;
if (Math.abs(oraclePrice - indexPrice) > maxDivergence) return;When subscribed to both SWIFT and onchain feeds, use isSignedMsgOrder(order) to avoid double-handling the same order. See SWIFT API for details.
Putting it all together
A production bot typically follows this structure:
Initialize VelocityClient + subscribe
Start the client and subscribe to required accounts/feeds.
Start OrderSubscriber (websocket + resync)
Bring up real-time order subscriptions with automatic resync.
Start PriorityFeeSubscriber
Track current priority fee levels for transaction construction.
Register SIGINT/SIGTERM handlers
Ensure graceful shutdown and emergency cleanup paths are active.
Main loop
Check health (and emergency cancel if needed), refresh oracle prices, update quotes, process JIT auctions (if participating), and log metrics (position, P&L, fill rate).
Sleep until next cycle
Pause until the next scheduled run tick.
Subaccount isolation
For multi-market strategies, use separate subaccounts per market to avoid order conflicts and simplify position tracking:
// Initialize with multiple subaccounts
const velocityClient = new VelocityClient({
connection,
wallet,
env: "mainnet-beta",
activeSubAccountId: 0,
subAccountIds: [0, 1, 2], // one per market
});
// Switch subaccount context for market-specific operations. `switchActiveUser` does NOT
// load/subscribe the target User itself -- call `addUser(subAccountId)` first if it isn't
// already loaded. It's still async: it re-derives/re-subscribes `userStats` when the
// authority also changes.
await velocityClient.addUser(subAccountId);
await velocityClient.switchActiveUser(subAccountId);The JitMaker enforces a 1:1 subaccount-to-market ratio and throws if the mapping isn't configured correctly.
Gotchas
- WebSocket silent failures: Solana WebSocket connections can stop delivering updates without disconnecting. The
resyncIntervalMsonOrderSubscribercatches this, but also monitor the last-update timestamp and force-reconnect if it is stale for more than 60 seconds. - Priority fee spikes: during congestion, priority fees can spike 100x. Construct
PriorityFeeSubscriberwithmaxFeeMicroLamports(and, if needed,priorityFeeMultiplier) so it enforces the cap itself before returning a strategy result, instead of draining the bot's SOL balance on fees. - Transaction size limits: Velocity transactions reference many accounts. Without Address Lookup Tables (ALTs), transactions hit the 1232-byte limit. Always fetch and use ALTs (see the ALT section above).
- Blockhash expiry: Solana blockhashes expire after ~60 seconds. A transaction stuck in a queue silently fails. Use
getLatestBlockhashwithcommitment: "confirmed"and setlastValidBlockHeightfor reliable expiry detection. - Rate limits: even paid RPC providers have limits. Batch reads where possible (e.g.,
getMultipleAccounts) and avoid redundant subscriptions.
The reference implementation of every pattern above is keeper-bots-v2, in the velocity-v1 monorepo, which is not public yet. floatingMaker.ts carries the mutex guards, slot-based cooldowns, and watchdog timer; jitMaker.ts the subaccount isolation, DLOB integration, and volatility checks; utils.ts the throttling, market type conversion, and volatility detection helpers.