Skip to content

Rust SDK

The Hyperstack Rust SDK is an asynchronous client for consuming streaming data from Hyperstack. It is designed for backend services, trading bots, and CLI tools that require type-safe access to Solana state projections.


Add to your Cargo.toml:

[dependencies]
hyperstack-sdk = "0.5.3"
hyperstack-stacks = { version = "0.5.3", optional = true }
tokio = { version = "1", features = ["full"] }
anyhow = "1"
futures = "0.3"

The hyperstack-stacks crate provides pre-built stack definitions for popular Solana protocols (optional but recommended).

By default, the SDK uses rustls for TLS. You can switch to native TLS:

[dependencies]
hyperstack-sdk = { version = "0.5.3", default-features = false, features = ["native-tls"] }

The SDK requires the Tokio runtime. Ensure you have it enabled in your project (specifically the rt-multi-thread, macros, and time features).


use hyperstack_sdk::prelude::*;
use hyperstack_stacks::ore::{OreStack, OreRound};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Connect to the ORE stack (URL is defined in OreStack)
let hs = HyperStack::<OreStack>::connect().await?;
println!("Connected! Streaming ORE rounds...\n");
// Access views directly
let mut stream = hs.views.ore_round.latest().listen();
// Stream updates
while let Some(round) = stream.next().await {
println!("Round # {:?}", round.id.round_id);
println!(" Motherlode: {:?}", round.state.motherlode);
println!(" Total difficulty: {:?}\n", round.state.total_difficulty);
}
Ok(())
}

Run with:

Terminal window
cargo run

Terminal window
cargo new my-hyperstack-app
cd my-hyperstack-app
[dependencies]
hyperstack-sdk = "0.5.3"
hyperstack-stacks = "0.5.3"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
anyhow = "1"
futures = "0.3"
src/main.rs
use hyperstack_sdk::prelude::*;
use hyperstack_stacks::ore::{OreStack, OreRound};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let hs = HyperStack::<OreStack>::connect().await?;
let mut stream = hs.views.ore_round.latest().listen();
while let Some(round) = stream.next().await {
println!("Round: {:?}", round.id.round_id);
}
Ok(())
}

Each stack defines its own URL, so connection is simple:

let hs = HyperStack::<OreStack>::connect().await?;

Override the default URL if needed:

let hs = HyperStack::<OreStack>::connect_url("wss://custom.endpoint.com").await?;

Configure the client with custom reconnection logic and intervals:

use std::time::Duration;
let hs = HyperStack::<OreStack>::builder()
.url("wss://custom.endpoint.com") // Optional: override default
.auto_reconnect(true)
.max_reconnect_attempts(10)
.reconnect_intervals(vec![
Duration::from_secs(1),
Duration::from_secs(2),
Duration::from_secs(5),
])
.ping_interval(Duration::from_secs(30))
.initial_data_timeout(Duration::from_secs(5))
.max_entries_per_view(5000)
.connect()
.await?;
// Check current state
match hs.connection_state().await {
ConnectionState::Connected => println!("Connected!"),
ConnectionState::Connecting => println!("Connecting..."),
ConnectionState::Disconnected => println!("Disconnected"),
ConnectionState::Reconnecting { attempt } => println!("Reconnecting (attempt {})...", attempt),
ConnectionState::Error => println!("Error"),
}
// Graceful disconnect
hs.disconnect().await;

By default, each view is limited to 10,000 entries to prevent memory issues on long-running clients. When the limit is reached, oldest entries are evicted (LRU).

// Custom limit
let hs = HyperStack::<OreStack>::builder()
.max_entries_per_view(5000)
.connect()
.await?;
// Unlimited (not recommended for long-running clients)
let hs = HyperStack::<OreStack>::builder()
.unlimited_entries()
.connect()
.await?;

Views provide typed access to your stack’s data. Access them directly through hs.views:

// Direct field access
let rounds = hs.views.ore_round.latest().get().await;
let all_rounds = hs.views.ore_round.list().get().await;
let specific = hs.views.ore_round.state().get("round_key").await;

