Skip to content

clients/go: the watch surface - #514

Open
bkeroack wants to merge 1 commit into
feat/go-sdk-corefrom
feat/go-sdk-watch
Open

clients/go: the watch surface#514
bkeroack wants to merge 1 commit into
feat/go-sdk-corefrom
feat/go-sdk-watch

Conversation

@bkeroack

@bkeroack bkeroack commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

PR 3 of the Go SDK stack. Stacked on #513 (which is stacked on #512) — merge bottom-up.

What this lands

The bidirectional Watch stream and every watch kind on it: scripts (with per-script min-value floors), outpoints, transaction lifecycles, depth alarms, descriptors, privacy-preserving script prefixes, and BIP 352 scan-key targets — plus SetCategories, SetWatchOptions, SetCursor, Rescan, and the atomic SetWatchSet replace.

WatchHandle is safe for concurrent use (a gRPC stream permits one Send at a time, so sends serialize behind a context-aware lock) and maps a torn-down stream to the control-closed error class — the signal the resilience layer keys re-registration off.

Two details the wire makes easy to get wrong

Both pinned by tests:

  1. Lifecycle watches and depth alarms are one message, dispatched on min_depths. An empty vector selects the lifecycle primitive, so an all-invalid depth-alarm call must send nothing rather than an empty min_depths — which would silently register a different, quota-charging watch than the caller asked for.
  2. min_values is either empty or exactly parallel to scripthashes, with 0 standing in for unfloored entries.

WatchSet is the declarative snapshot — full desired membership per kind, no deltas — rendered in deterministic sorted order rather than Go's randomized map order, because the differential parity harness (PR 7) diffs it against the Rust mirror, which renders from ordered maps.

Silent payments: no btcec

This is the SDK's only crypto. Depending on btcec would put two third-party crypto modules in every consumer's graph (btcec/v2 wraps Decred's dcrec/secp256k1/v4) and force MVS bumps on btcd/lnd-ecosystem applications — to validate a public key and multiply the base point once per target. Instead, in-tree math/big: a double-and-add-always ladder for b_scan*G, and Euler's criterion for the compressed-point check (no decompression, no square root).

The scope note is explicit in the source: math/big is not constant-time, and that is acceptable only because b_scan is a watch credential the client discloses to the node by design — it confers no spend authority. Do not reuse the code for a spending key. The scan secret is redacted by the type's String method (a stray %v cannot log it) and scrubbed by Zero.

Testing

  • Unit: an in-process gRPC server records what each helper puts on the wire — the control path has no per-message ack, so being the server is the only way to assert message shape. Covers every kind, the no-op contract, prefix width validation, batch-level rejection, and cancellation.
  • Crypto: known-vector (1G..5G) and curve-equation tests for the scalar multiplication; rejection of non-points including the x=1 near-miss (whose x³+7 is a residue — the sort of thing that makes a sloppy test vacuous); the label cap; secret redaction.
  • E2E: every watch kind against a live node — descriptor attribution (branch and derivation index), the min-value floor actually suppressing a match, raw_tx byte-matching getrawtransaction, the atomic replace's in-band accept/reject with its counts, in-band cursor acks followed by replay, and a bounded rescan clamped to the tip and terminated.

Scan-key registration is verified through SetWatchSet — the one control message with a deterministic ack — since constructing a real BIP 352 payment is out of scope for a suite that deliberately carries no Bitcoin library.

Negative verification: every new assertion was perturbed once and observed to fail before landing (floor removed, opt-in skipped, lifecycle substituted for a depth alarm, wrong descriptor key).

🤖 Generated with Claude Code

https://claude.ai/code/session_01GJqZbJtTUo7K7G9wJcrvHv

The bidirectional Watch stream and every watch kind on it: scripts (with
per-script min-value floors), outpoints, transaction lifecycles, depth
alarms, descriptors, privacy-preserving script prefixes, and BIP 352 scan-key
targets — plus set_categories, set_watch_options, set_cursor, rescan, and the
atomic set_watch_set replace.

`WatchHandle` is safe for concurrent use (a gRPC stream permits one Send at a
time, so sends are serialized behind a context-aware lock) and maps a
torn-down stream to the control-closed error class, which is the signal the
resilience layer keys its re-registration off.

Two details the wire makes easy to get wrong, and that the tests pin:

- Lifecycle watches and depth alarms are ONE message dispatched on
  `min_depths`. An empty vector selects the lifecycle primitive, so an
  all-invalid depth-alarm call must send NOTHING rather than an empty
  `min_depths` — which would silently register a different, quota-charging
  watch than the caller asked for.
- `min_values` is either empty or exactly parallel to `scripthashes`, with 0
  standing in for unfloored entries.

`WatchSet` is the declarative snapshot: full desired membership per kind, no
deltas, rendered in deterministic (sorted) order rather than Go's randomized
map order — the differential parity harness diffs this against the Rust
mirror, which renders from ordered maps.

Silent payments carry the SDK's only crypto. Rather than depend on btcec —
which would put two third-party crypto modules in every consumer's graph and
force MVS bumps on btcd/lnd-ecosystem applications — the scan-key identity
b_scan*G and the compressed-point check are in-tree math/big: a
double-and-add-always ladder and Euler's criterion, pinned against the
published 1G..5G vectors. The scope note is explicit in the source: math/big
is not constant-time, and this is acceptable only because b_scan is a watch
credential the client discloses to the node by design. The scan secret is
redacted by the type's String method (so a stray %v cannot log it) and
scrubbed by Zero.

Testing:

- Unit: an in-process gRPC server records what each helper puts on the wire,
  because the control path has no per-message ack — being the server is the
  only way to assert the message shape. Covers every kind, the no-op contract,
  prefix width validation, batch-level rejection, and cancellation.
- Crypto: known-vector and curve-equation tests for the scalar multiplication,
  rejection of non-points (including the x=1 near-miss, whose x^3+7 IS a
  residue), the label cap, and secret redaction.
- E2E: every watch kind against a live node, including descriptor attribution
  (branch and derivation index), the min-value floor actually suppressing,
  raw_tx byte-matching getrawtransaction, the atomic replace's in-band
  accept/reject with its counts, in-band cursor acks with replay, and a
  bounded rescan clamped to the tip and terminated.

Scan-key registration is verified through SetWatchSet — the one control
message with a deterministic ack — since constructing a real BIP 352 payment
is out of scope for a suite that deliberately carries no Bitcoin library.

Every new assertion was perturbed once and observed to fail before landing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJqZbJtTUo7K7G9wJcrvHv
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant