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

Silent Payments (BIP 352)

A BIP 352 silent-payment address (sp1…) is a reusable, static address that produces a unique, unlinkable taproot output on chain for every payment it receives. Nothing on chain connects two payments to the same address, and nothing identifies an output as a silent payment at all. The cost of that privacy falls on the receiver: finding your own payments means running an ECDH computation against candidate transactions, because there is no address string to look up.

satd implements the receive side: a tweak index, a streaming tweak firehose with cursor replay, mempool-time detection, and an optional server-side scan-key matcher, with typed support in both SDKs. The matching kernel is tested for parity against the BIP 352 reference vectors. Everything is opt-in; a node that enables none of it behaves exactly as before.

This chapter is the integrator guide: what each consumption mode gives you, how to pick one, and how to operate the index behind them. The wire-level contract lives in the streaming API specification (§7.7).

In the node, not beside it

Silent-payment support follows the same one-process, one-store model as satd's Electrum and Esplora surfaces: the tweak index is written inside block connection, atomically with the chainstate, and the serving and matching layers read it in-process from the same RocksDB store the node validates against. There is no companion indexer to keep in sync and no window where an external index's view lags the node across a reorg — rows are removed in the same batch that disconnects the block, and tweak events carry the block's own hash so a client re-anchors from the (block_hash, height) it already holds.

For context: Bitcoin Core has no silent-payment support in any released version as of this writing (August 2026), so receiving against a stock node means running a separate tweak-indexing daemon and serving layer beside it, each with its own sync state and reorg handling. satd's index produces the same per-block public tweak data such stacks do, served over the streaming API and a JSON-RPC method instead of a sidecar's own protocol.

The trade-off, as with every satd index, is local disk — measured in Disk Footprint & Indices.

Choosing a tier

Three consumption modes ride on the streaming surface. They differ in who runs the ECDH scan, and therefore in who ever sees your scan key.

Tier 1 — client-side scanTier 1.5 — mempool tweaksTier 2 — scan-key watch
Who computesyour walletyour walletthe node
Scan key leaves the deviceneverneverdisclosed to the node
Requires silentpaymentindex=1yesyesno (accelerates rescan only)
Detection latencyblockmempool admissionmempool admission
History / cold-syncunclamped cursor replaynone (best-effort, live only)RescanBlocks
TransportgRPC SubscribegRPC SubscribegRPC Watch (mirrored on WebSocket)
Typical consumerwallets, batch scannerspayment-notification clientsthin clients, phones

Tier 1 is the recommended, zero-custody mode. The node streams each block's public tweak data (BlockTweaks, category bit 8 — explicitly requested, never part of the categories = 0 default); the wallet runs one ECDH per tweak locally. The scan key never leaves the device, and the node learns nothing about which outputs are yours. Because every stored row embeds the hash of the block it describes, tweaks-only replay is exempt from the usual MAX_REPLAY_BLOCKS clamp: a fresh wallet cold-syncs the entire taproot era in one from_cursor subscription, paged and backpressured server-side.

Tier 1.5 is Tier 1 at mempool latency. Setting mempool_tweaks = true alongside bit 8 additionally delivers a MempoolTweak at each eligible transaction's admission — the same 33-byte tweak its later BlockTweaks entry will carry, plus the transaction's taproot outputs so a match is confirmed in-band without a getrawtransaction race. It is best-effort like the mempool itself: no durable cursor, no replay, no retraction on RBF (dedup by txid; the confirmed record at connect stays authoritative). A payment missed while offline is simply caught at confirmation.

Tier 2 moves the scan to the node. Register up to 16 (scan_secret, spend_pubkey) targets per connection and the node emits a SilentPaymentMatched for every output paying you — at mempool admission with confirmed = false, then again at confirmation with confirmed = true and a resume cursor. Each match carries the transaction's public tweak T and output counter k, which is exactly enough for the wallet to re-derive the output's full spending key offline from its own b_scan and b_spend. This mode works on any satd node: matching recomputes from the block and its undo data with the same kernel the index uses, so it needs no silentpaymentindex and costs the node nothing while no target is registered.

The trust trade is explicit: a scan key lets the node — and anyone who compromises it — learn which outputs are yours. It is not a spending key; b_spend's private half never leaves the client, so no one else can ever spend them. The node treats the secret accordingly: scan secrets live in memory for the connection's lifetime only, wrapped in a zeroize-on-drop buffer, never written to disk, a cursor, a status RPC, or a log line. Both SDKs refuse to send one over a plaintext transport that carries a bearer token, and a routable events bind requires auth or mTLS like every other watch kind. Pointing a thin client at your own node keeps the disclosure inside your trust boundary; pointing it at someone else's node is a choice to extend that boundary to them.