The hyperstack-stacks crate includes view definitions for popular protocols:

use hyperstack_stacks::ore::OreStack;
let ore = HyperStack::<OreStack>::connect().await?;

To create views for your own Solana programs, you’ll need to build a stack. The CLI then generates typed view accessors for you automatically.


The SDK provides three streaming methods with different levels of detail:

MethodReturnsDescription
.listen()TMerged entity directly (simplest - filters out deletes)
.watch()Update<T>Upsert/Patch/Delete events
.watch_rich()RichUpdate<T>Before/after diffs for tracking changes

The simplest API - emits merged entities directly, filtering out deletes:

let mut stream = hs.views.ore_round.latest().listen();
while let Some(round) = stream.next().await {
println!("Round: {:?}", round.id.round_id);
}

Stream all update types (upsert, patch, delete):

let mut stream = hs.views.ore_round.latest().watch();
while let Some(update) = stream.next().await {
match update {
Update::Upsert { data, .. } => {
println!("New/Updated round: {:?}", data.id.round_id);
}
Update::Patch { data, .. } => {
println!("Patched round: {:?}", data.id.round_id);
}
Update::Delete { key } => {
println!("Removed round: {:?}", key);
}
}
}

Track changes over time with before/after diffs:

let mut stream = hs.views.ore_round.latest().watch_rich();
while let Some(update) = stream.next().await {
match update {
RichUpdate::Created { data, .. } => {
println!("Created: {:?}", data.id.round_id);
}
RichUpdate::Updated { before, after, .. } => {
println!("Updated from {:?} to {:?}",
before.state.motherlode,
after.state.motherlode);
}
RichUpdate::Deleted { key, last_known } => {
println!("Deleted: {:?}", key);
}
}
}

Stream updates for a specific key using state views:

let specific_round = "some-round-key";
let mut stream = hs.views.ore_round.state().watch(specific_round);
while let Some(update) = stream.next().await {
println!("Round updated: {:?}", update);
}

All stream builders support server-side options:

// Limit to top 10 items
let mut stream = hs.views.ore_round.list().watch().take(10);
// Skip first 5, take next 10
let mut stream = hs.views.ore_round.list().watch().skip(5).take(10);
// Filter by field
let mut stream = hs.views.ore_round.list().watch().filter("status", "active");

Use standard stream adapters for client-side filtering:

use futures::StreamExt;
let mut stream = hs.views.ore_round.latest().watch().filter(|update| {
futures::future::ready(matches!(update, Update::Upsert { .. }))
});
// Only receives upsert events
while let Some(update) = stream.next().await {
println!("Upsert: {:?}", update);
}

Streams are lazy - calling watch() returns immediately without subscribing. The subscription happens automatically on first poll. This enables ergonomic method chaining:

use std::collections::HashSet;
let watchlist: HashSet<String> = /* tokens to watch */;
let mut price_alerts = hs.views.ore_round.list()
.watch_rich()
.filter(move |u| watchlist.contains(u.key()))
.filter_map(|update| match update {
RichUpdate::Updated { before, after, .. } => {
let prev = before.trading.last_trade_price.flatten().unwrap_or(0.0);
let curr = after.trading.last_trade_price.flatten().unwrap_or(0.0);
if prev > 0.0 {
let pct = (curr - prev) / prev * 100.0;
if pct.abs() > 0.1 {
return Some((after.info.name.clone(), pct));
}
}
None
}
_ => None,
});
while let Some((name, pct)) = price_alerts.next().await {
println!("[PRICE] {:?} changed by {:.2}%", name, pct);
}

Fetch current state without streaming:

// Get all items from a view
let rounds: Vec<OreRound> = hs.views.ore_round.latest().get().await;
println!("Found {} rounds", rounds.len());
// Get a specific entity by key
let round: Option<OreRound> = hs.views.ore_round.state().get("round-key").await;
if let Some(r) = round {
println!("Round: {:?}", r.id.round_id);
}

For hot paths where you can’t await, use sync methods to read from cache:

// Synchronous - returns cached data immediately
let cached_rounds = hs.views.ore_round.latest().get_sync();
let cached_round = hs.views.ore_round.state().get_sync("round-key");

