Page MenuHomePhabricator

No OneTemporary

This file is larger than 256 KB, so syntax highlighting was skipped.
diff --git a/electrum/electrumabc/avalanche/proof.py b/electrum/electrumabc/avalanche/proof.py
index 9237115cb..be2ebea93 100644
--- a/electrum/electrumabc/avalanche/proof.py
+++ b/electrum/electrumabc/avalanche/proof.py
@@ -1,336 +1,366 @@
# -*- coding: utf-8 -*-
# -*- mode: python3 -*-
#
# Electrum ABC - lightweight eCash client
# Copyright (C) 2020-2022 The Electrum ABC developers
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation files
# (the "Software"), to deal in the Software without restriction,
# including without limitation the rights to use, copy, modify, merge,
# publish, distribute, sublicense, and/or sell copies of the Software,
# and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
"""This module deals with building avalanche proofs.
This requires serializing some keys and UTXO metadata (stakes), and signing
the hash of the stakes to prove ownership of the UTXO.
"""
from __future__ import annotations
import struct
+from dataclasses import dataclass
+from functools import partial
from io import BytesIO
from typing import TYPE_CHECKING, List, Optional, Union
from ..crypto import Hash as sha256d
from ..serialize import (
DeserializationError,
SerializableObject,
deserialize_blob,
deserialize_sequence,
serialize_blob,
serialize_sequence,
)
from ..transaction import OutPoint, get_address_from_output_script
from ..uint256 import UInt256
from .primitives import Key, PublicKey
if TYPE_CHECKING:
from .. import address
from ..address import Address, ScriptOutput
+ from ..wallet import AbstractWallet
NO_SIGNATURE = b"\0" * 64
+@dataclass
+class StakeAndSigningData:
+ """Class storing a stake waiting to be signed (waiting for the stake commitment)"""
+
+ stake: Stake
+ address: Address
+
+
class Stake(SerializableObject):
def __init__(
self,
utxo: OutPoint,
amount: int,
height: int,
- pubkey: PublicKey,
is_coinbase: bool,
+ pubkey: Optional[PublicKey] = None,
):
self.utxo = utxo
self.amount = amount
"""Amount in satoshis (int64)"""
self.height = height
"""Block height containing this utxo (uint32)"""
# Electrum ABC uses 0 and -1 for unconfirmed coins.
assert height > 0
self.pubkey = pubkey
"""Public key"""
self.is_coinbase = is_coinbase
- self.stake_id = UInt256(sha256d(self.serialize()))
+ self.stake_id = None
"""Stake id used for sorting stakes in a proof"""
+ def compute_stake_id(self):
+ self.stake_id = UInt256(sha256d(self.serialize()))
+
def serialize(self) -> bytes:
+ assert self.pubkey
is_coinbase = int(self.is_coinbase)
height_ser = self.height << 1 | is_coinbase
return (
self.utxo.serialize()
+ struct.pack("qI", self.amount, height_ser)
+ self.pubkey.serialize()
)
def get_hash(self, commitment: bytes) -> bytes:
"""Return the bitcoin hash of the concatenation of proofid
and the serialized stake."""
return sha256d(commitment + self.serialize())
@classmethod
def deserialize(cls, stream: BytesIO) -> Stake:
utxo = OutPoint.deserialize(stream)
amount = struct.unpack("q", stream.read(8))[0]
height_ser = struct.unpack("I", stream.read(4))[0]
pubkey = PublicKey.deserialize(stream)
- return Stake(utxo, amount, height_ser >> 1, pubkey, height_ser & 1)
+ return Stake(utxo, amount, height_ser >> 1, height_ser & 1, pubkey)
class ProofId(UInt256):
pass
class LimitedProofId(UInt256):
@classmethod
def build(
cls,
sequence: int,
expiration_time: int,
stakes: List[Stake],
payout_script_pubkey: bytes,
) -> LimitedProofId:
"""Build a limited proofid from the Proof parameters"""
ss = struct.pack("<Qq", sequence, expiration_time)
ss += serialize_blob(payout_script_pubkey)
ss += serialize_sequence(stakes)
return cls(sha256d(ss))
def compute_proof_id(self, master: PublicKey) -> ProofId:
ss = self.serialize()
ss += master.serialize()
return ProofId(sha256d(ss))
class SignedStake(SerializableObject):
def __init__(self, stake, sig):
self.stake: Stake = stake
self.sig: bytes = sig
"""Signature for this stake, bytes of length 64"""
def serialize(self) -> bytes:
return self.stake.serialize() + self.sig
@classmethod
def deserialize(cls, stream: BytesIO) -> SignedStake:
stake = Stake.deserialize(stream)
sig = stream.read(64)
return SignedStake(stake, sig)
def verify_signature(self, commitment: bytes):
return self.stake.pubkey.verify_schnorr(
self.sig, self.stake.get_hash(commitment)
)
class Proof(SerializableObject):
def __init__(
self,
sequence: int,
expiration_time: int,
master_pub: PublicKey,
signed_stakes: List[SignedStake],
payout_script_pubkey: bytes,
signature: bytes,
):
self.sequence = sequence
"""uint64"""
self.expiration_time = expiration_time
"""int64"""
self.master_pub: PublicKey = master_pub
"""Master public key"""
self.signed_stakes: List[SignedStake] = signed_stakes
"""List of signed stakes sorted by their stake ID."""
self.payout_script_pubkey: bytes = payout_script_pubkey
self.signature: bytes = signature
"""Schnorr signature of some of the proof's data by the master key."""
self.limitedid = LimitedProofId.build(
sequence,
expiration_time,
[ss.stake for ss in signed_stakes],
payout_script_pubkey,
)
self.proofid = self.limitedid.compute_proof_id(master_pub)
self.stake_commitment: bytes = sha256d(
struct.pack("<q", self.expiration_time) + self.master_pub.serialize()
)
def serialize(self) -> bytes:
p = struct.pack("<Qq", self.sequence, self.expiration_time)
p += self.master_pub.serialize()
p += serialize_sequence(self.signed_stakes)
p += serialize_blob(self.payout_script_pubkey)
p += self.signature
return p
@classmethod
def deserialize(cls, stream: BytesIO) -> Proof:
sequence, expiration_time = struct.unpack("<Qq", stream.read(16))
master_pub = PublicKey.deserialize(stream)
signed_stakes = deserialize_sequence(stream, SignedStake)
payout_pubkey = deserialize_blob(stream)
signature = stream.read(64)
if len(signature) != 64:
raise DeserializationError(
"Could not deserialize proof data. Not enough data left for a "
f"complete Schnorr signature (found {len(signature)} bytes, expected "
"64 bytes)."
)
return Proof(
sequence,
expiration_time,
master_pub,
signed_stakes,
payout_pubkey,
signature,
)
def verify_master_signature(self) -> bool:
return self.master_pub.verify_schnorr(
self.signature, self.limitedid.serialize()
)
def get_payout_address(self) -> Union[Address, ScriptOutput, address.PublicKey]:
_txout_type, addr = get_address_from_output_script(self.payout_script_pubkey)
return addr
def is_signed(self):
return self.signature != NO_SIGNATURE
class ProofBuilder:
def __init__(
self,
sequence: int,
expiration_time: int,
payout_address: Union[Address, ScriptOutput, address.PublicKey],
+ wallet: AbstractWallet,
master: Optional[Key] = None,
master_pub: Optional[PublicKey] = None,
+ pwd: Optional[str] = None,
):
self.sequence = sequence
"""uint64"""
self.expiration_time = expiration_time
"""int64"""
self.master: Optional[Key] = master
"""Master private key. If not specified, the proof signature will be invalid."""
if self.master is not None:
if master_pub is not None and self.master.get_pubkey() != master_pub:
raise RuntimeError("Mismatching master and master_pub")
self.master_pub = self.master.get_pubkey()
elif master_pub is not None:
self.master_pub = master_pub
else:
raise RuntimeError("One of master or master_pub must be specified")
self.payout_address = payout_address
self.payout_script_pubkey = payout_address.to_script()
- self.stake_commitment = sha256d(
- struct.pack("<q", self.expiration_time) + self.master_pub.serialize()
- )
-
self.signed_stakes: List[SignedStake] = []
"""List of signed stakes sorted by stake ID.
Adding stakes through :meth:`add_signed_stake` takes care of the sorting.
"""
+ self.pwd = pwd
+ """Password if any"""
+
+ self.wallet = wallet
+ """The signing wallet"""
+
@classmethod
- def from_proof(cls, proof: Proof, master: Optional[Key] = None) -> ProofBuilder:
+ def from_proof(
+ cls, proof: Proof, wallet: AbstractWallet, master: Optional[Key] = None
+ ) -> ProofBuilder:
"""Create a proof builder using the data from an existing proof.
This is useful for adding more stakes to it.
The provided master private key must match the proof's master public key,
because changing the key would invalidate previous signed stakes.
If no master key is provided, the generated proof will have an invalid
signature.
"""
if master is not None and master.get_pubkey() != proof.master_pub:
raise KeyError("Mismatching master and master_pub")
builder = cls(
proof.sequence,
proof.expiration_time,
proof.get_payout_address(),
+ wallet,
master,
proof.master_pub,
)
builder.signed_stakes = proof.signed_stakes
return builder
- def add_utxo(self, txid: UInt256, vout, amount, height, wif_privkey, is_coinbase):
- """This method builds the :class:`Stake`, signs the stake using the private key,
- and then forwards the data to meth:`add_signed_stake`.
-
- :param str txid: Transaction hash (hex str)
- :param int vout: Output index for this utxo in the transaction.
- :param int amount: Amount in satoshis
- :param int height: Block height containing this transaction
- :param str wif_privkey: Private key unlocking this UTXO (in WIF format)
- :param bool is_coinbase: Is the coin UTXO a coinbase UTXO
- :return:
- """
- key = Key.from_wif(wif_privkey)
- utxo = OutPoint(txid, vout)
- self.sign_and_add_stake(
- Stake(utxo, amount, height, key.get_pubkey(), is_coinbase), key
+ def sign_and_add_stake(self, stake: StakeAndSigningData):
+ task = partial(
+ self.wallet.sign_stake,
+ stake,
+ self.expiration_time,
+ self.master_pub,
+ self.pwd,
)
- def sign_and_add_stake(self, stake: Stake, key: Key):
- self.add_signed_stake(
- SignedStake(stake, key.sign_schnorr(stake.get_hash(self.stake_commitment)))
- )
+ def add_signed_stake(signature):
+ if not signature:
+ return
+
+ self.add_signed_stake(SignedStake(stake.stake, signature))
+
+ self.wallet.thread.add(task, on_success=add_signed_stake)
def add_signed_stake(self, ss: SignedStake):
+ # At this stage the stake pubkey should be set, so we can compute the
+ # stake id. This has to be delayed because the pubkey is returned by the
+ # hardware wallet so we can't determine it in advance in this case.
+ ss.stake.compute_stake_id()
+
self.signed_stakes.append(ss)
# Enforce a unique sorting for stakes in a proof. The sorting key is a UInt256.
# See UInt256.compare for the specifics about sorting these objects.
self.signed_stakes.sort(key=lambda ss: ss.stake.stake_id)
- def build(self) -> Proof:
- ltd_id = LimitedProofId.build(
- self.sequence,
- self.expiration_time,
- [ss.stake for ss in self.signed_stakes],
- self.payout_script_pubkey,
- )
+ def build(self, on_completion):
+ # Make sure all the stakes are signed by the time we compute the proof.
+ # We achieve this by queuing a dummy task in the wallet thread and only
+ # build the proof when it completed so we end up serializing the calls.
+ def build_proof(_):
+ ltd_id = LimitedProofId.build(
+ self.sequence,
+ self.expiration_time,
+ [ss.stake for ss in self.signed_stakes],
+ self.payout_script_pubkey,
+ )
- signature = (
- self.master.sign_schnorr(ltd_id.serialize())
- if self.master is not None
- else NO_SIGNATURE
- )
+ signature = (
+ self.master.sign_schnorr(ltd_id.serialize())
+ if self.master is not None
+ else NO_SIGNATURE
+ )
- return Proof(
- self.sequence,
- self.expiration_time,
- self.master_pub,
- self.signed_stakes,
- self.payout_script_pubkey,
- signature,
- )
+ on_completion(
+ Proof(
+ self.sequence,
+ self.expiration_time,
+ self.master_pub,
+ self.signed_stakes,
+ self.payout_script_pubkey,
+ signature,
+ )
+ )
+
+ task = partial(lambda: None)
+ self.wallet.thread.add(task, on_success=build_proof)
diff --git a/electrum/electrumabc/keystore.py b/electrum/electrumabc/keystore.py
index 2a1575bc5..cff3eed45 100644
--- a/electrum/electrumabc/keystore.py
+++ b/electrum/electrumabc/keystore.py
@@ -1,916 +1,954 @@
# -*- mode: python3 -*-
#
# Electrum ABC - lightweight eCash client
# Copyright (C) 2020 The Electrum ABC developers
# Copyright (C) 2016 The Electrum developers
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation files
# (the "Software"), to deal in the Software without restriction,
# including without limitation the rights to use, copy, modify, merge,
# publish, distribute, sublicense, and/or sell copies of the Software,
# and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
from __future__ import annotations
import hashlib
+import struct
from abc import abstractmethod
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union
from mnemonic import Mnemonic
-from . import bitcoin, mnemo, networks, slip39
+from . import avalanche, bitcoin, mnemo, networks, slip39
from .address import Address, PublicKey
from .bip32 import (
CKD_pub,
bip32_private_derivation,
bip32_private_key,
bip32_public_derivation,
bip32_root,
deserialize_xprv,
deserialize_xpub,
is_xprv,
is_xpub,
xpub_from_xprv,
)
from .crypto import Hash, pw_decode, pw_encode
from .ecc import (
CURVE_ORDER,
GENERATOR,
PRIVATE_KEY_BYTECOUNT,
ECPrivkey,
ECPubkey,
SignatureType,
be_bytes_to_number,
)
from .plugins import run_hook
from .printerror import PrintError, print_error
from .util import BitcoinException, InvalidPassword, WalletFileException, bh2u
if TYPE_CHECKING:
from electrumabc_gui.qt.util import TaskThread
from electrumabc_plugins.hw_wallet import HardwareHandlerBase, HWPluginBase
+ from .avalanche.proof import Stake
from .transaction import Transaction
# as per bip-0032
MAXIMUM_INDEX_DERIVATION_PATH = 2**31 - 1
class KeyStore(PrintError):
type: str
def __init__(self):
PrintError.__init__(self)
self.wallet_advice = {}
def has_seed(self) -> bool:
return False
def has_derivation(self) -> bool:
"""Only applies to BIP32 keystores."""
return False
def is_watching_only(self) -> bool:
return False
def can_import(self) -> bool:
return False
def may_have_password(self) -> bool:
"""Returns whether the keystore can be encrypted with a password."""
raise NotImplementedError()
def get_tx_derivations(self, tx: Transaction) -> Dict[bytes, List[int]]:
"""Return a map of {xpubkey: derivation}
where xpubkey is a hex string in the format described in Xpub.get_xpubkey
and derivation is a [change_index, address_index] list."""
keypairs = {}
for txin in tx.txinputs():
if txin.is_complete():
continue
x_signatures = txin.signatures
pubkeys, x_pubkeys = txin.get_sorted_pubkeys()
for k, xpubk in enumerate(x_pubkeys):
if x_signatures[k] is not None:
# this pubkey already signed
continue
derivation = self.get_pubkey_derivation(xpubk)
if not derivation:
continue
keypairs[xpubk] = derivation
return keypairs
def can_sign(self, tx) -> bool:
if self.is_watching_only():
return False
return bool(self.get_tx_derivations(tx))
def set_wallet_advice(self, addr, advice):
pass
+ def supports_stake_signature(self):
+ return not self.is_watching_only()
+
class SoftwareKeyStore(KeyStore):
def __init__(self):
KeyStore.__init__(self)
def may_have_password(self):
return not self.is_watching_only()
def sign_message(self, sequence, message, password, sigtype=SignatureType.ECASH):
privkey, compressed = self.get_private_key(sequence, password)
key = ECPrivkey(privkey)
return key.sign_message(message, compressed, sigtype=sigtype)
def decrypt_message(self, sequence, message, password):
privkey, compressed = self.get_private_key(sequence, password)
ec = ECPrivkey(privkey)
decrypted = ec.decrypt_message(message)
return decrypted
def sign_transaction(self, tx: Transaction, password, *, use_cache=False):
if self.is_watching_only():
return
# Raise if password is not correct.
self.check_password(password)
# Add private keys
keypairs = self.get_tx_derivations(tx)
for k, v in keypairs.items():
keypairs[k] = self.get_private_key(v, password)
# Sign
if keypairs:
tx.sign(keypairs, use_cache=use_cache)
+ def sign_stake(
+ self,
+ stake: Stake,
+ index: Tuple[int],
+ expiration_time: int,
+ master_pubkey: avalanche.primitives.PublicKey,
+ password: Optional[str],
+ ):
+ privkey, compressed = self.get_private_key(index, password)
+ key = avalanche.primitives.Key(privkey, compressed)
+ stake.pubkey = key.get_pubkey()
+
+ stake_commitment = Hash(
+ struct.pack("<q", expiration_time) + master_pubkey.serialize()
+ )
+
+ return key.sign_schnorr(stake.get_hash(stake_commitment))
+
class ImportedKeyStore(SoftwareKeyStore):
# keystore for imported private keys
# private keys are encrypted versions of the WIF encoding
def __init__(self, d):
SoftwareKeyStore.__init__(self)
keypairs = d.get("keypairs", {})
self.keypairs = {
PublicKey.from_string(pubkey): enc_privkey
for pubkey, enc_privkey in keypairs.items()
}
self._sorted = None
def is_deterministic(self):
return False
def get_master_public_key(self):
return None
def dump(self):
keypairs = {
pubkey.to_storage_string(): enc_privkey
for pubkey, enc_privkey in self.keypairs.items()
}
return {
"type": "imported",
"keypairs": keypairs,
}
def can_import(self):
return True
def get_addresses(self):
if not self._sorted:
addresses = [pubkey.address for pubkey in self.keypairs]
self._sorted = sorted(addresses, key=lambda address: address.to_ui_string())
return self._sorted
def address_to_pubkey(self, address):
for pubkey in self.keypairs:
if pubkey.address == address:
return pubkey
return None
def remove_address(self, address):
pubkey = self.address_to_pubkey(address)
if pubkey:
self.keypairs.pop(pubkey)
if self._sorted:
self._sorted.remove(address)
def check_password(self, password):
pubkey = list(self.keypairs.keys())[0]
self.export_private_key(pubkey, password)
def import_privkey(self, WIF_privkey, password):
pubkey = PublicKey.from_WIF_privkey(WIF_privkey)
self.keypairs[pubkey] = pw_encode(WIF_privkey, password)
self._sorted = None
return pubkey
def delete_imported_key(self, key):
self.keypairs.pop(key)
def export_private_key(self, pubkey, password):
"""Returns a WIF string"""
WIF_privkey = pw_decode(self.keypairs[pubkey], password)
# this checks the password
if pubkey != PublicKey.from_WIF_privkey(WIF_privkey):
raise InvalidPassword()
return WIF_privkey
def get_private_key(self, pubkey, password):
"""Returns a (32 byte privkey, is_compressed) pair."""
WIF_privkey = self.export_private_key(pubkey, password)
return PublicKey.privkey_from_WIF_privkey(WIF_privkey)
def get_pubkey_derivation(self, x_pubkey: bytes):
if x_pubkey[0] in [0x02, 0x03, 0x04]:
pubkey = PublicKey.from_pubkey(x_pubkey)
if pubkey in self.keypairs:
return pubkey
elif x_pubkey[0] == 0xFD:
addr = bitcoin.script_to_address(x_pubkey[1:])
return self.address_to_pubkey(addr)
def update_password(self, old_password, new_password):
self.check_password(old_password)
if new_password == "":
new_password = None
for k, v in self.keypairs.items():
b = pw_decode(v, old_password)
c = pw_encode(b, new_password)
self.keypairs[k] = c
class DeterministicKeyStore(SoftwareKeyStore):
def __init__(self, d):
SoftwareKeyStore.__init__(self)
self.seed = d.get("seed", "")
self.passphrase = d.get("passphrase", "")
self.seed_type = d.get("seed_type")
def is_deterministic(self):
return True
def dump(self):
d = {}
if self.seed:
d["seed"] = self.seed
if self.passphrase:
d["passphrase"] = self.passphrase
if self.seed_type is not None:
d["seed_type"] = self.seed_type
return d
def has_seed(self):
return bool(self.seed)
def is_watching_only(self):
return not self.has_seed()
def add_seed(self, seed, *, seed_type="electrum"):
if self.seed:
raise Exception("a seed exists")
self.seed = self.format_seed(seed)
self.seed_type = seed_type
def format_seed(self, seed):
"""Default impl. for BIP39 or Electrum seed wallets. Old_Keystore
overrides this."""
return Mnemonic.normalize_string(seed)
def get_seed(self, password):
return pw_decode(self.seed, password)
def get_passphrase(self, password):
return pw_decode(self.passphrase, password) if self.passphrase else ""
@abstractmethod
def get_private_key(self, sequence, password) -> Tuple[bytes, bool]:
"""Get private key for a given bip 44 index.
Index is the last two elements of the bip 44 path (change, address_index).
Returns (pk, is_compressed)
"""
pass
class Xpub:
def __init__(self):
self.xpub = None
self.xpub_receive = None
self.xpub_change = None
def get_master_public_key(self):
return self.xpub
def derive_pubkey(self, for_change: bool, n):
xpub = self.xpub_change if for_change else self.xpub_receive
if xpub is None:
xpub = bip32_public_derivation(self.xpub, "", f"/{for_change:d}")
if for_change:
self.xpub_change = xpub
else:
self.xpub_receive = xpub
return self.get_pubkey_from_xpub(xpub, (n,))
@classmethod
def get_pubkey_from_xpub(self, xpub, sequence) -> bytes:
_, _, _, _, c, cK = deserialize_xpub(xpub)
for i in sequence:
cK, c = CKD_pub(cK, c, i)
return cK
def get_xpubkey(self, c: int, i: int) -> bytes:
"""Get the xpub key for a derivation path (change_index, key_index) in the
internal format:
prefix "ff" + bytes encoded xpub + bytes encoded (little-endian) indexes.
"""
def encode_path_int(path_int) -> bytes:
if path_int < 0xFFFF:
encodes = path_int.to_bytes(2, "little")
else:
encodes = b"\xff\xff" + path_int.to_bytes(4, "little")
return encodes
s = b"".join(map(encode_path_int, (c, i)))
return b"\xff" + bitcoin.DecodeBase58Check(self.xpub) + s
@classmethod
def parse_xpubkey(self, pubkey: bytes):
assert pubkey[0] == 0xFF
pk = pubkey[1:]
xkey = bitcoin.EncodeBase58Check(pk[0:78])
dd = pk[78:]
s = []
while dd:
# 2 bytes for derivation path index
n = int.from_bytes(dd[0:2], byteorder="little")
dd = dd[2:]
# in case of overflow, drop these 2 bytes; and use next 4 bytes instead
if n == 0xFFFF:
n = int.from_bytes(dd[0:4], byteorder="little")
dd = dd[4:]
s.append(n)
assert len(s) == 2
return xkey, s
def get_pubkey_derivation_based_on_wallet_advice(self, x_pubkey: bytes):
_, addr = xpubkey_to_address(x_pubkey)
retval = self.wallet_advice.get(addr)
# None or the derivation based on wallet advice.
return retval
def get_pubkey_derivation(self, x_pubkey: bytes):
if x_pubkey[0] == 0xFD:
return self.get_pubkey_derivation_based_on_wallet_advice(x_pubkey)
if x_pubkey[0] != 0xFF:
return
xpub, derivation = self.parse_xpubkey(x_pubkey)
if self.xpub != xpub:
return
return derivation
class BIP32KeyStore(DeterministicKeyStore, Xpub):
def __init__(self, d):
Xpub.__init__(self)
DeterministicKeyStore.__init__(self, d)
self.xpub = d.get("xpub")
self.xprv = d.get("xprv")
self.derivation = d.get("derivation")
def dump(self):
d = DeterministicKeyStore.dump(self)
d["type"] = "bip32"
d["xpub"] = self.xpub
d["xprv"] = self.xprv
d["derivation"] = self.derivation
return d
def get_master_private_key(self, password):
return pw_decode(self.xprv, password)
def check_password(self, password):
xprv = pw_decode(self.xprv, password)
try:
assert bitcoin.DecodeBase58Check(xprv) is not None
except Exception:
# Password was None but key was encrypted.
raise InvalidPassword()
if deserialize_xprv(xprv)[4] != deserialize_xpub(self.xpub)[4]:
raise InvalidPassword()
def update_password(self, old_password, new_password):
self.check_password(old_password)
if new_password == "":
new_password = None
if self.has_seed():
decoded = self.get_seed(old_password)
self.seed = pw_encode(decoded, new_password)
if self.passphrase:
decoded = self.get_passphrase(old_password)
self.passphrase = pw_encode(decoded, new_password)
if self.xprv is not None:
b = pw_decode(self.xprv, old_password)
self.xprv = pw_encode(b, new_password)
def has_derivation(self) -> bool:
"""Note: the derivation path may not always be saved. Older versions
of Electron Cash would not save the path to keystore :/."""
return bool(self.derivation)
def is_watching_only(self):
return self.xprv is None
def add_xprv(self, xprv):
self.xprv = xprv
self.xpub = xpub_from_xprv(xprv)
def add_xprv_from_seed(self, bip32_seed, xtype, derivation):
xprv, xpub = bip32_root(bip32_seed, xtype)
xprv, xpub = bip32_private_derivation(xprv, "m/", derivation)
self.add_xprv(xprv)
self.derivation = derivation
def get_private_key(self, sequence, password):
xprv = self.get_master_private_key(password)
_, _, _, _, c, k = deserialize_xprv(xprv)
pk = bip32_private_key(sequence, k, c)
return pk, True
def set_wallet_advice(self, addr, advice): # overrides KeyStore.set_wallet_advice
self.wallet_advice[addr] = advice
class OldKeyStore(DeterministicKeyStore):
def __init__(self, d):
DeterministicKeyStore.__init__(self, d)
self.mpk = bytes.fromhex(d.get("mpk", ""))
def get_hex_seed(self, password):
return pw_decode(self.seed, password).encode("utf8")
def dump(self):
d = DeterministicKeyStore.dump(self)
d["mpk"] = self.mpk.hex()
d["type"] = "old"
return d
def add_seed(self, seedphrase, *, seed_type="old"):
DeterministicKeyStore.add_seed(self, seedphrase, seed_type=seed_type)
s = self.get_hex_seed(None)
self.mpk = self.mpk_from_seed(s)
def add_master_public_key(self, mpk: bytes):
self.mpk = mpk
def format_seed(self, seed):
from . import old_mnemonic
seed = super().format_seed(seed)
# see if seed was entered as hex
if seed:
try:
bytes.fromhex(seed)
return str(seed)
except Exception:
pass
words = seed.split()
seed = old_mnemonic.mn_decode(words)
if not seed:
raise Exception("Invalid seed")
return seed
def get_seed(self, password):
from . import old_mnemonic
s = self.get_hex_seed(password)
return " ".join(old_mnemonic.mn_encode(s))
@classmethod
def mpk_from_seed(klass, seed) -> bytes:
secexp = klass.stretch_key(seed)
privkey = ECPrivkey.from_secret_scalar(secexp)
return privkey.get_public_key_bytes(compressed=False)[1:]
@classmethod
def stretch_key(self, seed):
x = seed
for i in range(100000):
x = hashlib.sha256(x + seed).digest()
return be_bytes_to_number(x)
@classmethod
def get_sequence(self, mpk: bytes, for_change: Union[int, bool], n: int):
return be_bytes_to_number(Hash(f"{n:d}:{for_change:d}:".encode("ascii") + mpk))
@classmethod
def get_pubkey_from_mpk(self, mpk: bytes, for_change, n) -> bytes:
z = self.get_sequence(mpk, for_change, n)
master_public_key = ECPubkey(b"\x04" + mpk)
public_key = master_public_key + z * GENERATOR
return public_key.get_public_key_bytes(compressed=False)
def derive_pubkey(self, for_change, n) -> bytes:
return self.get_pubkey_from_mpk(self.mpk, for_change, n)
def get_private_key_from_stretched_exponent(self, for_change, n, secexp):
secexp = (secexp + self.get_sequence(self.mpk, for_change, n)) % CURVE_ORDER
pk = int.to_bytes(
secexp, length=PRIVATE_KEY_BYTECOUNT, byteorder="big", signed=False
)
return pk
def get_private_key(self, sequence, password):
seed = self.get_hex_seed(password)
secexp = self.check_seed(seed)
for_change, n = sequence
pk = self.get_private_key_from_stretched_exponent(for_change, n, secexp)
return pk, False
def check_seed(self, seed):
"""As a performance optimization we also return the stretched key
in case the caller needs it. Otherwise we raise InvalidPassword."""
secexp = self.stretch_key(seed)
master_private_key = ECPrivkey.from_secret_scalar(secexp)
master_public_key = master_private_key.get_public_key_bytes(compressed=False)[
1:
]
if master_public_key != self.mpk:
print_error(
"invalid password (mpk)", self.mpk.hex(), bh2u(master_public_key)
)
raise InvalidPassword()
return secexp
def check_password(self, password):
seed = self.get_hex_seed(password)
self.check_seed(seed)
def get_master_public_key(self) -> bytes:
return self.mpk
def get_xpubkey(self, for_change: int, n: int) -> bytes:
s = for_change.to_bytes(2, "little") + n.to_bytes(2, "little")
return b"\xfe" + self.mpk + s
@classmethod
def parse_xpubkey(self, x_pubkey: bytes) -> Tuple[bytes, List[int]]:
assert x_pubkey[0] == 0xFE
pk = x_pubkey[1:]
mpk = pk[0:64]
dd = pk[64:]
s = []
while dd:
n = int.from_bytes(dd[0:2], "little")
dd = dd[2:]
s.append(n)
assert len(s) == 2
return mpk, s
def get_pubkey_derivation(self, x_pubkey: bytes):
if x_pubkey[0] != 0xFE:
return
mpk, derivation = self.parse_xpubkey(x_pubkey)
if self.mpk != mpk:
return
return derivation
def update_password(self, old_password, new_password):
self.check_password(old_password)
if new_password == "":
new_password = None
if self.has_seed():
decoded = pw_decode(self.seed, old_password)
self.seed = pw_encode(decoded, new_password)
class HardwareKeyStore(KeyStore, Xpub):
hw_type: str
device: str
plugin: HWPluginBase
thread: Optional[TaskThread]
# restore_wallet_class = BIP32_RD_Wallet
max_change_outputs = 1
def __init__(self, d):
Xpub.__init__(self)
KeyStore.__init__(self)
# Errors and other user interaction is done through the wallet's
# handler. The handler is per-window and preserved across
# device reconnects
self.xpub = d.get("xpub")
self.label = d.get("label")
self.derivation = d.get("derivation")
self.handler: Optional[HardwareHandlerBase] = None
run_hook("init_keystore", self)
self.thread = None
def set_label(self, label):
self.label = label
def may_have_password(self):
return False
def is_deterministic(self):
return True
def has_derivation(self) -> bool:
return bool(self.derivation)
def dump(self):
return {
"type": "hardware",
"hw_type": self.hw_type,
"xpub": self.xpub,
"derivation": self.derivation,
"label": self.label,
}
def unpaired(self):
"""A device paired with the wallet was diconnected. This can be
called in any thread context."""
self.print_error("unpaired")
def paired(self):
"""A device paired with the wallet was (re-)connected. This can be
called in any thread context."""
self.print_error("paired")
def can_export(self):
return False
def is_watching_only(self):
"""The wallet is not watching-only; the user will be prompted for
pin and passphrase as appropriate when needed."""
assert not self.has_seed()
return False
def needs_prevtx(self):
"""Returns true if this hardware wallet needs to know the input
transactions to sign a transactions"""
return True
def get_password_for_storage_encryption(self) -> str:
from .storage import get_derivation_used_for_hw_device_encryption
client = self.plugin.get_client(self)
derivation = get_derivation_used_for_hw_device_encryption()
xpub = client.get_xpub(derivation, "standard")
password = self.get_pubkey_from_xpub(xpub, ())
return password.hex()
+ def supports_stake_signature(self):
+ # Override if the HW supports stake signature
+ return False
+
+ @abstractmethod
+ def sign_stake(
+ self,
+ stake: Stake,
+ index: Tuple[int],
+ expiration_time: int,
+ master_pubkey: avalanche.primitives.PublicKey,
+ password: Optional[str],
+ ):
+ raise NotImplementedError("This wallet does not support stake signing")
+
# extended pubkeys
def is_xpubkey(x_pubkey: bytes):
return x_pubkey[0] == 0xFF
def parse_xpubkey(x_pubkey: bytes):
return BIP32KeyStore.parse_xpubkey(x_pubkey)
def xpubkey_to_address(x_pubkey: bytes) -> Tuple[bytes, Address]:
if x_pubkey[0] == 0xFD:
address = bitcoin.script_to_address(x_pubkey[1:])
return x_pubkey, address
if x_pubkey[0] in [0x02, 0x03, 0x04]:
# regular pubkey
pubkey = x_pubkey
elif x_pubkey[0] == 0xFF:
xpub, s = BIP32KeyStore.parse_xpubkey(x_pubkey)
pubkey = BIP32KeyStore.get_pubkey_from_xpub(xpub, s)
elif x_pubkey[0] == 0xFE:
mpk, s = OldKeyStore.parse_xpubkey(x_pubkey)
pubkey = OldKeyStore.get_pubkey_from_mpk(mpk, s[0], s[1])
else:
raise BitcoinException(f"Cannot parse pubkey. prefix: {hex(x_pubkey[0])}")
if pubkey:
address = Address.from_pubkey(pubkey.hex())
return pubkey, address
def xpubkey_to_pubkey(x_pubkey: bytes) -> bytes:
pubkey, address = xpubkey_to_address(x_pubkey)
return pubkey
hw_keystores = {}
def register_keystore(hw_type, constructor):
hw_keystores[hw_type] = constructor
def hardware_keystore(d):
hw_type = d["hw_type"]
if hw_type in hw_keystores:
constructor = hw_keystores[hw_type]
return constructor(d)
raise WalletFileException(f"unknown hardware type: {hw_type}")
def load_keystore(storage, name):
d = storage.get(name, {})
t = d.get("type")
if not t:
raise WalletFileException(
f"Wallet format requires update.\nCannot find keystore for name {name}"
)
if t == "old":
k = OldKeyStore(d)
elif t == "imported":
k = ImportedKeyStore(d)
elif t == "bip32":
k = BIP32KeyStore(d)
elif t == "hardware":
k = hardware_keystore(d)
else:
raise WalletFileException(f"Unknown type {t} for keystore named {name}")
return k
def is_old_mpk(mpk: str) -> bool:
try:
int(mpk, 16)
except ValueError:
# invalid hexadecimal string
return False
return len(mpk) == 128
def is_address_list(text):
parts = text.split()
return parts and all(Address.is_valid(x) for x in parts)
def get_private_keys(text, *, allow_bip38=False):
"""Returns the list of WIF private keys parsed out of text (whitespace
delimiited).
Note that if any of the tokens in text are invalid, will return None.
Optionally allows for bip38 encrypted WIF keys. Requires fast bip38."""
# break by newline
parts = text.split("\n")
# for each line, remove all whitespace
parts = list(filter(bool, ("".join(x.split()) for x in parts)))
if parts and all(
(
bitcoin.is_private_key(x)
or (
allow_bip38 and bitcoin.is_bip38_available() and bitcoin.is_bip38_key(x)
)
)
for x in parts
):
return parts
def is_private_key_list(text, *, allow_bip38=False):
return bool(get_private_keys(text, allow_bip38=allow_bip38))
def is_private(text: str) -> bool:
return mnemo.is_seed(text) or is_xprv(text) or is_private_key_list(text)
def is_master_key(text: str) -> bool:
return is_old_mpk(text) or is_xprv(text) or is_xpub(text)
def is_bip32_key(text: str) -> bool:
return is_xprv(text) or is_xpub(text)
def _bip44_derivation(coin: int, account_id: int) -> str:
return f"m/44'/{coin}'/{account_id}'"
def bip44_derivation_btc(account_id: int) -> str:
"""Return the BTC BIP44 derivation path for an account id."""
coin = 1 if networks.net.TESTNET else 0
return _bip44_derivation(coin, account_id)
def bip44_derivation_bch(account_id: int) -> str:
"""Return the BCH derivation path."""
coin = 1 if networks.net.TESTNET else 145
return _bip44_derivation(coin, account_id)
def bip44_derivation_bch_tokens(account_id: int) -> str:
"""Return the BCH derivation path."""
return _bip44_derivation(245, account_id)
def bip44_derivation_xec(account_id: int) -> str:
"""Return the XEC BIP44 derivation path for an account id."""
coin = 1 if networks.net.TESTNET else 899
return _bip44_derivation(coin, account_id)
def bip44_derivation_xec_tokens(account_id: int) -> str:
"""Return the BIP44 derivation path for XEC SLP tokens"""
return _bip44_derivation(1899, account_id)
def bip39_normalize_passphrase(passphrase):
"""This is called by some plugins"""
return mnemo.normalize_text(passphrase or "", is_passphrase=True)
def from_bip32_seed_and_derivation(bip32_seed: bytes, derivation: str) -> BIP32KeyStore:
xtype = "standard"
keystore = BIP32KeyStore({})
keystore.add_xprv_from_seed(bip32_seed, xtype, derivation)
return keystore
def from_seed(
seed: str | slip39.EncryptedSeed, passphrase, *, seed_type="", derivation=None
) -> KeyStore:
if not seed_type:
seed_type = mnemo.seed_type_name(seed) # auto-detect
if seed_type == "old":
keystore = OldKeyStore({})
keystore.add_seed(seed, seed_type=seed_type)
elif seed_type in ["standard", "electrum"]:
derivation = "m/"
bip32_seed = mnemo.MnemonicElectrum.mnemonic_to_seed(seed, passphrase)
keystore = from_bip32_seed_and_derivation(bip32_seed, derivation)
keystore.add_seed(seed, seed_type="electrum") # force it to be "electrum"
keystore.passphrase = passphrase
elif seed_type == "bip39":
derivation = derivation or bip44_derivation_xec(0)
bip32_seed = mnemo.bip39_mnemonic_to_seed(seed, passphrase or "")
keystore = from_bip32_seed_and_derivation(bip32_seed, derivation)
keystore.add_seed(seed, seed_type=seed_type)
keystore.passphrase = passphrase
elif seed_type == "slip39":
derivation = derivation or bip44_derivation_xec(0)
bip32_seed = seed.decrypt(passphrase)
keystore = from_bip32_seed_and_derivation(bip32_seed, derivation)
keystore.seed_type = "slip39"
# We don't save the "seed" (the shares) and passphrase to disk for now.
# Users won't be able to display the "seed" (the shares used to restore the
# wallet).
else:
raise InvalidSeed()
return keystore
class InvalidSeed(Exception):
pass
def from_private_key_list(text):
keystore = ImportedKeyStore({})
for x in get_private_keys(text):
keystore.import_privkey(x, None)
return keystore
def from_old_mpk(mpk):
keystore = OldKeyStore({})
keystore.add_master_public_key(mpk)
return keystore
def from_xpub(xpub):
k = BIP32KeyStore({})
k.xpub = xpub
return k
def from_xprv(xprv):
xpub = xpub_from_xprv(xprv)
k = BIP32KeyStore({})
k.xprv = xprv
k.xpub = xpub
return k
def from_master_key(text):
if is_xprv(text):
k = from_xprv(text)
elif is_old_mpk(text):
k = from_old_mpk(text)
elif is_xpub(text):
k = from_xpub(text)
else:
raise BitcoinException("Invalid master key")
return k
diff --git a/electrum/electrumabc/tests/test_avalanche.py b/electrum/electrumabc/tests/test_avalanche.py
index a18b230f0..da8a64f9f 100644
--- a/electrum/electrumabc/tests/test_avalanche.py
+++ b/electrum/electrumabc/tests/test_avalanche.py
@@ -1,717 +1,814 @@
import base64
import unittest
+from unittest import mock
-from .. import address
+from .. import address, storage
from ..address import Address, ScriptOutput
from ..avalanche.delegation import (
Delegation,
DelegationBuilder,
DelegationId,
Level,
WrongDelegatorKeyError,
)
from ..avalanche.primitives import Key, PublicKey
-from ..avalanche.proof import LimitedProofId, Proof, ProofBuilder, ProofId, Stake
+from ..avalanche.proof import (
+ LimitedProofId,
+ Proof,
+ ProofBuilder,
+ ProofId,
+ Stake,
+ StakeAndSigningData,
+)
+from ..keystore import from_private_key_list
from ..serialize import DeserializationError
from ..transaction import OutPoint, get_address_from_output_script
from ..uint256 import UInt256
+from ..wallet import ImportedPrivkeyWallet
-master = Key.from_wif("Kwr371tjA9u2rFSMZjTNun2PXXP3WPZu2afRHTcta6KxEUdm1vEw")
+master_wif = "Kwr371tjA9u2rFSMZjTNun2PXXP3WPZu2afRHTcta6KxEUdm1vEw"
+master = Key.from_wif(master_wif)
# prove that this is the same key as before
pubkey_hex = "030b4c866585dd868a9d62348a9cd008d6a312937048fff31670e7e920cfc7a744"
assert master.get_pubkey().keydata.hex() == pubkey_hex
utxos = [
{
"txid": UInt256.from_hex(
"24ae50f5d4e81e340b29708ab11cab48364e2ae2c53f8439cbe983257919fcb7",
),
"vout": 0,
"amount": 10000,
"height": 672828,
"privatekey": "5HueCGU8rMjxEXxiPuD5BDku4MkFqeZyd4dZ1jvhTVqvbTLvyTJ",
"iscoinbase": False,
+ "address": Address.from_string(
+ "ecash:qzn96x3rn48vveny856sc7acl3zd9zq39q34hl80wj"
+ ),
},
]
expected_proof1 = (
"2a00000000000000fff053650000000021030b4c866585dd868a9d62348a9cd008d6a31"
"2937048fff31670e7e920cfc7a74401b7fc19792583e9cb39843fc5e22a4e3648ab1cb1"
"8a70290b341ee8d4f550ae24000000001027000000000000788814004104d0de0aaeaef"
"ad02b8bdc8a01a1b8b11c696bd3d66a2c5f10780d95b7df42645cd85228a6fb29940e85"
"8e7e55842ae2bd115d1ed7cc0e82d934e929c97648cb0abd9740c85a05a7d543c3d3012"
"73d79ff7054758579e30cc05cdfe1aca3374adfe55104b409ffce4a2f19d8a5981d5f0c"
"79b23edac73352ab2898aca89270282500788bac77505ca17d6d0dcc946ced3990c2857"
"c73743cd74d881fcbcbc8eaaa8d72812ebb9a556610687ca592fe907a4af024390e0a92"
"60c4f5ea59e7ac426cc5"
)
expected_limited_id1 = (
"e5845c13b93a1c207bd72033c185a2f833eef1748ee62fd49161119ac2c22864"
)
expected_proofid1 = "74c91491e5d6730ea1701817ed6c34e9627904fc3117647cc7d4bce73f56e45a"
# data from Bitcoin ABC's proof_tests.cpp
sequence2 = 5502932407561118921
expiration2 = 5658701220890886376
master2 = Key.from_wif("L4J6gEE4wL9ji2EQbzS5dPMTTsw8LRvcMst1Utij4e3X5ccUSdqW")
# master_pub2 = "023beefdde700a6bc02036335b4df141c8bc67bb05a971f5ac2745fd683797dde3"
payout_pubkey = address.PublicKey(
bytes.fromhex("038439233261789dd340bdc1450172d9c671b72ee8c0b2736ed2a3a250760897fd")
)
utxos2 = [
{
"txid": UInt256.from_hex(
"37424bda9a405b59e7d4f61a4c154cea5ee34e445f3daa6033b64c70355f1e0b"
),
"vout": 2322162807,
"amount": 3291110545,
"height": 426611719,
"iscoinbase": True,
"privatekey": "KydYrKDNsVnY5uhpLyC4UmazuJvUjNoKJhEEv9f1mdK1D5zcnMSM",
+ "address": Address.from_string(
+ "ecash:qrl3p3j0vda2p6t7aepzc3c3fshefz0uhveex0udjh"
+ ),
},
{
"txid": UInt256.from_hex(
"300cbba81ef40a6d269be1e931ccb58c074ace4a9b06cc0f2a2c9bf1e176ede4"
),
"vout": 2507977928,
"amount": 2866370216,
"height": 1298955966,
"iscoinbase": True,
"privatekey": "KydYrKDNsVnY5uhpLyC4UmazuJvUjNoKJhEEv9f1mdK1D5zcnMSM",
+ "address": Address.from_string(
+ "ecash:qrl3p3j0vda2p6t7aepzc3c3fshefz0uhveex0udjh"
+ ),
},
{
"txid": UInt256.from_hex(
"2313cb59b19774df1f0b86e079ddac61c5846021324e4a36db154741868c09ac"
),
"vout": 35672324,
"amount": 3993160086,
"height": 484677071,
"iscoinbase": True,
"privatekey": "KydYrKDNsVnY5uhpLyC4UmazuJvUjNoKJhEEv9f1mdK1D5zcnMSM",
+ "address": Address.from_string(
+ "ecash:qrl3p3j0vda2p6t7aepzc3c3fshefz0uhveex0udjh"
+ ),
},
]
expected_proof2 = (
"c964aa6fde575e4ce8404581c7be874e21023beefdde700a6bc02036335b4df141c8bc67"
"bb05a971f5ac2745fd683797dde3030b1e5f35704cb63360aa3d5f444ee35eea4c154c1a"
"f6d4e7595b409ada4b42377764698a915c2ac4000000000f28db322102449fb5237efe8f"
"647d32e8b64f06c22d1d40368eaca2a71ffc6a13ecc8bce680da44b13031186044cd54f0"
"084dcbe703bdb74058a1ddd3efffb347c04d45ced339a41eecedad05f8380a4115016404"
"a2787f51e27165171976d1925944df0231e4ed76e1f19b2c2a0fcc069b4ace4a078cb5cc"
"31e9e19b266d0af41ea8bb0c30c8b47c95a856d9aa000000007dfdd89a2102449fb5237e"
"fe8f647d32e8b64f06c22d1d40368eaca2a71ffc6a13ecc8bce68019201c99059772f645"
"2efb50579edc11370a94ea0b7fc61f22cbacc1339a22a04a41b20066c617138d715d9562"
"9a837e4f74633f823dddda0a0a40d0f37b59a4ac098c86414715db364a4e32216084c561"
"acdd79e0860b1fdf7497b159cb13230451200296c902ee000000009f2bc7392102449fb5"
"237efe8f647d32e8b64f06c22d1d40368eaca2a71ffc6a13ecc8bce6800eb604ecae881c"
"e1eb68dcc1f94725f70aedec1e60077b59eb4ce4b44d5475ba16b8b0b370cad583eaf342"
"b4442bc0f09001f1cb1074526c58f2047892f79c252321038439233261789dd340bdc145"
"0172d9c671b72ee8c0b2736ed2a3a250760897fdacd6bf9c0c881001dc5749966a2f6562"
"f291339521b3894326c0740de880565549fc6838933c95fbee05ff547ae89bad63e92f55"
"2ca3ea4cc01ac3e4869d0dc61b"
)
expected_limited_id2 = UInt256.from_hex(
"7223b8cc572bdf8f123ee7dd0316962f0367b0be8bce9b6e9465d1f413d95616",
)
expected_proofid2 = UInt256.from_hex(
"95c9673bc14f3c36e9310297e8df81867b42dd1a7bb7944aeb6c1797fbd2a6d5",
)
+class WalletDummyThread:
+ """Mimic the TaskThread for testing"""
+
+ def __init__(self):
+ self.tasks = []
+
+ def add(self, task, on_success=None, on_done=None, on_error=None):
+ result = task()
+ if on_done:
+ on_done()
+ if on_success:
+ on_success(result)
+
+
+@mock.patch.object(storage.WalletStorage, "_write")
+def wallet_from_wif_keys(keys_wif, _mock_write):
+ ks = from_private_key_list(keys_wif)
+ store = storage.WalletStorage("if_this_exists_mocking_failed_648151893")
+ store.put("keystore", ks.dump())
+ wallet = ImportedPrivkeyWallet(store)
+ wallet.thread = WalletDummyThread()
+ return wallet
+
+
class TestAvalancheProofBuilder(unittest.TestCase):
def setUp(self) -> None:
# Print the entire serialized proofs on assertEqual failure
self.maxDiff = None
def _test(
self,
master_key,
sequence,
expiration,
utxos,
payout_address,
expected_proof_hex,
expected_limited_proofid,
expected_proofid,
):
+ wallet = wallet_from_wif_keys("\n".join([utxo["privatekey"] for utxo in utxos]))
+
proofbuilder = ProofBuilder(
sequence=sequence,
expiration_time=expiration,
payout_address=payout_address,
+ wallet=wallet,
master=master_key,
)
for utxo in utxos:
- key = Key.from_wif(utxo["privatekey"])
-
proofbuilder.sign_and_add_stake(
- Stake(
- OutPoint(utxo["txid"], utxo["vout"]),
- utxo["amount"],
- utxo["height"],
- key.get_pubkey(),
- utxo["iscoinbase"],
- ),
- key,
+ StakeAndSigningData(
+ Stake(
+ OutPoint(utxo["txid"], utxo["vout"]),
+ utxo["amount"],
+ utxo["height"],
+ utxo["iscoinbase"],
+ ),
+ utxo["address"],
+ )
)
- proof = proofbuilder.build()
- self.assertEqual(proof.to_hex(), expected_proof_hex)
- self.assertEqual(proofbuilder.stake_commitment, proof.stake_commitment)
+ def check_proof(proof):
+ self.assertEqual(proof.to_hex(), expected_proof_hex)
- self.assertEqual(proof.limitedid, expected_limited_proofid)
- self.assertEqual(proof.proofid, expected_proofid)
+ self.assertEqual(proof.limitedid, expected_limited_proofid)
+ self.assertEqual(proof.proofid, expected_proofid)
- self.assertTrue(proof.verify_master_signature())
- for ss in proof.signed_stakes:
- self.assertTrue(ss.verify_signature(proof.stake_commitment))
+ self.assertTrue(proof.verify_master_signature())
+ for ss in proof.signed_stakes:
+ self.assertTrue(ss.verify_signature(proof.stake_commitment))
- proof.signature = 64 * b"\0"
- self.assertFalse(proof.verify_master_signature())
- for ss in proof.signed_stakes:
- self.assertTrue(ss.verify_signature(proof.stake_commitment))
+ proof.signature = 64 * b"\0"
+ self.assertFalse(proof.verify_master_signature())
+ for ss in proof.signed_stakes:
+ self.assertTrue(ss.verify_signature(proof.stake_commitment))
- ss = proof.signed_stakes[0]
- ss.sig = 64 * b"\0"
- self.assertFalse(ss.verify_signature(proof.stake_commitment))
- for ss in proof.signed_stakes[1:]:
- self.assertTrue(ss.verify_signature(proof.stake_commitment))
+ ss = proof.signed_stakes[0]
+ ss.sig = 64 * b"\0"
+ self.assertFalse(ss.verify_signature(proof.stake_commitment))
+ for ss in proof.signed_stakes[1:]:
+ self.assertTrue(ss.verify_signature(proof.stake_commitment))
+
+ proofbuilder.build(on_completion=check_proof)
def test_1_stake(self):
self._test(
master,
42,
1699999999,
utxos,
ScriptOutput.from_string(""),
expected_proof1,
# The following proofid and limited id were obtained by passing
# the previous serialized proof to `bitcoin-cli decodeavalancheproof`
LimitedProofId.from_hex(expected_limited_id1),
ProofId.from_hex(expected_proofid1),
)
# A test similar to Bitcoin ABC's "Properly signed 1 UTXO proof, P2PKH payout
# script" (proof_tests.cpp), except that I rebuild it with the node's
# buildavalancheproof RPC to get the same signatures, as the test proof was
# generated with a random nonce.
# RPC command used (Bitcoin ABC commit bdee6e2):
# src/bitcoin-cli buildavalancheproof 6296457553413371353 -4129334692075929194 "L4J6gEE4wL9ji2EQbzS5dPMTTsw8LRvcMst1Utij4e3X5ccUSdqW" '[{"txid":"915d9cc742b46b77c52f69eb6be16739e5ff1cd82ad4fa4ac6581d3ef29fa769","vout":567214302,"amount":4446386380000.00,"height":1370779804,"iscoinbase":false,"privatekey":"KydYrKDNsVnY5uhpLyC4UmazuJvUjNoKJhEEv9f1mdK1D5zcnMSM"}]' "ecash:qrupwtz3a7lngsf6xz9qxr75k9jvt07d3uexmwmpqy"
# Proof ID and limited ID verified with node RPC decodeavalancheproof.
self._test(
master2,
6296457553413371353,
-4129334692075929194,
[
{
"txid": UInt256.from_hex(
"915d9cc742b46b77c52f69eb6be16739e5ff1cd82ad4fa4ac6581d3ef29fa769"
),
"vout": 567214302,
"amount": 444638638000000,
"height": 1370779804,
"iscoinbase": False,
"privatekey": (
"KydYrKDNsVnY5uhpLyC4UmazuJvUjNoKJhEEv9f1mdK1D5zcnMSM"
),
+ "address": Address.from_string(
+ "ecash:qrl3p3j0vda2p6t7aepzc3c3fshefz0uhveex0udjh"
+ ),
},
],
Address.from_string("ecash:qrupwtz3a7lngsf6xz9qxr75k9jvt07d3uexmwmpqy"),
"d97587e6c882615796011ec8f9a7b1c621023beefdde700a6bc02036335b4df141c8b"
"c67bb05a971f5ac2745fd683797dde30169a79ff23e1d58c64afad42ad81cffe53967"
"e16beb692fc5776bb442c79c5d91de00cf21804712806594010038e168a32102449fb"
"5237efe8f647d32e8b64f06c22d1d40368eaca2a71ffc6a13ecc8bce680e6569b4412"
"fbb651e44282419f62e9b3face655d3a96e286f70dd616592d6837ccf55cadd71eb53"
"50a4c46f23ca69230c27f6c0a7c1ed15aee38ab4cbc6f8d031976a914f8172c51efbf"
"34413a308a030fd4b164c5bfcd8f88ac2fe2dbc2d5d28ed70f4bf9e3e7e76db091570"
"8100f048a17f6347d95e1135d6403241db4f4b42aa170919bd0847d158d087d9b0d9b"
"92ad41114cf03a3d44ec84",
UInt256.from_hex(
"199bd28f711413cf2cf04a2520f3ccadbff296d9be231c00cb6308528a0b51ca",
),
UInt256.from_hex(
"8a2fcc5700a89f37a3726cdf3202353bf61f280815a9df744e3c9de6215a745a",
),
)
def test_3_stakes(self):
self._test(
master2,
sequence2,
expiration2,
utxos2,
payout_pubkey,
expected_proof2,
expected_limited_id2,
expected_proofid2,
)
# Change the order of UTXOS to test that the stakes have a unique order inside
# a proof.
self._test(
master2,
sequence2,
expiration2,
utxos2[::-1],
payout_pubkey,
expected_proof2,
expected_limited_id2,
expected_proofid2,
)
def test_adding_stakes_to_proof(self):
+ key_wif = "KydYrKDNsVnY5uhpLyC4UmazuJvUjNoKJhEEv9f1mdK1D5zcnMSM"
+
+ wallet = wallet_from_wif_keys(key_wif)
+
masterkey = Key.from_wif("L4J6gEE4wL9ji2EQbzS5dPMTTsw8LRvcMst1Utij4e3X5ccUSdqW")
proofbuilder = ProofBuilder(
sequence=0,
expiration_time=1670827913,
payout_address=Address.from_string(
"ecash:qzdf44zy632zk4etztvmaqav0y2cest4evtph9jyf4"
),
+ wallet=wallet,
master=masterkey,
)
txid = UInt256.from_hex(
"37424bda9a405b59e7d4f61a4c154cea5ee34e445f3daa6033b64c70355f1e0b"
)
- key = Key.from_wif("KydYrKDNsVnY5uhpLyC4UmazuJvUjNoKJhEEv9f1mdK1D5zcnMSM")
proofbuilder.sign_and_add_stake(
- Stake(
- OutPoint(txid, 0),
- amount=3291110545,
- height=700000,
- pubkey=key.get_pubkey(),
- is_coinbase=False,
- ),
- key,
+ StakeAndSigningData(
+ Stake(
+ OutPoint(txid, 0),
+ amount=3291110545,
+ height=700000,
+ is_coinbase=False,
+ ),
+ Address.from_string("ecash:qrl3p3j0vda2p6t7aepzc3c3fshefz0uhveex0udjh"),
+ )
)
- proof = proofbuilder.build()
- self.assertEqual(
- proof.to_hex(),
- "000000000000000089cf96630000000021023beefdde700a6bc02036335b4df141c8bc67bb05a971f5ac2745fd683797dde3010b1e5f35704cb63360aa3d5f444ee35eea4c154c1af6d4e7595b409ada4b423700000000915c2ac400000000c05c15002102449fb5237efe8f647d32e8b64f06c22d1d40368eaca2a71ffc6a13ecc8bce680b8d717142339f0baf0c8099bafd6491d42e73f7224cacf1daa20a2aeb7b4b3fa68a362bfed33bf20ec1c08452e6ad5536fec3e1198d839d64c2e0e6fe25afaa61976a9149a9ad444d4542b572b12d9be83ac79158cc175cb88acc768803afa6a4662bab4199535122b4a8c7fb9889f1fe77043d8ecd43ad04c5cf07e602e47b68deaac1bbdc7c170ad57c38aa47e5a5d23cac011c15ed31bbc54",
- )
- self.assertTrue(proof.verify_master_signature())
+ proof = None
+
+ def test_initial_proof(_proof):
+ nonlocal proof
+ proof = _proof
+
+ self.assertEqual(
+ proof.to_hex(),
+ "000000000000000089cf96630000000021023beefdde700a6bc02036335b4df141c8bc67bb05a971f5ac2745fd683797dde3010b1e5f35704cb63360aa3d5f444ee35eea4c154c1af6d4e7595b409ada4b423700000000915c2ac400000000c05c15002102449fb5237efe8f647d32e8b64f06c22d1d40368eaca2a71ffc6a13ecc8bce680b8d717142339f0baf0c8099bafd6491d42e73f7224cacf1daa20a2aeb7b4b3fa68a362bfed33bf20ec1c08452e6ad5536fec3e1198d839d64c2e0e6fe25afaa61976a9149a9ad444d4542b572b12d9be83ac79158cc175cb88acc768803afa6a4662bab4199535122b4a8c7fb9889f1fe77043d8ecd43ad04c5cf07e602e47b68deaac1bbdc7c170ad57c38aa47e5a5d23cac011c15ed31bbc54",
+ )
+ self.assertTrue(proof.verify_master_signature())
+
+ proofbuilder.build(on_completion=test_initial_proof)
# create a new builder from this proof, add more stakes
- proofbuilder_add_stakes = ProofBuilder.from_proof(proof, masterkey)
+ proofbuilder_add_stakes = ProofBuilder.from_proof(proof, wallet, masterkey)
txid = UInt256.from_hex(
"300cbba81ef40a6d269be1e931ccb58c074ace4a9b06cc0f2a2c9bf1e176ede4"
)
- key = Key.from_wif("KydYrKDNsVnY5uhpLyC4UmazuJvUjNoKJhEEv9f1mdK1D5zcnMSM")
proofbuilder_add_stakes.sign_and_add_stake(
- Stake(
- OutPoint(txid, 1),
- amount=2866370216,
- height=700001,
- pubkey=key.get_pubkey(),
- is_coinbase=False,
- ),
- key,
+ StakeAndSigningData(
+ Stake(
+ OutPoint(txid, 1),
+ amount=2866370216,
+ height=700001,
+ is_coinbase=False,
+ ),
+ Address.from_string("ecash:qrl3p3j0vda2p6t7aepzc3c3fshefz0uhveex0udjh"),
+ )
)
- proof = proofbuilder_add_stakes.build()
- self.assertEqual(
- proof.to_hex(),
- "000000000000000089cf96630000000021023beefdde700a6bc02036335b4df141c8bc67bb05a971f5ac2745fd683797dde302e4ed76e1f19b2c2a0fcc069b4ace4a078cb5cc31e9e19b266d0af41ea8bb0c3001000000a856d9aa00000000c25c15002102449fb5237efe8f647d32e8b64f06c22d1d40368eaca2a71ffc6a13ecc8bce68089bf7f0f956b084160d505dcd8b375499ffad816d1c76c8b13ac92d1ef3c5c3ecb6ee6c094ef790fb93f6711955c48f2cf098750427808c9e2aab77ee1b8de110b1e5f35704cb63360aa3d5f444ee35eea4c154c1af6d4e7595b409ada4b423700000000915c2ac400000000c05c15002102449fb5237efe8f647d32e8b64f06c22d1d40368eaca2a71ffc6a13ecc8bce680b8d717142339f0baf0c8099bafd6491d42e73f7224cacf1daa20a2aeb7b4b3fa68a362bfed33bf20ec1c08452e6ad5536fec3e1198d839d64c2e0e6fe25afaa61976a9149a9ad444d4542b572b12d9be83ac79158cc175cb88acec2623216b901037fb780e3d2a06f982bbe36d87be7adc82e83ebfc1f3c4eff6262577cfa9f72d18570dc5cdf9bf96676700abdb3d8f4bc989c975870ab8cbb7",
- )
+ def test_proof_with_added_stake(_proof):
+ self.assertEqual(
+ _proof.to_hex(),
+ "000000000000000089cf96630000000021023beefdde700a6bc02036335b4df141c8bc67bb05a971f5ac2745fd683797dde302e4ed76e1f19b2c2a0fcc069b4ace4a078cb5cc31e9e19b266d0af41ea8bb0c3001000000a856d9aa00000000c25c15002102449fb5237efe8f647d32e8b64f06c22d1d40368eaca2a71ffc6a13ecc8bce68089bf7f0f956b084160d505dcd8b375499ffad816d1c76c8b13ac92d1ef3c5c3ecb6ee6c094ef790fb93f6711955c48f2cf098750427808c9e2aab77ee1b8de110b1e5f35704cb63360aa3d5f444ee35eea4c154c1af6d4e7595b409ada4b423700000000915c2ac400000000c05c15002102449fb5237efe8f647d32e8b64f06c22d1d40368eaca2a71ffc6a13ecc8bce680b8d717142339f0baf0c8099bafd6491d42e73f7224cacf1daa20a2aeb7b4b3fa68a362bfed33bf20ec1c08452e6ad5536fec3e1198d839d64c2e0e6fe25afaa61976a9149a9ad444d4542b572b12d9be83ac79158cc175cb88acec2623216b901037fb780e3d2a06f982bbe36d87be7adc82e83ebfc1f3c4eff6262577cfa9f72d18570dc5cdf9bf96676700abdb3d8f4bc989c975870ab8cbb7",
+ )
+
+ proofbuilder_add_stakes.build(on_completion=test_proof_with_added_stake)
def test_without_master_private_key(self):
+ key_wif = "KydYrKDNsVnY5uhpLyC4UmazuJvUjNoKJhEEv9f1mdK1D5zcnMSM"
+ wallet = wallet_from_wif_keys(key_wif)
+
masterkey = Key.from_wif("L4J6gEE4wL9ji2EQbzS5dPMTTsw8LRvcMst1Utij4e3X5ccUSdqW")
master_pub = masterkey.get_pubkey()
proofbuilder = ProofBuilder(
sequence=0,
expiration_time=1670827913,
payout_address=Address.from_string(
"ecash:qzdf44zy632zk4etztvmaqav0y2cest4evtph9jyf4"
),
+ wallet=wallet,
master_pub=master_pub,
)
txid = UInt256.from_hex(
"37424bda9a405b59e7d4f61a4c154cea5ee34e445f3daa6033b64c70355f1e0b"
)
- key = Key.from_wif("KydYrKDNsVnY5uhpLyC4UmazuJvUjNoKJhEEv9f1mdK1D5zcnMSM")
proofbuilder.sign_and_add_stake(
- Stake(
- OutPoint(txid, 0),
- amount=3291110545,
- height=700000,
- pubkey=key.get_pubkey(),
- is_coinbase=False,
- ),
- key,
+ StakeAndSigningData(
+ Stake(
+ OutPoint(txid, 0),
+ amount=3291110545,
+ height=700000,
+ is_coinbase=False,
+ ),
+ Address.from_string("ecash:qrl3p3j0vda2p6t7aepzc3c3fshefz0uhveex0udjh"),
+ )
)
- proof = proofbuilder.build()
# Same proof as the first one in test_adding_stakes_to_proof
expected_hex = "000000000000000089cf96630000000021023beefdde700a6bc02036335b4df141c8bc67bb05a971f5ac2745fd683797dde3010b1e5f35704cb63360aa3d5f444ee35eea4c154c1af6d4e7595b409ada4b423700000000915c2ac400000000c05c15002102449fb5237efe8f647d32e8b64f06c22d1d40368eaca2a71ffc6a13ecc8bce680b8d717142339f0baf0c8099bafd6491d42e73f7224cacf1daa20a2aeb7b4b3fa68a362bfed33bf20ec1c08452e6ad5536fec3e1198d839d64c2e0e6fe25afaa61976a9149a9ad444d4542b572b12d9be83ac79158cc175cb88acc768803afa6a4662bab4199535122b4a8c7fb9889f1fe77043d8ecd43ad04c5cf07e602e47b68deaac1bbdc7c170ad57c38aa47e5a5d23cac011c15ed31bbc54"
expected_hex_no_sig = expected_hex[:-128] + 64 * "00"
- self.assertEqual(proof.to_hex(), expected_hex_no_sig)
- self.assertFalse(proof.verify_master_signature())
- proofbuilder = ProofBuilder.from_proof(proof)
- proof_from_proof_no_master = proofbuilder.build()
- self.assertEqual(proof_from_proof_no_master.to_hex(), expected_hex_no_sig)
- self.assertFalse(proof_from_proof_no_master.verify_master_signature())
+ proof = None
+
+ def check_proof_stage1(_proof):
+ nonlocal proof
+ proof = _proof
+
+ self.assertEqual(proof.to_hex(), expected_hex_no_sig)
+ self.assertFalse(proof.verify_master_signature())
+
+ proofbuilder.build(on_completion=check_proof_stage1)
+
+ proofbuilder = ProofBuilder.from_proof(proof, wallet)
+
+ def check_proof_stage2(_proof):
+ nonlocal proof
+ proof = _proof
+
+ self.assertEqual(proof.to_hex(), expected_hex_no_sig)
+ self.assertFalse(proof.verify_master_signature())
- proofbuilder = ProofBuilder.from_proof(proof, masterkey)
- proof_from_proof_with_master = proofbuilder.build()
- self.assertEqual(proof_from_proof_with_master.to_hex(), expected_hex)
- self.assertTrue(proof_from_proof_with_master.verify_master_signature())
+ proofbuilder.build(on_completion=check_proof_stage2)
+
+ proofbuilder = ProofBuilder.from_proof(proof, wallet, masterkey)
+
+ def check_proof_stage3(_proof):
+ self.assertEqual(_proof.to_hex(), expected_hex)
+ self.assertTrue(_proof.verify_master_signature())
+
+ proofbuilder.build(on_completion=check_proof_stage3)
def test_payout_address_script(self):
"""Test that the proof builder generates the expected script for an address"""
# This script was generated using Bitcoin ABC's decodeavalancheproof RPC
# on a proof build with the buildavalancheproof RPC using
# ADDRESS_ECREG_UNSPENDABLE as the payout address
payout_script_pubkey = bytes.fromhex(
"76a914000000000000000000000000000000000000000088ac"
)
# Sanity check
_txout_type, addr = get_address_from_output_script(payout_script_pubkey)
self.assertEqual(
addr.to_ui_string(), "ecash:qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqs7ratqfx"
)
+ wallet = wallet_from_wif_keys(
+ "KydYrKDNsVnY5uhpLyC4UmazuJvUjNoKJhEEv9f1mdK1D5zcnMSM"
+ )
pb = ProofBuilder(
- sequence=0, expiration_time=1670827913, payout_address=addr, master=master2
+ sequence=0,
+ expiration_time=1670827913,
+ payout_address=addr,
+ wallet=wallet,
+ master=master2,
)
- proof = pb.build()
- script = Address.from_string(
- "ecregtest:qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqcrl5mqkt",
- support_arbitrary_prefix=True,
- ).to_script()
- self.assertEqual(script, proof.payout_script_pubkey)
- self.assertEqual(addr, proof.get_payout_address())
+ def check_proof(proof):
+ script = Address.from_string(
+ "ecregtest:qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqcrl5mqkt",
+ support_arbitrary_prefix=True,
+ ).to_script()
+ self.assertEqual(script, proof.payout_script_pubkey)
+ self.assertEqual(addr, proof.get_payout_address())
+
+ pb.build(on_completion=check_proof)
class TestAvalancheProofFromHex(unittest.TestCase):
def test_proofid(self):
# Data from bitcoin ABC's proof_tests
# 1 stake
proof = Proof.from_hex(
"d97587e6c882615796011ec8f9a7b1c621023beefdde700a6bc02036335b4df141c8b"
"c67bb05a971f5ac2745fd683797dde30169a79ff23e1d58c64afad42ad81cffe53967"
"e16beb692fc5776bb442c79c5d91de00cf21804712806594010038e168a32102449fb"
"5237efe8f647d32e8b64f06c22d1d40368eaca2a71ffc6a13ecc8bce68099f1e258ab"
"54f960102c8b480e1dd5795422791bb8a7a19e5542fe8b6a76df7fa09a3fd4be62db7"
"50131f1fbea6f7bb978288f7fe941c39ef625aa80576e19fc43410469ab5a892ffa4b"
"b104a3d5760dd893a5502512eea4ba32a6d6672767be4959c0f70489b803a47a3abf8"
"3f30e8d9da978de4027c70ce7e0d3b0ad62eb08edd8f9ac05a9ea3a5333926249331f"
"34a41a3519bab179ce9228dc940019ee80f754da0499379229f9b49f1bccc6566a734"
"7227299f775939444505952f920ccea8b9f18"
)
ltd_id = LimitedProofId.from_hex(
"deabf2c0f8e656857340aeb029bbf88ba11dbaf2d98b8e754556f6ebc173801f"
)
proof_id = ProofId.from_hex(
"cdcdd71605139f49d4884b0c3d9a6be309f07b008a760bb3b25fcfcb7a3ffc46"
)
self.assertEqual(proof.limitedid, ltd_id)
self.assertEqual(proof.proofid, proof_id)
# 3 stakes
proof = Proof.from_hex(
"c964aa6fde575e4ce8404581c7be874e21023beefdde700a6bc02036335b4df141c8b"
"c67bb05a971f5ac2745fd683797dde3030b1e5f35704cb63360aa3d5f444ee35eea4c"
"154c1af6d4e7595b409ada4b42377764698a915c2ac4000000000f28db322102449fb"
"5237efe8f647d32e8b64f06c22d1d40368eaca2a71ffc6a13ecc8bce6809d1eddf2e4"
"6ca8bfc4ff8d512c2c9fed6371baf1335940397ec40b1d6da8f8f086f8cd01a90ecee"
"97096d0cfc4f56f8b5166d03ee1d1935a5b4e79c11cbf9c74e4ed76e1f19b2c2a0fcc"
"069b4ace4a078cb5cc31e9e19b266d0af41ea8bb0c30c8b47c95a856d9aa000000007"
"dfdd89a2102449fb5237efe8f647d32e8b64f06c22d1d40368eaca2a71ffc6a13ecc8"
"bce680dfcfdcf00a1ac526c8ca44fe095a0a204e5e2b85b0ad3fadaf53ec84e2c9408"
"300f2dc21781346d71f941e045871f7931622dc4a4331c795d8ca596d24ddb021ac09"
"8c86414715db364a4e32216084c561acdd79e0860b1fdf7497b159cb1323045120029"
"6c902ee000000009f2bc7392102449fb5237efe8f647d32e8b64f06c22d1d40368eac"
"a2a71ffc6a13ecc8bce6801f42d48c9369898b7c5eb4157f30745b9ee51b32882b320"
"32429f77166a1ebab6b88de018bf0340097887b1aeff8b7aa728a072b38e02ee8a705"
"14db1de147ad2321038439233261789dd340bdc1450172d9c671b72ee8c0b2736ed2a"
"3a250760897fdace7662689aa1c9c5d9d9a6dbe9a94859be27fbddca080abff31012a"
"5277bc98630c47bb04830514ac04304d726b598e05c4cd89506bb2e1f0a78f54ab3f3"
"15cfe"
)
ltd_id = LimitedProofId.from_hex(
"7223b8cc572bdf8f123ee7dd0316962f0367b0be8bce9b6e9465d1f413d95616"
)
proof_id = ProofId.from_hex(
"95c9673bc14f3c36e9310297e8df81867b42dd1a7bb7944aeb6c1797fbd2a6d5"
)
self.assertEqual(proof.limitedid, ltd_id)
self.assertEqual(proof.proofid, proof_id)
def test_proof_data(self):
# Reuse a proof from a test above, but instead of building the proof and
# checking that the resulting serialized proof matches the expected, we do
# it the other way around: deserialize the proof and check that all fields
# match the expected data.
proof = Proof.from_hex(expected_proof1)
self.assertEqual(proof.sequence, 42)
self.assertEqual(proof.expiration_time, 1699999999)
self.assertEqual(proof.master_pub, PublicKey.from_hex(pubkey_hex))
self.assertEqual(proof.limitedid, LimitedProofId.from_hex(expected_limited_id1))
self.assertEqual(proof.proofid, ProofId.from_hex(expected_proofid1))
self.assertEqual(proof.signed_stakes[0].stake.utxo.txid, utxos[0]["txid"])
self.assertEqual(proof.signed_stakes[0].stake.utxo.n, utxos[0]["vout"])
self.assertEqual(proof.signed_stakes[0].stake.amount, utxos[0]["amount"])
self.assertEqual(proof.signed_stakes[0].stake.height, utxos[0]["height"])
self.assertFalse(proof.signed_stakes[0].stake.is_coinbase)
self.assertEqual(
proof.signed_stakes[0].stake.pubkey,
PublicKey.from_hex(
"04d0de0aaeaefad02b8bdc8a01a1b8b11c696bd3d66a2c5f10780d95b7df42645cd852"
"28a6fb29940e858e7e55842ae2bd115d1ed7cc0e82d934e929c97648cb0a"
),
)
self.assertEqual(
proof.signed_stakes[0].sig,
base64.b64decode(
"vZdAyFoFp9VDw9MBJz15/3BUdYV54wzAXN/hrKM3St/lUQS0Cf/OSi8Z2KWYHV8MebI+2s"
"czUqsomKyoknAoJQ==".encode("ascii")
),
)
self.assertEqual(proof.payout_script_pubkey, b"")
proof2 = Proof.from_hex(expected_proof2)
self.assertEqual(proof2.payout_script_pubkey, payout_pubkey.to_script())
def test_raises_deserializationerror(self):
with self.assertRaises(DeserializationError):
Proof.from_hex("not hex")
with self.assertRaises(DeserializationError):
Proof.from_hex("aabbc")
# Drop the last hex char to make the string not valid hex
with self.assertRaises(DeserializationError):
Proof.from_hex(expected_proof1[:-1])
# Proper hex, but not a proof
with self.assertRaises(DeserializationError):
Proof.from_hex("aabbcc")
# Drop the last byte to make the signature incomplete
with self.assertRaises(DeserializationError):
Proof.from_hex(expected_proof1[:-2])
# A ProofId must have exactly 32 bytes
ProofId.from_hex(32 * "aa")
with self.assertRaises(DeserializationError):
ProofId.from_hex(31 * "aa")
with self.assertRaises(DeserializationError):
ProofId.from_hex(33 * "aa")
with self.assertRaises(DeserializationError):
LimitedProofId.from_hex(32 * "yz")
one_level_dg_hex = (
"46116afa1abaab88b96c115c248b77c7d8e099565c5fb40731482c6655ca450d21"
"023beefdde700a6bc02036335b4df141c8bc67bb05a971f5ac2745fd683797dde3"
"012103e49f9df52de2dea81cf7838b82521b69f2ea360f1c4eed9e6c89b7d0f9e6"
"45ef7d512ddbea7c88dcf38412b58374856a466e165797a69321c0928a89c64521"
"f7e2e767c93de645ef5125ec901dcd51347787ca29771e7786bbe402d2d5ead0dc"
)
class TestAvalancheDelegationBuilder(unittest.TestCase):
def setUp(self) -> None:
self.level1_privkey = Key.from_wif(
"KzzLLtiYiyFcTXPWUzywt2yEKk5FxkGbMfKhWgBd4oZdt8t8kk77"
)
self.level1_pubkey = PublicKey.from_hex(
"03e49f9df52de2dea81cf7838b82521b69f2ea360f1c4eed9e6c89b7d0f9e645ef"
)
self.level2_pubkey = PublicKey.from_hex(
"03aac52f4cfca700e7e9824298e0184755112e32f359c832f5f6ad2ef62a2c024a"
)
self.wrong_proof_master = Key.from_wif(
"KwM6hV6hxZt3Kt4NHMtWQGH5T2SwhpyswodUQC2zmSjg6KWFWkQU"
)
self.base_delegation = Delegation.from_hex(
"6428c2c29a116191d42fe68e74f1ee33f8a285c13320d77b201c3ab9135c84e521030b4c86"
"6585dd868a9d62348a9cd008d6a312937048fff31670e7e920cfc7a744012103e49f9df52d"
"e2dea81cf7838b82521b69f2ea360f1c4eed9e6c89b7d0f9e645ef22c1dd0a15c32d251dd9"
"93dde979e8f2751a468d622ca7db10bfc11180497d0ff4be928f362fd8fcd5259cef923bb4"
"71840c307e9bc4f89e5426b4e67b72d90e"
)
self.two_levels_delegation = Delegation.from_hex(
"6428c2c29a116191d42fe68e74f1ee33f8a285c13320d77b201c3ab9135c84e521030b4c86"
"6585dd868a9d62348a9cd008d6a312937048fff31670e7e920cfc7a744022103e49f9df52d"
"e2dea81cf7838b82521b69f2ea360f1c4eed9e6c89b7d0f9e645ef22c1dd0a15c32d251dd9"
"93dde979e8f2751a468d622ca7db10bfc11180497d0ff4be928f362fd8fcd5259cef923bb4"
"71840c307e9bc4f89e5426b4e67b72d90e2103aac52f4cfca700e7e9824298e0184755112e"
"32f359c832f5f6ad2ef62a2c024a77c153340bb951e56df134c66042426f4fe33b670bb2d4"
"85f6d96f9d0d1db525dfa449565b8f424d71615d5f6c9399334b2550d554577ffa2ee8d758"
"eb8ded88"
)
def test_from_ltd_id(self):
# This is based on the proof from the Bitcoin ABC test framework's unit test
# in messages.py:
# d97587e6c882615796011ec8f9a7b1c621023beefdde700a6bc02036335b4df141c8bc67bb
# 05a971f5ac2745fd683797dde30169a79ff23e1d58c64afad42ad81cffe53967e16beb692f
# c5776bb442c79c5d91de00cf21804712806594010038e168a32102449fb5237efe8f647d32
# e8b64f06c22d1d40368eaca2a71ffc6a13ecc8bce6804534ca1f5e22670be3df5cbd5957d8
# dd83d05c8f17eae391f0e7ffdce4fb3defadb7c079473ebeccf88c1f8ce87c61e451447b89
# c445967335ffd1aadef429982321023beefdde700a6bc02036335b4df141c8bc67bb05a971
# f5ac2745fd683797dde3ac7b0b7865200f63052ff980b93f965f398dda04917d411dd46e3c
# 009a5fef35661fac28779b6a22760c00004f5ddf7d9865c7fead7e4a840b94793959026164
# 0f
proof_master = Key.from_wif(
"L4J6gEE4wL9ji2EQbzS5dPMTTsw8LRvcMst1Utij4e3X5ccUSdqW"
)
dgb = DelegationBuilder(
LimitedProofId.from_hex(
"c1283084c878408b2a5a11b7a1155b3cccce91526e4da0ba3947bbcf9d9ed402"
),
proof_master.get_pubkey(),
)
dgb.add_level(proof_master, self.level1_pubkey)
self.assertEqual(
dgb.build(),
Delegation.from_hex(
"02d49e9dcfbb4739baa04d6e5291cecc3c5b15a1b7115a2a8b4078c8843028c121023b"
"eefdde700a6bc02036335b4df141c8bc67bb05a971f5ac2745fd683797dde3012103e4"
"9f9df52de2dea81cf7838b82521b69f2ea360f1c4eed9e6c89b7d0f9e645effa701924"
"fe7367835b3a0fb30bcc706f00624633980f601987400bb24551cf57bd9f2d106f5c58"
"4e4e0efa2069a606cf1aa64f776ccb3304f8486eb3d1ce3acf"
),
)
def test_from_proof(self):
proof_master = Key.from_wif(
"Kwr371tjA9u2rFSMZjTNun2PXXP3WPZu2afRHTcta6KxEUdm1vEw"
)
proof = Proof.from_hex(expected_proof1)
self.assertEqual(proof.master_pub, proof_master.get_pubkey())
dgb = DelegationBuilder.from_proof(proof)
# Level 1
dgb.add_level(proof_master, self.level1_pubkey)
self.assertEqual(dgb.build(), self.base_delegation)
# Level 2
dgb.add_level(self.level1_privkey, self.level2_pubkey)
self.assertEqual(dgb.build(), self.two_levels_delegation)
def test_wrong_privkey_raises(self):
proof = Proof.from_hex(expected_proof1)
dgb = DelegationBuilder.from_proof(proof)
with self.assertRaises(WrongDelegatorKeyError):
dgb.add_level(self.wrong_proof_master, self.level1_pubkey)
def test_from_delegation(self):
dgb = DelegationBuilder.from_delegation(self.base_delegation)
self.assertEqual(dgb.build(), self.base_delegation)
dgb.add_level(self.level1_privkey, self.level2_pubkey)
self.assertEqual(dgb.build(), self.two_levels_delegation)
class TestAvalancheDelegationFromHex(unittest.TestCase):
def setUp(self) -> None:
self.proof_master = PublicKey.from_hex(
"023beefdde700a6bc02036335b4df141c8bc67bb05a971f5ac2745fd683797dde3"
)
self.ltd_proof_id = LimitedProofId.from_hex(
"0d45ca55662c483107b45f5c5699e0d8c7778b245c116cb988abba1afa6a1146"
)
self.pubkey1 = PublicKey.from_hex(
"03e49f9df52de2dea81cf7838b82521b69f2ea360f1c4eed9e6c89b7d0f9e645ef"
)
self.pubkey2 = PublicKey.from_hex(
"03aac52f4cfca700e7e9824298e0184755112e32f359c832f5f6ad2ef62a2c024a"
)
self.sig1 = base64.b64decode(
"fVEt2+p8iNzzhBK1g3SFakZuFleXppMhwJKKicZFIffi52fJPeZF71El7JAdzVE0d4fKKXced4"
"a75ALS1erQ3A==".encode("ascii")
)
self.sig2 = base64.b64decode(
"XN3Q/+hOEuS/SeTAr3yFSOYYok4SSV1ln1unXhFOFSamGKowWx5pv2riCyVXmZ8uP+wl1fInH4"
"ud4NBrpzRFUA==".encode("ascii")
)
def test_empty_delegation(self):
delegation = Delegation.from_hex(
"46116afa1abaab88b96c115c248b77c7d8e099565c5fb40731482c6655ca450d21"
"023beefdde700a6bc02036335b4df141c8bc67bb05a971f5ac2745fd683797dde3"
"00"
)
self.assertEqual(delegation.proof_master, self.proof_master)
self.assertEqual(delegation.limited_proofid, self.ltd_proof_id)
self.assertEqual(
delegation.dgid,
DelegationId.from_hex(
"afc74900c1f28b69e466461fb1e0663352da6153be0fcd59280e27f2446391d5"
),
)
self.assertEqual(delegation.get_delegated_public_key(), delegation.proof_master)
self.assertEqual(delegation.levels, [])
self.assertEqual(delegation.verify(), (True, self.proof_master))
def test_one_level(self):
delegation = Delegation.from_hex(one_level_dg_hex)
self.assertEqual(delegation.proof_master, self.proof_master)
self.assertEqual(delegation.limited_proofid, self.ltd_proof_id)
self.assertEqual(
delegation.dgid,
DelegationId.from_hex(
"ffcd49dc98ebdbc90e731a7b0c89939bfe082f15f3aa82aca657176b83669185"
),
)
self.assertEqual(delegation.get_delegated_public_key(), self.pubkey1)
self.assertEqual(delegation.levels, [Level(self.pubkey1, self.sig1)])
self.assertEqual(delegation.verify(), (True, self.pubkey1))
def test_two_levels(self):
delegation = Delegation.from_hex(
"46116afa1abaab88b96c115c248b77c7d8e099565c5fb40731482c6655ca450d21"
"023beefdde700a6bc02036335b4df141c8bc67bb05a971f5ac2745fd683797dde3"
"022103e49f9df52de2dea81cf7838b82521b69f2ea360f1c4eed9e6c89b7d0f9e645e"
"f7d512ddbea7c88dcf38412b58374856a466e165797a69321c0928a89c64521f7e2e7"
"67c93de645ef5125ec901dcd51347787ca29771e7786bbe402d2d5ead0dc2103aac52"
"f4cfca700e7e9824298e0184755112e32f359c832f5f6ad2ef62a2c024a5cddd0ffe8"
"4e12e4bf49e4c0af7c8548e618a24e12495d659f5ba75e114e1526a618aa305b1e69b"
"f6ae20b2557999f2e3fec25d5f2271f8b9de0d06ba7344550"
)
self.assertEqual(delegation.proof_master, self.proof_master)
self.assertEqual(delegation.limited_proofid, self.ltd_proof_id)
self.assertEqual(
delegation.dgid,
DelegationId.from_hex(
"a3f98e6b5ec330219493d109e5c11ed8e302315df4604b5462e9fb80cb0fde89"
),
)
self.assertEqual(delegation.get_delegated_public_key(), self.pubkey2)
self.assertEqual(
delegation.levels,
[
Level(self.pubkey1, self.sig1),
Level(self.pubkey2, self.sig2),
],
)
def test_raises_deserializationerror(self):
# Drop the last hex char to make the string not valid hex
with self.assertRaises(DeserializationError):
Delegation.from_hex(one_level_dg_hex[:-1])
# Proper hex, but not a proof
with self.assertRaises(DeserializationError):
Delegation.from_hex("aabbcc")
# Drop the last byte to make the signature
with self.assertRaises(DeserializationError):
Delegation.from_hex(one_level_dg_hex[:-2])
if __name__ == "__main__":
unittest.main()
diff --git a/electrum/electrumabc/wallet.py b/electrum/electrumabc/wallet.py
index a79ba560c..a14b56038 100644
--- a/electrum/electrumabc/wallet.py
+++ b/electrum/electrumabc/wallet.py
@@ -1,3789 +1,3825 @@
# Electrum ABC - lightweight eCash client
# Copyright (C) 2020 The Electrum ABC developers
# Copyright (C) 2017-2022 The Electron Cash Developers
# Copyright (C) 2015 Thomas Voegtlin
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation files
# (the "Software"), to deal in the Software without restriction,
# including without limitation the rights to use, copy, modify, merge,
# publish, distribute, sublicense, and/or sell copies of the Software,
# and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# Wallet classes:
# - ImportedAddressWallet: imported address, no keystore
# - ImportedPrivkeyWallet: imported private keys, keystore
# - Standard_Wallet: one keystore, P2PKH
# - Multisig_Wallet: several keystores, P2SH
from __future__ import annotations
import copy
import errno
import itertools
import json
import os
import queue
import random
import threading
import time
from abc import abstractmethod
from collections import defaultdict, namedtuple
from enum import Enum, auto
from typing import (
TYPE_CHECKING,
Any,
Dict,
ItemsView,
List,
Optional,
Sequence,
Set,
Tuple,
Union,
ValuesView,
)
from weakref import ref
-from . import bitcoin, coinchooser, keystore, mnemo, paymentrequest, slp
+from . import avalanche, bitcoin, coinchooser, keystore, mnemo, paymentrequest, slp
from .address import Address, PublicKey, Script
+from .avalanche.proof import StakeAndSigningData
from .bip32 import xpub_type
from .bitcoin import ScriptType
from .constants import XEC
from .contacts import Contacts
from .crypto import Hash
from .ecc import ECPrivkey, SignatureType
from .i18n import _, ngettext
from .keystore import (
BIP32KeyStore,
DeterministicKeyStore,
HardwareKeyStore,
ImportedKeyStore,
KeyStore,
load_keystore,
xpubkey_to_address,
)
from .paymentrequest import (
PR_EXPIRED,
PR_PAID,
PR_UNCONFIRMED,
PR_UNKNOWN,
PR_UNPAID,
InvoiceStore,
)
from .plugins import plugin_loaders, run_hook
from .printerror import PrintError
from .storage import (
STO_EV_PLAINTEXT,
STO_EV_USER_PW,
STO_EV_XPUB_PW,
StorageKeys,
WalletStorage,
)
from .synchronizer import Synchronizer
from .transaction import (
DUST_THRESHOLD,
InputValueMissing,
Transaction,
TxInput,
TxOutput,
)
from .util import (
ExcessiveFee,
InvalidPassword,
NotEnoughFunds,
TimeoutException,
UserCancelled,
WalletFileException,
bh2u,
finalization_print_error,
format_satoshis,
format_time,
multisig_type,
profiler,
to_string,
)
from .verifier import SPV, SPVDelegate
from .version import PACKAGE_VERSION
if TYPE_CHECKING:
from electrumabc_gui.qt import ElectrumWindow
from .network import Network
from .simple_config import SimpleConfig
DEFAULT_CONFIRMED_ONLY = False
HistoryItemType = Tuple[str, int]
"""(tx_hash, block_height)"""
CoinsItemType = Tuple[int, int, bool]
"""(block_height, amount, is_coinbase)"""
UnspentCoinsType = Dict[str, CoinsItemType]
"""{"tx_hash:prevout": (block_height, amount, is_coinbase), ...}"""
SpendCoinsType = Dict[str, int]
"""{"tx_hash:prevout": block_height, ...}"""
class AddressNotFoundError(Exception):
"""Exception used for Address errors."""
def sweep_preparations(
privkeys,
network,
imax=100,
) -> Tuple[List[TxInput], Dict[bytes, Tuple[bytes, bool]]]:
"""Returns (utxos, keypairs) for a list of WIF private keys, where utxos is a list
of dictionaries, and keypairs is a {pubkey_hex: (privkey, is_compressed)} map."""
class InputsMaxxed(Exception):
pass
def append_utxos_to_inputs(inputs, pubkey: str, txin_type: bitcoin.ScriptType):
if txin_type == txin_type.p2pkh:
address = Address.from_pubkey(pubkey)
else:
address = PublicKey.from_pubkey(pubkey)
sh = address.to_scripthash_hex()
u = network.synchronous_get(("blockchain.scripthash.listunspent", [sh]))
for item in u:
if len(inputs) >= imax:
raise InputsMaxxed()
item["address"] = address
item["type"] = txin_type.name
item["prevout_hash"] = item["tx_hash"]
item["prevout_n"] = item["tx_pos"]
item["pubkeys"] = [pubkey]
item["x_pubkeys"] = [pubkey]
item["signatures"] = [None]
item["num_sig"] = 1
inputs.append(TxInput.from_coin_dict(item))
def find_utxos_for_privkey(txin_type: bitcoin.ScriptType, privkey, compressed):
pubkey = ECPrivkey(privkey).get_public_key_bytes(compressed)
append_utxos_to_inputs(inputs, pubkey.hex(), txin_type)
keypairs[pubkey] = privkey, compressed
inputs = []
keypairs = {}
try:
for sec in privkeys:
txin_type, privkey, compressed = bitcoin.deserialize_privkey(sec)
find_utxos_for_privkey(txin_type, privkey, compressed)
# do other lookups to increase support coverage
if bitcoin.is_minikey(sec):
# minikeys don't have a compressed byte
# we lookup both compressed and uncompressed pubkeys
find_utxos_for_privkey(txin_type, privkey, not compressed)
elif txin_type == bitcoin.ScriptType.p2pkh:
# WIF serialization does not distinguish p2pkh and p2pk
# we also search for pay-to-pubkey outputs
find_utxos_for_privkey(bitcoin.ScriptType.p2pk, privkey, compressed)
elif txin_type == bitcoin.ScriptType.p2sh:
raise ValueError(
_(
"The specified WIF key '{}' is a p2sh WIF key. These key types"
" cannot be swept."
).format(sec)
)
except InputsMaxxed:
pass
if not inputs:
raise ValueError(_("No inputs found. (Note that inputs need to be confirmed)"))
return inputs, keypairs
def sweep(
privkeys: Sequence[str],
network: Network,
config: SimpleConfig,
recipient: Address,
fee: Optional[int] = None,
imax: int = 100,
sign_schnorr: bool = False,
) -> Transaction:
"""Build a transaction sweeping all coins for a list of WIF keys."""
inputs, keypairs = sweep_preparations(privkeys, network, imax)
total = sum(i.get_value() for i in inputs)
if fee is None:
outputs = [TxOutput(bitcoin.TYPE_ADDRESS, recipient, total)]
tx = Transaction.from_io(inputs, outputs, sign_schnorr=sign_schnorr)
fee = config.estimate_fee(tx.estimated_size())
if total - fee < 0:
raise NotEnoughFunds(
_("Not enough funds on address.")
+ "\nTotal: %d satoshis\nFee: %d" % (total, fee)
)
if total - fee < DUST_THRESHOLD:
raise NotEnoughFunds(
_("Not enough funds on address.")
+ f"\nTotal: {total} satoshis\nFee: {fee}\nDust Threshold: {DUST_THRESHOLD}"
)
outputs = [TxOutput(bitcoin.TYPE_ADDRESS, recipient, total - fee)]
locktime = 0
if config.is_current_block_locktime_enabled():
locktime = network.get_local_height()
tx = Transaction.from_io(
inputs, outputs, locktime=locktime, sign_schnorr=sign_schnorr
)
tx.shuffle_inputs()
tx.sort_outputs()
tx.sign(keypairs)
return tx
class AbstractWallet(PrintError, SPVDelegate):
"""
Wallet classes are created to handle various address generation methods.
Completion states (watching-only, single account, no seed, etc) are handled inside classes.
"""
wallet_type: str = ""
max_change_outputs = 3
def __init__(self, storage: WalletStorage):
if storage.requires_upgrade():
raise Exception("storage must be upgraded before constructing wallet")
self.electrum_version = PACKAGE_VERSION
self.storage = storage
self.keystore: Optional[KeyStore] = None
self.thread = None # this is used by the qt main_window to store a QThread. We just make sure it's always defined as an attribute here.
self.network = None
# verifier (SPV) and synchronizer are started in start_threads
self.synchronizer = None
self.verifier: Optional[SPV] = None
# Some of the GUI classes, such as the Qt ElectrumWindow, use this to refer
# back to themselves. This should always be a weakref.ref (Weak.ref), or None
self.weak_window: Optional[ref[ElectrumWindow]] = None
self.slp = slp.WalletData(self)
finalization_print_error(self.slp) # debug object lifecycle
# Removes defunct entries from self.pruned_txo asynchronously
self.pruned_txo_cleaner_thread = None
# Cache of Address -> (c,u,x) balance. This cache is used by
# get_addr_balance to significantly speed it up (it is called a lot).
# Cache entries are invalidated when tx's are seen involving this
# address (address history chages). Entries to this cache are added
# only inside get_addr_balance.
# Note that this data structure is touched by the network and GUI
# thread concurrently without the use of locks, because Python GIL
# allows us to get away with such things. As such do not iterate over
# this dict, but simply add/remove items to/from it in 1-liners (which
# Python's GIL makes thread-safe implicitly).
self._addr_bal_cache = {}
# We keep a set of the wallet and receiving addresses so that is_mine()
# checks are O(logN) rather than O(N). This creates/resets that cache.
self.invalidate_address_set_cache()
self.gap_limit_for_change = 20 # constant
# saved fields
self.use_change = storage.get("use_change", True)
self.multiple_change = storage.get("multiple_change", False)
self.labels = storage.get("labels", {})
# Frozen addresses
frozen_addresses = storage.get("frozen_addresses", [])
self.frozen_addresses = {Address.from_string(addr) for addr in frozen_addresses}
# Frozen coins (UTXOs) -- note that we have 2 independent levels of "freezing": address-level and coin-level.
# The two types of freezing are flagged independently of each other and 'spendable' is defined as a coin that satisfies
# BOTH levels of freezing.
self.frozen_coins = set(storage.get("frozen_coins", []))
self.frozen_coins_tmp = set() # in-memory only
self.change_reserved = {
Address.from_string(a) for a in storage.get("change_reserved", ())
}
self.change_reserved_default = [
Address.from_string(a) for a in storage.get("change_reserved_default", ())
]
self.change_unreserved = [
Address.from_string(a) for a in storage.get("change_unreserved", ())
]
self.change_reserved_tmp = set() # in-memory only
# address -> list(txid, height)
history = storage.get("addr_history", {})
self._history = self.to_Address_dict(history)
# there is a difference between wallet.up_to_date and interface.is_up_to_date()
# interface.is_up_to_date() returns true when all requests have been answered and processed
# wallet.up_to_date is true when the wallet is synchronized (stronger requirement)
self.up_to_date = False
# The only lock. We used to have two here. That was more technical debt
# without much purpose. 1 lock is sufficient. In particular data
# structures that are touched by the network thread as well as the GUI
# (such as self.transactions, history, etc) need to be synchronized
# using this mutex.
self.lock = threading.RLock()
# load requests
requests = self.storage.get("payment_requests", {})
for key, req in requests.items():
req["address"] = Address.from_string(key)
self.receive_requests = {req["address"]: req for req in requests.values()}
# Transactions pending verification. A map from tx hash to transaction
# height. Access is contended so a lock is needed. Client code should
# use get_unverified_tx to get a thread-safe copy of this dict.
self.unverified_tx = defaultdict(int)
# Verified transactions. Each value is a (height, timestamp, block_pos) tuple. Access with self.lock.
self.verified_tx = storage.get("verified_tx3", {})
# save wallet type the first time
if self.storage.get("wallet_type") is None:
self.storage.put("wallet_type", self.wallet_type)
# invoices and contacts
self.invoices = InvoiceStore(self.storage)
self.contacts = Contacts(self.storage)
# try to load first so we can pick up the remove_transaction hook from load_transactions if need be
self.slp.load()
# Now, finally, after object is constructed -- we can do this
self.load_keystore()
self.load_addresses()
self.load_transactions()
self.build_reverse_history()
self.check_history()
if self.slp.need_rebuild:
# load failed, must rebuild from self.transactions
self.slp.rebuild()
self.slp.save() # commit changes to self.storage
# Print debug message on finalization
finalization_print_error(
self,
"[{}/{}] finalized".format(type(self).__name__, self.diagnostic_name()),
)
@classmethod
def to_Address_dict(cls, d):
"""Convert a dict of strings to a dict of Adddress objects."""
return {Address.from_string(text): value for text, value in d.items()}
@classmethod
def from_Address_dict(cls, d):
"""Convert a dict of Address objects to a dict of strings."""
return {addr.to_storage_string(): value for addr, value in d.items()}
def diagnostic_name(self):
return self.basename()
def __str__(self):
return self.basename()
def get_master_public_key(self):
return None
@abstractmethod
def load_keystore(self) -> None:
"""Load a keystore. Set attribute self.keystore"""
pass
@abstractmethod
def get_keystores(self) -> List[KeyStore]:
pass
@abstractmethod
def save_keystore(self) -> None:
pass
@profiler
def load_transactions(self):
txi = self.storage.get("txi", {})
self.txi = {
tx_hash: self.to_Address_dict(value)
for tx_hash, value in txi.items()
# skip empty entries to save memory and disk space
if value
}
txo = self.storage.get("txo", {})
self.txo = {
tx_hash: self.to_Address_dict(value)
for tx_hash, value in txo.items()
# skip empty entries to save memory and disk space
if value
}
self.tx_fees = self.storage.get("tx_fees", {})
self.pruned_txo = self.storage.get("pruned_txo", {})
self.pruned_txo_values = set(self.pruned_txo.values())
tx_list = self.storage.get("transactions", {})
self.transactions = {}
for tx_hash, rawhex in tx_list.items():
tx = Transaction(bytes.fromhex(rawhex))
self.transactions[tx_hash] = tx
if (
not self.txi.get(tx_hash)
and not self.txo.get(tx_hash)
and (tx_hash not in self.pruned_txo_values)
):
self.print_error("removing unreferenced tx", tx_hash)
self.transactions.pop(tx_hash)
self.slp.rm_tx(tx_hash)
@profiler
def save_transactions(self, write=False):
with self.lock:
tx = {}
for k, v in self.transactions.items():
tx[k] = str(v)
self.storage.put("transactions", tx)
txi = {
tx_hash: self.from_Address_dict(value)
for tx_hash, value in self.txi.items()
# skip empty entries to save memory and disk space
if value
}
txo = {
tx_hash: self.from_Address_dict(value)
for tx_hash, value in self.txo.items()
# skip empty entries to save memory and disk space
if value
}
self.storage.put("txi", txi)
self.storage.put("txo", txo)
self.storage.put("tx_fees", self.tx_fees)
self.storage.put("pruned_txo", self.pruned_txo)
history = self.from_Address_dict(self._history)
self.storage.put("addr_history", history)
self.slp.save()
if write:
self.storage.write()
def save_verified_tx(self, write=False):
with self.lock:
self.storage.put("verified_tx3", self.verified_tx)
if write:
self.storage.write()
def save_change_reservations(self):
with self.lock:
self.storage.put(
"change_reserved_default",
[a.to_storage_string() for a in self.change_reserved_default],
)
self.storage.put(
"change_reserved", [a.to_storage_string() for a in self.change_reserved]
)
unreserved = self.change_unreserved + list(self.change_reserved_tmp)
self.storage.put(
"change_unreserved", [a.to_storage_string() for a in unreserved]
)
def clear_history(self):
with self.lock:
self.txi = {}
self.txo = {}
self.tx_fees = {}
self.pruned_txo = {}
self.pruned_txo_values = set()
self.slp.clear()
self.save_transactions()
self._addr_bal_cache = {}
self._history = {}
self.tx_addr_hist = defaultdict(set)
@profiler
def build_reverse_history(self):
self.tx_addr_hist = defaultdict(set)
for addr, hist in self._history.items():
for tx_hash, h in hist:
self.tx_addr_hist[tx_hash].add(addr)
@profiler
def check_history(self):
save = False
my_addrs = [addr for addr in self._history if self.is_mine(addr)]
for addr in set(self._history) - set(my_addrs):
self._history.pop(addr)
save = True
for addr in my_addrs:
hist = self._history[addr]
for tx_hash, tx_height in hist:
if (
tx_hash in self.pruned_txo_values
or self.txi.get(tx_hash)
or self.txo.get(tx_hash)
):
continue
tx = self.transactions.get(tx_hash)
if tx is not None:
self.add_transaction(tx_hash, tx)
save = True
if save:
self.save_transactions()
def basename(self):
return os.path.basename(self.storage.path)
def save_addresses(self):
addr_dict = {
"receiving": [
addr.to_storage_string() for addr in self.receiving_addresses
],
"change": [addr.to_storage_string() for addr in self.change_addresses],
}
self.storage.put("addresses", addr_dict)
def load_addresses(self):
d = self.storage.get("addresses", {})
if not isinstance(d, dict):
d = {}
self.receiving_addresses = Address.from_strings(d.get("receiving", []))
self.change_addresses = Address.from_strings(d.get("change", []))
def synchronize(self):
pass
def is_deterministic(self):
return self.keystore.is_deterministic()
def set_up_to_date(self, up_to_date):
with self.lock:
self.up_to_date = up_to_date
if up_to_date:
self.save_addresses()
self.save_transactions()
# if the verifier is also up to date, persist that too;
# otherwise it will persist its results when it finishes
if self.verifier and self.verifier.is_up_to_date():
self.save_verified_tx()
self.storage.write()
def is_up_to_date(self):
with self.lock:
return self.up_to_date
def is_fully_settled_down(self):
"""Returns True iff the wallet is up to date and its synchronizer
and verifier aren't busy doing work, and its pruned_txo_values list
is currently empty. This is used as a final check by the Qt GUI
to decide if it should do a final refresh of all tabs in some cases."""
with self.lock:
ret = self.up_to_date
if ret and self.verifier:
ret = self.verifier.is_up_to_date()
if ret and self.synchronizer:
ret = self.synchronizer.is_up_to_date()
ret = ret and not self.pruned_txo_values
return bool(ret)
def set_label(self, name, text=None):
with self.lock:
if isinstance(name, Address):
name = name.to_storage_string()
changed = False
old_text = self.labels.get(name)
if text:
text = text.replace("\n", " ")
if old_text != text:
self.labels[name] = text
changed = True
else:
if old_text:
self.labels.pop(name)
changed = True
if changed:
run_hook("set_label", self, name, text)
self.storage.put("labels", self.labels)
return changed
def invalidate_address_set_cache(self):
"""This should be called from functions that add/remove addresses
from the wallet to ensure the address set caches are empty, in
particular from ImportedWallets which may add/delete addresses
thus the length check in is_mine() may not be accurate.
Deterministic wallets can neglect to call this function since their
address sets only grow and never shrink and thus the length check
of is_mine below is sufficient."""
self._recv_address_set_cached, self._change_address_set_cached = (
frozenset(),
frozenset(),
)
def is_mine(self, address: Address) -> bool:
"""Note this method assumes that the entire address set is
composed of self.get_change_addresses() + self.get_receiving_addresses().
In subclasses, if that is not the case -- REIMPLEMENT this method!"""
assert not isinstance(address, str)
# assumption here is get_receiving_addresses and get_change_addresses
# are cheap constant-time operations returning a list reference.
# If that is not the case -- reimplement this function.
ra, ca = self.get_receiving_addresses(), self.get_change_addresses()
# Detect if sets changed (addresses added/removed).
# Note the functions that add/remove addresses should invalidate this
# cache using invalidate_address_set_cache() above.
if len(ra) != len(self._recv_address_set_cached):
# re-create cache if lengths don't match
self._recv_address_set_cached = frozenset(ra)
if len(ca) != len(self._change_address_set_cached):
# re-create cache if lengths don't match
self._change_address_set_cached = frozenset(ca)
# Do a 2 x O(logN) lookup using sets rather than 2 x O(N) lookups
# if we were to use the address lists (this was the previous way).
# For small wallets it doesn't matter -- but for wallets with 5k or 10k
# addresses, it starts to add up siince is_mine() is called frequently
# especially while downloading address history.
return (
address in self._recv_address_set_cached
or address in self._change_address_set_cached
)
def is_change(self, address: Address) -> bool:
assert not isinstance(address, str)
ca = self.get_change_addresses()
if len(ca) != len(self._change_address_set_cached):
# re-create cache if lengths don't match
self._change_address_set_cached = frozenset(ca)
return address in self._change_address_set_cached
def get_address_index(self, address: Address) -> Tuple[int]:
"""Return last two elements of the bip 44 path (change, address_index)
for an address in the wallet."""
try:
return 0, self.receiving_addresses.index(address)
except ValueError:
pass
try:
return 1, self.change_addresses.index(address)
except ValueError:
pass
assert not isinstance(address, str)
raise AddressNotFoundError("Address {} not found".format(address))
def add_unverified_tx(self, tx_hash, tx_height):
with self.lock:
if tx_height == 0 and tx_hash in self.verified_tx:
self.verified_tx.pop(tx_hash)
if self.verifier:
self.verifier.merkle_roots.pop(tx_hash, None)
# tx will be verified only if height > 0
if tx_hash not in self.verified_tx:
self.unverified_tx[tx_hash] = tx_height
def add_verified_tx(self, tx_hash, info):
# Remove from the unverified map and add to the verified map and
with self.lock:
self.unverified_tx.pop(tx_hash, None)
self.verified_tx[tx_hash] = info # (tx_height, timestamp, pos)
height, conf, timestamp = self.get_tx_height(tx_hash)
self.network.trigger_callback(
"verified2", self, tx_hash, height, conf, timestamp
)
self._update_request_statuses_touched_by_tx(tx_hash)
def get_unverified_txs(self):
"""Returns a map from tx hash to transaction height"""
with self.lock:
return self.unverified_tx.copy()
def get_unverified_tx_pending_count(self):
"""Returns the number of unverified tx's that are confirmed and are
still in process and should be verified soon."""
with self.lock:
return len([1 for height in self.unverified_tx.values() if height > 0])
def undo_verifications(self, blockchain, height):
"""Used by the verifier when a reorg has happened"""
txs = set()
with self.lock:
for tx_hash, item in list(self.verified_tx.items()):
tx_height, timestamp, pos = item
if tx_height >= height:
header = blockchain.read_header(tx_height)
# fixme: use block hash, not timestamp
if not header or header.get("timestamp") != timestamp:
self.verified_tx.pop(tx_hash, None)
txs.add(tx_hash)
if txs:
# this is probably not necessary -- as the receive_history_callback will invalidate bad cache items -- but just to be paranoid we clear the whole balance cache on reorg anyway as a safety measure
self._addr_bal_cache = {}
for tx_hash in txs:
self._update_request_statuses_touched_by_tx(tx_hash)
return txs
def get_local_height(self):
"""return last known height if we are offline"""
return (
self.network.get_local_height()
if self.network
else self.storage.get("stored_height", 0)
)
def get_tx_height(self, tx_hash):
"""return the height and timestamp of a verified transaction."""
with self.lock:
if tx_hash in self.verified_tx:
height, timestamp, pos = self.verified_tx[tx_hash]
conf = max(self.get_local_height() - height + 1, 0)
return height, conf, timestamp
elif tx_hash in self.unverified_tx:
height = self.unverified_tx[tx_hash]
return height, 0, 0
else:
return 0, 0, 0
def get_txpos(self, tx_hash):
"return position, even if the tx is unverified"
with self.lock:
if tx_hash in self.verified_tx:
height, timestamp, pos = self.verified_tx[tx_hash]
return height, pos
elif tx_hash in self.unverified_tx:
height = self.unverified_tx[tx_hash]
return (height, 0) if height > 0 else ((1e9 - height), 0)
else:
return (1e9 + 1, 0)
def is_found(self):
return any(value for value in self._history.values())
def get_num_tx(self, address):
"""return number of transactions where address is involved"""
return len(self.get_address_history(address))
def get_tx_delta(self, tx_hash, address):
assert isinstance(address, Address)
"effect of tx on address"
# pruned
if tx_hash in self.pruned_txo_values:
return None
delta = 0
# substract the value of coins sent from address
d = self.txi.get(tx_hash, {}).get(address, [])
for n, v in d:
delta -= v
# add the value of the coins received at address
d = self.txo.get(tx_hash, {}).get(address, [])
for n, v, cb in d:
delta += v
return delta
WalletDelta = namedtuple(
"WalletDelta", "is_relevant, is_mine, v, fee, spends_coins_mine"
)
def get_wallet_delta(self, tx: Transaction) -> WalletDelta:
"""Effect of tx on wallet"""
is_relevant = False
is_mine = False
is_pruned = False
is_partial = False
v_in = v_out = v_out_mine = 0
spends_coins_mine = []
for txin in tx.txinputs():
if self.is_mine(txin.address):
is_mine = True
is_relevant = True
outpoint = txin.outpoint
d = self.txo.get(outpoint.txid.to_string(), {}).get(txin.address, [])
for n, v, cb in d:
if n == outpoint.n:
value = v
spends_coins_mine.append(str(outpoint))
break
else:
value = None
if value is None:
is_pruned = True
else:
v_in += value
else:
is_partial = True
if not is_mine:
is_partial = False
for txo in tx.outputs():
v_out += txo.value
if self.is_mine(txo.destination):
v_out_mine += txo.value
is_relevant = True
if is_pruned:
# some inputs are mine:
fee = None
if is_mine:
v = v_out_mine - v_out
else:
# no input is mine
v = v_out_mine
else:
v = v_out_mine - v_in
if is_partial:
# some inputs are mine, but not all
fee = None
else:
# all inputs are mine
fee = v_in - v_out
if not is_mine:
fee = None
return self.WalletDelta(is_relevant, is_mine, v, fee, spends_coins_mine)
TxInfo = namedtuple(
"TxInfo",
"tx_hash, status, label, can_broadcast, amount, fee, height, conf,"
" timestamp, exp_n, status_enum",
)
class StatusEnum(Enum):
Unconfirmed = auto()
NotVerified = auto()
Confirmed = auto()
Signed = auto()
Unsigned = auto()
PartiallySigned = auto()
def get_tx_extended_info(self, tx) -> Tuple[WalletDelta, TxInfo]:
"""Get extended information for a transaction, combined into 1 call (for performance)"""
delta = self.get_wallet_delta(tx)
info = self.get_tx_info(tx, delta)
return (delta, info)
def get_tx_info(self, tx, delta) -> TxInfo:
"""get_tx_info implementation"""
is_relevant, is_mine, v, fee, __ = delta
exp_n = None
can_broadcast = False
label = ""
height = conf = timestamp = None
tx_hash = tx.txid()
if tx.is_complete():
if tx_hash in self.transactions:
label = self.get_label(tx_hash)
height, conf, timestamp = self.get_tx_height(tx_hash)
if height > 0:
if conf:
status = ngettext(
"{conf} confirmation", "{conf} confirmations", conf
).format(conf=conf)
status_enum = self.StatusEnum.Confirmed
else:
status = _("Not verified")
status_enum = self.StatusEnum.NotVerified
else:
status = _("Unconfirmed")
status_enum = self.StatusEnum.Unconfirmed
if fee is None:
fee = self.tx_fees.get(tx_hash)
else:
status = _("Signed")
status_enum = self.StatusEnum.Signed
can_broadcast = self.network is not None
else:
s, r = tx.signature_count()
if s == 0:
status = _("Unsigned")
status_enum = self.StatusEnum.Unsigned
else:
status = _("Partially signed") + " (%d/%d)" % (s, r)
status_enum = self.StatusEnum.PartiallySigned
if is_relevant:
if is_mine:
if fee is not None:
amount = v + fee
else:
amount = v
else:
amount = v
else:
amount = None
return self.TxInfo(
tx_hash,
status,
label,
can_broadcast,
amount,
fee,
height,
conf,
timestamp,
exp_n,
status_enum,
)
def get_address_unspent(
self, address: Address, address_history: List[HistoryItemType]
) -> UnspentCoinsType:
received = {}
for tx_hash, height in address_history:
coins = self.txo.get(tx_hash, {}).get(address, [])
for n, v, is_cb in coins:
received[f"{tx_hash}:{n}"] = (height, v, is_cb)
return received
def get_address_spent(
self, address: Address, address_history: List[HistoryItemType]
) -> SpendCoinsType:
sent = {}
for tx_hash, height in address_history:
inputs = self.txi.get(tx_hash, {}).get(address, [])
for txi, v in inputs:
sent[txi] = height
return sent
def get_addr_io(self, address: Address) -> Tuple[UnspentCoinsType, SpendCoinsType]:
history = self.get_address_history(address)
received = self.get_address_unspent(address, history)
sent = self.get_address_spent(address, history)
return received, sent
def get_addr_utxo(self, address: Address) -> Dict[str, Dict[str, Any]]:
"""Return a {"tx_hash:prevout_n": dict_of_info, ...} dict"""
coins, spent = self.get_addr_io(address)
for txi in spent:
coins.pop(txi)
# cleanup/detect if the 'frozen coin' was spent and remove it from the frozen coin set
self.frozen_coins.discard(txi)
self.frozen_coins_tmp.discard(txi)
out = {}
for txo, v in coins.items():
tx_height, value, is_cb = v
prevout_hash, prevout_n = txo.split(":")
x = {
"address": address,
"value": value,
"prevout_n": int(prevout_n),
"prevout_hash": prevout_hash,
"height": tx_height,
"coinbase": is_cb,
"is_frozen_coin": (
txo in self.frozen_coins or txo in self.frozen_coins_tmp
),
"slp_token": self.slp.token_info_for_txo(
txo
), # (token_id_hex, qty) tuple or None
}
out[txo] = x
return out
def get_addr_balance(
self, address: Address, exclude_frozen_coins=False
) -> Tuple[int, int, int]:
"""Returns the balance of a bitcoin address as a tuple of:
(confirmed_matured, unconfirmed, unmatured)
Note that 'exclude_frozen_coins = True' only checks for coin-level
freezing, not address-level."""
assert isinstance(address, Address)
mempoolHeight = self.get_local_height() + 1
# we do not use the cache when excluding frozen coins as frozen status is a dynamic quantity that can change at any time in the UI
if not exclude_frozen_coins:
cached = self._addr_bal_cache.get(address)
if cached is not None:
return cached
received, sent = self.get_addr_io(address)
c = u = x = 0
had_cb = False
for txo, (tx_height, v, is_cb) in received.items():
if exclude_frozen_coins and (
txo in self.frozen_coins or txo in self.frozen_coins_tmp
):
continue
had_cb = (
had_cb or is_cb
) # remember if this address has ever seen a coinbase txo
if is_cb and tx_height + bitcoin.COINBASE_MATURITY > mempoolHeight:
x += v
elif tx_height > 0:
c += v
else:
u += v
if txo in sent:
if sent[txo] > 0:
c -= v
else:
u -= v
result = c, u, x
if not exclude_frozen_coins and not had_cb:
# Cache the results.
# Cache needs to be invalidated if a transaction is added to/
# removed from addr history. (See self._addr_bal_cache calls
# related to this littered throughout this file).
#
# Note that as a performance tweak we don't ever cache balances for
# addresses involving coinbase coins. The rationale being as
# follows: Caching of balances of the coinbase addresses involves
# a dynamic quantity: maturity of the coin (which considers the
# ever-changing block height).
#
# There wasn't a good place in this codebase to signal the maturity
# happening (and thus invalidate the cache entry for the exact
# address that holds the coinbase coin in question when a new
# block is found that matures a coinbase coin).
#
# In light of that fact, a possible approach would be to invalidate
# this entire cache when a new block arrives (this is what Electrum
# does). However, for Electron Cash with its focus on many addresses
# for future privacy features such as integrated CashShuffle --
# being notified in the wallet and invalidating the *entire* cache
# whenever a new block arrives (which is the exact time you do
# the most GUI refreshing and calling of this function) seems a bit
# heavy-handed, just for sake of the (relatively rare, for the
# average user) coinbase-carrying addresses.
#
# It's not a huge performance hit for the coinbase addresses to
# simply not cache their results, and have this function recompute
# their balance on each call, when you consider that as a
# consequence of this policy, all the other addresses that are
# non-coinbase can benefit from a cache that stays valid for longer
# than 1 block (so long as their balances haven't changed).
self._addr_bal_cache[address] = result
return result
def get_spendable_coins(self, domain, config, isInvoice=False):
confirmed_only = config.get("confirmed_only", DEFAULT_CONFIRMED_ONLY)
if isInvoice:
confirmed_only = True
return self.get_utxos(
domain,
exclude_frozen=True,
mature=True,
confirmed_only=confirmed_only,
exclude_slp=True,
)
def get_utxos(
self,
domain=None,
exclude_frozen=False,
mature=False,
confirmed_only=False,
*,
addr_set_out=None,
exclude_slp=True,
):
"""Note that exclude_frozen = True checks for BOTH address-level and
coin-level frozen status.
exclude_slp skips coins that also have SLP tokens on them. This defaults
to True in EC 4.0.10+ in order to prevent inadvertently burning tokens.
Optional kw-only arg `addr_set_out` specifies a set in which to add all
addresses encountered in the utxos returned."""
with self.lock:
mempoolHeight = self.get_local_height() + 1
coins = []
if domain is None:
domain = self.get_addresses()
if exclude_frozen:
domain = set(domain) - self.frozen_addresses
for addr in domain:
utxos = self.get_addr_utxo(addr)
len_before = len(coins)
for x in utxos.values():
if exclude_slp and x["slp_token"]:
continue
if exclude_frozen and x["is_frozen_coin"]:
continue
if confirmed_only and x["height"] <= 0:
continue
# A note about maturity: Previous versions of Electrum
# and Electron Cash were off by one. Maturity is
# calculated based off mempool height (chain tip height + 1).
# See bitcoind consensus/tx_verify.cpp Consensus::CheckTxInputs
# and also txmempool.cpp CTxMemPool::removeForReorg.
if (
mature
and x["coinbase"]
and mempoolHeight - x["height"] < bitcoin.COINBASE_MATURITY
):
continue
coins.append(x)
if addr_set_out is not None and len(coins) > len_before:
# add this address to the address set if it has results
addr_set_out.add(addr)
return coins
@abstractmethod
def get_receiving_addresses(
self, *, slice_start=None, slice_stop=None
) -> List[Address]:
pass
@abstractmethod
def has_seed(self) -> bool:
pass
def dummy_address(self) -> Address:
return self.get_receiving_addresses()[0]
def get_addresses(self) -> List[Address]:
return self.get_receiving_addresses() + self.get_change_addresses()
def get_change_addresses(self) -> List[Address]:
"""Reimplemented in subclasses for wallets that have a change address
set/derivation path.
"""
return []
def get_frozen_balance(self):
if not self.frozen_coins and not self.frozen_coins_tmp:
# performance short-cut -- get the balance of the frozen address set only IFF we don't have any frozen coins
return self.get_balance(self.frozen_addresses)
# otherwise, do this more costly calculation...
cc_no_f, uu_no_f, xx_no_f = self.get_balance(
None, exclude_frozen_coins=True, exclude_frozen_addresses=True
)
cc_all, uu_all, xx_all = self.get_balance(
None, exclude_frozen_coins=False, exclude_frozen_addresses=False
)
return (cc_all - cc_no_f), (uu_all - uu_no_f), (xx_all - xx_no_f)
def get_balance(
self, domain=None, exclude_frozen_coins=False, exclude_frozen_addresses=False
):
if domain is None:
domain = self.get_addresses()
if exclude_frozen_addresses:
domain = set(domain) - self.frozen_addresses
cc = uu = xx = 0
for addr in domain:
c, u, x = self.get_addr_balance(addr, exclude_frozen_coins)
cc += c
uu += u
xx += x
return cc, uu, xx
def get_address_history(self, address: Address) -> List[HistoryItemType]:
"""Returns a list of (tx_hash, block_height) for an address"""
assert isinstance(address, Address)
return self._history.get(address, [])
def _clean_pruned_txo_thread(self):
"""Runs in the thread self.pruned_txo_cleaner_thread which is only
active if self.network. Cleans the self.pruned_txo dict and the
self.pruned_txo_values set of spends that are not relevant to the
wallet. The processing below is needed because as of 9/16/2019, Electron
Cash temporarily puts all spends that pass through add_transaction and
have an unparseable address (txi['address'] is None) into the dict
self.pruned_txo. This is necessary for handling tx's with esoteric p2sh
scriptSigs and detecting balance changes properly for txins
containing such scriptSigs. See #895."""
def deser(ser):
prevout_hash, prevout_n = ser.split(":")
prevout_n = int(prevout_n)
return prevout_hash, prevout_n
def mkser(prevout_hash, prevout_n):
return f"{prevout_hash}:{prevout_n}"
def rm(ser, pruned_too=True, *, tup=None):
# tup arg is for performance when caller already knows the info
# (avoid a redundant .split on ':')
h, n = tup or deser(ser)
s = txid_n[h]
s.discard(n)
if not s:
txid_n.pop(h, None)
if pruned_too:
with self.lock:
tx_hash = self.pruned_txo.pop(ser, None)
self.pruned_txo_values.discard(tx_hash)
def add(ser):
prevout_hash, prevout_n = deser(ser)
txid_n[prevout_hash].add(prevout_n)
def keep_running():
return bool(self.network and self.pruned_txo_cleaner_thread is me)
def can_do_work():
return bool(txid_n and self.is_up_to_date())
debug = False # set this to true here to get more verbose output
me = threading.current_thread()
q = me.q
me.txid_n = txid_n = defaultdict(
set
) # dict of prevout_hash -> set of prevout_n (int)
last = time.time()
try:
self.print_error(f"{me.name}: thread started")
with self.lock:
# Setup -- grab whatever was already in pruned_txo at thread
# start
for ser in self.pruned_txo:
h, n = deser(ser)
txid_n[h].add(n)
while keep_running():
try:
ser = q.get(timeout=5.0 if can_do_work() else 20.0)
if ser is None:
# quit thread
return
if ser.startswith("r_"):
# remove requested
rm(ser[2:], False)
else:
# ser was added
add(ser)
del ser
except queue.Empty:
pass
if not can_do_work():
continue
t0 = time.time()
if t0 - last < 1.0: # run no more often than once per second
continue
last = t0
defunct_ct = 0
for prevout_hash, s in txid_n.copy().items():
for prevout_n in s.copy():
ser = mkser(prevout_hash, prevout_n)
with self.lock:
defunct = ser not in self.pruned_txo
if defunct:
# self.print_error(f"{me.name}: skipping already-cleaned", ser)
rm(ser, False, tup=(prevout_hash, prevout_n))
defunct_ct += 1
continue
if defunct_ct and debug:
self.print_error(
f"{me.name}: DEBUG",
defunct_ct,
"defunct txos removed in",
time.time() - t0,
"secs",
)
ct = 0
for prevout_hash, s in txid_n.copy().items():
try:
with self.lock:
tx = self.transactions.get(prevout_hash)
if tx is None:
tx = Transaction.tx_cache_get(prevout_hash)
if isinstance(tx, Transaction):
tx = Transaction(tx.raw) # take a copy
else:
if debug:
self.print_error(
f"{me.name}: DEBUG retrieving txid",
prevout_hash,
"...",
)
t1 = time.time()
tx = Transaction(
bytes.fromhex(
self.network.synchronous_get(
("blockchain.transaction.get", [prevout_hash])
)
)
)
if debug:
self.print_error(
f"{me.name}: DEBUG network retrieve took",
time.time() - t1,
"secs",
)
# Paranoia; intended side effect of the below assert
# is to also deserialize the tx (by calling the slow
# .txid()) which ensures the tx from the server
# is not junk.
assert prevout_hash == tx.txid(), "txid mismatch"
# will cache a copy
Transaction.tx_cache_put(tx, prevout_hash)
except Exception as e:
self.print_error(
f"{me.name}: Error retrieving txid",
prevout_hash,
":",
repr(e),
)
if (
not keep_running()
): # in case we got a network timeout *and* the wallet was closed
return
continue
if not keep_running():
return
for prevout_n in s.copy():
ser = mkser(prevout_hash, prevout_n)
try:
txo = tx.outputs()[prevout_n]
except IndexError:
self.print_error(
f"{me.name}: ERROR -- could not find output", ser
)
rm(ser, True, tup=(prevout_hash, prevout_n))
continue
rm_pruned_too = False
with self.lock:
mine = self.is_mine(txo.destination)
if not mine and ser in self.pruned_txo:
ct += 1
rm_pruned_too = True
rm(ser, rm_pruned_too, tup=(prevout_hash, prevout_n))
if rm_pruned_too and debug:
self.print_error(f"{me.name}: DEBUG removed", ser)
if ct:
with self.lock:
# Save changes to storage -- this is cheap and doesn't
# actually write to file yet, just flags storage as
# 'dirty' for when wallet.storage.write() is called
# later.
self.storage.put("pruned_txo", self.pruned_txo)
self.print_error(
f"{me.name}: removed",
ct,
"(non-relevant) pruned_txo's in",
f"{time.time() - t0:3.2f}",
"seconds",
)
except Exception:
import traceback
self.print_error(f"{me.name}:", traceback.format_exc())
raise
finally:
self.print_error(f"{me.name}: thread exiting")
def add_transaction(self, tx_hash: str, tx: Transaction):
if not tx.txinputs():
# bad tx came in off the wire -- all 0's or something, see #987
self.print_error(
"add_transaction: WARNING a tx came in from the network with 0"
" inputs! Bad server? Ignoring tx:",
tx_hash,
)
return
is_coinbase = tx.txinputs()[0].type == ScriptType.coinbase
with self.lock:
# HELPER FUNCTIONS
def add_to_self_txi(tx_hash, addr, ser, v):
"""addr must be 'is_mine'"""
d = self.txi.get(tx_hash)
if d is None:
self.txi[tx_hash] = d = {}
txis = d.get(addr)
if txis is None:
d[addr] = txis = []
txis.append((ser, v))
def find_in_self_txo(prevout_hash: str, prevout_n: int) -> tuple:
"""Returns a tuple of the (Address,value) for a given
prevout_hash:prevout_n, or (None, None) if not found. If valid
return, the Address object is found by scanning self.txo. The
lookup below is relatively fast in practice even on pathological
wallets."""
dd = self.txo.get(prevout_hash, {})
for addr2, item in dd.items():
for n, v, is_cb in item:
if n == prevout_n:
return addr2, v
return (None, None)
def put_pruned_txo(ser, tx_hash):
self.pruned_txo[ser] = tx_hash
self.pruned_txo_values.add(tx_hash)
t = self.pruned_txo_cleaner_thread
if t and t.q:
t.q.put(ser)
def pop_pruned_txo(ser):
next_tx = self.pruned_txo.pop(ser, None)
if next_tx:
self.pruned_txo_values.discard(next_tx)
t = self.pruned_txo_cleaner_thread
if t and t.q:
t.q.put("r_" + ser) # notify of removal
return next_tx
# /HELPER FUNCTIONS
# add inputs
self.txi[tx_hash] = d = {}
for txi in tx.txinputs():
if txi.type == ScriptType.coinbase:
continue
addr = txi.address
prevout_hash = txi.outpoint.txid.to_string()
prevout_n = txi.outpoint.n
ser = str(txi.outpoint)
# find value from prev output
if self.is_mine(addr):
dd = self.txo.get(prevout_hash, {})
for n, v, is_cb in dd.get(addr, []):
if n == prevout_n:
add_to_self_txi(tx_hash, addr, ser, v)
break
else:
# Coin's spend tx came in before its receive tx: flag
# the spend for when the receive tx will arrive into
# this function later.
put_pruned_txo(ser, tx_hash)
self._addr_bal_cache.pop(addr, None) # invalidate cache entry
del dd
elif addr is None:
# Unknown/unparsed address.. may be a strange p2sh scriptSig
# Try and find it in txout's if it's one of ours.
# See issue #895.
# Find address in self.txo for this prevout_hash:prevout_n
addr2, v = find_in_self_txo(prevout_hash, prevout_n)
if addr2 is not None and self.is_mine(addr2):
add_to_self_txi(tx_hash, addr2, ser, v)
self._addr_bal_cache.pop(addr2, None) # invalidate cache entry
else:
# Not found in self.txo. It may still be one of ours
# however since tx's can come in out of order due to
# CTOR, etc, and self.txo may not have it yet. So we
# flag the spend now, and when the out-of-order prevout
# tx comes in later for this input (if it's indeed one
# of ours), the real address for this input will get
# picked up then in the "add outputs" section below in
# this function. At that point, self.txi will be
# properly updated to indicate the coin in question was
# spent via an add_to_self_txi call.
#
# If it's *not* one of ours, however, the below will
# grow pruned_txo with an irrelevant entry. However, the
# irrelevant entry will eventually be reaped and removed
# by the self.pruned_txo_cleaner_thread which runs
# periodically in the background.
put_pruned_txo(ser, tx_hash)
del addr2, v
# don't keep empty entries in self.txi
if not d:
self.txi.pop(tx_hash, None)
# add outputs
self.txo[tx_hash] = d = {}
for n, txo in enumerate(tx.outputs()):
ser = tx_hash + ":%d" % n
mine = False
if self.is_mine(txo.destination):
# add coin to self.txo since it's mine.
mine = True
coins = d.get(txo.destination)
if coins is None:
d[txo.destination] = coins = []
coins.append((n, txo.value, is_coinbase))
del coins
# invalidate cache entry
self._addr_bal_cache.pop(txo.destination, None)
# give value to txi that spends me
next_tx = pop_pruned_txo(ser)
if next_tx is not None and mine:
add_to_self_txi(next_tx, txo.destination, ser, txo.value)
# don't keep empty entries in self.txo
if not d:
self.txo.pop(tx_hash, None)
# save
self.transactions[tx_hash] = tx
# Unconditionally invoke the SLP handler. Note that it is a fast &
# cheap no-op if this tx's outputs[0] is not an SLP script.
self.slp.add_tx(tx_hash, tx)
def remove_transaction(self, tx_hash):
with self.lock:
self.print_error("removing tx from history", tx_hash)
# Note that we don't actually remove the tx_hash from
# self.transactions, but instead rely on the unreferenced tx being
# removed the next time the wallet is loaded in self.load_transactions()
to_pop = []
for ser, hh in self.pruned_txo.items():
if hh == tx_hash:
to_pop.append(ser)
self.pruned_txo_values.discard(hh)
for ser in to_pop:
self.pruned_txo.pop(ser, None)
# add tx to pruned_txo, and undo the txi addition
for next_tx, dd in self.txi.items():
to_pop = []
for addr, txis_for_addr in dd.items():
del_idx = []
for idx, (ser, v) in enumerate(txis_for_addr):
prev_hash, prev_n = ser.split(":")
if prev_hash == tx_hash:
self._addr_bal_cache.pop(
addr, None
) # invalidate cache entry
del_idx.append(idx)
self.pruned_txo[ser] = next_tx
self.pruned_txo_values.add(next_tx)
for ctr, idx in enumerate(del_idx):
del txis_for_addr[idx - ctr]
if len(txis_for_addr) == 0:
to_pop.append(addr)
for addr in to_pop:
dd.pop(addr, None)
# invalidate addr_bal_cache for outputs involving this tx
d = self.txo.get(tx_hash, {})
for addr in d:
self._addr_bal_cache.pop(addr, None) # invalidate cache entry
try:
self.txi.pop(tx_hash)
except KeyError:
self.print_error("tx was not in input history", tx_hash)
try:
self.txo.pop(tx_hash)
except KeyError:
self.print_error("tx was not in output history", tx_hash)
# inform slp subsystem as well
self.slp.rm_tx(tx_hash)
def receive_tx_callback(self, tx_hash, tx, tx_height):
self.add_transaction(tx_hash, tx)
self.add_unverified_tx(tx_hash, tx_height)
self._update_request_statuses_touched_by_tx(tx_hash)
def _update_request_statuses_touched_by_tx(self, tx_hash):
tx = self.transactions.get(tx_hash)
if tx is None:
return
if (
self.network
and self.network.callback_listener_count("payment_received") > 0
):
for _a, addr, _b in tx.outputs():
# returns PR_UNKNOWN quickly if addr has no requests, otherwise
# returns tuple
status = self.get_request_status(addr)
if status != PR_UNKNOWN:
status = status[0] # unpack status from tuple
self.network.trigger_callback(
"payment_received", self, addr, status
)
def receive_history_callback(self, addr, hist: List[Tuple[str, int]], tx_fees):
hist_set = frozenset((tx_hash, height) for tx_hash, height in hist)
with self.lock:
old_hist = self.get_address_history(addr)
old_hist_set = frozenset((tx_hash, height) for tx_hash, height in old_hist)
for tx_hash, height in old_hist_set - hist_set:
s = self.tx_addr_hist.get(tx_hash)
if s:
s.discard(addr)
if not s:
# if no address references this tx anymore, kill it
# from txi/txo dicts.
if s is not None:
# We won't keep empty sets around.
self.tx_addr_hist.pop(tx_hash)
# note this call doesn't actually remove the tx from
# storage, it merely removes it from the self.txi
# and self.txo dicts
self.remove_transaction(tx_hash)
self._addr_bal_cache.pop(
addr, None
) # unconditionally invalidate cache entry
self._history[addr] = hist
for tx_hash, tx_height in hist:
# add it in case it was previously unconfirmed
self.add_unverified_tx(tx_hash, tx_height)
# add reference in tx_addr_hist
self.tx_addr_hist[tx_hash].add(addr)
# if addr is new, we have to recompute txi and txo
tx = self.transactions.get(tx_hash)
if (
tx is not None
and self.txi.get(tx_hash, {}).get(addr) is None
and self.txo.get(tx_hash, {}).get(addr) is None
):
self.add_transaction(tx_hash, tx)
# Store fees
self.tx_fees.update(tx_fees)
if self.network:
self.network.trigger_callback("on_history", self)
def add_tx_to_history(self, txid):
with self.lock:
for addr in itertools.chain(
self.txi.get(txid, {}).keys(), self.txo.get(txid, {}).keys()
):
cur_hist = self._history.get(addr, [])
if not any(x[0] == txid for x in cur_hist):
cur_hist.append((txid, 0))
self._history[addr] = cur_hist
TxHistory = namedtuple(
"TxHistory", "tx_hash, height, conf, timestamp, amount, balance"
)
@profiler
def get_history(self, domain=None, *, reverse=False) -> List[TxHistory]:
# get domain
if domain is None:
domain = self.get_addresses()
# 1. Get the history of each address in the domain, maintain the
# delta of a tx as the sum of its deltas on domain addresses
tx_deltas = defaultdict(int)
for addr in domain:
h = self.get_address_history(addr)
for tx_hash, height in h:
delta = self.get_tx_delta(tx_hash, addr)
if delta is None or tx_deltas[tx_hash] is None:
tx_deltas[tx_hash] = None
else:
tx_deltas[tx_hash] += delta
# 2. create sorted history
history = []
for tx_hash in tx_deltas:
delta = tx_deltas[tx_hash]
height, conf, timestamp = self.get_tx_height(tx_hash)
history.append((tx_hash, height, conf, timestamp, delta))
def sort_func_receives_before_sends(h_item):
"""Here we sort in a way such that receives are always ordered before sends, per block"""
height, pos = self.get_txpos(h_item[0])
delta = h_item[4] or 0 # Guard against delta == None by forcing None -> 0
return height, -delta, pos
history.sort(key=sort_func_receives_before_sends, reverse=True)
# 3. add balance
c, u, x = self.get_balance(domain)
balance = c + u + x
h2 = []
for tx_hash, height, conf, timestamp, delta in history:
h2.append(self.TxHistory(tx_hash, height, conf, timestamp, delta, balance))
if balance is None or delta is None:
balance = None
else:
balance -= delta
if not reverse:
h2.reverse()
return h2
def export_history(
self,
domain=None,
from_timestamp=None,
to_timestamp=None,
fx=None,
show_addresses=False,
decimal_point: int = XEC.decimals,
*,
fee_calc_timeout=10.0,
download_inputs=False,
progress_callback=None,
):
"""Export history. Used by RPC & GUI.
Arg notes:
- `fee_calc_timeout` is used when computing the fee (which is done
asynchronously in another thread) to limit the total amount of time in
seconds spent waiting for fee calculation. The timeout is a total time
allotment for this function call. (The reason the fee calc can take a
long time is for some pathological tx's, it is very slow to calculate
fee as it involves deserializing prevout_tx from the wallet, for each
input).
- `download_inputs`, if True, will allow for more accurate fee data to
be exported with the history by using the Transaction class input
fetcher to download *all* prevout_hash tx's for inputs (even for
inputs not in wallet). This feature requires self.network (ie, we need
to be online) otherwise it will behave as if download_inputs=False.
- `progress_callback`, if specified, is a callback which receives a
single float argument in the range [0.0,1.0] indicating how far along
the history export is going. This is intended for interop with GUI
code. Node the progress callback is not guaranteed to be called in the
context of the main thread, therefore GUI code should use appropriate
signals/slots to update the GUI with progress info.
Note on side effects: This function may update self.tx_fees. Rationale:
it will spend some time trying very hard to calculate accurate fees by
examining prevout_tx's (leveraging the fetch_input_data code in the
Transaction class). As such, it is worthwhile to cache the results in
self.tx_fees, which gets saved to wallet storage. This is not very
demanding on storage as even for very large wallets with huge histories,
tx_fees does not use more than a few hundred kb of space."""
from .util import timestamp_to_datetime
# we save copies of tx's we deserialize to this temp dict because we do
# *not* want to deserialize tx's in wallet.transactoins since that
# wastes memory
local_tx_cache = {}
# some helpers for this function
t0 = time.time()
def time_remaining():
return max(fee_calc_timeout - (time.time() - t0), 0)
class MissingTx(RuntimeError):
"""Can happen in rare circumstances if wallet history is being
radically reorged by network thread while we are in this code."""
def get_tx(tx_hash):
"""Try to get a tx from wallet, then from the Transaction class
cache if that fails. In either case it deserializes the copy and
puts the deserialized tx in local stack dict local_tx_cache. The
reason we don't deserialize the tx's from self.transactions is that
we do not want to keep deserialized tx's in memory. The
self.transactions dict should contain just raw tx's (not
deserialized). Deserialized tx's eat on the order of 10x the memory
because because of the Python lists, dict, etc they contain, per
instance."""
tx = local_tx_cache.get(tx_hash)
if tx:
return tx
tx = Transaction.tx_cache_get(tx_hash)
if not tx:
tx = copy.deepcopy(self.transactions.get(tx_hash))
if tx:
tx.deserialize()
local_tx_cache[tx_hash] = tx
else:
raise MissingTx(
f"txid {tx_hash} dropped out of wallet history while exporting"
)
return tx
def try_calc_fee(tx_hash):
"""Try to calc fee from cheapest to most expensive calculation.
Ultimately asks the transaction class to look at prevouts in wallet and uses
that scheme as a last (more CPU intensive) resort."""
fee = self.tx_fees.get(tx_hash)
if fee is not None:
return fee
def do_get_fee(tx_hash):
tx = get_tx(tx_hash)
def try_get_fee(tx):
try:
return tx.get_fee()
except InputValueMissing:
pass
fee = try_get_fee(tx)
t_remain = time_remaining()
if fee is None and t_remain:
q = queue.Queue()
def done():
q.put(1)
tx.fetch_input_data(
self, use_network=bool(download_inputs), done_callback=done
)
try:
q.get(timeout=t_remain)
except queue.Empty:
pass
fee = try_get_fee(tx)
return fee
fee = do_get_fee(tx_hash)
if fee is not None:
# save fee to wallet if we bothered to dl/calculate it.
self.tx_fees[tx_hash] = fee
return fee
def fmt_amt(v, is_diff):
if v is None:
return "--"
return format_satoshis(v, decimal_point=decimal_point, is_diff=is_diff)
# grab history
history = self.get_history(domain, reverse=True)
out = []
n, length = 0, max(1, float(len(history)))
for tx_hash, height, conf, timestamp, value, balance in history:
if progress_callback:
progress_callback(n / length)
n += 1
timestamp_safe = timestamp
if timestamp is None:
timestamp_safe = (
time.time()
) # set it to "now" so below code doesn't explode.
if from_timestamp and timestamp_safe < from_timestamp:
continue
if to_timestamp and timestamp_safe >= to_timestamp:
continue
try:
fee = try_calc_fee(tx_hash)
except MissingTx as e:
self.print_error(str(e))
continue
item = {
"txid": tx_hash,
"height": height,
"confirmations": conf,
"timestamp": timestamp_safe,
"value": fmt_amt(value, is_diff=True),
"fee": fmt_amt(fee, is_diff=False),
"balance": fmt_amt(balance, is_diff=False),
}
if item["height"] > 0:
date_str = (
format_time(timestamp) if timestamp is not None else _("unverified")
)
else:
date_str = _("unconfirmed")
item["date"] = date_str
try:
# Defensive programming.. sanitize label.
# The below ensures strings are utf8-encodable. We do this
# as a paranoia measure.
item["label"] = (
self.get_label(tx_hash)
.encode(encoding="utf-8", errors="replace")
.decode(encoding="utf-8", errors="replace")
)
except UnicodeError:
self.print_error(
f"Warning: could not export label for {tx_hash}, defaulting to ???"
)
item["label"] = "???"
if show_addresses:
tx = get_tx(tx_hash)
input_addresses = []
output_addresses = []
for txin in tx.txinputs():
if txin.type == ScriptType.coinbase:
continue
if txin.addr is None:
continue
input_addresses.append(txin.addr.to_ui_string())
for txo in tx.outputs():
output_addresses.append(txo.destination.to_ui_string())
item["input_addresses"] = input_addresses
item["output_addresses"] = output_addresses
if fx is not None:
date = timestamp_to_datetime(timestamp_safe)
item["fiat_value"] = fx.historical_value_str(value, date)
item["fiat_balance"] = fx.historical_value_str(balance, date)
item["fiat_fee"] = fx.historical_value_str(fee, date)
out.append(item)
if progress_callback:
# indicate done, just in case client code expects a 1.0 in order to detect
# completion
progress_callback(1.0)
return out
def get_label(self, tx_hash):
label = self.labels.get(tx_hash, "")
if not label:
label = self.get_default_label(tx_hash)
return label
def get_default_label(self, tx_hash):
if not self.txi.get(tx_hash):
d = self.txo.get(tx_hash, {})
labels = []
# use a copy to avoid possibility of dict changing during iteration,
# see #1328
for addr in list(d.keys()):
label = self.labels.get(addr.to_storage_string())
if label:
labels.append(label)
return ", ".join(labels)
return ""
def get_tx_status(self, tx_hash, height, conf, timestamp):
"""Return a status value and status string.
Meaning of the status flag:
- 0: unconfirmed parent
- 1: status no longer used (it used to mean low fee for BTC)
- 2: unconfirmed
- 3: not verified (included in latest block)
- 4: verified by 1 block
- 5: verified by 2 blocks
- 6: verified by 3 blocks
- 7: verified by 4 blocks
- 8: verified by 5 blocks
- 9: verified by 6 blocks or more
"""
if conf == 0:
tx = self.transactions.get(tx_hash)
if not tx:
status = 3
status_str = "unknown"
elif height < 0:
status = 0
status_str = "Unconfirmed parent"
elif height == 0:
status = 2
status_str = "Unconfirmed"
else:
status = 3
status_str = "Not Verified"
else:
status = 3 + min(conf, 6)
status_str = format_time(timestamp) if timestamp else _("unknown")
return status, status_str
def reserve_change_addresses(self, count, temporary=False):
"""Reserve and return `count` change addresses. In order
of preference, this will return from:
1. addresses 'freed' by `.unreserve_change_address`,
2. addresses in the last 20 (gap limit) of the change list,
3. newly-created addresses.
Of these, only unlabeled, unreserved addresses with no usage history
will be returned. If you pass temporary=False (default), this will
persist upon wallet saving, otherwise with temporary=True the address
will be made available again once the wallet is re-opened.
On non-deterministic wallets, this returns an empty list.
"""
if count <= 0 or not hasattr(self, "create_new_address"):
return []
with self.lock:
last_change_addrs = self.get_change_addresses()[
-self.gap_limit_for_change :
]
if not last_change_addrs:
# this happens in non-deterministic wallets but the above
# hasattr check should have caught those.
return []
def gen_change():
try:
while True:
yield self.change_unreserved.pop(0)
except IndexError:
pass
for addr in last_change_addrs:
yield addr
while True:
yield self.create_new_address(for_change=True)
result = []
for addr in gen_change():
if (
addr in self.change_reserved
or addr in self.change_reserved_tmp
or self.get_num_tx(addr) != 0
or addr in result
):
continue
addr_str = addr.to_storage_string()
if self.labels.get(addr_str):
continue
result.append(addr)
if temporary:
self.change_reserved_tmp.add(addr)
else:
self.change_reserved.add(addr)
if len(result) >= count:
return result
raise RuntimeError("Unable to generate new addresses") # should not happen
def unreserve_change_address(self, addr):
"""Unreserve an addr that was set by reserve_change_addresses, and
also explicitly reschedule this address to be usable by a future
reservation. Unreserving is appropriate when the address was never
actually shared or used in a transaction, and reduces empty gaps in
the change list.
"""
assert addr in self.get_change_addresses()
with self.lock:
self.change_reserved.discard(addr)
self.change_reserved_tmp.discard(addr)
self.change_unreserved.append(addr)
def get_default_change_addresses(self, count):
"""Return `count` change addresses from the default reserved list,
ignoring and removing used addresses. Reserves more as needed.
The same default change addresses keep getting repeated until they are
actually seen as used in a transaction from the network. Theoretically
this could hurt privacy if the user has multiple unsigned transactions
open at the same time, but practically this avoids address gaps for
normal usage. If you need non-repeated addresses, see
`reserve_change_addresses`.
On non-deterministic wallets, this returns an empty list.
"""
result = []
with self.lock:
for addr in list(self.change_reserved_default):
if len(result) >= count:
break
if self.get_num_tx(addr) != 0:
self.change_reserved_default.remove(addr)
continue
result.append(addr)
need_more = count - len(result)
if need_more > 0:
new_addrs = self.reserve_change_addresses(need_more)
self.change_reserved_default.extend(new_addrs)
result.extend(new_addrs)
return result
def make_unsigned_transaction(
self,
inputs: List[Dict],
outputs: List[TxOutput],
config: SimpleConfig,
fixed_fee=None,
change_addr=None,
sign_schnorr=None,
shuffle_outputs=True,
):
"""sign_schnorr flag controls whether to mark the tx as signing with
schnorr or not. Specify either a bool, or set the flag to 'None' to use
whatever the wallet is configured to use from the GUI"""
sign_schnorr = (
self.is_schnorr_enabled() if sign_schnorr is None else bool(sign_schnorr)
)
# check outputs
i_max = None
for i, txo in enumerate(outputs):
if txo.value == "!":
if i_max is not None:
raise RuntimeError("More than one output set to spend max")
i_max = i
# Avoid index-out-of-range with inputs[0] below
if not inputs:
raise NotEnoughFunds()
if fixed_fee is None and config.fee_per_kb() is None:
raise RuntimeError("Dynamic fee estimates not available")
# optimization for addresses with many coins: cache unspent coins
coins_for_address: Dict[str, UnspentCoinsType] = {}
for item in inputs:
address = item["address"]
if address not in coins_for_address:
coins_for_address[address] = self.get_address_unspent(
address, self.get_address_history(address)
)
self.add_input_info(item, coins_for_address[address])
# Fee estimator
if fixed_fee is None:
fee_estimator = config.estimate_fee
elif callable(fixed_fee):
fee_estimator = fixed_fee
else:
def fee_estimator(size):
return fixed_fee
txinputs = [TxInput.from_coin_dict(inp) for inp in inputs]
if i_max is None:
# Let the coin chooser select the coins to spend
change_addrs = []
if change_addr:
change_addrs = [change_addr]
if not change_addrs:
# hook gave us nothing, so find a change addr from the change
# reservation subsystem
max_change = self.max_change_outputs if self.multiple_change else 1
if self.use_change:
change_addrs = self.get_default_change_addresses(max_change)
else:
change_addrs = []
if not change_addrs:
# For some reason we couldn't get any autogenerated change
# address (non-deterministic wallet?). So, try to find an
# input address that belongs to us.
for inp in inputs:
backup_addr = inp["address"]
if self.is_mine(backup_addr):
change_addrs = [backup_addr]
break
else:
# ok, none of the inputs are "mine" (why?!) -- fall back
# to picking first max_change change_addresses that have
# no history
change_addrs = []
for addr in self.get_change_addresses()[
-self.gap_limit_for_change :
]:
if self.get_num_tx(addr) == 0:
change_addrs.append(addr)
if len(change_addrs) >= max_change:
break
if not change_addrs:
# No unused wallet addresses or no change addresses.
# Fall back to picking ANY wallet address
try:
# Pick a random address
change_addrs = [random.choice(self.get_addresses())]
except IndexError:
change_addrs = [] # Address-free wallet?!
# This should never happen
if not change_addrs:
raise RuntimeError("Can't find a change address!")
assert all(isinstance(addr, Address) for addr in change_addrs)
coin_chooser = coinchooser.CoinChooserPrivacy()
tx = coin_chooser.make_tx(
txinputs,
outputs,
change_addrs,
fee_estimator,
sign_schnorr=sign_schnorr,
)
else:
sendable = sum(x["value"] for x in inputs)
outputs[i_max] = outputs[i_max]._replace(value=0)
tx = Transaction.from_io(txinputs, outputs, sign_schnorr=sign_schnorr)
fee = fee_estimator(tx.estimated_size())
amount = max(0, sendable - tx.output_value() - fee)
outputs[i_max] = outputs[i_max]._replace(value=amount)
tx = Transaction.from_io(txinputs, outputs, sign_schnorr=sign_schnorr)
# If user tries to send too big of a fee (more than 50 sat/byte), stop them from shooting themselves in the foot
tx_in_bytes = tx.estimated_size()
fee_in_satoshis = tx.get_fee()
sats_per_byte = fee_in_satoshis / tx_in_bytes
if sats_per_byte > 100:
raise ExcessiveFee()
tx.shuffle_inputs()
tx.sort_outputs(shuffle=shuffle_outputs)
# Timelock tx to current height.
locktime = 0
if config.is_current_block_locktime_enabled():
locktime = self.get_local_height()
if locktime == -1: # We have no local height data (no headers synced).
locktime = 0
tx.locktime = locktime
run_hook("make_unsigned_transaction", self, tx)
return tx
def mktx(
self,
outputs: List[TxOutput],
password,
config,
fee=None,
change_addr=None,
domain=None,
sign_schnorr=None,
):
coins = self.get_spendable_coins(domain, config)
tx = self.make_unsigned_transaction(
coins, outputs, config, fee, change_addr, sign_schnorr=sign_schnorr
)
self.sign_transaction(tx, password)
return tx
def is_frozen(self, addr):
"""Address-level frozen query. Note: this is set/unset independent of
'coin' level freezing."""
assert isinstance(addr, Address)
return addr in self.frozen_addresses
def is_frozen_coin(self, utxo: Union[str, dict, Set[str]]) -> Union[bool, Set[str]]:
"""'coin' level frozen query. Note: this is set/unset independent of
address-level freezing.
`utxo` is a prevout:n string, or a dict as returned from get_utxos(),
in which case a bool is returned.
`utxo` may also be a set of prevout:n strings in which case a set is
returned which is the intersection of the internal frozen coin sets
and the `utxo` set."""
assert isinstance(utxo, (str, dict, set))
if isinstance(utxo, dict):
name = "{}:{}".format(utxo["prevout_hash"], utxo["prevout_n"])
ret = name in self.frozen_coins or name in self.frozen_coins_tmp
if ret != utxo["is_frozen_coin"]:
self.print_error(
"*** WARNING: utxo has stale is_frozen_coin flag", name
)
utxo["is_frozen_coin"] = ret # update stale flag
return ret
elif isinstance(utxo, set):
# set is returned
return (self.frozen_coins | self.frozen_coins_tmp) & utxo
else:
return utxo in self.frozen_coins or utxo in self.frozen_coins_tmp
def set_frozen_state(self, addrs, freeze):
"""Set frozen state of the addresses to `freeze`, True or False. Note
that address-level freezing is set/unset independent of coin-level
freezing, however both must be satisfied for a coin to be defined as
spendable."""
if all(self.is_mine(addr) for addr in addrs):
if freeze:
self.frozen_addresses |= set(addrs)
else:
self.frozen_addresses -= set(addrs)
frozen_addresses = [
addr.to_storage_string() for addr in self.frozen_addresses
]
self.storage.put("frozen_addresses", frozen_addresses)
return True
return False
def set_frozen_coin_state(self, utxos, freeze, *, temporary=False):
"""Set frozen state of the `utxos` to `freeze`, True or False. `utxos`
is a (possibly mixed) list of either "prevout:n" strings and/or
coin-dicts as returned from get_utxos(). Note that if passing prevout:n
strings as input, 'is_mine()' status is not checked for the specified
coin. Also note that coin-level freezing is set/unset independent of
address-level freezing, however both must be satisfied for a coin to be
defined as spendable.
The `temporary` flag only applies if `freeze = True`. In that case,
freezing coins will only affect the in-memory-only frozen set, which
doesn't get saved to storage. This mechanism was added so that plugins
(such as CashFusion) have a mechanism for ephemeral coin freezing that
doesn't persist across sessions.
Note that setting `freeze = False` effectively unfreezes both the
temporary and the permanent frozen coin sets all in 1 call. Thus after a
call to `set_frozen_coin_state(utxos, False), both the temporary and the
persistent frozen sets are cleared of all coins in `utxos`."""
add_set = self.frozen_coins if not temporary else self.frozen_coins_tmp
def add(utxo):
add_set.add(utxo)
def discard(utxo):
self.frozen_coins.discard(utxo)
self.frozen_coins_tmp.discard(utxo)
apply_operation = add if freeze else discard
original_size = len(self.frozen_coins)
with self.lock:
ok = 0
for utxo in utxos:
if isinstance(utxo, str):
apply_operation(utxo)
ok += 1
elif isinstance(utxo, dict):
txo = "{}:{}".format(utxo["prevout_hash"], utxo["prevout_n"])
apply_operation(txo)
utxo["is_frozen_coin"] = bool(freeze)
ok += 1
if original_size != len(self.frozen_coins):
# Performance optimization: only set storage if the perma-set
# changed.
self.storage.put("frozen_coins", list(self.frozen_coins))
return ok
@profiler
def prepare_for_verifier(self):
# review transactions that are in the history
for addr, hist in self._history.items():
for tx_hash, tx_height in hist:
# add it in case it was previously unconfirmed
self.add_unverified_tx(tx_hash, tx_height)
# if we are on a pruning server, remove unverified transactions
with self.lock:
vr = set(self.verified_tx.keys()) | set(self.unverified_tx.keys())
to_pop = []
for tx_hash in self.transactions.keys():
if tx_hash not in vr:
to_pop.append(tx_hash)
for tx_hash in to_pop:
self.print_error("removing transaction", tx_hash)
self.transactions.pop(tx_hash)
def start_threads(self, network):
self.network = network
if self.network:
self.start_pruned_txo_cleaner_thread()
self.prepare_for_verifier()
self.verifier = SPV(self.network, self)
self.synchronizer = Synchronizer(self, network)
finalization_print_error(self.verifier)
finalization_print_error(self.synchronizer)
network.add_jobs([self.verifier, self.synchronizer])
else:
self.verifier = None
self.synchronizer = None
def stop_threads(self):
if self.network:
# Note: syncrhonizer and verifier will remove themselves from the
# network thread the next time they run, as a result of the below
# release() calls.
# It is done this way (as opposed to an immediate clean-up here)
# because these objects need to do thier clean-up actions in a
# thread-safe fashion from within the thread where they normally
# operate on their data structures.
self.synchronizer.save()
self.synchronizer.release()
self.verifier.release()
self.synchronizer = None
self.verifier = None
self.stop_pruned_txo_cleaner_thread()
# Now no references to the syncronizer or verifier
# remain so they will be GC-ed
self.storage.put("stored_height", self.get_local_height())
self.save_addresses()
self.save_transactions()
self.save_verified_tx()
self.storage.put("frozen_coins", list(self.frozen_coins))
self.save_change_reservations()
self.storage.write()
def start_pruned_txo_cleaner_thread(self):
self.pruned_txo_cleaner_thread = threading.Thread(
target=self._clean_pruned_txo_thread,
daemon=True,
name="clean_pruned_txo_thread",
)
self.pruned_txo_cleaner_thread.q = queue.Queue()
self.pruned_txo_cleaner_thread.start()
def stop_pruned_txo_cleaner_thread(self):
t = self.pruned_txo_cleaner_thread
self.pruned_txo_cleaner_thread = None # this also signals a stop
if t and t.is_alive():
t.q.put(None) # signal stop
# if the join times out, it's ok. it means the thread was stuck in
# a network call and it will eventually exit.
t.join(timeout=3.0)
def wait_until_synchronized(self, callback=None, *, timeout=None):
tstart = time.time()
def check_timed_out():
if timeout is not None and time.time() - tstart > timeout:
raise TimeoutException()
def wait_for_wallet():
self.set_up_to_date(False)
while not self.is_up_to_date():
if callback:
msg = "{}\n{} {}".format(
_("Please wait..."),
_("Addresses generated:"),
len(self.get_addresses()),
)
callback(msg)
time.sleep(0.1)
check_timed_out()
def wait_for_network():
while not self.network.is_connected():
if callback:
msg = "{} \n".format(_("Connecting..."))
callback(msg)
time.sleep(0.1)
check_timed_out()
# wait until we are connected, because the user
# might have selected another server
if self.network:
wait_for_network()
wait_for_wallet()
else:
self.synchronize()
def can_export(self):
return not self.is_watching_only() and hasattr(self.keystore, "get_private_key")
def is_used(self, address):
return self.get_address_history(address) and self.is_empty(address)
def is_empty(self, address):
assert isinstance(address, Address)
return not any(self.get_addr_balance(address))
def address_is_old(self, address, age_limit=2):
age = -1
local_height = self.get_local_height()
for tx_hash, tx_height in self.get_address_history(address):
if tx_height == 0:
tx_age = 0
else:
tx_age = local_height - tx_height + 1
if tx_age > age:
age = tx_age
if age > age_limit:
break # ok, it's old. not need to keep looping
return age > age_limit
def cpfp(self, tx, fee, sign_schnorr=None, enable_current_block_locktime=True):
"""sign_schnorr is a bool or None for auto"""
sign_schnorr = (
self.is_schnorr_enabled() if sign_schnorr is None else bool(sign_schnorr)
)
txid = tx.txid()
for i, txo in enumerate(tx.outputs()):
if txo.type == bitcoin.TYPE_ADDRESS and self.is_mine(txo.destination):
break
else:
return
coins = self.get_addr_utxo(txo.destination)
item = coins.get(txid + ":%d" % i)
if not item:
return
coins = self.get_address_unspent(
item["address"], self.get_address_history(item["address"])
)
self.add_input_info(item, coins)
inputs = [TxInput.from_coin_dict(item)]
outputs = [TxOutput(bitcoin.TYPE_ADDRESS, txo.destination, txo.value - fee)]
locktime = 0
if enable_current_block_locktime:
locktime = self.get_local_height()
# note: no need to shuffle inputs or outputs here - single input/output
return Transaction.from_io(
inputs, outputs, locktime=locktime, sign_schnorr=sign_schnorr
)
def add_input_info(self, txin: Dict[str, Any], received: UnspentCoinsType):
address = txin["address"]
if self.is_mine(address):
txin["type"] = self.get_txin_type(address)
# eCash needs value to sign
item = received.get(txin["prevout_hash"] + ":%d" % txin["prevout_n"])
tx_height, value, is_cb = item
txin["value"] = value
self.add_input_sig_info(txin, address)
def can_sign(self, tx):
if tx.is_complete():
return False
for k in self.get_keystores():
# setup "wallet advice" so Xpub wallets know how to sign 'fd' type tx inputs
# by giving them the sequence number ahead of time
if isinstance(k, BIP32KeyStore):
for txin in tx.txinputs():
for x_pubkey in txin.x_pubkeys:
_, addr = xpubkey_to_address(x_pubkey)
try:
c, index = self.get_address_index(addr)
except Exception:
continue
if index is not None:
k.set_wallet_advice(addr, [c, index])
if k.can_sign(tx):
return True
return False
def get_input_tx(self, tx_hash):
# First look up an input transaction in the wallet where it
# will likely be. If co-signing a transaction it may not have
# all the input txs, in which case we ask the network.
tx = self.transactions.get(tx_hash)
if not tx and self.network:
request = ("blockchain.transaction.get", [tx_hash])
tx = Transaction(bytes.fromhex(self.network.synchronous_get(request)))
return tx
def add_input_values_to_tx(self, tx):
"""add input values to the tx, for signing"""
for txin in tx.txinputs():
if txin.get_value() is None:
inputtx = self.get_input_tx(txin.outpoint.txid.get_hex())
if inputtx is not None:
if not txin.is_complete():
out = inputtx.outputs()[txin.outpoint.n]
txin.set_value(out.value)
# may be needed by hardware wallets
txin.set_prev_tx(inputtx)
def add_hw_info(self, tx):
# add previous tx for hw wallets, if needed and not already there
if any(
(isinstance(k, HardwareKeyStore) and k.can_sign(tx) and k.needs_prevtx())
for k in self.get_keystores()
):
for txin in tx.txinputs():
if txin.get_prev_tx() is None:
txin.set_prev_tx(self.get_input_tx(txin.outpoint.txid.get_hex()))
# add output info for hw wallets
info = {}
xpubs = self.get_master_public_keys()
for txout in tx.outputs():
if self.is_change(txout.destination):
index = self.get_address_index(txout.destination)
pubkeys = self.get_public_keys(txout.destination)
# sort xpubs using the order of pubkeys
sorted_pubkeys, sorted_xpubs = zip(*sorted(zip(pubkeys, xpubs)))
info[txout.destination] = (
index,
sorted_xpubs,
self.m if isinstance(self, MultisigWallet) else None,
self.txin_type,
)
tx.output_info = info
def sign_transaction(self, tx, password, *, use_cache=False):
"""Sign a transaction, requires password (may be None for password-less
wallets). If `use_cache` is enabled then signing will be much faster.
For transactions with N inputs and M outputs, calculating all sighashes
takes only O(N + M) with the cache, as opposed to O(N^2 + NM) without
the cache.
Warning: If you modify non-signature parts of the transaction
afterwards, do not use `use_cache`!"""
if self.is_watching_only():
return
# add input values for signing
self.add_input_values_to_tx(tx)
# hardware wallets require extra info
if any(
(isinstance(k, HardwareKeyStore) and k.can_sign(tx))
for k in self.get_keystores()
):
self.add_hw_info(tx)
# sign
for k in self.get_keystores():
try:
if k.can_sign(tx):
k.sign_transaction(tx, password, use_cache=use_cache)
except UserCancelled:
continue
+ def sign_stake(
+ self,
+ stake: StakeAndSigningData,
+ expiration_time: int,
+ master_pubkey: avalanche.primitives.PublicKey,
+ password: Optional[str],
+ ):
+ """Sign an avalanche stake. Might not be supported by all wallets."""
+ index = self.get_address_index(stake.address)
+ signature = None
+ for k in self.get_keystores():
+ try:
+ if k.supports_stake_signature():
+ signature = k.sign_stake(
+ stake.stake, index, expiration_time, master_pubkey, password
+ )
+ except UserCancelled:
+ continue
+
+ return signature
+
def get_unused_addresses(self, *, for_change=False, frozen_ok=True):
# fixme: use slots from expired requests
with self.lock:
domain = (
self.get_receiving_addresses()
if not for_change
else (self.get_change_addresses() or self.get_receiving_addresses())
)
return [
addr
for addr in domain
if not self.get_address_history(addr)
and addr not in self.receive_requests
and (frozen_ok or addr not in self.frozen_addresses)
]
def get_unused_address(self, *, for_change=False, frozen_ok=True):
addrs = self.get_unused_addresses(for_change=for_change, frozen_ok=frozen_ok)
if addrs:
return addrs[0]
def get_receiving_address(self, *, frozen_ok=True):
"""Returns a receiving address or None."""
domain = self.get_unused_addresses(frozen_ok=frozen_ok)
if not domain:
domain = [
a
for a in self.get_receiving_addresses()
if frozen_ok or a not in self.frozen_addresses
]
if domain:
return domain[0]
def get_payment_status(
self, address: Address, amount: int
) -> Tuple[bool, Optional[int], List[str]]:
"""Return (is_paid, num_confirmations, list_of_tx_hashes)
is_paid is True if the address received at least the specified amount.
"""
local_height = self.get_local_height()
received = self.get_address_unspent(address, self.get_address_history(address))
transactions = []
for txo, x in received.items():
h, v, is_cb = x
txid, n = txo.split(":")
info = self.verified_tx.get(txid)
if info:
tx_height, timestamp, pos = info
conf = max(local_height - tx_height + 1, 0)
else:
conf = 0
transactions.append((conf, v, txid))
tx_hashes = []
vsum = 0
for conf, v, tx_hash in sorted(transactions, reverse=True):
vsum += v
tx_hashes.append(tx_hash)
if vsum >= amount:
return True, conf, tx_hashes
return False, None, []
def has_payment_request(self, addr):
"""Returns True iff Address addr has any extant payment requests
(even if expired), False otherwise."""
assert isinstance(addr, Address)
return bool(self.receive_requests.get(addr))
def get_payment_request(self, addr, config):
assert isinstance(addr, Address)
r = self.receive_requests.get(addr)
if not r:
return
out = copy.copy(r)
addr_text = addr.to_ui_string()
amount_text = format_satoshis(
r["amount"]
) # fixme: this should not be localized
out["URI"] = "{}?amount={}".format(addr_text, amount_text)
status, conf, tx_hashes = self.get_request_status(addr)
out["status"] = status
out["tx_hashes"] = tx_hashes
if conf is not None:
out["confirmations"] = conf
# check if bip70 file exists
rdir = config.get("requests_dir")
if rdir:
key = out.get("id", addr.to_storage_string())
path = os.path.join(rdir, "req", key[0], key[1], key)
if os.path.exists(path):
baseurl = "file://" + rdir
rewrite = config.get("url_rewrite")
if rewrite:
baseurl = baseurl.replace(*rewrite)
out["request_url"] = os.path.join(
baseurl, "req", key[0], key[1], key, key
)
out["URI"] += "&r=" + out["request_url"]
if "index_url" not in out:
out["index_url"] = (
os.path.join(baseurl, "index.html") + "?id=" + key
)
websocket_server_announce = config.get("websocket_server_announce")
if websocket_server_announce:
out["websocket_server"] = websocket_server_announce
else:
out["websocket_server"] = config.get(
"websocket_server", "localhost"
)
websocket_port_announce = config.get("websocket_port_announce")
if websocket_port_announce:
out["websocket_port"] = websocket_port_announce
else:
out["websocket_port"] = config.get("websocket_port", 9999)
return out
def get_request_status(self, key):
r = self.receive_requests.get(key)
if r is None:
return PR_UNKNOWN
address = r["address"]
amount = r.get("amount")
timestamp = r.get("time", 0)
if timestamp and not isinstance(timestamp, int):
timestamp = 0
expiration = r.get("exp")
if expiration and not isinstance(expiration, int):
expiration = 0
conf = None
tx_hashes = []
if amount:
paid, conf, tx_hashes = self.get_payment_status(address, amount)
if not paid:
status = PR_UNPAID
elif conf == 0:
status = PR_UNCONFIRMED
else:
status = PR_PAID
if (
status == PR_UNPAID
and expiration is not None
and time.time() > timestamp + expiration
):
status = PR_EXPIRED
else:
status = PR_UNKNOWN
return status, conf, tx_hashes
def make_payment_request(
self,
addr,
amount,
message,
expiration=None,
*,
op_return=None,
op_return_raw=None,
payment_url=None,
index_url=None,
):
assert isinstance(addr, Address)
if op_return and op_return_raw:
raise ValueError(
"both op_return and op_return_raw cannot be specified as arguments to"
" make_payment_request"
)
timestamp = int(time.time())
_id = bh2u(Hash(addr.to_storage_string() + "%d" % timestamp))[0:10]
d = {
"time": timestamp,
"amount": amount,
"exp": expiration,
"address": addr,
"memo": message,
"id": _id,
}
if payment_url:
d["payment_url"] = payment_url + "/" + _id
if index_url:
d["index_url"] = index_url + "/" + _id
if op_return:
d["op_return"] = op_return
if op_return_raw:
d["op_return_raw"] = op_return_raw
return d
def serialize_request(self, r):
result = r.copy()
result["address"] = r["address"].to_storage_string()
return result
def save_payment_requests(self):
def delete_address(value):
del value["address"]
return value
requests = {
addr.to_storage_string(): delete_address(value.copy())
for addr, value in self.receive_requests.items()
}
self.storage.put("payment_requests", requests)
self.storage.write()
def sign_payment_request(self, key, alias, alias_addr, password):
req = self.receive_requests.get(key)
alias_privkey = self.export_private_key(alias_addr, password)
pr = paymentrequest.make_unsigned_request(req)
paymentrequest.sign_request_with_alias(pr, alias, alias_privkey)
req["name"] = to_string(pr.pki_data)
req["sig"] = bh2u(pr.signature)
self.receive_requests[key] = req
self.save_payment_requests()
def add_payment_request(self, req, config, set_address_label=True):
addr = req["address"]
addr_text = addr.to_storage_string()
amount = req["amount"]
message = req["memo"]
self.receive_requests[addr] = req
self.save_payment_requests()
if set_address_label:
self.set_label(addr_text, message) # should be a default label
rdir = config.get("requests_dir")
if rdir and amount is not None:
key = req.get("id", addr_text)
pr = paymentrequest.make_request(config, req)
path = os.path.join(rdir, "req", key[0], key[1], key)
if not os.path.exists(path):
try:
os.makedirs(path)
except OSError as exc:
if exc.errno != errno.EEXIST:
raise
with open(os.path.join(path, key), "wb") as f:
f.write(pr.SerializeToString())
# reload
req = self.get_payment_request(addr, config)
req["address"] = req["address"].to_ui_string()
with open(os.path.join(path, key + ".json"), "w", encoding="utf-8") as f:
f.write(json.dumps(req))
def remove_payment_request(self, addr, config, clear_address_label_if_no_tx=True):
if isinstance(addr, str):
addr = Address.from_string(addr)
if addr not in self.receive_requests:
return False
r = self.receive_requests.pop(addr)
if clear_address_label_if_no_tx and not self.get_address_history(addr):
memo = r.get("memo")
# clear it only if the user didn't overwrite it with something else
if memo and memo == self.labels.get(addr.to_storage_string()):
self.set_label(addr, None)
rdir = config.get("requests_dir")
if rdir:
key = r.get("id", addr.to_storage_string())
for s in [".json", ""]:
n = os.path.join(rdir, "req", key[0], key[1], key, key + s)
if os.path.exists(n):
os.unlink(n)
self.save_payment_requests()
return True
def get_sorted_requests(self, config):
m = (self.get_payment_request(x, config) for x in self.receive_requests.keys())
try:
def f(x):
try:
addr = x["address"]
return self.get_address_index(addr) or addr
except Exception:
return addr
return sorted(m, key=f)
except TypeError:
# See issue #1231 -- can get inhomogenous results in the above
# sorting function due to the 'or addr' possible return.
# This can happen if addresses for some reason drop out of wallet
# while, say, the history rescan is running and it can't yet find
# an address index for an address. In that case we will
# return an unsorted list to the caller.
return list(m)
def get_fingerprint(self):
raise NotImplementedError()
def can_import_privkey(self):
return False
def can_import_address(self):
return False
def can_delete_address(self):
return False
def is_multisig(self):
# Subclass MultisigWallet overrides this
return False
def is_hardware(self):
return any(isinstance(k, HardwareKeyStore) for k in self.get_keystores())
def add_address(self, address, *, for_change=False):
assert isinstance(address, Address)
# paranoia, not really necessary -- just want to maintain the invariant that
# when we modify address history below we invalidate cache.
self._addr_bal_cache.pop(address, None)
self.invalidate_address_set_cache()
if address not in self._history:
self._history[address] = []
if self.synchronizer:
self.synchronizer.add(address, for_change=for_change)
def has_password(self):
return self.has_keystore_encryption() or self.has_storage_encryption()
def can_have_keystore_encryption(self):
return self.keystore and self.keystore.may_have_password()
def get_available_storage_encryption_version(self):
"""Returns the type of storage encryption offered to the user.
A wallet file (storage) is either encrypted with this version
or is stored in plaintext.
"""
if isinstance(self.keystore, HardwareKeyStore):
return STO_EV_XPUB_PW
else:
return STO_EV_USER_PW
def has_keystore_encryption(self):
"""Returns whether encryption is enabled for the keystore.
If True, e.g. signing a transaction will require a password.
"""
if self.can_have_keystore_encryption():
return self.storage.get("use_encryption", False)
return False
def has_storage_encryption(self):
"""Returns whether encryption is enabled for the wallet file on disk."""
return self.storage.is_encrypted()
@classmethod
def may_have_password(cls):
return True
def check_password(self, password):
if self.has_keystore_encryption():
self.keystore.check_password(password)
self.storage.check_password(password)
def update_password(self, old_pw, new_pw, encrypt_storage=False):
if old_pw is None and self.has_password():
raise InvalidPassword()
self.check_password(old_pw)
if encrypt_storage:
enc_version = self.get_available_storage_encryption_version()
else:
enc_version = STO_EV_PLAINTEXT
self.storage.set_password(new_pw, enc_version)
# note: Encrypting storage with a hw device is currently only
# allowed for non-multisig wallets. Further,
# Hardware_KeyStore.may_have_password() == False.
# If these were not the case,
# extra care would need to be taken when encrypting keystores.
self._update_password_for_keystore(old_pw, new_pw)
encrypt_keystore = self.can_have_keystore_encryption()
self.storage.set_keystore_encryption(bool(new_pw) and encrypt_keystore)
self.storage.write()
def sign_message(self, address, message, password, sigtype=SignatureType.ECASH):
index = self.get_address_index(address)
return self.keystore.sign_message(index, message, password, sigtype)
def decrypt_message(self, pubkey, message, password):
addr = self.pubkeys_to_address(pubkey)
index = self.get_address_index(addr)
return self.keystore.decrypt_message(index, message, password)
def rebuild_history(self):
"""This is an advanced function for use in the GUI when the user
wants to resynch the whole wallet from scratch, preserving labels
and contacts."""
if not self.network or not self.network.is_connected():
raise RuntimeError(
"Refusing to rebuild wallet without a valid server connection!"
)
if not self.synchronizer or not self.verifier:
raise RuntimeError("Refusing to rebuild a stopped wallet!")
network = self.network
self.synchronizer.clear_retired_change_addrs()
self.stop_threads()
do_addr_save = False
with self.lock:
self.transactions.clear()
self.unverified_tx.clear()
self.verified_tx.clear()
self.clear_history()
if isinstance(self, StandardWallet):
# reset the address list to default too, just in case. New synchronizer will pick up the addresses again.
self.receiving_addresses, self.change_addresses = (
self.receiving_addresses[: self.gap_limit],
self.change_addresses[: self.gap_limit_for_change],
)
do_addr_save = True
self.change_reserved.clear()
self.change_reserved_default.clear()
self.change_unreserved.clear()
self.change_reserved_tmp.clear()
self.invalidate_address_set_cache()
if do_addr_save:
self.save_addresses()
self.save_transactions()
self.save_change_reservations()
self.save_verified_tx()
self.storage.write()
self.start_threads(network)
self.network.trigger_callback("wallet_updated", self)
def is_schnorr_possible(self, reason: Optional[list] = None) -> bool:
"""Returns True if this wallet type is compatible.
`reason` is an optional list where you would like a translated string
of why Schnorr isn't possible placed (on False return)."""
ok = bool(not self.is_multisig() and not self.is_hardware())
if not ok and reason is not None:
reason.insert(0, _("Schnorr signatures are disabled for this wallet type."))
return ok
+ def is_stake_signature_possible(self) -> bool:
+ if self.is_watching_only():
+ return False
+
+ if not self.is_hardware() and not self.is_schnorr_possible():
+ return False
+
+ if self.is_hardware() and not any(
+ k.supports_stake_signature() for k in self.get_keystores()
+ ):
+ return False
+
+ return True
+
def is_schnorr_enabled(self) -> bool:
"""Returns whether schnorr is enabled AND possible for this wallet.
Schnorr is enabled per-wallet."""
if not self.is_schnorr_possible():
# Short-circuit out of here -- it's not even possible with this
# wallet type.
return False
ss_cfg = self.storage.get("sign_schnorr", None)
if ss_cfg is None:
# Schnorr was not set in config; figure out intelligent defaults.
# Note for watching-only we default to off if unspecified regardless,
# to not break compatibility with air-gapped signing systems that have
# older EC installed on the signing system. This is to avoid underpaying
# fees if signing system doesn't use Schnorr. We can turn on default
# Schnorr on watching-only sometime in the future after enough
# time has passed that air-gapped systems are unlikely to not
# have Schnorr enabled by default.
# TO DO: Finish refactor of txn serialized format to handle this
# case better!
ss_cfg = 2 if not self.is_watching_only() else 0
return bool(ss_cfg)
def set_schnorr_enabled(self, b: bool):
"""Enable schnorr for this wallet. Note that if Schnorr is not possible,
(due to missing libs or invalid wallet type) is_schnorr_enabled() will
still return False after calling this function with a True argument."""
# Note: we will have '1' at some point in the future which will mean:
# 'ask me per tx', so for now True -> 2.
self.storage.put("sign_schnorr", 2 if b else 0)
def is_watching_only(self):
raise NotImplementedError()
def get_history_values(self) -> ValuesView[Tuple[str, int]]:
"""Returns the an iterable (view) of all the List[tx_hash, height] pairs for
each address in the wallet.
"""
return self._history.values()
def get_history_items(self) -> ItemsView[Address, List[Tuple[str, int]]]:
return self._history.items()
DEFAULT_CHANGE_ADDR_SUBS_LIMIT = 1000
@property
def limit_change_addr_subs(self) -> int:
"""Returns positive nonzero if old change subs limiting is set in wallet storage, otherwise returns 0"""
val = int(
self.storage.get(
"limit_change_addr_subs", self.DEFAULT_CHANGE_ADDR_SUBS_LIMIT
)
)
if val >= 0:
return val
return self.DEFAULT_CHANGE_ADDR_SUBS_LIMIT
@limit_change_addr_subs.setter
def limit_change_addr_subs(self, val: int):
val = max(val or 0, 0) # Guard against bool, None, or negative
self.storage.put("limit_change_addr_subs", int(val))
def is_retired_change_addr(self, addr: Address) -> bool:
"""Returns True if the address in question is in the "retired change address" set (set maintained by
the synchronizer). If the network is not started (offline mode), will always return False.
"""
assert isinstance(addr, Address)
if not self.synchronizer:
return False
sh = addr.to_scripthash_hex()
return sh in self.synchronizer.change_scripthashes_that_are_retired
class SimpleWallet(AbstractWallet):
# wallet with a single keystore
def get_keystore(self):
return self.keystore
def get_keystores(self):
return [self.keystore]
def is_watching_only(self):
return self.keystore.is_watching_only()
def _update_password_for_keystore(self, old_pw, new_pw):
if self.keystore and self.keystore.may_have_password():
self.keystore.update_password(old_pw, new_pw)
self.save_keystore()
def save_keystore(self):
self.storage.put("keystore", self.keystore.dump())
class ImportedWalletBase(SimpleWallet):
txin_type = "p2pkh"
def get_txin_type(self, address):
return self.txin_type
def can_delete_address(self):
return len(self.get_addresses()) > 1 # Cannot delete the last address
def has_seed(self):
return False
def is_deterministic(self):
return False
def is_change(self, address):
return False
def get_master_public_keys(self):
return []
def is_beyond_limit(self, address, is_change):
return False
def get_fingerprint(self):
return ""
def get_receiving_addresses(self):
return self.get_addresses()
def delete_address(self, address):
assert isinstance(address, Address)
all_addrs = self.get_addresses()
if len(all_addrs) <= 1 or address not in all_addrs:
return
del all_addrs
transactions_to_remove = set() # only referred to by this address
transactions_new = set() # txs that are not only referred to by address
with self.lock:
for addr, details in self._history.items():
if addr == address:
for tx_hash, height in details:
transactions_to_remove.add(tx_hash)
self.tx_addr_hist[tx_hash].discard(address)
if not self.tx_addr_hist.get(tx_hash):
self.tx_addr_hist.pop(tx_hash, None)
else:
for tx_hash, height in details:
transactions_new.add(tx_hash)
transactions_to_remove -= transactions_new
self._history.pop(address, None)
for tx_hash in transactions_to_remove:
self.remove_transaction(tx_hash)
self.tx_fees.pop(tx_hash, None)
self.verified_tx.pop(tx_hash, None)
self.unverified_tx.pop(tx_hash, None)
self.transactions.pop(tx_hash, None)
# not strictly necessary, above calls also have this side-effect.
# but here to be safe. :)
self._addr_bal_cache.pop(address, None)
if self.verifier:
# TX is now gone. Toss its SPV proof in case we have it
# in memory. This allows user to re-add PK again and it
# will avoid the situation where the UI says "not verified"
# erroneously!
self.verifier.remove_spv_proof_for_tx(tx_hash)
# FIXME: what about pruned_txo?
self.storage.put("verified_tx3", self.verified_tx)
self.save_transactions()
self.set_label(address, None)
self.remove_payment_request(address, {})
self.set_frozen_state([address], False)
self.delete_address_derived(address)
self.save_addresses()
class ImportedAddressWallet(ImportedWalletBase):
# Watch-only wallet of imported addresses
wallet_type = "imported_addr"
def __init__(self, storage):
self._sorted = None
super().__init__(storage)
@classmethod
def from_text(cls, storage, text):
wallet = cls(storage)
for address in text.split():
wallet.import_address(Address.from_string(address))
return wallet
def is_watching_only(self):
return True
def get_keystores(self):
return []
def can_import_privkey(self):
return False
def load_keystore(self):
self.keystore = None
def save_keystore(self):
pass
def load_addresses(self):
addresses = self.storage.get("addresses", [])
self.addresses = [Address.from_string(addr) for addr in addresses]
def save_addresses(self):
self.storage.put(
"addresses", [addr.to_storage_string() for addr in self.addresses]
)
self.storage.write()
def can_change_password(self):
return False
def can_import_address(self):
return True
def get_addresses(self):
if not self._sorted:
self._sorted = sorted(self.addresses, key=lambda addr: addr.to_ui_string())
return self._sorted
def import_address(self, address):
assert isinstance(address, Address)
if address in self.addresses:
return False
self.addresses.append(address)
self.save_addresses()
self.storage.write()
self.add_address(address)
self._sorted = None
return True
def delete_address_derived(self, address):
self.addresses.remove(address)
self._sorted.remove(address)
def add_input_sig_info(self, txin, address):
x_pubkey = "fd" + address.to_script_hex()
txin["x_pubkeys"] = [x_pubkey]
txin["signatures"] = [None]
class ImportedPrivkeyWallet(ImportedWalletBase):
# wallet made of imported private keys
wallet_type = "imported_privkey"
def __init__(self, storage):
AbstractWallet.__init__(self, storage)
@classmethod
def from_text(cls, storage, text, password=None):
wallet = cls(storage)
storage.put("use_encryption", bool(password))
for privkey in text.split():
wallet.import_private_key(privkey, password)
return wallet
def is_watching_only(self):
return False
def get_keystores(self):
return [self.keystore]
def can_import_privkey(self):
return True
def load_keystore(self):
if self.storage.get("keystore"):
self.keystore = load_keystore(self.storage, "keystore")
else:
self.keystore = ImportedKeyStore({})
def save_keystore(self):
self.storage.put("keystore", self.keystore.dump())
def load_addresses(self):
pass
def save_addresses(self):
pass
def can_change_password(self):
return True
def can_import_address(self):
return False
def get_addresses(self):
return self.keystore.get_addresses()
def delete_address_derived(self, address):
self.keystore.remove_address(address)
self.save_keystore()
def get_address_index(self, address):
return self.get_public_key(address)
def get_public_key(self, address):
return self.keystore.address_to_pubkey(address)
def import_private_key(self, sec, pw):
pubkey = self.keystore.import_privkey(sec, pw)
self.save_keystore()
self.storage.write()
self.add_address(pubkey.address)
return pubkey.address.to_ui_string()
def export_private_key(self, address, password):
"""Returned in WIF format."""
pubkey = self.keystore.address_to_pubkey(address)
return self.keystore.export_private_key(pubkey, password)
def add_input_sig_info(self, txin, address):
assert txin["type"] == "p2pkh"
pubkey = self.keystore.address_to_pubkey(address)
txin["num_sig"] = 1
txin["x_pubkeys"] = [pubkey.to_ui_string()]
txin["signatures"] = [None]
def pubkeys_to_address(self, pubkey):
pubkey = PublicKey.from_string(pubkey)
if pubkey in self.keystore.keypairs:
return pubkey.address
class DeterministicWallet(AbstractWallet):
def __init__(self, storage):
self.keystore: Optional[DeterministicKeyStore] = None
AbstractWallet.__init__(self, storage)
self.gap_limit = storage.get(StorageKeys.GAP_LIMIT)
def has_seed(self):
return self.keystore.has_seed()
def get_receiving_addresses(self):
return self.receiving_addresses
def get_change_addresses(self):
return self.change_addresses
def get_seed(self, password):
return self.keystore.get_seed(password)
@abstractmethod
def get_public_keys(self, address: Address) -> List[str]:
"""Get a list of public keys (as hexadecimal strings)"""
pass
def change_gap_limit(self, value):
"""This method is not called in the code, it is kept for console use"""
with self.lock:
if value >= self.gap_limit:
self.gap_limit = value
self.storage.put(StorageKeys.GAP_LIMIT, self.gap_limit)
return True
elif value >= self.min_acceptable_gap():
addresses = self.get_receiving_addresses()
k = self.num_unused_trailing_addresses(addresses)
n = len(addresses) - k + value
self.receiving_addresses = self.receiving_addresses[0:n]
self.gap_limit = value
self.storage.put(StorageKeys.GAP_LIMIT, self.gap_limit)
self.save_addresses()
return True
else:
return False
def num_unused_trailing_addresses(self, addresses):
"""This method isn't called anywhere. Perhaps it is here for console use.
Can't be sure. -Calin"""
with self.lock:
k = 0
for addr in reversed(addresses):
if addr in self._history:
break
k = k + 1
return k
def min_acceptable_gap(self):
"""Caller needs to hold self.lock otherwise bad things may happen."""
# fixme: this assumes wallet is synchronized
n = 0
nmax = 0
addresses = self.get_receiving_addresses()
k = self.num_unused_trailing_addresses(addresses)
for a in addresses[0:-k]:
if a in self._history:
n = 0
else:
n += 1
if n > nmax:
nmax = n
return nmax + 1
def create_new_address(self, for_change=False, save=True):
for_change = bool(for_change)
with self.lock:
addr_list = (
self.change_addresses if for_change else self.receiving_addresses
)
n = len(addr_list)
x = self.derive_pubkeys(for_change, n)
address = self.pubkeys_to_address(x)
addr_list.append(address)
if save:
self.save_addresses()
self.add_address(address, for_change=for_change)
return address
def synchronize_sequence(self, for_change):
limit = self.gap_limit_for_change if for_change else self.gap_limit
while True:
addresses = (
self.get_change_addresses()
if for_change
else self.get_receiving_addresses()
)
if len(addresses) < limit:
self.create_new_address(for_change, save=False)
continue
if all(not self.address_is_old(a) for a in addresses[-limit:]):
break
else:
self.create_new_address(for_change, save=False)
def synchronize(self):
with self.lock:
self.synchronize_sequence(False)
self.synchronize_sequence(True)
def is_beyond_limit(self, address, is_change):
with self.lock:
if is_change:
addr_list = self.get_change_addresses()
limit = self.gap_limit_for_change
else:
addr_list = self.get_receiving_addresses()
limit = self.gap_limit
idx = addr_list.index(address)
if idx < limit:
return False
for addr in addr_list[-limit:]:
if addr in self._history:
return False
return True
def get_master_public_keys(self):
return [self.get_master_public_key()]
def get_fingerprint(self):
return self.get_master_public_key()
def get_txin_type(self, address):
return self.txin_type
def export_private_key(self, address: Address, password) -> str:
"""Export extended WIF format for a given address in this wallet."""
if self.is_watching_only():
raise RuntimeError("Cannot export private key for watching-only wallet")
index = self.get_address_index(address)
return self.export_private_key_for_index(index, password)
def export_private_key_for_index(self, index, password) -> str:
"""Export a wif private key for a given bip 44 index.
Index is the last two elements of the bip 44 path (change, address_index).
"""
pk, compressed = self.keystore.get_private_key(index, password)
return bitcoin.serialize_privkey(
pk, compressed, bitcoin.ScriptType[self.txin_type]
)
def get_auxiliary_pubkey_index(self, key: PublicKey, password) -> Optional[int]:
"""Return an index for an auxiliary public key. These are the keys on the
BIP 44 path that uses change index = 2, for keys that are guaranteed to not
be used for addresses. Return None if the public key is not mine or too old
to be detected wrt to the gap limit.
"""
max_index = self.storage.get(StorageKeys.AUXILIARY_KEY_INDEX)
gap_limit = self.storage.get(StorageKeys.GAP_LIMIT)
for index in range(max_index, max(-1, max_index - gap_limit), -1):
wif = self.export_private_key_for_index((2, index), password)
if PublicKey.from_WIF_privkey(wif) == key:
return index
return None
class SimpleDeterministicWallet(SimpleWallet, DeterministicWallet):
"""Deterministic Wallet with a single pubkey per address"""
def __init__(self, storage):
DeterministicWallet.__init__(self, storage)
def get_public_key(self, address):
sequence = self.get_address_index(address)
pubkey = self.get_pubkey(*sequence)
return pubkey
def load_keystore(self):
self.keystore = load_keystore(self.storage, "keystore")
try:
xtype = xpub_type(self.keystore.xpub)
except Exception:
xtype = "standard"
self.txin_type = "p2pkh" if xtype == "standard" else xtype
def get_pubkey(self, c, i) -> str:
return self.derive_pubkeys(c, i)
def get_public_keys(self, address):
return [self.get_public_key(address)]
def add_input_sig_info(self, txin, address):
derivation = self.get_address_index(address)
x_pubkey = self.keystore.get_xpubkey(*derivation)
txin["x_pubkeys"] = [x_pubkey.hex()]
txin["signatures"] = [None]
txin["num_sig"] = 1
def get_master_public_key(self):
return self.keystore.get_master_public_key()
def derive_pubkeys(self, c, i) -> str:
return self.keystore.derive_pubkey(c, i).hex()
class StandardWallet(SimpleDeterministicWallet):
wallet_type = "standard"
def pubkeys_to_address(self, pubkey):
return Address.from_pubkey(pubkey)
class MultisigWallet(DeterministicWallet):
# generic m of n
gap_limit = 20
def __init__(self, storage):
self.wallet_type = storage.get("wallet_type")
self.m, self.n = multisig_type(self.wallet_type)
DeterministicWallet.__init__(self, storage)
def get_public_keys(self, address):
sequence = self.get_address_index(address)
return self.get_pubkeys(*sequence)
def get_pubkeys(self, c, i):
return self.derive_pubkeys(c, i)
def pubkeys_to_address(self, pubkeys):
pubkeys = [bytes.fromhex(pubkey) for pubkey in pubkeys]
redeem_script = self.pubkeys_to_redeem_script(pubkeys)
return Address.from_multisig_script(redeem_script)
def pubkeys_to_redeem_script(self, pubkeys):
return Script.multisig_script(self.m, sorted(pubkeys))
def derive_pubkeys(self, c, i):
return [k.derive_pubkey(c, i).hex() for k in self.get_keystores()]
def load_keystore(self):
self.keystores = {}
for i in range(self.n):
name = "x%d/" % (i + 1)
self.keystores[name] = load_keystore(self.storage, name)
self.keystore = self.keystores["x1/"]
xtype = xpub_type(self.keystore.xpub)
self.txin_type = "p2sh" if xtype == "standard" else xtype
def save_keystore(self):
for name, k in self.keystores.items():
self.storage.put(name, k.dump())
def get_keystore(self):
return self.keystores.get("x1/")
def get_keystores(self):
return [self.keystores[i] for i in sorted(self.keystores.keys())]
def can_have_keystore_encryption(self):
return any(k.may_have_password() for k in self.get_keystores())
def _update_password_for_keystore(self, old_pw, new_pw):
for name, keystore_ in self.keystores.items():
if keystore_.may_have_password():
keystore_.update_password(old_pw, new_pw)
self.storage.put(name, keystore_.dump())
def check_password(self, password):
for name, keystore_ in self.keystores.items():
if keystore_.may_have_password():
keystore_.check_password(password)
self.storage.check_password(password)
def get_available_storage_encryption_version(self):
# multisig wallets are not offered hw device encryption
return STO_EV_USER_PW
def has_seed(self):
return self.keystore.has_seed()
def is_watching_only(self):
return not any(not k.is_watching_only() for k in self.get_keystores())
def get_master_public_key(self):
return self.keystore.get_master_public_key()
def get_master_public_keys(self):
return [k.get_master_public_key() for k in self.get_keystores()]
def get_fingerprint(self):
return "".join(sorted(self.get_master_public_keys()))
def add_input_sig_info(self, txin, address):
# x_pubkeys are not sorted here because it would be too slow
# They must be sorted by the code in charge of signing or serializing the
# transaction.
derivation = self.get_address_index(address)
txin["x_pubkeys"] = [
k.get_xpubkey(*derivation).hex() for k in self.get_keystores()
]
txin["pubkeys"] = None
# we need n place holders
txin["signatures"] = [None] * self.n
txin["num_sig"] = self.m
def is_multisig(self):
return True
wallet_types = ["standard", "multisig", "imported"]
def register_wallet_type(category):
wallet_types.append(category)
wallet_constructors = {
"standard": StandardWallet,
"old": StandardWallet,
"xpub": StandardWallet,
"imported_privkey": ImportedPrivkeyWallet,
"imported_addr": ImportedAddressWallet,
}
def register_constructor(wallet_type, constructor):
wallet_constructors[wallet_type] = constructor
# former WalletFactory
class Wallet:
"""The main wallet "entry point".
This class is actually a factory that will return a wallet of the correct
type when passed a WalletStorage instance."""
def __new__(self, storage) -> AbstractWallet:
wallet_type = storage.get("wallet_type")
# check here if I need to load a plugin
if wallet_type in plugin_loaders:
plugin_loaders[wallet_type]()
WalletClass = Wallet.wallet_class(wallet_type)
wallet = WalletClass(storage)
# Convert hardware wallets restored with older versions of
# Electrum to BIP44 wallets. A hardware wallet does not have
# a seed and plugins do not need to handle having one.
rwc = getattr(wallet, "restore_wallet_class", None)
if rwc and storage.get("seed", ""):
storage.print_error("converting wallet type to " + rwc.wallet_type)
storage.put("wallet_type", rwc.wallet_type)
wallet = rwc(storage)
return wallet
@staticmethod
def wallet_class(wallet_type):
if multisig_type(wallet_type):
return MultisigWallet
if wallet_type in wallet_constructors:
return wallet_constructors[wallet_type]
raise WalletFileException("Unknown wallet type: " + str(wallet_type))
def create_new_wallet(
*, path, passphrase=None, password=None, encrypt_file=True, seed_type=None
) -> dict:
"""Create a new wallet"""
storage = WalletStorage(path)
if storage.file_exists():
raise Exception("Remove the existing wallet first!")
if seed_type == "electrum":
seed = mnemo.MnemonicElectrum("en").make_seed()
elif seed_type in [None, "bip39"]:
seed = mnemo.make_bip39_words("english")
seed_type = "bip39"
else:
raise keystore.InvalidSeed(
f"Seed type {seed_type} not supported for new wallet creation"
)
k = keystore.from_seed(seed, passphrase, seed_type=seed_type)
storage.put("keystore", k.dump())
storage.put("wallet_type", "standard")
storage.put("seed_type", seed_type)
wallet = Wallet(storage)
wallet.update_password(old_pw=None, new_pw=password, encrypt_storage=encrypt_file)
wallet.synchronize()
msg = (
"Please keep your seed in a safe place; if you lose it, you will not be able to"
" restore your wallet."
)
wallet.storage.write()
return {"seed": seed, "wallet": wallet, "msg": msg}
def restore_wallet_from_text(
text,
*,
path,
config,
passphrase=None,
password=None,
encrypt_file=True,
gap_limit=None,
) -> dict:
"""Restore a wallet from text. Text can be a seed phrase, a master
public key, a master private key, a list of bitcoin addresses
or bitcoin private keys."""
storage = WalletStorage(path)
if storage.file_exists():
raise Exception("Remove the existing wallet first!")
text = text.strip()
if keystore.is_address_list(text):
wallet = ImportedAddressWallet.from_text(storage, text)
wallet.save_addresses()
elif keystore.is_private_key_list(
text,
):
k = keystore.ImportedKeyStore({})
storage.put("keystore", k.dump())
wallet = ImportedPrivkeyWallet.from_text(storage, text, password)
else:
if keystore.is_master_key(text):
k = keystore.from_master_key(text)
elif mnemo.is_seed(text):
k = keystore.from_seed(
text, passphrase
) # auto-detects seed type, preference order: old, electrum, bip39
else:
raise Exception("Seed or key not recognized")
storage.put("keystore", k.dump())
storage.put("wallet_type", "standard")
seed_type = getattr(k, "seed_type", None)
if seed_type:
storage.put("seed_type", seed_type) # Save, just in case
if gap_limit is not None:
storage.put(StorageKeys.GAP_LIMIT, gap_limit)
wallet = Wallet(storage)
wallet.update_password(old_pw=None, new_pw=password, encrypt_storage=encrypt_file)
wallet.synchronize()
msg = (
"This wallet was restored offline. It may contain more addresses than"
" displayed. Start a daemon and use load_wallet to sync its history."
)
wallet.storage.write()
return {"wallet": wallet, "msg": msg}
diff --git a/electrum/electrumabc_gui/qt/avalanche/proof_editor.py b/electrum/electrumabc_gui/qt/avalanche/proof_editor.py
index 7a7a26760..11eafbafe 100644
--- a/electrum/electrumabc_gui/qt/avalanche/proof_editor.py
+++ b/electrum/electrumabc_gui/qt/avalanche/proof_editor.py
@@ -1,1023 +1,1025 @@
from __future__ import annotations
import json
import struct
-from dataclasses import dataclass
from typing import List, Optional, Union
from PyQt5 import QtCore, QtGui, QtWidgets
from electrumabc.address import Address, AddressError
from electrumabc.avalanche.primitives import Key, PublicKey
-from electrumabc.avalanche.proof import Proof, ProofBuilder, SignedStake, Stake
+from electrumabc.avalanche.proof import (
+ Proof,
+ ProofBuilder,
+ SignedStake,
+ Stake,
+ StakeAndSigningData,
+)
from electrumabc.bitcoin import is_private_key
from electrumabc.constants import PROOF_DUST_THRESHOLD, STAKE_UTXO_CONFIRMATIONS
from electrumabc.i18n import _
from electrumabc.keystore import MAXIMUM_INDEX_DERIVATION_PATH
from electrumabc.serialize import DeserializationError, compact_size, serialize_blob
from electrumabc.storage import StorageKeys
from electrumabc.transaction import OutPoint, get_address_from_output_script
from electrumabc.uint256 import UInt256
-from electrumabc.util import format_satoshis
+from electrumabc.util import UserCancelled, format_satoshis
from electrumabc.wallet import AddressNotFoundError, DeterministicWallet
from .delegation_editor import AvaDelegationDialog
from .util import ButtonsLineEdit, CachedWalletPasswordWidget, get_auxiliary_privkey
-@dataclass
-class StakeAndKey:
- """Class storing a stake waiting to be signed (waiting for the stake commitment)"""
-
- stake: Stake
- key: Key
-
-
class TextColor:
NEUTRAL = "black"
GOOD_SIG = "darkgreen"
BAD_SIG = "darkred"
GOOD_STAKE_SIG = "blue"
BAD_STAKE_SIG = "darkmagenta"
def colored_text(text: str, color: str) -> str:
return f"<b><font color='{color}'>{text}</font></b>"
def proof_to_rich_text(proof: Proof) -> str:
"""
Return a proof hex as a colored html string. Colors are used to indicate the
validity of stake signatures and of the master signature.
"""
p = struct.pack("<Qq", proof.sequence, proof.expiration_time)
p += proof.master_pub.serialize()
p += compact_size(len(proof.signed_stakes))
rich_text = colored_text(p.hex(), TextColor.NEUTRAL)
for ss in proof.signed_stakes:
rich_text += colored_text(ss.stake.to_hex(), TextColor.NEUTRAL)
if ss.verify_signature(proof.stake_commitment):
rich_text += colored_text(ss.sig.hex(), TextColor.GOOD_STAKE_SIG)
else:
rich_text += colored_text(ss.sig.hex(), TextColor.BAD_STAKE_SIG)
rich_text += colored_text(
serialize_blob(proof.payout_script_pubkey).hex(), TextColor.NEUTRAL
)
if proof.verify_master_signature():
return rich_text + colored_text(proof.signature.hex(), TextColor.GOOD_SIG)
return rich_text + colored_text(proof.signature.hex(), TextColor.BAD_SIG)
class StakesWidget(QtWidgets.QTableWidget):
"""A table widget to display basic info about UTXOs, color coded to highlight
immature stakes or stakes below the dust threshold.
"""
total_amount_changed = QtCore.pyqtSignal("quint64")
"""Emit total stake amount in sats."""
def __init__(self, blockchain__height: int):
super().__init__()
self.setColumnCount(5)
self.setHorizontalHeaderLabels(
["txid", "vout", "amount (XEC)", "block height", ""]
)
self.verticalHeader().setVisible(False)
self.setSelectionMode(QtWidgets.QTableWidget.NoSelection)
# This is a simple global way to make the table read-only, without having to
# set flags on each individual item.
self.setEditTriggers(QtWidgets.QTableWidget.NoEditTriggers)
self.horizontalHeader().setSectionResizeMode(0, QtWidgets.QHeaderView.Stretch)
self.setColumnWidth(4, 50)
# select whole rows, with Ctrl and Shift key for selecting multiple rows
self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
self.customContextMenuRequested.connect(self.create_menu)
- self.stakes: List[Union[SignedStake, StakeAndKey]] = []
+ self.stakes: List[Union[SignedStake, StakeAndSigningData]] = []
# We assume that the tip height is not going to change much during the lifetime
# of this widget, so we don't have to watch for new blocks and update the
# maturity statuses.
self.blockchain_height = blockchain__height
self._red_cross_icon = QtGui.QIcon(":icons/red_cross.svg")
def create_menu(self, position):
menu = QtWidgets.QMenu()
selected_rows = [index.row() for index in self.selectionModel().selectedRows()]
if not selected_rows:
return
# Sort in descending order so we can simply delete the stakes one by one
# by index from the self.stakes list
selected_rows.sort(reverse=True)
def remove_coins():
ret = QtWidgets.QMessageBox.question(
self,
_("Confirm coin deletion"),
_("Are you sure you want to remove {} coins").format(
len(selected_rows)
),
)
if ret != QtWidgets.QMessageBox.Yes:
return
for idx in selected_rows:
self.remove_stake_by_row_index(idx)
menu.addAction(
_("Remove coins"),
remove_coins,
)
menu.exec_(self.viewport().mapToGlobal(position))
def delete_this_line(self):
# This method must be triggered by a signal emitted by a widget in a cell
# of this table.
row = self.indexAt(self.sender().pos()).row()
self.remove_stake_by_row_index(row)
def remove_stake_by_row_index(self, row_index: int):
self.removeRow(row_index)
del self.stakes[row_index]
self.update_total_amount()
def update_total_amount(self):
total_amount_sats = 0
for s in self.stakes:
total_amount_sats += s.stake.amount
self.total_amount_changed.emit(total_amount_sats)
def clear(self):
self.stakes.clear()
self.clearContents()
- def add_stakes(self, stakes: List[Union[SignedStake, StakeAndKey]]):
+ def add_stakes(self, stakes: List[Union[SignedStake, StakeAndSigningData]]):
previous_utxo_count = len(self.stakes)
self.stakes += stakes
self.setRowCount(len(self.stakes))
for i, ss in enumerate(stakes):
stake = ss.stake
height = stake.height
row_index = previous_utxo_count + i
txid_item = QtWidgets.QTableWidgetItem(stake.utxo.txid.get_hex())
self.setItem(row_index, 0, txid_item)
vout_item = QtWidgets.QTableWidgetItem(str(stake.utxo.n))
self.setItem(row_index, 1, vout_item)
amount_item = QtWidgets.QTableWidgetItem(
format_satoshis(stake.amount, num_zeros=2)
)
amount_item.setTextAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter)
if stake.amount < PROOF_DUST_THRESHOLD:
amount_item.setForeground(QtGui.QColor("red"))
amount_item.setToolTip(
_(
"The minimum threshold for a coin in an avalanche proof is "
f"{format_satoshis(PROOF_DUST_THRESHOLD)} XEC."
)
)
self.setItem(row_index, 2, amount_item)
height_item = QtWidgets.QTableWidgetItem(str(height))
utxo_validity_height = height + STAKE_UTXO_CONFIRMATIONS
if utxo_validity_height > self.blockchain_height:
height_item.setForeground(QtGui.QColor("orange"))
height_item.setToolTip(
_(
f"UTXOs with less than {STAKE_UTXO_CONFIRMATIONS} "
"confirmations cannot be used as stake proofs."
)
+ f"\nCurrent known block height is {self.blockchain_height}.\n"
f"Your proof will be valid after block {utxo_validity_height}."
)
self.setItem(row_index, 3, height_item)
del_button = QtWidgets.QPushButton()
del_button.setIcon(self._red_cross_icon)
del_button.clicked.connect(self.delete_this_line)
self.setCellWidget(row_index, 4, del_button)
self.update_total_amount()
class AvaProofEditor(CachedWalletPasswordWidget):
def __init__(
self,
wallet: DeterministicWallet,
receive_address: Optional[Address] = None,
parent: Optional[QtWidgets.QWidget] = None,
):
CachedWalletPasswordWidget.__init__(self, wallet, parent=parent)
# This is enough width to show a whole compressed pubkey.
self.setMinimumWidth(750)
# Enough height to show the entire proof without scrolling.
self.setMinimumHeight(680)
self.receive_address = receive_address
self.wallet = wallet
layout = QtWidgets.QVBoxLayout()
self.setLayout(layout)
layout.addWidget(QtWidgets.QLabel("Proof Sequence"))
self.sequence_sb = QtWidgets.QSpinBox()
self.sequence_sb.setMinimum(0)
layout.addWidget(self.sequence_sb)
layout.addSpacing(10)
expiration_layout = QtWidgets.QHBoxLayout()
layout.addLayout(expiration_layout)
self.expiration_checkbox = QtWidgets.QCheckBox("Enable proof expiration")
self.expiration_checkbox.setChecked(True)
expiration_layout.addWidget(self.expiration_checkbox)
expiration_date_sublayout = QtWidgets.QVBoxLayout()
expiration_layout.addLayout(expiration_date_sublayout)
expiration_date_sublayout.addWidget(QtWidgets.QLabel("Expiration date"))
self.calendar = QtWidgets.QDateTimeEdit()
self.calendar.setToolTip("Date and time at which the proof will expire")
expiration_date_sublayout.addWidget(self.calendar)
expiration_timestamp_sublayout = QtWidgets.QVBoxLayout()
expiration_layout.addLayout(expiration_timestamp_sublayout)
expiration_timestamp_sublayout.addWidget(
QtWidgets.QLabel("Expiration POSIX timestamp")
)
# Use a QDoubleSpinbox with precision set to 0 decimals, because
# QSpinBox is limited to the int32 range (January 19, 2038)
self.timestamp_widget = QtWidgets.QDoubleSpinBox()
self.timestamp_widget.setDecimals(0)
# date range: genesis block to Wed Jun 09 3554 16:53:20 GMT
self.timestamp_widget.setRange(1231006505, 50**10)
self.timestamp_widget.setSingleStep(86400)
self.timestamp_widget.setToolTip(
"POSIX time, seconds since 1970-01-01T00:00:00"
)
expiration_timestamp_sublayout.addWidget(self.timestamp_widget)
layout.addSpacing(10)
layout.addWidget(QtWidgets.QLabel("Avalanche Master Private Key (WIF)"))
self.master_key_edit = ButtonsLineEdit()
self.master_key_edit.addCopyButton()
self.master_key_edit.setToolTip(
"Private key that controls the proof. This is the key that signs the "
"delegation and authenticates the avalanche votes.\n\n"
"This key is avalanche specific, unrelated to your coins and cannot be "
"used to spend them. Never reuse a key, always generate a fresh new one."
)
layout.addWidget(self.master_key_edit)
layout.addSpacing(10)
layout.addWidget(QtWidgets.QLabel("Avalanche Master Public Key"))
self.master_pubkey_view = ButtonsLineEdit()
self.master_pubkey_view.addCopyButton()
self.master_pubkey_view.setReadOnly(True)
# setReadOnly does not change the style of the widget to indicate it is not
# editable. setEnabled(False) would prevent selecting and copying the key.
# Manually change the background color.
self.master_pubkey_view.setStyleSheet(
"QLineEdit {background-color: lightGray;}"
)
self.master_pubkey_view.setToolTip("Computed from Master private key")
layout.addWidget(self.master_pubkey_view)
layout.addSpacing(10)
layout.addWidget(QtWidgets.QLabel("Payout Address"))
self.payout_addr_edit = QtWidgets.QLineEdit()
self.payout_addr_edit.setToolTip("Address for receiving staking rewards")
layout.addWidget(self.payout_addr_edit)
layout.addSpacing(10)
self.utxos_wigdet = StakesWidget(self.wallet.get_local_height())
layout.addWidget(self.utxos_wigdet)
self.total_amount_label = QtWidgets.QLabel("Total amount:")
layout.addWidget(self.total_amount_label)
stakes_button_layout = QtWidgets.QHBoxLayout()
layout.addLayout(stakes_button_layout)
self.add_coins_from_file_button = QtWidgets.QPushButton("Add coins from file")
stakes_button_layout.addWidget(self.add_coins_from_file_button)
self.add_coins_from_wallet_button = QtWidgets.QPushButton(
"Add coins from wallet"
)
stakes_button_layout.addWidget(self.add_coins_from_wallet_button)
self.merge_stakes_button = QtWidgets.QPushButton("Merge stakes from proof")
self.merge_stakes_button.setToolTip(
"Add stakes from an existing proof. The proof master key and expiration "
"time must exactly match when merging proofs, or else the stake signatures "
"will be invalid."
)
stakes_button_layout.addWidget(self.merge_stakes_button)
self.generate_button = QtWidgets.QPushButton("Generate proof")
layout.addWidget(self.generate_button)
self.generate_button.clicked.connect(self._on_generate_clicked)
self.proof_display = QtWidgets.QTextEdit()
self.proof_display.setReadOnly(True)
layout.addWidget(self.proof_display)
proof_status_layout = QtWidgets.QHBoxLayout()
layout.addLayout(proof_status_layout)
master_sig_status_header_label = QtWidgets.QLabel("Master signature: ")
proof_status_layout.addWidget(master_sig_status_header_label)
self.master_sig_status_label = QtWidgets.QLabel("")
proof_status_layout.addWidget(self.master_sig_status_label)
stake_sigs_status_header_label = QtWidgets.QLabel("Stake signatures: ")
proof_status_layout.addWidget(stake_sigs_status_header_label)
self.stake_sigs_status_label = QtWidgets.QLabel("")
proof_status_layout.addWidget(self.stake_sigs_status_label)
proof_buttons_layout = QtWidgets.QHBoxLayout()
layout.addLayout(proof_buttons_layout)
self.load_proof_button = QtWidgets.QPushButton("Load proof")
self.load_proof_button.setToolTip("Load a proof from a .proof file.")
proof_buttons_layout.addWidget(self.load_proof_button)
self.save_proof_button = QtWidgets.QPushButton("Save proof")
self.save_proof_button.setToolTip("Save this proof to a .proof file.")
self.save_proof_button.setEnabled(False)
proof_buttons_layout.addWidget(self.save_proof_button)
self.generate_dg_button = QtWidgets.QPushButton("Generate a delegation")
self.generate_dg_button.setEnabled(False)
proof_buttons_layout.addWidget(self.generate_dg_button)
# Connect signals
self.expiration_checkbox.toggled.connect(self.on_expiration_cb_toggled)
self.calendar.dateTimeChanged.connect(self.on_datetime_changed)
self.timestamp_widget.valueChanged.connect(self.on_timestamp_changed)
self.master_key_edit.textChanged.connect(self.update_master_pubkey)
self.add_coins_from_file_button.clicked.connect(
self.on_add_coins_from_file_clicked
)
self.add_coins_from_wallet_button.clicked.connect(
self.on_add_coins_from_wallet_clicked
)
self.merge_stakes_button.clicked.connect(self.on_merge_stakes_clicked)
self.generate_dg_button.clicked.connect(self.open_dg_dialog)
self.load_proof_button.clicked.connect(self.on_load_proof_clicked)
self.save_proof_button.clicked.connect(self.on_save_proof_clicked)
self.utxos_wigdet.total_amount_changed.connect(self.on_stake_amount_changed)
# Init widgets
self.dg_dialog = None
# Suggest a private key to the user. He can change it if he wants.
self.master_key_suggestion: str = self._get_privkey_suggestion()
self.init_widgets()
def init_widgets(self):
# Clear internal state
self.sequence_sb.setValue(0)
# Set a default expiration date
self.expiration_checkbox.setChecked(True)
now = QtCore.QDateTime.currentDateTime()
self.calendar.setDateTime(now.addYears(3))
self.master_pubkey_view.setText("")
self.master_key_edit.setText(self.master_key_suggestion)
if self.receive_address is not None:
self.payout_addr_edit.setText(self.receive_address.to_ui_string())
self.utxos_wigdet.clear()
self.total_amount_label.setText("Total amount:")
self.proof_display.setText("")
self.master_sig_status_label.clear()
self.stake_sigs_status_label.clear()
def on_stake_amount_changed(self, amount: int):
self.total_amount_label.setText(
f"Total amount: <b>{format_satoshis(amount)} XEC</b>"
)
def add_utxos(self, utxos: List[dict]):
"""Add UTXOs from a list of dict objects, such as stored internally by
the wallet or loaded from a JSON file. These UTXOs must belong to the current
wallet, as they are not yet signed.
They must also be confirmed (i.e. have a block height number).
"""
unconfirmed_count = 0
stakes = []
- if self.wallet.has_password() and self.pwd is None:
+ if self.wallet.has_keystore_encryption() and self.pwd is None:
# We are here if the user cancelled the password dialog.
QtWidgets.QMessageBox.critical(
self,
_("Password required"),
f"Failed to add {len(utxos)} stakes to the proof because the "
f"decryption password for this wallet is unavailable.",
)
return
for utxo in utxos:
height = utxo["height"]
if height <= 0:
unconfirmed_count += 1
continue
address = utxo["address"]
if not isinstance(utxo["address"], Address):
# utxo loaded from JSON file (serialized)
address = Address.from_string(address)
txid = UInt256.from_hex(utxo["prevout_hash"])
# derive addresses as needed (if this is an offline wallet, it may not
# have derived addresses beyond the initial gap limit at index 20)
addr_index = utxo.get("address_index")
if addr_index is not None:
for_change = addr_index[0] == 1
num_addresses = (
len(self.wallet.change_addresses)
if for_change
else len(self.wallet.receiving_addresses)
)
for _i in range(num_addresses, addr_index[1] + 1):
self.wallet.create_new_address(for_change)
- try:
- wif_key = self.wallet.export_private_key(address, self.pwd)
- key = Key.from_wif(wif_key)
- except AddressNotFoundError:
- QtWidgets.QMessageBox.critical(
- self,
- _("Missing key or signature"),
- f'UTXO {utxo["prevout_hash"]}:{utxo["prevout_n"]} with address '
- f"{address.to_ui_string()} does not belong to this wallet.",
- )
- return
-
stakes.append(
- StakeAndKey(
+ StakeAndSigningData(
Stake(
OutPoint(txid, utxo["prevout_n"]),
amount=utxo["value"],
height=utxo["height"],
- pubkey=key.get_pubkey(),
is_coinbase=utxo["coinbase"],
),
- key,
+ address,
)
)
if unconfirmed_count:
QtWidgets.QMessageBox.warning(
self,
_("Excluded coins"),
f"{unconfirmed_count} coins have been ignored because they are "
"unconfirmed or do not have a block height specified.",
)
self.utxos_wigdet.add_stakes(stakes)
def _get_privkey_suggestion(self) -> str:
"""Get a private key to pre-fill the master key field.
Return it in WIF format, or return an empty string on failure (pwd dialog
cancelled).
"""
if not self.wallet.is_deterministic() or not self.wallet.can_export():
return ""
wif_pk = ""
if not self.wallet.has_password() or self.pwd is not None:
auxiliary_key_index = self.wallet.storage.get(
StorageKeys.AUXILIARY_KEY_INDEX,
)
wif_pk = get_auxiliary_privkey(
self.wallet, key_index=auxiliary_key_index, pwd=self.pwd
)
return wif_pk
def maybe_increment_auxkey_index(self):
"""Increment the index if the suggested key was used to sign the proof,
to discourage key reuse by suggesting another key the next time."""
if (
not self.master_key_suggestion
or self.master_key_edit.text() != self.master_key_suggestion
):
return
self.wallet.storage.put(
StorageKeys.AUXILIARY_KEY_INDEX,
min(
self.wallet.storage.get(StorageKeys.AUXILIARY_KEY_INDEX) + 1,
MAXIMUM_INDEX_DERIVATION_PATH,
),
)
def on_expiration_cb_toggled(self, is_checked: bool):
self.timestamp_widget.setEnabled(is_checked)
self.calendar.setEnabled(is_checked)
def on_datetime_changed(self, dt: QtCore.QDateTime):
"""Set the timestamp from a QDateTime"""
was_blocked = self.blockSignals(True)
self.timestamp_widget.setValue(dt.toSecsSinceEpoch())
self.blockSignals(was_blocked)
def on_timestamp_changed(self, timestamp: float):
"""Set the calendar date from POSIX timestamp"""
timestamp = int(timestamp)
was_blocked = self.blockSignals(True)
self.calendar.setDateTime(QtCore.QDateTime.fromSecsSinceEpoch(timestamp))
self.blockSignals(was_blocked)
def on_add_coins_from_file_clicked(self):
fileName, __ = QtWidgets.QFileDialog.getOpenFileName(
self,
"Select the file containing the data for coins to be used as stakes",
filter="JSON (*.json);;All files (*)",
)
if not fileName:
return
with open(fileName, "r", encoding="utf-8") as f:
utxos = json.load(f)
if utxos is None:
return
self.add_utxos(utxos)
def on_add_coins_from_wallet_clicked(self):
d = UtxosDialog(self.wallet)
if d.exec_() == QtWidgets.QDialog.Rejected:
return
utxos = d.get_selected_utxos()
if not check_utxos(utxos, self):
return
self.add_utxos(utxos)
def on_merge_stakes_clicked(self):
fileName, __ = QtWidgets.QFileDialog.getOpenFileName(
self,
"Select the proof file for merging stakes",
filter="Avalanche proof (*.proof);;All files (*)",
)
if not fileName:
return
with open(fileName, "r", encoding="utf-8") as f:
proof_hex = f.read()
# TODO: catch possible decoding, format, hex ... errors
self.utxos_wigdet.add_stakes(Proof.from_hex(proof_hex).signed_stakes)
self._on_generate_clicked()
def displayProof(self, proof: Proof):
self.proof_display.setText(proof_to_rich_text(proof))
assert proof.to_hex() == self.proof_display.toPlainText()
# Update status bar below actual proof display
if proof.verify_master_signature():
self.master_sig_status_label.setText(
colored_text("✅ Valid", TextColor.GOOD_SIG)
)
else:
self.master_sig_status_label.setText(
colored_text("❌ Invalid", TextColor.BAD_SIG)
)
good_count, bad_count = 0, 0
for ss in proof.signed_stakes:
if ss.verify_signature(proof.stake_commitment):
good_count += 1
else:
bad_count += 1
text = ""
if good_count:
text = colored_text(f"{good_count} good", TextColor.GOOD_STAKE_SIG)
if bad_count:
if text:
text += "; "
text += colored_text(f"{bad_count} bad", TextColor.BAD_STAKE_SIG)
self.stake_sigs_status_label.setText(
text or colored_text("No stakes", TextColor.NEUTRAL)
)
def on_load_proof_clicked(self):
d = LoadProofDialog(self)
if not d.exec_():
return
self.load_proof(d.proof)
self.generate_dg_button.setEnabled(True)
self.save_proof_button.setEnabled(True)
def find_auxiliary_privkey_from_pubkey(self, pubkey: PublicKey) -> Optional[str]:
"""Try to find the master private key from the master public key
by scanning a range of recent auxiliary keys.
Return the key in WIF format, or None."""
if (
not self.wallet.is_deterministic()
or not self.wallet.can_export()
or (self.wallet.has_password() and self.pwd is None)
):
return None
auxiliary_key_index = self.wallet.storage.get(StorageKeys.AUXILIARY_KEY_INDEX)
gap_limit = self.wallet.storage.get(StorageKeys.GAP_LIMIT)
wif_pk = None
# Scan backwards from the current auxiliary_key_index, because in most cases the key will be the most recent one.
for i in range(
auxiliary_key_index, max(-1, auxiliary_key_index - gap_limit), -1
):
maybe_wif_pk = get_auxiliary_privkey(self.wallet, key_index=i, pwd=self.pwd)
if Key.from_wif(maybe_wif_pk).get_pubkey() == pubkey:
wif_pk = maybe_wif_pk
break
return wif_pk
def load_proof(self, proof: Proof):
# Figure out whether we know the private key associated with the proof's master public key.
# First check the key that is currently typed in the privkey widget. If not so, try a range of recently used auxiliary keys.
provided_privkey = (
self.master_key_edit.text()
if is_private_key(self.master_key_edit.text())
else None
)
if (
provided_privkey is not None
and Key.from_wif(provided_privkey).get_pubkey() == proof.master_pub
):
known_privkey = provided_privkey
else:
known_privkey = self.find_auxiliary_privkey_from_pubkey(proof.master_pub)
self.init_widgets()
self.sequence_sb.setValue(proof.sequence)
if proof.expiration_time <= 0:
self.expiration_checkbox.setChecked(False)
else:
self.timestamp_widget.setValue(proof.expiration_time)
if known_privkey is None:
self.master_key_edit.setText("")
QtWidgets.QMessageBox.warning(
self,
"Missing private key",
"Unable to guess private key associated with this proof's public"
" key. You can fill it manually if you know it, or leave it blank"
- " if you just want to sign your stakes, ",
+ " if you just want to sign your stakes.",
)
else:
self.master_key_edit.setText(known_privkey)
self.master_pubkey_view.setText(proof.master_pub.to_hex())
_txout_type, addr = get_address_from_output_script(proof.payout_script_pubkey)
# note: this will work even if the "addr" is not an address (PublicKey or
# ScriptOutput), but the proof generation currently only supports addresses
self.payout_addr_edit.setText(addr.to_ui_string())
self.utxos_wigdet.add_stakes(proof.signed_stakes)
self.displayProof(proof)
def on_save_proof_clicked(self):
if not self.proof_display.toPlainText():
raise AssertionError(
"No proof to be saved. The save button should not be enabled."
)
proof = Proof.from_hex(self.proof_display.toPlainText())
default_filename = f"{proof.proofid.get_hex()[:8]}"
if not proof.verify_master_signature():
default_filename += "-unsigned"
default_filename += ".proof"
fileName, __ = QtWidgets.QFileDialog.getSaveFileName(
self,
"Save proof to file",
default_filename,
filter="Avalanche proof (*.proof);;All files (*)",
)
if not fileName:
return
with open(fileName, "w", encoding="utf-8") as f:
f.write(proof.to_hex())
def update_master_pubkey(self, master_wif: str):
if is_private_key(master_wif):
master_pub = Key.from_wif(master_wif).get_pubkey()
pubkey_str = master_pub.to_hex()
self.master_pubkey_view.setText(pubkey_str)
def _on_generate_clicked(self):
- proof = self._build()
- if proof is not None:
- self.displayProof(proof)
- if proof.is_signed():
- self.maybe_increment_auxkey_index()
- self.generate_dg_button.setEnabled(proof is not None)
- self.save_proof_button.setEnabled(proof is not None)
-
- def _build(self) -> Optional[Proof]:
+ def on_completion(proof):
+ if proof is not None:
+ self.displayProof(proof)
+ if proof.is_signed():
+ self.maybe_increment_auxkey_index()
+ self.generate_dg_button.setEnabled(proof is not None)
+ self.save_proof_button.setEnabled(proof is not None)
+
master_wif = self.master_key_edit.text()
if not is_private_key(master_wif):
try:
master_pub = PublicKey.from_hex(self.master_pubkey_view.text())
except DeserializationError:
QtWidgets.QMessageBox.critical(
self,
"No valid master key",
"You need to specify either a master private key or a master "
"public key before generating a proof.",
)
return
QtWidgets.QMessageBox.warning(
self,
"Invalid private key",
"Unable to parse private key. The generated proof will not be"
" signed. This is OK if you just intend to sign your stakes and"
" sign the proof later in a master wallet.",
)
master = None
else:
master = Key.from_wif(master_wif)
master_pub = None
try:
payout_address = Address.from_string(self.payout_addr_edit.text())
except AddressError as e:
QtWidgets.QMessageBox.critical(self, "Invalid payout address", str(e))
return
- if self.wallet.has_password() and self.pwd is None:
+ if self.wallet.has_keystore_encryption() and self.pwd is None:
self.proof_display.setText(
'<p style="color:red;">Password dialog cancelled!</p>'
)
return
expiration_time = (
0
if not self.expiration_checkbox.isChecked()
else self.calendar.dateTime().toSecsSinceEpoch()
)
proofbuilder = ProofBuilder(
sequence=self.sequence_sb.value(),
expiration_time=expiration_time,
payout_address=payout_address,
+ wallet=self.wallet,
master=master,
master_pub=master_pub,
+ pwd=self.pwd,
)
for ss in self.utxos_wigdet.stakes:
- if isinstance(ss, StakeAndKey):
- proofbuilder.sign_and_add_stake(ss.stake, ss.key)
+ if isinstance(ss, StakeAndSigningData):
+ try:
+ proofbuilder.sign_and_add_stake(ss)
+ except UserCancelled:
+ return
+ except AddressNotFoundError:
+ QtWidgets.QMessageBox.critical(
+ self,
+ _("Missing key or signature"),
+ f"UTXO with address {ss.address.to_ui_string()} does not belong to this wallet.",
+ )
+ return
+ except Exception as e:
+ QtWidgets.QMessageBox.critical(
+ self,
+ _("Unable to sign stake"),
+ f"{e}",
+ )
+ return
else:
proofbuilder.add_signed_stake(ss)
- return proofbuilder.build()
+ proofbuilder.build(on_completion=on_completion)
def open_dg_dialog(self):
if self.dg_dialog is None:
self.dg_dialog = AvaDelegationDialog(self.wallet, self._pwd, self)
self.dg_dialog.set_proof(self.proof_display.toPlainText())
self.dg_dialog.set_master(self.master_key_edit.text())
self.dg_dialog.show()
class AvaProofDialog(QtWidgets.QDialog):
def __init__(
self,
wallet: DeterministicWallet,
receive_address: Optional[Address] = None,
parent: Optional[QtWidgets.QWidget] = None,
):
super().__init__(parent)
self.setWindowTitle(f"Avalanche Proof Editor - {wallet.basename()}")
layout = QtWidgets.QVBoxLayout()
self.setLayout(layout)
self.proof_widget = AvaProofEditor(wallet, receive_address, self)
layout.addWidget(self.proof_widget)
buttons_layout = QtWidgets.QHBoxLayout()
layout.addLayout(buttons_layout)
self.close_button = QtWidgets.QPushButton("Close")
buttons_layout.addWidget(self.close_button)
self.close_button.clicked.connect(self.accept)
def add_utxos(self, utxos: List[dict]) -> bool:
if not check_utxos(utxos, self):
return False
self.proof_widget.add_utxos(utxos)
return True
class LoadProofDialog(QtWidgets.QDialog):
def __init__(self, parent: Optional[QtWidgets.QWidget] = None):
super().__init__(parent)
self.setWindowTitle("Load an existing proof")
self.proof: Optional[Proof] = None
layout = QtWidgets.QVBoxLayout()
self.setLayout(layout)
layout.addWidget(
QtWidgets.QLabel('Paste a hexadecimal proof or click "Load from file"')
)
self.proof_edit = QtWidgets.QTextEdit()
self.proof_edit.setAcceptRichText(False)
layout.addWidget(self.proof_edit)
self.load_from_file_button = QtWidgets.QPushButton("Load from file")
layout.addWidget(self.load_from_file_button)
buttons_layout = QtWidgets.QHBoxLayout()
layout.addLayout(buttons_layout)
self.ok_button = QtWidgets.QPushButton("OK")
self.ok_button.setEnabled(False)
buttons_layout.addWidget(self.ok_button)
self.cancel_button = QtWidgets.QPushButton("Cancel")
buttons_layout.addWidget(self.cancel_button)
self.load_from_file_button.clicked.connect(self.on_load_from_file_clicked)
self.ok_button.clicked.connect(self.accept)
self.cancel_button.clicked.connect(self.reject)
self.proof_edit.textChanged.connect(self.on_proof_text_changed)
def on_load_from_file_clicked(self):
proof_hex = self.load_from_file()
if proof_hex:
self.proof_edit.setText(proof_hex)
def load_from_file(self) -> Optional[str]:
fileName, __ = QtWidgets.QFileDialog.getOpenFileName(
self,
"Select the proof file",
filter="Avalanche proof (*.proof);;All files (*)",
)
if not fileName:
return
with open(fileName, "r", encoding="utf-8") as f:
proof_hex = f.read().strip()
if self.try_to_decode_proof(proof_hex):
self.accept()
def on_proof_text_changed(self):
self.try_to_decode_proof(self.proof_edit.toPlainText())
self.ok_button.setEnabled(self.proof is not None)
def try_to_decode_proof(self, proof_hex) -> bool:
try:
self.proof = Proof.from_hex(proof_hex)
except DeserializationError:
self.proof = None
return self.proof is not None
class StakeDustThresholdMessageBox(QtWidgets.QMessageBox):
"""QMessageBox question dialog with custom buttons."""
def __init__(self, parent=None):
super().__init__(parent)
self.setIcon(QtWidgets.QMessageBox.Warning)
self.setWindowTitle(_("Coins below the stake dust threshold"))
self.setText(
_(
"The value of one or more coins is below the"
f" {format_satoshis(PROOF_DUST_THRESHOLD)} XEC stake minimum threshold."
" The generated proof will be invalid."
)
)
self.setStandardButtons(QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel)
ok_button = self.button(QtWidgets.QMessageBox.Ok)
ok_button.setText(_("Continue, I'm just testing"))
self.cancel_button = self.button(QtWidgets.QMessageBox.Cancel)
self.setEscapeButton(self.cancel_button)
def has_cancelled(self) -> bool:
return self.clickedButton() == self.cancel_button
def check_utxos(utxos: List[dict], parent: Optional[QtWidgets.QWidget] = None) -> bool:
"""Check utxos are usable for avalanche proofs.
If they aren't, and the user has not acknowledged that he wants to build the
proof anyway, return False.
"""
if any(u["value"] < PROOF_DUST_THRESHOLD for u in utxos):
warning_dialog = StakeDustThresholdMessageBox(parent)
warning_dialog.exec_()
if warning_dialog.has_cancelled():
return False
return True
class UtxosDialog(QtWidgets.QDialog):
"""A widget listing all coins in a wallet and allowing to load multiple coins"""
def __init__(self, wallet: DeterministicWallet):
super().__init__()
self.setMinimumWidth(750)
self.wallet = wallet
self.utxos: List[dict] = []
self.selected_rows: List[int] = []
layout = QtWidgets.QVBoxLayout(self)
self.setLayout(layout)
self.utxos_table = QtWidgets.QTableWidget()
layout.addWidget(self.utxos_table)
self.utxos_table.setColumnCount(4)
self.utxos_table.setHorizontalHeaderLabels(
["txid", "vout", "amount (sats)", "block height"]
)
self.utxos_table.verticalHeader().setVisible(False)
self.utxos_table.setSelectionBehavior(QtWidgets.QTableWidget.SelectRows)
self.utxos_table.setSelectionMode(QtWidgets.QTableWidget.ExtendedSelection)
self.utxos_table.horizontalHeader().setSectionResizeMode(
0, QtWidgets.QHeaderView.Stretch
)
layout.addWidget(self.utxos_table)
self._fill_utxos_table()
buttons_layout = QtWidgets.QHBoxLayout()
layout.addLayout(buttons_layout)
self.load_button = QtWidgets.QPushButton("Load selected coins")
self.load_button.setEnabled(False)
buttons_layout.addWidget(self.load_button)
self.cancel_button = QtWidgets.QPushButton("Cancel")
buttons_layout.addWidget(self.cancel_button)
self.load_button.clicked.connect(self.accept)
self.cancel_button.clicked.connect(self.reject)
self.utxos_table.itemSelectionChanged.connect(self._on_selection_changed)
def _fill_utxos_table(self):
self.utxos = [u for u in self.wallet.get_utxos() if u["height"] > 0]
self.utxos.sort(key=lambda u: u["value"], reverse=True)
tip = self.wallet.get_local_height()
self.utxos_table.setRowCount(len(self.utxos))
for row_index, utxo in enumerate(self.utxos):
txid_item = QtWidgets.QTableWidgetItem(utxo["prevout_hash"])
self.utxos_table.setItem(row_index, 0, txid_item)
vout_item = QtWidgets.QTableWidgetItem(str(utxo["prevout_n"]))
self.utxos_table.setItem(row_index, 1, vout_item)
amount_item = QtWidgets.QTableWidgetItem(
format_satoshis(utxo["value"], num_zeros=2)
)
amount_item.setTextAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter)
if utxo["value"] < PROOF_DUST_THRESHOLD:
amount_item.setForeground(QtGui.QColor("red"))
amount_item.setToolTip(
_(
"The minimum threshold for a coin in an avalanche proof is "
f"{format_satoshis(PROOF_DUST_THRESHOLD)} XEC."
)
)
self.utxos_table.setItem(row_index, 2, amount_item)
height = utxo["height"]
height_item = QtWidgets.QTableWidgetItem(str(height))
utxo_validity_height = height + STAKE_UTXO_CONFIRMATIONS
if utxo_validity_height > tip:
height_item.setForeground(QtGui.QColor("orange"))
height_item.setToolTip(
_(
f"UTXOs with less than {STAKE_UTXO_CONFIRMATIONS} "
"confirmations cannot be used as stake proofs."
)
+ f"\nCurrent known block height is {tip}.\nYour proof will be "
f"valid after block {utxo_validity_height}."
)
self.utxos_table.setItem(row_index, 3, height_item)
def _on_selection_changed(self):
self.selected_rows = [
idx.row() for idx in self.utxos_table.selectionModel().selectedRows()
]
self.load_button.setEnabled(bool(self.selected_rows))
def get_selected_utxos(self) -> List[dict]:
return [self.utxos[r] for r in self.selected_rows]
diff --git a/electrum/electrumabc_gui/qt/main_window.py b/electrum/electrumabc_gui/qt/main_window.py
index c96e4a1b4..c5d9ac8f9 100644
--- a/electrum/electrumabc_gui/qt/main_window.py
+++ b/electrum/electrumabc_gui/qt/main_window.py
@@ -1,5456 +1,5461 @@
#
# Electrum ABC - lightweight eCash client
# Copyright (C) 2020 The Electrum ABC developers
# Copyright (C) 2012 thomasv@gitorious
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation files
# (the "Software"), to deal in the Software without restriction,
# including without limitation the rights to use, copy, modify, merge,
# publish, distribute, sublicense, and/or sell copies of the Software,
# and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
from __future__ import annotations
import contextlib
import copy
import csv
import json
import os
import shutil
import sys
import threading
import time
import traceback
from decimal import Decimal as PyDecimal # Qt 5.12 also exports Decimal
from functools import partial
from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Tuple
from PyQt5 import QtWidgets
from PyQt5.QtCore import (
QObject,
QRect,
QStringListModel,
Qt,
QTimer,
pyqtBoundSignal,
pyqtSignal,
)
from PyQt5.QtGui import QColor, QCursor, QFont, QIcon, QKeySequence, QTextOption
import electrumabc.constants
import electrumabc.web as web
from electrumabc import bitcoin, commands, keystore, networks, paymentrequest, util
from electrumabc.address import Address
from electrumabc.alias import DEFAULT_ENABLE_ALIASES
from electrumabc.bip32 import InvalidXKeyFormat, InvalidXKeyNotBase58, deserialize_xpub
from electrumabc.bitcoin import TYPE_ADDRESS
from electrumabc.constants import CURRENCY, PROJECT_NAME, REPOSITORY_URL, SCRIPT_NAME
from electrumabc.contacts import Contact
from electrumabc.ecc import ECPubkey
from electrumabc.i18n import _, ngettext
from electrumabc.paymentrequest import PR_PAID
from electrumabc.plugins import run_hook
from electrumabc.printerror import is_verbose
from electrumabc.simple_config import get_config
from electrumabc.transaction import (
OPReturn,
SerializationError,
Transaction,
TxOutput,
rawtx_from_str,
)
from electrumabc.util import (
ExcessiveFee,
InvalidPassword,
NotEnoughFunds,
PrintError,
UserCancelled,
Weak,
bfh,
bh2u,
format_fee_satoshis,
format_satoshis,
format_satoshis_plain,
format_time,
)
from electrumabc.wallet import AbstractWallet, MultisigWallet, sweep_preparations
from . import address_dialog, external_plugins_window, qrwindow
from .address_list import AddressList
from .amountedit import AmountEdit, MyLineEdit, XECAmountEdit
from .avalanche.delegation_editor import AvaDelegationDialog
from .avalanche.proof_editor import AvaProofDialog
from .avalanche.util import AuxiliaryKeysDialog
from .bip38_importer import Bip38Importer
from .console import Console
from .contact_list import ContactList
from .fee_slider import FeeSlider
from .history_list import HistoryList
from .invoice_dialog import InvoiceDialog, load_invoice_from_file_and_show_error_message
from .invoice_list import InvoiceList
from .multi_transactions_dialog import MultiTransactionsDialog
from .password_dialog import (
ChangePasswordDialogForHW,
ChangePasswordDialogForSW,
PassphraseDialog,
PasswordDialog,
)
from .paytoedit import PayToEdit
from .popup_widget import ShowPopupLabel
from .qrcodewidget import QRCodeWidget, QRDialog
from .qrreader import QrReaderCameraDialog
from .qrtextedit import ScanQRTextEdit, ShowQRTextEdit
from .request_list import RequestList
from .scan_beyond_gap import ScanBeyondGap
from .seed_dialog import SeedDialog
from .settings_dialog import SettingsDialog
from .sign_verify_dialog import SignVerifyDialog
from .statusbar import NetworkStatus, StatusBar
from .transaction_dialog import show_transaction
from .tree_widget import MyTreeWidget
from .util import (
MONOSPACE_FONT,
Buttons,
ButtonsLineEdit,
CancelButton,
ChoicesLayout,
CloseButton,
ColorScheme,
CopyCloseButton,
EnterButton,
HelpButton,
HelpLabel,
MessageBoxMixin,
OkButton,
RateLimiter,
TaskThread,
WaitingDialog,
WindowModalDialog,
WWLabel,
address_combo,
destroyed_print_error,
expiration_values,
filename_field,
getOpenFileName,
getSaveFileName,
rate_limited,
text_dialog,
)
from .utxo_list import UTXOList
if sys.platform.startswith("linux"):
from .udev_installer import InstallHardwareWalletSupportDialog
try:
# pre-load QtMultimedia at app start, if possible
# this is because lazy-loading it from within Python
# callbacks led to crashes on Linux, likely due to
# bugs in PyQt5 (crashes wouldn't happen when testing
# with PySide2!).
from PyQt5.QtMultimedia import QCameraInfo
del QCameraInfo # defensive programming: not always available so don't keep name around
except ImportError:
pass # we tried to pre-load it, failure is ok; camera just won't be available
if TYPE_CHECKING:
from . import ElectrumGui
class ElectrumWindow(QtWidgets.QMainWindow, MessageBoxMixin, PrintError):
# Note: self.clean_up_connections automatically detects signals named XXX_signal
# and disconnects them on window close.
payment_request_ok_signal = pyqtSignal()
payment_request_error_signal = pyqtSignal()
new_fx_quotes_signal = pyqtSignal()
new_fx_history_signal = pyqtSignal()
network_signal = pyqtSignal(str, object)
alias_received_signal = pyqtSignal()
history_updated_signal = pyqtSignal()
# note this signal occurs when an explicit update_labels() call happens. Interested
# GUIs should also listen for history_updated_signal as well which also indicates
# labels may have changed.
labels_updated_signal = pyqtSignal()
# functions wanting to be executed from timer_actions should connect to this
# signal, preferably via Qt.DirectConnection
on_timer_signal = pyqtSignal()
def __init__(self, gui_object: ElectrumGui, wallet: AbstractWallet):
QtWidgets.QMainWindow.__init__(self)
self.gui_object = gui_object
self.gui_thread = gui_object.gui_thread
self.wallet = wallet
assert not self.wallet.weak_window
# This enables plugins such as CashFusion to keep just a reference to the
# wallet, but eventually be able to find the window it belongs to.
self.wallet.weak_window = Weak.ref(self)
self.config = gui_object.config
assert self.wallet and self.config and self.gui_object
self.network = gui_object.daemon.network
self.fx = gui_object.daemon.fx
self.invoices = wallet.invoices
self.contacts = wallet.contacts
self.tray = gui_object.tray
self.app = gui_object.app
self.cleaned_up = False
self.payment_request = None
self.checking_accounts = False
self.qr_window = None
self.not_enough_funds = False
self.op_return_toolong = False
self.internalpluginsdialog = None
self.externalpluginsdialog = None
self.hardwarewalletdialog = None
self.require_fee_update = False
# alias for backwards compatibility for plugins -- this signal used to live in
# each window and has since been refactored to gui-object where it belongs
# (since it's really an app-global setting)
self.addr_fmt_changed = self.gui_object.addr_fmt_changed
self.tl_windows = []
self.tx_external_keypairs: Dict[bytes, Tuple[bytes, bool]] = {}
self._tx_dialogs = Weak.Set()
# manages network callbacks for 'new_transaction' and 'verified2', and collates
# GUI updates from said callbacks as a performance optimization
self.tx_update_mgr = TxUpdateMgr(self)
# defaults to empty list
self.send_tab_opreturn_widgets, self.receive_tab_opreturn_widgets = ([], [])
# keep track of shortcuts and disable them on close
self._shortcuts = Weak.Set()
self.status_bar = self.create_status_bar()
self.need_update = threading.Event()
self.labels_need_update = threading.Event()
self.completions = QStringListModel()
self.tabs = tabs = QtWidgets.QTabWidget(self)
self.send_tab = self.create_send_tab()
self.receive_tab = self.create_receive_tab()
self.address_list = self.create_addresses_tab()
self.utxo_list = self.create_utxo_tab()
self.console_tab = self.create_console_tab()
self.contact_list = self.create_contacts_tab()
self.converter_tab = self.create_converter_tab()
self.history_list = self.create_history_tab()
tabs.addTab(self.history_list, QIcon(":icons/tab_history.png"), _("History"))
tabs.addTab(self.send_tab, QIcon(":icons/tab_send.png"), _("Send"))
tabs.addTab(self.receive_tab, QIcon(":icons/tab_receive.png"), _("Receive"))
# clears/inits the opreturn widgets
self.on_toggled_opreturn(bool(self.config.get("enable_opreturn")))
def add_optional_tab(tabs, tab, icon, description, name, default=True):
tab.tab_icon = icon
tab.tab_description = description
tab.tab_pos = len(tabs)
tab.tab_name = name
if self.config.get("show_{}_tab".format(name), default):
tabs.addTab(tab, icon, description.replace("&", ""))
add_optional_tab(
tabs,
self.address_list,
QIcon(":icons/tab_addresses.png"),
_("&Addresses"),
"addresses",
)
add_optional_tab(
tabs, self.utxo_list, QIcon(":icons/tab_coins.png"), _("Co&ins"), "utxo"
)
add_optional_tab(
tabs,
self.contact_list,
QIcon(":icons/tab_contacts.png"),
_("Con&tacts"),
"contacts",
)
add_optional_tab(
tabs,
self.converter_tab,
QIcon(":icons/tab_converter.svg"),
_("Address Converter"),
"converter",
)
add_optional_tab(
tabs,
self.console_tab,
QIcon(":icons/tab_console.png"),
_("Con&sole"),
"console",
False,
)
tabs.setSizePolicy(
QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding
)
self.setCentralWidget(tabs)
if self.config.get("is_maximized"):
self.showMaximized()
self.init_menubar()
# We use a weak reference here to help along python gc of QShortcut children: prevent the lambdas below from holding a strong ref to self.
wrtabs = Weak.ref(tabs)
self._shortcuts.add(
QtWidgets.QShortcut(QKeySequence("Ctrl+W"), self, self.close)
)
# Below is now added to the menu as Ctrl+R but we'll also support F5 like browsers do
self._shortcuts.add(
QtWidgets.QShortcut(QKeySequence("F5"), self, self.update_wallet)
)
self._shortcuts.add(
QtWidgets.QShortcut(
QKeySequence("Ctrl+PgUp"),
self,
lambda: wrtabs()
and wrtabs().setCurrentIndex(
(wrtabs().currentIndex() - 1) % wrtabs().count()
),
)
)
self._shortcuts.add(
QtWidgets.QShortcut(
QKeySequence("Ctrl+PgDown"),
self,
lambda: wrtabs()
and wrtabs().setCurrentIndex(
(wrtabs().currentIndex() + 1) % wrtabs().count()
),
)
)
for i in range(tabs.count()):
self._shortcuts.add(
QtWidgets.QShortcut(
QKeySequence("Alt+" + str(i + 1)),
self,
lambda i=i: wrtabs() and wrtabs().setCurrentIndex(i),
)
)
self.payment_request_ok_signal.connect(self.payment_request_ok)
self.payment_request_error_signal.connect(self.payment_request_error)
self.gui_object.addr_fmt_changed.connect(self.status_bar.update_cashaddr_icon)
self.gui_object.update_available_signal.connect(
self.status_bar.on_update_available
)
self.history_list.setFocus(True)
# update fee slider in case we missed the callback
self.fee_slider.update()
self.load_wallet()
if self.network:
self.network_signal.connect(self.on_network_qt)
interests = [
"blockchain_updated",
"wallet_updated",
"new_transaction",
"status",
"banner",
"verified2",
"fee",
]
# To avoid leaking references to "self" that prevent the
# window from being GC-ed when closed, callbacks should be
# methods of this class only, and specifically not be
# partials, lambdas or methods of subobjects. Hence...
self.network.register_callback(self.on_network, interests)
# set initial message
self.console.showMessage(self.network.banner)
self.network.register_callback(self.on_quotes, ["on_quotes"])
self.network.register_callback(self.on_history, ["on_history"])
self.new_fx_quotes_signal.connect(self.on_fx_quotes)
self.new_fx_history_signal.connect(self.on_fx_history)
gui_object.timer.timeout.connect(self.timer_actions)
self.fetch_alias()
_first_shown = True
def showEvent(self, event):
super().showEvent(event)
if event.isAccepted() and self._first_shown:
self._first_shown = False
weakSelf = Weak.ref(self)
# do this immediately after this event handler finishes -- noop on everything but linux
def callback():
strongSelf = weakSelf()
if strongSelf:
strongSelf.gui_object.lin_win_maybe_show_highdpi_caveat_msg(
strongSelf
)
QTimer.singleShot(0, callback)
def on_history(self, event, *args):
# NB: event should always be 'on_history'
if not args or args[0] is self.wallet:
self.new_fx_history_signal.emit()
@rate_limited(3.0) # Rate limit to no more than once every 3 seconds
def on_fx_history(self):
if self.cleaned_up:
return
self.history_list.refresh_headers()
self.history_list.update()
self.address_list.update()
self.history_updated_signal.emit() # inform things like address_dialog that there's a new history
def on_quotes(self, b):
self.new_fx_quotes_signal.emit()
@rate_limited(3.0) # Rate limit to no more than once every 3 seconds
def on_fx_quotes(self):
if self.cleaned_up:
return
self.update_status()
# Refresh edits with the new rate
edit = self.fiat_send_e if self.fiat_send_e.is_last_edited else self.amount_e
edit.textEdited.emit(edit.text())
edit = (
self.fiat_receive_e
if self.fiat_receive_e.is_last_edited
else self.receive_amount_e
)
edit.textEdited.emit(edit.text())
# History tab needs updating if it used spot
if self.fx.history_used_spot:
self.history_list.update()
self.history_updated_signal.emit() # inform things like address_dialog that there's a new history
def toggle_tab(self, tab):
show = self.tabs.indexOf(tab) == -1
self.config.set_key("show_{}_tab".format(tab.tab_name), show)
item_format = (
_("Hide {tab_description}") if show else _("Show {tab_description}")
)
item_text = item_format.format(tab_description=tab.tab_description)
tab.menu_action.setText(item_text)
if show:
# Find out where to place the tab
index = len(self.tabs)
for i in range(len(self.tabs)):
try:
if tab.tab_pos < self.tabs.widget(i).tab_pos:
index = i
break
except AttributeError:
pass
self.tabs.insertTab(
index, tab, tab.tab_icon, tab.tab_description.replace("&", "")
)
else:
i = self.tabs.indexOf(tab)
self.tabs.removeTab(i)
def push_top_level_window(self, window):
"""Used for e.g. tx dialog box to ensure new dialogs are appropriately
parented. This used to be done by explicitly providing the parent
window, but that isn't something hardware wallet prompts know."""
self.tl_windows.append(window)
def pop_top_level_window(self, window, *, raise_if_missing=False):
try:
self.tl_windows.remove(window)
except ValueError:
if raise_if_missing:
raise
""" Window not in list. Suppressing the exception by default makes
writing cleanup handlers easier. Doing it this way fixes #1707. """
def top_level_window(self):
"""Do the right thing in the presence of tx dialog windows"""
override = self.tl_windows[-1] if self.tl_windows else None
return self.top_level_window_recurse(override)
def diagnostic_name(self):
return "%s/%s" % (PrintError.diagnostic_name(self), self.wallet.basename())
def is_hidden(self):
return self.isMinimized() or self.isHidden()
def show_or_hide(self):
if self.is_hidden():
self.bring_to_top()
else:
self.hide()
def bring_to_top(self):
self.show()
self.raise_()
def on_error(self, exc_info):
if not isinstance(exc_info[1], UserCancelled):
try:
traceback.print_exception(*exc_info)
except OSError:
# Issue #662, user got IO error.
# We want them to still get the error displayed to them.
pass
self.show_error(str(exc_info[1]))
def on_network(self, event, *args):
# self.print_error("on_network:", event, *args)
if event == "wallet_updated":
if args[0] is self.wallet:
self.need_update.set()
elif event == "blockchain_updated":
self.need_update.set()
elif event == "new_transaction":
self.tx_update_mgr.notif_add(args) # added only if this wallet's tx
if args[1] is self.wallet:
self.network_signal.emit(event, args)
elif event == "verified2":
self.tx_update_mgr.verif_add(args) # added only if this wallet's tx
if args[0] is self.wallet:
self.network_signal.emit(event, args)
elif event in ["status", "banner", "fee"]:
# Handle in GUI thread
self.network_signal.emit(event, args)
else:
self.print_error("unexpected network message:", event, args)
def on_network_qt(self, event, args=None):
if self.cleaned_up:
return
# Handle a network message in the GUI thread
if event == "status":
self.update_status()
elif event == "banner":
self.console.showMessage(args[0])
elif event == "fee":
pass
elif event == "new_transaction":
self.check_and_reset_receive_address_if_needed()
elif event == "verified2":
pass
else:
self.print_error("unexpected network_qt signal:", event, args)
def fetch_alias(self):
self.alias_info = None
alias = self.config.get("alias")
if alias:
alias = str(alias)
def f():
self.alias_info = self.contacts.resolve_openalias(alias)
self.alias_received_signal.emit()
t = threading.Thread(target=f)
t.setDaemon(True)
t.start()
def _close_wallet(self):
if self.wallet:
self.print_error("close_wallet", self.wallet.storage.path)
self.wallet.thread = None
run_hook("close_wallet", self.wallet)
def load_wallet(self):
self.wallet.thread = TaskThread(
self, self.on_error, name=self.wallet.diagnostic_name() + "/Wallet"
)
self.update_recently_visited(self.wallet.storage.path)
# address used to create a dummy transaction and estimate transaction fee
self.history_list.update()
self.address_list.update()
self.utxo_list.update()
self.need_update.set()
# update menus
self.seed_menu.setEnabled(self.wallet.has_seed())
self.status_bar.update_lock_icon(self.wallet.has_password())
self.update_buttons_on_seed()
self.update_console()
self.clear_receive_tab()
self.request_list.update()
self.tabs.show()
self.init_geometry()
if self.config.get("hide_gui") and self.tray.isVisible():
self.hide()
else:
self.show()
if self._is_invalid_testnet_wallet():
self.gui_object.daemon.stop_wallet(self.wallet.storage.path)
self._rebuild_history_action.setEnabled(False)
self._warn_if_invalid_testnet_wallet()
self.watching_only_changed()
self.history_updated_signal.emit() # inform things like address_dialog that there's a new history
run_hook("load_wallet", self.wallet, self)
def init_geometry(self):
winpos = self.wallet.storage.get("winpos-qt")
try:
screen = self.app.desktop().screenGeometry()
assert screen.contains(QRect(*winpos))
self.setGeometry(*winpos)
except Exception:
self.print_error("using default geometry")
self.setGeometry(100, 100, 840, 400)
def watching_only_changed(self):
title = "%s %s - %s" % (
PROJECT_NAME,
self.wallet.electrum_version,
self.wallet.basename(),
)
extra = [self.wallet.storage.get("wallet_type", "?")]
if self.wallet.is_watching_only():
self.warn_if_watching_only()
extra.append(_("watching only"))
title += " [%s]" % ", ".join(extra)
self.setWindowTitle(title)
self.password_menu.setEnabled(self.wallet.may_have_password())
self.import_privkey_menu.setVisible(self.wallet.can_import_privkey())
self.import_address_menu.setVisible(self.wallet.can_import_address())
self.show_aux_keys_menu.setVisible(
self.wallet.is_deterministic() and self.wallet.can_export()
)
self.export_menu.setEnabled(self.wallet.can_export())
def warn_if_watching_only(self):
if self.wallet.is_watching_only():
msg = " ".join(
[
_("This wallet is watching-only."),
_(f"This means you will not be able to spend {CURRENCY} with it."),
_(
"Make sure you own the seed phrase or the private keys, before"
f" you request {CURRENCY} to be sent to this wallet."
),
]
)
self.show_warning(msg, title=_("Information"))
def _is_invalid_testnet_wallet(self):
if not networks.net.TESTNET:
return False
is_old_bad = False
xkey = (
hasattr(self.wallet, "get_master_public_key")
and self.wallet.get_master_public_key()
) or None
if xkey:
try:
deserialize_xpub(xkey)
except InvalidXKeyNotBase58:
pass # old_keystore uses some other key format, so we will let it slide.
except InvalidXKeyFormat:
is_old_bad = True
return is_old_bad
def _warn_if_invalid_testnet_wallet(self):
"""This was added after the upgrade from the bad xpub testnet wallets
to the good tpub testnet wallet format in version 3.3.6. See #1164.
We warn users if they are using the bad wallet format and instruct
them on how to upgrade their wallets."""
is_old_bad = self._is_invalid_testnet_wallet()
if is_old_bad:
msg = " ".join(
[
_("This testnet wallet has an invalid master key format."),
_(
f"(Old versions of {PROJECT_NAME} before 3.3.6 produced invalid"
" testnet wallets)."
),
"<br><br>",
_(
"In order to use this wallet without errors with this version"
" of EC, please <b>re-generate this wallet from seed</b>."
),
"<br><br><em><i>~SPV stopped~</i></em>",
]
)
self.show_critical(msg, title=_("Invalid Master Key"), rich_text=True)
return is_old_bad
def open_wallet(self):
try:
wallet_folder = self.get_wallet_folder()
except FileNotFoundError as e:
self.show_error(str(e))
return
if not os.path.exists(wallet_folder):
wallet_folder = None
filename, __ = QtWidgets.QFileDialog.getOpenFileName(
self, _("Select your wallet file"), wallet_folder
)
if not filename:
return
self.gui_object.new_window(filename)
def backup_wallet(self):
self.wallet.storage.write() # make sure file is committed to disk
path = self.wallet.storage.path
wallet_folder = os.path.dirname(path)
filename, __ = QtWidgets.QFileDialog.getSaveFileName(
self, _("Enter a filename for the copy of your wallet"), wallet_folder
)
if not filename:
return
new_path = os.path.join(wallet_folder, filename)
if new_path != path:
try:
# Copy file contents
shutil.copyfile(path, new_path)
# Copy file attributes if possible
# (not supported on targets like Flatpak documents)
try:
shutil.copystat(path, new_path)
except (IOError, os.error):
pass
self.show_message(
_("A copy of your wallet file was created in")
+ " '%s'" % str(new_path),
title=_("Wallet backup created"),
)
except (IOError, os.error) as reason:
self.show_critical(
_(
f"{PROJECT_NAME} was unable to copy your wallet file to"
" the specified location."
)
+ "\n"
+ str(reason),
title=_("Unable to create backup"),
)
def update_recently_visited(self, filename):
recent = self.config.get("recently_open", [])
try:
sorted(recent)
except Exception:
recent = []
if filename in recent:
recent.remove(filename)
recent.insert(0, filename)
recent = [path for path in recent if os.path.exists(path)]
recent = recent[:5]
self.config.set_key("recently_open", recent)
self.recently_visited_menu.clear()
gui_object = self.gui_object
for i, k in enumerate(sorted(recent)):
b = os.path.basename(k)
def loader(k):
return lambda: gui_object.new_window(k)
self.recently_visited_menu.addAction(b, loader(k)).setShortcut(
QKeySequence("Ctrl+%d" % (i + 1))
)
self.recently_visited_menu.setEnabled(len(recent))
def get_wallet_folder(self):
return self.gui_object.get_wallet_folder()
def new_wallet(self):
try:
full_path = self.gui_object.get_new_wallet_path()
except FileNotFoundError as e:
self.show_error(str(e))
return
self.gui_object.start_new_window(full_path, None)
def init_menubar(self):
menubar = self.menuBar()
menubar.setObjectName(self.diagnostic_name() + ".QMenuBar")
file_menu = menubar.addMenu(_("&File"))
self.recently_visited_menu = file_menu.addMenu(_("Open &Recent"))
file_menu.addAction(_("&Open wallet") + "...", self.open_wallet).setShortcut(
QKeySequence.Open
)
file_menu.addAction(_("&New/Restore") + "...", self.new_wallet).setShortcut(
QKeySequence.New
)
file_menu.addAction(_("&Save Copy As") + "...", self.backup_wallet).setShortcut(
QKeySequence.SaveAs
)
file_menu.addAction(_("&Delete") + "...", self.remove_wallet)
file_menu.addSeparator()
file_menu.addAction(_("&Quit"), self.close).setShortcut(QKeySequence.Quit)
wallet_menu = menubar.addMenu(_("&Wallet"))
wallet_menu.addAction(
_("&Information"), self.show_master_public_keys, QKeySequence("Ctrl+I")
)
wallet_menu.addSeparator()
self.password_menu = wallet_menu.addAction(
_("&Password"), self.change_password_dialog
)
self.seed_menu = wallet_menu.addAction(_("&Seed"), self.show_seed_dialog)
self.private_keys_menu = wallet_menu.addMenu(_("Private Keys"))
self.private_keys_menu.addAction(_("&Sweep"), self.sweep_key_dialog)
self.import_privkey_menu = self.private_keys_menu.addAction(
_("&Import"), self.do_import_privkey
)
self.export_menu = self.private_keys_menu.addMenu(_("&Export"))
self.export_menu.addAction(_("&WIF Plaintext"), self.export_privkeys_dialog)
self.export_menu.addAction(_("&BIP38 Encrypted"), self.export_bip38_dialog)
self.import_address_menu = wallet_menu.addAction(
_("Import addresses"), self.import_addresses
)
self.show_aux_keys_menu = wallet_menu.addAction(
_("Show Auxiliary Keys"), self.show_auxiliary_keys
)
wallet_menu.addSeparator()
self._rebuild_history_action = wallet_menu.addAction(
_("&Rebuild History"), self.rebuild_history
)
self._scan_beyond_gap_action = wallet_menu.addAction(
_("Scan &More Addresses..."), self.scan_beyond_gap
)
self._scan_beyond_gap_action.setEnabled(
bool(self.wallet.is_deterministic() and self.network)
)
wallet_menu.addSeparator()
labels_menu = wallet_menu.addMenu(_("&Labels"))
labels_menu.addAction(_("&Import") + "...", self.do_import_labels)
labels_menu.addAction(_("&Export") + "...", self.do_export_labels)
contacts_menu = wallet_menu.addMenu(_("&Contacts"))
contacts_menu.addAction(_("&New") + "...", self.contact_list.new_contact_dialog)
if self.config.get("enable_aliases", DEFAULT_ENABLE_ALIASES):
contacts_menu.addAction(
_("Add eCash Alias") + "...", self.contact_list.fetch_alias_dialog
)
contacts_menu.addAction(
_("Import") + "...", lambda: self.contact_list.import_contacts()
)
contacts_menu.addAction(
_("Export") + "...", lambda: self.contact_list.export_contacts()
)
invoices_menu = wallet_menu.addMenu(_("Invoices"))
invoices_menu.addAction(
_("Import") + "...", lambda: self.invoice_list.import_invoices()
)
hist_menu = wallet_menu.addMenu(_("&History"))
hist_menu.addAction(_("Export") + "...", self.export_history_dialog)
wallet_menu.addSeparator()
wallet_menu.addAction(
_("&Find"), self.status_bar.toggle_search, QKeySequence("Ctrl+F")
)
wallet_menu.addAction(
_("Refresh GUI"), self.update_wallet, QKeySequence("Ctrl+R")
)
def add_toggle_action(view_menu, tab):
is_shown = self.tabs.indexOf(tab) > -1
item_format = (
_("Hide {tab_description}") if is_shown else _("Show {tab_description}")
)
item_name = item_format.format(tab_description=tab.tab_description)
tab.menu_action = view_menu.addAction(
item_name, lambda: self.toggle_tab(tab)
)
view_menu = menubar.addMenu(_("&View"))
add_toggle_action(view_menu, self.address_list)
add_toggle_action(view_menu, self.utxo_list)
add_toggle_action(view_menu, self.contact_list)
add_toggle_action(view_menu, self.converter_tab)
add_toggle_action(view_menu, self.console_tab)
tools_menu = menubar.addMenu(_("&Tools"))
prefs_tit = _("Preferences") + "..."
a = tools_menu.addAction(
prefs_tit, self.settings_dialog, QKeySequence("Ctrl+,")
)
if sys.platform == "darwin":
# This turns off the heuristic matching based on name and keeps the
# "Preferences" action out of the application menu and into the
# actual menu we specified on macOS.
a.setMenuRole(QtWidgets.QAction.NoRole)
gui_object = self.gui_object
weakSelf = Weak.ref(self)
tools_menu.addAction(
_("&Network") + "...",
lambda: gui_object.show_network_dialog(weakSelf()),
QKeySequence("Ctrl+K"),
)
tools_menu.addAction(
_("Optional &Features") + "...",
self.internal_plugins_dialog,
QKeySequence("Shift+Ctrl+P"),
)
tools_menu.addAction(
_("Installed &Plugins") + "...",
self.external_plugins_dialog,
QKeySequence("Ctrl+P"),
)
if sys.platform.startswith("linux"):
tools_menu.addSeparator()
tools_menu.addAction(
_("&Hardware Wallet Support..."), self.hardware_wallet_support
)
tools_menu.addSeparator()
tools_menu.addAction(
_("&Sign/Verify Message") + "...", self.sign_verify_message
)
tools_menu.addAction(
_("&Encrypt/Decrypt Message") + "...", self.encrypt_message
)
tools_menu.addSeparator()
tools_menu.addAction(_("&Pay to Many"), self.paytomany, QKeySequence("Ctrl+M"))
raw_transaction_menu = tools_menu.addMenu(_("&Load Transaction"))
raw_transaction_menu.addAction(
_("From &File") + "...", self.do_process_from_file
)
raw_transaction_menu.addAction(
_("From &Text") + "...", self.do_process_from_text, QKeySequence("Ctrl+T")
)
raw_transaction_menu.addAction(
_("From the &Blockchain") + "...",
self.do_process_from_txid,
QKeySequence("Ctrl+B"),
)
raw_transaction_menu.addAction(
_("From &QR Code") + "...", self.read_tx_from_qrcode
)
raw_transaction_menu.addAction(
_("From &Multiple files") + "...", self.do_process_from_multiple_files
)
self.raw_transaction_menu = raw_transaction_menu
invoice_menu = tools_menu.addMenu(_("&Invoice"))
invoice_menu.addAction(_("Create new invoice"), self.do_create_invoice)
invoice_menu.addAction(_("Load and edit invoice"), self.do_load_edit_invoice)
invoice_menu.addAction(_("Load and pay invoice"), self.do_load_pay_invoice)
tools_menu.addSeparator()
avaproof_action = tools_menu.addAction(
"Avalanche Proof Editor", self.open_proof_editor
)
tools_menu.addAction(
"Build Avalanche Delegation", self.build_avalanche_delegation
)
- if self.wallet.is_watching_only() or not self.wallet.is_schnorr_possible():
- avaproof_action.setEnabled(False)
- avaproof_action.setToolTip(
- "Cannot build avalanche proof or delegation for hardware, multisig "
- "or watch-only wallet (Schnorr signature is required)."
- )
+
+ def enable_disable_avatools():
+ if not self.wallet.is_stake_signature_possible():
+ avaproof_action.setEnabled(False)
+ avaproof_action.setToolTip(
+ "Cannot build avalanche proof or delegation for some hardware, "
+ "multisig or watch-only wallet (Schnorr signature is required)."
+ )
+
+ tools_menu.aboutToShow.connect(enable_disable_avatools)
+
run_hook("init_menubar_tools", self, tools_menu)
help_menu = menubar.addMenu(_("&Help"))
help_menu.addAction(_("&About"), self.show_about)
help_menu.addAction(_("About Qt"), self.app.aboutQt)
help_menu.addAction(
_("&Check for Updates"), lambda: self.gui_object.show_update_checker(self)
)
# help_menu.addAction(_("&Official Website"), lambda: webopen("https://..."))
help_menu.addSeparator()
# help_menu.addAction(_("Documentation"), lambda: webopen("http://...")).setShortcut(QKeySequence.HelpContents)
help_menu.addAction(_("&Report Bug..."), self.show_report_bug)
help_menu.addSeparator()
help_menu.addAction(_("&Donate to Server") + "...", self.donate_to_server)
def donate_to_server(self):
if self.gui_object.warn_if_no_network(self):
return
d = {}
spv_address = self.network.get_donation_address()
donation_for = _("Donation for")
if spv_address == "bitcoincash:qplw0d304x9fshz420lkvys2jxup38m9symky6k028":
# Fulcrum servers without a donation address specified in the
# configuration file broadcast the fulcrum donation address
spv_prefix = "Fulcrum developers"
host = "https://github.com/cculianu/Fulcrum"
# convert to an ecash: address
spv_address = "ecash:qplw0d304x9fshz420lkvys2jxup38m9syzms3d4vs"
else:
spv_prefix = _("Blockchain Server")
host = self.network.get_parameters()[0]
if spv_address:
d[spv_prefix + ": " + host] = spv_address
plugin_servers = run_hook("donation_address", self, multi=True)
for tup in plugin_servers:
if not isinstance(tup, (list, tuple)) or len(tup) != 2:
continue
desc, address = tup
if (
desc
and address
and isinstance(desc, str)
and isinstance(address, Address)
and desc not in d
and not desc.lower().startswith(spv_prefix.lower())
):
d[desc] = address.to_ui_string()
def do_payto(desc):
addr = d[desc]
# The message is intentionally untranslated, leave it like that
self.pay_to_URI(
"{pre}:{addr}?message={donation_for} {desc}".format(
pre=networks.net.CASHADDR_PREFIX,
addr=addr,
donation_for=donation_for,
desc=desc,
)
)
if len(d) == 1:
do_payto(next(iter(d.keys())))
elif len(d) > 1:
choices = tuple(d.keys())
index = self.query_choice(
_("Please select which server you would like to donate to:"),
choices,
add_cancel_button=True,
)
if index is not None:
do_payto(choices[index])
else:
self.show_error(_("No donation address for this server"))
def show_about(self):
QtWidgets.QMessageBox.about(
self,
f"{PROJECT_NAME}",
f"<p><font size=+3><b>{PROJECT_NAME}</b></font></p><p>"
+ _("Version")
+ f" {self.wallet.electrum_version}"
+ "</p>"
+ '<span style="font-weight:200;"><p>'
+ _(
f"{PROJECT_NAME}'s focus is speed, with low resource usage and"
f" simplifying {CURRENCY}. You do not need to perform regular "
"backups, because your wallet can be recovered from a secret "
"phrase that you can memorize or write on paper. Startup times "
"are instant because it operates in conjunction with "
"high-performance servers that handle the most complicated "
f"parts of the {CURRENCY} system."
)
+ "</p></span>"
+ f"<p><a href={REPOSITORY_URL}/blob/master/electrum/COPYING>"
+ _("License and copyright information")
+ "</a></p>",
)
def show_report_bug(self):
msg = " ".join(
[
_("Please report any bugs as issues on github:<br/>"),
(
f'<a href="{REPOSITORY_URL}/issues">'
f"{REPOSITORY_URL}/issues</a><br/><br/>"
),
_(
"Before reporting a bug, upgrade to the most recent version of "
f"{PROJECT_NAME} (latest release or git HEAD), and include the "
"version number in your report."
),
_("Try to explain not only what the bug is, but how it occurs."),
]
)
self.show_message(
msg, title=f"{PROJECT_NAME} - " + _("Reporting Bugs"), rich_text=True
)
def notify(self, message):
self.gui_object.notify(message)
def timer_actions(self):
# Note this runs in the GUI thread
if self.need_update.is_set():
# will clear flag when it runs. (also clears labels_need_update as well)
self._update_wallet()
if self.labels_need_update.is_set():
# will clear flag when it runs.
self._update_labels()
# resolve aliases
# FIXME this is a blocking network call that has a timeout of 5 sec
self.payto_e.resolve()
# update fee
if self.require_fee_update:
self.do_update_fee()
self.require_fee_update = False
# hook for other classes to be called here. For example the tx_update_mgr is
# called here (see TxUpdateMgr.do_check).
self.on_timer_signal.emit()
def format_amount(self, x, is_diff=False, whitespaces=False):
return format_satoshis(
x,
self.get_num_zeros(),
self.get_decimal_point(),
is_diff=is_diff,
whitespaces=whitespaces,
)
def format_amount_and_units(self, amount, is_diff=False):
text = self.format_amount(amount, is_diff=is_diff) + " " + self.base_unit()
x = self.fx.format_amount_and_units(amount, is_diff=is_diff)
if text and x:
text += " (%s)" % x
return text
def format_fee_rate(self, fee_rate):
sats_per_byte = format_fee_satoshis(
fee_rate / 1000, max(self.get_num_zeros(), 1)
)
return _("{sats_per_byte} sat/byte").format(sats_per_byte=sats_per_byte)
def get_decimal_point(self) -> int:
return self.config.get("decimal_point", 2)
def get_num_zeros(self) -> int:
return int(self.config.get("num_zeros", 2))
def base_unit(self):
if self.get_decimal_point() in electrumabc.constants.BASE_UNITS_BY_DECIMALS:
return electrumabc.constants.BASE_UNITS_BY_DECIMALS[
self.get_decimal_point()
]
raise Exception("Unknown base unit")
def connect_fields(self, window, btc_e, fiat_e, fee_e):
def edit_changed(edit):
if edit.follows:
return
edit.setStyleSheet(ColorScheme.DEFAULT.as_stylesheet())
fiat_e.is_last_edited = edit == fiat_e
amount = edit.get_amount()
rate = self.fx.exchange_rate() if self.fx else None
sats_per_unit = self.fx.satoshis_per_unit()
if rate is None or amount is None:
if edit is fiat_e:
btc_e.setText("")
if fee_e:
fee_e.setText("")
else:
fiat_e.setText("")
else:
if edit is fiat_e:
btc_e.follows = True
btc_e.setAmount(int(amount / PyDecimal(rate) * sats_per_unit))
btc_e.setStyleSheet(ColorScheme.BLUE.as_stylesheet())
btc_e.follows = False
if fee_e:
window.update_fee()
else:
fiat_e.follows = True
fiat_e.setText(
self.fx.ccy_amount_str(
amount * PyDecimal(rate) / sats_per_unit, False
)
)
fiat_e.setStyleSheet(ColorScheme.BLUE.as_stylesheet())
fiat_e.follows = False
btc_e.follows = False
fiat_e.follows = False
fiat_e.textChanged.connect(partial(edit_changed, fiat_e))
btc_e.textChanged.connect(partial(edit_changed, btc_e))
fiat_e.is_last_edited = False
def update_status(self):
if not self.wallet:
return
server_lag = 0
if self.network is None or not self.network.is_running():
text = _("Offline")
status = NetworkStatus.DISCONNECTED
elif self.network.is_connected():
server_height = self.network.get_server_height()
server_lag = self.network.get_local_height() - server_height
num_chains = len(self.network.get_blockchains())
# Server height can be 0 after switching to a new server
# until we get a headers subscription request response.
# Display the synchronizing message in that case.
if not self.wallet.up_to_date or server_height == 0:
text = _("Synchronizing...")
status = NetworkStatus.UPDATING
elif server_lag > 1:
text = _("Server is lagging ({} blocks)").format(server_lag)
if num_chains <= 1:
status = NetworkStatus.LAGGING
else:
status = NetworkStatus.LAGGING_FORK
else:
c, u, x = self.wallet.get_balance()
text_items = [
_("Balance: {amount_and_unit}").format(
amount_and_unit=self.format_amount_and_units(c)
)
]
if u:
text_items.append(
_("[{amount} unconfirmed]").format(
amount=self.format_amount(u, True).strip()
)
)
if x:
text_items.append(
_("[{amount} unmatured]").format(
amount=self.format_amount(x, True).strip()
)
)
extra = run_hook("balance_label_extra", self)
if isinstance(extra, str) and extra:
text_items.append(_("[{extra}]").format(extra=extra))
# append fiat balance and price
if self.fx.is_enabled():
fiat_text = self.fx.get_fiat_status_text(
c + u + x, self.base_unit(), self.get_decimal_point()
).strip()
if fiat_text:
text_items.append(fiat_text)
n_unverif = self.wallet.get_unverified_tx_pending_count()
if n_unverif >= 10:
# if there are lots left to verify, display this informative text
text_items.append(
_("[{count} unverified TXs]").format(count=n_unverif)
)
if not self.network.proxy:
status = (
NetworkStatus.CONNECTED
if num_chains <= 1
else NetworkStatus.CONNECTED_FORK
)
else:
status = (
NetworkStatus.CONNECTED_PROXY
if num_chains <= 1
else NetworkStatus.CONNECTED_PROXY_FORK
)
text = " ".join(text_items)
else:
text = _("Not connected")
status = NetworkStatus.DISCONNECTED
# server lag
self.tray.setToolTip("%s (%s)" % (text, self.wallet.basename()))
self.status_bar.update_status(text, status, server_lag)
run_hook("window_update_status", self)
def update_wallet(self):
# will enqueue an _update_wallet() call in at most 0.5 seconds from now.
self.need_update.set()
def _update_wallet(self):
"""Called by self.timer_actions every 0.5 secs if need_update flag is set.
Note that the flag is actually cleared by update_tabs."""
self.update_status()
if (
self.wallet.up_to_date
or not self.network
or not self.network.is_connected()
):
self.update_tabs()
@rate_limited(1.0, classlevel=True, ts_after=True)
def update_tabs(self):
if self.cleaned_up:
return
self.history_list.update()
self.request_list.update()
self.address_list.update()
self.utxo_list.update()
self.contact_list.update()
self.invoice_list.update()
self.update_completions()
# inform things like address_dialog that there's a new history, also clears
# self.tx_update_mgr.verif_q
self.history_updated_signal.emit()
self.need_update.clear() # clear flag
if self.labels_need_update.is_set():
# if flag was set, might as well declare the labels updated since they
# necessarily were due to a full update.
#
# just in case client code was waiting for this signal to proceed.
self.labels_updated_signal.emit()
# clear flag
self.labels_need_update.clear()
def update_labels(self):
# will enqueue an _update_labels() call in at most 0.5 seconds from now
self.labels_need_update.set()
@rate_limited(1.0)
def _update_labels(self):
"""Called by self.timer_actions every 0.5 secs if labels_need_update flag is set."""
if self.cleaned_up:
return
self.history_list.update_labels()
self.address_list.update_labels()
self.utxo_list.update_labels()
self.update_completions()
self.labels_updated_signal.emit()
# clear flag
self.labels_need_update.clear()
def create_history_tab(self):
history_list = HistoryList(self)
history_list.edited.connect(self.update_labels)
return history_list
def show_address(self, addr, *, parent=None):
parent = parent or self.top_level_window()
d = address_dialog.AddressDialog(self, addr, windowParent=parent)
d.exec_()
def show_transaction(self, tx: Transaction, tx_desc=None):
"""tx_desc is set only for txs created in the Send tab"""
d = show_transaction(tx, self, tx_desc)
self._tx_dialogs.add(d)
def on_toggled_opreturn(self, b):
"""toggles opreturn-related widgets for both the receive and send
tabs"""
b = bool(b)
self.config.set_key("enable_opreturn", b)
# send tab
if not b:
self.message_opreturn_e.setText("")
self.op_return_toolong = False
for x in self.send_tab_opreturn_widgets:
x.setVisible(b)
# receive tab
for x in self.receive_tab_opreturn_widgets:
x.setVisible(b)
def create_receive_tab(self):
# A 4-column grid layout. All the stretch is in the last column.
# The exchange rate plugin adds a fiat widget in column 2
self.receive_grid = grid = QtWidgets.QGridLayout()
grid.setSpacing(8)
grid.setColumnStretch(3, 1)
self.receive_address: Optional[Address] = None
self.receive_address_e = ButtonsLineEdit()
self.receive_address_e.addCopyButton()
self.receive_address_e.setReadOnly(True)
msg = _(
f"{CURRENCY} address where the payment should be received. Note that "
f"each payment request uses a different {CURRENCY} address."
)
label = HelpLabel(_("&Receiving address"), msg)
label.setBuddy(self.receive_address_e)
self.receive_address_e.textChanged.connect(self.update_receive_qr)
self.gui_object.addr_fmt_changed.connect(self.update_receive_address_widget)
grid.addWidget(label, 0, 0)
grid.addWidget(self.receive_address_e, 0, 1, 1, -1)
self.receive_message_e = QtWidgets.QLineEdit()
label = QtWidgets.QLabel(_("&Description"))
label.setBuddy(self.receive_message_e)
grid.addWidget(label, 2, 0)
grid.addWidget(self.receive_message_e, 2, 1, 1, -1)
self.receive_message_e.textChanged.connect(self.update_receive_qr)
# OP_RETURN requests
self.receive_opreturn_e = QtWidgets.QLineEdit()
msg = _(
"You may optionally append an OP_RETURN message to the payment URI and/or"
" QR you generate.\n\nNote: Not all wallets yet support OP_RETURN"
" parameters, so make sure the other party's wallet supports OP_RETURN"
" URIs."
)
self.receive_opreturn_label = label = HelpLabel(_("&OP_RETURN"), msg)
label.setBuddy(self.receive_opreturn_e)
self.receive_opreturn_rawhex_cb = QtWidgets.QCheckBox(_("Raw &hex script"))
self.receive_opreturn_rawhex_cb.setToolTip(
_(
"If unchecked, the textbox contents are UTF8-encoded into a single-push"
" script: <tt>OP_RETURN PUSH &lt;text&gt;</tt>. If checked, the text"
" contents will be interpreted as a raw hexadecimal script to be"
" appended after the OP_RETURN opcode: <tt>OP_RETURN"
" &lt;script&gt;</tt>."
)
)
grid.addWidget(label, 3, 0)
grid.addWidget(self.receive_opreturn_e, 3, 1, 1, 3)
grid.addWidget(self.receive_opreturn_rawhex_cb, 3, 4, Qt.AlignLeft)
self.receive_opreturn_e.textChanged.connect(self.update_receive_qr)
self.receive_opreturn_rawhex_cb.clicked.connect(self.update_receive_qr)
self.receive_tab_opreturn_widgets = [
self.receive_opreturn_e,
self.receive_opreturn_rawhex_cb,
self.receive_opreturn_label,
]
self.receive_amount_e = XECAmountEdit(self.get_decimal_point())
label = QtWidgets.QLabel(_("Requested &amount"))
label.setBuddy(self.receive_amount_e)
grid.addWidget(label, 4, 0)
grid.addWidget(self.receive_amount_e, 4, 1)
self.receive_amount_e.textChanged.connect(self.update_receive_qr)
self.fiat_receive_e = AmountEdit(self.fx.get_currency() if self.fx else "")
if not self.fx or not self.fx.is_enabled():
self.fiat_receive_e.setVisible(False)
grid.addWidget(self.fiat_receive_e, 4, 2, Qt.AlignLeft)
self.connect_fields(self, self.receive_amount_e, self.fiat_receive_e, None)
self.expires_combo = QtWidgets.QComboBox()
self.expires_combo.addItems([_(i[0]) for i in expiration_values])
self.expires_combo.setCurrentIndex(3)
self.expires_combo.setFixedWidth(self.receive_amount_e.width())
msg = " ".join(
[
_("Expiration date of your request."),
_(
"This information is seen by the recipient if you send them"
" a signed payment request."
),
_(
"Expired requests have to be deleted manually from your list,"
f" in order to free the corresponding {CURRENCY} addresses."
),
_(
f"The {CURRENCY} address never expires and will always be "
f"part of this {PROJECT_NAME} wallet."
),
]
)
label = HelpLabel(_("Request &expires"), msg)
label.setBuddy(self.expires_combo)
grid.addWidget(label, 5, 0)
grid.addWidget(self.expires_combo, 5, 1)
self.expires_label = QtWidgets.QLineEdit("")
self.expires_label.setReadOnly(1)
self.expires_label.hide()
grid.addWidget(self.expires_label, 5, 1)
self.save_request_button = QtWidgets.QPushButton(_("&Save"))
self.save_request_button.clicked.connect(self.save_payment_request)
self.new_request_button = QtWidgets.QPushButton(_("&Clear"))
self.new_request_button.clicked.connect(self.new_payment_request)
weakSelf = Weak.ref(self)
class MyQRCodeWidget(QRCodeWidget):
def mouseReleaseEvent(slf, e):
"""to make the QRWidget clickable"""
weakSelf() and weakSelf().show_qr_window()
self.receive_qr = MyQRCodeWidget(fixedSize=200)
self.receive_qr.setCursor(QCursor(Qt.PointingHandCursor))
self.receive_buttons = buttons = QtWidgets.QHBoxLayout()
buttons.addWidget(self.save_request_button)
buttons.addWidget(self.new_request_button)
buttons.addStretch(1)
grid.addLayout(buttons, 6, 2, 1, -1)
self.receive_requests_label = QtWidgets.QLabel(_("Re&quests"))
self.request_list = RequestList(self)
self.request_list.chkVisible()
self.receive_requests_label.setBuddy(self.request_list)
# layout
vbox_g = QtWidgets.QVBoxLayout()
vbox_g.addLayout(grid)
vbox_g.addStretch()
hbox = QtWidgets.QHBoxLayout()
hbox.addLayout(vbox_g)
vbox2 = QtWidgets.QVBoxLayout()
vbox2.setContentsMargins(0, 0, 0, 0)
vbox2.setSpacing(4)
vbox2.addWidget(self.receive_qr, Qt.AlignHCenter | Qt.AlignTop)
self.receive_qr.setToolTip(_("Receive request QR code (click for details)"))
but = uribut = QtWidgets.QPushButton(_("Copy &URI"))
def on_copy_uri():
if self.receive_qr.data:
uri = str(self.receive_qr.data)
self.copy_to_clipboard(
uri, _("Receive request URI copied to clipboard"), uribut
)
but.clicked.connect(on_copy_uri)
but.setSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed)
but.setToolTip(_("Click to copy the receive request URI to the clipboard"))
vbox2.addWidget(but)
vbox2.setAlignment(but, Qt.AlignHCenter | Qt.AlignVCenter)
hbox.addLayout(vbox2)
class ReceiveTab(QtWidgets.QWidget):
def showEvent(slf, e):
super().showEvent(e)
if e.isAccepted():
wslf = weakSelf()
if wslf:
wslf.check_and_reset_receive_address_if_needed()
w = ReceiveTab()
w.searchable_list = self.request_list
vbox = QtWidgets.QVBoxLayout(w)
vbox.addLayout(hbox)
vbox.addStretch(1)
vbox.addWidget(self.receive_requests_label)
vbox.addWidget(self.request_list)
vbox.setStretchFactor(self.request_list, 1000)
return w
def delete_payment_request(self, addr):
self.wallet.remove_payment_request(addr, self.config)
self.request_list.update()
self.address_list.update()
self.clear_receive_tab()
def get_request_URI(self, addr):
req = self.wallet.receive_requests[addr]
message = self.wallet.labels.get(addr.to_storage_string(), "")
amount = req["amount"]
op_return = req.get("op_return")
op_return_raw = req.get("op_return_raw") if not op_return else None
URI = web.create_URI(
addr, amount, message, op_return=op_return, op_return_raw=op_return_raw
)
if req.get("time"):
URI += "&time=%d" % req.get("time")
if req.get("exp"):
URI += "&exp=%d" % req.get("exp")
if req.get("name") and req.get("sig"):
sig = bfh(req.get("sig"))
sig = bitcoin.base_encode(sig, base=58)
URI += "&name=" + req["name"] + "&sig=" + sig
return str(URI)
def sign_payment_request(self, addr):
alias = self.config.get("alias")
if alias and self.alias_info:
alias_addr, alias_name, validated = self.alias_info
if alias_addr:
if self.wallet.is_mine(alias_addr):
msg = (
_("This payment request will be signed.")
+ "\n"
+ _("Please enter your password")
)
password = None
if self.wallet.has_keystore_encryption():
password = self.password_dialog(msg)
if not password:
return
try:
self.wallet.sign_payment_request(
addr, alias, alias_addr, password
)
except Exception as e:
traceback.print_exc(file=sys.stderr)
self.show_error(str(e) or repr(e))
return
else:
return
def save_payment_request(self):
if not self.receive_address:
self.show_error(_("No receiving address"))
amount = self.receive_amount_e.get_amount()
message = self.receive_message_e.text()
if not message and not amount:
self.show_error(_("No message or amount"))
return False
i = self.expires_combo.currentIndex()
expiration = expiration_values[i][1]
kwargs = {}
opr = self.receive_opreturn_e.text().strip()
if opr:
# save op_return, if any
arg = "op_return"
if self.receive_opreturn_rawhex_cb.isChecked():
arg = "op_return_raw"
kwargs[arg] = opr
req = self.wallet.make_payment_request(
self.receive_address, amount, message, expiration, **kwargs
)
self.wallet.add_payment_request(req, self.config)
self.sign_payment_request(self.receive_address)
self.request_list.update()
# when adding items to the view the current selection may not reflect what's in
# the UI. Make sure it's selected.
self.request_list.select_item_by_address(req.get("address"))
self.address_list.update()
self.save_request_button.setEnabled(False)
def view_and_paste(self, title, msg, data):
dialog = WindowModalDialog(self.top_level_window(), title)
vbox = QtWidgets.QVBoxLayout()
label = QtWidgets.QLabel(msg)
label.setWordWrap(True)
vbox.addWidget(label)
pr_e = ShowQRTextEdit(text=data)
vbox.addWidget(pr_e)
vbox.addLayout(Buttons(CopyCloseButton(pr_e.text, self.app, dialog)))
dialog.setLayout(vbox)
dialog.exec_()
def export_payment_request(self, addr):
r = self.wallet.receive_requests[addr]
try:
pr = paymentrequest.serialize_request(r).SerializeToString()
except ValueError as e:
"""User entered some large amount or other value that doesn't fit
into a C++ type. See #1738."""
self.show_error(str(e))
return
name = r["id"] + ".bip70"
fileName = getSaveFileName(
_("Select where to save your payment request"), name, self.config, "*.bip70"
)
if fileName:
with open(fileName, "wb+") as f:
f.write(util.to_bytes(pr))
self.show_message(_("Request saved successfully"))
self.saved = True
def new_payment_request(self):
addr = self.wallet.get_unused_address(frozen_ok=False)
if addr is None:
if not self.wallet.is_deterministic():
msg = [
_("No more addresses in your wallet."),
_(
"You are using a non-deterministic wallet, which cannot create"
" new addresses."
),
_(
"If you want to create new addresses, use a deterministic"
" wallet instead."
),
]
self.show_message(" ".join(msg))
# New! Since the button is called 'Clear' now, we let them proceed with a re-used address
addr = self.wallet.get_receiving_address()
else:
# Warn if past gap limit.
if not self.question(
_(
"Warning: The next address will not be recovered automatically"
" if you restore your wallet from seed; you may need to add it"
" manually.\n\nThis occurs because you have too many unused"
" addresses in your wallet. To avoid this situation, use the"
" existing addresses first.\n\nCreate anyway?"
)
):
return
addr = self.wallet.create_new_address(False)
self.set_receive_address(addr)
self.expires_label.hide()
self.expires_combo.show()
self.request_list.setCurrentItem(None)
self.receive_message_e.setFocus(1)
def set_receive_address(self, addr: Address):
self.receive_address = addr
self.receive_message_e.setText("")
self.receive_opreturn_rawhex_cb.setChecked(False)
self.receive_opreturn_e.setText("")
self.receive_amount_e.setAmount(None)
self.update_receive_address_widget()
def update_receive_address_widget(self):
text = ""
if self.receive_address:
text = self.receive_address.to_ui_string()
self.receive_address_e.setText(text)
@rate_limited(0.250, ts_after=True)
def check_and_reset_receive_address_if_needed(self):
"""Check to make sure the receive tab is kosher and doesn't contain
an already-used address. This should be called from the showEvent
for the tab."""
if not self.wallet.use_change or self.cleaned_up:
# if they don't care about change addresses, they are ok
# with re-using addresses, so skip this check.
return
# ok, they care about anonymity, so make sure the receive address
# is always an unused address.
if (
not self.receive_address # this should always be defined but check anyway
or self.receive_address
in self.wallet.frozen_addresses # make sure it's not frozen
or (
self.wallet.get_address_history(
self.receive_address
) # make a new address if it has a history
and not self.wallet.get_payment_request(
self.receive_address, self.config
)
)
): # and if they aren't actively editing one in the request_list widget
addr = self.wallet.get_unused_address(
frozen_ok=False
) # try unused, not frozen
if addr is None:
if self.wallet.is_deterministic():
# creae a new one if deterministic
addr = self.wallet.create_new_address(False)
else:
# otherwise give up and just re-use one.
addr = self.wallet.get_receiving_address()
self.receive_address = addr
self.update_receive_address_widget()
def clear_receive_tab(self):
self.expires_label.hide()
self.expires_combo.show()
self.request_list.setCurrentItem(None)
self.set_receive_address(self.wallet.get_receiving_address(frozen_ok=False))
def show_qr_window(self):
if not self.qr_window:
self.qr_window = qrwindow.QRWindow()
self.qr_window.setAttribute(Qt.WA_DeleteOnClose, True)
weakSelf = Weak.ref(self)
def destroyed_clean(x):
if weakSelf():
weakSelf().qr_window = None
weakSelf().print_error("QR Window destroyed.")
self.qr_window.destroyed.connect(destroyed_clean)
self.update_receive_qr()
if self.qr_window.isMinimized():
self.qr_window.showNormal()
else:
self.qr_window.show()
self.qr_window.raise_()
self.qr_window.activateWindow()
def show_send_tab(self):
self.tabs.setCurrentIndex(self.tabs.indexOf(self.send_tab))
def show_receive_tab(self):
self.tabs.setCurrentIndex(self.tabs.indexOf(self.receive_tab))
def receive_at(self, addr):
self.receive_address = addr
self.show_receive_tab()
self.update_receive_address_widget()
def update_receive_qr(self):
if not self.receive_address:
return
amount = self.receive_amount_e.get_amount()
message = self.receive_message_e.text()
self.save_request_button.setEnabled((amount is not None) or (message != ""))
kwargs = {}
if self.receive_opreturn_e.isVisible():
# set op_return if enabled
arg = "op_return"
if self.receive_opreturn_rawhex_cb.isChecked():
arg = "op_return_raw"
opret = self.receive_opreturn_e.text()
if opret:
kwargs[arg] = opret
# Special case hack -- see #1473. Omit ecash: prefix from
# legacy address if no other params present in receive request.
if (
Address.FMT_UI == Address.FMT_LEGACY
and not kwargs
and not amount
and not message
):
uri = self.receive_address.to_ui_string_without_prefix()
else:
# Otherwise proceed as normal, prepending ecash: to URI
uri = web.create_URI(self.receive_address, amount, message, **kwargs)
self.receive_qr.setData(uri)
if self.qr_window:
self.qr_window.set_content(
self, self.receive_address_e.text(), amount, message, uri, **kwargs
)
def create_send_tab(self):
# A 4-column grid layout. All the stretch is in the last column.
# The exchange rate plugin adds a fiat widget in column 2
self.send_grid = grid = QtWidgets.QGridLayout()
grid.setSpacing(8)
grid.setColumnStretch(3, 1)
self.amount_e = XECAmountEdit(self.get_decimal_point())
self.payto_e = PayToEdit(self)
# NB: the translators hopefully will not have too tough a time with this
# *fingers crossed* :)
msg = (
'<span style="font-weight:400;">'
+ _("Recipient of the funds.")
+ " "
+ _(
"You may enter:"
"<ul>"
f"<li> {CURRENCY} <b>Address</b> <b>★</b>"
"<li> Bitcoin Legacy <b>Address</b> <b>★</b>"
"<li> <b>Contact name</b> <b>★</b> from the Contacts tab"
"<li> <b>OpenAlias</b> e.g. <i>satoshi@domain.com</i>"
"</ul><br>"
"&nbsp;&nbsp;&nbsp;<b>★</b> = Supports <b>pay-to-many</b>, where"
" you may optionally enter multiple lines of the form:"
"</span><br><pre>"
" recipient1, amount1 \n"
" recipient2, amount2 \n"
" etc..."
"</pre>"
)
)
self.payto_label = payto_label = HelpLabel(_("Pay &to"), msg)
payto_label.setBuddy(self.payto_e)
qmark = (
":icons/question-mark-dark.svg"
if ColorScheme.dark_scheme
else ":icons/question-mark-light.svg"
)
qmark_help_but = HelpButton(
msg, button_text="", fixed_size=False, icon=QIcon(qmark), custom_parent=self
)
self.payto_e.addWidget(qmark_help_but, index=0)
grid.addWidget(payto_label, 1, 0)
grid.addWidget(self.payto_e, 1, 1, 1, -1)
completer = QtWidgets.QCompleter(self.payto_e)
completer.setCaseSensitivity(False)
self.payto_e.set_completer(completer)
completer.setModel(self.completions)
msg = (
_("Description of the transaction (not mandatory).")
+ "\n\n"
+ _(
"The description is not sent to the recipient of the funds. It is"
" stored in your wallet file, and displayed in the 'History' tab."
)
)
description_label = HelpLabel(_("&Description"), msg)
grid.addWidget(description_label, 2, 0)
self.message_e = MyLineEdit()
description_label.setBuddy(self.message_e)
grid.addWidget(self.message_e, 2, 1, 1, -1)
msg_opreturn = (
_("OP_RETURN data (optional).")
+ "\n\n"
+ _(
f"Posts a PERMANENT note to the {CURRENCY} "
"blockchain as part of this transaction."
)
+ "\n\n"
+ _(
"If you specify OP_RETURN text, you may leave the 'Pay to' field blank."
)
)
self.opreturn_label = HelpLabel(_("&OP_RETURN"), msg_opreturn)
grid.addWidget(self.opreturn_label, 3, 0)
self.message_opreturn_e = MyLineEdit()
self.opreturn_label.setBuddy(self.message_opreturn_e)
hbox = QtWidgets.QHBoxLayout()
hbox.addWidget(self.message_opreturn_e)
self.opreturn_rawhex_cb = QtWidgets.QCheckBox(_("&Raw hex script"))
self.opreturn_rawhex_cb.setToolTip(
_(
"If unchecked, the textbox contents are UTF8-encoded into a single-push"
" script: <tt>OP_RETURN PUSH &lt;text&gt;</tt>. If checked, the text"
" contents will be interpreted as a raw hexadecimal script to be"
" appended after the OP_RETURN opcode: <tt>OP_RETURN"
" &lt;script&gt;</tt>."
)
)
hbox.addWidget(self.opreturn_rawhex_cb)
self.opreturn_shuffle_outputs_cb = QtWidgets.QCheckBox(_("Shuffle outputs"))
self.opreturn_shuffle_outputs_cb.setChecked(True)
self.opreturn_shuffle_outputs_cb.setEnabled(
self.message_opreturn_e.text() != ""
)
self.opreturn_shuffle_outputs_cb.setToolTip(
_(
"<p>For some OP_RETURN use cases such as SLP, the order of the outputs"
" in the transaction matters, so you might want to uncheck this. By"
" default, outputs are shuffled for privacy reasons. This setting is "
"ignored if the OP_RETURN data is empty.</p>"
)
)
hbox.addWidget(self.opreturn_shuffle_outputs_cb)
grid.addLayout(hbox, 3, 1, 1, -1)
self.message_opreturn_e.textChanged.connect(
lambda text: self.opreturn_shuffle_outputs_cb.setEnabled(bool(text))
)
self.send_tab_opreturn_widgets = [
self.message_opreturn_e,
self.opreturn_rawhex_cb,
self.opreturn_shuffle_outputs_cb,
self.opreturn_label,
]
self.from_label = QtWidgets.QLabel(_("&From"))
grid.addWidget(self.from_label, 4, 0)
self.from_list = MyTreeWidget(["", ""], self.config, self.wallet)
self.from_list.customContextMenuRequested.connect(self.from_list_menu)
self.from_label.setBuddy(self.from_list)
self.from_list.setHeaderHidden(True)
self.from_list.setMaximumHeight(80)
grid.addWidget(self.from_list, 4, 1, 1, -1)
self.set_pay_from([])
msg = (
_("Amount to be sent.")
+ "\n\n"
+ _(
"The amount will be displayed in red if you do not have enough funds in"
" your wallet."
)
+ " "
+ _(
"Note that if you have frozen some of your addresses, the available"
" funds will be lower than your total balance."
)
+ "\n\n"
+ _('Keyboard shortcut: type "!" to send all your coins.')
)
amount_label = HelpLabel(_("&Amount"), msg)
amount_label.setBuddy(self.amount_e)
grid.addWidget(amount_label, 5, 0)
grid.addWidget(self.amount_e, 5, 1)
self.fiat_send_e = AmountEdit(self.fx.get_currency() if self.fx else "")
if not self.fx or not self.fx.is_enabled():
self.fiat_send_e.setVisible(False)
grid.addWidget(self.fiat_send_e, 5, 2)
self.amount_e.frozen.connect(
lambda: self.fiat_send_e.setFrozen(self.amount_e.isReadOnly())
)
self.max_button = EnterButton(_("&Max"), self.spend_max)
self.max_button.setFixedWidth(self.amount_e.width())
self.max_button.setCheckable(True)
grid.addWidget(self.max_button, 5, 3)
hbox = self.send_tab_extra_plugin_controls_hbox = QtWidgets.QHBoxLayout()
hbox.addStretch(1)
grid.addLayout(hbox, 5, 4, 1, -1)
msg = (
_(
f"{CURRENCY} transactions are in general not free. A transaction fee is"
" paid by the sender of the funds."
)
+ "\n\n"
+ _(
"The amount of fee can be decided freely by the sender. However, "
"transactions with low fees take more time to be processed."
)
+ "\n\n"
+ _(
"A suggested fee is automatically added to this field. You may "
"override it. The suggested fee increases with the size of the "
"transaction."
)
)
self.fee_e_label = HelpLabel(_("F&ee"), msg)
def fee_cb(dyn, pos, fee_rate):
if dyn:
self.config.set_key("fee_level", pos, False)
else:
self.config.set_key("fee_per_kb", fee_rate, False)
self.spend_max() if self.max_button.isChecked() else self.update_fee()
self.fee_slider = FeeSlider(self, self.config, fee_cb)
self.fee_e_label.setBuddy(self.fee_slider)
self.fee_slider.setFixedWidth(self.amount_e.width())
self.fee_custom_lbl = HelpLabel(
self.get_custom_fee_text(),
_("This is the fee rate that will be used for this transaction.")
+ "\n\n"
+ _(
"It is calculated from the Custom Fee Rate in preferences, but can be"
" overridden from the manual fee edit on this form (if enabled)."
)
+ "\n\n"
+ _(
"Generally, a fee of 1.0 sats/B is a good minimal rate to ensure your"
" transaction will make it into the next block."
),
)
self.fee_custom_lbl.setFixedWidth(self.amount_e.width())
self.fee_slider_mogrifier()
self.fee_e = XECAmountEdit(self.get_decimal_point())
if not self.config.get("show_fee", False):
self.fee_e.setVisible(False)
self.fee_e.textEdited.connect(self.update_fee)
# This is so that when the user blanks the fee and moves on,
# we go back to auto-calculate mode and put a fee back.
self.fee_e.editingFinished.connect(self.update_fee)
self.connect_fields(self, self.amount_e, self.fiat_send_e, self.fee_e)
grid.addWidget(self.fee_e_label, 6, 0)
grid.addWidget(self.fee_slider, 6, 1)
grid.addWidget(self.fee_custom_lbl, 6, 1)
grid.addWidget(self.fee_e, 6, 2)
self.preview_button = EnterButton(_("&Preview"), self.do_preview)
self.preview_button.setToolTip(
_("Display the details of your transactions before signing it.")
)
self.send_button = EnterButton(_("&Send"), self.do_send)
self.clear_button = EnterButton(_("&Clear"), self.do_clear)
buttons = QtWidgets.QHBoxLayout()
buttons.addStretch(1)
buttons.addWidget(self.clear_button)
buttons.addWidget(self.preview_button)
buttons.addWidget(self.send_button)
grid.addLayout(buttons, 7, 1, 1, 3)
self.payto_e.textChanged.connect(
self.update_buttons_on_seed
) # hide/unhide various buttons
self.amount_e.shortcut.connect(self.spend_max)
self.payto_e.textChanged.connect(self.update_fee)
self.amount_e.textEdited.connect(self.update_fee)
self.message_opreturn_e.textEdited.connect(self.update_fee)
self.message_opreturn_e.textChanged.connect(self.update_fee)
self.message_opreturn_e.editingFinished.connect(self.update_fee)
self.opreturn_rawhex_cb.stateChanged.connect(self.update_fee)
def reset_max(text):
self.max_button.setChecked(False)
enabled = not bool(text) and not self.amount_e.isReadOnly()
self.max_button.setEnabled(enabled)
self.amount_e.textEdited.connect(reset_max)
self.fiat_send_e.textEdited.connect(reset_max)
def entry_changed():
text = ""
if self.not_enough_funds:
amt_color, fee_color = ColorScheme.RED, ColorScheme.RED
text = _("Not enough funds")
c, u, x = self.wallet.get_frozen_balance()
if c + u + x:
text += (
" ("
+ self.format_amount(c + u + x).strip()
+ " "
+ self.base_unit()
+ " "
+ _("are frozen")
+ ")"
)
extra = run_hook("not_enough_funds_extra", self)
if isinstance(extra, str) and extra:
text += " ({})".format(extra)
elif self.fee_e.isModified():
amt_color, fee_color = ColorScheme.DEFAULT, ColorScheme.DEFAULT
elif self.amount_e.isModified():
amt_color, fee_color = ColorScheme.DEFAULT, ColorScheme.BLUE
else:
amt_color, fee_color = ColorScheme.BLUE, ColorScheme.BLUE
opret_color = ColorScheme.DEFAULT
if self.op_return_toolong:
opret_color = ColorScheme.RED
text = (
_(
"OP_RETURN message too large, needs to be no longer than 220"
" bytes"
)
+ (", " if text else "")
+ text
)
self.statusBar().showMessage(text)
self.amount_e.setStyleSheet(amt_color.as_stylesheet())
self.fee_e.setStyleSheet(fee_color.as_stylesheet())
self.message_opreturn_e.setStyleSheet(opret_color.as_stylesheet())
self.amount_e.textChanged.connect(entry_changed)
self.fee_e.textChanged.connect(entry_changed)
self.message_opreturn_e.textChanged.connect(entry_changed)
self.message_opreturn_e.textEdited.connect(entry_changed)
self.message_opreturn_e.editingFinished.connect(entry_changed)
self.opreturn_rawhex_cb.stateChanged.connect(entry_changed)
self.invoices_label = QtWidgets.QLabel(_("Invoices"))
self.invoice_list = InvoiceList(self)
self.invoice_list.chkVisible()
vbox0 = QtWidgets.QVBoxLayout()
vbox0.addLayout(grid)
hbox = QtWidgets.QHBoxLayout()
hbox.addLayout(vbox0)
w = QtWidgets.QWidget()
vbox = QtWidgets.QVBoxLayout(w)
vbox.addLayout(hbox)
vbox.addStretch(1)
vbox.addWidget(self.invoices_label)
vbox.addWidget(self.invoice_list)
vbox.setStretchFactor(self.invoice_list, 1000)
w.searchable_list = self.invoice_list
run_hook("create_send_tab", grid)
return w
def spend_max(self):
self.max_button.setChecked(True)
self.do_update_fee()
def update_fee(self):
self.require_fee_update = True
def get_payto_or_dummy(self):
r = self.payto_e.get_recipient()
if r:
return r
return (TYPE_ADDRESS, self.wallet.dummy_address())
def get_custom_fee_text(self, fee_rate=None):
if not self.config.has_custom_fee_rate():
return ""
else:
if fee_rate is None:
fee_rate = self.config.custom_fee_rate() / 1000.0
return str(round(fee_rate * 100) / 100) + " sats/B"
def do_update_fee(self):
"""Recalculate the fee. If the fee was manually input, retain it, but
still build the TX to see if there are enough funds.
"""
freeze_fee = self.fee_e.isModified() and (
self.fee_e.text() or self.fee_e.hasFocus()
)
amount = "!" if self.max_button.isChecked() else self.amount_e.get_amount()
fee_rate = None
if amount is None:
if not freeze_fee:
self.fee_e.setAmount(None)
self.not_enough_funds = False
self.statusBar().showMessage("")
else:
fee = self.fee_e.get_amount() if freeze_fee else None
outputs = self.payto_e.get_outputs(self.max_button.isChecked())
if not outputs:
_type, addr = self.get_payto_or_dummy()
outputs = [TxOutput(_type, addr, amount)]
try:
opreturn_message = (
self.message_opreturn_e.text()
if self.config.get("enable_opreturn")
else None
)
if opreturn_message:
if self.opreturn_rawhex_cb.isChecked():
outputs.append(OPReturn.output_for_rawhex(opreturn_message))
else:
outputs.append(OPReturn.output_for_stringdata(opreturn_message))
tx = self.wallet.make_unsigned_transaction(
self.get_coins(), outputs, self.config, fee
)
self.not_enough_funds = False
self.op_return_toolong = False
except NotEnoughFunds:
self.not_enough_funds = True
if not freeze_fee:
self.fee_e.setAmount(None)
return
except OPReturn.TooLarge:
self.op_return_toolong = True
return
except OPReturn.Error as e:
self.statusBar().showMessage(str(e))
return
except Exception:
return
if not freeze_fee:
fee = None if self.not_enough_funds else tx.get_fee()
self.fee_e.setAmount(fee)
if self.max_button.isChecked():
amount = tx.output_value()
self.amount_e.setAmount(amount)
if fee is not None:
fee_rate = fee / tx.estimated_size()
self.fee_slider_mogrifier(self.get_custom_fee_text(fee_rate))
def fee_slider_mogrifier(self, text=None):
fee_slider_hidden = self.config.has_custom_fee_rate()
self.fee_slider.setHidden(fee_slider_hidden)
self.fee_custom_lbl.setHidden(not fee_slider_hidden)
if text is not None:
self.fee_custom_lbl.setText(text)
def from_list_delete(self, name):
item = self.from_list.currentItem()
if (
item
and item.data(0, Qt.UserRole) == name
and not item.data(0, Qt.UserRole + 1)
):
i = self.from_list.indexOfTopLevelItem(item)
try:
self.pay_from.pop(i)
except IndexError:
# The list may contain items not in the pay_from if added by a
# plugin using the spendable_coin_filter hook
pass
self.redraw_from_list()
self.update_fee()
def from_list_menu(self, position):
item = self.from_list.itemAt(position)
if not item:
return
menu = QtWidgets.QMenu()
name = item.data(0, Qt.UserRole)
action = menu.addAction(_("Remove"), lambda: self.from_list_delete(name))
if item.data(0, Qt.UserRole + 1):
action.setText(_("Not Removable"))
action.setDisabled(True)
menu.exec_(self.from_list.viewport().mapToGlobal(position))
def set_pay_from(self, coins):
self.pay_from = list(coins)
self.redraw_from_list()
def redraw_from_list(self, *, spendable=None):
"""Optional kwarg spendable indicates *which* of the UTXOs in the
self.pay_from list are actually spendable. If this arg is specified,
coins in the self.pay_from list that aren't also in the 'spendable' list
will be grayed out in the UI, to indicate that they will not be used.
Otherwise all coins will be non-gray (default).
(Added for CashShuffle 02/23/2019)"""
sel = self.from_list.currentItem() and self.from_list.currentItem().data(
0, Qt.UserRole
)
self.from_list.clear()
self.from_label.setHidden(len(self.pay_from) == 0)
self.from_list.setHidden(len(self.pay_from) == 0)
def name(x):
return "{}:{}".format(x["prevout_hash"], x["prevout_n"])
def format_outpoint_and_address(x):
h = x["prevout_hash"]
return "{}...{}:{:d}\t{}".format(
h[0:10], h[-10:], x["prevout_n"], x["address"]
)
def grayify(twi):
b = twi.foreground(0)
b.setColor(Qt.gray)
for i in range(twi.columnCount()):
twi.setForeground(i, b)
def new(item, is_unremovable=False):
ret = QtWidgets.QTreeWidgetItem(
[format_outpoint_and_address(item), self.format_amount(item["value"])]
)
ret.setData(0, Qt.UserRole, name(item))
ret.setData(0, Qt.UserRole + 1, is_unremovable)
return ret
for item in self.pay_from:
twi = new(item)
if spendable is not None and item not in spendable:
grayify(twi)
self.from_list.addTopLevelItem(twi)
if name(item) == sel:
self.from_list.setCurrentItem(twi)
if spendable is not None: # spendable may be None if no plugin filtered coins.
for item in spendable:
# append items added by the plugin to the spendable list
# at the bottom. These coins are marked as "not removable"
# in the UI (the plugin basically insisted these coins must
# be spent with the other coins in the list for privacy).
if item not in self.pay_from:
twi = new(item, True)
self.from_list.addTopLevelItem(twi)
if name(item) == sel:
self.from_list.setCurrentItem(twi)
def get_contact_payto(self, contact: Contact) -> str:
assert isinstance(contact, Contact)
_type, label = contact.type, contact.name
if _type == "openalias":
return contact.address
return label + " " + "<" + contact.address + ">"
def update_completions(self):
contact_paytos = []
for contact in self.contact_list.get_full_contacts():
s = self.get_contact_payto(contact)
if s is not None:
contact_paytos.append(s)
# case-insensitive sort
contact_paytos.sort(key=lambda x: x.lower())
self.completions.setStringList(contact_paytos)
def protected(func):
"""Password request wrapper. The password is passed to the function
as the 'password' named argument. "None" indicates either an
unencrypted wallet, or the user cancelled the password request.
An empty input is passed as the empty string."""
def request_password(self, *args, **kwargs):
parent = self.top_level_window()
password = None
on_pw_cancel = kwargs.pop("on_pw_cancel", None)
while self.wallet.has_keystore_encryption():
password = self.password_dialog(parent=parent)
if password is None:
# User cancelled password input
if callable(on_pw_cancel):
on_pw_cancel()
return
try:
self.wallet.check_password(password)
break
except Exception as e:
self.show_error(str(e), parent=parent)
continue
kwargs["password"] = password
return func(self, *args, **kwargs)
return request_password
def read_send_tab(self):
isInvoice = False
if self.payment_request and self.payment_request.has_expired():
self.show_error(_("Payment request has expired"))
return
label = self.message_e.text()
if self.payment_request:
isInvoice = True
outputs = self.payment_request.get_outputs()
else:
errors = self.payto_e.get_errors()
if errors:
self.show_warning(
_("Invalid lines found:")
+ "\n\n"
+ "\n".join(
[_("Line #") + str(x[0] + 1) + ": " + x[1] for x in errors]
)
)
return
outputs = self.payto_e.get_outputs(self.max_button.isChecked())
if self.payto_e.is_alias and not self.payto_e.validated:
alias = self.payto_e.toPlainText()
msg = (
_(
'WARNING: the alias "{}" could not be validated via an'
" additional security check, DNSSEC, and thus may not be"
" correct."
).format(alias)
+ "\n"
)
msg += _("Do you wish to continue?")
if not self.question(msg):
return
try:
# handle op_return if specified and enabled
opreturn_message = self.message_opreturn_e.text()
if opreturn_message:
if self.opreturn_rawhex_cb.isChecked():
outputs.append(OPReturn.output_for_rawhex(opreturn_message))
else:
outputs.append(OPReturn.output_for_stringdata(opreturn_message))
except OPReturn.TooLarge as e:
self.show_error(str(e))
return
except OPReturn.Error as e:
self.show_error(str(e))
return
if not outputs:
self.show_error(_("No outputs"))
return
for o in outputs:
if o.value is None:
self.show_error(_("Invalid Amount"))
return
freeze_fee = (
self.fee_e.isVisible()
and self.fee_e.isModified()
and (self.fee_e.text() or self.fee_e.hasFocus())
)
fee = self.fee_e.get_amount() if freeze_fee else None
coins = self.get_coins(isInvoice)
return outputs, fee, label, coins
def _chk_no_segwit_suspects(self):
"""Makes sure the payto_e has no addresses that might be BTC segwit
in it and if it does, warn user. Intended to be called from do_send.
Returns True if no segwit suspects were detected in the payto_e,
False otherwise. If False is returned, a suitable error dialog
will have already been presented to the user."""
if bool(self.config.get("allow_legacy_p2sh", False)):
return True
segwits = set()
prefix_char = "3" if not networks.net.TESTNET else "2"
for line in self.payto_e.lines():
line = line.strip()
if ":" in line and line.lower().startswith(
networks.net.CASHADDR_PREFIX + ":"
):
# strip ecash: prefix
line = line.split(":", 1)[1]
if "," in line:
line = line.split(",", 1)[
0
] # if address, amount line, strip address out and ignore rest
line = line.strip()
if line.startswith(prefix_char) and Address.is_valid(line):
segwits.add(line)
if segwits:
msg = ngettext(
"Possible BTC Segwit address in 'Pay to' field. Please use CashAddr"
" format for p2sh addresses.\n\n{segwit_addresses}",
"Possible BTC Segwit addresses in 'Pay to' field. Please use"
" CashAddr format for p2sh addresses.\n\n{segwit_addresses}",
len(segwits),
).format(segwit_addresses="\n".join(segwits))
detail = _(
"Legacy '{prefix_char}...' p2sh address support in the Send tab is "
"restricted by default in order to prevent inadvertently "
f"sending {CURRENCY} to Segwit BTC addresses.\n\n"
"If you are an expert user, go to 'Preferences -> Transactions' "
"to enable the use of legacy p2sh addresses in the Send tab."
).format(prefix_char=prefix_char)
self.show_error(msg, detail_text=detail)
return False
return True
def _warn_if_legacy_address(self):
"""Show a warning if self.payto_e has legacy addresses, since the user
might be trying to send BTC instead of BCHA."""
warn_legacy_address = bool(self.config.get("warn_legacy_address", True))
if not warn_legacy_address:
return
for line in self.payto_e.lines():
line = line.strip()
if line.lower().startswith(networks.net.CASHADDR_PREFIX + ":"):
# strip "ecash:" prefix
line = line.split(":", 1)[1]
if "," in line:
# if address, amount line, strip address out and ignore rest
line = line.split(",", 1)[0]
line = line.strip()
if Address.is_legacy(line):
msg1 = (
_("You are about to send {} to a legacy address.").format(CURRENCY)
+ "<br><br>"
+ _(
"Legacy addresses are deprecated for {} "
", and used by Bitcoin (BTC)."
).format(CURRENCY)
)
msg2 = _("Proceed if what you intend to do is to send {}.").format(
CURRENCY
)
msg3 = _(
"If you intend to send BTC, close the application "
"and use a BTC wallet instead. {} is a "
"{} wallet, not a BTC wallet."
).format(PROJECT_NAME, CURRENCY)
res = self.msg_box(
parent=self,
icon=QtWidgets.QMessageBox.Warning,
title=_("You are sending to a legacy address"),
rich_text=True,
text=msg1,
informative_text=msg2,
detail_text=msg3,
checkbox_text=_("Never show this again"),
checkbox_ischecked=False,
)
if res[1]: # Never ask if checked
self.config.set_key("warn_legacy_address", False)
break
def do_preview(self):
self.do_send(preview=True)
def do_send(self, preview=False):
if run_hook("abort_send", self):
return
# paranoia -- force a resolve right away in case user pasted an
# openalias and hit preview too quickly.
self.payto_e.resolve(force_if_has_focus=True)
if not self._chk_no_segwit_suspects():
return
self._warn_if_legacy_address()
r = self.read_send_tab()
if not r:
return
outputs, fee, tx_desc, coins = r
shuffle_outputs = True
if (
self.message_opreturn_e.isVisible()
and self.message_opreturn_e.text()
and not self.opreturn_shuffle_outputs_cb.isChecked()
):
shuffle_outputs = False
try:
tx = self.wallet.make_unsigned_transaction(
coins, outputs, self.config, fee, shuffle_outputs=shuffle_outputs
)
except NotEnoughFunds:
self.show_message(_("Insufficient funds"))
return
except ExcessiveFee:
self.show_message(_("Your fee is too high. Max is 50 sat/byte."))
return
except Exception as e:
traceback.print_exc(file=sys.stderr)
self.show_message(str(e))
return
amount = (
tx.output_value()
if self.max_button.isChecked()
else sum(x[2] for x in outputs)
)
fee = tx.get_fee()
if preview:
# NB: this ultimately takes a deepcopy of the tx in question
# (TxDialog always takes a deep copy).
self.show_transaction(tx, tx_desc)
return
# We must "freeze" the tx and take a deep copy of it here. This is
# because it's possible that it points to coins in self.pay_from and
# other shared data. We want the tx to be immutable from this point
# forward with its own private data. This fixes a bug where sometimes
# the tx would stop being "is_complete" randomly after broadcast!
tx = copy.deepcopy(tx)
# confirmation dialog
msg = [
_("Amount to be sent") + ": " + self.format_amount_and_units(amount),
_("Mining fee") + ": " + self.format_amount_and_units(fee),
]
if fee < (tx.estimated_size()):
msg.append(
_("Warning")
+ ": "
+ _(
"You're using a fee of less than 1.0 sats/B. It may take a very"
" long time to confirm."
)
)
tx.ephemeral["warned_low_fee_already"] = True
if self.config.get("enable_opreturn") and self.message_opreturn_e.text():
msg.append(
_(
"You are using an OP_RETURN message. This gets permanently written"
" to the blockchain."
)
)
if self.wallet.has_keystore_encryption():
msg.append("")
msg.append(_("Enter your password to proceed"))
password = self.password_dialog("\n".join(msg))
if not password:
return
else:
msg.append(_("Proceed?"))
password = None
if not self.question("\n\n".join(msg)):
return
def sign_done(success):
if success:
if not tx.is_complete():
self.show_transaction(tx, tx_desc)
self.do_clear()
else:
self.broadcast_transaction(tx, tx_desc)
self.sign_tx_with_password(tx, sign_done, password)
@protected
def sign_tx(self, tx, callback, password):
self.sign_tx_with_password(tx, callback, password)
def sign_tx_with_password(self, tx, callback, password):
"""Sign the transaction in a separate thread. When done, calls
the callback with a success code of True or False.
"""
# call hook to see if plugin needs gui interaction
run_hook("sign_tx", self, tx)
def on_signed(result):
callback(True)
def on_failed(exc_info):
self.on_error(exc_info)
callback(False)
if self.tx_external_keypairs:
task = partial(
Transaction.sign, tx, self.tx_external_keypairs, use_cache=True
)
else:
task = partial(self.wallet.sign_transaction, tx, password, use_cache=True)
WaitingDialog(self, _("Signing transaction..."), task, on_signed, on_failed)
def broadcast_transaction(
self,
tx: Transaction,
tx_desc,
*,
callback: Optional[Callable[[bool], None]] = None,
):
if self.gui_object.warn_if_no_network(self):
# Don't allow a useless broadcast when in offline mode. Previous to this
# we were getting an exception on broadcast.
return
# Capture current TL window; override might be removed on return
parent = self.top_level_window()
if not self.network.is_connected():
# Don't allow a potentially very slow broadcast when obviously not connected.
parent.show_error(_("Not connected"))
return
# Check fee and warn if it's below 1.0 sats/B (and not warned already)
fee = None
try:
fee = tx.get_fee()
except Exception:
# no fee info available for tx
pass
# Check fee >= size otherwise warn. FIXME: If someday network relay
# rules change to be other than 1.0 sats/B minimum, this code needs
# to be changed.
if (
fee is not None
and tx.is_complete()
and fee < tx.estimated_size()
and not tx.ephemeral.get("warned_low_fee_already")
):
msg = (
_("Warning")
+ ": "
+ _(
"You're using a fee of less than 1.0 sats/B. It may take a very"
" long time to confirm."
)
+ "\n\n"
+ _("Proceed?")
)
if not self.question(msg, title=_("Low Fee")):
return
def broadcast_thread() -> Tuple[bool, str]:
# non-GUI thread
pr = self.payment_request
if not pr:
# Not a PR, just broadcast.
return self.network.broadcast_transaction(tx)
if pr.has_expired():
self.payment_request = None
return False, _("Payment request has expired")
refund_address = self.wallet.get_receiving_addresses()[0]
ack_status, ack_msg = pr.send_payment(str(tx), refund_address)
if not ack_status:
if ack_msg == "no url":
# "no url" hard-coded in send_payment method
# it means merchant doesn't need the tx sent to him
# since he didn't specify a POST url.
# so we just broadcast and rely on that result status.
ack_msg = None
else:
return False, ack_msg
# at this point either ack_status is True or there is "no url"
# and we proceed anyway with the broadcast
status, msg = self.network.broadcast_transaction(tx)
# prefer the merchant's ack_msg over the broadcast msg, but fallback
# to broadcast msg if no ack_msg.
msg = ack_msg or msg
# if both broadcast and merchant ACK failed -- it's a failure. if
# either succeeded -- it's a success
status = bool(ack_status or status)
if status:
self.invoices.set_paid(pr, tx.txid())
self.invoices.save()
self.payment_request = None
return status, msg
def broadcast_done(result: Optional[Tuple[bool, str]]):
# GUI thread
status, msg = result or (False, "")
if not status:
if msg.startswith("error: "):
# take the last part, sans the "error: " prefix
msg = msg.split(" ", 1)[-1]
if msg:
parent.show_error(msg)
if callback:
callback(False)
return
buttons, copy_index, copy_link = [_("Ok")], None, ""
try:
# returns None if not is_complete, but may raise potentially as well
txid = tx.txid()
except Exception:
txid = None
if txid is not None:
if tx_desc is not None:
self.wallet.set_label(txid, tx_desc)
copy_link = web.BE_URL(self.config, web.ExplorerUrlParts.TX, txid)
if copy_link:
# tx is complete and there is a copy_link
buttons.insert(0, _("Copy link"))
copy_index = 0
if (
parent.show_message(
_("Payment sent.") + "\n" + msg,
buttons=buttons,
defaultButton=buttons[-1],
escapeButton=buttons[-1],
)
== copy_index
):
# There WAS a 'Copy link' and they clicked it
self.copy_to_clipboard(
copy_link,
_("Block explorer link copied to clipboard"),
self.top_level_window(),
)
self.invoice_list.update()
self.do_clear()
if callback:
callback(True)
WaitingDialog(
self,
_("Broadcasting transaction..."),
broadcast_thread,
broadcast_done,
self.on_error,
)
def query_choice(self, msg, choices, *, add_cancel_button=False):
# Needed by QtHandler for hardware wallets
dialog = WindowModalDialog(self.top_level_window())
clayout = ChoicesLayout(msg, choices)
vbox = QtWidgets.QVBoxLayout(dialog)
vbox.addLayout(clayout.layout())
buts = [OkButton(dialog)]
if add_cancel_button:
buts.insert(0, CancelButton(dialog))
vbox.addLayout(Buttons(*buts))
result = dialog.exec_()
dialog.setParent(None)
if not result:
return None
return clayout.selected_index()
def lock_amount(self, b):
self.amount_e.setFrozen(b)
self.max_button.setEnabled(not b)
def prepare_for_payment_request(self):
self.show_send_tab()
self.payto_e.is_pr = True
for e in [self.payto_e, self.amount_e, self.message_e]:
e.setFrozen(True)
self.max_button.setDisabled(True)
self.payto_e.setText(_("please wait..."))
return True
def delete_invoice(self, key):
self.invoices.remove(key)
self.invoice_list.update()
def payment_request_ok(self):
pr = self.payment_request
key = self.invoices.add(pr)
status = self.invoices.get_status(key)
self.invoice_list.update()
if status == PR_PAID:
self.show_message("invoice already paid")
self.do_clear()
self.payment_request = None
return
self.payto_e.is_pr = True
if not pr.has_expired():
self.payto_e.setGreen()
else:
self.payto_e.setExpired()
self.payto_e.setText(pr.get_requestor())
self.amount_e.setText(
format_satoshis_plain(pr.get_amount(), self.get_decimal_point())
)
self.message_e.setText(pr.get_memo())
# signal to set fee
self.amount_e.textEdited.emit("")
def payment_request_error(self):
request_error = (self.payment_request and self.payment_request.error) or ""
self.payment_request = None
self.print_error("PaymentRequest error:", request_error)
self.show_error(
_("There was an error processing the payment request"),
rich_text=False,
detail_text=request_error,
)
self.do_clear()
def on_pr(self, request):
self.payment_request = request
if self.payment_request.verify(self.contacts):
self.payment_request_ok_signal.emit()
else:
self.payment_request_error_signal.emit()
def pay_to_URI(self, URI):
if not URI:
return
try:
out = web.parse_URI(URI, self.on_pr, strict=True, on_exc=self.on_error)
except web.ExtraParametersInURIWarning as e:
out = e.args[0] # out dict is in e.args[0]
extra_params = e.args[1:]
ShowPopupLabel(
name="`Pay to` error",
text=ngettext(
"Extra parameter in URI was ignored:\n\n{extra_params}",
"Extra parameters in URI were ignored:\n\n{extra_params}",
len(extra_params),
).format(extra_params=", ".join(extra_params)),
target=self.payto_e,
timeout=5000,
)
# fall through ...
except web.BadURIParameter as e:
extra_info = (len(e.args) > 1 and str(e.args[1])) or ""
self.print_error("Bad URI Parameter:", *[repr(i) for i in e.args])
if extra_info:
extra_info = "\n\n" + extra_info # prepend newlines
ShowPopupLabel(
name="`Pay to` error",
text=_("Bad parameter: {bad_param_name}{extra_info}").format(
bad_param_name=e.args[0], extra_info=extra_info
),
target=self.payto_e,
timeout=5000,
)
return
except web.DuplicateKeyInURIError as e:
# this exception always has a translated message as args[0]
# plus a list of keys as args[1:], see web.parse_URI
ShowPopupLabel(
name="`Pay to` error",
text=e.args[0] + ":\n\n" + ", ".join(e.args[1:]),
target=self.payto_e,
timeout=5000,
)
return
except Exception as e:
ShowPopupLabel(
name="`Pay to` error",
text=_("Invalid ecash URI:") + "\n\n" + str(e),
target=self.payto_e,
timeout=5000,
)
return
self.show_send_tab()
r = out.get("r")
if r:
self.prepare_for_payment_request()
return
addresses = out.get("addresses", [])
amounts = out.get("amounts", [])
if (len(addresses) == 1 and len(amounts) > 1) or (
len(addresses) != 1 and len(addresses) != len(amounts)
):
ShowPopupLabel(
name="`Pay to` error",
text=_("Inconsistent number of addresses and amounts in ecash URI:")
+ f" {len(addresses)} addresses and {len(amounts)} amounts",
target=self.payto_e,
timeout=5000,
)
return
label = out.get("label")
message = out.get("message")
op_return = out.get("op_return")
op_return_raw = out.get("op_return_raw")
# use label as description (not BIP21 compliant)
if label and not message:
message = label
if len(amounts) == 1:
self.amount_e.setAmount(amounts[0])
self.amount_e.textEdited.emit("")
if len(addresses) == 1:
# if address, set the payto field to the address.
self.payto_e.setText(addresses[0])
elif (
len(addresses) == 0
and URI.strip().lower().split(":", 1)[0] in web.parseable_schemes()
):
# if *not* address, then we set the payto field to the empty string
# only IFF it was ecash:, see issue Electron-Cash#1131.
self.payto_e.setText("")
elif len(addresses) > 1:
# For multiple outputs, we fill the payto field with the expected CSV
# string. Note that amounts are in sats and we convert them to XEC.
assert len(addresses) == len(amounts)
self.payto_e.setText(
"\n".join(
f"{addr}, {format_satoshis_plain(amount, self.get_decimal_point())}"
for addr, amount in zip(addresses, amounts)
)
)
if message:
self.message_e.setText(message)
if op_return:
self.message_opreturn_e.setText(op_return)
self.message_opreturn_e.setHidden(False)
self.opreturn_rawhex_cb.setHidden(False)
self.opreturn_rawhex_cb.setChecked(False)
self.opreturn_label.setHidden(False)
elif op_return_raw is not None:
# 'is not None' allows blank value.
# op_return_raw is secondary precedence to op_return
if not op_return_raw:
op_return_raw = "empty"
self.message_opreturn_e.setText(op_return_raw)
self.message_opreturn_e.setHidden(False)
self.opreturn_rawhex_cb.setHidden(False)
self.opreturn_rawhex_cb.setChecked(True)
self.opreturn_label.setHidden(False)
elif not self.config.get("enable_opreturn"):
self.message_opreturn_e.setText("")
self.message_opreturn_e.setHidden(True)
self.opreturn_rawhex_cb.setHidden(True)
self.opreturn_label.setHidden(True)
total_amount = sum(amounts)
if self.amount_exceeds_warning_threshold(total_amount):
# The user is one click away from broadcasting a tx prefilled by a URI.
# If the amount is significant, warn them about it.
self.show_warning(
_(
"The amount field has been populated by a BIP21 payment URI with a "
"value of {amount_and_unit}. Please check the amount and destination "
"carefully before sending the transaction."
).format(amount_and_unit=self.format_amount_and_units(total_amount))
)
def amount_exceeds_warning_threshold(self, amount_sats: int) -> bool:
USD_THRESHOLD = 100
XEC_THRESHOLD = 3_000_000
rate = self.fx.exchange_rate("USD") if self.fx else None
sats_per_unit = self.fx.satoshis_per_unit()
amount_xec = PyDecimal(amount_sats) / PyDecimal(sats_per_unit)
if rate is not None:
return amount_xec * rate >= USD_THRESHOLD
return amount_xec >= XEC_THRESHOLD
def do_clear(self):
"""Clears the send tab, resetting its UI state to its initiatial state."""
self.max_button.setChecked(False)
self.not_enough_funds = False
self.op_return_toolong = False
self.payment_request = None
self.payto_e.is_pr = False
self.payto_e.is_alias, self.payto_e.validated = (
False,
False,
) # clear flags to avoid bad things
for e in [
self.payto_e,
self.message_e,
self.amount_e,
self.fiat_send_e,
self.fee_e,
self.message_opreturn_e,
]:
e.setText("")
e.setFrozen(False)
self.payto_e.setHidden(False)
self.payto_label.setHidden(False)
self.max_button.setDisabled(False)
self.opreturn_rawhex_cb.setChecked(False)
self.opreturn_rawhex_cb.setDisabled(False)
self.set_pay_from([])
self.tx_external_keypairs = {}
self.message_opreturn_e.setVisible(self.config.get("enable_opreturn", False))
self.opreturn_rawhex_cb.setVisible(self.config.get("enable_opreturn", False))
self.opreturn_label.setVisible(self.config.get("enable_opreturn", False))
self.update_status()
run_hook("do_clear", self)
def set_frozen_state(self, addrs, freeze):
self.wallet.set_frozen_state(addrs, freeze)
self.address_list.update()
self.utxo_list.update()
self.update_fee()
def set_frozen_coin_state(self, utxos, freeze):
self.wallet.set_frozen_coin_state(utxos, freeze)
self.utxo_list.update()
self.update_fee()
def create_converter_tab(self):
source_address = QtWidgets.QLineEdit()
cash_address = ButtonsLineEdit()
cash_address.addCopyButton()
cash_address.setReadOnly(True)
cash_address_bch = ButtonsLineEdit()
cash_address_bch.addCopyButton()
cash_address_bch.setReadOnly(True)
legacy_address = ButtonsLineEdit()
legacy_address.addCopyButton()
legacy_address.setReadOnly(True)
widgets = [
(cash_address, Address.FMT_CASHADDR),
(cash_address_bch, Address.FMT_CASHADDR_BCH),
(legacy_address, Address.FMT_LEGACY),
]
def convert_address():
try:
addr = Address.from_string(
source_address.text().strip(), support_arbitrary_prefix=True
)
except Exception:
addr = None
for widget, fmt in widgets:
if addr:
widget.setText(addr.to_full_string(fmt))
else:
widget.setText("")
source_address.textChanged.connect(convert_address)
w = QtWidgets.QWidget()
grid = QtWidgets.QGridLayout()
grid.setSpacing(15)
grid.setColumnStretch(1, 2)
grid.setColumnStretch(2, 1)
label = QtWidgets.QLabel(_("&Address to convert"))
label.setBuddy(source_address)
grid.addWidget(label, 0, 0)
grid.addWidget(source_address, 0, 1)
label = QtWidgets.QLabel(_("&Cash address"))
label.setBuddy(cash_address)
grid.addWidget(label, 1, 0)
grid.addWidget(cash_address, 1, 1)
label = QtWidgets.QLabel(_("&BCH address"))
label.setBuddy(cash_address_bch)
grid.addWidget(label, 2, 0)
grid.addWidget(cash_address_bch, 2, 1)
label = QtWidgets.QLabel(_("&Legacy address"))
label.setBuddy(legacy_address)
grid.addWidget(label, 3, 0)
grid.addWidget(legacy_address, 3, 1)
w.setLayout(grid)
label = WWLabel(
_(
f"This tool helps convert between address formats for {CURRENCY} "
"addresses.\nYou are encouraged to use the 'Cash address' "
"format."
)
)
vbox = QtWidgets.QVBoxLayout()
vbox.addWidget(label)
vbox.addWidget(w)
vbox.addStretch(1)
w = QtWidgets.QWidget()
w.setLayout(vbox)
return w
def create_addresses_tab(self):
address_list = AddressList(self)
address_list.edited.connect(self.update_labels)
address_list.selection_cleared.connect(self.status_bar.clear_selected_amount)
address_list.selected_amount_changed.connect(
lambda satoshis: self.status_bar.set_selected_amount(
self.format_amount_and_units(satoshis)
)
)
return address_list
def create_utxo_tab(self):
utxo_list = UTXOList(self)
utxo_list.selection_cleared.connect(self.status_bar.clear_selected_amount)
utxo_list.selected_amount_changed.connect(
lambda satoshis: self.status_bar.set_selected_amount(
self.format_amount_and_units(satoshis)
)
)
self.gui_object.addr_fmt_changed.connect(utxo_list.update)
utxo_list.edited.connect(self.update_labels)
return utxo_list
def remove_address(self, addr):
if self.question(
_("Do you want to remove {} from your wallet?".format(addr.to_ui_string()))
):
self.wallet.delete_address(addr)
self.update_tabs()
self.update_status()
self.clear_receive_tab()
def get_coins(self, isInvoice=False):
coins = []
if self.pay_from:
coins = copy.deepcopy(self.pay_from)
else:
coins = self.wallet.get_spendable_coins(None, self.config, isInvoice)
run_hook(
"spendable_coin_filter", self, coins
) # may modify coins -- used by CashShuffle if in shuffle = ENABLED mode.
if self.pay_from:
# coins may have been filtered, so indicate this in the UI
self.redraw_from_list(spendable=coins)
return coins
def spend_coins(self, coins):
self.set_pay_from(coins)
self.show_send_tab()
run_hook("on_spend_coins", self, coins)
self.update_fee()
def paytomany(self):
self.show_send_tab()
self.do_clear()
self.payto_e.paytomany()
msg = "\n".join(
[
_("Enter a list of outputs in the 'Pay to' field."),
_("One output per line."),
_("Format: address, amount"),
_("You may load a CSV file using the file icon."),
]
)
self.show_message(msg, title=_("Pay to many"))
def payto_contacts(self, contacts: List[Contact]):
paytos = []
for contact in contacts:
s = self.get_contact_payto(contact)
if s is not None:
paytos.append(s)
self.payto_payees(paytos)
def payto_payees(self, payees: List[str]):
"""Like payto_contacts except it accepts a list of free-form strings
rather than requiring a list of Contacts objects"""
self.show_send_tab()
if len(payees) == 1:
self.payto_e.setText(payees[0])
self.amount_e.setFocus()
else:
text = "\n".join([payee + ", 0" for payee in payees])
self.payto_e.setText(text)
self.payto_e.setFocus()
def on_contact_updated(self):
self.history_list.update()
# inform things like address_dialog that there's a new history
self.history_updated_signal.emit()
self.update_completions()
def show_invoice(self, key):
pr = self.invoices.get(key)
pr.verify(self.contacts)
self.show_pr_details(pr)
def show_pr_details(self, pr):
key = pr.get_id()
d = WindowModalDialog(self.top_level_window(), _("Invoice"))
vbox = QtWidgets.QVBoxLayout(d)
grid = QtWidgets.QGridLayout()
grid.addWidget(QtWidgets.QLabel(_("Requestor") + ":"), 0, 0)
grid.addWidget(QtWidgets.QLabel(pr.get_requestor()), 0, 1)
grid.addWidget(QtWidgets.QLabel(_("Amount") + ":"), 1, 0)
outputs_str = "\n".join(
self.format_amount(x[2]) + self.base_unit() + " @ " + x[1].to_ui_string()
for x in pr.get_outputs()
)
grid.addWidget(QtWidgets.QLabel(outputs_str), 1, 1)
expires = pr.get_expiration_date()
grid.addWidget(QtWidgets.QLabel(_("Memo") + ":"), 2, 0)
grid.addWidget(QtWidgets.QLabel(pr.get_memo()), 2, 1)
grid.addWidget(QtWidgets.QLabel(_("Signature") + ":"), 3, 0)
grid.addWidget(QtWidgets.QLabel(pr.get_verify_status()), 3, 1)
if expires:
grid.addWidget(QtWidgets.QLabel(_("Expires") + ":"), 4, 0)
grid.addWidget(QtWidgets.QLabel(format_time(expires)), 4, 1)
vbox.addLayout(grid)
weakD = Weak.ref(d)
def do_export():
ext = pr.export_file_ext()
fn = getSaveFileName(_("Save invoice to file"), "*." + ext, self.config)
if not fn:
return
with open(fn, "wb") as f:
f.write(pr.export_file_data())
self.show_message(_("Invoice saved as" + " " + fn))
exportButton = EnterButton(_("Save"), do_export)
def do_delete():
if self.question(_("Delete invoice?")):
self.invoices.remove(key)
self.history_list.update()
self.history_updated_signal.emit() # inform things like address_dialog that there's a new history
self.invoice_list.update()
d = weakD()
if d:
d.close()
deleteButton = EnterButton(_("Delete"), do_delete)
vbox.addLayout(Buttons(exportButton, deleteButton, CloseButton(d)))
d.exec_()
d.setParent(None) # So Python can GC
def do_pay_invoice(self, key):
pr = self.invoices.get(key)
self.payment_request = pr
self.prepare_for_payment_request()
pr.error = None # this forces verify() to re-run
if pr.verify(self.contacts):
self.payment_request_ok()
else:
self.payment_request_error()
def create_console_tab(self):
self.console = Console(wallet=self.wallet)
return self.console
def create_contacts_tab(self) -> ContactList:
contact_list = ContactList(self.contacts, self.config, self.wallet)
contact_list.contact_updated.connect(self.on_contact_updated)
contact_list.payto_contacts_triggered.connect(self.payto_contacts)
contact_list.sign_verify_message_triggered.connect(self.sign_verify_message)
self.gui_object.addr_fmt_changed.connect(contact_list.update)
return contact_list
def update_console(self):
console = self.console
console.history = self.config.get("console-history", [])
console.history_index = len(console.history)
console.updateNamespace(
{
"wallet": self.wallet,
"network": self.network,
"plugins": self.gui_object.plugins,
"window": self,
}
)
console.updateNamespace({"util": util, "bitcoin": bitcoin})
set_json = Weak(self.console.set_json)
c = commands.Commands(
self.config,
self.wallet,
self.network,
self.gui_object.daemon,
lambda: set_json(True),
)
methods = {}
password_getter = Weak(self.password_dialog)
def mkfunc(f, method):
return lambda *args, **kwargs: f(
method, *args, password_getter=password_getter, **kwargs
)
for m in dir(c):
if m[0] == "_" or m in ["network", "wallet", "config"]:
continue
methods[m] = mkfunc(c._run, m)
console.updateNamespace(methods)
def create_status_bar(self) -> StatusBar:
sb = StatusBar(self.gui_object)
self.setStatusBar(sb)
sb.search_box.textChanged.connect(self.do_search)
sb.password_button.clicked.connect(self.change_password_dialog)
sb.preferences_button.clicked.connect(self.settings_dialog)
sb.seed_button.clicked.connect(lambda _checked: self.show_seed_dialog())
sb.status_button.clicked.connect(
lambda: self.gui_object.show_network_dialog(self)
)
sb.update_available_button.clicked.connect(
lambda: self.gui_object.show_update_checker(self, skip_check=True)
)
return sb
def update_buttons_on_seed(self):
self.status_bar.update_buttons_on_seed(
self.wallet.has_seed(), self.wallet.may_have_password()
)
self.send_button.setVisible(not self.wallet.is_watching_only())
self.preview_button.setVisible(True)
def change_password_dialog(self):
from electrumabc.storage import STO_EV_XPUB_PW
if self.wallet.get_available_storage_encryption_version() == STO_EV_XPUB_PW:
d = ChangePasswordDialogForHW(self, self.wallet)
ok, encrypt_file = d.run()
if not ok:
return
try:
hw_dev_pw = self.wallet.keystore.get_password_for_storage_encryption()
except UserCancelled:
return
except Exception as e:
traceback.print_exc(file=sys.stderr)
self.show_error(str(e))
return
old_password = hw_dev_pw if self.wallet.has_password() else None
new_password = hw_dev_pw if encrypt_file else None
else:
d = ChangePasswordDialogForSW(self, self.wallet)
ok, old_password, new_password, encrypt_file = d.run()
if not ok:
return
try:
self.wallet.update_password(old_password, new_password, encrypt_file)
self.gui_object.cache_password(
self.wallet, None
) # clear password cache when user changes it, just in case
run_hook("on_new_password", self, old_password, new_password)
except Exception as e:
if is_verbose:
traceback.print_exc(file=sys.stderr)
self.show_error(_("Failed to update password") + "\n\n" + str(e))
return
msg = (
_("Password was updated successfully")
if self.wallet.has_password()
else _("Password is disabled, this wallet is not protected")
)
self.show_message(msg, title=_("Success"))
self.status_bar.update_lock_icon(self.wallet.has_password())
def get_passphrase_dialog(
self, msg: str, title: Optional[str] = None, *, permit_empty=False
) -> str:
d = PassphraseDialog(
self.wallet, self.top_level_window(), msg, title, permit_empty=permit_empty
)
return d.run()
def do_search(self, pattern: str):
"""Apply search text to all tabs. FIXME: if a plugin later is loaded
it will not receive the search filter -- but most plugins I know about
do not support searchable_list anyway, so hopefully it's a non-issue."""
for i in range(self.tabs.count()):
tab = self.tabs.widget(i)
searchable_list = None
if isinstance(tab, MyTreeWidget):
searchable_list = tab
elif hasattr(tab, "searchable_list"):
searchable_list = tab.searchable_list
if searchable_list is None:
return
searchable_list.filter(pattern)
def show_master_public_keys(self):
dialog = WindowModalDialog(self.top_level_window(), _("Wallet Information"))
dialog.setMinimumSize(500, 100)
mpk_list = self.wallet.get_master_public_keys()
vbox = QtWidgets.QVBoxLayout()
wallet_type = self.wallet.storage.get("wallet_type", "")
grid = QtWidgets.QGridLayout()
basename = os.path.basename(self.wallet.storage.path)
grid.addWidget(QtWidgets.QLabel(_("Wallet name") + ":"), 0, 0)
grid.addWidget(QtWidgets.QLabel(basename), 0, 1)
grid.addWidget(QtWidgets.QLabel(_("Wallet type") + ":"), 1, 0)
grid.addWidget(QtWidgets.QLabel(wallet_type), 1, 1)
grid.addWidget(QtWidgets.QLabel(_("Script type") + ":"), 2, 0)
grid.addWidget(QtWidgets.QLabel(self.wallet.txin_type), 2, 1)
vbox.addLayout(grid)
if self.wallet.is_deterministic():
mpk_text = ShowQRTextEdit()
mpk_text.setMaximumHeight(150)
mpk_text.addCopyButton()
def show_mpk(index):
mpk_text.setText(mpk_list[index])
# only show the combobox in case multiple accounts are available
if len(mpk_list) > 1:
def label(key):
if isinstance(self.wallet, MultisigWallet):
return _("cosigner") + " " + str(key + 1)
return ""
labels = [label(i) for i in range(len(mpk_list))]
labels_clayout = ChoicesLayout(
_("Master Public Keys"),
labels,
lambda clayout: show_mpk(clayout.selected_index()),
)
vbox.addLayout(labels_clayout.layout())
else:
vbox.addWidget(QtWidgets.QLabel(_("Master Public Key")))
show_mpk(0)
vbox.addWidget(mpk_text)
vbox.addStretch(1)
vbox.addLayout(Buttons(CloseButton(dialog)))
dialog.setLayout(vbox)
dialog.exec_()
def remove_wallet(self):
if self.question(
"\n".join(
[
_("Delete wallet file?"),
"%s" % self.wallet.storage.path,
_(
"If your wallet contains funds, make sure you have saved its"
" seed."
),
]
)
):
self._delete_wallet()
@protected
def _delete_wallet(self, password):
wallet_path = self.wallet.storage.path
basename = os.path.basename(wallet_path)
r = self.gui_object.daemon.delete_wallet(
wallet_path
) # implicitly also calls stop_wallet
self.update_recently_visited(
wallet_path
) # this ensures it's deleted from the menu
if r:
self.show_error(_("Wallet removed: {}").format(basename))
else:
self.show_error(_("Wallet file not found: {}").format(basename))
self.close()
@protected
def show_seed_dialog(self, password):
if not self.wallet.has_seed():
self.show_message(_("This wallet has no seed"))
return
keystore = self.wallet.get_keystore()
try:
seed = keystore.get_seed(password)
passphrase = keystore.get_passphrase(password) # may be None or ''
derivation = (
keystore.has_derivation() and keystore.derivation
) # may be None or ''
seed_type = getattr(keystore, "seed_type", "")
if derivation == "m/" and seed_type in ["electrum", "standard"]:
derivation = None # suppress Electrum seed 'm/' derivation from UI
except Exception as e:
self.show_error(str(e))
return
d = SeedDialog(self.top_level_window(), seed, passphrase, derivation, seed_type)
d.exec_()
def show_qrcode(self, data, title=_("QR code"), parent=None):
if not data:
return
d = QRDialog(data, parent or self, title)
d.exec_()
d.setParent(None) # Help Python GC this sooner rather than later
@protected
def show_private_key(self, address, password):
if not address:
return
try:
pk = self.wallet.export_private_key(address, password)
except Exception as e:
if is_verbose:
traceback.print_exc(file=sys.stderr)
self.show_message(str(e))
return
xtype = bitcoin.deserialize_privkey(pk)[0]
d = WindowModalDialog(self.top_level_window(), _("Private key"))
d.setMinimumSize(600, 150)
vbox = QtWidgets.QVBoxLayout()
vbox.addWidget(QtWidgets.QLabel("{}: {}".format(_("Address"), address)))
vbox.addWidget(QtWidgets.QLabel(_("Script type") + ": " + xtype.name))
pk_lbl = QtWidgets.QLabel(_("Private key") + ":")
vbox.addWidget(pk_lbl)
keys_e = ShowQRTextEdit(text=pk)
keys_e.addCopyButton()
# BIP38 Encrypt Button
def setup_encrypt_button():
encrypt_but = QtWidgets.QPushButton(_("Encrypt BIP38") + "...")
f = encrypt_but.font()
f.setPointSize(f.pointSize() - 1)
encrypt_but.setFont(f) # make font -= 1
encrypt_but.setEnabled(bool(bitcoin.Bip38Key.canEncrypt()))
encrypt_but.setToolTip(
_("Encrypt this private key using BIP38 encryption")
if encrypt_but.isEnabled()
else _("BIP38 encryption unavailable: install pycryptodomex to enable")
)
border_color = ColorScheme.DEFAULT.as_color(False)
border_color.setAlphaF(0.65)
encrypt_but_ss_en = (
keys_e.styleSheet()
+ "QPushButton { border: 1px solid %s; border-radius: 6px; padding:"
" 2px; margin: 2px; } QPushButton:hover { border: 1px solid #3daee9;"
" } QPushButton:disabled { border: 1px solid transparent; "
% (border_color.name(QColor.HexArgb))
)
encrypt_but_ss_dis = keys_e.styleSheet()
encrypt_but.setStyleSheet(
encrypt_but_ss_en if encrypt_but.isEnabled() else encrypt_but_ss_dis
)
def on_encrypt():
passphrase = self.get_passphrase_dialog(
msg=(
_("Specify a passphrase to use for BIP38 encryption.")
+ "\n"
+ _(
"Save this passphrase if you save the generated key so you"
" may decrypt it later."
)
)
)
if not passphrase:
return
try:
bip38 = str(bitcoin.Bip38Key.encrypt(pk, passphrase))
keys_e.setText(bip38)
encrypt_but.setEnabled(False)
encrypt_but.setStyleSheet(encrypt_but_ss_dis)
pk_lbl.setText(_("BIP38 Key") + ":")
self.show_message(
_(
"WIF key has been encrypted using BIP38.\n\nYou may save"
" this encrypted key to a file or print out its QR code"
" and/or text.\n\nIt is strongly encrypted with the"
" passphrase you specified and safe to store"
" electronically. However, the passphrase should be stored"
" securely and not shared with anyone."
)
)
except Exception as e:
if is_verbose:
traceback.print_exc(file=sys.stderr)
self.show_error(str(e))
encrypt_but.clicked.connect(on_encrypt)
keys_e.addWidget(encrypt_but, 0)
setup_encrypt_button()
# /BIP38 Encrypt Button
vbox.addWidget(keys_e)
vbox.addWidget(QtWidgets.QLabel(_("Redeem Script") + ":"))
rds_e = ShowQRTextEdit(text=address.to_script().hex())
rds_e.addCopyButton()
vbox.addWidget(rds_e)
vbox.addLayout(Buttons(CloseButton(d)))
d.setLayout(vbox)
d.exec_()
def sign_verify_message(self, address=None):
d = SignVerifyDialog(self.wallet, address, parent=self)
d.exec_()
@protected
def do_decrypt(self, message_e, pubkey_e, encrypted_e, password):
if self.wallet.is_watching_only():
self.show_message(_("This is a watching-only wallet."))
return
cyphertext = encrypted_e.toPlainText()
task = partial(
self.wallet.decrypt_message, pubkey_e.text(), cyphertext, password
)
self.wallet.thread.add(
task, on_success=lambda text: message_e.setText(text.decode("utf-8"))
)
def do_encrypt(self, message_e, pubkey_e, encrypted_e):
message = message_e.toPlainText()
message = message.encode("utf-8")
try:
public_key = ECPubkey(bytes.fromhex(pubkey_e.text()))
encrypted = public_key.encrypt_message(message)
encrypted_e.setText(encrypted.decode("ascii"))
except Exception as e:
if is_verbose:
traceback.print_exc(file=sys.stderr)
self.show_warning(str(e))
def encrypt_message(self, address=None):
d = WindowModalDialog(self.top_level_window(), _("Encrypt/decrypt Message"))
d.setMinimumSize(610, 490)
layout = QtWidgets.QGridLayout(d)
message_e = QtWidgets.QTextEdit()
message_e.setAcceptRichText(False)
layout.addWidget(QtWidgets.QLabel(_("Message")), 1, 0)
layout.addWidget(message_e, 1, 1)
layout.setRowStretch(2, 3)
pubkey_e = QtWidgets.QLineEdit()
if address:
pubkey = self.wallet.get_public_key(address)
if not isinstance(pubkey, str):
pubkey = pubkey.to_ui_string()
pubkey_e.setText(pubkey)
layout.addWidget(QtWidgets.QLabel(_("Public key")), 2, 0)
layout.addWidget(pubkey_e, 2, 1)
encrypted_e = QtWidgets.QTextEdit()
encrypted_e.setAcceptRichText(False)
layout.addWidget(QtWidgets.QLabel(_("Encrypted")), 3, 0)
layout.addWidget(encrypted_e, 3, 1)
layout.setRowStretch(3, 1)
hbox = QtWidgets.QHBoxLayout()
b = QtWidgets.QPushButton(_("Encrypt"))
b.clicked.connect(lambda: self.do_encrypt(message_e, pubkey_e, encrypted_e))
hbox.addWidget(b)
b = QtWidgets.QPushButton(_("Decrypt"))
b.clicked.connect(lambda: self.do_decrypt(message_e, pubkey_e, encrypted_e))
hbox.addWidget(b)
b = QtWidgets.QPushButton(_("Close"))
b.clicked.connect(d.accept)
hbox.addWidget(b)
layout.addLayout(hbox, 4, 1)
d.exec_()
def password_dialog(self, msg=None, parent=None):
parent = parent or self
return PasswordDialog(parent, msg).run()
def tx_from_text(self, txt: str) -> Optional[Transaction]:
try:
raw_tx = rawtx_from_str(txt)
tx = Transaction(raw_tx, sign_schnorr=self.wallet.is_schnorr_enabled())
tx.deserialize()
if self.wallet:
my_coins = self.wallet.get_spendable_coins(None, self.config)
my_outpoints = [
vin["prevout_hash"] + ":" + str(vin["prevout_n"])
for vin in my_coins
]
for txin in tx.txinputs():
outpoint = str(txin.outpoint)
if outpoint in my_outpoints:
my_index = my_outpoints.index(outpoint)
txin.set_value(my_coins[my_index]["value"])
return tx
except Exception:
if is_verbose:
traceback.print_exc(file=sys.stderr)
self.show_critical(
_(f"{PROJECT_NAME} was unable to parse your transaction")
)
return
# Due to the asynchronous nature of the qr reader we need to keep the
# dialog instance as member variable to prevent reentrancy/multiple ones
# from being presented at once.
_qr_dialog = None
def read_tx_from_qrcode(self):
if self._qr_dialog:
# Re-entrancy prevention -- there is some lag between when the user
# taps the QR button and the modal dialog appears. We want to
# prevent multiple instances of the dialog from appearing, so we
# must do this.
self.print_error("Warning: QR dialog is already presented, ignoring.")
return
if self.gui_object.warn_if_cant_import_qrreader(self):
return
self._qr_dialog = None
try:
self._qr_dialog = QrReaderCameraDialog(parent=self.top_level_window())
def _on_qr_reader_finished(success: bool, error: str, result):
if self._qr_dialog:
self._qr_dialog.deleteLater()
self._qr_dialog = None
if not success:
if error:
self.show_error(error)
return
if not result:
return
# if the user scanned an ecash URI
if result.lower().startswith(networks.net.CASHADDR_PREFIX + ":"):
self.pay_to_URI(result)
return
# else if the user scanned an offline signed tx
try:
result = bh2u(bitcoin.base_decode(result, length=None, base=43))
# will show an error dialog on error
tx = self.tx_from_text(result)
if not tx:
return
except Exception as e:
self.show_error(str(e))
return
self.show_transaction(tx)
self._qr_dialog.qr_finished.connect(_on_qr_reader_finished)
self._qr_dialog.start_scan(get_config().get_video_device())
except Exception as e:
if is_verbose:
traceback.print_exc(file=sys.stderr)
self._qr_dialog = None
self.show_error(str(e))
def read_tx_from_file(self, filename: str) -> Optional[Transaction]:
try:
with open(filename, "r", encoding="utf-8") as f:
file_content = f.read()
file_content = file_content.strip()
json.loads(str(file_content))
except (ValueError, IOError, OSError, json.decoder.JSONDecodeError) as reason:
self.show_critical(
_(f"{PROJECT_NAME} was unable to open your transaction file")
+ "\n"
+ str(reason),
title=_("Unable to read file or no transaction found"),
)
return
tx = self.tx_from_text(file_content)
return tx
def do_process_from_text(self):
text = text_dialog(
self.top_level_window(),
_("Input raw transaction"),
_("Transaction:"),
_("Load transaction"),
)
if not text:
return
try:
tx = self.tx_from_text(text)
if tx:
self.show_transaction(tx)
except SerializationError as e:
self.show_critical(
_(f"{PROJECT_NAME} was unable to deserialize the transaction:")
+ "\n"
+ str(e)
)
def do_process_from_file(self):
fileName = getOpenFileName(
_("Select your transaction file"), self.config, "*.txn"
)
if not fileName:
return
try:
tx = self.read_tx_from_file(fileName)
if tx:
self.show_transaction(tx)
except SerializationError as e:
self.show_critical(
_(f"{PROJECT_NAME} was unable to deserialize the transaction:")
+ "\n"
+ str(e)
)
def do_process_from_multiple_files(self):
filenames, _filter = QtWidgets.QFileDialog.getOpenFileNames(
self,
"Select one or more files to open",
self.config.get("io_dir", os.path.expanduser("~")),
)
transactions = []
for filename in filenames:
try:
tx = self.read_tx_from_file(filename)
if tx is not None:
transactions.append(tx)
except SerializationError as e:
self.show_critical(
f"{PROJECT_NAME} was unable to deserialize the"
f" transaction in file {filename}:\n" + str(e)
)
if not transactions:
return
multi_tx_dialog = MultiTransactionsDialog(self.wallet, self, self)
multi_tx_dialog.widget.set_transactions(transactions)
multi_tx_dialog.exec_()
def do_process_from_txid(self, *, txid=None, parent=None):
parent = parent or self
if self.gui_object.warn_if_no_network(parent):
return
ok = txid is not None
if not ok:
txid, ok = QtWidgets.QInputDialog.getText(
parent, _("Lookup transaction"), _("Transaction ID") + ":"
)
if ok and txid:
ok, r = self.network.get_raw_tx_for_txid(txid, timeout=10.0)
if not ok:
parent.show_message(_("Error retrieving transaction") + ":\n" + r)
return
# note that presumably the tx is already signed if it comes from blockchain
# so this sign_schnorr parameter is superfluous, but here to satisfy
# my OCD -Calin
tx = Transaction(
bytes.fromhex(r), sign_schnorr=self.wallet.is_schnorr_enabled()
)
self.show_transaction(tx)
def do_create_invoice(self):
d = InvoiceDialog(self, self.fx)
d.set_address(self.receive_address)
d.show()
def do_load_edit_invoice(self):
d = InvoiceDialog(self, self.fx)
d.open_file_and_load_invoice()
d.show()
def do_load_pay_invoice(self):
filename, _selected_filter = QtWidgets.QFileDialog.getOpenFileName(
self,
_("Load invoice from file"),
filter="JSON file (*.json);;All files (*)",
)
if not filename:
return
invoice = load_invoice_from_file_and_show_error_message(filename, self)
xec_amount = invoice.get_xec_amount()
amount_str = format_satoshis_plain(
int(xec_amount * 100), self.get_decimal_point()
)
computed_rate = invoice.amount / xec_amount
if invoice is None:
return
self.show_send_tab()
self.payto_e.setText(invoice.address.to_ui_string())
self.amount_e.setText(amount_str)
self.message_e.setText(invoice.label)
# signal to set fee
self.amount_e.textEdited.emit("")
QtWidgets.QMessageBox.warning(
self,
_("Paying invoice"),
_(
"You are about to use the experimental 'Pay Invoice' feature. Please "
"review the XEC amount carefully before sending the transaction."
)
+ f"\n\nAddress: {invoice.address.to_ui_string()}"
f"\n\nAmount ({self.base_unit()}): {amount_str}"
f"\n\nLabel: {invoice.label}"
f"\n\nInvoice currency: {invoice.currency}"
f"\n\nExchange rate ({invoice.currency}/XEC): "
f"{1 if invoice.exchange_rate is None else computed_rate:.10f}",
)
def open_proof_editor(self):
dialog = AvaProofDialog(self.wallet, self.receive_address, parent=self)
dialog.show()
def build_avalanche_delegation(self):
"""
Open a dialog to build an avalanche delegation.
The user first provides a proof, a limited proof id or an existing delegation.
Then he provides a delegator private key (must match provided proof or
delegation) and a new delegated public key.
Alternatively, this dialog can be opened from the proof building dialog. It is
then prefilled with the correct data (except the delegated public key).
"""
dialog = AvaDelegationDialog(self.wallet, parent=self)
dialog.show()
def export_bip38_dialog(self):
"""Convenience method. Simply calls self.export_privkeys_dialog(bip38=True)"""
self.export_privkeys_dialog(bip38=True)
@protected
def export_privkeys_dialog(self, password, *, bip38=False):
if self.wallet.is_watching_only():
self.show_message(_("This is a watching-only wallet"))
return
if isinstance(self.wallet, MultisigWallet):
if bip38:
self.show_error(
_("WARNING: This is a multi-signature wallet.")
+ "\n"
+ _("It cannot be used with BIP38 encrypted keys.")
)
return
self.show_message(
_("WARNING: This is a multi-signature wallet.")
+ "\n"
+ _('It can not be "backed up" by simply exporting these private keys.')
)
if bip38:
if not bitcoin.Bip38Key.canEncrypt() or not bitcoin.Bip38Key.isFast():
self.show_error(
_(
"BIP38 Encryption is not available. Please install "
f"'pycryptodomex' and restart {PROJECT_NAME} to enable"
"BIP38."
)
)
return
passphrase = self.get_passphrase_dialog(
msg=(
_(
"You are exporting your wallet's private keys as BIP38"
" encrypted keys."
)
+ "\n\n"
+ _("You must specify a passphrase to use for encryption.")
+ "\n"
+ _(
"Save this passphrase so you may decrypt your BIP38 keys later."
)
)
)
if not passphrase:
# user cancel
return
bip38 = passphrase # overwrite arg with passphrase.. for use down below ;)
class MyWindowModalDialog(WindowModalDialog):
computing_privkeys_signal = pyqtSignal()
show_privkeys_signal = pyqtSignal()
d = MyWindowModalDialog(self.top_level_window(), _("Private keys"))
weak_d = Weak.ref(d)
d.setObjectName("WindowModalDialog - Private Key Export")
destroyed_print_error(d) # track object lifecycle
d.setMinimumSize(850, 300)
vbox = QtWidgets.QVBoxLayout(d)
lines = [
_("WARNING: ALL your private keys are secret."),
_("Exposing a single private key can compromise your entire wallet!"),
_(
"In particular, DO NOT use 'redeem private key' services proposed by"
" third parties."
),
]
if bip38:
del lines[0] # No need to scream-WARN them since BIP38 *are* encrypted
msg = "\n".join(lines)
vbox.addWidget(QtWidgets.QLabel(msg))
if bip38:
wwlbl = WWLabel()
def set_ww_txt(pf_shown=False):
if pf_shown:
pf_text = (
"<font face='{monoface}' size=+1><b>".format(
monoface=MONOSPACE_FONT
)
+ bip38
+ '</b></font> <a href="hide">{link}</a>'.format(link=_("Hide"))
)
else:
pf_text = '<a href="show">{link}</a>'.format(
link=_("Click to show")
)
wwlbl.setText(
_(
"The below keys are BIP38 <i>encrypted</i> using the"
" passphrase: {passphrase}<br>Please <i>write this passphrase"
" down</i> and store it in a secret place, separate from these"
" encrypted keys."
).format(passphrase=pf_text)
)
def toggle_ww_txt(link):
set_ww_txt(link == "show")
set_ww_txt()
wwlbl.linkActivated.connect(toggle_ww_txt)
vbox.addWidget(wwlbl)
e = QtWidgets.QTextEdit()
e.setFont(QFont(MONOSPACE_FONT))
e.setWordWrapMode(QTextOption.NoWrap)
e.setReadOnly(True)
vbox.addWidget(e)
defaultname = (
f"{SCRIPT_NAME}-private-keys.csv"
if not bip38
else f"{SCRIPT_NAME}-bip38-keys.csv"
)
select_msg = _("Select file to export your private keys to")
box, filename_e, csv_button = filename_field(
self.config, defaultname, select_msg
)
vbox.addSpacing(12)
vbox.addWidget(box)
b = OkButton(d, _("Export"))
b.setEnabled(False)
vbox.addLayout(Buttons(CancelButton(d), b))
private_keys = {}
addresses = self.wallet.get_addresses()
stop = False
def privkeys_thread():
for addr in addresses:
if not bip38:
# This artificial sleep is likely a security / paranoia measure
# to allow user to cancel or to make the process "feel expensive".
# In the bip38 case it's already slow enough so this delay
# is not needed.
time.sleep(0.100)
if stop:
return
try:
privkey = self.wallet.export_private_key(addr, password)
if bip38 and privkey:
privkey = str(
bitcoin.Bip38Key.encrypt(privkey, bip38)
) # __str__() -> base58 encoded bip38 key
except InvalidPassword:
# See #921 -- possibly a corrupted wallet or other strangeness
privkey = "INVALID_PASSWORD"
private_keys[addr.to_ui_string()] = privkey
strong_d = weak_d()
try:
if strong_d and not stop:
strong_d.computing_privkeys_signal.emit()
else:
return
finally:
del strong_d
if stop:
return
strong_d = weak_d()
if strong_d:
strong_d.show_privkeys_signal.emit()
def show_privkeys():
nonlocal stop
if stop:
return
s = "\n".join(
"{:45} {}".format(addr, privkey)
for addr, privkey in private_keys.items()
)
e.setText(s)
b.setEnabled(True)
stop = True
thr = None
def on_dialog_closed(*args):
nonlocal stop
stop = True
try:
d.computing_privkeys_signal.disconnect()
except TypeError:
pass
try:
d.show_privkeys_signal.disconnect()
except TypeError:
pass
try:
d.finished.disconnect()
except TypeError:
pass
if thr and thr.is_alive():
# wait for thread to end for maximal GC mojo
thr.join(timeout=1.0)
def computing_privkeys_slot():
if stop:
return
e.setText(
_("Please wait... {num}/{total}").format(
num=len(private_keys), total=len(addresses)
)
)
d.computing_privkeys_signal.connect(computing_privkeys_slot)
d.show_privkeys_signal.connect(show_privkeys)
d.finished.connect(on_dialog_closed)
thr = threading.Thread(target=privkeys_thread, daemon=True)
thr.start()
res = d.exec_()
if not res:
stop = True
return
filename = filename_e.text()
if not filename:
return
try:
self.do_export_privkeys(filename, private_keys, csv_button.isChecked())
except (IOError, os.error) as reason:
txt = "\n".join(
[
_(f"{PROJECT_NAME} was unable to produce a privatekey-export."),
str(reason),
]
)
self.show_critical(txt, title=_("Unable to create csv"))
except Exception as e:
self.show_message(str(e))
return
self.show_message(_("Private keys exported."))
def do_export_privkeys(self, fileName, pklist, is_csv):
with open(fileName, "w+", encoding="utf-8") as f:
if is_csv:
transaction = csv.writer(f)
transaction.writerow(["address", "private_key"])
for addr, pk in pklist.items():
transaction.writerow(["%34s" % addr, pk])
else:
f.write(json.dumps(pklist, indent=4))
def do_import_labels(self):
labelsFile = getOpenFileName(_("Open labels file"), self.config, "*.json")
if not labelsFile:
return
try:
with open(
labelsFile, "r", encoding="utf-8"
) as f: # always ensure UTF-8. See issue #1453.
data = f.read()
data = json.loads(data)
if (
type(data) is not dict
or not len(data)
or not all(type(v) is str and type(k) is str for k, v in data.items())
):
self.show_critical(
_("The file you selected does not appear to contain labels.")
)
return
for key, value in data.items():
self.wallet.set_label(key, value)
self.show_message(
_("Your labels were imported from") + " '%s'" % str(labelsFile)
)
except (IOError, OSError, json.decoder.JSONDecodeError) as reason:
self.show_critical(
_(f"{PROJECT_NAME} was unable to import your labels.")
+ "\n"
+ str(reason)
)
self.address_list.update()
self.history_list.update()
self.utxo_list.update()
self.history_updated_signal.emit() # inform things like address_dialog that there's a new history
def do_export_labels(self):
labels = self.wallet.labels
try:
fileName = getSaveFileName(
_("Select file to save your labels"),
f"{SCRIPT_NAME}_labels.json",
self.config,
"*.json",
)
if fileName:
with open(
fileName, "w+", encoding="utf-8"
) as f: # always ensure UTF-8. See issue #1453.
json.dump(labels, f, indent=4, sort_keys=True)
self.show_message(
_("Your labels were exported to") + " '%s'" % str(fileName)
)
except (IOError, os.error) as reason:
self.show_critical(
_(f"{PROJECT_NAME} was unable to export your labels.")
+ "\n"
+ str(reason)
)
def export_history_dialog(self):
d = WindowModalDialog(self.top_level_window(), _("Export History"))
d.setMinimumSize(400, 200)
vbox = QtWidgets.QVBoxLayout(d)
defaultname = os.path.expanduser(f"~/{SCRIPT_NAME}-history.csv")
select_msg = _("Select file to export your wallet transactions to")
box, filename_e, csv_button = filename_field(
self.config, defaultname, select_msg
)
vbox.addWidget(box)
include_addresses_chk = QtWidgets.QCheckBox(_("Include addresses"))
include_addresses_chk.setChecked(True)
include_addresses_chk.setToolTip(
_("Include input and output addresses in history export")
)
vbox.addWidget(include_addresses_chk)
fee_dl_chk = QtWidgets.QCheckBox(_("Fetch accurate fees from network (slower)"))
fee_dl_chk.setChecked(self.is_fetch_input_data())
fee_dl_chk.setEnabled(bool(self.wallet.network))
fee_dl_chk.setToolTip(
_(
"If this is checked, accurate fee and input value data will be"
" retrieved from the network"
)
)
vbox.addWidget(fee_dl_chk)
fee_time_w = QtWidgets.QWidget()
fee_time_w.setToolTip(
_(
"The amount of overall time in seconds to allow for downloading fee"
" data before giving up"
)
)
hbox = QtWidgets.QHBoxLayout(fee_time_w)
hbox.setContentsMargins(20, 0, 0, 0)
hbox.addWidget(QtWidgets.QLabel(_("Timeout:")), 0, Qt.AlignRight)
fee_time_sb = QtWidgets.QSpinBox()
fee_time_sb.setMinimum(10)
fee_time_sb.setMaximum(9999)
fee_time_sb.setSuffix(" " + _("seconds"))
fee_time_sb.setValue(30)
fee_dl_chk.clicked.connect(fee_time_w.setEnabled)
fee_time_w.setEnabled(fee_dl_chk.isChecked())
hbox.addWidget(fee_time_sb, 0, Qt.AlignLeft)
hbox.addStretch(1)
vbox.addWidget(fee_time_w)
vbox.addStretch(1)
hbox = Buttons(CancelButton(d), OkButton(d, _("Export")))
vbox.addLayout(hbox)
run_hook("export_history_dialog", self, hbox)
self.update()
res = d.exec_()
d.setParent(None) # for python GC
if not res:
return
filename = filename_e.text()
if not filename:
return
success = False
try:
# minimum 10s time for calc. fees, etc
timeout = max(fee_time_sb.value() if fee_dl_chk.isChecked() else 10.0, 10.0)
success = self.do_export_history(
filename,
csv_button.isChecked(),
download_inputs=fee_dl_chk.isChecked(),
timeout=timeout,
include_addresses=include_addresses_chk.isChecked(),
)
except Exception as reason:
export_error_label = _(
f"{PROJECT_NAME} was unable to produce a transaction export."
)
self.show_critical(
export_error_label + "\n" + str(reason),
title=_("Unable to export history"),
)
else:
if success:
self.show_message(
_("Your wallet history has been successfully exported.")
)
def is_fetch_input_data(self):
"""default on if network.auto_connect is True, otherwise use config value"""
return bool(
self.wallet
and self.wallet.network
and self.config.get("fetch_input_data", self.wallet.network.auto_connect)
)
def set_fetch_input_data(self, b):
self.config.set_key("fetch_input_data", bool(b))
def do_export_history(
self,
fileName,
is_csv,
*,
download_inputs=False,
timeout=30.0,
include_addresses=True,
):
wallet = self.wallet
if not wallet:
return
dlg = None # this will be set at the bottom of this function
def task():
def update_prog(x):
if dlg:
dlg.update_progress(int(x * 100))
return wallet.export_history(
fx=self.fx,
show_addresses=include_addresses,
decimal_point=self.get_decimal_point(),
fee_calc_timeout=timeout,
download_inputs=download_inputs,
progress_callback=update_prog,
)
success = False
def on_success(history):
nonlocal success
ccy = (self.fx and self.fx.get_currency()) or ""
has_fiat_columns = (
history
and self.fx
and self.fx.show_history()
and "fiat_value" in history[0]
and "fiat_balance" in history[0]
and "fiat_fee" in history[0]
)
lines = []
for item in history:
if is_csv:
cols = [
item["txid"],
item.get("label", ""),
item["confirmations"],
item["value"],
item["fee"],
item["date"],
]
if has_fiat_columns:
cols += [
item["fiat_value"],
item["fiat_balance"],
item["fiat_fee"],
]
if include_addresses:
inaddrs_filtered = (
x
for x in (item.get("input_addresses") or [])
if Address.is_valid(x)
)
outaddrs_filtered = (
x
for x in (item.get("output_addresses") or [])
if Address.is_valid(x)
)
cols.append(",".join(inaddrs_filtered))
cols.append(",".join(outaddrs_filtered))
lines.append(cols)
else:
if has_fiat_columns and ccy:
# add the currency to each entry in the json. this wastes space
# but json is bloated anyway so this won't hurt too much, we
# hope
item["fiat_currency"] = ccy
elif not has_fiat_columns:
# No need to include these fields as they will always be 'No Data'
item.pop("fiat_value", None)
item.pop("fiat_balance", None)
item.pop("fiat_fee", None)
lines.append(item)
with open(
fileName, "w+", encoding="utf-8"
) as f: # ensure encoding to utf-8. Avoid Windows cp1252. See #1453.
if is_csv:
transaction = csv.writer(f, lineterminator="\n")
cols = [
"transaction_hash",
"label",
"confirmations",
"value",
"fee",
"timestamp",
]
if has_fiat_columns:
cols += [
f"fiat_value_{ccy}",
f"fiat_balance_{ccy}",
f"fiat_fee_{ccy}",
] # in CSV mode, we use column names eg fiat_value_USD, etc
if include_addresses:
cols += ["input_addresses", "output_addresses"]
transaction.writerow(cols)
for line in lines:
transaction.writerow(line)
else:
f.write(json.dumps(lines, indent=4))
success = True
# kick off the waiting dialog to do all of the above
dlg = WaitingDialog(
self.top_level_window(),
_("Exporting history, please wait ..."),
task,
on_success,
self.on_error,
disable_escape_key=True,
auto_exec=False,
auto_show=False,
progress_bar=True,
progress_min=0,
progress_max=100,
)
dlg.exec_()
# this will block heere in the WaitingDialog event loop... and set success to True if success
return success
def sweep_key_dialog(self):
addresses = self.wallet.get_unused_addresses()
if not addresses:
try:
addresses = self.wallet.get_receiving_addresses()
except AttributeError:
addresses = self.wallet.get_addresses()
if not addresses:
self.show_warning(_("Wallet has no address to sweep to"))
return
d = WindowModalDialog(self.top_level_window(), title=_("Sweep private keys"))
d.setMinimumSize(600, 300)
vbox = QtWidgets.QVBoxLayout(d)
bip38_warn_label = QtWidgets.QLabel(
_(
"<b>BIP38 support is disabled because a requisite library is not"
" installed.</b> Please install 'cryptodomex' or omit BIP38 private"
" keys (private keys starting in 6P...). Decrypt keys to WIF format"
" (starting with 5, K, or L) in order to sweep."
)
)
bip38_warn_label.setWordWrap(True)
bip38_warn_label.setHidden(True)
vbox.addWidget(bip38_warn_label)
extra = ""
if bitcoin.is_bip38_available():
extra += " " + _("or BIP38 keys")
vbox.addWidget(QtWidgets.QLabel(_("Enter private keys") + extra + " :"))
keys_e = ScanQRTextEdit(allow_multi=True)
keys_e.setTabChangesFocus(True)
vbox.addWidget(keys_e)
h, addr_combo = address_combo(addresses)
vbox.addLayout(h)
vbox.addStretch(1)
sweep_button = OkButton(d, _("Sweep"))
vbox.addLayout(Buttons(CancelButton(d), sweep_button))
def get_address_text():
return addr_combo.currentText()
def get_priv_keys():
return keystore.get_private_keys(keys_e.toPlainText(), allow_bip38=True)
def has_bip38_keys_but_no_bip38():
if bitcoin.is_bip38_available():
return False
keys = [k for k in keys_e.toPlainText().split() if k]
return any(bitcoin.is_bip38_key(k) for k in keys)
def enable_sweep():
bad_bip38 = has_bip38_keys_but_no_bip38()
sweepok = bool(get_address_text() and not bad_bip38 and get_priv_keys())
sweep_button.setEnabled(sweepok)
bip38_warn_label.setHidden(not bad_bip38)
keys_e.textChanged.connect(enable_sweep)
enable_sweep()
res = d.exec_()
d.setParent(None)
if not res:
return
try:
self.do_clear()
keys = get_priv_keys()
bip38s = {}
for i, k in enumerate(keys):
if bitcoin.is_bip38_key(k):
bip38s[k] = i
if bip38s:
# For all the BIP38s detected, prompt for password
d2 = Bip38Importer(bip38s.keys(), parent=self.top_level_window())
d2.exec_()
d2.setParent(None)
if d2.decoded_keys:
for k, tup in d2.decoded_keys.items():
wif, adr = tup
# rewrite the keys they specified with the decrypted WIF in the keys list for sweep_preparations to work below...
i = bip38s[k]
keys[i] = wif
else:
self.show_message(_("User cancelled"))
return
inputs, keypairs = sweep_preparations(keys, self.network)
self.tx_external_keypairs = keypairs
self.payto_e.setText(get_address_text())
self.spend_coins([inp.to_coin_dict() for inp in inputs])
self.spend_max()
except Exception as e:
self.show_message(str(e))
return
self.payto_e.setFrozen(True)
self.amount_e.setFrozen(True)
self.warn_if_watching_only()
def _do_import(self, title, msg, func):
text = text_dialog(
self.top_level_window(), title, msg + " :", _("Import"), allow_multi=True
)
if not text:
return
bad, bad_info = [], []
good = []
for key in str(text).split():
try:
addr = func(key)
good.append(addr)
except Exception as e:
bad.append(key)
bad_info.append("{}: {}".format(key, str(e)))
continue
if good:
self.show_message(
_("The following addresses were added") + ":\n" + "\n".join(good)
)
if bad:
self.show_warning(
_("The following could not be imported") + ":\n" + "\n".join(bad),
detail_text="\n\n".join(bad_info),
)
self.address_list.update()
self.history_list.update()
self.history_updated_signal.emit() # inform things like address_dialog that there's a new history
def import_addresses(self):
if not self.wallet.can_import_address():
return
title, msg = _("Import addresses"), _("Enter addresses")
def import_addr(addr):
if self.wallet.import_address(Address.from_string(addr)):
return addr
return ""
self._do_import(title, msg, import_addr)
def show_auxiliary_keys(self):
if not self.wallet.is_deterministic() or not self.wallet.can_export():
return
d = AuxiliaryKeysDialog(self.wallet, parent=self)
d.show()
@protected
def do_import_privkey(self, password):
if not self.wallet.can_import_privkey():
return
title, msg = _("Import private keys"), _("Enter private keys")
if bitcoin.is_bip38_available():
msg += " " + _("or BIP38 keys")
def func(key):
if bitcoin.is_bip38_available() and bitcoin.is_bip38_key(key):
d = Bip38Importer(
[key],
parent=self.top_level_window(),
message=_(
"A BIP38 key was specified, please enter a password to"
" decrypt it"
),
show_count=False,
)
d.exec_()
d.setParent(None) # python GC quicker if this happens
if d.decoded_keys:
wif, adr = d.decoded_keys[key]
return self.wallet.import_private_key(wif, password)
else:
raise util.UserCancelled()
else:
return self.wallet.import_private_key(key, password)
self._do_import(title, msg, func)
def update_fiat(self):
b = self.fx and self.fx.is_enabled()
self.fiat_send_e.setVisible(b)
self.fiat_receive_e.setVisible(b)
self.history_list.refresh_headers()
self.history_list.update()
self.history_updated_signal.emit() # inform things like address_dialog that there's a new history
self.address_list.refresh_headers()
self.address_list.update()
self.update_status()
def settings_dialog(self):
d = SettingsDialog(
self.top_level_window(),
self.config,
self.wallet,
self.fx,
self.alias_info,
self.base_unit(),
self.gui_object,
)
d.num_zeros_changed.connect(self.update_tabs)
d.num_zeros_changed.connect(self.update_status)
d.custom_fee_changed.connect(self.fee_slider.update)
d.custom_fee_changed.connect(self.fee_slider_mogrifier)
d.show_fee_changed.connect(self.fee_e.setVisible)
d.alias_changed.connect(self.fetch_alias)
d.unit_changed.connect(self.update_tabs)
d.unit_changed.connect(self.update_status)
d.enable_opreturn_changed.connect(self.on_toggled_opreturn)
d.currency_changed.connect(self.update_fiat)
d.show_fiat_balance_toggled.connect(self.address_list.refresh_headers)
d.show_fiat_balance_toggled.connect(self.address_list.update)
def on_show_history(checked):
changed = bool(self.fx.get_history_config()) != bool(checked)
self.history_list.refresh_headers()
if self.fx.is_enabled() and checked:
# reset timeout to get historical rates
self.fx.timeout = 0
if changed:
# this won't happen too often as it's rate-limited
self.history_list.update()
d.show_history_rates_toggled.connect(on_show_history)
def update_amounts():
edits = self.amount_e, self.fee_e, self.receive_amount_e
amounts = [edit.get_amount() for edit in edits]
for edit, amount in zip(edits, amounts):
edit.setAmount(amount)
d.unit_changed.connect(update_amounts)
self.alias_received_signal.connect(lambda: d.set_alias_color(self.alias_info))
try:
# run the dialog
d.exec_()
finally:
self.alias_received_signal.disconnect()
d.dialog_finished = True # paranoia for scan_cameras
if self.fx:
self.fx.timeout = 0
run_hook("close_settings_dialog")
if d.need_restart:
self.show_message(
_(f"Please restart {PROJECT_NAME} to activate the new GUI settings"),
title=_("Success"),
)
elif d.need_wallet_reopen:
self.show_message(
_("Please close and reopen this wallet to activate the new settings"),
title=_("Success"),
)
def closeEvent(self, event):
# It seems in some rare cases this closeEvent() is called twice.
# clean_up() guards against that situation.
self.clean_up()
super().closeEvent(event)
event.accept() # paranoia. be sure it's always accepted.
def is_alive(self):
return bool(not self.cleaned_up)
def clean_up_connections(self):
def disconnect_signals():
del self.addr_fmt_changed # delete alias so it doesn interfere with below
for attr_name in dir(self):
if attr_name.endswith("_signal"):
sig = getattr(self, attr_name)
if isinstance(sig, pyqtBoundSignal):
try:
sig.disconnect()
except TypeError:
pass # no connections
# NB: this needs to match the attribute name in util.py rate_limited decorator
elif attr_name.endswith("__RateLimiter"):
rl_obj = getattr(self, attr_name)
if isinstance(rl_obj, RateLimiter):
rl_obj.kill_timer()
# The below shouldn't even be needed, since Qt should take care of this,
# but Axel Gembe got a crash related to this on Python 3.7.3, PyQt 5.12.3
# so here we are. See #1531
try:
self.gui_object.addr_fmt_changed.disconnect(
self.status_bar.update_cashaddr_icon
)
except TypeError:
pass
try:
self.gui_object.addr_fmt_changed.disconnect(
self.update_receive_address_widget
)
except TypeError:
pass
try:
self.gui_object.cashaddr_status_button_hidden_signal.disconnect(
self.status_bar.addr_converter_button.setHidden
)
except TypeError:
pass
try:
self.gui_object.update_available_signal.disconnect(
self.status_bar.on_update_available
)
except TypeError:
pass
try:
self.disconnect()
except TypeError:
pass
def disconnect_network_callbacks():
if self.network:
self.network.unregister_callback(self.on_network)
self.network.unregister_callback(self.on_quotes)
self.network.unregister_callback(self.on_history)
# /
disconnect_network_callbacks()
disconnect_signals()
def clean_up_children(self):
# Status bar holds references to self, so clear it to help GC this window
self.setStatusBar(None)
# Note that due to quirks on macOS and the shared menu bar, we do *NOT*
# clear the menuBar. Instead, doing this causes the object to get
# deleted and/or its actions (and more importantly menu action hotkeys)
# to go away immediately.
self.setMenuBar(None)
# Disable shortcuts immediately to prevent them from accidentally firing
# on us after we are closed. They will get deleted when this QObject
# is finally deleted by Qt.
for shortcut in self._shortcuts:
shortcut.setEnabled(False)
del shortcut
self._shortcuts.clear()
# Reparent children to 'None' so python GC can clean them up sooner rather than later.
# This also hopefully helps accelerate this window's GC.
children = [
c
for c in self.children()
if (
isinstance(c, (QtWidgets.QWidget, QtWidgets.QAction, TaskThread))
and not isinstance(
c,
(
QtWidgets.QStatusBar,
QtWidgets.QMenuBar,
QtWidgets.QFocusFrame,
QtWidgets.QShortcut,
),
)
)
]
for c in children:
try:
c.disconnect()
except TypeError:
pass
c.setParent(None)
def clean_up(self):
if self.cleaned_up:
return
self.cleaned_up = True
# guard against window close before load_wallet was called (#1554)
if self.wallet.thread:
self.wallet.thread.stop()
# Join the thread to make sure it's really dead.
self.wallet.thread.wait()
for w in [
self.address_list,
self.history_list,
self.utxo_list,
self.contact_list,
self.tx_update_mgr,
]:
if w:
# tell relevant object to clean itself up, unregister callbacks,
# disconnect signals, etc
w.clean_up()
with contextlib.suppress(TypeError):
self.gui_object.addr_fmt_changed.disconnect(self.utxo_list.update)
self.gui_object.addr_fmt_changed.disconnect(self.contact_list.update)
# We catch these errors with the understanding that there is no recovery at
# this point, given user has likely performed an action we cannot recover
# cleanly from. So we attempt to exit as cleanly as possible.
try:
self.config.set_key("is_maximized", self.isMaximized())
self.config.set_key("console-history", self.console.history[-50:], True)
except (OSError, PermissionError) as e:
self.print_error("unable to write to config (directory removed?)", e)
if not self.isMaximized():
try:
g = self.geometry()
self.wallet.storage.put(
"winpos-qt", [g.left(), g.top(), g.width(), g.height()]
)
except (OSError, PermissionError) as e:
self.print_error(
"unable to write to wallet storage (directory removed?)", e
)
# Should be no side-effects in this function relating to file access past this point.
if self.qr_window:
self.qr_window.close()
self.qr_window = None # force GC sooner rather than later.
for d in list(self._tx_dialogs):
# clean up all extant tx dialogs we opened as they hold references
# to us that will be invalidated
d.prompt_if_unsaved = False # make sure to unconditionally close
d.close()
self._close_wallet()
try:
self.gui_object.timer.timeout.disconnect(self.timer_actions)
except TypeError:
pass # defensive programming: this can happen if we got an exception before the timer action was connected
self.gui_object.close_window(self) # implicitly runs the hook: on_close_window
# Now, actually STOP the wallet's synchronizer and verifiers and remove
# it from the daemon. Note that its addresses will still stay
# 'subscribed' to the ElectrumX server until we connect to a new server,
# (due to ElectrumX protocol limitations).. but this is harmless.
self.gui_object.daemon.stop_wallet(self.wallet.storage.path)
# At this point all plugins should have removed any references to this window.
# Now, just to be paranoid, do some active destruction of signal/slot connections as well as
# Removing child widgets forcefully to speed up Python's own GC of this window.
self.clean_up_connections()
self.clean_up_children()
# And finally, print when we are destroyed by C++ for debug purposes
# We must call this here as above calls disconnected all signals
# involving this widget.
destroyed_print_error(self)
def internal_plugins_dialog(self):
if self.internalpluginsdialog:
# NB: reentrance here is possible due to the way the window menus work on MacOS.. so guard against it
self.internalpluginsdialog.raise_()
return
d = WindowModalDialog(
parent=self.top_level_window(), title=_("Optional Features")
)
weakD = Weak.ref(d)
gui_object = self.gui_object
plugins = gui_object.plugins
vbox = QtWidgets.QVBoxLayout(d)
# plugins
scroll = QtWidgets.QScrollArea()
scroll.setEnabled(True)
scroll.setWidgetResizable(True)
scroll.setMinimumSize(400, 250)
vbox.addWidget(scroll)
w = QtWidgets.QWidget()
scroll.setWidget(w)
w.setMinimumHeight(plugins.get_internal_plugin_count() * 35)
grid = QtWidgets.QGridLayout()
grid.setColumnStretch(0, 1)
weakGrid = Weak.ref(grid)
w.setLayout(grid)
settings_widgets = Weak.ValueDictionary()
def enable_settings_widget(p, name, i):
widget = settings_widgets.get(name)
grid = weakGrid()
d = weakD()
if d and grid and not widget and p and p.requires_settings():
widget = settings_widgets[name] = p.settings_widget(d)
grid.addWidget(widget, i, 1)
if widget:
widget.setEnabled(bool(p and p.is_enabled()))
if not p:
# Need to delete settings widget because keeping it around causes bugs as it points to a now-dead plugin instance
settings_widgets.pop(name)
widget.hide()
widget.setParent(None)
widget.deleteLater()
widget = None
def do_toggle(weakCb, name, i):
cb = weakCb()
if cb:
p = plugins.toggle_internal_plugin(name)
cb.setChecked(bool(p))
enable_settings_widget(p, name, i)
# All plugins get this whenever one is toggled.
run_hook("init_qt", gui_object)
for i, descr in enumerate(plugins.internal_plugin_metadata.values()):
# descr["__name__"] is the fully qualified package name
# (electrumabc_plugins.name)
name = descr["__name__"].split(".")[-1]
p = plugins.get_internal_plugin(name)
if descr.get("registers_keystore"):
continue
try:
plugins.retranslate_internal_plugin_metadata(name)
cb = QtWidgets.QCheckBox(descr["fullname"])
weakCb = Weak.ref(cb)
plugin_is_loaded = p is not None
cb_enabled = (
not plugin_is_loaded
and plugins.is_internal_plugin_available(name, self.wallet)
or plugin_is_loaded
and p.can_user_disable()
)
cb.setEnabled(cb_enabled)
cb.setChecked(plugin_is_loaded and p.is_enabled())
grid.addWidget(cb, i, 0)
enable_settings_widget(p, name, i)
cb.clicked.connect(partial(do_toggle, weakCb, name, i))
msg = descr["description"]
if descr.get("requires"):
msg += (
"\n\n"
+ _("Requires")
+ ":\n"
+ "\n".join(x[1] for x in descr.get("requires"))
)
grid.addWidget(HelpButton(msg), i, 2)
except Exception:
self.print_msg("error: cannot display plugin", name)
traceback.print_exc(file=sys.stderr)
grid.setRowStretch(len(plugins.internal_plugin_metadata.values()), 1)
vbox.addLayout(Buttons(CloseButton(d)))
self.internalpluginsdialog = d
d.exec_()
self.internalpluginsdialog = None # Python GC please!
def external_plugins_dialog(self):
if self.externalpluginsdialog:
# NB: reentrance here is possible due to the way the window menus work on MacOS.. so guard against it
self.externalpluginsdialog.raise_()
return
d = external_plugins_window.ExternalPluginsDialog(self, _("Plugin Manager"))
self.externalpluginsdialog = d
d.exec_()
self.externalpluginsdialog = None # allow python to GC
def hardware_wallet_support(self):
if not sys.platform.startswith("linux"):
self.print_error("FIXME! hardware_wallet_support is Linux only!")
return
if self.hardwarewalletdialog:
# NB: reentrance here is possible due to the way the window menus work on MacOS.. so guard against it
self.hardwarewalletdialog.raise_()
return
d = InstallHardwareWalletSupportDialog(
self.top_level_window(), self.gui_object.plugins
)
self.hardwarewalletdialog = d
d.exec_()
self.hardwarewalletdialog = None # allow python to GC
def cpfp(self, parent_tx, new_tx):
total_size = parent_tx.estimated_size() + new_tx.estimated_size()
d = WindowModalDialog(self.top_level_window(), _("Child Pays for Parent"))
vbox = QtWidgets.QVBoxLayout(d)
msg = (
"A CPFP is a transaction that sends an unconfirmed output back to "
"yourself, with a high fee. The goal is to have miners confirm "
"the parent transaction in order to get the fee attached to the "
"child transaction."
)
vbox.addWidget(WWLabel(_(msg)))
msg2 = (
"The proposed fee is computed using your "
"fee/kB settings, applied to the total size of both child and "
"parent transactions. After you broadcast a CPFP transaction, "
"it is normal to see a new unconfirmed transaction in your history."
)
vbox.addWidget(WWLabel(_(msg2)))
grid = QtWidgets.QGridLayout()
grid.addWidget(QtWidgets.QLabel(_("Total size") + ":"), 0, 0)
grid.addWidget(
QtWidgets.QLabel(_("{total_size} bytes").format(total_size=total_size)),
0,
1,
)
max_fee = new_tx.output_value()
grid.addWidget(QtWidgets.QLabel(_("Input amount") + ":"), 1, 0)
grid.addWidget(
QtWidgets.QLabel(self.format_amount(max_fee) + " " + self.base_unit()), 1, 1
)
output_amount = QtWidgets.QLabel("")
grid.addWidget(QtWidgets.QLabel(_("Output amount") + ":"), 2, 0)
grid.addWidget(output_amount, 2, 1)
fee_e = XECAmountEdit(self.get_decimal_point())
def f(x):
a = max_fee - fee_e.get_amount()
output_amount.setText(
(self.format_amount(a) + " " + self.base_unit()) if a else ""
)
fee_e.textChanged.connect(f)
fee = self.config.fee_per_kb() * total_size / 1000
fee_e.setAmount(fee)
grid.addWidget(QtWidgets.QLabel(_("Fee" + ":")), 3, 0)
grid.addWidget(fee_e, 3, 1)
def on_rate(dyn, pos, fee_rate):
fee = fee_rate * total_size / 1000
fee = min(max_fee, fee)
fee_e.setAmount(fee)
fee_slider = FeeSlider(self, self.config, on_rate)
fee_slider.update()
grid.addWidget(fee_slider, 4, 1)
vbox.addLayout(grid)
vbox.addLayout(Buttons(CancelButton(d), OkButton(d)))
result = d.exec_()
d.setParent(None) # So Python can GC
if not result:
return
fee = fee_e.get_amount()
if fee > max_fee:
self.show_error(_("Max fee exceeded"))
return
new_tx = self.wallet.cpfp(
parent_tx, fee, self.config.is_current_block_locktime_enabled()
)
if new_tx is None:
self.show_error(_("CPFP no longer valid"))
return
self.show_transaction(new_tx)
def rebuild_history(self):
if self.gui_object.warn_if_no_network(self):
# Don't allow if offline mode.
return
msg = " ".join(
[
_(
"This feature is intended to allow you to rebuild a wallet if it"
" has become corrupted."
),
"\n\n"
+ _(
"Your entire transaction history will be downloaded again from the"
" server and verified from the blockchain."
),
_("Just to be safe, back up your wallet file first!"),
"\n\n" + _("Rebuild this wallet's history now?"),
]
)
if self.question(msg, title=_("Rebuild Wallet History")):
try:
self.wallet.rebuild_history()
except RuntimeError as e:
self.show_error(str(e))
def scan_beyond_gap(self):
if self.gui_object.warn_if_no_network(self):
return
d = ScanBeyondGap(self)
d.exec_()
d.setParent(None) # help along Python by dropping refct to 0
def copy_to_clipboard(self, text, tooltip=None, widget=None):
tooltip = tooltip or _("Text copied to clipboard")
widget = widget or self
QtWidgets.qApp.clipboard().setText(text)
QtWidgets.QToolTip.showText(QCursor.pos(), tooltip, widget)
def _pick_address(self, *, title=None, icon=None) -> Address:
"""Returns None on user cancel, or a valid is_mine Address object
from the Address list."""
# Show user address picker
d = WindowModalDialog(self.top_level_window(), title or _("Choose an address"))
d.setObjectName("Window Modal Dialog - " + d.windowTitle())
destroyed_print_error(d) # track object lifecycle
d.setMinimumWidth(self.width() - 150)
vbox = QtWidgets.QVBoxLayout(d)
if icon:
hbox = QtWidgets.QHBoxLayout()
hbox.setContentsMargins(0, 0, 0, 0)
ic_lbl = QtWidgets.QLabel()
ic_lbl.setPixmap(icon.pixmap(50))
hbox.addWidget(ic_lbl)
hbox.addItem(QtWidgets.QSpacerItem(10, 1))
t_lbl = QtWidgets.QLabel(
"<font size=+1><b>" + (title or "") + "</b></font>"
)
hbox.addWidget(t_lbl, 0, Qt.AlignLeft)
hbox.addStretch(1)
vbox.addLayout(hbox)
vbox.addWidget(QtWidgets.QLabel(_("Choose an address") + ":"))
addrlist = AddressList(self, picker=True)
try:
addrlist.setObjectName("AddressList - " + d.windowTitle())
destroyed_print_error(addrlist) # track object lifecycle
addrlist.update()
vbox.addWidget(addrlist)
ok = OkButton(d)
ok.setDisabled(True)
addr = None
def on_item_changed(current, previous):
nonlocal addr
addr = current and current.data(0, addrlist.DataRoles.address)
ok.setEnabled(addr is not None)
def on_selection_changed():
items = addrlist.selectedItems()
if items:
on_item_changed(items[0], None)
else:
on_item_changed(None, None)
addrlist.currentItemChanged.connect(on_item_changed)
cancel = CancelButton(d)
vbox.addLayout(Buttons(cancel, ok))
res = d.exec_()
if res == QtWidgets.QDialog.Accepted:
return addr
return None
finally:
addrlist.clean_up() # required to unregister network callback
class TxUpdateMgr(QObject, PrintError):
"""Manages new transaction notifications and transaction verified
notifications from the network thread. It collates them and sends them to
the appropriate GUI controls in the main_window in an efficient manner."""
def __init__(self, main_window_parent):
assert isinstance(
main_window_parent, ElectrumWindow
), "TxUpdateMgr must be constructed with an ElectrumWindow as its parent"
super().__init__(main_window_parent)
self.cleaned_up = False
self.lock = threading.Lock() # used to lock thread-shared attrs below
# begin thread-shared attributes
self.notif_q = []
self.verif_q = []
self.need_process_v, self.need_process_n = False, False
# /end thread-shared attributes
self.weakParent = Weak.ref(main_window_parent)
# immediately clear verif_q on history update because it would be redundant
# to keep the verify queue around after a history list update
main_window_parent.history_updated_signal.connect(
self.verifs_get_and_clear, Qt.DirectConnection
)
# hook into main_window's timer_actions function
main_window_parent.on_timer_signal.connect(self.do_check, Qt.DirectConnection)
self.full_hist_refresh_timer = QTimer(self)
self.full_hist_refresh_timer.setInterval(1000)
self.full_hist_refresh_timer.setSingleShot(False)
self.full_hist_refresh_timer.timeout.connect(
self.schedule_full_hist_refresh_maybe
)
def diagnostic_name(self):
return (
((self.weakParent() and self.weakParent().diagnostic_name()) or "???")
+ "."
+ __class__.__name__
)
def clean_up(self):
self.cleaned_up = True
main_window_parent = self.weakParent() # weak -> strong ref
if main_window_parent:
try:
main_window_parent.history_updated_signal.disconnect(
self.verifs_get_and_clear
)
except TypeError:
pass
try:
main_window_parent.on_timer_signal.disconnect(self.do_check)
except TypeError:
pass
def do_check(self):
"""Called from timer_actions in main_window to check if notifs or
verifs need to update the GUI.
- Checks the need_process_[v|n] flags
- If either flag is set, call the @rate_limited process_verifs
and/or process_notifs functions which update GUI parent in a
rate-limited (collated) fashion (for decent GUI responsiveness)."""
with self.lock:
bV, bN = self.need_process_v, self.need_process_n
self.need_process_v, self.need_process_n = False, False
if bV:
self.process_verifs() # rate_limited call (1 per second)
if bN:
self.process_notifs() # rate_limited call (1 per 15 seconds)
def verifs_get_and_clear(self):
"""Clears the verif_q. This is called from the network
thread for the 'verified2' event as well as from the below
update_verifs (GUI thread), hence the lock."""
with self.lock:
ret = self.verif_q
self.verif_q = []
self.need_process_v = False
return ret
def notifs_get_and_clear(self):
with self.lock:
ret = self.notif_q
self.notif_q = []
self.need_process_n = False
return ret
def verif_add(self, args):
# args: [wallet, tx_hash, height, conf, timestamp]
# filter out tx's not for this wallet
parent = self.weakParent()
if not parent or parent.cleaned_up:
return
if args[0] is parent.wallet:
with self.lock:
self.verif_q.append(args[1:])
self.need_process_v = True
def notif_add(self, args):
parent = self.weakParent()
if not parent or parent.cleaned_up:
return
tx, wallet = args
# filter out tx's not for this wallet
if wallet is parent.wallet:
with self.lock:
self.notif_q.append(tx)
self.need_process_n = True
@rate_limited(1.0, ts_after=True)
def process_verifs(self):
"""Update history list with tx's from verifs_q, but limit the
GUI update rate to once per second."""
parent = self.weakParent()
if not parent or parent.cleaned_up:
return
items = self.verifs_get_and_clear()
if items:
t0 = time.time()
parent.history_list.setUpdatesEnabled(False)
had_sorting = parent.history_list.isSortingEnabled()
if had_sorting:
parent.history_list.setSortingEnabled(False)
n_updates = 0
for item in items:
did_update = parent.history_list.update_item(*item)
n_updates += 1 if did_update else 0
self.print_error(
"Updated {}/{} verified txs in GUI in {:0.2f} ms".format(
n_updates, len(items), (time.time() - t0) * 1e3
)
)
if had_sorting:
parent.history_list.setSortingEnabled(True)
parent.history_list.setUpdatesEnabled(True)
parent.update_status()
if parent.history_list.has_unknown_balances:
self.print_error(
"History tab: 'Unknown' balances detected, will schedule a GUI"
" refresh after wallet settles"
)
self._full_refresh_ctr = 0
self.full_hist_refresh_timer.start()
_full_refresh_ctr = 0
def schedule_full_hist_refresh_maybe(self):
"""self.full_hist_refresh_timer timeout slot. May schedule a full
history refresh after wallet settles if we have "Unknown" balances."""
parent = self.weakParent()
if self._full_refresh_ctr > 60:
# Too many retries. Give up.
self.print_error(
"History tab: Full refresh scheduler timed out.. wallet hasn't settled"
" in 1 minute. Giving up."
)
self.full_hist_refresh_timer.stop()
elif parent and parent.history_list.has_unknown_balances:
# Still have 'Unknown' balance. Check if wallet is settled.
if self.need_process_v or not parent.wallet.is_fully_settled_down():
# Wallet not fully settled down yet... schedule this function to run later
self.print_error(
"History tab: Wallet not yet settled.. will try again in 1"
" second..."
)
else:
# Wallet has settled. Schedule an update. Note this function may be called again
# in 1 second to check if the 'Unknown' situation has corrected itself.
self.print_error(
"History tab: Wallet has settled down, latching need_update to true"
)
parent.need_update.set()
self._full_refresh_ctr += 1
else:
# No more polling is required. 'Unknown' balance disappeared from
# GUI (or parent window was just closed).
self.full_hist_refresh_timer.stop()
self._full_refresh_ctr = 0
@rate_limited(5.0, classlevel=True)
def process_notifs(self):
parent = self.weakParent()
if not parent or parent.cleaned_up or not parent.network:
return
txns = self.notifs_get_and_clear()
if not txns:
return
# Combine the transactions
n_ok, total_amount = 0, 0
for tx in txns:
if tx:
delta = parent.wallet.get_wallet_delta(tx)
if not delta.is_relevant:
continue
total_amount += delta.v
n_ok += 1
if not parent.wallet.storage.get("gui_notify_tx", True) or total_amount <= 0:
return
self.print_error(f"Notifying GUI {n_ok} tx")
parent.notify(
_("New transaction: {}").format(
parent.format_amount_and_units(total_amount, is_diff=True)
)
)
diff --git a/electrum/electrumabc_plugins/trezor/clientbase.py b/electrum/electrumabc_plugins/trezor/clientbase.py
index fcbd563fb..eeae7df92 100644
--- a/electrum/electrumabc_plugins/trezor/clientbase.py
+++ b/electrum/electrumabc_plugins/trezor/clientbase.py
@@ -1,400 +1,434 @@
import time
from struct import pack
import trezorlib.btc
import trezorlib.device
from trezorlib.client import PASSPHRASE_ON_DEVICE, TrezorClient
from trezorlib.exceptions import (
Cancelled,
OutdatedFirmwareError,
TrezorException,
TrezorFailure,
)
from trezorlib.messages import ButtonRequestType, WordRequestType
+from electrumabc.avalanche.primitives import PublicKey
+from electrumabc.avalanche.proof import Stake
from electrumabc.bip32 import serialize_xpub
from electrumabc.i18n import _
from electrumabc.keystore import bip39_normalize_passphrase
from electrumabc.printerror import PrintError
from electrumabc.util import UserCancelled
from ..hw_wallet.plugin import HardwareClientBase
from .compat import RECOVERY_TYPE_MATRIX
MESSAGES = {
ButtonRequestType.ConfirmOutput: _(
"Confirm the transaction output on your {} device"
),
ButtonRequestType.ResetDevice: _(
"Complete the initialization process on your {} device"
),
ButtonRequestType.ConfirmWord: _("Write down the seed word shown on your {}"),
ButtonRequestType.WipeDevice: _(
"Confirm on your {} that you want to wipe it clean"
),
ButtonRequestType.ProtectCall: _("Confirm on your {} device the message to sign"),
ButtonRequestType.SignTx: _(
"Confirm the total amount spent and the transaction fee on your {} device"
),
ButtonRequestType.Address: _("Confirm wallet address on your {} device"),
ButtonRequestType._Deprecated_ButtonRequest_PassphraseType: _(
"Choose on your {} device where to enter your passphrase"
),
ButtonRequestType.PassphraseEntry: _(
"Please enter your passphrase on the {} device"
),
"default": _("Check your {} device to continue"),
}
def parse_path(n):
"""Convert bip32 path to list of uint32 integers with prime flags
m/0/-1/1' -> [0, 0x80000001, 0x80000001]
based on code in trezorlib
"""
path = []
BIP32_PRIME = 0x80000000
for x in n.split("/")[1:]:
if x == "":
continue
prime = 0
if x.endswith("'"):
x = x.replace("'", "")
prime = BIP32_PRIME
if x.startswith("-"):
prime = BIP32_PRIME
path.append(abs(int(x)) | prime)
return path
class TrezorClientBase(HardwareClientBase, PrintError):
def __init__(self, transport, handler, plugin):
HardwareClientBase.__init__(self, plugin=plugin)
self.client = TrezorClient(transport, ui=self)
self.device = plugin.device
self.handler = handler
self.msg = None
self.creating_wallet = False
self.in_flow = False
self.used()
def run_flow(self, message=None, creating_wallet=False):
if self.in_flow:
raise RuntimeError("Overlapping call to run_flow")
self.in_flow = True
self.msg = message
self.creating_wallet = creating_wallet
self.prevent_timeouts()
return self
def end_flow(self):
self.in_flow = False
self.msg = None
self.creating_wallet = False
self.handler.finished()
self.used()
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
self.end_flow()
if exc_value is not None:
if issubclass(exc_type, Cancelled):
raise UserCancelled from exc_value
elif issubclass(exc_type, TrezorFailure):
raise RuntimeError(str(exc_value)) from exc_value
elif issubclass(exc_type, OutdatedFirmwareError):
raise OutdatedFirmwareError(exc_value) from exc_value
else:
return False
return True
@property
def features(self):
return self.client.features
def __str__(self):
return "%s/%s" % (self.label(), self.features.device_id)
def label(self):
return (
"An unnamed trezor" if self.features.label is None else self.features.label
)
def is_initialized(self):
return self.features.initialized
def is_pairable(self):
return not self.features.bootloader_mode
def has_usable_connection_with_device(self):
if self.in_flow:
return True
try:
self.client.init_device()
except Exception:
return False
return True
def used(self):
self.last_operation = time.time()
def prevent_timeouts(self):
self.last_operation = float("inf")
def timeout(self, cutoff):
"""Time out the client if the last operation was before cutoff."""
if self.last_operation < cutoff:
self.print_error("timed out")
self.clear_session()
def i4b(self, x):
return pack(">I", x)
def get_xpub(self, bip32_path, xtype, creating=False):
address_n = parse_path(bip32_path)
with self.run_flow(creating_wallet=creating):
node = trezorlib.btc.get_public_node(self.client, address_n).node
return serialize_xpub(
xtype,
node.chain_code,
node.public_key,
node.depth,
self.i4b(node.fingerprint),
self.i4b(node.child_num),
)
def toggle_passphrase(self):
if self.features.passphrase_protection:
msg = _("Confirm on your {} device to disable passphrases")
else:
msg = _("Confirm on your {} device to enable passphrases")
enabled = not self.features.passphrase_protection
with self.run_flow(msg):
trezorlib.device.apply_settings(self.client, use_passphrase=enabled)
def change_label(self, label):
with self.run_flow(_("Confirm the new label on your {} device")):
trezorlib.device.apply_settings(self.client, label=label)
def change_homescreen(self, homescreen):
with self.run_flow(_("Confirm on your {} device to change your home screen")):
trezorlib.device.apply_settings(self.client, homescreen=homescreen)
def set_pin(self, remove):
if remove:
msg = _("Confirm on your {} device to disable PIN protection")
elif self.features.pin_protection:
msg = _("Confirm on your {} device to change your PIN")
else:
msg = _("Confirm on your {} device to set a PIN")
with self.run_flow(msg):
trezorlib.device.change_pin(self.client, remove)
def clear_session(self):
"""Clear the session to force pin (and passphrase if enabled)
re-entry. Does not leak exceptions."""
self.print_error("clear session:", self)
self.prevent_timeouts()
try:
self.client.clear_session()
except Exception as e:
# If the device was removed it has the same effect...
self.print_error("clear_session: ignoring error", str(e))
def close(self):
"""Called when Our wallet was closed or the device removed."""
self.print_error("closing client")
self.clear_session()
def atleast_version(self, major, minor=0, patch=0):
return self.client.version >= (major, minor, patch)
def is_uptodate(self):
if self.client.is_outdated():
return False
return self.client.version >= self.plugin.minimum_firmware
def get_trezor_model(self):
"""Returns '1' for Trezor One, 'T' for Trezor T, etc."""
return self.features.model
def device_model_name(self):
model = self.get_trezor_model()
if model == "1":
return "Trezor One"
elif model == "T":
return "Trezor T"
elif model == "Safe 3":
return "Safe 3"
elif model == "Safe 5":
return "Safe 5"
return None
def show_address(self, address_str, script_type, multisig=None):
coin_name = self.plugin.get_coin_name()
address_n = parse_path(address_str)
with self.run_flow():
return trezorlib.btc.get_address(
self.client,
coin_name,
address_n,
show_display=True,
script_type=script_type,
multisig=multisig,
)
def sign_message(self, address_str, message):
coin_name = self.plugin.get_coin_name()
address_n = parse_path(address_str)
with self.run_flow():
return trezorlib.btc.sign_message(
self.client, coin_name, address_n, message
)
+ def sign_stake(
+ self,
+ address_str: str,
+ stake: Stake,
+ expiration_time: int,
+ master_pubkey: PublicKey,
+ ):
+ try:
+ import trezorlib.ecash
+ except ImportError:
+ raise NotImplementedError(
+ _(
+ "Signing stakes with a Trezor device requires a compatible "
+ "version of trezorlib. Please install the correct version "
+ "and restart ElectrumABC."
+ ).format(self.device)
+ )
+
+ address_n = parse_path(address_str)
+ with self.run_flow():
+ return trezorlib.ecash.sign_stake(
+ self.client,
+ address_n,
+ bytes.fromhex(stake.utxo.txid.get_hex()),
+ stake.utxo.n,
+ stake.amount,
+ stake.height,
+ stake.is_coinbase,
+ expiration_time,
+ master_pubkey.keydata,
+ )
+
def recover_device(self, recovery_type, *args, **kwargs):
input_callback = self.mnemonic_callback(recovery_type)
with self.run_flow():
return trezorlib.device.recover(
self.client,
*args,
input_callback=input_callback,
type=recovery_type,
**kwargs
)
# ========= Unmodified trezorlib methods =========
def sign_tx(self, *args, **kwargs):
with self.run_flow():
return trezorlib.btc.sign_tx(self.client, *args, **kwargs)
def reset_device(self, *args, **kwargs):
with self.run_flow():
return trezorlib.device.reset(self.client, *args, **kwargs)
def wipe_device(self, *args, **kwargs):
with self.run_flow():
return trezorlib.device.wipe(self.client, *args, **kwargs)
# ========= UI methods ==========
def button_request(self, br):
message = self.msg or MESSAGES.get(br.code) or MESSAGES["default"]
def on_cancel():
try:
self.client.cancel()
except TrezorException as e:
self.print_error("Exception during cancel call:", repr(e))
self.handler.show_error(
_(
"The {} device is now in an inconsistent state.\n\nYou may have"
" to unplug the device and plug it back in and restart what you"
" were doing."
).format(self.device)
)
finally:
# HACK. This is to get out of the situation with a stuck install wizard
# when there is a client error after user hits "cancel" in the GUI.
# Unfortunately the libusb transport is buggy as hell... and there is
# no way to cancel an in-process command that I can tell.
#
# See trezor.py initialize_device() function for the caller that
# expects this code to be here and exit its event loop.
loops = getattr(self.handler, "_loops", None)
if loops and loops[0].isRunning():
loops[0].exit(3)
self.handler.show_message(message.format(self.device), on_cancel)
def get_pin(self, code=None):
if code == 2:
msg = _("Enter a new PIN for your {}:")
elif code == 3:
msg = _(
"Re-enter the new PIN for your {}.\n\n"
"NOTE: the positions of the numbers have changed!"
)
else:
msg = _("Enter your current {} PIN:")
pin = self.handler.get_pin(msg.format(self.device))
if not pin:
raise Cancelled
# check PIN length. Depends on model and firmware version
# https://github.com/trezor/trezor-firmware/issues/1167
limit = 9
if (
self.features.model == "1"
and (1, 10, 0) <= self.client.version
or (2, 4, 0) <= self.client.version
):
limit = 50
if len(pin) > limit:
self.handler.show_error(
_("The PIN cannot be longer than {} characters.").format(limit)
)
raise Cancelled
return pin
def get_passphrase(self, available_on_device):
if self.creating_wallet:
msg = _(
"Enter a passphrase to generate this wallet. Each time "
"you use this wallet your {} will prompt you for the "
"passphrase. If you forget the passphrase you cannot "
"access the eCash in the wallet."
).format(self.device)
else:
msg = _("Enter the passphrase to unlock this wallet:")
self.handler.passphrase_on_device = available_on_device
passphrase = self.handler.get_passphrase(msg, self.creating_wallet)
if passphrase is PASSPHRASE_ON_DEVICE:
return passphrase
if passphrase is None:
raise Cancelled
passphrase = bip39_normalize_passphrase(passphrase)
length = len(passphrase)
if length > 50:
self.handler.show_error(
_("Too long passphrase ({} > 50 chars).").format(length)
)
raise Cancelled
return passphrase
def _matrix_char(self, matrix_type):
num = 9 if matrix_type == WordRequestType.Matrix9 else 6
char = self.handler.get_matrix(num)
if char == "x":
raise Cancelled
return char
def mnemonic_callback(self, recovery_type):
if recovery_type is None:
return None
if recovery_type == RECOVERY_TYPE_MATRIX:
return self._matrix_char
step = 0
def word_callback(_ignored):
nonlocal step
step += 1
msg = _("Step {}/24. Enter seed word as explained on your {}:").format(
step, self.device
)
word = self.handler.get_word(msg)
if not word:
raise Cancelled
return word
return word_callback
diff --git a/electrum/electrumabc_plugins/trezor/trezor.py b/electrum/electrumabc_plugins/trezor/trezor.py
index 407cb8022..88039c31e 100644
--- a/electrum/electrumabc_plugins/trezor/trezor.py
+++ b/electrum/electrumabc_plugins/trezor/trezor.py
@@ -1,662 +1,695 @@
from __future__ import annotations
import sys
import traceback
from binascii import unhexlify
-from typing import TYPE_CHECKING, Any, NamedTuple
+from typing import TYPE_CHECKING, Any, NamedTuple, Optional, Tuple
+from electrumabc.avalanche.primitives import PublicKey
+from electrumabc.avalanche.proof import Stake
from electrumabc.base_wizard import HWD_SETUP_NEW_WALLET
from electrumabc.bip32 import deserialize_xpub
from electrumabc.bitcoin import TYPE_ADDRESS, TYPE_SCRIPT
from electrumabc.constants import DEFAULT_TXIN_SEQUENCE
from electrumabc.ecc import SignatureType
from electrumabc.i18n import _
from electrumabc.keystore import HardwareKeyStore, is_xpubkey, parse_xpubkey
from electrumabc.networks import NetworkConstants
from electrumabc.plugins import Device
from electrumabc.transaction import deserialize
from electrumabc.util import UserCancelled, bfh, versiontuple
from ..hw_wallet import HWPluginBase
if TYPE_CHECKING:
from electrumabc.transaction import Transaction
try:
import trezorlib
import trezorlib.transport
from trezorlib.client import PASSPHRASE_ON_DEVICE
from trezorlib.messages import (
BackupType,
Capability,
HDNodePathType,
HDNodeType,
InputScriptType,
MultisigRedeemScriptType,
OutputScriptType,
TransactionType,
TxInputType,
TxOutputBinType,
TxOutputType,
)
from .clientbase import TrezorClientBase, parse_path
from .compat import RECOVERY_TYPE_MATRIX, RECOVERY_TYPE_SCRAMBLED_WORDS
TREZORLIB = True
except Exception:
traceback.print_exc()
TREZORLIB = False
RECOVERY_TYPE_SCRAMBLED_WORDS, RECOVERY_TYPE_MATRIX = range(2)
PASSPHRASE_ON_DEVICE = object()
class _EnumMissing:
def __init__(self):
self.counter = 0
self.values = {}
def __getattr__(self, key):
if key not in self.values:
self.values[key] = self.counter
self.counter += 1
return self.values[key]
Capability = _EnumMissing()
BackupType = _EnumMissing()
# TREZOR initialization methods
TIM_NEW, TIM_RECOVER = range(2)
TREZOR_PRODUCT_KEY = "Trezor"
class TrezorKeyStore(HardwareKeyStore):
hw_type = "trezor"
device = TREZOR_PRODUCT_KEY
def get_derivation(self):
return self.derivation
def get_client(self, force_pair=True):
return self.plugin.get_client(self, force_pair)
def decrypt_message(self, sequence, message, password):
raise RuntimeError(
_("Encryption and decryption are not implemented by {}").format(self.device)
)
def sign_message(self, sequence, message, password, sigtype=SignatureType.BITCOIN):
client = self.get_client()
if self.plugin.has_native_ecash_support and sigtype == SignatureType.BITCOIN:
raise RuntimeError(
_("Bitcoin message signing is not available for {}").format(self.device)
)
if not self.plugin.has_native_ecash_support and sigtype == SignatureType.ECASH:
raise RuntimeError(
_("eCash message signing is not available for {}").format(self.device)
)
address_path = self.get_derivation() + "/%d/%d" % sequence
msg_sig = client.sign_message(address_path, message)
return msg_sig.signature
def sign_transaction(self, tx, password, *, use_cache=False):
if tx.is_complete():
return
# previous transactions used as inputs
prev_tx = {}
# path of the xpubs that are involved
xpub_path = {}
for txin in tx.inputs():
pubkeys, x_pubkeys = tx.get_sorted_pubkeys(txin)
x_pubkeys = [bytes.fromhex(xpub) for xpub in x_pubkeys]
tx_hash = txin["prevout_hash"]
if txin.get("prev_tx") is None:
raise RuntimeError(
_("Offline signing with {} is not supported.").format(self.device)
)
prev_tx[tx_hash] = txin["prev_tx"]
for x_pubkey in x_pubkeys:
if not is_xpubkey(x_pubkey):
continue
xpub, s = parse_xpubkey(x_pubkey)
if xpub == self.get_master_public_key():
xpub_path[xpub] = self.get_derivation()
self.plugin.sign_transaction(self, tx, prev_tx, xpub_path)
+ def sign_stake(
+ self,
+ stake: Stake,
+ index: Tuple[int],
+ expiration_time: int,
+ master_pubkey: PublicKey,
+ password: Optional[str],
+ ):
+ client = self.get_client()
+ if not self.supports_stake_signature():
+ raise NotImplementedError(
+ f"Stake signing is not available for {self.device}. Please make sure your firmware is up-to-date"
+ )
+
+ address_path = self.get_derivation() + "/%d/%d" % index
+ stake_sig = client.sign_stake(
+ address_path, stake, expiration_time, master_pubkey
+ )
+ stake.pubkey = PublicKey.from_hex(stake_sig.pubkey.hex())
+ return stake_sig.signature
+
def needs_prevtx(self):
# Trezor does need previous transactions for eCash
return True
+ def supports_stake_signature(self):
+ return self.plugin.has_stake_signature_support
+
class LibraryFoundButUnusable(Exception):
def __init__(self, library_version="unknown"):
self.library_version = library_version
class TrezorInitSettings(NamedTuple):
word_count: int
label: str
pin_enabled: bool
passphrase_enabled: bool
recovery_type: Any = None
backup_type: int = BackupType.Bip39
no_backup: bool = False
class TrezorPlugin(HWPluginBase):
# Derived classes provide:
#
# class-static variables: client_class, firmware_URL, handler_class,
# libraries_available, libraries_URL, minimum_firmware,
# wallet_class, types
firmware_URL = "https://wallet.trezor.io"
libraries_URL = "https://pypi.org/project/trezor/"
minimum_firmware = (1, 5, 2)
keystore_class = TrezorKeyStore
minimum_library = (0, 13, 8)
maximum_library = (0, 14)
DEVICE_IDS = (TREZOR_PRODUCT_KEY,)
MAX_LABEL_LEN = 32
def __init__(self, parent, config, name):
super().__init__(parent, config, name)
self.libraries_available = self.check_libraries_available()
if not self.libraries_available:
return
self.has_native_ecash_support = False
+ self.has_stake_signature_support = False
self.device_manager().register_enumerate_func(self.enumerate)
def check_libraries_available(self) -> bool:
def version_str(t):
return ".".join(str(i) for i in t)
try:
# this might raise ImportError or LibraryFoundButUnusable
library_version = self.get_library_version()
# if no exception so far, we might still raise LibraryFoundButUnusable
if (
library_version == "unknown"
or versiontuple(library_version) < self.minimum_library
or hasattr(self, "maximum_library")
and versiontuple(library_version) >= self.maximum_library
):
raise LibraryFoundButUnusable(library_version=library_version)
except ImportError:
return False
except LibraryFoundButUnusable as e:
library_version = e.library_version
max_version_str = (
version_str(self.maximum_library)
if hasattr(self, "maximum_library")
else "inf"
)
self.libraries_available_message = _(
"Library version for '{}' is incompatible."
).format(self.name) + "\nInstalled: {}, Needed: {} <= x < {}".format(
library_version, version_str(self.minimum_library), max_version_str
)
self.print_stderr(self.libraries_available_message)
return False
return True
def get_library_version(self):
import trezorlib
try:
version = trezorlib.__version__
except Exception:
version = "unknown"
if TREZORLIB:
return version
else:
raise LibraryFoundButUnusable(library_version=version)
def enumerate(self):
devices = trezorlib.transport.enumerate_devices()
return [
Device(
path=d.get_path(),
interface_number=-1,
id_=d.get_path(),
product_key=TREZOR_PRODUCT_KEY,
usage_page=0,
)
for d in devices
]
def create_client(self, device, handler):
try:
self.print_error("connecting to device at", device.path)
transport = trezorlib.transport.get_transport(device.path)
except Exception as e:
self.print_error("cannot connect at", device.path, str(e))
return None
if not transport:
self.print_error("cannot connect at", device.path)
return
self.print_error("connected to device at", device.path)
client = TrezorClientBase(transport, handler, self)
# Note that this can be toggled from True to False if the wallet doesn't
# use the eCash derivation path.
self.has_native_ecash_support = client.atleast_version(2, 8, 6)
# Override the class attribute if this trezor supports the 899'
# derivation path
TrezorPlugin.SUPPORTS_XEC_BIP44_DERIVATION = self.has_native_ecash_support
+ # Stake signature support is set once for all
+ self.has_stake_signature_support = (
+ "Ecash" in Capability.__members__
+ and Capability.Ecash in client.features.capabilities
+ )
+
return client
def get_client(self, keystore, force_pair=True):
# We are going to interact with the device. At this stage we need to
# determine whether we should use the native eCash mode or the "Bitcoin
# Cash compatibility" mode.
# It is possible that the device is an up-to-date Trezor that supports
# eCash, but the wallet has been created from a previous version and
# therefore should not use the eCash derivation path. In this case we
# should reset the has_native_ecash_support flag to avoid making the
# wallet unusable.
self.has_native_ecash_support &= keystore.get_derivation() == "m/44'/899'/0'"
devmgr = self.device_manager()
handler = keystore.handler
client = devmgr.client_for_keystore(self, handler, keystore, force_pair)
# returns the client for a given keystore. can use xpub
if client:
client.used()
return client
def get_coin_name(self):
# Note: testnet supported only by unofficial firmware
if self.has_native_ecash_support:
return "Ecash Testnet" if NetworkConstants.TESTNET else "Ecash"
return "Bcash Testnet" if NetworkConstants.TESTNET else "Bcash"
def _chk_settings_do_popup_maybe(
self, handler, method, model, settings: TrezorInitSettings
):
if (
method == TIM_RECOVER
and settings.recovery_type == RECOVERY_TYPE_SCRAMBLED_WORDS
and model == "1" # This only applies to the model '1'
):
handler.show_error(
_(
"You will be asked to enter 24 words regardless of your "
"seed's actual length. If you enter a word incorrectly or "
"misspell it, you cannot change it or go back - you will need "
"to start again from the beginning.\n\nSo please enter "
"the words carefully!"
)
)
def initialize_device(self, device_id, wizard, handler):
# Initialization method
msg = _(
"Choose how you want to initialize your {}.\n\n"
"Either method is secure since no secret information "
"will be entered into your computer."
).format(self.device)
choices = [
# Must be short as Qt doesn't word-wrap radio button text
(TIM_NEW, _("Let the device generate a completely new seed randomly")),
(TIM_RECOVER, _("Recover from a seed you have previously written down")),
]
devmgr = self.device_manager()
client = devmgr.client_by_id(device_id)
model = client.get_trezor_model()
def f(method):
loops = [
wizard.loop
] # We do it this way so as to pop the loop when it's done. This avoids possible multiple calls to loop.exit from different code paths.
handler._loops = loops # hack to prevent trezor transport errors from stalling the UI here. see clientbase.py button_request which aborts the wizard event loop on transport error
try:
import threading
settings = self.request_trezor_init_settings(wizard, method, device_id)
# We do this popup business here because doing it in the
# thread interferes with whatever other popups may happen
# from trezorlib. So we do this all-stop popup first if needed.
self._chk_settings_do_popup_maybe(handler, method, model, settings)
errors = []
t = threading.Thread(
target=self._initialize_device_safe,
args=(settings, method, device_id, loops, errors),
)
t.setDaemon(True)
t.start()
exit_code = wizard.loop.exec_()
loops.pop()
if exit_code != 0:
if errors and isinstance(errors[0], Exception):
msg = str(errors[0]).strip()
if msg:
# we do this here in the main thread so as to give
# the user the opportunity to actually *see* the error
# window before the wizard "goes back"
handler.show_error(msg)
# this method (initialize_device) was called with the expectation
# of leaving the device in an initialized state when finishing.
# signal that this is not the case:
raise UserCancelled()
finally:
delattr(handler, "_loops") # /clean up hack
wizard.choice_dialog(
title=_("Initialize Device"), message=msg, choices=choices, run_next=f
)
def _initialize_device_safe(self, settings, method, device_id, loops, errors):
exit_code = 0
try:
self._initialize_device(settings, method, device_id)
except UserCancelled:
exit_code = 2
except Exception as e:
traceback.print_exc(file=sys.stderr)
errors.append(e)
exit_code = 1
finally:
# leverage the GIL here for thread safety.
lc = loops.copy()
if lc:
lc[0].exit(exit_code)
def _initialize_device(self, settings, method, device_id):
devmgr = self.device_manager()
client = devmgr.client_by_id(device_id)
if not client:
raise Exception(_("The device was disconnected."))
if method == TIM_NEW:
strength_from_word_count = {12: 128, 18: 192, 20: 128, 24: 256, 33: 256}
client.reset_device(
strength=strength_from_word_count[settings.word_count],
passphrase_protection=settings.passphrase_enabled,
pin_protection=settings.pin_enabled,
label=settings.label,
backup_type=settings.backup_type,
no_backup=settings.no_backup,
)
elif method == TIM_RECOVER:
client.recover_device(
recovery_type=settings.recovery_type,
word_count=settings.word_count,
passphrase_protection=settings.passphrase_enabled,
pin_protection=settings.pin_enabled,
label=settings.label,
)
else:
raise RuntimeError("Unsupported recovery method")
def _make_node_path(self, xpub, address_n):
_, depth, fingerprint, child_num, chain_code, key = deserialize_xpub(xpub)
node = HDNodeType(
depth=depth,
fingerprint=int.from_bytes(fingerprint, "big"),
child_num=int.from_bytes(child_num, "big"),
chain_code=chain_code,
public_key=key,
)
return HDNodePathType(node=node, address_n=address_n)
def setup_device(self, device_info, wizard, purpose):
"""Called when creating a new wallet. Select the device to use. If
the device is uninitialized, go through the initialization
process."""
device_id = device_info.device.id_
client = self.scan_and_create_client_for_device(
device_id=device_id, wizard=wizard
)
if not client.is_uptodate():
raise Exception(
_(
"Outdated {} firmware for device labelled {}. Please "
"download the updated firmware from {}"
).format(self.device, client.label(), self.firmware_URL)
)
if not device_info.initialized:
self.initialize_device(device_id, wizard, client.handler)
is_creating_wallet = purpose == HWD_SETUP_NEW_WALLET
wizard.run_task_without_blocking_gui(
task=lambda: client.get_xpub("m", "standard", creating=is_creating_wallet)
)
client.used()
return client
def get_xpub(self, device_id, derivation, xtype, wizard):
client = self.scan_and_create_client_for_device(
device_id=device_id, wizard=wizard
)
xpub = client.get_xpub(derivation, xtype)
client.used()
return xpub
def get_trezor_input_script_type(self, is_multisig):
if is_multisig:
return InputScriptType.SPENDMULTISIG
else:
return InputScriptType.SPENDADDRESS
def sign_transaction(self, keystore, tx, prev_tx, xpub_path):
prev_tx = {
bfh(txhash): self.electrum_tx_to_txtype(tx, xpub_path)
for txhash, tx in prev_tx.items()
}
client = self.get_client(keystore)
inputs = self.tx_inputs(tx, xpub_path, True)
outputs = self.tx_outputs(keystore.get_derivation(), tx, client)
signatures, _ = client.sign_tx(
self.get_coin_name(),
inputs,
outputs,
lock_time=tx.locktime,
prev_txes=prev_tx,
version=tx.version,
)
tx.update_signatures(signatures)
def show_address(self, wallet, address, keystore=None):
if keystore is None:
keystore = wallet.get_keystore()
deriv_suffix = wallet.get_address_index(address)
derivation = keystore.derivation
address_path = "%s/%d/%d" % (derivation, *deriv_suffix)
# prepare multisig, if available
xpubs = wallet.get_master_public_keys()
if len(xpubs) > 1:
pubkeys = wallet.get_public_keys(address)
# sort xpubs using the order of pubkeys
sorted_pairs = sorted(zip(pubkeys, xpubs))
multisig = self._make_multisig(
wallet.m, [(xpub, deriv_suffix) for _, xpub in sorted_pairs]
)
else:
multisig = None
script_type = self.get_trezor_input_script_type(multisig is not None)
client = self.get_client(keystore)
client.show_address(address_path, script_type, multisig)
def tx_inputs(self, tx, xpub_path, for_sig=False):
inputs = []
for txin in tx.inputs():
if txin["type"] == "coinbase":
txinputtype = TxInputType(
prev_hash=b"\x00" * 32,
prev_index=0xFFFFFFFF, # signed int -1
)
else:
txinputtype = TxInputType(
prev_hash=unhexlify(txin["prevout_hash"]),
prev_index=txin["prevout_n"],
)
if for_sig:
x_pubkeys = txin["x_pubkeys"]
xpubs = [parse_xpubkey(bytes.fromhex(x)) for x in x_pubkeys]
txinputtype.multisig = self._make_multisig(
txin.get("num_sig"), xpubs, txin.get("signatures")
)
txinputtype.script_type = self.get_trezor_input_script_type(
txinputtype.multisig is not None
)
# find which key is mine
for xpub, deriv in xpubs:
if xpub in xpub_path:
xpub_n = parse_path(xpub_path[xpub])
txinputtype.address_n = xpub_n + deriv
break
if "value" in txin:
txinputtype.amount = txin["value"]
if "scriptSig" in txin:
script_sig = bytes.fromhex(txin["scriptSig"])
txinputtype.script_sig = script_sig
txinputtype.sequence = txin.get("sequence", DEFAULT_TXIN_SEQUENCE)
inputs.append(txinputtype)
return inputs
def _make_multisig(self, m, xpubs, signatures=None):
if len(xpubs) == 1:
return None
pubkeys = [self._make_node_path(xpub, deriv) for xpub, deriv in xpubs]
if signatures is None:
signatures = [b""] * len(pubkeys)
elif len(signatures) != len(pubkeys):
raise RuntimeError("Mismatched number of signatures")
else:
signatures = [bfh(x)[:-1] if x else b"" for x in signatures]
return MultisigRedeemScriptType(pubkeys=pubkeys, signatures=signatures, m=m)
def tx_outputs(self, derivation, tx: Transaction, client):
def create_output_by_derivation():
deriv = parse_path("/%d/%d" % index)
multisig = self._make_multisig(m, [(xpub, deriv) for xpub in xpubs])
script_type = (
OutputScriptType.PAYTOADDRESS
if multisig is None
else OutputScriptType.PAYTOMULTISIG
)
txoutputtype = TxOutputType(
multisig=multisig,
amount=amount,
address_n=parse_path(derivation + "/%d/%d" % index),
script_type=script_type,
)
return txoutputtype
def create_output_by_address():
if _type == TYPE_SCRIPT:
script = address.to_script()
# We only support OP_RETURN with one constant push
if (
script[0] == 0x6A
and amount == 0
and script[1] == len(script) - 2
and script[1] <= 75
):
return TxOutputType(
amount=amount,
script_type=OutputScriptType.PAYTOOPRETURN,
op_return_data=script[2:],
)
else:
raise Exception(_("Unsupported output script."))
elif _type == TYPE_ADDRESS:
ui_addr_fmt = address.FMT_UI
if (
not self.has_native_ecash_support
and ui_addr_fmt == address.FMT_CASHADDR
):
ui_addr_fmt = address.FMT_CASHADDR_BCH
addr_format = address.FMT_LEGACY
if client.get_trezor_model() == "T":
if client.atleast_version(2, 0, 8):
addr_format = ui_addr_fmt
elif client.atleast_version(2, 0, 7):
addr_format = address.FMT_CASHADDR_BCH
else:
if client.atleast_version(1, 6, 2):
addr_format = ui_addr_fmt
return TxOutputType(
amount=amount,
script_type=OutputScriptType.PAYTOADDRESS,
address=address.to_full_string(addr_format),
)
outputs = []
has_change = False
any_output_on_change_branch = self.is_any_tx_output_on_change_branch(tx)
for o in tx.outputs():
_type, address, amount = o.type, o.destination, o.value
use_create_by_derivation = False
info = tx.output_info.get(address)
if info is not None and not has_change:
index, xpubs, m, script_type = info
on_change_branch = index[0] == 1
# prioritise hiding outputs on the 'change' branch from user
# because no more than one change address allowed
# note: ^ restriction can be removed once we require fw
# that has https://github.com/trezor/trezor-mcu/pull/306
if on_change_branch == any_output_on_change_branch:
use_create_by_derivation = True
has_change = True
if use_create_by_derivation:
txoutputtype = create_output_by_derivation()
else:
txoutputtype = create_output_by_address()
outputs.append(txoutputtype)
return outputs
def is_any_tx_output_on_change_branch(self, tx):
if not tx.output_info:
return False
for _type, address, _amount in tx.outputs():
info = tx.output_info.get(address)
if info is not None and info[0][0] == 1:
return True
return False
# This function is called from the TREZOR libraries (via tx_api)
def get_tx(self, tx_hash):
# for electrum-abc previous tx is never needed, since it uses
# bip-143 signatures.
return None
def electrum_tx_to_txtype(self, tx, xpub_path):
t = TransactionType()
version, _, outputs, locktime = deserialize(tx.raw)
t.version = version
t.lock_time = locktime
t.inputs = self.tx_inputs(tx, xpub_path)
t.bin_outputs = [
TxOutputBinType(
amount=vout.value, script_pubkey=vout.destination.to_script()
)
for vout in outputs
]
return t

File Metadata

Mime Type
text/x-diff
Expires
Sun, Apr 27, 11:58 (1 d, 2 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
5573490
Default Alt Text
(537 KB)

Event Timeline