URI: 
       ttx_dialog.py - electrum - Electrum Bitcoin wallet
  HTML git clone https://git.parazyd.org/electrum
   DIR Log
   DIR Files
   DIR Refs
   DIR Submodules
       ---
       ttx_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()