diff --git a/joinmarket-qt.py b/joinmarket-qt.py new file mode 100644 index 00000000..30b79095 --- /dev/null +++ b/joinmarket-qt.py @@ -0,0 +1,1426 @@ + +''' +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 + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +''' + + +import sys, base64, textwrap, re, datetime, os, math, json, logging +import Queue, platform + +from decimal import Decimal +from functools import partial +from collections import namedtuple + +from PyQt4 import QtCore +from PyQt4.QtGui import * + +if platform.system() == 'Windows': + MONOSPACE_FONT = 'Lucida Console' +elif platform.system() == 'Darwin': + MONOSPACE_FONT = 'Monaco' +else: + MONOSPACE_FONT = 'monospace' + +GREEN_BG = "QWidget {background-color:#80ff80;}" +RED_BG = "QWidget {background-color:#ffcccc;}" +RED_FG = "QWidget {color:red;}" +BLUE_FG = "QWidget {color:blue;}" +BLACK_FG = "QWidget {color:black;}" + +import bitcoin as btc + +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, \ + validate_address, random_nick, get_log, IRCMessageChannel, \ + weighted_order_choose, get_blockchain_interface_instance, joinmarket_alert, \ + core_alert + +from sendpayment import SendPayment, PT + +log = get_log() +donation_address = '1LT6rwv26bV7mgvRosoSCyGM7ttVRsYidP' +donation_address_testnet = 'mz6FQosuiNe8135XaQqWYmXsa3aD8YsqGL' + +warnings = {"blockr_privacy": """You are using blockr as your method of +connecting to the blockchain; this means +that blockr.com can see the addresses you +query. This is bad for privacy - consider +using a Bitcoin Core node instead."""} +#configuration types +config_types = {'rpc_port': int, + 'port': int, + 'usessl': bool, + 'socks5': bool, + 'network': bool, + 'socks5_port': int, + 'maker_timeout_sec': int, + 'tx_fees': int, + 'gaplimit': int, + 'check_high_fee': int, + 'max_mix_depth': int, + 'txfee_default': int, + 'order_wait_time': int, + 'privacy_warning': None} +config_tips = {'blockchain_source': + 'options: blockr, bitcoin-rpc', + 'network': + 'one of "testnet" or "mainnet"', + 'rpc_host': + 'the host for bitcoind; only used if blockchain_source is bitcoin-rpc', + 'rpc_port': + 'port for connecting to bitcoind over rpc', + 'rpc_user': + 'user for connecting to bitcoind over rpc', + 'rpc_password': + 'password for connecting to bitcoind over rpc', + 'host': + 'hostname for IRC server', + 'channel': + 'channel name on IRC server', + 'port': + 'port for connecting to IRC server', + 'usessl': + 'check to use SSL for connection to IRC', + 'socks5': + 'check to use SOCKS5 proxy for IRC connection', + 'socks5_host': + 'host for SOCKS5 proxy', + 'socks5_port': + 'port for SOCKS5 proxy', + 'maker_timeout_sec': + 'timeout for waiting for replies from makers', + 'merge_algorithm': + 'for dust sweeping, try merge_algorithm = gradual, \n'+ + 'for more rapid dust sweeping, try merge_algorithm = greedy \n'+ + 'for most rapid dust sweeping, try merge_algorithm = greediest \n' + + ' but dont forget to bump your miner fees!', + 'tx_fees': + 'the fee estimate is based on a projection of how many satoshis \n'+ + 'per kB are needed to get in one of the next N blocks, N set here \n'+ + 'as the value of "tx_fees". This estimate is high if you set N=1, \n'+ + 'so we choose N=3 for a more reasonable figure, \n'+ + 'as our default. Note that for clients not using a local blockchain \n'+ + 'instance, we retrieve an estimate from the API at blockcypher.com, currently. \n', + 'gaplimit': 'How far forward to search for used addresses in the HD wallet', + 'check_high_fee': 'Percent fee considered dangerously high, default 2%', + 'max_mix_depth': 'Total number of mixdepths in the wallet, default 5', + 'txfee_default': 'Number of satoshis per counterparty for an initial\n'+ + 'tx fee estimate; this value is not usually used and is best left at\n'+ + 'the default of 5000', + 'order_wait_time': 'How long to wait for orders to arrive on entering\n'+ + 'the message channel, default is 30s' + } + +def 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) + #Extra setting not exposed to the GUI, but only for the GUI app + if 'privacy_warning' not in [_[0] for _ in gui_items]: + print 'overwriting privacy_warning' + jm_single().config.set("GUI", 'privacy_warning', '1') + +def persist_config(): + '''This loses all comments in the config file. + TODO: possibly correct that.''' + with open('joinmarket.cfg','w') as f: + jm_single().config.write(f) + +class QtHandler(logging.Handler): + def __init__(self): + logging.Handler.__init__(self) + def emit(self, record): + record = self.format(record) + if record: XStream.stdout().write('%s\n'%record) + +handler = QtHandler() +handler.setFormatter(logging.Formatter("%(levelname)s:%(message)s")) +log.addHandler(handler) + +class XStream(QtCore.QObject): + _stdout = None + _stderr = None + messageWritten = QtCore.pyqtSignal(str) + def flush( self ): + pass + def fileno( self ): + return -1 + def write( self, msg ): + if ( not self.signalsBlocked() ): + self.messageWritten.emit(unicode(msg)) + @staticmethod + def stdout(): + if ( not XStream._stdout ): + XStream._stdout = XStream() + sys.stdout = XStream._stdout + return XStream._stdout + @staticmethod + def stderr(): + if ( not XStream._stderr ): + XStream._stderr = XStream() + sys.stderr = XStream._stderr + return XStream._stderr + +class Buttons(QHBoxLayout): + def __init__(self, *buttons): + QHBoxLayout.__init__(self) + self.addStretch(1) + for b in buttons: + self.addWidget(b) + +class CloseButton(QPushButton): + def __init__(self, dialog): + QPushButton.__init__(self, "Close") + self.clicked.connect(dialog.close) + self.setDefault(True) + +class CopyButton(QPushButton): + def __init__(self, text_getter, app): + QPushButton.__init__(self, "Copy") + self.clicked.connect(lambda: app.clipboard().setText(text_getter())) + +class CopyCloseButton(QPushButton): + def __init__(self, text_getter, app, dialog): + QPushButton.__init__(self, "Copy and Close") + self.clicked.connect(lambda: app.clipboard().setText(text_getter())) + self.clicked.connect(dialog.close) + self.setDefault(True) + +class OkButton(QPushButton): + def __init__(self, dialog, label=None): + QPushButton.__init__(self, label or "OK") + self.clicked.connect(dialog.accept) + self.setDefault(True) + +class CancelButton(QPushButton): + def __init__(self, dialog, label=None): + QPushButton.__init__(self, label or "Cancel") + self.clicked.connect(dialog.reject) + +class HelpLabel(QLabel): + + def __init__(self, text, help_text, wtitle): + QLabel.__init__(self, text) + self.help_text = help_text + self.wtitle = wtitle + self.font = QFont() + self.setStyleSheet(BLUE_FG) + + def mouseReleaseEvent(self, x): + QMessageBox.information(w, self.wtitle, self.help_text, 'OK') + + def enterEvent(self, event): + self.font.setUnderline(True) + self.setFont(self.font) + app.setOverrideCursor(QCursor(QtCore.Qt.PointingHandCursor)) + return QLabel.enterEvent(self, event) + + def leaveEvent(self, event): + self.font.setUnderline(False) + self.setFont(self.font) + app.setOverrideCursor(QCursor(QtCore.Qt.ArrowCursor)) + return QLabel.leaveEvent(self, event) + + +def check_password_strength(password): + ''' + Check the strength of the password entered by the user and return back the same + :param password: password entered by user in New Password + :return: password strength Weak or Medium or Strong + ''' + password = unicode(password) + n = math.log(len(set(password))) + num = re.search("[0-9]", password) is not None and re.match("^[0-9]*$", password) is None + caps = password != password.upper() and password != password.lower() + extra = re.match("^[a-zA-Z0-9]*$", password) is None + score = len(password)*( n + caps + num + extra)/20 + password_strength = {0:"Weak",1:"Medium",2:"Strong",3:"Very Strong"} + return password_strength[min(3, int(score))] + +def update_password_strength(pw_strength_label,password): + ''' + call the function check_password_strength and update the label pw_strength + interactively as the user is typing the password + :param pw_strength_label: the label pw_strength + :param password: password entered in New Password text box + :return: None + ''' + if password: + colors = {"Weak":"Red","Medium":"Blue","Strong":"Green", + "Very Strong":"Green"} + strength = check_password_strength(password) + label = "Password Strength"+ ": "+"" + strength + "" + else: + label = "" + pw_strength_label.setText(label) + +def make_password_dialog(self, msg, new_pass=True): + + self.new_pw = QLineEdit() + self.new_pw.setEchoMode(2) + self.conf_pw = QLineEdit() + self.conf_pw.setEchoMode(2) + + vbox = QVBoxLayout() + label = QLabel(msg) + label.setWordWrap(True) + + grid = QGridLayout() + grid.setSpacing(8) + grid.setColumnMinimumWidth(0, 70) + grid.setColumnStretch(1,1) + #TODO perhaps add an icon here + logo = QLabel() + lockfile = ":icons/lock.png" + logo.setPixmap(QPixmap(lockfile).scaledToWidth(36)) + logo.setAlignment(QtCore.Qt.AlignCenter) + + grid.addWidget(logo, 0, 0) + grid.addWidget(label, 0, 1, 1, 2) + vbox.addLayout(grid) + + grid = QGridLayout() + grid.setSpacing(8) + grid.setColumnMinimumWidth(0, 250) + grid.setColumnStretch(1,1) + + grid.addWidget(QLabel('New Password' if new_pass else 'Password'), 1, 0) + grid.addWidget(self.new_pw, 1, 1) + + grid.addWidget(QLabel('Confirm Password'), 2, 0) + grid.addWidget(self.conf_pw, 2, 1) + vbox.addLayout(grid) + + #Password Strength Label + self.pw_strength = QLabel() + grid.addWidget(self.pw_strength, 3, 0, 1, 2) + self.new_pw.textChanged.connect(lambda: update_password_strength( + self.pw_strength, self.new_pw.text())) + + vbox.addStretch(1) + vbox.addLayout(Buttons(CancelButton(self), OkButton(self))) + return vbox + +class PasswordDialog(QDialog): + + def __init__(self): + super(PasswordDialog, self).__init__() + self.initUI() + + def initUI(self): + self.setWindowTitle('Create a new password') + msg = "Enter a new password" + self.setLayout(make_password_dialog(self,msg)) + self.show() + +class MyTreeWidget(QTreeWidget): + + def __init__(self, parent, create_menu, headers, stretch_column=None, + editable_columns=None): + QTreeWidget.__init__(self, parent) + self.parent = parent + self.stretch_column = stretch_column + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.customContextMenuRequested.connect(create_menu) + self.setUniformRowHeights(True) + # extend the syntax for consistency + self.addChild = self.addTopLevelItem + self.insertChild = self.insertTopLevelItem + self.editor = None + self.pending_update = False + if editable_columns is None: + editable_columns = [stretch_column] + self.editable_columns = editable_columns + self.itemActivated.connect(self.on_activated) + self.update_headers(headers) + + def update_headers(self, headers): + self.setColumnCount(len(headers)) + self.setHeaderLabels(headers) + self.header().setStretchLastSection(False) + for col in range(len(headers)): + sm = QHeaderView.Stretch if col == self.stretch_column else QHeaderView.ResizeToContents + self.header().setResizeMode(col, sm) + + def editItem(self, item, column): + if column in self.editable_columns: + self.editing_itemcol = (item, column, unicode(item.text(column))) + # Calling setFlags causes on_changed events for some reason + item.setFlags(item.flags() | Qt.ItemIsEditable) + QTreeWidget.editItem(self, item, column) + item.setFlags(item.flags() & ~Qt.ItemIsEditable) + + def keyPressEvent(self, event): + if event.key() == QtCore.Qt.Key_F2: + self.on_activated(self.currentItem(), self.currentColumn()) + else: + QTreeWidget.keyPressEvent(self, event) + + def permit_edit(self, item, column): + return (column in self.editable_columns + and self.on_permit_edit(item, column)) + + def on_permit_edit(self, item, column): + return True + + def on_activated(self, item, column): + if self.permit_edit(item, column): + self.editItem(item, column) + else: + pt = self.visualItemRect(item).bottomLeft() + pt.setX(50) + self.emit(QtCore.SIGNAL('customContextMenuRequested(const QPoint&)'), pt) + + def createEditor(self, parent, option, index): + self.editor = QStyledItemDelegate.createEditor(self.itemDelegate(), + parent, option, index) + self.editor.connect(self.editor, QtCore.SIGNAL("editingFinished()"), + self.editing_finished) + return self.editor + + def editing_finished(self): + # Long-time QT bug - pressing Enter to finish editing signals + # editingFinished twice. If the item changed the sequence is + # Enter key: editingFinished, on_change, editingFinished + # Mouse: on_change, editingFinished + # This mess is the cleanest way to ensure we make the + # on_edited callback with the updated item + if self.editor: + (item, column, prior_text) = self.editing_itemcol + if self.editor.text() == prior_text: + self.editor = None # Unchanged - ignore any 2nd call + elif item.text(column) == prior_text: + pass # Buggy first call on Enter key, item not yet updated + else: + # What we want - the updated item + self.on_edited(*self.editing_itemcol) + self.editor = None + + # Now do any pending updates + if self.editor is None and self.pending_update: + self.pending_update = False + self.on_update() + + def on_edited(self, item, column, prior): + '''Called only when the text actually changes''' + key = str(item.data(0, Qt.UserRole).toString()) + text = unicode(item.text(column)) + self.parent.wallet.set_label(key, text) + if text: + item.setForeground(column, QBrush(QColor('black'))) + else: + text = self.parent.wallet.get_default_label(key) + item.setText(column, text) + item.setForeground(column, QBrush(QColor('gray'))) + self.parent.history_list.update() + self.parent.update_completions() + + def update(self): + # Defer updates if editing + if self.editor: + self.pending_update = True + else: + self.on_update() + + def on_update(self): + pass + + def get_leaves(self, root): + child_count = root.childCount() + if child_count == 0: + yield root + for i in range(child_count): + item = root.child(i) + for x in self.get_leaves(item): + yield x + + def filter(self, p, columns): + p = unicode(p).lower() + for item in self.get_leaves(self.invisibleRootItem()): + item.setHidden(all([unicode(item.text(column)).lower().find(p) == -1 + for column in columns])) + +class SettingsTab(QDialog): + def __init__(self): + super(SettingsTab, self).__init__() + self.initUI() + + def initUI(self): + outerGrid = QGridLayout() + sA = QScrollArea() + sA.setWidgetResizable(True) + frame = QFrame() + grid = QGridLayout() + self.settingsFields = [] + j = 0 + for i,section in enumerate(jm_single().config.sections()): + pairs = jm_single().config.items(section) + #an awkward design element from the core code: maker_timeout_sec + #is set outside the config, if it doesn't exist in the config. + #Add it here and it will be in the newly updated config file. + if section=='MESSAGING' and 'maker_timeout_sec' not in [_[0] for _ in pairs]: + jm_single().config.set(section, 'maker_timeout_sec', '60') + pairs = jm_single().config.items(section) + newSettingsFields = self.getSettingsFields(section, + [_[0] for _ in pairs]) + self.settingsFields.extend(newSettingsFields) + sL = QLabel(section) + sL.setStyleSheet("QLabel {color: blue;}") + grid.addWidget(sL) + j += 1 + for k, ns in enumerate(newSettingsFields): + grid.addWidget(ns[0],j,0) + #try to find the tooltip for this label from config tips; + #it might not be there + if str(ns[0].text()) in config_tips: + ttS = config_tips[str(ns[0].text())] + ns[0].setToolTip(ttS) + grid.addWidget(ns[1],j,1) + sfindex = len(self.settingsFields)-len(newSettingsFields)+k + if isinstance(ns[1], QCheckBox): + ns[1].toggled.connect(lambda checked, s=section, + q=sfindex: self.handleEdit( + s, self.settingsFields[q], checked)) + else: + ns[1].editingFinished.connect( + lambda q=sfindex, s=section: self.handleEdit(s, + self.settingsFields[q])) + j+=1 + outerGrid.addWidget(sA) + sA.setWidget(frame) + frame.setLayout(grid) + frame.adjustSize() + self.setLayout(outerGrid) + self.show() + + def handleEdit(self, section, t, checked=None): + if isinstance(t[1], QCheckBox): + if str(t[0].text()) == 'Testnet': + oname = 'network' + oval = 'testnet' if checked else 'mainnet' + add = '' if not checked else ' - Testnet' + w.setWindowTitle(appWindowTitle + add) + else: + oname = str(t[0].text()) + oval = 'true' if checked else 'false' + log.debug('setting section: '+section+' and name: '+oname+' to: '+oval) + jm_single().config.set(section,oname,oval) + + else: #currently there is only QLineEdit + log.debug('setting section: '+section+' and name: '+ + str(t[0].text())+' to: '+str(t[1].text())) + jm_single().config.set(section, str(t[0].text()),str(t[1].text())) + if str(t[0].text())=='blockchain_source': + jm_single().bc_interface = get_blockchain_interface_instance( + jm_single().config) + + def getSettingsFields(self, section, names): + results = [] + for name in names: + val = jm_single().config.get(section, name) + if name in config_types: + t = config_types[name] + if t == bool: + qt = QCheckBox() + if val=='testnet' or val.lower()=='true': + qt.setChecked(True) + elif not t: + continue + else: + qt = QLineEdit(val) + if t == int: + qt.setValidator(QIntValidator(0, 65535)) + else: + qt = QLineEdit(val) + label = 'Testnet' if name=='network' else name + results.append((QLabel(label), qt)) + return results + +class SpendTab(QWidget): + def __init__(self): + super(SpendTab, self).__init__() + self.initUI() + + def initUI(self): + vbox = QVBoxLayout(self) + top = QFrame() + top.setFrameShape(QFrame.StyledPanel) + topLayout = QGridLayout() + top.setLayout(topLayout) + sA = QScrollArea() + sA.setWidgetResizable(True) + topLayout.addWidget(sA) + iFrame = QFrame() + sA.setWidget(iFrame) + innerTopLayout = QGridLayout() + innerTopLayout.setSpacing(4) + iFrame.setLayout(innerTopLayout) + + donateLayout = QHBoxLayout() + self.donateCheckBox = QCheckBox() + self.donateCheckBox.setChecked(False) + self.donateCheckBox.setMaximumWidth(30) + self.donateLimitBox = QDoubleSpinBox() + self.donateLimitBox.setMinimum(0.001) + self.donateLimitBox.setMaximum(0.100) + self.donateLimitBox.setSingleStep(0.001) + self.donateLimitBox.setDecimals(3) + self.donateLimitBox.setValue(0.010) + self.donateLimitBox.setMaximumWidth(100) + self.donateLimitBox.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + + donateLayout.addWidget(self.donateCheckBox) + label1 = QLabel("Check to send change lower than: ") + label1.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + donateLayout.addWidget(label1) + donateLayout.setAlignment(label1, QtCore.Qt.AlignLeft) + donateLayout.addWidget(self.donateLimitBox) + donateLayout.setAlignment(self.donateLimitBox, QtCore.Qt.AlignLeft) + label2 = QLabel(" BTC as a donation.") + donateLayout.addWidget(label2) + label2.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + donateLayout.setAlignment(label2, QtCore.Qt.AlignLeft) + label3 = HelpLabel('More','\n'.join( + ['If the calculated change for your transaction', + 'is smaller than the value you choose (default 0.01 btc)', + 'then that change is sent as a donation. If your change', + 'is larger than that, there will be no donation.', + '', + 'As well as helping the developers, this feature can,', + 'in certain circumstances, improve privacy, because there', + 'is no change output that can be linked with your inputs later.']), + 'About the donation feature') + label3.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + donateLayout.setAlignment(label3, QtCore.Qt.AlignLeft) + donateLayout.addWidget(label3) + donateLayout.addStretch(1) + innerTopLayout.addLayout(donateLayout, 0, 0, 1, 2) + + self.widgets = self.getSettingsWidgets() + for i, x in enumerate(self.widgets): + innerTopLayout.addWidget(x[0], i+1, 0) + innerTopLayout.addWidget(x[1], i+1, 1, 1, 2) + self.widgets[0][1].editingFinished.connect(lambda : self.checkAddress( + self.widgets[0][1].text())) + self.startButton =QPushButton('Start') + self.startButton.setToolTip('You will be prompted to decide whether to accept\n'+ + 'the transaction after connecting, and shown the\n'+ + 'fees to pay; you can cancel at that point if you wish.') + self.startButton.clicked.connect(self.startSendPayment) + #TODO: how to make the Abort button work, at least some of the time.. + self.abortButton = QPushButton('Abort') + self.abortButton.setEnabled(False) + buttons = QHBoxLayout() + buttons.addStretch(1) + buttons.addWidget(self.startButton) + buttons.addWidget(self.abortButton) + innerTopLayout.addLayout(buttons, len(self.widgets)+1, 0, 1, 2) + splitter1 = QSplitter(QtCore.Qt.Vertical) + self.textedit = QTextEdit() + self.textedit.verticalScrollBar().rangeChanged.connect(self.resizeScroll) + XStream.stdout().messageWritten.connect(self.updateConsoleText) + XStream.stderr().messageWritten.connect(self.updateConsoleText) + splitter1.addWidget(top) + splitter1.addWidget(self.textedit) + splitter1.setSizes([400, 200]) + self.setLayout(vbox) + vbox.addWidget(splitter1) + self.show() + + def updateConsoleText(self, txt): + #these alerts are a bit suboptimal; + #colored is better, and in the ultra-rare + #case of getting both, one will be swallowed. + #However, the transaction confirmation dialog + #will at least show both in RED and BOLD, and they will be more prominent. + if joinmarket_alert[0]: + w.statusBar().showMessage("JOINMARKET ALERT: " + joinmarket_alert[0]) + if core_alert[0]: + w.statusBar().showMessage("BITCOIN CORE ALERT: " + core_alert[0]) + self.textedit.insertPlainText(txt) + + def resizeScroll(self, mini, maxi): + self.textedit.verticalScrollBar().setValue(maxi) + + def startSendPayment(self, ignored_makers = None): + self.aborted = False + if not self.validateSettings(): + return + if jm_single().config.get("BLOCKCHAIN", "blockchain_source")=='blockr': + res = self.showBlockrWarning() + if res==True: + return + + #all settings are valid; start + 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') + + 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()) + #convert from bitcoins (enforced by QDoubleValidator) to satoshis + self.btc_amount_str = str(self.widgets[3][1].text()) + amount = int(Decimal(self.btc_amount_str)*Decimal('1e8')) + makercount = int(self.widgets[1][1].text()) + mixdepth = int(self.widgets[2][1].text()) + self.taker = SendPayment(self.irc, w.wallet, self.destaddr, amount, + 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) + 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.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? + #w.statusBar().showMessage("Found counterparties...") + + 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.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 = [] + 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(" ") + mbinfo.append("Counterparties chosen:") + mbinfo.append('Name, Order id, Coinjoin fee (sat.)') + for k,o in self.orders.iteritems(): + 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 * 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]), + QMessageBox.Yes,QMessageBox.No) + if reply == QMessageBox.Yes: + log.debug('You agreed, transaction proceeding') + w.statusBar().showMessage("Building transaction...") + thread3 = TaskThread(self) + 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.cjamount, self.utxos, + self.donateCheckBox.isChecked(), + self.donateLimitBox.value(), + da), + on_done=None) + else: + 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: + if not self.aborted: + 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: + self.giveUp() + return + + else: + w.statusBar().showMessage("Transaction completed successfully.") + QMessageBox.information(self,"Success", + "Transaction has been broadcast.\n"+ + "Txid: "+str(self.taker.txid)) + #persist the transaction to history + with open(jm_single().config.get("GUI", "history_file"),'ab') as f: + f.write(','.join([self.destaddr, self.btc_amount_str, + self.taker.txid, + datetime.datetime.now().strftime("%Y/%m/%d %H:%M:%S")])) + f.write('\n') #TODO: Windows + #update the TxHistory tab + txhist = w.centralWidget().widget(3) + txhist.updateTxInfo() + + self.startButton.setEnabled(True) + self.abortButton.setEnabled(False) + + def 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 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: + 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'+ + 'If you enter 0, a SWEEP transaction\nwill be performed,'+ + ' spending all the coins \nin the given mixdepth.'] + sT = [str, int, int, float] + #todo maxmixdepth + sMM = ['',(2,20),(0,jm_single().config.getint("GUI","max_mix_depth")-1), + (0.00000001,100.0,8)] + sD = ['', '3', '0', ''] + for x in zip(sN, sH, sT, sD, sMM): + ql = QLabel(x[0]) + ql.setToolTip(x[1]) + qle = QLineEdit(x[3]) + if x[2]==int: + qle.setValidator(QIntValidator(*x[4])) + if x[2]==float: + qle.setValidator(QDoubleValidator(*x[4])) + results.append((ql, qle)) + return results + + +class TxHistoryTab(QWidget): + def __init__(self): + super(TxHistoryTab, self).__init__() + self.initUI() + + def initUI(self): + self.tHTW = MyTreeWidget(self, + self.create_menu, self.getHeaders()) + self.tHTW.setSelectionMode(QAbstractItemView.ExtendedSelection) + self.tHTW.header().setResizeMode(QHeaderView.Interactive) + self.tHTW.header().setStretchLastSection(False) + self.tHTW.on_update = self.updateTxInfo + vbox = QVBoxLayout() + self.setLayout(vbox) + vbox.setMargin(0) + vbox.setSpacing(0) + vbox.addWidget(self.tHTW) + self.updateTxInfo() + self.show() + + def getHeaders(self): + '''Function included in case dynamic in future''' + return ['Receiving address','Amount in BTC','Transaction id','Date'] + + def updateTxInfo(self, txinfo=None): + self.tHTW.clear() + if not txinfo: + txinfo = self.getTxInfoFromFile() + for t in txinfo: + t_item = QTreeWidgetItem(t) + self.tHTW.addChild(t_item) + for i in range(4): + self.tHTW.resizeColumnToContents(i) + + def getTxInfoFromFile(self): + hf = jm_single().config.get("GUI", "history_file") + if not os.path.isfile(hf): + if w: + w.statusBar().showMessage("No transaction history found.") + return [] + txhist = [] + with open(hf,'rb') as f: + txlines = f.readlines() + for tl in txlines: + txhist.append(tl.strip().split(',')) + if not len(txhist[-1])==4: + QMessageBox.warning(self,"Error", + "Incorrectedly formatted file "+hf) + w.statusBar().showMessage("No transaction history found.") + return [] + return txhist[::-1] #appended to file in date order, window shows reverse + + def create_menu(self, position): + item = self.tHTW.currentItem() + if not item: + return + address_valid = False + if item: + address = str(item.text(0)) + try: + btc.b58check_to_hex(address) + address_valid = True + except AssertionError: + log.debug('no btc address found, not creating menu item') + + menu = QMenu() + if address_valid: + menu.addAction("Copy address to clipboard", + lambda: app.clipboard().setText(address)) + menu.addAction("Copy transaction id to clipboard", + lambda: app.clipboard().setText(str(item.text(2)))) + menu.addAction("Copy full tx info to clipboard", + lambda: app.clipboard().setText( + ','.join([str(item.text(_)) for _ in range(4)]))) + menu.exec_(self.tHTW.viewport().mapToGlobal(position)) + +class JMWalletTab(QWidget): + def __init__(self): + super(JMWalletTab, self).__init__() + self.wallet_name = 'NONE' + self.initUI() + + def initUI(self): + self.label1 = QLabel( + "CURRENT WALLET: "+self.wallet_name + ', total balance: 0.0', + self) + v = MyTreeWidget(self, self.create_menu, self.getHeaders()) + v.setSelectionMode(QAbstractItemView.ExtendedSelection) + v.on_update = self.updateWalletInfo + self.history = v + vbox = QVBoxLayout() + self.setLayout(vbox) + vbox.setMargin(0) + vbox.setSpacing(0) + vbox.addWidget(self.label1) + vbox.addWidget(v) + buttons = QWidget() + vbox.addWidget(buttons) + self.updateWalletInfo() + #vBoxLayout.addWidget(self.label2) + #vBoxLayout.addWidget(self.table) + self.show() + + def getHeaders(self): + '''Function included in case dynamic in future''' + return ['Address','Index','Balance','Used/New'] + + def create_menu(self, position): + item = self.history.currentItem() + address_valid = False + if item: + address = str(item.text(0)) + try: + btc.b58check_to_hex(address) + address_valid = True + except AssertionError: + log.debug('no btc address found, not creating menu item') + + menu = QMenu() + if address_valid: + menu.addAction("Copy address to clipboard", + lambda: app.clipboard().setText(address)) + menu.addAction("Resync wallet from blockchain", lambda: w.resyncWallet()) + #TODO add more items to context menu + menu.exec_(self.history.viewport().mapToGlobal(position)) + + def updateWalletInfo(self, walletinfo=None): + l = self.history + l.clear() + if walletinfo: + self.mainwindow = self.parent().parent().parent() + rows, mbalances, total_bal = walletinfo + if get_network() == 'testnet': + self.wallet_name = self.mainwindow.wallet.seed + else: + self.wallet_name = os.path.basename(self.mainwindow.wallet.path) + self.label1.setText( + "CURRENT WALLET: "+self.wallet_name + ', total balance: '+total_bal) + + for i in range(jm_single().config.getint("GUI","max_mix_depth")): + if walletinfo: + mdbalance = mbalances[i] + else: + mdbalance = "{0:.8f}".format(0) + m_item = QTreeWidgetItem(["Mixdepth " +str(i) + " , balance: "+mdbalance, + '','','','']) + l.addChild(m_item) + for forchange in [0,1]: + heading = 'EXTERNAL' if forchange==0 else 'INTERNAL' + heading_end = ' addresses m/0/%d/%d/' % (i, forchange) + heading += heading_end + seq_item = QTreeWidgetItem([ heading, '', '', '', '']) + m_item.addChild(seq_item) + if not forchange: + seq_item.setExpanded(True) + if not walletinfo: + item = QTreeWidgetItem(['None', '', '', '']) + seq_item.addChild(item) + else: + for j in range(len(rows[i][forchange])): + item = QTreeWidgetItem(rows[i][forchange][j]) + item.setFont(0,QFont(MONOSPACE_FONT)) + if rows[i][forchange][j][3] == 'used': + item.setForeground(3, QBrush(QColor('red'))) + seq_item.addChild(item) + + +class 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 closeEvent(self, event): + quit_msg = "Are you sure you want to quit?" + reply = QMessageBox.question(self, appWindowTitle, 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) + 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): + msgbox = QDialog(self) + lyt = QVBoxLayout(msgbox) + msgbox.setWindowTitle(appWindowTitle) + label1 = QLabel() + label1.setText(""+ + "Read more about Joinmarket

