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

Go SDK (satdevents)

satdevents is the Go client for the Streaming Consumption API. It is a full-parity sibling of the Rust SDK — same surface, same guarantees, expressed in Go idiom rather than transliterated from Rust.

It lives in the satd repository at clients/go/, as an independently versioned Go module:

go get github.com/epochbtc/satd/clients/go
import satdevents "github.com/epochbtc/satd/clients/go"

Only Go is needed — no Rust toolchain, no protoc. The protobuf bindings are committed, and CI regenerates them on every PR and fails on a diff, so they cannot drift from the .proto.

Note. Getting Started: Consuming Events walks the whole sequence — connect, firehose, durable watch, prefix privacy — one step at a time. It is written against the Rust SDK, but the sequence and the concepts are identical here; the mapping table below is the translation key.

Module layout & dependencies

The published SDK's dependency graph is gRPC and protobuf, and nothing else. That is a deliberate constraint, not an accident of being small: a client library that drags a Bitcoin stack or a secp256k1 implementation into every consumer forces version bumps on applications that already have their own. So:

  • Script and txid parameters are []byte and [32]byte with helpers, never library-specific types. Bring whatever Bitcoin library you already use, or none.
  • Silent-payment scan-key validation is an in-tree on-curve check rather than a curve dependency.
  • Code generators, linters, the E2E suite, and the examples each live in their own nested module, so what they need never reaches a consumer.
PathWhat
clients/go/the satdevents package
clients/go/eventspb/generated satd.events.v1 bindings, exported for cases a typed helper does not cover
clients/go/examples/thirteen runnable programs (own module)
clients/go/e2e/live-node end-to-end tests, build tag e2e (own module)
clients/go/tools/pinned generators and linters (own module)

The go directive tracks one release behind the latest stable Go, covering Go's two-release support window.

Rust → Go mapping

Where the two languages' idioms diverge, the Go shape wins. Nothing is dropped; this table is the translation key.

Rust SDKGo SDKWhy
StreamClient::builder(..).bearer_token(..).connect()Dial(ctx, target, WithBearerToken(..))functional options are Go's builder
enum Eventsealed Event interface + type switcha sealed interface is Go's closed union; the marker method is unexported, so the implementation set is fixed
Event::Unknown*UnknownEventsame forward-compatibility contract
#[non_exhaustive] enumsKnown() bool on each enum typeGo has no exhaustiveness check, so the "is this value from a newer node?" question is a method
Option<T> fieldspointer fields (*uint64)absent stays distinguishable from zero
cancel-safe futuresctx on every blocking callsee cancel safety
AutoClose::{Never, AtDepth(n)}AutoCloseNever, AutoCloseAtDepth(n)a uint32 newtype, zero meaning never
StreamError::is_retryable()Retryable(err), errors.Is(err, ErrX)Go error idiom, one sentinel per class

Connecting

client, err := satdevents.Dial(ctx, "node:50051",
    satdevents.WithTLSCAPem(caPEM),
    satdevents.WithBearerToken(token), // sent as `authorization: Bearer …`
)
defer client.Close()

Dial accepts host:port, optionally with an http:// or https:// scheme (stripped, for symmetry with the Rust client and the -eventsgrpcbind documentation). It does not block on the connection coming up — gRPC connects lazily, and the first Subscribe or Watch surfaces a connection failure.

Keepalive matching the server (30s/20s) is on by default; WithoutKeepalive and WithKeepalive override it.

The bearer token is honored only when the server enforces auth (-eventsgrpcauth).

A token requires an encrypted connection

Dial returns a KindInsecureCredential error (errors.Is(err, ErrInsecureCredential)) for a bearer token combined with an insecure transport, rather than putting the credential on the wire in the clear. Anyone who captures the token can subscribe to the firehose and register watches; on a Tier 2 scan-key watch the same stream also carries BIP 352 scan secrets, which disclose which outputs belong to the receiver.

gRPC-Go's own safety net does not cover this. It refuses to attach a PerRPCCredentials whose RequireTransportSecurity() reports true to an insecure channel, but this SDK attaches the token with metadata.AppendToOutgoingContext, which that check never sees.

Any WithTLS* option satisfies the requirement, as does an https:// target. For loopback and test harnesses, WithInsecureBearerToken(token) is the same thing with the risk accepted explicitly. It is a separate option rather than a flag so the unsafe choice has to be named at the call site, and so that switching back to WithBearerToken cannot silently leave the waiver behind.

TLS / mTLS

// Pin a satd node's own (self-signed) CA — the usual case.
client, err := satdevents.Dial(ctx, "node.example:50051",
    satdevents.WithTLSCAPem(caPEM),
    satdevents.WithBearerToken(token),
)

