Browse Source

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.
dependabot/pip/contrib/deterministic-build/ecdsa-0.13.3
ThomasV 6 years ago
parent
commit
a50f935aec
  1. 41
      electrum/gui/kivy/main_window.py
  2. 97
      electrum/gui/kivy/uix/dialogs/invoice_dialog.py
  3. 173
      electrum/gui/kivy/uix/screens.py
  4. 108
      electrum/gui/kivy/uix/ui_screens/send.kv
  5. 6
      electrum/gui/qt/history_list.py
  6. 78
      electrum/gui/qt/invoice_list.py
  7. 74
      electrum/gui/qt/main_window.py
  8. 9
      electrum/gui/qt/request_list.py
  9. 13
      electrum/lnchannel.py
  10. 115
      electrum/lnworker.py
  11. 104
      electrum/paymentrequest.py
  12. 9
      electrum/util.py
  13. 65
      electrum/wallet.py

41
electrum/gui/kivy/main_window.py

@ -11,11 +11,10 @@ import asyncio
from electrum.bitcoin import TYPE_ADDRESS from electrum.bitcoin import TYPE_ADDRESS
from electrum.storage import WalletStorage from electrum.storage import WalletStorage
from electrum.wallet import Wallet, InternalAddressCorruption from electrum.wallet import Wallet, InternalAddressCorruption
from electrum.paymentrequest import InvoiceStore
from electrum.util import profiler, InvalidPassword, send_exception_to_crash_reporter from electrum.util import profiler, InvalidPassword, send_exception_to_crash_reporter
from electrum.plugin import run_hook from electrum.plugin import run_hook
from electrum.util import format_satoshis, format_satoshis_plain, format_fee_satoshis 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 import blockchain
from electrum.network import Network, TxBroadcastError, BestEffortRequestFailed from electrum.network import Network, TxBroadcastError, BestEffortRequestFailed
from .i18n import _ from .i18n import _
@ -201,6 +200,19 @@ class ElectrumWindow(App):
if status == PR_PAID: if status == PR_PAID:
self.show_info(_('Payment Received') + '\n' + key) 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): def _get_bu(self):
decimal_point = self.electrum_config.get('decimal_point', DECIMAL_POINT_DEFAULT) decimal_point = self.electrum_config.get('decimal_point', DECIMAL_POINT_DEFAULT)
try: try:
@ -343,15 +355,12 @@ class ElectrumWindow(App):
self.show_error(_('No wallet loaded.')) self.show_error(_('No wallet loaded.'))
return return
if pr.verify(self.wallet.contacts): if pr.verify(self.wallet.contacts):
key = self.wallet.invoices.add(pr) key = pr.get_id()
if self.invoices_screen: invoice = self.wallet.get_invoice(key)
self.invoices_screen.update() if invoice and invoice['status'] == PR_PAID:
status = self.wallet.invoices.get_status(key)
if status == PR_PAID:
self.show_error("invoice already paid") self.show_error("invoice already paid")
self.send_screen.do_clear() self.send_screen.do_clear()
else: elif pr.has_expired():
if pr.has_expired():
self.show_error(_('Payment request has expired')) self.show_error(_('Payment request has expired'))
else: else:
self.switch_to('send') self.switch_to('send')
@ -418,6 +427,19 @@ class ElectrumWindow(App):
self.request_popup.set_status(status) self.request_popup.set_status(status)
self.request_popup.open() 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): def qr_dialog(self, title, data, show_text=False, text_for_clipboard=None):
from .uix.dialogs.qr_dialog import QRDialog from .uix.dialogs.qr_dialog import QRDialog
def on_qr_failure(): def on_qr_failure():
@ -519,6 +541,7 @@ class ElectrumWindow(App):
self.network.register_callback(self.on_payment_received, ['payment_received']) self.network.register_callback(self.on_payment_received, ['payment_received'])
self.network.register_callback(self.on_channels, ['channels']) self.network.register_callback(self.on_channels, ['channels'])
self.network.register_callback(self.on_channel, ['channel']) self.network.register_callback(self.on_channel, ['channel'])
self.network.register_callback(self.on_payment_status, ['payment_status'])
# load wallet # load wallet
self.load_wallet_by_name(self.electrum_config.get_wallet_path()) self.load_wallet_by_name(self.electrum_config.get_wallet_path())
# URI passed in config # URI passed in config

97
electrum/gui/kivy/uix/dialogs/invoice_dialog.py

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

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