"+ + "

".join(["Joinmarket core software version: "+JM_CORE_VERSION, + "JoinmarketQt version: "+JM_GUI_VERSION, + "Messaging protocol version:"+" %s" % ( + str(jm_single().JM_VERSION)), + "Help us support Bitcoin fungibility -", + "donate here: "])) + label2 = QLabel(donation_address) + for l in [label1, label2]: + l.setTextFormat(QtCore.Qt.RichText) + l.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction) + l.setOpenExternalLinks(True) + label2.setText(""+donation_address+"") + lyt.addWidget(label1) + lyt.addWidget(label2) + btnbox = QDialogButtonBox(msgbox) + btnbox.setStandardButtons(QDialogButtonBox.Ok) + btnbox.accepted.connect(msgbox.accept) + lyt.addWidget(btnbox) + msgbox.exec_() + + def 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') + layout = QGridLayout(d) + message_e = QTextEdit() + layout.addWidget(QLabel('Enter 12 words'), 0, 0) + layout.addWidget(message_e, 1, 0) + hbox = QHBoxLayout() + buttonBox = QDialogButtonBox(self) + buttonBox.setStandardButtons(QDialogButtonBox.Ok|QDialogButtonBox.Cancel) + buttonBox.button(QDialogButtonBox.Ok).clicked.connect(d.accept) + buttonBox.button(QDialogButtonBox.Cancel).clicked.connect(d.reject) + hbox.addWidget(buttonBox) + layout.addLayout(hbox, 3, 0) + result = d.exec_() + if result != QDialog.Accepted: + return + msg = str(message_e.toPlainText()) + words = msg.split() #splits on any number of ws chars + if not len(words)==12: + QMessageBox.warning(self, "Error","You did not provide 12 words, aborting.") + else: + 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': + current_path = os.path.dirname(os.path.realpath(__file__)) + if os.path.isdir(os.path.join(current_path,'wallets')): + current_path = os.path.join(current_path,'wallets') + firstarg = QFileDialog.getOpenFileName(self, 'Choose Wallet File', + directory=current_path) + #TODO validate the file looks vaguely like a wallet file + log.debug('Looking for wallet in: '+firstarg) + if not firstarg: + return + decrypted = False + while not decrypted: + text, ok = QInputDialog.getText(self, 'Decrypt wallet', + 'Enter your password:', mode=QLineEdit.Password) + if not ok: + return + pwd = str(text).strip() + decrypted = self.loadWalletFromBlockchain(firstarg, pwd) + else: + if not testnet_seed: + testnet_seed, ok = QInputDialog.getText(self, 'Load Testnet wallet', + 'Enter a testnet seed:', mode=QLineEdit.Normal) + if not ok: + return + firstarg = str(testnet_seed) + pwd = None + #ignore return value as there is no decryption failure possible + self.loadWalletFromBlockchain(firstarg, pwd) + + def loadWalletFromBlockchain(self, firstarg=None, pwd=None): + if (firstarg and pwd) or (firstarg and get_network()=='testnet'): + 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 + 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): + log.debug('generating wallet') + if get_network() == 'testnet': + seed = self.getTestnetSeed() + self.selectWallet(testnet_seed=seed) + else: + self.initWallet() + + def getTestnetSeed(self): + text, ok = QInputDialog.getText(self, 'Testnet seed', + 'Enter a string as seed (can be anything):') + if not ok or not text: + 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 + + 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 + #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): + 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] + jm_single().config.getint( + "GUI", "gaplimit")): + addr = wallet.get_addr(m, forchange, k) + balance = 0.0 + for addrvalue in wallet.unspent.values(): + if addr == addrvalue['address']: + balance += addrvalue['value'] + balance_depth += balance + used = ('used' if k < wallet.index[m][forchange] else 'new') + if balance > 0.0 or ( + k >= wallet.index[m][forchange] and forchange==0): + rows[m][forchange].append([addr, str(k), + "{0:.8f}".format(balance/1e8),used]) + mbalances.append(balance_depth) + total_balance += balance_depth + #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() +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 = 'JoinMarketQt' +w = JMMainWindow() +tabWidget = QTabWidget(w) +tabWidget.addTab(JMWalletTab(), "JM Wallet") +settingsTab = SettingsTab() +tabWidget.addTab(settingsTab, "Settings") +tabWidget.addTab(SpendTab(), "Send Payment") +tabWidget.addTab(TxHistoryTab(), "Tx History") +w.resize(600, 500) +suffix = ' - Testnet' if get_network() == 'testnet' else '' +w.setWindowTitle(appWindowTitle + suffix) +tabWidget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) +w.setCentralWidget(tabWidget) +w.show() + +sys.exit(app.exec_()) \ No newline at end of file diff --git a/joinmarket/__init__.py b/joinmarket/__init__.py index c5c04f12..c73dae97 100644 --- a/joinmarket/__init__.py +++ b/joinmarket/__init__.py @@ -16,9 +16,10 @@ from .slowaes import decryptData, encryptData from .taker import Taker, OrderbookWatch, CoinJoinTX 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 + get_network, jm_single, get_network, validate_address, \ + get_blockchain_interface_instance from .blockchaininterface import BlockrInterface, BlockchainInterface # Set default logging handler to avoid "No handler found" warnings. diff --git a/joinmarket/blockchaininterface.py b/joinmarket/blockchaininterface.py index 3de500a3..0f2af662 100644 --- a/joinmarket/blockchaininterface.py +++ b/joinmarket/blockchaininterface.py @@ -22,7 +22,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() @@ -112,13 +112,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): @@ -128,10 +128,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 @@ -140,14 +152,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/' res = btc.make_request(blockr_url + ','.join(addrs)) @@ -162,18 +174,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 = {} @@ -191,8 +203,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: @@ -250,8 +262,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 @@ -266,12 +278,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: @@ -285,7 +297,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 @@ -300,7 +312,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']) @@ -315,8 +327,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: @@ -329,13 +341,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, timeoutfun).start() + NotifyThread(self.blockr_domain(), txd, unconfirmfun, confirmfun, timeoutfun).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()) data = json.loads(json_str) if data['status'] != 'success': log.debug(data) @@ -358,9 +370,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 @@ -372,7 +384,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 @@ -381,14 +393,14 @@ 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 def bitcoincore_timeout_callback(uc_called, txout_set, txnotify_fun_list, timeoutfun): @@ -411,7 +423,7 @@ 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?') @@ -562,7 +574,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') @@ -616,8 +628,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) @@ -625,7 +637,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: @@ -733,12 +745,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 @@ -831,7 +843,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 diff --git a/joinmarket/configure.py b/joinmarket/configure.py index 0be85b51..333697db 100644 --- a/joinmarket/configure.py +++ b/joinmarket/configure.py @@ -118,8 +118,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 diff --git a/joinmarket/wallet.py b/joinmarket/wallet.py index 37225c72..7a2fb0c6 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 @@ -35,6 +36,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 @@ -116,7 +126,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 @@ -126,24 +137,28 @@ 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 - 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() - # 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) @@ -151,6 +166,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') @@ -166,9 +182,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: @@ -179,12 +198,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 065ac3d4..5e3941fe 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,156 @@ 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') + #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, None, None + + utxos = None + orders = None + cjamount = None + change_addr = None + choose_orders_recover = None + 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 + + 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): + #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 + 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 = None + + 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('tx negotation failed, ignored_makers=' + str( + self.ignored_makers)) + #triggers endpoint for GUI + self.taker.msgchan.shutdown() + + 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/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() diff --git a/texttable.py b/texttable.py new file mode 100644 index 00000000..995b2b7e --- /dev/null +++ b/texttable.py @@ -0,0 +1,121 @@ +# 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 + + @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 f7a0655c..75225113 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 + 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 ' @@ -59,6 +87,11 @@ 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 @@ -108,18 +141,21 @@ 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): addr = wallet.get_addr(m, forchange, k) balance = 0.0 @@ -139,11 +175,23 @@ 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 ) + if method != 'summary': + 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 +209,25 @@ 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) + if method != 'summary': + 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] @@ -185,13 +247,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' @@ -325,12 +381,13 @@ 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) + field_names += ['txid'] + + rows = [] + rows.append( field_names ) + balance = 0 utxo_count = 0 deposits = [] @@ -442,15 +499,18 @@ 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']] - l = s().join(printable_data) - print(l) + printable_data += [tx['txid']] + 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 @@ -488,3 +548,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)) +