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 scan | Tier 1.5 — mempool tweaks | Tier 2 — scan-key watch | |
|---|---|---|---|
| Who computes | your wallet | your wallet | the node |
| Scan key leaves the device | never | never | disclosed to the node |
Requires silentpaymentindex=1 | yes | yes | no (accelerates rescan only) |
| Detection latency | block | mempool admission | mempool admission |
| History / cold-sync | unclamped cursor replay | none (best-effort, live only) | RescanBlocks |
| Transport | gRPC Subscribe | gRPC Subscribe | gRPC Watch (mirrored on WebSocket) |
| Typical consumer | wallets, batch scanners | payment-notification clients | thin 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→ thesilentpaymentssection:enabled,synced, and abackfillobject withstate,cursor_height,snapshot_height,progress_ratio, andestimated_remaining_seconds. Use the reportedprogress_ratio, notcursor_height / snapshot_height— the latter measures from genesis and overstates a mainnet backfill from its first block.sat-tui→ the services row'ssp-idxcolumn.- 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— striptxidandmax_value, leaving the 33-byte tweak alone: the leanest form for bulk cold-sync.mempool_tweaks— additionally streamMempoolTweakat 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;MempoolTweakalways 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:
- 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.
- 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
hash160matches 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. - 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.
- The output scripts are what the shares derive to. satd recomputes each silent payment output script from BIP 352 and compares.
- The previous outputs are the real ones. A
witness_utxois 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:
| Status | Meaning |
|---|---|
ready | Shares complete, proofs valid, keys bound, and the script either absent or equal to the derived one. |
missing_shares | An 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_proof | A DLEQ proof does not verify. invalid_inputs names which input and why. |
invalid_script | PSBT_OUT_SCRIPT is present and is not what the shares derive to. |
invalid_inputs | The transaction cannot carry a silent payment: a segwit v2+ input, a sighash that is not SIGHASH_ALL, or a degenerate key sum. |
invalid_prevout | A previous output disagrees with the UTXO set. |
unverifiable | An 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:
- 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.
- Record the public key of every input it holds, so that anyone else can
check whose key a share belongs to. satd's
createpsbthas no wallet and cannot write these, which makes it the Signer's job. - Compute an ECDH share for each of those inputs, with a BIP 374 proof, and check every share it did not produce.
- Once every eligible input is covered, compute each output's script and
clear
PSBT_GLOBAL_TX_MODIFIABLE. - 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_SCRIPTfor 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.