Note: Sync methods return empty/None if data hasn’t been loaded yet.


MethodReturnsDescription
.get().awaitVec<T>Get all items
.get_sync()Vec<T>Synchronous cache read
.listen()Stream<T>Stream merged entities (no deletes)
.watch()Stream<Update<T>>Stream all update types
.watch_rich()Stream<RichUpdate<T>>Stream with before/after diffs
.watch_keys(&[keys])Stream<Update<T>>Stream updates for specific keys
MethodReturnsDescription
.get(key).awaitOption<T>Get entity by key
.get_sync(key)Option<T>Synchronous cache read
.listen(key)Stream<T>Stream merged entity values
.watch(key)Stream<Update<T>>Stream updates for key
.watch_rich(key)Stream<RichUpdate<T>>Stream with diffs for key
MethodDescription
.take(n)Server-side limit to N items
.skip(n)Server-side offset
.filter(key, value)Server-side filter

When streaming with watch(), you receive Update<T> variants:

pub enum Update<T> {
Upsert { key: String, data: T }, // Full entity update
Patch { key: String, data: T }, // Partial update (merged)
Delete { key: String }, // Entity removed
}

Helper methods: key(), data(), is_delete(), has_data(), into_data(), into_key(), map(f)

For tracking changes over time, use watch_rich():

pub enum RichUpdate<T> {
Created { key: String, data: T },
Updated { key: String, before: T, after: T, patch: Option<Value> },
Deleted { key: String, last_known: Option<T> },
}

The Updated variant includes patch - the raw JSON of changed fields, useful for checking what specifically changed:

if update.has_patch_field("trading") {
// The trading field was modified
}

Generated entity types often have fields typed as Option<Option<T>>. This represents the patch semantics of HyperStack updates:

ValueMeaning
NoneField was not included in this update (no change)
Some(None)Field was explicitly set to null
Some(Some(value))Field has a concrete value

This distinction matters for partial updates (patches). When the server sends a patch, only changed fields are included. An absent field means “keep the previous value”, while an explicit null means “clear this field”.

// Access a nested optional field
let price = token.trading.last_trade_price.flatten().unwrap_or(0.0);
// Check if field was explicitly set (vs absent from patch)
match &token.reserves.current_price_sol {
None => println!("Price not in this update"),
Some(None) => println!("Price explicitly cleared"),
Some(Some(price)) => println!("Price: {}", price),
}
// Compare values in before/after
if before.trading.last_trade_price != after.trading.last_trade_price {
println!("Price changed!");
}

Use the HyperStack CLI to generate a typed Rust SDK from your spec. See CLI Commands for the full reference and Configuration for hyperstack.toml options.

Terminal window
# Generate SDK crate
hs sdk create rust settlement-game
# With custom output directory
hs sdk create rust settlement-game --output ./crates/game-sdk
# With custom crate name
hs sdk create rust settlement-game --crate-name game-sdk
# Generate as a module instead of a standalone crate
hs sdk create rust settlement-game --module --output ./src/stacks/game

By default, the CLI generates a standalone crate with its own Cargo.toml:

generated/settlement-game-stack/
├── Cargo.toml
└── src/
├── lib.rs # Re-exports
├── types.rs # Data structs (with Option<Option<T>> for patchable fields)
└── entity.rs # Stack and Views implementations

With the --module flag, the CLI generates a module that can be embedded in an existing crate:

src/stacks/game/
├── mod.rs # Re-exports
├── types.rs # Data structs
└── entity.rs # Stack and Views implementations

Add the generated crate to your Cargo.toml:

[dependencies]
hyperstack-sdk = "0.5.3"
settlement-game-stack = { path = "./generated/settlement-game-stack" }

Then use it:

use hyperstack_sdk::prelude::*;
use settlement_game_stack::{SettlementStack, GameState};
let hs = HyperStack::<SettlementStack>::connect().await?;
let scores = hs.views.player_score.leaderboard().get().await;
let game = hs.views.game_state.state().get("game-key").await;

