Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Getting Started: Consuming Events

This chapter is the guided path from nothing to a durable, reconnect-surviving consumer of satd's Streaming Consumption API. It is deliberately narrative — one thread of a story, one runnable step at a time. Each step names the concept and then links to the two reference chapters that own the detail:

Reach for those when you want the full signature or the edge cases. Here we just build the thing.

We use the Rust SDK throughout. Nothing here is Rust-specific at the protocol level: WebSocket/SSE consumers follow the same sequence over the JSON rendering (see the Transports section).

Prerequisites. A running satd node with the events gRPC listener enabled (eventsgrpcbind = 127.0.0.1:50051), and a project that depends on satd-events-client. For a loopback node no token is needed; a remote node needs bearer auth or mTLS — Step 8 returns to that.

Step 1 — Choose a transport

One schema, three transports: gRPC (the primary programmatic surface), JSON-over-WebSocket (GET /ws, with a control channel), and SSE (GET /sse, a read-only browser/curl firehose). If you are writing a service, use gRPC — it is the only transport with the full bidirectional watch-set control channel. Details and the port model are in the Transports section. The rest of this tutorial is gRPC.

Step 2 — Connect

use satd_events_client::{StreamClient, SubscribeOptions, Categories, Event};

let mut client = StreamClient::builder("http://127.0.0.1:50051")
    .keepalive_default()
    .connect()
    .await?;

That's a plaintext loopback connection — fine for a node on the same host. TLS, mTLS, and bearer tokens are one builder call each; see Connecting and Step 8.

Step 3 — Tail the firehose

Before watching anything specific, prove the pipe works by tailing the raw event firehose — every block and mempool transition the node sees:

let mut events = client.subscribe(SubscribeOptions {
    categories: Categories::MEMPOOL | Categories::CHAIN,
    from_cursor: None,   // forward-only for now; Step 5 makes it durable
    since_seq:   None,
}).await?;

while let Some(event) = events.message().await? {
    match event {
        Event::BlockConnected { height, .. } => println!("block {height}"),
        Event::MempoolEnter { txid, .. }      => println!("mempool {txid}"),
        _ => {}
    }
}

Event is a flat enum, so you match rather than unwrap nested options. Full firehose semantics — categories, the captured Cursor, lag notices — are under Firehose — subscribe.

Step 4 — Watch something and react

The firehose is the wrong tool for "tell me about my scripts" — that's a watch-set. Open the bidirectional watch stream, register interest, and react to matches:

let (watch, mut events) = client.watch().await?;

// A direct script watch, with an optional per-script value floor (sat).
watch.add_scripts([(scripthash, Some(100_000))]).await?;

// Or a whole wallet from its exported descriptor — the server expands the
// gap-limit window and derives the scripts for you (keyless: public-key-only).
watch.add_descriptor(descriptor, /*gap*/ 20, /*start*/ 0).await?;

while let Some(event) = events.message().await? {
    if let Event::ScriptMatched { txid, descriptors, .. } = event {
        // `descriptors` attributes a descriptor-derived hit back to its
        // descriptor + exact (branch, derivation_index) — empty for a direct
        // watch — so a multi-wallet consumer routes the hit with no reverse index.
        println!("hit {txid} ({} descriptor attributions)", descriptors.len());
    }
}

Outpoint, txid-lifecycle, and confirmation-depth watches are the same shape — every kind, and the sharp edges the typed helpers smooth over, are under Watches — watch.

Step 5 — Make it survive a reconnect

The watch above loses its watch-set and its place in the stream the moment the connection drops. resilient_watch fixes both: it re-registers the watch-set on every reconnect and resumes from a persisted cursor, so a network blip or a process restart is invisible to your logic.

use satd_events_client::{ResilientWatchConfig, FileCursorStore, Event, AutoClose};
use std::sync::Arc;

let config = ResilientWatchConfig::new()
    .cursor_store(Arc::new(FileCursorStore::new("/var/lib/app/watch.cursor")));
let mut watch = client.resilient_watch(config);

// Registered once; replayed automatically across every reconnect.
watch.add_scripts([(scripthash, None)]).await?;
watch.add_tx_lifecycle([txid], AutoClose::AtDepth(6)).await?;

loop {
    match watch.next().await? {
        Event::ScriptMatched { txid, .. } => { /* your logic */ }
        _ => {}
    }
}

Kill the node's listener and bring it back: the wrapper reconnects with backoff, re-registers the set, and re-anchors the cursor — deterministically, off the in-band CursorAccepted/CursorRejected result rather than hope. This is the right default for any long-lived consumer. See Durable watch — resilient_watch.

