electrum

Electrum Bitcoin wallet
git clone https://git.parazyd.org/electrum
Log | Files | Refs | Submodules

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)