Or if using module mode, add to your lib.rs:

pub mod game; // Points to src/game/mod.rs

use hyperstack_sdk::HyperStackError;
match HyperStack::<OreStack>::connect().await {
Ok(hs) => println!("Connected!"),
Err(HyperStackError::Connection(e)) => {
eprintln!("Connection failed: {}", e);
}
Err(HyperStackError::Authentication(e)) => {
eprintln!("Auth failed: {}", e);
}
Err(e) => {
eprintln!("Unexpected error: {:?}", e);
}
}

The SDK automatically reconnects on connection loss with configurable backoff:

let hs = HyperStack::<OreStack>::builder()
.auto_reconnect(true)
.reconnect_intervals(vec![
Duration::from_secs(1),
Duration::from_secs(2),
Duration::from_secs(5),
Duration::from_secs(10),
])
.max_reconnect_attempts(20)
.connect()
.await?;

A full command-line app that streams ORE mining rounds:

src/main.rs
use hyperstack_sdk::prelude::*;
use hyperstack_stacks::ore::{OreStack, OreRound};
use std::time::Duration;
use tokio::time::timeout;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
println!("-----------------------------------");
println!(" Hyperstack ORE Round Monitor ");
println!("-----------------------------------\n");
// Connect with 10 second timeout
let hs = match timeout(
Duration::from_secs(10),
HyperStack::<OreStack>::connect()
).await {
Ok(Ok(hs)) => {
println!("Connected to ORE stack\n");
hs
}
Ok(Err(e)) => {
eprintln!("Connection error: {}", e);
return Err(e.into());
}
Err(_) => {
eprintln!("Connection timeout");
return Err(anyhow::anyhow!("Connection timeout"));
}
};
// Stream with stats
let mut round_count = 0;
let mut update_count = 0;
let mut stream = hs.views.ore_round.latest().watch();
println!("Streaming live ORE rounds (press Ctrl+C to exit)...\n");
while let Some(update) = stream.next().await {
update_count += 1;
match update {
Update::Upsert { data, .. } => {
round_count += 1;
println!(
"[#{:03}] Round #{} - Motherlode: {} SOL",
update_count,
data.id.round_id,
data.state.motherlode as f64 / 1_000_000_000.0
);
}
Update::Patch { data, .. } => {
println!(
"[#{:03}] Updated Round #{} - Difficulty: {}",
update_count,
data.id.round_id,
data.state.total_difficulty
);
}
Update::Delete { key } => {
println!("[#{:03}] Removed round: {:?}", update_count, key);
}
}
// Print stats every 10 updates
if update_count % 10 == 0 {
println!("\n--- Stats: {} rounds tracked, {} updates received ---\n",
round_count, update_count);
}
}
Ok(())
}

Run it:

Terminal window
cargo run

use futures::future::join;
// Watch multiple views concurrently
let latest_stream = hs.views.ore_round.latest().watch();
let list_stream = hs.views.ore_round.list().watch();
let (latest_result, list_result) = join(
process_stream(latest_stream),
process_stream(list_stream)
).await;
loop {
match HyperStack::<OreStack>::connect().await {
Ok(hs) => {
println!("Connected!");
let mut stream = hs.views.ore_round.latest().watch();
while let Some(update) = stream.next().await {
process_update(update);
}
// Stream ended - connection lost
println!("Connection lost, reconnecting...");
}
Err(e) => {
eprintln!("Connection failed: {}, retrying in 5s...", e);
tokio::time::sleep(Duration::from_secs(5)).await;
}
}
}
use tokio::signal;
let hs = HyperStack::<OreStack>::connect().await?;
let mut stream = hs.views.ore_round.latest().watch();
loop {
tokio::select! {
Some(update) = stream.next() => {
process_update(update);
}
_ = signal::ctrl_c() => {
println!("\nShutting down gracefully...");
hs.disconnect().await;
break;
}
}
}

Scaffold a complete Rust example that streams ORE mining rounds:

Terminal window
hs create my-ore-app --template rust-ore
cd my-ore-app
cargo run

Or run hs create interactively and select the rust-ore template.