[WIP] btcutil: Add library functions for BIP-0352 silent payment _scan_ support - #2466
Draft
guggero wants to merge 18 commits into
Draft
[WIP] btcutil: Add library functions for BIP-0352 silent payment _scan_ support#2466guggero wants to merge 18 commits into
guggero wants to merge 18 commits into
Conversation
guggero
force-pushed
the
silent-payments-receive
branch
from
May 20, 2026 10:43
415da0a to
aab6396
Compare
guggero
force-pushed
the
silent-payments-receive
branch
from
July 14, 2026 11:41
aab6396 to
afaa92a
Compare
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
force-pushed
the
silent-payments-receive
branch
from
July 17, 2026 10:02
afaa92a to
2fcde9a
Compare
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Depends on #2244, only the last two commits are new.
This is still very much work in progress.