From 70cd29f9e1d442863492aef69faf191e6ba40a30 Mon Sep 17 00:00:00 2001 From: ThomasV Date: Mon, 10 Jun 2019 14:05:02 +0200 Subject: [PATCH] GUI refactoring for Kivy and lightning. This also touches Qt and wallet code. --- electrum/gui/kivy/Makefile | 4 +- electrum/gui/kivy/main.kv | 3 + electrum/gui/kivy/main_window.py | 26 +- electrum/gui/kivy/theming/light/copy.png | Bin 0 -> 880 bytes .../kivy/theming/light/lightning_switch.svg | 288 ------------------ electrum/gui/kivy/theming/light/list.png | Bin 0 -> 1948 bytes electrum/gui/kivy/uix/dialogs/addresses.py | 7 +- electrum/gui/kivy/uix/dialogs/qr_dialog.py | 12 +- .../gui/kivy/uix/dialogs/request_dialog.py | 82 +++++ electrum/gui/kivy/uix/dialogs/requests.py | 44 ++- electrum/gui/kivy/uix/screens.py | 158 +++------- electrum/gui/kivy/uix/ui_screens/receive.kv | 93 ++---- electrum/gui/kivy/uix/ui_screens/send.kv | 36 +-- electrum/gui/qt/main_window.py | 30 +- electrum/gui/qt/request_list.py | 58 ++-- electrum/lnpeer.py | 2 +- electrum/lnworker.py | 25 +- electrum/wallet.py | 59 ++-- 18 files changed, 329 insertions(+), 598 deletions(-) create mode 100644 electrum/gui/kivy/theming/light/copy.png delete mode 100644 electrum/gui/kivy/theming/light/lightning_switch.svg create mode 100644 electrum/gui/kivy/theming/light/list.png create mode 100644 electrum/gui/kivy/uix/dialogs/request_dialog.py diff --git a/electrum/gui/kivy/Makefile b/electrum/gui/kivy/Makefile index 6c8226095..49cca4945 100644 --- a/electrum/gui/kivy/Makefile +++ b/electrum/gui/kivy/Makefile @@ -5,9 +5,7 @@ PYTHON = python3 .PHONY: theming apk clean theming: - bash -c 'for i in network lightning; do convert -background none theming/light/$$i.{svg,png}; done' - convert -background none -crop +0+390 theming/light/lightning_switch.svg theming/light/lightning_switch_off.png - convert -background none -crop 840x390+0+0 theming/light/lightning_switch.svg theming/light/lightning_switch_on.png + #bash -c 'for i in network lightning; do convert -background none theming/light/$$i.{svg,png}; done' $(PYTHON) -m kivy.atlas theming/light 1024 theming/light/*.png prepare: # running pre build setup diff --git a/electrum/gui/kivy/main.kv b/electrum/gui/kivy/main.kv index a9fc221c3..935aa413a 100644 --- a/electrum/gui/kivy/main.kv +++ b/electrum/gui/kivy/main.kv @@ -449,6 +449,9 @@ BoxLayout: ActionOvrButton: name: 'network' text: _('Network') + ActionOvrButton: + name: 'addresses_dialog' + text: _('Addresses') ActionOvrButton: name: 'lightning_channels_dialog' text: _('Channels') diff --git a/electrum/gui/kivy/main_window.py b/electrum/gui/kivy/main_window.py index 519eeb4e3..7a86f8654 100644 --- a/electrum/gui/kivy/main_window.py +++ b/electrum/gui/kivy/main_window.py @@ -195,6 +195,12 @@ class ElectrumWindow(App): def on_fee_histogram(self, *args): self._trigger_update_history() + def on_payment_received(self, event, wallet, key, status): + if self.request_popup and self.request_popup.key == key: + self.request_popup.set_status(status) + if status == PR_PAID: + self.show_info(_('Payment Received') + '\n' + key) + def _get_bu(self): decimal_point = self.electrum_config.get('decimal_point', DECIMAL_POINT_DEFAULT) try: @@ -328,6 +334,7 @@ class ElectrumWindow(App): self._settings_dialog = None self._password_dialog = None self.fee_status = self.electrum_config.get_fee_status() + self.request_popup = None def on_pr(self, pr): if not self.wallet: @@ -397,9 +404,17 @@ class ElectrumWindow(App): tab = self.tabs.ids[name + '_tab'] panel.switch_to(tab) - def show_request(self, addr): - self.switch_to('receive') - self.receive_screen.screen.address = addr + def show_request(self, is_lightning, key): + from .uix.dialogs.request_dialog import RequestDialog + if is_lightning: + request, direction, is_paid = self.wallet.lnworker.invoices.get(key) or (None, None, None) + status = self.wallet.lnworker.get_invoice_status(key) + else: + request = self.wallet.get_request_URI(key) + status, conf = self.wallet.get_request_status(key) + self.request_popup = RequestDialog('Request', request, key) + self.request_popup.set_status(status) + self.request_popup.open() def show_pr_details(self, req, status, is_invoice): from electrum.util import format_time @@ -534,6 +549,7 @@ class ElectrumWindow(App): self.network.register_callback(self.on_fee_histogram, ['fee_histogram']) self.network.register_callback(self.on_quotes, ['on_quotes']) self.network.register_callback(self.on_history, ['on_history']) + self.network.register_callback(self.on_payment_received, ['payment_received']) # load wallet self.load_wallet_by_name(self.electrum_config.get_wallet_path()) # URI passed in config @@ -1047,9 +1063,9 @@ class ElectrumWindow(App): popup.update() popup.open() - def addresses_dialog(self, screen): + def addresses_dialog(self): from .uix.dialogs.addresses import AddressesDialog - popup = AddressesDialog(self, screen, None) + popup = AddressesDialog(self) popup.update() popup.open() diff --git a/electrum/gui/kivy/theming/light/copy.png b/electrum/gui/kivy/theming/light/copy.png new file mode 100644 index 0000000000000000000000000000000000000000..739edbecb2f70ec848b3291ca5f30e0d044b8b0a GIT binary patch literal 880 zcmeAS@N?(olHy`uVBq!ia0vp^&w%(c2OE%_dN+S5kYY>nc6VX;4}uH!E}sk(;Vkfo zEM{Qf76xHPhFNnYfP(BLp1!W^kJ)%Q#8`StRz6~2VEXIn;uunK>+M}bBUML+;}8Gm z>~%>oHgfSg!n>ofh^g-Z+qwh1CsN*UPLqC<=G6CqMS0?)E$hthe%`mCFJH!o z_M68V_dJ$Zpu3-kkwaKO4H+x=2&lpEg-J(Q=2y;qa^OPwTbq;ihYi*ne4i{M^=sW) zX(sPTkUEDM$hg74afU;urd*OdSO0HeYpE=2|C`J&n-|WGpRx4)-`(Ctk0*teMoBCy zH;+2iWWbonbciL9X=&B3T}Sr6c)i=})9wd5#GTFe8yr`aKYm<~w{fWs%q1IA_UWz6 zw>~*@<<+gvRj#iVdv<5P-c7Hf#JuviiLVTFQxDq0T@BF#ahXAQ+4L>Nb)KIxyu!B5 zOxb>XXIi{lt^vvq55alEF>^IR}DUI1jNPFo$ppCj#RRFXXTQ zu|eU1z>J9?Kf^E?7Ou!JJebbcdVH?R&CHZL^CsO1v)g~LBqwbh?`nx>5fyhn{`Tj7 zXU|z2^Jzywy^s2Sv1ywg$YG7%8LNSjwBPg7jh{PgYd=}<_P&)^e$qVVe2vkK*hf(j zr<$svF^h0L$R~~bIy{c0ORa^kO_Gt^x9Z!p8s65M8!pY|l=l25wQt?}g-dmI9EXM* zBD_Fh_oJ!(#_MX||L@ZxOaCwb$J|!< - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - ON - OFF - - - diff --git a/electrum/gui/kivy/theming/light/list.png b/electrum/gui/kivy/theming/light/list.png new file mode 100644 index 0000000000000000000000000000000000000000..3cd3fe45ec57831f59a3fcd1614a64d35d3f3346 GIT binary patch literal 1948 zcmZXVdpOgJAIG=GMuwy)hMZ<;x*)gYa`<6enh`^8O_G{ie$AMOY3C5XT-uIH7^~A^ zzAjBgF5%lxkz5jUZRIvZMbZvJe(Rs#bDnda&*l9*@4sKq`YeCu^j?|C=&1v zr=#>6)gK3iri+ zHd>V4+THXxWacYsBO#}I&WiSGId0~5Y)_ZfJ^~y4_q~t0ycE<&>eJc&wV0T3Z zW%8PvXvGmFK!EvV*p_etLhKK8%;Xh~*7woCa;HNuY1RB(g z?Pp>4dUWt!@=n6ZaPn10xP&rm`RnVDV67#ghOmWR)qm*oib^L#JJFttp;=#VsHDkh zlZ^ZB<)RTupNh81mhj6w%$n(D4y_APpph*hZXBd*?D{MTV3qk=)&Py14S;|%A2CV} zPjk}Aw8|Iv#@jIBbL|Gy_jYN8NgBU(fGM1hm!iu&4@0$|$zJ17No>-e8mF|vs+(b= z=ajVu16(08v@^OfX*)rjSX;9`=F-pGUAEs-%Y1Zpt5^cn_o2mz?LSJwpUPkr~C zsv2Ci|5l97TqooIiO^r2e6vdPFV+Z)^q`g-la3S|J28D-xeD-tEy}n&eOz+06UB;2 z3@do`AH4>d8ekA&4k^$4-fxwuIevx`%6`+fqql&$oT7euV(|TeyKaThB51-b@j|t1 z4bTkrdvWnT7b(!Gv+3wjg8Ivg6e|o1RfE)q<^4Ef{1DG`z==X^<+SYbE)d^N*7b*4 zG4C(l_E=}fN7?8U9`Av~qZntW)Tp+RUgSUA@Gwyj{=n!1U{rXR4|=^{hR^DO)9EiQ z)xE>jFh0=o@_Ey4mJeCIZ_#v0)^CVkq4|m9lJ0x;5q~xci3JM)>b;!LdW-D7D2A7m zq<8gwIOb4>cUxMII%hP0PDSX*HSkx=n1~~7jj<2?pCeFE`9Vv4`@^>2nOLQm61o!jv- z$p)#LF<1{N`gk7WD0<{wu&`MC=20=%1XkhvC7#o1GJ^2? zpvp{LEC_fVYDgEhYh+Y~B>8eKhg~HRiYSIjqe%njmBja5uJA%#QxJ7~hOO+2=_;(6 z(_WZZfipsX1RhUaMF-wmvzW+%oM3jUJ;-`EVBateV#k|Map&lJtZc3#hl|8>)o(+9 z0Oi7N3M_4U^$EQYRx-=PCE0Rsq~rb(K7S>Ljh2-O0is0bZ^V-6(Z6RE zLH(%O7@LZrD0wQm3C5t6rs>Go{J_Q1{}eT}1h5(V-O(S0dhfloteL&6a?H@Qaz0y{ z6#(tIve$qn?#ZY7_cj}S243COw(~bjHuxx3>9pv83We6t_r`hbwl}vV23dC5NB{|( zchzHISy-Z%>q>N$y9R1%M~^iZGq9hZxZpQ8f+l7D_EkIvR?H?+Q41=<%0rhsI_#}Q z{K9qXEq7gT<_9e>#$ES6av?ENTH?TSG{dC~bbaFxWq!s*mvNDydo$rSrjc1(z)FoE zn$tBU2S(&8LRW?Rz$&z6eu6@W)A&Jzr|OBUF}AoU+)gWbJO(5qFHC3d{AF7$X6;n2 zvst|wV@K&G@w9;@;<69ERY>*ULhP;hU>gh#)`r#^dJox}XGHLUL0B&GsH^YD5-@SR lB}TXNp5EV^sSmw&v~j9cv=g>FL)xPdf}@K=l|AM9{{Xr9lcxXx literal 0 HcmV?d00001 diff --git a/electrum/gui/kivy/uix/dialogs/addresses.py b/electrum/gui/kivy/uix/dialogs/addresses.py index b1fc40cd0..28f58dffa 100644 --- a/electrum/gui/kivy/uix/dialogs/addresses.py +++ b/electrum/gui/kivy/uix/dialogs/addresses.py @@ -104,11 +104,9 @@ from electrum.gui.kivy.uix.context_menu import ContextMenu class AddressesDialog(Factory.Popup): - def __init__(self, app, screen, callback): + def __init__(self, app): Factory.Popup.__init__(self) self.app = app - self.screen = screen - self.callback = callback self.context_menu = None def get_card(self, addr, balance, is_used, label): @@ -155,7 +153,8 @@ class AddressesDialog(Factory.Popup): def do_use(self, obj): self.hide_menu() self.dismiss() - self.app.show_request(obj.address) + self.app.switch_to('receive') + self.app.receive_screen.set_address(obj.address) def do_view(self, obj): req = { 'address': obj.address, 'status' : obj.status } diff --git a/electrum/gui/kivy/uix/dialogs/qr_dialog.py b/electrum/gui/kivy/uix/dialogs/qr_dialog.py index 0685dfa9d..5b2a39714 100644 --- a/electrum/gui/kivy/uix/dialogs/qr_dialog.py +++ b/electrum/gui/kivy/uix/dialogs/qr_dialog.py @@ -23,6 +23,11 @@ Builder.load_string(''' spacing: '10dp' QRCodeWidget: id: qr + shaded: False + foreground_color: (0, 0, 0, 0.5) if self.shaded else (0, 0, 0, 0) + on_touch_down: + touch = args[1] + if self.collide_point(*touch.pos): self.shaded = not self.shaded TopLabel: text: root.data if root.show_text else '' Widget: @@ -33,9 +38,14 @@ Builder.load_string(''' Button: size_hint: 1, None height: '48dp' - text: _('Copy to clipboard') + text: _('Copy') on_release: root.copy_to_clipboard() + IconButton: + icon: 'atlas://electrum/gui/kivy/theming/light/share' + size_hint: 0.6, None + height: '48dp' + on_release: s.parent.do_share() Button: size_hint: 1, None height: '48dp' diff --git a/electrum/gui/kivy/uix/dialogs/request_dialog.py b/electrum/gui/kivy/uix/dialogs/request_dialog.py new file mode 100644 index 000000000..c61dff9e0 --- /dev/null +++ b/electrum/gui/kivy/uix/dialogs/request_dialog.py @@ -0,0 +1,82 @@ +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(''' + + 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' + QRCodeWidget: + id: qr + shaded: False + foreground_color: (0, 0, 0, 0.5) if self.shaded else (0, 0, 0, 0) + on_touch_down: + touch = args[1] + if self.collide_point(*touch.pos): self.shaded = not self.shaded + 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: _('Copy') + on_release: + root.copy_to_clipboard() + IconButton: + icon: 'atlas://electrum/gui/kivy/theming/light/share' + size_hint: 0.6, None + height: '48dp' + on_release: s.parent.do_share() + Button: + size_hint: 1, None + height: '48dp' + text: _('Close') + on_release: + popup.dismiss() +''') + +class RequestDialog(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 + #self.text_for_clipboard = text_for_clipboard if text_for_clipboard else data + + 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)) diff --git a/electrum/gui/kivy/uix/dialogs/requests.py b/electrum/gui/kivy/uix/dialogs/requests.py index 5aadae0d0..2a8deeec0 100644 --- a/electrum/gui/kivy/uix/dialogs/requests.py +++ b/electrum/gui/kivy/uix/dialogs/requests.py @@ -4,6 +4,12 @@ from kivy.properties import ObjectProperty from kivy.lang import Builder from decimal import Decimal +from electrum.util import age, PR_UNPAID +from electrum.lnutil import SENT, RECEIVED +from electrum.lnaddr import lndecode +import electrum.constants as constants +from electrum.bitcoin import COIN + Builder.load_string(''' #color: .305, .309, .309, 1 @@ -58,7 +64,7 @@ Builder.load_string(''' id: popup - title: _('Requests') + title: _('Pending requests') BoxLayout: id:box orientation: 'vertical' @@ -103,21 +109,20 @@ class RequestsDialog(Factory.Popup): self.cards = {} self.context_menu = None - def get_card(self, req): - address = req['address'] - ci = self.cards.get(address) + def get_card(self, is_lightning, key, address, amount, memo, timestamp): + ci = self.cards.get(key) if ci is None: ci = Factory.RequestItem() ci.address = address ci.screen = self - self.cards[address] = ci + ci.is_lightning = is_lightning + ci.key = key + self.cards[key] = ci - amount = req.get('amount') ci.amount = self.app.format_amount_and_units(amount) if amount else '' - ci.memo = req.get('memo', '') - status, conf = self.app.wallet.get_request_status(address) - ci.status = request_text[status] - ci.icon = pr_icon[status] + ci.memo = memo + ci.status = age(timestamp) + #ci.icon = pr_icon[status] #exp = pr.get_expiration_date() #ci.date = format_time(exp) if exp else _('Never') return ci @@ -127,14 +132,27 @@ class RequestsDialog(Factory.Popup): requests_list = self.ids.requests_container requests_list.clear_widgets() _list = self.app.wallet.get_sorted_requests(self.app.electrum_config) - for pr in _list: - ci = self.get_card(pr) + for req in _list[::-1]: + 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.get_card(is_lightning, key, address, amount, description, timestamp) requests_list.add_widget(ci) def do_show(self, obj): self.hide_menu() self.dismiss() - self.app.show_request(obj.address) + self.app.show_request(obj.is_lightning, obj.key) def do_delete(self, req): from .question import Question diff --git a/electrum/gui/kivy/uix/screens.py b/electrum/gui/kivy/uix/screens.py index a500bfa70..107344539 100644 --- a/electrum/gui/kivy/uix/screens.py +++ b/electrum/gui/kivy/uix/screens.py @@ -31,7 +31,7 @@ from electrum.plugin import run_hook from electrum.wallet import InternalAddressCorruption from electrum import simple_config from electrum.lnaddr import lndecode -from electrum.lnutil import RECEIVED, SENT +from electrum.lnutil import RECEIVED, SENT, PaymentFailure from .context_menu import ContextMenu from .dialogs.lightning_open_channel import LightningOpenChannelDialog @@ -233,7 +233,7 @@ class SendScreen(CScreen): self.screen.destinationtype = Destination.Address self.payment_request = None - def do_save(self): + def save_invoice(self): if not self.screen.address: return if self.screen.destinationtype == Destination.PR: @@ -247,7 +247,7 @@ class SendScreen(CScreen): pr = make_unsigned_request(req).SerializeToString() pr = PaymentRequest(pr) self.app.wallet.invoices.add(pr) - self.app.show_info(_("Invoice saved")) + #self.app.show_info(_("Invoice saved")) if pr.is_pr(): self.screen.destinationtype = Destination.PR self.payment_request = pr @@ -275,6 +275,8 @@ class SendScreen(CScreen): self.set_ln_invoice(data.rstrip()) else: self.set_URI(data) + # save automatically + self.save_invoice() def _do_send_lightning(self): if not self.screen.amount: @@ -282,27 +284,15 @@ class SendScreen(CScreen): return invoice = self.screen.address amount_sat = self.app.get_amount(self.screen.amount) - addr = self.app.wallet.lnworker._check_invoice(invoice, amount_sat) try: - route = self.app.wallet.lnworker._create_route_from_invoice(decoded_invoice=addr) - except Exception as e: - dia = LightningOpenChannelDialog(self.app, addr, str(e) + _(':\nYou can open a channel.')) - dia.open() + success = self.app.wallet.lnworker.pay(invoice, attempts=10, amount_sat=amount_sat, timeout=60) + except PaymentFailure as e: + self.app.show_error(_('Payment failure') + '\n' + str(e)) return - self.app.network.register_callback(self.payment_completed_async_thread, ['ln_payment_completed']) - _addr, _peer, coro = self.app.wallet.lnworker._pay(invoice, amount_sat) - fut = asyncio.run_coroutine_threadsafe(coro, self.app.network.asyncio_loop) - fut.add_done_callback(self.ln_payment_result) - - def payment_completed_async_thread(self, event, date, direction, htlc, preimage, chan_id): - Clock.schedule_once(lambda dt: self.payment_completed(direction, htlc, preimage)) - - def payment_completed(self, direction, htlc, preimage): - self.app.show_info(_('Payment received') if direction == RECEIVED else _('Payment sent')) - - def ln_payment_result(self, fut): - if fut.exception(): - self.app.show_error(_('Lightning payment failed:') + '\n' + repr(fut.exception())) + if success: + self.app.show_info(_('Payment was sent')) + else: + self.app.show_error(_('Payment failed')) def do_send(self): if self.screen.destinationtype == Destination.LN: @@ -389,37 +379,14 @@ class ReceiveScreen(CScreen): kvname = 'receive' - def update(self): - if not self.screen.address: - self.get_new_address() - else: - status = self.app.wallet.get_request_status(self.screen.address) - self.screen.status = _('Payment received') if status == PR_PAID else '' - def clear(self): self.screen.address = '' self.screen.amount = '' self.screen.message = '' self.screen.lnaddr = '' - def get_new_address(self) -> bool: - """Sets the address field, and returns whether the set address - is unused.""" - if not self.app.wallet: - return False - self.clear() - unused = True - try: - addr = self.app.wallet.get_unused_address() - if addr is None: - addr = self.app.wallet.get_receiving_address() or '' - unused = False - except InternalAddressCorruption as e: - addr = '' - self.app.show_error(str(e)) - send_exception_to_crash_reporter(e) + def set_address(self, addr): self.screen.address = addr - return unused def on_address(self, addr): req = self.app.wallet.get_payment_request(addr, self.app.electrum_config) @@ -430,7 +397,6 @@ class ReceiveScreen(CScreen): self.screen.amount = self.app.format_amount_and_units(amount) if amount else '' status = req.get('status', PR_UNKNOWN) self.screen.status = _('Payment received') if status == PR_PAID else '' - Clock.schedule_once(lambda dt: self.update_qr()) def get_URI(self): from electrum.util import create_bip21_uri @@ -441,73 +407,37 @@ class ReceiveScreen(CScreen): amount = Decimal(a) * pow(10, self.app.decimal_point()) return create_bip21_uri(self.screen.address, amount, self.screen.message) - @profiler - def update_qr(self): - qr = self.screen.ids.qr - if self.screen.ids.lnbutton.state == 'down': - qr.set_data(self.screen.lnaddr) - else: - uri = self.get_URI() - qr.set_data(uri) - def do_share(self): - if self.screen.ids.lnbutton.state == 'down': - if self.screen.lnaddr: - self.app.do_share('lightning://' + self.lnaddr, _('Share Lightning invoice')) - else: - uri = self.get_URI() - self.app.do_share(uri, _("Share Bitcoin Request")) + uri = self.get_URI() + self.app.do_share(uri, _("Share Bitcoin Request")) def do_copy(self): - if self.screen.ids.lnbutton.state == 'down': - if self.screen.lnaddr: - self.app._clipboard.copy(self.screen.lnaddr) - self.app.show_info(_('Invoice copied to clipboard')) - else: - uri = self.get_URI() - self.app._clipboard.copy(uri) - self.app.show_info(_('Request copied to clipboard')) - - def save_request(self): - addr = self.screen.address - if not addr: - return False + uri = self.get_URI() + self.app._clipboard.copy(uri) + self.app.show_info(_('Request copied to clipboard')) + + def new_request(self, lightning): amount = self.screen.amount - message = self.screen.message amount = self.app.get_amount(amount) if amount else 0 - req = self.app.wallet.make_payment_request(addr, amount, message, None) - try: + message = self.screen.message + expiration = 3600 # 1 hour + if lightning: + payment_hash = self.app.wallet.lnworker.add_invoice(amount, message) + request, direction, is_paid = self.app.wallet.lnworker.invoices.get(payment_hash.hex()) + key = payment_hash.hex() + else: + addr = self.screen.address or self.app.wallet.get_unused_address() + if not addr: + 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) self.app.wallet.add_payment_request(req, self.app.electrum_config) - added_request = True - except Exception as e: - self.app.show_error(_('Error adding payment request') + ':\n' + repr(e)) - added_request = False - finally: - self.app.update_tab('requests') - return added_request - - def on_amount_or_message(self): - if self.screen.ids.lnbutton.state == 'down': - if self.screen.amount: - self.screen.lnaddr = self.app.wallet.lnworker.add_invoice(self.app.get_amount(self.screen.amount), self.screen.message) - Clock.schedule_once(lambda dt: self.update_qr()) - - def do_new(self): - is_unused = self.get_new_address() - if not is_unused: - self.app.show_info(_('Please use the existing requests first.')) - - def do_save(self): - if self.save_request(): - self.app.show_info(_('Request was saved.')) - - def do_open_lnaddr(self, lnaddr): - self.clear() - self.screen.lnaddr = lnaddr - obj = lndecode(lnaddr, expected_hrp=constants.net.SEGWIT_HRP) - self.screen.message = dict(obj.tags).get('d', '') - self.screen.amount = self.app.format_amount_and_units(int(obj.amount * bitcoin.COIN)) - self.on_amount_or_message() + #request = self.get_URI() + key = addr + self.app.show_request(lightning, key) + + class TabbedCarousel(Factory.TabbedPanel): '''Custom TabbedPanel using a carousel used in the Main Screen @@ -581,15 +511,3 @@ class TabbedCarousel(Factory.TabbedPanel): self.carousel.add_widget(widget) return super(TabbedCarousel, self).add_widget(widget, index=index) - -class LightningButton(ToggleButtonBehavior, Image): - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.source = 'atlas://electrum/gui/kivy/theming/light/lightning_switch_off' - - def on_state(self, widget, value): - self.state = value - if value == 'down': - self.source = 'atlas://electrum/gui/kivy/theming/light/lightning_switch_on' - else: - self.source = 'atlas://electrum/gui/kivy/theming/light/lightning_switch_off' diff --git a/electrum/gui/kivy/uix/ui_screens/receive.kv b/electrum/gui/kivy/uix/ui_screens/receive.kv index 81d47a58b..1a7f5049b 100644 --- a/electrum/gui/kivy/uix/ui_screens/receive.kv +++ b/electrum/gui/kivy/uix/ui_screens/receive.kv @@ -9,51 +9,16 @@ ReceiveScreen: id: s name: 'receive' - address: '' amount: '' message: '' status: '' - lnaddr: '' - - on_address: - self.parent.on_address(self.address) - on_amount: - self.parent.on_amount_or_message() - on_message: - self.parent.on_amount_or_message() + is_lightning: False BoxLayout padding: '12dp', '12dp', '12dp', '12dp' spacing: '12dp' orientation: 'vertical' - size_hint: 1, 1 - FloatLayout: - id: bl - QRCodeWidget: - opacity: 0 if lnbutton.state == 'down' and not s.lnaddr else 1 - id: qr - size_hint: None, 1 - width: min(self.height, bl.width) - pos_hint: {'center': (.5, .5)} - shaded: False - foreground_color: (0, 0, 0, 0.5) if self.shaded else (0, 0, 0, 0) - on_touch_down: - touch = args[1] - if self.collide_point(*touch.pos): self.shaded = not self.shaded - Label: - text: root.status - opacity: 1 if root.status else 0 - pos_hint: {'center': (.5, .5)} - size_hint: None, 1 - width: min(self.height, bl.width) - bcolor: 0.3, 0.3, 0.3, 0.9 - canvas.before: - Color: - rgba: self.bcolor - Rectangle: - pos: self.pos - size: self.size SendReceiveBlueBottom: id: blue_bottom @@ -64,15 +29,17 @@ ReceiveScreen: height: blue_bottom.item_height spacing: '5dp' Image: - source: 'atlas://electrum/gui/kivy/theming/light/lightning' if lnbutton.state == 'down' else 'atlas://electrum/gui/kivy/theming/light/globe' + source: 'atlas://electrum/gui/kivy/theming/light/globe' size_hint: None, None size: '22dp', '22dp' pos_hint: {'center_y': .5} BlueButton: id: address_label - text: (s.address if s.address else _('Bitcoin Address')) if lnbutton.state != 'down' else (s.lnaddr if s.lnaddr else _('Please enter amount')) + text: _('Lightning') if root.is_lightning else (s.address if s.address else _('Bitcoin Address')) shorten: True - on_release: Clock.schedule_once(lambda dt: app.addresses_dialog(s) if lnbutton.state != 'down' else s.parent.do_copy()) + #on_release: Clock.schedule_once(lambda dt: app.addresses_dialog(s)) + on_release: + root.is_lightning = not root.is_lightning CardSeparator: opacity: message_selection.opacity color: blue_bottom.foreground_color @@ -113,37 +80,31 @@ ReceiveScreen: size_hint: 1, None height: '48dp' IconButton: - opacity: 1 if lnbutton.state != 'down' else 0 - icon: 'atlas://electrum/gui/kivy/theming/light/save' if lnbutton.state != 'down' else '' - size_hint: (0 if lnbutton.state == 'down' else 0.6), None - height: '48dp' - on_release: s.parent.do_save() if lnbutton.state != 'down' else None - width: (0 if lnbutton.state == 'down' else 100) - Button: - text: _('Requests') if lnbutton.state != 'down' else _('Lightning Invoices') - size_hint: 1 + (.6 if lnbutton.state == 'down' else 0), None + icon: 'atlas://electrum/gui/kivy/theming/light/list' + size_hint: 1, None height: '48dp' - on_release: Clock.schedule_once(lambda dt: app.requests_dialog(s) if lnbutton.state != 'down' else app.lightning_invoices_dialog(s.parent.do_open_lnaddr)) + on_release: Clock.schedule_once(lambda dt: app.requests_dialog(s)) + #Widget: + # size_hint: 0.5, 1 Button: - text: _('Copy') + text: _('Clear') size_hint: 1, None height: '48dp' - on_release: s.parent.do_copy() - IconButton: - icon: 'atlas://electrum/gui/kivy/theming/light/share' - size_hint: 0.6, None - height: '48dp' - on_release: s.parent.do_share() - BoxLayout: - size_hint: 1, None - height: '48dp' - LightningButton - id: lnbutton - on_state: s.parent.on_amount_or_message() - Widget - size_hint: 1, 1 + on_release: Clock.schedule_once(lambda dt: s.parent.clear()) Button: - text: _('New') + text: _('Request') size_hint: 1, None height: '48dp' - on_release: Clock.schedule_once(lambda dt: s.parent.do_new()) + on_release: Clock.schedule_once(lambda dt: s.parent.new_request(root.is_lightning)) + Widget: + size_hint: 1, 1 + #BoxLayout: + # size_hint: 1, None + # height: '48dp' + # IconButton: + # icon: 'atlas://electrum/gui/kivy/theming/light/list' + # size_hint: 0.5, None + # height: '48dp' + # on_release: Clock.schedule_once(lambda dt: app.requests_dialog(s)) + # Widget: + # size_hint: 2.5, 1 diff --git a/electrum/gui/kivy/uix/ui_screens/send.kv b/electrum/gui/kivy/uix/ui_screens/send.kv index 4a88b714b..5fecc2da2 100644 --- a/electrum/gui/kivy/uix/ui_screens/send.kv +++ b/electrum/gui/kivy/uix/ui_screens/send.kv @@ -70,7 +70,7 @@ SendScreen: pos_hint: {'center_y': .5} BlueButton: id: description - text: s.message if s.message else ({Destination.LN: _('Lightning invoice contains no description'), Destination.Address: _('Description'), Destination.PR: _('No Description')}[root.destinationtype]) + text: s.message if s.message else ({Destination.LN: _('No description'), Destination.Address: _('Description'), Destination.PR: _('No Description')}[root.destinationtype]) disabled: root.destinationtype != Destination.Address on_release: Clock.schedule_once(lambda dt: app.description_dialog(s)) CardSeparator: @@ -95,34 +95,34 @@ SendScreen: size_hint: 1, None height: '48dp' IconButton: - size_hint: 0.6, 1 - on_release: s.parent.do_save() - icon: 'atlas://electrum/gui/kivy/theming/light/save' - Button: - text: _('Invoices') - size_hint: 1, 1 - on_release: Clock.schedule_once(lambda dt: app.invoices_dialog(s)) - Button: - text: _('Paste') + size_hint: 0.5, 1 + icon: 'atlas://electrum/gui/kivy/theming/light/copy' on_release: s.parent.do_paste() IconButton: id: qr - size_hint: 0.6, 1 + size_hint: 0.5, 1 on_release: Clock.schedule_once(lambda dt: app.scan_qr(on_complete=app.on_qr)) icon: 'atlas://electrum/gui/kivy/theming/light/camera' - BoxLayout: - size_hint: 1, None - height: '48dp' Button: text: _('Clear') - on_release: s.parent.do_clear() - Widget: size_hint: 1, 1 + on_release: s.parent.do_clear() Button: text: _('Pay') size_hint: 1, 1 on_release: s.parent.do_send() Widget: size_hint: 1, 1 - - + #BoxLayout: + # size_hint: 1, None + # height: '48dp' + #IconButton: + # size_hint: 0.5, 1 + # on_release: s.parent.do_save() + # icon: 'atlas://electrum/gui/kivy/theming/light/save' + #IconButton: + # size_hint: 0.5, 1 + # icon: 'atlas://electrum/gui/kivy/theming/light/list' + # on_release: Clock.schedule_once(lambda dt: app.invoices_dialog(s)) + #Widget: + # size_hint: 2.5, 1 diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index 1411f8ad5..f8c538367 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -224,7 +224,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): interests = ['wallet_updated', 'network_updated', 'blockchain_updated', 'new_transaction', 'status', 'banner', 'verified', 'fee', 'fee_histogram', 'on_quotes', - 'on_history', 'channel', 'channels', 'ln_message', + 'on_history', 'channel', 'channels', 'payment_received', 'ln_payment_completed', 'ln_payment_attempt'] # To avoid leaking references to "self" that prevent the # window from being GC-ed when closed, callbacks should be @@ -362,7 +362,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): wallet, tx = args if wallet == self.wallet: self.tx_notification_queue.put(tx) - elif event in ['status', 'banner', 'verified', 'fee', 'fee_histogram', 'ln_message']: + elif event in ['status', 'banner', 'verified', 'fee', 'fee_histogram', 'payment_received']: # Handle in GUI thread self.network_signal.emit(event, args) elif event == 'on_quotes': @@ -404,10 +404,10 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.fee_slider.update() self.require_fee_update = True self.history_model.on_fee_histogram() - elif event == 'ln_message': - lnworker, message, htlc_id = args - if lnworker == self.wallet.lnworker: - self.notify(message) + elif event == 'payment_received': + wallet, key, status = args + if wallet == self.wallet: + self.notify(_('Payment received') + '\n' + key) else: self.logger.info(f"unexpected network_qt signal: {event} {args}") @@ -1039,24 +1039,6 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.invoice_list.update() self.clear_receive_tab() - def get_request_URI(self, addr): - req = self.wallet.receive_requests[addr] - message = self.wallet.labels.get(addr, '') - amount = req['amount'] - extra_query_params = {} - if req.get('time'): - extra_query_params['time'] = str(int(req.get('time'))) - if req.get('exp'): - extra_query_params['exp'] = str(int(req.get('exp'))) - if req.get('name') and req.get('sig'): - sig = bfh(req.get('sig')) - sig = bitcoin.base_encode(sig, base=58) - extra_query_params['name'] = req['name'] - extra_query_params['sig'] = sig - uri = util.create_bip21_uri(addr, amount, message, extra_query_params=extra_query_params) - return str(uri) - - def sign_payment_request(self, addr): alias = self.config.get('alias') alias_privkey = None diff --git a/electrum/gui/qt/request_list.py b/electrum/gui/qt/request_list.py index 1c3b6c302..2bc56c9ad 100644 --- a/electrum/gui/qt/request_list.py +++ b/electrum/gui/qt/request_list.py @@ -90,9 +90,9 @@ class RequestList(MyTreeView): if req is None: self.update() return - req = self.parent.get_request_URI(key) + 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) + req, direction, is_paid = self.wallet.lnworker.invoices.get(key) or (None, None, None) if req is None: self.update() return @@ -107,51 +107,37 @@ class RequestList(MyTreeView): self.model().clear() self.update_headers(self.__class__.headers) for req in self.wallet.get_sorted_requests(self.config): - address = req['address'] - if address not in domain: - continue + request_type = REQUEST_TYPE_LN if req.get('lightning', False) else REQUEST_TYPE_BITCOIN timestamp = req.get('time', 0) amount = req.get('amount') - expiration = req.get('exp', None) message = req['memo'] date = format_time(timestamp) status = req.get('status') - signature = req.get('sig') - requestor = req.get('name', '') amount_str = self.parent.format_amount(amount) if amount else "" labels = [date, message, amount_str, pr_tooltips.get(status,'')] items = [QStandardItem(e) for e in labels] self.set_editability(items) - if signature is not None: - items[self.Columns.DATE].setIcon(read_QIcon("seal.png")) - items[self.Columns.DATE].setToolTip(f'signed by {requestor}') - else: - items[self.Columns.DATE].setIcon(read_QIcon("bitcoin.png")) + items[self.Columns.DATE].setData(request_type, ROLE_REQUEST_TYPE) items[self.Columns.STATUS].setIcon(read_QIcon(pr_icons.get(status))) + if request_type == REQUEST_TYPE_LN: + items[self.Columns.DATE].setData(req['rhash'], ROLE_RHASH_OR_ADDR) + items[self.Columns.DATE].setIcon(read_QIcon("lightning.png")) + items[self.Columns.DATE].setData(REQUEST_TYPE_LN, ROLE_REQUEST_TYPE) + else: + address = req['address'] + if address not in domain: + continue + expiration = req.get('exp', None) + signature = req.get('sig') + requestor = req.get('name', '') + items[self.Columns.DATE].setData(address, ROLE_RHASH_OR_ADDR) + if signature is not None: + items[self.Columns.DATE].setIcon(read_QIcon("seal.png")) + items[self.Columns.DATE].setToolTip(f'signed by {requestor}') + else: + items[self.Columns.DATE].setIcon(read_QIcon("bitcoin.png")) self.model().insertRow(self.model().rowCount(), items) - items[self.Columns.DATE].setData(REQUEST_TYPE_BITCOIN, ROLE_REQUEST_TYPE) - items[self.Columns.DATE].setData(address, ROLE_RHASH_OR_ADDR) self.filter() - # lightning - lnworker = self.wallet.lnworker - items = lnworker.invoices.items() if lnworker else [] - for key, (invoice, direction, is_paid) in items: - if direction == SENT: - continue - status = lnworker.get_invoice_status(key) - 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 = format_time(lnaddr.date) - labels = [date, 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.DATE].setData(REQUEST_TYPE_LN, ROLE_REQUEST_TYPE) - items[self.Columns.DATE].setData(key, ROLE_RHASH_OR_ADDR) - items[self.Columns.STATUS].setIcon(read_QIcon(pr_icons.get(status))) - self.model().insertRow(self.model().rowCount(), items) # sort requests by date self.model().sort(self.Columns.DATE) # hide list if empty @@ -192,7 +178,7 @@ class RequestList(MyTreeView): def create_menu_bitcoin_payreq(self, menu, addr): menu.addAction(_("Copy Address"), lambda: self.parent.do_copy('Address', addr)) - menu.addAction(_("Copy URI"), lambda: self.parent.do_copy('URI', self.parent.get_request_URI(addr))) + menu.addAction(_("Copy URI"), lambda: self.parent.do_copy('URI', self.wallet.get_request_URI(addr))) menu.addAction(_("Save as BIP70 file"), lambda: self.parent.export_payment_request(addr)) menu.addAction(_("Delete"), lambda: self.parent.delete_payment_request(addr)) run_hook('receive_list_menu', menu, addr) diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py index 224821711..d2e290ad1 100644 --- a/electrum/lnpeer.py +++ b/electrum/lnpeer.py @@ -1249,7 +1249,7 @@ class Peer(Logger): id=htlc_id, payment_preimage=preimage) await self.await_remote(chan, remote_ctn) - self.network.trigger_callback('ln_message', self.lnworker, 'Payment received', htlc_id) + #self.lnworker.payment_received(htlc_id) async def fail_htlc(self, chan: Channel, htlc_id: int, onion_packet: OnionPacket, reason: OnionRoutingFailureMessage): diff --git a/electrum/lnworker.py b/electrum/lnworker.py index 0b999f1a5..9c18cd360 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -668,7 +668,6 @@ class LNWallet(LNWorker): except concurrent.futures.TimeoutError: raise PaymentFailure(_("Payment timed out")) - def get_channel_by_short_id(self, short_channel_id): with self.lock: for chan in self.channels.values(): @@ -812,6 +811,8 @@ class LNWallet(LNWorker): return invoice, direction, _ = self.invoices[key] self.save_invoice(payment_hash, invoice, direction, is_paid=True) + if direction == RECEIVED: + self.network.trigger_callback('payment_received', self.wallet, key, PR_PAID) def get_invoice(self, payment_hash: bytes) -> LnAddr: try: @@ -820,6 +821,28 @@ 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 + 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 + }) + return out + def _calc_routing_hints_for_invoice(self, amount_sat): """calculate routing hints (BOLT-11 'r' field)""" self.channel_db.load_data() diff --git a/electrum/wallet.py b/electrum/wallet.py index e810516af..641a26f01 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -34,6 +34,7 @@ import json import copy import errno import traceback +import operator from functools import partial from numbers import Number from decimal import Decimal @@ -44,7 +45,7 @@ from .util import (NotEnoughFunds, UserCancelled, profiler, format_satoshis, format_fee_satoshis, NoDynamicFeeEstimates, WalletFileException, BitcoinException, InvalidPassword, format_time, timestamp_to_datetime, Satoshis, - Fiat, bfh, bh2u, TxMinedInfo, quantize_feerate) + Fiat, bfh, bh2u, TxMinedInfo, quantize_feerate, create_bip21_uri) from .simple_config import get_config from .bitcoin import (COIN, TYPE_ADDRESS, is_address, address_to_script, is_minikey, relayfee, dust_threshold) @@ -1134,10 +1135,10 @@ class Abstract_Wallet(AddressSynchronizer): return wrapper def get_unused_addresses(self): - # fixme: use slots from expired requests domain = self.get_receiving_addresses() + in_use = [k for k in self.receive_requests.keys() if self.get_request_status(k)[0] != PR_EXPIRED] return [addr for addr in domain if not self.db.get_addr_history(addr) - and addr not in self.receive_requests.keys()] + and addr not in in_use] @check_returned_address def get_unused_address(self): @@ -1218,31 +1219,50 @@ class Abstract_Wallet(AddressSynchronizer): out['websocket_port'] = config.get('websocket_port', 9999) return out + def get_request_URI(self, addr): + req = self.receive_requests[addr] + message = self.labels.get(addr, '') + amount = req['amount'] + extra_query_params = {} + if req.get('time'): + extra_query_params['time'] = str(int(req.get('time'))) + if req.get('exp'): + extra_query_params['exp'] = str(int(req.get('exp'))) + if req.get('name') and req.get('sig'): + sig = bfh(req.get('sig')) + sig = bitcoin.base_encode(sig, base=58) + extra_query_params['name'] = req['name'] + extra_query_params['sig'] = sig + uri = create_bip21_uri(addr, amount, message, extra_query_params=extra_query_params) + return str(uri) + def get_request_status(self, key): r = self.receive_requests.get(key) if r is None: return PR_UNKNOWN address = r['address'] - amount = r.get('amount') + amount = r.get('amount', 0) timestamp = r.get('time', 0) if timestamp and type(timestamp) != int: timestamp = 0 expiration = r.get('exp') if expiration and type(expiration) != int: expiration = 0 - conf = None - if amount: - if self.is_up_to_date(): - 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 - else: - status = PR_UNKNOWN - else: - status = PR_UNKNOWN + + 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 return status, conf + def receive_tx_callback(self, tx_hash, tx, tx_height): + super().receive_tx_callback(tx_hash, tx, tx_height) + for txo in tx.outputs(): + addr = self.get_txout_address(txo) + if addr in self.receive_requests: + status, conf = self.get_request_status(addr) + self.network.trigger_callback('payment_received', self, addr, status) + def make_payment_request(self, addr, amount, message, expiration): timestamp = int(time.time()) _id = bh2u(sha256d(addr + "%d"%timestamp))[0:10] @@ -1306,9 +1326,12 @@ class Abstract_Wallet(AddressSynchronizer): return True def get_sorted_requests(self, config): - keys = map(lambda x: (self.get_address_index(x), x), self.receive_requests.keys()) - sorted_keys = sorted(filter(lambda x: x[0] is not None, keys)) - return [self.get_payment_request(x[1], config) for x in sorted_keys] + """ sorted by timestamp """ + out = [self.get_payment_request(x, config) for x in self.receive_requests.keys()] + if self.lnworker: + out += self.lnworker.get_invoices() + out.sort(key=operator.itemgetter('time')) + return out def get_fingerprint(self): raise NotImplementedError()