3838 PSBT_GLOBAL_FALLBACK_LOCKTIME , PSBT_GLOBAL_TX_VERSION , PSBT_IN_PREVIOUS_TXID ,
3939 PSBT_IN_OUTPUT_INDEX , PSBT_IN_SEQUENCE , PSBT_IN_REQUIRED_TIME_LOCKTIME ,
4040 PSBT_IN_REQUIRED_HEIGHT_LOCKTIME , MAX_SIGNERS ,
41+ PSBT_OUT_SP_V0_INFO , PSBT_OUT_SP_V0_LABEL ,
42+ PSBT_IN_SP_DLEQ , PSBT_IN_SP_ECDH_SHARE ,
43+ PSBT_GLOBAL_SP_DLEQ , PSBT_GLOBAL_SP_ECDH_SHARE ,
4144 AF_P2WSH , AF_P2WSH_P2SH , AF_P2SH , AF_P2TR , AF_P2WPKH , AF_CLASSIC , AF_P2WPKH_P2SH ,
4245 AFC_SEGWIT , AF_BARE_PK
4346)
47+ from silentpayments import SilentPaymentMixin
4448
4549psbt_tmp256 = bytearray (256 )
4650
@@ -290,6 +294,9 @@ def write(self, out_fd, ktype, val, key=b''):
290294
291295 def get (self , val ):
292296 # get the raw bytes for a value.
297+ ## Handle both in-memory values (bytes) and file coordinates (offset, length)
298+ if isinstance (val , (bytes , bytearray )):
299+ return val
293300 pos , ll = val
294301 self .fd .seek (pos )
295302 return self .fd .read (ll )
@@ -392,7 +399,9 @@ class psbtOutputProxy(psbtProxy):
392399
393400 blank_flds = ('unknown' , 'subpaths' , 'redeem_script' , 'witness_script' , 'sp_idxs' ,
394401 'is_change' , 'amount' , 'script' , 'attestation' , 'proprietary' ,
395- 'taproot_internal_key' , 'taproot_subpaths' , 'taproot_tree' , 'ik_idx' )
402+ 'taproot_internal_key' , 'taproot_subpaths' , 'taproot_tree' , 'ik_idx' ,
403+ 'sp_v0_info' , 'sp_v0_label' , # BIP-375 Silent Payments
404+ )
396405
397406 def __init__ (self , fd , idx ):
398407 super ().__init__ ()
@@ -454,6 +463,14 @@ def store(self, kt, key, val):
454463 self .taproot_subpaths .append ((key , val ))
455464 elif kt == PSBT_OUT_TAP_TREE :
456465 self .taproot_tree = val
466+ elif kt == PSBT_OUT_SP_V0_INFO :
467+ # BIP-375: Silent payment address (scan_key + spend_key)
468+ # val contains 66 bytes: scan_key (33) + spend_key (33)
469+ self .sp_v0_info = val
470+ elif kt == PSBT_OUT_SP_V0_LABEL :
471+ # BIP-375: Optional label for silent payment output
472+ # val contains 4-byte little-endian integer
473+ self .sp_v0_label = val
457474 else :
458475 self .unknown = self .unknown or []
459476 pos , length = key
@@ -487,6 +504,13 @@ def serialize(self, out_fd, is_v2):
487504 wr (PSBT_OUT_SCRIPT , self .script )
488505 wr (PSBT_OUT_AMOUNT , self .amount )
489506
507+ # BIP-375 Silent Payment fields
508+ if self .sp_v0_info :
509+ wr (PSBT_OUT_SP_V0_INFO , self .sp_v0_info )
510+
511+ if self .sp_v0_label :
512+ wr (PSBT_OUT_SP_V0_LABEL , self .sp_v0_label )
513+
490514 if self .proprietary :
491515 for k , v in self .proprietary :
492516 wr (PSBT_PROPRIETARY , v , k )
@@ -495,6 +519,7 @@ def serialize(self, out_fd, is_v2):
495519 for k , v in self .unknown :
496520 wr (None , v , k )
497521
522+
498523 def determine_my_change (self , out_idx , txo , parsed_subpaths , parent ):
499524 # Do things make sense for this output?
500525
@@ -636,6 +661,7 @@ class psbtInputProxy(psbtProxy):
636661 'taproot_merkle_root' , 'taproot_script_sigs' , 'taproot_scripts' , 'use_keypath' ,
637662 'taproot_subpaths' , 'taproot_internal_key' , 'taproot_key_sig' , 'tr_added_sigs' ,
638663 'ik_idx' ,
664+ 'sp_ecdh_shares' , 'sp_dleq_proofs' , # BIP-375 Silent Payments
639665 )
640666
641667 def __init__ (self , fd , idx ):
@@ -1058,6 +1084,18 @@ def store(self, kt, key, val):
10581084 self .req_time_locktime = unpack ("<I" , self .get (val ))[0 ]
10591085 elif kt == PSBT_IN_REQUIRED_HEIGHT_LOCKTIME :
10601086 self .req_height_locktime = unpack ("<I" , self .get (val ))[0 ]
1087+ elif kt == PSBT_IN_SP_ECDH_SHARE :
1088+ # BIP-375: Per-input ECDH share
1089+ # key contains scan_key (33 bytes), val contains ECDH share (33 bytes)
1090+ if self .sp_ecdh_shares is None :
1091+ self .sp_ecdh_shares = []
1092+ self .sp_ecdh_shares .append ((key , val ))
1093+ elif kt == PSBT_IN_SP_DLEQ :
1094+ # BIP-375: Per-input DLEQ proof
1095+ # key contains scan_key (33 bytes), val contains DLEQ proof (64 bytes)
1096+ if self .sp_dleq_proofs is None :
1097+ self .sp_dleq_proofs = []
1098+ self .sp_dleq_proofs .append ((key , val ))
10611099 else :
10621100 # including: PSBT_IN_FINAL_SCRIPTSIG, PSBT_IN_FINAL_SCRIPTWITNESS
10631101 self .unknown = self .unknown or []
@@ -1134,12 +1172,21 @@ def serialize(self, out_fd, is_v2):
11341172 if self .req_height_locktime is not None :
11351173 wr (PSBT_IN_REQUIRED_HEIGHT_LOCKTIME , pack ("<I" , self .req_height_locktime ))
11361174
1175+ # BIP-375 Silent Payment fields
1176+ if self .sp_ecdh_shares :
1177+ for k , v in self .sp_ecdh_shares :
1178+ wr (PSBT_IN_SP_ECDH_SHARE , v , k )
1179+
1180+ if self .sp_dleq_proofs :
1181+ for k , v in self .sp_dleq_proofs :
1182+ wr (PSBT_IN_SP_DLEQ , v , k )
1183+
11371184 if self .unknown :
11381185 for k , v in self .unknown :
11391186 wr (None , v , k )
11401187
11411188
1142- class psbtObject (psbtProxy ):
1189+ class psbtObject (psbtProxy , SilentPaymentMixin ):
11431190 "Just? parse and store"
11441191 short_values = { PSBT_GLOBAL_TX_MODIFIABLE }
11451192 no_keys = { PSBT_GLOBAL_UNSIGNED_TX }
@@ -1203,6 +1250,11 @@ def __init__(self):
12031250 self .has_gic = False # global input count
12041251 self .has_goc = False # global output count
12051252 self .has_gtv = False # global txn version
1253+
1254+ # BIP-375 Silent Payments: Global ECDH shares and DLEQ proofs
1255+ # Used when single signer owns all inputs
1256+ self .sp_global_ecdh_shares = None # List of (scan_key, ecdh_share) tuples
1257+ self .sp_global_dleq_proofs = None # List of (scan_key, dleq_proof) tuples
12061258
12071259 @property
12081260 def lock_time (self ):
@@ -1232,8 +1284,19 @@ def store(self, kt, key, val):
12321284 self .has_goc = True
12331285 elif kt == PSBT_GLOBAL_TX_MODIFIABLE :
12341286 # bytes of length 1 (tx modifiable in short_values)
1235- assert len (val ) == 1
1236- self .txn_modifiable = val [0 ]
1287+ self .txn_modifiable = self .get (val )[0 ]
1288+ elif kt == PSBT_GLOBAL_SP_ECDH_SHARE :
1289+ # BIP-375: Global ECDH share
1290+ # key contains scan_key (33 bytes), val contains ECDH share (33 bytes)
1291+ if self .sp_global_ecdh_shares is None :
1292+ self .sp_global_ecdh_shares = []
1293+ self .sp_global_ecdh_shares .append ((key , val ))
1294+ elif kt == PSBT_GLOBAL_SP_DLEQ :
1295+ # BIP-375: Global DLEQ proof
1296+ # key contains scan_key (33 bytes), val contains DLEQ proof (64 bytes)
1297+ if self .sp_global_dleq_proofs is None :
1298+ self .sp_global_dleq_proofs = []
1299+ self .sp_global_dleq_proofs .append ((key , val ))
12371300 else :
12381301 self .unknown = self .unknown or []
12391302 pos , length = key
@@ -1660,7 +1723,14 @@ async def validate(self):
16601723 if self .is_v2 :
16611724 # v2 requires inclusion
16621725 assert o .amount
1663- assert o .script
1726+ # BIP-375: Silent payment outputs don't have scripts until ECDH computation
1727+ # Scripts are derived during signing after ECDH shares are computed
1728+ if o .sp_v0_info :
1729+ # Use OP_RETURN with "SP" marker so UX shows "OP_RETURN - SP (pending) if multi-signer support is required"
1730+ # This is a placeholder; the real P2TR script is derived after ECDH computation
1731+ o .script = b'\x6a \x0c \x53 \x50 \x20 \x28 \x70 \x65 \x64 \x6e \x69 \x6e \x67 \x29 ' # OP_RETURN + PUSH 12 bytes + "SP (pending)"
1732+ else :
1733+ assert o .script , "PSBTv2 output missing script (not a silent payment)"
16641734 else :
16651735 # v0 requires exclusion
16661736 assert o .amount is None
@@ -2058,9 +2128,29 @@ def consider_inputs(self, cosign_xfp=None):
20582128
20592129 dis .progress_bar_show (1 )
20602130
2131+ # BIP-375: Validate Segwit version restrictions for silent payments
2132+ # Silent payment outputs cannot be mixed with inputs spending Segwit v>1
2133+ if self .has_silent_payment_outputs ():
2134+ for i , inp in enumerate (self .inputs ):
2135+ if not inp .utxo_spk :
2136+ continue
2137+
2138+ # Determine Segwit version from scriptPubKey
2139+ spk = inp .utxo_spk
2140+ if len (spk ) >= 2 and spk [0 ] >= 0x51 and spk [0 ] <= 0x60 :
2141+ # Witness version is OP_N where N = version
2142+ witness_version = spk [0 ] - 0x50
2143+
2144+ if witness_version > 1 :
2145+ raise FatalPSBTIssue (
2146+ "BIP-375 violation: Input #%d spends Segwit v%d output. "
2147+ "Silent payment outputs cannot be mixed with Segwit v>1 inputs." %
2148+ (i , witness_version ))
2149+
20612150 # useful info from all our parsed paths - will be validated against change outputs
20622151 return length_p , hard_pattern , prefix_p , idx_max
20632152
2153+
20642154 def consider_dangerous_sighash (self ):
20652155 # Check sighash flags are legal, useful, and safe. Warn about
20662156 # some risks if user has enabled special sighash values.
@@ -2164,6 +2254,15 @@ def serialize(self, out_fd, upgrade_txn=False):
21642254 for k , v in self .xpubs :
21652255 wr (PSBT_GLOBAL_XPUB , v , k )
21662256
2257+ # BIP-375: Global Silent Payment fields
2258+ if self .sp_global_ecdh_shares :
2259+ for k , v in self .sp_global_ecdh_shares :
2260+ wr (PSBT_GLOBAL_SP_ECDH_SHARE , v , k )
2261+
2262+ if self .sp_global_dleq_proofs :
2263+ for k , v in self .sp_global_dleq_proofs :
2264+ wr (PSBT_GLOBAL_SP_DLEQ , v , k )
2265+
21672266 if self .unknown :
21682267 for k , v in self .unknown :
21692268 wr (None , v , k )
@@ -2283,6 +2382,13 @@ def sign_it(self, alternate_secret=None, my_xfp=None):
22832382 "Deception regarding change output. "
22842383 "BIP-32 path doesn't match actual address." )
22852384
2385+
2386+ # BIP-375 Silent Payment Processing
2387+ # Process silent payment outputs before signing inputs
2388+ if self .has_silent_payment_outputs ():
2389+ ux_note = self .process_silent_payments_for_signing (sv , dis )
2390+ self .ux_notes .append (ux_note )
2391+
22862392 # progress
22872393 dis .fullscreen ('Signing...' )
22882394 # randomize secp context before each signing session
0 commit comments