Working towards multisig
This commit is contained in:
parent
cb20da5428
commit
cc5c0532e5
@ -8,18 +8,21 @@ import traceback
|
||||
|
||||
from electrum.bip32 import BIP32Node, InvalidMasterKeyVersionBytes
|
||||
from electrum.i18n import _
|
||||
from electrum.plugin import Device
|
||||
from electrum.plugin import Device, hook
|
||||
from electrum.keystore import Hardware_KeyStore, xpubkey_to_pubkey, Xpub
|
||||
from electrum.transaction import Transaction
|
||||
from electrum.wallet import Standard_Wallet
|
||||
from electrum.transaction import Transaction, multisig_script, parse_redeemScript_multisig
|
||||
from electrum.wallet import Standard_Wallet, Multisig_Wallet, Wallet
|
||||
from electrum.crypto import hash_160
|
||||
from electrum.util import bfh, bh2u, versiontuple, UserFacingException
|
||||
from electrum.base_wizard import ScriptTypeNotSupported
|
||||
from electrum.logging import get_logger
|
||||
from electrum.bitcoin import DecodeBase58Check
|
||||
|
||||
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
|
||||
|
||||
_logger = get_logger(__name__)
|
||||
|
||||
@ -30,10 +33,10 @@ 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.constants 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_BIP32_DERIVATION, PSBT_OUT_BIP32_DERIVATION, PSBT_OUT_REDEEM_SCRIPT)
|
||||
|
||||
from ckcc.client import ColdcardDevice, COINKITE_VID, CKCC_PID, CKCC_SIMULATOR_PATH
|
||||
|
||||
@ -60,32 +63,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
|
||||
|
||||
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.
|
||||
from binascii import b2a_hex
|
||||
|
||||
return b2a_hex(pack('>I', xfp)).decode('ascii').upper()
|
||||
|
||||
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?
|
||||
@ -114,13 +91,17 @@ class CKCCClient:
|
||||
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):
|
||||
print("verify")
|
||||
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)):
|
||||
@ -138,7 +119,11 @@ class CKCCClient:
|
||||
|
||||
self._expected_device = ex
|
||||
|
||||
if not getattr(self, 'ckcc_xpub', None):
|
||||
self.ckcc_xpub = expected_xpub
|
||||
|
||||
_logger.info("Successfully verified against MiTM")
|
||||
print("verify OK")
|
||||
|
||||
def is_pairable(self):
|
||||
# can't do anything w/ devices that aren't setup (this code not normally reachable)
|
||||
@ -216,9 +201,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')
|
||||
@ -268,6 +257,9 @@ 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
|
||||
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
|
||||
@ -275,15 +267,16 @@ class Coldcard_KeyStore(Hardware_KeyStore):
|
||||
# - save the fingerprint of the master xpub, as "xfp"
|
||||
# - 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?
|
||||
@ -300,6 +293,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)
|
||||
@ -383,144 +377,8 @@ 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():
|
||||
@ -530,15 +388,16 @@ class Coldcard_KeyStore(Hardware_KeyStore):
|
||||
|
||||
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(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?
|
||||
@ -551,7 +410,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()
|
||||
@ -565,8 +424,39 @@ 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, self.get_label())
|
||||
|
||||
self.merge_psbt(tx, psbt, wallet)
|
||||
|
||||
def merge_psbt(self, tx: Transaction, psbt: BasicPSBT, wallet: Wallet):
|
||||
# Take new signatures from PSBT, and merge into on-going in-memory transaction.
|
||||
# - "we trust everyone here"
|
||||
|
||||
count = 0
|
||||
for inp_idx, inp in enumerate(psbt.inputs):
|
||||
# need to map from pubkey to signing position in redeem script
|
||||
M, N, _, pubkeys, _ = parse_redeemScript_multisig(inp.redeem_script)
|
||||
assert (M, N) == (wallet.M, wallet.N)
|
||||
|
||||
for sig_pk in inp.part_sigs:
|
||||
pk_pos = pubkeys.find(sig_pk)
|
||||
assert pk_pos >= 0, "unknown pubkey?"
|
||||
tx.add_signature_to_txin(inp_idx, pk_pos, sig)
|
||||
count += 1
|
||||
|
||||
assert count, "unable to add any partial sigs"
|
||||
|
||||
# reset / update objs
|
||||
tx.raw = tx.serialize()
|
||||
tx.raw_psbt = None
|
||||
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
def _encode_txin_type(txin_type):
|
||||
@ -599,11 +489,30 @@ 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{}'.format(
|
||||
_('Error showing address') + ':', 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 = [
|
||||
@ -611,8 +520,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)
|
||||
@ -688,31 +596,102 @@ 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)
|
||||
|
||||
xpubs = []
|
||||
derivs = set()
|
||||
for xp, ks in zip(wallet.get_master_public_keys(), wallet.get_keystores()):
|
||||
xfp = getattr(ks, 'ckcc_xfp', None)
|
||||
if xfp is None:
|
||||
xfp = xfp_from_xpub(ks.get_master_public_key())
|
||||
|
||||
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; if it's a coldcard, easy.
|
||||
xfp = getattr(ks, 'ckcc_xfp', None)
|
||||
if xfp is None:
|
||||
xfp = xfp_from_xpub(ks.get_master_public_key())
|
||||
|
||||
xfps.append(unpacked_xfp_path(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)
|
||||
# overloads HW_PluginBase.load_wallet, which we need
|
||||
def XXX_load_wallet(self, wallet, window):
|
||||
# Hook runs when a specific wallet is openned okay; also maybe a new one?
|
||||
# - capture wallet containing keystore
|
||||
for ks in wallet.get_keystores():
|
||||
if type(ks) == Coldcard_KeyStore:
|
||||
ks.my_wallet = wallet
|
||||
|
||||
# EOF
|
||||
|
||||
@ -3,16 +3,22 @@ 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 .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
|
||||
|
||||
class Plugin(ColdcardPlugin, QtPluginBase):
|
||||
icon_unpaired = "coldcard_unpaired.png"
|
||||
@ -24,13 +30,54 @@ class Plugin(ColdcardPlugin, QtPluginBase):
|
||||
@only_hook_if_libraries_available
|
||||
@hook
|
||||
def receive_menu(self, menu, addrs, wallet):
|
||||
if type(wallet) is not Standard_Wallet:
|
||||
return
|
||||
keystore = wallet.get_keystore()
|
||||
# Context menu on each address in the Addresses Tab, right click...
|
||||
|
||||
if type(wallet) is Standard_Wallet:
|
||||
keystore = wallet.get_keystore()
|
||||
else:
|
||||
# find if any devices are connected and ready to go, use first of those.
|
||||
for ks in wallet.get_keystores():
|
||||
if ks.has_usable_connection_with_device():
|
||||
keystore = ks
|
||||
break
|
||||
else:
|
||||
# don't hook into menu
|
||||
return
|
||||
|
||||
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)
|
||||
keystore.thread.add(partial(self.show_address, wallet, addrs[0], keystore=keystore))
|
||||
menu.addAction(_("Show on Coldcard ({})").format(keystore.label), 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
|
||||
|
||||
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
|
||||
@ -65,22 +112,61 @@ class Plugin(ColdcardPlugin, QtPluginBase):
|
||||
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 Tool menu.
|
||||
tools_menu.addSeparator()
|
||||
tools_menu.addAction(_("&Combine PSBT Files"), lambda: self.psbt_combiner(main_window))
|
||||
|
||||
def psbt_combiner(self, window):
|
||||
title = _("Select the signed PSBT files to combine and transmit")
|
||||
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
|
||||
|
||||
# Consistency checks
|
||||
try:
|
||||
assert len(psbts) >= 2, _("Need 2 or more PSBT to be able to combine them.")
|
||||
first = psbts[0]
|
||||
for p in psbts:
|
||||
assert (p.txn == first.txn), \
|
||||
f"All must relate to the same original transaction, check file: {p.filename}"
|
||||
for idx, inp in enumerate(p.inputs):
|
||||
assert inp.part_sigs, f"No partial signatures found in file: {p.filename}"
|
||||
|
||||
except AssertionError as exc:
|
||||
window.show_critical(str(exc), title=_("Unable to combine PSBT files"))
|
||||
return
|
||||
|
||||
|
||||
class Coldcard_Handler(QtHandlerBase):
|
||||
@ -112,21 +198,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 +238,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 +255,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,10 +268,19 @@ 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>%s' % xfp2str(dev.master_fingerprint))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user