From e2ac78ee8fb2dc319c24c34d6de43366305bf1b1 Mon Sep 17 00:00:00 2001 From: Adam Gibson Date: Sun, 24 Jan 2016 23:52:02 +0200 Subject: [PATCH 01/40] first draft of jm-gui QT app for sendpayment; a number of modification/additions to sendpayment, taker, wallet code to accommodate --- jm-gui.py | 1069 +++++++++++++++++++++++++++++ joinmarket/__init__.py | 2 +- joinmarket/blockchaininterface.py | 120 ++-- joinmarket/wallet.py | 42 +- sendpayment.py | 106 ++- wallet-tool.py | 10 +- 6 files changed, 1274 insertions(+), 75 deletions(-) create mode 100644 jm-gui.py diff --git a/jm-gui.py b/jm-gui.py new file mode 100644 index 00000000..5b8dc073 --- /dev/null +++ b/jm-gui.py @@ -0,0 +1,1069 @@ + +''' + 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 + +from decimal import Decimal +from functools import partial +from collections import namedtuple + +from PyQt4 import QtCore +from PyQt4.QtGui import * + +import platform + +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 bitcoin as btc + +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 + +from sendpayment import SendPayment, PT +#https://gist.github.com/e000/869791 +import socks +#from socksipyhandler import SocksiPyHandler + +log = get_log() +#TODO options/settings not global +gaplimit = 6 + +#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} +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' + } + +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) + + +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) + + 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.setGeometry(300, 300, 290, 150) + 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(self.create_menu) + self.setUniformRowHeights(True) + # extend the syntax for consistency + self.addChild = self.addTopLevelItem + self.insertChild = self.insertTopLevelItem + + # Control which columns are editable + self.editor = None + self.pending_update = False + if editable_columns is None: + editable_columns = [stretch_column] + self.editable_columns = editable_columns + #self.setItemDelegate(ElectrumItemDelegate(self)) + self.itemActivated.connect(self.on_activated) + self.update_headers(headers) + + def create_menu(self, position): + self.selectedIndexes() + item = self.currentItem() + address_valid = False + if item: + address = str(item.text(0)) + try: + btc.b58check_to_hex(address) + address_valid = True + except AssertionError: + print '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.addAction(_("Details"), lambda: self.parent.show_transaction(self.wallet.transactions.get(tx_hash))) + #menu.addAction(_("Edit description"), lambda: self.editItem(item, self.editable_columns[0])) + #menu.addAction(_("View on block explorer"), lambda: webbrowser.open(tx_URL)) + menu.exec_(self.viewport().mapToGlobal(position)) + + 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])) + +#TODO change denominations, mbtc, ubtc, bits +# make a satoshi_to_unit() and unit_to_satoshi() +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) + 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) + #TODO why doesn't addWidget() with colspan = -1 work? + 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' + print 'setting sectoin: '+section+' and name: '+oname+' to: '+oval + jm_single().config.set(section,oname,oval) + + else: #currently there is only QLineEdit + jm_single().config.set(section, str(t[0].text()),str(t[1].text())) + + 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) + 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() + + 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) + + self.widgets = self.getSettingsWidgets() + for i, x in enumerate(self.widgets): + innerTopLayout.addWidget(x[0],i,0) + innerTopLayout.addWidget(x[1],i,1) + 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), 0, 1, 2) + splitter1 = QSplitter(QtCore.Qt.Vertical) + textedit = QTextEdit() + XStream.stdout().messageWritten.connect(textedit.insertPlainText) + XStream.stderr().messageWritten.connect(textedit.insertPlainText) + splitter1.addWidget(top) + splitter1.addWidget(textedit) + splitter1.setSizes([200,200]) + self.setLayout(vbox) + vbox.addWidget(splitter1) + self.show() + + def startSendPayment(self): + if not self.validateSettings(): + return + #all settings are valid; start + QMessageBox.information(self,"Sendpayment","Connecting to IRC.\n"+ + "View real-time log in the lower pane.") + self.startButton.setEnabled(False) + self.abortButton.setEnabled(True) + + jm_single().nickname = random_nick() + + log.debug('starting sendpayment') + #TODO: is this necessary? + #jm_single().bc_interface.sync_wallet(wallet) + + self.irc = IRCMessageChannel(jm_single().nickname) + 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')) + QMessageBox.information(self,"Info", "amount: "+str(amount)) + return + makercount = int(self.widgets[1][1].text()) + mixdepth = int(self.widgets[2][1].text()) + self.taker = SendPayment(self.irc, w.wallet, destaddr, amount, makercount, + 5000, 30, mixdepth, + False, weighted_order_choose, + isolated=True) + thread = TaskThread(self) + thread.add(self.runIRC, on_done=self.cleanUp) + w.statusBar().showMessage("Connecting to IRC ...") + thread2 = TaskThread(self) + thread2.add(self.createTxThread, on_done=self.doTx) + + def createTxThread(self): + self.pt = PT(self.taker) + self.orders, self.total_cj_fee = self.pt.create_tx() + log.debug("Finished create_tx") + #TODO this can't be done in a thread as currently built; + #how else? or fix? + #w.statusBar().showMessage("Found counterparties...") + + def doTx(self): + if not self.orders: + QMessageBox.warning(self,"Error","Not enough matching orders found.") + return + total_fee_pc = 1.0 * self.total_cj_fee / self.taker.amount + mbinfo = [] + mbinfo.append("Sending amount: "+self.btc_amount_str+" BTC") + mbinfo.append("to address: "+self.destaddr) + mbinfo.append(" ") + mbinfo.append("Counterparties chosen:") + mbinfo.append('\t'.join(['Name','Order id'])) + for k,o in self.orders.iteritems(): + mbinfo.append('\t'.join([k,str(o)])) + mbinfo.append('Total coinjoin fee = ' + str(float('%.3g' % ( + 100.0 * total_fee_pc))) + '%') + title = 'Check Transaction' + if total_fee_pc > 2: + title += ': WARNING: Fee is HIGH!!' + reply = QMessageBox.question(self, + title,'\n'.join(mbinfo), + QMessageBox.Yes,QMessageBox.No) + if reply == QMessageBox.Yes: + log.debug('You agreed, transaction proceeding') + w.statusBar().showMessage("Building transaction...") + thread3 = TaskThread(self) + thread3.add(partial(self.pt.do_tx,self.total_cj_fee, self.orders), + on_done=None) + else: + log.debug('You rejected the transaction') + return + + def cleanUp(self): + if not self.taker.txid: + w.statusBar().showMessage("Transaction aborted.") + QMessageBox.warning(self,"Failed","Transaction was not completed.") + else: + w.statusBar().showMessage("Transaction completed successfully.") + QMessageBox.information(self,"Success", + "Transaction has been broadcast.\n"+ + "Txid: "+self.taker.txid) + self.startButton.setEnabled(True) + self.abortButton.setEnabled(False) + + def runIRC(self): + try: + log.debug('starting irc') + self.irc.run() + except: + log.debug('CRASHING, DUMPING EVERYTHING') + debug_dump_object(w.wallet, ['addr_cache', 'keys', 'wallet_name', 'seed']) + debug_dump_object(self.taker) + import traceback + log.debug(traceback.format_exc()) + + def finishPayment(self): + log.debug("Done") + + def validateSettings(self): + valid, errmsg = validate_address(self.widgets[0][1].text()) + if not valid: + QMessageBox.warning(self,"Error", errmsg) + return False + errs = ["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: + QMessageBox.warning(self, "Error",errs[i-1]) + return False + if not w.wallet: + QMessageBox.warning(self,"Error","There is no wallet loaded.") + return False + return True + + def checkAddress(self, addr): + valid, errmsg = validate_address(str(addr)) + if not valid: + QMessageBox.warning(self, "Error","Bitcoin address not valid.\n"+errmsg) + + 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'] + sT = [str, int, int, float] + #todo maxmixdepth + sMM = ['',(2,20),(0,5),(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 JMWalletTab(QWidget): + def __init__(self, mixdepths): + super(JMWalletTab, self).__init__() + self.mixdepths = mixdepths + self.wallet_name = 'NONE' + self.initUI() + + def initUI(self): + self.label1 = QLabel( + "CURRENT WALLET: "+self.wallet_name + ', total balance: 0.0', + self) + #label1.resize(300,120) + v = MyTreeWidget(self, None, 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 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(self.mixdepths): + 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 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 JMMainWindow(QMainWindow): + def __init__(self): + super(JMMainWindow, self).__init__() + self.wallet=None + self.initUI() + + 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) + menubar = QMenuBar() + + walletMenu = menubar.addMenu('&Wallet') + walletMenu.addAction(loadAction) + walletMenu.addAction(generateAction) + walletMenu.addAction(recoverAction) + walletMenu.addAction(exitAction) + aboutMenu = menubar.addMenu('&About') + aboutMenu.addAction(aboutAction) + + self.setMenuBar(menubar) + self.show() + + def showAboutDialog(self): + QMessageBox.about(self, "Joinmarket", + "Version"+" %s" % (str(jm_single().JM_VERSION)) + + "\n\n" + + "Joinmarket sendpayment tool") + + def recoverWallet(self): + if get_network()=='testnet': + QMessageBox.information(self, 'Error', + 'recover from seedphrase not supported for testnet') + return + d = QDialog(self) + d.setModal(1) + d.setWindowTitle('Recover from seed') + #d.setMinimumSize(290, 130) + layout = QGridLayout(d) + message_e = QTextEdit() + layout.addWidget(QLabel('Enter 12 words'), 0, 0) + layout.addWidget(message_e, 1, 0) + #layout.setRowStretch(2,3) + 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: + print 'cancelled' + return + msg = str(message_e.toPlainText()) + words = msg.split() #splits on any number of ws chars + print words + if not len(words)==12: + QMessageBox.warning(self, "Error","You did not provide 12 words, aborting.") + else: + seed = mn_decode(words) + print 'seed is: '+seed + self.initWallet(seed=seed) + + + def selectWallet(self, testnet_seed=None): + if get_network() != 'testnet': + firstarg = QFileDialog.getOpenFileName(self, 'Choose Wallet File', + directory='/home/adam/DevRepos/JoinMarket/testing/joinmarket/wallets') + #TODO validate the wallet file, set the directory properly + log.debug('first arg is: '+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'): + self.wallet = Wallet(str(firstarg), max_mix_depth=5, pwd=pwd) + if not self.wallet.decrypted: + QMessageBox.warning(self,"Error","Wrong password") + 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: + QMessageBox.warning(self, "Error", "No wallet loaded") + return + self.wallet.init_index() #sync operation assumes index is empty + self.loadWalletFromBlockchain() + + + def generateWallet(self): + print '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: + QMessageBox.warning(self,"Error","No seed entered, aborting") + 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() + #TODO: CONSIDERABLY! improve this dialog + mb.setText("Write down this wallet recovery seed.") + 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(): + QMessageBox.warning(self,"Error","Passwords don't match.") + continue + break + + print 'got password: '+str(pd.new_pw.text()) + 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: + QMessageBox.warning(self,"Error","Create wallet aborted") + return + walletpath = os.path.join('wallets', str(walletname)) + # Does a wallet with the same name exist? + if os.path.isfile(walletpath): + QMessageBox.warning(self, 'Error', + walletpath + ' already exists. Aborting.') + return + else: + fd = open(walletpath, 'w') + fd.write(walletfile) + fd.close() + QMessageBox.information(self, "Wallet created", + 'Wallet saved to ' + str(walletname)) + self.loadWalletFromBlockchain(str(walletname), str(pd.new_pw.text())) + + +def get_wallet_printout(wallet): + 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] + 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]: + rows[m][forchange].append([addr, str(k), + "{0:.8f}".format(balance/1e8),used]) + mbalances.append(balance_depth) + total_balance += balance_depth + #rows is of format [[[addr,index,bal,used],[addr,...]*5], + #[[addr, index,..], [addr, index..]*5]] + #mbalances is a simple array of 5 mixdepth balances + return (rows, ["{0:.8f}".format(x/1e8) for x in mbalances], + "{0:.8f}".format(total_balance/1e8)) + +################################ +load_program_config() +app = QApplication(sys.argv) +appWindowTitle = 'Joinmarket GUI' +w = JMMainWindow() +tabWidget = QTabWidget(w) +mdepths = 5 +tabWidget.addTab(JMWalletTab(mdepths), "JM Wallet") +settingsTab = SettingsTab() +tabWidget.addTab(settingsTab, "Settings") +tabWidget.addTab(SpendTab(), "Send Payment") +w.resize(500, 300) +#w.move(300, 300) +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_()) \ No newline at end of file diff --git a/joinmarket/__init__.py b/joinmarket/__init__.py index 3d1eec39..2f890ae1 100644 --- a/joinmarket/__init__.py +++ b/joinmarket/__init__.py @@ -15,7 +15,7 @@ from .slowaes import decryptData, encryptData from .taker import Taker, OrderbookWatch from .wallet import AbstractWallet, BitcoinCoreInterface, Wallet, \ - BitcoinCoreWallet + BitcoinCoreWallet, create_wallet_file from .configure import load_program_config, jm_single, get_p2pk_vbyte, \ get_network, jm_single, get_network, validate_address from .blockchaininterface import BlockrInterface diff --git a/joinmarket/blockchaininterface.py b/joinmarket/blockchaininterface.py index 9a11220d..af3480c0 100644 --- a/joinmarket/blockchaininterface.py +++ b/joinmarket/blockchaininterface.py @@ -20,7 +20,7 @@ import subprocess from joinmarket.jsonrpc import JsonRpcConnectionError, JsonRpcError -from joinmarket.configure import get_p2pk_vbyte, jm_single +from joinmarket.configure import get_p2pk_vbyte, jm_single, get_network from joinmarket.support import get_log, chunks log = get_log() @@ -106,13 +106,13 @@ def query_utxo_set(self, txouts): otherwise returns value in satoshis, address and output script """ # address and output script contain the same information btw - + @abc.abstractmethod def estimate_fee_per_kb(self, N): '''Use the blockchain interface to get an estimate of the transaction fee per kb required for inclusion in the next N blocks. - ''' + ''' class BlockrInterface(BlockchainInterface): @@ -122,10 +122,22 @@ def __init__(self, testnet=False): super(BlockrInterface, self).__init__() # see bci.py in bitcoin module - self.network = 'testnet' if testnet else 'btc' - self.blockr_domain = 'tbtc' if testnet else 'btc' + #self.network = 'testnet' if testnet else 'btc' + #self.blockr_domain = 'tbtc' if testnet else 'btc' self.last_sync_unspent = 0 + def network_for_blockr_push(self): + if get_network() == 'testnet': + return 'testnet' + else: + return 'btc' + + def blockr_domain(self): + if get_network() == 'testnet': + return 'tbtc' + else: + return 'btc' + def sync_addresses(self, wallet): log.debug('downloading wallet history') # sets Wallet internal indexes to be at the next unused address @@ -134,14 +146,14 @@ def sync_addresses(self, wallet): unused_addr_count = 0 last_used_addr = '' while (unused_addr_count < wallet.gaplimit or - not is_index_ahead_of_cache( - wallet, mix_depth, forchange)): + not is_index_ahead_of_cache( + wallet, mix_depth, forchange)): addrs = [wallet.get_new_addr(mix_depth, forchange) for _ in range(self.BLOCKR_MAX_ADDR_REQ_COUNT)] # TODO send a pull request to pybitcointools # because this surely should be possible with a function from it - blockr_url = 'https://' + self.blockr_domain + blockr_url = 'https://' + self.blockr_domain() blockr_url += '.blockr.io/api/v1/address/txs/' # print 'downloading, lastusedaddr = ' + last_used_addr + @@ -163,18 +175,18 @@ def sync_addresses(self, wallet): wallet.index[mix_depth][forchange] = 0 else: wallet.index[mix_depth][forchange] = wallet.addr_cache[ - last_used_addr][ - 2] + 1 + last_used_addr][ + 2] + 1 def sync_unspent(self, wallet): # finds utxos in the wallet st = time.time() # dont refresh unspent dict more often than 10 minutes - rate_limit_time = 10 * 60 + rate_limit_time = 5 * 6 if st - self.last_sync_unspent < rate_limit_time: log.debug( - 'blockr sync_unspent() happened too recently (%dsec), skipping' - % (st - self.last_sync_unspent)) + 'blockr sync_unspent() happened too recently (%dsec), skipping' + % (st - self.last_sync_unspent)) return wallet.unspent = {} @@ -192,8 +204,8 @@ def sync_unspent(self, wallet): # unspent() doesnt tell you which address, you get a bunch of utxos # but dont know which privkey to sign with - blockr_url = 'https://' + self.blockr_domain + \ - '.blockr.io/api/v1/address/unspent/' + blockr_url = 'https://' + self.blockr_domain() + \ + '.blockr.io/api/v1/address/unspent/' res = btc.make_request(blockr_url + ','.join(req)) data = json.loads(res)['data'] if 'unspent' in data: @@ -246,8 +258,8 @@ def run(self): random.shuffle(self.output_addresses ) # seriously weird bug with blockr.io data = json.loads( - btc.make_request(blockr_url + ','.join( - self.output_addresses + btc.make_request(blockr_url + ','.join( + self.output_addresses ) + '?unconfirmed=1'))['data'] shared_txid = None @@ -262,12 +274,12 @@ def run(self): if len(shared_txid) == 0: continue time.sleep( - 2 - ) # here for some race condition bullshit with blockr.io + 2 + ) # here for some race condition bullshit with blockr.io blockr_url = 'https://' + self.blockr_domain blockr_url += '.blockr.io/api/v1/tx/raw/' data = json.loads(btc.make_request(blockr_url + ','.join( - shared_txid)))['data'] + shared_txid)))['data'] if not isinstance(data, list): data = [data] for txinfo in data: @@ -281,7 +293,7 @@ def run(self): break self.unconfirmfun( - btc.deserialize(unconfirmed_txhex), unconfirmed_txid) + btc.deserialize(unconfirmed_txhex), unconfirmed_txid) st = int(time.time()) confirmed_txid = None @@ -294,7 +306,7 @@ def run(self): blockr_url = 'https://' + self.blockr_domain blockr_url += '.blockr.io/api/v1/address/txs/' data = json.loads(btc.make_request(blockr_url + ','.join( - self.output_addresses)))['data'] + self.output_addresses)))['data'] shared_txid = None for addrtxs in data: txs = set(str(txdata['tx']) @@ -309,8 +321,8 @@ def run(self): blockr_url = 'https://' + self.blockr_domain blockr_url += '.blockr.io/api/v1/tx/raw/' data = json.loads( - btc.make_request( - blockr_url + ','.join(shared_txid)))['data'] + btc.make_request( + blockr_url + ','.join(shared_txid)))['data'] if not isinstance(data, list): data = [data] for txinfo in data: @@ -323,13 +335,13 @@ def run(self): confirmed_txhex = str(txinfo['tx']['hex']) break self.confirmfun( - btc.deserialize(confirmed_txhex), confirmed_txid, 1) + btc.deserialize(confirmed_txhex), confirmed_txid, 1) - NotifyThread(self.blockr_domain, txd, unconfirmfun, confirmfun).start() + NotifyThread(self.blockr_domain(), txd, unconfirmfun, confirmfun).start() def pushtx(self, txhex): try: - json_str = btc.blockr_pushtx(txhex, self.network) + json_str = btc.blockr_pushtx(txhex, self.network_for_blockr_push()) except Exception: log.debug('failed blockr.io pushtx') return None @@ -351,9 +363,9 @@ def query_utxo_set(self, txout): txids = [txids] data = [] for ids in txids: - blockr_url = 'https://' + self.blockr_domain + '.blockr.io/api/v1/tx/info/' + blockr_url = 'https://' + self.blockr_domain() + '.blockr.io/api/v1/tx/info/' blockr_data = json.loads( - btc.make_request(blockr_url + ','.join(ids)))['data'] + btc.make_request(blockr_url + ','.join(ids)))['data'] if not isinstance(blockr_data, list): blockr_data = [blockr_data] data += blockr_data @@ -365,7 +377,7 @@ def query_utxo_set(self, txout): result.append(None) else: result.append({'value': int(Decimal(vout['amount']) * Decimal( - '1e8')), + '1e8')), 'address': vout['address'], 'script': vout['extras']['script']}) return result @@ -374,21 +386,21 @@ def estimate_fee_per_kb(self, N): bcypher_fee_estimate_url = 'https://api.blockcypher.com/v1/btc/main' bcypher_data = json.loads(btc.make_request(bcypher_fee_estimate_url)) log.debug("Got blockcypher result: "+pprint.pformat(bcypher_data)) - if N<=2: - fee_per_kb = bcypher_data["high_fee_per_kb"] - elif N <=4: - fee_per_kb = bcypher_data["medium_fee_per_kb"] - else: - fee_per_kb = bcypher_data["low_fee_per_kb"] - - return fee_per_kb + if N<=2: + fee_per_kb = bcypher_data["high_fee_per_kb"] + elif N <=4: + fee_per_kb = bcypher_data["medium_fee_per_kb"] + else: + fee_per_kb = bcypher_data["low_fee_per_kb"] + + return fee_per_kb class NotifyRequestHeader(BaseHTTPServer.BaseHTTPRequestHandler): def __init__(self, request, client_address, base_server): self.btcinterface = base_server.btcinterface self.base_server = base_server BaseHTTPServer.BaseHTTPRequestHandler.__init__( - self, request, client_address, base_server) + self, request, client_address, base_server) def do_HEAD(self): pages = ('/walletnotify?', '/alertnotify?') @@ -433,7 +445,7 @@ def do_HEAD(self): else: confirmfun(txd, txid, txdata['confirmations']) self.btcinterface.txnotify_fun.remove( - (tx_out, unconfirmfun, confirmfun)) + (tx_out, unconfirmfun, confirmfun)) log.debug('ran confirmfun') elif self.path.startswith('/alertnotify?'): @@ -444,7 +456,7 @@ def do_HEAD(self): log.debug('ERROR: This is not a handled URL path. You may want to check your notify URL for typos.') os.system('curl -sI --connect-timeout 1 http://localhost:' + str( - self.base_server.server_address[1] + 1) + self.path) + self.base_server.server_address[1] + 1) + self.path) self.send_response(200) # self.send_header('Connection', 'close') self.end_headers() @@ -518,7 +530,7 @@ def add_watchonly_addresses(self, addr_list, wallet_name): for addr in addr_list: self.rpc('importaddress', [addr, wallet_name, False]) if jm_single().config.get( - "BLOCKCHAIN", "blockchain_source") != 'regtest': + "BLOCKCHAIN", "blockchain_source") != 'regtest': print('restart Bitcoin Core with -rescan if you\'re ' 'recovering an existing wallet from backup seed') print(' otherwise just restart this joinmarket script') @@ -572,8 +584,8 @@ def sync_addresses(self, wallet): breakloop = False while not breakloop: if unused_addr_count >= wallet.gaplimit and \ - is_index_ahead_of_cache(wallet, mix_depth, - forchange): + is_index_ahead_of_cache(wallet, mix_depth, + forchange): break mix_change_addrs = [ wallet.get_new_addr(mix_depth, forchange) @@ -581,7 +593,7 @@ def sync_addresses(self, wallet): for mc_addr in mix_change_addrs: if mc_addr not in imported_addr_list: too_few_addr_mix_change.append( - (mix_depth, forchange)) + (mix_depth, forchange)) breakloop = True break if mc_addr in used_addr_list: @@ -656,9 +668,9 @@ def pushtx(self, txhex): try: txid = self.rpc('sendrawtransaction', [txhex]) except JsonRpcConnectionError as e: - return (False, repr(e)) + return (False, repr(e)) except JsonRpcError as e: - return (False, str(e.code) + " " + str(e.message)) + return (False, str(e.code) + " " + str(e.message)) return (True, txid) def query_utxo_set(self, txout): @@ -678,12 +690,12 @@ def query_utxo_set(self, txout): def estimate_fee_per_kb(self, N): estimate = Decimal(1e8)*Decimal(self.rpc('estimatefee', [N])) - if estimate < 0: - #This occurs when Core has insufficient data to estimate. - #TODO anything better than a hardcoded default? - return 30000 + if estimate < 0: + #This occurs when Core has insufficient data to estimate. + #TODO anything better than a hardcoded default? + return 30000 else: - return estimate + return estimate # class for regtest chain access # running on local daemon. Only @@ -750,7 +762,7 @@ def get_received_by_addr(self, addresses, query_params): self.rpc('importaddress', [address, 'watchonly']) res.append({'address': address, 'balance': int(Decimal(1e8) * Decimal( - self.rpc('getreceivedbyaddress', [address])))}) + self.rpc('getreceivedbyaddress', [address])))}) return {'data': res} # todo: won't run anyways @@ -767,4 +779,4 @@ def get_received_by_addr(self, addresses, query_params): # # # if __name__ == '__main__': -# main() +# main() \ No newline at end of file diff --git a/joinmarket/wallet.py b/joinmarket/wallet.py index e5cee76d..d0a7d54c 100644 --- a/joinmarket/wallet.py +++ b/joinmarket/wallet.py @@ -3,13 +3,14 @@ import os import pprint import sys +import datetime from decimal import Decimal from ConfigParser import NoSectionError from getpass import getpass import bitcoin as btc -from joinmarket.slowaes import decryptData +from joinmarket.slowaes import decryptData, encryptData from joinmarket.blockchaininterface import BitcoinCoreInterface from joinmarket.configure import jm_single, get_network, get_p2pk_vbyte @@ -30,6 +31,15 @@ def estimate_tx_fee(ins, outs, txtype='p2pkh'): log.debug("got estimated tx bytes: "+str(tx_estimated_bytes)) return int((tx_estimated_bytes * fee_per_kb)/Decimal(1000.0)) +def create_wallet_file(pwd, seed): + password_key = btc.bin_dbl_sha256(pwd) + encrypted_seed = encryptData(password_key, seed.decode('hex')) + timestamp = datetime.datetime.now().strftime("%Y/%m/%d %H:%M:%S") + return json.dumps({'creator': 'joinmarket project', + 'creation_time': timestamp, + 'encrypted_seed': encrypted_seed.encode('hex'), + 'network': get_network()}) + class AbstractWallet(object): """ Abstract wallet for use with JoinMarket @@ -111,7 +121,8 @@ def __init__(self, max_mix_depth=2, gaplimit=6, extend_mixdepth=False, - storepassword=False): + storepassword=False, + pwd = None): super(Wallet, self).__init__() self.max_mix_depth = max_mix_depth self.storepassword = storepassword @@ -121,7 +132,10 @@ def __init__(self, self.unspent = {} self.spent_utxos = [] self.imported_privkeys = {} - self.seed = self.read_wallet_file_data(seedarg) + self.decrypted = False + self.seed = self.read_wallet_file_data(seedarg, pwd) + if not self.seed: + return if extend_mixdepth and len(self.index_cache) > max_mix_depth: self.max_mix_depth = len(self.index_cache) self.gaplimit = gaplimit @@ -131,13 +145,14 @@ def __init__(self, for c in range(self.max_mix_depth)] self.keys = [(btc.bip32_ckd(m, 0), btc.bip32_ckd(m, 1)) for m in mixing_depth_keys] + self.init_index() - # self.index = [[0, 0]]*max_mix_depth + def init_index(self): self.index = [] for i in range(self.max_mix_depth): self.index.append([0, 0]) - def read_wallet_file_data(self, filename): + def read_wallet_file_data(self, filename, pwd = None): self.path = None self.index_cache = [[0, 0]] * self.max_mix_depth path = os.path.join('wallets', filename) @@ -145,6 +160,7 @@ def read_wallet_file_data(self, filename): if get_network() == 'testnet': log.debug('filename interpreted as seed, only available in ' 'testnet because this probably has lower entropy') + self.decrypted = True return filename else: raise IOError('wallet file not found') @@ -160,9 +176,12 @@ def read_wallet_file_data(self, filename): sys.exit(0) if 'index_cache' in walletdata: self.index_cache = walletdata['index_cache'] - decrypted = False - while not decrypted: - password = getpass('Enter wallet decryption passphrase: ') + self.decrypted = False + while not self.decrypted: + if pwd: + password = pwd + else: + password = getpass('Enter wallet decryption passphrase: ') password_key = btc.bin_dbl_sha256(password) encrypted_seed = walletdata['encrypted_seed'] try: @@ -173,12 +192,15 @@ def read_wallet_file_data(self, filename): # padding by chance from a wrong password; sanity check the # seed length if len(decrypted_seed) == 32: - decrypted = True + self.decrypted = True else: raise ValueError except ValueError: print('Incorrect password') - decrypted = False + self.decrypted = False + if pwd: + decrypted_seed = None + break if self.storepassword: self.password_key = password_key self.walletdata = walletdata diff --git a/sendpayment.py b/sendpayment.py index 76d14f3b..9ba0b733 100644 --- a/sendpayment.py +++ b/sendpayment.py @@ -176,7 +176,7 @@ def run(self): class SendPayment(Taker): def __init__(self, msgchan, wallet, destaddr, amount, makercount, txfee, - waittime, mixdepth, answeryes, chooseOrdersFunc): + waittime, mixdepth, answeryes, chooseOrdersFunc, isolated=False): Taker.__init__(self, msgchan) self.wallet = wallet self.destaddr = destaddr @@ -187,10 +187,14 @@ def __init__(self, msgchan, wallet, destaddr, amount, makercount, txfee, self.mixdepth = mixdepth self.answeryes = answeryes self.chooseOrdersFunc = chooseOrdersFunc + #extra variables for GUI-style + self.isolated = isolated + self.txid = None def on_welcome(self): Taker.on_welcome(self) - PaymentThread(self).start() + if not self.isolated: + PaymentThread(self).start() def main(): @@ -320,6 +324,104 @@ def main(): log.debug(traceback.format_exc()) +#PaymentThread object modified (not a thread, refactored a bit) +#The reason is that Qt won't work with python threads, and we need +#separate threads for separate steps (returning chosen orders to gui), +#so the threading is in the gui code. +class PT(object): + + def __init__(self, taker): + self.taker = taker + self.ignored_makers = [] + + def create_tx(self): + time.sleep(self.taker.waittime) + crow = self.taker.db.execute( + 'SELECT COUNT(DISTINCT counterparty) FROM orderbook;').fetchone() + counterparty_count = crow['COUNT(DISTINCT counterparty)'] + counterparty_count -= len(self.ignored_makers) + if counterparty_count < self.taker.makercount: + log.debug('not enough counterparties to fill order, ending') + self.taker.msgchan.shutdown() + return None, None + + utxos = None + orders = None + cjamount = 0 + change_addr = None + choose_orders_recover = None + orders, total_cj_fee = self.sendpayment_choose_orders( + self.taker.amount, self.taker.makercount) + if not orders: + log.debug( + 'ERROR not enough liquidity in the orderbook, exiting') + return None, None + return orders, total_cj_fee + + def do_tx(self, total_cj_fee, orders): + total_amount = self.taker.amount + total_cj_fee + \ + self.taker.txfee*self.taker.makercount + log.debug('total estimated amount spent = ' + str(total_amount)) + #adjust the required amount upwards to anticipate a tripling of + #transaction fee after re-estimation; this is sufficiently conservative + #to make failures unlikely while keeping the occurence of failure to + #find sufficient utxos extremely rare. Indeed, a tripling of 'normal' + #txfee indicates undesirable behaviour on maker side anyway. + try: + utxos = self.taker.wallet.select_utxos(self.taker.mixdepth, + total_amount+2*self.taker.txfee*self.taker.makercount) + except Exception as e: + log.debug("Failed to select coins: "+repr(e)) + return + cjamount = self.taker.amount + log.debug("using coinjoin amount: "+str(cjamount)) + change_addr = self.taker.wallet.get_internal_addr(self.taker.mixdepth) + log.debug("using change address: "+change_addr) + choose_orders_recover = self.sendpayment_choose_orders + log.debug("About to start coinjoin") + try: + self.taker.start_cj(self.taker.wallet, cjamount, orders, utxos, + self.taker.destaddr, change_addr, + self.taker.makercount*self.taker.txfee, + self.finishcallback, choose_orders_recover) + except Exception as e: + log.debug("failed to start coinjoin: "+repr(e)) + + def finishcallback(self, coinjointx): + if coinjointx.all_responded: + pushed = coinjointx.self_sign_and_push() + if pushed: + log.debug('created fully signed tx, ending') + self.taker.txid = coinjointx.txid + else: + #Error should be in log, will not retry. + log.debug('failed to push tx, ending.') + self.taker.msgchan.shutdown() + return + self.ignored_makers += coinjointx.nonrespondants + log.debug('recreating the tx, ignored_makers=' + str( + self.ignored_makers)) + self.create_tx() + + def sendpayment_choose_orders(self, + cj_amount, + makercount, + nonrespondants=None, + active_nicks=None): + if nonrespondants is None: + nonrespondants = [] + if active_nicks is None: + active_nicks = [] + self.ignored_makers += nonrespondants + orders, total_cj_fee = choose_orders( + self.taker.db, cj_amount, makercount, self.taker.chooseOrdersFunc, + self.ignored_makers + active_nicks) + if not orders: + return None, 0 + log.debug('chosen orders to fill ' + str(orders) + ' totalcjfee=' + str( + total_cj_fee)) + return orders, total_cj_fee + if __name__ == "__main__": main() print('done') diff --git a/wallet-tool.py b/wallet-tool.py index 8074f467..7b9c101b 100644 --- a/wallet-tool.py +++ b/wallet-tool.py @@ -8,7 +8,7 @@ from optparse import OptionParser from joinmarket import load_program_config, get_network, Wallet, encryptData, \ - get_p2pk_vbyte, jm_single, mn_decode, mn_encode + get_p2pk_vbyte, jm_single, mn_decode, mn_encode, create_wallet_file import bitcoin as btc @@ -172,13 +172,7 @@ def cus_print(s): if password != password2: print('ERROR. Passwords did not match') sys.exit(0) - password_key = btc.bin_dbl_sha256(password) - encrypted_seed = encryptData(password_key, seed.decode('hex')) - timestamp = datetime.datetime.now().strftime("%Y/%m/%d %H:%M:%S") - walletfile = json.dumps({'creator': 'joinmarket project', - 'creation_time': timestamp, - 'encrypted_seed': encrypted_seed.encode('hex'), - 'network': get_network()}) + walletfile = create_wallet_file(password, seed) walletname = raw_input('Input wallet file name (default: wallet.json): ') if len(walletname) == 0: walletname = 'wallet.json' From c6267f3c449de9d30462063bb1518921ec1bd31f Mon Sep 17 00:00:00 2001 From: Adam Gibson Date: Mon, 25 Jan 2016 00:08:19 +0200 Subject: [PATCH 02/40] fix startsendpayment bug, remove amount debug window, fix transaction id reporting bug --- jm-gui.py | 6 ++---- joinmarket/taker.py | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/jm-gui.py b/jm-gui.py index 5b8dc073..970746b6 100644 --- a/jm-gui.py +++ b/jm-gui.py @@ -581,11 +581,9 @@ def startSendPayment(self): #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')) - QMessageBox.information(self,"Info", "amount: "+str(amount)) - return makercount = int(self.widgets[1][1].text()) mixdepth = int(self.widgets[2][1].text()) - self.taker = SendPayment(self.irc, w.wallet, destaddr, amount, makercount, + self.taker = SendPayment(self.irc, w.wallet, self.destaddr, amount, makercount, 5000, 30, mixdepth, False, weighted_order_choose, isolated=True) @@ -642,7 +640,7 @@ def cleanUp(self): w.statusBar().showMessage("Transaction completed successfully.") QMessageBox.information(self,"Success", "Transaction has been broadcast.\n"+ - "Txid: "+self.taker.txid) + "Txid: "+str(self.taker.txid)) self.startButton.setEnabled(True) self.abortButton.setEnabled(False) diff --git a/joinmarket/taker.py b/joinmarket/taker.py index 6667fa8b..902f13c2 100644 --- a/joinmarket/taker.py +++ b/joinmarket/taker.py @@ -302,7 +302,7 @@ def push(self, txd): # self.msgchan.push_tx(self.active_orders.keys()[0], txhex) pushed = jm_single().bc_interface.pushtx(tx) if pushed[0]: - self.txid = pushed[1] + self.txid = btc.txhash(tx) else: log.debug('unable to pushtx, reason: '+str(pushed[1])) return pushed[0] From 1771e5ed700a7a4e628460c43d523632a3015712 Mon Sep 17 00:00:00 2001 From: Adam Gibson Date: Mon, 25 Jan 2016 14:26:49 +0200 Subject: [PATCH 03/40] Add script for testing GUI against regtest, + modifications for handling transaction abort correctly in GUI --- jm-gui.py | 17 +++++-- sendpayment.py | 3 +- test/testyg-daemon.py | 100 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 test/testyg-daemon.py diff --git a/jm-gui.py b/jm-gui.py index 970746b6..fc9d47b0 100644 --- a/jm-gui.py +++ b/jm-gui.py @@ -562,6 +562,7 @@ def initUI(self): self.show() def startSendPayment(self): + self.aborted = False if not self.validateSettings(): return #all settings are valid; start @@ -604,6 +605,7 @@ def createTxThread(self): def doTx(self): if not self.orders: QMessageBox.warning(self,"Error","Not enough matching orders found.") + self.giveUp() return total_fee_pc = 1.0 * self.total_cj_fee / self.taker.amount mbinfo = [] @@ -629,13 +631,22 @@ def doTx(self): thread3.add(partial(self.pt.do_tx,self.total_cj_fee, self.orders), on_done=None) else: - log.debug('You rejected the transaction') + self.giveUp() return + def giveUp(self): + self.aborted = True + log.debug("Transaction aborted.") + self.taker.msgchan.shutdown() + self.abortButton.setEnabled(False) + self.startButton.setEnabled(True) + w.statusBar().showMessage("Transaction aborted.") + def cleanUp(self): if not self.taker.txid: - w.statusBar().showMessage("Transaction aborted.") - QMessageBox.warning(self,"Failed","Transaction was not completed.") + if not self.aborted: + w.statusBar().showMessage("Transaction failed.") + QMessageBox.warning(self,"Failed","Transaction was not completed.") else: w.statusBar().showMessage("Transaction completed successfully.") QMessageBox.information(self,"Success", diff --git a/sendpayment.py b/sendpayment.py index 9ba0b733..823fce93 100644 --- a/sendpayment.py +++ b/sendpayment.py @@ -342,7 +342,8 @@ def create_tx(self): counterparty_count -= len(self.ignored_makers) if counterparty_count < self.taker.makercount: log.debug('not enough counterparties to fill order, ending') - self.taker.msgchan.shutdown() + #NB: don't shutdown msgchan here, that is done by the caller + #after setting GUI state to reflect the reason for shutdown. return None, None utxos = None diff --git a/test/testyg-daemon.py b/test/testyg-daemon.py new file mode 100644 index 00000000..d07be219 --- /dev/null +++ b/test/testyg-daemon.py @@ -0,0 +1,100 @@ +#! /usr/bin/env python +from __future__ import absolute_import +'''Run yield generators on regtest.''' + +import sys +import os +import time +import binascii +import pexpect +import random +import subprocess +import unittest +from commontest import local_command, make_wallets, interact + +data_dir = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) +sys.path.insert(0, os.path.join(data_dir)) + +import bitcoin as btc + +from joinmarket import load_program_config, jm_single +from joinmarket import get_p2pk_vbyte, get_log, Wallet +from joinmarket.support import chunks + +python_cmd = 'python2' +yg_cmd = 'yield-generator-basic.py' +#yg_cmd = 'yield-generator-mixdepth.py' +#yg_cmd = 'yield-generator-deluxe.py' + +log = get_log() + + +class TumblerTests(unittest.TestCase): + + def setUp(self): + #create 7 new random wallets. + #put about 10 coins in each, spread over random mixdepths + #in units of 0.5 + + seeds = chunks(binascii.hexlify(os.urandom(15 * 7)), 7) + self.wallets = {} + for i in range(7): + self.wallets[i] = {'seed': seeds[i], + 'wallet': Wallet(seeds[i], + max_mix_depth=5)} + #adding coins somewhat randomly, spread over all 5 depths + for i in range(7): + w = self.wallets[i]['wallet'] + for j in range(5): + for k in range(4): + base = 0.001 if i == 6 else 2.0 + amt = base + random.random( + ) #average is 0.5 for tumbler, else 1.5 + jm_single().bc_interface.grab_coins( + w.get_external_addr(j), amt) + + def run_tumble(self, amt): + yigen_procs = [] + for i in range(6): + ygp = local_command([python_cmd, yg_cmd,\ + str(self.wallets[i]['seed'])], bg=True) + time.sleep(2) #give it a chance + yigen_procs.append(ygp) + +#A significant delay is needed to wait for the yield generators to sync + time.sleep(20) + + #start a tumbler + amt = amt * 1e8 #in satoshis + #send to any old address + dest_address = btc.privkey_to_address(os.urandom(32), get_p2pk_vbyte()) + try: + print 'taker seed: '+self.wallets[6]['seed'] + while True: + print 'hello' + time.sleep(80) + except subprocess.CalledProcessError, e: + for ygp in yigen_procs: + ygp.kill() + print e.returncode + print e.message + raise + + if any(yigen_procs): + for ygp in yigen_procs: + ygp.kill() + + return True + + def test_simple_send(self): + self.failUnless(self.run_tumble(1)) + + +def main(): + os.chdir(data_dir) + load_program_config() + unittest.main() + + +if __name__ == '__main__': + main() From 818b17c97a30e8e4d98a62daa1b2e9d5ff6cdcd2 Mon Sep 17 00:00:00 2001 From: Adam Gibson Date: Mon, 25 Jan 2016 16:27:18 +0200 Subject: [PATCH 04/40] add transaction history tab and persist transactions to a file --- jm-gui.py | 132 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 102 insertions(+), 30 deletions(-) diff --git a/jm-gui.py b/jm-gui.py index fc9d47b0..f7fd7dea 100644 --- a/jm-gui.py +++ b/jm-gui.py @@ -55,7 +55,7 @@ log = get_log() #TODO options/settings not global gaplimit = 6 - +history_file = 'jm-tx-history.txt' #configuration types config_types = {'rpc_port': int, 'port': int, @@ -280,7 +280,7 @@ def __init__(self, parent, create_menu, headers, stretch_column=None, self.parent = parent self.stretch_column = stretch_column self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) - self.customContextMenuRequested.connect(self.create_menu) + self.customContextMenuRequested.connect(create_menu) self.setUniformRowHeights(True) # extend the syntax for consistency self.addChild = self.addTopLevelItem @@ -296,29 +296,6 @@ def __init__(self, parent, create_menu, headers, stretch_column=None, self.itemActivated.connect(self.on_activated) self.update_headers(headers) - def create_menu(self, position): - self.selectedIndexes() - item = self.currentItem() - address_valid = False - if item: - address = str(item.text(0)) - try: - btc.b58check_to_hex(address) - address_valid = True - except AssertionError: - print '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.addAction(_("Details"), lambda: self.parent.show_transaction(self.wallet.transactions.get(tx_hash))) - #menu.addAction(_("Edit description"), lambda: self.editItem(item, self.editable_columns[0])) - #menu.addAction(_("View on block explorer"), lambda: webbrowser.open(tx_URL)) - menu.exec_(self.viewport().mapToGlobal(position)) - def update_headers(self, headers): self.setColumnCount(len(headers)) self.setHeaderLabels(headers) @@ -424,9 +401,7 @@ def filter(self, p, columns): for item in self.get_leaves(self.invisibleRootItem()): item.setHidden(all([unicode(item.text(column)).lower().find(p) == -1 for column in columns])) - -#TODO change denominations, mbtc, ubtc, bits -# make a satoshi_to_unit() and unit_to_satoshi() + class SettingsTab(QDialog): def __init__(self): super(SettingsTab, self).__init__() @@ -456,7 +431,6 @@ def initUI(self): if str(ns[0].text()) in config_tips: ttS = config_tips[str(ns[0].text())] ns[0].setToolTip(ttS) - #TODO why doesn't addWidget() with colspan = -1 work? grid.addWidget(ns[1],j,1) sfindex = len(self.settingsFields)-len(newSettingsFields)+k if isinstance(ns[1], QCheckBox): @@ -652,6 +626,11 @@ def cleanUp(self): QMessageBox.information(self,"Success", "Transaction has been broadcast.\n"+ "Txid: "+str(self.taker.txid)) + #persist the transaction to history + with open(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")])) self.startButton.setEnabled(True) self.abortButton.setEnabled(False) @@ -717,6 +696,79 @@ def getSettingsWidgets(self): 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): + if not os.path.isfile(history_file): + if w: + w.statusBar().showMessage("No transaction history found.") + return [] + txhist = [] + with open(history_file,'rb') as f: + txlines = f.readlines() + for tl in txlines: + txhist.append(tl.strip().split(',')) + if not len(txhist[-1])==4: + QMessageBox.warning(self,"Error", + "Incorrectedly formatted file jm-tx-history.txt") + 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() + 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, mixdepths): super(JMWalletTab, self).__init__() @@ -729,7 +781,7 @@ def initUI(self): "CURRENT WALLET: "+self.wallet_name + ', total balance: 0.0', self) #label1.resize(300,120) - v = MyTreeWidget(self, None, self.getHeaders()) + v = MyTreeWidget(self, self.create_menu, self.getHeaders()) v.setSelectionMode(QAbstractItemView.ExtendedSelection) v.on_update = self.updateWalletInfo self.history = v @@ -750,6 +802,25 @@ 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: + print '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() @@ -1067,6 +1138,7 @@ def get_wallet_printout(wallet): settingsTab = SettingsTab() tabWidget.addTab(settingsTab, "Settings") tabWidget.addTab(SpendTab(), "Send Payment") +tabWidget.addTab(TxHistoryTab(),"Tx History") w.resize(500, 300) #w.move(300, 300) suffix = ' - Testnet' if get_network() == 'testnet' else '' From 25ac8577290e41573b266afb572fa5cf598f8ed6 Mon Sep 17 00:00:00 2001 From: Adam Gibson Date: Mon, 25 Jan 2016 20:15:01 +0200 Subject: [PATCH 05/40] add donate-small-change feature to spending tab, more info on About dialog --- jm-gui.py | 44 +++++++++++++++++++++++++++++++++++++------- sendpayment.py | 16 ++++++++++++++-- 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/jm-gui.py b/jm-gui.py index f7fd7dea..733721c3 100644 --- a/jm-gui.py +++ b/jm-gui.py @@ -53,6 +53,8 @@ #from socksipyhandler import SocksiPyHandler log = get_log() +donation_address = '1LT6rwv26bV7mgvRosoSCyGM7ttVRsYidP' +donation_address_testnet = 'mz6FQosuiNe8135XaQqWYmXsa3aD8YsqGL' #TODO options/settings not global gaplimit = 6 history_file = 'jm-tx-history.txt' @@ -504,11 +506,27 @@ def initUI(self): innerTopLayout = QGridLayout() innerTopLayout.setSpacing(4) iFrame.setLayout(innerTopLayout) + + donateLayout = QHBoxLayout() + self.donateCheckBox = QCheckBox() + self.donateCheckBox.setChecked(True) + 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) + + donateLayout.addWidget(self.donateCheckBox) + donateLayout.addWidget(QLabel("Check to send change lower than: ")) + donateLayout.addWidget(self.donateLimitBox) + donateLayout.addWidget(QLabel(" BTC as a donation.")) + innerTopLayout.addLayout(donateLayout, 0, 0, 1, 2) self.widgets = self.getSettingsWidgets() for i, x in enumerate(self.widgets): - innerTopLayout.addWidget(x[0],i,0) - innerTopLayout.addWidget(x[1],i,1) + innerTopLayout.addWidget(x[0],i+1,0) + innerTopLayout.addWidget(x[1],i+1,1) self.widgets[0][1].editingFinished.connect(lambda : self.checkAddress( self.widgets[0][1].text())) self.startButton =QPushButton('Start') @@ -523,7 +541,7 @@ def initUI(self): buttons.addStretch(1) buttons.addWidget(self.startButton) buttons.addWidget(self.abortButton) - innerTopLayout.addLayout(buttons, len(self.widgets), 0, 1, 2) + innerTopLayout.addLayout(buttons, len(self.widgets)+1, 0, 1, 2) splitter1 = QSplitter(QtCore.Qt.Vertical) textedit = QTextEdit() XStream.stdout().messageWritten.connect(textedit.insertPlainText) @@ -602,7 +620,15 @@ def doTx(self): log.debug('You agreed, transaction proceeding') w.statusBar().showMessage("Building transaction...") thread3 = TaskThread(self) - thread3.add(partial(self.pt.do_tx,self.total_cj_fee, self.orders), + log.debug("Trigger is: "+str(self.donateLimitBox.value())) + if get_network()=='testnet': + da = donation_address_testnet + else: + da = donation_address + thread3.add(partial(self.pt.do_tx,self.total_cj_fee, self.orders, + self.donateCheckBox.isChecked(), + self.donateLimitBox.value(), + da), on_done=None) else: self.giveUp() @@ -631,6 +657,7 @@ def cleanUp(self): 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 self.startButton.setEnabled(True) self.abortButton.setEnabled(False) @@ -940,9 +967,12 @@ def initUI(self): def showAboutDialog(self): QMessageBox.about(self, "Joinmarket", - "Version"+" %s" % (str(jm_single().JM_VERSION)) + - "\n\n" + - "Joinmarket sendpayment tool") + "\n".join(["Joinmarket version: 0.1.2", + "Protocol version:"+" %s" % (str(jm_single().JM_VERSION)), + "Joinmarket sendpayment tool", + "Help support Bitcoin fungibility -" + "donate here: ", + donation_address])) def recoverWallet(self): if get_network()=='testnet': diff --git a/sendpayment.py b/sendpayment.py index 823fce93..a9a3b946 100644 --- a/sendpayment.py +++ b/sendpayment.py @@ -359,7 +359,8 @@ def create_tx(self): return None, None return orders, total_cj_fee - def do_tx(self, total_cj_fee, orders): + def do_tx(self, total_cj_fee, orders, + donate=False, donate_trigger=1000000, donation_address=None): total_amount = self.taker.amount + total_cj_fee + \ self.taker.txfee*self.taker.makercount log.debug('total estimated amount spent = ' + str(total_amount)) @@ -374,9 +375,20 @@ def do_tx(self, total_cj_fee, orders): except Exception as e: log.debug("Failed to select coins: "+repr(e)) return + my_total_in = sum([va['value'] for u, va in utxos.iteritems()]) cjamount = self.taker.amount log.debug("using coinjoin amount: "+str(cjamount)) - change_addr = self.taker.wallet.get_internal_addr(self.taker.mixdepth) + change_amount = my_total_in-cjamount + log.debug("using change amount: "+str(change_amount)) + if donate and change_amount < donate_trigger*1e8: + #sanity check + res = validate_address(donation_address) + if not res[0]: + log.debug("Donation address invalid! Error: "+res[1]) + return + change_addr = donation_address + else: + change_addr = self.taker.wallet.get_internal_addr(self.taker.mixdepth) log.debug("using change address: "+change_addr) choose_orders_recover = self.sendpayment_choose_orders log.debug("About to start coinjoin") From b4070f9adcc560cede76e1e6c9cef037f92110e4 Mon Sep 17 00:00:00 2001 From: Adam Gibson Date: Mon, 25 Jan 2016 20:44:16 +0200 Subject: [PATCH 06/40] window resize --- jm-gui.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/jm-gui.py b/jm-gui.py index 733721c3..2dcd44ef 100644 --- a/jm-gui.py +++ b/jm-gui.py @@ -548,7 +548,7 @@ def initUI(self): XStream.stderr().messageWritten.connect(textedit.insertPlainText) splitter1.addWidget(top) splitter1.addWidget(textedit) - splitter1.setSizes([200,200]) + splitter1.setSizes([400,200]) self.setLayout(vbox) vbox.addWidget(splitter1) self.show() @@ -1169,7 +1169,7 @@ def get_wallet_printout(wallet): tabWidget.addTab(settingsTab, "Settings") tabWidget.addTab(SpendTab(), "Send Payment") tabWidget.addTab(TxHistoryTab(),"Tx History") -w.resize(500, 300) +w.resize(600, 500) #w.move(300, 300) suffix = ' - Testnet' if get_network() == 'testnet' else '' w.setWindowTitle(appWindowTitle + suffix) From 801c4893f045cb3ff8041008fe2a2a8253b677a7 Mon Sep 17 00:00:00 2001 From: Adam Gibson Date: Mon, 25 Jan 2016 23:15:03 +0200 Subject: [PATCH 07/40] more fine control of layout of spend tab; added HelpLabel for clickable help text, used this for donation help text, set donation off by default --- jm-gui.py | 67 ++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 57 insertions(+), 10 deletions(-) diff --git a/jm-gui.py b/jm-gui.py index 2dcd44ef..fac1612c 100644 --- a/jm-gui.py +++ b/jm-gui.py @@ -180,6 +180,30 @@ 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("QLabel {color: blue;}") + + 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): ''' @@ -509,24 +533,48 @@ def initUI(self): donateLayout = QHBoxLayout() self.donateCheckBox = QCheckBox() - self.donateCheckBox.setChecked(True) + 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) - donateLayout.addWidget(QLabel("Check to send change lower than: ")) + 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.addWidget(QLabel(" BTC as a donation.")) + 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) + 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') @@ -548,7 +596,7 @@ def initUI(self): XStream.stderr().messageWritten.connect(textedit.insertPlainText) splitter1.addWidget(top) splitter1.addWidget(textedit) - splitter1.setSizes([400,200]) + splitter1.setSizes([400, 200]) self.setLayout(vbox) vbox.addWidget(splitter1) self.show() @@ -576,10 +624,9 @@ def startSendPayment(self): amount = int(Decimal(self.btc_amount_str)*Decimal('1e8')) makercount = int(self.widgets[1][1].text()) mixdepth = int(self.widgets[2][1].text()) - self.taker = SendPayment(self.irc, w.wallet, self.destaddr, amount, makercount, - 5000, 30, mixdepth, - False, weighted_order_choose, - isolated=True) + self.taker = SendPayment(self.irc, w.wallet, self.destaddr, amount, + makercount, 5000, 30, mixdepth, False, + weighted_order_choose, isolated=True) thread = TaskThread(self) thread.add(self.runIRC, on_done=self.cleanUp) w.statusBar().showMessage("Connecting to IRC ...") From bb90911b17881402c196ba53fce1383171ea6de7 Mon Sep 17 00:00:00 2001 From: Adam Gibson Date: Mon, 25 Jan 2016 23:33:44 +0200 Subject: [PATCH 08/40] auto-scroll to bottom of console --- jm-gui.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/jm-gui.py b/jm-gui.py index fac1612c..7d490c97 100644 --- a/jm-gui.py +++ b/jm-gui.py @@ -591,16 +591,20 @@ def initUI(self): buttons.addWidget(self.abortButton) innerTopLayout.addLayout(buttons, len(self.widgets)+1, 0, 1, 2) splitter1 = QSplitter(QtCore.Qt.Vertical) - textedit = QTextEdit() - XStream.stdout().messageWritten.connect(textedit.insertPlainText) - XStream.stderr().messageWritten.connect(textedit.insertPlainText) + self.textedit = QTextEdit() + self.textedit.verticalScrollBar().rangeChanged.connect(self.resizeScroll) + XStream.stdout().messageWritten.connect(self.textedit.insertPlainText) + XStream.stderr().messageWritten.connect(self.textedit.insertPlainText) splitter1.addWidget(top) - splitter1.addWidget(textedit) + splitter1.addWidget(self.textedit) splitter1.setSizes([400, 200]) self.setLayout(vbox) vbox.addWidget(splitter1) self.show() + def resizeScroll(self, mini, maxi): + self.textedit.verticalScrollBar().setValue(maxi) + def startSendPayment(self): self.aborted = False if not self.validateSettings(): From 5dc3da2413ce7ecd158afcbc11b29003635bc771 Mon Sep 17 00:00:00 2001 From: Adam Gibson Date: Mon, 25 Jan 2016 23:54:44 +0200 Subject: [PATCH 09/40] fix file path for wallet load --- jm-gui.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/jm-gui.py b/jm-gui.py index 7d490c97..980af61a 100644 --- a/jm-gui.py +++ b/jm-gui.py @@ -1063,9 +1063,12 @@ def recoverWallet(self): 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='/home/adam/DevRepos/JoinMarket/testing/joinmarket/wallets') - #TODO validate the wallet file, set the directory properly + directory=current_path) + #TODO validate the file looks vaguely like a wallet file log.debug('first arg is: '+firstarg) if not firstarg: return From 66a43246ae6b3ffa82abfdcccae43f3f11decd62 Mon Sep 17 00:00:00 2001 From: Adam Gibson Date: Tue, 26 Jan 2016 00:05:19 +0200 Subject: [PATCH 10/40] enable dynamic update of blockchain interface instance from settings tab --- jm-gui.py | 9 +++++++-- joinmarket/__init__.py | 3 ++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/jm-gui.py b/jm-gui.py index 980af61a..2ea5db4a 100644 --- a/jm-gui.py +++ b/jm-gui.py @@ -45,7 +45,7 @@ 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 + weighted_order_choose, get_blockchain_interface_instance from sendpayment import SendPayment, PT #https://gist.github.com/e000/869791 @@ -485,11 +485,16 @@ def handleEdit(self, section, t, checked=None): else: oname = str(t[0].text()) oval = 'true' if checked else 'false' - print 'setting sectoin: '+section+' and name: '+oname+' to: '+oval + 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 = [] diff --git a/joinmarket/__init__.py b/joinmarket/__init__.py index 2f890ae1..a1f0b57d 100644 --- a/joinmarket/__init__.py +++ b/joinmarket/__init__.py @@ -17,7 +17,8 @@ from .wallet import AbstractWallet, BitcoinCoreInterface, Wallet, \ BitcoinCoreWallet, create_wallet_file from .configure import load_program_config, jm_single, get_p2pk_vbyte, \ - get_network, jm_single, get_network, validate_address + get_network, jm_single, get_network, validate_address, \ + get_blockchain_interface_instance from .blockchaininterface import BlockrInterface # Set default logging handler to avoid "No handler found" warnings. From ec9151de63475e0e3eb77479f93e362527b00f54 Mon Sep 17 00:00:00 2001 From: Adam Gibson Date: Tue, 26 Jan 2016 15:43:33 +0200 Subject: [PATCH 11/40] handle ignored makers in GUI; uses complete restart including IRC restart, which is a little inelegant workflow but a much easier code patch --- jm-gui.py | 24 ++++++++++++++++++++---- sendpayment.py | 5 +++-- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/jm-gui.py b/jm-gui.py index 2ea5db4a..fcade9e2 100644 --- a/jm-gui.py +++ b/jm-gui.py @@ -610,7 +610,7 @@ def initUI(self): def resizeScroll(self, mini, maxi): self.textedit.verticalScrollBar().setValue(maxi) - def startSendPayment(self): + def startSendPayment(self, ignored_makers = None): self.aborted = False if not self.validateSettings(): return @@ -636,6 +636,9 @@ def startSendPayment(self): self.taker = SendPayment(self.irc, w.wallet, self.destaddr, amount, makercount, 5000, 30, mixdepth, False, weighted_order_choose, isolated=True) + self.pt = PT(self.taker) + if ignored_makers: + self.pt.ignored_makers.extend(ignored_makers) thread = TaskThread(self) thread.add(self.runIRC, on_done=self.cleanUp) w.statusBar().showMessage("Connecting to IRC ...") @@ -643,7 +646,6 @@ def startSendPayment(self): thread2.add(self.createTxThread, on_done=self.doTx) def createTxThread(self): - self.pt = PT(self.taker) self.orders, self.total_cj_fee = self.pt.create_tx() log.debug("Finished create_tx") #TODO this can't be done in a thread as currently built; @@ -701,8 +703,22 @@ def giveUp(self): def cleanUp(self): if not self.taker.txid: if not self.aborted: - w.statusBar().showMessage("Transaction failed.") - QMessageBox.warning(self,"Failed","Transaction was not completed.") + if not self.pt.ignored_makers: + w.statusBar().showMessage("Transaction failed.") + QMessageBox.warning(self,"Failed","Transaction was not completed.") + else: + reply = QMessageBox.question(self, "Transaction not completed.", + '\n'.join(["The following counterparties did not respond: ", + ','.join(self.pt.ignored_makers), + "This sometimes happens due to bad network connections.", + "", + "If you would like to try again, ignoring those", + "counterparties, click Yes."]), QMessageBox.Yes, QMessageBox.No) + if reply == QMessageBox.Yes: + self.startSendPayment(ignored_makers=self.pt.ignored_makers) + else: + return + else: w.statusBar().showMessage("Transaction completed successfully.") QMessageBox.information(self,"Success", diff --git a/sendpayment.py b/sendpayment.py index a9a3b946..44c24c4a 100644 --- a/sendpayment.py +++ b/sendpayment.py @@ -412,9 +412,10 @@ def finishcallback(self, coinjointx): self.taker.msgchan.shutdown() return self.ignored_makers += coinjointx.nonrespondants - log.debug('recreating the tx, ignored_makers=' + str( + log.debug('tx negotation failed, ignored_makers=' + str( self.ignored_makers)) - self.create_tx() + #triggers endpoint for GUI + self.taker.msgchan.shutdown() def sendpayment_choose_orders(self, cj_amount, From 7d36d6f28e5998c2720bcce636607c2ca2716bb0 Mon Sep 17 00:00:00 2001 From: Adam Gibson Date: Tue, 26 Jan 2016 15:50:50 +0200 Subject: [PATCH 12/40] update txhistory tab immediately on tx success --- jm-gui.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/jm-gui.py b/jm-gui.py index fcade9e2..608cb004 100644 --- a/jm-gui.py +++ b/jm-gui.py @@ -730,6 +730,10 @@ def cleanUp(self): 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) From fc4853e889052cab9c7323449302f82a22f61d5a Mon Sep 17 00:00:00 2001 From: Adam Gibson Date: Tue, 26 Jan 2016 16:43:39 +0200 Subject: [PATCH 13/40] add program description, minor comment and code removals --- jm-gui.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/jm-gui.py b/jm-gui.py index 608cb004..80c10868 100644 --- a/jm-gui.py +++ b/jm-gui.py @@ -1,5 +1,10 @@ ''' +Joinmarket GUI using PyQt for doing Sendpayment. +Some widgets copied and modified from https://github.com/spesmilo/electrum +The latest version of this code is currently maintained at: +https://github.com/AdamISZ/joinmarket/tree/gui + 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 @@ -16,7 +21,7 @@ import sys, base64, textwrap, re, datetime, os, math, json, logging -import Queue +import Queue, platform from decimal import Decimal from functools import partial @@ -25,8 +30,6 @@ from PyQt4 import QtCore from PyQt4.QtGui import * -import platform - if platform.system() == 'Windows': MONOSPACE_FONT = 'Lucida Console' elif platform.system() == 'Darwin': @@ -48,9 +51,6 @@ weighted_order_choose, get_blockchain_interface_instance from sendpayment import SendPayment, PT -#https://gist.github.com/e000/869791 -import socks -#from socksipyhandler import SocksiPyHandler log = get_log() donation_address = '1LT6rwv26bV7mgvRosoSCyGM7ttVRsYidP' @@ -253,7 +253,7 @@ def make_password_dialog(self, msg, new_pass=True): 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)) @@ -291,8 +291,7 @@ def __init__(self): super(PasswordDialog, self).__init__() self.initUI() - def initUI(self): - #self.setGeometry(300, 300, 290, 150) + def initUI(self): self.setWindowTitle('Create a new password') msg = "Enter a new password" self.setLayout(make_password_dialog(self,msg)) @@ -311,14 +310,11 @@ def __init__(self, parent, create_menu, headers, stretch_column=None, # extend the syntax for consistency self.addChild = self.addTopLevelItem self.insertChild = self.insertTopLevelItem - - # Control which columns are editable self.editor = None self.pending_update = False if editable_columns is None: editable_columns = [stretch_column] self.editable_columns = editable_columns - #self.setItemDelegate(ElectrumItemDelegate(self)) self.itemActivated.connect(self.on_activated) self.update_headers(headers) From ac28b3a3737960bb89f46f15d4d6601af9d22b66 Mon Sep 17 00:00:00 2001 From: Adam Gibson Date: Tue, 26 Jan 2016 21:00:36 +0200 Subject: [PATCH 14/40] removed several globals and magic constants with settings in config, in section GUI, and persist config to joinmarket.cfg on update, a couple more minor tidy up edits --- jm-gui.py | 105 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 80 insertions(+), 25 deletions(-) diff --git a/jm-gui.py b/jm-gui.py index 80c10868..cda13163 100644 --- a/jm-gui.py +++ b/jm-gui.py @@ -55,9 +55,7 @@ log = get_log() donation_address = '1LT6rwv26bV7mgvRosoSCyGM7ttVRsYidP' donation_address_testnet = 'mz6FQosuiNe8135XaQqWYmXsa3aD8YsqGL' -#TODO options/settings not global -gaplimit = 6 -history_file = 'jm-tx-history.txt' + #configuration types config_types = {'rpc_port': int, 'port': int, @@ -66,7 +64,12 @@ 'network': bool, 'socks5_port': int, 'maker_timeout_sec': int, - 'tx_fees': int} + 'tx_fees': int, + 'gaplimit': int, + 'check_high_fee': int, + 'max_mix_depth': int, + 'txfee_default': int, + 'order_wait_time': int} config_tips = {'blockchain_source': 'options: blockr, bitcoin-rpc', 'network': @@ -106,9 +109,40 @@ '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' + '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 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'] + gui_config_default_vals = ['6', 'jm-tx-history.txt', '2', '5', '5000', '30'] + 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) + +def persist_config(): + '''This loses all comments in the config file. + TODO: possibly correct that.''' + with open('joinmarket.cfg','wb') as f: + jm_single().config.write(f) + class QtHandler(logging.Handler): def __init__(self): logging.Handler.__init__(self) @@ -439,6 +473,12 @@ def initUI(self): 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) @@ -630,8 +670,11 @@ def startSendPayment(self, ignored_makers = None): makercount = int(self.widgets[1][1].text()) mixdepth = int(self.widgets[2][1].text()) self.taker = SendPayment(self.irc, w.wallet, self.destaddr, amount, - makercount, 5000, 30, mixdepth, False, - weighted_order_choose, isolated=True) + makercount, + jm_single().config.getint("GUI", "txfee_default"), + jm_single().config.getint("GUI", "order_wait_time"), + mixdepth, False, weighted_order_choose, + isolated=True) self.pt = PT(self.taker) if ignored_makers: self.pt.ignored_makers.extend(ignored_makers) @@ -665,7 +708,7 @@ def doTx(self): mbinfo.append('Total coinjoin fee = ' + str(float('%.3g' % ( 100.0 * total_fee_pc))) + '%') title = 'Check Transaction' - if total_fee_pc > 2: + if total_fee_pc > jm_single().config.getint("GUI","check_high_fee"): title += ': WARNING: Fee is HIGH!!' reply = QMessageBox.question(self, title,'\n'.join(mbinfo), @@ -713,6 +756,8 @@ def cleanUp(self): if reply == QMessageBox.Yes: self.startSendPayment(ignored_makers=self.pt.ignored_makers) else: + self.startButton.setEnabled(True) + self.abortButton.setEnabled(False) return else: @@ -721,7 +766,7 @@ def cleanUp(self): "Transaction has been broadcast.\n"+ "Txid: "+str(self.taker.txid)) #persist the transaction to history - with open(history_file,'ab') as f: + 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")])) @@ -781,7 +826,8 @@ def getSettingsWidgets(self): 'The amount IN BITCOINS to send.\n'] sT = [str, int, int, float] #todo maxmixdepth - sMM = ['',(2,20),(0,5),(0.00000001,100.0,8)] + 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]) @@ -830,18 +876,19 @@ def updateTxInfo(self, txinfo=None): self.tHTW.resizeColumnToContents(i) def getTxInfoFromFile(self): - if not os.path.isfile(history_file): + 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(history_file,'rb') as f: + with open(hf,'rb') as f: txlines = f.readlines() for tl in txlines: txhist.append(tl.strip().split(',')) if not len(txhist[-1])==4: QMessageBox.warning(self,"Error", - "Incorrectedly formatted file jm-tx-history.txt") + "Incorrectedly formatted file "+hf) w.statusBar().showMessage("No transaction history found.") return [] return txhist[::-1] #appended to file in date order, window shows reverse @@ -869,9 +916,8 @@ def create_menu(self, position): menu.exec_(self.tHTW.viewport().mapToGlobal(position)) class JMWalletTab(QWidget): - def __init__(self, mixdepths): + def __init__(self): super(JMWalletTab, self).__init__() - self.mixdepths = mixdepths self.wallet_name = 'NONE' self.initUI() @@ -879,7 +925,6 @@ def initUI(self): self.label1 = QLabel( "CURRENT WALLET: "+self.wallet_name + ', total balance: 0.0', self) - #label1.resize(300,120) v = MyTreeWidget(self, self.create_menu, self.getHeaders()) v.setSelectionMode(QAbstractItemView.ExtendedSelection) v.on_update = self.updateWalletInfo @@ -933,7 +978,7 @@ def updateWalletInfo(self, walletinfo=None): self.label1.setText( "CURRENT WALLET: "+self.wallet_name + ', total balance: '+total_bal) - for i in range(self.mixdepths): + for i in range(jm_single().config.getint("GUI","max_mix_depth")): if walletinfo: mdbalance = mbalances[i] else: @@ -1006,6 +1051,16 @@ def __init__(self): self.wallet=None self.initUI() + def closeEvent(self, event): + quit_msg = "Are you sure you want to quit?" + reply = QMessageBox.question(self, "Joinmarket", quit_msg, + QMessageBox.Yes, QMessageBox.No) + if reply == QMessageBox.Yes: + persist_config() + event.accept() + else: + event.ignore() + def initUI(self): self.statusBar().showMessage("Ready") self.setGeometry(300,300,250,150) @@ -1054,12 +1109,10 @@ def recoverWallet(self): d = QDialog(self) d.setModal(1) d.setWindowTitle('Recover from seed') - #d.setMinimumSize(290, 130) layout = QGridLayout(d) message_e = QTextEdit() layout.addWidget(QLabel('Enter 12 words'), 0, 0) layout.addWidget(message_e, 1, 0) - #layout.setRowStretch(2,3) hbox = QHBoxLayout() buttonBox = QDialogButtonBox(self) buttonBox.setStandardButtons(QDialogButtonBox.Ok|QDialogButtonBox.Cancel) @@ -1114,7 +1167,9 @@ def selectWallet(self, testnet_seed=None): def loadWalletFromBlockchain(self, firstarg=None, pwd=None): if (firstarg and pwd) or (firstarg and get_network()=='testnet'): - self.wallet = Wallet(str(firstarg), max_mix_depth=5, pwd=pwd) + self.wallet = Wallet(str(firstarg), + max_mix_depth=jm_single().config.getint("GUI","max_mix_depth"), + pwd=pwd) if not self.wallet.decrypted: QMessageBox.warning(self,"Error","Wrong password") return False @@ -1213,7 +1268,8 @@ def get_wallet_printout(wallet): balance_depth = 0 for forchange in [0,1]: rows[m].append([]) - for k in range(wallet.index[m][forchange] + gaplimit): + 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(): @@ -1234,18 +1290,17 @@ def get_wallet_printout(wallet): ################################ load_program_config() +update_config_for_gui() app = QApplication(sys.argv) appWindowTitle = 'Joinmarket GUI' w = JMMainWindow() tabWidget = QTabWidget(w) -mdepths = 5 -tabWidget.addTab(JMWalletTab(mdepths), "JM Wallet") +tabWidget.addTab(JMWalletTab(), "JM Wallet") settingsTab = SettingsTab() tabWidget.addTab(settingsTab, "Settings") tabWidget.addTab(SpendTab(), "Send Payment") -tabWidget.addTab(TxHistoryTab(),"Tx History") +tabWidget.addTab(TxHistoryTab(), "Tx History") w.resize(600, 500) -#w.move(300, 300) suffix = ' - Testnet' if get_network() == 'testnet' else '' w.setWindowTitle(appWindowTitle + suffix) tabWidget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) From d6a6afbdffeadd3557f061b390e5e839048712bc Mon Sep 17 00:00:00 2001 From: Adam Gibson Date: Tue, 26 Jan 2016 21:52:12 +0200 Subject: [PATCH 15/40] persist config in text mode for correct Windows newlines --- jm-gui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jm-gui.py b/jm-gui.py index cda13163..eb03a34e 100644 --- a/jm-gui.py +++ b/jm-gui.py @@ -140,7 +140,7 @@ def update_config_for_gui(): def persist_config(): '''This loses all comments in the config file. TODO: possibly correct that.''' - with open('joinmarket.cfg','wb') as f: + with open('joinmarket.cfg','w') as f: jm_single().config.write(f) class QtHandler(logging.Handler): From 49f5cf69488b5e65dba4eefac514358403f3a1ca Mon Sep 17 00:00:00 2001 From: Adam Gibson Date: Tue, 26 Jan 2016 23:32:00 +0200 Subject: [PATCH 16/40] improve About dialog, add copyable, clickable links --- jm-gui.py | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/jm-gui.py b/jm-gui.py index eb03a34e..ba88e2d1 100644 --- a/jm-gui.py +++ b/jm-gui.py @@ -1093,14 +1093,31 @@ def initUI(self): self.show() def showAboutDialog(self): - QMessageBox.about(self, "Joinmarket", - "\n".join(["Joinmarket version: 0.1.2", - "Protocol version:"+" %s" % (str(jm_single().JM_VERSION)), - "Joinmarket sendpayment tool", - "Help support Bitcoin fungibility -" - "donate here: ", - donation_address])) - + msgbox = QDialog(self) + lyt = QVBoxLayout(msgbox) + msgbox.setWindowTitle("Joinmarket GUI") + label1 = QLabel() + label1.setText(""+ + "Read more about Joinmarket

