electrum

Electrum Bitcoin wallet
git clone https://git.parazyd.org/electrum
Log | Files | Refs | Submodules

commit 0d156bc3e91696b98f9b9d45e6b6b2ba5eed03ca
parent 0b16f8ec3a6297f83548acc773ca810cc289dcf2
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:
Melectrum/commands.py | 18++++--------------
Melectrum/gui/kivy/uix/dialogs/invoice_dialog.py | 8++++----
Melectrum/gui/kivy/uix/dialogs/lightning_open_channel.py | 2+-
Melectrum/gui/kivy/uix/dialogs/request_dialog.py | 6+++---
Melectrum/gui/kivy/uix/screens.py | 30++++++++++++++++++------------
Melectrum/gui/qt/amountedit.py | 9+++++----
Melectrum/gui/qt/invoice_list.py | 2+-
Melectrum/gui/qt/main_window.py | 36+++++++++++++++++++++++-------------
Melectrum/gui/qt/request_list.py | 13++++++++++---
Melectrum/invoices.py | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Melectrum/json_db.py | 3+++
Melectrum/lnaddr.py | 15++++++++++++---
Melectrum/lnchannel.py | 2+-
Melectrum/lnworker.py | 44++++++++++++++++++++++++++------------------
Melectrum/paymentrequest.py | 2+-
Melectrum/tests/test_lnpeer.py | 2+-
Melectrum/util.py | 1+
Melectrum/wallet.py | 59++++++++++++++++++++++++++++++++++++-----------------------
Melectrum/wallet_db.py | 32++++++++++++++++++++++++++++----
19 files changed, 267 insertions(+), 135 deletions(-)

diff --git a/electrum/commands.py b/electrum/commands.py @@ -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) @@ -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 [ { diff --git a/electrum/gui/kivy/uix/dialogs/invoice_dialog.py b/electrum/gui/kivy/uix/dialogs/invoice_dialog.py @@ -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 @@ -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 [] @@ -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): diff --git a/electrum/gui/kivy/uix/dialogs/lightning_open_channel.py b/electrum/gui/kivy/uix/dialogs/lightning_open_channel.py @@ -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) diff --git a/electrum/gui/kivy/uix/dialogs/request_dialog.py b/electrum/gui/kivy/uix/dialogs/request_dialog.py @@ -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: @@ -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() @@ -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): diff --git a/electrum/gui/kivy/uix/screens.py b/electrum/gui/kivy/uix/screens.py @@ -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 @@ -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 @@ -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 { @@ -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): @@ -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) @@ -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) diff --git a/electrum/gui/qt/amountedit.py b/electrum/gui/qt/amountedit.py @@ -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: @@ -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): diff --git a/electrum/gui/qt/invoice_list.py b/electrum/gui/qt/invoice_list.py @@ -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) diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py @@ -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) @@ -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) @@ -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: @@ -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() @@ -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 @@ -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') @@ -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) @@ -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() @@ -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) diff --git a/electrum/gui/qt/request_list.py b/electrum/gui/qt/request_list.py @@ -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 @@ -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' diff --git a/electrum/invoices.py b/electrum/invoices.py @@ -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 @@ -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". @@ -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 @@ -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(), @@ -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) diff --git a/electrum/json_db.py b/electrum/json_db.py @@ -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 diff --git a/electrum/lnaddr.py b/electrum/lnaddr.py @@ -6,6 +6,7 @@ import time from hashlib import sha256 from binascii import hexlify from decimal import Decimal +from typing import Optional import bitstring @@ -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: @@ -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, diff --git a/electrum/lnchannel.py b/electrum/lnchannel.py @@ -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]) diff --git a/electrum/lnworker.py b/electrum/lnworker.py @@ -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 @@ -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) @@ -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: @@ -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() @@ -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) @@ -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) @@ -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: @@ -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] @@ -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] diff --git a/electrum/paymentrequest.py b/electrum/paymentrequest.py @@ -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 diff --git a/electrum/tests/test_lnpeer.py b/electrum/tests/test_lnpeer.py @@ -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) diff --git a/electrum/util.py b/electrum/util.py @@ -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 diff --git a/electrum/wallet.py b/electrum/wallet.py @@ -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 @@ -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]), @@ -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): @@ -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): @@ -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)) @@ -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) @@ -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, @@ -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: @@ -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, @@ -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 @@ -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) @@ -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] diff --git a/electrum/wallet_db.py b/electrum/wallet_db.py @@ -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 @@ -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 @@ -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() @@ -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 @@ -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':