Skip to content

Full Silent Payment Implementation - #145

Open
notTanveer wants to merge 12 commits into
diybitcoinhardware:masterfrom
notTanveer:feat/silent-payments-V2
Open

Full Silent Payment Implementation#145
notTanveer wants to merge 12 commits into
diybitcoinhardware:masterfrom
notTanveer:feat/silent-payments-V2

Conversation

@notTanveer

@notTanveer notTanveer commented Jul 9, 2026

Copy link
Copy Markdown

This PR contains the complete Silent Payments implementation with a clean, linear commit history to make the review process easier.. covers PRs #64, #87, #95, #96, #125, #126, #130, #133,

also includes a number of fixes by @odudex , squashed into their corresponding feat: commits to make the review process easier.

@notTanveer
notTanveer requested review from miketlk and odudex as code owners July 9, 2026 14:17
@notTanveer notTanveer changed the title Feat/silent payments v2 [FEAT] Full Silent Payment Implementation Jul 9, 2026
@notTanveer notTanveer changed the title [FEAT] Full Silent Payment Implementation Full Silent Payment Implementation Jul 9, 2026
@odudex

odudex commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

It's great to make it more digestible for review!
One detail, I spent some days rebasing scattered PRs and did tens of bugfixes. None of this work is present in this PR?

@notTanveer
notTanveer force-pushed the feat/silent-payments-V2 branch from 08a5d40 to 2d1da69 Compare July 13, 2026 14:34
@notTanveer

notTanveer commented Jul 13, 2026

Copy link
Copy Markdown
Author

One detail, I spent some days rebasing scattered PRs and did tens of bugfixes. None of this work is present in this PR?

I did include all of your fixes. a few might have been dropped during the cleanup since I removed a number of functions we no longer needed and moved helper functions that were only used by tests into their respective test files. if you notice anything specific that's missing, let me know and I'll take another look.

