screens.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)