tRestructure invoices and requests (WIP) - Terminology: use 'invoices' for outgoing payments, 'requests' for incoming payments - At the GUI level, try to handle invoices in a generic way. - Display ongoing payments in send tab. - electrum - Electrum Bitcoin wallet HTML git clone https://git.parazyd.org/electrum DIR Log DIR Files DIR Refs DIR Submodules --- DIR commit a50f935aecc1a47dffce195808ed3b1839c8e3a0 DIR parent 3902d774f7ced2311ab07e5309dac2eeff2af0c7 HTML Author: ThomasV <thomasv@electrum.org> Date: Sun, 11 Aug 2019 14:47:06 +0200 Restructure invoices and requests (WIP) - Terminology: use 'invoices' for outgoing payments, 'requests' for incoming payments - At the GUI level, try to handle invoices in a generic way. - Display ongoing payments in send tab. Diffstat: M electrum/gui/kivy/main_window.py | 47 +++++++++++++++++++++++-------- A electrum/gui/kivy/uix/dialogs/invo… | 97 ++++++++++++++++++++++++++++++ M electrum/gui/kivy/uix/screens.py | 183 ++++++++++++++++++------------- M electrum/gui/kivy/uix/ui_screens/s… | 108 ++++++++++++++++++++++--------- M electrum/gui/qt/history_list.py | 6 +++--- M electrum/gui/qt/invoice_list.py | 80 +++++++++++++------------------ M electrum/gui/qt/main_window.py | 74 ++++++++++++++----------------- M electrum/gui/qt/request_list.py | 9 ++++++--- M electrum/lnchannel.py | 13 ++++++++----- M electrum/lnworker.py | 117 ++++++++++++++++++++++--------- M electrum/paymentrequest.py | 104 +++---------------------------- M electrum/util.py | 19 +++++++++++++------ M electrum/wallet.py | 65 ++++++++++++++++++++++++++++--- 13 files changed, 563 insertions(+), 359 deletions(-) --- DIR diff --git a/electrum/gui/kivy/main_window.py b/electrum/gui/kivy/main_window.py t@@ -11,11 +11,10 @@ import asyncio from electrum.bitcoin import TYPE_ADDRESS from electrum.storage import WalletStorage from electrum.wallet import Wallet, InternalAddressCorruption -from electrum.paymentrequest import InvoiceStore from electrum.util import profiler, InvalidPassword, send_exception_to_crash_reporter from electrum.plugin import run_hook from electrum.util import format_satoshis, format_satoshis_plain, format_fee_satoshis -from electrum.paymentrequest import PR_UNPAID, PR_PAID, PR_UNKNOWN, PR_EXPIRED +from electrum.util import PR_UNPAID, PR_PAID, PR_UNKNOWN, PR_EXPIRED from electrum import blockchain from electrum.network import Network, TxBroadcastError, BestEffortRequestFailed from .i18n import _ t@@ -201,6 +200,19 @@ class ElectrumWindow(App): if status == PR_PAID: self.show_info(_('Payment Received') + '\n' + key) + def on_payment_status(self, event, key, status, *args): + self.update_tab('send') + if status == 'success': + self.show_info(_('Payment was sent')) + self._trigger_update_history() + elif status == 'progress': + pass + elif status == 'failure': + self.show_info(_('Payment failed')) + elif status == 'error': + e = args[0] + self.show_error(_('Error') + '\n' + str(e)) + def _get_bu(self): decimal_point = self.electrum_config.get('decimal_point', DECIMAL_POINT_DEFAULT) try: t@@ -343,19 +355,16 @@ class ElectrumWindow(App): self.show_error(_('No wallet loaded.')) return if pr.verify(self.wallet.contacts): - key = self.wallet.invoices.add(pr) - if self.invoices_screen: - self.invoices_screen.update() - status = self.wallet.invoices.get_status(key) - if status == PR_PAID: + key = pr.get_id() + invoice = self.wallet.get_invoice(key) + if invoice and invoice['status'] == PR_PAID: self.show_error("invoice already paid") self.send_screen.do_clear() + elif pr.has_expired(): + self.show_error(_('Payment request has expired')) else: - if pr.has_expired(): - self.show_error(_('Payment request has expired')) - else: - self.switch_to('send') - self.send_screen.set_request(pr) + self.switch_to('send') + self.send_screen.set_request(pr) else: self.show_error("invoice error:" + pr.error) self.send_screen.do_clear() t@@ -418,6 +427,19 @@ class ElectrumWindow(App): self.request_popup.set_status(status) self.request_popup.open() + def show_invoice(self, is_lightning, key): + from .uix.dialogs.invoice_dialog import InvoiceDialog + invoice = self.wallet.get_invoice(key) + if not invoice: + return + status = invoice['status'] + if is_lightning: + data = invoice['invoice'] + else: + data = key + self.invoice_popup = InvoiceDialog('Invoice', data, key) + self.invoice_popup.open() + def qr_dialog(self, title, data, show_text=False, text_for_clipboard=None): from .uix.dialogs.qr_dialog import QRDialog def on_qr_failure(): t@@ -519,6 +541,7 @@ class ElectrumWindow(App): self.network.register_callback(self.on_payment_received, ['payment_received']) self.network.register_callback(self.on_channels, ['channels']) self.network.register_callback(self.on_channel, ['channel']) + self.network.register_callback(self.on_payment_status, ['payment_status']) # load wallet self.load_wallet_by_name(self.electrum_config.get_wallet_path()) # URI passed in config DIR diff --git a/electrum/gui/kivy/uix/dialogs/invoice_dialog.py b/electrum/gui/kivy/uix/dialogs/invoice_dialog.py t@@ -0,0 +1,97 @@ +from kivy.factory import Factory +from kivy.lang import Builder +from kivy.core.clipboard import Clipboard +from kivy.app import App +from kivy.clock import Clock + +from electrum.gui.kivy.i18n import _ +from electrum.util import pr_tooltips + + +Builder.load_string(''' +<InvoiceDialog@Popup> + id: popup + title: '' + data: '' + status: 'unknown' + shaded: False + show_text: False + AnchorLayout: + anchor_x: 'center' + BoxLayout: + orientation: 'vertical' + size_hint: 1, 1 + padding: '10dp' + spacing: '10dp' + TopLabel: + text: root.data + TopLabel: + text: _('Status') + ': ' + root.status + Widget: + size_hint: 1, 0.2 + BoxLayout: + size_hint: 1, None + height: '48dp' + Button: + size_hint: 1, None + height: '48dp' + text: _('Delete') + on_release: root.delete_dialog() + IconButton: + icon: 'atlas://electrum/gui/kivy/theming/light/copy' + size_hint: 0.5, None + height: '48dp' + on_release: root.copy_to_clipboard() + IconButton: + icon: 'atlas://electrum/gui/kivy/theming/light/share' + size_hint: 0.5, None + height: '48dp' + on_release: root.do_share() + Button: + size_hint: 1, None + height: '48dp' + text: _('Pay') + on_release: root.do_pay() +''') + +class InvoiceDialog(Factory.Popup): + + def __init__(self, title, data, key): + Factory.Popup.__init__(self) + self.app = App.get_running_app() + self.title = title + self.data = data + self.key = key + + #def on_open(self): + # self.ids.qr.set_data(self.data) + + def set_status(self, status): + self.status = pr_tooltips[status] + + def on_dismiss(self): + self.app.request_popup = None + + def copy_to_clipboard(self): + Clipboard.copy(self.data) + msg = _('Text copied to clipboard.') + Clock.schedule_once(lambda dt: self.app.show_info(msg)) + + def do_share(self): + self.app.do_share(self.data, _("Share Invoice")) + self.dismiss() + + def do_pay(self): + invoice = self.app.wallet.get_invoice(self.key) + self.app.send_screen.do_pay_invoice(invoice) + self.dismiss() + + def delete_dialog(self): + from .question import Question + def cb(result): + if result: + self.app.wallet.delete_invoice(self.key) + self.dismiss() + self.app.send_screen.update() + d = Question(_('Delete invoice?'), cb) + d.open() DIR diff --git a/electrum/gui/kivy/uix/screens.py b/electrum/gui/kivy/uix/screens.py t@@ -4,7 +4,6 @@ from decimal import Decimal import re import threading import traceback, sys -from enum import Enum, auto from kivy.app import App from kivy.cache import Cache t@@ -23,6 +22,7 @@ from kivy.factory import Factory from kivy.utils import platform from electrum.util import profiler, parse_URI, format_time, InvalidPassword, NotEnoughFunds, Fiat +from electrum.util import PR_TYPE_ADDRESS, PR_TYPE_LN, PR_TYPE_BIP70 from electrum import bitcoin, constants from electrum.transaction import TxOutput, Transaction, tx_from_str from electrum.util import send_exception_to_crash_reporter, parse_URI, InvalidBitcoinURI t@@ -38,10 +38,6 @@ from .dialogs.lightning_open_channel import LightningOpenChannelDialog from electrum.gui.kivy.i18n import _ -class Destination(Enum): - Address = auto() - PR = auto() - LN = auto() class HistoryRecycleView(RecycleView): pass t@@ -49,6 +45,9 @@ class HistoryRecycleView(RecycleView): class RequestRecycleView(RecycleView): pass +class PaymentRecycleView(RecycleView): + pass + class CScreen(Factory.Screen): __events__ = ('on_activate', 'on_deactivate', 'on_enter', 'on_leave') action_view = ObjectProperty(None) t@@ -119,14 +118,12 @@ class HistoryScreen(CScreen): super(HistoryScreen, self).__init__(**kwargs) def show_item(self, obj): - print(obj) key = obj.key tx = self.app.wallet.db.get_transaction(key) if not tx: return self.app.tx_dialog(tx) - def get_card(self, tx_item): #tx_hash, tx_mined_status, value, balance): is_lightning = tx_item.get('lightning', False) timestamp = tx_item['timestamp'] t@@ -192,7 +189,7 @@ class SendScreen(CScreen): self.screen.message = uri.get('message', '') self.screen.amount = self.app.format_amount_and_units(amount) if amount else '' self.payment_request = None - self.screen.destinationtype = Destination.Address + self.screen.destinationtype = PR_TYPE_ADDRESS def set_ln_invoice(self, invoice): try: t@@ -204,19 +201,47 @@ class SendScreen(CScreen): self.screen.message = dict(lnaddr.tags).get('d', None) self.screen.amount = self.app.format_amount_and_units(lnaddr.amount * bitcoin.COIN) if lnaddr.amount else '' self.payment_request = None - self.screen.destinationtype = Destination.LN + self.screen.destinationtype = PR_TYPE_LN def update(self): + if not self.loaded: + return if self.app.wallet and self.payment_request_queued: self.set_URI(self.payment_request_queued) self.payment_request_queued = None + _list = self.app.wallet.get_invoices() + payments_container = self.screen.ids.payments_container + payments_container.data = [self.get_card(item) for item in _list if item['status'] != PR_PAID] + + def show_item(self, obj): + self.app.show_invoice(obj.is_lightning, obj.key) + + def get_card(self, item): + invoice_type = item['type'] + if invoice_type == PR_TYPE_LN: + key = item['rhash'] + status = get_request_status(item) # convert to str + elif invoice_type == PR_TYPE_BIP70: + key = item['id'] + status = get_request_status(item) # convert to str + elif invoice_type == PR_TYPE_ADDRESS: + key = item['address'] + status = get_request_status(item) # convert to str + return { + 'is_lightning': invoice_type == PR_TYPE_LN, + 'screen': self, + 'status': status, + 'key': key, + 'memo': item['message'], + 'amount': self.app.format_amount_and_units(item['amount'] or 0), + } def do_clear(self): self.screen.amount = '' self.screen.message = '' self.screen.address = '' self.payment_request = None - self.screen.destinationtype = Destination.Address + self.screen.destinationtype = PR_TYPE_ADDRESS def set_request(self, pr): self.screen.address = pr.get_requestor() t@@ -224,32 +249,10 @@ class SendScreen(CScreen): self.screen.amount = self.app.format_amount_and_units(amount) if amount else '' self.screen.message = pr.get_memo() if pr.is_pr(): - self.screen.destinationtype = Destination.PR - self.payment_request = pr - else: - self.screen.destinationtype = Destination.Address - self.payment_request = None - - def save_invoice(self): - if not self.screen.address: - return - if self.screen.destinationtype == Destination.PR: - # it should be already saved - return - # save address as invoice - from electrum.paymentrequest import make_unsigned_request, PaymentRequest - req = {'address':self.screen.address, 'memo':self.screen.message} - amount = self.app.get_amount(self.screen.amount) if self.screen.amount else 0 - req['amount'] = amount - pr = make_unsigned_request(req).SerializeToString() - pr = PaymentRequest(pr) - self.app.wallet.invoices.add(pr) - #self.app.show_info(_("Invoice saved")) - if pr.is_pr(): - self.screen.destinationtype = Destination.PR + self.screen.destinationtype = PR_TYPE_BIP70 self.payment_request = pr else: - self.screen.destinationtype = Destination.Address + self.screen.destinationtype = PR_TYPE_ADDRESS self.payment_request = None def do_paste(self): t@@ -275,63 +278,87 @@ class SendScreen(CScreen): self.set_ln_invoice(lower) else: self.set_URI(data) - # save automatically - self.save_invoice() - def _do_send_lightning(self): + def read_invoice(self): + address = str(self.screen.address) + if not address: + self.app.show_error(_('Recipient not specified.') + ' ' + _('Please scan a Bitcoin address or a payment request')) + return if not self.screen.amount: - self.app.show_error(_('Since the invoice contained no amount, you must enter one')) + self.app.show_error(_('Please enter an amount')) return - invoice = self.screen.address - amount_sat = self.app.get_amount(self.screen.amount) - threading.Thread(target=self._lnpay_thread, args=(invoice, amount_sat)).start() - - def _lnpay_thread(self, invoice, amount_sat): - self.do_clear() - self.app.show_info(_('Payment in progress..')) try: - success = self.app.wallet.lnworker.pay(invoice, attempts=10, amount_sat=amount_sat, timeout=60) - except PaymentFailure as e: - self.app.show_error(_('Payment failure') + '\n' + str(e)) - return - if success: - self.app.show_info(_('Payment was sent')) - self.app._trigger_update_history() - else: - self.app.show_error(_('Payment failed')) - - def do_send(self): - if self.screen.destinationtype == Destination.LN: - self._do_send_lightning() + amount = self.app.get_amount(self.screen.amount) + except: + self.app.show_error(_('Invalid amount') + ':\n' + self.screen.amount) return - elif self.screen.destinationtype == Destination.PR: - if self.payment_request.has_expired(): - self.app.show_error(_('Payment request has expired')) - return - outputs = self.payment_request.get_outputs() - else: - address = str(self.screen.address) - if not address: - self.app.show_error(_('Recipient not specified.') + ' ' + _('Please scan a Bitcoin address or a payment request')) - return + message = self.screen.message + if self.screen.destinationtype == PR_TYPE_LN: + return { + 'type': PR_TYPE_LN, + 'invoice': address, + 'amount': amount, + 'message': message, + } + elif self.screen.destinationtype == PR_TYPE_ADDRESS: if not bitcoin.is_address(address): self.app.show_error(_('Invalid Bitcoin Address') + ':\n' + address) return - try: - amount = self.app.get_amount(self.screen.amount) - except: - self.app.show_error(_('Invalid amount') + ':\n' + self.screen.amount) + return { + 'type': PR_TYPE_ADDRESS, + 'address': address, + 'amount': amount, + 'message': message, + } + elif self.screen.destinationtype == PR_TYPE_BIP70: + if self.payment_request.has_expired(): + self.app.show_error(_('Payment request has expired')) return + return self.payment_request.get_dict() + else: + raise Exception('Unknown invoice type') + + def do_save(self): + invoice = self.read_invoice() + if not invoice: + return + self.app.wallet.save_invoice(invoice) + self.do_clear() + self.update() + + def do_pay(self): + invoice = self.read_invoice() + if not invoice: + return + self.app.wallet.save_invoice(invoice) + self.do_clear() + self.update() + self.do_pay_invoice(invoice) + + def do_pay_invoice(self, invoice): + if invoice['type'] == PR_TYPE_LN: + self._do_send_lightning(invoice['invoice'], invoice['amount']) + return + elif invoice['type'] == PR_TYPE_ADDRESS: + address = invoice['address'] + amount = invoice['amount'] + message = invoice['message'] outputs = [TxOutput(bitcoin.TYPE_ADDRESS, address, amount)] - message = self.screen.message - amount = sum(map(lambda x:x[2], outputs)) + elif invoice['type'] == PR_TYPE_BIP70: + outputs = invoice['outputs'] + amount = sum(map(lambda x:x[2], outputs)) + # onchain payment if self.app.electrum_config.get('use_rbf'): - d = Question(_('Should this transaction be replaceable?'), lambda b: self._do_send(amount, message, outputs, b)) + d = Question(_('Should this transaction be replaceable?'), lambda b: self._do_send_onchain(amount, message, outputs, b)) d.open() else: - self._do_send(amount, message, outputs, False) + self._do_send_onchain(amount, message, outputs, False) + + def _do_send_lightning(self, invoice, amount): + attempts = 10 + threading.Thread(target=self.app.wallet.lnworker.pay, args=(invoice, amount, attempts)).start() - def _do_send(self, amount, message, outputs, rbf): + def _do_send_onchain(self, amount, message, outputs, rbf): # make unsigned transaction config = self.app.electrum_config coins = self.app.wallet.get_spendable_coins(None, config) t@@ -447,7 +474,7 @@ class ReceiveScreen(CScreen): self.app.show_request(lightning, key) def get_card(self, req): - is_lightning = req.get('lightning', False) + is_lightning = req.get('type') == PR_TYPE_LN if not is_lightning: address = req['address'] key = address DIR diff --git a/electrum/gui/kivy/uix/ui_screens/send.kv b/electrum/gui/kivy/uix/ui_screens/send.kv t@@ -1,10 +1,66 @@ #:import _ electrum.gui.kivy.i18n._ -#:import Destination electrum.gui.kivy.uix.screens.Destination +#:import Factory kivy.factory.Factory +#:import PR_TYPE_ADDRESS electrum.util.PR_TYPE_ADDRESS +#:import PR_TYPE_LN electrum.util.PR_TYPE_LN +#:import PR_TYPE_BIP70 electrum.util.PR_TYPE_BIP70 #:import Decimal decimal.Decimal #:set btc_symbol chr(171) #:set mbtc_symbol chr(187) #:set font_light 'electrum/gui/kivy/data/fonts/Roboto-Condensed.ttf' +<PaymentLabel@Label> + #color: .305, .309, .309, 1 + text_size: self.width, None + halign: 'left' + valign: 'top' + +<PaymentItem@CardItem> + key: '' + memo: '' + amount: '' + status: '' + date: '' + BoxLayout: + spacing: '8dp' + height: '32dp' + orientation: 'vertical' + Widget + PaymentLabel: + text: root.memo + shorten: True + shorten_from: 'right' + Widget + PaymentLabel: + text: root.key + color: .699, .699, .699, 1 + font_size: '13sp' + shorten: True + Widget + BoxLayout: + spacing: '8dp' + height: '32dp' + orientation: 'vertical' + Widget + PaymentLabel: + text: root.amount + halign: 'right' + font_size: '15sp' + Widget + PaymentLabel: + text: root.status + halign: 'right' + font_size: '13sp' + color: .699, .699, .699, 1 + Widget + +<PaymentRecycleView>: + viewclass: 'PaymentItem' + RecycleBoxLayout: + default_size: None, dp(56) + default_size_hint: 1, None + size_hint: 1, None + height: self.minimum_height + orientation: 'vertical' SendScreen: id: s t@@ -12,7 +68,7 @@ SendScreen: address: '' amount: '' message: '' - destinationtype: Destination.Address + destinationtype: PR_TYPE_ADDRESS BoxLayout padding: '12dp', '12dp', '12dp', '12dp' spacing: '12dp' t@@ -26,7 +82,7 @@ SendScreen: height: blue_bottom.item_height spacing: '5dp' Image: - source: 'atlas://electrum/gui/kivy/theming/light/globe' + source: 'atlas://electrum/gui/kivy/theming/light/globe' if root.destinationtype != PR_TYPE_LN else 'atlas://electrum/gui/kivy/theming/light/lightning' size_hint: None, None size: '22dp', '22dp' pos_hint: {'center_y': .5} t@@ -37,7 +93,7 @@ SendScreen: on_release: Clock.schedule_once(lambda dt: app.show_info(_('Copy and paste the recipient address using the Paste button, or use the camera to scan a QR code.'))) #on_release: Clock.schedule_once(lambda dt: app.popup_dialog('contacts')) CardSeparator: - opacity: int(root.destinationtype == Destination.Address) + opacity: int(root.destinationtype == PR_TYPE_ADDRESS) color: blue_bottom.foreground_color BoxLayout: size_hint: 1, None t@@ -53,10 +109,10 @@ SendScreen: id: amount_e default_text: _('Amount') text: s.amount if s.amount else _('Amount') - disabled: root.destinationtype == Destination.PR or root.destinationtype == Destination.LN and not s.amount + disabled: root.destinationtype == PR_TYPE_BIP70 or root.destinationtype == PR_TYPE_LN and not s.amount on_release: Clock.schedule_once(lambda dt: app.amount_dialog(s, True)) CardSeparator: - opacity: int(root.destinationtype == Destination.Address) + opacity: int(root.destinationtype == PR_TYPE_ADDRESS) color: blue_bottom.foreground_color BoxLayout: id: message_selection t@@ -70,37 +126,40 @@ SendScreen: pos_hint: {'center_y': .5} BlueButton: id: description - text: s.message if s.message else ({Destination.LN: _('No description'), Destination.Address: _('Description'), Destination.PR: _('No Description')}[root.destinationtype]) - disabled: root.destinationtype != Destination.Address + text: s.message if s.message else ({PR_TYPE_LN: _('No description'), PR_TYPE_ADDRESS: _('Description'), PR_TYPE_BIP70: _('No Description')}[root.destinationtype]) + disabled: root.destinationtype != PR_TYPE_ADDRESS on_release: Clock.schedule_once(lambda dt: app.description_dialog(s)) CardSeparator: - opacity: int(root.destinationtype == Destination.Address) + opacity: int(root.destinationtype == PR_TYPE_ADDRESS) color: blue_bottom.foreground_color BoxLayout: size_hint: 1, None - height: blue_bottom.item_height if root.destinationtype != Destination.LN else 0 + height: blue_bottom.item_height spacing: '5dp' Image: source: 'atlas://electrum/gui/kivy/theming/light/star_big_inactive' - opacity: 0.7 if root.destinationtype != Destination.LN else 0 size_hint: None, None size: '22dp', '22dp' pos_hint: {'center_y': .5} BlueButton: id: fee_e default_text: _('Fee') - text: app.fee_status if root.destinationtype != Destination.LN else '' - on_release: Clock.schedule_once(lambda dt: app.fee_dialog(s, True)) if root.destinationtype != Destination.LN else None + text: app.fee_status if root.destinationtype != PR_TYPE_LN else '' + on_release: Clock.schedule_once(lambda dt: app.fee_dialog(s, True)) if root.destinationtype != PR_TYPE_LN else None BoxLayout: size_hint: 1, None height: '48dp' IconButton: size_hint: 0.5, 1 + on_release: s.parent.do_save() + icon: 'atlas://electrum/gui/kivy/theming/light/save' + IconButton: + size_hint: 0.5, 1 icon: 'atlas://electrum/gui/kivy/theming/light/copy' on_release: s.parent.do_paste() IconButton: id: qr - size_hint: 0.5, 1 + size_hint: 1, 1 on_release: Clock.schedule_once(lambda dt: app.scan_qr(on_complete=app.on_qr)) icon: 'atlas://electrum/gui/kivy/theming/light/camera' Button: t@@ -110,19 +169,10 @@ SendScreen: Button: text: _('Pay') size_hint: 1, 1 - on_release: s.parent.do_send() + on_release: s.parent.do_pay() Widget: - size_hint: 1, 1 - #BoxLayout: - # size_hint: 1, None - # height: '48dp' - #IconButton: - # size_hint: 0.5, 1 - # on_release: s.parent.do_save() - # icon: 'atlas://electrum/gui/kivy/theming/light/save' - #IconButton: - # size_hint: 0.5, 1 - # icon: 'atlas://electrum/gui/kivy/theming/light/list' - # on_release: Clock.schedule_once(lambda dt: app.invoices_dialog(s)) - #Widget: - # size_hint: 2.5, 1 + size_hint: 1, 0.1 + PaymentRecycleView: + id: payments_container + scroll_type: ['bars', 'content'] + bar_width: '25dp' DIR diff --git a/electrum/gui/qt/history_list.py b/electrum/gui/qt/history_list.py t@@ -196,9 +196,9 @@ class HistoryModel(QAbstractItemModel, Logger): elif col != HistoryColumns.STATUS and role == Qt.FontRole: monospace_font = QFont(MONOSPACE_FONT) return QVariant(monospace_font) - elif col == HistoryColumns.DESCRIPTION and role == Qt.DecorationRole and not is_lightning\ - and self.parent.wallet.invoices.paid.get(tx_hash): - return QVariant(read_QIcon("seal")) + #elif col == HistoryColumns.DESCRIPTION and role == Qt.DecorationRole and not is_lightning\ + # and self.parent.wallet.invoices.paid.get(tx_hash): + # return QVariant(read_QIcon("seal")) elif col in (HistoryColumns.DESCRIPTION, HistoryColumns.AMOUNT) \ and role == Qt.ForegroundRole and not is_lightning and tx_item['value'].value < 0: red_brush = QBrush(QColor("#BC1E1E")) DIR diff --git a/electrum/gui/qt/invoice_list.py b/electrum/gui/qt/invoice_list.py t@@ -30,22 +30,20 @@ from PyQt5.QtGui import QStandardItemModel, QStandardItem, QFont from PyQt5.QtWidgets import QHeaderView, QMenu from electrum.i18n import _ -from electrum.util import format_time, pr_tooltips, PR_UNPAID +from electrum.util import format_time, PR_UNPAID, PR_PAID, get_request_status +from electrum.util import PR_TYPE_ADDRESS, PR_TYPE_LN, PR_TYPE_BIP70 from electrum.lnutil import lndecode, RECEIVED from electrum.bitcoin import COIN from electrum import constants -from .util import (MyTreeView, read_QIcon, MONOSPACE_FONT, PR_UNPAID, +from .util import (MyTreeView, read_QIcon, MONOSPACE_FONT, import_meta_gui, export_meta_gui, pr_icons) -REQUEST_TYPE_BITCOIN = 0 -REQUEST_TYPE_LN = 1 ROLE_REQUEST_TYPE = Qt.UserRole ROLE_REQUEST_ID = Qt.UserRole + 1 -from electrum.paymentrequest import PR_PAID class InvoiceList(MyTreeView): t@@ -56,7 +54,7 @@ class InvoiceList(MyTreeView): STATUS = 3 headers = { - Columns.DATE: _('Expires'), + Columns.DATE: _('Date'), Columns.DESCRIPTION: _('Description'), Columns.AMOUNT: _('Amount'), Columns.STATUS: _('Status'), t@@ -72,48 +70,38 @@ class InvoiceList(MyTreeView): self.update() def update(self): - inv_list = self.parent.invoices.unpaid_invoices() + _list = self.parent.wallet.get_invoices() self.model().clear() self.update_headers(self.__class__.headers) - for idx, pr in enumerate(inv_list): - key = pr.get_id() - status = self.parent.invoices.get_status(key) - if status is None: - continue - requestor = pr.get_requestor() - exp = pr.get_time() - date_str = format_time(exp) if exp else _('Never') - labels = [date_str, '[%s] '%requestor + pr.memo, self.parent.format_amount(pr.get_amount(), whitespaces=True), pr_tooltips.get(status,'')] + for idx, item in enumerate(_list): + invoice_type = item['type'] + if invoice_type == PR_TYPE_LN: + key = item['rhash'] + icon_name = 'lightning.png' + elif invoice_type == PR_TYPE_ADDRESS: + key = item['address'] + icon_name = 'bitcoin.png' + elif invoice_type == PR_TYPE_BIP70: + key = item['id'] + icon_name = 'seal.png' + else: + raise Exception('Unsupported type') + status = item['status'] + status_str = get_request_status(item) # convert to str + message = item['message'] + amount = item['amount'] + timestamp = item.get('time', 0) + date_str = format_time(timestamp) if timestamp else _('Unknown') + amount_str = self.parent.format_amount(amount, whitespaces=True) + labels = [date_str, message, amount_str, status_str] items = [QStandardItem(e) for e in labels] self.set_editability(items) - items[self.Columns.DATE].setIcon(read_QIcon('bitcoin.png')) + items[self.Columns.DATE].setIcon(read_QIcon(icon_name)) items[self.Columns.STATUS].setIcon(read_QIcon(pr_icons.get(status))) items[self.Columns.DATE].setData(key, role=ROLE_REQUEST_ID) - items[self.Columns.DATE].setData(REQUEST_TYPE_BITCOIN, role=ROLE_REQUEST_TYPE) + items[self.Columns.DATE].setData(invoice_type, role=ROLE_REQUEST_TYPE) self.model().insertRow(idx, items) - lnworker = self.parent.wallet.lnworker - items = list(lnworker.invoices.items()) if lnworker else [] - for key, (invoice, direction, is_paid) in items: - if direction == RECEIVED: - continue - status = lnworker.get_invoice_status(key) - if status == PR_PAID: - continue - lnaddr = lndecode(invoice, expected_hrp=constants.net.SEGWIT_HRP) - amount_sat = lnaddr.amount*COIN if lnaddr.amount else None - amount_str = self.parent.format_amount(amount_sat) if amount_sat else '' - description = lnaddr.get_description() - date_str = format_time(lnaddr.date) - labels = [date_str, description, amount_str, pr_tooltips.get(status,'')] - items = [QStandardItem(e) for e in labels] - self.set_editability(items) - items[self.Columns.DATE].setIcon(read_QIcon('lightning.png')) - items[self.Columns.STATUS].setIcon(read_QIcon(pr_icons.get(status))) - items[self.Columns.DATE].setData(key, role=ROLE_REQUEST_ID) - items[self.Columns.DATE].setData(REQUEST_TYPE_LN, role=ROLE_REQUEST_TYPE) - self.model().insertRow(self.model().rowCount(), items) - self.selectionModel().select(self.model().index(0,0), QItemSelectionModel.SelectCurrent) # sort requests by date self.model().sort(self.Columns.DATE) t@@ -138,7 +126,7 @@ class InvoiceList(MyTreeView): return key = item_col0.data(ROLE_REQUEST_ID) request_type = item_col0.data(ROLE_REQUEST_TYPE) - assert request_type in [REQUEST_TYPE_BITCOIN, REQUEST_TYPE_LN] + assert request_type in [PR_TYPE_ADDRESS, PR_TYPE_BIP70, PR_TYPE_LN] column = idx.column() column_title = self.model().horizontalHeaderItem(column).text() column_data = item.text() t@@ -147,17 +135,17 @@ class InvoiceList(MyTreeView): if column == self.Columns.AMOUNT: column_data = column_data.strip() menu.addAction(_("Copy {}").format(column_title), lambda: self.parent.app.clipboard().setText(column_data)) - if request_type == REQUEST_TYPE_BITCOIN: + if request_type in [PR_TYPE_BIP70, PR_TYPE_ADDRESS]: self.create_menu_bitcoin_payreq(menu, key) - elif request_type == REQUEST_TYPE_LN: + elif request_type == PR_TYPE_LN: self.create_menu_ln_payreq(menu, key) menu.exec_(self.viewport().mapToGlobal(position)) def create_menu_bitcoin_payreq(self, menu, payreq_key): - status = self.parent.invoices.get_status(payreq_key) + #status = self.parent.wallet.get_invoice_status(payreq_key) menu.addAction(_("Details"), lambda: self.parent.show_invoice(payreq_key)) - if status == PR_UNPAID: - menu.addAction(_("Pay Now"), lambda: self.parent.do_pay_invoice(payreq_key)) + #if status == PR_UNPAID: + menu.addAction(_("Pay Now"), lambda: self.parent.do_pay_invoice(payreq_key)) menu.addAction(_("Delete"), lambda: self.parent.delete_invoice(payreq_key)) def create_menu_ln_payreq(self, menu, payreq_key): DIR diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py t@@ -120,7 +120,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): payment_request_ok_signal = pyqtSignal() payment_request_error_signal = pyqtSignal() network_signal = pyqtSignal(str, object) - ln_payment_attempt_signal = pyqtSignal(str) + #ln_payment_attempt_signal = pyqtSignal(str) alias_received_signal = pyqtSignal() computing_privkeys_signal = pyqtSignal() show_privkeys_signal = pyqtSignal() t@@ -138,7 +138,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): assert wallet, "no wallet" self.wallet = wallet self.fx = gui_object.daemon.fx # type: FxThread - self.invoices = wallet.invoices + #self.invoices = wallet.invoices self.contacts = wallet.contacts self.tray = gui_object.tray self.app = gui_object.app t@@ -225,7 +225,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): 'new_transaction', 'status', 'banner', 'verified', 'fee', 'fee_histogram', 'on_quotes', 'on_history', 'channel', 'channels', 'payment_received', - 'ln_payment_completed', 'ln_payment_attempt'] + 'payment_status'] # To avoid leaking references to "self" that prevent the # window from being GC-ed when closed, callbacks should be # methods of this class only, and specifically not be t@@ -374,14 +374,8 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): elif event == 'channel': self.channels_list.update_single_row.emit(*args) self.update_status() - elif event == 'ln_payment_attempt': - msg = _('Sending lightning payment') + '... (%d/%d)'%(args[0]+1, LN_NUM_PAYMENT_ATTEMPTS) - self.ln_payment_attempt_signal.emit(msg) - elif event == 'ln_payment_completed': - # FIXME it is really inefficient to force update the whole GUI - # just for a single LN payment. individual rows in lists should be updated instead. - # consider: history tab, invoice list, request list - self.need_update.set() + elif event == 'payment_status': + self.on_payment_status(*args) elif event == 'status': self.update_status() elif event == 'banner': t@@ -1671,33 +1665,32 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.do_send(preview = True) def pay_lightning_invoice(self, invoice): - amount = self.amount_e.get_amount() - def on_success(result): - self.logger.info(f'ln payment success. {result}') - self.show_error(_('Payment succeeded')) - self.do_clear() - def on_failure(exc_info): - type_, e, traceback = exc_info - if isinstance(e, PaymentFailure): - self.show_error(_('Payment failed. {}').format(e)) - elif isinstance(e, InvoiceError): - self.show_error(_('InvoiceError: {}').format(e)) - else: - raise e + amount_sat = self.amount_e.get_amount() + attempts = LN_NUM_PAYMENT_ATTEMPTS def task(): - success = self.wallet.lnworker.pay(invoice, attempts=LN_NUM_PAYMENT_ATTEMPTS, amount_sat=amount, timeout=60) - if not success: - raise PaymentFailure(f'Failed after {LN_NUM_PAYMENT_ATTEMPTS} attempts') + self.wallet.lnworker.pay(invoice, amount_sat, attempts) + self.do_clear() + self.wallet.thread.add(task) + self.invoice_list.update() - msg = _('Sending lightning payment...') - d = WaitingDialog(self, msg, task, on_success, on_failure) - self.ln_payment_attempt_signal.connect(d.update) + def on_payment_status(self, key, status, *args): + # todo: check that key is in this wallet's invoice list + self.invoice_list.update() + if status == 'success': + self.show_message(_('Payment succeeded')) + self.need_update.set() + elif status == 'progress': + print('on_payment_status', key, status, args) + elif status == 'failure': + self.show_info(_('Payment failed')) + elif status == 'error': + e = args[0] + self.show_error(_('Error') + '\n' + str(e)) def do_send(self, preview = False): if self.payto_e.is_lightning: self.pay_lightning_invoice(self.payto_e.lightning_invoice) return - # if run_hook('abort_send', self): return outputs, fee_estimator, tx_desc, coins = self.read_send_tab() t@@ -1817,8 +1810,8 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): else: status, msg = True, tx.txid() if pr and status is True: - self.invoices.set_paid(pr, tx.txid()) - self.invoices.save() + key = pr.get_id() + self.wallet.set_invoice_paid(key, tx.txid()) self.payment_request = None refund_address = self.wallet.get_receiving_address() coro = pr.send_payment_and_receive_paymentack(str(tx), refund_address) t@@ -1889,17 +1882,16 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): return True def delete_invoice(self, key): - self.invoices.remove(key) + self.wallet.delete_invoice(key) self.invoice_list.update() def payment_request_ok(self): pr = self.payment_request if not pr: return - key = self.invoices.add(pr) - status = self.invoices.get_status(key) - self.invoice_list.update() - if status == PR_PAID: + key = pr.get_id() + invoice = self.wallet.get_invoice(key) + if invoice and invoice['status'] == PR_PAID: self.show_message("invoice already paid") self.do_clear() self.payment_request = None t@@ -2106,7 +2098,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.update_completions() def show_invoice(self, key): - pr = self.invoices.get(key) + pr = self.wallet.get_invoice(key) if pr is None: self.show_error('Cannot find payment request in wallet.') return t@@ -2143,7 +2135,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): exportButton = EnterButton(_('Save'), do_export) def do_delete(): if self.question(_('Delete invoice?')): - self.invoices.remove(key) + self.wallet.delete_invoices(key) self.history_list.update() self.invoice_list.update() d.close() t@@ -2152,7 +2144,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): d.exec_() def do_pay_invoice(self, key): - pr = self.invoices.get(key) + pr = self.wallet.get_invoice(key) self.payment_request = pr self.prepare_for_payment_request() pr.error = None # this forces verify() to re-run DIR diff --git a/electrum/gui/qt/request_list.py b/electrum/gui/qt/request_list.py t@@ -31,6 +31,7 @@ from PyQt5.QtCore import Qt, QItemSelectionModel from electrum.i18n import _ from electrum.util import format_time, age, get_request_status +from electrum.util import PR_TYPE_ADDRESS, PR_TYPE_LN, PR_TYPE_BIP70 from electrum.util import PR_UNPAID, PR_EXPIRED, PR_PAID, PR_UNKNOWN, PR_INFLIGHT, pr_tooltips from electrum.lnutil import SENT, RECEIVED from electrum.plugin import run_hook t@@ -104,9 +105,10 @@ class RequestList(MyTreeView): is_lightning = date_item.data(ROLE_REQUEST_TYPE) == REQUEST_TYPE_LN req = self.wallet.get_request(key, is_lightning) if req: + status = req['status'] status_str = get_request_status(req) status_item.setText(status_str) - status_item.setIcon(read_QIcon(pr_icons.get(req['status']))) + status_item.setIcon(read_QIcon(pr_icons.get(status))) def update(self): self.wallet = self.parent.wallet t@@ -118,10 +120,11 @@ class RequestList(MyTreeView): status = req.get('status') if status == PR_PAID: continue - request_type = REQUEST_TYPE_LN if req.get('lightning', False) else REQUEST_TYPE_BITCOIN + is_lightning = req['type'] == PR_TYPE_LN + request_type = REQUEST_TYPE_LN if is_lightning else REQUEST_TYPE_BITCOIN timestamp = req.get('time', 0) amount = req.get('amount') - message = req['memo'] + message = req['message'] if is_lightning else req['memo'] date = format_time(timestamp) amount_str = self.parent.format_amount(amount) if amount else "" status_str = get_request_status(req) DIR diff --git a/electrum/lnchannel.py b/electrum/lnchannel.py t@@ -31,7 +31,7 @@ from typing import Optional, Dict, List, Tuple, NamedTuple, Set, Callable, Itera import time from . import ecc -from .util import bfh, bh2u +from .util import bfh, bh2u, PR_PAID, PR_FAILED from .bitcoin import TYPE_SCRIPT, TYPE_ADDRESS from .bitcoin import redeem_script_to_address from .crypto import sha256, sha256d t@@ -165,8 +165,11 @@ class Channel(Logger): log = self.hm.log[subject] for htlc_id, htlc in log.get('adds', {}).items(): if htlc_id in log.get('fails',{}): - continue - status = 'settled' if htlc_id in log.get('settles',{}) else 'inflight' + status = 'failed' + elif htlc_id in log.get('settles',{}): + status = 'settled' + else: + status = 'inflight' direction = SENT if subject is LOCAL else RECEIVED rhash = bh2u(htlc.payment_hash) out[rhash] = (self.channel_id, htlc, direction, status) t@@ -563,7 +566,7 @@ class Channel(Logger): assert htlc_id not in log['settles'] self.hm.send_settle(htlc_id) if self.lnworker: - self.lnworker.set_paid(htlc.payment_hash) + self.lnworker.set_invoice_status(htlc.payment_hash, PR_PAID) def receive_htlc_settle(self, preimage, htlc_id): self.logger.info("receive_htlc_settle") t@@ -574,7 +577,7 @@ class Channel(Logger): self.hm.recv_settle(htlc_id) if self.lnworker: self.lnworker.save_preimage(htlc.payment_hash, preimage) - self.lnworker.set_paid(htlc.payment_hash) + self.lnworker.set_invoice_status(htlc.payment_hash, PR_PAID) def fail_htlc(self, htlc_id): self.logger.info("fail_htlc") DIR diff --git a/electrum/lnworker.py b/electrum/lnworker.py t@@ -21,7 +21,8 @@ import dns.exception from . import constants from . import keystore -from .util import PR_UNPAID, PR_EXPIRED, PR_PAID, PR_UNKNOWN, PR_INFLIGHT +from .util import PR_UNPAID, PR_EXPIRED, PR_PAID, PR_UNKNOWN, PR_INFLIGHT, profiler +from .util import PR_TYPE_LN from .keystore import BIP32_KeyStore from .bitcoin import COIN from .transaction import Transaction t@@ -396,14 +397,12 @@ class LNWallet(LNWorker): def get_invoice_status(self, key): if key not in self.invoices: return PR_UNKNOWN - invoice, direction, is_paid = self.invoices[key] + invoice, direction, status = self.invoices[key] lnaddr = lndecode(invoice, expected_hrp=constants.net.SEGWIT_HRP) - if is_paid: - return PR_PAID - elif lnaddr.is_expired(): + if status == PR_UNPAID and lnaddr.is_expired(): return PR_EXPIRED else: - return PR_UNPAID + return status def get_payments(self): # return one item per payment_hash t@@ -415,11 +414,35 @@ class LNWallet(LNWorker): out[k].append(v) return out + def get_unsettled_payments(self): + out = [] + for payment_hash, plist in self.get_payments().items(): + if len(plist) != 1: + continue + chan_id, htlc, _direction, status = plist[0] + if _direction != SENT: + continue + if status == 'settled': + continue + amount = htlc.amount_msat//1000 + item = { + 'is_lightning': True, + 'status': status, + 'key': payment_hash, + 'amount': amount, + 'timestamp': htlc.timestamp, + 'label': self.wallet.get_label(payment_hash) + } + out.append(item) + return out + def get_history(self): out = [] for payment_hash, plist in self.get_payments().items(): if len(plist) == 1: chan_id, htlc, _direction, status = plist[0] + if status != 'settled': + continue direction = 'sent' if _direction == SENT else 'received' amount_msat= int(_direction) * htlc.amount_msat timestamp = htlc.timestamp t@@ -751,17 +774,23 @@ class LNWallet(LNWorker): raise Exception(_("open_channel timed out")) return chan - def pay(self, invoice, attempts=1, amount_sat=None, timeout=10): + def pay(self, invoice, amount_sat=None, attempts=1): """ Can be called from other threads - Raises exception after timeout """ - coro = self._pay(invoice, attempts, amount_sat) + addr = lndecode(invoice, expected_hrp=constants.net.SEGWIT_HRP) + key = bh2u(addr.paymenthash) + coro = self._pay(invoice, amount_sat, attempts) fut = asyncio.run_coroutine_threadsafe(coro, self.network.asyncio_loop) try: - return fut.result(timeout=timeout) - except concurrent.futures.TimeoutError: - raise PaymentFailure(_("Payment timed out")) + success = fut.result() + except Exception as e: + self.network.trigger_callback('payment_status', key, 'error', e) + return + if success: + self.network.trigger_callback('payment_status', key, 'success') + else: + self.network.trigger_callback('payment_status', key, 'failure') def get_channel_by_short_id(self, short_channel_id): with self.lock: t@@ -770,20 +799,22 @@ class LNWallet(LNWorker): return chan @log_exceptions - async def _pay(self, invoice, attempts=1, amount_sat=None): + async def _pay(self, invoice, amount_sat=None, attempts=1): addr = lndecode(invoice, expected_hrp=constants.net.SEGWIT_HRP) - status = self.get_invoice_status(bh2u(addr.paymenthash)) + key = bh2u(addr.paymenthash) + status = self.get_invoice_status(key) if status == PR_PAID: + # fixme: use lightning_preimaages, because invoices are not permanently stored raise PaymentFailure(_("This invoice has been paid already")) self._check_invoice(invoice, amount_sat) - self.save_invoice(addr.paymenthash, invoice, SENT, is_paid=False) - self.wallet.set_label(bh2u(addr.paymenthash), addr.get_description()) + self.save_invoice(addr.paymenthash, invoice, SENT, PR_INFLIGHT) + self.wallet.set_label(key, addr.get_description()) for i in range(attempts): route = await self._create_route_from_invoice(decoded_invoice=addr) if not self.get_channel_by_short_id(route[0].short_channel_id): scid = format_short_channel_id(route[0].short_channel_id) raise Exception(f"Got route with unknown first channel: {scid}") - self.network.trigger_callback('ln_payment_attempt', i) + self.network.trigger_callback('payment_status', key, 'progress', i) if await self._pay_to_route(route, addr, invoice): return True return False t@@ -895,7 +926,7 @@ class LNWallet(LNWorker): ('x', expiry)] + routing_hints), self.node_keypair.privkey) - self.save_invoice(payment_hash, invoice, RECEIVED, is_paid=False) + self.save_invoice(payment_hash, invoice, RECEIVED, PR_UNPAID) self.save_preimage(payment_hash, payment_preimage) self.wallet.set_label(bh2u(payment_hash), message) return payment_hash t@@ -915,20 +946,24 @@ class LNWallet(LNWorker): except KeyError as e: raise UnknownPaymentHash(payment_hash) from e - def save_invoice(self, payment_hash:bytes, invoice, direction, *, is_paid=False): + def save_new_invoice(self, invoice): + addr = lndecode(invoice, expected_hrp=constants.net.SEGWIT_HRP) + self.save_invoice(addr.paymenthash, invoice, SENT, PR_UNPAID) + + def save_invoice(self, payment_hash:bytes, invoice, direction, status): key = bh2u(payment_hash) - self.invoices[key] = invoice, direction, is_paid + self.invoices[key] = invoice, direction, status self.storage.put('lightning_invoices', self.invoices) self.storage.write() - def set_paid(self, payment_hash): + def set_invoice_status(self, payment_hash, status): key = bh2u(payment_hash) if key not in self.invoices: # if we are forwarding return invoice, direction, _ = self.invoices[key] - self.save_invoice(payment_hash, invoice, direction, is_paid=True) - if direction == RECEIVED: + self.save_invoice(payment_hash, invoice, direction, status) + if direction == RECEIVED and status == PR_PAID: self.network.trigger_callback('payment_received', self.wallet, key, PR_PAID) def get_invoice(self, payment_hash: bytes) -> LnAddr: t@@ -939,6 +974,9 @@ class LNWallet(LNWorker): raise UnknownPaymentHash(payment_hash) from e def get_request(self, key): + if key not in self.invoices: + return + # todo: parse invoices when saving invoice, direction, is_paid = self.invoices[key] status = self.get_invoice_status(key) lnaddr = lndecode(invoice, expected_hrp=constants.net.SEGWIT_HRP) t@@ -946,23 +984,32 @@ class LNWallet(LNWorker): description = lnaddr.get_description() timestamp = lnaddr.date return { - 'lightning':True, - 'status':status, - 'amount':amount_sat, - 'time':timestamp, - 'exp':lnaddr.get_expiry(), - 'memo':description, - 'rhash':key, + 'type': PR_TYPE_LN, + 'status': status, + 'amount': amount_sat, + 'time': timestamp, + 'exp': lnaddr.get_expiry(), + 'message': description, + 'rhash': key, 'invoice': invoice } + @profiler def get_invoices(self): - items = self.invoices.items() + # invoices = outgoing out = [] - for key, (invoice, direction, is_paid) in items: - if direction == SENT: - continue - out.append(self.get_request(key)) + for key, (invoice, direction, status) in self.invoices.items(): + if direction == SENT and status != PR_PAID: + out.append(self.get_request(key)) + return out + + @profiler + def get_requests(self): + # requests = incoming + out = [] + for key, (invoice, direction, status) in self.invoices.items(): + if direction == RECEIVED and status != PR_PAID: + out.append(self.get_request(key)) return out async def _calc_routing_hints_for_invoice(self, amount_sat): DIR diff --git a/electrum/paymentrequest.py b/electrum/paymentrequest.py t@@ -41,6 +41,7 @@ except ImportError: from . import bitcoin, ecc, util, transaction, x509, rsakey from .util import bh2u, bfh, export_meta, import_meta, make_aiohttp_session from .util import PR_UNPAID, PR_EXPIRED, PR_PAID, PR_UNKNOWN, PR_INFLIGHT +from .util import PR_TYPE_BIP70 from .crypto import sha256 from .bitcoin import TYPE_ADDRESS from .transaction import TxOutput t@@ -270,13 +271,15 @@ class PaymentRequest: def get_dict(self): return { + 'type': PR_TYPE_BIP70, + 'id': self.get_id(), 'requestor': self.get_requestor(), - 'memo':self.get_memo(), - 'exp': self.get_expiration_date(), + 'message': self.get_memo(), + 'time': self.get_time(), + 'exp': self.get_expiration_date() - self.get_time(), 'amount': self.get_amount(), - 'signature': self.get_verify_status(), - 'txid': self.tx, - 'outputs': self.get_outputs() + 'outputs': self.get_outputs(), + 'hex': self.raw.hex(), } def get_id(self): t@@ -475,94 +478,3 @@ def make_request(config, req): if key_path and cert_path: sign_request_with_x509(pr, key_path, cert_path) return pr - - - -class InvoiceStore(Logger): - - def __init__(self, storage): - Logger.__init__(self) - self.storage = storage - self.invoices = {} - self.paid = {} - d = self.storage.get('invoices', {}) - self.load(d) - - def set_paid(self, pr, txid): - pr.tx = txid - pr_id = pr.get_id() - self.paid[txid] = pr_id - if pr_id not in self.invoices: - # in case the user had deleted it previously - self.add(pr) - - def load(self, d): - for k, v in d.items(): - try: - pr = PaymentRequest(bfh(v.get('hex'))) - pr.tx = v.get('txid') - pr.requestor = v.get('requestor') - self.invoices[k] = pr - if pr.tx: - self.paid[pr.tx] = k - except: - continue - - def import_file(self, path): - def validate(data): - return data # TODO - import_meta(path, validate, self.on_import) - - def on_import(self, data): - self.load(data) - self.save() - - def export_file(self, filename): - export_meta(self.dump(), filename) - - def dump(self): - d = {} - for k, pr in self.invoices.items(): - d[k] = { - 'hex': bh2u(pr.raw), - 'requestor': pr.requestor, - 'txid': pr.tx - } - return d - - def save(self): - self.storage.put('invoices', self.dump()) - - def get_status(self, key): - pr = self.get(key) - if pr is None: - self.logger.info(f"get_status() can't find pr for {key}") - return - if pr.tx is not None: - return PR_PAID - if pr.has_expired(): - return PR_EXPIRED - return PR_UNPAID - - def add(self, pr): - key = pr.get_id() - self.invoices[key] = pr - self.save() - return key - - def remove(self, key): - self.invoices.pop(key) - self.save() - - def get(self, k): - return self.invoices.get(k) - - def sorted_list(self): - # sort - return self.invoices.values() - - def unpaid_invoices(self): - return [self.invoices[k] for k in - filter(lambda x: self.get_status(x) not in (PR_PAID, None), - self.invoices.keys()) - ] DIR diff --git a/electrum/util.py b/electrum/util.py t@@ -73,19 +73,26 @@ base_units_list = ['BTC', 'mBTC', 'bits', 'sat'] # list(dict) does not guarante DECIMAL_POINT_DEFAULT = 5 # mBTC +# types of payment requests +PR_TYPE_ADDRESS = 0 +PR_TYPE_BIP70= 1 +PR_TYPE_LN = 2 + # status of payment requests -PR_UNPAID = 0 -PR_EXPIRED = 1 -PR_UNKNOWN = 2 # sent but not propagated -PR_PAID = 3 # send and propagated -PR_INFLIGHT = 4 # unconfirmed +PR_UNPAID = 0 +PR_EXPIRED = 1 +PR_UNKNOWN = 2 # sent but not propagated +PR_PAID = 3 # send and propagated +PR_INFLIGHT = 4 # unconfirmed +PR_FAILED = 5 pr_tooltips = { PR_UNPAID:_('Pending'), PR_PAID:_('Paid'), PR_UNKNOWN:_('Unknown'), PR_EXPIRED:_('Expired'), - PR_INFLIGHT:_('Paid (unconfirmed)') + PR_INFLIGHT:_('In progress'), + PR_FAILED:_('Failed'), } pr_expiration_values = { DIR diff --git a/electrum/wallet.py b/electrum/wallet.py t@@ -47,6 +47,7 @@ from .util import (NotEnoughFunds, UserCancelled, profiler, InvalidPassword, format_time, timestamp_to_datetime, Satoshis, Fiat, bfh, bh2u, TxMinedInfo, quantize_feerate, create_bip21_uri, OrderedDictWithIndex) from .util import age +from .util import PR_TYPE_ADDRESS, PR_TYPE_BIP70, PR_TYPE_LN from .simple_config import get_config from .bitcoin import (COIN, TYPE_ADDRESS, is_address, address_to_script, is_minikey, relayfee, dust_threshold) t@@ -60,14 +61,14 @@ from .transaction import Transaction, TxOutput, TxOutputHwInfo from .plugin import run_hook from .address_synchronizer import (AddressSynchronizer, TX_HEIGHT_LOCAL, TX_HEIGHT_UNCONF_PARENT, TX_HEIGHT_UNCONFIRMED, TX_HEIGHT_FUTURE) -from .paymentrequest import (PR_PAID, PR_UNPAID, PR_UNKNOWN, PR_EXPIRED, PR_INFLIGHT, - InvoiceStore) +from .util import PR_PAID, PR_UNPAID, PR_UNKNOWN, PR_EXPIRED, PR_INFLIGHT from .contacts import Contacts from .interface import NetworkException from .ecc_fast import is_using_fast_ecc from .mnemonic import Mnemonic from .logging import get_logger from .lnworker import LNWallet +from .paymentrequest import PaymentRequest if TYPE_CHECKING: from .network import Network t@@ -225,6 +226,7 @@ class Abstract_Wallet(AddressSynchronizer): self.frozen_coins = set(storage.get('frozen_coins', [])) # set of txid:vout strings self.fiat_value = storage.get('fiat_value', {}) self.receive_requests = storage.get('payment_requests', {}) + self.invoices = storage.get('invoices', {}) self.calc_unused_change_addresses() t@@ -232,8 +234,7 @@ class Abstract_Wallet(AddressSynchronizer): if self.storage.get('wallet_type') is None: self.storage.put('wallet_type', self.wallet_type) - # invoices and contacts - self.invoices = InvoiceStore(self.storage) + # contacts self.contacts = Contacts(self.storage) self._coin_price_cache = {} self.lnworker = LNWallet(self) if get_config().get('lightning') else None t@@ -498,6 +499,51 @@ class Abstract_Wallet(AddressSynchronizer): 'txpos_in_block': tx_mined_status.txpos, } + def save_invoice(self, invoice): + invoice_type = invoice['type'] + if invoice_type == PR_TYPE_LN: + self.lnworker.save_new_invoice(invoice['invoice']) + else: + if invoice_type == PR_TYPE_ADDRESS: + key = invoice['address'] + invoice['time'] = int(time.time()) + elif invoice_type == PR_TYPE_BIP70: + key = invoice['id'] + invoice['txid'] = None + else: + raise Exception('Unsupported invoice type') + self.invoices[key] = invoice + self.storage.put('invoices', self.invoices) + self.storage.write() + + def get_invoices(self): + out = [self.get_invoice(key) for key in self.invoices.keys()] + out = [x for x in out if x and x.get('status') != PR_PAID] + if self.lnworker: + out += self.lnworker.get_invoices() + out.sort(key=operator.itemgetter('time')) + return out + + def get_invoice(self, key): + if key in self.invoices: + item = copy.copy(self.invoices[key]) + request_type = item.get('type') + if request_type is None: + # todo: convert old bip70 invoices + return + # add status + if item.get('txid'): + status = PR_PAID + elif 'exp' in item and item['time'] + item['exp'] < time.time(): + status = PR_EXPIRED + else: + status = PR_UNPAID + item['status'] = status + return item + if self.lnworker: + return self.lnworker.get_request(key) + + @profiler def get_full_history(self, fx=None): transactions = OrderedDictWithIndex() t@@ -1221,6 +1267,7 @@ class Abstract_Wallet(AddressSynchronizer): if not r: return out = copy.copy(r) + out['type'] = PR_TYPE_ADDRESS out['URI'] = 'bitcoin:' + addr + '?amount=' + format_satoshis(out.get('amount')) status, conf = self.get_request_status(addr) out['status'] = status t@@ -1363,6 +1410,14 @@ class Abstract_Wallet(AddressSynchronizer): elif self.lnworker: self.lnworker.delete_invoice(key) + def delete_invoice(self, key): + """ lightning or on-chain """ + if key in self.invoices: + self.invoices.pop(key) + self.storage.put('invoices', self.invoices) + elif self.lnworker: + self.lnworker.delete_invoice(key) + def remove_payment_request(self, addr, config): if addr not in self.receive_requests: return False t@@ -1381,7 +1436,7 @@ class Abstract_Wallet(AddressSynchronizer): """ sorted by timestamp """ out = [self.get_payment_request(x, config) for x in self.receive_requests.keys()] if self.lnworker: - out += self.lnworker.get_invoices() + out += self.lnworker.get_requests() out.sort(key=operator.itemgetter('time')) return out