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.
 
 
 
 

1127 lines
44 KiB

#!/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 <http://www.gnu.org/licenses/>.
'''
import math, logging, qrcode, re, string
from io import BytesIO
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 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 "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"+ ": "+"<font color=" + \
colors[strength] + ">" + strength + "</font>"
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(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('%.8f' % 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 text:
if self.unitChooser.currentIndex() == 0:
self.valueInputBox.setText(str(sat_to_btc(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 = ['Starting mixdepth', '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 = ["The starting mixdepth can be decided from the Wallet tab; it must\n"
"have coins in it, but it's OK if some coins are in other mixdepths.",
"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, int, float, int]
#constraints
sMM = [(0, jm_single().config.getint("GUI", "max_mix_depth") - 1), (3, 20),
(2, 7), (0.00000001, 100.0, 8), (2, 10)]
sD = ['0', '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("mixdepthsrc", results[0][1])
self.registerField("makercount", results[1][1])
self.registerField("mixdepthcount", results[2][1])
self.registerField("timelambda", results[3][1])
self.registerField("txcountparams", results[4][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):
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['mixdepthsrc'] = int(self.field("mixdepthsrc"))
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)
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()