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.

It also carries and checks the send side, through BIP 375 PSBTs — see Sending below. satd holds no keys and signs nothing; what it adds is the verification a sender otherwise has no way to run.

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.

Sending (BIP 375)

Paying a silent payment address is not something a wallet can do alone with a raw transaction. The output script is derived from a shared secret between the sender's input keys and the recipient's scan key, so whoever assembles the transaction has to be told what that secret produced — and has no way to check the answer. BIP 375 is how that conversation happens inside a PSBT, and satd is the party that checks it.

BIP 375 is a draft. Its field set has not changed since 0.1.0 and satd is refereed against the vectors it publishes, but a draft can still change.

What satd does

BIP 375 PSBTs are PSBT version 2 only, and satd accepts version 2 on every PSBT method (see PSBT version 2). On top of carrying the fields, satd verifies them:

  1. Which inputs may contribute. BIP 352 admits P2TR, P2WPKH, P2PKH and P2SH-P2WPKH, excludes a taproot input committed to the nothing-up-my-sleeve internal key, and forbids a segwit version 2 or later input anywhere in the transaction. Every signature must be SIGHASH_ALL, or the outputs the secret was computed from could still change.
  2. Whose key each input really is. A taproot input's key is in its script. For the others, satd takes the key from the PSBT's derivation data, signatures or witness and keeps only one whose hash160 matches the previous output's script. This is stricter than BIP 375's reference validator, which takes the first declared key and never checks it. Without the check, a PSBT can name any key, prove an ECDH share against it, derive an output from it, and pass — while the transaction spends something else and the recipient scans for an output that was never created.
  3. Every ECDH share has a valid proof. BIP 374 DLEQ proofs are what make a share checkable without the private key behind it. satd verifies the global proof against the sum of the contributing keys, and each per-input proof against that input's own key.
  4. The output scripts are what the shares derive to. satd recomputes each silent payment output script from BIP 352 and compares.
  5. The previous outputs are the real ones. A witness_utxo is whatever the PSBT's author wrote, and for a taproot input it is the key the share is supposed to belong to. satd looks each one up in its own UTXO set. This is the one check a node can do and a hardware wallet cannot.

Reading the verdict

analyzepsbt reports a silent_payments object for any PSBT with silent payment outputs. Each output gets a status:

StatusMeaning
readyShares complete, proofs valid, keys bound, and the script either absent or equal to the derived one.
missing_sharesAn eligible input still owes an ECDH share for this scan key. Normal while the PSBT is going round; a fault once the script has been computed.
invalid_proofA DLEQ proof does not verify. invalid_inputs names which input and why.
invalid_scriptPSBT_OUT_SCRIPT is present and is not what the shares derive to.
invalid_inputsThe transaction cannot carry a silent payment: a segwit v2+ input, a sighash that is not SIGHASH_ALL, or a degenerate key sum.
invalid_prevoutA previous output disagrees with the UTXO set.
unverifiableAn eligible input's public key is not pinned down by the PSBT, so its share cannot be checked.

The object also carries derived_script whenever satd could compute one, which is what lets a coordinator see where the money is actually going before anyone signs. A verified: false object with a reason means satd could not run the checks at all — a malformed field, or a PSBT asking for more work than any relayable transaction needs. It never means "checked and fine".

finalizepsbt is BIP 375's Transaction Extractor, so it refuses unless every silent payment output is ready with a matching script. There is no override flag, and extract=false is gated too: a finalised PSBT is one sendrawtransaction away from the chain, and a silent payment paid to the wrong script cannot be recovered.

The flow

satd has no wallet and no coin selection, so choosing which coins to spend is still the caller's. Everything else is a node call.

# 1. Build a version 2 PSBT naming the recipient. The `sp1…` key needs
#    psbt_version=2; without it the node refuses and says so.
sat-cli createpsbt \
  '[{"txid":"…","vout":0}]' \
  '[{"sp1q…":0.25},{"bc1q…":0.2499}]' \
  0 null null 2

# 2. The node fills in the previous outputs it knows about.
sat-cli utxoupdatepsbt "$PSBT"

