This is purely informational and optional, with the main immediate use to provide the invoice description/message/label to the transaction dialog, so it can be stored when saving the tx in history, or passed along with PSBTs sent to cosigners. Before, the tx description was not saved in history when an invoice was not saved before signing and saving the tx for sending later.
1117 lines
48 KiB
Python
1117 lines
48 KiB
Python
#!/usr/bin/env python
|
|
#
|
|
# Electrum - lightweight Bitcoin client
|
|
# Copyright (C) 2012 thomasv@gitorious
|
|
#
|
|
# Permission is hereby granted, free of charge, to any person
|
|
# obtaining a copy of this software and associated documentation files
|
|
# (the "Software"), to deal in the Software without restriction,
|
|
# including without limitation the rights to use, copy, modify, merge,
|
|
# publish, distribute, sublicense, and/or sell copies of the Software,
|
|
# and to permit persons to whom the Software is furnished to do so,
|
|
# subject to the following conditions:
|
|
#
|
|
# The above copyright notice and this permission notice shall be
|
|
# included in all copies or substantial portions of the Software.
|
|
#
|
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
|
|
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
|
|
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
# SOFTWARE.
|
|
|
|
import asyncio
|
|
import concurrent.futures
|
|
import copy
|
|
import datetime
|
|
import time
|
|
from typing import TYPE_CHECKING, Optional, List, Union, Mapping, Callable
|
|
from functools import partial
|
|
from decimal import Decimal
|
|
|
|
from PyQt6.QtCore import QSize, Qt, QUrl, QPoint, pyqtSignal
|
|
from PyQt6.QtGui import QTextCharFormat, QBrush, QFont, QPixmap, QTextCursor, QAction
|
|
from PyQt6.QtWidgets import (QDialog, QLabel, QPushButton, QHBoxLayout, QVBoxLayout, QWidget,
|
|
QToolButton, QMenu, QTextBrowser,
|
|
QSizePolicy)
|
|
import qrcode
|
|
from qrcode import exceptions
|
|
|
|
from electrum import bitcoin
|
|
|
|
from electrum.bitcoin import NLOCKTIME_BLOCKHEIGHT_MAX, DummyAddress
|
|
from electrum.i18n import _
|
|
from electrum.plugin import run_hook
|
|
from electrum.transaction import SerializationError, Transaction, PartialTransaction, TxOutpoint, TxinDataFetchProgress
|
|
from electrum.logging import get_logger
|
|
from electrum.util import ShortID, get_asyncio_loop, UI_UNIT_NAME_TXSIZE_VBYTES, delta_time_str
|
|
from electrum.network import Network
|
|
from electrum.wallet import TxSighashRiskLevel, TxSighashDanger
|
|
|
|
from .util import (MessageBoxMixin, read_QIcon, Buttons, icon_path,
|
|
MONOSPACE_FONT, ColorScheme, ButtonsLineEdit, ShowQRLineEdit, text_dialog,
|
|
char_width_in_lineedit, TRANSACTION_FILE_EXTENSION_FILTER_SEPARATE,
|
|
TRANSACTION_FILE_EXTENSION_FILTER_ONLY_COMPLETE_TX,
|
|
TRANSACTION_FILE_EXTENSION_FILTER_ONLY_PARTIAL_TX,
|
|
getSaveFileName, ColorSchemeItem,
|
|
get_icon_qrcode, VLine, WaitingDialog)
|
|
from .rate_limiter import rate_limited
|
|
from .my_treeview import create_toolbar_with_menu, QMenuWithConfig
|
|
|
|
if TYPE_CHECKING:
|
|
from .main_window import ElectrumWindow
|
|
from electrum.wallet import Abstract_Wallet
|
|
from electrum.invoices import Invoice
|
|
|
|
|
|
_logger = get_logger(__name__)
|
|
dialogs = [] # Otherwise python randomly garbage collects the dialogs...
|
|
|
|
|
|
class TxSizeLabel(QLabel):
|
|
def setAmount(self, byte_size):
|
|
text = ""
|
|
if byte_size:
|
|
text = f"x {byte_size} {UI_UNIT_NAME_TXSIZE_VBYTES} ="
|
|
self.setText(text)
|
|
|
|
|
|
class TxFiatLabel(QLabel):
|
|
def setAmount(self, fiat_fee):
|
|
self.setText(('≈ %s' % fiat_fee) if fiat_fee else '')
|
|
|
|
|
|
class QTextBrowserWithDefaultSize(QTextBrowser):
|
|
def __init__(self, width: int = 0, height: int = 0):
|
|
self._width = width
|
|
self._height = height
|
|
QTextBrowser.__init__(self)
|
|
self.setLineWrapMode(QTextBrowser.LineWrapMode.NoWrap)
|
|
|
|
def sizeHint(self):
|
|
return QSize(self._width, self._height)
|
|
|
|
|
|
class TxInOutWidget(QWidget):
|
|
def __init__(self, main_window: 'ElectrumWindow', wallet: 'Abstract_Wallet'):
|
|
QWidget.__init__(self)
|
|
|
|
self.wallet = wallet
|
|
self.main_window = main_window
|
|
self.tx = None # type: Optional[Transaction]
|
|
self.inputs_header = QLabel()
|
|
self.inputs_textedit = QTextBrowserWithDefaultSize(750, 100)
|
|
self.inputs_textedit.setOpenLinks(False) # disable automatic link opening
|
|
self.inputs_textedit.anchorClicked.connect(self._open_internal_link) # send links to our handler
|
|
self.inputs_textedit.setTextInteractionFlags(
|
|
self.inputs_textedit.textInteractionFlags() | Qt.TextInteractionFlag.LinksAccessibleByMouse | Qt.TextInteractionFlag.LinksAccessibleByKeyboard)
|
|
self.inputs_textedit.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
self.inputs_textedit.customContextMenuRequested.connect(self.on_context_menu_for_inputs)
|
|
|
|
self.sighash_label = QLabel()
|
|
self.sighash_label.setStyleSheet('font-weight: bold')
|
|
self.sighash_danger = TxSighashDanger()
|
|
self.inputs_warning_icon = QLabel()
|
|
pixmap = QPixmap(icon_path("warning"))
|
|
pixmap_size = round(2 * char_width_in_lineedit())
|
|
pixmap = pixmap.scaled(pixmap_size, pixmap_size, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
|
|
self.inputs_warning_icon.setPixmap(pixmap)
|
|
self.inputs_warning_icon.setVisible(False)
|
|
|
|
self.inheader_hbox = QHBoxLayout()
|
|
self.inheader_hbox.setContentsMargins(0, 0, 0, 0)
|
|
self.inheader_hbox.addWidget(self.inputs_header)
|
|
self.inheader_hbox.addStretch(2)
|
|
self.inheader_hbox.addWidget(self.sighash_label)
|
|
self.inheader_hbox.addWidget(self.inputs_warning_icon)
|
|
|
|
self.txo_color_recv = TxOutputColoring(
|
|
legend=_("Wallet Address"), color=ColorScheme.GREEN, tooltip=_("Wallet receiving address"))
|
|
self.txo_color_change = TxOutputColoring(
|
|
legend=_("Change Address"), color=ColorScheme.YELLOW, tooltip=_("Wallet change address"))
|
|
self.txo_color_2fa = TxOutputColoring(
|
|
legend=_("TrustedCoin (2FA) batch fee"), color=ColorScheme.BLUE, tooltip=_("TrustedCoin (2FA) fee for the next batch of transactions"))
|
|
self.txo_color_swap = TxOutputColoring(
|
|
legend=_("Submarine swap address"), color=ColorScheme.BLUE, tooltip=_("Submarine swap address"))
|
|
self.outputs_header = QLabel()
|
|
self.outputs_textedit = QTextBrowserWithDefaultSize(750, 100)
|
|
self.outputs_textedit.setOpenLinks(False) # disable automatic link opening
|
|
self.outputs_textedit.anchorClicked.connect(self._open_internal_link) # send links to our handler
|
|
self.outputs_textedit.setTextInteractionFlags(
|
|
self.outputs_textedit.textInteractionFlags() | Qt.TextInteractionFlag.LinksAccessibleByMouse | Qt.TextInteractionFlag.LinksAccessibleByKeyboard)
|
|
self.outputs_textedit.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
self.outputs_textedit.customContextMenuRequested.connect(self.on_context_menu_for_outputs)
|
|
|
|
outheader_hbox = QHBoxLayout()
|
|
outheader_hbox.setContentsMargins(0, 0, 0, 0)
|
|
outheader_hbox.addWidget(self.outputs_header)
|
|
outheader_hbox.addStretch(2)
|
|
outheader_hbox.addWidget(self.txo_color_recv.legend_label)
|
|
outheader_hbox.addWidget(self.txo_color_change.legend_label)
|
|
outheader_hbox.addWidget(self.txo_color_2fa.legend_label)
|
|
outheader_hbox.addWidget(self.txo_color_swap.legend_label)
|
|
|
|
vbox = QVBoxLayout()
|
|
vbox.addLayout(self.inheader_hbox)
|
|
vbox.addWidget(self.inputs_textedit)
|
|
vbox.addLayout(outheader_hbox)
|
|
vbox.addWidget(self.outputs_textedit)
|
|
self.setLayout(vbox)
|
|
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
|
|
|
def update(self, tx: Optional[Transaction]):
|
|
self.tx = tx
|
|
if tx is None:
|
|
self.inputs_header.setText('')
|
|
self.inputs_textedit.setText('')
|
|
self.outputs_header.setText('')
|
|
self.outputs_textedit.setText('')
|
|
return
|
|
|
|
inputs_header_text = _("Inputs") + ' (%d)'%len(self.tx.inputs())
|
|
self.inputs_header.setText(inputs_header_text)
|
|
ext = QTextCharFormat() # "external"
|
|
lnk = QTextCharFormat()
|
|
lnk.setToolTip(_('Click to open, right-click for menu'))
|
|
lnk.setAnchor(True)
|
|
lnk.setUnderlineStyle(QTextCharFormat.UnderlineStyle.SingleUnderline)
|
|
tf_used_recv, tf_used_change, tf_used_2fa, tf_used_swap = False, False, False, False
|
|
|
|
def addr_text_format(addr: str) -> QTextCharFormat:
|
|
nonlocal tf_used_recv, tf_used_change, tf_used_2fa, tf_used_swap
|
|
sm = self.wallet.lnworker.swap_manager if self.wallet.lnworker else None
|
|
if self.wallet.is_mine(addr):
|
|
if self.wallet.is_change(addr):
|
|
tf_used_change = True
|
|
fmt = QTextCharFormat(self.txo_color_change.text_char_format)
|
|
else:
|
|
tf_used_recv = True
|
|
fmt = QTextCharFormat(self.txo_color_recv.text_char_format)
|
|
fmt.setAnchorHref(addr)
|
|
fmt.setToolTip(_('Click to open, right-click for menu'))
|
|
fmt.setAnchor(True)
|
|
fmt.setUnderlineStyle(QTextCharFormat.UnderlineStyle.SingleUnderline)
|
|
return fmt
|
|
elif sm and sm.is_lockup_address_for_a_swap(addr) or addr == DummyAddress.SWAP:
|
|
tf_used_swap = True
|
|
return self.txo_color_swap.text_char_format
|
|
elif self.wallet.is_billing_address(addr):
|
|
tf_used_2fa = True
|
|
return self.txo_color_2fa.text_char_format
|
|
return ext
|
|
|
|
def insert_tx_io(
|
|
*,
|
|
cursor: QTextCursor,
|
|
txio_idx: int,
|
|
is_coinbase: bool,
|
|
tcf_shortid: QTextCharFormat = None,
|
|
short_id: str,
|
|
addr: Optional[str],
|
|
value: Optional[int],
|
|
):
|
|
tcf_ext = QTextCharFormat(ext)
|
|
tcf_addr = addr_text_format(addr)
|
|
if tcf_shortid is None:
|
|
tcf_shortid = tcf_ext
|
|
a_name = f"txio_idx {txio_idx}"
|
|
for tcf in (tcf_ext, tcf_shortid, tcf_addr): # used by context menu creation
|
|
tcf.setAnchorNames([a_name])
|
|
if is_coinbase:
|
|
cursor.insertText('coinbase', tcf_ext)
|
|
else:
|
|
# short_id
|
|
cursor.insertText(short_id, tcf_shortid)
|
|
cursor.insertText(" " * max(0, 15 - len(short_id)), tcf_ext) # padding
|
|
cursor.insertText('\t', tcf_ext)
|
|
# addr
|
|
if addr is None:
|
|
address_str = '<address unknown>'
|
|
elif len(addr) <= 42:
|
|
address_str = addr
|
|
else:
|
|
address_str = addr[0:30] + '…' + addr[-11:]
|
|
cursor.insertText(address_str, tcf_addr)
|
|
cursor.insertText(" " * max(0, 42 - len(address_str)), tcf_ext) # padding
|
|
cursor.insertText('\t', tcf_ext)
|
|
# value
|
|
value_str = self.main_window.format_amount(value, whitespaces=True)
|
|
cursor.insertText(value_str, tcf_ext)
|
|
cursor.insertBlock()
|
|
|
|
i_text = self.inputs_textedit
|
|
i_text.clear()
|
|
i_text.setFont(QFont(MONOSPACE_FONT))
|
|
i_text.setReadOnly(True)
|
|
cursor = i_text.textCursor()
|
|
for txin_idx, txin in enumerate(self.tx.inputs()):
|
|
addr = self.wallet.adb.get_txin_address(txin)
|
|
txin_value = self.wallet.adb.get_txin_value(txin)
|
|
tcf_shortid = QTextCharFormat(lnk)
|
|
tcf_shortid.setAnchorHref(txin.prevout.txid.hex())
|
|
insert_tx_io(
|
|
cursor=cursor, is_coinbase=txin.is_coinbase_input(), txio_idx=txin_idx,
|
|
tcf_shortid=tcf_shortid,
|
|
short_id=str(txin.short_id), addr=addr, value=txin_value,
|
|
)
|
|
|
|
if isinstance(self.tx, PartialTransaction):
|
|
self.sighash_danger = self.wallet.check_sighash(self.tx)
|
|
if self.sighash_danger.risk_level >= TxSighashRiskLevel.WEIRD_SIGHASH:
|
|
self.sighash_label.setText(self.sighash_danger.short_message)
|
|
self.inputs_warning_icon.setVisible(True)
|
|
self.inputs_warning_icon.setToolTip(self.sighash_danger.get_long_message())
|
|
|
|
self.outputs_header.setText(_("Outputs") + ' (%d)'%len(self.tx.outputs()))
|
|
o_text = self.outputs_textedit
|
|
o_text.clear()
|
|
o_text.setFont(QFont(MONOSPACE_FONT))
|
|
o_text.setReadOnly(True)
|
|
tx_height, tx_pos = None, None
|
|
tx_hash = self.tx.txid()
|
|
if tx_hash:
|
|
tx_mined_info = self.wallet.adb.get_tx_height(tx_hash)
|
|
tx_height = tx_mined_info.height
|
|
tx_pos = tx_mined_info.txpos
|
|
cursor = o_text.textCursor()
|
|
for txout_idx, o in enumerate(self.tx.outputs()):
|
|
if tx_height is not None and tx_pos is not None and tx_pos >= 0:
|
|
short_id = ShortID.from_components(tx_height, tx_pos, txout_idx)
|
|
elif tx_hash:
|
|
short_id = TxOutpoint(bytes.fromhex(tx_hash), txout_idx).short_name()
|
|
else:
|
|
short_id = f"unknown:{txout_idx}"
|
|
addr = o.get_ui_address_str()
|
|
spender_txid = None # type: Optional[str]
|
|
if tx_hash:
|
|
spender_txid = self.wallet.db.get_spent_outpoint(tx_hash, txout_idx)
|
|
tcf_shortid = None
|
|
if spender_txid:
|
|
tcf_shortid = QTextCharFormat(lnk)
|
|
tcf_shortid.setAnchorHref(spender_txid)
|
|
insert_tx_io(
|
|
cursor=cursor, is_coinbase=False, txio_idx=txout_idx,
|
|
tcf_shortid=tcf_shortid,
|
|
short_id=str(short_id), addr=addr, value=o.value,
|
|
)
|
|
|
|
self.txo_color_recv.legend_label.setVisible(tf_used_recv)
|
|
self.txo_color_change.legend_label.setVisible(tf_used_change)
|
|
self.txo_color_2fa.legend_label.setVisible(tf_used_2fa)
|
|
self.txo_color_swap.legend_label.setVisible(tf_used_swap)
|
|
|
|
def _open_internal_link(self, target):
|
|
"""Accepts either a str txid, str address, or a QUrl which should be
|
|
of the bare form "txid" and/or "address" -- used by the clickable
|
|
links in the inputs/outputs QTextBrowsers"""
|
|
if isinstance(target, QUrl):
|
|
target = target.toString(QUrl.UrlFormattingOption.None_)
|
|
assert target
|
|
if bitcoin.is_address(target):
|
|
# target was an address, open address dialog
|
|
self.main_window.show_address(target, parent=self)
|
|
else:
|
|
# target was a txid, open new tx dialog
|
|
self.main_window.do_process_from_txid(txid=target, parent=self)
|
|
|
|
def on_context_menu_for_inputs(self, pos: QPoint):
|
|
i_text = self.inputs_textedit
|
|
global_pos = i_text.viewport().mapToGlobal(pos)
|
|
|
|
cursor = i_text.cursorForPosition(pos)
|
|
charFormat = cursor.charFormat()
|
|
name = charFormat.anchorNames() and charFormat.anchorNames()[0]
|
|
if not name:
|
|
menu = i_text.createStandardContextMenu()
|
|
menu.exec(global_pos)
|
|
return
|
|
|
|
menu = QMenu()
|
|
show_list = []
|
|
copy_list = []
|
|
# figure out which input they right-clicked on. input lines have an anchor named "txio_idx N"
|
|
txin_idx = int(name.split()[1]) # split "txio_idx N", translate N -> int
|
|
txin = self.tx.inputs()[txin_idx]
|
|
|
|
menu.addAction(_("Tx Input #{}").format(txin_idx)).setDisabled(True)
|
|
menu.addSeparator()
|
|
if txin.is_coinbase_input():
|
|
menu.addAction(_("Coinbase Input")).setDisabled(True)
|
|
else:
|
|
show_list += [(_("Show Prev Tx"), lambda: self._open_internal_link(txin.prevout.txid.hex()))]
|
|
copy_list += [(_("Copy Outpoint"), lambda: self.main_window.do_copy(txin.prevout.to_str()))]
|
|
addr = self.wallet.adb.get_txin_address(txin)
|
|
if addr:
|
|
if self.wallet.is_mine(addr):
|
|
show_list += [(_("Address Details"), lambda: self.main_window.show_address(addr, parent=self))]
|
|
copy_list += [(_("Copy Address"), lambda: self.main_window.do_copy(addr))]
|
|
txin_value = self.wallet.adb.get_txin_value(txin)
|
|
if txin_value:
|
|
value_str = self.main_window.format_amount(txin_value, add_thousands_sep=False)
|
|
copy_list += [(_("Copy Amount"), lambda: self.main_window.do_copy(value_str))]
|
|
|
|
for item in show_list:
|
|
menu.addAction(*item)
|
|
if show_list and copy_list:
|
|
menu.addSeparator()
|
|
for item in copy_list:
|
|
menu.addAction(*item)
|
|
|
|
menu.addSeparator()
|
|
std_menu = i_text.createStandardContextMenu()
|
|
menu.addActions(std_menu.actions())
|
|
menu.exec(global_pos)
|
|
|
|
def on_context_menu_for_outputs(self, pos: QPoint):
|
|
o_text = self.outputs_textedit
|
|
global_pos = o_text.viewport().mapToGlobal(pos)
|
|
|
|
cursor = o_text.cursorForPosition(pos)
|
|
charFormat = cursor.charFormat()
|
|
name = charFormat.anchorNames() and charFormat.anchorNames()[0]
|
|
if not name:
|
|
menu = o_text.createStandardContextMenu()
|
|
menu.exec(global_pos)
|
|
return
|
|
|
|
menu = QMenu()
|
|
show_list = []
|
|
copy_list = []
|
|
# figure out which output they right-clicked on. output lines have an anchor named "txio_idx N"
|
|
txout_idx = int(name.split()[1]) # split "txio_idx N", translate N -> int
|
|
menu.addAction(_("Tx Output #{}").format(txout_idx)).setDisabled(True)
|
|
menu.addSeparator()
|
|
if tx_hash := self.tx.txid():
|
|
outpoint = TxOutpoint(bytes.fromhex(tx_hash), txout_idx)
|
|
copy_list += [(_("Copy Outpoint"), lambda: self.main_window.do_copy(outpoint.to_str()))]
|
|
if addr := self.tx.outputs()[txout_idx].address:
|
|
if self.wallet.is_mine(addr):
|
|
show_list += [(_("Address Details"), lambda: self.main_window.show_address(addr, parent=self))]
|
|
copy_list += [(_("Copy Address"), lambda: self.main_window.do_copy(addr))]
|
|
else:
|
|
spk = self.tx.outputs()[txout_idx].scriptpubkey
|
|
copy_list += [(_("Copy scriptPubKey"), lambda: self.main_window.do_copy(spk.hex()))]
|
|
txout_value = self.tx.outputs()[txout_idx].value
|
|
value_str = self.main_window.format_amount(txout_value, add_thousands_sep=False)
|
|
copy_list += [(_("Copy Amount"), lambda: self.main_window.do_copy(value_str))]
|
|
|
|
for item in show_list:
|
|
menu.addAction(*item)
|
|
if show_list and copy_list:
|
|
menu.addSeparator()
|
|
for item in copy_list:
|
|
menu.addAction(*item)
|
|
|
|
run_hook('transaction_dialog_address_menu', menu, addr, self.wallet)
|
|
menu.addSeparator()
|
|
std_menu = o_text.createStandardContextMenu()
|
|
menu.addActions(std_menu.actions())
|
|
menu.exec(global_pos)
|
|
|
|
|
|
def show_transaction(
|
|
tx: Transaction,
|
|
*,
|
|
parent: 'ElectrumWindow',
|
|
prompt_if_unsaved: bool = False,
|
|
external_keypairs: Mapping[bytes, bytes] = None,
|
|
invoice: 'Invoice' = None,
|
|
on_closed: Callable[[], None] = None,
|
|
show_sign_button: bool = True,
|
|
show_broadcast_button: bool = True,
|
|
):
|
|
try:
|
|
d = TxDialog(
|
|
tx,
|
|
parent=parent,
|
|
prompt_if_unsaved=prompt_if_unsaved,
|
|
external_keypairs=external_keypairs,
|
|
invoice=invoice,
|
|
on_closed=on_closed,
|
|
)
|
|
if not show_sign_button:
|
|
d.sign_button.setVisible(False)
|
|
if not show_broadcast_button:
|
|
d.broadcast_button.setVisible(False)
|
|
except SerializationError as e:
|
|
_logger.exception('unable to deserialize the transaction')
|
|
parent.show_critical(_("Electrum was unable to deserialize the transaction:") + "\n" + str(e))
|
|
else:
|
|
d.show()
|
|
|
|
|
|
class TxDialog(QDialog, MessageBoxMixin):
|
|
|
|
throttled_update_sig = pyqtSignal() # emit from thread to do update in main thread
|
|
|
|
def __init__(
|
|
self,
|
|
tx: Transaction,
|
|
*,
|
|
parent: 'ElectrumWindow',
|
|
prompt_if_unsaved: bool,
|
|
external_keypairs: Mapping[bytes, bytes] = None,
|
|
invoice: 'Invoice' = None,
|
|
on_closed: Callable[[], None] = None,
|
|
):
|
|
'''Transactions in the wallet will show their description.
|
|
Pass desc to give a description for txs not yet in the wallet.
|
|
'''
|
|
# We want to be a top-level window
|
|
QDialog.__init__(self, parent=None)
|
|
self.tx = None # type: Optional[Transaction]
|
|
self.external_keypairs = external_keypairs
|
|
self.main_window = parent
|
|
self.config = parent.config
|
|
self.wallet = parent.wallet
|
|
self.invoice = invoice
|
|
self.prompt_if_unsaved = prompt_if_unsaved
|
|
self.on_closed = on_closed
|
|
self.saved = False
|
|
self.desc = None
|
|
if txid := tx.txid():
|
|
self.desc = self.wallet.get_label_for_txid(txid) or None
|
|
if not self.desc and self.invoice:
|
|
self.desc = self.invoice.get_message()
|
|
self.setMinimumWidth(640)
|
|
|
|
self.psbt_only_widgets = [] # type: List[Union[QWidget, QAction]]
|
|
|
|
vbox = QVBoxLayout()
|
|
self.setLayout(vbox)
|
|
toolbar, menu = create_toolbar_with_menu(self.config, '')
|
|
menu.addConfig(
|
|
self.config.cv.GUI_QT_TX_DIALOG_FETCH_TXIN_DATA,
|
|
callback=self.maybe_fetch_txin_data)
|
|
vbox.addLayout(toolbar)
|
|
|
|
vbox.addWidget(QLabel(_("Transaction ID:")))
|
|
self.tx_hash_e = ShowQRLineEdit('', self.config, title=_('Transaction ID'))
|
|
vbox.addWidget(self.tx_hash_e)
|
|
self.tx_desc_label = QLabel(_("Description:"))
|
|
vbox.addWidget(self.tx_desc_label)
|
|
self.tx_desc = ButtonsLineEdit('')
|
|
|
|
self.tx_desc.editingFinished.connect(self.store_tx_label)
|
|
self.tx_desc.addCopyButton()
|
|
vbox.addWidget(self.tx_desc)
|
|
|
|
self.add_tx_stats(vbox)
|
|
|
|
vbox.addSpacing(10)
|
|
|
|
self.io_widget = TxInOutWidget(self.main_window, self.wallet)
|
|
vbox.addWidget(self.io_widget)
|
|
|
|
self.sign_button = b = QPushButton(_("Sign"))
|
|
b.clicked.connect(self.sign)
|
|
|
|
self.broadcast_button = b = QPushButton(_("Broadcast"))
|
|
b.clicked.connect(self.do_broadcast)
|
|
|
|
self.save_button = b = QPushButton(_("Add to History"))
|
|
b.clicked.connect(self.save)
|
|
|
|
self.cancel_button = b = QPushButton(_("Close"))
|
|
b.clicked.connect(self.close)
|
|
b.setDefault(True)
|
|
|
|
self.export_actions_menu = export_actions_menu = QMenuWithConfig(config=self.config)
|
|
self.add_export_actions_to_menu(export_actions_menu)
|
|
export_actions_menu.addSeparator()
|
|
export_option = export_actions_menu.addConfig(
|
|
self.config.cv.GUI_QT_TX_DIALOG_EXPORT_STRIP_SENSITIVE_METADATA)
|
|
self.psbt_only_widgets.append(export_option)
|
|
export_option = export_actions_menu.addConfig(
|
|
self.config.cv.GUI_QT_TX_DIALOG_EXPORT_INCLUDE_GLOBAL_XPUBS)
|
|
self.psbt_only_widgets.append(export_option)
|
|
if self.wallet.has_support_for_slip_19_ownership_proofs():
|
|
export_option = export_actions_menu.addAction(
|
|
_('Include SLIP-19 ownership proofs'),
|
|
self._add_slip_19_ownership_proofs_to_tx)
|
|
export_option.setToolTip(_("Some cosigners (e.g. Trezor) might require this for coinjoins."))
|
|
self._export_option_slip19 = export_option
|
|
export_option.setCheckable(True)
|
|
export_option.setChecked(False)
|
|
self.psbt_only_widgets.append(export_option)
|
|
|
|
self.export_actions_button = QToolButton()
|
|
self.export_actions_button.setText(_("Share"))
|
|
self.export_actions_button.setMenu(export_actions_menu)
|
|
self.export_actions_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
|
|
|
|
partial_tx_actions_menu = QMenu()
|
|
ptx_merge_sigs_action = QAction(_("Merge signatures from"), self)
|
|
ptx_merge_sigs_action.triggered.connect(self.merge_sigs)
|
|
partial_tx_actions_menu.addAction(ptx_merge_sigs_action)
|
|
self._ptx_join_txs_action = QAction(_("Join inputs/outputs"), self)
|
|
self._ptx_join_txs_action.triggered.connect(self.join_tx_with_another)
|
|
partial_tx_actions_menu.addAction(self._ptx_join_txs_action)
|
|
self.partial_tx_actions_button = QToolButton()
|
|
self.partial_tx_actions_button.setText(_("Combine"))
|
|
self.partial_tx_actions_button.setMenu(partial_tx_actions_menu)
|
|
self.partial_tx_actions_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
|
|
self.psbt_only_widgets.append(self.partial_tx_actions_button)
|
|
|
|
# Action buttons
|
|
self.buttons = [self.partial_tx_actions_button, self.sign_button, self.broadcast_button, self.cancel_button]
|
|
# Transaction sharing buttons
|
|
self.sharing_buttons = [self.export_actions_button, self.save_button]
|
|
run_hook('transaction_dialog', self)
|
|
self.hbox = hbox = QHBoxLayout()
|
|
hbox.addLayout(Buttons(*self.sharing_buttons))
|
|
hbox.addStretch(1)
|
|
hbox.addLayout(Buttons(*self.buttons))
|
|
vbox.addLayout(hbox)
|
|
dialogs.append(self)
|
|
|
|
self._fetch_txin_data_fut = None # type: Optional[concurrent.futures.Future]
|
|
self._fetch_txin_data_progress = None # type: Optional[TxinDataFetchProgress]
|
|
self.throttled_update_sig.connect(self._throttled_update, Qt.ConnectionType.QueuedConnection)
|
|
|
|
self.set_tx(tx)
|
|
self.update()
|
|
self.set_title()
|
|
|
|
def store_tx_label(self):
|
|
text = self.tx_desc.text()
|
|
if self.wallet.set_label(self.tx.txid(), text):
|
|
self.main_window.history_list.update()
|
|
self.main_window.utxo_list.update()
|
|
self.main_window.labels_changed_signal.emit()
|
|
|
|
def set_tx(self, tx: 'Transaction'):
|
|
# Take a copy; it might get updated in the main window by
|
|
# e.g. the FX plugin. If this happens during or after a long
|
|
# sign operation the signatures are lost.
|
|
self.tx = tx = copy.deepcopy(tx)
|
|
try:
|
|
self.tx.deserialize()
|
|
except BaseException as e:
|
|
raise SerializationError(e)
|
|
# 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.
|
|
tx.add_info_from_wallet(self.wallet)
|
|
# FIXME for PSBTs, we do a blocking fetch, as the missing data might be needed for e.g. signing
|
|
# - otherwise, the missing data is for display-completeness only, e.g. fee, input addresses (we do it async)
|
|
if not tx.is_complete() and tx.is_missing_info_from_network():
|
|
self.main_window.run_coroutine_dialog(
|
|
tx.add_info_from_network(self.wallet.network, timeout=10),
|
|
_("Adding info to tx, from network..."),
|
|
)
|
|
else:
|
|
self.maybe_fetch_txin_data()
|
|
|
|
def do_broadcast(self):
|
|
self.main_window.push_top_level_window(self)
|
|
self.main_window.send_tab.save_pending_invoice()
|
|
try:
|
|
self.main_window.broadcast_transaction(self.tx, invoice=self.invoice)
|
|
finally:
|
|
self.main_window.pop_top_level_window(self)
|
|
self.saved = True
|
|
self.update()
|
|
|
|
def closeEvent(self, event):
|
|
if (self.prompt_if_unsaved and not self.saved
|
|
and not self.question(_('This transaction is not saved. Close anyway?'), title=_("Warning"))):
|
|
event.ignore()
|
|
else:
|
|
event.accept()
|
|
try:
|
|
dialogs.remove(self)
|
|
except ValueError:
|
|
pass # was not in list already
|
|
if self._fetch_txin_data_fut:
|
|
self._fetch_txin_data_fut.cancel()
|
|
self._fetch_txin_data_fut = None
|
|
|
|
if self.on_closed:
|
|
self.on_closed()
|
|
|
|
def reject(self):
|
|
# Override escape-key to close normally (and invoke closeEvent)
|
|
self.close()
|
|
|
|
def add_export_actions_to_menu(self, menu: QMenu) -> None:
|
|
def gettx() -> Transaction:
|
|
if not isinstance(self.tx, PartialTransaction):
|
|
return self.tx
|
|
tx = copy.deepcopy(self.tx)
|
|
if self.config.GUI_QT_TX_DIALOG_EXPORT_INCLUDE_GLOBAL_XPUBS:
|
|
Network.run_from_another_thread(
|
|
tx.prepare_for_export_for_hardware_device(self.wallet))
|
|
if self.config.GUI_QT_TX_DIALOG_EXPORT_STRIP_SENSITIVE_METADATA:
|
|
tx.prepare_for_export_for_coinjoin()
|
|
return tx
|
|
|
|
action = QAction(_("Copy to clipboard"), self)
|
|
action.triggered.connect(lambda: self.copy_to_clipboard(tx=gettx()))
|
|
menu.addAction(action)
|
|
|
|
action = QAction(get_icon_qrcode(), _("Show as QR code"), self)
|
|
action.triggered.connect(lambda: self.show_qr(tx=gettx()))
|
|
menu.addAction(action)
|
|
|
|
action = QAction(_("Save to file"), self)
|
|
action.triggered.connect(lambda: self.export_to_file(tx=gettx()))
|
|
menu.addAction(action)
|
|
|
|
def _add_slip_19_ownership_proofs_to_tx(self):
|
|
assert isinstance(self.tx, PartialTransaction)
|
|
|
|
def on_success(result):
|
|
self._export_option_slip19.setEnabled(False)
|
|
self.main_window.pop_top_level_window(self)
|
|
|
|
def on_failure(exc_info):
|
|
self._export_option_slip19.setChecked(False)
|
|
self.main_window.on_error(exc_info)
|
|
self.main_window.pop_top_level_window(self)
|
|
task = partial(self.wallet.add_slip_19_ownership_proofs_to_tx, self.tx)
|
|
msg = _('Adding SLIP-19 ownership proofs to transaction...')
|
|
self.main_window.push_top_level_window(self)
|
|
WaitingDialog(self, msg, task, on_success, on_failure)
|
|
|
|
def copy_to_clipboard(self, *, tx: Transaction = None):
|
|
if tx is None:
|
|
tx = self.tx
|
|
self.main_window.do_copy(str(tx), title=_("Transaction"))
|
|
|
|
def show_qr(self, *, tx: Transaction = None):
|
|
if tx is None:
|
|
tx = self.tx
|
|
qr_data, is_complete = tx.to_qr_data()
|
|
help_text = None
|
|
if not is_complete:
|
|
help_text = _(
|
|
"""Warning: Some data (prev txs / "full utxos") was left """
|
|
"""out of the QR code as it would not fit. This might cause issues if signing offline. """
|
|
"""As a workaround, try exporting the tx as file or text instead.""")
|
|
try:
|
|
self.main_window.show_qrcode(qr_data, _("Transaction"), parent=self, help_text=help_text)
|
|
except qrcode.exceptions.DataOverflowError:
|
|
self.show_error(_('Failed to display QR code.') + '\n' +
|
|
_('Transaction is too large in size.'))
|
|
except Exception as e:
|
|
self.show_error(_('Failed to display QR code.') + '\n' + repr(e))
|
|
|
|
def sign(self):
|
|
def sign_done(success):
|
|
if self.tx.is_complete():
|
|
self.prompt_if_unsaved = True
|
|
self.saved = False
|
|
self.update()
|
|
self.main_window.pop_top_level_window(self)
|
|
|
|
if self.io_widget.sighash_danger.needs_confirm():
|
|
if not self.question(
|
|
msg='\n'.join([
|
|
self.io_widget.sighash_danger.get_long_message(),
|
|
'',
|
|
_('Are you sure you want to sign this transaction?')
|
|
]),
|
|
title=self.io_widget.sighash_danger.short_message,
|
|
):
|
|
return
|
|
self.sign_button.setDisabled(True)
|
|
self.main_window.push_top_level_window(self)
|
|
self.main_window.sign_tx(self.tx, callback=sign_done, external_keypairs=self.external_keypairs)
|
|
|
|
def save(self):
|
|
self.main_window.push_top_level_window(self)
|
|
if self.main_window.save_transaction_into_wallet(self.tx):
|
|
self.store_tx_label()
|
|
self.save_button.setDisabled(True)
|
|
self.saved = True
|
|
self.main_window.pop_top_level_window(self)
|
|
|
|
def export_to_file(self, *, tx: Transaction = None):
|
|
if tx is None:
|
|
tx = self.tx
|
|
if isinstance(tx, PartialTransaction):
|
|
tx.finalize_psbt()
|
|
txid = tx.txid()
|
|
suffix = txid[0:8] if txid is not None else time.strftime('%Y%m%d-%H%M')
|
|
if tx.is_complete():
|
|
extension = 'txn'
|
|
default_filter = TRANSACTION_FILE_EXTENSION_FILTER_ONLY_COMPLETE_TX
|
|
else:
|
|
extension = 'psbt'
|
|
default_filter = TRANSACTION_FILE_EXTENSION_FILTER_ONLY_PARTIAL_TX
|
|
name = f'{self.wallet.basename()}-{suffix}.{extension}'
|
|
fileName = getSaveFileName(
|
|
parent=self,
|
|
title=_("Select where to save your transaction"),
|
|
filename=name,
|
|
filter=TRANSACTION_FILE_EXTENSION_FILTER_SEPARATE,
|
|
default_extension=extension,
|
|
default_filter=default_filter,
|
|
config=self.config,
|
|
)
|
|
if not fileName:
|
|
return
|
|
if tx.is_complete(): # network tx hex
|
|
with open(fileName, "w+") as f:
|
|
network_tx_hex = tx.serialize_to_network()
|
|
f.write(network_tx_hex + '\n')
|
|
else: # if partial: PSBT bytes
|
|
assert isinstance(tx, PartialTransaction)
|
|
with open(fileName, "wb+") as f:
|
|
f.write(tx.serialize_as_bytes())
|
|
|
|
self.show_message(_("Transaction exported successfully"))
|
|
self.saved = True
|
|
|
|
def merge_sigs(self):
|
|
if not isinstance(self.tx, PartialTransaction):
|
|
return
|
|
text = text_dialog(
|
|
parent=self,
|
|
title=_('Input raw transaction'),
|
|
header_layout=_("Transaction to merge signatures from") + ":",
|
|
ok_label=_("Load transaction"),
|
|
config=self.config,
|
|
)
|
|
if not text:
|
|
return
|
|
tx = self.main_window.tx_from_text(text)
|
|
if not tx:
|
|
return
|
|
try:
|
|
self.tx.combine_with_other_psbt(tx)
|
|
except Exception as e:
|
|
self.show_error(_("Error combining partial transactions") + ":\n" + repr(e))
|
|
return
|
|
self.update()
|
|
|
|
def join_tx_with_another(self):
|
|
if not isinstance(self.tx, PartialTransaction):
|
|
return
|
|
text = text_dialog(
|
|
parent=self,
|
|
title=_('Input raw transaction'),
|
|
header_layout=_("Transaction to join with") + " (" + _("add inputs and outputs") + "):",
|
|
ok_label=_("Load transaction"),
|
|
config=self.config,
|
|
)
|
|
if not text:
|
|
return
|
|
tx = self.main_window.tx_from_text(text)
|
|
if not tx:
|
|
return
|
|
try:
|
|
self.tx.join_with_other_psbt(tx, config=self.config)
|
|
except Exception as e:
|
|
self.show_error(_("Error joining partial transactions") + ":\n" + repr(e))
|
|
return
|
|
self.update()
|
|
|
|
@rate_limited(0.5, ts_after=True)
|
|
def _throttled_update(self):
|
|
self.update()
|
|
|
|
def update(self):
|
|
if self.tx is None:
|
|
return
|
|
self.io_widget.update(self.tx)
|
|
desc = self.desc
|
|
base_unit = self.main_window.base_unit()
|
|
format_amount = self.main_window.format_amount
|
|
format_fiat_and_units = self.main_window.format_fiat_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
|
|
size = self.tx.estimated_size()
|
|
txid = self.tx.txid()
|
|
fx = self.main_window.fx
|
|
tx_item_fiat = None
|
|
if txid is not None and fx.is_enabled() and amount is not None:
|
|
tx_item_fiat = self.wallet.get_tx_item_fiat(
|
|
tx_hash=txid, amount_sat=abs(amount), fx=fx, tx_fee=fee)
|
|
|
|
if self.wallet.lnworker and txid:
|
|
# if it is a group, collect ln amount
|
|
full_history = self.wallet.get_full_history()
|
|
item = full_history.get('group:' + txid)
|
|
ln_amount = item['ln_value'].value if item else None
|
|
else:
|
|
ln_amount = None
|
|
|
|
self.broadcast_button.setEnabled(tx_details.can_broadcast)
|
|
can_sign = not self.tx.is_complete() and \
|
|
(self.wallet.can_sign(self.tx) or bool(self.external_keypairs))
|
|
self.sign_button.setEnabled(can_sign and not self.io_widget.sighash_danger.needs_reject())
|
|
if sh_danger_msg := self.io_widget.sighash_danger.get_long_message():
|
|
self.sign_button.setToolTip(sh_danger_msg)
|
|
if tx_details.txid:
|
|
self.tx_hash_e.setText(tx_details.txid)
|
|
else:
|
|
# note: when not finalized, RBF and locktime changes do not trigger
|
|
# a make_tx, so the txid is unreliable, hence:
|
|
self.tx_hash_e.setText(_('Unknown'))
|
|
tx_in_db = bool(self.wallet.adb.get_transaction(txid))
|
|
if not desc and not tx_in_db:
|
|
self.tx_desc.hide()
|
|
self.tx_desc_label.hide()
|
|
else:
|
|
self.tx_desc.setText(desc)
|
|
self.tx_desc.show()
|
|
self.tx_desc_label.show()
|
|
self.status_label.setText(_('Status: {}').format(tx_details.status))
|
|
|
|
if tx_mined_status.timestamp:
|
|
time_str = datetime.datetime.fromtimestamp(tx_mined_status.timestamp).isoformat(' ')[:-3]
|
|
self.date_label.setText(_("Date: {}").format(time_str))
|
|
self.date_label.show()
|
|
elif exp_n is not None:
|
|
from electrum.fee_policy import FeePolicy
|
|
self.date_label.setText(_('Position in mempool: {}').format(FeePolicy.depth_tooltip(exp_n)))
|
|
self.date_label.show()
|
|
else:
|
|
self.date_label.hide()
|
|
if self.tx.locktime <= NLOCKTIME_BLOCKHEIGHT_MAX:
|
|
locktime_str = _('height')
|
|
else:
|
|
locktime_str = datetime.datetime.fromtimestamp(self.tx.locktime)
|
|
locktime_final_str = _("LockTime: {} ({})").format(self.tx.locktime, locktime_str)
|
|
self.locktime_final_label.setText(locktime_final_str)
|
|
|
|
nsequence_time = self.tx.get_time_based_relative_locktime()
|
|
nsequence_blocks = self.tx.get_block_based_relative_locktime()
|
|
if nsequence_time or nsequence_blocks:
|
|
if nsequence_time:
|
|
seconds = nsequence_time * 512
|
|
time_str = delta_time_str(datetime.timedelta(seconds=seconds))
|
|
else:
|
|
time_str = '{} blocks'.format(nsequence_blocks)
|
|
nsequence_str = _("Relative locktime: {}").format(time_str)
|
|
self.nsequence_label.setText(nsequence_str)
|
|
else:
|
|
self.nsequence_label.hide()
|
|
|
|
# TODO: 'Yes'/'No' might be better translatable than 'True'/'False'?
|
|
self.rbf_label.setText(_('Replace by fee: {}').format(_('True') if self.tx.is_rbf_enabled() else _('False')))
|
|
|
|
if tx_mined_status.header_hash:
|
|
self.block_height_label.setText(_("At block height: {}").format(tx_mined_status.height))
|
|
else:
|
|
self.block_height_label.hide()
|
|
if amount is None and ln_amount is None:
|
|
amount_str = _("Transaction unrelated to your wallet")
|
|
elif amount is None:
|
|
amount_str = ''
|
|
else:
|
|
amount_str = ''
|
|
if fx.is_enabled():
|
|
if tx_item_fiat: # historical tx -> using historical price
|
|
amount_str += ' ({})'.format(tx_item_fiat['fiat_value'].to_ui_string())
|
|
elif tx_details.is_related_to_wallet: # probably "tx preview" -> using current price
|
|
amount_str += ' ({})'.format(format_fiat_and_units(abs(amount)))
|
|
amount_str = format_amount(abs(amount)) + ' ' + base_unit + amount_str
|
|
if amount > 0:
|
|
amount_str = _("Amount received: {}").format(amount_str)
|
|
else:
|
|
amount_str = _("Amount sent: {}").format(amount_str)
|
|
if amount_str:
|
|
self.amount_label.setText(amount_str)
|
|
else:
|
|
self.amount_label.hide()
|
|
size_str = _("Size: {} {}").format(size, UI_UNIT_NAME_TXSIZE_VBYTES)
|
|
if fee is None:
|
|
if prog := self._fetch_txin_data_progress:
|
|
if not prog.has_errored:
|
|
fee_str = _("Downloading input data... {}").format(f"({prog.num_tasks_done}/{prog.num_tasks_total})")
|
|
else:
|
|
fee_str = _("Downloading input data... {}").format(_("error"))
|
|
else:
|
|
fee_str = _("Fee: {}").format(_("unknown"))
|
|
else:
|
|
fee_str = _("Fee: {}").format(f'{format_amount(fee)} {base_unit}')
|
|
if fx.is_enabled():
|
|
if tx_item_fiat: # historical tx -> using historical price
|
|
fee_str += ' ({})'.format(tx_item_fiat['fiat_fee'].to_ui_string())
|
|
elif tx_details.is_related_to_wallet: # probably "tx preview" -> using current price
|
|
fee_str += ' ({})'.format(format_fiat_and_units(fee))
|
|
|
|
fee_rate = Decimal(fee) / size # sat/byte
|
|
fee_str += ' ( %s ) ' % self.main_window.format_fee_rate(fee_rate * 1000)
|
|
if isinstance(self.tx, PartialTransaction):
|
|
# 'amount' is zero for self-payments, so in that case we use sum-of-outputs
|
|
invoice_amt = abs(amount) if amount else self.tx.output_value()
|
|
fee_warning_tuple = self.wallet.get_tx_fee_warning(
|
|
invoice_amt=invoice_amt, tx_size=size, fee=fee, txid=self.tx.txid())
|
|
if fee_warning_tuple:
|
|
allow_send, long_warning, short_warning = fee_warning_tuple
|
|
fee_str += " - <font color={color}>{header}: {body}</font>".format(
|
|
header=_('Warning'),
|
|
body=short_warning,
|
|
color=ColorScheme.RED.as_color().name(),
|
|
)
|
|
if isinstance(self.tx, PartialTransaction):
|
|
sh_warning = self.io_widget.sighash_danger.get_long_message()
|
|
self.fee_warning_icon.setToolTip(str(sh_warning))
|
|
self.fee_warning_icon.setVisible(can_sign and bool(sh_warning))
|
|
self.fee_label.setText(fee_str)
|
|
self.size_label.setText(size_str)
|
|
if ln_amount is None or ln_amount == 0:
|
|
ln_amount_str = ''
|
|
elif ln_amount > 0:
|
|
ln_amount_str = _('Amount received in channels: {}').format(format_amount(ln_amount) + ' ' + base_unit)
|
|
else:
|
|
assert ln_amount < 0, f"{ln_amount!r}"
|
|
ln_amount_str = _('Amount withdrawn from channels: {}').format(format_amount(-ln_amount) + ' ' + base_unit)
|
|
if ln_amount_str:
|
|
self.ln_amount_label.setText(ln_amount_str)
|
|
else:
|
|
self.ln_amount_label.hide()
|
|
show_psbt_only_widgets = isinstance(self.tx, PartialTransaction)
|
|
for widget in self.psbt_only_widgets:
|
|
if isinstance(widget, QMenu):
|
|
widget.menuAction().setVisible(show_psbt_only_widgets)
|
|
else:
|
|
widget.setVisible(show_psbt_only_widgets)
|
|
if tx_details.is_lightning_funding_tx:
|
|
self._ptx_join_txs_action.setEnabled(False) # would change txid
|
|
|
|
self.save_button.setEnabled(tx_details.can_save_as_local)
|
|
if tx_details.can_save_as_local:
|
|
self.save_button.setToolTip(_("Add transaction to history, without broadcasting it"))
|
|
else:
|
|
self.save_button.setToolTip(_("Transaction already in history or not yet signed."))
|
|
|
|
run_hook('transaction_dialog_update', self)
|
|
|
|
def add_tx_stats(self, vbox):
|
|
hbox_stats = QHBoxLayout()
|
|
hbox_stats.setContentsMargins(0, 0, 0, 0)
|
|
hbox_stats_w = QWidget()
|
|
hbox_stats_w.setLayout(hbox_stats)
|
|
hbox_stats_w.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
|
|
|
|
# left column
|
|
vbox_left = QVBoxLayout()
|
|
self.status_label = TxDetailLabel()
|
|
vbox_left.addWidget(self.status_label)
|
|
self.date_label = TxDetailLabel()
|
|
vbox_left.addWidget(self.date_label)
|
|
self.amount_label = TxDetailLabel()
|
|
vbox_left.addWidget(self.amount_label)
|
|
self.ln_amount_label = TxDetailLabel()
|
|
vbox_left.addWidget(self.ln_amount_label)
|
|
|
|
fee_hbox = QHBoxLayout()
|
|
self.fee_label = TxDetailLabel()
|
|
fee_hbox.addWidget(self.fee_label)
|
|
self.fee_warning_icon = QLabel()
|
|
pixmap = QPixmap(icon_path("warning"))
|
|
pixmap_size = round(2 * char_width_in_lineedit())
|
|
pixmap = pixmap.scaled(pixmap_size, pixmap_size, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
|
|
self.fee_warning_icon.setPixmap(pixmap)
|
|
self.fee_warning_icon.setVisible(False)
|
|
fee_hbox.addWidget(self.fee_warning_icon)
|
|
fee_hbox.addStretch(1)
|
|
vbox_left.addLayout(fee_hbox)
|
|
|
|
vbox_left.addStretch(1)
|
|
hbox_stats.addLayout(vbox_left, 50)
|
|
|
|
# vertical line separator
|
|
hbox_stats.addWidget(VLine())
|
|
|
|
# right column
|
|
vbox_right = QVBoxLayout()
|
|
self.size_label = TxDetailLabel()
|
|
vbox_right.addWidget(self.size_label)
|
|
self.rbf_label = TxDetailLabel()
|
|
vbox_right.addWidget(self.rbf_label)
|
|
|
|
self.locktime_final_label = TxDetailLabel()
|
|
vbox_right.addWidget(self.locktime_final_label)
|
|
|
|
self.nsequence_label = TxDetailLabel()
|
|
vbox_right.addWidget(self.nsequence_label)
|
|
|
|
self.block_height_label = TxDetailLabel()
|
|
vbox_right.addWidget(self.block_height_label)
|
|
vbox_right.addStretch(1)
|
|
hbox_stats.addLayout(vbox_right, 50)
|
|
|
|
vbox.addWidget(hbox_stats_w)
|
|
|
|
# set visibility after parenting can be determined by Qt
|
|
self.rbf_label.setVisible(True)
|
|
self.locktime_final_label.setVisible(True)
|
|
|
|
def set_title(self):
|
|
txid = self.tx.txid() or "<no txid yet>"
|
|
self.setWindowTitle(_("Transaction") + ' ' + txid)
|
|
|
|
def maybe_fetch_txin_data(self):
|
|
"""Download missing input data from the network, asynchronously.
|
|
Note: we fetch the prev txs, which allows calculating the fee and showing "input addresses".
|
|
We could also SPV-verify the tx, to fill in missing tx_mined_status (block height, blockhash, timestamp),
|
|
but this is not done currently.
|
|
"""
|
|
if not self.config.GUI_QT_TX_DIALOG_FETCH_TXIN_DATA:
|
|
return
|
|
tx = self.tx
|
|
if not tx:
|
|
return
|
|
if self._fetch_txin_data_fut is not None:
|
|
return
|
|
network = self.wallet.network
|
|
|
|
def progress_cb(prog: TxinDataFetchProgress):
|
|
self._fetch_txin_data_progress = prog
|
|
self.throttled_update_sig.emit()
|
|
|
|
async def wrapper():
|
|
try:
|
|
await tx.add_info_from_network(network, progress_cb=progress_cb)
|
|
finally:
|
|
self._fetch_txin_data_fut = None
|
|
|
|
self._fetch_txin_data_progress = None
|
|
self._fetch_txin_data_fut = asyncio.run_coroutine_threadsafe(wrapper(), get_asyncio_loop())
|
|
|
|
|
|
class TxDetailLabel(QLabel):
|
|
def __init__(self, *, word_wrap=None):
|
|
super().__init__()
|
|
self.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
|
if word_wrap is not None:
|
|
self.setWordWrap(word_wrap)
|
|
|
|
|
|
class TxOutputColoring:
|
|
# used for both inputs and outputs
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
legend: str,
|
|
color: ColorSchemeItem,
|
|
tooltip: str,
|
|
):
|
|
self.color = color.as_color(background=True)
|
|
self.legend_label = QLabel("<font color={color}>{box_char}</font> = {label}".format(
|
|
color=self.color.name(),
|
|
box_char="█",
|
|
label=legend,
|
|
))
|
|
font = self.legend_label.font()
|
|
font.setPointSize(font.pointSize() - 1)
|
|
self.legend_label.setFont(font)
|
|
self.legend_label.setVisible(False)
|
|
self.text_char_format = QTextCharFormat()
|
|
self.text_char_format.setBackground(QBrush(self.color))
|
|
self.text_char_format.setToolTip(tooltip)
|
|
|