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