The tweak index

Tier 1 and 1.5 serve from the sp_tweaks index: one row per block from taproot activation upward (height 709,632 on mainnet — earlier blocks cannot carry silent payments), holding the public tweak T = input_hash · A for every eligible transaction. Rows are written inside block connection, removed on disconnect, and rebuilt by -reindex-chainstate. A row is present even for a block with no eligible transactions, so row presence distinguishes "indexed, none" from "not indexed", and every row embeds its block's hash, so readers authenticate it without trusting the height-to-hash index.

Enable it with:

# bitcoin.conf — default off, restart to change
silentpaymentindex=1

A node that syncs from genesis with the flag set builds the index inline. To add it to an existing datadir, run the deferred backfill:

sat-cli backfillindex silentpayment

The backfill walks from taproot activation to the snapshot height pinned at start, resumes across daemon restarts, and answers to the generic index controls (pauseindex / resumeindex / cancelindex silentpayment). It refuses to start with less than 6 GiB of free disk. Progress is visible three ways, all reporting the same walk-relative ratio:

  • getsatdindexinfo → the silentpayments section: enabled, synced, and a backfill object with state, cursor_height, snapshot_height, progress_ratio, and estimated_remaining_seconds. Use the reported progress_ratio, not cursor_height / snapshot_height — the latter measures from genesis and overstates a mainnet backfill from its first block.
  • sat-tui → the services row's sp-idx column.
  • Prometheus → the satd_spindex_* family; see Observability & Metrics.

Until the backfill completes, the tweak-serving surfaces refuse rather than return a partial result: a from_cursor tweak replay is rejected in-band so a light client can never silently miss payments below the backfill frontier.

What it costs, measured on a synced mainnet node (August 2026): ~13 GB for the full taproot era, growing ~1 GB/year at the recent eligible-transaction rate, with a mainnet backfill taking 6 h 46 m. The full accounting, including the estimator's stint semantics and the row format, is in Disk Footprint & Indices.

Serving tweaks (Tier 1 on the wire)

With the index enabled and synced, a gRPC Subscribe with category bit 8 streams one BlockTweaks per connected block, shaped by five per-subscription knobs:

  • tweak_dust_limit — drop entries whose largest eligible output is below the floor (in sats). At 546 sat this trims roughly 10% of mainnet entries.
  • tweaks_only — strip txid and max_value, leaving the 33-byte tweak alone: the leanest form for bulk cold-sync.
  • mempool_tweaks — additionally stream MempoolTweak at admission (Tier 1.5).
  • tweak_outputs — include each entry's taproot outputs, re-derived at serve time, so matches confirm in-band. Off by default because it makes replay read each block; MempoolTweak always carries its outputs regardless.
  • tweak_unspent_only — cut-through: drop entries whose taproot outputs are all already spent. Entries that survive carry their full output set. The biggest single saving on a cold sync, and the one knob with a correctness caveat, below.

Cut-through is a balance scan, not a restore. tweak_unspent_only asks the node "is this coin still there?", answered against the UTXO set at the moment the event is served — not against the chain as of that height. A payment received at height H and spent at H+100 is therefore absent from a scan of H that runs today. A wallet that wants a current balance loses nothing and skips the ECDH for every coin that no longer exists; a wallet reconstructing its transaction history must leave the flag off, or the history will omit everything it has already spent. Entries dropped this way set the block's filtered flag, so an empty block is never mistaken for one with no eligible transactions.

Spentness decides only whether an entry survives — never which outputs a surviving entry carries. Scanning walks k = 0, 1, 2, … and stops at the first k with no match among the outputs it was given, so an entry trimmed to just its unspent outputs would cut the walk short and hide a live coin at a higher k. An entry with one spent and one live output therefore arrives carrying both. Like tweak_outputs, it re-derives outputs from the block, so it needs a block source and reads one block per event. It never applies to MempoolTweak — an unconfirmed output is in no confirmed UTXO set.

The firehose serves on gRPC only — the WebSocket/SSE transports do not carry the tweaks category. For scripts and integrators not on an SDK, getsilentpaymentblockdata "blockhash" ( verbosity dust_limit ) returns the same per-block bytes over JSON-RPC; see JSON-RPC Extensions.

Serving tweaks to existing wallets (Electrum)

The streaming API is the better protocol — durable cursors, mempool-time tweaks, reorg anchors — but no third-party wallet speaks it yet. The wallets that do scan silent payments today speak blockchain.tweaks.subscribe on the Electrum port, so satd serves that method too, from the same index and with the same tweaks:

electrum=1
silentpaymentindex=1

