Files
purple-electrumwallet/electrum/payment_identifier.py
T

618 lines
25 KiB
Python
Raw Normal View History

2023-03-19 13:32:43 +01:00
import asyncio
2023-06-20 20:54:31 +02:00
import time
2023-03-19 13:32:43 +01:00
import urllib
import re
2023-05-31 16:42:15 +02:00
from decimal import Decimal, InvalidOperation
from enum import IntEnum
from typing import NamedTuple, Optional, Callable, List, TYPE_CHECKING, Tuple, Union
2023-03-19 13:32:43 +01:00
from . import bitcoin
from .contacts import AliasNotFoundException
2023-05-31 16:42:15 +02:00
from .i18n import _
from .invoices import Invoice
2023-03-19 13:32:43 +01:00
from .logging import Logger
from .util import parse_max_spend, InvoiceError
2023-03-19 13:32:43 +01:00
from .util import get_asyncio_loop, log_exceptions
from .transaction import PartialTxOutput
2025-06-27 17:48:26 +02:00
from .lnurl import (decode_lnurl, request_lnurl, callback_lnurl, LNURLError,
lightning_address_to_url, try_resolve_lnurlpay, LNURL6Data,
LNURL3Data, LNURLData)
from .bitcoin import opcodes, construct_script
from .lnaddr import LnInvoiceException
2023-03-19 13:32:43 +01:00
from .lnutil import IncompatibleOrInsaneFeatures
from .bip21 import parse_bip21_URI, InvalidBitcoinURI, LIGHTNING_URI_SCHEME, BITCOIN_BIP21_URI_SCHEME
from .segwit_addr import bech32_decode
2023-03-19 13:32:43 +01:00
if TYPE_CHECKING:
from .wallet import Abstract_Wallet
2023-06-22 20:20:31 +02:00
from .transaction import Transaction
2023-05-31 17:26:40 +02:00
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
2023-03-19 13:32:43 +01:00
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
2023-03-19 13:32:43 +01:00
RE_ALIAS = r'(.*?)\s*\<([0-9A-Za-z]{1,})\>'
RE_EMAIL = r'\b[A-Za-z0-9._%+-]+@([A-Za-z0-9-]+\.)+[A-Z|a-z]{2,7}\b'
RE_DOMAIN = r'\b([A-Za-z0-9-]+\.)+[A-Z|a-z]{2,7}\b'
RE_SCRIPT_FN = r'script\((.*)\)'
2023-03-19 13:32:43 +01:00
2023-05-31 17:26:40 +02:00
class PaymentIdentifierState(IntEnum):
EMPTY = 0 # Initial state.
INVALID = 1 # Unrecognized PI
AVAILABLE = 2 # PI contains a payable destination
# payable means there's enough addressing information to submit to one
# of the channels Electrum supports (on-chain, lightning)
NEED_RESOLVE = 3 # PI contains a recognized destination format, but needs an online resolve step
LNURLP_FINALIZE = 4 # PI contains a resolved LNURLp, but needs amount and comment to resolve to a bolt11
2025-06-27 17:48:26 +02:00
LNURLW_FINALIZE = 5 # PI contains resolved LNURLw, user needs to enter amount and initiate withdraw
ERROR = 50 # generic error
NOT_FOUND = 51 # PI contains a recognized destination format, but resolve step was unsuccessful
MERCHANT_ERROR = 52 # PI failed notifying the merchant after broadcasting onchain TX
INVALID_AMOUNT = 53 # Specified amount not accepted
2023-06-26 10:01:10 +02:00
class PaymentIdentifierType(IntEnum):
UNKNOWN = 0
SPK = 1
BIP21 = 2
MULTILINE = 4
BOLT11 = 5
2025-06-27 17:48:26 +02:00
LNURL = 6 # before the resolve it's unknown if pi is LNURLP or LNURLW
LNURLP = 7
LNURLW = 8
EMAILLIKE = 9
OPENALIAS = 10
LNADDR = 11
DOMAINLIKE = 12
class FieldsForGUI(NamedTuple):
recipient: Optional[str]
amount: Optional[int]
description: Optional[str]
validated: Optional[bool]
comment: Optional[int]
amount_range: Optional[Tuple[int, int]]
2023-03-19 13:32:43 +01:00
class PaymentIdentifier(Logger):
"""
Takes:
* bitcoin addresses or script
* paytomany csv
* openalias
* bip21 URI
* lightning-URI (containing bolt11 or lnurl)
* bolt11 invoice
* lnurl
* lightning address
2023-03-19 13:32:43 +01:00
"""
def __init__(self, wallet: Optional['Abstract_Wallet'], text: str):
2023-03-19 13:32:43 +01:00
Logger.__init__(self)
self._state = PaymentIdentifierState.EMPTY
self.wallet = wallet
self.contacts = wallet.contacts if wallet is not None else None
self.config = wallet.config if wallet is not None else None
2023-06-20 20:54:31 +02:00
self.text = text.strip()
2023-06-26 10:01:10 +02:00
self._type = PaymentIdentifierType.UNKNOWN
2023-03-19 13:32:43 +01:00
self.error = None # if set, GUI should show error and stop
self.warning = None # if set, GUI should ask user if they want to proceed
# more than one of those may be set
self.multiline_outputs = None
2023-06-20 20:54:31 +02:00
self._is_max = False
2023-08-22 17:00:17 +00:00
self.bolt11 = None # type: Optional[Invoice]
2023-03-19 13:32:43 +01:00
self.bip21 = None
self.spk = None
self.spk_is_address = False
2023-03-19 13:32:43 +01:00
#
self.emaillike = None
self.domainlike = None
2023-03-19 13:32:43 +01:00
self.openalias_data = None
#
2025-06-27 17:48:26 +02:00
self.lnurl = None # type: Optional[str]
self.lnurl_data = None # type: Optional[LNURLData]
2023-06-22 14:36:33 +02:00
2023-03-19 13:32:43 +01:00
self.parse(text)
2023-06-20 20:54:31 +02:00
@property
def type(self):
return self._type
def set_state(self, state: 'PaymentIdentifierState'):
self.logger.debug(f'PI state {self._state.name} -> {state.name}')
self._state = state
@property
def state(self):
return self._state
2023-06-22 14:36:33 +02:00
def need_resolve(self):
return self._state == PaymentIdentifierState.NEED_RESOLVE
def need_finalize(self):
return self._state == PaymentIdentifierState.LNURLP_FINALIZE
2023-03-19 13:32:43 +01:00
def is_valid(self):
return self._state not in [PaymentIdentifierState.INVALID, PaymentIdentifierState.EMPTY]
2023-03-19 13:32:43 +01:00
2023-06-22 20:20:31 +02:00
def is_available(self):
return self._state in [PaymentIdentifierState.AVAILABLE]
2023-03-19 13:32:43 +01:00
def is_lightning(self):
return bool(self.lnurl) or bool(self.bolt11)
def is_onchain(self):
2026-03-20 15:51:07 +00:00
if self._type in [PaymentIdentifierType.SPK, PaymentIdentifierType.MULTILINE,
PaymentIdentifierType.OPENALIAS]:
return True
if self._type in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.BOLT11, PaymentIdentifierType.LNADDR]:
return bool(self.bolt11) and bool(self.bolt11.get_address())
if self._type == PaymentIdentifierType.BIP21:
return bool(self.bip21.get('address', None)) or (bool(self.bolt11) and bool(self.bolt11.get_address()))
2023-03-19 13:32:43 +01:00
def is_multiline(self):
return bool(self.multiline_outputs)
2023-06-20 20:54:31 +02:00
def is_multiline_max(self):
return self.is_multiline() and self._is_max
def is_amount_locked(self):
if self._type == PaymentIdentifierType.BIP21:
2023-06-20 20:54:31 +02:00
return bool(self.bip21.get('amount'))
2023-06-26 10:01:10 +02:00
elif self._type == PaymentIdentifierType.BOLT11:
return bool(self.bolt11.get_amount_sat())
2023-06-26 10:01:10 +02:00
elif self._type in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR]:
2023-06-20 20:54:31 +02:00
# amount limits known after resolve, might be specific amount or locked to range
if self.need_resolve():
2023-06-27 12:12:23 +02:00
return False
2023-06-20 20:54:31 +02:00
if self.need_finalize():
self.logger.debug(f'lnurl f {self.lnurl_data.min_sendable_sat}-{self.lnurl_data.max_sendable_sat}')
return not (self.lnurl_data.min_sendable_sat < self.lnurl_data.max_sendable_sat)
return True
2023-06-26 10:01:10 +02:00
elif self._type == PaymentIdentifierType.MULTILINE:
2023-06-20 20:54:31 +02:00
return True
else:
2023-06-22 14:36:33 +02:00
return False
2023-06-20 20:54:31 +02:00
def is_error(self) -> bool:
return self._state >= PaymentIdentifierState.ERROR
2023-03-19 13:32:43 +01:00
def get_error(self) -> str:
return self.error
def parse(self, text: str):
2023-03-19 13:32:43 +01:00
# parse text, set self._type and self.error
text = text.strip()
if not text:
return
2023-05-31 17:26:40 +02:00
if outputs := self._parse_as_multiline(text):
2023-06-26 10:01:10 +02:00
self._type = PaymentIdentifierType.MULTILINE
2023-03-19 13:32:43 +01:00
self.multiline_outputs = outputs
if self.error:
self.set_state(PaymentIdentifierState.INVALID)
else:
self.set_state(PaymentIdentifierState.AVAILABLE)
elif invoice_or_lnurl := maybe_extract_bech32_lightning_payment_identifier(text):
2023-03-19 13:32:43 +01:00
if invoice_or_lnurl.startswith('lnurl'):
2025-06-27 17:48:26 +02:00
self._type = PaymentIdentifierType.LNURL
2023-03-19 13:32:43 +01:00
try:
self.lnurl = decode_lnurl(invoice_or_lnurl)
self.set_state(PaymentIdentifierState.NEED_RESOLVE)
2023-03-19 13:32:43 +01:00
except Exception as e:
2023-06-20 20:54:31 +02:00
self.error = _("Error parsing LNURL") + f":\n{e}"
self.set_state(PaymentIdentifierState.INVALID)
2023-03-19 13:32:43 +01:00
return
else:
2023-06-26 10:01:10 +02:00
self._type = PaymentIdentifierType.BOLT11
2023-06-20 20:54:31 +02:00
try:
self.bolt11 = Invoice.from_bech32(invoice_or_lnurl)
except InvoiceError as e:
self.error = self._get_error_from_invoiceerror(e)
2023-06-20 20:54:31 +02:00
self.set_state(PaymentIdentifierState.INVALID)
self.logger.debug(f'Exception cause {e.args!r}')
2023-06-20 20:54:31 +02:00
return
self.set_state(PaymentIdentifierState.AVAILABLE)
2023-03-19 13:32:43 +01:00
elif text.lower().startswith(BITCOIN_BIP21_URI_SCHEME + ':'):
try:
out = parse_bip21_URI(text)
except InvalidBitcoinURI as e:
self.error = _("Error parsing URI") + f":\n{e}"
self.set_state(PaymentIdentifierState.INVALID)
2023-03-19 13:32:43 +01:00
return
self.bip21 = out
2026-03-20 15:51:07 +00:00
self._type = PaymentIdentifierType.BIP21
# check optional lightning in bip21, set self.bolt11 if valid
bolt11 = out.get('lightning')
if bolt11:
try:
self.bolt11 = Invoice.from_bech32(bolt11)
# carry BIP21 onchain address in Invoice.outputs in case bolt11 doesn't contain a fallback
# address but the BIP21 URI has one.
if bip21_address := self.bip21.get('address'):
amount = self.bip21.get('amount', 0)
self.bolt11.outputs = [PartialTxOutput.from_address_and_value(bip21_address, amount)]
except InvoiceError as e:
self.logger.debug(self._get_error_from_invoiceerror(e))
elif not self.bip21.get('address'):
# no address and no bolt11, invalid
self.set_state(PaymentIdentifierState.INVALID)
return
self.set_state(PaymentIdentifierState.AVAILABLE)
elif self.parse_output(text)[0]:
scriptpubkey, is_address = self.parse_output(text)
2023-06-26 10:01:10 +02:00
self._type = PaymentIdentifierType.SPK
2023-03-19 13:32:43 +01:00
self.spk = scriptpubkey
self.spk_is_address = is_address
self.set_state(PaymentIdentifierState.AVAILABLE)
elif self.contacts and (contact := self.contacts.by_name(text)):
if contact['type'] == 'address':
self._type = PaymentIdentifierType.BIP21
self.bip21 = {
'address': contact['address'],
'label': contact['name']
}
self.set_state(PaymentIdentifierState.AVAILABLE)
elif contact['type'] in ('openalias', 'lnaddress'):
self._type = PaymentIdentifierType.EMAILLIKE
self.emaillike = contact['address']
self.set_state(PaymentIdentifierState.NEED_RESOLVE)
elif re.match(RE_EMAIL, (maybe_emaillike := remove_uri_prefix(text, prefix=LIGHTNING_URI_SCHEME))):
2023-06-26 10:01:10 +02:00
self._type = PaymentIdentifierType.EMAILLIKE
self.emaillike = maybe_emaillike
self.set_state(PaymentIdentifierState.NEED_RESOLVE)
elif re.match(RE_DOMAIN, text):
self._type = PaymentIdentifierType.DOMAINLIKE
self.domainlike = text
self.set_state(PaymentIdentifierState.NEED_RESOLVE)
2023-03-19 13:32:43 +01:00
elif self.error is None:
truncated_text = f"{text[:100]}..." if len(text) > 100 else text
self.error = f"Unknown payment identifier:\n{truncated_text}"
self.set_state(PaymentIdentifierState.INVALID)
def resolve(self, *, on_finished: Callable[['PaymentIdentifier'], None]) -> None:
assert self._state == PaymentIdentifierState.NEED_RESOLVE
coro = self._do_resolve(on_finished=on_finished)
asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop())
@log_exceptions
async def _do_resolve(self, *, on_finished: Callable[['PaymentIdentifier'], None] = None):
try:
if self.emaillike or self.domainlike:
openalias_key = self.emaillike if self.emaillike else self.domainlike
openalias_task = asyncio.create_task(self.resolve_openalias(openalias_key))
# prefers lnurl over openalias if both are available
lnurl = lightning_address_to_url(self.emaillike) if self.emaillike else None
2025-06-27 17:48:26 +02:00
if lnurl is not None and (lnurl_result := await try_resolve_lnurlpay(lnurl)):
openalias_task.cancel()
self._type = PaymentIdentifierType.LNADDR
self.lnurl = lnurl
self.lnurl_data = lnurl_result
self.set_state(PaymentIdentifierState.LNURLP_FINALIZE)
elif openalias_result := await openalias_task:
self.openalias_data = openalias_result
address = openalias_result.get('address')
2023-06-20 20:54:31 +02:00
try:
# this assertion error message is shown in the GUI
assert bitcoin.is_address(address), f"{_('Openalias address invalid')}: {address[:100]}"
scriptpubkey = bitcoin.address_to_script(address)
2023-06-26 10:01:10 +02:00
self._type = PaymentIdentifierType.OPENALIAS
2023-06-20 20:54:31 +02:00
self.spk = scriptpubkey
self.set_state(PaymentIdentifierState.AVAILABLE)
except Exception as e:
self.error = str(e)
self.set_state(PaymentIdentifierState.NOT_FOUND)
else:
self.set_state(PaymentIdentifierState.NOT_FOUND)
elif self.lnurl:
data = await request_lnurl(self.lnurl)
self.lnurl_data = data
2025-06-27 17:48:26 +02:00
if isinstance(data, LNURL6Data):
self._type = PaymentIdentifierType.LNURLP
self.set_state(PaymentIdentifierState.LNURLP_FINALIZE)
elif isinstance(data, LNURL3Data):
self._type = PaymentIdentifierType.LNURLW
self.set_state(PaymentIdentifierState.LNURLW_FINALIZE)
else:
raise NotImplementedError(f"Invalid LNURL type? {data=}")
2023-06-20 20:54:31 +02:00
self.logger.debug(f'LNURL data: {data!r}')
else:
self.set_state(PaymentIdentifierState.ERROR)
return
except Exception as e:
self.error = str(e)
self.logger.error(f"_do_resolve() got error: {e!r}")
self.set_state(PaymentIdentifierState.ERROR)
finally:
if on_finished:
on_finished(self)
def finalize(
self,
*,
amount_sat: int = 0,
comment: str = None,
on_finished: Callable[['PaymentIdentifier'], None] = None,
):
assert self._state == PaymentIdentifierState.LNURLP_FINALIZE
coro = self._do_finalize(amount_sat=amount_sat, comment=comment, on_finished=on_finished)
asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop())
@log_exceptions
async def _do_finalize(
self,
*,
amount_sat: int = None,
comment: str = None,
on_finished: Callable[['PaymentIdentifier'], None] = None,
):
from .invoices import Invoice
try:
if not self.lnurl_data:
raise Exception("Unexpected missing LNURL data")
if not (self.lnurl_data.min_sendable_sat <= amount_sat <= self.lnurl_data.max_sendable_sat):
self.error = _('Amount must be between {} and {} sat.').format(
self.lnurl_data.min_sendable_sat, self.lnurl_data.max_sendable_sat)
2023-06-22 20:20:31 +02:00
self.set_state(PaymentIdentifierState.INVALID_AMOUNT)
return
2023-06-22 20:20:31 +02:00
if self.lnurl_data.comment_allowed == 0:
comment = None
params = {'amount': amount_sat * 1000}
if comment:
params['comment'] = comment
2023-06-22 20:20:31 +02:00
try:
2023-06-22 20:20:31 +02:00
invoice_data = await callback_lnurl(self.lnurl_data.callback_url, params=params)
except LNURLError as e:
self.error = f"LNURL request encountered error: {e}"
2023-06-22 20:20:31 +02:00
self.set_state(PaymentIdentifierState.ERROR)
return
2023-06-22 20:20:31 +02:00
bolt11_invoice = invoice_data.get('pr')
invoice = Invoice.from_bech32(bolt11_invoice)
if invoice.get_amount_sat() != amount_sat:
raise Exception("lnurl returned invoice with wrong amount")
# this will change what is returned by get_fields_for_GUI
2023-08-22 17:00:17 +00:00
self.bolt11 = invoice
self.set_state(PaymentIdentifierState.AVAILABLE)
except Exception as e:
self.error = str(e)
self.logger.error(f"_do_finalize() got error: {e!r}")
self.set_state(PaymentIdentifierState.ERROR)
finally:
if on_finished:
on_finished(self)
2023-03-19 13:32:43 +01:00
def get_onchain_outputs(self, amount):
2026-03-20 15:51:07 +00:00
if self.multiline_outputs:
2023-03-19 13:32:43 +01:00
return self.multiline_outputs
elif self.spk:
return [PartialTxOutput(scriptpubkey=self.spk, value=amount)]
elif self.bip21:
address = self.bip21.get('address')
scriptpubkey, is_address = self.parse_output(address)
assert is_address # unlikely, but make sure it is an address, not a script
2023-03-19 13:32:43 +01:00
return [PartialTxOutput(scriptpubkey=scriptpubkey, value=amount)]
else:
raise Exception('not onchain')
def _parse_as_multiline(self, text: str):
2023-03-19 13:32:43 +01:00
# filter out empty lines
lines = text.split('\n')
lines = [i for i in lines if i]
2023-05-31 17:26:40 +02:00
is_multiline = len(lines) > 1
2023-03-19 13:32:43 +01:00
outputs = [] # type: List[PartialTxOutput]
2023-06-20 20:54:31 +02:00
errors = ''
2023-03-19 13:32:43 +01:00
total = 0
2023-06-20 20:54:31 +02:00
self._is_max = False
2023-03-19 13:32:43 +01:00
for i, line in enumerate(lines):
try:
output = self.parse_address_and_amount(line)
outputs.append(output)
if parse_max_spend(output.value):
2023-06-20 20:54:31 +02:00
self._is_max = True
else:
total += output.value
2023-03-19 13:32:43 +01:00
except Exception as e:
2023-06-20 20:54:31 +02:00
errors = f'{errors}line #{i}: {str(e)}\n'
2023-03-19 13:32:43 +01:00
continue
if is_multiline and errors:
2023-06-20 20:54:31 +02:00
self.error = errors.strip() if errors else None
self.logger.debug(f'multiline: {outputs!r}, {self.error}')
2023-03-19 13:32:43 +01:00
return outputs
def parse_address_and_amount(self, line: str) -> PartialTxOutput:
2023-03-19 13:32:43 +01:00
try:
x, y = line.split(',')
except ValueError:
raise Exception("expected two comma-separated values: (address, amount)") from None
scriptpubkey, is_address = self.parse_output(x)
if not scriptpubkey:
raise Exception('Invalid address')
2023-03-19 13:32:43 +01:00
amount = self.parse_amount(y)
return PartialTxOutput(scriptpubkey=scriptpubkey, value=amount)
def parse_output(self, x: str) -> Tuple[Optional[bytes], bool]:
2023-03-19 13:32:43 +01:00
try:
address = self.parse_address(x)
return bitcoin.address_to_script(address), True
2023-03-19 13:32:43 +01:00
except Exception as e:
2023-06-20 20:54:31 +02:00
pass
2023-03-19 13:32:43 +01:00
try:
m = re.match('^' + RE_SCRIPT_FN + '$', x)
script = self.parse_script(str(m.group(1)))
return script, False
2023-03-19 13:32:43 +01:00
except Exception as e:
pass
2023-06-20 20:54:31 +02:00
return None, False
2023-03-19 13:32:43 +01:00
def parse_script(self, x: str) -> bytes:
script = bytearray()
2023-03-19 13:32:43 +01:00
for word in x.split():
if word[0:3] == 'OP_':
opcode_int = opcodes[word]
script += construct_script([opcode_int])
else:
bytes.fromhex(word) # to test it is hex data
script += construct_script([word])
return bytes(script)
2023-03-19 13:32:43 +01:00
def parse_amount(self, x: str) -> Union[str, int]:
2023-03-19 13:32:43 +01:00
x = x.strip()
if not x:
raise Exception("Amount is empty")
if parse_max_spend(x):
return x
p = pow(10, self.config.BTC_AMOUNTS_DECIMAL_POINT)
2023-03-19 13:32:43 +01:00
try:
return int(p * Decimal(x))
2023-05-31 16:42:15 +02:00
except InvalidOperation:
2023-03-19 13:32:43 +01:00
raise Exception("Invalid amount")
def parse_address(self, line: str):
2023-03-19 13:32:43 +01:00
r = line.strip()
2023-05-31 17:26:40 +02:00
m = re.match('^' + RE_ALIAS + '$', r)
2023-03-19 13:32:43 +01:00
address = str(m.group(2) if m else r)
assert bitcoin.is_address(address)
return address
def _get_error_from_invoiceerror(self, e: 'InvoiceError') -> str:
error = _("Error parsing Lightning invoice") + f":\n{e!r}"
if e.args and len(e.args):
arg = e.args[0]
if isinstance(arg, LnInvoiceException):
error = _("Error parsing Lightning invoice") + f":\n{e}"
elif isinstance(arg, IncompatibleOrInsaneFeatures):
error = _("Invoice requires unknown or incompatible Lightning feature") + f":\n{e!r}"
return error
def get_fields_for_GUI(self) -> FieldsForGUI:
2023-03-19 13:32:43 +01:00
recipient = None
amount = None
description = None
validated = None
2023-06-20 20:54:31 +02:00
comment = None
amount_range = None
2023-03-19 13:32:43 +01:00
if (self.emaillike or self.domainlike) and self.openalias_data:
key = self.emaillike if self.emaillike else self.domainlike
2023-03-19 13:32:43 +01:00
address = self.openalias_data.get('address')
name = self.openalias_data.get('name')
description = name
recipient = key + ' <' + address + '>'
2023-03-19 13:32:43 +01:00
elif self.bolt11:
recipient, amount, description = self._get_bolt11_fields()
2023-03-19 13:32:43 +01:00
elif self.lnurl and self.lnurl_data:
2025-06-27 17:48:26 +02:00
assert isinstance(self.lnurl_data, LNURL6Data), f"{self.lnurl_data=}"
domain = urllib.parse.urlparse(self.lnurl).netloc
2023-03-19 13:32:43 +01:00
recipient = f"{self.lnurl_data.metadata_plaintext} <{domain}>"
2023-06-20 20:54:31 +02:00
description = self.lnurl_data.metadata_plaintext
2023-03-19 13:32:43 +01:00
if self.lnurl_data.comment_allowed:
2023-06-20 20:54:31 +02:00
comment = self.lnurl_data.comment_allowed
if self.lnurl_data.min_sendable_sat:
amount = self.lnurl_data.min_sendable_sat
if self.lnurl_data.min_sendable_sat != self.lnurl_data.max_sendable_sat:
amount_range = (self.lnurl_data.min_sendable_sat, self.lnurl_data.max_sendable_sat)
2023-03-19 13:32:43 +01:00
elif self.spk:
2023-06-20 20:54:31 +02:00
pass
2023-03-19 13:32:43 +01:00
elif self.multiline_outputs:
pass
elif self.bip21:
label = self.bip21.get('label')
address = self.bip21.get('address')
recipient = f'{label} <{address}>' if label else address
amount = self.bip21.get('amount')
2023-03-19 13:32:43 +01:00
description = self.bip21.get('message')
# TODO: use label as description? (not BIP21 compliant)
# if label and not description:
# description = label
2023-03-19 13:32:43 +01:00
return FieldsForGUI(recipient=recipient, amount=amount, description=description,
comment=comment, validated=validated, amount_range=amount_range)
2023-03-19 13:32:43 +01:00
def _get_bolt11_fields(self):
lnaddr = self.bolt11._lnaddr # TODO: improve access to lnaddr
2023-03-19 13:32:43 +01:00
pubkey = lnaddr.pubkey.serialize().hex()
2023-05-31 17:26:40 +02:00
for k, v in lnaddr.tags:
2023-03-19 13:32:43 +01:00
if k == 'd':
description = v
break
else:
2023-05-31 17:26:40 +02:00
description = ''
2023-03-19 13:32:43 +01:00
amount = lnaddr.get_amount_sat()
return pubkey, amount, description
async def resolve_openalias(self, key: str) -> Optional[dict]:
2023-03-19 13:32:43 +01:00
parts = key.split(sep=',') # assuming single line
if parts and len(parts) > 0 and bitcoin.is_address(parts[0]):
return None
try:
data = await self.contacts.resolve(key) # TODO: don't use contacts as delegate to resolve openalias, separate.
self.logger.debug(f'OA: {data!r}')
return data
except AliasNotFoundException as e:
self.logger.info(f'OpenAlias not found: {repr(e)}')
return None
2023-03-19 13:32:43 +01:00
except Exception as e:
self.logger.info(f'error resolving address/alias: {repr(e)}')
return None
def has_expired(self):
2026-03-20 15:51:07 +00:00
if self.bolt11:
return self.bolt11.has_expired()
2023-06-20 20:54:31 +02:00
elif self.bip21:
expires = self.bip21.get('exp') + self.bip21.get('time') if self.bip21.get('exp') else 0
return bool(expires) and expires < time.time()
2023-03-19 13:32:43 +01:00
return False
def invoice_from_payment_identifier(
pi: 'PaymentIdentifier',
wallet: 'Abstract_Wallet',
amount_sat: Union[int, str],
message: str = None
) -> Optional[Invoice]:
2026-03-20 15:51:07 +00:00
assert pi.state in [PaymentIdentifierState.AVAILABLE,]
assert pi.is_onchain() if amount_sat == '!' else True # MAX should only be allowed if pi has onchain destination
if pi.is_lightning() and not amount_sat == '!':
invoice = pi.bolt11
if not invoice:
return
if invoice._lnaddr.get_amount_msat() is None:
invoice.set_amount_msat(int(amount_sat * 1000))
return invoice
else:
outputs = pi.get_onchain_outputs(amount_sat)
message = pi.bip21.get('message') if pi.bip21 else message
return wallet.create_invoice(
outputs=outputs,
message=message,
2026-03-20 15:51:07 +00:00
URI=pi.bip21,
)