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.
2192 lines
88 KiB
2192 lines
88 KiB
# -*- coding: utf-8 -*- |
|
|
|
import os |
|
import datetime |
|
import asyncio |
|
from functools import partial |
|
|
|
from PyQt6.QtCore import Qt, QTimer, pyqtSignal |
|
from PyQt6.QtGui import (QIcon, QTextCursor, QIntValidator, QFont, |
|
QKeySequence, QColor, QBrush, QAction) |
|
from PyQt6.QtWidgets import (QWidget, QGridLayout, QHBoxLayout, QLabel, |
|
QVBoxLayout, QDialog, QPushButton, QTabWidget, |
|
QCheckBox, QPlainTextEdit, QApplication, |
|
QMessageBox, QScrollArea, QFrame, QSizePolicy, |
|
QTextEdit, QGroupBox, QLineEdit, QSplitter, |
|
QTreeWidgetItem, QHeaderView, QAbstractItemView, |
|
QStatusBar, QMenu, QFileDialog, |
|
QDoubleSpinBox, QStyle) |
|
|
|
from electrum import constants, bitcoin |
|
from electrum.bip32 import xpub_type |
|
from electrum.i18n import _ |
|
from electrum.simple_config import FEE_ETA_TARGETS |
|
|
|
from electrum.gui.qt.util import (read_QIcon, HelpLabel, MessageBoxMixin, |
|
QtEventListener, qt_event_listener, |
|
MONOSPACE_FONT, WindowModalDialog, |
|
Buttons, OkButton) |
|
|
|
from .jm_util import guess_address_script_type, filter_log_line, JMStates |
|
from .jmclient import (JMClientProtocolFactory, Taker, get_max_cj_fee_values, |
|
fidelity_bond_weighted_order_choose, get_schedule, |
|
ScheduleGenerationErrorNoFunds, schedule_to_text, |
|
tumbler_filter_orders_callback, NO_ROUNDING, |
|
get_default_max_relative_fee, direct_send, |
|
get_default_max_absolute_fee, parse_schedule_line, |
|
general_custom_change_warning, |
|
nonwallet_custom_change_warning, |
|
sweep_custom_change_warning, wallet_display, |
|
tumbler_taker_finished_update, restart_wait) |
|
from .jmbitcoin.amount import amount_to_sat, amount_to_str |
|
|
|
from .jm_qt_support import (ScheduleWizard, TumbleRestartWizard, config_tips, |
|
config_types, MyTreeWidget, JMQtMessageBox, |
|
donation_more_message, BitcoinAmountEdit, |
|
JMIntValidator, conf_sections, conf_names) |
|
from .jm_qt_obwatch import OBWatchTab |
|
|
|
|
|
JM_GUI_VERSION = '33' |
|
|
|
DATE_FORMAT = "%Y/%m/%d %H:%M:%S" |
|
|
|
|
|
class GUIConfig: |
|
|
|
def __init__(self): |
|
self.gaplimit = 6 |
|
self.check_high_fee = 2 |
|
self.max_mix_depth = 5 |
|
self.order_wait_time = 30 |
|
self.checktx = True |
|
|
|
|
|
GUIconf = GUIConfig() |
|
|
|
|
|
class FilteredPlainTextEdit(QPlainTextEdit): |
|
|
|
def contextMenuEvent(self, event): |
|
f_copy = QAction(_('Copy filtered'), self) |
|
f_copy.triggered.connect(lambda checked: self.copy_filtered()) |
|
f_copy.setEnabled(self.textCursor().hasSelection()) |
|
|
|
copy_icon = QIcon.fromTheme('edit-copy') |
|
if copy_icon: |
|
f_copy.setIcon(copy_icon) |
|
|
|
menu = self.createStandardContextMenu(event.pos()) |
|
menu.insertAction(menu.actions()[0], f_copy) |
|
menu.exec(event.globalPos()) |
|
|
|
def copy_filtered(self): |
|
cursor = self.textCursor() |
|
if not cursor.hasSelection(): |
|
return |
|
|
|
all_lines = self.toPlainText().splitlines() |
|
sel_beg = cursor.selectionStart() |
|
sel_end = cursor.selectionEnd() |
|
l_beg = 0 |
|
result_lines = [] |
|
for i in range(len(all_lines)): |
|
cur_line = all_lines[i] |
|
cur_len = len(cur_line) |
|
l_end = l_beg + cur_len |
|
|
|
if l_end > sel_beg and l_beg < sel_end: |
|
filtered_line = filter_log_line(cur_line) |
|
l_sel_start = None if sel_beg <= l_beg else sel_beg - l_beg |
|
l_sel_end = None if sel_end >= l_end else sel_end - l_beg |
|
clipped_line = filtered_line[l_sel_start:l_sel_end] |
|
result_lines.append(clipped_line) |
|
|
|
l_beg += (cur_len + 1) |
|
if l_beg > sel_end: |
|
break |
|
QApplication.clipboard().setText('\n'.join(result_lines)) |
|
|
|
|
|
class WarnExDialog(WindowModalDialog): |
|
|
|
def __init__(self, jmman, parent): |
|
WindowModalDialog.__init__(self, parent, _('Warning')) |
|
self.setMinimumWidth(500) |
|
self.setMaximumWidth(500) |
|
warn_icon = self.style().standardIcon( |
|
QStyle.StandardPixmap.SP_MessageBoxWarning) |
|
self.setWindowIcon(warn_icon) |
|
|
|
self.jm_dlg = parent |
|
self.jmman = jmman |
|
jmconf = jmman.jmconf |
|
|
|
self.grid = grid = QGridLayout() |
|
vbox = QVBoxLayout(self) |
|
vbox.addLayout(grid) |
|
grid.setSpacing(8) |
|
|
|
warn_label = QLabel() |
|
warn_label.setPixmap(warn_icon.pixmap(48)) |
|
grid.addWidget(warn_label, 0, 0) |
|
|
|
warn_text = jmconf.warn_electrumx_data(full_txt=True) |
|
grid.addWidget(QLabel(warn_text, wordWrap=True), 0, 1) |
|
|
|
is_enabled = jmman.enabled |
|
|
|
self.read_cb = read_cb = QCheckBox(_('I have read the warning.')) |
|
read_cb.stateChanged.connect(self.on_read_cb_changed) |
|
self.read_cb.setHidden(is_enabled) |
|
grid.addWidget(read_cb, 1, 1) |
|
|
|
read_cb_checked = self.read_cb.isChecked() |
|
self.activate_btn = b = QPushButton(_('Activate JM plugin')) |
|
b.clicked.connect(self.on_activate_btn_clicked) |
|
b.setHidden(is_enabled) |
|
b.setEnabled(read_cb_checked) |
|
grid.addWidget(b, 2, 1) |
|
|
|
self.active_lb = lb = QLabel(_('JM plugin is activated.')) |
|
lb.setHidden(not is_enabled) |
|
grid.addWidget(lb, 3, 1) |
|
|
|
self.hide_cb = hide_cb = QCheckBox( |
|
_('Do not show this on JoinMarket dialog open.')) |
|
hide_cb.setChecked(not jmconf.show_warn_electrumx) |
|
self.hide_cb.setEnabled(False or is_enabled) |
|
hide_cb.stateChanged.connect(self.on_hide_cb_changed) |
|
grid.addWidget(hide_cb, 4, 1) |
|
|
|
vbox.addLayout(Buttons(OkButton(self))) |
|
self.setLayout(vbox) |
|
|
|
def update_ui(self): |
|
read_cb_checked = self.read_cb.isChecked() |
|
is_enabled = self.jmman.enabled |
|
|
|
self.read_cb.setHidden(is_enabled) |
|
self.activate_btn.setEnabled(read_cb_checked) |
|
self.activate_btn.setHidden(is_enabled) |
|
self.active_lb.setHidden(not is_enabled) |
|
self.hide_cb.setEnabled(is_enabled) |
|
self.jm_dlg.set_tabs_visible() |
|
|
|
def on_read_cb_changed(self, x): |
|
self.update_ui() |
|
|
|
def on_activate_btn_clicked(self, x): |
|
if (JMQtMessageBox(self, "Activate JM plugin?", mbtype='question') != |
|
QMessageBox.StandardButton.Yes): |
|
return |
|
self.jmman.enable_jm() |
|
if self.jmman.enabled: |
|
JMQtMessageBox(self, _('JM plugin is activated'), mbtype='info', |
|
title=_('Info')) |
|
self.update_ui() |
|
|
|
def on_hide_cb_changed(self, x): |
|
self.jmman.jmconf.show_warn_electrumx = (Qt.CheckState(x) != |
|
Qt.CheckState.Checked) |
|
self.update_ui() |
|
|
|
|
|
class WarnExLabel(HelpLabel): |
|
|
|
def __init__(self, jmman, parent): |
|
self.parent_win = parent |
|
self.jmman = jmman |
|
self.jmconf = jmconf = jmman.jmconf |
|
text = jmconf.warn_electrumx_data() |
|
help_text = jmconf.warn_electrumx_data(full_txt=True) |
|
super(WarnExLabel, self).__init__(text, help_text) |
|
|
|
def mouseReleaseEvent(self, x): |
|
self.show_warn() |
|
|
|
def show_warn(self): |
|
WarnExDialog(self.jmman, self.parent_win).exec() |
|
|
|
|
|
class SpendStateMgr: |
|
"""A primitive class keep track of the mode |
|
in which the spend tab is being run |
|
""" |
|
def __init__(self, updatecallback, jmman, update_status_btn_cb): |
|
self.updatecallback = updatecallback |
|
self.jmman = jmman |
|
self.update_status_btn_cb = update_status_btn_cb |
|
self.reset_vars() |
|
|
|
def jmman_ready(self): |
|
self.jmman.state = JMStates.Ready |
|
self.update_status_btn_cb() |
|
|
|
def jmman_mixing(self): |
|
self.jmman.state = JMStates.Mixing |
|
self.update_status_btn_cb() |
|
|
|
def updateType(self, t): |
|
self.typestate = t |
|
self.updatecallback() |
|
|
|
def updateRun(self, r): |
|
self.runstate = r |
|
self.updatecallback() |
|
|
|
def reset_vars(self): |
|
self.typestate = 'single' |
|
self.runstate = 'ready' |
|
self.schedule_name = None |
|
self.loaded_schedule = None |
|
self.prev_runstates = [] |
|
|
|
def reset(self): |
|
self.jmman_ready() |
|
self.reset_vars() |
|
self.updatecallback() |
|
|
|
@property |
|
def waiting(self): |
|
return self.runstate == 'waiting' |
|
|
|
@waiting.setter |
|
def waiting(self, waiting: bool): |
|
if waiting: |
|
self.prev_runstates.append(self.runstate) |
|
self.runstate = 'waiting' |
|
elif self.prev_runstates: |
|
self.runstate = self.prev_runstates.pop() |
|
else: |
|
self.runstate = 'ready' |
|
self.updatecallback() |
|
|
|
|
|
class JMDlgUnsupportedJM(QDialog, MessageBoxMixin): |
|
|
|
def __init__(self, mwin, plugin): |
|
QDialog.__init__(self, parent=None) |
|
flags = self.windowFlags() |
|
flags = flags & ~Qt.WindowType.Dialog |
|
flags = flags | Qt.WindowType.Window |
|
flags = flags | Qt.WindowType.WindowMinimizeButtonHint |
|
flags = flags | Qt.WindowType.WindowMaximizeButtonHint |
|
self.setWindowFlags(flags) |
|
|
|
self.setMinimumSize(900, 480) |
|
self.setWindowIcon(read_QIcon('electrum.png')) |
|
self.mwin = mwin |
|
self.plugin = plugin |
|
self.wallet = mwin.wallet |
|
self.jmman = jmman = mwin.wallet.jmman |
|
title = '%s - %s' % (plugin.MSG_TITLE, str(self.wallet)) |
|
self.setWindowTitle(title) |
|
|
|
layout = QGridLayout() |
|
self.setLayout(layout) |
|
|
|
jm_unsupported_label = QLabel(jmman.unsupported_msg) |
|
jm_unsupported_label.setWordWrap(True) |
|
layout.addWidget(jm_unsupported_label, 0, 0, 1, -1) |
|
|
|
self.close_btn = b = QPushButton(_('Close')) |
|
b.setDefault(True) |
|
b.clicked.connect(self.close) |
|
layout.addWidget(b, 2, 1) |
|
layout.setRowStretch(1, 1) |
|
layout.setColumnStretch(0, 1) |
|
|
|
def closeEvent(self, event): |
|
if self.mwin in self.plugin.jm_dialogs: |
|
del self.plugin.jm_dialogs[self.mwin] |
|
event.accept() |
|
|
|
|
|
class JMDlg(QtEventListener, QDialog, MessageBoxMixin): |
|
|
|
def __init__(self, mwin, plugin): |
|
QDialog.__init__(self, parent=None) |
|
flags = self.windowFlags() |
|
flags = flags & ~Qt.WindowType.Dialog |
|
flags = flags | Qt.WindowType.Window |
|
flags = flags | Qt.WindowType.WindowMinimizeButtonHint |
|
flags = flags | Qt.WindowType.WindowMaximizeButtonHint |
|
self.setWindowFlags(flags) |
|
|
|
self.setMinimumSize(900, 480) |
|
self.setWindowIcon(read_QIcon('electrum.png')) |
|
self.mwin = mwin |
|
self.app = mwin.gui_object.app |
|
self.plugin = plugin |
|
self.config = plugin.config |
|
# self.format_amount = mwin.format_amount |
|
self.wallet = mwin.wallet |
|
self.jmman = jmman = mwin.wallet.jmman |
|
self.logger = jmman.logger |
|
GUIconf.max_mix_depth = jmman.jmconf.mixdepth + 1 |
|
if constants.net.TESTNET: |
|
testnet_str = ' ' + constants.net.NET_NAME.capitalize() |
|
else: |
|
testnet_str = '' |
|
self.win_title = '%s%s - %s' % (plugin.MSG_TITLE, testnet_str, |
|
str(self.wallet)) |
|
self.setWindowTitle(self.win_title) |
|
|
|
self.statusBar = QStatusBar() |
|
layout = QVBoxLayout() |
|
self.setLayout(layout) |
|
|
|
# setup logging |
|
self.log_handler = self.jmman.log_handler |
|
self.logger = self.jmman.logger |
|
self.log_view = FilteredPlainTextEdit() |
|
self.log_view.setMaximumBlockCount(1000) |
|
self.log_view.setVerticalScrollBarPolicy( |
|
Qt.ScrollBarPolicy.ScrollBarAlwaysOn) |
|
self.log_view.setReadOnly(True) |
|
|
|
self.tabs = tabs = QTabWidget(self) |
|
layout.addWidget(tabs) |
|
layout.addWidget(self.statusBar) |
|
|
|
self.wallet_tab = JMWalletTab(self) |
|
tabs.addTab(self.wallet_tab, "JM Wallet") |
|
self.spend_tab = SpendTab(self) |
|
tabs.addTab(self.spend_tab, "Coinjoins") |
|
self.ob_watch_tab = OBWatchTab(self) |
|
tabs.addTab(self.ob_watch_tab, "OB Watch") |
|
self.coins_tab = CoinsTab(self) |
|
tabs.addTab(self.coins_tab, "Coins") |
|
self.history_tab = TxHistoryTab(self) |
|
tabs.addTab(self.history_tab, "Tx History") |
|
self.settings_tab = SettingsTab(self) |
|
tabs.addTab(self.settings_tab, "Settings") |
|
self.set_tabs_visible() |
|
|
|
self.register_callbacks() |
|
self.init_log() |
|
self.on_tabs_changed(0) |
|
self.tabs.currentChanged.connect(self.on_tabs_changed) |
|
|
|
def set_tabs_visible(self): |
|
tabs = self.tabs |
|
for tab in [self.spend_tab, self.ob_watch_tab, self.coins_tab, |
|
self.history_tab, self.settings_tab]: |
|
idx = tabs.indexOf(tab) |
|
tabs.setTabVisible(idx, self.jmman.enabled) |
|
|
|
def showEvent(self, event): |
|
super(JMDlg, self).showEvent(event) |
|
QTimer.singleShot(0, self.on_shown) |
|
|
|
def on_shown(self): |
|
if self.jmman.jmconf.show_warn_electrumx: |
|
self.settings_tab.warn_ex_label.show_warn() |
|
|
|
def on_tabs_changed(self, idx): |
|
if self.tabs.currentWidget() == self.spend_tab: |
|
self.append_log_tail() |
|
self.log_handler.notify = True |
|
elif self.tabs.currentWidget() == self.settings_tab: |
|
self.settings_tab.constructUI(update=True) |
|
else: |
|
self.log_handler.notify = False |
|
|
|
@qt_event_listener |
|
def on_event_status(self): |
|
self.wallet_tab.updateWalletInfo() |
|
self.coins_tab.updateUtxos() |
|
|
|
@qt_event_listener |
|
def on_event_jm_log_changes(self, *args): |
|
jmman = args[0] |
|
if jmman == self.jmman: |
|
if self.log_handler.head > self.log_head: |
|
self.clear_log_head() |
|
if self.log_handler.tail > self.log_tail: |
|
self.append_log_tail() |
|
|
|
def init_log(self): |
|
self.log_head = self.log_handler.head |
|
self.log_tail = self.log_head |
|
|
|
def append_log_tail(self): |
|
log_handler = self.log_handler |
|
log_tail = log_handler.tail |
|
lv = self.log_view |
|
vert_sb = self.log_view.verticalScrollBar() |
|
was_at_end = (vert_sb.value() == vert_sb.maximum()) |
|
for i in range(self.log_tail, log_tail): |
|
log_line = '' |
|
log_record = log_handler.log.get(i, None) |
|
if log_record: |
|
log_line = log_handler.format(log_record) |
|
lv.appendHtml(log_line) |
|
if was_at_end: |
|
cursor = self.log_view.textCursor() |
|
cursor.movePosition(QTextCursor.End) |
|
self.log_view.setTextCursor(cursor) |
|
self.log_view.ensureCursorVisible() |
|
self.log_tail = log_tail |
|
|
|
def clear_log_head(self): |
|
self.log_head = self.log_handler.head |
|
|
|
def resizeEvent(self, event): |
|
self.log_view.ensureCursorVisible() |
|
return super(JMDlg, self).resizeEvent(event) |
|
|
|
def shutdown(self): |
|
self.logger.info('Shutting down JMDlg') |
|
try: |
|
spend_tab = self.spend_tab |
|
jmman = self.jmman |
|
jmman.jmconf.reset_mincjamount() |
|
self.ob_watch_tab.on_stop_ob_watch() |
|
|
|
async def shutdown_mixing(): |
|
if spend_tab.spendstate.runstate == 'ready': |
|
if jmman.state == JMStates.Mixing: |
|
jmman.state = JMStates.Ready |
|
return |
|
|
|
self.logger.info('Shutting down mixing') |
|
try: |
|
while not spend_tab.clientfactory: |
|
await asyncio.sleep(0.2) |
|
|
|
spend_tab.abortTransactions() |
|
proto_daemon = spend_tab.clientfactory.proto_daemon |
|
spend_tab.clientfactory = None |
|
spend_tab.taker = None |
|
|
|
while not proto_daemon.mcc: |
|
await asyncio.sleep(0.2) |
|
|
|
await proto_daemon.mc_shutdown( |
|
shutdown_unavailable=True) |
|
if jmman.state == JMStates.Mixing: |
|
jmman.state = JMStates.Ready |
|
self.logger.info('Shutting down finished') |
|
except Exception as e: |
|
self.logger.info(f'Error shutting down mixing: ' |
|
f'{repr(e)}') |
|
|
|
asyncio.run_coroutine_threadsafe(shutdown_mixing(), jmman.loop) |
|
except Exception as e: |
|
self.logger.error(f'Error shutting down JMDlg: {repr(e)}') |
|
|
|
def closeEvent(self, event): |
|
if not self.plugin.unconditional_close_jm_dlg: |
|
quit_msg = "Are you sure you want to quit?" |
|
reply = JMQtMessageBox(self, quit_msg, mbtype='question') |
|
if reply != QMessageBox.StandardButton.Yes: |
|
event.ignore() |
|
return |
|
self.shutdown() |
|
self.unregister_callbacks() |
|
if self.mwin in self.plugin.jm_dialogs: |
|
del self.plugin.jm_dialogs[self.mwin] |
|
event.accept() |
|
|
|
def done(self, r): |
|
pass # do nothing on reject/accept especially reject from Esc key |
|
|
|
def show_error(self, msg, title=None): |
|
if title is None: |
|
title = "Error" |
|
JMQtMessageBox(self, msg, mbtype='crit', title=title) |
|
|
|
@classmethod |
|
def autofreeze_warning_cb(cls, parent_win, outpoint, utxo): |
|
""" Handles coins sent to reused addresses, |
|
preventing forced address reuse, according to value of |
|
POLICY setting `max_sats_freeze_reuse` (see |
|
WalletService.check_for_reuse()). |
|
""" |
|
utxostr = (f'outpoint:\n{outpoint}\n\n' |
|
f'address:\n{utxo.address}\n\n' |
|
f'value:\n{utxo.value}') |
|
msg = (f"New utxo has been automatically frozen to prevent forced " |
|
f"address reuse:\n\n{utxostr}\n\n You can unfreeze this utxo " |
|
f"via the Coins tab.") |
|
JMQtMessageBox(parent_win, msg, mbtype='info', |
|
title="New utxo frozen") |
|
|
|
|
|
class JMWalletTab(QWidget): |
|
|
|
def __init__(self, jm_dlg): |
|
super().__init__() |
|
self.jm_dlg = jm_dlg |
|
self.jmman = jm_dlg.jmman |
|
self.logger = self.jmman.logger |
|
self.wallet_name = jm_dlg.win_title |
|
self.initUI() |
|
|
|
def initUI(self): |
|
self.label1 = QLabel('', self) |
|
self.label1.setAlignment( |
|
Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft) |
|
v = MyTreeWidget(self, self.create_menu, self.getHeaders()) |
|
v.header().resizeSection(0, 400) # size of "Address" column |
|
v.header().resizeSection(1, 130) # size of "Index" column |
|
v.setSelectionMode( |
|
QAbstractItemView.SelectionMode.ExtendedSelection) |
|
v.on_update = self.updateWalletInfo |
|
v.hide() |
|
self.walletTree = v |
|
vbox = QVBoxLayout() |
|
self.setLayout(vbox) |
|
vbox.setContentsMargins(0, 0, 0, 0) |
|
vbox.setSpacing(0) |
|
vbox.addWidget(self.label1) |
|
vbox.addWidget(v) |
|
buttons = QWidget() |
|
vbox.addWidget(buttons) |
|
self.updateWalletInfo() |
|
self.show() |
|
|
|
def getHeaders(self): |
|
'''Function included in case dynamic in future''' |
|
return ['Address', 'Index', 'Balance', 'Status', 'Label'] |
|
|
|
def create_menu(self, position): |
|
item = self.walletTree.currentItem() |
|
address_valid = False |
|
xpub_exists = False |
|
if item: |
|
txt = str(item.text(0)) |
|
if bitcoin.is_address(txt): |
|
address_valid = True |
|
|
|
parsed = txt.split() |
|
if len(parsed) > 1: |
|
try: |
|
if xpub_type(parsed[-1]) in ['standard', 'p2wpkh-p2sh', |
|
'p2wsh-p2sh', 'p2wpkh', |
|
'p2wsh']: |
|
xpub = parsed[-1] |
|
xpub_exists = True |
|
except BaseException: |
|
pass |
|
|
|
menu = QMenu() |
|
if address_valid: |
|
copy_addr_act = QAction("Copy address to clipboard", menu) |
|
copy_addr_act.triggered.connect( |
|
lambda: QApplication.clipboard().setText(txt)) |
|
copy_addr_act.setShortcut( |
|
QKeySequence(QKeySequence.StandardKey.Copy)) |
|
menu.addAction(copy_addr_act) |
|
if item.text(4): |
|
menu.addAction( |
|
"Copy label to clipboard", |
|
lambda: QApplication.clipboard().setText(item.text(4))) |
|
# Show QR code option only for new addresses to avoid address reuse |
|
if item.text(3) == "new": |
|
menu.addAction("Show QR code", |
|
lambda: self.openAddressQRCodePopup(txt)) |
|
if xpub_exists: |
|
copy_xpub_act = QAction("Copy extended public key to clipboard", |
|
menu) |
|
copy_xpub_act.triggered.connect( |
|
lambda: QApplication.clipboard().setText(txt)) |
|
copy_xpub_act.setShortcut( |
|
QKeySequence(QKeySequence.StandardKey.Copy)) |
|
menu.addAction(copy_xpub_act) |
|
menu.addAction("Show QR code", |
|
lambda: self.openQRCodePopup(xpub, xpub)) |
|
|
|
# TODO add more items to context menu |
|
menu.exec(self.walletTree.viewport().mapToGlobal(position)) |
|
|
|
def openQRCodePopup(self, title, data): |
|
self.jm_dlg.mwin.show_qrcode(data, 'Address', parent=self) |
|
|
|
def openAddressQRCodePopup(self, address): |
|
self.openQRCodePopup(address, address) |
|
|
|
def updateWalletInfo(self): |
|
if not self.jmman.enabled: |
|
return |
|
max_mixdepth_count = GUIconf.max_mix_depth |
|
|
|
previous_expand_states = [] |
|
# before deleting, note whether items were expanded |
|
for i in range(self.walletTree.topLevelItemCount()): |
|
tli = self.walletTree.invisibleRootItem().child(i) |
|
# expandedness is a list beginning with the top level expand state, |
|
# followed by the expand state of its children |
|
expandedness = [tli.isExpanded()] |
|
for j in range(tli.childCount()): |
|
expandedness.append(tli.child(j).isExpanded()) |
|
previous_expand_states.append(expandedness) |
|
self.walletTree.clear() |
|
|
|
walletinfo = get_wallet_printout(self.jmman) |
|
|
|
rows, mbalances, xpubs, total_bal = walletinfo |
|
self.label1.setText("CURRENT WALLET: " + self.wallet_name + |
|
', total balance: ' + total_bal) |
|
self.walletTree.show() |
|
|
|
for mixdepth in range(max_mixdepth_count): |
|
mdbalance = mbalances[mixdepth] |
|
account_xpub = xpubs[mixdepth][-1] |
|
|
|
m_item = QTreeWidgetItem(["Mixdepth " + str(mixdepth) + |
|
" , balance: " + mdbalance + ", " + |
|
account_xpub, '', '', '', '']) |
|
self.walletTree.addChild(m_item) |
|
|
|
# if expansion states existed, reinstate them: |
|
if len(previous_expand_states) == max_mixdepth_count: |
|
m_item.setExpanded(previous_expand_states[mixdepth][0]) |
|
# we expand at the mixdepth level by default |
|
else: |
|
m_item.setExpanded(True) |
|
|
|
for address_type in [0, 1]: |
|
if address_type == 0: |
|
heading = "EXTERNAL " + xpubs[mixdepth][address_type] |
|
elif address_type == 0: |
|
heading = "INTERNAL" |
|
else: |
|
heading = "" |
|
|
|
seq_item = QTreeWidgetItem([heading, '', '', '', '']) |
|
m_item.addChild(seq_item) |
|
|
|
# by default, the external addresses of mixdepth 0 is expanded |
|
should_expand = mixdepth == 0 and address_type == 0 |
|
for address_index in range(len(rows[mixdepth][address_type])): |
|
item = QTreeWidgetItem( |
|
rows[mixdepth][address_type][address_index]) |
|
item.setFont(0, QFont(MONOSPACE_FONT)) |
|
if rows[mixdepth][address_type][address_index][3] != "new": |
|
item.setForeground(3, QBrush(QColor('red'))) |
|
# by default, if the balance is non zero, it is |
|
# also expanded |
|
balance = float( |
|
rows[mixdepth][address_type][address_index][2]) |
|
if balance > 0: |
|
should_expand = True |
|
seq_item.addChild(item) |
|
# Remember user choice, if expansion states existed, |
|
# reinstate them: |
|
if len(previous_expand_states) == max_mixdepth_count: |
|
should_expand = \ |
|
previous_expand_states[mixdepth][address_type+1] |
|
seq_item.setExpanded(should_expand) |
|
|
|
|
|
class SpendTab(QWidget): |
|
|
|
info_callback_signal = pyqtSignal(tuple) |
|
error_callback_signal = pyqtSignal(tuple) |
|
check_offers_signal = pyqtSignal(tuple) |
|
check_direct_send_signal = pyqtSignal(tuple) |
|
taker_info_signal = pyqtSignal(tuple) |
|
taker_finished_signal = pyqtSignal(tuple) |
|
start_single_signal = pyqtSignal() |
|
start_join_signal = pyqtSignal() |
|
|
|
def __init__(self, jm_dlg): |
|
super().__init__() |
|
self.jm_dlg = jm_dlg |
|
self.jmman = jmman = jm_dlg.jmman |
|
self.logger = jmman.logger |
|
self.initUI() |
|
self.taker = None |
|
self.filter_offers_response = None |
|
self.clientfactory = None |
|
self.tumbler_options = None |
|
# timer for waiting for confirmation on restart |
|
self.restartTimer = QTimer() |
|
# timer for wait for next transaction |
|
self.nextTxTimer = None |
|
# tracks which mode the spend tab is run in |
|
update_jm_status_btn = partial(self.jm_dlg.plugin.update_jm_status_btn, |
|
jmman.wallet) |
|
self.spendstate = SpendStateMgr(self.toggleButtons, jmman, |
|
update_jm_status_btn) |
|
self.spendstate.reset() # trigger callback to 'ready' state |
|
|
|
# connect callback signals |
|
self.info_callback_signal.connect(self._infoDirectSend) |
|
self.error_callback_signal.connect(self._errorDirectSend) |
|
self.check_offers_signal.connect(self._checkOffers) |
|
self.check_direct_send_signal.connect(self._checkDirectSend) |
|
self.taker_info_signal.connect(self._takerInfo) |
|
self.taker_finished_signal.connect(self._takerFinished) |
|
self.start_single_signal.connect(self.startSingle) |
|
self.start_join_signal.connect(self.startJoin) |
|
|
|
def switchToJoinmarket(self): |
|
self.numCPLabel.setVisible(True) |
|
self.numCPInput.setVisible(True) |
|
|
|
def clearFields(self, ignored): |
|
self.switchToJoinmarket() |
|
self.addressInput.setText('') |
|
self.amountInput.setText('') |
|
self.changeInput.setText('') |
|
self.addressInput.setEnabled(True) |
|
self.mixdepthInput.setEnabled(True) |
|
self.amountInput.setEnabled(True) |
|
self.changeInput.setEnabled(True) |
|
self.startButton.setEnabled(True) |
|
self.abortButton.setEnabled(False) |
|
|
|
def checkAddress(self, addr): |
|
valid = bitcoin.is_address(str(addr)) |
|
if not valid and len(addr) > 0: |
|
JMQtMessageBox(self, "Bitcoin address not valid.", |
|
mbtype='warn', |
|
title="Error") |
|
|
|
def parseURIAndValidateAddress(self, addr): |
|
addr = addr.strip() |
|
self.checkAddress(addr) |
|
|
|
def checkAmount(self, amount_str): |
|
if not amount_str: |
|
return False |
|
try: |
|
amount_sat = amount_to_sat(amount_str) |
|
except ValueError as e: |
|
JMQtMessageBox(self, f'{repr(e)}', title="Error", mbtype="warn") |
|
return False |
|
jmman = self.jmman |
|
if amount_sat < jmman.jmconf.DUST_THRESHOLD: |
|
JMQtMessageBox(self, |
|
"Amount " + amount_to_str(amount_sat) + |
|
" is below dust threshold " + |
|
amount_to_str(jmman.jmconf.DUST_THRESHOLD) + ".", |
|
mbtype='warn', |
|
title="Error") |
|
return False |
|
return True |
|
|
|
def cache_keypairs(self, callback, *, tx_cnt=None): |
|
jmman = self.jmman |
|
if jmman.jmw.check_need_new_keypairs(): |
|
password = None |
|
jm_dlg = self.jm_dlg |
|
while jmman.wallet.has_keystore_encryption(): |
|
password = jm_dlg.mwin.password_dialog(parent=jm_dlg) |
|
if password is None: |
|
# User cancelled password input |
|
return True, None |
|
try: |
|
jmman.wallet.check_password(password) |
|
break |
|
except Exception as e: |
|
jm_dlg.show_error(str(e)) |
|
continue |
|
coro = jmman.jmw.make_keypairs_cache(password, callback, |
|
tx_cnt=tx_cnt) |
|
asyncio.run_coroutine_threadsafe(coro, jmman.loop) |
|
return True, callback |
|
else: |
|
return False, None |
|
|
|
def generateTumbleSchedule(self): |
|
global GUIconf |
|
# needs a set of tumbler options and destination addresses, so needs |
|
# a wizard |
|
jmman = self.jmman |
|
wizard = ScheduleWizard(jmman) |
|
wizard_return = wizard.exec() |
|
if wizard_return == QDialog.DialogCode.Rejected: |
|
return |
|
try: |
|
self.spendstate.loaded_schedule = wizard.get_schedule( |
|
jmman.jmw.get_balance_by_mixdepth(), jmman.jmconf.mixdepth) |
|
except ScheduleGenerationErrorNoFunds: |
|
JMQtMessageBox(self, |
|
"Failed to start tumbler; no funds available.", |
|
title="Tumbler start failed.") |
|
return |
|
self.spendstate.schedule_name = 'generated' |
|
self.updateSchedView() |
|
self.tumbler_options = wizard.opts |
|
self.tumbler_destaddrs = wizard.get_destaddrs() |
|
self.sch_startButton.setEnabled(True) |
|
|
|
def selectSchedule(self): |
|
firstarg = QFileDialog.getOpenFileName( |
|
self, 'Choose Schedule File', |
|
options=QFileDialog.Option.DontUseNativeDialog)[0] |
|
if not firstarg: |
|
return |
|
# TODO validate the schedule |
|
self.logger.debug('Looking for schedule in: ' + str(firstarg)) |
|
|
|
res, schedule = get_schedule(firstarg) |
|
if not res: |
|
JMQtMessageBox(self, "Not a valid JM schedule file", mbtype='crit', |
|
title='Error') |
|
else: |
|
self.jm_dlg.statusBar.showMessage("Schedule loaded OK.") |
|
self.spendstate.loaded_schedule = schedule |
|
self.spendstate.schedule_name = os.path.basename(str(firstarg)) |
|
self.updateSchedView() |
|
reply = JMQtMessageBox(self, "An incomplete tumble run" |
|
" detected.\nDo you want to" |
|
" restart?", |
|
title="Restart detected", |
|
mbtype='question') |
|
if reply != QMessageBox.StandardButton.Yes: |
|
return |
|
self.tumbler_options = True |
|
|
|
def updateSchedView(self): |
|
if self.spendstate.schedule_name: |
|
self.sch_label2.setText(self.spendstate.schedule_name) |
|
self.sched_view.setText( |
|
schedule_to_text(self.spendstate.loaded_schedule)) |
|
else: |
|
self.sch_label2.setText("None") |
|
self.sched_view.setText("") |
|
|
|
def getDonateLayout(self): |
|
donateLayout = QHBoxLayout() |
|
self.donateCheckBox = QCheckBox() |
|
self.donateCheckBox.setChecked(False) |
|
# Temporarily disabled |
|
self.donateCheckBox.setEnabled(False) |
|
self.donateCheckBox.setMaximumWidth(30) |
|
self.donateLimitBox = QDoubleSpinBox() |
|
self.donateLimitBox.setMinimum(0.001) |
|
self.donateLimitBox.setMaximum(0.100) |
|
self.donateLimitBox.setSingleStep(0.001) |
|
self.donateLimitBox.setDecimals(3) |
|
self.donateLimitBox.setValue(0.010) |
|
self.donateLimitBox.setMaximumWidth(100) |
|
self.donateLimitBox.setSizePolicy( |
|
QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) |
|
donateLayout.addWidget(self.donateCheckBox) |
|
label1 = QLabel("Check to send change lower than: ") |
|
label1.setSizePolicy( |
|
QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) |
|
donateLayout.addWidget(label1) |
|
donateLayout.setAlignment( |
|
label1, Qt.AlignmentFlag.AlignLeft) |
|
donateLayout.addWidget(self.donateLimitBox) |
|
donateLayout.setAlignment( |
|
self.donateLimitBox, Qt.AlignmentFlag.AlignLeft) |
|
label2 = QLabel(" BTC as a donation.") |
|
donateLayout.addWidget(label2) |
|
label2.setSizePolicy( |
|
QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) |
|
donateLayout.setAlignment( |
|
label2, Qt.AlignmentFlag.AlignLeft) |
|
label3 = HelpLabel('More', donation_more_message, |
|
'About the donation feature') |
|
label3.setSizePolicy( |
|
QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) |
|
donateLayout.setAlignment( |
|
label3, Qt.AlignmentFlag.AlignLeft) |
|
donateLayout.addWidget(label3) |
|
donateLayout.addStretch(1) |
|
return donateLayout |
|
|
|
def initUI(self): |
|
jmman = self.jm_dlg.jmman |
|
vbox = QVBoxLayout(self) |
|
top = QFrame() |
|
top.setFrameShape(QFrame.Shape.StyledPanel) |
|
topLayout = QGridLayout() |
|
top.setLayout(topLayout) |
|
sA = QScrollArea() |
|
sA.setWidgetResizable(True) |
|
topLayout.addWidget(sA) |
|
self.qtw = QTabWidget() |
|
sA.setWidget(self.qtw) |
|
self.single_join_tab = QWidget() |
|
self.schedule_tab = QWidget() |
|
self.qtw.addTab(self.single_join_tab, "Single Join") |
|
self.qtw.addTab(self.schedule_tab, "Multiple Join") |
|
|
|
# construct layout for scheduler |
|
sch_layout = QGridLayout() |
|
sch_layout.setSpacing(4) |
|
self.schedule_tab.setLayout(sch_layout) |
|
current_schedule_layout = QVBoxLayout() |
|
sch_label1 = QLabel("Current schedule: ") |
|
sch_label1.setSizePolicy( |
|
QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) |
|
self.sch_label2 = QLabel("None") |
|
current_schedule_layout.addWidget(sch_label1) |
|
current_schedule_layout.addWidget(self.sch_label2) |
|
self.sched_view = QTextEdit() |
|
self.sched_view.setReadOnly(True) |
|
self.sched_view.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap) |
|
current_schedule_layout.addWidget(self.sched_view) |
|
sch_layout.addLayout(current_schedule_layout, 0, 0, 1, 1) |
|
self.schedule_set_button = QPushButton('Choose schedule file') |
|
self.schedule_set_button.clicked.connect(self.selectSchedule) |
|
self.wallet_schedule_button = QPushButton('Schedule from wallet') |
|
self.wallet_schedule_button.clicked.connect(self.load_wallet_schedule) |
|
self.schedule_generate_button = QPushButton('Generate tumble schedule') |
|
self.schedule_generate_button.clicked.connect( |
|
self.generateTumbleSchedule) |
|
self.sch_startButton = QPushButton('Run schedule') |
|
# not runnable until schedule chosen |
|
self.sch_startButton.setEnabled(False) |
|
self.sch_startButton.clicked.connect(self.startMultiple) |
|
self.sch_abortButton = QPushButton('Abort') |
|
self.sch_abortButton.setEnabled(False) |
|
self.sch_abortButton.clicked.connect(self.abortTransactions) |
|
|
|
sch_buttons_box = QGroupBox("Actions") |
|
sch_buttons_layout = QVBoxLayout() |
|
sch_buttons_layout.addWidget(self.schedule_set_button) |
|
sch_buttons_layout.addWidget(self.wallet_schedule_button) |
|
sch_buttons_layout.addWidget(self.schedule_generate_button) |
|
sch_buttons_layout.addWidget(self.sch_startButton) |
|
sch_buttons_layout.addWidget(self.sch_abortButton) |
|
sch_buttons_box.setLayout(sch_buttons_layout) |
|
sch_layout.addWidget(sch_buttons_box, 0, 1, 1, 1) |
|
|
|
# construct layout for single joins |
|
innerTopLayout = QGridLayout() |
|
innerTopLayout.setSpacing(4) |
|
self.single_join_tab.setLayout(innerTopLayout) |
|
|
|
# Temporarily disabled |
|
# donateLayout = self.getDonateLayout() |
|
# innerTopLayout.addLayout(donateLayout, 0, 0, 1, 2) |
|
|
|
recipientLabel = QLabel('Recipient address / URI') |
|
recipientLabel.setToolTip( |
|
'The address or bitcoin: URI you want to send the payment to') |
|
self.addressInput = QLineEdit('') |
|
self.addressInput.editingFinished.connect( |
|
lambda: self.parseURIAndValidateAddress(self.addressInput.text())) |
|
innerTopLayout.addWidget(recipientLabel, 1, 0) |
|
innerTopLayout.addWidget(self.addressInput, 1, 1, 1, 2) |
|
|
|
self.numCPLabel = QLabel('Number of counterparties') |
|
self.numCPLabel.setToolTip( |
|
'How many other parties to send to; if you enter 4\n' + |
|
', there will be 5 participants, including you.\n' + |
|
'Enter 0 to send direct without coinjoin.') |
|
self.numCPInput = QLineEdit('9') |
|
self.numCPInput.setValidator(QIntValidator(0, 20)) |
|
innerTopLayout.addWidget(self.numCPLabel, 2, 0) |
|
innerTopLayout.addWidget(self.numCPInput, 2, 1, 1, 2) |
|
|
|
mixdepthLabel = QLabel('Mixdepth') |
|
mixdepthLabel.setToolTip( |
|
'The mixdepth of the wallet to send the payment from') |
|
self.mixdepthInput = QLineEdit('0') |
|
|
|
self.mixdepthInput.setValidator( |
|
QIntValidator(0, jmman.jmconf.mixdepth - 1)) |
|
innerTopLayout.addWidget(mixdepthLabel, 3, 0) |
|
innerTopLayout.addWidget(self.mixdepthInput, 3, 1, 1, 2) |
|
|
|
amountLabel = QLabel('Amount') |
|
amountLabel.setToolTip( |
|
'The amount to send.\n' + |
|
'If you enter 0, a SWEEP transaction\nwill be performed,' + |
|
' spending all the coins \nin the given mixdepth.') |
|
self.amountInput = BitcoinAmountEdit('') |
|
innerTopLayout.addWidget(amountLabel, 4, 0) |
|
innerTopLayout.addWidget(self.amountInput, 4, 1, 1, 2) |
|
|
|
changeLabel = QLabel('Custom change address') |
|
changeLabel.setToolTip( |
|
'Specify an address to receive change, rather ' + |
|
'than sending it to the internal wallet.') |
|
self.changeInput = QLineEdit() |
|
self.changeInput.editingFinished.connect( |
|
lambda: self.checkAddress(self.changeInput.text().strip())) |
|
self.changeInput.setPlaceholderText("(optional)") |
|
innerTopLayout.addWidget(changeLabel, 5, 0) |
|
innerTopLayout.addWidget(self.changeInput, 5, 1, 1, 2) |
|
|
|
self.startButton = QPushButton('Start') |
|
self.startButton.setToolTip( |
|
'If "checktx" is selected in the Settings, you will be \n' |
|
'prompted to decide whether to accept\n' |
|
'the transaction after connecting, and shown the\n' |
|
'fees to pay; you can cancel at that point, or by \n' |
|
'pressing "Abort".') |
|
self.startButton.clicked.connect(self.startSingle) |
|
self.abortButton = QPushButton('Abort') |
|
self.abortButton.setEnabled(False) |
|
buttons = QHBoxLayout() |
|
buttons.addStretch(1) |
|
buttons.addWidget(self.startButton) |
|
buttons.addWidget(self.abortButton) |
|
self.abortButton.clicked.connect(self.abortTransactions) |
|
innerTopLayout.addLayout(buttons, 6, 0, 1, 2) |
|
splitter1 = QSplitter(Qt.Orientation.Vertical) |
|
splitter1.addWidget(top) |
|
splitter1.addWidget(self.jm_dlg.log_view) |
|
splitter1.setSizes([400, 200]) |
|
self.setLayout(vbox) |
|
vbox.addWidget(splitter1) |
|
self.show() |
|
|
|
def load_wallet_schedule(self): |
|
schedule_txt = self.jmman.jmconf.get_schedule().encode('utf-8') |
|
schedule = [] |
|
schedule_lines = schedule_txt.splitlines() |
|
for sl in schedule_lines: |
|
parsed, res = parse_schedule_line(schedule, sl) |
|
if not parsed: |
|
JMQtMessageBox(self, "Not a valid JM schedule saved" |
|
" in the wallet", |
|
mbtype='crit', title='Error') |
|
self.logger.warning(f"Wrong schedule loaded from wallet: " |
|
f"error: {res}, data: {schedule_txt}." |
|
f" Clearning wrong schedule") |
|
self.jmman.jmconf.set_schedule('') |
|
return |
|
elif res is not None: |
|
schedule = res |
|
|
|
self.jm_dlg.statusBar.showMessage("Schedule loaded OK.") |
|
self.spendstate.loaded_schedule = schedule |
|
self.spendstate.schedule_name = 'saved in wallet' |
|
self.updateSchedView() |
|
reply = JMQtMessageBox(self, "An incomplete tumble run" |
|
" detected.\nDo you want to" |
|
" restart?", |
|
title="Restart detected", |
|
mbtype='question') |
|
if reply != QMessageBox.StandardButton.Yes: |
|
return |
|
self.tumbler_options = True |
|
|
|
def restartWaitWrap(self): |
|
if restart_wait(self.jmman, self.waitingtxid): |
|
self.restartTimer.stop() |
|
self.waitingtxid = None |
|
self.jm_dlg.statusBar.showMessage("Transaction in a block," |
|
" now continuing.") |
|
self.startJoin() |
|
|
|
def startJoin_callback(self): |
|
self.spendstate.waiting = False |
|
self.start_join_signal.emit() |
|
|
|
def startMultiple(self): |
|
jmman = self.jmman |
|
if not self.spendstate.runstate == 'ready': |
|
self.logger.info("Cannot start join, already running.") |
|
return |
|
if not self.spendstate.loaded_schedule: |
|
self.logger.info("Cannot start, no schedule loaded.") |
|
return |
|
|
|
self.spendstate.jmman_mixing() |
|
self.spendstate.updateType('multiple') |
|
self.spendstate.updateRun('running') |
|
|
|
if self.tumbler_options: |
|
# Uses the flag 'True' value from selectSchedule to recognize a |
|
# restart, which needs new dynamic option values. The rationale |
|
# for using input is in case the user can increase success |
|
# probability by changing them. |
|
if self.tumbler_options is True: |
|
wizard = TumbleRestartWizard(jmman) |
|
wizard_return = wizard.exec() |
|
if wizard_return == QDialog.DialogCode.Rejected: |
|
self.spendstate.reset() |
|
return |
|
self.tumbler_options = wizard.getOptions() |
|
# check for a partially-complete schedule; if so, |
|
# follow restart logic |
|
# 1. filter out complete: |
|
self.spendstate.loaded_schedule = [ |
|
s for s in self.spendstate.loaded_schedule if s[-1] != 1] |
|
# reload destination addresses |
|
self.tumbler_destaddrs = [x[3] for x |
|
in self.spendstate.loaded_schedule |
|
if x not in ["INTERNAL", "addrask"]] |
|
# 2 Check for unconfirmed |
|
if (isinstance(self.spendstate.loaded_schedule[0][-1], str) |
|
and len(self.spendstate.loaded_schedule[0][-1]) == 64): |
|
# ensure last transaction is confirmed before restart |
|
jmman.tumble_log.info("WAITING TO RESTART...") |
|
self.jm_dlg.statusBar.showMessage("Waiting for confirmation" |
|
" to restart..") |
|
txid = self.spendstate.loaded_schedule[0][-1] |
|
# remove the already-done entry (this connects to the other |
|
# TODO, probably better *not* to truncate the done-already |
|
# txs from file, but simplest for now. |
|
self.spendstate.loaded_schedule = \ |
|
self.spendstate.loaded_schedule[1:] |
|
# defers startJoin() call until tx seen on network. Note that |
|
# since we already updated state to running, user cannot |
|
# start another transactions while waiting. Also, use :0 |
|
# because it always exists |
|
self.waitingtxid = txid |
|
self.restartTimer.timeout.connect(self.restartWaitWrap) |
|
self.restartTimer.start(5000) |
|
self.updateSchedView() |
|
return |
|
self.updateSchedView() |
|
self.startJoin() |
|
|
|
def checkDirectSend(self, dtx, destaddr, amount, fee, custom_change_addr): |
|
res_fut = self.jmman.loop.create_future() |
|
self.check_direct_send_signal.emit(( |
|
res_fut, dtx, destaddr, amount, fee, custom_change_addr)) |
|
return res_fut |
|
|
|
def _checkDirectSend(self, args): |
|
"""Give user info to decide whether to accept a direct send; |
|
note the callback includes the full prettified transaction, |
|
but currently not printing it for space reasons. |
|
""" |
|
try: |
|
res_fut, dtx, destaddr, amount, fee, custom_change_addr = args |
|
mbinfo = ["Sending " + amount_to_str(amount) + ",", |
|
"to: " + destaddr + ","] |
|
|
|
if custom_change_addr: |
|
mbinfo.append("change to: " + custom_change_addr + ",") |
|
|
|
mbinfo += ["fee: " + amount_to_str(fee) + ".", |
|
"Accept?"] |
|
reply = JMQtMessageBox(self, |
|
'\n'.join([m + '<p>' for m in mbinfo]), |
|
mbtype='question', title="Direct send") |
|
if reply == QMessageBox.StandardButton.Yes: |
|
self.direct_send_amount = amount |
|
res_fut.set_result(True) |
|
else: |
|
res_fut.set_result(False) |
|
except Exception as e: |
|
res_fut.set_result(False) |
|
self.logger.info(f"Error in _checkDirectSend: {repr(e)}") |
|
|
|
def infoDirectSend(self, msg, txinfo): |
|
self.info_callback_signal.emit((msg, txinfo)) |
|
|
|
def _infoDirectSend(self, args): |
|
msg, txinfo = args |
|
destaddr = str(self.addressInput.text().strip()) |
|
if txinfo: |
|
if isinstance(txinfo, str): |
|
w = self.jmman.wallet |
|
txid = txinfo |
|
tx = w.adb.get_transaction(txid) |
|
else: |
|
tx = txinfo |
|
txid = tx.txid() |
|
self.clearFields(None) |
|
if not txinfo: |
|
self.giveUp() |
|
else: |
|
# since direct_send() assumes a one-shot processing, it does |
|
# not add a callback for confirmation, so that event could |
|
# get lost; we do that here to ensure that the confirmation |
|
# event is noticed: |
|
def qt_directsend_callback(rtxd, rtxid, confs): |
|
if rtxid == txid: |
|
return True |
|
return False |
|
jmman = self.jmman |
|
jmman.jmw.active_txs[txid] = tx |
|
jmman.jmw.wallet_service_register_callbacks( |
|
[qt_directsend_callback], txid, cb_type="confirmed") |
|
self.persistTxToHistory(destaddr, self.direct_send_amount, |
|
txid) |
|
self.cleanUp() |
|
JMQtMessageBox(self, msg, title="Success") |
|
|
|
def errorDirectSend(self, msg): |
|
self.error_callback_signal.emit((msg,)) |
|
|
|
def _errorDirectSend(self, args): |
|
msg = args[0] |
|
JMQtMessageBox(self, msg, mbtype="warn", title="Error") |
|
|
|
def startSingle_callback(self): |
|
self.spendstate.waiting = False |
|
self.start_single_signal.emit() |
|
|
|
def startSingle(self): |
|
jmman = self.jmman |
|
if not self.spendstate.runstate == 'ready': |
|
self.logger.info("Cannot start join, already running.") |
|
if not self.validateSingleSend(): |
|
return |
|
|
|
destaddr = str(self.addressInput.text().strip()) |
|
try: |
|
amount = amount_to_sat(self.amountInput.text()) |
|
except ValueError as e: |
|
JMQtMessageBox(self, f'{repr(e)}', title="Error", mbtype="warn") |
|
return |
|
makercount = int(self.numCPInput.text()) |
|
mixdepth = int(self.mixdepthInput.text()) |
|
|
|
if makercount == 0: |
|
need_cache, callback = self.cache_keypairs( |
|
self.startSingle_callback, tx_cnt=0) |
|
if need_cache: |
|
if callback: |
|
self.spendstate.waiting = True |
|
self.jm_dlg.statusBar.showMessage( |
|
"Caching keypairs to sign ...") |
|
else: |
|
self.logger.info('Need password to cache keypairs to sign') |
|
self.spendstate.waiting = False |
|
return |
|
custom_change = None |
|
if len(self.changeInput.text().strip()) > 0: |
|
custom_change = str(self.changeInput.text().strip()) |
|
coro = direct_send(jmman, mixdepth, |
|
[(destaddr, amount)], |
|
accept_callback=self.checkDirectSend, |
|
info_callback=self.infoDirectSend, |
|
error_callback=self.errorDirectSend, |
|
custom_change_addr=custom_change, |
|
return_transaction=True) |
|
asyncio.run_coroutine_threadsafe(coro, jmman.loop) |
|
return |
|
|
|
# for coinjoin sends no point to send below dust threshold, likely |
|
# there will be no makers for such amount. |
|
if amount != 0 and makercount > 0 and not self.checkAmount(amount): |
|
return |
|
|
|
if makercount < jmman.jmconf.minimum_makers: |
|
JMQtMessageBox(self, "Number of counterparties (" + str( |
|
makercount) + ") below minimum_makers (" + str( |
|
jmman.jmconf.minimum_makers) + |
|
") in configuration.", |
|
title="Error", mbtype="warn") |
|
return |
|
|
|
# note 'amount' is integer, so not interpreted as fraction |
|
# see notes in sample testnet schedule for format |
|
self.spendstate.loaded_schedule = [[mixdepth, amount, makercount, |
|
destaddr, 0, NO_ROUNDING, 0]] |
|
self.spendstate.jmman_mixing() |
|
self.spendstate.updateType('single') |
|
self.spendstate.updateRun('running') |
|
self.startJoin() |
|
|
|
def getMaxCJFees(self, relfee, absfee): |
|
""" Used as a callback to decide relative and absolute |
|
maximum fees for coinjoins, in cases where the user has not |
|
set these values in the config (which is the default).""" |
|
if relfee is None: |
|
relfee = get_default_max_relative_fee() |
|
if absfee is None: |
|
absfee = get_default_max_absolute_fee() |
|
msg = (f'Your maximum absolute fee from one counterparty has been ' |
|
f'set to: {absfee} satoshis.\nYour maximum relative fee from ' |
|
f'one counterparty has been set to: {relfee}.\nTo change ' |
|
f'these, please change the settings:\nmax_cj_fee_abs = ' |
|
f'your-value-in-satoshis\nmax_cj_fee_rel = ' |
|
f'your-value-as-decimal\nmax_cj_fee_confirmed to confirm ' |
|
f'values\nin the [OTHER] section.\nNote: If you don\'t do ' |
|
f'this, this dialog will interrupt the tumbler.') |
|
JMQtMessageBox(self, msg, mbtype="info", title="Setting fee limits.") |
|
return relfee, absfee |
|
|
|
def startJoin(self): |
|
schedule_len = len(list( |
|
filter(lambda x: x[-1] == 0, self.spendstate.loaded_schedule))) |
|
need_cache, callback = self.cache_keypairs(self.startJoin_callback, |
|
tx_cnt=schedule_len) |
|
if need_cache: |
|
if callback: |
|
self.spendstate.waiting = True |
|
self.jm_dlg.statusBar.showMessage( |
|
"Caching keypairs to sign ...") |
|
else: |
|
self.logger.info('Need password to cache keypairs to sign') |
|
self.spendstate.waiting = False |
|
return |
|
|
|
self.logger.debug('starting coinjoin ..') |
|
# Decide whether to interrupt processing to sanity check the fees |
|
jmman = self.jmman |
|
if self.tumbler_options: |
|
check_offers_callback = self.checkOffersTumbler |
|
elif GUIconf.checktx: |
|
check_offers_callback = self.checkOffers |
|
else: |
|
check_offers_callback = None |
|
|
|
destaddrs = self.tumbler_destaddrs if self.tumbler_options else [] |
|
custom_change = None |
|
if len(self.changeInput.text().strip()) > 0: |
|
custom_change = str(self.changeInput.text().strip()) |
|
jmman = self.jmman |
|
maxcjfee = get_max_cj_fee_values(jmman, |
|
user_callback=self.getMaxCJFees) |
|
self.logger.info("Using maximum coinjoin fee limits" |
|
" per maker of {:.4%}, {} ".format( |
|
maxcjfee[0], amount_to_str(maxcjfee[1]))) |
|
self.taker = Taker(jmman, |
|
self.spendstate.loaded_schedule, |
|
maxcjfee, |
|
order_chooser=fidelity_bond_weighted_order_choose, |
|
callbacks=[check_offers_callback, |
|
self.takerInfo, |
|
self.takerFinished], |
|
tdestaddrs=destaddrs, |
|
custom_change_address=custom_change, |
|
ignored_makers=jmman.jmw.ignored_makers) |
|
self.clientfactory = JMClientProtocolFactory(self.taker) |
|
jmman.set_client_factory(self.clientfactory) |
|
coro = self.clientfactory.getClient().clientStart() |
|
asyncio.run_coroutine_threadsafe(coro, self.jmman.loop) |
|
self.jm_dlg.statusBar.showMessage("Connecting to message channels ...") |
|
|
|
def takerInfo(self, infotype, infomsg): |
|
if not self.taker: |
|
return |
|
self.taker_info_signal.emit((infotype, infomsg)) |
|
|
|
def _takerInfo(self, args): |
|
infotype, infomsg = args |
|
if infotype == "INFO": |
|
# use of a dialog interrupts processing?, investigate. |
|
if len(infomsg) > 200: |
|
self.logger.info("INFO: " + infomsg) |
|
else: |
|
self.jm_dlg.statusBar.showMessage(infomsg) |
|
elif infotype == "ABORT": |
|
JMQtMessageBox(self, infomsg, |
|
mbtype='warn') |
|
# Abort signal explicitly means this transaction will not continue. |
|
self.abortTransactions() |
|
else: |
|
raise NotImplementedError |
|
|
|
def checkOffersTumbler(self, offers_fees, cjamount): |
|
return tumbler_filter_orders_callback( |
|
self.jmman, offers_fees, cjamount, self.taker) |
|
|
|
def checkOffers(self, offers_fee, cjamount): |
|
if not self.taker: |
|
return |
|
res_fut = self.jmman.loop.create_future() |
|
self.check_offers_signal.emit((res_fut, offers_fee, cjamount)) |
|
return res_fut |
|
|
|
def _checkOffers(self, args): |
|
"""Parse offers and total fee from client protocol, |
|
allow the user to agree or decide. |
|
""" |
|
try: |
|
res_fut, offers_fee, cjamount = args |
|
if self.taker.aborted: |
|
self.logger.debug("Not processing offers, user has aborted.") |
|
res_fut.set_result(False) |
|
return |
|
|
|
if not offers_fee: |
|
JMQtMessageBox(self, |
|
"Not enough matching offers found.", |
|
mbtype='warn', |
|
title="Error") |
|
self.giveUp() |
|
res_fut.set_result(False) |
|
return |
|
offers, total_cj_fee = offers_fee |
|
total_fee_pc = 1.0 * total_cj_fee / self.taker.cjamount |
|
|
|
mbinfo = [] |
|
mbinfo.append("Sending amount: " + |
|
amount_to_str(self.taker.cjamount)) |
|
mbinfo.append("to address: " + self.taker.my_cj_addr) |
|
mbinfo.append(" ") |
|
mbinfo.append("Counterparties chosen:") |
|
mbinfo.append('Name, Order id, Coinjoin fee (sat.)') |
|
for k, o in offers.items(): |
|
if o['ordertype'] in ['sw0reloffer', 'swreloffer', 'reloffer']: |
|
display_fee = int(self.taker.cjamount * |
|
float(o['cjfee'])) - int(o['txfee']) |
|
elif o['ordertype'] in ['sw0absoffer', 'swabsoffer', |
|
'absoffer']: |
|
display_fee = int(o['cjfee']) - int(o['txfee']) |
|
else: |
|
self.logger.debug("Unsupported order type: " + |
|
str(o['ordertype']) + ", aborting.") |
|
self.giveUp() |
|
res_fut.set_result(False) |
|
return |
|
mbinfo.append(k + ', ' + str(o['oid']) + ', ' + str( |
|
display_fee)) |
|
mbinfo.append('Total coinjoin fee = ' + |
|
amount_to_str(total_cj_fee) + |
|
', or ' + |
|
str(float('%.3g' % (100.0 * total_fee_pc))) + '%') |
|
title = 'Check Transaction' |
|
if total_fee_pc * 100 > GUIconf.check_high_fee: |
|
title += ': WARNING: Fee is HIGH!!' |
|
reply = JMQtMessageBox(self, |
|
'\n'.join([m + '<p>' for m in mbinfo]), |
|
mbtype='question', |
|
title=title) |
|
if reply == QMessageBox.StandardButton.Yes: |
|
# amount is now accepted; |
|
# The user is now committed to the transaction |
|
self.abortButton.setEnabled(False) |
|
res_fut.set_result(True) |
|
return |
|
else: |
|
self.filter_offers_response = "REJECT" |
|
self.giveUp() |
|
res_fut.set_result(False) |
|
return |
|
except Exception as e: |
|
res_fut.set_result(False) |
|
self.logger.info(f"Error in _checkOffers: {repr(e)}") |
|
|
|
def startNextTransaction(self): |
|
coro = self.clientfactory.getClient().clientStart() |
|
asyncio.run_coroutine_threadsafe(coro, self.jmman.loop) |
|
|
|
def takerFinished(self, res, fromtx=False, waittime=0.0, txdetails=None): |
|
if not self.taker: |
|
return |
|
self.taker_finished_signal.emit((res, fromtx, waittime, txdetails)) |
|
|
|
def _takerFinished(self, args): |
|
"""Callback (after pass-through signal) for jmclient.Taker |
|
on completion of each join transaction. |
|
""" |
|
res, fromtx, waittime, txdetails = args |
|
# non-GUI-specific state updates first: |
|
if self.tumbler_options: |
|
jmman = self.jmman |
|
tumble_log = jmman.tumble_log |
|
tumbler_taker_finished_update(self.taker, tumble_log, |
|
self.tumbler_options, res, |
|
fromtx, |
|
waittime, |
|
txdetails) |
|
|
|
self.spendstate.loaded_schedule = self.taker.schedule |
|
# Shows the schedule updates in the GUI; TODO make this more visual |
|
if self.spendstate.typestate == 'multiple': |
|
self.updateSchedView() |
|
|
|
# GUI-specific updates; QTimer.singleShot serves the role |
|
# of reactor.callLater |
|
if fromtx == "unconfirmed": |
|
self.jm_dlg.statusBar.showMessage( |
|
"Transaction seen on network: " + self.taker.txid) |
|
if self.spendstate.typestate == 'single': |
|
self.clearFields(None) |
|
JMQtMessageBox(self, "Transaction broadcast OK. You can" |
|
" safely \nshut down if you don't want" |
|
" to wait.", |
|
title="Success") |
|
# TODO: theoretically possible to miss this if confirmed event |
|
# seen before unconfirmed. |
|
self.persistTxToHistory(self.taker.my_cj_addr, self.taker.cjamount, |
|
self.taker.txid) |
|
|
|
# TODO prob best to completely fold multiple and tumble to reduce |
|
# complexity/duplication |
|
if (self.spendstate.typestate == 'multiple' |
|
and not self.tumbler_options): |
|
self.taker.jmman.wallet.save_db() # FIXME? |
|
return |
|
if fromtx: |
|
if res: |
|
self.jm_dlg.statusBar.showMessage("Transaction confirmed: " + |
|
self.taker.txid) |
|
# singleShot argument is in milliseconds |
|
if self.nextTxTimer: |
|
self.nextTxTimer.stop() |
|
self.nextTxTimer = QTimer() |
|
self.nextTxTimer.setSingleShot(True) |
|
self.nextTxTimer.timeout.connect(self.startNextTransaction) |
|
self.nextTxTimer.start(int(waittime*60*1000)) |
|
# QTimer.singleShot(int(self.taker_finished_waittime*60*1000), |
|
# self.startNextTransaction) |
|
else: |
|
if self.tumbler_options: |
|
self.jm_dlg.statusBar.showMessage("Transaction failed," |
|
" trying again...") |
|
QTimer.singleShot(0, self.startNextTransaction) |
|
else: |
|
# currently does not continue for non-tumble schedules |
|
self.giveUp() |
|
else: |
|
if res: |
|
self.jm_dlg.statusBar.showMessage("All transaction(s)" |
|
" completed successfully.") |
|
if len(self.taker.schedule) == 1: |
|
msg = "Transaction has been confirmed.\n" + "Txid: " + \ |
|
str(self.taker.txid) |
|
else: |
|
msg = "All transactions have been confirmed." |
|
JMQtMessageBox(self, msg, title="Success") |
|
self.cleanUp() |
|
else: |
|
self.giveUp() |
|
|
|
def persistTxToHistory(self, addr, amt, txid): |
|
# persist the transaction to history |
|
now = round(datetime.datetime.now().timestamp()) |
|
self.jmman.jmw.add_jm_tx(txid, addr, amt, now) |
|
# update the TxHistory tab |
|
self.jm_dlg.history_tab.updateTxInfo() |
|
|
|
def toggleButtons(self): |
|
"""Refreshes accessibility of buttons in the (single, multiple) join |
|
tabs based on the current state as defined by the SpendStateMgr |
|
instance. Thus, should always be called on any update to that instance. |
|
""" |
|
# The first two buttons are for the single join tab; the remaining 4 |
|
# are for the multijoin tab. |
|
btns = (self.startButton, self.abortButton, |
|
self.schedule_set_button, self.wallet_schedule_button, |
|
self.schedule_generate_button, self.sch_startButton, |
|
self.sch_abortButton) |
|
if self.spendstate.runstate == 'ready': |
|
btnsettings = (True, False, True, True, True, True, False) |
|
elif self.spendstate.runstate == 'waiting': |
|
btnsettings = (False, False, False, False, False, False, False) |
|
elif self.spendstate.runstate == 'running': |
|
if self.spendstate.typestate == 'single': |
|
# can only abort current run, nothing else |
|
btnsettings = (False, True, False, False, False, False, False) |
|
elif self.spendstate.typestate == 'multiple': |
|
btnsettings = (False, False, False, False, False, False, True) |
|
else: |
|
assert False |
|
else: |
|
assert False |
|
|
|
for b, s in zip(btns, btnsettings): |
|
b.setEnabled(s) |
|
|
|
def abortTransactions(self): |
|
jmman = self.jmman |
|
coro = jmman.jmw.cleanup_keypairs() |
|
asyncio.run_coroutine_threadsafe(coro, jmman.loop) |
|
self.taker.aborted = True |
|
self.giveUp() |
|
|
|
def giveUp(self): |
|
"""Inform the user that the transaction failed, then reset state. |
|
""" |
|
self.logger.debug("Transaction aborted.") |
|
self.jm_dlg.statusBar.showMessage("Transaction aborted.") |
|
if self.taker and len(self.taker.ignored_makers) > 0: |
|
JMQtMessageBox(self, "These Makers did not respond, and will be \n" |
|
"ignored in future: \n" + str( |
|
','.join(self.taker.ignored_makers)), |
|
title="Transaction aborted") |
|
self.jmman.jmw.ignored_makers.extend(self.taker.ignored_makers) |
|
self.cleanUp() |
|
|
|
def cleanUp(self): |
|
"""Reset state to 'ready' |
|
""" |
|
# Qt specific: because schedules can restart in same app instance, |
|
# we must clean up any existing delayed actions via singleShot. |
|
# Currently this should only happen via self.abortTransactions. |
|
if self.nextTxTimer: |
|
self.nextTxTimer.stop() |
|
self.spendstate.reset() |
|
self.tumbler_options = None |
|
self.tumbler_destaddrs = None |
|
|
|
def validateSingleSend(self): |
|
if len(self.addressInput.text()) == 0: |
|
JMQtMessageBox( |
|
self, |
|
"Recipient address must be provided.", |
|
mbtype='warn', title="Error") |
|
return False |
|
|
|
valid = bitcoin.is_address(str(self.addressInput.text().strip())) |
|
if not valid: |
|
JMQtMessageBox(self, 'Invalid address', |
|
mbtype='warn', title="Error") |
|
return False |
|
if len(self.numCPInput.text()) == 0: |
|
JMQtMessageBox( |
|
self, |
|
"Number of counterparties must be provided. Enter '0'" |
|
" to do a direct send instead of a CoinJoin.", |
|
mbtype='warn', title="Error") |
|
return False |
|
if len(self.mixdepthInput.text()) == 0: |
|
JMQtMessageBox( |
|
self, |
|
"Mixdepth must be chosen.", |
|
mbtype='warn', title="Error") |
|
return False |
|
if len(self.amountInput.text()) == 0: |
|
JMQtMessageBox( |
|
self, |
|
"Amount, in bitcoins, must be provided.", |
|
mbtype='warn', title="Error") |
|
return False |
|
jmman = self.jmman |
|
if len(self.changeInput.text().strip()) != 0: |
|
dest_addr = str(self.addressInput.text().strip()) |
|
change_addr = str(self.changeInput.text().strip()) |
|
makercount = int(self.numCPInput.text()) |
|
try: |
|
amount = amount_to_sat(self.amountInput.text()) |
|
except ValueError as e: |
|
JMQtMessageBox(self, f'{repr(e)}', title="Error", |
|
mbtype="warn") |
|
return False |
|
valid = bitcoin.is_address(change_addr) |
|
if not valid: |
|
JMQtMessageBox(self, "Custom change address is invalid.", |
|
mbtype='warn', title="Error") |
|
return False |
|
|
|
if change_addr == dest_addr: |
|
msg = ''.join(["Custom change address cannot be the ", |
|
"same as the recipient address."]) |
|
JMQtMessageBox(self, |
|
msg, |
|
mbtype='warn', title="Error") |
|
return False |
|
if amount == 0: |
|
JMQtMessageBox(self, sweep_custom_change_warning, |
|
mbtype='warn', title="Error") |
|
return False |
|
if makercount > 0: |
|
reply = JMQtMessageBox(self, general_custom_change_warning, |
|
mbtype='question', title="Warning") |
|
if reply == QMessageBox.StandardButton.No: |
|
return False |
|
|
|
if makercount > 0: |
|
change_addr_type = guess_address_script_type(change_addr) |
|
wallet_type = jmman.wallet.get_txin_type() |
|
if change_addr_type != wallet_type: |
|
reply = JMQtMessageBox( |
|
self, nonwallet_custom_change_warning, |
|
mbtype='question', |
|
title="Warning" |
|
) |
|
if reply == QMessageBox.StandardButton.No: |
|
return False |
|
|
|
return True |
|
|
|
|
|
class CoinsTab(QWidget): |
|
|
|
def __init__(self, jm_dlg): |
|
super().__init__() |
|
self.jm_dlg = jm_dlg |
|
self.jmman = jm_dlg.jmman |
|
self.logger = self.jmman.logger |
|
self.initUI() |
|
|
|
def initUI(self): |
|
self.cTW = v = MyTreeWidget(self, self.create_menu, self.getHeaders()) |
|
self.cTW.setSelectionMode( |
|
QAbstractItemView.SelectionMode.ExtendedSelection) |
|
self.cTW.header().setSectionResizeMode( |
|
QHeaderView.ResizeMode.Interactive) |
|
self.cTW.header().setStretchLastSection(False) |
|
self.cTW.on_update = self.updateUtxos |
|
v.header().resizeSection(0, 400) |
|
v.header().resizeSection(1, 130) |
|
v.header().resizeSection(2, 400) |
|
|
|
vbox = QVBoxLayout() |
|
self.setLayout(vbox) |
|
vbox.setContentsMargins(0, 0, 0, 0) |
|
vbox.setSpacing(0) |
|
vbox.addWidget(self.cTW) |
|
self.updateUtxos() |
|
self.show() |
|
|
|
def getHeaders(self): |
|
'''Function included in case dynamic in future''' |
|
return ['Txid:n', 'Amount in BTC', 'Address', 'Label'] |
|
|
|
def updateUtxos(self): |
|
""" Note that this refresh of the display only accesses in-process |
|
utxo database (no sync e.g.) so can be immediate. |
|
""" |
|
jmman = self.jmman |
|
if not jmman.enabled: |
|
return |
|
self.cTW.clear() |
|
|
|
def show_blank(): |
|
m_item = QTreeWidgetItem(["No coins", "", "", ""]) |
|
self.cTW.addChild(m_item) |
|
self.cTW.show() |
|
|
|
utxos_enabled = {} |
|
utxos_disabled = {} |
|
for i in range(GUIconf.max_mix_depth): |
|
utxos_e, utxos_d = jmman.jmw.get_utxos_enabled_disabled(i) |
|
if utxos_e != {}: |
|
utxos_enabled[i] = utxos_e |
|
if utxos_d != {}: |
|
utxos_disabled[i] = utxos_d |
|
if utxos_enabled == {} and utxos_disabled == {}: |
|
show_blank() |
|
return |
|
|
|
for i in range(GUIconf.max_mix_depth): |
|
uem = utxos_enabled.get(i) |
|
udm = utxos_disabled.get(i) |
|
m_item = QTreeWidgetItem(["Mixdepth " + str(i), '', '', '']) |
|
self.cTW.addChild(m_item) |
|
for heading in ["NOT FROZEN", "FROZEN"]: |
|
um = uem if heading == "NOT FROZEN" else udm |
|
seq_item = QTreeWidgetItem([heading, '', '', '']) |
|
m_item.addChild(seq_item) |
|
seq_item.setExpanded(True) |
|
if um is None: |
|
item = QTreeWidgetItem(['None', '', '', '']) |
|
seq_item.addChild(item) |
|
else: |
|
for k, v in um.items(): |
|
# txid:index, btc, address |
|
s = "{0:.08f}".format(v['value']/1e8) |
|
a = v['address'] |
|
item = QTreeWidgetItem([k, s, a, v["label"]]) |
|
item.setFont(0, QFont(MONOSPACE_FONT)) |
|
seq_item.addChild(item) |
|
m_item.setExpanded(True) |
|
|
|
def toggle_utxo_disable(self, txids, idxs): |
|
w = self.jmman.wallet |
|
for i in range(0, len(txids)): |
|
prevout_txid = txids[i] |
|
prevout_idx = idxs[i] |
|
prevout_str = f'{prevout_txid}:{prevout_idx}' |
|
tx = w.adb.get_transaction(prevout_txid) |
|
if not tx: |
|
continue |
|
tx_output = tx.outputs()[prevout_idx] |
|
addr = tx_output.address |
|
coins = w.adb.get_utxos(domain=[addr]) |
|
coin = None |
|
for c in coins: |
|
if c.prevout.to_str() == prevout_str: |
|
coin = c |
|
break |
|
if not coin: |
|
continue |
|
is_frozen = w.is_frozen_coin(coin) |
|
self.jm_dlg.mwin.set_frozen_state_of_coins([coin], not is_frozen) |
|
self.updateUtxos() |
|
|
|
def create_menu(self, position): |
|
# all selected items |
|
selected_items = self.cTW.selectedItems() |
|
txids = [] |
|
idxs = [] |
|
if len(selected_items) == 0: |
|
return |
|
try: |
|
for item in selected_items: |
|
if ':' not in item.text(0): |
|
continue |
|
txid, idx = item.text(0).split(":") |
|
assert len(txid) == 64 |
|
idx = int(idx) |
|
assert idx >= 0 |
|
txids.append(txid) |
|
idxs.append(idx) |
|
except Exception as e: |
|
self.logger.error("Error retrieving txids in Coins tab: " + |
|
repr(e)) |
|
return |
|
# current item |
|
item = self.cTW.currentItem() |
|
if ':' not in item.text(0): |
|
return |
|
txid, idx = item.text(0).split(":") |
|
|
|
menu = QMenu() |
|
menu.addAction("Freeze/un-freeze utxo(s) (toggle)", |
|
lambda: self.toggle_utxo_disable(txids, idxs)) |
|
menu.addAction("Copy transaction id to clipboard", |
|
lambda: QApplication.clipboard().setText(txid)) |
|
menu.exec(self.cTW.viewport().mapToGlobal(position)) |
|
|
|
|
|
class TxHistoryTab(QWidget): |
|
|
|
def __init__(self, jm_dlg): |
|
super().__init__() |
|
self.jm_dlg = jm_dlg |
|
self.jmman = jm_dlg.jmman |
|
self.logger = self.jmman.logger |
|
self.initUI() |
|
|
|
def initUI(self): |
|
self.tHTW = MyTreeWidget(self, self.create_menu, self.getHeaders()) |
|
self.tHTW.setSelectionMode( |
|
QAbstractItemView.SelectionMode.ExtendedSelection) |
|
self.tHTW.header().setSectionResizeMode( |
|
QHeaderView.ResizeMode.Interactive) |
|
self.tHTW.header().setStretchLastSection(False) |
|
self.tHTW.on_update = self.updateTxInfo |
|
vbox = QVBoxLayout() |
|
self.setLayout(vbox) |
|
vbox.setContentsMargins(0, 0, 0, 0) |
|
vbox.setSpacing(0) |
|
vbox.addWidget(self.tHTW) |
|
self.updateTxInfo() |
|
self.show() |
|
|
|
def getHeaders(self): |
|
'''Function included in case dynamic in future''' |
|
return ['Receiving address', 'Amount in BTC', 'Transaction id', 'Date'] |
|
|
|
def updateTxInfo(self, txinfo=None): |
|
self.tHTW.clear() |
|
if not self.jmman.enabled: |
|
return |
|
if not txinfo: |
|
txinfo = self.getTxInfoFromHistory() |
|
for t in txinfo: |
|
t_item = QTreeWidgetItem(t) |
|
self.tHTW.addChild(t_item) |
|
for i in range(4): |
|
self.tHTW.resizeColumnToContents(i) |
|
|
|
def getTxInfoFromHistory(self): |
|
txhist = [] |
|
jm_txs = self.jmman.jmw.get_jm_txs() |
|
for txid, (address, amount, date) in jm_txs.items(): |
|
txhist.append((address, amount, txid, date)) |
|
txhist.sort(key=lambda x: x[3]) |
|
return [(address, str(amount), txid, |
|
datetime.datetime.fromtimestamp(date).strftime(DATE_FORMAT)) |
|
for address, amount, txid, date in txhist] |
|
|
|
def create_menu(self, position): |
|
item = self.tHTW.currentItem() |
|
if not item: |
|
return |
|
address_valid = False |
|
if item: |
|
address = str(item.text(0)) |
|
address_valid = bitcoin.is_address(address) |
|
|
|
menu = QMenu() |
|
if address_valid: |
|
menu.addAction("Copy address to clipboard", |
|
lambda: QApplication.clipboard().setText(address)) |
|
menu.addAction("Copy transaction id to clipboard", |
|
lambda: QApplication.clipboard().setText( |
|
str(item.text(2)))) |
|
menu.addAction("Copy full transaction info to clipboard", |
|
lambda: QApplication.clipboard().setText( |
|
','.join([str(item.text(_)) for _ in range(4)]))) |
|
menu.exec(self.tHTW.viewport().mapToGlobal(position)) |
|
|
|
|
|
class SettingsTab(QWidget): |
|
|
|
def __init__(self, jm_dlg): |
|
super().__init__() |
|
self.jm_dlg = jm_dlg |
|
self.jmman = jm_dlg.jmman |
|
self.logger = self.jmman.logger |
|
self.settings_grid = None |
|
self.constructUI() |
|
|
|
def constructUI(self, *, update=False): |
|
jmman = self.jmman |
|
jmconf = jmman.jmconf |
|
if update and self.settings_grid is not None: |
|
for i in reversed(range(self.settings_grid.count())): |
|
self.settings_grid.itemAt(i).widget().setParent(None) |
|
else: |
|
self.outerGrid = QGridLayout() |
|
self.warn_ex_label = WarnExLabel(jmman, self.jm_dlg) |
|
self.outerGrid.addWidget(self.warn_ex_label, 0, 0, 1, -1) |
|
|
|
# subscribe_spent |
|
sub_spent_cb = QCheckBox(jmconf.subscribe_spent_data()) |
|
sub_spent_cb.setChecked(jmconf.subscribe_spent) |
|
|
|
def on_sub_spent_state_changed(x): |
|
jmconf.subscribe_spent = (Qt.CheckState(x) == |
|
Qt.CheckState.Checked) |
|
|
|
sub_spent_cb.stateChanged.connect(on_sub_spent_state_changed) |
|
self.outerGrid.addWidget(sub_spent_cb, 1, 0, 1, -1) |
|
|
|
b = QPushButton(_('Reset to defaults')) |
|
b.clicked.connect(self.reset_to_defaults) |
|
self.outerGrid.addLayout(Buttons(b), 2, 0) |
|
|
|
sA = QScrollArea() |
|
sA.setWidgetResizable(True) |
|
frame = QFrame() |
|
self.settings_grid = grid = QGridLayout() |
|
|
|
if not jmman.enabled: |
|
self.outerGrid.addWidget(sA, 3, 0) |
|
|
|
sA.setWidget(frame) |
|
frame.setLayout(grid) |
|
frame.adjustSize() |
|
if not update: |
|
self.setLayout(self.outerGrid) |
|
self.show() |
|
return |
|
|
|
self.msg_channels = self.jmman.jmconf.get_msg_channels() |
|
self.settingsFields = [] |
|
j = 0 |
|
for i, section in enumerate(conf_sections): |
|
if section == 'MESSAGING': |
|
index_map = {v: i for i, v in |
|
enumerate(['onion', 'irc1', 'irc2'])} |
|
sorted_sections = sorted( |
|
self.msg_channels.items(), |
|
key=lambda pair: index_map.get(pair[0], 1e5)) |
|
for subsection, msg_channel in sorted_sections: |
|
if subsection == 'onion': |
|
opts_names = ['enabled', 'type', 'socks5_host', |
|
'socks5_port', 'directory_nodes'] |
|
else: |
|
opts_names = ['enabled', 'type', 'channel', 'host', |
|
'port', 'usessl', 'socks5', |
|
'socks5_host', 'socks5_port'] |
|
index_map = {v: i for i, v in |
|
enumerate(opts_names)} |
|
names = list(msg_channel.keys()) |
|
subsection_name = f'{section}:{subsection}' |
|
newSettingsFields = self.getSettingsFields(subsection_name, |
|
names) |
|
newSettingsFields = sorted( |
|
newSettingsFields, |
|
key=lambda pair: index_map.get(pair[0].text(), 1e5)) |
|
self.settingsFields.extend(newSettingsFields) |
|
sL = QLabel(subsection_name) |
|
sL.setStyleSheet("QLabel {color: green;}") |
|
grid.addWidget(sL) |
|
j += 1 |
|
for k, ns in enumerate(newSettingsFields): |
|
grid.addWidget(ns[0], j, 0) |
|
# try to find the tooltip for this label |
|
# it might not be there |
|
if str(ns[0].text()) in config_tips: |
|
ttS = config_tips[str(ns[0].text())] |
|
ns[0].setToolTip(ttS) |
|
grid.addWidget(ns[1], j, 1) |
|
sfindex = (len(self.settingsFields) - |
|
len(newSettingsFields) + k) |
|
if isinstance(ns[1], QCheckBox): |
|
ns[1].toggled.connect( |
|
lambda checked, s=subsection_name, q=sfindex: |
|
self.handleEdit(s, self.settingsFields[q], |
|
checked)) |
|
else: |
|
ns[1].editingFinished.connect( |
|
lambda q=sfindex, s=subsection_name: |
|
self.handleEdit(s, self.settingsFields[q])) |
|
j += 1 |
|
else: |
|
names = conf_names[section] |
|
newSettingsFields = self.getSettingsFields(section, names) |
|
self.settingsFields.extend(newSettingsFields) |
|
sL = QLabel(section) |
|
sL.setStyleSheet("QLabel {color: green;}") |
|
grid.addWidget(sL) |
|
j += 1 |
|
for k, ns in enumerate(newSettingsFields): |
|
grid.addWidget(ns[0], j, 0) |
|
# try to find the tooltip for this label from config tips; |
|
# it might not be there |
|
if str(ns[0].text()) in config_tips: |
|
ttS = config_tips[str(ns[0].text())] |
|
ns[0].setToolTip(ttS) |
|
grid.addWidget(ns[1], j, 1) |
|
sfindex = (len(self.settingsFields) - |
|
len(newSettingsFields) + k) |
|
if isinstance(ns[1], QCheckBox): |
|
ns[1].toggled.connect( |
|
lambda checked, s=section, q=sfindex: |
|
self.handleEdit(s, self.settingsFields[q], |
|
checked)) |
|
else: |
|
ns[1].editingFinished.connect( |
|
lambda q=sfindex, s=section: |
|
self.handleEdit(s, self.settingsFields[q])) |
|
j += 1 |
|
self.outerGrid.addWidget(sA, 3, 0) |
|
|
|
sA.setWidget(frame) |
|
frame.setLayout(grid) |
|
frame.adjustSize() |
|
if not update: |
|
self.setLayout(self.outerGrid) |
|
self.show() |
|
|
|
def reset_to_defaults(self): |
|
reply = JMQtMessageBox(self, |
|
"Do you want to reset all settings" |
|
" to default values?", |
|
title="Confirm reset settings", |
|
mbtype='question') |
|
if reply != QMessageBox.StandardButton.Yes: |
|
return |
|
conf_keys = set() |
|
for section, keys in conf_names.items(): |
|
conf_keys |= set(keys) |
|
jmconf = self.jmman.jmconf |
|
jmconf.reset_to_defaults(conf_keys) |
|
jmconf.reset_mincjamount() |
|
self.constructUI(update=True) |
|
|
|
def handleEdit(self, section, t, checked=None): |
|
global GUIconf |
|
jmman = self.jmman |
|
sBar = self.jm_dlg.statusBar |
|
if section.startswith('MESSAGING'): |
|
subsection = section.split(':')[-1] |
|
oname = str(t[0].text()) |
|
config_types.get(oname) |
|
if isinstance(t[1], QCheckBox): |
|
oval = checked |
|
else: |
|
oval = t[1].text() |
|
if oname in ["port", 'socks5_port']: |
|
oval = int(oval) |
|
self.msg_channels[subsection][oname] = oval |
|
self.jmman.jmconf.set_msg_channels(self.msg_channels) |
|
else: |
|
oname = str(t[0].text()) |
|
if isinstance(t[1], QCheckBox): |
|
setattr(jmman.jmconf, oname, bool(checked)) |
|
oval = str(getattr(jmman.jmconf, oname)) |
|
else: |
|
config_types.get(oname) |
|
otype = config_types.get(oname) or int |
|
oval = t[1].text() |
|
try: |
|
if section == 'GUI': |
|
setattr(GUIconf, oname, otype(oval)) |
|
val = str(getattr(GUIconf, oname)) |
|
else: |
|
setattr(jmman.jmconf, oname, otype(oval)) |
|
val = str(getattr(jmman.jmconf, oname)) |
|
if val != oval: |
|
t[1].setText(val) |
|
if oname == 'tx_fees': |
|
targets = sorted(FEE_ETA_TARGETS) |
|
msg = (f'tx_fees is set to closest high value' |
|
f' {val} from electrum' |
|
f' FEE_ETA_TARGETS {targets}') |
|
sBar.showMessage(msg) |
|
except Exception as e: |
|
sBar.showMessage(f'{oname} can not be persisted: {e}') |
|
if oname == 'mixdepth': |
|
self.jm_dlg.wallet_tab.updateWalletInfo() |
|
self.jm_dlg.coins_tab.updateUtxos() |
|
self.logger.debug(f'setting section: {section}, name: {oname}' |
|
f' to: {oval}') |
|
|
|
def getSettingsFields(self, section, names): |
|
results = [] |
|
jmman = self.jmman |
|
if section.startswith('MESSAGING:'): |
|
subsection = section.split(':')[-1] |
|
for name, val in self.msg_channels[subsection].items(): |
|
if name == 'type': |
|
continue |
|
t = config_types.get(name, type(None)) |
|
if t == int: |
|
sf = QLineEdit(str(val)) |
|
if name in ["port", 'socks5_port']: |
|
sf.setValidator(JMIntValidator(1, 65535)) |
|
elif t == bool: |
|
sf = QCheckBox() |
|
sf.setChecked(val) |
|
else: |
|
sf = QLineEdit(str(val)) |
|
results.append((QLabel(name), sf)) |
|
return results |
|
for name in names: |
|
val = None |
|
for n in conf_names[section]: |
|
if n == name: |
|
if section == 'GUI': |
|
val = str(getattr(GUIconf, name)) |
|
else: |
|
val = str(getattr(jmman.jmconf, name)) |
|
break |
|
if name in config_types: |
|
t = config_types[name] |
|
if t == bool: |
|
sf = QCheckBox() |
|
checked = True if val == 'True' else False |
|
sf.setChecked(checked) |
|
elif t == 'amount': |
|
sf = BitcoinAmountEdit(val) |
|
elif t: |
|
sf = QLineEdit(val) |
|
if t == int: |
|
if name == "tx_fees": |
|
# must account for both tx_fees settings type, |
|
# and we set upper limit well above default absurd |
|
# check just in case a high value is needed: |
|
sf.setValidator(JMIntValidator(1, 1000000)) |
|
else: |
|
continue |
|
else: |
|
sf = QLineEdit(val) |
|
results.append((QLabel(name), sf)) |
|
return results |
|
|
|
|
|
def get_wallet_printout(jmman): |
|
"""Given a WalletService object, retrieve the list of |
|
addresses and corresponding balances to be displayed. |
|
We retrieve a WalletView abstraction, and iterate over |
|
sub-objects to arrange the per-mixdepth and per-address lists. |
|
The format of the returned data is: |
|
rows: is of format [[[addr,index,bal,status,label],[addr,...]]*5, |
|
[[addr, index,..], [addr, index..]]*5] |
|
mbalances: is a simple array of 5 mixdepth balances |
|
xpubs: [[xpubext, xpubint], ...] |
|
Bitcoin amounts returned are in btc, not satoshis |
|
""" |
|
walletview = wallet_display(jmman, False, serialized=False) |
|
rows = [] |
|
mbalances = [] |
|
xpubs = [] |
|
for j, acct in enumerate(walletview.children): |
|
mbalances.append(acct.get_fmt_balance()) |
|
rows.append([]) |
|
xpubs.append([]) |
|
for i, branch in enumerate(acct.children): |
|
xpubs[j].append(branch.xpub) |
|
rows[j].append([]) |
|
for entry in branch.children: |
|
rows[-1][i].append([entry.serialize_address(), |
|
entry.serialize_wallet_position(), |
|
entry.serialize_amounts(), |
|
entry.serialize_status(), |
|
entry.serialize_label()]) |
|
# Append the account xpub to the end of the list |
|
account_xpub = acct.xpub |
|
xpubs[j].append(account_xpub) |
|
total_bal = walletview.get_fmt_balance() |
|
return (rows, mbalances, xpubs, total_bal)
|
|
|