• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

spesmilo / electrum / 5735552722403328

16 May 2025 10:28AM UTC coverage: 59.722% (+0.002%) from 59.72%
5735552722403328

Pull #9833

CirrusCI

f321x
make lightning dns seed fetching async
Pull Request #9833: dns: use async dnspython interface

22 of 50 new or added lines in 7 files covered. (44.0%)

1107 existing lines in 11 files now uncovered.

21549 of 36082 relevant lines covered (59.72%)

2.39 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

54.74
/electrum/payment_identifier.py
1
import asyncio
4✔
2
import time
4✔
3
import urllib
4✔
4
import re
4✔
5
from decimal import Decimal, InvalidOperation
4✔
6
from enum import IntEnum
4✔
7
from typing import NamedTuple, Optional, Callable, List, TYPE_CHECKING, Tuple, Union
4✔
8

9
from . import bitcoin
4✔
10
from .contacts import AliasNotFoundException
4✔
11
from .i18n import _
4✔
12
from .invoices import Invoice
4✔
13
from .logging import Logger
4✔
14
from .util import parse_max_spend, InvoiceError
4✔
15
from .util import get_asyncio_loop, log_exceptions
4✔
16
from .transaction import PartialTxOutput
4✔
17
from .lnurl import decode_lnurl, request_lnurl, callback_lnurl, LNURLError, lightning_address_to_url
4✔
18
from .bitcoin import opcodes, construct_script
4✔
19
from .lnaddr import LnInvoiceException
4✔
20
from .lnutil import IncompatibleOrInsaneFeatures
4✔
21
from .bip21 import parse_bip21_URI, InvalidBitcoinURI, LIGHTNING_URI_SCHEME, BITCOIN_BIP21_URI_SCHEME
4✔
22
from . import paymentrequest
4✔
23

24
if TYPE_CHECKING:
4✔
25
    from .wallet import Abstract_Wallet
×
26
    from .transaction import Transaction
×
27

28

29
def maybe_extract_lightning_payment_identifier(data: str) -> Optional[str]:
4✔
30
    data = data.strip()  # whitespaces
4✔
31
    data = data.lower()
4✔
32
    if data.startswith(LIGHTNING_URI_SCHEME + ':ln'):
4✔
33
        cut_prefix = LIGHTNING_URI_SCHEME + ':'
4✔
34
        data = data[len(cut_prefix):]
4✔
35
    if data.startswith('ln'):
4✔
36
        return data
4✔
37
    return None
4✔
38

39

40
def is_uri(data: str) -> bool:
4✔
41
    data = data.lower()
×
42
    if (data.startswith(LIGHTNING_URI_SCHEME + ":") or
×
43
            data.startswith(BITCOIN_BIP21_URI_SCHEME + ':')):
44
        return True
×
45
    return False
×
46

47

48
RE_ALIAS = r'(.*?)\s*\<([0-9A-Za-z]{1,})\>'
4✔
49
RE_EMAIL = r'\b[A-Za-z0-9._%+-]+@([A-Za-z0-9-]+\.)+[A-Z|a-z]{2,7}\b'
4✔
50
RE_DOMAIN = r'\b([A-Za-z0-9-]+\.)+[A-Z|a-z]{2,7}\b'
4✔
51
RE_SCRIPT_FN = r'script\((.*)\)'
4✔
52

53

54
class PaymentIdentifierState(IntEnum):
4✔
55
    EMPTY = 0               # Initial state.
4✔
56
    INVALID = 1             # Unrecognized PI
4✔
57
    AVAILABLE = 2           # PI contains a payable destination
4✔
58
                            # payable means there's enough addressing information to submit to one
59
                            # of the channels Electrum supports (on-chain, lightning)
60
    NEED_RESOLVE = 3        # PI contains a recognized destination format, but needs an online resolve step
4✔
61
    LNURLP_FINALIZE = 4     # PI contains a resolved LNURLp, but needs amount and comment to resolve to a bolt11
4✔
62
    MERCHANT_NOTIFY = 5     # PI contains a valid payment request and on-chain destination. It should notify
4✔
63
                            # the merchant payment processor of the tx after on-chain broadcast,
64
                            # and supply a refund address (bip70)
65
    MERCHANT_ACK = 6        # PI notified merchant. nothing to be done.
4✔
66
    ERROR = 50              # generic error
4✔
67
    NOT_FOUND = 51          # PI contains a recognized destination format, but resolve step was unsuccessful
4✔
68
    MERCHANT_ERROR = 52     # PI failed notifying the merchant after broadcasting onchain TX
4✔
69
    INVALID_AMOUNT = 53     # Specified amount not accepted
4✔
70

71