@ -4,7 +4,6 @@ from decimal import Decimal
import re import re
import threading import threading
import traceback, sys import traceback, sys
from enum import Enum, auto
from kivy.app import App from kivy.app import App
from kivy.cache import Cache from kivy.cache import Cache
@ -23,6 +22,7 @@ from kivy.factory import Factory
from kivy.utils import platform from kivy.utils import platform
from electrum.util import profiler, parse_URI, format_time, InvalidPassword, NotEnoughFunds, Fiat 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 import bitcoin, constants
from electrum.transaction import TxOutput, Transaction, tx_from_str 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 send_exception_to_crash_reporter, parse_URI, InvalidBitcoinURI
@ -38,10 +38,6 @@ from .dialogs.lightning_open_channel import LightningOpenChannelDialog
from electrum.gui.kivy.i18n import _ from electrum.gui.kivy.i18n import _
class Destination(Enum):
Address = auto()
PR = auto()
LN = auto()
class HistoryRecycleView(RecycleView): class HistoryRecycleView(RecycleView):
pass pass
@ -49,6 +45,9 @@ class HistoryRecycleView(RecycleView):
class RequestRecycleView(RecycleView): class RequestRecycleView(RecycleView):
pass pass
class PaymentRecycleView(RecycleView):
pass
class CScreen(Factory.Screen): class CScreen(Factory.Screen):
__events__ = ('on_activate', 'on_deactivate', 'on_enter', 'on_leave') __events__ = ('on_activate', 'on_deactivate', 'on_enter', 'on_leave')
action_view = ObjectProperty(None) action_view = ObjectProperty(None)
@ -119,14 +118,12 @@ class HistoryScreen(CScreen):
super(HistoryScreen, self).__init__(**kwargs) super(HistoryScreen, self).__init__(**kwargs)
def show_item(self, obj): def show_item(self, obj):
print(obj)
key = obj.key key = obj.key
tx = self.app.wallet.db.get_transaction(key) tx = self.app.wallet.db.get_transaction(key)
if not tx: if not tx:
return return
self.app.tx_dialog(tx) self.app.tx_dialog(tx)
def get_card(self, tx_item): #tx_hash, tx_mined_status, value, balance): def get_card(self, tx_item): #tx_hash, tx_mined_status, value, balance):
is_lightning = tx_item.get('lightning', False) is_lightning = tx_item.get('lightning', False)
timestamp = tx_item['timestamp'] timestamp = tx_item['timestamp']
@ -192,7 +189,7 @@ class SendScreen(CScreen):
self.screen.message = uri.get('message', '') self.screen.message = uri.get('message', '')
self.screen.amount = self.app.format_amount_and_units(amount) if amount else '' self.screen.amount = self.app.format_amount_and_units(amount) if amount else ''
self.payment_request = None self.payment_request = None
self.screen.destinationtype = Destination.Address self.screen.destinationtype = PR_TYPE_ADDRESS
def set_ln_invoice(self, invoice): def set_ln_invoice(self, invoice):
try: try:
@ -204,19 +201,47 @@ class SendScreen(CScreen):
self.screen.message = dict(lnaddr.tags).get('d', None) 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.screen.amount = self.app.format_amount_and_units(lnaddr.amount * bitcoin.COIN) if lnaddr.amount else ''
self.payment_request = None self.payment_request = None
self.screen.destinationtype = Destination.LN self.screen.destinationtype = PR_TYPE_LN
def update(self): def update(self):
if not self.loaded:
return
if self.app.wallet and self.payment_request_queued: if self.app.wallet and self.payment_request_queued:
self.set_URI(self.payment_request_queued) self.set_URI(self.payment_request_queued)
self.payment_request_queued = None 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): def do_clear(self):
self.screen.amount = '' self.screen.amount = ''
self.screen.message = '' self.screen.message = ''
self.screen.address = '' self.screen.address = ''
self.payment_request = None self.payment_request = None
self.screen.destinationtype = Destination.Address self.screen.destinationtype = PR_TYPE_ADDRESS
def set_request(self, pr): def set_request(self, pr):
self.screen.address = pr.get_requestor() self.screen.address = pr.get_requestor()
@ -224,32 +249,10 @@ class SendScreen(CScreen):
self.screen.amount = self.app.format_amount_and_units(amount) if amount else '' self.screen.amount = self.app.format_amount_and_units(amount) if amount else ''
self.screen.message = pr.get_memo() self.screen.message = pr.get_memo()
if pr.is_pr(): 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.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.payment_request = pr self.payment_request = pr
else: else:
self.screen.destinationtype = Destination.Address self.screen.destinationtype = PR_TYPE_ADDRESS
self.payment_request = None self.payment_request = None
def do_paste(self): def do_paste(self):
@ -275,63 +278,87 @@ class SendScreen(CScreen):
self.set_ln_invoice(lower) self.set_ln_invoice(lower)
else: else:
self.set_URI(data) 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: 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 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: try:
success = self.app.wallet.lnworker.pay(invoice, attempts=10, amount_sat=amount_sat, timeout=60) amount = self.app.get_amount(self.screen.amount)
except PaymentFailure as e: except:
self.app.show_error(_('Payment failure') + '\n' + str(e)) self.app.show_error(_('Invalid amount') + ':\n' + self.screen.amount)
return return
if success: message = self.screen.message
self.app.show_info(_('Payment was sent')) if self.screen.destinationtype == PR_TYPE_LN:
self.app._trigger_update_history() return {
else: 'type': PR_TYPE_LN,
self.app.show_error(_('Payment failed')) 'invoice': address,
'amount': amount,
def do_send(self): 'message': message,
if self.screen.destinationtype == Destination.LN: }
self._do_send_lightning() elif self.screen.destinationtype == PR_TYPE_ADDRESS:
if not bitcoin.is_address(address):
self.app.show_error(_('Invalid Bitcoin Address') + ':\n' + address)
return return
elif self.screen.destinationtype == Destination.PR: return {
'type': PR_TYPE_ADDRESS,
'address': address,
'amount': amount,
'message': message,
}
elif self.screen.destinationtype == PR_TYPE_BIP70:
if self.payment_request.has_expired(): if self.payment_request.has_expired():
self.app.show_error(_('Payment request has expired')) self.app.show_error(_('Payment request has expired'))
return return
outputs = self.payment_request.get_outputs() return self.payment_request.get_dict()
else: else:
address = str(self.screen.address) raise Exception('Unknown invoice type')
if not address:
self.app.show_error(_('Recipient not specified.') + ' ' + _('Please scan a Bitcoin address or a payment request')) def do_save(self):
invoice = self.read_invoice()
if not invoice:
return return
if not bitcoin.is_address(address): self.app.wallet.save_invoice(invoice)
self.app.show_error(_('Invalid Bitcoin Address') + ':\n' + address) self.do_clear()
self.update()
def do_pay(self):
invoice = self.read_invoice()
if not invoice:
return return
try: self.app.wallet.save_invoice(invoice)
amount = self.app.get_amount(self.screen.amount) self.do_clear()
except: self.update()
self.app.show_error(_('Invalid amount') + ':\n' + self.screen.amount) 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 return
elif invoice['type'] == PR_TYPE_ADDRESS:
address = invoice['address']
amount = invoice['amount']
message = invoice['message']
outputs = [TxOutput(bitcoin.TYPE_ADDRESS, address, amount)] outputs = [TxOutput(bitcoin.TYPE_ADDRESS, address, amount)]
message = self.screen.message elif invoice['type'] == PR_TYPE_BIP70:
outputs = invoice['outputs']
amount = sum(map(lambda x:x[2], outputs)) amount = sum(map(lambda x:x[2], outputs))
# onchain payment
if self.app.electrum_config.get('use_rbf'): 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() d.open()
else: else:
self._do_send(amount, message, outputs, False) self._do_send_onchain(amount, message, outputs, False)
def _do_send(self, amount, message, outputs, rbf): 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_onchain(self, amount, message, outputs, rbf):
# make unsigned transaction # make unsigned transaction
config = self.app.electrum_config config = self.app.electrum_config
coins = self.app.wallet.get_spendable_coins(None, config) coins = self.app.wallet.get_spendable_coins(None, config)
@ -447,7 +474,7 @@ class ReceiveScreen(CScreen):
self.app.show_request(lightning, key) self.app.show_request(lightning, key)
def get_card(self, req): def get_card(self, req):
is_lightning = req.get('lightning', False) is_lightning = req.get('type') == PR_TYPE_LN
if not is_lightning: if not is_lightning:
address = req['address'] address = req['address']
key = address key = address

