tDisplay and refresh the status of incoming payment requests: - All requests have an expiration date - Paid requests are automatically removed from the list - Unpaid, unconfirmed and expired requests are displayed - Fix a bug in get_payment_status, conf was off by one - electrum - Electrum Bitcoin wallet HTML git clone https://git.parazyd.org/electrum DIR Log DIR Files DIR Refs DIR Submodules --- DIR commit 8010123c0858bf9735cdfc6637899799ea6891f2 DIR parent 336cf81a6def88ae803c11926c93b56de2bed1b8 HTML Author: ThomasV <thomasv@electrum.org> Date: Wed, 21 Aug 2019 18:25:36 +0200 Display and refresh the status of incoming payment requests: - All requests have an expiration date - Paid requests are automatically removed from the list - Unpaid, unconfirmed and expired requests are displayed - Fix a bug in get_payment_status, conf was off by one Diffstat: M electrum/commands.py | 13 ++++--------- M electrum/gui/kivy/uix/screens.py | 48 ++++++++++++------------------- M electrum/gui/kivy/uix/ui_screens/r… | 21 +++++---------------- M electrum/gui/qt/main_window.py | 26 +++++++++++++++++++------- M electrum/gui/qt/request_list.py | 41 +++++++++++++++++++------------ M electrum/gui/qt/util.py | 8 +------- M electrum/lnworker.py | 42 ++++++++++++++++++------------- M electrum/util.py | 39 ++++++++++++++++++------------- M electrum/wallet.py | 23 +++++++++++++++++------ 9 files changed, 137 insertions(+), 124 deletions(-) --- DIR diff --git a/electrum/commands.py b/electrum/commands.py t@@ -670,14 +670,9 @@ class Commands: return decrypted.decode('utf-8') def _format_request(self, out): - pr_str = { - PR_UNKNOWN: 'Unknown', - PR_UNPAID: 'Pending', - PR_PAID: 'Paid', - PR_EXPIRED: 'Expired', - } + from .util import get_request_status out['amount_BTC'] = format_satoshis(out.get('amount')) - out['status'] = pr_str[out.get('status', PR_UNKNOWN)] + out['status'] = get_request_status(out) return out @command('w') t@@ -850,9 +845,9 @@ class Commands: return await self.lnworker._pay(invoice, attempts=attempts) @command('wn') - async def addinvoice(self, requested_amount, message): + async def addinvoice(self, requested_amount, message, expiration=3600): # using requested_amount because it is documented in param_descriptions - payment_hash = await self.lnworker._add_invoice_coro(satoshis(requested_amount), message) + payment_hash = await self.lnworker._add_invoice_coro(satoshis(requested_amount), message, expiration) invoice, direction, is_paid = self.lnworker.invoices[bh2u(payment_hash)] return invoice DIR diff --git a/electrum/gui/kivy/uix/screens.py b/electrum/gui/kivy/uix/screens.py t@@ -2,7 +2,6 @@ import asyncio from weakref import ref from decimal import Decimal import re -import datetime import threading import traceback, sys from enum import Enum, auto t@@ -27,7 +26,7 @@ from electrum.util import profiler, parse_URI, format_time, InvalidPassword, Not 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 -from electrum.util import PR_UNPAID, PR_PAID, PR_UNKNOWN, PR_EXPIRED, TxMinedInfo, age +from electrum.util import PR_UNPAID, PR_PAID, PR_UNKNOWN, PR_EXPIRED, TxMinedInfo, get_request_status, pr_expiration_values from electrum.plugin import run_hook from electrum.wallet import InternalAddressCorruption from electrum import simple_config t@@ -404,12 +403,14 @@ class SendScreen(CScreen): class ReceiveScreen(CScreen): kvname = 'receive' - cards = {} def __init__(self, **kwargs): super(ReceiveScreen, self).__init__(**kwargs) self.menu_actions = [(_('Show'), self.do_show), (_('Delete'), self.do_delete)] - self.expiration = self.app.electrum_config.get('request_expiration', 3600) # 1 hour + Clock.schedule_interval(lambda dt: self.update(), 5) + + def expiry(self): + return self.app.electrum_config.get('request_expiry', 3600) # 1 hour def clear(self): self.screen.address = '' t@@ -452,9 +453,8 @@ class ReceiveScreen(CScreen): amount = self.screen.amount amount = self.app.get_amount(amount) if amount else 0 message = self.screen.message - expiration = self.expiration if lightning: - payment_hash = self.app.wallet.lnworker.add_invoice(amount, message) + payment_hash = self.app.wallet.lnworker.add_invoice(amount, message, self.expiry()) request, direction, is_paid = self.app.wallet.lnworker.invoices.get(payment_hash.hex()) key = payment_hash.hex() else: t@@ -463,40 +463,37 @@ class ReceiveScreen(CScreen): self.app.show_info(_('No address available. Please remove some of your pending requests.')) return self.screen.address = addr - req = self.app.wallet.make_payment_request(addr, amount, message, expiration) + req = self.app.wallet.make_payment_request(addr, amount, message, self.expiry()) self.app.wallet.add_payment_request(req, self.app.electrum_config) key = addr + self.clear() self.update() self.app.show_request(lightning, key) def get_card(self, req): is_lightning = req.get('lightning', False) - status = req['status'] - #if status != PR_UNPAID: - # continue if not is_lightning: address = req['address'] key = address else: key = req['rhash'] address = req['invoice'] - timestamp = req.get('time', 0) amount = req.get('amount') description = req.get('memo', '') - ci = self.cards.get(key) - if ci is None: - ci = {} - ci['address'] = address - ci['is_lightning'] = is_lightning - ci['key'] = key - ci['screen'] = self - self.cards[key] = ci + ci = {} + ci['screen'] = self + ci['address'] = address + ci['is_lightning'] = is_lightning + ci['key'] = key ci['amount'] = self.app.format_amount_and_units(amount) if amount else '' ci['memo'] = description - ci['status'] = age(timestamp) + ci['status'] = get_request_status(req) + ci['is_expired'] = req['status'] == PR_EXPIRED return ci def update(self): + if not self.loaded: + return _list = self.app.wallet.get_sorted_requests(self.app.electrum_config) requests_container = self.screen.ids.requests_container requests_container.data = [self.get_card(item) for item in _list if item.get('status') != PR_PAID] t@@ -507,16 +504,9 @@ class ReceiveScreen(CScreen): def expiration_dialog(self, obj): from .dialogs.choice_dialog import ChoiceDialog - choices = { - 10*60: _('10 minutes'), - 60*60: _('1 hour'), - 24*60*60: _('1 day'), - 7*24*60*60: _('1 week') - } def callback(c): - self.expiration = c - self.app.electrum_config.set_key('request_expiration', c) - d = ChoiceDialog(_('Expiration date'), choices, self.expiration, callback) + self.app.electrum_config.set_key('request_expiry', c) + d = ChoiceDialog(_('Expiration date'), pr_expiration_values, self.expiry(), callback) d.open() def do_delete(self, req): DIR diff --git a/electrum/gui/kivy/uix/ui_screens/receive.kv b/electrum/gui/kivy/uix/ui_screens/receive.kv t@@ -13,29 +13,22 @@ valign: 'top' <RequestItem@CardItem> + is_expired: False address: '' memo: '' amount: '' status: '' - date: '' - icon: 'atlas://electrum/gui/kivy/theming/light/important' - Image: - id: icon - source: root.icon - size_hint: None, 1 - width: self.height *.54 - mipmap: True BoxLayout: spacing: '8dp' height: '32dp' orientation: 'vertical' Widget RequestLabel: - text: root.address + text: root.memo shorten: True Widget RequestLabel: - text: root.memo + text: root.address color: .699, .699, .699, 1 font_size: '13sp' shorten: True t@@ -54,7 +47,7 @@ text: root.status halign: 'right' font_size: '13sp' - color: .699, .699, .699, 1 + color: (1., .2, .2, 1) if root.is_expired else (.7, .7, .7, 1) Widget <RequestRecycleView>: t@@ -75,7 +68,6 @@ ReceiveScreen: message: '' status: '' is_lightning: False - show_list: True BoxLayout padding: '12dp', '12dp', '12dp', '12dp' t@@ -100,7 +92,6 @@ ReceiveScreen: text: _('Lightning') if root.is_lightning else (s.address if s.address else _('Bitcoin Address')) shorten: True on_release: root.is_lightning = not root.is_lightning - #on_release: Clock.schedule_once(lambda dt: app.addresses_dialog(s)) CardSeparator: opacity: message_selection.opacity color: blue_bottom.foreground_color t@@ -144,7 +135,7 @@ ReceiveScreen: icon: 'atlas://electrum/gui/kivy/theming/light/list' size_hint: 0.5, None height: '48dp' - on_release: root.show_list = not root.show_list + on_release: Clock.schedule_once(lambda dt: app.addresses_dialog()) IconButton: icon: 'atlas://electrum/gui/kivy/theming/light/clock1' size_hint: 0.5, None t@@ -166,5 +157,3 @@ ReceiveScreen: id: requests_container scroll_type: ['bars', 'content'] bar_width: '25dp' - opacity: 1 if root.show_list else 0 - disabled: not root.show_list DIR diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py t@@ -73,6 +73,7 @@ from electrum.exchange_rate import FxThread from electrum.simple_config import SimpleConfig from electrum.logging import Logger from electrum.paymentrequest import PR_PAID +from electrum.util import pr_expiration_values from .exception_window import Exception_Hook from .amountedit import AmountEdit, BTCAmountEdit, MyLineEdit, FeerateEdit t@@ -83,7 +84,7 @@ from .fee_slider import FeeSlider from .util import (read_QIcon, ColorScheme, text_dialog, icon_path, WaitingDialog, WindowModalDialog, ChoicesLayout, HelpLabel, FromList, Buttons, OkButton, InfoButton, WWLabel, TaskThread, CancelButton, - CloseButton, HelpButton, MessageBoxMixin, EnterButton, expiration_values, + CloseButton, HelpButton, MessageBoxMixin, EnterButton, ButtonsLineEdit, CopyCloseButton, import_meta_gui, export_meta_gui, filename_field, address_field, char_width_in_lineedit, webopen) from .util import ButtonsTextEdit t@@ -753,6 +754,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): return fileName def timer_actions(self): + self.request_list.refresh_status() # Note this runs in the GUI thread if self.need_update.is_set(): self.need_update.clear() t@@ -945,9 +947,20 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.connect_fields(self, self.receive_amount_e, self.fiat_receive_e, None) self.expires_combo = QComboBox() - self.expires_combo.addItems([i[0] for i in expiration_values]) - self.expires_combo.setCurrentIndex(3) + evl = sorted(pr_expiration_values.items()) + evl_keys = [i[0] for i in evl] + evl_values = [i[1] for i in evl] + default_expiry = self.config.get('request_expiry', 3600) + try: + i = evl_keys.index(default_expiry) + except ValueError: + i = 0 + self.expires_combo.addItems(evl_values) + self.expires_combo.setCurrentIndex(i) self.expires_combo.setFixedWidth(self.receive_amount_e.width()) + def on_expiry(i): + self.config.set_key('request_expiry', evl_keys[i]) + self.expires_combo.currentIndexChanged.connect(on_expiry) msg = ' '.join([ _('Expiration date of your request.'), _('This information is seen by the recipient if you send them a signed payment request.'), t@@ -1057,13 +1070,12 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): def create_invoice(self, is_lightning): amount = self.receive_amount_e.get_amount() message = self.receive_message_e.text() - i = self.expires_combo.currentIndex() - expiration = list(map(lambda x: x[1], expiration_values))[i] + expiry = self.config.get('request_expiry', 3600) if is_lightning: - payment_hash = self.wallet.lnworker.add_invoice(amount, message) + payment_hash = self.wallet.lnworker.add_invoice(amount, message, expiry) key = bh2u(payment_hash) else: - key = self.create_bitcoin_request(amount, message, expiration) + key = self.create_bitcoin_request(amount, message, expiry) self.address_list.update() self.request_list.update() self.request_list.select_key(key) DIR diff --git a/electrum/gui/qt/request_list.py b/electrum/gui/qt/request_list.py t@@ -30,7 +30,7 @@ from PyQt5.QtWidgets import QMenu, QHeaderView from PyQt5.QtCore import Qt, QItemSelectionModel from electrum.i18n import _ -from electrum.util import format_time, age +from electrum.util import format_time, age, get_request_status 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@@ -85,20 +85,28 @@ class RequestList(MyTreeView): item = self.model().itemFromIndex(idx.sibling(idx.row(), self.Columns.DATE)) request_type = item.data(ROLE_REQUEST_TYPE) key = item.data(ROLE_RHASH_OR_ADDR) - if request_type == REQUEST_TYPE_BITCOIN: - req = self.wallet.receive_requests.get(key) - if req is None: - self.update() - return - req = self.wallet.get_request_URI(key) - elif request_type == REQUEST_TYPE_LN: - req, direction, is_paid = self.wallet.lnworker.invoices.get(key) or (None, None, None) - if req is None: - self.update() - return - else: - raise Exception(f"unknown request type: {request_type}") - self.parent.receive_address_e.setText(req) + is_lightning = request_type == REQUEST_TYPE_LN + req = self.wallet.get_request(key, is_lightning) + if req is None: + self.update() + return + text = req.get('invoice') if is_lightning else req.get('URI') + self.parent.receive_address_e.setText(text) + + def refresh_status(self): + m = self.model() + for r in range(m.rowCount()): + idx = m.index(r, self.Columns.STATUS) + date_idx = idx.sibling(idx.row(), self.Columns.DATE) + date_item = m.itemFromIndex(date_idx) + status_item = m.itemFromIndex(idx) + key = date_item.data(ROLE_RHASH_OR_ADDR) + is_lightning = date_item.data(ROLE_REQUEST_TYPE) == REQUEST_TYPE_LN + req = self.wallet.get_request(key, is_lightning) + if req: + status_str = get_request_status(req) + status_item.setText(status_str) + status_item.setIcon(read_QIcon(pr_icons.get(req['status']))) def update(self): self.wallet = self.parent.wallet t@@ -116,7 +124,8 @@ class RequestList(MyTreeView): message = req['memo'] date = format_time(timestamp) amount_str = self.parent.format_amount(amount) if amount else "" - labels = [date, message, amount_str, pr_tooltips.get(status,'')] + status_str = get_request_status(req) + labels = [date, message, amount_str, status_str] items = [QStandardItem(e) for e in labels] self.set_editability(items) items[self.Columns.DATE].setData(request_type, ROLE_REQUEST_TYPE) DIR diff --git a/electrum/gui/qt/util.py b/electrum/gui/qt/util.py t@@ -45,16 +45,10 @@ pr_icons = { PR_UNPAID:"unpaid.png", PR_PAID:"confirmed.png", PR_EXPIRED:"expired.png", - PR_INFLIGHT:"lightning.png", + PR_INFLIGHT:"unconfirmed.png", } -expiration_values = [ - (_('1 hour'), 60*60), - (_('1 day'), 24*60*60), - (_('1 week'), 7*24*60*60), - (_('Never'), None) -] class EnterButton(QPushButton): DIR diff --git a/electrum/lnworker.py b/electrum/lnworker.py t@@ -868,8 +868,8 @@ class LNWallet(LNWorker): raise PaymentFailure(_("No path found")) return route - def add_invoice(self, amount_sat, message): - coro = self._add_invoice_coro(amount_sat, message) + def add_invoice(self, amount_sat, message, expiry): + coro = self._add_invoice_coro(amount_sat, message, expiry) fut = asyncio.run_coroutine_threadsafe(coro, self.network.asyncio_loop) try: return fut.result(timeout=5) t@@ -877,7 +877,7 @@ class LNWallet(LNWorker): raise Exception(_("add_invoice timed out")) @log_exceptions - async def _add_invoice_coro(self, amount_sat, message): + async def _add_invoice_coro(self, amount_sat, message, expiry): payment_preimage = os.urandom(32) payment_hash = sha256(payment_preimage) amount_btc = amount_sat/Decimal(COIN) if amount_sat else None t@@ -887,7 +887,8 @@ class LNWallet(LNWorker): "Other clients will likely not be able to send to us.") invoice = lnencode(LnAddr(payment_hash, amount_btc, tags=[('d', message), - ('c', MIN_FINAL_CLTV_EXPIRY_FOR_INVOICE)] + ('c', MIN_FINAL_CLTV_EXPIRY_FOR_INVOICE), + ('x', expiry)] + routing_hints), self.node_keypair.privkey) self.save_invoice(payment_hash, invoice, RECEIVED, is_paid=False) t@@ -933,26 +934,31 @@ class LNWallet(LNWorker): except KeyError as e: raise UnknownPaymentHash(payment_hash) from e + def get_request(self, key): + invoice, direction, is_paid = self.invoices[key] + status = self.get_invoice_status(key) + lnaddr = lndecode(invoice, expected_hrp=constants.net.SEGWIT_HRP) + amount_sat = lnaddr.amount*COIN if lnaddr.amount else None + 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, + 'invoice': invoice + } + def get_invoices(self): items = self.invoices.items() out = [] for key, (invoice, direction, is_paid) in items: if direction == SENT: continue - status = self.get_invoice_status(key) - lnaddr = lndecode(invoice, expected_hrp=constants.net.SEGWIT_HRP) - amount_sat = lnaddr.amount*COIN if lnaddr.amount else None - description = lnaddr.get_description() - timestamp = lnaddr.date - out.append({ - 'lightning':True, - 'status':status, - 'amount':amount_sat, - 'time':timestamp, - 'memo':description, - 'rhash':key, - 'invoice': invoice - }) + out.append(self.get_request(key)) return out async def _calc_routing_hints_for_invoice(self, amount_sat): DIR diff --git a/electrum/util.py b/electrum/util.py t@@ -78,16 +78,34 @@ PR_UNPAID = 0 PR_EXPIRED = 1 PR_UNKNOWN = 2 # sent but not propagated PR_PAID = 3 # send and propagated -PR_INFLIGHT = 4 # lightning +PR_INFLIGHT = 4 # unconfirmed pr_tooltips = { PR_UNPAID:_('Pending'), PR_PAID:_('Paid'), PR_UNKNOWN:_('Unknown'), PR_EXPIRED:_('Expired'), - PR_INFLIGHT:_('Inflight') + PR_INFLIGHT:_('Paid (unconfirmed)') } +pr_expiration_values = { + 10*60: _('10 minutes'), + 60*60: _('1 hour'), + 24*60*60: _('1 day'), + 7*24*60*60: _('1 week') +} + +def get_request_status(req): + status = req['status'] + status_str = pr_tooltips[status] + if status == PR_UNPAID: + if req.get('exp'): + expiration = req['exp'] + req['time'] + status_str = _('Expires') + ' ' + age(expiration, include_seconds=True) + else: + status_str = _('Pending') + return status_str + class UnknownBaseUnit(Exception): pass t@@ -638,22 +656,11 @@ def time_difference(distance_in_time, include_seconds): distance_in_seconds = int(round(abs(distance_in_time.days * 86400 + distance_in_time.seconds))) distance_in_minutes = int(round(distance_in_seconds/60)) - if distance_in_minutes <= 1: + if distance_in_minutes == 0: if include_seconds: - for remainder in [5, 10, 20]: - if distance_in_seconds < remainder: - return "less than %s seconds" % remainder - if distance_in_seconds < 40: - return "half a minute" - elif distance_in_seconds < 60: - return "less than a minute" - else: - return "1 minute" + return "%s seconds" % distance_in_seconds else: - if distance_in_minutes == 0: - return "less than a minute" - else: - return "1 minute" + return "less than a minute" elif distance_in_minutes < 45: return "%s minutes" % distance_in_minutes elif distance_in_minutes < 90: DIR diff --git a/electrum/wallet.py b/electrum/wallet.py t@@ -46,6 +46,7 @@ from .util import (NotEnoughFunds, UserCancelled, profiler, WalletFileException, BitcoinException, InvalidPassword, format_time, timestamp_to_datetime, Satoshis, Fiat, bfh, bh2u, TxMinedInfo, quantize_feerate, create_bip21_uri, OrderedDictWithIndex) +from .util import age from .simple_config import get_config from .bitcoin import (COIN, TYPE_ADDRESS, is_address, address_to_script, is_minikey, relayfee, dust_threshold) t@@ -59,7 +60,7 @@ 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, +from .paymentrequest import (PR_PAID, PR_UNPAID, PR_UNKNOWN, PR_EXPIRED, PR_INFLIGHT, InvoiceStore) from .contacts import Contacts from .interface import NetworkException t@@ -1204,7 +1205,7 @@ class Abstract_Wallet(AddressSynchronizer): txid, n = txo.split(':') info = self.db.get_verified_tx(txid) if info: - conf = local_height - info.height + conf = local_height - info.height + 1 else: conf = 0 l.append((conf, v)) t@@ -1282,13 +1283,23 @@ class Abstract_Wallet(AddressSynchronizer): expiration = r.get('exp') if expiration and type(expiration) != int: expiration = 0 - paid, conf = self.get_payment_status(address, amount) - status = PR_PAID if paid else PR_UNPAID - if status == PR_UNPAID and expiration is not None and time.time() > timestamp + expiration: - status = PR_EXPIRED + if not paid: + if expiration is not None and time.time() > timestamp + expiration: + status = PR_EXPIRED + else: + status = PR_UNPAID + else: + status = PR_INFLIGHT if conf <= 0 else PR_PAID return status, conf + def get_request(self, key, is_lightning): + if not is_lightning: + req = self.get_payment_request(key, {}) + else: + req = self.lnworker.get_request(key) + return req + def receive_tx_callback(self, tx_hash, tx, tx_height): super().receive_tx_callback(tx_hash, tx, tx_height) for txo in tx.outputs():