72
class PaymentIdentifierType(IntEnum):
4✔
73
    UNKNOWN = 0
4✔
74
    SPK = 1
4✔
75
    BIP21 = 2
4✔
76
    BIP70 = 3
4✔
77
    MULTILINE = 4
4✔
78
    BOLT11 = 5
4✔
79
    LNURLP = 6
4✔
80
    EMAILLIKE = 7
4✔
81
    OPENALIAS = 8
4✔
82
    LNADDR = 9
4✔
83
    DOMAINLIKE = 10
4✔
84

85

86
class FieldsForGUI(NamedTuple):
4✔
87
    recipient: Optional[str]
4✔
88
    amount: Optional[int]
4✔
89
    description: Optional[str]
4✔
90
    validated: Optional[bool]
4✔
91
    comment: Optional[int]
4✔
92
    amount_range: Optional[Tuple[int, int]]
4✔
93

94

95
class PaymentIdentifier(Logger):
4✔
96
    """
97
    Takes:
98
        * bitcoin addresses or script
99
        * paytomany csv
100
        * openalias
101
        * bip21 URI
102
        * lightning-URI (containing bolt11 or lnurl)
103
        * bolt11 invoice
104
        * lnurl
105
        * lightning address
106
    """
107

108
    def __init__(self, wallet: Optional['Abstract_Wallet'], text: str):
4✔
109
        Logger.__init__(self)
4✔
110
        self._state = PaymentIdentifierState.EMPTY
4✔
111
        self.wallet = wallet
4✔
112
        self.contacts = wallet.contacts if wallet is not None else None
4✔
113
        self.config = wallet.config if wallet is not None else None
4✔
114
        self.text = text.strip()
4✔
115
        self._type = PaymentIdentifierType.UNKNOWN
4✔
116
        self.error = None    # if set, GUI should show error and stop
4✔
117
        self.warning = None  # if set, GUI should ask user if they want to proceed
4✔
118
        # more than one of those may be set
119
        self.multiline_outputs = None
4✔
120
        self._is_max = False
4✔
121
        self.bolt11 = None  # type: Optional[Invoice]
4✔
122
        self.bip21 = None
4✔
123
        self.spk = None
4✔
124
        self.spk_is_address = False
4✔
125
        #
126
        self.emaillike = None
4✔
127
        self.domainlike = None
4✔
128
        self.openalias_data = None
4✔
129
        #
130
        self.bip70 = None
4✔
131
        self.bip70_data = None
4✔
132
        self.merchant_ack_status = None
4✔
133
        self.merchant_ack_message = None
4✔
134
        #
135
        self.lnurl = None
4✔
136
        self.lnurl_data = None
4✔
137

138
        self.parse(text)
4✔
139

140
    @property
4✔
141
    def type(self):
4✔
142
        return self._type
4✔
143

144
    def set_state(self, state: 'PaymentIdentifierState'):
4✔
145
        self.logger.debug(f'PI state {self._state.name} -> {state.name}')
4✔
146
        self._state = state
4✔
147

148
    @property
4✔
149
    def state(self):
4✔
150
        return self._state
4✔
151

152
    def need_resolve(self):
4✔
153
        return self._state == PaymentIdentifierState.NEED_RESOLVE
4✔
154

155
    def need_finalize(self):
4✔
156
        return self._state == PaymentIdentifierState.LNURLP_FINALIZE
4✔
157

158
    def need_merchant_notify(self):
4✔
159
        return self._state == PaymentIdentifierState.MERCHANT_NOTIFY
×
160

161
    def is_valid(self):
4✔
162
        return self._state not in [PaymentIdentifierState.INVALID, PaymentIdentifierState.EMPTY]
4✔
163

164
    def is_available(self):
4✔
165
        return self._state in [PaymentIdentifierState.AVAILABLE]
4✔
166

167
    def is_lightning(self):
4✔
168
        return bool(self.lnurl) or bool(self.bolt11)
4✔
169

170
    def is_onchain(self):
4✔
171
        if self._type in [PaymentIdentifierType.SPK, PaymentIdentifierType.MULTILINE, PaymentIdentifierType.BIP70,
4✔
172
                          PaymentIdentifierType.OPENALIAS]:
173
            return True
×
174
        if self._type in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.BOLT11, PaymentIdentifierType.LNADDR]:
4✔
175
            return bool(self.bolt11) and bool(self.bolt11.get_address())
4✔
176
        if self._type == PaymentIdentifierType.BIP21:
4✔
177
            return bool(self.bip21.get('address', None)) or (bool(self.bolt11) and bool(self.bolt11.get_address()))
4✔
178

179
    def is_multiline(self):
