302 lines
11 KiB
Python
302 lines
11 KiB
Python
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('''<center>
|
|
<span style="font-size: x-large">Coldcard Wallet</span>
|
|
<br><span style="font-size: medium">from Coinkite Inc.</span>
|
|
<br><a href="https://coldcardwallet.com">coldcardwallet.com</a>''')
|
|
|
|
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('<tt>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('<tt>%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('<tt>%s' % xfp2str(dev.master_fingerprint))
|
|
self.serial.setText('<tt>%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('<tt>%s' % fw_rel)
|
|
self.fw_built.setText('<tt>%s' % fw_date)
|
|
self.bl_version.setText('<tt>%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("<I", hdr)[0]
|
|
|
|
if magic != FW_HEADER_MAGIC:
|
|
raise ValueError("Bad magic")
|
|
except Exception as exc:
|
|
self.window.show_error("Does not appear to be a Coldcard firmware file.\n\n%s" % exc)
|
|
return
|
|
|
|
# TODO:
|
|
# - detect if they are trying to downgrade; aint gonna work
|
|
# - warn them about the reboot?
|
|
# - length checks
|
|
# - add progress local bar
|
|
self.window.show_message("Ready to Upgrade.\n\nBe patient. Unit will reboot itself when complete.")
|
|
|
|
def doit():
|
|
dlen, _ = dev.upload_file(firmware, verify=True)
|
|
assert dlen == len(firmware)
|
|
|
|
# append the firmware header a second time
|
|
result = dev.send_recv(CCProtocolPacker.upload(size, size+FW_HEADER_SIZE, hdr))
|
|
|
|
# make it reboot into bootloader which might install it
|
|
dev.send_recv(CCProtocolPacker.reboot())
|
|
|
|
self.thread.add(doit)
|
|
self.close()
|