// Publicly trusted certificate: the system roots.
client, err := satdevents.Dial(ctx, "node.example:50051", satdevents.WithTLS())

// Mutual TLS, against a node with `eventsgrpcmtls=1`.
client, err := satdevents.Dial(ctx, "node.example:50051",
    satdevents.WithTLSCAPem(caPEM),
    satdevents.WithMTLS(certPEM, keyPEM),
)

WithTLSServerName overrides the verified name, for dialing by IP or through a tunnel.

Requesting TLS against an explicit http:// target is refused, not silently downgraded. That combination can only be a mistake, and connecting in cleartext would leak the token and the whole event stream while the caller believed the link was encrypted.

Firehose: Subscribe

stream, err := client.Subscribe(ctx, satdevents.SubscribeOptions{
    Categories: satdevents.CategoryMempool | satdevents.CategoryChain,
})

for {
    ev, err := stream.Recv()
    if err != nil {
        if errors.Is(err, io.EOF) { break }
        return err
    }
    switch e := ev.(type) {
    case *satdevents.BlockConnected:
        log.Printf("block %d %s", e.Height, satdevents.DisplayHex(e.Hash))
    case *satdevents.MempoolEnter:
        log.Printf("tx %s fee=%d", satdevents.DisplayHex(e.Txid), e.Fee)
    }
}

CategoryAll (zero, the default) means every category except CategoryTweaks and CategoryStatus, which are explicit-request only. That exclusion is what keeps a client written against an older node from suddenly receiving a body it has no parser for after the node is upgraded — so if you want status or tweak events, ask for them by name.

stream.Cursor() returns the latest durable resume position. Persist it to resume later.

Durable firehose: ResilientSubscribe

sub := client.ResilientSubscribe(ctx,
    satdevents.SubscribeOptions{Categories: satdevents.CategoryChain},
    satdevents.ResilientConfig{
        CursorStore: satdevents.NewFileCursorStore("/var/lib/app/satd.cursor"),
    })
defer sub.Close()

for {
    ev, err := sub.Next(ctx)   // reconnects and replays underneath
    if err != nil { return err }
    // ...
}

Next returns an error only on a permanent failure (bad endpoint or token, PERMISSION_DENIED, a failed cursor write), when retries are exhausted, or when ctx is done. An ordinary disconnect is not an error — it is handled.

The zero ResilientConfig is valid: default backoff (500 ms doubling to a 30 s ceiling, retrying forever), auto-resume on lag, and no persistence. Set a CursorStore; without one, a restart resumes forward-only and silently skips everything that happened while the process was down.

CursorStore is an interface — FileCursorStore is provided, and a database or key-value implementation is a two-method type.

Watches: Watch

handle, stream, err := client.Watch(ctx)
defer handle.Close()

err = handle.AddScripts(ctx, []satdevents.ScriptWatch{
    {Scripthash: satdevents.ScripthashOf(scriptPubKey)},
})
err = handle.AddOutpoints(ctx, []satdevents.OutpointRef{{Txid: txid, Vout: 0}})
err = handle.AddTxLifecycle(ctx, [][32]byte{txid}, satdevents.AutoCloseAtDepth(6))
err = handle.AddDepthAlarms(ctx, [][32]byte{txid}, []uint32{1, 3})
err = handle.AddDescriptor(ctx, descriptor, 20 /*gap*/, 0 /*start*/)
err = handle.AddSilentPayments(ctx, []satdevents.SilentPaymentTarget{target})
err = handle.AddScriptPrefixes(ctx, watcher.Prefixes(16))

The WatchHandle is safe for concurrent use — sends are serialized internally, since a gRPC stream permits only one send at a time. Every Add has a matching Remove, and SendControl is the escape hatch for anything the typed helpers do not wrap yet.

The watch-set is per-connection. The server holds no principal-keyed state, so when the stream drops, the watch-set and its quota leases go with it and a fresh stream starts blank. That is why the durable variant below exists.

Some registrations are outcome-in-band rather than outcome-on-return: SetCursor and SetWatchSet return nil once the request reaches the control stream, and the actual outcome arrives on the event stream as exactly one CursorAccepted/CursorRejected or WatchSetReplaced/WatchSetRejected. Drive catch-up off those events, not off the return value.

Durable watch: ResilientWatch

watch := client.ResilientWatch(ctx, satdevents.ResilientWatchConfig{
    CursorStore: satdevents.NewFileCursorStore("/var/lib/app/watch.cursor"),
    WatchSetLoader: func(ctx context.Context, set *satdevents.WatchSet) error {
        rows, err := db.WatchedScripts(ctx)   // your durable truth
        if err != nil { return err }
        for _, r := range rows {
            set.AddScripts(satdevents.ScriptWatch{Scripthash: r.Scripthash})
        }
        return nil
    },
})
defer watch.Close()