108
electrum/gui/kivy/uix/ui_screens/send.kv

@ -1,10 +1,66 @@
#:import _ electrum.gui.kivy.i18n._ #: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 #:import Decimal decimal.Decimal
#:set btc_symbol chr(171) #:set btc_symbol chr(171)
#:set mbtc_symbol chr(187) #:set mbtc_symbol chr(187)
#:set font_light 'electrum/gui/kivy/data/fonts/Roboto-Condensed.ttf' #: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: SendScreen:
id: s id: s
@ -12,7 +68,7 @@ SendScreen:
address: '' address: ''
amount: '' amount: ''
message: '' message: ''
destinationtype: Destination.Address destinationtype: PR_TYPE_ADDRESS
BoxLayout BoxLayout
padding: '12dp', '12dp', '12dp', '12dp' padding: '12dp', '12dp', '12dp', '12dp'
spacing: '12dp' spacing: '12dp'
@ -26,7 +82,7 @@ SendScreen:
height: blue_bottom.item_height height: blue_bottom.item_height
spacing: '5dp' spacing: '5dp'
Image: 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_hint: None, None
size: '22dp', '22dp' size: '22dp', '22dp'
pos_hint: {'center_y': .5} pos_hint: {'center_y': .5}
@ -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.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')) #on_release: Clock.schedule_once(lambda dt: app.popup_dialog('contacts'))
CardSeparator: CardSeparator:
opacity: int(root.destinationtype == Destination.Address) opacity: int(root.destinationtype == PR_TYPE_ADDRESS)
color: blue_bottom.foreground_color color: blue_bottom.foreground_color
BoxLayout: BoxLayout:
size_hint: 1, None size_hint: 1, None
@ -53,10 +109,10 @@ SendScreen:
id: amount_e id: amount_e
default_text: _('Amount') default_text: _('Amount')
text: s.amount if s.amount else _('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)) on_release: Clock.schedule_once(lambda dt: app.amount_dialog(s, True))
CardSeparator: CardSeparator:
opacity: int(root.destinationtype == Destination.Address) opacity: int(root.destinationtype == PR_TYPE_ADDRESS)
color: blue_bottom.foreground_color color: blue_bottom.foreground_color
BoxLayout: BoxLayout:
id: message_selection id: message_selection
@ -70,37 +126,40 @@ SendScreen:
pos_hint: {'center_y': .5} pos_hint: {'center_y': .5}
BlueButton: BlueButton:
id: description id: description
text: s.message if s.message else ({Destination.LN: _('No description'), Destination.Address: _('Description'), Destination.PR: _('No Description')}[root.destinationtype]) 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 != Destination.Address disabled: root.destinationtype != PR_TYPE_ADDRESS
on_release: Clock.schedule_once(lambda dt: app.description_dialog(s)) on_release: Clock.schedule_once(lambda dt: app.description_dialog(s))
CardSeparator: CardSeparator:
opacity: int(root.destinationtype == Destination.Address) opacity: int(root.destinationtype == PR_TYPE_ADDRESS)
color: blue_bottom.foreground_color color: blue_bottom.foreground_color
BoxLayout: BoxLayout:
size_hint: 1, None size_hint: 1, None
height: blue_bottom.item_height if root.destinationtype != Destination.LN else 0 height: blue_bottom.item_height
spacing: '5dp' spacing: '5dp'
Image: Image:
source: 'atlas://electrum/gui/kivy/theming/light/star_big_inactive' 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_hint: None, None
size: '22dp', '22dp' size: '22dp', '22dp'
pos_hint: {'center_y': .5} pos_hint: {'center_y': .5}
BlueButton: BlueButton:
id: fee_e id: fee_e
default_text: _('Fee') default_text: _('Fee')
text: app.fee_status if root.destinationtype != Destination.LN else '' 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 != Destination.LN else None on_release: Clock.schedule_once(lambda dt: app.fee_dialog(s, True)) if root.destinationtype != PR_TYPE_LN else None
BoxLayout: BoxLayout:
size_hint: 1, None size_hint: 1, None
height: '48dp' height: '48dp'
IconButton:
size_hint: 0.5, 1
on_release: s.parent.do_save()
icon: 'atlas://electrum/gui/kivy/theming/light/save'
IconButton: IconButton:
size_hint: 0.5, 1 size_hint: 0.5, 1
icon: 'atlas://electrum/gui/kivy/theming/light/copy' icon: 'atlas://electrum/gui/kivy/theming/light/copy'
on_release: s.parent.do_paste() on_release: s.parent.do_paste()
IconButton: IconButton:
id: qr 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)) on_release: Clock.schedule_once(lambda dt: app.scan_qr(on_complete=app.on_qr))
icon: 'atlas://electrum/gui/kivy/theming/light/camera' icon: 'atlas://electrum/gui/kivy/theming/light/camera'
Button: Button:
@ -110,19 +169,10 @@ SendScreen:
Button: Button:
text: _('Pay') text: _('Pay')
size_hint: 1, 1 size_hint: 1, 1
on_release: s.parent.do_send() on_release: s.parent.do_pay()
Widget: Widget:
size_hint: 1, 1 size_hint: 1, 0.1
#BoxLayout: PaymentRecycleView:
# size_hint: 1, None id: payments_container
# height: '48dp' scroll_type: ['bars', 'content']
#IconButton: bar_width: '25dp'
# 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

6
electrum/gui/qt/history_list.py