4✔
180
        return bool(self.multiline_outputs)
4✔
181

182
    def is_multiline_max(self):
4✔
183
        return self.is_multiline() and self._is_max
4✔
184

185
    def is_amount_locked(self):
4✔
186
        if self._type == PaymentIdentifierType.BIP21:
4✔
187
            return bool(self.bip21.get('amount'))
×
188
        elif self._type == PaymentIdentifierType.BIP70:
4✔
189
            return not self.need_resolve()  # always fixed after resolve?
×
190
        elif self._type == PaymentIdentifierType.BOLT11:
4✔
191
            return bool(self.bolt11.get_amount_sat())
4✔
192
        elif self._type in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR]:
×
193
            # amount limits known after resolve, might be specific amount or locked to range
194
            if self.need_resolve():
×
195
                return False
×
196
            if self.need_finalize():
×
197
                self.logger.debug(f'lnurl f {self.lnurl_data.min_sendable_sat}-{self.lnurl_data.max_sendable_sat}')
×
198
                return not (self.lnurl_data.min_sendable_sat < self.lnurl_data.max_sendable_sat)
×
199
            return True
×
200
        elif self._type == PaymentIdentifierType.MULTILINE:
×
201
            return True
×
202
        else:
203
            return False
×
204

205
    def is_error(self) -> bool:
4✔
206
        return self._state >= PaymentIdentifierState.ERROR
4✔
207

208
    def get_error(self) -> str:
4✔
209
        return self.error
×
210

211
    def parse(self, text: str):
4✔
212
        # parse text, set self._type and self.error
213
        text = text.strip()
4✔
214
        if not text:
4✔
215
            return
×
216
        if outputs := self._parse_as_multiline(text):
4✔
217
            self._type = PaymentIdentifierType.MULTILINE
4✔
218
            self.multiline_outputs = outputs
4✔
219
            if self.error:
4✔
220
                self.set_state(PaymentIdentifierState.INVALID)
×
221
            else:
222
                self.set_state(PaymentIdentifierState.AVAILABLE)
4✔
223
        elif invoice_or_lnurl := maybe_extract_lightning_payment_identifier(text):
4✔
224
            if invoice_or_lnurl.startswith('lnurl'):
4✔
225
                self._type = PaymentIdentifierType.LNURLP
4✔
226
                try:
4✔
227
                    self.lnurl = decode_lnurl(invoice_or_lnurl)
4✔
228
                    self.set_state(PaymentIdentifierState.NEED_RESOLVE)
4✔
229
                except Exception as e:
×
230
                    self.error = _("Error parsing LNURL") + f":\n{e}"
×
231
                    self.set_state(PaymentIdentifierState.INVALID)
×
232
                    return
×
233
            else:
234
                self._type = PaymentIdentifierType.BOLT11
4✔
235
                try:
4✔
236
                    self.bolt11 = Invoice.from_bech32(invoice_or_lnurl)
4✔
237
                except InvoiceError as e:
×
238
                    self.error = self._get_error_from_invoiceerror(e)
×
239
                    self.set_state(PaymentIdentifierState.INVALID)
×
240
                    self.logger.debug(f'Exception cause {e.args!r}')
×
241
                    return
×
242
                self.set_state(PaymentIdentifierState.AVAILABLE)
4✔
243
        elif text.lower().startswith(BITCOIN_BIP21_URI_SCHEME + ':'):
4✔
244
            try:
4✔
245
                out = parse_bip21_URI(text)
4✔
246
            except InvalidBitcoinURI as e:
4✔
247
                self.error = _("Error parsing URI") + f":\n{e}"
4✔
248
                self.set_state(PaymentIdentifierState.INVALID)
4✔
249
                return
4✔
250
            self.bip21 = out
4✔
251
            self.bip70 = out.get('r')
4✔
252
            if self.bip70:
4✔
253
                self._type = PaymentIdentifierType.BIP70
4✔
254
                self.set_state(PaymentIdentifierState.NEED_RESOLVE)
4✔
255
            else:
256
                self._type = PaymentIdentifierType.BIP21
4✔
257
                # check optional lightning in bip21, set self.bolt11 if valid
258
                bolt11 = out.get('lightning')
4✔
259
                if bolt11:
4✔
260
                    try:
4✔
261
                        self.bolt11 = Invoice.from_bech32(bolt11)
4✔
262
                        # carry BIP21 onchain address in Invoice.outputs in case bolt11 doesn't contain a fallback
263
                        # address but the BIP21 URI has one.
264
                        if bip21_address := self.bip21.get('address'):
4✔
265
                            amount = self.bip21.get('amount', 0)
4✔
266
                            self.bolt11.outputs = [PartialTxOutput.from_address_and_value(bip21_address, amount)]
