From 3d69f3b0beb4a3be74aeef8a96f5ffef5f82ee90 Mon Sep 17 00:00:00 2001 From: ThomasV Date: Tue, 10 Mar 2020 13:27:02 +0100 Subject: [PATCH] improve payment status callbacks: - add 'computing route' status for lightning payments - use separate callbacks for invoice status and payment popups - show payment error and payment logs in kivy --- electrum/gui/kivy/main_window.py | 14 +++-- .../gui/kivy/uix/dialogs/invoice_dialog.py | 10 ++++ electrum/gui/qt/main_window.py | 20 ++++--- electrum/gui/qt/util.py | 3 +- electrum/lnworker.py | 53 ++++++++++++++----- electrum/util.py | 3 ++ 6 files changed, 76 insertions(+), 27 deletions(-) diff --git a/electrum/gui/kivy/main_window.py b/electrum/gui/kivy/main_window.py index d05251804..e71c99665 100644 --- a/electrum/gui/kivy/main_window.py +++ b/electrum/gui/kivy/main_window.py @@ -235,11 +235,13 @@ class ElectrumWindow(App): self.update_tab('send') if self.invoice_popup and self.invoice_popup.key == key: self.invoice_popup.update_status() - if status == PR_PAID: - self.show_info(_('Payment was sent')) - self._trigger_update_history() - elif status == PR_FAILED: - self.show_info(_('Payment failed')) + + def on_payment_succeeded(self, event, key): + self.show_info(_('Payment was sent')) + self._trigger_update_history() + + def on_payment_failed(self, event, key, reason): + self.show_info(_('Payment failed') + '\n\n' + reason) def _get_bu(self): decimal_point = self.electrum_config.get('decimal_point', DECIMAL_POINT_DEFAULT) @@ -569,6 +571,8 @@ class ElectrumWindow(App): self.network.register_callback(self.on_channel, ['channel']) self.network.register_callback(self.on_invoice_status, ['invoice_status']) self.network.register_callback(self.on_request_status, ['request_status']) + self.network.register_callback(self.on_payment_failed, ['payment_failed']) + self.network.register_callback(self.on_payment_succeeded, ['payment_succeeded']) self.network.register_callback(self.on_channel_db, ['channel_db']) self.network.register_callback(self.set_num_peers, ['gossip_peers']) self.network.register_callback(self.set_unknown_channels, ['unknown_channels']) diff --git a/electrum/gui/kivy/uix/dialogs/invoice_dialog.py b/electrum/gui/kivy/uix/dialogs/invoice_dialog.py index a39018ba3..ab3b5c9be 100644 --- a/electrum/gui/kivy/uix/dialogs/invoice_dialog.py +++ b/electrum/gui/kivy/uix/dialogs/invoice_dialog.py @@ -40,6 +40,10 @@ Builder.load_string(''' TopLabel: text: _('Status') + ': ' + root.status_str color: root.status_color + on_touch_down: + touch = args[1] + touched = bool(self.collide_point(*touch.pos)) + if touched: root.show_log() TopLabel: text: root.warning color: (0.9, 0.6, 0.3, 1) @@ -84,6 +88,7 @@ class InvoiceDialog(Factory.Popup): self.amount = r.get('amount') self.is_lightning = r.get('type') == PR_TYPE_LN self.update_status() + self.log = self.app.wallet.lnworker.logs[self.key] if self.is_lightning else [] def update_status(self): req = self.app.wallet.get_invoice(self.key) @@ -120,3 +125,8 @@ class InvoiceDialog(Factory.Popup): self.app.send_screen.update() d = Question(_('Delete invoice?'), cb) d.open() + + def show_log(self): + if self.log: + log_str = _('Payment log:') + '\n\n' + '\n'.join([str(x.exception) for x in self.log]) + self.app.show_info(log_str) diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index e13552799..1a43a7df3 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -265,6 +265,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): 'new_transaction', 'status', 'banner', 'verified', 'fee', 'fee_histogram', 'on_quotes', 'on_history', 'channel', 'channels_updated', + 'payment_failed', 'payment_succeeded', 'invoice_status', 'request_status', 'ln_gossip_sync_progress'] # To avoid leaking references to "self" that prevent the # window from being GC-ed when closed, callbacks should be @@ -419,6 +420,10 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.on_request_status(*args) elif event == 'invoice_status': self.on_invoice_status(*args) + elif event == 'payment_succeeded': + self.on_payment_succeeded(*args) + elif event == 'payment_failed': + self.on_payment_failed(*args) elif event == 'status': self.update_status() elif event == 'banner': @@ -1448,15 +1453,14 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): req = self.wallet.get_invoice(key) if req is None: return - status = req['status'] self.invoice_list.update_item(key, req) - if status == PR_PAID: - self.show_message(_('Payment succeeded')) - self.need_update.set() - elif status == PR_FAILED: - self.show_error(_('Payment failed')) - else: - pass + + def on_payment_succeeded(self, key, description=None): + self.show_message(_('Payment succeeded')) + self.need_update.set() + + def on_payment_failed(self, key, reason): + self.show_error(_('Payment failed') + '\n\n' + reason) def read_invoice(self): if self.check_send_tab_payto_line_and_show_errors(): diff --git a/electrum/gui/qt/util.py b/electrum/gui/qt/util.py index f1888924a..184d509dc 100644 --- a/electrum/gui/qt/util.py +++ b/electrum/gui/qt/util.py @@ -24,7 +24,7 @@ from PyQt5.QtWidgets import (QPushButton, QLabel, QMessageBox, QHBoxLayout, from electrum.i18n import _, languages from electrum.util import FileImportFailed, FileExportFailed, make_aiohttp_session, resource_path -from electrum.util import PR_UNPAID, PR_PAID, PR_EXPIRED, PR_INFLIGHT, PR_UNKNOWN, PR_FAILED +from electrum.util import PR_UNPAID, PR_PAID, PR_EXPIRED, PR_INFLIGHT, PR_UNKNOWN, PR_FAILED, PR_ROUTING if TYPE_CHECKING: from .main_window import ElectrumWindow @@ -47,6 +47,7 @@ pr_icons = { PR_EXPIRED:"expired.png", PR_INFLIGHT:"unconfirmed.png", PR_FAILED:"warning.png", + PR_ROUTING:"unconfirmed.png", } diff --git a/electrum/lnworker.py b/electrum/lnworker.py index 6361335f2..d9398b4d7 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -24,7 +24,7 @@ from aiorpcx import run_in_thread from . import constants from . import keystore from .util import profiler -from .util import PR_UNPAID, PR_EXPIRED, PR_PAID, PR_INFLIGHT, PR_FAILED +from .util import PR_UNPAID, PR_EXPIRED, PR_PAID, PR_INFLIGHT, PR_FAILED, PR_ROUTING from .util import PR_TYPE_LN from .lnutil import LN_MAX_FUNDING_SAT from .keystore import BIP32_KeyStore @@ -69,6 +69,9 @@ if TYPE_CHECKING: from .wallet import Abstract_Wallet +SAVED_PR_STATUS = [PR_PAID, PR_UNPAID, PR_INFLIGHT] # status that are persisted + + NUM_PEERS_TARGET = 4 PEER_RETRY_INTERVAL = 600 # seconds PEER_RETRY_INTERVAL_FOR_CHANNELS = 30 # seconds @@ -421,7 +424,8 @@ class LNWallet(LNWorker): self.preimages = self.db.get_dict('lightning_preimages') # RHASH -> preimage self.sweep_address = wallet.get_receiving_address() self.lock = threading.RLock() - self.logs = defaultdict(list) # type: Dict[str, List[PaymentAttemptLog]] # key is RHASH + self.logs = defaultdict(list) # (not persisted) type: Dict[str, List[PaymentAttemptLog]] # key is RHASH + self.is_routing = set() # (not persisted) keys of invoices that are in PR_ROUTING state # used in tests self.enable_htlc_settle = asyncio.Event() self.enable_htlc_settle.set() @@ -920,25 +924,35 @@ class LNWallet(LNWorker): self.wallet.set_label(key, lnaddr.get_description()) log = self.logs[key] success = False + reason = '' for i in range(attempts): try: # note: this call does path-finding which takes ~1 second # -> we will BLOCK the asyncio loop... (could just run in a thread and await, # but then the graph could change while the path-finding runs on it) + self.set_invoice_status(key, PR_ROUTING) + self.network.trigger_callback('invoice_status', key) route = self._create_route_from_invoice(decoded_invoice=lnaddr) - self.set_payment_status(payment_hash, PR_INFLIGHT) + self.set_invoice_status(key, PR_INFLIGHT) self.network.trigger_callback('invoice_status', key) payment_attempt_log = await self._pay_to_route(route, lnaddr) except Exception as e: log.append(PaymentAttemptLog(success=False, exception=e)) - self.set_payment_status(payment_hash, PR_UNPAID) + self.set_invoice_status(key, PR_UNPAID) + reason = str(e) break log.append(payment_attempt_log) success = payment_attempt_log.success if success: break - self.logger.debug(f'payment attempts log for RHASH {key}: {repr(log)}') + else: + reason = 'failed after %d attempts' % attemps self.network.trigger_callback('invoice_status', key) + if success: + self.network.trigger_callback('payment_succeeded', key) + else: + self.network.trigger_callback('payment_failed', key, reason) + self.logger.debug(f'payment attempts log for RHASH {key}: {repr(log)}') return success async def _pay_to_route(self, route: LNPaymentRoute, lnaddr: LnAddr) -> PaymentAttemptLog: @@ -1038,6 +1052,7 @@ class LNWallet(LNWorker): f"min_final_cltv_expiry: {addr.get_min_final_cltv_expiry()}")) return addr + @profiler def _create_route_from_invoice(self, decoded_invoice) -> LNPaymentRoute: amount_msat = int(decoded_invoice.amount * COIN * 1000) invoice_pubkey = decoded_invoice.pubkey.serialize() @@ -1170,7 +1185,7 @@ class LNWallet(LNWorker): def save_payment_info(self, info: PaymentInfo) -> None: key = info.payment_hash.hex() - assert info.status in [PR_PAID, PR_UNPAID, PR_INFLIGHT] + assert info.status in SAVED_PR_STATUS with self.lock: self.payments[key] = info.amount, info.direction, info.status self.wallet.save_db() @@ -1184,19 +1199,29 @@ class LNWallet(LNWorker): return status def get_invoice_status(self, key): + log = self.logs[key] + if key in self.is_routing: + return PR_ROUTING # status may be PR_FAILED status = self.get_payment_status(bfh(key)) - log = self.logs[key] if status == PR_UNPAID and log: status = PR_FAILED return status + def set_invoice_status(self, key, status): + if status == PR_ROUTING: + self.is_routing.add(key) + elif key in self.is_routing: + self.is_routing.remove(key) + if status in SAVED_PR_STATUS: + self.save_payment_status(bfh(key), status) + async def await_payment(self, payment_hash): success, preimage, reason = await self.pending_payments[payment_hash] self.pending_payments.pop(payment_hash) return success, preimage, reason - def set_payment_status(self, payment_hash: bytes, status): + def save_payment_status(self, payment_hash: bytes, status): try: info = self.get_payment_info(payment_hash) except UnknownPaymentHash: @@ -1206,27 +1231,29 @@ class LNWallet(LNWorker): self.save_payment_info(info) def payment_failed(self, chan, payment_hash: bytes, reason): - self.set_payment_status(payment_hash, PR_UNPAID) + self.save_payment_status(payment_hash, PR_UNPAID) f = self.pending_payments.get(payment_hash) if f and not f.cancelled(): f.set_result((False, None, reason)) else: chan.logger.info('received unexpected payment_failed, probably from previous session') - self.network.trigger_callback('invoice_status', payment_hash.hex()) + self.network.trigger_callback('invoice_status', key) + self.network.trigger_callback('payment_failed', payment_hash.hex()) def payment_sent(self, chan, payment_hash: bytes): - self.set_payment_status(payment_hash, PR_PAID) + self.save_payment_status(payment_hash, PR_PAID) preimage = self.get_preimage(payment_hash) f = self.pending_payments.get(payment_hash) if f and not f.cancelled(): f.set_result((True, preimage, None)) else: chan.logger.info('received unexpected payment_sent, probably from previous session') - self.network.trigger_callback('invoice_status', payment_hash.hex()) + self.network.trigger_callback('invoice_status', key) + self.network.trigger_callback('payment_succeeded', payment_hash.hex()) self.network.trigger_callback('ln_payment_completed', payment_hash, chan.channel_id) def payment_received(self, chan, payment_hash: bytes): - self.set_payment_status(payment_hash, PR_PAID) + self.save_payment_status(payment_hash, PR_PAID) self.network.trigger_callback('request_status', payment_hash.hex(), PR_PAID) self.network.trigger_callback('ln_payment_completed', payment_hash, chan.channel_id) diff --git a/electrum/util.py b/electrum/util.py index 048451b23..4214bf137 100644 --- a/electrum/util.py +++ b/electrum/util.py @@ -85,6 +85,7 @@ PR_UNKNOWN = 2 # sent but not propagated PR_PAID = 3 # send and propagated PR_INFLIGHT = 4 # unconfirmed PR_FAILED = 5 +PR_ROUTING = 6 pr_color = { PR_UNPAID: (.7, .7, .7, 1), @@ -93,6 +94,7 @@ pr_color = { PR_EXPIRED: (.9, .2, .2, 1), PR_INFLIGHT: (.9, .6, .3, 1), PR_FAILED: (.9, .2, .2, 1), + PR_ROUTING: (.9, .6, .3, 1), } pr_tooltips = { @@ -102,6 +104,7 @@ pr_tooltips = { PR_EXPIRED:_('Expired'), PR_INFLIGHT:_('In progress'), PR_FAILED:_('Failed'), + PR_ROUTING: _('Computing route...'), } PR_DEFAULT_EXPIRATION_WHEN_CREATING = 24*60*60 # 1 day