from functools import partial from typing import TYPE_CHECKING, Sequence from PyQt6.QtCore import Qt from PyQt6.QtWidgets import QPushButton, QLabel, QVBoxLayout, QWidget, QGridLayout from electrum.i18n import _ from electrum.plugin import hook from electrum.wallet import Multisig_Wallet from electrum.keystore import Hardware_KeyStore from electrum.util import ChoiceItem from electrum.hw_wallet.qt import QtHandlerBase, QtPluginBase from electrum.hw_wallet.plugin import only_hook_if_libraries_available from electrum.gui.qt.wizard.wallet import WCScriptAndDerivation, WCHWXPub, WCHWUninitialized, WCHWUnlock from electrum.gui.qt.util import WindowModalDialog, CloseButton, getOpenFileName, getSaveFileName, RichLabel from electrum.gui.qt.main_window import ElectrumWindow from .coldcard import ColdcardPlugin, xfp2str if TYPE_CHECKING: from electrum.gui.qt.wizard.wallet import QENewWalletWizard CC_DEBUG = False class Plugin(ColdcardPlugin, QtPluginBase): icon_unpaired = "coldcard_unpaired.png" icon_paired = "coldcard.png" def create_handler(self, window): return Coldcard_Handler(window) def trim_file_suffix(self, path): return path.rsplit('.', 1)[0] @only_hook_if_libraries_available @hook def receive_menu(self, menu, addrs, wallet): if len(addrs) != 1: return self._add_menu_action(menu, addrs[0], wallet) @only_hook_if_libraries_available @hook def transaction_dialog_address_menu(self, menu, addr, wallet): self._add_menu_action(menu, addr, wallet) @only_hook_if_libraries_available @hook def wallet_info_buttons(self, main_window: 'ElectrumWindow', dialog): # user is about to see the "Wallet Information" dialog # - add a button if multisig wallet, and a Coldcard is a cosigner. assert isinstance(main_window, ElectrumWindow), f"{type(main_window)}" buttons = [] wallet = main_window.wallet if type(wallet) is not Multisig_Wallet: return coldcard_keystores = [ ks for ks in wallet.get_keystores() if type(ks) == self.keystore_class ] if not coldcard_keystores: # doesn't involve a Coldcard wallet, hide feature return btn_export = QPushButton(_("Export multisig for Coldcard as file")) btn_export.clicked.connect(lambda unused: self.export_multisig_setup(main_window, wallet)) buttons.append(btn_export) btn_import_usb = QPushButton(_("Export multisig to Coldcard via USB")) btn_import_usb.clicked.connect(lambda unused: self.import_multisig_wallet_to_cc(main_window, coldcard_keystores)) buttons.append(btn_import_usb) return buttons def import_multisig_wallet_to_cc(self, main_window: 'ElectrumWindow', coldcard_keystores: Sequence[Hardware_KeyStore]): from io import StringIO from ckcc.protocol import CCProtocolPacker index = main_window.query_choice( _("Please select which {} device to use:").format(self.device), [ChoiceItem(key=i, label=ks.label) for i, ks in enumerate(coldcard_keystores)] ) if index is not None: selected_keystore = coldcard_keystores[index] client = self.get_client(selected_keystore, force_pair=True, allow_user_interaction=False) if client is None: main_window.show_error("{} not connected.").format(selected_keystore.label) return wallet = main_window.wallet sio = StringIO() basename = self.trim_file_suffix(wallet.basename()) ColdcardPlugin.export_ms_wallet(wallet, sio, basename) sio.seek(0) file_len, sha = client.dev.upload_file(sio.read().encode("utf-8"), verify=True) client.dev.send_recv(CCProtocolPacker.multisig_enroll(file_len, sha)) main_window.show_message('\n'.join([ _("Wallet setup file '{}' imported successfully.").format(basename), _("Confirm import on your {} device.").format(selected_keystore.label) ])) def export_multisig_setup(self, main_window, wallet): basename = self.trim_file_suffix(wallet.basename()) name = f'{basename}-cc-export.txt'.replace(' ', '-') fileName = getSaveFileName( parent=main_window, title=_("Select where to save the setup file"), filename=name, filter="*.txt", config=self.config, ) if fileName: with open(fileName, "wt") as f: ColdcardPlugin.export_ms_wallet(wallet, f, basename) main_window.show_message(_("Wallet setup file '{}' exported successfully").format(name)) def show_settings_dialog(self, window, keystore): # When they click on the icon for CC we come here. # - doesn't matter if device not connected, continue CKCCSettingsDialog(window, self, keystore).exec() @hook def init_wallet_wizard(self, wizard: 'QENewWalletWizard'): self.extend_wizard(wizard) # insert coldcard pages in new wallet wizard def extend_wizard(self, wizard: 'QENewWalletWizard'): super().extend_wizard(wizard) views = { 'coldcard_start': {'gui': WCScriptAndDerivation}, 'coldcard_xpub': {'gui': WCHWXPub}, 'coldcard_not_initialized': {'gui': WCHWUninitialized}, 'coldcard_unlock': {'gui': WCHWUnlock} } wizard.navmap_merge(views) class Coldcard_Handler(QtHandlerBase): MESSAGE_DIALOG_TITLE = _("Coldcard Status") def __init__(self, win): super(Coldcard_Handler, self).__init__(win, 'Coldcard') class CKCCSettingsDialog(WindowModalDialog): def __init__(self, window: ElectrumWindow, plugin, keystore): title = _("{} Settings").format(plugin.device) super(CKCCSettingsDialog, self).__init__(window, title) self.setMaximumWidth(540) # Note: Coldcard may **not** be connected at present time. Keep working! devmgr = plugin.device_manager() #config = devmgr.config #handler = keystore.handler self.thread = thread = keystore.thread self.keystore = keystore assert isinstance(window, ElectrumWindow), f"{type(window)}" self.window = window def connect_and_doit(): # Attempt connection to device, or raise. device_id = plugin.choose_device(window, keystore) if not device_id: raise RuntimeError("Device not connected") client = devmgr.client_by_id(device_id) if not client: raise RuntimeError("Device not connected") return client body = QWidget() body_layout = QVBoxLayout(body) grid = QGridLayout() grid.setColumnStretch(2, 1) title = RichLabel('''
Coldcard Wallet
from Coinkite Inc.
coldcardwallet.com''') grid.addWidget(title, 0, 0, 1, 2, Qt.AlignmentFlag.AlignHCenter) y = 3 rows = [ ('xfp', _("Master Fingerprint")), ('serial', _("USB Serial")), ('fw_version', _("Firmware Version")), ('fw_built', _("Build Date")), ('bl_version', _("Bootloader")), ] for row_num, (member_name, label) in enumerate(rows): # XXX we know xfp already, even if not connected widget = QLabel('000000000000') widget.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse | Qt.TextInteractionFlag.TextSelectableByKeyboard) grid.addWidget(QLabel(label), y, 0, 1, 1, Qt.AlignmentFlag.AlignRight) grid.addWidget(widget, y, 1, 1, 1, Qt.AlignmentFlag.AlignLeft) setattr(self, member_name, widget) y += 1 body_layout.addLayout(grid) upg_btn = QPushButton(_('Upgrade')) #upg_btn.setDefault(False) def _start_upgrade(): thread.add(connect_and_doit, on_success=self.start_upgrade) upg_btn.clicked.connect(_start_upgrade) y += 3 grid.addWidget(upg_btn, y, 0) grid.addWidget(CloseButton(self), y, 1) dialog_vbox = QVBoxLayout(self) dialog_vbox.addWidget(body) # Fetch firmware/versions values and show them. thread.add(connect_and_doit, on_success=self.show_values, on_error=self.show_placeholders) def show_placeholders(self, unclear_arg): # device missing, so hide lots of detail. self.xfp.setText('%s' % self.keystore.get_root_fingerprint()) self.serial.setText('(not connected)') self.fw_version.setText('') self.fw_built.setText('') self.bl_version.setText('') def show_values(self, client): dev = client.dev self.xfp.setText('%s' % xfp2str(dev.master_fingerprint)) self.serial.setText('%s' % dev.serial) # ask device for versions: allow extras for future fw_date, fw_rel, bl_rel, *rfu = client.get_version() self.fw_version.setText('%s' % fw_rel) self.fw_built.setText('%s' % fw_date) self.bl_version.setText('%s' % bl_rel) def start_upgrade(self, client): # ask for a filename (must have already downloaded it) dev = client.dev fileName = getOpenFileName( parent=self, title="Select upgraded firmware file", filter="*.dfu", config=self.window.config, ) if not fileName: return from ckcc.utils import dfu_parse from ckcc.sigheader import FW_HEADER_SIZE, FW_HEADER_OFFSET, FW_HEADER_MAGIC from ckcc.protocol import CCProtocolPacker import struct try: with open(fileName, 'rb') as fd: # unwrap firmware from the DFU offset, size, *ignored = dfu_parse(fd) fd.seek(offset) firmware = fd.read(size) hpos = FW_HEADER_OFFSET hdr = bytes(firmware[hpos:hpos + FW_HEADER_SIZE]) # needed later too magic = struct.unpack_from("