electrum

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

tx_dialog.py (13356B)


      1 import copy
      2 from datetime import datetime
      3 from typing import NamedTuple, Callable, TYPE_CHECKING
      4 from functools import partial
      5 
      6 from kivy.app import App
      7 from kivy.factory import Factory
      8 from kivy.properties import ObjectProperty
      9 from kivy.lang import Builder
     10 from kivy.clock import Clock
     11 from kivy.uix.label import Label
     12 from kivy.uix.dropdown import DropDown
     13 from kivy.uix.button import Button
     14 
     15 from .question import Question
     16 from electrum.gui.kivy.i18n import _
     17 
     18 from electrum.util import InvalidPassword
     19 from electrum.address_synchronizer import TX_HEIGHT_LOCAL
     20 from electrum.wallet import CannotBumpFee, CannotDoubleSpendTx
     21 from electrum.transaction import Transaction, PartialTransaction
     22 from electrum.network import NetworkException
     23 from ...util import address_colors
     24 
     25 if TYPE_CHECKING:
     26     from ...main_window import ElectrumWindow
     27 
     28 
     29 Builder.load_string('''
     30 #:import KIVY_GUI_PATH electrum.gui.kivy.KIVY_GUI_PATH
     31 
     32 <TxDialog>
     33     id: popup
     34     title: _('Transaction')
     35     is_mine: True
     36     can_sign: False
     37     can_broadcast: False
     38     can_rbf: False
     39     fee_str: ''
     40     feerate_str: ''
     41     date_str: ''
     42     date_label:''
     43     amount_str: ''
     44     tx_hash: ''
     45     status_str: ''
     46     description: ''
     47     outputs_str: ''
     48     BoxLayout:
     49         orientation: 'vertical'
     50         ScrollView:
     51             scroll_type: ['bars', 'content']
     52             bar_width: '25dp'
     53             GridLayout:
     54                 height: self.minimum_height
     55                 size_hint_y: None
     56                 cols: 1
     57                 spacing: '10dp'
     58                 padding: '10dp'
     59                 GridLayout:
     60                     height: self.minimum_height
     61                     size_hint_y: None
     62                     cols: 1
     63                     spacing: '10dp'
     64                     BoxLabel:
     65                         text: _('Status')
     66                         value: root.status_str
     67                     BoxLabel:
     68                         text: _('Description') if root.description else ''
     69                         value: root.description
     70                     BoxLabel:
     71                         text: root.date_label
     72                         value: root.date_str
     73                     BoxLabel:
     74                         text: _('Amount sent') if root.is_mine else _('Amount received')
     75                         value: root.amount_str
     76                     BoxLabel:
     77                         text: _('Transaction fee') if root.fee_str else ''
     78                         value: root.fee_str
     79                     BoxLabel:
     80                         text: _('Transaction fee rate') if root.feerate_str else ''
     81                         value: root.feerate_str
     82                 TopLabel:
     83                     text: _('Transaction ID') + ':' if root.tx_hash else ''
     84                 TxHashLabel:
     85                     data: root.tx_hash
     86                     name: _('Transaction ID')
     87                 TopLabel:
     88                     text: _('Outputs') + ':'
     89                 OutputList:
     90                     id: output_list
     91         Widget:
     92             size_hint: 1, 0.1
     93 
     94         BoxLayout:
     95             size_hint: 1, None
     96             height: '48dp'
     97             Button:
     98                 id: action_button
     99                 size_hint: 0.5, None
    100                 height: '48dp'
    101                 text: ''
    102                 disabled: True
    103                 opacity: 0
    104                 on_release: root.on_action_button_clicked()
    105             IconButton:
    106                 size_hint: 0.5, None
    107                 height: '48dp'
    108                 icon: f'atlas://{KIVY_GUI_PATH}/theming/light/qrcode'
    109                 on_release: root.show_qr()
    110             Button:
    111                 size_hint: 0.5, None
    112                 height: '48dp'
    113                 text: _('Label')
    114                 on_release: root.label_dialog()
    115             Button:
    116                 size_hint: 0.5, None
    117                 height: '48dp'
    118                 text: _('Close')
    119                 on_release: root.dismiss()
    120 ''')
    121 
    122 
    123 class ActionButtonOption(NamedTuple):
    124     text: str
    125     func: Callable
    126     enabled: bool
    127 
    128 
    129 class TxDialog(Factory.Popup):
    130 
    131     def __init__(self, app, tx):
    132         Factory.Popup.__init__(self)
    133         self.app = app  # type: ElectrumWindow
    134         self.wallet = self.app.wallet
    135         self.tx = tx  # type: Transaction
    136         self._action_button_fn = lambda btn: None
    137 
    138         # If the wallet can populate the inputs with more info, do it now.
    139         # As a result, e.g. we might learn an imported address tx is segwit,
    140         # or that a beyond-gap-limit address is is_mine.
    141         # note: this might fetch prev txs over the network.
    142         # note: this is a no-op for complete txs
    143         tx.add_info_from_wallet(self.wallet)
    144 
    145     def on_open(self):
    146         self.update()
    147 
    148     def update(self):
    149         format_amount = self.app.format_amount_and_units
    150         tx_details = self.wallet.get_tx_info(self.tx)
    151         tx_mined_status = tx_details.tx_mined_status
    152         exp_n = tx_details.mempool_depth_bytes
    153         amount, fee = tx_details.amount, tx_details.fee
    154         self.status_str = tx_details.status
    155         self.description = tx_details.label
    156         self.can_broadcast = tx_details.can_broadcast
    157         self.can_rbf = tx_details.can_bump
    158         self.can_dscancel = tx_details.can_dscancel
    159         self.tx_hash = tx_details.txid or ''
    160         if tx_mined_status.timestamp:
    161             self.date_label = _('Date')
    162             self.date_str = datetime.fromtimestamp(tx_mined_status.timestamp).isoformat(' ')[:-3]
    163         elif exp_n is not None:
    164             self.date_label = _('Mempool depth')
    165             self.date_str = _('{} from tip').format('%.2f MB'%(exp_n/1000000))
    166         else:
    167             self.date_label = ''
    168             self.date_str = ''
    169 
    170         self.can_sign = self.wallet.can_sign(self.tx)
    171         if amount is None:
    172             self.amount_str = _("Transaction unrelated to your wallet")
    173         elif amount > 0:
    174             self.is_mine = False
    175             self.amount_str = format_amount(amount)
    176         else:
    177             self.is_mine = True
    178             self.amount_str = format_amount(-amount)
    179         risk_of_burning_coins = (isinstance(self.tx, PartialTransaction)
    180                                  and self.can_sign
    181                                  and fee is not None
    182                                  and bool(self.wallet.get_warning_for_risk_of_burning_coins_as_fees(self.tx)))
    183         if fee is not None and not risk_of_burning_coins:
    184             self.fee_str = format_amount(fee)
    185             fee_per_kb = fee / self.tx.estimated_size() * 1000
    186             self.feerate_str = self.app.format_fee_rate(fee_per_kb)
    187         else:
    188             self.fee_str = _('unknown')
    189             self.feerate_str = _('unknown')
    190         self.ids.output_list.update(self.tx.outputs())
    191 
    192         for dict_entry in self.ids.output_list.data:
    193             dict_entry['color'], dict_entry['background_color'] = address_colors(self.wallet, dict_entry['address'])
    194 
    195         self.can_remove_tx = tx_details.can_remove
    196         self.update_action_button()
    197 
    198     def update_action_button(self):
    199         action_button = self.ids.action_button
    200         options = (
    201             ActionButtonOption(text=_('Sign'), func=lambda btn: self.do_sign(), enabled=self.can_sign),
    202             ActionButtonOption(text=_('Broadcast'), func=lambda btn: self.do_broadcast(), enabled=self.can_broadcast),
    203             ActionButtonOption(text=_('Bump fee'), func=lambda btn: self.do_rbf(), enabled=self.can_rbf),
    204             ActionButtonOption(text=_('Cancel (double-spend)'), func=lambda btn: self.do_dscancel(), enabled=self.can_dscancel),
    205             ActionButtonOption(text=_('Remove'), func=lambda btn: self.remove_local_tx(), enabled=self.can_remove_tx),
    206         )
    207         num_options = sum(map(lambda o: bool(o.enabled), options))
    208         # if no options available, hide button
    209         if num_options == 0:
    210             action_button.disabled = True
    211             action_button.opacity = 0
    212             return
    213         action_button.disabled = False
    214         action_button.opacity = 1
    215 
    216         if num_options == 1:
    217             # only one option, button will correspond to that
    218             for option in options:
    219                 if option.enabled:
    220                     action_button.text = option.text
    221                     self._action_button_fn = option.func
    222         else:
    223             # multiple options. button opens dropdown which has one sub-button for each
    224             dropdown = DropDown()
    225             action_button.text = _('Options')
    226             self._action_button_fn = dropdown.open
    227             for option in options:
    228                 if option.enabled:
    229                     btn = Button(text=option.text, size_hint_y=None, height='48dp')
    230                     btn.bind(on_release=option.func)
    231                     dropdown.add_widget(btn)
    232 
    233     def on_action_button_clicked(self):
    234         action_button = self.ids.action_button
    235         self._action_button_fn(action_button)
    236 
    237     def _add_info_to_tx_from_wallet_and_network(self, tx: PartialTransaction) -> bool:
    238         """Returns whether successful."""
    239         # note side-effect: tx is being mutated
    240         assert isinstance(tx, PartialTransaction)
    241         try:
    242             # note: this might download input utxos over network
    243             # FIXME network code in gui thread...
    244             tx.add_info_from_wallet(self.wallet, ignore_network_issues=False)
    245         except NetworkException as e:
    246             self.app.show_error(repr(e))
    247             return False
    248         return True
    249 
    250     def do_rbf(self):
    251         from .bump_fee_dialog import BumpFeeDialog
    252         tx = self.tx
    253         txid = tx.txid()
    254         assert txid
    255         if not isinstance(tx, PartialTransaction):
    256             tx = PartialTransaction.from_tx(tx)
    257         if not self._add_info_to_tx_from_wallet_and_network(tx):
    258             return
    259         fee = tx.get_fee()
    260         assert fee is not None
    261         size = tx.estimated_size()
    262         cb = partial(self._do_rbf, tx=tx, txid=txid)
    263         d = BumpFeeDialog(self.app, fee, size, cb)
    264         d.open()
    265 
    266     def _do_rbf(
    267             self,
    268             new_fee_rate,
    269             is_final,
    270             *,
    271             tx: PartialTransaction,
    272             txid: str,
    273     ):
    274         if new_fee_rate is None:
    275             return
    276         try:
    277             new_tx = self.wallet.bump_fee(
    278                 tx=tx,
    279                 txid=txid,
    280                 new_fee_rate=new_fee_rate,
    281             )
    282         except CannotBumpFee as e:
    283             self.app.show_error(str(e))
    284             return
    285         new_tx.set_rbf(not is_final)
    286         self.tx = new_tx
    287         self.update()
    288         self.do_sign()
    289 
    290     def do_dscancel(self):
    291         from .dscancel_dialog import DSCancelDialog
    292         tx = self.tx
    293         txid = tx.txid()
    294         assert txid
    295         if not isinstance(tx, PartialTransaction):
    296             tx = PartialTransaction.from_tx(tx)
    297         if not self._add_info_to_tx_from_wallet_and_network(tx):
    298             return
    299         fee = tx.get_fee()
    300         assert fee is not None
    301         size = tx.estimated_size()
    302         cb = partial(self._do_dscancel, tx=tx)
    303         d = DSCancelDialog(self.app, fee, size, cb)
    304         d.open()
    305 
    306     def _do_dscancel(
    307             self,
    308             new_fee_rate,
    309             *,
    310             tx: PartialTransaction,
    311     ):
    312         if new_fee_rate is None:
    313             return
    314         try:
    315             new_tx = self.wallet.dscancel(
    316                 tx=tx,
    317                 new_fee_rate=new_fee_rate,
    318             )
    319         except CannotDoubleSpendTx as e:
    320             self.app.show_error(str(e))
    321             return
    322         self.tx = new_tx
    323         self.update()
    324         self.do_sign()
    325 
    326     def do_sign(self):
    327         self.app.protected(_("Sign this transaction?"), self._do_sign, ())
    328 
    329     def _do_sign(self, password):
    330         self.status_str = _('Signing') + '...'
    331         Clock.schedule_once(lambda dt: self.__do_sign(password), 0.1)
    332 
    333     def __do_sign(self, password):
    334         try:
    335             self.app.wallet.sign_transaction(self.tx, password)
    336         except InvalidPassword:
    337             self.app.show_error(_("Invalid PIN"))
    338         self.update()
    339 
    340     def do_broadcast(self):
    341         self.app.broadcast(self.tx)
    342 
    343     def show_qr(self):
    344         original_raw_tx = str(self.tx)
    345         qr_data = self.tx.to_qr_data()
    346         self.app.qr_dialog(_("Raw Transaction"), qr_data, text_for_clipboard=original_raw_tx)
    347 
    348     def remove_local_tx(self):
    349         txid = self.tx.txid()
    350         num_child_txs = len(self.wallet.get_depending_transactions(txid))
    351         question = _("Are you sure you want to remove this transaction?")
    352         if num_child_txs > 0:
    353             question = (_("Are you sure you want to remove this transaction and {} child transactions?")
    354                         .format(num_child_txs))
    355 
    356         def on_prompt(b):
    357             if b:
    358                 self.wallet.remove_transaction(txid)
    359                 self.wallet.save_db()
    360                 self.app._trigger_update_wallet()  # FIXME private...
    361                 self.dismiss()
    362         d = Question(question, on_prompt)
    363         d.open()
    364 
    365     def label_dialog(self):
    366         from .label_dialog import LabelDialog
    367         key = self.tx.txid()
    368         text = self.app.wallet.get_label_for_txid(key)
    369         def callback(text):
    370             self.app.wallet.set_label(key, text)
    371             self.update()
    372             self.app.history_screen.update()
    373         d = LabelDialog(_('Enter Transaction Label'), text, callback)
    374         d.open()