It is a stream rather than a call (the JSON-RPC result is the first height, the rest arrive as notifications, {"message":"done"} ends the chunk), and its historical_mode parameter is the cut-through trade described above with the polarity flipped: false cuts spent coins, true keeps them for a restore. The Electrum chapter has the wire shape and the per-network behaviour. Nothing here needs an operator decision: Cake Wallet probes the method only when the advertised server name contains electrs, and satd's default name does, so enabling the index above is enough.

Walkthrough: a zero-custody light wallet (Tier 1)

The shipped SDK examples are the reference implementations — sp_light_scan.rs (Rust) and sp_light_scan (Go) — each a complete scanner in one file: subscribe, ECDH, label handling, in-band output confirmation, and a restart-durable resume cursor. The shape, in Rust:

let opts = SubscribeOptions {
    categories: Categories::TWEAKS,   // bit 8 — never implied by "all"
    mempool_tweaks: true,             // Tier 1.5: detect at admission
    tweak_outputs: true,              // confirm matches in-band
    // Cold-start anchor, used only when the cursor file is empty. A cursor
    // names the last height already done, so `activation - 1` scans the
    // activation block itself.
    from_cursor: Some(Cursor { height: 709_631, ..Default::default() }),
    ..Default::default()
};
let mut sub = client.resilient_subscribe(
    opts,
    ResilientConfig::new().cursor_store(Arc::new(FileCursorStore::new(path))),
);
loop {
    // Propagate, never `while let Ok(..)`: `next()` returns `Err` on every
    // PERMANENT failure — a corrupt cursor file, a rejected subscribe (an index
    // still backfilling answers `FAILED_PRECONDITION`), retries exhausted.
    // Swallowing that exits the loop silently and the wallet reports a zero
    // balance it never actually scanned for.
    let event = sub.next().await?;
    // scan, then poll again — the next poll commits this event's cursor
}

For each TweakEntry, the wallet computes locally, per BIP 352:

ecdh  = b_scan · T                                  // one point multiply per entry
t_k   = hash("BIP0352/SharedSecret", ecdh ‖ k)      // k = 0, 1, … per candidate output
P_k   = B_spend + t_k · G                           // expected output key

and compares P_k's x-only form against the transaction's taproot outputs — carried in the event itself under tweak_outputs, so no follow-up RPC is needed. A payment to a labeled address (BIP 352 §5) shifts P_k by the label tweak; scan with each of your labels, and include label 0 even if you issue none, because label 0 is how your own change comes back. On a match, the spending key is b_spend + t_k (plus the label tweak if any) — derived entirely on the device.

Cold-sync is the same subscription with a from_cursor at taproot activation; the replay is unclamped, index-backed, and ends in-band on any storage error rather than skipping a height.

The resume anchor to persist is the cursor of the last event you have finished scanning, not the last one delivered — a cursor written ahead of the work it stands for turns a crash into a silently skipped block, and for a scanner a skipped block is a missed payment. Both SDKs get this right for you: a ResilientSubscription with a CursorStore commits on poll, writing an event's cursor only when you come back for the next one, so an interrupted scan replays its last block instead of stepping over it. Use that rather than hand-rolling persistence around the raw stream; both reference examples do (sp_light_scan.rs, sp_light_scan). The mirror-image slip — persisting the previous event's cursor — costs only a repeated scan, and has shipped in a production wallet (cake_wallet#3574).

Walkthrough: a thin client with a registered scan key (Tier 2)

The reference implementations are sp_wallet.rs and sp_wallet. The shape, in Go:

target := satdevents.SilentPaymentTarget{
    ScanSecret:  bScan,          // disclosed to the node: a watch credential, not a spend key
    SpendPubkey: spendPubkey,    // public half only; b_spend never leaves the client
    Labels:      []uint32{0},    // label 0 catches your own change
}
handle.AddSilentPayments(ctx, []satdevents.SilentPaymentTarget{target})

From here the node does the scanning. Each SilentPaymentMatched arrives twice — once at mempool admission (confirmed = false, best-effort) and once at confirmation (confirmed = true, with a resume cursor) — and carries the output key and value plus the public tweak T and counter k, from which the client re-derives the full spending key offline exactly as in Tier 1. Targets are removed by their identity b_scan · G, which the client derives locally; each target costs one watch-quota unit.

A fresh wallet cold-syncs by registering its targets and issuing a RescanBlocks over the taproot-activation-to-tip window. The rescan produces exactly the matches the live path would have; on a node whose tweak index is enabled and complete it also runs faster, reading each block's tweaks from the index (verified per block against the stored row's embedded hash) instead of recomputing them. The index changes rescan speed, never results.

Both SDKs' ResilientWatch re-registers scan-key targets automatically on reconnect, so a dropped connection never silently stops the watch; see the Rust SDK and Go SDK chapters for the reconnect-and-resume contract and the TLS posture around scan secrets.