pi: fix incorrectly parsing emaillike with 'ln' prefix
What changed, and why it matters
This commit fixes a bug in Electrum where certain email-like or domain-like payment identifiers that happened to start with the letters 'ln' (such as 'lnbcuser@some.domain') were wrongly treated as Lightning Network invoices. The fix makes the parser check whether the string is actually valid bech32 format before classifying it as a Lightning payment. This could have caused users to send payments to the wrong destination or prevented them from resolving legitimate email/domain payment identifiers.
Review the updated parser logic to confirm bech32_decode correctly rejects all malformed inputs, and consider whether additional URI prefix edge cases (e.g., mixed-case schemes, percent-encoding) need handling. Users should upgrade to a version containing this commit if they rely on Lightning or email/domain payment identifiers.
Security signals we found
Parser confusion between email/domain identifiers and Lightning invoices
Missing input validation on bech32 structure before Lightning classification
Potential UI misrouting of user-entered payment identifiers
Test expansion for emaillike/domainlike strings with 'ln' prefix
Evidence from the diff
The patch renames maybe_extract_lightning_payment_identifier to maybe_extract_bech32_lightning_payment_identifier and adds a real bech32_decode validation step before returning a candidate Lightning invoice string. It also introduces a helper remove_uri_prefix to strip the ‘lightning:’ URI scheme case-insensitively. Previously, any string starting with ‘ln’ after lowercasing and optional URI prefix stripping was treated as a Lightning identifier, which caused false positives for email-like strings (e.g., ‘lnbcuser@some.domain’) and domain-like strings (e.g., ‘lnbcsome.domain’). The new code requires a non-empty human-readable part and data part from bech32_decode. Tests are updated to cover these cases.
Changed components
electrum/payment_identifier.pytests/test_payment_identifier.pyInspect captured patch +75 / −57
diff --git a/electrum/payment_identifier.py b/electrum/payment_identifier.py
index 725dc44..72f8e53 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 04f59d9..e44f906 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(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(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_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'
Why this scored 46/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.