diff --git a/scripts/joinmarket-qt.py b/scripts/joinmarket-qt.py index 2a35d1743..9b4b39c69 100755 --- a/scripts/joinmarket-qt.py +++ b/scripts/joinmarket-qt.py @@ -79,7 +79,7 @@ from qtsupport import ScheduleWizard, TumbleRestartWizard, config_tips,\ config_types, QtHandler, XStream, Buttons, OkButton, CancelButton,\ PasswordDialog, MyTreeWidget, JMQtMessageBox, BLUE_FG,\ - donation_more_message + donation_more_message, BitcoinAmountEdit from twisted.internet import task @@ -101,6 +101,7 @@ def update_config_for_gui(): if gcn not in [_[0] for _ in gui_items]: jm_single().config.set("GUI", gcn, gcv) + def checkAddress(parent, addr): addr = addr.strip() if btc.is_bip21_uri(addr): @@ -114,9 +115,8 @@ def checkAddress(parent, addr): return addr = parsed['address'] if 'amount' in parsed: - parent.widgets[3][1].setText( - str(btc.sat_to_btc(parsed['amount']))) - parent.widgets[0][1].setText(addr) + parent.amountInput.setText(parsed['amount']) + parent.addressInput.setText(addr) valid, errmsg = validate_address(str(addr)) if not valid: JMQtMessageBox(parent, @@ -140,37 +140,6 @@ def checkAmount(parent, amount_str): return True -def getSettingsWidgets(): - results = [] - sN = ['Recipient address', 'Number of counterparties', 'Mixdepth', - 'Amount (BTC or sat)'] - sH = ['The address you want to send the payment to', - '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.', - 'The mixdepth of the wallet to send the payment from', - 'The amount to send, either BTC (if contains dot) or satoshis.\n' + - 'If you enter 0, a SWEEP transaction\nwill be performed,' + - ' spending all the coins \nin the given mixdepth.'] - sT = [str, int, int, float] - #todo maxmixdepth - sMM = ['', (2, 20), - (0, jm_single().config.getint("GUI", "max_mix_depth") - 1), - (0.00000001, 100.0, 8)] - sD = ['', '9', '0', ''] - 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: - qdv = QDoubleValidator(*x[4]) - qle.setValidator(qdv) - results.append((ql, qle)) - return results - - handler = QtHandler() handler.setFormatter(logging.Formatter("%(levelname)s:%(message)s")) log.addHandler(handler) @@ -295,6 +264,8 @@ def getSettingsFields(self, section, names): qt = QCheckBox() if val == 'testnet' or val.lower() == 'true': qt.setChecked(True) + elif t == 'amount': + qt = BitcoinAmountEdit(val) elif not t: continue else: @@ -506,12 +477,44 @@ def initUI(self): donateLayout = self.getDonateLayout() innerTopLayout.addLayout(donateLayout, 0, 0, 1, 2) - self.widgets = getSettingsWidgets() - for i, x in enumerate(self.widgets): - innerTopLayout.addWidget(x[0], i + 1, 0) - innerTopLayout.addWidget(x[1], i + 1, 1, 1, 2) - self.widgets[0][1].editingFinished.connect( - lambda: checkAddress(self, self.widgets[0][1].text())) + + recipientLabel = QLabel('Recipient address') + recipientLabel.setToolTip( + 'The address you want to send the payment to') + self.addressInput = QLineEdit() + self.addressInput.editingFinished.connect( + lambda: checkAddress(self, self.addressInput.text())) + innerTopLayout.addWidget(recipientLabel, 1, 0) + innerTopLayout.addWidget(self.addressInput, 1, 1, 1, 2) + + numCPLabel = QLabel('Number of counterparties') + 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(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, jm_single().config.getint("GUI", "max_mix_depth") - 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) + self.startButton = QPushButton('Start') self.startButton.setToolTip( 'If "checktx" is selected in the Settings, you will be \n' @@ -527,7 +530,7 @@ def initUI(self): buttons.addWidget(self.startButton) buttons.addWidget(self.abortButton) self.abortButton.clicked.connect(self.abortTransactions) - innerTopLayout.addLayout(buttons, len(self.widgets) + 1, 0, 1, 2) + innerTopLayout.addLayout(buttons, 5, 0, 1, 2) splitter1 = QSplitter(QtCore.Qt.Vertical) self.textedit = QTextEdit() self.textedit.verticalScrollBar().rangeChanged.connect( @@ -639,25 +642,12 @@ def startSingle(self): log.info("Cannot start join, already running.") if not self.validateSettings(): return - destaddr = str(self.widgets[0][1].text()).strip() - makercount = int(self.widgets[1][1].text()) - mixdepth = int(self.widgets[2][1].text()) - btc_amount_str = self.widgets[3][1].text() - if makercount != 0: - # for coinjoin sends no point to send below dust threshold, - # there will be no makers for such amount. - if (btc_amount_str != '0' and - not checkAmount(self, btc_amount_str)): - return - if makercount < jm_single().config.getint( - "POLICY", "minimum_makers"): - JMQtMessageBox(self, "Number of counterparties (" + str( - makercount) + ") below minimum_makers (" + str( - jm_single().config.getint("POLICY", "minimum_makers")) + - ") in configuration.", - title="Error", mbtype="warn") - return - amount = btc.amount_to_sat(btc_amount_str) + + destaddr = str(self.addressInput.text().strip()) + amount = btc.amount_to_sat(self.amountInput.text()) + makercount = int(self.numCPInput.text()) + mixdepth = int(self.mixdepthInput.text()) + if makercount == 0: try: txid = direct_send(mainWindow.wallet_service, amount, mixdepth, @@ -684,6 +674,20 @@ def qt_directsend_callback(rtxd, rtxid, confs): self.cleanUp() return + # for coinjoin sends no point to send below dust threshold, likely + # there will be no makers for such amount. + if amount != 0 and not checkAmount(self, amount): + return + + if makercount < jm_single().config.getint( + "POLICY", "minimum_makers"): + JMQtMessageBox(self, "Number of counterparties (" + str( + makercount) + ") below minimum_makers (" + str( + jm_single().config.getint("POLICY", "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, @@ -975,18 +979,28 @@ def cleanUp(self): self.tumbler_destaddrs = None def validateSettings(self): - valid, errmsg = validate_address(str( - self.widgets[0][1].text().strip())) + valid, errmsg = validate_address( + str(self.addressInput.text().strip())) if not valid: JMQtMessageBox(self, errmsg, mbtype='warn', title="Error") return False - errs = ["Non-zero number of counterparties must be provided.", + if len(self.numCPInput.text()) == 0: + JMQtMessageBox( + self, + "Non-zero number of counterparties must be provided.", + mbtype='warn', title="Error") + return False + if len(self.mixdepthInput.text()) == 0: + JMQtMessageBox( + self, "Mixdepth must be chosen.", - "Amount, in bitcoins, must be provided."] - for i in range(1, 4): - if len(self.widgets[i][1].text()) == 0: - JMQtMessageBox(self, errs[i - 1], mbtype='warn', title="Error") - return False + mbtype='warn', title="Error") + return False + if len(self.amountInput.text()) == 0: + JMQtMessageBox( + self, + "Amount, in bitcoins, must be provided.", + mbtype='warn', title="Error") if not mainWindow.wallet_service: JMQtMessageBox(self, "There is no wallet loaded.", diff --git a/scripts/qtsupport.py b/scripts/qtsupport.py index 08e78a97e..0532ea012 100644 --- a/scripts/qtsupport.py +++ b/scripts/qtsupport.py @@ -17,12 +17,12 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . ''' -import math, re, logging +import math, re, logging, string from PySide2 import QtCore from PySide2.QtGui import * from PySide2.QtWidgets import * - +from jmbitcoin.amount import amount_to_sat, btc_to_sat, sat_to_btc from jmclient import (jm_single, validate_address, get_tumble_schedule) @@ -53,8 +53,9 @@ 'check_high_fee': int, 'max_mix_depth': int, 'order_wait_time': int, - "no_daemon": int, - "daemon_port": 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 "testnet" or "mainnet"', @@ -99,7 +100,7 @@ "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 satoshis/kilobyte you are willing to pay,\n" + + "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" + @@ -507,20 +508,115 @@ def filter(self, p, columns): 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(SchStaticPage, self).__init__(parent) - self.setTitle("Manually create a schedule entry") +# TODO implement this option +#class SchStaticPage(QWizardPage): +# def __init__(self, parent): +# super(SchStaticPage, self).__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 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(QIntValidator): + + def __init__(self): + super().__init__(0, 2147483647) + self.setLocale(QtCore.QLocale.c()) + 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 BitcoinAmountEdit(QWidget): + + def __init__(self, default_value): + super().__init__() 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())) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(1) + self.valueInputBox = QLineEdit() + self.editingFinished = self.valueInputBox.editingFinished + layout.addWidget(self.valueInputBox, 0, 0) + 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, 0, 1) + if default_value: + self.valueInputBox.setText(str(sat_to_btc(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(str(sat_to_btc(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 self.unitChooser.currentIndex() == 0: + self.valueInputBox.setText(str(sat_to_btc(text))) + else: + self.valueInputBox.setText(str(text)) + + 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):