DEV Community

Cover image for The OrbitFlare SDK: One Rust Crate for Every Solana Transport
OrbitFlare RPC
OrbitFlare RPC

Posted on

The OrbitFlare SDK: One Rust Crate for Every Solana Transport

Building a Solana app in Rust tends to mean you collect SDKs. One for JSON-RPC because you need get_balance and get_transaction for the obvious one-shot reads. One for WebSocket because polling for account changes gets tired fast. One for gRPC because Yellowstone is the only realistic way to keep up once your volume passes a certain threshold. And often a fourth for whatever specialized feed you've signed up for on top of it, because the first three stop being enough the moment latency starts mattering to your app.

Each one arrives with its own types, its own error model, its own opinions about how to reconnect when the socket drops, and its own way of threading auth into a URL. You end up writing the same wrapper code in four slightly different places - retry the request, fall back to a secondary endpoint, redact the api key out of log lines, time out when the connection has gone silent - and none of it is the reason you started the project. It's the least interesting code in any Solana app, and somehow it ends up being the most fragile too.

The OrbitFlare Rust SDK

OrbitFlare homepage
OrbitFlare sits at the other end of that problem. We run Solana RPC, WebSocket, and gRPC endpoints, plus our own Jetstream feed for the cases where Geyser-based streams can't keep up.

Our new Rust SDK is what you use to talk to any of it, and it's shaped around a single builder pattern and a single error type across every transport, so the code you write to call get_balance over HTTP reads almost identically to the code you write to open a binary gRPC firehose.

What that buys you, beyond a shorter Cargo.toml, is that all the plumbing you would otherwise be writing per-client lives once, in one place, and gets better over time instead of drifting in four directions at once.

The Transports

The crate exposes four transport modules, each behind its own feature flag.

  • rpc is the typed JSON-RPC client you'll reach for whenever you need a one-shot read, a transaction submission, or a historical lookup.

    let rpc = RpcClientBuilder::new()
        .url("http://ams.rpc.orbitflare.com")
        .build()?;
    
  • ws gives you the pub/sub surface for the things you want to watch as they happen on-chain: slots, accounts, signatures, and log filters.

    let ws = WsClientBuilder::new()
        .url("ws://ams.rpc.orbitflare.com")
        .build()
        .await?;
    
  • grpc is the Yellowstone-compatible firehose, where you open one long-lived subscription, hand the server a set of filters, and let it stream every matching event straight from the validator's Geyser plugin.

    let geyser = GeyserClientBuilder::new()
        .url("http://ams.rpc.orbitflare.com:10000")
        .build()?;
    
  • jetstream has the same API shape as the gRPC client, but the data comes out of our own shred-decoded pipeline rather than Geyser, which is a meaningful win when "earlier" matters to what you're building.

    let jetstream = JetstreamClientBuilder::new()
        .url("http://ams.jetstream.orbitflare.com")
        .build()?;
    

Installation

cargo add orbitflare-sdk --features <features>
Enter fullscreen mode Exit fullscreen mode

The available features are rpc, ws, grpc, and jetstream. Pick whichever combination your app actually needs and your dependency tree stays lean.

What You Don't Have to Write

The boring infrastructure around any transport client - reconnect, fallback, liveness, api key handling - is where most of the bugs in any Solana app quietly live, and it's fundamentally the same work regardless of which protocol you're speaking.

The SDK handles it once, on your behalf, and runs the same implementation across every client. When a socket drops, the subscriptions you had on it get re-registered against the new connection. When a primary endpoint starts misbehaving, the client backs off and cycles through whatever fallback URLs you chained onto the builder, bringing the primary back into rotation once it recovers.

And anywhere a URL might end up in an error log, the api key gets redacted out of it first and you start grepping your tracing output.

In a Real Project

Most apps built on the SDK end up with roughly the same shape. You construct the clients you need up front, hand them off to whichever parts of the app are going to use them, and let the SDK manage the underlying connections for the lifetime of the process.

use orbitflare_sdk::{
    GeyserClientBuilder, Result, RpcClientBuilder, WsClientBuilder,
};