4✔
267
                    except InvoiceError as e:
×
268
                        self.logger.debug(self._get_error_from_invoiceerror(e))
×
269
                elif not self.bip21.get('address'):
4✔
270
                    # no address and no bolt11, invalid
271
                    self.set_state(PaymentIdentifierState.INVALID)
×
272
                    return
×
273
                self.set_state(PaymentIdentifierState.AVAILABLE)
4✔
274
        elif self.parse_output(text)[0]:
4✔
275
            scriptpubkey, is_address = self.parse_output(text)
4✔
276
            self._type = PaymentIdentifierType.SPK
4✔
277
            self.spk = scriptpubkey
4✔
278
            self.spk_is_address = is_address
4✔
279
            self.set_state(PaymentIdentifierState.AVAILABLE)
4✔
280
        elif self.contacts and (contact := self.contacts.by_name(text)):
4✔
281
            if contact['type'] == 'address':
×
282
                self._type = PaymentIdentifierType.BIP21
×
283
                self.bip21 = {
×
284
                    'address': contact['address'],
285
                    'label': contact['name']
286
                }
287
                self.set_state(PaymentIdentifierState.AVAILABLE)
×
288
            elif contact['type'] == 'openalias':
×
289
                self._type = PaymentIdentifierType.EMAILLIKE
×
290
                self.emaillike = contact['address']
×
291
                self.set_state(PaymentIdentifierState.NEED_RESOLVE)
×
292
        elif re.match(RE_EMAIL, text):
4✔
293
            self._type = PaymentIdentifierType.EMAILLIKE
4✔
294
            self.emaillike = text
4✔
295
            self.set_state(PaymentIdentifierState.NEED_RESOLVE)
4✔
296
        elif re.match(RE_DOMAIN, text):
4✔
297
            self._type = PaymentIdentifierType.DOMAINLIKE
4✔
298
            self.domainlike = text
4✔
299
            self.set_state(PaymentIdentifierState.NEED_RESOLVE)
4✔
300
        elif self.error is None:
4✔
301
            truncated_text = f"{text[:100]}..." if len(text) > 100 else text
4✔
302
            self.error = f"Unknown payment identifier:\n{truncated_text}"
4✔
303
            self.set_state(PaymentIdentifierState.INVALID)
4✔
304

305
    def resolve(self, *, on_finished: Callable[['PaymentIdentifier'], None]) -> None:
4✔
306
        assert self._state == PaymentIdentifierState.NEED_RESOLVE
×
307
        coro = self._do_resolve(on_finished=on_finished)
×
308
        asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop())
×
309

310
    @log_exceptions
4✔
311
    async def _do_resolve(self, *, on_finished: Callable[['PaymentIdentifier'], None] = None):
4✔
312
        try:
×
313
            if self.emaillike or self.domainlike:
×
314
                # TODO: parallel lookup?
315
                key = self.emaillike if self.emaillike else self.domainlike
×
316
                data = await self.resolve_openalias(key)
×
317
                if data:
×
318
                    self.openalias_data = data
×
319
                    self.logger.debug(f'OA: {data!r}')
×
320
                    address = data.get('address')
×
321
                    if not data.get('validated'):
×
322
                        self.warning = _(
×
323
                            'WARNING: the alias "{}" could not be validated via an additional '
324
                            'security check, DNSSEC, and thus may not be correct.').format(key)
325
                    try:
×
326
                        assert bitcoin.is_address(address)
×
327
                        scriptpubkey = bitcoin.address_to_script(address)
×
328
                        self._type = PaymentIdentifierType.OPENALIAS
×
329
                        self.spk = scriptpubkey
×
330
                        self.set_state(PaymentIdentifierState.AVAILABLE)
×
331
                    except Exception as e:
×
332
                        self.error = str(e)
×
333
                        self.set_state(PaymentIdentifierState.NOT_FOUND)
×
334
                elif self.emaillike:
×
335
                    lnurl = lightning_address_to_url(self.emaillike)
×
336
                    try:
×
337
                        data = await request_lnurl(lnurl)
×
338
                        self._type = PaymentIdentifierType.LNADDR
×
339
                        self.lnurl = lnurl
×
340
                        self.lnurl_data = data
×
341
                        self.set_state(PaymentIdentifierState.LNURLP_FINALIZE)
×
342
                    except LNURLError as e:
×
343
                        self.set_state(PaymentIdentifierState.NOT_FOUND)
×
344
                    except Exception as e:
×
345
                        # NOTE: any other exception is swallowed here (e.g. DNS error)
346
                        # as the user may be typing and we have an incomplete emaillike
