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()