Browse Source

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
dependabot/pip/contrib/deterministic-build/ecdsa-0.13.3
ThomasV 5 years ago
parent
commit
8010123c08
  1. 13
      electrum/commands.py
  2. 40
      electrum/gui/kivy/uix/screens.py
  3. 21
      electrum/gui/kivy/uix/ui_screens/receive.kv
  4. 26
      electrum/gui/qt/main_window.py
  5. 35
      electrum/gui/qt/request_list.py
  6. 8
      electrum/gui/qt/util.py
  7. 30
      electrum/lnworker.py
  8. 37
      electrum/util.py
  9. 21
      electrum/wallet.py

13
electrum/commands.py

@ -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')
@ -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

40
electrum/gui/kivy/uix/screens.py

@ -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
@ -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
@ -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 = ''
@ -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:
@ -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['screen'] = self
ci['address'] = address
ci['is_lightning'] = is_lightning
ci['key'] = key
ci['screen'] = self
self.cards[key] = ci
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]
@ -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):

21
electrum/gui/kivy/uix/ui_screens/receive.kv

@ -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
@ -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>:
@ -75,7 +68,6 @@ ReceiveScreen:
message: ''
status: ''
is_lightning: False
show_list: True
BoxLayout
padding: '12dp', '12dp', '12dp', '12dp'
@ -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
@ -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
@ -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

26
electrum/gui/qt/main_window.py

@ -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
@ -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
@ -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()
@ -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.'),
@ -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)

35
electrum/gui/qt/request_list.py

@ -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
@ -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)
is_lightning = request_type == REQUEST_TYPE_LN
req = self.wallet.get_request(key, is_lightning)
if req is None:
self.update()
return
else:
raise Exception(f"unknown request type: {request_type}")
self.parent.receive_address_e.setText(req)
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
@ -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)

8
electrum/gui/qt/util.py

@ -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):

30
electrum/lnworker.py

@ -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)
@ -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
@ -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)
@ -933,26 +934,31 @@ class LNWallet(LNWorker):
except KeyError as e:
raise UnknownPaymentHash(payment_hash) from e
def get_invoices(self):
items = self.invoices.items()
out = []
for key, (invoice, direction, is_paid) in items:
if direction == SENT:
continue
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
out.append({
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
out.append(self.get_request(key))
return out
async def _calc_routing_hints_for_invoice(self, amount_sat):

37
electrum/util.py

@ -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
@ -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"
elif distance_in_minutes < 45:
return "%s minutes" % distance_in_minutes
elif distance_in_minutes < 90:

21
electrum/wallet.py

@ -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)
@ -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
@ -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))
@ -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:
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():

Loading…
Cancel
Save