347
                        self.set_state(PaymentIdentifierState.NOT_FOUND)
×
348
                else:
349
                    self.set_state(PaymentIdentifierState.NOT_FOUND)
×
350
            elif self.bip70:
×
351
                pr = await paymentrequest.get_payment_request(self.bip70)
×
352
                if pr.verify():
×
353
                    self.bip70_data = pr
×
354
                    self.set_state(PaymentIdentifierState.MERCHANT_NOTIFY)
×
355
                else:
356
                    self.error = pr.error
×
357
                    self.set_state(PaymentIdentifierState.ERROR)
×
358
            elif self.lnurl:
×
359
                data = await request_lnurl(self.lnurl)
×
360
                self.lnurl_data = data
×
361
                self.set_state(PaymentIdentifierState.LNURLP_FINALIZE)
×
362
                self.logger.debug(f'LNURL data: {data!r}')
×
363
            else:
364
                self.set_state(PaymentIdentifierState.ERROR)
×
365
                return
×
366
        except Exception as e:
×
367
            self.error = str(e)
×
368
            self.logger.error(f"_do_resolve() got error: {e!r}")
×
369
            self.set_state(PaymentIdentifierState.ERROR)
×
370
        finally:
371
            if on_finished:
×
372
                on_finished(self)
×
373

374
    def finalize(
4✔
375
        self,
376
        *,
377
        amount_sat: int = 0,
378
        comment: str = None,
379
        on_finished: Callable[['PaymentIdentifier'], None] = None,
380
    ):
381
        assert self._state == PaymentIdentifierState.LNURLP_FINALIZE
×
382
        coro = self._do_finalize(amount_sat=amount_sat, comment=comment, on_finished=on_finished)
×
383
        asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop())
×
384

385
    @log_exceptions
4✔
386
    async def _do_finalize(
4✔
387
        self,
388
        *,
389
        amount_sat: int = None,
390
        comment: str = None,
391
        on_finished: Callable[['PaymentIdentifier'], None] = None,
392
    ):
393
        from .invoices import Invoice
×
394
        try:
×
395
            if not self.lnurl_data:
×
396
                raise Exception("Unexpected missing LNURL data")
×
397

398
            if not (self.lnurl_data.min_sendable_sat <= amount_sat <= self.lnurl_data.max_sendable_sat):
×
399
                self.error = _('Amount must be between {} and {} sat.').format(
×
400
                    self.lnurl_data.min_sendable_sat, self.lnurl_data.max_sendable_sat)
401
                self.set_state(PaymentIdentifierState.INVALID_AMOUNT)
×
402
                return
×
403

404
            if self.lnurl_data.comment_allowed == 0:
×
405
                comment = None
×
406
            params = {'amount': amount_sat * 1000}
×
407
            if comment:
×
408
                params['comment'] = comment
×
409

410
            try:
×
411
                invoice_data = await callback_lnurl(self.lnurl_data.callback_url, params=params)
×
412
            except LNURLError as e:
×
413
                self.error = f"LNURL request encountered error: {e}"
×
414
                self.set_state(PaymentIdentifierState.ERROR)
×
415
                return
×
416

417
            bolt11_invoice = invoice_data.get('pr')
×
418
            invoice = Invoice.from_bech32(bolt11_invoice)
×
419
            if invoice.get_amount_sat() != amount_sat:
×
420
                raise Exception("lnurl returned invoice with wrong amount")
×
421
            # this will change what is returned by get_fields_for_GUI
422
            self.bolt11 = invoice
×
423
            self.set_state(PaymentIdentifierState.AVAILABLE)
×
424
        except Exception as e:
×
425
            self.error = str(e)
×
426
            self.logger.error(f"_do_finalize() got error: {e!r}")
×
427
            self.set_state(PaymentIdentifierState.ERROR)
×
428
        finally:
429
            if on_finished:
×
430
                on_finished(self)
×
431

432
    def notify_merchant(
4✔
433
        self,
434
        *,
435
        tx: 'Transaction',
436
        refund_address: str,
437
        on_finished: Callable[['PaymentIdentifier'], None] = None,
438
    ):
439
        assert self._state == PaymentIdentifierState.MERCHANT_NOTIFY
×
440
        assert tx
×
441
        assert refund_address
×
442
        coro = self._do_notify_merchant(tx, refund_address, on_finished=on_finished)
×
443
        asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop())
×
444

445
    @log_exceptions
4✔
446
    async def _do_notify_merchant(
4✔
447
        self,
448
        tx: 'Transaction',
449
        refund_address: str,
450
        *,
451
        on_finished: Callable[['PaymentIdentifier'], None] = None,
452
    ):
453
        try:
×
454
            if not self.bip70_data:
×
455
                self.set_state(PaymentIdentifierState.ERROR)
×
456
                return
×
457

458
            ack_status, ack_msg = await self.bip70_data.send_payment_and_receive_paymentack(tx.serialize(), refund_address)
×
459
            self.logger.info(f"Payment ACK: {ack_status}. Ack message: {ack_msg}")
×
460
            self.merchant_ack_status = ack_status
×
461
            self.merchant_ack_message = ack_msg
×
462
            self.set_state(PaymentIdentifierState.MERCHANT_ACK)
×
463
        except Exception as e:
×
464
            self.error = str(e)
×
465
            self.logger.error(f"_do_notify_merchant() got error: {e!r}")
×
466
            self.set_state(PaymentIdentifierState.MERCHANT_ERROR)
×
467
        finally:
468
            if on_finished:
×
469
                on_finished(self)
×
470

471
    def get_onchain_outputs(self, amount):
4✔
472
        if self.bip70:
4✔
473
            return self.bip70_data.get_outputs()
×
474
        elif self.multiline_outputs:
4✔
475
            return self.multiline_outputs
×
476
        elif self.spk:
4✔
477
            return [PartialTxOutput(scriptpubkey=self.spk, value=amount)]
×
478
        elif self.bip21:
4✔
479
            address = self.bip21.get('address')
4✔
480
            scriptpubkey, is_address = self.parse_output(address)
4✔
481
            assert is_address  # unlikely, but make sure it is an address, not a script
4✔
482
            return [PartialTxOutput(scriptpubkey=scriptpubkey, value=amount)]
4✔
483
        else:
484
            raise Exception('not onchain')
×
485

486
    def _parse_as_multiline(self, text: str):
4✔
487
        # filter out empty lines
488
        lines = text.split('\n')
4✔
489
        lines = [i for i in lines if i]
4✔
490
        is_multiline = len(lines) > 1
4✔
491
        outputs = []  # type: List[PartialTxOutput]
4✔
492
        errors = ''
4✔
493
        total = 0
4✔
494
        self._is_max = False
4✔
495
        for i, line in enumerate(lines):
4✔
496
            try:
4✔
497
                output = self.parse_address_and_amount(line)
4✔
498
                outputs.append(output)
4✔
499
                if parse_max_spend(output.value):
4✔
500
                    self._is_max = True
4✔
501
                else:
502
                    total += output.value
4✔
503
            except Exception as e:
4✔
504
                errors = f'{errors}line #{i}: {str(e)}\n'
4✔
505
                continue
4✔
506
        if is_multiline and errors:
4✔
507
            self.error = errors.strip() if errors else None
×
508
        self.logger.debug(f'multiline: {outputs!r}, {self.error}')
4✔
509
        return outputs
4✔
510

511
    def parse_address_and_amount(self, line: str) -> PartialTxOutput:
4✔
512
        try:
4✔
513
            x, y = line.split(',')
4✔
514
        except ValueError:
4✔
515
            raise Exception("expected two comma-separated values: (address, amount)") from None
4✔
516
        scriptpubkey, is_address = self.parse_output(x)
4✔
517
        if not scriptpubkey:
4✔
518
            raise Exception('Invalid address')
×
519
        amount = self.parse_amount(y)
4✔
520
        return PartialTxOutput(scriptpubkey=scriptpubkey, value=amount)
4✔
521

522
    def parse_output(self, x: str) -> Tuple[Optional[bytes], bool]:
4✔
523
        try:
4✔
524
            address = self.parse_address(x)
4✔
525
            return bitcoin.address_to_script(address), True
4✔
526
        except Exception as e:
4✔
527
            pass
4✔
528
        try:
4✔
529
            m = re.match('^' + RE_SCRIPT_FN + '$', x)
4✔
530
            script = self.parse_script(str(m.group(1)))
4✔
531
            return script, False
4✔
532
        except Exception as e:
4✔
533
            pass
4✔
534

535
        return None, False
4✔
536

537
    def parse_script(self, x: str) -> bytes:
4✔
538
        script = bytearray()
4✔
539
        for word in x.split():
4✔
540
            if word[0:3] == 'OP_':
4✔
541
                opcode_int = opcodes[word]
4✔
542
                script += construct_script([opcode_int])
4✔
543
            else:
544
                bytes.fromhex(word)  # to test it is hex data
4✔
545
                script += construct_script([word])
4✔
546
        return bytes(script)
4✔
547

548
    def parse_amount(self, x: str) -> Union[str, int]:
4✔
549
        x = x.strip()
4✔
550
        if not x:
4✔
551
            raise Exception("Amount is empty")
×
552
        if parse_max_spend(x):