for {
    ev, err := watch.Next(ctx)
    if err != nil { return err }
    // ...
}

Without a loader, ResilientWatch re-registers its in-memory mirror of the Add/Remove calls made through it. That is correct for a watch-set built once at startup — but the mirror is empty after a process restart, and goes stale if the truth changes while the stream is down.

With a loader, the mirror becomes a cache of your truth:

  • the loader runs after every successful (re)connect, before any event is pumped, so the first events after a reconnect already land on a fully populated subscription;
  • the loaded set replaces the mirror, so the next reconnect re-derives from the loader rather than from accumulated in-process edits;
  • a loader error is transient — backed off and retried on the next connect, not surfaced. A momentary outage of your database must not kill a consumer whose contract is at-least-once. A permanently broken loader is indistinguishable from a transient one and retries forever; set Backoff.MaxRetries if you need a terminal error instead.

Reload(ctx) realigns a live stream with the loader's truth on demand, and returns a ReloadSummary (Added, Removed, Unchanged, Applied). Use it after adding an address, so a later reconnect's loader agrees with what this process registered.

Cancel safety

ResilientSubscription.Next and ResilientWatch.Next take a ctx, and cancelling it never consumes an event. (Stream.Recv on the non-resilient surfaces takes no ctx; it is unblocked by cancelling the context passed to Subscribe/Watch, and that surfaces as a gRPC CANCELED status rather than context.Canceled — check ctx.Err(), not errors.Is(err, context.Canceled).)

The reconnect state machine runs on its own goroutine and hands events over an unbuffered channel, so it is never more than one event ahead of the caller. Returning on ctx.Done() therefore cannot drop an event in flight: the handoff only completes when the caller actually receives. Cancel Next freely — in a select against a command channel, or under a per-call deadline — and call it again.

This is the Go equivalent of the Rust SDK's explicit cancel-safe state machine. The language does the work here, because a gRPC Recv cannot be abandoned mid-flight without losing the message.

Prefix watches (privacy-preserving)

Register a coarse bits-wide bucket of sha256(scriptPubKey); the node learns only the bucket and delivers everything in it, and the client filters locally.

watcher := satdevents.NewPrefixWatcherWithScripts(script1, script2)
err = handle.AddScriptPrefixes(ctx, watcher.Prefixes(16))

// on each *satdevents.PrefixMatched:
hits, err := watcher.Filter(m)
for _, f := range hits.Funding { /* f.Vout, f.Value */ }
for _, s := range hits.Spending { /* s.Outpoint */ }
if hits.HasUnresolved() {
    // The server did not retain these prevout scripts. They are UNKNOWN, not
    // misses — resolve the outpoints yourself before concluding otherwise.
}

Distinct scripts sharing a bucket collapse into a single registration, which is the point: the node cannot tell how many of your scripts a bucket covers. Bits below the bucket width are masked before deduplicating, so a coarse bucket really does collapse — at 1 bit, any number of scripts registers at most two buckets. MaxPrefixBits (32) is where the server's mask saturates; a wider registration cannot be more selective and is rejected client-side rather than silently dropped by the server.

A server may lower the ceiling further via streamprefixmaxbits. That bound is not advertised over the wire, so an over-precise (but ≤ 32) prefix can still be dropped server-side with no client-side signal.

Errors

Every SDK error is an *Error carrying a Kind, and each kind has a sentinel that errors.Is matches:

if errors.Is(err, satdevents.ErrPermissionDenied) { /* fix the token's caps */ }
if satdevents.Retryable(err) { /* back off and retry */ }

var serr *satdevents.Error
if errors.As(err, &serr) && serr.Status != nil {
    log.Print(serr.Status.Code(), serr.Status.Message())  // server detail survives
}

A Lagged notice is deliberately not an error: it is a normal, recoverable event carrying a resume cursor. ErrUnauthenticated is reported non-retryable on purpose — a blind retry with the same token will not help.

Delivery guarantees

Cursors commit on poll: a delivered event's cursor is persisted only once the caller comes back for the following event, which is an implicit ack. The store therefore never advances past an event you have not received, so a crash mid-processing replays that event. This is at-least-once, not at-most-once; dedup on your side, keyed by what you process, if you need exactly-once.

The exception is ReplayGap. It means the persisted cursor fell outside the server's replay window: the blocks it names were never delivered and never will be. Full-resync that range from another source — logging it and moving on loses transactions.

Stability & versioning

