Skip to content

Commit b1d4144

Browse files
author
kratm2
committed
integrate BIP21-style silent payment URI support
1 parent e7c962a commit b1d4144

File tree

6 files changed

+152
-47
lines changed

6 files changed

+152
-47
lines changed

electrum/bip21.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
from .util import format_satoshis_plain
88
from .bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC
99
from .lnaddr import lndecode, LnDecodeException
10-
from .silent_payment import is_silent_payment_address
10+
from .silent_payment import SilentPaymentAddress
11+
from . import constants
1112

1213
# note: when checking against these, use .lower() to support case-insensitivity
1314
BITCOIN_BIP21_URI_SCHEME = 'bitcoin'
@@ -17,6 +18,10 @@
1718
class InvalidBitcoinURI(Exception):
1819
pass
1920

21+
class MissingFallbackAddress(Exception):
22+
"""Raised when a fallback address was required but not provided in the BIP21 URI."""
23+
pass
24+
2025

2126
def parse_bip21_URI(uri: str) -> dict:
2227
"""Raises InvalidBitcoinURI on malformed URI."""
@@ -50,7 +55,7 @@ def parse_bip21_URI(uri: str) -> dict:
5055

5156
out = {k: v[0] for k, v in pq.items()}
5257
if address:
53-
if not bitcoin.is_address(address) and not is_silent_payment_address(address):
58+
if not bitcoin.is_address(address):
5459
raise InvalidBitcoinURI(f"Invalid bitcoin address: {address}")
5560
out['address'] = address
5661
if 'amount' in out:
@@ -101,6 +106,17 @@ def parse_bip21_URI(uri: str) -> dict:
101106
if ln_fallback_addr != address:
102107
raise InvalidBitcoinURI("Inconsistent lightning field in bip21: address")
103108

109+
# silent payment
110+
for key in ('sp', 'tsp'):
111+
if key in out:
112+
if key != constants.net.BIP352_HRP:
113+
raise InvalidBitcoinURI(
114+
f"Silent payment field '{key}' does not match expected network HRP '{constants.net.BIP352_HRP}'")
115+
try:
116+
SilentPaymentAddress(out[key]) # validates SP address
117+
except Exception as e:
118+
raise InvalidBitcoinURI(f"Failed to decode '{key}' field: {e!r}") from e
119+
104120
return out
105121

106122

electrum/gui/qt/send_tab.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ def on_amount_changed(self, text):
223223
is_spk_script = pi.type == PaymentIdentifierType.SPK and not pi.spk_is_address
224224
valid_amount = is_spk_script or bool(self.amount_e.get_amount())
225225
ready_to_finalize = not pi.need_resolve()
226-
sp_ok = self.wallet_can_send_sp if pi.involves_silent_payments() else True
226+
sp_ok = self.wallet_can_send_sp if pi.involves_silent_payments(self.wallet_can_send_sp) else True
227227
self.send_button.setEnabled(pi.is_valid() and not pi_error and valid_amount and ready_to_finalize and sp_ok)
228228

229229
def do_paste(self):
@@ -427,7 +427,7 @@ def update_fields(self):
427427

428428
self.clear_button.setEnabled(True)
429429

430-
involves_sp = pi.involves_silent_payments() # invoices involving silent payment are not persisted
430+
involves_sp = pi.involves_silent_payments(self.wallet_can_send_sp) # invoices involving silent payment are not persisted
431431
sp_ok = self.wallet_can_send_sp if involves_sp else True
432432

433433
if pi.is_multiline():
@@ -457,7 +457,7 @@ def update_fields(self):
457457
lock_max=lock_max,
458458
lock_description=False)
459459
if lock_recipient:
460-
fields = pi.get_fields_for_GUI()
460+
fields = pi.get_fields_for_GUI(bip21_prefer_fallback=not self.wallet_can_send_sp)
461461
if fields.recipient:
462462
self.payto_e.setText(fields.recipient)
463463
if fields.description:

electrum/payment_identifier.py

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,11 @@
1919
from .bitcoin import opcodes, construct_script
2020
from .lnaddr import LnInvoiceException
2121
from .lnutil import IncompatibleOrInsaneFeatures
22-
from .bip21 import parse_bip21_URI, InvalidBitcoinURI, LIGHTNING_URI_SCHEME, BITCOIN_BIP21_URI_SCHEME
22+
from .bip21 import parse_bip21_URI, InvalidBitcoinURI, LIGHTNING_URI_SCHEME, BITCOIN_BIP21_URI_SCHEME, \
23+
MissingFallbackAddress
2324
from . import paymentrequest
2425
from .silent_payment import is_silent_payment_address, SILENT_PAYMENT_DUMMY_SPK, SilentPaymentAddress
26+
from . import constants
2527

