Runtime Events
Observe transactions, slot ticks, account streaming, and runtime errors from inside a test. Rust exposes a channel; JS exposes a drain method.
The Surfnet runtime emits a stream of structured events covering transaction processing, slot ticks, account streaming, time travel, and errors. The SDK exposes this stream so tests can assert on runtime behavior without parsing logs.
Rust: Channel Receiver
Surfnet::events() returns a reference to the crossbeam_channel::Receiver<SimnetEvent> that the runtime publishes to. Drain it with try_iter() (non-blocking) or read individual events with recv() / recv_timeout().
use surfpool_sdk::{SimnetEvent, Surfnet};
let surfnet = Surfnet::start().await?;
// Drain whatever has accumulated so far.
for event in surfnet.events().try_iter() {
match event {
SimnetEvent::TransactionProcessed(_ts, metadata, error) => {
println!("tx {} -> {:?}", metadata.signature, metadata.logs);
if let Some(err) = error {
eprintln!(" failed: {err:?}");
}
}
SimnetEvent::ErrorLog(_ts, message) => {
eprintln!("runtime error: {message}");
}
other => {
println!("event: {other:?}");
}
}
}Receiver is shared
If you spawn a background task to consume events, do not also call try_iter from the main thread. Events will be split unpredictably between the two consumers.
JS: Drain Method
The JS bindings buffer events internally and expose them through drainEvents. Each call returns whatever has accumulated since the previous call, then clears the buffer.
import { Surfnet } from "@solana/surfpool";
const surfnet = Surfnet.start();
try {
// ... do test setup that triggers events ...
const events = surfnet.drainEvents();
for (const event of events) {
switch (event.kind) {
case "transactionProcessed":
console.log("tx", event.transactionSignature, event.logs);
break;
case "errorLog":
console.error("runtime error:", event.message);
break;
default:
console.log(event.kind, event.message ?? "");
}
}
} finally {
surfnet.stop();
}Unbounded buffer
If you never call drainEvents, the buffer grows for the life of the instance. That's fine for short tests, but call it periodically for long-running fixtures so memory doesn't balloon.
SimnetEventValue Shape (JS)
The JS event type is a flat object with a kind discriminator and optional fields. Not every field is set on every variant — destructure based on kind.
interface SimnetEventValue {
kind: string; // discriminator, e.g. "TransactionProcessed"
message?: string; // human-readable message
timestamp?: string; // ISO timestamp
// startup
initialTransactionCount?: number;
clock?: ClockValue;
// slot / clock
epochInfo?: EpochInfoValue;
clockCommand?: string;
slotIntervalMs?: number;
// accounts
accountPubkey?: string;
// transactions
transactionSignature?: string;
logs?: string[];
computeUnitsConsumed?: number;
fee?: number;
// errors
errorMessage?: string;
// misc
tag?: string;
profileKey?: string;
profileSlot?: number;
runbookId?: string;
runbookErrors?: string[];
}Event Kinds
Kind strings on the JS side are camelCase; the matching Rust variant uses PascalCase. Match on these exact strings — they're case-sensitive and stable across versions.
JS kind | Rust variant | Emitted when |
|---|---|---|
ready | SimnetEvent::Ready | The runtime finishes booting; carries initialTransactionCount. |
connected | SimnetEvent::Connected | Connected to the upstream remote RPC; carries message (the URL). |
aborted | SimnetEvent::Aborted | Startup was aborted; carries message (the reason). |
shutdown | SimnetEvent::Shutdown | A graceful shutdown begins. |
systemClockUpdated | SimnetEvent::SystemClockUpdated | The system clock advanced; carries clock. |
clockUpdate | SimnetEvent::ClockUpdate | A clock command ran (pause/resume/update interval); carries clockCommand and optionally slotIntervalMs. |
epochInfoUpdate | SimnetEvent::EpochInfoUpdate | A new epoch info snapshot is available; carries epochInfo. |
blockHashExpired | SimnetEvent::BlockHashExpired | A blockhash that was in use has expired. |
transactionReceived | SimnetEvent::TransactionReceived | A transaction was queued; carries timestamp, transactionSignature. |
transactionProcessed | SimnetEvent::TransactionProcessed | A transaction was executed; carries timestamp, transactionSignature, logs, computeUnitsConsumed, fee, and errorMessage on failure. |
accountUpdate | SimnetEvent::AccountUpdate | An account changed — covers streamed accounts and cheatcode-driven mutations; carries timestamp and accountPubkey. |
infoLog / warnLog / errorLog / debugLog | SimnetEvent::InfoLog / WarnLog / ErrorLog / DebugLog | The runtime emitted a log line; carries timestamp and message. |
pluginLoaded | SimnetEvent::PluginLoaded | A Geyser plugin loaded; carries message (the plugin name). |
taggedProfile | SimnetEvent::TaggedProfile | A profiler result was tagged; carries tag, profileKey, profileSlot, logs, computeUnitsConsumed. |
runbookStarted / runbookCompleted | SimnetEvent::RunbookStarted / RunbookCompleted | A runbook started or finished; carries runbookId and (on completion) optional runbookErrors. |
Assertion Patterns
Assert A Transaction Landed
import { Surfnet } from "@solana/surfpool";
const surfnet = Surfnet.start();
try {
const sig = await sendTransaction(surfnet.rpcUrl /* ... */);
// ... wait for confirmation via RPC ...
const landed = surfnet
.drainEvents()
.some(e => e.kind === "transactionProcessed" && e.transactionSignature === sig);
console.assert(landed, "expected transaction to land");
} finally {
surfnet.stop();
}Assert No Runtime Errors Occurred
use surfpool_sdk::{SimnetEvent, Surfnet};
let mut surfnet = Surfnet::start().await?;
// ... run the test ...
let errors: Vec<_> = surfnet
.events()
.try_iter()
.filter(|e| matches!(e, SimnetEvent::ErrorLog(_, _)))
.collect();
assert!(errors.is_empty(), "runtime errors: {errors:?}");
surfnet.stop()?;Why Events Beat Log Parsing
Prefer the event stream over stdout
The runtime prints structured events through tracing, which can be tempting to capture and parse from stdout. Don't. The structured event stream:
- Survives log-level changes — events are emitted regardless of
RUST_LOG. - Comes with typed payloads — no string parsing, no format drift between versions.
- Has stable kinds — log lines are formatted for humans and change freely; event kinds are part of the SDK contract.