diff --git a/electrum/bip32.py b/electrum/bip32.py
index 785253ad4459..f968b0f7f782 100644
--- a/electrum/bip32.py
+++ b/electrum/bip32.py
@@ -90,7 +90,7 @@ def CKD_pub(parent_pubkey: bytes, parent_chaincode: bytes, child_index: int) ->
# helper function, callable with arbitrary 'child_index' byte-string.
-# i.e.: 'child_index' does not need to fit into 32 bits here! (c.f. trustedcoin billing)
+# i.e.: 'child_index' does not need to fit into 32 bits here! (c.f. trustedcoin make_xpub)
def _CKD_pub(parent_pubkey: bytes, parent_chaincode: bytes, child_index: bytes) -> Tuple[bytes, bytes]:
I = hmac_oneshot(parent_chaincode, parent_pubkey + child_index, hashlib.sha512)
pubkey = ecc.ECPrivkey(I[0:32]) + ecc.ECPubkey(parent_pubkey)
diff --git a/electrum/cosigner.py b/electrum/cosigner.py
new file mode 100644
index 000000000000..e71727563328
--- /dev/null
+++ b/electrum/cosigner.py
@@ -0,0 +1,264 @@
+# Copyright (C) 2026 The Electrum developers
+# Distributed under the MIT software license, see the accompanying
+# file LICENCE or http://www.opensource.org/licenses/mit-license.php
+
+"""Keys of the wallets this device cosigns for, stored in the 'keystores' file.
+
+This device can sign for a wallet it does not have: a key is scanned from a QR
+code, together with the master public keys of the other cosigners, which are
+needed in order to rebuild the scripts of that wallet. What we sign is checked
+against those scripts, because the transaction we are given may lie about what
+it does.
+
+A cosigner is indexed by the fingerprint that its key uses in transactions (the
+root fingerprint of its key origin), so that a transaction can be matched against
+that file without decrypting anything. Everything else is encrypted with the
+password of the device, so that whoever reads the file cannot derive the addresses
+of those wallets, and thus cannot see their history.
+"""
+
+import json
+import os
+import stat
+from typing import Dict, List, Optional, Sequence, Union, TYPE_CHECKING
+
+from . import descriptor, keystore
+from .bip32 import is_xprv, is_xpub, xpub_type
+from .crypto import pw_encode_with_version_and_mac, pw_decode_with_version_and_mac
+from .transaction import PartialTransaction, PartialTxInput, PartialTxOutput, Sighash
+from .util import os_chmod
+
+if TYPE_CHECKING:
+ from .simple_config import SimpleConfig
+
+
+KEYSTORES_FILE_NAME = 'keystores'
+
+# The device that holds the other keys of the wallet displays one of them in a
+# QR code, for this device to scan.
+COSIGNER_QR_PREFIX = 'cosigner:'
+
+# A wallet derives only a few addresses beyond the ones it has used, so it may never find
+# the change of a transaction that was sent far ahead of them. Where its addresses end we
+# cannot know, but normal use does not reach an index this high.
+HIGH_DERIVATION_INDEX = 1000
+
+
+class Cosigner:
+ """A key this device signs with, and the wallet that key belongs to.
+
+ The wallet is described the way Electrum describes one: the master public keys
+ of the other cosigners, and the number of signatures it requires. The type of
+ its scripts follows from the header of the master keys ('xpub', 'Ypub', 'Zpub'...).
+ """
+
+ def __init__(self, *, xprv: str, xpubs: Sequence[str] = (), m: int = 1):
+ self.xprv = xprv
+ self.xpubs = list(xpubs)
+ self.m = m
+ self.keystore = keystore.from_xprv(xprv)
+ # the order of the keys does not matter: Electrum sorts them in the script
+ self.keystores = [self.keystore] + [keystore.from_xpub(xpub) for xpub in self.xpubs]
+ if not 1 <= m <= len(self.keystores):
+ raise ValueError(f'{m} signatures for {len(self.keystores)} keys')
+ if len({ks.xpub for ks in self.keystores}) != len(self.keystores):
+ raise ValueError('duplicate keys')
+ if len({xpub_type(ks.xpub) for ks in self.keystores}) != 1:
+ raise ValueError('the keys of a wallet must have the same type')
+
+ @classmethod
+ def from_qr_data(cls, data: str) -> 'Cosigner':
+ """Reads the QR code that sets up this device as cosigner. Raises ValueError."""
+ if not data.startswith(COSIGNER_QR_PREFIX):
+ raise ValueError('not a cosigner QR code')
+ m, __, keys = data[len(COSIGNER_QR_PREFIX):].partition(':')
+ xprv, *xpubs = keys.split(':')
+ if not (m.isdigit() and is_xprv(xprv) and all(is_xpub(xpub) for xpub in xpubs)):
+ raise ValueError('invalid keys in cosigner QR code')
+ return cls(xprv=xprv, xpubs=xpubs, m=int(m))
+
+ def to_qr_data(self) -> str:
+ """The QR code that sets up another device as cosigner of this wallet."""
+ return COSIGNER_QR_PREFIX + ':'.join([str(self.m), self.xprv, *self.xpubs])
+
+ @classmethod
+ def from_dict(cls, d: Dict) -> 'Cosigner':
+ return cls(xprv=d['xprv'], xpubs=d['xpubs'], m=d['m'])
+
+ def to_dict(self) -> Dict:
+ return {'m': self.m, 'xprv': self.xprv, 'xpubs': self.xpubs}
+
+ @property
+ def cosigner_id(self) -> str:
+ """A cosigner is indexed by the fingerprint its key uses in transactions."""
+ return self.keystore.get_root_fingerprint()
+
+ @property
+ def script_type(self) -> str:
+ # as Standard_Wallet and Multisig_Wallet do, in load_keystore
+ xtype = xpub_type(self.keystore.xpub)
+ if len(self.keystores) == 1:
+ return 'p2pkh' if xtype == 'standard' else xtype
+ return 'p2sh' if xtype == 'standard' else xtype
+
+ def get_script_descriptor(self, der_suffix: Sequence[int]) -> descriptor.Descriptor:
+ pubkeys = [ks.get_pubkey_provider(der_suffix) for ks in self.keystores]
+ return descriptor.from_legacy_electrum_script_type(self.script_type, pubkeys=pubkeys, m=self.m)
+
+ def claims_our_key(self, txinout: Union[PartialTxInput, PartialTxOutput]) -> bool:
+ """Whether the transaction says that input or output uses our key."""
+ return self.keystore.find_my_pubkey_in_txinout(txinout)[0] is not None
+
+ def get_wallet_script(
+ self,
+ txinout: Union[PartialTxInput, PartialTxOutput],
+ ) -> Optional[descriptor.Descriptor]:
+ """The script of the wallet for that input or output, None if it does not belong to it.
+ The derivation found in the transaction is only a hint: whoever created the transaction
+ can claim our key for a script they own, so the script is recomputed from the keys.
+ """
+ __, der_suffix = self.keystore.find_my_pubkey_in_txinout(txinout, only_der_suffix=True)
+ if der_suffix is None:
+ return None
+ desc = self.get_script_descriptor(der_suffix)
+ return desc if txinout.scriptpubkey == desc.expand().output_script else None
+
+ def is_wallet_output(self, txout: PartialTxOutput) -> bool:
+ """Whether that output pays back to the wallet."""
+ return self.get_wallet_script(txout) is not None
+
+ def is_high_derivation_index(self, txout: PartialTxOutput) -> bool:
+ """Whether that output of the wallet is so far ahead of the addresses it uses that
+ it may never be found, see HIGH_DERIVATION_INDEX. Only asked about wallet outputs,
+ whose derivation was verified against the script they pay to.
+ """
+ __, der_suffix = self.keystore.find_my_pubkey_in_txinout(txout, only_der_suffix=True)
+ return der_suffix is not None and der_suffix[-1] >= HIGH_DERIVATION_INDEX
+
+ def add_wallet_info_to_tx(self, tx: PartialTransaction) -> None:
+ """Adds the scripts of the wallet, which a signer would otherwise get from its
+ wallet. Raises ValueError if the transaction cannot be verified.
+ """
+ for txin in tx.inputs():
+ if not self.claims_our_key(txin):
+ continue
+ desc = self.get_wallet_script(txin)
+ if desc is None:
+ raise ValueError('input does not spend from this wallet')
+ txin.script_descriptor = desc
+ if not desc.is_segwit() and txin.utxo is None:
+ # the signature of a non-segwit input does not commit to its amount. Without
+ # the previous transaction we cannot know it, and the fee could be anything.
+ # note: we ask the script we recomputed, as txin.is_segwit() believes the
+ # witness field of the transaction, which is not ours.
+ raise ValueError('missing previous transaction of a non-segwit input')
+ if txin.sighash is not None and txin.sighash != Sighash.ALL:
+ # with SIGHASH_NONE or ANYONECANPAY, our signature would not commit to the
+ # outputs of the transaction, which could then be changed after we signed
+ raise ValueError(f'non-default sighash type: {txin.sighash}')
+ if tx.get_fee() is None:
+ # the amount of an input is unknown, so we cannot tell what the transaction pays
+ raise ValueError('unknown fee')
+
+ def sign_transaction(self, tx: PartialTransaction) -> None:
+ """Signs the transaction with our key. Raises ValueError if the transaction
+ cannot be verified."""
+ self.add_wallet_info_to_tx(tx)
+ self.keystore.sign_transaction(tx, None)
+
+
+def get_cosigner_ids(config: 'SimpleConfig') -> List[str]:
+ """The fingerprints of the cosigners this device signs for."""
+ return list(_read_keystores(config))
+
+
+def get_cosigner(config: 'SimpleConfig', cosigner_id: str, password: str) -> Cosigner:
+ """Raises InvalidPassword."""
+ cosigner = Cosigner.from_dict(_decrypt(_read_keystores(config)[cosigner_id], password))
+ if cosigner.cosigner_id != cosigner_id:
+ raise ValueError(f'cosigner {cosigner_id} does not match its keys')
+ return cosigner
+
+
+def add_cosigner(config: 'SimpleConfig', cosigner: Cosigner, password: str) -> None:
+ assert password, 'the keys of a cosigner must be encrypted'
+ cosigners = _read_keystores(config)
+ cosigners[cosigner.cosigner_id] = _encrypt(cosigner.to_dict(), password)
+ _write_keystores(config, cosigners)
+
+
+def find_cosigner_id_for_tx(config: 'SimpleConfig', tx: PartialTransaction) -> Optional[str]:
+ """The cosigner that transaction needs, None if this device does not sign for it.
+ Nothing is decrypted, so the password of the device is not needed here.
+ """
+ fingerprints = {fp.hex() for txin in tx.inputs() for fp, __ in txin.bip32_paths.values()}
+ for cosigner_id in get_cosigner_ids(config):
+ if cosigner_id in fingerprints:
+ return cosigner_id
+ return None
+
+
+def find_cosigner_for_tx(config: 'SimpleConfig', tx: PartialTransaction, password: str) -> Optional[Cosigner]:
+ """The keys that transaction needs, None if this device does not sign for it.
+ Raises InvalidPassword.
+ """
+ cosigner_id = find_cosigner_id_for_tx(config, tx)
+ return get_cosigner(config, cosigner_id, password) if cosigner_id is not None else None
+
+
+def check_cosigners_password(config: 'SimpleConfig', password: str) -> None:
+ """Whether the keys of the cosigners can be read with that password.
+ Raises InvalidPassword.
+ """
+ for blob in _read_keystores(config).values():
+ _decrypt(blob, password)
+
+
+def update_cosigners_password(config: 'SimpleConfig', old_password: str, new_password: str) -> None:
+ """Re-encrypts the keys, after the password of the device has changed.
+ Raises InvalidPassword, without writing anything.
+ """
+ cosigners = _read_keystores(config)
+ if not cosigners:
+ # this device cosigns for nothing; do not create the file
+ return
+ _write_keystores(config, {
+ cosigner_id: _encrypt(_decrypt(blob, old_password), new_password)
+ for cosigner_id, blob in cosigners.items()
+ })
+
+
+def _encrypt(data: Dict, password: str) -> str:
+ return pw_encode_with_version_and_mac(json.dumps(data).encode('utf8'), password)
+
+
+def _decrypt(blob: str, password: str) -> Dict:
+ return json.loads(pw_decode_with_version_and_mac(blob, password).decode('utf8'))
+
+
+def _keystores_path(config: 'SimpleConfig') -> str:
+ return os.path.join(config.path, KEYSTORES_FILE_NAME)
+
+
+def _read_keystores(config: 'SimpleConfig') -> Dict[str, str]:
+ path = _keystores_path(config)
+ if not os.path.exists(path):
+ return {}
+ with open(path, 'r', encoding='utf-8') as f:
+ cosigners = json.loads(f.read())
+ if not isinstance(cosigners, dict):
+ raise ValueError(f'invalid keystores file at {path}')
+ return cosigners
+
+
+def _write_keystores(config: 'SimpleConfig', cosigners: Dict[str, str]) -> None:
+ # written through a temporary file: a partial write would lose the keys of
+ # the other wallets this device cosigns for
+ path = _keystores_path(config)
+ temp_path = f'{path}.tmp.{os.getpid()}'
+ with open(temp_path, 'w', encoding='utf-8') as f:
+ os_chmod(temp_path, stat.S_IREAD | stat.S_IWRITE) # set restrictive perms *before* we write data
+ f.write(json.dumps(cosigners, indent=4, sort_keys=True))
+ f.flush()
+ os.fsync(f.fileno())
+ os.replace(temp_path, path)
diff --git a/electrum/daemon.py b/electrum/daemon.py
index 9823bc365850..11fbf7b97e76 100644
--- a/electrum/daemon.py
+++ b/electrum/daemon.py
@@ -49,7 +49,8 @@
)
from .wallet import Wallet, Abstract_Wallet
from .storage import WalletStorage
-from .wallet_db import WalletDB, WalletUnfinished
+from .wallet_db import WalletDB
+from .cosigner import check_cosigners_password, update_cosigners_password
from .commands import known_commands, Commands
from .simple_config import SimpleConfig
from .exchange_rate import FxThread
@@ -554,8 +555,6 @@ def _load_wallet(
storage.decrypt(password)
# read data, pass it to db
db = WalletDB(storage.read(), storage=storage, upgrade=upgrade)
- if db.get_action():
- raise WalletUnfinished(db)
wallet = Wallet(db, config=config)
if force_check_password:
wallet.check_password(password)
@@ -765,8 +764,12 @@ def update_password_for_directory(
return False
if is_unified and old_password == new_password:
return True
+ # check the cosigner keys before any wallet is touched, so that a failure
+ # cannot leave them encrypted with a password that is no longer used
+ check_cosigners_password(self.config, old_password)
self.check_password_for_directory(
old_password=old_password, new_password=new_password, wallet_dir=wallet_dir)
+ update_cosigners_password(self.config, old_password, new_password)
return True
def update_recently_opened_wallets(self, wallet_path, *, remove: bool = False) -> None:
diff --git a/electrum/descriptor.py b/electrum/descriptor.py
index d23213e64d26..77f5afb69f85 100644
--- a/electrum/descriptor.py
+++ b/electrum/descriptor.py
@@ -1009,6 +1009,41 @@ def parse_tree(tree_str):
raise ValueError("{} is not a valid descriptor function".format(func))
+def from_legacy_electrum_script_type(
+ script_type: str,
+ *,
+ pubkeys: List['PubkeyProvider'],
+ m: Optional[int] = None,
+) -> 'Descriptor':
+ """Builds the descriptor of a script type Electrum names, as wallets do.
+ Inverse of Descriptor.to_legacy_electrum_script_type. m is the number of
+ signatures a multisig script requires.
+
+ :raises: NotImplementedError: if the script type is unknown
+ """
+ if script_type == 'p2pk':
+ return PKDescriptor(pubkey=pubkeys[0])
+ elif script_type == 'p2pkh':
+ return PKHDescriptor(pubkey=pubkeys[0])
+ elif script_type == 'p2wpkh':
+ return WPKHDescriptor(pubkey=pubkeys[0])
+ elif script_type == 'p2wpkh-p2sh':
+ wpkh = WPKHDescriptor(pubkey=pubkeys[0])
+ return SHDescriptor(subdescriptor=wpkh)
+ elif script_type == 'p2sh':
+ multi = MultisigDescriptor(pubkeys=pubkeys, thresh=m, is_sorted=True)
+ return SHDescriptor(subdescriptor=multi)
+ elif script_type == 'p2wsh':
+ multi = MultisigDescriptor(pubkeys=pubkeys, thresh=m, is_sorted=True)
+ return WSHDescriptor(subdescriptor=multi)
+ elif script_type == 'p2wsh-p2sh':
+ multi = MultisigDescriptor(pubkeys=pubkeys, thresh=m, is_sorted=True)
+ wsh = WSHDescriptor(subdescriptor=multi)
+ return SHDescriptor(subdescriptor=wsh)
+ else:
+ raise NotImplementedError(f"unexpected {script_type=}")
+
+
def parse_descriptor(desc: str) -> 'Descriptor':
"""
Parse a descriptor string into a :class:`Descriptor`.
diff --git a/electrum/gui/qml/components/ConfirmTxDialog.qml b/electrum/gui/qml/components/ConfirmTxDialog.qml
index 78e563830a57..59db532c8ebe 100644
--- a/electrum/gui/qml/components/ConfirmTxDialog.qml
+++ b/electrum/gui/qml/components/ConfirmTxDialog.qml
@@ -134,17 +134,6 @@ ElDialog {
id: feepicker
width: parent.width
finalizer: dialog.finalizer
-
- Label {
- visible: !finalizer.extraFee.isEmpty
- text: qsTr('Extra fee')
- color: Material.accentColor
- }
-
- FormattedAmount {
- visible: !finalizer.extraFee.isEmpty
- amount: finalizer.extraFee
- }
}
}
diff --git a/electrum/gui/qml/components/Constants.qml b/electrum/gui/qml/components/Constants.qml
index ea08a2fac575..b2ada3ffa9a5 100644
--- a/electrum/gui/qml/components/Constants.qml
+++ b/electrum/gui/qml/components/Constants.qml
@@ -70,8 +70,7 @@ Item {
property color colorAddressUsed: Qt.rgba(0.5,0.5,0.5,1)
property color colorAddressUsedWithBalance: Qt.rgba(0.75,0.75,0.75,1)
property color colorAddressFrozen: Qt.rgba(0.5,0.5,1,1)
- property color colorAddressBilling: "#8cb3f2"
- property color colorAddressSwap: colorAddressBilling
+ property color colorAddressSwap: "#8cb3f2"
property color colorAddressAccounting: "#ff9b45"
function colorAlpha(baseColor, alpha) {
diff --git a/electrum/gui/qml/components/CosignDialog.qml b/electrum/gui/qml/components/CosignDialog.qml
new file mode 100644
index 000000000000..44fce8308c32
--- /dev/null
+++ b/electrum/gui/qml/components/CosignDialog.qml
@@ -0,0 +1,124 @@
+import QtQuick
+import QtQuick.Layouts
+import QtQuick.Controls
+import QtQuick.Controls.Material
+
+import org.electrum
+
+import "controls"
+
+ElDialog {
+ id: dialog
+
+ title: qsTr('Sign transaction')
+ iconSource: Qt.resolvedUrl('../../icons/key.png')
+
+ property var summary
+ property string psbt
+ property string password
+
+ anchors.centerIn: parent
+ width: parent.width * 4/5
+ padding: 0
+
+ function signAndBroadcast() {
+ // the password was needed to read the transaction, so we have it already
+ Cosigner.signAndBroadcast(psbt, password)
+ dialog.close()
+ }
+
+ ColumnLayout {
+ width: parent.width
+ spacing: 0
+
+ ColumnLayout {
+ Layout.margins: constants.paddingLarge
+ Layout.fillWidth: true
+
+ Label {
+ Layout.fillWidth: true
+ wrapMode: Text.Wrap
+ text: qsTr('This transaction was created by a wallet that this device signs for.')
+ }
+
+ InfoTextArea {
+ Layout.fillWidth: true
+ Layout.topMargin: constants.paddingMedium
+ visible: summary['warning']
+ iconStyle: InfoTextArea.IconStyle.Warn
+ text: summary['warning'] ? summary['warning'] : ''
+ }
+
+ Label {
+ Layout.topMargin: constants.paddingMedium
+ text: qsTr('Outputs')
+ color: Material.accentColor
+ }
+
+ Repeater {
+ model: summary['outputs']
+ delegate: RowLayout {
+ Layout.fillWidth: true
+ Label {
+ Layout.fillWidth: true
+ text: modelData['is_change']
+ ? modelData['address'] + ' (' + qsTr('change') + ')'
+ : modelData['address']
+ font.family: FixedFont
+ font.pixelSize: constants.fontSizeSmall
+ wrapMode: Text.WrapAnywhere
+ }
+ Label {
+ text: modelData['value']
+ font.family: FixedFont
+ }
+ }
+ }
+
+ GridLayout {
+ Layout.topMargin: constants.paddingMedium
+ Layout.fillWidth: true
+ columns: 2
+
+ Label {
+ text: qsTr('Amount')
+ color: Material.accentColor
+ }
+ Label {
+ Layout.fillWidth: true
+ text: summary['amount']
+ font.family: FixedFont
+ }
+ Label {
+ text: qsTr('Mining fee')
+ color: Material.accentColor
+ }
+ Label {
+ Layout.fillWidth: true
+ text: summary['fee']
+ font.family: FixedFont
+ }
+ }
+ }
+
+ DialogButtonContainer {
+ Layout.fillWidth: true
+
+ FlatButton {
+ Layout.fillWidth: true
+ Layout.preferredWidth: 1
+ text: qsTr('Cancel')
+ icon.source: Qt.resolvedUrl('../../icons/closebutton.png')
+ onClicked: dialog.close()
+ }
+
+ FlatButton {
+ Layout.fillWidth: true
+ Layout.preferredWidth: 1
+ text: qsTr('Sign and broadcast')
+ icon.source: Qt.resolvedUrl('../../icons/key.png')
+ onClicked: dialog.signAndBroadcast()
+ }
+ }
+ }
+}
diff --git a/electrum/gui/qml/components/CosignerHandler.qml b/electrum/gui/qml/components/CosignerHandler.qml
new file mode 100644
index 000000000000..70a5ddc58784
--- /dev/null
+++ b/electrum/gui/qml/components/CosignerHandler.qml
@@ -0,0 +1,242 @@
+import QtQuick
+import QtQuick.Layouts
+import QtQuick.Controls
+
+import org.electrum
+
+import "controls"
+
+// Scanned QR codes that concern the wallets this device signs for without having them.
+Item {
+ id: root
+
+ // the transaction being read, the dialog that says so, and what the dialog that
+ // confirms it needs. Kept until that dialog has it.
+ property var _pending
+
+ // called by the scan dialogs of the app. Returns true if the data was for us.
+ function handleScannedData(data) {
+ if (Cosigner.isCosignerQr(data)) {
+ confirmSetup(data)
+ return true
+ }
+ if (Cosigner.canCosign(data)) {
+ if (Cosigner.canUseAppPassword()) {
+ signTransaction(data, '')
+ } else {
+ // the keys of the wallets we sign for are encrypted
+ var dialog = app.passwordDialog.createObject(app, {
+ infotext: qsTr('Enter the password of this device to read the transaction.')
+ })
+ dialog.passwordEntered.connect(function(password) {
+ if (!Cosigner.verifyPassword(password)) {
+ dialog.clearPassword()
+ dialog.errorMessage = qsTr('Invalid Password')
+ return
+ }
+ dialog.close()
+ signTransaction(data, password)
+ })
+ dialog.open()
+ }
+ return true
+ }
+ return false
+ }
+
+ function scanQrCode() {
+ var scanner = app.scanDialog.createObject(app, {
+ hint: qsTr('Scan a QR code displayed by Electrum desktop')
+ })
+ scanner.onFoundText.connect(function(data) {
+ scanner.close()
+ if (!handleScannedData(data.trim()))
+ showError(qsTr('This is not a cosigner QR code.'))
+ })
+ scanner.open()
+ }
+
+ function confirmSetup(data) {
+ var dialog = app.messageDialog.createObject(app, {
+ title: qsTr('Cosigner'),
+ text: qsTr('Set up this device as the cosigner of your Electrum desktop wallet?'),
+ yesno: true
+ })
+ dialog.accepted.connect(function() {
+ setupCosigner(data)
+ })
+ dialog.open()
+ }
+
+ function setupCosigner(data) {
+ if (Cosigner.canUseAppPassword() || Cosigner.hasWallets()) {
+ // the password of the app encrypts the cosigner key. If it is not
+ // available, Cosigner explains what the user has to do first.
+ Cosigner.setupCosigner(data, '')
+ return
+ }
+ // There is no wallet on this device. If it already holds keys, they use the password
+ // of the app, and the user types that one: letting them choose another password here
+ // would leave this device with two of them. Otherwise, they choose it now.
+ var dialog = Cosigner.hasCosigners()
+ ? app.passwordDialog.createObject(app, {
+ infotext: qsTr('Enter the password of this device to store this key.')
+ })
+ : app.passwordDialog.createObject(app, {
+ confirmPassword: true,
+ infotext: [
+ qsTr('Choose a password for Electrum.'),
+ qsTr('It encrypts the cosigner key, and the wallets you create on this device.')
+ ].join(' ')
+ })
+ dialog.passwordEntered.connect(function(password) {
+ // if this device already holds keys, the password must be the one they use
+ if (!Cosigner.verifyPassword(password)) {
+ dialog.clearPassword()
+ dialog.errorMessage = qsTr('Invalid Password')
+ return
+ }
+ dialog.close()
+ Cosigner.setupCosigner(data, password)
+ })
+ dialog.open()
+ }
+
+ function signTransaction(data, password) {
+ // reading the transaction may have to wait for the network, so the summary
+ // of it comes back with onPsbtLoaded
+ _pending = {
+ psbt: data,
+ password: password ? password : '',
+ busy: readingDialog.createObject(app)
+ }
+ _pending.busy.open()
+ Cosigner.loadPsbt(_pending.psbt, _pending.password)
+ }
+
+ // what signTransaction put aside, if its dialog is not open yet
+ function _takePending() {
+ if (!_pending)
+ return null
+ _pending.busy.stop()
+ var pending = _pending
+ _pending = null
+ return pending
+ }
+
+ function showError(message) {
+ var dialog = app.messageDialog.createObject(app, {
+ title: qsTr('Cosigner'),
+ iconSource: Qt.resolvedUrl('../../icons/warning.png'),
+ text: message
+ })
+ dialog.open()
+ }
+
+ Connections {
+ target: Cosigner
+ function onAuthRequired(method, authMessage) {
+ app.handleAuthRequired(Cosigner, method, authMessage)
+ }
+ function onCosignerAdded() {
+ var dialog = app.messageDialog.createObject(app, {
+ title: qsTr('Cosigner'),
+ text: qsTr('This device is now a cosigner of your desktop wallet.')
+ })
+ dialog.open()
+ }
+ function onSetupFailed(message) {
+ showError(message)
+ }
+ function onPsbtLoaded(summary) {
+ var pending = _takePending()
+ if (!pending) // the dialog that confirms it is already open
+ return
+ var dialog = cosignDialog.createObject(app, {
+ summary: summary,
+ psbt: pending.psbt,
+ password: pending.password
+ })
+ dialog.open()
+ }
+ function onSignFailed(message) {
+ _takePending()
+ showError(message)
+ }
+ function onSignSuccess(txid) {
+ var dialog = app.messageDialog.createObject(app, {
+ title: qsTr('Cosigner'),
+ text: [
+ qsTr('The transaction was signed and broadcast.'),
+ txid
+ ].join('\n\n')
+ })
+ dialog.open()
+ }
+ }
+
+ Component {
+ id: cosignDialog
+ CosignDialog {
+ onClosed: destroy()
+ }
+ }
+
+ // shown while the previous transactions of a transaction are fetched
+ Component {
+ id: readingDialog
+ ElDialog {
+ id: dialog
+
+ title: qsTr('Cosigner')
+ iconSource: Qt.resolvedUrl('../../icons/key.png')
+ allowClose: false
+ resizeWithKeyboard: false
+ needsSystemBarPadding: false
+ x: Math.floor((parent.width - implicitWidth) / 2)
+ y: Math.floor((parent.height - implicitHeight) / 2)
+
+ // only show up if the network keeps us waiting
+ function open() {
+ showTimer.start()
+ }
+
+ function stop() {
+ showTimer.stop()
+ if (visible) {
+ close()
+ } else {
+ // a dialog that was never shown does not get its onClosed callbacks
+ Qt.callLater(function() { dialog.destroy() })
+ }
+ }
+
+ ColumnLayout {
+ width: parent.width
+
+ BusyIndicator {
+ Layout.alignment: Qt.AlignHCenter
+ running: true
+ }
+
+ Label {
+ Layout.alignment: Qt.AlignHCenter
+ text: qsTr('Reading the transaction...')
+ }
+
+ Item {
+ Layout.preferredHeight: 20
+ }
+ }
+
+ Timer {
+ id: showTimer
+ interval: 250
+ repeat: false
+ onTriggered: dialog.visible = true
+ }
+
+ onClosed: destroy()
+ }
+ }
+}
diff --git a/electrum/gui/qml/components/OtpDialog.qml b/electrum/gui/qml/components/OtpDialog.qml
deleted file mode 100644
index 39b045f59954..000000000000
--- a/electrum/gui/qml/components/OtpDialog.qml
+++ /dev/null
@@ -1,89 +0,0 @@
-import QtQuick
-import QtQuick.Layouts
-import QtQuick.Controls
-import QtQuick.Controls.Material
-
-import org.electrum 1.0
-
-import "controls"
-
-ElDialog {
- id: dialog
-
- title: qsTr('Trustedcoin')
- iconSource: Qt.resolvedUrl('../../../plugins/trustedcoin/trustedcoin-status.png')
-
- property string otpauth
-
- property bool _waiting: false
- property string _otpError
- property string passwordCharacter: '8'
- focus: true
-
- ColumnLayout {
- width: parent.width
-
- Label {
- text: qsTr('Enter Authenticator code')
- font.pixelSize: constants.fontSizeLarge
- Layout.alignment: Qt.AlignHCenter
- }
-
- TextField {
- id: otpEdit
- Layout.preferredWidth: leftPadding + rightPadding + fontMetrics.advanceWidth(passwordCharacter) * 6
- Layout.alignment: Qt.AlignHCenter
- font.pixelSize: constants.fontSizeXXLarge
- maximumLength: 6
- inputMethodHints: Qt.ImhSensitiveData | Qt.ImhDigitsOnly
- echoMode: TextInput.Password
- focus: true
- enabled: !_waiting
- Keys.onPressed: _otpError = ''
- onTextChanged: {
- if (text.length == 6) {
- _waiting = true
- Daemon.currentWallet.submitOtp(otpEdit.text)
- }
- }
- }
-
- Label {
- Layout.topMargin: constants.paddingMedium
- Layout.bottomMargin: constants.paddingMedium
- Layout.fillWidth: true
- wrapMode: Text.Wrap
- horizontalAlignment: Text.AlignHCenter
-
- text: _otpError
- color: constants.colorError
-
- BusyIndicator {
- anchors.centerIn: parent
- width: constants.iconSizeXLarge
- height: constants.iconSizeXLarge
- visible: _waiting
- running: _waiting
- }
- }
- }
-
- Connections {
- target: Daemon.currentWallet
- function onOtpSuccess() {
- _waiting = false
- otpauth = otpEdit.text
- dialog.accept()
- }
- function onOtpFailed(code, message) {
- _waiting = false
- _otpError = message
- otpEdit.text = ''
- }
- }
-
- FontMetrics {
- id: fontMetrics
- font: otpEdit.font
- }
-}
diff --git a/electrum/gui/qml/components/SendDialog.qml b/electrum/gui/qml/components/SendDialog.qml
index 6244823f8860..5ae3d9f00386 100644
--- a/electrum/gui/qml/components/SendDialog.qml
+++ b/electrum/gui/qml/components/SendDialog.qml
@@ -32,7 +32,9 @@ ElDialog {
function dispatch(data) {
data = data.trim()
- if (bitcoin.isRawTx(data)) {
+ if (app.cosignerHandler.handleScannedData(data)) {
+ // a key for this device to sign with, or a transaction to sign
+ } else if (bitcoin.isRawTx(data)) {
txFound(data)
} else if (Daemon.currentWallet.isValidChannelBackup(data)) {
channelBackupFound(data)
diff --git a/electrum/gui/qml/components/TxDetails.qml b/electrum/gui/qml/components/TxDetails.qml
index 0a5d217be9ce..b353d47ccb24 100644
--- a/electrum/gui/qml/components/TxDetails.qml
+++ b/electrum/gui/qml/components/TxDetails.qml
@@ -447,7 +447,7 @@ Pane {
}
} else if (txdetails.wallet.isWatchOnly) {
msg = qsTr('This transaction should be signed. Present this QR code to the signing device')
- } else if (txdetails.wallet.isMultisig && txdetails.wallet.walletType != '2fa') {
+ } else if (txdetails.wallet.isMultisig) {
if (txdetails.canSign) {
msg = qsTr('Note: this wallet can sign, but has not signed this transaction yet')
} else {
diff --git a/electrum/gui/qml/components/WalletDetails.qml b/electrum/gui/qml/components/WalletDetails.qml
index eaacb621be2b..71edf066bdef 100644
--- a/electrum/gui/qml/components/WalletDetails.qml
+++ b/electrum/gui/qml/components/WalletDetails.qml
@@ -299,70 +299,11 @@ Pane {
Label {
Layout.fillWidth: true
visible: _is2fa
- text: Daemon.currentWallet.canSignWithoutServer
- ? qsTr('disabled (can sign without server)')
+ text: Daemon.currentWallet.canSignWithoutCosigner
+ ? qsTr('disabled (can sign without cosigner)')
: qsTr('enabled')
}
- Label {
- visible: _is2fa && !Daemon.currentWallet.canSignWithoutServer
- text: qsTr('Remaining TX')
- color: Material.accentColor
- }
-
- Label {
- Layout.fillWidth: true
- visible: _is2fa && !Daemon.currentWallet.canSignWithoutServer
- text: 'tx_remaining' in Daemon.currentWallet.billingInfo
- ? Daemon.currentWallet.billingInfo['tx_remaining']
- : qsTr('unknown')
- }
-
- Label {
- Layout.columnSpan: 2
- Layout.topMargin: constants.paddingSmall
- visible: _is2fa && !Daemon.currentWallet.canSignWithoutServer
- text: qsTr('Billing')
- color: Material.accentColor
- }
-
- TextHighlightPane {
- Layout.columnSpan: 2
- Layout.fillWidth: true
- visible: _is2fa && !Daemon.currentWallet.canSignWithoutServer
-
- ColumnLayout {
- spacing: 0
-
- ButtonGroup {
- id: billinggroup
- onCheckedButtonChanged: {
- Config.trustedcoinPrepay = checkedButton.value
- }
- }
-
- Repeater {
- model: AppController.plugin('trustedcoin').billingModel
- delegate: RowLayout {
- RadioButton {
- ButtonGroup.group: billinggroup
- property string value: modelData.value
- text: modelData.text
- checked: modelData.value == Config.trustedcoinPrepay
- }
- Label {
- text: Config.formatSats(modelData.sats_per_tx)
- font.family: FixedFont
- }
- Label {
- text: Config.baseUnit + '/tx'
- color: Material.accentColor
- }
- }
- }
- }
- }
-
Repeater {
id: keystores
model: Daemon.currentWallet.keystores
diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml
index 55f73519db45..d86567f8970a 100644
--- a/electrum/gui/qml/components/WalletMainView.qml
+++ b/electrum/gui/qml/components/WalletMainView.qml
@@ -49,7 +49,9 @@ Item {
})
scanner.onFoundText.connect(function(data) {
data = data.trim()
- if (bitcoin.isRawTx(data)) {
+ if (app.cosignerHandler.handleScannedData(data)) {
+ // a key for this device to sign with, or a transaction to sign
+ } else if (bitcoin.isRawTx(data)) {
app.stack.push(Qt.resolvedUrl('TxDetails.qml'), { rawtx: data })
} else if (Daemon.currentWallet.isValidChannelBackup(data)) {
var dialog = app.messageDialog.createObject(app, {
@@ -479,11 +481,6 @@ Item {
})
dialog.open()
}
- function onOtpRequested() {
- console.log('OTP requested')
- var dialog = otpDialog.createObject(mainView)
- dialog.open()
- }
function onBroadcastFailed(txid, code, message) {
var dialog = app.messageDialog.createObject(app, {
title: qsTr('Error'),
@@ -748,16 +745,6 @@ Item {
}
}
- Component {
- id: otpDialog
- OtpDialog {
- width: parent.width * 2/3
- anchors.centerIn: parent
-
- onClosed: destroy()
- }
- }
-
Component {
id: exportTxDialog
ExportTxDialog {
diff --git a/electrum/gui/qml/components/controls/TxOutput.qml b/electrum/gui/qml/components/controls/TxOutput.qml
index f9309c0aceab..6f86a97caa5c 100644
--- a/electrum/gui/qml/components/controls/TxOutput.qml
+++ b/electrum/gui/qml/components/controls/TxOutput.qml
@@ -21,9 +21,7 @@ TextHighlightPane {
: qsTr('mine')
: model.is_swap
? qsTr('swap')
- : model.is_billing
- ? qsTr('billing')
- : ""
+ : ""
RowLayout {
width: parent.width
@@ -81,13 +79,11 @@ TextHighlightPane {
? model.is_change
? constants.colorAddressInternal
: constants.colorAddressExternal
- : model.is_billing
- ? constants.colorAddressBilling
- : model.is_swap
- ? constants.colorAddressSwap
- : model.is_accounting
- ? constants.colorAddressAccounting
- : Material.foreground
+ : model.is_swap
+ ? constants.colorAddressSwap
+ : model.is_accounting
+ ? constants.colorAddressAccounting
+ : Material.foreground
TapHandler {
enabled: allowClickAddress && model.is_mine
onTapped: {
diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml
index 4bbcef5b82e3..a974494f7bf5 100644
--- a/electrum/gui/qml/components/main.qml
+++ b/electrum/gui/qml/components/main.qml
@@ -529,6 +529,11 @@ ApplicationWindow
width: parent.width
}
+ property alias cosignerHandler: _cosignerHandler
+ CosignerHandler {
+ id: _cosignerHandler
+ }
+
Component.onCompleted: {
coverTimer.start()
@@ -645,7 +650,7 @@ ApplicationWindow
let qtobject = app._pendingBiometricAuth.qtobject
let method = app._pendingBiometricAuth.method
- if (Daemon.currentWallet.verifyPassword(password)) {
+ if (app.verifyPassword(password)) {
qtobject.authProceed()
} else {
console.warn("Biometric password invalid falling back to manual input")
@@ -844,6 +849,14 @@ ApplicationWindow
}
}
+ // the password of this device. The wallets use it, and so do the keys of the wallets
+ // this device cosigns for, which can be here when no wallet is open.
+ function verifyPassword(password) {
+ return Daemon.currentWallet
+ ? Daemon.currentWallet.verifyPassword(password)
+ : Cosigner.verifyPassword(password)
+ }
+
function handleAuthRequired(qtobject, method, authMessage) {
console.log('auth using method ' + method)
@@ -857,8 +870,8 @@ ApplicationWindow
}
}
- if (Daemon.currentWallet.verifyPassword('')) {
- // wallet has no password
+ if (app.verifyPassword('')) {
+ // nothing here is protected by a password
qtobject.authProceed()
return
}
@@ -885,7 +898,7 @@ ApplicationWindow
if (method === 'wallet' || method === 'wallet_password_only') {
var dialog = app.passwordDialog.createObject(app, authMessage ? {'title': authMessage} : {})
dialog.passwordEntered.connect(function(password) {
- if (Daemon.currentWallet.verifyPassword(password)) {
+ if (app.verifyPassword(password)) {
dialog.close()
qtobject.authProceed()
} else {
diff --git a/electrum/gui/qml/components/wizard/WCWalletName.qml b/electrum/gui/qml/components/wizard/WCWalletName.qml
index 1020b03cd4e5..2b37a383ff8d 100644
--- a/electrum/gui/qml/components/wizard/WCWalletName.qml
+++ b/electrum/gui/qml/components/wizard/WCWalletName.qml
@@ -4,6 +4,8 @@ import QtQuick.Controls
import org.electrum 1.0
+import "../controls"
+
WizardComponent {
valid: Daemon.isValidWalletName(wallet_name.text)
@@ -25,6 +27,16 @@ WizardComponent {
text: Daemon.suggestWalletName()
inputMethodHints: Qt.ImhNoPredictiveText
}
+
+ // this device can be set up as cosigner of another wallet, without having one
+ FlatButton {
+ Layout.fillWidth: true
+ Layout.topMargin: constants.paddingLarge
+ text: qsTr('Scan QR code')
+ icon.source: Qt.resolvedUrl('../../../icons/qrcode.png')
+ visible: !Cosigner.hasWallets()
+ onClicked: app.cosignerHandler.scanQrCode()
+ }
}
Component.onCompleted: {
diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py
index 35b51f7f82d7..fbaf3c20c34c 100644
--- a/electrum/gui/qml/qeapp.py
+++ b/electrum/gui/qml/qeapp.py
@@ -26,6 +26,7 @@
from electrum.lnurl import SUPPORTED_LNURL_SCHEMES
from .qeconfig import QEConfig
+from .qecosigner import QECosigner
from .qedaemon import QEDaemon
from .qenetwork import QENetwork
from .qewallet import QEWallet
@@ -540,6 +541,7 @@ def __init__(self, args, *, config: 'SimpleConfig', daemon: 'Daemon', plugins: '
self.config = QEConfig(config)
self.network = QENetwork(daemon.network)
self.daemon = QEDaemon(daemon, self.plugins)
+ self.cosigner = QECosigner(config, parent=self)
self.appController = QEAppController(self, self.plugins)
self.maxAmount = QEAmount(is_max=True)
self.biometrics = QEBiometrics(config=config, parent=self)
@@ -547,6 +549,7 @@ def __init__(self, args, *, config: 'SimpleConfig', daemon: 'Daemon', plugins: '
self.context.setContextProperty('Config', self.config)
self.context.setContextProperty('Network', self.network)
self.context.setContextProperty('Daemon', self.daemon)
+ self.context.setContextProperty('Cosigner', self.cosigner)
self.context.setContextProperty('FixedFont', self.fixedFont)
self.context.setContextProperty('MAX', self.maxAmount)
self.context.setContextProperty('QRIP', self.qr_ip_h)
diff --git a/electrum/gui/qml/qeconfig.py b/electrum/gui/qml/qeconfig.py
index d4ca6b9d5aa0..a7506de8b5fc 100644
--- a/electrum/gui/qml/qeconfig.py
+++ b/electrum/gui/qml/qeconfig.py
@@ -226,17 +226,6 @@ def useRecoverableChannels(self, useRecoverableChannels):
self.config.LIGHTNING_USE_RECOVERABLE_CHANNELS = useRecoverableChannels
self.useRecoverableChannelsChanged.emit()
- trustedcoinPrepayChanged = pyqtSignal()
- @pyqtProperty(int, notify=trustedcoinPrepayChanged)
- def trustedcoinPrepay(self):
- return self.config.PLUGIN_TRUSTEDCOIN_NUM_PREPAY
-
- @trustedcoinPrepay.setter
- def trustedcoinPrepay(self, num_prepay):
- if num_prepay != self.config.PLUGIN_TRUSTEDCOIN_NUM_PREPAY:
- self.config.PLUGIN_TRUSTEDCOIN_NUM_PREPAY = num_prepay
- self.trustedcoinPrepayChanged.emit()
-
preferredRequestTypeChanged = pyqtSignal()
@pyqtProperty(str, notify=preferredRequestTypeChanged)
def preferredRequestType(self):
diff --git a/electrum/gui/qml/qecosigner.py b/electrum/gui/qml/qecosigner.py
new file mode 100644
index 000000000000..c81f9f98de55
--- /dev/null
+++ b/electrum/gui/qml/qecosigner.py
@@ -0,0 +1,247 @@
+import threading
+from typing import Optional, TYPE_CHECKING
+
+from PyQt6.QtCore import pyqtSignal, pyqtSlot, QModelIndex, QObject
+
+from electrum.cosigner import (Cosigner, add_cosigner, check_cosigners_password, find_cosigner_for_tx,
+ find_cosigner_id_for_tx, get_cosigner_ids)
+from electrum.i18n import _
+from electrum.logging import get_logger
+from electrum.network import Network, NetworkException, TxBroadcastError, BestEffortRequestFailed
+from electrum.transaction import PartialTransaction, tx_from_any
+from electrum.util import InvalidPassword
+
+from .auth import AuthMixin, auth_protect
+from .qedaemon import QEDaemon
+
+if TYPE_CHECKING:
+ from electrum.simple_config import SimpleConfig
+
+
+class QECosigner(AuthMixin, QObject):
+ """Signs for the wallets of the 'keystores' file, which this device does not have."""
+
+ _logger = get_logger(__name__)
+
+ cosignerAdded = pyqtSignal()
+ setupFailed = pyqtSignal([str], arguments=['message'])
+ psbtLoaded = pyqtSignal(['QVariantMap'], arguments=['summary'])
+ signFailed = pyqtSignal([str], arguments=['message'])
+ signSuccess = pyqtSignal([str], arguments=['txid'])
+
+ def __init__(self, config: 'SimpleConfig', parent=None):
+ super().__init__(parent)
+ self._config = config
+
+ @pyqtSlot(str, result=bool)
+ def isCosignerQr(self, data: str) -> bool:
+ """Whether that QR code holds a key for this device to sign with."""
+ try:
+ Cosigner.from_qr_data(data)
+ except ValueError:
+ return False
+ return True
+
+ @pyqtSlot(str, result=bool)
+ def canCosign(self, data: str) -> bool:
+ """Whether that QR code holds a transaction this device signs for. The keys are
+ indexed by the fingerprint they use in transactions, so this needs no password."""
+ tx = self._parse_tx(data)
+ return tx is not None and find_cosigner_id_for_tx(self._config, tx) is not None
+
+ @pyqtSlot(result=bool)
+ def canUseAppPassword(self) -> bool:
+ """Whether the password of this device is known, and can encrypt the key."""
+ qedaemon = QEDaemon.instance
+ return bool(qedaemon.singlePasswordEnabled and qedaemon.singlePassword)
+
+ @pyqtSlot(result=bool)
+ def hasWallets(self) -> bool:
+ return bool(QEDaemon.instance.availableWallets.rowCount(QModelIndex()))
+
+ @pyqtSlot(result=bool)
+ def hasCosigners(self) -> bool:
+ """Whether this device already holds keys, which use the password of the app."""
+ return bool(get_cosigner_ids(self._config))
+
+ @pyqtSlot(str, result=bool)
+ def verifyPassword(self, password: str) -> bool:
+ """Whether that password is the one of this device. The gui authenticates against
+ us when no wallet is open, as the keys of a cosigner can be here without any wallet.
+ """
+ if single_password := QEDaemon.instance.singlePassword:
+ return password == single_password
+ try:
+ check_cosigners_password(self._config, password)
+ except InvalidPassword:
+ return False
+ return True
+
+ @pyqtSlot(str, str)
+ def setupCosigner(self, data: str, password: str):
+ """Stores the key displayed by the QR code of the other device."""
+ qedaemon = QEDaemon.instance
+ try:
+ cosigner = Cosigner.from_qr_data(data)
+ except ValueError as e:
+ self._logger.info(f'invalid cosigner QR code: {e}')
+ self.setupFailed.emit(_('This is not a cosigner QR code.'))
+ return
+ if self.canUseAppPassword():
+ password = qedaemon.singlePassword
+ elif self.hasWallets():
+ self.setupFailed.emit(' '.join([
+ _('Electrum needs the password of this device in order to encrypt the cosigner key.'),
+ _('Please open one of your wallets first. If your wallets use different passwords, '
+ 'change them so that they all use the same password.'),
+ ]))
+ return
+ elif not password:
+ self.setupFailed.emit(_('A password is required.'))
+ return
+ else:
+ # there is no wallet on this device. If it already holds keys, they use the
+ # password of the app, and the user has to type that one: a second password
+ # would leave the daemon unable to re-encrypt them all when it changes.
+ try:
+ check_cosigners_password(self._config, password)
+ except InvalidPassword:
+ self.setupFailed.emit(_('Invalid password'))
+ return
+ self._add_cosigner(cosigner, password)
+
+ @auth_protect(method='wallet', message=_('Set up this device as cosigner?'))
+ def _add_cosigner(self, cosigner: Cosigner, password: str) -> None:
+ if not self.hasWallets():
+ # there is no wallet on this device yet: this password becomes the password of the app
+ QEDaemon.instance.setSinglePassword(password)
+ add_cosigner(self._config, cosigner, password)
+ self.cosignerAdded.emit()
+
+ def _add_info_to_tx(self, tx: PartialTransaction, cosigner: Cosigner) -> None:
+ """Completes the transaction with what the QR code could not carry, and checks
+ what it claims. Raises if it cannot be verified."""
+ if tx.is_missing_info_from_network():
+ # QR codes do not contain the previous transactions
+ Network.run_from_another_thread(tx.add_info_from_network(
+ Network.get_instance(), ignore_network_issues=False, timeout=10))
+ cosigner.add_wallet_info_to_tx(tx)
+
+ @pyqtSlot(str, str)
+ def loadPsbt(self, data: str, password: str):
+ """Describes the transaction to be signed, for the confirmation dialog. The
+ previous transactions are fetched from the network, so this does not run on the
+ gui thread: the summary comes back with psbtLoaded, a failure with signFailed.
+ """
+ def load_task():
+ try:
+ summary = self._load_psbt(data, password)
+ except Exception as e:
+ # the gui waits for one of our signals before it lets the user go on
+ self._logger.exception('could not read transaction')
+ summary = {'error': repr(e)}
+ if error := summary.get('error'):
+ self.signFailed.emit(error)
+ else:
+ self.psbtLoaded.emit(summary)
+
+ threading.Thread(target=load_task, daemon=True).start()
+
+ def _load_psbt(self, data: str, password: str) -> dict:
+ config = self._config
+ tx = self._parse_tx(data)
+ if tx is None:
+ return {'error': _('This is not a transaction QR code.')}
+ try:
+ cosigner = find_cosigner_for_tx(config, tx, password or QEDaemon.instance.singlePassword)
+ except InvalidPassword:
+ return {'error': _('Invalid password')}
+ if cosigner is None:
+ return {'error': _('This transaction belongs to no wallet that this device signs for.')}
+ try:
+ self._add_info_to_tx(tx, cosigner)
+ except NetworkException as e:
+ self._logger.info(f'could not fetch previous transactions: {e}')
+ return {'error': _('Could not fetch the previous transactions from the network.')}
+ except Exception as e:
+ self._logger.info(f'could not verify transaction: {e}')
+ return {'error': _('This transaction could not be verified.')}
+ outputs = []
+ amount = 0
+ claims_our_key = False
+ high_index = False
+ for txout in tx.outputs():
+ is_change = cosigner.is_wallet_output(txout)
+ if is_change:
+ high_index = high_index or cosigner.is_high_derivation_index(txout)
+ else:
+ amount += txout.value
+ claims_our_key = claims_our_key or cosigner.claims_our_key(txout)
+ outputs.append({
+ 'address': txout.get_ui_address_str(),
+ 'value': config.format_amount_and_units(txout.value),
+ 'is_change': is_change,
+ })
+ warnings = []
+ if claims_our_key:
+ warnings.append(_('An output of this transaction falsely claims to belong to your wallet.'))
+ if high_index:
+ warnings.append(_('The change of this transaction is sent to an address that your wallet may never find.'))
+ return {
+ 'outputs': outputs,
+ 'amount': config.format_amount_and_units(amount),
+ 'fee': config.format_amount_and_units(tx.get_fee()),
+ 'warning': '\n'.join(warnings),
+ }
+
+ @pyqtSlot(str, str)
+ def signAndBroadcast(self, data: str, password: str):
+ self._sign_and_broadcast(data, password)
+
+ @auth_protect(method='wallet', message=_('Sign and broadcast this transaction?'))
+ def _sign_and_broadcast(self, data: str, password: str) -> None:
+ def sign_task():
+ try:
+ tx = self._parse_tx(data)
+ cosigner = find_cosigner_for_tx(
+ self._config, tx, password or QEDaemon.instance.singlePassword)
+ self._add_info_to_tx(tx, cosigner)
+ cosigner.sign_transaction(tx)
+ except InvalidPassword:
+ self.signFailed.emit(_('Invalid password'))
+ return
+ except NetworkException as e:
+ self._logger.info(f'could not fetch previous transactions: {e}')
+ self.signFailed.emit(_('Could not fetch the previous transactions from the network.'))
+ return
+ except Exception as e:
+ self._logger.exception('could not sign transaction')
+ self.signFailed.emit(repr(e))
+ return
+ if not tx.is_complete():
+ self.signFailed.emit(_('Could not sign transaction'))
+ return
+ self._broadcast(tx)
+
+ threading.Thread(target=sign_task, daemon=True).start()
+
+ def _parse_tx(self, data: str) -> Optional[PartialTransaction]:
+ try:
+ tx = tx_from_any(data)
+ except Exception:
+ return None
+ return tx if isinstance(tx, PartialTransaction) else None
+
+ def _broadcast(self, tx: PartialTransaction) -> None:
+ network = Network.get_instance()
+ if not network:
+ self.signFailed.emit(_('You are offline.'))
+ return
+ try:
+ Network.run_from_another_thread(network.broadcast_transaction(tx))
+ except TxBroadcastError as e:
+ self.signFailed.emit(e.get_message_for_gui())
+ except BestEffortRequestFailed as e:
+ self.signFailed.emit(repr(e))
+ else:
+ self.signSuccess.emit(tx.txid())
diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py
index b0779c100dc4..7177ed19a339 100644
--- a/electrum/gui/qml/qedaemon.py
+++ b/electrum/gui/qml/qedaemon.py
@@ -442,6 +442,15 @@ def singlePassword(self, password: str):
self._password = password
self.singlePasswordChanged.emit()
+ def setSinglePassword(self, password: str) -> None:
+ """Set the password of this device. Only for when there is no wallet yet,
+ so that wallets created later use that password too."""
+ assert password
+ self.daemon.config.WALLET_SHOULD_USE_SINGLE_PASSWORD = True
+ self._use_single_password = True
+ self._password = password
+ self.singlePasswordChanged.emit()
+
@pyqtSlot(result=str)
def suggestWalletName(self):
# FIXME why not use util.get_new_wallet_name ?
@@ -470,7 +479,13 @@ def setPassword(self, password):
def _update_password_for_directory_and_unlock_wallets(self, *, old_password, new_password):
# note: this assumes all wallet files are in a single directory.
# change wallet passwords:
- ret = self.daemon.update_password_for_directory(old_password=old_password, new_password=new_password)
+ try:
+ ret = self.daemon.update_password_for_directory(old_password=old_password, new_password=new_password)
+ except InvalidPassword:
+ # the keys of a cosigner cannot be read with that password, and nothing was
+ # changed. This must not keep the user from opening their wallets.
+ self._logger.warning('cosigner keys do not use the password of this device')
+ ret = False
# If some wallets just had their password changed, they got "locked" by wallet.update_password().
# If the password is not unified yet, other loaded wallets might still be unlocked.
# restore the invariant that all loaded wallets in qml must be unlocked:
diff --git a/electrum/gui/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py
index 65b4cda0b838..7cd1de3c00b8 100644
--- a/electrum/gui/qml/qeinvoice.py
+++ b/electrum/gui/qml/qeinvoice.py
@@ -449,8 +449,7 @@ def calc_max(address):
make_tx = lambda fee_policy, *, confirmed_only=False: self._wallet.wallet.make_unsigned_transaction(
coins=self._wallet.wallet.get_spendable_coins(None),
outputs=outputs,
- fee_policy=fee_policy,
- is_sweep=False)
+ fee_policy=fee_policy)
amount, message = self._wallet.determine_max(mktx=make_tx)
if amount is None:
self._amountOverride.isMax = False
diff --git a/electrum/gui/qml/qetxdetails.py b/electrum/gui/qml/qetxdetails.py
index a5e4dabe03fd..9a066aed9f4f 100644
--- a/electrum/gui/qml/qetxdetails.py
+++ b/electrum/gui/qml/qetxdetails.py
@@ -325,7 +325,6 @@ def update(self, from_txid: bool = False):
'short_id': '', # TODO
'is_mine': self._wallet.wallet.is_mine(x.get_ui_address_str()),
'is_change': self._wallet.wallet.is_change(x.get_ui_address_str()),
- 'is_billing': self._wallet.wallet.is_billing_address(x.get_ui_address_str()),
'is_swap': False if not sm else sm.is_lockup_address_for_a_swap(x.get_ui_address_str()) or x.get_ui_address_str() == DummyAddress.SWAP,
'is_accounting': self._wallet.wallet.is_accounting_address(x.get_ui_address_str())
}, self._tx.outputs()))
diff --git a/electrum/gui/qml/qetxfinalizer.py b/electrum/gui/qml/qetxfinalizer.py
index 3480d759e43b..eddc0547da21 100644
--- a/electrum/gui/qml/qetxfinalizer.py
+++ b/electrum/gui/qml/qetxfinalizer.py
@@ -16,7 +16,6 @@
)
from electrum.wallet import CannotBumpFee, CannotDoubleSpendTx, CannotCPFP, BumpFeeStrategy, sweep_preparations
from electrum import keystore
-from electrum.plugin import run_hook
from electrum.fee_policy import FeePolicy, FeeMethod
from electrum.network import NetworkException
@@ -360,7 +359,6 @@ def update_outputs_from_tx(self, tx: PartialTransaction):
'short_id': str(TxOutpoint(bytes.fromhex(tx.txid()), idx).short_name()) if tx.txid() else '',
'is_mine': self._wallet.wallet.is_mine(o.get_ui_address_str()),
'is_change': self._wallet.wallet.is_change(o.get_ui_address_str()),
- 'is_billing': self._wallet.wallet.is_billing_address(o.get_ui_address_str()),
'is_swap': False if not sm else sm.is_lockup_address_for_a_swap(o.get_ui_address_str()) or o.get_ui_address_str() == DummyAddress.SWAP,
'is_accounting': self._wallet.wallet.is_accounting_address(o.get_ui_address_str()),
'is_reserve': o.is_utxo_reserve
@@ -409,7 +407,6 @@ def __init__(
self._address = ''
self._amount = QEAmount()
self._effectiveAmount = QEAmount()
- self._extraFee = QEAmount()
self._canRbf = False
addressChanged = pyqtSignal()
@@ -441,18 +438,6 @@ def amount(self, amount: QEAmount):
def effectiveAmount(self):
return self._effectiveAmount
- extraFeeChanged = pyqtSignal()
- @pyqtProperty(QVariant, notify=extraFeeChanged)
- def extraFee(self) -> QEAmount:
- return self._extraFee
-
- @extraFee.setter
- def extraFee(self, extrafee: QEAmount):
- assert extrafee is None or isinstance(extrafee, QEAmount)
- if self._extraFee != extrafee:
- self._extraFee.copyFrom(extrafee)
- self.extraFeeChanged.emit()
-
canRbfChanged = pyqtSignal()
@pyqtProperty(bool, notify=canRbfChanged)
def canRbf(self):
@@ -520,11 +505,6 @@ def update(self):
self.update_from_tx(tx)
- x_fee = run_hook('get_tx_extra_fee', self._wallet.wallet, tx)
- if x_fee:
- x_fee_address, x_fee_amount = x_fee
- self.extraFee = QEAmount(amount_sat=x_fee_amount)
-
self.update_fee_warning_from_tx(tx=tx, invoice_amt=amount)
if self._amount.isMax and not self.warning:
@@ -1152,7 +1132,7 @@ def make_sweep_tx(self):
outputs = [PartialTxOutput.from_address_and_value(address, value='!')]
tx = self._wallet.wallet.make_unsigned_transaction(
- coins=coins, outputs=outputs, fee_policy=self._fee_policy, rbf=self._rbf, is_sweep=True)
+ coins=coins, outputs=outputs, fee_policy=self._fee_policy, rbf=self._rbf)
self._logger.debug('fee: %d, inputs: %d, outputs: %d' % (tx.get_fee(), len(tx.inputs()), len(tx.outputs())))
tx.sign(keypairs)
diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py
index ed3ecf3fbf13..6630dce739f0 100644
--- a/electrum/gui/qml/qewallet.py
+++ b/electrum/gui/qml/qewallet.py
@@ -1,10 +1,8 @@
import asyncio
import base64
import queue
-import threading
import time
from typing import TYPE_CHECKING, Callable, Optional, Any, Tuple
-from functools import partial
from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QTimer
@@ -18,7 +16,6 @@
UserFacingException,
)
from electrum.lnutil import MIN_FUNDING_SAT
-from electrum.plugin import run_hook
from electrum.wallet import Multisig_Wallet
from electrum.crypto import pw_decode_with_version_and_mac
from electrum.fee_policy import FeePolicy, FixedFeePolicy
@@ -76,9 +73,6 @@ def getInstanceFor(cls, wallet):
saveTxSuccess = pyqtSignal([str], arguments=['txid'])
saveTxError = pyqtSignal([str, str, str], arguments=['txid', 'code', 'message'])
importChannelBackupFailed = pyqtSignal([str], arguments=['message'])
- otpRequested = pyqtSignal()
- otpSuccess = pyqtSignal()
- otpFailed = pyqtSignal([str, str], arguments=['code', 'message'])
peersUpdated = pyqtSignal()
seedRetrieved = pyqtSignal()
messageSigned = pyqtSignal([str], arguments=['signature'])
@@ -114,8 +108,6 @@ def __init__(self, wallet: 'Abstract_Wallet', parent=None):
self._seed = ''
self._seed_passphrase = ''
- self._otp_on_submit = None # type: Callable[[str], None]
-
self.tx_notification_queue = queue.Queue()
self.tx_notification_last_time = 0
@@ -351,13 +343,6 @@ def name(self):
def isLightning(self):
return bool(self.wallet.lnworker)
- billingInfoChanged = pyqtSignal()
- @pyqtProperty('QVariantMap', notify=billingInfoChanged)
- def billingInfo(self):
- if self.wallet.wallet_type != '2fa':
- return {}
- return self.wallet.billing_info if self.wallet.billing_info is not None else {}
-
@pyqtProperty(bool, notify=dataChanged)
def canHaveLightning(self):
return self.wallet.can_have_lightning()
@@ -446,15 +431,11 @@ def derivationPrefix(self):
def masterPubkey(self):
return self.wallet.get_master_public_key()
- @pyqtProperty(bool, notify=dataChanged)
- def canSignWithoutServer(self):
- return self.wallet.can_sign_without_server() if self.wallet.wallet_type == '2fa' else True
-
@pyqtProperty(bool, notify=dataChanged)
def canSignWithoutCosigner(self):
if isinstance(self.wallet, Multisig_Wallet):
- if self.wallet.wallet_type == '2fa': # 2fa is multisig, but it handles cosigning itself
- return True
+ if self.wallet.wallet_type == '2fa':
+ return self.wallet.can_sign_without_cosigner()
return self.wallet.m == 1
return True
@@ -559,10 +540,6 @@ def sign(self, tx, *,
self.do_sign(tx, False, on_success, on_failure)
def do_sign(self, tx, broadcast, on_success: Callable[[Transaction], None] = None, on_failure: Callable[[Optional[Any]], None] = None):
- # tc_sign_wrapper is only used by 2fa. don't pass on_failure handler, it is handled via otpFailed signal
- sign_hook = run_hook('tc_sign_wrapper', self.wallet, tx,
- partial(self.on_sign_complete, broadcast, on_success),
- partial(self.on_sign_failed, None))
try:
# ignore_warnings=True, because UI checks and asks user confirmation itself
tx = self.wallet.sign_transaction(tx, self.password, ignore_warnings=True)
@@ -578,11 +555,6 @@ def do_sign(self, tx, broadcast, on_success: Callable[[Transaction], None] = Non
on_failure()
return
- if sign_hook:
- self._logger.debug('plugin needs to sign tx too')
- sign_hook(tx)
- return
-
txid = tx.txid()
self._logger.debug(f'do_sign(), txid={txid}')
@@ -599,30 +571,6 @@ def do_sign(self, tx, broadcast, on_success: Callable[[Transaction], None] = Non
if on_success:
on_success(tx)
- # this assumes a 2fa wallet, but there are no other tc_sign_wrapper hooks, so that's ok
- def on_sign_complete(self, broadcast, cb: Callable[[Transaction], None] = None, tx: Transaction = None):
- self.otpSuccess.emit()
- if cb:
- cb(tx)
- if broadcast:
- self.broadcast(tx)
-
- # this assumes a 2fa wallet, but there are no other tc_sign_wrapper hooks, so that's ok
- def on_sign_failed(self, cb: Callable[[], None] | None = None, error: str | None = None):
- self.otpFailed.emit('error', error)
- if cb:
- cb()
-
- def request_otp(self, on_submit: Callable[[str], None]):
- self._otp_on_submit = on_submit
- self.otpRequested.emit()
-
- @pyqtSlot(str)
- def submitOtp(self, otp):
- def submit_otp_task():
- self._otp_on_submit(otp)
- threading.Thread(target=submit_otp_task, daemon=True).start()
-
def broadcast(self, tx):
assert tx.is_complete()
diff --git a/electrum/gui/qt/__init__.py b/electrum/gui/qt/__init__.py
index 6175cc1b1027..3ce4e09d381d 100644
--- a/electrum/gui/qt/__init__.py
+++ b/electrum/gui/qt/__init__.py
@@ -72,12 +72,9 @@
WalletFileException, get_new_wallet_name, InvalidPassword,
standardize_path, UserFacingException)
from electrum.wallet import Wallet, Abstract_Wallet
-from electrum.wallet_db import WalletRequiresSplit, WalletRequiresUpgrade, WalletUnfinished
+from electrum.wallet_db import WalletRequiresSplit, WalletRequiresUpgrade
from electrum.gui import BaseElectrumGui
from electrum.simple_config import SimpleConfig
-from electrum.wizard import WizardViewState
-from electrum.keystore import load_keystore
-from electrum.bip32 import is_xprv
from electrum import constants
from electrum.gui.common_qt.i18n import ElectrumTranslator
@@ -425,8 +422,6 @@ def __handle_wallet_loading_exc(exc: Exception, pos):
pass # open with wizard below
except WalletRequiresUpgrade:
pass # open with wizard below
- except WalletUnfinished:
- pass # open with wizard below
except Exception as e:
__handle_wallet_loading_exc(e, 1)
# if app is starting, still let wizard appear
@@ -499,8 +494,6 @@ def _start_wizard_to_select_or_create_wallet(self, path) -> Optional[Abstract_Wa
if not d['wallet_exists']:
self.logger.info('about to create wallet')
wizard.create_storage()
- if d['wallet_type'] == '2fa' and 'x3' not in d:
- return
wallet_file = wizard.path
else:
wallet_file = d['wallet_name']
@@ -513,36 +506,6 @@ def _start_wizard_to_select_or_create_wallet(self, path) -> Optional[Abstract_Wa
except WalletRequiresSplit as e:
wizard.run_split(wallet_file, e._split_data)
return
- except WalletUnfinished as e:
- # wallet creation is not complete, 2fa online phase
- db = e._wallet_db
- action = db.get_action()
- assert action[1] == 'accept_terms_of_use', 'only support for resuming trustedcoin split setup'
- k1 = load_keystore(db, 'x1')
- if password is not None:
- xprv = k1.get_master_private_key(password)
- else:
- xprv = db.get('x1')['xprv']
- if not is_xprv(xprv):
- xprv = k1
- _wiz_data_updates = {
- 'wallet_name': wallet_file,
- 'xprv1': xprv,
- 'xpub1': db.get('x1')['xpub'],
- 'xpub2': db.get('x2')['xpub'],
- }
- data = {**d, **_wiz_data_updates}
- wizard = QENewWalletWizard(self.config, self.app, self.plugins, self.daemon, path,
- start_viewstate=WizardViewState('trustedcoin_tos', data, {}))
- result = wizard.exec()
- if result == QDialog.DialogCode.Rejected:
- self.logger.info('wizard dialog cancelled by user')
- return
- db.put('x3', wizard.get_wizard_data()['x3'])
- db.write_and_force_consolidation() # TODO API for db is a bit weird: there should be a close method
-
- wallet = self.daemon.load_wallet(wallet_file, password, upgrade=True)
- return wallet
def close_window(self, window: ElectrumWindow):
if window in self.windows:
diff --git a/electrum/gui/qt/confirm_tx_dialog.py b/electrum/gui/qt/confirm_tx_dialog.py
index 459c8fc1b50a..ddc8c4e73609 100644
--- a/electrum/gui/qt/confirm_tx_dialog.py
+++ b/electrum/gui/qt/confirm_tx_dialog.py
@@ -36,7 +36,6 @@
from electrum.i18n import _
from electrum.util import (UserCancelled, quantize_feerate, profiler, NotEnoughFunds, NoDynamicFeeEstimates,
UserFacingException)
-from electrum.plugin import run_hook
from electrum.transaction import PartialTransaction, PartialTxOutput, Transaction
from electrum.wallet import InternalAddressCorruption
from electrum.bitcoin import DummyAddress
@@ -652,7 +651,6 @@ def _update_widgets(self):
self.locktime_e.set_locktime(self.tx.locktime)
self.io_widget.update(self.tx)
self.fee_label.setText(self.main_window.config.format_amount_and_units(self.tx.get_fee()))
- self._update_extra_fees()
if self.send_change_to_lightning_available():
self.change_to_ln_swap_providers_button.setVisible(True)
@@ -722,9 +720,6 @@ def set_locktime(self):
def _update_amount_label(self):
pass
- def _update_extra_fees(self):
- pass
-
def _update_message(self):
style = ColorScheme.RED if self.error else ColorScheme.BLUE
message_str = '\n'.join(self.messages) if self.messages else ''
@@ -1109,25 +1104,8 @@ def create_grid(self):
grid.setColumnStretch(4, 1)
- # extra fee
- self.extra_fee_label = QLabel(_("Additional fees") + ": ")
- self.extra_fee_label.setVisible(False)
- self.extra_fee_value = QLabel('')
- self.extra_fee_value.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
- self.extra_fee_value.setVisible(False)
- grid.addWidget(self.extra_fee_label, 5, 0)
- grid.addWidget(self.extra_fee_value, 5, 1)
-
# locktime editor
grid.addWidget(self.locktime_label, 6, 0)
grid.addWidget(self.locktime_e, 6, 1, 1, 2)
return grid
-
- def _update_extra_fees(self):
- x_fee = run_hook('get_tx_extra_fee', self.wallet, self.tx)
- if x_fee:
- x_fee_address, x_fee_amount = x_fee
- self.extra_fee_label.setVisible(True)
- self.extra_fee_value.setVisible(True)
- self.extra_fee_value.setText(self.main_window.format_amount_and_units(x_fee_amount))
diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py
index aeb7458ebed8..5321776ca461 100644
--- a/electrum/gui/qt/main_window.py
+++ b/electrum/gui/qt/main_window.py
@@ -1430,7 +1430,8 @@ def get_manually_selected_coins(self) -> Optional[Sequence[PartialTxInput]]:
def broadcast_or_show(self, tx: Transaction, *, invoice: 'Invoice' = None):
if not tx.is_complete():
- self.show_transaction(tx, invoice=invoice)
+ if not run_hook('show_incomplete_tx', self, tx):
+ self.show_transaction(tx, invoice=invoice)
return
if not self.network:
self.show_error(_("You can't broadcast a transaction without a live network connection."))
@@ -1469,7 +1470,6 @@ def on_success(result):
def on_failure(exc_info):
self.on_error(exc_info)
callback(False)
- on_success = run_hook('tc_sign_wrapper', self.wallet, tx, on_success, on_failure) or on_success
if external_keypairs:
# can sign directly
task = partial(tx.sign, external_keypairs)
diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py
index 761ed9e67741..f157918ae407 100644
--- a/electrum/gui/qt/send_tab.py
+++ b/electrum/gui/qt/send_tab.py
@@ -246,16 +246,13 @@ def spend_max(self):
if pi.type == PaymentIdentifierType.BIP21:
assert 'amount' not in pi.bip21
- if run_hook('abort_send', self):
- return
outputs = pi.get_onchain_outputs('!')
if not outputs:
return
make_tx = lambda fee_policy, *, confirmed_only=False: self.wallet.make_unsigned_transaction(
fee_policy=fee_policy,
coins=self.window.get_coins(),
- outputs=outputs,
- is_sweep=False)
+ outputs=outputs)
try:
try:
tx = make_tx(FeePolicy(self.config.FEE_POLICY))
@@ -271,16 +268,11 @@ def spend_max(self):
self.max_button.setChecked(True)
amount = tx.output_value()
- __, x_fee_amount = run_hook('get_tx_extra_fee', self.wallet, tx) or (None, 0)
- amount_after_all_fees = amount - x_fee_amount
- self.amount_e.setAmount(amount_after_all_fees)
+ self.amount_e.setAmount(amount)
# show tooltip explaining max amount
mining_fee = tx.get_fee()
mining_fee_str = self.format_amount_and_units(mining_fee)
msg = _("Mining fee: {} (can be adjusted on next screen)").format(mining_fee_str)
- if x_fee_amount:
- twofactor_fee_str = self.format_amount_and_units(x_fee_amount)
- msg += "\n" + _("2fa fee: {} (for the next batch of transactions)").format(twofactor_fee_str)
frozen_bal = self.wallet.get_frozen_balance_str()
if frozen_bal:
msg += "\n" + _("Some coins are frozen: {} (can be unfrozen in the Addresses or in the Coins tab)").format(frozen_bal)
@@ -298,11 +290,6 @@ def pay_onchain_dialog(
get_coins: Callable[..., Sequence[PartialTxInput]] = None,
invoice: Optional[Invoice] = None
) -> None:
- # trustedcoin requires this
- if run_hook('abort_send', self):
- return
-
- is_sweep = bool(external_keypairs)
# we call get_coins inside make_tx, so that inputs can be changed dynamically
if get_coins is None:
get_coins = self.window.get_coins
@@ -314,7 +301,6 @@ def make_tx(fee_policy, *, confirmed_only=False, base_tx=None):
coins=coins,
outputs=outputs,
base_tx=base_tx,
- is_sweep=is_sweep,
send_change_to_lightning=self.config.WALLET_SEND_CHANGE_TO_LIGHTNING,
merge_duplicate_outputs=self.config.WALLET_MERGE_DUPLICATE_OUTPUTS,
)
@@ -347,8 +333,6 @@ def make_tx(fee_policy, *, confirmed_only=False, base_tx=None):
tx,
external_keypairs=external_keypairs,
invoice=invoice,
- show_sign_button=self.wallet.wallet_type != '2fa',
- show_broadcast_button=self.wallet.wallet_type != '2fa',
)
return
self.save_pending_invoice()
diff --git a/electrum/gui/qt/transaction_dialog.py b/electrum/gui/qt/transaction_dialog.py
index ea9b478a34ec..020ccb11c724 100644
--- a/electrum/gui/qt/transaction_dialog.py
+++ b/electrum/gui/qt/transaction_dialog.py
@@ -135,8 +135,6 @@ def __init__(self, main_window: 'ElectrumWindow', wallet: 'Abstract_Wallet'):
legend=_("Change Address"), color=ColorScheme.YELLOW, tooltip=_("Wallet change address"))
self.txo_color_accounting = TxOutputColoring(
legend=_("Accounting Address"), color=ColorScheme.ORANGE, tooltip=_("Address from which funds were swept to your wallet."))
- self.txo_color_2fa = TxOutputColoring(
- legend=_("TrustedCoin (2FA) batch fee"), color=ColorScheme.BLUE, tooltip=_("TrustedCoin (2FA) fee for the next batch of transactions"))
self.txo_color_swap = TxOutputColoring(
legend=_("Submarine swap address"), color=ColorScheme.BLUE, tooltip=_("Submarine swap address"))
self.outputs_header = QLabel()
@@ -154,7 +152,6 @@ def __init__(self, main_window: 'ElectrumWindow', wallet: 'Abstract_Wallet'):
outheader_hbox.addStretch(2)
outheader_hbox.addWidget(self.txo_color_recv.legend_label)
outheader_hbox.addWidget(self.txo_color_change.legend_label)
- outheader_hbox.addWidget(self.txo_color_2fa.legend_label)
outheader_hbox.addWidget(self.txo_color_swap.legend_label)
outheader_hbox.addWidget(self.txo_color_accounting.legend_label)
@@ -182,11 +179,11 @@ def update(self, tx: Optional[Transaction]):
lnk.setToolTip(_('Click to open, right-click for menu'))
lnk.setAnchor(True)
lnk.setUnderlineStyle(QTextCharFormat.UnderlineStyle.SingleUnderline)
- tf_used_recv, tf_used_change, tf_used_2fa, tf_used_swap = False, False, False, False
+ tf_used_recv, tf_used_change, tf_used_swap = False, False, False
tf_used_accounting = False
def addr_text_format(addr: str) -> QTextCharFormat:
- nonlocal tf_used_recv, tf_used_change, tf_used_2fa, tf_used_swap, tf_used_accounting
+ nonlocal tf_used_recv, tf_used_change, tf_used_swap, tf_used_accounting
sm = self.wallet.lnworker.swap_manager if self.wallet.lnworker else None
if self.wallet.is_mine(addr):
if self.wallet.is_change(addr):
@@ -203,9 +200,6 @@ def addr_text_format(addr: str) -> QTextCharFormat:
elif sm and sm.is_lockup_address_for_a_swap(addr) or addr == DummyAddress.SWAP:
tf_used_swap = True
return self.txo_color_swap.text_char_format
- elif self.wallet.is_billing_address(addr):
- tf_used_2fa = True
- return self.txo_color_2fa.text_char_format
elif self.wallet.is_accounting_address(addr):
tf_used_accounting = True
return self.txo_color_accounting.text_char_format
@@ -304,7 +298,6 @@ def insert_tx_io(
self.txo_color_recv.legend_label.setVisible(tf_used_recv)
self.txo_color_change.legend_label.setVisible(tf_used_change)
- self.txo_color_2fa.legend_label.setVisible(tf_used_2fa)
self.txo_color_swap.legend_label.setVisible(tf_used_swap)
self.txo_color_accounting.legend_label.setVisible(tf_used_accounting)
diff --git a/electrum/plugins/timelock_recovery/timelock_recovery.py b/electrum/plugins/timelock_recovery/timelock_recovery.py
index 9d42cf44f0ca..7835fd425a65 100644
--- a/electrum/plugins/timelock_recovery/timelock_recovery.py
+++ b/electrum/plugins/timelock_recovery/timelock_recovery.py
@@ -91,7 +91,6 @@ def make_unsigned_alert_tx(self, fee_policy) -> 'PartialTransaction':
coins=self.wallet.get_spendable_coins(confirmed_only=False),
outputs=alert_tx_outputs,
fee_policy=fee_policy,
- is_sweep=False,
locktime=self.alert_tx.locktime if self.alert_tx else None,
)
@@ -125,7 +124,6 @@ def make_unsigned_recovery_tx(self, fee_policy) -> 'PartialTransaction':
coins=[recovery_tx_input],
outputs=[output for output in self.outputs if output.value != 0],
fee_policy=fee_policy,
- is_sweep=False,
locktime=self.recovery_tx.locktime if self.recovery_tx else None,
)
@@ -150,7 +148,6 @@ def make_unsigned_cancellation_tx(self, fee_policy) -> 'PartialTransaction':
PartialTxOutput(scriptpubkey=address_to_script(self.get_cancellation_address()), value='!'),
],
fee_policy=fee_policy,
- is_sweep=False,
locktime=self.cancellation_tx.locktime if self.cancellation_tx else None,
)
diff --git a/electrum/plugins/trustedcoin/cmdline.py b/electrum/plugins/trustedcoin/cmdline.py
index 2ad281aae48d..64395056c51d 100644
--- a/electrum/plugins/trustedcoin/cmdline.py
+++ b/electrum/plugins/trustedcoin/cmdline.py
@@ -23,22 +23,8 @@
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
-from electrum.i18n import _
from .trustedcoin import TrustedCoinPlugin
class Plugin(TrustedCoinPlugin):
-
- 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():
- self.logger.info("twofactor:sign_tx")
- auth_code = None
- if wallet.keystores['x3'].can_sign(tx, ignore_watching_only=True):
- msg = _('Please enter your Google Authenticator code:')
- auth_code = int(input(msg))
- else:
- self.logger.info("twofactor: xpub3 not needed")
- wallet.auth_code = auth_code
-
+ pass
diff --git a/electrum/plugins/trustedcoin/common_qt.py b/electrum/plugins/trustedcoin/common_qt.py
deleted file mode 100644
index 272421fd2987..000000000000
--- a/electrum/plugins/trustedcoin/common_qt.py
+++ /dev/null
@@ -1,256 +0,0 @@
-import threading
-import socket
-import base64
-from typing import TYPE_CHECKING
-
-from PyQt6.QtCore import pyqtSignal, pyqtProperty, pyqtSlot
-
-from electrum.i18n import _
-from electrum.bip32 import BIP32Node
-from electrum import bitcoin
-
-from .trustedcoin import (server, ErrorConnectingServer, MOBILE_DISCLAIMER, TrustedCoinException)
-from electrum.gui.common_qt.plugins import PluginQObject
-
-if TYPE_CHECKING:
- from electrum.wizard import NewWalletWizard
-
-
-class TrustedcoinPluginQObject(PluginQObject):
- canSignWithoutServerChanged = pyqtSignal()
- termsAndConditionsRetrieved = pyqtSignal([str], arguments=['message'])
- termsAndConditionsError = pyqtSignal([str], arguments=['message'])
- otpError = pyqtSignal([str], arguments=['message'])
- otpSuccess = pyqtSignal()
- disclaimerChanged = pyqtSignal()
- keystoreChanged = pyqtSignal()
- otpSecretChanged = pyqtSignal()
- shortIdChanged = pyqtSignal()
- billingModelChanged = pyqtSignal()
-
- remoteKeyStateChanged = pyqtSignal()
- remoteKeyError = pyqtSignal([str], arguments=['message'])
-
- requestOtp = pyqtSignal()
-
- def __init__(self, plugin, wizard: 'NewWalletWizard', parent):
- super().__init__(plugin, parent)
- self.wizard = wizard
- self._canSignWithoutServer = False
- self._otpSecret = ''
- self._shortId = ''
- self._billingModel = []
- self._remoteKeyState = ''
- self._verifyingOtp = False
-
- @pyqtProperty(str, notify=disclaimerChanged)
- def disclaimer(self):
- return '\n\n'.join(MOBILE_DISCLAIMER)
-
- @pyqtProperty(bool, notify=canSignWithoutServerChanged)
- def canSignWithoutServer(self):
- return self._canSignWithoutServer
-
- @pyqtProperty('QVariantMap', notify=keystoreChanged)
- def keystore(self):
- return self._keystore
-
- @pyqtProperty(str, notify=otpSecretChanged)
- def otpSecret(self):
- return self._otpSecret
-
- @pyqtProperty(str, notify=shortIdChanged)
- def shortId(self):
- return self._shortId
-
- @pyqtProperty(str, notify=remoteKeyStateChanged)
- def remoteKeyState(self):
- return self._remoteKeyState
-
- @remoteKeyState.setter
- def remoteKeyState(self, new_state):
- if self._remoteKeyState != new_state:
- self._remoteKeyState = new_state
- self.remoteKeyStateChanged.emit()
-
- @pyqtProperty('QVariantList', notify=billingModelChanged)
- def billingModel(self):
- return self._billingModel
-
- def updateBillingInfo(self, wallet):
- billing_model = []
-
- price_per_tx = wallet.price_per_tx
- for k, v in sorted(price_per_tx.items()):
- if k == 1:
- continue
- item = {
- 'text': 'Pay every %d transactions' % k,
- 'value': k,
- 'sats_per_tx': v / k
- }
- billing_model.append(item)
-
- self._billingModel = billing_model
- self.billingModelChanged.emit()
-
- @pyqtSlot()
- def fetchTermsAndConditions(self):
- def fetch_task():
- try:
- self.plugin.logger.debug('TOS')
- tos = server.get_terms_of_service()
- except ErrorConnectingServer as e:
- self.termsAndConditionsError.emit(_('Error connecting to server'))
- except Exception as e:
- self.termsAndConditionsError.emit('%s: %s' % (_('Error'), repr(e)))
- else:
- self.termsAndConditionsRetrieved.emit(tos)
- finally:
- self._busy = False
- self.busyChanged.emit()
-
- self._busy = True
- self.busyChanged.emit()
- t = threading.Thread(target=fetch_task)
- t.daemon = True
- t.start()
-
- @pyqtSlot()
- def createKeystore(self):
- email = 'dummy@electrum.org'
-
- self.remoteKeyState = ''
- self._otpSecret = ''
- self.otpSecretChanged.emit()
-
- wizard_data = self.wizard.get_wizard_data()
-
- xprv1, xpub1, xprv2, xpub2, xpub3, short_id = self.plugin.create_keys(wizard_data)
-
- def create_remote_key_task():
- try:
- self.plugin.logger.debug('create remote key')
- r = server.create(xpub1, xpub2, email)
-
- otp_secret = r['otp_secret']
- _xpub3 = r['xpubkey_cosigner']
- _id = r['id']
- except (socket.error, ErrorConnectingServer) as e:
- self.remoteKeyState = 'error'
- self.remoteKeyError.emit(f'Network error: {str(e)}')
- except TrustedCoinException as e:
- if e.status_code == 409:
- self.remoteKeyState = 'wallet_known'
- self._shortId = short_id
- self.shortIdChanged.emit()
- else:
- self.remoteKeyState = 'error'
- self.logger.warning(str(e))
- self.remoteKeyError.emit(f'Service error: {str(e)}')
- except (KeyError, TypeError) as e: # catch any assumptions
- self.remoteKeyState = 'error'
- self.remoteKeyError.emit(f'Error: {str(e)}')
- self.logger.error(str(e))
- else:
- if short_id != _id:
- self.remoteKeyState = 'error'
- self.logger.error("unexpected trustedcoin short_id: expected {}, received {}".format(short_id, _id))
- self.remoteKeyError.emit('Unexpected short_id')
- return
- if xpub3 != _xpub3:
- self.remoteKeyState = 'error'
- self.logger.error("unexpected trustedcoin xpub3: expected {}, received {}".format(xpub3, _xpub3))
- self.remoteKeyError.emit('Unexpected trustedcoin xpub3')
- return
- self.remoteKeyState = 'new'
- self._otpSecret = otp_secret
- self.otpSecretChanged.emit()
- self._shortId = short_id
- self.shortIdChanged.emit()
- finally:
- self._busy = False
- self.busyChanged.emit()
-
- self._busy = True
- self.busyChanged.emit()
-
- t = threading.Thread(target=create_remote_key_task)
- t.daemon = True
- t.start()
-
- @pyqtSlot()
- def resetOtpSecret(self):
- self.remoteKeyState = ''
-
- wizard_data = self.wizard.get_wizard_data()
-
- xprv1, xpub1, xprv2, xpub2, xpub3, short_id = self.plugin.create_keys(wizard_data)
-
- def reset_otp_task():
- try:
- self.plugin.logger.debug('reset_otp')
- r = server.get_challenge(short_id)
- challenge = r.get('challenge')
- message = 'TRUSTEDCOIN CHALLENGE: ' + challenge
-
- def f(xprv):
- rootnode = BIP32Node.from_xkey(xprv)
- key = rootnode.subkey_at_private_derivation((0, 0)).eckey
- sig = bitcoin.ecdsa_sign_usermessage(key, message, is_compressed=True)
- return base64.b64encode(sig).decode()
-
- signatures = [f(x) for x in [xprv1, xprv2]]
- r = server.reset_auth(short_id, challenge, signatures)
- otp_secret = r.get('otp_secret')
- except (socket.error, ErrorConnectingServer) as e:
- self.remoteKeyState = 'error'
- self.remoteKeyError.emit(f'Network error: {str(e)}')
- except Exception as e:
- self.remoteKeyState = 'error'
- self.remoteKeyError.emit(f'Error: {str(e)}')
- else:
- self.remoteKeyState = 'reset'
- self._otpSecret = otp_secret
- self.otpSecretChanged.emit()
- finally:
- self._busy = False
- self.busyChanged.emit()
-
- self._busy = True
- self.busyChanged.emit()
-
- t = threading.Thread(target=reset_otp_task, daemon=True)
- t.start()
-
- @pyqtSlot(str, int)
- def checkOtp(self, short_id, otp):
- assert type(otp) is int # make sure this doesn't fail subtly
-
- def check_otp_task():
- try:
- self.plugin.logger.debug(f'check OTP, shortId={short_id}, otp={otp}')
- server.auth(short_id, otp)
- except TrustedCoinException as e:
- if e.status_code == 400: # invalid OTP
- self.plugin.logger.debug('Invalid one-time password.')
- self.otpError.emit(_('Invalid one-time password.'))
- else:
- self.plugin.logger.error(str(e))
- self.otpError.emit(f'Service error: {str(e)}')
- except Exception as e:
- self.plugin.logger.error(str(e))
- self.otpError.emit(f'Error: {str(e)}')
- else:
- self.plugin.logger.debug('OTP verify success')
- self.otpSuccess.emit()
- finally:
- self._busy = False
- self.busyChanged.emit()
- self._verifyingOtp = False
-
- self._verifyingOtp = True
- self._busy = True
- self.busyChanged.emit()
- t = threading.Thread(target=check_otp_task, daemon=True)
- t.start()
diff --git a/electrum/plugins/trustedcoin/manifest.json b/electrum/plugins/trustedcoin/manifest.json
index d2916445ce4c..31e8bdb0aa57 100644
--- a/electrum/plugins/trustedcoin/manifest.json
+++ b/electrum/plugins/trustedcoin/manifest.json
@@ -1,7 +1,7 @@
{
"name": "trustedcoin",
"fullname": "Two Factor Authentication",
- "description": "This plugin adds two-factor authentication to your wallet.\nFor more information, visit https://api.trustedcoin.com/#/electrum-help",
+ "description": "This plugin adds two-factor authentication to your wallet, with the Electrum app on your phone as cosigner. It replaces the discontinued TrustedCoin cosigning service.",
"requires_wallet_type": ["2fa"],
"registers_wallet_type": "2fa",
"icon":"trustedcoin-status.png",
diff --git a/electrum/plugins/trustedcoin/qml.py b/electrum/plugins/trustedcoin/qml.py
index 76c7a1217b50..ffc5a5e3b0c5 100644
--- a/electrum/plugins/trustedcoin/qml.py
+++ b/electrum/plugins/trustedcoin/qml.py
@@ -1,150 +1,57 @@
-from functools import partial
-from typing import TYPE_CHECKING, Callable
+from typing import TYPE_CHECKING
+
+from PyQt6.QtCore import pyqtProperty
-from electrum.i18n import _
from electrum.plugin import hook
-from electrum.util import UserFacingException
-from electrum.gui.qml.qewallet import QEWallet
+from electrum.gui.common_qt.plugins import PluginQObject
from electrum.gui.qml.qedaemon import QEDaemon
-from .common_qt import TrustedcoinPluginQObject
-from .trustedcoin import TrustedCoinPlugin, TrustedCoinException, Wallet_2fa
+from .trustedcoin import TrustedCoinPlugin, MOBILE_DISCLAIMER
if TYPE_CHECKING:
from electrum.gui.qml import ElectrumQmlApplication
- from electrum.wallet import Abstract_Wallet
- from electrum.wizard import NewWalletWizard
- from electrum.transaction import PartialTransaction
+ from electrum.gui.qml.qewizard import QENewWalletWizard
+
+
+class TrustedcoinPluginQObject(PluginQObject):
+
+ @pyqtProperty(str, constant=True)
+ def disclaimer(self):
+ return '\n\n'.join(MOBILE_DISCLAIMER)
class Plugin(TrustedCoinPlugin):
def __init__(self, *args):
super().__init__(*args)
- self._app = None # type: ElectrumQmlApplication
self.so = None # type: TrustedcoinPluginQObject
- @hook
- def load_wallet(self, wallet: 'Abstract_Wallet'):
- if not isinstance(wallet, self.wallet_class):
- return
- self.logger.debug(f'plugin enabled for wallet "{str(wallet)}"')
- if wallet.can_sign_without_server():
- self.so._canSignWithoutServer = True
- self.so.canSignWithoutServerChanged.emit()
-
- msg = ' '.join([
- _('This wallet was restored from seed, and it contains two master private keys.'),
- _('Therefore, two-factor authentication is disabled.')
- ])
- self.logger.info(msg)
- self.start_request_thread(wallet)
-
@hook
def init_qml(self, app: 'ElectrumQmlApplication'):
self.logger.debug(f'init_qml hook called, gui={str(type(app))}')
- self._app = app
- wizard = QEDaemon.instance.newWalletWizard
# important: TrustedcoinPluginQObject needs to be parented, as keeping a ref
# in the plugin is not enough to avoid gc
- # Note: storing the trustedcoin qt helper in the plugin is different from the desktop client,
- # which stores the helper in the wizard object. As the mobile client only shows a single wizard
- # at a time, this is ok for now.
- self.so = TrustedcoinPluginQObject(self, wizard, self._app)
- # extend wizard
- self.extend_wizard(wizard)
-
- # wizard support functions
+ self.so = TrustedcoinPluginQObject(self, app)
+ self.extend_wizard(QEDaemon.instance.newWalletWizard)
- def extend_wizard(self, wizard: 'NewWalletWizard'):
+ def extend_wizard(self, wizard: 'QENewWalletWizard'):
super().extend_wizard(wizard)
views = {
'trustedcoin_start': {
'gui': '../../../../plugins/trustedcoin/qml/Disclaimer',
},
- 'trustedcoin_choose_seed': {
- 'gui': '../../../../plugins/trustedcoin/qml/ChooseSeed',
- },
- 'trustedcoin_create_seed': {
- 'gui': 'WCCreateSeed',
- },
- 'trustedcoin_create_ext': {
- 'gui': 'WCEnterExt',
- },
- 'trustedcoin_confirm_seed': {
- 'gui': 'WCConfirmSeed',
- },
- 'trustedcoin_confirm_ext': {
- 'gui': 'WCConfirmExt',
- },
+ # on mobile, restoring from seed disables two-factor authentication
'trustedcoin_have_seed': {
'gui': 'WCHaveSeed',
+ 'next': lambda d: 'trustedcoin_have_ext' if wizard.wants_ext(d) else 'wallet_password',
+ 'accept': lambda d: None if wizard.wants_ext(d) else self.recovery_disable(d),
+ 'last': lambda d: wizard.is_single_password() and not wizard.wants_ext(d),
},
'trustedcoin_have_ext': {
'gui': 'WCEnterExt',
+ 'next': 'wallet_password',
+ 'accept': self.recovery_disable,
+ 'last': lambda d: wizard.is_single_password(),
},
- 'trustedcoin_keep_disable': {
- 'gui': '../../../../plugins/trustedcoin/qml/KeepDisable',
- },
- 'trustedcoin_tos': {
- 'gui': '../../../../plugins/trustedcoin/qml/Terms',
- },
- 'trustedcoin_keystore_unlock': {
- # TODO when QML can import external wallet files
- },
- 'trustedcoin_show_confirm_otp': {
- 'gui': '../../../../plugins/trustedcoin/qml/ShowConfirmOTP',
- }
}
wizard.navmap_merge(views)
-
- # running wallet functions
-
- def prompt_user_for_otp(
- self,
- wallet: Wallet_2fa,
- tx: 'PartialTransaction',
- on_success: Callable[['PartialTransaction'], None],
- on_failure: Callable[[str], None],
- ):
- self.logger.debug('prompt_user_for_otp')
- qewallet = QEWallet.getInstanceFor(wallet)
- qewallet.request_otp(partial(self.on_otp, wallet, tx, on_success=on_success, on_failure=on_failure))
-
- def on_otp(
- self,
- wallet: Wallet_2fa,
- tx: 'PartialTransaction',
- otp,
- *,
- on_success: Callable[['PartialTransaction'], None],
- on_failure: Callable[[str], None] = None
- ):
- self.logger.debug('on_otp')
- assert wallet and isinstance(wallet, Wallet_2fa)
-
- on_failure = on_failure if on_failure else lambda x: self.logger.error(x)
-
- if not otp:
- on_failure(_('No auth code'))
- return
-
- try:
- wallet.on_otp(tx, otp)
- except UserFacingException as e:
- on_failure(_('Invalid one-time password.'))
- except TrustedCoinException as e:
- if e.status_code == 400: # invalid OTP
- on_failure(_('Invalid one-time password.'))
- else:
- on_failure(_('Service Error') + ':\n' + str(e))
- except Exception as e:
- on_failure(_('Error') + ':\n' + str(e))
- else:
- on_success(tx)
-
- def billing_info_retrieved(self, wallet):
- self.logger.info('billing_info_retrieved')
- qewallet = QEWallet.getInstanceFor(wallet)
- qewallet.billingInfoChanged.emit()
- self.so.updateBillingInfo(wallet)
diff --git a/electrum/plugins/trustedcoin/qml/ChooseSeed.qml b/electrum/plugins/trustedcoin/qml/ChooseSeed.qml
deleted file mode 100644
index 8665dee27c94..000000000000
--- a/electrum/plugins/trustedcoin/qml/ChooseSeed.qml
+++ /dev/null
@@ -1,38 +0,0 @@
-import QtQuick 2.6
-import QtQuick.Layouts 1.0
-import QtQuick.Controls 2.1
-
-import "../../../gui/qml/components/wizard"
-
-WizardComponent {
- valid: keystoregroup.checkedButton !== null
-
- onAccept: {
- wizard_data['keystore_type'] = keystoregroup.checkedButton.keystoretype
- }
-
- ButtonGroup {
- id: keystoregroup
- }
-
- ColumnLayout {
- width: parent.width
- Label {
- text: qsTr('Do you want to create a new seed, or restore a wallet using an existing seed?')
- Layout.preferredWidth: parent.width
- wrapMode: Text.Wrap
- }
- RadioButton {
- ButtonGroup.group: keystoregroup
- property string keystoretype: 'createseed'
- checked: true
- text: qsTr('Create a new seed')
- }
- RadioButton {
- ButtonGroup.group: keystoregroup
- property string keystoretype: 'haveseed'
- text: qsTr('I already have a seed')
- }
- }
-}
-
diff --git a/electrum/plugins/trustedcoin/qml/Disclaimer.qml b/electrum/plugins/trustedcoin/qml/Disclaimer.qml
index 5d2f90187003..e7a8fa3b3f31 100644
--- a/electrum/plugins/trustedcoin/qml/Disclaimer.qml
+++ b/electrum/plugins/trustedcoin/qml/Disclaimer.qml
@@ -11,6 +11,10 @@ WizardComponent {
property QtObject plugin
+ function apply() {
+ wizard_data['keystore_type'] = 'haveseed'
+ }
+
ColumnLayout {
width: parent.width
diff --git a/electrum/plugins/trustedcoin/qml/KeepDisable.qml b/electrum/plugins/trustedcoin/qml/KeepDisable.qml
deleted file mode 100644
index 3e10d5175739..000000000000
--- a/electrum/plugins/trustedcoin/qml/KeepDisable.qml
+++ /dev/null
@@ -1,35 +0,0 @@
-import QtQuick 2.6
-import QtQuick.Layouts 1.0
-import QtQuick.Controls 2.1
-
-import "../../../gui/qml/components/wizard"
-
-WizardComponent {
- valid: keepordisablegroup.checkedButton
-
- function apply() {
- wizard_data['trustedcoin_keepordisable'] = keepordisablegroup.checkedButton.keepordisable
- }
-
- ButtonGroup {
- id: keepordisablegroup
- onCheckedButtonChanged: checkIsLast()
- }
-
- ColumnLayout {
- Label {
- text: qsTr('Restore 2FA wallet')
- }
- RadioButton {
- ButtonGroup.group: keepordisablegroup
- property string keepordisable: 'keep'
- checked: true
- text: qsTr('Keep')
- }
- RadioButton {
- ButtonGroup.group: keepordisablegroup
- property string keepordisable: 'disable'
- text: qsTr('Disable')
- }
- }
-}
diff --git a/electrum/plugins/trustedcoin/qml/ShowConfirmOTP.qml b/electrum/plugins/trustedcoin/qml/ShowConfirmOTP.qml
deleted file mode 100644
index 8b2f818f6634..000000000000
--- a/electrum/plugins/trustedcoin/qml/ShowConfirmOTP.qml
+++ /dev/null
@@ -1,166 +0,0 @@
-import QtQuick 2.6
-import QtQuick.Layouts 1.0
-import QtQuick.Controls 2.1
-
-import "../../../gui/qml/components/wizard"
-import "../../../gui/qml/components/controls"
-
-WizardComponent {
- valid: otpVerified
-
- property QtObject plugin
-
- property bool otpVerified: false
-
- ColumnLayout {
- width: parent.width
-
- Label {
- text: qsTr('Authenticator secret')
- }
-
- InfoTextArea {
- id: errorBox
- Layout.fillWidth: true
- iconStyle: InfoTextArea.IconStyle.Error
- visible: !otpVerified && plugin.remoteKeyState == 'error'
- }
-
- InfoTextArea {
- Layout.fillWidth: true
- iconStyle: InfoTextArea.IconStyle.Warn
- visible: plugin.remoteKeyState == 'wallet_known'
- text: qsTr('This wallet is already registered with TrustedCoin. ')
- + qsTr('To finalize wallet creation, please enter your Google Authenticator Code. ')
- }
-
- QRImage {
- Layout.alignment: Qt.AlignHCenter
- visible: plugin.remoteKeyState == 'new' || plugin.remoteKeyState == 'reset'
- qrdata: encodeURI('otpauth://totp/Electrum 2FA ' + wizard_data['wallet_name']
- + '?secret=' + plugin.otpSecret + '&digits=6')
- render: plugin.otpSecret
- onClicked: {
- if (plugin.otpSecret) {
- AppController.textToClipboard(plugin.otpSecret)
- toaster.show(this, qsTr('Copied!'))
- // On Android the app will get killed when switching to the authenticator app,
- // losing the wizard state. TODO: re-enable once we have means to keep app alive in background.
- // if (AppController.isAndroid()) {
- // Qt.openUrlExternally(qrdata)
- // } else {
- // AppController.textToClipboard(plugin.otpSecret)
- // toaster.show(this, qsTr('Copied!'))
- // }
- }
- }
- }
-
- Item {
- Layout.alignment: Qt.AlignHCenter
- visible: plugin.otpSecret
- implicitWidth: otpSecretPane.implicitWidth
- implicitHeight: otpSecretPane.implicitHeight
- TextHighlightPane {
- id: otpSecretPane
- Label {
- text: plugin.otpSecret
- font.family: FixedFont
- font.bold: true
- }
- }
- MouseArea {
- anchors.fill: parent
- onClicked: {
- AppController.textToClipboard(plugin.otpSecret)
- toaster.show(otpSecretPane, qsTr('Copied!'))
- }
- }
- }
-
- Label {
- Layout.fillWidth: true
- visible: !otpVerified && plugin.otpSecret
- wrapMode: Text.Wrap
- text: qsTr('Enter or scan into authenticator app. Then authenticate below')
- }
-
- Label {
- Layout.fillWidth: true
- visible: !otpVerified && plugin.remoteKeyState == 'wallet_known'
- wrapMode: Text.Wrap
- text: qsTr('If you still have your OTP secret, then authenticate below')
- }
-
- TextField {
- id: otp_auth
- visible: !otpVerified && (plugin.otpSecret || plugin.remoteKeyState == 'wallet_known')
- Layout.alignment: Qt.AlignHCenter
- focus: true
- inputMethodHints: Qt.ImhSensitiveData | Qt.ImhDigitsOnly
- validator: IntValidator {bottom: 0; top: 999999;}
- font.family: FixedFont
- font.pixelSize: constants.fontSizeLarge
- onTextChanged: {
- if (text.length >= 6) {
- plugin.checkOtp(plugin.shortId, otp_auth.text)
- text = ''
- }
- }
- }
-
- Label {
- Layout.fillWidth: true
- visible: !otpVerified && plugin.remoteKeyState == 'wallet_known'
- wrapMode: Text.Wrap
- text: qsTr('Otherwise, you can request your OTP secret from the server, by pressing the button below')
- }
-
- Button {
- Layout.alignment: Qt.AlignHCenter
- visible: plugin.remoteKeyState == 'wallet_known' && !otpVerified
- text: qsTr('Request OTP secret')
- onClicked: plugin.resetOtpSecret()
- }
-
- Image {
- Layout.alignment: Qt.AlignHCenter
- source: '../../../gui/icons/confirmed.png'
- visible: otpVerified
- Layout.preferredWidth: constants.iconSizeXLarge
- Layout.preferredHeight: constants.iconSizeXLarge
- }
- }
-
- BusyIndicator {
- anchors.centerIn: parent
- visible: plugin ? plugin.busy : false
- running: visible
- }
-
- Component.onCompleted: {
- plugin = AppController.plugin('trustedcoin')
- plugin.createKeystore()
- otp_auth.forceActiveFocus()
- }
-
- Toaster {
- id: toaster
- }
-
- Connections {
- target: plugin
- function onOtpError(message) {
- console.log('OTP verify error')
- errorBox.text = message
- }
- function onOtpSuccess() {
- console.log('OTP verify success')
- otpVerified = true
- }
- function onRemoteKeyError(message) {
- errorBox.text = message
- }
- }
-}
-
diff --git a/electrum/plugins/trustedcoin/qml/Terms.qml b/electrum/plugins/trustedcoin/qml/Terms.qml
deleted file mode 100644
index b22836efb008..000000000000
--- a/electrum/plugins/trustedcoin/qml/Terms.qml
+++ /dev/null
@@ -1,67 +0,0 @@
-import QtQuick 2.6
-import QtQuick.Layouts 1.0
-import QtQuick.Controls 2.1
-
-import org.electrum 1.0
-
-import "../../../gui/qml/components/wizard"
-import "../../../gui/qml/components/controls"
-
-WizardComponent {
- valid: !plugin ? false
- : tosShown
-
- property QtObject plugin
- property bool tosShown: false
-
- ColumnLayout {
- anchors.fill: parent
-
- Label {
- text: qsTr('Terms and conditions')
- }
-
- TextHighlightPane {
- Layout.fillWidth: true
- Layout.fillHeight: true
- rightPadding: 0
-
- Flickable {
- anchors.fill: parent
- contentHeight: termsText.height
- clip: true
- boundsBehavior: Flickable.StopAtBounds
-
- Label {
- id: termsText
- width: parent.width
- rightPadding: constants.paddingSmall
- wrapMode: Text.Wrap
- }
- ScrollIndicator.vertical: ScrollIndicator { }
- }
-
- BusyIndicator {
- anchors.centerIn: parent
- visible: plugin ? plugin.busy : false
- running: visible
- }
- }
- }
-
- Component.onCompleted: {
- plugin = AppController.plugin('trustedcoin')
- plugin.fetchTermsAndConditions()
- }
-
- Connections {
- target: plugin
- function onTermsAndConditionsRetrieved(message) {
- termsText.text = message
- tosShown = true
- }
- function onTermsAndConditionsError(message) {
- termsText.text = message
- }
- }
-}
diff --git a/electrum/plugins/trustedcoin/qt.py b/electrum/plugins/trustedcoin/qt.py
index 4fd049995355..bed3af37c6e9 100644
--- a/electrum/plugins/trustedcoin/qt.py
+++ b/electrum/plugins/trustedcoin/qt.py
@@ -24,80 +24,40 @@
# SOFTWARE.
from functools import partial
-import os
from typing import TYPE_CHECKING
-from PyQt6.QtGui import QPixmap, QMovie, QColor
-from PyQt6.QtCore import QObject, pyqtSignal, QSize, Qt
-from PyQt6.QtWidgets import (QTextEdit, QVBoxLayout, QLabel, QGridLayout, QHBoxLayout,
- QRadioButton, QCheckBox, QPushButton, QWidget)
+import qrcode
+
+from PyQt6.QtWidgets import QDialog
from electrum.i18n import _
from electrum.plugin import hook
-from electrum.util import InvalidPassword, ChoiceItem
-from electrum.logging import Logger, get_logger
-from electrum import keystore
-
-from electrum.gui.qt.util import (WindowModalDialog, WaitingDialog, OkButton, CancelButton, Buttons, icon_path,
- internal_plugin_icon_path, WWLabel, CloseButton, ColorScheme,
- ChoiceWidget, PasswordLineEdit, char_width_in_lineedit)
-from electrum.gui.qt.qrcodewidget import QRCodeWidget
-from electrum.gui.qt.amountedit import AmountEdit
+from electrum.util import ChoiceItem
+from electrum.wizard import WizardViewState
+
+from electrum.gui.common_qt.util import QtEventListener, qt_event_listener
+from electrum.gui.qt.util import (internal_plugin_icon_path, WWLabel, ColorScheme, ChoiceWidget,
+ read_QIcon_from_bytes)
+from electrum.gui.qt.qrcodewidget import QRCodeWidget, QRDialog
from electrum.gui.qt.main_window import StatusBarButton
-from electrum.gui.qt.wizard.wallet import (WCCreateSeed, WCConfirmSeed, WCHaveSeed, WCEnterExt, WCConfirmExt,
- WalletWizardComponent)
-from electrum.gui.qt.util import read_QIcon_from_bytes
+from electrum.gui.qt.wizard.wallet import (WCHaveSeed, WCEnterExt, WalletWizardComponent, QEKeystoreWizard)
-from .common_qt import TrustedcoinPluginQObject
-from .trustedcoin import TrustedCoinPlugin, DISCLAIMER
+from .trustedcoin import TrustedCoinPlugin, DISCLAIMER, make_cosigner_qr_data
if TYPE_CHECKING:
from electrum.gui.qt.main_window import ElectrumWindow
from electrum.wallet import Abstract_Wallet
+ from electrum.transaction import PartialTransaction
from electrum.gui.qt.wizard.wallet import QENewWalletWizard
-class TOS(QTextEdit):
- tos_signal = pyqtSignal()
- error_signal = pyqtSignal(object)
-
-
-class HandlerTwoFactor(QObject, Logger):
-
- def __init__(self, plugin, window):
- QObject.__init__(self)
- self.plugin = plugin
- self.window = window
- Logger.__init__(self)
-
- def prompt_user_for_otp(self, wallet, tx, on_success, on_failure):
- if not isinstance(wallet, self.plugin.wallet_class):
- return
- if wallet.can_sign_without_server():
- return
- if not wallet.keystores['x3'].can_sign(tx, ignore_watching_only=True):
- self.logger.info("twofactor: xpub3 not needed")
- return
- window = self.window.top_level_window()
- auth_code = self.plugin.auth_dialog(window)
- WaitingDialog(parent=window,
- message=_('Waiting for TrustedCoin server to sign transaction...'),
- task=lambda: wallet.on_otp(tx, auth_code),
- on_success=lambda *args: on_success(tx),
- on_error=on_failure)
-
-
class Plugin(TrustedCoinPlugin):
- def __init__(self, parent, config, name):
- super().__init__(parent, config, name)
-
@hook
def load_wallet(self, wallet: 'Abstract_Wallet', window: 'ElectrumWindow'):
if not isinstance(wallet, self.wallet_class):
return
- wallet.handler_2fa = HandlerTwoFactor(self, window)
- if wallet.can_sign_without_server():
+ if wallet.can_sign_without_cosigner():
msg = ' '.join([
_('This wallet was restored from seed, and it contains two master private keys.'),
_('Therefore, two-factor authentication is disabled.')
@@ -108,221 +68,128 @@ def load_wallet(self, wallet: 'Abstract_Wallet', window: 'ElectrumWindow'):
action = partial(self.settings_dialog, window)
icon = read_QIcon_from_bytes(self.read_file("trustedcoin-status.png"))
sb = window.statusBar()
- button = StatusBarButton(icon, _("TrustedCoin"), action, sb.height())
+ button = StatusBarButton(icon, _("Two-factor authentication"), action, sb.height())
sb.addPermanentWidget(button)
- self.start_request_thread(window.wallet)
-
- def auth_dialog(self, window):
- d = WindowModalDialog(window, _("Authorization"))
- vbox = QVBoxLayout(d)
- pw = AmountEdit(None, is_int=True)
- msg = _('Please enter your Google Authenticator code')
- vbox.addWidget(QLabel(msg))
- grid = QGridLayout()
- grid.setSpacing(8)
- grid.addWidget(QLabel(_('Code')), 1, 0)
- grid.addWidget(pw, 1, 1)
- vbox.addLayout(grid)
- msg = _('If you have lost your second factor, you need to restore your wallet from seed in order to request a new code.')
- label = QLabel(msg)
- label.setWordWrap(True)
- vbox.addWidget(label)
- vbox.addLayout(Buttons(CancelButton(d), OkButton(d)))
- if not d.exec():
- return
- return pw.get_amount()
-
- def prompt_user_for_otp(self, wallet, tx, on_success, on_failure):
- wallet.handler_2fa.prompt_user_for_otp(wallet, tx, on_success, on_failure)
-
- def waiting_dialog_for_billing_info(self, window, *, on_finished=None):
- def task():
- return self.request_billing_info(window.wallet, suppress_connection_error=False)
-
- def on_error(exc_info):
- e = exc_info[1]
- window.show_error("{header}\n{exc}\n\n{tor}"
- .format(header=_('Error getting TrustedCoin account info.'),
- exc=repr(e),
- tor=_('If you keep experiencing network problems, try using a Tor proxy.')))
- return WaitingDialog(parent=window,
- message=_('Requesting account info from TrustedCoin server...'),
- task=task,
- on_success=on_finished,
- on_error=on_error)
@hook
- def abort_send(self, window):
- wallet = window.wallet
- if not isinstance(wallet, self.wallet_class):
- return
- if wallet.can_sign_without_server():
- return
- if wallet.billing_info is None:
- self.waiting_dialog_for_billing_info(window)
+ def show_incomplete_tx(self, window: 'ElectrumWindow', tx: 'PartialTransaction') -> bool:
+ # after signing with the first key, show the transaction to the mobile cosigner
+ if not isinstance(window.wallet, self.wallet_class):
+ return False
+ help_text = ' '.join([
+ _('Scan this QR code with the Electrum app on your phone, in order to sign and broadcast the transaction.'),
+ _('If your phone is not set up as cosigner yet, click on the two-factor authentication icon in the status bar.'),
+ ])
+ try:
+ dialog = PsbtQRDialog(tx, wallet=window.wallet, parent=window, config=self.config, help_text=help_text)
+ except qrcode.exceptions.DataOverflowError:
+ window.show_error('\n'.join([
+ _('This transaction is too large to fit in a QR code.'),
+ _('Try to spend fewer coins at once.'),
+ ]))
return True
- return False
+ dialog.exec()
+ return True
+
+ def settings_dialog(self, window: 'ElectrumWindow'):
+ msg = '\n\n'.join([
+ _('This wallet is protected by two-factor authentication.'),
+ ' '.join([
+ _('The TrustedCoin service has been discontinued: transactions are now co-signed by the Electrum app on your phone.'),
+ _('After you sign a transaction, scan the QR code displayed by Electrum with your phone, in order to broadcast it.'),
+ ]),
+ _('Do you want to set up your phone as cosigner of this wallet? You will need your 2FA seed.'),
+ ])
+ if window.question(msg, title=_('Two-factor authentication')):
+ self.setup_cosigner(window)
- def settings_dialog(self, window):
- self.waiting_dialog_for_billing_info(window,
- on_finished=partial(self.show_settings_dialog, window))
+ def setup_cosigner(self, window: 'ElectrumWindow'):
+ # for existing wallets: the second master private key is not in the wallet file
+ wallet = window.wallet
+ params = {'icon': self.icon_path('trustedcoin-wizard.png')}
+ data = {
+ 'wallet_type': '2fa',
+ 'keystore_type': 'haveseed',
+ 'trustedcoin_wallet_xpubs': [wallet.keystores[k].get_master_public_key() for k in ['x1', 'x2']],
+ }
+ wizard = QEKeystoreWizard(
+ config=self.config, app=window.gui_object.app, plugins=window.gui_object.plugins,
+ start_viewstate=WizardViewState('trustedcoin_have_seed', data, params))
+ wizard.window_title = _('Set up mobile cosigner')
+ wizard.navmap_merge({
+ 'trustedcoin_have_seed': {
+ 'gui': WCHaveSeed,
+ 'params': params,
+ 'next': lambda d: 'trustedcoin_have_ext' if wizard.wants_ext(d) else 'trustedcoin_show_cosigner_qr',
+ },
+ 'trustedcoin_have_ext': {
+ 'gui': WCEnterExt,
+ 'params': params,
+ 'next': 'trustedcoin_show_cosigner_qr',
+ },
+ 'trustedcoin_show_cosigner_qr': {
+ 'gui': WCShowCosignerQR,
+ 'params': params,
+ 'last': True,
+ },
+ })
+ if wizard.exec() == QDialog.DialogCode.Accepted:
+ window.show_message(_('Your phone is now set up as cosigner of this wallet.'))
def icon_path(self, name):
return internal_plugin_icon_path(self.name, name)
- def show_settings_dialog(self, window, success):
- if not success:
- window.show_message(_('Server not reachable.'))
- return
-
- wallet = window.wallet
- d = WindowModalDialog(window, _("TrustedCoin Information"))
- d.setMinimumSize(500, 200)
- vbox = QVBoxLayout(d)
- hbox = QHBoxLayout()
-
- logo = QLabel()
- logo.setPixmap(QPixmap(self.icon_path("trustedcoin-status.png")))
- msg = _('This wallet is protected by TrustedCoin\'s two-factor authentication.') + '
'\
- + _("For more information, visit") + " https://api.trustedcoin.com/#/electrum-help"
- label = QLabel(msg)
- label.setOpenExternalLinks(True)
- label.setTextFormat(Qt.TextFormat.RichText)
-
- hbox.addStretch(10)
- hbox.addWidget(logo)
- hbox.addStretch(10)
- hbox.addWidget(label)
- hbox.addStretch(10)
-
- vbox.addLayout(hbox)
- vbox.addStretch(10)
-
- msg = _('TrustedCoin charges a small fee to co-sign transactions. '
- 'The fee depends on how many prepaid transactions you buy. '
- 'An extra output is added to your transaction every time you run out of prepaid transactions.')
- label = QLabel(msg)
- label.setWordWrap(True)
- vbox.addWidget(label)
-
- vbox.addStretch(10)
- grid = QGridLayout()
- vbox.addLayout(grid)
-
- price_per_tx = wallet.price_per_tx
- n_prepay = wallet.num_prepay()
- i = 0
- for k, v in sorted(price_per_tx.items()):
- if k == 1:
- continue
- grid.addWidget(QLabel("Pay every %d transactions:"%k), i, 0)
- grid.addWidget(QLabel(window.format_amount(v/k) + ' ' + window.base_unit() + "/tx"), i, 1)
- b = QRadioButton()
- b.setChecked(k == n_prepay)
-
- def on_click(b, k):
- self.config.PLUGIN_TRUSTEDCOIN_NUM_PREPAY = k
- b.clicked.connect(partial(on_click, k=k))
- grid.addWidget(b, i, 2)
- i += 1
-
- n = wallet.billing_info.get('tx_remaining', 0)
- grid.addWidget(QLabel(_("Your wallet has {} prepaid transactions.").format(n)), i, 0)
- vbox.addLayout(Buttons(CloseButton(d)))
- d.exec()
-
@hook
def init_wallet_wizard(self, wizard: 'QENewWalletWizard'):
- wizard.trustedcoin_qhelper = TrustedcoinPluginQObject(self, wizard, None)
self.extend_wizard(wizard)
- if wizard.start_viewstate and wizard.start_viewstate.view.startswith('trustedcoin_'):
- wizard.start_viewstate.params.update({'icon': self.icon_path('trustedcoin-wizard.png')})
def extend_wizard(self, wizard: 'QENewWalletWizard'):
super().extend_wizard(wizard)
+ params = {'icon': self.icon_path('trustedcoin-wizard.png')}
views = {
'trustedcoin_start': {
'gui': WCDisclaimer,
- 'params': {'icon': self.icon_path('trustedcoin-wizard.png')},
- },
- 'trustedcoin_choose_seed': {
- 'gui': WCChooseSeed,
- 'params': {'icon': self.icon_path('trustedcoin-wizard.png')},
- },
- 'trustedcoin_create_seed': {
- 'gui': WCCreateSeed,
- 'params': {'icon': self.icon_path('trustedcoin-wizard.png')},
- },
- 'trustedcoin_create_ext': {
- 'gui': WCEnterExt,
- 'params': {'icon': self.icon_path('trustedcoin-wizard.png')},
- },
- 'trustedcoin_confirm_seed': {
- 'gui': WCConfirmSeed,
- 'params': {'icon': self.icon_path('trustedcoin-wizard.png')},
- },
- 'trustedcoin_confirm_ext': {
- 'gui': WCConfirmExt,
- 'params': {'icon': self.icon_path('trustedcoin-wizard.png')},
+ 'params': params,
},
'trustedcoin_have_seed': {
'gui': WCHaveSeed,
- 'params': {'icon': self.icon_path('trustedcoin-wizard.png')},
+ 'params': params,
},
'trustedcoin_have_ext': {
'gui': WCEnterExt,
- 'params': {'icon': self.icon_path('trustedcoin-wizard.png')},
+ 'params': params,
},
'trustedcoin_keep_disable': {
'gui': WCKeepDisable,
- 'params': {'icon': self.icon_path('trustedcoin-wizard.png')},
- },
- 'trustedcoin_tos': {
- 'gui': WCTerms,
- 'params': {'icon': self.icon_path('trustedcoin-wizard.png')},
+ 'params': params,
},
- 'trustedcoin_keystore_unlock': {
- 'gui': WCKeystorePassword,
- 'params': {'icon': self.icon_path('trustedcoin-wizard.png')},
+ 'trustedcoin_show_cosigner_qr': {
+ 'gui': WCShowCosignerQR,
+ 'params': params,
},
- 'trustedcoin_show_confirm_otp': {
- 'gui': WCShowConfirmOTP,
- 'params': {'icon': self.icon_path('trustedcoin-wizard.png')},
- }
}
wizard.navmap_merge(views)
- # insert page offering choice to go online or continue on another system
- ext_online = {
- 'trustedcoin_continue_online': {
- 'gui': WCContinueOnline,
- 'params': {'icon': self.icon_path('trustedcoin-wizard.png')},
- 'next': lambda d: 'trustedcoin_tos' if d['trustedcoin_go_online'] else 'wallet_password',
- 'accept': self.on_continue_online,
- 'last': lambda d: not d['trustedcoin_go_online'] and wizard.is_single_password()
- },
- 'trustedcoin_confirm_seed': {
- 'next': lambda d: 'trustedcoin_confirm_ext' if wizard.wants_ext(d) else 'trustedcoin_continue_online'
- },
- 'trustedcoin_confirm_ext': {
- 'next': 'trustedcoin_continue_online',
- },
- 'trustedcoin_keep_disable': {
- 'next': lambda d: 'trustedcoin_continue_online' if d['trustedcoin_keepordisable'] != 'disable'
- else 'wallet_password',
- }
- }
- wizard.navmap_merge(ext_online)
- def on_continue_online(self, wizard_data):
- if not wizard_data['trustedcoin_go_online']:
- self.logger.debug('Staying offline, create keystores here')
- xprv1, xpub1, xprv2, xpub2, xpub3, short_id = self.create_keys(wizard_data)
- k1 = keystore.from_xprv(xprv1)
- k2 = keystore.from_xpub(xpub2)
+class PsbtQRDialog(QRDialog, QtEventListener):
+ """Shows a transaction to the mobile cosigner, until the wallet sees it."""
- wizard_data['x1'] = k1.dump()
- wizard_data['x2'] = k2.dump()
+ def __init__(self, tx: 'PartialTransaction', *, wallet: 'Abstract_Wallet', parent, config, help_text: str):
+ qr_data, __ = tx.to_qr_data()
+ QRDialog.__init__(self, data=qr_data, parent=parent,
+ title=_('Partially signed transaction'), help_text=help_text, config=config)
+ self.wallet = wallet
+ self.prevouts = {txin.prevout.to_str() for txin in tx.inputs()}
+ self.finished.connect(lambda: self.unregister_callbacks())
+ self.register_callbacks()
+
+ @qt_event_listener
+ def on_event_adb_added_tx(self, adb, tx_hash, tx):
+ if adb != self.wallet.adb:
+ return
+ # the cosigner broadcast the transaction, so there is no point in showing it anymore
+ if any(txin.prevout.to_str() in self.prevouts for txin in tx.inputs()):
+ self.accept()
class WCDisclaimer(WalletWizardComponent):
@@ -335,18 +202,22 @@ def __init__(self, parent, wizard):
self._valid = True
def apply(self):
- pass
+ # the desktop wizard can only restore 2fa wallets from seed
+ self.wizard_data['keystore_type'] = 'haveseed'
-class WCChooseSeed(WalletWizardComponent):
+class WCKeepDisable(WalletWizardComponent):
def __init__(self, parent, wizard):
- WalletWizardComponent.__init__(self, parent, wizard, title=_('Create or restore'))
- message = _('Do you want to create a new seed, or restore a wallet using an existing seed?')
+ WalletWizardComponent.__init__(self, parent, wizard, title=_('Restore 2FA wallet'))
+ message = ' '.join([
+ _('You are going to restore a wallet protected with two-factor authentication.'),
+ _('Do you want to keep using two-factor authentication with this wallet, with the Electrum app on your phone as cosigner,'),
+ _('or do you want to disable it, and have two master private keys in your wallet?'),
+ ])
choices = [
- ChoiceItem(key='createseed', label=_('Create a new seed')),
- ChoiceItem(key='haveseed', label=_('I already have a seed')),
+ ChoiceItem(key='keep', label=_('Keep, with Electrum on my phone as cosigner')),
+ ChoiceItem(key='disable', label=_('Disable')),
]
-
self.choice_w = ChoiceWidget(message=message, choices=choices)
self.layout().addWidget(self.choice_w)
self.layout().addStretch(1)
@@ -354,116 +225,37 @@ def __init__(self, parent, wizard):
self._valid = True
def apply(self):
- self.wizard_data['keystore_type'] = self.choice_w.selected_key
+ self.wizard_data['trustedcoin_keepordisable'] = self.choice_w.selected_key
-class WCTerms(WalletWizardComponent):
+class WCShowCosignerQR(WalletWizardComponent):
def __init__(self, parent, wizard):
- WalletWizardComponent.__init__(self, parent, wizard, title=_('Terms and conditions'))
- self._has_tos = False
- self.tos_e = TOS()
- self.tos_e.setReadOnly(True)
- self.layout().addWidget(self.tos_e)
-
- def on_ready(self):
- self.fetch_terms_and_conditions()
-
- def fetch_terms_and_conditions(self):
- self.wizard.trustedcoin_qhelper.busyChanged.connect(self.on_busy_changed)
- self.wizard.trustedcoin_qhelper.termsAndConditionsRetrieved.connect(self.on_terms_retrieved)
- self.wizard.trustedcoin_qhelper.termsAndConditionsError.connect(self.on_terms_error)
- self.wizard.trustedcoin_qhelper.fetchTermsAndConditions()
-
- def on_busy_changed(self):
- self.busy = self.wizard.trustedcoin_qhelper.busy
-
- def on_terms_retrieved(self, tos: str) -> None:
- self._has_tos = True
- self.tos_e.setText(tos)
- self.validate()
+ WalletWizardComponent.__init__(self, parent, wizard, title=_('Mobile cosigner'))
- def on_terms_error(self, error: str) -> None:
- self.error = error
-
- def validate(self):
- self.valid = self._has_tos
-
- def apply(self):
- pass
-
-
-class WCShowConfirmOTP(WalletWizardComponent):
- _logger = get_logger(__name__)
-
- def __init__(self, parent, wizard):
- WalletWizardComponent.__init__(self, parent, wizard, title=_('Authenticator secret'))
- self._otp_verified = False
- self._is_online_continuation = False
-
- self.new_otp = QWidget()
- new_otp_layout = QVBoxLayout()
- scanlabel = WWLabel(_('Enter or scan into authenticator app. Then authenticate below'))
- new_otp_layout.addWidget(scanlabel)
+ self.layout().addWidget(WWLabel(' '.join([
+ _('On your phone, open Electrum and scan this QR code.'),
+ _("If there is no wallet on your phone yet, use the 'Scan QR code' button of its wizard."),
+ ])))
self.qr = QRCodeWidget('')
- new_otp_layout.addWidget(self.qr)
- self.secretlabel = WWLabel()
- new_otp_layout.addWidget(self.secretlabel)
- self.new_otp.setLayout(new_otp_layout)
-
- self.exist_otp = QWidget()
- exist_otp_layout = QVBoxLayout()
- knownlabel = WWLabel(_('This wallet is already registered with TrustedCoin.'))
- exist_otp_layout.addWidget(knownlabel)
- self.knownsecretlabel = WWLabel(_('If you still have your OTP secret, then authenticate below to finalize wallet creation'))
- exist_otp_layout.addWidget(self.knownsecretlabel)
- self.exist_otp.setLayout(exist_otp_layout)
-
- self.authlabelnew = WWLabel(_('Then, enter your Google Authenticator code:'))
- self.authlabelexist = WWLabel(_('Google Authenticator code:'))
-
- self.spinner = QMovie(icon_path('spinner.gif'))
- self.spinner.setScaledSize(QSize(24, 24))
- self.spinner.setBackgroundColor(QColor('black'))
- self.spinner_l = QLabel()
- self.spinner_l.setMargin(5)
- self.spinner_l.setVisible(False)
- self.spinner_l.setMovie(self.spinner)
-
- self.otp_status_l = QLabel()
- self.otp_status_l.setAlignment(Qt.AlignmentFlag.AlignHCenter)
- self.otp_status_l.setVisible(False)
-
- self.resetlabel = WWLabel(_('If you have lost your OTP secret, click the button below to request a new secret from the server.'))
- self.button = QPushButton('Request OTP secret')
- self.button.clicked.connect(self.on_request_otp)
-
- hbox = QHBoxLayout()
- hbox.addWidget(self.authlabelnew)
- hbox.addWidget(self.authlabelexist)
- hbox.addStretch(1)
- hbox.addWidget(self.spinner_l)
- self.otp_e = AmountEdit(None, is_int=True)
- self.otp_e.setFocus()
- self.otp_e.setMaximumWidth(150)
- self.otp_e.textEdited.connect(self.on_otp_edited)
- hbox.addWidget(self.otp_e)
-
- self.layout().addWidget(self.new_otp)
- self.layout().addWidget(self.exist_otp)
- self.layout().addLayout(hbox)
- self.layout().addWidget(self.otp_status_l)
- self.layout().addWidget(self.resetlabel)
- self.layout().addWidget(self.button)
+ self.layout().addWidget(self.qr)
+ warning_l = WWLabel(_('This QR code contains a private key of your wallet. Do not show it to anyone else.'))
+ warning_l.setStyleSheet(ColorScheme.RED.as_stylesheet())
+ self.layout().addWidget(warning_l)
self.layout().addStretch(1)
+ self._valid = True
+
def on_ready(self):
- self.wizard.trustedcoin_qhelper.busyChanged.connect(self.on_busy_changed)
- self.wizard.trustedcoin_qhelper.remoteKeyError.connect(self.on_remote_key_error)
- self.wizard.trustedcoin_qhelper.otpSuccess.connect(self.on_otp_success)
- self.wizard.trustedcoin_qhelper.otpError.connect(self.on_otp_error)
- self.wizard.trustedcoin_qhelper.remoteKeyError.connect(self.on_remote_key_error)
+ plugin = self.wizard.plugins.get_plugin('trustedcoin')
+ xprv1, xpub1, xprv2, xpub2, xpub3 = plugin.create_keys(self.wizard_data)
+ wallet_xpubs = self.wizard_data.get('trustedcoin_wallet_xpubs')
+ if wallet_xpubs is not None and wallet_xpubs != [xpub1, xpub2]:
+ self.error = _('This seed does not match this wallet.')
+ self.valid = False
+ return
+ self.qr.setData(make_cosigner_qr_data(xprv2, xpub1, xpub3))
- # set higher minHeight so the qr code and the input field are shown without scrolling
+ # set higher minHeight so the qr code is shown without scrolling
prev_height = self.wizard.height()
prev_min_height = self.wizard.minimumHeight()
def restore_prev_height():
@@ -471,173 +263,9 @@ def restore_prev_height():
self.wizard.resize(self.wizard.width(), prev_height)
self.wizard.next_button.clicked.disconnect(restore_prev_height)
self.wizard.back_button.clicked.disconnect(restore_prev_height)
- self.wizard.setMinimumHeight(530)
+ self.wizard.setMinimumHeight(600)
self.wizard.next_button.clicked.connect(restore_prev_height)
self.wizard.back_button.clicked.connect(restore_prev_height)
- self._is_online_continuation = 'seed' not in self.wizard_data
- if self._is_online_continuation:
- self.knownsecretlabel.setText(_('Authenticate below to finalize wallet creation'))
-
- self.wizard.trustedcoin_qhelper.createKeystore()
-
- def update(self):
- is_new = bool(self.wizard.trustedcoin_qhelper.remoteKeyState != 'wallet_known')
- self.new_otp.setVisible(is_new)
- self.exist_otp.setVisible(not is_new)
- self.authlabelnew.setVisible(is_new)
- self.authlabelexist.setVisible(not is_new)
- self.authlabelexist.setEnabled(not self._otp_verified)
- self.otp_e.setEnabled(not self._otp_verified)
- self.resetlabel.setVisible(not is_new and not self._otp_verified and not self._is_online_continuation)
- self.button.setVisible(not is_new and not self._otp_verified and not self._is_online_continuation)
-
- if self.wizard.trustedcoin_qhelper.otpSecret:
- self.secretlabel.setText(self.wizard.trustedcoin_qhelper.otpSecret)
- uri = 'otpauth://totp/Electrum 2FA %s?secret=%s&digits=6' % (
- os.path.basename(self.wizard_data['wallet_name']), self.wizard.trustedcoin_qhelper.otpSecret)
- self.qr.setData(uri)
-
- def on_busy_changed(self):
- if not self.wizard.trustedcoin_qhelper._verifyingOtp:
- self.busy = self.wizard.trustedcoin_qhelper.busy
- if not self.busy:
- self.update()
-
- def on_remote_key_error(self, text):
- self._logger.error(text)
- self.error = text
-
- def on_request_otp(self):
- self.otp_status_l.setVisible(False)
- self.wizard.trustedcoin_qhelper.resetOtpSecret()
- self.update()
-
- def on_otp_success(self):
- self._otp_verified = True
- self.otp_status_l.setText('Valid!')
- self.otp_status_l.setVisible(True)
- self.otp_status_l.setStyleSheet(ColorScheme.GREEN.as_stylesheet(False))
- self.setEnabled(True)
- self.spinner_l.setVisible(False)
- self.spinner.stop()
-
- self.valid = True
-
- def on_otp_error(self, message):
- self.otp_status_l.setText(message)
- self.otp_status_l.setVisible(True)
- self.otp_status_l.setStyleSheet(ColorScheme.RED.as_stylesheet(False))
- self.setEnabled(True)
- self.spinner_l.setVisible(False)
- self.spinner.stop()
-
- def on_otp_edited(self):
- self.otp_status_l.setVisible(False)
- text = self.otp_e.text()
- if len(text) > 0:
- try:
- otp_int = int(text)
- except ValueError:
- return
- if len(text) == 6:
- # verify otp
- self.wizard.trustedcoin_qhelper.checkOtp(self.wizard.trustedcoin_qhelper.shortId, otp_int)
- self.setEnabled(False)
- self.spinner_l.setVisible(True)
- self.spinner.start()
- self.otp_e.setText('')
-
def apply(self):
pass
-
-
-class WCKeepDisable(WalletWizardComponent):
- def __init__(self, parent, wizard):
- WalletWizardComponent.__init__(self, parent, wizard, title=_('Restore 2FA wallet'))
- message = ' '.join([
- 'You are going to restore a wallet protected with two-factor authentication.',
- 'Do you want to keep using two-factor authentication with this wallet,',
- 'or do you want to disable it, and have two master private keys in your wallet?'
- ])
- choices = [
- ChoiceItem(key='keep', label=_('Keep')),
- ChoiceItem(key='disable', label=_('Disable')),
- ]
- self.choice_w = ChoiceWidget(message=message, choices=choices)
- self.layout().addWidget(self.choice_w)
- self.layout().addStretch(1)
-
- self._valid = True
-
- def apply(self):
- self.wizard_data['trustedcoin_keepordisable'] = self.choice_w.selected_key
-
-
-class WCContinueOnline(WalletWizardComponent):
- def __init__(self, parent, wizard):
- WalletWizardComponent.__init__(self, parent, wizard, title=_('Continue Online'))
- self.cb_online = QCheckBox(_('Go online to complete wallet creation'))
-
- def on_ready(self):
- path = os.path.join(os.path.dirname(self.wizard._daemon.config.get_wallet_path()), self.wizard_data['wallet_name'])
- msg = [
- _("Your wallet file is: {}.").format(path),
- _("You need to be online in order to complete the creation of "
- "your wallet. If you want to continue online, keep the checkbox "
- "checked and press Next."),
- _("If you want this system to stay offline "
- "and continue the completion of the wallet on an online system, "
- "uncheck the checkbox and press Finish.")
- ]
-
- self.layout().addWidget(WWLabel('\n\n'.join(msg)))
- self.layout().addStretch(1)
-
- self.cb_online.setChecked(True)
- self.cb_online.stateChanged.connect(self.on_updated)
- # self.cb_online.setToolTip(_("Check this box to request a new secret. You will need to retype your seed."))
- self.layout().addWidget(self.cb_online)
- self.layout().setAlignment(self.cb_online, Qt.AlignmentFlag.AlignHCenter)
- self.layout().addStretch(1)
-
- self._valid = True
-
- def apply(self):
- self.wizard_data['trustedcoin_go_online'] = self.cb_online.isChecked()
-
-
-class WCKeystorePassword(WalletWizardComponent):
- def __init__(self, parent, wizard):
- WalletWizardComponent.__init__(self, parent, wizard, title=_('Unlock Keystore'))
- self.layout().addStretch(1)
-
- hbox2 = QHBoxLayout()
- hbox2.addStretch(1)
- self.pw_e = PasswordLineEdit('', self)
- self.pw_e.setFixedWidth(17 * char_width_in_lineedit())
- self.pw_e.textEdited.connect(self.on_text)
- pw_label = QLabel(_('Password') + ':')
- hbox2.addWidget(pw_label)
- hbox2.addWidget(self.pw_e)
- hbox2.addStretch(1)
- self.layout().addLayout(hbox2)
- self.layout().addStretch(1)
-
- self.ks = None
-
- def on_ready(self):
- self.ks = self.wizard_data['xprv1']
-
- def on_text(self):
- try:
- self.ks.check_password(self.pw_e.text())
- except InvalidPassword:
- self.valid = False
- return
- self.valid = True
-
- def apply(self):
- if self.valid:
- self.wizard_data['xprv1'] = self.ks.get_master_private_key(self.pw_e.text())
- self.wizard_data['password'] = self.pw_e.text()
diff --git a/electrum/plugins/trustedcoin/trustedcoin.py b/electrum/plugins/trustedcoin/trustedcoin.py
index b308e316abbc..59b0ef36db2a 100644
--- a/electrum/plugins/trustedcoin/trustedcoin.py
+++ b/electrum/plugins/trustedcoin/trustedcoin.py
@@ -23,28 +23,18 @@
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
-import json
-import time
import hashlib
-from typing import Dict, Union, Sequence, List, TYPE_CHECKING
-from urllib.parse import urljoin
-from urllib.parse import quote
-
-from aiohttp import ClientResponse
+from typing import Tuple, TYPE_CHECKING
import electrum_ecc as ecc
-from electrum import constants, keystore, version, bip32, bitcoin
-from electrum.bip32 import BIP32Node, xpub_type, is_xprv
+from electrum import constants, cosigner, keystore, bip32
+from electrum.bip32 import BIP32Node, xpub_type
from electrum.crypto import sha256
-from electrum.transaction import PartialTxOutput, PartialTxInput, PartialTransaction, Transaction
from electrum.mnemonic import Mnemonic, calc_seed_type, is_any_2fa_seed_type
from electrum.wallet import Multisig_Wallet, Deterministic_Wallet
from electrum.i18n import _
-from electrum.plugin import BasePlugin, hook
-from electrum.util import NotEnoughFunds, UserFacingException, error_text_str_to_safe_str
-from electrum.network import Network
-from electrum.logging import Logger
+from electrum.plugin import BasePlugin
from electrum.keystore import KeyStore
if TYPE_CHECKING:
@@ -64,187 +54,30 @@ def get_signing_xpub(xtype):
return node._replace(xtype=xtype).to_xpub()
-def get_billing_xpub():
- if constants.net.TESTNET:
- return "tpubD6NzVbkrYhZ4X11EJFTJujsYbUmVASAYY7gXsEt4sL97AMBdypiH1E9ZVTpdXXEy3Kj9Eqd1UkxdGtvDt5z23DKsh6211CfNJo8bLLyem5r"
- else:
- return "xpub6DTBdtBB8qUmH5c77v8qVGVoYk7WjJNpGvutqjLasNG1mbux6KsojaLrYf2sRhXAVU4NaFuHhbD9SvVPRt1MB1MaMooRuhHcAZH1yhQ1qDU"
-
-
DESKTOP_DISCLAIMER = [
- _("Two-factor authentication is a service provided by TrustedCoin. "
- "It uses a multi-signature wallet, where you own 2 of 3 keys. "
- "The third key is stored on a remote server that signs transactions on "
- "your behalf. To use this service, you will need a smartphone with "
- "Google Authenticator installed."),
- _("A small fee will be charged on each transaction that uses the "
- "remote server. You may check and modify your billing preferences "
- "once the installation is complete."),
- _("Note that your coins are not locked in this service. You may withdraw "
- "your funds at any time and at no cost, without the remote server, by "
- "using the 'restore wallet' option with your wallet seed."),
- _("The next step will generate the seed of your wallet. This seed will "
- "NOT be saved in your computer, and it must be stored on paper. "
- "To be safe from malware, you may want to do this on an offline "
- "computer, and move your wallet later to an online computer."),
+ _("The two-factor authentication service of TrustedCoin has been discontinued. "
+ "Your coins are not locked: they can be recovered with your 2FA seed."),
+ _("A 2FA wallet is a multi-signature wallet, where two of the three keys are derived from your seed. "
+ "You may keep using two-factor authentication, with the Electrum app on your phone as cosigner: "
+ "this computer will hold the first key, and your phone will hold the second key."),
+ _("Alternatively, you may disable two-factor authentication, and have both keys in this wallet."),
]
DISCLAIMER = DESKTOP_DISCLAIMER
MOBILE_DISCLAIMER = [
- _("Two-factor authentication is a service provided by TrustedCoin. "
- "To use it, you must have a separate device with Google Authenticator."),
- _("This service uses a multi-signature wallet, where you own 2 of 3 keys. "
- "The third key is stored on a remote server that signs transactions on "
- "your behalf. A small fee will be charged on each transaction that uses the "
- "remote server."),
- _("Note that your coins are not locked in this service. You may withdraw "
- "your funds at any time and at no cost, without the remote server, by "
- "using the 'restore wallet' option with your wallet seed."),
+ _("The two-factor authentication service of TrustedCoin has been discontinued. "
+ "Your coins are not locked: they can be recovered with your 2FA seed."),
+ _("To use this device as the cosigner of a 2FA wallet on Electrum desktop, "
+ "restore your 2FA seed on desktop, and scan the QR code it displays."),
+ _("Alternatively, you may restore your 2FA seed on this device, with two-factor authentication disabled."),
]
-RESTORE_MSG = _("Enter the seed for your 2-factor wallet:")
-
-
-class TrustedCoinException(Exception):
- def __init__(self, message, *, status_code=0):
- # note: 'message' is arbitrary text coming from the server
- safer_message = (
- f"Received error from 2FA server\n"
- f"[DO NOT TRUST THIS MESSAGE]:\n\n"
- f"status_code={status_code}\n\n"
- f"{error_text_str_to_safe_str(message)}")
- Exception.__init__(self, safer_message)
- self.status_code = status_code
-
-
-class ErrorConnectingServer(Exception):
- def __init__(self, reason: Union[str, Exception] = None):
- self.reason = reason
-
- def __str__(self):
- header = _("Error connecting to {} server").format('TrustedCoin')
- reason = self.reason
- if isinstance(reason, BaseException):
- reason = repr(reason)
- return f"{header}:\n{reason}" if reason else header
-
-
-class TrustedCoinCosignerClient(Logger):
- def __init__(self, user_agent=None, base_url='https://api.trustedcoin.com/2/'):
- self.base_url = base_url
- self.debug = False
- self.user_agent = user_agent
- Logger.__init__(self)
-
- async def handle_response(self, resp: ClientResponse):
- if resp.status != 200:
- try:
- r = await resp.json()
- message = r['message']
- except Exception:
- message = await resp.text()
- raise TrustedCoinException(message, status_code=resp.status)
- try:
- return await resp.json()
- except Exception:
- return await resp.text()
-
- def send_request(self, method, relative_url, data=None, *, timeout=None):
- network = Network.get_instance()
- if not network:
- raise ErrorConnectingServer('You are offline.')
- url = urljoin(self.base_url, relative_url)
- if self.debug:
- self.logger.debug(f'<-- {method} {url} {data}')
- headers = {}
- if self.user_agent:
- headers['user-agent'] = self.user_agent
- try:
- if method == 'get':
- response = Network.send_http_on_proxy(method, url,
- params=data,
- headers=headers,
- on_finish=self.handle_response,
- timeout=timeout)
- elif method == 'post':
- response = Network.send_http_on_proxy(method, url,
- json=data,
- headers=headers,
- on_finish=self.handle_response,
- timeout=timeout)
- else:
- raise Exception(f"unexpected {method=!r}")
- except TrustedCoinException:
- raise
- except Exception as e:
- raise ErrorConnectingServer(e)
- else:
- if self.debug:
- self.logger.debug(f'--> {response}')
- return response
-
- def get_terms_of_service(self, billing_plan='electrum-per-tx-otp'):
- """
- Returns the TOS for the given billing plan as a plain/text unicode string.
- :param billing_plan: the plan to return the terms for
- """
- payload = {'billing_plan': billing_plan}
- return self.send_request('get', 'tos', payload)
-
- def create(self, xpubkey1, xpubkey2, email, billing_plan='electrum-per-tx-otp'):
- """
- Creates a new cosigner resource.
- :param xpubkey1: a bip32 extended public key (customarily the hot key)
- :param xpubkey2: a bip32 extended public key (customarily the cold key)
- :param email: a contact email
- :param billing_plan: the billing plan for the cosigner
- """
- payload = {
- 'email': email,
- 'xpubkey1': xpubkey1,
- 'xpubkey2': xpubkey2,
- 'billing_plan': billing_plan,
- }
- return self.send_request('post', 'cosigner', payload)
-
- def auth(self, id, otp):
- """
- Attempt to authenticate for a particular cosigner.
- :param id: the id of the cosigner
- :param otp: the one time password
- """
- payload = {'otp': otp}
- return self.send_request('post', 'cosigner/%s/auth' % quote(id), payload)
-
- def get(self, id):
- """ Get billing info """
- return self.send_request('get', 'cosigner/%s' % quote(id))
-
- def get_challenge(self, id):
- """ Get challenge to reset Google Auth secret """
- return self.send_request('get', 'cosigner/%s/otp_secret' % quote(id))
-
- def reset_auth(self, id, challenge, signatures):
- """ Reset Google Auth secret """
- payload = {'challenge': challenge, 'signatures': signatures}
- return self.send_request('post', 'cosigner/%s/otp_secret' % quote(id), payload)
-
- def sign(self, id, transaction, otp):
- """
- Attempt to authenticate for a particular cosigner.
- :param id: the id of the cosigner
- :param transaction: the hex encoded [partially signed] compact transaction to sign
- :param otp: the one time password
- """
- payload = {
- 'otp': otp,
- 'transaction': transaction
- }
- return self.send_request('post', 'cosigner/%s/sign' % quote(id), payload,
- timeout=60)
-
-server = TrustedCoinCosignerClient(user_agent="Electrum/" + version.ELECTRUM_VERSION)
+def make_cosigner_qr_data(xprv2: str, xpub1: str, xpub3: str) -> str:
+ """The QR code that the desktop wizard displays for the mobile app to scan, which
+ sets it up as cosigner: the second master private key, and the two other public ones.
+ """
+ return cosigner.Cosigner(xprv=xprv2, xpubs=[xpub1, xpub3], m=2).to_qr_data()
class Wallet_2fa(Multisig_Wallet):
@@ -253,137 +86,14 @@ class Wallet_2fa(Multisig_Wallet):
def __init__(self, db, *, config):
self.m, self.n = 2, 3
+ if not db.get('x3'):
+ # wallet created offline, and never completed online with the TrustedCoin server
+ xpub3 = get_xpub3(db.get('x1')['xpub'], db.get('x2')['xpub'])
+ db.put('x3', keystore.from_xpub(xpub3).dump())
Deterministic_Wallet.__init__(self, db, config=config)
- self.is_billing = False
- self.billing_info = None
- self._load_billing_addresses()
-
- def _load_billing_addresses(self):
- billing_addresses = {
- 'legacy': self.db.get('trustedcoin_billing_addresses', {}),
- 'segwit': self.db.get('trustedcoin_billing_addresses_segwit', {})
- }
- self._billing_addresses = {} # type: Dict[str, Dict[int, str]] # addr_type -> index -> addr
- self._billing_addresses_set = set() # set of addrs
- for addr_type, d in list(billing_addresses.items()):
- self._billing_addresses[addr_type] = {}
- # convert keys from str to int
- for index, addr in d.items():
- self._billing_addresses[addr_type][int(index)] = addr
- self._billing_addresses_set.add(addr)
-
- def can_sign_without_server(self):
- return not self.keystores['x2'].is_watching_only()
-
- def get_user_id(self):
- return get_user_id(self.db)
-
- def min_prepay(self):
- return min(self.price_per_tx.keys())
-
- def num_prepay(self):
- default_fallback = self.min_prepay()
- num = self.config.PLUGIN_TRUSTEDCOIN_NUM_PREPAY
- if num not in self.price_per_tx:
- num = default_fallback
- return num
-
- def extra_fee(self):
- if self.can_sign_without_server():
- return 0
- if self.billing_info is None:
- self.plugin.start_request_thread(self)
- return 0
- if self.billing_info.get('tx_remaining'):
- return 0
- if self.is_billing:
- return 0
- n = self.num_prepay()
- price = int(self.price_per_tx[n])
- # sanity check: price capped at 0.5 mBTC per tx or 20 mBTC total
- # (note that the server can influence our choice of n by sending unexpected values)
- if price > min(50_000 * n, 2_000_000):
- raise Exception(f"too high trustedcoin fee ({price} for {n} txns)")
- return price
-
- def make_unsigned_transaction(
- self, *,
- outputs: List[PartialTxOutput],
- is_sweep=False,
- **kwargs,
- ) -> PartialTransaction:
-
- mk_tx = lambda o: Multisig_Wallet.make_unsigned_transaction(
- self, outputs=o, **kwargs)
- extra_fee = self.extra_fee() if not is_sweep else 0
- if extra_fee:
- address = self.billing_info['billing_address_segwit']
- 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() >= extra_fee:
- raise
- self.logger.info("not charging for this tx")
- else:
- tx = mk_tx(outputs)
- return tx
-
- 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_as_bytes().hex()
- assert raw_tx[:10] == "70736274ff", f"bad magic. {raw_tx[:10]}"
- try:
- r = server.sign(short_id, raw_tx, otp)
- except TrustedCoinException as e:
- if e.status_code == 400: # invalid OTP
- raise UserFacingException(_('Invalid one-time password.')) from e
- else:
- raise
- if r:
- 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
- self.plugin.start_request_thread(self)
-
- def add_new_billing_address(self, billing_index: int, address: str, addr_type: str):
- billing_addresses_of_this_type = self._billing_addresses[addr_type]
- saved_addr = billing_addresses_of_this_type.get(billing_index)
- if saved_addr is not None:
- if saved_addr == address:
- return # already saved this address
- else:
- raise Exception('trustedcoin billing address inconsistency.. '
- 'for index {}, already saved {}, now got {}'
- .format(billing_index, saved_addr, address))
- if billing_index > 50_000: # otherwise DOS against CPU/memory/disk
- raise Exception(f"trustedcoin billing_index too high. got {billing_index} > 50_000")
- # do we have all prior indices? (are we synced?)
- largest_index_we_have = max(billing_addresses_of_this_type) if billing_addresses_of_this_type else -1
- if largest_index_we_have + 1 < billing_index: # need to sync
- for i in range(largest_index_we_have + 1, billing_index):
- addr = make_billing_address(self, i, addr_type=addr_type)
- billing_addresses_of_this_type[i] = addr
- self._billing_addresses_set.add(addr)
- # save this address; and persist to disk
- billing_addresses_of_this_type[billing_index] = address
- self._billing_addresses_set.add(address)
- self._billing_addresses[addr_type] = billing_addresses_of_this_type
- self.db.put('trustedcoin_billing_addresses', self._billing_addresses['legacy'])
- self.db.put('trustedcoin_billing_addresses_segwit', self._billing_addresses['segwit'])
-
- def is_billing_address(self, addr: str) -> bool:
- return addr in self._billing_addresses_set
+
+ def can_sign_without_cosigner(self) -> bool:
+ return not self.keystores['x1'].is_watching_only() and not self.keystores['x2'].is_watching_only()
def can_enable_disable_keystore(self, ks: KeyStore) -> bool:
return False
@@ -418,37 +128,18 @@ def make_xpub(xpub, s) -> str:
return child_node.to_xpub()
-def make_billing_address(wallet, num, addr_type):
- long_id, short_id = wallet.get_user_id()
- xpub = make_xpub(get_billing_xpub(), long_id)
- usernode = BIP32Node.from_xkey(xpub)
- child_node = usernode.subkey_at_public_derivation([num])
- pubkey = child_node.eckey.get_public_key_bytes(compressed=True)
- if addr_type == 'legacy':
- return bitcoin.public_key_to_p2pkh(pubkey)
- elif addr_type == 'segwit':
- return bitcoin.public_key_to_p2wpkh(pubkey)
- else:
- raise ValueError(f'unexpected billing type: {addr_type}')
-
-
-def finish_requesting(func):
- def f(self, *args, **kwargs):
- try:
- return func(self, *args, **kwargs)
- finally:
- self.requesting = False
- return f
+def get_xpub3(xpub1: str, xpub2: str) -> str:
+ """The third key is derived from the TrustedCoin signing key."""
+ long_user_id, short_id = get_user_id({'x1': {'xpub': xpub1}, 'x2': {'xpub': xpub2}})
+ return make_xpub(get_signing_xpub(xpub_type(xpub1)), long_user_id)
class TrustedCoinPlugin(BasePlugin):
wallet_class = Wallet_2fa
- disclaimer_msg = DISCLAIMER
def __init__(self, parent, config, name):
BasePlugin.__init__(self, parent, config, name)
self.wallet_class.plugin = self
- self.requesting = False
def is_available(self):
return True
@@ -459,86 +150,6 @@ def is_enabled(self):
def can_user_disable(self):
return False
- @hook
- def tc_sign_wrapper(self, wallet, tx, on_success, on_failure):
- if not isinstance(wallet, self.wallet_class):
- return
- if tx.is_complete():
- return
- if wallet.can_sign_without_server():
- return
- if not wallet.keystores['x3'].can_sign(tx, ignore_watching_only=True):
- 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
-
- def prompt_user_for_otp(self, wallet, tx, on_success, on_failure) -> None:
- raise NotImplementedError()
-
- @hook
- def get_tx_extra_fee(self, wallet, tx: Transaction):
- if type(wallet) is not Wallet_2fa:
- return
- for o in tx.outputs():
- if wallet.is_billing_address(o.address):
- return o.address, o.value
-
- @finish_requesting
- def request_billing_info(self, wallet: 'Wallet_2fa', *, suppress_connection_error=True):
- if wallet.can_sign_without_server():
- return
- self.logger.info("request billing info")
- try:
- billing_info = server.get(wallet.get_user_id()[1])
- except ErrorConnectingServer as e:
- if suppress_connection_error:
- self.logger.info(repr(e))
- return
- raise
- billing_index = billing_info['billing_index']
- # add segwit billing address; this will be used for actual billing
- billing_address = make_billing_address(wallet, billing_index, addr_type='segwit')
- if billing_address != billing_info['billing_address_segwit']:
- raise Exception(f'unexpected trustedcoin billing address: '
- f'calculated {billing_address}, received {billing_info["billing_address_segwit"]}')
- wallet.add_new_billing_address(billing_index, billing_address, addr_type='segwit')
- # also add legacy billing address; only used for detecting past payments in GUI
- billing_address = make_billing_address(wallet, billing_index, addr_type='legacy')
- wallet.add_new_billing_address(billing_index, billing_address, addr_type='legacy')
-
- wallet.billing_info = billing_info
- wallet.price_per_tx = dict(billing_info['price_per_tx'])
- wallet.price_per_tx.pop(1, None)
- self.billing_info_retrieved(wallet)
- return True
-
- def billing_info_retrieved(self, wallet):
- # override to handle billing info when it becomes available
- pass
-
- def start_request_thread(self, wallet):
- from threading import Thread
- if self.requesting is False:
- self.requesting = True
- t = Thread(target=self.request_billing_info, args=(wallet,))
- t.daemon = True
- t.start()
- return t
-
- def make_seed(self, seed_type):
- if not is_any_2fa_seed_type(seed_type):
- raise Exception(f'unexpected seed type: {seed_type!r}')
- return Mnemonic('english').make_seed(seed_type=seed_type)
-
- @hook
- def do_clear(self, window):
- window.wallet.is_billing = False
-
@classmethod
def get_xkeys(cls, seed, t, passphrase, derivation):
assert is_any_2fa_seed_type(t)
@@ -576,38 +187,11 @@ def xkeys_from_seed(cls, seed, passphrase):
raise Exception(f'unexpected seed type: {t!r}')
return xprv1, xpub1, xprv2, xpub2
- @hook
- def get_action(self, db):
- if db.get('wallet_type') != '2fa':
- return
- if not db.get('x1'):
- return self, 'show_disclaimer'
- if not db.get('x2'):
- return self, 'show_disclaimer'
- if not db.get('x3'):
- return self, 'accept_terms_of_use'
-
# insert trustedcoin pages in new wallet wizard
def extend_wizard(self, wizard: 'NewWalletWizard'):
views = {
'trustedcoin_start': {
- 'next': 'trustedcoin_choose_seed',
- },
- 'trustedcoin_choose_seed': {
- 'next': lambda d: 'trustedcoin_create_seed' if d['keystore_type'] == 'createseed'
- else 'trustedcoin_have_seed'
- },
- 'trustedcoin_create_seed': {
- 'next': lambda d: 'trustedcoin_create_ext' if wizard.wants_ext(d) else 'trustedcoin_confirm_seed',
- },
- 'trustedcoin_create_ext': {
- 'next': 'trustedcoin_confirm_seed',
- },
- 'trustedcoin_confirm_seed': {
- 'next': lambda d: 'trustedcoin_confirm_ext' if wizard.wants_ext(d) else 'trustedcoin_tos',
- },
- 'trustedcoin_confirm_ext': {
- 'next': 'trustedcoin_tos',
+ 'next': 'trustedcoin_have_seed',
},
'trustedcoin_have_seed': {
'next': lambda d: 'trustedcoin_have_ext' if wizard.wants_ext(d) else 'trustedcoin_keep_disable',
@@ -616,66 +200,31 @@ def extend_wizard(self, wizard: 'NewWalletWizard'):
'next': 'trustedcoin_keep_disable',
},
'trustedcoin_keep_disable': {
- 'next': lambda d: 'trustedcoin_tos' if d['trustedcoin_keepordisable'] != 'disable'
+ 'next': lambda d: 'trustedcoin_show_cosigner_qr' if d['trustedcoin_keepordisable'] != 'disable'
else 'wallet_password',
- 'accept': self.recovery_disable,
+ 'accept': lambda d: self.recovery_disable(d) if d['trustedcoin_keepordisable'] == 'disable' else None,
'last': lambda d: wizard.is_single_password() and d['trustedcoin_keepordisable'] == 'disable'
},
- 'trustedcoin_tos': {
- 'next': lambda d: 'trustedcoin_show_confirm_otp' if 'xprv1' not in d or is_xprv(d['xprv1'])
- else 'trustedcoin_keystore_unlock'
- },
- 'trustedcoin_keystore_unlock': {
- 'next': 'trustedcoin_show_confirm_otp'
- },
- 'trustedcoin_show_confirm_otp': {
- 'accept': self.on_accept_otp_secret,
+ # show xprv2 to the mobile cosigner, keep xprv1
+ 'trustedcoin_show_cosigner_qr': {
+ 'accept': self.on_accept_cosigner_qr,
'next': 'wallet_password',
- 'last': lambda d: wizard.is_single_password() or 'xprv1' in d
- }
+ 'last': lambda d: wizard.is_single_password()
+ },
}
wizard.navmap_merge(views)
- # combined create_keystore and create_remote_key pre
- def create_keys(self, wizard_data):
- if 'seed' not in wizard_data:
- # online continuation
- xprv1, xpub1, xprv2, xpub2 = (wizard_data['xprv1'], wizard_data['xpub1'], None, wizard_data['xpub2'])
- else:
- seed_extension = wizard_data['seed_extra_words'] if wizard_data['seed_extend'] else ''
- xprv1, xpub1, xprv2, xpub2 = self.xkeys_from_seed(wizard_data['seed'], seed_extension)
-
- data = {'x1': {'xpub': xpub1}, 'x2': {'xpub': xpub2}}
-
- # Generate third key deterministically.
- long_user_id, short_id = get_user_id(data)
- xtype = xpub_type(xpub1)
- xpub3 = make_xpub(get_signing_xpub(xtype), long_user_id)
+ def create_keys(self, wizard_data) -> Tuple[str, str, str, str, str]:
+ seed_extension = wizard_data['seed_extra_words'] if wizard_data['seed_extend'] else ''
+ xprv1, xpub1, xprv2, xpub2 = self.xkeys_from_seed(wizard_data['seed'], seed_extension)
+ return xprv1, xpub1, xprv2, xpub2, get_xpub3(xpub1, xpub2)
- return xprv1, xpub1, xprv2, xpub2, xpub3, short_id
-
- def on_accept_otp_secret(self, wizard_data):
- self.logger.debug('OTP secret accepted, creating keystores')
- xprv1, xpub1, xprv2, xpub2, xpub3, short_id = self.create_keys(wizard_data)
- k1 = keystore.from_xprv(xprv1)
- k2 = keystore.from_xpub(xpub2)
- k3 = keystore.from_xpub(xpub3)
-
- wizard_data['x1'] = k1.dump()
- wizard_data['x2'] = k2.dump()
- wizard_data['x3'] = k3.dump()
+ def on_accept_cosigner_qr(self, wizard_data):
+ self.logger.debug('mobile cosigner confirmed, creating keystores')
+ xprv1, xpub1, xprv2, xpub2, xpub3 = self.create_keys(wizard_data)
+ wizard_data.update({'x1': xprv1, 'x2': xpub2, 'x3': xpub3})
def recovery_disable(self, wizard_data):
- if wizard_data['trustedcoin_keepordisable'] != 'disable':
- return
-
self.logger.debug('2fa disabled, creating keystores')
- xprv1, xpub1, xprv2, xpub2, xpub3, short_id = self.create_keys(wizard_data)
- k1 = keystore.from_xprv(xprv1)
- k2 = keystore.from_xprv(xprv2)
- k3 = keystore.from_xpub(xpub3)
-
- wizard_data['x1'] = k1.dump()
- wizard_data['x2'] = k2.dump()
- wizard_data['x3'] = k3.dump()
-
+ xprv1, xpub1, xprv2, xpub2, xpub3 = self.create_keys(wizard_data)
+ wizard_data.update({'x1': xprv1, 'x2': xprv2, 'x3': xpub3})
diff --git a/electrum/simple_config.py b/electrum/simple_config.py
index a4fed3f9064a..aa657aeb2f35 100644
--- a/electrum/simple_config.py
+++ b/electrum/simple_config.py
@@ -993,8 +993,6 @@ def __setattr__(self, name, value):
# connect to remote WT
WATCHTOWER_CLIENT_URL = ConfigVar('watchtower_url', default=None, type_=str)
- PLUGIN_TRUSTEDCOIN_NUM_PREPAY = ConfigVar('trustedcoin_prepay', default=20, type_=int)
-
def read_user_config(path: Optional[str]) -> Dict[str, Any]:
"""Parse and store the user config settings in electrum.conf into user_config[]."""
diff --git a/electrum/wallet.py b/electrum/wallet.py
index 9c8a888b0b12..1bfbff50e699 100644
--- a/electrum/wallet.py
+++ b/electrum/wallet.py
@@ -411,6 +411,7 @@ class Abstract_Wallet(ABC, Logger, EventListener):
txin_type: str
wallet_type: str
+ m = None # type: Optional[int] # number of signatures a multisig wallet requires
lnworker: Optional['LNWallet']
network: Optional['Network']
@@ -2007,7 +2008,6 @@ def make_unsigned_transaction(
inputs: Optional[List[PartialTxInput]] = None,
fee_policy: FeePolicy,
change_addr: str | None = None,
- is_sweep: bool = False, # used by Wallet_2fa subclass
rbf: bool = True,
BIP69_sort: Optional[bool] = True,
base_tx: Optional[Transaction] = None,
@@ -2573,9 +2573,6 @@ def _bump_fee_through_decreasing_payment(
return PartialTransaction.from_io(inputs, outputs)
def _is_rbf_allowed_to_touch_tx_output(self, txout: TxOutput) -> bool:
- # 2fa fee outputs if present, should not be removed or have their value decreased
- if self.is_billing_address(txout.address):
- return False
# submarine swap funding outputs must not be decreased
if self.lnworker and self.lnworker.swap_manager.is_lockup_address_for_a_swap(txout.address):
return False
@@ -2755,27 +2752,7 @@ def get_script_descriptor_for_address(self, address: str) -> Optional[Descriptor
pubkeys = [ks.get_pubkey_provider(addr_index) for ks in self.get_keystores()]
if not pubkeys:
return None
- if script_type == 'p2pk':
- return descriptor.PKDescriptor(pubkey=pubkeys[0])
- elif script_type == 'p2pkh':
- return descriptor.PKHDescriptor(pubkey=pubkeys[0])
- elif script_type == 'p2wpkh':
- return descriptor.WPKHDescriptor(pubkey=pubkeys[0])
- elif script_type == 'p2wpkh-p2sh':
- wpkh = descriptor.WPKHDescriptor(pubkey=pubkeys[0])
- return descriptor.SHDescriptor(subdescriptor=wpkh)
- elif script_type == 'p2sh':
- multi = descriptor.MultisigDescriptor(pubkeys=pubkeys, thresh=self.m, is_sorted=True)
- return descriptor.SHDescriptor(subdescriptor=multi)
- elif script_type == 'p2wsh':
- multi = descriptor.MultisigDescriptor(pubkeys=pubkeys, thresh=self.m, is_sorted=True)
- return descriptor.WSHDescriptor(subdescriptor=multi)
- elif script_type == 'p2wsh-p2sh':
- multi = descriptor.MultisigDescriptor(pubkeys=pubkeys, thresh=self.m, is_sorted=True)
- wsh = descriptor.WSHDescriptor(subdescriptor=multi)
- return descriptor.SHDescriptor(subdescriptor=wsh)
- else:
- raise NotImplementedError(f"unexpected {script_type=}")
+ return descriptor.from_legacy_electrum_script_type(script_type, pubkeys=pubkeys, m=self.m)
def can_sign(self, tx: Transaction) -> bool:
if not isinstance(tx, PartialTransaction):
@@ -3411,10 +3388,6 @@ def coin_price(self, txid, price_func, ccy, txin_value) -> Decimal:
p = self.price_at_timestamp(txid, price_func)
return p * txin_value/Decimal(COIN)
- def is_billing_address(self, addr):
- # overridden for TrustedCoin wallets
- return False
-
@abstractmethod
def is_watching_only(self) -> bool:
pass
diff --git a/electrum/wallet_db.py b/electrum/wallet_db.py
index e3f289b31d5e..6b0873f25c3b 100644
--- a/electrum/wallet_db.py
+++ b/electrum/wallet_db.py
@@ -46,7 +46,7 @@
from .json_db import JsonDB, locked, modifier
from . import stored_dict
from .stored_dict import StoredObject, stored_at, register_key, register_name
-from .plugin import run_hook, plugin_loaders
+from .plugin import plugin_loaders
from .version import ELECTRUM_VERSION
from .i18n import _
@@ -64,12 +64,6 @@ def __init__(self, split_data):
self._split_data = split_data
-class WalletUnfinished(WalletFileException):
- def __init__(self, wallet_db: 'WalletDB'):
- super().__init__()
- self._wallet_db = wallet_db
-
-
# seed_version is now used for the version of the wallet file
OLD_SEED_VERSION = 4 # electrum versions < 2.0
NEW_SEED_VERSION = 11 # electrum versions >= 2.0
@@ -2094,10 +2088,6 @@ def split_accounts(klass, root_path, split_data):
file_list.append(path)
return file_list
- def get_action(self):
- action = run_hook('get_action', self)
- return action
-
def load_plugins(self):
wallet_type = self.get('wallet_type')
if wallet_type in plugin_loaders:
diff --git a/electrum/wizard.py b/electrum/wizard.py
index abf99f5a03be..8554f4b149a1 100644
--- a/electrum/wizard.py
+++ b/electrum/wizard.py
@@ -180,7 +180,7 @@ def sanitize_stack_item(self, _stack_item) -> dict:
# multisig:
"multisig_participants", "multisig_signatures", "multisig_current_cosigner", "cosigner_keystore_type",
# trustedcoin:
- "trustedcoin_keepordisable", "trustedcoin_go_online",
+ "trustedcoin_keepordisable",
]
def sanitize(_dict):
@@ -723,7 +723,9 @@ def create_storage(self, path: str, data: dict):
# TODO: refactor using self.keystore_from_data
k = None
- if 'keystore_type' not in data:
+ if data['wallet_type'] == '2fa':
+ pass # keystores were created by the trustedcoin plugin, see below
+ elif 'keystore_type' not in data:
assert data['wallet_type'] == 'imported'
addresses = {}
if 'private_key_list' in data:
@@ -764,9 +766,6 @@ def create_storage(self, path: str, data: dict):
else:
script = data['script_type'] if data['script_type'] != 'p2pkh' else 'standard'
k = keystore.from_bip43_rootseed(root_seed, derivation=derivation, xtype=script)
- elif is_any_2fa_seed_type(data['seed_type']):
- self._logger.debug('creating keystore from 2fa seed')
- k = keystore.from_xprv(data['x1']['xprv'])
else:
raise NotImplementedError('unsupported/unknown seed_type %s' % data['seed_type'])
elif data['keystore_type'] == 'masterkey':
@@ -818,17 +817,11 @@ def create_storage(self, path: str, data: dict):
if data['wallet_type'] == 'standard':
db.put('keystore', k.dump())
elif data['wallet_type'] == '2fa':
- db.put('x1', k.dump())
- if 'trustedcoin_keepordisable' in data and data['trustedcoin_keepordisable'] == 'disable':
- k2 = keystore.from_xprv(data['x2']['xprv'])
- if data['encrypt'] and k2.may_have_password():
- k2.update_password(None, data['password'])
- db.put('x2', k2.dump())
- else:
- db.put('x2', data['x2'])
- if 'x3' in data:
- db.put('x3', data['x3'])
- db.put('use_trustedcoin', True)
+ for name in ['x1', 'x2', 'x3']:
+ k2fa = keystore.from_master_key(data[name])
+ if data['password'] and k2fa.may_have_password():
+ k2fa.update_password(None, data['password'])
+ db.put(name, k2fa.dump())
elif data['wallet_type'] == 'multisig':
if not isinstance(k, keystore.Xpub):
raise TypeError(f'unexpected keystore(main) type={type(k)} in multisig. not bip32.')
diff --git a/tests/test_cosigner.py b/tests/test_cosigner.py
new file mode 100644
index 000000000000..38471277200e
--- /dev/null
+++ b/tests/test_cosigner.py
@@ -0,0 +1,154 @@
+from typing import Tuple
+
+from electrum import keystore
+from electrum.address_synchronizer import TX_HEIGHT_UNCONFIRMED
+from electrum.bip32 import BIP32Node
+from electrum.bitcoin import address_to_script
+from electrum.cosigner import (Cosigner, HIGH_DERIVATION_INDEX, add_cosigner,
+ check_cosigners_password)
+from electrum.fee_policy import FixedFeePolicy
+from electrum.simple_config import SimpleConfig
+from electrum.transaction import PartialTransaction, PartialTxOutput, Transaction, tx_from_any
+from electrum.util import InvalidPassword
+
+from . import ElectrumTestCase
+from .test_wallet_vertical import WalletIntegrityHelper
+
+
+def _keys(name: str, xtype: str) -> Tuple[str, str]:
+ """A master key pair of that type, the same one for a given name."""
+ node = BIP32Node.from_rootseed(name.encode('utf8'), xtype=xtype)
+ return node.to_xprv(), node.to_xpub()
+
+
+class CosignerTestCase(ElectrumTestCase):
+ """This device signs for wallets it does not have, see electrum/cosigner.py."""
+
+ def setUp(self):
+ super().setUp()
+ self.config = SimpleConfig({'electrum_path': self.electrum_path})
+
+ def _make_tx(self, wallet) -> PartialTransaction:
+ """A transaction of that wallet, signed with the keys it has, as the cosigner
+ gets it: through a QR code, so it carries nothing but what a PSBT carries.
+ """
+ spk = address_to_script(wallet.get_receiving_addresses()[0])
+ funding_tx = Transaction(
+ '0200000001' + 32 * '11' + '0000000000ffffffff01'
+ + (100_000).to_bytes(8, 'little').hex() + bytes([len(spk)]).hex() + spk.hex() + '00000000')
+ wallet.adb.receive_tx_callback(funding_tx, tx_height=TX_HEIGHT_UNCONFIRMED)
+ outputs = [PartialTxOutput.from_address_and_value('bc1qs2svwhfz47qv9qju2waa6prxzv5f522fc4p06t', 50_000)]
+ tx = wallet.make_unsigned_transaction(outputs=outputs, fee_policy=FixedFeePolicy(1000))
+ wallet.sign_transaction(tx, password=None) # does nothing if the wallet is watching-only
+ qr_data, __ = tx.to_qr_data()
+ return tx_from_any(qr_data)
+
+ async def test_cosigns_for_a_multisig_wallet(self):
+ # a 2-of-3 wallet on another device, which holds the first key only
+ xprv1, xpub1 = _keys('key 1', 'p2wsh')
+ xprv2, xpub2 = _keys('key 2', 'p2wsh')
+ xprv3, xpub3 = _keys('key 3', 'p2wsh')
+ wallet = WalletIntegrityHelper.create_multisig_wallet(
+ [keystore.from_xprv(xprv1), keystore.from_xpub(xpub2), keystore.from_xpub(xpub3)],
+ '2of3', config=self.config)
+ tx = self._make_tx(wallet)
+ self.assertFalse(tx.is_complete())
+
+ cosigner = Cosigner(xprv=xprv2, xpubs=[xpub1, xpub3], m=2)
+ # the change of the transaction is recomputed from the master public keys
+ change = [txout for txout in tx.outputs() if cosigner.is_wallet_output(txout)]
+ self.assertEqual(1, len(change))
+ self.assertTrue(wallet.is_mine(change[0].address))
+
+ cosigner.sign_transaction(tx)
+ self.assertTrue(tx.is_complete())
+
+ async def test_signs_for_a_single_sig_wallet(self):
+ # a watching-only wallet on another device: this one holds its key
+ xprv, xpub = _keys('the only key', 'p2wpkh')
+ wallet = WalletIntegrityHelper.create_standard_wallet(
+ keystore.from_xpub(xpub), config=self.config)
+ tx = self._make_tx(wallet)
+ self.assertFalse(tx.is_complete())
+
+ cosigner = Cosigner(xprv=xprv)
+ change = [txout for txout in tx.outputs() if cosigner.is_wallet_output(txout)]
+ self.assertEqual(1, len(change))
+ self.assertTrue(wallet.is_mine(change[0].address))
+
+ cosigner.sign_transaction(tx)
+ self.assertTrue(tx.is_complete())
+
+ async def test_change_sent_far_ahead_of_the_wallet_is_flagged(self):
+ # a wallet derives only a few addresses beyond the ones it has used, so change
+ # sent far ahead of them may never be found: the app warns about that
+ xprv, xpub = _keys('the only key', 'p2wpkh')
+ wallet = WalletIntegrityHelper.create_standard_wallet(
+ keystore.from_xpub(xpub), config=self.config)
+ tx = self._make_tx(wallet)
+ cosigner = Cosigner(xprv=xprv)
+
+ change = [txout for txout in tx.outputs() if cosigner.is_wallet_output(txout)]
+ self.assertEqual(1, len(change))
+ self.assertFalse(cosigner.is_high_derivation_index(change[0]))
+
+ # the transaction can pay to the wallet and still be out of its reach
+ der_suffix = [1, HIGH_DERIVATION_INDEX]
+ far_ahead = PartialTxOutput(
+ scriptpubkey=cosigner.get_script_descriptor(der_suffix).expand().output_script,
+ value=change[0].value)
+ far_ahead.bip32_paths = {
+ cosigner.keystore.derive_pubkey(*der_suffix): (bytes.fromhex(cosigner.cosigner_id), der_suffix)}
+ self.assertTrue(cosigner.is_wallet_output(far_ahead))
+ self.assertTrue(cosigner.is_high_derivation_index(far_ahead))
+
+ async def test_script_type_follows_the_master_keys(self):
+ for xtype, n, script_type in [
+ ('standard', 1, 'p2pkh'),
+ ('p2wpkh', 1, 'p2wpkh'),
+ ('p2wpkh-p2sh', 1, 'p2wpkh-p2sh'),
+ ('standard', 3, 'p2sh'),
+ ('p2wsh', 3, 'p2wsh'),
+ ('p2wsh-p2sh', 3, 'p2wsh-p2sh'),
+ ]:
+ with self.subTest(msg=f'{xtype} {n} of {n}'):
+ keys = [_keys(f'key {i} {xtype}', xtype) for i in range(n)]
+ cosigner = Cosigner(xprv=keys[0][0], xpubs=[xpub for __, xpub in keys[1:]], m=n)
+ self.assertEqual(script_type, cosigner.script_type)
+
+ async def test_qr_code_of_a_cosigner(self):
+ xprv, xpub = _keys('ours', 'p2wsh')
+ __, other = _keys('theirs', 'p2wsh')
+ cosigner = Cosigner(xprv=xprv, xpubs=[other], m=2)
+ self.assertEqual(f'cosigner:2:{xprv}:{other}', cosigner.to_qr_data())
+
+ scanned = Cosigner.from_qr_data(cosigner.to_qr_data())
+ self.assertEqual(cosigner.to_dict(), scanned.to_dict())
+ # a cosigner is indexed by the fingerprint its key uses in transactions
+ self.assertEqual(keystore.from_xpub(xpub).get_root_fingerprint(), scanned.cosigner_id)
+
+ async def test_keys_that_use_two_passwords_cannot_all_be_read(self):
+ # why the app must not let the user choose a new password once it holds keys:
+ # they are read with a single password, which the daemon re-encrypts them with
+ xprv_one, __ = _keys('one', 'p2wpkh')
+ xprv_two, __ = _keys('two', 'p2wpkh')
+ add_cosigner(self.config, Cosigner(xprv=xprv_one), 'password one')
+ add_cosigner(self.config, Cosigner(xprv=xprv_two), 'password two')
+ for password in ['password one', 'password two']:
+ with self.assertRaises(InvalidPassword):
+ check_cosigners_password(self.config, password)
+
+ async def test_invalid_qr_codes(self):
+ xprv, xpub = _keys('ours', 'p2wsh')
+ __, other = _keys('theirs', 'p2wsh')
+ __, single = _keys('theirs', 'p2wpkh')
+ for data in [
+ xprv, # not a cosigner QR code at all
+ f'cosigner:2:{xpub}:{other}', # a public key cannot sign
+ f'cosigner:2:{xprv}:{other}:{other}', # the same key twice
+ f'cosigner:3:{xprv}:{other}', # more signatures than keys
+ f'cosigner:0:{xprv}', # no signature at all
+ f'cosigner:1:{xprv}:{single}', # keys of different types
+ ]:
+ with self.assertRaises(ValueError):
+ Cosigner.from_qr_data(data)
diff --git a/tests/test_daemon.py b/tests/test_daemon.py
index c6b2b2eb78e6..360d1498e7a5 100644
--- a/tests/test_daemon.py
+++ b/tests/test_daemon.py
@@ -10,13 +10,20 @@
from electrum.wallet import Abstract_Wallet
from electrum.lnworker import LNWallet, LNPeerManager
from electrum.lnwatcher import LNWatcher
-from electrum import util
+from electrum import util, cosigner
+from electrum.cosigner import Cosigner
+from electrum.util import InvalidPassword
from electrum.utils.memory_leak import count_objects_in_memory
from electrum import constants
from . import ElectrumTestCase, as_testnet, restore_wallet_from_text__for_unittest
+# a key this device could sign with, and the fingerprint it uses in transactions
+COSIGNER_XPRV = "xprv9s21ZrQH143K3feHz3D5uMWDq118ZefvHNXxqg1Lsc222kCPNxix2skuH7TTnecemDdogJvkSqHDa3qqo8vkdxPQJwocYmg13X4FPnY5Y51"
+COSIGNER_ID = "f95ca6f8"
+
+
class DaemonTestCase(ElectrumTestCase):
config: 'SimpleConfig'
@@ -196,6 +203,39 @@ async def test_can_unify_large_folder_yet_to_be_unified(self):
self.assertTrue(is_unified)
self._run_post_unif_sanity_checks(paths, password="123456")
+ # cosigner keys --->
+
+ async def test_cosigner_keys_follow_the_directory_password(self):
+ # the keys of the wallets this device signs for are encrypted with that password
+ path = self._restore_wallet_from_text("9dk", password="123456", encrypt_file=True)
+ cosigner.add_cosigner(self.config, Cosigner(xprv=COSIGNER_XPRV), "123456")
+ is_unified = self.daemon.update_password_for_directory(old_password="123456", new_password="asdasd")
+ self.assertTrue(is_unified)
+ self._run_post_unif_sanity_checks([path], password="asdasd")
+ self.assertEqual(
+ COSIGNER_XPRV, cosigner.get_cosigner(self.config, COSIGNER_ID, "asdasd").xprv)
+ with self.assertRaises(InvalidPassword):
+ cosigner.get_cosigner(self.config, COSIGNER_ID, "123456")
+
+ async def test_cosigner_keys_that_use_another_password_leave_the_wallets_alone(self):
+ # e.g. the keystores file was restored from a backup; the user must still be able
+ # to open their wallets, so nothing may be half-updated
+ path = self._restore_wallet_from_text("9dk", password="123456", encrypt_file=True)
+ with open(path, "rb") as f:
+ raw_before = f.read()
+ cosigner.add_cosigner(self.config, Cosigner(xprv=COSIGNER_XPRV), "999999")
+ with self.assertRaises(InvalidPassword):
+ self.daemon.update_password_for_directory(old_password="123456", new_password="asdasd")
+ with open(path, "rb") as f:
+ self.assertEqual(raw_before, f.read())
+ self.assertEqual(
+ COSIGNER_XPRV, cosigner.get_cosigner(self.config, COSIGNER_ID, "999999").xprv)
+
+ async def test_no_cosigner_key_is_written_for_a_device_that_cosigns_for_nothing(self):
+ self._restore_wallet_from_text("9dk", password="123456", encrypt_file=True)
+ self.daemon.update_password_for_directory(old_password="123456", new_password="asdasd")
+ self.assertFalse(os.path.exists(os.path.join(self.electrum_path, "keystores")))
+
# misc --->
async def test_wallet_objects_are_properly_garbage_collected_after_check_pw_for_dir(self):
diff --git a/tests/test_wallet_vertical.py b/tests/test_wallet_vertical.py
index 257ecaafd654..fc19a4bf5752 100644
--- a/tests/test_wallet_vertical.py
+++ b/tests/test_wallet_vertical.py
@@ -2599,7 +2599,6 @@ async def get_transaction(self, txid):
coins=coins,
outputs=[PartialTxOutput.from_address_and_value(dest_addr, value='!')],
fee_policy=FixedFeePolicy(500),
- is_sweep=True,
)
tx.sign(keypairs)
self.assertTrue(tx.is_complete())
diff --git a/tests/test_wizard.py b/tests/test_wizard.py
index 76c4ae3903e6..79c2ba9447ff 100644
--- a/tests/test_wizard.py
+++ b/tests/test_wizard.py
@@ -7,7 +7,17 @@
from electrum.plugin import Plugins, DeviceInfo, Device
from electrum.wizard import ServerConnectWizard, NewWalletWizard, WizardViewState, KeystoreWizard
from electrum.daemon import Daemon
-from electrum.wallet import Abstract_Wallet, Deterministic_Wallet
+from electrum.wallet import Abstract_Wallet, Deterministic_Wallet, Wallet
+from electrum.wallet_db import WalletDB
+from electrum.address_synchronizer import TX_HEIGHT_UNCONFIRMED
+from electrum.bitcoin import address_to_script
+from electrum.fee_policy import FixedFeePolicy
+from electrum.transaction import (Transaction, PartialTransaction, PartialTxInput, PartialTxOutput,
+ Sighash, TxOutpoint, TxOutput, tx_from_any)
+from electrum import cosigner
+from electrum.cosigner import Cosigner
+from electrum.plugins.trustedcoin import trustedcoin
+from electrum.util import InvalidPassword
from electrum import util
from electrum import slip39
from electrum.bip32 import KeyOriginInfo
@@ -792,29 +802,46 @@ async def test_create_standard_wallet_haveseed_slip39_passphrase(self):
v = w.resolve_next(v.view, d)
self._set_password_and_check_address(v=v, w=w, recv_addr="bc1qs2svwhfz47qv9qju2waa6prxzv5f522fc4p06t")
- async def test_2fa_createseed(self):
+ def _wizard_for_2fa_haveseed(
+ self,
+ *,
+ name: str = "mywallet",
+ keepordisable: str,
+ seed_extra_words: str | None = None,
+ ) -> tuple[NewWalletWizard, WizardViewState]:
+ """Restores a 2fa wallet from seed, up to the 'wallet_password' view."""
self.assertTrue(self.config.get('enable_plugin_trustedcoin'))
- w = self._wizard_for(wallet_type='2fa')
+ w = self._wizard_for(name=name, wallet_type='2fa')
v = w._current
d = v.wizard_data
self.assertEqual('trustedcoin_start', v.view)
+
+ d.update({'keystore_type': 'haveseed'})
v = w.resolve_next(v.view, d)
- self.assertEqual('trustedcoin_choose_seed', v.view)
- d.update({'keystore_type': 'createseed'})
- v = w.resolve_next(v.view, d)
- self.assertEqual('trustedcoin_create_seed', v.view)
+ self.assertEqual('trustedcoin_have_seed', v.view)
d.update({
'seed': 'oblige basket safe educate whale bacon celery demand novel slice various awkward',
- 'seed_type': '2fa', 'seed_extend': False, 'seed_variant': 'electrum',
+ 'seed_type': '2fa', 'seed_extend': seed_extra_words is not None, 'seed_variant': 'electrum',
})
v = w.resolve_next(v.view, d)
- self.assertEqual('trustedcoin_confirm_seed', v.view)
- v = w.resolve_next(v.view, d)
- self.assertEqual('trustedcoin_tos', v.view)
- v = w.resolve_next(v.view, d)
- self.assertEqual('trustedcoin_show_confirm_otp', v.view)
+ if seed_extra_words is not None:
+ self.assertEqual('trustedcoin_have_ext', v.view)
+ d.update({'seed_extra_words': seed_extra_words})
+ v = w.resolve_next(v.view, d)
+ self.assertEqual('trustedcoin_keep_disable', v.view)
+ d.update({'trustedcoin_keepordisable': keepordisable})
v = w.resolve_next(v.view, d)
+ if keepordisable == 'keep':
+ self.assertEqual('trustedcoin_show_cosigner_qr', v.view)
+ v = w.resolve_next(v.view, d)
+ return w, v
+
+ async def test_2fa_haveseed_keep2FAenabled(self):
+ w, v = self._wizard_for_2fa_haveseed(keepordisable='keep')
wallet = self._set_password_and_check_address(v=v, w=w, recv_addr="bc1qnf5qafvpx0afk47433j3tt30pqkxp5wa263m77wt0pvyqq67rmfs522m94")
+ self.assertFalse(wallet.keystores['x1'].is_watching_only())
+ self.assertTrue(wallet.keystores['x2'].is_watching_only())
+ self.assertFalse(wallet.can_sign_without_cosigner())
with self.subTest(msg="2fa wallet cannot enable/disable keystore"):
for ks in wallet.get_keystores():
@@ -826,83 +853,201 @@ async def test_2fa_createseed(self):
wallet.enable_keystore(ks, False, None)
self.assertTrue("2fa wallet cannot" in ctx.exception.args[0])
- async def test_2fa_haveseed_keep2FAenabled(self):
- self.assertTrue(self.config.get('enable_plugin_trustedcoin'))
- w = self._wizard_for(wallet_type='2fa')
- v = w._current
- d = v.wizard_data
- self.assertEqual('trustedcoin_start', v.view)
-
- v = w.resolve_next(v.view, d)
- self.assertEqual('trustedcoin_choose_seed', v.view)
- d.update({'keystore_type': 'haveseed'})
- v = w.resolve_next(v.view, d)
- self.assertEqual('trustedcoin_have_seed', v.view)
- d.update({
- 'seed': 'oblige basket safe educate whale bacon celery demand novel slice various awkward',
- 'seed_type': '2fa', 'seed_extend': False, 'seed_variant': 'electrum',
- })
- v = w.resolve_next(v.view, d)
- self.assertEqual('trustedcoin_keep_disable', v.view)
- d.update({'trustedcoin_keepordisable': 'keep'})
- v = w.resolve_next(v.view, d)
- self.assertEqual('trustedcoin_tos', v.view)
- v = w.resolve_next(v.view, d)
- self.assertEqual('trustedcoin_show_confirm_otp', v.view)
- v = w.resolve_next(v.view, d)
- self._set_password_and_check_address(v=v, w=w, recv_addr="bc1qnf5qafvpx0afk47433j3tt30pqkxp5wa263m77wt0pvyqq67rmfs522m94")
-
async def test_2fa_haveseed_disable2FA(self):
- self.assertTrue(self.config.get('enable_plugin_trustedcoin'))
- w = self._wizard_for(wallet_type='2fa')
- v = w._current
- d = v.wizard_data
- self.assertEqual('trustedcoin_start', v.view)
-
- v = w.resolve_next(v.view, d)
- self.assertEqual('trustedcoin_choose_seed', v.view)
- d.update({'keystore_type': 'haveseed'})
- v = w.resolve_next(v.view, d)
- self.assertEqual('trustedcoin_have_seed', v.view)
- d.update({
- 'seed': 'oblige basket safe educate whale bacon celery demand novel slice various awkward',
- 'seed_type': '2fa', 'seed_extend': False, 'seed_variant': 'electrum',
- })
- v = w.resolve_next(v.view, d)
- self.assertEqual('trustedcoin_keep_disable', v.view)
- d.update({'trustedcoin_keepordisable': 'disable'})
- v = w.resolve_next(v.view, d)
- self._set_password_and_check_address(v=v, w=w, recv_addr="bc1qnf5qafvpx0afk47433j3tt30pqkxp5wa263m77wt0pvyqq67rmfs522m94")
+ w, v = self._wizard_for_2fa_haveseed(keepordisable='disable')
+ wallet = self._set_password_and_check_address(v=v, w=w, recv_addr="bc1qnf5qafvpx0afk47433j3tt30pqkxp5wa263m77wt0pvyqq67rmfs522m94")
+ self.assertTrue(wallet.can_sign_without_cosigner())
async def test_2fa_haveseed_passphrase(self):
- self.assertTrue(self.config.get('enable_plugin_trustedcoin'))
- w = self._wizard_for(wallet_type='2fa')
- v = w._current
- d = v.wizard_data
- self.assertEqual('trustedcoin_start', v.view)
-
- v = w.resolve_next(v.view, d)
- self.assertEqual('trustedcoin_choose_seed', v.view)
- d.update({'keystore_type': 'haveseed'})
- v = w.resolve_next(v.view, d)
- self.assertEqual('trustedcoin_have_seed', v.view)
- d.update({
- 'seed': 'oblige basket safe educate whale bacon celery demand novel slice various awkward',
- 'seed_type': '2fa', 'seed_extend': True, 'seed_variant': 'electrum',
- })
- v = w.resolve_next(v.view, d)
- self.assertEqual('trustedcoin_have_ext', v.view)
- d.update({'seed_extra_words': UNICODE_HORROR})
- v = w.resolve_next(v.view, d)
- self.assertEqual('trustedcoin_keep_disable', v.view)
- d.update({'trustedcoin_keepordisable': 'keep'})
- v = w.resolve_next(v.view, d)
- self.assertEqual('trustedcoin_tos', v.view)
- v = w.resolve_next(v.view, d)
- self.assertEqual('trustedcoin_show_confirm_otp', v.view)
- v = w.resolve_next(v.view, d)
+ w, v = self._wizard_for_2fa_haveseed(keepordisable='keep', seed_extra_words=UNICODE_HORROR)
self._set_password_and_check_address(v=v, w=w, recv_addr="bc1qcnu9ay4v3w0tawuxe6wlh6mh33rrpauqnufdgkxx7we8vpx3e6wqa25qud")
+ async def test_2fa_mobile_cosigner(self):
+ recv_addr = "bc1qnf5qafvpx0afk47433j3tt30pqkxp5wa263m77wt0pvyqq67rmfs522m94"
+ # desktop: restore the 2fa seed, and display the cosigner QR code
+ w, v = self._wizard_for_2fa_haveseed(name='desktop', keepordisable='keep')
+ xprv1, xpub1, xprv2, xpub2, xpub3 = w.plugins.get_plugin('trustedcoin').create_keys(v.wizard_data)
+ cosigner_qr = trustedcoin.make_cosigner_qr_data(xprv2, xpub1, xpub3)
+ self.assertEqual(
+ 'cosigner:2:'
+ 'ZprvAkSth4YNtt4491XR5QabACZmFzdDV7XPN2KiYEaJsbMBtKJDEsji7cNDMEEusUbvqYtze57R9UvKsBn2g5LLAjhQaVSNTMKagZfQKSe8KBA:'
+ 'Zpub6ySF6a5GjFcMJW1bx83wzKoke47zD6ouqGZ9zfBBne6htiwJtqPpvxfcWn9HsJF8wsKVzzMCunJA1Ux7Cm9AAtKY658yzheEV7usDVXa9E7:'
+ 'Zpub6vZyhw1ShkEwNuzEqzZx6oLjntiSVTtdNZwVuKFPkYTN8o1nq2UK4e6HnucfhgLm3UVJ1ZWrnfmN8swnT7bYJ5e7sGUqHsTghP8Wc7MJ5ji',
+ cosigner_qr)
+ desktop_wallet = self._set_password_and_check_address(v=v, w=w, recv_addr=recv_addr, password='desktop')
+
+ # mobile: the key is stored in the keystores file, no wallet is created
+ cosigner.add_cosigner(self.config, Cosigner.from_qr_data(cosigner_qr), 'mobile')
+
+ # this device also cosigns for another 2fa wallet
+ other_seed = 'universe topic remind silver february ranch shine worth innocent cattle enhance wise'
+ o_xprv1, o_xpub1, o_xprv2, o_xpub2 = trustedcoin.TrustedCoinPlugin.xkeys_from_seed(other_seed, '')
+ other = Cosigner.from_qr_data(trustedcoin.make_cosigner_qr_data(
+ o_xprv2, o_xpub1, trustedcoin.get_xpub3(o_xpub1, o_xpub2)))
+ cosigner.add_cosigner(self.config, other, 'mobile')
+ self.assertEqual(2, len(cosigner.get_cosigner_ids(self.config)))
+ # setting up the same wallet again does not add a second entry
+ cosigner.add_cosigner(self.config, Cosigner.from_qr_data(cosigner_qr), 'mobile')
+ self.assertEqual(2, len(cosigner.get_cosigner_ids(self.config)))
+ # reading the keystores file does not tell which wallets this device cosigns for
+ with open(os.path.join(self.electrum_path, "keystores")) as f:
+ stored = f.read()
+ for key in [xpub1, xpub2, xpub3, xprv2, o_xpub1, o_xpub2]:
+ self.assertNotIn(key, stored)
+ # they are indexed by the fingerprint their key uses in transactions
+ self.assertEqual(
+ sorted([keystore.from_xpub(xpub2).get_root_fingerprint(), other.cosigner_id]),
+ sorted(cosigner.get_cosigner_ids(self.config)))
+
+ with self.subTest(msg="desktop signs, mobile cosigns from its keystores file"):
+ spk = address_to_script(recv_addr)
+ funding_tx = Transaction(
+ '0200000001' + 32 * '11' + '0000000000ffffffff01'
+ + (100_000).to_bytes(8, 'little').hex() + bytes([len(spk)]).hex() + spk.hex() + '00000000')
+ desktop_wallet.adb.receive_tx_callback(funding_tx, tx_height=TX_HEIGHT_UNCONFIRMED)
+ outputs = [PartialTxOutput.from_address_and_value('bc1qs2svwhfz47qv9qju2waa6prxzv5f522fc4p06t', 50_000)]
+ tx = desktop_wallet.make_unsigned_transaction(outputs=outputs, fee_policy=FixedFeePolicy(1000))
+ desktop_wallet.sign_transaction(tx, password='desktop')
+ self.assertFalse(tx.is_complete())
+
+ qr_data, __ = tx.to_qr_data()
+ # the mobile app finds the key of that wallet in its keystores file
+ tx = tx_from_any(qr_data)
+ mobile = cosigner.find_cosigner_for_tx(self.config, tx, 'mobile')
+ self.assertEqual(xpub2, mobile.keystore.get_master_public_key())
+ mobile.sign_transaction(tx)
+ self.assertTrue(tx.is_complete())
+
+ with self.subTest(msg="the change of a transaction is not taken on trust"):
+ evil_tx = tx_from_any(qr_data)
+ ours = [o for o in evil_tx.outputs() if mobile.is_wallet_output(o)]
+ others = [o for o in evil_tx.outputs() if not mobile.is_wallet_output(o)]
+ self.assertEqual(1, len(ours))
+ self.assertEqual(1, len(others))
+ # whoever creates the transaction can claim one of our keys for an output of its own
+ others[0].bip32_paths = dict(ours[0].bip32_paths)
+ self.assertTrue(mobile.claims_our_key(others[0]))
+ # but that output does not pay to the script of the 2fa wallet
+ self.assertFalse(mobile.is_wallet_output(others[0]))
+ self.assertTrue(mobile.is_wallet_output(ours[0]))
+
+ with self.subTest(msg="a transaction whose fee is unknown is refused"):
+ # an input of unknown value, which the cosigner does not sign for
+ foreign_txin = PartialTxInput(prevout=TxOutpoint(txid=bytes(32), out_idx=0))
+ evil_tx = PartialTransaction.from_io(
+ [tx_from_any(qr_data).inputs()[0], foreign_txin],
+ tx_from_any(qr_data).outputs())
+ self.assertIsNone(evil_tx.get_fee())
+ with self.assertRaises(ValueError):
+ mobile.sign_transaction(evil_tx)
+
+ with self.subTest(msg="a non-default sighash type is refused"):
+ for sighash in [Sighash.NONE, Sighash.SINGLE, Sighash.ALL | Sighash.ANYONECANPAY]:
+ evil_tx = tx_from_any(qr_data)
+ evil_tx.inputs()[0].sighash = sighash
+ with self.assertRaises(ValueError):
+ mobile.sign_transaction(evil_tx)
+ # the default is accepted, whether it is spelled out or not
+ for sighash in [None, Sighash.ALL]:
+ signed = tx_from_any(qr_data)
+ signed.inputs()[0].sighash = sighash
+ mobile.sign_transaction(signed)
+ self.assertTrue(signed.is_complete())
+
+ with self.subTest(msg="wrong password"):
+ with self.assertRaises(InvalidPassword):
+ cosigner.find_cosigner_for_tx(self.config, tx, 'wrong')
+
+ with self.subTest(msg="transaction of an unrelated wallet"):
+ unrelated_tx = PartialTransaction.from_io(
+ [PartialTxInput(prevout=TxOutpoint(txid=bytes(32), out_idx=0))],
+ [PartialTxOutput.from_address_and_value(recv_addr, 10_000)])
+ self.assertIsNone(cosigner.find_cosigner_for_tx(self.config, unrelated_tx, 'mobile'))
+
+ with self.subTest(msg="the cosigner keys follow the password of the device"):
+ cosigner.update_cosigners_password(self.config, 'mobile', 'new password')
+ with self.assertRaises(InvalidPassword):
+ cosigner.find_cosigner_for_tx(self.config, tx, 'mobile')
+ mobile = cosigner.find_cosigner_for_tx(self.config, tx, 'new password')
+ self.assertEqual(xpub2, mobile.keystore.get_master_public_key())
+ # the other wallet this device cosigns for is re-encrypted too
+ self.assertEqual(
+ o_xprv2, cosigner.get_cosigner(self.config, other.cosigner_id, 'new password').xprv)
+
+ async def test_2fa_legacy_input_needs_prev_tx(self):
+ # the signature of a non-segwit input does not commit to its amount, so the
+ # cosigner needs the previous transaction in order to know the fee
+ seed = 'kiss live scene rude gate step hip quarter bunker oxygen motor glove'
+ xprv1, xpub1, xprv2, xpub2 = trustedcoin.TrustedCoinPlugin.xkeys_from_seed(seed, '')
+ xpub3 = trustedcoin.get_xpub3(xpub1, xpub2)
+ wallet = WalletIntegrityHelper.create_multisig_wallet(
+ [keystore.from_xprv(xprv1), keystore.from_xpub(xpub2), keystore.from_xpub(xpub3)],
+ '2of3', config=self.config)
+ self.assertEqual('p2sh', wallet.txin_type)
+
+ recv_addr = wallet.get_receiving_addresses()[0]
+ spk = address_to_script(recv_addr)
+ funding_tx = Transaction(
+ '0200000001' + 32 * '11' + '0000000000ffffffff01'
+ + (100_000).to_bytes(8, 'little').hex() + bytes([len(spk)]).hex() + spk.hex() + '00000000')
+ wallet.adb.receive_tx_callback(funding_tx, tx_height=TX_HEIGHT_UNCONFIRMED)
+ outputs = [PartialTxOutput.from_address_and_value('bc1qs2svwhfz47qv9qju2waa6prxzv5f522fc4p06t', 50_000)]
+ tx = wallet.make_unsigned_transaction(outputs=outputs, fee_policy=FixedFeePolicy(1000))
+ wallet.sign_transaction(tx, password=None)
+ cosigner.add_cosigner(
+ self.config, Cosigner.from_qr_data(trustedcoin.make_cosigner_qr_data(xprv2, xpub1, xpub3)),
+ 'mobile')
+ mobile = cosigner.find_cosigner_for_tx(self.config, tx, 'mobile')
+
+ with self.subTest(msg="the previous transaction is fetched, then the cosigner signs"):
+ signed = tx_from_any(tx.to_qr_data()[0])
+ # QR codes do not carry previous transactions
+ self.assertIsNone(signed.inputs()[0].utxo)
+ with self.assertRaises(ValueError):
+ mobile.sign_transaction(signed)
+ # the mobile app fetches it from the network before it signs
+ signed.inputs()[0].utxo = funding_tx
+ mobile.sign_transaction(signed)
+ self.assertTrue(signed.is_complete())
+
+ def unbacked_input() -> PartialTxInput:
+ # the amount is claimed by the transaction, but nothing backs it
+ txin = tx.inputs()[0]
+ evil_txin = PartialTxInput(prevout=txin.prevout)
+ evil_txin.witness_utxo = TxOutput(scriptpubkey=txin.scriptpubkey, value=1_000)
+ evil_txin.redeem_script = txin.redeem_script
+ evil_txin.bip32_paths = dict(txin.bip32_paths)
+ return evil_txin
+
+ with self.subTest(msg="a claimed amount that nothing backs is refused"):
+ evil_tx = PartialTransaction.from_io([unbacked_input()], tx.outputs())
+ self.assertIsNone(evil_tx.inputs()[0].utxo)
+ with self.assertRaises(ValueError):
+ mobile.sign_transaction(evil_tx)
+
+ with self.subTest(msg="a witness field does not make a legacy input pass"):
+ evil_txin = unbacked_input()
+ evil_txin.witness = bytes.fromhex('0100')
+ # electrum believes that field, so it must not decide whether we sign
+ self.assertTrue(evil_txin.is_segwit())
+ evil_tx = PartialTransaction.from_io([evil_txin], tx.outputs())
+ with self.assertRaises(ValueError):
+ mobile.sign_transaction(evil_tx)
+
+ async def test_2fa_wallet_without_x3(self):
+ # created offline, and never completed online with the TrustedCoin server
+ recv_addr = "bc1qnf5qafvpx0afk47433j3tt30pqkxp5wa263m77wt0pvyqq67rmfs522m94"
+ w, v = self._wizard_for_2fa_haveseed(keepordisable='keep')
+ wallet = self._set_password_and_check_address(v=v, w=w, recv_addr=recv_addr)
+ xpub3 = wallet.keystores['x3'].get_master_public_key()
+
+ storage = WalletStorage(wallet.storage.path)
+ db = WalletDB(storage.read(), storage=storage, upgrade=True)
+ db.put('x3', None)
+ wallet = Wallet(db, config=self.config)
+ self.assertEqual(xpub3, wallet.keystores['x3'].get_master_public_key())
+ self.assertEqual(recv_addr, wallet.get_receiving_addresses()[0])
+
async def test_2fa_haveseed_redirected_from_standard(self):
w = self._wizard_for(wallet_type='standard')
v = w._current
@@ -924,9 +1069,7 @@ async def test_2fa_haveseed_redirected_from_standard(self):
self.assertEqual('2fa', v.wizard_data['wallet_type'])
d.update({'trustedcoin_keepordisable': 'keep'})
v = w.resolve_next(v.view, d)
- self.assertEqual('trustedcoin_tos', v.view)
- v = w.resolve_next(v.view, d)
- self.assertEqual('trustedcoin_show_confirm_otp', v.view)
+ self.assertEqual('trustedcoin_show_cosigner_qr', v.view)
v = w.resolve_next(v.view, d)
wallet = self._set_password_and_check_address(v=v, w=w, recv_addr="bc1qnf5qafvpx0afk47433j3tt30pqkxp5wa263m77wt0pvyqq67rmfs522m94")
self.assertEqual('2fa', wallet.wallet_type)