@ -196,9 +196,9 @@ class HistoryModel(QAbstractItemModel, Logger):
elif col != HistoryColumns.STATUS and role == Qt.FontRole: elif col != HistoryColumns.STATUS and role == Qt.FontRole:
monospace_font = QFont(MONOSPACE_FONT) monospace_font = QFont(MONOSPACE_FONT)
return QVariant(monospace_font) return QVariant(monospace_font)
elif col == HistoryColumns.DESCRIPTION and role == Qt.DecorationRole and not is_lightning\ #elif col == HistoryColumns.DESCRIPTION and role == Qt.DecorationRole and not is_lightning\
and self.parent.wallet.invoices.paid.get(tx_hash): # and self.parent.wallet.invoices.paid.get(tx_hash):
return QVariant(read_QIcon("seal")) # return QVariant(read_QIcon("seal"))
elif col in (HistoryColumns.DESCRIPTION, HistoryColumns.AMOUNT) \ elif col in (HistoryColumns.DESCRIPTION, HistoryColumns.AMOUNT) \
and role == Qt.ForegroundRole and not is_lightning and tx_item['value'].value < 0: and role == Qt.ForegroundRole and not is_lightning and tx_item['value'].value < 0:
red_brush = QBrush(QColor("#BC1E1E")) red_brush = QBrush(QColor("#BC1E1E"))

78
electrum/gui/qt/invoice_list.py

@ -30,22 +30,20 @@ from PyQt5.QtGui import QStandardItemModel, QStandardItem, QFont
from PyQt5.QtWidgets import QHeaderView, QMenu from PyQt5.QtWidgets import QHeaderView, QMenu
from electrum.i18n import _ 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.lnutil import lndecode, RECEIVED
from electrum.bitcoin import COIN from electrum.bitcoin import COIN
from electrum import constants 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) import_meta_gui, export_meta_gui, pr_icons)
REQUEST_TYPE_BITCOIN = 0
REQUEST_TYPE_LN = 1
ROLE_REQUEST_TYPE = Qt.UserRole ROLE_REQUEST_TYPE = Qt.UserRole
ROLE_REQUEST_ID = Qt.UserRole + 1 ROLE_REQUEST_ID = Qt.UserRole + 1
from electrum.paymentrequest import PR_PAID
class InvoiceList(MyTreeView): class InvoiceList(MyTreeView):
@ -56,7 +54,7 @@ class InvoiceList(MyTreeView):
STATUS = 3 STATUS = 3
headers = { headers = {
Columns.DATE: _('Expires'), Columns.DATE: _('Date'),
Columns.DESCRIPTION: _('Description'), Columns.DESCRIPTION: _('Description'),
Columns.AMOUNT: _('Amount'), Columns.AMOUNT: _('Amount'),
Columns.STATUS: _('Status'), Columns.STATUS: _('Status'),
@ -72,48 +70,38 @@ class InvoiceList(MyTreeView):
self.update() self.update()
def update(self): def update(self):
inv_list = self.parent.invoices.unpaid_invoices() _list = self.parent.wallet.get_invoices()
self.model().clear() self.model().clear()
self.update_headers(self.__class__.headers) self.update_headers(self.__class__.headers)
for idx, pr in enumerate(inv_list): for idx, item in enumerate(_list):
key = pr.get_id() invoice_type = item['type']
status = self.parent.invoices.get_status(key) if invoice_type == PR_TYPE_LN:
if status is None: key = item['rhash']
continue icon_name = 'lightning.png'
requestor = pr.get_requestor() elif invoice_type == PR_TYPE_ADDRESS:
exp = pr.get_time() key = item['address']
date_str = format_time(exp) if exp else _('Never') icon_name = 'bitcoin.png'
labels = [date_str, '[%s] '%requestor + pr.memo, self.parent.format_amount(pr.get_amount(), whitespaces=True), pr_tooltips.get(status,'')] 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] items = [QStandardItem(e) for e in labels]
self.set_editability(items) 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.STATUS].setIcon(read_QIcon(pr_icons.get(status)))
items[self.Columns.DATE].setData(key, role=ROLE_REQUEST_ID) 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) 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) self.selectionModel().select(self.model().index(0,0), QItemSelectionModel.SelectCurrent)
# sort requests by date # sort requests by date
self.model().sort(self.Columns.DATE) self.model().sort(self.Columns.DATE)
@ -138,7 +126,7 @@ class InvoiceList(MyTreeView):
return return
key = item_col0.data(ROLE_REQUEST_ID) key = item_col0.data(ROLE_REQUEST_ID)
request_type = item_col0.data(ROLE_REQUEST_TYPE) 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 = idx.column()
column_title = self.model().horizontalHeaderItem(column).text() column_title = self.model().horizontalHeaderItem(column).text()
column_data = item.text() column_data = item.text()
@ -147,16 +135,16 @@ class InvoiceList(MyTreeView):
if column == self.Columns.AMOUNT: if column == self.Columns.AMOUNT:
column_data = column_data.strip() column_data = column_data.strip()
menu.addAction(_("Copy {}").format(column_title), lambda: self.parent.app.clipboard().setText(column_data)) 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) 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) self.create_menu_ln_payreq(menu, key)
menu.exec_(self.viewport().mapToGlobal(position)) menu.exec_(self.viewport().mapToGlobal(position))
def create_menu_bitcoin_payreq(self, menu, payreq_key): 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)) menu.addAction(_("Details"), lambda: self.parent.show_invoice(payreq_key))
if status == PR_UNPAID: #if status == PR_UNPAID:
menu.addAction(_("Pay Now"), lambda: self.parent.do_pay_invoice(payreq_key)) menu.addAction(_("Pay Now"), lambda: self.parent.do_pay_invoice(payreq_key))
menu.addAction(_("Delete"), lambda: self.parent.delete_invoice(payreq_key)) menu.addAction(_("Delete"), lambda: self.parent.delete_invoice(payreq_key))

74
electrum/gui/qt/main_window.py

