diff --git a/contrib/requirements/requirements-hw.txt b/contrib/requirements/requirements-hw.txt
index e8c7b2e9b..507da9734 100644
--- a/contrib/requirements/requirements-hw.txt
+++ b/contrib/requirements/requirements-hw.txt
@@ -2,5 +2,6 @@ Cython>=0.27
trezor[hidapi]>=0.9.0
keepkey
btchip-python
+ckcc-protocol
websocket-client
hidapi
diff --git a/icons.qrc b/icons.qrc
index 82cfba322..16242879e 100644
--- a/icons.qrc
+++ b/icons.qrc
@@ -49,6 +49,8 @@
icons/speaker.png
icons/trezor_unpaired.png
icons/trezor.png
+ icons/coldcard.png
+ icons/coldcard_unpaired.png
icons/trustedcoin-status.png
icons/trustedcoin-wizard.png
icons/unconfirmed.png
diff --git a/icons/coldcard.png b/icons/coldcard.png
new file mode 100644
index 000000000..11e79a2bf
Binary files /dev/null and b/icons/coldcard.png differ
diff --git a/icons/coldcard_unpaired.png b/icons/coldcard_unpaired.png
new file mode 100644
index 000000000..223318109
Binary files /dev/null and b/icons/coldcard_unpaired.png differ
diff --git a/lib/transaction.py b/lib/transaction.py
index 54ca59485..e23522d5c 100644
--- a/lib/transaction.py
+++ b/lib/transaction.py
@@ -1020,12 +1020,12 @@ class Transaction:
else:
return network_ser
- def serialize_to_network(self, estimate_size=False, witness=True):
+ def serialize_to_network(self, estimate_size=False, witness=True, blank_scripts=False):
nVersion = int_to_hex(self.version, 4)
nLocktime = int_to_hex(self.locktime, 4)
inputs = self.inputs()
outputs = self.outputs()
- txins = var_int(len(inputs)) + ''.join(self.serialize_input(txin, self.input_script(txin, estimate_size)) for txin in inputs)
+ txins = var_int(len(inputs)) + ''.join(self.serialize_input(txin, self.input_script(txin, estimate_size) if not blank_scripts else '') for txin in inputs)
txouts = var_int(len(outputs)) + ''.join(self.serialize_output(o) for o in outputs)
use_segwit_ser_for_estimate_size = estimate_size and self.is_segwit(guess_for_address=True)
use_segwit_ser_for_actual_use = not estimate_size and \
diff --git a/plugins/coldcard/README.md b/plugins/coldcard/README.md
new file mode 100644
index 000000000..04e62ce81
--- /dev/null
+++ b/plugins/coldcard/README.md
@@ -0,0 +1,25 @@
+
+# Coldcard Hardware Wallet Plugin
+
+## Just the glue please
+
+- This code connects the public USB API and Electrum. Leverages all the good work that's been
+done my the Electrum team to support hardware wallets.
+
+
+### Ctags
+
+- I find this command useful (at top level) ... but I'm a VIM user.
+
+ ctags -f .tags electrum `find . -name ENV -prune -o -name \*.py`
+
+### Working with latest ckcc-protocol
+
+- at top level, do this:
+
+ pip install -e git+ssh://git@github.com/Coldcard/ckcc-protocol.git#egg=ckcc-protocol
+
+- but you'll need the https version of that, not ssh like I can.
+- also a branch name would be good in there
+- do `pip uninstall ckcc` first
+- see
diff --git a/plugins/coldcard/__init__.py b/plugins/coldcard/__init__.py
new file mode 100644
index 000000000..e7aef3e72
--- /dev/null
+++ b/plugins/coldcard/__init__.py
@@ -0,0 +1,7 @@
+from electrum.i18n import _
+
+fullname = 'Coldcard Wallet'
+description = 'Provides support for the Coldcard hardware wallet from Coinkite'
+requires = [('ckcc-protocol', 'github.com/Coldcard/ckcc-protocol')]
+registers_keystore = ('hardware', 'coldcard', _("Coldcard wallet"))
+available_for = ['qt', 'cmdline']
diff --git a/plugins/coldcard/cmdline.py b/plugins/coldcard/cmdline.py
new file mode 100644
index 000000000..e70db7bdb
--- /dev/null
+++ b/plugins/coldcard/cmdline.py
@@ -0,0 +1,47 @@
+from electrum.plugins import hook
+from .coldcard import ColdcardPlugin
+from electrum.util import print_msg, print_error, raw_input, print_stderr
+
+class ColdcardCmdLineHandler:
+
+ def get_passphrase(self, msg, confirm):
+ raise NotImplementedError
+
+ def get_pin(self, msg):
+ raise NotImplementedError
+
+ def prompt_auth(self, msg):
+ raise NotImplementedError
+
+ def yes_no_question(self, msg):
+ print_msg(msg)
+ return raw_input() in 'yY'
+
+ def stop(self):
+ pass
+
+ def show_message(self, msg, on_cancel=None):
+ print_stderr(msg)
+
+ def show_error(self, msg, blocking=False):
+ print_error(msg)
+
+ def update_status(self, b):
+ print_error('hw device status', b)
+
+ def finished(self):
+ pass
+
+class Plugin(ColdcardPlugin):
+ handler = ColdcardCmdLineHandler()
+
+ @hook
+ def init_keystore(self, keystore):
+ if not isinstance(keystore, self.keystore_class):
+ return
+ keystore.handler = self.handler
+
+ def create_handler(self, window):
+ return self.handler
+
+# EOF
diff --git a/plugins/coldcard/coldcard.py b/plugins/coldcard/coldcard.py
new file mode 100644
index 000000000..5872c7251
--- /dev/null
+++ b/plugins/coldcard/coldcard.py
@@ -0,0 +1,561 @@
+#
+# Coldcard Electrum plugin main code.
+#
+#
+from struct import pack, unpack
+import hashlib
+import os, sys, time, io
+import traceback
+
+from electrum import bitcoin
+from electrum.bitcoin import TYPE_ADDRESS, int_to_hex
+from electrum.i18n import _
+from electrum.plugins import BasePlugin, Device
+from electrum.keystore import Hardware_KeyStore, xpubkey_to_pubkey
+from electrum.transaction import Transaction
+from electrum.wallet import Standard_Wallet
+from electrum.crypto import hash_160
+from ..hw_wallet import HW_PluginBase
+from ..hw_wallet.plugin import is_any_tx_output_on_change_branch
+from electrum.util import print_error, is_verbose, bfh, bh2u, versiontuple
+from electrum.base_wizard import ScriptTypeNotSupported
+
+try:
+ import hid
+ from ckcc.protocol import CCProtocolPacker, CCProtocolUnpacker
+ from ckcc.protocol import CCProtoError, CCUserRefused, CCBusyError
+ from ckcc.constants import (MAX_MSG_LEN, MAX_BLK_LEN, MSG_SIGNING_MAX_LENGTH, MAX_TXN_LEN,
+ AF_CLASSIC, AF_P2SH, AF_P2WPKH, AF_P2WSH, AF_P2WPKH_P2SH, AF_P2WSH_P2SH)
+ from ckcc.client import ColdcardDevice, COINKITE_VID, CKCC_PID, CKCC_SIMULATOR_PATH
+
+ requirements_ok = True
+except ImportError:
+ requirements_ok = False
+
+class ElectrumColdcardDevice(ColdcardDevice):
+ # avoid use of pycoin for MiTM message signature test
+ def mitm_verify(self, sig, expect_xpub):
+ # verify a signature (65 bytes) over the session key, using the master bip32 node
+ # - customized to use specific EC library of Electrum.
+ from electrum.ecc import ECPubkey
+
+ xtype, depth, parent_fingerprint, child_number, chain_code, K_or_k \
+ = bitcoin.deserialize_xpub(expect_xpub)
+
+ pubkey = ECPubkey(K_or_k)
+ try:
+ pubkey.verify_message_hash(sig[1:65], self.session_key)
+ return True
+ except:
+ return False
+
+def my_var_int(l):
+ # Bitcoin serialization of integers... directly into binary!
+ if l < 253:
+ return pack("B", l)
+ elif l < 0x10000:
+ return pack("' % (self.dev.master_fingerprint,
+ self.label())
+
+ @property
+ def expected_master(self, xp):
+ return self._expected_master
+
+ @expected_master.setter
+ def expected_master(self, xp):
+ if self._expected_master is None:
+ # Do the MiTM test now that we know what master xpub should be
+ self.dev.check_mitm(expected_master=xp)
+ self._expected_master = xp
+ print_error('[coldcard]', "USB MiTM test passed")
+ else:
+ assert self._expected_master == xp, "XPUB changing?"
+
+ def is_pairable(self):
+ # can't do anything w/ devices that aren't setup (but not normally reachable)
+ return bool(self.dev.master_xpub)
+
+ def timeout(self, cutoff):
+ # nothing to do?
+ pass
+
+ def close(self):
+ # close the HID device (so can be reused)
+ self.dev.close()
+ self.dev = None
+
+ def is_initialized(self):
+ return bool(self.dev.master_xpub)
+
+ def label(self):
+ # 'label' of this Coldcard. Warning: gets saved into wallet file, which might
+ # not be encrypted, so better for privacy if based on xpub/fingerprint rather than
+ # USB serial number.
+ if self.dev.is_simulator:
+ return 'Coldcard Simulator 0x%08x' % self.dev.master_fingerprint
+
+ if not self.dev.master_fingerprint:
+ # failback; not expected
+ return 'Coldcard #' + self.dev.serial
+
+ return 'Coldcard 0x%08x' % self.dev.master_fingerprint
+
+ def has_usable_connection_with_device(self):
+ # Do end-to-end ping test
+ try:
+ self.ping_check()
+ return True
+ except:
+ return False
+
+ def get_xpub(self, bip32_path, xtype):
+ # TODO: xtype?
+ return self.dev.send_recv(CCProtocolPacker.get_xpub(bip32_path), timeout=5000)
+
+ def ping_check(self):
+ # check connection is working
+ assert self.dev.session_key, 'not encrypted?'
+ req = b'1234 Electrum Plugin 4321' # free up to 59 bytes
+ try:
+ echo = self.dev.send_recv(CCProtocolPacker.ping(req))
+ assert echo == req
+ except:
+ raise RuntimeError("Communication trouble with Coldcard")
+
+ def show_address(self, path, addr_fmt):
+ # prompt user w/ addres, also returns it immediately.
+ return self.dev.send_recv(CCProtocolPacker.show_address(path, addr_fmt), timeout=None)
+
+ def sign_message_start(self, path, msg):
+ # this starts the UX experience.
+ self.dev.send_recv(CCProtocolPacker.sign_message(msg, path), timeout=None)
+
+ def sign_message_poll(self):
+ # poll device... if user has approved, will get tuple: (addr, sig) else None
+ return self.dev.send_recv(CCProtocolPacker.get_signed_msg(), timeout=None)
+
+ def sign_transaction_start(self, raw_psbt, finalize=True):
+ # Multiple steps to sign:
+ # - upload binary
+ # - start signing UX
+ # - wait for coldcard to complete process, or have it refused.
+ # - download resulting txn
+ assert 20 <= len(raw_psbt) < MAX_TXN_LEN, 'PSBT is too big'
+ dlen, chk = self.dev.upload_file(raw_psbt)
+
+ resp = self.dev.send_recv(CCProtocolPacker.sign_transaction(dlen, chk, finalize=finalize),
+ timeout=None)
+
+ if resp != None:
+ raise ValueError(resp)
+
+ def sign_transaction_poll(self):
+ # poll device... if user has approved, will get tuple: (addr, sig) else None
+ return self.dev.send_recv(CCProtocolPacker.get_signed_txn(), timeout=None)
+
+ def download_file(self, length, checksum, file_number=1):
+ # get a file
+ return self.dev.download_file(length, checksum, file_number=file_number)
+
+
+
+class Coldcard_KeyStore(Hardware_KeyStore):
+ hw_type = 'coldcard'
+ device = 'Coldcard'
+
+ def __init__(self, d):
+ Hardware_KeyStore.__init__(self, d)
+ # Errors and other user interaction is done through the wallet's
+ # handler. The handler is per-window and preserved across
+ # device reconnects
+ self.force_watching_only = False
+ self.ux_busy = False
+
+ # seems like only the derivation path and resulting xpub is stored in
+ # the wallet file... however, we need to know the master xpub to verify
+ # the mitm and also so we can put the right value into the subkey paths
+ # of PSBT files that might be generated offline
+ #self.ckcc_master_xpub = d.get('ckcc_master_xpub', '')
+
+ def get_derivation(self):
+ return self.derivation
+
+ def get_client(self):
+ rv = self.plugin.get_client(self)
+ if rv and self.ckcc_master_xpub:
+ rv.expected_master = self.ckcc_master_xpub
+ return rv
+
+ def give_error(self, message, clear_client=False):
+ print_error(message)
+ if not self.ux_busy:
+ self.handler.show_error(message)
+ else:
+ self.ux_busy = False
+ if clear_client:
+ self.client = None
+ raise Exception(message)
+
+ def wrap_busy(func):
+ # decorator: function takes over the UX on the device.
+ def wrapper(self, *args, **kwargs):
+ try:
+ self.ux_busy = True
+ return func(self, *args, **kwargs)
+ finally:
+ self.ux_busy = False
+ return wrapper
+
+ def decrypt_message(self, pubkey, message, password):
+ raise RuntimeError(_('Encryption and decryption are currently not supported for {}').format(self.device))
+
+ @wrap_busy
+ def sign_message(self, sequence, message, password):
+ # Sign a message on device. Since we have big screen, of course we
+ # have to show the message unabiguously there first!
+ try:
+ msg = message.encode('ascii', errors='strict')
+ assert 1 <= len(msg) <= MSG_SIGNING_MAX_LENGTH
+ except (UnicodeError, AssertionError):
+ # there are other restrictions on message content,
+ # but let the device enforce and report those
+ self.handler.show_error('Only short (%d max) ASCII messages can be signed.'
+ % MSG_SIGNING_MAX_LENGTH)
+ return b''
+
+ client = self.get_client()
+ path = self.get_derivation() + ("/%d/%d" % sequence)
+ try:
+ cl = self.get_client()
+ try:
+ self.handler.show_message("Signing message (using %s)..." % path)
+
+ cl.sign_message_start(path, msg)
+
+ while 1:
+ # How to kill some time, without locking UI?
+ time.sleep(0.250)
+
+ resp = cl.sign_message_poll()
+ if resp is not None:
+ break
+
+ finally:
+ self.handler.finished()
+
+ assert len(resp) == 2
+ addr, raw_sig = resp
+
+ # already encoded in Bitcoin fashion, binary.
+ assert 40 < len(raw_sig) <= 65
+
+ return raw_sig
+
+ except (CCUserRefused, CCBusyError) as exc:
+ self.handler.show_error(str(exc))
+ except CCProtoError as exc:
+ traceback.print_exc(file=sys.stderr)
+ self.handler.show_error('{}\n\n{}'.format(
+ _('Error showing address') + ':', str(exc)))
+ except Exception as e:
+ self.give_error(e, True)
+
+ # give empty bytes for error cases; it seems to clear the old signature box
+ return b''
+
+ def build_psbt(self, tx, xfp=None, wallet=None):
+ # Render a PSBT file, for upload to Coldcard.
+ #
+ if xfp is None:
+ # we need to BIP32 fingerprint value: 4 bytes of ripemd(sha256(pubkey))
+ kk = self.ckcc_master_xpub
+ print("kk = %r" % kk)
+ kk = bfh(self.get_pubkey_from_xpub(kk, []))
+ print("pubk = %r" % kk)
+ assert len(kk) == 33
+
+ xfp, = unpack(' fingerprint LE32
+ # a/b/c => ints
+ base_path = pack(' not multisig, must be bip32
+ if type(wallet) is not Standard_Wallet:
+ keystore.handler.show_error(_('This function is only available for standard wallets when using {}.').format(self.device))
+ return
+
+ sequence = wallet.get_address_index(address)
+ txin_type = wallet.get_txin_type(address)
+ keystore.show_address(sequence, txin_type)
+
+# EOF
diff --git a/plugins/coldcard/qt.py b/plugins/coldcard/qt.py
new file mode 100644
index 000000000..ebab43ef9
--- /dev/null
+++ b/plugins/coldcard/qt.py
@@ -0,0 +1,97 @@
+import time
+
+from electrum.i18n import _
+from electrum.plugins import hook
+from electrum.wallet import Standard_Wallet
+from electrum_gui.qt.util import *
+
+from .coldcard import ColdcardPlugin
+from ..hw_wallet.qt import QtHandlerBase, QtPluginBase
+
+
+class Plugin(ColdcardPlugin, QtPluginBase):
+ icon_unpaired = ":icons/coldcard_unpaired.png"
+ icon_paired = ":icons/coldcard.png"
+
+ def create_handler(self, window):
+ return Coldcard_Handler(window)
+
+ @hook
+ def receive_menu(self, menu, addrs, wallet):
+ if type(wallet) is not Standard_Wallet:
+ return
+ keystore = wallet.get_keystore()
+ if type(keystore) == self.keystore_class and len(addrs) == 1:
+ def show_address():
+ keystore.thread.add(partial(self.show_address, wallet, addrs[0]))
+ menu.addAction(_("Show on Coldcard"), show_address)
+
+ @hook
+ def transaction_dialog(self, dia):
+ # see gui/qt/transaction_dialog.py
+
+ keystore = dia.wallet.get_keystore()
+ if type(keystore) != self.keystore_class:
+ # not a Coldcard wallet, hide feature
+ return
+
+ # - add a new button, near "export"
+ btn = QPushButton(_("Save PSBT"))
+ btn.clicked.connect(lambda unused: self.export_psbt(dia))
+ if dia.tx.is_complete():
+ # but disable it for signed transactions (nothing to do if already signed)
+ btn.setDisabled(True)
+
+ dia.sharing_buttons.append(btn)
+
+ def export_psbt(self, dia):
+ # Called from hook in transaction dialog
+ tx = dia.tx
+ assert not tx.is_complete(), 'expect unsigned txn'
+
+ # can only expect Coldcard wallets to work with these files (right now)
+ keystore = dia.wallet.get_keystore()
+ assert type(keystore) == self.keystore_class
+
+ # convert to PSBT
+ raw_psbt = keystore.build_psbt(tx, wallet=dia.wallet)
+
+ name = (dia.wallet.basename() + time.strftime('-%y%m%d-%H%M.psbt')).replace(' ', '-')
+ fileName = dia.main_window.getSaveFileName(_("Select where to save the PSBT file"),
+ name, "*.psbt")
+ if fileName:
+ with open(fileName, "wb+") as f:
+ f.write(raw_psbt)
+ dia.show_message(_("Transaction exported successfully"))
+ dia.saved = True
+
+
+class Coldcard_Handler(QtHandlerBase):
+ setup_signal = pyqtSignal()
+ #auth_signal = pyqtSignal(object)
+
+ def __init__(self, win):
+ super(Coldcard_Handler, self).__init__(win, 'Coldcard')
+ self.setup_signal.connect(self.setup_dialog)
+ #self.auth_signal.connect(self.auth_dialog)
+
+
+ def message_dialog(self, msg):
+ self.clear_dialog()
+ self.dialog = dialog = WindowModalDialog(self.top_level_window(), _("Coldcard Status"))
+ l = QLabel(msg)
+ vbox = QVBoxLayout(dialog)
+ vbox.addWidget(l)
+ dialog.show()
+
+ def get_setup(self):
+ self.done.clear()
+ self.setup_signal.emit()
+ self.done.wait()
+ return
+
+ def setup_dialog(self):
+ self.show_error(_('Please initialization your Coldcard while disconnected.'))
+ return
+
+# EOF
diff --git a/setup.py b/setup.py
index ab79b00b0..383dfd44a 100755
--- a/setup.py
+++ b/setup.py
@@ -69,6 +69,7 @@ setup(
'electrum_plugins.revealer',
'electrum_plugins.trezor',
'electrum_plugins.digitalbitbox',
+ 'electrum_plugins.coldcard',
'electrum_plugins.trustedcoin',
'electrum_plugins.virtualkeyboard',
],