33 min read
In August 2026, Yellowstone Vixen was renamed Shipstern and the repository moved from rpcpool/yellowstone-vixen to solana-rpc/shipstern. The framework is the same, but crate names, the proc-macro, and module paths now use the shipstern prefix. This guide has been updated to reflect the new name and to use the new packages and libraries throughout. If you are migrating an existing Vixen project, update the crate names in Cargo.toml and the import paths shown below.
Overview
This guide builds a real-time Jupiter Limit Order monitor in Rust using Solana gRPC (Yellowstone-compatible Geyser gRPC) and Shipstern (formerly Yellowstone Vixen), a Rust-based Solana data parsing toolkit. The application streams confirmed limit order transactions and logs order placements, fills, and cancellations with human-readable token amounts and symbols. Along the way, the guide demonstrates three Shipstern features: IDL-based parser codegen via proc-macro, the failed field in TransactionPrefilter for filtering out failed transactions, and structured tracing for observability.
- Build a real-time Jupiter Limit Order v2 monitor in Rust using Solana gRPC and Shipstern
- Generate a type-safe parser directly from the Jupiter Limit Order IDL using Shipstern's
include_shipstern_parser!proc-macro - Stream only confirmed (non-failed) transactions using Shipstern's
TransactionPrefilter - Log order placements, fills, and cancellations with human-readable token symbols and amounts
- Resume after a crash or restart from a persisted slot checkpoint with
from-slot, inside the roughly 20-minute replay window - Run on Quicknode's Solana gRPC (Solana mainnet)
What You Will Do
- Set up a Rust project with Shipstern v0.9.0 dependencies
- Add the Jupiter Limit Order v2 IDL in Codama format
- Generate a type-safe instruction parser using Shipstern's proc-macro
- Implement a handler that logs limit order events with human-readable output
- Build and run a Shipstern runtime connected to Quicknode's Solana gRPC endpoint
- Persist the last processed slot, resume from it on restart, and warn when the checkpoint is too old to replay
What You Will Need
- Rust and Cargo installed on your system
- A Quicknode account with Solana gRPC access (included with Scale and Business plans, or available via the Solana gRPC add-on on Build and Accelerate)
- Node.js (v18+) and the Anchor CLI, needed only if you convert your own Anchor IDL as shown in Convert Your Own Anchor IDL
- Basic understanding of Rust and Solana programs
- Familiarity with Solana gRPC: Monitor Solana Programs with Solana gRPC (Rust) provides a solid foundation for how Solana gRPC streaming works
- Understanding of Codama: How to Create Anchor Program Clients using Codama covers the format and CLI
This guide also uses the following packages:
| Dependency | Version |
|---|---|
| rustc | 1.93.1 |
| shipstern | 0.9.0 |
| shipstern-core | 0.9.0 |
| shipstern-parser | 0.9.0 |
| shipstern-proc-macro | 0.9.0 |
| shipstern-yellowstone-grpc-source | 0.9.0 |
| borsh | 1 |
| bs58 | 0.5 |
| chrono | 0.4 |
| reqwest | 0.12 |
| serde | 1 |
| clap | 4 |
| rustls | 0.23 |
| toml | 0.8 |
| tracing | 0.1 |
| tracing-subscriber | 0.3 |
What is Solana gRPC?
Solana gRPC is a high-performance data streaming solution for Solana built on the Geyser plugin system. Rather than polling an RPC endpoint for new data or managing WebSocket reconnections, Solana gRPC pushes account updates, transactions, and slot notifications to your application as a continuous stream. This provides lower latency and higher throughput compared to traditional approaches.
Quicknode provides a managed Solana gRPC endpoint. It's included with Scale and Business plans; on Build and Accelerate plans, it remains available via the Solana gRPC add-on. You can enable it on any Solana endpoint from the Quicknode dashboard, which provides a dedicated gRPC endpoint and authentication token for your application.
Key filtering capabilities include:
- Filter by program address to receive only transactions involving a specific program
- Filter out failed transactions so only confirmed onchain events are processed
- Filter out vote transactions to reduce noise
What is Shipstern?
Shipstern (formerly Yellowstone Vixen) is an open-source Rust framework for building Solana data pipelines on top of Solana gRPC. It handles gRPC subscriptions, transaction filtering, and deserialization automatically, so application code receives typed Rust structs instead of raw bytes.
Shipstern uses a Parser + Handler architecture to separate data extraction from business logic:
- Parser: Deserializes raw transaction data into typed Rust structs
- Handler: Receives the parsed data and implements your application logic
- Pipeline: Connects a parser to one or more handlers
- Runtime: Manages the gRPC connection, stream subscription, and pipeline execution
Three production-focused features, introduced in Vixen v0.6.1 and carried forward in Shipstern, are demonstrated in this guide:
- IDL-based codegen via proc-macro: Generate a type-safe parser directly from a program's IDL using
include_shipstern_parser!, eliminating manual deserialization code failedfield inTransactionPrefilter: Exclude failed transactions from the stream so only confirmed onchain events reach your handler- Tracing spans for observability: Structured, filterable log output via the
tracingcrate instead of plainlog/env_logger
Shipstern vs Carbon
Shipstern and Carbon are both Rust frameworks for parsing Solana program data. They share the same core approach: stream transactions via Solana gRPC, decode instruction data into typed structs, and process events in handlers, but differ in how they generate parsers and what data sources they support:
| Feature | Shipstern | Carbon |
|---|---|---|
| Parser generation | IDL codegen via include_shipstern_parser! proc-macro at compile time | Pre-built decoder crates + CLI codegen from IDL |
| Data sources | Solana gRPC (Yellowstone), Yellowstone Fumarole, Solana Snapshot, Anza Jetstream, Solana RPC | Solana gRPC, JITO Shredstream, JSON RPC |
| Built-in program support | SPL Token, Token Extensions, BPF Loader, Stake Pool; generate the rest from any Anchor IDL | 60+ pre-built decoders for popular programs |
| Setup for a supported program | Fetch IDL, convert to Codama, invoke the macro | Add a single Cargo crate dependency |
| Observability | tracing spans + Prometheus metrics | Prometheus metrics + logging |
| Pipeline model | Runtime, Pipeline, Parser, Handler | Pipeline, Datasource, Decoder, Processor |
For a Carbon-based guide, see Solana gRPC and Carbon: Parse Real-time Solana Program Data.
The Sample App: Jupiter Limit Order Monitor
Jupiter Limit Orders is an onchain order book that lets traders place orders that execute automatically when the market reaches a specified price. Unlike instant swaps, limit orders sit onchain until a keeper (referred to as taker in the program's accounts) fills them at or better than the requested price. The maker can also cancel an unfilled order at any time to reclaim their tokens.
Jupiter Limit Orders is used for this guide for three reasons:
- All activity flows through a single Anchor program (
j1o2qRpjcyUwEvwtcfhEQefh773ZgjxcVRry7LDqg5X), which maps cleanly to Shipstern's single-pipeline model. - A Codama-format IDL for the program ships in the Shipstern repo, which lets us demonstrate Shipstern's IDL codegen feature end-to-end.
- The program emits three discrete, semantically distinct instruction types (one per lifecycle event), which makes the handler logic straightforward to read and extend.
The three instructions this guide monitors are:
InitializeOrder: A maker creates a new limit order specifying input/output tokens and amountsFillOrder: A keeper fills an existing order at or better than the requested priceCancelOrder: A maker cancels their unfilled order and reclaims their tokens
For each event, the handler resolves human-readable token symbols from Jupiter's token API and formats raw token amounts using the decimal precision from the transaction's token balance metadata.
Set Up the Project
Create the project directory and an idls folder to store the Jupiter Limit Order IDL:
cargo new jupiter-lo-shipstern
cd jupiter-lo-shipstern
mkdir idls
Replace the contents of Cargo.toml with the following. All Shipstern crates are pinned to v0.9.0:
[package]
name = "jupiter-lo-shipstern"
version = "0.1.0"
edition = "2021"
[dependencies]
shipstern = "0.9.0"
shipstern-core = "0.9.0"
shipstern-parser = "0.9.0"
shipstern-proc-macro = "0.9.0"
shipstern-yellowstone-grpc-source = "0.9.0"
borsh = { version = "1", features = ["derive"] }
bs58 = "0.5"
chrono = { version = "0.4", features = ["clock"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls", "blocking"] }
serde = { version = "1", features = ["derive"] }
clap = { version = "4", features = ["derive"] }
rustls = { version = "0.23", features = ["ring"] }
toml = "0.8"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
Note that shipstern-core must be a direct dependency (not just transitive through shipstern) because the proc-macro generates code that references it by crate name.
Here is what the key dependencies do:
shipstern/shipstern-core: The Shipstern runtime and core traits (Parser, Handler, Pipeline)shipstern-proc-macro: Theinclude_shipstern_parser!macro for IDL-based codegenshipstern-yellowstone-grpc-source: Connects the Shipstern runtime to a Solana gRPC endpointborsh: Serialization format used by Solana programs (required by the generated parser)bs58: Base58 encoding for transaction signatureschrono: Timestamp formatting for log outputreqwest: HTTP client for fetching token symbols from the Jupiter API and the current slot on startupclap: Command-line argument parsing for the--configflagrustls: TLS provider required by the gRPC connectiontracing/tracing-subscriber: Structured logging with environment-based filtering
Create Shipstern.toml at the project root. This is the only config file the application needs:
[source]
endpoint = "https://YOUR_QN_ENDPOINT:443"
x-token = "YOUR_X_TOKEN"
timeout = 120
commitment-level = "confirmed"
accept-compression = "zstd"
You can find these values in the Quicknode dashboard under your Solana mainnet endpoint's Solana gRPC settings. The dashboard displays your endpoint URL in the format https://your-endpoint-name.solana-mainnet.quiknode.pro/abc123.
For the endpoint field, keep the https:// scheme, drop the token path, and append :443 to the hostname (for example https://your-endpoint-name.solana-mainnet.quiknode.pro:443). Use the trailing token (abc123) as your x-token.
The accept-compression = "zstd" setting enables compressed responses on port 443, which optimizes your metered data usage. For more details, see the Solana gRPC documentation.
Generate Parser from Limit Order IDL
Instead of writing manual deserialization code for each instruction, you provide a program's IDL in Codama format and the include_shipstern_parser! macro generates a complete type-safe parser at compile time.
Add the Jupiter Limit Order v2 IDL:
The Shipstern repo includes a ready-to-use Codama IDL for Jupiter Limit Order v2 at tests/idls/limit_order_v2.json. Download it into your idls/ folder:
curl -o idls/lo_v2.json https://raw.githubusercontent.com/solana-rpc/shipstern/main/tests/idls/limit_order_v2.json
The IDL contains the full program interface definition: instruction names, account structures, argument types, and the program address. This is what Shipstern uses to generate typed Rust code. With the file in place, skip ahead to Invoke the Proc-Macro.
Convert Your Own Anchor IDL
Most Anchor programs do not ship a Codama IDL, so you fetch the program's Anchor IDL and convert it. Jupiter no longer publishes the Limit Order v2 IDL onchain, so use the download above for this guide and the flow below for your own programs.
Fetch the onchain IDL with the Anchor CLI. Replace PROGRAM_ID with the program address and YOUR_QN_RPC_URL with your Quicknode Solana mainnet RPC endpoint:
anchor idl fetch PROGRAM_ID \
--provider.cluster YOUR_QN_RPC_URL \
-o idls/program_raw.json
codama convert can reject an IDL that is valid Anchor output. When a PDA seed references an argument by a path that does not match the actual argument structure, Codama cannot resolve the reference and errors out. Expect to troubleshoot cases like this yourself when a program has no Codama IDL ready to use. A fix for nested seed paths is tracked in codama-idl/codama#990.
Jupiter Limit Order v2 hits exactly this case. Its initialize_order instruction declares a seed as "path": "unique_id", but unique_id sits inside a params struct rather than at the top level. To fix an IDL like this, open the raw file and find the seed reference in the instruction's account seeds:
{
"kind": "arg",
"path": "unique_id"
}
Change it to the full path:
{
"kind": "arg",
"path": "params.unique_id"
}
Convert to Codama Format:
Initialize the root project folder as a Node.js project so we can run Codama commands:
npm init -y
npm install -g @codama/cli
npm install @codama/nodes-from-anchor
@codama/nodes-from-anchor is required to understand Anchor's IDL format during the conversion.
Convert the raw Anchor IDL to Codama format:
codama convert idls/program_raw.json idls/program.json
Point the macro at the file you just wrote. The next section calls include_shipstern_parser!("idls/lo_v2.json"), so use idls/program.json there instead if you followed this flow.
Invoke the Proc-Macro
Create src/parser.rs with the following:
use shipstern_proc_macro::include_shipstern_parser;
include_shipstern_parser!("idls/lo_v2.json");
This single macro call generates at compile time:
- A
limit_order2module containing all generated types - A typed
Instructionsenum with variants for every instruction in the IDL (InitializeOrder,FillOrder,CancelOrder, and others) - Typed account and argument structs for each instruction
- An
InstructionParserthat implements Shipstern's parser trait - A
TransactionPrefilterconfigured with three filters: program address (Jupiter LO v2 only),failed: Some(false)to exclude failed transactions, andvote: Some(false)to exclude vote transactions
Implement the Monitor
Shipstern uses the tracing crate for structured, filterable log output, replacing the older log/env_logger pattern used in Vixen versions before v0.6.1.
The tracing subscriber respects the RUST_LOG environment variable, so you control verbosity at runtime without recompiling:
RUST_LOG=info: Shows handler output and Shipstern lifecycle eventsRUST_LOG=debug: Adds gRPC connection details and stream subscription activityRUST_LOG=warn: Shows only warnings and errors
The initialization happens in main() before any other code runs, ensuring all Shipstern internals and your handler code benefit from structured logging.
Implement the Limit Order Handler
Create src/handlers.rs. The handler receives parsed instructions from the generated parser and logs the three order events: placements, fills, and cancellations.
The handler includes helpers that fetch human-readable token symbols from Jupiter's token API and cache them to avoid repeated lookups. It also extracts decimal precision from the transaction's token balance metadata to display amounts in human-readable form (e.g., 1.5 USDC instead of 1500000).
use std::{collections::HashMap, sync::Mutex};
use shipstern::shipstern_core::instruction::InstructionUpdate;
use crate::parser::limit_order2;
fn fmt_amount(raw: u64, decimals: u32) -> String {
if decimals == 0 {
return raw.to_string();
}
let s = format!("{:.prec$}", raw as f64 / 10f64.powi(decimals as i32), prec = decimals as usize);
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
fn fmt_token(amount: &str, symbol: &str, mint: &str) -> String {
if symbol == mint {
format!("{amount} {mint}")
} else {
format!("{amount} {symbol} ({mint})")
}
}
async fn fetch_symbol(mint: &str) -> String {
#[derive(serde::Deserialize)]
struct Token {
id: String,
symbol: String,
}
let url = format!("https://api.jup.ag/tokens/v2/search?query={mint}");
let fallback = mint.to_string();
let Ok(resp) = reqwest::get(&url).await else {
return fallback;
};
let Ok(tokens) = resp.json::<Vec<Token>>().await else {
return fallback;
};
tokens
.into_iter()
.find(|t| t.id == mint)
.map(|t| t.symbol)
.unwrap_or(fallback)
}
#[derive(Debug, Default)]
pub struct LimitOrderHandler {
symbol_cache: Mutex<HashMap<String, String>>,
}
impl LimitOrderHandler {
async fn get_symbol(&self, mint: &str) -> String {
{
let cache = self.symbol_cache.lock().unwrap();
if let Some(sym) = cache.get(mint) {
return sym.clone();
}
}
let symbol = fetch_symbol(mint).await;
self.symbol_cache
.lock()
.unwrap()
.insert(mint.to_string(), symbol.clone());
symbol
}
}
impl shipstern::Handler<limit_order2::Instructions, InstructionUpdate>
for LimitOrderHandler
{
async fn handle(
&self,
value: &limit_order2::Instructions,
raw: &InstructionUpdate,
) -> shipstern::HandlerResult<()> {
use limit_order2::instruction::Instruction;
let sig = bs58::encode(&raw.shared.signature).into_string();
let ts = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.6fZ");
let pre = &raw.shared.pre_token_balances;
let post = &raw.shared.post_token_balances;
let find_decimals = |mint: &str| {
pre.iter()
.chain(post.iter())
.find(|b| b.mint == mint)
.and_then(|b| b.ui_token_amount.as_ref())
.map(|u| u.decimals)
};
match &value.instruction {
Instruction::InitializeOrder { accounts, args } => {
let input_mint_str = accounts.input_mint.to_string();
let output_mint_str = accounts.output_mint.to_string();
let input_symbol = self.get_symbol(&input_mint_str).await;
let output_symbol = self.get_symbol(&output_mint_str).await;
let making_amount = find_decimals(&input_mint_str)
.map(|d| fmt_amount(args.making_amount, d))
.unwrap_or_else(|| args.making_amount.to_string());
let taking_amount = find_decimals(&output_mint_str)
.map(|d| fmt_amount(args.taking_amount, d))
.unwrap_or_else(|| args.taking_amount.to_string());
let expired_at = args.expired_at
.and_then(|t| chrono::DateTime::from_timestamp(t, 0))
.map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
.unwrap_or_else(|| "None".to_string());
let making = fmt_token(&making_amount, &input_symbol, &input_mint_str);
let taking = fmt_token(&taking_amount, &output_symbol, &output_mint_str);
tracing::info!(
tx = %sig,
maker = %accounts.maker,
making_amount = %making,
taking_amount = %taking,
expired_at = %expired_at,
"New limit order placed - {ts}",
);
},
Instruction::FillOrder { accounts, args } => {
let input_mint_str = accounts.input_mint.to_string();
let output_mint_str = accounts.output_mint.to_string();
let input_symbol = self.get_symbol(&input_mint_str).await;
let input_amount = find_decimals(&input_mint_str)
.map(|d| fmt_amount(args.input_amount, d))
.unwrap_or_else(|| args.input_amount.to_string());
let input = fmt_token(&input_amount, &input_symbol, &input_mint_str);
tracing::info!(
tx = %sig,
taker = %accounts.taker,
output_mint = %output_mint_str,
input_amount = %input,
"Limit order filled - {ts}",
);
},
Instruction::CancelOrder { accounts, .. } => {
tracing::info!(
tx = %sig,
maker = %accounts.maker,
order = %accounts.order,
"Limit order cancelled - {ts}",
);
},
_ => {},
}
Ok(())
}
}
The handler implements Shipstern's Handler trait for the generated limit_order2::Instructions type. It pattern-matches on the three order instructions, plus a wildcard arm for everything else:
InitializeOrder: Logs the maker's address, input/output tokens with human-readable amounts, and the order's expiration timestampFillOrder: Logs the taker's address, the output mint, and the input amount being filledCancelOrder: Logs the maker's address and the order account being cancelled_(wildcard): Silently ignores all other instructions (fee updates, admin operations, etc.)
The handler also formats raw amounts by decimal precision and resolves ticker symbols through Jupiter's token API. Each mint it has not seen before costs one HTTP request before the log line prints, so a latency-sensitive pipeline should preload the token list or drop the symbol lookup.
Build the Shipstern Runtime
Replace the generated src/main.rs with the following code. This ties everything together: tracing initialization, the TLS crypto provider, config loading, and the Shipstern runtime with the instruction pipeline.
use std::path::PathBuf;
use clap::Parser as _;
use shipstern::Pipeline;
use shipstern_yellowstone_grpc_source::YellowstoneGrpcSource;
mod handlers;
mod parser;
use handlers::LimitOrderHandler;
use parser::limit_order2;
#[derive(clap::Parser)]
#[command(version, author, about = "Monitor Jupiter Limit Order v2 events via Shipstern")]
struct Opts {
#[arg(long, short)]
config: PathBuf,
}
fn main() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
rustls::crypto::ring::default_provider()
.install_default()
.expect("Failed to install rustls crypto provider");
let Opts { config } = Opts::parse();
let config = std::fs::read_to_string(config).expect("Error reading config file");
let config = toml::from_str(&config).expect("Error parsing config");
shipstern::Runtime::<YellowstoneGrpcSource>::builder()
.instruction(Pipeline::new(limit_order2::InstructionParser, [LimitOrderHandler::default()]))
.build(config)
.run();
}
The runtime setup performs four steps:
- Tracing: Initializes
tracing-subscriberwithEnvFilterso theRUST_LOGenvironment variable controls log verbosity at runtime without recompiling - TLS: Installs the
rustlscrypto provider, which is required by the gRPC TLS connection to Quicknode - Config: Reads and parses
Shipstern.tomlfor the endpoint, token, and stream settings - Runtime: Builds a Shipstern
Runtimewith a single instructionPipelinethat connects the generatedInstructionParserto theLimitOrderHandler, then starts streaming
The Pipeline::new call wires the parser to the handler. The generated InstructionParser handles subscription filtering (program address, failed transactions, vote transactions) and deserialization. Your handler receives only parsed, typed instruction data for the Jupiter Limit Order v2 program.
Build and Run the Application
Build the project:
cargo build
Run it with the config flag. Set RUST_LOG=info to see handler output and Shipstern lifecycle events:
RUST_LOG=info cargo run -- --config ./Shipstern.toml
Expected Output
Once connected, limit order events print as they occur on mainnet (at RUST_LOG=info, Shipstern itself is quiet until shutdown). Each line is prefixed with the shipstern.process.transaction:shipstern.handle tracing span. Here is real output for each of the three event types:
2026-09-09T21:24:56.462853Z INFO shipstern.process.transaction:shipstern.handle: jupiter_lo_shipstern::handlers: New limit order placed - 2026-09-09T21:24:56.077735Z tx=3ZW1bzzSsZZTG6JMFSEZWYkV2vyjnLnDta3zrhEPcMszUYKXooBmKH8UXNsWUp2GDniPt9q25TStxzrNjsunY4qD maker=mT7cXePAXuGjFQwMp86KQMhn2p7KLBiXKK69Yo6CNmm making_amount=482354.913977 chud (DVAnBCJYEfC8TDFJR2xXUXvDxsMAfhLFWDPCxUf1pump) taking_amount=0.064532255 SOL (So11111111111111111111111111111111111111112) expired_at=2026-10-09T21:24:54Z
2026-09-09T21:25:29.427075Z INFO shipstern.process.transaction:shipstern.handle: jupiter_lo_shipstern::handlers: Limit order cancelled - 2026-09-09T21:25:29.427060Z tx=5DW4zdypw94CnAyrvAafgetHXXZcK6GDThSuyGsK5GLdxXx63rm9Frkzesh6GGKAi7Ly9DLJWGsAU5PRYYVB86ba maker=mT7cXePAXuGjFQwMp86KQMhn2p7KLBiXKK69Yo6CNmm order=6qJYsD5mJ9HYiQY1vKK1heuooXGounqTS2sWGjcvKNn4
2026-09-09T21:26:26.283985Z INFO shipstern.process.transaction:shipstern.handle: jupiter_lo_shipstern::handlers: Limit order filled - 2026-09-09T21:26:26.022134Z tx=2grUmtah8CpjVqXrMkvLz5K3XMq438MCY26fcUAF11SwjGywKGJRvYfVv8Yqdz752UtGMXX5yQPmqV8EmjdhVX9e taker=j1opmdubY84LUeidrPCsSGskTCYmeJVzds1UWm6nngb output_mint=9Pfync3ejPC9eHqVzq3nYQJAhyhjqpnB9UsaSfLxpump input_amount=13.474212 USDC (EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v)
Each entry includes an ISO 8601 timestamp, the transaction signature, and the relevant accounts and amounts for the event type. Token amounts are displayed in human-readable form with ticker symbols resolved from Jupiter's token API. Expect events within a minute or two; Jupiter Limit Order v2 activity is steady but not constant.
Recover from Restarts
As built so far, the monitor subscribes from the live tip every time it starts. If the process stops, it misses every limit order event until it comes back, and nothing in the logs says so.
Auto-reconnect covers a dropped gRPC connection while the process is alive. It does nothing when the process crashes or restarts. For that, persist the last processed slot and pass it to from-slot on startup. Quicknode's Solana gRPC endpoints replay about the last 3,000 slots, roughly 20 minutes. Events older than that are gone for good.
Auto-reconnect is on by default since Shipstern moved to yellowstone-grpc-client 13.1. It resumes from the last slot it saw and deduplicates replayed events. By default it retries 10 times, waiting 500 ms first and doubling the wait each time, which covers about 8.5 minutes of outage. The auto-reconnect, reconnect-max-retries, and reconnect-slot-retention keys under [source] in Shipstern.toml change this behavior. When the retries run out, the process exits, so run it under a supervisor such as systemd or Docker's restart: always.
Persist the Last Processed Slot
Every InstructionUpdate carries its slot in raw.shared.slot. Create src/checkpoint.rs to save it to a file (a database row works too). Writing to a temporary file and renaming it means a crash mid-write can't leave a truncated checkpoint:
use std::{fs, io, path::Path};
pub const CHECKPOINT_PATH: &str = "last_slot.txt";
pub fn load() -> Option<u64> {
fs::read_to_string(CHECKPOINT_PATH).ok()?.trim().parse().ok()
}
pub fn save(slot: u64) -> io::Result<()> {
let tmp = Path::new(CHECKPOINT_PATH).with_extension("tmp");
fs::write(&tmp, slot.to_string())?;
fs::rename(tmp, CHECKPOINT_PATH)
}
Replace src/handlers.rs with the following:
use std::{collections::HashMap, sync::Mutex};
use shipstern::shipstern_core::instruction::InstructionUpdate;
use crate::{checkpoint, parser::limit_order2};
fn fmt_amount(raw: u64, decimals: u32) -> String {
if decimals == 0 {
return raw.to_string();
}
let s = format!("{:.prec$}", raw as f64 / 10f64.powi(decimals as i32), prec = decimals as usize);
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
fn fmt_token(amount: &str, symbol: &str, mint: &str) -> String {
if symbol == mint {
format!("{amount} {mint}")
} else {
format!("{amount} {symbol} ({mint})")
}
}
async fn fetch_symbol(mint: &str) -> String {
#[derive(serde::Deserialize)]
struct Token {
id: String,
symbol: String,
}
let url = format!("https://api.jup.ag/tokens/v2/search?query={mint}");
let fallback = mint.to_string();
let Ok(resp) = reqwest::get(&url).await else {
return fallback;
};
let Ok(tokens) = resp.json::<Vec<Token>>().await else {
return fallback;
};
tokens
.into_iter()
.find(|t| t.id == mint)
.map(|t| t.symbol)
.unwrap_or(fallback)
}
#[derive(Debug, Default)]
pub struct LimitOrderHandler {
symbol_cache: Mutex<HashMap<String, String>>,
last_slot: Mutex<u64>,
}
impl LimitOrderHandler {
async fn get_symbol(&self, mint: &str) -> String {
{
let cache = self.symbol_cache.lock().unwrap();
if let Some(sym) = cache.get(mint) {
return sym.clone();
}
}
let symbol = fetch_symbol(mint).await;
self.symbol_cache
.lock()
.unwrap()
.insert(mint.to_string(), symbol.clone());
symbol
}
}
impl shipstern::Handler<limit_order2::Instructions, InstructionUpdate>
for LimitOrderHandler
{
async fn handle(
&self,
value: &limit_order2::Instructions,
raw: &InstructionUpdate,
) -> shipstern::HandlerResult<()> {
use limit_order2::instruction::Instruction;
let sig = bs58::encode(&raw.shared.signature).into_string();
let ts = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.6fZ");
let pre = &raw.shared.pre_token_balances;
let post = &raw.shared.post_token_balances;
let find_decimals = |mint: &str| {
pre.iter()
.chain(post.iter())
.find(|b| b.mint == mint)
.and_then(|b| b.ui_token_amount.as_ref())
.map(|u| u.decimals)
};
match &value.instruction {
Instruction::InitializeOrder { accounts, args } => {
let input_mint_str = accounts.input_mint.to_string();
let output_mint_str = accounts.output_mint.to_string();
let input_symbol = self.get_symbol(&input_mint_str).await;
let output_symbol = self.get_symbol(&output_mint_str).await;
let making_amount = find_decimals(&input_mint_str)
.map(|d| fmt_amount(args.making_amount, d))
.unwrap_or_else(|| args.making_amount.to_string());
let taking_amount = find_decimals(&output_mint_str)
.map(|d| fmt_amount(args.taking_amount, d))
.unwrap_or_else(|| args.taking_amount.to_string());
let expired_at = args.expired_at
.and_then(|t| chrono::DateTime::from_timestamp(t, 0))
.map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
.unwrap_or_else(|| "None".to_string());
let making = fmt_token(&making_amount, &input_symbol, &input_mint_str);
let taking = fmt_token(&taking_amount, &output_symbol, &output_mint_str);
tracing::info!(
tx = %sig,
maker = %accounts.maker,
making_amount = %making,
taking_amount = %taking,
expired_at = %expired_at,
"New limit order placed - {ts}",
);
},
Instruction::FillOrder { accounts, args } => {
let input_mint_str = accounts.input_mint.to_string();
let output_mint_str = accounts.output_mint.to_string();
let input_symbol = self.get_symbol(&input_mint_str).await;
let input_amount = find_decimals(&input_mint_str)
.map(|d| fmt_amount(args.input_amount, d))
.unwrap_or_else(|| args.input_amount.to_string());
let input = fmt_token(&input_amount, &input_symbol, &input_mint_str);
tracing::info!(
tx = %sig,
taker = %accounts.taker,
output_mint = %output_mint_str,
input_amount = %input,
"Limit order filled - {ts}",
);
},
Instruction::CancelOrder { accounts, .. } => {
tracing::info!(
tx = %sig,
maker = %accounts.maker,
order = %accounts.order,
"Limit order cancelled - {ts}",
);
},
_ => {},
}
let slot = raw.shared.slot;
let mut last_slot = self.last_slot.lock().unwrap();
if slot > *last_slot {
match checkpoint::save(slot) {
Ok(()) => *last_slot = slot,
Err(err) => tracing::warn!(%err, slot, "Failed to save checkpoint"),
}
}
Ok(())
}
}
The handler now imports the checkpoint module and tracks the highest saved slot in a last_slot field. At the end of handle, it saves raw.shared.slot whenever the slot is newer than the last one saved. The Mutex stops concurrent handlers from writing slots out of order, and a failed write logs a warning instead of stopping the stream.
Resume with from-slot on Startup
On startup, the monitor compares the checkpoint to the current slot:
| Checkpoint | Action |
|---|---|
| None | Start from the live tip |
| Inside the replay window | Resume from the saved slot |
| Outside the replay window | Log the slots that are permanently missed, then resume from the oldest available slot |
The monitor resumes from the saved slot itself, because a crash can land before every transaction in that slot is handled. The last event before a crash can print twice as a result. Replace src/main.rs with the following:
use std::path::PathBuf;
use clap::Parser as _;
use shipstern::{config::ShipsternConfig, Pipeline};
use shipstern_yellowstone_grpc_source::{YellowstoneGrpcConfig, YellowstoneGrpcSource};
mod checkpoint;
mod handlers;
mod parser;
use handlers::LimitOrderHandler;
use parser::limit_order2;
/// Quicknode Solana gRPC replays roughly the last 3,000 slots (about 20 minutes).
const REPLAY_WINDOW_SLOTS: u64 = 3_000;
/// Headroom so the resume slot is still inside the window when the stream connects.
const REPLAY_MARGIN_SLOTS: u64 = 100;
#[derive(clap::Parser)]
#[command(version, author, about = "Monitor Jupiter Limit Order v2 events via Shipstern")]
struct Opts {
#[arg(long, short)]
config: PathBuf,
}
fn current_slot(rpc_url: &str) -> Result<u64, reqwest::Error> {
#[derive(serde::Deserialize)]
struct SlotResponse {
result: u64,
}
let resp: SlotResponse = reqwest::blocking::Client::new()
.post(rpc_url)
.header("Content-Type", "application/json")
.body(r#"{"jsonrpc":"2.0","id":1,"method":"getSlot","params":[{"commitment":"confirmed"}]}"#)
.send()?
.error_for_status()?
.json()?;
Ok(resp.result)
}
fn resume_slot() -> Option<u64> {
let Some(saved) = checkpoint::load() else {
tracing::info!("No checkpoint found, starting from the live tip");
return None;
};
let rpc_url = std::env::var("SOLANA_RPC_URL").expect("SOLANA_RPC_URL must be set");
let current = current_slot(&rpc_url).expect("Failed to fetch the current slot");
let gap = current.saturating_sub(saved);
if gap < REPLAY_WINDOW_SLOTS - REPLAY_MARGIN_SLOTS {
tracing::info!(saved, current, gap, "Resuming from checkpoint");
return Some(saved);
}
let earliest = current - REPLAY_WINDOW_SLOTS + REPLAY_MARGIN_SLOTS;
tracing::warn!(
saved,
current,
gap,
"Checkpoint is outside the replay window. Slots {} to {} are permanently missed; resuming from slot {earliest}",
saved + 1,
earliest - 1,
);
Some(earliest)
}
fn main() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
rustls::crypto::ring::default_provider()
.install_default()
.expect("Failed to install rustls crypto provider");
let Opts { config } = Opts::parse();
let config = std::fs::read_to_string(config).expect("Error reading config file");
let mut config: ShipsternConfig<YellowstoneGrpcConfig> =
toml::from_str(&config).expect("Error parsing config");
config.source.from_slot = resume_slot();
shipstern::Runtime::<YellowstoneGrpcSource>::builder()
.instruction(Pipeline::new(limit_order2::InstructionParser, [LimitOrderHandler::default()]))
.build(config)
.run();
}
The 3,000-slot window is approximate. For the exact oldest replayable slot, call subscribeReplayInfo.
Test Crash Recovery
Set SOLANA_RPC_URL to your Quicknode Solana mainnet HTTP endpoint:
export SOLANA_RPC_URL="https://your-endpoint-name.solana-mainnet.quiknode.pro/abc123"
Run the monitor until last_slot.txt exists:
RUST_LOG=info cargo run -- --config ./Shipstern.toml
Simulate a crash from a second terminal:
pkill -9 -f target/debug/jupiter-lo-shipstern
Restart the monitor after a minute or two. Events from the downtime replay before live events resume:
2026-09-29T21:21:15.796148Z INFO jupiter_lo_shipstern: Resuming from checkpoint saved=451759961 current=451761060 gap=1099
2026-09-29T21:21:16.114587Z INFO shipstern.process.transaction:shipstern.handle: jupiter_lo_shipstern::handlers: Limit order cancelled - 2026-09-29T21:21:16.114560Z tx=V3vNfmYHaTqRd82dBU1npMDZa1tam7vjjzJpfNDGQpRi6d7jrdz5vRqu73UHfmhxz2mrXbULuz6W9h63HtFotNh maker=3E9MAXtNVNK8hV9D34KrLTJNCyMRAGvik9KGwS6YetfT order=E4gnxGGssH1dBgesXwSUQwewi2zse3wv5MG1yD4Fxuxc
2026-09-29T21:21:16.720591Z INFO shipstern.process.transaction:shipstern.handle: jupiter_lo_shipstern::handlers: Limit order cancelled - 2026-09-29T21:21:16.720571Z tx=3GufytMPaVEh1LLyhBb44pLbG5KFegMK5dYJ8tvn4SBpAEpa6Lwvr7NXfazLHwdPaUL2bG5H3YQKeo99GTwDNjFh maker=BL7dzuMxWqne9aUVC6PJTqsbLWX5iUjzj6bSqef6eK7h order=4tURFPJmBNKzRvim9KbzP3ZsiwNUeVp6k6wHZyzNWesH
The first event is the last one before the crash, replayed from the saved slot. The second landed during the downtime. The gap exceeds the 90 seconds of downtime because the checkpoint only advances on program activity.
To test the out-of-window branch, stop the monitor, move the checkpoint 5,000 slots back, and restart:
echo $(( $(cat last_slot.txt) - 5000 )) > last_slot.txt
2026-09-29T21:22:36.220489Z WARN jupiter_lo_shipstern: Checkpoint is outside the replay window. Slots 451756114 to 451758456 are permanently missed; resuming from slot 451758457 saved=451756113 current=451761357 gap=5244
Expand Your Monitor
The architecture you built is flexible. Here are some ways to extend it:
- Monitor a different Jupiter program: Swap in the IDL for Jupiter DCA, Perps, or any other Anchor program. The pipeline structure stays the same; only the IDL file and handler logic change.
- Watch multiple programs simultaneously: Shipstern supports multiple program subscriptions. Add a second instruction pipeline to monitor both Limit Order v1 and v2 in the same runtime.
- Add account monitoring: Alongside instruction monitoring, add an account pipeline to track changes to specific limit order account states.
- Persist events to a database: Extend the handler to write parsed events to PostgreSQL, ClickHouse, or another datastore for historical analysis. Store the slot checkpoint in the same database transaction as the events, so the two can never disagree after a crash.
Frequently Asked Questions
What is Solana gRPC and how does it enable real-time monitoring on Solana?
Solana gRPC is a high-performance gRPC interface for streaming Solana blockchain data, including transactions and account updates, in real time. It allows Rust applications to subscribe to specific programs like Jupiter for low-latency limit order feeds without polling.
What is Shipstern (formerly Vixen) in the context of Solana development?
Shipstern, formerly known as Yellowstone Vixen, is an open-source Rust framework for building typed data pipelines on top of Solana gRPC. It handles the gRPC connection, stream filtering, and instruction deserialization automatically, so you write business logic against typed Rust structs rather than raw bytes. Its core abstractions are a Parser, which decodes instructions, a Handler, which processes them, and a Pipeline, which connects the two inside a managed Runtime.
Can I use Shipstern to monitor programs other than Jupiter Limit Orders?
Yes. Shipstern's proc-macro works with any Anchor program that publishes an IDL. Fetch the IDL with the Anchor CLI, convert it to Codama format, and pass it to include_shipstern_parser!. The generated parser and pipeline structure remain the same. Only the IDL file and your handler logic change.
Why does the TransactionPrefilter exclude failed transactions?
Shipstern's TransactionPrefilter includes a failed field, introduced in Vixen v0.6.1. Setting it to exclude failed transactions ensures only confirmed, successfully executed transactions reach your handler. For a limit order monitor, this prevents logging events like failed fill attempts that never actually executed onchain.
Does Shipstern lose data if my monitor crashes or restarts?
Auto-reconnect is on by default and covers a dropped connection while the process is running. It retries 10 times with exponential backoff from 500 ms, about 8.5 minutes in total. A crash or restart loses its in-memory position. To recover, persist the last processed slot and pass it as from-slot on startup. Quicknode's Solana gRPC replays about the last 3,000 slots, roughly 20 minutes, so anything older is permanently missed.
Do I need a Quicknode account to use Shipstern?
Shipstern itself is an open-source framework. However, it requires a Solana gRPC endpoint to stream data. Quicknode provides Solana gRPC (included with Scale and Business plans, or available via the add-on on Build and Accelerate) that you can enable on any Quicknode Solana endpoint from the dashboard.
What is Codama and why is a conversion step needed?
Codama is an IDL format that provides richer type information than Anchor's native IDL format. Shipstern's proc-macro requires Codama format to generate accurate typed structs and enums. The codama convert command bridges the two formats. This one-time conversion step is what unlocks Shipstern's compile-time codegen for any Anchor program.
Wrapping Up
You have built a real-time Jupiter Limit Order monitor that streams confirmed transactions via Solana gRPC, parses them into typed Rust structs using Shipstern's IDL codegen, and logs order placements, fills, and cancellations with human-readable token output. From here, you can apply the same pattern to any Anchor program by swapping in its IDL, making Shipstern (formerly Vixen) a versatile foundation for building Solana data pipelines.
Resources
- Guide: Monitor Solana Programs with Solana gRPC (Rust)
- Guide: Solana gRPC and Carbon: Parse Real-time Solana Program Data
- Guide: How to Create Anchor Program Clients using Codama
- Shipstern GitHub (formerly Yellowstone Vixen)
- Shipstern v0.8.0 release notes (rename)
- Codama IDL
- Quicknode Solana gRPC Documentation
- Anchor Documentation
- Jupiter Trigger Order API Overview
Have questions? Join the Quicknode Discord or follow @quicknode for updates.