@ -120,7 +120,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger):
payment_request_ok_signal = pyqtSignal() payment_request_ok_signal = pyqtSignal()
payment_request_error_signal = pyqtSignal() payment_request_error_signal = pyqtSignal()
network_signal = pyqtSignal(str, object) network_signal = pyqtSignal(str, object)
ln_payment_attempt_signal = pyqtSignal(str) #ln_payment_attempt_signal = pyqtSignal(str)
alias_received_signal = pyqtSignal() alias_received_signal = pyqtSignal()
computing_privkeys_signal = pyqtSignal() computing_privkeys_signal = pyqtSignal()
show_privkeys_signal = pyqtSignal() show_privkeys_signal = pyqtSignal()
@ -138,7 +138,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger):
assert wallet, "no wallet" assert wallet, "no wallet"
self.wallet = wallet self.wallet = wallet
self.fx = gui_object.daemon.fx # type: FxThread self.fx = gui_object.daemon.fx # type: FxThread
self.invoices = wallet.invoices #self.invoices = wallet.invoices
self.contacts = wallet.contacts self.contacts = wallet.contacts
self.tray = gui_object.tray self.tray = gui_object.tray
self.app = gui_object.app self.app = gui_object.app
@ -225,7 +225,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger):
'new_transaction', 'status', 'new_transaction', 'status',
'banner', 'verified', 'fee', 'fee_histogram', 'on_quotes', 'banner', 'verified', 'fee', 'fee_histogram', 'on_quotes',
'on_history', 'channel', 'channels', 'payment_received', 'on_history', 'channel', 'channels', 'payment_received',
'ln_payment_completed', 'ln_payment_attempt'] 'payment_status']
# To avoid leaking references to "self" that prevent the # To avoid leaking references to "self" that prevent the
# window from being GC-ed when closed, callbacks should be # window from being GC-ed when closed, callbacks should be
# methods of this class only, and specifically not be # methods of this class only, and specifically not be
@ -374,14 +374,8 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger):
elif event == 'channel': elif event == 'channel':
self.channels_list.update_single_row.emit(*args) self.channels_list.update_single_row.emit(*args)
self.update_status() self.update_status()
elif event == 'ln_payment_attempt': elif event == 'payment_status':
msg = _('Sending lightning payment') + '... (%d/%d)'%(args[0]+1, LN_NUM_PAYMENT_ATTEMPTS) self.on_payment_status(*args)
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 == 'status': elif event == 'status':
self.update_status() self.update_status()
elif event == 'banner': elif event == 'banner':
@ -1671,33 +1665,32 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger):
self.do_send(preview = True) self.do_send(preview = True)
def pay_lightning_invoice(self, invoice): def pay_lightning_invoice(self, invoice):
amount = self.amount_e.get_amount() amount_sat = self.amount_e.get_amount()
def on_success(result): attempts = LN_NUM_PAYMENT_ATTEMPTS
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
def task(): def task():
success = self.wallet.lnworker.pay(invoice, attempts=LN_NUM_PAYMENT_ATTEMPTS, amount_sat=amount, timeout=60) self.wallet.lnworker.pay(invoice, amount_sat, attempts)
if not success: self.do_clear()
raise PaymentFailure(f'Failed after {LN_NUM_PAYMENT_ATTEMPTS} attempts') self.wallet.thread.add(task)
self.invoice_list.update()
msg = _('Sending lightning payment...') def on_payment_status(self, key, status, *args):
d = WaitingDialog(self, msg, task, on_success, on_failure) # todo: check that key is in this wallet's invoice list
self.ln_payment_attempt_signal.connect(d.update) 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): def do_send(self, preview = False):
if self.payto_e.is_lightning: if self.payto_e.is_lightning:
self.pay_lightning_invoice(self.payto_e.lightning_invoice) self.pay_lightning_invoice(self.payto_e.lightning_invoice)
return return
#
if run_hook('abort_send', self): if run_hook('abort_send', self):
return return
outputs, fee_estimator, tx_desc, coins = self.read_send_tab() outputs, fee_estimator, tx_desc, coins = self.read_send_tab()
@ -1817,8 +1810,8 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger):
else: else:
status, msg = True, tx.txid() status, msg = True, tx.txid()
if pr and status is True: if pr and status is True:
self.invoices.set_paid(pr, tx.txid()) key = pr.get_id()
self.invoices.save() self.wallet.set_invoice_paid(key, tx.txid())
self.payment_request = None self.payment_request = None
refund_address = self.wallet.get_receiving_address() refund_address = self.wallet.get_receiving_address()
coro = pr.send_payment_and_receive_paymentack(str(tx), refund_address) coro = pr.send_payment_and_receive_paymentack(str(tx), refund_address)
@ -1889,17 +1882,16 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger):
return True return True
def delete_invoice(self, key): def delete_invoice(self, key):
self.invoices.remove(key) self.wallet.delete_invoice(key)
self.invoice_list.update() self.invoice_list.update()
def payment_request_ok(self): def payment_request_ok(self):
pr = self.payment_request pr = self.payment_request
if not pr: if not pr:
return return
key = self.invoices.add(pr) key = pr.get_id()
status = self.invoices.get_status(key) invoice = self.wallet.get_invoice(key)
self.invoice_list.update() if invoice and invoice['status'] == PR_PAID:
if status == PR_PAID:
self.show_message("invoice already paid") self.show_message("invoice already paid")
self.do_clear() self.do_clear()
self.payment_request = None self.payment_request = None
@ -2106,7 +2098,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger):
self.update_completions() self.update_completions()
def show_invoice(self, key): def show_invoice(self, key):
pr = self.invoices.get(key) pr = self.wallet.get_invoice(key)
if pr is None: if pr is None:
self.show_error('Cannot find payment request in wallet.') self.show_error('Cannot find payment request in wallet.')
return return
@ -2143,7 +2135,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger):
exportButton = EnterButton(_('Save'), do_export) exportButton = EnterButton(_('Save'), do_export)
def do_delete(): def do_delete():
if self.question(_('Delete invoice?')): if self.question(_('Delete invoice?')):
self.invoices.remove(key) self.wallet.delete_invoices(key)
self.history_list.update() self.history_list.update()
self.invoice_list.update() self.invoice_list.update()
d.close() d.close()
@ -2152,7 +2144,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger):
d.exec_() d.exec_()
def do_pay_invoice(self, key): def do_pay_invoice(self, key):
pr = self.invoices.get(key) pr = self.wallet.get_invoice(key)
self.payment_request = pr self.payment_request = pr
self.prepare_for_payment_request() self.prepare_for_payment_request()
pr.error = None # this forces verify() to re-run pr.error = None # this forces verify() to re-run

9
electrum/gui/qt/request_list.py

