From 2cd1de4f21cf807906004ba68d021a521032cb3c Mon Sep 17 00:00:00 2001 From: f321x Date: Sun, 21 Dec 2025 16:18:09 +0100 Subject: [PATCH 1/2] pi: fix incorrectly parsing emaillike with 'ln' prefix Fixes a bug where we incorrectly parsed emaillike payment identifiers as bech32 lightning payment identifier if they start with a 'ln' prefix. --- electrum/payment_identifier.py | 31 +++++----- tests/test_payment_identifier.py | 99 +++++++++++++++++++------------- 2 files changed, 74 insertions(+), 56 deletions(-) diff --git a/electrum/payment_identifier.py b/electrum/payment_identifier.py index 725dc44f7..72f8e53a3 100644 --- a/electrum/payment_identifier.py +++ b/electrum/payment_identifier.py @@ -21,6 +21,7 @@ from .bitcoin import opcodes, construct_script from .lnaddr import LnInvoiceException from .lnutil import IncompatibleOrInsaneFeatures from .bip21 import parse_bip21_URI, InvalidBitcoinURI, LIGHTNING_URI_SCHEME, BITCOIN_BIP21_URI_SCHEME +from .segwit_addr import bech32_decode from . import paymentrequest if TYPE_CHECKING: @@ -28,23 +29,21 @@ if TYPE_CHECKING: from .transaction import Transaction -def maybe_extract_lightning_payment_identifier(data: str) -> Optional[str]: - data = data.strip() # whitespaces - data = data.lower() - if data.startswith(LIGHTNING_URI_SCHEME + ':ln'): - cut_prefix = LIGHTNING_URI_SCHEME + ':' - data = data[len(cut_prefix):] - if data.startswith('ln'): - return data - return None +def maybe_extract_bech32_lightning_payment_identifier(data: str) -> Optional[str]: + data = remove_uri_prefix(data, prefix=LIGHTNING_URI_SCHEME) + if not data.startswith('ln'): + return None + decoded_bech32 = bech32_decode(data, ignore_long_length=True) + if not decoded_bech32.hrp or not decoded_bech32.data: + return None + return data -def is_uri(data: str) -> bool: - data = data.lower() - if (data.startswith(LIGHTNING_URI_SCHEME + ":") or - data.startswith(BITCOIN_BIP21_URI_SCHEME + ':')): - return True - return False +def remove_uri_prefix(data: str, *, prefix: str) -> str: + assert isinstance(data, str) and isinstance(prefix, str) + data = data.lower().strip() + data = data.removeprefix(prefix + ':') + return data RE_ALIAS = r'(.*?)\s*\<([0-9A-Za-z]{1,})\>' @@ -225,7 +224,7 @@ class PaymentIdentifier(Logger): self.set_state(PaymentIdentifierState.INVALID) else: self.set_state(PaymentIdentifierState.AVAILABLE) - elif invoice_or_lnurl := maybe_extract_lightning_payment_identifier(text): + elif invoice_or_lnurl := maybe_extract_bech32_lightning_payment_identifier(text): if invoice_or_lnurl.startswith('lnurl'): self._type = PaymentIdentifierType.LNURL try: diff --git a/tests/test_payment_identifier.py b/tests/test_payment_identifier.py index 04f59d93b..e44f9067f 100644 --- a/tests/test_payment_identifier.py +++ b/tests/test_payment_identifier.py @@ -4,9 +4,10 @@ from unittest.mock import patch from electrum import SimpleConfig from electrum.invoices import Invoice -from electrum.payment_identifier import (maybe_extract_lightning_payment_identifier, PaymentIdentifier, - PaymentIdentifierType, PaymentIdentifierState, - invoice_from_payment_identifier) +from electrum.payment_identifier import ( + maybe_extract_bech32_lightning_payment_identifier, PaymentIdentifier, PaymentIdentifierType, + PaymentIdentifierState, invoice_from_payment_identifier, remove_uri_prefix, +) from electrum.lnurl import LNURL6Data, LNURL3Data, LNURLError from electrum.transaction import PartialTxOutput @@ -34,18 +35,37 @@ class TestPaymentIdentifier(ElectrumTestCase): }) self.wallet2_path = os.path.join(self.electrum_path, "somewallet2") - def test_maybe_extract_lightning_payment_identifier(self): + def test_maybe_extract_bech32_lightning_payment_identifier(self): bolt11 = "lnbc1ps9zprzpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygsdqq9qypqszpyrpe4tym8d3q87d43cgdhhlsrt78epu7u99mkzttmt2wtsx0304rrw50addkryfrd3vn3zy467vxwlmf4uz7yvntuwjr2hqjl9lw5cqwtp2dy" lnurl = "lnurl1dp68gurn8ghj7um9wfmxjcm99e5k7telwy7nxenrxvmrgdtzxsenjcm98pjnwxq96s9" - self.assertEqual(bolt11, maybe_extract_lightning_payment_identifier(f"{bolt11}".upper())) - self.assertEqual(bolt11, maybe_extract_lightning_payment_identifier(f"lightning:{bolt11}")) - self.assertEqual(bolt11, maybe_extract_lightning_payment_identifier(f" lightning:{bolt11} ".upper())) - self.assertEqual(lnurl, maybe_extract_lightning_payment_identifier(lnurl)) - self.assertEqual(lnurl, maybe_extract_lightning_payment_identifier(f" lightning:{lnurl} ".upper())) + self.assertEqual(bolt11, maybe_extract_bech32_lightning_payment_identifier(f"{bolt11}".upper())) + self.assertEqual(bolt11, maybe_extract_bech32_lightning_payment_identifier(f"lightning:{bolt11}")) + self.assertEqual(bolt11, maybe_extract_bech32_lightning_payment_identifier(f" lightning:{bolt11} ".upper())) + self.assertEqual(lnurl, maybe_extract_bech32_lightning_payment_identifier(lnurl)) + self.assertEqual(lnurl, maybe_extract_bech32_lightning_payment_identifier(f" lightning:{lnurl} ".upper())) - self.assertEqual(None, maybe_extract_lightning_payment_identifier(f"bitcoin:{bolt11}")) - self.assertEqual(None, maybe_extract_lightning_payment_identifier(f":{bolt11}")) - self.assertEqual(None, maybe_extract_lightning_payment_identifier(f"garbage text")) + self.assertEqual(None, maybe_extract_bech32_lightning_payment_identifier(f"bitcoin:{bolt11}")) + self.assertEqual(None, maybe_extract_bech32_lightning_payment_identifier(f":{bolt11}")) + self.assertEqual(None, maybe_extract_bech32_lightning_payment_identifier(f"garbage text")) + + def test_remove_uri_prefix(self): + lightning, bitcoin = 'lightning', 'bitcoin' + tests = ( + (lightning, '', ''), + (lightning, 'lightning:test', 'test'), + (lightning, 'bitcoin:test', 'bitcoin:test'), + (lightning, 'lightningtest', 'lightningtest'), + (lightning, 'lightning test', 'lightning test'), + (bitcoin, 'lightning:test', 'lightning:test'), + (bitcoin, 'bitcoin:test', 'test'), + (bitcoin, 'bitcoin', 'bitcoin'), + (bitcoin, 'bitcoin:', ''), + ) + for prefix, input_str, expected_output_str in tests: + output_str = remove_uri_prefix(input_str, prefix=prefix) + self.assertEqual(expected_output_str, output_str, msg=output_str) + with self.assertRaises(AssertionError): + remove_uri_prefix(data=1234, prefix="test") def test_bolt11(self): # no amount, no fallback address @@ -337,35 +357,34 @@ class TestPaymentIdentifier(ElectrumTestCase): self.assertTrue(pi.is_available()) def test_email_and_domain(self): - pi_str = 'some.domain' - pi = PaymentIdentifier(None, pi_str) - self.assertTrue(pi.is_valid()) - self.assertEqual(PaymentIdentifierType.DOMAINLIKE, pi.type) - self.assertFalse(pi.is_available()) - self.assertTrue(pi.need_resolve()) - - pi_str = 'some.weird.but.valid.domain' - pi = PaymentIdentifier(None, pi_str) - self.assertTrue(pi.is_valid()) - self.assertEqual(PaymentIdentifierType.DOMAINLIKE, pi.type) - self.assertFalse(pi.is_available()) - self.assertTrue(pi.need_resolve()) - - pi_str = 'user@some.domain' - pi = PaymentIdentifier(None, pi_str) - self.assertTrue(pi.is_valid()) - self.assertEqual(PaymentIdentifierType.EMAILLIKE, pi.type) - self.assertFalse(pi.is_available()) - self.assertTrue(pi.need_resolve()) - - pi_str = 'user@some.weird.but.valid.domain' - pi = PaymentIdentifier(None, pi_str) - self.assertTrue(pi.is_valid()) - self.assertEqual(PaymentIdentifierType.EMAILLIKE, pi.type) - self.assertFalse(pi.is_available()) - self.assertTrue(pi.need_resolve()) - # TODO resolve mock + domain_pi_strings = ( + 'some.domain', + 'some.weird.but.valid.domain', + 'lnbcsome.weird.but.valid.domain', + 'bc1qsome.weird.but.valid.domain', + 'lnurlsome.weird.but.valid.domain', + ) + for pi_str in domain_pi_strings: + pi = PaymentIdentifier(None, pi_str) + self.assertTrue(pi.is_valid()) + self.assertEqual(PaymentIdentifierType.DOMAINLIKE, pi.type) + self.assertFalse(pi.is_available()) + self.assertTrue(pi.need_resolve()) + + email_pi_strings = ( + 'user@some.domain', + 'user@some.weird.but.valid.domain', + 'lnbcuser@some.domain', + 'lnurluser@some.domain', + 'bc1quser@some.domain', + ) + for pi_str in email_pi_strings: + pi = PaymentIdentifier(None, pi_str) + self.assertTrue(pi.is_valid()) + self.assertEqual(PaymentIdentifierType.EMAILLIKE, pi.type) + self.assertFalse(pi.is_available()) + self.assertTrue(pi.need_resolve()) def test_bip70(self): pi_str = 'bitcoin:?r=https://test.bitpay.com/i/87iLJoaYVyJwFXtdassQJv' From dd1d98e37c61a32e217b9379a85e069db484b68c Mon Sep 17 00:00:00 2001 From: f321x Date: Mon, 22 Dec 2025 10:04:24 +0100 Subject: [PATCH 2/2] pi: allow emaillike pi with 'lightning:' prefix Lightning addresses with 'lightning:' do occur in the wild and make sense (how else would e.g. the smartphone know to open a lightning wallet instead of the e-mail client). So we should allow this. --- electrum/payment_identifier.py | 4 ++-- tests/test_payment_identifier.py | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/electrum/payment_identifier.py b/electrum/payment_identifier.py index 72f8e53a3..130907a9b 100644 --- a/electrum/payment_identifier.py +++ b/electrum/payment_identifier.py @@ -293,9 +293,9 @@ class PaymentIdentifier(Logger): self._type = PaymentIdentifierType.EMAILLIKE self.emaillike = contact['address'] self.set_state(PaymentIdentifierState.NEED_RESOLVE) - elif re.match(RE_EMAIL, text): + elif re.match(RE_EMAIL, (maybe_emaillike := remove_uri_prefix(text, prefix=LIGHTNING_URI_SCHEME))): self._type = PaymentIdentifierType.EMAILLIKE - self.emaillike = text + self.emaillike = maybe_emaillike self.set_state(PaymentIdentifierState.NEED_RESOLVE) elif re.match(RE_DOMAIN, text): self._type = PaymentIdentifierType.DOMAINLIKE diff --git a/tests/test_payment_identifier.py b/tests/test_payment_identifier.py index e44f9067f..0f3a3bff4 100644 --- a/tests/test_payment_identifier.py +++ b/tests/test_payment_identifier.py @@ -378,6 +378,11 @@ class TestPaymentIdentifier(ElectrumTestCase): 'lnbcuser@some.domain', 'lnurluser@some.domain', 'bc1quser@some.domain', + 'lightning:user@some.domain', + 'lightning:user@some.weird.but.valid.domain', + 'lightning:lnbcuser@some.domain', + 'lightning:lnurluser@some.domain', + 'lightning:bc1quser@some.domain', ) for pi_str in email_pi_strings: pi = PaymentIdentifier(None, pi_str)