I built a Solana slot streaming crate from scratch — here's why
If you're building anything on Solana that needs to react fast — MEV, bundles, liquid staking triggers, whatever — you need to know when each slot starts and who's leading it.
The dominant answer is Yellowstone. It works. It's fast. And it costs money every month, tied to a managed endpoint you don't control.
I wanted to know if there was a better way.
The free alternative nobody talks about
Solana's RPC protocol includes a native WebSocket subscription called slotsUpdatesSubscribe. It fires on every slot event: first shred received, optimistic confirmation, root finalization, dead slots. Available on any standard RPC endpoint. No gRPC. No subscription plan.
The problem? It's raw. You get a slot number and an event type. No leader identity, no Jito flag, no context about what the slot means for your system.
A slot number without a leader is like knowing a race is starting but not knowing who's running.
So I built vigil — a Rust crate that takes the raw WebSocket stream and makes it useful.
What vigil adds
Three enrichment layers on top of the raw stream:
tokio::broadcast::Sender<SlotEvent>
Fig. 1 — enrichment layers vigil adds over the raw WebSocket stream
After enrichment, every FirstShredReceived event carries leader: Option<Pubkey> and is_jito_leader: Option<bool>. No RPC in the hot path.
The interesting part: local leader schedule
Solana's leader schedule is fully deterministic — every validator computes it identically. Agave uses ChaCha20 with a fixed seed to shuffle the validator set into a schedule for each epoch.
The algorithm isn't documented anywhere as a standalone spec. I read the agave source, extracted it, and reimplemented it:
O(1) lookup → schedule[slot − epoch_start_slot]
Fig. 2 — ChaCha20 seed construction and the resulting 4-slot consecutive schedule
The 4-slot consecutive chunk is what gives Solana its characteristic rhythm — each validator leads four slots in a row before the next one takes over.
The output is identical to what the agave validator produces. Verify it yourself: run the schedule for the current epoch, pick any slot, check who's actually producing blocks. It matches.
Lookup is O(1) once computed. The cache pre-computes the next epoch in the background so rotation at epoch boundaries is instantaneous.
Jito detection
Simpler than the schedule. One HTTP call on init, then a hash lookup per slot:
// One HTTP call on init — stored as HashSet<Pubkey>
let identities: HashSet<Pubkey> = kobe_response
.validators
.into_iter()
.filter(|v| v.running_jito)
.map(|v| v.identity_account.parse().unwrap())
.collect();
// In the hot path — single hash lookup per slot
jito_set.contains(leader_pubkey) // → true/falseEvery FirstShredReceived event gets is_jito_leader: Some(true) or Some(false). No allocation, no RPC.
Real numbers
Smoke test against Solana mainnet, 60 seconds:
Fig. 3 — key metrics from a 60-second mainnet smoke test
The ~400ms median is Solana's own slot cadence — vigil observes the chain's natural rhythm with no added latency from an intermediate service.
Using it
Five lines to get a working stream:
use vigil::SlotStreamBuilder;
let stream = SlotStreamBuilder::new()
.endpoint("wss://mainnet.helius-rpc.com/?api-key=...")
.rpc_endpoint("https://mainnet.helius-rpc.com/?api-key=...")
.build()
.await?;
while let Ok(event) = stream.recv().await {
match event {
SlotEvent::FirstShredReceived { slot, leader, is_jito_leader, .. } => {
// who's leading this slot, and are they on Jito
println!("{slot} — {leader:?} — jito: {is_jito_leader:?}");
}
SlotEvent::Dead { slot, .. } => println!("slot {slot} skipped"),
_ => {}
}
}Enable features in Cargo.toml:
[dependencies]
vigil = { git = "...", features = ["leader-schedule", "jito-detection"] }
# jito-detection requires leader-schedule — can't detect Jito without knowing
# the leader. Leave both off for pure slot timing with zero RPC dependencies.vigil vs Yellowstone vs both
| Mode | Subscription | First-shred | Leader | Jito | Failover |
|---|---|---|---|---|---|
YELLOWSTONE gRPC | Required | ~50ms early | ✓ | ✓ | — |
WEBSOCKET vigil | None | ~400ms slot cadence | ✓ | ✓ | — |
DUAL both | None for fallback | Best available | ✓ | ✓ | ✓ |
Fig. 4 — mode comparison. DUAL runs both simultaneously with automatic failover.
The honest tradeoff: Yellowstone gets you first-shred events earlier in the slot. If you're in a latency war where 50ms matters, pay for it.
If you're not — or you want resilience against gRPC outages — vigil is the answer. In dual mode, Yellowstone is primary and vigil runs silently in the background. When gRPC stalls, vigil takes over. No restart, no config change, no manual intervention.
In dual mode the dedup is a single AtomicU64— if the slot already advanced, the slower source's event is dropped.What's next
vigil 0.1 covers the core loop. On the roadmap:
- Validator reliability scoring — ValidatorStats accumulates skip rate and avg arrival time — queryable before a slot starts.
- Historical replay — from_slot() is wired. Replaying from the ring buffer works for recent slots; deeper history needs a persistence layer.
- Stake weight visibility — The schedule knows each validator's stake. Surface it in SlotEvent for callers who want to gate on threshold.
vigil is used in production inside Sinker as the slot source for --slot-source websocket and --slot-source dual.