@ -31,6 +31,7 @@ from PyQt5.QtCore import Qt, QItemSelectionModel
from electrum.i18n import _ from electrum.i18n import _
from electrum.util import format_time, age, get_request_status 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.util import PR_UNPAID, PR_EXPIRED, PR_PAID, PR_UNKNOWN, PR_INFLIGHT, pr_tooltips
from electrum.lnutil import SENT, RECEIVED from electrum.lnutil import SENT, RECEIVED
from electrum.plugin import run_hook from electrum.plugin import run_hook
@ -104,9 +105,10 @@ class RequestList(MyTreeView):
is_lightning = date_item.data(ROLE_REQUEST_TYPE) == REQUEST_TYPE_LN is_lightning = date_item.data(ROLE_REQUEST_TYPE) == REQUEST_TYPE_LN
req = self.wallet.get_request(key, is_lightning) req = self.wallet.get_request(key, is_lightning)
if req: if req:
status = req['status']
status_str = get_request_status(req) status_str = get_request_status(req)
status_item.setText(status_str) 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): def update(self):
self.wallet = self.parent.wallet self.wallet = self.parent.wallet
@ -118,10 +120,11 @@ class RequestList(MyTreeView):
status = req.get('status') status = req.get('status')
if status == PR_PAID: if status == PR_PAID:
continue 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) timestamp = req.get('time', 0)
amount = req.get('amount') amount = req.get('amount')
message = req['memo'] message = req['message'] if is_lightning else req['memo']
date = format_time(timestamp) date = format_time(timestamp)
amount_str = self.parent.format_amount(amount) if amount else "" amount_str = self.parent.format_amount(amount) if amount else ""
status_str = get_request_status(req) status_str = get_request_status(req)

13
electrum/lnchannel.py

@ -31,7 +31,7 @@ from typing import Optional, Dict, List, Tuple, NamedTuple, Set, Callable, Itera
import time import time
from . import ecc 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 TYPE_SCRIPT, TYPE_ADDRESS
from .bitcoin import redeem_script_to_address from .bitcoin import redeem_script_to_address
from .crypto import sha256, sha256d from .crypto import sha256, sha256d
@ -165,8 +165,11 @@ class Channel(Logger):
log = self.hm.log[subject] log = self.hm.log[subject]
for htlc_id, htlc in log.get('adds', {}).items(): for htlc_id, htlc in log.get('adds', {}).items():
if htlc_id in log.get('fails',{}): if htlc_id in log.get('fails',{}):
continue status = 'failed'
status = 'settled' if htlc_id in log.get('settles',{}) else 'inflight' elif htlc_id in log.get('settles',{}):
status = 'settled'
else:
status = 'inflight'
direction = SENT if subject is LOCAL else RECEIVED direction = SENT if subject is LOCAL else RECEIVED
rhash = bh2u(htlc.payment_hash) rhash = bh2u(htlc.payment_hash)
out[rhash] = (self.channel_id, htlc, direction, status) out[rhash] = (self.channel_id, htlc, direction, status)
@ -563,7 +566,7 @@ class Channel(Logger):
assert htlc_id not in log['settles'] assert htlc_id not in log['settles']
self.hm.send_settle(htlc_id) self.hm.send_settle(htlc_id)
if self.lnworker: 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): def receive_htlc_settle(self, preimage, htlc_id):
self.logger.info("receive_htlc_settle") self.logger.info("receive_htlc_settle")
@ -574,7 +577,7 @@ class Channel(Logger):
self.hm.recv_settle(htlc_id) self.hm.recv_settle(htlc_id)
if self.lnworker: if self.lnworker:
self.lnworker.save_preimage(htlc.payment_hash, preimage) 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): def fail_htlc(self, htlc_id):
self.logger.info("fail_htlc") self.logger.info("fail_htlc")

115
electrum/lnworker.py

