Skip to content

features/silent-payments - #174

Draft
42Pupusas wants to merge 12 commits into
Blockstream:masterfrom
42Pupusas:features/bip-352-silent-payments
Draft

features/silent-payments#174
42Pupusas wants to merge 12 commits into
Blockstream:masterfrom
42Pupusas:features/bip-352-silent-payments

Conversation

@42Pupusas

Copy link
Copy Markdown

Enables Silent Payment address creation, scanning and spending capabilities on Wollet and SwSigner, motivated by JAN3's bounty, and following the ELIP proposal.

Wollet is configured with scan-only material (b_scan plus the public B_spend), so it never holds b_spend. Scan results carry a SpendTweak, never a completed key. Correctness is verified publicly, as B_spend + spend_tweak*G == output spend pubkey.

Spending goes through SwSigner::sign(), which re-derives b_spend internally, re-verifies the wallet's tweak against the Taproot output actually being spent, and only then signs. The wallet attaches SilentPaymentInputMeta (account + spend tweak + expected B_spend; never a key) as a PSET proprietary key. The signer treats that metadata as untrusted input and validates it before use.

BlockchainBackend::scan_silent_payments() finds payments through a light client. Backends advertise Capability::SilentPayments and answer silent_payment_tweaks() with each block's T = input_hash·A; the wallet derives the candidate scripts those tweaks would produce for its own keys, then confirms them with an ordinary script-history query.

EsploraClient earns the capability by computing tweaks from blocks locally, with no server-side change required. It reads prevout scripts from the paged /block/{hash}/txs listing (one request per 25 txs rather than one per prevout, which gets public instances to 429).

A backend without the capability returns Error::SilentPaymentsUnsupportedByBackend rather than an empty result: plain script-history backends can never surface these outputs, so they refuse honestly instead of silently missing money. Waterfalls' descriptor filtering cannot serve SP scripts, so it only advertises the capability via the underlying Esplora path.

Discovery runs as part of full_scan(). How far it has progressed is tracked separately from the wallet tip, because a wallet that scanned before its scan material was configured must not treat that history as already searched; with a birthday set, it resumes from there. Findings travel in the Update (wire version 6) so they persist and replay on restore like anything else a scan finds.

Compatibility

Older Update versions decode unchanged.

Empty silent-payment state hashes exactly as it did before the feature existed, so every already-persisted wallet stays valid. There are tests pinning this specifically.

Wollet::status() incorporates B_scan and B_spend (never a secret), so changing signer or account correctly invalidates an incompatible persisted cache.

The feature is opt-in (silentpayments, in all three crates), so nothing changes for existing consumers until they ask for it. The feature-off build carries no dead state, and the default workspace build/test/clippy are unaffected.

Reviewing

The diff is large (~7.8k added), so the history was rebuilt to be read commit by commit, and while drafted here as a single PR for e2e testing , could be split cleanly into two PRs at ae600d6a (boundary + signer, then wallet).