4✔
553
            return x
4✔
554
        p = pow(10, self.config.get_decimal_point())
4✔
555
        try:
4✔
556
            return int(p * Decimal(x))
4✔
557
        except InvalidOperation:
×
558
            raise Exception("Invalid amount")
×
559

560
    def parse_address(self, line: str):
4✔
561
        r = line.strip()
4✔
562
        m = re.match('^' + RE_ALIAS + '$', r)
4✔
563
        address = str(m.group(2) if m else r)
4✔
564
        assert bitcoin.is_address(address)
4✔
565
        return address
4✔
566

567
    def _get_error_from_invoiceerror(self, e: 'InvoiceError') -> str:
4✔
568
        error = _("Error parsing Lightning invoice") + f":\n{e!r}"
×
569
        if e.args and len(e.args):
×
570
            arg = e.args[0]
×
571
            if isinstance(arg, LnInvoiceException):
×
572
                error = _("Error parsing Lightning invoice") + f":\n{e}"
×
573
            elif isinstance(arg, IncompatibleOrInsaneFeatures):
×
574
                error = _("Invoice requires unknown or incompatible Lightning feature") + f":\n{e!r}"
×
575
        return error
×
576

577
    def get_fields_for_GUI(self) -> FieldsForGUI:
4✔
578
        recipient = None
×
579
        amount = None
×
580
        description = None
×
581
        validated = None
×
582
        comment = None
×
583
        amount_range = None
×
584

585
        if (self.emaillike or self.domainlike) and self.openalias_data:
×
586
            key = self.emaillike if self.emaillike else self.domainlike
×
587
            address = self.openalias_data.get('address')
×
588
            name = self.openalias_data.get('name')
×
589
            description = name
×
590
            recipient = key + ' <' + address + '>'
×
591
            validated = self.openalias_data.get('validated')
×
592
            if not validated:
×
593
                self.warning = _('WARNING: the alias "{}" could not be validated via an additional '
×
594
                                 'security check, DNSSEC, and thus may not be correct.').format(key)
595

596
        elif self.bolt11:
×
597
            recipient, amount, description = self._get_bolt11_fields()
×
598

599
        elif self.lnurl and self.lnurl_data:
×
600
            domain = urllib.parse.urlparse(self.lnurl).netloc
×
601
            recipient = f"{self.lnurl_data.metadata_plaintext} <{domain}>"
×
602
            description = self.lnurl_data.metadata_plaintext
×
603
            if self.lnurl_data.comment_allowed:
×
604
                comment = self.lnurl_data.comment_allowed
×
605
            if self.lnurl_data.min_sendable_sat:
×
606
                amount = self.lnurl_data.min_sendable_sat
×
607
                if self.lnurl_data.min_sendable_sat != self.lnurl_data.max_sendable_sat:
×
608
                    amount_range = (self.lnurl_data.min_sendable_sat, self.lnurl_data.max_sendable_sat)
×
609

610
        elif self.bip70 and self.bip70_data:
×
611
            pr = self.bip70_data
×
612
            if pr.error:
×
613
                self.error = pr.error
×
614
            else:
615
                recipient = pr.get_requestor()
×
616
                amount = pr.get_amount()
×
617
                description = pr.get_memo()
×
618
                validated = not pr.has_expired()
×
619

620
        elif self.spk:
×
621
            pass
×
622

623
        elif self.multiline_outputs:
×
624
            pass
×
625

626
        elif self.bip21:
×
627
            label = self.bip21.get('label')
×
628
            address = self.bip21.get('address')
×
629
            recipient = f'{label} <{address}>' if label else address
×
630
            amount = self.bip21.get('amount')
×
631
            description = self.bip21.get('message')
×
632
            # TODO: use label as description? (not BIP21 compliant)
633
            # if label and not description:
634
            #     description = label
635

636
        return FieldsForGUI(recipient=recipient, amount=amount, description=description,
×
637
                            comment=comment, validated=validated, amount_range=amount_range)
638

639
    def _get_bolt11_fields(self):
4✔
640
        lnaddr = self.bolt11._lnaddr # TODO: improve access to lnaddr
×
641
        pubkey = lnaddr.pubkey.serialize().hex()
×
642
        for k, v in lnaddr.tags:
×
643
            if k == 'd':
×
644
                description = v
×
645
                break
×
646
        else:
647
            description = ''
×
648
        amount = lnaddr.get_amount_sat()
×
649
        return pubkey, amount, description
×
650

651
    async def resolve_openalias(self, key: str) -> Optional[dict]:
4✔
652
        # TODO: below check needed? we already matched RE_EMAIL/RE_DOMAIN