# 3. A Signer — the wallet, or a hardware device that speaks BIP 375 —
#    computes the ECDH shares, proves them, derives the output scripts,
#    clears PSBT_GLOBAL_TX_MODIFIABLE, and only then signs.

# 4. The node checks all of it before anyone commits.
sat-cli analyzepsbt "$SIGNED"     # silent_payments.outputs[].status == "ready"

# 5. Extract and broadcast.
sat-cli finalizepsbt "$SIGNED"
sat-cli sendrawtransaction "$HEX"

Step 3 needs an input's private key, which never enters this node — so it happens in the client. sat-cli signpsbtwithkey does it.

Signing with sat-cli

sat-cli signpsbtwithkey is a BIP 375 Signer. Given a version 2 PSBT and a key on stdin it does the Signer's duties in the order BIP 375 states them, because the order is the safety property:

  1. Refuse a transaction that cannot carry a silent payment at all — a segwit version 2 or later input, or a sighash that is not SIGHASH_ALL.
  2. Record the public key of every input it holds, so that anyone else can check whose key a share belongs to. satd's createpsbt has no wallet and cannot write these, which makes it the Signer's job.
  3. Compute an ECDH share for each of those inputs, with a BIP 374 proof, and check every share it did not produce.
  4. Once every eligible input is covered, compute each output's script and clear PSBT_GLOBAL_TX_MODIFIABLE.
  5. Only then sign. It never signs while an output has no script: a signature commits to the outputs, so signing first would commit to a transaction that is still going to change.

A signer holding only some of the inputs is the normal case, not an error. It writes its shares, names the inputs that still owe one, emits the PSBT, and exits 2 — the same exit code a partially signed version 0 PSBT gets. The next signer continues. A signer that holds every eligible input writes one global share per scan key instead of one per input; a signer that does not must not, because a global share claims to cover inputs it does not hold.

The key is read from stdin, never over RPC, and the auxiliary randomness for each BIP 374 proof is 32 fresh bytes from the operating system. Reusing it across two proofs for the same key can leak the secret, which is why two runs over the same PSBT produce different proofs of the same share.

Signing with a device

sat-cli signpsbtwithsigner passes a version 2 PSBT to an external signer untouched. For a silent payment the only party that can compute an ECDH share is the one holding the input's private key, so a device is exactly the right party to ask — and sat-cli cannot know which devices have learned BIP 375.

No shipping device writes BIP 375 fields today. What does work now is the second half: BIP 375 says a PSBT is plain PSBTv2 once every output has a script, so a device that has never heard of BIP 375 can sign one another signer has already completed. In that case the device shows a taproot address, not the sp1… one, so the user's assurance is the node's verdict — run analyzepsbt and check for ready/matches before the PSBT goes to the device.

Either way sat-cli checks the reply, with the same code the node runs. A signer adds signature data; it does not choose the transaction. A reply describing a different transaction, one whose silent payment script is not what its own shares derive to, or one carrying a signature while an output still has no script, is refused rather than emitted.

The reply is also checked against the document that was sent, input by input. A DLEQ proof says a share was computed from the key bound to an input, and which key that is comes from the previous output the PSBT says the input spends — which the device hands back along with everything else. A device that substitutes a previous output paying a key of its own can then do the rest of the BIP 375 work honestly against it: every proof verifies, and the script the outputs get is derived from a key no input has, so the recipient's scan never finds the payment. sat-cli refuses a reply that restates what an input spends, or the key bound to it. The node refuses the same thing at finalizepsbt, where the UTXO set has the last word.

What satd will not do

  • It will not choose which coins to spend. There is no wallet here.
  • It will not sign. Signing is sat-cli, in the client, with the key on stdin.
  • It will not hold a scan or spend key, or a label.
  • It will not compute PSBT_OUT_SCRIPT for you. BIP 375 gives that to the Signer, along with clearing the modifiable flags, because computing it is what freezes the transaction.
  • It will not verify PSBT_OUT_SP_V0_LABEL: checking a label needs the recipient's scan secret.