#[tokio::main]
async fn main() -> Result<()> {
    let rpc = RpcClientBuilder::new()
        .url("http://ams.rpc.orbitflare.com")
        .build()?;

    let ws = WsClientBuilder::new()
        .urls(&[
            "ws://ams.rpc.orbitflare.com",
            "ws://lon.rpc.orbitflare.com",
        ])
        .build()
        .await?;

    let geyser = GeyserClientBuilder::new()
        .url("http://ams.rpc.orbitflare.com:10000")
        .build()?;

    // ... use rpc, ws, geyser
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

The demo we're going to build over the rest of this post is a small wallet ticker. You pass in a pubkey at the command line, and the program prints a live view of that wallet's SOL balance along with every token account it owns, updating the display in place as the balances change. Around 120 lines of code end to end.

It uses the same pattern the code sketch above hinted at. The RPC client handles the bootstrap - one get_balance for the SOL side, one get_token_accounts_by_owner for the SPL tokens, and a handful of small lookups to decorate each account with its mint's decimals so the output reads as token amounts rather than raw integers.

From there the WebSocket client takes over: we open an account_subscribe for the wallet itself and one for each token account we found, merge the updates into a single stream inside the app, and redraw the dashboard whenever any of them tick. The RPC client gets you the starting state; the WebSocket client keeps it honest from there.

Setting Up the Project

Create a new Cargo project and pull in the dependencies you'll need:

[package]
name = "wallet-ticker"
version = "0.1.0"
edition = "2024"
rust-version = "1.85"

[dependencies]
orbitflare-sdk = { version = "0.2.0", features = ["rpc", "ws"] }
tokio = { version = "1.52.1", features = ["full"] }
serde_json = "1.0.149"
base64 = "0.22.1"
Enter fullscreen mode Exit fullscreen mode

orbitflare-sdk with the rpc and ws features gives you exactly the two transports this project uses and nothing else.

Tokio's full feature is convenient for a tutorial; in a production app you'd bare that down to the runtime pieces you actually rely on.

base64 and serde_json show up because WebSocket account updates arrive as base64-encoded bytes wrapped in a JSON envelope, and you'll be reaching into both.

Building the Clients

RpcClient::build is synchronous; WsClient::build is async because it actually opens the socket. The Arc wrap on ws is there because each subscription will live in its own tokio::spawn and they all need to reach the same underlying connection:

let rpc = RpcClientBuilder::new()
    .url("http://ams.rpc.orbitflare.com")
    .commitment("confirmed")
    .build()?;

let ws = Arc::new(
    WsClientBuilder::new()
        .url("ws://ams.rpc.orbitflare.com")
        .build()
        .await?,
);
Enter fullscreen mode Exit fullscreen mode

The Bootstrap

Pulling the wallet's current state is two RPC calls:

let mut sol_lamports = rpc.get_balance(&wallet).await?;
let raw_tokens: Vec<Value> = rpc
    .get_token_accounts_by_owner(&wallet, None, None)
    .await?;
Enter fullscreen mode Exit fullscreen mode

get_token_accounts_by_owner returns each entry in jsonParsed encoding, so the mint address, token amount, and decimals are already structured fields you can lift straight into a HashMap<pubkey, Holding>. The pubkey you key by is the token account's own address - that's the id you'll subscribe to in the next step, not the mint.

Subscribing Concurrently

Once you have the list of accounts, you need one subscription for the wallet itself and one per token account. The shape that works is a tokio::spawn per subscription sharing an Arc<WsClient>, with all of them pushing updates into a single mpsc channel the main loop drains:

let ws = Arc::new(ws);
let (tx, mut rx) = mpsc::channel::<Update>(1024);

for pubkey in holdings.keys().cloned().collect::<Vec<_>>() {
    let ws = Arc::clone(&ws);
    let tx = tx.clone();
    tokio::spawn(async move {
        let Ok(mut sub) = ws.account_subscribe(&pubkey, "confirmed").await else {
            return;
        };
        while let Some(v) = sub.next().await {
            // decode v["data"][0] as an SPL token account,
            // then send an Update down tx
        }
    });
}
Enter fullscreen mode Exit fullscreen mode

The tokio::spawn is what makes this scale. A naive for pubkey in ... { ws.account_subscribe(...).await? } loop works too, but it serializes one subscribe per round-trip. That's fine for a dozen accounts, painful for a few hundred, and flat-out unusable on wallets with thousands of token accounts. Spawning lets every subscription open in parallel, and the first updates start landing in the channel before the last subscribe ack even comes back.

The Render Loop

Everything comes together in a short loop that reads the next update, mutates local state, and redraws:

while let Some(update) = rx.recv().await {
    match update {
        Update::Sol(lamports) => sol_lamports = lamports,
        Update::Token { account, amount } => {
            if let Some(h) = holdings.get_mut(&account) {
                h.amount = amount;
            }
        }
    }
    render(&wallet, sol_lamports, &holdings);
}
Enter fullscreen mode Exit fullscreen mode

Run it against any Solana address:

ORBITFLARE_LICENSE_KEY=ORBIT-KKKKKK-EEEEEE-YYYYYY cargo run --release -- <pubkey>
Enter fullscreen mode Exit fullscreen mode

You'll see the dashboard populate within a few hundred milliseconds as the RPC bootstrap lands, then each line ticks in place the moment something touches the wallet on-chain:

wallet: CKs1E69a2e9TmH4mKKLrXFF8kD3ZnwKjoEuXa6sz9WqX

  SOL     0.040420950
  EPjF...Dt1v      42.500000
  So11...1112       0.005000
Enter fullscreen mode Exit fullscreen mode

Ctrl+C to quit. The full source - about 140 lines of Rust end to end, including the render function, the state types, and the bootstrap parse loop - is linked at the bottom of the post.

That's about it for the SDK in broad strokes - a builder per transport, a shared error and retry story, and the reconnect and fallback work kept inside the crate where you don't have to think about it.

The ticker above is the smallest app that meaningfully exercises more than one of the clients at a time, but the shape scales up from there: the same pattern works just as well when you're stitching together a copy trader, a liquidation monitor, or anything else that needs to move between one-shot reads and live streams in the same loop.

With the SDK in place, the plumbing is off your plate and what's left is the part of the project that's actually yours to build. Which, for most of us, is the only reason we started one in the first place.

Resources

  1. orbitflare-sdk on crates.io
  2. Full Wallet Ticker Project
  3. SDK Examples on GitHub
  4. Docs

If you build something with the crate, or run into a rough edge that should be smoother, we'd love to hear about it and help you out!

Top comments (0)