You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
377 lines
13 KiB
377 lines
13 KiB
import copy |
|
from datetime import datetime |
|
from typing import NamedTuple, Callable, TYPE_CHECKING |
|
from functools import partial |
|
|
|
from kivy.app import App |
|
from kivy.factory import Factory |
|
from kivy.properties import ObjectProperty |
|
from kivy.lang import Builder |
|
from kivy.clock import Clock |
|
from kivy.uix.label import Label |
|
from kivy.uix.dropdown import DropDown |
|
from kivy.uix.button import Button |
|
|
|
from .question import Question |
|
from electrum.gui.kivy.i18n import _ |
|
|
|
from electrum.util import InvalidPassword |
|
from electrum.address_synchronizer import TX_HEIGHT_LOCAL |
|
from electrum.wallet import CannotBumpFee, CannotDoubleSpendTx |
|
from electrum.transaction import Transaction, PartialTransaction |
|
from electrum.network import NetworkException |
|
from ...util import address_colors |
|
|
|
if TYPE_CHECKING: |
|
from ...main_window import ElectrumWindow |
|
|
|
|
|
Builder.load_string(''' |
|
#:import KIVY_GUI_PATH electrum.gui.kivy.KIVY_GUI_PATH |
|
|
|
<TxDialog> |
|
id: popup |
|
title: _('Transaction') |
|
is_mine: True |
|
can_sign: False |
|
can_broadcast: False |
|
can_rbf: False |
|
fee_str: '' |
|
feerate_str: '' |
|
date_str: '' |
|
date_label:'' |
|
amount_str: '' |
|
tx_hash: '' |
|
status_str: '' |
|
description: '' |
|
outputs_str: '' |
|
BoxLayout: |
|
orientation: 'vertical' |
|
ScrollView: |
|
scroll_type: ['bars', 'content'] |
|
bar_width: '25dp' |
|
GridLayout: |
|
height: self.minimum_height |
|
size_hint_y: None |
|
cols: 1 |
|
spacing: '10dp' |
|
padding: '10dp' |
|
GridLayout: |
|
height: self.minimum_height |
|
size_hint_y: None |
|
cols: 1 |
|
spacing: '10dp' |
|
BoxLabel: |
|
text: _('Status') |
|
value: root.status_str |
|
BoxLabel: |
|
text: _('Description') if root.description else '' |
|
value: root.description |
|
BoxLabel: |
|
text: root.date_label |
|
value: root.date_str |
|
BoxLabel: |
|
text: _('Amount sent') if root.is_mine else _('Amount received') |
|
value: root.amount_str |
|
BoxLabel: |
|
text: _('Transaction fee') if root.fee_str else '' |
|
value: root.fee_str |
|
BoxLabel: |
|
text: _('Transaction fee rate') if root.feerate_str else '' |
|
value: root.feerate_str |
|
TopLabel: |
|
text: _('Transaction ID') + ':' if root.tx_hash else '' |
|
TxHashLabel: |
|
data: root.tx_hash |
|
name: _('Transaction ID') |
|
TopLabel: |
|
text: _('Outputs') + ':' |
|
OutputList: |
|
id: output_list |
|
Widget: |
|
size_hint: 1, 0.1 |
|
|
|
BoxLayout: |
|
size_hint: 1, None |
|
height: '48dp' |
|
Button: |
|
id: action_button |
|
size_hint: 0.5, None |
|
height: '48dp' |
|
text: '' |
|
disabled: True |
|
opacity: 0 |
|
on_release: root.on_action_button_clicked() |
|
IconButton: |
|
size_hint: 0.5, None |
|
height: '48dp' |
|
icon: f'atlas://{KIVY_GUI_PATH}/theming/light/qrcode' |
|
on_release: root.show_qr() |
|
Button: |
|
size_hint: 0.5, None |
|
height: '48dp' |
|
text: _('Label') |
|
on_release: root.label_dialog() |
|
Button: |
|
size_hint: 0.5, None |
|
height: '48dp' |
|
text: _('Close') |
|
on_release: root.dismiss() |
|
''') |
|
|
|
|
|
class ActionButtonOption(NamedTuple): |
|
text: str |
|
func: Callable |
|
enabled: bool |
|
|
|
|
|
class TxDialog(Factory.Popup): |
|
|
|
def __init__(self, app, tx): |
|
Factory.Popup.__init__(self) |
|
self.app = app # type: ElectrumWindow |
|
self.wallet = self.app.wallet |
|
self.tx = tx # type: Transaction |
|
self._action_button_fn = lambda btn: None |
|
|
|
# If the wallet can populate the inputs with more info, do it now. |
|
# As a result, e.g. we might learn an imported address tx is segwit, |
|
# or that a beyond-gap-limit address is is_mine. |
|
# note: this might fetch prev txs over the network. |
|
# note: this is a no-op for complete txs |
|
tx.add_info_from_wallet(self.wallet) |
|
|
|
def on_open(self): |
|
self.update() |
|
|
|
def update(self): |
|
format_amount = self.app.format_amount_and_units |
|
tx_details = self.wallet.get_tx_info(self.tx) |
|
tx_mined_status = tx_details.tx_mined_status |
|
exp_n = tx_details.mempool_depth_bytes |
|
amount, fee = tx_details.amount, tx_details.fee |
|
self.status_str = tx_details.status |
|
self.description = tx_details.label |
|
self.can_broadcast = tx_details.can_broadcast |
|
self.can_rbf = tx_details.can_bump |
|
self.can_dscancel = tx_details.can_dscancel |
|
self.tx_hash = tx_details.txid or '' |
|
if tx_mined_status.timestamp: |
|
self.date_label = _('Date') |
|
self.date_str = datetime.fromtimestamp(tx_mined_status.timestamp).isoformat(' ')[:-3] |
|
elif exp_n is not None: |
|
self.date_label = _('Mempool depth') |
|
self.date_str = _('{} from tip').format('%.2f MB'%(exp_n/1000000)) |
|
else: |
|
self.date_label = '' |
|
self.date_str = '' |
|
|
|
self.can_sign = self.wallet.can_sign(self.tx) |
|
if amount is None: |
|
self.amount_str = _("Transaction unrelated to your wallet") |
|
elif amount > 0: |
|
self.is_mine = False |
|
self.amount_str = format_amount(amount) |
|
else: |
|
self.is_mine = True |
|
self.amount_str = format_amount(-amount) |
|
risk_of_burning_coins = (isinstance(self.tx, PartialTransaction) |
|
and self.can_sign |
|
and fee is not None |
|
and bool(self.wallet.get_warning_for_risk_of_burning_coins_as_fees(self.tx))) |
|
if fee is not None and not risk_of_burning_coins: |
|
self.fee_str = format_amount(fee) |
|
fee_per_kb = fee / self.tx.estimated_size() * 1000 |
|
self.feerate_str = self.app.format_fee_rate(fee_per_kb) |
|
else: |
|
self.fee_str = _('unknown') |
|
self.feerate_str = _('unknown') |
|
self.ids.output_list.update(self.tx.outputs()) |
|
|
|
for dict_entry in self.ids.output_list.data: |
|
dict_entry['color'], dict_entry['background_color'] = address_colors(self.wallet, dict_entry['address']) |
|
|
|
self.can_remove_tx = tx_details.can_remove |
|
self.update_action_button() |
|
|
|
def update_action_button(self): |
|
action_button = self.ids.action_button |
|
options = ( |
|
ActionButtonOption(text=_('Sign'), func=lambda btn: self.do_sign(), enabled=self.can_sign), |
|
ActionButtonOption(text=_('Broadcast'), func=lambda btn: self.do_broadcast(), enabled=self.can_broadcast), |
|
ActionButtonOption(text=_('Bump fee'), func=lambda btn: self.do_rbf(), enabled=self.can_rbf), |
|
ActionButtonOption(text=_('Cancel (double-spend)'), func=lambda btn: self.do_dscancel(), enabled=self.can_dscancel), |
|
ActionButtonOption(text=_('Remove'), func=lambda btn: self.remove_local_tx(), enabled=self.can_remove_tx), |
|
) |
|
num_options = sum(map(lambda o: bool(o.enabled), options)) |
|
# if no options available, hide button |
|
if num_options == 0: |
|
action_button.disabled = True |
|
action_button.opacity = 0 |
|
return |
|
action_button.disabled = False |
|
action_button.opacity = 1 |
|
|
|
if num_options == 1: |
|
# only one option, button will correspond to that |
|
for option in options: |
|
if option.enabled: |
|
action_button.text = option.text |
|
self._action_button_fn = option.func |
|
else: |
|
# multiple options. button opens dropdown which has one sub-button for each |
|
dropdown = DropDown() |
|
action_button.text = _('Options') |
|
self._action_button_fn = dropdown.open |
|
for option in options: |
|
if option.enabled: |
|
btn = Button(text=option.text, size_hint_y=None, height='48dp') |
|
btn.bind(on_release=option.func) |
|
dropdown.add_widget(btn) |
|
|
|
def on_action_button_clicked(self): |
|
action_button = self.ids.action_button |
|
self._action_button_fn(action_button) |
|
|
|
def _add_info_to_tx_from_wallet_and_network(self, tx: PartialTransaction) -> bool: |
|
"""Returns whether successful.""" |
|
# note side-effect: tx is being mutated |
|
assert isinstance(tx, PartialTransaction) |
|
try: |
|
# note: this might download input utxos over network |
|
# FIXME network code in gui thread... |
|
tx.add_info_from_wallet(self.wallet, ignore_network_issues=False) |
|
except NetworkException as e: |
|
self.app.show_error(repr(e)) |
|
return False |
|
return True |
|
|
|
def do_rbf(self): |
|
from .bump_fee_dialog import BumpFeeDialog |
|
tx = self.tx |
|
txid = tx.txid() |
|
assert txid |
|
if not isinstance(tx, PartialTransaction): |
|
tx = PartialTransaction.from_tx(tx) |
|
if not self._add_info_to_tx_from_wallet_and_network(tx): |
|
return |
|
fee = tx.get_fee() |
|
assert fee is not None |
|
size = tx.estimated_size() |
|
cb = partial(self._do_rbf, tx=tx, txid=txid) |
|
d = BumpFeeDialog(self.app, fee, size, cb) |
|
d.open() |
|
|
|
def _do_rbf( |
|
self, |
|
new_fee_rate, |
|
is_final, |
|
*, |
|
tx: PartialTransaction, |
|
txid: str, |
|
): |
|
if new_fee_rate is None: |
|
return |
|
try: |
|
new_tx = self.wallet.bump_fee( |
|
tx=tx, |
|
txid=txid, |
|
new_fee_rate=new_fee_rate, |
|
) |
|
except CannotBumpFee as e: |
|
self.app.show_error(str(e)) |
|
return |
|
if is_final: |
|
new_tx.set_rbf(False) |
|
self.tx = new_tx |
|
self.update() |
|
self.do_sign() |
|
|
|
def do_dscancel(self): |
|
from .dscancel_dialog import DSCancelDialog |
|
tx = self.tx |
|
txid = tx.txid() |
|
assert txid |
|
if not isinstance(tx, PartialTransaction): |
|
tx = PartialTransaction.from_tx(tx) |
|
if not self._add_info_to_tx_from_wallet_and_network(tx): |
|
return |
|
fee = tx.get_fee() |
|
assert fee is not None |
|
size = tx.estimated_size() |
|
cb = partial(self._do_dscancel, tx=tx) |
|
d = DSCancelDialog(self.app, fee, size, cb) |
|
d.open() |
|
|
|
def _do_dscancel( |
|
self, |
|
new_fee_rate, |
|
*, |
|
tx: PartialTransaction, |
|
): |
|
if new_fee_rate is None: |
|
return |
|
try: |
|
new_tx = self.wallet.dscancel( |
|
tx=tx, |
|
new_fee_rate=new_fee_rate, |
|
) |
|
except CannotDoubleSpendTx as e: |
|
self.app.show_error(str(e)) |
|
return |
|
self.tx = new_tx |
|
self.update() |
|
self.do_sign() |
|
|
|
def do_sign(self): |
|
self.app.protected(_("Sign this transaction?"), self._do_sign, ()) |
|
|
|
def _do_sign(self, password): |
|
self.status_str = _('Signing') + '...' |
|
Clock.schedule_once(lambda dt: self.__do_sign(password), 0.1) |
|
|
|
def __do_sign(self, password): |
|
try: |
|
self.app.wallet.sign_transaction(self.tx, password) |
|
except InvalidPassword: |
|
self.app.show_error(_("Invalid PIN")) |
|
self.update() |
|
|
|
def do_broadcast(self): |
|
self.app.broadcast(self.tx) |
|
|
|
def show_qr(self): |
|
original_raw_tx = str(self.tx) |
|
qr_data = self.tx.to_qr_data() |
|
self.app.qr_dialog(_("Raw Transaction"), qr_data, text_for_clipboard=original_raw_tx) |
|
|
|
def remove_local_tx(self): |
|
txid = self.tx.txid() |
|
to_delete = {txid} |
|
to_delete |= self.wallet.get_depending_transactions(txid) |
|
question = _("Are you sure you want to remove this transaction?") |
|
if len(to_delete) > 1: |
|
question = (_("Are you sure you want to remove this transaction and {} child transactions?") |
|
.format(len(to_delete) - 1)) |
|
|
|
def on_prompt(b): |
|
if b: |
|
for tx in to_delete: |
|
self.wallet.remove_transaction(tx) |
|
self.wallet.save_db() |
|
self.app._trigger_update_wallet() # FIXME private... |
|
self.dismiss() |
|
d = Question(question, on_prompt) |
|
d.open() |
|
|
|
def label_dialog(self): |
|
from .label_dialog import LabelDialog |
|
key = self.tx.txid() |
|
text = self.app.wallet.get_label_for_txid(key) |
|
def callback(text): |
|
self.app.wallet.set_label(key, text) |
|
self.update() |
|
self.app.history_screen.update() |
|
d = LabelDialog(_('Enter Transaction Label'), text, callback) |
|
d.open()
|
|
|