diff --git a/jmclient/jmclient/blockchaininterface.py b/jmclient/jmclient/blockchaininterface.py
index 4b52ea2..d7b8ba6 100644
--- a/jmclient/jmclient/blockchaininterface.py
+++ b/jmclient/jmclient/blockchaininterface.py
@@ -639,7 +639,7 @@ class BitcoinCoreInterface(BlockchainInterface):
return 'joinmarket-wallet-' + btc.dbl_sha256(wallet.keys[0][0])[:6]
def rpc(self, method, args):
- if method not in ['importaddress', 'walletpassphrase']:
+ if method not in ['importaddress', 'walletpassphrase', 'getaccount']:
log.debug('rpc: ' + method + " " + str(args))
res = self.jsonRpc.call(method, args)
if isinstance(res, unicode):
diff --git a/scripts/joinmarket-qt.py b/scripts/joinmarket-qt.py
new file mode 100644
index 0000000..2990514
--- /dev/null
+++ b/scripts/joinmarket-qt.py
@@ -0,0 +1,1740 @@
+#!/usr/bin/env python
+from __future__ import print_function
+
+'''
+Joinmarket GUI using PyQt for doing coinjoins.
+Some widgets copied and modified from https://github.com/spesmilo/electrum
+
+
+ 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 sys, base64, textwrap, re, datetime, os, math, json, logging
+import Queue, platform, csv, threading, time
+
+from decimal import Decimal
+from functools import partial
+from collections import namedtuple
+
+from PyQt4 import QtCore
+from PyQt4.QtGui import *
+
+if platform.system() == 'Windows':
+ MONOSPACE_FONT = 'Lucida Console'
+elif platform.system() == 'Darwin':
+ MONOSPACE_FONT = 'Monaco'
+else:
+ MONOSPACE_FONT = 'monospace'
+
+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;}"
+
+import jmbitcoin as btc
+
+JM_CORE_VERSION = '0.2.2'
+JM_GUI_VERSION = '5'
+
+from jmclient import (load_program_config, get_network, Wallet,
+ get_p2pk_vbyte, jm_single, validate_address,
+ get_log, weighted_order_choose, Taker,
+ JMTakerClientProtocolFactory, WalletError,
+ start_reactor)
+#from joinmarket import load_program_config, get_network, Wallet, encryptData, \
+# get_p2pk_vbyte, jm_single, mn_decode, mn_encode, create_wallet_file, \
+# validate_address, random_nick, get_log, IRCMessageChannel, \
+# weighted_order_choose, get_blockchain_interface_instance, joinmarket_alert, \
+# core_alert
+
+log = get_log()
+donation_address = '1LT6rwv26bV7mgvRosoSCyGM7ttVRsYidP'
+donation_address_testnet = 'mz6FQosuiNe8135XaQqWYmXsa3aD8YsqGL'
+
+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,
+ 'port': int,
+ 'usessl': bool,
+ 'socks5': bool,
+ 'network': bool,
+ 'socks5_port': int,
+ 'maker_timeout_sec': int,
+ 'tx_fees': int,
+ 'gaplimit': int,
+ 'check_high_fee': int,
+ 'max_mix_depth': int,
+ 'txfee_default': int,
+ 'order_wait_time': int,
+ 'privacy_warning': None}
+config_tips = {
+ 'blockchain_source': 'options: blockr, bitcoin-rpc',
+ 'network': 'one of "testnet" or "mainnet"',
+ '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': 'check to use SSL for connection to IRC',
+ 'socks5': 'check to use SOCKS5 proxy for IRC 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' +
+ ' but dont forget to bump your miner fees!',
+ '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, \n' +
+ 'as our default. Note that for clients not using a local blockchain \n' +
+ 'instance, we retrieve an estimate from the API at blockcypher.com, currently. \n',
+ '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',
+ 'txfee_default': 'Number of satoshis per counterparty for an initial\n' +
+ 'tx fee estimate; this value is not usually used and is best left at\n' +
+ 'the default of 5000',
+ 'order_wait_time': 'How long to wait for orders to arrive on entering\n' +
+ 'the message channel, default is 30s'
+}
+
+
+def JMQtMessageBox(obj, msg, mbtype='info', title=''):
+ 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:
+ mbtypes[mbtype](obj, title, msg)
+
+
+def update_config_for_gui():
+ '''The default joinmarket config does not contain these GUI settings
+ (they are generally set by command line flags or not needed).
+ If they are set in the file, use them, else set the defaults.
+ These *will* be persisted to joinmarket.cfg, but that will not affect
+ operation of the command line version.
+ '''
+ gui_config_names = ['gaplimit', 'history_file', 'check_high_fee',
+ 'max_mix_depth', 'txfee_default', 'order_wait_time',
+ 'daemon_port']
+ gui_config_default_vals = ['6', 'jm-tx-history.txt', '2', '5', '5000', '30',
+ '27183']
+ if "GUI" not in jm_single().config.sections():
+ jm_single().config.add_section("GUI")
+ gui_items = jm_single().config.items("GUI")
+ for gcn, gcv in zip(gui_config_names, gui_config_default_vals):
+ if gcn not in [_[0] for _ in gui_items]:
+ jm_single().config.set("GUI", gcn, gcv)
+ #Extra setting not exposed to the GUI, but only for the GUI app
+ if 'privacy_warning' not in [_[0] for _ in gui_items]:
+ print('overwriting privacy_warning')
+ jm_single().config.set("GUI", 'privacy_warning', '1')
+
+
+def persist_config():
+ '''This loses all comments in the config file.
+ TODO: possibly correct that.'''
+ with open('joinmarket.cfg', 'w') as f:
+ jm_single().config.write(f)
+
+class TaskThread(QtCore.QThread):
+ '''Thread that runs background tasks. Callbacks are guaranteed
+ to happen in the context of its parent.'''
+
+ Task = namedtuple("Task", "task cb_success cb_done cb_error")
+ doneSig = QtCore.pyqtSignal(object, object, object)
+
+ def __init__(self, parent, on_error=None):
+ super(TaskThread, self).__init__(parent)
+ self.on_error = on_error
+ self.tasks = Queue.Queue()
+ self.doneSig.connect(self.on_done)
+ self.start()
+
+ def add(self, task, on_success=None, on_done=None, on_error=None):
+ on_error = on_error or self.on_error
+ self.tasks.put(TaskThread.Task(task, on_success, on_done, on_error))
+
+ def run(self):
+ while True:
+ task = self.tasks.get()
+ if not task:
+ break
+ try:
+ result = task.task()
+ self.doneSig.emit(result, task.cb_done, task.cb_success)
+ except BaseException:
+ self.doneSig.emit(sys.exc_info(), task.cb_done, task.cb_error)
+
+ def on_done(self, result, cb_done, cb):
+ # This runs in the parent's thread.
+ if cb_done:
+ cb_done()
+ if cb:
+ cb(result)
+
+ def stop(self):
+ self.tasks.put(None)
+
+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)
+
+
+handler = QtHandler()
+handler.setFormatter(logging.Formatter("%(levelname)s:%(message)s"))
+log.addHandler(handler)
+
+
+class XStream(QtCore.QObject):
+ _stdout = None
+ _stderr = None
+ messageWritten = QtCore.pyqtSignal(str)
+
+ def flush(self):
+ pass
+
+ def fileno(self):
+ return -1
+
+ def write(self, msg):
+ if (not self.signalsBlocked()):
+ self.messageWritten.emit(unicode(msg))
+
+ @staticmethod
+ def stdout():
+ if (not XStream._stdout):
+ XStream._stdout = XStream()
+ sys.stdout = XStream._stdout
+ return XStream._stdout
+
+ @staticmethod
+ def stderr():
+ if (not XStream._stderr):
+ XStream._stderr = XStream()
+ 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)
+
+
+class HelpLabel(QLabel):
+
+ def __init__(self, text, help_text, wtitle):
+ QLabel.__init__(self, text)
+ self.help_text = help_text
+ self.wtitle = wtitle
+ self.font = QFont()
+ self.setStyleSheet(BLUE_FG)
+
+ def mouseReleaseEvent(self, x):
+ QMessageBox.information(w, self.wtitle, self.help_text, 'OK')
+
+ def enterEvent(self, event):
+ self.font.setUnderline(True)
+ self.setFont(self.font)
+ app.setOverrideCursor(QCursor(QtCore.Qt.PointingHandCursor))
+ return QLabel.enterEvent(self, event)
+
+ def leaveEvent(self, event):
+ self.font.setUnderline(False)
+ self.setFont(self.font)
+ app.setOverrideCursor(QCursor(QtCore.Qt.ArrowCursor))
+ return QLabel.leaveEvent(self, event)
+
+
+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
+ '''
+ password = unicode(password)
+ 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, new_pass=True):
+
+ self.new_pw = QLineEdit()
+ self.new_pw.setEchoMode(2)
+ self.conf_pw = QLineEdit()
+ self.conf_pw.setEchoMode(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 Password' if new_pass else 'Password'), 1, 0)
+ grid.addWidget(self.new_pw, 1, 1)
+
+ grid.addWidget(QLabel('Confirm Password'), 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(PasswordDialog, self).__init__()
+ self.initUI()
+
+ def initUI(self):
+ self.setWindowTitle('Create a new password')
+ msg = "Enter a new password"
+ 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)):
+ sm = QHeaderView.Stretch if col == self.stretch_column else QHeaderView.ResizeToContents
+ self.header().setResizeMode(col, sm)
+
+ 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).toString())
+ 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]))
+
+
+class SettingsTab(QDialog):
+
+ def __init__(self):
+ super(SettingsTab, self).__init__()
+ self.initUI()
+
+ def initUI(self):
+ outerGrid = QGridLayout()
+ sA = QScrollArea()
+ sA.setWidgetResizable(True)
+ frame = QFrame()
+ grid = QGridLayout()
+ self.settingsFields = []
+ j = 0
+ for i, section in enumerate(jm_single().config.sections()):
+ pairs = jm_single().config.items(section)
+ #an awkward design element from the core code: maker_timeout_sec
+ #is set outside the config, if it doesn't exist in the config.
+ #Add it here and it will be in the newly updated config file.
+ if section == 'MESSAGING' and 'maker_timeout_sec' not in [
+ _[0] for _ in pairs
+ ]:
+ jm_single().config.set(section, 'maker_timeout_sec', '60')
+ pairs = jm_single().config.items(section)
+ newSettingsFields = self.getSettingsFields(section,
+ [_[0] for _ in pairs])
+ self.settingsFields.extend(newSettingsFields)
+ sL = QLabel(section)
+ sL.setStyleSheet("QLabel {color: blue;}")
+ grid.addWidget(sL)
+ j += 1
+ for k, ns in enumerate(newSettingsFields):
+ grid.addWidget(ns[0], j, 0)
+ #try to find the tooltip for this label from config tips;
+ #it might not be there
+ if str(ns[0].text()) in config_tips:
+ ttS = config_tips[str(ns[0].text())]
+ ns[0].setToolTip(ttS)
+ grid.addWidget(ns[1], j, 1)
+ sfindex = len(self.settingsFields) - len(newSettingsFields) + k
+ if isinstance(ns[1], QCheckBox):
+ ns[1].toggled.connect(lambda checked, s=section,
+ q=sfindex: self.handleEdit(
+ s, self.settingsFields[q], checked))
+ else:
+ ns[1].editingFinished.connect(
+ lambda q=sfindex, s=section: self.handleEdit(s,
+ self.settingsFields[q]))
+ j += 1
+ outerGrid.addWidget(sA)
+ sA.setWidget(frame)
+ frame.setLayout(grid)
+ frame.adjustSize()
+ self.setLayout(outerGrid)
+ self.show()
+
+ def handleEdit(self, section, t, checked=None):
+ if isinstance(t[1], QCheckBox):
+ if str(t[0].text()) == 'Testnet':
+ oname = 'network'
+ oval = 'testnet' if checked else 'mainnet'
+ add = '' if not checked else ' - Testnet'
+ w.setWindowTitle(appWindowTitle + add)
+ else:
+ oname = str(t[0].text())
+ oval = 'true' if checked else 'false'
+ log.debug('setting section: ' + section + ' and name: ' + oname +
+ ' to: ' + oval)
+ jm_single().config.set(section, oname, oval)
+
+ else: #currently there is only QLineEdit
+ log.debug('setting section: ' + section + ' and name: ' + str(t[
+ 0].text()) + ' to: ' + str(t[1].text()))
+ jm_single().config.set(section, str(t[0].text()), str(t[1].text()))
+ if str(t[0].text()) == 'blockchain_source':
+ jm_single().bc_interface = get_blockchain_interface_instance(
+ jm_single().config)
+
+ def getSettingsFields(self, section, names):
+ results = []
+ for name in names:
+ val = jm_single().config.get(section, name)
+ if name in config_types:
+ t = config_types[name]
+ if t == bool:
+ qt = QCheckBox()
+ if val == 'testnet' or val.lower() == 'true':
+ qt.setChecked(True)
+ elif not t:
+ continue
+ else:
+ qt = QLineEdit(val)
+ if t == int:
+ qt.setValidator(QIntValidator(0, 65535))
+ else:
+ qt = QLineEdit(val)
+ label = 'Testnet' if name == 'network' else name
+ results.append((QLabel(label), qt))
+ return results
+
+
+class SpendTab(QWidget):
+
+ def __init__(self):
+ super(SpendTab, self).__init__()
+ self.initUI()
+ self.taker = None
+ self.filter_offers_response = None
+ self.taker_info_response = None
+ self.clientfactory = None
+ #signals from client backend to GUI
+ self.jmclient_obj = QtCore.QObject()
+ #This signal/callback requires user acceptance decision.
+ self.jmclient_obj.connect(self.jmclient_obj, QtCore.SIGNAL('JMCLIENT:offers'),
+ self.checkOffers)
+ #This signal/callback is for information only (including abort/error
+ #conditions which require no feedback from user.
+ self.jmclient_obj.connect(self.jmclient_obj, QtCore.SIGNAL('JMCLIENT:info'),
+ self.takerInfo)
+ #Signal indicating Taker has finished its work
+ self.jmclient_obj.connect(self.jmclient_obj, QtCore.SIGNAL('JMCLIENT:finished'),
+ self.takerFinished)
+
+ def initUI(self):
+ vbox = QVBoxLayout(self)
+ top = QFrame()
+ top.setFrameShape(QFrame.StyledPanel)
+ topLayout = QGridLayout()
+ top.setLayout(topLayout)
+ sA = QScrollArea()
+ sA.setWidgetResizable(True)
+ topLayout.addWidget(sA)
+ iFrame = QFrame()
+ sA.setWidget(iFrame)
+ innerTopLayout = QGridLayout()
+ innerTopLayout.setSpacing(4)
+ iFrame.setLayout(innerTopLayout)
+
+ donateLayout = QHBoxLayout()
+ self.donateCheckBox = QCheckBox()
+ self.donateCheckBox.setChecked(False)
+ self.donateCheckBox.setMaximumWidth(30)
+ self.donateLimitBox = QDoubleSpinBox()
+ self.donateLimitBox.setMinimum(0.001)
+ self.donateLimitBox.setMaximum(0.100)
+ self.donateLimitBox.setSingleStep(0.001)
+ self.donateLimitBox.setDecimals(3)
+ self.donateLimitBox.setValue(0.010)
+ self.donateLimitBox.setMaximumWidth(100)
+ self.donateLimitBox.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
+ donateLayout.addWidget(self.donateCheckBox)
+ label1 = QLabel("Check to send change lower than: ")
+ label1.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
+ donateLayout.addWidget(label1)
+ donateLayout.setAlignment(label1, QtCore.Qt.AlignLeft)
+ donateLayout.addWidget(self.donateLimitBox)
+ donateLayout.setAlignment(self.donateLimitBox, QtCore.Qt.AlignLeft)
+ label2 = QLabel(" BTC as a donation.")
+ donateLayout.addWidget(label2)
+ label2.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
+ donateLayout.setAlignment(label2, QtCore.Qt.AlignLeft)
+ label3 = HelpLabel('More', '\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.']),
+ 'About the donation feature')
+ label3.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
+ donateLayout.setAlignment(label3, QtCore.Qt.AlignLeft)
+ donateLayout.addWidget(label3)
+ donateLayout.addStretch(1)
+ innerTopLayout.addLayout(donateLayout, 0, 0, 1, 2)
+ self.widgets = self.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: self.checkAddress(self.widgets[0][1].text()))
+ self.startButton = QPushButton('Start')
+ self.startButton.setToolTip(
+ 'You will be prompted to decide whether to accept\n' +
+ 'the transaction after connecting, and shown the\n' +
+ 'fees to pay; you can cancel at that point if you wish.')
+ self.startButton.clicked.connect(self.startSendPayment)
+ #TODO: how to make the Abort button work, at least some of the time..
+ self.abortButton = QPushButton('Abort')
+ self.abortButton.setEnabled(False)
+ buttons = QHBoxLayout()
+ buttons.addStretch(1)
+ buttons.addWidget(self.startButton)
+ buttons.addWidget(self.abortButton)
+ innerTopLayout.addLayout(buttons, len(self.widgets) + 1, 0, 1, 2)
+ splitter1 = QSplitter(QtCore.Qt.Vertical)
+ self.textedit = QTextEdit()
+ self.textedit.verticalScrollBar().rangeChanged.connect(
+ self.resizeScroll)
+ XStream.stdout().messageWritten.connect(self.updateConsoleText)
+ XStream.stderr().messageWritten.connect(self.updateConsoleText)
+ splitter1.addWidget(top)
+ splitter1.addWidget(self.textedit)
+ splitter1.setSizes([400, 200])
+ self.setLayout(vbox)
+ vbox.addWidget(splitter1)
+ self.show()
+
+ def updateConsoleText(self, txt):
+ #these alerts are a bit suboptimal;
+ #colored is better, and in the ultra-rare
+ #case of getting both, one will be swallowed.
+ #However, the transaction confirmation dialog
+ #will at least show both in RED and BOLD, and they will be more prominent.
+ #TODO in new daemon this is not accessible? Or?
+ """
+ if joinmarket_alert[0]:
+ w.statusBar().showMessage("JOINMARKET ALERT: " + joinmarket_alert[
+ 0])
+ if core_alert[0]:
+ w.statusBar().showMessage("BITCOIN CORE ALERT: " + core_alert[0])
+ """
+ self.textedit.insertPlainText(txt)
+
+ def resizeScroll(self, mini, maxi):
+ self.textedit.verticalScrollBar().setValue(maxi)
+
+ def startSendPayment(self, ignored_makers=None):
+ self.aborted = False
+ if not self.validateSettings():
+ return
+ if jm_single().config.get("BLOCKCHAIN",
+ "blockchain_source") == 'blockr':
+ res = self.showBlockrWarning()
+ if res == True:
+ return
+
+ #all settings are valid; start
+ JMQtMessageBox(
+ self,
+ "Connecting to IRC.\nView real-time log in the lower pane.",
+ title="Sendpayment")
+ self.startButton.setEnabled(False)
+ self.abortButton.setEnabled(True)
+
+ log.debug('starting sendpayment')
+
+ w.statusBar().showMessage("Syncing wallet ...")
+ jm_single().bc_interface.sync_wallet(w.wallet, fast=True)
+ self.destaddr = str(self.widgets[0][1].text())
+ #convert from bitcoins (enforced by QDoubleValidator) to satoshis
+ self.btc_amount_str = str(self.widgets[3][1].text())
+ amount = int(Decimal(self.btc_amount_str) * Decimal('1e8'))
+ makercount = int(self.widgets[1][1].text())
+ mixdepth = int(self.widgets[2][1].text())
+ #note 'amount' is integer, so not interpreted as fraction
+ #TODO allow fractional for mixdepth? would need a dialog/warning
+ self.taker_schedule = [(mixdepth, amount, makercount,
+ self.destaddr, 0)]
+ self.taker = Taker(w.wallet,
+ self.taker_schedule,
+ order_chooser=weighted_order_choose,
+ callbacks=[self.callback_checkOffers,
+ self.callback_takerInfo,
+ self.callback_takerFinished])
+ if ignored_makers:
+ self.taker.ignored_makers.extend(ignored_makers)
+ if not self.clientfactory:
+ #First run means we need to start: create clientfactory
+ #and start reactor Thread
+ self.clientfactory = JMTakerClientProtocolFactory(self.taker)
+ thread = TaskThread(self)
+ thread.add(partial(start_reactor,
+ "localhost",
+ jm_single().config.getint("GUI", "daemon_port"),
+ self.clientfactory,
+ ish=False,
+ daemon=True))
+ else:
+ self.clientfactory.getClient().taker = self.taker
+ self.clientfactory.getClient().clientStart()
+ w.statusBar().showMessage("Connecting to IRC ...")
+
+ def callback_checkOffers(self, offers_fee, cjamount):
+ """Receives the signal from the JMClient thread
+ """
+ self.offers_fee = offers_fee
+ self.proposed_amount = cjamount
+ self.jmclient_obj.emit(QtCore.SIGNAL('JMCLIENT:offers'))
+ #The JMClient thread must wait for user input
+ while not self.filter_offers_response:
+ time.sleep(0.1)
+ if self.filter_offers_response == "ACCEPT":
+ self.filter_offers_response = None
+ return True
+ self.filter_offers_response = None
+ return False
+
+ def callback_takerInfo(self, infotype, infomsg):
+ if infotype == "ABORT":
+ self.taker_info_type = 'warn'
+ elif infotype == "INFO":
+ self.taker_info_type = 'info'
+ else:
+ raise NotImplementedError
+ self.taker_infomsg = infomsg
+ self.jmclient_obj.emit(QtCore.SIGNAL('JMCLIENT:info'))
+ while not self.taker_info_response:
+ time.sleep(0.1)
+ #No need to check response type, only OK for msgbox
+ self.taker_info_response = None
+ return
+
+ def callback_takerFinished(self, res, fromtx=False, waittime=0):
+ self.taker_finished_res = res
+ self.taker_finished_fromtx = fromtx
+ #TODO; equivalent of reactor.callLater to deliberately delay (for tumbler)
+ self.taker_finished_waittime = waittime
+ self.jmclient_obj.emit(QtCore.SIGNAL('JMCLIENT:finished'))
+ return
+
+ def takerInfo(self):
+ if self.taker_info_type == "info":
+ w.statusBar().showMessage(self.taker_infomsg)
+ else:
+ JMQtMessageBox(self, self.taker_infomsg, mbtype=self.taker_info_type)
+ self.taker_info_response = True
+
+ def checkOffers(self):
+ """Parse offers and total fee from client protocol,
+ allow the user to agree or decide.
+ """
+ if not self.offers_fee:
+ JMQtMessageBox(self,
+ "Not enough matching offers found.",
+ mbtype='warn',
+ title="Error")
+ self.giveUp()
+ return
+ offers, total_cj_fee = self.offers_fee
+ total_fee_pc = 1.0 * total_cj_fee / self.proposed_amount
+ #reset the btc amount display string if it's a sweep:
+ if self.taker.cjamount == 0:
+ self.btc_amount_str = str(
+ (Decimal(self.proposed_amount) / Decimal('1e8'))) + " BTC"
+
+ #TODO separate this out into a function
+ mbinfo = []
+ #See note above re: alerts
+ """
+ if joinmarket_alert[0]:
+ mbinfo.append("JOINMARKET ALERT: " +
+ joinmarket_alert[0] + "")
+ mbinfo.append(" ")
+ if core_alert[0]:
+ mbinfo.append("BITCOIN CORE ALERT: " +
+ core_alert[0] + "")
+ mbinfo.append(" ")
+ """
+ mbinfo.append("Sending amount: " + self.btc_amount_str)
+ mbinfo.append("to address: " + self.destaddr)
+ mbinfo.append(" ")
+ mbinfo.append("Counterparties chosen:")
+ mbinfo.append('Name, Order id, Coinjoin fee (sat.)')
+ for k, o in offers.iteritems():
+ if o['ordertype'] == 'reloffer':
+ display_fee = int(self.proposed_amount *
+ float(o['cjfee'])) - int(o['txfee'])
+ elif o['ordertype'] == 'absoffer':
+ display_fee = int(o['cjfee']) - int(o['txfee'])
+ else:
+ log.debug("Unsupported order type: " + str(o['ordertype']) +
+ ", aborting.")
+ self.giveUp()
+ return False
+ mbinfo.append(k + ', ' + str(o['oid']) + ', ' + str(
+ display_fee))
+ mbinfo.append('Total coinjoin fee = ' + str(total_cj_fee) +
+ ' satoshis, or ' + str(float('%.3g' % (
+ 100.0 * total_fee_pc))) + '%')
+ title = 'Check Transaction'
+ if total_fee_pc * 100 > jm_single().config.getint("GUI",
+ "check_high_fee"):
+ title += ': WARNING: Fee is HIGH!!'
+ reply = JMQtMessageBox(self,
+ '\n'.join([m + '
' for m in mbinfo]),
+ mbtype='question',
+ title=title)
+ if reply == QMessageBox.Yes:
+ #amount is now accepted; pass control back to reactor
+ self.cjamount = self.proposed_amount
+ self.filter_offers_response = "ACCEPT"
+ else:
+ self.filter_offers_response = "REJECT"
+ self.giveUp()
+
+ def takerFinished(self):
+ if self.taker_finished_fromtx:
+ if self.taker_finished_res:
+ jm_single().bc_interface.sync_wallet(wallet)
+ self.clientfactory.getClient().clientStart()
+ else:
+ #a transaction failed; just stop
+ self.giveUp()
+ else:
+ if not self.taker_finished_res:
+ log.info("Did not complete successfully, shutting down")
+ else:
+ log.info("All transactions completed correctly")
+ self.cleanUp()
+
+ def giveUp(self):
+ self.aborted = True
+ log.debug("Transaction aborted.")
+ self.abortButton.setEnabled(False)
+ self.startButton.setEnabled(True)
+ w.statusBar().showMessage("Transaction aborted.")
+
+ def cleanUp(self):
+ if not self.taker.txid:
+ if not self.aborted:
+ if not self.taker.ignored_makers:
+ w.statusBar().showMessage("Transaction failed.")
+ JMQtMessageBox(self,
+ "Transaction was not completed.",
+ mbtype='warn',
+ title="Failed")
+ else:
+ reply = JMQtMessageBox(
+ self,
+ '\n'.join([
+ "The following counterparties did not respond: ",
+ ','.join(self.taker.ignored_makers),
+ "This sometimes happens due to bad network connections.",
+ "",
+ "If you would like to try again, ignoring those",
+ "counterparties, click Yes."
+ ]),
+ mbtype='question',
+ title="Transaction not completed.")
+ if reply == QMessageBox.Yes:
+ self.startSendPayment(
+ ignored_makers=self.taker.ignored_makers)
+ else:
+ self.giveUp()
+ return
+
+ else:
+ w.statusBar().showMessage("Transaction completed successfully.")
+ JMQtMessageBox(self,
+ "Transaction has been broadcast.\n" + "Txid: " +
+ str(self.taker.txid),
+ title="Success")
+ #persist the transaction to history
+ with open(jm_single().config.get("GUI", "history_file"), 'ab') as f:
+ f.write(','.join([self.destaddr, self.btc_amount_str,
+ self.taker.txid, datetime.datetime.now(
+ ).strftime("%Y/%m/%d %H:%M:%S")]))
+ f.write('\n') #TODO: Windows
+ #update the TxHistory tab
+ txhist = w.centralWidget().widget(3)
+ txhist.updateTxInfo()
+
+ self.startButton.setEnabled(True)
+ self.abortButton.setEnabled(False)
+
+ def validateSettings(self):
+ valid, errmsg = validate_address(self.widgets[0][1].text())
+ if not valid:
+ JMQtMessageBox(self, errmsg, mbtype='warn', title="Error")
+ return False
+ errs = ["Non-zero number of counterparties must be provided.",
+ "Mixdepth must be chosen.",
+ "Amount, in bitcoins, must be provided."]
+ for i in range(1, 4):
+ if self.widgets[i][1].text().size() == 0:
+ JMQtMessageBox(self, errs[i - 1], mbtype='warn', title="Error")
+ return False
+ #QIntValidator does not prevent entry of 0 for counterparties.
+ #Note, use of '1' is not recommended, but not prevented here.
+ if self.widgets[1][1].text() == '0':
+ JMQtMessageBox(self, errs[0], mbtype='warn', title="Error")
+ return False
+ if not w.wallet:
+ JMQtMessageBox(self,
+ "There is no wallet loaded.",
+ mbtype='warn',
+ title="Error")
+ return False
+ return True
+
+ def showBlockrWarning(self):
+ if jm_single().config.getint("GUI", "privacy_warning") == 0:
+ return False
+ qmb = QMessageBox()
+ qmb.setIcon(QMessageBox.Warning)
+ qmb.setWindowTitle("Privacy Warning")
+ qcb = QCheckBox("Don't show this warning again.")
+ lyt = qmb.layout()
+ lyt.addWidget(QLabel(warnings['blockr_privacy']), 0, 1)
+ lyt.addWidget(qcb, 1, 1)
+ qmb.addButton(QPushButton("Continue"), QMessageBox.YesRole)
+ qmb.addButton(QPushButton("Cancel"), QMessageBox.NoRole)
+
+ qmb.exec_()
+
+ switch_off_warning = '0' if qcb.isChecked() else '1'
+ jm_single().config.set("GUI", "privacy_warning", switch_off_warning)
+
+ res = qmb.buttonRole(qmb.clickedButton())
+ if res == QMessageBox.YesRole:
+ return False
+ elif res == QMessageBox.NoRole:
+ return True
+ else:
+ log.debug("GUI error: unrecognized button, canceling.")
+ return True
+
+ def checkAddress(self, addr):
+ valid, errmsg = validate_address(str(addr))
+ if not valid:
+ JMQtMessageBox(self,
+ "Bitcoin address not valid.\n" + errmsg,
+ mbtype='warn',
+ title="Error")
+
+ def getSettingsWidgets(self):
+ results = []
+ sN = ['Recipient address', 'Number of counterparties', 'Mixdepth',
+ 'Amount in bitcoins (BTC)']
+ 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',
+ 'The mixdepth of the wallet to send the payment from',
+ 'The amount IN BITCOINS to send.\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 = ['', '3', '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:
+ qle.setValidator(QDoubleValidator(*x[4]))
+ results.append((ql, qle))
+ return results
+
+
+class TxHistoryTab(QWidget):
+
+ def __init__(self):
+ super(TxHistoryTab, self).__init__()
+ self.initUI()
+
+ def initUI(self):
+ self.tHTW = MyTreeWidget(self, self.create_menu, self.getHeaders())
+ self.tHTW.setSelectionMode(QAbstractItemView.ExtendedSelection)
+ self.tHTW.header().setResizeMode(QHeaderView.Interactive)
+ self.tHTW.header().setStretchLastSection(False)
+ self.tHTW.on_update = self.updateTxInfo
+ vbox = QVBoxLayout()
+ self.setLayout(vbox)
+ vbox.setMargin(0)
+ vbox.setSpacing(0)
+ vbox.addWidget(self.tHTW)
+ self.updateTxInfo()
+ self.show()
+
+ def getHeaders(self):
+ '''Function included in case dynamic in future'''
+ return ['Receiving address', 'Amount in BTC', 'Transaction id', 'Date']
+
+ def updateTxInfo(self, txinfo=None):
+ self.tHTW.clear()
+ if not txinfo:
+ txinfo = self.getTxInfoFromFile()
+ for t in txinfo:
+ t_item = QTreeWidgetItem(t)
+ self.tHTW.addChild(t_item)
+ for i in range(4):
+ self.tHTW.resizeColumnToContents(i)
+
+ def getTxInfoFromFile(self):
+ hf = jm_single().config.get("GUI", "history_file")
+ if not os.path.isfile(hf):
+ if w:
+ w.statusBar().showMessage("No transaction history found.")
+ return []
+ txhist = []
+ with open(hf, 'rb') as f:
+ txlines = f.readlines()
+ for tl in txlines:
+ txhist.append(tl.strip().split(','))
+ if not len(txhist[-1]) == 4:
+ JMQtMessageBox(self,
+ "Incorrectedly formatted file " + hf,
+ mbtype='warn',
+ title="Error")
+ w.statusBar().showMessage("No transaction history found.")
+ return []
+ return txhist[::-1
+ ] #appended to file in date order, window shows reverse
+
+ def create_menu(self, position):
+ item = self.tHTW.currentItem()
+ if not item:
+ return
+ address_valid = False
+ if item:
+ address = str(item.text(0))
+ try:
+ btc.b58check_to_hex(address)
+ address_valid = True
+ except AssertionError:
+ log.debug('no btc address found, not creating menu item')
+
+ menu = QMenu()
+ if address_valid:
+ menu.addAction("Copy address to clipboard",
+ lambda: app.clipboard().setText(address))
+ menu.addAction("Copy transaction id to clipboard",
+ lambda: app.clipboard().setText(str(item.text(2))))
+ menu.addAction("Copy full tx info to clipboard",
+ lambda: app.clipboard().setText(
+ ','.join([str(item.text(_)) for _ in range(4)])))
+ menu.exec_(self.tHTW.viewport().mapToGlobal(position))
+
+
+class JMWalletTab(QWidget):
+
+ def __init__(self):
+ super(JMWalletTab, self).__init__()
+ self.wallet_name = 'NONE'
+ self.initUI()
+
+ def initUI(self):
+ self.label1 = QLabel(
+ "CURRENT WALLET: " + self.wallet_name + ', total balance: 0.0',
+ self)
+ v = MyTreeWidget(self, self.create_menu, self.getHeaders())
+ v.setSelectionMode(QAbstractItemView.ExtendedSelection)
+ v.on_update = self.updateWalletInfo
+ self.history = v
+ vbox = QVBoxLayout()
+ self.setLayout(vbox)
+ vbox.setMargin(0)
+ vbox.setSpacing(0)
+ vbox.addWidget(self.label1)
+ vbox.addWidget(v)
+ buttons = QWidget()
+ vbox.addWidget(buttons)
+ self.updateWalletInfo()
+ #vBoxLayout.addWidget(self.label2)
+ #vBoxLayout.addWidget(self.table)
+ self.show()
+
+ def getHeaders(self):
+ '''Function included in case dynamic in future'''
+ return ['Address', 'Index', 'Balance', 'Used/New']
+
+ def create_menu(self, position):
+ item = self.history.currentItem()
+ address_valid = False
+ if item:
+ address = str(item.text(0))
+ try:
+ btc.b58check_to_hex(address)
+ address_valid = True
+ except AssertionError:
+ log.debug('no btc address found, not creating menu item')
+
+ menu = QMenu()
+ if address_valid:
+ menu.addAction("Copy address to clipboard",
+ lambda: app.clipboard().setText(address))
+ menu.addAction("Resync wallet from blockchain",
+ lambda: w.resyncWallet())
+ #TODO add more items to context menu
+ menu.exec_(self.history.viewport().mapToGlobal(position))
+
+ def updateWalletInfo(self, walletinfo=None):
+ l = self.history
+ l.clear()
+ if walletinfo:
+ self.mainwindow = self.parent().parent().parent()
+ rows, mbalances, total_bal = walletinfo
+ if get_network() == 'testnet':
+ self.wallet_name = self.mainwindow.wallet.seed
+ else:
+ self.wallet_name = os.path.basename(self.mainwindow.wallet.path)
+ self.label1.setText("CURRENT WALLET: " + self.wallet_name +
+ ', total balance: ' + total_bal)
+
+ for i in range(jm_single().config.getint("GUI", "max_mix_depth")):
+ if walletinfo:
+ mdbalance = mbalances[i]
+ else:
+ mdbalance = "{0:.8f}".format(0)
+ m_item = QTreeWidgetItem(["Mixdepth " + str(i) + " , balance: " +
+ mdbalance, '', '', '', ''])
+ l.addChild(m_item)
+ for forchange in [0, 1]:
+ heading = 'EXTERNAL' if forchange == 0 else 'INTERNAL'
+ heading_end = ' addresses m/0/%d/%d/' % (i, forchange)
+ heading += heading_end
+ seq_item = QTreeWidgetItem([heading, '', '', '', ''])
+ m_item.addChild(seq_item)
+ if not forchange:
+ seq_item.setExpanded(True)
+ if not walletinfo:
+ item = QTreeWidgetItem(['None', '', '', ''])
+ seq_item.addChild(item)
+ else:
+ for j in range(len(rows[i][forchange])):
+ item = QTreeWidgetItem(rows[i][forchange][j])
+ item.setFont(0, QFont(MONOSPACE_FONT))
+ if rows[i][forchange][j][3] == 'used':
+ item.setForeground(3, QBrush(QColor('red')))
+ seq_item.addChild(item)
+
+
+class JMMainWindow(QMainWindow):
+
+ def __init__(self):
+ super(JMMainWindow, self).__init__()
+ self.wallet = None
+ self.initUI()
+
+ def closeEvent(self, event):
+ quit_msg = "Are you sure you want to quit?"
+ reply = JMQtMessageBox(self, quit_msg, mbtype='question')
+ if reply == QMessageBox.Yes:
+ persist_config()
+ event.accept()
+ else:
+ event.ignore()
+
+ def initUI(self):
+ self.statusBar().showMessage("Ready")
+ self.setGeometry(300, 300, 250, 150)
+ exitAction = QAction(QIcon('exit.png'), '&Exit', self)
+ exitAction.setShortcut('Ctrl+Q')
+ exitAction.setStatusTip('Exit application')
+ exitAction.triggered.connect(qApp.quit)
+ generateAction = QAction('&Generate', self)
+ generateAction.setStatusTip('Generate new wallet')
+ generateAction.triggered.connect(self.generateWallet)
+ loadAction = QAction('&Load', self)
+ loadAction.setStatusTip('Load wallet from file')
+ loadAction.triggered.connect(self.selectWallet)
+ recoverAction = QAction('&Recover', self)
+ recoverAction.setStatusTip('Recover wallet from seedphrase')
+ recoverAction.triggered.connect(self.recoverWallet)
+ aboutAction = QAction('About Joinmarket', self)
+ aboutAction.triggered.connect(self.showAboutDialog)
+ exportPrivAction = QAction('&Export keys', self)
+ exportPrivAction.setStatusTip('Export all private keys to a csv file')
+ exportPrivAction.triggered.connect(self.exportPrivkeysCsv)
+ menubar = QMenuBar()
+
+ walletMenu = menubar.addMenu('&Wallet')
+ walletMenu.addAction(loadAction)
+ walletMenu.addAction(generateAction)
+ walletMenu.addAction(recoverAction)
+ walletMenu.addAction(exportPrivAction)
+ walletMenu.addAction(exitAction)
+ aboutMenu = menubar.addMenu('&About')
+ aboutMenu.addAction(aboutAction)
+
+ self.setMenuBar(menubar)
+ self.show()
+
+ def showAboutDialog(self):
+ msgbox = QDialog(self)
+ lyt = QVBoxLayout(msgbox)
+ msgbox.setWindowTitle(appWindowTitle)
+ label1 = QLabel()
+ label1.setText(
+ ""
+ + "Read more about Joinmarket
" + "
".join(
+ ["Joinmarket core software version: " + JM_CORE_VERSION,
+ "JoinmarketQt version: " + JM_GUI_VERSION,
+ "Messaging protocol version:" + " %s" % (
+ str(jm_single().JM_VERSION)
+ ), "Help us support Bitcoin fungibility -", "donate here: "]))
+ label2 = QLabel(donation_address)
+ for l in [label1, label2]:
+ l.setTextFormat(QtCore.Qt.RichText)
+ l.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction)
+ l.setOpenExternalLinks(True)
+ label2.setText("" +
+ donation_address + "")
+ lyt.addWidget(label1)
+ lyt.addWidget(label2)
+ btnbox = QDialogButtonBox(msgbox)
+ btnbox.setStandardButtons(QDialogButtonBox.Ok)
+ btnbox.accepted.connect(msgbox.accept)
+ lyt.addWidget(btnbox)
+ msgbox.exec_()
+
+ def exportPrivkeysCsv(self):
+ if not self.wallet:
+ JMQtMessageBox(self,
+ "No wallet loaded.",
+ mbtype='crit',
+ title="Error")
+ return
+ #TODO add password protection; too critical
+ d = QDialog(self)
+ d.setWindowTitle('Private keys')
+ d.setMinimumSize(850, 300)
+ vbox = QVBoxLayout(d)
+
+ msg = "%s\n%s\n%s" % (
+ "WARNING: ALL your private keys are secret.",
+ "Exposing a single private key can compromise your entire wallet!",
+ "In particular, DO NOT use 'redeem private key' services proposed by third parties."
+ )
+ vbox.addWidget(QLabel(msg))
+ e = QTextEdit()
+ e.setReadOnly(True)
+ vbox.addWidget(e)
+ b = OkButton(d, 'Export')
+ b.setEnabled(False)
+ vbox.addLayout(Buttons(CancelButton(d), b))
+ private_keys = {}
+ #prepare list of addresses with non-zero balance
+ #TODO: consider adding a 'export insanely huge amount'
+ #option for anyone with gaplimit troubles, although
+ #that is a complete mess for a user, mostly changing
+ #the gaplimit in the Settings tab should address it.
+ rows = get_wallet_printout(self.wallet)
+ addresses = []
+ for forchange in rows[0]:
+ for mixdepth in forchange:
+ for addr_info in mixdepth:
+ if float(addr_info[2]) > 0:
+ addresses.append(addr_info[0])
+ done = False
+
+ def privkeys_thread():
+ for addr in addresses:
+ time.sleep(0.1)
+ if done:
+ break
+ priv = self.wallet.get_key_from_addr(addr)
+ private_keys[addr] = btc.wif_compressed_privkey(
+ priv,
+ vbyte=get_p2pk_vbyte())
+ d.emit(QtCore.SIGNAL('computing_privkeys'))
+ d.emit(QtCore.SIGNAL('show_privkeys'))
+
+ def show_privkeys():
+ s = "\n".join(map(lambda x: x[0] + "\t" + x[1], private_keys.items(
+ )))
+ e.setText(s)
+ b.setEnabled(True)
+
+ d.connect(
+ d, QtCore.SIGNAL('computing_privkeys'),
+ lambda: e.setText("Please wait... %d/%d" % (len(private_keys), len(addresses))))
+ d.connect(d, QtCore.SIGNAL('show_privkeys'), show_privkeys)
+
+ threading.Thread(target=privkeys_thread).start()
+ if not d.exec_():
+ done = True
+ return
+ privkeys_fn_base = 'joinmarket-private-keys'
+ i = 0
+ privkeys_fn = privkeys_fn_base
+ while os.path.isfile(privkeys_fn + '.csv'):
+ i += 1
+ privkeys_fn = privkeys_fn_base + str(i)
+ try:
+ with open(privkeys_fn + '.csv', "w") as f:
+ transaction = csv.writer(f)
+ transaction.writerow(["address", "private_key"])
+ for addr, pk in private_keys.items():
+ #sanity check
+ if not btc.privtoaddr(
+ btc.from_wif_privkey(pk,
+ vbyte=get_p2pk_vbyte()),
+ magicbyte=get_p2pk_vbyte()) == addr:
+ JMQtMessageBox(None, "Failed to create privkey export -" +\
+ " critical error in key parsing.",
+ mbtype='crit')
+ return
+ transaction.writerow(["%34s" % addr, pk])
+ except (IOError, os.error), reason:
+ export_error_label = "JoinmarketQt was unable to produce a private key-export."
+ JMQtMessageBox(None,
+ export_error_label + "\n" + str(reason),
+ mbtype='crit',
+ title="Unable to create csv")
+
+ except Exception as e:
+ JMQtMessageBox(self, str(e), mbtype='crit', title="Error")
+ return
+
+ JMQtMessageBox(self,
+ "Private keys exported to: " + privkeys_fn + '.csv',
+ title="Success")
+
+ def recoverWallet(self):
+ if get_network() == 'testnet':
+ JMQtMessageBox(self,
+ 'recover from seedphrase not supported for testnet',
+ mbtype='crit',
+ title="Error")
+ return
+ d = QDialog(self)
+ d.setModal(1)
+ d.setWindowTitle('Recover from seed')
+ layout = QGridLayout(d)
+ message_e = QTextEdit()
+ layout.addWidget(QLabel('Enter 12 words'), 0, 0)
+ layout.addWidget(message_e, 1, 0)
+ hbox = QHBoxLayout()
+ buttonBox = QDialogButtonBox(self)
+ buttonBox.setStandardButtons(QDialogButtonBox.Ok |
+ QDialogButtonBox.Cancel)
+ buttonBox.button(QDialogButtonBox.Ok).clicked.connect(d.accept)
+ buttonBox.button(QDialogButtonBox.Cancel).clicked.connect(d.reject)
+ hbox.addWidget(buttonBox)
+ layout.addLayout(hbox, 3, 0)
+ result = d.exec_()
+ if result != QDialog.Accepted:
+ return
+ msg = str(message_e.toPlainText())
+ words = msg.split() #splits on any number of ws chars
+ if not len(words) == 12:
+ JMQtMessageBox(self,
+ "You did not provide 12 words, aborting.",
+ mbtype='warn',
+ title="Error")
+ else:
+ try:
+ seed = mn_decode(words)
+ self.initWallet(seed=seed)
+ except ValueError as e:
+ JMQtMessageBox(self,
+ "Could not decode seedphrase: " + repr(e),
+ mbtype='warn',
+ title="Error")
+
+ def selectWallet(self, testnet_seed=None):
+ if get_network() != 'testnet':
+ current_path = os.path.dirname(os.path.realpath(__file__))
+ if os.path.isdir(os.path.join(current_path, 'wallets')):
+ current_path = os.path.join(current_path, 'wallets')
+ firstarg = QFileDialog.getOpenFileName(self,
+ 'Choose Wallet File',
+ directory=current_path)
+ #TODO validate the file looks vaguely like a wallet file
+ log.debug('Looking for wallet in: ' + firstarg)
+ if not firstarg:
+ return
+ decrypted = False
+ while not decrypted:
+ text, ok = QInputDialog.getText(self,
+ 'Decrypt wallet',
+ 'Enter your password:',
+ mode=QLineEdit.Password)
+ if not ok:
+ return
+ pwd = str(text).strip()
+ decrypted = self.loadWalletFromBlockchain(firstarg, pwd)
+ else:
+ if not testnet_seed:
+ testnet_seed, ok = QInputDialog.getText(self,
+ 'Load Testnet wallet',
+ 'Enter a testnet seed:',
+ mode=QLineEdit.Normal)
+ if not ok:
+ return
+ firstarg = str(testnet_seed)
+ pwd = None
+ #ignore return value as there is no decryption failure possible
+ self.loadWalletFromBlockchain(firstarg, pwd)
+
+ def loadWalletFromBlockchain(self, firstarg=None, pwd=None):
+ if (firstarg and pwd) or (firstarg and get_network() == 'testnet'):
+ try:
+ self.wallet = Wallet(
+ str(firstarg),
+ pwd,
+ max_mix_depth=jm_single().config.getint(
+ "GUI", "max_mix_depth"))
+ except WalletError:
+ JMQtMessageBox(self,
+ "Wrong password",
+ mbtype='warn',
+ title="Error")
+ return False
+ if 'listunspent_args' not in jm_single().config.options('POLICY'):
+ jm_single().config.set('POLICY', 'listunspent_args', '[0]')
+ assert self.wallet, "No wallet loaded"
+ thread = TaskThread(self)
+ task = partial(jm_single().bc_interface.sync_wallet, self.wallet)
+ thread.add(task, on_done=self.updateWalletInfo)
+ self.statusBar().showMessage("Reading wallet from blockchain ...")
+ return True
+
+ def updateWalletInfo(self):
+ t = self.centralWidget().widget(0)
+ if not self.wallet: #failure to sync in constructor means object is not created
+ newstmsg = "Unable to sync wallet - see error in console."
+ else:
+ t.updateWalletInfo(get_wallet_printout(self.wallet))
+ newstmsg = "Wallet synced successfully."
+ self.statusBar().showMessage(newstmsg)
+
+ def resyncWallet(self):
+ if not self.wallet:
+ JMQtMessageBox(self,
+ "No wallet loaded",
+ mbtype='warn',
+ title="Error")
+ return
+ self.loadWalletFromBlockchain()
+
+ def generateWallet(self):
+ log.debug('generating wallet')
+ if get_network() == 'testnet':
+ seed = self.getTestnetSeed()
+ self.selectWallet(testnet_seed=seed)
+ else:
+ self.initWallet()
+
+ def getTestnetSeed(self):
+ text, ok = QInputDialog.getText(
+ self, 'Testnet seed', 'Enter a string as seed (can be anything):')
+ if not ok or not text:
+ JMQtMessageBox(self,
+ "No seed entered, aborting",
+ mbtype='warn',
+ title="Error")
+ return
+ return str(text).strip()
+
+ def initWallet(self, seed=None):
+ '''Creates a new mainnet
+ wallet
+ '''
+ if not seed:
+ seed = btc.sha256(os.urandom(64))[:32]
+ words = mn_encode(seed)
+ mb = QMessageBox()
+ seed_recovery_warning = [
+ "WRITE DOWN THIS WALLET RECOVERY SEED.",
+ "If you fail to do this, your funds are",
+ "at risk. Do NOT ignore this step!!!"
+ ]
+ mb.setText("\n".join(seed_recovery_warning))
+ mb.setInformativeText(' '.join(words))
+ mb.setStandardButtons(QMessageBox.Ok)
+ ret = mb.exec_()
+
+ pd = PasswordDialog()
+ while True:
+ pd.exec_()
+ if pd.new_pw.text() != pd.conf_pw.text():
+ JMQtMessageBox(self,
+ "Passwords don't match.",
+ mbtype='warn',
+ title="Error")
+ continue
+ break
+
+ walletfile = create_wallet_file(str(pd.new_pw.text()), seed)
+ walletname, ok = QInputDialog.getText(self, 'Choose wallet name',
+ 'Enter wallet file name:',
+ QLineEdit.Normal, "wallet.json")
+ if not ok:
+ JMQtMessageBox(self, "Create wallet aborted", mbtype='warn')
+ return
+ #create wallets subdir if it doesn't exist
+ if not os.path.exists('wallets'):
+ os.makedirs('wallets')
+ walletpath = os.path.join('wallets', str(walletname))
+ # Does a wallet with the same name exist?
+ if os.path.isfile(walletpath):
+ JMQtMessageBox(self,
+ walletpath + ' already exists. Aborting.',
+ mbtype='warn',
+ title="Error")
+ return
+ else:
+ fd = open(walletpath, 'w')
+ fd.write(walletfile)
+ fd.close()
+ JMQtMessageBox(self,
+ 'Wallet saved to ' + str(walletname),
+ title="Wallet created")
+ self.loadWalletFromBlockchain(
+ str(walletname), str(pd.new_pw.text()))
+
+
+def get_wallet_printout(wallet):
+ """Given a joinmarket wallet, retrieve the list of
+ addresses and corresponding balances to be displayed;
+ this could/should be a re-used function for both
+ command line and GUI.
+ The format of the retrieved data is:
+ rows: is of format [[[addr,index,bal,used],[addr,...]]*5,
+ [[addr, index,..], [addr, index..]]*5]
+ mbalances: is a simple array of 5 mixdepth balances
+ total_balance: whole wallet
+ Bitcoin amounts returned are in btc, not satoshis
+ """
+ rows = []
+ mbalances = []
+ total_balance = 0
+ for m in range(wallet.max_mix_depth):
+ rows.append([])
+ balance_depth = 0
+ for forchange in [0, 1]:
+ rows[m].append([])
+ for k in range(wallet.index[m][forchange] + jm_single(
+ ).config.getint("GUI", "gaplimit")):
+ addr = wallet.get_addr(m, forchange, k)
+ balance = 0.0
+ for addrvalue in wallet.unspent.values():
+ if addr == addrvalue['address']:
+ balance += addrvalue['value']
+ balance_depth += balance
+ used = ('used' if k < wallet.index[m][forchange] else 'new')
+ if balance > 0.0 or (k >= wallet.index[m][forchange] and
+ forchange == 0):
+ rows[m][forchange].append([addr, str(k), "{0:.8f}".format(
+ balance / 1e8), used])
+ mbalances.append(balance_depth)
+ total_balance += balance_depth
+
+ return (rows, ["{0:.8f}".format(x / 1e8) for x in mbalances],
+ "{0:.8f}".format(total_balance / 1e8))
+
+################################
+config_load_error = False
+app = QApplication(sys.argv)
+try:
+ load_program_config()
+except Exception as e:
+ config_load_error = "Failed to setup joinmarket: "+repr(e)
+ if "RPC" in repr(e):
+ config_load_error += '\n'*3 + ''.join(
+ ["Errors about failed RPC connections usually mean an incorrectly ",
+ "configured instance of Bitcoin Core (e.g. it hasn't been started ",
+ "or the rpc ports are not correct in your joinmarket.cfg or your ",
+ "bitcoin.conf file; see the joinmarket wiki for configuration details."
+ ])
+ JMQtMessageBox(None, config_load_error, mbtype='crit', title='failed to load')
+ exit(1)
+update_config_for_gui()
+
+#for testing, TODO remove
+jm_single().maker_timeout_sec = 5
+
+#we're not downloading from github, so logs dir
+#might not exist
+if not os.path.exists('logs'):
+ os.makedirs('logs')
+appWindowTitle = 'JoinMarketQt'
+w = JMMainWindow()
+tabWidget = QTabWidget(w)
+tabWidget.addTab(JMWalletTab(), "JM Wallet")
+settingsTab = SettingsTab()
+tabWidget.addTab(settingsTab, "Settings")
+tabWidget.addTab(SpendTab(), "Send Payment")
+tabWidget.addTab(TxHistoryTab(), "Tx History")
+w.resize(600, 500)
+suffix = ' - Testnet' if get_network() == 'testnet' else ''
+w.setWindowTitle(appWindowTitle + suffix)
+tabWidget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
+w.setCentralWidget(tabWidget)
+w.show()
+
+sys.exit(app.exec_())