tMerge pull request #5721 from SomberNight/201910_psbt - electrum - Electrum Bitcoin wallet HTML git clone https://git.parazyd.org/electrum DIR Log DIR Files DIR Refs DIR Submodules --- DIR commit 707b74d22b28d942c445754311736f158e505990 DIR parent 6d12ebabbb686bcd028b659c6a6ce2cb4782bc14 HTML Author: ThomasV <thomasv@electrum.org> Date: Thu, 7 Nov 2019 17:10:20 +0100 Merge pull request #5721 from SomberNight/201910_psbt integrate PSBT support natively. WIP Diffstat: M electrum/address_synchronizer.py | 94 +++++++++++++++---------------- M electrum/base_wizard.py | 9 ++++++--- M electrum/bip32.py | 71 ++++++++++++++++++++++++++++--- M electrum/bitcoin.py | 24 ++++++++++++++++-------- M electrum/coinchooser.py | 81 +++++++++++++++++-------------- M electrum/commands.py | 97 ++++++++++++++++++------------- M electrum/ecc.py | 10 ++++++++++ M electrum/gui/kivy/main_window.py | 16 ++++++---------- M electrum/gui/kivy/uix/dialogs/__in… | 11 ++++++++--- M electrum/gui/kivy/uix/dialogs/tx_d… | 37 +++++++++++++++++++++++-------- M electrum/gui/kivy/uix/screens.py | 12 +++++------- M electrum/gui/qt/address_dialog.py | 12 ++++++++---- M electrum/gui/qt/main_window.py | 104 ++++++++++++++++---------------- M electrum/gui/qt/paytoedit.py | 40 +++++++++++++++---------------- M electrum/gui/qt/transaction_dialog… | 227 ++++++++++++++++++++++++------- M electrum/gui/qt/util.py | 9 ++++++--- M electrum/gui/qt/utxo_list.py | 48 ++++++++++++++++--------------- M electrum/gui/stdio.py | 9 +++++---- M electrum/gui/text.py | 9 +++++---- M electrum/json_db.py | 72 ++++++++++++++++++++++--------- M electrum/keystore.py | 372 ++++++++++++++++++------------- M electrum/lnchannel.py | 48 ++++++++++++++----------------- M electrum/lnpeer.py | 28 +++++++++++++++------------- M electrum/lnsweep.py | 113 +++++++++++++------------------ M electrum/lnutil.py | 105 +++++++++++++++---------------- M electrum/lnwatcher.py | 6 ++++-- M electrum/lnworker.py | 2 +- M electrum/network.py | 5 +++-- M electrum/paymentrequest.py | 23 ++++++++++++----------- M electrum/plugin.py | 8 ++++++-- M electrum/plugins/audio_modem/qt.py | 8 ++++++-- D electrum/plugins/coldcard/basic_ps… | 313 ------------------------------- D electrum/plugins/coldcard/build_ps… | 397 ------------------------------- M electrum/plugins/coldcard/coldcard… | 169 ++++++++++++------------------- M electrum/plugins/coldcard/qt.py | 145 ++++--------------------------- M electrum/plugins/cosigner_pool/qt.… | 48 ++++++++++++++++---------------- M electrum/plugins/digitalbitbox/dig… | 103 +++++++++++++------------------ M electrum/plugins/digitalbitbox/qt.… | 12 ++++++------ M electrum/plugins/greenaddress_inst… | 9 +++++---- M electrum/plugins/hw_wallet/plugin.… | 47 ++++++++++++++++++++++--------- M electrum/plugins/keepkey/keepkey.py | 220 ++++++++++++++----------------- M electrum/plugins/ledger/ledger.py | 141 +++++++++++++------------------ M electrum/plugins/safe_t/safe_t.py | 220 ++++++++++++++----------------- M electrum/plugins/trezor/trezor.py | 155 ++++++++++++++----------------- M electrum/plugins/trustedcoin/cmdli… | 2 +- A electrum/plugins/trustedcoin/legac… | 106 ++++++++++++++++++++++++++++++ M electrum/plugins/trustedcoin/trust… | 37 ++++++++++++++++++------------- M electrum/scripts/bip70.py | 3 ++- M electrum/segwit_addr.py | 2 ++ M electrum/synchronizer.py | 6 +++--- M electrum/tests/regtest/regtest.sh | 6 +++--- M electrum/tests/test_bitcoin.py | 10 +++++++++- M electrum/tests/test_commands.py | 21 +++++++++++++++++++++ M electrum/tests/test_lnchannel.py | 2 +- M electrum/tests/test_lnutil.py | 11 ++++------- A electrum/tests/test_psbt.py | 269 +++++++++++++++++++++++++++++++ M electrum/tests/test_transaction.py | 289 +++++++++++++++---------------- M electrum/tests/test_wallet.py | 2 +- M electrum/tests/test_wallet_vertica… | 463 +++++++++++++++++++++++-------- M electrum/transaction.py | 2130 ++++++++++++++++++++----------- M electrum/util.py | 6 ++++-- M electrum/wallet.py | 537 ++++++++++++++++++------------- 62 files changed, 4171 insertions(+), 3420 deletions(-) --- DIR diff --git a/electrum/address_synchronizer.py b/electrum/address_synchronizer.py t@@ -29,9 +29,9 @@ from collections import defaultdict from typing import TYPE_CHECKING, Dict, Optional, Set, Tuple, NamedTuple, Sequence from . import bitcoin -from .bitcoin import COINBASE_MATURITY, TYPE_ADDRESS, TYPE_PUBKEY +from .bitcoin import COINBASE_MATURITY from .util import profiler, bfh, TxMinedInfo -from .transaction import Transaction, TxOutput +from .transaction import Transaction, TxOutput, TxInput, PartialTxInput, TxOutpoint, PartialTransaction from .synchronizer import Synchronizer from .verifier import SPV from .blockchain import hash_header t@@ -125,12 +125,12 @@ class AddressSynchronizer(Logger): """Return number of transactions where address is involved.""" return len(self._history_local.get(addr, ())) - def get_txin_address(self, txi) -> Optional[str]: - addr = txi.get('address') - if addr and addr != "(pubkey)": - return addr - prevout_hash = txi.get('prevout_hash') - prevout_n = txi.get('prevout_n') + def get_txin_address(self, txin: TxInput) -> Optional[str]: + if isinstance(txin, PartialTxInput): + if txin.address: + return txin.address + prevout_hash = txin.prevout.txid.hex() + prevout_n = txin.prevout.out_idx for addr in self.db.get_txo_addresses(prevout_hash): l = self.db.get_txo_addr(prevout_hash, addr) for n, v, is_cb in l: t@@ -138,14 +138,8 @@ class AddressSynchronizer(Logger): return addr return None - def get_txout_address(self, txo: TxOutput): - if txo.type == TYPE_ADDRESS: - addr = txo.address - elif txo.type == TYPE_PUBKEY: - addr = bitcoin.public_key_to_p2pkh(bfh(txo.address)) - else: - addr = None - return addr + def get_txout_address(self, txo: TxOutput) -> Optional[str]: + return txo.address def load_unverified_transactions(self): # review transactions that are in the history t@@ -183,7 +177,7 @@ class AddressSynchronizer(Logger): if self.synchronizer: self.synchronizer.add(address) - def get_conflicting_transactions(self, tx_hash, tx, include_self=False): + def get_conflicting_transactions(self, tx_hash, tx: Transaction, include_self=False): """Returns a set of transaction hashes from the wallet history that are directly conflicting with tx, i.e. they have common outpoints being spent with tx. t@@ -194,10 +188,10 @@ class AddressSynchronizer(Logger): conflicting_txns = set() with self.transaction_lock: for txin in tx.inputs(): - if txin['type'] == 'coinbase': + if txin.is_coinbase(): continue - prevout_hash = txin['prevout_hash'] - prevout_n = txin['prevout_n'] + prevout_hash = txin.prevout.txid.hex() + prevout_n = txin.prevout.out_idx spending_tx_hash = self.db.get_spent_outpoint(prevout_hash, prevout_n) if spending_tx_hash is None: continue t@@ -213,7 +207,7 @@ class AddressSynchronizer(Logger): conflicting_txns -= {tx_hash} return conflicting_txns - def add_transaction(self, tx_hash, tx, allow_unrelated=False) -> bool: + def add_transaction(self, tx_hash, tx: Transaction, allow_unrelated=False) -> bool: """Returns whether the tx was successfully added to the wallet history.""" assert tx_hash, tx_hash assert tx, tx t@@ -226,7 +220,7 @@ class AddressSynchronizer(Logger): # BUT we track is_mine inputs in a txn, and during subsequent calls # of add_transaction tx, we might learn of more-and-more inputs of # being is_mine, as we roll the gap_limit forward - is_coinbase = tx.inputs()[0]['type'] == 'coinbase' + is_coinbase = tx.inputs()[0].is_coinbase() tx_height = self.get_tx_height(tx_hash).height if not allow_unrelated: # note that during sync, if the transactions are not properly sorted, t@@ -277,11 +271,11 @@ class AddressSynchronizer(Logger): self._get_addr_balance_cache.pop(addr, None) # invalidate cache return for txi in tx.inputs(): - if txi['type'] == 'coinbase': + if txi.is_coinbase(): continue - prevout_hash = txi['prevout_hash'] - prevout_n = txi['prevout_n'] - ser = prevout_hash + ':%d' % prevout_n + prevout_hash = txi.prevout.txid.hex() + prevout_n = txi.prevout.out_idx + ser = txi.prevout.to_str() self.db.set_spent_outpoint(prevout_hash, prevout_n, tx_hash) add_value_from_prev_output() # add outputs t@@ -310,10 +304,10 @@ class AddressSynchronizer(Logger): if tx is not None: # if we have the tx, this branch is faster for txin in tx.inputs(): - if txin['type'] == 'coinbase': + if txin.is_coinbase(): continue - prevout_hash = txin['prevout_hash'] - prevout_n = txin['prevout_n'] + prevout_hash = txin.prevout.txid.hex() + prevout_n = txin.prevout.out_idx self.db.remove_spent_outpoint(prevout_hash, prevout_n) else: # expensive but always works t@@ -572,7 +566,7 @@ class AddressSynchronizer(Logger): return cached_local_height return self.network.get_local_height() if self.network else self.db.get('stored_height', 0) - def add_future_tx(self, tx, num_blocks): + def add_future_tx(self, tx: Transaction, num_blocks): with self.lock: self.add_transaction(tx.txid(), tx) self.future_tx[tx.txid()] = num_blocks t@@ -649,14 +643,16 @@ class AddressSynchronizer(Logger): if self.is_mine(addr): is_mine = True is_relevant = True - d = self.db.get_txo_addr(txin['prevout_hash'], addr) + d = self.db.get_txo_addr(txin.prevout.txid.hex(), addr) for n, v, cb in d: - if n == txin['prevout_n']: + if n == txin.prevout.out_idx: value = v break else: value = None if value is None: + value = txin.value_sats() + if value is None: is_pruned = True else: v_in += value t@@ -736,23 +732,19 @@ class AddressSynchronizer(Logger): sent[txi] = height return received, sent - def get_addr_utxo(self, address): + def get_addr_utxo(self, address: str) -> Dict[TxOutpoint, PartialTxInput]: coins, spent = self.get_addr_io(address) for txi in spent: coins.pop(txi) out = {} - for txo, v in coins.items(): + for prevout_str, v in coins.items(): tx_height, value, is_cb = v - prevout_hash, prevout_n = txo.split(':') - x = { - 'address':address, - 'value':value, - 'prevout_n':int(prevout_n), - 'prevout_hash':prevout_hash, - 'height':tx_height, - 'coinbase':is_cb - } - out[txo] = x + prevout = TxOutpoint.from_str(prevout_str) + utxo = PartialTxInput(prevout=prevout) + utxo._trusted_address = address + utxo._trusted_value_sats = value + utxo.block_height = tx_height + out[prevout] = utxo return out # return the total amount ever received by an address t@@ -799,7 +791,8 @@ class AddressSynchronizer(Logger): @with_local_height_cached def get_utxos(self, domain=None, *, excluded_addresses=None, - mature_only: bool = False, confirmed_only: bool = False, nonlocal_only: bool = False): + mature_only: bool = False, confirmed_only: bool = False, + nonlocal_only: bool = False) -> Sequence[PartialTxInput]: coins = [] if domain is None: domain = self.get_addresses() t@@ -809,14 +802,15 @@ class AddressSynchronizer(Logger): mempool_height = self.get_local_height() + 1 # height of next block for addr in domain: utxos = self.get_addr_utxo(addr) - for x in utxos.values(): - if confirmed_only and x['height'] <= 0: + for utxo in utxos.values(): + if confirmed_only and utxo.block_height <= 0: continue - if nonlocal_only and x['height'] == TX_HEIGHT_LOCAL: + if nonlocal_only and utxo.block_height == TX_HEIGHT_LOCAL: continue - if mature_only and x['coinbase'] and x['height'] + COINBASE_MATURITY > mempool_height: + if (mature_only and utxo.prevout.is_coinbase() + and utxo.block_height + COINBASE_MATURITY > mempool_height): continue - coins.append(x) + coins.append(utxo) continue return coins DIR diff --git a/electrum/base_wizard.py b/electrum/base_wizard.py t@@ -33,7 +33,7 @@ from typing import List, TYPE_CHECKING, Tuple, NamedTuple, Any, Dict, Optional from . import bitcoin from . import keystore from . import mnemonic -from .bip32 import is_bip32_derivation, xpub_type, normalize_bip32_derivation +from .bip32 import is_bip32_derivation, xpub_type, normalize_bip32_derivation, BIP32Node from .keystore import bip44_derivation, purpose48_derivation from .wallet import (Imported_Wallet, Standard_Wallet, Multisig_Wallet, wallet_types, Wallet, Abstract_Wallet) t@@ -230,7 +230,7 @@ class BaseWizard(Logger): assert bitcoin.is_private_key(pk) txin_type, pubkey = k.import_privkey(pk, None) addr = bitcoin.pubkey_to_address(txin_type, pubkey) - self.data['addresses'][addr] = {'type':txin_type, 'pubkey':pubkey, 'redeem_script':None} + self.data['addresses'][addr] = {'type':txin_type, 'pubkey':pubkey} self.keystores.append(k) else: return self.terminate() t@@ -394,7 +394,7 @@ class BaseWizard(Logger): # For segwit, a custom path is used, as there is no standard at all. default_choice_idx = 2 choices = [ - ('standard', 'legacy multisig (p2sh)', "m/45'/0"), + ('standard', 'legacy multisig (p2sh)', normalize_bip32_derivation("m/45'/0")), ('p2wsh-p2sh', 'p2sh-segwit multisig (p2wsh-p2sh)', purpose48_derivation(0, xtype='p2wsh-p2sh')), ('p2wsh', 'native segwit multisig (p2wsh)', purpose48_derivation(0, xtype='p2wsh')), ] t@@ -420,16 +420,19 @@ class BaseWizard(Logger): from .keystore import hardware_keystore try: xpub = self.plugin.get_xpub(device_info.device.id_, derivation, xtype, self) + root_xpub = self.plugin.get_xpub(device_info.device.id_, 'm', 'standard', self) except ScriptTypeNotSupported: raise # this is handled in derivation_dialog except BaseException as e: self.logger.exception('') self.show_error(e) return + xfp = BIP32Node.from_xkey(root_xpub).calc_fingerprint_of_this_node().hex().lower() d = { 'type': 'hardware', 'hw_type': name, 'derivation': derivation, + 'root_fingerprint': xfp, 'xpub': xpub, 'label': device_info.label, } DIR diff --git a/electrum/bip32.py b/electrum/bip32.py t@@ -3,7 +3,7 @@ # file LICENCE or http://www.opensource.org/licenses/mit-license.php import hashlib -from typing import List, Tuple, NamedTuple, Union, Iterable +from typing import List, Tuple, NamedTuple, Union, Iterable, Sequence, Optional from .util import bfh, bh2u, BitcoinException from . import constants t@@ -116,7 +116,7 @@ class BIP32Node(NamedTuple): eckey: Union[ecc.ECPubkey, ecc.ECPrivkey] chaincode: bytes depth: int = 0 - fingerprint: bytes = b'\x00'*4 + fingerprint: bytes = b'\x00'*4 # as in serialized format, this is the *parent's* fingerprint child_number: bytes = b'\x00'*4 @classmethod t@@ -161,7 +161,18 @@ class BIP32Node(NamedTuple): eckey=ecc.ECPrivkey(master_k), chaincode=master_c) + @classmethod + def from_bytes(cls, b: bytes) -> 'BIP32Node': + if len(b) != 78: + raise Exception(f"unexpected xkey raw bytes len {len(b)} != 78") + xkey = EncodeBase58Check(b) + return cls.from_xkey(xkey) + def to_xprv(self, *, net=None) -> str: + payload = self.to_xprv_bytes(net=net) + return EncodeBase58Check(payload) + + def to_xprv_bytes(self, *, net=None) -> bytes: if not self.is_private(): raise Exception("cannot serialize as xprv; private key missing") payload = (xprv_header(self.xtype, net=net) + t@@ -172,9 +183,13 @@ class BIP32Node(NamedTuple): bytes([0]) + self.eckey.get_secret_bytes()) assert len(payload) == 78, f"unexpected xprv payload len {len(payload)}" - return EncodeBase58Check(payload) + return payload def to_xpub(self, *, net=None) -> str: + payload = self.to_xpub_bytes(net=net) + return EncodeBase58Check(payload) + + def to_xpub_bytes(self, *, net=None) -> bytes: payload = (xpub_header(self.xtype, net=net) + bytes([self.depth]) + self.fingerprint + t@@ -182,7 +197,7 @@ class BIP32Node(NamedTuple): self.chaincode + self.eckey.get_public_key_bytes(compressed=True)) assert len(payload) == 78, f"unexpected xpub payload len {len(payload)}" - return EncodeBase58Check(payload) + return payload def to_xkey(self, *, net=None) -> str: if self.is_private(): t@@ -190,6 +205,12 @@ class BIP32Node(NamedTuple): else: return self.to_xpub(net=net) + def to_bytes(self, *, net=None) -> bytes: + if self.is_private(): + return self.to_xprv_bytes(net=net) + else: + return self.to_xpub_bytes(net=net) + def convert_to_public(self) -> 'BIP32Node': if not self.is_private(): return self t@@ -248,6 +269,12 @@ class BIP32Node(NamedTuple): fingerprint=fingerprint, child_number=child_number) + def calc_fingerprint_of_this_node(self) -> bytes: + """Returns the fingerprint of this node. + Note that self.fingerprint is of the *parent*. + """ + return hash_160(self.eckey.get_public_key_bytes(compressed=True))[0:4] + def xpub_type(x): return BIP32Node.from_xkey(x).xtype t@@ -308,7 +335,7 @@ def convert_bip32_path_to_list_of_uint32(n: str) -> List[int]: return path -def convert_bip32_intpath_to_strpath(path: List[int]) -> str: +def convert_bip32_intpath_to_strpath(path: Sequence[int]) -> str: s = "m/" for child_index in path: if not isinstance(child_index, int): t@@ -336,8 +363,40 @@ def is_bip32_derivation(s: str) -> bool: return True -def normalize_bip32_derivation(s: str) -> str: +def normalize_bip32_derivation(s: Optional[str]) -> Optional[str]: + if s is None: + return None if not is_bip32_derivation(s): raise ValueError(f"invalid bip32 derivation: {s}") ints = convert_bip32_path_to_list_of_uint32(s) return convert_bip32_intpath_to_strpath(ints) + + +def is_all_public_derivation(path: Union[str, Iterable[int]]) -> bool: + """Returns whether all levels in path use non-hardened derivation.""" + if isinstance(path, str): + path = convert_bip32_path_to_list_of_uint32(path) + for child_index in path: + if child_index < 0: + raise ValueError('the bip32 index needs to be non-negative') + if child_index & BIP32_PRIME: + return False + return True + + +def root_fp_and_der_prefix_from_xkey(xkey: str) -> Tuple[Optional[str], Optional[str]]: + """Returns the root bip32 fingerprint and the derivation path from the + root to the given xkey, if they can be determined. Otherwise (None, None). + """ + node = BIP32Node.from_xkey(xkey) + derivation_prefix = None + root_fingerprint = None + assert node.depth >= 0, node.depth + if node.depth == 0: + derivation_prefix = 'm' + root_fingerprint = node.calc_fingerprint_of_this_node().hex().lower() + elif node.depth == 1: + child_number_int = int.from_bytes(node.child_number, 'big') + derivation_prefix = convert_bip32_intpath_to_strpath([child_number_int]) + root_fingerprint = node.fingerprint.hex() + return root_fingerprint, derivation_prefix DIR diff --git a/electrum/bitcoin.py b/electrum/bitcoin.py t@@ -45,6 +45,7 @@ COIN = 100000000 TOTAL_COIN_SUPPLY_LIMIT_IN_BTC = 21000000 # supported types of transaction outputs +# TODO kill these with fire TYPE_ADDRESS = 0 TYPE_PUBKEY = 1 TYPE_SCRIPT = 2 t@@ -237,6 +238,9 @@ def script_num_to_hex(i: int) -> str: def var_int(i: int) -> str: # https://en.bitcoin.it/wiki/Protocol_specification#Variable_length_integer + # https://github.com/bitcoin/bitcoin/blob/efe1ee0d8d7f82150789f1f6840f139289628a2b/src/serialize.h#L247 + # "CompactSize" + assert i >= 0, i if i<0xfd: return int_to_hex(i) elif i<=0xffff: t@@ -372,24 +376,28 @@ def pubkey_to_address(txin_type: str, pubkey: str, *, net=None) -> str: else: raise NotImplementedError(txin_type) -def redeem_script_to_address(txin_type: str, redeem_script: str, *, net=None) -> str: + +# TODO this method is confusingly named +def redeem_script_to_address(txin_type: str, scriptcode: str, *, net=None) -> str: if net is None: net = constants.net if txin_type == 'p2sh': - return hash160_to_p2sh(hash_160(bfh(redeem_script)), net=net) + # given scriptcode is a redeem_script + return hash160_to_p2sh(hash_160(bfh(scriptcode)), net=net) elif txin_type == 'p2wsh': - return script_to_p2wsh(redeem_script, net=net) + # given scriptcode is a witness_script + return script_to_p2wsh(scriptcode, net=net) elif txin_type == 'p2wsh-p2sh': - scriptSig = p2wsh_nested_script(redeem_script) - return hash160_to_p2sh(hash_160(bfh(scriptSig)), net=net) + # given scriptcode is a witness_script + redeem_script = p2wsh_nested_script(scriptcode) + return hash160_to_p2sh(hash_160(bfh(redeem_script)), net=net) else: raise NotImplementedError(txin_type) def script_to_address(script: str, *, net=None) -> str: from .transaction import get_address_from_output_script - t, addr = get_address_from_output_script(bfh(script), net=net) - assert t == TYPE_ADDRESS - return addr + return get_address_from_output_script(bfh(script), net=net) + def address_to_script(addr: str, *, net=None) -> str: if net is None: net = constants.net DIR diff --git a/electrum/coinchooser.py b/electrum/coinchooser.py t@@ -24,11 +24,11 @@ # SOFTWARE. from collections import defaultdict from math import floor, log10 -from typing import NamedTuple, List, Callable +from typing import NamedTuple, List, Callable, Sequence, Union, Dict, Tuple from decimal import Decimal -from .bitcoin import sha256, COIN, TYPE_ADDRESS, is_address -from .transaction import Transaction, TxOutput +from .bitcoin import sha256, COIN, is_address +from .transaction import Transaction, TxOutput, PartialTransaction, PartialTxInput, PartialTxOutput from .util import NotEnoughFunds from .logging import Logger t@@ -73,21 +73,21 @@ class PRNG: class Bucket(NamedTuple): desc: str - weight: int # as in BIP-141 - value: int # in satoshis - effective_value: int # estimate of value left after subtracting fees. in satoshis - coins: List[dict] # UTXOs - min_height: int # min block height where a coin was confirmed - witness: bool # whether any coin uses segwit + weight: int # as in BIP-141 + value: int # in satoshis + effective_value: int # estimate of value left after subtracting fees. in satoshis + coins: List[PartialTxInput] # UTXOs + min_height: int # min block height where a coin was confirmed + witness: bool # whether any coin uses segwit class ScoredCandidate(NamedTuple): penalty: float - tx: Transaction + tx: PartialTransaction buckets: List[Bucket] -def strip_unneeded(bkts, sufficient_funds): +def strip_unneeded(bkts: List[Bucket], sufficient_funds) -> List[Bucket]: '''Remove buckets that are unnecessary in achieving the spend amount''' if sufficient_funds([], bucket_value_sum=0): # none of the buckets are needed t@@ -108,26 +108,27 @@ class CoinChooserBase(Logger): def __init__(self): Logger.__init__(self) - def keys(self, coins): + def keys(self, coins: Sequence[PartialTxInput]) -> Sequence[str]: raise NotImplementedError - def bucketize_coins(self, coins, *, fee_estimator_vb): + def bucketize_coins(self, coins: Sequence[PartialTxInput], *, fee_estimator_vb): keys = self.keys(coins) - buckets = defaultdict(list) + buckets = defaultdict(list) # type: Dict[str, List[PartialTxInput]] for key, coin in zip(keys, coins): buckets[key].append(coin) # fee_estimator returns fee to be paid, for given vbytes. # guess whether it is just returning a constant as follows. constant_fee = fee_estimator_vb(2000) == fee_estimator_vb(200) - def make_Bucket(desc, coins): + def make_Bucket(desc: str, coins: List[PartialTxInput]): witness = any(Transaction.is_segwit_input(coin, guess_for_address=True) for coin in coins) # note that we're guessing whether the tx uses segwit based # on this single bucket weight = sum(Transaction.estimated_input_weight(coin, witness) for coin in coins) - value = sum(coin['value'] for coin in coins) - min_height = min(coin['height'] for coin in coins) + value = sum(coin.value_sats() for coin in coins) + min_height = min(coin.block_height for coin in coins) + assert min_height is not None # the fee estimator is typically either a constant or a linear function, # so the "function:" effective_value(bucket) will be homomorphic for addition # i.e. effective_value(b1) + effective_value(b2) = effective_value(b1 + b2) t@@ -148,10 +149,12 @@ class CoinChooserBase(Logger): return list(map(make_Bucket, buckets.keys(), buckets.values())) - def penalty_func(self, base_tx, *, tx_from_buckets) -> Callable[[List[Bucket]], ScoredCandidate]: + def penalty_func(self, base_tx, *, + tx_from_buckets: Callable[[List[Bucket]], Tuple[PartialTransaction, List[PartialTxOutput]]]) \ + -> Callable[[List[Bucket]], ScoredCandidate]: raise NotImplementedError - def _change_amounts(self, tx, count, fee_estimator_numchange) -> List[int]: + def _change_amounts(self, tx: PartialTransaction, count: int, fee_estimator_numchange) -> List[int]: # Break change up if bigger than max_change output_amounts = [o.value for o in tx.outputs()] # Don't split change of less than 0.02 BTC t@@ -205,7 +208,8 @@ class CoinChooserBase(Logger): return amounts - def _change_outputs(self, tx, change_addrs, fee_estimator_numchange, dust_threshold): + def _change_outputs(self, tx: PartialTransaction, change_addrs, fee_estimator_numchange, + dust_threshold) -> List[PartialTxOutput]: amounts = self._change_amounts(tx, len(change_addrs), fee_estimator_numchange) assert min(amounts) >= 0 assert len(change_addrs) >= len(amounts) t@@ -213,21 +217,23 @@ class CoinChooserBase(Logger): # If change is above dust threshold after accounting for the # size of the change output, add it to the transaction. amounts = [amount for amount in amounts if amount >= dust_threshold] - change = [TxOutput(TYPE_ADDRESS, addr, amount) + change = [PartialTxOutput.from_address_and_value(addr, amount) for addr, amount in zip(change_addrs, amounts)] return change - def _construct_tx_from_selected_buckets(self, *, buckets, base_tx, change_addrs, - fee_estimator_w, dust_threshold, base_weight): + def _construct_tx_from_selected_buckets(self, *, buckets: Sequence[Bucket], + base_tx: PartialTransaction, change_addrs, + fee_estimator_w, dust_threshold, + base_weight) -> Tuple[PartialTransaction, List[PartialTxOutput]]: # make a copy of base_tx so it won't get mutated - tx = Transaction.from_io(base_tx.inputs()[:], base_tx.outputs()[:]) + tx = PartialTransaction.from_io(base_tx.inputs()[:], base_tx.outputs()[:]) tx.add_inputs([coin for b in buckets for coin in b.coins]) tx_weight = self._get_tx_weight(buckets, base_weight=base_weight) # change is sent back to sending address unless specified if not change_addrs: - change_addrs = [tx.inputs()[0]['address']] + change_addrs = [tx.inputs()[0].address] # note: this is not necessarily the final "first input address" # because the inputs had not been sorted at this point assert is_address(change_addrs[0]) t@@ -240,7 +246,7 @@ class CoinChooserBase(Logger): return tx, change - def _get_tx_weight(self, buckets, *, base_weight) -> int: + def _get_tx_weight(self, buckets: Sequence[Bucket], *, base_weight: int) -> int: """Given a collection of buckets, return the total weight of the resulting transaction. base_weight is the weight of the tx that includes the fixed (non-change) t@@ -260,8 +266,9 @@ class CoinChooserBase(Logger): return total_weight - def make_tx(self, coins, inputs, outputs, change_addrs, fee_estimator_vb, - dust_threshold): + def make_tx(self, *, coins: Sequence[PartialTxInput], inputs: List[PartialTxInput], + outputs: List[PartialTxOutput], change_addrs: Sequence[str], + fee_estimator_vb: Callable, dust_threshold: int) -> PartialTransaction: """Select unspent coins to spend to pay outputs. If the change is greater than dust_threshold (after adding the change output to the transaction) it is kept, otherwise none is sent and it is t@@ -276,11 +283,11 @@ class CoinChooserBase(Logger): assert outputs, 'tx outputs cannot be empty' # Deterministic randomness from coins - utxos = [c['prevout_hash'] + str(c['prevout_n']) for c in coins] - self.p = PRNG(''.join(sorted(utxos))) + utxos = [c.prevout.serialize_to_network() for c in coins] + self.p = PRNG(b''.join(sorted(utxos))) # Copy the outputs so when adding change we don't modify "outputs" - base_tx = Transaction.from_io(inputs[:], outputs[:]) + base_tx = PartialTransaction.from_io(inputs[:], outputs[:]) input_value = base_tx.input_value() # Weight of the transaction with no inputs and no change t@@ -331,14 +338,15 @@ class CoinChooserBase(Logger): return tx - def choose_buckets(self, buckets, sufficient_funds, + def choose_buckets(self, buckets: List[Bucket], + sufficient_funds: Callable, penalty_func: Callable[[List[Bucket]], ScoredCandidate]) -> ScoredCandidate: raise NotImplemented('To be subclassed') class CoinChooserRandom(CoinChooserBase): - def bucket_candidates_any(self, buckets, sufficient_funds): + def bucket_candidates_any(self, buckets: List[Bucket], sufficient_funds) -> List[List[Bucket]]: '''Returns a list of bucket sets.''' if not buckets: raise NotEnoughFunds() t@@ -373,7 +381,8 @@ class CoinChooserRandom(CoinChooserBase): candidates = [[buckets[n] for n in c] for c in candidates] return [strip_unneeded(c, sufficient_funds) for c in candidates] - def bucket_candidates_prefer_confirmed(self, buckets, sufficient_funds): + def bucket_candidates_prefer_confirmed(self, buckets: List[Bucket], + sufficient_funds) -> List[List[Bucket]]: """Returns a list of bucket sets preferring confirmed coins. Any bucket can be: t@@ -433,13 +442,13 @@ class CoinChooserPrivacy(CoinChooserRandom): """ def keys(self, coins): - return [coin['address'] for coin in coins] + return [coin.scriptpubkey.hex() for coin in coins] def penalty_func(self, base_tx, *, tx_from_buckets): min_change = min(o.value for o in base_tx.outputs()) * 0.75 max_change = max(o.value for o in base_tx.outputs()) * 1.33 - def penalty(buckets) -> ScoredCandidate: + def penalty(buckets: List[Bucket]) -> ScoredCandidate: # Penalize using many buckets (~inputs) badness = len(buckets) - 1 tx, change_outputs = tx_from_buckets(buckets) DIR diff --git a/electrum/commands.py b/electrum/commands.py t@@ -35,16 +35,17 @@ import asyncio import inspect from functools import wraps, partial from decimal import Decimal -from typing import Optional, TYPE_CHECKING, Dict +from typing import Optional, TYPE_CHECKING, Dict, List from .import util, ecc from .util import bfh, bh2u, format_satoshis, json_decode, json_encode, is_hash256_str, is_hex_str, to_bytes, timestamp_to_datetime from .util import standardize_path from . import bitcoin -from .bitcoin import is_address, hash_160, COIN, TYPE_ADDRESS +from .bitcoin import is_address, hash_160, COIN from .bip32 import BIP32Node from .i18n import _ -from .transaction import Transaction, multisig_script, TxOutput +from .transaction import (Transaction, multisig_script, TxOutput, PartialTransaction, PartialTxOutput, + tx_from_any, PartialTxInput, TxOutpoint) from .paymentrequest import PR_PAID, PR_UNPAID, PR_UNKNOWN, PR_EXPIRED from .synchronizer import Notifier from .wallet import Abstract_Wallet, create_new_wallet, restore_wallet_from_text t@@ -299,11 +300,13 @@ class Commands: async def listunspent(self, wallet: Abstract_Wallet = None): """List unspent outputs. Returns the list of unspent transaction outputs in your wallet.""" - l = copy.deepcopy(wallet.get_utxos()) - for i in l: - v = i["value"] - i["value"] = str(Decimal(v)/COIN) if v is not None else None - return l + coins = [] + for txin in wallet.get_utxos(): + d = txin.to_json() + v = d.pop("value_sats") + d["value"] = str(Decimal(v)/COIN) if v is not None else None + coins.append(d) + return coins @command('n') async def getaddressunspent(self, address): t@@ -320,46 +323,50 @@ class Commands: Outputs must be a list of {'address':address, 'value':satoshi_amount}. """ keypairs = {} - inputs = jsontx.get('inputs') - outputs = jsontx.get('outputs') + inputs = [] # type: List[PartialTxInput] locktime = jsontx.get('lockTime', 0) - for txin in inputs: - if txin.get('output'): - prevout_hash, prevout_n = txin['output'].split(':') - txin['prevout_n'] = int(prevout_n) - txin['prevout_hash'] = prevout_hash - sec = txin.get('privkey') + for txin_dict in jsontx.get('inputs'): + if txin_dict.get('prevout_hash') is not None and txin_dict.get('prevout_n') is not None: + prevout = TxOutpoint(txid=bfh(txin_dict['prevout_hash']), out_idx=int(txin_dict['prevout_n'])) + elif txin_dict.get('output'): + prevout = TxOutpoint.from_str(txin_dict['output']) + else: + raise Exception("missing prevout for txin") + txin = PartialTxInput(prevout=prevout) + txin._trusted_value_sats = int(txin_dict['value']) + sec = txin_dict.get('privkey') if sec: txin_type, privkey, compressed = bitcoin.deserialize_privkey(sec) pubkey = ecc.ECPrivkey(privkey).get_public_key_hex(compressed=compressed) keypairs[pubkey] = privkey, compressed - txin['type'] = txin_type - txin['x_pubkeys'] = [pubkey] - txin['signatures'] = [None] - txin['num_sig'] = 1 - - outputs = [TxOutput(TYPE_ADDRESS, x['address'], int(x['value'])) for x in outputs] - tx = Transaction.from_io(inputs, outputs, locktime=locktime) + txin.script_type = txin_type + txin.pubkeys = [bfh(pubkey)] + txin.num_sig = 1 + inputs.append(txin) + + outputs = [PartialTxOutput.from_address_and_value(txout['address'], int(txout['value'])) + for txout in jsontx.get('outputs')] + tx = PartialTransaction.from_io(inputs, outputs, locktime=locktime) tx.sign(keypairs) - return tx.as_dict() + return tx.serialize() @command('wp') async def signtransaction(self, tx, privkey=None, password=None, wallet: Abstract_Wallet = None): """Sign a transaction. The wallet keys will be used unless a private key is provided.""" - tx = Transaction(tx) + tx = PartialTransaction(tx) if privkey: txin_type, privkey2, compressed = bitcoin.deserialize_privkey(privkey) pubkey = ecc.ECPrivkey(privkey2).get_public_key_bytes(compressed=compressed).hex() tx.sign({pubkey:(privkey2, compressed)}) else: wallet.sign_transaction(tx, password) - return tx.as_dict() + return tx.serialize() @command('') async def deserialize(self, tx): """Deserialize a serialized transaction""" - tx = Transaction(tx) - return tx.deserialize(force_full_parse=True) + tx = tx_from_any(tx) + return tx.to_json() @command('n') async def broadcast(self, tx): t@@ -392,9 +399,9 @@ class Commands: if isinstance(address, str): address = address.strip() if is_address(address): - return wallet.export_private_key(address, password)[0] + return wallet.export_private_key(address, password) domain = address - return [wallet.export_private_key(address, password)[0] for address in domain] + return [wallet.export_private_key(address, password) for address in domain] @command('w') async def ismine(self, address, wallet: Abstract_Wallet = None): t@@ -513,8 +520,13 @@ class Commands: privkeys = privkey.split() self.nocheck = nocheck #dest = self._resolver(destination) - tx = sweep(privkeys, self.network, self.config, destination, tx_fee, imax) - return tx.as_dict() if tx else None + tx = sweep(privkeys, + network=self.network, + config=self.config, + to_address=destination, + fee=tx_fee, + imax=imax) + return tx.serialize() if tx else None @command('wp') async def signmessage(self, address, message, password=None, wallet: Abstract_Wallet = None): t@@ -541,17 +553,20 @@ class Commands: for address, amount in outputs: address = self._resolver(address, wallet) amount = satoshis(amount) - final_outputs.append(TxOutput(TYPE_ADDRESS, address, amount)) + final_outputs.append(PartialTxOutput.from_address_and_value(address, amount)) coins = wallet.get_spendable_coins(domain_addr) if domain_coins is not None: - coins = [coin for coin in coins if (coin['prevout_hash'] + ':' + str(coin['prevout_n']) in domain_coins)] + coins = [coin for coin in coins if (coin.prevout.to_str() in domain_coins)] if feerate is not None: fee_per_kb = 1000 * Decimal(feerate) fee_estimator = partial(SimpleConfig.estimate_fee_for_feerate, fee_per_kb) else: fee_estimator = fee - tx = wallet.make_unsigned_transaction(coins, final_outputs, fee_estimator, change_addr) + tx = wallet.make_unsigned_transaction(coins=coins, + outputs=final_outputs, + fee=fee_estimator, + change_addr=change_addr) if locktime is not None: tx.locktime = locktime if rbf is None: t@@ -581,7 +596,7 @@ class Commands: rbf=rbf, password=password, locktime=locktime) - return tx.as_dict() + return tx.serialize() @command('wp') async def paytomany(self, outputs, fee=None, feerate=None, from_addr=None, from_coins=None, change_addr=None, t@@ -602,7 +617,7 @@ class Commands: rbf=rbf, password=password, locktime=locktime) - return tx.as_dict() + return tx.serialize() @command('w') async def onchain_history(self, year=None, show_addresses=False, show_fiat=False, wallet: Abstract_Wallet = None): t@@ -703,7 +718,7 @@ class Commands: raise Exception("Unknown transaction") if tx.txid() != txid: raise Exception("Mismatching txid") - return tx.as_dict() + return tx.serialize() @command('') async def encrypt(self, pubkey, message) -> str: t@@ -960,7 +975,7 @@ class Commands: chan_id, _ = channel_id_from_funding_tx(txid, int(index)) chan = wallet.lnworker.channels[chan_id] tx = chan.force_close_tx() - return tx.as_dict() + return tx.serialize() def eval_bool(x: str) -> bool: if x == 'false': return False t@@ -1037,7 +1052,7 @@ command_options = { # don't use floats because of rounding errors -from .transaction import tx_from_str +from .transaction import convert_tx_str_to_hex json_loads = lambda x: json.loads(x, parse_float=lambda x: str(Decimal(x))) arg_types = { 'num': int, t@@ -1046,7 +1061,7 @@ arg_types = { 'year': int, 'from_height': int, 'to_height': int, - 'tx': tx_from_str, + 'tx': convert_tx_str_to_hex, 'pubkeys': json_loads, 'jsontx': json_loads, 'inputs': json_loads, DIR diff --git a/electrum/ecc.py b/electrum/ecc.py t@@ -25,6 +25,7 @@ import base64 import hashlib +import functools from typing import Union, Tuple, Optional import ecdsa t@@ -181,6 +182,7 @@ class _PubkeyForPointAtInfinity: point = ecdsa.ellipticcurve.INFINITY +@functools.total_ordering class ECPubkey(object): def __init__(self, b: Optional[bytes]): t@@ -257,6 +259,14 @@ class ECPubkey(object): def __ne__(self, other): return not (self == other) + def __hash__(self): + return hash(self._pubkey.point.x()) + + def __lt__(self, other): + if not isinstance(other, ECPubkey): + raise TypeError('comparison not defined for ECPubkey and {}'.format(type(other))) + return self._pubkey.point.x() < other._pubkey.point.x() + def verify_message_for_address(self, sig65: bytes, message: bytes, algo=lambda x: sha256d(msg_magic(x))) -> None: assert_bytes(message) h = algo(message) DIR diff --git a/electrum/gui/kivy/main_window.py b/electrum/gui/kivy/main_window.py t@@ -9,7 +9,6 @@ import threading import asyncio from typing import TYPE_CHECKING, Optional -from electrum.bitcoin import TYPE_ADDRESS from electrum.storage import WalletStorage, StorageReadWriteError from electrum.wallet import Wallet, InternalAddressCorruption from electrum.util import profiler, InvalidPassword, send_exception_to_crash_reporter t@@ -398,12 +397,9 @@ class ElectrumWindow(App): self.set_ln_invoice(data) return # try to decode transaction - from electrum.transaction import Transaction - from electrum.util import bh2u + from electrum.transaction import tx_from_any try: - text = bh2u(base_decode(data, None, base=43)) - tx = Transaction(text) - tx.deserialize() + tx = tx_from_any(data) except: tx = None if tx: t@@ -855,7 +851,7 @@ class ElectrumWindow(App): self._trigger_update_status() def get_max_amount(self): - from electrum.transaction import TxOutput + from electrum.transaction import PartialTxOutput if run_hook('abort_send', self): return '' inputs = self.wallet.get_spendable_coins(None) t@@ -866,9 +862,9 @@ class ElectrumWindow(App): addr = str(self.send_screen.screen.address) if not addr: addr = self.wallet.dummy_address() - outputs = [TxOutput(TYPE_ADDRESS, addr, '!')] + outputs = [PartialTxOutput.from_address_and_value(addr, '!')] try: - tx = self.wallet.make_unsigned_transaction(inputs, outputs) + tx = self.wallet.make_unsigned_transaction(coins=inputs, outputs=outputs) except NoDynamicFeeEstimates as e: Clock.schedule_once(lambda dt, bound_e=e: self.show_error(str(bound_e))) return '' t@@ -1199,7 +1195,7 @@ class ElectrumWindow(App): if not self.wallet.can_export(): return try: - key = str(self.wallet.export_private_key(addr, password)[0]) + key = str(self.wallet.export_private_key(addr, password)) pk_label.data = key except InvalidPassword: self.show_error("Invalid PIN") DIR diff --git a/electrum/gui/kivy/uix/dialogs/__init__.py b/electrum/gui/kivy/uix/dialogs/__init__.py t@@ -1,3 +1,5 @@ +from typing import TYPE_CHECKING, Sequence + from kivy.app import App from kivy.clock import Clock from kivy.factory import Factory t@@ -8,6 +10,9 @@ from kivy.uix.boxlayout import BoxLayout from electrum.gui.kivy.i18n import _ +if TYPE_CHECKING: + from ...main_window import ElectrumWindow + from electrum.transaction import TxOutput class AnimatedPopup(Factory.Popup): t@@ -202,13 +207,13 @@ class OutputList(RecycleView): def __init__(self, **kwargs): super(OutputList, self).__init__(**kwargs) - self.app = App.get_running_app() + self.app = App.get_running_app() # type: ElectrumWindow - def update(self, outputs): + def update(self, outputs: Sequence['TxOutput']): res = [] for o in outputs: value = self.app.format_amount_and_units(o.value) - res.append({'address': o.address, 'value': value}) + res.append({'address': o.get_ui_address_str(), 'value': value}) self.data = res DIR diff --git a/electrum/gui/kivy/uix/dialogs/tx_dialog.py b/electrum/gui/kivy/uix/dialogs/tx_dialog.py t@@ -1,5 +1,6 @@ +import copy from datetime import datetime -from typing import NamedTuple, Callable +from typing import NamedTuple, Callable, TYPE_CHECKING from kivy.app import App from kivy.factory import Factory t@@ -16,6 +17,10 @@ from electrum.gui.kivy.i18n import _ from electrum.util import InvalidPassword from electrum.address_synchronizer import TX_HEIGHT_LOCAL from electrum.wallet import CannotBumpFee +from electrum.transaction import Transaction, PartialTransaction + +if TYPE_CHECKING: + from ...main_window import ElectrumWindow Builder.load_string(''' t@@ -121,11 +126,16 @@ class TxDialog(Factory.Popup): def __init__(self, app, tx): Factory.Popup.__init__(self) - self.app = app + self.app = app # type: ElectrumWindow self.wallet = self.app.wallet - self.tx = tx + self.tx = tx # type: Transaction self._action_button_fn = lambda btn: None + # if the wallet can populate the inputs with more info, do it now. + # as a result, e.g. we might learn an imported address tx is segwit, + # or that a beyond-gap-limit address is is_mine + tx.add_info_from_wallet(self.wallet) + def on_open(self): self.update() t@@ -150,6 +160,7 @@ class TxDialog(Factory.Popup): self.date_label = '' self.date_str = '' + self.can_sign = self.wallet.can_sign(self.tx) if amount is None: self.amount_str = _("Transaction unrelated to your wallet") elif amount > 0: t@@ -158,15 +169,18 @@ class TxDialog(Factory.Popup): else: self.is_mine = True self.amount_str = format_amount(-amount) - if fee is not None: + risk_of_burning_coins = (isinstance(self.tx, PartialTransaction) + and self.can_sign + and fee is not None + and self.tx.is_there_risk_of_burning_coins_as_fees()) + if fee is not None and not risk_of_burning_coins: self.fee_str = format_amount(fee) fee_per_kb = fee / self.tx.estimated_size() * 1000 self.feerate_str = self.app.format_fee_rate(fee_per_kb) else: self.fee_str = _('unknown') self.feerate_str = _('unknown') - self.can_sign = self.wallet.can_sign(self.tx) - self.ids.output_list.update(self.tx.get_outputs_for_UI()) + self.ids.output_list.update(self.tx.outputs()) self.is_local_tx = tx_mined_status.height == TX_HEIGHT_LOCAL self.update_action_button() t@@ -252,10 +266,15 @@ class TxDialog(Factory.Popup): def show_qr(self): from electrum.bitcoin import base_encode, bfh - raw_tx = str(self.tx) - text = bfh(raw_tx) + original_raw_tx = str(self.tx) + tx = copy.deepcopy(self.tx) # make copy as we mutate tx + if isinstance(tx, PartialTransaction): + # this makes QR codes a lot smaller (or just possible in the first place!) + tx.convert_all_utxos_to_witness_utxos() + + text = tx.serialize_as_bytes() text = base_encode(text, base=43) - self.app.qr_dialog(_("Raw Transaction"), text, text_for_clipboard=raw_tx) + self.app.qr_dialog(_("Raw Transaction"), text, text_for_clipboard=original_raw_tx) def remove_local_tx(self): txid = self.tx.txid() DIR diff --git a/electrum/gui/kivy/uix/screens.py b/electrum/gui/kivy/uix/screens.py t@@ -22,11 +22,10 @@ from kivy.lang import Builder from kivy.factory import Factory from kivy.utils import platform -from electrum.bitcoin import TYPE_ADDRESS from electrum.util import profiler, parse_URI, format_time, InvalidPassword, NotEnoughFunds, Fiat from electrum.util import PR_TYPE_ONCHAIN, PR_TYPE_LN from electrum import bitcoin, constants -from electrum.transaction import TxOutput, Transaction, tx_from_str +from electrum.transaction import Transaction, tx_from_any, PartialTransaction, PartialTxOutput from electrum.util import send_exception_to_crash_reporter, parse_URI, InvalidBitcoinURI from electrum.util import PR_UNPAID, PR_PAID, PR_UNKNOWN, PR_EXPIRED, PR_INFLIGHT, TxMinedInfo, get_request_status, pr_expiration_values from electrum.plugin import run_hook t@@ -276,8 +275,7 @@ class SendScreen(CScreen): return # try to decode as transaction try: - raw_tx = tx_from_str(data) - tx = Transaction(raw_tx) + tx = tx_from_any(data) tx.deserialize() except: tx = None t@@ -313,7 +311,7 @@ class SendScreen(CScreen): if not bitcoin.is_address(address): self.app.show_error(_('Invalid Bitcoin Address') + ':\n' + address) return - outputs = [TxOutput(TYPE_ADDRESS, address, amount)] + outputs = [PartialTxOutput.from_address_and_value(address, amount)] return self.app.wallet.create_invoice(outputs, message, self.payment_request, self.parsed_URI) def do_save(self): t@@ -353,11 +351,11 @@ class SendScreen(CScreen): def _do_pay_onchain(self, invoice, rbf): # make unsigned transaction - outputs = invoice['outputs'] # type: List[TxOutput] + outputs = invoice['outputs'] # type: List[PartialTxOutput] amount = sum(map(lambda x: x.value, outputs)) coins = self.app.wallet.get_spendable_coins(None) try: - tx = self.app.wallet.make_unsigned_transaction(coins, outputs, None) + tx = self.app.wallet.make_unsigned_transaction(coins=coins, outputs=outputs) except NotEnoughFunds: self.app.show_error(_("Not enough funds")) return DIR diff --git a/electrum/gui/qt/address_dialog.py b/electrum/gui/qt/address_dialog.py t@@ -84,16 +84,20 @@ class AddressDialog(WindowModalDialog): pubkey_e.setReadOnly(True) vbox.addWidget(pubkey_e) - try: - redeem_script = self.wallet.pubkeys_to_redeem_script(pubkeys) - except BaseException as e: - redeem_script = None + redeem_script = self.wallet.get_redeem_script(address) if redeem_script: vbox.addWidget(QLabel(_("Redeem Script") + ':')) redeem_e = ShowQRTextEdit(text=redeem_script) redeem_e.addCopyButton(self.app) vbox.addWidget(redeem_e) + witness_script = self.wallet.get_witness_script(address) + if witness_script: + vbox.addWidget(QLabel(_("Witness Script") + ':')) + witness_e = ShowQRTextEdit(text=witness_script) + witness_e.addCopyButton(self.app) + vbox.addWidget(witness_e) + vbox.addWidget(QLabel(_("History"))) addr_hist_model = AddressHistoryModel(self.parent, self.address) self.hw = HistoryList(self.parent, addr_hist_model) DIR diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py t@@ -36,7 +36,7 @@ import base64 from functools import partial import queue import asyncio -from typing import Optional, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING, Sequence, List, Union from PyQt5.QtGui import QPixmap, QKeySequence, QIcon, QCursor, QFont from PyQt5.QtCore import Qt, QRect, QStringListModel, QSize, pyqtSignal t@@ -50,7 +50,7 @@ from PyQt5.QtWidgets import (QMessageBox, QComboBox, QSystemTrayIcon, QTabWidget import electrum from electrum import (keystore, simple_config, ecc, constants, util, bitcoin, commands, coinchooser, paymentrequest) -from electrum.bitcoin import COIN, is_address, TYPE_ADDRESS +from electrum.bitcoin import COIN, is_address from electrum.plugin import run_hook from electrum.i18n import _ from electrum.util import (format_time, format_satoshis, format_fee_satoshis, t@@ -64,7 +64,8 @@ from electrum.util import (format_time, format_satoshis, format_fee_satoshis, InvalidBitcoinURI, InvoiceError) from electrum.util import PR_TYPE_ONCHAIN, PR_TYPE_LN from electrum.lnutil import PaymentFailure, SENT, RECEIVED -from electrum.transaction import Transaction, TxOutput +from electrum.transaction import (Transaction, PartialTxInput, + PartialTransaction, PartialTxOutput) from electrum.address_synchronizer import AddTransactionException from electrum.wallet import (Multisig_Wallet, CannotBumpFee, Abstract_Wallet, sweep_preparations, InternalAddressCorruption) t@@ -922,7 +923,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): def show_transaction(self, tx, *, invoice=None, tx_desc=None): '''tx_desc is set only for txs created in the Send tab''' - show_transaction(tx, self, invoice=invoice, desc=tx_desc) + show_transaction(tx, parent=self, invoice=invoice, desc=tx_desc) def create_receive_tab(self): # A 4-column grid layout. All the stretch is in the last column. t@@ -1434,11 +1435,11 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): def update_fee(self): self.require_fee_update = True - def get_payto_or_dummy(self): - r = self.payto_e.get_recipient() + def get_payto_or_dummy(self) -> bytes: + r = self.payto_e.get_destination_scriptpubkey() if r: return r - return (TYPE_ADDRESS, self.wallet.dummy_address()) + return bfh(bitcoin.address_to_script(self.wallet.dummy_address())) def do_update_fee(self): '''Recalculate the fee. If the fee was manually input, retain it, but t@@ -1461,13 +1462,15 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): coins = self.get_coins() if not outputs: - _type, addr = self.get_payto_or_dummy() - outputs = [TxOutput(_type, addr, amount)] + scriptpubkey = self.get_payto_or_dummy() + outputs = [PartialTxOutput(scriptpubkey=scriptpubkey, value=amount)] is_sweep = bool(self.tx_external_keypairs) make_tx = lambda fee_est: \ self.wallet.make_unsigned_transaction( - coins, outputs, - fixed_fee=fee_est, is_sweep=is_sweep) + coins=coins, + outputs=outputs, + fee=fee_est, + is_sweep=is_sweep) try: tx = make_tx(fee_estimator) self.not_enough_funds = False t@@ -1546,7 +1549,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): menu.addAction(_("Remove"), lambda: self.from_list_delete(item)) menu.exec_(self.from_list.viewport().mapToGlobal(position)) - def set_pay_from(self, coins): + def set_pay_from(self, coins: Sequence[PartialTxInput]): self.pay_from = list(coins) self.redraw_from_list() t@@ -1555,12 +1558,14 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.from_label.setHidden(len(self.pay_from) == 0) self.from_list.setHidden(len(self.pay_from) == 0) - def format(x): - h = x.get('prevout_hash') - return h[0:10] + '...' + h[-10:] + ":%d"%x.get('prevout_n') + '\t' + "%s"%x.get('address') + '\t' + def format(txin: PartialTxInput): + h = txin.prevout.txid.hex() + out_idx = txin.prevout.out_idx + addr = txin.address + return h[0:10] + '...' + h[-10:] + ":%d"%out_idx + '\t' + addr + '\t' for coin in self.pay_from: - item = QTreeWidgetItem([format(coin), self.format_amount(coin['value'])]) + item = QTreeWidgetItem([format(coin), self.format_amount(coin.value_sats())]) item.setFont(0, QFont(MONOSPACE_FONT)) self.from_list.addTopLevelItem(item) t@@ -1620,14 +1625,14 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): fee_estimator = None return fee_estimator - def read_outputs(self): + def read_outputs(self) -> List[PartialTxOutput]: if self.payment_request: outputs = self.payment_request.get_outputs() else: outputs = self.payto_e.get_outputs(self.max_button.isChecked()) return outputs - def check_send_tab_onchain_outputs_and_show_errors(self, outputs) -> bool: + def check_send_tab_onchain_outputs_and_show_errors(self, outputs: List[PartialTxOutput]) -> bool: """Returns whether there are errors with outputs. Also shows error dialog to user if so. """ t@@ -1636,12 +1641,9 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): return True for o in outputs: - if o.address is None: + if o.scriptpubkey is None: self.show_error(_('Bitcoin Address is None')) return True - if o.type == TYPE_ADDRESS and not bitcoin.is_address(o.address): - self.show_error(_('Invalid Bitcoin Address')) - return True if o.value is None: self.show_error(_('Invalid Amount')) return True t@@ -1749,20 +1751,23 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): return elif invoice['type'] == PR_TYPE_ONCHAIN: message = invoice['message'] - outputs = invoice['outputs'] + outputs = invoice['outputs'] # type: List[PartialTxOutput] else: raise Exception('unknown invoice type') if run_hook('abort_send', self): return - outputs = [TxOutput(*x) for x in outputs] + for txout in outputs: + assert isinstance(txout, PartialTxOutput) fee_estimator = self.get_send_fee_estimator() coins = self.get_coins() try: is_sweep = bool(self.tx_external_keypairs) tx = self.wallet.make_unsigned_transaction( - coins, outputs, fixed_fee=fee_estimator, + coins=coins, + outputs=outputs, + fee=fee_estimator, is_sweep=is_sweep) except (NotEnoughFunds, NoDynamicFeeEstimates) as e: self.show_message(str(e)) t@@ -1837,7 +1842,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): def sign_tx(self, tx, callback, password): self.sign_tx_with_password(tx, callback, password) - def sign_tx_with_password(self, tx, callback, password): + def sign_tx_with_password(self, tx: PartialTransaction, callback, password): '''Sign the transaction in a separate thread. When done, calls the callback with a success code of True or False. ''' t@@ -1849,13 +1854,13 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): on_success = run_hook('tc_sign_wrapper', self.wallet, tx, on_success, on_failure) or on_success if self.tx_external_keypairs: # can sign directly - task = partial(Transaction.sign, tx, self.tx_external_keypairs) + task = partial(tx.sign, self.tx_external_keypairs) else: task = partial(self.wallet.sign_transaction, tx, password) msg = _('Signing transaction...') WaitingDialog(self, msg, task, on_success, on_failure) - def broadcast_transaction(self, tx, *, invoice=None, tx_desc=None): + def broadcast_transaction(self, tx: Transaction, *, invoice=None, tx_desc=None): def broadcast_thread(): # non-GUI thread t@@ -1879,7 +1884,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): if pr: self.payment_request = None refund_address = self.wallet.get_receiving_address() - coro = pr.send_payment_and_receive_paymentack(str(tx), refund_address) + coro = pr.send_payment_and_receive_paymentack(tx.serialize(), refund_address) fut = asyncio.run_coroutine_threadsafe(coro, self.network.asyncio_loop) ack_status, ack_msg = fut.result(timeout=20) self.logger.info(f"Payment ACK: {ack_status}. Ack message: {ack_msg}") t@@ -2077,7 +2082,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.utxo_list.update() self.update_fee() - def set_frozen_state_of_coins(self, utxos, freeze: bool): + def set_frozen_state_of_coins(self, utxos: Sequence[PartialTxInput], freeze: bool): self.wallet.set_frozen_state_of_coins(utxos, freeze) self.utxo_list.update() self.update_fee() t@@ -2124,7 +2129,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): else: return self.wallet.get_spendable_coins(None) - def spend_coins(self, coins): + def spend_coins(self, coins: Sequence[PartialTxInput]): self.set_pay_from(coins) self.set_onchain(len(coins) > 0) self.show_send_tab() t@@ -2527,7 +2532,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): if not address: return try: - pk, redeem_script = self.wallet.export_private_key(address, password) + pk = self.wallet.export_private_key(address, password) except Exception as e: self.logger.exception('') self.show_message(repr(e)) t@@ -2542,11 +2547,11 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): keys_e = ShowQRTextEdit(text=pk) keys_e.addCopyButton(self.app) vbox.addWidget(keys_e) - if redeem_script: - vbox.addWidget(QLabel(_("Redeem Script") + ':')) - rds_e = ShowQRTextEdit(text=redeem_script) - rds_e.addCopyButton(self.app) - vbox.addWidget(rds_e) + # if redeem_script: + # vbox.addWidget(QLabel(_("Redeem Script") + ':')) + # rds_e = ShowQRTextEdit(text=redeem_script) + # rds_e.addCopyButton(self.app) + # vbox.addWidget(rds_e) vbox.addLayout(Buttons(CloseButton(d))) d.setLayout(vbox) d.exec_() t@@ -2718,11 +2723,10 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): d = PasswordDialog(parent, msg) return d.run() - def tx_from_text(self, txt) -> Optional[Transaction]: - from electrum.transaction import tx_from_str + def tx_from_text(self, data: Union[str, bytes]) -> Union[None, 'PartialTransaction', 'Transaction']: + from electrum.transaction import tx_from_any try: - tx = tx_from_str(txt) - return Transaction(tx) + return tx_from_any(data) except BaseException as e: self.show_critical(_("Electrum was unable to parse your transaction") + ":\n" + repr(e)) return t@@ -2741,25 +2745,21 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.pay_to_URI(data) return # else if the user scanned an offline signed tx - try: - data = bh2u(bitcoin.base_decode(data, length=None, base=43)) - except BaseException as e: - self.show_error((_('Could not decode QR code')+':\n{}').format(repr(e))) - return tx = self.tx_from_text(data) if not tx: return self.show_transaction(tx) def read_tx_from_file(self) -> Optional[Transaction]: - fileName = self.getOpenFileName(_("Select your transaction file"), "*.txn") + fileName = self.getOpenFileName(_("Select your transaction file"), "*.txn;;*.psbt") if not fileName: return try: with open(fileName, "r") as f: - file_content = f.read() + file_content = f.read() # type: Union[str, bytes] except (ValueError, IOError, os.error) as reason: - self.show_critical(_("Electrum was unable to open your transaction file") + "\n" + str(reason), title=_("Unable to read file or no transaction found")) + self.show_critical(_("Electrum was unable to open your transaction file") + "\n" + str(reason), + title=_("Unable to read file or no transaction found")) return return self.tx_from_text(file_content) t@@ -2831,7 +2831,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): time.sleep(0.1) if done or cancelled: break - privkey = self.wallet.export_private_key(addr, password)[0] + privkey = self.wallet.export_private_key(addr, password) private_keys[addr] = privkey self.computing_privkeys_signal.emit() if not cancelled: t@@ -3130,7 +3130,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): vbox.addLayout(Buttons(CloseButton(d))) d.exec_() - def cpfp(self, parent_tx: Transaction, new_tx: Transaction) -> None: + def cpfp(self, parent_tx: Transaction, new_tx: PartialTransaction) -> None: total_size = parent_tx.estimated_size() + new_tx.estimated_size() parent_txid = parent_tx.txid() assert parent_txid t@@ -3257,7 +3257,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): new_tx.set_rbf(False) self.show_transaction(new_tx, tx_desc=tx_label) - def save_transaction_into_wallet(self, tx): + def save_transaction_into_wallet(self, tx: Transaction): win = self.top_level_window() try: if not self.wallet.add_transaction(tx.txid(), tx): DIR diff --git a/electrum/gui/qt/paytoedit.py b/electrum/gui/qt/paytoedit.py t@@ -25,13 +25,13 @@ import re from decimal import Decimal -from typing import NamedTuple, Sequence +from typing import NamedTuple, Sequence, Optional, List from PyQt5.QtGui import QFontMetrics from electrum import bitcoin from electrum.util import bfh -from electrum.transaction import TxOutput, push_script +from electrum.transaction import push_script, PartialTxOutput from electrum.bitcoin import opcodes from electrum.logging import Logger from electrum.lnaddr import LnDecodeException t@@ -65,12 +65,12 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): self.heightMax = 150 self.c = None self.textChanged.connect(self.check_text) - self.outputs = [] + self.outputs = [] # type: List[PartialTxOutput] self.errors = [] # type: Sequence[PayToLineError] self.is_pr = False self.is_alias = False self.update_size() - self.payto_address = None + self.payto_scriptpubkey = None # type: Optional[bytes] self.lightning_invoice = None self.previous_payto = '' t@@ -86,19 +86,19 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): def setExpired(self): self.setStyleSheet(util.ColorScheme.RED.as_stylesheet(True)) - def parse_address_and_amount(self, line): + def parse_address_and_amount(self, line) -> PartialTxOutput: x, y = line.split(',') - out_type, out = self.parse_output(x) + scriptpubkey = self.parse_output(x) amount = self.parse_amount(y) - return TxOutput(out_type, out, amount) + return PartialTxOutput(scriptpubkey=scriptpubkey, value=amount) - def parse_output(self, x): + def parse_output(self, x) -> bytes: try: address = self.parse_address(x) - return bitcoin.TYPE_ADDRESS, address + return bfh(bitcoin.address_to_script(address)) except: script = self.parse_script(x) - return bitcoin.TYPE_SCRIPT, script + return bfh(script) def parse_script(self, x): script = '' t@@ -131,9 +131,9 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): return # filter out empty lines lines = [i for i in self.lines() if i] - outputs = [] + outputs = [] # type: List[PartialTxOutput] total = 0 - self.payto_address = None + self.payto_scriptpubkey = None self.lightning_invoice = None if len(lines) == 1: data = lines[0] t@@ -152,10 +152,10 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): self.lightning_invoice = lower return try: - self.payto_address = self.parse_output(data) + self.payto_scriptpubkey = self.parse_output(data) except: pass - if self.payto_address: + if self.payto_scriptpubkey: self.win.set_onchain(True) self.win.lock_amount(False) return t@@ -177,7 +177,7 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): self.win.max_button.setChecked(is_max) self.outputs = outputs - self.payto_address = None + self.payto_scriptpubkey = None if self.win.max_button.isChecked(): self.win.do_update_fee() t@@ -188,18 +188,16 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): def get_errors(self) -> Sequence[PayToLineError]: return self.errors - def get_recipient(self): - return self.payto_address + def get_destination_scriptpubkey(self) -> Optional[bytes]: + return self.payto_scriptpubkey def get_outputs(self, is_max): - if self.payto_address: + if self.payto_scriptpubkey: if is_max: amount = '!' else: amount = self.amount_edit.get_amount() - - _type, addr = self.payto_address - self.outputs = [TxOutput(_type, addr, amount)] + self.outputs = [PartialTxOutput(scriptpubkey=self.payto_scriptpubkey, value=amount)] return self.outputs[:] DIR diff --git a/electrum/gui/qt/transaction_dialog.py b/electrum/gui/qt/transaction_dialog.py t@@ -26,12 +26,12 @@ import sys import copy import datetime -import json import traceback -from typing import TYPE_CHECKING +import time +from typing import TYPE_CHECKING, Callable from PyQt5.QtCore import QSize, Qt -from PyQt5.QtGui import QTextCharFormat, QBrush, QFont +from PyQt5.QtGui import QTextCharFormat, QBrush, QFont, QPixmap from PyQt5.QtWidgets import (QDialog, QLabel, QPushButton, QHBoxLayout, QVBoxLayout, QTextEdit, QFrame, QAction, QToolButton, QMenu) import qrcode t@@ -42,11 +42,12 @@ from electrum.i18n import _ from electrum.plugin import run_hook from electrum import simple_config from electrum.util import bfh -from electrum.transaction import SerializationError, Transaction +from electrum.transaction import SerializationError, Transaction, PartialTransaction, PartialTxInput from electrum.logging import get_logger -from .util import (MessageBoxMixin, read_QIcon, Buttons, CopyButton, - MONOSPACE_FONT, ColorScheme, ButtonsLineEdit) +from .util import (MessageBoxMixin, read_QIcon, Buttons, CopyButton, icon_path, + MONOSPACE_FONT, ColorScheme, ButtonsLineEdit, text_dialog, + char_width_in_lineedit) if TYPE_CHECKING: from .main_window import ElectrumWindow t@@ -60,9 +61,9 @@ _logger = get_logger(__name__) dialogs = [] # Otherwise python randomly garbage collects the dialogs... -def show_transaction(tx, parent, *, invoice=None, desc=None, prompt_if_unsaved=False): +def show_transaction(tx: Transaction, *, parent: 'ElectrumWindow', invoice=None, desc=None, prompt_if_unsaved=False): try: - d = TxDialog(tx, parent, invoice, desc, prompt_if_unsaved) + d = TxDialog(tx, parent=parent, invoice=invoice, desc=desc, prompt_if_unsaved=prompt_if_unsaved) except SerializationError as e: _logger.exception('unable to deserialize the transaction') parent.show_critical(_("Electrum was unable to deserialize the transaction:") + "\n" + str(e)) t@@ -73,7 +74,7 @@ def show_transaction(tx, parent, *, invoice=None, desc=None, prompt_if_unsaved=F class TxDialog(QDialog, MessageBoxMixin): - def __init__(self, tx: Transaction, parent: 'ElectrumWindow', invoice, desc, prompt_if_unsaved): + def __init__(self, tx: Transaction, *, parent: 'ElectrumWindow', invoice, desc, prompt_if_unsaved): '''Transactions in the wallet will show their description. Pass desc to give a description for txs not yet in the wallet. ''' t@@ -96,8 +97,8 @@ class TxDialog(QDialog, MessageBoxMixin): # if the wallet can populate the inputs with more info, do it now. # as a result, e.g. we might learn an imported address tx is segwit, - # in which case it's ok to display txid - tx.add_inputs_info(self.wallet) + # or that a beyond-gap-limit address is is_mine + tx.add_info_from_wallet(self.wallet) self.setMinimumWidth(950) self.setWindowTitle(_("Transaction")) t@@ -115,7 +116,15 @@ class TxDialog(QDialog, MessageBoxMixin): self.add_tx_stats(vbox) vbox.addSpacing(10) - self.add_io(vbox) + + self.inputs_header = QLabel() + vbox.addWidget(self.inputs_header) + self.inputs_textedit = QTextEditWithDefaultSize() + vbox.addWidget(self.inputs_textedit) + self.outputs_header = QLabel() + vbox.addWidget(self.outputs_header) + self.outputs_textedit = QTextEditWithDefaultSize() + vbox.addWidget(self.outputs_textedit) self.sign_button = b = QPushButton(_("Sign")) b.clicked.connect(self.sign) t@@ -136,23 +145,35 @@ class TxDialog(QDialog, MessageBoxMixin): b.clicked.connect(self.close) b.setDefault(True) - export_actions_menu = QMenu() - action = QAction(_("Copy to clipboard"), self) - action.triggered.connect(lambda: parent.app.clipboard().setText((lambda: str(self.tx))())) - export_actions_menu.addAction(action) - action = QAction(read_QIcon(qr_icon), _("Show as QR code"), self) - action.triggered.connect(self.show_qr) - export_actions_menu.addAction(action) - action = QAction(_("Export to file"), self) - action.triggered.connect(self.export) - export_actions_menu.addAction(action) + self.export_actions_menu = export_actions_menu = QMenu() + self.add_export_actions_to_menu(export_actions_menu) + export_actions_menu.addSeparator() + if isinstance(tx, PartialTransaction): + export_for_coinjoin_submenu = export_actions_menu.addMenu(_("For CoinJoin; strip privates")) + self.add_export_actions_to_menu(export_for_coinjoin_submenu, gettx=self._gettx_for_coinjoin) + self.export_actions_button = QToolButton() self.export_actions_button.setText(_("Export")) self.export_actions_button.setMenu(export_actions_menu) self.export_actions_button.setPopupMode(QToolButton.InstantPopup) + partial_tx_actions_menu = QMenu() + ptx_merge_sigs_action = QAction(_("Merge signatures from"), self) + ptx_merge_sigs_action.triggered.connect(self.merge_sigs) + partial_tx_actions_menu.addAction(ptx_merge_sigs_action) + ptx_join_txs_action = QAction(_("Join inputs/outputs"), self) + ptx_join_txs_action.triggered.connect(self.join_tx_with_another) + partial_tx_actions_menu.addAction(ptx_join_txs_action) + self.partial_tx_actions_button = QToolButton() + self.partial_tx_actions_button.setText(_("Combine")) + self.partial_tx_actions_button.setMenu(partial_tx_actions_menu) + self.partial_tx_actions_button.setPopupMode(QToolButton.InstantPopup) + # Action buttons - self.buttons = [self.sign_button, self.broadcast_button, self.cancel_button] + self.buttons = [] + if isinstance(tx, PartialTransaction): + self.buttons.append(self.partial_tx_actions_button) + self.buttons += [self.sign_button, self.broadcast_button, self.cancel_button] # Transaction sharing buttons self.sharing_buttons = [self.export_actions_button, self.save_button] t@@ -189,8 +210,43 @@ class TxDialog(QDialog, MessageBoxMixin): # Override escape-key to close normally (and invoke closeEvent) self.close() - def show_qr(self): - text = bfh(str(self.tx)) + def add_export_actions_to_menu(self, menu: QMenu, *, gettx: Callable[[], Transaction] = None) -> None: + if gettx is None: + gettx = lambda: None + + action = QAction(_("Copy to clipboard"), self) + action.triggered.connect(lambda: self.copy_to_clipboard(tx=gettx())) + menu.addAction(action) + + qr_icon = "qrcode_white.png" if ColorScheme.dark_scheme else "qrcode.png" + action = QAction(read_QIcon(qr_icon), _("Show as QR code"), self) + action.triggered.connect(lambda: self.show_qr(tx=gettx())) + menu.addAction(action) + + action = QAction(_("Export to file"), self) + action.triggered.connect(lambda: self.export_to_file(tx=gettx())) + menu.addAction(action) + + def _gettx_for_coinjoin(self) -> PartialTransaction: + if not isinstance(self.tx, PartialTransaction): + raise Exception("Can only export partial transactions for coinjoins.") + tx = copy.deepcopy(self.tx) + tx.prepare_for_export_for_coinjoin() + return tx + + def copy_to_clipboard(self, *, tx: Transaction = None): + if tx is None: + tx = self.tx + self.main_window.app.clipboard().setText(str(tx)) + + def show_qr(self, *, tx: Transaction = None): + if tx is None: + tx = self.tx + tx = copy.deepcopy(tx) # make copy as we mutate tx + if isinstance(tx, PartialTransaction): + # this makes QR codes a lot smaller (or just possible in the first place!) + tx.convert_all_utxos_to_witness_utxos() + text = tx.serialize_as_bytes() text = base_encode(text, base=43) try: self.main_window.show_qrcode(text, 'Transaction', parent=self) t@@ -222,17 +278,68 @@ class TxDialog(QDialog, MessageBoxMixin): self.saved = True self.main_window.pop_top_level_window(self) - - def export(self): - name = 'signed_%s.txn' % (self.tx.txid()[0:8]) if self.tx.is_complete() else 'unsigned.txn' - fileName = self.main_window.getSaveFileName(_("Select where to save your signed transaction"), name, "*.txn") - if fileName: + def export_to_file(self, *, tx: Transaction = None): + if tx is None: + tx = self.tx + if isinstance(tx, PartialTransaction): + tx.finalize_psbt() + if tx.is_complete(): + name = 'signed_%s.txn' % (tx.txid()[0:8]) + else: + name = self.wallet.basename() + time.strftime('-%Y%m%d-%H%M.psbt') + fileName = self.main_window.getSaveFileName(_("Select where to save your signed transaction"), name, "*.txn;;*.psbt") + if not fileName: + return + if tx.is_complete(): # network tx hex with open(fileName, "w+") as f: - f.write(json.dumps(self.tx.as_dict(), indent=4) + '\n') - self.show_message(_("Transaction exported successfully")) - self.saved = True + network_tx_hex = tx.serialize_to_network() + f.write(network_tx_hex + '\n') + else: # if partial: PSBT bytes + assert isinstance(tx, PartialTransaction) + with open(fileName, "wb+") as f: + f.write(tx.serialize_as_bytes()) + + self.show_message(_("Transaction exported successfully")) + self.saved = True + + def merge_sigs(self): + if not isinstance(self.tx, PartialTransaction): + return + text = text_dialog(self, _('Input raw transaction'), + _("Transaction to merge signatures from") + ":", + _("Load transaction")) + if not text: + return + tx = self.main_window.tx_from_text(text) + if not tx: + return + try: + self.tx.combine_with_other_psbt(tx) + except Exception as e: + self.show_error(_("Error combining partial transactions") + ":\n" + repr(e)) + return + self.update() + + def join_tx_with_another(self): + if not isinstance(self.tx, PartialTransaction): + return + text = text_dialog(self, _('Input raw transaction'), + _("Transaction to join with") + " (" + _("add inputs and outputs") + "):", + _("Load transaction")) + if not text: + return + tx = self.main_window.tx_from_text(text) + if not tx: + return + try: + self.tx.join_with_other_psbt(tx) + except Exception as e: + self.show_error(_("Error joining partial transactions") + ":\n" + repr(e)) + return + self.update() def update(self): + self.update_io() desc = self.desc base_unit = self.main_window.base_unit() format_amount = self.main_window.format_amount t@@ -287,13 +394,17 @@ class TxDialog(QDialog, MessageBoxMixin): feerate_warning = simple_config.FEERATE_WARNING_HIGH_FEE if fee_rate > feerate_warning: fee_str += ' - ' + _('Warning') + ': ' + _("high fee") + '!' + if isinstance(self.tx, PartialTransaction): + risk_of_burning_coins = (can_sign and fee is not None + and self.tx.is_there_risk_of_burning_coins_as_fees()) + self.fee_warning_icon.setVisible(risk_of_burning_coins) self.amount_label.setText(amount_str) self.fee_label.setText(fee_str) self.size_label.setText(size_str) run_hook('transaction_dialog_update', self) - def add_io(self, vbox): - vbox.addWidget(QLabel(_("Inputs") + ' (%d)'%len(self.tx.inputs()))) + def update_io(self): + self.inputs_header.setText(_("Inputs") + ' (%d)'%len(self.tx.inputs())) ext = QTextCharFormat() rec = QTextCharFormat() rec.setBackground(QBrush(ColorScheme.GREEN.as_color(background=True))) t@@ -315,39 +426,39 @@ class TxDialog(QDialog, MessageBoxMixin): def format_amount(amt): return self.main_window.format_amount(amt, whitespaces=True) - i_text = QTextEditWithDefaultSize() + i_text = self.inputs_textedit + i_text.clear() i_text.setFont(QFont(MONOSPACE_FONT)) i_text.setReadOnly(True) cursor = i_text.textCursor() - for x in self.tx.inputs(): - if x['type'] == 'coinbase': + for txin in self.tx.inputs(): + if txin.is_coinbase(): cursor.insertText('coinbase') else: - prevout_hash = x.get('prevout_hash') - prevout_n = x.get('prevout_n') + prevout_hash = txin.prevout.txid.hex() + prevout_n = txin.prevout.out_idx cursor.insertText(prevout_hash + ":%-4d " % prevout_n, ext) - addr = self.wallet.get_txin_address(x) + addr = self.wallet.get_txin_address(txin) if addr is None: addr = '' cursor.insertText(addr, text_format(addr)) - if x.get('value'): - cursor.insertText(format_amount(x['value']), ext) + if isinstance(txin, PartialTxInput) and txin.value_sats() is not None: + cursor.insertText(format_amount(txin.value_sats()), ext) cursor.insertBlock() - vbox.addWidget(i_text) - vbox.addWidget(QLabel(_("Outputs") + ' (%d)'%len(self.tx.outputs()))) - o_text = QTextEditWithDefaultSize() + self.outputs_header.setText(_("Outputs") + ' (%d)'%len(self.tx.outputs())) + o_text = self.outputs_textedit + o_text.clear() o_text.setFont(QFont(MONOSPACE_FONT)) o_text.setReadOnly(True) cursor = o_text.textCursor() - for o in self.tx.get_outputs_for_UI(): - addr, v = o.address, o.value + for o in self.tx.outputs(): + addr, v = o.get_ui_address_str(), o.value cursor.insertText(addr, text_format(addr)) if v is not None: cursor.insertText('\t', ext) cursor.insertText(format_amount(v), ext) cursor.insertBlock() - vbox.addWidget(o_text) def add_tx_stats(self, vbox): hbox_stats = QHBoxLayout() t@@ -362,8 +473,24 @@ class TxDialog(QDialog, MessageBoxMixin): vbox_left.addWidget(self.date_label) self.amount_label = TxDetailLabel() vbox_left.addWidget(self.amount_label) + + fee_hbox = QHBoxLayout() self.fee_label = TxDetailLabel() - vbox_left.addWidget(self.fee_label) + fee_hbox.addWidget(self.fee_label) + self.fee_warning_icon = QLabel() + pixmap = QPixmap(icon_path("warning")) + pixmap_size = round(2 * char_width_in_lineedit()) + pixmap = pixmap.scaled(pixmap_size, pixmap_size, Qt.KeepAspectRatio, Qt.SmoothTransformation) + self.fee_warning_icon.setPixmap(pixmap) + self.fee_warning_icon.setToolTip(_("Warning") + ": " + + _("The fee could not be verified. Signing non-segwit inputs is risky:\n" + "if this transaction was maliciously modified before you sign,\n" + "you might end up paying a higher mining fee than displayed.")) + self.fee_warning_icon.setVisible(False) + fee_hbox.addWidget(self.fee_warning_icon) + fee_hbox.addStretch(1) + vbox_left.addLayout(fee_hbox) + vbox_left.addStretch(1) hbox_stats.addLayout(vbox_left, 50) DIR diff --git a/electrum/gui/qt/util.py b/electrum/gui/qt/util.py t@@ -840,13 +840,16 @@ def export_meta_gui(electrum_window, title, exporter): def get_parent_main_window(widget): """Returns a reference to the ElectrumWindow this widget belongs to.""" from .main_window import ElectrumWindow + from .transaction_dialog import TxDialog for _ in range(100): if widget is None: return None - if not isinstance(widget, ElectrumWindow): - widget = widget.parentWidget() - else: + if isinstance(widget, ElectrumWindow): return widget + elif isinstance(widget, TxDialog): + return widget.main_window + else: + widget = widget.parentWidget() return None DIR diff --git a/electrum/gui/qt/utxo_list.py b/electrum/gui/qt/utxo_list.py t@@ -23,7 +23,7 @@ # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -from typing import Optional, List +from typing import Optional, List, Dict from enum import IntEnum from PyQt5.QtCore import Qt t@@ -31,9 +31,11 @@ from PyQt5.QtGui import QStandardItemModel, QStandardItem, QFont from PyQt5.QtWidgets import QAbstractItemView, QMenu from electrum.i18n import _ +from electrum.transaction import PartialTxInput from .util import MyTreeView, ColorScheme, MONOSPACE_FONT + class UTXOList(MyTreeView): class Columns(IntEnum): t@@ -64,21 +66,21 @@ class UTXOList(MyTreeView): def update(self): self.wallet = self.parent.wallet utxos = self.wallet.get_utxos() - self.utxo_dict = {} + self.utxo_dict = {} # type: Dict[str, PartialTxInput] self.model().clear() self.update_headers(self.__class__.headers) - for idx, x in enumerate(utxos): - self.insert_utxo(idx, x) + for idx, utxo in enumerate(utxos): + self.insert_utxo(idx, utxo) self.filter() - def insert_utxo(self, idx, x): - address = x['address'] - height = x.get('height') - name = x.get('prevout_hash') + ":%d"%x.get('prevout_n') - name_short = x.get('prevout_hash')[:16] + '...' + ":%d"%x.get('prevout_n') - self.utxo_dict[name] = x - label = self.wallet.get_label(x.get('prevout_hash')) - amount = self.parent.format_amount(x['value'], whitespaces=True) + def insert_utxo(self, idx, utxo: PartialTxInput): + address = utxo.address + height = utxo.block_height + name = utxo.prevout.to_str() + name_short = utxo.prevout.txid.hex()[:16] + '...' + ":%d" % utxo.prevout.out_idx + self.utxo_dict[name] = utxo + label = self.wallet.get_label(utxo.prevout.txid.hex()) + amount = self.parent.format_amount(utxo.value_sats(), whitespaces=True) labels = [name_short, address, label, amount, '%d'%height] utxo_item = [QStandardItem(x) for x in labels] self.set_editability(utxo_item) t@@ -89,7 +91,7 @@ class UTXOList(MyTreeView): if self.wallet.is_frozen_address(address): utxo_item[self.Columns.ADDRESS].setBackground(ColorScheme.BLUE.as_color(True)) utxo_item[self.Columns.ADDRESS].setToolTip(_('Address is frozen')) - if self.wallet.is_frozen_coin(x): + if self.wallet.is_frozen_coin(utxo): utxo_item[self.Columns.OUTPOINT].setBackground(ColorScheme.BLUE.as_color(True)) utxo_item[self.Columns.OUTPOINT].setToolTip(f"{name}\n{_('Coin is frozen')}") else: t@@ -114,26 +116,26 @@ class UTXOList(MyTreeView): menu.addAction(_("Spend"), lambda: self.parent.spend_coins(coins)) assert len(coins) >= 1, len(coins) if len(coins) == 1: - utxo_dict = coins[0] - addr = utxo_dict['address'] - txid = utxo_dict['prevout_hash'] + utxo = coins[0] + addr = utxo.address + txid = utxo.prevout.txid.hex() # "Details" tx = self.wallet.db.get_transaction(txid) if tx: label = self.wallet.get_label(txid) or None # Prefer None if empty (None hides the Description: field in the window) - menu.addAction(_("Details"), lambda: self.parent.show_transaction(tx, label)) + menu.addAction(_("Details"), lambda: self.parent.show_transaction(tx, tx_desc=label)) # "Copy ..." idx = self.indexAt(position) if not idx.isValid(): return self.add_copy_menu(menu, idx) # "Freeze coin" - if not self.wallet.is_frozen_coin(utxo_dict): - menu.addAction(_("Freeze Coin"), lambda: self.parent.set_frozen_state_of_coins([utxo_dict], True)) + if not self.wallet.is_frozen_coin(utxo): + menu.addAction(_("Freeze Coin"), lambda: self.parent.set_frozen_state_of_coins([utxo], True)) else: menu.addSeparator() menu.addAction(_("Coin is frozen"), lambda: None).setEnabled(False) - menu.addAction(_("Unfreeze Coin"), lambda: self.parent.set_frozen_state_of_coins([utxo_dict], False)) + menu.addAction(_("Unfreeze Coin"), lambda: self.parent.set_frozen_state_of_coins([utxo], False)) menu.addSeparator() # "Freeze address" if not self.wallet.is_frozen_address(addr): t@@ -146,9 +148,9 @@ class UTXOList(MyTreeView): else: # multiple items selected menu.addSeparator() - addrs = [utxo_dict['address'] for utxo_dict in coins] - is_coin_frozen = [self.wallet.is_frozen_coin(utxo_dict) for utxo_dict in coins] - is_addr_frozen = [self.wallet.is_frozen_address(utxo_dict['address']) for utxo_dict in coins] + addrs = [utxo.address for utxo in coins] + is_coin_frozen = [self.wallet.is_frozen_coin(utxo) for utxo in coins] + is_addr_frozen = [self.wallet.is_frozen_address(utxo.address) for utxo in coins] if not all(is_coin_frozen): menu.addAction(_("Freeze Coins"), lambda: self.parent.set_frozen_state_of_coins(coins, True)) if any(is_coin_frozen): DIR diff --git a/electrum/gui/stdio.py b/electrum/gui/stdio.py t@@ -5,8 +5,8 @@ import logging from electrum import WalletStorage, Wallet from electrum.util import format_satoshis -from electrum.bitcoin import is_address, COIN, TYPE_ADDRESS -from electrum.transaction import TxOutput +from electrum.bitcoin import is_address, COIN +from electrum.transaction import PartialTxOutput from electrum.network import TxBroadcastError, BestEffortRequestFailed from electrum.logging import console_stderr_handler t@@ -197,8 +197,9 @@ class ElectrumGui: if c == "n": return try: - tx = self.wallet.mktx([TxOutput(TYPE_ADDRESS, self.str_recipient, amount)], - password, self.config, fee) + tx = self.wallet.mktx(outputs=[PartialTxOutput.from_address_and_value(self.str_recipient, amount)], + password=password, + fee=fee) except Exception as e: print(repr(e)) return DIR diff --git a/electrum/gui/text.py b/electrum/gui/text.py t@@ -9,8 +9,8 @@ import logging import electrum from electrum.util import format_satoshis -from electrum.bitcoin import is_address, COIN, TYPE_ADDRESS -from electrum.transaction import TxOutput +from electrum.bitcoin import is_address, COIN +from electrum.transaction import PartialTxOutput from electrum.wallet import Wallet from electrum.storage import WalletStorage from electrum.network import NetworkParameters, TxBroadcastError, BestEffortRequestFailed t@@ -360,8 +360,9 @@ class ElectrumGui: else: password = None try: - tx = self.wallet.mktx([TxOutput(TYPE_ADDRESS, self.str_recipient, amount)], - password, self.config, fee) + tx = self.wallet.mktx(outputs=[PartialTxOutput.from_address_and_value(self.str_recipient, amount)], + password=password, + fee=fee) except Exception as e: self.show_message(repr(e)) return DIR diff --git a/electrum/json_db.py b/electrum/json_db.py t@@ -28,7 +28,7 @@ import json import copy import threading from collections import defaultdict -from typing import Dict, Optional, List, Tuple, Set, Iterable, NamedTuple +from typing import Dict, Optional, List, Tuple, Set, Iterable, NamedTuple, Sequence from . import util, bitcoin from .util import profiler, WalletFileException, multisig_type, TxMinedInfo t@@ -40,15 +40,11 @@ from .logging import Logger OLD_SEED_VERSION = 4 # electrum versions < 2.0 NEW_SEED_VERSION = 11 # electrum versions >= 2.0 -FINAL_SEED_VERSION = 19 # electrum >= 2.7 will set this to prevent +FINAL_SEED_VERSION = 20 # electrum >= 2.7 will set this to prevent # old versions from overwriting new format -class JsonDBJsonEncoder(util.MyEncoder): - def default(self, obj): - if isinstance(obj, Transaction): - return str(obj) - return super().default(obj) +JsonDBJsonEncoder = util.MyEncoder class TxFeesValue(NamedTuple): t@@ -217,6 +213,7 @@ class JsonDB(Logger): self._convert_version_17() self._convert_version_18() self._convert_version_19() + self._convert_version_20() self.put('seed_version', FINAL_SEED_VERSION) # just to be sure self._after_upgrade_tasks() t@@ -425,10 +422,10 @@ class JsonDB(Logger): for txid, raw_tx in transactions.items(): tx = Transaction(raw_tx) for txin in tx.inputs(): - if txin['type'] == 'coinbase': + if txin.is_coinbase(): continue - prevout_hash = txin['prevout_hash'] - prevout_n = txin['prevout_n'] + prevout_hash = txin.prevout.txid.hex() + prevout_n = txin.prevout.out_idx spent_outpoints[prevout_hash][str(prevout_n)] = txid self.put('spent_outpoints', spent_outpoints) t@@ -448,10 +445,45 @@ class JsonDB(Logger): self.put('tx_fees', None) self.put('seed_version', 19) - # def _convert_version_20(self): - # TODO for "next" upgrade: - # - move "pw_hash_version" from keystore to storage - # pass + def _convert_version_20(self): + # store 'derivation' (prefix) and 'root_fingerprint' in all xpub-based keystores. + # store explicit None values if we cannot retroactively determine them + if not self._is_upgrade_method_needed(19, 19): + return + + from .bip32 import BIP32Node, convert_bip32_intpath_to_strpath + # note: This upgrade method reimplements bip32.root_fp_and_der_prefix_from_xkey. + # This is done deliberately, to avoid introducing that method as a dependency to this upgrade. + for ks_name in ('keystore', *['x{}/'.format(i) for i in range(1, 16)]): + ks = self.get(ks_name, None) + if ks is None: continue + xpub = ks.get('xpub', None) + if xpub is None: continue + bip32node = BIP32Node.from_xkey(xpub) + # derivation prefix + derivation_prefix = ks.get('derivation', None) + if derivation_prefix is None: + assert bip32node.depth >= 0, bip32node.depth + if bip32node.depth == 0: + derivation_prefix = 'm' + elif bip32node.depth == 1: + child_number_int = int.from_bytes(bip32node.child_number, 'big') + derivation_prefix = convert_bip32_intpath_to_strpath([child_number_int]) + ks['derivation'] = derivation_prefix + # root fingerprint + root_fingerprint = ks.get('ckcc_xfp', None) + if root_fingerprint is not None: + root_fingerprint = root_fingerprint.to_bytes(4, byteorder="little", signed=False).hex().lower() + if root_fingerprint is None: + if bip32node.depth == 0: + root_fingerprint = bip32node.calc_fingerprint_of_this_node().hex().lower() + elif bip32node.depth == 1: + root_fingerprint = bip32node.fingerprint.hex() + ks['root_fingerprint'] = root_fingerprint + ks.pop('ckcc_xfp', None) + self.put(ks_name, ks) + + self.put('seed_version', 20) def _convert_imported(self): if not self._is_upgrade_method_needed(0, 13): t@@ -758,16 +790,16 @@ class JsonDB(Logger): @modifier def add_change_address(self, addr): - self._addr_to_addr_index[addr] = (True, len(self.change_addresses)) + self._addr_to_addr_index[addr] = (1, len(self.change_addresses)) self.change_addresses.append(addr) @modifier def add_receiving_address(self, addr): - self._addr_to_addr_index[addr] = (False, len(self.receiving_addresses)) + self._addr_to_addr_index[addr] = (0, len(self.receiving_addresses)) self.receiving_addresses.append(addr) @locked - def get_address_index(self, address): + def get_address_index(self, address) -> Optional[Sequence[int]]: return self._addr_to_addr_index.get(address) @modifier t@@ -801,11 +833,11 @@ class JsonDB(Logger): self.data['addresses'][name] = [] self.change_addresses = self.data['addresses']['change'] self.receiving_addresses = self.data['addresses']['receiving'] - self._addr_to_addr_index = {} # key: address, value: (is_change, index) + self._addr_to_addr_index = {} # type: Dict[str, Sequence[int]] # key: address, value: (is_change, index) for i, addr in enumerate(self.receiving_addresses): - self._addr_to_addr_index[addr] = (False, i) + self._addr_to_addr_index[addr] = (0, i) for i, addr in enumerate(self.change_addresses): - self._addr_to_addr_index[addr] = (True, i) + self._addr_to_addr_index[addr] = (1, i) @profiler def _load_transactions(self): DIR diff --git a/electrum/keystore.py b/electrum/keystore.py t@@ -26,16 +26,17 @@ from unicodedata import normalize import hashlib -from typing import Tuple, TYPE_CHECKING, Union, Sequence +import re +from typing import Tuple, TYPE_CHECKING, Union, Sequence, Optional, Dict, List, NamedTuple from . import bitcoin, ecc, constants, bip32 -from .bitcoin import (deserialize_privkey, serialize_privkey, - public_key_to_p2pkh) +from .bitcoin import deserialize_privkey, serialize_privkey from .bip32 import (convert_bip32_path_to_list_of_uint32, BIP32_PRIME, - is_xpub, is_xprv, BIP32Node) + is_xpub, is_xprv, BIP32Node, normalize_bip32_derivation, + convert_bip32_intpath_to_strpath) from .ecc import string_to_number, number_to_string from .crypto import (pw_decode, pw_encode, sha256, sha256d, PW_HASH_VERSION_LATEST, - SUPPORTED_PW_HASH_VERSIONS, UnsupportedPasswordHashVersion) + SUPPORTED_PW_HASH_VERSIONS, UnsupportedPasswordHashVersion, hash_160) from .util import (InvalidPassword, WalletFileException, BitcoinException, bh2u, bfh, inv_dict) from .mnemonic import Mnemonic, load_wordlist, seed_type, is_seed t@@ -43,13 +44,14 @@ from .plugin import run_hook from .logging import Logger if TYPE_CHECKING: - from .transaction import Transaction + from .transaction import Transaction, PartialTransaction, PartialTxInput, PartialTxOutput class KeyStore(Logger): def __init__(self): Logger.__init__(self) + self.is_requesting_to_be_rewritten_to_wallet_file = False # type: bool def has_seed(self): return False t@@ -67,25 +69,19 @@ class KeyStore(Logger): """Returns whether the keystore can be encrypted with a password.""" raise NotImplementedError() - def get_tx_derivations(self, tx): + def get_tx_derivations(self, tx: 'PartialTransaction') -> Dict[str, Union[Sequence[int], str]]: keypairs = {} for txin in tx.inputs(): - num_sig = txin.get('num_sig') - if num_sig is None: + if txin.is_complete(): continue - x_signatures = txin['signatures'] - signatures = [sig for sig in x_signatures if sig] - if len(signatures) == num_sig: - # input is complete - continue - for k, x_pubkey in enumerate(txin['x_pubkeys']): - if x_signatures[k] is not None: + for pubkey in txin.pubkeys: + if pubkey in txin.part_sigs: # this pubkey already signed continue - derivation = self.get_pubkey_derivation(x_pubkey) + derivation = self.get_pubkey_derivation(pubkey, txin) if not derivation: continue - keypairs[x_pubkey] = derivation + keypairs[pubkey.hex()] = derivation return keypairs def can_sign(self, tx): t@@ -108,9 +104,64 @@ class KeyStore(Logger): def decrypt_message(self, sequence, message, password) -> bytes: raise NotImplementedError() # implemented by subclasses - def sign_transaction(self, tx: 'Transaction', password) -> None: + def sign_transaction(self, tx: 'PartialTransaction', password) -> None: raise NotImplementedError() # implemented by subclasses + def get_pubkey_derivation(self, pubkey: bytes, + txinout: Union['PartialTxInput', 'PartialTxOutput'], + *, only_der_suffix=True) \ + -> Union[Sequence[int], str, None]: + """Returns either a derivation int-list if the pubkey can be HD derived from this keystore, + the pubkey itself (hex) if the pubkey belongs to the keystore but not HD derived, + or None if the pubkey is unrelated. + """ + def test_der_suffix_against_pubkey(der_suffix: Sequence[int], pubkey: bytes) -> bool: + if len(der_suffix) != 2: + return False + if pubkey.hex() != self.derive_pubkey(*der_suffix): + return False + return True + + if hasattr(self, 'get_root_fingerprint'): + if pubkey not in txinout.bip32_paths: + return None + fp_found, path_found = txinout.bip32_paths[pubkey] + der_suffix = None + full_path = None + # try fp against our root + my_root_fingerprint_hex = self.get_root_fingerprint() + my_der_prefix_str = self.get_derivation_prefix() + ks_der_prefix = convert_bip32_path_to_list_of_uint32(my_der_prefix_str) if my_der_prefix_str else None + if (my_root_fingerprint_hex is not None and ks_der_prefix is not None and + fp_found.hex() == my_root_fingerprint_hex): + if path_found[:len(ks_der_prefix)] == ks_der_prefix: + der_suffix = path_found[len(ks_der_prefix):] + if not test_der_suffix_against_pubkey(der_suffix, pubkey): + der_suffix = None + # try fp against our intermediate fingerprint + if (der_suffix is None and hasattr(self, 'xpub') and + fp_found == BIP32Node.from_xkey(self.xpub).calc_fingerprint_of_this_node()): + der_suffix = path_found + if not test_der_suffix_against_pubkey(der_suffix, pubkey): + der_suffix = None + if der_suffix is None: + return None + if ks_der_prefix is not None: + full_path = ks_der_prefix + list(der_suffix) + return der_suffix if only_der_suffix else full_path + return None + + def find_my_pubkey_in_txinout( + self, txinout: Union['PartialTxInput', 'PartialTxOutput'], + *, only_der_suffix: bool = False + ) -> Tuple[Optional[bytes], Optional[List[int]]]: + # note: we assume that this cosigner only has one pubkey in this txin/txout + for pubkey in txinout.bip32_paths: + path = self.get_pubkey_derivation(pubkey, txinout, only_der_suffix=only_der_suffix) + if path and not isinstance(path, (str, bytes)): + return pubkey, list(path) + return None, None + class Software_KeyStore(KeyStore): t@@ -210,14 +261,10 @@ class Imported_KeyStore(Software_KeyStore): raise InvalidPassword() return privkey, compressed - def get_pubkey_derivation(self, x_pubkey): - if x_pubkey[0:2] in ['02', '03', '04']: - if x_pubkey in self.keypairs.keys(): - return x_pubkey - elif x_pubkey[0:2] == 'fd': - addr = bitcoin.script_to_address(x_pubkey[2:]) - if addr in self.addresses: - return self.addresses[addr].get('pubkey') + def get_pubkey_derivation(self, pubkey, txin, *, only_der_suffix=True): + if pubkey.hex() in self.keypairs: + return pubkey.hex() + return None def update_password(self, old_password, new_password): self.check_password(old_password) t@@ -230,7 +277,6 @@ class Imported_KeyStore(Software_KeyStore): self.pw_hash_version = PW_HASH_VERSION_LATEST - class Deterministic_KeyStore(Software_KeyStore): def __init__(self, d): t@@ -277,15 +323,85 @@ class Deterministic_KeyStore(Software_KeyStore): class Xpub: - def __init__(self): + def __init__(self, *, derivation_prefix: str = None, root_fingerprint: str = None): self.xpub = None self.xpub_receive = None self.xpub_change = None + # "key origin" info (subclass should persist these): + self._derivation_prefix = derivation_prefix # type: Optional[str] + self._root_fingerprint = root_fingerprint # type: Optional[str] + def get_master_public_key(self): return self.xpub - def derive_pubkey(self, for_change, n): + def get_derivation_prefix(self) -> Optional[str]: + """Returns to bip32 path from some root node to self.xpub + Note that the return value might be None; if it is unknown. + """ + return self._derivation_prefix + + def get_root_fingerprint(self) -> Optional[str]: + """Returns the bip32 fingerprint of the top level node. + This top level node is the node at the beginning of the derivation prefix, + i.e. applying the derivation prefix to it will result self.xpub + Note that the return value might be None; if it is unknown. + """ + return self._root_fingerprint + + def get_fp_and_derivation_to_be_used_in_partial_tx(self, der_suffix: Sequence[int], *, + only_der_suffix: bool = True) -> Tuple[bytes, Sequence[int]]: + """Returns fingerprint and derivation path corresponding to a derivation suffix. + The fingerprint is either the root fp or the intermediate fp, depending on what is available + and 'only_der_suffix', and the derivation path is adjusted accordingly. + """ + fingerprint_hex = self.get_root_fingerprint() + der_prefix_str = self.get_derivation_prefix() + if not only_der_suffix and fingerprint_hex is not None and der_prefix_str is not None: + # use root fp, and true full path + fingerprint_bytes = bfh(fingerprint_hex) + der_prefix_ints = convert_bip32_path_to_list_of_uint32(der_prefix_str) + else: + # use intermediate fp, and claim der suffix is the full path + fingerprint_bytes = BIP32Node.from_xkey(self.xpub).calc_fingerprint_of_this_node() + der_prefix_ints = convert_bip32_path_to_list_of_uint32('m') + der_full = der_prefix_ints + list(der_suffix) + return fingerprint_bytes, der_full + + def get_xpub_to_be_used_in_partial_tx(self, *, only_der_suffix: bool) -> str: + assert self.xpub + fp_bytes, der_full = self.get_fp_and_derivation_to_be_used_in_partial_tx(der_suffix=[], + only_der_suffix=only_der_suffix) + bip32node = BIP32Node.from_xkey(self.xpub) + depth = len(der_full) + child_number_int = der_full[-1] if len(der_full) >= 1 else 0 + child_number_bytes = child_number_int.to_bytes(length=4, byteorder="big") + fingerprint = bytes(4) if depth == 0 else bip32node.fingerprint + bip32node = bip32node._replace(depth=depth, + fingerprint=fingerprint, + child_number=child_number_bytes) + return bip32node.to_xpub() + + def add_key_origin_from_root_node(self, *, derivation_prefix: str, root_node: BIP32Node): + assert self.xpub + # try to derive ourselves from what we were given + child_node1 = root_node.subkey_at_private_derivation(derivation_prefix) + child_pubkey_bytes1 = child_node1.eckey.get_public_key_bytes(compressed=True) + child_node2 = BIP32Node.from_xkey(self.xpub) + child_pubkey_bytes2 = child_node2.eckey.get_public_key_bytes(compressed=True) + if child_pubkey_bytes1 != child_pubkey_bytes2: + raise Exception("(xpub, derivation_prefix, root_node) inconsistency") + self.add_key_origin(derivation_prefix=derivation_prefix, + root_fingerprint=root_node.calc_fingerprint_of_this_node().hex().lower()) + + def add_key_origin(self, *, derivation_prefix: Optional[str], root_fingerprint: Optional[str]): + assert self.xpub + self._root_fingerprint = root_fingerprint + self._derivation_prefix = normalize_bip32_derivation(derivation_prefix) + + def derive_pubkey(self, for_change, n) -> str: + for_change = int(for_change) + assert for_change in (0, 1) xpub = self.xpub_change if for_change else self.xpub_receive if xpub is None: rootnode = BIP32Node.from_xkey(self.xpub) t@@ -301,54 +417,13 @@ class Xpub: node = BIP32Node.from_xkey(xpub).subkey_at_public_derivation(sequence) return node.eckey.get_public_key_hex(compressed=True) - def get_xpubkey(self, c, i): - def encode_path_int(path_int) -> str: - if path_int < 0xffff: - hex = bitcoin.int_to_hex(path_int, 2) - else: - hex = 'ffff' + bitcoin.int_to_hex(path_int, 4) - return hex - s = ''.join(map(encode_path_int, (c, i))) - return 'ff' + bh2u(bitcoin.DecodeBase58Check(self.xpub)) + s - - @classmethod - def parse_xpubkey(self, pubkey): - # type + xpub + derivation - assert pubkey[0:2] == 'ff' - pk = bfh(pubkey) - # xpub: - pk = pk[1:] - xkey = bitcoin.EncodeBase58Check(pk[0:78]) - # derivation: - dd = pk[78:] - s = [] - while dd: - # 2 bytes for derivation path index - n = int.from_bytes(dd[0:2], byteorder="little") - dd = dd[2:] - # in case of overflow, drop these 2 bytes; and use next 4 bytes instead - if n == 0xffff: - n = int.from_bytes(dd[0:4], byteorder="little") - dd = dd[4:] - s.append(n) - assert len(s) == 2 - return xkey, s - - def get_pubkey_derivation(self, x_pubkey): - if x_pubkey[0:2] != 'ff': - return - xpub, derivation = self.parse_xpubkey(x_pubkey) - if self.xpub != xpub: - return - return derivation - class BIP32_KeyStore(Deterministic_KeyStore, Xpub): type = 'bip32' def __init__(self, d): - Xpub.__init__(self) + Xpub.__init__(self, derivation_prefix=d.get('derivation'), root_fingerprint=d.get('root_fingerprint')) Deterministic_KeyStore.__init__(self, d) self.xpub = d.get('xpub') self.xprv = d.get('xprv') t@@ -360,6 +435,8 @@ class BIP32_KeyStore(Deterministic_KeyStore, Xpub): d = Deterministic_KeyStore.dump(self) d['xpub'] = self.xpub d['xprv'] = self.xprv + d['derivation'] = self.get_derivation_prefix() + d['root_fingerprint'] = self.get_root_fingerprint() return d def get_master_private_key(self, password): t@@ -388,14 +465,22 @@ class BIP32_KeyStore(Deterministic_KeyStore, Xpub): def is_watching_only(self): return self.xprv is None + def add_xpub(self, xpub): + assert is_xpub(xpub) + self.xpub = xpub + root_fingerprint, derivation_prefix = bip32.root_fp_and_der_prefix_from_xkey(xpub) + self.add_key_origin(derivation_prefix=derivation_prefix, root_fingerprint=root_fingerprint) + def add_xprv(self, xprv): + assert is_xprv(xprv) self.xprv = xprv - self.xpub = bip32.xpub_from_xprv(xprv) + self.add_xpub(bip32.xpub_from_xprv(xprv)) def add_xprv_from_seed(self, bip32_seed, xtype, derivation): rootnode = BIP32Node.from_rootseed(bip32_seed, xtype=xtype) node = rootnode.subkey_at_private_derivation(derivation) self.add_xprv(node.to_xprv()) + self.add_key_origin_from_root_node(derivation_prefix=derivation, root_node=rootnode) def get_private_key(self, sequence, password): xprv = self.get_master_private_key(password) t@@ -415,6 +500,7 @@ class Old_KeyStore(Deterministic_KeyStore): def __init__(self, d): Deterministic_KeyStore.__init__(self, d) self.mpk = d.get('mpk') + self._root_fingerprint = None def get_hex_seed(self, password): return pw_decode(self.seed, password, version=self.pw_hash_version).encode('utf8') t@@ -477,7 +563,7 @@ class Old_KeyStore(Deterministic_KeyStore): public_key = master_public_key + z*ecc.generator() return public_key.get_public_key_hex(compressed=False) - def derive_pubkey(self, for_change, n): + def derive_pubkey(self, for_change, n) -> str: return self.get_pubkey_from_mpk(self.mpk, for_change, n) def get_private_key_from_stretched_exponent(self, for_change, n, secexp): t@@ -508,31 +594,25 @@ class Old_KeyStore(Deterministic_KeyStore): def get_master_public_key(self): return self.mpk - def get_xpubkey(self, for_change, n): - s = ''.join(map(lambda x: bitcoin.int_to_hex(x,2), (for_change, n))) - return 'fe' + self.mpk + s - - @classmethod - def parse_xpubkey(self, x_pubkey): - assert x_pubkey[0:2] == 'fe' - pk = x_pubkey[2:] - mpk = pk[0:128] - dd = pk[128:] - s = [] - while dd: - n = int(bitcoin.rev_hex(dd[0:4]), 16) - dd = dd[4:] - s.append(n) - assert len(s) == 2 - return mpk, s - - def get_pubkey_derivation(self, x_pubkey): - if x_pubkey[0:2] != 'fe': - return - mpk, derivation = self.parse_xpubkey(x_pubkey) - if self.mpk != mpk: - return - return derivation + def get_derivation_prefix(self) -> str: + return 'm' + + def get_root_fingerprint(self) -> str: + if self._root_fingerprint is None: + master_public_key = ecc.ECPubkey(bfh('04'+self.mpk)) + xfp = hash_160(master_public_key.get_public_key_bytes(compressed=True))[0:4] + self._root_fingerprint = xfp.hex().lower() + return self._root_fingerprint + + # TODO Old_KeyStore and Xpub could share a common baseclass? + def get_fp_and_derivation_to_be_used_in_partial_tx(self, der_suffix: Sequence[int], *, + only_der_suffix: bool = True) -> Tuple[bytes, Sequence[int]]: + fingerprint_hex = self.get_root_fingerprint() + der_prefix_str = self.get_derivation_prefix() + fingerprint_bytes = bfh(fingerprint_hex) + der_prefix_ints = convert_bip32_path_to_list_of_uint32(der_prefix_str) + der_full = der_prefix_ints + list(der_suffix) + return fingerprint_bytes, der_full def update_password(self, old_password, new_password): self.check_password(old_password) t@@ -554,14 +634,13 @@ class Hardware_KeyStore(KeyStore, Xpub): type = 'hardware' def __init__(self, d): - Xpub.__init__(self) + Xpub.__init__(self, derivation_prefix=d.get('derivation'), root_fingerprint=d.get('root_fingerprint')) KeyStore.__init__(self) # Errors and other user interaction is done through the wallet's # handler. The handler is per-window and preserved across # device reconnects self.xpub = d.get('xpub') self.label = d.get('label') - self.derivation = d.get('derivation') self.handler = None run_hook('init_keystore', self) t@@ -582,7 +661,8 @@ class Hardware_KeyStore(KeyStore, Xpub): 'type': self.type, 'hw_type': self.hw_type, 'xpub': self.xpub, - 'derivation':self.derivation, + 'derivation': self.get_derivation_prefix(), + 'root_fingerprint': self.get_root_fingerprint(), 'label':self.label, } t@@ -624,6 +704,16 @@ class Hardware_KeyStore(KeyStore, Xpub): def ready_to_sign(self): return super().ready_to_sign() and self.has_usable_connection_with_device() + def opportunistically_fill_in_missing_info_from_device(self, client): + assert client is not None + if self._root_fingerprint is None: + # digitalbitbox (at least) does not reveal xpubs corresponding to unhardened paths + # so ask for a direct child, and read out fingerprint from that: + child_of_root_xpub = client.get_xpub("m/0'", xtype='standard') + root_fingerprint = BIP32Node.from_xkey(child_of_root_xpub).fingerprint.hex().lower() + self._root_fingerprint = root_fingerprint + self.is_requesting_to_be_rewritten_to_wallet_file = True + def bip39_normalize_passphrase(passphrase): return normalize('NFKD', passphrase or '') t@@ -684,16 +774,17 @@ PURPOSE48_SCRIPT_TYPES_INV = inv_dict(PURPOSE48_SCRIPT_TYPES) def xtype_from_derivation(derivation: str) -> str: """Returns the script type to be used for this derivation.""" - if derivation.startswith("m/84'"): - return 'p2wpkh' - elif derivation.startswith("m/49'"): - return 'p2wpkh-p2sh' - elif derivation.startswith("m/44'"): - return 'standard' - elif derivation.startswith("m/45'"): - return 'standard' - bip32_indices = convert_bip32_path_to_list_of_uint32(derivation) + if len(bip32_indices) >= 1: + if bip32_indices[0] == 84 + BIP32_PRIME: + return 'p2wpkh' + elif bip32_indices[0] == 49 + BIP32_PRIME: + return 'p2wpkh-p2sh' + elif bip32_indices[0] == 44 + BIP32_PRIME: + return 'standard' + elif bip32_indices[0] == 45 + BIP32_PRIME: + return 'standard' + if len(bip32_indices) >= 4: if bip32_indices[0] == 48 + BIP32_PRIME: # m / purpose' / coin_type' / account' / script_type' / change / address_index t@@ -704,40 +795,6 @@ def xtype_from_derivation(derivation: str) -> str: return 'standard' -# extended pubkeys - -def is_xpubkey(x_pubkey): - return x_pubkey[0:2] == 'ff' - - -def parse_xpubkey(x_pubkey): - assert x_pubkey[0:2] == 'ff' - return BIP32_KeyStore.parse_xpubkey(x_pubkey) - - -def xpubkey_to_address(x_pubkey): - if x_pubkey[0:2] == 'fd': - address = bitcoin.script_to_address(x_pubkey[2:]) - return x_pubkey, address - if x_pubkey[0:2] in ['02', '03', '04']: - pubkey = x_pubkey - elif x_pubkey[0:2] == 'ff': - xpub, s = BIP32_KeyStore.parse_xpubkey(x_pubkey) - pubkey = BIP32_KeyStore.get_pubkey_from_xpub(xpub, s) - elif x_pubkey[0:2] == 'fe': - mpk, s = Old_KeyStore.parse_xpubkey(x_pubkey) - pubkey = Old_KeyStore.get_pubkey_from_mpk(mpk, s[0], s[1]) - else: - raise BitcoinException("Cannot parse pubkey. prefix: {}" - .format(x_pubkey[0:2])) - if pubkey: - address = public_key_to_p2pkh(bfh(pubkey)) - return pubkey, address - -def xpubkey_to_pubkey(x_pubkey): - pubkey, address = xpubkey_to_address(x_pubkey) - return pubkey - hw_keystores = {} def register_keystore(hw_type, constructor): t@@ -770,7 +827,7 @@ def load_keystore(storage, name) -> KeyStore: def is_old_mpk(mpk: str) -> bool: try: - int(mpk, 16) + int(mpk, 16) # test if hex string except: return False if len(mpk) != 128: t@@ -804,16 +861,18 @@ def is_private_key_list(text, *, allow_spaces_inside_key=True, raise_on_error=Fa raise_on_error=raise_on_error)) -is_mpk = lambda x: is_old_mpk(x) or is_xpub(x) -is_private = lambda x: is_seed(x) or is_xprv(x) or is_private_key_list(x) -is_master_key = lambda x: is_old_mpk(x) or is_xprv(x) or is_xpub(x) -is_private_key = lambda x: is_xprv(x) or is_private_key_list(x) -is_bip32_key = lambda x: is_xprv(x) or is_xpub(x) +def is_master_key(x): + return is_old_mpk(x) or is_bip32_key(x) + + +def is_bip32_key(x): + return is_xprv(x) or is_xpub(x) def bip44_derivation(account_id, bip43_purpose=44): coin = constants.net.BIP44_COIN_TYPE - return "m/%d'/%d'/%d'" % (bip43_purpose, coin, int(account_id)) + der = "m/%d'/%d'/%d'" % (bip43_purpose, coin, int(account_id)) + return normalize_bip32_derivation(der) def purpose48_derivation(account_id: int, xtype: str) -> str: t@@ -824,7 +883,8 @@ def purpose48_derivation(account_id: int, xtype: str) -> str: script_type_int = PURPOSE48_SCRIPT_TYPES.get(xtype) if script_type_int is None: raise Exception('unknown xtype: {}'.format(xtype)) - return "m/%d'/%d'/%d'/%d'" % (bip43_purpose, coin, account_id, script_type_int) + der = "m/%d'/%d'/%d'/%d'" % (bip43_purpose, coin, account_id, script_type_int) + return normalize_bip32_derivation(der) def from_seed(seed, passphrase, is_p2sh=False): t@@ -861,14 +921,12 @@ def from_old_mpk(mpk): def from_xpub(xpub): k = BIP32_KeyStore({}) - k.xpub = xpub + k.add_xpub(xpub) return k def from_xprv(xprv): - xpub = bip32.xpub_from_xprv(xprv) k = BIP32_KeyStore({}) - k.xprv = xprv - k.xpub = xpub + k.add_xprv(xprv) return k def from_master_key(text): DIR diff --git a/electrum/lnchannel.py b/electrum/lnchannel.py t@@ -32,10 +32,9 @@ import time from . import ecc from .util import bfh, bh2u -from .bitcoin import TYPE_SCRIPT, TYPE_ADDRESS from .bitcoin import redeem_script_to_address from .crypto import sha256, sha256d -from .transaction import Transaction +from .transaction import Transaction, PartialTransaction from .logging import Logger from .lnonion import decode_onion_error t@@ -528,19 +527,19 @@ class Channel(Logger): ctx = self.make_commitment(subject, point, ctn) return secret, ctx - def get_commitment(self, subject, ctn): + def get_commitment(self, subject, ctn) -> PartialTransaction: secret, ctx = self.get_secret_and_commitment(subject, ctn) return ctx - def get_next_commitment(self, subject: HTLCOwner) -> Transaction: + def get_next_commitment(self, subject: HTLCOwner) -> PartialTransaction: ctn = self.get_next_ctn(subject) return self.get_commitment(subject, ctn) - def get_latest_commitment(self, subject: HTLCOwner) -> Transaction: + def get_latest_commitment(self, subject: HTLCOwner) -> PartialTransaction: ctn = self.get_latest_ctn(subject) return self.get_commitment(subject, ctn) - def get_oldest_unrevoked_commitment(self, subject: HTLCOwner) -> Transaction: + def get_oldest_unrevoked_commitment(self, subject: HTLCOwner) -> PartialTransaction: ctn = self.get_oldest_unrevoked_ctn(subject) return self.get_commitment(subject, ctn) t@@ -603,7 +602,7 @@ class Channel(Logger): self.hm.recv_fail(htlc_id) def pending_local_fee(self): - return self.constraints.capacity - sum(x[2] for x in self.get_next_commitment(LOCAL).outputs()) + return self.constraints.capacity - sum(x.value for x in self.get_next_commitment(LOCAL).outputs()) def update_fee(self, feerate: int, from_us: bool): # feerate uses sat/kw t@@ -658,7 +657,7 @@ class Channel(Logger): def __str__(self): return str(self.serialize()) - def make_commitment(self, subject, this_point, ctn) -> Transaction: + def make_commitment(self, subject, this_point, ctn) -> PartialTransaction: assert type(subject) is HTLCOwner feerate = self.get_feerate(subject, ctn) other = REMOTE if LOCAL == subject else LOCAL t@@ -717,21 +716,20 @@ class Channel(Logger): onchain_fees, htlcs=htlcs) - def get_local_index(self): - return int(self.config[LOCAL].multisig_key.pubkey > self.config[REMOTE].multisig_key.pubkey) - def make_closing_tx(self, local_script: bytes, remote_script: bytes, - fee_sat: int) -> Tuple[bytes, Transaction]: + fee_sat: int) -> Tuple[bytes, PartialTransaction]: """ cooperative close """ - _, outputs = make_commitment_outputs({ + _, outputs = make_commitment_outputs( + fees_per_participant={ LOCAL: fee_sat * 1000 if self.constraints.is_initiator else 0, REMOTE: fee_sat * 1000 if not self.constraints.is_initiator else 0, }, - self.balance(LOCAL), - self.balance(REMOTE), - (TYPE_SCRIPT, bh2u(local_script)), - (TYPE_SCRIPT, bh2u(remote_script)), - [], self.config[LOCAL].dust_limit_sat) + local_amount_msat=self.balance(LOCAL), + remote_amount_msat=self.balance(REMOTE), + local_script=bh2u(local_script), + remote_script=bh2u(remote_script), + htlcs=[], + dust_limit_sat=self.config[LOCAL].dust_limit_sat) closing_tx = make_closing_tx(self.config[LOCAL].multisig_key.pubkey, self.config[REMOTE].multisig_key.pubkey, t@@ -744,25 +742,23 @@ class Channel(Logger): sig = ecc.sig_string_from_der_sig(der_sig[:-1]) return sig, closing_tx - def signature_fits(self, tx): + def signature_fits(self, tx: PartialTransaction): remote_sig = self.config[LOCAL].current_commitment_signature preimage_hex = tx.serialize_preimage(0) - pre_hash = sha256d(bfh(preimage_hex)) + msg_hash = sha256d(bfh(preimage_hex)) assert remote_sig - res = ecc.verify_signature(self.config[REMOTE].multisig_key.pubkey, remote_sig, pre_hash) + res = ecc.verify_signature(self.config[REMOTE].multisig_key.pubkey, remote_sig, msg_hash) return res def force_close_tx(self): tx = self.get_latest_commitment(LOCAL) assert self.signature_fits(tx) - tx = Transaction(str(tx)) - tx.deserialize(True) tx.sign({bh2u(self.config[LOCAL].multisig_key.pubkey): (self.config[LOCAL].multisig_key.privkey, True)}) remote_sig = self.config[LOCAL].current_commitment_signature remote_sig = ecc.der_sig_from_sig_string(remote_sig) + b"\x01" - sigs = tx._inputs[0]["signatures"] - none_idx = sigs.index(None) - tx.add_signature_to_txin(0, none_idx, bh2u(remote_sig)) + tx.add_signature_to_txin(txin_idx=0, + signing_pubkey=self.config[REMOTE].multisig_key.pubkey.hex(), + sig=remote_sig.hex()) assert tx.is_complete() return tx DIR diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py t@@ -11,7 +11,7 @@ import asyncio import os import time from functools import partial -from typing import List, Tuple, Dict, TYPE_CHECKING, Optional, Callable +from typing import List, Tuple, Dict, TYPE_CHECKING, Optional, Callable, Union import traceback import sys from datetime import datetime t@@ -24,7 +24,7 @@ from . import ecc from .ecc import sig_string_from_r_and_s, get_r_and_s_from_sig_string, der_sig_from_sig_string from . import constants from .util import bh2u, bfh, log_exceptions, list_enabled_bits, ignore_exceptions, chunks, SilentTaskGroup -from .transaction import Transaction, TxOutput +from .transaction import Transaction, TxOutput, PartialTxOutput from .logging import Logger from .lnonion import (new_onion_packet, decode_onion_error, OnionFailureCode, calc_hops_data_for_payment, process_onion_packet, OnionPacket, construct_onion_error, OnionRoutingFailureMessage, t@@ -48,7 +48,7 @@ from .interface import GracefulDisconnect, NetworkException from .lnrouter import fee_for_edge_msat if TYPE_CHECKING: - from .lnworker import LNWorker + from .lnworker import LNWorker, LNGossip, LNWallet from .lnrouter import RouteEdge t@@ -62,7 +62,7 @@ def channel_id_from_funding_tx(funding_txid: str, funding_index: int) -> Tuple[b class Peer(Logger): - def __init__(self, lnworker: 'LNWorker', pubkey:bytes, transport: LNTransportBase): + def __init__(self, lnworker: Union['LNGossip', 'LNWallet'], pubkey:bytes, transport: LNTransportBase): self.initialized = asyncio.Event() self.querying = asyncio.Event() self.transport = transport t@@ -483,8 +483,8 @@ class Peer(Logger): push_msat: int, temp_channel_id: bytes) -> Channel: wallet = self.lnworker.wallet # dry run creating funding tx to see if we even have enough funds - funding_tx_test = wallet.mktx([TxOutput(bitcoin.TYPE_ADDRESS, wallet.dummy_address(), funding_sat)], - password, nonlocal_only=True) + funding_tx_test = wallet.mktx(outputs=[PartialTxOutput.from_address_and_value(wallet.dummy_address(), funding_sat)], + password=password, nonlocal_only=True) await asyncio.wait_for(self.initialized.wait(), LN_P2P_NETWORK_TIMEOUT) feerate = self.lnworker.current_feerate_per_kw() local_config = self.make_local_config(funding_sat, push_msat, LOCAL) t@@ -563,8 +563,8 @@ class Peer(Logger): # create funding tx redeem_script = funding_output_script(local_config, remote_config) funding_address = bitcoin.redeem_script_to_address('p2wsh', redeem_script) - funding_output = TxOutput(bitcoin.TYPE_ADDRESS, funding_address, funding_sat) - funding_tx = wallet.mktx([funding_output], password, nonlocal_only=True) + funding_output = PartialTxOutput.from_address_and_value(funding_address, funding_sat) + funding_tx = wallet.mktx(outputs=[funding_output], password=password, nonlocal_only=True) funding_txid = funding_tx.txid() funding_index = funding_tx.outputs().index(funding_output) # remote commitment transaction t@@ -691,7 +691,7 @@ class Peer(Logger): outp = funding_tx.outputs()[funding_idx] redeem_script = funding_output_script(chan.config[REMOTE], chan.config[LOCAL]) funding_address = bitcoin.redeem_script_to_address('p2wsh', redeem_script) - if outp != TxOutput(bitcoin.TYPE_ADDRESS, funding_address, funding_sat): + if not (outp.address == funding_address and outp.value == funding_sat): chan.set_state('DISCONNECTED') raise Exception('funding outpoint mismatch') t@@ -1485,11 +1485,13 @@ class Peer(Logger): break # TODO: negotiate better our_fee = their_fee - # index of our_sig - i = chan.get_local_index() # add signatures - closing_tx.add_signature_to_txin(0, i, bh2u(der_sig_from_sig_string(our_sig) + b'\x01')) - closing_tx.add_signature_to_txin(0, 1-i, bh2u(der_sig_from_sig_string(their_sig) + b'\x01')) + closing_tx.add_signature_to_txin(txin_idx=0, + signing_pubkey=chan.config[LOCAL].multisig_key.pubkey, + sig=bh2u(der_sig_from_sig_string(our_sig) + b'\x01')) + closing_tx.add_signature_to_txin(txin_idx=0, + signing_pubkey=chan.config[REMOTE].multisig_key.pubkey, + sig=bh2u(der_sig_from_sig_string(their_sig) + b'\x01')) # broadcast await self.network.broadcast_transaction(closing_tx) return closing_tx.txid() DIR diff --git a/electrum/lnsweep.py b/electrum/lnsweep.py t@@ -6,7 +6,7 @@ from typing import Optional, Dict, List, Tuple, TYPE_CHECKING, NamedTuple, Calla from enum import Enum, auto from .util import bfh, bh2u -from .bitcoin import TYPE_ADDRESS, redeem_script_to_address, dust_threshold +from .bitcoin import redeem_script_to_address, dust_threshold from . import ecc from .lnutil import (make_commitment_output_to_remote_address, make_commitment_output_to_local_witness_script, derive_privkey, derive_pubkey, derive_blinded_pubkey, derive_blinded_privkey, t@@ -15,7 +15,8 @@ from .lnutil import (make_commitment_output_to_remote_address, make_commitment_o get_ordered_channel_configs, privkey_to_pubkey, get_per_commitment_secret_from_seed, RevocationStore, extract_ctn_from_tx_and_chan, UnableToDeriveSecret, SENT, RECEIVED, map_htlcs_to_ctx_output_idxs, Direction) -from .transaction import Transaction, TxOutput, construct_witness +from .transaction import (Transaction, TxOutput, construct_witness, PartialTransaction, PartialTxInput, + PartialTxOutput, TxOutpoint) from .simple_config import SimpleConfig from .logging import get_logger t@@ -254,7 +255,7 @@ def create_sweeptxs_for_our_ctx(*, chan: 'Channel', ctx: Transaction, is_revocation=False, config=chan.lnworker.config) # side effect - txs[htlc_tx.prevout(0)] = SweepInfo(name='first-stage-htlc', + txs[htlc_tx.inputs()[0].prevout.to_str()] = SweepInfo(name='first-stage-htlc', csv_delay=0, cltv_expiry=htlc_tx.locktime, gen_tx=lambda: htlc_tx) t@@ -336,7 +337,7 @@ def create_sweeptxs_for_their_ctx(*, chan: 'Channel', ctx: Transaction, gen_tx = create_sweeptx_for_their_revoked_ctx(chan, ctx, per_commitment_secret, chan.sweep_address) if gen_tx: tx = gen_tx() - txs[tx.prevout(0)] = SweepInfo(name='to_local_for_revoked_ctx', + txs[tx.inputs()[0].prevout.to_str()] = SweepInfo(name='to_local_for_revoked_ctx', csv_delay=0, cltv_expiry=0, gen_tx=gen_tx) t@@ -433,66 +434,58 @@ def create_htlctx_that_spends_from_our_ctx(chan: 'Channel', our_pcp: bytes, local_htlc_sig = bfh(htlc_tx.sign_txin(0, local_htlc_privkey)) txin = htlc_tx.inputs()[0] witness_program = bfh(Transaction.get_preimage_script(txin)) - txin['witness'] = bh2u(make_htlc_tx_witness(remote_htlc_sig, local_htlc_sig, preimage, witness_program)) + txin.witness = make_htlc_tx_witness(remote_htlc_sig, local_htlc_sig, preimage, witness_program) return witness_script, htlc_tx def create_sweeptx_their_ctx_htlc(ctx: Transaction, witness_script: bytes, sweep_address: str, preimage: Optional[bytes], output_idx: int, privkey: bytes, is_revocation: bool, - cltv_expiry: int, config: SimpleConfig) -> Optional[Transaction]: + cltv_expiry: int, config: SimpleConfig) -> Optional[PartialTransaction]: assert type(cltv_expiry) is int preimage = preimage or b'' # preimage is required iff (not is_revocation and htlc is offered) val = ctx.outputs()[output_idx].value - sweep_inputs = [{ - 'scriptSig': '', - 'type': 'p2wsh', - 'signatures': [], - 'num_sig': 0, - 'prevout_n': output_idx, - 'prevout_hash': ctx.txid(), - 'value': val, - 'coinbase': False, - 'preimage_script': bh2u(witness_script), - }] + prevout = TxOutpoint(txid=bfh(ctx.txid()), out_idx=output_idx) + txin = PartialTxInput(prevout=prevout) + txin._trusted_value_sats = val + txin.witness_script = witness_script + txin.script_sig = b'' + sweep_inputs = [txin] tx_size_bytes = 200 # TODO (depends on offered/received and is_revocation) fee = config.estimate_fee(tx_size_bytes, allow_fallback_to_static_rates=True) outvalue = val - fee if outvalue <= dust_threshold(): return None - sweep_outputs = [TxOutput(TYPE_ADDRESS, sweep_address, outvalue)] - tx = Transaction.from_io(sweep_inputs, sweep_outputs, version=2, locktime=cltv_expiry) + sweep_outputs = [PartialTxOutput.from_address_and_value(sweep_address, outvalue)] + tx = PartialTransaction.from_io(sweep_inputs, sweep_outputs, version=2, locktime=cltv_expiry) sig = bfh(tx.sign_txin(0, privkey)) if not is_revocation: witness = construct_witness([sig, preimage, witness_script]) else: revocation_pubkey = privkey_to_pubkey(privkey) witness = construct_witness([sig, revocation_pubkey, witness_script]) - tx.inputs()[0]['witness'] = witness + tx.inputs()[0].witness = bfh(witness) assert tx.is_complete() return tx def create_sweeptx_their_ctx_to_remote(sweep_address: str, ctx: Transaction, output_idx: int, our_payment_privkey: ecc.ECPrivkey, - config: SimpleConfig) -> Optional[Transaction]: + config: SimpleConfig) -> Optional[PartialTransaction]: our_payment_pubkey = our_payment_privkey.get_public_key_hex(compressed=True) val = ctx.outputs()[output_idx].value - sweep_inputs = [{ - 'type': 'p2wpkh', - 'x_pubkeys': [our_payment_pubkey], - 'num_sig': 1, - 'prevout_n': output_idx, - 'prevout_hash': ctx.txid(), - 'value': val, - 'coinbase': False, - 'signatures': [None], - }] + prevout = TxOutpoint(txid=bfh(ctx.txid()), out_idx=output_idx) + txin = PartialTxInput(prevout=prevout) + txin._trusted_value_sats = val + txin.script_type = 'p2wpkh' + txin.pubkeys = [bfh(our_payment_pubkey)] + txin.num_sig = 1 + sweep_inputs = [txin] tx_size_bytes = 110 # approx size of p2wpkh->p2wpkh fee = config.estimate_fee(tx_size_bytes, allow_fallback_to_static_rates=True) outvalue = val - fee if outvalue <= dust_threshold(): return None - sweep_outputs = [TxOutput(TYPE_ADDRESS, sweep_address, outvalue)] - sweep_tx = Transaction.from_io(sweep_inputs, sweep_outputs) + sweep_outputs = [PartialTxOutput.from_address_and_value(sweep_address, outvalue)] + sweep_tx = PartialTransaction.from_io(sweep_inputs, sweep_outputs) sweep_tx.set_rbf(True) sweep_tx.sign({our_payment_pubkey: (our_payment_privkey.get_secret_bytes(), True)}) if not sweep_tx.is_complete(): t@@ -502,7 +495,7 @@ def create_sweeptx_their_ctx_to_remote(sweep_address: str, ctx: Transaction, out def create_sweeptx_ctx_to_local(*, sweep_address: str, ctx: Transaction, output_idx: int, witness_script: str, privkey: bytes, is_revocation: bool, config: SimpleConfig, - to_self_delay: int=None) -> Optional[Transaction]: + to_self_delay: int=None) -> Optional[PartialTransaction]: """Create a txn that sweeps the 'to_local' output of a commitment transaction into our wallet. t@@ -510,61 +503,51 @@ def create_sweeptx_ctx_to_local(*, sweep_address: str, ctx: Transaction, output_ is_revocation: tells us which ^ """ val = ctx.outputs()[output_idx].value - sweep_inputs = [{ - 'scriptSig': '', - 'type': 'p2wsh', - 'signatures': [], - 'num_sig': 0, - 'prevout_n': output_idx, - 'prevout_hash': ctx.txid(), - 'value': val, - 'coinbase': False, - 'preimage_script': witness_script, - }] + prevout = TxOutpoint(txid=bfh(ctx.txid()), out_idx=output_idx) + txin = PartialTxInput(prevout=prevout) + txin._trusted_value_sats = val + txin.script_sig = b'' + txin.witness_script = bfh(witness_script) + sweep_inputs = [txin] if not is_revocation: assert isinstance(to_self_delay, int) - sweep_inputs[0]['sequence'] = to_self_delay + sweep_inputs[0].nsequence = to_self_delay tx_size_bytes = 121 # approx size of to_local -> p2wpkh fee = config.estimate_fee(tx_size_bytes, allow_fallback_to_static_rates=True) outvalue = val - fee if outvalue <= dust_threshold(): return None - sweep_outputs = [TxOutput(TYPE_ADDRESS, sweep_address, outvalue)] - sweep_tx = Transaction.from_io(sweep_inputs, sweep_outputs, version=2) + sweep_outputs = [PartialTxOutput.from_address_and_value(sweep_address, outvalue)] + sweep_tx = PartialTransaction.from_io(sweep_inputs, sweep_outputs, version=2) sig = sweep_tx.sign_txin(0, privkey) witness = construct_witness([sig, int(is_revocation), witness_script]) - sweep_tx.inputs()[0]['witness'] = witness + sweep_tx.inputs()[0].witness = bfh(witness) return sweep_tx def create_sweeptx_that_spends_htlctx_that_spends_htlc_in_ctx(*, htlc_tx: Transaction, htlctx_witness_script: bytes, sweep_address: str, privkey: bytes, is_revocation: bool, to_self_delay: int, - config: SimpleConfig) -> Optional[Transaction]: + config: SimpleConfig) -> Optional[PartialTransaction]: val = htlc_tx.outputs()[0].value - sweep_inputs = [{ - 'scriptSig': '', - 'type': 'p2wsh', - 'signatures': [], - 'num_sig': 0, - 'prevout_n': 0, - 'prevout_hash': htlc_tx.txid(), - 'value': val, - 'coinbase': False, - 'preimage_script': bh2u(htlctx_witness_script), - }] + prevout = TxOutpoint(txid=bfh(htlc_tx.txid()), out_idx=0) + txin = PartialTxInput(prevout=prevout) + txin._trusted_value_sats = val + txin.script_sig = b'' + txin.witness_script = htlctx_witness_script + sweep_inputs = [txin] if not is_revocation: assert isinstance(to_self_delay, int) - sweep_inputs[0]['sequence'] = to_self_delay + sweep_inputs[0].nsequence = to_self_delay tx_size_bytes = 200 # TODO fee = config.estimate_fee(tx_size_bytes, allow_fallback_to_static_rates=True) outvalue = val - fee if outvalue <= dust_threshold(): return None - sweep_outputs = [TxOutput(TYPE_ADDRESS, sweep_address, outvalue)] - tx = Transaction.from_io(sweep_inputs, sweep_outputs, version=2) + sweep_outputs = [PartialTxOutput.from_address_and_value(sweep_address, outvalue)] + tx = PartialTransaction.from_io(sweep_inputs, sweep_outputs, version=2) sig = bfh(tx.sign_txin(0, privkey)) witness = construct_witness([sig, int(is_revocation), htlctx_witness_script]) - tx.inputs()[0]['witness'] = witness + tx.inputs()[0].witness = bfh(witness) assert tx.is_complete() return tx DIR diff --git a/electrum/lnutil.py b/electrum/lnutil.py t@@ -10,11 +10,11 @@ import re from .util import bfh, bh2u, inv_dict from .crypto import sha256 -from .transaction import Transaction +from .transaction import (Transaction, PartialTransaction, PartialTxInput, TxOutpoint, + PartialTxOutput, opcodes, TxOutput) from .ecc import CURVE_ORDER, sig_string_from_der_sig, ECPubkey, string_to_number from . import ecc, bitcoin, crypto, transaction -from .transaction import opcodes, TxOutput, Transaction -from .bitcoin import push_script, redeem_script_to_address, TYPE_ADDRESS +from .bitcoin import push_script, redeem_script_to_address, address_to_script from . import segwit_addr from .i18n import _ from .lnaddr import lndecode t@@ -97,6 +97,7 @@ class ScriptHtlc(NamedTuple): htlc: 'UpdateAddHtlc' +# FIXME duplicate of TxOutpoint in transaction.py?? class Outpoint(NamedTuple("Outpoint", [('txid', str), ('output_index', int)])): def to_str(self): return "{}:{}".format(self.txid, self.output_index) t@@ -287,7 +288,7 @@ def make_htlc_tx_output(amount_msat, local_feerate, revocationpubkey, local_dela fee = fee // 1000 * 1000 final_amount_sat = (amount_msat - fee) // 1000 assert final_amount_sat > 0, final_amount_sat - output = TxOutput(bitcoin.TYPE_ADDRESS, p2wsh, final_amount_sat) + output = PartialTxOutput.from_address_and_value(p2wsh, final_amount_sat) return script, output def make_htlc_tx_witness(remotehtlcsig: bytes, localhtlcsig: bytes, t@@ -299,29 +300,23 @@ def make_htlc_tx_witness(remotehtlcsig: bytes, localhtlcsig: bytes, return bfh(transaction.construct_witness([0, remotehtlcsig, localhtlcsig, payment_preimage, witness_script])) def make_htlc_tx_inputs(htlc_output_txid: str, htlc_output_index: int, - amount_msat: int, witness_script: str) -> List[dict]: + amount_msat: int, witness_script: str) -> List[PartialTxInput]: assert type(htlc_output_txid) is str assert type(htlc_output_index) is int assert type(amount_msat) is int assert type(witness_script) is str - c_inputs = [{ - 'scriptSig': '', - 'type': 'p2wsh', - 'signatures': [], - 'num_sig': 0, - 'prevout_n': htlc_output_index, - 'prevout_hash': htlc_output_txid, - 'value': amount_msat // 1000, - 'coinbase': False, - 'sequence': 0x0, - 'preimage_script': witness_script, - }] + txin = PartialTxInput(prevout=TxOutpoint(txid=bfh(htlc_output_txid), out_idx=htlc_output_index), + nsequence=0) + txin.witness_script = bfh(witness_script) + txin.script_sig = b'' + txin._trusted_value_sats = amount_msat // 1000 + c_inputs = [txin] return c_inputs -def make_htlc_tx(*, cltv_expiry: int, inputs, output) -> Transaction: +def make_htlc_tx(*, cltv_expiry: int, inputs: List[PartialTxInput], output: PartialTxOutput) -> PartialTransaction: assert type(cltv_expiry) is int c_outputs = [output] - tx = Transaction.from_io(inputs, c_outputs, locktime=cltv_expiry, version=2) + tx = PartialTransaction.from_io(inputs, c_outputs, locktime=cltv_expiry, version=2) return tx def make_offered_htlc(revocation_pubkey: bytes, remote_htlcpubkey: bytes, t@@ -437,7 +432,7 @@ def map_htlcs_to_ctx_output_idxs(*, chan: 'Channel', ctx: Transaction, pcp: byte def make_htlc_tx_with_open_channel(*, chan: 'Channel', pcp: bytes, subject: 'HTLCOwner', htlc_direction: 'Direction', commit: Transaction, ctx_output_idx: int, - htlc: 'UpdateAddHtlc', name: str = None) -> Tuple[bytes, Transaction]: + htlc: 'UpdateAddHtlc', name: str = None) -> Tuple[bytes, PartialTransaction]: amount_msat, cltv_expiry, payment_hash = htlc.amount_msat, htlc.cltv_expiry, htlc.payment_hash for_us = subject == LOCAL conf, other_conf = get_ordered_channel_configs(chan=chan, for_us=for_us) t@@ -472,19 +467,15 @@ def make_htlc_tx_with_open_channel(*, chan: 'Channel', pcp: bytes, subject: 'HTL return witness_script_of_htlc_tx_output, htlc_tx def make_funding_input(local_funding_pubkey: bytes, remote_funding_pubkey: bytes, - funding_pos: int, funding_txid: bytes, funding_sat: int): + funding_pos: int, funding_txid: str, funding_sat: int) -> PartialTxInput: pubkeys = sorted([bh2u(local_funding_pubkey), bh2u(remote_funding_pubkey)]) # commitment tx input - c_input = { - 'type': 'p2wsh', - 'x_pubkeys': pubkeys, - 'signatures': [None, None], - 'num_sig': 2, - 'prevout_n': funding_pos, - 'prevout_hash': funding_txid, - 'value': funding_sat, - 'coinbase': False, - } + prevout = TxOutpoint(txid=bfh(funding_txid), out_idx=funding_pos) + c_input = PartialTxInput(prevout=prevout) + c_input.script_type = 'p2wsh' + c_input.pubkeys = [bfh(pk) for pk in pubkeys] + c_input.num_sig = 2 + c_input._trusted_value_sats = funding_sat return c_input class HTLCOwner(IntFlag): t@@ -504,18 +495,18 @@ RECEIVED = Direction.RECEIVED LOCAL = HTLCOwner.LOCAL REMOTE = HTLCOwner.REMOTE -def make_commitment_outputs(fees_per_participant: Mapping[HTLCOwner, int], local_amount: int, remote_amount: int, - local_tupl, remote_tupl, htlcs: List[ScriptHtlc], dust_limit_sat: int) -> Tuple[List[TxOutput], List[TxOutput]]: - to_local_amt = local_amount - fees_per_participant[LOCAL] - to_local = TxOutput(*local_tupl, to_local_amt // 1000) - to_remote_amt = remote_amount - fees_per_participant[REMOTE] - to_remote = TxOutput(*remote_tupl, to_remote_amt // 1000) +def make_commitment_outputs(*, fees_per_participant: Mapping[HTLCOwner, int], local_amount_msat: int, remote_amount_msat: int, + local_script: str, remote_script: str, htlcs: List[ScriptHtlc], dust_limit_sat: int) -> Tuple[List[PartialTxOutput], List[PartialTxOutput]]: + to_local_amt = local_amount_msat - fees_per_participant[LOCAL] + to_local = PartialTxOutput(scriptpubkey=bfh(local_script), value=to_local_amt // 1000) + to_remote_amt = remote_amount_msat - fees_per_participant[REMOTE] + to_remote = PartialTxOutput(scriptpubkey=bfh(remote_script), value=to_remote_amt // 1000) non_htlc_outputs = [to_local, to_remote] htlc_outputs = [] for script, htlc in htlcs: - htlc_outputs.append(TxOutput(bitcoin.TYPE_ADDRESS, - bitcoin.redeem_script_to_address('p2wsh', bh2u(script)), - htlc.amount_msat // 1000)) + addr = bitcoin.redeem_script_to_address('p2wsh', bh2u(script)) + htlc_outputs.append(PartialTxOutput(scriptpubkey=bfh(address_to_script(addr)), + value=htlc.amount_msat // 1000)) # trim outputs c_outputs_filtered = list(filter(lambda x: x.value >= dust_limit_sat, non_htlc_outputs + htlc_outputs)) t@@ -533,13 +524,13 @@ def make_commitment(ctn, local_funding_pubkey, remote_funding_pubkey, delayed_pubkey, to_self_delay, funding_txid, funding_pos, funding_sat, local_amount, remote_amount, dust_limit_sat, fees_per_participant, - htlcs: List[ScriptHtlc]) -> Transaction: + htlcs: List[ScriptHtlc]) -> PartialTransaction: c_input = make_funding_input(local_funding_pubkey, remote_funding_pubkey, funding_pos, funding_txid, funding_sat) obs = get_obscured_ctn(ctn, funder_payment_basepoint, fundee_payment_basepoint) locktime = (0x20 << 24) + (obs & 0xffffff) sequence = (0x80 << 24) + (obs >> 24) - c_input['sequence'] = sequence + c_input.nsequence = sequence c_inputs = [c_input] t@@ -555,13 +546,19 @@ def make_commitment(ctn, local_funding_pubkey, remote_funding_pubkey, htlcs = list(htlcs) htlcs.sort(key=lambda x: x.htlc.cltv_expiry) - htlc_outputs, c_outputs_filtered = make_commitment_outputs(fees_per_participant, local_amount, remote_amount, - (bitcoin.TYPE_ADDRESS, local_address), (bitcoin.TYPE_ADDRESS, remote_address), htlcs, dust_limit_sat) + htlc_outputs, c_outputs_filtered = make_commitment_outputs( + fees_per_participant=fees_per_participant, + local_amount_msat=local_amount, + remote_amount_msat=remote_amount, + local_script=address_to_script(local_address), + remote_script=address_to_script(remote_address), + htlcs=htlcs, + dust_limit_sat=dust_limit_sat) assert sum(x.value for x in c_outputs_filtered) <= funding_sat, (c_outputs_filtered, funding_sat) # create commitment tx - tx = Transaction.from_io(c_inputs, c_outputs_filtered, locktime=locktime, version=2) + tx = PartialTransaction.from_io(c_inputs, c_outputs_filtered, locktime=locktime, version=2) return tx def make_commitment_output_to_local_witness_script( t@@ -578,11 +575,9 @@ def make_commitment_output_to_local_address( def make_commitment_output_to_remote_address(remote_payment_pubkey: bytes) -> str: return bitcoin.pubkey_to_address('p2wpkh', bh2u(remote_payment_pubkey)) -def sign_and_get_sig_string(tx, local_config, remote_config): - pubkeys = sorted([bh2u(local_config.multisig_key.pubkey), bh2u(remote_config.multisig_key.pubkey)]) +def sign_and_get_sig_string(tx: PartialTransaction, local_config, remote_config): tx.sign({bh2u(local_config.multisig_key.pubkey): (local_config.multisig_key.privkey, True)}) - sig_index = pubkeys.index(bh2u(local_config.multisig_key.pubkey)) - sig = bytes.fromhex(tx.inputs()[0]["signatures"][sig_index]) + sig = tx.inputs()[0].part_sigs[local_config.multisig_key.pubkey] sig_64 = sig_string_from_der_sig(sig[:-1]) return sig_64 t@@ -598,11 +593,11 @@ def get_obscured_ctn(ctn: int, funder: bytes, fundee: bytes) -> int: mask = int.from_bytes(sha256(funder + fundee)[-6:], 'big') return ctn ^ mask -def extract_ctn_from_tx(tx, txin_index: int, funder_payment_basepoint: bytes, +def extract_ctn_from_tx(tx: Transaction, txin_index: int, funder_payment_basepoint: bytes, fundee_payment_basepoint: bytes) -> int: tx.deserialize() locktime = tx.locktime - sequence = tx.inputs()[txin_index]['sequence'] + sequence = tx.inputs()[txin_index].nsequence obs = ((sequence & 0xffffff) << 24) + (locktime & 0xffffff) return get_obscured_ctn(obs, funder_payment_basepoint, fundee_payment_basepoint) t@@ -671,12 +666,12 @@ def get_compressed_pubkey_from_bech32(bech32_pubkey: str) -> bytes: def make_closing_tx(local_funding_pubkey: bytes, remote_funding_pubkey: bytes, - funding_txid: bytes, funding_pos: int, funding_sat: int, - outputs: List[TxOutput]) -> Transaction: + funding_txid: str, funding_pos: int, funding_sat: int, + outputs: List[PartialTxOutput]) -> PartialTransaction: c_input = make_funding_input(local_funding_pubkey, remote_funding_pubkey, funding_pos, funding_txid, funding_sat) - c_input['sequence'] = 0xFFFF_FFFF - tx = Transaction.from_io([c_input], outputs, locktime=0, version=2) + c_input.nsequence = 0xFFFF_FFFF + tx = PartialTransaction.from_io([c_input], outputs, locktime=0, version=2) return tx DIR diff --git a/electrum/lnwatcher.py b/electrum/lnwatcher.py t@@ -77,9 +77,11 @@ class SweepStore(SqlDB): return set([r[0] for r in c.fetchall()]) @sql - def add_sweep_tx(self, funding_outpoint, ctn, prevout, tx): + def add_sweep_tx(self, funding_outpoint, ctn, prevout, tx: Transaction): c = self.conn.cursor() - c.execute("""INSERT INTO sweep_txs (funding_outpoint, ctn, prevout, tx) VALUES (?,?,?,?)""", (funding_outpoint, ctn, prevout, bfh(str(tx)))) + assert tx.is_complete() + raw_tx = bfh(tx.serialize()) + c.execute("""INSERT INTO sweep_txs (funding_outpoint, ctn, prevout, tx) VALUES (?,?,?,?)""", (funding_outpoint, ctn, prevout, raw_tx)) self.conn.commit() @sql DIR diff --git a/electrum/lnworker.py b/electrum/lnworker.py t@@ -375,7 +375,7 @@ class LNWallet(LNWorker): for ctn in range(watchtower_ctn + 1, current_ctn): sweeptxs = chan.create_sweeptxs(ctn) for tx in sweeptxs: - await watchtower.add_sweep_tx(outpoint, ctn, tx.prevout(0), str(tx)) + await watchtower.add_sweep_tx(outpoint, ctn, tx.inputs()[0].prevout.to_str(), tx.serialize()) def start_network(self, network: 'Network'): self.lnwatcher = LNWatcher(network) DIR diff --git a/electrum/network.py b/electrum/network.py t@@ -64,6 +64,7 @@ if TYPE_CHECKING: from .channel_db import ChannelDB from .lnworker import LNGossip from .lnwatcher import WatchTower + from .transaction import Transaction _logger = get_logger(__name__) t@@ -887,11 +888,11 @@ class Network(Logger): return await self.interface.session.send_request('blockchain.transaction.get_merkle', [tx_hash, tx_height]) @best_effort_reliable - async def broadcast_transaction(self, tx, *, timeout=None) -> None: + async def broadcast_transaction(self, tx: 'Transaction', *, timeout=None) -> None: if timeout is None: timeout = self.get_network_timeout_seconds(NetworkTimeout.Urgent) try: - out = await self.interface.session.send_request('blockchain.transaction.broadcast', [str(tx)], timeout=timeout) + out = await self.interface.session.send_request('blockchain.transaction.broadcast', [tx.serialize()], timeout=timeout) # note: both 'out' and exception messages are untrusted input from the server except (RequestTimedOut, asyncio.CancelledError, asyncio.TimeoutError): raise # pass-through DIR diff --git a/electrum/paymentrequest.py b/electrum/paymentrequest.py t@@ -25,7 +25,7 @@ import hashlib import sys import time -from typing import Optional +from typing import Optional, List import asyncio import urllib.parse t@@ -42,8 +42,8 @@ from . import bitcoin, ecc, util, transaction, x509, rsakey from .util import bh2u, bfh, export_meta, import_meta, make_aiohttp_session from .util import PR_UNPAID, PR_EXPIRED, PR_PAID, PR_UNKNOWN, PR_INFLIGHT from .crypto import sha256 -from .bitcoin import TYPE_ADDRESS -from .transaction import TxOutput +from .bitcoin import address_to_script +from .transaction import PartialTxOutput from .network import Network from .logging import get_logger, Logger t@@ -128,7 +128,7 @@ class PaymentRequest: return str(self.raw) def parse(self, r): - self.outputs = [] + self.outputs = [] # type: List[PartialTxOutput] if self.error: return self.id = bh2u(sha256(r)[0:16]) t@@ -141,12 +141,12 @@ class PaymentRequest: self.details = pb2.PaymentDetails() self.details.ParseFromString(self.data.serialized_payment_details) for o in self.details.outputs: - type_, addr = transaction.get_address_from_output_script(o.script) - if type_ != TYPE_ADDRESS: + addr = transaction.get_address_from_output_script(o.script) + if not addr: # TODO maybe rm restriction but then get_requestor and get_id need changes self.error = "only addresses are allowed as outputs" return - self.outputs.append(TxOutput(type_, addr, o.amount)) + self.outputs.append(PartialTxOutput.from_address_and_value(addr, o.amount)) self.memo = self.details.memo self.payment_url = self.details.payment_url t@@ -252,8 +252,9 @@ class PaymentRequest: def get_address(self): o = self.outputs[0] - assert o.type == TYPE_ADDRESS - return o.address + addr = o.address + assert addr + return addr def get_requestor(self): return self.requestor if self.requestor else self.get_address() t@@ -278,7 +279,7 @@ class PaymentRequest: paymnt.merchant_data = pay_det.merchant_data paymnt.transactions.append(bfh(raw_tx)) ref_out = paymnt.refund_to.add() - ref_out.script = util.bfh(transaction.Transaction.pay_script(TYPE_ADDRESS, refund_addr)) + ref_out.script = util.bfh(address_to_script(refund_addr)) paymnt.memo = "Paid using Electrum" pm = paymnt.SerializeToString() payurl = urllib.parse.urlparse(pay_det.payment_url) t@@ -326,7 +327,7 @@ def make_unsigned_request(req): if amount is None: amount = 0 memo = req['memo'] - script = bfh(Transaction.pay_script(TYPE_ADDRESS, addr)) + script = bfh(address_to_script(addr)) outputs = [(script, amount)] pd = pb2.PaymentDetails() for script, amount in outputs: DIR diff --git a/electrum/plugin.py b/electrum/plugin.py t@@ -39,6 +39,7 @@ from .logging import get_logger, Logger if TYPE_CHECKING: from .plugins.hw_wallet import HW_PluginBase + from .keystore import Hardware_KeyStore _logger = get_logger(__name__) t@@ -442,20 +443,23 @@ class DeviceMgr(ThreadJob): self.scan_devices() return self.client_lookup(id_) - def client_for_keystore(self, plugin, handler, keystore, force_pair): + def client_for_keystore(self, plugin, handler, keystore: 'Hardware_KeyStore', force_pair): self.logger.info("getting client for keystore") if handler is None: raise Exception(_("Handler not found for") + ' ' + plugin.name + '\n' + _("A library is probably missing.")) handler.update_status(False) devices = self.scan_devices() xpub = keystore.xpub - derivation = keystore.get_derivation() + derivation = keystore.get_derivation_prefix() + assert derivation is not None client = self.client_by_xpub(plugin, xpub, handler, devices) if client is None and force_pair: info = self.select_device(plugin, handler, keystore, devices) client = self.force_pair_xpub(plugin, handler, info, xpub, derivation, devices) if client: handler.update_status(True) + if client: + keystore.opportunistically_fill_in_missing_info_from_device(client) self.logger.info("end client for keystore") return client DIR diff --git a/electrum/plugins/audio_modem/qt.py b/electrum/plugins/audio_modem/qt.py t@@ -4,6 +4,7 @@ import json from io import BytesIO import sys import platform +from typing import TYPE_CHECKING from PyQt5.QtWidgets import (QComboBox, QGridLayout, QLabel, QPushButton) t@@ -12,6 +13,9 @@ from electrum.gui.qt.util import WaitingDialog, EnterButton, WindowModalDialog, from electrum.i18n import _ from electrum.logging import get_logger +if TYPE_CHECKING: + from electrum.gui.qt.transaction_dialog import TxDialog + _logger = get_logger(__name__) t@@ -71,12 +75,12 @@ class Plugin(BasePlugin): return bool(d.exec_()) @hook - def transaction_dialog(self, dialog): + def transaction_dialog(self, dialog: 'TxDialog'): b = QPushButton() b.setIcon(read_QIcon("speaker.png")) def handler(): - blob = json.dumps(dialog.tx.as_dict()) + blob = dialog.tx.serialize() self._send(parent=dialog, blob=blob) b.clicked.connect(handler) dialog.sharing_buttons.insert(-1, b) DIR diff --git a/electrum/plugins/coldcard/basic_psbt.py b/electrum/plugins/coldcard/basic_psbt.py t@@ -1,313 +0,0 @@ -# -# 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 - DIR diff --git a/electrum/plugins/coldcard/build_psbt.py b/electrum/plugins/coldcard/build_psbt.py t@@ -1,397 +0,0 @@ -# -# 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 - DIR diff --git a/electrum/plugins/coldcard/coldcard.py b/electrum/plugins/coldcard/coldcard.py t@@ -2,16 +2,18 @@ # Coldcard Electrum plugin main code. # # -from struct import pack, unpack -import os, sys, time, io +import os, time, io import traceback +from typing import TYPE_CHECKING +import struct +from electrum import bip32 from electrum.bip32 import BIP32Node, InvalidMasterKeyVersionBytes from electrum.i18n import _ 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.transaction import PartialTransaction +from electrum.wallet import Standard_Wallet, Multisig_Wallet, Abstract_Wallet from electrum.util import bfh, bh2u, versiontuple, UserFacingException from electrum.base_wizard import ScriptTypeNotSupported from electrum.logging import get_logger t@@ -19,9 +21,9 @@ from electrum.logging import get_logger from ..hw_wallet import HW_PluginBase 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) +if TYPE_CHECKING: + from electrum.keystore import Xpub + _logger = get_logger(__name__) t@@ -86,7 +88,7 @@ class CKCCClient: return '<CKCCClient: xfp=%s label=%r>' % (xfp2str(self.dev.master_fingerprint), self.label()) - def verify_connection(self, expected_xfp, expected_xpub=None): + def verify_connection(self, expected_xfp: int, expected_xpub=None): ex = (expected_xfp, expected_xpub) if self._expected_device == ex: t@@ -213,7 +215,7 @@ class CKCCClient: # 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): + def sign_transaction_start(self, raw_psbt: bytes, *, finalize: bool = False): # Multiple steps to sign: # - upload binary # - start signing UX t@@ -242,6 +244,8 @@ class Coldcard_KeyStore(Hardware_KeyStore): hw_type = 'coldcard' device = 'Coldcard' + plugin: 'ColdcardPlugin' + def __init__(self, d): Hardware_KeyStore.__init__(self, d) # Errors and other user interaction is done through the wallet's t@@ -250,39 +254,22 @@ 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 BE32 is more natural way to view it + # we need to know at least the fingerprint of the master xpub to verify against MiTM # - 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 = getattr(lab, 'xpub', None) - else: - # wallet load: fatal if missing, we need them! - self.ckcc_xfp = d['ckcc_xfp'] - self.ckcc_xpub = d.get('ckcc_xpub', None) + self.ckcc_xpub = getattr(lab, 'xpub', None) or d.get('ckcc_xpub', None) def dump(self): # our additions to the stored data about keystore -- only during creation? d = Hardware_KeyStore.dump(self) - - d['ckcc_xfp'] = self.ckcc_xfp d['ckcc_xpub'] = self.ckcc_xpub - return d - def get_derivation(self): - return self.derivation + def get_xfp_int(self) -> int: + xfp = self.get_root_fingerprint() + assert xfp is not None + return xfp_int_from_xfp_bytes(bfh(xfp)) def get_client(self): # called when user tries to do something like view address, sign somthing. t@@ -290,7 +277,8 @@ class Coldcard_KeyStore(Hardware_KeyStore): # - 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) + xfp_int = self.get_xfp_int() + rv.verify_connection(xfp_int, self.ckcc_xpub) return rv t@@ -332,7 +320,7 @@ class Coldcard_KeyStore(Hardware_KeyStore): return b'' client = self.get_client() - path = self.get_derivation() + ("/%d/%d" % sequence) + path = self.get_derivation_prefix() + ("/%d/%d" % sequence) try: cl = self.get_client() try: t@@ -372,28 +360,23 @@ class Coldcard_KeyStore(Hardware_KeyStore): return b'' @wrap_busy - def sign_transaction(self, tx: Transaction, password): - # Build a PSBT in memory, upload it for signing. + def sign_transaction(self, tx, password): + # Upload PSBT 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 + assert client.dev.master_fingerprint == self.get_xfp_int() - # makes PSBT required - raw_psbt = build_psbt(tx, self.my_wallet) - - cc_finalize = not (type(self.my_wallet) is Multisig_Wallet) + raw_psbt = tx.serialize_as_bytes() try: try: self.handler.show_message("Authorize Transaction...") - client.sign_transaction_start(raw_psbt, cc_finalize) + client.sign_transaction_start(raw_psbt) while 1: # How to kill some time, without locking UI? t@@ -420,18 +403,11 @@ class Coldcard_KeyStore(Hardware_KeyStore): self.give_error(e, True) return - 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. + tx2 = PartialTransaction.from_raw_psbt(raw_resp) + # apply partial signatures back into txn + tx.combine_with_other_psbt(tx2) + # 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): t@@ -447,7 +423,7 @@ class Coldcard_KeyStore(Hardware_KeyStore): @wrap_busy def show_address(self, sequence, txin_type): client = self.get_client() - address_path = self.get_derivation()[2:] + "/%d/%d"%sequence + address_path = self.get_derivation_prefix()[2:] + "/%d/%d"%sequence addr_fmt = self._encode_txin_type(txin_type) try: try: t@@ -573,7 +549,7 @@ class ColdcardPlugin(HW_PluginBase): xpub = client.get_xpub(derivation, xtype) return xpub - def get_client(self, keystore, force_pair=True): + def get_client(self, keystore, force_pair=True) -> 'CKCCClient': # Acquire a connection to the hardware device (via USB) devmgr = self.device_manager() handler = keystore.handler t@@ -586,9 +562,10 @@ class ColdcardPlugin(HW_PluginBase): return client @staticmethod - def export_ms_wallet(wallet, fp, name): + def export_ms_wallet(wallet: Multisig_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. + assert isinstance(wallet, Multisig_Wallet) print('# Exported from Electrum', file=fp) print(f'Name: {name:.20s}', file=fp) t@@ -597,12 +574,12 @@ class ColdcardPlugin(HW_PluginBase): 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) + for xpub, ks in zip(wallet.get_master_public_keys(), wallet.get_keystores()): + fp_bytes, der_full = ks.get_fp_and_derivation_to_be_used_in_partial_tx(der_suffix=[], only_der_suffix=False) + fp_hex = fp_bytes.hex().upper() + der_prefix_str = bip32.convert_bip32_intpath_to_strpath(der_full) + xpubs.append( (fp_hex, xpub, der_prefix_str) ) + derivs.add(der_prefix_str) # Derivation doesn't matter too much to the Coldcard, since it # uses key path data from PSBT or USB request as needed. However, t@@ -613,14 +590,14 @@ class ColdcardPlugin(HW_PluginBase): print('', file=fp) assert len(xpubs) == wallet.n - for xfp, xp, dd in xpubs: + for xfp, xpub, der_prefix in xpubs: if derivs: # show as a comment if unclear - print(f'# derivation: {dd}', file=fp) + print(f'# derivation: {der_prefix}', file=fp) - print(f'{xfp}: {xp}\n', file=fp) + print(f'{xfp}: {xpub}\n', file=fp) - def show_address(self, wallet, address, keystore=None): + def show_address(self, wallet, address, keystore: 'Coldcard_KeyStore' = None): if keystore is None: keystore = wallet.get_keystore() if not self.show_address_helper(wallet, address, keystore): t@@ -633,50 +610,36 @@ class ColdcardPlugin(HW_PluginBase): sequence = wallet.get_address_index(address) keystore.show_address(sequence, txin_type) elif type(wallet) is Multisig_Wallet: + assert isinstance(wallet, Multisig_Wallet) # only here for type-hints in IDE # 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)) + pubkey_deriv_info = wallet.get_public_keys_with_deriv_info(address) + pubkeys = sorted([pk for pk in list(pubkey_deriv_info)]) + xfp_paths = [] + for pubkey_hex in pubkey_deriv_info: + ks, der_suffix = pubkey_deriv_info[pubkey_hex] + fp_bytes, der_full = ks.get_fp_and_derivation_to_be_used_in_partial_tx(der_suffix, only_der_suffix=False) + xfp_int = xfp_int_from_xfp_bytes(fp_bytes) + xfp_paths.append([xfp_int] + list(der_full)) - # put into BIP45 (sorted) order - pkx = list(sorted(zip(pubkeys, xfps))) + script = bfh(wallet.pubkeys_to_scriptcode(pubkeys)) - 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) + keystore.show_p2sh_address(wallet.m, script, xfp_paths, txin_type) else: keystore.handler.show_error(_('This function is only available for standard wallets when using {}.').format(self.device)) return - @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) + +def xfp_int_from_xfp_bytes(fp_bytes: bytes) -> int: + return int.from_bytes(fp_bytes, byteorder="little", signed=False) + + +def xfp2str(xfp: int) -> str: + # 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 struct.pack('<I', xfp).hex().lower() # EOF DIR diff --git a/electrum/plugins/coldcard/qt.py b/electrum/plugins/coldcard/qt.py t@@ -1,25 +1,22 @@ import time, os from functools import partial +import copy from PyQt5.QtCore import Qt, pyqtSignal from PyQt5.QtWidgets import QPushButton, QLabel, QVBoxLayout, QWidget, QGridLayout -from PyQt5.QtWidgets import QFileDialog + +from electrum.gui.qt.util import WindowModalDialog, CloseButton, get_parent_main_window, Buttons +from electrum.gui.qt.transaction_dialog import TxDialog from electrum.i18n import _ from electrum.plugin import hook -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 electrum.wallet import Multisig_Wallet +from electrum.transaction import PartialTransaction 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 t@@ -73,135 +70,29 @@ class Plugin(ColdcardPlugin, QtPluginBase): 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 - + def transaction_dialog(self, dia: TxDialog): # 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" - 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 - - if tx.is_complete(): - # if they sign while dialog is open, it can transition from unsigned to signed, - # which we don't support here, so do nothing - return - - # convert to PSBT - build_psbt(tx, dia.wallet) + def gettx_for_coldcard_export() -> PartialTransaction: + if not isinstance(dia.tx, PartialTransaction): + raise Exception("Can only export partial transactions for coinjoins.") + tx = copy.deepcopy(dia.tx) + tx.add_info_from_wallet(dia.wallet, include_xpubs_and_full_paths=True) + return tx - 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(tx.raw_psbt) - dia.show_message(_("Transaction exported successfully")) - dia.saved = True + # add a new "export" option + if isinstance(dia.tx, PartialTransaction): + export_submenu = dia.export_actions_menu.addMenu(_("For {}; include xpubs").format(self.device)) + dia.add_export_actions_to_menu(export_submenu, gettx=gettx_for_coldcard_export) def show_settings_dialog(self, window, keystore): # When they click on the icon for CC we come here. # - doesn't matter if device not connected, continue CKCCSettingsDialog(window, self, keystore).exec_() - @hook - def init_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, tx_desc=desc) class Coldcard_Handler(QtHandlerBase): setup_signal = pyqtSignal() t@@ -307,7 +198,7 @@ class CKCCSettingsDialog(WindowModalDialog): def show_placeholders(self, unclear_arg): # device missing, so hide lots of detail. - self.xfp.setText('<tt>%s' % xfp2str(self.keystore.ckcc_xfp)) + self.xfp.setText('<tt>%s' % self.keystore.get_root_fingerprint()) self.serial.setText('(not connected)') self.fw_version.setText('') self.fw_built.setText('') DIR diff --git a/electrum/plugins/cosigner_pool/qt.py b/electrum/plugins/cosigner_pool/qt.py t@@ -25,23 +25,25 @@ import time from xmlrpc.client import ServerProxy +from typing import TYPE_CHECKING, Union, List, Tuple from PyQt5.QtCore import QObject, pyqtSignal from PyQt5.QtWidgets import QPushButton from electrum import util, keystore, ecc, crypto from electrum import transaction +from electrum.transaction import Transaction, PartialTransaction, tx_from_any from electrum.bip32 import BIP32Node from electrum.plugin import BasePlugin, hook from electrum.i18n import _ from electrum.wallet import Multisig_Wallet from electrum.util import bh2u, bfh -from electrum.gui.qt.transaction_dialog import show_transaction +from electrum.gui.qt.transaction_dialog import show_transaction, TxDialog from electrum.gui.qt.util import WaitingDialog -import sys -import traceback +if TYPE_CHECKING: + from electrum.gui.qt.main_window import ElectrumWindow server = ServerProxy('https://cosigner.electrum.org/', allow_none=True) t@@ -97,8 +99,8 @@ class Plugin(BasePlugin): self.listener = None self.obj = QReceiveSignalObject() self.obj.cosigner_receive_signal.connect(self.on_receive) - self.keys = [] - self.cosigner_list = [] + self.keys = [] # type: List[Tuple[str, str, ElectrumWindow]] + self.cosigner_list = [] # type: List[Tuple[ElectrumWindow, str, bytes, str]] @hook def init_qt(self, gui): t@@ -116,10 +118,11 @@ class Plugin(BasePlugin): def is_available(self): return True - def update(self, window): + def update(self, window: 'ElectrumWindow'): wallet = window.wallet if type(wallet) != Multisig_Wallet: return + assert isinstance(wallet, Multisig_Wallet) # only here for type-hints in IDE if self.listener is None: self.logger.info("starting listener") self.listener = Listener(self) t@@ -131,7 +134,7 @@ class Plugin(BasePlugin): self.keys = [] self.cosigner_list = [] for key, keystore in wallet.keystores.items(): - xpub = keystore.get_master_public_key() + xpub = keystore.get_master_public_key() # type: str pubkey = BIP32Node.from_xkey(xpub).eckey.get_public_key_bytes(compressed=True) _hash = bh2u(crypto.sha256d(pubkey)) if not keystore.is_watching_only(): t@@ -142,14 +145,14 @@ class Plugin(BasePlugin): self.listener.set_keyhashes([t[1] for t in self.keys]) @hook - def transaction_dialog(self, d): + def transaction_dialog(self, d: 'TxDialog'): d.cosigner_send_button = b = QPushButton(_("Send to cosigner")) b.clicked.connect(lambda: self.do_send(d.tx)) d.buttons.insert(0, b) self.transaction_dialog_update(d) @hook - def transaction_dialog_update(self, d): + def transaction_dialog_update(self, d: 'TxDialog'): if d.tx.is_complete() or d.wallet.can_sign(d.tx): d.cosigner_send_button.hide() return t@@ -160,17 +163,14 @@ class Plugin(BasePlugin): else: d.cosigner_send_button.hide() - def cosigner_can_sign(self, tx, cosigner_xpub): - from electrum.keystore import is_xpubkey, parse_xpubkey - xpub_set = set([]) - for txin in tx.inputs(): - for x_pubkey in txin['x_pubkeys']: - if is_xpubkey(x_pubkey): - xpub, s = parse_xpubkey(x_pubkey) - xpub_set.add(xpub) - return cosigner_xpub in xpub_set - - def do_send(self, tx): + def cosigner_can_sign(self, tx: Transaction, cosigner_xpub: str) -> bool: + if not isinstance(tx, PartialTransaction): + return False + if tx.is_complete(): + return False + return cosigner_xpub in {bip32node.to_xpub() for bip32node in tx.xpubs} + + def do_send(self, tx: Union[Transaction, PartialTransaction]): def on_success(result): window.show_message(_("Your transaction was sent to the cosigning pool.") + '\n' + _("Open your cosigner wallet to retrieve it.")) t@@ -184,7 +184,7 @@ class Plugin(BasePlugin): if not self.cosigner_can_sign(tx, xpub): continue # construct message - raw_tx_bytes = bfh(str(tx)) + raw_tx_bytes = tx.serialize_as_bytes() public_key = ecc.ECPubkey(K) message = public_key.encrypt_message(raw_tx_bytes).decode('ascii') # send message t@@ -223,12 +223,12 @@ class Plugin(BasePlugin): return try: privkey = BIP32Node.from_xkey(xprv).eckey - message = bh2u(privkey.decrypt_message(message)) + message = privkey.decrypt_message(message) except Exception as e: self.logger.exception('') window.show_error(_('Error decrypting message') + ':\n' + repr(e)) return self.listener.clear(keyhash) - tx = transaction.Transaction(message) - show_transaction(tx, window, prompt_if_unsaved=True) + tx = tx_from_any(message) + show_transaction(tx, parent=window, prompt_if_unsaved=True) DIR diff --git a/electrum/plugins/digitalbitbox/digitalbitbox.py b/electrum/plugins/digitalbitbox/digitalbitbox.py t@@ -14,20 +14,21 @@ import re import struct import sys import time +import copy from electrum.crypto import sha256d, EncodeAES_base64, EncodeAES_bytes, DecodeAES_bytes, hmac_oneshot from electrum.bitcoin import (TYPE_ADDRESS, push_script, var_int, public_key_to_p2pkh, is_address) -from electrum.bip32 import BIP32Node +from electrum.bip32 import BIP32Node, convert_bip32_intpath_to_strpath, is_all_public_derivation from electrum import ecc from electrum.ecc import msg_magic from electrum.wallet import Standard_Wallet from electrum import constants -from electrum.transaction import Transaction +from electrum.transaction import Transaction, PartialTransaction, PartialTxInput from electrum.i18n import _ from electrum.keystore import Hardware_KeyStore from ..hw_wallet import HW_PluginBase -from electrum.util import to_string, UserCancelled, UserFacingException +from electrum.util import to_string, UserCancelled, UserFacingException, bfh from electrum.base_wizard import ScriptTypeNotSupported, HWD_SETUP_NEW_WALLET from electrum.network import Network from electrum.logging import get_logger t@@ -449,21 +450,13 @@ class DigitalBitbox_KeyStore(Hardware_KeyStore): hw_type = 'digitalbitbox' device = 'DigitalBitbox' + plugin: 'DigitalBitboxPlugin' def __init__(self, d): Hardware_KeyStore.__init__(self, d) self.force_watching_only = False self.maxInputs = 14 # maximum inputs per single sign command - - def get_derivation(self): - return str(self.derivation) - - - def is_p2pkh(self): - return self.derivation.startswith("m/44'/") - - def give_error(self, message, clear_client = False): if clear_client: self.client = None t@@ -478,7 +471,7 @@ class DigitalBitbox_KeyStore(Hardware_KeyStore): sig = None try: message = message.encode('utf8') - inputPath = self.get_derivation() + "/%d/%d" % sequence + inputPath = self.get_derivation_prefix() + "/%d/%d" % sequence msg_hash = sha256d(msg_magic(message)) inputHash = to_hexstr(msg_hash) hasharray = [] t@@ -540,58 +533,50 @@ class DigitalBitbox_KeyStore(Hardware_KeyStore): try: p2pkhTransaction = True - derivations = self.get_tx_derivations(tx) inputhasharray = [] hasharray = [] pubkeyarray = [] # Build hasharray from inputs for i, txin in enumerate(tx.inputs()): - if txin['type'] == 'coinbase': + if txin.is_coinbase(): self.give_error("Coinbase not supported") # should never happen - if txin['type'] != 'p2pkh': + if txin.script_type != 'p2pkh': p2pkhTransaction = False - for x_pubkey in txin['x_pubkeys']: - if x_pubkey in derivations: - index = derivations.get(x_pubkey) - inputPath = "%s/%d/%d" % (self.get_derivation(), index[0], index[1]) - inputHash = sha256d(binascii.unhexlify(tx.serialize_preimage(i))) - hasharray_i = {'hash': to_hexstr(inputHash), 'keypath': inputPath} - hasharray.append(hasharray_i) - inputhasharray.append(inputHash) - break - else: - self.give_error("No matching x_key for sign_transaction") # should never happen + my_pubkey, inputPath = self.find_my_pubkey_in_txinout(txin) + if not inputPath: + self.give_error("No matching pubkey for sign_transaction") # should never happen + inputPath = convert_bip32_intpath_to_strpath(inputPath) + inputHash = sha256d(bfh(tx.serialize_preimage(i))) + hasharray_i = {'hash': to_hexstr(inputHash), 'keypath': inputPath} + hasharray.append(hasharray_i) + inputhasharray.append(inputHash) # Build pubkeyarray from outputs - for o in tx.outputs(): - assert o.type == TYPE_ADDRESS - info = tx.output_info.get(o.address) - if info is not None: - if info.is_change: - index = info.address_index - changePath = self.get_derivation() + "/%d/%d" % index - changePubkey = self.derive_pubkey(index[0], index[1]) - pubkeyarray_i = {'pubkey': changePubkey, 'keypath': changePath} - pubkeyarray.append(pubkeyarray_i) + for txout in tx.outputs(): + assert txout.address + if txout.is_change: + changePubkey, changePath = self.find_my_pubkey_in_txinout(txout) + assert changePath + changePath = convert_bip32_intpath_to_strpath(changePath) + changePubkey = changePubkey.hex() + pubkeyarray_i = {'pubkey': changePubkey, 'keypath': changePath} + pubkeyarray.append(pubkeyarray_i) # Special serialization of the unsigned transaction for # the mobile verification app. # At the moment, verification only works for p2pkh transactions. if p2pkhTransaction: - class CustomTXSerialization(Transaction): - @classmethod - def input_script(self, txin, estimate_size=False): - if txin['type'] == 'p2pkh': - return Transaction.get_preimage_script(txin) - if txin['type'] == 'p2sh': - # Multisig verification has partial support, but is disabled. This is the - # expected serialization though, so we leave it here until we activate it. - return '00' + push_script(Transaction.get_preimage_script(txin)) - raise Exception("unsupported type %s" % txin['type']) - tx_dbb_serialized = CustomTXSerialization(tx.serialize()).serialize_to_network() + tx_copy = copy.deepcopy(tx) + # monkey-patch method of tx_copy instance to change serialization + def input_script(self, txin: PartialTxInput, *, estimate_size=False): + if txin.script_type == 'p2pkh': + return Transaction.get_preimage_script(txin) + raise Exception("unsupported type %s" % txin.script_type) + tx_copy.input_script = input_script.__get__(tx_copy, PartialTransaction) + tx_dbb_serialized = tx_copy.serialize_to_network() else: # We only need this for the signing echo / verification. tx_dbb_serialized = None t@@ -656,12 +641,9 @@ class DigitalBitbox_KeyStore(Hardware_KeyStore): if len(dbb_signatures) != len(tx.inputs()): raise Exception("Incorrect number of transactions signed.") # Should never occur for i, txin in enumerate(tx.inputs()): - num = txin['num_sig'] - for pubkey in txin['pubkeys']: - signatures = list(filter(None, txin['signatures'])) - if len(signatures) == num: - break # txin is complete - ii = txin['pubkeys'].index(pubkey) + for pubkey_bytes in txin.pubkeys: + if txin.is_complete(): + break signed = dbb_signatures[i] if 'recid' in signed: # firmware > v2.1.1 t@@ -673,20 +655,19 @@ class DigitalBitbox_KeyStore(Hardware_KeyStore): elif 'pubkey' in signed: # firmware <= v2.1.1 pk = signed['pubkey'] - if pk != pubkey: + if pk != pubkey_bytes.hex(): continue sig_r = int(signed['sig'][:64], 16) sig_s = int(signed['sig'][64:], 16) sig = ecc.der_sig_from_r_and_s(sig_r, sig_s) sig = to_hexstr(sig) + '01' - tx.add_signature_to_txin(i, ii, sig) + tx.add_signature_to_txin(txin_idx=i, signing_pubkey=pubkey_bytes.hex(), sig=sig) except UserCancelled: raise except BaseException as e: self.give_error(e, True) else: - _logger.info("Transaction is_complete {tx.is_complete()}") - tx.raw = tx.serialize() + _logger.info(f"Transaction is_complete {tx.is_complete()}") class DigitalBitboxPlugin(HW_PluginBase): t@@ -760,6 +741,8 @@ class DigitalBitboxPlugin(HW_PluginBase): def get_xpub(self, device_id, derivation, xtype, wizard): if xtype not in self.SUPPORTED_XTYPES: raise ScriptTypeNotSupported(_('This type of script is not supported with {}.').format(self.device)) + if is_all_public_derivation(derivation): + raise Exception(f"The {self.device} does not reveal xpubs corresponding to non-hardened paths. (path: {derivation})") devmgr = self.device_manager() client = devmgr.client_by_id(device_id) client.handler = self.create_handler(wizard) t@@ -788,11 +771,11 @@ class DigitalBitboxPlugin(HW_PluginBase): if not self.is_mobile_paired(): keystore.handler.show_error(_('This function is only available after pairing your {} with a mobile device.').format(self.device)) return - if not keystore.is_p2pkh(): + if wallet.get_txin_type(address) != 'p2pkh': keystore.handler.show_error(_('This function is only available for p2pkh keystores when using {}.').format(self.device)) return change, index = wallet.get_address_index(address) - keypath = '%s/%d/%d' % (keystore.derivation, change, index) + keypath = '%s/%d/%d' % (keystore.get_derivation_prefix(), change, index) xpub = self.get_client(keystore)._get_xpub(keypath) verify_request_payload = { "type": 'p2pkh', DIR diff --git a/electrum/plugins/digitalbitbox/qt.py b/electrum/plugins/digitalbitbox/qt.py t@@ -2,7 +2,7 @@ from functools import partial from electrum.i18n import _ from electrum.plugin import hook -from electrum.wallet import Standard_Wallet +from electrum.wallet import Standard_Wallet, Abstract_Wallet from ..hw_wallet.qt import QtHandlerBase, QtPluginBase from ..hw_wallet.plugin import only_hook_if_libraries_available t@@ -18,7 +18,7 @@ class Plugin(DigitalBitboxPlugin, QtPluginBase): @only_hook_if_libraries_available @hook - def receive_menu(self, menu, addrs, wallet): + def receive_menu(self, menu, addrs, wallet: Abstract_Wallet): if type(wallet) is not Standard_Wallet: return t@@ -29,12 +29,12 @@ class Plugin(DigitalBitboxPlugin, QtPluginBase): if not self.is_mobile_paired(): return - if not keystore.is_p2pkh(): - return - if len(addrs) == 1: + addr = addrs[0] + if wallet.get_txin_type(addr) != 'p2pkh': + return def show_address(): - keystore.thread.add(partial(self.show_address, wallet, addrs[0], keystore)) + keystore.thread.add(partial(self.show_address, wallet, addr, keystore)) menu.addAction(_("Show on {}").format(self.device), show_address) DIR diff --git a/electrum/plugins/greenaddress_instant/qt.py b/electrum/plugins/greenaddress_instant/qt.py t@@ -36,6 +36,7 @@ from electrum.network import Network if TYPE_CHECKING: from aiohttp import ClientResponse + from electrum.gui.qt.transaction_dialog import TxDialog class Plugin(BasePlugin): t@@ -43,13 +44,13 @@ class Plugin(BasePlugin): button_label = _("Verify GA instant") @hook - def transaction_dialog(self, d): + def transaction_dialog(self, d: 'TxDialog'): d.verify_button = QPushButton(self.button_label) d.verify_button.clicked.connect(lambda: self.do_verify(d)) d.buttons.insert(0, d.verify_button) self.transaction_dialog_update(d) - def get_my_addr(self, d): + def get_my_addr(self, d: 'TxDialog'): """Returns the address for given tx which can be used to request instant confirmation verification from GreenAddress""" for o in d.tx.outputs(): t@@ -58,13 +59,13 @@ class Plugin(BasePlugin): return None @hook - def transaction_dialog_update(self, d): + def transaction_dialog_update(self, d: 'TxDialog'): if d.tx.is_complete() and self.get_my_addr(d): d.verify_button.show() else: d.verify_button.hide() - def do_verify(self, d): + def do_verify(self, d: 'TxDialog'): tx = d.tx wallet = d.wallet window = d.main_window DIR diff --git a/electrum/plugins/hw_wallet/plugin.py b/electrum/plugins/hw_wallet/plugin.py t@@ -24,11 +24,18 @@ # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. +from typing import TYPE_CHECKING, Dict, List, Union, Tuple, Sequence + from electrum.plugin import BasePlugin, hook from electrum.i18n import _ from electrum.bitcoin import is_address, TYPE_SCRIPT, opcodes from electrum.util import bfh, versiontuple, UserFacingException -from electrum.transaction import TxOutput, Transaction +from electrum.transaction import TxOutput, Transaction, PartialTransaction, PartialTxInput, PartialTxOutput +from electrum.bip32 import BIP32Node + +if TYPE_CHECKING: + from electrum.wallet import Abstract_Wallet + from electrum.keystore import Hardware_KeyStore class HW_PluginBase(BasePlugin): t@@ -65,7 +72,10 @@ class HW_PluginBase(BasePlugin): """ raise NotImplementedError() - def show_address(self, wallet, address, keystore=None): + def get_client(self, keystore: 'Hardware_KeyStore', force_pair: bool = True): + raise NotImplementedError() + + def show_address(self, wallet: 'Abstract_Wallet', address, keystore: 'Hardware_KeyStore' = None): pass # implemented in child classes def show_address_helper(self, wallet, address, keystore=None): t@@ -132,20 +142,12 @@ class HW_PluginBase(BasePlugin): return self._ignore_outdated_fw -def is_any_tx_output_on_change_branch(tx: Transaction) -> bool: - if not tx.output_info: - return False - for o in tx.outputs(): - info = tx.output_info.get(o.address) - if info is not None: - return info.is_change - return False +def is_any_tx_output_on_change_branch(tx: PartialTransaction) -> bool: + return any([txout.is_change for txout in tx.outputs()]) def trezor_validate_op_return_output_and_get_data(output: TxOutput) -> bytes: - if output.type != TYPE_SCRIPT: - raise Exception("Unexpected output type: {}".format(output.type)) - script = bfh(output.address) + script = output.scriptpubkey if not (script[0] == opcodes.OP_RETURN and script[1] == len(script) - 2 and script[1] <= 75): raise UserFacingException(_("Only OP_RETURN scripts, with one constant push, are supported.")) t@@ -154,6 +156,25 @@ def trezor_validate_op_return_output_and_get_data(output: TxOutput) -> bytes: return script[2:] +def get_xpubs_and_der_suffixes_from_txinout(tx: PartialTransaction, + txinout: Union[PartialTxInput, PartialTxOutput]) \ + -> List[Tuple[str, List[int]]]: + xfp_to_xpub_map = {xfp: bip32node for bip32node, (xfp, path) + in tx.xpubs.items()} # type: Dict[bytes, BIP32Node] + xfps = [txinout.bip32_paths[pubkey][0] for pubkey in txinout.pubkeys] + try: + xpubs = [xfp_to_xpub_map[xfp] for xfp in xfps] + except KeyError as e: + raise Exception(f"Partial transaction is missing global xpub for " + f"fingerprint ({str(e)}) in input/output") from e + xpubs_and_deriv_suffixes = [] + for bip32node, pubkey in zip(xpubs, txinout.pubkeys): + xfp, path = txinout.bip32_paths[pubkey] + der_suffix = list(path)[bip32node.depth:] + xpubs_and_deriv_suffixes.append((bip32node.to_xpub(), der_suffix)) + return xpubs_and_deriv_suffixes + + def only_hook_if_libraries_available(func): # note: this decorator must wrap @hook, not the other way around, # as 'hook' uses the name of the function it wraps DIR diff --git a/electrum/plugins/keepkey/keepkey.py b/electrum/plugins/keepkey/keepkey.py t@@ -1,19 +1,23 @@ from binascii import hexlify, unhexlify import traceback import sys +from typing import NamedTuple, Any, Optional, Dict, Union, List, Tuple, TYPE_CHECKING from electrum.util import bfh, bh2u, UserCancelled, UserFacingException from electrum.bitcoin import TYPE_ADDRESS, TYPE_SCRIPT from electrum.bip32 import BIP32Node from electrum import constants from electrum.i18n import _ -from electrum.transaction import deserialize, Transaction -from electrum.keystore import Hardware_KeyStore, is_xpubkey, parse_xpubkey +from electrum.transaction import Transaction, PartialTransaction, PartialTxInput, PartialTxOutput +from electrum.keystore import Hardware_KeyStore from electrum.base_wizard import ScriptTypeNotSupported from ..hw_wallet import HW_PluginBase -from ..hw_wallet.plugin import is_any_tx_output_on_change_branch, trezor_validate_op_return_output_and_get_data +from ..hw_wallet.plugin import (is_any_tx_output_on_change_branch, trezor_validate_op_return_output_and_get_data, + get_xpubs_and_der_suffixes_from_txinout) +if TYPE_CHECKING: + from .client import KeepKeyClient # TREZOR initialization methods TIM_NEW, TIM_RECOVER, TIM_MNEMONIC, TIM_PRIVKEY = range(0, 4) t@@ -23,8 +27,7 @@ class KeepKey_KeyStore(Hardware_KeyStore): hw_type = 'keepkey' device = 'KeepKey' - def get_derivation(self): - return self.derivation + plugin: 'KeepKeyPlugin' def get_client(self, force_pair=True): return self.plugin.get_client(self, force_pair) t@@ -34,7 +37,7 @@ class KeepKey_KeyStore(Hardware_KeyStore): def sign_message(self, sequence, message, password): client = self.get_client() - address_path = self.get_derivation() + "/%d/%d"%sequence + address_path = self.get_derivation_prefix() + "/%d/%d"%sequence address_n = client.expand_path(address_path) msg_sig = client.sign_message(self.plugin.get_coin_name(), address_n, message) return msg_sig.signature t@@ -44,22 +47,13 @@ class KeepKey_KeyStore(Hardware_KeyStore): return # previous transactions used as inputs prev_tx = {} - # path of the xpubs that are involved - xpub_path = {} for txin in tx.inputs(): - pubkeys, x_pubkeys = tx.get_sorted_pubkeys(txin) - tx_hash = txin['prevout_hash'] - if txin.get('prev_tx') is None and not Transaction.is_segwit_input(txin): - raise UserFacingException(_('Offline signing with {} is not supported for legacy inputs.').format(self.device)) - prev_tx[tx_hash] = txin['prev_tx'] - for x_pubkey in x_pubkeys: - if not is_xpubkey(x_pubkey): - continue - xpub, s = parse_xpubkey(x_pubkey) - if xpub == self.get_master_public_key(): - xpub_path[xpub] = self.get_derivation() + tx_hash = txin.prevout.txid.hex() + if txin.utxo is None and not Transaction.is_segwit_input(txin): + raise UserFacingException(_('Missing previous tx for legacy input.')) + prev_tx[tx_hash] = txin.utxo - self.plugin.sign_transaction(self, tx, prev_tx, xpub_path) + self.plugin.sign_transaction(self, tx, prev_tx) class KeepKeyPlugin(HW_PluginBase): t@@ -164,7 +158,7 @@ class KeepKeyPlugin(HW_PluginBase): return client - def get_client(self, keystore, force_pair=True): + def get_client(self, keystore, force_pair=True) -> Optional['KeepKeyClient']: devmgr = self.device_manager() handler = keystore.handler with devmgr.hid_lock: t@@ -306,12 +300,11 @@ class KeepKeyPlugin(HW_PluginBase): return self.types.PAYTOMULTISIG raise ValueError('unexpected txin type: {}'.format(electrum_txin_type)) - def sign_transaction(self, keystore, tx, prev_tx, xpub_path): + def sign_transaction(self, keystore, tx: PartialTransaction, prev_tx): self.prev_tx = prev_tx - self.xpub_path = xpub_path client = self.get_client(keystore) - inputs = self.tx_inputs(tx, True) - outputs = self.tx_outputs(keystore.get_derivation(), tx) + inputs = self.tx_inputs(tx, for_sig=True, keystore=keystore) + outputs = self.tx_outputs(tx, keystore=keystore) signatures = client.sign_tx(self.get_coin_name(), inputs, outputs, lock_time=tx.locktime, version=tx.version)[0] signatures = [(bh2u(x) + '01') for x in signatures] t@@ -326,137 +319,118 @@ class KeepKeyPlugin(HW_PluginBase): if not client.atleast_version(1, 3): keystore.handler.show_error(_("Your device firmware is too old")) return - change, index = wallet.get_address_index(address) - derivation = keystore.derivation - address_path = "%s/%d/%d"%(derivation, change, index) + deriv_suffix = wallet.get_address_index(address) + derivation = keystore.get_derivation_prefix() + address_path = "%s/%d/%d"%(derivation, *deriv_suffix) address_n = client.expand_path(address_path) + script_type = self.get_keepkey_input_script_type(wallet.txin_type) + + # prepare multisig, if available: xpubs = wallet.get_master_public_keys() - if len(xpubs) == 1: - script_type = self.get_keepkey_input_script_type(wallet.txin_type) - client.get_address(self.get_coin_name(), address_n, True, script_type=script_type) - else: - def f(xpub): - return self._make_node_path(xpub, [change, index]) + if len(xpubs) > 1: pubkeys = wallet.get_public_keys(address) # sort xpubs using the order of pubkeys - sorted_pubkeys, sorted_xpubs = zip(*sorted(zip(pubkeys, xpubs))) - pubkeys = list(map(f, sorted_xpubs)) - multisig = self.types.MultisigRedeemScriptType( - pubkeys=pubkeys, - signatures=[b''] * wallet.n, - m=wallet.m, - ) - script_type = self.get_keepkey_input_script_type(wallet.txin_type) - client.get_address(self.get_coin_name(), address_n, True, multisig=multisig, script_type=script_type) - - def tx_inputs(self, tx, for_sig=False): + sorted_pairs = sorted(zip(pubkeys, xpubs)) + multisig = self._make_multisig( + wallet.m, + [(xpub, deriv_suffix) for pubkey, xpub in sorted_pairs]) + else: + multisig = None + + client.get_address(self.get_coin_name(), address_n, True, multisig=multisig, script_type=script_type) + + def tx_inputs(self, tx: Transaction, *, for_sig=False, keystore: 'KeepKey_KeyStore' = None): inputs = [] for txin in tx.inputs(): txinputtype = self.types.TxInputType() - if txin['type'] == 'coinbase': + if txin.is_coinbase(): prev_hash = b"\x00"*32 prev_index = 0xffffffff # signed int -1 else: if for_sig: - x_pubkeys = txin['x_pubkeys'] - if len(x_pubkeys) == 1: - x_pubkey = x_pubkeys[0] - xpub, s = parse_xpubkey(x_pubkey) - xpub_n = self.client_class.expand_path(self.xpub_path[xpub]) - txinputtype.address_n.extend(xpub_n + s) - txinputtype.script_type = self.get_keepkey_input_script_type(txin['type']) + assert isinstance(tx, PartialTransaction) + assert isinstance(txin, PartialTxInput) + assert keystore + if len(txin.pubkeys) > 1: + xpubs_and_deriv_suffixes = get_xpubs_and_der_suffixes_from_txinout(tx, txin) + multisig = self._make_multisig(txin.num_sig, xpubs_and_deriv_suffixes) else: - def f(x_pubkey): - xpub, s = parse_xpubkey(x_pubkey) - return self._make_node_path(xpub, s) - pubkeys = list(map(f, x_pubkeys)) - multisig = self.types.MultisigRedeemScriptType( - pubkeys=pubkeys, - signatures=map(lambda x: bfh(x)[:-1] if x else b'', txin.get('signatures')), - m=txin.get('num_sig'), - ) - script_type = self.get_keepkey_input_script_type(txin['type']) - txinputtype = self.types.TxInputType( - script_type=script_type, - multisig=multisig - ) - # find which key is mine - for x_pubkey in x_pubkeys: - if is_xpubkey(x_pubkey): - xpub, s = parse_xpubkey(x_pubkey) - if xpub in self.xpub_path: - xpub_n = self.client_class.expand_path(self.xpub_path[xpub]) - txinputtype.address_n.extend(xpub_n + s) - break - - prev_hash = unhexlify(txin['prevout_hash']) - prev_index = txin['prevout_n'] - - if 'value' in txin: - txinputtype.amount = txin['value'] + multisig = None + script_type = self.get_keepkey_input_script_type(txin.script_type) + txinputtype = self.types.TxInputType( + script_type=script_type, + multisig=multisig) + my_pubkey, full_path = keystore.find_my_pubkey_in_txinout(txin) + if full_path: + txinputtype.address_n.extend(full_path) + + prev_hash = txin.prevout.txid + prev_index = txin.prevout.out_idx + + if txin.value_sats() is not None: + txinputtype.amount = txin.value_sats() txinputtype.prev_hash = prev_hash txinputtype.prev_index = prev_index - if txin.get('scriptSig') is not None: - script_sig = bfh(txin['scriptSig']) - txinputtype.script_sig = script_sig + if txin.script_sig is not None: + txinputtype.script_sig = txin.script_sig - txinputtype.sequence = txin.get('sequence', 0xffffffff - 1) + txinputtype.sequence = txin.nsequence inputs.append(txinputtype) return inputs - def tx_outputs(self, derivation, tx: Transaction): + def _make_multisig(self, m, xpubs): + if len(xpubs) == 1: + return None + pubkeys = [self._make_node_path(xpub, deriv) for xpub, deriv in xpubs] + return self.types.MultisigRedeemScriptType( + pubkeys=pubkeys, + signatures=[b''] * len(pubkeys), + m=m) + + def tx_outputs(self, tx: PartialTransaction, *, keystore: 'KeepKey_KeyStore'): def create_output_by_derivation(): - script_type = self.get_keepkey_output_script_type(info.script_type) - if len(xpubs) == 1: - address_n = self.client_class.expand_path(derivation + "/%d/%d" % index) - txoutputtype = self.types.TxOutputType( - amount=amount, - script_type=script_type, - address_n=address_n, - ) + script_type = self.get_keepkey_output_script_type(txout.script_type) + if len(txout.pubkeys) > 1: + xpubs_and_deriv_suffixes = get_xpubs_and_der_suffixes_from_txinout(tx, txout) + multisig = self._make_multisig(txout.num_sig, xpubs_and_deriv_suffixes) else: - address_n = self.client_class.expand_path("/%d/%d" % index) - pubkeys = [self._make_node_path(xpub, address_n) for xpub in xpubs] - multisig = self.types.MultisigRedeemScriptType( - pubkeys=pubkeys, - signatures=[b''] * len(pubkeys), - m=m) - txoutputtype = self.types.TxOutputType( - multisig=multisig, - amount=amount, - address_n=self.client_class.expand_path(derivation + "/%d/%d" % index), - script_type=script_type) + multisig = None + my_pubkey, full_path = keystore.find_my_pubkey_in_txinout(txout) + assert full_path + txoutputtype = self.types.TxOutputType( + multisig=multisig, + amount=txout.value, + address_n=full_path, + script_type=script_type) return txoutputtype def create_output_by_address(): txoutputtype = self.types.TxOutputType() - txoutputtype.amount = amount - if _type == TYPE_SCRIPT: - txoutputtype.script_type = self.types.PAYTOOPRETURN - txoutputtype.op_return_data = trezor_validate_op_return_output_and_get_data(o) - elif _type == TYPE_ADDRESS: + txoutputtype.amount = txout.value + if address: txoutputtype.script_type = self.types.PAYTOADDRESS txoutputtype.address = address + else: + txoutputtype.script_type = self.types.PAYTOOPRETURN + txoutputtype.op_return_data = trezor_validate_op_return_output_and_get_data(txout) return txoutputtype outputs = [] has_change = False any_output_on_change_branch = is_any_tx_output_on_change_branch(tx) - for o in tx.outputs(): - _type, address, amount = o.type, o.address, o.value + for txout in tx.outputs(): + address = txout.address use_create_by_derivation = False - info = tx.output_info.get(address) - if info is not None and not has_change: - index, xpubs, m = info.address_index, info.sorted_xpubs, info.num_sig + if txout.is_mine and not has_change: # prioritise hiding outputs on the 'change' branch from user # because no more than one change address allowed - if info.is_change == any_output_on_change_branch: + if txout.is_change == any_output_on_change_branch: use_create_by_derivation = True has_change = True t@@ -468,20 +442,20 @@ class KeepKeyPlugin(HW_PluginBase): return outputs - def electrum_tx_to_txtype(self, tx): + def electrum_tx_to_txtype(self, tx: Optional[Transaction]): t = self.types.TransactionType() if tx is None: # probably for segwit input and we don't need this prev txn return t - d = deserialize(tx.raw) - t.version = d['version'] - t.lock_time = d['lockTime'] + tx.deserialize() + t.version = tx.version + t.lock_time = tx.locktime inputs = self.tx_inputs(tx) t.inputs.extend(inputs) - for vout in d['outputs']: + for out in tx.outputs(): o = t.bin_outputs.add() - o.amount = vout['value'] - o.script_pubkey = bfh(vout['scriptPubKey']) + o.amount = out.value + o.script_pubkey = out.scriptpubkey return t # This function is called from the TREZOR libraries (via tx_api) DIR diff --git a/electrum/plugins/ledger/ledger.py b/electrum/plugins/ledger/ledger.py t@@ -4,11 +4,13 @@ import sys import traceback from electrum import ecc -from electrum.bitcoin import TYPE_ADDRESS, int_to_hex, var_int, is_segwit_script_type -from electrum.bip32 import BIP32Node +from electrum import bip32 +from electrum.crypto import hash_160 +from electrum.bitcoin import int_to_hex, var_int, is_segwit_script_type +from electrum.bip32 import BIP32Node, convert_bip32_intpath_to_strpath from electrum.i18n import _ from electrum.keystore import Hardware_KeyStore -from electrum.transaction import Transaction +from electrum.transaction import Transaction, PartialTransaction, PartialTxInput, PartialTxOutput from electrum.wallet import Standard_Wallet from electrum.util import bfh, bh2u, versiontuple, UserFacingException from electrum.base_wizard import ScriptTypeNotSupported t@@ -78,9 +80,6 @@ class Ledger_Client(): def label(self): return "" - def i4b(self, x): - return pack('>I', x) - def has_usable_connection_with_device(self): try: self.dongleObject.getFirmwareVersion() t@@ -101,29 +100,27 @@ class Ledger_Client(): raise UserFacingException(MSG_NEEDS_FW_UPDATE_SEGWIT) if xtype in ['p2wpkh-p2sh', 'p2wsh-p2sh'] and not self.supports_segwit(): raise UserFacingException(MSG_NEEDS_FW_UPDATE_SEGWIT) - splitPath = bip32_path.split('/') - if splitPath[0] == 'm': - splitPath = splitPath[1:] - bip32_path = bip32_path[2:] - fingerprint = 0 - if len(splitPath) > 1: - prevPath = "/".join(splitPath[0:len(splitPath) - 1]) + bip32_path = bip32.normalize_bip32_derivation(bip32_path) + bip32_intpath = bip32.convert_bip32_path_to_list_of_uint32(bip32_path) + bip32_path = bip32_path[2:] # cut off "m/" + if len(bip32_intpath) >= 1: + prevPath = bip32.convert_bip32_intpath_to_strpath(bip32_intpath[:-1])[2:] nodeData = self.dongleObject.getWalletPublicKey(prevPath) publicKey = compress_public_key(nodeData['publicKey']) - h = hashlib.new('ripemd160') - h.update(hashlib.sha256(publicKey).digest()) - fingerprint = unpack(">I", h.digest()[0:4])[0] + fingerprint_bytes = hash_160(publicKey)[0:4] + childnum_bytes = bip32_intpath[-1].to_bytes(length=4, byteorder="big") + else: + fingerprint_bytes = bytes(4) + childnum_bytes = bytes(4) nodeData = self.dongleObject.getWalletPublicKey(bip32_path) publicKey = compress_public_key(nodeData['publicKey']) - depth = len(splitPath) - lastChild = splitPath[len(splitPath) - 1].split('\'') - childnum = int(lastChild[0]) if len(lastChild) == 1 else 0x80000000 | int(lastChild[0]) + depth = len(bip32_intpath) return BIP32Node(xtype=xtype, eckey=ecc.ECPubkey(publicKey), chaincode=nodeData['chainCode'], depth=depth, - fingerprint=self.i4b(fingerprint), - child_number=self.i4b(childnum)).to_xpub() + fingerprint=fingerprint_bytes, + child_number=childnum_bytes).to_xpub() def has_detached_pin_support(self, client): try: t@@ -217,6 +214,8 @@ class Ledger_KeyStore(Hardware_KeyStore): hw_type = 'ledger' device = 'Ledger' + plugin: 'LedgerPlugin' + def __init__(self, d): Hardware_KeyStore.__init__(self, d) # Errors and other user interaction is done through the wallet's t@@ -231,9 +230,6 @@ class Ledger_KeyStore(Hardware_KeyStore): obj['cfg'] = self.cfg return obj - def get_derivation(self): - return self.derivation - def get_client(self): return self.plugin.get_client(self).dongleObject t@@ -260,13 +256,6 @@ class Ledger_KeyStore(Hardware_KeyStore): self.signing = False return wrapper - def address_id_stripped(self, address): - # Strip the leading "m/" - change, index = self.get_address_index(address) - derivation = self.derivation - address_path = "%s/%d/%d"%(derivation, change, index) - return address_path[2:] - def decrypt_message(self, pubkey, message, password): raise UserFacingException(_('Encryption and decryption are currently not supported for {}').format(self.device)) t@@ -277,7 +266,7 @@ class Ledger_KeyStore(Hardware_KeyStore): message_hash = hashlib.sha256(message).hexdigest().upper() # prompt for the PIN before displaying the dialog if necessary client = self.get_client() - address_path = self.get_derivation()[2:] + "/%d/%d"%sequence + address_path = self.get_derivation_prefix()[2:] + "/%d/%d"%sequence self.handler.show_message("Signing message ...\r\nMessage hash: "+message_hash) try: info = self.get_client().signMessagePrepare(address_path, message) t@@ -318,16 +307,13 @@ class Ledger_KeyStore(Hardware_KeyStore): @test_pin_unlocked @set_and_unset_signing - def sign_transaction(self, tx: Transaction, password): + def sign_transaction(self, tx, password): if tx.is_complete(): return - client = self.get_client() inputs = [] inputsPaths = [] - pubKeys = [] chipInputs = [] redeemScripts = [] - signatures = [] changePath = "" output = None p2shTransaction = False t@@ -336,60 +322,52 @@ class Ledger_KeyStore(Hardware_KeyStore): self.get_client() # prompt for the PIN before displaying the dialog if necessary # Fetch inputs of the transaction to sign - derivations = self.get_tx_derivations(tx) for txin in tx.inputs(): - if txin['type'] == 'coinbase': + if txin.is_coinbase(): self.give_error("Coinbase not supported") # should never happen - if txin['type'] in ['p2sh']: + if txin.script_type in ['p2sh']: p2shTransaction = True - if txin['type'] in ['p2wpkh-p2sh', 'p2wsh-p2sh']: + if txin.script_type in ['p2wpkh-p2sh', 'p2wsh-p2sh']: if not self.get_client_electrum().supports_segwit(): self.give_error(MSG_NEEDS_FW_UPDATE_SEGWIT) segwitTransaction = True - if txin['type'] in ['p2wpkh', 'p2wsh']: + if txin.script_type in ['p2wpkh', 'p2wsh']: if not self.get_client_electrum().supports_native_segwit(): self.give_error(MSG_NEEDS_FW_UPDATE_SEGWIT) segwitTransaction = True - pubkeys, x_pubkeys = tx.get_sorted_pubkeys(txin) - for i, x_pubkey in enumerate(x_pubkeys): - if x_pubkey in derivations: - signingPos = i - s = derivations.get(x_pubkey) - hwAddress = "%s/%d/%d" % (self.get_derivation()[2:], s[0], s[1]) - break - else: - self.give_error("No matching x_key for sign_transaction") # should never happen + my_pubkey, full_path = self.find_my_pubkey_in_txinout(txin) + if not full_path: + self.give_error("No matching pubkey for sign_transaction") # should never happen + full_path = convert_bip32_intpath_to_strpath(full_path)[2:] redeemScript = Transaction.get_preimage_script(txin) - txin_prev_tx = txin.get('prev_tx') + txin_prev_tx = txin.utxo if txin_prev_tx is None and not Transaction.is_segwit_input(txin): - raise UserFacingException(_('Offline signing with {} is not supported for legacy inputs.').format(self.device)) - txin_prev_tx_raw = txin_prev_tx.raw if txin_prev_tx else None + raise UserFacingException(_('Missing previous tx for legacy input.')) + txin_prev_tx_raw = txin_prev_tx.serialize() if txin_prev_tx else None inputs.append([txin_prev_tx_raw, - txin['prevout_n'], + txin.prevout.out_idx, redeemScript, - txin['prevout_hash'], - signingPos, - txin.get('sequence', 0xffffffff - 1), - txin.get('value')]) - inputsPaths.append(hwAddress) - pubKeys.append(pubkeys) + txin.prevout.txid.hex(), + my_pubkey, + txin.nsequence, + txin.value_sats()]) + inputsPaths.append(full_path) # Sanity check if p2shTransaction: for txin in tx.inputs(): - if txin['type'] != 'p2sh': + if txin.script_type != 'p2sh': self.give_error("P2SH / regular input mixed in same transaction not supported") # should never happen txOutput = var_int(len(tx.outputs())) for o in tx.outputs(): - output_type, addr, amount = o.type, o.address, o.value - txOutput += int_to_hex(amount, 8) - script = tx.pay_script(output_type, addr) + txOutput += int_to_hex(o.value, 8) + script = o.scriptpubkey.hex() txOutput += var_int(len(script)//2) txOutput += script txOutput = bfh(txOutput) t@@ -403,21 +381,21 @@ class Ledger_KeyStore(Hardware_KeyStore): self.give_error("Transaction with more than 2 outputs not supported") has_change = False any_output_on_change_branch = is_any_tx_output_on_change_branch(tx) - for o in tx.outputs(): - assert o.type == TYPE_ADDRESS - info = tx.output_info.get(o.address) - if (info is not None) and len(tx.outputs()) > 1 \ + for txout in tx.outputs(): + assert txout.address + if txout.is_mine and len(tx.outputs()) > 1 \ and not has_change: - index = info.address_index # prioritise hiding outputs on the 'change' branch from user # because no more than one change address allowed - if info.is_change == any_output_on_change_branch: - changePath = self.get_derivation()[2:] + "/%d/%d"%index + if txout.is_change == any_output_on_change_branch: + my_pubkey, changePath = self.find_my_pubkey_in_txinout(txout) + assert changePath + changePath = convert_bip32_intpath_to_strpath(changePath)[2:] has_change = True else: - output = o.address + output = txout.address else: - output = o.address + output = txout.address self.handler.show_message(_("Confirm Transaction on your Ledger device...")) try: t@@ -467,7 +445,10 @@ class Ledger_KeyStore(Hardware_KeyStore): singleInput, redeemScripts[inputIndex], version=tx.version) inputSignature = self.get_client().untrustedHashSign(inputsPaths[inputIndex], pin, lockTime=tx.locktime) inputSignature[0] = 0x30 # force for 1.4.9+ - signatures.append(inputSignature) + my_pubkey = inputs[inputIndex][4] + tx.add_signature_to_txin(txin_idx=inputIndex, + signing_pubkey=my_pubkey.hex(), + sig=inputSignature.hex()) inputIndex = inputIndex + 1 else: while inputIndex < len(inputs): t@@ -488,7 +469,10 @@ class Ledger_KeyStore(Hardware_KeyStore): # Sign input with the provided PIN inputSignature = self.get_client().untrustedHashSign(inputsPaths[inputIndex], pin, lockTime=tx.locktime) inputSignature[0] = 0x30 # force for 1.4.9+ - signatures.append(inputSignature) + my_pubkey = inputs[inputIndex][4] + tx.add_signature_to_txin(txin_idx=inputIndex, + signing_pubkey=my_pubkey.hex(), + sig=inputSignature.hex()) inputIndex = inputIndex + 1 firstTransaction = False except UserWarning: t@@ -508,16 +492,11 @@ class Ledger_KeyStore(Hardware_KeyStore): finally: self.handler.finished() - for i, txin in enumerate(tx.inputs()): - signingPos = inputs[i][4] - tx.add_signature_to_txin(i, signingPos, bh2u(signatures[i])) - tx.raw = tx.serialize() - @test_pin_unlocked @set_and_unset_signing def show_address(self, sequence, txin_type): client = self.get_client() - address_path = self.get_derivation()[2:] + "/%d/%d"%sequence + address_path = self.get_derivation_prefix()[2:] + "/%d/%d"%sequence self.handler.show_message(_("Showing address ...")) segwit = is_segwit_script_type(txin_type) segwitNative = txin_type == 'p2wpkh' DIR diff --git a/electrum/plugins/safe_t/safe_t.py b/electrum/plugins/safe_t/safe_t.py t@@ -1,6 +1,7 @@ from binascii import hexlify, unhexlify import traceback import sys +from typing import NamedTuple, Any, Optional, Dict, Union, List, Tuple, TYPE_CHECKING from electrum.util import bfh, bh2u, versiontuple, UserCancelled, UserFacingException from electrum.bitcoin import TYPE_ADDRESS, TYPE_SCRIPT t@@ -8,13 +9,16 @@ from electrum.bip32 import BIP32Node from electrum import constants from electrum.i18n import _ from electrum.plugin import Device -from electrum.transaction import deserialize, Transaction -from electrum.keystore import Hardware_KeyStore, is_xpubkey, parse_xpubkey +from electrum.transaction import Transaction, PartialTransaction, PartialTxInput, PartialTxOutput +from electrum.keystore import Hardware_KeyStore from electrum.base_wizard import ScriptTypeNotSupported from ..hw_wallet import HW_PluginBase -from ..hw_wallet.plugin import is_any_tx_output_on_change_branch, trezor_validate_op_return_output_and_get_data +from ..hw_wallet.plugin import (is_any_tx_output_on_change_branch, trezor_validate_op_return_output_and_get_data, + get_xpubs_and_der_suffixes_from_txinout) +if TYPE_CHECKING: + from .client import SafeTClient # Safe-T mini initialization methods TIM_NEW, TIM_RECOVER, TIM_MNEMONIC, TIM_PRIVKEY = range(0, 4) t@@ -24,8 +28,7 @@ class SafeTKeyStore(Hardware_KeyStore): hw_type = 'safe_t' device = 'Safe-T mini' - def get_derivation(self): - return self.derivation + plugin: 'SafeTPlugin' def get_client(self, force_pair=True): return self.plugin.get_client(self, force_pair) t@@ -35,7 +38,7 @@ class SafeTKeyStore(Hardware_KeyStore): def sign_message(self, sequence, message, password): client = self.get_client() - address_path = self.get_derivation() + "/%d/%d"%sequence + address_path = self.get_derivation_prefix() + "/%d/%d"%sequence address_n = client.expand_path(address_path) msg_sig = client.sign_message(self.plugin.get_coin_name(), address_n, message) return msg_sig.signature t@@ -45,22 +48,13 @@ class SafeTKeyStore(Hardware_KeyStore): return # previous transactions used as inputs prev_tx = {} - # path of the xpubs that are involved - xpub_path = {} for txin in tx.inputs(): - pubkeys, x_pubkeys = tx.get_sorted_pubkeys(txin) - tx_hash = txin['prevout_hash'] - if txin.get('prev_tx') is None and not Transaction.is_segwit_input(txin): - raise UserFacingException(_('Offline signing with {} is not supported for legacy inputs.').format(self.device)) - prev_tx[tx_hash] = txin['prev_tx'] - for x_pubkey in x_pubkeys: - if not is_xpubkey(x_pubkey): - continue - xpub, s = parse_xpubkey(x_pubkey) - if xpub == self.get_master_public_key(): - xpub_path[xpub] = self.get_derivation() + tx_hash = txin.prevout.txid.hex() + if txin.utxo is None and not Transaction.is_segwit_input(txin): + raise UserFacingException(_('Missing previous tx for legacy input.')) + prev_tx[tx_hash] = txin.utxo - self.plugin.sign_transaction(self, tx, prev_tx, xpub_path) + self.plugin.sign_transaction(self, tx, prev_tx) class SafeTPlugin(HW_PluginBase): t@@ -148,7 +142,7 @@ class SafeTPlugin(HW_PluginBase): return client - def get_client(self, keystore, force_pair=True): + def get_client(self, keystore, force_pair=True) -> Optional['SafeTClient']: devmgr = self.device_manager() handler = keystore.handler with devmgr.hid_lock: t@@ -302,12 +296,11 @@ class SafeTPlugin(HW_PluginBase): return self.types.OutputScriptType.PAYTOMULTISIG raise ValueError('unexpected txin type: {}'.format(electrum_txin_type)) - def sign_transaction(self, keystore, tx, prev_tx, xpub_path): + def sign_transaction(self, keystore, tx: PartialTransaction, prev_tx): self.prev_tx = prev_tx - self.xpub_path = xpub_path client = self.get_client(keystore) - inputs = self.tx_inputs(tx, True) - outputs = self.tx_outputs(keystore.get_derivation(), tx) + inputs = self.tx_inputs(tx, for_sig=True, keystore=keystore) + outputs = self.tx_outputs(tx, keystore=keystore) signatures = client.sign_tx(self.get_coin_name(), inputs, outputs, lock_time=tx.locktime, version=tx.version)[0] signatures = [(bh2u(x) + '01') for x in signatures] t@@ -322,139 +315,120 @@ class SafeTPlugin(HW_PluginBase): if not client.atleast_version(1, 0): keystore.handler.show_error(_("Your device firmware is too old")) return - change, index = wallet.get_address_index(address) - derivation = keystore.derivation - address_path = "%s/%d/%d"%(derivation, change, index) + deriv_suffix = wallet.get_address_index(address) + derivation = keystore.get_derivation_prefix() + address_path = "%s/%d/%d"%(derivation, *deriv_suffix) address_n = client.expand_path(address_path) + script_type = self.get_safet_input_script_type(wallet.txin_type) + + # prepare multisig, if available: xpubs = wallet.get_master_public_keys() - if len(xpubs) == 1: - script_type = self.get_safet_input_script_type(wallet.txin_type) - client.get_address(self.get_coin_name(), address_n, True, script_type=script_type) - else: - def f(xpub): - return self._make_node_path(xpub, [change, index]) + if len(xpubs) > 1: pubkeys = wallet.get_public_keys(address) # sort xpubs using the order of pubkeys - sorted_pubkeys, sorted_xpubs = zip(*sorted(zip(pubkeys, xpubs))) - pubkeys = list(map(f, sorted_xpubs)) - multisig = self.types.MultisigRedeemScriptType( - pubkeys=pubkeys, - signatures=[b''] * wallet.n, - m=wallet.m, - ) - script_type = self.get_safet_input_script_type(wallet.txin_type) - client.get_address(self.get_coin_name(), address_n, True, multisig=multisig, script_type=script_type) - - def tx_inputs(self, tx, for_sig=False): + sorted_pairs = sorted(zip(pubkeys, xpubs)) + multisig = self._make_multisig( + wallet.m, + [(xpub, deriv_suffix) for pubkey, xpub in sorted_pairs]) + else: + multisig = None + + client.get_address(self.get_coin_name(), address_n, True, multisig=multisig, script_type=script_type) + + def tx_inputs(self, tx: Transaction, *, for_sig=False, keystore: 'SafeTKeyStore' = None): inputs = [] for txin in tx.inputs(): txinputtype = self.types.TxInputType() - if txin['type'] == 'coinbase': + if txin.is_coinbase(): prev_hash = b"\x00"*32 prev_index = 0xffffffff # signed int -1 else: if for_sig: - x_pubkeys = txin['x_pubkeys'] - if len(x_pubkeys) == 1: - x_pubkey = x_pubkeys[0] - xpub, s = parse_xpubkey(x_pubkey) - xpub_n = self.client_class.expand_path(self.xpub_path[xpub]) - txinputtype._extend_address_n(xpub_n + s) - txinputtype.script_type = self.get_safet_input_script_type(txin['type']) + assert isinstance(tx, PartialTransaction) + assert isinstance(txin, PartialTxInput) + assert keystore + if len(txin.pubkeys) > 1: + xpubs_and_deriv_suffixes = get_xpubs_and_der_suffixes_from_txinout(tx, txin) + multisig = self._make_multisig(txin.num_sig, xpubs_and_deriv_suffixes) else: - def f(x_pubkey): - xpub, s = parse_xpubkey(x_pubkey) - return self._make_node_path(xpub, s) - pubkeys = list(map(f, x_pubkeys)) - multisig = self.types.MultisigRedeemScriptType( - pubkeys=pubkeys, - signatures=list(map(lambda x: bfh(x)[:-1] if x else b'', txin.get('signatures'))), - m=txin.get('num_sig'), - ) - script_type = self.get_safet_input_script_type(txin['type']) - txinputtype = self.types.TxInputType( - script_type=script_type, - multisig=multisig - ) - # find which key is mine - for x_pubkey in x_pubkeys: - if is_xpubkey(x_pubkey): - xpub, s = parse_xpubkey(x_pubkey) - if xpub in self.xpub_path: - xpub_n = self.client_class.expand_path(self.xpub_path[xpub]) - txinputtype._extend_address_n(xpub_n + s) - break - - prev_hash = unhexlify(txin['prevout_hash']) - prev_index = txin['prevout_n'] - - if 'value' in txin: - txinputtype.amount = txin['value'] + multisig = None + script_type = self.get_safet_input_script_type(txin.script_type) + txinputtype = self.types.TxInputType( + script_type=script_type, + multisig=multisig) + my_pubkey, full_path = keystore.find_my_pubkey_in_txinout(txin) + if full_path: + txinputtype._extend_address_n(full_path) + + prev_hash = txin.prevout.txid + prev_index = txin.prevout.out_idx + + if txin.value_sats() is not None: + txinputtype.amount = txin.value_sats() txinputtype.prev_hash = prev_hash txinputtype.prev_index = prev_index - if txin.get('scriptSig') is not None: - script_sig = bfh(txin['scriptSig']) - txinputtype.script_sig = script_sig + if txin.script_sig is not None: + txinputtype.script_sig = txin.script_sig - txinputtype.sequence = txin.get('sequence', 0xffffffff - 1) + txinputtype.sequence = txin.nsequence inputs.append(txinputtype) return inputs - def tx_outputs(self, derivation, tx: Transaction): + def _make_multisig(self, m, xpubs): + if len(xpubs) == 1: + return None + pubkeys = [self._make_node_path(xpub, deriv) for xpub, deriv in xpubs] + return self.types.MultisigRedeemScriptType( + pubkeys=pubkeys, + signatures=[b''] * len(pubkeys), + m=m) + + def tx_outputs(self, tx: PartialTransaction, *, keystore: 'SafeTKeyStore'): def create_output_by_derivation(): - script_type = self.get_safet_output_script_type(info.script_type) - if len(xpubs) == 1: - address_n = self.client_class.expand_path(derivation + "/%d/%d" % index) - txoutputtype = self.types.TxOutputType( - amount=amount, - script_type=script_type, - address_n=address_n, - ) + script_type = self.get_safet_output_script_type(txout.script_type) + if len(txout.pubkeys) > 1: + xpubs_and_deriv_suffixes = get_xpubs_and_der_suffixes_from_txinout(tx, txout) + multisig = self._make_multisig(txout.num_sig, xpubs_and_deriv_suffixes) else: - address_n = self.client_class.expand_path("/%d/%d" % index) - pubkeys = [self._make_node_path(xpub, address_n) for xpub in xpubs] - multisig = self.types.MultisigRedeemScriptType( - pubkeys=pubkeys, - signatures=[b''] * len(pubkeys), - m=m) - txoutputtype = self.types.TxOutputType( - multisig=multisig, - amount=amount, - address_n=self.client_class.expand_path(derivation + "/%d/%d" % index), - script_type=script_type) + multisig = None + my_pubkey, full_path = keystore.find_my_pubkey_in_txinout(txout) + assert full_path + txoutputtype = self.types.TxOutputType( + multisig=multisig, + amount=txout.value, + address_n=full_path, + script_type=script_type) return txoutputtype def create_output_by_address(): txoutputtype = self.types.TxOutputType() - txoutputtype.amount = amount - if _type == TYPE_SCRIPT: - txoutputtype.script_type = self.types.OutputScriptType.PAYTOOPRETURN - txoutputtype.op_return_data = trezor_validate_op_return_output_and_get_data(o) - elif _type == TYPE_ADDRESS: + txoutputtype.amount = txout.value + if address: txoutputtype.script_type = self.types.OutputScriptType.PAYTOADDRESS txoutputtype.address = address + else: + txoutputtype.script_type = self.types.OutputScriptType.PAYTOOPRETURN + txoutputtype.op_return_data = trezor_validate_op_return_output_and_get_data(txout) return txoutputtype outputs = [] has_change = False any_output_on_change_branch = is_any_tx_output_on_change_branch(tx) - for o in tx.outputs(): - _type, address, amount = o.type, o.address, o.value + for txout in tx.outputs(): + address = txout.address use_create_by_derivation = False - info = tx.output_info.get(address) - if info is not None and not has_change: - index, xpubs, m = info.address_index, info.sorted_xpubs, info.num_sig + if txout.is_mine and not has_change: # prioritise hiding outputs on the 'change' branch from user # because no more than one change address allowed # note: ^ restriction can be removed once we require fw # that has https://github.com/trezor/trezor-mcu/pull/306 - if info.is_change == any_output_on_change_branch: + if txout.is_change == any_output_on_change_branch: use_create_by_derivation = True has_change = True t@@ -466,20 +440,20 @@ class SafeTPlugin(HW_PluginBase): return outputs - def electrum_tx_to_txtype(self, tx): + def electrum_tx_to_txtype(self, tx: Optional[Transaction]): t = self.types.TransactionType() if tx is None: # probably for segwit input and we don't need this prev txn return t - d = deserialize(tx.raw) - t.version = d['version'] - t.lock_time = d['lockTime'] + tx.deserialize() + t.version = tx.version + t.lock_time = tx.locktime inputs = self.tx_inputs(tx) t._extend_inputs(inputs) - for vout in d['outputs']: + for out in tx.outputs(): o = t._add_bin_outputs() - o.amount = vout['value'] - o.script_pubkey = bfh(vout['scriptPubKey']) + o.amount = out.value + o.script_pubkey = out.scriptpubkey return t # This function is called from the TREZOR libraries (via tx_api) DIR diff --git a/electrum/plugins/trezor/trezor.py b/electrum/plugins/trezor/trezor.py t@@ -1,6 +1,6 @@ import traceback import sys -from typing import NamedTuple, Any +from typing import NamedTuple, Any, Optional, Dict, Union, List, Tuple, TYPE_CHECKING from electrum.util import bfh, bh2u, versiontuple, UserCancelled, UserFacingException from electrum.bitcoin import TYPE_ADDRESS, TYPE_SCRIPT t@@ -8,14 +8,15 @@ from electrum.bip32 import BIP32Node, convert_bip32_path_to_list_of_uint32 as pa from electrum import constants from electrum.i18n import _ from electrum.plugin import Device -from electrum.transaction import deserialize, Transaction -from electrum.keystore import Hardware_KeyStore, is_xpubkey, parse_xpubkey +from electrum.transaction import Transaction, PartialTransaction, PartialTxInput, PartialTxOutput +from electrum.keystore import Hardware_KeyStore from electrum.base_wizard import ScriptTypeNotSupported, HWD_SETUP_NEW_WALLET from electrum.logging import get_logger from ..hw_wallet import HW_PluginBase from ..hw_wallet.plugin import (is_any_tx_output_on_change_branch, trezor_validate_op_return_output_and_get_data, - LibraryFoundButUnusable, OutdatedHwFirmwareException) + LibraryFoundButUnusable, OutdatedHwFirmwareException, + get_xpubs_and_der_suffixes_from_txinout) _logger = get_logger(__name__) t@@ -53,8 +54,7 @@ class TrezorKeyStore(Hardware_KeyStore): hw_type = 'trezor' device = TREZOR_PRODUCT_KEY - def get_derivation(self): - return self.derivation + plugin: 'TrezorPlugin' def get_client(self, force_pair=True): return self.plugin.get_client(self, force_pair) t@@ -64,7 +64,7 @@ class TrezorKeyStore(Hardware_KeyStore): def sign_message(self, sequence, message, password): client = self.get_client() - address_path = self.get_derivation() + "/%d/%d"%sequence + address_path = self.get_derivation_prefix() + "/%d/%d"%sequence msg_sig = client.sign_message(address_path, message) return msg_sig.signature t@@ -73,22 +73,13 @@ class TrezorKeyStore(Hardware_KeyStore): return # previous transactions used as inputs prev_tx = {} - # path of the xpubs that are involved - xpub_path = {} for txin in tx.inputs(): - pubkeys, x_pubkeys = tx.get_sorted_pubkeys(txin) - tx_hash = txin['prevout_hash'] - if txin.get('prev_tx') is None and not Transaction.is_segwit_input(txin): - raise UserFacingException(_('Offline signing with {} is not supported for legacy inputs.').format(self.device)) - prev_tx[tx_hash] = txin['prev_tx'] - for x_pubkey in x_pubkeys: - if not is_xpubkey(x_pubkey): - continue - xpub, s = parse_xpubkey(x_pubkey) - if xpub == self.get_master_public_key(): - xpub_path[xpub] = self.get_derivation() + tx_hash = txin.prevout.txid.hex() + if txin.utxo is None and not Transaction.is_segwit_input(txin): + raise UserFacingException(_('Missing previous tx for legacy input.')) + prev_tx[tx_hash] = txin.utxo - self.plugin.sign_transaction(self, tx, prev_tx, xpub_path) + self.plugin.sign_transaction(self, tx, prev_tx) class TrezorInitSettings(NamedTuple): t@@ -172,7 +163,7 @@ class TrezorPlugin(HW_PluginBase): # note that this call can still raise! return TrezorClientBase(transport, handler, self) - def get_client(self, keystore, force_pair=True): + def get_client(self, keystore, force_pair=True) -> Optional['TrezorClientBase']: devmgr = self.device_manager() handler = keystore.handler with devmgr.hid_lock: t@@ -327,11 +318,11 @@ class TrezorPlugin(HW_PluginBase): return OutputScriptType.PAYTOMULTISIG raise ValueError('unexpected txin type: {}'.format(electrum_txin_type)) - def sign_transaction(self, keystore, tx, prev_tx, xpub_path): - prev_tx = { bfh(txhash): self.electrum_tx_to_txtype(tx, xpub_path) for txhash, tx in prev_tx.items() } + def sign_transaction(self, keystore, tx: PartialTransaction, prev_tx): + prev_tx = { bfh(txhash): self.electrum_tx_to_txtype(tx) for txhash, tx in prev_tx.items() } client = self.get_client(keystore) - inputs = self.tx_inputs(tx, xpub_path, True) - outputs = self.tx_outputs(keystore.get_derivation(), tx) + inputs = self.tx_inputs(tx, for_sig=True, keystore=keystore) + outputs = self.tx_outputs(tx, keystore=keystore) details = SignTx(lock_time=tx.locktime, version=tx.version) signatures, _ = client.sign_tx(self.get_coin_name(), inputs, outputs, details=details, prev_txes=prev_tx) signatures = [(bh2u(x) + '01') for x in signatures] t@@ -343,7 +334,7 @@ class TrezorPlugin(HW_PluginBase): if not self.show_address_helper(wallet, address, keystore): return deriv_suffix = wallet.get_address_index(address) - derivation = keystore.derivation + derivation = keystore.get_derivation_prefix() address_path = "%s/%d/%d"%(derivation, *deriv_suffix) script_type = self.get_trezor_input_script_type(wallet.txin_type) t@@ -355,111 +346,107 @@ class TrezorPlugin(HW_PluginBase): sorted_pairs = sorted(zip(pubkeys, xpubs)) multisig = self._make_multisig( wallet.m, - [(xpub, deriv_suffix) for _, xpub in sorted_pairs]) + [(xpub, deriv_suffix) for pubkey, xpub in sorted_pairs]) else: multisig = None client = self.get_client(keystore) client.show_address(address_path, script_type, multisig) - def tx_inputs(self, tx, xpub_path, for_sig=False): + def tx_inputs(self, tx: Transaction, *, for_sig=False, keystore: 'TrezorKeyStore' = None): inputs = [] for txin in tx.inputs(): txinputtype = TxInputType() - if txin['type'] == 'coinbase': + if txin.is_coinbase(): prev_hash = b"\x00"*32 prev_index = 0xffffffff # signed int -1 else: if for_sig: - x_pubkeys = txin['x_pubkeys'] - xpubs = [parse_xpubkey(x) for x in x_pubkeys] - multisig = self._make_multisig(txin.get('num_sig'), xpubs, txin.get('signatures')) - script_type = self.get_trezor_input_script_type(txin['type']) + assert isinstance(tx, PartialTransaction) + assert isinstance(txin, PartialTxInput) + assert keystore + if len(txin.pubkeys) > 1: + xpubs_and_deriv_suffixes = get_xpubs_and_der_suffixes_from_txinout(tx, txin) + multisig = self._make_multisig(txin.num_sig, xpubs_and_deriv_suffixes) + else: + multisig = None + script_type = self.get_trezor_input_script_type(txin.script_type) txinputtype = TxInputType( script_type=script_type, multisig=multisig) - # find which key is mine - for xpub, deriv in xpubs: - if xpub in xpub_path: - xpub_n = parse_path(xpub_path[xpub]) - txinputtype.address_n = xpub_n + deriv - break - - prev_hash = bfh(txin['prevout_hash']) - prev_index = txin['prevout_n'] - - if 'value' in txin: - txinputtype.amount = txin['value'] + my_pubkey, full_path = keystore.find_my_pubkey_in_txinout(txin) + if full_path: + txinputtype.address_n = full_path + + prev_hash = txin.prevout.txid + prev_index = txin.prevout.out_idx + + if txin.value_sats() is not None: + txinputtype.amount = txin.value_sats() txinputtype.prev_hash = prev_hash txinputtype.prev_index = prev_index - if txin.get('scriptSig') is not None: - script_sig = bfh(txin['scriptSig']) - txinputtype.script_sig = script_sig + if txin.script_sig is not None: + txinputtype.script_sig = txin.script_sig - txinputtype.sequence = txin.get('sequence', 0xffffffff - 1) + txinputtype.sequence = txin.nsequence inputs.append(txinputtype) return inputs - def _make_multisig(self, m, xpubs, signatures=None): + def _make_multisig(self, m, xpubs): if len(xpubs) == 1: return None - pubkeys = [self._make_node_path(xpub, deriv) for xpub, deriv in xpubs] - if signatures is None: - signatures = [b''] * len(pubkeys) - elif len(signatures) != len(pubkeys): - raise RuntimeError('Mismatched number of signatures') - else: - signatures = [bfh(x)[:-1] if x else b'' for x in signatures] - return MultisigRedeemScriptType( pubkeys=pubkeys, - signatures=signatures, + signatures=[b''] * len(pubkeys), m=m) - def tx_outputs(self, derivation, tx: Transaction): + def tx_outputs(self, tx: PartialTransaction, *, keystore: 'TrezorKeyStore'): def create_output_by_derivation(): - script_type = self.get_trezor_output_script_type(info.script_type) - deriv = parse_path("/%d/%d" % index) - multisig = self._make_multisig(m, [(xpub, deriv) for xpub in xpubs]) + script_type = self.get_trezor_output_script_type(txout.script_type) + if len(txout.pubkeys) > 1: + xpubs_and_deriv_suffixes = get_xpubs_and_der_suffixes_from_txinout(tx, txout) + multisig = self._make_multisig(txout.num_sig, xpubs_and_deriv_suffixes) + else: + multisig = None + my_pubkey, full_path = keystore.find_my_pubkey_in_txinout(txout) + assert full_path txoutputtype = TxOutputType( multisig=multisig, - amount=amount, - address_n=parse_path(derivation + "/%d/%d" % index), + amount=txout.value, + address_n=full_path, script_type=script_type) return txoutputtype def create_output_by_address(): txoutputtype = TxOutputType() - txoutputtype.amount = amount - if _type == TYPE_SCRIPT: - txoutputtype.script_type = OutputScriptType.PAYTOOPRETURN - txoutputtype.op_return_data = trezor_validate_op_return_output_and_get_data(o) - elif _type == TYPE_ADDRESS: + txoutputtype.amount = txout.value + if address: txoutputtype.script_type = OutputScriptType.PAYTOADDRESS txoutputtype.address = address + else: + txoutputtype.script_type = OutputScriptType.PAYTOOPRETURN + txoutputtype.op_return_data = trezor_validate_op_return_output_and_get_data(txout) return txoutputtype outputs = [] has_change = False any_output_on_change_branch = is_any_tx_output_on_change_branch(tx) - for o in tx.outputs(): - _type, address, amount = o.type, o.address, o.value + for txout in tx.outputs(): + address = txout.address use_create_by_derivation = False - info = tx.output_info.get(address) - if info is not None and not has_change: - index, xpubs, m = info.address_index, info.sorted_xpubs, info.num_sig + if txout.is_mine and not has_change: # prioritise hiding outputs on the 'change' branch from user # because no more than one change address allowed # note: ^ restriction can be removed once we require fw # that has https://github.com/trezor/trezor-mcu/pull/306 - if info.is_change == any_output_on_change_branch: + if txout.is_change == any_output_on_change_branch: use_create_by_derivation = True has_change = True t@@ -471,17 +458,17 @@ class TrezorPlugin(HW_PluginBase): return outputs - def electrum_tx_to_txtype(self, tx, xpub_path): + def electrum_tx_to_txtype(self, tx: Optional[Transaction]): t = TransactionType() if tx is None: # probably for segwit input and we don't need this prev txn return t - d = deserialize(tx.raw) - t.version = d['version'] - t.lock_time = d['lockTime'] - t.inputs = self.tx_inputs(tx, xpub_path) + tx.deserialize() + t.version = tx.version + t.lock_time = tx.locktime + t.inputs = self.tx_inputs(tx) t.bin_outputs = [ - TxOutputBinType(amount=vout['value'], script_pubkey=bfh(vout['scriptPubKey'])) - for vout in d['outputs'] + TxOutputBinType(amount=o.value, script_pubkey=o.scriptpubkey) + for o in tx.outputs() ] return t DIR diff --git a/electrum/plugins/trustedcoin/cmdline.py b/electrum/plugins/trustedcoin/cmdline.py t@@ -30,7 +30,7 @@ from .trustedcoin import TrustedCoinPlugin class Plugin(TrustedCoinPlugin): - def prompt_user_for_otp(self, wallet, tx): + def prompt_user_for_otp(self, wallet, tx): # FIXME this is broken if not isinstance(wallet, self.wallet_class): return if not wallet.can_sign_without_server(): DIR diff --git a/electrum/plugins/trustedcoin/legacy_tx_format.py b/electrum/plugins/trustedcoin/legacy_tx_format.py t@@ -0,0 +1,106 @@ +# Copyright (C) 2018 The Electrum developers +# Distributed under the MIT software license, see the accompanying +# file LICENCE or http://www.opensource.org/licenses/mit-license.php + +import copy +from typing import Union + +from electrum import bitcoin +from electrum.bitcoin import push_script, int_to_hex, var_int +from electrum.transaction import (Transaction, PartialTransaction, PartialTxInput, + multisig_script, construct_witness) +from electrum.keystore import BIP32_KeyStore +from electrum.wallet import Multisig_Wallet + + +ELECTRUM_PARTIAL_TXN_HEADER_MAGIC = b'EPTF\xff' +PARTIAL_FORMAT_VERSION = b'\x00' +NO_SIGNATURE = b'\xff' + + +def get_xpubkey(keystore: BIP32_KeyStore, c, i) -> str: + def encode_path_int(path_int) -> str: + if path_int < 0xffff: + hex = bitcoin.int_to_hex(path_int, 2) + else: + hex = 'ffff' + bitcoin.int_to_hex(path_int, 4) + return hex + + s = ''.join(map(encode_path_int, (c, i))) + return 'ff' + bitcoin.DecodeBase58Check(keystore.xpub).hex() + s + + +def serialize_tx_in_legacy_format(tx: PartialTransaction, *, wallet: Multisig_Wallet) -> str: + assert isinstance(tx, PartialTransaction) + + # copy tx so we don't mutate the input arg + # monkey-patch method of tx instance to change serialization + tx = copy.deepcopy(tx) + + def get_siglist(txin: 'PartialTxInput', *, estimate_size=False): + if txin.prevout.is_coinbase(): + return [], [] + if estimate_size: + try: + pubkey_size = len(txin.pubkeys[0]) + except IndexError: + pubkey_size = 33 # guess it is compressed + num_pubkeys = max(1, len(txin.pubkeys)) + pk_list = ["00" * pubkey_size] * num_pubkeys + # we assume that signature will be 0x48 bytes long + num_sig = max(txin.num_sig, num_pubkeys) + sig_list = [ "00" * 0x48 ] * num_sig + else: + pk_list = ["" for pk in txin.pubkeys] + for ks in wallet.get_keystores(): + my_pubkey, full_path = ks.find_my_pubkey_in_txinout(txin) + x_pubkey = get_xpubkey(ks, full_path[-2], full_path[-1]) + pubkey_index = txin.pubkeys.index(my_pubkey) + pk_list[pubkey_index] = x_pubkey + assert all(pk_list) + sig_list = [txin.part_sigs.get(pubkey, NO_SIGNATURE).hex() for pubkey in txin.pubkeys] + return pk_list, sig_list + + def input_script(self, txin: PartialTxInput, *, estimate_size=False) -> str: + assert estimate_size is False + pubkeys, sig_list = get_siglist(txin, estimate_size=estimate_size) + script = ''.join(push_script(x) for x in sig_list) + if txin.script_type == 'p2sh': + # put op_0 before script + script = '00' + script + redeem_script = multisig_script(pubkeys, txin.num_sig) + script += push_script(redeem_script) + return script + elif txin.script_type == 'p2wsh': + return '' + raise Exception(f"unexpected type {txin.script_type}") + tx.input_script = input_script.__get__(tx, PartialTransaction) + + def serialize_witness(self, txin: PartialTxInput, *, estimate_size=False): + assert estimate_size is False + if txin.witness is not None: + return txin.witness.hex() + if txin.prevout.is_coinbase(): + return '' + assert isinstance(txin, PartialTxInput) + if not self.is_segwit_input(txin): + return '00' + pubkeys, sig_list = get_siglist(txin, estimate_size=estimate_size) + if txin.script_type == 'p2wsh': + witness_script = multisig_script(pubkeys, txin.num_sig) + witness = construct_witness([0] + sig_list + [witness_script]) + else: + raise Exception(f"unexpected type {txin.script_type}") + if txin.is_complete() or estimate_size: + partial_format_witness_prefix = '' + else: + input_value = int_to_hex(txin.value_sats(), 8) + witness_version = int_to_hex(0, 2) + partial_format_witness_prefix = var_int(0xffffffff) + input_value + witness_version + return partial_format_witness_prefix + witness + tx.serialize_witness = serialize_witness.__get__(tx, PartialTransaction) + + buf = ELECTRUM_PARTIAL_TXN_HEADER_MAGIC.hex() + buf += PARTIAL_FORMAT_VERSION.hex() + buf += tx.serialize_to_network() + return buf DIR diff --git a/electrum/plugins/trustedcoin/trustedcoin.py b/electrum/plugins/trustedcoin/trustedcoin.py t@@ -29,7 +29,7 @@ import base64 import time import hashlib from collections import defaultdict -from typing import Dict, Union +from typing import Dict, Union, Sequence, List from urllib.parse import urljoin from urllib.parse import quote t@@ -39,7 +39,7 @@ from electrum import ecc, constants, keystore, version, bip32, bitcoin from electrum.bitcoin import TYPE_ADDRESS from electrum.bip32 import BIP32Node, xpub_type from electrum.crypto import sha256 -from electrum.transaction import TxOutput +from electrum.transaction import PartialTxOutput, PartialTxInput, PartialTransaction, Transaction from electrum.mnemonic import Mnemonic, seed_type, is_any_2fa_seed_type from electrum.wallet import Multisig_Wallet, Deterministic_Wallet from electrum.i18n import _ t@@ -50,6 +50,8 @@ from electrum.network import Network from electrum.base_wizard import BaseWizard, WizardWalletPasswordSetting from electrum.logging import Logger +from .legacy_tx_format import serialize_tx_in_legacy_format + def get_signing_xpub(xtype): if not constants.net.TESTNET: t@@ -259,6 +261,8 @@ server = TrustedCoinCosignerClient(user_agent="Electrum/" + version.ELECTRUM_VER class Wallet_2fa(Multisig_Wallet): + plugin: 'TrustedCoinPlugin' + wallet_type = '2fa' def __init__(self, storage, *, config): t@@ -314,34 +318,35 @@ class Wallet_2fa(Multisig_Wallet): raise Exception('too high trustedcoin fee ({} for {} txns)'.format(price, n)) return price - def make_unsigned_transaction(self, coins, outputs, fixed_fee=None, - change_addr=None, is_sweep=False): + def make_unsigned_transaction(self, *, coins: Sequence[PartialTxInput], + outputs: List[PartialTxOutput], fee=None, + change_addr: str = None, is_sweep=False) -> PartialTransaction: mk_tx = lambda o: Multisig_Wallet.make_unsigned_transaction( - self, coins, o, fixed_fee, change_addr) - fee = self.extra_fee() if not is_sweep else 0 - if fee: + self, coins=coins, outputs=o, fee=fee, change_addr=change_addr) + extra_fee = self.extra_fee() if not is_sweep else 0 + if extra_fee: address = self.billing_info['billing_address_segwit'] - fee_output = TxOutput(TYPE_ADDRESS, address, fee) + fee_output = PartialTxOutput.from_address_and_value(address, extra_fee) try: tx = mk_tx(outputs + [fee_output]) except NotEnoughFunds: # TrustedCoin won't charge if the total inputs is # lower than their fee tx = mk_tx(outputs) - if tx.input_value() >= fee: + if tx.input_value() >= extra_fee: raise self.logger.info("not charging for this tx") else: tx = mk_tx(outputs) return tx - def on_otp(self, tx, otp): + def on_otp(self, tx: PartialTransaction, otp): if not otp: self.logger.info("sign_transaction: no auth code") return otp = int(otp) long_user_id, short_id = self.get_user_id() - raw_tx = tx.serialize() + raw_tx = serialize_tx_in_legacy_format(tx, wallet=self) try: r = server.sign(short_id, raw_tx, otp) except TrustedCoinException as e: t@@ -350,8 +355,9 @@ class Wallet_2fa(Multisig_Wallet): else: raise if r: - raw_tx = r.get('transaction') - tx.update(raw_tx) + received_raw_tx = r.get('transaction') + received_tx = Transaction(received_raw_tx) + tx.combine_with_other_psbt(received_tx) self.logger.info(f"twofactor: is complete {tx.is_complete()}") # reset billing_info self.billing_info = None t@@ -457,15 +463,16 @@ class TrustedCoinPlugin(BasePlugin): self.logger.info("twofactor: xpub3 not needed") return def wrapper(tx): + assert tx self.prompt_user_for_otp(wallet, tx, on_success, on_failure) return wrapper @hook - def get_tx_extra_fee(self, wallet, tx): + def get_tx_extra_fee(self, wallet, tx: Transaction): if type(wallet) != Wallet_2fa: return for o in tx.outputs(): - if o.type == TYPE_ADDRESS and wallet.is_billing_address(o.address): + if wallet.is_billing_address(o.address): return o.address, o.value def finish_requesting(func): DIR diff --git a/electrum/scripts/bip70.py b/electrum/scripts/bip70.py t@@ -7,6 +7,7 @@ import tlslite from electrum.transaction import Transaction from electrum import paymentrequest from electrum import paymentrequest_pb2 as pb2 +from electrum.bitcoin import address_to_script chain_file = 'mychain.pem' cert_file = 'mycert.pem' t@@ -26,7 +27,7 @@ certificates.certificate.extend(map(lambda x: str(x.bytes), chain.x509List)) with open(cert_file, 'r') as f: rsakey = tlslite.utils.python_rsakey.Python_RSAKey.parsePEM(f.read()) -script = Transaction.pay_script('address', address).decode('hex') +script = address_to_script(address) pr_string = paymentrequest.make_payment_request(amount, script, memo, rsakey) DIR diff --git a/electrum/segwit_addr.py b/electrum/segwit_addr.py t@@ -103,6 +103,8 @@ def convertbits(data, frombits, tobits, pad=True): def decode(hrp, addr): """Decode a segwit address.""" + if addr is None: + return (None, None) hrpgot, data = bech32_decode(addr) if hrpgot != hrp: return (None, None) DIR diff --git a/electrum/synchronizer.py b/electrum/synchronizer.py t@@ -209,7 +209,7 @@ class Synchronizer(SynchronizerBase): async def _get_transaction(self, tx_hash, *, allow_server_not_finding_tx=False): self._requests_sent += 1 try: - result = await self.network.get_transaction(tx_hash) + raw_tx = await self.network.get_transaction(tx_hash) except UntrustedServerReturnedError as e: # most likely, "No such mempool or blockchain transaction" if allow_server_not_finding_tx: t@@ -219,7 +219,7 @@ class Synchronizer(SynchronizerBase): raise finally: self._requests_answered += 1 - tx = Transaction(result) + tx = Transaction(raw_tx) try: tx.deserialize() # see if raises except Exception as e: t@@ -233,7 +233,7 @@ class Synchronizer(SynchronizerBase): raise SynchronizerFailure(f"received tx does not match expected txid ({tx_hash} != {tx.txid()})") tx_height = self.requested_tx.pop(tx_hash) self.wallet.receive_tx_callback(tx_hash, tx, tx_height) - self.logger.info(f"received tx {tx_hash} height: {tx_height} bytes: {len(tx.raw)}") + self.logger.info(f"received tx {tx_hash} height: {tx_height} bytes: {len(raw_tx)}") # callbacks self.wallet.network.trigger_callback('new_transaction', self.wallet, tx) DIR diff --git a/electrum/tests/regtest/regtest.sh b/electrum/tests/regtest/regtest.sh t@@ -147,7 +147,7 @@ if [[ $1 == "breach" ]]; then echo "alice pays" $alice lnpay $request sleep 2 - ctx=$($alice get_channel_ctx $channel | jq '.hex' | tr -d '"') + ctx=$($alice get_channel_ctx $channel) request=$($bob add_lightning_request 0.01 -m "blah2") echo "alice pays again" $alice lnpay $request t@@ -224,7 +224,7 @@ if [[ $1 == "breach_with_unspent_htlc" ]]; then echo "SETTLE_DELAY did not work, $settled != 0" exit 1 fi - ctx=$($alice get_channel_ctx $channel | jq '.hex' | tr -d '"') + ctx=$($alice get_channel_ctx $channel) sleep 5 settled=$($alice list_channels | jq '.[] | .local_htlcs | .settles | length') if [[ "$settled" != "1" ]]; then t@@ -251,7 +251,7 @@ if [[ $1 == "breach_with_spent_htlc" ]]; then echo "alice pays bob" invoice=$($bob add_lightning_request 0.05 -m "test") $alice lnpay $invoice --timeout=1 || true - ctx=$($alice get_channel_ctx $channel | jq '.hex' | tr -d '"') + ctx=$($alice get_channel_ctx $channel) settled=$($alice list_channels | jq '.[] | .local_htlcs | .settles | length') if [[ "$settled" != "0" ]]; then echo "SETTLE_DELAY did not work, $settled != 0" DIR diff --git a/electrum/tests/test_bitcoin.py b/electrum/tests/test_bitcoin.py t@@ -12,7 +12,7 @@ from electrum.bitcoin import (public_key_to_p2pkh, address_from_private_key, from electrum.bip32 import (BIP32Node, convert_bip32_intpath_to_strpath, xpub_from_xprv, xpub_type, is_xprv, is_bip32_derivation, is_xpub, convert_bip32_path_to_list_of_uint32, - normalize_bip32_derivation) + normalize_bip32_derivation, is_all_public_derivation) from electrum.crypto import sha256d, SUPPORTED_PW_HASH_VERSIONS from electrum import ecc, crypto, constants from electrum.ecc import number_to_string, string_to_number t@@ -494,6 +494,14 @@ class Test_xprv_xpub(ElectrumTestCase): self.assertEqual("m/0/2/1'", normalize_bip32_derivation("m/0/2/-1/")) self.assertEqual("m/0/1'/1'/5'", normalize_bip32_derivation("m/0//-1/1'///5h")) + def test_is_all_public_derivation(self): + self.assertFalse(is_all_public_derivation("m/0/1'/1'")) + self.assertFalse(is_all_public_derivation("m/0/2/1'")) + self.assertFalse(is_all_public_derivation("m/0/1'/1'/5")) + self.assertTrue(is_all_public_derivation("m")) + self.assertTrue(is_all_public_derivation("m/0")) + self.assertTrue(is_all_public_derivation("m/75/22/3")) + def test_xtype_from_derivation(self): self.assertEqual('standard', xtype_from_derivation("m/44'")) self.assertEqual('standard', xtype_from_derivation("m/44'/")) DIR diff --git a/electrum/tests/test_commands.py b/electrum/tests/test_commands.py t@@ -159,3 +159,24 @@ class TestCommandsTestnet(TestCaseForTestnet): for xkey1, xtype1 in xprvs: for xkey2, xtype2 in xprvs: self.assertEqual(xkey2, cmds._run('convert_xkey', (xkey1, xtype2))) + + def test_serialize(self): + cmds = Commands(config=self.config) + jsontx = { + "inputs": [ + { + "prevout_hash": "9d221a69ca3997cbeaf5624d723e7dc5f829b1023078c177d37bdae95f37c539", + "prevout_n": 1, + "value": 1000000, + "privkey": "p2wpkh:cVDXzzQg6RoCTfiKpe8MBvmm5d5cJc6JLuFApsFDKwWa6F5TVHpD" + } + ], + "outputs": [ + { + "address": "tb1q4s8z6g5jqzllkgt8a4har94wl8tg0k9m8kv5zd", + "value": 990000 + } + ] + } + self.assertEqual("0200000000010139c5375fe9da7bd377c1783002b129f8c57d3e724d62f5eacb9739ca691a229d0100000000feffffff01301b0f0000000000160014ac0e2d229200bffb2167ed6fd196aef9d687d8bb02483045022100fa88a9e7930b2af269fd0a5cb7fbbc3d0a05606f3ac6ea8a40686ebf02fdd85802203dd19603b4ee8fdb81d40185572027686f70ea299c6a3e22bc2545e1396398b20121021f110909ded653828a254515b58498a6bafc96799fb0851554463ed44ca7d9da00000000", + cmds._run('serialize', (jsontx,))) DIR diff --git a/electrum/tests/test_lnchannel.py b/electrum/tests/test_lnchannel.py t@@ -170,7 +170,7 @@ class TestFee(ElectrumTestCase): """ def test_fee(self): alice_channel, bob_channel = create_test_channels(253, 10000000000, 5000000000) - self.assertIn(9999817, [x[2] for x in alice_channel.get_latest_commitment(LOCAL).outputs()]) + self.assertIn(9999817, [x.value for x in alice_channel.get_latest_commitment(LOCAL).outputs()]) class TestChannel(ElectrumTestCase): maxDiff = 999 DIR diff --git a/electrum/tests/test_lnutil.py b/electrum/tests/test_lnutil.py t@@ -9,7 +9,7 @@ from electrum.lnutil import (RevocationStore, get_per_commitment_secret_from_see get_compressed_pubkey_from_bech32, split_host_port, ConnStringFormatError, ScriptHtlc, extract_nodeid, calc_onchain_fees, UpdateAddHtlc) from electrum.util import bh2u, bfh -from electrum.transaction import Transaction +from electrum.transaction import Transaction, PartialTransaction from . import ElectrumTestCase t@@ -570,7 +570,7 @@ class TestLNUtil(ElectrumTestCase): localhtlcsig=bfh(local_sig), payment_preimage=htlc_payment_preimage if success else b'', # will put 00 on witness if timeout witness_script=htlc) - our_htlc_tx._inputs[0]['witness'] = bh2u(our_htlc_tx_witness) + our_htlc_tx._inputs[0].witness = our_htlc_tx_witness return str(our_htlc_tx) def test_commitment_tx_with_one_output(self): t@@ -669,7 +669,7 @@ class TestLNUtil(ElectrumTestCase): ref_commit_tx_str = '02000000000101bef67e4e2fb9ddeeb3461973cd4c62abb35050b1add772995b820b584a488489000000000038b02b8002c0c62d0000000000160014ccf1af2f2aabee14bb40fa3851ab2301de84311054a56a00000000002200204adb4e2f00643db396dd120d4e7dc17625f5f2c11a40d857accc862d6b7dd80e0400473044022051b75c73198c6deee1a875871c3961832909acd297c6b908d59e3319e5185a46022055c419379c5051a78d00dbbce11b5b664a0c22815fbcc6fcef6b1937c383693901483045022100f51d2e566a70ba740fc5d8c0f07b9b93d2ed741c3c0860c613173de7d39e7968022041376d520e9c0e1ad52248ddf4b22e12be8763007df977253ef45a4ca3bdb7c001475221023da092f6980e58d2c037173180e9a465476026ee50f96695963e8efe436f54eb21030e9f7b623d2ccc7c9bd44d66d5ce21ce504c0acf6385a132cec6d3c39fa711c152ae3e195220' self.assertEqual(str(our_commit_tx), ref_commit_tx_str) - def sign_and_insert_remote_sig(self, tx, remote_pubkey, remote_signature, pubkey, privkey): + def sign_and_insert_remote_sig(self, tx: PartialTransaction, remote_pubkey, remote_signature, pubkey, privkey): assert type(remote_pubkey) is bytes assert len(remote_pubkey) == 33 assert type(remote_signature) is str t@@ -678,10 +678,7 @@ class TestLNUtil(ElectrumTestCase): assert len(pubkey) == 33 assert len(privkey) == 33 tx.sign({bh2u(pubkey): (privkey[:-1], True)}) - pubkeys, _x_pubkeys = tx.get_sorted_pubkeys(tx.inputs()[0]) - index_of_pubkey = pubkeys.index(bh2u(remote_pubkey)) - tx._inputs[0]["signatures"][index_of_pubkey] = remote_signature + "01" - tx.raw = None + tx.add_signature_to_txin(txin_idx=0, signing_pubkey=remote_pubkey.hex(), sig=remote_signature + "01") def test_get_compressed_pubkey_from_bech32(self): self.assertEqual(b'\x03\x84\xef\x87\xd9d\xa2\xaaa7=\xff\xb8\xfe=t8[}>;\n\x13\xa8e\x8eo:\xf5Mi\xb5H', DIR diff --git a/electrum/tests/test_psbt.py b/electrum/tests/test_psbt.py t@@ -0,0 +1,269 @@ +from pprint import pprint +import unittest + +from electrum import constants +from electrum.transaction import (tx_from_any, PartialTransaction, BadHeaderMagic, UnexpectedEndOfStream, + SerializationError, PSBTInputConsistencyFailure) + +from . import ElectrumTestCase, TestCaseForTestnet + + +class TestValidPSBT(TestCaseForTestnet): + # test cases from BIP-0174 + + def test_valid_psbt_001(self): + # Case: PSBT with one P2PKH input. Outputs are empty parazyd.org:70 /git/electrum/commit/707b74d22b28d942c445754311736f158e505990.gph:7173: line too long