noncepad/catscope-rust-bot

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:

  1. Its own methodson_load, mid_on_account, mid_on_token, mid_on_tx
  2. InboundMesasgeHandler — handles messages from the optimizer
  3. CommitHook — 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(&params, &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 as BotIn_Stdin packets
  • loopBotStdout — reads BotOut_Stdout packets off the stream, parses them as catmsg.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.