mirror of
https://github.com/JoinMarket-Org/joinmarket-clientserver.git
synced 2026-08-13 12:33:40 +02:00
1124 lines
44 KiB
Python
1124 lines
44 KiB
Python
#!/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 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"+ ": "+"<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(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()
|