commit c81f5395af88f929e423b0a54c960888128d7ab7
parent bd83ca02863920479fefd41f1e54b0315fc6a318
Author: SomberNight <somber.night@protonmail.com>
Date: Wed, 18 Sep 2019 18:35:05 +0200
Merge pull request #5440 from Coldcard/multisig
Add multisig support for Coldcard plugin
Diffstat:
7 files changed, 1063 insertions(+), 238 deletions(-)
diff --git a/contrib/requirements/requirements-hw.txt b/contrib/requirements/requirements-hw.txt
@@ -2,5 +2,5 @@ trezor[hidapi]>=0.11.0
safet[hidapi]>=0.1.0
keepkey>=6.0.3
btchip-python>=0.1.26
-ckcc-protocol>=0.7.2
+ckcc-protocol>=0.7.7
hidapi
diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py
@@ -2390,29 +2390,39 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger):
ks_type = str(keystore_types[0]) if keystore_types else _('No keystore')
grid.addWidget(QLabel(ks_type), 4, 1)
vbox.addLayout(grid)
+
if self.wallet.is_deterministic():
mpk_text = ShowQRTextEdit()
mpk_text.setMaximumHeight(150)
mpk_text.addCopyButton(self.app)
+
def show_mpk(index):
mpk_text.setText(mpk_list[index])
mpk_text.repaint() # macOS hack for #4777
+
# only show the combobox in case multiple accounts are available
if len(mpk_list) > 1:
- def label(key):
- if isinstance(self.wallet, Multisig_Wallet):
- return _("cosigner") + f' {key+1} ( keystore: {keystore_types[key]} )'
- return ''
- labels = [label(i) for i in range(len(mpk_list))]
+ # only show the combobox if multiple master keys are defined
+ def label(idx, ks):
+ if isinstance(self.wallet, Multisig_Wallet) and hasattr(ks, 'label'):
+ return _("cosigner") + f' {idx+1}: {ks.get_type_text()} {ks.label}'
+ else:
+ return _("keystore") + f' {idx+1}'
+
+ labels = [label(idx, ks) for idx, ks in enumerate(self.wallet.get_keystores())]
+
on_click = lambda clayout: show_mpk(clayout.selected_index())
labels_clayout = ChoicesLayout(_("Master Public Keys"), labels, on_click)
vbox.addLayout(labels_clayout.layout())
else:
vbox.addWidget(QLabel(_("Master Public Key")))
+
show_mpk(0)
vbox.addWidget(mpk_text)
+
vbox.addStretch(1)
- vbox.addLayout(Buttons(CloseButton(dialog)))
+ btns = run_hook('wallet_info_buttons', self, dialog) or Buttons(CloseButton(dialog))
+ vbox.addLayout(btns)
dialog.setLayout(vbox)
dialog.exec_()
diff --git a/electrum/plugins/coldcard/basic_psbt.py b/electrum/plugins/coldcard/basic_psbt.py
@@ -0,0 +1,313 @@
+#
+# basic_psbt.py - yet another PSBT parser/serializer but used only for test cases.
+#
+# - history: taken from coldcard-firmware/testing/psbt.py
+# - trying to minimize electrum code in here, and generally, dependancies.
+#
+import io
+import struct
+from base64 import b64decode
+from binascii import a2b_hex, b2a_hex
+from struct import pack, unpack
+
+from electrum.transaction import Transaction
+
+# BIP-174 (aka PSBT) defined values
+#
+PSBT_GLOBAL_UNSIGNED_TX = (0)
+PSBT_GLOBAL_XPUB = (1)
+
+PSBT_IN_NON_WITNESS_UTXO = (0)
+PSBT_IN_WITNESS_UTXO = (1)
+PSBT_IN_PARTIAL_SIG = (2)
+PSBT_IN_SIGHASH_TYPE = (3)
+PSBT_IN_REDEEM_SCRIPT = (4)
+PSBT_IN_WITNESS_SCRIPT = (5)
+PSBT_IN_BIP32_DERIVATION = (6)
+PSBT_IN_FINAL_SCRIPTSIG = (7)
+PSBT_IN_FINAL_SCRIPTWITNESS = (8)
+
+PSBT_OUT_REDEEM_SCRIPT = (0)
+PSBT_OUT_WITNESS_SCRIPT = (1)
+PSBT_OUT_BIP32_DERIVATION = (2)
+
+# Serialization/deserialization tools
+def ser_compact_size(l):
+ r = b""
+ if l < 253:
+ r = struct.pack("B", l)
+ elif l < 0x10000:
+ r = struct.pack("<BH", 253, l)
+ elif l < 0x100000000:
+ r = struct.pack("<BI", 254, l)
+ else:
+ r = struct.pack("<BQ", 255, l)
+ return r
+
+def deser_compact_size(f):
+ try:
+ nit = f.read(1)[0]
+ except IndexError:
+ return None # end of file
+
+ if nit == 253:
+ nit = struct.unpack("<H", f.read(2))[0]
+ elif nit == 254:
+ nit = struct.unpack("<I", f.read(4))[0]
+ elif nit == 255:
+ nit = struct.unpack("<Q", f.read(8))[0]
+ return nit
+
+def my_var_int(l):
+ # Bitcoin serialization of integers... directly into binary!
+ if l < 253:
+ return pack("B", l)
+ elif l < 0x10000:
+ return pack("<BH", 253, l)
+ elif l < 0x100000000:
+ return pack("<BI", 254, l)
+ else:
+ return pack("<BQ", 255, l)
+
+
+class PSBTSection:
+
+ def __init__(self, fd=None, idx=None):
+ self.defaults()
+ self.my_index = idx
+
+ if not fd: return
+
+ while 1:
+ ks = deser_compact_size(fd)
+ if ks is None: break
+ if ks == 0: break
+
+ key = fd.read(ks)
+ vs = deser_compact_size(fd)
+ val = fd.read(vs)
+
+ kt = key[0]
+ self.parse_kv(kt, key[1:], val)
+
+ def serialize(self, fd, my_idx):
+
+ def wr(ktype, val, key=b''):
+ fd.write(ser_compact_size(1 + len(key)))
+ fd.write(bytes([ktype]) + key)
+ fd.write(ser_compact_size(len(val)))
+ fd.write(val)
+
+ self.serialize_kvs(wr)
+
+ fd.write(b'\0')
+
+class BasicPSBTInput(PSBTSection):
+ def defaults(self):
+ self.utxo = None
+ self.witness_utxo = None
+ self.part_sigs = {}
+ self.sighash = None
+ self.bip32_paths = {}
+ self.redeem_script = None
+ self.witness_script = None
+ self.others = {}
+
+ def __eq__(a, b):
+ if a.sighash != b.sighash:
+ if a.sighash is not None and b.sighash is not None:
+ return False
+
+ rv = a.utxo == b.utxo and \
+ a.witness_utxo == b.witness_utxo and \
+ a.redeem_script == b.redeem_script and \
+ a.witness_script == b.witness_script and \
+ a.my_index == b.my_index and \
+ a.bip32_paths == b.bip32_paths and \
+ sorted(a.part_sigs.keys()) == sorted(b.part_sigs.keys())
+
+ # NOTE: equality test on signatures requires parsing DER stupidness
+ # and some maybe understanding of R/S values on curve that I don't have.
+
+ return rv
+
+ def parse_kv(self, kt, key, val):
+ if kt == PSBT_IN_NON_WITNESS_UTXO:
+ self.utxo = val
+ assert not key
+ elif kt == PSBT_IN_WITNESS_UTXO:
+ self.witness_utxo = val
+ assert not key
+ elif kt == PSBT_IN_PARTIAL_SIG:
+ self.part_sigs[key] = val
+ elif kt == PSBT_IN_SIGHASH_TYPE:
+ assert len(val) == 4
+ self.sighash = struct.unpack("<I", val)[0]
+ assert not key
+ elif kt == PSBT_IN_BIP32_DERIVATION:
+ self.bip32_paths[key] = val
+ elif kt == PSBT_IN_REDEEM_SCRIPT:
+ self.redeem_script = val
+ assert not key
+ elif kt == PSBT_IN_WITNESS_SCRIPT:
+ self.witness_script = val
+ assert not key
+ elif kt in ( PSBT_IN_REDEEM_SCRIPT,
+ PSBT_IN_WITNESS_SCRIPT,
+ PSBT_IN_FINAL_SCRIPTSIG,
+ PSBT_IN_FINAL_SCRIPTWITNESS):
+ assert not key
+ self.others[kt] = val
+ else:
+ raise KeyError(kt)
+
+ def serialize_kvs(self, wr):
+ if self.utxo:
+ wr(PSBT_IN_NON_WITNESS_UTXO, self.utxo)
+ if self.witness_utxo:
+ wr(PSBT_IN_WITNESS_UTXO, self.witness_utxo)
+ if self.redeem_script:
+ wr(PSBT_IN_REDEEM_SCRIPT, self.redeem_script)
+ if self.witness_script:
+ wr(PSBT_IN_WITNESS_SCRIPT, self.witness_script)
+ for pk, val in sorted(self.part_sigs.items()):
+ wr(PSBT_IN_PARTIAL_SIG, val, pk)
+ if self.sighash is not None:
+ wr(PSBT_IN_SIGHASH_TYPE, struct.pack('<I', self.sighash))
+ for k in self.bip32_paths:
+ wr(PSBT_IN_BIP32_DERIVATION, self.bip32_paths[k], k)
+ for k in self.others:
+ wr(k, self.others[k])
+
+class BasicPSBTOutput(PSBTSection):
+ def defaults(self):
+ self.redeem_script = None
+ self.witness_script = None
+ self.bip32_paths = {}
+
+ def __eq__(a, b):
+ return a.redeem_script == b.redeem_script and \
+ a.witness_script == b.witness_script and \
+ a.my_index == b.my_index and \
+ a.bip32_paths == b.bip32_paths
+
+ def parse_kv(self, kt, key, val):
+ if kt == PSBT_OUT_REDEEM_SCRIPT:
+ self.redeem_script = val
+ assert not key
+ elif kt == PSBT_OUT_WITNESS_SCRIPT:
+ self.witness_script = val
+ assert not key
+ elif kt == PSBT_OUT_BIP32_DERIVATION:
+ self.bip32_paths[key] = val
+ else:
+ raise ValueError(kt)
+
+ def serialize_kvs(self, wr):
+ if self.redeem_script:
+ wr(PSBT_OUT_REDEEM_SCRIPT, self.redeem_script)
+ if self.witness_script:
+ wr(PSBT_OUT_WITNESS_SCRIPT, self.witness_script)
+ for k in self.bip32_paths:
+ wr(PSBT_OUT_BIP32_DERIVATION, self.bip32_paths[k], k)
+
+
+class BasicPSBT:
+ "Just? parse and store"
+
+ def __init__(self):
+
+ self.txn = None
+ self.filename = None
+ self.parsed_txn = None
+ self.xpubs = []
+
+ self.inputs = []
+ self.outputs = []
+
+ def __eq__(a, b):
+ return a.txn == b.txn and \
+ len(a.inputs) == len(b.inputs) and \
+ len(a.outputs) == len(b.outputs) and \
+ all(a.inputs[i] == b.inputs[i] for i in range(len(a.inputs))) and \
+ all(a.outputs[i] == b.outputs[i] for i in range(len(a.outputs))) and \
+ sorted(a.xpubs) == sorted(b.xpubs)
+
+ def parse(self, raw, filename=None):
+ # auto-detect and decode Base64 and Hex.
+ if raw[0:10].lower() == b'70736274ff':
+ raw = a2b_hex(raw.strip())
+ if raw[0:6] == b'cHNidP':
+ raw = b64decode(raw)
+ assert raw[0:5] == b'psbt\xff', "bad magic"
+
+ self.filename = filename
+
+ with io.BytesIO(raw[5:]) as fd:
+
+ # globals
+ while 1:
+ ks = deser_compact_size(fd)
+ if ks is None: break
+
+ if ks == 0: break
+
+ key = fd.read(ks)
+ vs = deser_compact_size(fd)
+ val = fd.read(vs)
+
+ kt = key[0]
+ if kt == PSBT_GLOBAL_UNSIGNED_TX:
+ self.txn = val
+
+ self.parsed_txn = Transaction(val.hex())
+ num_ins = len(self.parsed_txn.inputs())
+ num_outs = len(self.parsed_txn.outputs())
+
+ elif kt == PSBT_GLOBAL_XPUB:
+ # key=(xpub) => val=(path)
+ self.xpubs.append( (key, val) )
+ else:
+ raise ValueError('unknown global key type: 0x%02x' % kt)
+
+ assert self.txn, 'missing reqd section'
+
+ self.inputs = [BasicPSBTInput(fd, idx) for idx in range(num_ins)]
+ self.outputs = [BasicPSBTOutput(fd, idx) for idx in range(num_outs)]
+
+ sep = fd.read(1)
+ assert sep == b''
+
+ return self
+
+ def serialize(self, fd):
+
+ def wr(ktype, val, key=b''):
+ fd.write(ser_compact_size(1 + len(key)))
+ fd.write(bytes([ktype]) + key)
+ fd.write(ser_compact_size(len(val)))
+ fd.write(val)
+
+ fd.write(b'psbt\xff')
+
+ wr(PSBT_GLOBAL_UNSIGNED_TX, self.txn)
+
+ for k,v in self.xpubs:
+ wr(PSBT_GLOBAL_XPUB, v, key=k)
+
+ # sep
+ fd.write(b'\0')
+
+ for idx, inp in enumerate(self.inputs):
+ inp.serialize(fd, idx)
+
+ for idx, outp in enumerate(self.outputs):
+ outp.serialize(fd, idx)
+
+ def as_bytes(self):
+ with io.BytesIO() as fd:
+ self.serialize(fd)
+ return fd.getvalue()
+
+# EOF
+
diff --git a/electrum/plugins/coldcard/build_psbt.py b/electrum/plugins/coldcard/build_psbt.py
@@ -0,0 +1,397 @@
+#
+# build_psbt.py - create a PSBT from (unsigned) transaction and keystore data.
+#
+import io
+import struct
+from binascii import a2b_hex, b2a_hex
+from struct import pack, unpack
+
+from electrum.transaction import (Transaction, multisig_script, parse_redeemScript_multisig,
+ NotRecognizedRedeemScript)
+
+from electrum.logging import get_logger
+from electrum.wallet import Standard_Wallet, Multisig_Wallet, Abstract_Wallet
+from electrum.keystore import xpubkey_to_pubkey, Xpub
+from electrum.util import bfh, bh2u
+from electrum.crypto import hash_160, sha256
+from electrum.bitcoin import DecodeBase58Check
+from electrum.i18n import _
+
+from .basic_psbt import (
+ PSBT_GLOBAL_UNSIGNED_TX, PSBT_GLOBAL_XPUB, PSBT_IN_NON_WITNESS_UTXO, PSBT_IN_WITNESS_UTXO,
+ PSBT_IN_SIGHASH_TYPE, PSBT_IN_REDEEM_SCRIPT, PSBT_IN_WITNESS_SCRIPT, PSBT_IN_PARTIAL_SIG,
+ PSBT_IN_BIP32_DERIVATION, PSBT_OUT_BIP32_DERIVATION,
+ PSBT_OUT_REDEEM_SCRIPT, PSBT_OUT_WITNESS_SCRIPT)
+from .basic_psbt import BasicPSBT
+
+
+_logger = get_logger(__name__)
+
+def xfp2str(xfp):
+ # Standardized way to show an xpub's fingerprint... it's a 4-byte string
+ # and not really an integer. Used to show as '0x%08x' but that's wrong endian.
+ return b2a_hex(pack('<I', xfp)).decode('ascii').upper()
+
+def xfp_from_xpub(xpub):
+ # sometime we need to BIP32 fingerprint value: 4 bytes of ripemd(sha256(pubkey))
+ kk = bfh(Xpub.get_pubkey_from_xpub(xpub, []))
+ assert len(kk) == 33
+ xfp, = unpack('<I', hash_160(kk)[0:4])
+ return xfp
+
+def packed_xfp_path(xfp, text_path, int_path=[]):
+ # Convert text subkey derivation path into binary format needed for PSBT
+ # - binary LE32 values, first one is the fingerprint
+ rv = pack('<I', xfp)
+
+ for x in text_path.split('/'):
+ if x == 'm': continue
+ if x.endswith("'"):
+ x = int(x[:-1]) | 0x80000000
+ else:
+ x = int(x)
+ rv += pack('<I', x)
+
+ for x in int_path:
+ rv += pack('<I', x)
+
+ return rv
+
+def unpacked_xfp_path(xfp, text_path):
+ # Convert text subkey derivation path into format needed for PSBT
+ # - binary LE32 values, first one is the fingerprint
+ # - but as ints, not bytes yet
+ rv = [xfp]
+
+ for x in text_path.split('/'):
+ if x == 'm': continue
+ if x.endswith("'"):
+ x = int(x[:-1]) | 0x80000000
+ else:
+ x = int(x)
+ rv.append(x)
+
+ return rv
+
+def xfp_for_keystore(ks):
+ # Need the fingerprint of the MASTER key for a keystore we're playing with.
+ xfp = getattr(ks, 'ckcc_xfp', None)
+
+ if xfp is None:
+ xfp = xfp_from_xpub(ks.get_master_public_key())
+ setattr(ks, 'ckcc_xfp', xfp)
+
+ return xfp
+
+def packed_xfp_path_for_keystore(ks, int_path=[]):
+ # Return XFP + common prefix path for keystore, as binary ready for PSBT
+ derv = getattr(ks, 'derivation', 'm')
+ return packed_xfp_path(xfp_for_keystore(ks), derv[2:] or 'm', int_path=int_path)
+
+# Serialization/deserialization tools
+def ser_compact_size(l):
+ r = b""
+ if l < 253:
+ r = struct.pack("B", l)
+ elif l < 0x10000:
+ r = struct.pack("<BH", 253, l)
+ elif l < 0x100000000:
+ r = struct.pack("<BI", 254, l)
+ else:
+ r = struct.pack("<BQ", 255, l)
+ return r
+
+def deser_compact_size(f):
+ try:
+ nit = f.read(1)[0]
+ except IndexError:
+ return None # end of file
+
+ if nit == 253:
+ nit = struct.unpack("<H", f.read(2))[0]
+ elif nit == 254:
+ nit = struct.unpack("<I", f.read(4))[0]
+ elif nit == 255:
+ nit = struct.unpack("<Q", f.read(8))[0]
+ return nit
+
+def my_var_int(l):
+ # Bitcoin serialization of integers... directly into binary!
+ if l < 253:
+ return pack("B", l)
+ elif l < 0x10000:
+ return pack("<BH", 253, l)
+ elif l < 0x100000000:
+ return pack("<BI", 254, l)
+ else:
+ return pack("<BQ", 255, l)
+
+def build_psbt(tx: Transaction, wallet: Abstract_Wallet):
+ # Render a PSBT file, for possible upload to Coldcard.
+ #
+ # TODO this should be part of Wallet object, or maybe Transaction?
+
+ if getattr(tx, 'raw_psbt', False):
+ _logger.info('PSBT cache hit')
+ return tx.raw_psbt
+
+ inputs = tx.inputs()
+ if 'prev_tx' not in inputs[0]:
+ # fetch info about inputs, if needed?
+ # - needed during export PSBT flow, not normal online signing
+ wallet.add_hw_info(tx)
+
+ # wallet.add_hw_info installs this attr
+ assert tx.output_info is not None, 'need data about outputs'
+
+ # Build a map of all pubkeys needed as derivation from master XFP, in PSBT binary format
+ # 1) binary version of the common subpath for all keys
+ # m/ => fingerprint LE32
+ # a/b/c => ints
+ #
+ # 2) all used keys in transaction:
+ # - for all inputs and outputs (when its change back)
+ # - for all keystores, if multisig
+ #
+ subkeys = {}
+ for ks in wallet.get_keystores():
+
+ # XFP + fixed prefix for this keystore
+ ks_prefix = packed_xfp_path_for_keystore(ks)
+
+ # all pubkeys needed for input signing
+ for xpubkey, derivation in ks.get_tx_derivations(tx).items():
+ pubkey = xpubkey_to_pubkey(xpubkey)
+
+ # assuming depth two, non-harded: change + index
+ aa, bb = derivation
+ assert 0 <= aa < 0x80000000 and 0 <= bb < 0x80000000
+
+ subkeys[bfh(pubkey)] = ks_prefix + pack('<II', aa, bb)
+
+ # all keys related to change outputs
+ for o in tx.outputs():
+ if o.address in tx.output_info:
+ # this address "is_mine" but might not be change (if I send funds to myself)
+ output_info = tx.output_info.get(o.address)
+ if not output_info.is_change:
+ continue
+ chg_path = output_info.address_index
+ assert chg_path[0] == 1 and len(chg_path) == 2, f"unexpected change path: {chg_path}"
+ pubkey = ks.derive_pubkey(True, chg_path[1])
+ subkeys[bfh(pubkey)] = ks_prefix + pack('<II', *chg_path)
+
+ for txin in inputs:
+ assert txin['type'] != 'coinbase', _("Coinbase not supported")
+
+ if txin['type'] in ['p2sh', 'p2wsh-p2sh', 'p2wsh']:
+ assert type(wallet) is Multisig_Wallet
+
+ # Construct PSBT from start to finish.
+ out_fd = io.BytesIO()
+ out_fd.write(b'psbt\xff')
+
+ def write_kv(ktype, val, key=b''):
+ # serialize helper: write w/ size and key byte
+ out_fd.write(my_var_int(1 + len(key)))
+ out_fd.write(bytes([ktype]) + key)
+
+ if isinstance(val, str):
+ val = bfh(val)
+
+ out_fd.write(my_var_int(len(val)))
+ out_fd.write(val)
+
+
+ # global section: just the unsigned txn
+ class CustomTXSerialization(Transaction):
+ @classmethod
+ def input_script(cls, txin, estimate_size=False):
+ return ''
+
+ unsigned = bfh(CustomTXSerialization(tx.serialize()).serialize_to_network(witness=False))
+ write_kv(PSBT_GLOBAL_UNSIGNED_TX, unsigned)
+
+ if type(wallet) is Multisig_Wallet:
+
+ # always put the xpubs into the PSBT, useful at least for checking
+ for xp, ks in zip(wallet.get_master_public_keys(), wallet.get_keystores()):
+ ks_prefix = packed_xfp_path_for_keystore(ks)
+
+ write_kv(PSBT_GLOBAL_XPUB, ks_prefix, DecodeBase58Check(xp))
+
+ # end globals section
+ out_fd.write(b'\x00')
+
+ # inputs section
+ for txin in inputs:
+ if Transaction.is_segwit_input(txin):
+ utxo = txin['prev_tx'].outputs()[txin['prevout_n']]
+ spendable = txin['prev_tx'].serialize_output(utxo)
+ write_kv(PSBT_IN_WITNESS_UTXO, spendable)
+ else:
+ write_kv(PSBT_IN_NON_WITNESS_UTXO, str(txin['prev_tx']))
+
+ pubkeys, x_pubkeys = tx.get_sorted_pubkeys(txin)
+
+ pubkeys = [bfh(k) for k in pubkeys]
+
+ if type(wallet) is Multisig_Wallet:
+ # always need a redeem script for multisig
+ scr = Transaction.get_preimage_script(txin)
+
+ if Transaction.is_segwit_input(txin):
+ # needed for both p2wsh-p2sh and p2wsh
+ write_kv(PSBT_IN_WITNESS_SCRIPT, bfh(scr))
+ else:
+ write_kv(PSBT_IN_REDEEM_SCRIPT, bfh(scr))
+
+ sigs = txin.get('signatures')
+
+ for pk_pos, (pubkey, x_pubkey) in enumerate(zip(pubkeys, x_pubkeys)):
+ if pubkey in subkeys:
+ # faster? case ... calculated above
+ write_kv(PSBT_IN_BIP32_DERIVATION, subkeys[pubkey], pubkey)
+ else:
+ # when an input is partly signed, tx.get_tx_derivations()
+ # doesn't include that keystore's value and yet we need it
+ # because we need to show a correct keypath...
+ assert x_pubkey[0:2] == 'ff', x_pubkey
+
+ for ks in wallet.get_keystores():
+ d = ks.get_pubkey_derivation(x_pubkey)
+ if d is not None:
+ ks_path = packed_xfp_path_for_keystore(ks, d)
+ write_kv(PSBT_IN_BIP32_DERIVATION, ks_path, pubkey)
+ break
+ else:
+ raise AssertionError("no keystore for: %s" % x_pubkey)
+
+ if txin['type'] == 'p2wpkh-p2sh':
+ assert len(pubkeys) == 1, 'can be only one redeem script per input'
+ pa = hash_160(pubkey)
+ assert len(pa) == 20
+ write_kv(PSBT_IN_REDEEM_SCRIPT, b'\x00\x14'+pa)
+
+ # optional? insert (partial) signatures that we already have
+ if sigs and sigs[pk_pos]:
+ write_kv(PSBT_IN_PARTIAL_SIG, bfh(sigs[pk_pos]), pubkey)
+
+ out_fd.write(b'\x00')
+
+ # outputs section
+ for o in tx.outputs():
+ # can be empty, but must be present, and helpful to show change inputs
+ # wallet.add_hw_info() adds some data about change outputs into tx.output_info
+ if o.address in tx.output_info:
+ # this address "is_mine" but might not be change (if I send funds to myself)
+ output_info = tx.output_info.get(o.address)
+ if output_info.is_change:
+ pubkeys = [bfh(i) for i in wallet.get_public_keys(o.address)]
+
+ # Add redeem/witness script?
+ if type(wallet) is Multisig_Wallet:
+ # always need a redeem script for multisig cases
+ scr = bfh(multisig_script([bh2u(i) for i in sorted(pubkeys)], wallet.m))
+
+ if output_info.script_type == 'p2wsh-p2sh':
+ write_kv(PSBT_OUT_WITNESS_SCRIPT, scr)
+ write_kv(PSBT_OUT_REDEEM_SCRIPT, b'\x00\x20' + sha256(scr))
+ elif output_info.script_type == 'p2wsh':
+ write_kv(PSBT_OUT_WITNESS_SCRIPT, scr)
+ elif output_info.script_type == 'p2sh':
+ write_kv(PSBT_OUT_REDEEM_SCRIPT, scr)
+ else:
+ raise ValueError(output_info.script_type)
+
+ elif output_info.script_type == 'p2wpkh-p2sh':
+ # need a redeem script when P2SH is used to wrap p2wpkh
+ assert len(pubkeys) == 1
+ pa = hash_160(pubkeys[0])
+ write_kv(PSBT_OUT_REDEEM_SCRIPT, b'\x00\x14' + pa)
+
+ # Document change output's bip32 derivation(s)
+ for pubkey in pubkeys:
+ sk = subkeys[pubkey]
+ write_kv(PSBT_OUT_BIP32_DERIVATION, sk, pubkey)
+
+ out_fd.write(b'\x00')
+
+ # capture for later use
+ tx.raw_psbt = out_fd.getvalue()
+
+ return tx.raw_psbt
+
+
+def recover_tx_from_psbt(first: BasicPSBT, wallet: Abstract_Wallet) -> Transaction:
+ # Take a PSBT object and re-construct the Electrum transaction object.
+ # - does not include signatures, see merge_sigs_from_psbt
+ # - any PSBT in the group could be used for this purpose; all must share tx details
+
+ tx = Transaction(first.txn.hex())
+ tx.deserialize(force_full_parse=True)
+
+ # .. add back some data that's been preserved in the PSBT, but isn't part of
+ # of the unsigned bitcoin txn
+ tx.is_partial_originally = True
+
+ for idx, inp in enumerate(tx.inputs()):
+ scr = first.inputs[idx].redeem_script or first.inputs[idx].witness_script
+
+ # XXX should use transaction.py parse_scriptSig() here!
+ if scr:
+ try:
+ M, N, __, pubkeys, __ = parse_redeemScript_multisig(scr)
+ except NotRecognizedRedeemScript:
+ # limitation: we can only handle M-of-N multisig here
+ raise ValueError("Cannot handle non M-of-N multisig input")
+
+ inp['pubkeys'] = pubkeys
+ inp['x_pubkeys'] = pubkeys
+ inp['num_sig'] = M
+ inp['type'] = 'p2wsh' if first.inputs[idx].witness_script else 'p2sh'
+
+ # bugfix: transaction.py:parse_input() puts empty dict here, but need a list
+ inp['signatures'] = [None] * N
+
+ if 'prev_tx' not in inp:
+ # fetch info about inputs' previous txn
+ wallet.add_hw_info(tx)
+
+ if 'value' not in inp:
+ # we'll need to know the value of the outpts used as part
+ # of the witness data, much later...
+ inp['value'] = inp['prev_tx'].outputs()[inp['prevout_n']].value
+
+ return tx
+
+def merge_sigs_from_psbt(tx: Transaction, psbt: BasicPSBT):
+ # Take new signatures from PSBT, and merge into in-memory transaction object.
+ # - "we trust everyone here" ... no validation/checks
+
+ count = 0
+ for inp_idx, inp in enumerate(psbt.inputs):
+ if not inp.part_sigs:
+ continue
+
+ scr = inp.redeem_script or inp.witness_script
+
+ # need to map from pubkey to signing position in redeem script
+ M, N, _, pubkeys, _ = parse_redeemScript_multisig(scr)
+ #assert (M, N) == (wallet.m, wallet.n)
+
+ for sig_pk in inp.part_sigs:
+ pk_pos = pubkeys.index(sig_pk.hex())
+ tx.add_signature_to_txin(inp_idx, pk_pos, inp.part_sigs[sig_pk].hex())
+ count += 1
+
+ #print("#%d: sigs = %r" % (inp_idx, tx.inputs()[inp_idx]['signatures']))
+
+ # reset serialization of TX
+ tx.raw = tx.serialize()
+ tx.raw_psbt = None
+
+ return count
+
+# EOF
+
diff --git a/electrum/plugins/coldcard/coldcard.py b/electrum/plugins/coldcard/coldcard.py
@@ -8,18 +8,20 @@ import traceback
from electrum.bip32 import BIP32Node, InvalidMasterKeyVersionBytes
from electrum.i18n import _
-from electrum.plugin import Device
-from electrum.keystore import Hardware_KeyStore, xpubkey_to_pubkey, Xpub
-from electrum.transaction import Transaction
-from electrum.wallet import Standard_Wallet
-from electrum.crypto import hash_160
+from electrum.plugin import Device, hook
+from electrum.keystore import Hardware_KeyStore
+from electrum.transaction import Transaction, multisig_script
+from electrum.wallet import Standard_Wallet, Multisig_Wallet
from electrum.util import bfh, bh2u, versiontuple, UserFacingException
from electrum.base_wizard import ScriptTypeNotSupported
from electrum.logging import get_logger
from ..hw_wallet import HW_PluginBase
-from ..hw_wallet.plugin import LibraryFoundButUnusable
+from ..hw_wallet.plugin import LibraryFoundButUnusable, only_hook_if_libraries_available
+from .basic_psbt import BasicPSBT
+from .build_psbt import (build_psbt, xfp2str, unpacked_xfp_path,
+ merge_sigs_from_psbt, xfp_for_keystore)
_logger = get_logger(__name__)
@@ -30,10 +32,6 @@ try:
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.constants import (
- PSBT_GLOBAL_UNSIGNED_TX, PSBT_IN_NON_WITNESS_UTXO, PSBT_IN_WITNESS_UTXO,
- PSBT_IN_SIGHASH_TYPE, PSBT_IN_REDEEM_SCRIPT, PSBT_IN_WITNESS_SCRIPT,
- PSBT_IN_BIP32_DERIVATION, PSBT_OUT_BIP32_DERIVATION, PSBT_OUT_REDEEM_SCRIPT)
from ckcc.client import ColdcardDevice, COINKITE_VID, CKCC_PID, CKCC_SIMULATOR_PATH
@@ -60,26 +58,6 @@ except ImportError:
CKCC_SIMULATED_PID = CKCC_PID ^ 0x55aa
-def my_var_int(l):
- # Bitcoin serialization of integers... directly into binary!
- if l < 253:
- return pack("B", l)
- elif l < 0x10000:
- return pack("<BH", 253, l)
- elif l < 0x100000000:
- return pack("<BI", 254, l)
- else:
- return pack("<BQ", 255, l)
-
-def xfp_from_xpub(xpub):
- # sometime we need to BIP32 fingerprint value: 4 bytes of ripemd(sha256(pubkey))
- # UNTESTED
- kk = bfh(Xpub.get_pubkey_from_xpub(xpub, []))
- assert len(kk) == 33
- xfp, = unpack('<I', hash_160(kk)[0:4])
- return xfp
-
-
class CKCCClient:
# Challenge: I haven't found anywhere that defines a base class for this 'client',
# nor an API (interface) to be met. Winging it. Gets called from lib/plugins.py mostly?
@@ -105,24 +83,27 @@ class CKCCClient:
# should expect. It's also kinda slow.
def __repr__(self):
- return '<CKCCClient: xfp=%08x label=%r>' % (self.dev.master_fingerprint,
+ return '<CKCCClient: xfp=%s label=%r>' % (xfp2str(self.dev.master_fingerprint),
self.label())
- def verify_connection(self, expected_xfp, expected_xpub):
+ def verify_connection(self, expected_xfp, expected_xpub=None):
ex = (expected_xfp, expected_xpub)
if self._expected_device == ex:
# all is as expected
return
+ if expected_xpub is None:
+ expected_xpub = self.dev.master_xpub
+
if ( (self._expected_device is not None)
or (self.dev.master_fingerprint != expected_xfp)
or (self.dev.master_xpub != expected_xpub)):
# probably indicating programing error, not hacking
_logger.info(f"xpubs. reported by device: {self.dev.master_xpub}. "
f"stored in file: {expected_xpub}")
- raise RuntimeError("Expecting 0x%08x but that's not what's connected?!" %
- expected_xfp)
+ raise RuntimeError("Expecting %s but that's not what's connected?!" %
+ xfp2str(expected_xfp))
# check signature over session key
# - mitm might have lied about xfp and xpub up to here
@@ -132,10 +113,13 @@ class CKCCClient:
self._expected_device = ex
+ if not getattr(self, 'ckcc_xpub', None):
+ self.ckcc_xpub = expected_xpub
+
_logger.info("Successfully verified against MiTM")
def is_pairable(self):
- # can't do anything w/ devices that aren't setup (but not normally reachable)
+ # can't do anything w/ devices that aren't setup (this code not normally reachable)
return bool(self.dev.master_xpub)
def timeout(self, cutoff):
@@ -155,12 +139,12 @@ class CKCCClient:
# not be encrypted, so better for privacy if based on xpub/fingerprint rather than
# USB serial number.
if self.dev.is_simulator:
- lab = 'Coldcard Simulator 0x%08x' % self.dev.master_fingerprint
+ lab = 'Coldcard Simulator ' + xfp2str(self.dev.master_fingerprint)
elif not self.dev.master_fingerprint:
# failback; not expected
lab = 'Coldcard #' + self.dev.serial
else:
- lab = 'Coldcard 0x%08x' % self.dev.master_fingerprint
+ lab = 'Coldcard ' + xfp2str(self.dev.master_fingerprint)
# Hack zone: during initial setup I need the xfp and master xpub but
# very few objects are passed between the various steps of base_wizard.
@@ -210,9 +194,13 @@ class CKCCClient:
raise RuntimeError("Communication trouble with Coldcard")
def show_address(self, path, addr_fmt):
- # prompt user w/ addres, also returns it immediately.
+ # prompt user w/ address, also returns it immediately.
return self.dev.send_recv(CCProtocolPacker.show_address(path, addr_fmt), timeout=None)
+ def show_p2sh_address(self, *args, **kws):
+ # prompt user w/ p2sh address, also returns it immediately.
+ return self.dev.send_recv(CCProtocolPacker.show_p2sh_address(*args, **kws), timeout=None)
+
def get_version(self):
# gives list of strings
return self.dev.send_recv(CCProtocolPacker.version(), timeout=1000).split('\n')
@@ -262,22 +250,27 @@ class Coldcard_KeyStore(Hardware_KeyStore):
self.force_watching_only = False
self.ux_busy = False
+ # for multisig I need to know what wallet this keystore is part of
+ # will be set by link_wallet
+ self.my_wallet = None
+
# Seems like only the derivation path and resulting **derived** xpub is stored in
# the wallet file... however, we need to know at least the fingerprint of the master
# xpub to verify against MiTM, and also so we can put the right value into the subkey paths
# of PSBT files that might be generated offline.
# - save the fingerprint of the master xpub, as "xfp"
- # - it's a LE32 int, but hex more natural way to see it
+ # - it's a LE32 int, but hex BE32 is more natural way to view it
# - device reports these value during encryption setup process
+ # - full xpub value now optional
lab = d['label']
if hasattr(lab, 'xfp'):
# initial setup
self.ckcc_xfp = lab.xfp
- self.ckcc_xpub = lab.xpub
+ self.ckcc_xpub = getattr(lab, 'xpub', None)
else:
# wallet load: fatal if missing, we need them!
self.ckcc_xfp = d['ckcc_xfp']
- self.ckcc_xpub = d['ckcc_xpub']
+ self.ckcc_xpub = d.get('ckcc_xpub', None)
def dump(self):
# our additions to the stored data about keystore -- only during creation?
@@ -294,6 +287,7 @@ class Coldcard_KeyStore(Hardware_KeyStore):
def get_client(self):
# called when user tries to do something like view address, sign somthing.
# - not called during probing/setup
+ # - will fail if indicated device can't produce the xpub (at derivation) expected
rv = self.plugin.get_client(self)
if rv:
rv.verify_connection(self.ckcc_xfp, self.ckcc_xpub)
@@ -377,162 +371,29 @@ class Coldcard_KeyStore(Hardware_KeyStore):
# give empty bytes for error cases; it seems to clear the old signature box
return b''
- def build_psbt(self, tx: Transaction, wallet=None, xfp=None):
- # Render a PSBT file, for upload to Coldcard.
- #
- if xfp is None:
- # need fingerprint of MASTER xpub, not the derived key
- xfp = self.ckcc_xfp
-
- inputs = tx.inputs()
-
- if 'prev_tx' not in inputs[0]:
- # fetch info about inputs, if needed?
- # - needed during export PSBT flow, not normal online signing
- assert wallet, 'need wallet reference'
- wallet.add_hw_info(tx)
-
- # wallet.add_hw_info installs this attr
- assert tx.output_info is not None, 'need data about outputs'
-
- # Build map of pubkey needed as derivation from master, in PSBT binary format
- # 1) binary version of the common subpath for all keys
- # m/ => fingerprint LE32
- # a/b/c => ints
- base_path = pack('<I', xfp)
- for x in self.get_derivation()[2:].split('/'):
- if x.endswith("'"):
- x = int(x[:-1]) | 0x80000000
- else:
- x = int(x)
- base_path += pack('<I', x)
-
- # 2) all used keys in transaction
- subkeys = {}
- derivations = self.get_tx_derivations(tx)
- for xpubkey in derivations:
- pubkey = xpubkey_to_pubkey(xpubkey)
-
- # assuming depth two, non-harded: change + index
- aa, bb = derivations[xpubkey]
- assert 0 <= aa < 0x80000000
- assert 0 <= bb < 0x80000000
-
- subkeys[bfh(pubkey)] = base_path + pack('<II', aa, bb)
-
- for txin in inputs:
- if txin['type'] == 'coinbase':
- self.give_error("Coinbase not supported")
-
- if txin['type'] in ['p2sh', 'p2wsh-p2sh', 'p2wsh']:
- self.give_error('No support yet for inputs of type: ' + txin['type'])
-
- # Construct PSBT from start to finish.
- out_fd = io.BytesIO()
- out_fd.write(b'psbt\xff')
-
- def write_kv(ktype, val, key=b''):
- # serialize helper: write w/ size and key byte
- out_fd.write(my_var_int(1 + len(key)))
- out_fd.write(bytes([ktype]) + key)
-
- if isinstance(val, str):
- val = bfh(val)
-
- out_fd.write(my_var_int(len(val)))
- out_fd.write(val)
-
-
- # global section: just the unsigned txn
- class CustomTXSerialization(Transaction):
- @classmethod
- def input_script(cls, txin, estimate_size=False):
- return ''
-
- unsigned = bfh(CustomTXSerialization(tx.serialize()).serialize_to_network(witness=False))
- write_kv(PSBT_GLOBAL_UNSIGNED_TX, unsigned)
-
- # end globals section
- out_fd.write(b'\x00')
-
- # inputs section
- for txin in inputs:
- if Transaction.is_segwit_input(txin):
- utxo = txin['prev_tx'].outputs()[txin['prevout_n']]
- spendable = txin['prev_tx'].serialize_output(utxo)
- write_kv(PSBT_IN_WITNESS_UTXO, spendable)
- else:
- write_kv(PSBT_IN_NON_WITNESS_UTXO, str(txin['prev_tx']))
-
- pubkeys, x_pubkeys = tx.get_sorted_pubkeys(txin)
-
- pubkeys = [bfh(k) for k in pubkeys]
-
- for k in pubkeys:
- write_kv(PSBT_IN_BIP32_DERIVATION, subkeys[k], k)
-
- if txin['type'] == 'p2wpkh-p2sh':
- assert len(pubkeys) == 1, 'can be only one redeem script per input'
- pa = hash_160(k)
- assert len(pa) == 20
- write_kv(PSBT_IN_REDEEM_SCRIPT, b'\x00\x14'+pa)
-
- out_fd.write(b'\x00')
-
- # outputs section
- for o in tx.outputs():
- # can be empty, but must be present, and helpful to show change inputs
- # wallet.add_hw_info() adds some data about change outputs into tx.output_info
- if o.address in tx.output_info:
- # this address "is_mine" but might not be change (I like to sent to myself)
- output_info = tx.output_info.get(o.address)
- index, xpubs = output_info.address_index, output_info.sorted_xpubs
-
- if index[0] == 1 and len(index) == 2:
- # it is a change output (based on our standard derivation path)
- assert len(xpubs) == 1 # not expecting multisig
- xpubkey = xpubs[0]
-
- # document its bip32 derivation in output section
- aa, bb = index
- assert 0 <= aa < 0x80000000
- assert 0 <= bb < 0x80000000
-
- deriv = base_path + pack('<II', aa, bb)
- pubkey = bfh(self.get_pubkey_from_xpub(xpubkey, index))
-
- write_kv(PSBT_OUT_BIP32_DERIVATION, deriv, pubkey)
-
- if output_info.script_type == 'p2wpkh-p2sh':
- pa = hash_160(pubkey)
- assert len(pa) == 20
- write_kv(PSBT_OUT_REDEEM_SCRIPT, b'\x00\x14' + pa)
-
- out_fd.write(b'\x00')
-
- return out_fd.getvalue()
-
-
@wrap_busy
- def sign_transaction(self, tx, password):
+ def sign_transaction(self, tx: Transaction, password):
# Build a PSBT in memory, upload it for signing.
# - we can also work offline (without paired device present)
if tx.is_complete():
return
+ assert self.my_wallet, "Not clear which wallet associated with this Coldcard"
+
client = self.get_client()
assert client.dev.master_fingerprint == self.ckcc_xfp
- raw_psbt = self.build_psbt(tx)
+ # makes PSBT required
+ raw_psbt = build_psbt(tx, self.my_wallet)
- #open('debug.psbt', 'wb').write(out_fd.getvalue())
+ cc_finalize = not (type(self.my_wallet) is Multisig_Wallet)
try:
try:
self.handler.show_message("Authorize Transaction...")
- client.sign_transaction_start(raw_psbt, True)
+ client.sign_transaction_start(raw_psbt, cc_finalize)
while 1:
# How to kill some time, without locking UI?
@@ -545,7 +406,7 @@ class Coldcard_KeyStore(Hardware_KeyStore):
rlen, rsha = resp
# download the resulting txn.
- new_raw = client.download_file(rlen, rsha)
+ raw_resp = client.download_file(rlen, rsha)
finally:
self.handler.finished()
@@ -559,8 +420,18 @@ class Coldcard_KeyStore(Hardware_KeyStore):
self.give_error(e, True)
return
- # trust the coldcard to re-searilize final product right?
- tx.update(bh2u(new_raw))
+ if cc_finalize:
+ # We trust the coldcard to re-serialize final transaction ready to go
+ tx.update(bh2u(raw_resp))
+ else:
+ # apply partial signatures back into txn
+ psbt = BasicPSBT()
+ psbt.parse(raw_resp, client.label())
+
+ merge_sigs_from_psbt(tx, psbt)
+
+ # caller's logic looks at tx now and if it's sufficiently signed,
+ # will send it if that's the user's intent.
@staticmethod
def _encode_txin_type(txin_type):
@@ -593,11 +464,33 @@ class Coldcard_KeyStore(Hardware_KeyStore):
self.logger.exception('')
self.handler.show_error(exc)
+ @wrap_busy
+ def show_p2sh_address(self, M, script, xfp_paths, txin_type):
+ client = self.get_client()
+ addr_fmt = self._encode_txin_type(txin_type)
+ try:
+ try:
+ self.handler.show_message(_("Showing address ..."))
+ dev_addr = client.show_p2sh_address(M, xfp_paths, script, addr_fmt=addr_fmt)
+ # we could double check address here
+ finally:
+ self.handler.finished()
+ except CCProtoError as exc:
+ self.logger.exception('Error showing address')
+ self.handler.show_error('{}.\n{}\n\n{}'.format(
+ _('Error showing address'),
+ _('Make sure you have imported the correct wallet description '
+ 'file on the device for this multisig wallet.'),
+ str(exc)))
+ except BaseException as exc:
+ self.logger.exception('')
+ self.handler.show_error(exc)
+
class ColdcardPlugin(HW_PluginBase):
keystore_class = Coldcard_KeyStore
- minimum_library = (0, 7, 2)
+ minimum_library = (0, 7, 7)
client = None
DEVICE_IDS = [
@@ -605,8 +498,7 @@ class ColdcardPlugin(HW_PluginBase):
(COINKITE_VID, CKCC_SIMULATED_PID)
]
- #SUPPORTED_XTYPES = ('standard', 'p2wpkh-p2sh', 'p2wpkh', 'p2wsh-p2sh', 'p2wsh')
- SUPPORTED_XTYPES = ('standard', 'p2wpkh', 'p2wpkh-p2sh')
+ SUPPORTED_XTYPES = ('standard', 'p2wpkh-p2sh', 'p2wpkh', 'p2wsh-p2sh', 'p2wsh')
def __init__(self, parent, config, name):
HW_PluginBase.__init__(self, parent, config, name)
@@ -682,31 +574,109 @@ class ColdcardPlugin(HW_PluginBase):
return xpub
def get_client(self, keystore, force_pair=True):
- # All client interaction should not be in the main GUI thread
+ # Acquire a connection to the hardware device (via USB)
devmgr = self.device_manager()
handler = keystore.handler
with devmgr.hid_lock:
client = devmgr.client_for_keystore(self, handler, keystore, force_pair)
- # returns the client for a given keystore. can use xpub
- #if client:
- # client.used()
+
if client is not None:
client.ping_check()
+
return client
+ @staticmethod
+ def export_ms_wallet(wallet, fp, name):
+ # Build the text file Coldcard needs to understand the multisig wallet
+ # it is participating in. All involved Coldcards can share same file.
+
+ print('# Exported from Electrum', file=fp)
+ print(f'Name: {name:.20s}', file=fp)
+ print(f'Policy: {wallet.m} of {wallet.n}', file=fp)
+ print(f'Format: {wallet.txin_type.upper()}' , file=fp)
+
+ xpubs = []
+ derivs = set()
+ for xp, ks in zip(wallet.get_master_public_keys(), wallet.get_keystores()):
+ xfp = xfp_for_keystore(ks)
+ dd = getattr(ks, 'derivation', 'm')
+
+ xpubs.append( (xfp2str(xfp), xp, dd) )
+ derivs.add(dd)
+
+ # Derivation doesn't matter too much to the Coldcard, since it
+ # uses key path data from PSBT or USB request as needed. However,
+ # if there is a clear value, provide it.
+ if len(derivs) == 1:
+ print("Derivation: " + derivs.pop(), file=fp)
+
+ print('', file=fp)
+
+ assert len(xpubs) == wallet.n
+ for xfp, xp, dd in xpubs:
+ if derivs:
+ # show as a comment if unclear
+ print(f'# derivation: {dd}', file=fp)
+
+ print(f'{xfp}: {xp}\n', file=fp)
+
def show_address(self, wallet, address, keystore=None):
if keystore is None:
keystore = wallet.get_keystore()
if not self.show_address_helper(wallet, address, keystore):
return
+ txin_type = wallet.get_txin_type(address)
+
# Standard_Wallet => not multisig, must be bip32
- if type(wallet) is not Standard_Wallet:
+ if type(wallet) is Standard_Wallet:
+ sequence = wallet.get_address_index(address)
+ keystore.show_address(sequence, txin_type)
+ elif type(wallet) is Multisig_Wallet:
+ # More involved for P2SH/P2WSH addresses: need M, and all public keys, and their
+ # derivation paths. Must construct script, and track fingerprints+paths for
+ # all those keys
+
+ pubkeys = wallet.get_public_keys(address)
+
+ xfps = []
+ for xp, ks in zip(wallet.get_master_public_keys(), wallet.get_keystores()):
+ path = "%s/%d/%d" % (getattr(ks, 'derivation', 'm'),
+ *wallet.get_address_index(address))
+
+ # need master XFP for each co-signers
+ ks_xfp = xfp_for_keystore(ks)
+ xfps.append(unpacked_xfp_path(ks_xfp, path))
+
+ # put into BIP45 (sorted) order
+ pkx = list(sorted(zip(pubkeys, xfps)))
+
+ script = bfh(multisig_script([pk for pk,xfp in pkx], wallet.m))
+
+ keystore.show_p2sh_address(wallet.m, script, [xfp for pk,xfp in pkx], txin_type)
+
+ else:
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)
+ @classmethod
+ def link_wallet(cls, wallet):
+ # PROBLEM: wallet.sign_transaction() does not pass in the wallet to the individual
+ # keystores, and we need to know about our co-signers at that time.
+ # FIXME the keystore needs a reference to the wallet object because
+ # it constructs a PSBT from an electrum tx object inside keystore.sign_transaction.
+ # instead keystore.sign_transaction's API should be changed such that its input
+ # *is* a PSBT and not an electrum tx object
+ for ks in wallet.get_keystores():
+ if type(ks) == Coldcard_KeyStore:
+ if not ks.my_wallet:
+ ks.my_wallet = wallet
+
+ @hook
+ def load_wallet(self, wallet, window):
+ # make sure hook in superclass also runs:
+ if hasattr(super(), 'load_wallet'):
+ super().load_wallet(wallet, window)
+ self.link_wallet(wallet)
# EOF
diff --git a/electrum/plugins/coldcard/qt.py b/electrum/plugins/coldcard/qt.py
@@ -1,18 +1,27 @@
-import time
+import time, os
from functools import partial
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtWidgets import QPushButton, QLabel, QVBoxLayout, QWidget, QGridLayout
+from PyQt5.QtWidgets import QFileDialog
from electrum.i18n import _
from electrum.plugin import hook
-from electrum.wallet import Standard_Wallet
-from electrum.gui.qt.util import WindowModalDialog, CloseButton, get_parent_main_window
+from electrum.wallet import Standard_Wallet, Multisig_Wallet
+from electrum.gui.qt.util import WindowModalDialog, CloseButton, get_parent_main_window, Buttons
+from electrum.transaction import Transaction
-from .coldcard import ColdcardPlugin
+from .coldcard import ColdcardPlugin, xfp2str
from ..hw_wallet.qt import QtHandlerBase, QtPluginBase
from ..hw_wallet.plugin import only_hook_if_libraries_available
+from binascii import a2b_hex
+from base64 import b64encode, b64decode
+
+from .basic_psbt import BasicPSBT
+from .build_psbt import build_psbt, merge_sigs_from_psbt, recover_tx_from_psbt
+
+CC_DEBUG = False
class Plugin(ColdcardPlugin, QtPluginBase):
icon_unpaired = "coldcard_unpaired.png"
@@ -24,22 +33,53 @@ class Plugin(ColdcardPlugin, QtPluginBase):
@only_hook_if_libraries_available
@hook
def receive_menu(self, menu, addrs, wallet):
- if type(wallet) is not Standard_Wallet:
+ # Context menu on each address in the Addresses Tab, right click...
+ if len(addrs) != 1:
+ return
+ for keystore in wallet.get_keystores():
+ if type(keystore) == self.keystore_class:
+ def show_address(keystore=keystore):
+ keystore.thread.add(partial(self.show_address, wallet, addrs[0], keystore=keystore))
+ device_name = "{} ({})".format(self.device, keystore.label)
+ menu.addAction(_("Show on {}").format(device_name), show_address)
+
+ @only_hook_if_libraries_available
+ @hook
+ def wallet_info_buttons(self, main_window, dialog):
+ # user is about to see the "Wallet Information" dialog
+ # - add a button if multisig wallet, and a Coldcard is a cosigner.
+ wallet = main_window.wallet
+
+ if type(wallet) is not Multisig_Wallet:
+ return
+
+ if not any(type(ks) == self.keystore_class for ks in wallet.get_keystores()):
+ # doesn't involve a Coldcard wallet, hide feature
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)
+
+ btn = QPushButton(_("Export for Coldcard"))
+ btn.clicked.connect(lambda unused: self.export_multisig_setup(main_window, wallet))
+
+ return Buttons(btn, CloseButton(dialog))
+
+ def export_multisig_setup(self, main_window, wallet):
+
+ basename = wallet.basename().rsplit('.', 1)[0] # trim .json
+ name = f'{basename}-cc-export.txt'.replace(' ', '-')
+ fileName = main_window.getSaveFileName(_("Select where to save the setup file"),
+ name, "*.txt")
+ if fileName:
+ with open(fileName, "wt") as f:
+ ColdcardPlugin.export_ms_wallet(wallet, f, basename)
+ main_window.show_message(_("Wallet setup file exported successfully"))
@only_hook_if_libraries_available
@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
+ # if not a Coldcard wallet, hide feature
+ if not any(type(ks) == self.keystore_class for ks in dia.wallet.get_keystores()):
return
# - add a new button, near "export"
@@ -60,28 +100,108 @@ class Plugin(ColdcardPlugin, QtPluginBase):
# which we don't support here, so do nothing
return
- # 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)
+ build_psbt(tx, dia.wallet)
- name = (dia.wallet.basename() + time.strftime('-%y%m%d-%H%M.psbt')).replace(' ', '-')
+ name = (dia.wallet.basename() + time.strftime('-%y%m%d-%H%M.psbt'))\
+ .replace(' ', '-').replace('.json', '')
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)
+ f.write(tx.raw_psbt)
dia.show_message(_("Transaction exported successfully"))
dia.saved = True
def show_settings_dialog(self, window, keystore):
# When they click on the icon for CC we come here.
- device_id = self.choose_device(window, keystore)
- if device_id:
- CKCCSettingsDialog(window, self, keystore, device_id).exec_()
+ # - doesn't matter if device not connected, continue
+ CKCCSettingsDialog(window, self, keystore).exec_()
+ @hook
+ def init_menubar_tools(self, main_window, tools_menu):
+ # add some PSBT-related tools to the "Load Transaction" menu.
+ rt = main_window.raw_transaction_menu
+ wallet = main_window.wallet
+ rt.addAction(_("From &PSBT File or Files"), lambda: self.psbt_combiner(main_window, wallet))
+
+ def psbt_combiner(self, window, wallet):
+ title = _("Select the PSBT file to load or PSBT files to combine")
+ directory = ''
+ fnames, __ = QFileDialog.getOpenFileNames(window, title, directory, "PSBT Files (*.psbt)")
+
+ psbts = []
+ for fn in fnames:
+ try:
+ with open(fn, "rb") as f:
+ raw = f.read()
+
+ psbt = BasicPSBT()
+ psbt.parse(raw, fn)
+
+ psbts.append(psbt)
+ except (AssertionError, ValueError, IOError, os.error) as reason:
+ window.show_critical(_("Electrum was unable to open your PSBT file") + "\n" + str(reason), title=_("Unable to read file"))
+ return
+
+ warn = []
+ if not psbts: return # user picked nothing
+
+ # Consistency checks and warnings.
+ try:
+ first = psbts[0]
+ for p in psbts:
+ fn = os.path.split(p.filename)[1]
+
+ assert (p.txn == first.txn), \
+ "All must relate to the same unsigned transaction."
+
+ for idx, inp in enumerate(p.inputs):
+ if not inp.part_sigs:
+ warn.append(fn + ':\n ' + _("No partial signatures found for input #%d") % idx)
+
+ assert first.inputs[idx].redeem_script == inp.redeem_script, "Mismatched redeem scripts"
+ assert first.inputs[idx].witness_script == inp.witness_script, "Mismatched witness"
+
+ except AssertionError as exc:
+ # Fatal errors stop here.
+ window.show_critical(str(exc),
+ title=_("Unable to combine PSBT files, check: ")+p.filename)
+ return
+
+ if warn:
+ # Lots of potential warnings...
+ window.show_warning('\n\n'.join(warn), title=_("PSBT warnings"))
+
+ # Construct an Electrum transaction object from data in first PSBT file.
+ try:
+ tx = recover_tx_from_psbt(first, wallet)
+ except BaseException as exc:
+ if CC_DEBUG:
+ from PyQt5.QtCore import pyqtRemoveInputHook; pyqtRemoveInputHook()
+ import pdb; pdb.post_mortem()
+ window.show_critical(str(exc), title=_("Unable to understand PSBT file"))
+ return
+
+ # Combine the signatures from all the PSBTS (may do nothing if unsigned PSBTs)
+ for p in psbts:
+ try:
+ merge_sigs_from_psbt(tx, p)
+ except BaseException as exc:
+ if CC_DEBUG:
+ from PyQt5.QtCore import pyqtRemoveInputHook; pyqtRemoveInputHook()
+ import pdb; pdb.post_mortem()
+ window.show_critical("Unable to merge signatures: " + str(exc),
+ title=_("Unable to combine PSBT file: ") + p.filename)
+ return
+
+ # Display result, might not be complete yet, but hopefully it's ready to transmit!
+ if len(psbts) == 1:
+ desc = _("From PSBT file: ") + fn
+ else:
+ desc = _("Combined from %d PSBT files") % len(psbts)
+
+ window.show_transaction(tx, desc)
class Coldcard_Handler(QtHandlerBase):
setup_signal = pyqtSignal()
@@ -112,21 +232,25 @@ class Coldcard_Handler(QtHandlerBase):
return
class CKCCSettingsDialog(WindowModalDialog):
- '''This dialog doesn't require a device be paired with a wallet.
- We want users to be able to wipe a device even if they've forgotten
- their PIN.'''
- def __init__(self, window, plugin, keystore, device_id):
+ def __init__(self, window, 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
+ #config = devmgr.config
+ #handler = keystore.handler
self.thread = thread = keystore.thread
+ self.keystore = keystore
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")
@@ -148,13 +272,14 @@ class CKCCSettingsDialog(WindowModalDialog):
y = 3
rows = [
+ ('xfp', _("Master Fingerprint")),
+ ('serial', _("USB Serial")),
('fw_version', _("Firmware Version")),
('fw_built', _("Build Date")),
('bl_version', _("Bootloader")),
- ('xfp', _("Master Fingerprint")),
- ('serial', _("USB Serial")),
]
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.TextSelectableByMouse | Qt.TextSelectableByKeyboard)
@@ -164,7 +289,7 @@ class CKCCSettingsDialog(WindowModalDialog):
y += 1
body_layout.addLayout(grid)
- upg_btn = QPushButton('Upgrade')
+ upg_btn = QPushButton(_('Upgrade'))
#upg_btn.setDefault(False)
def _start_upgrade():
thread.add(connect_and_doit, on_success=self.start_upgrade)
@@ -177,13 +302,22 @@ class CKCCSettingsDialog(WindowModalDialog):
dialog_vbox = QVBoxLayout(self)
dialog_vbox.addWidget(body)
- # Fetch values and show them
- thread.add(connect_and_doit, on_success=self.show_values)
+ # 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' % xfp2str(self.keystore.ckcc_xfp))
+ 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>0x%08x' % dev.master_fingerprint)
+ self.xfp.setText('<tt>%s' % xfp2str(dev.master_fingerprint))
self.serial.setText('<tt>%s' % dev.serial)
# ask device for versions: allow extras for future
diff --git a/electrum/plugins/hw_wallet/qt.py b/electrum/plugins/hw_wallet/qt.py
@@ -265,4 +265,5 @@ class QtPluginBase(object):
else:
addr = uri.get('address')
keystore.thread.add(partial(plugin.show_address, wallet, addr, keystore))
- receive_address_e.addButton("eye1.png", show_address, _("Show on {}").format(plugin.device))
+ dev_name = f"{plugin.device} ({keystore.label})"
+ receive_address_e.addButton("eye1.png", show_address, _("Show on {}").format(dev_name))