522 lines
17 KiB
Python
522 lines
17 KiB
Python
from typing import Optional
|
|
|
|
from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject
|
|
|
|
from electrum.i18n import _
|
|
from electrum.logging import get_logger
|
|
from electrum.bitcoin import DummyAddress
|
|
from electrum.util import format_time, TxMinedInfo
|
|
from electrum.transaction import tx_from_any, Transaction, PartialTransaction
|
|
from electrum.network import Network
|
|
from electrum.address_synchronizer import TX_HEIGHT_UNCONF_PARENT, TX_HEIGHT_UNCONFIRMED, TX_HEIGHT_FUTURE
|
|
from electrum.wallet import TxSighashDanger
|
|
from electrum.fee_policy import FeePolicy
|
|
|
|
from .qewallet import QEWallet
|
|
from .qetypes import QEAmount
|
|
from .util import QtEventListener, qt_event_listener
|
|
|
|
|
|
class QETxDetails(QObject, QtEventListener):
|
|
_logger = get_logger(__name__)
|
|
|
|
confirmRemoveLocalTx = pyqtSignal([str], arguments=['message'])
|
|
txRemoved = pyqtSignal()
|
|
saveTxError = pyqtSignal([str, str], arguments=['code', 'message'])
|
|
saveTxSuccess = pyqtSignal()
|
|
|
|
detailsChanged = pyqtSignal()
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self.register_callbacks()
|
|
self.destroyed.connect(lambda: self.on_destroy())
|
|
|
|
self._wallet = None # type: Optional[QEWallet]
|
|
self._txid = ''
|
|
self._rawtx = ''
|
|
self._label = ''
|
|
|
|
self._tx = None # type: Optional[Transaction]
|
|
|
|
self._status = ''
|
|
self._amount = QEAmount()
|
|
self._lnamount = QEAmount()
|
|
self._fee = QEAmount()
|
|
self._feerate_str = ''
|
|
self._inputs = []
|
|
self._outputs = []
|
|
|
|
self._is_lightning_funding_tx = False
|
|
self._can_bump = False
|
|
self._can_dscancel = False
|
|
self._can_broadcast = False
|
|
self._can_cpfp = False
|
|
self._can_save_as_local = False
|
|
self._can_remove = False
|
|
self._can_sign = False
|
|
self._is_unrelated = False
|
|
self._is_complete = False
|
|
self._is_mined = False
|
|
self._is_rbf_enabled = False
|
|
self._is_removed = False
|
|
self._lock_delay = 0
|
|
self._sighash_danger = TxSighashDanger()
|
|
|
|
self._mempool_depth = ''
|
|
self._in_mempool = False
|
|
|
|
self._date = ''
|
|
self._timestamp = 0
|
|
self._confirmations = 0
|
|
self._header_hash = ''
|
|
self._short_id = ""
|
|
|
|
def on_destroy(self):
|
|
self.unregister_callbacks()
|
|
|
|
@qt_event_listener
|
|
def on_event_verified(self, wallet, txid, info):
|
|
if wallet == self._wallet.wallet and txid == self._txid:
|
|
self._logger.debug(f'verified event for our txid {txid}')
|
|
self.update()
|
|
|
|
@qt_event_listener
|
|
def on_event_new_transaction(self, wallet, tx):
|
|
if wallet == self._wallet.wallet and tx.txid() == self._txid:
|
|
self._logger.debug(f'new_transaction event for our txid {self._txid}')
|
|
self.update()
|
|
|
|
@qt_event_listener
|
|
def on_event_removed_transaction(self, wallet, tx):
|
|
if wallet == self._wallet.wallet and tx.txid() == self._txid:
|
|
self._logger.debug(f'removed my transaction {tx.txid()}')
|
|
self._is_removed = True
|
|
self.update()
|
|
self.txRemoved.emit()
|
|
|
|
@qt_event_listener
|
|
def on_event_fee_histogram(self, histogram):
|
|
if not self._wallet or not self._tx:
|
|
return
|
|
self.update()
|
|
|
|
walletChanged = pyqtSignal()
|
|
@pyqtProperty(QEWallet, notify=walletChanged)
|
|
def wallet(self):
|
|
return self._wallet
|
|
|
|
@wallet.setter
|
|
def wallet(self, wallet: QEWallet):
|
|
if self._wallet != wallet:
|
|
self._wallet = wallet
|
|
self.walletChanged.emit()
|
|
|
|
txidChanged = pyqtSignal()
|
|
@pyqtProperty(str, notify=txidChanged)
|
|
def txid(self):
|
|
return self._txid
|
|
|
|
@txid.setter
|
|
def txid(self, txid: str):
|
|
if self._txid != txid:
|
|
self._logger.debug(f'txid set -> {txid}')
|
|
self._txid = txid
|
|
self.txidChanged.emit()
|
|
self.update(from_txid=True)
|
|
|
|
@pyqtProperty(str, notify=detailsChanged)
|
|
def rawtx(self):
|
|
return self._rawtx
|
|
|
|
@rawtx.setter
|
|
def rawtx(self, rawtx: str):
|
|
if self._rawtx != rawtx:
|
|
self._logger.debug(f'rawtx set -> {rawtx}')
|
|
self._rawtx = rawtx
|
|
if not rawtx:
|
|
return
|
|
try:
|
|
self._tx = tx_from_any(rawtx, deserialize=True)
|
|
self._txid = self._tx.txid()
|
|
self.txidChanged.emit()
|
|
self.update()
|
|
except Exception as e:
|
|
self._tx = None
|
|
self._logger.error(repr(e))
|
|
|
|
labelChanged = pyqtSignal()
|
|
@pyqtProperty(str, notify=labelChanged)
|
|
def label(self):
|
|
return self._label
|
|
|
|
@pyqtSlot(str)
|
|
def setLabel(self, label: str):
|
|
if label != self._label:
|
|
self._wallet.wallet.set_label(self._txid, label)
|
|
self._label = label
|
|
self.labelChanged.emit()
|
|
|
|
@pyqtProperty(str, notify=detailsChanged)
|
|
def status(self):
|
|
return self._status
|
|
|
|
@pyqtProperty(str, notify=detailsChanged)
|
|
def warning(self):
|
|
return self._sighash_danger.get_long_message()
|
|
|
|
@pyqtProperty(QEAmount, notify=detailsChanged)
|
|
def amount(self):
|
|
return self._amount
|
|
|
|
@pyqtProperty(QEAmount, notify=detailsChanged)
|
|
def lnAmount(self):
|
|
return self._lnamount
|
|
|
|
@pyqtProperty(QEAmount, notify=detailsChanged)
|
|
def fee(self):
|
|
return self._fee
|
|
|
|
@pyqtProperty(str, notify=detailsChanged)
|
|
def feeRateStr(self):
|
|
return self._feerate_str
|
|
|
|
@pyqtProperty('QVariantList', notify=detailsChanged)
|
|
def inputs(self):
|
|
return self._inputs
|
|
|
|
@pyqtProperty('QVariantList', notify=detailsChanged)
|
|
def outputs(self):
|
|
return self._outputs
|
|
|
|
@pyqtProperty(bool, notify=detailsChanged)
|
|
def isMined(self):
|
|
return self._is_mined
|
|
|
|
@pyqtProperty(bool, notify=detailsChanged)
|
|
def isRemoved(self):
|
|
return self._is_removed
|
|
|
|
@pyqtProperty(str, notify=detailsChanged)
|
|
def mempoolDepth(self):
|
|
return self._mempool_depth
|
|
|
|
@pyqtProperty(bool, notify=detailsChanged)
|
|
def inMempool(self):
|
|
return self._in_mempool
|
|
|
|
@pyqtProperty(str, notify=detailsChanged)
|
|
def date(self):
|
|
return self._date
|
|
|
|
@pyqtProperty(int, notify=detailsChanged)
|
|
def timestamp(self):
|
|
return self._timestamp
|
|
|
|
@pyqtProperty(int, notify=detailsChanged)
|
|
def confirmations(self):
|
|
return self._confirmations
|
|
|
|
@pyqtProperty(str, notify=detailsChanged)
|
|
def shortId(self):
|
|
return self._short_id
|
|
|
|
@pyqtProperty(str, notify=detailsChanged)
|
|
def headerHash(self):
|
|
return self._header_hash
|
|
|
|
@pyqtProperty(bool, notify=detailsChanged)
|
|
def isLightningFundingTx(self):
|
|
return self._is_lightning_funding_tx
|
|
|
|
@pyqtProperty(bool, notify=detailsChanged)
|
|
def canBump(self):
|
|
return self._can_bump
|
|
|
|
@pyqtProperty(bool, notify=detailsChanged)
|
|
def canCancel(self):
|
|
return self._can_dscancel
|
|
|
|
@pyqtProperty(bool, notify=detailsChanged)
|
|
def canBroadcast(self):
|
|
return self._can_broadcast
|
|
|
|
@pyqtProperty(bool, notify=detailsChanged)
|
|
def canCpfp(self):
|
|
return self._can_cpfp
|
|
|
|
@pyqtProperty(bool, notify=detailsChanged)
|
|
def canSaveAsLocal(self):
|
|
return self._can_save_as_local
|
|
|
|
@pyqtProperty(bool, notify=detailsChanged)
|
|
def canRemove(self):
|
|
return self._can_remove
|
|
|
|
@pyqtProperty(bool, notify=detailsChanged)
|
|
def canSign(self):
|
|
return self._can_sign
|
|
|
|
@pyqtProperty(bool, notify=detailsChanged)
|
|
def isUnrelated(self):
|
|
return self._is_unrelated
|
|
|
|
@pyqtProperty(bool, notify=detailsChanged)
|
|
def isComplete(self):
|
|
return self._is_complete
|
|
|
|
@pyqtProperty(bool, notify=detailsChanged)
|
|
def isRbfEnabled(self):
|
|
return self._is_rbf_enabled
|
|
|
|
@pyqtProperty(int, notify=detailsChanged)
|
|
def lockDelay(self):
|
|
return self._lock_delay
|
|
|
|
@pyqtProperty(bool, notify=detailsChanged)
|
|
def shouldConfirm(self):
|
|
return self._sighash_danger.needs_confirm()
|
|
|
|
def update(self, from_txid: bool = False):
|
|
assert self._wallet
|
|
|
|
if self._is_removed:
|
|
self._logger.debug('tx removed, disable gui options')
|
|
self._can_broadcast = False
|
|
self._can_bump = False
|
|
self._can_dscancel = False
|
|
self._can_cpfp = False
|
|
self._can_save_as_local = False
|
|
self._can_remove = False
|
|
self._can_sign = False
|
|
self._mempool_depth = ''
|
|
self._status = _('removed')
|
|
self.detailsChanged.emit()
|
|
return
|
|
|
|
if from_txid:
|
|
self._tx = self._wallet.wallet.db.get_transaction(self._txid)
|
|
assert self._tx is not None, f'unknown txid "{self._txid}"'
|
|
|
|
#self._logger.debug(repr(self._tx.to_json()))
|
|
|
|
self._logger.debug('adding info from wallet')
|
|
self._tx.add_info_from_wallet(self._wallet.wallet)
|
|
if not self._tx.is_complete() and self._tx.is_missing_info_from_network():
|
|
Network.run_from_another_thread(
|
|
self._tx.add_info_from_network(self._wallet.wallet.network, timeout=10)) # FIXME is this needed?...
|
|
|
|
sm = self._wallet.wallet.lnworker.swap_manager if self._wallet.wallet.lnworker else None
|
|
|
|
self._inputs = list(map(lambda x: {
|
|
'short_id': x.prevout.short_name(),
|
|
'value': x.value_sats(),
|
|
'address': x.address,
|
|
'is_mine': self._wallet.wallet.is_mine(x.address),
|
|
'is_change': self._wallet.wallet.is_change(x.address),
|
|
'is_swap': False if not sm else sm.is_lockup_address_for_a_swap(x.address) or x.address == DummyAddress.SWAP,
|
|
'is_accounting': self._wallet.wallet.is_accounting_address(x.address)
|
|
}, self._tx.inputs()))
|
|
self._outputs = list(map(lambda x: {
|
|
'address': x.get_ui_address_str(),
|
|
'value': QEAmount(amount_sat=x.value),
|
|
'short_id': '', # TODO
|
|
'is_mine': self._wallet.wallet.is_mine(x.get_ui_address_str()),
|
|
'is_change': self._wallet.wallet.is_change(x.get_ui_address_str()),
|
|
'is_billing': self._wallet.wallet.is_billing_address(x.get_ui_address_str()),
|
|
'is_swap': False if not sm else sm.is_lockup_address_for_a_swap(x.get_ui_address_str()) or x.get_ui_address_str() == DummyAddress.SWAP,
|
|
'is_accounting': self._wallet.wallet.is_accounting_address(x.get_ui_address_str())
|
|
}, self._tx.outputs()))
|
|
|
|
txinfo = self._wallet.wallet.get_tx_info(self._tx)
|
|
|
|
self._logger.debug(repr(txinfo))
|
|
|
|
# can be None if outputs unrelated to wallet seed,
|
|
# e.g. to_local local_force_close commitment CSV-locked p2wsh script
|
|
if txinfo.amount is None:
|
|
self._amount.satsInt = 0
|
|
else:
|
|
self._amount.satsInt = txinfo.amount
|
|
|
|
self._status = txinfo.status
|
|
self._fee.satsInt = txinfo.fee
|
|
|
|
self._feerate_str = ""
|
|
if txinfo.fee is not None:
|
|
size = self._tx.estimated_size()
|
|
fee_per_kb = txinfo.fee / size * 1000
|
|
self._feerate_str = self._wallet.wallet.config.format_fee_rate(fee_per_kb)
|
|
|
|
self._sighash_danger = TxSighashDanger()
|
|
|
|
self._lock_delay = 0
|
|
self._in_mempool = False
|
|
self._is_mined = False if not txinfo.tx_mined_status else txinfo.tx_mined_status.height > 0
|
|
if self._is_mined:
|
|
self.update_mined_status(txinfo.tx_mined_status)
|
|
else:
|
|
if txinfo.tx_mined_status.height in [TX_HEIGHT_UNCONFIRMED, TX_HEIGHT_UNCONF_PARENT]:
|
|
self._mempool_depth = FeePolicy.depth_tooltip(txinfo.mempool_depth_bytes)
|
|
self._in_mempool = True
|
|
elif txinfo.tx_mined_status.height == TX_HEIGHT_FUTURE:
|
|
self._lock_delay = txinfo.tx_mined_status.wanted_height - self._wallet.wallet.adb.get_local_height()
|
|
if isinstance(self._tx, PartialTransaction):
|
|
self._sighash_danger = self._wallet.wallet.check_sighash(self._tx)
|
|
|
|
if self._wallet.wallet.lnworker:
|
|
# Calling wallet.get_full_history here is inefficient.
|
|
# We should probably pass the tx_item to the constructor.
|
|
full_history = self._wallet.wallet.get_full_history()
|
|
item = full_history.get('group:' + self._txid)
|
|
self._lnamount.satsInt = int(item['ln_value'].value) if item else 0
|
|
else:
|
|
self._lnamount.satsInt = 0
|
|
|
|
self._is_complete = self._tx.is_complete()
|
|
self._is_rbf_enabled = self._tx.is_rbf_enabled()
|
|
self._is_unrelated = txinfo.amount is None and self._lnamount.isEmpty
|
|
self._is_lightning_funding_tx = txinfo.is_lightning_funding_tx
|
|
self._can_broadcast = txinfo.can_broadcast
|
|
self._can_bump = txinfo.can_bump
|
|
self._can_dscancel = txinfo.can_dscancel
|
|
self._can_cpfp = txinfo.can_cpfp
|
|
self._can_save_as_local = txinfo.can_save_as_local
|
|
self._can_remove = txinfo.can_remove
|
|
self._can_sign = (
|
|
not self._is_complete
|
|
and self._wallet.wallet.can_sign(self._tx)
|
|
and not self._sighash_danger.needs_reject()
|
|
)
|
|
|
|
self.detailsChanged.emit()
|
|
|
|
if self._txid:
|
|
label = self._wallet.wallet.get_label_for_txid(self._txid)
|
|
if self._label != label:
|
|
self._label = label
|
|
self.labelChanged.emit()
|
|
|
|
def update_mined_status(self, tx_mined_info: TxMinedInfo):
|
|
self._mempool_depth = ''
|
|
self._date = format_time(tx_mined_info.timestamp)
|
|
self._timestamp = tx_mined_info.timestamp
|
|
self._confirmations = tx_mined_info.conf
|
|
self._header_hash = tx_mined_info.header_hash
|
|
self._short_id = tx_mined_info.short_id() or ""
|
|
|
|
@pyqtSlot()
|
|
def signAndBroadcast(self):
|
|
self._sign(broadcast=True)
|
|
|
|
@pyqtSlot()
|
|
def sign(self):
|
|
self._sign(broadcast=False)
|
|
|
|
def _sign(self, broadcast):
|
|
# TODO: connecting/disconnecting signal handlers here is hmm
|
|
try:
|
|
if broadcast:
|
|
self._wallet.broadcastSucceeded.disconnect(self.onBroadcastSucceeded)
|
|
self._wallet.broadcastFailed.disconnect(self.onBroadcastFailed)
|
|
except Exception:
|
|
pass
|
|
|
|
if broadcast:
|
|
self._wallet.broadcastSucceeded.connect(self.onBroadcastSucceeded)
|
|
self._wallet.broadcastFailed.connect(self.onBroadcastFailed)
|
|
self._wallet.sign_and_broadcast(self._tx, on_success=self.on_signed_tx)
|
|
else:
|
|
self._wallet.sign(self._tx, on_success=self.on_signed_tx)
|
|
|
|
# side-effect: signing updates self._tx
|
|
# we rely on this for broadcast
|
|
|
|
def on_signed_tx(self, tx: Transaction):
|
|
self._logger.debug('on_signed_tx')
|
|
self.update()
|
|
|
|
@pyqtSlot()
|
|
def broadcast(self):
|
|
assert self._tx.is_complete()
|
|
|
|
try:
|
|
self._wallet.broadcastFailed.disconnect(self.onBroadcastFailed)
|
|
except Exception:
|
|
pass
|
|
self._wallet.broadcastFailed.connect(self.onBroadcastFailed)
|
|
|
|
self._can_broadcast = False
|
|
self.detailsChanged.emit()
|
|
|
|
self._wallet.broadcast(self._tx)
|
|
|
|
@pyqtSlot(str)
|
|
def onBroadcastSucceeded(self, txid):
|
|
if txid != self._txid:
|
|
return
|
|
|
|
self._logger.debug('onBroadcastSucceeded')
|
|
try:
|
|
self._wallet.broadcastSucceeded.disconnect(self.onBroadcastSucceeded)
|
|
except Exception:
|
|
pass
|
|
|
|
self._can_broadcast = False
|
|
self.detailsChanged.emit()
|
|
|
|
@pyqtSlot(str, str, str)
|
|
def onBroadcastFailed(self, txid, code, reason):
|
|
if txid != self._txid:
|
|
return
|
|
|
|
try:
|
|
self._wallet.broadcastFailed.disconnect(self.onBroadcastFailed)
|
|
except Exception:
|
|
pass
|
|
|
|
self._can_broadcast = True
|
|
self.detailsChanged.emit()
|
|
|
|
@pyqtSlot()
|
|
@pyqtSlot(bool)
|
|
def removeLocalTx(self, confirm=False):
|
|
assert self._can_remove, 'cannot remove'
|
|
txid = self._txid
|
|
assert txid, 'txid unset'
|
|
|
|
if not confirm:
|
|
num_child_txs = len(self._wallet.wallet.adb.get_depending_transactions(txid))
|
|
question = _("Are you sure you want to remove this transaction?")
|
|
if num_child_txs > 0:
|
|
question = (
|
|
_("Are you sure you want to remove this transaction and {} child transactions?")
|
|
.format(num_child_txs))
|
|
self.confirmRemoveLocalTx.emit(question)
|
|
return
|
|
|
|
self._wallet.wallet.adb.remove_transaction(txid)
|
|
self._wallet.wallet.save_db()
|
|
|
|
# NOTE: from here, the tx/txid is unknown and all properties are invalid.
|
|
# UI should close TxDetails and avoid interacting with this qetxdetails instance.
|
|
self._tx = None
|
|
|
|
@pyqtSlot()
|
|
def save(self):
|
|
if not self._tx:
|
|
return
|
|
|
|
if self._wallet.save_tx(self._tx):
|
|
self._can_save_as_local = False
|
|
self._can_remove = True
|
|
self.detailsChanged.emit()
|
|
|
|
@pyqtSlot(result='QVariantList')
|
|
def getSerializedTx(self):
|
|
txqr = self._tx.to_qr_data()
|
|
label = ""
|
|
if txid := self._tx.txid():
|
|
label = self._wallet.wallet.get_label_for_txid(txid)
|
|
return [str(self._tx), txqr[0], txqr[1], label]
|