77import chains
88import ngu
99import stash
10+ from glob import settings
1011from dleq import generate_dleq_proof , verify_dleq_proof
11- from exceptions import FatalPSBTIssue
12+ from exceptions import FatalPSBTIssue , FraudulentChangeOutput
1213from precomp_tag_hash import (
14+ BIP352_LABEL_TAG_H ,
1315 BIP352_SHARED_SECRET_TAG_H ,
1416 BIP352_INPUTS_TAG_H ,
1517 TAP_TWEAK_H ,
@@ -46,41 +48,34 @@ def encode_silent_payment_address(scan_key, spend_key, version=0):
4648 return ngu .codecs .bip352_encode (hrp , scan_key , spend_key , version )
4749
4850
49- def validate_bip376_spend (input , output_xonly , my_xfp = None , parent = None ):
50- """Validate silent payment spend using PSBT input data
51-
52- TODO: replace with test vectors once available
53-
54- Raises FatalPSBTIssue if any SP spend validation checks fail
55- """
56- B_spend_coords , val_coords = input .sp_spend_bip32_derivation
57- xfp_path = input .parse_xfp_path (val_coords )
58- if my_xfp is not None :
59- xfp_path = input .handle_zero_xfp (xfp_path , my_xfp , parent )
60-
61- sp_path = xfp_path [1 :]
62- if len (sp_path ) != 5 :
63- raise FatalPSBTIssue ("SP spend path must have 5 components" )
64- if sp_path [0 ] != (352 | 0x80000000 ):
65- raise FatalPSBTIssue ("SP spend key purpose must use 352h path" )
66-
67- expected_coin = chains .current_chain ().b44_cointype | 0x80000000
68- if sp_path [1 ] != expected_coin :
69- raise FatalPSBTIssue ("SP spend path coin type does not match network" )
70- if not (sp_path [2 ] & 0x80000000 ):
71- raise FatalPSBTIssue ("SP spend path account must be hardened" )
72- if sp_path [3 ] != 0x80000000 :
73- raise FatalPSBTIssue ("SP spend path key type must be 0h" )
74-
75- B_spend = input .get (B_spend_coords )
76- if _compute_silent_payment_spending_xonly (B_spend , input .sp_tweak ) != output_xonly :
77- raise FatalPSBTIssue ("SP_TWEAK does not match UTXO output key" )
51+ def sp_derive_path (coin_type , account ):
52+ """BIP-352 base derivation path for a given coin type and account number"""
53+ return "m/352h/%dh/%dh" % (coin_type , account )
7854
7955
8056# -----------------------------------------------------------------------------
8157# Silent Payments Cryptographic Primitives
8258# -----------------------------------------------------------------------------
8359
60+ def _apply_label_to_spend_key (B_spend_bytes , b_scan_bytes , label ):
61+ """
62+ Apply BIP-352 label tweak to spend key
63+
64+ Formula: B_m = B_spend + hash_BIP0352/Label(b_scan || m)*G
65+
66+ Args:
67+ B_spend_bytes: Base spend public key (33-byte compressed)
68+ b_scan_bytes: Scan private key (32-byte scalar)
69+ label: Label integer (0 for change)
70+
71+ Returns:
72+ bytes: Labeled spend public key (33-byte compressed)
73+ """
74+ msg = b_scan_bytes + label .to_bytes (4 , 'big' )
75+ label_tweak_bytes = ngu .hash .sha256t (BIP352_LABEL_TAG_H , msg , True )
76+ label_tweak_point = ngu .secp256k1 .ec_pubkey_tweak_mul (G , label_tweak_bytes )
77+ return ngu .secp256k1 .ec_pubkey_combine ([B_spend_bytes , label_tweak_point ])
78+
8479
8580def _combine_pubkeys (pubkeys ):
8681 """
@@ -294,21 +289,19 @@ class SilentPaymentsMixin:
294289 This class assumes it is mixed into psbtObject and has access to psbt as self
295290 """
296291
297- def process_silent_payments (self , sv ):
292+ def process_silent_payment_outputs (self , sv ):
298293 """
299294 Core SP workflow: validate, compute shares, compute scripts
300295
301296 Notes:
302- - This function is intended to be called during the preview phase, before signing
297+ - This function is intended to be called during the preview phase and again during signing
303298 - Single-signer should be able to generate output and preview immediately
304299 - Multi-signer should generate shares and prompt user to collect all shares if not complete
305300
306301 Returns:
307302 bool: True if output scripts were computed and are ready for preview/signing
308- False if we generated shares but are waiting on others, or if we don't have necessary info to proceed
303+ False if we generated shares but are waiting on others
309304 """
310- if not self .has_silent_payment_outputs ():
311- return False
312305 self ._validate_psbt_structure ()
313306 self ._validate_input_eligibility ()
314307 self ._validate_ecdh_coverage ()
@@ -319,7 +312,7 @@ def process_silent_payments(self, sv):
319312 if self ._is_ecdh_coverage_complete ():
320313 # Computes scripts, or validates existing ones against recomputed values
321314 self ._compute_silent_payment_output_scripts ()
322- self ._detect_sp_change_outputs ()
315+ self ._detect_sp_change_outputs (sv )
323316 return self ._all_sp_outputs_have_scripts ()
324317 return False
325318
@@ -341,6 +334,63 @@ def render_silent_payment_output_string(self, output):
341334
342335 return " - silent payments address -\n %s\n " % encode_silent_payment_address (scan_key , spend_key )
343336
337+ def validate_silent_payment_inputs (self , sv ):
338+ """
339+ Validate inputs spending silent payments (BIP-376)
340+
341+ Raises FatalPSBTIssue on any failure.
342+ """
343+ coin_type = chains .current_chain ().b44_cointype
344+ expected_coin = coin_type | 0x80000000
345+
346+ candidate_accounts = self ._get_candidate_sp_accounts ()
347+
348+ for i , inp in enumerate (self .inputs ):
349+ if not inp .is_sp_spend :
350+ continue
351+
352+ # Validate path shape and extract B_spend for both our and foreign inputs
353+ B_spend_coords , val_coords = inp .sp_spend_bip32_derivation
354+ xfp_path = inp .parse_xfp_path (val_coords )
355+ xfp_path = inp .handle_zero_xfp (xfp_path , self .my_xfp , self )
356+
357+ sp_path = xfp_path [1 :]
358+ if len (sp_path ) != 5 :
359+ raise FatalPSBTIssue ("Input #%d: SP spend path must have 5 components" % i )
360+ if sp_path [0 ] != (352 | 0x80000000 ):
361+ raise FatalPSBTIssue ("Input #%d: SP spend key purpose must use 352h path" % i )
362+ if sp_path [1 ] != expected_coin :
363+ raise FatalPSBTIssue ("Input #%d: SP spend path coin type does not match network" % i )
364+ if not (sp_path [2 ] & 0x80000000 ):
365+ raise FatalPSBTIssue ("Input #%d: SP spend path account must be hardened" % i )
366+ if sp_path [3 ] != 0x80000000 :
367+ raise FatalPSBTIssue ("Input #%d: SP spend path key type must be 0h" % i )
368+ if sp_path [4 ] != 0 :
369+ raise FatalPSBTIssue ("Input #%d: SP spend path index must be 0" % i )
370+
371+ B_spend = inp .get (B_spend_coords )
372+
373+ # For our inputs, validate account is known and B_spend matches derivation from this device
374+ if xfp_path [0 ] == self .my_xfp :
375+ account = sp_path [2 ] & ~ 0x80000000
376+ if account not in candidate_accounts :
377+ raise FatalPSBTIssue (
378+ "Input #%d: SP spend account not recognized on this device" % i )
379+
380+ spend_node = sv .derive_path (
381+ sp_derive_path (coin_type , account ) + "/0h/0" , register = False )
382+ derived = spend_node .pubkey ()
383+ del spend_node
384+ if derived != B_spend :
385+ raise FatalPSBTIssue ("Input #%d: SP spend pubkey mismatch" % i )
386+
387+ spk = inp .utxo_spk
388+ if not spk or not _is_p2tr (spk ):
389+ raise FatalPSBTIssue ("Input #%d: SP spend UTXO is not P2TR" % i )
390+ utxo_xonly = spk [2 :34 ]
391+ if _compute_silent_payment_spending_xonly (B_spend , inp .sp_tweak ) != utxo_xonly :
392+ raise FatalPSBTIssue ("Input #%d: SP_TWEAK does not match UTXO output key" % i )
393+
344394 # -----------------------------------------------------------------------------
345395 # Input Helper Functions
346396 # -----------------------------------------------------------------------------
@@ -613,9 +663,6 @@ def _compute_and_store_ecdh_shares(self, sv):
613663 """
614664 Compute ECDH shares and DLEQ proofs for our inputs, store in PSBT fields
615665
616- Notes:
617- Sets self.sp_all_inputs_ours for callers
618-
619666 Returns:
620667 bool: True if shares were computed
621668 False if we have no signable inputs
@@ -644,14 +691,12 @@ def _compute_and_store_ecdh_shares(self, sv):
644691 raise FatalPSBTIssue ("No eligible inputs for ECDH computation" )
645692 return False
646693
647- self .sp_all_inputs_ours = not has_foreign
648-
649694 scan_keys = self ._get_silent_payment_scan_keys ()
650695 if not scan_keys :
651696 return False
652697
653698 for scan_key in scan_keys :
654- if self . sp_all_inputs_ours :
699+ if not has_foreign :
655700 # Single-signer: combine all input private keys, one global ECDH share and DLEQ proofs
656701 combined_sk = _sum_privkeys (sk for _ , sk in input_material )
657702 ecdh_share = _compute_ecdh_share (combined_sk , scan_key )
@@ -714,6 +759,10 @@ def _compute_silent_payment_output_scripts(self):
714759 # Utility Functions
715760 # -----------------------------------------------------------------------------
716761
762+ def has_silent_payment_inputs (self ):
763+ """Check if PSBT contains any silent payment inputs"""
764+ return any (inp .is_sp_spend for inp in self .inputs )
765+
717766 def has_silent_payment_outputs (self ):
718767 """
719768 Check if PSBT contains any silent payment outputs
@@ -733,17 +782,59 @@ def _all_sp_outputs_have_scripts(self):
733782 return False
734783 return True
735784
736- def _detect_sp_change_outputs (self ):
785+ def _detect_sp_change_outputs (self , sv ):
737786 """
738- Mark SP outputs as change if sp_v0_label is present and equals 0 (BIP-352 change convention)
787+ Mark SP outputs as change if they match our accounts
739788
740- No return value; modifies self.outputs 'is_change' in place
789+ Raises FraudulentChangeOutput if all SP-eligible inputs are ours (single-signer);
790+ multi-signer, a non-matching label=0 output is skipped (may belong to a co-signer).
741791 """
742- for outp in self .outputs :
792+ has_foreign = False
793+ for inp in self .inputs :
794+ if not self ._is_input_eligible (inp ):
795+ continue
796+ if inp .sp_idxs :
797+ if self ._derive_input_privkey (inp , sv ) is None :
798+ has_foreign = True
799+ break
800+ elif self ._pubkey_from_input (inp ):
801+ has_foreign = True
802+ break
803+
804+ coin_type = chains .current_chain ().b44_cointype
805+ candidate_accounts = self ._get_candidate_sp_accounts ()
806+
807+ for out_idx , outp in enumerate (self .outputs ):
743808 if not outp .sp_v0_info or not outp .sp_v0_label :
744809 continue
745- if int .from_bytes (outp .sp_v0_label , "little" ) == 0 :
746- outp .is_change = True
810+ if int .from_bytes (outp .sp_v0_label , "little" ) != 0 :
811+ continue
812+
813+ scan_pub = outp .sp_v0_info [:33 ]
814+ spend_pub = outp .sp_v0_info [33 :66 ]
815+
816+ matched = False
817+ for account in candidate_accounts :
818+ base = sp_derive_path (coin_type , account )
819+ scan_node = sv .derive_path (base + "/1h/0" , register = False )
820+ spend_node = sv .derive_path (base + "/0h/0" , register = False )
821+ label_spend_pub = _apply_label_to_spend_key (spend_node .pubkey (), scan_node .privkey (), 0 )
822+ matched = (scan_node .pubkey () == scan_pub ) and (label_spend_pub == spend_pub )
823+ del scan_node , spend_node
824+ if matched :
825+ outp .is_change = True
826+ break
827+
828+ if not matched and not has_foreign :
829+ raise FraudulentChangeOutput (out_idx , "SP change output keys do not match this device" )
830+
831+ def _get_candidate_sp_accounts (self ):
832+ """Get all known account numbers from device settings, always include account 0"""
833+ seen = {0 }
834+ for _af , acct_num in settings .get ('accts' , []):
835+ if acct_num :
836+ seen .add (acct_num )
837+ return sorted (seen )
747838
748839 def _get_outpoints (self ):
749840 """
0 commit comments