diff --git a/README.rst b/README.rst index eb5f90669eca..5e85c4f8f5cd 100644 --- a/README.rst +++ b/README.rst @@ -52,6 +52,9 @@ Run install (this should install dependencies):: python3 -m pip install .[fast] +In case of problems with btchip library:: + + pip install git+https://git@github.com/bitcoinvault/btchip-python.git@v0.1.31-btcv Compile the protobuf description file:: diff --git a/contrib/requirements/requirements-hw.txt b/contrib/requirements/requirements-hw.txt index dc1f233a3f55..a1bf4386376f 100644 --- a/contrib/requirements/requirements-hw.txt +++ b/contrib/requirements/requirements-hw.txt @@ -1,6 +1,6 @@ trezor[hidapi]>=0.11.5 safet>=0.1.5 keepkey>=6.0.3 -btchip-python>=0.1.26 +btchip-python@git://github.com/bitcoinvault/btchip-python.git@v0.1.31-btcv ckcc-protocol>=0.7.7 hidapi diff --git a/electrum/base_wizard.py b/electrum/base_wizard.py index ecbaace339aa..8f27c75b44a7 100755 --- a/electrum/base_wizard.py +++ b/electrum/base_wizard.py @@ -28,6 +28,8 @@ import sys from typing import List, TYPE_CHECKING, Tuple, NamedTuple, Any, Dict, Optional +from btchip.btchipException import BTChipException + from . import bitcoin from . import keystore from . import mnemonic @@ -41,7 +43,7 @@ from .simple_config import SimpleConfig from .storage import (WalletStorage, StorageEncryptionVersion, get_derivation_used_for_hw_device_encryption) -from .three_keys import short_mnemonic +from .three_keys.pubkey_type import PubkeyType from .util import UserCancelled, InvalidPassword from .wallet import (wallet_types) @@ -49,7 +51,10 @@ from .plugin import DeviceInfo # hardware device setup purpose -HWD_SETUP_NEW_WALLET, HWD_SETUP_DECRYPT_WALLET = range(0, 2) +HWD_SETUP_NEW_WALLET = 0 +HWD_SETUP_DECRYPT_WALLET = 1 +HWD_SETUP_NEW_BTCV_WALLET = 2 +HWD_SETUP_DECRYPT_BTCV_WALLET = 3 class ScriptTypeNotSupported(Exception): pass @@ -215,6 +220,17 @@ def process_choice(choice): action = 'three_keys_2fa' + sub_action else: raise Exception('Invalid multikey wallet type: ' + self.wallet_type) + elif choice[:11] == 'multikey_hw': + self.data['multikey_type'] = 'hw' + self.data['wallet_type'] += '-hw' + self.wallet_type += '-hw' + sub_action = choice[-7:] + if self.wallet_type == '2-key-hw': + action = 'two_keys_hw' + sub_action + elif self.wallet_type == '3-key-hw': + action = 'three_keys_hw' + sub_action + else: + raise Exception('Invalid multikey wallet type: ' + self.wallet_type) else: raise Exception('Invalid choice: ' + choice) self.run(action) @@ -228,7 +244,9 @@ def process_choice(choice): choices = [ ('multikey_2fa_create', _('Use Gold Wallet and create a new wallet')), ('multikey_2fa_import', _('Use Gold Wallet and import an existing wallet')), - ('multikey_standalone', _('Do not use Gold Wallet')), + ('multikey_hw_create', _('Use Ledger device and create a new wallet')), + ('multikey_hw_import', _('Use Ledger device and import an existing wallet')), + ('multikey_standalone', _('Do not use Gold Wallet nor Ledger device')), ] self.choice_dialog(title=title, message=message, choices=choices, run_next=process_choice) @@ -242,6 +260,13 @@ def two_keys_2fa_create(self): def two_keys_2fa_import(self): self.get_authenticator_pubkey(run_next=self.on_two_keys_import) + def two_keys_hw_create(self): + self.get_hw_password(run_next=self.on_two_keys_hw_create, title=_('Cancel password')) + + def two_keys_hw_import(self): + self.check_hw_password(run_next=self.on_two_keys_hw_import, title=_('Cancel password')) + + def on_two_keys_create(self, recovery_pubkey: str): self.data['recovery_pubkey'] = recovery_pubkey self.run('choose_keystore') @@ -250,6 +275,14 @@ def on_two_keys_import(self, recovery_pubkey: str): self.data['recovery_pubkey'] = recovery_pubkey self.run('restore_from_seed') + def on_two_keys_hw_create(self, recovery_password: str): + self.data['recovery_password'] = recovery_password + self.run('choose_hw_device', HWD_SETUP_NEW_BTCV_WALLET) + + def on_two_keys_hw_import(self, recovery_password: str): + self.data['recovery_password'] = recovery_password + self.run('choose_hw_device', HWD_SETUP_DECRYPT_BTCV_WALLET) + def three_keys_standalone(self): def collect_instant_pubkey(instant_pubkey: str): self.data['instant_pubkey'] = instant_pubkey @@ -271,6 +304,12 @@ def collect_instant_pubkey(instant_pubkey: str): self.get_authenticator_pubkey(run_next=collect_instant_pubkey) + def three_keys_hw_create(self): + self.get_hw_passwords(run_next=self.on_three_keys_hw_create, title=_('Instant and cancel passwords')) + + def three_keys_hw_import(self): + self.check_hw_passwords(run_next=self.on_three_keys_hw_import, title=_('Instant and cancel passwords')) + def on_three_keys_create(self, recovery_pubkey: str): self.data['recovery_pubkey'] = recovery_pubkey self.run('choose_keystore') @@ -279,8 +318,18 @@ def on_three_keys_import(self, recovery_pubkey: str): self.data['recovery_pubkey'] = recovery_pubkey self.run('restore_from_seed') + def on_three_keys_hw_create(self, *passwords): + self.data['instant_password'] = passwords[0] + self.data['recovery_password'] = passwords[1] + self.run('choose_hw_device', HWD_SETUP_NEW_BTCV_WALLET) + + def on_three_keys_hw_import(self, *passwords): + self.data['instant_password'] = passwords[0] + self.data['recovery_password'] = passwords[1] + self.run('choose_hw_device', HWD_SETUP_DECRYPT_BTCV_WALLET) + def choose_keystore(self): - assert self.wallet_type in ['standard', 'multisig', '2-key', '3-key'] + assert self.wallet_type in ['standard', 'multisig', '2-key', '3-key', '2-key-hw', '3-key-hw'] i = len(self.keystores) title = _('Add cosigner') + ' (%d of %d)' % (i + 1, self.n) if self.wallet_type == 'multisig' else _('Keystore') if self.wallet_type == 'multisig' and i > 0: @@ -304,7 +353,7 @@ def choose_keystore(self): advanced_choices = [ ('restore_from_key', _('Use a master key')), ] - if not self.is_kivy and self.wallet_type not in ['2-key', '3-key']: + if not self.is_kivy and self.wallet_type not in ['2-key', '3-key', '2-key-hw', '3-key-hw']: advanced_choices.append(('choose_hw_device', _('Use a hardware device'))) if self.wallet_type == 'multisig': self.choice_dialog(title=title, message=message, choices=base_choices + advanced_choices, run_next=self.run) @@ -489,6 +538,28 @@ def f(derivation, script_type): if hasattr(client, 'clear_session'): # FIXME not all hw wallet plugins have this client.clear_session() raise + elif purpose == HWD_SETUP_NEW_BTCV_WALLET: + if 'recovery_password' not in self.data: + raise Exception('Recovery password not set') + + self.plugin.set_recovery_password(device_info.device.id_, self.data['recovery_password'], self) + if 'instant_password' in self.data: + self.plugin.set_instant_password(device_info.device.id_, self.data['instant_password'], self) + else: + self.plugin.set_instant_password(device_info.device.id_, str(0x00), self) + + self.run('on_hw_derivation', name, device_info, bip44_derivation(0), 'p2wsh-p2sh') + elif purpose == HWD_SETUP_DECRYPT_BTCV_WALLET: + if 'recovery_password' not in self.data: + raise Exception('Invalid recovery password') + if 'instant_password' in self.data: + self.run('on_hw_derivation', name, device_info, bip44_derivation(0), 'p2wsh-p2sh', + btcv_instant_password_check=self.data['instant_password'], + btcv_recovery_password_check=self.data['recovery_password']) + else: + self.run('on_hw_derivation', name, device_info, bip44_derivation(0), 'p2wsh-p2sh', + btcv_recovery_password_check=self.data['recovery_password'], + btcv_instant_password_check=str(0x00)) else: raise Exception('unknown purpose: %s' % purpose) @@ -526,10 +597,11 @@ def derivation_and_script_type_dialog(self, f): self.show_error(e) # let the user choose again - def on_hw_derivation(self, name, device_info, derivation, xtype): + def on_hw_derivation(self, name, device_info, derivation, xtype, xpub_keystore=False, pubkey_type=PubkeyType.PUBKEY_ALERT, + btcv_instant_password_check=None, btcv_recovery_password_check=None): from .keystore import hardware_keystore try: - xpub = self.plugin.get_xpub(device_info.device.id_, derivation, xtype, self) + xpub = self.plugin.get_xpub(device_info.device.id_, derivation, xtype, self, pubkey_type) root_xpub = self.plugin.get_xpub(device_info.device.id_, 'm', 'standard', self) except ScriptTypeNotSupported: raise # this is handled in derivation_dialog @@ -538,16 +610,24 @@ def on_hw_derivation(self, name, device_info, derivation, xtype): 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, - } - k = hardware_keystore(d) - self.on_keystore(k) + if xpub_keystore: + k = keystore.from_master_key(xpub) + else: + d = { + 'type': 'hardware', + 'hw_type': name, + 'derivation': derivation, + 'root_fingerprint': xfp, + 'xpub': xpub, + 'label': device_info.label, + } + k = hardware_keystore(d) + from electrum.plugins.ledger.ledger import Ledger_KeyStore + if isinstance(k, Ledger_KeyStore) \ + and not k.are_3keys_ledger_passwords_correct(self, btcv_instant_password_check, btcv_recovery_password_check): + # user already notified about invalid password(s) + return self.terminate() + self.on_keystore(k, name, device_info) def passphrase_dialog(self, run_next, is_restoring=False): title = _('Seed extension') @@ -609,7 +689,7 @@ def on_bip43(self, seed, passphrase, derivation, script_type): k = keystore.from_bip39_seed(seed, passphrase, derivation, xtype=script_type) self.on_keystore(k) - def on_keystore(self, k): + def on_keystore(self, k, name=None, device_info=None): has_xpub = isinstance(k, keystore.Xpub) if has_xpub: t1 = xpub_type(k.xpub) @@ -620,6 +700,28 @@ def on_keystore(self, k): return self.keystores.append(k) self.run('create_wallet') + elif self.wallet_type in ['2-key-hw', '3-key-hw']: + if has_xpub and t1 != 'p2wsh-p2sh': + self.show_error(_('Wrong key type') + ' %s' % t1) + self.run('choose_keystore') + return + self.keystores.append(k) + + keystores_needed = 2 if self.wallet_type == '2-key-hw' else 3 + if len(self.keystores) < keystores_needed: + if not name or not device_info: + self.show_error(_('Missing device info')) + self.run('choose_keystore') + return + script_type = 'p2wsh-p2sh' + derivation = bip44_derivation(0) + if keystores_needed == 3: + pubkey_type = len(self.keystores) + elif keystores_needed == 2: + pubkey_type = 2 * len(self.keystores) + self.run('on_hw_derivation', name, device_info, derivation, script_type, True, pubkey_type) + else: + self.run('create_wallet') elif self.wallet_type == 'multisig': assert has_xpub if t1 not in ['standard', 'p2wsh', 'p2wsh-p2sh']: @@ -694,7 +796,7 @@ def on_password(self, password, *, encrypt_storage: bool, self.data['seed_type'] = self.seed_type keys = self.keystores[0].dump() self.data['keystore'] = keys - elif self.wallet_type == 'multisig': + elif self.wallet_type in ['multisig', '2-key-hw', '3-key-hw']: for i, k in enumerate(self.keystores): self.data['x%d/' % (i + 1)] = k.dump() elif self.wallet_type == 'imported': diff --git a/electrum/gui/qt/__init__.py b/electrum/gui/qt/__init__.py index 13e691be05f9..e66279e5d92f 100644 --- a/electrum/gui/qt/__init__.py +++ b/electrum/gui/qt/__init__.py @@ -31,7 +31,7 @@ from typing import Optional, TYPE_CHECKING from .terms_and_conditions_mixin import TermsNotAccepted -from .three_keys_windows import ElectrumARWindow, ElectrumAIRWindow +from .three_keys_windows import ElectrumARWindow, ElectrumAIRWindow, ElectrumARHWWindow, ElectrumAIRHWWindow try: import PyQt5 @@ -211,6 +211,10 @@ def _create_window_for_wallet(self, wallet): w = ElectrumARWindow(self, wallet) elif wallet_type == '3-key': w = ElectrumAIRWindow(self, wallet) + elif wallet_type == '2-key-hw': + w = ElectrumARHWWindow(self, wallet) + elif wallet_type == '3-key-hw': + w = ElectrumAIRHWWindow(self, wallet) else: w = ElectrumWindow(self, wallet) self.windows.append(w) diff --git a/electrum/gui/qt/installwizard.py b/electrum/gui/qt/installwizard.py index 2aea4408a5b2..ac1e864c7c70 100755 --- a/electrum/gui/qt/installwizard.py +++ b/electrum/gui/qt/installwizard.py @@ -21,7 +21,7 @@ from .password_dialog import PasswordLayout, PasswordLayoutForHW, PW_NEW from .seed_dialog import SeedLayout, KeysLayout from .terms_and_conditions_mixin import TermsAndConditionsMixin, PushedButton -from .three_keys_dialogs import InsertPubKeyDialog, Qr2FaDialog +from .three_keys_dialogs import InsertPubKeyDialog, InsertHWPasswordDialog, Qr2FaDialog, CheckHWPasswordDialog from .util import (MessageBoxMixin, Buttons, icon_path, ChoicesLayout, WWLabel, InfoButton, char_width_in_lineedit, get_default_language) @@ -651,6 +651,84 @@ def get_authenticator_pubkey(self, run_next, disallowed_key=None): self.exec_layout(layout, _('Gold Wallet authenticator public key'), next_enabled=False) return layout.get_compressed_pubkey() + @wizard_dialog + def get_hw_password(self, run_next, title): + label = QLabel() + message = _('Please provide password related to your Ledger device') + label.setText(message) + label.setOpenExternalLinks(True) + label.setTextInteractionFlags(Qt.TextBrowserInteraction) + label.setWordWrap(True) + + layout = InsertHWPasswordDialog(self, message_label=label) + self.exec_layout(layout, title, next_enabled=False) + return layout.get_password() + + @wizard_dialog + def get_hw_passwords(self, run_next, title): + label1 = QLabel() + message1 = _('Please provide instant password related to your Ledger device') + label1.setText(message1) + label1.setOpenExternalLinks(True) + label1.setTextInteractionFlags(Qt.TextBrowserInteraction) + label1.setWordWrap(True) + + layout1 = InsertHWPasswordDialog(self, message_label=label1) + self.exec_layout(layout1, title, next_enabled=False) + instant_password = layout1.get_password() + + label2 = QLabel() + message2 = _('Please provide cancel password related to your Ledger device') + label2.setText(message2) + label2.setOpenExternalLinks(True) + label2.setTextInteractionFlags(Qt.TextBrowserInteraction) + label2.setWordWrap(True) + + layout2 = InsertHWPasswordDialog(self, message_label=label2) + self.exec_layout(layout2, title, next_enabled=False) + recovery_password = layout2.get_password() + + return (instant_password, recovery_password) + + @wizard_dialog + def check_hw_password(self, run_next, title): + label = QLabel() + message = _('Please provide password related to your Ledger device') + label.setText(message) + label.setOpenExternalLinks(True) + label.setTextInteractionFlags(Qt.TextBrowserInteraction) + label.setWordWrap(True) + + layout = CheckHWPasswordDialog(self, message_label=label) + self.exec_layout(layout, title, next_enabled=False) + return layout.get_password() + + @wizard_dialog + def check_hw_passwords(self, run_next, title): + label1 = QLabel() + message1 = _('Please provide instant password related to your Ledger device') + label1.setText(message1) + label1.setOpenExternalLinks(True) + label1.setTextInteractionFlags(Qt.TextBrowserInteraction) + label1.setWordWrap(True) + + layout1 = CheckHWPasswordDialog(self, message_label=label1) + self.exec_layout(layout1, title, next_enabled=False) + instant_password = layout1.get_password() + + label2 = QLabel() + message2 = _('Please provide cancel password related to your Ledger device') + label2.setText(message2) + label2.setOpenExternalLinks(True) + label2.setTextInteractionFlags(Qt.TextBrowserInteraction) + label2.setWordWrap(True) + + layout2 = CheckHWPasswordDialog(self, message_label=label2) + self.exec_layout(layout2, title, next_enabled=False) + recovery_password = layout2.get_password() + + return (instant_password, recovery_password) + @wizard_dialog def display_2fa_pairing_qr(self, run_next, entropy: bytes): title_label = QLabel() diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index 914aad3fee41..32ded03eb3d1 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -72,7 +72,7 @@ from electrum.version import ELECTRUM_VERSION from electrum.wallet import (Multisig_Wallet, CannotBumpFee, Abstract_Wallet, sweep_preparations, InternalAddressCorruption, - ThreeKeysWallet) + ThreeKeysWallet, ThreeKeysHWWallet) from .amountedit import AmountEdit, BTCAmountEdit, MyLineEdit, FeerateEdit from .channels_list import ChannelsList from .confirm_tx_dialog import ConfirmTxDialog @@ -1541,6 +1541,9 @@ def on_failure(exc_info): elif external_keypairs: # can sign directly task = partial(tx.sign, external_keypairs) + elif isinstance(self.wallet, ThreeKeysHWWallet) and self.wallet.is_instant_mode(): + instant_password = self._get_instant_password() + task = partial(self.wallet.sign_instant_transaction, tx, password, None, instant_password) else: task = partial(self.wallet.sign_transaction, tx, password) msg = _('Signing transaction...') diff --git a/electrum/gui/qt/recovery_list.py b/electrum/gui/qt/recovery_list.py index e6d588328903..00aa8538d58f 100755 --- a/electrum/gui/qt/recovery_list.py +++ b/electrum/gui/qt/recovery_list.py @@ -7,16 +7,17 @@ from PyQt5.QtCore import Qt from PyQt5.QtGui import QStandardItemModel, QStandardItem, QMouseEvent, QValidator, QKeySequence -from PyQt5.QtWidgets import QPushButton, QLabel, QWidget, QComboBox,\ - QTreeView, QHeaderView, QStyledItemDelegate,\ - QVBoxLayout, QGridLayout,\ - QCompleter, QShortcut +from PyQt5.QtWidgets import QPushButton, QLabel, QWidget, QComboBox, \ + QTreeView, QHeaderView, QStyledItemDelegate, \ + QVBoxLayout, QGridLayout, \ + QCompleter, QShortcut, QLineEdit from electrum.i18n import _ from electrum.logging import get_logger -from electrum.wallet import Abstract_Wallet +from electrum.wallet import Abstract_Wallet, TwoKeysHWWallet, ThreeKeysHWWallet from electrum.util import get_request_status, PR_TYPE_ONCHAIN, PR_TYPE_LN from electrum import bitcoin +from .three_keys_dialogs import MAX_3KEYS_PASSWD_LEN from .util import read_QIcon, pr_icons, WaitingDialog, filter_non_printable, ColorScheme from .confirm_tx_dialog import ConfirmTxDialog @@ -82,6 +83,10 @@ def clear_cache(self): def onItemChecked(self): self.selected() self.tab.update_recovery_button() + if (isinstance(self.tab, RecoveryTabAIR) and self.tab.is_hw): + self.tab.on_3k_password_line_edit() + elif self.tab.is_hw: + self.tab.on_recovery_password_line_edit() def onSelectAll(self): model = self.model() @@ -183,6 +188,7 @@ def __init__(self, parent, wallet: Abstract_Wallet, config): self.config = config self.wallet = wallet self.is_2fa = self.wallet.storage.get('multikey_type', '') == '2fa' + self.is_hw = self.wallet.storage.get('multikey_type', '') == 'hw' QWidget.__init__(self) self.invoice_list = RecoveryView(self.electrum_main_window, self) @@ -272,12 +278,23 @@ def validate_words(words): validity_fn(is_valid and len(seed) == 12) self.update_recovery_button() + def on_recovery_password_line_edit(self): + password = self._get_recovery_password() + is_valid = (len(password) > 0) or len(self.invoice_list.selected()) == 0 + if is_valid: + self.recovery_password_line.setStyleSheet(ColorScheme.DEFAULT.as_stylesheet(True)) + else: + self.recovery_password_line.setStyleSheet(ColorScheme.RED.as_stylesheet(True)) + def get_recovery_seed(self): text = self.recovery_privkey_line.text() words = text.split() del text return words + def _get_recovery_password(self): + return self.recovery_password_line.text() + def _get_recovery_keypair(self): stored_recovery_pubkey = self.wallet.storage.get('recovery_pubkey') seed = self.get_recovery_seed() @@ -341,7 +358,12 @@ def on_failure(exc_info): on_success = run_hook('tc_sign_wrapper', self.wallet, tx, on_success, on_failure) or on_success if self.wallet.is_recovery_mode(): - task = partial(self.wallet.sign_recovery_transaction, tx, password, external_keypairs) + if not self.is_hw: + task = partial(self.wallet.sign_recovery_transaction, tx, password, external_keypairs) + elif isinstance(self.wallet, TwoKeysHWWallet): + task = partial(self.wallet.sign_recovery_transaction, tx, password, external_keypairs, self._get_recovery_password()) + elif isinstance(self.wallet, ThreeKeysHWWallet): + task = partial(self.wallet.sign_recovery_transaction, tx, password, external_keypairs, self._get_recovery_password(), self._get_instant_password()) else: task = partial(self.wallet.sign_transaction, tx, password, external_keypairs) msg = _('Signing transaction...') @@ -352,7 +374,7 @@ def recover_action(self): atxs = self.invoice_list.selected() address = self.recovery_address_line.currentText() recovery_keypair = None - if not self.is_2fa: + if not self.is_2fa and not self.is_hw: recovery_keypair = self._get_recovery_keypair() if not is_address_valid(address): @@ -371,9 +393,12 @@ def recover_action(self): recovery_keypairs=recovery_keypair, ) - if not self.is_2fa: + if not self.is_2fa and not self.is_hw: self.recovery_privkey_line.setText('') + if self.is_hw: + self._clean_password_lines() + def _create_privkey_line(self, on_edit): class CompleterDelegate(QStyledItemDelegate): def initStyleOption(self, option, index): @@ -399,6 +424,8 @@ def clear_cache(self): def update_view(self): self.invoice_list.update_data() + def _clean_password_lines(self): + pass class RecoveryTabAR(RecoveryTab): @@ -416,11 +443,18 @@ def __init__(self, parent, wallet: Abstract_Wallet, config): grid_layout.addWidget(self.recovery_address_line, 0, 1) # Row 2 - if not self.is_2fa: + if not self.is_2fa and not self.is_hw: grid_layout.addWidget(QLabel(_('Cancel seedphrase')), 1, 0) # complete line edit with suggestions self.recovery_privkey_line = self._create_privkey_line(self.on_recovery_seed_line_edit) grid_layout.addWidget(self.recovery_privkey_line, 1, 1) + if self.is_hw: + grid_layout.addWidget(QLabel(_('Cancel password')), 1, 0) + self.recovery_password_line = QLineEdit() + self.recovery_password_line.setEchoMode(QLineEdit.Password) + self.recovery_password_line.setMaxLength(MAX_3KEYS_PASSWD_LEN) + self.recovery_password_line.textChanged.connect(self.on_recovery_password_line_edit) + grid_layout.addWidget(self.recovery_password_line, 1, 1) # Row 3 button = self.recover_button @@ -434,7 +468,7 @@ def __init__(self, parent, wallet: Abstract_Wallet, config): self.setLayout(self.main_layout) def update_recovery_button(self): - if self.is_2fa: + if self.is_2fa or self.is_hw: enabled = self.is_address_valid and len(self.invoice_list.selected()) else: enabled = self.is_address_valid \ @@ -447,6 +481,9 @@ def update_recovery_button(self): else: self.recover_button.setText('Send cancel transaction') + def _clean_password_lines(self): + self.recovery_password_line.setText('') + class RecoveryTabAIR(RecoveryTab): @@ -467,19 +504,34 @@ def __init__(self, parent, wallet: Abstract_Wallet, config): grid_layout.addWidget(self.recovery_address_line, 0, 1) # Row 2 - if not self.is_2fa: + if not self.is_2fa and not self.is_hw: grid_layout.addWidget(QLabel(_('Secure Fast seedphrase')), 1, 0) # complete line edit with suggestions self.instant_privkey_line = self._create_privkey_line(self.on_instant_seed_line_edit) self.instant_privkey_line.setContextMenuPolicy(Qt.PreventContextMenu) grid_layout.addWidget(self.instant_privkey_line, 1, 1) + elif self.is_hw: + grid_layout.addWidget(QLabel(_('Instant password')), 1, 0) + self.instant_password_line = QLineEdit() + self.instant_password_line.setEchoMode(QLineEdit.Password) + self.instant_password_line.setMaxLength(MAX_3KEYS_PASSWD_LEN) + self.instant_password_line.textChanged.connect(self.on_3k_password_line_edit) + grid_layout.addWidget(self.instant_password_line, 1, 1) # Row 3 - grid_layout.addWidget(QLabel(_('Cancel seedphrase')), 2, 0) # complete line edit with suggestions - self.recovery_privkey_line = self._create_privkey_line(self.on_recovery_seed_line_edit) - self.recovery_privkey_line.setContextMenuPolicy(Qt.PreventContextMenu) - grid_layout.addWidget(self.recovery_privkey_line, 2, 1) + if self.is_hw: + grid_layout.addWidget(QLabel(_('Cancel password')), 2, 0) + self.recovery_password_line = QLineEdit() + self.recovery_password_line.setEchoMode(QLineEdit.Password) + self.recovery_password_line.setMaxLength(MAX_3KEYS_PASSWD_LEN) + self.recovery_password_line.textChanged.connect(self.on_3k_password_line_edit) + grid_layout.addWidget(self.recovery_password_line, 2, 1) + else: + grid_layout.addWidget(QLabel(_('Cancel seedphrase')), 2, 0) + self.recovery_privkey_line = self._create_privkey_line(self.on_recovery_seed_line_edit) + self.recovery_privkey_line.setContextMenuPolicy(Qt.PreventContextMenu) + grid_layout.addWidget(self.recovery_privkey_line, 2, 1) # Row 4 button = self.recover_button @@ -508,6 +560,10 @@ def _get_instant_keypair(self): return {pubkey: (privkey, True)} def recover_action(self): + if self.is_hw: + RecoveryTab.recover_action(self) + return + #TODO: design pattern for this(!) try: address = self.recovery_address_line.currentText() instant_keypair = None @@ -546,6 +602,11 @@ def update_recovery_button(self): enabled = self.is_address_valid \ and self.is_recovery_seed_valid \ and len(self.invoice_list.selected()) + elif self.is_hw: + enabled = self.is_address_valid \ + and len(self.invoice_list.selected()) \ + and len(self.instant_password_line.text()) \ + and len(self.recovery_password_line.text()) else: enabled = self.is_address_valid \ and self.is_instant_seed_valid \ @@ -562,3 +623,24 @@ def on_instant_seed_line_edit(self): return self.on_seed_line_edit(self.instant_privkey_line, self.get_instant_seed(), self.set_instant_seed_validity) + + def _get_instant_password(self): + return self.instant_password_line.text() + + def set_password_line_style(self, password, password_line): + is_valid = (len(password) > 0) or len(self.invoice_list.selected()) == 0 + if is_valid: + password_line.setStyleSheet(ColorScheme.DEFAULT.as_stylesheet(True)) + else: + password_line.setStyleSheet(ColorScheme.RED.as_stylesheet(True)) + + def on_3k_password_line_edit(self): + password = self._get_recovery_password() + self.set_password_line_style(password, self.recovery_password_line) + password = self._get_instant_password() + self.set_password_line_style(password, self.instant_password_line) + self.update_recovery_button() + + def _clean_password_lines(self): + self.recovery_password_line.setText('') + self.instant_password_line.setText('') diff --git a/electrum/gui/qt/three_keys_dialogs.py b/electrum/gui/qt/three_keys_dialogs.py index 3b759dcf986f..c32d8d2a5799 100644 --- a/electrum/gui/qt/three_keys_dialogs.py +++ b/electrum/gui/qt/three_keys_dialogs.py @@ -23,6 +23,7 @@ class ValidationState(IntEnum): INTERMEDIATE = 2 CROPPED = 3 +MAX_3KEYS_PASSWD_LEN = 32 class PubKeyValidator: COMPRESSED_PREFIXES = ('02', '03') @@ -138,6 +139,66 @@ def get_compressed_pubkey(self): return pubkey.get_public_key_hex(compressed=True) +class InsertHWPasswordDialog(QVBoxLayout): + def __init__(self, parent, message_label): + super().__init__() + self.parent = parent + label1 = message_label + self.edit1 = QLineEdit() + self.edit1.setEchoMode(QLineEdit.Password) + self.edit1.setMaxLength(MAX_3KEYS_PASSWD_LEN) + label2 = QLabel("Repeat password: ") + self.edit2 = QLineEdit() + self.edit2.setEchoMode(QLineEdit.Password) + self.edit2.setMaxLength(MAX_3KEYS_PASSWD_LEN) + self.error_label = ErrorLabel() + + self.edit1.textChanged.connect(self._on_change) + self.edit2.textChanged.connect(self._on_change) + self.addWidget(label1) + self.addWidget(self.edit1) + self.addWidget(label2) + self.addWidget(self.edit2) + self.addWidget(self.error_label) + + def _on_change(self): + if(self._get_str(self.edit1) != self._get_str(self.edit2)): + self.error_label.setText("Password confirmation does not match") + self.parent.next_button.setEnabled(False) + else: + self.error_label.setText("") + self.parent.next_button.setEnabled(self._get_str(self.edit1).strip() != '') + + def _get_str(self, line) -> str: + return line.text().replace('\n', '') + + def get_password(self): + return self._get_str(self.edit1) + + +class CheckHWPasswordDialog(QVBoxLayout): + def __init__(self, parent, message_label): + super().__init__() + self.parent = parent + label = message_label + self.edit = QLineEdit() + self.edit.setEchoMode(QLineEdit.Password) + self.edit.setMaxLength(MAX_3KEYS_PASSWD_LEN) + + self.edit.textChanged.connect(self._on_change) + self.addWidget(label) + self.addWidget(self.edit) + + def _on_change(self): + self.parent.next_button.setEnabled(self._get_str(self.edit).strip() != '') + + def _get_str(self, line) -> str: + return line.text().replace('\n', '') + + def get_password(self): + return self._get_str(self.edit) + + class Qr2FaDialog(QVBoxLayout): def __init__(self, parent, title_label: str, pin_label: str, qr_data: dict): diff --git a/electrum/gui/qt/three_keys_windows.py b/electrum/gui/qt/three_keys_windows.py index c3a342e6c162..d0558d80a2cb 100644 --- a/electrum/gui/qt/three_keys_windows.py +++ b/electrum/gui/qt/three_keys_windows.py @@ -3,7 +3,7 @@ from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QVBoxLayout, QLabel, QWidget, QHBoxLayout, \ QGridLayout, QCompleter, QComboBox, \ - QStyledItemDelegate + QStyledItemDelegate, QLineEdit from electrum.i18n import _ from .amountedit import BTCAmountEdit, MyLineEdit, AmountEdit @@ -11,7 +11,7 @@ from .confirm_tx_dialog import ConfirmTxDialog from .main_window import ElectrumWindow from .recovery_list import RecoveryTabAR, RecoveryTabAIR -from .three_keys_dialogs import PreviewPsbtTxDialog +from .three_keys_dialogs import PreviewPsbtTxDialog, MAX_3KEYS_PASSWD_LEN from .transaction_dialog import PreviewTxDialog from .util import read_QIcon, HelpLabel, EnterButton, ColorScheme from ...mnemonic import load_wordlist @@ -26,6 +26,7 @@ class ElectrumMultikeyWalletWindow(ElectrumWindow): def __init__(self, gui_object: 'ElectrumGui', wallet: 'Abstract_Wallet'): self.is_2fa = wallet.storage.get('multikey_type', '') == '2fa' + self.is_hw = wallet.storage.get('multikey_type', '') == 'hw' super().__init__(gui_object=gui_object, wallet=wallet) self.recovery_tab = self.create_recovery_tab(wallet, self.config) self.tabs.addTab(self.recovery_tab, read_QIcon('recovery.png'), _('Cancel')) @@ -35,7 +36,7 @@ def __init__(self, gui_object: 'ElectrumGui', wallet: 'Abstract_Wallet'): self.READY_TO_UPDATE = True def timer_actions(self): - # synchronizing the timer thread with end of the __init__ call + # synchronizing the timer thread with end of the __init__ call if self.READY_TO_UPDATE: super().timer_actions() @@ -51,8 +52,9 @@ def show_recovery_tab(self): def update_tabs(self, wallet=None): super().update_tabs(wallet=wallet) - self.recovery_tab.update_view() - self.recovery_tab.update_recovery_button() + if hasattr(self, 'recovery_tab'): + self.recovery_tab.update_view() + self.recovery_tab.update_recovery_button() class ElectrumARWindow(ElectrumMultikeyWalletWindow): @@ -305,7 +307,7 @@ def create_send_tab(self): grid.addWidget(self.max_button, 3, 3) def on_tx_type(index): - if not self.is_2fa: + if not self.is_2fa and not self.is_hw: if self.tx_type_combo.currentIndex() == self.TX_TYPES.Secure: self.instant_privkey_line.setEnabled(False) self.instant_privkey_line.clear() @@ -313,6 +315,21 @@ def on_tx_type(index): elif self.tx_type_combo.currentIndex() == self.TX_TYPES.Secure_Fast: self.instant_privkey_line.setEnabled(True) self.label_transaction_limitations.hide() + elif self.is_hw: + if self.tx_type_combo.currentIndex() == self.TX_TYPES.Secure: + self.instant_password_line.setEnabled(False) + self.instant_password_line.clear() + self.label_transaction_limitations.show() + elif self.tx_type_combo.currentIndex() == self.TX_TYPES.Secure_Fast: + self.instant_password_line.setEnabled(True) + self.label_transaction_limitations.hide() + password = self.instant_password_line.text() + is_valid = (len(password) > 0) or not self.instant_password_line.isEnabled() + if is_valid: + self.instant_password_line.setStyleSheet(ColorScheme.DEFAULT.as_stylesheet(True)) + else: + self.instant_password_line.setStyleSheet(ColorScheme.RED.as_stylesheet(True)) + self.instant_password_line.textChanged.connect(on_tx_type) else: if self.tx_type_combo.currentIndex() == self.TX_TYPES.Secure: description_label.setEnabled(True) @@ -339,7 +356,7 @@ def on_tx_type(index): grid.addWidget(tx_type_label, 4, 0) grid.addWidget(self.tx_type_combo, 4, 1, 1, -1) - if not self.is_2fa: + if not self.is_2fa and not self.is_hw: instant_privkey_label = HelpLabel(_('Secure Fast Tx seed'), msg) self.instant_privkey_line = CompletionTextEdit() self.instant_privkey_line.setTabChangesFocus(False) @@ -362,6 +379,16 @@ def initStyleOption(self, option, index): grid.addWidget(instant_privkey_label, 5, 0) grid.addWidget(self.instant_privkey_line, 5, 1, 1, -1) + elif self.is_hw: + instant_password_label = HelpLabel(_('Secure Fast Tx password'), msg) + self.instant_password_line = QLineEdit() + self.instant_password_line.setEchoMode(QLineEdit.Password) + self.instant_password_line.setMaxLength(MAX_3KEYS_PASSWD_LEN) + self.instant_password_line.setEnabled(False) + grid.addWidget(instant_password_label, 5, 0) + grid.addWidget(self.instant_password_line, 5, 1, 1, -1) + + self.save_button = EnterButton(_("Save"), self.do_save_invoice) self.send_button = EnterButton(_("Pay"), self.do_pay) self.clear_button = EnterButton(_("Clear"), self.do_clear) @@ -461,7 +488,7 @@ def do_pay_invoice(self, invoice, external_keypairs=None): self.wallet.set_alert() if invoice['txtype'] == TxType.INSTANT.name: try: - if not self.is_2fa and external_keypairs == None: + if not self.is_2fa and not self.is_hw and external_keypairs == None: external_keypairs = self.get_instant_keypair() self.wallet.set_instant() except Exception as e: @@ -495,7 +522,7 @@ def do_clear(self): for e in [self.payto_e, self.message_e, self.amount_e]: e.setText('') e.setFrozen(False) - if not self.is_2fa: + if not self.is_2fa and not self.is_hw: self.instant_privkey_line.clear() self.tx_type_combo.setCurrentIndex(self.TX_TYPES.Secure) self.update_status() @@ -543,3 +570,90 @@ def preview_tx_dialog(self, make_tx, outputs, external_keypairs=None, invoice=No else PreviewTxDialog d = dialog_class(make_tx, outputs, external_keypairs, window=self, invoice=invoice) d.show() + + +class ElectrumARHWWindow(ElectrumARWindow): + + def __init__(self, gui_object: 'ElectrumGui', wallet: 'Abstract_Wallet'): + super().__init__(gui_object=gui_object, wallet=wallet) + + def do_pay(self): + invoice = self.read_invoice() + if not invoice: + return + invoice['txtype'] = TxType.ALERT_PENDING.name + self.wallet.save_invoice(invoice) + self.invoice_list.update() + self.do_clear() + self.do_pay_invoice(invoice) + + def pay_onchain_dialog(self, inputs, outputs, invoice=None, external_keypairs=None): + # trustedcoin requires this + if run_hook('abort_send', self): + return + is_sweep = False # Was bool(external_keypairs). Should be good to keep it false, since we do not use trustedcoin + + if invoice['txtype'] == TxType.ALERT_PENDING.name: + self.wallet.set_alert() + elif invoice['txtype'] == TxType.INSTANT.name: + self.wallet.set_recovery() + elif invoice['txtype'] == TxType.RECOVERY.name: + self.wallet.set_instant() + + make_tx = lambda fee_est: self.wallet.make_unsigned_transaction( + coins=inputs, + outputs=outputs, + fee=fee_est, + is_sweep=is_sweep) + if self.config.get('advanced_preview'): + self.preview_tx_dialog(make_tx, outputs, external_keypairs=external_keypairs, invoice=invoice) + return + + output_values = [x.value for x in outputs] + output_value = '!' if '!' in output_values else sum(output_values) + d = ConfirmTxDialog(self, make_tx, output_value, is_sweep) + d.update_tx() + if d.not_enough_funds: + self.show_message(_('Not Enough Funds')) + return + cancelled, is_send, password, tx = d.run() + if cancelled: + return + + if is_send: + def sign_done(success): + if success: + self.wallet.multisig_script_generator.set_alert() + self.broadcast_or_show(tx, invoice=invoice) + self.sign_tx_with_password(tx, sign_done, password, external_keypairs) + else: + self.preview_tx_dialog(make_tx, outputs, external_keypairs=external_keypairs, invoice=invoice) + + +class ElectrumAIRHWWindow(ElectrumAIRWindow): + def __init__(self, gui_object: 'ElectrumGui', wallet: 'Abstract_Wallet'): + super().__init__(gui_object=gui_object, wallet=wallet) + + def do_pay(self): + invoice = self.read_invoice() + if not invoice: + return + + try: + if self.tx_type_combo.currentIndex() == self.TX_TYPES.Secure_Fast: + invoice['txtype'] = TxType.INSTANT.name + self.instant_password_line.text() + self.wallet.set_instant() + else: + invoice['txtype'] = TxType.ALERT_PENDING.name + self.wallet.set_alert() + except Exception as e: + self.on_error([0, str(e)]) + return + + self.wallet.save_invoice(invoice) + self.invoice_list.update() + self.do_pay_invoice(invoice) + + def _get_instant_password(self): + return self.instant_password_line.text() diff --git a/electrum/keystore.py b/electrum/keystore.py index 859e74989f8d..2989c944d674 100644 --- a/electrum/keystore.py +++ b/electrum/keystore.py @@ -745,6 +745,9 @@ def opportunistically_fill_in_missing_info_from_device(self, client: 'HardwareCl self.label = client.label() self.is_requesting_to_be_rewritten_to_wallet_file = True + def set_btcv_password_use(self, tx_type, password=None): + raise NotImplementedError() + def bip39_normalize_passphrase(passphrase): return normalize('NFKD', passphrase or '') diff --git a/electrum/plugins/hw_wallet/plugin.py b/electrum/plugins/hw_wallet/plugin.py index ee68dd7945c7..f61c0c660ed4 100644 --- a/electrum/plugins/hw_wallet/plugin.py +++ b/electrum/plugins/hw_wallet/plugin.py @@ -32,6 +32,7 @@ from electrum.util import bfh, versiontuple, UserFacingException from electrum.transaction import TxOutput, Transaction, PartialTransaction, PartialTxInput, PartialTxOutput from electrum.bip32 import BIP32Node +from electrum.three_keys.pubkey_type import PubkeyType if TYPE_CHECKING: from electrum.wallet import Abstract_Wallet @@ -167,6 +168,12 @@ def can_recognize_device(self, device: Device) -> bool: """ return device.product_key in self.DEVICE_IDS + def set_instant_password(self, device_id, password: str, wizard) -> bool: + raise NotImplementedError() + + def set_recovery_password(self, device_id, password: str, wizard) -> bool: + raise NotImplementedError() + class HardwareClientBase: @@ -195,7 +202,7 @@ def label(self) -> str: def has_usable_connection_with_device(self) -> bool: raise NotImplementedError() - def get_xpub(self, bip32_path: str, xtype) -> str: + def get_xpub(self, bip32_path: str, xtype, pubkey_type=PubkeyType.PUBKEY_ALERT) -> str: raise NotImplementedError() diff --git a/electrum/plugins/ledger/ledger.py b/electrum/plugins/ledger/ledger.py index 9b9d0baf55ac..fbbe91946ae7 100644 --- a/electrum/plugins/ledger/ledger.py +++ b/electrum/plugins/ledger/ledger.py @@ -1,8 +1,11 @@ +import enum from struct import pack, unpack import hashlib import sys import traceback +from PyQt5.QtWidgets import QMessageBox + from electrum import ecc from electrum import bip32 from electrum.crypto import hash_160 @@ -16,6 +19,7 @@ from electrum.base_wizard import ScriptTypeNotSupported from electrum.logging import get_logger from electrum.plugin import Device +from electrum.three_keys.pubkey_type import PubkeyType from typing import Tuple, Optional from ..hw_wallet import HW_PluginBase, HardwareClientBase @@ -62,6 +66,12 @@ def catch_exception(self, *args, **kwargs): return catch_exception +class LedgerBtcvTxType(enum.IntEnum): + ALERT = 0x00 + INSTANT = 0x01 + RECOVERY = 0x02 + + class Ledger_Client(HardwareClientBase): def __init__(self, hidDevice): self.dongleObject = btchip(hidDevice) @@ -90,7 +100,7 @@ def has_usable_connection_with_device(self): return True @test_pin_unlocked - def get_xpub(self, bip32_path, xtype): + def get_xpub(self, bip32_path, xtype, pubkey_type=PubkeyType.PUBKEY_ALERT): self.checkDevice() # bip32_path is of the form 44'/0'/1' # S-L-O-W - we don't handle the fingerprint directly, so compute @@ -107,14 +117,17 @@ def get_xpub(self, bip32_path, xtype): 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) + nodeData = self.dongleObject.getWalletPublicKey(prevPath, btcvAddr=False) publicKey = compress_public_key(nodeData['publicKey']) 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) + if(pubkey_type == LedgerBtcvTxType.ALERT): + nodeData = self.dongleObject.getWalletPublicKey(bip32_path, btcvAddr=False) + else: + nodeData = self.dongleObject.getWalletPublicKey(bip32_path, btcvPubkeyTree=pubkey_type) publicKey = compress_public_key(nodeData['publicKey']) depth = len(bip32_intpath) return BIP32Node(xtype=xtype, @@ -124,6 +137,14 @@ def get_xpub(self, bip32_path, xtype): fingerprint=fingerprint_bytes, child_number=childnum_bytes).to_xpub() + @test_pin_unlocked + def set_instant_password(self, password): + self.dongleObject.setBTCVPassword(password, btchip.BTCV_PASSWORD_INSTANT) + + @test_pin_unlocked + def set_recovery_password(self, password): + self.dongleObject.setBTCVPassword(password, btchip.BTCV_PASSWORD_RECOVERY) + def has_detached_pin_support(self, client): try: client.getVerifyPinRemainingAttempts() @@ -307,11 +328,49 @@ def sign_message(self, sequence, message, password): # And convert it return bytes([27 + 4 + (signature[0] & 0x01)]) + r + s + @test_pin_unlocked + def set_btcv_password_use(self, tx_type=LedgerBtcvTxType.ALERT, password=None, wizard=None): + if self.handler == None: + self.handler = self.plugin.create_handler(wizard) + client = self.get_client() + password_hash = bytearray(32) + if password: + m = hashlib.sha256() + m.update(bytearray.fromhex(bytearray(password.encode('utf-8')).hex().ljust(64, '0'))) + password_hash = m.digest() + try: + client.setBTCVPasswordUse(password_hash, tx_type) + except BTChipException as e: + if e.sw == 0x6a80: + raise BTChipException(e.message + "\n(Password may be incorrect)") + + def are_3keys_ledger_passwords_correct(self, wizard, btcv_instant_password_check=None, btcv_recovery_password_check=None): + try: + if btcv_recovery_password_check: + self.set_btcv_password_use(tx_type=LedgerBtcvTxType.RECOVERY, password=btcv_recovery_password_check, wizard=wizard) + if btcv_instant_password_check: + self.set_btcv_password_use(tx_type=LedgerBtcvTxType.INSTANT, password=btcv_instant_password_check, wizard=wizard) + except BTChipException as e: + msg = _('Hardware wallet import fail') + "\n" + str(e) + if e.sw == 0x6a80: + msg = msg + "\n" + ('Invalid password(s)') + if wizard: + wizard.show_error(msg) + return False + finally: + self.set_btcv_password_use(tx_type=LedgerBtcvTxType.ALERT, wizard=self) + return True + @test_pin_unlocked @set_and_unset_signing - def sign_transaction(self, tx, password): + def sign_transaction(self, tx, password, pubkey_index=None, recovery_password=None, instant_password=False): + if tx.is_complete(): return + if recovery_password and not self.are_3keys_ledger_passwords_correct(None, btcv_recovery_password_check=recovery_password): + raise RuntimeError("Invalid recovery password provided") + if instant_password and not self.are_3keys_ledger_passwords_correct(None, btcv_instant_password_check=instant_password): + raise RuntimeError("Invalid instant password provided") inputs = [] inputsPaths = [] chipInputs = [] @@ -341,7 +400,11 @@ def sign_transaction(self, tx, password): self.give_error(MSG_NEEDS_FW_UPDATE_SEGWIT) segwitTransaction = True - my_pubkey, full_path = self.find_my_pubkey_in_txinout(txin) + if pubkey_index: + my_pubkey, = txin.bip32_paths.keys()[pubkey_index] + full_path = self.get_pubkey_derivation(my_pubkey, txin, only_der_suffix=False) + else: + 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:] @@ -444,7 +507,7 @@ def sign_transaction(self, tx, password): while inputIndex < len(inputs): singleInput = [ chipInputs[inputIndex] ] self.get_client().startUntrustedTransaction(False, 0, - singleInput, redeemScripts[inputIndex], version=tx.version) + 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+ my_pubkey = inputs[inputIndex][4] @@ -452,6 +515,20 @@ def sign_transaction(self, tx, password): signing_pubkey=my_pubkey.hex(), sig=inputSignature.hex()) inputIndex = inputIndex + 1 + + if recovery_password: + three_keys_pubkey_index = 1 + if instant_password: + three_keys_pubkey_index = 2 + self.add_3k_signatures_to_tx(changePath, chipInputs, inputs, inputsPaths, + LedgerBtcvTxType.RECOVERY, output, pin, rawTx, recovery_password, + redeemScripts, tx, txOutput, three_keys_pubkey_index) + if instant_password: + three_keys_pubkey_index = 1 + self.add_3k_signatures_to_tx(changePath, chipInputs, inputs, inputsPaths, + LedgerBtcvTxType.INSTANT, output, pin, rawTx, instant_password, + redeemScripts, tx, txOutput, three_keys_pubkey_index) + else: while inputIndex < len(inputs): self.get_client().startUntrustedTransaction(firstTransaction, inputIndex, @@ -494,6 +571,53 @@ def sign_transaction(self, tx, password): finally: self.handler.finished() + def add_3k_signatures_to_tx(self, changePath, chipInputs, inputs, inputsPaths, tx_type, output, + pin, rawTx, three_keys_password, redeemScripts, tx, txOutput, three_keys_pubkey_index): + inputIndex = 0 + self.get_client().startUntrustedTransaction(True, inputIndex, + chipInputs, redeemScripts[inputIndex], version=tx.version) + # we don't set meaningful outputAddress, amount and fees + # as we only care about the alternateEncoding==True branch + outputData = self.get_client().finalizeInput(b'', 0, 0, changePath, bfh(rawTx)) + outputData['outputData'] = txOutput + if outputData['confirmationNeeded']: + outputData['address'] = output + self.handler.finished() + pin = self.handler.get_auth(outputData) # does the authenticate dialog and returns pin + if not pin: + raise UserWarning() + self.handler.show_message(_("Confirmed. Signing Transaction...")) + while inputIndex < len(inputs): + singleInput = [chipInputs[inputIndex]] + self.get_client().startUntrustedTransaction(False, 0, + singleInput, redeemScripts[inputIndex], version=tx.version) + self.set_btcv_password_use(tx_type=tx_type, password=three_keys_password) + + inputSignature = self.get_client().untrustedHashSign(inputsPaths[inputIndex], pin, lockTime=tx.locktime) + inputSignature[0] = 0x30 # force for 1.4.9+ + + my_pubkey = list(tx.inputs()[inputIndex].bip32_paths.keys())[three_keys_pubkey_index] + + tx.add_signature_to_txin(txin_idx=inputIndex, + signing_pubkey=my_pubkey.hex(), + sig=inputSignature.hex()) + inputIndex += 1 + + @test_pin_unlocked + @set_and_unset_signing + def get_address(self, sequence, txin_type, btcvAddr=False): + client = self.get_client() + address_path = self.get_derivation_prefix()[2:] + "/%d/%d"%sequence + segwit = is_segwit_script_type(txin_type) + segwitNative = txin_type == 'p2wpkh' + + try: + result = client.getWalletPublicKey(address_path, showOnScreen=False, segwit=segwit, segwitNative=segwitNative, btcvAddr=btcvAddr) + return result['address'].decode("utf-8") + except Exception as e: + self.logger.exception(e) + + @test_pin_unlocked @set_and_unset_signing def show_address(self, sequence, txin_type): @@ -593,14 +717,36 @@ def setup_device(self, device_info, wizard, purpose): client.handler = self.create_handler(wizard) client.get_xpub("m/44'/0'", 'standard') # TODO replace by direct derivation once Nano S > 1.1 - def get_xpub(self, device_id, derivation, xtype, wizard): + def set_recovery_password(self, device_id, password, wizard): + devmgr = self.device_manager() + client = devmgr.client_by_id(device_id) + client.handler = self.create_handler(wizard) + client.checkDevice() + try: + client.set_recovery_password(password) + return True + except: + return False + + def set_instant_password(self, device_id, password, wizard): + devmgr = self.device_manager() + client = devmgr.client_by_id(device_id) + client.handler = self.create_handler(wizard) + client.checkDevice() + try: + client.set_instant_password(password) + return True + except: + return False + + def get_xpub(self, device_id, derivation, xtype, wizard, pubkey_type=PubkeyType.PUBKEY_ALERT): if xtype not in self.SUPPORTED_XTYPES: raise ScriptTypeNotSupported(_('This type of script is not supported with {device}.').format(device=self.device)) devmgr = self.device_manager() client = devmgr.client_by_id(device_id) client.handler = self.create_handler(wizard) client.checkDevice() - xpub = client.get_xpub(derivation, xtype) + xpub = client.get_xpub(derivation, xtype, pubkey_type) return xpub def get_client(self, keystore, force_pair=True): diff --git a/electrum/three_keys/pubkey_type.py b/electrum/three_keys/pubkey_type.py new file mode 100644 index 000000000000..0bf96854b94a --- /dev/null +++ b/electrum/three_keys/pubkey_type.py @@ -0,0 +1,6 @@ +from enum import IntEnum + +class PubkeyType(IntEnum): + PUBKEY_ALERT = 0 + PUBKEY_INSTANT = 1 + PUBKEY_RECOVERY = 2 diff --git a/electrum/three_keys/script.py b/electrum/three_keys/script.py index d7436d5b0a36..1684d80a1ef5 100644 --- a/electrum/three_keys/script.py +++ b/electrum/three_keys/script.py @@ -14,39 +14,45 @@ def __init__(self, recovery_pubkey: str): self._recovery_alert_flag = None self.witness_flags = [] - def get_redeem_script(self, public_keys: List[str]) -> str: - if not isinstance(public_keys, list) or len(public_keys) not in [1, 2]: - raise ThreeKeysError(f"Wrong input type! Expected 1 or 2 elements list not '{public_keys}'") - # filter out recovery pubkey - filtered_keys = list(filter(lambda item: item != self.recovery_pubkey, public_keys)) - if len(filtered_keys) != 1: - raise ThreeKeysError(f'Cannot deduce pubkey from {public_keys}') - - pub_key = filtered_keys[0] + @staticmethod + def create_redeem_script(alert_pubkey, recovery_pubkey): return ( opcodes.OP_IF.hex() + opcodes.OP_1.hex() + opcodes.OP_ELSE.hex() + opcodes.OP_2.hex() + opcodes.OP_ENDIF.hex() + - - push_script(pub_key) + - push_script(self.recovery_pubkey) + - + push_script(alert_pubkey) + + push_script(recovery_pubkey) + opcodes.OP_2.hex() + opcodes.OP_CHECKMULTISIG.hex() ) + @staticmethod + def _create_script_sig(signatures, flag, redeem_script): + return ( + opcodes.OP_0.hex() + + signatures + + flag + + push_script(redeem_script) + ) + + def get_redeem_script(self, public_keys: List[str]) -> str: + if not isinstance(public_keys, list) or len(public_keys) not in [1, 2]: + raise ThreeKeysError(f"Wrong input type! Expected 1 or 2 elements list not '{public_keys}'") + # filter out recovery pubkey + filtered_keys = list(filter(lambda item: item != self.recovery_pubkey, public_keys)) + if len(filtered_keys) != 1: + raise ThreeKeysError(f'Cannot deduce pubkey from {public_keys}') + + pub_key = filtered_keys[0] + return self.create_redeem_script(pub_key, self.recovery_pubkey) + def get_script_sig(self, signatures: List[str], public_keys: List[str]) -> str: if self._recovery_alert_flag is None: raise ThreeKeysError('Recovery/alert flag not set!') sigs = ''.join(push_script(sig) for sig in signatures) - return ( - opcodes.OP_0.hex() + - sigs + - self._recovery_alert_flag + - push_script(self.get_redeem_script(public_keys)) - ) + return self._create_script_sig(sigs, self._recovery_alert_flag, self.get_redeem_script(public_keys)) def set_alert(self): # 1 of 2 @@ -72,11 +78,8 @@ def __init__(self, recovery_pubkey: str, instant_pubkey: str): self._instant_recovery_alert_flag = None self.witness_flags = [] - def get_redeem_script(self, public_keys: List[str]) -> str: - if not isinstance(public_keys, list) or len(public_keys) > 3: - raise ThreeKeysError(f"Wrong input type! Expected list not '{public_keys}'") - - pub_key = public_keys[0] + @staticmethod + def create_redeem_script(alert_pubkey, instant_pubkey, recovery_pubkey): return ( opcodes.OP_IF.hex() + opcodes.OP_1.hex() + @@ -87,25 +90,34 @@ def get_redeem_script(self, public_keys: List[str]) -> str: opcodes.OP_3.hex() + opcodes.OP_ENDIF.hex() + opcodes.OP_ENDIF.hex() + - - push_script(pub_key) + - push_script(self.instant_pubkey) + - push_script(self.recovery_pubkey) + - + push_script(alert_pubkey) + + push_script(instant_pubkey) + + push_script(recovery_pubkey) + opcodes.OP_3.hex() + opcodes.OP_CHECKMULTISIG.hex() ) + @staticmethod + def _create_script_sig(signatures, flags, redeem_script): + return ( + opcodes.OP_0.hex() + + signatures + + flags + + push_script(redeem_script) + ) + + def get_redeem_script(self, public_keys: List[str]) -> str: + if not isinstance(public_keys, list) or len(public_keys) > 3: + raise ThreeKeysError(f"Wrong input type! Expected list not '{public_keys}'") + + pub_key = public_keys[0] + return self.create_redeem_script(pub_key, self.instant_pubkey, self.recovery_pubkey) + def get_script_sig(self, signatures: List[str], public_keys: List[str]) -> str: if self._instant_recovery_alert_flag is None: raise ThreeKeysError('Recovery/alert/instant flag not set!') sigs = ''.join(push_script(sig) for sig in signatures) - return ( - opcodes.OP_0.hex() + - sigs + - self._instant_recovery_alert_flag + - push_script(self.get_redeem_script(public_keys)) - ) + return self._create_script_sig(sigs, self._instant_recovery_alert_flag, self.get_redeem_script(public_keys)) def set_alert(self): # 1 of 3 @@ -130,3 +142,28 @@ def is_instant_mode(self): def is_alert_mode(self): return self.witness_flags == [1] + + +class TwoKeysHWScriptGenerator(TwoKeysScriptGenerator): + def __init__(self, recovery_pubkey: str): + self._recovery_alert_flag = None + self.recovery_pubkey = recovery_pubkey + self.witness_flags = [] + + def get_redeem_script(self, public_keys: List[str]) -> str: + if not isinstance(public_keys, list) or len(public_keys) != 2: + raise ThreeKeysError(f"Wrong input type! Expected 2 elements list not '{public_keys}'") + return self.create_redeem_script(public_keys[0], public_keys[1]) + + +class ThreeKeysHWScriptGenerator(ThreeKeysScriptGenerator): + def __init__(self, instant_pubkey: str, recovery_pubkey: str): + self._instant_recovery_alert_flag = None + self.instant_pubkey = instant_pubkey + self.recovery_pubkey = recovery_pubkey + self.witness_flags = [] + + def get_redeem_script(self, public_keys: List[str]) -> str: + if not isinstance(public_keys, list) or len(public_keys) != 3: + raise ThreeKeysError(f"Wrong input type! Expected 3 elements list not '{public_keys}'") + return self.create_redeem_script(public_keys[0], public_keys[1], public_keys[2]) diff --git a/electrum/transaction.py b/electrum/transaction.py index 3a0caeb6ae2a..3616ca9b04dc 100644 --- a/electrum/transaction.py +++ b/electrum/transaction.py @@ -521,6 +521,9 @@ def update_inputs(self): for input in self._inputs: input.multisig_script_generator = self.multisig_script_generator + def update_input_multisig_generator(self, input, multisig_script_generator): + input.multisig_script_generator = multisig_script_generator + def to_json(self) -> dict: d = { 'version': self.version, diff --git a/electrum/wallet.py b/electrum/wallet.py index ab6fee7c7dcb..aebe7accf01b 100755 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -59,7 +59,8 @@ from .plugin import run_hook from .simple_config import SimpleConfig from .storage import StorageEncryptionVersion, WalletStorage -from .three_keys.script import TwoKeysScriptGenerator, ThreeKeysScriptGenerator +from .three_keys.script import TwoKeysScriptGenerator, ThreeKeysScriptGenerator, ThreeKeysError, \ + TwoKeysHWScriptGenerator, ThreeKeysHWScriptGenerator from .three_keys.transaction import TxType, ThreeKeysTransaction from .three_keys.utils import filter_spendable_coins, update_tx_status from .transaction import (Transaction, TxInput, UnknownTxinType, TxOutput, @@ -2078,14 +2079,16 @@ def get_public_keys_with_deriv_info(self, address: str): return {k.derive_pubkey(*der_suffix): (k, der_suffix) for k in self.get_keystores()} - def _add_input_sig_info(self, txin, address, *, only_der_suffix=True): - self._add_txinout_derivation_info(txin, address, only_der_suffix=only_der_suffix) + def _add_input_sig_info(self, txin, address, *, only_der_suffix=True, sort_pubkeys=True): + self._add_txinout_derivation_info(txin, address, only_der_suffix=only_der_suffix, sort_pubkeys=sort_pubkeys) - def _add_txinout_derivation_info(self, txinout, address, *, only_der_suffix=True): + def _add_txinout_derivation_info(self, txinout, address, *, only_der_suffix=True, sort_pubkeys=True): if not self.is_mine(address): return pubkey_deriv_info = self.get_public_keys_with_deriv_info(address) - txinout.pubkeys = sorted([bfh(pk) for pk in list(pubkey_deriv_info)]) + txinout.pubkeys = [bfh(pk) for pk in list(pubkey_deriv_info)] + if sort_pubkeys: + txinout.pubkeys = sorted(txinout.pubkeys) 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, @@ -2306,6 +2309,7 @@ def __init__(self, storage: WalletStorage, *, config: SimpleConfig, scriptGenera self.multikey_type = storage.get('multikey_type') self.multisig_script_generator = scriptGenerator self.set_alert() + # super has to be at the end otherwise wallet breaks super().__init__(storage=storage, config=config) self.multiple_change = storage.get('multiple_change', True) @@ -2341,7 +2345,7 @@ def derive_txin_type_from_keystore(keystore): if txin_type == 'standard': return 'p2sh' - if 'p2wpkh' in txin_type: + if 'p2wpkh' in txin_type or 'p2wsh-p2sh' == txin_type: return 'p2wsh-p2sh' raise UnknownTxinType(f'Cannot derive txin_type from {txin_type}') @@ -2508,7 +2512,7 @@ def sign_recovery_transaction(self, tx: PartialTransaction, password, recovery_k tx.finalize_psbt() return tx - + def get_wallet_label(self): return '2-Key Vault' @@ -2574,6 +2578,296 @@ def get_wallet_label(self): return '3-Key Vault' +class MultikeyHWWallet(Multisig_Wallet): + + def __init__(self, storage: WalletStorage, *, config: SimpleConfig, scriptGenerator: MultiKeyScriptGenerator): + self.wallet_type = storage.get('wallet_type') + self.multikey_type = storage.get('multikey_type') + self.multisig_script_generator = scriptGenerator + if isinstance(self.multisig_script_generator, ThreeKeysHWScriptGenerator): + self.n = 3 + else: + self.n = 2 + self.m = 1 + Deterministic_Wallet.__init__(self, storage, config=config) + # super has to be at the end otherwise wallet breaks + self.multiple_change = storage.get('multiple_change', False) + + def load_keystore(self): + self.keystores = {} + for i in range(self.n): + name = 'x%d/'%(i+1) + self.keystores[name] = load_keystore(self.storage, name) + self.keystore = self.keystores['x1/'] + self.txin_type = 'p2wsh-p2sh' + + def make_unsigned_transaction(self, *, coins: Sequence[PartialTxInput], + outputs: List[PartialTxOutput], fee=None, + change_addr: str = None, is_sweep=False) -> PartialTransaction: + self.update_tx_input_multisig_generator(coins) + tx = super().make_unsigned_transaction( + coins=coins, + outputs=outputs, + fee=fee, + change_addr=change_addr, + is_sweep=is_sweep, + ) + self.update_transaction_multisig_generator(tx) + return tx + + def update_transaction_multisig_generator(self, tx: Transaction): + tx.multisig_script_generator = self.multisig_script_generator + tx.update_inputs() + + def update_tx_input_multisig_generator(self, inputs: Sequence[PartialTxInput]): + for txin in inputs: + txin.multisig_script_generator = self.multisig_script_generator + + def get_atxs_to_recovery(self): + txi_list = self.db.list_txi() + recovery_mempool_transactions = { + history_item.txid: self.db.get_transaction(history_item.txid) + for history_item in self.get_history() if history_item.tx_mined_status.conf == 0 and history_item.tx_mined_status.txtype == TxType.RECOVERY.name + } + with self.transaction_lock: + conflicting_alert_inputs = set([ + txin.prevout.txid.hex() + for tx in recovery_mempool_transactions.values() for txin in tx.inputs() + ]) + for tx_hash, tx in self.db.transactions.items(): + mined_info = self.get_tx_height(tx_hash) + # skip incoming alerts, mempool alerts and alerts conflicted with recovery mempool + if tx.tx_type == TxType.ALERT_PENDING and mined_info.conf > 0 and tx_hash in txi_list: + if not set([txin.prevout.txid.hex() for txin in tx.inputs()]).issubset(conflicting_alert_inputs): + yield tx + + def get_inputs_and_output_for_recovery(self, alert_transactions: ThreeKeysTransaction, destination_address: str): + inputs = [PartialTxInput.from_txin(txin) for atx in alert_transactions for txin in atx.inputs()] + scriptpubkey = bfh(bitcoin.address_to_script(destination_address)) + # ! sign sets max value to output + output = PartialTxOutput(scriptpubkey=scriptpubkey, value='!') + return inputs, output + + def prepare_inputs_for_recovery(self, inputs: list): + """Methods for modification tx inputs coming from alert transaction to work with recovery tx. + Method adds missing address, satoshi and height value from db storage""" + updated_inputs = copy.deepcopy(inputs) + # cache for not doubling fetching data for repeating address + db_address_satoshi_height_cache = {} + for input in updated_inputs: + tx_hash = input.prevout.txid.hex() + prevout_index = input.prevout.out_idx + key = (tx_hash, prevout_index) + if key not in db_address_satoshi_height_cache: + fetched_data = self.db.get_address_satoshi_height_for_tx(tx_hash) + db_address_satoshi_height_cache.update(fetched_data) + fetched_data = fetched_data[key] + else: + fetched_data = db_address_satoshi_height_cache[key] + + input._trusted_address = fetched_data['address'] + input._trusted_value_sats = fetched_data['satoshi'] + input.block_height = fetched_data['height'] + return updated_inputs + + def set_alert(self): + from .plugins.ledger.ledger import LedgerBtcvTxType + self.multisig_script_generator.set_alert() + self._get_hw_keystore().set_btcv_password_use(tx_type=LedgerBtcvTxType.ALERT) + + def set_recovery(self): + self.multisig_script_generator.set_recovery() + + def set_instant(self): + self.multisig_script_generator.set_instant() + + def sign_transaction(self, tx: Transaction, password, update_pubkeys_fn=None, recovery_password=None, instant_password=None) -> Optional[PartialTransaction]: + if self.is_watching_only(): + return + if not isinstance(tx, PartialTransaction): + return + tmp_tx = copy.deepcopy(tx) + for input in tmp_tx.inputs(): + tmp_pubkeys = self.get_public_keys_with_deriv_info(input.address) + self.multisig_script_generator.recovery_pubkey = list(tmp_pubkeys)[1] + if isinstance(self.multisig_script_generator, ThreeKeysHWScriptGenerator): + self.multisig_script_generator.instant_pubkey = list(tmp_pubkeys)[2] + tmp_tx.multisig_script_generator = self.multisig_script_generator + tmp_tx.update_input_multisig_generator(input, copy.deepcopy(self.multisig_script_generator)) + + tmp_tx.add_info_from_wallet(self, include_xpubs_and_full_paths=True) + if update_pubkeys_fn: + update_pubkeys_fn(tmp_tx) + + for k in sorted(self.get_keystores(), key=lambda ks: ks.ready_to_sign(), reverse=True): + try: + if k.can_sign(tmp_tx): + from electrum.plugins.ledger.ledger import Ledger_KeyStore + if isinstance(k, Ledger_KeyStore): + from electrum.plugins.ledger.ledger import LedgerBtcvTxType + self._get_hw_keystore().set_btcv_password_use(tx_type=LedgerBtcvTxType.ALERT) + + k.sign_transaction(tmp_tx, password, None, recovery_password, instant_password) + + else: + raise NotImplementedError + except UserCancelled: + continue + + tmp_tx.remove_xpubs_and_bip32_paths() + tx.combine_with_other_psbt(tmp_tx, True) + tx.add_info_from_wallet(self, include_xpubs_and_full_paths=False) + return tx + + def get_coin_chooser(self): + if self.multisig_script_generator.is_alert_mode(): + return coinchooser.get_coin_chooser_alert(self.config) + else: + return coinchooser.get_coin_chooser(self.config) + + def add_input_info(self, txin: PartialTxInput, *, only_der_suffix: bool = True) -> None: + address = self.get_txin_address(txin) + if not self.is_mine(address): + is_mine = self._learn_derivation_path_for_address_from_txinout(txin, address) + if not is_mine: + return + # set script_type first, as later checks might rely on it: + txin.script_type = self.get_txin_type(address) + self._add_input_utxo_info(txin, address) + txin.num_sig = self.m if isinstance(self, Multisig_Wallet) else 1 + if txin.redeem_script is None: + try: + redeem_script_hex = self.get_redeem_script(address) + txin.redeem_script = bfh(redeem_script_hex) if redeem_script_hex else None + except UnknownTxinType: + pass + if txin.witness_script is None: + try: + witness_script_hex = self.get_witness_script(address) + txin.witness_script = bfh(witness_script_hex) if witness_script_hex else None + except UnknownTxinType: + pass + self._add_input_sig_info(txin, address, only_der_suffix=only_der_suffix, sort_pubkeys=False) + + def _get_hw_keystore(self): + for k in self.get_keystores(): + if isinstance(k, Hardware_KeyStore): + return k + raise AssertionError("Hardware keystore not found") + + def is_recovery_mode(self): + return self.multisig_script_generator.is_recovery_mode() + +class TwoKeysHWWallet(MultikeyHWWallet): + + def __init__(self, storage: WalletStorage, *, config: SimpleConfig): + script_generator = TwoKeysHWScriptGenerator(recovery_pubkey=storage.get('recovery_pubkey')) + super().__init__(storage, config=config, scriptGenerator=script_generator) + + def pubkeys_to_scriptcode(self, pubkeys: Sequence[str]) -> str: + if not isinstance(pubkeys, list) or len(pubkeys) != 2: + raise ThreeKeysError(f"Wrong input type! Expected list not '{pubkeys}'") + + return TwoKeysScriptGenerator.create_redeem_script(pubkeys[0], pubkeys[1]) + + def _add_recovery_pubkey_to_transaction(self, tx): + for input in tx.inputs(): + recovery_pubkey = bytes.fromhex(input.multisig_script_generator.recovery_pubkey) + if recovery_pubkey not in input.pubkeys: + input.pubkeys.append(recovery_pubkey) + input.num_sig = 2 + assert len(input.pubkeys) == 2, 'Wrong number of pubkeys for performing recovery tx' + return tx + + def sign_recovery_transaction(self, tx: PartialTransaction, password, recovery_keypairs, recovery_password=None)-> Optional[ + PartialTransaction]: + + if not isinstance(tx, PartialTransaction): + return + + tx = self.sign_transaction(tx, password, self._add_recovery_pubkey_to_transaction, recovery_password) + + if not tx.is_complete(): + _logger.error(f'Recovery transaction not completed') + tx.finalize_psbt() + + return tx + + def get_wallet_label(self): + return '2-Key Vault' + + +class ThreeKeysHWWallet(MultikeyHWWallet): + + def __init__(self, storage: WalletStorage, *, config: SimpleConfig): + + script_generator = ThreeKeysHWScriptGenerator(instant_pubkey=storage.get('instant_pubkey'), + recovery_pubkey=storage.get('recovery_pubkey')) + super().__init__(storage, config=config, scriptGenerator=script_generator) + self.n = 3 + self.m = 1 + + def pubkeys_to_scriptcode(self, pubkeys: Sequence[str]) -> str: + if not isinstance(pubkeys, list) or len(pubkeys) != 3: + raise ThreeKeysError(f"Wrong input type! Expected list not '{pubkeys}'") + + return ThreeKeysScriptGenerator.create_redeem_script(pubkeys[0], pubkeys[1], pubkeys[2]) + + def sign_instant_transaction(self, tx: PartialTransaction, password, instant_keypairs, instant_password) -> Optional[PartialTransaction]: + if not isinstance(tx, PartialTransaction): + return + + tx = self.sign_transaction(tx, password, self._add_instant_pubkey_to_transaction, None, instant_password) + + if not tx.is_complete(): + _logger.error(f'Recovery transaction not completed') + tx.finalize_psbt() + + return tx + + def _add_instant_pubkey_to_transaction(self, tx): + for input in tx.inputs(): + instant_pubkey = bytes.fromhex(self.multisig_script_generator.instant_pubkey) + if instant_pubkey not in input.pubkeys: + input.pubkeys.append(instant_pubkey) + input.num_sig = 2 + assert len(input.pubkeys) == 3, 'Wrong number of pubkeys for performing recovery tx' + return tx + + def _add_recovery_pubkey_to_transaction(self, tx): + for input in tx.inputs(): + instant_pubkey = bytes.fromhex(input.multisig_script_generator.instant_pubkey) + if instant_pubkey not in input.pubkeys: + input.pubkeys.append(instant_pubkey) + recovery_pubkey = bytes.fromhex(input.multisig_script_generator.recovery_pubkey) + if recovery_pubkey not in input.pubkeys: + input.pubkeys.append(recovery_pubkey) + input.num_sig = 3 + assert len(input.pubkeys) == 3, 'Wrong number of pubkeys for performing recovery tx' + return tx + + + def sign_recovery_transaction(self, tx: PartialTransaction, password, recovery_keypairs, recovery_password, instant_password)-> Optional[ + PartialTransaction]: + + if not isinstance(tx, PartialTransaction): + return + + tx = self.sign_transaction(tx, password, self._add_recovery_pubkey_to_transaction, recovery_password, instant_password) + + if not tx.is_complete(): + _logger.error(f'Recovery transaction not completed') + tx.finalize_psbt() + + return tx + + def get_wallet_label(self): + return '3-Key Vault' + + def is_instant_mode(self): + return self.multisig_script_generator.is_instant_mode() + + wallet_types = [ '2-key', '3-key', @@ -2593,12 +2887,16 @@ def register_wallet_type(category): 'xpub': Standard_Wallet, 'imported': Imported_Wallet, '2-key': TwoKeysWallet, - '3-key': ThreeKeysWallet + '3-key': ThreeKeysWallet, + '2-key-hw': TwoKeysHWWallet, + '3-key-hw': ThreeKeysHWWallet } + def register_constructor(wallet_type, constructor): wallet_constructors[wallet_type] = constructor + # former WalletFactory class Wallet(object): """The main wallet "entry point".