Skip to content

Go SDK 6/8: prefix privacy - #517

Open
bkeroack wants to merge 2 commits into
feat/go-sdk-resilient-watchfrom
feat/go-sdk-prefix
Open

Go SDK 6/8: prefix privacy#517
bkeroack wants to merge 2 commits into
feat/go-sdk-resilient-watchfrom
feat/go-sdk-prefix

Conversation

@bkeroack

@bkeroack bkeroack commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Sixth of eight PRs implementing the Go SDK per SATD_GO_SDK_PLAN.md.

Stack — merge bottom-up. Base is #516 (feat/go-sdk-resilient-watch), which is itself based on #515.

What this adds

PrefixWatcher, the local re-filter that makes prefix watching usable, plus the minimal transaction decoder it needs.

A prefix watch registers a bits-wide prefix of sha256(scriptPubKey); the node delivers every transaction in that 2^-bits bucket, so it learns the bucket and never the script. The consumer then has to filter the decoys out locally — any precise follow-up fetch would re-leak the exact script the bucket was hiding.

PrefixWatcher holds the real scriptPubKeys and, per delivery, recomputes sha256(scriptPubKey) over every output and every retained spent prevout, returning only genuine hits.

Prevouts whose script the node did not retain (a mempool spend below the full retention tier) cannot be settled locally. Those surface as PrefixHits.Unresolved rather than being folded into either answer — reporting them as non-matches would silently drop real spends. HasUnresolved() exists so callers cannot conclude "no match" by accident.

Two deliberate divergences from a naive port

1. Prefixes are masked below their declared width. The wire carries whole bytes, so a 12-bit bucket ships 2 bytes, and the server keys on (bits, top32 & prefix_mask(bits)) — the top 12 bits only. Shipping the low 4 bits unmasked hands the node more scripthash than the declared bucket width and narrows the anonymity set for nothing in return. Masking costs nothing (identical bucket key server-side) and is what makes narrow buckets actually collapse: at 1 bit, any number of scripts registers at most two buckets instead of one per distinct leading byte.

⚠️ This is a divergence in wire output from the Rust SDK (satd-events-client), which sends the unmasked bytes. PR 7's parity harness must account for it. Flagging it explicitly rather than letting the harness discover it as a spurious diff.

2. An in-tree decoder. Filtering needs outputs, input outpoints, and the txid. The SDK depends on no Bitcoin library by design, so tx.go provides exactly those and nothing else. Both serializations are accepted; the txid is always hashed over the legacy one, which is what makes it witness-invariant and what the node's wire txids are.

It is a decoder, not a validator — it is only ever fed bytes the node produced, so the risk it guards is a malformed or truncated payload, not consensus divergence.

Allocation bounds

The decoder is handed length prefixes it did not choose, so every count and length is checked against the bytes actually remaining before sizing an allocation.

The bound divides rather than multiplies: count * minBytesEach wraps uint64 for a count near 2^64 and would wave through exactly the payload it exists to reject. There is a test pinning that — an input count chosen so count * 41 wraps down to 4, precisely the bytes left.

The allocation tests claim ~1e6 items rather than ~4e9 on purpose. The decoder rejects both identically, but the assertion only matters when the bound is broken, and a broken bound turns a 4-billion count into ~137 GB resident. That does not fail an 8 MB budget assertion — it OOMs the machine and takes the job's whole cgroup down with it. This was not hypothetical: it killed a dev box twice while validating this PR. A million items is just as impossible for a 50-byte payload and costs 37 MB to prove.

Testing

  • Unit: decoder (legacy + segwit, txid witness-invariance, compact-size round-trip, malformed input, allocation bounds), prefix derivation/masking/dedup/determinism, and filtering (true match, pure decoy, unwatched-prevout spend, unresolved prevout, vin attribution).
  • E2E against a live regtest node: 3 tests, including a deterministic decoy — a bucket covering wallet C is registered while only wallet B is watched, so the decoy arrives by construction rather than by coin flip.
  • Full E2E suite green.
  • ./lint.sh (fmt/vet/staticcheck/errcheck) clean.
  • Per plan §5, every new assertion was perturbed once and observed to fail: 19/19 caught.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HZw7Sf8PZj3qPJ6orxDH17

bkeroack and others added 2 commits August 5, 2026 16:39
A prefix watch trades precision for privacy: the consumer registers a
bits-wide prefix of sha256(scriptPubKey) and the node delivers every
transaction falling in that 2^-bits bucket, so it learns the bucket and
never the script. The cost is decoys, which the consumer has to filter
out locally -- and that filter has to be local, because any precise
follow-up fetch would re-leak the exact script the bucket was hiding.

PrefixWatcher is that filter. It holds the real scriptPubKeys, and for
each delivery it recomputes sha256(scriptPubKey) over every output and
every retained spent prevout, reporting only the true hits. Prevouts the
node did not retain the script for cannot be settled locally, so they
surface separately as Unresolved rather than being folded into either
answer; treating them as non-matches would silently drop real spends.

Two things differ from a naive port of the Rust SDK.

Prefixes are masked below the declared width. The wire carries whole
bytes, so a 12-bit bucket ships 2 bytes, and the node keys on the top 12
bits only. Shipping the remaining 4 bits unmasked would hand over more
scripthash than the declared bucket width and narrow the anonymity set
for free. Masking is free -- the node derives the identical bucket key
either way -- and it is what makes narrow buckets actually collapse: at
1 bit, any number of scripts now registers at most two buckets rather
than one per distinct leading byte. This is a deliberate divergence in
wire output from the Rust SDK and PR 7's parity harness has to account
for it.

Filtering needs the transaction's outputs, its input outpoints, and its
txid, which means a decoder. The SDK depends on no Bitcoin library by
design, so tx.go supplies exactly those three things and nothing else.
It accepts both serializations and always hashes the txid over the
legacy one, which is what makes the id witness-invariant and what the
node's wire txids are.

The decoder is fed length prefixes it did not choose, so every count and
length is checked against the bytes actually remaining before it sizes
an allocation. That bound divides rather than multiplies: count *
minBytesEach wraps uint64 for a count near 2^64 and would wave through
the very payload it exists to reject. There is a test for exactly that
wrap.

Those allocation tests deliberately claim ~1e6 items and not ~4e9. The
decoder rejects both identically, but the assertion only means anything
when the bound is broken -- and a broken bound turns a 4-billion count
into ~137 GB resident, which does not fail an 8 MB budget assertion, it
OOMs the host and takes the job's cgroup with it. A million items is
just as impossible for a 50-byte payload and costs 37 MB to prove.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HZw7Sf8PZj3qPJ6orxDH17
The test waited for the loader's third attempt and then asserted on
`srv.legCount()-1`, on the assumption that a third attempt implies a
third connection. It does not: `attempts` is incremented on ENTRY to the
loader, so it reaches 3 before the leg that attempt belongs to has been
registered server-side. The assertion then interrogates a still-empty
leg and fails with "leg 1 recorded 0 of 1 control messages".

Deriving the index from a counter that races the reconnect was the bug.
Poll every leg for the AddScripts control instead, which is what the test
actually means: the eventually-loaded set is registered somewhere, on
whichever connection finally succeeded.

Caught by CI on #517 -- a PR that touches no Go runtime code -- which is
the flake presenting as an unrelated failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HZw7Sf8PZj3qPJ6orxDH17
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