(i squashed the fix: commits into the corresponding `feat' commit)

@notTanveer
notTanveer force-pushed the feat/silent-payments-V2 branch 2 times, most recently from 043a824 to 3105a96 Compare July 13, 2026 20:40
Comment thread src/embit/silent_payments/psbt.py Fixed
Comment thread src/embit/silent_payments/psbt.py Fixed
Comment thread src/embit/silent_payments/psbt.py Fixed
Comment thread src/embit/silent_payments/psbt.py Fixed
Comment thread src/embit/silent_payments/psbt.py Fixed
Comment thread src/embit/silent_payments/signing.py Fixed
Comment thread src/embit/psbt.py Fixed
Comment thread src/embit/psbt.py Fixed
Comment thread src/embit/silent_payments/signing.py Fixed
Comment thread src/embit/silent_payments/sp.py Fixed
Comment thread src/embit/silent_payments/psbt.py Fixed
@notTanveer
notTanveer force-pushed the feat/silent-payments-V2 branch 2 times, most recently from f20926b to 9f26ec7 Compare July 20, 2026 14:03
@notTanveer
notTanveer force-pushed the feat/silent-payments-V2 branch 3 times, most recently from 6443846 to dcd028b Compare August 4, 2026 18:40

@macgyver13 macgyver13 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lots of great work here! The single signer flow appears to be nearing completion.

This review focuses on the silent-payments specific code. Rather than scatter comments across the diff, I pushed a review branch:
notTanveer/embit@feat/silent-payments-V2...macgyver13:embit:review/silent-payments-V2

Two commits address issues that surfaced during a Claude-assisted review, makes sense to me but you may want to dig deeper:

  • harden resolve BIP32 derivation match with our UTXOs - _resolve_bip32_privkey didn't bind the derived key to the input's scriptPubKey, so a PSBT claiming a path we own for an input we don't control would fold that key into a_sum. Comparison now includes parity.
  • raise SPValidationError for invalid Silent Payment spend inputs in sign_with() - sign_input_with_sp_tweak returned 0 for a bad tweak. BIP-376 requires signer to fail.

The bulk of the branch is a larger change moving validation logic under the psbt.sign_with() public API, so downstream consumers get the validation rather than re-implementing it. Per-input share test vectors are explicitly disabled until that work is ready.
Doing the same for the BIP-352 vectors would likely turn up more. Happy to take a pass at that if it's useful. Reuse as much or as little of the branch as you like.

Comment thread src/embit/silent_payments/sp.py Outdated
Comment thread src/embit/silent_payments/sp.py
Comment thread src/embit/silent_payments/sp.py
Comment thread src/embit/silent_payments/sp.py Outdated
Comment thread src/embit/silent_payments/sp.py Outdated
Comment thread src/embit/silent_payments/sp.py Outdated
Comment thread src/embit/psbt.py

@notTanveer notTanveer left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review findings fixed in, 59908f5:

SilentPaymentsPSBT.sign_with() was crashing when it hit a key that couldn't sign. _resolve_sp_privkeys() was dropping the can_sign flag returned by resolve_signing_root(), so a public-only descriptor key ended up in resolve_input_privkey(), where hasattr(root, "secret") raises ArgumentError for a descriptor Key instead of just returning False.

PSBT.sign_with() should return 0 in this case. There was also a worse case with multi-key descriptors: if an earlier private key had already written ECDH shares, DLEQ proofs, and output scripts, hitting the public key would throw and leave the PSBT half-derived.

Also cleaned up a few related things:

  • Moved the duplicated _update_tx_modifiable logic from PSBT and PSBTView into a shared next_tx_modifiable().
  • Removed the dead tx_modifiable_flags block in PSBTView.sign_with(), which could only run after nothing was signed.
  • Removed the unused NETWORKS import.

@odudex

odudex commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Branch needs to be rebased, as a PSBTv2 related hardening was committed to master

notTanveer and others added 8 commits August 19, 2026 17:54
- replaced None with error messages.
- made Bech32DecodeError an EmbitError.
- Subclass EmbitError so existing except EmbitError callers still catch
  malformed-address errors (e.g. script.address_to_scriptpubkey).
- Restore the BIP-141 2-40 byte witness program limit in decode() and
  drop the sp/tsp special-casing; SP addresses are not witness programs.
- Replace the 118 length cap with BIP-352's recommended 1023 so valid
  higher-version silent payment addresses decode; cite BIP-173/BIP-352.
Co-authored-by: odudex <5049478+odudex@users.noreply.github.com>
Co-authored-by: odudex <5049478+odudex@users.noreply.github.com>
Co-authored-by: odudex <5049478+odudex@users.noreply.github.com>
Co-authored-by: odudex <5049478+odudex@users.noreply.github.com>
Co-authored-by: macgyver <4712150+macgyver@users.noreply.github.com>
Co-authored-by: odudex <5049478+odudex@users.noreply.github.com>
SilentPaymentsPSBT.sign_with() aborted with ArgumentError on a public-only
descriptor key. _resolve_sp_privkeys() discarded the can_sign flag from
resolve_signing_root(), so such a key reached resolve_input_privkey(), whose
hasattr(root, "secret") probe raises on a descriptor Key instead of returning
False; PSBT.sign_with() returns 0 in the same situation. With a multi-key
descriptor a private key earlier in root.keys had already written ECDH shares,
DLEQ proofs and output scripts before a later public key threw, leaving the
PSBT half-derived. Honour can_sign before the SP send path runs.

Also:

- Extract next_tx_modifiable() in psbt.py. PSBT and PSBTView each carried a
  byte-identical copy of the BIP-370 sighash -> flag rules, so a later
  correction to one would silently leave the constrained-device path wrong.
  Going through a pure function also removes a None &= -2 TypeError on a
  PSBTView built with version=2 and no global_kvs, whose flags setter no-ops.

- Drop the trailing tx_modifiable_flags block in PSBTView.sign_with(). Every
  path in sign_input() that adds a signature already updates the flags, so the
  block could only fire after signing nothing, stamping TX_MODIFIABLE=0 onto a
  PSBT this pass never touched. PSBT.sign_with() does not do this.

- Remove the unused NETWORKS import in silent_payments/sp.py.
… rebase

Rebasing feat/silent-payments-V2 onto upstream/master's PSBTv2 hardening
series (eb6104f) merged in upstream's declarative _validate_global_key,
V2_FIELDS/_validate_key scope checks, and _read_global_value eager-shape
validation, but left _validate_global_fields (canonical count-field
encoding, fixed-length checks, required-field presence) unwired in both
PSBT.read_from and PSBTView.view. Malformed PSBT_GLOBAL_INPUT_COUNT /
PSBT_GLOBAL_OUTPUT_COUNT values crashed with RuntimeError/ValueError
instead of PSBTError, non-canonical compact encodings went unrejected,
and PSBTView accepted a v2 PSBT missing PSBT_GLOBAL_TX_VERSION until
something later touched the lazy .tx_version property.

Wire _validate_global_fields into both parse paths, replacing the
now-duplicate manual v0/v2 field-exclusion checks it subsumes. All 25
of upstream's tests/tests/test_psbt_versions.py cases now pass.
@notTanveer
notTanveer force-pushed the feat/silent-payments-V2 branch from 137a493 to 839c5ab Compare August 19, 2026 14:31
BIP-352 makes H the single exception to "the sending wallet MUST have
access to the private key corresponding to the taproot output key", so a
NUMS internal key is the one PSBT field that can drop an input from
a_sum. It was believed on faith, and believing it is silent: an excluded
input never reaches _resolve_sp_privkeys to be missed, yet the signer
still key-path signs it, so the recipient - who scores eligibility from
the on-chain witness - derives a shared secret we never used and never
finds the payment.

The claim now counts only once it reproduces the scriptPubKey, which is
all the UTXO vouches for - the doctrine _p2sh_redeem_script and the
bip32 hash160 binding already apply. Unproven, the input stays eligible:
a false claim about an input we control then resolves normally, and a
true one has no key to resolve and aborts the send. A BIP-341 tweaked
NUMS lands there too, which is correct - such a transaction cannot be a
silent payment at all.

Also in this pass:

- Generate every DLEQ proof before writing any of them. A proof failure
  used to leave shares without proofs, a state validate_sp() rejects
  forever, so the PSBT could never be signed again.
- Restore the clear_metadata contract: KEEP_ALL is a no-op, and sp_data
  is dropped only once script_pubkey exists. Before the send it is the
  output's whole definition, and dropping it produced a PSBT that could
  no longer be parsed.
- Reject malformed and PSBTv0 global SP fields instead of retaining them
  as unknowns and re-serializing them verbatim, matching the input and
  output paths.
- Refuse derive_sp_outputs_from_keys() on a PSBT with no SP outputs
  instead of clearing the global shares and TX_MODIFIABLE and returning
  as if it had succeeded.
- Range-check the silent payment address version, which bech32_encode
  indexes CHARSET with: 32 raised a bare IndexError and -1 wrapped to
31.
- Drop the DLEQ verification claim from the validate_sp() docstring; the
  check needs A_sum, which the PSBT does not carry.
_verify_declared_sp()
  does it on the send path.
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.

7 participants