653
        # if not (('.' in key) and ('<' not in key) and (' ' not in key)):
654
        #     return None
655
        parts = key.split(sep=',')  # assuming single line
×
656
        if parts and len(parts) > 0 and bitcoin.is_address(parts[0]):
×
657
            return None
×
658
        try:
×
NEW
659
            data = await self.contacts.resolve(key)  # TODO: don't use contacts as delegate to resolve openalias, separate.
×
660
            return data
×
661
        except AliasNotFoundException as e:
×
662
            self.logger.info(f'OpenAlias not found: {repr(e)}')
×
663
            return None
×
UNCOV
664
        except Exception as e:
×
UNCOV
665
            self.logger.info(f'error resolving address/alias: {repr(e)}')
×
UNCOV
666
            return None
×
667

668
    def has_expired(self):
4✔
669
        if self.bip70 and self.bip70_data:
4✔
UNCOV
670
            return self.bip70_data.has_expired()
×
671
        elif self.bolt11:
4✔
672
            return self.bolt11.has_expired()
4✔
673
        elif self.bip21:
4✔
674
            expires = self.bip21.get('exp') + self.bip21.get('time') if self.bip21.get('exp') else 0
4✔
675
            return bool(expires) and expires < time.time()
4✔
UNCOV
676
        return False
×
677

678

679
def invoice_from_payment_identifier(
4✔
680
    pi: 'PaymentIdentifier',
681
    wallet: 'Abstract_Wallet',
682
    amount_sat: Union[int, str],
683
    message: str = None
684
) -> Optional[Invoice]:
685
    assert pi.state in [PaymentIdentifierState.AVAILABLE, PaymentIdentifierState.MERCHANT_NOTIFY]
4✔
686
    assert pi.is_onchain() if amount_sat == '!' else True  # MAX should only be allowed if pi has onchain destination
4✔
687

688
    if pi.is_lightning() and not amount_sat == '!':
4✔
689
        invoice = pi.bolt11
4✔
690
        if not invoice:
4✔
UNCOV
691
            return
×
692
        if invoice._lnaddr.get_amount_msat() is None:
4✔
693
            invoice.set_amount_msat(int(amount_sat * 1000))
4✔
694
        return invoice
4✔
695
    else:
696
        outputs = pi.get_onchain_outputs(amount_sat)
4✔
697
        message = pi.bip21.get('message') if pi.bip21 else message
4✔
698
        bip70_data = pi.bip70_data if pi.bip70 else None
4✔
699
        return wallet.create_invoice(
4✔
700
            outputs=outputs,
701
            message=message,
702
            pr=bip70_data,
703
            URI=pi.bip21)
704

705

706
# Note: this is only really used for bip70 to handle MECHANT_NOTIFY state from
707
# a saved bip70 invoice.
708
# TODO: reflect bip70-only in function name, or implement other types as well.
709
def payment_identifier_from_invoice(
4✔
710
    wallet: 'Abstract_Wallet',
711
    invoice: Invoice
712
) -> Optional[PaymentIdentifier]:
713
    if not invoice:
×
714
        return
×
715
    pi = PaymentIdentifier(wallet, '')
×
716
    if invoice.bip70:
×
717
        pi._type = PaymentIdentifierType.BIP70
×
UNCOV
718
        pi.bip70_data = paymentrequest.PaymentRequest(bytes.fromhex(invoice.bip70))
×
UNCOV
719
        pi.set_state(PaymentIdentifierState.MERCHANT_NOTIFY)
×
UNCOV
720
        return pi
×
721
    # else:
722
    #     if invoice.outputs:
723
    #         if len(invoice.outputs) > 1:
724
    #             pi._type = PaymentIdentifierType.MULTILINE
725
    #             pi.multiline_outputs = invoice.outputs
726
    #             pi.set_state(PaymentIdentifierState.AVAILABLE)
727
    #         else:
728
    #             pi._type = PaymentIdentifierType.BIP21
729
    #             params = {}
730
    #             if invoice.exp:
731
    #                 params['exp'] = str(invoice.exp)
732
    #             if invoice.time:
733
    #                 params['time'] = str(invoice.time)
734
    #             pi.bip21 = create_bip21_uri(invoice.outputs[0].address, invoice.get_amount_sat(), invoice.message,
735
    #                                         extra_query_params=params)
736
    #             pi.set_state(PaymentIdentifierState.AVAILABLE)
737
    #     elif invoice.is_lightning():
738
    #         pi._type = PaymentIdentifierType.BOLT11
739
    #         pi.bolt11 = invoice
740
    #         pi.set_state(PaymentIdentifierState.AVAILABLE)
741
    #     else:
742
    #         return None
743
    #     return pi
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc