tscreens.py - electrum - Electrum Bitcoin wallet
HTML git clone https://git.parazyd.org/electrum
DIR Log
DIR Files
DIR Refs
DIR Submodules
---
tscreens.py (20895B)
---
1 import asyncio
2 from weakref import ref
3 from decimal import Decimal
4 import re
5 import threading
6 import traceback, sys
7 from typing import TYPE_CHECKING, List, Optional, Dict, Any
8
9 from kivy.app import App
10 from kivy.cache import Cache
11 from kivy.clock import Clock
12 from kivy.compat import string_types
13 from kivy.properties import (ObjectProperty, DictProperty, NumericProperty,
14 ListProperty, StringProperty)
15
16 from kivy.uix.recycleview import RecycleView
17 from kivy.uix.label import Label
18 from kivy.uix.behaviors import ToggleButtonBehavior
19 from kivy.uix.image import Image
20
21 from kivy.lang import Builder
22 from kivy.factory import Factory
23 from kivy.utils import platform
24
25 from electrum.util import profiler, parse_URI, format_time, InvalidPassword, NotEnoughFunds, Fiat
26 from electrum.invoices import (PR_TYPE_ONCHAIN, PR_TYPE_LN, PR_DEFAULT_EXPIRATION_WHEN_CREATING,
27 PR_PAID, PR_UNKNOWN, PR_EXPIRED, PR_INFLIGHT,
28 LNInvoice, pr_expiration_values, Invoice, OnchainInvoice)
29 from electrum import bitcoin, constants
30 from electrum.transaction import Transaction, tx_from_any, PartialTransaction, PartialTxOutput
31 from electrum.util import parse_URI, InvalidBitcoinURI, TxMinedInfo, maybe_extract_bolt11_invoice
32 from electrum.wallet import InternalAddressCorruption
33 from electrum import simple_config
34 from electrum.lnaddr import lndecode
35 from electrum.lnutil import RECEIVED, SENT, PaymentFailure
36 from electrum.logging import Logger
37
38 from .dialogs.question import Question
39 from .dialogs.lightning_open_channel import LightningOpenChannelDialog
40
41 from electrum.gui.kivy import KIVY_GUI_PATH
42 from electrum.gui.kivy.i18n import _
43
44 if TYPE_CHECKING:
45 from electrum.gui.kivy.main_window import ElectrumWindow
46 from electrum.paymentrequest import PaymentRequest
47
48
49 class HistoryRecycleView(RecycleView):
50 pass
51
52 class RequestRecycleView(RecycleView):
53 pass
54
55 class PaymentRecycleView(RecycleView):
56 pass
57
58 class CScreen(Factory.Screen):
59 __events__ = ('on_activate', 'on_deactivate', 'on_enter', 'on_leave')
60 action_view = ObjectProperty(None)
61 kvname = None
62 app = App.get_running_app() # type: ElectrumWindow
63
64 def on_enter(self):
65 # FIXME: use a proper event don't use animation time of screen
66 Clock.schedule_once(lambda dt: self.dispatch('on_activate'), .25)
67 pass
68
69 def update(self):
70 pass
71
72 def on_activate(self):
73 setattr(self.app, self.kvname + '_screen', self)
74 self.update()
75
76 def on_leave(self):
77 self.dispatch('on_deactivate')
78
79 def on_deactivate(self):
80 pass
81
82
83 # note: this list needs to be kept in sync with another in qt
84 TX_ICONS = [
85 "unconfirmed",
86 "close",
87 "unconfirmed",
88 "close",
89 "clock1",
90 "clock2",
91 "clock3",
92 "clock4",
93 "clock5",
94 "confirmed",
95 ]
96
97
98 Builder.load_file(KIVY_GUI_PATH + '/uix/ui_screens/history.kv')
99 Builder.load_file(KIVY_GUI_PATH + '/uix/ui_screens/send.kv')
100 Builder.load_file(KIVY_GUI_PATH + '/uix/ui_screens/receive.kv')
101
102
103 class HistoryScreen(CScreen):
104
105 tab = ObjectProperty(None)
106 kvname = 'history'
107 cards = {}
108
109 def __init__(self, **kwargs):
110 self.ra_dialog = None
111 super(HistoryScreen, self).__init__(**kwargs)
112
113 def show_item(self, obj):
114 key = obj.key
115 tx_item = self.history.get(key)
116 if tx_item.get('lightning') and tx_item['type'] == 'payment':
117 self.app.lightning_tx_dialog(tx_item)
118 return
119 if tx_item.get('lightning'):
120 tx = self.app.wallet.lnworker.lnwatcher.db.get_transaction(key)
121 else:
122 tx = self.app.wallet.db.get_transaction(key)
123 if not tx:
124 return
125 self.app.tx_dialog(tx)
126
127 def get_card(self, tx_item): #tx_hash, tx_mined_status, value, balance):
128 is_lightning = tx_item.get('lightning', False)
129 timestamp = tx_item['timestamp']
130 key = tx_item.get('txid') or tx_item['payment_hash']
131 if is_lightning:
132 status = 0
133 status_str = 'unconfirmed' if timestamp is None else format_time(int(timestamp))
134 icon = f'atlas://{KIVY_GUI_PATH}/theming/light/lightning'
135 message = tx_item['label']
136 fee_msat = tx_item['fee_msat']
137 fee = int(fee_msat/1000) if fee_msat else None
138 fee_text = '' if fee is None else 'fee: %d sat'%fee
139 else:
140 tx_hash = tx_item['txid']
141 conf = tx_item['confirmations']
142 tx_mined_info = TxMinedInfo(height=tx_item['height'],
143 conf=tx_item['confirmations'],
144 timestamp=tx_item['timestamp'])
145 status, status_str = self.app.wallet.get_tx_status(tx_hash, tx_mined_info)
146 icon = f'atlas://{KIVY_GUI_PATH}/theming/light/' + TX_ICONS[status]
147 message = tx_item['label'] or tx_hash
148 fee = tx_item['fee_sat']
149 fee_text = '' if fee is None else 'fee: %d sat'%fee
150 ri = {}
151 ri['screen'] = self
152 ri['key'] = key
153 ri['icon'] = icon
154 ri['date'] = status_str
155 ri['message'] = message
156 ri['fee_text'] = fee_text
157 value = tx_item['value'].value
158 if value is not None:
159 ri['is_mine'] = value <= 0
160 ri['amount'] = self.app.format_amount(value, is_diff = True)
161 if 'fiat_value' in tx_item:
162 ri['quote_text'] = str(tx_item['fiat_value'])
163 return ri
164
165 def update(self, see_all=False):
166 wallet = self.app.wallet
167 if wallet is None:
168 return
169 self.history = wallet.get_full_history(self.app.fx)
170 history = reversed(self.history.values())
171 history_card = self.ids.history_container
172 history_card.data = [self.get_card(item) for item in history]
173
174
175 class SendScreen(CScreen, Logger):
176
177 kvname = 'send'
178 payment_request = None # type: Optional[PaymentRequest]
179 parsed_URI = None
180
181 def __init__(self, **kwargs):
182 CScreen.__init__(self, **kwargs)
183 Logger.__init__(self)
184 self.is_max = False
185
186 def set_URI(self, text: str):
187 if not self.app.wallet:
188 return
189 try:
190 uri = parse_URI(text, self.app.on_pr, loop=self.app.asyncio_loop)
191 except InvalidBitcoinURI as e:
192 self.app.show_info(_("Error parsing URI") + f":\n{e}")
193 return
194 self.parsed_URI = uri
195 amount = uri.get('amount')
196 self.address = uri.get('address', '')
197 self.message = uri.get('message', '')
198 self.amount = self.app.format_amount_and_units(amount) if amount else ''
199 self.is_max = False
200 self.payment_request = None
201 self.is_lightning = False
202
203 def set_ln_invoice(self, invoice: str):
204 try:
205 invoice = str(invoice).lower()
206 lnaddr = lndecode(invoice, expected_hrp=constants.net.SEGWIT_HRP)
207 except Exception as e:
208 self.app.show_info(invoice + _(" is not a valid Lightning invoice: ") + repr(e)) # repr because str(Exception()) == ''
209 return
210 self.address = invoice
211 self.message = dict(lnaddr.tags).get('d', None)
212 self.amount = self.app.format_amount_and_units(lnaddr.amount * bitcoin.COIN) if lnaddr.amount else ''
213 self.payment_request = None
214 self.is_lightning = True
215
216 def update(self):
217 if self.app.wallet is None:
218 return
219 _list = self.app.wallet.get_unpaid_invoices()
220 _list.reverse()
221 payments_container = self.ids.payments_container
222 payments_container.data = [self.get_card(invoice) for invoice in _list]
223
224 def update_item(self, key, invoice):
225 payments_container = self.ids.payments_container
226 data = payments_container.data
227 for item in data:
228 if item['key'] == key:
229 item.update(self.get_card(invoice))
230 payments_container.data = data
231 payments_container.refresh_from_data()
232
233 def show_item(self, obj):
234 self.app.show_invoice(obj.is_lightning, obj.key)
235
236 def get_card(self, item: Invoice) -> Dict[str, Any]:
237 status = self.app.wallet.get_invoice_status(item)
238 status_str = item.get_status_str(status)
239 is_lightning = item.type == PR_TYPE_LN
240 key = self.app.wallet.get_key_for_outgoing_invoice(item)
241 if is_lightning:
242 assert isinstance(item, LNInvoice)
243 address = item.rhash
244 if self.app.wallet.lnworker:
245 log = self.app.wallet.lnworker.logs.get(key)
246 if status == PR_INFLIGHT and log:
247 status_str += '... (%d)'%len(log)
248 is_bip70 = False
249 else:
250 assert isinstance(item, OnchainInvoice)
251 address = item.get_address()
252 is_bip70 = bool(item.bip70)
253 return {
254 'is_lightning': is_lightning,
255 'is_bip70': is_bip70,
256 'screen': self,
257 'status': status,
258 'status_str': status_str,
259 'key': key,
260 'memo': item.message or _('No Description'),
261 'address': address,
262 'amount': self.app.format_amount_and_units(item.get_amount_sat() or 0),
263 }
264
265 def do_clear(self):
266 self.amount = ''
267 self.message = ''
268 self.address = ''
269 self.payment_request = None
270 self.is_lightning = False
271 self.is_bip70 = False
272 self.parsed_URI = None
273 self.is_max = False
274
275 def set_request(self, pr: 'PaymentRequest'):
276 self.address = pr.get_requestor()
277 amount = pr.get_amount()
278 self.amount = self.app.format_amount_and_units(amount) if amount else ''
279 self.message = pr.get_memo()
280 self.locked = True
281 self.payment_request = pr
282
283 def do_paste(self):
284 data = self.app._clipboard.paste().strip()
285 if not data:
286 self.app.show_info(_("Clipboard is empty"))
287 return
288 # try to decode as transaction
289 try:
290 tx = tx_from_any(data)
291 tx.deserialize()
292 except:
293 tx = None
294 if tx:
295 self.app.tx_dialog(tx)
296 return
297 # try to decode as URI/address
298 bolt11_invoice = maybe_extract_bolt11_invoice(data)
299 if bolt11_invoice is not None:
300 self.set_ln_invoice(bolt11_invoice)
301 else:
302 self.set_URI(data)
303
304 def read_invoice(self):
305 address = str(self.address)
306 if not address:
307 self.app.show_error(_('Recipient not specified.') + ' ' + _('Please scan a Bitcoin address or a payment request'))
308 return
309 if not self.amount:
310 self.app.show_error(_('Please enter an amount'))
311 return
312 if self.is_max:
313 amount = '!'
314 else:
315 try:
316 amount = self.app.get_amount(self.amount)
317 except:
318 self.app.show_error(_('Invalid amount') + ':\n' + self.amount)
319 return
320 message = self.message
321 if self.is_lightning:
322 return LNInvoice.from_bech32(address)
323 else: # on-chain
324 if self.payment_request:
325 outputs = self.payment_request.get_outputs()
326 else:
327 if not bitcoin.is_address(address):
328 self.app.show_error(_('Invalid Bitcoin Address') + ':\n' + address)
329 return
330 outputs = [PartialTxOutput.from_address_and_value(address, amount)]
331 return self.app.wallet.create_invoice(
332 outputs=outputs,
333 message=message,
334 pr=self.payment_request,
335 URI=self.parsed_URI)
336
337 def do_save(self):
338 invoice = self.read_invoice()
339 if not invoice:
340 return
341 self.save_invoice(invoice)
342
343 def save_invoice(self, invoice):
344 self.app.wallet.save_invoice(invoice)
345 self.do_clear()
346 self.update()
347
348 def do_pay(self):
349 invoice = self.read_invoice()
350 if not invoice:
351 return
352 self.do_pay_invoice(invoice)
353
354 def do_pay_invoice(self, invoice):
355 if invoice.is_lightning():
356 if self.app.wallet.lnworker:
357 self.app.protected(_('Pay lightning invoice?'), self._do_pay_lightning, (invoice,))
358 else:
359 self.app.show_error(_("Lightning payments are not available for this wallet"))
360 else:
361 self._do_pay_onchain(invoice)
362
363 def _do_pay_lightning(self, invoice: LNInvoice, pw) -> None:
364 def pay_thread():
365 try:
366 coro = self.app.wallet.lnworker.pay_invoice(invoice.invoice, attempts=10)
367 fut = asyncio.run_coroutine_threadsafe(coro, self.app.network.asyncio_loop)
368 fut.result()
369 except Exception as e:
370 self.app.show_error(repr(e))
371 self.save_invoice(invoice)
372 threading.Thread(target=pay_thread).start()
373
374 def _do_pay_onchain(self, invoice: OnchainInvoice) -> None:
375 from .dialogs.confirm_tx_dialog import ConfirmTxDialog
376 d = ConfirmTxDialog(self.app, invoice)
377 d.open()
378
379 def send_tx(self, tx, invoice, password):
380 if self.app.wallet.has_password() and password is None:
381 return
382 self.save_invoice(invoice)
383 def on_success(tx):
384 if tx.is_complete():
385 self.app.broadcast(tx)
386 else:
387 self.app.tx_dialog(tx)
388 def on_failure(error):
389 self.app.show_error(error)
390 if self.app.wallet.can_sign(tx):
391 self.app.show_info("Signing...")
392 self.app.sign_tx(tx, password, on_success, on_failure)
393 else:
394 self.app.tx_dialog(tx)
395
396
397 class ReceiveScreen(CScreen):
398
399 kvname = 'receive'
400
401 def __init__(self, **kwargs):
402 super(ReceiveScreen, self).__init__(**kwargs)
403 Clock.schedule_interval(lambda dt: self.update(), 5)
404 self.is_max = False # not used for receiving (see app.amount_dialog)
405
406 def expiry(self):
407 return self.app.electrum_config.get('request_expiry', PR_DEFAULT_EXPIRATION_WHEN_CREATING)
408
409 def clear(self):
410 self.address = ''
411 self.amount = ''
412 self.message = ''
413 self.lnaddr = ''
414
415 def set_address(self, addr):
416 self.address = addr
417
418 def on_address(self, addr):
419 req = self.app.wallet.get_request(addr)
420 self.status = ''
421 if req:
422 self.message = req.get('memo', '')
423 amount = req.get('amount')
424 self.amount = self.app.format_amount_and_units(amount) if amount else ''
425 status = req.get('status', PR_UNKNOWN)
426 self.status = _('Payment received') if status == PR_PAID else ''
427
428 def get_URI(self):
429 from electrum.util import create_bip21_uri
430 amount = self.amount
431 if amount:
432 a, u = self.amount.split()
433 assert u == self.app.base_unit
434 amount = Decimal(a) * pow(10, self.app.decimal_point())
435 return create_bip21_uri(self.address, amount, self.message)
436
437 def do_copy(self):
438 uri = self.get_URI()
439 self.app._clipboard.copy(uri)
440 self.app.show_info(_('Request copied to clipboard'))
441
442 def new_request(self, lightning):
443 amount = self.amount
444 amount = self.app.get_amount(amount) if amount else 0
445 message = self.message
446 if lightning:
447 key = self.app.wallet.lnworker.add_request(amount, message, self.expiry())
448 else:
449 addr = self.address or self.app.wallet.get_unused_address()
450 if not addr:
451 if not self.app.wallet.is_deterministic():
452 addr = self.app.wallet.get_receiving_address()
453 else:
454 self.app.show_info(_('No address available. Please remove some of your pending requests.'))
455 return
456 self.address = addr
457 req = self.app.wallet.make_payment_request(addr, amount, message, self.expiry())
458 self.app.wallet.add_payment_request(req)
459 key = addr
460 self.clear()
461 self.update()
462 self.app.show_request(lightning, key)
463
464 def get_card(self, req: Invoice) -> Dict[str, Any]:
465 is_lightning = req.is_lightning()
466 if not is_lightning:
467 assert isinstance(req, OnchainInvoice)
468 address = req.get_address()
469 else:
470 assert isinstance(req, LNInvoice)
471 address = req.invoice
472 key = self.app.wallet.get_key_for_receive_request(req)
473 amount = req.get_amount_sat()
474 description = req.message
475 status = self.app.wallet.get_request_status(key)
476 status_str = req.get_status_str(status)
477 ci = {}
478 ci['screen'] = self
479 ci['address'] = address
480 ci['is_lightning'] = is_lightning
481 ci['key'] = key
482 ci['amount'] = self.app.format_amount_and_units(amount) if amount else ''
483 ci['memo'] = description or _('No Description')
484 ci['status'] = status
485 ci['status_str'] = status_str
486 return ci
487
488 def update(self):
489 if self.app.wallet is None:
490 return
491 _list = self.app.wallet.get_unpaid_requests()
492 _list.reverse()
493 requests_container = self.ids.requests_container
494 requests_container.data = [self.get_card(item) for item in _list]
495
496 def update_item(self, key, request):
497 payments_container = self.ids.requests_container
498 data = payments_container.data
499 for item in data:
500 if item['key'] == key:
501 status = self.app.wallet.get_request_status(key)
502 status_str = request.get_status_str(status)
503 item['status'] = status
504 item['status_str'] = status_str
505 payments_container.data = data # needed?
506 payments_container.refresh_from_data()
507
508 def show_item(self, obj):
509 self.app.show_request(obj.is_lightning, obj.key)
510
511 def expiration_dialog(self, obj):
512 from .dialogs.choice_dialog import ChoiceDialog
513 def callback(c):
514 self.app.electrum_config.set_key('request_expiry', c)
515 d = ChoiceDialog(_('Expiration date'), pr_expiration_values, self.expiry(), callback)
516 d.open()
517
518
519 class TabbedCarousel(Factory.TabbedPanel):
520 '''Custom TabbedPanel using a carousel used in the Main Screen
521 '''
522
523 carousel = ObjectProperty(None)
524
525 def animate_tab_to_center(self, value):
526 scrlv = self._tab_strip.parent
527 if not scrlv:
528 return
529 idx = self.tab_list.index(value)
530 n = len(self.tab_list)
531 if idx in [0, 1]:
532 scroll_x = 1
533 elif idx in [n-1, n-2]:
534 scroll_x = 0
535 else:
536 scroll_x = 1. * (n - idx - 1) / (n - 1)
537 mation = Factory.Animation(scroll_x=scroll_x, d=.25)
538 mation.cancel_all(scrlv)
539 mation.start(scrlv)
540
541 def on_current_tab(self, instance, value):
542 self.animate_tab_to_center(value)
543
544 def on_index(self, instance, value):
545 current_slide = instance.current_slide
546 if not hasattr(current_slide, 'tab'):
547 return
548 tab = current_slide.tab
549 ct = self.current_tab
550 try:
551 if ct.text != tab.text:
552 carousel = self.carousel
553 carousel.slides[ct.slide].dispatch('on_leave')
554 self.switch_to(tab)
555 carousel.slides[tab.slide].dispatch('on_enter')
556 except AttributeError:
557 current_slide.dispatch('on_enter')
558
559 def switch_to(self, header):
560 # we have to replace the functionality of the original switch_to
561 if not header:
562 return
563 if not hasattr(header, 'slide'):
564 header.content = self.carousel
565 super(TabbedCarousel, self).switch_to(header)
566 try:
567 tab = self.tab_list[-1]
568 except IndexError:
569 return
570 self._current_tab = tab
571 tab.state = 'down'
572 return
573
574 carousel = self.carousel
575 self.current_tab.state = "normal"
576 header.state = 'down'
577 self._current_tab = header
578 # set the carousel to load the appropriate slide
579 # saved in the screen attribute of the tab head
580 slide = carousel.slides[header.slide]
581 if carousel.current_slide != slide:
582 carousel.current_slide.dispatch('on_leave')
583 carousel.load_slide(slide)
584 slide.dispatch('on_enter')
585
586 def add_widget(self, widget, index=0):
587 if isinstance(widget, Factory.CScreen):
588 self.carousel.add_widget(widget)
589 return
590 super(TabbedCarousel, self).add_widget(widget, index=index)