URI: 
       tMerge pull request #6256 from SomberNight/202006_invoices_need_msat_precision_2 - electrum - Electrum Bitcoin wallet
  HTML git clone https://git.parazyd.org/electrum
   DIR Log
   DIR Files
   DIR Refs
   DIR Submodules
       ---
   DIR commit 0d156bc3e91696b98f9b9d45e6b6b2ba5eed03ca
   DIR parent 0b16f8ec3a6297f83548acc773ca810cc289dcf2
  HTML Author: ThomasV <thomasv@electrum.org>
       Date:   Tue, 23 Jun 2020 17:30:55 +0200
       
       Merge pull request #6256 from SomberNight/202006_invoices_need_msat_precision_2
       
       LN invoices: support msat precision (alt 2nd approach)
       Diffstat:
         M electrum/commands.py                |      18 ++++--------------
         M electrum/gui/kivy/uix/dialogs/invo… |       8 ++++----
         M electrum/gui/kivy/uix/dialogs/ligh… |       2 +-
         M electrum/gui/kivy/uix/dialogs/requ… |       6 +++---
         M electrum/gui/kivy/uix/screens.py    |      30 ++++++++++++++++++------------
         M electrum/gui/qt/amountedit.py       |       9 +++++----
         M electrum/gui/qt/invoice_list.py     |       2 +-
         M electrum/gui/qt/main_window.py      |      36 ++++++++++++++++++++-----------
         M electrum/gui/qt/request_list.py     |      13 ++++++++++---
         M electrum/invoices.py                |     118 +++++++++++++++++++++++--------
         M electrum/json_db.py                 |       3 +++
         M electrum/lnaddr.py                  |      15 ++++++++++++---
         M electrum/lnchannel.py               |       2 +-
         M electrum/lnworker.py                |      44 ++++++++++++++++++-------------
         M electrum/paymentrequest.py          |       2 +-
         M electrum/tests/test_lnpeer.py       |       2 +-
         M electrum/util.py                    |       1 +
         M electrum/wallet.py                  |      59 +++++++++++++++++++------------
         M electrum/wallet_db.py               |      32 +++++++++++++++++++++++++++----
       
       19 files changed, 267 insertions(+), 135 deletions(-)
       ---
   DIR diff --git a/electrum/commands.py b/electrum/commands.py
       t@@ -990,23 +990,14 @@ class Commands:
                return chan.funding_outpoint.to_str()
        
            @command('')
       -    async def decode_invoice(self, invoice):
       -        from .lnaddr import lndecode
       -        lnaddr = lndecode(invoice)
       -        return {
       -            'pubkey': lnaddr.pubkey.serialize().hex(),
       -            'amount_BTC': lnaddr.amount,
       -            'rhash': lnaddr.paymenthash.hex(),
       -            'description': lnaddr.get_description(),
       -            'exp': lnaddr.get_expiry(),
       -            'time': lnaddr.date,
       -            #'tags': str(lnaddr.tags),
       -        }
       +    async def decode_invoice(self, invoice: str):
       +        invoice = LNInvoice.from_bech32(invoice)
       +        return invoice.to_debug_json()
        
            @command('wn')
            async def lnpay(self, invoice, attempts=1, timeout=30, wallet: Abstract_Wallet = None):
                lnworker = wallet.lnworker
       -        lnaddr = lnworker._check_invoice(invoice, None)
       +        lnaddr = lnworker._check_invoice(invoice)
                payment_hash = lnaddr.paymenthash
                wallet.save_invoice(LNInvoice.from_bech32(invoice))
                success, log = await lnworker._pay(invoice, attempts=attempts)
       t@@ -1026,7 +1017,6 @@ class Commands:
            async def list_channels(self, wallet: Abstract_Wallet = None):
                # we output the funding_outpoint instead of the channel_id because lnd uses channel_point (funding outpoint) to identify channels
                from .lnutil import LOCAL, REMOTE, format_short_channel_id
       -        encoder = util.MyEncoder()
                l = list(wallet.lnworker.channels.items())
                return [
                    {
   DIR diff --git a/electrum/gui/kivy/uix/dialogs/invoice_dialog.py b/electrum/gui/kivy/uix/dialogs/invoice_dialog.py
       t@@ -44,7 +44,7 @@ Builder.load_string('''
                    RefLabel:
                        data: root.description or _('No description')
                    TopLabel:
       -                text: _('Amount') + ': ' + app.format_amount_and_units(root.amount)
       +                text: _('Amount') + ': ' + app.format_amount_and_units(root.amount_sat)
                    TopLabel:
                        text: _('Status') + ': ' + root.status_str
                        color: root.status_color
       t@@ -93,9 +93,9 @@ class InvoiceDialog(Factory.Popup):
                self.data = data
                self.key = key
                invoice = self.app.wallet.get_invoice(key)
       -        self.amount = invoice.amount
       +        self.amount_sat = invoice.get_amount_sat()
                self.description = invoice.message
       -        self.is_lightning = invoice.type == PR_TYPE_LN
       +        self.is_lightning = invoice.is_lightning()
                self.update_status()
                self.log = self.app.wallet.lnworker.logs[self.key] if self.is_lightning else []
        
       t@@ -106,7 +106,7 @@ class InvoiceDialog(Factory.Popup):
                self.status_color = pr_color[self.status]
                self.can_pay = self.status in [PR_UNPAID, PR_FAILED]
                if self.can_pay and self.is_lightning and self.app.wallet.lnworker:
       -            if self.amount and self.amount > self.app.wallet.lnworker.num_sats_can_send():
       +            if self.amount_sat and self.amount_sat > self.app.wallet.lnworker.num_sats_can_send():
                        self.warning = _('Warning') + ': ' + _('This amount exceeds the maximum you can currently send with your channels')
        
            def on_dismiss(self):
   DIR diff --git a/electrum/gui/kivy/uix/dialogs/lightning_open_channel.py b/electrum/gui/kivy/uix/dialogs/lightning_open_channel.py
       t@@ -118,7 +118,7 @@ class LightningOpenChannelDialog(Factory.Popup):
                    fee = self.app.electrum_config.fee_per_kb()
                    if not fee:
                        fee = config.FEERATE_FALLBACK_STATIC_FEE
       -            self.amount = self.app.format_amount_and_units(self.lnaddr.amount * COIN + fee * 2)
       +            self.amount = self.app.format_amount_and_units(self.lnaddr.amount * COIN + fee * 2)  # FIXME magic number?!
                    self.pubkey = bh2u(self.lnaddr.pubkey.serialize())
                if self.msg:
                    self.app.show_info(self.msg)
   DIR diff --git a/electrum/gui/kivy/uix/dialogs/request_dialog.py b/electrum/gui/kivy/uix/dialogs/request_dialog.py
       t@@ -44,7 +44,7 @@ Builder.load_string('''
                    TopLabel:
                        text: _('Description') + ': ' + root.description or _('None')
                    TopLabel:
       -                text: _('Amount') + ': ' + app.format_amount_and_units(root.amount)
       +                text: _('Amount') + ': ' + app.format_amount_and_units(root.amount_sat)
                    TopLabel:
                        text: (_('Address') if not root.is_lightning else _('Payment hash')) + ': '
                    RefLabel:
       t@@ -93,7 +93,7 @@ class RequestDialog(Factory.Popup):
                r = self.app.wallet.get_request(key)
                self.is_lightning = r.is_lightning()
                self.data = r.invoice if self.is_lightning else self.app.wallet.get_request_URI(r)
       -        self.amount = r.amount or 0
       +        self.amount_sat = r.get_amount_sat() or 0
                self.description = r.message
                self.update_status()
        
       t@@ -111,7 +111,7 @@ class RequestDialog(Factory.Popup):
                self.status_str = req.get_status_str(self.status)
                self.status_color = pr_color[self.status]
                if self.status == PR_UNPAID and self.is_lightning and self.app.wallet.lnworker:
       -            if self.amount and self.amount > self.app.wallet.lnworker.num_sats_can_receive():
       +            if self.amount_sat and self.amount_sat > self.app.wallet.lnworker.num_sats_can_receive():
                        self.warning = _('Warning') + ': ' + _('This amount exceeds the maximum you can currently receive with your channels')
        
            def on_dismiss(self):
   DIR diff --git a/electrum/gui/kivy/uix/screens.py b/electrum/gui/kivy/uix/screens.py
       t@@ -4,7 +4,7 @@ from decimal import Decimal
        import re
        import threading
        import traceback, sys
       -from typing import TYPE_CHECKING, List, Optional
       +from typing import TYPE_CHECKING, List, Optional, Dict, Any
        
        from kivy.app import App
        from kivy.cache import Cache
       t@@ -26,7 +26,7 @@ from kivy.logger import Logger
        from electrum.util import profiler, parse_URI, format_time, InvalidPassword, NotEnoughFunds, Fiat
        from electrum.invoices import (PR_TYPE_ONCHAIN, PR_TYPE_LN, PR_DEFAULT_EXPIRATION_WHEN_CREATING,
                                       PR_PAID, PR_UNKNOWN, PR_EXPIRED, PR_INFLIGHT,
       -                               LNInvoice, pr_expiration_values)
       +                               LNInvoice, pr_expiration_values, Invoice, OnchainInvoice)
        from electrum import bitcoin, constants
        from electrum.transaction import Transaction, tx_from_any, PartialTransaction, PartialTxOutput
        from electrum.util import parse_URI, InvalidBitcoinURI, TxMinedInfo, maybe_extract_bolt11_invoice
       t@@ -224,17 +224,19 @@ class SendScreen(CScreen):
            def show_item(self, obj):
                self.app.show_invoice(obj.is_lightning, obj.key)
        
       -    def get_card(self, item):
       +    def get_card(self, item: Invoice):
                status = self.app.wallet.get_invoice_status(item)
                status_str = item.get_status_str(status)
                is_lightning = item.type == PR_TYPE_LN
                if is_lightning:
       +            assert isinstance(item, LNInvoice)
                    key = item.rhash
                    log = self.app.wallet.lnworker.logs.get(key)
                    if status == PR_INFLIGHT and log:
                        status_str += '... (%d)'%len(log)
                    is_bip70 = False
                else:
       +            assert isinstance(item, OnchainInvoice)
                    key = item.id
                    is_bip70 = bool(item.bip70)
                return {
       t@@ -245,7 +247,7 @@ class SendScreen(CScreen):
                    'status_str': status_str,
                    'key': key,
                    'memo': item.message,
       -            'amount': self.app.format_amount_and_units(item.amount or 0),
       +            'amount': self.app.format_amount_and_units(item.get_amount_sat() or 0),
                }
        
            def do_clear(self):
       t@@ -345,16 +347,18 @@ class SendScreen(CScreen):
                    else:
                        do_pay(False)
        
       -    def _do_pay_lightning(self, invoice):
       -        attempts = 10
       +    def _do_pay_lightning(self, invoice: LNInvoice) -> None:
                threading.Thread(
                    target=self.app.wallet.lnworker.pay,
       -            args=(invoice.invoice, invoice.amount),
       -            kwargs={'attempts':10}).start()
       +            args=(invoice.invoice,),
       +            kwargs={
       +                'attempts': 10,
       +            },
       +        ).start()
        
       -    def _do_pay_onchain(self, invoice, rbf):
       +    def _do_pay_onchain(self, invoice: OnchainInvoice, rbf: bool) -> None:
                # make unsigned transaction
       -        outputs = invoice.outputs  # type: List[PartialTxOutput]
       +        outputs = invoice.outputs
                coins = self.app.wallet.get_spendable_coins(None)
                try:
                    tx = self.app.wallet.make_unsigned_transaction(coins=coins, outputs=outputs)
       t@@ -482,15 +486,17 @@ class ReceiveScreen(CScreen):
                self.update()
                self.app.show_request(lightning, key)
        
       -    def get_card(self, req):
       +    def get_card(self, req: Invoice) -> Dict[str, Any]:
                is_lightning = req.is_lightning()
                if not is_lightning:
       +            assert isinstance(req, OnchainInvoice)
                    address = req.get_address()
                    key = address
                else:
       +            assert isinstance(req, LNInvoice)
                    key = req.rhash
                    address = req.invoice
       -        amount = req.amount
       +        amount = req.get_amount_sat()
                description = req.message
                status = self.app.wallet.get_request_status(key)
                status_str = req.get_status_str(status)
   DIR diff --git a/electrum/gui/qt/amountedit.py b/electrum/gui/qt/amountedit.py
       t@@ -92,6 +92,7 @@ class BTCAmountEdit(AmountEdit):
                return decimal_point_to_base_unit_name(self.decimal_point())
        
            def get_amount(self):
       +        # returns amt in satoshis
                try:
                    x = Decimal(str(self.text()))
                except:
       t@@ -106,11 +107,11 @@ class BTCAmountEdit(AmountEdit):
                amount = Decimal(max_prec_amount) / pow(10, self.max_precision()-self.decimal_point())
                return Decimal(amount) if not self.is_int else int(amount)
        
       -    def setAmount(self, amount):
       -        if amount is None:
       -            self.setText(" ") # Space forces repaint in case units changed
       +    def setAmount(self, amount_sat):
       +        if amount_sat is None:
       +            self.setText(" ")  # Space forces repaint in case units changed
                else:
       -            self.setText(format_satoshis_plain(amount, decimal_point=self.decimal_point()))
       +            self.setText(format_satoshis_plain(amount_sat, decimal_point=self.decimal_point()))
        
        
        class FeerateEdit(BTCAmountEdit):
   DIR diff --git a/electrum/gui/qt/invoice_list.py b/electrum/gui/qt/invoice_list.py
       t@@ -110,7 +110,7 @@ class InvoiceList(MyTreeView):
                    status = self.parent.wallet.get_invoice_status(item)
                    status_str = item.get_status_str(status)
                    message = item.message
       -            amount = item.amount
       +            amount = item.get_amount_sat()
                    timestamp = item.time or 0
                    date_str = format_time(timestamp) if timestamp else _('Unknown')
                    amount_str = self.parent.format_amount(amount, whitespaces=True)
   DIR diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py
       t@@ -61,7 +61,7 @@ from electrum.util import (format_time,
                                   get_new_wallet_name, send_exception_to_crash_reporter,
                                   InvalidBitcoinURI, maybe_extract_bolt11_invoice, NotEnoughFunds,
                                   NoDynamicFeeEstimates, MultipleSpendMaxTxOutputs)
       -from electrum.invoices import PR_TYPE_ONCHAIN, PR_TYPE_LN, PR_DEFAULT_EXPIRATION_WHEN_CREATING
       +from electrum.invoices import PR_TYPE_ONCHAIN, PR_TYPE_LN, PR_DEFAULT_EXPIRATION_WHEN_CREATING, Invoice
        from electrum.invoices import PR_PAID, PR_FAILED, pr_expiration_values, LNInvoice, OnchainInvoice
        from electrum.transaction import (Transaction, PartialTxInput,
                                          PartialTransaction, PartialTxOutput)
       t@@ -159,6 +159,8 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger):
            show_privkeys_signal = pyqtSignal()
            show_error_signal = pyqtSignal(str)
        
       +    payment_request: Optional[paymentrequest.PaymentRequest]
       +
            def __init__(self, gui_object: 'ElectrumGui', wallet: Abstract_Wallet):
                QMainWindow.__init__(self)
        
       t@@ -877,9 +879,11 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger):
                self.notify_transactions()
        
            def format_amount(self, x, is_diff=False, whitespaces=False):
       +        # x is in sats
                return self.config.format_amount(x, is_diff=is_diff, whitespaces=whitespaces)
        
            def format_amount_and_units(self, amount):
       +        # amount is in sats
                text = self.config.format_amount_and_units(amount)
                x = self.fx.format_amount_and_units(amount) if self.fx else None
                if text and x:
       t@@ -1480,13 +1484,17 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger):
        
                return False  # no errors
        
       -    def pay_lightning_invoice(self, invoice: str, amount_sat: int):
       +    def pay_lightning_invoice(self, invoice: str, *, amount_msat: Optional[int]):
       +        if amount_msat is None:
       +            raise Exception("missing amount for LN invoice")
       +        amount_sat = Decimal(amount_msat) / 1000
       +        # FIXME this is currently lying to user as we truncate to satoshis
                msg = _("Pay lightning invoice?") + '\n\n' + _("This will send {}?").format(self.format_amount_and_units(amount_sat))
                if not self.question(msg):
                    return
                attempts = LN_NUM_PAYMENT_ATTEMPTS
                def task():
       -            self.wallet.lnworker.pay(invoice, amount_sat, attempts=attempts)
       +            self.wallet.lnworker.pay(invoice, amount_msat=amount_msat, attempts=attempts)
                self.do_clear()
                self.wallet.thread.add(task)
                self.invoice_list.update()
       t@@ -1523,10 +1531,10 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger):
                        self.show_error(_('Lightning is disabled'))
                        return
                    invoice = LNInvoice.from_bech32(invoice_str)
       -            if invoice.amount is None:
       -                amount = self.amount_e.get_amount()
       -                if amount:
       -                    invoice.amount = amount
       +            if invoice.get_amount_msat() is None:
       +                amount_sat = self.amount_e.get_amount()
       +                if amount_sat:
       +                    invoice.amount_msat = int(amount_sat * 1000)
                        else:
                            self.show_error(_('No amount'))
                            return
       t@@ -1565,10 +1573,12 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger):
                    outputs += invoice.outputs
                self.pay_onchain_dialog(self.get_coins(), outputs)
        
       -    def do_pay_invoice(self, invoice):
       +    def do_pay_invoice(self, invoice: 'Invoice'):
                if invoice.type == PR_TYPE_LN:
       -            self.pay_lightning_invoice(invoice.invoice, invoice.amount)
       +            assert isinstance(invoice, LNInvoice)
       +            self.pay_lightning_invoice(invoice.invoice, amount_msat=invoice.get_amount_msat())
                elif invoice.type == PR_TYPE_ONCHAIN:
       +            assert isinstance(invoice, OnchainInvoice)
                    self.pay_onchain_dialog(self.get_coins(), invoice.outputs)
                else:
                    raise Exception('unknown invoice type')
       t@@ -1837,8 +1847,8 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger):
                self.payto_e.setFrozen(True)
                self.payto_e.setText(pubkey)
                self.message_e.setText(description)
       -        if lnaddr.amount is not None:
       -            self.amount_e.setAmount(lnaddr.amount * COIN)
       +        if lnaddr.get_amount_sat() is not None:
       +            self.amount_e.setAmount(lnaddr.get_amount_sat())
                #self.amount_e.textEdited.emit("")
                self.set_onchain(False)
        
       t@@ -1979,7 +1989,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger):
                self.update_completions()
        
            def show_onchain_invoice(self, invoice: OnchainInvoice):
       -        amount_str = self.format_amount(invoice.amount) + ' ' + self.base_unit()
       +        amount_str = self.format_amount(invoice.amount_sat) + ' ' + self.base_unit()
                d = WindowModalDialog(self, _("Onchain Invoice"))
                vbox = QVBoxLayout(d)
                grid = QGridLayout()
       t@@ -2029,7 +2039,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger):
                grid.addWidget(QLabel(_("Node ID") + ':'), 0, 0)
                grid.addWidget(QLabel(lnaddr.pubkey.serialize().hex()), 0, 1)
                grid.addWidget(QLabel(_("Amount") + ':'), 1, 0)
       -        amount_str = self.format_amount(invoice.amount) + ' ' + self.base_unit()
       +        amount_str = self.format_amount(invoice.get_amount_sat()) + ' ' + self.base_unit()
                grid.addWidget(QLabel(amount_str), 1, 1)
                grid.addWidget(QLabel(_("Description") + ':'), 2, 0)
                grid.addWidget(QLabel(invoice.message), 2, 1)
   DIR diff --git a/electrum/gui/qt/request_list.py b/electrum/gui/qt/request_list.py
       t@@ -32,7 +32,7 @@ from PyQt5.QtCore import Qt, QItemSelectionModel, QModelIndex
        
        from electrum.i18n import _
        from electrum.util import format_time
       -from electrum.invoices import PR_TYPE_ONCHAIN, PR_TYPE_LN
       +from electrum.invoices import PR_TYPE_ONCHAIN, PR_TYPE_LN, LNInvoice, OnchainInvoice
        from electrum.plugin import run_hook
        
        from .util import MyTreeView, pr_icons, read_QIcon, webopen, MySortModel
       t@@ -130,21 +130,28 @@ class RequestList(MyTreeView):
                self.std_model.clear()
                self.update_headers(self.__class__.headers)
                for req in self.wallet.get_sorted_requests():
       -            key = req.rhash if req.is_lightning() else req.id
       +            if req.is_lightning():
       +                assert isinstance(req, LNInvoice)
       +                key = req.rhash
       +            else:
       +                assert isinstance(req, OnchainInvoice)
       +                key = req.id
                    status = self.parent.wallet.get_request_status(key)
                    status_str = req.get_status_str(status)
                    request_type = req.type
                    timestamp = req.time
       -            amount = req.amount
       +            amount = req.get_amount_sat()
                    message = req.message
                    date = format_time(timestamp)
                    amount_str = self.parent.format_amount(amount) if amount else ""
                    labels = [date, message, amount_str, status_str]
                    if req.is_lightning():
       +                assert isinstance(req, LNInvoice)
                        key = req.rhash
                        icon = read_QIcon("lightning.png")
                        tooltip = 'lightning request'
                    else:
       +                assert isinstance(req, OnchainInvoice)
                        key = req.get_address()
                        icon = read_QIcon("bitcoin.png")
                        tooltip = 'onchain request'
   DIR diff --git a/electrum/invoices.py b/electrum/invoices.py
       t@@ -1,11 +1,13 @@
       -import attr
        import time
       -from typing import TYPE_CHECKING, List
       +from typing import TYPE_CHECKING, List, Optional, Union, Dict, Any
       +from decimal import Decimal
       +
       +import attr
        
        from .json_db import StoredObject
        from .i18n import _
        from .util import age
       -from .lnaddr import lndecode
       +from .lnaddr import lndecode, LnAddr
        from . import constants
        from .bitcoin import COIN
        from .transaction import PartialTxOutput
       t@@ -67,6 +69,7 @@ def _decode_outputs(outputs) -> List[PartialTxOutput]:
                ret.append(output)
            return ret
        
       +
        # hack: BOLT-11 is not really clear on what an expiry of 0 means.
        # It probably interprets it as 0 seconds, so already expired...
        # Our higher level invoices code however uses 0 for "never".
       t@@ -75,11 +78,11 @@ LN_EXPIRY_NEVER = 100 * 365 * 24 * 60 * 60  # 100 years
        
        @attr.s
        class Invoice(StoredObject):
       -    type = attr.ib(type=int)
       -    message = attr.ib(type=str)
       -    amount = attr.ib(type=int)
       -    exp = attr.ib(type=int)
       -    time = attr.ib(type=int)
       +    type = attr.ib(type=int, kw_only=True)
       +
       +    message: str
       +    exp: int
       +    time: int
        
            def is_lightning(self):
                return self.type == PR_TYPE_LN
       t@@ -94,22 +97,42 @@ class Invoice(StoredObject):
                        status_str = _('Pending')
                return status_str
        
       +    def get_amount_sat(self) -> Union[int, Decimal, str, None]:
       +        """Returns a decimal satoshi amount, or '!' or None."""
       +        raise NotImplementedError()
       +
       +    @classmethod
       +    def from_json(cls, x: dict) -> 'Invoice':
       +        # note: these raise if x has extra fields
       +        if x.get('type') == PR_TYPE_LN:
       +            return LNInvoice(**x)
       +        else:
       +            return OnchainInvoice(**x)
       +
       +
        @attr.s
        class OnchainInvoice(Invoice):
       -    id = attr.ib(type=str)
       -    outputs = attr.ib(type=list, converter=_decode_outputs)
       -    bip70 = attr.ib(type=str) # may be None
       -    requestor = attr.ib(type=str) # may be None
       +    message = attr.ib(type=str, kw_only=True)
       +    amount_sat = attr.ib(kw_only=True)  # type: Union[None, int, str]  # in satoshis. can be '!'
       +    exp = attr.ib(type=int, kw_only=True)
       +    time = attr.ib(type=int, kw_only=True)
       +    id = attr.ib(type=str, kw_only=True)
       +    outputs = attr.ib(kw_only=True, converter=_decode_outputs)  # type: List[PartialTxOutput]
       +    bip70 = attr.ib(type=str, kw_only=True)  # type: Optional[str]
       +    requestor = attr.ib(type=str, kw_only=True)  # type: Optional[str]
        
            def get_address(self) -> str:
                assert len(self.outputs) == 1
                return self.outputs[0].address
        
       +    def get_amount_sat(self) -> Union[int, str, None]:
       +        return self.amount_sat
       +
            @classmethod
            def from_bip70_payreq(cls, pr: 'PaymentRequest') -> 'OnchainInvoice':
                return OnchainInvoice(
                    type=PR_TYPE_ONCHAIN,
       -            amount=pr.get_amount(),
       +            amount_sat=pr.get_amount(),
                    outputs=pr.get_outputs(),
                    message=pr.get_memo(),
                    id=pr.get_id(),
       t@@ -121,26 +144,63 @@ class OnchainInvoice(Invoice):
        
        @attr.s
        class LNInvoice(Invoice):
       -    rhash = attr.ib(type=str)
            invoice = attr.ib(type=str)
       +    amount_msat = attr.ib(kw_only=True)  # type: Optional[int]  # needed for zero amt invoices
       +
       +    __lnaddr = None
       +
       +    @property
       +    def _lnaddr(self) -> LnAddr:
       +        if self.__lnaddr is None:
       +            self.__lnaddr = lndecode(self.invoice)
       +        return self.__lnaddr
       +
       +    @property
       +    def rhash(self) -> str:
       +        return self._lnaddr.paymenthash.hex()
       +
       +    def get_amount_msat(self) -> Optional[int]:
       +        amount_btc = self._lnaddr.amount
       +        amount = int(amount_btc * COIN * 1000) if amount_btc else None
       +        return amount or self.amount_msat
       +
       +    def get_amount_sat(self) -> Union[Decimal, None]:
       +        amount_msat = self.get_amount_msat()
       +        if amount_msat is None:
       +            return None
       +        return Decimal(amount_msat) / 1000
       +
       +    @property
       +    def exp(self) -> int:
       +        return self._lnaddr.get_expiry()
       +
       +    @property
       +    def time(self) -> int:
       +        return self._lnaddr.date
       +
       +    @property
       +    def message(self) -> str:
       +        return self._lnaddr.get_description()
        
            @classmethod
       -    def from_bech32(klass, invoice: str) -> 'LNInvoice':
       -        lnaddr = lndecode(invoice, expected_hrp=constants.net.SEGWIT_HRP)
       -        amount = int(lnaddr.amount * COIN) if lnaddr.amount else None
       +    def from_bech32(cls, invoice: str) -> 'LNInvoice':
       +        amount_msat = lndecode(invoice).get_amount_msat()
                return LNInvoice(
       -            type = PR_TYPE_LN,
       -            amount = amount,
       -            message = lnaddr.get_description(),
       -            time = lnaddr.date,
       -            exp = lnaddr.get_expiry(),
       -            rhash = lnaddr.paymenthash.hex(),
       -            invoice = invoice,
       +            type=PR_TYPE_LN,
       +            invoice=invoice,
       +            amount_msat=amount_msat,
                )
        
       +    def to_debug_json(self) -> Dict[str, Any]:
       +        d = self.to_json()
       +        d.update({
       +            'pubkey': self._lnaddr.pubkey.serialize().hex(),
       +            'amount_BTC': self._lnaddr.amount,
       +            'rhash': self._lnaddr.paymenthash.hex(),
       +            'description': self._lnaddr.get_description(),
       +            'exp': self._lnaddr.get_expiry(),
       +            'time': self._lnaddr.date,
       +            # 'tags': str(lnaddr.tags),
       +        })
       +        return d
        
       -def invoice_from_json(x: dict) -> Invoice:
       -    if x.get('type') == PR_TYPE_LN:
       -        return LNInvoice(**x)
       -    else:
       -        return OnchainInvoice(**x)
   DIR diff --git a/electrum/json_db.py b/electrum/json_db.py
       t@@ -60,6 +60,9 @@ class StoredObject:
            def to_json(self):
                d = dict(vars(self))
                d.pop('db', None)
       +        # don't expose/store private stuff
       +        d = {k: v for k, v in d.items()
       +             if not k.startswith('_')}
                return d
        
        
   DIR diff --git a/electrum/lnaddr.py b/electrum/lnaddr.py
       t@@ -6,6 +6,7 @@ import time
        from hashlib import sha256
        from binascii import hexlify
        from decimal import Decimal
       +from typing import Optional
        
        import bitstring
        
       t@@ -33,7 +34,7 @@ def shorten_amount(amount):
                    break
            return str(amount) + unit
        
       -def unshorten_amount(amount):
       +def unshorten_amount(amount) -> Decimal:
            """ Given a shortened amount, convert it into a decimal
            """
            # BOLT #11:
       t@@ -271,12 +272,20 @@ class LnAddr(object):
                self.signature = None
                self.pubkey = None
                self.currency = constants.net.SEGWIT_HRP if currency is None else currency
       -        self.amount = amount  # in bitcoins
       +        self.amount = amount  # type: Optional[Decimal]  # in bitcoins
                self._min_final_cltv_expiry = 9
        
       -    def get_amount_sat(self):
       +    def get_amount_sat(self) -> Optional[Decimal]:
       +        # note that this has msat resolution potentially
       +        if self.amount is None:
       +            return None
                return self.amount * COIN
        
       +    def get_amount_msat(self) -> Optional[int]:
       +        if self.amount is None:
       +            return None
       +        return int(self.amount * COIN * 1000)
       +
            def __str__(self):
                return "LnAddr[{}, amount={}{} tags=[{}]]".format(
                    hexlify(self.pubkey.serialize()).decode('utf-8') if self.pubkey else None,
   DIR diff --git a/electrum/lnchannel.py b/electrum/lnchannel.py
       t@@ -136,7 +136,7 @@ class RevokeAndAck(NamedTuple):
        class RemoteCtnTooFarInFuture(Exception): pass
        
        
       -def htlcsum(htlcs):
       +def htlcsum(htlcs: Iterable[UpdateAddHtlc]):
            return sum([x.amount_msat for x in htlcs])
        
        
   DIR diff --git a/electrum/lnworker.py b/electrum/lnworker.py
       t@@ -133,7 +133,7 @@ FALLBACK_NODE_LIST_MAINNET = [
        
        class PaymentInfo(NamedTuple):
            payment_hash: bytes
       -    amount: int  # in satoshis
       +    amount: Optional[int]  # in satoshis  # TODO make it msat and rename to amount_msat
            direction: int
            status: int
        
       t@@ -491,7 +491,7 @@ class LNWallet(LNWorker):
                self.lnwatcher = None
                self.features |= LnFeatures.OPTION_DATA_LOSS_PROTECT_REQ
                self.features |= LnFeatures.OPTION_STATIC_REMOTEKEY_REQ
       -        self.payments = self.db.get_dict('lightning_payments')     # RHASH -> amount, direction, is_paid
       +        self.payments = self.db.get_dict('lightning_payments')     # RHASH -> amount, direction, is_paid  # FIXME amt should be msat
                self.preimages = self.db.get_dict('lightning_preimages')   # RHASH -> preimage
                self.sweep_address = wallet.get_new_sweep_address_for_channel()  # TODO possible address-reuse
                self.logs = defaultdict(list)  # type: Dict[str, List[PaymentAttemptLog]]  # key is RHASH  # (not persisted)
       t@@ -597,7 +597,7 @@ class LNWallet(LNWorker):
                        out[k] += v
                return out
        
       -    def get_payment_value(self, info, plist):
       +    def get_payment_value(self, info: Optional['PaymentInfo'], plist):
                amount_msat = 0
                fee_msat = None
                for chan_id, htlc, _direction in plist:
       t@@ -832,11 +832,11 @@ class LNWallet(LNWorker):
                    raise Exception(_("open_channel timed out"))
                return chan, funding_tx
        
       -    def pay(self, invoice: str, amount_sat: int = None, *, attempts: int = 1) -> Tuple[bool, List[PaymentAttemptLog]]:
       +    def pay(self, invoice: str, *, amount_msat: int = None, attempts: int = 1) -> Tuple[bool, List[PaymentAttemptLog]]:
                """
                Can be called from other threads
                """
       -        coro = self._pay(invoice, amount_sat, attempts=attempts)
       +        coro = self._pay(invoice, amount_msat=amount_msat, attempts=attempts)
                fut = asyncio.run_coroutine_threadsafe(coro, self.network.asyncio_loop)
                return fut.result()
        
       t@@ -846,10 +846,15 @@ class LNWallet(LNWorker):
                        return chan
        
            @log_exceptions
       -    async def _pay(self, invoice: str, amount_sat: int = None, *,
       -                   attempts: int = 1,
       -                   full_path: LNPaymentPath = None) -> Tuple[bool, List[PaymentAttemptLog]]:
       -        lnaddr = self._check_invoice(invoice, amount_sat)
       +    async def _pay(
       +            self,
       +            invoice: str,
       +            *,
       +            amount_msat: int = None,
       +            attempts: int = 1,
       +            full_path: LNPaymentPath = None,
       +    ) -> Tuple[bool, List[PaymentAttemptLog]]:
       +        lnaddr = self._check_invoice(invoice, amount_msat=amount_msat)
                payment_hash = lnaddr.paymenthash
                key = payment_hash.hex()
                amount = int(lnaddr.amount * COIN)
       t@@ -901,7 +906,7 @@ class LNWallet(LNWorker):
                await peer.initialized
                htlc = peer.pay(route=route,
                                chan=chan,
       -                        amount_msat=int(lnaddr.amount * COIN * 1000),
       +                        amount_msat=lnaddr.get_amount_msat(),
                                payment_hash=lnaddr.paymenthash,
                                min_final_cltv_expiry=lnaddr.get_min_final_cltv_expiry(),
                                payment_secret=lnaddr.payment_secret)
       t@@ -993,12 +998,15 @@ class LNWallet(LNWorker):
                return blacklist
        
            @staticmethod
       -    def _check_invoice(invoice: str, amount_sat: int = None) -> LnAddr:
       +    def _check_invoice(invoice: str, *, amount_msat: int = None) -> LnAddr:
                addr = lndecode(invoice, expected_hrp=constants.net.SEGWIT_HRP)
                if addr.is_expired():
                    raise InvoiceError(_("This invoice has expired"))
       -        if amount_sat:
       -            addr.amount = Decimal(amount_sat) / COIN
       +        if amount_msat:  # replace amt in invoice. main usecase is paying zero amt invoices
       +            existing_amt_msat = addr.get_amount_msat()
       +            if existing_amt_msat and amount_msat < existing_amt_msat:
       +                raise Exception("cannot pay lower amt than what is originally in LN invoice")
       +            addr.amount = Decimal(amount_msat) / COIN / 1000
                if addr.amount is None:
                    raise InvoiceError(_("Missing amount"))
                if addr.get_min_final_cltv_expiry() > lnutil.NBLOCK_CLTV_EXPIRY_TOO_FAR_INTO_FUTURE:
       t@@ -1010,7 +1018,7 @@ class LNWallet(LNWorker):
            @profiler
            def _create_route_from_invoice(self, decoded_invoice: 'LnAddr',
                                           *, full_path: LNPaymentPath = None) -> LNPaymentRoute:
       -        amount_msat = int(decoded_invoice.amount * COIN * 1000)
       +        amount_msat = decoded_invoice.get_amount_msat()
                invoice_pubkey = decoded_invoice.pubkey.serialize()
                # use 'r' field from invoice
                route = None  # type: Optional[LNPaymentRoute]
       t@@ -1310,11 +1318,11 @@ class LNWallet(LNWorker):
                    return Decimal(max(chan.available_to_spend(REMOTE) if chan.is_open() else 0
                                       for chan in self.channels.values()))/1000 if self.channels else 0
        
       -    def can_pay_invoice(self, invoice):
       -        return invoice.amount <= self.num_sats_can_send()
       +    def can_pay_invoice(self, invoice: LNInvoice) -> bool:
       +        return invoice.get_amount_sat() <= self.num_sats_can_send()
        
       -    def can_receive_invoice(self, invoice):
       -        return invoice.amount <= self.num_sats_can_receive()
       +    def can_receive_invoice(self, invoice: LNInvoice) -> bool:
       +        return invoice.get_amount_sat() <= self.num_sats_can_receive()
        
            async def close_channel(self, chan_id):
                chan = self._channels[chan_id]
   DIR diff --git a/electrum/paymentrequest.py b/electrum/paymentrequest.py
       t@@ -326,7 +326,7 @@ def make_unsigned_request(req: 'OnchainInvoice'):
                time = 0
            if exp and type(exp) != int:
                exp = 0
       -    amount = req.amount
       +    amount = req.amount_sat
            if amount is None:
                amount = 0
            memo = req.message
   DIR diff --git a/electrum/tests/test_lnpeer.py b/electrum/tests/test_lnpeer.py
       t@@ -586,7 +586,7 @@ class TestPeer(ElectrumTestCase):
                    route = w1._create_route_from_invoice(decoded_invoice=lnaddr)
                    htlc = p1.pay(route=route,
                                  chan=alice_channel,
       -                          amount_msat=int(lnaddr.amount * COIN * 1000),
       +                          amount_msat=lnaddr.get_amount_msat(),
                                  payment_hash=lnaddr.paymenthash,
                                  min_final_cltv_expiry=lnaddr.get_min_final_cltv_expiry(),
                                  payment_secret=lnaddr.payment_secret)
   DIR diff --git a/electrum/util.py b/electrum/util.py
       t@@ -793,6 +793,7 @@ def block_explorer_URL(config: 'SimpleConfig', kind: str, item: str) -> Optional
        class InvalidBitcoinURI(Exception): pass
        
        
       +# TODO rename to parse_bip21_uri or similar
        def parse_URI(uri: str, on_pr: Callable = None, *, loop=None) -> dict:
            """Raises InvalidBitcoinURI on malformed URI."""
            from . import bitcoin
   DIR diff --git a/electrum/wallet.py b/electrum/wallet.py
       t@@ -70,7 +70,7 @@ from .transaction import (Transaction, TxInput, UnknownTxinType, TxOutput,
        from .plugin import run_hook
        from .address_synchronizer import (AddressSynchronizer, TX_HEIGHT_LOCAL,
                                           TX_HEIGHT_UNCONF_PARENT, TX_HEIGHT_UNCONFIRMED, TX_HEIGHT_FUTURE)
       -from .invoices import Invoice, OnchainInvoice, invoice_from_json, LNInvoice
       +from .invoices import Invoice, OnchainInvoice, LNInvoice
        from .invoices import PR_PAID, PR_UNPAID, PR_UNKNOWN, PR_EXPIRED, PR_INFLIGHT, PR_TYPE_ONCHAIN, PR_TYPE_LN
        from .contacts import Contacts
        from .interface import NetworkException
       t@@ -693,7 +693,7 @@ class Abstract_Wallet(AddressSynchronizer, ABC):
                    amount = sum(x.value for x in outputs)
                invoice = OnchainInvoice(
                    type=PR_TYPE_ONCHAIN,
       -            amount=amount,
       +            amount_sat=amount,
                    outputs=outputs,
                    message=message,
                    id=bh2u(sha256(repr(outputs))[0:16]),
       t@@ -738,7 +738,7 @@ class Abstract_Wallet(AddressSynchronizer, ABC):
            def import_requests(self, path):
                data = read_json_file(path)
                for x in data:
       -            req = invoice_from_json(x)
       +            req = Invoice.from_json(x)
                    self.add_payment_request(req)
        
            def export_requests(self, path):
       t@@ -747,7 +747,7 @@ class Abstract_Wallet(AddressSynchronizer, ABC):
            def import_invoices(self, path):
                data = read_json_file(path)
                for x in data:
       -            invoice = invoice_from_json(x)
       +            invoice = Invoice.from_json(x)
                    self.save_invoice(invoice)
        
            def export_invoices(self, path):
       t@@ -1630,7 +1630,7 @@ class Abstract_Wallet(AddressSynchronizer, ABC):
            def get_request_URI(self, req: OnchainInvoice) -> str:
                addr = req.get_address()
                message = self.labels.get(addr, '')
       -        amount = req.amount
       +        amount = req.amount_sat
                extra_query_params = {}
                if req.time:
                    extra_query_params['time'] = str(int(req.time))
       t@@ -1663,9 +1663,11 @@ class Abstract_Wallet(AddressSynchronizer, ABC):
                if r is None:
                    return PR_UNKNOWN
                if r.is_lightning():
       +            assert isinstance(r, LNInvoice)
                    status = self.lnworker.get_payment_status(bfh(r.rhash)) if self.lnworker else PR_UNKNOWN
                else:
       -            paid, conf = self.get_payment_status(r.get_address(), r.amount)
       +            assert isinstance(r, OnchainInvoice)
       +            paid, conf = self.get_payment_status(r.get_address(), r.amount_sat)
                    status = PR_PAID if paid else PR_UNPAID
                return self.check_expired_status(r, status)
        
       t@@ -1689,8 +1691,7 @@ class Abstract_Wallet(AddressSynchronizer, ABC):
                is_lightning = x.is_lightning()
                d = {
                    'is_lightning': is_lightning,
       -            'amount': x.amount,
       -            'amount_BTC': format_satoshis(x.amount),
       +            'amount_BTC': format_satoshis(x.get_amount_sat()),
                    'message': x.message,
                    'timestamp': x.time,
                    'expiration': x.exp,
       t@@ -1698,13 +1699,19 @@ class Abstract_Wallet(AddressSynchronizer, ABC):
                    'status_str': status_str,
                }
                if is_lightning:
       +            assert isinstance(x, LNInvoice)
                    d['rhash'] = x.rhash
                    d['invoice'] = x.invoice
       +            d['amount_msat'] = x.get_amount_msat()
                    if self.lnworker and status == PR_UNPAID:
                        d['can_receive'] = self.lnworker.can_receive_invoice(x)
                else:
       +            assert isinstance(x, OnchainInvoice)
       +            amount_sat = x.get_amount_sat()
       +            assert isinstance(amount_sat, (int, str, type(None)))
       +            d['amount_sat'] = amount_sat
                    addr = x.get_address()
       -            paid, conf = self.get_payment_status(addr, x.amount)
       +            paid, conf = self.get_payment_status(addr, x.amount_sat)
                    d['address'] = addr
                    d['URI'] = self.get_request_URI(x)
                    if conf is not None:
       t@@ -1728,8 +1735,7 @@ class Abstract_Wallet(AddressSynchronizer, ABC):
                is_lightning = x.is_lightning()
                d = {
                    'is_lightning': is_lightning,
       -            'amount': x.amount,
       -            'amount_BTC': format_satoshis(x.amount),
       +            'amount_BTC': format_satoshis(x.get_amount_sat()),
                    'message': x.message,
                    'timestamp': x.time,
                    'expiration': x.exp,
       t@@ -1739,10 +1745,14 @@ class Abstract_Wallet(AddressSynchronizer, ABC):
                if is_lightning:
                    assert isinstance(x, LNInvoice)
                    d['invoice'] = x.invoice
       +            d['amount_msat'] = x.get_amount_msat()
                    if self.lnworker and status == PR_UNPAID:
                        d['can_pay'] = self.lnworker.can_pay_invoice(x)
                else:
                    assert isinstance(x, OnchainInvoice)
       +            amount_sat = x.get_amount_sat()
       +            assert isinstance(amount_sat, (int, str, type(None)))
       +            d['amount_sat'] = amount_sat
                    d['outputs'] = [y.to_legacy_tuple() for y in x.outputs]
                    if x.bip70:
                        d['bip70'] = x.bip70
       t@@ -1757,20 +1767,23 @@ class Abstract_Wallet(AddressSynchronizer, ABC):
                        status = self.get_request_status(addr)
                        util.trigger_callback('request_status', addr, status)
        
       -    def make_payment_request(self, address, amount, message, expiration):
       -        amount = amount or 0
       +    def make_payment_request(self, address, amount_sat, message, expiration):
       +        # TODO maybe merge with wallet.create_invoice()...
       +        #      note that they use incompatible "id"
       +        amount_sat = amount_sat or 0
                timestamp = int(time.time())
                _id = bh2u(sha256d(address + "%d"%timestamp))[0:10]
                return OnchainInvoice(
       -            type = PR_TYPE_ONCHAIN,
       -            outputs = [(TYPE_ADDRESS, address, amount)],
       -            message = message,
       -            time = timestamp,
       -            amount = amount,
       -            exp = expiration,
       -            id = _id,
       -            bip70 = None,
       -            requestor = None)
       +            type=PR_TYPE_ONCHAIN,
       +            outputs=[(TYPE_ADDRESS, address, amount_sat)],
       +            message=message,
       +            time=timestamp,
       +            amount_sat=amount_sat,
       +            exp=expiration,
       +            id=_id,
       +            bip70=None,
       +            requestor=None,
       +        )
        
            def sign_payment_request(self, key, alias, alias_addr, password):  # FIXME this is broken
                req = self.receive_requests.get(key)
       t@@ -1820,7 +1833,7 @@ class Abstract_Wallet(AddressSynchronizer, ABC):
                self.receive_requests.pop(addr)
                return True
        
       -    def get_sorted_requests(self):
       +    def get_sorted_requests(self) -> List[Invoice]:
                """ sorted by timestamp """
                out = [self.get_request(x) for x in self.receive_requests.keys()]
                out = [x for x in out if x is not None]
   DIR diff --git a/electrum/wallet_db.py b/electrum/wallet_db.py
       t@@ -33,7 +33,7 @@ import binascii
        
        from . import util, bitcoin
        from .util import profiler, WalletFileException, multisig_type, TxMinedInfo, bfh
       -from .invoices import PR_TYPE_ONCHAIN, invoice_from_json
       +from .invoices import PR_TYPE_ONCHAIN, Invoice
        from .keystore import bip44_derivation
        from .transaction import Transaction, TxOutpoint, tx_from_any, PartialTransaction, PartialTxOutput
        from .logging import Logger
       t@@ -52,7 +52,7 @@ if TYPE_CHECKING:
        
        OLD_SEED_VERSION = 4        # electrum versions < 2.0
        NEW_SEED_VERSION = 11       # electrum versions >= 2.0
       -FINAL_SEED_VERSION = 29     # electrum >= 2.7 will set this to prevent
       +FINAL_SEED_VERSION = 30     # electrum >= 2.7 will set this to prevent
                                    # old versions from overwriting new format
        
        
       t@@ -177,6 +177,7 @@ class WalletDB(JsonDB):
                self._convert_version_27()
                self._convert_version_28()
                self._convert_version_29()
       +        self._convert_version_30()
                self.put('seed_version', FINAL_SEED_VERSION)  # just to be sure
        
                self._after_upgrade_tasks()
       t@@ -643,6 +644,29 @@ class WalletDB(JsonDB):
                        d[key] = item
                self.data['seed_version'] = 29
        
       +    def _convert_version_30(self):
       +        if not self._is_upgrade_method_needed(29, 29):
       +            return
       +
       +        from .invoices import PR_TYPE_ONCHAIN, PR_TYPE_LN
       +        requests = self.data.get('payment_requests', {})
       +        invoices = self.data.get('invoices', {})
       +        for d in [invoices, requests]:
       +            for key, item in list(d.items()):
       +                _type = item['type']
       +                if _type == PR_TYPE_ONCHAIN:
       +                    item['amount_sat'] = item.pop('amount')
       +                elif _type == PR_TYPE_LN:
       +                    amount_sat = item.pop('amount')
       +                    item['amount_msat'] = 1000 * amount_sat if amount_sat is not None else None
       +                    item.pop('exp')
       +                    item.pop('message')
       +                    item.pop('rhash')
       +                    item.pop('time')
       +                else:
       +                    raise Exception(f"unknown invoice type: {_type}")
       +        self.data['seed_version'] = 30
       +
            def _convert_imported(self):
                if not self._is_upgrade_method_needed(0, 13):
                    return
       t@@ -1127,9 +1151,9 @@ class WalletDB(JsonDB):
                    # note: for performance, "deserialize=False" so that we will deserialize these on-demand
                    v = dict((k, tx_from_any(x, deserialize=False)) for k, x in v.items())
                if key == 'invoices':
       -            v = dict((k, invoice_from_json(x)) for k, x in v.items())
       +            v = dict((k, Invoice.from_json(x)) for k, x in v.items())
                if key == 'payment_requests':
       -            v = dict((k, invoice_from_json(x)) for k, x in v.items())
       +            v = dict((k, Invoice.from_json(x)) for k, x in v.items())
                elif key == 'adds':
                    v = dict((k, UpdateAddHtlc.from_tuple(*x)) for k, x in v.items())
                elif key == 'fee_updates':