What this is
A WASM bot that runs inside a Solana validator. Instead of making network calls to read chain state, the validator pushes account updates directly into the bot’s process. Transactions submitted by the bot skip the external RPC path and go directly into the validator’s pipeline.
This gives you sub-millisecond read latency. The full cycle — bot sends tx → leader packages it into a block → geyser fires Transaction event → bot reads the result — happens entirely within the validator host, with no external network hops on the read side.
What is actually happening physically
You have a Solana validator — a large server running the blockchain. Inside that server, Catscope has loaded your WASM bot as a plugin. The bot lives inside the validator process.
You also have a separate Go program (the optimizer) running somewhere — same machine or a different one. The optimizer does not have direct access to the bot’s memory. It can only talk to the bot through the validator’s gRPC API, which acts as a relay.
The bot has no network stack. It cannot make HTTP calls or open sockets. Its only connections to the outside world are:
- stdin — bytes coming IN from the optimizer (commands)
- stdout — bytes going OUT to the optimizer (status, wallet address, pong)
- geyser events — account, token, and transaction updates pushed by the validator
The event loop
The bot is built around SampleV4Hook, which implements EventHandler. The validator calls on_event() once for each event it delivers.
| Event | Method | When it fires |
|---|---|---|
| Stdin | on_message |
optimizer sends a command |
| Commit | CommitHook |
per slot: account balances confirmed |
| Account | mid_on_account |
low-latency: raw account data changed |
| Token | mid_on_token |
low-latency: SPL token account changed |
| Transaction | mid_on_tx |
low-latency: transaction result for your wallet |
Low-latency vs Commit: Account, Token, and Transaction events arrive immediately as the validator processes them — before the slot is finalized. Commit events arrive once per slot, after finalization, and carry confirmed balance data. Use the low-latency events for price tracking and arb detection; use Commit for balance accounting that needs to be authoritative.
Implementing a bot
Start from src/brain/samplev4/. It is the published template. Copy the directory and rename it for your bot type.
mod.rs — the entry point
SampleV4Hook holds all the bot’s state in Rc<UnsafeCell<T>> fields. This pattern exists because Rust’s borrow checker cannot track that the fields are disjoint; StateHelper is the short-lived struct that borrows them all mutably at once for the duration of one event.
pub struct SampleV4Hook {
rc_configuration: Rc<UnsafeCell<Configuration>>,
rc_wallet: Rc<UnsafeCell<Wallet>>,
rc_state: Rc<UnsafeCell<State>>,
tmp_q_msg: Rc<UnsafeCell<VecDeque<MessageSend<CustomMessageOutbound>>>>,
o_rc_graph: Option<Rc<UnsafeCell<Graph>>>,
o_rc_edgemgr: Option<Rc<UnsafeCell<EdgeManager>>>, // read-only account index
msg_outbound: Option<MessageOutbound>,
o_msg_parser: Option<MessageInbound>,
...
}
state.rs — the logic
StateHelper<'a> is created at the top of on_event() and dropped after the event is processed. Put your bot logic here.
It implements three things:
- Its own methods —
on_load,mid_on_account,mid_on_token,mid_on_tx InboundMesasgeHandler— handles messages from the optimizerCommitHook— handles per-slot confirmed state
on_load
Called once when the bot starts. Register any subscriptions, initialize your DexTrader, and build your ArbGraph here.
pub(crate) fn on_load(&mut self) {
// subscribe to pool accounts
let g = self.o_graph.as_mut().unwrap();
g.subscribe(pool_account_pubkey);
// initialize trader
self.state.trader.register_orca_whirlpool(pool_id);
self.state.trader.register_raydium_amm(pool_id, Some(swap_cfg));
// build arb graph
self.state.arb.add_edge(node_a, node_b, 1.0, 0.003, pool_id, mint_a, mint_b);
self.state.arb.add_edge(node_b, node_a, 1.0, 0.003, pool_id, mint_b, mint_a);
self.state.arb.build_cycles(3);
}
mid_on_account — low-latency price updates
pub(crate) fn mid_on_account(&mut self, mut account_wrapper: AccountWrapper) {
while let Some((header, body)) = account_wrapper.account() {
if let Some(update) = self.state.trader.on_account(header, body) {
self.state.arb.update_from_price_update(&update, |cycle_id, profit| {
// arbitrage detected — queue a swap tx
});
}
}
}
CommitHook::on_account — confirmed balance
fn on_account(&mut self, header: &Header, body: &[u8], ...) {
// update authoritative wallet balance here
self.wallet.on_account(header);
// also forward to trader for vault reserves
if let Some(update) = self.state.trader.on_account(header, body) {
self.state.arb.update_from_price_update(&update, |cycle_id, profit| { ... });
}
}
Inbound messages from optimizer
Override on_message in the InboundMesasgeHandler impl to handle custom commands:
crate::message::MessageAction::Custom(custom) => match custom {
CustomMessageInbound::SetReturnWallet(pubkey) => {
self.state.return_wallet = Some(pubkey);
}
CustomMessageInbound::Blank => {}
},
Define your custom message types in message.rs. The key, ping/pong, shutdown, and configuration-update messages are handled by the framework before your code runs.
Sending messages to the optimizer
Push to self.q_msg from any handler:
self.q_msg.push_back(MessageSend::Wallet(pubkey));
// or custom:
self.q_msg.push_back(MessageSend::Custom(CustomMessageOutbound::Status(my_status)));
The framework drains the queue and flushes to stdout after every event.
DexTrader — multi-DEX price tracking
DexTrader parses account updates from Raydium AMM v4, Orca Whirlpool, and Kamino Lending. Register pools once at startup; forward every account and token update you receive.
let mut trader = DexTrader::new();
// Register at startup
trader.register_raydium_amm(pool_id, Some(swap_cfg));
trader.register_orca_whirlpool(pool_id);
trader.register_kamino_reserve(reserve_id); // price only, no swap IX
Forwarding updates:
// In mid_on_account or CommitHook::on_account:
if let Some(update) = trader.on_account(header, body) {
// pool price changed
}
// In mid_on_token or CommitHook::on_token:
if let Some(update) = trader.on_token(&token_account) {
// vault reserves changed → price recalculated
}
The vault_to_pool mapping is built automatically the first time on_account parses a pool state. You do not need to register vault accounts separately.
Price snapshot:
if let Some(p) = trader.price(pool_id) {
println!("price {} → {}: {}", p.token_a, p.token_b, p.price);
println!("reserves: {} / {}", p.reserve_a, p.reserve_b);
}
// Constant-product quote (no tx built):
let out = trader.quote(pool_id, input_mint, amount_in);
Building a swap instruction:
let params = SwapParams {
pool: pool_id,
input_mint,
output_mint,
amount_in,
min_amount_out,
user_source_token_account,
user_destination_token_account,
user_wallet,
};
trader.sell(¶ms, &mut self.wallet)?; // appends ix to wallet
buy and sell are both aliases for swap — the direction is determined by input_mint/output_mint.
Kamino reserves provide price data only; they do not support direct swap instructions.
ArbGraph — incremental arbitrage detection
ArbGraph models assets as nodes and swap pairs as directed edges. It detects negative cycles (profitable arbitrage) in sub-microsecond time as prices change.
Theory: Each edge weight is w = −ln(price × (1 − fee)). A cycle is profitable when Σ w_i < 0, meaning the product of prices along the cycle exceeds 1.0 after fees.
Setup
// One node per asset. Assets are identified by integer node IDs.
let mut arb = ArbGraph::new(n_assets);
// Two directed edges per pool (both swap directions).
let e_sol_usdc = arb.add_edge(
node_sol, node_usdc,
initial_price, // USDC per SOL
0.003, // 0.3% fee
pool_id, mint_sol, mint_usdc,
);
let e_usdc_sol = arb.add_edge(
node_usdc, node_sol,
1.0 / initial_price,
0.003,
pool_id, mint_usdc, mint_sol,
);
// Call once after all edges are added.
// Enumerates all simple directed cycles up to max_len edges.
arb.build_cycles(3); // triangles: A→B→C→A
Processing price updates
// From DexTrader::on_account or on_token:
if let Some(update) = trader.on_account(header, body) {
arb.update_from_price_update(&update, |cycle_id, profit_factor| {
// profit_factor = exp(−cycle_cost) − 1
// e.g. 0.005 = 0.5% gross profit before gas
let path = arb.cycle_path(cycle_id);
// path is a slice of EdgeId — use arb.edge(id) to get pool/mint info
for &eid in path {
let e = arb.edge(eid);
// build swap: e.input_mint → e.output_mint via e.pool_id
}
});
}
update_from_price_update does no heap allocations. It uses a fixed-size stack buffer and processes O(cycles_per_edge) updates. Expected: 200 ns – 2 µs per price event at 10–80 cycles per edge.
Querying profitable cycles
// All cycles that are currently profitable:
for opp in arb.profitable_cycles() {
let path = arb.cycle_path(opp.cycle_id);
println!("profit: {:.3}%", opp.profit_factor * 100.0);
}
The Optimizer (Go)
The optimizer is a Go program that manages the bot over gRPC. It connects to the validator via pbb.BotClient and opens a bidirectional stream (Bot_RunClient). Two goroutines run against that stream:
loopBotStdin— drains a Go channel (stdinC) and sends messages into the gRPC stream asBotIn_StdinpacketsloopBotStdout— readsBotOut_Stdoutpackets off the stream, parses them ascatmsg.Message, and routes to handlers (OnWallet,OnPong,OnCustom)
Optimizer → bot:
Bot.Send(msg)
→ stdinC (Go channel)
→ loopBotStdin → gRPC stream
→ validator host
→ WASM bot Event::Stdin
→ StateHelper::on_message()
Bot → optimizer:
q_msg.push_back(MessageSend::...)
→ MessageOutbound::write/flush
→ validator host
→ gRPC stream BotOut_Stdout
→ loopBotStdout → stdoutC
→ internalBot::onStdout → OnWallet / OnPong / OnCustom
The ping/pong handshake and wallet address exchange are implemented on both sides. At startup the bot sends its wallet pubkey via MessageSend::Wallet; the optimizer receives it via OnWallet and can then fund the bot’s account.
Build
# Add WASM target (once)
rustup target add wasm32-wasip2
# Build
cargo build --target wasm32-wasip2 --release
# Unit tests run on native (no validator needed)
cargo test --target x86_64-unknown-linux-gnu
The output WASM file is loaded by the Catscope host as a plugin. The Catscope host (validator plugin) handles all gRPC, stdin/stdout routing, and event delivery — your bot only implements EventHandler.