Skip to content

Commit e065256

Browse files
committed
Implement Silent Payment PSBT Signing
1 parent 0c637e5 commit e065256

2 files changed

Lines changed: 122 additions & 5 deletions

File tree

shared/auth.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,7 @@ def render_output(self, o):
296296
#
297297
val = ' '.join(self.chain.render_value(o.nValue))
298298
try:
299+
# TODO: handle Silent Payment outputs
299300
dest = self.chain.render_address(o.scriptPubKey)
300301
# known script types are short enough that we can display QR on both hw versions
301302
return '%s\n - to address -\n%s\n' % (val, show_single_address(dest)), dest
@@ -369,6 +370,12 @@ async def interact(self):
369370

370371
ccc_c_xfp = CCCFeature.get_xfp() # can be None
371372
args = self.psbt.consider_inputs(cosign_xfp=ccc_c_xfp)
373+
374+
# BIP-375: Preview Silent Payment outputs if possible
375+
# This computes real addresses for single-signer scenarios
376+
if self.psbt.has_silent_payment_outputs():
377+
self.psbt.preview_silent_payment_outputs()
378+
372379
self.psbt.consider_outputs(*args, cosign_xfp=ccc_c_xfp)
373380
del args # not needed anymore
374381
# we can properly assess sighash only after we know
@@ -688,6 +695,10 @@ def output_summary_text(self, msg):
688695
total_change = 0
689696
has_change = False
690697

698+
# TODO: handle Silent Payment outputs - display appropiate message
699+
# Pay to silent payment address
700+
# Pay to silent payment address with change
701+
691702
for idx, tx_out in self.psbt.output_iter():
692703
outp = self.psbt.outputs[idx]
693704
if outp.is_change:

shared/psbt.py

Lines changed: 111 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,13 @@
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

4549
psbt_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

Comments
 (0)