#!/usr/bin/env python3
'''
Joinmarket GUI using PyQt for doing coinjoins.
Some widgets copied and modified from https://github.com/spesmilo/electrum
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see
' for m in mbinfo]), mbtype='question', title="Direct send") if reply == QMessageBox.Yes: self.direct_send_amount = amount return True else: return False async def infoDirectSend(self, msg): self.clearFields(None) await JMQtMessageBox(self, msg, title="Success") async def errorDirectSend(self, msg): await JMQtMessageBox(self, msg, mbtype="warn", title="Error") async def startSingle(self): if not self.spendstate.runstate == 'ready': log.info("Cannot start join, already running.") if not await self.validateSingleSend(): return destaddr = str(self.addressInput.text().strip()) try: amount = btc.amount_to_sat(self.amountInput.text()) except ValueError as e: await JMQtMessageBox(self, e.args[0], title="Error", mbtype="warn") return makercount = int(self.numCPInput.text()) mixdepth = int(self.mixdepthInput.text()) bip78url = self.pjEndpointInput.text() if makercount == 0 and not bip78url: custom_change = None if len(self.changeInput.text().strip()) > 0: custom_change = str(self.changeInput.text().strip()) try: tx = await direct_send( mainWindow.wallet_service, mixdepth, [(destaddr, amount)], accept_callback=self.checkDirectSend, info_callback=self.infoDirectSend, error_callback=self.errorDirectSend, return_transaction=True, custom_change_addr=custom_change) txid = bintohex(tx.GetTxid()[::-1]) except Exception as e: await JMQtMessageBox( self, e.args[0], title="Error", mbtype="warn") return if not txid: self.giveUp() else: # since direct_send() assumes a one-shot processing, it does # not add a callback for confirmation, so that event could # get lost; we do that here to ensure that the confirmation # event is noticed: def qt_directsend_callback(rtxd, rtxid, confs): if rtxid == txid: return True return False mainWindow.wallet_service.active_txs[txid] = tx mainWindow.wallet_service.register_callbacks([qt_directsend_callback], txid, cb_type="confirmed") self.persistTxToHistory(destaddr, self.direct_send_amount, txid) self.cleanUp() return if bip78url: manager = parse_payjoin_setup(self.bip21_uri, mainWindow.wallet_service, mixdepth, "joinmarket-qt") # start BIP78 AMP protocol if not yet up: if not self.bip78_daemon_started: daemon = jm_single().config.getint("DAEMON", "no_daemon") daemon = True if daemon == 1 else False start_reactor(jm_single().config.get("DAEMON", "daemon_host"), jm_single().config.getint("DAEMON", "daemon_port"), bip78=True, jm_coinjoin=False, ish=False, daemon=daemon, gui=True) self.bip78_daemon_started = True # disable form fields until payment is done self.addressInput.setEnabled(False) self.pjEndpointInput.setEnabled(False) self.mixdepthInput.setEnabled(False) self.amountInput.setEnabled(False) self.changeInput.setEnabled(False) self.startButton.setEnabled(False) await send_payjoin(manager, accept_callback=self.checkDirectSend, info_callback=self.infoDirectSend) self.clearFields(None) return # for coinjoin sends no point to send below dust threshold, likely # there will be no makers for such amount. if amount != 0 and makercount > 0 and not self.checkAmount(amount): return if makercount < jm_single().config.getint( "POLICY", "minimum_makers"): await JMQtMessageBox(self, "Number of counterparties (" + str( makercount) + ") below minimum_makers (" + str( jm_single().config.getint("POLICY", "minimum_makers")) + ") in configuration.", title="Error", mbtype="warn") return #note 'amount' is integer, so not interpreted as fraction #see notes in sample testnet schedule for format self.spendstate.loaded_schedule = [[mixdepth, amount, makercount, destaddr, 0, NO_ROUNDING, 0]] self.spendstate.updateType('single') self.spendstate.updateRun('running') await self.startJoin() async def getMaxCJFees(self, relfee, absfee): """ Used as a callback to decide relative and absolute maximum fees for coinjoins, in cases where the user has not set these values in the config (which is the default).""" if relfee is None: relfee = get_default_max_relative_fee() if absfee is None: absfee = get_default_max_absolute_fee() msg = ("Your maximum absolute fee in from one counterparty has been " "set to: " + str(absfee) + " satoshis.\n" "Your maximum relative fee from one counterparty has been set " "to: " + str(relfee) + ".\n" "To change these, please edit the config file and change the " "settings:\n" "max_cj_fee_abs = your-value-in-satoshis\n" "max_cj_fee_rel = your-value-as-decimal\n" "in the [POLICY] section.\n" "Note: If you don't do this, this dialog will interrupt the tumbler.") await JMQtMessageBox(self, msg, mbtype="info", title="Setting fee limits.") return relfee, absfee async def startJoin(self): if not mainWindow.wallet_service: asyncio.ensure_future( JMQtMessageBox(self, "Cannot start without a loaded wallet.", mbtype="crit", title="Error")) return log.debug('starting coinjoin ..') #Decide whether to interrupt processing to sanity check the fees if self.tumbler_options: check_offers_callback = self.checkOffersTumbler elif jm_single().config.get("GUI", "checktx") == "true": check_offers_callback = self.checkOffers else: check_offers_callback = None destaddrs = self.tumbler_destaddrs if self.tumbler_options else [] custom_change = None if len(self.changeInput.text().strip()) > 0: custom_change = str(self.changeInput.text().strip()) maxcjfee = await get_max_cj_fee_values(jm_single().config, None, user_callback=self.getMaxCJFees) log.info("Using maximum coinjoin fee limits per maker of {:.4%}, {} " "".format(maxcjfee[0], btc.amount_to_str(maxcjfee[1]))) wallet = mainWindow.wallet_service.wallet self.taker = Taker(mainWindow.wallet_service, self.spendstate.loaded_schedule, maxcjfee, order_chooser=fidelity_bond_weighted_order_choose, callbacks=[check_offers_callback, self.takerInfo, self.takerFinished], tdestaddrs=destaddrs, custom_change_address=custom_change, ignored_makers=ignored_makers) if not self.clientfactory: #First run means we need to start: create clientfactory #and start reactor connections self.clientfactory = JMClientProtocolFactory(self.taker) daemon = jm_single().config.getint("DAEMON", "no_daemon") daemon = True if daemon == 1 else False start_reactor(jm_single().config.get("DAEMON", "daemon_host"), jm_single().config.getint("DAEMON", "daemon_port"), self.clientfactory, ish=False, daemon=daemon, gui=True) else: #This will re-use message channels in background (daemon), no restart self.clientfactory.getClient().client = self.taker self.clientfactory.getClient().clientStart() mainWindow.statusBar().showMessage("Connecting to message channels ...") def takerInfo(self, infotype, infomsg): if infotype == "INFO": #use of a dialog interrupts processing?, investigate. if len(infomsg) > 200: log.info("INFO: " + infomsg) else: mainWindow.statusBar().showMessage(infomsg) elif infotype == "ABORT": asyncio.ensure_future( JMQtMessageBox(self, infomsg, mbtype='warn')) #Abort signal explicitly means this transaction will not continue. self.abortTransactions() else: raise NotImplementedError def checkOffersTumbler(self, offers_fees, cjamount): return tumbler_filter_orders_callback(offers_fees, cjamount, self.taker) async def checkOffers(self, offers_fee, cjamount): """Parse offers and total fee from client protocol, allow the user to agree or decide. """ if self.taker.aborted: log.debug("Not processing offers, user has aborted.") return False if not offers_fee: await JMQtMessageBox(self, "Not enough matching offers found.", mbtype='warn', title="Error") self.giveUp() return offers, total_cj_fee = offers_fee total_fee_pc = 1.0 * total_cj_fee / self.taker.cjamount mbinfo = [] mbinfo.append("Sending amount: " + btc.amount_to_str(self.taker.cjamount)) mbinfo.append("to address: " + self.taker.my_cj_addr) mbinfo.append(" ") mbinfo.append("Counterparties chosen:") mbinfo.append('Name, Order id, Coinjoin fee (sat.)') for k, o in offers.items(): if o['ordertype'] in ['trreloffer', 'sw0reloffer', 'swreloffer', 'reloffer']: display_fee = int(self.taker.cjamount * float(o['cjfee'])) - int(o['txfee']) elif o['ordertype'] in ['trabsoffer', 'sw0absoffer', 'swabsoffer', 'absoffer']: display_fee = int(o['cjfee']) - int(o['txfee']) else: log.debug("Unsupported order type: " + str(o['ordertype']) + ", aborting.") self.giveUp() return False mbinfo.append(k + ', ' + str(o['oid']) + ', ' + str( display_fee)) mbinfo.append('Total coinjoin fee = ' + btc.amount_to_str(total_cj_fee) + ', 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 = await JMQtMessageBox(self, '\n'.join([m + '
' for m in mbinfo]), mbtype='question', title=title) if reply == QMessageBox.Yes: #amount is now accepted; #The user is now committed to the transaction self.abortButton.setEnabled(False) return True else: self.filter_offers_response = "REJECT" self.giveUp() return False def startNextTransaction(self): self.clientfactory.getClient().clientStart() async def takerFinished(self, res, fromtx=False, waittime=0.0, txdetails=None): """Callback (after pass-through signal) for jmclient.Taker on completion of each join transaction. """ #non-GUI-specific state updates first: if self.tumbler_options: sfile = os.path.join(logsdir, 'TUMBLE.schedule') tumbler_taker_finished_update(self.taker, sfile, tumble_log, self.tumbler_options, res, fromtx, waittime, txdetails) self.spendstate.loaded_schedule = self.taker.schedule #Shows the schedule updates in the GUI; TODO make this more visual if self.spendstate.typestate == 'multiple': self.updateSchedView() #GUI-specific updates; QTimer.singleShot serves the role #of reactor.callLater if fromtx == "unconfirmed": mainWindow.statusBar().showMessage( "Transaction seen on network: " + self.taker.txid) if self.spendstate.typestate == 'single': self.clearFields(None) await JMQtMessageBox( self, "Transaction broadcast OK. You can safely \n" "shut down if you don't want to wait.", title="Success") #TODO: theoretically possible to miss this if confirmed event #seen before unconfirmed. self.persistTxToHistory(self.taker.my_cj_addr, self.taker.cjamount, self.taker.txid) #TODO prob best to completely fold multiple and tumble to reduce #complexity/duplication if self.spendstate.typestate == 'multiple' and not self.tumbler_options: self.taker.wallet_service.save_wallet() return if fromtx: if res: mainWindow.statusBar().showMessage("Transaction confirmed: " + self.taker.txid) #singleShot argument is in milliseconds if self.nextTxTimer: self.nextTxTimer.stop() self.nextTxTimer = QtCore.QTimer() self.nextTxTimer.setSingleShot(True) self.nextTxTimer.timeout.connect(self.startNextTransaction) self.nextTxTimer.start(int(waittime*60*1000)) #QtCore.QTimer.singleShot(int(self.taker_finished_waittime*60*1000), # self.startNextTransaction) else: if self.tumbler_options: mainWindow.statusBar().showMessage("Transaction failed, trying again...") QtCore.QTimer.singleShot(0, self.startNextTransaction) else: #currently does not continue for non-tumble schedules self.giveUp() else: if res: mainWindow.statusBar().showMessage("All transaction(s) completed successfully.") if len(self.taker.schedule) == 1: msg = "Transaction has been confirmed.\n" + "Txid: " + \ str(self.taker.txid) else: msg = "All transactions have been confirmed." await JMQtMessageBox(self, msg, title="Success") self.cleanUp() else: self.giveUp() def persistTxToHistory(self, addr, amt, txid): #persist the transaction to history hf = jm_single().config.get("GUI", "history_file") wallet_path = mainWindow.wallet_service.wallet._storage.get_location() hf = f'{wallet_path}-{hf}' with open(hf, 'ab') as f: f.write((','.join([addr, btc.amount_to_btc_str(amt), txid, datetime.datetime.now( ).strftime("%Y/%m/%d %H:%M:%S")])).encode('utf-8')) f.write(b'\n') #TODO: Windows #update the TxHistory tab txhist = mainWindow.centralWidget().widget(3) txhist.updateTxInfo() def toggleButtons(self): """Refreshes accessibility of buttons in the (single, multiple) join tabs based on the current state as defined by the SpendStateMgr instance. Thus, should always be called on any update to that instance. """ #The first two buttons are for the single join tab; the remaining 4 #are for the multijoin tab. btns = (self.startButton, self.abortButton, self.schedule_set_button, self.schedule_generate_button, self.sch_startButton, self.sch_abortButton) if self.spendstate.runstate == 'ready': btnsettings = (True, False, True, True, True, False) elif self.spendstate.runstate == 'running': if self.spendstate.typestate == 'single': #can only abort current run, nothing else btnsettings = (False, True, False, False, False, False) elif self.spendstate.typestate == 'multiple': btnsettings = (False, False, False, False, False, True) else: assert False else: assert False for b, s in zip(btns, btnsettings): b.setEnabled(s) def abortTransactions(self): if self.pjEndpointInput.isVisible(): self.clearFields(None) else: self.taker.aborted = True self.giveUp() def giveUp(self): """Inform the user that the transaction failed, then reset state. """ log.debug("Transaction aborted.") mainWindow.statusBar().showMessage("Transaction aborted.") if self.taker and len(self.taker.ignored_makers) > 0: asyncio.ensure_future( JMQtMessageBox(self, "These Makers did not respond, and will be \n" "ignored in future: \n" + str(','.join(self.taker.ignored_makers)), title="Transaction aborted")) ignored_makers.extend(self.taker.ignored_makers) self.cleanUp() def cleanUp(self): """Reset state to 'ready' """ #Qt specific: because schedules can restart in same app instance, #we must clean up any existing delayed actions via singleShot. #Currently this should only happen via self.abortTransactions. if self.nextTxTimer: self.nextTxTimer.stop() self.spendstate.reset() self.tumbler_options = None self.tumbler_destaddrs = None async def validateSingleSend(self): if not mainWindow.wallet_service: await JMQtMessageBox(self, "There is no wallet loaded.", mbtype='warn', title="Error") return False if jm_single().bc_interface is None: await JMQtMessageBox( self, "Sending coins not possible without blockchain source.", mbtype='warn', title="Error") return False if len(self.addressInput.text()) == 0: await JMQtMessageBox( self, "Recipient address or BIP21 bitcoin: " "payment URI must be provided.", mbtype='warn', title="Error") return False valid, errmsg = validate_address( str(self.addressInput.text().strip())) if not valid: await JMQtMessageBox(self, errmsg, mbtype='warn', title="Error") return False if len(self.numCPInput.text()) == 0: await JMQtMessageBox( self, "Number of counterparties must be provided. " "Enter '0' to do a direct send instead of a CoinJoin.", mbtype='warn', title="Error") return False if len(self.mixdepthInput.text()) == 0: await JMQtMessageBox( self, "Mixdepth must be chosen.", mbtype='warn', title="Error") return False if len(self.amountInput.text()) == 0: await JMQtMessageBox( self, "Amount must be provided.", mbtype='warn', title="Error") return False if len(self.changeInput.text().strip()) != 0: dest_addr = str(self.addressInput.text().strip()) change_addr = str(self.changeInput.text().strip()) makercount = int(self.numCPInput.text()) try: amount = btc.amount_to_sat(self.amountInput.text()) except ValueError as e: await JMQtMessageBox( self, e.args[0], title="Error", mbtype="warn") return False valid, errmsg = validate_address(change_addr) if not valid: await JMQtMessageBox(self, "Custom change address is invalid: \"%s\"" % errmsg, mbtype='warn', title="Error") return False if change_addr == dest_addr: msg = ''.join(["Custom change address cannot be the ", "same as the recipient address."]) await JMQtMessageBox(self, msg, mbtype='warn', title="Error") return False if amount == 0: await JMQtMessageBox(self, sweep_custom_change_warning, mbtype='warn', title="Error") return False if makercount > 0: reply = await JMQtMessageBox( self, general_custom_change_warning, mbtype='question', title="Warning") if reply == QMessageBox.No: return False if makercount > 0: engine_recognized = True try: change_addr_type = mainWindow.wallet_service.get_outtype( change_addr) except EngineError: engine_recognized = False wallet_type = mainWindow.wallet_service.get_txtype() if not engine_recognized or change_addr_type != wallet_type: reply = await JMQtMessageBox( self, nonwallet_custom_change_warning, mbtype='question', title="Warning") if reply == QMessageBox.No: return False return True class TxHistoryTab(QWidget): def __init__(self): super().__init__() self.initUI() mainWindow.tx_history_tab = self def initUI(self): self.tHTW = MyTreeWidget(self, self.create_menu, self.getHeaders()) self.tHTW.header().setSectionResizeMode(QHeaderView.Interactive) self.tHTW.header().setStretchLastSection(False) self.tHTW.on_update = self.updateTxInfo vbox = QVBoxLayout() self.setLayout(vbox) vbox.setContentsMargins(0,0,0,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 mainWindow.wallet_service: return [] wallet_path = mainWindow.wallet_service.wallet._storage.get_location() hf = f'{wallet_path}-{hf}' if not os.path.isfile(hf): if mainWindow: mainWindow.statusBar().showMessage( "No transaction history found.") return [] txhist = [] with open(hf, 'rb') as f: txlines = f.readlines() for tl in txlines: txhist.append(tl.decode('utf-8').strip().split(',')) if not len(txhist[-1]) == 4: asyncio.ensure_future( JMQtMessageBox(self, "Incorrectedly formatted file " + hf, mbtype='warn', title="Error")) mainWindow.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)) address_valid = validate_address(address)[0] menu = QMenu(self) 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 transaction info to clipboard", lambda: app.clipboard().setText( ','.join([str(item.text(_)) for _ in range(4)]))) pos = self.tHTW.viewport().mapToGlobal(position) menu.popup(pos) class CoinsTab(QWidget): def __init__(self): super().__init__() self.initUI() def initUI(self): self.cTW = MyTreeWidget(self, self.create_menu, self.getHeaders()) self.cTW.setSelectionMode(QAbstractItemView.ExtendedSelection) self.cTW.header().setSectionResizeMode(QHeaderView.Interactive) self.cTW.header().setStretchLastSection(False) self.cTW.on_update = lambda: asyncio.create_task(self.updateUtxos()) vbox = QVBoxLayout() self.setLayout(vbox) vbox.setContentsMargins(0,0,0,0) vbox.setSpacing(0) vbox.addWidget(self.cTW) async def async_initUI(self): await self.updateUtxos() self.show() def getHeaders(self): '''Function included in case dynamic in future''' return ['Txid:n', 'Amount in BTC', 'Address', 'Label'] async def updateUtxos(self): """ Note that this refresh of the display only accesses in-process utxo database (no sync e.g.) so can be immediate. """ self.cTW.clear() def show_blank(): m_item = QTreeWidgetItem(["No coins", "", "", ""]) self.cTW.addChild(m_item) self.cTW.show() if not mainWindow.wallet_service: show_blank() return utxos_enabled = {} utxos_disabled = {} for i in range(jm_single().config.getint("GUI", "max_mix_depth")): utxos_e, utxos_d = await get_utxos_enabled_disabled( mainWindow.wallet_service, i) if utxos_e != {}: utxos_enabled[i] = utxos_e if utxos_d != {}: utxos_disabled[i] = utxos_d if utxos_enabled == {} and utxos_disabled == {}: show_blank() return for i in range(jm_single().config.getint("GUI", "max_mix_depth")): uem = utxos_enabled.get(i) udm = utxos_disabled.get(i) m_item = QTreeWidgetItem(["Mixdepth " + str(i), '', '', '']) self.cTW.addChild(m_item) for heading in ["NOT FROZEN", "FROZEN"]: um = uem if heading == "NOT FROZEN" else udm seq_item = QTreeWidgetItem([heading, '', '', '']) m_item.addChild(seq_item) seq_item.setExpanded(True) if um is None: item = QTreeWidgetItem(['None', '', '', '']) seq_item.addChild(item) else: for k, v in um.items(): # txid:index, btc, address success, t = utxo_to_utxostr(k) # keys must be utxo format else a coding error: assert success s = "{0:.08f}".format(v['value']/1e8) a = await mainWindow.wallet_service.script_to_addr( v["script"]) item = QTreeWidgetItem([t, s, a, v["label"]]) item.setFont(0, QFont(MONOSPACE_FONT)) #if rows[i][forchange][j][3] != 'new': # item.setForeground(3, QBrush(QColor('red'))) seq_item.addChild(item) m_item.setExpanded(True) async def toggle_utxo_disable(self, txids, idxs): for i in range(0, len(txids)): txid = txids[i] txid_bytes = hextobin(txid) mainWindow.wallet_service.toggle_disable_utxo(txid_bytes, idxs[i]) await self.updateUtxos() def create_menu(self, position): # all selected items selected_items = self.cTW.selectedItems() txids = [] idxs = [] if len(selected_items) == 0: return try: for item in selected_items: txid, idx = item.text(0).split(":") assert len(txid) == 64 idx = int(idx) assert idx >= 0 txids.append(txid) idxs.append(idx) except Exception as e: log.debug("Error retrieving txids in Coins tab: " + repr(e)) return menu = QMenu(self) if len(selected_items) > 0: menu.addAction( "Freeze/un-freeze utxo(s) (toggle)", lambda: asyncio.create_task( self.toggle_utxo_disable(txids, idxs))) if len(selected_items) == 1: # current item item = self.cTW.currentItem() txid = item.text(0).split(":") menu.addAction("Copy transaction id to clipboard", lambda: app.clipboard().setText(txid)) pos = self.cTW.viewport().mapToGlobal(position) menu.popup(pos) class JMWalletTab(QWidget): def __init__(self): super().__init__() self.wallet_name = 'NONE' self.initUI() def initUI(self): self.label1 = QLabel( 'No wallet loaded. Use "Wallet > Load" to load existing wallet ' + 'or "Wallet > Generate" to create a new wallet.', self) self.label1.setAlignment(QtCore.Qt.AlignTop | QtCore.Qt.AlignLeft) v = MyTreeWidget(self, self.create_menu, self.getHeaders()) v.header().resizeSection(0, 400) # size of "Address" column v.header().resizeSection(1, 130) # size of "Index" column v.on_update = lambda: asyncio.create_task(self.updateWalletInfo()) v.hide() self.walletTree = v vbox = QVBoxLayout() self.setLayout(vbox) vbox.setContentsMargins(0,0,0,0) vbox.setSpacing(0) vbox.addWidget(self.label1) vbox.addWidget(v) buttons = QWidget() vbox.addWidget(buttons) async def async_initUI(self): await self.updateWalletInfo() self.show() def getHeaders(self): '''Function included in case dynamic in future''' return ['Address', 'Index', 'Balance', 'Status', 'Label'] def create_menu(self, position): item = self.walletTree.currentItem() address_valid = False xpub_exists = False if item: txt = str(item.text(0)) if validate_address(txt)[0]: address_valid = True parsed = txt.split() if len(parsed) > 1: if is_extended_public_key(parsed[-1]): xpub = parsed[-1] xpub_exists = True menu = QMenu(self) if address_valid: menu.addAction("Copy address to clipboard", lambda: app.clipboard().setText(txt), shortcut=QKeySequence(QKeySequence.Copy)) if item.text(4): menu.addAction("Copy label to clipboard", lambda: app.clipboard().setText(item.text(4))) # Show QR code option only for new addresses to avoid address reuse if item.text(3) == "new": menu.addAction("Show QR code", lambda: self.openAddressQRCodePopup(txt)) if xpub_exists: menu.addAction("Copy extended public key to clipboard", lambda: app.clipboard().setText(xpub), shortcut=QKeySequence(QKeySequence.Copy)) menu.addAction("Show QR code", lambda: self.openQRCodePopup(xpub, xpub)) menu.addAction( "Refresh wallet", lambda: asyncio.create_task( mainWindow.updateWalletInfo(None, "all")), shortcut=QKeySequence(QKeySequence.Refresh)) #TODO add more items to context menu pos = self.walletTree.viewport().mapToGlobal(position) menu.popup(pos) def openQRCodePopup(self, title, data): popup = QRCodePopup(self, title, data) popup.show() def openAddressQRCodePopup(self, address): bip21_uri = btc.encode_bip21_uri(address, {}) # From BIP173 (bech32) spec: # For presentation, lowercase is usually preferable, but inside # QR codes uppercase SHOULD be used, as those permit the use of # alphanumeric mode, which is 45% more compact than the normal # byte mode. # So we keep case for QR popup title, but convert BIP21 URI to be # encoded in QR code to uppercase, if possible. if detect_script_type(mainWindow.wallet_service.addr_to_script(address)) == TYPE_P2WPKH: bip21_uri = bip21_uri.upper() self.openQRCodePopup(address, bip21_uri) async def updateWalletInfo(self, walletinfo=None): max_mixdepth_count = jm_single().config.getint("GUI", "max_mix_depth") previous_expand_states = [] # before deleting, note whether items were expanded for i in range(self.walletTree.topLevelItemCount()): tli = self.walletTree.invisibleRootItem().child(i) # expandedness is a list beginning with the top level expand state, # followed by the expand state of its children expandedness = [tli.isExpanded()] for j in range(tli.childCount()): expandedness.append(tli.child(j).isExpanded()) previous_expand_states.append(expandedness) self.walletTree.clear() # Skip the remaining of this method if wallet info doesn't exist if not walletinfo: return rows, mbalances, xpubs, total_bal = walletinfo if jm_single().config.get("BLOCKCHAIN", "blockchain_source") == "regtest": self.wallet_name = mainWindow.testwalletname else: self.wallet_name = os.path.basename( mainWindow.wallet_service.get_storage_location()) if total_bal is None: if jm_single().bc_interface is not None: total_bal = " (syncing..)" else: total_bal = " (unknown, no blockchain source available)" self.label1.setText("CURRENT WALLET: " + self.wallet_name + ', total balance: ' + total_bal) self.walletTree.show() if jm_single().bc_interface is None and self.wallet_name != 'NONE': return for mixdepth in range(max_mixdepth_count): mdbalance = mbalances[mixdepth] account_xpub = xpubs[mixdepth][-1] m_item = QTreeWidgetItem(["Mixdepth " + str(mixdepth) + " , balance: " + mdbalance + ", " + account_xpub, '', '', '', '']) self.walletTree.addChild(m_item) # if expansion states existed, reinstate them: if len(previous_expand_states) == max_mixdepth_count: m_item.setExpanded(previous_expand_states[mixdepth][0]) # we expand at the mixdepth level by default else: m_item.setExpanded(True) for address_type in [ BaseWallet.ADDRESS_TYPE_EXTERNAL, BaseWallet.ADDRESS_TYPE_INTERNAL, FidelityBondMixin.BIP32_TIMELOCK_ID]: if address_type == FidelityBondMixin.BIP32_TIMELOCK_ID \ and (mixdepth != FidelityBondMixin.FIDELITY_BOND_MIXDEPTH or not isinstance(mainWindow.wallet_service.wallet, FidelityBondMixin)): continue if address_type == BaseWallet.ADDRESS_TYPE_EXTERNAL: heading = "EXTERNAL " + xpubs[mixdepth][address_type] elif address_type == BaseWallet.ADDRESS_TYPE_INTERNAL: heading = "INTERNAL" elif address_type == FidelityBondMixin.BIP32_TIMELOCK_ID: heading = "TIMELOCK" else: heading = "" seq_item = QTreeWidgetItem([heading, '', '', '', '']) m_item.addChild(seq_item) # by default, the external addresses of mixdepth 0 is expanded should_expand = mixdepth == 0 and address_type == BaseWallet.ADDRESS_TYPE_EXTERNAL for address_index in range(len(rows[mixdepth][address_type])): item = QTreeWidgetItem(rows[mixdepth][address_type][address_index]) item.setFont(0, QFont(MONOSPACE_FONT)) if rows[mixdepth][address_type][address_index][3] != 'new': item.setForeground(3, QBrush(QColor('red'))) # by default, if the balance is non zero, it is also expanded if float(rows[mixdepth][address_type][address_index][2]) > 0: should_expand = True seq_item.addChild(item) # Remember user choice, if expansion states existed, reinstate them: if len(previous_expand_states) == max_mixdepth_count: should_expand = previous_expand_states[mixdepth][address_type+1] seq_item.setExpanded(should_expand) class JMMainWindow(QMainWindow): computing_privkeys_signal = QtCore.Signal() show_privkeys_signal = QtCore.Signal() def __init__(self, reactor): super().__init__() # the wallet service that encapsulates # the wallet we will interact with self.wallet_service = None # keep track of whether wallet sync message # was already shown self.syncmsg = "" # loop used for initial sync: self.walletRefresh = None self.update_registered = False # BIP 78 Receiver manager object, only # created when user starts a payjoin event: self.backend_receiver = None # Keep track of whether the BIP78 daemon has # been started, to avoid unnecessary duplication: self.bip78daemon = False self.reactor = reactor self.initUI() # a flag to indicate that shutdown should not # depend on user input self.unconditional_shutdown = False self.close_event_confirmed = False def closeEvent(self, event): if self.close_event_confirmed: event.accept() if self.reactor.threadpool is not None: self.reactor.threadpool.stop() stop_reactor() return def finished_cb(result): if result == QMessageBox.Yes: self.close_event_confirmed = True self.close() elif result == QMessageBox.Ok and self.unconditional_shutdown: self.close_event_confirmed = True self.close() if self.unconditional_shutdown: quit_msg = "RPC connection is lost; shutting down." asyncio.ensure_future( JMQtMessageBox( self, quit_msg, mbtype='crit', title="Error", finished_cb=finished_cb)) else: quit_msg = "Are you sure you want to quit?" asyncio.ensure_future( JMQtMessageBox( self, quit_msg, mbtype='question', finished_cb=finished_cb)) event.ignore() def initUI(self): self.statusBar().showMessage("Ready") self.setGeometry(300, 300, 250, 150) openWalletAction = QAction('&Open...', self) openWalletAction.setStatusTip('Open JoinMarket wallet file') openWalletAction.setShortcut(QKeySequence.Open) openWalletAction.triggered.connect( lambda: asyncio.create_task(self.openWallet())) generateAction = QAction('&Generate...', self) generateAction.setStatusTip('Generate new wallet') generateAction.triggered.connect( lambda: asyncio.create_task(self.generateWallet())) recoverAction = QAction('&Recover...', self) recoverAction.setStatusTip('Recover wallet from seed phrase') recoverAction.triggered.connect( lambda: asyncio.create_task(self.recoverWallet())) showSeedAction = QAction('&Show seed', self) showSeedAction.setStatusTip('Show wallet seed phrase') showSeedAction.triggered.connect(self.showSeedDialog) exportPrivAction = QAction('&Export keys', self) exportPrivAction.setStatusTip('Export all private keys to a file') exportPrivAction.triggered.connect( lambda: asyncio.create_task(self.exportPrivkeysJson())) changePassAction = QAction('&Change passphrase...', self) changePassAction.setStatusTip('Change wallet encryption passphrase') changePassAction.triggered.connect( lambda: asyncio.create_task(self.changePassphrase())) receivePayjoinAction = QAction('Receive &payjoin...', self) receivePayjoinAction.setStatusTip('Receive BIP78 style payment') receivePayjoinAction.triggered.connect(self.receiver_bip78_init) quitAction = QAction(QIcon('exit.png'), '&Quit', self) quitAction.setShortcut(QKeySequence.Quit) quitAction.setStatusTip('Quit application') quitAction.triggered.connect(qApp.quit) aboutAction = QAction('About Joinmarket', self) aboutAction.triggered.connect(self.showAboutDialog) menubar = self.menuBar() walletMenu = menubar.addMenu('&Wallet') walletMenu.addAction(openWalletAction) walletMenu.addAction(generateAction) walletMenu.addAction(recoverAction) walletMenu.addAction(showSeedAction) walletMenu.addAction(exportPrivAction) walletMenu.addAction(changePassAction) walletMenu.addAction(receivePayjoinAction) walletMenu.addAction(quitAction) aboutMenu = menubar.addMenu('&About') aboutMenu.addAction(aboutAction) def receiver_bip78_init(self): """ Initializes BIP78 workflow with modal dialog. """ if not self.wallet_service: asyncio.ensure_future( JMQtMessageBox(self, "No wallet loaded.", mbtype='crit', title="Error")) return self.receiver_bip78_dialog = ReceiveBIP78Dialog( self, self.startReceiver, self.stopReceiver) async def startReceiver(self): """ Initializes BIP78 Receiving object and starts the setup of onion service to serve request. Returns False in case we are not able to start due to bad parameters, otherwise True (not affected by success of whole request generation process). """ assert self.receiver_bip78_dialog amount = btc.amount_to_sat( self.receiver_bip78_dialog.get_amount_text()) mixdepth = self.receiver_bip78_dialog.get_mixdepth() if mixdepth > self.wallet_service.mixdepth: asyncio.ensure_future( JMQtMessageBox(self, "Wallet does not have mixdepth " + str(mixdepth), mbtype='crit', title="Error")) return False if self.wallet_service.get_balance_by_mixdepth(minconfs=1)[mixdepth] == 0: asyncio.ensure_future( JMQtMessageBox(self, "Mixdepth " + str(mixdepth) + " has no confirmed coins.", mbtype='crit', title="Error")) return False self.backend_receiver = JMBIP78ReceiverManager(self.wallet_service, mixdepth, amount, 80, self.receiver_bip78_dialog.info_update, uri_created_callback=self.receiver_bip78_dialog.update_uri, shutdown_callback=self.receiver_bip78_dialog.process_complete, mode="gui") await self.backend_receiver.async_init(self.wallet_service, mixdepth, amount, mode="gui") if not self.bip78daemon: #First run means we need to start: create daemon; # the client and its connection are created in the .initiate() # call. daemon = jm_single().config.getint("DAEMON", "no_daemon") if daemon: # this call not needed if daemon is external. start_reactor(jm_single().config.get("DAEMON", "daemon_host"), jm_single().config.getint("DAEMON", "daemon_port"), jm_coinjoin=False, bip78=True, daemon=True, gui=True, rs=False) self.bip78daemon = True await self.backend_receiver.initiate() return True def stopReceiver(self): if self.backend_receiver is None: return asyncio.ensure_future( self.backend_receiver.shutdown()) def showAboutDialog(self): msgbox = QDialog(self) lyt = QVBoxLayout(msgbox) msgbox.setWindowTitle(appWindowTitle) about_text_label = QLabel() about_text_label.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)
)]))
about_text_label.setWordWrap(True)
for l in [about_text_label]:
l.setTextFormat(QtCore.Qt.RichText)
l.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction)
l.setOpenExternalLinks(True)
lyt.addWidget(about_text_label)
btnbox = QDialogButtonBox(msgbox)
btnbox.setStandardButtons(QDialogButtonBox.Ok)
btnbox.accepted.connect(msgbox.accept)
lyt.addWidget(btnbox)
msgbox.open()
async def exportPrivkeysJson(self):
if not self.wallet_service:
asyncio.ensure_future(
JMQtMessageBox(self,
"No wallet loaded.",
mbtype='crit',
title="Error"))
return
#TODO add password protection; too critical
d = JMExportPrivkeysDialog(self)
d.finished.connect(d.on_finished)
d.open()
# release control to event loop to show dialog
await asyncio.sleep(0.1)
private_keys = {}
addresses = []
done = False
async def gather_privkeys():
# prepare list of addresses with non-zero balance
# TODO: consider adding a 'export insanely huge amount'
# option for anyone with gaplimit troubles, although
# that is a complete mess for a user, mostly changing
# the gaplimit in the Settings tab should address it.
self.computing_privkeys_signal.emit()
# release control to event loop to show please wait text
await asyncio.sleep(0.01)
rows = await get_wallet_printout(self.wallet_service)
for forchange in rows[0]:
for mixdepth in forchange:
for addr_info in mixdepth:
if float(addr_info[2]) > 0:
addresses.append(addr_info[0])
for addr in addresses:
if done:
break
priv = self.wallet_service.get_key_from_addr(addr)
private_keys[addr] = BTCEngine.privkey_to_wif(priv)
self.computing_privkeys_signal.emit()
# release control to event loop to show progress
await asyncio.sleep(0.01)
self.show_privkeys_signal.emit()
def show_privkeys():
s = "\n".join(map(
lambda x: x[0] + "\t" + x[1], private_keys.items()))
d.e.setText(s)
d.b.setEnabled(True)
self.computing_privkeys_signal.connect(lambda: d.e.setText(
"Please wait... %d/%d" % (len(private_keys), len(addresses))))
self.show_privkeys_signal.connect(show_privkeys)
gather_privkeys_task = asyncio.ensure_future(gather_privkeys())
await gather_privkeys_task
result = await d.result()
if result == QDialog.Rejected:
done = True
return
privkeys_fn_base = 'joinmarket-private-keys'
i = 0
privkeys_fn = privkeys_fn_base
# Updated to use json format, simply because L1354 writer
# has some extremely weird behaviour cross Py2/Py3
while os.path.isfile(os.path.join(jm_single().datadir,
privkeys_fn + '.json')):
i += 1
privkeys_fn = privkeys_fn_base + str(i)
try:
with open(os.path.join(jm_single().datadir,
privkeys_fn + '.json'), "wb") as f:
for addr, pk in private_keys.items():
# sanity check
rawpriv, _ = BTCEngine.wif_to_privkey(pk)
if not addr == self.wallet_service._ENGINE.privkey_to_address(rawpriv):
asyncio.ensure_future(
JMQtMessageBox(
None,
"Failed to create privkey export -"
" critical error in key parsing.",
mbtype='crit'))
return
f.write(json.dumps(private_keys, indent=4).encode('utf-8'))
except (IOError, os.error) as reason:
export_error_label = "JoinmarketQt was unable to produce a private key-export."
asyncio.ensure_future(
JMQtMessageBox(None,
export_error_label + "\n" + str(reason),
mbtype='crit',
title="Unable to create json file"))
except Exception as er:
asyncio.ensure_future(
JMQtMessageBox(self, str(er), mbtype='crit', title="Error"))
return
asyncio.ensure_future(
JMQtMessageBox(self,
"Private keys exported to: " +
os.path.join(
jm_single().datadir, privkeys_fn) +
'.json',
title="Success"))
async def seedEntry(self) -> Tuple[Optional[str], Optional[str]]:
d = QDialog(self)
d.setModal(True)
d.setWindowTitle('Recover from mnemonic phrase')
layout = QGridLayout(d)
message_e = QTextEdit()
layout.addWidget(QLabel('Enter 12 words'), 0, 0)
layout.addWidget(message_e, 1, 0)
pp_hbox = QHBoxLayout()
pp_field = QLineEdit()
pp_field.setEnabled(False)
use_pp = QCheckBox('Input Mnemonic Extension', self)
use_pp.setCheckState(QtCore.Qt.CheckState(False))
def _use_pp_state_changed(state):
checked = Qt.CheckState(state) == QtCore.Qt.Checked
pp_field.setEnabled(checked)
if not checked:
pp_field.clear()
use_pp.stateChanged.connect(_use_pp_state_changed)
pp_hbox.addWidget(use_pp)
pp_hbox.addWidget(pp_field)
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, 4, 0)
layout.addLayout(pp_hbox, 3, 0)
d.result_fut = asyncio.get_event_loop().create_future()
@QtCore.Slot(QMessageBox.StandardButton)
def on_finished(button):
d.result_fut.set_result(button)
d.finished.connect(on_finished)
d.open()
await d.result_fut
result = d.result_fut.result()
if result != QDialog.Accepted:
return None, None
mn_extension = None
if use_pp.checkState() == QtCore.Qt.Checked:
mn_extension = pp_field.text()
return message_e.toPlainText(), mn_extension
def autofreeze_warning_cb(self, utxo):
""" Handles coins sent to reused addresses,
preventing forced address reuse, according to value of
POLICY setting `max_sats_freeze_reuse` (see
WalletService.check_for_reuse()).
"""
success, utxostr = utxo_to_utxostr(utxo)
assert success, "Autofreeze warning cb called with invalid utxo."
msg = "New utxo has been automatically " +\
"frozen to prevent forced address reuse:\n" + utxostr +\
"\n You can unfreeze this utxo via the Coins tab."
asyncio.ensure_future(
JMQtMessageBox(self, msg, mbtype='info',
title="New utxo frozen"))
def restartWithMsg(self, msg):
asyncio.ensure_future(
JMQtMessageBox(self, msg, mbtype='info',
title="Restart"))
self.close()
async def recoverWallet(self):
try:
success = await wallet_generate_recover_bip39(
"recover", os.path.join(jm_single().datadir, 'wallets'),
"wallet.jmdat",
display_seed_callback=None,
enter_seed_callback=self.seedEntry,
enter_wallet_password_callback=self.getPassword,
enter_wallet_file_name_callback=self.getWalletFileName,
enter_if_use_seed_extension=None,
enter_seed_extension_callback=None,
enter_do_support_fidelity_bonds=lambda: False)
if not success:
await JMQtMessageBox(self,
"Failed to recover wallet.",
mbtype='warn', title="Error")
return
except Exception as e:
await JMQtMessageBox(self, e.args[0], title="Error", mbtype="warn")
return
await JMQtMessageBox(self, 'Wallet saved to ' + self.walletname,
title="Wallet created")
await self.initWallet(seed=self.walletname)
async def openWallet(self):
wallet_loaded = False
error_text = ""
filename_text = JMOpenWalletDialog.DEFAULT_WALLET_FILE_TEXT
while not wallet_loaded:
openWalletDialog = JMOpenWalletDialog(self)
openWalletDialog.finished.connect(openWalletDialog.on_finished)
# Set default wallet file name and verify its lock status
openWalletDialog.walletFileEdit.setText(filename_text)
openWalletDialog.errorMessageLabel.setText(
openWalletDialog.verify_lock())
openWalletDialog.errorMessageLabel.setText(error_text)
openWalletDialog.open()
result = await openWalletDialog.result()
if result == QDialog.Accepted:
wallet_file_text = openWalletDialog.walletFileEdit.text()
wallet_path = wallet_file_text.strip()
if not os.path.isabs(wallet_path):
wallet_path = os.path.join(
jm_single().datadir, 'wallets', wallet_path)
try:
wallet_loaded = await mainWindow.loadWalletFromBlockchain(
wallet_path, openWalletDialog.passphraseEdit.text(),
rethrow=True)
except Exception as e:
error_text = str(e)
filename_text = openWalletDialog.walletFileEdit.text()
else:
break
async def selectWallet(self, testnet_seed=None):
if jm_single().config.get("BLOCKCHAIN", "blockchain_source") != "regtest":
# guaranteed to exist as load_program_config was called on startup:
wallets_path = os.path.join(jm_single().datadir, 'wallets')
d = JMFileDialog(self)
d.setOptions(QFileDialog.DontUseNativeDialog)
d.setWindowTitle('Choose Wallet File')
d.setDirectory(wallets_path)
d.finished.connect(d.on_finished)
d.open()
await d.result_fut
result = d.result_fut.result()
if result != QDialog.Accepted:
return
filenames = d.selectedFiles()
if not filenames or len(filenames) != 1:
return
filename = filenames[0]
#TODO validate the file looks vaguely like a wallet file
log.debug('Looking for wallet in: ' + str(filename))
decrypted = False
while not decrypted:
text, ok = await JMInputDialog(
self, 'Decrypt wallet', 'Enter your password:',
echo_mode=QLineEdit.Password)
if not ok:
return
pwd = str(text).strip()
try:
decrypted = await self.loadWalletFromBlockchain(
filename, pwd)
except Exception as e:
await JMQtMessageBox(self, str(e),
mbtype='warn', title="Error")
return
if decrypted == "error":
# special case, not a failure to decrypt the file but
# a failure of wallet loading, give up:
self.close()
else:
if not testnet_seed:
testnet_seed, ok = await JMInputDialog(
self, 'Load Testnet wallet', 'Enter a testnet seed:',
QLineEdit.Normal)
if testnet_seed:
testnet_seed = testnet_seed.strip()
if not ok or not testnet_seed:
await JMQtMessageBox(self, "No seed entered, aborting",
mbtype='warn', title="Error")
return
filename = testnet_seed
pwd = None
#ignore return value as there is no decryption failure possible
await self.loadWalletFromBlockchain(filename, pwd)
async def loadWalletFromBlockchain(self, filename=None,
pwd=None, rethrow=False):
if filename:
wallet_path = get_wallet_path(str(filename), None)
try:
wallet = await open_test_wallet_maybe(
wallet_path, str(filename), None, ask_for_password=False,
password=pwd.encode('utf-8') if pwd else None,
gap_limit=jm_single().config.getint("GUI", "gaplimit"))
except RetryableStorageError as e:
if rethrow:
raise e
else:
await JMQtMessageBox(self, str(e),
mbtype='warn', title="Error")
return False
# only used for GUI display on regtest:
self.testwalletname = wallet.seed = str(filename)
if 'listunspent_args' not in jm_single().config.options('POLICY'):
jm_single().config.set('POLICY', 'listunspent_args', '[0]')
assert wallet, "No wallet loaded"
# shut down any existing wallet service
# monitoring loops
if self.wallet_service is not None:
if self.wallet_service.isRunning():
self.wallet_service.stopService()
if self.walletRefresh is not None:
self.walletRefresh.stop()
self.wallet_service = WalletService(wallet)
# in case an RPC error occurs in the constructor:
if self.wallet_service.rpc_error:
await JMQtMessageBox(self,self.wallet_service.rpc_error,
mbtype='warn',title="Error")
return "error"
if jm_single().bc_interface is None:
await self.centralWidget().widget(0).updateWalletInfo(
await get_wallet_printout(self.wallet_service))
return True
# add information callbacks:
self.wallet_service.add_restart_callback(self.restartWithMsg)
self.wallet_service.autofreeze_warning_cb = self.autofreeze_warning_cb
self.wallet_service.startService()
self.syncmsg = ""
self.walletRefresh = task.LoopingCall(
self.updateWalletInfo, None, None)
self.walletRefresh.start(5.0)
self.statusBar().showMessage("Reading wallet from blockchain ...")
return True
async def updateWalletInfo(self, txd, txid):
""" TODO: see use of `jmclient.BaseWallet.process_new_tx` in
`jmclient.WalletService.transaction_monitor`;
we could similarly find the exact utxos to update in the view,
though it's not entirely trivial, using the provided arguments.
For now we can just recreate the entire view, as this event is rare.
"""
t = self.centralWidget().widget(0)
if not self.wallet_service: #failure to sync in constructor means object is not created
newsyncmsg = "Unable to sync wallet - see error in console."
elif not self.wallet_service.isRunning():
await JMQtMessageBox(
self,
"The Joinmarket wallet service has stopped; this is usually "
"caused by a Bitcoin Core RPC connection failure. "
"Is your node running?",
mbtype='crit', title="Error")
qApp.exit(EXIT_FAILURE)
return
elif not self.wallet_service.synced:
return
else:
# sync phase is finished; we now wait for callbacks:
if self.walletRefresh and self.walletRefresh.running:
self.walletRefresh.stop()
self.walletRefresh = None
# the callback allows us to update our view only on changes:
if not self.update_registered:
self.wallet_service.register_callbacks(
[self.updateWalletInfo], None, "all")
self.update_registered = True
try:
await t.updateWalletInfo(
await get_wallet_printout(self.wallet_service))
except Exception:
# this is very likely to happen in case Core RPC connection goes
# down (but, order of events means it is not deterministic).
log.debug("Failed to get wallet information, is there a problem with "
"the blockchain interface?")
return
newsyncmsg = "Wallet synced successfully."
self.tx_history_tab.updateTxInfo()
if newsyncmsg != self.syncmsg:
self.syncmsg = newsyncmsg
self.statusBar().showMessage(self.syncmsg)
async def generateWallet(self):
log.debug('generating wallet')
if jm_single().config.get("BLOCKCHAIN", "blockchain_source") == "regtest":
seed = await self.getTestnetSeed()
if seed:
await self.selectWallet(testnet_seed=seed)
else:
await self.initWallet()
async def checkPassphrase(self):
match = False
while not match:
text, ok = await JMInputDialog(
self, 'Passphrase check', 'Enter your passphrase:',
echo_mode=QLineEdit.Password)
if not ok:
return False
pwd = text.encode('utf-8')
match = self.wallet_service.check_wallet_passphrase(pwd)
if not match:
asyncio.ensure_future(
JMQtMessageBox(self, "Wrong passphrase.",
mbtype='warn', title="Error"))
return True
async def changePassphrase(self):
if not self.wallet_service:
await JMQtMessageBox(
self, "Cannot change passphrase without loaded wallet.",
mbtype="crit", title="Error")
return
change_res = False
check_res = await self.checkPassphrase()
if check_res:
change_res = await wallet_change_passphrase(
self.wallet_service, self.getPassword)
if not check_res or not change_res:
await JMQtMessageBox(self, "Failed to change passphrase.",
title="Error", mbtype="warn")
return
await JMQtMessageBox(self, "Passphrase changed successfully.",
title="Passphrase changed")
async def getTestnetSeed(self):
text, ok = await JMInputDialog(
self, 'Testnet seed', 'Enter a 32 char hex string as seed:')
if text:
text = text.strip()
if not ok or not text:
await JMQtMessageBox(self, "No seed entered, aborting",
mbtype='warn', title="Error")
return
return text
def showSeedDialog(self):
if not self.wallet_service:
asyncio.ensure_future(
JMQtMessageBox(self,
"No wallet loaded.",
mbtype='crit',
title="Error"))
return
try:
self.displayWords(*self.wallet_service.get_mnemonic_words())
except NotImplementedError:
asyncio.ensure_future(
JMQtMessageBox(self,
"Wallet does not support seed phrases",
mbtype='info',
title="Error"))
async def getPassword(self) -> str:
self.textpassword = textpassword = await JMPasswordDialog(parent=self)
return textpassword.encode('utf-8') if textpassword else textpassword
async def getWalletFileName(self) -> str:
walletname, ok = await JMInputDialog(
self, 'Choose wallet name', 'Enter wallet file name:',
QLineEdit.Normal, "wallet.jmdat")
if not ok:
asyncio.ensure_future(
JMQtMessageBox(self, "Create wallet aborted", mbtype='warn'))
# cannot use None for a 'fail' condition, as this is used
# for the case where the default wallet name is to be used in non-Qt.
return "cancelled"
self.walletname = str(walletname).strip()
return self.walletname
def displayWords(self, words: str, mnemonic_extension: str) -> None:
seed_recovery_warning = "
".join([
"WRITE DOWN THIS WALLET RECOVERY SEED.",
"If you fail to do this, your funds are",
"at risk. Do NOT ignore this step!!!"
])
text = "" + words + ""
if mnemonic_extension:
text += "
Seed extension: " + mnemonic_extension.decode('utf-8') + ""
asyncio.ensure_future(
JMQtMessageBox(
self, seed_recovery_warning, mbtype='info',
title='Show wallet seed phrase', informative_text=text))
async def promptUseMnemonicExtension(self) -> bool:
msg = ("Would you like to use a two-factor mnemonic recovery "
"phrase?\nIf you don\'t know what this is press No.")
reply = await JMQtMessageBox(
self, msg, title='Use mnemonic extension?',
mbtype='question')
return reply == QMessageBox.Yes
async def promptInputMnemonicExtension(self) -> Optional[str]:
mnemonic_extension, ok = await JMInputDialog(
self, 'Input Mnemonic Extension', 'Enter mnemonic Extension:',
QLineEdit.Normal, "")
if not ok:
return None
mnemonic_extension = mnemonic_extension and mnemonic_extension.strip()
return str(mnemonic_extension)
async def initWallet(self, seed=None):
'''Creates a new wallet if seed not provided.
Initializes by syncing.
'''
if not seed:
try:
# guaranteed to exist as load_program_config was called on startup:
wallets_path = os.path.join(jm_single().datadir, 'wallets')
success = await wallet_generate_recover_bip39(
"generate", wallets_path, "wallet.jmdat",
display_seed_callback=self.displayWords,
enter_seed_callback=None,
enter_wallet_password_callback=self.getPassword,
enter_wallet_file_name_callback=self.getWalletFileName,
enter_if_use_seed_extension=self.promptUseMnemonicExtension,
enter_seed_extension_callback=self.promptInputMnemonicExtension,
enter_do_support_fidelity_bonds=lambda: False)
if not success:
await JMQtMessageBox(self,
"Failed to create new wallet file.",
title="Error", mbtype="warn")
return
except Exception as e:
await JMQtMessageBox(self, e.args[0],
title="Error", mbtype="warn")
return
await JMQtMessageBox(self, 'Wallet saved to ' + self.walletname,
title="Wallet created")
await self.loadWalletFromBlockchain(
self.walletname, pwd=self.textpassword)
async def get_wallet_printout(wallet_service):
"""Given a WalletService object, retrieve the list of
addresses and corresponding balances to be displayed.
We retrieve a WalletView abstraction, and iterate over
sub-objects to arrange the per-mixdepth and per-address lists.
The format of the returned data is:
rows: is of format [[[addr,index,bal,status,label],[addr,...]]*5,
[[addr, index,..], [addr, index..]]*5]
mbalances: is a simple array of 5 mixdepth balances
xpubs: [[xpubext, xpubint], ...]
Bitcoin amounts returned are in btc, not satoshis
"""
walletview = await wallet_display(wallet_service, False, serialized=False)
rows = []
mbalances = []
xpubs = []
for j, acct in enumerate(walletview.children):
mbalances.append(acct.get_fmt_balance())
rows.append([])
xpubs.append([])
for i, branch in enumerate(acct.children):
xpubs[j].append(branch.xpub)
rows[j].append([])
for entry in branch.children:
rows[-1][i].append([entry.serialize_address(),
entry.serialize_wallet_position(),
entry.serialize_amounts(),
entry.serialize_status(),
entry.serialize_label()])
# Append the account xpub to the end of the list
FBONDS_PUBKEY_PREFIX = "fbonds-mpk-"
account_xpub = acct.xpub
if account_xpub.startswith(FBONDS_PUBKEY_PREFIX):
account_xpub = account_xpub[len(FBONDS_PUBKEY_PREFIX):]
xpubs[j].append(account_xpub)
# in case the wallet is not yet synced, don't return an incorrect
# 0 balance, but signal incompleteness:
total_bal = walletview.get_fmt_balance() if wallet_service.synced else None
return (rows, mbalances, xpubs, total_bal)
################################
async def refuse_if_non_segwit_wallet():
# refuse to load non-segwit wallet (needs extra work in wallet-utils).
if not jm_single().config.get("POLICY", "segwit") == "true":
wallet_load_error = ''.join([
"Joinmarket-Qt only supports segwit based wallets, ",
"please edit the config file and remove any setting ",
"of the field `segwit` in the `POLICY` section."])
await JMQtMessageBox(None, wallet_load_error, mbtype='crit',
title='Incompatible wallet type')
twisted_sys_exit(EXIT_FAILURE)
async def onTabChange(i, tabWidget):
""" Respond to change of tab.
"""
# TODO: hardcoded literal;
# note that this is needed for an auto-update
# of utxos on the Coins tab only atm.
if i == 2:
await tabWidget.widget(2).updateUtxos()
def qt_exception_handler(context):
exc = context['exception']
if isinstance(exc, SystemExit):
raise exc
if context["message"]:
print(f"{context['message']} from task {context['task']._name},"
"read the following traceback:")
print(context["traceback"])
async def main():
global logsdir, tumble_log
loop = asyncio.get_event_loop()
loop.set_exception_handler(qt_exception_handler)
parser = OptionParser(usage='usage: %prog [options]')
add_base_options(parser)
# wallet related base options are not applicable:
parser.remove_option("--recoversync")
parser.remove_option("--wallet-password-stdin")
(options, args) = parser.parse_args()
config_load_error = False
try:
load_program_config(config_path=options.datadir)
except Exception as e:
config_load_error = "Failed to setup joinmarket: "+repr(e)
if "RPC" in repr(e):
config_load_error += '\n'*3 + ''.join(
["Errors about failed RPC connections usually mean an incorrectly ",
"configured instance of Bitcoin Core (e.g. it hasn't been started ",
"or the rpc ports are not correct in your joinmarket.cfg or your ",
"bitcoin.conf file)."
])
await JMQtMessageBox(None, config_load_error,
mbtype='crit', title='failed to load')
twisted_sys_exit(EXIT_FAILURE)
# Only partial functionality (see wallet info, change config) is possible
# without a blockchain interface.
if jm_single().bc_interface is None:
blockchain_warning = ''.join([
"No blockchain source currently configured. ",
"You will be able to see wallet information and change configuration ",
"but other functionality will be limited. ",
"Go to the 'Settings' tab and configure blockchain settings there."])
await JMQtMessageBox(None, blockchain_warning,
mbtype='warn', title='No blockchain source')
await refuse_if_non_segwit_wallet()
update_config_for_gui()
check_and_start_tor()
# to allow testing of confirm/unconfirm callback for multiple txs
if isinstance(jm_single().bc_interface, RegtestBitcoinCoreInterface):
jm_single().bc_interface.tick_forward_chain_interval = 10
jm_single().bc_interface.simulating = True
jm_single().maker_timeout_sec = 15
# trigger start with a fake tx
jm_single().bc_interface.pushtx(b"\x00"*20)
logsdir = os.path.join(jm_single().datadir, "logs")
# tumble log will not always be used, but is made available anyway:
tumble_log = get_tumble_log(logsdir)
tabWidget = QTabWidget(mainWindow)
jm_wallet_tab = JMWalletTab()
await jm_wallet_tab.async_initUI()
tabWidget.addTab(jm_wallet_tab, "JM Wallet")
tabWidget.addTab(SpendTab(), "Coinjoins")
coins_tab = CoinsTab()
await coins_tab.async_initUI()
tabWidget.addTab(coins_tab, "Coins")
tabWidget.addTab(TxHistoryTab(), "Tx History")
settingsTab = SettingsTab()
tabWidget.addTab(settingsTab, "Settings")
mainWindow.resize(600, 500)
if get_network() == 'testnet':
suffix = ' - Testnet'
elif get_network() == 'testnet4':
suffix = ' - Testnet4'
elif get_network() == 'signet':
suffix = ' - Signet'
else:
suffix = ''
mainWindow.setWindowTitle(appWindowTitle + suffix)
tabWidget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
mainWindow.setCentralWidget(tabWidget)
tabWidget.currentChanged.connect(
lambda i: asyncio.create_task(onTabChange(i, tabWidget)))
mainWindow.show()
# Qt does not stop automatically when we stop the qt5reactor, and
# also we don't want to close without warning the user;
# patch our stop_reactor method to include the necessary cleanup:
def qt_shutdown():
# checking ensures we only fire the close
# event once even if stop_reactor is called
# multiple times (which it often is):
if mainWindow.isVisible():
mainWindow.unconditional_shutdown = True
mainWindow.close()
set_custom_stop_reactor(qt_shutdown)
# Upon launching the app, ask the user to choose a wallet to open
await mainWindow.openWallet()
logsdir = None
tumble_log = None
# ignored makers list persisted across entire app run
ignored_makers = []
appWindowTitle = 'JoinMarketQt'
from twisted.internet import reactor
mainWindow = JMMainWindow(reactor)
reactor.runReturn()
import PySide6.QtAsyncio as QtAsyncio
QtAsyncio.run(coro=main(), keep_running=True, quit_qapp=False,
handle_sigint=False, debug=True)