OpenBook v2 Crank Market-Making Strategy on Solana
A hands-on OpenBook v2 market making strategy for Solana: run the event-queue crank, quote a tight CLOB spread, and use self-trade prevention to avoid wash fills.
OpenBook v2 is the fork that kept Solana's on-chain central limit order book alive after Serum was abandoned, and it fixed the single most annoying operational tax of the old design: someone has to crank the market. If you are quoting on OpenBook v2, you are not just posting bids and asks — you are running a loop that processes the event queue, or your fills sit in limbo and your inventory numbers lie to you. That distinction is the whole strategy. This is a genuine CLOB market-making loop, not an AMM range or an off-chain quote engine, and the mechanics are different enough that most people port a Binance-style maker and wonder why their positions drift.
Why the crank exists at all
OpenBook v2 settles matches lazily. When your order crosses a resting order, the program does not immediately credit both parties' token accounts. Instead it writes a FillEvent to a ring buffer — the event queue — and moves on. Someone has to come along and call consume_events to actually apply those fills to the open-orders accounts. That someone is the crank.
Serum offloaded this to a permissionless crowd of crankers, which meant your fills could be processed seconds late by a stranger's bot. OpenBook v2 kept the queue model but made it far more practical to run your own crank alongside your maker, because as a market maker you have the strongest incentive to process events promptly: until consume_events runs, your OpenOrdersAccount shows the wrong position.base_free and position.quote_free, and any inventory-aware quoting logic you built is operating on stale state.
So the loop has two jobs that most CLOB makers keep separate:
- Post and cancel quotes based on current inventory and the reference price.
- Drain the event queue so your inventory view stays accurate.
Skip the second and your bot will happily double-count exposure and either over-hedge or blow through a position limit.
The core loop
Here is the shape of it. This is pseudocode over the openbook-v2 Rust client, but the sequence is what matters:
loop {
// 1. Refresh reference price (Pyth/Switchboard or a CEX mid)
let mid = oracle.mid()?;
// 2. Read our own open-orders account fresh from chain
let oo = load_open_orders(&rpc, &oo_key)?;
let base_pos = oo.position.base_position_native();
// 3. Skew quotes against inventory
let skew = INV_GAIN * (base_pos as f64 / MAX_BASE);
let bid = mid * (1.0 - HALF_SPREAD - skew);
let ask = mid * (1.0 + HALF_SPREAD - skew);
// 4. Cancel-and-replace in ONE transaction
let mut ix = vec![
cancel_all_orders(&market, &oo_key)?,
place_order(Side::Bid, price_lots(bid), size_lots(), oo_key)?,
place_order(Side::Ask, price_lots(ask), size_lots(), oo_key)?,
];
// 5. Crank our own fills in the SAME tx if the queue is non-empty
if event_queue_len(&market)? > 0 {
ix.push(consume_events(&market, &[oo_key], LIMIT)?);
}
send_versioned_tx(&rpc, ix, &payer)?;
sleep(QUOTE_INTERVAL);
}
Two things people get wrong here.
Batch cancel-and-replace atomically. OpenBook v2 supports cancel_all_orders plus multiple place_order instructions in one transaction, and you want them atomic. If you cancel in transaction A and replace in transaction B, there is a window — often 400ms to a full slot or two under congestion — where you have no quotes up, or worse, only one side up and you get picked off on the exposed leg. One transaction, one slot, both sides move together.
Crank your own account, not the whole market. consume_events takes a slice of open-orders accounts to credit. If you pass only your own oo_key, you settle just your fills cheaply. You are not obligated to crank the entire market for everyone — though on a thin market you sometimes have to, because unrelated resting fills ahead of yours in the ring buffer block the queue head. The queue is FIFO, so if the event at the head belongs to another maker, you must include their account (or wait for someone else to). Budget for this: on a quiet market you may be the only crank, and skipping it stalls the book.
Self-trade prevention is not optional
This is where a naive maker loses real money. When you quote both sides tight, your own bid can cross your own ask on the next price tick, and without protection you eat the taker fee to trade against yourself, inflate reported volume, and churn inventory for nothing. OpenBook v2 has native self-trade behavior on every place_order:
DecrementTake— the incoming order takes against the resting one but neither fills; sizes decrement. Usually what you want.CancelProvide— cancel your own resting order, let the incoming order rest. Good when you always want the fresh quote to win.AbortTransaction— revert the whole tx. Useful as a hard guard while you are still debugging quote logic.
Set it explicitly on both legs. I default to CancelProvide on the side I am refreshing so the newest quote always survives, which pairs naturally with the atomic cancel-and-replace above. If you are coming from an AMM-style approach where this class of problem doesn't exist, it is worth reading how passive on-chain liquidity behaves differently in our writeup on Meteora DLMM fee-farming and auto-rebalancing — the inventory risk is real in both, but a CLOB gives you explicit price control an AMM curve never will.
Sizing, lots, and the gotchas that eat you
OpenBook v2 works in lots, not raw token amounts. Every market defines base_lot_size and quote_lot_size, and your price and size have to be expressed in those units or the program rejects the order. A market with a base lot of 1,000,000 (six decimals, so 1 whole token) and a tick that rounds price to the quote lot will silently round your intended 15.3 bps spread to something coarser than you think on low-priced tokens. Pull the market config once at startup and compute your min viable spread from the tick size — do not assume it is finer than it is.
A few more from production:
- Rent and account count. Each
OpenOrdersAccountholds a fixed number of order slots (aBookSidenode budget). Quote too many price levels and you run out of slots before you run out of capital. One tight level per side is usually enough for a maker; ladders are for inventory unwind, not for spread capture. - Priority fees during volatility. Your cancel-and-replace competes for block space. Under load, a static compute-unit price means your quote update lands two slots late and you get run over. Scale the priority fee with recent slot fill rates.
- Oracle staleness. If you skew inventory against a stale mid, you will lean into adverse selection. Reject the quote cycle if the Pyth confidence interval widens past a threshold rather than quoting through news.
For anyone weighing this against keeping quotes off-chain and only touching the chain to settle, the tradeoff mirrors what we covered in the just-in-time liquidity strategy on Solana: on-chain quoting costs you a transaction per update but gives you a real resting order other flow can hit, which is the entire point of being a maker rather than a JIT filler. If your edge is scheduled or conditional rather than continuous, a trigger-and-recurring-order engine is a better fit than a full quoting loop.
Where this actually pays
Continuous two-sided quoting on OpenBook v2 makes sense when you have a reliable external reference price and you can crank fast enough that your inventory view is never more than a slot or two stale. The edge is the spread minus adverse selection minus your fee and rent overhead — and the crank is what keeps the middle term honest. Instrument it: log queue depth, time-to-consume, and realized-vs-quoted spread every cycle. If time-to-consume creeps up, your inventory math is drifting and your skew is wrong before you notice a bad fill.
If you are deciding whether a CLOB maker or a different structure fits your capital and latency profile, our strategy consultation is the right place to pressure-test it before you write the loop, and a code review and audit of the crank-and-quote path catches the atomicity and self-trade bugs that only surface with real fills. If you would rather see the whole thing live, we build the quoting loop and its monitoring dashboard together so you can watch queue depth and inventory drift in one place.
Need a bot like this built?
We design, build and run trading bots on Solana, Hyperliquid and Polymarket.
Start a projectMore from the blog
Public vs Paid RPC: How to Run a Fair Benchmark
A fair comparison of public and paid RPC endpoints must account for quotas, workload, freshness and support guarantees.
Read articleRPC Benchmark Percentiles Explained: p50, p95 and p99
Why averages hide the latency spikes that break trading bots, wallets and production blockchain applications.
Read articleHyperliquid REST vs WebSocket Benchmark: What to Measure
A benchmark plan for Hyperliquid market data that separates request latency from streaming freshness and recovery.
Read article