2628
if TYPE_CHECKING:
2729
from .wallet import Abstract_Wallet
@@ -176,7 +178,8 @@ def is_onchain(self):
176178
if self._type in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.BOLT11, PaymentIdentifierType.LNADDR]:
177179
return bool(self.bolt11) and bool(self.bolt11.get_address())
178180
if self._type == PaymentIdentifierType.BIP21:
179-
return bool(self.bip21.get('address', None)) or (bool(self.bolt11) and bool(self.bolt11.get_address()))
181+
return (bool(self.bip21.get('address', None)) or bool(self.bip21.get(constants.net.BIP352_HRP, None)) or
182+
(bool(self.bolt11) and bool(self.bolt11.get_address())))
180183

181184
def is_multiline(self):
182185
return bool(self.multiline_outputs)
@@ -268,8 +271,9 @@ def parse(self, text: str):
268271
self.bolt11.outputs = [PartialTxOutput.from_address_and_value(bip21_address, amount)]
269272
except InvoiceError as e:
270273
self.logger.debug(self._get_error_from_invoiceerror(e))
271-
elif not self.bip21.get('address'):
272-
# no address and no bolt11, invalid
274+
elif not self.bip21.get('address') and not self.bip21.get(constants.net.BIP352_HRP):
275+
print(self.bip21.get(constants.net.BIP352_HRP))
276+
# no address, no bolt11 and no silent payment address, invalid
273277
self.set_state(PaymentIdentifierState.INVALID)
274278
return
275279
self.set_state(PaymentIdentifierState.AVAILABLE)
@@ -464,7 +468,7 @@ async def _do_notify_merchant(
464468
if on_finished:
465469
on_finished(self)
466470

467-
def get_onchain_outputs(self, amount):
471+
def get_onchain_outputs(self, amount, bip21_use_fallback=False):
468472
if self.bip70:
469473
return self.bip70_data.get_outputs()
470474
elif self.multiline_outputs:
@@ -476,7 +480,17 @@ def get_onchain_outputs(self, amount):
476480
output.sp_addr = SilentPaymentAddress(addr)
477481
return [output]
478482
elif self.bip21:
479-
address = self.bip21.get('address')
483+
address = self.bip21.get('address') # fallback address if sp_address is present
484+
sp_address = self.bip21.get(constants.net.BIP352_HRP)
485+
486+
if sp_address:
487+
if bip21_use_fallback:
488+
if not address:
489+
raise MissingFallbackAddress('requested BIP21 fallback address but none was provided.')
490+
# fallback is requested and present, keep using `address`
491+
else:
492+
address = sp_address # use silent payment address
493+
480494
scriptpubkey, is_address = self.parse_output(address)
481495
assert is_address # unlikely, but make sure it is an address, not a script
482496
output = PartialTxOutput(scriptpubkey=scriptpubkey, value=amount)
@@ -581,7 +595,7 @@ def _get_error_from_invoiceerror(self, e: 'InvoiceError') -> str:
581595
error = _("Invoice requires unknown or incompatible Lightning feature") + f":\n{e!r}"
582596
return error
583597

584-
def get_fields_for_GUI(self) -> FieldsForGUI:
598+
def get_fields_for_GUI(self, *, bip21_prefer_fallback=False) -> FieldsForGUI:
585599
recipient = None
586600
amount = None
587601
description = None
@@ -633,6 +647,9 @@ def get_fields_for_GUI(self) -> FieldsForGUI:
633647
elif self.bip21:
634648
label = self.bip21.get('label')
635649
address = self.bip21.get('address')
650+
sp_address = self.bip21.get(constants.net.BIP352_HRP)
651+
if sp_address and not (address and bip21_prefer_fallback): # return fallback address if provided and needed
652+
address = sp_address
636653
recipient = f'{label} <{address}>' if label else address
637654
amount = self.bip21.get('amount')
638655
description = self.bip21.get('message')
@@ -680,9 +697,14 @@ def has_expired(self):
680697
return bool(expires) and expires < time.time()
681698
return False
682699

683-
def involves_silent_payments(self):
700+
def involves_silent_payments(self, wallet_can_send_sp=True) -> bool:
684701
try:
685-
return any(o.is_silent_payment() for o in self.get_onchain_outputs(0))
702+
return any(o.is_silent_payment() for o
703+
in self.get_onchain_outputs(0, bip21_use_fallback=not wallet_can_send_sp))
704+
except MissingFallbackAddress:
705+
# BIP21 URI contained only a silent payment address, and the wallet cannot send to it.
706+
# Since no fallback address was provided, we treat this as involving silent payments.
707+
return True
686708
except Exception as e:
687709
return False
688710

@@ -704,7 +726,7 @@ def invoice_from_payment_identifier(
704726
invoice.set_amount_msat(int(amount_sat * 1000))
705727
return invoice
706728
else:
707-
outputs = pi.get_onchain_outputs(amount_sat)
729+
outputs = pi.get_onchain_outputs(amount_sat, bip21_use_fallback=not wallet.can_send_silent_payment())
708730
message = pi.bip21.get('message') if pi.bip21 else message
709731
bip70_data = pi.bip70_data if pi.bip70 else None
710732
return wallet.create_invoice(

electrum/silent_payment.py

Lines changed: 34 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -14,40 +14,13 @@
1414

1515
class SilentPaymentUnsupportedWalletException(Exception): pass
1616

17-
class SilentPaymentAddress:
18-
"""
19-
Takes a silent payment address and decodes into keys. Raises if address is invalid.
20-
"""
21-
def __init__(self, address: str, *, net=None):
22-
if net is None: net = constants.net
23-
self._encoded = address
24-
self._B_Scan, self._B_Spend = _decode_silent_payment_addr(net.BIP352_HRP, address)
25-
26-
@property
27-
def encoded(self) -> str:
28-
return self._encoded
29-
30-
@property
31-
def B_Scan(self) -> ecc.ECPubkey:
32-
return self._B_Scan
33-
34-
@property
35-
def B_Spend(self) -> ecc.ECPubkey:
36-
return self._B_Spend
37-
38-
def __eq__(self, other):
39-
if not isinstance(other, SilentPaymentAddress):
40-
return NotImplemented
41-
return self.encoded == other.encoded
42-
43-
def __hash__(self):
44-
return hash(self.encoded)
45-
4617
class SilentPaymentException(Exception):
4718
def __init__(self, message: str):
4819
self.message = message
4920
super().__init__(message)
5021

22+
class InvalidSilentPaymentAddress(SilentPaymentException): pass
23+
5124
class SilentPaymentReuseException(SilentPaymentException):
5225
def __init__(self, reused_address: str):
5326
short_addr = reused_address[:8] + "…" + reused_address[-8:]
@@ -75,6 +48,38 @@ def __init__(self):
7548
)
7649
super().__init__(msg)
7750

51+
class SilentPaymentAddress:
52+
"""
53+
Takes a silent payment address and decodes into keys. Raises InvalidSilentPaymentAddress if address is invalid.
54+
"""
55+
def __init__(self, address: str, *, net=None):
56+
if net is None: net = constants.net
57+
try:
58+
self._B_Scan, self._B_Spend = _decode_silent_payment_addr(net.BIP352_HRP, address)
59+
except Exception as e:
60+
raise InvalidSilentPaymentAddress(address) from e
61+
self._encoded = address
62+
63+
@property
64+
def encoded(self) -> str:
65+
return self._encoded
66+
67+
@property
68+
def B_Scan(self) -> ecc.ECPubkey:
69+
return self._B_Scan
70+
71+
@property
72+
def B_Spend(self) -> ecc.ECPubkey:
73+
return self._B_Spend
74+
75+
def __eq__(self, other):
76+
if not isinstance(other, SilentPaymentAddress):
77+
return NotImplemented
78+
return self.encoded == other.encoded
79+
80+
def __hash__(self):
81+
return hash(self.encoded)
82+
7883
def create_silent_payment_outputs(input_privkeys: list[ECPrivkey],
7984
outpoints: list['TxOutpoint'],
8085
recipients: list[SilentPaymentAddress],

tests/test_payment_identifier.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import os
22

33
from electrum import SimpleConfig
4+
from electrum.bip21 import MissingFallbackAddress
45
from electrum.invoices import Invoice
56
from electrum.payment_identifier import (maybe_extract_lightning_payment_identifier, PaymentIdentifier,
67
PaymentIdentifierType, invoice_from_payment_identifier)
@@ -379,10 +380,30 @@ def test_silent_payment_multiline(self):
379380
self.assertEqual('3!', pi.multiline_outputs[2].value)
380381

381382
def test_silent_payment_bip21(self):
382-
bip21 = 'bitcoin:sp1qqvwfct0plnus9vnyd08tvvcwq49g7xfjt3fnwcyu5zc29fj969fg7q4ffc6dnhl9anhec779az46rstpp0t6kzxqmg4tkelfhrejl532ycfaxvsj?message=sp_unit_test?amount=0.001'
383+
# The PaymentIdentifier is considered valid if the BIP21 URI itself is valid,
384+
# regardless of whether the wallet is silent payment-capable.
385+
386+
# test no fallback
387+
bip21 = 'bitcoin:?sp=sp1qqvwfct0plnus9vnyd08tvvcwq49g7xfjt3fnwcyu5zc29fj969fg7q4ffc6dnhl9anhec779az46rstpp0t6kzxqmg4tkelfhrejl532ycfaxvsj&message=sp_unit_test&amount=0.001'
383388
pi = PaymentIdentifier(None, bip21)
384389
self.assertTrue(pi.is_available())
385-
self.assertFalse(pi.is_lightning())
386390
self.assertTrue(pi.is_onchain())
387391
self.assertIsNotNone(pi.bip21)
388-
self.assertTrue(pi.involves_silent_payments())
392+
self.assertTrue(pi.involves_silent_payments(True)) # wallet is sp-capable
393+
self.assertTrue(pi.involves_silent_payments(False)) # wallet is not sp-capable, but there is no fallback -> involves
394+
# Raise in get_onchain_outputs if fallback is requested but non is present
395+
self.assertRaises(MissingFallbackAddress, pi.get_onchain_outputs, 0, bip21_use_fallback=True)
396+
397+
# test with fallback in context where wallet is silent payment capable
398+
bip21 = 'bitcoin:1RustyRX2oai4EYYDpQGWvEL62BBGqN9T?sp=sp1qqvwfct0plnus9vnyd08tvvcwq49g7xfjt3fnwcyu5zc29fj969fg7q4ffc6dnhl9anhec779az46rstpp0t6kzxqmg4tkelfhrejl532ycfaxvsj&message=sp_unit_test&amount=0.001'
399+
pi = PaymentIdentifier(None, bip21)
400+
self.assertTrue(pi.is_available())
401+
self.assertTrue(pi.is_onchain())
402+
self.assertIsNotNone(pi.bip21)
403+
self.assertTrue(pi.involves_silent_payments(True)) # wallet is sp-capable, so fallback is ignored
404+
self.assertFalse(pi.involves_silent_payments(False)) # wallet is not sp-capable, so fallback is taken -> no sp-involvement
405+
# make sure fallback is taken from get_onchain_outputs if wallet can not send sp
406+
self.assertEqual(
407+
pi.get_onchain_outputs(0, bip21_use_fallback=True)[0].address,
408+
'1RustyRX2oai4EYYDpQGWvEL62BBGqN9T'
409+
)

tests/test_util.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,47 @@ def test_parse_URI_lightning_consistency(self):
187187
# bip21 uri that includes "lightning" key with garbage unparsable value
188188
self.assertRaises(InvalidBitcoinURI, parse_bip21_URI, 'bitcoin:tb1qu5ua3szskclyd48wlfdwfd32j65phxy9yf7ytl?amount=0.0008&message=test266&lightning=lntb700u1p3kqy26pp5l7rj7w0u5sdsj24umzdlhdasdasdasdasd')
189189

190+
def test_parse_URI_sp_with_no_fallback(self):
191+
self._do_test_parse_URI(
192+
'bitcoin:?sp=sp1qqvwfct0plnus9vnyd08tvvcwq49g7xfjt3fnwcyu5zc29fj969fg7q4ffc6dnhl9anhec779az46rstpp0t6kzxqmg4tkelfhrejl532ycfaxvsj&amount=0.001',
193+
{ 'sp': 'sp1qqvwfct0plnus9vnyd08tvvcwq49g7xfjt3fnwcyu5zc29fj969fg7q4ffc6dnhl9anhec779az46rstpp0t6kzxqmg4tkelfhrejl532ycfaxvsj', 'amount': 100000}
194+
)
195+
196+
def test_parse_URI_sp_with_fallback(self):
197+
self._do_test_parse_URI(
198+
'bitcoin:15mKKb2eos1hWa6tisdPwwDC1a5J1y9nma?sp=sp1qqvwfct0plnus9vnyd08tvvcwq49g7xfjt3fnwcyu5zc29fj969fg7q4ffc6dnhl9anhec779az46rstpp0t6kzxqmg4tkelfhrejl532ycfaxvsj&amount=0.001',
199+
{ 'address': '15mKKb2eos1hWa6tisdPwwDC1a5J1y9nma', 'sp': 'sp1qqvwfct0plnus9vnyd08tvvcwq49g7xfjt3fnwcyu5zc29fj969fg7q4ffc6dnhl9anhec779az46rstpp0t6kzxqmg4tkelfhrejl532ycfaxvsj', 'amount': 100000}
200+
)
201+
202+
def test_parse_URI_invalid_sp_addresses(self):
203+
# hrp / network mismatch
204+
self.assertRaises(InvalidBitcoinURI, parse_bip21_URI, 'bitcoin:?sp=tsp1qq0aut0j4rpngmjf55a6nr98h0kvlc2s83jwv8h9rmaqpgjqvc67wyqefkstjcfchzd9hpy84qara7dwu6jfpx2p9amjwg4j4hv3hsla8nvqa4ap0')
205+
self.assertRaises(InvalidBitcoinURI, parse_bip21_URI, 'bitcoin:?tsp=sp1qqvwfct0plnus9vnyd08tvvcwq49g7xfjt3fnwcyu5zc29fj969fg7q4ffc6dnhl9anhec779az46rstpp0t6kzxqmg4tkelfhrejl532ycfaxvsj')
206+
# invalid sp addr
207+
self.assertRaises(InvalidBitcoinURI, parse_bip21_URI, 'bitcoin:?sp=sp_invalid_sp')
208+
209+
210+
@as_testnet
211+
def test_parse_URI_testnet_sp_with_no_fallback(self):
212+
self._do_test_parse_URI(
213+
'bitcoin:?tsp=tsp1qq0aut0j4rpngmjf55a6nr98h0kvlc2s83jwv8h9rmaqpgjqvc67wyqefkstjcfchzd9hpy84qara7dwu6jfpx2p9amjwg4j4hv3hsla8nvqa4ap0&amount=0.001',
214+
{
215+
'tsp': 'tsp1qq0aut0j4rpngmjf55a6nr98h0kvlc2s83jwv8h9rmaqpgjqvc67wyqefkstjcfchzd9hpy84qara7dwu6jfpx2p9amjwg4j4hv3hsla8nvqa4ap0',
216+
'amount': 100000
217+
}
218+
)
219+
220+
@as_testnet
221+
def test_parse_URI_testnet_sp_with_fallback(self):
222+
self._do_test_parse_URI(
223+
'bitcoin:tb1qesj6xuz9963tca9y8e09dpwu8a48t3nmvs26je?tsp=tsp1qq0aut0j4rpngmjf55a6nr98h0kvlc2s83jwv8h9rmaqpgjqvc67wyqefkstjcfchzd9hpy84qara7dwu6jfpx2p9amjwg4j4hv3hsla8nvqa4ap0&amount=0.001',
224+
{
225+
'address': 'tb1qesj6xuz9963tca9y8e09dpwu8a48t3nmvs26je',
226+
'tsp': 'tsp1qq0aut0j4rpngmjf55a6nr98h0kvlc2s83jwv8h9rmaqpgjqvc67wyqefkstjcfchzd9hpy84qara7dwu6jfpx2p9amjwg4j4hv3hsla8nvqa4ap0',
227+
'amount': 100000
228+
}
229+
)
230+
190231
def test_is_hash256_str(self):
191232
self.assertTrue(is_hash256_str('09a4c03e3bdf83bbe3955f907ee52da4fc12f4813d459bc75228b64ad08617c7'))
192233
self.assertTrue(is_hash256_str('2A5C3F4062E4F2FCCE7A1C7B4310CB647B327409F580F4ED72CB8FC0B1804DFA'))

0 commit comments

Comments
 (0)