The SDK is versioned with the node: clients/go/vX.Y.Z is cut at the node's vX.Y.Z, even when no Go code changed, and satdevents.Version names it. A unit test pins Version to the workspace version, so the two cannot drift. Pick an SDK whose minor version is at or below your node's. Newer nodes only add: an event kind this build predates decodes to *UnknownEvent, and an unknown enum value is preserved with Known() == false, so a Status from a newer node still routes correctly on Severity and Message.

Each time Subscribe or Watch opens a stream, the SDK reads the version the node advertises in its satd-version response header and compares it with its own:

NodeResult
Same or newerOpens silently.
One minor version behindOpens and logs a warning through log/slog (WithLogger, default slog.Default()), once per client per node version, with attributes node_version and sdk_version.
Two or more minor versions, or a major version, behindRefused with ErrNodeTooOld.
Different event schemaRefused with ErrSchemaMismatch.

A node older than 0.6.0 sends no header and counts as 0.5. The warning means "upgrade the node": features added after the node's release are unavailable, and request fields it does not recognise are ignored. For a rolling upgrade where clients go first, WithAllowOldNode() turns ErrNodeTooOld into the same warning. Nothing bypasses a schema mismatch. Both errors are non-retryable, so the resilient layers return them from Next instead of reconnecting. Client.NodeVersion() returns what the node last advertised. The full rule is in STABILITY_POLICY.md.

Watch waits for the node's response headers before returning, as Subscribe already did. satd sends them as soon as the stream is set up, so a quiet watch-set does not hold it up.

The first tag under this scheme is clients/go/v0.6.0; the previous tag was clients/go/v0.1.0, shipped with node 0.5.0. Because there is no go.mod at the repository root, the node's own vX.Y.Z tags do not collide, and the module proxy serves consumers a zip of the module subtree only — importing this SDK does not pull the Rust tree.

The module stays within v0/v1: Go's semantic import versioning makes v2+ a breaking import-path change (.../go/v2), so the bar for declaring v1 is "we can live with this API".

How parity is verified

The Go SDK is not a best-effort port kept in sync by hand. Every satd PR runs:

  1. the Go unit tests, including a protobuf-reflection test that fails when a new event variant is added to the .proto without a Go decoder;
  2. a build of all thirteen examples, so a renamed method cannot rot in a file whose whole job is to be copied;
  3. the Go E2E suite against the same freshly built satd binary the Rust E2E suite uses;
  4. a differential parity harness — one node, two clients, byte-identical watch spec. The Go SDK and the Rust satd-events-client each render every event they receive to canonical JSON, and the two dumps are diffed line by line. A field one SDK decodes differently, or a variant one of them cannot decode at all, fails the PR.

The harness normalizes exactly two things, and no more: it drops the publisher's per-process instance_id (which differs per connection by definition), and it sorts by cursor rather than arrival order (two connections are served by independent tasks, so interleaving is server scheduling, not a parity property). It therefore proves the two SDKs see the same events with the same field values — not that they see them in the same order.

Examples

Thirteen runnable programs live in clients/go/examples/, one per usage shape:

cd clients/go/examples
go run ./firehose_tail   -endpoint 127.0.0.1:50051
go run ./resilient_tail  -endpoint 127.0.0.1:50051 -cursor /tmp/satd.cursor
go run ./health_watch    -endpoint 127.0.0.1:50051
go run ./tls_tail        -endpoint node.example:50051 -ca ./node-ca.pem
ExampleWhat it shows
deposit_notifythe smallest useful integration: tell me when this address is paid
firehose_tailthe minimum: connect, subscribe, print
resilient_tailreconnect + file-backed cursor; the shape to copy
watch_outpointswatch outpoints for their spend
descriptor_walletwatch by descriptor, advance the gap limit
lifecycle_alarmsseen → confirmed → replaced, with depth alarms
resilient_watchwatch-set rebuilt from a durable truth on every reconnect
health_watchnode-health alerting, and why silence needs a deadline
prefix_privacycoarse buckets registered, real filtering done locally
sp_walletBIP 352 scan-key watch (Tier 2)
sp_light_scanBIP 352 client-side scan (Tier 1), scan key never leaves the device; durable cold-sync cursor
tls_tailTLS with a pinned self-signed node CA
mtls_tailmutual TLS against eventsgrpcmtls=1

health_watch is the alerting shape worth reading in full: it subscribes to heartbeats and enforces a deadline on them, because a wedged publisher on a live connection produces silence, and silence otherwise reads as "nothing is wrong". It also documents what a status stream structurally cannot tell you — status events are not replayable, so getwarnings over JSON-RPC remains the authoritative answer to "what is wrong right now".