Derivation coordinates (SilentPaymentAccount), scan-only material with no path to b_spend (SilentPaymentScanMaterial), and the PSET proprietary-key metadata a wallet attaches for a signer (SilentPaymentInputMeta), behind a new silentpayments feature. Living in lwk_common lets wallet and signer share the boundary without depending on each other.
It was hardcoded to all-zeros. Correct by accident today: this function only reaches BIP-143 v0 sighashes, which never commit to the genesis hash. The regression test pins both facts so a future taproot path cannot silently inherit the wrong constant.
A new silentpayments feature adds SilentPaymentSigner (scan-material export that never returns b_spend) and a silent-payment phase inside the ordinary Signer::sign: it reads untrusted SilentPaymentInputMeta off each PSET input, verifies the named account and that the tweaked key reproduces the Taproot output being spent, and only then signs. Hardware signers refuse loudly (no such protocol operation exists yet).
Shared fixtures for building deterministic transactions, outpoints and secret keys, used by the silent payment unit tests in lwk_wollet.
tests/e2e.rs imports electrum/esplora/amp0 types at file scope and the list_transactions example uses the electrum client, so feature-off builds of --all-targets failed. required-features skips the targets instead; --test e2e -- --list under default features still shows every test.
The full scan/derive/track stack behind a new silentpayments feature: bech32 lqsp addresses, sender/receiver output derivation with CT unblinding via the shared secret, input aggregation with BIP-352 eligibility, per-tx and per-block scanning (locally computed or tweak-server assisted), the cache entry and utxo views, and recipient resolution for the transaction builder. Pinned against the ELIP known-answer vectors.
WolletBuilder accepts scan-only material and a birthday; the cache tracks found outputs by script alongside descriptor-derived ones so utxos/balance/spent bookkeeping need no special cases; Update version 6 carries discovered outputs and scan progress; scan material participates in wallet status so persistence cannot mix wallets. TxBuilder gains silent payment recipients (resolved at finish, when the final inputs are known), standalone SP utxos, correct Taproot key-path weight for fee estimation, and PSET annotation that attaches verified signing metadata for the signer. Empty SP state hashes exactly as before the feature existed, keeping every persisted wallet valid.
A new Capability::SilentPayments gates discovery: script-history backends can never surface SP outputs, so backends without it honestly refuse rather than silently missing money. The Esplora client earns it by computing per-block partial tweaks locally, reading every prevout script from the paged /block/{hash}/txs listing (one page per 25 txs instead of one request per prevout, which public instances 429). Discovery progress is tracked separately from the tip so a wallet that scanned before configuring keys does not skip history, and resumes from the birthday when set. Waterfalls descriptor filtering cannot serve SP scripts, so its client only advertises the capability through the underlying Esplora path.
The e2e suite runs the full lifecycle against a real elementsd + esplora: send to an lqsp address, discover through full_scan, and spend the discovered output back through the ordinary signer flow. Negative coverage pins that a stranger discovers nothing and that a wallet without scan material claims no discovery. The stray electrum_oidc TODO is dropped: silent payments now exercise the feature-gating CI story it was standing in for.
The feature shipped default-on, which quietly enrolls every downstream consumer of lwk_wollet in a new protocol before it has settled. Opt-in matches lwk_common and lwk_signer, where it always was.

CI only builds default features, so the code would otherwise compile nowhere: add an explicit feature-on test run covering the three crates.
The ELIP does specify a derivation path, so 'has no derivation path' read as if it contradicted the spec. The path reaches b_spend; the key that signs is b_spend + t_k, tweaked by the paying transaction and so off any BIP-32 path. Comments and changelog now say that.
@42Pupusas

42Pupusas commented Aug 3, 2026

Copy link
Copy Markdown
Author

Worth noting I didn't build on #170 as it takes a different shape: a parallel SilentPaymentWollet that exports discovered outputs through a regenerated descriptor, plus a tweak-server/BIP158 path. This PR instead integrates silent payments into the existing Wollet and full_scan so discovered outputs are ordinary wallet money, works against current Esplora/Electrum backends unchanged, and keeps signing inside SwSigner. Both approaches are legitimate but the structural differences were enough that a rebase wasn't worth it. Happy to reconcile with #170 wherever the maintainers prefer.

@42Pupusas
42Pupusas force-pushed the features/bip-352-silent-payments branch from 5083da4 to e421b00 Compare August 3, 2026 14:51
The k loop and the gap counter live in the scanner, which matches on
scriptPubKey alone and never sees a TxOut; unblinding happens afterwards in
the tx scanner, so a failed unblind drops the utxo without rewinding k. That
is the ELIP rule, but nothing pinned it: folding the unblind into the scan
loop would let a sender hide every later output of a transaction behind one
matching but unblindable output at k = 0.

The test fails if the unblind failure is turned into a break.
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