Skip to content

[WIP] btcutil: Add library functions for BIP-0352 silent payment _scan_ support - #2466

Draft
guggero wants to merge 18 commits into
btcsuite:masterfrom
guggero:silent-payments-receive
Draft

[WIP] btcutil: Add library functions for BIP-0352 silent payment _scan_ support#2466
guggero wants to merge 18 commits into
btcsuite:masterfrom
guggero:silent-payments-receive

Conversation

@guggero

@guggero guggero commented Dec 16, 2025

Copy link
Copy Markdown
Collaborator

Depends on #2244, only the last two commits are new.

This is still very much work in progress.

@guggero
guggero force-pushed the silent-payments-receive branch from 415da0a to aab6396 Compare May 20, 2026 10:43
@guggero
guggero force-pushed the silent-payments-receive branch from aab6396 to afaa92a Compare July 14, 2026 11:41
guggero added 10 commits July 17, 2026 12:01
Fixes an independent issue discovered while reviewing and testing
the code that failed to detect duplicate xPubs because the wrong
variable (keyData holding the nil value of the global unsigned TX,
instead of the lowercase keydata that's the loop variable).
This commit adds library functions for calculating block tweaks. A
block tweak is the set of Silent Payment transaction tweaks that are
contained in a block. A SP transaction tweak is the pre-calculated input
public key sum tweaked by the input hash of a transaction and is
required for light clients to be able to calculate potential Silent
Payment outputs in a transaction or block without needing to download
the full block/transaction.
This commit adds helper functions that can be used by clients (including
light clients) to scan the chain for potential Silent Payment outputs.
@guggero
guggero force-pushed the silent-payments-receive branch from afaa92a to 2fcde9a Compare July 17, 2026 10:02
guggero added 8 commits July 19, 2026 12:25
The BIP-0352 input rules permit only compressed (and x-only) public
keys for shared secret derivation. The pubkey-hash witness shape check
used by the P2PKH and P2WPKH shortcut paths accepted any key that
btcec.ParsePubKey understands, including 65-byte uncompressed keys, so
transactions spending uncompressed-key outputs produced a tweak where
the reference implementation produces none, making receivers derive
wrong candidate outputs for such transactions. This is exactly what the
official test vector "P2PKH and P2WPKH Uncompressed Keys are skipped"
checks, which failed before this commit.

The P2SH-P2WPKH heuristic had a similar looseness in a different shape:
any input with a non-empty scriptSig and a two-item witness was treated
as nested P2WPKH, so a P2SH-P2WSH spend whose second witness item
happens to be a parseable 33-byte public key would contribute a bogus
key to the input sum. The scriptSig must now be exactly the canonical
23-byte push of the P2WPKH witness program.
A transaction whose eligible input public keys sum to the point at
infinity (for example a key and its negation, seen in the wild on
signet) has no valid ECDH tweak; BIP-0352 instructs receivers to skip
such transactions. The summation converted the infinity point to the
affine coordinates (0, 0) and returned it as if it were a key, which
serialized as the bogus compressed point 0x02||0x00...00 and ended up
being served to light clients by tweak-data index servers.

Only the final sum is checked: an intermediate sum at infinity during
the summation must not abort the scan, per the official test vector
"Input keys intermediate sum is zero but final sum is non-zero".
PublicKeyFromInput sliced the assumed internal key out of the last
witness item of a script path spend without checking its length first,
panicking on any two-item taproot witness whose last item is shorter
than 33 bytes. Consensus rules prevent such witnesses in mined blocks,
but the function is an exported API that is also fed unconfirmed
transactions, where a crafted witness would crash the caller (such as
a tweak-data index server) instead of skipping the input.
The receiving test harness only paired expected output keys through
OutputMatches and silently ignored everything else the official
vectors pin down: the transaction tweak (the value a tweak-data index
server serves), the ECDH shared secret, each found output's private
key tweak, and the vectors that specify an expected output count
instead of an output list. The K_max limit vector in particular passed
vacuously, since its n_outputs field was never parsed and the manual
pairing loop iterated zero times.

The harness now runs the same flow a scanning client uses: derive the
tweak end to end via TransactionTweakData, multiply by the scan key
and assert the shared secret, then walk output index k over a
candidate-output map with all label variants, honoring the K_max scan
limit from BIP-0352 v1.1.0. Every found output's private key tweak is
compared to the vector's and checked to complete the spend key to the
output key, so a wallet could actually spend what it detects.
OutputMatches keeps its own focused test.
A spend public key equal to the negated tweak point pushes the derived
output key P = B + t_k*G to the point at infinity, which the affine
conversion turns into the coordinates (0, 0) — not a valid public key,
returned to the caller without an error. Honest key derivation cannot
hit this case, but the function must not hand out an invalid point for
any input. The secp256k1 silentpayments module rejects the same case.

The related corner case of a label tweak that negates t_k (combined
private key tweak of zero, output key equal to the plain spend key) is
valid and stays accepted; a test pins that behavior to match the
secp256k1 module as well.
Add test cases distilled from comparing this package against the
BIP-0352 reference implementation, the secp256k1 silentpayments module
and independent Go/Rust scanners:

  - Annex handling: the annex is any last witness element whose first
    byte is 0x50, not only a single-byte 0x50 element (a divergence
    found in other implementations). Mishandling it shifts which
    element is treated as the control block and bypasses the NUMS
    internal key skip.

  - Smallest-outpoint selection: the outpoint is compared in its
    serialized little-endian form, so for the same txid, vout 256
    sorts before vout 1. An integer comparison would produce a
    valid-looking but wrong input hash and silently break
    sender/receiver agreement.
Break the light client scanning hot path (candidate output key
derivation per served transaction tweak) into its primitives, so the
cost profile can be compared between platforms. The interesting
comparison is native vs. GOOS=js GOARCH=wasm, where a browser scanner
runs: measured on the same machine, elliptic curve operations are a
uniform ~1.7x slower in wasm and the per-tweak pipeline decomposes
into ~63% ECDH scalar multiplication, ~31% the two per-address base
multiplications (including one field inversion each) and ~5% tweak
point decompression.
Scanning derives candidate output keys for every served transaction
tweak, so per-tweak overhead multiplies by hundreds of thousands over
a spam-heavy block range. TransactionOutputKeysForFilterBatch derives
the candidates of many tweaks in one call and exploits two batch-level
shortcuts the per-tweak variant cannot:

  - The output tweak t_0 depends only on the shared secret, not on the
    spend key, so its point is computed once per transaction and the
    per-address candidates become cheap point additions instead of
    full base multiplications.

  - All per-point affine conversions (one modular inversion each, both
    for the ECDH shared secrets and for the candidate keys) collapse
    into two inversions for the entire batch via Montgomery's trick.

Benchmarks show 1.4x faster candidate derivation per tweak (102us to
72us native, 177us to 126us in wasm, for the base+change address
pair); the remaining cost is ~90% the one irreducible ECDH scalar
multiplication per tweak. Equivalence with the per-tweak variant is
tested against random keys, all official vectors and the
point-at-infinity corner case.
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