#!/usr/bin/env python ''' Qt files for the wizard for initiating a tumbler run. This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . ''' import math, logging, qrcode, re, string from io import BytesIO from PySide2 import QtCore from PySide2.QtGui import * from PySide2.QtWidgets import * from bitcointx.core import satoshi_to_coins from jmbitcoin.amount import amount_to_sat, btc_to_sat, sat_to_str from jmbitcoin.bip21 import decode_bip21_uri from jmclient import (jm_single, validate_address, get_tumble_schedule) GREEN_BG = "QWidget {background-color:#80ff80;}" RED_BG = "QWidget {background-color:#ffcccc;}" RED_FG = "QWidget {color:red;}" BLUE_FG = "QWidget {color:blue;}" BLACK_FG = "QWidget {color:black;}" donation_address = 'Currently disabled' donation_address_testnet = 'Currently disabled' #TODO legacy, remove or change warnings = {"blockr_privacy": """You are using blockr as your method of connecting to the blockchain; this means that blockr.com can see the addresses you query. This is bad for privacy - consider using a Bitcoin Core node instead."""} #configuration types config_types = {'rpc_port': int, 'network': bool, 'checktx': bool, 'socks5_port': int, 'maker_timeout_sec': int, 'tx_fees': int, 'gaplimit': int, 'check_high_fee': int, 'max_mix_depth': int, 'order_wait_time': int, 'no_daemon': int, 'daemon_port': int, 'absurd_fee_per_kb': 'amount'} config_tips = { 'blockchain_source': 'options: bitcoin-rpc, regtest (for testing)', 'network': 'one of "signet", "testnet" or "mainnet"', 'checktx': 'whether to check fees before completing transaction', 'rpc_host': 'the host for bitcoind; only used if blockchain_source is bitcoin-rpc', 'rpc_port': 'port for connecting to bitcoind over rpc', 'rpc_user': 'user for connecting to bitcoind over rpc', 'rpc_password': 'password for connecting to bitcoind over rpc', 'host': 'hostname for IRC server', 'channel': 'channel name on IRC server', 'port': 'port for connecting to IRC server', 'usessl': "'true'/'false' to use SSL for each connection to IRC\n", 'socks5': "'true'/'false' to use a SOCKS5 proxy for each connection", 'socks5_host': 'host for SOCKS5 proxy', 'socks5_port': 'port for SOCKS5 proxy', 'maker_timeout_sec': 'timeout for waiting for replies from makers', 'merge_algorithm': 'for dust sweeping, try merge_algorithm = gradual, \n' + 'for more rapid dust sweeping, try merge_algorithm = greedy, \n' + 'for most rapid dust sweeping, try merge_algorithm = greediest \n', 'tx_fees': 'the fee estimate is based on a projection of how many satoshis \n' + 'per kB are needed to get in one of the next N blocks, N set here \n' + 'as the value of "tx_fees". This estimate is high if you set N=1, \n' + 'so we choose N=3 for a more reasonable figure as our default.\n' + 'Alternative: Any value higher than 1000 will be interpreted as \n' + 'fee value in satoshi per KB. This overrides the dynamic estimation.', 'gaplimit': 'How far forward to search for used addresses in the HD wallet', 'check_high_fee': 'Percent fee considered dangerously high, default 2%', 'max_mix_depth': 'Total number of mixdepths in the wallet, default 5', 'order_wait_time': 'How long to wait for orders to arrive on entering\n' + 'the message channel, default is 30s', 'no_daemon': "1 means don't use a separate daemon; set to 0 only if you\n" + "are running an instance of joinmarketd separately", "daemon_port": "The port on which the joinmarket daemon is running", "daemon_host": "The host on which the joinmarket daemon is running; remote\n" + "hosts should be considered *highly* experimental for now, not recommended.", "use_ssl": "Set to 'true' to use TLS for client-daemon connection; see\n" + "documentation for details on how to set up certs if you use this.", "history_file": "Location of the file storing transaction history", "segwit": "Only used for migrating legacy wallets; see documentation.", "native": "NOT currently supported, except for PayJoin (command line only)", "console_log_level": "one of INFO, DEBUG, WARN, ERROR; INFO is least noisy;\n" + "consider switching to DEBUG in case of problems.", "absurd_fee_per_kb": "maximum amount per kilobyte you are willing to pay,\n" + "whatever the fee estimate currently says.", "tx_broadcast": "Options: self, random-peer, not-self (note: random-maker\n" + "is not currently supported).\n" + "self = broadcast transaction with your own ip\n" + "random-peer = everyone who took part in the coinjoin has\n" + "a chance of broadcasting.\n" + "not-self = never broadcast with your own ip.", "privacy_warning": "Not currently used, ignore.", "taker_utxo_retries": "Global consensus parameter, do NOT change.\n" + "See documentation of use of 'commitments'.", "taker_utxo_age": "Global consensus parameter, do NOT change.\n" + "See documentation of use of 'commitments'.", "taker_utxo_amtpercent": "Global consensus parameter, do not change.\n" + "See documentation of use of 'commitments'.", "accept_commitment_broadcasts": "Not used, ignore.", "commit_file_location": "Location of the file that stores the commitments\n" + "you've used, and any external commitments you've loaded.\n" + "See documentation of use of 'commitments'.", "listunspent_args": "Set to [1, 9999999] to show and use only coins that\n" + "are confirmed; set to [0] to spend all coins including unconfirmed; this\n" + "is not advisable.", "minimum_makers": "The minimum number of counterparties for the transaction\n" + "to complete (default 2). If set to a high value it can cause transactions\n" + "to fail much more frequently.", "max_sats_freeze_reuse": "Threshold number of satoshis below which an\n" + "incoming utxo to a reused address in the wallet will\n" + "be AUTOMATICALLY frozen. -1 means always freeze reuse.", } #Temporarily disabled donation_more_message = "Currently disabled" """ donation_more_message = '\n'.join( ['If the calculated change for your transaction', 'is smaller than the value you choose (default 0.01 btc)', 'then that change is sent as a donation. If your change', 'is larger than that, there will be no donation.', '', 'As well as helping the developers, this feature can,', 'in certain circumstances, improve privacy, because there', 'is no change output that can be linked with your inputs later.']) """ def JMQtMessageBox(obj, msg, mbtype='info', title='', detailed_text= None): mbtypes = {'info': QMessageBox.information, 'crit': QMessageBox.critical, 'warn': QMessageBox.warning, 'question': QMessageBox.question} title = "JoinmarketQt - " + title if mbtype == 'question': return QMessageBox.question(obj, title, msg, QMessageBox.Yes, QMessageBox.No) else: if detailed_text: assert mbtype == 'info' class JMQtDMessageBox(QMessageBox): def __init__(self): QMessageBox.__init__(self) self.setSizeGripEnabled(True) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.layout().setSizeConstraint(QLayout.SetMaximumSize) def resizeEvent(self, event): self.setMinimumHeight(0) self.setMaximumHeight(16777215) self.setMinimumWidth(0) self.setMaximumWidth(16777215) result = super().resizeEvent(event) details_box = self.findChild(QTextEdit) if details_box is not None: details_box.setMinimumHeight(0) details_box.setMaximumHeight(16777215) details_box.setMinimumWidth(0) details_box.setMaximumWidth(16777215) details_box.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) return result b = JMQtDMessageBox() b.setIcon(QMessageBox.Information) b.setWindowTitle(title) b.setText(msg) b.setDetailedText(detailed_text) b.setStandardButtons(QMessageBox.Ok) retval = b.exec_() else: mbtypes[mbtype](obj, title, msg) class QtHandler(logging.Handler): def __init__(self): logging.Handler.__init__(self) def emit(self, record): record = self.format(record) if record: XStream.stdout().write('%s\n' % record) class XStream(QtCore.QObject): _stdout = None _stderr = None messageWritten = QtCore.Signal(str) def flush(self): pass def fileno(self): return -1 def write(self, msg): if (not self.signalsBlocked()): self.messageWritten.emit(msg) @staticmethod def stdout(): if (not XStream._stdout): XStream._stdout = XStream() # temporarily removed, seems not needed #sys.stdout = XStream._stdout return XStream._stdout @staticmethod def stderr(): if (not XStream._stderr): XStream._stderr = XStream() # temporarily removed, seems not needed #sys.stderr = XStream._stderr return XStream._stderr class Buttons(QHBoxLayout): def __init__(self, *buttons): QHBoxLayout.__init__(self) self.addStretch(1) for b in buttons: self.addWidget(b) class CloseButton(QPushButton): def __init__(self, dialog): QPushButton.__init__(self, "Close") self.clicked.connect(dialog.close) self.setDefault(True) class CopyButton(QPushButton): def __init__(self, text_getter, app): QPushButton.__init__(self, "Copy") self.clicked.connect(lambda: app.clipboard().setText(text_getter())) class CopyCloseButton(QPushButton): def __init__(self, text_getter, app, dialog): QPushButton.__init__(self, "Copy and Close") self.clicked.connect(lambda: app.clipboard().setText(text_getter())) self.clicked.connect(dialog.close) self.setDefault(True) class OkButton(QPushButton): def __init__(self, dialog, label=None): QPushButton.__init__(self, label or "OK") self.clicked.connect(dialog.accept) self.setDefault(True) class CancelButton(QPushButton): def __init__(self, dialog, label=None): QPushButton.__init__(self, label or "Cancel") self.clicked.connect(dialog.reject) def check_password_strength(password): ''' Check the strength of the password entered by the user and return back the same :param password: password entered by user in New Password :return: password strength Weak or Medium or Strong ''' n = math.log(len(set(password))) num = re.search("[0-9]", password) is not None and re.match( "^[0-9]*$", password) is None caps = password != password.upper() and password != password.lower() extra = re.match("^[a-zA-Z0-9]*$", password) is None score = len(password) * (n + caps + num + extra) / 20 password_strength = {0: "Weak", 1: "Medium", 2: "Strong", 3: "Very Strong"} return password_strength[min(3, int(score))] def update_password_strength(pw_strength_label, password): ''' call the function check_password_strength and update the label pw_strength interactively as the user is typing the password :param pw_strength_label: the label pw_strength :param password: password entered in New Password text box :return: None ''' if password: colors = {"Weak": "Red", "Medium": "Blue", "Strong": "Green", "Very Strong": "Green"} strength = check_password_strength(password) label = "Password Strength"+ ": "+"" + strength + "" else: label = "" pw_strength_label.setText(label) def make_password_dialog(self, msg): self.new_pw = QLineEdit() self.new_pw.setEchoMode(QLineEdit.EchoMode(2)) self.conf_pw = QLineEdit() self.conf_pw.setEchoMode(QLineEdit.EchoMode(2)) vbox = QVBoxLayout() label = QLabel(msg) label.setWordWrap(True) grid = QGridLayout() grid.setSpacing(8) grid.setColumnMinimumWidth(0, 70) grid.setColumnStretch(1, 1) #TODO perhaps add an icon here logo = QLabel() lockfile = ":icons/lock.png" logo.setPixmap(QPixmap(lockfile).scaledToWidth(36)) logo.setAlignment(QtCore.Qt.AlignCenter) grid.addWidget(logo, 0, 0) grid.addWidget(label, 0, 1, 1, 2) vbox.addLayout(grid) grid = QGridLayout() grid.setSpacing(8) grid.setColumnMinimumWidth(0, 250) grid.setColumnStretch(1, 1) grid.addWidget(QLabel('New Passphrase'), 1, 0) grid.addWidget(self.new_pw, 1, 1) grid.addWidget(QLabel('Confirm Passphrase'), 2, 0) grid.addWidget(self.conf_pw, 2, 1) vbox.addLayout(grid) #Password Strength Label self.pw_strength = QLabel() grid.addWidget(self.pw_strength, 3, 0, 1, 2) self.new_pw.textChanged.connect( lambda: update_password_strength(self.pw_strength, self.new_pw.text())) vbox.addStretch(1) vbox.addLayout(Buttons(CancelButton(self), OkButton(self))) return vbox class PasswordDialog(QDialog): def __init__(self): super().__init__() self.initUI() def initUI(self): self.setWindowTitle('Create a new passphrase') msg = "Enter a new passphrase" self.setLayout(make_password_dialog(self, msg)) self.show() class MyTreeWidget(QTreeWidget): def __init__(self, parent, create_menu, headers, stretch_column=None, editable_columns=None): QTreeWidget.__init__(self, parent) self.parent = parent self.stretch_column = stretch_column self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) self.customContextMenuRequested.connect(create_menu) self.setUniformRowHeights(True) # extend the syntax for consistency self.addChild = self.addTopLevelItem self.insertChild = self.insertTopLevelItem self.editor = None self.pending_update = False if editable_columns is None: editable_columns = [stretch_column] self.editable_columns = editable_columns self.itemActivated.connect(self.on_activated) self.update_headers(headers) def update_headers(self, headers): self.setColumnCount(len(headers)) self.setHeaderLabels(headers) self.header().setStretchLastSection(False) for col in range(len(headers)): #note, a single stretch column is currently not used. self.header().setSectionResizeMode(col, QHeaderView.Interactive) def editItem(self, item, column): if column in self.editable_columns: self.editing_itemcol = (item, column, unicode(item.text(column))) # Calling setFlags causes on_changed events for some reason item.setFlags(item.flags() | Qt.ItemIsEditable) QTreeWidget.editItem(self, item, column) item.setFlags(item.flags() & ~Qt.ItemIsEditable) def keyPressEvent(self, event): if event.key() == QtCore.Qt.Key_F2: self.on_activated(self.currentItem(), self.currentColumn()) else: QTreeWidget.keyPressEvent(self, event) def permit_edit(self, item, column): return (column in self.editable_columns and self.on_permit_edit(item, column)) def on_permit_edit(self, item, column): return True def on_activated(self, item, column): if self.permit_edit(item, column): self.editItem(item, column) else: pt = self.visualItemRect(item).bottomLeft() pt.setX(50) self.emit( QtCore.SIGNAL('customContextMenuRequested(const QPoint&)'), pt) def createEditor(self, parent, option, index): self.editor = QStyledItemDelegate.createEditor(self.itemDelegate(), parent, option, index) self.editor.connect(self.editor, QtCore.SIGNAL("editingFinished()"), self.editing_finished) return self.editor def editing_finished(self): # Long-time QT bug - pressing Enter to finish editing signals # editingFinished twice. If the item changed the sequence is # Enter key: editingFinished, on_change, editingFinished # Mouse: on_change, editingFinished # This mess is the cleanest way to ensure we make the # on_edited callback with the updated item if self.editor: (item, column, prior_text) = self.editing_itemcol if self.editor.text() == prior_text: self.editor = None # Unchanged - ignore any 2nd call elif item.text(column) == prior_text: pass # Buggy first call on Enter key, item not yet updated else: # What we want - the updated item self.on_edited(*self.editing_itemcol) self.editor = None # Now do any pending updates if self.editor is None and self.pending_update: self.pending_update = False self.on_update() def on_edited(self, item, column, prior): '''Called only when the text actually changes''' key = str(item.data(0, Qt.UserRole)) text = unicode(item.text(column)) self.parent.wallet.set_label(key, text) if text: item.setForeground(column, QBrush(QColor('black'))) else: text = self.parent.wallet.get_default_label(key) item.setText(column, text) item.setForeground(column, QBrush(QColor('gray'))) self.parent.history_list.update() self.parent.update_completions() def update(self): # Defer updates if editing if self.editor: self.pending_update = True else: self.on_update() def on_update(self): pass def get_leaves(self, root): child_count = root.childCount() if child_count == 0: yield root for i in range(child_count): item = root.child(i) for x in self.get_leaves(item): yield x def filter(self, p, columns): p = unicode(p).lower() for item in self.get_leaves(self.invisibleRootItem()): item.setHidden(all([unicode(item.text(column)).lower().find(p) == -1 for column in columns])) # TODO implement this option #class SchStaticPage(QWizardPage): # def __init__(self, parent): # super().__init__(parent) # self.setTitle("Manually create a schedule entry") # layout = QGridLayout() # wdgts = getSettingsWidgets() # for i, x in enumerate(wdgts): # layout.addWidget(x[0], i + 1, 0) # layout.addWidget(x[1], i + 1, 1, 1, 2) # wdgts[0][1].editingFinished.connect( # lambda: checkAddress(self, wdgts[0][1].text())) # self.setLayout(layout) class JMIntValidator(QIntValidator): def __init__(self, minval, maxval): super().__init__(minval, maxval) self.minval = minval self.maxval = maxval self.allowed = set(string.digits) def validate(self, arg__1, arg__2): if not arg__1: return QValidator.Intermediate if not set(arg__1) <= self.allowed: return QValidator.Invalid # above guarantees integer if not (int(arg__1) <= self.maxval and int(arg__1) >= self.minval): return QValidator.Invalid return super().validate(arg__1, arg__2) class BitcoinAmountBTCValidator(QDoubleValidator): def __init__(self): super().__init__(0.00000000, 20999999.9769, 8) self.setLocale(QtCore.QLocale.c()) # Only numbers and "." as a decimal separator must be allowed, # no thousands separators, as per BIP21 self.allowed = set(string.digits + ".") def validate(self, arg__1, arg__2): if not arg__1: return QValidator.Intermediate if not set(arg__1) <= self.allowed: return QValidator.Invalid return super().validate(arg__1, arg__2) class BitcoinAmountSatValidator(JMIntValidator): def __init__(self): super().__init__(0, 2147483647) class BitcoinAmountEdit(QWidget): def __init__(self, default_value): super().__init__() layout = QHBoxLayout() layout.setSizeConstraint(QLayout.SetMaximumSize) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(1) self.valueInputBox = QLineEdit() self.editingFinished = self.valueInputBox.editingFinished layout.addWidget(self.valueInputBox) self.unitChooser = QComboBox() self.unitChooser.setInsertPolicy(QComboBox.NoInsert) self.unitChooser.addItems(["BTC", "sat"]) self.unitChooser.currentIndexChanged.connect(self.onUnitChanged) self.BTCValidator = BitcoinAmountBTCValidator() self.SatValidator = BitcoinAmountSatValidator() self.setModeBTC() layout.addWidget(self.unitChooser) if default_value: self.valueInputBox.setText(str(satoshi_to_coins(amount_to_sat( default_value)))) self.setLayout(layout) def setModeBTC(self): self.valueInputBox.setPlaceholderText("0.00000000") self.valueInputBox.setMaxLength(17) self.valueInputBox.setValidator(self.BTCValidator) def setModeSat(self): self.valueInputBox.setPlaceholderText("0") self.valueInputBox.setMaxLength(16) self.valueInputBox.setValidator(self.SatValidator) # index: 0 - BTC, 1 - sat def onUnitChanged(self, index): if index == 0: # switch from sat to BTC sat_amount = self.valueInputBox.text() self.setModeBTC() if sat_amount: self.valueInputBox.setText(sat_to_str(sat_amount)) else: # switch from BTC to sat btc_amount = self.valueInputBox.text() self.setModeSat() if btc_amount: self.valueInputBox.setText(str(btc_to_sat(btc_amount))) def setText(self, text): if text: if self.unitChooser.currentIndex() == 0: self.valueInputBox.setText(str(satoshi_to_coins(text))) else: self.valueInputBox.setText(str(text)) else: self.valueInputBox.setText('') def setEnabled(self, enabled): self.valueInputBox.setEnabled(enabled) self.unitChooser.setEnabled(enabled) def text(self): if len(self.valueInputBox.text()) == 0: return '' elif self.unitChooser.currentIndex() == 0: return str(btc_to_sat(self.valueInputBox.text())) else: return self.valueInputBox.text() class SchDynamicPage1(QWizardPage): def __init__(self, parent): super().__init__(parent) self.setTitle("Tumble schedule generation") self.setSubTitle("Set parameters for the sequence of transactions in the tumble.") results = [] sN = ['Average number of counterparties', 'How many mixdepths to tumble through', 'Average wait time between transactions, in minutes', 'Average number of transactions per mixdepth'] #Tooltips sH = [ "How many other participants are in each coinjoin, on average; but\n" "each individual coinjoin will have a number that's varied according to\n" "settings on the next page", "For example, if you start at mixdepth 1 and enter 4 here, the tumble\n" "will move coins from mixdepth 1 to mixdepth 5", "This is the time waited *after* 1 confirmation has occurred, and is\n" "varied randomly.", "Will be varied randomly, see advanced settings next page"] #types sT = [int, int, float, int] #constraints sMM = [(3, 20), (2, 7), (0.00000001, 100.0, 8), (2, 10)] sD = ['9', '4', '60.0', '2'] for x in zip(sN, sH, sT, sD, sMM): ql = QLabel(x[0]) ql.setToolTip(x[1]) qle = QLineEdit(x[3]) if x[2] == int: qle.setValidator(QIntValidator(*x[4])) if x[2] == float: qle.setValidator(QDoubleValidator(*x[4])) results.append((ql, qle)) layout = QGridLayout() layout.setSpacing(4) for i, x in enumerate(results): layout.addWidget(x[0], i + 1, 0) layout.addWidget(x[1], i + 1, 1, 1, 2) self.setLayout(layout) self.registerField("makercount", results[0][1]) self.registerField("mixdepthcount", results[1][1]) self.registerField("timelambda", results[2][1]) self.registerField("txcountparams", results[3][1]) class SchDynamicPage2(QWizardPage): def initializePage(self): addrLEs = [] requested_mixdepths = int(self.field("mixdepthcount")) #for testing if jm_single().config.get("BLOCKCHAIN", "blockchain_source") == "regtest": testaddrs = ["mteaYsGsLCL9a4cftZFTpGEWXNwZyDt5KS", "msFGHeut3rfJk5sKuoZNfpUq9MeVMqmido", "mkZfBXCRPs8fCmwWLrspjCvYozDhK6Eepz"] else: testaddrs = ["","",""] #less than 3 is unacceptable for privacy effect, more is optional self.required_addresses = max(3, requested_mixdepths - 1) for i in range(self.required_addresses): if i >= self.addrfieldsused: self.layout.addWidget(QLabel("Destination address: " + str(i)), i, 0) if i < len(testaddrs): addrLEs.append(QLineEdit(testaddrs[i])) else: addrLEs.append(QLineEdit("")) self.layout.addWidget(addrLEs[-1], i, 1, 1, 2) #addrLEs[-1].editingFinished.connect( # lambda: checkAddress(self, addrLEs[-1].text())) self.registerField("destaddr"+str(i), addrLEs[-1]) self.addrfieldsused = self.required_addresses self.setLayout(self.layout) def __init__(self, parent): super().__init__(parent) self.setTitle("Destination addresses") self.setSubTitle("Enter destination addresses for coins; " "minimum 3 for privacy. You may leave later ones blank.") self.layout = QGridLayout() self.layout.setSpacing(4) self.addrfieldsused = 0 class SchFinishPage(QWizardPage): def __init__(self, parent): super().__init__(parent) self.setTitle("Advanced options") self.setSubTitle("(the default values are usually sufficient)") layout = QGridLayout() layout.setSpacing(4) results = [] sN = ['Makercount sdev', 'Tx count sdev', 'Minimum maker count', 'Minimum transaction count', 'Min coinjoin amount', 'Response wait time', 'Stage 1 transaction wait time increase', 'Rounding Chance'] for w in ["One", "Two", "Three", "Four", "Five"]: sN += [w + " significant figures rounding weight"] #Tooltips sH = ["Standard deviation of the number of makers to use in each " "transaction.", "Standard deviation of the number of transactions to use in each " "mixdepth", "The lowest allowed number of maker counterparties.", "The lowest allowed number of transactions in one mixdepth.", "The lowest allowed size of any coinjoin, in satoshis.", "The time in seconds to wait for response from counterparties.", "The factor increase in wait time for stage 1 sweep coinjoins", "The probability of non-sweep coinjoin amounts being rounded"] for w in ["one", "two", "three", "four", "five"]: sH += ["If rounding happens (determined by Rounding Chance) then this " "is the relative probability of rounding to " + w + " significant figures"] #types sT = [float, float, int, int, int, float, float, float] + [int]*5 #constraints sMM = [(0.0, 10.0, 2), (0.0, 10.0, 2), (2,20), (1, 10), (100000, 100000000), (10.0, 500.0, 2), (0, 100, 1), (0.0, 1.0, 3)] + [(0, 10000)]*5 sD = ['1.0', '1.0', '2', '2', '1000000', '20', '3', '0.25'] +\ ['55', '15', '25', '65', '40'] for x in zip(sN, sH, sT, sD, sMM): ql = QLabel(x[0]) ql.setToolTip(x[1]) qle = QLineEdit(x[3]) if x[2] == int: qle.setValidator(QIntValidator(*x[4])) if x[2] == float: qle.setValidator(QDoubleValidator(*x[4])) results.append((ql, qle)) layout = QGridLayout() layout.setSpacing(4) for i, x in enumerate(results): layout.addWidget(x[0], i + 1, 0) layout.addWidget(x[1], i + 1, 1, 1, 2) self.setLayout(layout) #fields not considered 'mandatory' as defaults are accepted self.registerField("makercountsdev", results[0][1]) self.registerField("txcountsdev", results[1][1]) self.registerField("minmakercount", results[2][1]) self.registerField("mintxcount", results[3][1]) self.registerField("mincjamount", results[4][1]) self.registerField("waittime", results[5][1]) self.registerField("stage1_timelambda_increase", results[6][1]) self.registerField("rounding_chance", results[7][1]) for i in range(5): self.registerField("rounding_sigfig_weight_" + str(i+1), results[8+i][1]) class SchIntroPage(QWizardPage): def __init__(self, parent): super().__init__(parent) self.setTitle("Generate a join transaction schedule") self.rbgroup = QButtonGroup(self) self.r0 = QRadioButton("Define schedule manually (not yet implemented)") self.r0.setEnabled(False) self.r1 = QRadioButton("Generate a tumble schedule automatically") self.rbgroup.addButton(self.r0) self.rbgroup.addButton(self.r1) layout = QVBoxLayout() layout.addWidget(self.r0) layout.addWidget(self.r1) self.setLayout(layout) """ def nextId(self): if self.rbgroup.checkedButton() == self.r0: self.parent().staticSchedule = True return 3 elif self.rbgroup.checkedButton() == self.r1: self.parent().staticSchedule = False return 1 else: return 0 """ class ScheduleWizard(QWizard): def __init__(self): super().__init__() self.setWindowTitle("Joinmarket schedule generator") self.setPage(0, SchIntroPage(self)) self.setPage(1, SchDynamicPage1(self)) self.setPage(2, SchDynamicPage2(self)) #self.setPage(3, SchStaticPage(self)) self.setPage(3, SchFinishPage(self)) def get_name(self): #TODO de-hardcode generated name return "TUMBLE.schedule" def get_destaddrs(self): return self.destaddrs def get_schedule(self, wallet_balance_by_mixdepth, max_mixdepth_in_wallet): self.destaddrs = [] for i in range(self.page(2).required_addresses): daddrstring = str(self.field("destaddr"+str(i))) if validate_address(daddrstring)[0]: self.destaddrs.append(daddrstring) elif daddrstring != "": JMQtMessageBox(self, "Error, invalid address", mbtype='crit', title='Error') return None self.opts = {} self.opts['mixdepthcount'] = int(self.field("mixdepthcount")) self.opts['txfee'] = -1 self.opts['addrcount'] = len(self.destaddrs) self.opts['makercountrange'] = (int(self.field("makercount")), float(self.field("makercountsdev"))) self.opts['minmakercount'] = int(self.field("minmakercount")) self.opts['txcountparams'] = (int(self.field("txcountparams")), float(self.field("txcountsdev"))) self.opts['mintxcount'] = int(self.field("mintxcount")) self.opts['timelambda'] = float(self.field("timelambda")) self.opts['waittime'] = float(self.field("waittime")) self.opts["stage1_timelambda_increase"] = float(self.field("stage1_timelambda_increase")) self.opts['mincjamount'] = int(self.field("mincjamount")) #needed for Taker to check: self.opts['rounding_chance'] = float(self.field("rounding_chance")) self.opts['rounding_sigfig_weights'] = tuple([int(self.field("rounding_sigfig_weight_" + str(i+1))) for i in range(5)]) jm_single().mincjamount = self.opts['mincjamount'] return get_tumble_schedule(self.opts, self.destaddrs, wallet_balance_by_mixdepth, max_mixdepth_in_wallet) class TumbleRestartWizard(QWizard): def __init__(self): super().__init__() self.setWindowTitle("Restart tumbler schedule") self.setPage(0, RestartSettingsPage(self)) def getOptions(self): self.opts = {} self.opts['mincjamount'] = int(self.field("mincjamount")) relfeeval = float(self.field("maxrelfee")) absfeeval = int(self.field("maxabsfee")) self.opts['maxcjfee'] = (relfeeval, absfeeval) #needed for Taker to check: jm_single().mincjamount = self.opts['mincjamount'] return self.opts class RestartSettingsPage(QWizardPage): def __init__(self, parent): super().__init__(parent) self.setTitle("Tumbler options") self.setSubTitle("Options settings that can be varied on restart") layout = QGridLayout() layout.setSpacing(4) results = [] sN = ['Min coinjoin amount', 'Max relative fee per counterparty (e.g. 0.005)', 'Max fee per counterparty, satoshis (e.g. 10000)'] #Tooltips sH = ["The lowest allowed size of any coinjoin, in satoshis.", "A decimal fraction (e.g. 0.001 = 0.1%) (this AND next must be violated to reject", "Integer number of satoshis (this AND previous must be violated to reject)"] #types sT = [int, float, int] #constraints sMM = [(100000, 100000000), (0.000001, 0.25, 6), (0, 10000000)] sD = ['1000000', '0.0005', '10000'] for x in zip(sN, sH, sT, sD, sMM): ql = QLabel(x[0]) ql.setToolTip(x[1]) qle = QLineEdit(x[3]) if x[2] == int: qle.setValidator(QIntValidator(*x[4])) if x[2] == float: qle.setValidator(QDoubleValidator(*x[4])) results.append((ql, qle)) layout = QGridLayout() layout.setSpacing(4) for i, x in enumerate(results): layout.addWidget(x[0], i + 1, 0) layout.addWidget(x[1], i + 1, 1, 1, 2) self.setLayout(layout) #fields not considered 'mandatory' as defaults are accepted self.registerField("mincjamount", results[0][1]) self.registerField("maxrelfee", results[1][1]) self.registerField("maxabsfee", results[2][1]) class CopyOnClickLineEdit(QLineEdit): """ Small wrapper class around QLineEdit to support copy-to-clipboard-on-click. """ def __init__(self, s): # This is needed to prevent # infinite loop, but # TODO: This is very suboptimal # since the copy can only be done once. self.was_copied = False super().__init__(s) def focusInEvent(self, event): super().focusInEvent(event) self.selectAll() self.copy() if not self.was_copied: JMQtMessageBox(self, "URI copied to clipboard", mbtype="info") self.was_copied = True class QRCodePopup(QDialog): def __init__(self, parent, title, data): super().__init__(parent) self.setWindowTitle(title) buf = BytesIO() img = qrcode.make(data) img.save(buf, "PNG") self.imageLabel = QLabel() qt_pixmap = QPixmap() qt_pixmap.loadFromData(buf.getvalue(), "PNG") self.imageLabel.setPixmap(qt_pixmap) layout = QVBoxLayout() layout.addWidget(self.imageLabel) self.setLayout(layout) self.initUI() def initUI(self): self.show() class ReceiveBIP78Dialog(QDialog): parameter_names = ['Amount to receive', 'Mixdepth'] parameter_tooltips = [ "How much you should receive (after any fees) in BTC or sats.", "The mixdepth you source coins from to create inputs for the\n" "payjoin. Note your receiving address will be chosen from the\n" "*next* mixdepth after this (or 0 if last)."] parameter_types = ["btc", int] parameter_settings = ["", 0] def __init__(self, action_fn, cancel_fn, parameter_settings=None): """ Parameter action_fn: each time the user opens the dialog they will pass a function to be connected to the action-button. Signature: no arguments, return value False if action initiation is aborted, otherwise True. """ super().__init__() if parameter_settings: self.parameter_settings = parameter_settings # these QLineEdit or QLabel objects will contain the # settings for the receiver as chosen by the user: self.receiver_settings_ql = [] self.action_fn = action_fn # callback for actions to take when closing this dialog: self.cancel_fn = cancel_fn self.updates_final = False self.initUI() def initUI(self): self.setModal(1) self.setWindowTitle("Receive Payjoin") self.setLayout(self.get_receive_bip78_dialog()) self.show() def info_update(self, msg): """ Sets update text in the dialog to the str parameter msg, but does not overwrite after that, if the message ends with ":FINAL". TODO: Info updates need to be richer, supporting multiple messages. """ if not self.updates_final: if msg.endswith(":FINAL"): self.updates_final = True msg = msg.split(":FINAL")[0] self.updates_label.setText(msg) def get_amount_text(self): return self.receiver_settings_ql[0][1].text() def get_mixdepth(self): return int(self.receiver_settings_ql[1][1].text()) def update_uri(self, uri): self.bip21_widget.setDisabled(False) self.bip21_widget.setText(uri) self.bip21_widget.was_copied = False def shutdown_actions(self): self.cancel_fn() self.close() def process_complete(self): """ Called by the owning Qt object when the BIP78 workflow is complete, whether successful or not. """ # Give user indication that they # can quit without cancelling: self.close_btn.setVisible(True) self.qr_btn.setVisible(False) self.btnbox.button(QDialogButtonBox.Cancel).setDisabled(True) def start_generate(self): """ Before starting up the hidden service and initiating the payment workflow, disallow starting again; user will need to close and reopen to restart. If the 'start generate request' action is aborted, we reset the generate button. """ self.generate_btn.setDisabled(True) if not self.action_fn(): self.generate_btn.setDisabled(False) def get_receive_bip78_dialog(self): """ Displays editable parameters and BIP21 URI once the receiver is ready. """ # TODO: allow custom mixdepths valid_ranges = [None, (0, 4)] # note that this iteration is not currently helpful, # if anything making the code *more* verbose, but could be # if we add several more fields: for x in zip(self.parameter_names, self.parameter_tooltips, self.parameter_types, self.parameter_settings, valid_ranges): ql = QLabel(x[0]) ql.setToolTip(x[1]) editfield = BitcoinAmountEdit if x[2] == "btc" else QLineEdit ql2 = editfield(str(x[3])) if x[4]: if x[2] == int: ql2.setValidator(QIntValidator(*x[4])) elif x[2] == float: ql2.setValidator(QDoubleValidator(*x[4])) # note no validators for the btc type as that # has its own internal validation. self.receiver_settings_ql.append((ql, ql2)) layout = QGridLayout(self) layout.setSpacing(4) for i, x in enumerate(self.receiver_settings_ql): layout.addWidget(x[0], i + 1, 0) layout.addWidget(x[1], i + 1, 1, 1, 2) # As well as editable settings, we also need two more # fields: one for information updates, and one for the # final (copyable) URI: self.updates_label = QLabel("Waiting ...") self.bip21_widget = CopyOnClickLineEdit("") self.bip21_widget.setReadOnly(True) # Note that the initial state is disabled, meaning # click events won't register and it won't look editable: self.bip21_widget.setDisabled(True) layout.addWidget(self.updates_label, i+2, 0, 1, 2) layout.addWidget(self.bip21_widget, i+3, 0, 1, 2) # Buttons for start/cancel/close: self.btnbox = QDialogButtonBox() self.btnbox.setStandardButtons(QDialogButtonBox.Cancel) self.generate_btn = self.btnbox.addButton("&Generate request", QDialogButtonBox.ActionRole) self.close_btn = self.btnbox.addButton("C&lose", QDialogButtonBox.AcceptRole) self.close_btn.setVisible(False) self.qr_btn = self.btnbox.addButton("Show &QR code", QDialogButtonBox.ActionRole) layout.addWidget(self.btnbox, i+4, 0) # note that we don't use a standard 'Close' button because # it is also associated with 'rejection' (and we don't use "OK" because # concept doesn't quite fit here: self.btnbox.rejected.connect(self.shutdown_actions) self.generate_btn.clicked.connect(self.start_generate) self.qr_btn.clicked.connect(self.open_qr_code_popup) # does not trigger cancel_fn callback: self.close_btn.clicked.connect(self.close) return layout def open_qr_code_popup(self): bip21_uri = self.bip21_widget.text() if bip21_uri: parsed_uri = decode_bip21_uri(bip21_uri) popup = QRCodePopup(self, parsed_uri['address'], bip21_uri) popup.show()