@ -21,7 +21,8 @@ import dns.exception
from . import constants from . import constants
from . import keystore 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 .keystore import BIP32_KeyStore
from .bitcoin import COIN from .bitcoin import COIN
from .transaction import Transaction from .transaction import Transaction
@ -396,14 +397,12 @@ class LNWallet(LNWorker):
def get_invoice_status(self, key): def get_invoice_status(self, key):
if key not in self.invoices: if key not in self.invoices:
return PR_UNKNOWN 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) lnaddr = lndecode(invoice, expected_hrp=constants.net.SEGWIT_HRP)
if is_paid: if status == PR_UNPAID and lnaddr.is_expired():
return PR_PAID
elif lnaddr.is_expired():
return PR_EXPIRED return PR_EXPIRED
else: else:
return PR_UNPAID return status
def get_payments(self): def get_payments(self):
# return one item per payment_hash # return one item per payment_hash
@ -415,11 +414,35 @@ class LNWallet(LNWorker):
out[k].append(v) out[k].append(v)
return out 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): def get_history(self):
out = [] out = []
for payment_hash, plist in self.get_payments().items(): for payment_hash, plist in self.get_payments().items():
if len(plist) == 1: if len(plist) == 1:
chan_id, htlc, _direction, status = plist[0] chan_id, htlc, _direction, status = plist[0]
if status != 'settled':
continue
direction = 'sent' if _direction == SENT else 'received' direction = 'sent' if _direction == SENT else 'received'
amount_msat= int(_direction) * htlc.amount_msat amount_msat= int(_direction) * htlc.amount_msat
timestamp = htlc.timestamp timestamp = htlc.timestamp
@ -751,17 +774,23 @@ class LNWallet(LNWorker):
raise Exception(_("open_channel timed out")) raise Exception(_("open_channel timed out"))
return chan 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 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) fut = asyncio.run_coroutine_threadsafe(coro, self.network.asyncio_loop)
try: try:
return fut.result(timeout=timeout) success = fut.result()
except concurrent.futures.TimeoutError: except Exception as e:
raise PaymentFailure(_("Payment timed out")) 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): def get_channel_by_short_id(self, short_channel_id):
with self.lock: with self.lock:
@ -770,20 +799,22 @@ class LNWallet(LNWorker):
return chan return chan
@log_exceptions @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) 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: if status == PR_PAID:
# fixme: use lightning_preimaages, because invoices are not permanently stored
raise PaymentFailure(_("This invoice has been paid already")) raise PaymentFailure(_("This invoice has been paid already"))
self._check_invoice(invoice, amount_sat) self._check_invoice(invoice, amount_sat)
self.save_invoice(addr.paymenthash, invoice, SENT, is_paid=False) self.save_invoice(addr.paymenthash, invoice, SENT, PR_INFLIGHT)
self.wallet.set_label(bh2u(addr.paymenthash), addr.get_description()) self.wallet.set_label(key, addr.get_description())
for i in range(attempts): for i in range(attempts):
route = await self._create_route_from_invoice(decoded_invoice=addr) route = await self._create_route_from_invoice(decoded_invoice=addr)
if not self.get_channel_by_short_id(route[0].short_channel_id): if not self.get_channel_by_short_id(route[0].short_channel_id):
scid = format_short_channel_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}") 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): if await self._pay_to_route(route, addr, invoice):
return True return True
return False return False
@ -895,7 +926,7 @@ class LNWallet(LNWorker):
('x', expiry)] ('x', expiry)]
+ routing_hints), + routing_hints),
self.node_keypair.privkey) 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.save_preimage(payment_hash, payment_preimage)
self.wallet.set_label(bh2u(payment_hash), message) self.wallet.set_label(bh2u(payment_hash), message)
return payment_hash return payment_hash
@ -915,20 +946,24 @@ class LNWallet(LNWorker):
except KeyError as e: except KeyError as e:
raise UnknownPaymentHash(payment_hash) from 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) 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.put('lightning_invoices', self.invoices)
self.storage.write() self.storage.write()
def set_paid(self, payment_hash): def set_invoice_status(self, payment_hash, status):
key = bh2u(payment_hash) key = bh2u(payment_hash)
if key not in self.invoices: if key not in self.invoices:
# if we are forwarding # if we are forwarding
return return
invoice, direction, _ = self.invoices[key] invoice, direction, _ = self.invoices[key]
self.save_invoice(payment_hash, invoice, direction, is_paid=True) self.save_invoice(payment_hash, invoice, direction, status)
if direction == RECEIVED: if direction == RECEIVED and status == PR_PAID:
self.network.trigger_callback('payment_received', self.wallet, key, PR_PAID) self.network.trigger_callback('payment_received', self.wallet, key, PR_PAID)
def get_invoice(self, payment_hash: bytes) -> LnAddr: def get_invoice(self, payment_hash: bytes) -> LnAddr:
@ -939,6 +974,9 @@ class LNWallet(LNWorker):
raise UnknownPaymentHash(payment_hash) from e raise UnknownPaymentHash(payment_hash) from e
def get_request(self, key): def get_request(self, key):
if key not in self.invoices:
return
# todo: parse invoices when saving
invoice, direction, is_paid = self.invoices[key] invoice, direction, is_paid = self.invoices[key]
status = self.get_invoice_status(key) status = self.get_invoice_status(key)
lnaddr = lndecode(invoice, expected_hrp=constants.net.SEGWIT_HRP) lnaddr = lndecode(invoice, expected_hrp=constants.net.SEGWIT_HRP)
@ -946,22 +984,31 @@ class LNWallet(LNWorker):
description = lnaddr.get_description() description = lnaddr.get_description()
timestamp = lnaddr.date timestamp = lnaddr.date
return { return {
'lightning':True, 'type': PR_TYPE_LN,
'status':status, 'status': status,
'amount':amount_sat, 'amount': amount_sat,
'time':timestamp, 'time': timestamp,
'exp':lnaddr.get_expiry(), 'exp': lnaddr.get_expiry(),
'memo':description, 'message': description,
'rhash':key, 'rhash': key,
'invoice': invoice 'invoice': invoice
} }
@profiler
def get_invoices(self): def get_invoices(self):
items = self.invoices.items() # invoices = outgoing
out = [] out = []
for key, (invoice, direction, is_paid) in items: for key, (invoice, direction, status) in self.invoices.items():
if direction == SENT: if direction == SENT and status != PR_PAID:
continue 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)) out.append(self.get_request(key))
return out return out

104
electrum/paymentrequest.py

@ -41,6 +41,7 @@ except ImportError:
from . import bitcoin, ecc, util, transaction, x509, rsakey from . import bitcoin, ecc, util, transaction, x509, rsakey
from .util import bh2u, bfh, export_meta, import_meta, make_aiohttp_session 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_UNPAID, PR_EXPIRED, PR_PAID, PR_UNKNOWN, PR_INFLIGHT
from .util import PR_TYPE_BIP70
from .crypto import sha256 from .crypto import sha256
from .bitcoin import TYPE_ADDRESS from .bitcoin import TYPE_ADDRESS
from .transaction import TxOutput from .transaction import TxOutput
@ -270,13 +271,15 @@ class PaymentRequest:
def get_dict(self): def get_dict(self):
return { return {
'type': PR_TYPE_BIP70,
'id': self.get_id(),
'requestor': self.get_requestor(), 'requestor': self.get_requestor(),
'memo':self.get_memo(), 'message': self.get_memo(),
'exp': self.get_expiration_date(), 'time': self.get_time(),
'exp': self.get_expiration_date() - self.get_time(),
'amount': self.get_amount(), 'amount': self.get_amount(),
'signature': self.get_verify_status(), 'outputs': self.get_outputs(),
'txid': self.tx, 'hex': self.raw.hex(),
'outputs': self.get_outputs()
} }
def get_id(self): def get_id(self):
@ -475,94 +478,3 @@ def make_request(config, req):
if key_path and cert_path: if key_path and cert_path:
sign_request_with_x509(pr, key_path, cert_path) sign_request_with_x509(pr, key_path, cert_path)
return pr 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())
]

9
electrum/util.py