Step 6 — Bind the watch-set to your source of truth

The mirror resilient_watch keeps is authoritative only if you build the set once and never change it. Real consumers have a durable source of truth — a DB table of watched addresses — that changes while the process runs. Give the wrapper a watch_set_loader and it rebuilds the canonical set from that truth on every reconnect (and on a fresh start rehydrates from truth, not from an empty mirror):

let config = ResilientWatchConfig::new()
    .cursor_store(Arc::new(FileCursorStore::new("/var/lib/app/watch.cursor")))
    .watch_set_loader({
        let db = db.clone();
        move |builder| {
            let db = db.clone();
            async move {
                for row in db.load_watched_scripts().await? {
                    builder.add_scripts([(row.scripthash, row.min_value)]);
                }
                Ok(())
            }
        }
    });

When the truth changes while the stream is up — a bulk import, an operator "make the wire match truth now" — call watch.reload().await?. It re-runs the loader and pushes the whole desired set as a single atomic SetWatchSet, which the server reconciles by effective coverage under its lock and answers deterministically (WatchSetReplaced or WatchSetRejected { reason, .. }QuotaExceeded, CapExceeded, or Malformed). No client-computed delta can strand coverage. Full semantics: the loader and reload() subsections of Durable watch — resilient_watch.

Step 7 — Watch privately with a script prefix

Every step so far tells the node exactly which scripts you care about. For a custodian, an exchange, or a privacy-sensitive wallet, that interest set is itself sensitive — the node operator learns precisely whom you watch. A prefix watch breaks that link.

You register only a coarse bits-bit prefix of sha256(scriptPubKey). The server delivers every transaction that falls in that 2^-bits bucket — so it learns only the bucket, never your exact script — and you filter the decoys out locally. PrefixWatcher (the bitcoin feature) computes the buckets to register and does the local filtering:

use satd_events_client::{PrefixWatcher, Event};

let mut watcher = PrefixWatcher::new();
watcher.watch_script(&my_script_pubkey);          // add each real script locally

let (watch, mut events) = client.watch().await?;
watch.add_script_prefixes(watcher.prefixes(16)).await?;   // register 16-bit buckets

while let Some(event) = events.message().await? {
    if let Event::PrefixMatched(m) = event {
        let hits = watcher.filter(&m)?;           // recomputes sha256(spk), drops decoys
        for f in &hits.funding  { /* a genuine funding match */ }
        for s in &hits.spending { /* a genuine spend match  */ }
        if hits.has_unresolved() {
            // A spend-side prevout the server didn't retain (mempool below the
            // `full` tier). Resolve the outpoint yourself before concluding
            // non-match — never treat "absent" as "not mine".
        }
    }
}

The privacy/bandwidth trade-off is the bits knob: fewer bits means a larger bucket, more decoy traffic, and a weaker link between you and any one script. filter deliberately never issues a precise follow-up fetch — that would re-leak the interest the bucket exists to hide. Retention tiers for the spend side are governed by streamprevoutmeta; both the tiers and the full mechanism are in Prefix watches and the Streaming API chapter.

Step 8 — Going remote safely

Everything above assumed a loopback node. A remote bind must be encrypted and authenticated — the bearer token and the entire event stream travel in the clear over plaintext http://. Add TLS (public-CA or a pinned self-signed CA) and a token, or mutual TLS, with one builder call each:

let mut client = StreamClient::builder("https://node.example:50051")
    .tls()                       // or .tls_ca_pem(std::fs::read("node-ca.pem")?)
    .bearer_token(token)
    .keepalive_default()
    .connect()
    .await?;

The node-side config (eventsgrpctlscert, eventsgrpcmtls, eventsgrpcallowremote) is in the Transport encryption section; the client-side builder options, including mTLS client identity, are under TLS / mTLS.

Where to next

You now have the whole arc: connect → firehose → watch-set → durable → truth-bound → private → remote. From here, follow the reference chapters for the detail this tutorial deferred:

  • Quotas and error handling — watch quota, rate limits, and which StreamErrors are retryable: Errors and the Authentication & quotas section.
  • Cursors and replay — exact confirmed-side replay vs. best-effort mempool replay, and the replay-truncation ReplayGap: Cursors & replay.
  • Runnable examplesfirehose_tail, resilient_tail, watch_outpoints, descriptor_wallet, lifecycle_alarms, prefix_privacy, tls_tail, mtls_tail, in satd-events-client/examples/.