"+ + "

".join(["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 recoverWallet(self): if get_network()=='testnet': QMessageBox.information(self, 'Error', From 4f0d901c8effeac42ba81e3de1f5e4c1247ad637 Mon Sep 17 00:00:00 2001 From: Adam Gibson Date: Tue, 26 Jan 2016 23:42:48 +0200 Subject: [PATCH 17/40] removed unwanted prints of secret data and changed other print statements to log.debug --- jm-gui.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/jm-gui.py b/jm-gui.py index ba88e2d1..e53b91da 100644 --- a/jm-gui.py +++ b/jm-gui.py @@ -955,7 +955,7 @@ def create_menu(self, position): btc.b58check_to_hex(address) address_valid = True except AssertionError: - print 'no btc address found, not creating menu item' + log.debug('no btc address found, not creating menu item') menu = QMenu() if address_valid: @@ -1139,16 +1139,13 @@ def recoverWallet(self): layout.addLayout(hbox, 3, 0) result = d.exec_() if result != QDialog.Accepted: - print 'cancelled' return msg = str(message_e.toPlainText()) words = msg.split() #splits on any number of ws chars - print words if not len(words)==12: QMessageBox.warning(self, "Error","You did not provide 12 words, aborting.") else: seed = mn_decode(words) - print 'seed is: '+seed self.initWallet(seed=seed) @@ -1217,7 +1214,7 @@ def resyncWallet(self): def generateWallet(self): - print 'generating wallet' + log.debug('generating wallet') if get_network() == 'testnet': seed = self.getTestnetSeed() self.selectWallet(testnet_seed=seed) @@ -1253,8 +1250,7 @@ def initWallet(self, seed = None): QMessageBox.warning(self,"Error","Passwords don't match.") continue break - - print 'got password: '+str(pd.new_pw.text()) + 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") From e62680e6e32b3e8402032da35e7b68a8bea1e0a6 Mon Sep 17 00:00:00 2001 From: Adam Gibson Date: Wed, 27 Jan 2016 00:01:40 +0200 Subject: [PATCH 18/40] create wallets subdirectory if it doesnt exist --- jm-gui.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/jm-gui.py b/jm-gui.py index e53b91da..680b28ad 100644 --- a/jm-gui.py +++ b/jm-gui.py @@ -1257,6 +1257,9 @@ def initWallet(self, seed = None): if not ok: QMessageBox.warning(self,"Error","Create wallet aborted") 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): From e2c5ecd9f37fb3e7cba34c20f143a05fc95eb263 Mon Sep 17 00:00:00 2001 From: Adam Gibson Date: Wed, 27 Jan 2016 14:28:56 +0200 Subject: [PATCH 19/40] dont display new addresses for internal branch --- jm-gui.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/jm-gui.py b/jm-gui.py index 680b28ad..27f85c89 100644 --- a/jm-gui.py +++ b/jm-gui.py @@ -1293,7 +1293,8 @@ def get_wallet_printout(wallet): 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]: + 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) From fcfc6fd58f757dd1cf2f41ceea9798a9f9f65ec2 Mon Sep 17 00:00:00 2001 From: Adam Gibson Date: Wed, 27 Jan 2016 14:53:59 +0200 Subject: [PATCH 20/40] only show txhistory context menu if an item is selected --- jm-gui.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/jm-gui.py b/jm-gui.py index 27f85c89..6957fc68 100644 --- a/jm-gui.py +++ b/jm-gui.py @@ -895,6 +895,8 @@ def getTxInfoFromFile(self): def create_menu(self, position): item = self.tHTW.currentItem() + if not item: + return address_valid = False if item: address = str(item.text(0)) From d2dc662eed1ec798ac1ca65b9ed804a4da8f4461 Mon Sep 17 00:00:00 2001 From: Adam Gibson Date: Wed, 27 Jan 2016 15:02:15 +0200 Subject: [PATCH 21/40] signed commit. add logs dir if not there, add detailed version information to About --- jm-gui.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/jm-gui.py b/jm-gui.py index 6957fc68..5ab42719 100644 --- a/jm-gui.py +++ b/jm-gui.py @@ -45,6 +45,9 @@ import bitcoin as btc +JM_CORE_VERSION = '0.1.2' +JM_GUI_VERSION = '1' + 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, \ @@ -1102,7 +1105,9 @@ def showAboutDialog(self): label1.setText(""+ "Read more about Joinmarket

"+ - "

".join(["Protocol version:"+" %s" % ( + "

".join(["Joinmarket core software version: "+JM_CORE_VERSION, + "Joinmarket GUI version: "+JM_GUI_VERSION, + "Messaging protocol version:"+" %s" % ( str(jm_single().JM_VERSION)), "Help us support Bitcoin fungibility -", "donate here: "])) @@ -1310,6 +1315,10 @@ def get_wallet_printout(wallet): ################################ load_program_config() update_config_for_gui() +#we're not downloading from github, so logs dir +#might not exist +if not os.path.exists('logs'): + os.makedirs('logs') app = QApplication(sys.argv) appWindowTitle = 'Joinmarket GUI' w = JMMainWindow() From 3114e3afedb72f37a649ae3790f9887156c9d1c9 Mon Sep 17 00:00:00 2001 From: Adam Gibson Date: Tue, 9 Feb 2016 23:32:09 +0200 Subject: [PATCH 22/40] add privacy warning for Blockr usage --- jm-gui.py | 48 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/jm-gui.py b/jm-gui.py index 5ab42719..8a083815 100644 --- a/jm-gui.py +++ b/jm-gui.py @@ -59,6 +59,11 @@ 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, @@ -72,7 +77,8 @@ 'check_high_fee': int, 'max_mix_depth': int, 'txfee_default': int, - 'order_wait_time': int} + 'order_wait_time': int, + 'privacy_warning': None} config_tips = {'blockchain_source': 'options: blockr, bitcoin-rpc', 'network': @@ -139,6 +145,10 @@ def update_config_for_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. @@ -545,6 +555,8 @@ def getSettingsFields(self, section, names): qt = QCheckBox() if val=='testnet' or val.lower()=='true': qt.setChecked(True) + elif not t: + continue else: qt = QLineEdit(val) if t == int: @@ -653,6 +665,11 @@ 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 QMessageBox.information(self,"Sendpayment","Connecting to IRC.\n"+ "View real-time log in the lower pane.") @@ -812,7 +829,34 @@ def validateSettings(self): QMessageBox.warning(self,"Error","There is no wallet loaded.") 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: From f6a11286e0fa0093aae82e9641c7235748b23fb9 Mon Sep 17 00:00:00 2001 From: Adam Gibson Date: Tue, 9 Feb 2016 23:47:37 +0200 Subject: [PATCH 23/40] Show error if recovery words are wrong, correct wallet load debug line --- jm-gui.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/jm-gui.py b/jm-gui.py index 8a083815..92b21885 100644 --- a/jm-gui.py +++ b/jm-gui.py @@ -1196,9 +1196,12 @@ def recoverWallet(self): if not len(words)==12: QMessageBox.warning(self, "Error","You did not provide 12 words, aborting.") else: - seed = mn_decode(words) - self.initWallet(seed=seed) - + try: + seed = mn_decode(words) + self.initWallet(seed=seed) + except ValueError as e: + QMessageBox.warning(self, "Error", + "Could not decode seedphrase: "+repr(e)) def selectWallet(self, testnet_seed=None): if get_network() != 'testnet': @@ -1208,7 +1211,7 @@ def selectWallet(self, testnet_seed=None): firstarg = QFileDialog.getOpenFileName(self, 'Choose Wallet File', directory=current_path) #TODO validate the file looks vaguely like a wallet file - log.debug('first arg is: '+firstarg) + log.debug('Looking for wallet in: '+firstarg) if not firstarg: return decrypted = False From bab6951a079cd00b0107f4f4eb58ea61504e5046 Mon Sep 17 00:00:00 2001 From: Adam Gibson Date: Thu, 11 Feb 2016 01:03:37 +0200 Subject: [PATCH 24/40] add sweep; set amount=0BTC in spending tab --- jm-gui.py | 22 ++++++---- sendpayment.py | 112 +++++++++++++++++++++++++++++++++---------------- 2 files changed, 90 insertions(+), 44 deletions(-) diff --git a/jm-gui.py b/jm-gui.py index 92b21885..c59d926a 100644 --- a/jm-gui.py +++ b/jm-gui.py @@ -705,7 +705,7 @@ def startSendPayment(self, ignored_makers = None): thread2.add(self.createTxThread, on_done=self.doTx) def createTxThread(self): - self.orders, self.total_cj_fee = self.pt.create_tx() + self.orders, self.total_cj_fee, self.cjamount, self.utxos = self.pt.create_tx() log.debug("Finished create_tx") #TODO this can't be done in a thread as currently built; #how else? or fix? @@ -716,10 +716,16 @@ def doTx(self): QMessageBox.warning(self,"Error","Not enough matching orders found.") self.giveUp() return - total_fee_pc = 1.0 * self.total_cj_fee / self.taker.amount + + total_fee_pc = 1.0 * self.total_cj_fee / self.cjamount + + #reset the btc amount display string if it's a sweep: + if self.taker.amount == 0: + self.btc_amount_str = str((Decimal(self.cjamount)/Decimal('1e8'))) + mbinfo = [] - mbinfo.append("Sending amount: "+self.btc_amount_str+" BTC") - mbinfo.append("to address: "+self.destaddr) + mbinfo.append("Sending amount: " + self.btc_amount_str + " BTC") + mbinfo.append("to address: " + self.destaddr) mbinfo.append(" ") mbinfo.append("Counterparties chosen:") mbinfo.append('\t'.join(['Name','Order id'])) @@ -743,6 +749,7 @@ def doTx(self): else: da = donation_address thread3.add(partial(self.pt.do_tx,self.total_cj_fee, self.orders, + self.cjamount, self.utxos, self.donateCheckBox.isChecked(), self.donateLimitBox.value(), da), @@ -776,8 +783,7 @@ def cleanUp(self): if reply == QMessageBox.Yes: self.startSendPayment(ignored_makers=self.pt.ignored_makers) else: - self.startButton.setEnabled(True) - self.abortButton.setEnabled(False) + self.giveUp() return else: @@ -870,7 +876,9 @@ def getSettingsWidgets(self): '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'] + '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), diff --git a/sendpayment.py b/sendpayment.py index 44c24c4a..598a67c0 100644 --- a/sendpayment.py +++ b/sendpayment.py @@ -344,52 +344,90 @@ def create_tx(self): log.debug('not enough counterparties to fill order, ending') #NB: don't shutdown msgchan here, that is done by the caller #after setting GUI state to reflect the reason for shutdown. - return None, None + return None, None, None, None utxos = None orders = None - cjamount = 0 + cjamount = None change_addr = None choose_orders_recover = None - orders, total_cj_fee = self.sendpayment_choose_orders( - self.taker.amount, self.taker.makercount) - if not orders: - log.debug( - 'ERROR not enough liquidity in the orderbook, exiting') - return None, None - return orders, total_cj_fee + if self.taker.amount == 0: + utxos = self.taker.wallet.get_utxos_by_mixdepth()[ + self.taker.mixdepth] + #do our best to estimate the fee based on the number of + #our own utxos; this estimate may be significantly higher + #than the default set in option.txfee * makercount, where + #we have a large number of utxos to spend. If it is smaller, + #we'll be conservative and retain the original estimate. + est_ins = len(utxos)+3*self.taker.makercount + log.debug("Estimated ins: "+str(est_ins)) + est_outs = 2*self.taker.makercount + 1 + log.debug("Estimated outs: "+str(est_outs)) + estimated_fee = estimate_tx_fee(est_ins, est_outs) + log.debug("We have a fee estimate: "+str(estimated_fee)) + log.debug("And a requested fee of: "+str( + self.taker.txfee * self.taker.makercount)) + if estimated_fee > self.taker.makercount * self.taker.txfee: + #both values are integers; we can ignore small rounding errors + self.taker.txfee = estimated_fee / self.taker.makercount + total_value = sum([va['value'] for va in utxos.values()]) + orders, cjamount = choose_sweep_orders( + self.taker.db, total_value, self.taker.txfee, + self.taker.makercount, self.taker.chooseOrdersFunc, + self.ignored_makers) + if not orders: + raise Exception("Could not find orders to complete transaction.") + total_cj_fee = total_value - cjamount - \ + self.taker.txfee*self.taker.makercount - def do_tx(self, total_cj_fee, orders, + else: + orders, total_cj_fee = self.sendpayment_choose_orders( + self.taker.amount, self.taker.makercount) + cjamount = self.taker.amount + if not orders: + log.debug( + 'ERROR not enough liquidity in the orderbook, exiting') + return None, None, None, None + return orders, total_cj_fee, cjamount, utxos + + def do_tx(self, total_cj_fee, orders, cjamount, utxos, donate=False, donate_trigger=1000000, donation_address=None): - total_amount = self.taker.amount + total_cj_fee + \ - self.taker.txfee*self.taker.makercount - log.debug('total estimated amount spent = ' + str(total_amount)) - #adjust the required amount upwards to anticipate a tripling of - #transaction fee after re-estimation; this is sufficiently conservative - #to make failures unlikely while keeping the occurence of failure to - #find sufficient utxos extremely rare. Indeed, a tripling of 'normal' - #txfee indicates undesirable behaviour on maker side anyway. - try: - utxos = self.taker.wallet.select_utxos(self.taker.mixdepth, - total_amount+2*self.taker.txfee*self.taker.makercount) - except Exception as e: - log.debug("Failed to select coins: "+repr(e)) - return - my_total_in = sum([va['value'] for u, va in utxos.iteritems()]) - cjamount = self.taker.amount - log.debug("using coinjoin amount: "+str(cjamount)) - change_amount = my_total_in-cjamount - log.debug("using change amount: "+str(change_amount)) - if donate and change_amount < donate_trigger*1e8: - #sanity check - res = validate_address(donation_address) - if not res[0]: - log.debug("Donation address invalid! Error: "+res[1]) + #for non-sweep, we now have to set amount, change address and utxo selection + if self.taker.amount > 0: + total_amount = self.taker.amount + total_cj_fee + \ + self.taker.txfee*self.taker.makercount + log.debug('total estimated amount spent = ' + str(total_amount)) + #adjust the required amount upwards to anticipate a tripling of + #transaction fee after re-estimation; this is sufficiently conservative + #to make failures unlikely while keeping the occurence of failure to + #find sufficient utxos extremely rare. Indeed, a tripling of 'normal' + #txfee indicates undesirable behaviour on maker side anyway. + try: + utxos = self.taker.wallet.select_utxos(self.taker.mixdepth, + total_amount+2*self.taker.txfee*self.taker.makercount) + except Exception as e: + log.debug("Failed to select coins: "+repr(e)) return - change_addr = donation_address + my_total_in = sum([va['value'] for u, va in utxos.iteritems()]) + log.debug("using coinjoin amount: "+str(cjamount)) + change_amount = my_total_in-cjamount + log.debug("using change amount: "+str(change_amount)) + if donate and change_amount < donate_trigger*1e8: + #sanity check + res = validate_address(donation_address) + if not res[0]: + log.debug("Donation address invalid! Error: "+res[1]) + return + change_addr = donation_address + else: + change_addr = self.taker.wallet.get_internal_addr(self.taker.mixdepth) + log.debug("using change address: "+change_addr) + + #For sweeps, we reset the change address to None, and use the provided + #amount and utxos (calculated in the first step) else: - change_addr = self.taker.wallet.get_internal_addr(self.taker.mixdepth) - log.debug("using change address: "+change_addr) + change_addr = None + choose_orders_recover = self.sendpayment_choose_orders log.debug("About to start coinjoin") try: From b6d21919046264ab0acdebd56473c2778b030194 Mon Sep 17 00:00:00 2001 From: Adam Gibson Date: Thu, 11 Feb 2016 20:10:57 +0200 Subject: [PATCH 25/40] add joinmarket and core alerts to status bar, and very loudly to the transaction agreement dialog. --- jm-gui.py | 34 ++++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/jm-gui.py b/jm-gui.py index c59d926a..e8802cf6 100644 --- a/jm-gui.py +++ b/jm-gui.py @@ -51,7 +51,8 @@ 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 + weighted_order_choose, get_blockchain_interface_instance, joinmarket_alert, \ + core_alert from sendpayment import SendPayment, PT @@ -234,7 +235,7 @@ def __init__(self, text, help_text, wtitle): self.help_text = help_text self.wtitle = wtitle self.font = QFont() - self.setStyleSheet("QLabel {color: blue;}") + self.setStyleSheet(BLUE_FG) def mouseReleaseEvent(self, x): QMessageBox.information(w, self.wtitle, self.help_text, 'OK') @@ -649,8 +650,8 @@ def initUI(self): splitter1 = QSplitter(QtCore.Qt.Vertical) self.textedit = QTextEdit() self.textedit.verticalScrollBar().rangeChanged.connect(self.resizeScroll) - XStream.stdout().messageWritten.connect(self.textedit.insertPlainText) - XStream.stderr().messageWritten.connect(self.textedit.insertPlainText) + XStream.stdout().messageWritten.connect(self.updateConsoleText) + XStream.stderr().messageWritten.connect(self.updateConsoleText) splitter1.addWidget(top) splitter1.addWidget(self.textedit) splitter1.setSizes([400, 200]) @@ -658,6 +659,18 @@ def initUI(self): 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. + 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) @@ -724,6 +737,14 @@ def doTx(self): self.btc_amount_str = str((Decimal(self.cjamount)/Decimal('1e8'))) mbinfo = [] + 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 + " BTC") mbinfo.append("to address: " + self.destaddr) mbinfo.append(" ") @@ -737,7 +758,7 @@ def doTx(self): if total_fee_pc > jm_single().config.getint("GUI","check_high_fee"): title += ': WARNING: Fee is HIGH!!' reply = QMessageBox.question(self, - title,'\n'.join(mbinfo), + title,'\n'.join([m + '

' for m in mbinfo]), QMessageBox.Yes,QMessageBox.No) if reply == QMessageBox.Yes: log.debug('You agreed, transaction proceeding') @@ -1152,7 +1173,7 @@ def initUI(self): def showAboutDialog(self): msgbox = QDialog(self) lyt = QVBoxLayout(msgbox) - msgbox.setWindowTitle("Joinmarket GUI") + msgbox.setWindowTitle("JoinMarket GUI") label1 = QLabel() label1.setText(""+ @@ -1370,6 +1391,7 @@ def get_wallet_printout(wallet): ################################ load_program_config() update_config_for_gui() + #we're not downloading from github, so logs dir #might not exist if not os.path.exists('logs'): From 291b2c7c0a154d79aac911b6808fe13b84c663a8 Mon Sep 17 00:00:00 2001 From: Adam Gibson Date: Thu, 11 Feb 2016 22:56:45 +0200 Subject: [PATCH 26/40] update GUI version to 2, change app name fields to JoinMarketQt --- jm-gui.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/jm-gui.py b/jm-gui.py index e8802cf6..eec1c3a6 100644 --- a/jm-gui.py +++ b/jm-gui.py @@ -46,7 +46,7 @@ import bitcoin as btc JM_CORE_VERSION = '0.1.2' -JM_GUI_VERSION = '1' +JM_GUI_VERSION = '2' from joinmarket import load_program_config, get_network, Wallet, encryptData, \ get_p2pk_vbyte, jm_single, mn_decode, mn_encode, create_wallet_file, \ @@ -1131,7 +1131,7 @@ def __init__(self): def closeEvent(self, event): quit_msg = "Are you sure you want to quit?" - reply = QMessageBox.question(self, "Joinmarket", quit_msg, + reply = QMessageBox.question(self, appWindowTitle, quit_msg, QMessageBox.Yes, QMessageBox.No) if reply == QMessageBox.Yes: persist_config() @@ -1173,13 +1173,13 @@ def initUI(self): def showAboutDialog(self): msgbox = QDialog(self) lyt = QVBoxLayout(msgbox) - msgbox.setWindowTitle("JoinMarket GUI") + msgbox.setWindowTitle(appWindowTitle) label1 = QLabel() label1.setText(""+ "Read more about Joinmarket

"+ "

".join(["Joinmarket core software version: "+JM_CORE_VERSION, - "Joinmarket GUI version: "+JM_GUI_VERSION, + "JoinmarketQt version: "+JM_GUI_VERSION, "Messaging protocol version:"+" %s" % ( str(jm_single().JM_VERSION)), "Help us support Bitcoin fungibility -", @@ -1397,7 +1397,7 @@ def get_wallet_printout(wallet): if not os.path.exists('logs'): os.makedirs('logs') app = QApplication(sys.argv) -appWindowTitle = 'Joinmarket GUI' +appWindowTitle = 'JoinMarketQt' w = JMMainWindow() tabWidget = QTabWidget(w) tabWidget.addTab(JMWalletTab(), "JM Wallet") From 4edb6dcc82c7b7b698aed9bfef55799faa7d69d3 Mon Sep 17 00:00:00 2001 From: Adam Gibson Date: Thu, 11 Feb 2016 22:59:15 +0200 Subject: [PATCH 27/40] rename main gui script to joinmarket-qt.py --- jm-gui.py => joinmarket-qt.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename jm-gui.py => joinmarket-qt.py (100%) diff --git a/jm-gui.py b/joinmarket-qt.py similarity index 100% rename from jm-gui.py rename to joinmarket-qt.py From f563581c34defec1f0eddc0c54e133707676919d Mon Sep 17 00:00:00 2001 From: Adam Gibson Date: Tue, 16 Feb 2016 00:03:20 +0200 Subject: [PATCH 28/40] sync wallet before starting tx to avoid spending already spent utxos --- joinmarket-qt.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/joinmarket-qt.py b/joinmarket-qt.py index eec1c3a6..1aeffd95 100644 --- a/joinmarket-qt.py +++ b/joinmarket-qt.py @@ -692,8 +692,9 @@ def startSendPayment(self, ignored_makers = None): jm_single().nickname = random_nick() log.debug('starting sendpayment') - #TODO: is this necessary? - #jm_single().bc_interface.sync_wallet(wallet) + + w.statusBar().showMessage("Syncing wallet ...") + jm_single().bc_interface.sync_wallet(w.wallet) self.irc = IRCMessageChannel(jm_single().nickname) self.destaddr = str(self.widgets[0][1].text()) From 7dff7f958c5931c5d6f8d4c6c31ea1d68c4d9582 Mon Sep 17 00:00:00 2001 From: Adam Gibson Date: Tue, 16 Feb 2016 00:09:54 +0200 Subject: [PATCH 29/40] update joinmarket version numbers for v0.1.3 --- joinmarket-qt.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/joinmarket-qt.py b/joinmarket-qt.py index 1aeffd95..9842be72 100644 --- a/joinmarket-qt.py +++ b/joinmarket-qt.py @@ -45,8 +45,8 @@ import bitcoin as btc -JM_CORE_VERSION = '0.1.2' -JM_GUI_VERSION = '2' +JM_CORE_VERSION = '0.1.3' +JM_GUI_VERSION = '3' from joinmarket import load_program_config, get_network, Wallet, encryptData, \ get_p2pk_vbyte, jm_single, mn_decode, mn_encode, create_wallet_file, \ From 9a114cf30475de597158b4e8a877d6a353ca4f6c Mon Sep 17 00:00:00 2001 From: Adam Gibson Date: Tue, 16 Feb 2016 01:34:28 +0200 Subject: [PATCH 30/40] fix order reading for new order dict, display more detailed information in check transaction dialog --- joinmarket-qt.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/joinmarket-qt.py b/joinmarket-qt.py index 9842be72..30b79095 100644 --- a/joinmarket-qt.py +++ b/joinmarket-qt.py @@ -750,13 +750,23 @@ def doTx(self): mbinfo.append("to address: " + self.destaddr) mbinfo.append(" ") mbinfo.append("Counterparties chosen:") - mbinfo.append('\t'.join(['Name','Order id'])) + mbinfo.append('Name, Order id, Coinjoin fee (sat.)') for k,o in self.orders.iteritems(): - mbinfo.append('\t'.join([k,str(o)])) - mbinfo.append('Total coinjoin fee = ' + str(float('%.3g' % ( - 100.0 * total_fee_pc))) + '%') + if o['ordertype']=='relorder': + display_fee = int(self.cjamount*float(o['cjfee'])) - int(o['txfee']) + elif o['ordertype'] == 'absorder': + display_fee = int(o['cjfee']) - int(o['txfee']) + else: + log.debug("Unsupported order type: " + str( + o['ordertype']) + ", aborting.") + self.giveUp() + return + mbinfo.append(k + ', ' + str(o['oid']) + ', ' + str(display_fee)) + mbinfo.append('Total coinjoin fee = ' +str( + self.total_cj_fee) + ' satoshis, or ' + str(float('%.3g' % ( + 100.0 * total_fee_pc))) + '%') title = 'Check Transaction' - if total_fee_pc > jm_single().config.getint("GUI","check_high_fee"): + if total_fee_pc * 100 > jm_single().config.getint("GUI","check_high_fee"): title += ': WARNING: Fee is HIGH!!' reply = QMessageBox.question(self, title,'\n'.join([m + '

' for m in mbinfo]), From bb76d671117d85dc121b95babe6ae69b0f7dabda Mon Sep 17 00:00:00 2001 From: chris-belcher Date: Sat, 19 Mar 2016 16:49:03 +0000 Subject: [PATCH 31/40] reverted default port change --- joinmarket/configure.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/joinmarket/configure.py b/joinmarket/configure.py index 74841338..9294a9fd 100644 --- a/joinmarket/configure.py +++ b/joinmarket/configure.py @@ -117,8 +117,7 @@ def jm_single(): socks5_port = 9050 #for tor #host = 6dvj6v5imhny3anf.onion -#onion / i2p have their own ports on CGAN -#port = 6698 +#port = 6697 #usessl = true #socks5 = true maker_timeout_sec = 30 From 832a397ca5cd43a9a49c24fb3072e780ec87590a Mon Sep 17 00:00:00 2001 From: wozz Date: Tue, 24 May 2016 11:42:15 -0400 Subject: [PATCH 32/40] Fix NotifyThread Extra line, looks like a merge error --- joinmarket/blockchaininterface.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/joinmarket/blockchaininterface.py b/joinmarket/blockchaininterface.py index 418a6a5a..4a7d0b22 100644 --- a/joinmarket/blockchaininterface.py +++ b/joinmarket/blockchaininterface.py @@ -343,8 +343,7 @@ def run(self): self.confirmfun( btc.deserialize(confirmed_txhex), confirmed_txid, 1) - NotifyThread(self.blockr_domain(), txd, unconfirmfun, confirmfun).start() - NotifyThread(self.blockr_domain, txd, unconfirmfun, confirmfun, timeoutfun).start() + NotifyThread(self.blockr_domain(), txd, unconfirmfun, confirmfun, timeoutfun).start() def pushtx(self, txhex): try: From a889de142235fbfec1fb5bc29d9f386c7f4580ac Mon Sep 17 00:00:00 2001 From: dan-da Date: Wed, 25 May 2016 16:40:23 -0700 Subject: [PATCH 33/40] format tables nicely fixed-width with headers+borders via texttable.py. --- texttable.py | 122 +++++++++++++++++++++++++++++++++++++++++++++++++ wallet-tool.py | 85 +++++++++++++++++++++++++++++----- 2 files changed, 195 insertions(+), 12 deletions(-) create mode 100644 texttable.py diff --git a/texttable.py b/texttable.py new file mode 100644 index 00000000..5d00c5b0 --- /dev/null +++ b/texttable.py @@ -0,0 +1,122 @@ +# texttable-python. ( https://github.com/dan-da/texttable-python ) +# author: Dan Libby. https://github.com/dan-da/ + +from collections import OrderedDict + +class ttrow(OrderedDict): + pass + +# +# A class to print text in formatted tables. +# +class texttable: + + + # Formats a fixed-width text table, with borders. + # + # param rows array of rows. each row contains array or ttrow (key/val) + # param headertype keys | firstrow | none/None + # param footertype keys | lastrow | none/None + # param empty_row_string String to use when there is no data, or None. + @classmethod + def table( cls, rows, headertype = 'keys', footertype = 'none', empty_row_string = 'No Data' ): + + if not len( rows ): + if( empty_row_string != None ): + rows = [ [ empty_row_string ] ] + else: + return '' + + header = footer = None + if( headertype == 'keys' and isinstance(rows[0], dict)): + header = cls.obj_arr( rows[0] ).keys() + elif( headertype == 'firstrow' ): + header = cls.obj_arr( rows[0] ) + rows = rows[1:] + if( footertype == 'keys' and len( rows ) and isinstance(rows[len(rows)-1],dict) ): + footer = cls.obj_arr( rows[len(rows) - 1] ).keys() + elif( footertype == 'lastrow' and len( rows ) ): + footer = cls.obj_arr( rows[len(rows)-1] ) + rows = rows[:-1] + + col_widths = {} + + if( header ): + cls.calc_row_col_widths( col_widths, header ) + if( footer ): + cls.calc_row_col_widths( col_widths, footer ) + for row in rows: + row = cls.obj_arr( row ) + cls.calc_row_col_widths( col_widths, row ) + + buf = '' + if( header ): + buf += cls.print_divider_row( col_widths ) + buf += cls.print_row( col_widths, header ) + buf += cls.print_divider_row( col_widths ) + for row in rows: + row = cls.obj_arr( row ) + buf += cls.print_row( col_widths, row ) + buf += cls.print_divider_row( col_widths ) + if( footer ): + buf += cls.print_row( col_widths, footer ) + buf += cls.print_divider_row( col_widths ) + + return buf + + @classmethod + def print_divider_row( cls, col_widths ): + buf = '+' + for i in range(0, len(col_widths)): + width = col_widths[i] + buf += '-' + '-'.ljust( width, '-' ) + "-+" + buf += "\n" + return buf + + @classmethod + def print_row( cls, col_widths, row ): + buf = '|' + idx = 0 + for val in row: + + if isinstance(row, dict): + val = row[val] + val = str(val) + + pad_type = 'left' if cls.is_numeric( val ) else 'right' + if pad_type == 'left': + buf += ' ' + val.rjust( col_widths[idx], ' ' ) + " |" + else: + buf += ' ' + val.ljust( col_widths[idx], ' ' ) + " |" + idx = idx + 1 + return buf + "\n" + + @classmethod + def calc_row_col_widths( cls, col_widths, row ): + idx = 0 + + for val in row: + + if isinstance(row, dict): + val = row[val] + val = str(val) + + if idx not in col_widths: + col_widths[idx] = 0 + if( len(val) > col_widths[idx] ): + col_widths[idx] = len(val) + idx = idx + 1 + + @classmethod + def obj_arr( cls, t ): + return t + return dir( t ) if isinstance( t, object ) else t + + @classmethod + def is_numeric(cls, var): + try: + float(var) + return True + except ValueError: + return False + diff --git a/wallet-tool.py b/wallet-tool.py index ea65eb1f..d765a197 100644 --- a/wallet-tool.py +++ b/wallet-tool.py @@ -8,12 +8,40 @@ import sqlite3 from optparse import OptionParser +from texttable import texttable + from joinmarket import load_program_config, get_network, Wallet, encryptData, \ get_p2pk_vbyte, jm_single, mn_decode, mn_encode, BitcoinCoreInterface, \ JsonRpcError, create_wallet_file import bitcoin as btc + +# a function to print a text table. +# Detects if running in a TTY and if so disables terminal wrapping +# because the wrapping is too ugly for words. +# In this mode, terminals truncate text if line is too long. +# A good way to view the full table if it is too wide for terminal +# is to pipe output through less -S, which enables horizontal scrolling. +# eg: python wallet-tool.py | less -S +def printtable(rows, headertype=None, footertype=None): + if sys.stdout.isatty(): + print( '\033[?7l' ) # disable terminal wrapping + print( texttable.table(rows, headertype, footertype) ) + print( '\033[?7h' ) # re-enable terminal wrapping + else: + print( texttable.table(rows, headertype, footertype) ) + +def printcsv(rows): + import StringIO + import csv + for row in rows: + si = StringIO.StringIO() + cw = csv.writer(si, quoting=csv.QUOTE_MINIMAL) + cw.writerow(row) + print(si.getvalue().strip('\r\n')) + + description = ( 'Does useful little tasks involving your bip32 wallet. The ' 'method is one of the following: (display) Shows addresses and ' @@ -109,6 +137,7 @@ def cus_print(s): print(s) total_balance = 0 + rows = [] for m in range(wallet.max_mix_depth): cus_print('mixing depth %d m/0/%d/' % (m, m)) balance_depth = 0 @@ -120,6 +149,7 @@ def cus_print(s): cus_print(' ' + ('external' if forchange == 0 else 'internal') + ' addresses m/0/%d/%d' % (m, forchange) + ' ' + xpub_key) + trows = [] for k in range(wallet.index[m][forchange] + options.gaplimit): addr = wallet.get_addr(m, forchange, k) balance = 0.0 @@ -139,11 +169,22 @@ def cus_print(s): privkey = '' if (method == 'displayall' or balance > 0 or (used == ' new' and forchange == 0)): - cus_print(' m/0/%d/%d/%03d %-35s%s %.8f btc %s' % - (m, forchange, k, addr, used, balance / 1e8, - privkey)) + trow = ['m/0/%d/%d/%03d' % (m, forchange, k), + '%-35s' % (addr), + used, + '%.8f' % (balance / 1e8)] + if options.showprivkey: + trow.append(privkey) + trows.append( trow ) + + header = ['Depth', 'Address', 'Used', 'Balance'] + if options.showprivkey: + header.append( 'Private Key' ) + trows.insert(0, header ) + printtable( trows, headertype='firstrow' ) if m in wallet.imported_privkeys: cus_print(' import addresses') + prows = [] for privkey in wallet.imported_privkeys[m]: addr = btc.privtoaddr(privkey, magicbyte=get_p2pk_vbyte()) balance = 0.0 @@ -161,11 +202,24 @@ def cus_print(s): 'wif_compressed', get_p2pk_vbyte()) else: wip_privkey = '' - cus_print(' ' * 13 + '%-35s%s %.8f btc %s' % ( - addr, used, balance / 1e8, wip_privkey)) + prow = ['%-35s' % (addr), used, '%.8f' % (balance / 1e8)] + if options.showprivkey: + prow.append(wip_privkey) + prows.append( prow ) + header = ['Address', 'Used', 'Balance'] + if options.showprivkey: + header.append( 'Private Key' ) + prows.insert(0, header) + printtable( prows, headertype='firstrow' ) total_balance += balance_depth - print('for mixdepth=%d balance=%.8fbtc' % (m, balance_depth / 1e8)) - print('total balance = %.8fbtc' % (total_balance / 1e8)) + row = [m, '%.8fbtc' % (balance_depth / 1e8)] + rows.append(row) + row = ['total:', '%.8fbtc' % (total_balance / 1e8)] + # add a header + rows.insert(0, ['Mix Depth', 'Balance']) + # and a footer + rows.append(row) + printtable(rows, headertype='firstrow', footertype='lastrow') elif method == 'generate' or method == 'recover': if method == 'generate': seed = btc.sha256(os.urandom(64))[:32] @@ -319,12 +373,14 @@ def skip_n1_btc(v): return sat_to_str(v) if v != -1 else '#' + ' '*10 field_names = ['tx#', 'timestamp', 'type', 'amount/btc', - 'balance-change/btc', 'balance/btc', 'coinjoin-n', 'total-fees', + 'bal-change/btc', 'balance/btc', 'coinjoin-n', 'total-fees', 'utxo-count', 'mixdepth-from', 'mixdepth-to'] if options.csv: field_names += ['txid'] - l = s().join(field_names) - print(l) + + rows = [] + rows.append( field_names ) + balance = 0 utxo_count = 0 deposits = [] @@ -438,13 +494,17 @@ def skip_n1_btc(v): skip_n1(mixdepth_dst)] if options.csv: printable_data += [tx['txid']] - l = s().join(printable_data) - print(l) + rows.append( printable_data ) if tx_type != 'cj internal': deposits.append(delta_balance) deposit_times.append(rpctx['blocktime']) + if options.csv: + printcsv(rows) + else: + printtable(rows, headertype='firstrow') + bestblockhash = jm_single().bc_interface.rpc('getbestblockhash', []) try: #works with pruning enabled, but only after v0.12 @@ -482,3 +542,4 @@ def f(r, deposits, deposit_times, now, final_balance): if utxo_count != len(wallet.unspent): print(('BUG ERROR: wallet utxo count (%d) does not match utxo count from ' + 'history (%s)') % (len(wallet.unspent), utxo_count)) + From f1df808300d6079a57f65dfb81a672373315b79c Mon Sep 17 00:00:00 2001 From: dan-da Date: Wed, 25 May 2016 20:55:11 -0700 Subject: [PATCH 34/40] remove unused line --- texttable.py | 1 - 1 file changed, 1 deletion(-) diff --git a/texttable.py b/texttable.py index 5d00c5b0..995b2b7e 100644 --- a/texttable.py +++ b/texttable.py @@ -110,7 +110,6 @@ def calc_row_col_widths( cls, col_widths, row ): @classmethod def obj_arr( cls, t ): return t - return dir( t ) if isinstance( t, object ) else t @classmethod def is_numeric(cls, var): From 2eb3bda6e5aebf712a41108c5507e47a1abda21c Mon Sep 17 00:00:00 2001 From: Adam Gibson Date: Thu, 2 Jun 2016 13:00:51 +0300 Subject: [PATCH 35/40] Merge pull request #551 from AdamISZ/fix_filtered_mixdepth fix crash on insufficient coins in any mixdepth --- yield-generator-basic.py | 2 ++ yield-generator-mixdepth.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/yield-generator-basic.py b/yield-generator-basic.py index 0e00ef69..eaf4b34b 100644 --- a/yield-generator-basic.py +++ b/yield-generator-basic.py @@ -104,6 +104,8 @@ def oid_to_order(self, cjorder, oid, amount): filtered_mix_balance = [m for m in mix_balance.iteritems() if m[1] >= total_amount] + if not filtered_mix_balance: + return None, None, None log.debug('mix depths that have enough = ' + str(filtered_mix_balance)) filtered_mix_balance = sorted(filtered_mix_balance, key=lambda x: x[0]) mixdepth = filtered_mix_balance[0][0] diff --git a/yield-generator-mixdepth.py b/yield-generator-mixdepth.py index 3807f302..7ff6ad13 100644 --- a/yield-generator-mixdepth.py +++ b/yield-generator-mixdepth.py @@ -159,6 +159,8 @@ def oid_to_order(self, cjorder, oid, amount): filtered_mix_balance = [m for m in mix_balance.iteritems() if m[1] >= total_amount] + if not filtered_mix_balance: + return None, None, None log.debug('mix depths that have enough, filtered_mix_balance = ' + str( filtered_mix_balance)) From e2314d095a68def9ac40ac32bc6454ea2912e457 Mon Sep 17 00:00:00 2001 From: Alex Cato Date: Wed, 8 Jun 2016 18:34:20 +0200 Subject: [PATCH 36/40] yg-oscill: fix empty filtered_mix_balance crash --- yield-generator-oscillator.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/yield-generator-oscillator.py b/yield-generator-oscillator.py index ecb919b8..1fe339ba 100644 --- a/yield-generator-oscillator.py +++ b/yield-generator-oscillator.py @@ -494,9 +494,8 @@ def oid_to_order(self, cjorder, oid, amount): if m[1] >= total_amount + output_size_min] log.debug('mix depths that have enough with output_size_min, ' + str( filtered_mix_balance)) - try: - len(filtered_mix_balance) > 0 - except Exception: + + if not filtered_mix_balance: log.debug('No mix depths have enough funds to cover the ' + 'amount, cjfee, and output_size_min.') return None, None, None From 89e40ae01bee48931247b992ab8bb26da809b2a9 Mon Sep 17 00:00:00 2001 From: dan-da Date: Thu, 9 Jun 2016 00:42:54 -0700 Subject: [PATCH 37/40] fix: only summary table should be printed when 'summary' flag is present --- wallet-tool.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/wallet-tool.py b/wallet-tool.py index d765a197..53078f98 100644 --- a/wallet-tool.py +++ b/wallet-tool.py @@ -179,9 +179,10 @@ def cus_print(s): header = ['Depth', 'Address', 'Used', 'Balance'] if options.showprivkey: - header.append( 'Private Key' ) + header.append( 'Private Key' ) trows.insert(0, header ) - printtable( trows, headertype='firstrow' ) + if method != 'summary': + printtable( trows, headertype='firstrow' ) if m in wallet.imported_privkeys: cus_print(' import addresses') prows = [] @@ -210,7 +211,8 @@ def cus_print(s): if options.showprivkey: header.append( 'Private Key' ) prows.insert(0, header) - printtable( prows, headertype='firstrow' ) + if method != 'summary': + printtable( prows, headertype='firstrow' ) total_balance += balance_depth row = [m, '%.8fbtc' % (balance_depth / 1e8)] rows.append(row) From 47c7ef0229b846a7ab854852ac0d2aa84d46d1bd Mon Sep 17 00:00:00 2001 From: dan-da Date: Sun, 12 Jun 2016 12:44:31 -0700 Subject: [PATCH 38/40] fix: external addresses were not printing --- wallet-tool.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/wallet-tool.py b/wallet-tool.py index 53078f98..720296bf 100644 --- a/wallet-tool.py +++ b/wallet-tool.py @@ -177,12 +177,12 @@ def cus_print(s): trow.append(privkey) trows.append( trow ) - header = ['Depth', 'Address', 'Used', 'Balance'] - if options.showprivkey: - header.append( 'Private Key' ) - trows.insert(0, header ) - if method != 'summary': - printtable( trows, headertype='firstrow' ) + header = ['Depth', 'Address', 'Used', 'Balance'] + if options.showprivkey: + header.append( 'Private Key' ) + trows.insert(0, header ) + if method != 'summary': + printtable( trows, headertype='firstrow' ) if m in wallet.imported_privkeys: cus_print(' import addresses') prows = [] From 8a7e05eb9fb0fd439979a73f3d8c741b48ea9e78 Mon Sep 17 00:00:00 2001 From: dan-da Date: Wed, 15 Jun 2016 11:19:39 -0700 Subject: [PATCH 39/40] add --show-xpub option --- joinmarket/wallet.py | 8 ++++---- wallet-tool.py | 16 +++++++++++----- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/joinmarket/wallet.py b/joinmarket/wallet.py index 1e7b62cd..7a2fb0c6 100644 --- a/joinmarket/wallet.py +++ b/joinmarket/wallet.py @@ -144,13 +144,13 @@ def __init__(self, if extend_mixdepth and len(self.index_cache) > max_mix_depth: self.max_mix_depth = len(self.index_cache) self.gaplimit = gaplimit - master = btc.bip32_master_key(self.seed, (btc.MAINNET_PRIVATE if + self.master_key = btc.bip32_master_key(self.seed, (btc.MAINNET_PRIVATE if get_network() == 'mainnet' else btc.TESTNET_PRIVATE)) - m_0 = btc.bip32_ckd(master, 0) - mixing_depth_keys = [btc.bip32_ckd(m_0, c) + m_0 = btc.bip32_ckd(self.master_key, 0) + self.mixing_depth_keys = [btc.bip32_ckd(m_0, c) for c in range(self.max_mix_depth)] self.keys = [(btc.bip32_ckd(m, 0), btc.bip32_ckd(m, 1)) - for m in mixing_depth_keys] + for m in self.mixing_depth_keys] self.init_index() def init_index(self): diff --git a/wallet-tool.py b/wallet-tool.py index 720296bf..539a5d62 100644 --- a/wallet-tool.py +++ b/wallet-tool.py @@ -87,6 +87,11 @@ def printcsv(rows): dest='csv', default=False, help=('When using the history method, output as csv')) +parser.add_option('--show-xpub', + action='store_true', + dest='showxpub', + default=False, + help=('Display master xpub key for wallet and each mix level')) (options, args) = parser.parse_args() # if the index_cache stored in wallet.json is longer than the default @@ -136,18 +141,19 @@ def cus_print(s): if method != 'summary': print(s) + if options.showxpub: + cus_print('wallet xpub: %s\n' % (btc.bip32_privtopub(wallet.master_key))) + total_balance = 0 rows = [] for m in range(wallet.max_mix_depth): cus_print('mixing depth %d m/0/%d/' % (m, m)) + if options.showxpub: + cus_print(' xpub: %s\n' % (btc.bip32_privtopub(wallet.mixing_depth_keys[m]))) balance_depth = 0 for forchange in [0, 1]: - if forchange == 0: - xpub_key = btc.bip32_privtopub(wallet.keys[m][forchange]) - else: - xpub_key = '' cus_print(' ' + ('external' if forchange == 0 else 'internal') + - ' addresses m/0/%d/%d' % (m, forchange) + ' ' + xpub_key) + ' addresses m/0/%d/%d' % (m, forchange) ) trows = [] for k in range(wallet.index[m][forchange] + options.gaplimit): From eb12a0ef8a56e5cf270fff5b975c57fd8f606549 Mon Sep 17 00:00:00 2001 From: dan-da Date: Wed, 15 Jun 2016 20:46:11 -0700 Subject: [PATCH 40/40] show txid in table output --- wallet-tool.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/wallet-tool.py b/wallet-tool.py index 539a5d62..75225113 100644 --- a/wallet-tool.py +++ b/wallet-tool.py @@ -383,8 +383,7 @@ def skip_n1_btc(v): field_names = ['tx#', 'timestamp', 'type', 'amount/btc', 'bal-change/btc', 'balance/btc', 'coinjoin-n', 'total-fees', 'utxo-count', 'mixdepth-from', 'mixdepth-to'] - if options.csv: - field_names += ['txid'] + field_names += ['txid'] rows = [] rows.append( field_names ) @@ -500,8 +499,7 @@ def skip_n1_btc(v): sat_to_str_p(delta_balance), sat_to_str(balance), skip_n1(cj_n), skip_n1_btc(fees), utxo_count_str, skip_n1(mixdepth_src), skip_n1(mixdepth_dst)] - if options.csv: - printable_data += [tx['txid']] + printable_data += [tx['txid']] rows.append( printable_data ) if tx_type != 'cj internal':