@ -73,19 +73,26 @@ base_units_list = ['BTC', 'mBTC', 'bits', 'sat'] # list(dict) does not guarante
DECIMAL_POINT_DEFAULT = 5 # mBTC 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 # status of payment requests
PR_UNPAID = 0 PR_UNPAID = 0
PR_EXPIRED = 1 PR_EXPIRED = 1
PR_UNKNOWN = 2 # sent but not propagated PR_UNKNOWN = 2 # sent but not propagated
PR_PAID = 3 # send and propagated PR_PAID = 3 # send and propagated
PR_INFLIGHT = 4 # unconfirmed PR_INFLIGHT = 4 # unconfirmed
PR_FAILED = 5
pr_tooltips = { pr_tooltips = {
PR_UNPAID:_('Pending'), PR_UNPAID:_('Pending'),
PR_PAID:_('Paid'), PR_PAID:_('Paid'),
PR_UNKNOWN:_('Unknown'), PR_UNKNOWN:_('Unknown'),
PR_EXPIRED:_('Expired'), PR_EXPIRED:_('Expired'),
PR_INFLIGHT:_('Paid (unconfirmed)') PR_INFLIGHT:_('In progress'),
PR_FAILED:_('Failed'),
} }
pr_expiration_values = { pr_expiration_values = {

65
electrum/wallet.py

@ -47,6 +47,7 @@ from .util import (NotEnoughFunds, UserCancelled, profiler,
InvalidPassword, format_time, timestamp_to_datetime, Satoshis, InvalidPassword, format_time, timestamp_to_datetime, Satoshis,
Fiat, bfh, bh2u, TxMinedInfo, quantize_feerate, create_bip21_uri, OrderedDictWithIndex) Fiat, bfh, bh2u, TxMinedInfo, quantize_feerate, create_bip21_uri, OrderedDictWithIndex)
from .util import age from .util import age
from .util import PR_TYPE_ADDRESS, PR_TYPE_BIP70, PR_TYPE_LN
from .simple_config import get_config from .simple_config import get_config
from .bitcoin import (COIN, TYPE_ADDRESS, is_address, address_to_script, from .bitcoin import (COIN, TYPE_ADDRESS, is_address, address_to_script,
is_minikey, relayfee, dust_threshold) is_minikey, relayfee, dust_threshold)
@ -60,14 +61,14 @@ from .transaction import Transaction, TxOutput, TxOutputHwInfo
from .plugin import run_hook from .plugin import run_hook
from .address_synchronizer import (AddressSynchronizer, TX_HEIGHT_LOCAL, from .address_synchronizer import (AddressSynchronizer, TX_HEIGHT_LOCAL,
TX_HEIGHT_UNCONF_PARENT, TX_HEIGHT_UNCONFIRMED, TX_HEIGHT_FUTURE) TX_HEIGHT_UNCONF_PARENT, TX_HEIGHT_UNCONFIRMED, TX_HEIGHT_FUTURE)
from .paymentrequest import (PR_PAID, PR_UNPAID, PR_UNKNOWN, PR_EXPIRED, PR_INFLIGHT, from .util import PR_PAID, PR_UNPAID, PR_UNKNOWN, PR_EXPIRED, PR_INFLIGHT
InvoiceStore)
from .contacts import Contacts from .contacts import Contacts
from .interface import NetworkException from .interface import NetworkException
from .ecc_fast import is_using_fast_ecc from .ecc_fast import is_using_fast_ecc
from .mnemonic import Mnemonic from .mnemonic import Mnemonic
from .logging import get_logger from .logging import get_logger
from .lnworker import LNWallet from .lnworker import LNWallet
from .paymentrequest import PaymentRequest
if TYPE_CHECKING: if TYPE_CHECKING:
from .network import Network from .network import Network
@ -225,6 +226,7 @@ class Abstract_Wallet(AddressSynchronizer):
self.frozen_coins = set(storage.get('frozen_coins', [])) # set of txid:vout strings self.frozen_coins = set(storage.get('frozen_coins', [])) # set of txid:vout strings
self.fiat_value = storage.get('fiat_value', {}) self.fiat_value = storage.get('fiat_value', {})
self.receive_requests = storage.get('payment_requests', {}) self.receive_requests = storage.get('payment_requests', {})
self.invoices = storage.get('invoices', {})
self.calc_unused_change_addresses() self.calc_unused_change_addresses()
@ -232,8 +234,7 @@ class Abstract_Wallet(AddressSynchronizer):
if self.storage.get('wallet_type') is None: if self.storage.get('wallet_type') is None:
self.storage.put('wallet_type', self.wallet_type) self.storage.put('wallet_type', self.wallet_type)
# invoices and contacts # contacts
self.invoices = InvoiceStore(self.storage)
self.contacts = Contacts(self.storage) self.contacts = Contacts(self.storage)
self._coin_price_cache = {} self._coin_price_cache = {}
self.lnworker = LNWallet(self) if get_config().get('lightning') else None self.lnworker = LNWallet(self) if get_config().get('lightning') else None
@ -498,6 +499,51 @@ class Abstract_Wallet(AddressSynchronizer):
'txpos_in_block': tx_mined_status.txpos, '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 @profiler
def get_full_history(self, fx=None): def get_full_history(self, fx=None):
transactions = OrderedDictWithIndex() transactions = OrderedDictWithIndex()
@ -1221,6 +1267,7 @@ class Abstract_Wallet(AddressSynchronizer):
if not r: if not r:
return return
out = copy.copy(r) out = copy.copy(r)
out['type'] = PR_TYPE_ADDRESS
out['URI'] = 'bitcoin:' + addr + '?amount=' + format_satoshis(out.get('amount')) out['URI'] = 'bitcoin:' + addr + '?amount=' + format_satoshis(out.get('amount'))
status, conf = self.get_request_status(addr) status, conf = self.get_request_status(addr)
out['status'] = status out['status'] = status
@ -1363,6 +1410,14 @@ class Abstract_Wallet(AddressSynchronizer):
elif self.lnworker: elif self.lnworker:
self.lnworker.delete_invoice(key) 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): def remove_payment_request(self, addr, config):
if addr not in self.receive_requests: if addr not in self.receive_requests:
return False return False
@ -1381,7 +1436,7 @@ class Abstract_Wallet(AddressSynchronizer):
""" sorted by timestamp """ """ sorted by timestamp """
out = [self.get_payment_request(x, config) for x in self.receive_requests.keys()] out = [self.get_payment_request(x, config) for x in self.receive_requests.keys()]
if self.lnworker: if self.lnworker:
out += self.lnworker.get_invoices() out += self.lnworker.get_requests()
out.sort(key=operator.itemgetter('time')) out.sort(key=operator.itemgetter('time'))
return out return out

Loading…
Cancel
Save